🪻 distributed transcription service
thistle.dunkirk.sh
1import { css, html, LitElement } from "lit";
2import { customElement, property, state } from "lit/decorators.js";
3import { hashPasswordClient } from "../lib/client-auth";
4
5@customElement("reset-password-form")
6export class ResetPasswordForm extends LitElement {
7 @property({ type: String }) token: string | null = null;
8 @state() private email: string | null = null;
9 @state() private password = "";
10 @state() private confirmPassword = "";
11 @state() private error = "";
12 @state() private isSubmitting = false;
13 @state() private isSuccess = false;
14 @state() private isLoadingEmail = false;
15
16 static override styles = css`
17 :host {
18 display: block;
19 }
20
21 .reset-card {
22 background: var(--background);
23 border: 2px solid var(--secondary);
24 border-radius: 12px;
25 padding: 2.5rem;
26 max-width: 25rem;
27 width: 100%;
28 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
29 }
30
31 .reset-title {
32 margin-top: 0;
33 margin-bottom: 2rem;
34 color: var(--text);
35 text-align: center;
36 font-size: 1.75rem;
37 }
38
39 .form-group {
40 margin-bottom: 1.5rem;
41 }
42
43 label {
44 display: block;
45 margin-bottom: 0.25rem;
46 font-weight: 500;
47 color: var(--text);
48 font-size: 0.875rem;
49 }
50
51 input {
52 width: 100%;
53 padding: 0.75rem;
54 border: 2px solid var(--secondary);
55 border-radius: 6px;
56 font-size: 1rem;
57 font-family: inherit;
58 background: var(--background);
59 color: var(--text);
60 transition: all 0.2s;
61 box-sizing: border-box;
62 }
63
64 input::placeholder {
65 color: var(--secondary);
66 opacity: 1;
67 }
68
69 input:focus {
70 outline: none;
71 border-color: var(--primary);
72 }
73
74 .error-banner {
75 background: #fecaca;
76 border: 2px solid rgba(220, 38, 38, 0.8);
77 border-radius: 6px;
78 padding: 1rem;
79 margin-bottom: 1rem;
80 color: #dc2626;
81 font-weight: 500;
82 }
83
84 .btn-primary {
85 width: 100%;
86 padding: 0.75rem 1.5rem;
87 border: 2px solid var(--primary);
88 border-radius: 6px;
89 font-size: 1rem;
90 font-weight: 500;
91 cursor: pointer;
92 transition: all 0.2s;
93 font-family: inherit;
94 background: var(--primary);
95 color: white;
96 margin-top: 0.5rem;
97 }
98
99 .btn-primary:hover:not(:disabled) {
100 background: transparent;
101 color: var(--primary);
102 }
103
104 .btn-primary:disabled {
105 opacity: 0.6;
106 cursor: not-allowed;
107 }
108
109 .back-link {
110 display: block;
111 text-align: center;
112 margin-top: 1.5rem;
113 color: var(--primary);
114 text-decoration: none;
115 font-weight: 500;
116 font-size: 0.875rem;
117 transition: all 0.2s;
118 }
119
120 .back-link:hover {
121 color: var(--accent);
122 }
123
124 .success-message {
125 text-align: center;
126 }
127
128 .success-icon {
129 font-size: 3rem;
130 margin-bottom: 1rem;
131 }
132
133 .success-text {
134 color: var(--primary);
135 font-size: 1.25rem;
136 font-weight: 500;
137 margin-bottom: 1.5rem;
138 }
139
140 .success-link {
141 display: inline-block;
142 padding: 0.75rem 1.5rem;
143 background: var(--accent);
144 color: white;
145 text-decoration: none;
146 border-radius: 6px;
147 font-weight: 500;
148 transition: all 0.2s;
149 }
150
151 .success-link:hover {
152 background: var(--primary);
153 }
154 `;
155
156 override async updated(changedProperties: Map<string, unknown>) {
157 super.updated(changedProperties);
158
159 // When token property changes and we don't have email yet, load it
160 if (
161 changedProperties.has("token") &&
162 this.token &&
163 !this.email &&
164 !this.isLoadingEmail
165 ) {
166 await this.loadEmail();
167 }
168 }
169
170 private async loadEmail() {
171 this.isLoadingEmail = true;
172 this.error = "";
173
174 try {
175 const url = `/api/auth/reset-password?token=${encodeURIComponent(this.token || "")}`;
176 const response = await fetch(url);
177 const data = await response.json();
178
179 if (!response.ok) {
180 throw new Error(data.error || "Invalid or expired reset token");
181 }
182
183 this.email = data.email;
184 } catch (err) {
185 this.error =
186 err instanceof Error ? err.message : "Failed to verify reset token";
187 } finally {
188 this.isLoadingEmail = false;
189 }
190 }
191
192 override render() {
193 if (!this.token) {
194 return html`
195 <div class="reset-card">
196 <h1 class="reset-title">Reset Password</h1>
197 <div class="error-banner">Invalid or missing reset token</div>
198 <a href="/" class="back-link">Back to home</a>
199 </div>
200 `;
201 }
202
203 if (this.isLoadingEmail) {
204 return html`
205 <div class="reset-card">
206 <h1 class="reset-title">Reset Password</h1>
207 <p style="text-align: center; color: var(--text);">Verifying reset token...</p>
208 </div>
209 `;
210 }
211
212 if (this.error && !this.email) {
213 return html`
214 <div class="reset-card">
215 <h1 class="reset-title">Reset Password</h1>
216 <div class="error-banner">${this.error}</div>
217 <a href="/" class="back-link">Back to home</a>
218 </div>
219 `;
220 }
221
222 if (this.isSuccess) {
223 return html`
224 <div class="reset-card">
225 <div class="success-message">
226 <div class="success-icon">✓</div>
227 <div class="success-text">Password reset successfully!</div>
228 <a href="/" class="success-link">Go to home</a>
229 </div>
230 </div>
231 `;
232 }
233
234 return html`
235 <div class="reset-card">
236 <h1 class="reset-title">Reset Password</h1>
237
238 <form @submit=${this.handleSubmit}>
239 ${
240 this.error
241 ? html`<div class="error-banner">${this.error}</div>`
242 : ""
243 }
244
245 <div class="form-group">
246 <label for="password">New Password</label>
247 <input
248 type="password"
249 id="password"
250 .value=${this.password}
251 @input=${(e: Event) => {
252 this.password = (e.target as HTMLInputElement).value;
253 }}
254 required
255 minlength="8"
256 placeholder="Enter new password (min 8 characters)"
257 >
258 </div>
259
260 <div class="form-group">
261 <label for="confirm-password">Confirm Password</label>
262 <input
263 type="password"
264 id="confirm-password"
265 .value=${this.confirmPassword}
266 @input=${(e: Event) => {
267 this.confirmPassword = (e.target as HTMLInputElement).value;
268 }}
269 required
270 minlength="8"
271 placeholder="Confirm new password"
272 >
273 </div>
274
275 <button type="submit" class="btn-primary" ?disabled=${this.isSubmitting}>
276 ${this.isSubmitting ? "Resetting..." : "Reset Password"}
277 </button>
278 </form>
279
280 <a href="/" class="back-link">Back to home</a>
281 </div>
282 `;
283 }
284
285 private async handleSubmit(e: Event) {
286 e.preventDefault();
287 this.error = "";
288
289 // Validate passwords match
290 if (this.password !== this.confirmPassword) {
291 this.error = "Passwords do not match";
292 return;
293 }
294
295 // Validate password length
296 if (this.password.length < 8) {
297 this.error = "Password must be at least 8 characters";
298 return;
299 }
300
301 this.isSubmitting = true;
302
303 try {
304 if (!this.email) {
305 throw new Error("Email not loaded");
306 }
307
308 // Hash password client-side with user's email
309 const hashedPassword = await hashPasswordClient(
310 this.password,
311 this.email,
312 );
313
314 const response = await fetch("/api/auth/reset-password", {
315 method: "POST",
316 headers: { "Content-Type": "application/json" },
317 body: JSON.stringify({ token: this.token, password: hashedPassword }),
318 });
319
320 const data = await response.json();
321
322 if (!response.ok) {
323 throw new Error(data.error || "Failed to reset password");
324 }
325
326 // Show success message
327 this.isSuccess = true;
328 } catch (err) {
329 this.error =
330 err instanceof Error ? err.message : "Failed to reset password";
331 } finally {
332 this.isSubmitting = false;
333 }
334 }
335}