main fe6ee00

sign-on-window.svelte RawHistoryFolder

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>

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 9

↑↓ moveenter openesc close