sh3-core 0.13.1 → 0.13.2

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 (119) hide show
  1. package/dist/BrandSlot.svelte +62 -13
  2. package/dist/__test__/setup-dom.js +5 -0
  3. package/dist/api.d.ts +3 -0
  4. package/dist/api.js +3 -0
  5. package/dist/apps/lifecycle.js +10 -2
  6. package/dist/apps/types.d.ts +11 -4
  7. package/dist/apps/workspace-rekey.d.ts +1 -0
  8. package/dist/apps/workspace-rekey.js +35 -0
  9. package/dist/apps/workspace-rekey.test.js +23 -0
  10. package/dist/auth/admin-users.svelte.d.ts +9 -0
  11. package/dist/auth/admin-users.svelte.js +42 -0
  12. package/dist/auth/admin-users.test.d.ts +1 -0
  13. package/dist/auth/admin-users.test.js +52 -0
  14. package/dist/createShell.js +5 -5
  15. package/dist/documents/config.d.ts +5 -1
  16. package/dist/documents/config.js +16 -8
  17. package/dist/documents/index.d.ts +1 -1
  18. package/dist/documents/index.js +1 -1
  19. package/dist/host-entry.d.ts +1 -1
  20. package/dist/host-entry.js +1 -1
  21. package/dist/host.d.ts +1 -1
  22. package/dist/host.js +8 -2
  23. package/dist/primitives/Button.svelte +50 -4
  24. package/dist/primitives/Button.svelte.d.ts +3 -1
  25. package/dist/primitives/Collapsible.svelte +110 -0
  26. package/dist/primitives/Collapsible.svelte.d.ts +14 -0
  27. package/dist/primitives/widgets/AppPicker.svelte +41 -0
  28. package/dist/primitives/widgets/AppPicker.svelte.d.ts +9 -0
  29. package/dist/primitives/widgets/AppPicker.svelte.test.d.ts +1 -0
  30. package/dist/primitives/widgets/AppPicker.svelte.test.js +26 -0
  31. package/dist/primitives/widgets/AppPicker.test.d.ts +1 -0
  32. package/dist/primitives/widgets/AppPicker.test.js +74 -0
  33. package/dist/primitives/widgets/ColorSwatch.svelte +7 -2
  34. package/dist/primitives/widgets/ColorSwatch.svelte.d.ts +2 -1
  35. package/dist/primitives/widgets/ColorSwatch.svelte.test.d.ts +1 -0
  36. package/dist/primitives/widgets/ColorSwatch.svelte.test.js +31 -0
  37. package/dist/primitives/widgets/Field.svelte +4 -2
  38. package/dist/primitives/widgets/Field.svelte.d.ts +2 -2
  39. package/dist/primitives/widgets/Field.svelte.test.d.ts +1 -0
  40. package/dist/primitives/widgets/Field.svelte.test.js +33 -0
  41. package/dist/primitives/widgets/FilePicker.svelte +2 -2
  42. package/dist/primitives/widgets/FilePicker.svelte.d.ts +2 -2
  43. package/dist/primitives/widgets/FilePicker.svelte.test.d.ts +1 -0
  44. package/dist/primitives/widgets/FilePicker.svelte.test.js +31 -0
  45. package/dist/primitives/widgets/IconToggleGroup.svelte +4 -4
  46. package/dist/primitives/widgets/IconToggleGroup.svelte.d.ts +3 -3
  47. package/dist/primitives/widgets/IconToggleGroup.svelte.test.d.ts +1 -0
  48. package/dist/primitives/widgets/IconToggleGroup.svelte.test.js +40 -0
  49. package/dist/primitives/widgets/NumberInput.svelte +19 -9
  50. package/dist/primitives/widgets/NumberInput.svelte.d.ts +2 -2
  51. package/dist/primitives/widgets/NumberInput.svelte.test.d.ts +1 -0
  52. package/dist/primitives/widgets/NumberInput.svelte.test.js +48 -0
  53. package/dist/primitives/widgets/PickerList.d.ts +24 -0
  54. package/dist/primitives/widgets/PickerList.js +21 -0
  55. package/dist/primitives/widgets/PickerList.svelte +150 -0
  56. package/dist/primitives/widgets/PickerList.svelte.d.ts +16 -0
  57. package/dist/primitives/widgets/PickerList.svelte.test.d.ts +1 -0
  58. package/dist/primitives/widgets/PickerList.svelte.test.js +31 -0
  59. package/dist/primitives/widgets/PickerList.test.d.ts +1 -0
  60. package/dist/primitives/widgets/PickerList.test.js +218 -0
  61. package/dist/primitives/widgets/RangeSlider.svelte +11 -4
  62. package/dist/primitives/widgets/RangeSlider.svelte.d.ts +2 -2
  63. package/dist/primitives/widgets/RangeSlider.svelte.test.d.ts +1 -0
  64. package/dist/primitives/widgets/RangeSlider.svelte.test.js +38 -0
  65. package/dist/primitives/widgets/Segmented.svelte +4 -4
  66. package/dist/primitives/widgets/Segmented.svelte.d.ts +3 -3
  67. package/dist/primitives/widgets/Segmented.svelte.test.d.ts +1 -0
  68. package/dist/primitives/widgets/Segmented.svelte.test.js +25 -0
  69. package/dist/primitives/widgets/Select.svelte +4 -4
  70. package/dist/primitives/widgets/Select.svelte.d.ts +3 -3
  71. package/dist/primitives/widgets/Select.svelte.test.d.ts +1 -0
  72. package/dist/primitives/widgets/Select.svelte.test.js +37 -0
  73. package/dist/primitives/widgets/Slider.svelte +4 -2
  74. package/dist/primitives/widgets/Slider.svelte.d.ts +2 -2
  75. package/dist/primitives/widgets/Slider.svelte.test.d.ts +1 -0
  76. package/dist/primitives/widgets/Slider.svelte.test.js +22 -0
  77. package/dist/primitives/widgets/SliderGroup.svelte +4 -2
  78. package/dist/primitives/widgets/SliderGroup.svelte.d.ts +2 -2
  79. package/dist/primitives/widgets/SliderGroup.svelte.test.d.ts +1 -0
  80. package/dist/primitives/widgets/SliderGroup.svelte.test.js +34 -0
  81. package/dist/primitives/widgets/Textarea.svelte +5 -2
  82. package/dist/primitives/widgets/Textarea.svelte.d.ts +2 -2
  83. package/dist/primitives/widgets/Textarea.svelte.test.d.ts +1 -0
  84. package/dist/primitives/widgets/Textarea.svelte.test.js +29 -0
  85. package/dist/primitives/widgets/UserPicker.svelte +53 -0
  86. package/dist/primitives/widgets/UserPicker.svelte.d.ts +9 -0
  87. package/dist/primitives/widgets/UserPicker.svelte.test.d.ts +1 -0
  88. package/dist/primitives/widgets/UserPicker.svelte.test.js +30 -0
  89. package/dist/primitives/widgets/UserPicker.test.d.ts +1 -0
  90. package/dist/primitives/widgets/UserPicker.test.js +115 -0
  91. package/dist/primitives/widgets/_contract.d.ts +27 -0
  92. package/dist/primitives/widgets/_contract.js +10 -0
  93. package/dist/projects/session-state.svelte.d.ts +17 -0
  94. package/dist/projects/session-state.svelte.js +39 -0
  95. package/dist/projects/session-state.test.d.ts +1 -0
  96. package/dist/projects/session-state.test.js +55 -0
  97. package/dist/projects-shard/DeleteProjectDialog.svelte +150 -0
  98. package/dist/projects-shard/DeleteProjectDialog.svelte.d.ts +12 -0
  99. package/dist/projects-shard/DeleteProjectDialog.test.d.ts +1 -0
  100. package/dist/projects-shard/DeleteProjectDialog.test.js +120 -0
  101. package/dist/projects-shard/ProjectManage.svelte +209 -0
  102. package/dist/projects-shard/ProjectManage.svelte.d.ts +8 -0
  103. package/dist/projects-shard/ProjectsSection.svelte +120 -0
  104. package/dist/projects-shard/ProjectsSection.svelte.d.ts +3 -0
  105. package/dist/projects-shard/index.d.ts +4 -0
  106. package/dist/projects-shard/index.js +4 -0
  107. package/dist/projects-shard/projectsApi.d.ts +20 -0
  108. package/dist/projects-shard/projectsApi.js +44 -0
  109. package/dist/projects-shard/projectsApi.test.d.ts +1 -0
  110. package/dist/projects-shard/projectsApi.test.js +71 -0
  111. package/dist/projects-shard/projectsShard.svelte.d.ts +10 -0
  112. package/dist/projects-shard/projectsShard.svelte.js +148 -0
  113. package/dist/sh3core-shard/ShellHome.svelte +19 -1
  114. package/dist/shards/activate-scopeid.test.d.ts +1 -0
  115. package/dist/shards/{activate-tenantid.test.js → activate-scopeid.test.js} +6 -6
  116. package/dist/version.d.ts +1 -1
  117. package/dist/version.js +1 -1
  118. package/package.json +1 -1
  119. /package/dist/{shards/activate-tenantid.test.d.ts → apps/workspace-rekey.test.d.ts} +0 -0
@@ -0,0 +1,209 @@
1
+ <script lang="ts">
2
+ /*
3
+ * Admin-only float view for creating, editing, or deleting a project.
4
+ *
5
+ * Members and app allowlist are picked from live data via UserPicker
6
+ * (over /api/admin/users) and AppPicker (over the client-side app
7
+ * registry). The current user is pre-selected on the create path so
8
+ * "include myself" works without looking up the underlying user id.
9
+ */
10
+
11
+ import { projectsApi, type ProjectRecord } from './projectsApi';
12
+ import { refreshProjects } from './projectsShard.svelte';
13
+ import { getUser } from '../auth/auth.svelte';
14
+ import AppPicker from '../primitives/widgets/AppPicker.svelte';
15
+ import UserPicker from '../primitives/widgets/UserPicker.svelte';
16
+ import { modalManager } from '../overlays/modal';
17
+ import DeleteProjectDialog from './DeleteProjectDialog.svelte';
18
+
19
+ interface Props {
20
+ project?: ProjectRecord | null;
21
+ onClose: () => void;
22
+ }
23
+
24
+ let { project = null, onClose }: Props = $props();
25
+
26
+ const isEdit = $derived(project !== null);
27
+ const me = $derived(getUser());
28
+
29
+ let name = $state(project?.name ?? '');
30
+ let description = $state(project?.description ?? '');
31
+ let members = $state<string[]>(
32
+ project ? [...project.members] : (me?.id ? [me.id] : []),
33
+ );
34
+ let appAllowlist = $state<string[]>(project ? [...project.appAllowlist] : []);
35
+ let saving = $state(false);
36
+ let error = $state<string | null>(null);
37
+
38
+ async function save() {
39
+ if (!name.trim()) {
40
+ error = 'Name is required';
41
+ return;
42
+ }
43
+ saving = true;
44
+ error = null;
45
+ const payload = {
46
+ name: name.trim(),
47
+ description: description.trim() || undefined,
48
+ members,
49
+ appAllowlist,
50
+ };
51
+ try {
52
+ if (isEdit && project) {
53
+ await projectsApi.update(project.id, payload);
54
+ } else {
55
+ await projectsApi.create(payload);
56
+ }
57
+ await refreshProjects();
58
+ onClose();
59
+ } catch (e) {
60
+ error = (e as Error).message;
61
+ } finally {
62
+ saving = false;
63
+ }
64
+ }
65
+
66
+ function remove() {
67
+ if (!isEdit || !project) return;
68
+ const target = project;
69
+ modalManager.open(DeleteProjectDialog, {
70
+ projectName: target.name,
71
+ projectId: target.id,
72
+ onConfirm: async ({ wipeData }: { wipeData: boolean }) => {
73
+ saving = true;
74
+ error = null;
75
+ try {
76
+ await projectsApi.delete(target.id, { wipeData });
77
+ await refreshProjects();
78
+ onClose();
79
+ } catch (e) {
80
+ error = (e as Error).message;
81
+ } finally {
82
+ saving = false;
83
+ }
84
+ },
85
+ });
86
+ }
87
+ </script>
88
+
89
+ <div class="project-manage">
90
+ <div class="body">
91
+ <h2>{isEdit ? `Edit ${project!.name}` : 'Create Project'}</h2>
92
+
93
+ {#if isEdit}
94
+ <p class="project-id">ID: <code>{project!.id}</code></p>
95
+ {/if}
96
+
97
+ <label class="field">
98
+ <span>Name</span>
99
+ <input type="text" bind:value={name} placeholder="Acme Website" disabled={saving} />
100
+ </label>
101
+
102
+ <label class="field">
103
+ <span>Description</span>
104
+ <textarea bind:value={description} rows={2} disabled={saving}></textarea>
105
+ </label>
106
+
107
+ <label class="field">
108
+ <span>Members</span>
109
+ <UserPicker bind:value={members} disabled={saving} />
110
+ {#if me}
111
+ <span class="hint">
112
+ Your id: <code>{me.id}</code> ({me.username ?? me.displayName})
113
+ </span>
114
+ {/if}
115
+ </label>
116
+
117
+ <label class="field">
118
+ <span>App allowlist</span>
119
+ <AppPicker bind:value={appAllowlist} disabled={saving} />
120
+ </label>
121
+
122
+ {#if error}
123
+ <p class="error">{error}</p>
124
+ {/if}
125
+ </div>
126
+
127
+ <div class="actions">
128
+ <button type="button" class="primary" onclick={save} disabled={saving}>
129
+ {isEdit ? 'Save' : 'Create'}
130
+ </button>
131
+ <button type="button" onclick={onClose} disabled={saving}>Cancel</button>
132
+ {#if isEdit}
133
+ <button type="button" class="danger" onclick={remove} disabled={saving}>Delete</button>
134
+ {/if}
135
+ </div>
136
+ </div>
137
+
138
+ <style>
139
+ .project-manage {
140
+ position: absolute;
141
+ inset: 0;
142
+ display: flex;
143
+ flex-direction: column;
144
+ font: inherit;
145
+ color: var(--shell-fg);
146
+ background: var(--shell-bg);
147
+ }
148
+ .body {
149
+ flex: 1;
150
+ overflow-y: auto;
151
+ padding: 16px 16px 8px;
152
+ }
153
+ h2 { margin: 0 0 8px; font-size: 16px; }
154
+ .project-id { font-size: 12px; color: var(--shell-fg-muted); margin: 0 0 16px; }
155
+ .project-id code { font-family: var(--shell-font-mono, monospace); }
156
+ .field {
157
+ display: flex;
158
+ flex-direction: column;
159
+ gap: 4px;
160
+ margin-bottom: 12px;
161
+ font-size: 13px;
162
+ }
163
+ .field span { color: var(--shell-fg-muted); }
164
+ .hint { font-size: 11px; color: var(--shell-fg-muted); }
165
+ .hint code {
166
+ font-family: var(--shell-font-mono, monospace);
167
+ color: var(--shell-fg);
168
+ background: var(--shell-bg-elevated);
169
+ padding: 0 4px;
170
+ border-radius: var(--shell-radius-sm, 3px);
171
+ }
172
+ .field input,
173
+ .field textarea {
174
+ background: var(--shell-bg-elevated);
175
+ color: var(--shell-fg);
176
+ border: 1px solid var(--shell-border);
177
+ border-radius: var(--shell-radius-sm, 3px);
178
+ padding: 6px 8px;
179
+ font: inherit;
180
+ font-size: 13px;
181
+ }
182
+ .field textarea { resize: vertical; min-height: 60px; }
183
+ .error { color: var(--shell-error, #c33); font-size: 13px; margin: 0 0 8px; }
184
+ .actions {
185
+ display: flex;
186
+ gap: 8px;
187
+ padding: 12px 16px;
188
+ border-top: 1px solid var(--shell-border);
189
+ background: var(--shell-bg);
190
+ flex: 0 0 auto;
191
+ }
192
+ .actions button {
193
+ background: var(--shell-bg-elevated);
194
+ color: var(--shell-fg);
195
+ border: 1px solid var(--shell-border);
196
+ border-radius: var(--shell-radius-sm, 3px);
197
+ padding: 6px 14px;
198
+ font: inherit;
199
+ cursor: pointer;
200
+ }
201
+ .actions button:hover { border-color: var(--shell-accent); }
202
+ .actions button.primary {
203
+ background: var(--shell-accent);
204
+ color: #fff;
205
+ border-color: var(--shell-accent);
206
+ }
207
+ .actions button.danger { margin-left: auto; color: var(--shell-error, #c33); }
208
+ .actions button:disabled { opacity: 0.5; cursor: not-allowed; }
209
+ </style>
@@ -0,0 +1,8 @@
1
+ import { type ProjectRecord } from './projectsApi';
2
+ interface Props {
3
+ project?: ProjectRecord | null;
4
+ onClose: () => void;
5
+ }
6
+ declare const ProjectManage: import("svelte").Component<Props, {}, "">;
7
+ type ProjectManage = ReturnType<typeof ProjectManage>;
8
+ export default ProjectManage;
@@ -0,0 +1,120 @@
1
+ <script lang="ts">
2
+ /*
3
+ * Projects section for ShellHome.
4
+ *
5
+ * Renders the list of projects the current user is a member of as
6
+ * selectable cards. Selecting a project sets sessionState.activeProjectId
7
+ * (which then filters the apps grid via the appAllowlist) and binds any
8
+ * subsequently launched app's documents to the project scope.
9
+ */
10
+
11
+ import { projectsState, openProjectManage } from './projectsShard.svelte';
12
+ import { sessionState, setActiveProjectId } from '../projects/session-state.svelte';
13
+ import { isAdmin } from '../auth/auth.svelte';
14
+
15
+ const visible = $derived(projectsState.projects.length > 0);
16
+ const activeId = $derived(sessionState.activeProjectId);
17
+ const elevated = $derived(isAdmin());
18
+
19
+ function selectProject(id: string) {
20
+ setActiveProjectId(activeId === id ? null : id);
21
+ }
22
+
23
+ function editProject(id: string, ev: MouseEvent) {
24
+ ev.stopPropagation();
25
+ const project = projectsState.projects.find((p) => p.id === id) ?? null;
26
+ if (project) openProjectManage(project);
27
+ }
28
+ </script>
29
+
30
+ {#if visible}
31
+ <section class="projects-section">
32
+ <h2 class="projects-heading">Projects</h2>
33
+ <div class="projects-grid">
34
+ {#each projectsState.projects as project (project.id)}
35
+ <div class="project-card-wrap">
36
+ <button
37
+ type="button"
38
+ class="project-card"
39
+ class:active={activeId === project.id}
40
+ onclick={() => selectProject(project.id)}
41
+ title={project.description ?? `${project.members.length} member${project.members.length === 1 ? '' : 's'}`}
42
+ >
43
+ <span class="project-name">{project.name}</span>
44
+ <span class="project-meta">{project.members.length} member{project.members.length === 1 ? '' : 's'}</span>
45
+ </button>
46
+ {#if elevated}
47
+ <button
48
+ type="button"
49
+ class="project-card-edit"
50
+ onclick={(ev) => editProject(project.id, ev)}
51
+ aria-label="Edit project"
52
+ title="Edit project"
53
+ >⚙</button>
54
+ {/if}
55
+ </div>
56
+ {/each}
57
+ </div>
58
+ </section>
59
+ {/if}
60
+
61
+ <style>
62
+ .projects-section {
63
+ width: 100%;
64
+ max-width: 720px;
65
+ margin-bottom: 28px;
66
+ }
67
+ .projects-heading {
68
+ font-size: 13px;
69
+ font-weight: 600;
70
+ text-transform: uppercase;
71
+ letter-spacing: 0.06em;
72
+ color: var(--shell-fg-subtle);
73
+ margin: 0 0 12px;
74
+ }
75
+ .projects-grid {
76
+ display: grid;
77
+ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
78
+ gap: 10px;
79
+ }
80
+ .project-card-wrap { position: relative; }
81
+ .project-card {
82
+ display: flex;
83
+ flex-direction: column;
84
+ align-items: flex-start;
85
+ gap: 4px;
86
+ padding: 12px 14px;
87
+ background: var(--shell-grad-bg-elevated, var(--shell-bg-elevated));
88
+ border: 1px solid var(--shell-border);
89
+ border-radius: var(--shell-radius-md);
90
+ cursor: pointer;
91
+ text-align: left;
92
+ color: inherit;
93
+ font: inherit;
94
+ transition: border-color 120ms ease, transform 120ms ease;
95
+ width: 100%;
96
+ }
97
+ .project-card-edit {
98
+ position: absolute;
99
+ top: 6px;
100
+ right: 6px;
101
+ background: transparent;
102
+ border: 0;
103
+ color: var(--shell-fg-muted);
104
+ cursor: pointer;
105
+ padding: 2px 6px;
106
+ font-size: 14px;
107
+ border-radius: var(--shell-radius-sm, 3px);
108
+ }
109
+ .project-card-edit:hover { color: var(--shell-fg); background: var(--shell-bg-elevated); }
110
+ .project-card:hover {
111
+ border-color: var(--shell-accent);
112
+ transform: translateY(-1px);
113
+ }
114
+ .project-card.active {
115
+ border-color: var(--shell-accent);
116
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--shell-accent) 40%, transparent);
117
+ }
118
+ .project-name { font-weight: 600; font-size: 13px; }
119
+ .project-meta { font-size: 11px; color: var(--shell-fg-muted); }
120
+ </style>
@@ -0,0 +1,3 @@
1
+ declare const ProjectsSection: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type ProjectsSection = ReturnType<typeof ProjectsSection>;
3
+ export default ProjectsSection;
@@ -0,0 +1,4 @@
1
+ export { projectsShard, projectsState, refreshProjects, openProjectManage, } from './projectsShard.svelte';
2
+ export { projectsApi, type ProjectRecord } from './projectsApi';
3
+ export { default as ProjectsSection } from './ProjectsSection.svelte';
4
+ export { default as ProjectManage } from './ProjectManage.svelte';
@@ -0,0 +1,4 @@
1
+ export { projectsShard, projectsState, refreshProjects, openProjectManage, } from './projectsShard.svelte';
2
+ export { projectsApi } from './projectsApi';
3
+ export { default as ProjectsSection } from './ProjectsSection.svelte';
4
+ export { default as ProjectManage } from './ProjectManage.svelte';
@@ -0,0 +1,20 @@
1
+ export interface ProjectRecord {
2
+ id: string;
3
+ name: string;
4
+ description?: string;
5
+ members: string[];
6
+ appAllowlist: string[];
7
+ createdBy: string;
8
+ createdAt: number;
9
+ updatedAt: number;
10
+ }
11
+ export declare const projectsApi: {
12
+ list(): Promise<ProjectRecord[]>;
13
+ listAll(): Promise<ProjectRecord[]>;
14
+ get(id: string): Promise<ProjectRecord>;
15
+ create(input: Pick<ProjectRecord, "name" | "description" | "members" | "appAllowlist">): Promise<ProjectRecord>;
16
+ update(id: string, patch: Partial<Pick<ProjectRecord, "name" | "description" | "members" | "appAllowlist">>): Promise<ProjectRecord>;
17
+ delete(id: string, opts?: {
18
+ wipeData?: boolean;
19
+ }): Promise<void>;
20
+ };
@@ -0,0 +1,44 @@
1
+ /*
2
+ * Client wrapper for the /api/projects HTTP surface.
3
+ *
4
+ * Returns plain JSON objects; the shard layer is responsible for keeping
5
+ * a reactive copy and for converting these to actions / views.
6
+ */
7
+ async function jsonFetch(url, init) {
8
+ var _a;
9
+ const res = await fetch(url, Object.assign({ credentials: 'include' }, init));
10
+ if (!res.ok)
11
+ throw new Error(`${(_a = init === null || init === void 0 ? void 0 : init.method) !== null && _a !== void 0 ? _a : 'GET'} ${url} failed: ${res.status}`);
12
+ return res.json();
13
+ }
14
+ export const projectsApi = {
15
+ async list() {
16
+ const body = await jsonFetch('/api/projects', { method: 'GET' });
17
+ return body.projects;
18
+ },
19
+ async listAll() {
20
+ const body = await jsonFetch('/api/projects/all', { method: 'GET' });
21
+ return body.projects;
22
+ },
23
+ async get(id) {
24
+ return jsonFetch(`/api/projects/${encodeURIComponent(id)}`, { method: 'GET' });
25
+ },
26
+ async create(input) {
27
+ return jsonFetch('/api/projects', {
28
+ method: 'POST',
29
+ headers: { 'content-type': 'application/json' },
30
+ body: JSON.stringify(input),
31
+ });
32
+ },
33
+ async update(id, patch) {
34
+ return jsonFetch(`/api/projects/${encodeURIComponent(id)}`, {
35
+ method: 'PATCH',
36
+ headers: { 'content-type': 'application/json' },
37
+ body: JSON.stringify(patch),
38
+ });
39
+ },
40
+ async delete(id, opts) {
41
+ const qs = (opts === null || opts === void 0 ? void 0 : opts.wipeData) ? '?wipeData=1' : '';
42
+ await jsonFetch(`/api/projects/${encodeURIComponent(id)}${qs}`, { method: 'DELETE' });
43
+ },
44
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { projectsApi } from './projectsApi';
3
+ describe('projectsApi', () => {
4
+ beforeEach(() => {
5
+ vi.stubGlobal('fetch', vi.fn());
6
+ });
7
+ it('list() GETs /api/projects and returns projects array', async () => {
8
+ globalThis.fetch.mockResolvedValue({
9
+ ok: true,
10
+ json: async () => ({ projects: [{ id: 'acme-abcd', name: 'Acme', members: [], appAllowlist: [], createdBy: 'admin', createdAt: 0, updatedAt: 0 }] }),
11
+ });
12
+ const projects = await projectsApi.list();
13
+ expect(globalThis.fetch).toHaveBeenCalledWith('/api/projects', expect.objectContaining({ method: 'GET' }));
14
+ expect(projects).toHaveLength(1);
15
+ expect(projects[0].name).toBe('Acme');
16
+ });
17
+ it('listAll() GETs /api/projects/all', async () => {
18
+ globalThis.fetch.mockResolvedValue({
19
+ ok: true,
20
+ json: async () => ({ projects: [] }),
21
+ });
22
+ await projectsApi.listAll();
23
+ expect(globalThis.fetch).toHaveBeenCalledWith('/api/projects/all', expect.objectContaining({ method: 'GET' }));
24
+ });
25
+ it('create() POSTs JSON body to /api/projects', async () => {
26
+ globalThis.fetch.mockResolvedValue({
27
+ ok: true,
28
+ json: async () => ({ id: 'x-1234', name: 'X', members: [], appAllowlist: [], createdBy: 'admin', createdAt: 0, updatedAt: 0 }),
29
+ });
30
+ const project = await projectsApi.create({ name: 'X', members: [], appAllowlist: [] });
31
+ expect(globalThis.fetch).toHaveBeenCalledWith('/api/projects', expect.objectContaining({ method: 'POST' }));
32
+ expect(project.id).toBe('x-1234');
33
+ });
34
+ it('update() PATCHes /api/projects/:id', async () => {
35
+ globalThis.fetch.mockResolvedValue({
36
+ ok: true,
37
+ json: async () => ({ id: 'a', name: 'A', members: [], appAllowlist: [], createdBy: 'admin', createdAt: 0, updatedAt: 0 }),
38
+ });
39
+ await projectsApi.update('a', { name: 'A' });
40
+ expect(globalThis.fetch).toHaveBeenCalledWith('/api/projects/a', expect.objectContaining({ method: 'PATCH' }));
41
+ });
42
+ it('delete() DELETEs /api/projects/:id', async () => {
43
+ globalThis.fetch.mockResolvedValue({ ok: true, json: async () => ({ ok: true }) });
44
+ await projectsApi.delete('a');
45
+ expect(globalThis.fetch).toHaveBeenCalledWith('/api/projects/a', expect.objectContaining({ method: 'DELETE' }));
46
+ });
47
+ it('throws on non-ok response', async () => {
48
+ globalThis.fetch.mockResolvedValue({ ok: false, status: 403 });
49
+ await expect(projectsApi.list()).rejects.toThrow(/403/);
50
+ });
51
+ });
52
+ describe('projectsApi.delete with wipeData', () => {
53
+ beforeEach(() => {
54
+ vi.stubGlobal('fetch', vi.fn());
55
+ });
56
+ it('appends ?wipeData=1 when opts.wipeData is true', async () => {
57
+ globalThis.fetch.mockResolvedValue({ ok: true, json: async () => ({ ok: true, wipedData: true }) });
58
+ await projectsApi.delete('a', { wipeData: true });
59
+ expect(globalThis.fetch).toHaveBeenCalledWith('/api/projects/a?wipeData=1', expect.objectContaining({ method: 'DELETE' }));
60
+ });
61
+ it('does not append the query when opts is omitted', async () => {
62
+ globalThis.fetch.mockResolvedValue({ ok: true, json: async () => ({ ok: true, wipedData: false }) });
63
+ await projectsApi.delete('a');
64
+ expect(globalThis.fetch).toHaveBeenCalledWith('/api/projects/a', expect.objectContaining({ method: 'DELETE' }));
65
+ });
66
+ it('does not append the query when opts.wipeData is false', async () => {
67
+ globalThis.fetch.mockResolvedValue({ ok: true, json: async () => ({ ok: true, wipedData: false }) });
68
+ await projectsApi.delete('a', { wipeData: false });
69
+ expect(globalThis.fetch).toHaveBeenCalledWith('/api/projects/a', expect.objectContaining({ method: 'DELETE' }));
70
+ });
71
+ });
@@ -0,0 +1,10 @@
1
+ import type { Shard } from '../shards/types';
2
+ import { type ProjectRecord } from './projectsApi';
3
+ export declare const projectsState: {
4
+ projects: ProjectRecord[];
5
+ loading: boolean;
6
+ error: string | null;
7
+ };
8
+ export declare function refreshProjects(): Promise<void>;
9
+ export declare function openProjectManage(project: ProjectRecord | null): void;
10
+ export declare const projectsShard: Shard;
@@ -0,0 +1,148 @@
1
+ /*
2
+ * `__projects__` shard — multi-member project scope module.
3
+ *
4
+ * Maintains a reactive list of projects the current user is a member of.
5
+ * Refreshes on activate and on visibility-change so a project created in
6
+ * one tab appears in another after it regains focus. Registers admin-only
7
+ * actions that open the ProjectManage float view (create/edit/delete).
8
+ */
9
+ import { mount, unmount } from 'svelte';
10
+ import { projectsApi } from './projectsApi';
11
+ import { VERSION } from '../version';
12
+ import ProjectManage from './ProjectManage.svelte';
13
+ import { floatManager } from '../overlays/float';
14
+ import { isAdmin } from '../auth/auth.svelte';
15
+ export const projectsState = $state({ projects: [], loading: false, error: null });
16
+ export async function refreshProjects() {
17
+ projectsState.loading = true;
18
+ projectsState.error = null;
19
+ try {
20
+ projectsState.projects = await projectsApi.list();
21
+ }
22
+ catch (e) {
23
+ projectsState.error = e.message;
24
+ }
25
+ finally {
26
+ projectsState.loading = false;
27
+ }
28
+ }
29
+ const PROJECTS_MANAGE_VIEW = 'projects:manage';
30
+ /*
31
+ * The float manager drops `meta` for `dismissable: true` (per the bare
32
+ * SlotNode wrapper in float.ts). Edit-mode needs to know which project
33
+ * to load, so we stash the target on a module-level slot the factory
34
+ * consumes on mount. Cleared after the factory has read it so a later
35
+ * Create from the palette can't accidentally inherit a previous edit
36
+ * target.
37
+ */
38
+ let pendingTarget = null;
39
+ function consumePendingTarget() {
40
+ const t = pendingTarget;
41
+ pendingTarget = null;
42
+ return t;
43
+ }
44
+ export function openProjectManage(project) {
45
+ pendingTarget = project;
46
+ floatManager.open(PROJECTS_MANAGE_VIEW, {
47
+ title: project ? `Edit ${project.name}` : 'Create Project',
48
+ size: { w: 560, h: 620 },
49
+ dismissable: true,
50
+ });
51
+ }
52
+ export const projectsShard = {
53
+ manifest: {
54
+ id: '__projects__',
55
+ label: 'Projects',
56
+ version: VERSION,
57
+ views: [{ id: PROJECTS_MANAGE_VIEW, label: 'Project Manager' }],
58
+ },
59
+ activate(ctx) {
60
+ void refreshProjects();
61
+ if (typeof document !== 'undefined') {
62
+ document.addEventListener('visibilitychange', () => {
63
+ if (document.visibilityState === 'visible')
64
+ void refreshProjects();
65
+ });
66
+ }
67
+ const factory = {
68
+ mount(container, _mountCtx) {
69
+ const project = consumePendingTarget();
70
+ // The float manager has already pushed an entry for this mount —
71
+ // the most recent one whose viewId matches us is ours.
72
+ const list = floatManager.list();
73
+ const floatId = list.length > 0 ? list[list.length - 1].id : null;
74
+ const close = () => {
75
+ if (floatId)
76
+ floatManager.close(floatId);
77
+ };
78
+ const instance = mount(ProjectManage, {
79
+ target: container,
80
+ props: { project, onClose: close },
81
+ });
82
+ return {
83
+ unmount() {
84
+ unmount(instance);
85
+ },
86
+ };
87
+ },
88
+ };
89
+ ctx.registerView(PROJECTS_MANAGE_VIEW, factory);
90
+ ctx.actions.register({
91
+ id: 'sh3.project.create',
92
+ label: 'Create Project…',
93
+ scope: ['home', 'app'],
94
+ paletteItem: true,
95
+ disabled: () => !isAdmin(),
96
+ run: () => {
97
+ if (isAdmin())
98
+ openProjectManage(null);
99
+ },
100
+ });
101
+ ctx.actions.register({
102
+ id: 'sh3.project.manage',
103
+ label: 'Manage Project…',
104
+ scope: ['home', 'app'],
105
+ submenu: true,
106
+ paletteItem: true,
107
+ disabled: () => !isAdmin(),
108
+ });
109
+ // Dynamic children: one per project in projectsState.projects, kept in
110
+ // sync as projects are created/deleted. Mirrors the sh3.app.launch
111
+ // pattern in sh3coreShard.
112
+ const manageUnregisters = new Map();
113
+ $effect.root(() => {
114
+ $effect(() => {
115
+ const currentIds = new Set();
116
+ for (const project of projectsState.projects) {
117
+ currentIds.add(project.id);
118
+ if (manageUnregisters.has(project.id))
119
+ continue;
120
+ const off = ctx.actions.register({
121
+ id: `sh3.project.manage:${project.id}`,
122
+ label: project.name,
123
+ scope: ['home', 'app'],
124
+ submenuOf: 'sh3.project.manage',
125
+ run: () => {
126
+ // Re-read the live record so renames since registration are
127
+ // reflected in the form.
128
+ const live = projectsState.projects.find((p) => p.id === project.id);
129
+ if (live)
130
+ openProjectManage(live);
131
+ },
132
+ });
133
+ manageUnregisters.set(project.id, off);
134
+ }
135
+ for (const id of [...manageUnregisters.keys()]) {
136
+ if (!currentIds.has(id)) {
137
+ manageUnregisters.get(id)();
138
+ manageUnregisters.delete(id);
139
+ }
140
+ }
141
+ });
142
+ });
143
+ },
144
+ autostart() {
145
+ /* register on the self-starting path so the project list is available
146
+ on the home screen without an app launch. */
147
+ },
148
+ };