@1agh/maude 0.53.2 → 0.55.0
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.
- package/apps/studio/annotations-layer.tsx +11 -1
- package/apps/studio/api.ts +13 -7
- package/apps/studio/bin/screenshot.sh +43 -3
- package/apps/studio/build.ts +37 -3
- package/apps/studio/canvas-build-sandbox.ts +101 -9
- package/apps/studio/canvas-build-worker.ts +7 -1
- package/apps/studio/canvas-build.ts +9 -1
- package/apps/studio/client/app.jsx +226 -25
- package/apps/studio/client/export-center.jsx +10 -2
- package/apps/studio/client/github.js +7 -0
- package/apps/studio/client/panels/CloudBar.jsx +461 -40
- package/apps/studio/client/panels/SettingsPanel.jsx +6 -1
- package/apps/studio/client/styles/3-shell-maude.css +30 -0
- package/apps/studio/client/styles/4-components.css +40 -0
- package/apps/studio/cloud/endpoints.ts +48 -6
- package/apps/studio/cloud-build.ts +208 -0
- package/apps/studio/collab/room.ts +43 -7
- package/apps/studio/context.ts +12 -5
- package/apps/studio/dist/client.bundle.js +983 -983
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/git/repo-lock.ts +305 -0
- package/apps/studio/git/service.ts +171 -21
- package/apps/studio/http.ts +159 -13
- package/apps/studio/inspect.ts +66 -6
- package/apps/studio/server.ts +114 -17
- package/apps/studio/session-scope.ts +101 -0
- package/apps/studio/sync/autocommit.ts +84 -46
- package/apps/studio/sync/index.ts +20 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +61 -0
- package/apps/studio/test/canvas-shell-base.test.ts +107 -0
- package/apps/studio/test/cloud-build.test.ts +95 -0
- package/apps/studio/test/cloud-endpoints.test.ts +231 -4
- package/apps/studio/test/cloud-shell-surfaces.test.ts +31 -2
- package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
- package/apps/studio/test/git-system-engine.test.ts +135 -0
- package/apps/studio/test/read-only-gate.test.ts +8 -0
- package/apps/studio/test/repo-concurrency.test.ts +146 -0
- package/apps/studio/test/repo-lock.test.ts +350 -0
- package/apps/studio/test/server-lifecycle.test.ts +11 -1
- package/apps/studio/test/session-runtime-state.test.ts +226 -0
- package/apps/studio/test/session-scope.test.ts +100 -0
- package/apps/studio/test/shell-importmap.test.ts +8 -1
- package/apps/studio/test/sync-autocommit.test.ts +76 -0
- package/apps/studio/test/sync-hardening.test.ts +75 -0
- package/apps/studio/use-collab.tsx +21 -1
- package/apps/studio/whats-new.json +54 -0
- package/apps/studio/ws.ts +44 -8
- package/cli/commands/kg.mjs +49 -19
- package/cli/lib/gitignore-block.mjs +3 -0
- package/package.json +11 -9
- package/plugins/design/dependencies.json +1 -1
- package/plugins/design/templates/_shell.html +66 -32
- package/plugins/flow/.claude-plugin/config.schema.json +1 -1
- package/plugins/flow/dependencies.json +1 -1
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { useEffect, useRef, useState } from 'react';
|
|
16
16
|
|
|
17
|
-
import { invoke, isNativeApp, listen } from '../github.js';
|
|
17
|
+
import { invoke, isNativeApp, listen, openCloudUrl } from '../github.js';
|
|
18
18
|
|
|
19
19
|
const api = async (path, init) => {
|
|
20
20
|
const res = await fetch(path, init);
|
|
@@ -22,6 +22,87 @@ const api = async (path, init) => {
|
|
|
22
22
|
return { ok: res.ok, status: res.status, json };
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Reach the OS browser from whichever surface this is running on.
|
|
27
|
+
*
|
|
28
|
+
* In the desktop shell `window.open` does nothing at all — WKWebView drops it
|
|
29
|
+
* silently — which is what used to strand a person mid-sign-in holding a code
|
|
30
|
+
* and no page to type it into. The shell therefore goes through the Rust
|
|
31
|
+
* opener, which zone-locks the URL against the configured cloud address (the
|
|
32
|
+
* webview is untrusted, DDR-054: there is deliberately no general opener).
|
|
33
|
+
*
|
|
34
|
+
* Best-effort by design: an older shell has no such command, a self-hosted
|
|
35
|
+
* origin can drift, and the browser can block the popup — so every caller ALSO
|
|
36
|
+
* shows the address as something the person can click or copy by hand.
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* Is this an address we are willing to render as a link at all?
|
|
40
|
+
*
|
|
41
|
+
* The zone lock lives in Rust, but the dialog SHOWS the address and offers to
|
|
42
|
+
* copy it — so an off-zone `verification_url` (hostile/compromised control
|
|
43
|
+
* plane, MITM'd plaintext self-host) would be presented in trusted app chrome
|
|
44
|
+
* with a label inviting the person to open it by hand. Refusing to render it is
|
|
45
|
+
* the only place that can be stopped (attacker pass 2026-08-04, B4).
|
|
46
|
+
*/
|
|
47
|
+
export function isDisplayableUrl(url, cloudUrl) {
|
|
48
|
+
let u;
|
|
49
|
+
let base;
|
|
50
|
+
try {
|
|
51
|
+
u = new URL(String(url));
|
|
52
|
+
base = new URL(String(cloudUrl ?? DEFAULT_CLOUD_URL));
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
if (!/^https?:$/.test(u.protocol)) return false;
|
|
57
|
+
// Same two arms the Rust opener enforces. A scheme check alone was NOT enough:
|
|
58
|
+
// `https://evil.example/activate?code=…` from a hostile or MITM'd control plane
|
|
59
|
+
// passed it and still rendered as a live href with a Copy button — and a
|
|
60
|
+
// middle-click or "Open link" never reaches the click handler, let alone Rust
|
|
61
|
+
// (attacker re-review 2026-08-04, NEW-3).
|
|
62
|
+
if (u.origin === base.origin) return true;
|
|
63
|
+
const host = u.hostname;
|
|
64
|
+
return (
|
|
65
|
+
u.protocol === 'https:' &&
|
|
66
|
+
!u.port &&
|
|
67
|
+
(u.pathname === '' || u.pathname === '/') &&
|
|
68
|
+
!u.search &&
|
|
69
|
+
!u.hash &&
|
|
70
|
+
(host === DEFAULT_CLOUD_HOST || host.endsWith(`.${DEFAULT_CLOUD_HOST}`))
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Reach the OS browser. Resolves to true when the open was accepted.
|
|
76
|
+
*
|
|
77
|
+
* A refusal must NOT be swallowed: the Rust side refusing is precisely the
|
|
78
|
+
* security event, and a silently dead click under a label reading "if your
|
|
79
|
+
* browser didn't open, go to:" teaches the person to copy the address into a
|
|
80
|
+
* browser the lock cannot see (B4). Callers surface the failure instead.
|
|
81
|
+
*/
|
|
82
|
+
function openExternal(url) {
|
|
83
|
+
if (!url) return Promise.resolve('failed');
|
|
84
|
+
if (isNativeApp()) {
|
|
85
|
+
return openCloudUrl(url).then(
|
|
86
|
+
() => 'ok',
|
|
87
|
+
// A REFUSAL is the security event and reads differently from "no browser
|
|
88
|
+
// would start" — collapsing both into one boolean lost the distinction the
|
|
89
|
+
// dialog needs to say something true.
|
|
90
|
+
(err) => (/Refusing to open/i.test(String(err)) ? 'refused' : 'failed')
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
// `window.open(..., 'noopener')` returns null even on SUCCESS (that is the
|
|
94
|
+
// whole point of noopener), so its result says nothing about whether a tab
|
|
95
|
+
// opened. Treating null as failure made the browser build print "Maude
|
|
96
|
+
// couldn't open your browser" on every single sign-in — training people that
|
|
97
|
+
// the one warning this dialog has is noise (attacker re-review, NEW-2).
|
|
98
|
+
try {
|
|
99
|
+
window.open(url, '_blank', 'noopener');
|
|
100
|
+
return Promise.resolve('ok');
|
|
101
|
+
} catch {
|
|
102
|
+
return Promise.resolve('failed');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
25
106
|
/**
|
|
26
107
|
* Parse a maude://open/<project>?code=mhc_… deep link. Untrusted input — a
|
|
27
108
|
* drive-by page can navigate to maude:// — so nothing here is acted on
|
|
@@ -31,21 +112,141 @@ const api = async (path, init) => {
|
|
|
31
112
|
export function parseDeepLink(url) {
|
|
32
113
|
const m = /^maude:\/\/open\/([a-z0-9]+(?:-[a-z0-9]+)*)\/?\?(.*)$/.exec(String(url ?? ''));
|
|
33
114
|
if (!m) return null;
|
|
115
|
+
// The platform caps a project id at 40 chars (deriveProjectId), but that bound
|
|
116
|
+
// lives server-side and a drive-by maude:// link never passes through it — so
|
|
117
|
+
// without this, arbitrary attacker text renders in the dialog's heading, prose
|
|
118
|
+
// and aria-label. Nothing longer can name a real project anyway.
|
|
119
|
+
if (m[1].length > 40) return null;
|
|
34
120
|
const code = new URLSearchParams(m[2]).get('code') ?? '';
|
|
35
121
|
if (!/^mhc_[0-9a-f]{16,128}$/.test(code)) return null;
|
|
36
122
|
return { project: m[1], code };
|
|
37
123
|
}
|
|
38
124
|
|
|
39
|
-
/**
|
|
125
|
+
/**
|
|
126
|
+
* A viewer's browser home for a project — the project's OWN address.
|
|
127
|
+
*
|
|
128
|
+
* It used to derive `view-<host>`, which no longer exists: the read-only gallery
|
|
129
|
+
* was deleted in Cloud Phase 25 C5, and `tenantFromHostname` now hard-refuses any
|
|
130
|
+
* `view-` label (deliberately, so a leftover custom domain can't spin up a cell
|
|
131
|
+
* literally named `view-alligators`). So this returned a guaranteed 404 — which
|
|
132
|
+
* was invisible while `window.open` was a no-op in the desktop shell, and would
|
|
133
|
+
* have become a visibly dead menu item the moment the opener started working
|
|
134
|
+
* (attacker re-review 2026-08-04). The browser door replaced the gallery: the
|
|
135
|
+
* real project, for people who actually have access.
|
|
136
|
+
*/
|
|
40
137
|
export function shareViewUrl(projectUrl, projectId) {
|
|
41
138
|
try {
|
|
42
139
|
const u = new URL(projectUrl);
|
|
43
|
-
return `${u.protocol}
|
|
140
|
+
return `${u.protocol}//${u.host}`;
|
|
44
141
|
} catch {
|
|
45
|
-
return `https
|
|
142
|
+
return `https://${projectId}.${DEFAULT_CLOUD_HOST}`;
|
|
46
143
|
}
|
|
47
144
|
}
|
|
48
145
|
|
|
146
|
+
/** Where Maude Cloud lives by default — mirrors the Rust opener's compiled host. */
|
|
147
|
+
const DEFAULT_CLOUD_URL = 'https://cloud.maude.sh';
|
|
148
|
+
const DEFAULT_CLOUD_HOST = 'cloud.maude.sh';
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Fold a name the way the PLATFORM does, so a folder and a project id can be
|
|
152
|
+
* compared at all.
|
|
153
|
+
*
|
|
154
|
+
* This mirrors `deriveProjectId` (apps/cloud/checkout.mjs) deliberately, NFKD
|
|
155
|
+
* strip and 40-char cap included. A plain kebab-case fold looked equivalent and
|
|
156
|
+
* wasn't: `Zkušební tým` derives the id `zkusebni-tym` on the platform but folded
|
|
157
|
+
* to `zku-ebn-t-m` here, so every accented project name failed to match its own
|
|
158
|
+
* workspace and warned on a perfectly legitimate connect — and any name over 40
|
|
159
|
+
* characters did the same, because the platform truncates and this didn't. A
|
|
160
|
+
* warning that fires on correct behaviour is a warning people learn to dismiss.
|
|
161
|
+
*/
|
|
162
|
+
const norm = (s) =>
|
|
163
|
+
String(s ?? '')
|
|
164
|
+
.normalize('NFKD')
|
|
165
|
+
.replace(/[̀-ͯ]/g, '')
|
|
166
|
+
.toLowerCase()
|
|
167
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
168
|
+
.replace(/^-+|-+$/g, '')
|
|
169
|
+
.replace(/-{2,}/g, '-')
|
|
170
|
+
.slice(0, 40)
|
|
171
|
+
.replace(/-+$/, '');
|
|
172
|
+
|
|
173
|
+
/** The host an address carries, or '' if it isn't a readable URL. */
|
|
174
|
+
const hostOf = (url) => {
|
|
175
|
+
try {
|
|
176
|
+
return new URL(url).hostname;
|
|
177
|
+
} catch {
|
|
178
|
+
return '';
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/** The project label a cell address carries, or '' if it carries none. */
|
|
183
|
+
function projectFromHubUrl(hubUrl, cloudHost) {
|
|
184
|
+
const host = hostOf(hubUrl);
|
|
185
|
+
if (!cloudHost || host === cloudHost) return '';
|
|
186
|
+
// `<project>.cloud.maude.sh` — only meaningful INSIDE the configured zone.
|
|
187
|
+
if (!host.endsWith(`.${cloudHost}`)) return '';
|
|
188
|
+
return norm(host.slice(0, -(cloudHost.length + 1)).replace(/^view-/, ''));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Does this folder look like the cloud project a link is naming?
|
|
193
|
+
*
|
|
194
|
+
* A hint about a decision the person makes — never an authorization gate. It
|
|
195
|
+
* exists because the confusing half of "Open in Maude" is that it attaches
|
|
196
|
+
* whatever folder happens to be open, so somebody with one project open who
|
|
197
|
+
* presses it on another is one click from syncing the wrong designs into the
|
|
198
|
+
* wrong workspace.
|
|
199
|
+
*
|
|
200
|
+
* WHAT THIS FUNCTION LEARNED THE HARD WAY (attacker pass 2026-08-04, B1): the
|
|
201
|
+
* first version treated a near-match — either name containing the other — as
|
|
202
|
+
* agreement, to avoid crying wolf over `alligators-web` vs `alligators`. That
|
|
203
|
+
* reasoning assumed a NON-ADVERSARIAL name collision. It isn't one: the name
|
|
204
|
+
* being compared against is a cloud project id, which anyone can register. An
|
|
205
|
+
* attacker registers `alligators-design-sync`, it contains `alligators`, and the
|
|
206
|
+
* hint falls silent on exactly the case it exists for. The server's 409 does not
|
|
207
|
+
* stand behind it either, because the claim and the actual project agree — it
|
|
208
|
+
* genuinely IS the attacker's project.
|
|
209
|
+
*
|
|
210
|
+
* So a near-match no longer resolves to "fine". It gets its OWN state, and says
|
|
211
|
+
* out loud that two similar names are still two different workspaces. Only an
|
|
212
|
+
* exact match (or a credentialed link) is silent.
|
|
213
|
+
*
|
|
214
|
+
* States: 'linked' (a real, credentialed link to this project) · 'match' (the
|
|
215
|
+
* names are the same) · 'similar' (alike but NOT the same — warn, softly) ·
|
|
216
|
+
* 'mismatch' (unrelated, or linked elsewhere — warn) · 'unknown' (no signal:
|
|
217
|
+
* status hasn't resolved, or the addresses carry no project name, as on every
|
|
218
|
+
* self-host and e2e stub at `http://127.0.0.1:<port>`). 'unknown' warns too —
|
|
219
|
+
* quietly, because failing open here is what B3 was.
|
|
220
|
+
*/
|
|
221
|
+
export function localIdentityHint({
|
|
222
|
+
localProject,
|
|
223
|
+
linkedHubUrl,
|
|
224
|
+
linkedHubCredentialed,
|
|
225
|
+
cloudUrl,
|
|
226
|
+
claimed,
|
|
227
|
+
resolved = true,
|
|
228
|
+
}) {
|
|
229
|
+
if (!resolved) return 'unknown';
|
|
230
|
+
const want = norm(claimed);
|
|
231
|
+
if (!want) return 'unknown';
|
|
232
|
+
|
|
233
|
+
// A linkedHub only reassures when a stored credential corroborates it — the
|
|
234
|
+
// config file it comes from is committed and travels with the repo (B2).
|
|
235
|
+
const linkedProject = linkedHubUrl ? projectFromHubUrl(linkedHubUrl, hostOf(cloudUrl)) : '';
|
|
236
|
+
if (linkedProject && linkedHubCredentialed) {
|
|
237
|
+
return linkedProject === want ? 'linked' : 'mismatch';
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Otherwise the folder's own name is the signal — the one that catches the
|
|
241
|
+
// reported case (one project open, "Open in Maude" pressed on another).
|
|
242
|
+
const local = norm(localProject);
|
|
243
|
+
if (!local) return 'unknown';
|
|
244
|
+
if (local === want) return 'match';
|
|
245
|
+
const shorter = local.length <= want.length ? local : want;
|
|
246
|
+
const related = shorter.length >= 4 && (local.includes(want) || want.includes(local));
|
|
247
|
+
return related ? 'similar' : 'mismatch';
|
|
248
|
+
}
|
|
249
|
+
|
|
49
250
|
function Spark({ size = 15 }) {
|
|
50
251
|
return (
|
|
51
252
|
<svg width={size} height={size} viewBox="0 0 32 32" fill="currentColor" aria-hidden="true">
|
|
@@ -70,6 +271,14 @@ function Icon({ name, size = 15 }) {
|
|
|
70
271
|
<path d="M3 10.5V3.2A.7.7 0 0 1 3.7 2.5H10" />
|
|
71
272
|
</>
|
|
72
273
|
),
|
|
274
|
+
// Direction, not departure — the decision modal's two sides are joined by
|
|
275
|
+
// "flows into", and the `external` glyph read as "opens in the browser".
|
|
276
|
+
'arrow-right': (
|
|
277
|
+
<>
|
|
278
|
+
<line x1="2.5" y1="8" x2="12.5" y2="8" />
|
|
279
|
+
<polyline points="9 4.5 12.5 8 9 11.5" />
|
|
280
|
+
</>
|
|
281
|
+
),
|
|
73
282
|
link: (
|
|
74
283
|
<>
|
|
75
284
|
<path d="M6.5 9.5l3-3" />
|
|
@@ -102,17 +311,42 @@ export default function CloudBar() {
|
|
|
102
311
|
const [note, setNote] = useState('');
|
|
103
312
|
const [error, setError] = useState('');
|
|
104
313
|
const [copied, setCopied] = useState(false);
|
|
314
|
+
const [copiedLink, setCopiedLink] = useState(false);
|
|
315
|
+
const [openFailed, setOpenFailed] = useState(''); // '' | 'refused' | 'failed'
|
|
316
|
+
const [cloudUrl, setCloudUrl] = useState('https://cloud.maude.sh');
|
|
317
|
+
// `resolved` gates the deep-link dialog: rendering the decision before the
|
|
318
|
+
// data it turns on made the hint fail OPEN on the launch path (B3).
|
|
319
|
+
const [local, setLocal] = useState({ project: '', linkedHub: null, resolved: false });
|
|
105
320
|
const [pending, setPending] = useState(null); // { project, code } from maude://
|
|
106
321
|
const railRef = useRef(null);
|
|
107
322
|
const pollRef = useRef(null);
|
|
108
323
|
|
|
109
324
|
useEffect(() => {
|
|
110
|
-
api('/_api/cloud/status')
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
325
|
+
api('/_api/cloud/status')
|
|
326
|
+
.then((r) => {
|
|
327
|
+
if (r.json?.url) setCloudUrl(r.json.url);
|
|
328
|
+
// Resolved even on an error body — what must never happen is staying
|
|
329
|
+
// un-resolved forever and leaving the dialog unreachable; an empty
|
|
330
|
+
// project name resolves to 'unknown', which warns rather than reassures.
|
|
331
|
+
setLocal({
|
|
332
|
+
project: r.json?.project ?? '',
|
|
333
|
+
linkedHub: r.json?.linkedHub ?? null,
|
|
334
|
+
resolved: true,
|
|
335
|
+
});
|
|
336
|
+
if (r.ok && r.json?.connected) {
|
|
337
|
+
setEmail(r.json.email);
|
|
338
|
+
setState('in');
|
|
339
|
+
} else setState('out');
|
|
340
|
+
})
|
|
341
|
+
.catch(() => {
|
|
342
|
+
// A rejected fetch (the sidecar restarting under a launch-path deep link)
|
|
343
|
+
// would otherwise leave `resolved` false forever — and since the dialog
|
|
344
|
+
// is gated on it, the parked link would never be shown at all, silently
|
|
345
|
+
// burning a one-shot two-minute code. Resolve into the 'unknown' branch,
|
|
346
|
+
// which warns, and let the rail fall back to signed-out.
|
|
347
|
+
setLocal((l) => ({ ...l, resolved: true }));
|
|
348
|
+
setState('out');
|
|
349
|
+
});
|
|
116
350
|
return () => clearInterval(pollRef.current);
|
|
117
351
|
}, []);
|
|
118
352
|
|
|
@@ -124,7 +358,7 @@ export default function CloudBar() {
|
|
|
124
358
|
const consume = (url) => {
|
|
125
359
|
const parsed = parseDeepLink(url);
|
|
126
360
|
if (!parsed) return;
|
|
127
|
-
// NEVER replace a link the person is already looking at. The
|
|
361
|
+
// NEVER replace a link the person is already looking at. The dialog is a
|
|
128
362
|
// single slot, so an overwrite would swap the code out from under a
|
|
129
363
|
// click that was aimed at the previous one — the person confirms the
|
|
130
364
|
// name they read and connects the link that arrived last (validate
|
|
@@ -163,6 +397,18 @@ export default function CloudBar() {
|
|
|
163
397
|
}
|
|
164
398
|
}
|
|
165
399
|
|
|
400
|
+
// Escape answers the decision modal the same way the scrim does: Not now.
|
|
401
|
+
// Declining must always be the cheap move — the dashboard re-mints a code on
|
|
402
|
+
// the next "Open in Maude".
|
|
403
|
+
useEffect(() => {
|
|
404
|
+
if (!pending) return undefined;
|
|
405
|
+
const onKey = (e) => {
|
|
406
|
+
if (e.key === 'Escape') setPending(null);
|
|
407
|
+
};
|
|
408
|
+
document.addEventListener('keydown', onKey);
|
|
409
|
+
return () => document.removeEventListener('keydown', onKey);
|
|
410
|
+
}, [pending]);
|
|
411
|
+
|
|
166
412
|
useEffect(() => {
|
|
167
413
|
if (!menuOpen) return undefined;
|
|
168
414
|
const onDoc = (e) => {
|
|
@@ -180,7 +426,8 @@ export default function CloudBar() {
|
|
|
180
426
|
return;
|
|
181
427
|
}
|
|
182
428
|
setDevice(r.json);
|
|
183
|
-
|
|
429
|
+
setOpenFailed('');
|
|
430
|
+
openExternal(r.json.verificationUrl).then((s) => setOpenFailed(s === 'ok' ? '' : s));
|
|
184
431
|
pollRef.current = setInterval(async () => {
|
|
185
432
|
const p = await api('/_api/cloud/signin/poll', {
|
|
186
433
|
method: 'POST',
|
|
@@ -245,41 +492,158 @@ export default function CloudBar() {
|
|
|
245
492
|
setState('out');
|
|
246
493
|
}
|
|
247
494
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
495
|
+
// One clipboard write, one 1.5 s "Copied" flash. The code and the link are the
|
|
496
|
+
// same gesture, so they share the mechanism — and both swallow a refusal
|
|
497
|
+
// (no clipboard permission) silently: the value is on screen either way.
|
|
498
|
+
function copyToClipboard(text, mark) {
|
|
499
|
+
if (!text) return;
|
|
500
|
+
navigator.clipboard?.writeText(text).then(
|
|
251
501
|
() => {
|
|
252
|
-
|
|
253
|
-
setTimeout(() =>
|
|
502
|
+
mark(true);
|
|
503
|
+
setTimeout(() => mark(false), 1500);
|
|
254
504
|
},
|
|
255
505
|
() => {}
|
|
256
506
|
);
|
|
257
507
|
}
|
|
258
508
|
|
|
509
|
+
const copyCode = () => copyToClipboard(device?.userCode, setCopied);
|
|
510
|
+
const copyLink = () => copyToClipboard(device?.verificationUrl, setCopiedLink);
|
|
511
|
+
|
|
259
512
|
if (state === 'loading') return null;
|
|
260
513
|
|
|
514
|
+
const hint = pending
|
|
515
|
+
? localIdentityHint({
|
|
516
|
+
localProject: local.project,
|
|
517
|
+
linkedHubUrl: local.linkedHub?.url,
|
|
518
|
+
linkedHubCredentialed: local.linkedHub?.credentialed,
|
|
519
|
+
cloudUrl,
|
|
520
|
+
claimed: pending.project,
|
|
521
|
+
resolved: local.resolved,
|
|
522
|
+
})
|
|
523
|
+
: 'unknown';
|
|
524
|
+
// Anything but a clean, corroborated agreement gets the warning treatment and
|
|
525
|
+
// a demoted Connect. Silence is reserved for the two cases we can vouch for.
|
|
526
|
+
const reassured = hint === 'linked' || hint === 'match';
|
|
527
|
+
|
|
261
528
|
return (
|
|
262
529
|
<div className="gi-rail cb-rail" ref={railRef} data-testid="cloud-bar">
|
|
263
|
-
{
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
<
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
530
|
+
{/* Not until `local` has resolved: the launch-path deep link can beat the
|
|
531
|
+
status fetch, and a modal rendered without the local half showed no
|
|
532
|
+
warning and a primary Connect — failing OPEN in the one scenario with
|
|
533
|
+
the least context (attacker pass B3). */}
|
|
534
|
+
{pending && local.resolved && (
|
|
535
|
+
<div
|
|
536
|
+
className="gi-modal"
|
|
537
|
+
role="dialog"
|
|
538
|
+
aria-modal="true"
|
|
539
|
+
aria-label={`Connect this folder to ${pending.project}`}
|
|
540
|
+
>
|
|
541
|
+
<div className="gi-scrim" aria-hidden="true" onClick={() => setPending(null)} />
|
|
542
|
+
<div className="gi-dialog gi-dialog--code" data-testid="cloud-deeplink-dialog">
|
|
543
|
+
<div className="gi-dc-head">
|
|
544
|
+
<span className="gi-dc-marks"><Icon name="link" size={24} /></span>
|
|
545
|
+
<h2>Connect to {pending.project}?</h2>
|
|
546
|
+
</div>
|
|
547
|
+
{/* Both sides, named. The old strip said "Connect this project to X?"
|
|
548
|
+
— which never told anyone WHICH project "this" was, the one thing
|
|
549
|
+
the decision turns on. */}
|
|
550
|
+
<div className="gi-dl-pair">
|
|
551
|
+
<span className="gi-dl-side">
|
|
552
|
+
<span className="gi-dl-lbl">This folder</span>
|
|
553
|
+
<span className="gi-dl-val" data-testid="cloud-deeplink-local">{local.project || 'the open project'}</span>
|
|
554
|
+
</span>
|
|
555
|
+
<span className="gi-dl-arrow" aria-hidden="true"><Icon name="arrow-right" size={16} /></span>
|
|
556
|
+
<span className="gi-dl-side">
|
|
557
|
+
<span className="gi-dl-lbl">Cloud project</span>
|
|
558
|
+
<span className="gi-dl-val">{pending.project}</span>
|
|
559
|
+
</span>
|
|
560
|
+
</div>
|
|
561
|
+
<p className="gi-dl-what">
|
|
562
|
+
This folder’s <code>.design/</code> canvases will sync with the {pending.project}{' '}
|
|
563
|
+
workspace. Nothing else in this repo is uploaded.
|
|
564
|
+
</p>
|
|
565
|
+
{hint === 'linked' && (
|
|
566
|
+
<p className="gi-dl-note">This folder is already linked to {pending.project} — connecting refreshes the link.</p>
|
|
567
|
+
)}
|
|
568
|
+
{!reassured && (
|
|
569
|
+
<div className="gi-dl-warn" data-testid="cloud-deeplink-mismatch">
|
|
570
|
+
<strong>
|
|
571
|
+
{hint === 'similar' ? (
|
|
572
|
+
<>
|
|
573
|
+
{local.project} and {pending.project} are different workspaces.
|
|
574
|
+
</>
|
|
575
|
+
) : hint === 'unknown' ? (
|
|
576
|
+
<>Maude can’t tell which project this folder is.</>
|
|
577
|
+
) : (
|
|
578
|
+
<>
|
|
579
|
+
This folder looks like {local.project || 'something else'}, not{' '}
|
|
580
|
+
{pending.project}.
|
|
581
|
+
</>
|
|
582
|
+
)}
|
|
583
|
+
</strong>
|
|
584
|
+
<span>
|
|
585
|
+
Connecting will sync <em>this folder’s</em> designs into {pending.project}. If you
|
|
586
|
+
meant to work on {pending.project}, open its folder in Maude and press “Open in
|
|
587
|
+
Maude” again. Don’t have it on this machine? It opens in{' '}
|
|
588
|
+
{isDisplayableUrl(cloudUrl, cloudUrl) ? (
|
|
589
|
+
<a
|
|
590
|
+
className="gi-dc-link-a"
|
|
591
|
+
href={cloudUrl}
|
|
592
|
+
rel="noopener noreferrer"
|
|
593
|
+
target="_blank"
|
|
594
|
+
onClick={(e) => {
|
|
595
|
+
e.preventDefault();
|
|
596
|
+
openExternal(cloudUrl);
|
|
597
|
+
}}
|
|
598
|
+
>
|
|
599
|
+
your browser
|
|
600
|
+
</a>
|
|
601
|
+
) : (
|
|
602
|
+
'your browser'
|
|
603
|
+
)}
|
|
604
|
+
.
|
|
605
|
+
</span>
|
|
606
|
+
</div>
|
|
607
|
+
)}
|
|
608
|
+
<div className="gi-dc-foot">
|
|
609
|
+
{/* On a mismatch the safe action leads and connecting is the one you
|
|
610
|
+
have to mean. No project switcher lives here on purpose: the
|
|
611
|
+
handoff code is one-shot with a two-minute life, and a switch
|
|
612
|
+
restarts the server — the person would come back to a dead code
|
|
613
|
+
and a question they already answered. */}
|
|
614
|
+
{!reassured ? (
|
|
615
|
+
<>
|
|
616
|
+
<button type="button" className="btn" onClick={() => setPending(null)} data-testid="cloud-deeplink-dismiss">
|
|
617
|
+
Not now
|
|
618
|
+
</button>
|
|
619
|
+
<button
|
|
620
|
+
type="button"
|
|
621
|
+
className="btn btn--ghost"
|
|
622
|
+
disabled={busy === 'deeplink'}
|
|
623
|
+
onClick={connectPending}
|
|
624
|
+
data-testid="cloud-deeplink-connect"
|
|
625
|
+
>
|
|
626
|
+
{busy === 'deeplink' ? 'Connecting…' : 'Connect anyway'}
|
|
627
|
+
</button>
|
|
628
|
+
</>
|
|
629
|
+
) : (
|
|
630
|
+
<>
|
|
631
|
+
<button
|
|
632
|
+
type="button"
|
|
633
|
+
className="btn"
|
|
634
|
+
disabled={busy === 'deeplink'}
|
|
635
|
+
onClick={connectPending}
|
|
636
|
+
data-testid="cloud-deeplink-connect"
|
|
637
|
+
>
|
|
638
|
+
{busy === 'deeplink' ? 'Connecting…' : 'Connect'}
|
|
639
|
+
</button>
|
|
640
|
+
<button type="button" className="btn btn--ghost" onClick={() => setPending(null)} data-testid="cloud-deeplink-dismiss">
|
|
641
|
+
Not now
|
|
642
|
+
</button>
|
|
643
|
+
</>
|
|
644
|
+
)}
|
|
645
|
+
</div>
|
|
646
|
+
</div>
|
|
283
647
|
</div>
|
|
284
648
|
)}
|
|
285
649
|
{state === 'out' && (
|
|
@@ -332,7 +696,7 @@ export default function CloudBar() {
|
|
|
332
696
|
type="button"
|
|
333
697
|
className="gi-menu-item"
|
|
334
698
|
role="menuitem"
|
|
335
|
-
onClick={() =>
|
|
699
|
+
onClick={() => openExternal(shareViewUrl(p.url, p.id))}
|
|
336
700
|
title="Viewing works in the browser. Editing in the app needs the member role — ask whoever runs the project."
|
|
337
701
|
data-testid={`cloud-project-${p.id}`}
|
|
338
702
|
>
|
|
@@ -356,7 +720,7 @@ export default function CloudBar() {
|
|
|
356
720
|
)
|
|
357
721
|
)}
|
|
358
722
|
<div className="gi-menu-sep" />
|
|
359
|
-
<button type="button" className="gi-menu-item" role="menuitem" onClick={() =>
|
|
723
|
+
<button type="button" className="gi-menu-item" role="menuitem" onClick={() => openExternal(cloudUrl)}>
|
|
360
724
|
<Icon name="external" size={15} /> Open the dashboard
|
|
361
725
|
</button>
|
|
362
726
|
<button type="button" className="gi-menu-item gi-menu-item--danger" role="menuitem" onClick={signOut}>
|
|
@@ -374,7 +738,11 @@ export default function CloudBar() {
|
|
|
374
738
|
<div className="gi-dc-head">
|
|
375
739
|
<span className="gi-dc-marks"><Spark size={26} /></span>
|
|
376
740
|
<h2>Sign in to Maude Cloud</h2>
|
|
377
|
-
|
|
741
|
+
{/* Says what to DO, not what supposedly already happened. The old
|
|
742
|
+
copy claimed the browser had opened — a claim this surface
|
|
743
|
+
cannot make, since the shell's open is best-effort and the
|
|
744
|
+
person is the one who finds out it didn't. */}
|
|
745
|
+
<p>Confirm this code on your Maude Cloud dashboard to connect.</p>
|
|
378
746
|
</div>
|
|
379
747
|
<div className="gi-code">
|
|
380
748
|
<span className="gi-code-val" data-testid="cloud-user-code">{device.userCode}</span>
|
|
@@ -382,6 +750,59 @@ export default function CloudBar() {
|
|
|
382
750
|
<Icon name="copy" size={15} /> {copied ? 'Copied' : 'Copy'}
|
|
383
751
|
</button>
|
|
384
752
|
</div>
|
|
753
|
+
{/* The manual path, always present — never conditional on whether the
|
|
754
|
+
open "worked". It is the only thing standing between an older
|
|
755
|
+
shell / a drifted self-host origin / a blocked popup and a dead
|
|
756
|
+
end. The address already carries the code (`/activate?code=…`),
|
|
757
|
+
so following it is one click, not a re-type. */}
|
|
758
|
+
{device.verificationUrl && isDisplayableUrl(device.verificationUrl, cloudUrl) && (
|
|
759
|
+
<div className="gi-dc-link">
|
|
760
|
+
<span className="gi-dc-link-lbl">
|
|
761
|
+
{openFailed === 'failed'
|
|
762
|
+
? 'Maude couldn’t open your browser. Open this address yourself:'
|
|
763
|
+
: 'If your browser didn’t open, go to:'}
|
|
764
|
+
</span>
|
|
765
|
+
<div className="gi-dc-link-row">
|
|
766
|
+
<a
|
|
767
|
+
className="gi-dc-url gi-dc-link-a"
|
|
768
|
+
href={device.verificationUrl}
|
|
769
|
+
rel="noopener noreferrer"
|
|
770
|
+
target="_blank"
|
|
771
|
+
data-testid="cloud-signin-link"
|
|
772
|
+
onClick={(e) => {
|
|
773
|
+
e.preventDefault();
|
|
774
|
+
openExternal(device.verificationUrl).then((s) =>
|
|
775
|
+
setOpenFailed(s === 'ok' ? '' : s)
|
|
776
|
+
);
|
|
777
|
+
}}
|
|
778
|
+
>
|
|
779
|
+
{device.verificationUrl}
|
|
780
|
+
</a>
|
|
781
|
+
<button type="button" className="btn btn--ghost btn--sm" onClick={copyLink} aria-label="Copy the link">
|
|
782
|
+
<Icon name="copy" size={14} /> {copiedLink ? 'Copied' : 'Copy link'}
|
|
783
|
+
</button>
|
|
784
|
+
</div>
|
|
785
|
+
</div>
|
|
786
|
+
)}
|
|
787
|
+
{openFailed === 'refused' && (
|
|
788
|
+
// A REFUSAL is not "your browser didn't open" — it is Maude saying
|
|
789
|
+
// that address isn't Maude Cloud. Saying so, and NOT offering to
|
|
790
|
+
// help open it, is the whole difference (attacker re-review NEW-2).
|
|
791
|
+
<div className="gi-dl-warn" data-testid="cloud-signin-refused">
|
|
792
|
+
<strong>Maude wouldn’t open that address.</strong>
|
|
793
|
+
<span>It isn’t a Maude Cloud address. Sign in on your dashboard instead.</span>
|
|
794
|
+
</div>
|
|
795
|
+
)}
|
|
796
|
+
{device.verificationUrl && !isDisplayableUrl(device.verificationUrl, cloudUrl) && (
|
|
797
|
+
// An address we won't put behind a link or a copy button. Naming it
|
|
798
|
+
// is the point — silently dropping it would look like a bug.
|
|
799
|
+
<div className="gi-dl-warn" data-testid="cloud-signin-badurl">
|
|
800
|
+
<strong>Maude Cloud sent an address that doesn’t look right.</strong>
|
|
801
|
+
<span>
|
|
802
|
+
Nothing was opened. Sign in on your dashboard directly instead of following it.
|
|
803
|
+
</span>
|
|
804
|
+
</div>
|
|
805
|
+
)}
|
|
385
806
|
<div className="gi-dc-status" aria-live="polite">
|
|
386
807
|
<span className="gi-pulse" aria-hidden="true" />
|
|
387
808
|
<span>Waiting for you to confirm in the browser…</span>
|
|
@@ -802,7 +802,12 @@ function AppearanceTab({ theme, onSetTheme, cpMode, onSetCpMode }) {
|
|
|
802
802
|
|
|
803
803
|
export default function SettingsPanel({
|
|
804
804
|
/** `{ dashboardUrl, projectName }` when this is a cloud tab, else null. */
|
|
805
|
-
|
|
805
|
+
// Tri-state (see the note where this value is created): `undefined` until
|
|
806
|
+
// the server config answers, `null` for the desktop, an object for a cloud
|
|
807
|
+
// tab. Defaulting it to `null` here is what re-broke the boot 404 after the
|
|
808
|
+
// call sites were fixed — a default fires on `undefined`, so "not known yet"
|
|
809
|
+
// became "not cloud" one layer further in, invisibly.
|
|
810
|
+
cloud,
|
|
806
811
|
onClose,
|
|
807
812
|
initialTab,
|
|
808
813
|
theme,
|