Commit

web: sign-in, account settings and the verify banner say they are working and confirm; the account menu is never served from the browser's copy after a change

syntaqxcommitted Parentf344e57Browse files
24 files+234−1400/24 viewed
+10−4
11 import { Form } from "react-router";
22
3−import { Button } from "./ui";
3+import { SubmitButton } from "./ui";
44
5−/** One row's way out of a list in your settings: a key, a token, an application. */
5+/**
6+ * One row's way out of a list in your settings: a key, a token, an
7+ * application. It says it is working until the row has gone.
8+ */
69 export function DeleteButton({
710 intent,
811 id,
912 label = "Delete",
13+ pending = "Deleting…",
1014 }: {
1115 intent: string;
1216 id: string;
1317 label?: string;
18+ /** The words while it works. */
19+ pending?: string;
1420 }) {
1521 return (
1622 <Form method="post" className="ml-auto">
1723 <input type="hidden" name="intent" value={intent} />
1824 <input type="hidden" name="id" value={id} />
19− <Button variant="quiet" type="submit">
25+ <SubmitButton variant="quiet" pending={pending} match={{ intent, id }}>
2026 {label}
21− </Button>
27+ </SubmitButton>
2228 </Form>
2329 );
2430 }
+15−10
1−import { ImageUp, Trash2 } from "lucide-react";
1+import { ImageUp, LoaderCircle, Trash2 } from "lucide-react";
22 import { useRef } from "react";
3−import { Form, useNavigation } from "react-router";
3+import { Form } from "react-router";
44
55 import { AVATAR_ACCEPT } from "../lib/avatar-upload";
6−import { Avatar, Button, ErrorText } from "./ui";
6+import { Avatar, ErrorText, SubmitButton, usePending } from "./ui";
77
88 /**
99 * Uploading an avatar, as GitHub's settings do: the one shown now, a
2525 error?: string;
2626 about: string;
2727 }) {
28− const navigation = useNavigation();
2928 const form = useRef<HTMLFormElement>(null);
30− const intent = navigation.formData?.get("intent");
31− const busy = navigation.state === "submitting" && (intent === "avatar" || intent === "remove-avatar");
29+ // Working until the new picture (or the letter) is the one shown.
30+ const uploading = usePending({ intent: "avatar" });
31+ const removing = usePending({ intent: "remove-avatar" });
32+ const busy = uploading || removing;
3233 return (
3334 <div className="flex items-start gap-4">
3435 <Avatar name={name} image={image} size={64} square={square} />
4243 busy ? "pointer-events-none opacity-50" : ""
4344 }`}
4445 >
45− <ImageUp size={15} />
46− {busy && intent === "avatar" ? "Uploading…" : image ? "Upload a new image" : "Upload an image"}
46+ {uploading ? (
47+ <LoaderCircle size={15} aria-hidden="true" className="animate-spin" />
48+ ) : (
49+ <ImageUp size={15} />
50+ )}
51+ {uploading ? "Uploading…" : image ? "Upload a new image" : "Upload an image"}
4752 <input
4853 type="file"
4954 name="avatar"
5863 {image && (
5964 <Form method="post">
6065 <input type="hidden" name="intent" value="remove-avatar" />
61− <Button type="submit" variant="quiet" disabled={busy}>
66+ <SubmitButton variant="quiet" pending="Removing…" match={{ intent: "remove-avatar" }} disabled={uploading}>
6267 <Trash2 size={15} />
6368 Remove
64− </Button>
69+ </SubmitButton>
6570 </Form>
6671 )}
6772 </div>
+43−22
55
66 import { addressActions, backupChoices } from "../lib/emails";
77 import type { EmailActionData } from "../lib/emails.server";
8−import { Button, ErrorText, Field, Input, Pill, TimeAgo } from "./ui";
8+import { ErrorText, Field, Input, Pill, SubmitButton, TimeAgo } from "./ui";
99 import { SwitchCard } from "./ui/switch";
1010
1111 /** One small form posting one intent about one address. */
12−function AddressButton({ intent, email, label }: { intent: string; email: string; label: string }) {
12+function AddressButton({ intent, email, label, pending }: { intent: string; email: string; label: string; pending: string }) {
1313 return (
1414 <Form method="post">
1515 <input type="hidden" name="intent" value={intent} />
1616 <input type="hidden" name="email" value={email} />
17− <Button variant="quiet" type="submit">
17+ <SubmitButton variant="quiet" pending={pending} match={{ intent, email }}>
1818 {label}
19− </Button>
19+ </SubmitButton>
2020 </Form>
2121 );
2222 }
6464 <Input name="password" type="password" autoComplete="current-password" required autoFocus />
6565 </Field>
6666 </div>
67− <Button type="submit">Confirm</Button>
67+ <SubmitButton pending="Confirming…" match={{ intent: pending.intent }}>
68+ Confirm
69+ </SubmitButton>
6870 </div>
6971 <p className="text-xs text-faint">You will not be asked again for 10 minutes.</p>
7072 </Form>
7880 );
7981 }
8082
81−/** A person's email addresses, in their account settings. */
83+/** The answer to one form's post, under that form: what went wrong, or that it worked. */
84+function Answer({ actionData }: { actionData: EmailActionData | undefined }) {
85+ return (
86+ <>
87+ <ErrorText>{actionData?.emailError}</ErrorText>
88+ {actionData?.emailNotice && (
89+ <p role="status" className="mt-2 text-sm text-muted">
90+ {actionData.emailNotice}
91+ </p>
92+ )}
93+ </>
94+ );
95+}
96+
97+/**
98+ * A person's email addresses, in their account settings. `intent` is the
99+ * form that last posted, so its answer shows beside it.
100+ */
82101 export function EmailsSection({
83102 data,
84103 actionData,
104+ intent,
85105 hasPassword,
86106 }: {
87107 data: AccountEmails | null;
88108 actionData: EmailActionData | undefined;
109+ intent?: string;
89110 hasPassword: boolean;
90111 }) {
91112 if (!data) return null;
113+ const answerFor = (one: string) => (intent === one ? actionData : undefined);
114+ const elsewhere = intent === "backup-email" || intent === "email-privacy";
92115 const backups = backupChoices(data.emails);
93116 const backup = data.emails.find((email) => email.backup);
94117 const full = data.emails.length >= data.limit;
123146 </div>
124147 </div>
125148 <div className="flex shrink-0 flex-wrap gap-2 sm:justify-end">
126− {can.resend && <AddressButton intent="resend-email" email={email.email} label="Resend link" />}
127− {can.makePrimary && <AddressButton intent="primary-email" email={email.email} label="Make primary" />}
128− {can.remove && <AddressButton intent="remove-email" email={email.email} label="Remove" />}
149+ {can.resend && <AddressButton intent="resend-email" email={email.email} label="Resend link" pending="Sending…" />}
150+ {can.makePrimary && <AddressButton intent="primary-email" email={email.email} label="Make primary" pending="Saving…" />}
151+ {can.remove && <AddressButton intent="remove-email" email={email.email} label="Remove" pending="Removing…" />}
129152 </div>
130153 </li>
131154 );
132155 })}
133156 </ul>
134157
135− <Form method="post" className="mt-4">
158+ {/* Keyed on the list, so an address once added leaves the field empty. */}
159+ <Form key={data.emails.length} method="post" className="mt-4">
136160 <input type="hidden" name="intent" value="add-email" />
137161 <div className="flex flex-col gap-3 sm:flex-row sm:items-end">
138162 <div className="grow">
140164 <Input name="email" type="email" autoComplete="email" placeholder="you@example.com" required disabled={full} />
141165 </Field>
142166 </div>
143− <Button type="submit" disabled={full}>
167+ <SubmitButton pending="Adding…" match={{ intent: "add-email" }} disabled={full}>
144168 Add
145− </Button>
169+ </SubmitButton>
146170 </div>
147171 <p className="mt-1.5 text-xs text-faint">
148172 {full ? `You have ${data.limit} addresses, the most an account can have.` : "g1t sends it a link to confirm it."}
149173 </p>
150174 </Form>
151− <ErrorText>{actionData?.emailError}</ErrorText>
152− {actionData?.emailNotice && (
153− <p role="status" className="mt-2 text-sm text-muted">
154− {actionData.emailNotice}
155− </p>
156− )}
175+ <Answer actionData={elsewhere ? undefined : actionData} />
157176
158177 <div className="mt-8">
159178 <h2 className="font-medium">Backup address</h2>
178197 ))}
179198 </select>
180199 </label>
181− <Button variant="quiet" type="submit" disabled={backups.length === 0}>
200+ <SubmitButton variant="quiet" pending="Saving…" match={{ intent: "backup-email" }} disabled={backups.length === 0}>
182201 Save
183− </Button>
202+ </SubmitButton>
184203 </Form>
204+ <Answer actionData={answerFor("backup-email")} />
185205 {backups.length === 0 && (
186206 <p className="mt-2 text-xs text-faint">Add and confirm a second address to choose a backup.</p>
187207 )}
203223 <p className="text-xs text-faint">
204224 Commits g1t makes for you now carry <span className="font-mono [overflow-wrap:anywhere]">{data.commitEmail}</span>.
205225 </p>
206− <Button variant="quiet" type="submit">
226+ <SubmitButton variant="quiet" pending="Saving…" match={{ intent: "email-privacy" }}>
207227 Save
208− </Button>
228+ </SubmitButton>
209229 </div>
210230 </Form>
231+ <Answer actionData={answerFor("email-privacy")} />
211232 </div>
212233 </section>
213234 );
+15−5
22 import { Form } from "react-router";
33 import { siGithub } from "simple-icons";
44
5−import { TimeAgo } from "./ui";
5+import { SubmitButton, TimeAgo } from "./ui";
66
77 /** GitHub's mark (from Simple Icons), for the buttons that go there. */
88 export function GithubMark({ className = "size-4" }: { className?: string }) {
7070 )}
7171 {syncing && (
7272 <Form method="post" className="ml-auto flex items-center gap-3">
73− <button type="submit" name="intent" value="github-sync" className="text-xs text-muted hover:text-fg">
73+ <SubmitButton
74+ name="intent"
75+ value="github-sync"
76+ pending="Syncing…"
77+ className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg disabled:opacity-50"
78+ >
7479 Sync now
75− </button>
76− <button type="submit" name="intent" value="github-stop" className="text-xs text-faint hover:text-danger">
80+ </SubmitButton>
81+ <SubmitButton
82+ name="intent"
83+ value="github-stop"
84+ pending="Stopping…"
85+ className="inline-flex items-center gap-1 text-xs text-faint hover:text-danger disabled:opacity-50"
86+ >
7787 Stop {link.mode === "mirror" ? "mirroring" : "pushing"}
78− </button>
88+ </SubmitButton>
7989 </Form>
8090 )}
8191 {link.lastError && <p className="w-full text-xs text-warn">{link.lastError}</p>}
+6−3
66 import { useSignUpCopy } from "../lib/registration";
77 import { missingKind, notFoundCopy } from "../lib/not-found";
88 import { Pixel404 } from "./logo";
9−import { ButtonLink } from "./ui";
9+import { ButtonLink, SubmitButton } from "./ui";
1010
1111 /**
1212 * The page for anything someone cannot see. It reads only the kind of thing
7474 className="mt-3 text-xs text-faint"
7575 >
7676 Signed in as <span className="font-mono text-muted">@{copy.signedInAs}</span> ·{" "}
77− <button type="submit" className="text-muted underline-offset-4 hover:text-fg hover:underline">
77+ <SubmitButton
78+ pending="Signing out…"
79+ className="inline-flex items-center gap-1 text-muted underline-offset-4 hover:text-fg hover:underline disabled:opacity-50"
80+ >
7881 Switch account
79− </button>
82+ </SubmitButton>
8083 </Form>
8184 )}
8285 </main>
+6−7
11 import { Check } from "lucide-react";
22 import { useState } from "react";
3−import { Form, Link, useNavigation } from "react-router";
3+import { Form, Link } from "react-router";
44
55 import { PROFILE_LIMITS, type Profile } from "@g1t/contracts";
66
7−import { Button } from "./ui";
7+import { SubmitButton, usePending } from "./ui";
88 import { Field, FieldDescription, FieldError, FieldLabel } from "./ui/field";
99 import { Input } from "./ui/input";
1010 import { Textarea } from "./ui/textarea";
2424 error?: string;
2525 saved: boolean;
2626 }) {
27− const navigation = useNavigation();
28− const busy = navigation.state === "submitting" && navigation.formData?.get("intent") === "profile";
27+ const busy = usePending({ intent: "profile" });
2928 const [bio, setBio] = useState(profile?.bio ?? "");
3029 return (
3130 <section id="profile" className="scroll-mt-20">
9998 <FieldDescription>An https:// address.</FieldDescription>
10099 </Field>
101100 <div className="flex flex-wrap items-center gap-3 sm:col-span-2">
102− <Button type="submit" disabled={busy}>
103− {busy ? "Saving…" : "Save profile"}
104− </Button>
101+ <SubmitButton pending="Saving…" match={{ intent: "profile" }}>
102+ Save profile
103+ </SubmitButton>
105104 {saved && !busy && !error && (
106105 <span className="inline-flex items-center gap-1.5 text-sm text-muted">
107106 <Check size={14} className="text-accent" />
+16−8
3737 import displayFont from "@g1t/theme/fonts/bricolage-grotesque-latin.woff2?url";
3838 import sansFont from "@g1t/theme/fonts/hanken-grotesk-latin.woff2?url";
3939 import { Logo } from "./components/logo";
40−import { Avatar, ButtonLink, notACredential } from "./components/ui";
40+import { Avatar, ButtonLink, SubmitButton, notACredential } from "./components/ui";
4141 import {
4242 DropdownMenu,
4343 DropdownMenuContent,
429429 // Undefined when the root loader itself failed.
430430 const root = useRouteLoaderData<typeof loader>("root");
431431 const user = root?.user;
432− const { pathname } = useLocation();
432+ const { pathname, search } = useLocation();
433+ // /verify lands here with ?sent=1 once it has sent the link again.
434+ const sentAgain = new URLSearchParams(search).get("sent") === "1";
433435 // Drawn around the error page too: a 404 keeps the sidebar out of a
434436 // project or workspace the viewer cannot see.
435437 const error = useRouteError();
449451 action="/verify"
450452 className="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-b border-warn/30 bg-warn/10 px-4 py-2 text-sm"
451453 >
452− <span>
453− Confirm your email address to create repositories and push. We
454− sent you a link.
454+ <span role="status">
455+ {sentAgain
456+ ? "We sent the link again. It can take a minute to arrive."
457+ : "Confirm your email address to create repositories and push. We sent you a link."}
455458 </span>
456− <button type="submit" className="font-medium underline underline-offset-4">
457− Send it again
458− </button>
459+ <SubmitButton
460+ name="intent"
461+ value="resend-verification"
462+ pending="Sending…"
463+ className="inline-flex items-center gap-1.5 font-medium underline underline-offset-4 disabled:opacity-50"
464+ >
465+ {sentAgain ? "Send another" : "Send it again"}
466+ </SubmitButton>
459467 </Form>
460468 );
461469 const banner =
+4−5
1−import { Form, Link, redirect, useNavigation } from "react-router";
1+import { Form, Link, redirect } from "react-router";
22
33 import type { Route } from "./+types/auth-github-username";
44 import { AuthCard } from "../components/auth-card";
55 import { GithubMark } from "../components/github";
6−import { Button, ErrorText, Field, Input } from "../components/ui";
6+import { ErrorText, Field, Input, SubmitButton } from "../components/ui";
77 import { PENDING_COOKIE, cookie, readCookie } from "../lib/github";
88 import { githubSignIn } from "../lib/github.server";
99 import { page } from "../lib/meta";
4444 }
4545
4646 export default function GithubUsername({ loaderData, actionData }: Route.ComponentProps) {
47− const busy = useNavigation().state === "submitting";
4847 return (
4948 <AuthCard
5049 title="Create your account"
8483 )}
8584 <ErrorText>{actionData?.error}</ErrorText>
8685 <div className="pt-2 *:w-full">
87− <Button type="submit" disabled={busy}>
86+ <SubmitButton pending="Creating account…">
8887 Create account
89− </Button>
88+ </SubmitButton>
9089 </div>
9190 </Form>
9291 </AuthCard>
+7−5
33
44 import type { Route } from "./+types/device";
55 import { page } from "../lib/meta";
6−import { Button, ErrorText, Field, Input } from "../components/ui";
6+import { Button, ErrorText, Field, Input, SubmitButton, usePending } from "../components/ui";
77 import { identity } from "../lib/services.server";
88 import { assertSameOrigin, requireUser } from "../lib/session.server";
99
3939
4040 export default function Device({ loaderData, actionData }: Route.ComponentProps) {
4141 const { user, code, pending } = loaderData;
42+ // Either answer turns both buttons off until it is in.
43+ const deciding = usePending();
4244
4345 if (actionData && "done" in actionData) {
4446 const approved = actionData.done === "approved";
8688 <Form method="post" className="mt-6 flex gap-3">
8789 <input type="hidden" name="code" value={pending.userCode} />
8890 <div className="grow *:w-full">
89− <Button variant="accent" type="submit" name="decision" value="approve">
91+ <SubmitButton variant="accent" name="decision" value="approve" pending="Approving…" disabled={deciding}>
9092 Approve
91− </Button>
93+ </SubmitButton>
9294 </div>
93− <Button variant="quiet" type="submit" name="decision" value="deny">
95+ <SubmitButton variant="quiet" name="decision" value="deny" pending="Denying…" disabled={deciding}>
9496 Deny
95− </Button>
97+ </SubmitButton>
9698 </Form>
9799 </>
98100 ) : (
+2−2
33 import type { Route } from "./+types/forgot";
44 import { page } from "../lib/meta";
55 import { AuthCard } from "../components/auth-card";
6−import { Button, Field, Input } from "../components/ui";
6+import { Field, Input, SubmitButton } from "../components/ui";
77 import { identity } from "../lib/services.server";
88 import { assertSameOrigin, clientOf } from "../lib/session.server";
99
4040 <Input name="email" type="email" autoComplete="email" required autoFocus />
4141 </Field>
4242 <div className="pt-2 *:w-full">
43− <Button type="submit">Send reset link</Button>
43+ <SubmitButton pending="Sending…">Send reset link</SubmitButton>
4444 </div>
4545 </Form>
4646 )}
+42−29
11 import { CircleAlert, Lock, Ticket } from "lucide-react";
2−import { Form, Link, data, redirect, useNavigation } from "react-router";
2+import { Form, Link, data, redirect } from "react-router";
33
44 import type { InvitePreview, User } from "@g1t/contracts";
55
88 import { Mark } from "../components/logo";
99 import { ContinueWithGithub, OrDivider } from "../components/github";
1010 import { Honeypot } from "../components/honeypot";
11−import { Avatar, Button, ButtonLink, ErrorText, Field, Input } from "../components/ui";
11+import { Avatar, ButtonLink, ErrorText, Field, Input, SubmitButton } from "../components/ui";
1212 import { githubSignInEnabled } from "../lib/github.server";
1313 import { identity } from "../lib/services.server";
1414 import { cleanCode, landingFor, looksAutomated, suggestUsername, welcomeCookie } from "../lib/invites";
115115
116116 const user = requireUser(context, request);
117117 const result = await identity.acceptInvite(user, code);
118− if (!result.ok) return data({ error: result.error.message }, { status: 422 });
118+ // Said with a 200, so the page loads again and shows the invite as it
119+ // now stands (used up, revoked) beside the reason: after a 4xx answer
120+ // React Router keeps the page's data as it was.
121+ if (!result.ok) return { error: result.error.message };
119122 throw landIn(request, invite, [], true);
120123 }
121124
194197 /** Signing up, on this page: the address the invite was sent to, a username, a password. */
195198 function SignUp({ loaded, error }: { loaded: Loaded; error: string | null }) {
196199 const invite = loaded.invite!;
197− const busy = useNavigation().state === "submitting";
198200 const here = `/invite/${loaded.code}`;
199201 const back = `${here}?accept=1`;
200202 const github = `/auth/github?${new URLSearchParams({ invite: loaded.code, next: back })}`;
247249 </Field>
248250 <ErrorText>{error}</ErrorText>
249251 <div className="pt-1 *:w-full">
250− <Button type="submit" disabled={busy}>
252+ <SubmitButton pending="Creating account…" match={{ intent: "register" }}>
251253 {invite.workspace
252254 ? `Create account and join ${invite.workspace.name}`
253255 : invite.repository
254256 ? "Create account and accept"
255257 : "Create account"}
256− </Button>
258+ </SubmitButton>
257259 </div>
258260 </Form>
259261 <p className="mt-5 text-center text-sm text-muted">
281283 Signed in as <span className="font-mono text-fg">{viewer.username}</span>
282284 </p>
283285 );
286+ // Its own intent, which /logout ignores, so only its button says it is working.
284287 const signOut = (label: string, variant: "primary" | "quiet") => (
285288 <Form method="post" action={`/logout?next=${encodeURIComponent(here)}`}>
286− <Button type="submit" variant={variant}>
289+ <input type="hidden" name="intent" value="sign-out" />
290+ <SubmitButton variant={variant} pending="Signing out…" match={{ intent: "sign-out" }}>
287291 {label}
288− </Button>
292+ </SubmitButton>
289293 </Form>
290294 );
291295 if (invite.forViewer === false) {
328332 );
329333 }
330334 return (
331− <Form method="post" className="space-y-4">
332− <input type="hidden" name="intent" value="accept" />
333− {signedInAs}
334− {invite.forViewer === null && (
335− <p className="text-sm text-muted">This invite is for anyone with the link. Accepting uses it up.</p>
336− )}
337− <ErrorText>{error}</ErrorText>
338− <div className="flex flex-wrap items-center gap-3">
339− <Button type="submit">{joinLabel(invite)}</Button>
340− <span className="text-sm text-muted">
341− Not you?{" "}
342− <button
343− type="submit"
344− formAction={`/logout?next=${encodeURIComponent(here)}`}
345− className="text-fg underline underline-offset-4"
346− >
347− Sign out
348− </button>
349− </span>
350− </div>
351− </Form>
335+ <>
336+ {/* "Not you?" posts this form, so it does not carry the accept form's intent. */}
337+ <Form id="invite-sign-out" method="post" action={`/logout?next=${encodeURIComponent(here)}`} hidden>
338+ <input type="hidden" name="intent" value="sign-out" />
339+ </Form>
340+ <Form method="post" className="space-y-4">
341+ <input type="hidden" name="intent" value="accept" />
342+ {signedInAs}
343+ {invite.forViewer === null && (
344+ <p className="text-sm text-muted">This invite is for anyone with the link. Accepting uses it up.</p>
345+ )}
346+ <ErrorText>{error}</ErrorText>
347+ <div className="flex flex-wrap items-center gap-3">
348+ <SubmitButton pending={invite.workspace ? "Joining…" : "Accepting…"} match={{ intent: "accept" }}>
349+ {joinLabel(invite)}
350+ </SubmitButton>
351+ <span className="text-sm text-muted">
352+ Not you?{" "}
353+ <SubmitButton
354+ form="invite-sign-out"
355+ pending="Signing out…"
356+ match={{ intent: "sign-out" }}
357+ className="inline-flex items-center gap-1 text-fg underline underline-offset-4 disabled:opacity-50"
358+ >
359+ Sign out
360+ </SubmitButton>
361+ </span>
362+ </div>
363+ </Form>
364+ </>
352365 );
353366 }
354367
+2−2
77 import { ContinueWithGithub, GithubMark, OrDivider } from "../components/github";
88 import { PENDING_COOKIE, cookie, readCookie } from "../lib/github";
99 import { githubSignIn, githubSignInEnabled } from "../lib/github.server";
10−import { Button, ErrorText, Field, Input } from "../components/ui";
10+import { ErrorText, Field, Input, SubmitButton } from "../components/ui";
1111 import { identity } from "../lib/services.server";
1212 import {
1313 assertSameOrigin,
101101 </Field>
102102 <ErrorText>{actionData?.error}</ErrorText>
103103 <div className="pt-2 *:w-full">
104− <Button type="submit">Sign in</Button>
104+ <SubmitButton pending="Signing in…">Sign in</SubmitButton>
105105 </div>
106106 <p className="text-center text-sm">
107107 <Link to="/forgot" className="text-muted hover:text-fg">
+15−9
11 import { ArrowLeft, Lock, Plus } from "lucide-react";
2−import { Form, Link, redirect, useNavigation } from "react-router";
2+import { Form, Link, redirect } from "react-router";
33
44 import type { Route } from "./+types/new-github";
55 import { GithubMark } from "../components/github";
6−import { Button, ButtonLink, ErrorText, Pill } from "../components/ui";
6+import { ButtonLink, ErrorText, Pill, SubmitButton } from "../components/ui";
77 import { CheckboxOption } from "../components/ui/checkbox";
88 import { FieldLegend, FieldSet } from "../components/ui/field";
99 import { RadioCard, RadioGroup } from "../components/ui/radio-group";
6161 const installationId = Number(form.get("installation"));
6262 if (form.get("intent") === "remove-installation") {
6363 const removed = await githubApp.removeInstallation(user, workspace, installationId);
64− if (!removed.ok) return { error: removed.error.message, results: [] };
64+ // Shown by the list it was removed from, which is there with or without an account chosen.
65+ if (!removed.ok) return { error: null, removeError: removed.error.message, results: [] };
6566 throw redirect(`/new/github?workspace=${encodeURIComponent(workspace)}`);
6667 }
6768 const mode = (["import", "mirror", "push"] as const).find((option) => option === form.get("mode")) ?? "import";
8485
8586 export default function NewFromGithub({ loaderData, actionData }: Route.ComponentProps) {
8687 const { workspace, workspaces, installations, installation, repositories, linked, owner } = loaderData;
87− const busy = useNavigation().state === "submitting";
8888 const here = `/new/github?workspace=${workspace}`;
8989 const names = new Map((repositories?.repositories ?? []).map((repo) => [repo.id, repo.fullName]));
9090 return (
191191 <input type="hidden" name="intent" value="remove-installation" />
192192 <input type="hidden" name="workspace" value={workspace} />
193193 <input type="hidden" name="installation" value={item.id} />
194− <button type="submit" className="text-xs text-faint hover:text-danger">
194+ <SubmitButton
195+ pending="Removing…"
196+ match={{ intent: "remove-installation", installation: String(item.id) }}
197+ className="inline-flex items-center gap-1 text-xs text-faint hover:text-danger disabled:opacity-50"
198+ >
195199 Remove
196− </button>
200+ </SubmitButton>
197201 </Form>
198202 )}
199203 </span>
201205 ))}
202206 </ul>
203207 )}
208+ <ErrorText>{actionData && "removeError" in actionData ? actionData.removeError : null}</ErrorText>
204209 </section>
205210
206211 {installation && (
207212 <Form method="post" className="mt-10 space-y-8">
213+ <input type="hidden" name="intent" value="import" />
208214 <input type="hidden" name="workspace" value={workspace} />
209215 <input type="hidden" name="installation" value={installation.id} />
210216 <FieldSet>
294300 )}
295301 <ErrorText>{actionData?.error}</ErrorText>
296302 <div className="flex items-center gap-3">
297− <Button type="submit" variant="accent" disabled={busy}>
298− {busy ? "Bringing them across…" : "Bring to g1t"}
299− </Button>
303+ <SubmitButton variant="accent" pending="Bringing them across…" match={{ intent: "import" }}>
304+ Bring to g1t
305+ </SubmitButton>
300306 <ButtonLink to={`/new?workspace=${workspace}`} variant="quiet">
301307 Cancel
302308 </ButtonLink>
+4−5
11 import { Box, Download, GitBranch, Globe, Lock, Sparkles } from "lucide-react";
22 import { useState } from "react";
3−import { Form, redirect, useNavigate, useNavigation } from "react-router";
3+import { Form, redirect, useNavigate } from "react-router";
44
55 import type { Route } from "./+types/new";
66 import { page } from "../lib/meta";
7−import { Avatar, Button, ErrorText } from "../components/ui";
7+import { Avatar, ErrorText, SubmitButton } from "../components/ui";
88 import { Field, FieldDescription, FieldLabel, FieldLegend, FieldSet } from "../components/ui/field";
99 import { Input, InputAddon, InputGroup } from "../components/ui/input";
1010 import { RadioCard, RadioGroup } from "../components/ui/radio-group";
9393 const sources = loaderData.github ? SOURCES.map((option) => (option.id === "mirror" ? GITHUB_SOURCE : option)) : SOURCES;
9494 const [workspace, setWorkspace] = useState(loaderData.selected);
9595 const [name, setName] = useState("");
96− const busy = useNavigation().state === "submitting";
9796 return (
9897 <main className="mx-auto max-w-2xl px-4 py-12">
9998 <span className="flex size-10 items-center justify-center rounded-xl bg-accent/10 text-accent ring-1 ring-accent/30">
212211 </FieldSet>
213212
214213 <ErrorText>{actionData?.error}</ErrorText>
215− <Button type="submit" variant="accent" disabled={busy}>
214+ <SubmitButton variant="accent" pending={source === "import" ? "Importing…" : "Creating…"}>
216215 Create project
217− </Button>
216+ </SubmitButton>
218217 </Form>
219218 </main>
220219 );
+7−5
55
66 import type { Route } from "./+types/oauth-authorize";
77 import { page } from "../lib/meta";
8−import { Button, ErrorText } from "../components/ui";
8+import { ErrorText, SubmitButton, usePending } from "../components/ui";
99 import { ScopeChecklist } from "../components/token-scopes";
1010 import { identity } from "../lib/services.server";
1111 import { addresses } from "../lib/addresses.server";
118118
119119 export default function Authorize({ loaderData, actionData }: Route.ComponentProps) {
120120 const { user, request, requested, query } = loaderData;
121+ // Either answer turns both buttons off until it is in.
122+ const deciding = usePending();
121123
122124 if (!request.ok) {
123125 return (
170172 </p>
171173 <ErrorText>{actionData?.error}</ErrorText>
172174 <div className="mt-4 flex gap-2">
173− <Button variant="accent" type="submit" name="decision" value="approve">
175+ <SubmitButton variant="accent" name="decision" value="approve" pending="Approving…" disabled={deciding}>
174176 Approve
175− </Button>
176− <Button variant="quiet" type="submit" name="decision" value="deny">
177+ </SubmitButton>
178+ <SubmitButton variant="quiet" name="decision" value="deny" pending="Denying…" disabled={deciding}>
177179 Deny
178− </Button>
180+ </SubmitButton>
179181 </div>
180182 </div>
181183 </Form>
+7−3
99 import { ContinueWithGithub, OrDivider } from "../components/github";
1010 import { Honeypot } from "../components/honeypot";
1111 import { githubSignInEnabled } from "../lib/github.server";
12−import { Avatar, Button, ErrorText, Field, Input } from "../components/ui";
12+import { Avatar, Button, ErrorText, Field, Input, SubmitButton } from "../components/ui";
1313 import { identity } from "../lib/services.server";
1414 import { cleanCode, looksAutomated } from "../lib/invites";
1515 import { clientKey, registrationMode } from "../lib/registration.server";
187187 </Field>
188188 <ErrorText>{error}</ErrorText>
189189 <div className="pt-2 *:w-full">
190− <Button type="submit">{invite?.workspace ? `Create account and join ${invite.workspace.slug}` : "Create account"}</Button>
190+ <SubmitButton pending="Creating account…">
191+ {invite?.workspace ? `Create account and join ${invite.workspace.slug}` : "Create account"}
192+ </SubmitButton>
191193 </div>
192194 </Form>
193195 </>
262264 </Field>
263265 <ErrorText>{actionData && "requestError" in actionData ? actionData.requestError : undefined}</ErrorText>
264266 <div className="*:w-full">
265− <Button type="submit">Request access</Button>
267+ <SubmitButton pending="Sending…" match={{ intent: "request-access" }}>
268+ Request access
269+ </SubmitButton>
266270 </div>
267271 </Form>
268272 </>
+2−2
33 import type { Route } from "./+types/reset";
44 import { page } from "../lib/meta";
55 import { AuthCard } from "../components/auth-card";
6−import { Button, ErrorText, Field, Input } from "../components/ui";
6+import { ErrorText, Field, Input, SubmitButton } from "../components/ui";
77 import { identity } from "../lib/services.server";
88 import { assertSameOrigin } from "../lib/session.server";
99
5151 </Field>
5252 <ErrorText>{actionData?.error}</ErrorText>
5353 <div className="pt-2 *:w-full">
54− <Button type="submit">Set password</Button>
54+ <SubmitButton pending="Saving…">Set password</SubmitButton>
5555 </div>
5656 </Form>
5757 </AuthCard>
+3−1
3030 email: primary,
3131 invites_left: invites?.allowance.limit != null ? (invites.allowance.remaining ?? 0) : null,
3232 };
33− return Response.json(data, { headers: { "cache-control": "private, max-age=60" } });
33+ // Not kept by the browser: the menu asks again after a name, address or
34+ // invite changes, and a kept copy would show the old one for a minute.
35+ return Response.json(data, { headers: { "cache-control": "private, no-store" } });
3436 }
+5−3
33
44 import type { Route } from "./+types/applications";
55 import { page } from "../../lib/meta";
6−import { Button, ButtonLink, ErrorText, TimeAgo } from "../../components/ui";
6+import { ButtonLink, ErrorText, SubmitButton, TimeAgo } from "../../components/ui";
77 import { DeleteButton } from "../../components/account-settings";
88 import { AccessSummary, ScopeChecklist } from "../../components/token-scopes";
99 import { grantFromForm } from "../../lib/token-scopes";
7474 Change access
7575 </ButtonLink>
7676 )}
77− <DeleteButton intent="sign-out-application" id={application.id} label="Sign out" />
77+ <DeleteButton intent="sign-out-application" id={application.id} label="Sign out" pending="Signing out…" />
7878 </div>
7979 </div>
8080 {open && (
8888 <ScopeChecklist initial={application.scopes} />
8989 {actionData?.editing === application.id && <ErrorText>{actionData.error}</ErrorText>}
9090 <div className="flex gap-2">
91− <Button type="submit">Save access</Button>
91+ <SubmitButton pending="Saving…" match={{ intent: "update-application", id: application.id }}>
92+ Save access
93+ </SubmitButton>
9294 <ButtonLink variant="quiet" to="." preventScrollReset>
9395 Cancel
9496 </ButtonLink>
+3−1
2222 const form = await request.formData();
2323 // Sensitive changes may first ask for the password (lib/emails.server.ts).
2424 if ((EMAIL_INTENTS as readonly string[]).includes(String(form.get("intent")))) {
25− return { emailAction: await emailAction(user, form, request) };
25+ // Which form posted, so its answer shows beside it.
26+ return { emailAction: await emailAction(user, form, request), emailIntent: String(form.get("intent")) };
2627 }
2728 return null;
2829 }
3233 <EmailsSection
3334 data={loaderData.emails}
3435 actionData={actionData?.emailAction}
36+ intent={actionData?.emailIntent}
3537 hasPassword={loaderData.hasPassword}
3638 />
3739 );
+3−3
22
33 import type { Route } from "./+types/github";
44 import { page } from "../../lib/meta";
5−import { Button, ErrorText, TimeAgo } from "../../components/ui";
5+import { ErrorText, SubmitButton, TimeAgo } from "../../components/ui";
66 import { assertSameOrigin, requireUser } from "../../lib/session.server";
77 import { GithubMark } from "../../components/github";
88 import { githubSignIn } from "../../lib/github.server";
5454 )}
5555 <Form method="post">
5656 <input type="hidden" name="intent" value="unlink-github" />
57− <Button variant="quiet" type="submit" disabled={!github.hasPassword} title={github.hasPassword ? undefined : "GitHub is how you sign in. Set a password first."}>
57+ <SubmitButton variant="quiet" pending="Unlinking…" match={{ intent: "unlink-github" }} disabled={!github.hasPassword} title={github.hasPassword ? undefined : "GitHub is how you sign in. Set a password first."}>
5858 Unlink
59− </Button>
59+ </SubmitButton>
6060 </Form>
6161 </div>
6262 </div>
+6−3
33
44 import type { Route } from "./+types/keys";
55 import { page } from "../../lib/meta";
6−import { Button, ErrorText, Field, Input } from "../../components/ui";
6+import { ErrorText, Field, Input, SubmitButton } from "../../components/ui";
77 import { DeleteButton } from "../../components/account-settings";
88 import { assertSameOrigin, requireUser } from "../../lib/session.server";
99
5151 </li>
5252 ))}
5353 </ul>
54− <Form method="post" className={`space-y-3 ${keys.length > 0 ? "mt-4" : ""}`}>
54+ {/* Keyed on the list, so a key once added leaves the fields empty for the next. */}
55+ <Form key={keys.length} method="post" className={`space-y-3 ${keys.length > 0 ? "mt-4" : ""}`}>
5556 <input type="hidden" name="intent" value="add-key" />
5657 <Field label="Title (optional)">
5758 <Input name="title" maxLength={100} />
6061 <Input name="key" placeholder="ssh-ed25519 AAAA…" required />
6162 </Field>
6263 <ErrorText>{actionData?.keyError}</ErrorText>
63− <Button type="submit">Add SSH key</Button>
64+ <SubmitButton pending="Adding…" match={{ intent: "add-key" }}>
65+ Add SSH key
66+ </SubmitButton>
6467 </Form>
6568 </section>
6669 );
+2−0
6464 />
6565 </div>
6666 </section>
67+ {/* Keyed on what was saved, so the fields show identity's copy once it answers. */}
6768 <ProfileSection
69+ key={JSON.stringify(profile)}
6870 username={user.username}
6971 profile={profile}
7072 error={actionData && "profileError" in actionData ? actionData.profileError : undefined}
+9−3
66 import type { Route } from "./+types/tokens";
77 import { page } from "../../lib/meta";
88 import {
9− Button,
9+ SubmitButton,
1010 ButtonLink,
1111 ErrorText,
1212 Field,
155155 </ul>
156156
157157 {!editing && (
158+ // Keyed on the token just made, so the fields start over for the next.
158159 <Form
160+ key={created?.id ?? "new"}
159161 method="post"
160162 className="mt-8 space-y-5 rounded-md border border-line p-4 sm:p-5"
161163 >
174176 </div>
175177 <ScopeChecklist initial={presetScopes("agent")} />
176178 {!actionData?.editing && <ErrorText>{actionData?.error}</ErrorText>}
177− <Button type="submit">Create token</Button>
179+ <SubmitButton pending="Creating…" match={{ intent: "add-token" }}>
180+ Create token
181+ </SubmitButton>
178182 </Form>
179183 )}
180184 </section>
246250 <ScopeChecklist initial={token.scopes} />
247251 <ErrorText>{error}</ErrorText>
248252 <div className="flex gap-2">
249− <Button type="submit">Save access</Button>
253+ <SubmitButton pending="Saving…" match={{ intent: "update-token", id: token.id }}>
254+ Save access
255+ </SubmitButton>
250256 <ButtonLink variant="quiet" to="." preventScrollReset>
251257 Cancel
252258 </ButtonLink>