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

EasDeviceHostEditor.tsx

347 lines13.3 KB

1import { useAtomValue } from "@effect/atom-react";
2import { useId, useRef, useState } from "react";
3import * as Cause from "effect/Cause";
4import * as Option from "effect/Option";
5import {
6 isSecretReference,
7 type DeviceEasAccount,
8 type EasDeviceHostConfig,
9 type EnvironmentId,
10 type SettingsSecretValue,
11} from "@t3tools/contracts";
12import { XIcon } from "lucide-react";
13​
14import { deviceEasAccountCheck, deviceEnvironment } from "../../state/device";
15import { useAtomCommand } from "../../state/use-atom-command";
16import { Button } from "../ui/button";
17import {
18 Dialog,
19 DialogDescription,
20 DialogFooter,
21 DialogHeader,
22 DialogPanel,
23 DialogPopup,
24 DialogTitle,
25} from "../ui/dialog";
26import { Input } from "../ui/input";
27import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
28import { Spinner } from "../ui/spinner";
29import { parseEasDeviceHostDraft } from "./easDeviceHostsSettings.logic";
30import { SecretSourceInput, useSecretSources } from "./SecretSourceInput";
31​
32const FROM_THREAD_PROJECT = "thread-project";
33​
34const CREDENTIAL_SOURCES: Record<DeviceEasAccount["credentialSource"], string> = {
35 settings: "the access token saved here",
36 env: "EXPO_TOKEN on the server",
37 "eas-login": "the server's eas login",
38};
39​
40const failureMessage = (cause: Cause.Cause<unknown>) => {
41 const error = Cause.squash(cause);
42 return error instanceof Error ? error.message : String(error);
43};
44​
45type AccountCheck =
46 | { status: "idle" }
47 | { status: "checking" }
48 | { status: "signed-in"; account: DeviceEasAccount }
49 | { status: "failed"; error: string };
50​
51export function EasDeviceHostEditor(props: {
52 environmentId: EnvironmentId;
53 host: EasDeviceHostConfig;
54 isNew: boolean;
55 busy: boolean;
56 onSave: (host: EasDeviceHostConfig) => void;
57 onClose: () => void;
58}) {
59 const check = useAtomCommand(deviceEnvironment.easAccount, { reportFailure: false });
60 const [draft, setDraft] = useState(props.host);
61 const tokenId = useId();
62 const secretSources = useSecretSources(props.environmentId);
63 const [tokenValid, setTokenValid] = useState(true);
64 const [tokenInputKey, setTokenInputKey] = useState(0);
65 // A saved token arrives redacted; only a replacement or a clear changes it.
66 const [token, setToken] = useState<
67 { kind: "saved" } | { kind: "new"; value: SettingsSecretValue } | { kind: "cleared" }
68 >(props.host.accessToken ? { kind: "saved" } : { kind: "cleared" });
69 // Checking on open names the saved project and shows a rejected credential
70 // before anything is changed. A manual check or a token edit replaces it.
71 const openCheck = useAtomValue(
72 deviceEasAccountCheck({
73 environmentId: props.environmentId,
74 input: props.host.accessToken
75 ? props.isNew
76 ? {}
77 : { hostId: props.host.id }
78 : { accessToken: "" },
79 }),
80 );
81 const [checked, setAccount] = useState<AccountCheck | null>(null);
82 const account: AccountCheck =
83 checked ??
84 (openCheck._tag === "Initial" || openCheck.waiting
85 ? { status: "checking" }
86 : openCheck._tag === "Success"
87 ? { status: "signed-in", account: openCheck.value }
88 : { status: "failed", error: failureMessage(openCheck.cause) });
89 const next: EasDeviceHostConfig = {
90 ...draft,
91 label: draft.label.trim(),
92 accessToken:
93 token.kind === "saved" ? props.host.accessToken : token.kind === "new" ? token.value : "",
94 };
95 const valid = tokenValid && Option.isSome(parseEasDeviceHostDraft(next));
96 // Why Save is off, for the limits the fields' ranges do not show.
97 const problem = valid
98 ? null
99 : !tokenValid
100 ? "Enter a valid secret reference."
101 : draft.maxSessionMinutes > 0 && draft.idleMinutes >= draft.maxSessionMinutes
102 ? "Idle minutes must be smaller than session minutes."
103 : "Give the host a label and limits within the ranges shown.";
104 const apps = account.status === "signed-in" ? account.account.apps : [];
105​
106 // A check answers for the credential it was asked about: a later check or a
107 // token edit makes an earlier answer stale.
108 const latestCheck = useRef(0);
109 const resetAccount = () => {
110 latestCheck.current++;
111 setAccount({ status: "idle" });
112 };
113 const requestCheck = async () => {
114 const run = ++latestCheck.current;
115 const result = await check({
116 environmentId: props.environmentId,
117 input: {
118 ...(token.kind === "saved" && !props.isNew ? { hostId: props.host.id } : {}),
119 // A cleared token is sent as empty, so the check does not fall back to
120 // the saved one that Save is about to drop.
121 ...(token.kind === "new"
122 ? { accessToken: token.value }
123 : token.kind === "cleared"
124 ? { accessToken: "" }
125 : {}),
126 },
127 });
128 if (run !== latestCheck.current) return;
129 setAccount(
130 result._tag === "Success"
131 ? { status: "signed-in", account: result.value }
132 : { status: "failed", error: failureMessage(result.cause) },
133 );
134 };
135 const checkAccount = () => {
136 setAccount({ status: "checking" });
137 void requestCheck();
138 };
139​
140 const limit = (
141 field: "maxSessionMinutes" | "idleMinutes" | "maxConcurrentSessions",
142 label: string,
143 max: number,
144 ) => (
145 <label className="block space-y-1.5">
146 <span>{label}</span>
147 <Input
148 type="number"
149 min={1}
150 max={max}
151 value={draft[field]}
152 disabled={props.busy}
153 onChange={(event) => {
154 const value = Number(event.target.value);
155 setDraft((current) => ({ ...current, [field]: value }));
156 }}
157 />
158 </label>
159 );
160​
161 return (
162 <Dialog
163 open
164 onOpenChange={(open) => {
165 if (!open && !props.busy) props.onClose();
166 }}
167 >
168 <DialogPopup showCloseButton={!props.busy}>
169 <form
170 className="flex min-h-0 flex-col"
171 onSubmit={(event) => {
172 event.preventDefault();
173 if (valid && !props.busy) props.onSave(next);
174 }}
175 >
176 <DialogHeader>
177 <DialogTitle>{props.isNew ? "Add Expo host" : "Edit Expo host"}</DialogTitle>
178 <DialogDescription>
179 The environment's server signs in to Expo and starts the sessions. Without a token
180 here it uses EXPO_TOKEN, then its eas login.
181 </DialogDescription>
182 </DialogHeader>
183 <DialogPanel>
184 <label className="block space-y-1.5 text-sm">
185 <span>Name</span>
186 <Input
187 autoFocus
188 required
189 value={draft.label}
190 disabled={props.busy}
191 onChange={(event) => {
192 const label = event.target.value;
193 setDraft((current) => ({ ...current, label }));
194 }}
195 />
196 </label>
197 <div className="space-y-1.5 text-sm">
198 <label htmlFor={tokenId} className="block">
199 Expo access token
200 </label>
201 <div className="flex items-center gap-2">
202 <SecretSourceInput
203 key={tokenInputKey}
204 id={tokenId}
205 aria-label="Expo access token"
206 className="min-w-0 flex-1"
207 configured={token.kind === "saved" && typeof props.host.accessToken === "string"}
208 value={
209 token.kind === "new"
210 ? token.value
211 : token.kind === "saved" && isSecretReference(props.host.accessToken)
212 ? props.host.accessToken
213 : ""
214 }
215 sources={secretSources}
216 environmentId={props.environmentId}
217 onValidityChange={(valid) => {
218 setTokenValid(valid);
219 resetAccount();
220 }}
221 onSecretChange={(secret) => {
222 setToken(secret ? { kind: "new", value: secret } : { kind: "cleared" });
223 resetAccount();
224 }}
225 disabled={props.busy}
226 placeholder="Optional"
227 onChange={(event) => {
228 const value = event.target.value;
229 // Emptying the field is not clearing the saved token; the
230 // Clear button is, so a saved token comes back here.
231 setToken(
232 value
233 ? { kind: "new", value }
234 : props.host.accessToken
235 ? { kind: "saved" }
236 : { kind: "cleared" },
237 );
238 resetAccount();
239 }}
240 />
241 {token.kind !== "cleared" ? (
242 <Button
243 type="button"
244 size="icon-sm"
245 variant="ghost-destructive"
246 aria-label="Clear access token"
247 onClick={() => {
248 setToken({ kind: "cleared" });
249 setTokenValid(true);
250 setTokenInputKey((key) => key + 1);
251 resetAccount();
252 }}
253 >
254 <XIcon className="size-3.5" />
255 </Button>
256 ) : null}
257 </div>
258 </div>
259 <div className="flex items-center justify-between gap-3 rounded-lg border border-border/60 px-3 py-2.5">
260 <p role="status" className="min-w-0 text-xs text-muted-foreground">
261 {account.status === "signed-in"
262 ? `Signed in as ${account.account.signedInAs} with ${CREDENTIAL_SOURCES[account.account.credentialSource]}.`
263 : account.status === "failed"
264 ? account.error
265 : account.status === "checking"
266 ? "Checking the Expo account…"
267 : "Check the Expo account before choosing a project."}
268 </p>
269 <Button
270 type="button"
271 size="sm"
272 variant="outline"
273 disabled={props.busy || !tokenValid || account.status === "checking"}
274 onClick={() => void checkAccount()}
275 >
276 {account.status === "checking" ? <Spinner size="xs" /> : null} Check account
277 </Button>
278 </div>
279 <div className="space-y-1.5 text-sm">
280 <span>Expo project</span>
281 <Select
282 value={draft.appId ?? FROM_THREAD_PROJECT}
283 disabled={props.busy}
284 onValueChange={(value) => {
285 if (typeof value !== "string") return;
286 setDraft((current) => {
287 const { appId: _, ...rest } = current;
288 return value === FROM_THREAD_PROJECT ? rest : { ...rest, appId: value };
289 });
290 }}
291 >
292 <SelectTrigger size="sm" aria-label="Expo project">
293 <SelectValue>
294 {(value: string) =>
295 value === FROM_THREAD_PROJECT
296 ? "From the thread's project"
297 : (apps.find((app) => app.id === value)?.fullName ?? value)
298 }
299 </SelectValue>
300 </SelectTrigger>
301 <SelectPopup>
302 <SelectItem value={FROM_THREAD_PROJECT}>From the thread's project</SelectItem>
303 {draft.appId && !apps.some((app) => app.id === draft.appId) ? (
304 <SelectItem value={draft.appId}>{draft.appId}</SelectItem>
305 ) : null}
306 {apps.map((app) => (
307 <SelectItem key={app.id} value={app.id}>
308 {app.fullName}
309 </SelectItem>
310 ))}
311 </SelectPopup>
312 </Select>
313 <p className="text-xs text-muted-foreground">
314 Sessions bill the project's Expo account. "From the thread's project" reads
315 extra.eas.projectId from the project's app config.
316 </p>
317 </div>
318 <div className="grid grid-cols-3 gap-3 text-sm">
319 {limit("maxConcurrentSessions", "Sessions at once", 20)}
320 {limit("maxSessionMinutes", "Session minutes", 115)}
321 {limit("idleMinutes", "Idle minutes", 60)}
322 </div>
323 <p className="text-xs text-muted-foreground">
324 Expo Simulators needs a paid Expo account. A session runs for up to 115 minutes and
325 stops early when it is idle for the idle minutes, so idle minutes must be less than
326 session minutes.
327 {props.isNew ? null : " Saving restarts this host and stops its running sessions."}
328 </p>
329 {problem === null ? null : (
330 <p className="text-xs text-destructive" role="alert">
331 {problem}
332 </p>
333 )}
334 </DialogPanel>
335 <DialogFooter>
336 <Button type="button" variant="ghost" disabled={props.busy} onClick={props.onClose}>
337 Cancel
338 </Button>
339 <Button type="submit" disabled={props.busy || !valid}>
340 {props.busy ? <Spinner size="xs" /> : null} Save host
341 </Button>
342 </DialogFooter>
343 </form>
344 </DialogPopup>
345 </Dialog>
346 );