Files
lib-arcadia-auth-ui/src/login-form.tsx
jules 640707666c refactor: rename @crema/arcadia-client → @crema/arcadia-core-client
Disambiguates the Phoenix/auth client lib from lib-arcadia-agents-client.
Dir lib-arcadia-client → lib-arcadia-core-client; alias updated in
tsconfig paths, vite config, app.css @source, imports, CI and docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 13:31:54 +10:00

127 lines
3.9 KiB
TypeScript

"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-core-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&apos;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>
);
};