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>
This commit is contained in:
@@ -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";
|
||||||
|
|||||||
262
src/invitation-accept-form.tsx
Normal file
262
src/invitation-accept-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user