Compare commits
2 Commits
640707666c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0232d27bb7 | ||
|
|
5e1923bae7 |
20
package.json
Normal file
20
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
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