@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
package/frontend/App.svelte
CHANGED
|
@@ -1,38 +1,46 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { onMount, onDestroy } from 'svelte';
|
|
3
|
+
import { authStore } from '$frontend/lib/stores/features/auth.svelte';
|
|
3
4
|
import WorkspaceLayout from '$frontend/lib/components/workspace/WorkspaceLayout.svelte';
|
|
4
5
|
import ConnectionBanner from '$frontend/lib/components/common/ConnectionBanner.svelte';
|
|
5
6
|
import UpdateBanner from '$frontend/lib/components/common/UpdateBanner.svelte';
|
|
7
|
+
import LoadingScreen from '$frontend/lib/components/common/LoadingScreen.svelte';
|
|
8
|
+
import SetupPage from '$frontend/lib/components/auth/SetupPage.svelte';
|
|
9
|
+
import LoginPage from '$frontend/lib/components/auth/LoginPage.svelte';
|
|
10
|
+
import InvitePage from '$frontend/lib/components/auth/InvitePage.svelte';
|
|
6
11
|
import { backgroundTerminalService } from '$frontend/lib/services/terminal/background';
|
|
7
12
|
import { initializeMCPPreview } from '$frontend/lib/services/preview';
|
|
8
13
|
import { globalStreamMonitor } from '$frontend/lib/services/notification/global-stream-monitor';
|
|
9
14
|
import { tunnelStore } from '$frontend/lib/stores/features/tunnel.svelte';
|
|
10
15
|
import { startUpdateChecker, stopUpdateChecker } from '$frontend/lib/stores/ui/update.svelte';
|
|
11
16
|
|
|
12
|
-
|
|
13
|
-
// For now, we'll just render the main workspace
|
|
17
|
+
let servicesInitialized = false;
|
|
14
18
|
|
|
15
|
-
// Initialize
|
|
19
|
+
// Initialize auth on mount
|
|
16
20
|
onMount(async () => {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
await authStore.initialize();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Initialize background services when auth is ready
|
|
25
|
+
$effect(() => {
|
|
26
|
+
if (authStore.authState === 'ready' && !servicesInitialized) {
|
|
27
|
+
servicesInitialized = true;
|
|
20
28
|
|
|
21
|
-
|
|
22
|
-
|
|
29
|
+
// Initialize global stream monitor (registers WS listener, non-blocking)
|
|
30
|
+
globalStreamMonitor.initialize();
|
|
23
31
|
|
|
24
|
-
|
|
25
|
-
|
|
32
|
+
// Initialize background terminal service
|
|
33
|
+
backgroundTerminalService.initialize();
|
|
26
34
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
initializeMCPPreview();
|
|
35
|
+
// Initialize MCP Preview Integration
|
|
36
|
+
initializeMCPPreview();
|
|
30
37
|
|
|
31
|
-
|
|
32
|
-
|
|
38
|
+
// Restore tunnel status
|
|
39
|
+
tunnelStore.checkStatus();
|
|
33
40
|
|
|
34
|
-
|
|
35
|
-
|
|
41
|
+
// Start periodic update checker
|
|
42
|
+
startUpdateChecker();
|
|
43
|
+
}
|
|
36
44
|
});
|
|
37
45
|
|
|
38
46
|
onDestroy(() => {
|
|
@@ -40,16 +48,26 @@
|
|
|
40
48
|
});
|
|
41
49
|
</script>
|
|
42
50
|
|
|
43
|
-
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
{#if authStore.authState === 'loading'}
|
|
52
|
+
<LoadingScreen isVisible={true} progress={30} loadingText="Connecting..." />
|
|
53
|
+
{:else if authStore.authState === 'setup'}
|
|
54
|
+
<SetupPage />
|
|
55
|
+
{:else if authStore.authState === 'login'}
|
|
56
|
+
<LoginPage />
|
|
57
|
+
{:else if authStore.authState === 'invite'}
|
|
58
|
+
<InvitePage />
|
|
59
|
+
{:else}
|
|
60
|
+
<!-- authState === 'ready' -->
|
|
61
|
+
<div class="flex flex-col h-dvh w-screen overflow-hidden">
|
|
62
|
+
<ConnectionBanner />
|
|
63
|
+
<UpdateBanner />
|
|
64
|
+
|
|
65
|
+
<div class="flex-1 min-h-0">
|
|
66
|
+
<WorkspaceLayout>
|
|
67
|
+
{#snippet children()}
|
|
68
|
+
<!-- Main content -->
|
|
69
|
+
{/snippet}
|
|
70
|
+
</WorkspaceLayout>
|
|
71
|
+
</div>
|
|
54
72
|
</div>
|
|
55
|
-
|
|
73
|
+
{/if}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from 'svelte';
|
|
3
|
+
import { authStore } from '$frontend/lib/stores/features/auth.svelte';
|
|
4
|
+
import ws from '$frontend/lib/utils/ws';
|
|
5
|
+
|
|
6
|
+
let name = $state('');
|
|
7
|
+
let error = $state('');
|
|
8
|
+
let isLoading = $state(false);
|
|
9
|
+
let isValidating = $state(true);
|
|
10
|
+
let inviteValid = $state(false);
|
|
11
|
+
let showPAT = $state(false);
|
|
12
|
+
let patCopied = $state(false);
|
|
13
|
+
|
|
14
|
+
// Rate limit countdown
|
|
15
|
+
let lockoutSeconds = $state(0);
|
|
16
|
+
let countdownInterval: ReturnType<typeof setInterval> | null = null;
|
|
17
|
+
|
|
18
|
+
function parseRateLimitSeconds(message: string): number {
|
|
19
|
+
const match = message.match(/Try again in (\d+) seconds/);
|
|
20
|
+
return match ? parseInt(match[1], 10) : 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function startCountdown(seconds: number) {
|
|
24
|
+
stopCountdown();
|
|
25
|
+
lockoutSeconds = seconds;
|
|
26
|
+
countdownInterval = setInterval(() => {
|
|
27
|
+
lockoutSeconds -= 1;
|
|
28
|
+
if (lockoutSeconds <= 0) {
|
|
29
|
+
stopCountdown();
|
|
30
|
+
error = '';
|
|
31
|
+
}
|
|
32
|
+
}, 1000);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function stopCountdown() {
|
|
36
|
+
lockoutSeconds = 0;
|
|
37
|
+
if (countdownInterval) {
|
|
38
|
+
clearInterval(countdownInterval);
|
|
39
|
+
countdownInterval = null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const isLockedOut = $derived(lockoutSeconds > 0);
|
|
44
|
+
|
|
45
|
+
const displayError = $derived(
|
|
46
|
+
isLockedOut
|
|
47
|
+
? `Too many failed attempts. Try again in ${lockoutSeconds} seconds.`
|
|
48
|
+
: error
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
// Extract invite token from URL hash
|
|
52
|
+
const hash = window.location.hash;
|
|
53
|
+
const inviteToken = hash.startsWith('#invite/') ? hash.slice(8) : '';
|
|
54
|
+
|
|
55
|
+
onMount(async () => {
|
|
56
|
+
if (!inviteToken) {
|
|
57
|
+
error = 'No invite token found';
|
|
58
|
+
isValidating = false;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const result = await ws.http('auth:validate-invite', { inviteToken });
|
|
64
|
+
inviteValid = result.valid;
|
|
65
|
+
if (!result.valid) {
|
|
66
|
+
error = result.error ?? 'Invalid invite token';
|
|
67
|
+
}
|
|
68
|
+
} catch (err) {
|
|
69
|
+
error = err instanceof Error ? err.message : 'Failed to validate invite';
|
|
70
|
+
} finally {
|
|
71
|
+
isValidating = false;
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
async function handleAccept() {
|
|
76
|
+
if (!name.trim()) {
|
|
77
|
+
error = 'Please enter a display name';
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
error = '';
|
|
82
|
+
isLoading = true;
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
await authStore.acceptInvite(inviteToken, name.trim());
|
|
86
|
+
showPAT = true;
|
|
87
|
+
} catch (err) {
|
|
88
|
+
const message = err instanceof Error ? err.message : 'Failed to accept invite';
|
|
89
|
+
error = message;
|
|
90
|
+
|
|
91
|
+
const seconds = parseRateLimitSeconds(message);
|
|
92
|
+
if (seconds > 0) {
|
|
93
|
+
startCountdown(seconds);
|
|
94
|
+
}
|
|
95
|
+
} finally {
|
|
96
|
+
isLoading = false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function copyPAT() {
|
|
101
|
+
if (authStore.personalAccessToken) {
|
|
102
|
+
await navigator.clipboard.writeText(authStore.personalAccessToken);
|
|
103
|
+
patCopied = true;
|
|
104
|
+
setTimeout(() => { patCopied = false; }, 2000);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function handleContinue() {
|
|
109
|
+
authStore.completeInvite();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
113
|
+
if (e.key === 'Enter' && !showPAT && !isLockedOut) {
|
|
114
|
+
handleAccept();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
</script>
|
|
118
|
+
|
|
119
|
+
<div class="fixed inset-0 z-[9999] bg-white dark:bg-slate-950 flex items-center justify-center">
|
|
120
|
+
<div class="flex flex-col items-center gap-6 text-center px-4 max-w-md w-full">
|
|
121
|
+
<!-- Logo -->
|
|
122
|
+
<div>
|
|
123
|
+
<img src="/favicon.svg" alt="Clopen" class="w-16 h-16 rounded-2xl shadow-xl" />
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
{#if isValidating}
|
|
127
|
+
<div class="space-y-2">
|
|
128
|
+
<h1 class="text-2xl font-bold text-slate-900 dark:text-slate-100">Clopen</h1>
|
|
129
|
+
<p class="text-sm text-slate-500 dark:text-slate-400">Validating invite...</p>
|
|
130
|
+
</div>
|
|
131
|
+
{:else if !inviteValid && !showPAT}
|
|
132
|
+
<!-- Invalid invite -->
|
|
133
|
+
<div class="space-y-4">
|
|
134
|
+
<h1 class="text-2xl font-bold text-slate-900 dark:text-slate-100">Invalid Invite</h1>
|
|
135
|
+
<p class="text-sm text-red-500">{error}</p>
|
|
136
|
+
<a
|
|
137
|
+
href="/"
|
|
138
|
+
class="inline-block py-2 px-4 rounded-lg bg-slate-200 dark:bg-slate-800 hover:bg-slate-300 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 text-sm font-medium transition-colors"
|
|
139
|
+
>
|
|
140
|
+
Go to Login
|
|
141
|
+
</a>
|
|
142
|
+
</div>
|
|
143
|
+
{:else if !showPAT}
|
|
144
|
+
<!-- Accept invite form -->
|
|
145
|
+
<div class="space-y-1">
|
|
146
|
+
<h1 class="text-2xl font-bold text-slate-900 dark:text-slate-100">You've been invited</h1>
|
|
147
|
+
<p class="text-sm text-slate-500 dark:text-slate-400">Enter your name to join Clopen</p>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
<div class="w-full space-y-4">
|
|
151
|
+
<div class="text-left">
|
|
152
|
+
<label for="name" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
|
153
|
+
Display Name
|
|
154
|
+
</label>
|
|
155
|
+
<input
|
|
156
|
+
id="name"
|
|
157
|
+
type="text"
|
|
158
|
+
bind:value={name}
|
|
159
|
+
onkeydown={handleKeydown}
|
|
160
|
+
placeholder="Enter your name"
|
|
161
|
+
disabled={isLoading || isLockedOut}
|
|
162
|
+
class="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 text-sm focus:outline-none focus:ring-2 focus:ring-violet-500 disabled:opacity-50"
|
|
163
|
+
/>
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
{#if displayError}
|
|
167
|
+
<p class="text-sm text-red-500">{displayError}</p>
|
|
168
|
+
{/if}
|
|
169
|
+
|
|
170
|
+
<button
|
|
171
|
+
onclick={handleAccept}
|
|
172
|
+
disabled={isLoading || !name.trim() || isLockedOut}
|
|
173
|
+
class="w-full py-2 px-4 rounded-lg bg-violet-600 hover:bg-violet-700 text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
174
|
+
>
|
|
175
|
+
{isLoading ? 'Joining...' : 'Join'}
|
|
176
|
+
</button>
|
|
177
|
+
</div>
|
|
178
|
+
{:else}
|
|
179
|
+
<!-- PAT Display -->
|
|
180
|
+
<div class="space-y-1">
|
|
181
|
+
<h1 class="text-2xl font-bold text-slate-900 dark:text-slate-100">Welcome!</h1>
|
|
182
|
+
<p class="text-sm text-slate-500 dark:text-slate-400">Your account has been created</p>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<div class="w-full space-y-4">
|
|
186
|
+
<div class="p-4 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800">
|
|
187
|
+
<p class="text-sm font-medium text-amber-800 dark:text-amber-200 mb-2">
|
|
188
|
+
Your Personal Access Token
|
|
189
|
+
</p>
|
|
190
|
+
<p class="text-xs text-amber-700 dark:text-amber-300 mb-3">
|
|
191
|
+
Save this token — you'll need it to log in on other devices. It won't be shown again.
|
|
192
|
+
</p>
|
|
193
|
+
<div class="flex items-center gap-2">
|
|
194
|
+
<code class="flex-1 px-3 py-2 rounded 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">
|
|
195
|
+
{authStore.personalAccessToken}
|
|
196
|
+
</code>
|
|
197
|
+
<button
|
|
198
|
+
onclick={copyPAT}
|
|
199
|
+
class="shrink-0 px-3 py-2 rounded bg-amber-100 dark:bg-amber-900 hover:bg-amber-200 dark:hover:bg-amber-800 text-amber-800 dark:text-amber-200 text-xs font-medium transition-colors"
|
|
200
|
+
>
|
|
201
|
+
{patCopied ? 'Copied!' : 'Copy'}
|
|
202
|
+
</button>
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
<button
|
|
207
|
+
onclick={handleContinue}
|
|
208
|
+
class="w-full py-2 px-4 rounded-lg bg-violet-600 hover:bg-violet-700 text-white text-sm font-medium transition-colors"
|
|
209
|
+
>
|
|
210
|
+
Continue to Clopen
|
|
211
|
+
</button>
|
|
212
|
+
</div>
|
|
213
|
+
{/if}
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { authStore } from '$frontend/lib/stores/features/auth.svelte';
|
|
3
|
+
|
|
4
|
+
let token = $state('');
|
|
5
|
+
let error = $state('');
|
|
6
|
+
let isLoading = $state(false);
|
|
7
|
+
|
|
8
|
+
// Rate limit countdown
|
|
9
|
+
let lockoutSeconds = $state(0);
|
|
10
|
+
let countdownInterval: ReturnType<typeof setInterval> | null = null;
|
|
11
|
+
|
|
12
|
+
function parseRateLimitSeconds(message: string): number {
|
|
13
|
+
const match = message.match(/Try again in (\d+) seconds/);
|
|
14
|
+
return match ? parseInt(match[1], 10) : 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function startCountdown(seconds: number) {
|
|
18
|
+
stopCountdown();
|
|
19
|
+
lockoutSeconds = seconds;
|
|
20
|
+
countdownInterval = setInterval(() => {
|
|
21
|
+
lockoutSeconds -= 1;
|
|
22
|
+
if (lockoutSeconds <= 0) {
|
|
23
|
+
stopCountdown();
|
|
24
|
+
error = '';
|
|
25
|
+
}
|
|
26
|
+
}, 1000);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function stopCountdown() {
|
|
30
|
+
lockoutSeconds = 0;
|
|
31
|
+
if (countdownInterval) {
|
|
32
|
+
clearInterval(countdownInterval);
|
|
33
|
+
countdownInterval = null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const isLockedOut = $derived(lockoutSeconds > 0);
|
|
38
|
+
|
|
39
|
+
// Build display error — replace server seconds with live countdown
|
|
40
|
+
const displayError = $derived(
|
|
41
|
+
isLockedOut
|
|
42
|
+
? `Too many failed attempts. Try again in ${lockoutSeconds} seconds.`
|
|
43
|
+
: error
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
async function handleLogin() {
|
|
47
|
+
const trimmed = token.trim();
|
|
48
|
+
|
|
49
|
+
if (!trimmed) {
|
|
50
|
+
error = 'Please enter your access token';
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!trimmed.startsWith('clp_pat_')) {
|
|
55
|
+
error = 'Invalid token format. Use your Personal Access Token (clp_pat_...)';
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
error = '';
|
|
60
|
+
isLoading = true;
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
await authStore.login(trimmed);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
const message = err instanceof Error ? err.message : 'Login failed';
|
|
66
|
+
error = message;
|
|
67
|
+
|
|
68
|
+
const seconds = parseRateLimitSeconds(message);
|
|
69
|
+
if (seconds > 0) {
|
|
70
|
+
startCountdown(seconds);
|
|
71
|
+
}
|
|
72
|
+
} finally {
|
|
73
|
+
isLoading = false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
78
|
+
if (e.key === 'Enter' && !isLockedOut) {
|
|
79
|
+
handleLogin();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
</script>
|
|
83
|
+
|
|
84
|
+
<div class="fixed inset-0 z-[9999] bg-white dark:bg-slate-950 flex items-center justify-center">
|
|
85
|
+
<div class="flex flex-col items-center gap-6 text-center px-4 max-w-md w-full">
|
|
86
|
+
<!-- Logo -->
|
|
87
|
+
<div>
|
|
88
|
+
<img src="/favicon.svg" alt="Clopen" class="w-16 h-16 rounded-2xl shadow-xl" />
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<div class="space-y-1">
|
|
92
|
+
<h1 class="text-2xl font-bold text-slate-900 dark:text-slate-100">Clopen</h1>
|
|
93
|
+
<p class="text-sm text-slate-500 dark:text-slate-400">Enter your access token to sign in</p>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<div class="w-full space-y-4">
|
|
97
|
+
<div class="text-left">
|
|
98
|
+
<label for="token" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
|
99
|
+
Access Token
|
|
100
|
+
</label>
|
|
101
|
+
<input
|
|
102
|
+
id="token"
|
|
103
|
+
type="password"
|
|
104
|
+
bind:value={token}
|
|
105
|
+
onkeydown={handleKeydown}
|
|
106
|
+
placeholder="clp_pat_..."
|
|
107
|
+
disabled={isLoading || isLockedOut}
|
|
108
|
+
class="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-violet-500 disabled:opacity-50"
|
|
109
|
+
/>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
{#if displayError}
|
|
113
|
+
<p class="text-sm text-red-500">{displayError}</p>
|
|
114
|
+
{/if}
|
|
115
|
+
|
|
116
|
+
<button
|
|
117
|
+
onclick={handleLogin}
|
|
118
|
+
disabled={isLoading || !token.trim() || isLockedOut}
|
|
119
|
+
class="w-full py-2 px-4 rounded-lg bg-violet-600 hover:bg-violet-700 text-white text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
120
|
+
>
|
|
121
|
+
{isLoading ? 'Signing in...' : 'Sign In'}
|
|
122
|
+
</button>
|
|
123
|
+
|
|
124
|
+
<p class="text-xs text-slate-400 dark:text-slate-500">
|
|
125
|
+
Don't have a token? Ask your admin for an invite link.
|
|
126
|
+
</p>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|