🪻 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 (changedProperties.has('token') && this.token && !this.email && !this.isLoadingEmail) {
161 await this.loadEmail();
162 }
163 }
164
165 private async loadEmail() {
166 this.isLoadingEmail = true;
167 this.error = "";
168
169 try {
170 const url = `/api/auth/reset-password?token=${encodeURIComponent(this.token || "")}`;
171 const response = await fetch(url);
172 const data = await response.json();
173
174 if (!response.ok) {
175 throw new Error(data.error || "Invalid or expired reset token");
176 }
177
178 this.email = data.email;
179 } catch (err) {
180 this.error = err instanceof Error ? err.message : "Failed to verify reset token";
181 } finally {
182 this.isLoadingEmail = false;
183 }
184 }
185
186 override render() {
187 if (!this.token) {
188 return html`
189 <div class="reset-card">
190 <h1 class="reset-title">Reset Password</h1>
191 <div class="error-banner">Invalid or missing reset token</div>
192 <a href="/" class="back-link">Back to home</a>
193 </div>
194 `;
195 }
196
197 if (this.isLoadingEmail) {
198 return html`
199 <div class="reset-card">
200 <h1 class="reset-title">Reset Password</h1>
201 <p style="text-align: center; color: var(--text);">Verifying reset token...</p>
202 </div>
203 `;
204 }
205
206 if (this.error && !this.email) {
207 return html`
208 <div class="reset-card">
209 <h1 class="reset-title">Reset Password</h1>
210 <div class="error-banner">${this.error}</div>
211 <a href="/" class="back-link">Back to home</a>
212 </div>
213 `;
214 }
215
216 if (this.isSuccess) {
217 return html`
218 <div class="reset-card">
219 <div class="success-message">
220 <div class="success-icon">✓</div>
221 <div class="success-text">Password reset successfully!</div>
222 <a href="/" class="success-link">Go to home</a>
223 </div>
224 </div>
225 `;
226 }
227
228 return html`
229 <div class="reset-card">
230 <h1 class="reset-title">Reset Password</h1>
231
232 <form @submit=${this.handleSubmit}>
233 ${this.error
234 ? html`<div class="error-banner">${this.error}</div>`
235 : ""}
236
237 <div class="form-group">
238 <label for="password">New Password</label>
239 <input
240 type="password"
241 id="password"
242 .value=${this.password}
243 @input=${(e: Event) => {
244 this.password = (e.target as HTMLInputElement).value;
245 }}
246 required
247 minlength="8"
248 placeholder="Enter new password (min 8 characters)"
249 >
250 </div>
251
252 <div class="form-group">
253 <label for="confirm-password">Confirm Password</label>
254 <input
255 type="password"
256 id="confirm-password"
257 .value=${this.confirmPassword}
258 @input=${(e: Event) => {
259 this.confirmPassword = (e.target as HTMLInputElement).value;
260 }}
261 required
262 minlength="8"
263 placeholder="Confirm new password"
264 >
265 </div>
266
267 <button type="submit" class="btn-primary" ?disabled=${this.isSubmitting}>
268 ${this.isSubmitting ? "Resetting..." : "Reset Password"}
269 </button>
270 </form>
271
272 <a href="/" class="back-link">Back to home</a>
273 </div>
274 `;
275 }
276
277 private async handleSubmit(e: Event) {
278 e.preventDefault();
279 this.error = "";
280
281 // Validate passwords match
282 if (this.password !== this.confirmPassword) {
283 this.error = "Passwords do not match";
284 return;
285 }
286
287 // Validate password length
288 if (this.password.length < 8) {
289 this.error = "Password must be at least 8 characters";
290 return;
291 }
292
293 this.isSubmitting = true;
294
295 try {
296 if (!this.email) {
297 throw new Error("Email not loaded");
298 }
299
300 // Hash password client-side with user's email
301 const hashedPassword = await hashPasswordClient(this.password, this.email);
302
303 const response = await fetch("/api/auth/reset-password", {
304 method: "POST",
305 headers: { "Content-Type": "application/json" },
306 body: JSON.stringify({ token: this.token, password: hashedPassword }),
307 });
308
309 const data = await response.json();
310
311 if (!response.ok) {
312 throw new Error(data.error || "Failed to reset password");
313 }
314
315 // Show success message
316 this.isSuccess = true;
317 } catch (err) {
318 this.error =
319 err instanceof Error ? err.message : "Failed to reset password";
320 } finally {
321 this.isSubmitting = false;
322 }
323 }
324}