forked from yvv/decision
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>
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user