diff --git a/src/index.tsx b/src/index.tsx index e7aa405..4d75c68 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -18,10 +18,11 @@ // PasswordResetRequestForm — wraps ForgotPasswordForm // PasswordResetConfirmForm — wraps ResetPasswordForm // 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 -// (wrap TwoFactorSetup from auth-ui's advanced surface), -// InvitationAcceptForm. +// (wrap TwoFactorSetup from auth-ui's advanced surface). // =========================================================================== "use client"; @@ -40,3 +41,8 @@ export { type TwoFactorChallengeFormProps, type TwoFactorResult, } from "./two-factor-challenge-form"; +export { + InvitationAcceptForm, + type InvitationAcceptFormProps, + type InvitationPreview, +} from "./invitation-accept-form"; diff --git a/src/invitation-accept-form.tsx b/src/invitation-accept-form.tsx new file mode 100644 index 0000000..4347ff7 --- /dev/null +++ b/src/invitation-accept-form.tsx @@ -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: 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; + 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 = ({ + token, + invitationsPath = "/api/v1/invitations", + onSuccess, + brand, + heading = "Accept your invitation", + style, +}) => { + const arcadia = useArcadiaClient(); + const [phase, setPhase] = useState({ kind: "loading" }); + + const [name, setName] = useState(""); + const [password, setPassword] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( + +

+ Loading your invitation… +

+
+ ); + } + + if (phase.kind === "invalid") { + return ( + +

+ {phase.message} +

+
+ ); + } + + const { invite } = phase; + + return ( + + {invite.inviter_name}{" "} + invited you to join{" "} + {invite.tenant_name} + {invite.role_name ? <> as {invite.role_name} : null}. + + } + > +
+
+ Email +
+ {invite.email} +
+
+ +
+ + 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" + /> +
+ +
+ + + {password ? : null} +
+ + {error ? ( +
+ {error} +
+ ) : null} + + +
+
+ ); +};