forked from yvv/decision
v3 : vote par la chaîne, corrections de la relecture de 74f96be (interface)
- rien n'est parti (nœud muet avant la signature, refus, fenêtre fermée avant) : la page lâche le bulletin ; signé, ou montré en code Ğecko, il est gardé et le serveur le confirme sans la page ; le refus de la chaîne se dit (« chain ») ; - le reçu : téléchargeable depuis la fenêtre de vote, et la page « Tes bulletins » (/bulletins) qui garde l'historique et vérifie un reçu dans le navigateur, sans le serveur (lib/receipt.ts, mêmes vecteurs que le moteur) ; - le panier ne dit plus « Tes votes comptent dès l'envoi » quand le vote est fermé ; - @noble/hashes déclaré (utilisé par e2e/chain-mock.mjs et le reçu) ; - chaîne simulée : finalité, pages, filtres, nœud qui tombe ; contrôles C32, C33. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
<script lang="ts">
|
||||
// Your ballots on the Ğ1 chain, and their receipts (ADR 0008). The research
|
||||
// note promises everyone can check their own vote with its salt: the receipt is
|
||||
// the ballot itself — its lines and its salt —, for the one who asked only, and
|
||||
// it is checked here, in the browser, without asking the server anything.
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import Guard from '$lib/components/Guard.svelte';
|
||||
import { i18n, t } from '$lib/i18n/index.svelte';
|
||||
import { Remote } from '$lib/load.svelte';
|
||||
import { checkReceipt, type ReceiptCheck } from '$lib/receipt';
|
||||
import { session } from '$lib/session.svelte';
|
||||
import type { BallotEntry, LangMap } from '$lib/types';
|
||||
|
||||
/** A receipt weighs a few kilobytes: a larger file is not one. */
|
||||
const MAX_FILE = 1_000_000;
|
||||
|
||||
const data = new Remote(() => api.get<{ ballots: BallotEntry[] }>('/ballots'));
|
||||
onMount(() => {
|
||||
if (session.me) void data.load();
|
||||
});
|
||||
|
||||
let pasted = $state('');
|
||||
let check = $state<ReceiptCheck | null>(null);
|
||||
|
||||
const titleOf = (title: LangMap | string) => (typeof title === 'string' ? title : (title[i18n.lang] ?? title.fr ?? ''));
|
||||
|
||||
function standing(b: BallotEntry): string {
|
||||
if (b.status === 'pending') return t('ballots.status.pending');
|
||||
if (b.status === 'confirmed') return t('ballots.status.confirmed', { name: b.voter ?? '' });
|
||||
if (b.status === 'refused') return t('ballots.status.refused', { reason: t(`ballot.reason.${b.reason ?? 'transfer'}`) });
|
||||
return t('ballots.status.expired');
|
||||
}
|
||||
|
||||
async function fromFile(e: Event) {
|
||||
const file = (e.currentTarget as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
pasted = '';
|
||||
check = file.size > MAX_FILE ? { kind: 'unreadable' } : checkReceipt(await file.text());
|
||||
}
|
||||
|
||||
function fromText(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
check = checkReceipt(pasted);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<h1>{t('ballots.title')}</h1>
|
||||
<p class="lead">{t('ballots.intro')}</p>
|
||||
|
||||
<section aria-labelledby="ballots-mine">
|
||||
<h2 id="ballots-mine" class="eyebrow">{t('ballots.mine')}</h2>
|
||||
{#if !session.me}
|
||||
<p class="muted">
|
||||
<a href="/connexion?return={encodeURIComponent('/bulletins')}">{t('ballots.signin')}</a>
|
||||
</p>
|
||||
{:else if data.error}
|
||||
<Guard code={data.error} onretry={() => data.load()} />
|
||||
{:else if data.value}
|
||||
<ul class="list">
|
||||
{#each data.value.ballots as b (b.id)}
|
||||
<li class="card block ballot">
|
||||
<p class="small faint">{i18n.date(b.createdAt)} · {titleOf(b.doc.title)} · {i18n.plural('ballots.lines', b.lines)}</p>
|
||||
<p class="state {b.status}">
|
||||
{standing(b)}{#if b.block !== null}<span class="faint"> · {t('ballots.block', { n: i18n.number(b.block) })}</span>{/if}
|
||||
</p>
|
||||
<code class="comment">{b.comment}</code>
|
||||
<a class="btn small" href="/api/ballots/{b.id}/receipt" download>{t('ballots.receipt')}</a>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="faint">{t('ballots.none')}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="ballots-verify">
|
||||
<h2 id="ballots-verify" class="eyebrow">{t('ballots.verify.title')}</h2>
|
||||
<div class="card block">
|
||||
<p class="muted">{t('ballots.verify.intro')}</p>
|
||||
<label class="field">
|
||||
<span class="label">{t('ballots.verify.file')}</span>
|
||||
<input type="file" accept="application/json,.json" onchange={fromFile} />
|
||||
</label>
|
||||
<form class="paste" onsubmit={fromText}>
|
||||
<label class="field">
|
||||
<span class="label">{t('ballots.verify.paste')}</span>
|
||||
<textarea rows="4" spellcheck="false" bind:value={pasted}></textarea>
|
||||
</label>
|
||||
<button class="btn" type="submit" disabled={!pasted.trim()}>{t('ballots.verify.go')}</button>
|
||||
</form>
|
||||
<div class="result" aria-live="polite">
|
||||
{#if check?.kind === 'ok'}
|
||||
<p class="ok">{t('ballots.verify.ok')}</p>
|
||||
<code class="comment">{check.comment}</code>
|
||||
<ul class="lines">
|
||||
{#each check.lines as l (l.proposalId)}
|
||||
<li>{t('ballots.verify.line', { article: l.article ?? '', title: l.title ?? l.proposalId, choice: t(`vote.choice.${l.choice}`) })}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<p class="faint small">
|
||||
{check.block !== null ? t('ballots.verify.where.block', { block: i18n.number(check.block) }) : t('ballots.verify.where')}
|
||||
</p>
|
||||
{:else if check?.kind === 'mismatch'}
|
||||
<p class="warn" role="alert">{t('ballots.verify.bad')}</p>
|
||||
<p class="small">{t('ballots.verify.computed')} <code class="comment">{check.computed}</code></p>
|
||||
<p class="small">{t('ballots.verify.expected')} <code class="comment">{check.comment}</code></p>
|
||||
{:else if check?.kind === 'unreadable'}
|
||||
<p class="warn" role="alert">{t('ballots.verify.unreadable')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<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);
|
||||
}
|
||||
.lead {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
section {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
.list,
|
||||
.lines {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.block {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
.ballot {
|
||||
justify-items: start;
|
||||
}
|
||||
.state {
|
||||
font-weight: 750;
|
||||
}
|
||||
.state.confirmed {
|
||||
color: var(--moss);
|
||||
}
|
||||
.state.pending {
|
||||
color: var(--water);
|
||||
}
|
||||
.state.refused,
|
||||
.state.expired {
|
||||
color: var(--ochre);
|
||||
}
|
||||
.comment {
|
||||
font-size: 0.8rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.label {
|
||||
font-weight: 800;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
textarea {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.paste {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
justify-items: start;
|
||||
}
|
||||
.paste .field,
|
||||
.paste textarea {
|
||||
width: 100%;
|
||||
}
|
||||
.result {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.lines {
|
||||
gap: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.ok {
|
||||
font-weight: 800;
|
||||
color: var(--moss);
|
||||
}
|
||||
.warn {
|
||||
color: var(--ochre);
|
||||
font-weight: 700;
|
||||
}
|
||||
.small {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user