Snapshot of main at 9f39a6e1e (2026-11-02)
This commit is contained in:
commit
6ccf1792d7
2580 files changed
+1040202
No files matched your search
@@ -0,0 +1,214 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
{{ __('Verify poll') }}
|
||||
</x-slot>
|
||||
|
||||
<div class="py-6">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-theme-background shadow-xl sm:rounded-lg p-6 sm:px-20 space-y-6">
|
||||
|
||||
<div>
|
||||
<h2 class="text-2xl lg:text-3xl font-semibold text-theme-primary">{{ $title }}</h2>
|
||||
@if ($slug)
|
||||
<a href="{{ route('poll.show', ['slug' => $slug]) }}" class="text-sm text-theme-secondary underline">{{ __('Back to the poll') }}</a>
|
||||
@endif
|
||||
<p class="mt-4 text-sm text-theme-secondary">
|
||||
{{ __('Every vote is recorded as an anonymous ballot in a ledger. Each ballot contains the fingerprint of the one before it, so changing, adding or removing a ballot afterwards breaks the chain. You can check your own vote with your receipt code, and recount all ballots yourself.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- State of the ledger -->
|
||||
<div class="p-4 bg-theme-surface rounded-md border border-theme-border space-y-2">
|
||||
<p class="text-sm text-theme-primary">
|
||||
<strong>{{ __('Ballot ledger') }}:</strong>
|
||||
@if ($chain['valid'])
|
||||
<span class="text-green-700">{{ __('intact') }}</span> ({{ trans_choice(':count entry|:count entries', $chain['entries'], ['count' => $chain['entries']]) }})
|
||||
@else
|
||||
<span class="text-red-600">{{ __('broken at entry :sequence', ['sequence' => $chain['broken_at']]) }}</span>
|
||||
@endif
|
||||
</p>
|
||||
<p class="text-sm text-theme-secondary">
|
||||
{{ __('Ballots counted: :active. Cast: :cast, changed: :change, withdrawn: :withdraw.', $counts) }}
|
||||
</p>
|
||||
@if ($poll->sealed_at)
|
||||
<p class="text-sm text-theme-primary">
|
||||
<strong>{{ __('Sealed') }}:</strong> {{ $poll->sealed_at->locale(app()->getLocale())->isoFormat('LLL') }}
|
||||
<code class="block mt-1 break-all font-mono text-xs text-theme-secondary">{{ $poll->seal_hash }}</code>
|
||||
</p>
|
||||
<p class="text-xs text-theme-secondary">{{ __('Note this seal somewhere: if the ledger is ever changed afterwards, it will no longer match.') }}</p>
|
||||
@else
|
||||
<p class="text-sm text-theme-secondary">{{ __('The ledger is sealed when the poll closes.') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Receipt check -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-theme-primary mb-3">{{ __('Check your vote') }}</h3>
|
||||
<livewire:polls.receipt-check :pollId="$poll->id" />
|
||||
</div>
|
||||
|
||||
@if ($ballotsHidden)
|
||||
<p class="text-sm text-theme-secondary">{{ __('The ballot list and the recount become available here when the results of this poll are visible to you.') }}</p>
|
||||
@endif
|
||||
|
||||
@if ($public)
|
||||
<!-- Recount -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-theme-primary mb-3">{{ __('Recount') }}</h3>
|
||||
<p class="text-sm {{ $recount['matches'] ? 'text-green-700' : 'text-red-600' }}">
|
||||
{{ $recount['matches'] ? __('The recount of the ballots matches the published result.') : __('The recount of the ballots does not match the published result.') }}
|
||||
</p>
|
||||
<ul class="mt-2 space-y-1">
|
||||
@foreach ($recount['questions'] as $questionId => $matches)
|
||||
<li class="text-sm text-theme-primary">
|
||||
<span class="{{ $matches ? 'text-green-700' : 'text-red-600' }}">{{ $matches ? '✓' : '✗' }}</span>
|
||||
{{ $questions->get($questionId)?->labelFor(app()->getLocale()) }}
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Browser verification -->
|
||||
<div x-data="{
|
||||
running: false,
|
||||
result: null,
|
||||
error: null,
|
||||
labels: @js(['chain' => __('Hash chain'), 'recount' => __('Recount matches the published result'), 'seal' => __('Seal')]),
|
||||
async run() {
|
||||
this.running = true;
|
||||
this.error = null;
|
||||
this.result = null;
|
||||
try {
|
||||
const response = await fetch(@js(route('polls.ledger', $poll)), { headers: { Accept: 'application/json' } });
|
||||
this.result = await window.PollLedgerVerifier.verify(await response.json());
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
}
|
||||
this.running = false;
|
||||
},
|
||||
}">
|
||||
<h3 class="text-lg font-semibold text-theme-primary mb-3">{{ __('Verify in your browser') }}</h3>
|
||||
<p class="text-sm text-theme-secondary mb-3">
|
||||
{{ __('Your browser downloads all ballots and recalculates every fingerprint and the count itself, without relying on the server. The code that does this is included in this page.') }}
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<x-jetstream.button type="button" x-on:click="run()" x-bind:disabled="running" class="bg-theme-brand hover:bg-opacity-80">
|
||||
<span x-show="!running">{{ __('Verify in your browser') }}</span>
|
||||
<span x-show="running" x-cloak>{{ __('Processing...') }}</span>
|
||||
</x-jetstream.button>
|
||||
<a href="{{ route('polls.ledger', $poll) }}" class="text-sm text-theme-secondary underline" download>{{ __('Download the ledger (JSON)') }}</a>
|
||||
</div>
|
||||
<template x-if="result">
|
||||
<div class="mt-4 p-4 rounded-md border" :class="result.valid ? 'border-green-200 bg-green-50' : 'border-red-200 bg-red-50'">
|
||||
<p class="text-sm font-semibold" :class="result.valid ? 'text-green-800' : 'text-red-800'"
|
||||
x-text="result.valid ? @js(__('Everything checks out.')) : @js(__('The ledger did not pass the check.'))"></p>
|
||||
<ul class="mt-2 space-y-1">
|
||||
<template x-for="item in result.checks" :key="item.key">
|
||||
<li class="text-sm" :class="item.ok ? 'text-green-800' : 'text-red-800'">
|
||||
<span x-text="item.ok ? '✓' : '✗'"></span>
|
||||
<span x-text="labels[item.key] || item.key"></span>
|
||||
<span class="text-xs" x-text="item.detail"></span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="error" x-cloak class="mt-3 text-sm text-red-600" x-text="error"></p>
|
||||
</div>
|
||||
|
||||
<!-- Anonymous ballot list -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-theme-primary mb-3">{{ __('All ballots') }}</h3>
|
||||
<p class="text-sm text-theme-secondary mb-3">{{ __('Ballots are shown by fingerprint, never by voter. Free text is hidden as [text]; it is still part of the fingerprint.') }}</p>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">#</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">{{ __('Type') }}</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">{{ __('Fingerprint') }}</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">{{ __('Status') }}</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">{{ __('Answers') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
@foreach ($ballots as $entry)
|
||||
@php($replaced = $replacedBy->get($entry->sequence))
|
||||
<tr>
|
||||
<td class="px-3 py-2 text-gray-500">{{ $entry->sequence }}</td>
|
||||
<td class="px-3 py-2 text-gray-900">{{ __(['cast' => 'Vote', 'change' => 'Changed vote', 'withdraw' => 'Withdrawal', 'seal' => 'Seal'][$entry->entry_type]) }}</td>
|
||||
<td class="px-3 py-2 font-mono text-xs text-gray-500">{{ $entry->fingerprint ? substr($entry->fingerprint, 0, 12) : '-' }}</td>
|
||||
<td class="px-3 py-2 text-gray-500">
|
||||
@if (!$entry->isBallot())
|
||||
-
|
||||
@elseif ($replaced === 'withdraw')
|
||||
{{ __('Withdrawn') }}
|
||||
@elseif ($replaced)
|
||||
{{ __('Replaced') }}
|
||||
@else
|
||||
{{ __('Counted') }}
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-3 py-2 text-gray-900">
|
||||
@if ($entry->isBallot())
|
||||
@foreach ($entry->payload['answers'] ?? [] as $answer)
|
||||
@php($question = $questions->get($answer['question_id']))
|
||||
@if ($question && app(\App\Polls\QuestionTypeRegistry::class)->has($question->type))
|
||||
<div>
|
||||
<span class="text-gray-500">{{ \Illuminate\Support\Str::limit($question->labelFor(app()->getLocale()), 40) }}:</span>
|
||||
{{ $question->questionType()->answerToText($answer['value'] ?? null, !empty($answer['text_sha256']) ? '[' . __('text') . ']' : null, $question->settings ?? [], app()->getLocale()) ?: '-' }}
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
@elseif ($entry->entry_type === 'withdraw')
|
||||
{{ __('Ballot #:sequence no longer counts', ['sequence' => $entry->refers_to_sequence]) }}
|
||||
@elseif ($entry->entry_type === 'seal')
|
||||
{{ __(':count ballots counted at closing', ['count' => $entry->payload['active_ballots'] ?? 0]) }}
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-4">{{ $ballots->links() }}</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Limits -->
|
||||
<div class="p-4 bg-theme-surface rounded-md border border-theme-border">
|
||||
<h3 class="text-sm font-semibold text-theme-primary">{{ __('What this can and cannot prove') }}</h3>
|
||||
|
||||
<p class="mt-3 text-sm font-medium text-theme-primary">{{ __('This page can show:') }}</p>
|
||||
<ul class="mt-1 list-disc pl-5 space-y-1 text-sm text-theme-secondary">
|
||||
<li>{{ __('That your vote was counted the way you cast it. Enter your receipt code to see your ballot.') }}</li>
|
||||
<li>{{ __('That no vote was changed, added or removed afterwards. Each ballot carries a fingerprint of the ballot before it, like links in a chain.') }}</li>
|
||||
<li>{{ __('That nothing changed after the poll was sealed. Write down the seal at the top: if it is still the same later, nothing has changed.') }}</li>
|
||||
</ul>
|
||||
<p class="mt-2 text-sm text-theme-secondary italic">
|
||||
{{ __('Example: if someone changed one vote from Yes to No, its fingerprint would no longer match the next ballot. The chain breaks and the check shows it.') }}
|
||||
</p>
|
||||
|
||||
<p class="mt-3 text-sm font-medium text-theme-primary">{{ __('This page cannot show:') }}</p>
|
||||
<ul class="mt-1 list-disc pl-5 space-y-1 text-sm text-theme-secondary">
|
||||
<li>{{ __('Who cast the other ballots. The list is anonymous. The platform does keep track of who voted, but only so that each profile votes once.') }}</li>
|
||||
</ul>
|
||||
|
||||
<p class="mt-3 text-sm font-medium text-theme-primary">{{ __('Good to know:') }}</p>
|
||||
<ul class="mt-1 list-disc pl-5 space-y-1 text-sm text-theme-secondary">
|
||||
<li>{{ __('Keep your receipt code to yourself: anyone who has it can see how you voted.') }}</li>
|
||||
<li>{{ __('We cannot send your code again if you lose it, because we only store a fingerprint of it.') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($public)
|
||||
@push('scripts')
|
||||
<script>
|
||||
{!! file_get_contents(resource_path('js/poll-ledger-verifier.js')) !!}
|
||||
</script>
|
||||
@endpush
|
||||
@endif
|
||||
</x-app-layout>
|
||||
Reference in new issue
Block a user