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:
Yvv
2026-10-21 08:12:21 +02:00
co-authored by Claude Opus 5.5
parent c3d6bf88b9
commit fd19dbf4ec
29 changed files with 938 additions and 85 deletions
+22 -26
View File
@@ -2,16 +2,18 @@
import { onMount } from 'svelte';
import { api, ApiError } from '$lib/api';
import Guard from '$lib/components/Guard.svelte';
import LinkReveal from '$lib/components/LinkReveal.svelte';
import TeamLinks, { type TeamLink } from '$lib/components/TeamLinks.svelte';
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
import { Remote } from '$lib/load.svelte';
import { session } from '$lib/session.svelte';
import { toasts } from '$lib/toast.svelte';
interface Overview {
publication: { remote: string | null; publicKey: string | null };
groups: { slug: string; name: string; policy: string; hidden: boolean; members: { identityId: string; grantedAt: string }[] }[];
identities: { id: string; kind: 'g1' | 'invite' | 'dev'; displayName: string; g1Member: boolean; admin: boolean; lastSeenAt: string | null }[];
identities: { id: string; kind: 'g1' | 'invite' | 'dev'; displayName: string; g1Member: boolean; admin: boolean; lastSeenAt: string | null; teamLink: string | null }[];
invitations: { id: string; label: string; admin: boolean; groups: string[]; expiresAt: string }[];
teamLinks: TeamLink[];
}
interface Feedback {
feedback: { id: number; author: string | null; route: string; body: string; createdAt: string; status: 'new' | 'seen' | 'done' }[];
@@ -29,6 +31,7 @@
let groups = $state<string[]>([]);
let admin = $state(false);
let link = $state('');
let relinked = $state<{ id: string; name: string; link: string } | null>(null);
let error = $state('');
const nameOf = (id: string) => data.value?.identities.find((i) => i.id === id)?.displayName ?? id;
@@ -54,9 +57,12 @@
});
}
async function copy() {
await navigator.clipboard.writeText(link);
toasts.show(t('access.invite.copied'));
// A new sign-in link for an invited person (another device, a lost session).
async function relink(id: string, name: string) {
await act(async () => {
const r = await api.post<{ link: string }>('/access/invitations', { label: name, identityId: id });
relinked = { id, name, link: r.link };
});
}
async function status(id: number, s: 'seen' | 'done') {
@@ -118,13 +124,7 @@
<label class="check"><input type="checkbox" bind:checked={admin} /> {t('access.invite.admin')}</label>
</fieldset>
<button class="btn primary small" type="submit">{t('access.invite.create')}</button>
{#if link}
<div class="link">
<p class="small">{t('access.invite.link')}</p>
<code>{link}</code>
<button class="btn small" type="button" onclick={copy}>{t('access.invite.copy')}</button>
</div>
{/if}
{#if link}<LinkReveal {link} note={t('access.invite.link')} />{/if}
</form>
{#if data.value.invitations.length}
<ul class="list">
@@ -138,6 +138,8 @@
{/if}
</section>
<TeamLinks groups={data.value.groups} links={data.value.teamLinks} {act} />
<section>
<h2 class="eyebrow">{t('access.identities')}</h2>
<ul class="list">
@@ -147,9 +149,14 @@
<b>{i.displayName}</b> · {t(`access.kind.${i.kind}`)}
{#if i.g1Member} · {t('access.member')}{/if}
{#if i.admin} · <span class="tag water">{t('access.admin')}</span>{/if}
{#if i.teamLink} · <span class="faint small">{t('access.team.via', { label: i.teamLink })}</span>{/if}
</span>
<span class="row">
{#if i.kind === 'invite'}<button class="btn quiet small" type="button" onclick={() => relink(i.id, i.displayName)}>{t('access.relink')}</button>{/if}
<button class="btn quiet small" type="button" onclick={() => act(() => api.post(`/access/identities/${encodeURIComponent(i.id)}/admin`, { admin: !i.admin }))}>{i.admin ? t('access.admin.take') : t('access.admin.give')}</button>
</span>
<button class="btn quiet small" type="button" onclick={() => act(() => api.post(`/access/identities/${encodeURIComponent(i.id)}/admin`, { admin: !i.admin }))}>{i.admin ? t('access.admin.take') : t('access.admin.give')}</button>
</li>
{#if relinked?.id === i.id}<li class="reveal"><LinkReveal link={relinked.link} note={t('access.relink.link', { name: relinked.name })} /></li>{/if}
{/each}
</ul>
</section>
@@ -253,19 +260,8 @@
.block > .btn {
justify-self: start;
}
.link {
display: grid;
gap: 0.5rem;
padding: 0.8rem;
border-radius: var(--r-input);
background: var(--water-wash);
}
.link code {
word-break: break-all;
font-size: 0.85rem;
}
.link .btn {
justify-self: start;
.list li.reveal {
display: block;
}
.small {
font-size: 0.84rem;