diff --git a/ui/src/api/auth.ts b/ui/src/api/auth.ts index 01a3298de62..b0da51651a1 100644 --- a/ui/src/api/auth.ts +++ b/ui/src/api/auth.ts @@ -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) { const res = await fetch(`/api/auth${path}`, { method: "POST", @@ -24,12 +69,7 @@ async function authPost(path: string, body: Record) { }); 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(path: string, body: Record, 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); } diff --git a/ui/src/pages/InviteLanding.test.tsx b/ui/src/pages/InviteLanding.test.tsx index 18f272917b6..47cfcabe4aa 100644 --- a/ui/src/pages/InviteLanding.test.tsx +++ b/ui/src/pages/InviteLanding.test.tsx @@ -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( + + + + } /> + + + , + ); + }); + 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({ diff --git a/ui/src/pages/InviteLanding.tsx b/ui/src/pages/InviteLanding.tsx index 14ccf45a767..f021172f32f 100644 --- a/ui/src/pages/InviteLanding.tsx +++ b/ui/src/pages/InviteLanding.tsx @@ -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("sign_in"); + const [authMode, setAuthMode] = useState("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(null); - const [authError, setAuthError] = useState(null); + const [authFeedback, setAuthFeedback] = useState(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
Invalid invite token.
; @@ -298,157 +372,276 @@ export function InviteLandingPage() { return (
-
-
- -
-

- {invite.inviteType === "bootstrap_ceo" ? "Set up Paperclip" : `Join ${companyDisplayName}`} -

- {sessionQuery.data ? ( -

Signed in as {sessionLabel}

- ) : null} -
-
+
+
+
+
+ +
+

+ You've been invited to join Paperclip +

+

+ {invite.inviteType === "bootstrap_ceo" ? "Set up Paperclip" : `Join ${companyDisplayName}`} +

+

+ {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."} +

+
+
- {showsAgentForm ? ( -
- - -