@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
@@ -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
- // NOTE: In Phase 3, we'll need to handle routing for SPA
13
- // For now, we'll just render the main workspace
17
+ let servicesInitialized = false;
14
18
 
15
- // Initialize background terminal service and MCP preview integration
19
+ // Initialize auth on mount
16
20
  onMount(async () => {
17
- // Initialize global stream monitor FIRST (just registers a WS listener, non-blocking)
18
- // Must run before any await to ensure cross-project notifications work immediately
19
- globalStreamMonitor.initialize();
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
- // Initialize background service first and wait for it
22
- await backgroundTerminalService.initialize();
29
+ // Initialize global stream monitor (registers WS listener, non-blocking)
30
+ globalStreamMonitor.initialize();
23
31
 
24
- // Now background service has restored any persisted sessions
25
- // The terminal store will check this when initializing
32
+ // Initialize background terminal service
33
+ backgroundTerminalService.initialize();
26
34
 
27
- // Initialize MCP Preview Integration
28
- // This sets up listeners for MCP browser automation events
29
- initializeMCPPreview();
35
+ // Initialize MCP Preview Integration
36
+ initializeMCPPreview();
30
37
 
31
- // Restore tunnel status from server
32
- tunnelStore.checkStatus();
38
+ // Restore tunnel status
39
+ tunnelStore.checkStatus();
33
40
 
34
- // Start periodic update checker
35
- startUpdateChecker();
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
- <div class="flex flex-col h-dvh w-screen overflow-hidden">
44
- <ConnectionBanner />
45
- <UpdateBanner />
46
-
47
- <div class="flex-1 min-h-0">
48
- <WorkspaceLayout>
49
- {#snippet children()}
50
- <!-- Main content will be here -->
51
- <!-- TODO: Add SPA router in Phase 3 if needed -->
52
- {/snippet}
53
- </WorkspaceLayout>
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
- </div>
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>