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
24 files+234−1400/24 viewed
| 1 | 1 | import { Form } from "react-router"; | |
| 2 | 2 | ||
| 3 | − | import { Button } from "./ui"; | |
| 3 | + | import { SubmitButton } from "./ui"; | |
| 4 | 4 | ||
| 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 | + | */ | |
| 6 | 9 | export function DeleteButton({ | |
| 7 | 10 | intent, | |
| 8 | 11 | id, | |
| 9 | 12 | label = "Delete", | |
| 13 | + | pending = "Deleting…", | |
| 10 | 14 | }: { | |
| 11 | 15 | intent: string; | |
| 12 | 16 | id: string; | |
| 13 | 17 | label?: string; | |
| 18 | + | /** The words while it works. */ | |
| 19 | + | pending?: string; | |
| 14 | 20 | }) { | |
| 15 | 21 | return ( | |
| 16 | 22 | <Form method="post" className="ml-auto"> | |
| 17 | 23 | <input type="hidden" name="intent" value={intent} /> | |
| 18 | 24 | <input type="hidden" name="id" value={id} /> | |
| 19 | − | <Button variant="quiet" type="submit"> | |
| 25 | + | <SubmitButton variant="quiet" pending={pending} match={{ intent, id }}> | |
| 20 | 26 | {label} | |
| 21 | − | </Button> | |
| 27 | + | </SubmitButton> | |
| 22 | 28 | </Form> | |
| 23 | 29 | ); | |
| 24 | 30 | } |
| 1 | − | import { ImageUp, Trash2 } from "lucide-react"; | |
| 1 | + | import { ImageUp, LoaderCircle, Trash2 } from "lucide-react"; | |
| 2 | 2 | import { useRef } from "react"; | |
| 3 | − | import { Form, useNavigation } from "react-router"; | |
| 3 | + | import { Form } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import { AVATAR_ACCEPT } from "../lib/avatar-upload"; | |
| 6 | − | import { Avatar, Button, ErrorText } from "./ui"; | |
| 6 | + | import { Avatar, ErrorText, SubmitButton, usePending } from "./ui"; | |
| 7 | 7 | ||
| 8 | 8 | /** | |
| 9 | 9 | * Uploading an avatar, as GitHub's settings do: the one shown now, a | |
| 25 | 25 | error?: string; | |
| 26 | 26 | about: string; | |
| 27 | 27 | }) { | |
| 28 | − | const navigation = useNavigation(); | |
| 29 | 28 | 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; | |
| 32 | 33 | return ( | |
| 33 | 34 | <div className="flex items-start gap-4"> | |
| 34 | 35 | <Avatar name={name} image={image} size={64} square={square} /> | |
| 42 | 43 | busy ? "pointer-events-none opacity-50" : "" | |
| 43 | 44 | }`} | |
| 44 | 45 | > | |
| 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"} | |
| 47 | 52 | <input | |
| 48 | 53 | type="file" | |
| 49 | 54 | name="avatar" | |
| 58 | 63 | {image && ( | |
| 59 | 64 | <Form method="post"> | |
| 60 | 65 | <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}> | |
| 62 | 67 | <Trash2 size={15} /> | |
| 63 | 68 | Remove | |
| 64 | − | </Button> | |
| 69 | + | </SubmitButton> | |
| 65 | 70 | </Form> | |
| 66 | 71 | )} | |
| 67 | 72 | </div> |
| 5 | 5 | ||
| 6 | 6 | import { addressActions, backupChoices } from "../lib/emails"; | |
| 7 | 7 | 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"; | |
| 9 | 9 | import { SwitchCard } from "./ui/switch"; | |
| 10 | 10 | ||
| 11 | 11 | /** 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 }) { | |
| 13 | 13 | return ( | |
| 14 | 14 | <Form method="post"> | |
| 15 | 15 | <input type="hidden" name="intent" value={intent} /> | |
| 16 | 16 | <input type="hidden" name="email" value={email} /> | |
| 17 | − | <Button variant="quiet" type="submit"> | |
| 17 | + | <SubmitButton variant="quiet" pending={pending} match={{ intent, email }}> | |
| 18 | 18 | {label} | |
| 19 | − | </Button> | |
| 19 | + | </SubmitButton> | |
| 20 | 20 | </Form> | |
| 21 | 21 | ); | |
| 22 | 22 | } | |
| 64 | 64 | <Input name="password" type="password" autoComplete="current-password" required autoFocus /> | |
| 65 | 65 | </Field> | |
| 66 | 66 | </div> | |
| 67 | − | <Button type="submit">Confirm</Button> | |
| 67 | + | <SubmitButton pending="Confirming…" match={{ intent: pending.intent }}> | |
| 68 | + | Confirm | |
| 69 | + | </SubmitButton> | |
| 68 | 70 | </div> | |
| 69 | 71 | <p className="text-xs text-faint">You will not be asked again for 10 minutes.</p> | |
| 70 | 72 | </Form> | |
| 78 | 80 | ); | |
| 79 | 81 | } | |
| 80 | 82 | ||
| 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 | + | */ | |
| 82 | 101 | export function EmailsSection({ | |
| 83 | 102 | data, | |
| 84 | 103 | actionData, | |
| 104 | + | intent, | |
| 85 | 105 | hasPassword, | |
| 86 | 106 | }: { | |
| 87 | 107 | data: AccountEmails | null; | |
| 88 | 108 | actionData: EmailActionData | undefined; | |
| 109 | + | intent?: string; | |
| 89 | 110 | hasPassword: boolean; | |
| 90 | 111 | }) { | |
| 91 | 112 | if (!data) return null; | |
| 113 | + | const answerFor = (one: string) => (intent === one ? actionData : undefined); | |
| 114 | + | const elsewhere = intent === "backup-email" || intent === "email-privacy"; | |
| 92 | 115 | const backups = backupChoices(data.emails); | |
| 93 | 116 | const backup = data.emails.find((email) => email.backup); | |
| 94 | 117 | const full = data.emails.length >= data.limit; | |
| 123 | 146 | </div> | |
| 124 | 147 | </div> | |
| 125 | 148 | <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…" />} | |
| 129 | 152 | </div> | |
| 130 | 153 | </li> | |
| 131 | 154 | ); | |
| 132 | 155 | })} | |
| 133 | 156 | </ul> | |
| 134 | 157 | ||
| 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"> | |
| 136 | 160 | <input type="hidden" name="intent" value="add-email" /> | |
| 137 | 161 | <div className="flex flex-col gap-3 sm:flex-row sm:items-end"> | |
| 138 | 162 | <div className="grow"> | |
| 140 | 164 | <Input name="email" type="email" autoComplete="email" placeholder="you@example.com" required disabled={full} /> | |
| 141 | 165 | </Field> | |
| 142 | 166 | </div> | |
| 143 | − | <Button type="submit" disabled={full}> | |
| 167 | + | <SubmitButton pending="Adding…" match={{ intent: "add-email" }} disabled={full}> | |
| 144 | 168 | Add | |
| 145 | − | </Button> | |
| 169 | + | </SubmitButton> | |
| 146 | 170 | </div> | |
| 147 | 171 | <p className="mt-1.5 text-xs text-faint"> | |
| 148 | 172 | {full ? `You have ${data.limit} addresses, the most an account can have.` : "g1t sends it a link to confirm it."} | |
| 149 | 173 | </p> | |
| 150 | 174 | </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} /> | |
| 157 | 176 | ||
| 158 | 177 | <div className="mt-8"> | |
| 159 | 178 | <h2 className="font-medium">Backup address</h2> | |
| 178 | 197 | ))} | |
| 179 | 198 | </select> | |
| 180 | 199 | </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}> | |
| 182 | 201 | Save | |
| 183 | − | </Button> | |
| 202 | + | </SubmitButton> | |
| 184 | 203 | </Form> | |
| 204 | + | <Answer actionData={answerFor("backup-email")} /> | |
| 185 | 205 | {backups.length === 0 && ( | |
| 186 | 206 | <p className="mt-2 text-xs text-faint">Add and confirm a second address to choose a backup.</p> | |
| 187 | 207 | )} | |
| 203 | 223 | <p className="text-xs text-faint"> | |
| 204 | 224 | Commits g1t makes for you now carry <span className="font-mono [overflow-wrap:anywhere]">{data.commitEmail}</span>. | |
| 205 | 225 | </p> | |
| 206 | − | <Button variant="quiet" type="submit"> | |
| 226 | + | <SubmitButton variant="quiet" pending="Saving…" match={{ intent: "email-privacy" }}> | |
| 207 | 227 | Save | |
| 208 | − | </Button> | |
| 228 | + | </SubmitButton> | |
| 209 | 229 | </div> | |
| 210 | 230 | </Form> | |
| 231 | + | <Answer actionData={answerFor("email-privacy")} /> | |
| 211 | 232 | </div> | |
| 212 | 233 | </section> | |
| 213 | 234 | ); |
| 2 | 2 | import { Form } from "react-router"; | |
| 3 | 3 | import { siGithub } from "simple-icons"; | |
| 4 | 4 | ||
| 5 | − | import { TimeAgo } from "./ui"; | |
| 5 | + | import { SubmitButton, TimeAgo } from "./ui"; | |
| 6 | 6 | ||
| 7 | 7 | /** GitHub's mark (from Simple Icons), for the buttons that go there. */ | |
| 8 | 8 | export function GithubMark({ className = "size-4" }: { className?: string }) { | |
| 70 | 70 | )} | |
| 71 | 71 | {syncing && ( | |
| 72 | 72 | <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 | + | > | |
| 74 | 79 | 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 | + | > | |
| 77 | 87 | Stop {link.mode === "mirror" ? "mirroring" : "pushing"} | |
| 78 | − | </button> | |
| 88 | + | </SubmitButton> | |
| 79 | 89 | </Form> | |
| 80 | 90 | )} | |
| 81 | 91 | {link.lastError && <p className="w-full text-xs text-warn">{link.lastError}</p>} |
| 6 | 6 | import { useSignUpCopy } from "../lib/registration"; | |
| 7 | 7 | import { missingKind, notFoundCopy } from "../lib/not-found"; | |
| 8 | 8 | import { Pixel404 } from "./logo"; | |
| 9 | − | import { ButtonLink } from "./ui"; | |
| 9 | + | import { ButtonLink, SubmitButton } from "./ui"; | |
| 10 | 10 | ||
| 11 | 11 | /** | |
| 12 | 12 | * The page for anything someone cannot see. It reads only the kind of thing | |
| 74 | 74 | className="mt-3 text-xs text-faint" | |
| 75 | 75 | > | |
| 76 | 76 | 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 | + | > | |
| 78 | 81 | Switch account | |
| 79 | − | </button> | |
| 82 | + | </SubmitButton> | |
| 80 | 83 | </Form> | |
| 81 | 84 | )} | |
| 82 | 85 | </main> |
| 1 | 1 | import { Check } from "lucide-react"; | |
| 2 | 2 | import { useState } from "react"; | |
| 3 | − | import { Form, Link, useNavigation } from "react-router"; | |
| 3 | + | import { Form, Link } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import { PROFILE_LIMITS, type Profile } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | − | import { Button } from "./ui"; | |
| 7 | + | import { SubmitButton, usePending } from "./ui"; | |
| 8 | 8 | import { Field, FieldDescription, FieldError, FieldLabel } from "./ui/field"; | |
| 9 | 9 | import { Input } from "./ui/input"; | |
| 10 | 10 | import { Textarea } from "./ui/textarea"; | |
| 24 | 24 | error?: string; | |
| 25 | 25 | saved: boolean; | |
| 26 | 26 | }) { | |
| 27 | − | const navigation = useNavigation(); | |
| 28 | − | const busy = navigation.state === "submitting" && navigation.formData?.get("intent") === "profile"; | |
| 27 | + | const busy = usePending({ intent: "profile" }); | |
| 29 | 28 | const [bio, setBio] = useState(profile?.bio ?? ""); | |
| 30 | 29 | return ( | |
| 31 | 30 | <section id="profile" className="scroll-mt-20"> | |
| 99 | 98 | <FieldDescription>An https:// address.</FieldDescription> | |
| 100 | 99 | </Field> | |
| 101 | 100 | <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> | |
| 105 | 104 | {saved && !busy && !error && ( | |
| 106 | 105 | <span className="inline-flex items-center gap-1.5 text-sm text-muted"> | |
| 107 | 106 | <Check size={14} className="text-accent" /> |
| 37 | 37 | import displayFont from "@g1t/theme/fonts/bricolage-grotesque-latin.woff2?url"; | |
| 38 | 38 | import sansFont from "@g1t/theme/fonts/hanken-grotesk-latin.woff2?url"; | |
| 39 | 39 | import { Logo } from "./components/logo"; | |
| 40 | − | import { Avatar, ButtonLink, notACredential } from "./components/ui"; | |
| 40 | + | import { Avatar, ButtonLink, SubmitButton, notACredential } from "./components/ui"; | |
| 41 | 41 | import { | |
| 42 | 42 | DropdownMenu, | |
| 43 | 43 | DropdownMenuContent, | |
| 429 | 429 | // Undefined when the root loader itself failed. | |
| 430 | 430 | const root = useRouteLoaderData<typeof loader>("root"); | |
| 431 | 431 | 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"; | |
| 433 | 435 | // Drawn around the error page too: a 404 keeps the sidebar out of a | |
| 434 | 436 | // project or workspace the viewer cannot see. | |
| 435 | 437 | const error = useRouteError(); | |
| 449 | 451 | action="/verify" | |
| 450 | 452 | 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" | |
| 451 | 453 | > | |
| 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."} | |
| 455 | 458 | </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> | |
| 459 | 467 | </Form> | |
| 460 | 468 | ); | |
| 461 | 469 | const banner = |
| 1 | − | import { Form, Link, redirect, useNavigation } from "react-router"; | |
| 1 | + | import { Form, Link, redirect } from "react-router"; | |
| 2 | 2 | ||
| 3 | 3 | import type { Route } from "./+types/auth-github-username"; | |
| 4 | 4 | import { AuthCard } from "../components/auth-card"; | |
| 5 | 5 | import { GithubMark } from "../components/github"; | |
| 6 | − | import { Button, ErrorText, Field, Input } from "../components/ui"; | |
| 6 | + | import { ErrorText, Field, Input, SubmitButton } from "../components/ui"; | |
| 7 | 7 | import { PENDING_COOKIE, cookie, readCookie } from "../lib/github"; | |
| 8 | 8 | import { githubSignIn } from "../lib/github.server"; | |
| 9 | 9 | import { page } from "../lib/meta"; | |
| 44 | 44 | } | |
| 45 | 45 | ||
| 46 | 46 | export default function GithubUsername({ loaderData, actionData }: Route.ComponentProps) { | |
| 47 | − | const busy = useNavigation().state === "submitting"; | |
| 48 | 47 | return ( | |
| 49 | 48 | <AuthCard | |
| 50 | 49 | title="Create your account" | |
| 84 | 83 | )} | |
| 85 | 84 | <ErrorText>{actionData?.error}</ErrorText> | |
| 86 | 85 | <div className="pt-2 *:w-full"> | |
| 87 | − | <Button type="submit" disabled={busy}> | |
| 86 | + | <SubmitButton pending="Creating account…"> | |
| 88 | 87 | Create account | |
| 89 | − | </Button> | |
| 88 | + | </SubmitButton> | |
| 90 | 89 | </div> | |
| 91 | 90 | </Form> | |
| 92 | 91 | </AuthCard> |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/device"; | |
| 5 | 5 | 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"; | |
| 7 | 7 | import { identity } from "../lib/services.server"; | |
| 8 | 8 | import { assertSameOrigin, requireUser } from "../lib/session.server"; | |
| 9 | 9 | ||
| 39 | 39 | ||
| 40 | 40 | export default function Device({ loaderData, actionData }: Route.ComponentProps) { | |
| 41 | 41 | const { user, code, pending } = loaderData; | |
| 42 | + | // Either answer turns both buttons off until it is in. | |
| 43 | + | const deciding = usePending(); | |
| 42 | 44 | ||
| 43 | 45 | if (actionData && "done" in actionData) { | |
| 44 | 46 | const approved = actionData.done === "approved"; | |
| 86 | 88 | <Form method="post" className="mt-6 flex gap-3"> | |
| 87 | 89 | <input type="hidden" name="code" value={pending.userCode} /> | |
| 88 | 90 | <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}> | |
| 90 | 92 | Approve | |
| 91 | − | </Button> | |
| 93 | + | </SubmitButton> | |
| 92 | 94 | </div> | |
| 93 | − | <Button variant="quiet" type="submit" name="decision" value="deny"> | |
| 95 | + | <SubmitButton variant="quiet" name="decision" value="deny" pending="Denying…" disabled={deciding}> | |
| 94 | 96 | Deny | |
| 95 | − | </Button> | |
| 97 | + | </SubmitButton> | |
| 96 | 98 | </Form> | |
| 97 | 99 | </> | |
| 98 | 100 | ) : ( |
| 3 | 3 | import type { Route } from "./+types/forgot"; | |
| 4 | 4 | import { page } from "../lib/meta"; | |
| 5 | 5 | import { AuthCard } from "../components/auth-card"; | |
| 6 | − | import { Button, Field, Input } from "../components/ui"; | |
| 6 | + | import { Field, Input, SubmitButton } from "../components/ui"; | |
| 7 | 7 | import { identity } from "../lib/services.server"; | |
| 8 | 8 | import { assertSameOrigin, clientOf } from "../lib/session.server"; | |
| 9 | 9 | ||
| 40 | 40 | <Input name="email" type="email" autoComplete="email" required autoFocus /> | |
| 41 | 41 | </Field> | |
| 42 | 42 | <div className="pt-2 *:w-full"> | |
| 43 | − | <Button type="submit">Send reset link</Button> | |
| 43 | + | <SubmitButton pending="Sending…">Send reset link</SubmitButton> | |
| 44 | 44 | </div> | |
| 45 | 45 | </Form> | |
| 46 | 46 | )} |
| 1 | 1 | 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"; | |
| 3 | 3 | ||
| 4 | 4 | import type { InvitePreview, User } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 8 | 8 | import { Mark } from "../components/logo"; | |
| 9 | 9 | import { ContinueWithGithub, OrDivider } from "../components/github"; | |
| 10 | 10 | 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"; | |
| 12 | 12 | import { githubSignInEnabled } from "../lib/github.server"; | |
| 13 | 13 | import { identity } from "../lib/services.server"; | |
| 14 | 14 | import { cleanCode, landingFor, looksAutomated, suggestUsername, welcomeCookie } from "../lib/invites"; | |
| 115 | 115 | ||
| 116 | 116 | const user = requireUser(context, request); | |
| 117 | 117 | 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 }; | |
| 119 | 122 | throw landIn(request, invite, [], true); | |
| 120 | 123 | } | |
| 121 | 124 | ||
| 194 | 197 | /** Signing up, on this page: the address the invite was sent to, a username, a password. */ | |
| 195 | 198 | function SignUp({ loaded, error }: { loaded: Loaded; error: string | null }) { | |
| 196 | 199 | const invite = loaded.invite!; | |
| 197 | − | const busy = useNavigation().state === "submitting"; | |
| 198 | 200 | const here = `/invite/${loaded.code}`; | |
| 199 | 201 | const back = `${here}?accept=1`; | |
| 200 | 202 | const github = `/auth/github?${new URLSearchParams({ invite: loaded.code, next: back })}`; | |
| 247 | 249 | </Field> | |
| 248 | 250 | <ErrorText>{error}</ErrorText> | |
| 249 | 251 | <div className="pt-1 *:w-full"> | |
| 250 | − | <Button type="submit" disabled={busy}> | |
| 252 | + | <SubmitButton pending="Creating account…" match={{ intent: "register" }}> | |
| 251 | 253 | {invite.workspace | |
| 252 | 254 | ? `Create account and join ${invite.workspace.name}` | |
| 253 | 255 | : invite.repository | |
| 254 | 256 | ? "Create account and accept" | |
| 255 | 257 | : "Create account"} | |
| 256 | − | </Button> | |
| 258 | + | </SubmitButton> | |
| 257 | 259 | </div> | |
| 258 | 260 | </Form> | |
| 259 | 261 | <p className="mt-5 text-center text-sm text-muted"> | |
| 281 | 283 | Signed in as <span className="font-mono text-fg">{viewer.username}</span> | |
| 282 | 284 | </p> | |
| 283 | 285 | ); | |
| 286 | + | // Its own intent, which /logout ignores, so only its button says it is working. | |
| 284 | 287 | const signOut = (label: string, variant: "primary" | "quiet") => ( | |
| 285 | 288 | <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" }}> | |
| 287 | 291 | {label} | |
| 288 | − | </Button> | |
| 292 | + | </SubmitButton> | |
| 289 | 293 | </Form> | |
| 290 | 294 | ); | |
| 291 | 295 | if (invite.forViewer === false) { | |
| 328 | 332 | ); | |
| 329 | 333 | } | |
| 330 | 334 | 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 | + | </> | |
| 352 | 365 | ); | |
| 353 | 366 | } | |
| 354 | 367 |
| 7 | 7 | import { ContinueWithGithub, GithubMark, OrDivider } from "../components/github"; | |
| 8 | 8 | import { PENDING_COOKIE, cookie, readCookie } from "../lib/github"; | |
| 9 | 9 | 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"; | |
| 11 | 11 | import { identity } from "../lib/services.server"; | |
| 12 | 12 | import { | |
| 13 | 13 | assertSameOrigin, | |
| 101 | 101 | </Field> | |
| 102 | 102 | <ErrorText>{actionData?.error}</ErrorText> | |
| 103 | 103 | <div className="pt-2 *:w-full"> | |
| 104 | − | <Button type="submit">Sign in</Button> | |
| 104 | + | <SubmitButton pending="Signing in…">Sign in</SubmitButton> | |
| 105 | 105 | </div> | |
| 106 | 106 | <p className="text-center text-sm"> | |
| 107 | 107 | <Link to="/forgot" className="text-muted hover:text-fg"> |
| 1 | 1 | import { ArrowLeft, Lock, Plus } from "lucide-react"; | |
| 2 | − | import { Form, Link, redirect, useNavigation } from "react-router"; | |
| 2 | + | import { Form, Link, redirect } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/new-github"; | |
| 5 | 5 | import { GithubMark } from "../components/github"; | |
| 6 | − | import { Button, ButtonLink, ErrorText, Pill } from "../components/ui"; | |
| 6 | + | import { ButtonLink, ErrorText, Pill, SubmitButton } from "../components/ui"; | |
| 7 | 7 | import { CheckboxOption } from "../components/ui/checkbox"; | |
| 8 | 8 | import { FieldLegend, FieldSet } from "../components/ui/field"; | |
| 9 | 9 | import { RadioCard, RadioGroup } from "../components/ui/radio-group"; | |
| 61 | 61 | const installationId = Number(form.get("installation")); | |
| 62 | 62 | if (form.get("intent") === "remove-installation") { | |
| 63 | 63 | 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: [] }; | |
| 65 | 66 | throw redirect(`/new/github?workspace=${encodeURIComponent(workspace)}`); | |
| 66 | 67 | } | |
| 67 | 68 | const mode = (["import", "mirror", "push"] as const).find((option) => option === form.get("mode")) ?? "import"; | |
| 84 | 85 | ||
| 85 | 86 | export default function NewFromGithub({ loaderData, actionData }: Route.ComponentProps) { | |
| 86 | 87 | const { workspace, workspaces, installations, installation, repositories, linked, owner } = loaderData; | |
| 87 | − | const busy = useNavigation().state === "submitting"; | |
| 88 | 88 | const here = `/new/github?workspace=${workspace}`; | |
| 89 | 89 | const names = new Map((repositories?.repositories ?? []).map((repo) => [repo.id, repo.fullName])); | |
| 90 | 90 | return ( | |
| 191 | 191 | <input type="hidden" name="intent" value="remove-installation" /> | |
| 192 | 192 | <input type="hidden" name="workspace" value={workspace} /> | |
| 193 | 193 | <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 | + | > | |
| 195 | 199 | Remove | |
| 196 | − | </button> | |
| 200 | + | </SubmitButton> | |
| 197 | 201 | </Form> | |
| 198 | 202 | )} | |
| 199 | 203 | </span> | |
| 201 | 205 | ))} | |
| 202 | 206 | </ul> | |
| 203 | 207 | )} | |
| 208 | + | <ErrorText>{actionData && "removeError" in actionData ? actionData.removeError : null}</ErrorText> | |
| 204 | 209 | </section> | |
| 205 | 210 | ||
| 206 | 211 | {installation && ( | |
| 207 | 212 | <Form method="post" className="mt-10 space-y-8"> | |
| 213 | + | <input type="hidden" name="intent" value="import" /> | |
| 208 | 214 | <input type="hidden" name="workspace" value={workspace} /> | |
| 209 | 215 | <input type="hidden" name="installation" value={installation.id} /> | |
| 210 | 216 | <FieldSet> | |
| 294 | 300 | )} | |
| 295 | 301 | <ErrorText>{actionData?.error}</ErrorText> | |
| 296 | 302 | <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> | |
| 300 | 306 | <ButtonLink to={`/new?workspace=${workspace}`} variant="quiet"> | |
| 301 | 307 | Cancel | |
| 302 | 308 | </ButtonLink> |
| 1 | 1 | import { Box, Download, GitBranch, Globe, Lock, Sparkles } from "lucide-react"; | |
| 2 | 2 | import { useState } from "react"; | |
| 3 | − | import { Form, redirect, useNavigate, useNavigation } from "react-router"; | |
| 3 | + | import { Form, redirect, useNavigate } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import type { Route } from "./+types/new"; | |
| 6 | 6 | import { page } from "../lib/meta"; | |
| 7 | − | import { Avatar, Button, ErrorText } from "../components/ui"; | |
| 7 | + | import { Avatar, ErrorText, SubmitButton } from "../components/ui"; | |
| 8 | 8 | import { Field, FieldDescription, FieldLabel, FieldLegend, FieldSet } from "../components/ui/field"; | |
| 9 | 9 | import { Input, InputAddon, InputGroup } from "../components/ui/input"; | |
| 10 | 10 | import { RadioCard, RadioGroup } from "../components/ui/radio-group"; | |
| 93 | 93 | const sources = loaderData.github ? SOURCES.map((option) => (option.id === "mirror" ? GITHUB_SOURCE : option)) : SOURCES; | |
| 94 | 94 | const [workspace, setWorkspace] = useState(loaderData.selected); | |
| 95 | 95 | const [name, setName] = useState(""); | |
| 96 | − | const busy = useNavigation().state === "submitting"; | |
| 97 | 96 | return ( | |
| 98 | 97 | <main className="mx-auto max-w-2xl px-4 py-12"> | |
| 99 | 98 | <span className="flex size-10 items-center justify-center rounded-xl bg-accent/10 text-accent ring-1 ring-accent/30"> | |
| 212 | 211 | </FieldSet> | |
| 213 | 212 | ||
| 214 | 213 | <ErrorText>{actionData?.error}</ErrorText> | |
| 215 | − | <Button type="submit" variant="accent" disabled={busy}> | |
| 214 | + | <SubmitButton variant="accent" pending={source === "import" ? "Importing…" : "Creating…"}> | |
| 216 | 215 | Create project | |
| 217 | − | </Button> | |
| 216 | + | </SubmitButton> | |
| 218 | 217 | </Form> | |
| 219 | 218 | </main> | |
| 220 | 219 | ); |
| 9 | 9 | import { ContinueWithGithub, OrDivider } from "../components/github"; | |
| 10 | 10 | import { Honeypot } from "../components/honeypot"; | |
| 11 | 11 | 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"; | |
| 13 | 13 | import { identity } from "../lib/services.server"; | |
| 14 | 14 | import { cleanCode, looksAutomated } from "../lib/invites"; | |
| 15 | 15 | import { clientKey, registrationMode } from "../lib/registration.server"; | |
| 187 | 187 | </Field> | |
| 188 | 188 | <ErrorText>{error}</ErrorText> | |
| 189 | 189 | <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> | |
| 191 | 193 | </div> | |
| 192 | 194 | </Form> | |
| 193 | 195 | </> | |
| 262 | 264 | </Field> | |
| 263 | 265 | <ErrorText>{actionData && "requestError" in actionData ? actionData.requestError : undefined}</ErrorText> | |
| 264 | 266 | <div className="*:w-full"> | |
| 265 | − | <Button type="submit">Request access</Button> | |
| 267 | + | <SubmitButton pending="Sending…" match={{ intent: "request-access" }}> | |
| 268 | + | Request access | |
| 269 | + | </SubmitButton> | |
| 266 | 270 | </div> | |
| 267 | 271 | </Form> | |
| 268 | 272 | </> |
| 3 | 3 | import type { Route } from "./+types/reset"; | |
| 4 | 4 | import { page } from "../lib/meta"; | |
| 5 | 5 | import { AuthCard } from "../components/auth-card"; | |
| 6 | − | import { Button, ErrorText, Field, Input } from "../components/ui"; | |
| 6 | + | import { ErrorText, Field, Input, SubmitButton } from "../components/ui"; | |
| 7 | 7 | import { identity } from "../lib/services.server"; | |
| 8 | 8 | import { assertSameOrigin } from "../lib/session.server"; | |
| 9 | 9 | ||
| 51 | 51 | </Field> | |
| 52 | 52 | <ErrorText>{actionData?.error}</ErrorText> | |
| 53 | 53 | <div className="pt-2 *:w-full"> | |
| 54 | − | <Button type="submit">Set password</Button> | |
| 54 | + | <SubmitButton pending="Saving…">Set password</SubmitButton> | |
| 55 | 55 | </div> | |
| 56 | 56 | </Form> | |
| 57 | 57 | </AuthCard> |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/applications"; | |
| 5 | 5 | import { page } from "../../lib/meta"; | |
| 6 | − | import { Button, ButtonLink, ErrorText, TimeAgo } from "../../components/ui"; | |
| 6 | + | import { ButtonLink, ErrorText, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 7 | 7 | import { DeleteButton } from "../../components/account-settings"; | |
| 8 | 8 | import { AccessSummary, ScopeChecklist } from "../../components/token-scopes"; | |
| 9 | 9 | import { grantFromForm } from "../../lib/token-scopes"; | |
| 74 | 74 | Change access | |
| 75 | 75 | </ButtonLink> | |
| 76 | 76 | )} | |
| 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…" /> | |
| 78 | 78 | </div> | |
| 79 | 79 | </div> | |
| 80 | 80 | {open && ( | |
| 88 | 88 | <ScopeChecklist initial={application.scopes} /> | |
| 89 | 89 | {actionData?.editing === application.id && <ErrorText>{actionData.error}</ErrorText>} | |
| 90 | 90 | <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> | |
| 92 | 94 | <ButtonLink variant="quiet" to="." preventScrollReset> | |
| 93 | 95 | Cancel | |
| 94 | 96 | </ButtonLink> |
| 22 | 22 | const form = await request.formData(); | |
| 23 | 23 | // Sensitive changes may first ask for the password (lib/emails.server.ts). | |
| 24 | 24 | 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")) }; | |
| 26 | 27 | } | |
| 27 | 28 | return null; | |
| 28 | 29 | } | |
| 32 | 33 | <EmailsSection | |
| 33 | 34 | data={loaderData.emails} | |
| 34 | 35 | actionData={actionData?.emailAction} | |
| 36 | + | intent={actionData?.emailIntent} | |
| 35 | 37 | hasPassword={loaderData.hasPassword} | |
| 36 | 38 | /> | |
| 37 | 39 | ); |
| 2 | 2 | ||
| 3 | 3 | import type { Route } from "./+types/github"; | |
| 4 | 4 | import { page } from "../../lib/meta"; | |
| 5 | − | import { Button, ErrorText, TimeAgo } from "../../components/ui"; | |
| 5 | + | import { ErrorText, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 6 | 6 | import { assertSameOrigin, requireUser } from "../../lib/session.server"; | |
| 7 | 7 | import { GithubMark } from "../../components/github"; | |
| 8 | 8 | import { githubSignIn } from "../../lib/github.server"; | |
| 54 | 54 | )} | |
| 55 | 55 | <Form method="post"> | |
| 56 | 56 | <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."}> | |
| 58 | 58 | Unlink | |
| 59 | − | </Button> | |
| 59 | + | </SubmitButton> | |
| 60 | 60 | </Form> | |
| 61 | 61 | </div> | |
| 62 | 62 | </div> |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/keys"; | |
| 5 | 5 | import { page } from "../../lib/meta"; | |
| 6 | − | import { Button, ErrorText, Field, Input } from "../../components/ui"; | |
| 6 | + | import { ErrorText, Field, Input, SubmitButton } from "../../components/ui"; | |
| 7 | 7 | import { DeleteButton } from "../../components/account-settings"; | |
| 8 | 8 | import { assertSameOrigin, requireUser } from "../../lib/session.server"; | |
| 9 | 9 | ||
| 51 | 51 | </li> | |
| 52 | 52 | ))} | |
| 53 | 53 | </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" : ""}`}> | |
| 55 | 56 | <input type="hidden" name="intent" value="add-key" /> | |
| 56 | 57 | <Field label="Title (optional)"> | |
| 57 | 58 | <Input name="title" maxLength={100} /> | |
| 60 | 61 | <Input name="key" placeholder="ssh-ed25519 AAAA…" required /> | |
| 61 | 62 | </Field> | |
| 62 | 63 | <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> | |
| 64 | 67 | </Form> | |
| 65 | 68 | </section> | |
| 66 | 69 | ); |
| 64 | 64 | /> | |
| 65 | 65 | </div> | |
| 66 | 66 | </section> | |
| 67 | + | {/* Keyed on what was saved, so the fields show identity's copy once it answers. */} | |
| 67 | 68 | <ProfileSection | |
| 69 | + | key={JSON.stringify(profile)} | |
| 68 | 70 | username={user.username} | |
| 69 | 71 | profile={profile} | |
| 70 | 72 | error={actionData && "profileError" in actionData ? actionData.profileError : undefined} |
| 6 | 6 | import type { Route } from "./+types/tokens"; | |
| 7 | 7 | import { page } from "../../lib/meta"; | |
| 8 | 8 | import { | |
| 9 | − | Button, | |
| 9 | + | SubmitButton, | |
| 10 | 10 | ButtonLink, | |
| 11 | 11 | ErrorText, | |
| 12 | 12 | Field, | |
| 155 | 155 | </ul> | |
| 156 | 156 | ||
| 157 | 157 | {!editing && ( | |
| 158 | + | // Keyed on the token just made, so the fields start over for the next. | |
| 158 | 159 | <Form | |
| 160 | + | key={created?.id ?? "new"} | |
| 159 | 161 | method="post" | |
| 160 | 162 | className="mt-8 space-y-5 rounded-md border border-line p-4 sm:p-5" | |
| 161 | 163 | > | |
| 174 | 176 | </div> | |
| 175 | 177 | <ScopeChecklist initial={presetScopes("agent")} /> | |
| 176 | 178 | {!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> | |
| 178 | 182 | </Form> | |
| 179 | 183 | )} | |
| 180 | 184 | </section> | |
| 246 | 250 | <ScopeChecklist initial={token.scopes} /> | |
| 247 | 251 | <ErrorText>{error}</ErrorText> | |
| 248 | 252 | <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> | |
| 250 | 256 | <ButtonLink variant="quiet" to="." preventScrollReset> | |
| 251 | 257 | Cancel | |
| 252 | 258 | </ButtonLink> |