tangled
alpha
login
or
join now
dunkirk.sh
/
thistle
1
fork
atom
🪻 distributed transcription service
thistle.dunkirk.sh
1
fork
atom
overview
issues
pulls
pipelines
feat: make a better reset password flow
dunkirk.sh
4 months ago
bbd5c004
6789cc46
verified
This commit was signed with the committer's
known signature
.
dunkirk.sh
SSH Key Fingerprint:
SHA256:DqcG0RXYExE26KiWo3VxJnsxswN1QNfTBvB+bdSpk80=
+447
-162
5 changed files
expand all
collapse all
unified
split
src
components
reset-password-form.ts
user-modal.ts
index.test.README.md
index.ts
pages
reset-password.html
+324
src/components/reset-password-form.ts
reviewed
···
1
1
+
import { css, html, LitElement } from "lit";
2
2
+
import { customElement, property, state } from "lit/decorators.js";
3
3
+
import { hashPasswordClient } from "../lib/client-auth";
4
4
+
5
5
+
@customElement("reset-password-form")
6
6
+
export class ResetPasswordForm extends LitElement {
7
7
+
@property({ type: String }) token: string | null = null;
8
8
+
@state() private email: string | null = null;
9
9
+
@state() private password = "";
10
10
+
@state() private confirmPassword = "";
11
11
+
@state() private error = "";
12
12
+
@state() private isSubmitting = false;
13
13
+
@state() private isSuccess = false;
14
14
+
@state() private isLoadingEmail = false;
15
15
+
16
16
+
static override styles = css`
17
17
+
:host {
18
18
+
display: block;
19
19
+
}
20
20
+
21
21
+
.reset-card {
22
22
+
background: var(--background);
23
23
+
border: 2px solid var(--secondary);
24
24
+
border-radius: 12px;
25
25
+
padding: 2.5rem;
26
26
+
max-width: 25rem;
27
27
+
width: 100%;
28
28
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
29
29
+
}
30
30
+
31
31
+
.reset-title {
32
32
+
margin-top: 0;
33
33
+
margin-bottom: 2rem;
34
34
+
color: var(--text);
35
35
+
text-align: center;
36
36
+
font-size: 1.75rem;
37
37
+
}
38
38
+
39
39
+
.form-group {
40
40
+
margin-bottom: 1.5rem;
41
41
+
}
42
42
+
43
43
+
label {
44
44
+
display: block;
45
45
+
margin-bottom: 0.25rem;
46
46
+
font-weight: 500;
47
47
+
color: var(--text);
48
48
+
font-size: 0.875rem;
49
49
+
}
50
50
+
51
51
+
input {
52
52
+
width: 100%;
53
53
+
padding: 0.75rem;
54
54
+
border: 2px solid var(--secondary);
55
55
+
border-radius: 6px;
56
56
+
font-size: 1rem;
57
57
+
font-family: inherit;
58
58
+
background: var(--background);
59
59
+
color: var(--text);
60
60
+
transition: all 0.2s;
61
61
+
box-sizing: border-box;
62
62
+
}
63
63
+
64
64
+
input::placeholder {
65
65
+
color: var(--secondary);
66
66
+
opacity: 1;
67
67
+
}
68
68
+
69
69
+
input:focus {
70
70
+
outline: none;
71
71
+
border-color: var(--primary);
72
72
+
}
73
73
+
74
74
+
.error-banner {
75
75
+
background: #fecaca;
76
76
+
border: 2px solid rgba(220, 38, 38, 0.8);
77
77
+
border-radius: 6px;
78
78
+
padding: 1rem;
79
79
+
margin-bottom: 1rem;
80
80
+
color: #dc2626;
81
81
+
font-weight: 500;
82
82
+
}
83
83
+
84
84
+
.btn-primary {
85
85
+
width: 100%;
86
86
+
padding: 0.75rem 1.5rem;
87
87
+
border: 2px solid var(--primary);
88
88
+
border-radius: 6px;
89
89
+
font-size: 1rem;
90
90
+
font-weight: 500;
91
91
+
cursor: pointer;
92
92
+
transition: all 0.2s;
93
93
+
font-family: inherit;
94
94
+
background: var(--primary);
95
95
+
color: white;
96
96
+
margin-top: 0.5rem;
97
97
+
}
98
98
+
99
99
+
.btn-primary:hover:not(:disabled) {
100
100
+
background: transparent;
101
101
+
color: var(--primary);
102
102
+
}
103
103
+
104
104
+
.btn-primary:disabled {
105
105
+
opacity: 0.6;
106
106
+
cursor: not-allowed;
107
107
+
}
108
108
+
109
109
+
.back-link {
110
110
+
display: block;
111
111
+
text-align: center;
112
112
+
margin-top: 1.5rem;
113
113
+
color: var(--primary);
114
114
+
text-decoration: none;
115
115
+
font-weight: 500;
116
116
+
font-size: 0.875rem;
117
117
+
transition: all 0.2s;
118
118
+
}
119
119
+
120
120
+
.back-link:hover {
121
121
+
color: var(--accent);
122
122
+
}
123
123
+
124
124
+
.success-message {
125
125
+
text-align: center;
126
126
+
}
127
127
+
128
128
+
.success-icon {
129
129
+
font-size: 3rem;
130
130
+
margin-bottom: 1rem;
131
131
+
}
132
132
+
133
133
+
.success-text {
134
134
+
color: var(--primary);
135
135
+
font-size: 1.25rem;
136
136
+
font-weight: 500;
137
137
+
margin-bottom: 1.5rem;
138
138
+
}
139
139
+
140
140
+
.success-link {
141
141
+
display: inline-block;
142
142
+
padding: 0.75rem 1.5rem;
143
143
+
background: var(--accent);
144
144
+
color: white;
145
145
+
text-decoration: none;
146
146
+
border-radius: 6px;
147
147
+
font-weight: 500;
148
148
+
transition: all 0.2s;
149
149
+
}
150
150
+
151
151
+
.success-link:hover {
152
152
+
background: var(--primary);
153
153
+
}
154
154
+
`;
155
155
+
156
156
+
override async updated(changedProperties: Map<string, unknown>) {
157
157
+
super.updated(changedProperties);
158
158
+
159
159
+
// When token property changes and we don't have email yet, load it
160
160
+
if (changedProperties.has('token') && this.token && !this.email && !this.isLoadingEmail) {
161
161
+
await this.loadEmail();
162
162
+
}
163
163
+
}
164
164
+
165
165
+
private async loadEmail() {
166
166
+
this.isLoadingEmail = true;
167
167
+
this.error = "";
168
168
+
169
169
+
try {
170
170
+
const url = `/api/auth/reset-password?token=${encodeURIComponent(this.token || "")}`;
171
171
+
const response = await fetch(url);
172
172
+
const data = await response.json();
173
173
+
174
174
+
if (!response.ok) {
175
175
+
throw new Error(data.error || "Invalid or expired reset token");
176
176
+
}
177
177
+
178
178
+
this.email = data.email;
179
179
+
} catch (err) {
180
180
+
this.error = err instanceof Error ? err.message : "Failed to verify reset token";
181
181
+
} finally {
182
182
+
this.isLoadingEmail = false;
183
183
+
}
184
184
+
}
185
185
+
186
186
+
override render() {
187
187
+
if (!this.token) {
188
188
+
return html`
189
189
+
<div class="reset-card">
190
190
+
<h1 class="reset-title">Reset Password</h1>
191
191
+
<div class="error-banner">Invalid or missing reset token</div>
192
192
+
<a href="/" class="back-link">Back to home</a>
193
193
+
</div>
194
194
+
`;
195
195
+
}
196
196
+
197
197
+
if (this.isLoadingEmail) {
198
198
+
return html`
199
199
+
<div class="reset-card">
200
200
+
<h1 class="reset-title">Reset Password</h1>
201
201
+
<p style="text-align: center; color: var(--text);">Verifying reset token...</p>
202
202
+
</div>
203
203
+
`;
204
204
+
}
205
205
+
206
206
+
if (this.error && !this.email) {
207
207
+
return html`
208
208
+
<div class="reset-card">
209
209
+
<h1 class="reset-title">Reset Password</h1>
210
210
+
<div class="error-banner">${this.error}</div>
211
211
+
<a href="/" class="back-link">Back to home</a>
212
212
+
</div>
213
213
+
`;
214
214
+
}
215
215
+
216
216
+
if (this.isSuccess) {
217
217
+
return html`
218
218
+
<div class="reset-card">
219
219
+
<div class="success-message">
220
220
+
<div class="success-icon">✓</div>
221
221
+
<div class="success-text">Password reset successfully!</div>
222
222
+
<a href="/" class="success-link">Go to home</a>
223
223
+
</div>
224
224
+
</div>
225
225
+
`;
226
226
+
}
227
227
+
228
228
+
return html`
229
229
+
<div class="reset-card">
230
230
+
<h1 class="reset-title">Reset Password</h1>
231
231
+
232
232
+
<form @submit=${this.handleSubmit}>
233
233
+
${this.error
234
234
+
? html`<div class="error-banner">${this.error}</div>`
235
235
+
: ""}
236
236
+
237
237
+
<div class="form-group">
238
238
+
<label for="password">New Password</label>
239
239
+
<input
240
240
+
type="password"
241
241
+
id="password"
242
242
+
.value=${this.password}
243
243
+
@input=${(e: Event) => {
244
244
+
this.password = (e.target as HTMLInputElement).value;
245
245
+
}}
246
246
+
required
247
247
+
minlength="8"
248
248
+
placeholder="Enter new password (min 8 characters)"
249
249
+
>
250
250
+
</div>
251
251
+
252
252
+
<div class="form-group">
253
253
+
<label for="confirm-password">Confirm Password</label>
254
254
+
<input
255
255
+
type="password"
256
256
+
id="confirm-password"
257
257
+
.value=${this.confirmPassword}
258
258
+
@input=${(e: Event) => {
259
259
+
this.confirmPassword = (e.target as HTMLInputElement).value;
260
260
+
}}
261
261
+
required
262
262
+
minlength="8"
263
263
+
placeholder="Confirm new password"
264
264
+
>
265
265
+
</div>
266
266
+
267
267
+
<button type="submit" class="btn-primary" ?disabled=${this.isSubmitting}>
268
268
+
${this.isSubmitting ? "Resetting..." : "Reset Password"}
269
269
+
</button>
270
270
+
</form>
271
271
+
272
272
+
<a href="/" class="back-link">Back to home</a>
273
273
+
</div>
274
274
+
`;
275
275
+
}
276
276
+
277
277
+
private async handleSubmit(e: Event) {
278
278
+
e.preventDefault();
279
279
+
this.error = "";
280
280
+
281
281
+
// Validate passwords match
282
282
+
if (this.password !== this.confirmPassword) {
283
283
+
this.error = "Passwords do not match";
284
284
+
return;
285
285
+
}
286
286
+
287
287
+
// Validate password length
288
288
+
if (this.password.length < 8) {
289
289
+
this.error = "Password must be at least 8 characters";
290
290
+
return;
291
291
+
}
292
292
+
293
293
+
this.isSubmitting = true;
294
294
+
295
295
+
try {
296
296
+
if (!this.email) {
297
297
+
throw new Error("Email not loaded");
298
298
+
}
299
299
+
300
300
+
// Hash password client-side with user's email
301
301
+
const hashedPassword = await hashPasswordClient(this.password, this.email);
302
302
+
303
303
+
const response = await fetch("/api/auth/reset-password", {
304
304
+
method: "POST",
305
305
+
headers: { "Content-Type": "application/json" },
306
306
+
body: JSON.stringify({ token: this.token, password: hashedPassword }),
307
307
+
});
308
308
+
309
309
+
const data = await response.json();
310
310
+
311
311
+
if (!response.ok) {
312
312
+
throw new Error(data.error || "Failed to reset password");
313
313
+
}
314
314
+
315
315
+
// Show success message
316
316
+
this.isSuccess = true;
317
317
+
} catch (err) {
318
318
+
this.error =
319
319
+
err instanceof Error ? err.message : "Failed to reset password";
320
320
+
} finally {
321
321
+
this.isSubmitting = false;
322
322
+
}
323
323
+
}
324
324
+
}
+22
-26
src/components/user-modal.ts
reviewed
···
233
233
color: #991b1b;
234
234
}
235
235
236
236
+
.info-text {
237
237
+
color: var(--text);
238
238
+
font-size: 0.875rem;
239
239
+
margin: 0 0 1rem 0;
240
240
+
line-height: 1.5;
241
241
+
opacity: 0.8;
242
242
+
}
243
243
+
236
244
.session-list, .passkey-list {
237
245
list-style: none;
238
246
padding: 0;
···
442
450
443
451
private async handleChangePassword(e: Event) {
444
452
e.preventDefault();
445
445
-
const form = e.target as HTMLFormElement;
446
446
-
const input = form.querySelector("input") as HTMLInputElement;
447
447
-
const password = input.value;
448
448
-
449
449
-
if (password.length < 8) {
450
450
-
alert("Password must be at least 8 characters");
451
451
-
return;
452
452
-
}
453
453
454
454
if (
455
455
!confirm(
456
456
-
"Are you sure you want to change this user's password? This will log them out of all devices.",
456
456
+
"Send a password reset email to this user? They will receive a link to set a new password.",
457
457
)
458
458
) {
459
459
return;
460
460
}
461
461
462
462
+
const form = e.target as HTMLFormElement;
462
463
const submitBtn = form.querySelector(
463
464
'button[type="submit"]',
464
465
) as HTMLButtonElement;
465
466
submitBtn.disabled = true;
466
466
-
submitBtn.textContent = "Updating...";
467
467
+
submitBtn.textContent = "Sending...";
467
468
468
469
try {
469
469
-
const res = await fetch(`/api/admin/users/${this.userId}/password`, {
470
470
-
method: "PUT",
470
470
+
const res = await fetch(`/api/admin/users/${this.userId}/password-reset`, {
471
471
+
method: "POST",
471
472
headers: { "Content-Type": "application/json" },
472
472
-
body: JSON.stringify({ password }),
473
473
});
474
474
475
475
if (!res.ok) {
476
476
-
throw new Error("Failed to update password");
476
476
+
const data = await res.json();
477
477
+
throw new Error(data.error || "Failed to send password reset email");
477
478
}
478
479
479
480
alert(
480
480
-
"Password updated successfully. User has been logged out of all devices.",
481
481
+
"Password reset email sent successfully. The user will receive a link to set a new password.",
481
482
);
482
482
-
input.value = "";
483
483
-
await this.loadUserDetails();
484
484
-
} catch {
485
485
-
alert("Failed to update password");
483
483
+
} catch (err) {
484
484
+
this.error = err instanceof Error ? err.message : "Failed to send password reset email";
486
485
} finally {
487
486
submitBtn.disabled = false;
488
488
-
submitBtn.textContent = "Update Password";
487
487
+
submitBtn.textContent = "Send Reset Email";
489
488
}
490
489
}
491
490
···
645
644
</div>
646
645
647
646
<div class="detail-section">
648
648
-
<h3 class="detail-section-title">Change Password</h3>
647
647
+
<h3 class="detail-section-title">Password Reset</h3>
648
648
+
<p class="info-text">Send a password reset email to this user. They will receive a secure link to set a new password.</p>
649
649
<form @submit=${this.handleChangePassword}>
650
650
-
<div class="form-group">
651
651
-
<label class="form-label" for="new-password">New Password</label>
652
652
-
<input type="password" id="new-password" class="form-input" placeholder="Enter new password (min 8 characters)">
653
653
-
</div>
654
654
-
<button type="submit" class="btn btn-primary">Update Password</button>
650
650
+
<button type="submit" class="btn btn-primary">Send Reset Email</button>
655
651
</form>
656
652
</div>
657
653
+1
-1
src/index.test.README.md
reviewed
···
70
70
- `PUT /api/admin/users/:id/role` - Update user role
71
71
- `PUT /api/admin/users/:id/name` - Update user name
72
72
- `PUT /api/admin/users/:id/email` - Update user email
73
73
-
- `PUT /api/admin/users/:id/password` - Update user password
73
73
+
- `POST /api/admin/users/:id/password-reset` - Send password reset email
74
74
- `GET /api/admin/users/:id/sessions` - List user sessions
75
75
- `DELETE /api/admin/users/:id/sessions` - Delete all user sessions
76
76
- `DELETE /api/admin/users/:id/sessions/:sessionId` - Delete specific session
+69
-11
src/index.ts
reviewed
···
664
664
},
665
665
},
666
666
"/api/auth/reset-password": {
667
667
+
GET: async (req) => {
668
668
+
try {
669
669
+
const url = new URL(req.url);
670
670
+
const token = url.searchParams.get("token");
671
671
+
672
672
+
if (!token) {
673
673
+
return Response.json(
674
674
+
{ error: "Token required" },
675
675
+
{ status: 400 },
676
676
+
);
677
677
+
}
678
678
+
679
679
+
const userId = verifyPasswordResetToken(token);
680
680
+
if (!userId) {
681
681
+
return Response.json(
682
682
+
{ error: "Invalid or expired reset token" },
683
683
+
{ status: 400 },
684
684
+
);
685
685
+
}
686
686
+
687
687
+
// Get user's email for client-side password hashing
688
688
+
const user = db
689
689
+
.query<{ email: string }, [number]>("SELECT email FROM users WHERE id = ?")
690
690
+
.get(userId);
691
691
+
692
692
+
if (!user) {
693
693
+
return Response.json({ error: "User not found" }, { status: 404 });
694
694
+
}
695
695
+
696
696
+
return Response.json({ email: user.email });
697
697
+
} catch (error) {
698
698
+
console.error("[Email] Get reset token info error:", error);
699
699
+
return Response.json(
700
700
+
{ error: "Failed to verify token" },
701
701
+
{ status: 500 },
702
702
+
);
703
703
+
}
704
704
+
},
667
705
POST: async (req) => {
668
706
try {
669
707
const body = await req.json();
···
2162
2200
}
2163
2201
},
2164
2202
},
2165
2165
-
"/api/admin/users/:id/password": {
2166
2166
-
PUT: async (req) => {
2203
2203
+
"/api/admin/users/:id/password-reset": {
2204
2204
+
POST: async (req) => {
2167
2205
try {
2168
2206
requireAdmin(req);
2169
2207
const userId = Number.parseInt(req.params.id, 10);
···
2171
2209
return Response.json({ error: "Invalid user ID" }, { status: 400 });
2172
2210
}
2173
2211
2174
2174
-
const body = await req.json();
2175
2175
-
const { password } = body as { password: string };
2212
2212
+
// Get user details
2213
2213
+
const user = db
2214
2214
+
.query<
2215
2215
+
{ id: number; email: string; name: string | null },
2216
2216
+
[number]
2217
2217
+
>("SELECT id, email, name FROM users WHERE id = ?")
2218
2218
+
.get(userId);
2176
2219
2177
2177
-
if (!password || password.length < 8) {
2178
2178
-
return Response.json(
2179
2179
-
{ error: "Password must be at least 8 characters" },
2180
2180
-
{ status: 400 },
2181
2181
-
);
2220
2220
+
if (!user) {
2221
2221
+
return Response.json({ error: "User not found" }, { status: 404 });
2182
2222
}
2183
2223
2184
2184
-
await updateUserPassword(userId, password);
2185
2185
-
return Response.json({ success: true });
2224
2224
+
// Create password reset token
2225
2225
+
const origin = req.headers.get("origin") || "http://localhost:3000";
2226
2226
+
const resetToken = createPasswordResetToken(user.id);
2227
2227
+
const resetLink = `${origin}/reset-password?token=${resetToken}`;
2228
2228
+
2229
2229
+
// Send password reset email
2230
2230
+
await sendEmail({
2231
2231
+
to: user.email,
2232
2232
+
subject: "Reset your password - Thistle",
2233
2233
+
html: passwordResetTemplate({
2234
2234
+
name: user.name,
2235
2235
+
resetLink,
2236
2236
+
}),
2237
2237
+
});
2238
2238
+
2239
2239
+
return Response.json({
2240
2240
+
success: true,
2241
2241
+
message: "Password reset email sent"
2242
2242
+
});
2186
2243
} catch (error) {
2244
2244
+
console.error("[Admin] Password reset error:", error);
2187
2245
return handleError(error);
2188
2246
}
2189
2247
},
+31
-124
src/pages/reset-password.html
reviewed
···
5
5
<meta charset="UTF-8">
6
6
<meta name="viewport" content="width=device-width, initial-scale=1.0">
7
7
<title>Reset Password - Thistle</title>
8
8
-
<link rel="icon"
9
9
-
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>🪻</text></svg>">
8
8
+
<link rel="apple-touch-icon" sizes="180x180" href="../../public/favicon/apple-touch-icon.png">
9
9
+
<link rel="icon" type="image/png" sizes="32x32" href="../../public/favicon/favicon-32x32.png">
10
10
+
<link rel="icon" type="image/png" sizes="16x16" href="../../public/favicon/favicon-16x16.png">
11
11
+
<link rel="manifest" href="../../public/favicon/site.webmanifest">
10
12
<link rel="stylesheet" href="../styles/main.css">
13
13
+
<style>
14
14
+
main {
15
15
+
display: flex;
16
16
+
align-items: center;
17
17
+
justify-content: center;
18
18
+
padding: 4rem 1rem;
19
19
+
}
20
20
+
</style>
11
21
</head>
12
22
13
23
<body>
14
14
-
<auth-component></auth-component>
15
15
-
24
24
+
<header>
25
25
+
<div class="header-content">
26
26
+
<a href="/" class="site-title">
27
27
+
<img src="../../public/favicon/favicon-32x32.png" alt="Thistle logo">
28
28
+
<span>Thistle</span>
29
29
+
</a>
30
30
+
<auth-component></auth-component>
31
31
+
</div>
32
32
+
</header>
33
33
+
16
34
<main>
17
17
-
<div class="container" style="max-width: 28rem; margin: 4rem auto; padding: 0 1rem;">
18
18
-
<div class="reset-card" style="background: var(--white); border: 1px solid var(--silver); border-radius: 0.5rem; padding: 2rem;">
19
19
-
<h1 style="margin-top: 0; text-align: center;">🪻 Reset Password</h1>
20
20
-
21
21
-
<div id="form-container">
22
22
-
<form id="reset-form">
23
23
-
<div style="margin-bottom: 1.5rem;">
24
24
-
<label for="password" style="display: block; margin-bottom: 0.5rem; font-weight: 500;">New Password</label>
25
25
-
<input
26
26
-
type="password"
27
27
-
id="password"
28
28
-
required
29
29
-
minlength="8"
30
30
-
style="width: 100%; padding: 0.75rem; border: 1px solid var(--silver); border-radius: 0.25rem; font-size: 1rem;"
31
31
-
>
32
32
-
</div>
33
33
-
34
34
-
<div style="margin-bottom: 1.5rem;">
35
35
-
<label for="confirm-password" style="display: block; margin-bottom: 0.5rem; font-weight: 500;">Confirm Password</label>
36
36
-
<input
37
37
-
type="password"
38
38
-
id="confirm-password"
39
39
-
required
40
40
-
minlength="8"
41
41
-
style="width: 100%; padding: 0.75rem; border: 1px solid var(--silver); border-radius: 0.25rem; font-size: 1rem;"
42
42
-
>
43
43
-
</div>
44
44
-
45
45
-
<div id="error-message" style="display: none; color: var(--coral); margin-bottom: 1rem; padding: 0.75rem; background: #fef2f2; border-radius: 0.25rem;"></div>
46
46
-
47
47
-
<button
48
48
-
type="submit"
49
49
-
id="submit-btn"
50
50
-
style="width: 100%; padding: 0.75rem; background: var(--accent); color: var(--white); border: none; border-radius: 0.25rem; font-size: 1rem; font-weight: 500; cursor: pointer;"
51
51
-
>
52
52
-
Reset Password
53
53
-
</button>
54
54
-
</form>
55
55
-
56
56
-
<div style="text-align: center; margin-top: 1.5rem;">
57
57
-
<a href="/" style="color: var(--primary); text-decoration: none;">Back to home</a>
58
58
-
</div>
59
59
-
</div>
60
60
-
61
61
-
<div id="success-message" style="display: none; text-align: center;">
62
62
-
<div style="color: var(--primary); margin-bottom: 1rem;">
63
63
-
✓ Password reset successfully!
64
64
-
</div>
65
65
-
<a href="/" style="color: var(--accent); font-weight: 500; text-decoration: none;">Go to home</a>
66
66
-
</div>
67
67
-
</div>
68
68
-
</div>
35
35
+
<reset-password-form id="reset-form"></reset-password-form>
69
36
</main>
70
37
71
38
<script type="module" src="../components/auth.ts"></script>
39
39
+
<script type="module" src="../components/reset-password-form.ts"></script>
72
40
<script type="module">
73
73
-
import { hashPasswordClient } from '../lib/client-auth.ts';
74
74
-
75
75
-
const form = document.getElementById('reset-form');
76
76
-
const passwordInput = document.getElementById('password');
77
77
-
const confirmPasswordInput = document.getElementById('confirm-password');
78
78
-
const submitBtn = document.getElementById('submit-btn');
79
79
-
const errorMessage = document.getElementById('error-message');
80
80
-
const formContainer = document.getElementById('form-container');
81
81
-
const successMessage = document.getElementById('success-message');
82
82
-
83
83
-
// Get token from URL
41
41
+
// Wait for component to be defined before setting token
42
42
+
await customElements.whenDefined('reset-password-form');
43
43
+
44
44
+
// Get token from URL and pass to component
84
45
const urlParams = new URLSearchParams(window.location.search);
85
46
const token = urlParams.get('token');
86
86
-
87
87
-
if (!token) {
88
88
-
errorMessage.textContent = 'Invalid or missing reset token';
89
89
-
errorMessage.style.display = 'block';
90
90
-
form.style.display = 'none';
47
47
+
const resetForm = document.getElementById('reset-form');
48
48
+
if (resetForm) {
49
49
+
resetForm.token = token;
91
50
}
92
92
-
93
93
-
form?.addEventListener('submit', async (e) => {
94
94
-
e.preventDefault();
95
95
-
96
96
-
const password = passwordInput.value;
97
97
-
const confirmPassword = confirmPasswordInput.value;
98
98
-
99
99
-
// Validate passwords match
100
100
-
if (password !== confirmPassword) {
101
101
-
errorMessage.textContent = 'Passwords do not match';
102
102
-
errorMessage.style.display = 'block';
103
103
-
return;
104
104
-
}
105
105
-
106
106
-
// Validate password length
107
107
-
if (password.length < 8) {
108
108
-
errorMessage.textContent = 'Password must be at least 8 characters';
109
109
-
errorMessage.style.display = 'block';
110
110
-
return;
111
111
-
}
112
112
-
113
113
-
errorMessage.style.display = 'none';
114
114
-
submitBtn.disabled = true;
115
115
-
submitBtn.textContent = 'Resetting...';
116
116
-
117
117
-
try {
118
118
-
// Hash password client-side
119
119
-
const hashedPassword = await hashPasswordClient(password);
120
120
-
121
121
-
const response = await fetch('/api/auth/reset-password', {
122
122
-
method: 'POST',
123
123
-
headers: { 'Content-Type': 'application/json' },
124
124
-
body: JSON.stringify({ token, password: hashedPassword }),
125
125
-
});
126
126
-
127
127
-
const data = await response.json();
128
128
-
129
129
-
if (!response.ok) {
130
130
-
throw new Error(data.error || 'Failed to reset password');
131
131
-
}
132
132
-
133
133
-
// Show success message
134
134
-
formContainer.style.display = 'none';
135
135
-
successMessage.style.display = 'block';
136
136
-
137
137
-
} catch (error) {
138
138
-
errorMessage.textContent = error.message || 'Failed to reset password';
139
139
-
errorMessage.style.display = 'block';
140
140
-
submitBtn.disabled = false;
141
141
-
submitBtn.textContent = 'Reset Password';
142
142
-
}
143
143
-
});
144
51
</script>
145
52
</body>
146
53