mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
Improve invite auth clarity
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
2a3910190f
commit
089d73e2f4
3 files changed
+488
-177
No files matched your search
+47
-7
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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'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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user