Merge branch 'worktree-agent-af9f0f386d52b6a73'
7 files+81−250/7 viewed
| 154 | 154 | To change someone's role, pick another beside their name. To take it away, | |
| 155 | 155 | choose **Remove**. Removing takes away only the role given on this | |
| 156 | 156 | repository: an owner's Admin and a member's base permission stay. Anyone | |
| 157 | − | can remove their own role from a repository. | |
| 157 | + | can remove their own role from a repository. Each change is confirmed | |
| 158 | + | under the list; one that is refused says why on that person's row. | |
| 158 | 159 | ||
| 159 | 160 | ## Outside collaborators | |
| 160 | 161 |
| 554 | 554 | yet can use that address, and following the link confirms it. | |
| 555 | 555 | ||
| 556 | 556 | The page answers the same way whether or not the address has an account. | |
| 557 | − | g1t sends at most 5 reset links an hour to one address. | |
| 557 | + | g1t sends at most 5 reset links an hour to one address. If g1t cannot | |
| 558 | + | take the request at all, the page says so and keeps what you typed, so you | |
| 559 | + | can try again. | |
| 558 | 560 | ||
| 559 | 561 | Setting a new password signs you out everywhere and emails your primary | |
| 560 | 562 | and backup addresses. |
| 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 { Field, Input, SubmitButton } from "../components/ui"; | |
| 6 | + | import { ErrorText, 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 | ||
| 14 | 14 | export async function action({ request }: Route.ActionArgs) { | |
| 15 | 15 | assertSameOrigin(request); | |
| 16 | 16 | const form = await request.formData(); | |
| 17 | − | await identity.requestPasswordReset(String(form.get("email") ?? ""), clientOf(request)); | |
| 18 | − | return { sent: true }; | |
| 17 | + | try { | |
| 18 | + | await identity.requestPasswordReset(String(form.get("email") ?? ""), clientOf(request)); | |
| 19 | + | } catch (error) { | |
| 20 | + | // Logged, never shown: what went wrong could say whether the address | |
| 21 | + | // has an account. The person hears only that it did not go. | |
| 22 | + | console.warn("forgot:", error); | |
| 23 | + | return { sent: false, error: "We could not send a reset link just now. Try again in a few minutes." }; | |
| 24 | + | } | |
| 25 | + | return { sent: true, error: null }; | |
| 19 | 26 | } | |
| 20 | 27 | ||
| 21 | 28 | export default function Forgot({ actionData }: Route.ComponentProps) { | |
| 39 | 46 | <Field label="Email"> | |
| 40 | 47 | <Input name="email" type="email" autoComplete="email" required autoFocus /> | |
| 41 | 48 | </Field> | |
| 49 | + | <ErrorText>{actionData?.error}</ErrorText> | |
| 42 | 50 | <div className="pt-2 *:w-full"> | |
| 43 | 51 | <SubmitButton pending="Sending…">Send reset link</SubmitButton> | |
| 44 | 52 | </div> |
| 1 | 1 | import { env } from "cloudflare:workers"; | |
| 2 | 2 | import { GitPullRequest, MessageSquare, Plus, Sparkles, X } from "lucide-react"; | |
| 3 | + | import { useEffect, useRef } from "react"; | |
| 3 | 4 | import { Form, Link, useRouteLoaderData } from "react-router"; | |
| 4 | 5 | ||
| 5 | 6 | import type { Route } from "./+types/issues"; | |
| 95 | 96 | // An archived repository's issues are locked: no new ones. | |
| 96 | 97 | const layout = useRouteLoaderData("routes/repo/layout") as { repo?: { archivedAt?: string | null } } | undefined; | |
| 97 | 98 | const archived = Boolean(layout?.repo?.archivedAt); | |
| 99 | + | // Handed over: the ticks are cleared. Refused: they stay, to try again. | |
| 100 | + | const form = useRef<HTMLFormElement>(null); | |
| 101 | + | useEffect(() => { | |
| 102 | + | if (actionData && !actionData.error) form.current?.reset(); | |
| 103 | + | }, [actionData]); | |
| 98 | 104 | return ( | |
| 99 | 105 | <div> | |
| 100 | 106 | <StateTabs | |
| 130 | 136 | ); | |
| 131 | 137 | })} | |
| 132 | 138 | </div> | |
| 133 | − | <Form method="post" className="mt-4"> | |
| 139 | + | <Form ref={form} method="post" className="mt-4"> | |
| 134 | 140 | {assignable && ( | |
| 135 | 141 | <div className="mb-3 flex flex-wrap items-center gap-3 rounded-xl border border-accent/30 bg-accent/5 px-4 py-2.5"> | |
| 136 | 142 | <Sparkles size={15} className="shrink-0 text-accent" /> |
| 1039 | 1039 | {catchUp && "error" in catchUp && <ErrorText>{catchUp.error}</ErrorText>} | |
| 1040 | 1040 | </StatusRow> | |
| 1041 | 1041 | ) : ( | |
| 1042 | − | pull.headCommit && ( | |
| 1042 | + | (pull.headCommit || (catchUp && "error" in catchUp)) && ( | |
| 1043 | 1043 | <StatusRow | |
| 1044 | 1044 | icon={<CircleCheck size={16} className="text-accent" />} | |
| 1045 | 1045 | title={ | |
| 1056 | 1056 | </> | |
| 1057 | 1057 | )} | |
| 1058 | 1058 | {agentCatchUp && "g1t merged it in and pushed the result. Its checks run again on the new commit."} | |
| 1059 | + | {/* A catch-up refused or failed after the pull request stopped being behind. */} | |
| 1060 | + | {catchUp && "error" in catchUp && <ErrorText>{catchUp.error}</ErrorText>} | |
| 1059 | 1061 | </StatusRow> | |
| 1060 | 1062 | ) | |
| 1061 | 1063 | )} |
| 95 | 95 | } | |
| 96 | 96 | case "remove": { | |
| 97 | 97 | const removed = await identity.removeCollaborator(user, params.owner, params.repo, text("username")); | |
| 98 | − | return removed.ok ? { intent, ok: true, error: null, message: null } : failed(removed.error.message); | |
| 98 | + | return removed.ok | |
| 99 | + | ? { intent, ok: true, error: null, message: `${text("username")} no longer has a role given here.` } | |
| 100 | + | : failed(removed.error.message); | |
| 99 | 101 | } | |
| 100 | 102 | case "revoke": { | |
| 101 | 103 | const revoked = await identity.revokeRepoInvitation(user, params.owner, params.repo, text("id")); | |
| 121 | 123 | (a, b) => REPO_ROLES.indexOf(b.role) - REPO_ROLES.indexOf(a.role) || a.username.localeCompare(b.username), | |
| 122 | 124 | ); | |
| 123 | 125 | const result = actionData?.intent === "add" ? actionData : undefined; | |
| 126 | + | // Roles are changed and people removed from their own rows, which say | |
| 127 | + | // what went wrong; what went right is said once, under the list. | |
| 128 | + | const [notice, setNotice] = useState<string | null>(null); | |
| 124 | 129 | return ( | |
| 125 | 130 | <> | |
| 126 | 131 | <RepoSettingsHeading base={base} /> | |
| 170 | 175 | > | |
| 171 | 176 | <ul className="divide-y divide-line rounded-xl border border-line"> | |
| 172 | 177 | {people.map((person) => ( | |
| 173 | − | <PersonRow key={person.username} person={person} manage={manage} base={access.base_permission} full={full} /> | |
| 178 | + | <PersonRow | |
| 179 | + | key={person.username} | |
| 180 | + | person={person} | |
| 181 | + | manage={manage} | |
| 182 | + | base={access.base_permission} | |
| 183 | + | full={full} | |
| 184 | + | onDone={setNotice} | |
| 185 | + | /> | |
| 174 | 186 | ))} | |
| 175 | 187 | </ul> | |
| 176 | − | {actionData && ["role", "remove"].includes(actionData.intent) && ( | |
| 177 | − | actionData.ok ? ( | |
| 178 | − | actionData.message && <p className="text-sm text-muted" role="status">{actionData.message}</p> | |
| 179 | − | ) : ( | |
| 180 | − | <ErrorText>{actionData.error}</ErrorText> | |
| 181 | − | ) | |
| 188 | + | {notice && ( | |
| 189 | + | <p className="text-sm text-accent" role="status"> | |
| 190 | + | {notice} | |
| 191 | + | </p> | |
| 182 | 192 | )} | |
| 183 | 193 | </Section> | |
| 184 | 194 | ||
| 195 | 205 | ))} | |
| 196 | 206 | </ul> | |
| 197 | 207 | )} | |
| 198 | − | {actionData?.intent === "revoke" && !actionData.ok && <ErrorText>{actionData.error}</ErrorText>} | |
| 199 | 208 | </Section> | |
| 200 | 209 | )} | |
| 201 | 210 | ||
| 264 | 273 | manage, | |
| 265 | 274 | base, | |
| 266 | 275 | full, | |
| 276 | + | onDone, | |
| 267 | 277 | }: { | |
| 268 | 278 | person: Collaborator; | |
| 269 | 279 | manage: boolean; | |
| 270 | 280 | base: Parameters<typeof baseRole>[0]; | |
| 271 | 281 | full: string; | |
| 282 | + | onDone: (notice: string | null) => void; | |
| 272 | 283 | }) { | |
| 273 | 284 | const fetcher = useFetcher<Outcome>(); | |
| 285 | + | useEffect(() => { | |
| 286 | + | if (fetcher.data) onDone(fetcher.data.ok ? fetcher.data.message : null); | |
| 287 | + | }, [fetcher.data, onDone]); | |
| 274 | 288 | const outside = person.workspace_role == null; | |
| 275 | 289 | // A member keeps the base permission whatever role they are given here, | |
| 276 | 290 | // so only higher roles mean anything for them. | |
| 317 | 331 | {/* The same room for Remove on every row, so the roles line up. */} | |
| 318 | 332 | {manage && ( | |
| 319 | 333 | <span className="flex w-[5.5rem] justify-end"> | |
| 320 | − | {editable && person.direct != null && <RemoveButton person={person} full={full} outside={outside} />} | |
| 334 | + | {editable && person.direct != null && ( | |
| 335 | + | <RemoveButton person={person} full={full} outside={outside} onDone={onDone} /> | |
| 336 | + | )} | |
| 321 | 337 | </span> | |
| 322 | 338 | )} | |
| 323 | 339 | </div> | |
| 326 | 342 | } | |
| 327 | 343 | ||
| 328 | 344 | /** Taking someone's role here away, after saying what that means. */ | |
| 329 | − | function RemoveButton({ person, full, outside }: { person: Collaborator; full: string; outside: boolean }) { | |
| 345 | + | function RemoveButton({ | |
| 346 | + | person, | |
| 347 | + | full, | |
| 348 | + | outside, | |
| 349 | + | onDone, | |
| 350 | + | }: { | |
| 351 | + | person: Collaborator; | |
| 352 | + | full: string; | |
| 353 | + | outside: boolean; | |
| 354 | + | onDone: (notice: string | null) => void; | |
| 355 | + | }) { | |
| 330 | 356 | const fetcher = useFetcher<Outcome>(); | |
| 331 | 357 | const [open, setOpen] = useState(false); | |
| 358 | + | // Said as soon as the answer comes, before the row leaves the list. | |
| 359 | + | useEffect(() => { | |
| 360 | + | if (fetcher.data) onDone(fetcher.data.ok ? fetcher.data.message : null); | |
| 361 | + | }, [fetcher.data, onDone]); | |
| 332 | 362 | useEffect(() => { | |
| 333 | 363 | if (fetcher.state === "idle" && fetcher.data?.ok) setOpen(false); | |
| 334 | 364 | }, [fetcher.state, fetcher.data]); |