sh3-core 0.19.6 → 0.20.1

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.
Files changed (66) hide show
  1. package/dist/app/admin/AuthSettingsView.svelte +3 -9
  2. package/dist/app/admin/MountsView.svelte +276 -0
  3. package/dist/app/admin/MountsView.svelte.d.ts +3 -0
  4. package/dist/app/admin/SystemView.svelte +6 -6
  5. package/dist/app/admin/UsersView.svelte +103 -7
  6. package/dist/app/admin/adminApp.js +1 -0
  7. package/dist/app/admin/adminShard.svelte.js +10 -0
  8. package/dist/apps/lifecycle.js +1 -0
  9. package/dist/apps/types.d.ts +7 -0
  10. package/dist/assets/iconIds.generated.d.ts +1 -1
  11. package/dist/assets/iconIds.generated.js +1 -0
  12. package/dist/assets/icons.svg +5 -0
  13. package/dist/auth/admin-users.svelte.js +2 -1
  14. package/dist/auth/auth.svelte.d.ts +4 -5
  15. package/dist/auth/auth.svelte.js +5 -6
  16. package/dist/auth/types.d.ts +0 -2
  17. package/dist/chrome/CompactChrome.svelte +25 -6
  18. package/dist/chrome/FloatsSheet.svelte +7 -32
  19. package/dist/chrome/FloatsSheet.svelte.d.ts +1 -2
  20. package/dist/chrome/FloatsSheet.svelte.test.js +8 -14
  21. package/dist/chrome/MenuSheet.svelte +154 -148
  22. package/dist/chrome/MenuSheet.svelte.d.ts +1 -2
  23. package/dist/chrome/MenuSheet.svelte.test.js +24 -12
  24. package/dist/createShell.js +32 -21
  25. package/dist/createShell.remoteAuth.test.js +9 -3
  26. package/dist/documents/browse.d.ts +18 -1
  27. package/dist/documents/browse.js +40 -7
  28. package/dist/documents/browse.test.js +35 -35
  29. package/dist/documents/config.d.ts +4 -0
  30. package/dist/documents/config.js +15 -2
  31. package/dist/documents/handle.js +25 -17
  32. package/dist/documents/http-backend.js +10 -2
  33. package/dist/documents/index.d.ts +2 -2
  34. package/dist/documents/index.js +1 -1
  35. package/dist/documents/picker-api.d.ts +4 -2
  36. package/dist/documents/picker-api.test.d.ts +1 -1
  37. package/dist/documents/picker-api.test.js +87 -57
  38. package/dist/documents/picker-primitive.d.ts +4 -0
  39. package/dist/documents/picker-primitive.js +27 -29
  40. package/dist/documents/types.d.ts +17 -5
  41. package/dist/documents/types.js +2 -0
  42. package/dist/layout/presets.test.js +4 -4
  43. package/dist/layout/types.d.ts +1 -1
  44. package/dist/layouts-shard/LayoutsSection.svelte +3 -16
  45. package/dist/primitives/widgets/DocumentFilePicker.svelte +4 -4
  46. package/dist/primitives/widgets/PickerList.svelte +1 -0
  47. package/dist/primitives/widgets/_DocumentBrowser.svelte +5 -8
  48. package/dist/projects-shard/DeleteProjectDialog.svelte +32 -1
  49. package/dist/projects-shard/ProjectManage.svelte +197 -28
  50. package/dist/projects-shard/ProjectManage.svelte.test.d.ts +1 -0
  51. package/dist/projects-shard/ProjectManage.svelte.test.js +320 -0
  52. package/dist/projects-shard/ProjectsSection.svelte +3 -16
  53. package/dist/projects-shard/projectsApi.js +2 -1
  54. package/dist/registry/permission-descriptions.js +4 -0
  55. package/dist/server-shard/types.d.ts +21 -0
  56. package/dist/sh3core-shard/HomeSection.svelte +107 -0
  57. package/dist/sh3core-shard/HomeSection.svelte.d.ts +10 -0
  58. package/dist/sh3core-shard/Sh3Home.svelte +9 -23
  59. package/dist/shards/activate.svelte.d.ts +4 -0
  60. package/dist/shards/activate.svelte.js +9 -1
  61. package/dist/shards/types.d.ts +7 -0
  62. package/dist/shell-shard/tenant-fs-client.js +2 -1
  63. package/dist/transport/apiFetch.js +12 -5
  64. package/dist/version.d.ts +1 -1
  65. package/dist/version.js +1 -1
  66. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { untrack } from 'svelte';
2
3
  import {
3
4
  buildTree,
4
5
  formatSize,
@@ -30,7 +31,7 @@
30
31
  let shardId = $state<string | null>(null);
31
32
  let prefix = $state('');
32
33
  let selectedFile = $state<DocEntry | null>(null);
33
- let filename = $state(suggestedName);
34
+ let filename = $state(untrack(() => suggestedName));
34
35
  let activeIdx = $state(0);
35
36
  let listEl = $state<HTMLElement | undefined>(undefined);
36
37
 
@@ -236,13 +237,9 @@
236
237
 
237
238
  <style>
238
239
  .sh3-doc-browser {
239
- background: var(--sh3-bg-elevated);
240
- border: 1px solid var(--sh3-border-strong);
241
- border-radius: var(--sh3-widget-radius);
242
- box-shadow: var(--sh3-shadow-lg);
243
- width: 420px;
244
- max-height: 480px;
245
- display: flex; flex-direction: column;
240
+ display: flex;
241
+ flex-direction: column;
242
+ max-height: 85vh;
246
243
  overflow: hidden;
247
244
  color: var(--sh3-fg);
248
245
  font-size: 0.8125rem;
@@ -112,7 +112,38 @@
112
112
  color: var(--sh3-fg);
113
113
  cursor: pointer;
114
114
  }
115
- .delete-project-dialog__opt input { margin-top: 3px; }
115
+ .delete-project-dialog__opt input[type='checkbox'] {
116
+ appearance: none;
117
+ -webkit-appearance: none;
118
+ width: 16px;
119
+ height: 16px;
120
+ margin-top: 3px;
121
+ border: 1px solid var(--sh3-border);
122
+ border-radius: 3px;
123
+ background: var(--sh3-bg-elevated);
124
+ cursor: pointer;
125
+ flex-shrink: 0;
126
+ position: relative;
127
+ }
128
+ .delete-project-dialog__opt input[type='checkbox']:checked {
129
+ background: var(--sh3-accent);
130
+ border-color: var(--sh3-accent);
131
+ }
132
+ .delete-project-dialog__opt input[type='checkbox']:checked::after {
133
+ content: '';
134
+ position: absolute;
135
+ left: 4px;
136
+ top: 1px;
137
+ width: 6px;
138
+ height: 10px;
139
+ border: solid #fff;
140
+ border-width: 0 2px 2px 0;
141
+ transform: rotate(45deg);
142
+ }
143
+ .delete-project-dialog__opt input[type='checkbox']:disabled {
144
+ opacity: 0.5;
145
+ cursor: not-allowed;
146
+ }
116
147
  .delete-project-dialog__path {
117
148
  display: block;
118
149
  font-family: var(--sh3-font-mono, monospace);
@@ -14,8 +14,16 @@
14
14
  import { getUser } from '../auth/auth.svelte';
15
15
  import AppPicker from '../primitives/widgets/AppPicker.svelte';
16
16
  import UserPicker from '../primitives/widgets/UserPicker.svelte';
17
+ import TabbedPanel from '../primitives/TabbedPanel.svelte';
17
18
  import { modalManager } from '../overlays/modal';
18
19
  import DeleteProjectDialog from './DeleteProjectDialog.svelte';
20
+ import { apiFetch } from '../transport/apiFetch';
21
+
22
+ interface MountEntry {
23
+ id: string;
24
+ label?: string;
25
+ status: 'resolved' | 'unresolved' | 'error';
26
+ }
19
27
 
20
28
  interface Props {
21
29
  project?: ProjectRecord | null;
@@ -44,6 +52,36 @@
44
52
  );
45
53
  let saving = $state(false);
46
54
  let error = $state<string | null>(null);
55
+ let activeTab = $state(0);
56
+ let allMounts = $state<MountEntry[]>([]);
57
+ let mountAttached = $state<Set<string>>(new Set());
58
+ let mountsLoading = $state(true);
59
+ let mountsLoadError = $state<string | null>(null);
60
+ let baselineApps = $state<string[]>(untrack(() => (project ? [...project.appAllowlist] : [])));
61
+ let baselineMembers = $state<string[]>(
62
+ untrack(() => {
63
+ if (project) return [...project.members];
64
+ const userId = getUser()?.id;
65
+ return userId ? [userId] : [];
66
+ }),
67
+ );
68
+ let baselineMounts = $state<Set<string>>(new Set());
69
+
70
+ function arrayEq(a: string[], b: string[]): boolean {
71
+ if (a.length !== b.length) return false;
72
+ const sa = [...a].sort();
73
+ const sb = [...b].sort();
74
+ return sa.every((v, i) => v === sb[i]);
75
+ }
76
+ function setEq(a: Set<string>, b: Set<string>): boolean {
77
+ if (a.size !== b.size) return false;
78
+ for (const v of a) if (!b.has(v)) return false;
79
+ return true;
80
+ }
81
+
82
+ const appsDirty = $derived(!arrayEq(appAllowlist, baselineApps));
83
+ const membersDirty = $derived(!arrayEq(members, baselineMembers));
84
+ const mountsDirty = $derived(!setEq(mountAttached, baselineMounts));
47
85
 
48
86
  async function save() {
49
87
  if (!name.trim()) {
@@ -59,11 +97,49 @@
59
97
  appAllowlist,
60
98
  };
61
99
  try {
62
- if (isEdit && project) {
63
- await projectsApi.update(project.id, payload);
64
- } else {
65
- await projectsApi.create(payload);
100
+ const saved = isEdit && project
101
+ ? await projectsApi.update(project.id, payload)
102
+ : await projectsApi.create(payload);
103
+
104
+ const projectId = saved.id;
105
+ const toAttach: string[] = [];
106
+ const toDetach: string[] = [];
107
+ for (const id of mountAttached) if (!baselineMounts.has(id)) toAttach.push(id);
108
+ for (const id of baselineMounts) if (!mountAttached.has(id)) toDetach.push(id);
109
+
110
+ const errors: string[] = [];
111
+ for (const mountId of toAttach) {
112
+ try {
113
+ const res = await apiFetch('/api/admin/mount-attachments', {
114
+ method: 'POST',
115
+ headers: { 'content-type': 'application/json' },
116
+ body: JSON.stringify({ mountId, tenantId: projectId }),
117
+ });
118
+ if (!res.ok) errors.push(`attach ${mountId}: ${res.status}`);
119
+ } catch (e) {
120
+ errors.push(`attach ${mountId}: ${(e as Error).message}`);
121
+ }
122
+ }
123
+ for (const mountId of toDetach) {
124
+ try {
125
+ const res = await apiFetch('/api/admin/mount-attachments', {
126
+ method: 'DELETE',
127
+ headers: { 'content-type': 'application/json' },
128
+ body: JSON.stringify({ mountId, tenantId: projectId }),
129
+ });
130
+ if (!res.ok && res.status !== 204) errors.push(`detach ${mountId}: ${res.status}`);
131
+ } catch (e) {
132
+ errors.push(`detach ${mountId}: ${(e as Error).message}`);
133
+ }
134
+ }
135
+ if (errors.length > 0) {
136
+ error = `Mount changes failed: ${errors.join('; ')}`;
137
+ baselineMounts = new Set(mountAttached);
138
+ await refreshProjects();
139
+ return;
66
140
  }
141
+
142
+ baselineMounts = new Set(mountAttached);
67
143
  await refreshProjects();
68
144
  onClose();
69
145
  } catch (e) {
@@ -94,10 +170,51 @@
94
170
  },
95
171
  });
96
172
  }
173
+
174
+ function toggleMount(mountId: string, checked: boolean): void {
175
+ const next = new Set(mountAttached);
176
+ if (checked) next.add(mountId);
177
+ else next.delete(mountId);
178
+ mountAttached = next;
179
+ }
180
+
181
+ async function loadMounts(): Promise<void> {
182
+ mountsLoading = true;
183
+ mountsLoadError = null;
184
+ try {
185
+ const reqs: Promise<Response>[] = [
186
+ apiFetch('/api/admin/mounts'),
187
+ ];
188
+ if (project) {
189
+ reqs.push(apiFetch(
190
+ `/api/admin/tenants/${encodeURIComponent(project.id)}/attachments`,
191
+ ));
192
+ }
193
+ const results = await Promise.all(reqs);
194
+ const mountsRes = results[0];
195
+ const attRes = results[1];
196
+
197
+ if (!mountsRes.ok) throw new Error(`GET /api/admin/mounts failed: ${mountsRes.status}`);
198
+ allMounts = await mountsRes.json() as MountEntry[];
199
+
200
+ if (attRes) {
201
+ if (!attRes.ok) throw new Error(`GET tenant attachments failed: ${attRes.status}`);
202
+ const atts = await attRes.json() as { mountId: string }[];
203
+ mountAttached = new Set(atts.map((a) => a.mountId));
204
+ baselineMounts = new Set(mountAttached);
205
+ }
206
+ } catch (e) {
207
+ mountsLoadError = (e as Error).message;
208
+ } finally {
209
+ mountsLoading = false;
210
+ }
211
+ }
212
+
213
+ loadMounts();
97
214
  </script>
98
215
 
99
216
  <div class="project-manage">
100
- <div class="body">
217
+ <header class="header">
101
218
  <h2>{isEdit ? `Edit ${project!.name}` : 'Create Project'}</h2>
102
219
 
103
220
  {#if isEdit}
@@ -113,27 +230,22 @@
113
230
  <span>Description</span>
114
231
  <textarea bind:value={description} rows={2} disabled={saving}></textarea>
115
232
  </label>
233
+ </header>
116
234
 
117
- <label class="field">
118
- <span>Members</span>
119
- <UserPicker bind:value={members} disabled={saving} />
120
- {#if me}
121
- <span class="hint">
122
- Your id: <code>{me.id}</code> ({me.username ?? me.displayName})
123
- </span>
124
- {/if}
125
- </label>
126
-
127
- <label class="field">
128
- <span>App allowlist</span>
129
- <AppPicker bind:value={appAllowlist} disabled={saving} />
130
- </label>
131
-
132
- {#if error}
133
- <p class="error">{error}</p>
134
- {/if}
235
+ <div class="tabs">
236
+ <TabbedPanel
237
+ labels={['Apps', 'Users', 'Mounts']}
238
+ {activeTab}
239
+ onActiveChange={(i) => (activeTab = i)}
240
+ dirty={[appsDirty, membersDirty, mountsDirty]}
241
+ body={tabBody}
242
+ />
135
243
  </div>
136
244
 
245
+ {#if error}
246
+ <p class="error">{error}</p>
247
+ {/if}
248
+
137
249
  <div class="actions">
138
250
  <button type="button" class="primary" onclick={save} disabled={saving}>
139
251
  {isEdit ? 'Save' : 'Create'}
@@ -145,6 +257,54 @@
145
257
  </div>
146
258
  </div>
147
259
 
260
+ {#snippet tabBody(i: number)}
261
+ <div class="tab-pane">
262
+ {#if i === 0}
263
+ <label class="field">
264
+ <span>App allowlist</span>
265
+ <AppPicker bind:value={appAllowlist} disabled={saving} />
266
+ </label>
267
+ {:else if i === 1}
268
+ <label class="field">
269
+ <span>Members</span>
270
+ <UserPicker bind:value={members} disabled={saving} />
271
+ {#if me}
272
+ <span class="hint">
273
+ Your id: <code>{me.id}</code> ({me.username ?? me.displayName})
274
+ </span>
275
+ {/if}
276
+ </label>
277
+ {:else}
278
+ {#if mountsLoading}
279
+ <p class="muted">Loading mounts...</p>
280
+ {:else if mountsLoadError}
281
+ <p class="error">{mountsLoadError}</p>
282
+ {:else if allMounts.length === 0}
283
+ <p class="muted">No mounts configured. Create mounts first.</p>
284
+ {:else}
285
+ <ul class="mount-list">
286
+ {#each allMounts as mount (mount.id)}
287
+ <li class="mount-row" data-mount-row={mount.id}>
288
+ <label>
289
+ <input
290
+ class="sh3-base-check"
291
+ type="checkbox"
292
+ checked={mountAttached.has(mount.id)}
293
+ disabled={saving}
294
+ onchange={(e) => toggleMount(mount.id, (e.currentTarget as HTMLInputElement).checked)}
295
+ />
296
+ <span class="mount-status" data-status={mount.status}></span>
297
+ <span class="mount-label">{mount.label ?? mount.id}</span>
298
+ <span class="mount-id">{mount.id}</span>
299
+ </label>
300
+ </li>
301
+ {/each}
302
+ </ul>
303
+ {/if}
304
+ {/if}
305
+ </div>
306
+ {/snippet}
307
+
148
308
  <style>
149
309
  .project-manage {
150
310
  position: absolute;
@@ -155,11 +315,20 @@
155
315
  color: var(--sh3-fg);
156
316
  background: var(--sh3-bg);
157
317
  }
158
- .body {
159
- flex: 1;
160
- overflow-y: auto;
161
- padding: 16px 16px 8px;
162
- }
318
+ .header { padding: 16px 16px 8px; flex: 0 0 auto; border-bottom: 1px solid var(--sh3-border); }
319
+ .tabs { flex: 1 1 auto; min-height: 0; position: relative; }
320
+ .tab-pane { padding: 16px; height: 100%; overflow-y: auto; box-sizing: border-box; }
321
+ .muted { color: var(--sh3-fg-muted); font-style: italic; font-size: 13px; }
322
+ .mount-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
323
+ .mount-row { padding: 6px 0; border-bottom: 1px solid var(--sh3-border); font-size: 13px; }
324
+ .mount-row label { display: flex; align-items: center; gap: 8px; cursor: pointer; }
325
+ .mount-row input[type="checkbox"] { cursor: pointer; }
326
+ .mount-status { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; background: #999; }
327
+ .mount-status[data-status="resolved"] { background: #4caf50; }
328
+ .mount-status[data-status="unresolved"] { background: #ff9800; }
329
+ .mount-status[data-status="error"] { background: #f44336; }
330
+ .mount-label { font-weight: 500; }
331
+ .mount-id { font-size: 11px; color: var(--sh3-fg-muted); font-family: var(--sh3-font-mono, monospace); margin-left: auto; }
163
332
  h2 { margin: 0 0 8px; font-size: 16px; }
164
333
  .project-id { font-size: 12px; color: var(--sh3-fg-muted); margin: 0 0 16px; }
165
334
  .project-id code { font-family: var(--sh3-font-mono, monospace); }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,320 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { mount, unmount, tick } from 'svelte';
3
+ import ProjectManage from './ProjectManage.svelte';
4
+ import { __resetAdminUsersForTest } from '../auth/admin-users.svelte';
5
+ let host;
6
+ let cmp = null;
7
+ let originalFetch;
8
+ function makeProject(overrides = {}) {
9
+ return Object.assign({ id: 'acme-1234', name: 'Acme', description: '', members: ['user-1'], appAllowlist: [], createdBy: 'user-1', createdAt: 0, updatedAt: 0 }, overrides);
10
+ }
11
+ beforeEach(() => {
12
+ __resetAdminUsersForTest();
13
+ host = document.createElement('div');
14
+ document.body.appendChild(host);
15
+ originalFetch = globalThis.fetch;
16
+ globalThis.fetch = vi.fn(async (input) => {
17
+ const url = String(input);
18
+ if (url.endsWith('/api/admin/mounts'))
19
+ return new Response('[]', { status: 200 });
20
+ if (url.includes('/api/admin/tenants/'))
21
+ return new Response('[]', { status: 200 });
22
+ if (url.endsWith('/api/admin/users'))
23
+ return new Response('[]', { status: 200 });
24
+ return new Response('null', { status: 200 });
25
+ });
26
+ });
27
+ afterEach(() => {
28
+ if (cmp) {
29
+ unmount(cmp);
30
+ cmp = null;
31
+ }
32
+ host.remove();
33
+ globalThis.fetch = originalFetch;
34
+ });
35
+ describe('ProjectManage tabs', () => {
36
+ it('renders three tabs: Apps, Users, Mounts', async () => {
37
+ cmp = mount(ProjectManage, {
38
+ target: host,
39
+ props: { project: makeProject(), onClose: () => { } },
40
+ });
41
+ await tick();
42
+ const labels = Array.from(host.querySelectorAll('[role="tab"]')).map((el) => { var _a, _b; return (_b = (_a = el.textContent) === null || _a === void 0 ? void 0 : _a.trim()) !== null && _b !== void 0 ? _b : ''; });
43
+ expect(labels).toEqual(['Apps', 'Users', 'Mounts']);
44
+ });
45
+ });
46
+ describe('ProjectManage mount fetch', () => {
47
+ it('on edit open, fetches /api/admin/mounts and the project attachments', async () => {
48
+ const calls = [];
49
+ globalThis.fetch = vi.fn(async (input) => {
50
+ const url = String(input);
51
+ calls.push(url);
52
+ if (url.endsWith('/api/admin/users'))
53
+ return new Response('[]', { status: 200 });
54
+ if (url.endsWith('/api/admin/mounts')) {
55
+ return new Response(JSON.stringify([
56
+ { id: 'assets', label: 'Assets', status: 'resolved' },
57
+ { id: 'pkgs', label: 'Packages', status: 'unresolved' },
58
+ ]), { status: 200 });
59
+ }
60
+ if (url.endsWith('/api/admin/tenants/acme-1234/attachments')) {
61
+ return new Response(JSON.stringify([{ mountId: 'assets', tenantId: 'acme-1234', attachedAt: '' }]), { status: 200 });
62
+ }
63
+ return new Response('null', { status: 200 });
64
+ });
65
+ cmp = mount(ProjectManage, {
66
+ target: host,
67
+ props: { project: makeProject({ id: 'acme-1234' }), onClose: () => { } },
68
+ });
69
+ await tick();
70
+ await new Promise((r) => setTimeout(r, 0));
71
+ expect(calls).toContain('/api/admin/mounts');
72
+ expect(calls).toContain('/api/admin/tenants/acme-1234/attachments');
73
+ });
74
+ it('on create (no project), does NOT fetch tenant attachments', async () => {
75
+ const calls = [];
76
+ globalThis.fetch = vi.fn(async (input) => {
77
+ const url = String(input);
78
+ calls.push(url);
79
+ if (url.endsWith('/api/admin/users'))
80
+ return new Response('[]', { status: 200 });
81
+ if (url.endsWith('/api/admin/mounts'))
82
+ return new Response('[]', { status: 200 });
83
+ return new Response('null', { status: 200 });
84
+ });
85
+ cmp = mount(ProjectManage, {
86
+ target: host,
87
+ props: { project: null, onClose: () => { } },
88
+ });
89
+ await tick();
90
+ await new Promise((r) => setTimeout(r, 0));
91
+ expect(calls).toContain('/api/admin/mounts');
92
+ expect(calls.some((c) => c.includes('/api/admin/tenants/'))).toBe(false);
93
+ });
94
+ });
95
+ describe('ProjectManage mount list', () => {
96
+ it('renders one checkbox per mount with attached state seeded from server', async () => {
97
+ globalThis.fetch = vi.fn(async (input) => {
98
+ const url = String(input);
99
+ if (url.endsWith('/api/admin/users'))
100
+ return new Response('[]', { status: 200 });
101
+ if (url.endsWith('/api/admin/mounts')) {
102
+ return new Response(JSON.stringify([
103
+ { id: 'assets', label: 'Assets', status: 'resolved' },
104
+ { id: 'pkgs', label: 'Packages', status: 'resolved' },
105
+ ]), { status: 200 });
106
+ }
107
+ if (url.endsWith('/api/admin/tenants/acme-1234/attachments')) {
108
+ return new Response(JSON.stringify([
109
+ { mountId: 'assets', tenantId: 'acme-1234', attachedAt: '' },
110
+ ]), { status: 200 });
111
+ }
112
+ return new Response('null', { status: 200 });
113
+ });
114
+ cmp = mount(ProjectManage, {
115
+ target: host,
116
+ props: { project: makeProject({ id: 'acme-1234' }), onClose: () => { } },
117
+ });
118
+ await tick();
119
+ await new Promise((r) => setTimeout(r, 0));
120
+ await tick();
121
+ const tabs = host.querySelectorAll('[role="tab"]');
122
+ tabs[2].click();
123
+ await tick();
124
+ const rows = host.querySelectorAll('[data-mount-row]');
125
+ expect(rows.length).toBe(2);
126
+ const assetsCb = host.querySelector('[data-mount-row="assets"] input[type="checkbox"]');
127
+ const pkgsCb = host.querySelector('[data-mount-row="pkgs"] input[type="checkbox"]');
128
+ expect(assetsCb.checked).toBe(true);
129
+ expect(pkgsCb.checked).toBe(false);
130
+ });
131
+ it('toggling a checkbox mutates mountAttached', async () => {
132
+ globalThis.fetch = vi.fn(async (input) => {
133
+ const url = String(input);
134
+ if (url.endsWith('/api/admin/users'))
135
+ return new Response('[]', { status: 200 });
136
+ if (url.endsWith('/api/admin/mounts')) {
137
+ return new Response(JSON.stringify([
138
+ { id: 'assets', label: 'Assets', status: 'resolved' },
139
+ ]), { status: 200 });
140
+ }
141
+ if (url.includes('/api/admin/tenants/'))
142
+ return new Response('[]', { status: 200 });
143
+ return new Response('null', { status: 200 });
144
+ });
145
+ cmp = mount(ProjectManage, {
146
+ target: host,
147
+ props: { project: makeProject({ id: 'acme-1234' }), onClose: () => { } },
148
+ });
149
+ await tick();
150
+ await new Promise((r) => setTimeout(r, 0));
151
+ await tick();
152
+ const tabs = host.querySelectorAll('[role="tab"]');
153
+ tabs[2].click();
154
+ await tick();
155
+ const cb = host.querySelector('[data-mount-row="assets"] input[type="checkbox"]');
156
+ expect(cb.checked).toBe(false);
157
+ cb.click();
158
+ await tick();
159
+ expect(cb.checked).toBe(true);
160
+ });
161
+ });
162
+ describe('ProjectManage dirty indicators', () => {
163
+ it('shows the dirty dot on the Mounts tab after toggling a checkbox', async () => {
164
+ globalThis.fetch = vi.fn(async (input) => {
165
+ const url = String(input);
166
+ if (url.endsWith('/api/admin/users'))
167
+ return new Response('[]', { status: 200 });
168
+ if (url.endsWith('/api/admin/mounts')) {
169
+ return new Response(JSON.stringify([
170
+ { id: 'assets', label: 'Assets', status: 'resolved' },
171
+ ]), { status: 200 });
172
+ }
173
+ if (url.includes('/api/admin/tenants/'))
174
+ return new Response('[]', { status: 200 });
175
+ return new Response('null', { status: 200 });
176
+ });
177
+ cmp = mount(ProjectManage, {
178
+ target: host,
179
+ props: { project: makeProject({ id: 'acme-1234' }), onClose: () => { } },
180
+ });
181
+ await tick();
182
+ await new Promise((r) => setTimeout(r, 0));
183
+ await tick();
184
+ const tabs = host.querySelectorAll('[role="tab"]');
185
+ expect(host.querySelectorAll('.tab-dirty').length).toBe(0);
186
+ tabs[2].click();
187
+ await tick();
188
+ const cb = host.querySelector('[data-mount-row="assets"] input[type="checkbox"]');
189
+ cb.click();
190
+ await tick();
191
+ const dirtyDot = tabs[2].querySelector('.tab-dirty');
192
+ expect(dirtyDot).not.toBeNull();
193
+ expect(tabs[0].querySelector('.tab-dirty')).toBeNull();
194
+ expect(tabs[1].querySelector('.tab-dirty')).toBeNull();
195
+ });
196
+ });
197
+ describe('ProjectManage save mount diff', () => {
198
+ it('on edit save, fires POST for added mounts and DELETE for removed mounts', async () => {
199
+ const calls = [];
200
+ globalThis.fetch = vi.fn(async (input, init) => {
201
+ var _a;
202
+ const url = String(input);
203
+ const method = ((_a = init === null || init === void 0 ? void 0 : init.method) !== null && _a !== void 0 ? _a : 'GET').toUpperCase();
204
+ const body = (init === null || init === void 0 ? void 0 : init.body) ? JSON.parse(init.body) : undefined;
205
+ calls.push({ method, url, body });
206
+ if (url.endsWith('/api/admin/users'))
207
+ return new Response('[]', { status: 200 });
208
+ if (method === 'GET' && url.endsWith('/api/admin/mounts')) {
209
+ return new Response(JSON.stringify([
210
+ { id: 'assets', label: 'Assets', status: 'resolved' },
211
+ { id: 'pkgs', label: 'Packages', status: 'resolved' },
212
+ ]), { status: 200 });
213
+ }
214
+ if (method === 'GET' && url.endsWith('/api/admin/tenants/acme-1234/attachments')) {
215
+ return new Response(JSON.stringify([
216
+ { mountId: 'assets', tenantId: 'acme-1234', attachedAt: '' },
217
+ ]), { status: 200 });
218
+ }
219
+ if (method === 'PATCH' && url === '/api/projects/acme-1234') {
220
+ return new Response(JSON.stringify({ id: 'acme-1234', name: 'Acme', members: ['user-1'], appAllowlist: [], createdBy: 'user-1', createdAt: 0, updatedAt: 1 }), { status: 200 });
221
+ }
222
+ if (url === '/api/admin/mount-attachments') {
223
+ return new Response('{}', { status: method === 'DELETE' ? 204 : 201 });
224
+ }
225
+ if (method === 'GET' && url === '/api/projects') {
226
+ return new Response(JSON.stringify({ projects: [] }), { status: 200 });
227
+ }
228
+ return new Response('null', { status: 200 });
229
+ });
230
+ cmp = mount(ProjectManage, {
231
+ target: host,
232
+ props: { project: makeProject({ id: 'acme-1234' }), onClose: () => { } },
233
+ });
234
+ await tick();
235
+ await new Promise((r) => setTimeout(r, 0));
236
+ await tick();
237
+ const tabs = host.querySelectorAll('[role="tab"]');
238
+ tabs[2].click();
239
+ await tick();
240
+ host.querySelector('[data-mount-row="assets"] input').click();
241
+ await tick();
242
+ host.querySelector('[data-mount-row="pkgs"] input').click();
243
+ await tick();
244
+ const saveBtn = Array.from(host.querySelectorAll('button.primary'))[0];
245
+ saveBtn.click();
246
+ await tick();
247
+ await new Promise((r) => setTimeout(r, 0));
248
+ await tick();
249
+ const patchIdx = calls.findIndex((c) => c.method === 'PATCH' && c.url === '/api/projects/acme-1234');
250
+ const attachCalls = calls.filter((c) => c.url === '/api/admin/mount-attachments');
251
+ expect(patchIdx).toBeGreaterThanOrEqual(0);
252
+ expect(attachCalls.length).toBe(2);
253
+ const post = attachCalls.find((c) => c.method === 'POST');
254
+ const del = attachCalls.find((c) => c.method === 'DELETE');
255
+ expect(post === null || post === void 0 ? void 0 : post.body).toEqual({ mountId: 'pkgs', tenantId: 'acme-1234' });
256
+ expect(del === null || del === void 0 ? void 0 : del.body).toEqual({ mountId: 'assets', tenantId: 'acme-1234' });
257
+ });
258
+ it('on save with no mount diff, fires zero attachment calls', async () => {
259
+ const calls = [];
260
+ globalThis.fetch = vi.fn(async (input, init) => {
261
+ var _a;
262
+ const url = String(input);
263
+ const method = ((_a = init === null || init === void 0 ? void 0 : init.method) !== null && _a !== void 0 ? _a : 'GET').toUpperCase();
264
+ calls.push({ method, url });
265
+ if (url.endsWith('/api/admin/users'))
266
+ return new Response('[]', { status: 200 });
267
+ if (method === 'GET' && url.endsWith('/api/admin/mounts')) {
268
+ return new Response(JSON.stringify([{ id: 'assets', label: 'Assets', status: 'resolved' }]), { status: 200 });
269
+ }
270
+ if (method === 'GET' && url.endsWith('/api/admin/tenants/acme-1234/attachments')) {
271
+ return new Response(JSON.stringify([{ mountId: 'assets', tenantId: 'acme-1234', attachedAt: '' }]), { status: 200 });
272
+ }
273
+ if (method === 'PATCH' && url === '/api/projects/acme-1234') {
274
+ return new Response(JSON.stringify({ id: 'acme-1234', name: 'Acme', members: ['user-1'], appAllowlist: [], createdBy: 'user-1', createdAt: 0, updatedAt: 1 }), { status: 200 });
275
+ }
276
+ if (method === 'GET' && url === '/api/projects') {
277
+ return new Response(JSON.stringify({ projects: [] }), { status: 200 });
278
+ }
279
+ return new Response('null', { status: 200 });
280
+ });
281
+ cmp = mount(ProjectManage, {
282
+ target: host,
283
+ props: { project: makeProject({ id: 'acme-1234' }), onClose: () => { } },
284
+ });
285
+ await tick();
286
+ await new Promise((r) => setTimeout(r, 0));
287
+ await tick();
288
+ const saveBtn = host.querySelector('button.primary');
289
+ saveBtn.click();
290
+ await tick();
291
+ await new Promise((r) => setTimeout(r, 0));
292
+ await tick();
293
+ expect(calls.some((c) => c.url === '/api/admin/mount-attachments')).toBe(false);
294
+ });
295
+ });
296
+ describe('ProjectManage mount empty state', () => {
297
+ it('shows the empty message when no mounts are configured', async () => {
298
+ globalThis.fetch = vi.fn(async (input) => {
299
+ const url = String(input);
300
+ if (url.endsWith('/api/admin/users'))
301
+ return new Response('[]', { status: 200 });
302
+ if (url.endsWith('/api/admin/mounts'))
303
+ return new Response('[]', { status: 200 });
304
+ if (url.includes('/api/admin/tenants/'))
305
+ return new Response('[]', { status: 200 });
306
+ return new Response('null', { status: 200 });
307
+ });
308
+ cmp = mount(ProjectManage, {
309
+ target: host,
310
+ props: { project: makeProject({ id: 'acme-1234' }), onClose: () => { } },
311
+ });
312
+ await tick();
313
+ await new Promise((r) => setTimeout(r, 0));
314
+ await tick();
315
+ const tabs = host.querySelectorAll('[role="tab"]');
316
+ tabs[2].click();
317
+ await tick();
318
+ expect(host.textContent).toContain('No mounts configured. Create mounts first.');
319
+ });
320
+ });