@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.
- package/README.md +23 -1
- package/backend/index.ts +25 -1
- 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/chat/stream-manager.ts +4 -1
- 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/database/queries/session-queries.ts +13 -0
- package/backend/lib/database/queries/snapshot-queries.ts +1 -1
- package/backend/lib/engine/adapters/opencode/server.ts +9 -1
- package/backend/lib/engine/adapters/opencode/stream.ts +175 -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/snapshot/helpers.ts +22 -49
- package/backend/lib/snapshot/snapshot-service.ts +148 -83
- 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/chat/stream.ts +13 -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/backend/ws/snapshot/restore.ts +111 -12
- package/backend/ws/snapshot/timeline.ts +56 -29
- package/bin/clopen.ts +56 -1
- 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/chat/input/ChatInput.svelte +1 -2
- package/frontend/lib/components/chat/input/components/EngineModelPicker.svelte +2 -2
- package/frontend/lib/components/chat/input/composables/use-chat-actions.svelte.ts +4 -4
- package/frontend/lib/components/chat/input/composables/use-textarea-resize.svelte.ts +11 -19
- package/frontend/lib/components/checkpoint/TimelineModal.svelte +15 -3
- package/frontend/lib/components/checkpoint/timeline/TimelineNode.svelte +30 -19
- package/frontend/lib/components/checkpoint/timeline/types.ts +4 -0
- package/frontend/lib/components/common/FolderBrowser.svelte +9 -9
- package/frontend/lib/components/common/UpdateBanner.svelte +2 -2
- package/frontend/lib/components/git/CommitForm.svelte +6 -4
- package/frontend/lib/components/history/HistoryModal.svelte +1 -1
- package/frontend/lib/components/history/HistoryView.svelte +1 -1
- 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/PanelHeader.svelte +1 -1
- package/frontend/lib/components/workspace/WorkspaceLayout.svelte +5 -10
- package/frontend/lib/components/workspace/panels/ChatPanel.svelte +3 -2
- package/frontend/lib/services/preview/browser/browser-webcodecs.service.ts +31 -8
- package/frontend/lib/stores/core/sessions.svelte.ts +15 -1
- 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 -14
- package/frontend/lib/stores/ui/workspace.svelte.ts +4 -4
- 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
|
@@ -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}
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
8
|
+
<AuthModeSettings />
|
|
9
|
+
|
|
10
|
+
<div class="mt-6">
|
|
11
|
+
<UpdateSettings />
|
|
12
|
+
</div>
|
|
8
13
|
|
|
9
14
|
<div class="mt-6">
|
|
10
15
|
<DataManagementSettings />
|