1<script lang="ts">
2 /**
3 * AIM "Sign On" window: screen name + password, "Save password" checkbox,
4 * and the classic three-step connection sequence (modem, chime).
5 * Also hosts the sign-up tab.
6 */
7 import { invalidate } from '$app/navigation';
8 import { z } from 'zod';
9 import XpWindow from './xp-window.svelte';
10 import PasswordStrengthIndicator from '../password-strength-indicator.svelte';
11 import { desktop } from '$lib/states/desktop.svelte';
12 import { chatState } from '$lib/states/chat.svelte';
13 import { createSafeUser } from '$lib/types/chat';
14 import { playSound, unlockAudio, SOUND_DURATIONS_MS } from '$lib/aim/sounds.svelte';
15 import {
16 loginSchema,
17 createRegistrationSchema,
18 DEFAULT_PASSWORD_CONSTRAINTS
19 } from '$lib/validation/password';
20 import type { RegisterResponse, RegisterResponseError } from '$lib/types/payloads';
21
22 let { tab = 'signin' } = $props<{ tab?: 'signin' | 'signup' }>();
23
24 const WINDOW_ID = 'login';
25 const REMEMBER_KEY = 'pdraim-screen-name';
26
27 // svelte-ignore state_referenced_locally
28 let activeTab = $state<'signin' | 'signup'>(tab);
29
30 // Sign in
31 let username = $state('');
32 let password = $state('');
33 let rememberName = $state(true);
34 let error = $state('');
35
36 // Sign-on sequence: 0 idle, 1 connecting, 2 verifying, 3 starting
37 let step = $state(0);
38 let signedOnAs = $state<string | null>(null);
39
40 // Sign up
41 let suUsername = $state('');
42 let suPassword = $state('');
43 let suConfirmPassword = $state('');
44 let captchaAnswer = $state('');
45 let signupError = $state('');
46 let signupBusy = $state(false);
47 let showStrength = $state(false);
48
49 $effect(() => {
50 const saved = localStorage.getItem(REMEMBER_KEY);
51 if (saved && !username) username = saved;
52 });
53
54 const STEP_LABELS = [
55 '',
56 'Connexion en cours…',
57 'Vérification du nom et du mot de passe…',
58 'Démarrage des services…'
59 ];
60
61 // Aborted by "Annuler": stops the login request and every later step of the sequence.
62 let signOnAbort: AbortController | null = null;
63
64 function wait(ms: number, signal: AbortSignal) {
65 return new Promise<void>((resolve, reject) => {
66 const timer = setTimeout(resolve, ms);
67 signal.addEventListener(
68 'abort',
69 () => {
70 clearTimeout(timer);
71 reject(signal.reason);
72 },
73 { once: true }
74 );
75 });
76 }
77
78 async function login(
79 name: string,
80 pass: string,
81 signal: AbortSignal
82 ): Promise<{ ok: boolean; error?: string }> {
83 const res = await fetch('/api/session/login', {
84 method: 'POST',
85 headers: { 'Content-Type': 'application/json' },
86 body: JSON.stringify({ username: name, password: pass }),
87 signal
88 });
89 const data = await res.json();
90 if (!res.ok) return { ok: false, error: data.error || 'Connexion impossible.' };
91 signal.throwIfAborted();
92 chatState.setCurrentUser(data.user ? createSafeUser(data.user) : null);
93 await invalidate('app:session');
94 await invalidate('app:chat');
95 return { ok: true };
96 }
97
98 async function runSignOn(name: string, pass: string) {
99 signOnAbort?.abort();
100 const controller = new AbortController();
101 signOnAbort = controller;
102 const { signal } = controller;
103 unlockAudio();
104 error = '';
105 step = 1;
106 desktop.signingOn = true;
107 playSound('modem');
108 const modemStart = Date.now();
109 try {
110 await wait(900, signal);
111 step = 2;
112 const result = await login(name, pass, signal);
113 if (!result.ok) {
114 error = result.error ?? 'Connexion impossible.';
115 step = 0;
116 return;
117 }
118 if (rememberName) localStorage.setItem(REMEMBER_KEY, name);
119 else localStorage.removeItem(REMEMBER_KEY);
120 step = 3;
121 // Let the modem "handshake" finish before the welcome chime
122 const remaining = SOUND_DURATIONS_MS.modem - (Date.now() - modemStart);
123 await wait(Math.max(400, Math.min(remaining, 2500)), signal);
124 playSound('welcome');
125 signedOnAs = name;
126 desktop.openBuddyList();
127 desktop.focus(WINDOW_ID);
128 await wait(1200, signal);
129 desktop.close(WINDOW_ID);
130 } catch (err) {
131 if (!signal.aborted) throw err;
132 // Cancelled after the credentials were sent: the server may have created a
133 // session, so honour "Annuler" by signing out (a no-op without a session).
134 if (step >= 2 && signOnAbort === controller) {
135 const loggedOut = await fetch('/api/session/logout', { method: 'POST' })
136 .then((res) => res.ok)
137 .catch(() => false);
138 // Re-sync client auth with the server either way; only claim success if the logout worked.
139 await invalidate('app:session');
140 if (!loggedOut) error = 'Annulation impossible : tu es peut-être connecté.';
141 }
142 } finally {
143 if (signOnAbort === controller) {
144 signOnAbort = null;
145 desktop.signingOn = false;
146 if (signal.aborted) {
147 step = 0;
148 signedOnAs = null;
149 }
150 }
151 }
152 }
153
154 async function handleSignin(e: Event) {
155 e.preventDefault();
156 const parsed = loginSchema.safeParse({ username: username.trim(), password: password.trim() });
157 if (!parsed.success) {
158 error = parsed.error.issues[0]?.message ?? 'Saisie invalide';
159 return;
160 }
161 try {
162 await runSignOn(username.trim(), password.trim());
163 } catch (err) {
164 console.debug('Sign-on error', err);
165 error = 'Erreur de connexion. Réessaie.';
166 step = 0;
167 }
168 }
169
170 async function handleSignup(e: Event) {
171 e.preventDefault();
172 signupError = '';
173 const schema = createRegistrationSchema(DEFAULT_PASSWORD_CONSTRAINTS);
174 try {
175 schema.parse({
176 suUsername: suUsername.trim(),
177 suPassword: suPassword.trim(),
178 suConfirmPassword: suConfirmPassword.trim(),
179 captchaAnswer: captchaAnswer.trim(),
180 turnstileToken: ''
181 });
182 } catch (err) {
183 signupError =
184 err instanceof z.ZodError
185 ? (err.issues[0]?.message ?? 'Saisie invalide')
186 : 'Saisie invalide';
187 return;
188 }
189 signupBusy = true;
190 try {
191 const res = await fetch('/api/register', {
192 method: 'POST',
193 headers: { 'Content-Type': 'application/json' },
194 body: JSON.stringify({ suUsername, suPassword, suConfirmPassword, captchaAnswer })
195 });
196 const data = (await res.json()) as RegisterResponse;
197 if (!res.ok) {
198 signupError = (data as RegisterResponseError).error || 'Inscription impossible.';
199 return;
200 }
201 username = suUsername.trim();
202 password = suPassword.trim();
203 activeTab = 'signin';
204 await runSignOn(username, password);
205 } catch (err) {
206 console.debug('Signup error', err);
207 signupError = "Erreur d'inscription. Réessaie.";
208 } finally {
209 signupBusy = false;
210 }
211 }
212
213 function cancelSignOn() {
214 // runSignOn's catch/finally resets the sequence state
215 signOnAbort?.abort();
216 }
217</script>
218
219<XpWindow
220 id={WINDOW_ID}
221 width={300}
222 height={activeTab === 'signin' ? 380 : 600}
223 resizableWindow={false}
224>
225 <div class="signon">
226 <div class="banner">
227 <img src="/aim/running-man-48.png" alt="" width="44" height="44" />
228 <div class="brand">
229 <div class="brand-aol">
230 <img src="/aim/aol-logo-16.png" alt="" width="14" height="14" /> PDR
231 </div>
232 <div class="brand-title">Instant</div>
233 <div class="brand-title">Messenger</div>
234 </div>
235 </div>
236
237 {#if signedOnAs}
238 <div class="progress">
239 <p class="welcome">Bienvenue, <b>{signedOnAs}</b> !</p>
240 <p class="hint">Ouverture de la liste de contacts…</p>
241 </div>
242 {:else if step > 0}
243 <div class="progress">
244 <p class="step-title">Connexion à PDR AIM</p>
245 <ol class="steps">
246 {#each [1, 2, 3] as n (n)}
247 <li class:done={step > n} class:active={step === n}>{STEP_LABELS[n]}</li>
248 {/each}
249 </ol>
250 <div class="bar"><div class="bar-fill" style="width: {step * 33}%"></div></div>
251 <button type="button" onclick={cancelSignOn}>Annuler</button>
252 </div>
253 {:else}
254 <div class="tabs">
255 <button
256 type="button"
257 class:active={activeTab === 'signin'}
258 onclick={() => (activeTab = 'signin')}>Connexion</button
259 >
260 <button
261 type="button"
262 class:active={activeTab === 'signup'}
263 onclick={() => (activeTab = 'signup')}>Inscription</button
264 >
265 </div>
266
267 {#if activeTab === 'signin'}
268 <form onsubmit={handleSignin}>
269 <div class="field-row-stacked">
270 <label for="so-name">Pseudo</label>
271 <input id="so-name" type="text" bind:value={username} autocomplete="username" />
272 </div>
273 <div class="field-row-stacked">
274 <label for="so-pass">Mot de passe</label>
275 <input
276 id="so-pass"
277 type="password"
278 bind:value={password}
279 autocomplete="current-password"
280 />
281 </div>
282 <div class="field-row">
283 <input id="so-remember" type="checkbox" bind:checked={rememberName} />
284 <label for="so-remember">Se souvenir du pseudo</label>
285 </div>
286 {#if error}
287 <div class="error">{error}</div>
288 {/if}
289 <div class="actions">
290 <button type="submit" class="primary">Se connecter</button>
291 </div>
292 <p class="hint">
293 Pas encore de compte ?
294 <button type="button" class="link" onclick={() => (activeTab = 'signup')}
295 >Inscris-toi</button
296 >
297 </p>
298 </form>
299 {:else}
300 <form onsubmit={handleSignup}>
301 <div class="field-row-stacked">
302 <label for="su-name">Pseudo</label>
303 <input id="su-name" type="text" bind:value={suUsername} autocomplete="username" />
304 </div>
305 <div class="field-row-stacked">
306 <label for="su-pass">Mot de passe</label>
307 <input
308 id="su-pass"
309 type="password"
310 bind:value={suPassword}
311 autocomplete="new-password"
312 onfocus={() => (showStrength = true)}
313 />
314 {#if showStrength}
315 <PasswordStrengthIndicator password={suPassword} showDetails={false} />
316 {/if}
317 </div>
318 <div class="field-row-stacked">
319 <label for="su-confirm">Confirmer le mot de passe</label>
320 <input
321 id="su-confirm"
322 type="password"
323 bind:value={suConfirmPassword}
324 autocomplete="new-password"
325 />
326 </div>
327 <div class="field-row-stacked">
328 <label for="su-captcha">Que signifie PDR ?</label>
329 <input id="su-captcha" type="text" bind:value={captchaAnswer} />
330 </div>
331 {#if signupError}
332 <div class="error">{signupError}</div>
333 {/if}
334 <div class="actions">
335 <button type="submit" class="primary" disabled={signupBusy}>
336 {signupBusy ? 'Inscription…' : "S'inscrire"}
337 </button>
338 </div>
339 </form>
340 {/if}
341 {/if}
342 </div>
343</XpWindow>
344
345<style>
346 .signon {
347 display: flex;
348 flex-direction: column;
349 gap: 8px;
350 font-size: 11px;
351 height: 100%;
352 overflow: hidden;
353 }
354
355 .banner {
356 display: flex;
357 align-items: center;
358 gap: 12px;
359 padding: 10px 12px;
360 background: #1b2a9a;
361 border: 1px solid #0f1a66;
362 color: #fff;
363 }
364
365 .brand-aol {
366 display: flex;
367 align-items: center;
368 gap: 4px;
369 font-weight: bold;
370 font-size: 12px;
371 letter-spacing: 1px;
372 }
373
374 .brand-title {
375 font-style: italic;
376 font-size: 17px;
377 line-height: 1;
378 color: #fff;
379 }
380
381 .tabs {
382 display: flex;
383 border-bottom: 1px solid #919b9c;
384 }
385
386 .tabs button {
387 flex: 1;
388 border: 1px solid #919b9c;
389 border-bottom: none;
390 background: #ece9d8;
391 box-shadow: none;
392 border-radius: 3px 3px 0 0;
393 min-width: 0;
394 padding: 4px;
395 font-size: 11px;
396 }
397
398 .tabs button.active {
399 background: #fff;
400 font-weight: bold;
401 position: relative;
402 top: 1px;
403 }
404
405 form {
406 display: flex;
407 flex-direction: column;
408 gap: 8px;
409 padding: 4px 2px;
410 }
411
412 .field-row-stacked input[type='text'],
413 .field-row-stacked input[type='password'] {
414 width: 100%;
415 }
416
417 .actions {
418 display: flex;
419 justify-content: center;
420 margin-top: 4px;
421 }
422
423 .primary {
424 font-weight: bold;
425 min-width: 120px;
426 }
427
428 .error {
429 color: #c00000;
430 background: #fff0f0;
431 border: 1px solid #e0a0a0;
432 padding: 4px 6px;
433 }
434
435 .hint {
436 color: #555;
437 text-align: center;
438 margin: 4px 0 0;
439 }
440
441 .link {
442 background: none;
443 border: none;
444 box-shadow: none;
445 color: #0000c0;
446 text-decoration: underline;
447 padding: 0;
448 min-width: 0;
449 font-size: 11px;
450 cursor: pointer;
451 }
452
453 .progress {
454 display: flex;
455 flex-direction: column;
456 gap: 10px;
457 padding: 10px 6px;
458 align-items: center;
459 }
460
461 .step-title {
462 font-weight: bold;
463 margin: 0;
464 }
465
466 .steps {
467 list-style: none;
468 padding: 0;
469 margin: 0;
470 width: 100%;
471 display: flex;
472 flex-direction: column;
473 gap: 4px;
474 }
475
476 .steps li {
477 color: #888;
478 padding-left: 18px;
479 position: relative;
480 }
481
482 .steps li::before {
483 content: '○';
484 position: absolute;
485 left: 2px;
486 }
487
488 .steps li.active {
489 color: #000;
490 font-weight: bold;
491 }
492
493 .steps li.active::before {
494 content: '●';
495 color: #f6c700;
496 animation: blink 0.8s steps(2, start) infinite;
497 }
498
499 .steps li.done {
500 color: #2a7a2a;
501 }
502
503 .steps li.done::before {
504 content: '✓';
505 color: #2a7a2a;
506 }
507
508 .bar {
509 width: 100%;
510 height: 14px;
511 border: 1px solid #919b9c;
512 background: #fff;
513 padding: 1px;
514 }
515
516 .bar-fill {
517 height: 100%;
518 background: repeating-linear-gradient(90deg, #2c6ed5 0 8px, transparent 8px 10px);
519 transition: width 0.4s ease;
520 }
521
522 .welcome {
523 font-size: 14px;
524 margin: 8px 0 0;
525 }
526
527 @keyframes blink {
528 to {
529 visibility: hidden;
530 }
531 }
532</style>