pr_01m47d24b0e6n91zwymwxg0vpx/apps/web/app/components/secrets.tsx

146 lines5,656 bytesCodeBlame
1/**
2 * A repository's or a workspace's secrets and variables, as workflows read
3 * them: `secrets.NAME` and `vars.NAME`. A repository's list includes what
4 * it inherits from its workspace, which its own of the same name replace.
5 */
6import { KeyRound, Trash2, Variable } from "lucide-react";
7import { useEffect, useRef } from "react";
8import { Form, useNavigation } from "react-router";
9
10import type { Setting, SettingKind } from "@g1t/contracts";
11
12import type { SecretsAction, SecretsData } from "../lib/secrets.server";
13import { Button, EmptyState, ErrorText, Field, Input, Textarea, TimeAgo } from "./ui";
14
15function SettingRow({ setting, kind, manage, inherited }: { setting: Setting; kind: SettingKind; manage: boolean; inherited: boolean }) {
16 const busy = useNavigation().state === "submitting";
17 return (
18 <li className="flex items-center gap-3 border-t border-line px-4 py-2.5 first:border-t-0">
19 <span className="font-mono text-[0.8125rem]">{setting.name}</span>
20 {kind === "variable" && setting.value != null && (
21 <span className="min-w-0 truncate font-mono text-xs text-muted">{setting.value}</span>
22 )}
23 {inherited && (
24 <span className="shrink-0 rounded-full px-2 py-px text-xs text-muted ring-1 ring-line">From the workspace</span>
25 )}
26 <span className="ml-auto shrink-0 text-xs text-faint">
27 Updated <TimeAgo at={setting.updatedAt} />
28 </span>
29 {manage && !inherited && (
30 <Form method="post" className="shrink-0">
31 <input type="hidden" name="intent" value="delete" />
32 <input type="hidden" name="kind" value={kind} />
33 <input type="hidden" name="name" value={setting.name} />
34 <button
35 type="submit"
36 disabled={busy}
37 aria-label={`Remove ${setting.name}`}
38 title="Remove"
39 className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-danger"
40 >
41 <Trash2 size={14} />
42 </button>
43 </Form>
44 )}
45 </li>
46 );
47}
48
49function Section({
50 kind,
51 items,
52 scope,
53 manage,
54 action,
55}: {
56 kind: SettingKind;
57 items: Setting[];
58 scope: "repository" | "workspace";
59 manage: boolean;
60 action: SecretsAction | undefined;
61}) {
62 const navigation = useNavigation();
63 const form = useRef<HTMLFormElement>(null);
64 const mine = action?.kind === kind;
65 // Clear the form once something was saved.
66 useEffect(() => {
67 if (mine && action?.done && navigation.state === "idle") form.current?.reset();
68 }, [mine, action, navigation.state]);
69 const secret = kind === "secret";
70 return (
71 <section>
72 <h3 className="flex items-center gap-2 text-sm font-medium">
73 {secret ? <KeyRound size={15} className="text-accent" /> : <Variable size={15} className="text-accent" />}
74 {secret ? "Secrets" : "Variables"}
75 </h3>
76 <p className="mt-1 max-w-2xl text-sm text-muted">
77 {secret ? (
78 <>
79 Read in workflows as <code className="text-fg">{"${{ secrets.NAME }}"}</code>. Values are sealed when
80 saved, never shown again, and hidden in logs.
81 </>
82 ) : (
83 <>
84 Read in workflows as <code className="text-fg">{"${{ vars.NAME }}"}</code>. For settings that are not
85 secret; their values are shown.
86 </>
87 )}
88 {scope === "workspace" && " Every repository in the workspace reads these, unless it has its own of the same name."}
89 </p>
90 <div className="mt-4">
91 {items.length === 0 ? (
92 <EmptyState title={secret ? "No secrets yet" : "No variables yet"} />
93 ) : (
94 <ul className="overflow-hidden rounded-xl border border-line bg-surface">
95 {items.map((item) => (
96 <SettingRow key={`${item.scope}:${item.name}`} setting={item} kind={kind} manage={manage} inherited={item.scope !== scope} />
97 ))}
98 </ul>
99 )}
100 </div>
101 {manage && (
102 <Form ref={form} method="post" className="mt-4 grid gap-3 rounded-xl border border-line bg-surface p-4">
103 <input type="hidden" name="intent" value="set" />
104 <input type="hidden" name="kind" value={kind} />
105 <Field label="Name" hint="Letters, digits and underscores. Saving one that exists replaces it.">
106 <Input name="name" required placeholder={secret ? "NPM_TOKEN" : "DEPLOY_REGION"} autoComplete="off" />
107 </Field>
108 <Field label="Value">
109 {secret ? (
110 <Textarea name="value" required rows={3} autoComplete="off" spellCheck={false} />
111 ) : (
112 <Input name="value" autoComplete="off" />
113 )}
114 </Field>
115 <div className="flex items-center gap-3">
116 <Button type="submit" disabled={navigation.state === "submitting"}>
117 {secret ? "Save secret" : "Save variable"}
118 </Button>
119 {mine && action?.done && <span className="text-sm text-muted">{action.done}</span>}
120 </div>
121 {mine && <ErrorText>{action?.error}</ErrorText>}
122 </Form>
123 )}
124 </section>
125 );
126}
127
128export function SecretsPanel({
129 data,
130 action,
131 scope,
132 manage,
133}: {
134 data: SecretsData;
135 action: SecretsAction | undefined;
136 scope: "repository" | "workspace";
137 manage: boolean;
138}) {
139 return (
140 <div className="max-w-4xl space-y-12">
141 <ErrorText>{data.error}</ErrorText>
142 <Section kind="secret" items={data.secrets} scope={scope} manage={manage} action={action} />
143 <Section kind="variable" items={data.variables} scope={scope} manage={manage} action={action} />
144 </div>
145 );
146}