Files
decision/web/src/routes/equipe/+page.svelte
T
YvvandClaude Opus 5.5 fd19dbf4ec v3 : liens d'équipe, publication sur git.open.us.org, huitième relecture
Liens d'équipe (Yvv, 2026-10-21) : un lien réutilisable jusqu'à une date
et pour un nombre de personnes, révocable. Chaque ouverture demande un nom
et crée sa propre identité d'invitation, jamais administratrice ; un nom
déjà pris est refusé ; une personne déjà connectée reçoit les groupes.
trustinWallet montre par quel lien chaque identité est venue, et donne un
nouveau lien de connexion à une personne invitée (autre appareil).
Migration additive 0002 (table team_links, colonne identities.team_link).

Publication : yvv/libredecision-textes sur git.open.us.org, pour
l'instant (un dépôt pour l'ouverture, puis le dépôt officiel, viendront).

Huitième relecture (c3d6bf8 : ok), points corrigés :
- une négation n'est jamais coupée ni formée par collage (« ja mais ») ;
- caractères invisibles : tout l'ensemble Unicode des ignorables et les
  contrôles autres que saut de ligne et tabulation ;
- sauts de ligne et indentation font partie de la forme (listes) ;
- un titre tient sur une ligne.

Boucle : 65 tests Rust, svelte-check, 9 vitest, e2e 13/13 (C12 ajouté).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:12:21 +02:00

113 lines
3.1 KiB
Svelte

<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { api, ApiError } from '$lib/api';
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
import { session } from '$lib/session.svelte';
import type { Me } from '$lib/types';
interface Peek {
label: string;
groups: string[];
expiresAt: string;
signedInAs: string | null;
}
let phase = $state<'working' | 'invalid' | 'missing' | 'ready'>('working');
let link = $state<Peek | null>(null);
let token = '';
let name = $state('');
let error = $state('');
let busy = $state(false);
onMount(async () => {
// The token travels in the fragment: it never reaches a server log.
token = location.hash.slice(1).trim();
history.replaceState(null, '', '/equipe');
if (!/^[0-9a-f]{64}$/.test(token)) {
phase = 'missing';
return;
}
try {
link = await api.post<Peek>('/auth/team/peek', { token });
phase = 'ready';
} catch {
phase = 'invalid';
}
});
async function join(e: SubmitEvent) {
e.preventDefault();
error = '';
busy = true;
try {
const r = await api.post<{ me: Me }>('/auth/team', link?.signedInAs ? { token } : { token, name });
session.me = r.me;
await goto('/groupes', { replaceState: true });
} catch (err) {
const code = err instanceof ApiError ? err.code : 'internal';
if (code === 'invitation_invalid') phase = 'invalid';
else error = t(`error.${code}` as Key);
} finally {
busy = false;
}
}
</script>
<div class="page">
<h1>{t('team.title')}</h1>
{#if phase === 'ready' && link}
<p class="lead">{t('team.opens', { label: link.label, groups: link.groups.join(', ') })}</p>
<p class="faint small">{t('team.until', { date: i18n.date(link.expiresAt) })}</p>
<form class="card block" onsubmit={join}>
{#if link.signedInAs}
<p>{t('team.signed', { name: link.signedInAs })}</p>
<button class="btn primary" type="submit" disabled={busy}>{t('team.add')}</button>
{:else}
<label class="field">
<span>{t('team.name')}</span>
<input class="input" bind:value={name} minlength="2" maxlength="60" autocomplete="name" required />
</label>
<p class="faint small">{t('team.name.hint')}</p>
<button class="btn primary" type="submit" disabled={busy}>{t('team.join')}</button>
{/if}
{#if error}<p class="warn" role="alert">{error}</p>{/if}
</form>
{:else}
<p class="muted" role="status">{t(`team.${phase}` as Key)}</p>
{/if}
</div>
<style>
.page {
max-width: 34rem;
margin: 0 auto;
padding: clamp(2.5rem, 9vh, 6rem) 1.4rem;
display: grid;
gap: 1rem;
}
h1 {
font-family: var(--font-text);
font-weight: 500;
font-size: 2.2rem;
}
.lead {
font-size: 1.08rem;
}
.block {
padding: 1.1rem 1.2rem;
display: grid;
gap: 0.7rem;
}
.block > .btn {
justify-self: start;
}
.small {
font-size: 0.84rem;
}
.warn {
color: var(--ochre);
font-weight: 700;
}
</style>