Andrew commited on
Commit
e273a27
·
1 Parent(s): 3c1958b

feat(login): Add registration and password recovery to LoginModal

Browse files
Files changed (1) hide show
  1. src/lib/components/LoginModal.svelte +343 -114
src/lib/components/LoginModal.svelte CHANGED
@@ -5,57 +5,126 @@
5
  import LogoHuggingFaceBorderless from "$lib/components/icons/LogoHuggingFaceBorderless.svelte";
6
  import Modal from "$lib/components/Modal.svelte";
7
  import { cookiesAreEnabled } from "$lib/utils/cookiesAreEnabled";
8
- import Logo from "./icons/Logo.svelte";
9
- import { usePublicConfig } from "$lib/utils/PublicConfig.svelte";
 
 
 
 
10
 
11
- const publicConfig = usePublicConfig();
12
 
13
- interface Props {
14
- onclose?: () => void;
15
- }
16
 
17
- let { onclose }: Props = $props();
18
 
19
- let showPasswordForm = $state(false);
20
- let passwordErrorMessage = $state(false);
21
- let isSubmitting = $state(false);
 
 
 
 
 
 
 
 
22
 
23
- async function handlePasswordSubmit(event: SubmitEvent) {
24
- event.preventDefault();
25
- if (isSubmitting) return;
26
 
27
- if (!cookiesAreEnabled()) {
28
- window.open(window.location.href, "_blank");
29
- return;
30
- }
31
 
32
- isSubmitting = true;
33
- passwordErrorMessage = false;
 
 
34
 
35
- const formData = new FormData(event.target as HTMLFormElement);
36
- const body = Object.fromEntries(formData.entries());
 
37
 
38
- try {
39
- const response = await fetch(`${base}/login/password`, {
40
- method: "POST",
41
- headers: { "Content-Type": "application/json" },
42
- body: JSON.stringify(body),
43
- });
44
 
45
- if (response.ok) {
46
- window.location.href = `${base}/`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  } else {
48
- const result = await response.json();
49
- console.error(result.message);
50
- passwordErrorMessage = true;
51
  }
52
- } catch (e) {
53
- console.error(e);
54
- passwordErrorMessage = true;
55
- } finally {
56
- isSubmitting = false;
57
  }
58
- }
59
  </script>
60
 
61
  <Modal onclose={() => onclose?.()} width="!max-w-[400px] !m-4">
@@ -66,89 +135,249 @@ async function handlePasswordSubmit(event: SubmitEvent) {
66
  <Logo classNames="mr-1" />
67
  {publicConfig.PUBLIC_APP_NAME}
68
  </h2>
69
- <!-- <p class="text-balance text-lg font-semibold leading-snug text-gray-800">
70
- {publicConfig.PUBLIC_APP_DESCRIPTION}
71
- </p>
72
- <p class="text-balance rounded-xl border bg-white/80 p-2 text-base text-gray-800">
73
- {publicConfig.PUBLIC_APP_GUEST_MESSAGE}
74
- </p> -->
75
-
76
- <div class="flex w-full flex-col items-center gap-3">
77
- {#if page.data.loginRequired}
78
- <a
79
- href="{base}/login"
80
- class="flex w-full flex-wrap items-center justify-center whitespace-nowrap rounded-full bg-black px-5 py-2 text-center text-lg font-semibold text-gray-100 transition-colors hover:bg-gray-900"
81
- >
82
- Sign in with
83
- <span class="flex items-center">
84
- &nbsp;<LogoHuggingFaceBorderless classNames="text-xl mr-1" /> Hugging Face
85
- </span>
86
- </a>
87
-
88
- <button
89
- type="button"
90
- onclick={() => {
91
- showPasswordForm = !showPasswordForm;
92
- if (!showPasswordForm) {
93
- passwordErrorMessage = false;
94
- }
95
- }}
96
- class="flex w-full items-center justify-center whitespace-nowrap rounded-full border-2 border-black bg-white px-5 py-2 text-lg font-semibold text-gray-800 transition-colors hover:bg-gray-100"
97
- >
98
- Sign in with username/password
99
- </button>
100
 
101
- {#if showPasswordForm}
102
- <form
103
- onsubmit={handlePasswordSubmit}
104
- class="flex w-full flex-col gap-2 rounded-xl border border-gray-200 bg-white/80 p-3 text-left shadow-sm"
105
- >
106
- <label class="text-sm font-medium text-gray-700">
107
- Username or email
108
- <input
109
- type="text"
110
- name="username"
111
- autocomplete="username"
112
- required
113
- class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-black focus:outline-none focus:ring-2 focus:ring-black/50"
114
- />
115
- </label>
116
- <label class="text-sm font-medium text-gray-700">
117
- Password
118
- <input
119
- type="password"
120
- name="password"
121
- autocomplete="current-password"
122
- required
123
- class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-black focus:outline-none focus:ring-2 focus:ring-black/50"
124
- />
125
- </label>
126
- {#if passwordErrorMessage}
127
- <p class="text-sm text-red-600">Invalid username or password. Please try again.</p>
128
  {/if}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  <button
130
- type="submit"
131
- disabled={isSubmitting}
132
- class="mt-1 flex w-full items-center justify-center whitespace-nowrap rounded-full bg-black px-4 py-2 text-lg font-semibold text-gray-100 transition-colors hover:bg-gray-900 disabled:cursor-not-allowed disabled:bg-gray-500"
133
  >
134
- {#if isSubmitting}Submitting...{:else}Continue{/if}
 
 
 
 
135
  </button>
136
- </form>
137
- {/if}
138
- {:else}
139
  <button
140
- class="flex w-full items-center justify-center whitespace-nowrap rounded-full border-2 border-black bg-black px-5 py-2 text-lg font-semibold text-gray-100 transition-colors hover:bg-gray-900"
141
- onclick={(e) => {
142
- if (!cookiesAreEnabled()) {
143
- e.preventDefault();
144
- window.open(window.location.href, "_blank");
145
- }
146
- onclose?.();
147
- }}
148
  >
149
- Start chatting
150
  </button>
151
- {/if}
152
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  </div>
154
  </Modal>
 
5
  import LogoHuggingFaceBorderless from "$lib/components/icons/LogoHuggingFaceBorderless.svelte";
6
  import Modal from "$lib/components/Modal.svelte";
7
  import { cookiesAreEnabled } from "$lib/utils/cookiesAreEnabled";
8
+ import Logo from "./icons/Logo.svelte";
9
+ import CarbonCopy from "~icons/carbon/copy";
10
+ import CarbonCheckmark from "~icons/carbon/checkmark";
11
+ import CarbonView from "~icons/carbon/view";
12
+ import CarbonViewOff from "~icons/carbon/view-off";
13
+ import { usePublicConfig } from "$lib/utils/PublicConfig.svelte";
14
 
15
+ const publicConfig = usePublicConfig();
16
 
17
+ interface Props {
18
+ onclose?: () => void;
19
+ }
20
 
21
+ let { onclose }: Props = $props();
22
 
23
+ let showPasswordForm = $state(false);
24
+ let isRegistering = $state(false);
25
+ let isRecovering = $state(false);
26
+ let passwordErrorMessage = $state("");
27
+ let passwordSuccessMessage = $state("");
28
+ let isSubmitting = $state(false);
29
+
30
+ let recoveryKey = $state("");
31
+ let recoveryKeyCopied = $state(false);
32
+ let passwordVisible = $state(false);
33
+ let passwordInput = $state("");
34
 
35
+ let usernameInput = $state("");
 
 
36
 
37
+ async function handlePasswordSubmit(event: SubmitEvent) {
38
+ event.preventDefault();
39
+ if (isSubmitting) return;
 
40
 
41
+ if (!cookiesAreEnabled()) {
42
+ window.open(window.location.href, "_blank");
43
+ return;
44
+ }
45
 
46
+ isSubmitting = true;
47
+ passwordErrorMessage = "";
48
+ passwordSuccessMessage = "";
49
 
50
+ const formData = new FormData(event.target as HTMLFormElement);
51
+ const body = Object.fromEntries(formData.entries());
 
 
 
 
52
 
53
+ try {
54
+ if (isRecovering) {
55
+ const response = await fetch(`${base}/login/recover`, {
56
+ method: "POST",
57
+ headers: { "Content-Type": "application/json" },
58
+ body: JSON.stringify(body),
59
+ });
60
+
61
+ if (response.ok) {
62
+ const data = await response.json();
63
+ if (data.newRecoveryKey) {
64
+ isRecovering = false;
65
+ recoveryKey = data.newRecoveryKey;
66
+ passwordSuccessMessage = "Password reset successfully. Please save your NEW recovery key.";
67
+ } else {
68
+ isRecovering = false;
69
+ isRegistering = false;
70
+ passwordSuccessMessage = "Password reset successfully. Please sign in.";
71
+ }
72
+ } else {
73
+ const result = await response.json();
74
+ passwordErrorMessage = result.message || "Recovery failed";
75
+ }
76
+ } else if (isRegistering) {
77
+ const response = await fetch(`${base}/login/register`, {
78
+ method: "POST",
79
+ headers: { "Content-Type": "application/json" },
80
+ body: JSON.stringify(body),
81
+ });
82
+
83
+ if (response.ok) {
84
+ const data = await response.json();
85
+ recoveryKey = data.recoveryKey;
86
+ } else {
87
+ const result = await response.json();
88
+ passwordErrorMessage = result.message || "Registration failed";
89
+ }
90
+ } else {
91
+ const response = await fetch(`${base}/login/password`, {
92
+ method: "POST",
93
+ headers: { "Content-Type": "application/json" },
94
+ body: JSON.stringify(body),
95
+ });
96
+
97
+ if (response.ok) {
98
+ window.location.href = `${base}/`;
99
+ } else {
100
+ const result = await response.json();
101
+ passwordErrorMessage = result.message || "Invalid username or password";
102
+ }
103
+ }
104
+ } catch (e) {
105
+ console.error(e);
106
+ passwordErrorMessage = "An error occurred. Please try again.";
107
+ } finally {
108
+ isSubmitting = false;
109
+ }
110
+ }
111
+
112
+ function handleCopyRecoveryKey() {
113
+ navigator.clipboard.writeText(recoveryKey);
114
+ recoveryKeyCopied = true;
115
+ }
116
+
117
+ function handleCloseRecoveryModal() {
118
+ if (passwordSuccessMessage && passwordSuccessMessage.includes("NEW recovery key")) {
119
+ recoveryKey = "";
120
+ recoveryKeyCopied = false;
121
+ isRecovering = false;
122
+ isRegistering = false;
123
+ passwordSuccessMessage = "Password updated successfully. Please sign in with your new password.";
124
  } else {
125
+ window.location.href = `${base}/`;
 
 
126
  }
 
 
 
 
 
127
  }
 
128
  </script>
129
 
130
  <Modal onclose={() => onclose?.()} width="!max-w-[400px] !m-4">
 
135
  <Logo classNames="mr-1" />
136
  {publicConfig.PUBLIC_APP_NAME}
137
  </h2>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
 
139
+ {#if recoveryKey}
140
+ <div class="flex w-full flex-col items-center gap-4">
141
+ <h3 class="text-xl font-semibold text-gray-800">
142
+ {#if passwordSuccessMessage && passwordSuccessMessage.includes("NEW recovery key")}
143
+ Password Updated!
144
+ {:else}
145
+ Account Created!
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  {/if}
147
+ </h3>
148
+ <p class="text-sm text-gray-600 text-balance">
149
+ {#if passwordSuccessMessage && passwordSuccessMessage.includes("NEW recovery key")}
150
+ Your password has been updated.
151
+ <br/><br/>
152
+ Your old recovery key has been invalidated. <strong>Save this NEW key safely.</strong> It is now the ONLY way to recover your account.
153
+ {:else}
154
+ Save this Recovery Key safely. <strong>It is the ONLY way to recover your account</strong> if you forget your password.
155
+ {/if}
156
+ </p>
157
+
158
+ <div class="flex w-full items-center gap-2 rounded-md border border-gray-200 bg-gray-50 p-2">
159
+ <code class="flex-1 break-all font-mono text-sm text-gray-800">
160
+ {recoveryKey}
161
+ </code>
162
  <button
163
+ onclick={handleCopyRecoveryKey}
164
+ class="flex h-8 w-8 flex-none items-center justify-center rounded-md text-gray-500 hover:bg-gray-200 hover:text-black transition-colors"
165
+ title="Copy Recovery Key"
166
  >
167
+ {#if recoveryKeyCopied}
168
+ <CarbonCheckmark class="text-green-600" />
169
+ {:else}
170
+ <CarbonCopy />
171
+ {/if}
172
  </button>
173
+ </div>
174
+
 
175
  <button
176
+ onclick={handleCloseRecoveryModal}
177
+ disabled={!recoveryKeyCopied}
178
+ class="mt-2 w-full rounded-full bg-black px-5 py-2 text-lg font-semibold text-gray-100 transition-colors hover:bg-gray-900 disabled:cursor-not-allowed disabled:opacity-50"
 
 
 
 
 
179
  >
180
+ I have saved my key
181
  </button>
182
+ </div>
183
+ {:else}
184
+ <div class="flex w-full flex-col items-center gap-3">
185
+ {#if page.data.loginRequired}
186
+ <a
187
+ href="{base}/login"
188
+ class="flex w-full flex-wrap items-center justify-center whitespace-nowrap rounded-full bg-black px-5 py-2 text-center text-lg font-semibold text-gray-100 transition-colors hover:bg-gray-900"
189
+ >
190
+ Sign in with
191
+ <span class="flex items-center">
192
+ &nbsp;<LogoHuggingFaceBorderless classNames="text-xl mr-1" /> Hugging Face
193
+ </span>
194
+ </a>
195
+
196
+ {#if !showPasswordForm}
197
+ <button
198
+ type="button"
199
+ onclick={() => {
200
+ showPasswordForm = true;
201
+ isRegistering = false;
202
+ isRecovering = false;
203
+ passwordInput = "";
204
+ passwordErrorMessage = "";
205
+ passwordSuccessMessage = "";
206
+ }}
207
+ class="flex w-full items-center justify-center whitespace-nowrap rounded-full border-2 border-black bg-white px-5 py-2 text-lg font-semibold text-gray-800 transition-colors hover:bg-gray-100"
208
+ >
209
+ Sign in with username/password
210
+ </button>
211
+ {/if}
212
+
213
+ {#if showPasswordForm}
214
+ <form
215
+ onsubmit={handlePasswordSubmit}
216
+ class="flex w-full flex-col gap-2 rounded-xl border border-gray-200 bg-white/80 p-3 text-left shadow-sm"
217
+ >
218
+ <div class="mb-2 text-center">
219
+ <h3 class="text-lg font-semibold">
220
+ {#if isRecovering}
221
+ Reset Password
222
+ {:else if isRegistering}
223
+ Create Account
224
+ {:else}
225
+ Sign In
226
+ {/if}
227
+ </h3>
228
+ </div>
229
+
230
+ <label class="text-sm font-medium text-gray-700">
231
+ Username
232
+ <input
233
+ type="text"
234
+ name="username"
235
+ autocomplete="username"
236
+ required
237
+ bind:value={usernameInput}
238
+ class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-black focus:outline-none focus:ring-2 focus:ring-black/50"
239
+ />
240
+ </label>
241
+
242
+ {#if isRecovering}
243
+ <label class="text-sm font-medium text-gray-700">
244
+ Recovery Key
245
+ <input
246
+ type="text"
247
+ name="recoveryKey"
248
+ autocomplete="off"
249
+ required
250
+ class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-black focus:outline-none focus:ring-2 focus:ring-black/50"
251
+ />
252
+ </label>
253
+ <label class="text-sm font-medium text-gray-700">
254
+ New Password
255
+ <div class="relative mt-1">
256
+ <input
257
+ type={passwordVisible ? "text" : "password"}
258
+ name="newPassword"
259
+ autocomplete="new-password"
260
+ required
261
+ minlength="8"
262
+ bind:value={passwordInput}
263
+ class="w-full rounded-md border border-gray-300 pl-3 pr-20 py-2 text-base text-gray-900 focus:border-black focus:outline-none focus:ring-2 focus:ring-black/50"
264
+ />
265
+ <div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
266
+ <button
267
+ type="button"
268
+ class="p-1 text-gray-500 hover:text-black"
269
+ onclick={() => (passwordVisible = !passwordVisible)}
270
+ title={passwordVisible ? "Hide password" : "Show password"}
271
+ >
272
+ {#if passwordVisible}
273
+ <CarbonViewOff />
274
+ {:else}
275
+ <CarbonView />
276
+ {/if}
277
+ </button>
278
+ </div>
279
+ </div>
280
+ </label>
281
+ {:else}
282
+ {#if isRegistering}
283
+ <label class="text-sm font-medium text-gray-700">
284
+ Email (Optional)
285
+ <input
286
+ type="email"
287
+ name="email"
288
+ autocomplete="email"
289
+ class="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-base text-gray-900 focus:border-black focus:outline-none focus:ring-2 focus:ring-black/50"
290
+ />
291
+ </label>
292
+ {/if}
293
+
294
+ <label class="text-sm font-medium text-gray-700">
295
+ Password
296
+ <div class="relative mt-1">
297
+ <input
298
+ type={passwordVisible ? "text" : "password"}
299
+ name="password"
300
+ autocomplete={isRegistering ? "new-password" : "current-password"}
301
+ required
302
+ minlength="8"
303
+ bind:value={passwordInput}
304
+ class="w-full rounded-md border border-gray-300 pl-3 pr-20 py-2 text-base text-gray-900 focus:border-black focus:outline-none focus:ring-2 focus:ring-black/50"
305
+ />
306
+ <div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
307
+ <button
308
+ type="button"
309
+ class="p-1 text-gray-500 hover:text-black"
310
+ onclick={() => (passwordVisible = !passwordVisible)}
311
+ title={passwordVisible ? "Hide password" : "Show password"}
312
+ >
313
+ {#if passwordVisible}
314
+ <CarbonViewOff />
315
+ {:else}
316
+ <CarbonView />
317
+ {/if}
318
+ </button>
319
+ </div>
320
+ </div>
321
+ </label>
322
+ {/if}
323
+
324
+ {#if passwordErrorMessage}
325
+ <p class="text-sm text-red-600">{passwordErrorMessage}</p>
326
+ {/if}
327
+ {#if passwordSuccessMessage}
328
+ <p class="text-sm text-green-600">{passwordSuccessMessage}</p>
329
+ {/if}
330
+
331
+ <button
332
+ type="submit"
333
+ disabled={isSubmitting}
334
+ class="mt-1 flex w-full items-center justify-center whitespace-nowrap rounded-full bg-black px-4 py-2 text-lg font-semibold text-gray-100 transition-colors hover:bg-gray-900 disabled:cursor-not-allowed disabled:bg-gray-500"
335
+ >
336
+ {#if isSubmitting}
337
+ Submitting...
338
+ {:else}
339
+ {#if isRecovering}
340
+ Reset Password
341
+ {:else if isRegistering}
342
+ Create Account
343
+ {:else}
344
+ Sign In
345
+ {/if}
346
+ {/if}
347
+ </button>
348
+
349
+ {#if !isRecovering && !isRegistering}
350
+ <div class="mt-1 text-center">
351
+ <button type="button" onclick={() => { isRecovering = true; isRegistering = false; passwordInput = ""; passwordErrorMessage = ""; passwordSuccessMessage = ""; }} class="text-sm text-gray-500 hover:underline">Forgot Password?</button>
352
+ </div>
353
+ {/if}
354
+
355
+ <div class="mt-2 text-center text-sm text-gray-600">
356
+ {#if isRecovering}
357
+ Remember it? <button type="button" onclick={() => { isRecovering = false; passwordInput = ""; passwordErrorMessage = ""; passwordSuccessMessage = ""; }} class="font-semibold underline">Sign in</button>
358
+ {:else if isRegistering}
359
+ Already have an account? <button type="button" onclick={() => { isRegistering = false; passwordInput = ""; passwordErrorMessage = ""; passwordSuccessMessage = ""; }} class="font-semibold underline">Sign in</button>
360
+ {:else}
361
+ Don't have an account? <button type="button" onclick={() => { isRegistering = true; passwordInput = ""; passwordErrorMessage = ""; passwordSuccessMessage = ""; }} class="font-semibold underline">Create one</button>
362
+ {/if}
363
+ </div>
364
+ </form>
365
+ {/if}
366
+ {:else}
367
+ <button
368
+ class="flex w-full items-center justify-center whitespace-nowrap rounded-full border-2 border-black bg-black px-5 py-2 text-lg font-semibold text-gray-100 transition-colors hover:bg-gray-900"
369
+ onclick={(e) => {
370
+ if (!cookiesAreEnabled()) {
371
+ e.preventDefault();
372
+ window.open(window.location.href, "_blank");
373
+ }
374
+ onclose?.();
375
+ }}
376
+ >
377
+ Start chatting
378
+ </button>
379
+ {/if}
380
+ </div>
381
+ {/if}
382
  </div>
383
  </Modal>