@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.
- package/README.md +23 -1
- package/backend/index.ts +20 -0
- package/backend/lib/auth/auth-service.ts +484 -0
- package/backend/lib/auth/index.ts +4 -0
- package/backend/lib/auth/permissions.ts +63 -0
- package/backend/lib/auth/rate-limiter.ts +145 -0
- package/backend/lib/auth/tokens.ts +53 -0
- package/backend/lib/database/migrations/024_create_users_table.ts +29 -0
- package/backend/lib/database/migrations/025_create_auth_sessions_table.ts +38 -0
- package/backend/lib/database/migrations/026_create_invite_tokens_table.ts +31 -0
- package/backend/lib/database/migrations/index.ts +21 -0
- package/backend/lib/database/queries/auth-queries.ts +201 -0
- package/backend/lib/database/queries/index.ts +2 -1
- package/backend/lib/engine/adapters/opencode/server.ts +1 -1
- package/backend/lib/mcp/config.ts +13 -18
- package/backend/lib/mcp/index.ts +9 -0
- package/backend/lib/mcp/remote-server.ts +132 -0
- package/backend/lib/mcp/servers/helper.ts +49 -3
- package/backend/lib/mcp/servers/index.ts +3 -2
- package/backend/lib/preview/browser/browser-audio-capture.ts +20 -3
- package/backend/lib/preview/browser/browser-navigation-tracker.ts +3 -0
- package/backend/lib/preview/browser/browser-pool.ts +73 -176
- package/backend/lib/preview/browser/browser-preview-service.ts +3 -2
- package/backend/lib/preview/browser/browser-tab-manager.ts +261 -23
- package/backend/lib/preview/browser/browser-video-capture.ts +36 -1
- package/backend/lib/utils/ws.ts +65 -1
- package/backend/ws/auth/index.ts +17 -0
- package/backend/ws/auth/invites.ts +84 -0
- package/backend/ws/auth/login.ts +269 -0
- package/backend/ws/auth/status.ts +41 -0
- package/backend/ws/auth/users.ts +32 -0
- package/backend/ws/engine/claude/accounts.ts +3 -1
- package/backend/ws/engine/utils.ts +38 -6
- package/backend/ws/index.ts +4 -4
- package/backend/ws/preview/browser/interact.ts +27 -5
- package/bin/clopen.ts +39 -0
- package/bun.lock +113 -51
- package/frontend/App.svelte +47 -29
- package/frontend/lib/components/auth/InvitePage.svelte +215 -0
- package/frontend/lib/components/auth/LoginPage.svelte +129 -0
- package/frontend/lib/components/auth/SetupPage.svelte +1022 -0
- package/frontend/lib/components/common/FolderBrowser.svelte +9 -9
- package/frontend/lib/components/common/UpdateBanner.svelte +2 -2
- package/frontend/lib/components/preview/browser/BrowserPreview.svelte +1 -1
- package/frontend/lib/components/preview/browser/core/mcp-handlers.svelte.ts +12 -4
- package/frontend/lib/components/settings/SettingsModal.svelte +50 -15
- package/frontend/lib/components/settings/SettingsView.svelte +21 -7
- package/frontend/lib/components/settings/account/AccountSettings.svelte +5 -0
- package/frontend/lib/components/settings/admin/InviteManagement.svelte +239 -0
- package/frontend/lib/components/settings/admin/UserManagement.svelte +127 -0
- package/frontend/lib/components/settings/general/AdvancedSettings.svelte +10 -4
- package/frontend/lib/components/settings/general/AuthModeSettings.svelte +229 -0
- package/frontend/lib/components/settings/general/GeneralSettings.svelte +6 -1
- package/frontend/lib/components/settings/general/UpdateSettings.svelte +5 -5
- package/frontend/lib/components/settings/security/SecuritySettings.svelte +10 -0
- package/frontend/lib/components/settings/system/SystemSettings.svelte +10 -0
- package/frontend/lib/components/settings/user/UserSettings.svelte +147 -74
- package/frontend/lib/components/workspace/WorkspaceLayout.svelte +5 -10
- package/frontend/lib/services/preview/browser/browser-webcodecs.service.ts +31 -8
- package/frontend/lib/stores/features/auth.svelte.ts +296 -0
- package/frontend/lib/stores/features/settings.svelte.ts +53 -9
- package/frontend/lib/stores/features/user.svelte.ts +26 -68
- package/frontend/lib/stores/ui/settings-modal.svelte.ts +42 -21
- package/frontend/lib/stores/ui/update.svelte.ts +2 -2
- package/package.json +8 -6
- package/shared/types/stores/settings.ts +16 -2
- package/shared/utils/logger.ts +1 -0
- package/shared/utils/ws-client.ts +30 -13
- package/shared/utils/ws-server.ts +42 -4
- package/backend/lib/mcp/stdio-server.ts +0 -103
- 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:
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
584
|
-
|
|
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
|
-
//
|
|
1155
|
-
|
|
1156
|
-
|
|
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
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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 {
|
|
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
|
-
//
|
|
11
|
-
|
|
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
|
-
|
|
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
|
|
31
|
+
return authStore.authState === 'loading';
|
|
22
32
|
},
|
|
23
33
|
|
|
24
|
-
//
|
|
34
|
+
// No-op: auth store handles initialization before WorkspaceLayout mounts
|
|
25
35
|
async initialize() {
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
50
|
+
// No-op: user data comes from auth store
|
|
87
51
|
refresh() {
|
|
88
|
-
|
|
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
|
+
};
|