Files
decision/web/src/routes/acces/+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

287 lines
9.9 KiB
Svelte

<script lang="ts">
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';
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; 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' }[];
}
const data = new Remote(() => api.get<Overview>('/access'));
const fb = new Remote(() => api.get<Feedback>('/access/feedback'));
onMount(() => {
void data.load();
void fb.load();
});
let choose = $state<Record<string, string>>({});
let label = $state('');
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;
async function act(fn: () => Promise<unknown>) {
error = '';
try {
await fn();
await data.load();
} catch (e) {
error = t(`error.${e instanceof ApiError ? e.code : 'internal'}` as Key);
}
}
async function invite(e: SubmitEvent) {
e.preventDefault();
await act(async () => {
const r = await api.post<{ link: string }>('/access/invitations', { label, groups, admin });
link = r.link;
label = '';
groups = [];
admin = false;
});
}
// 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') {
await api.post(`/access/feedback/${id}`, { status: s });
await fb.load();
}
</script>
{#if !session.me}
<Guard code="unauthenticated" />
{:else if !session.me.admin}
<div class="page"><p class="muted">{t('access.denied')}</p></div>
{:else if data.error}
<Guard code={data.error} onretry={() => data.load()} />
{:else if data.value}
<div class="page">
<h1>{t('access.title')}</h1>
<p class="muted">{t('access.intro')}</p>
{#if error}<p class="warn" role="alert">{error}</p>{/if}
<section>
<h2 class="eyebrow">{t('access.groups')}</h2>
{#each data.value.groups as g (g.slug)}
<div class="card block">
<h3>{g.name}</h3>
{#if g.policy === 'g1-members'}<p class="faint small">{t('access.members.wot')}</p>{/if}
<ul class="members">
{#each g.members as m (m.identityId)}
<li>
<span>{nameOf(m.identityId)}</span>
<button class="btn quiet small" type="button" onclick={() => act(() => api.del(`/access/groups/${g.slug}/members/${encodeURIComponent(m.identityId)}`))}>{t('access.revoke')}</button>
</li>
{:else}
<li class="faint small">{t('access.members.none')}</li>
{/each}
</ul>
<div class="row">
<select class="select" bind:value={choose[g.slug]} aria-label={t('access.choose')}>
<option value={undefined}>{t('access.choose')}</option>
{#each data.value.identities.filter((i) => !g.members.some((m) => m.identityId === i.id)) as i (i.id)}
<option value={i.id}>{i.displayName} · {t(`access.kind.${i.kind}`)}</option>
{/each}
</select>
<button class="btn small" type="button" disabled={!choose[g.slug]} onclick={() => act(() => api.post(`/access/groups/${g.slug}/members`, { identityId: choose[g.slug] }))}>{t('access.grant')}</button>
</div>
</div>
{/each}
</section>
<section>
<h2 class="eyebrow">{t('access.invitations')}</h2>
<form class="card block" onsubmit={invite}>
<label class="field"><span>{t('access.invite.label')}</span><input class="input" bind:value={label} maxlength="60" required /></label>
<fieldset>
<legend>{t('access.invite.groups')}</legend>
{#each data.value.groups as g (g.slug)}
<label class="check"><input type="checkbox" value={g.slug} bind:group={groups} /> {g.name}</label>
{/each}
<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}<LinkReveal {link} note={t('access.invite.link')} />{/if}
</form>
{#if data.value.invitations.length}
<ul class="list">
{#each data.value.invitations as inv (inv.id)}
<li>
<span><b>{inv.label}</b> · {t('access.invite.pending')} · {i18n.date(inv.expiresAt)}</span>
<button class="btn quiet small" type="button" onclick={() => act(() => api.del(`/access/invitations/${inv.id}`))}>{t('access.invite.cancel')}</button>
</li>
{/each}
</ul>
{/if}
</section>
<TeamLinks groups={data.value.groups} links={data.value.teamLinks} {act} />
<section>
<h2 class="eyebrow">{t('access.identities')}</h2>
<ul class="list">
{#each data.value.identities as i (i.id)}
<li>
<span>
<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>
</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>
<section>
<h2 class="eyebrow">{t('access.publication')}</h2>
<div class="card block">
{#if data.value.publication.remote}
<p class="small">{t('access.publication.remote', { remote: data.value.publication.remote })}</p>
{#if data.value.publication.publicKey}
<p class="small muted">{t('access.publication.key')}</p>
<code class="key">{data.value.publication.publicKey}</code>
{/if}
{:else}
<p class="small muted">{t('access.publication.none')}</p>
{/if}
</div>
</section>
<section>
<h2 class="eyebrow">{t('access.feedback')}</h2>
{#each fb.value?.feedback ?? [] as f (f.id)}
<div class="card block fb" class:done={f.status === 'done'}>
<p class="small faint">{f.author ?? '—'} · {i18n.date(f.createdAt)} · <code>{f.route}</code></p>
<p class="body">{f.body}</p>
<div class="row">
{#if f.status === 'new'}<button class="btn quiet small" type="button" onclick={() => status(f.id, 'seen')}>{t('access.feedback.seen')}</button>{/if}
{#if f.status !== 'done'}<button class="btn quiet small" type="button" onclick={() => status(f.id, 'done')}>{t('access.feedback.done')}</button>{/if}
</div>
</div>
{:else}
<p class="faint">{t('access.feedback.none')}</p>
{/each}
</section>
</div>
{/if}
<style>
.page {
max-width: 46rem;
margin: 0 auto;
padding: clamp(2.5rem, 7vh, 4.5rem) 1.4rem 5rem;
display: grid;
gap: 1.4rem;
}
h1 {
font-family: var(--font-text);
font-weight: 500;
font-size: clamp(1.8rem, 1.4rem + 1.5vw, 2.4rem);
}
h3 {
font-size: 1.05rem;
font-weight: 800;
}
section {
display: grid;
gap: 0.7rem;
}
.block {
padding: 1.1rem 1.2rem;
display: grid;
gap: 0.6rem;
}
.members,
.list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.3rem;
}
.members li,
.list li {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.6rem;
}
.row {
display: flex;
gap: 0.5rem;
}
fieldset {
border: 0;
padding: 0;
margin: 0;
display: grid;
gap: 0.35rem;
}
legend {
font-size: 0.82rem;
font-weight: 700;
color: var(--ink-soft);
margin-bottom: 0.3rem;
}
.check {
display: flex;
gap: 0.5rem;
align-items: center;
}
.block > .btn {
justify-self: start;
}
.list li.reveal {
display: block;
}
.small {
font-size: 0.84rem;
}
.warn {
color: var(--ochre);
font-weight: 700;
}
.key {
word-break: break-all;
font-size: 0.8rem;
background: var(--surface-2);
padding: 0.6rem 0.8rem;
border-radius: var(--r-input);
}
.fb .body {
white-space: pre-wrap;
}
.fb.done {
opacity: 0.6;
}
</style>