@awsmbiz/inbis-auth-ui 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,2374 @@
1
+ import { Alert, AlertDescription, AlertTitle, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label } from "@awsmbiz/inbis-ui";
2
+ import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
3
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
4
+ import { renderSVG } from "uqr";
5
+ // src/form-parts.tsx
6
+ /** One labelled input of an auth form. */
7
+ function TextField(props) {
8
+ return /* @__PURE__ */ jsxs("div", {
9
+ className: "grid gap-2",
10
+ children: [
11
+ /* @__PURE__ */ jsx(Label, {
12
+ htmlFor: props.id,
13
+ children: props.label
14
+ }),
15
+ /* @__PURE__ */ jsx(Input, {
16
+ id: props.id,
17
+ name: props.name,
18
+ type: props.type,
19
+ autoComplete: props.autoComplete,
20
+ required: true,
21
+ value: props.value,
22
+ onChange: (event) => {
23
+ props.onChange(event.target.value);
24
+ },
25
+ "aria-invalid": props.invalid ? true : void 0,
26
+ "aria-describedby": props.hint === void 0 ? void 0 : `${props.id}-hint`
27
+ }),
28
+ props.hint === void 0 ? null : /* @__PURE__ */ jsx("p", {
29
+ id: `${props.id}-hint`,
30
+ className: "text-xs text-muted-foreground",
31
+ children: props.hint
32
+ })
33
+ ]
34
+ });
35
+ }
36
+ /** A form-level error, announced to assistive technology. */
37
+ function ErrorAlert({ title, message }) {
38
+ return /* @__PURE__ */ jsxs(Alert, {
39
+ variant: "destructive",
40
+ role: "alert",
41
+ children: [title === void 0 ? null : /* @__PURE__ */ jsx(AlertTitle, { children: title }), /* @__PURE__ */ jsx(AlertDescription, { children: message })]
42
+ });
43
+ }
44
+ /** A confirmation, announced politely. */
45
+ function Notice({ title, message }) {
46
+ return /* @__PURE__ */ jsxs(Alert, {
47
+ role: "status",
48
+ children: [title === void 0 ? null : /* @__PURE__ */ jsx(AlertTitle, { children: title }), /* @__PURE__ */ jsx(AlertDescription, { children: message })]
49
+ });
50
+ }
51
+ /** Reads one query parameter of the current page (browser only). */
52
+ function queryParam(search, name) {
53
+ const source = search ?? (typeof window === "undefined" ? "" : window.location.search);
54
+ return new URLSearchParams(source).get(name);
55
+ }
56
+ /** An absolute URL on the current page's origin, for links the server emails. */
57
+ function onThisOrigin(path) {
58
+ return typeof window === "undefined" ? path : new URL(path, window.location.origin).toString();
59
+ }
60
+ // src/messages.ts
61
+ /**
62
+ * Every user-facing string of `@awsmbiz/inbis-auth-ui`, by message key. English only
63
+ * in the baseline; keys keep a later translation a data change.
64
+ */
65
+ const messages = {
66
+ "signIn.title": "Sign in",
67
+ "signIn.description": "Sign in to {appName} with your email and password.",
68
+ "signIn.descriptionPhone": "Sign in to {appName} with your phone number.",
69
+ "signIn.email": "Email",
70
+ "signIn.password": "Password",
71
+ "signIn.submit": "Sign in",
72
+ "signIn.submitting": "Signing in…",
73
+ "signIn.failed": "Could not sign in",
74
+ "signIn.invalidCredentials": "The email or password is incorrect.",
75
+ "signIn.noMethods": "No sign-in method is enabled for this app.",
76
+ "twoFactor.title": "Two-factor authentication",
77
+ "twoFactor.challenge.description": "Enter the 6-digit code from your authenticator app.",
78
+ "twoFactor.challenge.backupDescription": "Enter one of your backup codes. Each code works once.",
79
+ "twoFactor.code": "6-digit code",
80
+ "twoFactor.backupCode": "Backup code",
81
+ "twoFactor.verify": "Verify",
82
+ "twoFactor.verifying": "Verifying…",
83
+ "twoFactor.useBackupCode": "Use a backup code",
84
+ "twoFactor.useAuthenticator": "Use your authenticator app",
85
+ "twoFactor.failed": "Could not verify",
86
+ "twoFactor.invalidCode": "That code is not valid. Check your authenticator app and try again.",
87
+ "twoFactor.invalidBackupCode": "That backup code is not valid or has already been used.",
88
+ "twoFactor.challengeExpired": "This sign-in has expired. Sign in again.",
89
+ "twoFactor.tooManyAttempts": "Too many attempts. Sign in again to get a new chance.",
90
+ "twoFactor.locked": "Too many failed attempts. Try again later.",
91
+ "twoFactor.settings.loading": "Loading…",
92
+ "twoFactor.settings.unavailable": "Two-factor authentication is not available for this app.",
93
+ "twoFactor.settings.on": "Two-factor authentication is on.",
94
+ "twoFactor.settings.off": "Two-factor authentication is off.",
95
+ "twoFactor.settings.explainer": "Protect your account with a code from an authenticator app each time you sign in.",
96
+ "twoFactor.settings.setUp": "Set up authenticator app",
97
+ "twoFactor.settings.regenerate": "Regenerate backup codes",
98
+ "twoFactor.settings.disable": "Turn off two-factor authentication",
99
+ "twoFactor.settings.failed": "Could not change two-factor authentication",
100
+ "twoFactor.password": "Current password",
101
+ "twoFactor.password.setUp": "Confirm your password to set up an authenticator app.",
102
+ "twoFactor.password.regenerate": "Confirm your password to replace your backup codes. The old codes stop working.",
103
+ "twoFactor.password.disable": "Confirm your password to turn off two-factor authentication.",
104
+ "twoFactor.continue": "Continue",
105
+ "twoFactor.regenerateSubmit": "Regenerate",
106
+ "twoFactor.disableSubmit": "Turn off",
107
+ "twoFactor.cancel": "Cancel",
108
+ "twoFactor.working": "Working…",
109
+ "twoFactor.invalidPassword": "The password is incorrect.",
110
+ "twoFactor.notFresh": "For your security, sign in again before changing two-factor authentication.",
111
+ "twoFactor.signInAgain": "Sign in again",
112
+ "twoFactor.enrol.scan": "Scan this QR code with your authenticator app, then enter the 6-digit code it shows.",
113
+ "twoFactor.enrol.qrAlt": "QR code for your authenticator app",
114
+ "twoFactor.enrol.manual": "Can't scan it? Enter this key instead:",
115
+ "twoFactor.enrol.submit": "Verify and turn on",
116
+ "twoFactor.backupCodes.title": "Save your backup codes",
117
+ "twoFactor.backupCodes.description": "Each code signs you in once if you lose your authenticator. Store them somewhere safe; they are not shown again.",
118
+ "twoFactor.backupCodes.list": "Backup codes",
119
+ "twoFactor.backupCodes.done": "I have saved my backup codes",
120
+ "signIn.noAccount": "No account yet?",
121
+ "signIn.createAccount": "Create an account",
122
+ "signUp.title": "Create an account",
123
+ "signUp.description": "Create your {appName} account.",
124
+ "signUp.name": "Name",
125
+ "signUp.email": "Email",
126
+ "signUp.password": "Password",
127
+ "signUp.passwordHint": "{minLength} to {maxLength} characters.",
128
+ "signUp.submit": "Create account",
129
+ "signUp.submitting": "Creating account…",
130
+ "signUp.failed": "Could not create the account",
131
+ "signUp.closedTitle": "Registration is not open",
132
+ "signUp.closed": "{appName} does not accept new accounts here. Ask an administrator for an invitation.",
133
+ "signUp.backToSignIn": "Back to sign in",
134
+ "deleteAccount.title": "Delete account",
135
+ "deleteAccount.unavailable": "Accounts cannot be deleted here. Contact {appName} to close your account.",
136
+ "deleteAccount.warning": "This permanently deletes your {appName} account and signs you out everywhere. It cannot be undone.",
137
+ "deleteAccount.warningGrace": "Your {appName} account will be permanently deleted after {graceDays} days, and you will be signed out everywhere. Signing in again before then cancels the deletion.",
138
+ "deleteAccount.confirmLabel": "Type DELETE to confirm",
139
+ "deleteAccount.confirmWord": "DELETE",
140
+ "deleteAccount.submit": "Delete my account",
141
+ "deleteAccount.submitting": "Deleting…",
142
+ "deleteAccount.cancel": "Cancel",
143
+ "deleteAccount.failed": "Could not delete the account",
144
+ "deleteAccount.notFresh": "For your security, sign in again before deleting your account.",
145
+ "deleteAccount.signInAgain": "Sign in again",
146
+ "deleteAccount.deleted": "Your account has been deleted.",
147
+ "deleteAccount.scheduled": "Your account will be deleted on {date}. Sign in before then to keep it.",
148
+ "deleteAccount.done": "Done",
149
+ "signIn.forgotPassword": "Forgot password?",
150
+ "signIn.emailNotVerified": "Verify your email address before signing in. Check your inbox for the link.",
151
+ "signIn.resendVerification": "Send a new verification link",
152
+ "verifyEmail.title": "Verify your email",
153
+ "verifyEmail.description": "Enter your email address and we will send you a new verification link.",
154
+ "verifyEmail.email": "Email",
155
+ "verifyEmail.submit": "Send verification link",
156
+ "verifyEmail.submitting": "Sending…",
157
+ "verifyEmail.sent": "If that email belongs to an account that still needs verifying, a new link is on its way. It works for a limited time.",
158
+ "verifyEmail.checkInbox": "Check your inbox: we sent you a link to verify your email address.",
159
+ "verifyEmail.verifiedTitle": "Email verified",
160
+ "verifyEmail.verified": "Your email address is verified.",
161
+ "verifyEmail.continue": "Continue to sign in",
162
+ "verifyEmail.invalid": "This verification link is invalid or has expired. Request a new one below.",
163
+ "verifyEmail.failed": "Could not send the link",
164
+ "forgotPassword.title": "Reset your password",
165
+ "forgotPassword.description": "Enter your email address and we will send you a link to choose a new password.",
166
+ "forgotPassword.email": "Email",
167
+ "forgotPassword.submit": "Send reset link",
168
+ "forgotPassword.submitting": "Sending…",
169
+ "forgotPassword.sent": "If an account exists for that email, we have sent a link to reset the password. It works for a limited time.",
170
+ "forgotPassword.failed": "Could not send the link",
171
+ "resetPassword.title": "Choose a new password",
172
+ "resetPassword.newPassword": "New password",
173
+ "resetPassword.confirmPassword": "Confirm new password",
174
+ "resetPassword.submit": "Set new password",
175
+ "resetPassword.submitting": "Saving…",
176
+ "resetPassword.failed": "Could not change the password",
177
+ "resetPassword.mismatch": "The passwords do not match.",
178
+ "resetPassword.invalidLink": "This reset link has expired or was already used.",
179
+ "resetPassword.compromised": "This password has appeared in a data breach, so it is not safe to use. Choose a different one.",
180
+ "resetPassword.tooShort": "The password must be at least {minLength} characters.",
181
+ "resetPassword.tooLong": "The password must be at most {maxLength} characters.",
182
+ "resetPassword.done": "Your password has been changed. Sign in with your new password.",
183
+ "resetPassword.requestNew": "Request a new link",
184
+ "invitation.title": "Accept your invitation",
185
+ "invitation.description": "Create your {appName} account for {email}.",
186
+ "invitation.descriptionAdmin": "Create your {appName} administrator account for {email}.",
187
+ "invitation.loading": "Checking your invitation…",
188
+ "invitation.invalid": "This invitation link is invalid, has expired or was already used. Ask an administrator for a new invitation.",
189
+ "invitation.phone": "This invitation is for a phone number. Sign in with your phone number to accept it.",
190
+ "invitation.name": "Name",
191
+ "invitation.password": "Password",
192
+ "invitation.confirmPassword": "Confirm password",
193
+ "invitation.submit": "Create account",
194
+ "invitation.submitting": "Creating account…",
195
+ "invitation.failed": "Could not create the account",
196
+ "invitation.nameRequired": "Enter your name.",
197
+ "invitation.exists": "An account with this email already exists. Sign in instead.",
198
+ "invitation.notAllowed": "This app is not accepting new accounts right now. Ask an administrator.",
199
+ "invitation.done": "Your account is ready. Sign in to continue.",
200
+ "sessions.title": "Your sessions",
201
+ "sessions.description": "Devices and browsers signed in to your account.",
202
+ "sessions.loading": "Loading…",
203
+ "sessions.loadFailed": "Could not load your sessions. Reload the page to try again.",
204
+ "sessions.unknownDevice": "Unknown device",
205
+ "sessions.signedInAt": "Signed in {date}",
206
+ "sessions.thisDevice": "This device",
207
+ "sessions.revoke": "Sign out",
208
+ "sessions.revokeOthers": "Sign out all other sessions",
209
+ "sessions.othersRevoked": "Signed out {count} other sessions.",
210
+ "sessions.failed": "Could not sign out the session",
211
+ "sessions.notFresh": "For your security, sign in again before signing out other sessions.",
212
+ "sessions.notFound": "That session has already ended.",
213
+ "sessions.signInAgain": "Sign in again",
214
+ "signIn.withPhone": "Use a phone number instead",
215
+ "phone.title": "Sign in with your phone",
216
+ "phone.description": "We will text a sign-in code to your phone.",
217
+ "phone.number": "Phone number",
218
+ "phone.numberHint": "Include the country code, for example +44 7700 900123.",
219
+ "phone.send": "Send code",
220
+ "phone.sending": "Sending…",
221
+ "phone.codeSent": "If {phoneNumber} can sign in here, a {codeLength}-digit code is on its way. It works for {minutes} minutes.",
222
+ "phone.code": "6-digit code",
223
+ "phone.verify": "Verify",
224
+ "phone.verifying": "Verifying…",
225
+ "phone.resend": "Send a new code",
226
+ "phone.changeNumber": "Use a different number",
227
+ "phone.failed": "Could not sign in",
228
+ "phone.invalidNumber": "Enter the number in international format, starting with + and the country code.",
229
+ "phone.invalidCode": "That code is not right. Check the SMS and try again.",
230
+ "phone.expired": "That code has expired or was already used. Send a new code.",
231
+ "phone.tooManyAttempts": "Too many wrong codes. Send a new code.",
232
+ "phone.notAllowed": "This number cannot sign in here. Ask an administrator for an invitation.",
233
+ "phone.banned": "This account is suspended. Contact an administrator.",
234
+ "invitation.descriptionPhone": "Join {appName} with {phoneNumber}. We will text you a sign-in code.",
235
+ "invitation.descriptionPhoneAdmin": "Join {appName} as an administrator with {phoneNumber}. We will text you a sign-in code.",
236
+ "stepUp.setUp.title": "Set up two-factor authentication",
237
+ "stepUp.setUp.description": "This area needs two-factor authentication. Set up an authenticator app to continue.",
238
+ "stepUp.verify.title": "Verify it's you",
239
+ "stepUp.verify.description": "This area needs a sign-in completed with your authenticator app. Sign in again with your password and code.",
240
+ "stepUp.method.title": "Sign in again",
241
+ "stepUp.method.description": "This area needs a sign-in with {methods}. Sign in again to continue.",
242
+ "stepUp.method.password": "your email and password",
243
+ "stepUp.method.phoneOtp": "a code sent to your phone",
244
+ "stepUp.method.other": "another sign-in method",
245
+ "stepUp.signInAgain": "Sign in again",
246
+ "auth.backToSignIn": "Back to sign in",
247
+ "auth.signIn": "Sign in",
248
+ "config.loading": "Loading…",
249
+ "config.failed": "Could not load the sign-in options. Reload the page to try again.",
250
+ "error.network": "Could not reach the server. Check your connection and try again.",
251
+ "error.generic": "Something went wrong. Try again.",
252
+ "error.rateLimited": "Too many attempts. Wait a minute and try again.",
253
+ "captcha.failed": "The security check could not run. Reload the page to try again.",
254
+ "captcha.rejected": "The security check did not pass. Complete it again and retry.",
255
+ "phone.countryNotAllowed": "Phone numbers from this country cannot be used here.",
256
+ "phone.tooManyRequests": "Too many codes were requested. Wait a while before asking for another.",
257
+ "phone.smsPaused": "Text messages are paused for today. Try again later or use another way to sign in."
258
+ };
259
+ /** Looks up a message and fills `{name}` placeholders. */
260
+ function t(key, values = {}) {
261
+ return messages[key].replace(/\{(\w+)\}/g, (match, name) => values[name] ?? match);
262
+ }
263
+ // src/shell.tsx
264
+ /** The centred card every auth screen renders in. */
265
+ function Shell({ title, description, children }) {
266
+ return /* @__PURE__ */ jsx("main", {
267
+ className: "flex min-h-svh items-center justify-center bg-muted/40 p-4",
268
+ children: /* @__PURE__ */ jsxs(Card, {
269
+ className: "w-full max-w-sm",
270
+ children: [/* @__PURE__ */ jsxs(CardHeader, { children: [/* @__PURE__ */ jsx(CardTitle, { children: /* @__PURE__ */ jsx("h1", { children: title }) }), description === void 0 ? null : /* @__PURE__ */ jsx(CardDescription, { children: description })] }), /* @__PURE__ */ jsx(CardContent, { children })]
271
+ })
272
+ });
273
+ }
274
+ /**
275
+ * Renders the loading and error states of the public config inside the shell,
276
+ * and `ready(config)` once it has loaded.
277
+ */
278
+ function ConfigGate({ state, title, ready }) {
279
+ if (state.status === "loading") return /* @__PURE__ */ jsx(Shell, {
280
+ title,
281
+ children: /* @__PURE__ */ jsx("p", {
282
+ className: "text-sm text-muted-foreground",
283
+ "aria-live": "polite",
284
+ children: t("config.loading")
285
+ })
286
+ });
287
+ if (state.status === "error") return /* @__PURE__ */ jsx(Shell, {
288
+ title,
289
+ children: /* @__PURE__ */ jsx(Alert, {
290
+ variant: "destructive",
291
+ role: "alert",
292
+ children: /* @__PURE__ */ jsx(AlertDescription, { children: t("config.failed") })
293
+ })
294
+ });
295
+ return ready(state.config);
296
+ }
297
+ // src/turnstile.tsx
298
+ /**
299
+ * Cloudflare Turnstile, rendered explicitly as Cloudflare documents it
300
+ * (https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/):
301
+ * the script is loaded once from Cloudflare, and each form renders its own
302
+ * widget. A token works once, so forms reset the widget after every submit.
303
+ * Pages with a Content-Security-Policy must allow `https://challenges.cloudflare.com`
304
+ * in `script-src` and `frame-src`.
305
+ */
306
+ const SCRIPT_URL = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
307
+ let loading;
308
+ /** Loads Cloudflare's widget script once per page. */
309
+ function loadTurnstile() {
310
+ loading ??= new Promise((resolve, reject) => {
311
+ if (window.turnstile !== void 0) {
312
+ resolve(window.turnstile);
313
+ return;
314
+ }
315
+ const script = document.createElement("script");
316
+ script.src = SCRIPT_URL;
317
+ script.async = true;
318
+ script.defer = true;
319
+ script.onload = () => {
320
+ if (window.turnstile === void 0) reject(/* @__PURE__ */ new Error("Turnstile did not load."));
321
+ else resolve(window.turnstile);
322
+ };
323
+ script.onerror = () => {
324
+ loading = void 0;
325
+ reject(/* @__PURE__ */ new Error("Turnstile could not be loaded."));
326
+ };
327
+ document.head.appendChild(script);
328
+ });
329
+ return loading;
330
+ }
331
+ /**
332
+ * The Turnstile check for one form, when the public config's `captcha` covers
333
+ * `action`; otherwise a no-op state.
334
+ */
335
+ function useTurnstile(captcha, action) {
336
+ const siteKey = captcha?.endpoints.includes(action) === true ? captcha.siteKey : void 0;
337
+ const container = useRef(null);
338
+ const widgetId = useRef(null);
339
+ const [token, setToken] = useState(null);
340
+ const [failed, setFailed] = useState(false);
341
+ useEffect(() => {
342
+ if (siteKey === void 0) return void 0;
343
+ let active = true;
344
+ loadTurnstile().then((turnstile) => {
345
+ if (!active || container.current === null) return;
346
+ widgetId.current = turnstile.render(container.current, {
347
+ sitekey: siteKey,
348
+ action,
349
+ callback: (value) => {
350
+ setFailed(false);
351
+ setToken(value);
352
+ },
353
+ "expired-callback": () => {
354
+ setToken(null);
355
+ },
356
+ "error-callback": () => {
357
+ setToken(null);
358
+ setFailed(true);
359
+ }
360
+ });
361
+ }, () => {
362
+ if (active) setFailed(true);
363
+ });
364
+ return () => {
365
+ active = false;
366
+ if (widgetId.current !== null) window.turnstile?.remove(widgetId.current);
367
+ widgetId.current = null;
368
+ };
369
+ }, [siteKey, action]);
370
+ const reset = useCallback(() => {
371
+ setToken(null);
372
+ if (widgetId.current !== null) window.turnstile?.reset(widgetId.current);
373
+ }, []);
374
+ if (siteKey === void 0) return {
375
+ widget: null,
376
+ ready: true,
377
+ token: void 0,
378
+ reset
379
+ };
380
+ return {
381
+ widget: /* @__PURE__ */ jsxs("div", {
382
+ className: "grid gap-2",
383
+ children: [/* @__PURE__ */ jsx("div", {
384
+ ref: container,
385
+ "data-testid": "turnstile"
386
+ }), failed ? /* @__PURE__ */ jsx("p", {
387
+ role: "status",
388
+ className: "text-sm text-destructive",
389
+ children: t("captcha.failed")
390
+ }) : null]
391
+ }),
392
+ ready: token !== null,
393
+ token: token ?? void 0,
394
+ reset
395
+ };
396
+ }
397
+ // src/use-public-auth-config.ts
398
+ /** Loads `GET {basePath}/public-config` once per client. */
399
+ function usePublicAuthConfig(client) {
400
+ const [state, setState] = useState({ status: "loading" });
401
+ useEffect(() => {
402
+ let active = true;
403
+ setState({ status: "loading" });
404
+ client.getPublicConfig().then((config) => {
405
+ if (active) setState({
406
+ status: "ready",
407
+ config
408
+ });
409
+ }, () => {
410
+ if (active) setState({ status: "error" });
411
+ });
412
+ return () => {
413
+ active = false;
414
+ };
415
+ }, [client]);
416
+ return state;
417
+ }
418
+ // src/phone-sign-in.tsx
419
+ function phoneError(error) {
420
+ switch (error.code) {
421
+ case "NETWORK_ERROR": return t("error.network");
422
+ case "INVALID_PHONE_NUMBER": return t("phone.invalidNumber");
423
+ case "INVALID_OTP": return t("phone.invalidCode");
424
+ case "OTP_EXPIRED":
425
+ case "OTP_NOT_FOUND": return t("phone.expired");
426
+ case "TOO_MANY_ATTEMPTS": return t("phone.tooManyAttempts");
427
+ case "REGISTRATION_NOT_ALLOWED": return t("phone.notAllowed");
428
+ case "BANNED_USER": return t("phone.banned");
429
+ case "PHONE_COUNTRY_NOT_ALLOWED": return t("phone.countryNotAllowed");
430
+ case "TOO_MANY_OTP_REQUESTS": return t("phone.tooManyRequests");
431
+ case "SMS_DAILY_CAP_REACHED": return t("phone.smsPaused");
432
+ case "RATE_LIMITED": return t("error.rateLimited");
433
+ case "MISSING_RESPONSE":
434
+ case "VERIFICATION_FAILED": return t("captcha.rejected");
435
+ default: return t("error.generic");
436
+ }
437
+ }
438
+ /**
439
+ * The second step of phone sign-in: the code from the SMS. Better Auth allows
440
+ * a few attempts per code; after that, or once it expires, the user sends a new one.
441
+ */
442
+ function PhoneOtpVerification({ client, phoneNumber, phoneOtp, onSignedIn, onResend, onChangeNumber }) {
443
+ const id = useId();
444
+ const [code, setCode] = useState("");
445
+ const [submitting, setSubmitting] = useState(false);
446
+ const [error, setError] = useState(null);
447
+ async function submit() {
448
+ setSubmitting(true);
449
+ setError(null);
450
+ const result = await client.phone.verify({
451
+ phoneNumber,
452
+ code: code.trim()
453
+ });
454
+ setSubmitting(false);
455
+ if (result.ok) onSignedIn();
456
+ else setError(phoneError(result.error));
457
+ }
458
+ return /* @__PURE__ */ jsxs("form", {
459
+ className: "grid gap-4",
460
+ noValidate: true,
461
+ onSubmit: (event) => {
462
+ event.preventDefault();
463
+ submit();
464
+ },
465
+ children: [
466
+ /* @__PURE__ */ jsx(Notice, { message: t("phone.codeSent", {
467
+ phoneNumber,
468
+ codeLength: String(phoneOtp.codeLength),
469
+ minutes: String(phoneOtp.expiresInMinutes)
470
+ }) }),
471
+ error === null ? null : /* @__PURE__ */ jsx(ErrorAlert, {
472
+ title: t("phone.failed"),
473
+ message: error
474
+ }),
475
+ /* @__PURE__ */ jsxs("div", {
476
+ className: "grid gap-2",
477
+ children: [/* @__PURE__ */ jsx(Label, {
478
+ htmlFor: `${id}-code`,
479
+ children: t("phone.code")
480
+ }), /* @__PURE__ */ jsx(Input, {
481
+ id: `${id}-code`,
482
+ name: "code",
483
+ autoComplete: "one-time-code",
484
+ inputMode: "numeric",
485
+ pattern: "[0-9]*",
486
+ maxLength: phoneOtp.codeLength,
487
+ required: true,
488
+ autoFocus: true,
489
+ value: code,
490
+ onChange: (event) => {
491
+ setCode(event.target.value);
492
+ },
493
+ "aria-invalid": error === null ? void 0 : true
494
+ })]
495
+ }),
496
+ /* @__PURE__ */ jsx(Button, {
497
+ type: "submit",
498
+ disabled: submitting,
499
+ children: submitting ? t("phone.verifying") : t("phone.verify")
500
+ }),
501
+ /* @__PURE__ */ jsx(Button, {
502
+ type: "button",
503
+ variant: "link",
504
+ className: "h-auto p-0",
505
+ onClick: onResend,
506
+ children: t("phone.resend")
507
+ }),
508
+ onChangeNumber === void 0 ? null : /* @__PURE__ */ jsx(Button, {
509
+ type: "button",
510
+ variant: "link",
511
+ className: "h-auto p-0",
512
+ onClick: onChangeNumber,
513
+ children: t("phone.changeNumber")
514
+ })
515
+ ]
516
+ });
517
+ }
518
+ /** Phone sign-in as a form: the number (unless fixed), then the code. */
519
+ function PhoneSignIn({ client, phoneOtp, onSignedIn, phoneNumber: fixed, captcha }) {
520
+ const [phoneNumber, setPhoneNumber] = useState(fixed ?? "");
521
+ const [sentTo, setSentTo] = useState(null);
522
+ const [sends, setSends] = useState(0);
523
+ const [error, setError] = useState(null);
524
+ const needsCheck = captcha?.endpoints.includes("phoneOtp") === true;
525
+ async function send(number, captchaToken) {
526
+ setError(null);
527
+ const result = await client.phone.sendOtp({
528
+ phoneNumber: number,
529
+ ...captchaToken === void 0 ? {} : { captchaToken }
530
+ });
531
+ if (result.ok) {
532
+ setSentTo(number);
533
+ setSends((count) => count + 1);
534
+ } else {
535
+ setSentTo(null);
536
+ setError(phoneError(result.error));
537
+ }
538
+ }
539
+ if (sentTo === null) return /* @__PURE__ */ jsx(PhoneNumberStep, {
540
+ phoneNumber,
541
+ onPhoneNumberChange: setPhoneNumber,
542
+ fixed: fixed !== void 0,
543
+ captcha,
544
+ error,
545
+ onSend: send
546
+ });
547
+ return /* @__PURE__ */ jsx(PhoneOtpVerification, {
548
+ client,
549
+ phoneNumber: sentTo,
550
+ phoneOtp,
551
+ onSignedIn,
552
+ onResend: () => {
553
+ if (needsCheck) setSentTo(null);
554
+ else send(sentTo);
555
+ },
556
+ ...fixed === void 0 ? { onChangeNumber: () => {
557
+ setSentTo(null);
558
+ } } : {}
559
+ }, sends);
560
+ }
561
+ /** The first step: the number (unless fixed) and, when on, the Turnstile check. */
562
+ function PhoneNumberStep({ phoneNumber, onPhoneNumberChange, fixed, captcha, error, onSend }) {
563
+ const id = useId();
564
+ const [sending, setSending] = useState(false);
565
+ const check = useTurnstile(captcha, "phoneOtp");
566
+ async function submit() {
567
+ setSending(true);
568
+ const token = check.token;
569
+ check.reset();
570
+ await onSend(phoneNumber.trim(), token);
571
+ setSending(false);
572
+ }
573
+ return /* @__PURE__ */ jsxs("form", {
574
+ className: "grid gap-4",
575
+ noValidate: true,
576
+ onSubmit: (event) => {
577
+ event.preventDefault();
578
+ submit();
579
+ },
580
+ children: [
581
+ error === null ? null : /* @__PURE__ */ jsx(ErrorAlert, {
582
+ title: t("phone.failed"),
583
+ message: error
584
+ }),
585
+ fixed ? null : /* @__PURE__ */ jsx(TextField, {
586
+ id: `${id}-phone`,
587
+ label: t("phone.number"),
588
+ name: "phone",
589
+ type: "tel",
590
+ autoComplete: "tel",
591
+ value: phoneNumber,
592
+ onChange: onPhoneNumberChange,
593
+ invalid: error !== null,
594
+ hint: t("phone.numberHint")
595
+ }),
596
+ check.widget,
597
+ /* @__PURE__ */ jsx(Button, {
598
+ type: "submit",
599
+ disabled: sending || !check.ready,
600
+ children: sending ? t("phone.sending") : t("phone.send")
601
+ })
602
+ ]
603
+ });
604
+ }
605
+ /**
606
+ * Phone sign-in screen (when the public config has `methods.phoneOtp`): enter
607
+ * an international number, receive a code by SMS, enter it. A number's first
608
+ * sign-in creates the account when registration is open or the number was
609
+ * invited; the screen never reveals whether a number has an account.
610
+ */
611
+ function PhoneSignInScreen({ client, onSignedIn, onSignIn }) {
612
+ const state = usePublicAuthConfig(client);
613
+ return /* @__PURE__ */ jsx(ConfigGate, {
614
+ state,
615
+ title: t("phone.title"),
616
+ ready: (config) => /* @__PURE__ */ jsx(Shell, {
617
+ title: t("phone.title"),
618
+ description: t("phone.description"),
619
+ children: /* @__PURE__ */ jsxs("div", {
620
+ className: "grid gap-4",
621
+ children: [config.methods.phoneOtp === void 0 ? /* @__PURE__ */ jsx("p", {
622
+ className: "text-sm text-muted-foreground",
623
+ children: t("signIn.noMethods")
624
+ }) : /* @__PURE__ */ jsx(PhoneSignIn, {
625
+ client,
626
+ phoneOtp: config.methods.phoneOtp,
627
+ onSignedIn,
628
+ captcha: config.captcha
629
+ }), onSignIn === void 0 ? null : /* @__PURE__ */ jsx("p", {
630
+ className: "text-center text-sm",
631
+ children: /* @__PURE__ */ jsx(Button, {
632
+ type: "button",
633
+ variant: "link",
634
+ className: "h-auto p-0",
635
+ onClick: onSignIn,
636
+ children: t("auth.backToSignIn")
637
+ })
638
+ })]
639
+ })
640
+ })
641
+ });
642
+ }
643
+ // src/accept-invitation-screen.tsx
644
+ function errorMessage$5(error, bounds) {
645
+ switch (error.code) {
646
+ case "NETWORK_ERROR": return t("error.network");
647
+ case "INVITATION_INVALID": return t("invitation.invalid");
648
+ case "INVALID_NAME": return t("invitation.nameRequired");
649
+ case "USER_ALREADY_EXISTS": return t("invitation.exists");
650
+ case "REGISTRATION_NOT_ALLOWED": return t("invitation.notAllowed");
651
+ case "PASSWORD_COMPROMISED": return t("resetPassword.compromised");
652
+ case "PASSWORD_TOO_SHORT": return bounds === void 0 ? error.message : t("resetPassword.tooShort", { minLength: String(bounds.minLength) });
653
+ case "PASSWORD_TOO_LONG": return bounds === void 0 ? error.message : t("resetPassword.tooLong", { maxLength: String(bounds.maxLength) });
654
+ default: return error.message || t("error.generic");
655
+ }
656
+ }
657
+ function AcceptForm({ client, onSignIn, token, bounds }) {
658
+ const id = useId();
659
+ const [name, setName] = useState("");
660
+ const [password, setPassword] = useState("");
661
+ const [confirm, setConfirm] = useState("");
662
+ const [submitting, setSubmitting] = useState(false);
663
+ const [error, setError] = useState(null);
664
+ const [done, setDone] = useState(false);
665
+ async function submit() {
666
+ const invalid = name.trim() === "" ? t("invitation.nameRequired") : bounds !== void 0 && password !== confirm ? t("resetPassword.mismatch") : null;
667
+ setError(invalid);
668
+ if (invalid !== null) return;
669
+ setSubmitting(true);
670
+ const result = await client.invitations.accept({
671
+ token,
672
+ name: name.trim(),
673
+ password
674
+ });
675
+ setSubmitting(false);
676
+ if (result.ok) setDone(true);
677
+ else setError(errorMessage$5(result.error, bounds));
678
+ }
679
+ if (done) return /* @__PURE__ */ jsxs("div", {
680
+ className: "grid gap-4",
681
+ children: [/* @__PURE__ */ jsx(Notice, { message: t("invitation.done") }), /* @__PURE__ */ jsx(Button, {
682
+ type: "button",
683
+ onClick: onSignIn,
684
+ children: t("auth.signIn")
685
+ })]
686
+ });
687
+ const hint = bounds === void 0 ? void 0 : t("signUp.passwordHint", {
688
+ minLength: String(bounds.minLength),
689
+ maxLength: String(bounds.maxLength)
690
+ });
691
+ return /* @__PURE__ */ jsxs("form", {
692
+ className: "grid gap-4",
693
+ noValidate: true,
694
+ onSubmit: (event) => {
695
+ event.preventDefault();
696
+ submit();
697
+ },
698
+ children: [
699
+ error === null ? null : /* @__PURE__ */ jsx(ErrorAlert, {
700
+ title: t("invitation.failed"),
701
+ message: error
702
+ }),
703
+ /* @__PURE__ */ jsx(TextField, {
704
+ id: `${id}-name`,
705
+ label: t("invitation.name"),
706
+ name: "name",
707
+ type: "text",
708
+ autoComplete: "name",
709
+ value: name,
710
+ onChange: setName,
711
+ invalid: error !== null
712
+ }),
713
+ bounds === void 0 ? null : /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(TextField, {
714
+ id: `${id}-password`,
715
+ label: t("invitation.password"),
716
+ name: "new-password",
717
+ type: "password",
718
+ autoComplete: "new-password",
719
+ value: password,
720
+ onChange: setPassword,
721
+ invalid: error !== null,
722
+ ...hint === void 0 ? {} : { hint }
723
+ }), /* @__PURE__ */ jsx(TextField, {
724
+ id: `${id}-confirm`,
725
+ label: t("invitation.confirmPassword"),
726
+ name: "confirm-password",
727
+ type: "password",
728
+ autoComplete: "new-password",
729
+ value: confirm,
730
+ onChange: setConfirm,
731
+ invalid: error !== null
732
+ })] }),
733
+ /* @__PURE__ */ jsx(Button, {
734
+ type: "submit",
735
+ disabled: submitting,
736
+ children: submitting ? t("invitation.submitting") : t("invitation.submit")
737
+ })
738
+ ]
739
+ });
740
+ }
741
+ /**
742
+ * Accepts an invitation from its emailed link (`?token=…`): shows who it is
743
+ * for, asks for a name and (when the password method is on) a password, and
744
+ * creates the account. Accepting does not sign in; it offers sign-in after.
745
+ * An invalid, expired, revoked or used link says so and creates nothing.
746
+ */
747
+ function AcceptInvitationScreen(props) {
748
+ const state = usePublicAuthConfig(props.client);
749
+ const token = queryParam(props.search, "token") ?? "";
750
+ const [lookup, setLookup] = useState(token === "" ? { state: "invalid" } : { state: "loading" });
751
+ useEffect(() => {
752
+ if (token === "") return void 0;
753
+ let active = true;
754
+ props.client.invitations.get({ token }).then((result) => {
755
+ if (active) setLookup(result.ok ? {
756
+ state: "ready",
757
+ invitation: result.data
758
+ } : { state: "invalid" });
759
+ });
760
+ return () => {
761
+ active = false;
762
+ };
763
+ }, [props.client, token]);
764
+ return /* @__PURE__ */ jsx(ConfigGate, {
765
+ state,
766
+ title: t("invitation.title"),
767
+ ready: (config) => {
768
+ const invitation = lookup.state === "ready" ? lookup.invitation : void 0;
769
+ const admin = invitation?.role === "admin";
770
+ const description = invitation === void 0 ? void 0 : invitation.email !== null ? t(admin ? "invitation.descriptionAdmin" : "invitation.description", {
771
+ appName: config.app.name,
772
+ email: invitation.email
773
+ }) : invitation.phoneNumber !== null ? t(admin ? "invitation.descriptionPhoneAdmin" : "invitation.descriptionPhone", {
774
+ appName: config.app.name,
775
+ phoneNumber: invitation.phoneNumber
776
+ }) : void 0;
777
+ const phoneOtp = config.methods.phoneOtp;
778
+ const phoneNumber = invitation?.email === null ? invitation.phoneNumber : null;
779
+ const { onSignedIn } = props;
780
+ return /* @__PURE__ */ jsx(Shell, {
781
+ title: t("invitation.title"),
782
+ ...description === void 0 ? {} : { description },
783
+ children: /* @__PURE__ */ jsxs("div", {
784
+ className: "grid gap-4",
785
+ children: [
786
+ lookup.state === "loading" ? /* @__PURE__ */ jsx("p", {
787
+ className: "text-sm text-muted-foreground",
788
+ children: t("invitation.loading")
789
+ }) : null,
790
+ lookup.state === "invalid" ? /* @__PURE__ */ jsx(ErrorAlert, { message: t("invitation.invalid") }) : null,
791
+ phoneNumber !== null && phoneOtp !== void 0 && onSignedIn !== void 0 ? /* @__PURE__ */ jsx(PhoneSignIn, {
792
+ client: props.client,
793
+ phoneOtp,
794
+ phoneNumber,
795
+ onSignedIn,
796
+ captcha: config.captcha
797
+ }) : invitation?.email === null ? /* @__PURE__ */ jsx(ErrorAlert, { message: t("invitation.phone") }) : null,
798
+ invitation?.email == null ? null : /* @__PURE__ */ jsx(AcceptForm, {
799
+ ...props,
800
+ token,
801
+ bounds: config.password
802
+ }),
803
+ /* @__PURE__ */ jsx("p", {
804
+ className: "text-center text-sm",
805
+ children: /* @__PURE__ */ jsx(Button, {
806
+ type: "button",
807
+ variant: "link",
808
+ className: "h-auto p-0",
809
+ onClick: props.onSignIn,
810
+ children: t("auth.backToSignIn")
811
+ })
812
+ })
813
+ ]
814
+ })
815
+ });
816
+ }
817
+ });
818
+ }
819
+ // src/forgot-password-screen.tsx
820
+ function errorMessage$4(error) {
821
+ if (error.code === "NETWORK_ERROR") return t("error.network");
822
+ if (error.code === "MISSING_RESPONSE" || error.code === "VERIFICATION_FAILED") return t("captcha.rejected");
823
+ return error.message || t("error.generic");
824
+ }
825
+ function ForgotPasswordForm({ client, resetPasswordURL, captcha, onSent }) {
826
+ const id = useId();
827
+ const [email, setEmail] = useState("");
828
+ const [submitting, setSubmitting] = useState(false);
829
+ const [error, setError] = useState(null);
830
+ const check = useTurnstile(captcha, "passwordReset");
831
+ async function submit() {
832
+ setSubmitting(true);
833
+ setError(null);
834
+ const captchaToken = check.token;
835
+ check.reset();
836
+ const result = await client.requestPasswordReset({
837
+ email,
838
+ redirectTo: resetPasswordURL ?? onThisOrigin("/reset-password"),
839
+ ...captchaToken === void 0 ? {} : { captchaToken }
840
+ });
841
+ setSubmitting(false);
842
+ if (result.ok) onSent();
843
+ else setError(errorMessage$4(result.error));
844
+ }
845
+ return /* @__PURE__ */ jsxs("form", {
846
+ className: "grid gap-4",
847
+ noValidate: true,
848
+ onSubmit: (event) => {
849
+ event.preventDefault();
850
+ submit();
851
+ },
852
+ children: [
853
+ error === null ? null : /* @__PURE__ */ jsx(ErrorAlert, {
854
+ title: t("forgotPassword.failed"),
855
+ message: error
856
+ }),
857
+ /* @__PURE__ */ jsx(TextField, {
858
+ id: `${id}-email`,
859
+ label: t("forgotPassword.email"),
860
+ name: "email",
861
+ type: "email",
862
+ autoComplete: "email",
863
+ value: email,
864
+ onChange: setEmail,
865
+ invalid: error !== null
866
+ }),
867
+ check.widget,
868
+ /* @__PURE__ */ jsx(Button, {
869
+ type: "submit",
870
+ disabled: submitting || !check.ready,
871
+ children: submitting ? t("forgotPassword.submitting") : t("forgotPassword.submit")
872
+ })
873
+ ]
874
+ });
875
+ }
876
+ /**
877
+ * Asks for an email address and sends a password-reset link. Always confirms
878
+ * in the same words, whether or not the address has an account. Renders the
879
+ * Turnstile widget when the server's public config asks for it.
880
+ */
881
+ function ForgotPasswordScreen({ client, onSignIn, resetPasswordURL }) {
882
+ const state = usePublicAuthConfig(client);
883
+ const [sent, setSent] = useState(false);
884
+ const back = /* @__PURE__ */ jsx("p", {
885
+ className: "text-center text-sm",
886
+ children: /* @__PURE__ */ jsx(Button, {
887
+ type: "button",
888
+ variant: "link",
889
+ className: "h-auto p-0",
890
+ onClick: onSignIn,
891
+ children: t("auth.backToSignIn")
892
+ })
893
+ });
894
+ return /* @__PURE__ */ jsx(ConfigGate, {
895
+ state,
896
+ title: t("forgotPassword.title"),
897
+ ready: (config) => /* @__PURE__ */ jsx(Shell, {
898
+ title: t("forgotPassword.title"),
899
+ description: t("forgotPassword.description"),
900
+ children: /* @__PURE__ */ jsxs("div", {
901
+ className: "grid gap-4",
902
+ children: [sent ? /* @__PURE__ */ jsx(Notice, { message: t("forgotPassword.sent") }) : /* @__PURE__ */ jsx(ForgotPasswordForm, {
903
+ client,
904
+ captcha: config.captcha,
905
+ onSent: () => {
906
+ setSent(true);
907
+ },
908
+ ...resetPasswordURL === void 0 ? {} : { resetPasswordURL }
909
+ }), back]
910
+ })
911
+ })
912
+ });
913
+ }
914
+ // src/reset-password-screen.tsx
915
+ function errorMessage$3(error, bounds) {
916
+ switch (error.code) {
917
+ case "NETWORK_ERROR": return t("error.network");
918
+ case "INVALID_TOKEN": return t("resetPassword.invalidLink");
919
+ case "PASSWORD_COMPROMISED": return t("resetPassword.compromised");
920
+ case "PASSWORD_TOO_SHORT": return bounds === void 0 ? error.message : t("resetPassword.tooShort", { minLength: String(bounds.minLength) });
921
+ case "PASSWORD_TOO_LONG": return bounds === void 0 ? error.message : t("resetPassword.tooLong", { maxLength: String(bounds.maxLength) });
922
+ default: return error.message || t("error.generic");
923
+ }
924
+ }
925
+ function ResetForm({ client, onSignIn, onRequestNewLink, token, bounds }) {
926
+ const id = useId();
927
+ const [password, setPassword] = useState("");
928
+ const [confirm, setConfirm] = useState("");
929
+ const [submitting, setSubmitting] = useState(false);
930
+ const [error, setError] = useState(null);
931
+ const [done, setDone] = useState(false);
932
+ async function submit() {
933
+ if (password !== confirm) {
934
+ setError({
935
+ message: t("resetPassword.mismatch"),
936
+ invalidLink: false
937
+ });
938
+ return;
939
+ }
940
+ setSubmitting(true);
941
+ setError(null);
942
+ const result = await client.resetPassword({
943
+ token,
944
+ newPassword: password
945
+ });
946
+ setSubmitting(false);
947
+ if (result.ok) setDone(true);
948
+ else setError({
949
+ message: errorMessage$3(result.error, bounds),
950
+ invalidLink: result.error.code === "INVALID_TOKEN"
951
+ });
952
+ }
953
+ if (done) return /* @__PURE__ */ jsxs("div", {
954
+ className: "grid gap-4",
955
+ children: [/* @__PURE__ */ jsx(Notice, { message: t("resetPassword.done") }), /* @__PURE__ */ jsx(Button, {
956
+ type: "button",
957
+ onClick: onSignIn,
958
+ children: t("auth.signIn")
959
+ })]
960
+ });
961
+ const hint = bounds === void 0 ? void 0 : t("signUp.passwordHint", {
962
+ minLength: String(bounds.minLength),
963
+ maxLength: String(bounds.maxLength)
964
+ });
965
+ return /* @__PURE__ */ jsxs("form", {
966
+ className: "grid gap-4",
967
+ noValidate: true,
968
+ onSubmit: (event) => {
969
+ event.preventDefault();
970
+ submit();
971
+ },
972
+ children: [
973
+ error === null ? null : /* @__PURE__ */ jsx(ErrorAlert, {
974
+ title: t("resetPassword.failed"),
975
+ message: error.message
976
+ }),
977
+ error?.invalidLink === true ? /* @__PURE__ */ jsx(Button, {
978
+ type: "button",
979
+ variant: "outline",
980
+ onClick: onRequestNewLink,
981
+ children: t("resetPassword.requestNew")
982
+ }) : null,
983
+ /* @__PURE__ */ jsx(TextField, {
984
+ id: `${id}-password`,
985
+ label: t("resetPassword.newPassword"),
986
+ name: "new-password",
987
+ type: "password",
988
+ autoComplete: "new-password",
989
+ value: password,
990
+ onChange: setPassword,
991
+ invalid: error !== null,
992
+ ...hint === void 0 ? {} : { hint }
993
+ }),
994
+ /* @__PURE__ */ jsx(TextField, {
995
+ id: `${id}-confirm`,
996
+ label: t("resetPassword.confirmPassword"),
997
+ name: "confirm-password",
998
+ type: "password",
999
+ autoComplete: "new-password",
1000
+ value: confirm,
1001
+ onChange: setConfirm,
1002
+ invalid: error !== null
1003
+ }),
1004
+ /* @__PURE__ */ jsx(Button, {
1005
+ type: "submit",
1006
+ disabled: submitting,
1007
+ children: submitting ? t("resetPassword.submitting") : t("resetPassword.submit")
1008
+ })
1009
+ ]
1010
+ });
1011
+ }
1012
+ /**
1013
+ * Sets a new password with the token from an emailed reset link
1014
+ * (`?token=…`). An expired or used link (`?error=INVALID_TOKEN`, or the
1015
+ * server rejecting the token) offers a new link instead.
1016
+ */
1017
+ function ResetPasswordScreen(props) {
1018
+ const state = usePublicAuthConfig(props.client);
1019
+ const token = queryParam(props.search, "token");
1020
+ const linkError = queryParam(props.search, "error");
1021
+ return /* @__PURE__ */ jsx(ConfigGate, {
1022
+ state,
1023
+ title: t("resetPassword.title"),
1024
+ ready: (config) => /* @__PURE__ */ jsx(Shell, {
1025
+ title: t("resetPassword.title"),
1026
+ children: /* @__PURE__ */ jsxs("div", {
1027
+ className: "grid gap-4",
1028
+ children: [token === null || token === "" || linkError !== null ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(ErrorAlert, { message: t("resetPassword.invalidLink") }), /* @__PURE__ */ jsx(Button, {
1029
+ type: "button",
1030
+ onClick: props.onRequestNewLink,
1031
+ children: t("resetPassword.requestNew")
1032
+ })] }) : /* @__PURE__ */ jsx(ResetForm, {
1033
+ ...props,
1034
+ token,
1035
+ bounds: config.password
1036
+ }), /* @__PURE__ */ jsx("p", {
1037
+ className: "text-center text-sm",
1038
+ children: /* @__PURE__ */ jsx(Button, {
1039
+ type: "button",
1040
+ variant: "link",
1041
+ className: "h-auto p-0",
1042
+ onClick: props.onSignIn,
1043
+ children: t("auth.backToSignIn")
1044
+ })
1045
+ })]
1046
+ })
1047
+ })
1048
+ });
1049
+ }
1050
+ // src/two-factor-challenge.tsx
1051
+ function challengeError(error, backup) {
1052
+ switch (error.code) {
1053
+ case "NETWORK_ERROR": return t("error.network");
1054
+ case "INVALID_CODE": return t("twoFactor.invalidCode");
1055
+ case "INVALID_BACKUP_CODE": return t("twoFactor.invalidBackupCode");
1056
+ case "INVALID_TWO_FACTOR_COOKIE": return t("twoFactor.challengeExpired");
1057
+ case "TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE": return t("twoFactor.tooManyAttempts");
1058
+ case "ACCOUNT_TEMPORARILY_LOCKED": return t("twoFactor.locked");
1059
+ default:
1060
+ if (error.status === 401) return backup ? t("twoFactor.invalidBackupCode") : t("twoFactor.invalidCode");
1061
+ return t("error.generic");
1062
+ }
1063
+ }
1064
+ /**
1065
+ * The second step of a password sign-in for a user with 2FA on: a code from
1066
+ * the authenticator app, or a single-use backup code instead. Rendered by
1067
+ * `SignInScreen` after the password is accepted; usable on its own too.
1068
+ */
1069
+ function TwoFactorChallenge({ client, onVerified }) {
1070
+ const id = useId();
1071
+ const [backup, setBackup] = useState(false);
1072
+ const [code, setCode] = useState("");
1073
+ const [submitting, setSubmitting] = useState(false);
1074
+ const [error, setError] = useState(null);
1075
+ async function submit() {
1076
+ setSubmitting(true);
1077
+ setError(null);
1078
+ const trimmed = code.trim();
1079
+ const result = backup ? await client.twoFactor.verifyBackupCode({ code: trimmed }) : await client.twoFactor.verifyTotp({ code: trimmed });
1080
+ setSubmitting(false);
1081
+ if (result.ok) onVerified();
1082
+ else setError(challengeError(result.error, backup));
1083
+ }
1084
+ function switchMode() {
1085
+ setBackup(!backup);
1086
+ setCode("");
1087
+ setError(null);
1088
+ }
1089
+ return /* @__PURE__ */ jsxs("form", {
1090
+ className: "grid gap-4",
1091
+ noValidate: true,
1092
+ onSubmit: (event) => {
1093
+ event.preventDefault();
1094
+ submit();
1095
+ },
1096
+ children: [
1097
+ /* @__PURE__ */ jsx("p", {
1098
+ className: "text-sm text-muted-foreground",
1099
+ children: backup ? t("twoFactor.challenge.backupDescription") : t("twoFactor.challenge.description")
1100
+ }),
1101
+ error === null ? null : /* @__PURE__ */ jsxs(Alert, {
1102
+ variant: "destructive",
1103
+ role: "alert",
1104
+ children: [/* @__PURE__ */ jsx(AlertTitle, { children: t("twoFactor.failed") }), /* @__PURE__ */ jsx(AlertDescription, { children: error })]
1105
+ }),
1106
+ /* @__PURE__ */ jsxs("div", {
1107
+ className: "grid gap-2",
1108
+ children: [/* @__PURE__ */ jsx(Label, {
1109
+ htmlFor: `${id}-code`,
1110
+ children: backup ? t("twoFactor.backupCode") : t("twoFactor.code")
1111
+ }), /* @__PURE__ */ jsx(Input, {
1112
+ id: `${id}-code`,
1113
+ name: "code",
1114
+ autoComplete: "one-time-code",
1115
+ ...backup ? {} : {
1116
+ inputMode: "numeric",
1117
+ pattern: "[0-9]*",
1118
+ maxLength: 6
1119
+ },
1120
+ required: true,
1121
+ autoFocus: true,
1122
+ value: code,
1123
+ onChange: (event) => {
1124
+ setCode(event.target.value);
1125
+ },
1126
+ "aria-invalid": error === null ? void 0 : true
1127
+ })]
1128
+ }),
1129
+ /* @__PURE__ */ jsx(Button, {
1130
+ type: "submit",
1131
+ disabled: submitting,
1132
+ children: submitting ? t("twoFactor.verifying") : t("twoFactor.verify")
1133
+ }),
1134
+ /* @__PURE__ */ jsx(Button, {
1135
+ type: "button",
1136
+ variant: "link",
1137
+ className: "h-auto p-0",
1138
+ onClick: switchMode,
1139
+ children: backup ? t("twoFactor.useAuthenticator") : t("twoFactor.useBackupCode")
1140
+ })
1141
+ ]
1142
+ });
1143
+ }
1144
+ // src/sign-in-screen.tsx
1145
+ function errorMessage$2(error) {
1146
+ if (error.code === "NETWORK_ERROR") return t("error.network");
1147
+ if (error.code === "EMAIL_NOT_VERIFIED") return t("signIn.emailNotVerified");
1148
+ if (error.status === 401) return t("signIn.invalidCredentials");
1149
+ if (error.code === "RATE_LIMITED") return t("error.rateLimited");
1150
+ if (error.code === "MISSING_RESPONSE" || error.code === "VERIFICATION_FAILED") return t("captcha.rejected");
1151
+ return error.message || t("error.generic");
1152
+ }
1153
+ function EmailPasswordForm({ client, onSignedIn, callbackURL, onTwoFactorRequired, onVerifyEmail, captcha }) {
1154
+ const id = useId();
1155
+ const [submitting, setSubmitting] = useState(false);
1156
+ const [error, setError] = useState(null);
1157
+ const [unverified, setUnverified] = useState(false);
1158
+ const [email, setEmail] = useState("");
1159
+ const [password, setPassword] = useState("");
1160
+ const check = useTurnstile(captcha, "signIn");
1161
+ async function submit() {
1162
+ setSubmitting(true);
1163
+ setError(null);
1164
+ const captchaToken = check.token;
1165
+ check.reset();
1166
+ const result = await client.signInWithEmail({
1167
+ email,
1168
+ password,
1169
+ ...callbackURL === void 0 ? {} : { callbackURL },
1170
+ ...captchaToken === void 0 ? {} : { captchaToken }
1171
+ });
1172
+ setSubmitting(false);
1173
+ if (!result.ok) {
1174
+ setError(errorMessage$2(result.error));
1175
+ setUnverified(result.error.code === "EMAIL_NOT_VERIFIED");
1176
+ } else if (result.twoFactor === void 0) onSignedIn();
1177
+ else onTwoFactorRequired();
1178
+ }
1179
+ return /* @__PURE__ */ jsxs("form", {
1180
+ className: "grid gap-4",
1181
+ noValidate: true,
1182
+ onSubmit: (event) => {
1183
+ event.preventDefault();
1184
+ submit();
1185
+ },
1186
+ children: [
1187
+ error === null ? null : /* @__PURE__ */ jsxs(Alert, {
1188
+ variant: "destructive",
1189
+ role: "alert",
1190
+ children: [/* @__PURE__ */ jsx(AlertTitle, { children: t("signIn.failed") }), /* @__PURE__ */ jsx(AlertDescription, { children: error })]
1191
+ }),
1192
+ unverified && onVerifyEmail !== void 0 ? /* @__PURE__ */ jsx(Button, {
1193
+ type: "button",
1194
+ variant: "outline",
1195
+ onClick: onVerifyEmail,
1196
+ children: t("signIn.resendVerification")
1197
+ }) : null,
1198
+ /* @__PURE__ */ jsxs("div", {
1199
+ className: "grid gap-2",
1200
+ children: [/* @__PURE__ */ jsx(Label, {
1201
+ htmlFor: `${id}-email`,
1202
+ children: t("signIn.email")
1203
+ }), /* @__PURE__ */ jsx(Input, {
1204
+ id: `${id}-email`,
1205
+ name: "email",
1206
+ type: "email",
1207
+ autoComplete: "username",
1208
+ required: true,
1209
+ value: email,
1210
+ onChange: (event) => {
1211
+ setEmail(event.target.value);
1212
+ },
1213
+ "aria-invalid": error === null ? void 0 : true
1214
+ })]
1215
+ }),
1216
+ /* @__PURE__ */ jsxs("div", {
1217
+ className: "grid gap-2",
1218
+ children: [/* @__PURE__ */ jsx(Label, {
1219
+ htmlFor: `${id}-password`,
1220
+ children: t("signIn.password")
1221
+ }), /* @__PURE__ */ jsx(Input, {
1222
+ id: `${id}-password`,
1223
+ name: "password",
1224
+ type: "password",
1225
+ autoComplete: "current-password",
1226
+ required: true,
1227
+ value: password,
1228
+ onChange: (event) => {
1229
+ setPassword(event.target.value);
1230
+ },
1231
+ "aria-invalid": error === null ? void 0 : true
1232
+ })]
1233
+ }),
1234
+ check.widget,
1235
+ /* @__PURE__ */ jsx(Button, {
1236
+ type: "submit",
1237
+ disabled: submitting || !check.ready,
1238
+ children: submitting ? t("signIn.submitting") : t("signIn.submit")
1239
+ })
1240
+ ]
1241
+ });
1242
+ }
1243
+ function SignInMethods(props) {
1244
+ const { config, onCreateAccount, onForgotPassword, onPhoneSignIn, ...formProps } = props;
1245
+ const phoneOtp = config.methods.phoneOtp;
1246
+ if (config.methods.emailPassword === void 0) {
1247
+ if (phoneOtp !== void 0) return /* @__PURE__ */ jsx(PhoneSignIn, {
1248
+ client: props.client,
1249
+ phoneOtp,
1250
+ onSignedIn: props.onSignedIn,
1251
+ captcha: config.captcha
1252
+ });
1253
+ return /* @__PURE__ */ jsx("p", {
1254
+ className: "text-sm text-muted-foreground",
1255
+ children: t("signIn.noMethods")
1256
+ });
1257
+ }
1258
+ return /* @__PURE__ */ jsxs("div", {
1259
+ className: "grid gap-4",
1260
+ children: [
1261
+ /* @__PURE__ */ jsx(EmailPasswordForm, {
1262
+ ...formProps,
1263
+ captcha: config.captcha
1264
+ }),
1265
+ phoneOtp !== void 0 && onPhoneSignIn !== void 0 ? /* @__PURE__ */ jsx(Button, {
1266
+ type: "button",
1267
+ variant: "outline",
1268
+ onClick: onPhoneSignIn,
1269
+ children: t("signIn.withPhone")
1270
+ }) : null,
1271
+ config.email?.passwordReset === true && onForgotPassword !== void 0 ? /* @__PURE__ */ jsx("p", {
1272
+ className: "text-center text-sm",
1273
+ children: /* @__PURE__ */ jsx(Button, {
1274
+ type: "button",
1275
+ variant: "link",
1276
+ className: "h-auto p-0",
1277
+ onClick: onForgotPassword,
1278
+ children: t("signIn.forgotPassword")
1279
+ })
1280
+ }) : null,
1281
+ config.registration.mode === "open" && onCreateAccount !== void 0 ? /* @__PURE__ */ jsxs("p", {
1282
+ className: "text-center text-sm text-muted-foreground",
1283
+ children: [
1284
+ t("signIn.noAccount"),
1285
+ " ",
1286
+ /* @__PURE__ */ jsx(Button, {
1287
+ type: "button",
1288
+ variant: "link",
1289
+ className: "h-auto p-0",
1290
+ onClick: onCreateAccount,
1291
+ children: t("signIn.createAccount")
1292
+ })
1293
+ ]
1294
+ }) : null
1295
+ ]
1296
+ });
1297
+ }
1298
+ /**
1299
+ * The sign-in screen. Renders only the methods the server's public config
1300
+ * enables (email + password, and phone OTP). For a user with 2FA on, the password is
1301
+ * followed by the 2FA challenge (authenticator code or backup code).
1302
+ */
1303
+ function SignInScreen(props) {
1304
+ const state = usePublicAuthConfig(props.client);
1305
+ const [twoFactor, setTwoFactor] = useState(false);
1306
+ if (twoFactor) return /* @__PURE__ */ jsx(Shell, {
1307
+ title: t("twoFactor.title"),
1308
+ children: /* @__PURE__ */ jsx(TwoFactorChallenge, {
1309
+ client: props.client,
1310
+ onVerified: props.onSignedIn
1311
+ })
1312
+ });
1313
+ return /* @__PURE__ */ jsx(ConfigGate, {
1314
+ state,
1315
+ title: t("signIn.title"),
1316
+ ready: (config) => /* @__PURE__ */ jsx(Shell, {
1317
+ title: t("signIn.title"),
1318
+ description: t(config.methods.emailPassword === void 0 && config.methods.phoneOtp !== void 0 ? "signIn.descriptionPhone" : "signIn.description", { appName: config.app.name }),
1319
+ children: /* @__PURE__ */ jsx(SignInMethods, {
1320
+ ...props,
1321
+ config,
1322
+ onTwoFactorRequired: () => {
1323
+ setTwoFactor(true);
1324
+ }
1325
+ })
1326
+ })
1327
+ });
1328
+ }
1329
+ // src/sign-up-screen.tsx
1330
+ function errorMessage$1(error) {
1331
+ if (error.code === "NETWORK_ERROR") return t("error.network");
1332
+ if (error.code === "MISSING_RESPONSE" || error.code === "VERIFICATION_FAILED") return t("captcha.rejected");
1333
+ return error.message || t("error.generic");
1334
+ }
1335
+ function Field(props) {
1336
+ return /* @__PURE__ */ jsxs("div", {
1337
+ className: "grid gap-2",
1338
+ children: [
1339
+ /* @__PURE__ */ jsx(Label, {
1340
+ htmlFor: props.id,
1341
+ children: props.label
1342
+ }),
1343
+ /* @__PURE__ */ jsx(Input, {
1344
+ id: props.id,
1345
+ name: props.name,
1346
+ type: props.type,
1347
+ autoComplete: props.autoComplete,
1348
+ required: true,
1349
+ value: props.value,
1350
+ onChange: (event) => {
1351
+ props.onChange(event.target.value);
1352
+ },
1353
+ "aria-invalid": props.invalid ? true : void 0,
1354
+ "aria-describedby": props.hint === void 0 ? void 0 : `${props.id}-hint`
1355
+ }),
1356
+ props.hint === void 0 ? null : /* @__PURE__ */ jsx("p", {
1357
+ id: `${props.id}-hint`,
1358
+ className: "text-xs text-muted-foreground",
1359
+ children: props.hint
1360
+ })
1361
+ ]
1362
+ });
1363
+ }
1364
+ function SignUpForm({ client, onSignedUp, callbackURL, config }) {
1365
+ const id = useId();
1366
+ const [submitting, setSubmitting] = useState(false);
1367
+ const [error, setError] = useState(null);
1368
+ const [name, setName] = useState("");
1369
+ const [email, setEmail] = useState("");
1370
+ const [password, setPassword] = useState("");
1371
+ const check = useTurnstile(config.captcha, "signUp");
1372
+ async function submit() {
1373
+ setSubmitting(true);
1374
+ setError(null);
1375
+ const captchaToken = check.token;
1376
+ check.reset();
1377
+ const result = await client.signUpWithEmail({
1378
+ name,
1379
+ email,
1380
+ password,
1381
+ ...callbackURL === void 0 ? {} : { callbackURL },
1382
+ ...captchaToken === void 0 ? {} : { captchaToken }
1383
+ });
1384
+ setSubmitting(false);
1385
+ if (result.ok) onSignedUp();
1386
+ else setError(errorMessage$1(result.error));
1387
+ }
1388
+ const invalid = error !== null;
1389
+ const bounds = config.password;
1390
+ return /* @__PURE__ */ jsxs("form", {
1391
+ className: "grid gap-4",
1392
+ noValidate: true,
1393
+ onSubmit: (event) => {
1394
+ event.preventDefault();
1395
+ submit();
1396
+ },
1397
+ children: [
1398
+ error === null ? null : /* @__PURE__ */ jsxs(Alert, {
1399
+ variant: "destructive",
1400
+ role: "alert",
1401
+ children: [/* @__PURE__ */ jsx(AlertTitle, { children: t("signUp.failed") }), /* @__PURE__ */ jsx(AlertDescription, { children: error })]
1402
+ }),
1403
+ /* @__PURE__ */ jsx(Field, {
1404
+ id: `${id}-name`,
1405
+ label: t("signUp.name"),
1406
+ name: "name",
1407
+ type: "text",
1408
+ autoComplete: "name",
1409
+ value: name,
1410
+ onChange: setName,
1411
+ invalid
1412
+ }),
1413
+ /* @__PURE__ */ jsx(Field, {
1414
+ id: `${id}-email`,
1415
+ label: t("signUp.email"),
1416
+ name: "email",
1417
+ type: "email",
1418
+ autoComplete: "email",
1419
+ value: email,
1420
+ onChange: setEmail,
1421
+ invalid
1422
+ }),
1423
+ /* @__PURE__ */ jsx(Field, {
1424
+ id: `${id}-password`,
1425
+ label: t("signUp.password"),
1426
+ name: "password",
1427
+ type: "password",
1428
+ autoComplete: "new-password",
1429
+ value: password,
1430
+ onChange: setPassword,
1431
+ invalid,
1432
+ ...bounds === void 0 ? {} : { hint: t("signUp.passwordHint", {
1433
+ minLength: String(bounds.minLength),
1434
+ maxLength: String(bounds.maxLength)
1435
+ }) }
1436
+ }),
1437
+ check.widget,
1438
+ /* @__PURE__ */ jsx(Button, {
1439
+ type: "submit",
1440
+ disabled: submitting || !check.ready,
1441
+ children: submitting ? t("signUp.submitting") : t("signUp.submit")
1442
+ })
1443
+ ]
1444
+ });
1445
+ }
1446
+ /**
1447
+ * The register screen. Shows the form only when the server's public config says
1448
+ * registration is `open` and email + password is enabled; otherwise it explains
1449
+ * that registration is not open. The server enforces the policy either way.
1450
+ */
1451
+ function SignUpScreen(props) {
1452
+ const state = usePublicAuthConfig(props.client);
1453
+ const backToSignIn = /* @__PURE__ */ jsx(Button, {
1454
+ type: "button",
1455
+ variant: "link",
1456
+ className: "h-auto p-0",
1457
+ onClick: props.onSignIn,
1458
+ children: t("signUp.backToSignIn")
1459
+ });
1460
+ return /* @__PURE__ */ jsx(ConfigGate, {
1461
+ state,
1462
+ title: t("signUp.title"),
1463
+ ready: (config) => config.registration.mode === "open" && config.methods.emailPassword !== void 0 ? /* @__PURE__ */ jsx(Shell, {
1464
+ title: t("signUp.title"),
1465
+ description: t("signUp.description", { appName: config.app.name }),
1466
+ children: /* @__PURE__ */ jsxs("div", {
1467
+ className: "grid gap-4",
1468
+ children: [/* @__PURE__ */ jsx(SignUpForm, {
1469
+ ...props,
1470
+ config
1471
+ }), /* @__PURE__ */ jsx("p", {
1472
+ className: "text-center text-sm",
1473
+ children: backToSignIn
1474
+ })]
1475
+ })
1476
+ }) : /* @__PURE__ */ jsx(Shell, {
1477
+ title: t("signUp.title"),
1478
+ children: /* @__PURE__ */ jsxs("div", {
1479
+ className: "grid gap-4",
1480
+ children: [/* @__PURE__ */ jsxs(Alert, { children: [/* @__PURE__ */ jsx(AlertTitle, { children: t("signUp.closedTitle") }), /* @__PURE__ */ jsx(AlertDescription, { children: t("signUp.closed", { appName: config.app.name }) })] }), /* @__PURE__ */ jsx("p", {
1481
+ className: "text-center text-sm",
1482
+ children: backToSignIn
1483
+ })]
1484
+ })
1485
+ })
1486
+ });
1487
+ }
1488
+ // src/verify-email-screen.tsx
1489
+ function errorMessage(error) {
1490
+ if (error.code === "NETWORK_ERROR") return t("error.network");
1491
+ return error.message || t("error.generic");
1492
+ }
1493
+ function ResendForm({ client, callbackURL }) {
1494
+ const id = useId();
1495
+ const [email, setEmail] = useState("");
1496
+ const [submitting, setSubmitting] = useState(false);
1497
+ const [sent, setSent] = useState(false);
1498
+ const [error, setError] = useState(null);
1499
+ async function submit() {
1500
+ setSubmitting(true);
1501
+ setError(null);
1502
+ const result = await client.sendVerificationEmail({
1503
+ email,
1504
+ callbackURL: callbackURL ?? onThisOrigin("/verify-email?verified=1")
1505
+ });
1506
+ setSubmitting(false);
1507
+ if (result.ok) setSent(true);
1508
+ else setError(errorMessage(result.error));
1509
+ }
1510
+ if (sent) return /* @__PURE__ */ jsx(Notice, { message: t("verifyEmail.sent") });
1511
+ return /* @__PURE__ */ jsxs("form", {
1512
+ className: "grid gap-4",
1513
+ noValidate: true,
1514
+ onSubmit: (event) => {
1515
+ event.preventDefault();
1516
+ submit();
1517
+ },
1518
+ children: [
1519
+ error === null ? null : /* @__PURE__ */ jsx(ErrorAlert, {
1520
+ title: t("verifyEmail.failed"),
1521
+ message: error
1522
+ }),
1523
+ /* @__PURE__ */ jsx(TextField, {
1524
+ id: `${id}-email`,
1525
+ label: t("verifyEmail.email"),
1526
+ name: "email",
1527
+ type: "email",
1528
+ autoComplete: "email",
1529
+ value: email,
1530
+ onChange: setEmail,
1531
+ invalid: error !== null
1532
+ }),
1533
+ /* @__PURE__ */ jsx(Button, {
1534
+ type: "submit",
1535
+ disabled: submitting,
1536
+ children: submitting ? t("verifyEmail.submitting") : t("verifyEmail.submit")
1537
+ })
1538
+ ]
1539
+ });
1540
+ }
1541
+ /**
1542
+ * Where verification links land, and where to send users after sign-up
1543
+ * (`?sent=1`: "check your inbox"). Shows success (`?verified=1`), explains an
1544
+ * expired or invalid link (`?error=…`), and otherwise lets the user ask for a
1545
+ * new link. Requesting a link answers the same whether or not the address has
1546
+ * an account.
1547
+ */
1548
+ function VerifyEmailScreen(props) {
1549
+ const linkError = queryParam(props.search, "error");
1550
+ const verified = queryParam(props.search, "verified") === "1";
1551
+ const justSent = queryParam(props.search, "sent") === "1";
1552
+ if (verified && linkError === null) return /* @__PURE__ */ jsx(Shell, {
1553
+ title: t("verifyEmail.verifiedTitle"),
1554
+ children: /* @__PURE__ */ jsxs("div", {
1555
+ className: "grid gap-4",
1556
+ children: [/* @__PURE__ */ jsx(Notice, { message: t("verifyEmail.verified") }), /* @__PURE__ */ jsx(Button, {
1557
+ type: "button",
1558
+ onClick: props.onSignIn,
1559
+ children: t("verifyEmail.continue")
1560
+ })]
1561
+ })
1562
+ });
1563
+ return /* @__PURE__ */ jsx(Shell, {
1564
+ title: t("verifyEmail.title"),
1565
+ description: t("verifyEmail.description"),
1566
+ children: /* @__PURE__ */ jsxs("div", {
1567
+ className: "grid gap-4",
1568
+ children: [
1569
+ linkError === null ? null : /* @__PURE__ */ jsx(ErrorAlert, { message: t("verifyEmail.invalid") }),
1570
+ justSent && linkError === null ? /* @__PURE__ */ jsx(Notice, { message: t("verifyEmail.checkInbox") }) : null,
1571
+ /* @__PURE__ */ jsx(ResendForm, { ...props }),
1572
+ /* @__PURE__ */ jsx("p", {
1573
+ className: "text-center text-sm",
1574
+ children: /* @__PURE__ */ jsx(Button, {
1575
+ type: "button",
1576
+ variant: "link",
1577
+ className: "h-auto p-0",
1578
+ onClick: props.onSignIn,
1579
+ children: t("auth.backToSignIn")
1580
+ })
1581
+ })
1582
+ ]
1583
+ })
1584
+ });
1585
+ }
1586
+ // src/two-factor-steps.tsx
1587
+ function failure$2(error, fallback = "error.generic") {
1588
+ if (error.code === "SESSION_NOT_FRESH") return {
1589
+ message: t("twoFactor.notFresh"),
1590
+ notFresh: true
1591
+ };
1592
+ if (error.code === "NETWORK_ERROR") return {
1593
+ message: t("error.network"),
1594
+ notFresh: false
1595
+ };
1596
+ if (error.code === "INVALID_PASSWORD") return {
1597
+ message: t("twoFactor.invalidPassword"),
1598
+ notFresh: false
1599
+ };
1600
+ if (error.code === "INVALID_CODE") return {
1601
+ message: t("twoFactor.invalidCode"),
1602
+ notFresh: false
1603
+ };
1604
+ return {
1605
+ message: t(fallback),
1606
+ notFresh: false
1607
+ };
1608
+ }
1609
+ function FailureAlert({ failed, onReauthenticate }) {
1610
+ return /* @__PURE__ */ jsxs(Alert, {
1611
+ variant: "destructive",
1612
+ role: "alert",
1613
+ children: [/* @__PURE__ */ jsx(AlertTitle, { children: t("twoFactor.settings.failed") }), /* @__PURE__ */ jsxs(AlertDescription, { children: [failed.message, failed.notFresh && onReauthenticate !== void 0 ? /* @__PURE__ */ jsx(Button, {
1614
+ type: "button",
1615
+ variant: "link",
1616
+ className: "h-auto p-0",
1617
+ onClick: onReauthenticate,
1618
+ children: t("twoFactor.signInAgain")
1619
+ }) : null] })]
1620
+ });
1621
+ }
1622
+ const PASSWORD_PROMPT = {
1623
+ setUp: {
1624
+ prompt: "twoFactor.password.setUp",
1625
+ submit: "twoFactor.continue"
1626
+ },
1627
+ regenerate: {
1628
+ prompt: "twoFactor.password.regenerate",
1629
+ submit: "twoFactor.regenerateSubmit"
1630
+ },
1631
+ disable: {
1632
+ prompt: "twoFactor.password.disable",
1633
+ submit: "twoFactor.disableSubmit"
1634
+ }
1635
+ };
1636
+ /** Asks for the current password before a 2FA change. */
1637
+ function PasswordStep({ purpose, onSubmit, onCancel, onReauthenticate }) {
1638
+ const id = useId();
1639
+ const [password, setPassword] = useState("");
1640
+ const [submitting, setSubmitting] = useState(false);
1641
+ const [failed, setFailed] = useState(null);
1642
+ const { prompt, submit } = PASSWORD_PROMPT[purpose];
1643
+ return /* @__PURE__ */ jsxs("form", {
1644
+ className: "grid gap-4",
1645
+ noValidate: true,
1646
+ onSubmit: (event) => {
1647
+ event.preventDefault();
1648
+ setSubmitting(true);
1649
+ setFailed(null);
1650
+ onSubmit(password).then((result) => {
1651
+ setSubmitting(false);
1652
+ setFailed(result);
1653
+ });
1654
+ },
1655
+ children: [
1656
+ /* @__PURE__ */ jsx("p", {
1657
+ className: "text-sm text-muted-foreground",
1658
+ children: t(prompt)
1659
+ }),
1660
+ failed === null ? null : /* @__PURE__ */ jsx(FailureAlert, {
1661
+ failed,
1662
+ onReauthenticate
1663
+ }),
1664
+ /* @__PURE__ */ jsxs("div", {
1665
+ className: "grid gap-2",
1666
+ children: [/* @__PURE__ */ jsx(Label, {
1667
+ htmlFor: `${id}-password`,
1668
+ children: t("twoFactor.password")
1669
+ }), /* @__PURE__ */ jsx(Input, {
1670
+ id: `${id}-password`,
1671
+ name: "password",
1672
+ type: "password",
1673
+ autoComplete: "current-password",
1674
+ required: true,
1675
+ autoFocus: true,
1676
+ value: password,
1677
+ onChange: (event) => {
1678
+ setPassword(event.target.value);
1679
+ },
1680
+ "aria-invalid": failed === null ? void 0 : true
1681
+ })]
1682
+ }),
1683
+ /* @__PURE__ */ jsxs("div", {
1684
+ className: "flex gap-2",
1685
+ children: [/* @__PURE__ */ jsx(Button, {
1686
+ type: "submit",
1687
+ disabled: submitting,
1688
+ children: submitting ? t("twoFactor.working") : t(submit)
1689
+ }), /* @__PURE__ */ jsx(Button, {
1690
+ type: "button",
1691
+ variant: "outline",
1692
+ onClick: onCancel,
1693
+ children: t("twoFactor.cancel")
1694
+ })]
1695
+ })
1696
+ ]
1697
+ });
1698
+ }
1699
+ /** QR code and manual key for the authenticator app, then the first code. */
1700
+ function VerifyStep({ totpURI, onVerify, onCancel }) {
1701
+ const id = useId();
1702
+ const [code, setCode] = useState("");
1703
+ const [submitting, setSubmitting] = useState(false);
1704
+ const [failed, setFailed] = useState(null);
1705
+ const qr = useMemo(() => `data:image/svg+xml;charset=utf-8,${encodeURIComponent(renderSVG(totpURI, { border: 2 }))}`, [totpURI]);
1706
+ const secret = useMemo(() => new URL(totpURI).searchParams.get("secret") ?? "", [totpURI]);
1707
+ return /* @__PURE__ */ jsxs("form", {
1708
+ className: "grid gap-4",
1709
+ noValidate: true,
1710
+ onSubmit: (event) => {
1711
+ event.preventDefault();
1712
+ setSubmitting(true);
1713
+ setFailed(null);
1714
+ onVerify(code.trim()).then((result) => {
1715
+ setSubmitting(false);
1716
+ setFailed(result);
1717
+ });
1718
+ },
1719
+ children: [
1720
+ /* @__PURE__ */ jsx("p", {
1721
+ className: "text-sm text-muted-foreground",
1722
+ children: t("twoFactor.enrol.scan")
1723
+ }),
1724
+ /* @__PURE__ */ jsx("img", {
1725
+ src: qr,
1726
+ alt: t("twoFactor.enrol.qrAlt"),
1727
+ width: 192,
1728
+ height: 192,
1729
+ className: "mx-auto bg-white"
1730
+ }),
1731
+ /* @__PURE__ */ jsxs("div", {
1732
+ className: "grid gap-1 text-sm",
1733
+ children: [/* @__PURE__ */ jsx("span", {
1734
+ className: "text-muted-foreground",
1735
+ children: t("twoFactor.enrol.manual")
1736
+ }), /* @__PURE__ */ jsx("code", {
1737
+ className: "break-all font-mono",
1738
+ "data-testid": "totp-secret",
1739
+ children: secret
1740
+ })]
1741
+ }),
1742
+ failed === null ? null : /* @__PURE__ */ jsx(FailureAlert, { failed }),
1743
+ /* @__PURE__ */ jsxs("div", {
1744
+ className: "grid gap-2",
1745
+ children: [/* @__PURE__ */ jsx(Label, {
1746
+ htmlFor: `${id}-code`,
1747
+ children: t("twoFactor.code")
1748
+ }), /* @__PURE__ */ jsx(Input, {
1749
+ id: `${id}-code`,
1750
+ name: "code",
1751
+ autoComplete: "one-time-code",
1752
+ inputMode: "numeric",
1753
+ pattern: "[0-9]*",
1754
+ maxLength: 6,
1755
+ required: true,
1756
+ value: code,
1757
+ onChange: (event) => {
1758
+ setCode(event.target.value);
1759
+ },
1760
+ "aria-invalid": failed === null ? void 0 : true
1761
+ })]
1762
+ }),
1763
+ /* @__PURE__ */ jsxs("div", {
1764
+ className: "flex gap-2",
1765
+ children: [/* @__PURE__ */ jsx(Button, {
1766
+ type: "submit",
1767
+ disabled: submitting,
1768
+ children: submitting ? t("twoFactor.verifying") : t("twoFactor.enrol.submit")
1769
+ }), /* @__PURE__ */ jsx(Button, {
1770
+ type: "button",
1771
+ variant: "outline",
1772
+ onClick: onCancel,
1773
+ children: t("twoFactor.cancel")
1774
+ })]
1775
+ })
1776
+ ]
1777
+ });
1778
+ }
1779
+ function BackupCodesStep({ backupCodes, onDone }) {
1780
+ return /* @__PURE__ */ jsxs("div", {
1781
+ className: "grid gap-4",
1782
+ children: [
1783
+ /* @__PURE__ */ jsx("p", {
1784
+ className: "text-sm text-muted-foreground",
1785
+ children: t("twoFactor.backupCodes.description")
1786
+ }),
1787
+ /* @__PURE__ */ jsx("ul", {
1788
+ "aria-label": t("twoFactor.backupCodes.list"),
1789
+ className: "grid grid-cols-2 gap-2 font-mono text-sm",
1790
+ children: backupCodes.map((code) => /* @__PURE__ */ jsx("li", { children: code }, code))
1791
+ }),
1792
+ /* @__PURE__ */ jsx(Button, {
1793
+ type: "button",
1794
+ onClick: onDone,
1795
+ children: t("twoFactor.backupCodes.done")
1796
+ })
1797
+ ]
1798
+ });
1799
+ }
1800
+ function StatusStep({ enabled, onChoose }) {
1801
+ return /* @__PURE__ */ jsxs("div", {
1802
+ className: "grid gap-4",
1803
+ children: [/* @__PURE__ */ jsx("p", {
1804
+ className: "text-sm font-medium",
1805
+ children: enabled ? t("twoFactor.settings.on") : t("twoFactor.settings.off")
1806
+ }), enabled ? /* @__PURE__ */ jsxs("div", {
1807
+ className: "grid gap-2",
1808
+ children: [/* @__PURE__ */ jsx(Button, {
1809
+ type: "button",
1810
+ variant: "outline",
1811
+ onClick: () => {
1812
+ onChoose("regenerate");
1813
+ },
1814
+ children: t("twoFactor.settings.regenerate")
1815
+ }), /* @__PURE__ */ jsx(Button, {
1816
+ type: "button",
1817
+ variant: "destructive",
1818
+ onClick: () => {
1819
+ onChoose("disable");
1820
+ },
1821
+ children: t("twoFactor.settings.disable")
1822
+ })]
1823
+ }) : /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("p", {
1824
+ className: "text-sm text-muted-foreground",
1825
+ children: t("twoFactor.settings.explainer")
1826
+ }), /* @__PURE__ */ jsx(Button, {
1827
+ type: "button",
1828
+ onClick: () => {
1829
+ onChoose("setUp");
1830
+ },
1831
+ children: t("twoFactor.settings.setUp")
1832
+ })] })]
1833
+ });
1834
+ }
1835
+ // src/two-factor-settings-screen.tsx
1836
+ /** Loads whether the signed-in user has 2FA on (`null` while loading, `"error"` when unknown). */
1837
+ function useTwoFactorEnabled(client) {
1838
+ const [enabled, setEnabled] = useState(null);
1839
+ const [version, setVersion] = useState(0);
1840
+ useEffect(() => {
1841
+ let active = true;
1842
+ client.getSession().then((current) => {
1843
+ if (active) setEnabled(current === null ? "error" : current.user.twoFactorEnabled);
1844
+ }, () => {
1845
+ if (active) setEnabled("error");
1846
+ });
1847
+ return () => {
1848
+ active = false;
1849
+ };
1850
+ }, [client, version]);
1851
+ return [enabled, useCallback(() => {
1852
+ setVersion((v) => v + 1);
1853
+ }, [])];
1854
+ }
1855
+ function TwoFactorSettings({ client, onReauthenticate }) {
1856
+ const [enabled, reload] = useTwoFactorEnabled(client);
1857
+ const [step, setStep] = useState({ kind: "status" });
1858
+ const backToStatus = () => {
1859
+ setStep({ kind: "status" });
1860
+ reload();
1861
+ };
1862
+ if (step.kind === "codes") return /* @__PURE__ */ jsx(Shell, {
1863
+ title: t("twoFactor.backupCodes.title"),
1864
+ children: /* @__PURE__ */ jsx(BackupCodesStep, {
1865
+ backupCodes: step.backupCodes,
1866
+ onDone: backToStatus
1867
+ })
1868
+ });
1869
+ let body;
1870
+ if (step.kind === "password") {
1871
+ const purpose = step.purpose;
1872
+ body = /* @__PURE__ */ jsx(PasswordStep, {
1873
+ purpose,
1874
+ onReauthenticate,
1875
+ onCancel: backToStatus,
1876
+ onSubmit: async (password) => {
1877
+ if (purpose === "setUp") {
1878
+ const result = await client.twoFactor.enable({ password });
1879
+ if (!result.ok) return failure$2(result.error);
1880
+ setStep({
1881
+ kind: "verify",
1882
+ ...result.data
1883
+ });
1884
+ } else if (purpose === "regenerate") {
1885
+ const result = await client.twoFactor.regenerateBackupCodes({ password });
1886
+ if (!result.ok) return failure$2(result.error);
1887
+ setStep({
1888
+ kind: "codes",
1889
+ backupCodes: result.data.backupCodes
1890
+ });
1891
+ } else {
1892
+ const result = await client.twoFactor.disable({ password });
1893
+ if (!result.ok) return failure$2(result.error);
1894
+ backToStatus();
1895
+ }
1896
+ return null;
1897
+ }
1898
+ }, purpose);
1899
+ } else if (step.kind === "verify") {
1900
+ const { totpURI, backupCodes } = step;
1901
+ body = /* @__PURE__ */ jsx(VerifyStep, {
1902
+ totpURI,
1903
+ onCancel: backToStatus,
1904
+ onVerify: async (code) => {
1905
+ const result = await client.twoFactor.verifyTotp({ code });
1906
+ if (!result.ok) return failure$2(result.error.status === 401 ? {
1907
+ ...result.error,
1908
+ code: "INVALID_CODE"
1909
+ } : result.error);
1910
+ setStep({
1911
+ kind: "codes",
1912
+ backupCodes
1913
+ });
1914
+ return null;
1915
+ }
1916
+ });
1917
+ } else if (enabled === null) body = /* @__PURE__ */ jsx("p", {
1918
+ className: "text-sm text-muted-foreground",
1919
+ "aria-live": "polite",
1920
+ children: t("twoFactor.settings.loading")
1921
+ });
1922
+ else if (enabled === "error") body = /* @__PURE__ */ jsx(Alert, {
1923
+ variant: "destructive",
1924
+ role: "alert",
1925
+ children: /* @__PURE__ */ jsx(AlertDescription, { children: t("error.generic") })
1926
+ });
1927
+ else body = /* @__PURE__ */ jsx(StatusStep, {
1928
+ enabled,
1929
+ onChoose: (purpose) => {
1930
+ setStep({
1931
+ kind: "password",
1932
+ purpose
1933
+ });
1934
+ }
1935
+ });
1936
+ return /* @__PURE__ */ jsx(Shell, {
1937
+ title: t("twoFactor.title"),
1938
+ children: body
1939
+ });
1940
+ }
1941
+ /**
1942
+ * Account security: shows whether 2FA is on and lets the signed-in user set up
1943
+ * an authenticator app (QR code, verify, backup codes), regenerate the backup
1944
+ * codes, or turn 2FA off. Every change asks for the current password, and
1945
+ * regenerating or turning off also needs a recent sign-in.
1946
+ */
1947
+ function TwoFactorSettingsScreen(props) {
1948
+ const state = usePublicAuthConfig(props.client);
1949
+ return /* @__PURE__ */ jsx(ConfigGate, {
1950
+ state,
1951
+ title: t("twoFactor.title"),
1952
+ ready: (config) => config.twoFactor === void 0 ? /* @__PURE__ */ jsx(Shell, {
1953
+ title: t("twoFactor.title"),
1954
+ children: /* @__PURE__ */ jsx("p", {
1955
+ className: "text-sm text-muted-foreground",
1956
+ children: t("twoFactor.settings.unavailable")
1957
+ })
1958
+ }) : /* @__PURE__ */ jsx(TwoFactorSettings, { ...props })
1959
+ });
1960
+ }
1961
+ // src/delete-account-screen.tsx
1962
+ function failure$1(error) {
1963
+ if (error.code === "SESSION_NOT_FRESH") return {
1964
+ message: t("deleteAccount.notFresh"),
1965
+ notFresh: true
1966
+ };
1967
+ if (error.code === "NETWORK_ERROR") return {
1968
+ message: t("error.network"),
1969
+ notFresh: false
1970
+ };
1971
+ return {
1972
+ message: t("error.generic"),
1973
+ notFresh: false
1974
+ };
1975
+ }
1976
+ function DeleteAccountForm({ client, onDeleted, onCancel, onReauthenticate, config, graceDays }) {
1977
+ const id = useId();
1978
+ const [confirmation, setConfirmation] = useState("");
1979
+ const [submitting, setSubmitting] = useState(false);
1980
+ const [failed, setFailed] = useState(null);
1981
+ const [done, setDone] = useState(null);
1982
+ if (done !== null) return /* @__PURE__ */ jsxs("div", {
1983
+ className: "grid gap-4",
1984
+ children: [/* @__PURE__ */ jsx(Alert, {
1985
+ role: "status",
1986
+ children: /* @__PURE__ */ jsx(AlertDescription, { children: done.deleteAfter === null ? t("deleteAccount.deleted") : t("deleteAccount.scheduled", { date: new Date(done.deleteAfter).toLocaleDateString() }) })
1987
+ }), /* @__PURE__ */ jsx(Button, {
1988
+ type: "button",
1989
+ onClick: onDeleted,
1990
+ children: t("deleteAccount.done")
1991
+ })]
1992
+ });
1993
+ async function submit() {
1994
+ setSubmitting(true);
1995
+ setFailed(null);
1996
+ const result = await client.deleteAccount();
1997
+ setSubmitting(false);
1998
+ if (result.ok) setDone(result.data);
1999
+ else setFailed(failure$1(result.error));
2000
+ }
2001
+ const appName = config.app.name;
2002
+ return /* @__PURE__ */ jsxs("form", {
2003
+ className: "grid gap-4",
2004
+ noValidate: true,
2005
+ onSubmit: (event) => {
2006
+ event.preventDefault();
2007
+ submit();
2008
+ },
2009
+ children: [
2010
+ /* @__PURE__ */ jsx("p", {
2011
+ className: "text-sm text-muted-foreground",
2012
+ children: graceDays === 0 ? t("deleteAccount.warning", { appName }) : t("deleteAccount.warningGrace", {
2013
+ appName,
2014
+ graceDays: String(graceDays)
2015
+ })
2016
+ }),
2017
+ failed === null ? null : /* @__PURE__ */ jsxs(Alert, {
2018
+ variant: "destructive",
2019
+ role: "alert",
2020
+ children: [/* @__PURE__ */ jsx(AlertTitle, { children: t("deleteAccount.failed") }), /* @__PURE__ */ jsxs(AlertDescription, { children: [failed.message, failed.notFresh && onReauthenticate !== void 0 ? /* @__PURE__ */ jsx(Button, {
2021
+ type: "button",
2022
+ variant: "link",
2023
+ className: "h-auto p-0",
2024
+ onClick: onReauthenticate,
2025
+ children: t("deleteAccount.signInAgain")
2026
+ }) : null] })]
2027
+ }),
2028
+ /* @__PURE__ */ jsxs("div", {
2029
+ className: "grid gap-2",
2030
+ children: [/* @__PURE__ */ jsx(Label, {
2031
+ htmlFor: `${id}-confirm`,
2032
+ children: t("deleteAccount.confirmLabel")
2033
+ }), /* @__PURE__ */ jsx(Input, {
2034
+ id: `${id}-confirm`,
2035
+ name: "confirm",
2036
+ autoComplete: "off",
2037
+ required: true,
2038
+ value: confirmation,
2039
+ onChange: (event) => {
2040
+ setConfirmation(event.target.value);
2041
+ }
2042
+ })]
2043
+ }),
2044
+ /* @__PURE__ */ jsxs("div", {
2045
+ className: "flex gap-2",
2046
+ children: [/* @__PURE__ */ jsx(Button, {
2047
+ type: "submit",
2048
+ variant: "destructive",
2049
+ disabled: submitting || confirmation.trim() !== t("deleteAccount.confirmWord"),
2050
+ children: submitting ? t("deleteAccount.submitting") : t("deleteAccount.submit")
2051
+ }), /* @__PURE__ */ jsx(Button, {
2052
+ type: "button",
2053
+ variant: "outline",
2054
+ onClick: onCancel,
2055
+ children: t("deleteAccount.cancel")
2056
+ })]
2057
+ })
2058
+ ]
2059
+ });
2060
+ }
2061
+ /**
2062
+ * Lets the signed-in user delete their account, when the server's public
2063
+ * config allows it. The user confirms by typing a word; the server also
2064
+ * requires a recent sign-in and offers "sign in again" when it is not.
2065
+ */
2066
+ function DeleteAccountScreen(props) {
2067
+ const state = usePublicAuthConfig(props.client);
2068
+ return /* @__PURE__ */ jsx(ConfigGate, {
2069
+ state,
2070
+ title: t("deleteAccount.title"),
2071
+ ready: (config) => /* @__PURE__ */ jsx(Shell, {
2072
+ title: t("deleteAccount.title"),
2073
+ children: config.accountDeletion === void 0 ? /* @__PURE__ */ jsx("p", {
2074
+ className: "text-sm text-muted-foreground",
2075
+ children: t("deleteAccount.unavailable", { appName: config.app.name })
2076
+ }) : /* @__PURE__ */ jsx(DeleteAccountForm, {
2077
+ ...props,
2078
+ config,
2079
+ graceDays: config.accountDeletion.graceDays
2080
+ })
2081
+ })
2082
+ });
2083
+ }
2084
+ // src/sessions-screen.tsx
2085
+ function failure(error) {
2086
+ if (error.code === "SESSION_NOT_FRESH") return {
2087
+ message: t("sessions.notFresh"),
2088
+ notFresh: true
2089
+ };
2090
+ if (error.code === "SESSION_NOT_FOUND") return {
2091
+ message: t("sessions.notFound"),
2092
+ notFresh: false
2093
+ };
2094
+ if (error.code === "NETWORK_ERROR") return {
2095
+ message: t("error.network"),
2096
+ notFresh: false
2097
+ };
2098
+ return {
2099
+ message: t("error.generic"),
2100
+ notFresh: false
2101
+ };
2102
+ }
2103
+ function useSessions(client) {
2104
+ const [loaded, setLoaded] = useState({ status: "loading" });
2105
+ const [version, setVersion] = useState(0);
2106
+ useEffect(() => {
2107
+ let active = true;
2108
+ client.sessions.list().then((result) => {
2109
+ if (active) setLoaded(result.ok ? {
2110
+ status: "ready",
2111
+ sessions: result.data
2112
+ } : { status: "error" });
2113
+ });
2114
+ return () => {
2115
+ active = false;
2116
+ };
2117
+ }, [client, version]);
2118
+ return [loaded, useCallback(() => {
2119
+ setVersion((v) => v + 1);
2120
+ }, [])];
2121
+ }
2122
+ function SessionItem({ session, busy, onRevoke }) {
2123
+ return /* @__PURE__ */ jsxs("li", {
2124
+ className: "flex items-start justify-between gap-2 py-3",
2125
+ children: [/* @__PURE__ */ jsxs("div", {
2126
+ className: "grid min-w-0 gap-0.5 text-sm",
2127
+ children: [
2128
+ /* @__PURE__ */ jsx("span", {
2129
+ className: "truncate font-medium",
2130
+ children: session.userAgent ?? t("sessions.unknownDevice")
2131
+ }),
2132
+ /* @__PURE__ */ jsxs("span", {
2133
+ className: "text-muted-foreground",
2134
+ children: [t("sessions.signedInAt", { date: new Date(session.createdAt).toLocaleString() }), session.ipAddress === null ? "" : ` · ${session.ipAddress}`]
2135
+ }),
2136
+ session.current ? /* @__PURE__ */ jsx("span", {
2137
+ className: "text-xs font-medium",
2138
+ children: t("sessions.thisDevice")
2139
+ }) : null
2140
+ ]
2141
+ }), session.current ? null : /* @__PURE__ */ jsx(Button, {
2142
+ type: "button",
2143
+ variant: "outline",
2144
+ size: "sm",
2145
+ disabled: busy,
2146
+ onClick: onRevoke,
2147
+ children: t("sessions.revoke")
2148
+ })]
2149
+ });
2150
+ }
2151
+ function SessionList({ client, onReauthenticate }) {
2152
+ const [loaded, reload] = useSessions(client);
2153
+ const [busy, setBusy] = useState(false);
2154
+ const [failed, setFailed] = useState(null);
2155
+ const [notice, setNotice] = useState(null);
2156
+ async function run(action) {
2157
+ setBusy(true);
2158
+ setFailed(null);
2159
+ setNotice(null);
2160
+ const result = await action();
2161
+ setBusy(false);
2162
+ if (result.ok) {
2163
+ setNotice(result.notice ?? null);
2164
+ reload();
2165
+ } else setFailed(failure(result.error));
2166
+ }
2167
+ const revoke = (sessionId) => run(async () => {
2168
+ const result = await client.sessions.revoke({ sessionId });
2169
+ return result.ok ? { ok: true } : result;
2170
+ });
2171
+ const revokeOthers = () => run(async () => {
2172
+ const result = await client.sessions.revokeOthers();
2173
+ return result.ok ? {
2174
+ ok: true,
2175
+ notice: t("sessions.othersRevoked", { count: String(result.data.revoked) })
2176
+ } : result;
2177
+ });
2178
+ if (loaded.status === "loading") return /* @__PURE__ */ jsx("p", {
2179
+ className: "text-sm text-muted-foreground",
2180
+ "aria-live": "polite",
2181
+ children: t("sessions.loading")
2182
+ });
2183
+ if (loaded.status === "error") return /* @__PURE__ */ jsx(Alert, {
2184
+ variant: "destructive",
2185
+ role: "alert",
2186
+ children: /* @__PURE__ */ jsx(AlertDescription, { children: t("sessions.loadFailed") })
2187
+ });
2188
+ const hasOthers = loaded.sessions.some((session) => !session.current);
2189
+ return /* @__PURE__ */ jsxs("div", {
2190
+ className: "grid gap-4",
2191
+ children: [
2192
+ notice === null ? null : /* @__PURE__ */ jsx(Alert, {
2193
+ role: "status",
2194
+ children: /* @__PURE__ */ jsx(AlertDescription, { children: notice })
2195
+ }),
2196
+ failed === null ? null : /* @__PURE__ */ jsxs(Alert, {
2197
+ variant: "destructive",
2198
+ role: "alert",
2199
+ children: [/* @__PURE__ */ jsx(AlertTitle, { children: t("sessions.failed") }), /* @__PURE__ */ jsxs(AlertDescription, { children: [failed.message, failed.notFresh && onReauthenticate !== void 0 ? /* @__PURE__ */ jsx(Button, {
2200
+ type: "button",
2201
+ variant: "link",
2202
+ className: "h-auto p-0",
2203
+ onClick: onReauthenticate,
2204
+ children: t("sessions.signInAgain")
2205
+ }) : null] })]
2206
+ }),
2207
+ /* @__PURE__ */ jsx("ul", {
2208
+ className: "divide-y",
2209
+ "aria-label": t("sessions.title"),
2210
+ children: loaded.sessions.map((session) => /* @__PURE__ */ jsx(SessionItem, {
2211
+ session,
2212
+ busy,
2213
+ onRevoke: () => void revoke(session.id)
2214
+ }, session.id))
2215
+ }),
2216
+ hasOthers ? /* @__PURE__ */ jsx(Button, {
2217
+ type: "button",
2218
+ variant: "destructive",
2219
+ disabled: busy,
2220
+ onClick: () => void revokeOthers(),
2221
+ children: t("sessions.revokeOthers")
2222
+ }) : null
2223
+ ]
2224
+ });
2225
+ }
2226
+ /**
2227
+ * The signed-in user's sessions: where and when each one signed in, which one
2228
+ * is this device, "Sign out" for each other session and "Sign out all other
2229
+ * sessions". Signing out other sessions needs a recent sign-in; on
2230
+ * `SESSION_NOT_FRESH` the screen explains why and offers "Sign in again".
2231
+ */
2232
+ function SessionsScreen(props) {
2233
+ const state = usePublicAuthConfig(props.client);
2234
+ return /* @__PURE__ */ jsx(ConfigGate, {
2235
+ state,
2236
+ title: t("sessions.title"),
2237
+ ready: () => /* @__PURE__ */ jsx(Shell, {
2238
+ title: t("sessions.title"),
2239
+ description: t("sessions.description"),
2240
+ children: /* @__PURE__ */ jsx(SessionList, { ...props })
2241
+ })
2242
+ });
2243
+ }
2244
+ // src/step-up-screen.tsx
2245
+ const METHOD_LABELS = {
2246
+ password: "stepUp.method.password",
2247
+ "phone-otp": "stepUp.method.phoneOtp"
2248
+ };
2249
+ function methodsText(methods) {
2250
+ const labels = [...new Set(methods.map((method) => t(METHOD_LABELS[method] ?? "stepUp.method.other")))];
2251
+ return labels.length === 0 ? t("stepUp.method.other") : labels.join(" or ");
2252
+ }
2253
+ function SignInAgain({ title, description, onSignInAgain }) {
2254
+ return /* @__PURE__ */ jsx(Shell, {
2255
+ title,
2256
+ description,
2257
+ children: /* @__PURE__ */ jsx(Button, {
2258
+ type: "button",
2259
+ className: "w-full",
2260
+ onClick: onSignInAgain,
2261
+ children: t("stepUp.signInAgain")
2262
+ })
2263
+ });
2264
+ }
2265
+ /** 2FA set-up for a signed-in user who has none yet: password, QR code and first code, backup codes. */
2266
+ function SetUpTwoFactor({ client, onSatisfied, onSignInAgain }) {
2267
+ const [step, setStep] = useState({ kind: "password" });
2268
+ const title = t("stepUp.setUp.title");
2269
+ if (step.kind === "codes") return /* @__PURE__ */ jsx(Shell, {
2270
+ title: t("twoFactor.backupCodes.title"),
2271
+ children: /* @__PURE__ */ jsx(BackupCodesStep, {
2272
+ backupCodes: step.backupCodes,
2273
+ onDone: onSatisfied
2274
+ })
2275
+ });
2276
+ if (step.kind === "verify") {
2277
+ const { totpURI, backupCodes } = step;
2278
+ return /* @__PURE__ */ jsx(Shell, {
2279
+ title,
2280
+ children: /* @__PURE__ */ jsx(VerifyStep, {
2281
+ totpURI,
2282
+ onCancel: onSignInAgain,
2283
+ onVerify: async (code) => {
2284
+ const result = await client.twoFactor.verifyTotp({ code });
2285
+ if (!result.ok) return failure$2(result.error.status === 401 ? {
2286
+ ...result.error,
2287
+ code: "INVALID_CODE"
2288
+ } : result.error);
2289
+ setStep({
2290
+ kind: "codes",
2291
+ backupCodes
2292
+ });
2293
+ return null;
2294
+ }
2295
+ })
2296
+ });
2297
+ }
2298
+ return /* @__PURE__ */ jsx(Shell, {
2299
+ title,
2300
+ description: t("stepUp.setUp.description"),
2301
+ children: /* @__PURE__ */ jsx(PasswordStep, {
2302
+ purpose: "setUp",
2303
+ onCancel: onSignInAgain,
2304
+ onSubmit: async (password) => {
2305
+ const result = await client.twoFactor.enable({ password });
2306
+ if (!result.ok) return failure$2(result.error);
2307
+ setStep({
2308
+ kind: "verify",
2309
+ ...result.data
2310
+ });
2311
+ return null;
2312
+ }
2313
+ })
2314
+ });
2315
+ }
2316
+ /**
2317
+ * Shown instead of a protected page that answered `403 TWO_FACTOR_REQUIRED`
2318
+ * or `403 STEP_UP_REQUIRED` (ADR-3), so the page's content never renders:
2319
+ * - 2FA required, none set up: sets up an authenticator app here (enrolment
2320
+ * verifies the session), then `onSatisfied()`.
2321
+ * - 2FA required, already set up: explains, and offers "Sign in again"
2322
+ * (password, then the authenticator code).
2323
+ * - Step-up: names the allowed methods and offers "Sign in again".
2324
+ */
2325
+ function StepUpScreen({ client, requirement, onSatisfied, onSignInAgain }) {
2326
+ const [enrolled, setEnrolled] = useState(null);
2327
+ const needsTwoFactor = requirement.kind === "two-factor";
2328
+ useEffect(() => {
2329
+ if (!needsTwoFactor) return;
2330
+ let active = true;
2331
+ client.getSession().then((current) => {
2332
+ if (active) setEnrolled(current === null ? "error" : current.user.twoFactorEnabled);
2333
+ }, () => {
2334
+ if (active) setEnrolled("error");
2335
+ });
2336
+ return () => {
2337
+ active = false;
2338
+ };
2339
+ }, [client, needsTwoFactor]);
2340
+ if (requirement.kind === "step-up") return /* @__PURE__ */ jsx(SignInAgain, {
2341
+ title: t("stepUp.method.title"),
2342
+ description: t("stepUp.method.description", { methods: methodsText(requirement.allowedMethods) }),
2343
+ onSignInAgain
2344
+ });
2345
+ if (enrolled === null) return /* @__PURE__ */ jsx(Shell, {
2346
+ title: t("stepUp.verify.title"),
2347
+ children: /* @__PURE__ */ jsx("p", {
2348
+ className: "text-sm text-muted-foreground",
2349
+ "aria-live": "polite",
2350
+ children: t("twoFactor.settings.loading")
2351
+ })
2352
+ });
2353
+ if (enrolled === "error") return /* @__PURE__ */ jsx(Shell, {
2354
+ title: t("stepUp.verify.title"),
2355
+ children: /* @__PURE__ */ jsx(Alert, {
2356
+ variant: "destructive",
2357
+ role: "alert",
2358
+ children: /* @__PURE__ */ jsx(AlertDescription, { children: t("error.generic") })
2359
+ })
2360
+ });
2361
+ if (enrolled) return /* @__PURE__ */ jsx(SignInAgain, {
2362
+ title: t("stepUp.verify.title"),
2363
+ description: t("stepUp.verify.description"),
2364
+ onSignInAgain
2365
+ });
2366
+ return /* @__PURE__ */ jsx(SetUpTwoFactor, {
2367
+ client,
2368
+ onSatisfied,
2369
+ onSignInAgain
2370
+ });
2371
+ }
2372
+ export { AcceptInvitationScreen, DeleteAccountScreen, ForgotPasswordScreen, PhoneOtpVerification, PhoneSignIn, PhoneSignInScreen, ResetPasswordScreen, SessionsScreen, SignInScreen, SignUpScreen, StepUpScreen, TwoFactorChallenge, TwoFactorSettingsScreen, VerifyEmailScreen, messages, t, usePublicAuthConfig, useTurnstile };
2373
+
2374
+ //# sourceMappingURL=index.js.map