Commit

Merge branch 'worktree-agent-af9f0f386d52b6a73'

syntaqxcommitted Parents9a30d84b066c8aBrowse files
7 files+81−250/7 viewed
+2−1
154154 To change someone's role, pick another beside their name. To take it away,
155155 choose **Remove**. Removing takes away only the role given on this
156156 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.
158159
159160 ## Outside collaborators
160161
+3−1
554554 yet can use that address, and following the link confirms it.
555555
556556 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.
558560
559561 Setting a new password signs you out everywhere and emails your primary
560562 and backup addresses.
+11−3
33 import type { Route } from "./+types/forgot";
44 import { page } from "../lib/meta";
55 import { AuthCard } from "../components/auth-card";
6−import { Field, Input, SubmitButton } from "../components/ui";
6+import { ErrorText, Field, Input, SubmitButton } from "../components/ui";
77 import { identity } from "../lib/services.server";
88 import { assertSameOrigin, clientOf } from "../lib/session.server";
99
1414 export async function action({ request }: Route.ActionArgs) {
1515 assertSameOrigin(request);
1616 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 };
1926 }
2027
2128 export default function Forgot({ actionData }: Route.ComponentProps) {
3946 <Field label="Email">
4047 <Input name="email" type="email" autoComplete="email" required autoFocus />
4148 </Field>
49+ <ErrorText>{actionData?.error}</ErrorText>
4250 <div className="pt-2 *:w-full">
4351 <SubmitButton pending="Sending…">Send reset link</SubmitButton>
4452 </div>
+14−7
103103 if (scopes.length === 0) {
104104 return { error: "Leave at least one box ticked, or deny." };
105105 }
106− const { code } = await identity.oauthAuthorize(user, {
107− clientId: checked.clientId,
108− clientName: checked.clientName,
109− redirectUri: checked.redirectUri,
110− codeChallenge: checked.codeChallenge,
111− scopes,
112− });
106+ let code: string;
107+ try {
108+ ({ code } = await identity.oauthAuthorize(user, {
109+ clientId: checked.clientId,
110+ clientName: checked.clientName,
111+ redirectUri: checked.redirectUri,
112+ codeChallenge: checked.codeChallenge,
113+ scopes,
114+ }));
115+ } catch (error) {
116+ // The service's own words are for the log; the person can try again.
117+ console.warn("oauth-authorize:", error);
118+ return { error: "g1t could not approve this sign-in just now. Try again in a moment." };
119+ }
113120 // `iss` is the API's origin, the issuer its metadata names (RFC 9207).
114121 throw redirect(
115122 callback(checked.redirectUri, { code, state: checked.state, iss: addresses().api }),
+7−1
11 import { env } from "cloudflare:workers";
22 import { GitPullRequest, MessageSquare, Plus, Sparkles, X } from "lucide-react";
3+import { useEffect, useRef } from "react";
34 import { Form, Link, useRouteLoaderData } from "react-router";
45
56 import type { Route } from "./+types/issues";
9596 // An archived repository's issues are locked: no new ones.
9697 const layout = useRouteLoaderData("routes/repo/layout") as { repo?: { archivedAt?: string | null } } | undefined;
9798 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]);
98104 return (
99105 <div>
100106 <StateTabs
130136 );
131137 })}
132138 </div>
133− <Form method="post" className="mt-4">
139+ <Form ref={form} method="post" className="mt-4">
134140 {assignable && (
135141 <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">
136142 <Sparkles size={15} className="shrink-0 text-accent" />
+3−1
10391039 {catchUp && "error" in catchUp && <ErrorText>{catchUp.error}</ErrorText>}
10401040 </StatusRow>
10411041 ) : (
1042− pull.headCommit && (
1042+ (pull.headCommit || (catchUp && "error" in catchUp)) && (
10431043 <StatusRow
10441044 icon={<CircleCheck size={16} className="text-accent" />}
10451045 title={
10561056 </>
10571057 )}
10581058 {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>}
10591061 </StatusRow>
10601062 )
10611063 )}
+41−11
9595 }
9696 case "remove": {
9797 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);
99101 }
100102 case "revoke": {
101103 const revoked = await identity.revokeRepoInvitation(user, params.owner, params.repo, text("id"));
121123 (a, b) => REPO_ROLES.indexOf(b.role) - REPO_ROLES.indexOf(a.role) || a.username.localeCompare(b.username),
122124 );
123125 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);
124129 return (
125130 <>
126131 <RepoSettingsHeading base={base} />
170175 >
171176 <ul className="divide-y divide-line rounded-xl border border-line">
172177 {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+ />
174186 ))}
175187 </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>
182192 )}
183193 </Section>
184194
195205 ))}
196206 </ul>
197207 )}
198− {actionData?.intent === "revoke" && !actionData.ok && <ErrorText>{actionData.error}</ErrorText>}
199208 </Section>
200209 )}
201210
264273 manage,
265274 base,
266275 full,
276+ onDone,
267277 }: {
268278 person: Collaborator;
269279 manage: boolean;
270280 base: Parameters<typeof baseRole>[0];
271281 full: string;
282+ onDone: (notice: string | null) => void;
272283 }) {
273284 const fetcher = useFetcher<Outcome>();
285+ useEffect(() => {
286+ if (fetcher.data) onDone(fetcher.data.ok ? fetcher.data.message : null);
287+ }, [fetcher.data, onDone]);
274288 const outside = person.workspace_role == null;
275289 // A member keeps the base permission whatever role they are given here,
276290 // so only higher roles mean anything for them.
317331 {/* The same room for Remove on every row, so the roles line up. */}
318332 {manage && (
319333 <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+ )}
321337 </span>
322338 )}
323339 </div>
326342 }
327343
328344 /** 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+}) {
330356 const fetcher = useFetcher<Outcome>();
331357 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]);
332362 useEffect(() => {
333363 if (fetcher.state === "idle" && fetcher.data?.ok) setOpen(false);
334364 }, [fetcher.state, fetcher.data]);