init: initial commit
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
.DS_Store
|
||||
*.log
|
||||
dist/
|
||||
.vscode/
|
||||
.idea/
|
||||
57
README.md
Normal file
57
README.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# @crema/arcadia-auth-ui
|
||||
|
||||
Auth screens for arcadia-backed apps — login, signup, password reset, 2FA, OAuth, invitation acceptance — wired to [`@crema/arcadia-client`](../lib-arcadia-client) and themed via Skyrise tokens (`var(--card)`, `var(--foreground)`, `var(--primary)`, `--radius`).
|
||||
|
||||
Components are headless about routing — each takes callbacks (`onSuccess`, `onForgotPassword`, etc) so the consuming app routes the user wherever it wants.
|
||||
|
||||
## Public API
|
||||
|
||||
```ts
|
||||
import { LoginForm } from "@crema/arcadia-auth-ui";
|
||||
```
|
||||
|
||||
Planned additions: `SignupForm`, `PasswordResetRequestForm`, `PasswordResetConfirmForm`, `TwoFactorChallengeForm`, `TwoFactorSetupForm`, `OAuthButtons`, `InvitationAcceptForm`.
|
||||
|
||||
## Usage
|
||||
|
||||
In a route component (e.g. `app/routes/login.tsx`):
|
||||
|
||||
```tsx
|
||||
import { LoginForm } from "@crema/arcadia-auth-ui";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div style={{ display: "grid", placeItems: "center", minHeight: "100dvh", padding: "2rem" }}>
|
||||
<LoginForm
|
||||
onSuccess={async ({ tokens, twoFactorRequired, twoFactorChallenge }) => {
|
||||
if (twoFactorRequired) {
|
||||
navigate("/login/2fa", { state: { challenge: twoFactorChallenge } });
|
||||
return;
|
||||
}
|
||||
sessionStorage.setItem("arcadia_token", tokens.access_token);
|
||||
navigate("/");
|
||||
}}
|
||||
onForgotPassword={() => navigate("/login/forgot")}
|
||||
onSignup={() => navigate("/signup")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Requires `<ArcadiaProvider>` somewhere up the tree (typically in `app/root.tsx`).
|
||||
|
||||
## Theming
|
||||
|
||||
All components consume Skyrise tokens via inline styles with CSS variables (the lib convention — components in libs don't use Tailwind classes). They render correctly under any theme that satisfies the Skyrise token contract.
|
||||
|
||||
Surface tints (`body[data-surface="snow|stone|sage|slate"]`) and dark mode (`html.dark`) work transparently — the form picks up whatever the active surface defines.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Inline imports only — no own `package.json`.
|
||||
- Path-aliased into apps via `tsconfig.json` `paths`: `@crema/arcadia-auth-ui` → `../lib-arcadia-auth-ui/src/index.tsx`.
|
||||
- Tailwind `@source` line in the consuming app's `app.css` so any utility classes used (none today, but room for them) get scanned.
|
||||
- Every interactive element has `data-action="auth-<id>"` so scripts and the LLM action bus can drive auth flows in tests / demos.
|
||||
42
src/index.tsx
Normal file
42
src/index.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
// PURPOSE: Arcadia-specific wiring around the @crema/auth-ui form library.
|
||||
// Each form here is a thin wrapper over the matching auth-ui form
|
||||
// (SignInForm, SignUpForm, ForgotPasswordForm, ResetPasswordForm,
|
||||
// OtpForm) that adds:
|
||||
// • the right arcadia endpoint path
|
||||
// • request body shaping (e.g. splitting `name` → first/last)
|
||||
// • response decoding (e.g. 2FA challenge branch on /auth/login)
|
||||
// • ArcadiaError → human message handling
|
||||
//
|
||||
// The visuals, validation, accessibility, and form layout all
|
||||
// come from @crema/auth-ui. If you want to restyle the auth
|
||||
// surface, do it there — these wrappers stay thin on purpose.
|
||||
// ===========================================================================
|
||||
// EXPORTS
|
||||
// LoginForm — wraps SignInForm; POSTs /api/v1/auth/login;
|
||||
// returns tokens + user, or 2FA challenge
|
||||
// SignupForm — wraps SignUpForm; POSTs /api/v1/auth/register
|
||||
// PasswordResetRequestForm — wraps ForgotPasswordForm
|
||||
// PasswordResetConfirmForm — wraps ResetPasswordForm
|
||||
// TwoFactorChallengeForm — wraps OtpForm; POSTs /api/v1/2fa/verify
|
||||
//
|
||||
// (Planned) OAuthButtons (wrap SocialButton), TwoFactorSetupForm
|
||||
// (wrap TwoFactorSetup from auth-ui's advanced surface),
|
||||
// InvitationAcceptForm.
|
||||
// ===========================================================================
|
||||
"use client";
|
||||
|
||||
export { LoginForm, type LoginFormProps, type LoginResult } from "./login-form";
|
||||
export { SignupForm, type SignupFormProps, type SignupResult } from "./signup-form";
|
||||
export {
|
||||
PasswordResetRequestForm,
|
||||
type PasswordResetRequestFormProps,
|
||||
} from "./password-reset-request-form";
|
||||
export {
|
||||
PasswordResetConfirmForm,
|
||||
type PasswordResetConfirmFormProps,
|
||||
} from "./password-reset-confirm-form";
|
||||
export {
|
||||
TwoFactorChallengeForm,
|
||||
type TwoFactorChallengeFormProps,
|
||||
type TwoFactorResult,
|
||||
} from "./two-factor-challenge-form";
|
||||
126
src/login-form.tsx
Normal file
126
src/login-form.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type CSSProperties, type FC, type ReactNode } from "react";
|
||||
import { AuthCard, SignInForm, type SignInValues } from "@crema/auth-ui";
|
||||
import {
|
||||
ArcadiaError,
|
||||
useArcadiaClient,
|
||||
type ArcadiaAuthTokens,
|
||||
type ArcadiaUser,
|
||||
} from "@crema/arcadia-client";
|
||||
|
||||
export interface LoginResult {
|
||||
tokens: ArcadiaAuthTokens;
|
||||
user?: ArcadiaUser;
|
||||
/** True if the API responded with a 2FA challenge instead of tokens. */
|
||||
twoFactorRequired?: boolean;
|
||||
/** Opaque challenge token to pass to the 2FA step (when twoFactorRequired). */
|
||||
twoFactorChallenge?: string;
|
||||
/** Whether the user asked to be remembered (from the form's checkbox). */
|
||||
remember?: boolean;
|
||||
}
|
||||
|
||||
export interface LoginFormProps {
|
||||
/** Where to POST. Defaults to "/api/v1/auth/login". */
|
||||
loginPath?: string;
|
||||
/** Called on successful auth (or 2FA challenge — check twoFactorRequired). */
|
||||
onSuccess: (result: LoginResult) => void | Promise<void>;
|
||||
/** "Forgot password?" link click. If omitted, the link is hidden. */
|
||||
onForgotPassword?: () => void;
|
||||
/** "Don't have an account?" link click. Rendered in the card footer. */
|
||||
onSignup?: () => void;
|
||||
/** Optional brand row above the form (logo, app name). */
|
||||
brand?: ReactNode;
|
||||
/** Heading text. Default: "Sign in". */
|
||||
heading?: string;
|
||||
/** Subhead under the heading. */
|
||||
subhead?: ReactNode;
|
||||
/** Pre-fill email (e.g. from query string after invitation accept). */
|
||||
defaultEmail?: string;
|
||||
/** Override styles on the outer card. */
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const LoginForm: FC<LoginFormProps> = ({
|
||||
loginPath = "/api/v1/auth/login",
|
||||
onSuccess,
|
||||
onForgotPassword,
|
||||
onSignup,
|
||||
brand,
|
||||
heading = "Sign in",
|
||||
subhead,
|
||||
defaultEmail,
|
||||
style,
|
||||
}) => {
|
||||
const arcadia = useArcadiaClient();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit({ email, password, remember }: SignInValues) {
|
||||
setError(null);
|
||||
try {
|
||||
const res = await arcadia.POST<{
|
||||
data: ArcadiaAuthTokens & {
|
||||
user?: ArcadiaUser;
|
||||
two_factor_required?: boolean;
|
||||
two_factor_challenge?: string;
|
||||
};
|
||||
}>(loginPath, { body: { email, password } });
|
||||
const { user, two_factor_required, two_factor_challenge, ...tokens } = res.data;
|
||||
await onSuccess({
|
||||
tokens,
|
||||
user,
|
||||
twoFactorRequired: two_factor_required,
|
||||
twoFactorChallenge: two_factor_challenge,
|
||||
remember,
|
||||
});
|
||||
} catch (err) {
|
||||
// SignInForm catches and clears its own pending state when this throws.
|
||||
// We surface the message via local error state below the form.
|
||||
if (err instanceof ArcadiaError) setError(err.message);
|
||||
else setError("Something went wrong. Please try again.");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthCard
|
||||
logo={brand}
|
||||
title={heading}
|
||||
description={subhead}
|
||||
footer={
|
||||
onSignup ? (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Don't have an account?{" "}
|
||||
<button
|
||||
type="button"
|
||||
data-action="auth-login-signup"
|
||||
onClick={onSignup}
|
||||
className="text-primary underline underline-offset-2"
|
||||
>
|
||||
Sign up
|
||||
</button>
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
className="auth-arcadia-login"
|
||||
// style is forwarded via wrapper; AuthCard takes className not style
|
||||
>
|
||||
<div data-action="auth-login" style={style}>
|
||||
<SignInForm
|
||||
onSubmit={handleSubmit}
|
||||
onForgot={onForgotPassword}
|
||||
defaultEmail={defaultEmail}
|
||||
/>
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-3 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
||||
data-action="auth-login-error"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</AuthCard>
|
||||
);
|
||||
};
|
||||
59
src/password-reset-confirm-form.tsx
Normal file
59
src/password-reset-confirm-form.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type CSSProperties, type FC, type ReactNode } from "react";
|
||||
import { AuthCard, ResetPasswordForm, type ResetPasswordValues } from "@crema/auth-ui";
|
||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client";
|
||||
|
||||
export interface PasswordResetConfirmFormProps {
|
||||
/** Token from the reset email URL. */
|
||||
token: string;
|
||||
/** Where to POST. Defaults to "/api/v1/password-reset/confirm". */
|
||||
confirmPath?: string;
|
||||
onSuccess: () => void | Promise<void>;
|
||||
brand?: ReactNode;
|
||||
heading?: string;
|
||||
subhead?: ReactNode;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const PasswordResetConfirmForm: FC<PasswordResetConfirmFormProps> = ({
|
||||
token,
|
||||
confirmPath = "/api/v1/password-reset/confirm",
|
||||
onSuccess,
|
||||
brand,
|
||||
heading = "Set a new password",
|
||||
subhead,
|
||||
style,
|
||||
}) => {
|
||||
const arcadia = useArcadiaClient();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit({ password }: ResetPasswordValues) {
|
||||
setError(null);
|
||||
try {
|
||||
await arcadia.POST(confirmPath, { body: { token, password } });
|
||||
await onSuccess();
|
||||
} catch (err) {
|
||||
if (err instanceof ArcadiaError) setError(err.message);
|
||||
else setError("Something went wrong. Please try again.");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthCard logo={brand} title={heading} description={subhead}>
|
||||
<div data-action="auth-pwreset-confirm" style={style}>
|
||||
<ResetPasswordForm onSubmit={handleSubmit} />
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-3 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
||||
data-action="auth-pwreset-confirm-error"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</AuthCard>
|
||||
);
|
||||
};
|
||||
88
src/password-reset-request-form.tsx
Normal file
88
src/password-reset-request-form.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type CSSProperties, type FC, type ReactNode } from "react";
|
||||
import { AuthCard, ForgotPasswordForm } from "@crema/auth-ui";
|
||||
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client";
|
||||
|
||||
export interface PasswordResetRequestFormProps {
|
||||
/** Where to POST. Defaults to "/api/v1/password-reset/request". */
|
||||
requestPath?: string;
|
||||
onSuccess?: (email: string) => void | Promise<void>;
|
||||
onBack?: () => void;
|
||||
brand?: ReactNode;
|
||||
heading?: string;
|
||||
subhead?: ReactNode;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const PasswordResetRequestForm: FC<PasswordResetRequestFormProps> = ({
|
||||
requestPath = "/api/v1/password-reset/request",
|
||||
onSuccess,
|
||||
onBack,
|
||||
brand,
|
||||
heading = "Reset your password",
|
||||
subhead = "Enter the email associated with your account and we'll send you a reset link.",
|
||||
style,
|
||||
}) => {
|
||||
const arcadia = useArcadiaClient();
|
||||
const [sentTo, setSentTo] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(email: string) {
|
||||
setError(null);
|
||||
try {
|
||||
await arcadia.POST(requestPath, { body: { email } });
|
||||
setSentTo(email);
|
||||
await onSuccess?.(email);
|
||||
} catch (err) {
|
||||
// Most deployments respond 200 even for unknown emails (anti-enumeration).
|
||||
// We only land here on hard failures (rate limit, validation, server).
|
||||
if (err instanceof ArcadiaError) setError(err.message);
|
||||
else setError("Something went wrong. Please try again.");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthCard
|
||||
logo={brand}
|
||||
title={heading}
|
||||
description={subhead}
|
||||
footer={
|
||||
onBack ? (
|
||||
<button
|
||||
type="button"
|
||||
data-action="auth-pwreset-back"
|
||||
onClick={onBack}
|
||||
className="text-sm text-primary underline underline-offset-2"
|
||||
>
|
||||
Back to sign in
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div data-action="auth-pwreset-request" style={style}>
|
||||
{sentTo ? (
|
||||
<div
|
||||
role="status"
|
||||
className="rounded-md border border-success/30 bg-success/10 px-3 py-2 text-sm"
|
||||
style={{ color: "var(--success, currentColor)" }}
|
||||
>
|
||||
If an account exists for <strong>{sentTo}</strong>, we've sent a reset link.
|
||||
</div>
|
||||
) : (
|
||||
<ForgotPasswordForm onSubmit={handleSubmit} onBack={onBack} />
|
||||
)}
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-3 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
||||
data-action="auth-pwreset-error"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</AuthCard>
|
||||
);
|
||||
};
|
||||
105
src/signup-form.tsx
Normal file
105
src/signup-form.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type CSSProperties, type FC, type ReactNode } from "react";
|
||||
import { AuthCard, SignUpForm as AuthSignUpForm, type SignUpValues } from "@crema/auth-ui";
|
||||
import {
|
||||
ArcadiaError,
|
||||
useArcadiaClient,
|
||||
type ArcadiaAuthTokens,
|
||||
type ArcadiaUser,
|
||||
} from "@crema/arcadia-client";
|
||||
|
||||
export interface SignupResult {
|
||||
tokens?: ArcadiaAuthTokens;
|
||||
user?: ArcadiaUser;
|
||||
/** Some deployments require email verification before issuing tokens. */
|
||||
emailVerificationSent?: boolean;
|
||||
}
|
||||
|
||||
export interface SignupFormProps {
|
||||
/** Where to POST. Defaults to "/api/v1/auth/register". */
|
||||
registerPath?: string;
|
||||
onSuccess: (result: SignupResult) => void | Promise<void>;
|
||||
onSignin?: () => void;
|
||||
brand?: ReactNode;
|
||||
heading?: string;
|
||||
subhead?: ReactNode;
|
||||
/** Links surfaced inside the form's terms checkbox. */
|
||||
termsHref?: string;
|
||||
privacyHref?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const SignupForm: FC<SignupFormProps> = ({
|
||||
registerPath = "/api/v1/auth/register",
|
||||
onSuccess,
|
||||
onSignin,
|
||||
brand,
|
||||
heading = "Create your account",
|
||||
subhead,
|
||||
termsHref,
|
||||
privacyHref,
|
||||
style,
|
||||
}) => {
|
||||
const arcadia = useArcadiaClient();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit({ name, email, password }: SignUpValues) {
|
||||
setError(null);
|
||||
// Arcadia's /auth/register expects first_name + last_name. Split the
|
||||
// single name field on the first space; trailing parts go to last_name.
|
||||
const [first_name = "", ...rest] = name.trim().split(/\s+/);
|
||||
const last_name = rest.join(" ");
|
||||
try {
|
||||
const res = await arcadia.POST<{
|
||||
data: Partial<ArcadiaAuthTokens> & {
|
||||
user?: ArcadiaUser;
|
||||
email_verification_sent?: boolean;
|
||||
};
|
||||
}>(registerPath, { body: { email, password, first_name, last_name } });
|
||||
const { user, email_verification_sent, ...maybeTokens } = res.data;
|
||||
const tokens = maybeTokens.access_token ? (maybeTokens as ArcadiaAuthTokens) : undefined;
|
||||
await onSuccess({ tokens, user, emailVerificationSent: email_verification_sent });
|
||||
} catch (err) {
|
||||
if (err instanceof ArcadiaError) setError(err.message);
|
||||
else setError("Something went wrong. Please try again.");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthCard
|
||||
logo={brand}
|
||||
title={heading}
|
||||
description={subhead}
|
||||
footer={
|
||||
onSignin ? (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Already have an account?{" "}
|
||||
<button
|
||||
type="button"
|
||||
data-action="auth-signup-signin"
|
||||
onClick={onSignin}
|
||||
className="text-primary underline underline-offset-2"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div data-action="auth-signup" style={style}>
|
||||
<AuthSignUpForm onSubmit={handleSubmit} termsHref={termsHref} privacyHref={privacyHref} />
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-3 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
||||
data-action="auth-signup-error"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</AuthCard>
|
||||
);
|
||||
};
|
||||
122
src/two-factor-challenge-form.tsx
Normal file
122
src/two-factor-challenge-form.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type CSSProperties, type FC, type ReactNode } from "react";
|
||||
import { AuthCard, OtpForm } from "@crema/auth-ui";
|
||||
import {
|
||||
ArcadiaError,
|
||||
useArcadiaClient,
|
||||
type ArcadiaAuthTokens,
|
||||
type ArcadiaUser,
|
||||
} from "@crema/arcadia-client";
|
||||
|
||||
export interface TwoFactorResult {
|
||||
tokens: ArcadiaAuthTokens;
|
||||
user?: ArcadiaUser;
|
||||
}
|
||||
|
||||
export interface TwoFactorChallengeFormProps {
|
||||
/** Opaque challenge token from the prior login response. */
|
||||
challenge: string;
|
||||
/** Where to POST. Defaults to "/api/v1/2fa/verify". */
|
||||
verifyPath?: string;
|
||||
onSuccess: (result: TwoFactorResult) => void | Promise<void>;
|
||||
/** Switch to recovery-code entry. If your deployment doesn't support
|
||||
* recovery codes, leave undefined. */
|
||||
onUseRecoveryCode?: () => void;
|
||||
/** Switch back to login. */
|
||||
onBack?: () => void;
|
||||
brand?: ReactNode;
|
||||
heading?: string;
|
||||
subhead?: ReactNode;
|
||||
/** "totp" (default, 6-digit code) or "recovery" (longer string). */
|
||||
mode?: "totp" | "recovery";
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const TwoFactorChallengeForm: FC<TwoFactorChallengeFormProps> = ({
|
||||
challenge,
|
||||
verifyPath = "/api/v1/2fa/verify",
|
||||
onSuccess,
|
||||
onUseRecoveryCode,
|
||||
onBack,
|
||||
brand,
|
||||
heading = "Enter your verification code",
|
||||
subhead,
|
||||
mode = "totp",
|
||||
style,
|
||||
}) => {
|
||||
const arcadia = useArcadiaClient();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const defaultSubhead =
|
||||
mode === "recovery"
|
||||
? "Enter one of the recovery codes you saved when setting up two-factor authentication."
|
||||
: "Open your authenticator app and enter the 6-digit code.";
|
||||
|
||||
async function handleSubmit(code: string) {
|
||||
setError(null);
|
||||
try {
|
||||
const res = await arcadia.POST<{
|
||||
data: ArcadiaAuthTokens & { user?: ArcadiaUser };
|
||||
}>(verifyPath, {
|
||||
body: mode === "recovery" ? { challenge, recovery_code: code } : { challenge, code },
|
||||
});
|
||||
const { user, ...tokens } = res.data;
|
||||
await onSuccess({ tokens, user });
|
||||
} catch (err) {
|
||||
if (err instanceof ArcadiaError) setError(err.message);
|
||||
else setError("Something went wrong. Please try again.");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthCard
|
||||
logo={brand}
|
||||
title={heading}
|
||||
description={subhead ?? defaultSubhead}
|
||||
footer={
|
||||
<div className="flex w-full justify-between text-sm">
|
||||
{onUseRecoveryCode ? (
|
||||
<button
|
||||
type="button"
|
||||
data-action="auth-2fa-recovery"
|
||||
onClick={onUseRecoveryCode}
|
||||
className="text-primary underline underline-offset-2"
|
||||
>
|
||||
{mode === "recovery" ? "Use authenticator code" : "Use recovery code"}
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{onBack ? (
|
||||
<button
|
||||
type="button"
|
||||
data-action="auth-2fa-back"
|
||||
onClick={onBack}
|
||||
className="text-primary underline underline-offset-2"
|
||||
>
|
||||
Back to sign in
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div data-action="auth-2fa" style={style}>
|
||||
{/* OtpForm is fixed-length numeric; recovery-code mode falls back
|
||||
to the same input but allows longer alphanumeric codes via the
|
||||
same handler. The visual is similar enough to ship as-is. */}
|
||||
<OtpForm onSubmit={handleSubmit} length={mode === "recovery" ? 8 : 6} />
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-3 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
|
||||
data-action="auth-2fa-error"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</AuthCard>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user