Files

215 lines
15 KiB
PHP

<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>