1import type { EnvironmentId, SourceControlProviderAuth } from "@t3tools/contracts";
2import { EyeIcon, EyeOffIcon } from "lucide-react";
3import { useState } from "react";
4
5import { useEnvironmentSettings } from "../../hooks/useSettings";
6import { serverEnvironment } from "../../state/server";
7import { useAtomCommand } from "../../state/use-atom-command";
8import { Button } from "../ui/button";
9import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
10import { Switch } from "../ui/switch";
11import { groupGitHubAccounts, nextGitHubHosts } from "./GitHubAccountSettings.logic";
12import { readGitHubSettings } from "@t3tools/client-runtime/source-control-clients";
13import { redactedPlaceholder } from "./RedactedSensitiveText";
14
15/** Sentinel select value for "follow gh's active login"; logins never contain spaces. */
16const ACTIVE_ACCOUNT = "active gh account";
17
18/**
19 * A login, blurred like RedactedSensitiveText until the panel reveals it. Plain text, not a
20 * button, so it can sit inside select options; one panel toggle reveals every login.
21 */
22function RedactedLogin(props: {
23 readonly account: string;
24 readonly revealed: boolean;
25 /** Distinguishes hidden logins from each other for a screen reader, e.g. "Account 2". */
26 readonly label?: string;
27}) {
28 return props.revealed ? (
29 <span className="min-w-0 truncate font-mono text-2xs">{props.account}</span>
30 ) : (
31 <span className="min-w-0 truncate font-mono text-2xs">
32 <span className="select-none blur-xs" aria-hidden>
33 {redactedPlaceholder(props.account)}
34 </span>
35 <span className="sr-only">{props.label ?? "Hidden account"}</span>
36 </span>
37 );
38}
39
40/**
41 * Per-host GitHub choices for one environment: turn a host off, or pin which of the
42 * logins `gh` holds is used instead of its active one. Changes save immediately.
43 */
44export function GitHubAccountSettings({
45 environmentId,
46 auth,
47 onSaved,
48}: {
49 readonly environmentId: EnvironmentId;
50 readonly auth: SourceControlProviderAuth;
51 readonly onSaved: () => void;
52}) {
53 const github = useEnvironmentSettings(environmentId, readGitHubSettings);
54 const hosts = github.hosts;
55 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {
56 label: "save GitHub account settings",
57 });
58 const [saving, setSaving] = useState(false);
59 const [revealed, setRevealed] = useState(false);
60 const groups = groupGitHubAccounts(auth.accounts ?? []);
61
62 const save = async (
63 host: string,
64 change: { readonly enabled?: boolean; readonly account?: string | null },
65 ) => {
66 setSaving(true);
67 try {
68 const result = await updateSettings({
69 environmentId,
70 input: {
71 patch: {
72 sourceControlHosts: { github: { hosts: nextGitHubHosts(hosts, host, change) } },
73 },
74 },
75 });
76 if (result._tag === "Success") onSaved();
77 } finally {
78 setSaving(false);
79 }
80 };
81
82 if (groups.length === 0) {
83 return (
84 <p className="text-xs text-muted-foreground">
85 Sign in with <code className="rounded bg-muted px-1 py-px text-2xs">gh auth login</code> on
86 the server host, then rescan to choose accounts here.
87 </p>
88 );
89 }
90
91 return (
92 <div className="grid gap-4">
93 <div className="flex items-center justify-between gap-3">
94 <p className="max-w-2xl text-xs leading-relaxed text-muted-foreground">
95 Choose which <code className="rounded bg-muted px-1 py-px text-2xs">gh</code> login each
96 GitHub host uses, or turn a host off.
97 </p>
98 <Button
99 size="icon-xs"
100 variant="ghost-muted"
101 onClick={() => setRevealed((current) => !current)}
102 aria-label={revealed ? "Hide GitHub accounts" : "Reveal GitHub accounts"}
103 aria-pressed={revealed}
104 >
105 {revealed ? <EyeOffIcon /> : <EyeIcon />}
106 </Button>
107 </div>
108 {groups.map((group) => {
109 const choice = hosts[group.host];
110 const enabled = choice?.enabled ?? true;
111 const stalePin =
112 choice?.account !== undefined && !group.selectable.includes(choice.account);
113 const pinned = choice?.account !== undefined && !stalePin ? choice.account : ACTIVE_ACCOUNT;
114 return (
115 <div key={group.host} className="grid gap-2">
116 <div className="flex items-center justify-between gap-3">
117 <div className="min-w-0 space-y-0.5">
118 <span className="text-xs font-medium text-foreground">{group.host}</span>
119 {group.selectable.length === 1 && group.selectable[0] !== undefined ? (
120 <p className="flex min-w-0 items-center gap-1 text-xs text-muted-foreground">
121 <span>Signed in as</span>
122 <RedactedLogin revealed={revealed} account={group.selectable[0]} />
123 </p>
124 ) : null}
125 </div>
126 <Switch
127 checked={enabled}
128 disabled={saving}
129 aria-label={`Use GitHub on ${group.host}`}
130 onCheckedChange={(checked) => void save(group.host, { enabled: checked })}
131 />
132 </div>
133 {group.selectable.length > 1 ? (
134 <div className="flex items-center justify-between gap-3">
135 <span className="text-xs text-muted-foreground">Account</span>
136 <div className="w-64 max-w-full">
137 <Select
138 value={pinned}
139 disabled={saving || !enabled}
140 onValueChange={(value) => {
141 if (typeof value !== "string") return;
142 void save(group.host, {
143 account: value === ACTIVE_ACCOUNT ? null : value,
144 });
145 }}
146 >
147 <SelectTrigger size="sm" aria-label={`GitHub account for ${group.host}`}>
148 <SelectValue>
149 {(value: string) =>
150 value === ACTIVE_ACCOUNT ? (
151 <span className="flex min-w-0 items-center gap-1">
152 Active gh account
153 {group.activeAccount ? (
154 <>
155 (
156 <RedactedLogin
157 revealed={revealed}
158 account={group.activeAccount}
159 />
160 )
161 </>
162 ) : null}
163 </span>
164 ) : (
165 <RedactedLogin revealed={revealed} account={value} />
166 )
167 }
168 </SelectValue>
169 </SelectTrigger>
170 <SelectPopup align="end" alignItemWithTrigger={false}>
171 <SelectItem value={ACTIVE_ACCOUNT}>
172 <span className="flex min-w-0 items-center gap-1">
173 Active gh account
174 {group.activeAccount ? (
175 <>
176 (<RedactedLogin revealed={revealed} account={group.activeAccount} />)
177 </>
178 ) : null}
179 </span>
180 </SelectItem>
181 {group.selectable.map((account, index) => (
182 <SelectItem key={account} value={account}>
183 <RedactedLogin
184 revealed={revealed}
185 account={account}
186 label={`Account ${index + 1}`}
187 />
188 </SelectItem>
189 ))}
190 </SelectPopup>
191 </Select>
192 </div>
193 </div>
194 ) : null}
195 {stalePin ? (
196 <div className="flex items-center justify-between gap-3">
197 <p className="text-xs text-warning">
198 The chosen login is no longer signed in, so the active gh login is used.
199 </p>
200 <Button
201 size="xs"
202 variant="outline"
203 disabled={saving}
204 onClick={() => void save(group.host, { account: null })}
205 >
206 Use active login
207 </Button>
208 </div>
209 ) : null}
210 {group.broken.map((entry) => (
211 <p
212 key={entry.account}
213 className="flex min-w-0 flex-wrap items-center gap-1 text-xs text-muted-foreground/70"
214 >
215 <RedactedLogin revealed={revealed} account={entry.account} />
216 <span>can't be used: {entry.error ?? "gh reports this login as invalid."}</span>
217 </p>
218 ))}
219 {group.environmentVariable ? (
220 <p className="text-xs text-warning">
221 {group.environmentVariable} is set on the server, so it overrides the account chosen
222 here until it is unset.
223 </p>
224 ) : null}
225 </div>
226 );
227 })}
228 </div>
229 );
230}