Open History to see the last commit
1import { pipe } from "effect/Function";
2import {
3 SECRET_REQUEST_DEFAULT_PLACEHOLDER,
4 secretRequestAnswerInput,
5 secretRequestDisplay,
6 secretRequestFailureMessage,
7 secretRequestLink,
8 secretRequestPrivacyNote,
9 secretRequestReference,
10 secretRequestSources,
11 type SecretRequestItem,
12 type SecretRequestReply,
13 type SecretRequestSource,
14} from "@t3tools/client-runtime/secret-request";
15import {
16 isAtomCommandInterrupted,
17 squashAtomCommandFailure,
18} from "@t3tools/client-runtime/state/runtime";
19import type {
20 EnvironmentId,
21 OrchestrationV2ProjectedTurnItem,
22 SecretProviderId,
23} from "@t3tools/contracts";
24import { useAtomValue } from "@effect/atom-react";
25import { CheckIcon, ExternalLinkIcon, LockIcon, MinusIcon, ShieldCheckIcon } from "lucide-react";
26import { useId, useRef, useState, type FormEvent } from "react";
27
28import { isSecretsFeatureEnabled } from "../../env";
29import { serverEnvironment } from "../../state/server";
30import { useAtomCommand } from "../../state/use-atom-command";
31import { Button } from "../ui/button";
32import { Input } from "../ui/input";
33import { SecretSourceSelect } from "../settings/SecretSourceInput";
34import { WorkLogRow } from "./WorkLog";
35
36export function SecretRequestCard(props: {
37 readonly environmentId: EnvironmentId;
38 readonly item: SecretRequestItem;
39 readonly visibility: OrchestrationV2ProjectedTurnItem["visibility"];
40}) {
41 const { item } = props;
42 const display = secretRequestDisplay(item, props.visibility);
43 if (display.kind === "answered" || display.kind === "pending-elsewhere") {
44 const Icon =
45 display.kind === "pending-elsewhere"
46 ? LockIcon
47 : display.outcome === "saved"
48 ? CheckIcon
49 : MinusIcon;
50 return (
51 <WorkLogRow
52 data-v2-item-type={item.type}
53 icon={<Icon className="size-3.5 text-icon-muted" aria-hidden />}
54 label={
55 <>
56 {item.label} · {display.label}
57 </>
58 }
59 />
60 );
61 }
62 return <PendingSecretRequestForm environmentId={props.environmentId} item={item} />;
63}
64
65const NO_SOURCES: ReadonlyArray<SecretRequestSource> = [];
66
67function useSecretRequestSources(environmentId: EnvironmentId) {
68 const config = useAtomValue(serverEnvironment.configValueAtom(environmentId));
69 return isSecretsFeatureEnabled
70 ? secretRequestSources(config, config?.settings.secretProviders ?? {})
71 : NO_SOURCES;
72}
73
74function PendingSecretRequestForm(props: {
75 readonly environmentId: EnvironmentId;
76 readonly item: SecretRequestItem;
77}) {
78 const { item } = props;
79 const inputId = useId();
80 const errorId = useId();
81 const privacyId = useId();
82 const answer = useAtomCommand(serverEnvironment.answerSecretRequest, {
83 label: "answer secret request",
84 // The failure cause holds the request; keep it out of the console.
85 reportFailure: false,
86 reportDefect: false,
87 });
88 const sources = useSecretRequestSources(props.environmentId);
89 const [sourceId, setSourceId] = useState<SecretProviderId | null>(null);
90 const source = sources.find((candidate) => candidate.id === sourceId) ?? null;
91 const [secret, setSecret] = useState("");
92 const [reference, setReference] = useState("");
93 const [submitting, setSubmitting] = useState(false);
94 const [error, setError] = useState<string | null>(null);
95 // Enter and click can both submit before a render.
96 const inFlight = useRef(false);
97
98 const send = async (reply: SecretRequestReply) => {
99 const input = secretRequestAnswerInput(item, reply);
100 if (input === null || inFlight.current) return;
101 inFlight.current = true;
102 setSubmitting(true);
103 setError(null);
104 const result = await answer({ environmentId: props.environmentId, input }).finally(() => {
105 inFlight.current = false;
106 setSubmitting(false);
107 });
108 if (result._tag === "Success") {
109 setSecret("");
110 setReference("");
111 return;
112 }
113 if (!isAtomCommandInterrupted(result)) {
114 pipe(squashAtomCommandFailure(result), secretRequestFailureMessage, setError);
115 }
116 };
117
118 const onSubmit = (event: FormEvent<HTMLFormElement>) => {
119 event.preventDefault();
120 if (source === null) {
121 void send({ type: "save", secret });
122 } else if (secretRequestReference(source, reference) === undefined) {
123 setError(source.invalidReferenceMessage);
124 } else {
125 void send({ type: "reference", source, reference });
126 }
127 };
128 const draft = source === null ? secret : reference;
129 const link = secretRequestLink(item);
130 const describedBy = error !== null ? `${privacyId} ${errorId}` : privacyId;
131
132 return (
133 <form
134 data-v2-item-type={item.type}
135 className="flex min-w-0 flex-col gap-3 rounded-xl border border-border/60 bg-card p-4"
136 onSubmit={onSubmit}
137 autoComplete="off"
138 // On click, not pointerdown: the mousedown that follows a pointerdown
139 // moves focus back off the input. A click that ends a text selection
140 // leaves it alone so the reason can still be copied.
141 onClick={(event) => {
142 if (!(event.target instanceof Element)) return;
143 if (event.target.closest("input, button, a")) return;
144 if (window.getSelection()?.isCollapsed === false) return;
145 document.getElementById(inputId)?.focus();
146 }}
147 >
148 <div className="flex min-w-0 flex-col gap-1">
149 <label htmlFor={inputId} className="text-sm font-medium text-foreground">
150 {item.label}
151 </label>
152 {item.reason.trim() ? (
153 <p className="text-sm wrap-anywhere text-muted-foreground">{item.reason}</p>
154 ) : null}
155 </div>
156 {link !== null ? (
157 <div className="flex min-w-0 flex-col items-start gap-1.5">
158 <Button
159 variant="outline"
160 size="sm"
161 render={<a href={link.href} target="_blank" rel="noopener noreferrer" />}
162 >
163 Open {link.host}
164 <ExternalLinkIcon aria-hidden />
165 </Button>
166 {/* A link found in the reason is already shown there. */}
167 {item.url !== undefined ? (
168 <p className="font-mono text-xs wrap-anywhere text-muted-foreground">{item.url}</p>
169 ) : null}
170 </div>
171 ) : null}
172 <div className="flex min-w-0 items-center gap-2">
173 {sources.length > 0 ? (
174 <SecretSourceSelect
175 value={source?.id ?? null}
176 sources={sources}
177 label={item.label}
178 disabled={submitting}
179 onValueChange={(next) => {
180 setSourceId(next);
181 setError(null);
182 }}
183 />
184 ) : null}
185 <div className="min-w-0 flex-1">
186 {source === null ? (
187 <Input
188 id={inputId}
189 // A CSS-masked text field still exposes its value to screen readers.
190 type="password"
191 autoComplete="off"
192 autoCorrect="off"
193 autoCapitalize="off"
194 spellCheck={false}
195 data-1p-ignore
196 data-lpignore="true"
197 data-bwignore
198 placeholder={item.placeholder ?? SECRET_REQUEST_DEFAULT_PLACEHOLDER}
199 value={secret}
200 disabled={submitting}
201 aria-invalid={error !== null || undefined}
202 aria-describedby={describedBy}
203 onChange={(event) => setSecret(event.currentTarget.value)}
204 />
205 ) : (
206 <Input
207 id={inputId}
208 font="mono"
209 autoComplete="off"
210 autoCorrect="off"
211 autoCapitalize="off"
212 spellCheck={false}
213 placeholder={source.referencePlaceholder}
214 value={reference}
215 disabled={submitting}
216 aria-invalid={error !== null || undefined}
217 aria-describedby={describedBy}
218 onChange={(event) => {
219 setReference(event.currentTarget.value);
220 setError(null);
221 }}
222 />
223 )}
224 </div>
225 <Button type="submit" disabled={submitting || draft.trim().length === 0}>
226 Save securely
227 </Button>
228 </div>
229 {error !== null ? (
230 <p id={errorId} role="alert" className="text-xs text-destructive">
231 {error}
232 </p>
233 ) : null}
234 <div className="flex min-w-0 items-center justify-between gap-2">
235 <p
236 id={privacyId}
237 className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground"
238 >
239 <ShieldCheckIcon className="size-3.5 shrink-0" aria-hidden />
240 {secretRequestPrivacyNote(item)}
241 </p>
242 <Button
243 type="button"
244 size="xs"
245 variant="ghost-muted"
246 disabled={submitting}
247 onClick={() => void send({ type: "decline" })}
248 >
249 Decline
250 </Button>
251 </div>
252 </form>
253 );
254}