Improve invite auth clarity

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-13 07:49:47 -05:00
1 parent 2a3910190f
commit 089d73e2f4
3 files changed
+488 -177

No files matched your search

+47 -7
View File
@@ -6,6 +6,28 @@ import {
type UpdateCurrentUserProfile,
} from "@paperclipai/shared";
type AuthErrorBody =
| {
code?: string;
message?: string;
error?: string | { code?: string; message?: string };
}
| null;
export class AuthApiError extends Error {
status: number;
code: string | null;
body: unknown;
constructor(message: string, status: number, body: unknown, code: string | null = null) {
super(message);
this.name = "AuthApiError";
this.status = status;
this.code = code;
this.body = body;
}
}
function toSession(value: unknown): AuthSession | null {
const direct = authSessionSchema.safeParse(value);
if (direct.success) return direct.data;
@@ -15,6 +37,29 @@ function toSession(value: unknown): AuthSession | null {
return nested.success ? nested.data : null;
}
function extractAuthError(payload: AuthErrorBody, status: number) {
const nested =
payload?.error && typeof payload.error === "object"
? payload.error
: null;
const code =
typeof nested?.code === "string"
? nested.code
: typeof payload?.code === "string"
? payload.code
: null;
const message =
typeof nested?.message === "string" && nested.message.trim().length > 0
? nested.message
: typeof payload?.message === "string" && payload.message.trim().length > 0
? payload.message
: typeof payload?.error === "string" && payload.error.trim().length > 0
? payload.error
: `Request failed: ${status}`;
return new AuthApiError(message, status, payload, code);
}
async function authPost(path: string, body: Record<string, unknown>) {
const res = await fetch(`/api/auth${path}`, {
method: "POST",
@@ -24,12 +69,7 @@ async function authPost(path: string, body: Record<string, unknown>) {
});
const payload = await res.json().catch(() => null);
if (!res.ok) {
const message =
(payload as { error?: { message?: string } | string } | null)?.error &&
typeof (payload as { error?: { message?: string } | string }).error === "object"
? ((payload as { error?: { message?: string } }).error?.message ?? `Request failed: ${res.status}`)
: (payload as { error?: string } | null)?.error ?? `Request failed: ${res.status}`;
throw new Error(message);
throw extractAuthError(payload as AuthErrorBody, res.status);
}
return payload;
}
@@ -43,7 +83,7 @@ async function authPatch<T>(path: string, body: Record<string, unknown>, parse:
});
const payload = await res.json().catch(() => null);
if (!res.ok) {
throw new Error((payload as { error?: string } | null)?.error ?? `Request failed: ${res.status}`);
throw extractAuthError(payload as AuthErrorBody, res.status);
}
return parse(payload);
}
+97 -19
View File
@@ -117,19 +117,13 @@ describe("InviteLandingPage", () => {
vi.clearAllMocks();
});
it("keeps account signup inline and remembers the invite token while signing in", async () => {
getSessionMock
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
.mockResolvedValue({
session: { id: "session-1", userId: "user-1" },
user: {
id: "user-1",
name: "Jane Example",
email: "jane@example.com",
image: null,
},
});
it("defaults invite auth to account creation and guides existing users back to sign in", async () => {
signUpEmailMock.mockRejectedValue(
Object.assign(new Error("User already exists. Use another email."), {
code: "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL",
status: 422,
}),
);
const root = createRoot(container);
const queryClient = new QueryClient({
@@ -150,24 +144,29 @@ describe("InviteLandingPage", () => {
await flushReact();
await flushReact();
expect(container.textContent).toContain("You've been invited to join Paperclip");
expect(container.textContent).toContain("Join Acme Robotics");
expect(container.textContent).toContain("Sign in");
expect(container.textContent).toContain("Create account");
expect(container.textContent).not.toContain("Join as human");
expect(container.textContent).not.toContain("How personal access works");
expect(container.textContent).not.toContain("Choose your path");
expect(container.textContent).toContain("I already have an account");
expect(container.textContent).toContain("Message from inviter");
expect(container.querySelector('[data-testid="invite-inline-auth"]')).not.toBeNull();
expect(localStorage.getItem("paperclip:pending-invite-token")).toBe("pcp_invite_test");
expect(container.querySelector('img[alt="Acme Robotics logo"]')).not.toBeNull();
expect(container.querySelector('input[name="name"]')).not.toBeNull();
const nameInput = container.querySelector('input[name="name"]') as HTMLInputElement | null;
const emailInput = container.querySelector('input[name="email"]') as HTMLInputElement | null;
const passwordInput = container.querySelector('input[name="password"]') as HTMLInputElement | null;
expect(nameInput).not.toBeNull();
expect(emailInput).not.toBeNull();
expect(passwordInput).not.toBeNull();
const inputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
expect(inputValueSetter).toBeTypeOf("function");
await act(async () => {
inputValueSetter!.call(nameInput, "Jane Example");
nameInput!.dispatchEvent(new Event("input", { bubbles: true }));
nameInput!.dispatchEvent(new Event("change", { bubbles: true }));
inputValueSetter!.call(emailInput, "jane@example.com");
emailInput!.dispatchEvent(new Event("input", { bubbles: true }));
emailInput!.dispatchEvent(new Event("change", { bubbles: true }));
@@ -186,11 +185,14 @@ describe("InviteLandingPage", () => {
await flushReact();
await flushReact();
expect(signInEmailMock).toHaveBeenCalledWith({
expect(signUpEmailMock).toHaveBeenCalledWith({
name: "Jane Example",
email: "jane@example.com",
password: "supersecret",
});
expect(getSessionMock).toHaveBeenCalled();
expect(container.textContent).toContain("An account already exists for jane@example.com. Sign in below to continue with this invite.");
expect(container.querySelector('input[name="name"]')).toBeNull();
expect(container.textContent).toContain("Sign in to continue");
expect(localStorage.getItem("paperclip:pending-invite-token")).toBe("pcp_invite_test");
await act(async () => {
@@ -198,6 +200,82 @@ describe("InviteLandingPage", () => {
});
});
it("turns invalid sign-in responses into a clear invite-specific message", async () => {
signInEmailMock.mockRejectedValue(
Object.assign(new Error("Invalid email or password"), {
code: "INVALID_EMAIL_OR_PASSWORD",
status: 401,
}),
);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
<QueryClientProvider client={queryClient}>
<Routes>
<Route path="/invite/:token" element={<InviteLandingPage />} />
</Routes>
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
const inputValueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
expect(inputValueSetter).toBeTypeOf("function");
const existingAccountButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "I already have an account",
);
expect(existingAccountButton).not.toBeNull();
await act(async () => {
existingAccountButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const emailInput = container.querySelector('input[name="email"]') as HTMLInputElement | null;
const passwordInput = container.querySelector('input[name="password"]') as HTMLInputElement | null;
expect(emailInput).not.toBeNull();
expect(passwordInput).not.toBeNull();
await act(async () => {
inputValueSetter!.call(emailInput, "jane@example.com");
emailInput!.dispatchEvent(new Event("input", { bubbles: true }));
emailInput!.dispatchEvent(new Event("change", { bubbles: true }));
inputValueSetter!.call(passwordInput, "wrongpass");
passwordInput!.dispatchEvent(new Event("input", { bubbles: true }));
passwordInput!.dispatchEvent(new Event("change", { bubbles: true }));
});
const authForm = container.querySelector('[data-testid="invite-inline-auth"]') as HTMLFormElement | null;
expect(authForm).not.toBeNull();
await act(async () => {
authForm?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
await flushReact();
await flushReact();
expect(signInEmailMock).toHaveBeenCalledWith({
email: "jane@example.com",
password: "wrongpass",
});
expect(container.textContent).toContain(
"That email and password did not match an existing Paperclip account. Check both fields, or create an account first if you are new here.",
);
await act(async () => {
root.unmount();
});
});
it("falls back to the generated company icon when the invite logo fails to load", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
+344 -151
View File
@@ -12,8 +12,10 @@ import { healthApi } from "../api/health";
import { getAdapterLabel } from "../adapters/adapter-display-registry";
import { clearPendingInviteToken, rememberPendingInviteToken } from "../lib/invite-memory";
import { queryKeys } from "../lib/queryKeys";
import { formatDate } from "../lib/utils";
type AuthMode = "sign_in" | "sign_up";
type AuthFeedback = { tone: "error" | "info"; message: string };
const joinAdapterOptions: AgentAdapterType[] = [...AGENT_ADAPTER_TYPES];
const ENABLED_INVITE_ADAPTERS = new Set([
@@ -36,13 +38,78 @@ function readNestedString(value: unknown, path: string[]): string | null {
const fieldClassName =
"w-full border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-zinc-500";
const panelClassName = "border border-zinc-800 bg-zinc-950/95 p-6";
const modeButtonBaseClassName =
"flex-1 border px-3 py-2 text-sm transition-colors";
function formatHumanRole(role: string | null | undefined) {
if (!role) return null;
return role.charAt(0).toUpperCase() + role.slice(1);
}
function getAuthErrorCode(error: unknown) {
if (!error || typeof error !== "object") return null;
const code = (error as { code?: unknown }).code;
return typeof code === "string" && code.trim().length > 0 ? code : null;
}
function getAuthErrorMessage(error: unknown) {
if (!(error instanceof Error)) return null;
const message = error.message.trim();
return message.length > 0 ? message : null;
}
function mapInviteAuthFeedback(
error: unknown,
authMode: AuthMode,
email: string,
): AuthFeedback {
const code = getAuthErrorCode(error);
const message = getAuthErrorMessage(error);
const emailLabel = email.trim().length > 0 ? email.trim() : "that email";
if (code === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
return {
tone: "info",
message: `An account already exists for ${emailLabel}. Sign in below to continue with this invite.`,
};
}
if (code === "INVALID_EMAIL_OR_PASSWORD") {
return {
tone: "error",
message:
"That email and password did not match an existing Paperclip account. Check both fields, or create an account first if you are new here.",
};
}
if (authMode === "sign_in" && message === "Request failed: 401") {
return {
tone: "error",
message:
"That email and password did not match an existing Paperclip account. Check both fields, or create an account first if you are new here.",
};
}
if (authMode === "sign_up" && message === "Request failed: 422") {
return {
tone: "info",
message: `An account may already exist for ${emailLabel}. Try signing in instead.`,
};
}
return {
tone: "error",
message: message ?? "Authentication failed",
};
}
export function InviteLandingPage() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const params = useParams();
const token = (params.token ?? "").trim();
const [authMode, setAuthMode] = useState<AuthMode>("sign_in");
const [authMode, setAuthMode] = useState<AuthMode>("sign_up");
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
@@ -51,7 +118,7 @@ export function InviteLandingPage() {
const [capabilities, setCapabilities] = useState("");
const [result, setResult] = useState<{ kind: "bootstrap" | "join"; payload: unknown } | null>(null);
const [error, setError] = useState<string | null>(null);
const [authError, setAuthError] = useState<string | null>(null);
const [authFeedback, setAuthFeedback] = useState<AuthFeedback | null>(null);
const healthQuery = useQuery({
queryKey: queryKeys.health,
@@ -107,6 +174,8 @@ export function InviteLandingPage() {
const companyLogoUrl = invite?.companyLogoUrl?.trim() || null;
const companyBrandColor = invite?.companyBrandColor?.trim() || null;
const invitedByUserName = invite?.invitedByUserName?.trim() || null;
const inviteMessage = invite?.inviteMessage?.trim() || null;
const requestedHumanRole = formatHumanRole(invite?.humanRole);
const requiresHumanAccount =
healthQuery.data?.deploymentMode === "authenticated" &&
!sessionQuery.data &&
@@ -168,22 +237,27 @@ export function InviteLandingPage() {
});
},
onSuccess: async () => {
setAuthError(null);
setAuthFeedback(null);
rememberPendingInviteToken(token);
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
},
onError: (err) => {
setAuthError(err instanceof Error ? err.message : "Authentication failed");
const nextFeedback = mapInviteAuthFeedback(err, authMode, email);
if (getAuthErrorCode(err) === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
setAuthMode("sign_in");
setPassword("");
}
setAuthFeedback(nextFeedback);
},
});
const joinButtonLabel = useMemo(() => {
if (!invite) return "Continue";
if (invite.inviteType === "bootstrap_ceo") return "Accept invite";
if (showsAgentForm) return "Submit";
return "Join company";
}, [invite, showsAgentForm]);
if (showsAgentForm) return "Submit request";
return sessionQuery.data ? "Accept invite" : "Continue";
}, [invite, sessionQuery.data, showsAgentForm]);
if (!token) {
return <div className="mx-auto max-w-xl py-10 text-sm text-destructive">Invalid invite token.</div>;
@@ -298,157 +372,276 @@ export function InviteLandingPage() {
return (
<div className="min-h-screen bg-zinc-950 px-6 py-12 text-zinc-100">
<div className="mx-auto max-w-md border border-zinc-800 bg-zinc-950 p-6">
<div className="flex items-center gap-3">
<CompanyPatternIcon
companyName={companyDisplayName}
logoUrl={companyLogoUrl}
brandColor={companyBrandColor}
className="h-12 w-12 border border-zinc-800 rounded-none"
/>
<div>
<h1 className="text-lg font-semibold">
{invite.inviteType === "bootstrap_ceo" ? "Set up Paperclip" : `Join ${companyDisplayName}`}
</h1>
{sessionQuery.data ? (
<p className="mt-1 text-sm text-zinc-400">Signed in as {sessionLabel}</p>
) : null}
</div>
</div>
<div className="mx-auto max-w-5xl">
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.15fr)_minmax(360px,0.85fr)]">
<section className={`${panelClassName} space-y-6`}>
<div className="flex items-start gap-4">
<CompanyPatternIcon
companyName={companyDisplayName}
logoUrl={companyLogoUrl}
brandColor={companyBrandColor}
className="h-16 w-16 rounded-none border border-zinc-800"
/>
<div className="min-w-0">
<p className="text-xs uppercase tracking-[0.24em] text-zinc-500">
You&apos;ve been invited to join Paperclip
</p>
<h1 className="mt-2 text-2xl font-semibold">
{invite.inviteType === "bootstrap_ceo" ? "Set up Paperclip" : `Join ${companyDisplayName}`}
</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-zinc-300">
{showsAgentForm
? "Review the invite details, then submit the agent information below to start the join request."
: requiresHumanAccount
? "Create your Paperclip account first. If you already have one, switch to sign in and continue the invite with the same email."
: "Your account is ready. Review the invite details, then accept it to continue."}
</p>
</div>
</div>
{showsAgentForm ? (
<div className="mt-6 space-y-4">
<label className="block text-sm">
<span className="mb-1 block text-zinc-400">Agent name</span>
<input
className={fieldClassName}
value={agentName}
onChange={(event) => setAgentName(event.target.value)}
/>
</label>
<label className="block text-sm">
<span className="mb-1 block text-zinc-400">Adapter type</span>
<select
className={fieldClassName}
value={adapterType}
onChange={(event) => setAdapterType(event.target.value as AgentAdapterType)}
>
{joinAdapterOptions.map((type) => (
<option key={type} value={type} disabled={!ENABLED_INVITE_ADAPTERS.has(type)}>
{getAdapterLabel(type)}{!ENABLED_INVITE_ADAPTERS.has(type) ? " (Coming soon)" : ""}
</option>
))}
</select>
</label>
<label className="block text-sm">
<span className="mb-1 block text-zinc-400">Capabilities</span>
<textarea
className={fieldClassName}
rows={4}
value={capabilities}
onChange={(event) => setCapabilities(event.target.value)}
/>
</label>
{error ? <p className="text-xs text-red-400">{error}</p> : null}
<Button
className="w-full rounded-none"
disabled={acceptMutation.isPending || agentName.trim().length === 0}
onClick={() => acceptMutation.mutate()}
>
{acceptMutation.isPending ? "Working..." : joinButtonLabel}
</Button>
</div>
) : requiresHumanAccount ? (
<div className="mt-6">
<form
className="space-y-4"
method="post"
action={authMode === "sign_up" ? "/api/auth/sign-up/email" : "/api/auth/sign-in/email"}
onSubmit={(event) => {
event.preventDefault();
if (authMutation.isPending) return;
if (!authCanSubmit) {
setAuthError("Please fill in all required fields.");
return;
}
authMutation.mutate();
}}
data-testid="invite-inline-auth"
>
{authMode === "sign_up" ? (
<div className="grid gap-3 sm:grid-cols-2">
<div className="border border-zinc-800 p-3">
<div className="text-xs uppercase tracking-[0.2em] text-zinc-500">Company</div>
<div className="mt-1 text-sm text-zinc-100">{companyDisplayName}</div>
</div>
<div className="border border-zinc-800 p-3">
<div className="text-xs uppercase tracking-[0.2em] text-zinc-500">Invited by</div>
<div className="mt-1 text-sm text-zinc-100">{invitedByUserName ?? "Paperclip board"}</div>
</div>
<div className="border border-zinc-800 p-3">
<div className="text-xs uppercase tracking-[0.2em] text-zinc-500">Requested access</div>
<div className="mt-1 text-sm text-zinc-100">
{showsAgentForm ? "Agent join request" : requestedHumanRole ?? "Company access"}
</div>
</div>
<div className="border border-zinc-800 p-3">
<div className="text-xs uppercase tracking-[0.2em] text-zinc-500">Invite expires</div>
<div className="mt-1 text-sm text-zinc-100">{formatDate(invite.expiresAt)}</div>
</div>
</div>
{inviteMessage ? (
<div className="border border-amber-500/40 bg-amber-500/10 p-4">
<div className="text-xs uppercase tracking-[0.2em] text-amber-200/80">Message from inviter</div>
<p className="mt-2 text-sm leading-6 text-amber-50">{inviteMessage}</p>
</div>
) : null}
{sessionQuery.data ? (
<div className="border border-emerald-500/40 bg-emerald-500/10 p-4 text-sm text-emerald-50">
Signed in as <span className="font-medium">{sessionLabel}</span>.
</div>
) : null}
</section>
<section className={`${panelClassName} h-fit`}>
{showsAgentForm ? (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold">Submit agent details</h2>
<p className="mt-1 text-sm text-zinc-400">
This invite will create an approval request for a new agent in {companyDisplayName}.
</p>
</div>
<label className="block text-sm">
<span className="mb-1 block text-zinc-400">Name</span>
<span className="mb-1 block text-zinc-400">Agent name</span>
<input
name="name"
className={fieldClassName}
value={name}
onChange={(event) => setName(event.target.value)}
autoComplete="name"
value={agentName}
onChange={(event) => setAgentName(event.target.value)}
/>
</label>
) : null}
<label className="block text-sm">
<span className="mb-1 block text-zinc-400">Email</span>
<input
name="email"
type="email"
className={fieldClassName}
value={email}
onChange={(event) => setEmail(event.target.value)}
autoComplete="email"
/>
</label>
<label className="block text-sm">
<span className="mb-1 block text-zinc-400">Password</span>
<input
name="password"
type="password"
className={fieldClassName}
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete={authMode === "sign_in" ? "current-password" : "new-password"}
/>
</label>
{authError ? <p className="text-xs text-red-400">{authError}</p> : null}
<Button
type="submit"
className="w-full rounded-none"
disabled={authMutation.isPending}
aria-disabled={!authCanSubmit || authMutation.isPending}
>
{authMutation.isPending
? "Working..."
: authMode === "sign_in"
? "Sign in"
: "Create account"}
</Button>
</form>
<label className="block text-sm">
<span className="mb-1 block text-zinc-400">Adapter type</span>
<select
className={fieldClassName}
value={adapterType}
onChange={(event) => setAdapterType(event.target.value as AgentAdapterType)}
>
{joinAdapterOptions.map((type) => (
<option key={type} value={type} disabled={!ENABLED_INVITE_ADAPTERS.has(type)}>
{getAdapterLabel(type)}{!ENABLED_INVITE_ADAPTERS.has(type) ? " (Coming soon)" : ""}
</option>
))}
</select>
</label>
<label className="block text-sm">
<span className="mb-1 block text-zinc-400">Capabilities</span>
<textarea
className={fieldClassName}
rows={4}
value={capabilities}
onChange={(event) => setCapabilities(event.target.value)}
/>
</label>
{error ? <p className="text-xs text-red-400">{error}</p> : null}
<Button
className="w-full rounded-none"
disabled={acceptMutation.isPending || agentName.trim().length === 0}
onClick={() => acceptMutation.mutate()}
>
{acceptMutation.isPending ? "Working..." : joinButtonLabel}
</Button>
</div>
) : requiresHumanAccount ? (
<div className="space-y-5">
<div>
<h2 className="text-lg font-semibold">
{authMode === "sign_up" ? "Create your account" : "Sign in to continue"}
</h2>
<p className="mt-1 text-sm text-zinc-400">
{authMode === "sign_up"
? `Start with a Paperclip account. After that, you'll come right back here to accept the invite for ${companyDisplayName}.`
: "Use the Paperclip account that already matches this invite. If you do not have one yet, switch back to create account."}
</p>
</div>
<div className="mt-3">
<button
type="button"
className="text-sm text-zinc-400 underline underline-offset-2"
onClick={() => {
setAuthError(null);
setAuthMode(authMode === "sign_in" ? "sign_up" : "sign_in");
}}
>
{authMode === "sign_in" ? "Create account" : "Back to sign in"}
</button>
</div>
</div>
) : (
<div className="mt-6">
{error ? <p className="mb-3 text-xs text-red-400">{error}</p> : null}
<Button
className="w-full rounded-none"
disabled={acceptMutation.isPending}
onClick={() => acceptMutation.mutate()}
>
{acceptMutation.isPending ? "Working..." : joinButtonLabel}
</Button>
</div>
)}
<div className="flex gap-2">
<button
type="button"
className={`${modeButtonBaseClassName} ${
authMode === "sign_up"
? "border-zinc-100 bg-zinc-100 text-zinc-950"
: "border-zinc-800 text-zinc-300 hover:border-zinc-600"
}`}
onClick={() => {
setAuthFeedback(null);
setAuthMode("sign_up");
}}
>
Create account
</button>
<button
type="button"
className={`${modeButtonBaseClassName} ${
authMode === "sign_in"
? "border-zinc-100 bg-zinc-100 text-zinc-950"
: "border-zinc-800 text-zinc-300 hover:border-zinc-600"
}`}
onClick={() => {
setAuthFeedback(null);
setAuthMode("sign_in");
}}
>
I already have an account
</button>
</div>
<form
className="space-y-4"
method="post"
action={authMode === "sign_up" ? "/api/auth/sign-up/email" : "/api/auth/sign-in/email"}
onSubmit={(event) => {
event.preventDefault();
if (authMutation.isPending) return;
if (!authCanSubmit) {
setAuthFeedback({ tone: "error", message: "Please fill in all required fields." });
return;
}
authMutation.mutate();
}}
data-testid="invite-inline-auth"
>
{authMode === "sign_up" ? (
<label className="block text-sm">
<span className="mb-1 block text-zinc-400">Name</span>
<input
name="name"
className={fieldClassName}
value={name}
onChange={(event) => {
setName(event.target.value);
setAuthFeedback(null);
}}
autoComplete="name"
autoFocus
/>
</label>
) : null}
<label className="block text-sm">
<span className="mb-1 block text-zinc-400">Email</span>
<input
name="email"
type="email"
className={fieldClassName}
value={email}
onChange={(event) => {
setEmail(event.target.value);
setAuthFeedback(null);
}}
autoComplete="email"
autoFocus={authMode === "sign_in"}
/>
</label>
<label className="block text-sm">
<span className="mb-1 block text-zinc-400">Password</span>
<input
name="password"
type="password"
className={fieldClassName}
value={password}
onChange={(event) => {
setPassword(event.target.value);
setAuthFeedback(null);
}}
autoComplete={authMode === "sign_in" ? "current-password" : "new-password"}
/>
</label>
{authFeedback ? (
<p
className={`text-xs ${
authFeedback.tone === "info" ? "text-amber-300" : "text-red-400"
}`}
>
{authFeedback.message}
</p>
) : null}
<Button
type="submit"
className="w-full rounded-none"
disabled={authMutation.isPending}
aria-disabled={!authCanSubmit || authMutation.isPending}
>
{authMutation.isPending
? "Working..."
: authMode === "sign_in"
? "Sign in and continue"
: "Create account and continue"}
</Button>
</form>
<p className="text-xs leading-5 text-zinc-500">
{authMode === "sign_up"
? "Already signed up before? Use the existing-account option instead so the invite lands on the right Paperclip user."
: "No account yet? Switch back to create account so you can accept the invite with a new login."}
</p>
</div>
) : (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold">
{invite.inviteType === "bootstrap_ceo" ? "Accept bootstrap invite" : "Accept company invite"}
</h2>
<p className="mt-1 text-sm text-zinc-400">
{isCurrentMember
? `This account already belongs to ${companyDisplayName}.`
: `This will ${
invite.inviteType === "bootstrap_ceo" ? "finish setting up Paperclip" : `submit or complete your join request for ${companyDisplayName}`
}.`}
</p>
</div>
{error ? <p className="text-xs text-red-400">{error}</p> : null}
<Button
className="w-full rounded-none"
disabled={acceptMutation.isPending || isCurrentMember}
onClick={() => acceptMutation.mutate()}
>
{acceptMutation.isPending ? "Working..." : joinButtonLabel}
</Button>
</div>
)}
</section>
</div>
</div>
</div>
);