@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
@@ -6,7 +6,7 @@
6
6
  import Dialog from './Dialog.svelte';
7
7
  import { debug } from '$shared/utils/logger';
8
8
  import ws from '$frontend/lib/utils/ws';
9
- import { settings } from '$frontend/lib/stores/features/settings.svelte';
9
+ import { systemSettings } from '$frontend/lib/stores/features/settings.svelte';
10
10
 
11
11
  interface FileItem {
12
12
  name: string;
@@ -43,7 +43,7 @@
43
43
  let deleteFolderConfirmName = $state('');
44
44
 
45
45
  // Derived: whether directory access is restricted
46
- const hasRestrictions = $derived(settings.allowedBasePaths && settings.allowedBasePaths.length > 0);
46
+ const hasRestrictions = $derived(systemSettings.allowedBasePaths && systemSettings.allowedBasePaths.length > 0);
47
47
 
48
48
  // Detect backend OS from current path (drive letter = Windows)
49
49
  const isWindows = $derived(/^[A-Za-z]:/.test(currentPath));
@@ -89,13 +89,13 @@
89
89
 
90
90
  // Check if a path is accessible (within allowed base paths)
91
91
  function isPathAllowed(path: string): boolean {
92
- if (!settings.allowedBasePaths || settings.allowedBasePaths.length === 0) return true;
93
- return settings.allowedBasePaths.some(base => isWithinBase(path, base));
92
+ if (!systemSettings.allowedBasePaths || systemSettings.allowedBasePaths.length === 0) return true;
93
+ return systemSettings.allowedBasePaths.some(base => isWithinBase(path, base));
94
94
  }
95
95
 
96
96
  // Check if current path is at the restriction boundary (cannot go up)
97
97
  const atRestrictionBoundary = $derived(
98
- hasRestrictions && settings.allowedBasePaths.some(base => pathsEqual(currentPath, base))
98
+ hasRestrictions && systemSettings.allowedBasePaths.some(base => pathsEqual(currentPath, base))
99
99
  );
100
100
 
101
101
  // Get available drives/mount points for all platforms
@@ -128,8 +128,8 @@
128
128
  // Get user's home directory or current working directory
129
129
  async function getInitialPath(): Promise<string> {
130
130
  // If restrictions are set, start at the first allowed base path
131
- if (settings.allowedBasePaths && settings.allowedBasePaths.length > 0) {
132
- return settings.allowedBasePaths[0];
131
+ if (systemSettings.allowedBasePaths && systemSettings.allowedBasePaths.length > 0) {
132
+ return systemSettings.allowedBasePaths[0];
133
133
  }
134
134
 
135
135
  try {
@@ -225,7 +225,7 @@
225
225
 
226
226
  // Enforce access restrictions
227
227
  if (!isPathAllowed(currentPath)) {
228
- error = `Access restricted. Allowed paths: ${settings.allowedBasePaths.join(', ')}`;
228
+ error = `Access restricted. Allowed paths: ${systemSettings.allowedBasePaths.join(', ')}`;
229
229
  items = [];
230
230
  return;
231
231
  }
@@ -546,7 +546,7 @@
546
546
  <div class="flex items-center gap-2 flex-wrap">
547
547
  {#if hasRestrictions}
548
548
  <!-- Restricted mode: show allowed base paths as quick access -->
549
- {#each settings.allowedBasePaths as basePath (basePath)}
549
+ {#each systemSettings.allowedBasePaths as basePath (basePath)}
550
550
  <button
551
551
  onclick={() => navigateToLocation(basePath)}
552
552
  class="px-3 py-1.5 text-xs rounded-lg bg-slate-200 dark:bg-slate-700 hover:bg-slate-300 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-300 transition-colors"
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { updateState, runUpdate, dismissUpdate, checkForUpdate } from '$frontend/lib/stores/ui/update.svelte';
3
- import { settings, updateSettings } from '$frontend/lib/stores/features/settings.svelte';
3
+ import { systemSettings, updateSystemSettings } from '$frontend/lib/stores/features/settings.svelte';
4
4
  import Icon from '$frontend/lib/components/common/Icon.svelte';
5
5
  import { slide } from 'svelte/transition';
6
6
 
@@ -22,7 +22,7 @@
22
22
  }
23
23
 
24
24
  function toggleAutoUpdate() {
25
- updateSettings({ autoUpdate: !settings.autoUpdate });
25
+ updateSystemSettings({ autoUpdate: !systemSettings.autoUpdate });
26
26
  }
27
27
 
28
28
  function handleRetry() {
@@ -112,7 +112,7 @@
112
112
  }
113
113
  },
114
114
  onMcpCursorHide: () => {
115
- // Cursor stays visible between automated steps until user interacts manually
115
+ mcpVirtualCursor = { ...mcpVirtualCursor, visible: false };
116
116
  },
117
117
  transformBrowserToDisplayCoordinates: (browserX, browserY) => {
118
118
  return transformBrowserToDisplayCoordinates(browserX, browserY);
@@ -68,6 +68,15 @@ export function createMcpHandler(config: McpHandlerConfig) {
68
68
  handleTestCompleted(data);
69
69
  });
70
70
 
71
+ // Hide cursor when the entire Claude request finishes or is stopped
72
+ ws.on('chat:complete', () => {
73
+ if (onCursorHide) onCursorHide();
74
+ });
75
+
76
+ ws.on('chat:cancelled', () => {
77
+ if (onCursorHide) onCursorHide();
78
+ });
79
+
71
80
  // MCP Tab Management - Request/Response handlers
72
81
  setupTabManagementListeners();
73
82
 
@@ -154,10 +163,9 @@ export function createMcpHandler(config: McpHandlerConfig) {
154
163
  }
155
164
  }
156
165
 
157
- function handleTestCompleted(data: { sessionId: string; timestamp: number; source: 'mcp' }) {
158
- if (mcpControlState.browserSessionId === data.sessionId && onCursorHide) {
159
- onCursorHide();
160
- }
166
+ function handleTestCompleted(_data: { sessionId: string; timestamp: number; source: 'mcp' }) {
167
+ // Cursor is hidden via chat:complete / chat:cancelled listeners instead,
168
+ // because test-completed fires per-tool-call, not at end of full request.
161
169
  }
162
170
 
163
171
  function handleTabsListRequest(data: { requestId: string }) {
@@ -9,14 +9,19 @@
9
9
  settingsSections,
10
10
  type SettingsSection
11
11
  } from '$frontend/lib/stores/ui/settings-modal.svelte';
12
+ import { authStore } from '$frontend/lib/stores/features/auth.svelte';
13
+ import { systemSettings } from '$frontend/lib/stores/features/settings.svelte';
12
14
 
13
15
  // Import settings components
14
16
  import ModelSettings from './model/ModelSettings.svelte';
15
17
  import AIEnginesSettings from './engines/AIEnginesSettings.svelte';
16
18
  import AppearanceSettings from './appearance/AppearanceSettings.svelte';
17
- import UserSettings from './user/UserSettings.svelte';
19
+ import AccountSettings from './account/AccountSettings.svelte';
18
20
  import NotificationSettings from './notifications/NotificationSettings.svelte';
19
- import GeneralSettings from './general/GeneralSettings.svelte';
21
+ import TeamSettings from './admin/UserManagement.svelte';
22
+ import InviteManagement from './admin/InviteManagement.svelte';
23
+ import SecuritySettings from './security/SecuritySettings.svelte';
24
+ import SystemSettings from './system/SystemSettings.svelte';
20
25
 
21
26
  // Responsive state
22
27
  let isMobileMenuOpen = $state(false);
@@ -25,6 +30,17 @@
25
30
  const isMobile = $derived(windowWidth < 768);
26
31
  const isOpen = $derived(settingsModalState.isOpen);
27
32
  const activeSection = $derived(settingsModalState.activeSection);
33
+ const isAdmin = $derived(authStore.isAdmin);
34
+ const isNoAuth = $derived(systemSettings.authMode === 'none');
35
+
36
+ // Filter sections: hide admin-only tabs for non-admins, hide team in no-auth mode
37
+ const visibleSections = $derived(
38
+ settingsSections.filter(s => {
39
+ if (s.adminOnly && !isAdmin) return false;
40
+ if (s.id === 'team' && isNoAuth) return false;
41
+ return true;
42
+ })
43
+ );
28
44
 
29
45
  // Handle section change
30
46
  function handleSectionChange(section: SettingsSection) {
@@ -48,6 +64,14 @@
48
64
  }
49
65
  }
50
66
 
67
+ // Auto-redirect when current section becomes hidden
68
+ $effect(() => {
69
+ const isVisible = visibleSections.some(s => s.id === activeSection);
70
+ if (!isVisible && visibleSections.length > 0) {
71
+ setActiveSection(visibleSections[0].id);
72
+ }
73
+ });
74
+
51
75
  // Get current section info
52
76
  const currentSectionInfo = $derived(
53
77
  settingsSections.find((s) => s.id === activeSection) || settingsSections[0]
@@ -122,9 +146,9 @@
122
146
  <div class="flex flex-1 min-h-0 relative">
123
147
  <!-- Sidebar -->
124
148
  <aside
125
- class="flex flex-col w-68 shrink-0 bg-white dark:bg-slate-900/98 border-r border-slate-200 dark:border-slate-800
149
+ class="flex flex-col w-65 shrink-0 bg-white dark:bg-slate-900/98 border-r border-slate-200 dark:border-slate-800
126
150
  {isMobile
127
- ? 'absolute left-0 top-0 bottom-0 z-10 w-70 shadow-[4px_0_20px_rgba(0,0,0,0.15)] dark:shadow-[4px_0_20px_rgba(0,0,0,0.3)] transition-transform duration-[250ms] ease-out'
151
+ ? 'absolute left-0 top-0 bottom-0 z-10 w-70 shadow-[4px_0_20px_rgba(0,0,0,0.15)] dark:shadow-[4px_0_20px_rgba(0,0,0,0.3)] transition-transform duration-250 ease-out'
128
152
  : ''}
129
153
  {isMobile && !isMobileMenuOpen ? '-translate-x-full' : 'translate-x-0'}"
130
154
  >
@@ -149,7 +173,7 @@
149
173
  {/if}
150
174
 
151
175
  <nav class="flex-1 overflow-y-auto p-3">
152
- {#each settingsSections as section (section.id)}
176
+ {#each visibleSections as section (section.id)}
153
177
  <button
154
178
  type="button"
155
179
  class="flex items-start gap-3 w-full py-3 px-3.5 bg-transparent border-none rounded-lg text-slate-500 text-sm text-left cursor-pointer transition-all duration-150 mb-1
@@ -199,25 +223,36 @@
199
223
  <div in:fly={{ x: 20, duration: 200 }}>
200
224
  <ModelSettings />
201
225
  </div>
202
- {:else if activeSection === 'engines'}
203
- <div in:fly={{ x: 20, duration: 200 }}>
204
- <AIEnginesSettings />
205
- </div>
206
226
  {:else if activeSection === 'appearance'}
207
227
  <div in:fly={{ x: 20, duration: 200 }}>
208
228
  <AppearanceSettings />
209
229
  </div>
210
- {:else if activeSection === 'user'}
211
- <div in:fly={{ x: 20, duration: 200 }}>
212
- <UserSettings />
213
- </div>
214
230
  {:else if activeSection === 'notifications'}
215
231
  <div in:fly={{ x: 20, duration: 200 }}>
216
232
  <NotificationSettings />
217
233
  </div>
218
- {:else if activeSection === 'general'}
234
+ {:else if activeSection === 'account'}
235
+ <div in:fly={{ x: 20, duration: 200 }}>
236
+ <AccountSettings />
237
+ </div>
238
+ {:else if activeSection === 'engines' && isAdmin}
239
+ <div in:fly={{ x: 20, duration: 200 }}>
240
+ <AIEnginesSettings />
241
+ </div>
242
+ {:else if activeSection === 'team' && isAdmin && !isNoAuth}
243
+ <div in:fly={{ x: 20, duration: 200 }}>
244
+ <TeamSettings />
245
+ <div class="mt-6">
246
+ <InviteManagement />
247
+ </div>
248
+ </div>
249
+ {:else if activeSection === 'security' && isAdmin}
250
+ <div in:fly={{ x: 20, duration: 200 }}>
251
+ <SecuritySettings />
252
+ </div>
253
+ {:else if activeSection === 'system' && isAdmin}
219
254
  <div in:fly={{ x: 20, duration: 200 }}>
220
- <GeneralSettings />
255
+ <SystemSettings />
221
256
  </div>
222
257
  {/if}
223
258
  </div>
@@ -1,12 +1,19 @@
1
1
  <script lang="ts">
2
+ import { authStore } from '$frontend/lib/stores/features/auth.svelte';
2
3
  import PageTemplate from '../common/PageTemplate.svelte';
3
4
 
4
5
  // Import modular components
5
6
  import ModelSettings from './model/ModelSettings.svelte';
6
7
  import AppearanceSettings from './appearance/AppearanceSettings.svelte';
7
- import UserSettings from './user/UserSettings.svelte';
8
+ import AccountSettings from './account/AccountSettings.svelte';
8
9
  import NotificationSettings from './notifications/NotificationSettings.svelte';
9
- import GeneralSettings from './general/GeneralSettings.svelte';
10
+ import SecuritySettings from './security/SecuritySettings.svelte';
11
+ import SystemSettings from './system/SystemSettings.svelte';
12
+ import UserManagement from './admin/UserManagement.svelte';
13
+ import InviteManagement from './admin/InviteManagement.svelte';
14
+
15
+ const isAdmin = $derived(authStore.isAdmin);
16
+ const isNoAuth = $derived(authStore.isNoAuth);
10
17
  </script>
11
18
 
12
19
  <PageTemplate
@@ -22,14 +29,21 @@
22
29
  <!-- Appearance Configuration -->
23
30
  <AppearanceSettings />
24
31
 
25
- <!-- User Settings -->
26
- <UserSettings />
27
-
28
32
  <!-- Notification Settings -->
29
33
  <NotificationSettings />
30
34
 
31
- <!-- General Settings -->
32
- <GeneralSettings />
35
+ <!-- Account (hidden in no-auth mode) -->
36
+ {#if !isNoAuth}
37
+ <AccountSettings />
38
+ {/if}
39
+
40
+ <!-- Admin-only sections -->
41
+ {#if isAdmin}
42
+ <UserManagement />
43
+ <InviteManagement />
44
+ <SecuritySettings />
45
+ <SystemSettings />
46
+ {/if}
33
47
 
34
48
  </div>
35
49
  </div>
@@ -0,0 +1,5 @@
1
+ <script lang="ts">
2
+ import UserSettings from '../user/UserSettings.svelte';
3
+ </script>
4
+
5
+ <UserSettings />
@@ -0,0 +1,239 @@
1
+ <script lang="ts">
2
+ import { authStore } from '$frontend/lib/stores/features/auth.svelte';
3
+ import { addNotification } from '$frontend/lib/stores/ui/notification.svelte';
4
+ import Icon from '../../common/Icon.svelte';
5
+ import Dialog from '../../common/Dialog.svelte';
6
+ import ws from '$frontend/lib/utils/ws';
7
+ import { debug } from '$shared/utils/logger';
8
+
9
+ interface Invite {
10
+ id: string;
11
+ role: string;
12
+ label: string | null;
13
+ max_uses: number;
14
+ use_count: number;
15
+ expires_at: string | null;
16
+ created_at: string;
17
+ }
18
+
19
+ let invites = $state<Invite[]>([]);
20
+ let loading = $state(true);
21
+ let isCreating = $state(false);
22
+
23
+ // Store generated URLs by invite ID — persisted in sessionStorage so
24
+ // they survive page refresh (raw token only available at creation time)
25
+ const STORAGE_KEY = 'clopen-invite-urls';
26
+ let inviteURLs = $state<Record<string, string>>({});
27
+
28
+ function loadStoredURLs() {
29
+ try {
30
+ const stored = sessionStorage.getItem(STORAGE_KEY);
31
+ if (stored) inviteURLs = JSON.parse(stored);
32
+ } catch { /* ignore */ }
33
+ }
34
+
35
+ function saveURLsToStorage() {
36
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify(inviteURLs));
37
+ }
38
+
39
+ // Per-invite copy feedback
40
+ let copiedId = $state<string | null>(null);
41
+ let copiedTimer: ReturnType<typeof setTimeout> | null = null;
42
+
43
+ // Revoke state
44
+ let showRevokeConfirm = $state(false);
45
+ let inviteToRevoke = $state<Invite | null>(null);
46
+
47
+ // Countdown ticker
48
+ let tick = $state(0);
49
+ let tickInterval: ReturnType<typeof setInterval> | null = null;
50
+
51
+ // Filter: only show unused and not-expired invites
52
+ const activeInvites = $derived.by(() => {
53
+ void tick;
54
+ const now = Date.now();
55
+ return invites.filter(inv => {
56
+ const usedUp = inv.max_uses > 0 && inv.use_count >= inv.max_uses;
57
+ const expired = inv.expires_at !== null && new Date(inv.expires_at).getTime() < now;
58
+ return !usedUp && !expired;
59
+ });
60
+ });
61
+
62
+ function formatCountdown(expiresAt: string): string {
63
+ void tick;
64
+ const remaining = new Date(expiresAt).getTime() - Date.now();
65
+ if (remaining <= 0) return 'Expired';
66
+
67
+ const totalSec = Math.ceil(remaining / 1000);
68
+ const min = Math.floor(totalSec / 60);
69
+ const sec = totalSec % 60;
70
+ return `${min}:${String(sec).padStart(2, '0')}`;
71
+ }
72
+
73
+ function startTicker() {
74
+ if (tickInterval) return;
75
+ tickInterval = setInterval(() => { tick++; }, 1000);
76
+ }
77
+
78
+ function stopTicker() {
79
+ if (tickInterval) {
80
+ clearInterval(tickInterval);
81
+ tickInterval = null;
82
+ }
83
+ }
84
+
85
+ async function loadInvites() {
86
+ loading = true;
87
+ try {
88
+ invites = await ws.http('auth:list-invites', {});
89
+ } catch (error) {
90
+ debug.error('auth', 'Failed to load invites:', error);
91
+ } finally {
92
+ loading = false;
93
+ }
94
+ }
95
+
96
+ async function generateInvite() {
97
+ isCreating = true;
98
+ try {
99
+ const result = await ws.http('auth:create-invite', {
100
+ maxUses: 1,
101
+ expiresInMinutes: 15
102
+ });
103
+
104
+ const baseURL = window.location.origin;
105
+ const url = `${baseURL}/#invite/${result.inviteToken}`;
106
+ inviteURLs = { ...inviteURLs, [result.invite.id]: url };
107
+ saveURLsToStorage();
108
+
109
+ addNotification({ type: 'success', title: 'Created', message: 'Invite link created' });
110
+ await loadInvites();
111
+ } catch (error) {
112
+ debug.error('auth', 'Failed to create invite:', error);
113
+ addNotification({ type: 'error', title: 'Error', message: 'Failed to create invite' });
114
+ } finally {
115
+ isCreating = false;
116
+ }
117
+ }
118
+
119
+ function copyInviteURL(inviteId: string) {
120
+ const url = inviteURLs[inviteId];
121
+ if (!url) return;
122
+ navigator.clipboard.writeText(url);
123
+ copiedId = inviteId;
124
+ if (copiedTimer) clearTimeout(copiedTimer);
125
+ copiedTimer = setTimeout(() => { copiedId = null; }, 2000);
126
+ }
127
+
128
+ function confirmRevoke(invite: Invite) {
129
+ inviteToRevoke = invite;
130
+ showRevokeConfirm = true;
131
+ }
132
+
133
+ async function revokeInvite() {
134
+ if (!inviteToRevoke) return;
135
+ const revokedId = inviteToRevoke.id;
136
+ try {
137
+ await ws.http('auth:revoke-invite', { id: revokedId });
138
+ const { [revokedId]: _, ...rest } = inviteURLs;
139
+ inviteURLs = rest;
140
+ saveURLsToStorage();
141
+ invites = invites.filter(inv => inv.id !== revokedId);
142
+ addNotification({ type: 'success', title: 'Revoked', message: 'Invite link has been revoked' });
143
+ } catch (error) {
144
+ debug.error('auth', 'Failed to revoke invite:', error);
145
+ addNotification({ type: 'error', title: 'Error', message: 'Failed to revoke invite' });
146
+ } finally {
147
+ showRevokeConfirm = false;
148
+ inviteToRevoke = null;
149
+ }
150
+ }
151
+
152
+ // Load on mount + start ticker + restore URLs from storage
153
+ $effect(() => {
154
+ if (authStore.isAdmin) {
155
+ loadStoredURLs();
156
+ loadInvites();
157
+ startTicker();
158
+ return () => stopTicker();
159
+ }
160
+ });
161
+ </script>
162
+
163
+ {#if authStore.isAdmin}
164
+ <div class="py-1">
165
+ <h3 class="text-base font-bold text-slate-900 dark:text-slate-100 mb-1.5">Invite</h3>
166
+ <p class="text-sm text-slate-600 dark:text-slate-500 mb-5">Generate invite links for new team members</p>
167
+
168
+ {#if loading}
169
+ <div class="flex items-center justify-center gap-3 py-8 text-slate-600 dark:text-slate-500 text-sm">
170
+ <div class="w-5 h-5 border-2 border-violet-500/20 border-t-violet-600 rounded-full animate-spin"></div>
171
+ <span>Loading...</span>
172
+ </div>
173
+ {:else}
174
+ <div class="flex flex-col gap-2">
175
+ {#each activeInvites as invite (invite.id)}
176
+ {@const url = inviteURLs[invite.id]}
177
+ <div class="flex items-center gap-2 px-3 py-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg">
178
+ <div class="flex-1 min-w-0 font-mono text-xs text-slate-600 dark:text-slate-400 truncate select-all">
179
+ {url ?? '—'}
180
+ </div>
181
+ {#if url}
182
+ <button
183
+ type="button"
184
+ onclick={() => copyInviteURL(invite.id)}
185
+ class="flex items-center justify-center w-7 h-7 rounded-md transition-all shrink-0
186
+ {copiedId === invite.id
187
+ ? 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400'
188
+ : 'hover:bg-violet-100 dark:hover:bg-violet-900/30 text-slate-400 hover:text-violet-600 dark:hover:text-violet-400'}"
189
+ title="Copy link"
190
+ >
191
+ <Icon name={copiedId === invite.id ? 'lucide:check' : 'lucide:copy'} class="w-3.5 h-3.5" />
192
+ </button>
193
+ {/if}
194
+ {#if invite.expires_at}
195
+ <span class="text-xs font-mono text-slate-500 tabular-nums shrink-0">
196
+ {formatCountdown(invite.expires_at)}
197
+ </span>
198
+ {/if}
199
+ <button
200
+ type="button"
201
+ onclick={() => confirmRevoke(invite)}
202
+ class="flex items-center justify-center w-7 h-7 rounded-md hover:bg-red-100 dark:hover:bg-red-900/30 text-slate-400 hover:text-red-500 dark:hover:text-red-400 transition-all shrink-0"
203
+ title="Revoke invite"
204
+ >
205
+ <Icon name="lucide:x" class="w-3.5 h-3.5" />
206
+ </button>
207
+ </div>
208
+ {/each}
209
+
210
+ <button
211
+ type="button"
212
+ onclick={generateInvite}
213
+ disabled={isCreating}
214
+ class="inline-flex items-center gap-1.5 py-2 px-3.5 mt-1 bg-violet-500/10 dark:bg-violet-500/10 border border-violet-500/20 dark:border-violet-500/25 rounded-lg text-violet-600 dark:text-violet-400 text-xs font-semibold cursor-pointer transition-all duration-150 hover:bg-violet-500/20 hover:border-violet-600/40 self-start disabled:opacity-50 disabled:cursor-not-allowed"
215
+ >
216
+ {#if isCreating}
217
+ <div class="w-3.5 h-3.5 border-2 border-violet-600/30 border-t-violet-600 rounded-full animate-spin"></div>
218
+ Generating...
219
+ {:else}
220
+ <Icon name="lucide:plus" class="w-3.5 h-3.5" />
221
+ Generate Invite Link
222
+ {/if}
223
+ </button>
224
+ </div>
225
+ {/if}
226
+ </div>
227
+
228
+ <Dialog
229
+ bind:isOpen={showRevokeConfirm}
230
+ onClose={() => { showRevokeConfirm = false; inviteToRevoke = null; }}
231
+ title="Revoke Invite"
232
+ type="warning"
233
+ message="Revoke this invite? Anyone with this link will no longer be able to join."
234
+ confirmText="Revoke"
235
+ cancelText="Cancel"
236
+ showCancel={true}
237
+ onConfirm={revokeInvite}
238
+ />
239
+ {/if}
@@ -0,0 +1,127 @@
1
+ <script lang="ts">
2
+ import { authStore } from '$frontend/lib/stores/features/auth.svelte';
3
+ import { addNotification } from '$frontend/lib/stores/ui/notification.svelte';
4
+ import Icon from '../../common/Icon.svelte';
5
+ import Dialog from '../../common/Dialog.svelte';
6
+ import ws from '$frontend/lib/utils/ws';
7
+ import { debug } from '$shared/utils/logger';
8
+
9
+ interface User {
10
+ id: string;
11
+ name: string;
12
+ color: string;
13
+ avatar: string;
14
+ role: 'admin' | 'member';
15
+ createdAt: string;
16
+ }
17
+
18
+ let users = $state<User[]>([]);
19
+ let loading = $state(true);
20
+
21
+ let showRemoveConfirm = $state(false);
22
+ let userToRemove = $state<User | null>(null);
23
+
24
+ async function loadUsers() {
25
+ loading = true;
26
+ try {
27
+ users = await ws.http('auth:list-users', {});
28
+ } catch (error) {
29
+ debug.error('settings', 'Failed to load users:', error);
30
+ addNotification({ type: 'error', title: 'Error', message: 'Failed to load users' });
31
+ } finally {
32
+ loading = false;
33
+ }
34
+ }
35
+
36
+ function confirmRemove(user: User) {
37
+ userToRemove = user;
38
+ showRemoveConfirm = true;
39
+ }
40
+
41
+ async function removeUser() {
42
+ if (!userToRemove) return;
43
+ try {
44
+ await ws.http('auth:remove-user', { userId: userToRemove.id });
45
+ addNotification({ type: 'success', title: 'Removed', message: `${userToRemove.name} has been removed` });
46
+ showRemoveConfirm = false;
47
+ userToRemove = null;
48
+ await loadUsers();
49
+ } catch (error) {
50
+ debug.error('settings', 'Failed to remove user:', error);
51
+ addNotification({ type: 'error', title: 'Error', message: error instanceof Error ? error.message : 'Failed to remove user' });
52
+ }
53
+ }
54
+
55
+ // Load on mount
56
+ $effect(() => {
57
+ if (authStore.isAdmin) {
58
+ loadUsers();
59
+ }
60
+ });
61
+ </script>
62
+
63
+ {#if authStore.isAdmin}
64
+ <div class="py-1">
65
+ <h3 class="text-base font-bold text-slate-900 dark:text-slate-100 mb-1.5">User Management</h3>
66
+ <p class="text-sm text-slate-600 dark:text-slate-500 mb-5">Manage team members</p>
67
+
68
+ {#if loading}
69
+ <div class="flex items-center justify-center gap-3 py-8 text-slate-600 dark:text-slate-500 text-sm">
70
+ <div class="w-5 h-5 border-2 border-violet-500/20 border-t-violet-600 rounded-full animate-spin"></div>
71
+ <span>Loading users...</span>
72
+ </div>
73
+ {:else}
74
+ <div class="flex flex-col gap-2">
75
+ {#each users as user (user.id)}
76
+ <div class="flex items-center gap-3 p-3.5 bg-slate-100/80 dark:bg-slate-800/80 border border-slate-200 dark:border-slate-800 rounded-xl">
77
+ <div
78
+ class="flex items-center justify-center w-9 h-9 rounded-lg text-sm font-bold text-white shrink-0"
79
+ style="background-color: {user.color || '#7c3aed'}"
80
+ >
81
+ {user.avatar || 'U'}
82
+ </div>
83
+ <div class="flex-1 min-w-0">
84
+ <div class="text-sm font-semibold text-slate-900 dark:text-slate-100">
85
+ {user.name}
86
+ {#if user.id === authStore.currentUser?.id}
87
+ <span class="text-xs text-slate-500 font-normal ml-1">(you)</span>
88
+ {/if}
89
+ </div>
90
+ <div class="text-xs text-slate-500">
91
+ {user.role === 'admin' ? 'Administrator' : 'Member'} &middot; Joined {new Date(user.createdAt).toLocaleDateString()}
92
+ </div>
93
+ </div>
94
+ <span class="inline-flex items-center gap-1 py-1 px-2.5 rounded-full text-2xs font-semibold
95
+ {user.role === 'admin' ? 'bg-violet-500/15 text-violet-600 dark:text-violet-400' : 'bg-slate-200 dark:bg-slate-700 text-slate-600 dark:text-slate-400'}">
96
+ <Icon name="lucide:{user.role === 'admin' ? 'shield' : 'user'}" class="w-3 h-3" />
97
+ {user.role === 'admin' ? 'Admin' : 'Member'}
98
+ </span>
99
+ {#if user.role !== 'admin' && user.id !== authStore.currentUser?.id}
100
+ <button
101
+ type="button"
102
+ onclick={() => confirmRemove(user)}
103
+ class="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 text-slate-400 hover:text-red-500 dark:hover:text-red-400 transition-all"
104
+ title="Remove user"
105
+ >
106
+ <Icon name="lucide:user-minus" class="w-4 h-4" />
107
+ </button>
108
+ {/if}
109
+ </div>
110
+ {/each}
111
+ </div>
112
+ {/if}
113
+ </div>
114
+
115
+ <!-- Remove User Confirmation -->
116
+ <Dialog
117
+ bind:isOpen={showRemoveConfirm}
118
+ onClose={() => { showRemoveConfirm = false; userToRemove = null; }}
119
+ title="Remove User"
120
+ type="warning"
121
+ message={`Remove "${userToRemove?.name || ''}" from the team? Their sessions will be terminated immediately.`}
122
+ confirmText="Remove"
123
+ cancelText="Cancel"
124
+ showCancel={true}
125
+ onConfirm={removeUser}
126
+ />
127
+ {/if}