open, interoperable sandbox platform for agents and humans 📦 ✨
pocketenv.io
claude-code
atproto
sandbox
openclaw
agent
1import { useEffect, useState } from "react";
2import { createPortal } from "react-dom";
3import { useForm } from "react-hook-form";
4import { z } from "zod";
5import { zodResolver } from "@hookform/resolvers/zod";
6import {
7 useAddSecretMutation,
8 useSecretQuery,
9 useUpdateSecretMutation,
10} from "../../../hooks/useSecret";
11import { useSodium } from "../../../hooks/useSodium";
12import { PUBLIC_KEY } from "../../../consts";
13import { useNotyf } from "../../../hooks/useNotyf";
14
15const UPPER_SNAKE_CASE_REGEX = /^[A-Z][A-Z0-9_]*$/;
16
17const schema = z.object({
18 name: z
19 .string()
20 .min(1, "Name is required")
21 .regex(
22 UPPER_SNAKE_CASE_REGEX,
23 "Name must be in UPPER_SNAKE_CASE (e.g. MY_SECRET)",
24 ),
25 value: z.string().min(1, "Value is required"),
26});
27
28type FormValues = z.infer<typeof schema>;
29
30export type AddSecretModalProps = {
31 isOpen: boolean;
32 onClose: () => void;
33 sandboxId: string;
34 secretId?: string;
35};
36
37function AddSecretModal({
38 isOpen,
39 onClose,
40 sandboxId,
41 secretId,
42}: AddSecretModalProps) {
43 const sodium = useSodium();
44 const notyf = useNotyf();
45 const [isLoading, setIsLoading] = useState(false);
46 const { mutateAsync: addSecret } = useAddSecretMutation();
47 const { mutateAsync: updateSecret } = useUpdateSecretMutation();
48 const { data } = useSecretQuery(secretId!);
49 const {
50 register,
51 handleSubmit,
52 reset,
53 setValue,
54 formState: { errors },
55 } = useForm<FormValues>({
56 resolver: zodResolver(schema),
57 });
58
59 const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
60 const transformed = e.target.value
61 .toUpperCase()
62 .replace(/\s+/g, "_")
63 .replace(/[^A-Z0-9_]/g, "");
64 setValue("name", transformed, { shouldValidate: true });
65 };
66
67 useEffect(() => {
68 if (data) {
69 setValue("name", data.secret?.name);
70 }
71 }, [data, setValue]);
72
73 useEffect(() => {
74 const handleEscapeKey = (event: KeyboardEvent) => {
75 if (event.key === "Escape" && isOpen) {
76 reset();
77 onClose();
78 }
79 };
80
81 document.addEventListener("keydown", handleEscapeKey);
82 return () => {
83 document.removeEventListener("keydown", handleEscapeKey);
84 };
85 }, [isOpen, onClose, reset]);
86
87 const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
88 e.stopPropagation();
89 if (e.target === e.currentTarget) {
90 reset();
91 onClose();
92 }
93 };
94
95 const handleContentClick = (e: React.MouseEvent<HTMLDivElement>) => {
96 e.stopPropagation();
97 };
98
99 const handleCloseButton = (e: React.MouseEvent<HTMLButtonElement>) => {
100 e.stopPropagation();
101 reset();
102 onClose();
103 };
104
105 const onSubmit = async (data: FormValues) => {
106 setIsLoading(true);
107 const sealed = sodium.cryptoBoxSeal(
108 sodium.fromString(data.value),
109 sodium.fromHex(PUBLIC_KEY),
110 );
111 try {
112 if (secretId) {
113 await updateSecret({
114 id: secretId,
115 name: data.name,
116 value: sodium.toBase64(
117 sealed,
118 sodium.base64Variants.URLSAFE_NO_PADDING,
119 ),
120 sandboxId,
121 });
122 setIsLoading(false);
123 reset();
124 onClose();
125 notyf.open("primary", "Secret updated successfully!");
126 return;
127 }
128 await addSecret({
129 sandboxId,
130 name: data.name,
131 value: sodium.toBase64(
132 sealed,
133 sodium.base64Variants.URLSAFE_NO_PADDING,
134 ),
135 });
136
137 setIsLoading(false);
138 reset();
139 onClose();
140 notyf.open("primary", "Secret added successfully!");
141 } catch {
142 notyf.open("error", "Failed to add secret!");
143 setIsLoading(false);
144 reset();
145 onClose();
146 }
147 };
148
149 if (!isOpen) return null;
150
151 return createPortal(
152 <>
153 <div
154 className="overlay modal modal-middle overlay-open:opacity-100 overlay-open:duration-300 open opened"
155 role="dialog"
156 style={{ outline: "none", zIndex: 80 }}
157 onClick={handleBackdropClick}
158 onMouseDown={handleBackdropClick}
159 >
160 <div
161 className={`overlay-animation-target modal-dialog overlay-open:duration-300 transition-all ease-out modal-dialog-lg overlay-open:mt-4 mt-12`}
162 onClick={handleContentClick}
163 onMouseDown={handleContentClick}
164 >
165 <div className="modal-content">
166 <div className="modal-header">
167 <div className="flex-1">
168 {secretId ? "Edit Secret" : "Add Secret"}
169 </div>
170 <button
171 type="button"
172 className="btn btn-text btn-circle btn-sm absolute end-3 top-3"
173 aria-label="Close"
174 onClick={handleCloseButton}
175 onMouseDown={(e) => e.stopPropagation()}
176 >
177 <span className="icon-[tabler--x] size-4"></span>
178 </button>
179 </div>
180 <form onSubmit={handleSubmit(onSubmit)}>
181 <div className="modal-body">
182 <div className="form-control w-full">
183 <label className="label">
184 <span className="label-text font-bold mb-1 text-[14px]">
185 Name
186 </span>
187 </label>
188 <div
189 className={`input input-bordered w-full input-lg text-[15px] font-semibold bg-transparent ${errors.name ? "input-error" : ""}`}
190 >
191 <input
192 type="text"
193 placeholder="YOUR_SECRET_NAME"
194 className={`grow`}
195 autoComplete="off"
196 data-1p-ignore
197 data-lpignore="true"
198 data-form-type="other"
199 style={{ fontFamily: "CaskaydiaNerdFontMonoRegular" }}
200 {...register("name", { onChange: handleNameChange })}
201 />
202 </div>
203 {errors.name && (
204 <span className="text-error text-[12px] mt-1">
205 {errors.name.message}
206 </span>
207 )}
208 <div className="mt-5">
209 <label className="label">
210 <span className="label-text font-bold mb-1 text-[14px]">
211 Value
212 </span>
213 </label>
214 <textarea
215 className={`textarea max-w-full h-[250px] text-[14px] font-semibold`}
216 aria-label="Textarea"
217 placeholder="Secret Value"
218 style={{ fontFamily: "CaskaydiaNerdFontMonoRegular" }}
219 {...register("value")}
220 ></textarea>
221 {errors.value && (
222 <span className="text-error text-[12px] mt-1 block">
223 {errors.value.message}
224 </span>
225 )}
226 </div>
227 </div>
228 </div>
229 <div className="modal-footer">
230 <button type="submit" className="btn btn-primary">
231 {isLoading && (
232 <span className="loading loading-spinner loading-xs mr-1.5"></span>
233 )}
234 {secretId ? "Save Changes" : "Add Secret"}
235 </button>
236 </div>
237 </form>
238 </div>
239 </div>
240 </div>
241
242 <div
243 data-overlay-backdrop-template=""
244 style={{ zIndex: 79 }}
245 className="overlay-backdrop transition duration-300 fixed inset-0 bg-base-300/60 overflow-y-auto opacity-75"
246 onClick={handleBackdropClick}
247 onMouseDown={(e) => e.stopPropagation()}
248 ></div>
249 </>,
250 document.body,
251 );
252}
253
254export default AddSecretModal;