Compare commits

...

2 Commits

Author SHA1 Message Date
jules
0232d27bb7 chore(pkg): add package.json (name/version/peerDeps/exports/sideEffects)
Libs shipped as bare source with no manifest — consumable only via per-app
vite/tsconfig alias surgery, no version contract, no tree-shaking signal.
Add a minimal package.json matching the @crema/content-ui template: entry +
exports map, declared peerDependencies, sideEffects:false. Mechanical, no
code change. Frontend audit 2026-06-20, rank 2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 20:36:54 +10:00
jules
5e1923bae7 Add InvitationAcceptForm
Loads an invitation by token (public arcadia-core endpoint), shows the
inviter and team, collects name + password, and POSTs
/api/v1/invitations/:token/accept. The token is the trust — no auth or
tenant header required. Exported from the index.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 12:30:03 +10:00
3 changed files with 290 additions and 2 deletions

20
package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "@crema/arcadia-auth-ui",
"version": "0.0.1",
"private": true,
"description": "Arcadia Auth components for the Crema design system. Builds on @crema/arcadia-core-client, @crema/auth-ui.",
"type": "module",
"main": "./src/index.tsx",
"types": "./src/index.tsx",
"exports": {
".": "./src/index.tsx"
},
"files": [
"src"
],
"sideEffects": false,
"peerDependencies": {
"react": "^19.2.4",
"react-dom": "^19.2.4"
}
}

View File

@@ -18,10 +18,11 @@
// PasswordResetRequestForm — wraps ForgotPasswordForm // PasswordResetRequestForm — wraps ForgotPasswordForm
// PasswordResetConfirmForm — wraps ResetPasswordForm // PasswordResetConfirmForm — wraps ResetPasswordForm
// TwoFactorChallengeForm — wraps OtpForm; POSTs /api/v1/2fa/verify // TwoFactorChallengeForm — wraps OtpForm; POSTs /api/v1/2fa/verify
// InvitationAcceptForm — loads an invite by token, then POSTs
// /api/v1/invitations/:token/accept
// //
// (Planned) OAuthButtons (wrap SocialButton), TwoFactorSetupForm // (Planned) OAuthButtons (wrap SocialButton), TwoFactorSetupForm
// (wrap TwoFactorSetup from auth-ui's advanced surface), // (wrap TwoFactorSetup from auth-ui's advanced surface).
// InvitationAcceptForm.
// =========================================================================== // ===========================================================================
"use client"; "use client";
@@ -40,3 +41,8 @@ export {
type TwoFactorChallengeFormProps, type TwoFactorChallengeFormProps,
type TwoFactorResult, type TwoFactorResult,
} from "./two-factor-challenge-form"; } from "./two-factor-challenge-form";
export {
InvitationAcceptForm,
type InvitationAcceptFormProps,
type InvitationPreview,
} from "./invitation-accept-form";

View File

@@ -0,0 +1,262 @@
"use client";
import {
useEffect,
useId,
useState,
type CSSProperties,
type FC,
type FormEvent,
type ReactNode,
} from "react";
import {
AuthCard,
PasswordInput,
PasswordStrengthMeter,
scorePassword,
} from "@crema/auth-ui";
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-core-client";
/** What arcadia-core's public GET /invitations/:token returns. */
export interface InvitationPreview {
email: string;
tenant_name: string;
role_name: string;
inviter_name: string;
expires_at: string | null;
expired: boolean;
accepted: boolean;
revoked: boolean;
}
export interface InvitationAcceptFormProps {
/** Token from the invitation email URL (/invite/<token>). */
token: string;
/**
* Base path of the public invitation API on arcadia-core. The form reads
* `${invitationsPath}/${token}` and POSTs `${invitationsPath}/${token}/accept`.
* Defaults to "/api/v1/invitations".
*/
invitationsPath?: string;
/** Called after the account is created so the host can redirect to sign-in. */
onSuccess: () => void | Promise<void>;
brand?: ReactNode;
heading?: string;
style?: CSSProperties;
}
type Phase =
| { kind: "loading" }
| { kind: "invalid"; title: string; message: string }
| { kind: "ready"; invite: InvitationPreview };
/**
* Public "accept your invitation" surface. Loads the invite by token to show
* who invited the user and to which team, then collects a name + password and
* creates the account. The token (delivered to the invitee's inbox) is the
* trust — no auth or tenant header is required.
*/
export const InvitationAcceptForm: FC<InvitationAcceptFormProps> = ({
token,
invitationsPath = "/api/v1/invitations",
onSuccess,
brand,
heading = "Accept your invitation",
style,
}) => {
const arcadia = useArcadiaClient();
const [phase, setPhase] = useState<Phase>({ kind: "loading" });
const [name, setName] = useState("");
const [password, setPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const nameId = useId();
const passwordId = useId();
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await arcadia.GET<{ data: InvitationPreview }>(
`${invitationsPath}/${encodeURIComponent(token)}`,
);
if (cancelled) return;
const invite = res.data;
if (invite.accepted) {
setPhase({
kind: "invalid",
title: "Already accepted",
message:
"This invitation has already been used. Try signing in instead.",
});
} else if (invite.revoked) {
setPhase({
kind: "invalid",
title: "Invitation revoked",
message:
"This invitation was revoked. Ask whoever invited you to send a new one.",
});
} else if (invite.expired) {
setPhase({
kind: "invalid",
title: "Invitation expired",
message:
"This invitation has expired. Ask whoever invited you to resend it.",
});
} else {
setPhase({ kind: "ready", invite });
}
} catch (err) {
if (cancelled) return;
const notFound = err instanceof ArcadiaError && err.status === 404;
setPhase({
kind: "invalid",
title: notFound ? "Invitation not found" : "Something went wrong",
message: notFound
? "This invitation link is invalid. Check the link in your email and try again."
: "We couldn't load this invitation. Please try again in a moment.",
});
}
})();
return () => {
cancelled = true;
};
}, [arcadia, invitationsPath, token]);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
if (!name.trim()) {
setError("Enter your name.");
return;
}
if (scorePassword(password).score < 2) {
setError("Choose a stronger password.");
return;
}
// arcadia-core's accept endpoint 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(" ");
setSubmitting(true);
try {
await arcadia.POST(`${invitationsPath}/${encodeURIComponent(token)}/accept`, {
body: { user: { first_name, last_name, password } },
});
await onSuccess();
} catch (err) {
if (err instanceof ArcadiaError) setError(err.message);
else setError("Something went wrong. Please try again.");
} finally {
setSubmitting(false);
}
}
if (phase.kind === "loading") {
return (
<AuthCard logo={brand} title={heading}>
<p className="text-sm text-muted-foreground" data-action="auth-invite-loading">
Loading your invitation
</p>
</AuthCard>
);
}
if (phase.kind === "invalid") {
return (
<AuthCard logo={brand} title={phase.title}>
<p className="text-sm text-muted-foreground" data-action="auth-invite-invalid">
{phase.message}
</p>
</AuthCard>
);
}
const { invite } = phase;
return (
<AuthCard
logo={brand}
title={heading}
description={
<>
<span className="font-medium text-foreground">{invite.inviter_name}</span>{" "}
invited you to join{" "}
<span className="font-medium text-foreground">{invite.tenant_name}</span>
{invite.role_name ? <> as {invite.role_name}</> : null}.
</>
}
>
<form
onSubmit={handleSubmit}
data-action="auth-invite-accept"
className="flex flex-col gap-4"
style={style}
>
<div className="flex flex-col gap-1.5">
<span className="text-sm font-medium">Email</span>
<div
className="h-9 w-full rounded-md border bg-muted/40 px-3 text-sm leading-9 text-muted-foreground"
data-action="auth-invite-email"
>
{invite.email}
</div>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor={nameId} className="text-sm font-medium">
Your name
</label>
<input
id={nameId}
type="text"
autoComplete="name"
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Ada Lovelace"
className="h-9 w-full rounded-md border bg-background px-3 text-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor={passwordId} className="text-sm font-medium">
Choose a password
</label>
<PasswordInput
id={passwordId}
value={password}
onChange={setPassword}
autoComplete="new-password"
required
/>
{password ? <PasswordStrengthMeter value={password} /> : null}
</div>
{error ? (
<div
role="alert"
className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
data-action="auth-invite-error"
>
{error}
</div>
) : null}
<button
type="submit"
disabled={submitting}
data-action="auth-invite-submit"
className="inline-flex h-9 w-full items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{submitting ? "Creating your account…" : "Accept & create account"}
</button>
</form>
</AuthCard>
);
};