@myrialabs/clopen 0.1.10 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +23 -1
  2. package/backend/index.ts +20 -0
  3. package/backend/lib/auth/auth-service.ts +484 -0
  4. package/backend/lib/auth/index.ts +4 -0
  5. package/backend/lib/auth/permissions.ts +63 -0
  6. package/backend/lib/auth/rate-limiter.ts +145 -0
  7. package/backend/lib/auth/tokens.ts +53 -0
  8. package/backend/lib/database/migrations/024_create_users_table.ts +29 -0
  9. package/backend/lib/database/migrations/025_create_auth_sessions_table.ts +38 -0
  10. package/backend/lib/database/migrations/026_create_invite_tokens_table.ts +31 -0
  11. package/backend/lib/database/migrations/index.ts +21 -0
  12. package/backend/lib/database/queries/auth-queries.ts +201 -0
  13. package/backend/lib/database/queries/index.ts +2 -1
  14. package/backend/lib/engine/adapters/opencode/server.ts +1 -1
  15. package/backend/lib/mcp/config.ts +13 -18
  16. package/backend/lib/mcp/index.ts +9 -0
  17. package/backend/lib/mcp/remote-server.ts +132 -0
  18. package/backend/lib/mcp/servers/helper.ts +49 -3
  19. package/backend/lib/mcp/servers/index.ts +3 -2
  20. package/backend/lib/preview/browser/browser-audio-capture.ts +20 -3
  21. package/backend/lib/preview/browser/browser-navigation-tracker.ts +3 -0
  22. package/backend/lib/preview/browser/browser-pool.ts +73 -176
  23. package/backend/lib/preview/browser/browser-preview-service.ts +3 -2
  24. package/backend/lib/preview/browser/browser-tab-manager.ts +261 -23
  25. package/backend/lib/preview/browser/browser-video-capture.ts +36 -1
  26. package/backend/lib/utils/ws.ts +65 -1
  27. package/backend/ws/auth/index.ts +17 -0
  28. package/backend/ws/auth/invites.ts +84 -0
  29. package/backend/ws/auth/login.ts +269 -0
  30. package/backend/ws/auth/status.ts +41 -0
  31. package/backend/ws/auth/users.ts +32 -0
  32. package/backend/ws/engine/claude/accounts.ts +3 -1
  33. package/backend/ws/engine/utils.ts +38 -6
  34. package/backend/ws/index.ts +4 -4
  35. package/backend/ws/preview/browser/interact.ts +27 -5
  36. package/bin/clopen.ts +39 -0
  37. package/bun.lock +113 -51
  38. package/frontend/App.svelte +47 -29
  39. package/frontend/lib/components/auth/InvitePage.svelte +215 -0
  40. package/frontend/lib/components/auth/LoginPage.svelte +129 -0
  41. package/frontend/lib/components/auth/SetupPage.svelte +1022 -0
  42. package/frontend/lib/components/common/FolderBrowser.svelte +9 -9
  43. package/frontend/lib/components/common/UpdateBanner.svelte +2 -2
  44. package/frontend/lib/components/preview/browser/BrowserPreview.svelte +1 -1
  45. package/frontend/lib/components/preview/browser/core/mcp-handlers.svelte.ts +12 -4
  46. package/frontend/lib/components/settings/SettingsModal.svelte +50 -15
  47. package/frontend/lib/components/settings/SettingsView.svelte +21 -7
  48. package/frontend/lib/components/settings/account/AccountSettings.svelte +5 -0
  49. package/frontend/lib/components/settings/admin/InviteManagement.svelte +239 -0
  50. package/frontend/lib/components/settings/admin/UserManagement.svelte +127 -0
  51. package/frontend/lib/components/settings/general/AdvancedSettings.svelte +10 -4
  52. package/frontend/lib/components/settings/general/AuthModeSettings.svelte +229 -0
  53. package/frontend/lib/components/settings/general/GeneralSettings.svelte +6 -1
  54. package/frontend/lib/components/settings/general/UpdateSettings.svelte +5 -5
  55. package/frontend/lib/components/settings/security/SecuritySettings.svelte +10 -0
  56. package/frontend/lib/components/settings/system/SystemSettings.svelte +10 -0
  57. package/frontend/lib/components/settings/user/UserSettings.svelte +147 -74
  58. package/frontend/lib/components/workspace/WorkspaceLayout.svelte +5 -10
  59. package/frontend/lib/services/preview/browser/browser-webcodecs.service.ts +31 -8
  60. package/frontend/lib/stores/features/auth.svelte.ts +296 -0
  61. package/frontend/lib/stores/features/settings.svelte.ts +53 -9
  62. package/frontend/lib/stores/features/user.svelte.ts +26 -68
  63. package/frontend/lib/stores/ui/settings-modal.svelte.ts +42 -21
  64. package/frontend/lib/stores/ui/update.svelte.ts +2 -2
  65. package/package.json +8 -6
  66. package/shared/types/stores/settings.ts +16 -2
  67. package/shared/utils/logger.ts +1 -0
  68. package/shared/utils/ws-client.ts +30 -13
  69. package/shared/utils/ws-server.ts +42 -4
  70. package/backend/lib/mcp/stdio-server.ts +0 -103
  71. package/backend/ws/mcp/index.ts +0 -61
@@ -26,8 +26,7 @@
26
26
  import { initializeProjects } from '$frontend/lib/stores/core/projects.svelte';
27
27
  import { initializeSessions } from '$frontend/lib/stores/core/sessions.svelte';
28
28
  import { initializeNotifications } from '$frontend/lib/stores/ui/notification.svelte';
29
- import { applyServerSettings } from '$frontend/lib/stores/features/settings.svelte';
30
- import { userStore } from '$frontend/lib/stores/features/user.svelte';
29
+ import { applyServerSettings, loadSystemSettings } from '$frontend/lib/stores/features/settings.svelte';
31
30
  import { initPresence } from '$frontend/lib/stores/core/presence.svelte';
32
31
  import ws from '$frontend/lib/utils/ws';
33
32
  import { debug } from '$shared/utils/logger';
@@ -78,14 +77,9 @@
78
77
  initializeNotifications();
79
78
  initializeWorkspace();
80
79
 
81
- // Step 2: Initialize user + wait for WebSocket in parallel
82
- // userStore.initialize() reads localStorage (fast) and sets WS context locally.
83
- // waitUntilConnected() waits for WS to connect and sync any pending context.
80
+ // Step 2: WebSocket is already connected (auth completed before this mounts)
84
81
  setProgress(20, 'Connecting...');
85
- await Promise.all([
86
- userStore.initialize(),
87
- ws.waitUntilConnected(10000)
88
- ]);
82
+ await ws.waitUntilConnected(10000);
89
83
 
90
84
  // Step 3: Restore user state from server
91
85
  setProgress(30, 'Restoring state...');
@@ -97,13 +91,14 @@
97
91
  debug.warn('workspace', 'Failed to restore server state, using defaults:', err);
98
92
  }
99
93
 
100
- // Step 4: Apply restored state + setup presence (sync operations)
94
+ // Step 4: Apply restored state + load system settings + setup presence
101
95
  setProgress(40);
102
96
  if (serverState?.settings) {
103
97
  applyServerSettings(serverState.settings);
104
98
  }
105
99
  restoreLastView(serverState?.lastView);
106
100
  restoreUnreadSessions(serverState?.unreadSessions);
101
+ await loadSystemSettings();
107
102
  initPresence();
108
103
 
109
104
  // Step 5: Load projects (with server-restored currentProjectId)
@@ -175,6 +175,17 @@ export class BrowserWebCodecsService {
175
175
  return false;
176
176
  }
177
177
 
178
+ // Pre-initialize AudioContext NOW, during user gesture context.
179
+ // Browsers require a user gesture to start AudioContext — creating it
180
+ // later (e.g. when first audio chunk arrives) results in a permanently
181
+ // suspended context that never plays audio.
182
+ if (!this.audioContext || this.audioContext.state === 'closed') {
183
+ this.audioContext = new AudioContext({ sampleRate: 48000 });
184
+ }
185
+ if (this.audioContext.state === 'suspended') {
186
+ await this.audioContext.resume().catch(() => {});
187
+ }
188
+
178
189
  // Clean up any existing connection
179
190
  if (this.peerConnection || this.isConnected || this.sessionId) {
180
191
  debug.log('webcodecs', 'Cleaning up previous connection');
@@ -546,7 +557,7 @@ export class BrowserWebCodecsService {
546
557
  private async initAudioDecoder(): Promise<void> {
547
558
  this.audioCodecConfig = {
548
559
  codec: 'opus',
549
- sampleRate: 44100,
560
+ sampleRate: 48000,
550
561
  numberOfChannels: 2
551
562
  };
552
563
 
@@ -580,8 +591,17 @@ export class BrowserWebCodecsService {
580
591
  */
581
592
  private async initAudioContext(): Promise<void> {
582
593
  try {
583
- this.audioContext = new AudioContext({ sampleRate: 44100 });
584
- debug.log('webcodecs', 'AudioContext initialized');
594
+ // Reuse AudioContext created in startStreaming (user gesture context)
595
+ if (!this.audioContext || this.audioContext.state === 'closed') {
596
+ this.audioContext = new AudioContext({ sampleRate: 48000 });
597
+ }
598
+
599
+ // Resume if suspended (may happen without user gesture)
600
+ if (this.audioContext.state === 'suspended') {
601
+ await this.audioContext.resume();
602
+ }
603
+
604
+ debug.log('webcodecs', `AudioContext initialized (state: ${this.audioContext.state})`);
585
605
  } catch (error) {
586
606
  debug.error('webcodecs', 'AudioContext init error:', error);
587
607
  }
@@ -737,6 +757,11 @@ export class BrowserWebCodecsService {
737
757
  private playAudioFrame(audioData: AudioData): void {
738
758
  if (!this.audioContext) return;
739
759
 
760
+ // Safety net: resume AudioContext if it somehow got suspended
761
+ if (this.audioContext.state === 'suspended') {
762
+ this.audioContext.resume().catch(() => {});
763
+ }
764
+
740
765
  try {
741
766
  // Create AudioBuffer
742
767
  const buffer = this.audioContext.createBuffer(
@@ -1151,11 +1176,9 @@ export class BrowserWebCodecsService {
1151
1176
  this.audioDecoder = null;
1152
1177
  }
1153
1178
 
1154
- // Close audio context
1155
- if (this.audioContext && this.audioContext.state !== 'closed') {
1156
- await this.audioContext.close().catch(() => {});
1157
- this.audioContext = null;
1158
- }
1179
+ // Keep AudioContext alive across reconnections — it was created during
1180
+ // user gesture in startStreaming() and closing it means we can't resume
1181
+ // without another user gesture. Just reset playback state below.
1159
1182
 
1160
1183
  // Close data channel
1161
1184
  if (this.dataChannel) {
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Auth Store — Svelte 5 Runes
3
+ *
4
+ * Manages authentication state: setup, login, invite, and session persistence.
5
+ * Session token is stored in localStorage for cross-refresh persistence.
6
+ * The token is validated against the server on each app load.
7
+ * Supports no-auth mode (single user, no login required).
8
+ */
9
+
10
+ import ws from '$frontend/lib/utils/ws';
11
+ import { debug } from '$shared/utils/logger';
12
+ import type { AuthMode } from '$shared/types/stores/settings';
13
+
14
+ const SESSION_TOKEN_KEY = 'clopen-session-token';
15
+
16
+ export type AuthState = 'loading' | 'setup' | 'login' | 'invite' | 'ready';
17
+
18
+ export interface AuthUser {
19
+ id: string;
20
+ name: string;
21
+ role: 'admin' | 'member';
22
+ color: string;
23
+ avatar: string;
24
+ createdAt: string;
25
+ }
26
+
27
+ // Reactive state
28
+ let authState = $state<AuthState>('loading');
29
+ let currentUser = $state<AuthUser | null>(null);
30
+ let sessionToken = $state<string | null>(null);
31
+ let personalAccessToken = $state<string | null>(null);
32
+ let authMode = $state<AuthMode>('required');
33
+
34
+ export const authStore = {
35
+ get authState() { return authState; },
36
+ get currentUser() { return currentUser; },
37
+ get sessionToken() { return sessionToken; },
38
+ /** PAT is only available right after setup/invite accept — shown once */
39
+ get personalAccessToken() { return personalAccessToken; },
40
+ /** Current auth mode from server */
41
+ get authMode() { return authMode; },
42
+
43
+ get isAdmin() { return currentUser?.role === 'admin'; },
44
+ get isAuthenticated() { return authState === 'ready' && currentUser !== null; },
45
+ get isNoAuth() { return authMode === 'none'; },
46
+
47
+ /**
48
+ * Initialize auth — called on app mount.
49
+ * Determines which page to show: setup, login, invite, or main app.
50
+ */
51
+ async initialize() {
52
+ authState = 'loading';
53
+
54
+ try {
55
+ // Wait for WebSocket connection
56
+ await ws.waitUntilConnected(10000);
57
+
58
+ // Read stored session token
59
+ const storedToken = localStorage.getItem(SESSION_TOKEN_KEY);
60
+
61
+ // If we have a stored token, try to authenticate
62
+ if (storedToken) {
63
+ try {
64
+ const result = await ws.http('auth:login', { token: storedToken });
65
+ currentUser = result.user;
66
+ sessionToken = result.sessionToken;
67
+ // Update stored token (may have been refreshed)
68
+ localStorage.setItem(SESSION_TOKEN_KEY, result.sessionToken);
69
+ // Set token on WS client for reconnection auth
70
+ ws.setSessionToken(result.sessionToken);
71
+
72
+ // Fetch auth mode and onboarding status from server
73
+ const status = await ws.http('auth:status', {});
74
+ authMode = status.authMode;
75
+
76
+ // If onboarding not yet completed, show wizard instead of going to ready
77
+ if (!status.onboardingComplete) {
78
+ authState = 'setup';
79
+ debug.log('auth', `Authenticated but onboarding pending: ${result.user.name}`);
80
+ return;
81
+ }
82
+
83
+ authState = 'ready';
84
+ debug.log('auth', `Authenticated: ${result.user.name} (${result.user.role}), authMode: ${authMode}`);
85
+ return;
86
+ } catch {
87
+ // Token invalid or expired — clear and continue
88
+ localStorage.removeItem(SESSION_TOKEN_KEY);
89
+ sessionToken = null;
90
+ debug.log('auth', 'Stored session token invalid, clearing');
91
+ }
92
+ }
93
+
94
+ // Check if invite token is in URL hash
95
+ const hash = window.location.hash;
96
+ if (hash.startsWith('#invite/')) {
97
+ authState = 'invite';
98
+ return;
99
+ }
100
+
101
+ // Check server status
102
+ const status = await ws.http('auth:status', {});
103
+ authMode = status.authMode;
104
+
105
+ if (!status.onboardingComplete) {
106
+ if (status.needsSetup) {
107
+ // Fresh install — show wizard
108
+ authState = 'setup';
109
+ } else if (authMode === 'none') {
110
+ // No-auth mode, existing data — auto-login then show wizard
111
+ await this.autoLoginNoAuth();
112
+ authState = 'setup';
113
+ } else {
114
+ // With-auth mode, existing users, no session — need to login first
115
+ // After login, the login() method will redirect to setup wizard
116
+ authState = 'login';
117
+ }
118
+ } else if (authMode === 'none') {
119
+ // Onboarding done, no-auth mode: auto-login
120
+ await this.autoLoginNoAuth();
121
+ } else {
122
+ authState = 'login';
123
+ }
124
+ } catch (error) {
125
+ debug.error('auth', 'Auth initialization failed:', error);
126
+ try {
127
+ const status = await ws.http('auth:status', {});
128
+ authMode = status.authMode;
129
+ authState = status.needsSetup ? 'setup' : 'login';
130
+ } catch {
131
+ authState = 'login';
132
+ }
133
+ }
134
+ },
135
+
136
+ /**
137
+ * Auto-login for no-auth mode (returning visitors).
138
+ */
139
+ async autoLoginNoAuth() {
140
+ const result = await ws.http('auth:auto-login-no-auth', {});
141
+ currentUser = result.user;
142
+ sessionToken = result.sessionToken;
143
+ localStorage.setItem(SESSION_TOKEN_KEY, result.sessionToken);
144
+ ws.setSessionToken(result.sessionToken);
145
+ authState = 'ready';
146
+ debug.log('auth', `No-auth auto-login: ${result.user.name}`);
147
+ },
148
+
149
+ /**
150
+ * Setup — create first admin account (with-auth mode).
151
+ */
152
+ async setup(name: string) {
153
+ const result = await ws.http('auth:setup', { name });
154
+ currentUser = result.user;
155
+ sessionToken = result.sessionToken;
156
+ personalAccessToken = result.personalAccessToken;
157
+ localStorage.setItem(SESSION_TOKEN_KEY, result.sessionToken);
158
+ ws.setSessionToken(result.sessionToken);
159
+ authMode = 'required';
160
+ // Don't set authState to 'ready' yet — setup page shows PAT first
161
+ debug.log('auth', `Admin setup complete: ${result.user.name}`);
162
+ },
163
+
164
+ /**
165
+ * Setup no-auth mode — create default admin, no PAT needed.
166
+ */
167
+ async setupNoAuth() {
168
+ const result = await ws.http('auth:setup-no-auth', {});
169
+ currentUser = result.user;
170
+ sessionToken = result.sessionToken;
171
+ localStorage.setItem(SESSION_TOKEN_KEY, result.sessionToken);
172
+ ws.setSessionToken(result.sessionToken);
173
+ authMode = 'none';
174
+ // Don't set authState to 'ready' yet — wizard continues to next step
175
+ debug.log('auth', `No-auth setup complete: ${result.user.name}`);
176
+ },
177
+
178
+ /**
179
+ * Complete setup — transition to ready state after wizard is done.
180
+ * Saves onboardingComplete flag so wizard won't show again.
181
+ */
182
+ async completeSetup() {
183
+ personalAccessToken = null;
184
+
185
+ // Save onboardingComplete to system settings
186
+ try {
187
+ const { loadSystemSettings, updateSystemSettings } = await import('$frontend/lib/stores/features/settings.svelte');
188
+ await loadSystemSettings();
189
+ await updateSystemSettings({ onboardingComplete: true });
190
+ } catch {
191
+ // Best-effort — if this fails, wizard may show again
192
+ debug.warn('auth', 'Failed to save onboardingComplete flag');
193
+ }
194
+
195
+ authState = 'ready';
196
+ },
197
+
198
+ /**
199
+ * Login with a Personal Access Token (PAT).
200
+ */
201
+ async login(token: string) {
202
+ const result = await ws.http('auth:login', { token });
203
+ currentUser = result.user;
204
+ sessionToken = result.sessionToken;
205
+ localStorage.setItem(SESSION_TOKEN_KEY, result.sessionToken);
206
+ ws.setSessionToken(result.sessionToken);
207
+
208
+ // Check if onboarding is pending
209
+ const status = await ws.http('auth:status', {});
210
+ authMode = status.authMode;
211
+ if (!status.onboardingComplete) {
212
+ authState = 'setup';
213
+ debug.log('auth', `Logged in, onboarding pending: ${result.user.name}`);
214
+ return;
215
+ }
216
+
217
+ authState = 'ready';
218
+ debug.log('auth', `Logged in: ${result.user.name} (${result.user.role})`);
219
+ },
220
+
221
+ /**
222
+ * Accept invite — create account from invite token.
223
+ */
224
+ async acceptInvite(inviteToken: string, name: string) {
225
+ const result = await ws.http('auth:accept-invite', { inviteToken, name });
226
+ currentUser = result.user;
227
+ sessionToken = result.sessionToken;
228
+ personalAccessToken = result.personalAccessToken;
229
+ localStorage.setItem(SESSION_TOKEN_KEY, result.sessionToken);
230
+ ws.setSessionToken(result.sessionToken);
231
+ // Clear invite hash from URL
232
+ window.location.hash = '';
233
+ debug.log('auth', `Invite accepted: ${result.user.name}`);
234
+ },
235
+
236
+ /**
237
+ * Complete invite — transition to ready after user has copied PAT.
238
+ */
239
+ completeInvite() {
240
+ personalAccessToken = null;
241
+ authState = 'ready';
242
+ },
243
+
244
+ /**
245
+ * Logout — clear session.
246
+ */
247
+ async logout() {
248
+ try {
249
+ await ws.http('auth:logout', {});
250
+ } catch {
251
+ // Ignore errors during logout
252
+ }
253
+ currentUser = null;
254
+ sessionToken = null;
255
+ personalAccessToken = null;
256
+ localStorage.removeItem(SESSION_TOKEN_KEY);
257
+ ws.setSessionToken(null);
258
+ authState = 'login';
259
+ debug.log('auth', 'Logged out');
260
+ },
261
+
262
+ /**
263
+ * Logout all sessions (admin action — used when switching auth mode).
264
+ */
265
+ async logoutAll() {
266
+ try {
267
+ await ws.http('auth:logout-all', {});
268
+ } catch {
269
+ // Ignore errors
270
+ }
271
+ currentUser = null;
272
+ sessionToken = null;
273
+ personalAccessToken = null;
274
+ localStorage.removeItem(SESSION_TOKEN_KEY);
275
+ ws.setSessionToken(null);
276
+ authState = 'login';
277
+ debug.log('auth', 'All sessions logged out');
278
+ },
279
+
280
+ /**
281
+ * Update display name.
282
+ */
283
+ async updateName(newName: string) {
284
+ const updated = await ws.http('auth:update-name', { newName });
285
+ currentUser = updated;
286
+ debug.log('auth', `Name updated: ${updated.name}`);
287
+ },
288
+
289
+ /**
290
+ * Regenerate Personal Access Token.
291
+ */
292
+ async regeneratePAT(): Promise<string> {
293
+ const result = await ws.http('auth:regenerate-pat', {});
294
+ return result.personalAccessToken;
295
+ }
296
+ };
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * Settings Store with Svelte 5 Runes
3
3
  *
4
- * Centralized store for user settings with server-side persistence
5
- * Settings are stored on the server via user:save-state / user:restore-state
6
- * No localStorage usage - server is single source of truth
4
+ * Centralized store for user settings with server-side persistence.
5
+ * Per-user settings: stored via user:save-state / user:restore-state
6
+ * System settings: stored via settings:get / settings:update-system (admin-only write)
7
7
  */
8
8
 
9
9
  import { DEFAULT_MODEL, DEFAULT_ENGINE } from '$shared/constants/engines';
10
- import type { AppSettings } from '$shared/types/stores/settings';
10
+ import type { AppSettings, SystemSettings } from '$shared/types/stores/settings';
11
11
  import { builtInPresets } from '$frontend/lib/stores/ui/workspace.svelte';
12
12
  import ws from '$frontend/lib/utils/ws';
13
13
 
@@ -22,7 +22,7 @@ const createDefaultPresetVisibility = (): Record<string, boolean> => {
22
22
  return visibility;
23
23
  };
24
24
 
25
- // Default settings
25
+ // Default per-user settings
26
26
  const defaultSettings: AppSettings = {
27
27
  selectedEngine: DEFAULT_ENGINE,
28
28
  selectedModel: DEFAULT_MODEL,
@@ -32,14 +32,24 @@ const defaultSettings: AppSettings = {
32
32
  soundNotifications: true,
33
33
  pushNotifications: false,
34
34
  layoutPresetVisibility: createDefaultPresetVisibility(),
35
+ fontSize: 13
36
+ };
37
+
38
+ // Default system settings
39
+ const defaultSystemSettings: SystemSettings = {
40
+ authMode: 'required',
41
+ onboardingComplete: false,
35
42
  allowedBasePaths: [],
36
- fontSize: 13,
37
- autoUpdate: false
43
+ autoUpdate: false,
44
+ sessionLifetimeDays: 30
38
45
  };
39
46
 
40
47
  // Create and export reactive settings state directly (starts with defaults)
41
48
  export const settings = $state<AppSettings>({ ...defaultSettings });
42
49
 
50
+ // System-wide settings (admin-configurable, read by all users)
51
+ export const systemSettings = $state<SystemSettings>({ ...defaultSystemSettings });
52
+
43
53
  export function applyFontSize(size: number): void {
44
54
  if (typeof window !== 'undefined') {
45
55
  document.documentElement.style.fontSize = `${size}px`;
@@ -47,7 +57,7 @@ export function applyFontSize(size: number): void {
47
57
  }
48
58
 
49
59
  /**
50
- * Apply server-provided settings during initialization.
60
+ * Apply server-provided per-user settings during initialization.
51
61
  * Called from WorkspaceLayout with state from user:restore-state.
52
62
  */
53
63
  export function applyServerSettings(serverSettings: Partial<AppSettings> | null): void {
@@ -59,7 +69,41 @@ export function applyServerSettings(serverSettings: Partial<AppSettings> | null)
59
69
  }
60
70
  }
61
71
 
62
- // Save settings to server (fire-and-forget)
72
+ /**
73
+ * Load system settings from server.
74
+ * Called during initialization after auth is ready.
75
+ */
76
+ export async function loadSystemSettings(): Promise<void> {
77
+ try {
78
+ const result = await ws.http('settings:get', { key: 'system:settings' });
79
+ if (result?.value) {
80
+ const parsed = typeof result.value === 'string' ? JSON.parse(result.value) : result.value;
81
+ Object.assign(systemSettings, { ...defaultSystemSettings, ...parsed });
82
+ debug.log('settings', 'Loaded system settings');
83
+ }
84
+ } catch {
85
+ // System settings may not exist yet — use defaults
86
+ debug.log('settings', 'No system settings found, using defaults');
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Save system settings (admin only).
92
+ */
93
+ export async function updateSystemSettings(newSettings: Partial<SystemSettings>): Promise<void> {
94
+ Object.assign(systemSettings, newSettings);
95
+ try {
96
+ await ws.http('settings:update', {
97
+ key: 'system:settings',
98
+ value: JSON.stringify({ ...systemSettings })
99
+ });
100
+ debug.log('settings', 'System settings saved');
101
+ } catch (err) {
102
+ debug.error('settings', 'Failed to save system settings:', err);
103
+ }
104
+ }
105
+
106
+ // Save per-user settings to server (fire-and-forget)
63
107
  function saveSettings(): void {
64
108
  ws.http('user:save-state', { key: 'settings', value: { ...settings } }).catch(err => {
65
109
  debug.error('settings', 'Failed to save settings to server:', err);
@@ -1,96 +1,54 @@
1
1
  /**
2
2
  * User Store - Svelte 5 Runes
3
- * Manages anonymous user state and provides reactive updates
3
+ *
4
+ * Delegates to auth store for user identity.
5
+ * Kept for backward compatibility with components that import userStore.
4
6
  */
5
7
 
6
- import { getOrCreateAnonymousUser, updateAnonymousUserName, getCurrentAnonymousUser, type AnonymousUser } from '$shared/utils/anonymous-user';
8
+ import { authStore } from './auth.svelte';
7
9
  import { debug } from '$shared/utils/logger';
8
10
  import ws from '$frontend/lib/utils/ws';
9
11
 
10
- // User state - initialize with null, will be properly set after async load
11
- let currentUser = $state<AnonymousUser | null>(null);
12
- let isInitializing = $state<boolean>(false);
12
+ // Re-export user type from auth store
13
+ export type { AuthUser as AnonymousUser } from './auth.svelte';
13
14
 
14
- // User store
15
+ // User store (delegates to auth store)
15
16
  export const userStore = {
16
17
  get currentUser() {
17
- return currentUser;
18
+ const user = authStore.currentUser;
19
+ if (!user) return null;
20
+ // Return in the shape expected by existing components
21
+ return {
22
+ id: user.id,
23
+ name: user.name,
24
+ color: user.color,
25
+ avatar: user.avatar,
26
+ createdAt: user.createdAt
27
+ };
18
28
  },
19
29
 
20
30
  get isInitializing() {
21
- return isInitializing;
31
+ return authStore.authState === 'loading';
22
32
  },
23
33
 
24
- // Initialize user (called on app start)
34
+ // No-op: auth store handles initialization before WorkspaceLayout mounts
25
35
  async initialize() {
26
- if (typeof window === 'undefined') {
27
- debug.warn('user', 'Cannot initialize user on server side');
28
- return;
29
- }
30
-
31
- if (isInitializing) {
32
- debug.warn('user', 'User initialization already in progress');
33
- return;
34
- }
35
-
36
- isInitializing = true;
37
-
38
- try {
39
- // First check if user already exists in localStorage (fast path)
40
- const existingUser = getCurrentAnonymousUser();
41
- if (existingUser) {
42
- currentUser = existingUser;
43
- // Sync user context with WebSocket (for user-targeted broadcasting)
44
- // IMPORTANT: Must await to ensure server has context before other operations
45
- await ws.setUser(existingUser.id);
46
- debug.log('user', '✅ Loaded existing user from localStorage:', existingUser.name);
47
- } else {
48
- // Generate new user from server
49
- debug.log('user', 'No existing user, generating from server...');
50
- const newUser = await getOrCreateAnonymousUser();
51
- currentUser = newUser;
52
- // Sync user context with WebSocket
53
- // IMPORTANT: Must await to ensure server has context before other operations
54
- if (newUser) {
55
- await ws.setUser(newUser.id);
56
- }
57
- }
58
- } catch (error) {
59
- debug.error('user', 'Failed to initialize user:', error);
60
- } finally {
61
- isInitializing = false;
62
- }
36
+ debug.log('user', 'initialize() called auth store handles this');
63
37
  },
64
38
 
65
- // Update user name
39
+ // Update user name via auth store
66
40
  async updateName(newName: string): Promise<boolean> {
67
- if (typeof window === 'undefined') {
68
- return false;
69
- }
70
-
71
41
  try {
72
- const updatedUser = await updateAnonymousUserName(newName);
73
-
74
- if (updatedUser) {
75
- currentUser = updatedUser;
76
- return true;
77
- }
78
-
79
- return false;
42
+ await authStore.updateName(newName);
43
+ return true;
80
44
  } catch (error) {
81
45
  debug.error('user', 'Failed to update user name:', error);
82
46
  return false;
83
47
  }
84
48
  },
85
49
 
86
- // Refresh user from localStorage
50
+ // No-op: user data comes from auth store
87
51
  refresh() {
88
- if (typeof window !== 'undefined') {
89
- const user = getCurrentAnonymousUser();
90
- if (user) {
91
- currentUser = user;
92
- debug.log('user', '✅ Refreshed user from localStorage:', user.name);
93
- }
94
- }
52
+ debug.log('user', 'refresh() called user data comes from auth store');
95
53
  }
96
- };
54
+ };