v0.1.4 2490b00

aim-login.svelte RawHistoryFolder

1<!-- AIM Login Component Updated for Svelte5 with Sign In/Sign Up Tabs -->
2<script lang="ts">
3    import { draggable } from '$lib/actions/draggable';
4    import type { RegisterResponse, RegisterResponseError } from '$lib/types/payloads';
5    import type { User, SafeUser } from '$lib/types/chat';
6    import { invalidateAll } from '$app/navigation';
7    import { chatState } from '$lib/states/chat.svelte';
8    import Turnstile from './turnstile.svelte';
9    import { createSafeUser } from '$lib/types/chat';
10    
11    interface $$Props {
12        showAuth: boolean;
13        onLoginSuccess?: (user: SafeUser | null) => void;
14        activeTab?: 'signin' | 'signup';
15    }
16
17    let { showAuth = $bindable(), onLoginSuccess, activeTab: initialTab = 'signin' } = $props();
18    
19    let activeTab = $state(initialTab);
20
21    // Sign In state
22    let siUsername = $state('');
23    let siPassword = $state('');
24    let error = $state('');
25    let loginStatus = $state('idle');
26    let siTurnstileToken = $state('');
27  
28    // Sign Up state
29    let suUsername = $state('');
30    let suPassword = $state('');
31    let suConfirmPassword = $state('');
32    let captchaAnswer = $state('');
33    let signupError = $state('');
34    let signupStatus = $state('idle');
35    let suTurnstileToken = $state('');
36
37    function handleClose() {
38        console.debug("Closing AIM Login component.");
39        showAuth = false;
40    }
41
42    async function handleSigninSubmit() {
43        error = '';
44        loginStatus = 'loading';
45        if (!siUsername || !siPassword || !siTurnstileToken) {
46            error = 'Please complete all fields including the security check';
47            loginStatus = 'error';
48            return;
49        }
50
51        console.debug("Attempting login for", siUsername);
52        try {
53            const res = await fetch('/api/session/login', {
54                method: 'POST',
55                headers: { 'Content-Type': 'application/json' },
56                body: JSON.stringify({ 
57                    username: siUsername, 
58                    password: siPassword,
59                    turnstileToken: siTurnstileToken
60                })
61            });
62
63            const data = await res.json();
64            if (!res.ok) {
65                error = data.error || "Login failed.";
66                console.debug("Login failed:", data.error);
67                loginStatus = 'error';
68                return;
69            }
70
71            console.debug("Login succeeded:", { ...data, user: data.user ? { ...data.user, password: '[REDACTED]' } : null });
72            await chatState.setCurrentUser(data.user ? createSafeUser(data.user) : null);
73            loginStatus = 'success';
74            
75            // Call the callback prop if provided
76            onLoginSuccess?.(data.user ? createSafeUser(data.user) : null);
77            // Auto close login window after 3 seconds on successful login
78            setTimeout(() => {
79                console.debug("Auto closing AIM Login component after successful login");
80                invalidateAll();
81                handleClose();
82            }, 3000);
83
84            return;
85        } catch (err) {
86            console.debug("Login error:", err);
87            error = "Login error. Please try again.";
88            loginStatus = 'error';
89        }
90    }
91
92    // New: Create a helper function for client-side validations based on server rules.
93    function validateSignupData(): string | null {
94        // Trim values
95        const username = suUsername.trim();
96        const password = suPassword.trim();
97        const confirmPasswordValue = suConfirmPassword.trim();
98
99        if (!username || !password || !confirmPasswordValue) {
100            return 'All fields must be filled';
101        }
102
103        if (username.length < 3) {
104            return 'Username must be at least 3 characters';
105        }
106
107        if (username.length > 32) {
108            return 'Username must be at most 32 characters';
109        }
110
111        // Only allow letters, numbers, underscores, and dashes in the username.
112        const usernameRegex = /^[a-zA-Z0-9_-]+$/;
113        if (!usernameRegex.test(username)) {
114            return 'Invalid username. Only letters, numbers, underscores, and dashes are allowed.';
115        }
116
117        if (password !== confirmPasswordValue) {
118            return 'Passwords do not match';
119        }
120
121        if (password.length < 8 || password.length > 64) {
122            return 'Password must be between 8 and 64 characters';
123        }
124
125        // All checks passed.
126        return null;
127    }
128
129    async function handleSignupSubmit() {
130        signupError = '';
131        signupStatus = 'loading';
132
133        // Client-side validations before submitting the form.
134        const validationError = validateSignupData();
135        if (validationError) {
136            signupError = validationError;
137            signupStatus = 'error';
138            return;
139        }
140
141        // Additional check for captcha and Turnstile token,
142        // in case those fields are not filled.
143        if (!captchaAnswer || !suTurnstileToken) {
144            signupError = 'Please complete all fields including the security check';
145            signupStatus = 'error';
146            return;
147        }
148
149        console.debug("Attempting registration for", suUsername);
150        try {
151            const res = await fetch('/api/register', {
152                method: 'POST',
153                headers: { 'Content-Type': 'application/json' },
154                body: JSON.stringify({
155                    suUsername,
156                    suPassword,
157                    suConfirmPassword,
158                    captchaAnswer,
159                    turnstileToken: suTurnstileToken
160                })
161            });
162
163            const data = await res.json() as RegisterResponse;
164            if (!res.ok) {
165                const errorData = data as RegisterResponseError;
166                signupError = errorData.error || "Registration failed.";
167                signupStatus = 'error';
168                return;
169            }
170            console.debug("Registration succeeded:", data);
171            signupStatus = 'success';
172
173            // Auto login after registration.
174            await handleAutoLoginSignup();
175            return;
176        } catch (err) {
177            console.debug("Registration error", err);
178            signupError = "Registration error. Please try again.";
179            signupStatus = 'error';
180        }
181    }
182
183    async function handleAutoLoginSignup() {
184        try {
185            const res = await fetch('/api/session/login', {
186                method: 'POST',
187                headers: { 'Content-Type': 'application/json' },
188                body: JSON.stringify({ username: suUsername, password: suPassword })
189            });
190            const data = await res.json();
191            if (!res.ok) {
192                signupError = data.error || "Login failed after registration.";
193                signupStatus = 'error';
194                return;
195            }
196            console.debug("Auto login after registration succeeded:", data);
197            await chatState.setCurrentUser(data.user ? createSafeUser(data.user) : null);
198            onLoginSuccess?.(data.user ? createSafeUser(data.user) : null);
199            setTimeout(() => {
200                console.debug("Auto closing AIM Login component after auto login on registration");
201                invalidateAll();
202                handleClose();
203            }, 3000);
204
205        } catch (err) {
206            console.debug("Auto login error:", err);
207            signupError = "Auto login error. Please try to login manually.";
208            signupStatus = 'error';
209        }
210    }
211
212    function switchTab(tab: string) {
213        activeTab = tab;
214        error = '';
215        signupError = '';
216    }
217
218    function handleKeydown(event: KeyboardEvent) {
219        if (event.key === 'Escape') {
220            console.debug("Escape key pressed, closing AIM Login component.");
221            handleClose();
222        }
223    }
224
225    // Added dragging state and handler similar to chat-room
226    let windowX = $state(0);
227    let windowY = $state(0);
228    
229    function handleDragMove(event: CustomEvent<{ x: number; y: number }>) {
230        windowX = event.detail.x;
231        windowY = event.detail.y;
232    }
233</script>
234
235<svelte:window onkeydown={handleKeydown} />
236
237<!-- Updated outer div to handle dragging with inline style and drag event -->
238<div class="login-window window" use:draggable={{ handle: '.title-bar' }} ondragmove={handleDragMove} style="left: {windowX}px; top: {windowY}px;">
239    <div class="title-bar">
240        <div class="title-bar-text">
241            <img src="/desktop/aim-desktop-icon.png" alt="AIM" class="title-icon" width="16" height="16" />
242            {activeTab === 'signin' ? 'Connexion' : 'Inscription'}
243        </div>
244        <div class="title-bar-controls">
245            <button aria-label="Close" onclick={handleClose}>×</button>
246        </div>
247    </div>
248
249    <div class="tabs">
250        <button class:active={activeTab === 'signin'} onclick={() => switchTab('signin')}>Connexion</button>
251        <button class:active={activeTab === 'signup'} onclick={() => switchTab('signup')}>Inscription</button>
252    </div>
253
254    <div class="window-content">
255         {#if activeTab === 'signin'}
256            <form onsubmit={handleSigninSubmit}>
257                <div class="form-group">
258                    <label for="si-username">Nom d'utilisateur</label>
259                    <input type="text" id="si-username" bind:value={siUsername} placeholder="Nom d'utilisateur" />
260                </div>
261                <div class="form-group">
262                    <label for="si-password">Mot de passe</label>
263                    <input type="password" id="si-password" bind:value={siPassword} placeholder="Mot de passe" />
264                </div>
265                <Turnstile onVerify={(token: string) => siTurnstileToken = token} />
266                {#if error}
267                    <div class="error">{error}</div>
268                {/if}
269                <div class="button-bar">
270                    <button type="submit" class="icon-button {loginStatus === 'success' ? 'success-border' : loginStatus === 'error' ? 'error-border' : ''}">
271                        {#if loginStatus === 'loading'}
272                            <svg width="24" height="24" viewBox="0 0 24 24">
273                                <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" opacity="0.25"></circle>
274                                <path d="M12 2a10 10 0 0 1 0 20" stroke="currentColor" stroke-width="2" fill="none">
275                                    <animateTransform attributeName="transform" type="rotate" values="0 12 12;360 12 12" dur="1s" repeatCount="indefinite"/>
276                                </path>
277                            </svg>
278                        {:else if loginStatus === 'success'}
279                            <svg width="24" height="24" viewBox="0 0 24 24">
280                                <path d="M20 6L9 17l-5-5" stroke="green" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
281                            </svg>
282                        {:else if loginStatus === 'error'}
283                            <svg width="24" height="24" viewBox="0 0 24 24">
284                                <circle cx="12" cy="12" r="10" stroke="red" stroke-width="2" fill="none"/>
285                                <line x1="12" y1="8" x2="12" y2="13" stroke="red" stroke-width="2" stroke-linecap="round"/>
286                                <circle cx="12" cy="17" r="1" fill="red"/>
287                            </svg>
288                        {:else}
289                            <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
290                                <path d="M12 4C9.24 4 7 6.24 7 9c0 2.76 2.24 5 5 5s5-2.24 5-5c0-2.76-2.24-5-5-5zm0 2c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 10c-3.33 0-7 1.67-7 5v1h14v-1c0-3.33-3.67-5-7-5zm0 2c2.67 0 5 1.33 5 3H7c0-1.67 2.33-3 5-3z" fill="currentColor"/>
291                            </svg>
292                        {/if}
293                        Se connecter
294                    </button>
295                </div>
296            </form>
297         {:else}
298            <form onsubmit={handleSignupSubmit}>
299                <div class="form-group">
300                    <label for="su-username">Nom d'utilisateur</label>
301                    <input type="text" id="su-username" bind:value={suUsername} placeholder="Choisissez un nom d'utilisateur" />
302                </div>
303                <div class="form-group">
304                    <label for="su-password">Mot de passe</label>
305                    <input type="password" id="su-password" bind:value={suPassword} placeholder="Mot de passe" />
306                </div>
307                <div class="form-group">
308                    <label for="su-confirm-password">Confirmer le mot de passe</label>
309                    <input type="password" id="su-confirm-password" bind:value={suConfirmPassword} placeholder="Confirmer le mot de passe" />
310                </div>
311                <div class="form-group">
312                    <label for="captcha">Quelle est la signification de PDR ?</label>
313                    <input type="text" id="captcha" bind:value={captchaAnswer} placeholder="Entrez votre réponse" />
314                </div>
315                <Turnstile onVerify={(token: string) => suTurnstileToken = token} />
316                {#if signupError}
317                    <div class="error">{signupError}</div>
318                {/if}
319                <div class="button-bar">
320                    <button type="submit" class="icon-button {signupStatus === 'success' ? 'success-border' : signupStatus === 'error' ? 'error-border' : ''}">
321                        {#if signupStatus === 'loading'}
322                            <svg width="24" height="24" viewBox="0 0 24 24">
323                                <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" opacity="0.25"></circle>
324                                <path d="M12 2a10 10 0 0 1 0 20" stroke="currentColor" stroke-width="2" fill="none">
325                                    <animateTransform attributeName="transform" type="rotate" values="0 12 12;360 12 12" dur="1s" repeatCount="indefinite"></animateTransform>
326                                </path>
327                            </svg>
328                        {:else if signupStatus === 'success'}
329                            <svg width="24" height="24" viewBox="0 0 24 24">
330                                <path d="M20 6L9 17l-5-5" stroke="green" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"></path>
331                            </svg>
332                        {:else if signupStatus === 'error'}
333                            <svg width="24" height="24" viewBox="0 0 24 24">
334                                <circle cx="12" cy="12" r="10" stroke="red" stroke-width="2" fill="none"></circle>
335                                <line x1="12" y1="8" x2="12" y2="13" stroke="red" stroke-width="2" stroke-linecap="round"></line>
336                                <circle cx="12" cy="17" r="1" fill="red"></circle>
337                            </svg>
338                        {:else}
339                            <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
340                                <path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" fill="currentColor"></path>
341                            </svg>
342                        {/if}
343                        S'inscrire
344                    </button>
345                </div>
346            </form>
347         {/if}
348    </div>
349</div>
350
351<style>
352    .login-window {
353        width: 300px;
354        background: #ECE9D8;
355        /* Changed to fixed positioning for correct dragging */
356        position: fixed;
357    }
358    .title-bar {
359        background: linear-gradient(180deg, #0054E3 0%, #0047AB 100%);
360        padding: 4px 6px;
361        display: flex;
362        justify-content: space-between;
363        align-items: center;
364        color: white;
365        font-weight: bold;
366        user-select: none;
367        position: relative;
368    }
369    .title-bar-text {
370        display: flex;
371        align-items: center;
372        gap: 4px;
373        font-size: 0.875rem;
374    }
375
376    .tabs {
377        padding: 0 3px;
378        display: flex;
379        border-bottom: 1px solid #0054E3;
380    }
381    .tabs button {
382        flex: 1;
383        padding: 8px;
384        border: none;
385        background: #ECE9D8;
386        font-size: 1rem;
387        cursor: pointer;
388    }
389    .tabs button.active {
390        background: #fff;
391        border-bottom: 2px solid #0054E3;
392        font-weight: bold;
393    }
394    .window-content {
395        padding: 16px;
396    }
397    .form-group {
398        margin-bottom: 16px;
399    }
400    .form-group label {
401        display: block;
402        margin-bottom: 4px;
403        font-size: 1rem;
404    }
405    .form-group input {
406        width: 100%;
407        padding: 4px;
408        font-size: 1rem;
409        font-family: Arial, Verdana, Tahoma, sans-serif;
410    }
411    .button-bar {
412        display: flex;
413        justify-content: center;
414        margin-top: 20px;
415    }
416    .icon-button {
417        display: flex;
418        flex-direction: column;
419        align-items: center;
420        gap: 4px;
421        background: none;
422        border: none;
423        cursor: pointer;
424        font-size: 1rem;
425        color: black;
426    }
427    .error {
428        color: red;
429        font-size: 1rem;
430        margin-top: 8px;
431    }
432    .success-border {
433        border: 2px solid green;
434    }
435    .error-border {
436        border: 2px solid red;
437    }
438</style> 

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 8

↑↓ moveenter openesc close