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>