Skip to content
Source
Sign inSign up

SourceControlAppSignInSettings.tsx

277 lines10.0 KB

1import type { EnvironmentId, SourceControlSignInPrompt } from "@t3tools/contracts";
2import {
3 readHostCredentialSettings,
4 type SourceControlAppSignIn,
5 type SourceControlDefinition,
6} from "@t3tools/client-runtime/source-control-clients";
7import { useAtomValue } from "@effect/atom-react";
8import * as Cause from "effect/Cause";
9import { CheckIcon, ExternalLinkIcon, CopyIcon } from "lucide-react";
10import { useState, type ReactNode } from "react";
11​
12import { T3AppIcon } from "~/components/T3AppIcon";
13import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
14import { useEnvironmentSettings } from "../../hooks/useSettings";
15import { cn } from "../../lib/utils";
16import { serverEnvironment } from "../../state/server";
17import { sourceControlEnvironment } from "../../state/sourceControl";
18import { useAtomCommand } from "../../state/use-atom-command";
19import { Button, InlineButton } from "../ui/button";
20import { Spinner } from "../ui/spinner";
21import { RedactedSensitiveText } from "./RedactedSensitiveText";
22​
23type SignInState =
24 | { readonly status: "idle" }
25 | { readonly status: "starting" }
26 | { readonly status: "waiting"; readonly prompt: SourceControlSignInPrompt }
27 | { readonly status: "failed"; readonly message: string };
28​
29const failureMessage = (cause: Cause.Cause<unknown>) => {
30 const error = Cause.squash(cause);
31 return error instanceof Error ? error.message : String(error);
32};
33​
34const expiryTime = (expiresAt: number) =>
35 new Date(expiresAt).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
36​
37function ExternalLink(props: { readonly href: string; readonly children: ReactNode }) {
38 return (
39 <InlineButton render={<a href={props.href} target="_blank" rel="noreferrer noopener" />}>
40 {props.children}
41 <ExternalLinkIcon aria-hidden className="size-3" />
42 </InlineButton>
43 );
45​
46function AppMark(props: { readonly signedIn: boolean }) {
47 return (
48 <div className="relative size-10 shrink-0">
49 <T3AppIcon className="size-full" />
50 {props.signedIn ? (
51 <span className="absolute -right-1 -bottom-1 grid size-4 place-items-center rounded-full bg-success text-white ring-2 ring-card">
52 <CheckIcon aria-hidden className="size-2.5" strokeWidth={3} />
53 </span>
54 ) : null}
55 </div>
56 );
58​
59/** The device code, one cell per character, as GitHub prints it. */
60function DeviceCode(props: { readonly code: string }) {
61 return (
62 <div className="flex items-center gap-1 font-mono text-lg font-medium">
63 <span className="sr-only">{props.code}</span>
64 {Array.from(props.code, (char, index) =>
65 char === "-" ? (
66 <span key={index} aria-hidden className="px-0.5 text-muted-foreground">
67 –
68 </span>
69 ) : (
70 <span
71 key={index}
72 aria-hidden
73 className="grid h-10 w-8 place-items-center rounded-md border border-border bg-background shadow-xs"
74 >
75 {char}
76 </span>
77 ),
78 )}
79 </div>
80 );
82​
83export function SourceControlAppSignInSettings({
84 environmentId,
85 definition,
86 appSignIn,
87 account,
88 onChanged,
89}: {
90 readonly environmentId: EnvironmentId;
91 readonly definition: SourceControlDefinition;
92 readonly appSignIn: SourceControlAppSignIn;
93 /** The login discovery reports, which is the signed-in account when no token overrides it. */
94 readonly account: string | null;
95 readonly onChanged: () => void;
96}) {
97 const host = appSignIn.hosts[0] ?? "";
98 const { signIns, tokens } = useEnvironmentSettings(environmentId, (settings) =>
99 readHostCredentialSettings(settings, definition.kind),
100 );
101 const signedIn = (signIns[host] ?? "").length > 0;
102 const token = tokens[host];
103 // A token read from a secret provider overrides the sign-in like a saved one.
104 const tokenOverrides = typeof token === "string" ? token.length > 0 : token !== undefined;
105 const canSignIn = useAtomValue(
106 sourceControlEnvironment.startSignIn.permissionAtom(environmentId),
107 );
108 const start = useAtomCommand(sourceControlEnvironment.startSignIn, { reportFailure: false });
109 const wait = useAtomCommand(sourceControlEnvironment.waitForSignIn, { reportFailure: false });
110 const cancel = useAtomCommand(sourceControlEnvironment.cancelSignIn, { reportFailure: false });
111 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {
112 label: `sign out of ${appSignIn.appName}`,
113 });
114 const { copyToClipboard, isCopied } = useCopyToClipboard({
115 timeout: 1500,
116 target: "sign-in code",
117 });
118 const [state, setState] = useState<SignInState>({ status: "idle" });
119​
120 const signIn = async () => {
121 setState({ status: "starting" });
122 const input = { provider: definition.kind, host };
123 const started = await start({ environmentId, input });
124 if (started._tag !== "Success") {
125 setState({ status: "failed", message: failureMessage(started.cause) });
126 return;
127 }
128 const prompt = started.value;
129 setState({ status: "waiting", prompt });
130 const finished = await wait({ environmentId, input });
131 // A newer attempt, a cancel, or a sign-out owns the state by now.
132 setState((current) => {
133 if (current.status !== "waiting" || current.prompt !== prompt) return current;
134 return finished._tag === "Success"
135 ? { status: "idle" }
136 : { status: "failed", message: failureMessage(finished.cause) };
137 });
138 if (finished._tag === "Success") onChanged();
139 };
140​
141 const signOut = async () => {
142 setState({ status: "idle" });
143 const result = await updateSettings({
144 environmentId,
145 // Send the whole map; other hosts' redaction markers preserve their sessions.
146 input: {
147 patch: {
148 sourceControlHosts: { [definition.kind]: { signIns: { ...signIns, [host]: "" } } },
149 },
150 },
151 });
152 if (result._tag === "Success") onChanged();
153 };
154​
155 const waiting = !signedIn && state.status === "waiting" ? state.prompt : null;
156​
157 let subtitle: ReactNode;
158 let action: ReactNode;
159 if (signedIn) {
160 subtitle =
161 account !== null && !tokenOverrides ? (
162 <span className="flex min-w-0 items-center gap-1">
163 Signed in as
164 <RedactedSensitiveText
165 value={account}
166 ariaLabel="Signed-in GitHub account"
167 revealTooltip="Show account"
168 hideTooltip="Hide account"
169 />
170 </span>
171 ) : tokenOverrides ? (
172 "Signed in, but the token saved below is used while it can be read."
173 ) : (
174 "Signed in"
175 );
176 action = (
177 <Button size="xs" variant="outline" disabled={!canSignIn} onClick={() => void signOut()}>
178 Sign out
179 </Button>
180 );
181 } else if (waiting !== null) {
182 subtitle = "Approve the app on GitHub to finish signing in.";
183 action = (
184 <Button
185 size="xs"
186 variant="ghost"
187 onClick={() => {
188 setState({ status: "idle" });
189 void cancel({ environmentId, input: { provider: definition.kind, host } });
190 }}
191 >
192 Cancel
193 </Button>
194 );
195 } else {
196 subtitle = "A far higher GitHub API rate limit than a personal token or gh login.";
197 action = (
198 <Button
199 size="xs"
200 disabled={!canSignIn || state.status === "starting"}
201 onClick={() => void signIn()}
202 >
203 {state.status === "starting" ? <Spinner /> : null}
204 {state.status === "failed" ? "Try again" : "Sign in with GitHub"}
205 </Button>
206 );
207 }
208​
209 return (
210 <div
211 className={cn(
212 "overflow-hidden rounded-xl border bg-card",
213 state.status === "failed" && !signedIn ? "border-destructive/40" : "border-border",
214 )}
215 >
216 <div className="flex items-center gap-3 p-3">
217 <AppMark signedIn={signedIn} />
218 <div className="min-w-0 flex-1 space-y-0.5">
219 <p className="text-sm font-medium text-foreground">{appSignIn.appName}</p>
220 <div className="text-xs text-muted-foreground">{subtitle}</div>
221 </div>
222 <div className="shrink-0">{action}</div>
223 </div>
224​
225 {waiting !== null ? (
226 <div className="grid gap-4 border-t border-border bg-muted/40 px-3 py-4 sm:grid-cols-[auto_1fr] sm:items-center sm:gap-6">
227 <div className="flex items-center gap-2">
228 <DeviceCode code={waiting.userCode} />
229 <Button
230 size="icon-xs"
231 variant="ghost"
232 aria-label="Copy code"
233 onClick={() => copyToClipboard(waiting.userCode, undefined)}
234 >
235 {isCopied ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />}
236 </Button>
237 </div>
238 <div className="grid gap-2 sm:justify-items-end">
239 <Button
240 size="sm"
241 render={
242 <a href={waiting.verificationUri} target="_blank" rel="noreferrer noopener" />
243 }
244 onClick={() => copyToClipboard(waiting.userCode, undefined)}
245 >
246 Copy code & open GitHub
247 <ExternalLinkIcon aria-hidden className="size-3.5" />
248 </Button>
249 <p className="flex items-center gap-1.5 text-xs text-muted-foreground">
250 <span aria-hidden className="size-1.5 rounded-full bg-warning" />
251 Waiting for approval · code expires at {expiryTime(waiting.expiresAt)}
252 </p>
253 </div>
254 </div>
255 ) : null}
256​
257 {signedIn ? (
258 <div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
259 <span className="min-w-0 flex-1">
260 Reaches repositories in accounts and organizations that installed the app.
261 </span>
262 <ExternalLink href={appSignIn.installUrl}>Install on an organization</ExternalLink>
263 <ExternalLink href={appSignIn.revokeUrl}>Manage on GitHub</ExternalLink>
264 </div>
265 ) : null}
266​
267 {state.status === "failed" && !signedIn ? (
268 <p
269 role="alert"
270 className="border-t border-destructive/20 bg-destructive/5 px-3 py-2 text-xs text-destructive"
271 >
272 {state.message}
273 </p>
274 ) : null}
275 </div>
276 );