From 24ed9b34f7322d11062a3d50477d3d60e94311c6 Mon Sep 17 00:00:00 2001 From: Giuliano Silvestro Date: Thu, 30 Apr 2026 08:26:35 +1000 Subject: [PATCH] init: initial commit Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 6 ++ README.md | 57 +++++++++++++ src/index.tsx | 42 ++++++++++ src/login-form.tsx | 126 ++++++++++++++++++++++++++++ src/password-reset-confirm-form.tsx | 59 +++++++++++++ src/password-reset-request-form.tsx | 88 +++++++++++++++++++ src/signup-form.tsx | 105 +++++++++++++++++++++++ src/two-factor-challenge-form.tsx | 122 +++++++++++++++++++++++++++ 8 files changed, 605 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 src/index.tsx create mode 100644 src/login-form.tsx create mode 100644 src/password-reset-confirm-form.tsx create mode 100644 src/password-reset-request-form.tsx create mode 100644 src/signup-form.tsx create mode 100644 src/two-factor-challenge-form.tsx diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1403132 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.DS_Store +*.log +dist/ +.vscode/ +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..f39bff1 --- /dev/null +++ b/README.md @@ -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 ( +
+ { + if (twoFactorRequired) { + navigate("/login/2fa", { state: { challenge: twoFactorChallenge } }); + return; + } + sessionStorage.setItem("arcadia_token", tokens.access_token); + navigate("/"); + }} + onForgotPassword={() => navigate("/login/forgot")} + onSignup={() => navigate("/signup")} + /> +
+ ); +} +``` + +Requires `` 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-"` so scripts and the LLM action bus can drive auth flows in tests / demos. diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 0000000..e7aa405 --- /dev/null +++ b/src/index.tsx @@ -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"; diff --git a/src/login-form.tsx b/src/login-form.tsx new file mode 100644 index 0000000..2a06535 --- /dev/null +++ b/src/login-form.tsx @@ -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; + /** "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 = ({ + loginPath = "/api/v1/auth/login", + onSuccess, + onForgotPassword, + onSignup, + brand, + heading = "Sign in", + subhead, + defaultEmail, + style, +}) => { + const arcadia = useArcadiaClient(); + const [error, setError] = useState(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 ( + + Don't have an account?{" "} + + + ) : undefined + } + className="auth-arcadia-login" + // style is forwarded via wrapper; AuthCard takes className not style + > +
+ + {error ? ( +
+ {error} +
+ ) : null} +
+
+ ); +}; diff --git a/src/password-reset-confirm-form.tsx b/src/password-reset-confirm-form.tsx new file mode 100644 index 0000000..3a85da6 --- /dev/null +++ b/src/password-reset-confirm-form.tsx @@ -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; + brand?: ReactNode; + heading?: string; + subhead?: ReactNode; + style?: CSSProperties; +} + +export const PasswordResetConfirmForm: FC = ({ + token, + confirmPath = "/api/v1/password-reset/confirm", + onSuccess, + brand, + heading = "Set a new password", + subhead, + style, +}) => { + const arcadia = useArcadiaClient(); + const [error, setError] = useState(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 ( + +
+ + {error ? ( +
+ {error} +
+ ) : null} +
+
+ ); +}; diff --git a/src/password-reset-request-form.tsx b/src/password-reset-request-form.tsx new file mode 100644 index 0000000..3635693 --- /dev/null +++ b/src/password-reset-request-form.tsx @@ -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; + onBack?: () => void; + brand?: ReactNode; + heading?: string; + subhead?: ReactNode; + style?: CSSProperties; +} + +export const PasswordResetRequestForm: FC = ({ + 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(null); + const [error, setError] = useState(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 ( + + Back to sign in + + ) : undefined + } + > +
+ {sentTo ? ( +
+ If an account exists for {sentTo}, we've sent a reset link. +
+ ) : ( + + )} + {error ? ( +
+ {error} +
+ ) : null} +
+
+ ); +}; diff --git a/src/signup-form.tsx b/src/signup-form.tsx new file mode 100644 index 0000000..c61ec64 --- /dev/null +++ b/src/signup-form.tsx @@ -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; + 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 = ({ + registerPath = "/api/v1/auth/register", + onSuccess, + onSignin, + brand, + heading = "Create your account", + subhead, + termsHref, + privacyHref, + style, +}) => { + const arcadia = useArcadiaClient(); + const [error, setError] = useState(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 & { + 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 ( + + Already have an account?{" "} + + + ) : undefined + } + > +
+ + {error ? ( +
+ {error} +
+ ) : null} +
+
+ ); +}; diff --git a/src/two-factor-challenge-form.tsx b/src/two-factor-challenge-form.tsx new file mode 100644 index 0000000..263a3d9 --- /dev/null +++ b/src/two-factor-challenge-form.tsx @@ -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; + /** 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 = ({ + 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(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 ( + + {onUseRecoveryCode ? ( + + ) : ( + + )} + {onBack ? ( + + ) : null} + + } + > +
+ {/* 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. */} + + {error ? ( +
+ {error} +
+ ) : null} +
+
+ ); +};