v0.1.5 0b43029

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