@unoverse-platform/studio 0.1.56 → 0.1.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,232 +0,0 @@
1
- /**
2
- * Connect to a universe: the "when you are ready" step.
3
- *
4
- * Everything before this point is offline. A developer builds components, templates and
5
- * nodes against their own folders with no account and no network. This screen is where
6
- * they choose somewhere to publish, and it is deliberately the only place Studio asks
7
- * for anything.
8
- *
9
- * The flow is: type an address, Studio asks that universe who authenticates it, then
10
- * signs in against whatever the answer was. Studio names no provider.
11
- *
12
- * See docs/architecture/LOCAL_STUDIO.md.
13
- */
14
- import { useState } from "react";
15
- import { useAuth } from "react-oidc-context";
16
- import {
17
- normaliseUrl,
18
- defaultLabel,
19
- discover,
20
- loginReady,
21
- canPublish,
22
- loadUniverses,
23
- saveUniverse,
24
- removeUniverse,
25
- type Universe,
26
- } from "./universes";
27
-
28
- /** Decode a JWT payload without verifying it. Display only: the universe verifies. */
29
- function claimsOf(accessToken: string | undefined): Record<string, unknown> | null {
30
- if (!accessToken) return null;
31
- try {
32
- return JSON.parse(atob(accessToken.split(".")[1] ?? ""));
33
- } catch {
34
- return null;
35
- }
36
- }
37
-
38
- /**
39
- * Sign out of the connected universe.
40
- *
41
- * `removeUser` clears the local session only: it does not sign you out of the identity
42
- * provider, so signing in again may return the same account without prompting. That is the
43
- * provider's decision, not ours, and a full federated logout needs a logout URL configured
44
- * per universe. Local is the honest scope for a tool.
45
- */
46
- function SignOut({ auth }: { auth: ReturnType<typeof useAuth> }) {
47
- return (
48
- <button
49
- onClick={() => auth?.removeUser()}
50
- className="text-xs text-gray-500 underline-offset-2 hover:text-gray-300 hover:underline"
51
- >
52
- Sign out
53
- </button>
54
- );
55
- }
56
-
57
- export function ConnectUniverse({
58
- active,
59
- onSelect,
60
- onClose,
61
- }: {
62
- active: Universe | null;
63
- onSelect: (universe: Universe | null) => void;
64
- onClose: () => void;
65
- }) {
66
- const [universes, setUniverses] = useState<Universe[]>(() => loadUniverses());
67
- const [address, setAddress] = useState("");
68
- const [busy, setBusy] = useState(false);
69
- const [error, setError] = useState<string | null>(null);
70
-
71
- // Undefined when no provider is mounted, which is the normal offline case.
72
- const auth = useAuth();
73
- const claims = claimsOf(auth?.user?.access_token);
74
- const signedIn = Boolean(auth?.isAuthenticated);
75
-
76
- async function connect() {
77
- setError(null);
78
- setBusy(true);
79
- try {
80
- const url = normaliseUrl(address);
81
- const config = await discover(url);
82
- const universe: Universe = {
83
- url,
84
- label: defaultLabel(url),
85
- config,
86
- connectedAt: new Date().toISOString(),
87
- };
88
- setUniverses(saveUniverse(universe));
89
- setAddress("");
90
- onSelect(universe);
91
- } catch (e: any) {
92
- // These messages are written to be actionable (universes.ts), so show them as-is.
93
- setError(e?.message ?? String(e));
94
- } finally {
95
- setBusy(false);
96
- }
97
- }
98
-
99
- function forget(url: string) {
100
- setUniverses(removeUniverse(url));
101
- if (active?.url === url) onSelect(null);
102
- }
103
-
104
- const readiness = active ? loginReady(active.config) : null;
105
- const allowed = active && claims ? canPublish(claims, active.config) : false;
106
- /** The signed-in address. Namespace-aware: Auth0 emits `email` ONLY namespaced on an
107
- * access token, so a plain `claims.email` is undefined and the id leaks into the UI. */
108
- const email = (() => {
109
- const direct = (claims as any)?.email;
110
- if (typeof direct === "string") return direct;
111
- const key = Object.keys(claims ?? {}).find((k) => k.endsWith("/email") || k.endsWith("/claims/email"));
112
- const value = key ? (claims as any)[key] : undefined;
113
- return typeof value === "string" ? value : null;
114
- })();
115
-
116
- /** What the token says, for the message below. Trimmed, as the checks are. */
117
- const permissionList: string[] = (() => {
118
- const direct = (claims as any)?.permissions;
119
- const nsKey = Object.keys(claims ?? {}).find((k) => k.endsWith("/permissions"));
120
- const list = Array.isArray(direct) ? direct : ((claims as any)?.[nsKey ?? ""] ?? []);
121
- return Array.isArray(list) ? list.map((p) => String(p).trim()) : [];
122
- })();
123
-
124
- return (
125
- <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/60 p-8" onClick={onClose}>
126
- <div
127
- className="w-full max-w-lg rounded-xl border border-gray-800 bg-[#151b22] p-6 text-gray-200 shadow-2xl"
128
- onClick={(e) => e.stopPropagation()}
129
- >
130
- <h2 className="text-base font-semibold text-gray-100">Publish to a universe</h2>
131
- <p className="mt-1 text-xs text-gray-400">
132
- Studio works offline. Connect a universe when you are ready to publish.
133
- </p>
134
-
135
- {/* Add one */}
136
- <div className="mt-5 flex gap-2">
137
- <input
138
- value={address}
139
- onChange={(e) => setAddress(e.target.value)}
140
- onKeyDown={(e) => e.key === "Enter" && !busy && connect()}
141
- placeholder="your-universe.com"
142
- className="flex-1 rounded border border-gray-700 bg-[#1b2129] px-3 py-2 text-sm text-gray-200 placeholder:text-gray-600 focus:border-gray-500 focus:outline-none"
143
- />
144
- <button
145
- onClick={connect}
146
- disabled={busy || !address.trim()}
147
- className="rounded bg-gray-100 px-4 py-2 text-sm font-medium text-gray-900 hover:bg-white disabled:opacity-30"
148
- >
149
- {busy ? "Checking..." : "Connect"}
150
- </button>
151
- </div>
152
- {error && <p className="mt-2 text-xs text-red-400">{error}</p>}
153
-
154
- {/* The ones this developer has connected to. There is no central directory of
155
- universes and there cannot be: each is independent with its own provider. */}
156
- {universes.length > 0 && (
157
- <ul className="mt-5 space-y-2">
158
- {universes.map((u) => (
159
- <li
160
- key={u.url}
161
- className={`flex items-center justify-between rounded-md border px-3 py-2 text-sm ${
162
- active?.url === u.url
163
- ? "border-gray-500 bg-white/5"
164
- : "border-gray-800 hover:border-gray-700"
165
- }`}
166
- >
167
- <button className="text-left" onClick={() => onSelect(u)}>
168
- <span className="font-medium">{u.label}</span>
169
- <span className="ml-2 text-gray-500">{u.url}</span>
170
- </button>
171
- <button className="text-xs text-gray-500 hover:text-red-400" onClick={() => forget(u.url)}>
172
- Forget
173
- </button>
174
- </li>
175
- ))}
176
- </ul>
177
- )}
178
-
179
- {/* Sign-in state for the selected universe. Every branch says what to do next. */}
180
- {active && (
181
- <div className="mt-5 border-t border-gray-800 pt-4 text-sm">
182
- {readiness && !readiness.ok ? (
183
- // A universe with auth off, or one misconfigured. Publishing is not
184
- // possible, and saying so beats a redirect to a provider that is not there.
185
- <p className="text-xs text-amber-400">{readiness.reason}</p>
186
- ) : !signedIn ? (
187
- <button
188
- onClick={() => auth?.signinRedirect()}
189
- className="rounded bg-gray-100 px-4 py-2 text-sm font-medium text-gray-900 hover:bg-white"
190
- >
191
- Sign in to {active.label}
192
- </button>
193
- ) : allowed ? (
194
- <div className="flex items-center justify-between gap-3">
195
- {/* The fact, not the identifier. A raw `auth0|692be24b…` is noise: it is not
196
- how anyone thinks of themselves, and it appeared only because `email` is a
197
- NAMESPACED claim that a plain read misses (AUTH_TOKEN_FLOW.md names this
198
- exact footgun). Read properly it is an address; absent, the sentence is
199
- better without it. */}
200
- <p className="text-sm font-medium text-emerald-400">
201
- Ready to publish{email ? <span className="font-normal text-gray-400"> as {email}</span> : null}
202
- </p>
203
- <SignOut auth={auth} />
204
- </div>
205
- ) : (
206
- // Signed in but without the permission. Said HERE, not at the first push,
207
- // so nobody builds for an hour and then finds out.
208
- <div>
209
- <p className="text-xs text-amber-400">
210
- Signed in, but your account does not have{" "}
211
- <code>{active.config.publishPermission}</code> on this universe. Ask whoever
212
- administers it to grant it.
213
- </p>
214
- {/* WHAT THE TOKEN ACTUALLY CARRIES. Kept, in a reduced form, because "you do not
215
- have this permission" is unhelpful when the identity provider visibly
216
- says you do. Printing the claim turns a guess into a fact: it is how a
217
- leading space in an Auth0 permission (" marketplace:publish") was found,
218
- after the screens, the token and the label had all agreed it was correct. */}
219
- <p className="mt-2 text-xs text-gray-500">
220
- Your token carries: <code>{permissionList.join(", ") || "no permissions claim"}</code>
221
- </p>
222
- <div className="mt-2">
223
- <SignOut auth={auth} />
224
- </div>
225
- </div>
226
- )}
227
- </div>
228
- )}
229
- </div>
230
- </div>
231
- );
232
- }
@@ -1,309 +0,0 @@
1
- /**
2
- * Publish a project to the connected universe.
3
- *
4
- * PLAN, THEN SEND. Publishing writes rows that every workflow in a universe can use, so
5
- * "what am I about to do" is answered before anything moves. Blind publishing is how a
6
- * project half-lands and nobody can tell by looking.
7
- *
8
- * The plan runs the moment this opens, unasked: it reads disk and dry-runs against the
9
- * universe, writing nothing. Only the second press sends. An empty dialog with a button
10
- * marked "check" made the developer ask for the answer they opened the dialog to get.
11
- *
12
- * The work happens in Studio's local host (`local/plugin.ts`), not here: it reads the
13
- * developer's disk, which a browser cannot. This screen collects a decision and shows a
14
- * result. It calls the same functions as `unoverse publish assets`, so the two cannot
15
- * disagree about what a publish is.
16
- *
17
- * See docs/architecture/DECLARATIVE_NODES.md §9.
18
- */
19
- import { useEffect, useRef, useState } from "react";
20
- import { useAuth } from "react-oidc-context";
21
- import { canPublish, type Universe } from "./universes";
22
-
23
- interface Item {
24
- kind: string;
25
- name: string;
26
- why?: string;
27
- mode?: string;
28
- }
29
- interface Plan {
30
- create: Item[];
31
- update: Item[];
32
- unchanged: Item[];
33
- refused: Item[];
34
- }
35
- interface Problem {
36
- level: string;
37
- file: string;
38
- line?: number;
39
- msg: string;
40
- }
41
-
42
- /** Decode a JWT payload without verifying it. Display only: the universe verifies. */
43
- function claimsOf(token: string | undefined): Record<string, unknown> | null {
44
- if (!token) return null;
45
- try {
46
- return JSON.parse(atob(token.split(".")[1] ?? ""));
47
- } catch {
48
- return null;
49
- }
50
- }
51
-
52
- export function PublishDialog({
53
- project,
54
- universe,
55
- onClose,
56
- }: {
57
- project: string;
58
- universe: Universe;
59
- onClose: () => void;
60
- }) {
61
- const auth = useAuth();
62
- const token = auth?.user?.access_token;
63
-
64
- const [plan, setPlan] = useState<Plan | null>(null);
65
- const [problems, setProblems] = useState<Problem[]>([]);
66
- const [blocked, setBlocked] = useState<Problem[] | null>(null);
67
- const [sent, setSent] = useState<{ sent: Item[]; failed: Item[] } | null>(null);
68
- const [busy, setBusy] = useState(false);
69
- const [error, setError] = useState<string | null>(null);
70
-
71
- const allowed = canPublish(claimsOf(token), universe.config);
72
-
73
- async function call(step: "plan" | "send") {
74
- setError(null);
75
- setBusy(true);
76
- try {
77
- const res = await fetch(`/publish/${step}`, {
78
- method: "POST",
79
- headers: { "content-type": "application/json" },
80
- body: JSON.stringify({ project, universe: universe.url, token }),
81
- });
82
- const body = await res.json();
83
- if (body.error) throw new Error(body.error);
84
- // Errors STOP the publish. Reported as findings rather than a failure, because the
85
- // developer's next action is to fix a file, not to retry.
86
- if (body.blocked) {
87
- setBlocked(body.errors);
88
- return;
89
- }
90
- setProblems(body.problems ?? []);
91
- setPlan(body.plan);
92
- if (step === "send") setSent({ sent: body.sent ?? [], failed: body.failed ?? [] });
93
- } catch (e: any) {
94
- setError(e?.message ?? String(e));
95
- } finally {
96
- setBusy(false);
97
- }
98
- }
99
-
100
- /**
101
- * PLAN ON OPEN. The dialog's whole job is to answer "what am I about to publish", so it
102
- * answers it without being asked. Planning reads disk and dry-runs against the universe;
103
- * it writes nothing, which is what makes running it unprompted safe.
104
- *
105
- * The guard is a ref rather than the usual empty dep array: `call` closes over state that
106
- * changes, and a second plan on a re-render would be a wasted round trip and a flicker.
107
- */
108
- const planned = useRef(false);
109
- useEffect(() => {
110
- if (planned.current || !allowed || !token) return;
111
- planned.current = true;
112
- call("plan");
113
- }, [allowed, token]);
114
-
115
- const toSend = (plan?.create.length ?? 0) + (plan?.update.length ?? 0);
116
- const warnings = problems.filter((p) => p.level === "warn").length;
117
-
118
- /**
119
- * ONE ROW PER ITEM. A comma-joined list per status cannot be scanned: the question a
120
- * developer opens this with is "which of MY things is about to move", and that is a
121
- * property of the item, not of a group heading. So every item gets a line carrying its
122
- * own status, and the statuses sort so the ones that will move sit at the top.
123
- *
124
- * The kind is singular here, because a row is one thing. `bpp` is a style set and
125
- * `bpp-chat-layout` is an app; without that word they read as five apps, which is how a
126
- * developer concludes their components were never collected when they were.
127
- */
128
- const KIND_LABEL: Record<string, string> = {
129
- component: "component",
130
- atom: "atom",
131
- style: "styles",
132
- template: "app",
133
- node: "node",
134
- skill: "skill",
135
- "prompt-block": "prompt block",
136
- recipe: "recipe",
137
- };
138
-
139
- /**
140
- * Contrast is the whole job here. Dark text on a dark panel made a five row list
141
- * unreadable, so the name carries full weight, the status carries colour, and only the
142
- * kind is allowed to recede.
143
- */
144
- const STATUS = {
145
- create: { label: "new", pill: "bg-emerald-400/15 text-emerald-300 ring-emerald-400/30", order: 0 },
146
- update: { label: "changed", pill: "bg-amber-400/15 text-amber-300 ring-amber-400/30", order: 1 },
147
- refused: { label: "refused", pill: "bg-red-400/15 text-red-300 ring-red-400/30", order: 2 },
148
- unchanged: { label: "unchanged", pill: "bg-gray-500/10 text-gray-400 ring-gray-500/30", order: 3 },
149
- } as const;
150
- type Status = keyof typeof STATUS;
151
-
152
- /** Every item, flattened, carrying its status. Sorted by what will move, then by name. */
153
- const rows: Array<Item & { status: Status }> = plan
154
- ? (Object.keys(STATUS) as Status[])
155
- .flatMap((status) => plan[status].map((i) => ({ ...i, status })))
156
- .sort(
157
- (a, b) =>
158
- STATUS[a.status].order - STATUS[b.status].order ||
159
- a.kind.localeCompare(b.kind) ||
160
- a.name.localeCompare(b.name),
161
- )
162
- : [];
163
-
164
- return (
165
- <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/60 p-8" onClick={onClose}>
166
- <div
167
- className="w-full max-w-xl rounded-xl border border-gray-800 bg-[#151b22] p-6 text-gray-200 shadow-2xl"
168
- onClick={(e) => e.stopPropagation()}
169
- >
170
- <h2 className="text-base font-semibold text-gray-100">
171
- Publish <span className="text-gray-400">{project}</span> to {universe.label}
172
- </h2>
173
-
174
- {/* Said HERE, before any work, not at the first push. */}
175
- {!allowed && (
176
- <p className="mt-3 text-xs text-amber-400">
177
- Your account does not have <code>{universe.config.publishPermission}</code> on this
178
- universe. Ask whoever administers it to grant it.
179
- </p>
180
- )}
181
-
182
- {error && <p className="mt-3 text-xs text-red-400">{error}</p>}
183
-
184
- {busy && !plan && !blocked && (
185
- <p className="mt-4 text-sm text-gray-500">Reading {project}…</p>
186
- )}
187
-
188
- {/* Lint errors: nothing was sent, and the file and line are what fixes it. */}
189
- {blocked && (
190
- <div className="mt-4">
191
- <p className="text-xs text-red-400">
192
- {blocked.length} error{blocked.length === 1 ? "" : "s"} in {project}. Nothing was sent.
193
- </p>
194
- <ul className="mt-2 max-h-48 space-y-1 overflow-y-auto text-xs">
195
- {blocked.slice(0, 12).map((p, i) => (
196
- <li key={i} className="text-gray-400">
197
- <span className="text-gray-500">
198
- {p.file}
199
- {p.line ? `:${p.line}` : ""}
200
- </span>{" "}
201
- {p.msg}
202
- </li>
203
- ))}
204
- </ul>
205
- </div>
206
- )}
207
-
208
- {plan && !blocked && (
209
- <div className="mt-4">
210
- {/* The count sentence answers "how much", the list answers "which". Both, always. */}
211
- <p className="text-xs font-medium uppercase tracking-wide text-gray-400">
212
- {[
213
- plan.create.length && `${plan.create.length} new`,
214
- plan.update.length && `${plan.update.length} changed`,
215
- plan.unchanged.length && `${plan.unchanged.length} unchanged`,
216
- plan.refused.length && `${plan.refused.length} refused`,
217
- ]
218
- .filter(Boolean)
219
- .join(", ") || "nothing to publish"}
220
- </p>
221
-
222
- <div className="mt-3 max-h-72 divide-y divide-white/5 overflow-y-auto rounded-lg border border-white/10 bg-black/20">
223
- {rows.map((r) => (
224
- <div key={`${r.kind}/${r.name}`} className="flex items-baseline gap-3 px-3 py-2 hover:bg-white/5">
225
- <span
226
- className={`w-[74px] shrink-0 rounded px-1.5 py-0.5 text-center text-[11px] font-semibold uppercase tracking-wide ring-1 ${STATUS[r.status].pill}`}
227
- >
228
- {STATUS[r.status].label}
229
- </span>
230
- <span
231
- className={`flex-1 truncate text-sm font-medium ${
232
- r.status === "unchanged" ? "text-gray-400" : "text-white"
233
- }`}
234
- >
235
- {r.name}
236
- </span>
237
- {/* The kind sits last and quiet: it labels the name rather than competing with it. */}
238
- <span className="shrink-0 text-xs text-gray-500">{KIND_LABEL[r.kind] ?? r.kind}</span>
239
- {/* The reason belongs ON the refused row: it is the only row it explains. */}
240
- {r.why && <span className="shrink-0 text-xs text-red-300">{r.why}</span>}
241
- </div>
242
- ))}
243
- </div>
244
- {/*
245
- Said once, in the place where its absence gets noticed. The Components tab
246
- lists the design system alongside this project's own, so a plan holding two
247
- components where the tab showed eighteen reads as a collection bug.
248
- */}
249
- <p className="pt-3 text-xs leading-relaxed text-gray-500">
250
- Only {project} publishes. Design system components, atoms and styles belong to
251
- the universe already.
252
- </p>
253
- {/*
254
- A COUNT ALONE IS USELESS. "2 warnings" tells a developer something is wrong
255
- and gives them no way to find it, which is worse than silence: it is a nag
256
- with no address. The file, line and message are what fixes it, so they are
257
- here, one click away rather than one grep away.
258
- */}
259
- {warnings > 0 && (
260
- <details className="pt-1">
261
- <summary className="cursor-pointer text-xs text-amber-300/80 hover:text-amber-300">
262
- {warnings} warning{warnings === 1 ? "" : "s"}, not blocking
263
- </summary>
264
- <ul className="mt-2 max-h-40 space-y-2 overflow-y-auto text-xs">
265
- {problems
266
- .filter((p) => p.level === "warn")
267
- .map((p, i) => (
268
- <li key={i} className="leading-relaxed">
269
- <span className="text-gray-500">
270
- {p.file?.replace(/^.*\/design\//, "")}
271
- {p.line ? `:${p.line}` : ""}
272
- </span>
273
- <br />
274
- <span className="text-gray-400">{p.msg}</span>
275
- </li>
276
- ))}
277
- </ul>
278
- </details>
279
- )}
280
- </div>
281
- )}
282
-
283
- {sent && (
284
- <p className={`mt-4 text-sm ${sent.failed.length ? "text-red-400" : "text-emerald-400"}`}>
285
- {sent.sent.length} published{sent.failed.length ? `, ${sent.failed.length} failed` : ""}
286
- </p>
287
- )}
288
-
289
- <div className="mt-6 flex items-center gap-2">
290
- {plan && !sent && toSend > 0 && (
291
- <button
292
- onClick={() => call("send")}
293
- disabled={busy}
294
- className="rounded bg-emerald-500 px-4 py-2 text-sm font-semibold text-emerald-950 hover:bg-emerald-400 disabled:opacity-30"
295
- >
296
- {busy ? "Publishing…" : `Publish ${toSend} item${toSend === 1 ? "" : "s"}`}
297
- </button>
298
- )}
299
- {plan && !sent && toSend === 0 && (
300
- <p className="text-sm text-emerald-400">{project} is already up to date</p>
301
- )}
302
- <button onClick={onClose} className="rounded px-3 py-2 text-sm text-gray-400 hover:text-gray-200">
303
- {sent ? "Done" : "Cancel"}
304
- </button>
305
- </div>
306
- </div>
307
- </div>
308
- );
309
- }
@@ -1,121 +0,0 @@
1
- /**
2
- * The signed-in session for ONE universe.
3
- *
4
- * Local Studio is offline and unauthenticated by default, and that must not change:
5
- * building a component needs no account, no network and no provider. Authentication
6
- * appears only when a developer connects to a universe in order to publish.
7
- *
8
- * So the provider is mounted CONDITIONALLY. With no universe selected, or one that has
9
- * auth switched off, children render exactly as they did before this file existed.
10
- *
11
- * THE CONFIG COMES FROM THE UNIVERSE, NOT FROM HERE. Canvas reads its issuer and client
12
- * id from `window.__GRAVITY_CONFIG__` injected into its own page; Studio runs on the
13
- * developer's machine and cannot, so it asks (`universes.ts` → discover). That is what
14
- * keeps the tooling provider-agnostic: nothing in Studio names Auth0.
15
- *
16
- * See docs/architecture/LOCAL_STUDIO.md.
17
- */
18
- import type { ReactNode } from "react";
19
- import { AuthProvider, AuthContext, type AuthContextProps } from "react-oidc-context";
20
- import { WebStorageStateStore } from "oidc-client-ts";
21
- import { loginReady, type Universe } from "./universes";
22
-
23
- /**
24
- * The OIDC settings for one universe. Mirrors Canvas's `oidcConfig`
25
- * (`apps/canvas/src/App.jsx`) so a token minted for Studio looks the same to the
26
- * platform as one minted for Canvas, with one difference: every value is read from the
27
- * universe rather than from this app's build.
28
- */
29
- function oidcConfigFor(universe: Universe) {
30
- const { issuer, clientId, audience } = universe.config;
31
- return {
32
- authority: issuer as string,
33
- client_id: clientId as string,
34
- // Studio's own origin. The universe's identity provider must list this as an allowed
35
- // callback, which is the one setup step a customer does on their side.
36
- redirect_uri: window.location.origin,
37
- scope: "openid profile email",
38
- // Auth0 and friends only mint an API access token when an audience is asked for.
39
- // Without it the login succeeds and the token is useless against the universe.
40
- ...(audience ? { extraQueryParams: { audience } } : {}),
41
- // sessionStorage, not localStorage: the token dies with the tab. Studio's standing
42
- // rule is that it never persists a secret (local/keys.ts), and a token is a secret.
43
- userStore: new WebStorageStateStore({ store: window.sessionStorage }),
44
- // Same reason Canvas disables it: a stored user carries the redirect_uri it was
45
- // minted with, and a silent renew against a different universe reuses the wrong one.
46
- automaticSilentRenew: false,
47
- onSigninCallback: () => {
48
- window.history.replaceState({}, document.title, window.location.pathname);
49
- },
50
- };
51
- }
52
-
53
- /**
54
- * A signed-out session, for when Studio is not connected to a universe.
55
- *
56
- * Cast because `AuthContextProps` also carries the oidc-client-ts settings and event
57
- * surface, none of which exists without a provider and none of which the screens touch.
58
- * The honest alternative is a fake settings object, which would be a bigger lie than this.
59
- */
60
- const OFFLINE_SESSION = {
61
- isAuthenticated: false,
62
- isLoading: false,
63
- user: undefined,
64
- error: undefined,
65
- activeNavigator: undefined,
66
- signinRedirect: offline("signinRedirect"),
67
- signinPopup: offline("signinPopup"),
68
- signinSilent: offline("signinSilent"),
69
- signoutRedirect: offline("signoutRedirect"),
70
- signoutPopup: offline("signoutPopup"),
71
- signoutSilent: offline("signoutSilent"),
72
- removeUser: offline("removeUser"),
73
- clearStaleState: offline("clearStaleState"),
74
- querySessionStatus: offline("querySessionStatus"),
75
- revokeTokens: offline("revokeTokens"),
76
- startSilentRenew: () => {},
77
- stopSilentRenew: () => {},
78
- } as unknown as AuthContextProps;
79
-
80
- /** Reject loudly: calling this means a caller forgot Studio has no universe connected. */
81
- function offline(name: string) {
82
- return async () => {
83
- throw new Error(`${name} needs a universe. Connect one first (Studio is offline).`);
84
- };
85
- }
86
-
87
- export function UniverseSession({
88
- universe,
89
- children,
90
- }: {
91
- universe: Universe | null;
92
- children: ReactNode;
93
- }) {
94
- // No universe, or one that cannot be logged into: the offline case, and the normal one.
95
- //
96
- // The shared screens call `useAuth()` unconditionally and cope with having no session.
97
- // But react-oidc-context warns whenever the context VALUE is falsy:
98
- //
99
- // const context = React.useContext(AuthContext);
100
- // if (!context) console.warn("AuthProvider context is undefined, ...");
101
- //
102
- // so mounting the provider with `undefined` warns exactly as loudly as mounting nothing
103
- // (both were tried). With TemplatesView re-rendering it is a stream of warnings about a
104
- // state that is fully supported.
105
- //
106
- // So hand it a real value meaning SIGNED OUT. Everything the screens read is present and
107
- // false; the sign-in actions throw rather than no-op, because reaching one from here
108
- // would mean a code path forgot there is no universe, and silence would hide that.
109
- if (!universe || !loginReady(universe.config).ok) {
110
- return <AuthContext.Provider value={OFFLINE_SESSION}>{children}</AuthContext.Provider>;
111
- }
112
-
113
- // `key` forces a fresh provider when the universe changes. Without it the library keeps
114
- // the previous authority and client id, and a developer switching universes would stay
115
- // signed in to the old one while appearing to be on the new.
116
- return (
117
- <AuthProvider key={universe.url} {...oidcConfigFor(universe)}>
118
- {children}
119
- </AuthProvider>
120
- );
121
- }