sh3-core 0.19.5 → 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 (69) hide show
  1. package/dist/api.d.ts +1 -0
  2. package/dist/app/admin/AuthSettingsView.svelte +3 -9
  3. package/dist/app/admin/MountsView.svelte +276 -0
  4. package/dist/app/admin/MountsView.svelte.d.ts +3 -0
  5. package/dist/app/admin/SystemView.svelte +6 -6
  6. package/dist/app/admin/UsersView.svelte +103 -7
  7. package/dist/app/admin/adminApp.js +1 -0
  8. package/dist/app/admin/adminShard.svelte.js +10 -0
  9. package/dist/apps/lifecycle.js +1 -0
  10. package/dist/apps/types.d.ts +7 -0
  11. package/dist/assets/iconIds.generated.d.ts +1 -1
  12. package/dist/assets/iconIds.generated.js +1 -0
  13. package/dist/assets/icons.svg +5 -0
  14. package/dist/auth/admin-users.svelte.js +2 -1
  15. package/dist/auth/auth.svelte.d.ts +4 -5
  16. package/dist/auth/auth.svelte.js +5 -6
  17. package/dist/auth/types.d.ts +0 -2
  18. package/dist/chrome/CompactChrome.svelte +25 -6
  19. package/dist/chrome/FloatsSheet.svelte +7 -32
  20. package/dist/chrome/FloatsSheet.svelte.d.ts +1 -2
  21. package/dist/chrome/FloatsSheet.svelte.test.js +8 -14
  22. package/dist/chrome/MenuSheet.svelte +154 -148
  23. package/dist/chrome/MenuSheet.svelte.d.ts +1 -2
  24. package/dist/chrome/MenuSheet.svelte.test.js +24 -12
  25. package/dist/createShell.js +32 -21
  26. package/dist/createShell.remoteAuth.test.js +9 -3
  27. package/dist/documents/browse.d.ts +18 -1
  28. package/dist/documents/browse.js +40 -7
  29. package/dist/documents/browse.test.js +35 -35
  30. package/dist/documents/config.d.ts +4 -0
  31. package/dist/documents/config.js +15 -2
  32. package/dist/documents/handle.js +25 -17
  33. package/dist/documents/http-backend.js +10 -2
  34. package/dist/documents/index.d.ts +2 -2
  35. package/dist/documents/index.js +1 -1
  36. package/dist/documents/picker-api.d.ts +33 -0
  37. package/dist/documents/picker-api.js +1 -0
  38. package/dist/documents/picker-api.test.d.ts +1 -0
  39. package/dist/documents/picker-api.test.js +162 -0
  40. package/dist/documents/picker-primitive.d.ts +11 -0
  41. package/dist/documents/picker-primitive.js +56 -0
  42. package/dist/documents/types.d.ts +17 -5
  43. package/dist/documents/types.js +2 -0
  44. package/dist/layout/presets.test.js +4 -4
  45. package/dist/layout/types.d.ts +1 -1
  46. package/dist/layouts-shard/LayoutsSection.svelte +3 -16
  47. package/dist/primitives/widgets/DocumentFilePicker.svelte +4 -4
  48. package/dist/primitives/widgets/PickerList.svelte +1 -0
  49. package/dist/primitives/widgets/_DocumentBrowser.svelte +7 -8
  50. package/dist/primitives/widgets/_DocumentBrowser.svelte.d.ts +1 -0
  51. package/dist/projects-shard/DeleteProjectDialog.svelte +32 -1
  52. package/dist/projects-shard/ProjectManage.svelte +197 -28
  53. package/dist/projects-shard/ProjectManage.svelte.test.d.ts +1 -0
  54. package/dist/projects-shard/ProjectManage.svelte.test.js +320 -0
  55. package/dist/projects-shard/ProjectsSection.svelte +3 -16
  56. package/dist/projects-shard/projectsApi.js +2 -1
  57. package/dist/registry/permission-descriptions.js +4 -0
  58. package/dist/server-shard/types.d.ts +21 -0
  59. package/dist/sh3core-shard/HomeSection.svelte +107 -0
  60. package/dist/sh3core-shard/HomeSection.svelte.d.ts +10 -0
  61. package/dist/sh3core-shard/Sh3Home.svelte +9 -23
  62. package/dist/shards/activate.svelte.d.ts +4 -0
  63. package/dist/shards/activate.svelte.js +31 -14
  64. package/dist/shards/types.d.ts +15 -0
  65. package/dist/shell-shard/tenant-fs-client.js +2 -1
  66. package/dist/transport/apiFetch.js +12 -5
  67. package/dist/version.d.ts +1 -1
  68. package/dist/version.js +1 -1
  69. package/package.json +1 -1
package/dist/api.d.ts CHANGED
@@ -30,6 +30,7 @@ export { inspectActiveLayout, spliceIntoActiveLayout, dockIntoActiveLayout, focu
30
30
  export type { DocumentHandle, DocumentHandleOptions, DocumentFormat, DocumentMeta, DocumentChange, AutosaveController, } from './documents/types';
31
31
  export { PERMISSION_DOCUMENTS_BROWSE, PERMISSION_DOCUMENTS_READ, PERMISSION_DOCUMENTS_WRITE, } from './documents/types';
32
32
  export type { BrowseCapability } from './documents/browse';
33
+ export type { DocumentPickerApi, DocumentOpenOptions, DocumentSaveOptions } from './documents/picker-api';
33
34
  export type { ContributionsApi } from './contributions/types';
34
35
  export type { SyncPolicy, SyncPolicyRule, DocStatus, ConflictFile, ConflictBranch, } from './documents/sync-types';
35
36
  export { PERMISSION_SYNC_PEER, PERMISSION_SYNC_POLICY } from './documents/sync-types';
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import type { GlobalSettings } from '../../auth/types';
7
+ import { apiFetch } from '../../transport/apiFetch';
7
8
 
8
9
  let settings = $state<GlobalSettings | null>(null);
9
10
  let loading = $state(true);
@@ -13,7 +14,7 @@
13
14
  async function fetchSettings() {
14
15
  loading = true;
15
16
  try {
16
- const res = await fetch('/api/admin/settings', { credentials: 'include' });
17
+ const res = await apiFetch('/api/admin/settings');
17
18
  if (!res.ok) throw new Error('Failed to fetch settings');
18
19
  settings = await res.json();
19
20
  } catch (err) {
@@ -28,10 +29,9 @@
28
29
  saving = true;
29
30
  error = null;
30
31
  try {
31
- const res = await fetch('/api/admin/settings', {
32
+ const res = await apiFetch('/api/admin/settings', {
32
33
  method: 'PUT',
33
34
  headers: { 'Content-Type': 'application/json' },
34
- credentials: 'include',
35
35
  body: JSON.stringify(settings),
36
36
  });
37
37
  if (!res.ok) throw new Error('Failed to save settings');
@@ -53,12 +53,6 @@
53
53
  <p class="admin-muted">Loading...</p>
54
54
  {:else if settings}
55
55
  <div class="admin-auth-fields">
56
- <label class="admin-toggle">
57
- <input type="checkbox" bind:checked={settings.auth.required} />
58
- <span>Require sign-in</span>
59
- <span class="admin-hint">When enabled, unauthenticated visitors see a sign-in wall.</span>
60
- </label>
61
-
62
56
  <label class="admin-toggle">
63
57
  <input type="checkbox" bind:checked={settings.auth.guestAllowed} />
64
58
  <span>Allow guest browsing</span>
@@ -0,0 +1,276 @@
1
+ <script lang="ts">
2
+ import { apiFetch } from '../../transport/apiFetch';
3
+
4
+ interface MountEntry {
5
+ id: string;
6
+ label?: string;
7
+ path: string;
8
+ status: 'resolved' | 'unresolved' | 'error';
9
+ attachmentCount: number;
10
+ createdAt: string;
11
+ updatedAt: string;
12
+ }
13
+
14
+ let mounts = $state<MountEntry[]>([]);
15
+ let loading = $state(true);
16
+ let error = $state<string | null>(null);
17
+
18
+ // Create form
19
+ let showCreate = $state(false);
20
+ let newId = $state('');
21
+ let newLabel = $state('');
22
+ let newPath = $state('');
23
+ let createError = $state<string | null>(null);
24
+ let createWarning = $state<string | null>(null);
25
+
26
+ // Edit state
27
+ let editingId = $state<string | null>(null);
28
+ let editLabel = $state('');
29
+ let editPath = $state('');
30
+
31
+ // Browse state
32
+ let browsingId = $state<string | null>(null);
33
+ let browseEntries = $state<{ name: string; kind: string; size?: number }[]>([]);
34
+ let browseError = $state<string | null>(null);
35
+
36
+ // Delete confirmation
37
+ let deletingId = $state<string | null>(null);
38
+
39
+ async function fetchMounts() {
40
+ loading = true;
41
+ error = null;
42
+ try {
43
+ const res = await apiFetch('/api/admin/mounts');
44
+ if (!res.ok) throw new Error('Failed to fetch mounts');
45
+ mounts = await res.json();
46
+ } catch (err) {
47
+ error = err instanceof Error ? err.message : 'Failed to load mounts';
48
+ } finally {
49
+ loading = false;
50
+ }
51
+ }
52
+
53
+ async function createMount() {
54
+ createError = null;
55
+ createWarning = null;
56
+ try {
57
+ const res = await apiFetch('/api/admin/mounts', {
58
+ method: 'POST',
59
+ headers: { 'Content-Type': 'application/json' },
60
+ body: JSON.stringify({ id: newId, label: newLabel || undefined, path: newPath }),
61
+ });
62
+ const body = await res.json();
63
+ if (!res.ok) {
64
+ createError = body.error || 'Failed to create mount';
65
+ return;
66
+ }
67
+ if (body.warning) createWarning = body.warning;
68
+ newId = '';
69
+ newLabel = '';
70
+ newPath = '';
71
+ showCreate = false;
72
+ createWarning = null;
73
+ await fetchMounts();
74
+ } catch {
75
+ createError = 'Network error';
76
+ }
77
+ }
78
+
79
+ function startEdit(mount: MountEntry) {
80
+ editingId = mount.id;
81
+ editLabel = mount.label || '';
82
+ editPath = mount.path;
83
+ }
84
+
85
+ async function saveEdit() {
86
+ if (!editingId) return;
87
+ try {
88
+ const res = await apiFetch(`/api/admin/mounts/${editingId}`, {
89
+ method: 'PUT',
90
+ headers: { 'Content-Type': 'application/json' },
91
+ body: JSON.stringify({ label: editLabel || undefined, path: editPath }),
92
+ });
93
+ if (!res.ok) return;
94
+ editingId = null;
95
+ await fetchMounts();
96
+ } catch { /* ignore */ }
97
+ }
98
+
99
+ async function deleteMount(id: string) {
100
+ try {
101
+ await apiFetch(`/api/admin/mounts/${id}`, { method: 'DELETE' });
102
+ deletingId = null;
103
+ await fetchMounts();
104
+ } catch { /* ignore */ }
105
+ }
106
+
107
+ async function toggleBrowse(id: string) {
108
+ if (browsingId === id) {
109
+ browsingId = null;
110
+ return;
111
+ }
112
+ browsingId = id;
113
+ browseError = null;
114
+ browseEntries = [];
115
+ try {
116
+ const res = await apiFetch(`/api/admin/mounts/${id}/browse`);
117
+ if (!res.ok) {
118
+ const body = await res.json().catch(() => ({}));
119
+ browseError = body.error || 'Failed to browse';
120
+ return;
121
+ }
122
+ browseEntries = await res.json();
123
+ } catch {
124
+ browseError = 'Network error';
125
+ }
126
+ }
127
+
128
+ function statusColor(status: string): string {
129
+ return status === 'resolved' ? '#4caf50' : status === 'unresolved' ? '#ff9800' : '#f44336';
130
+ }
131
+
132
+ fetchMounts();
133
+ </script>
134
+
135
+ <svelte:window onkeydown={(e) => { if (e.key === 'Escape' && deletingId) deletingId = null; }} />
136
+
137
+ <div class="admin-mounts">
138
+ <div class="admin-section-header">
139
+ <h2>Mounts</h2>
140
+ <button type="button" class="admin-btn" onclick={() => { showCreate = !showCreate; }}>
141
+ {showCreate ? 'Cancel' : 'New mount'}
142
+ </button>
143
+ </div>
144
+
145
+ {#if showCreate}
146
+ <form class="admin-create-form" onsubmit={(e) => { e.preventDefault(); createMount(); }}>
147
+ <input class="admin-input" type="text" placeholder="Mount ID (slug, e.g. game-assets)" bind:value={newId} />
148
+ <input class="admin-input" type="text" placeholder="Label (optional)" bind:value={newLabel} />
149
+ <input class="admin-input" type="text" placeholder="Absolute path on server" bind:value={newPath} />
150
+ <button type="submit" class="admin-btn" disabled={!newId.trim() || !newPath.trim()}>Create</button>
151
+ {#if createWarning}<div class="admin-warning">{createWarning}</div>{/if}
152
+ {#if createError}<div class="admin-error">{createError}</div>{/if}
153
+ </form>
154
+ {/if}
155
+
156
+ {#if loading}
157
+ <p class="admin-muted">Loading...</p>
158
+ {:else if error}
159
+ <p class="admin-error">{error}</p>
160
+ {:else}
161
+ <ul class="admin-mount-list">
162
+ {#each mounts as mount (mount.id)}
163
+ <li class="admin-mount-item">
164
+ <div class="admin-mount-main">
165
+ {#if editingId === mount.id}
166
+ <form class="admin-edit-form" onsubmit={(e) => { e.preventDefault(); saveEdit(); }}>
167
+ <input class="admin-input" type="text" bind:value={editLabel} placeholder="Label" />
168
+ <input class="admin-input" type="text" bind:value={editPath} placeholder="Path" />
169
+ <div class="admin-edit-actions">
170
+ <button type="submit" class="admin-btn">Save</button>
171
+ <button type="button" class="admin-btn-secondary" onclick={() => { editingId = null; }}>Cancel</button>
172
+ </div>
173
+ </form>
174
+ {:else}
175
+ <div class="admin-mount-info">
176
+ <div class="admin-mount-top">
177
+ <span class="admin-status-dot" style="background: {statusColor(mount.status)}" title={mount.status}></span>
178
+ <span class="admin-mount-name">{mount.label || mount.id}</span>
179
+ <span class="admin-mount-id">{mount.id}</span>
180
+ </div>
181
+ <span class="admin-mount-path" title={mount.path}>{mount.path}</span>
182
+ <span class="admin-mount-meta">{mount.attachmentCount} tenant(s) attached</span>
183
+ </div>
184
+ <div class="admin-mount-actions">
185
+ <button type="button" class="admin-btn-secondary" onclick={() => toggleBrowse(mount.id)}>
186
+ {browsingId === mount.id ? 'Close' : 'Browse'}
187
+ </button>
188
+ <button type="button" class="admin-btn-secondary" onclick={() => startEdit(mount)}>Edit</button>
189
+ <button type="button" class="admin-btn-danger" onclick={() => { deletingId = mount.id; }}>Delete</button>
190
+ </div>
191
+ {/if}
192
+ </div>
193
+
194
+ {#if browsingId === mount.id}
195
+ <div class="admin-mount-browse">
196
+ {#if browseError}
197
+ <p class="admin-error">{browseError}</p>
198
+ {:else if browseEntries.length === 0}
199
+ <p class="admin-muted">Empty directory</p>
200
+ {:else}
201
+ <ul class="admin-browse-list">
202
+ {#each browseEntries as entry (entry.name)}
203
+ <li class="admin-browse-item">
204
+ <span class="admin-browse-kind">{entry.kind === 'directory' ? '[_]' : '[ ]'}</span>
205
+ <span>{entry.name}</span>
206
+ {#if entry.size !== undefined}
207
+ <span class="admin-browse-size">{entry.size} B</span>
208
+ {/if}
209
+ </li>
210
+ {/each}
211
+ </ul>
212
+ {/if}
213
+ </div>
214
+ {/if}
215
+ </li>
216
+ {/each}
217
+ </ul>
218
+ {/if}
219
+
220
+ {#if deletingId}
221
+ <div class="admin-modal-root">
222
+ <button
223
+ type="button"
224
+ class="admin-modal-backdrop"
225
+ aria-label="Close dialog"
226
+ onclick={() => { deletingId = null; }}
227
+ ></button>
228
+ <div class="admin-modal" role="dialog" aria-modal="true" aria-labelledby="delete-mount-title">
229
+ <h3 id="delete-mount-title">Delete Mount</h3>
230
+ <p>This will remove the mount and all its tenant attachments. This cannot be undone.</p>
231
+ <div class="admin-modal-actions">
232
+ <button type="button" class="admin-btn-danger" onclick={() => deleteMount(deletingId!)}>Delete</button>
233
+ <button type="button" class="admin-btn-secondary" onclick={() => { deletingId = null; }}>Cancel</button>
234
+ </div>
235
+ </div>
236
+ </div>
237
+ {/if}
238
+ </div>
239
+
240
+ <style>
241
+ .admin-mounts { padding: 24px; font-family: system-ui, sans-serif; color: var(--sh3-fg); }
242
+ .admin-section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
243
+ .admin-section-header h2 { margin: 0; font-size: 18px; }
244
+ .admin-create-form, .admin-edit-form { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; max-width: 500px; }
245
+ .admin-input { padding: 8px 12px; background: var(--sh3-bg, #1a1a2e); color: var(--sh3-fg); border: 1px solid var(--sh3-border, #3a3a5c); border-radius: var(--sh3-radius, 6px); font-size: 13px; }
246
+ .admin-btn { font-weight: 600; font-size: 13px; }
247
+ .admin-btn:disabled { opacity: 0.6; cursor: not-allowed; }
248
+ .admin-btn-secondary { background: transparent; color: var(--sh3-fg-subtle); border: 1px solid var(--sh3-border); font-size: 12px; }
249
+ .admin-btn-danger { background: transparent; color: var(--sh3-error, #d32f2f); border: 1px solid var(--sh3-error, #d32f2f); font-size: 12px; }
250
+ .admin-mount-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
251
+ .admin-mount-item { padding: 12px 16px; background: var(--sh3-bg-elevated, #252540); border: 1px solid var(--sh3-border, #3a3a5c); border-radius: var(--sh3-radius, 6px); }
252
+ .admin-mount-main { display: flex; justify-content: space-between; align-items: flex-start; }
253
+ .admin-mount-info { display: flex; flex-direction: column; gap: 4px; flex: 1; }
254
+ .admin-mount-top { display: flex; align-items: center; gap: 8px; }
255
+ .admin-status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
256
+ .admin-mount-name { font-weight: 600; }
257
+ .admin-mount-id { font-size: 11px; color: var(--sh3-fg-subtle); font-family: monospace; }
258
+ .admin-mount-path { font-size: 12px; font-family: monospace; color: var(--sh3-fg-subtle); word-break: break-all; }
259
+ .admin-mount-meta { font-size: 11px; color: var(--sh3-fg-muted); }
260
+ .admin-mount-actions { display: flex; gap: 6px; flex-shrink: 0; margin-left: 16px; }
261
+ .admin-edit-actions { display: flex; gap: 6px; }
262
+ .admin-mount-browse { margin-top: 12px; padding: 12px; background: var(--sh3-bg, #1a1a2e); border-radius: var(--sh3-radius, 6px); max-height: 200px; overflow-y: auto; }
263
+ .admin-browse-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
264
+ .admin-browse-item { display: flex; gap: 8px; font-size: 12px; font-family: monospace; }
265
+ .admin-browse-kind { color: var(--sh3-fg-subtle); width: 24px; }
266
+ .admin-browse-size { color: var(--sh3-fg-muted); margin-left: auto; }
267
+ .admin-modal-root { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 100; }
268
+ .admin-modal-backdrop { position: absolute; inset: 0; background: rgba(0,0,0,0.6); border: none; padding: 0; margin: 0; cursor: pointer; }
269
+ .admin-modal { position: relative; background: var(--sh3-bg-elevated, #252540); border: 1px solid var(--sh3-border, #3a3a5c); border-radius: var(--sh3-radius, 8px); padding: 24px; max-width: 400px; width: 100%; }
270
+ .admin-modal h3 { margin: 0 0 8px; }
271
+ .admin-modal p { margin: 0 0 16px; font-size: 13px; color: var(--sh3-fg-subtle); }
272
+ .admin-modal-actions { display: flex; gap: 8px; justify-content: flex-end; }
273
+ .admin-error { color: var(--sh3-error, #d32f2f); font-size: 13px; }
274
+ .admin-warning { color: #ff9800; font-size: 13px; }
275
+ .admin-muted { color: var(--sh3-fg-muted); font-style: italic; }
276
+ </style>
@@ -0,0 +1,3 @@
1
+ declare const MountsView: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type MountsView = ReturnType<typeof MountsView>;
3
+ export default MountsView;
@@ -3,6 +3,8 @@
3
3
  * Admin System view — server status, restart, and package-bundle cache policy.
4
4
  */
5
5
 
6
+ import { apiFetch } from '../../transport/apiFetch';
7
+
6
8
  const SNAP_POINTS: Array<{ value: number; label: string }> = [
7
9
  { value: 0, label: 'Off (no-store)' },
8
10
  { value: 5, label: '5s (dev)' },
@@ -35,7 +37,7 @@
35
37
 
36
38
  async function fetchVersion() {
37
39
  try {
38
- const res = await fetch('/api/version');
40
+ const res = await apiFetch('/api/version');
39
41
  if (res.ok) {
40
42
  const body = await res.json();
41
43
  version = body.version;
@@ -45,7 +47,7 @@
45
47
 
46
48
  async function fetchSettings() {
47
49
  try {
48
- const res = await fetch('/api/admin/settings', { credentials: 'include' });
50
+ const res = await apiFetch('/api/admin/settings');
49
51
  if (res.ok) {
50
52
  const body = await res.json();
51
53
  const age = body.packages?.cacheMaxAge ?? 31536000;
@@ -59,9 +61,8 @@
59
61
  savingCache = true;
60
62
  cacheError = null;
61
63
  try {
62
- const res = await fetch('/api/admin/settings', {
64
+ const res = await apiFetch('/api/admin/settings', {
63
65
  method: 'PUT',
64
- credentials: 'include',
65
66
  headers: { 'Content-Type': 'application/json' },
66
67
  body: JSON.stringify({ packages: { cacheMaxAge } }),
67
68
  });
@@ -84,9 +85,8 @@
84
85
  restarting = true;
85
86
  restartError = null;
86
87
  try {
87
- const res = await fetch('/api/admin/restart', {
88
+ const res = await apiFetch('/api/admin/restart', {
88
89
  method: 'POST',
89
- credentials: 'include',
90
90
  });
91
91
  if (!res.ok) {
92
92
  const body = await res.json().catch(() => ({}));
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import type { AuthUser } from '../../auth/types';
7
+ import { apiFetch } from '../../transport/apiFetch';
7
8
 
8
9
  let users = $state<AuthUser[]>([]);
9
10
  let loading = $state(true);
@@ -23,11 +24,54 @@
23
24
  let editRole = $state<'admin' | 'user'>('user');
24
25
  let editPassword = $state('');
25
26
 
27
+ // Mount attachment state
28
+ let mountModalUser = $state<AuthUser | null>(null);
29
+ let allMounts = $state<{ id: string; label?: string; status: string }[]>([]);
30
+ let attachedMountIds = $state<Set<string>>(new Set());
31
+ let mountLoading = $state(false);
32
+
33
+ async function openMountModal(user: AuthUser) {
34
+ mountModalUser = user;
35
+ mountLoading = true;
36
+ try {
37
+ const [mountsRes, attRes] = await Promise.all([
38
+ apiFetch('/api/admin/mounts'),
39
+ apiFetch(`/api/admin/tenants/${user.id}/attachments`),
40
+ ]);
41
+ allMounts = mountsRes.ok ? await mountsRes.json() : [];
42
+ const atts: { mountId: string }[] = attRes.ok ? await attRes.json() : [];
43
+ attachedMountIds = new Set(atts.map(a => a.mountId));
44
+ } catch { /* ignore */ }
45
+ mountLoading = false;
46
+ }
47
+
48
+ async function toggleMountAttachment(mountId: string) {
49
+ if (!mountModalUser) return;
50
+ const wasAttached = attachedMountIds.has(mountId);
51
+ try {
52
+ if (wasAttached) {
53
+ await apiFetch('/api/admin/mount-attachments', {
54
+ method: 'DELETE',
55
+ headers: { 'Content-Type': 'application/json' },
56
+ body: JSON.stringify({ mountId, tenantId: mountModalUser.id }),
57
+ });
58
+ attachedMountIds = new Set([...attachedMountIds].filter(id => id !== mountId));
59
+ } else {
60
+ await apiFetch('/api/admin/mount-attachments', {
61
+ method: 'POST',
62
+ headers: { 'Content-Type': 'application/json' },
63
+ body: JSON.stringify({ mountId, tenantId: mountModalUser.id }),
64
+ });
65
+ attachedMountIds = new Set([...attachedMountIds, mountId]);
66
+ }
67
+ } catch { /* ignore */ }
68
+ }
69
+
26
70
  async function fetchUsers() {
27
71
  loading = true;
28
72
  error = null;
29
73
  try {
30
- const res = await fetch('/api/admin/users', { credentials: 'include' });
74
+ const res = await apiFetch('/api/admin/users');
31
75
  if (!res.ok) throw new Error('Failed to fetch users');
32
76
  users = await res.json();
33
77
  } catch (err) {
@@ -40,10 +84,9 @@
40
84
  async function createUser() {
41
85
  createError = null;
42
86
  try {
43
- const res = await fetch('/api/admin/users', {
87
+ const res = await apiFetch('/api/admin/users', {
44
88
  method: 'POST',
45
89
  headers: { 'Content-Type': 'application/json' },
46
- credentials: 'include',
47
90
  body: JSON.stringify({
48
91
  username: newUsername,
49
92
  displayName: newDisplayName || newUsername,
@@ -82,10 +125,9 @@
82
125
  };
83
126
  if (editPassword.trim()) patch.password = editPassword;
84
127
  try {
85
- const res = await fetch(`/api/admin/users/${editingId}`, {
128
+ const res = await apiFetch(`/api/admin/users/${editingId}`, {
86
129
  method: 'PUT',
87
130
  headers: { 'Content-Type': 'application/json' },
88
- credentials: 'include',
89
131
  body: JSON.stringify(patch),
90
132
  });
91
133
  if (!res.ok) return;
@@ -96,9 +138,8 @@
96
138
 
97
139
  async function deleteUser(id: string) {
98
140
  try {
99
- await fetch(`/api/admin/users/${id}`, {
141
+ await apiFetch(`/api/admin/users/${id}`, {
100
142
  method: 'DELETE',
101
- credentials: 'include',
102
143
  });
103
144
  await fetchUsers();
104
145
  } catch { /* ignore */ }
@@ -107,6 +148,8 @@
107
148
  fetchUsers();
108
149
  </script>
109
150
 
151
+ <svelte:window onkeydown={(e) => { if (e.key === 'Escape' && mountModalUser) mountModalUser = null; }} />
152
+
110
153
  <div class="admin-users">
111
154
  <div class="admin-users-header">
112
155
  <h2>Users</h2>
@@ -156,6 +199,7 @@
156
199
  <span class="admin-user-meta">{user.username} · {user.role}</span>
157
200
  </div>
158
201
  <div class="admin-user-actions">
202
+ <button type="button" class="admin-btn-secondary" onclick={() => openMountModal(user)}>Mounts</button>
159
203
  <button type="button" class="admin-btn-secondary" onclick={() => startEdit(user)}>Edit</button>
160
204
  <button type="button" class="admin-btn-danger" onclick={() => deleteUser(user.id)}>Delete</button>
161
205
  </div>
@@ -164,6 +208,46 @@
164
208
  {/each}
165
209
  </ul>
166
210
  {/if}
211
+
212
+ {#if mountModalUser}
213
+ <div class="admin-modal-root">
214
+ <button
215
+ type="button"
216
+ class="admin-modal-backdrop"
217
+ aria-label="Close dialog"
218
+ onclick={() => { mountModalUser = null; }}
219
+ ></button>
220
+ <div class="admin-modal" role="dialog" aria-modal="true" aria-labelledby="mount-modal-title">
221
+ <h3 id="mount-modal-title">Mount Attachments for {mountModalUser.displayName}</h3>
222
+ {#if mountLoading}
223
+ <p class="admin-muted">Loading...</p>
224
+ {:else if allMounts.length === 0}
225
+ <p class="admin-muted">No mounts configured. Create mounts first.</p>
226
+ {:else}
227
+ <ul class="admin-mount-checklist">
228
+ {#each allMounts as mount (mount.id)}
229
+ <li class="admin-mount-checklist-item">
230
+ <label class="admin-checklist-label">
231
+ <input
232
+ class="sh3-base-check"
233
+ type="checkbox"
234
+ checked={attachedMountIds.has(mount.id)}
235
+ onchange={() => toggleMountAttachment(mount.id)}
236
+ />
237
+ <span class="admin-status-dot" style="background: {mount.status === 'resolved' ? '#4caf50' : mount.status === 'unresolved' ? '#ff9800' : '#f44336'}"></span>
238
+ <span>{mount.label || mount.id}</span>
239
+ <span class="admin-mount-id">{mount.id}</span>
240
+ </label>
241
+ </li>
242
+ {/each}
243
+ </ul>
244
+ {/if}
245
+ <div class="admin-modal-actions">
246
+ <button type="button" class="admin-btn" onclick={() => { mountModalUser = null; }}>Close</button>
247
+ </div>
248
+ </div>
249
+ </div>
250
+ {/if}
167
251
  </div>
168
252
 
169
253
  <style>
@@ -185,4 +269,16 @@
185
269
  .admin-edit-actions { display: flex; gap: 6px; }
186
270
  .admin-error { color: var(--sh3-error, #d32f2f); font-size: 13px; }
187
271
  .admin-muted { color: var(--sh3-fg-muted); font-style: italic; }
272
+ .admin-mount-checklist { list-style: none; margin: 0 0 16px; padding: 0; display: flex; flex-direction: column; gap: 4px; }
273
+ .admin-mount-checklist-item { padding: 8px 0; border-bottom: 1px solid var(--sh3-border, #3a3a5c); }
274
+ .admin-checklist-label { display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 13px; }
275
+ .admin-checklist-label input[type="checkbox"] { cursor: pointer; }
276
+ .admin-status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
277
+ .admin-modal-root { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 100; }
278
+ .admin-modal-backdrop { position: absolute; inset: 0; background: rgba(0,0,0,0.6); border: none; padding: 0; margin: 0; cursor: pointer; }
279
+ .admin-modal { position: relative; background: var(--sh3-bg-elevated, #252540); border: 1px solid var(--sh3-border, #3a3a5c); border-radius: var(--sh3-radius, 8px); padding: 24px; max-width: 400px; width: 100%; }
280
+ .admin-modal h3 { margin: 0 0 8px; }
281
+ .admin-modal p { margin: 0 0 16px; font-size: 13px; color: var(--sh3-fg-subtle); }
282
+ .admin-modal-actions { display: flex; gap: 8px; justify-content: flex-end; }
283
+ .admin-mount-id { font-size: 11px; color: var(--sh3-fg-subtle); font-family: monospace; margin-left: auto; }
188
284
  </style>
@@ -21,6 +21,7 @@ export const adminApp = {
21
21
  { slotId: 'admin.auth', viewId: 'sh3-admin:auth', label: 'Auth' },
22
22
  { slotId: 'admin.system', viewId: 'sh3-admin:system', label: 'System' },
23
23
  { slotId: 'admin.keys', viewId: 'sh3-admin:keys', label: 'Keys' },
24
+ { slotId: 'admin.mounts', viewId: 'sh3-admin:mounts', label: 'Mounts' },
24
25
  ],
25
26
  },
26
27
  };
@@ -15,6 +15,7 @@ import UsersView from './UsersView.svelte';
15
15
  import AuthSettingsView from './AuthSettingsView.svelte';
16
16
  import SystemView from './SystemView.svelte';
17
17
  import ApiKeysView from './ApiKeysView.svelte';
18
+ import MountsView from './MountsView.svelte';
18
19
  import { VERSION } from '../../version';
19
20
  /** Module-level server URL, set during activate. */
20
21
  export let adminServerUrl = '';
@@ -23,11 +24,13 @@ export const adminShard = {
23
24
  id: 'sh3-admin',
24
25
  label: 'Admin',
25
26
  version: VERSION,
27
+ permissions: ['documents:mount'],
26
28
  views: [
27
29
  { id: 'sh3-admin:users', label: 'Users' },
28
30
  { id: 'sh3-admin:auth', label: 'Auth Settings' },
29
31
  { id: 'sh3-admin:system', label: 'System' },
30
32
  { id: 'sh3-admin:keys', label: 'API Keys' },
33
+ { id: 'sh3-admin:mounts', label: 'Mounts' },
31
34
  ],
32
35
  },
33
36
  activate(ctx) {
@@ -55,9 +58,16 @@ export const adminShard = {
55
58
  return { unmount() { unmount(instance); } };
56
59
  },
57
60
  };
61
+ const mountsFactory = {
62
+ mount(container, _context) {
63
+ const instance = mount(MountsView, { target: container });
64
+ return { unmount() { unmount(instance); } };
65
+ },
66
+ };
58
67
  ctx.registerView('sh3-admin:users', usersFactory);
59
68
  ctx.registerView('sh3-admin:auth', authFactory);
60
69
  ctx.registerView('sh3-admin:system', systemFactory);
61
70
  ctx.registerView('sh3-admin:keys', keysFactory);
71
+ ctx.registerView('sh3-admin:mounts', mountsFactory);
62
72
  },
63
73
  };
@@ -69,6 +69,7 @@ function getOrCreateAppContext(appId, scopeId, args) {
69
69
  const scope = scopeId !== null && scopeId !== void 0 ? scopeId : resolveLaunchScope();
70
70
  ctx = {
71
71
  scopeId: scope,
72
+ getScope: () => sessionState.activeProjectId ? 'project' : 'tenant',
72
73
  args: args !== null && args !== void 0 ? args : {},
73
74
  state: (schema) => createStateZones(`__app__:${appId}:scope:${scope}`, schema),
74
75
  zones: ((_a = app === null || app === void 0 ? void 0 : app.manifest.permissions) === null || _a === void 0 ? void 0 : _a.includes(PERMISSION_STATE_MANAGE))
@@ -96,6 +96,13 @@ export interface AppContext {
96
96
  * scope and cannot reach across scopes — exiting a scope unloads the app.
97
97
  */
98
98
  scopeId: string;
99
+ /**
100
+ * Whether this app instance is running in a 'tenant' (personal) or 'project'
101
+ * scope. Determined at launch time from session state; the app's document
102
+ * handles and state zones are bound to this scope for their lifetime.
103
+ * Returns 'tenant' when no project is active, 'project' otherwise.
104
+ */
105
+ getScope(): 'tenant' | 'project';
99
106
  /**
100
107
  * Arguments supplied by the caller at launch time via `LaunchAppOptions.args`.
101
108
  * Defaults to `{}` when the app is launched without explicit args.
@@ -1,2 +1,2 @@
1
- export declare const ICON_IDS: readonly ["activity", "align-horizontal-justify-center", "align-horizontal-justify-end", "align-horizontal-justify-start", "app-window", "archive", "archive-restore", "axis-3d", "box", "brick-wall", "bug", "building-2", "cable", "calendar", "camera", "check", "chevron-down", "chevron-right", "circle-check", "circle-dot", "circle-minus", "circle-x", "clipboard", "clipboard-paste", "clock", "command", "compass", "component", "copy", "cpu", "crop", "crosshair", "crown", "dollar-sign", "download", "droplet", "ellipsis-vertical", "eraser", "euro", "external-link", "eye", "eye-off", "file", "file-archive", "file-diff", "file-plus", "file-text", "flame", "flip-horizontal-2", "flip-vertical-2", "folder", "folder-open", "folder-plus", "folder-tree", "gallery-vertical-end", "gamepad-2", "gauge", "gem", "git-branch", "git-commit-horizontal", "git-merge", "globe", "grid-2x2", "grid-3x3", "group", "hard-drive", "heart", "history", "house", "image", "info", "joystick", "key", "layers", "layout-dashboard", "layout-grid", "layout-list", "layout-panel-left", "layout-panel-top", "layout-template", "lightbulb", "link", "list-ordered", "list-tree", "lock", "log-out", "magnet", "mail", "map", "maximize", "menu", "minimize", "moon", "mouse-pointer", "move", "move-3d", "music", "navigation", "network", "notebook-pen", "palette", "panel-right", "panel-top", "pause", "pencil", "pipette", "play", "plus", "pointer", "pound-sterling", "receipt", "redo-2", "refresh-cw", "rocket", "rotate-3d", "rotate-ccw", "rotate-cw", "ruler", "save", "scissors", "scroll-text", "search", "send", "server", "settings", "shield", "skull", "sliders-horizontal", "snowflake", "sparkles", "square", "square-terminal", "star", "sun", "sword", "table-properties", "target", "texture", "timer", "trash-2", "triangle-alert", "type", "undo-2", "ungroup", "unity", "upload", "user", "users", "video", "volume-2", "wand-sparkles", "wind", "x", "zap", "zoom-in", "zoom-out"];
1
+ export declare const ICON_IDS: readonly ["activity", "align-horizontal-justify-center", "align-horizontal-justify-end", "align-horizontal-justify-start", "app-window", "archive", "archive-restore", "axis-3d", "box", "brick-wall", "bug", "building-2", "cable", "calendar", "camera", "check", "chevron-down", "chevron-left", "chevron-right", "circle-check", "circle-dot", "circle-minus", "circle-x", "clipboard", "clipboard-paste", "clock", "command", "compass", "component", "copy", "cpu", "crop", "crosshair", "crown", "dollar-sign", "download", "droplet", "ellipsis-vertical", "eraser", "euro", "external-link", "eye", "eye-off", "file", "file-archive", "file-diff", "file-plus", "file-text", "flame", "flip-horizontal-2", "flip-vertical-2", "folder", "folder-open", "folder-plus", "folder-tree", "gallery-vertical-end", "gamepad-2", "gauge", "gem", "git-branch", "git-commit-horizontal", "git-merge", "globe", "grid-2x2", "grid-3x3", "group", "hard-drive", "heart", "history", "house", "image", "info", "joystick", "key", "layers", "layout-dashboard", "layout-grid", "layout-list", "layout-panel-left", "layout-panel-top", "layout-template", "lightbulb", "link", "list-ordered", "list-tree", "lock", "log-out", "magnet", "mail", "map", "maximize", "menu", "minimize", "moon", "mouse-pointer", "move", "move-3d", "music", "navigation", "network", "notebook-pen", "palette", "panel-right", "panel-top", "pause", "pencil", "pipette", "play", "plus", "pointer", "pound-sterling", "receipt", "redo-2", "refresh-cw", "rocket", "rotate-3d", "rotate-ccw", "rotate-cw", "ruler", "save", "scissors", "scroll-text", "search", "send", "server", "settings", "shield", "skull", "sliders-horizontal", "snowflake", "sparkles", "square", "square-terminal", "star", "sun", "sword", "table-properties", "target", "texture", "timer", "trash-2", "triangle-alert", "type", "undo-2", "ungroup", "unity", "upload", "user", "users", "video", "volume-2", "wand-sparkles", "wind", "x", "zap", "zoom-in", "zoom-out"];
2
2
  export type IconId = (typeof ICON_IDS)[number];
@@ -17,6 +17,7 @@ export const ICON_IDS = [
17
17
  'camera',
18
18
  'check',
19
19
  'chevron-down',
20
+ 'chevron-left',
20
21
  'chevron-right',
21
22
  'circle-check',
22
23
  'circle-dot',