Skip to content
Source
Sign inSign up
Open History to see the last commit

SourceControlAccountSettings.tsx

234 lines9.1 KB

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