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.
- package/dist/app/admin/AuthSettingsView.svelte +3 -9
- package/dist/app/admin/MountsView.svelte +276 -0
- package/dist/app/admin/MountsView.svelte.d.ts +3 -0
- package/dist/app/admin/SystemView.svelte +6 -6
- package/dist/app/admin/UsersView.svelte +103 -7
- package/dist/app/admin/adminApp.js +1 -0
- package/dist/app/admin/adminShard.svelte.js +10 -0
- package/dist/apps/lifecycle.js +1 -0
- package/dist/apps/types.d.ts +7 -0
- package/dist/assets/iconIds.generated.d.ts +1 -1
- package/dist/assets/iconIds.generated.js +1 -0
- package/dist/assets/icons.svg +5 -0
- package/dist/auth/admin-users.svelte.js +2 -1
- package/dist/auth/auth.svelte.d.ts +4 -5
- package/dist/auth/auth.svelte.js +5 -6
- package/dist/auth/types.d.ts +0 -2
- package/dist/chrome/CompactChrome.svelte +25 -6
- package/dist/chrome/FloatsSheet.svelte +7 -32
- package/dist/chrome/FloatsSheet.svelte.d.ts +1 -2
- package/dist/chrome/FloatsSheet.svelte.test.js +8 -14
- package/dist/chrome/MenuSheet.svelte +154 -148
- package/dist/chrome/MenuSheet.svelte.d.ts +1 -2
- package/dist/chrome/MenuSheet.svelte.test.js +24 -12
- package/dist/createShell.js +32 -21
- package/dist/createShell.remoteAuth.test.js +9 -3
- package/dist/documents/browse.d.ts +18 -1
- package/dist/documents/browse.js +40 -7
- package/dist/documents/browse.test.js +35 -35
- package/dist/documents/config.d.ts +4 -0
- package/dist/documents/config.js +15 -2
- package/dist/documents/handle.js +25 -17
- package/dist/documents/http-backend.js +10 -2
- package/dist/documents/index.d.ts +2 -2
- package/dist/documents/index.js +1 -1
- package/dist/documents/picker-api.d.ts +4 -2
- package/dist/documents/picker-api.test.d.ts +1 -1
- package/dist/documents/picker-api.test.js +87 -57
- package/dist/documents/picker-primitive.d.ts +4 -0
- package/dist/documents/picker-primitive.js +27 -29
- package/dist/documents/types.d.ts +17 -5
- package/dist/documents/types.js +2 -0
- package/dist/layout/presets.test.js +4 -4
- package/dist/layout/types.d.ts +1 -1
- package/dist/layouts-shard/LayoutsSection.svelte +3 -16
- package/dist/primitives/widgets/DocumentFilePicker.svelte +4 -4
- package/dist/primitives/widgets/PickerList.svelte +1 -0
- package/dist/primitives/widgets/_DocumentBrowser.svelte +5 -8
- package/dist/projects-shard/DeleteProjectDialog.svelte +32 -1
- package/dist/projects-shard/ProjectManage.svelte +197 -28
- package/dist/projects-shard/ProjectManage.svelte.test.d.ts +1 -0
- package/dist/projects-shard/ProjectManage.svelte.test.js +320 -0
- package/dist/projects-shard/ProjectsSection.svelte +3 -16
- package/dist/projects-shard/projectsApi.js +2 -1
- package/dist/registry/permission-descriptions.js +4 -0
- package/dist/server-shard/types.d.ts +21 -0
- package/dist/sh3core-shard/HomeSection.svelte +107 -0
- package/dist/sh3core-shard/HomeSection.svelte.d.ts +10 -0
- package/dist/sh3core-shard/Sh3Home.svelte +9 -23
- package/dist/shards/activate.svelte.d.ts +4 -0
- package/dist/shards/activate.svelte.js +9 -1
- package/dist/shards/types.d.ts +7 -0
- package/dist/shell-shard/tenant-fs-client.js +2 -1
- package/dist/transport/apiFetch.js +12 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- 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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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 {
|
|
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
|
-
|
|
63
|
-
await projectsApi.update(project.id, payload)
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
{
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
.
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
+
});
|