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>