@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
|
@@ -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 {
|
|
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(
|
|
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 (!
|
|
93
|
-
return
|
|
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 &&
|
|
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 (
|
|
132
|
-
return
|
|
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: ${
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
19
|
+
import AccountSettings from './account/AccountSettings.svelte';
|
|
18
20
|
import NotificationSettings from './notifications/NotificationSettings.svelte';
|
|
19
|
-
import
|
|
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-
|
|
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-
|
|
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
|
|
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 === '
|
|
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
|
-
<
|
|
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
|
|
8
|
+
import AccountSettings from './account/AccountSettings.svelte';
|
|
8
9
|
import NotificationSettings from './notifications/NotificationSettings.svelte';
|
|
9
|
-
import
|
|
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
|
-
<!--
|
|
32
|
-
|
|
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,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'} · 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}
|