@myrialabs/clopen 0.1.9 → 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 (94) hide show
  1. package/README.md +23 -1
  2. package/backend/index.ts +25 -1
  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/chat/stream-manager.ts +4 -1
  9. package/backend/lib/database/migrations/024_create_users_table.ts +29 -0
  10. package/backend/lib/database/migrations/025_create_auth_sessions_table.ts +38 -0
  11. package/backend/lib/database/migrations/026_create_invite_tokens_table.ts +31 -0
  12. package/backend/lib/database/migrations/index.ts +21 -0
  13. package/backend/lib/database/queries/auth-queries.ts +201 -0
  14. package/backend/lib/database/queries/index.ts +2 -1
  15. package/backend/lib/database/queries/session-queries.ts +13 -0
  16. package/backend/lib/database/queries/snapshot-queries.ts +1 -1
  17. package/backend/lib/engine/adapters/opencode/server.ts +9 -1
  18. package/backend/lib/engine/adapters/opencode/stream.ts +175 -1
  19. package/backend/lib/mcp/config.ts +13 -18
  20. package/backend/lib/mcp/index.ts +9 -0
  21. package/backend/lib/mcp/remote-server.ts +132 -0
  22. package/backend/lib/mcp/servers/helper.ts +49 -3
  23. package/backend/lib/mcp/servers/index.ts +3 -2
  24. package/backend/lib/preview/browser/browser-audio-capture.ts +20 -3
  25. package/backend/lib/preview/browser/browser-navigation-tracker.ts +3 -0
  26. package/backend/lib/preview/browser/browser-pool.ts +73 -176
  27. package/backend/lib/preview/browser/browser-preview-service.ts +3 -2
  28. package/backend/lib/preview/browser/browser-tab-manager.ts +261 -23
  29. package/backend/lib/preview/browser/browser-video-capture.ts +36 -1
  30. package/backend/lib/snapshot/helpers.ts +22 -49
  31. package/backend/lib/snapshot/snapshot-service.ts +148 -83
  32. package/backend/lib/utils/ws.ts +65 -1
  33. package/backend/ws/auth/index.ts +17 -0
  34. package/backend/ws/auth/invites.ts +84 -0
  35. package/backend/ws/auth/login.ts +269 -0
  36. package/backend/ws/auth/status.ts +41 -0
  37. package/backend/ws/auth/users.ts +32 -0
  38. package/backend/ws/chat/stream.ts +13 -0
  39. package/backend/ws/engine/claude/accounts.ts +3 -1
  40. package/backend/ws/engine/utils.ts +38 -6
  41. package/backend/ws/index.ts +4 -4
  42. package/backend/ws/preview/browser/interact.ts +27 -5
  43. package/backend/ws/snapshot/restore.ts +111 -12
  44. package/backend/ws/snapshot/timeline.ts +56 -29
  45. package/bin/clopen.ts +56 -1
  46. package/bun.lock +113 -51
  47. package/frontend/App.svelte +47 -29
  48. package/frontend/lib/components/auth/InvitePage.svelte +215 -0
  49. package/frontend/lib/components/auth/LoginPage.svelte +129 -0
  50. package/frontend/lib/components/auth/SetupPage.svelte +1022 -0
  51. package/frontend/lib/components/chat/input/ChatInput.svelte +1 -2
  52. package/frontend/lib/components/chat/input/components/EngineModelPicker.svelte +2 -2
  53. package/frontend/lib/components/chat/input/composables/use-chat-actions.svelte.ts +4 -4
  54. package/frontend/lib/components/chat/input/composables/use-textarea-resize.svelte.ts +11 -19
  55. package/frontend/lib/components/checkpoint/TimelineModal.svelte +15 -3
  56. package/frontend/lib/components/checkpoint/timeline/TimelineNode.svelte +30 -19
  57. package/frontend/lib/components/checkpoint/timeline/types.ts +4 -0
  58. package/frontend/lib/components/common/FolderBrowser.svelte +9 -9
  59. package/frontend/lib/components/common/UpdateBanner.svelte +2 -2
  60. package/frontend/lib/components/git/CommitForm.svelte +6 -4
  61. package/frontend/lib/components/history/HistoryModal.svelte +1 -1
  62. package/frontend/lib/components/history/HistoryView.svelte +1 -1
  63. package/frontend/lib/components/preview/browser/BrowserPreview.svelte +1 -1
  64. package/frontend/lib/components/preview/browser/core/mcp-handlers.svelte.ts +12 -4
  65. package/frontend/lib/components/settings/SettingsModal.svelte +50 -15
  66. package/frontend/lib/components/settings/SettingsView.svelte +21 -7
  67. package/frontend/lib/components/settings/account/AccountSettings.svelte +5 -0
  68. package/frontend/lib/components/settings/admin/InviteManagement.svelte +239 -0
  69. package/frontend/lib/components/settings/admin/UserManagement.svelte +127 -0
  70. package/frontend/lib/components/settings/general/AdvancedSettings.svelte +10 -4
  71. package/frontend/lib/components/settings/general/AuthModeSettings.svelte +229 -0
  72. package/frontend/lib/components/settings/general/GeneralSettings.svelte +6 -1
  73. package/frontend/lib/components/settings/general/UpdateSettings.svelte +5 -5
  74. package/frontend/lib/components/settings/security/SecuritySettings.svelte +10 -0
  75. package/frontend/lib/components/settings/system/SystemSettings.svelte +10 -0
  76. package/frontend/lib/components/settings/user/UserSettings.svelte +147 -74
  77. package/frontend/lib/components/workspace/PanelHeader.svelte +1 -1
  78. package/frontend/lib/components/workspace/WorkspaceLayout.svelte +5 -10
  79. package/frontend/lib/components/workspace/panels/ChatPanel.svelte +3 -2
  80. package/frontend/lib/services/preview/browser/browser-webcodecs.service.ts +31 -8
  81. package/frontend/lib/stores/core/sessions.svelte.ts +15 -1
  82. package/frontend/lib/stores/features/auth.svelte.ts +296 -0
  83. package/frontend/lib/stores/features/settings.svelte.ts +53 -9
  84. package/frontend/lib/stores/features/user.svelte.ts +26 -68
  85. package/frontend/lib/stores/ui/settings-modal.svelte.ts +42 -21
  86. package/frontend/lib/stores/ui/update.svelte.ts +2 -14
  87. package/frontend/lib/stores/ui/workspace.svelte.ts +4 -4
  88. package/package.json +8 -6
  89. package/shared/types/stores/settings.ts +16 -2
  90. package/shared/utils/logger.ts +1 -0
  91. package/shared/utils/ws-client.ts +30 -13
  92. package/shared/utils/ws-server.ts +42 -4
  93. package/backend/lib/mcp/stdio-server.ts +0 -103
  94. package/backend/ws/mcp/index.ts +0 -61
@@ -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}
@@ -1,9 +1,13 @@
1
1
  <script lang="ts">
2
- import { settings, updateSettings } from '$frontend/lib/stores/features/settings.svelte';
2
+ import { systemSettings, updateSystemSettings } from '$frontend/lib/stores/features/settings.svelte';
3
+ import { authStore } from '$frontend/lib/stores/features/auth.svelte';
3
4
  import Icon from '../../common/Icon.svelte';
4
5
  import Dialog from '../../common/Dialog.svelte';
5
6
  import { detectPlatform } from '$frontend/lib/utils/platform';
6
7
 
8
+ const isAdmin = $derived(authStore.isAdmin);
9
+ const settings = $derived(systemSettings);
10
+
7
11
  let showAddPathDialog = $state(false);
8
12
  let newPathValue = $state('');
9
13
 
@@ -29,7 +33,7 @@
29
33
  function addPath() {
30
34
  const path = newPathValue.trim();
31
35
  if (path && !settings.allowedBasePaths.includes(path)) {
32
- updateSettings({ allowedBasePaths: [...settings.allowedBasePaths, path] });
36
+ updateSystemSettings({ allowedBasePaths: [...settings.allowedBasePaths, path] });
33
37
  }
34
38
  newPathValue = '';
35
39
  showAddPathDialog = false;
@@ -38,7 +42,7 @@
38
42
  function removePath(index: number) {
39
43
  const newPaths = [...settings.allowedBasePaths];
40
44
  newPaths.splice(index, 1);
41
- updateSettings({ allowedBasePaths: newPaths });
45
+ updateSystemSettings({ allowedBasePaths: newPaths });
42
46
  if (editingIndex === index) {
43
47
  editingIndex = null;
44
48
  editingValue = '';
@@ -56,7 +60,7 @@
56
60
  if (path) {
57
61
  const newPaths = [...settings.allowedBasePaths];
58
62
  newPaths[editingIndex] = path;
59
- updateSettings({ allowedBasePaths: newPaths });
63
+ updateSystemSettings({ allowedBasePaths: newPaths });
60
64
  }
61
65
  editingIndex = null;
62
66
  editingValue = '';
@@ -73,6 +77,7 @@
73
77
  }
74
78
  </script>
75
79
 
80
+ {#if isAdmin}
76
81
  <div class="py-1">
77
82
  <h3 class="text-base font-bold text-slate-900 dark:text-slate-100 mb-1.5">Advanced</h3>
78
83
  <p class="text-sm text-slate-600 dark:text-slate-500 mb-5">Security and access control settings</p>
@@ -170,6 +175,7 @@
170
175
  </div>
171
176
  </div>
172
177
  </div>
178
+ {/if}
173
179
 
174
180
  <Dialog
175
181
  bind:isOpen={showAddPathDialog}
@@ -0,0 +1,229 @@
1
+ <script lang="ts">
2
+ import { systemSettings, updateSystemSettings } from '$frontend/lib/stores/features/settings.svelte';
3
+ import { authStore } from '$frontend/lib/stores/features/auth.svelte';
4
+ import { addNotification } from '$frontend/lib/stores/ui/notification.svelte';
5
+ import Icon from '../../common/Icon.svelte';
6
+ import Dialog from '../../common/Dialog.svelte';
7
+ import type { AuthMode } from '$shared/types/stores/settings';
8
+ import { debug } from '$shared/utils/logger';
9
+
10
+ const isAdmin = $derived(authStore.isAdmin);
11
+ const currentMode = $derived(systemSettings.authMode);
12
+
13
+ // Simple confirmation dialog (required → none)
14
+ let showSimpleConfirm = $state(false);
15
+ let pendingMode = $state<AuthMode>('required');
16
+
17
+ // PAT dialog (none → required)
18
+ let showPatDialog = $state(false);
19
+ let generatedPat = $state('');
20
+ let patCopied = $state(false);
21
+ let isPreparingSwitch = $state(false);
22
+
23
+ async function requestModeChange(mode: AuthMode) {
24
+ if (mode === currentMode) return;
25
+ pendingMode = mode;
26
+
27
+ if (currentMode === 'none' && mode === 'required') {
28
+ // Switching to with-auth: regenerate PAT first, then show dialog
29
+ isPreparingSwitch = true;
30
+ try {
31
+ const pat = await authStore.regeneratePAT();
32
+ generatedPat = pat;
33
+ showPatDialog = true;
34
+ } catch (error) {
35
+ debug.error('settings', 'Failed to regenerate PAT for auth mode switch:', error);
36
+ addNotification({ type: 'error', title: 'Error', message: 'Failed to prepare authentication switch' });
37
+ } finally {
38
+ isPreparingSwitch = false;
39
+ }
40
+ } else {
41
+ showSimpleConfirm = true;
42
+ }
43
+ }
44
+
45
+ function confirmSimpleChange() {
46
+ showSimpleConfirm = false;
47
+ updateSystemSettings({ authMode: pendingMode });
48
+ }
49
+
50
+ async function confirmWithAuthSwitch() {
51
+ showPatDialog = false;
52
+
53
+ // Save auth mode
54
+ await updateSystemSettings({ authMode: 'required' });
55
+
56
+ // Logout all sessions (including current) — forces everyone to re-login
57
+ await authStore.logoutAll();
58
+
59
+ generatedPat = '';
60
+ patCopied = false;
61
+ }
62
+
63
+ function cancelPatDialog() {
64
+ showPatDialog = false;
65
+ generatedPat = '';
66
+ patCopied = false;
67
+ }
68
+
69
+ async function copyPat() {
70
+ if (generatedPat) {
71
+ await navigator.clipboard.writeText(generatedPat);
72
+ patCopied = true;
73
+ setTimeout(() => { patCopied = false; }, 2000);
74
+ }
75
+ }
76
+ </script>
77
+
78
+ {#if isAdmin}
79
+ <div class="py-1">
80
+ <h3 class="text-base font-bold text-slate-900 dark:text-slate-100 mb-1.5">Authentication</h3>
81
+ <p class="text-sm text-slate-600 dark:text-slate-500 mb-5">Configure how users access Clopen</p>
82
+
83
+ <div class="flex flex-col gap-3.5">
84
+ <!-- No Login -->
85
+ <button
86
+ type="button"
87
+ disabled={isPreparingSwitch}
88
+ class="w-full text-left p-4 bg-slate-100/80 dark:bg-slate-800/80 border rounded-xl transition-all
89
+ {currentMode === 'none'
90
+ ? 'border-violet-500/50 ring-1 ring-violet-500/20'
91
+ : 'border-slate-200 dark:border-slate-800 hover:border-slate-300 dark:hover:border-slate-700'}
92
+ disabled:opacity-50 disabled:cursor-not-allowed"
93
+ onclick={() => requestModeChange('none')}
94
+ >
95
+ <div class="flex items-center gap-3.5">
96
+ <div class="flex items-center justify-center w-10 h-10 rounded-lg shrink-0
97
+ {currentMode === 'none'
98
+ ? 'bg-violet-500/15 text-violet-600 dark:text-violet-400'
99
+ : 'bg-slate-200/80 dark:bg-slate-700 text-slate-400'}">
100
+ <Icon name="lucide:lock-open" class="w-5 h-5" />
101
+ </div>
102
+ <div class="flex-1 min-w-0">
103
+ <div class="text-sm font-semibold text-slate-900 dark:text-slate-100">No Login</div>
104
+ <div class="text-xs text-slate-600 dark:text-slate-500">
105
+ No authentication required. Anyone with access to this URL can use Clopen.
106
+ </div>
107
+ </div>
108
+ {#if currentMode === 'none'}
109
+ <div class="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300 shrink-0">
110
+ <span class="w-1.5 h-1.5 rounded-full bg-violet-500"></span>
111
+ Active
112
+ </div>
113
+ {/if}
114
+ </div>
115
+ </button>
116
+
117
+ <!-- With Login -->
118
+ <button
119
+ type="button"
120
+ disabled={isPreparingSwitch}
121
+ class="w-full text-left p-4 bg-slate-100/80 dark:bg-slate-800/80 border rounded-xl transition-all
122
+ {currentMode === 'required'
123
+ ? 'border-violet-500/50 ring-1 ring-violet-500/20'
124
+ : 'border-slate-200 dark:border-slate-800 hover:border-slate-300 dark:hover:border-slate-700'}
125
+ disabled:opacity-50 disabled:cursor-not-allowed"
126
+ onclick={() => requestModeChange('required')}
127
+ >
128
+ <div class="flex items-center gap-3.5">
129
+ <div class="flex items-center justify-center w-10 h-10 rounded-lg shrink-0
130
+ {currentMode === 'required'
131
+ ? 'bg-violet-500/15 text-violet-600 dark:text-violet-400'
132
+ : 'bg-slate-200/80 dark:bg-slate-700 text-slate-400'}">
133
+ <Icon name="lucide:lock" class="w-5 h-5" />
134
+ </div>
135
+ <div class="flex-1 min-w-0">
136
+ <div class="text-sm font-semibold text-slate-900 dark:text-slate-100">With Login</div>
137
+ <div class="text-xs text-slate-600 dark:text-slate-500">
138
+ Authenticate with a Personal Access Token. Supports multiple users and invite links.
139
+ </div>
140
+ </div>
141
+ {#if currentMode === 'required'}
142
+ <div class="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300 shrink-0">
143
+ <span class="w-1.5 h-1.5 rounded-full bg-violet-500"></span>
144
+ Active
145
+ </div>
146
+ {/if}
147
+ </div>
148
+ </button>
149
+ </div>
150
+
151
+ {#if isPreparingSwitch}
152
+ <div class="flex items-center gap-2 mt-4 text-sm text-slate-500">
153
+ <div class="w-4 h-4 border-2 border-violet-500/20 border-t-violet-600 rounded-full animate-spin"></div>
154
+ <span>Preparing authentication switch...</span>
155
+ </div>
156
+ {/if}
157
+ </div>
158
+ {/if}
159
+
160
+ <!-- Simple confirm dialog (required → none) -->
161
+ <Dialog
162
+ bind:isOpen={showSimpleConfirm}
163
+ onClose={() => { showSimpleConfirm = false; }}
164
+ type="info"
165
+ title="Change Authentication Mode"
166
+ message="Switching to No Login mode will disable login requirements. Existing users and sessions will be preserved but bypassed."
167
+ confirmText="Confirm"
168
+ cancelText="Cancel"
169
+ showCancel={true}
170
+ onConfirm={confirmSimpleChange}
171
+ />
172
+
173
+ <!-- PAT dialog (none → required) -->
174
+ <Dialog
175
+ bind:isOpen={showPatDialog}
176
+ onClose={cancelPatDialog}
177
+ type="warning"
178
+ title="Change Authentication Mode"
179
+ closable={false}
180
+ confirmText="Confirm"
181
+ cancelText="Cancel"
182
+ showCancel={true}
183
+ onConfirm={confirmWithAuthSwitch}
184
+ >
185
+ {#snippet children()}
186
+ <div class="flex items-start space-x-4">
187
+ <div class="bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-700/50 rounded-xl p-3 border">
188
+ <Icon name="lucide:triangle-alert" class="w-6 h-6 text-amber-600 dark:text-amber-400" />
189
+ </div>
190
+
191
+ <div class="flex-1 space-y-3">
192
+ <h3 class="text-lg font-semibold text-slate-900 dark:text-slate-100">
193
+ Change Authentication Mode
194
+ </h3>
195
+ <p class="text-sm text-slate-600 dark:text-slate-400 leading-relaxed">
196
+ Switching to With Login mode will require authentication. All sessions will be logged out and you will need this token to log in again.
197
+ </p>
198
+
199
+ <!-- PAT Display -->
200
+ <div class="p-3.5 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800">
201
+ <div class="flex items-center gap-2 text-sm font-semibold text-amber-800 dark:text-amber-200 mb-2">
202
+ <Icon name="lucide:key-round" class="w-4 h-4" />
203
+ <span>Your Personal Access Token</span>
204
+ </div>
205
+ <div class="flex items-center gap-2">
206
+ <code class="flex-1 px-3 py-2 rounded-md bg-white dark:bg-slate-900 border border-amber-300 dark:border-amber-700 text-xs font-mono text-slate-900 dark:text-slate-100 select-all break-all">
207
+ {generatedPat}
208
+ </code>
209
+ <button
210
+ type="button"
211
+ onclick={copyPat}
212
+ class="shrink-0 flex items-center justify-center w-9 h-9 rounded-lg transition-all
213
+ {patCopied
214
+ ? 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400'
215
+ : 'bg-amber-100 dark:bg-amber-900 hover:bg-amber-200 dark:hover:bg-amber-800 text-amber-800 dark:text-amber-200'}"
216
+ title="Copy token"
217
+ >
218
+ <Icon name={patCopied ? 'lucide:check' : 'lucide:copy'} class="w-4 h-4" />
219
+ </button>
220
+ </div>
221
+ <div class="flex items-center gap-1.5 mt-2 text-xs text-amber-600 dark:text-amber-400">
222
+ <Icon name="lucide:triangle-alert" class="w-3.5 h-3.5 shrink-0" />
223
+ <span>Copy this token now. It won't be shown again.</span>
224
+ </div>
225
+ </div>
226
+ </div>
227
+ </div>
228
+ {/snippet}
229
+ </Dialog>
@@ -2,9 +2,14 @@
2
2
  import DataManagementSettings from './DataManagementSettings.svelte';
3
3
  import AdvancedSettings from './AdvancedSettings.svelte';
4
4
  import UpdateSettings from './UpdateSettings.svelte';
5
+ import AuthModeSettings from './AuthModeSettings.svelte';
5
6
  </script>
6
7
 
7
- <UpdateSettings />
8
+ <AuthModeSettings />
9
+
10
+ <div class="mt-6">
11
+ <UpdateSettings />
12
+ </div>
8
13
 
9
14
  <div class="mt-6">
10
15
  <DataManagementSettings />