Open History to see the last commit
1import {
2 isSecretReference,
3 type EnvironmentId,
4 type SecretReference,
5 type SettingsSecretValue,
6} from "@t3tools/contracts";
7import { ExternalLinkIcon } from "lucide-react";
8import { useId, useState } from "react";
9
10import { useEnvironmentSettings } from "../../hooks/useSettings";
11import {
12 readHostCredentialSettings,
13 type SourceControlDefinition,
14 type SourceControlHostCredentials,
15} from "@t3tools/client-runtime/source-control-clients";
16import { serverEnvironment } from "../../state/server";
17import { useAtomCommand } from "../../state/use-atom-command";
18import { Button, InlineButton } from "../ui/button";
19import { Input } from "../ui/input";
20import { Label } from "../ui/label";
21import { SecretSourceInput, useSecretSources } from "./SecretSourceInput";
22
23const hasToken = (token: SettingsSecretValue | undefined) =>
24 typeof token === "string" ? token.length > 0 : token !== undefined;
25
26export function SourceControlTokenSettings({
27 environmentId,
28 definition,
29 credentials,
30 onSaved,
31}: {
32 readonly environmentId: EnvironmentId;
33 readonly definition: SourceControlDefinition;
34 readonly credentials: SourceControlHostCredentials;
35 readonly onSaved: () => void;
36}) {
37 const id = useId();
38 const hostInputId = `${definition.kind}-token-host-${environmentId}-${id}`;
39 const tokenInputId = `${definition.kind}-token-${environmentId}-${id}`;
40 const { tokens } = useEnvironmentSettings(environmentId, (settings) =>
41 readHostCredentialSettings(settings, definition.kind),
42 );
43 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {
44 label: `save ${definition.label} token`,
45 });
46 const defaultHost = definition.publicHost ?? "";
47 const [host, setHost] = useState(defaultHost);
48 const secretSources = useSecretSources(environmentId);
49 const [pending, setPending] = useState<{
50 readonly key: string;
51 readonly draft: string | SecretReference;
52 } | null>(null);
53 const [saving, setSaving] = useState(false);
54 const normalizedHost = host.trim().toLowerCase();
55 const savedToken = tokens[normalizedHost];
56 const isSaved = hasToken(savedToken);
57 const savedHosts = Object.entries(tokens)
58 .filter(([, value]) => hasToken(value))
59 .map(([saved]) => saved);
60 const sourceKey = JSON.stringify([normalizedHost, savedToken ?? ""]);
61 const draft =
62 pending?.key === sourceKey ? pending.draft : isSecretReference(savedToken) ? savedToken : "";
63 const submission = typeof draft === "string" ? draft.trim() : draft;
64
65 const save = async (target: string, token: SettingsSecretValue) => {
66 setSaving(true);
67 const result = await updateSettings({
68 environmentId,
69 // Send the whole map; other tokens' redaction markers preserve their values.
70 input: {
71 patch: {
72 sourceControlHosts: { [definition.kind]: { tokens: { ...tokens, [target]: token } } },
73 },
74 },
75 });
76 setSaving(false);
77 if (result._tag === "Success") {
78 setPending(null);
79 onSaved();
80 }
81 };
82
83 return (
84 <form
85 className="grid gap-3"
86 onSubmit={(event) => {
87 event.preventDefault();
88 if (normalizedHost && submission !== "") void save(normalizedHost, submission);
89 }}
90 >
91 {/* Locked while saving: a successful save clears the draft, which would drop edits made mid-request. */}
92 <fieldset disabled={saving} className="contents">
93 <p className="max-w-2xl text-meta leading-relaxed text-muted-foreground">
94 A token saved here is used before{" "}
95 <code className="rounded bg-muted px-1 py-px text-caption">
96 {credentials.tokenEnvVar}
97 </code>{" "}
98 and the{" "}
99 <code className="rounded bg-muted px-1 py-px text-caption">{credentials.cliName}</code>{" "}
100 login, so {definition.label} works without the {credentials.cliLabel}. Give it{" "}
101 {credentials.tokenAccess}.{" "}
102 {normalizedHost || defaultHost ? (
103 <InlineButton
104 render={
105 <a
106 href={credentials.newTokenUrl(normalizedHost || defaultHost)}
107 target="_blank"
108 rel="noreferrer noopener"
109 />
110 }
111 >
112 Create a token
113 <ExternalLinkIcon aria-hidden className="size-3" />
114 </InlineButton>
115 ) : null}
116 </p>
117 <div className="grid gap-3 sm:grid-cols-[12rem_1fr]">
118 <div className="grid gap-1.5">
119 <Label htmlFor={hostInputId}>Host</Label>
120 <Input
121 id={hostInputId}
122 autoComplete="off"
123 size="sm"
124 placeholder={defaultHost}
125 value={host}
126 onChange={(event) => setHost(event.target.value)}
127 />
128 </div>
129 <div className="grid gap-1.5">
130 <Label htmlFor={tokenInputId}>Token</Label>
131 <SecretSourceInput
132 id={tokenInputId}
133 size="sm"
134 aria-label={`${definition.label} token`}
135 configured={isSaved && !isSecretReference(savedToken)}
136 value={draft}
137 sources={secretSources}
138 onChange={(event) => setPending({ key: sourceKey, draft: event.target.value })}
139 onSecretChange={(secret) => setPending({ key: sourceKey, draft: secret ?? "" })}
140 />
141 </div>
142 </div>
143 <div className="flex items-center justify-between gap-3">
144 <p className="text-meta text-muted-foreground">
145 {savedHosts.length === 0
146 ? `No token saved; the server uses ${credentials.tokenEnvVar} or the ${credentials.cliName} login.`
147 : `Saved for ${savedHosts.join(", ")}.`}
148 </p>
149 <div className="flex shrink-0 gap-2">
150 {isSaved ? (
151 <Button
152 size="xs"
153 variant="secondary"
154 disabled={saving}
155 onClick={() => void save(normalizedHost, "")}
156 >
157 Remove
158 </Button>
159 ) : null}
160 <Button
161 type="submit"
162 size="xs"
163 disabled={!normalizedHost || submission === "" || saving}
164 >
165 Save
166 </Button>
167 </div>
168 </div>
169 </fieldset>
170 </form>
171 );
172}