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

SecretsSettings.tsx

264 lines9.3 KB

1import type { EnvironmentId, SecretProviderConfig, SecretProviderId } from "@t3tools/contracts";
2import { PlusIcon } from "lucide-react";
3import { useState } from "react";
4​
5import { useEnvironmentSettings, useUpdateEnvironmentSettings } from "../../hooks/useSettings";
6import type { EnvironmentPresentation } from "../../state/environments";
7import { useEnvironmentSessionState } from "../../state/session";
8import { OnePasswordIcon, type Icon } from "../Icons";
9import {
10 AlertDialog,
11 AlertDialogClose,
12 AlertDialogDescription,
13 AlertDialogFooter,
14 AlertDialogHeader,
15 AlertDialogPopup,
16 AlertDialogTitle,
17} from "../ui/alert-dialog";
18import { Button } from "../ui/button";
19import { SecretProviderDialog } from "./SecretProviderDialog";
20import { classifySecretProvider } from "./SecretProviderDialog.logic";
21import { secretProviderUses } from "./SecretsSettings.logic";
22import {
23 classifyProviderEnvironmentAccess,
24 resolveRemoteOperateAccess,
25} from "./ProviderSettingsPanel.logic";
26import { useSettingsScope } from "./SettingsScopeContext";
27import { RedactedSensitiveText } from "./RedactedSensitiveText";
28import { searchableSetting } from "./settingsSearch";
29import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout";
30​
31const SECRET_PROVIDER_KINDS: Readonly<
32 Record<string, { readonly name: string; readonly icon: Icon }>
33> = {
34 one_password: { name: "1Password", icon: OnePasswordIcon },
35};
36​
37export function SecretsSettingsPanel() {
38 const { scope, environment } = useSettingsScope();
39 const connected = environment?.connection.phase === "connected";
40 return (
41 <SettingsPageContainer>
42 {connected &&
43 environment.serverConfig !== null &&
44 environment.serverConfig.secretProviderSettings !== true ? (
45 <SettingsSection {...searchableSetting("secret-providers")}>
46 <p className="px-4 py-3 text-sm text-muted-foreground">
47 Update T3 Code on {environment.label} to manage its secret providers.
48 </p>
49 </SettingsSection>
50 ) : connected ? (
51 <EnvironmentSecretProviders
52 key={environment.environmentId}
53 environment={environment}
54 projectScoped={scope.kind === "project" || scope.kind === "checkout"}
55 />
56 ) : (
57 <SettingsSection {...searchableSetting("secret-providers")}>
58 <p className="px-4 py-3 text-sm text-muted-foreground">
59 Connect an environment to manage the secret providers its provider variables read from.
60 </p>
61 </SettingsSection>
62 )}
63 </SettingsPageContainer>
64 );
66​
67function EnvironmentSecretProviders({
68 environment,
69 projectScoped,
70}: {
71 readonly environment: EnvironmentPresentation;
72 readonly projectScoped: boolean;
73}) {
74 const environmentId = environment.environmentId;
75 const sessionState = useEnvironmentSessionState(environmentId);
76 // The server requires the same grant for secret providers as for provider settings.
77 const access = classifyProviderEnvironmentAccess({
78 connectionPhase: environment.connection.phase,
79 hasServerConfig: environment.serverConfig !== null,
80 operateAccess: resolveRemoteOperateAccess({
81 session: sessionState.data,
82 isPending: sessionState.isPending,
83 hasError: sessionState.hasError,
84 }),
85 });
86 const readOnly = access.kind !== "editable" || projectScoped;
87 const providers = useEnvironmentSettings(environmentId, (settings) => settings.secretProviders);
88 const updateSettings = useUpdateEnvironmentSettings(environmentId);
89 const [dialog, setDialog] = useState<
90 null | { readonly mode: "add" } | { readonly mode: "edit"; readonly id: SecretProviderId }
91 >(null);
92 const entries = Object.entries(providers) as Array<[SecretProviderId, SecretProviderConfig]>;
93 const editingConfig = dialog?.mode === "edit" ? providers[dialog.id] : undefined;
94 const editing =
95 dialog?.mode === "edit" && editingConfig !== undefined
96 ? { id: dialog.id, config: editingConfig }
97 : undefined;
98 return (
99 <>
100 <SettingsSection
101 {...searchableSetting("secret-providers")}
102 headerAction={
103 !readOnly ? (
104 <Button size="xs" variant="outline" onClick={() => setDialog({ mode: "add" })}>
105 <PlusIcon className="size-3" aria-hidden />
106 Add provider
107 </Button>
108 ) : null
109 }
110 >
111 {projectScoped ? (
112 <p className="px-4 pt-3 text-xs text-muted-foreground">
113 Environment-wide setting. Select an environment to change it.
114 </p>
115 ) : null}
116 {entries.length === 0 ? (
117 <SettingsRow
118 title="No secret providers configured."
119 description="Add a 1Password account, then pick it as the source of a provider variable."
120 />
121 ) : (
122 entries.map(([id, config]) => {
123 const classified = classifySecretProvider(config);
124 const kind =
125 classified.kind === "unsupported"
126 ? undefined
127 : SECRET_PROVIDER_KINDS[classified.kind];
128 return (
129 <SettingsRow
130 key={id}
131 title={
132 <span className="flex min-w-0 items-center gap-2">
133 {kind ? (
134 <kind.icon className="size-4.5 shrink-0 text-foreground/80" aria-hidden />
135 ) : null}
136 <span className="min-w-0 wrap-anywhere">{config.label}</span>
137 </span>
138 }
139 description={
140 classified.kind === "one_password" && kind ? (
141 <span className="flex min-w-0 items-center gap-1">
142 <span className="shrink-0">{kind.name} ·</span>
143 <RedactedSensitiveText
144 value={classified.account}
145 ariaLabel="Toggle account visibility"
146 revealTooltip="Click to reveal account"
147 hideTooltip="Click to hide account"
148 className="max-w-full truncate"
149 />
150 </span>
151 ) : (
152 `Unsupported provider (${config.kind})`
153 )
154 }
155 control={
156 !readOnly ? (
157 <div className="flex items-center gap-1">
158 {kind ? (
159 <Button
160 size="xs"
161 variant="ghost"
162 onClick={() => setDialog({ mode: "edit", id })}
163 >
164 Edit
165 </Button>
166 ) : null}
167 <RemoveSecretProviderButton
168 environmentId={environmentId}
169 id={id}
170 label={config.label}
171 onConfirm={() => updateSettings({ secretProviders: { [id]: null } })}
172 />
173 </div>
174 ) : null
175 }
176 />
177 );
178 })
179 )}
180 </SettingsSection>
181 {dialog !== null && !readOnly && (dialog.mode === "add" || editing !== undefined) ? (
182 <SecretProviderDialog
183 onClose={() => setDialog(null)}
184 environmentId={environmentId}
185 environmentLabel={environment.label}
186 existingIds={new Set(Object.keys(providers))}
187 {...(editing === undefined ? {} : { existing: editing })}
188 />
189 ) : null}
190 </>
191 );
193​
194function RemoveSecretProviderButton({
195 environmentId,
196 id,
197 label,
198 onConfirm,
199}: {
200 readonly environmentId: EnvironmentId;
201 readonly id: SecretProviderId;
202 readonly label: string;
203 readonly onConfirm: () => void;
204}) {
205 const [open, setOpen] = useState(false);
206 return (
207 <>
208 <Button size="xs" variant="ghost" onClick={() => setOpen(true)}>
209 Remove
210 </Button>
211 <AlertDialog open={open} onOpenChange={setOpen}>
212 <AlertDialogPopup>
213 <AlertDialogHeader>
214 <AlertDialogTitle>Remove {label}?</AlertDialogTitle>
215 <SecretProviderUses environmentId={environmentId} id={id} label={label} />
216 </AlertDialogHeader>
217 <AlertDialogFooter>
218 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
219 <Button
220 variant="destructive"
221 onClick={() => {
222 setOpen(false);
223 onConfirm();
224 }}
225 >
226 Remove provider
227 </Button>
228 </AlertDialogFooter>
229 </AlertDialogPopup>
230 </AlertDialog>
231 </>
232 );
234​
235function SecretProviderUses({
236 environmentId,
237 id,
238 label,
239}: {
240 readonly environmentId: EnvironmentId;
241 readonly id: SecretProviderId;
242 readonly label: string;
243}) {
244 const uses = secretProviderUses(useEnvironmentSettings(environmentId), id);
245 if (uses.length === 0) {
246 return (
247 <AlertDialogDescription>
248 Nothing reads from {label}. The secrets themselves are untouched.
249 </AlertDialogDescription>
250 );
251 }
252 return (
253 <>
254 <AlertDialogDescription>
255 These read from {label} and will be unset. The secrets themselves are untouched.
256 </AlertDialogDescription>
257 <ul className="max-h-48 list-disc space-y-0.5 overflow-y-auto ps-5 text-sm wrap-anywhere">
258 {uses.map((use) => (
259 <li key={use.id}>{use.label}</li>
260 ))}
261 </ul>
262 </>
263 );