Commit 1819b943 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(portal): PWA, push, the check-in gate, the native shell, and the docs that were wrong

S6, S7, the staff half of S8, S9 and S10, plus the admin screens S3 and S5
were waiting on.

PWA (S6)
--------
The worker lives at /app/sw.js and is generated per deploy, because the
precache list comes from public/build/manifest.json and the cache name is a
hash of it — a deploy evicts the old cache instead of leaving a worker
serving asset URLs that no longer exist.

Its scope is /app/, not the root: a root worker would control /dashboard and
/api too, serving admins a stale shell and leaving cached credentialed
responses on a shared front-desk tablet.

HTML is never precached. wire:navigate swaps <head> wholesale and prefetches
on hover, so an HTML cache fills with unvisited pages and then injects @vite
hashes from a build that no longer exists — a blank page with no error.
/livewire/* is never cached at all: its snapshot checksum is bound to APP_KEY
and the session, so a replayed one is a corrupt-snapshot error rather than a
stale render. The only offline artifact is a static page with no session and
no CSRF token in it.

nginx gets exact-match locations for /app/sw.js and /app/manifest.webmanifest.
Both end in an extension the static-asset regex claims, and that regex ends in
try_files $uri =404 — so without these the worker 404s before reaching PHP.

Push (S7)
---------
FCM, not VAPID. kreait/firebase-php is installed, device_tokens exists, twelve
listeners already funnel through PushNotificationService, every client has
their own Firebase project, and FCM HTTP v1 delivers to Web Push endpoints
with the same CloudMessage and the same token column. VAPID buys independence
from Google — not a constraint here — for a second sender, table, log path and
prune policy.

So the change is: platform CHECK widened to include 'web', a user_agent column
for sensible pruning, and a unique index on (device_token, user_id) — never on
the token alone, which is what the deleted DeviceController keyed on, letting
anyone claim anyone's token so the victim's phone received the attacker's
notifications. Duplicates are cleared before the index, because a failed
migration blocks every later one on that client forever.

The check-in gate (S8)
----------------------
Staff-scan only. The printed-poster direction stays cut: a printed QR is a
public, permanent, non-secret string, so rotation is impossible by
construction — it proves the member once visited, or knows someone who did.

The scanner screen works with a connected barcode reader by default and uses
BarcodeDetector where the browser has it, because most reception desks have
the reader and not the camera permission.

A real bug the tests caught: participants.status is cast to an enum, so
comparing it to the string 'active' was always false — the gate would have
turned everyone away.

The native shell (S9)
---------------------
flutter_shell/ holds one long-lived Sanctum token in the Keychain or
EncryptedSharedPreferences with the single ability portal:session, and
exchanges it at /app/session-exchange for an ordinary web session in the
WebView's own jar. The token never reaches JavaScript. /app/* is never
exempted from CSRF — that shortcut is what turns a wrapper from safe into
trivially exploitable.

Every bridge is an exported native capability, so each is narrow and checked
natively: the host allowlist is compared against the origin read from the
controller, never from the page; biometrics gate a native action and return
nothing the page can use as an authorisation decision; QR is decoded natively
and only the string crosses.

flutter_inappwebview rather than webview_flutter, because <input type="file">
is inert in a bare Android WebView without onShowFileChooser — and that single
gap breaks the transfer-proof upload, which is the portal's whole money path.

Two endpoints only, and they are the only routes on the sanctum guard. The
deleted API minted tokens with mobile:* — every endpoint it would ever grow.

E3's recommendation stands and the shell is not shipped this cycle. It exists
so that shipping is a decision rather than a project.

App content (S10)
-----------------
One `channel` column on website_news and website_sections instead of the
parallel CMS the plan called for. website_sections, website_news,
website_menus, media, a page builder and website:blueprint export|import all
already exist; a second CMS is a second migration surface, a second editor to
keep in step, and a second place for content to go missing, forever.

Admin screens
-------------
Portal invitations, where the raw link exists for exactly one render and is
never recoverable afterwards. The duplicate-account merge screen E4 asked for
— the prerequisite for ever putting a unique index on users.phone, and the
reason ambiguous phone logins can be refused rather than guessed. Both move
references rather than deleting rows: a deleted user id in a financial record
is worse than a duplicate account.

Documentation that was actively wrong
-------------------------------------
docs/agent-rules/05-financial-integrity.md described double-entry as two rows
with a type of 'debit' or 'credit', and 16-enums-and-checks.md registered that
vocabulary. That schema has never existed — 2024_01_01_000013 created the
single-row shape with both account columns from the start. Anyone writing code
from that text got a mass-assignment no-op and a row that silently said
nothing. CLAUDE.md repeated the same claim, and also said Livewire 3 while
composer.json says ^4.3 — a difference that decides whether a public property
is an IDOR.

The test suite is now symmetrical: tests that build their own tables skip off
SQLite, tests that need a real tenant skip off Postgres, so the whole file
runs clean under either connection instead of one of them being a lie.

Suite: 76 pass on SQLite (24 skipped), and against the restored tenant
PortalSmokeTest 3/3, PaymentProofTest 11/11, CheckInScanTest 10/10.
portal.css is 4.98 kB gzipped against app.css at 31.10 kB.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 2c95f617
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
Modular ERP for sports organisations. Activity-agnostic: any sport is a Modular ERP for sports organisations. Activity-agnostic: any sport is a
configuration of the same business model. configuration of the same business model.
**Stack:** Laravel 13 / PHP 8.4 · PostgreSQL 16 · Livewire 3 + Alpine + Tailwind · **Stack:** Laravel 13 / PHP 8.4 · PostgreSQL 16 · Livewire 4 + Alpine + Tailwind 4 ·
Arabic-first (`ar` default, bilingual) · CapRover deploy. Arabic-first (`ar` default, bilingual) · CapRover deploy.
**Layout:** `app/Domain/{Module}/{Models,Services,Events,Enums}`, **Layout:** `app/Domain/{Module}/{Models,Services,Events,Enums}`,
...@@ -55,10 +55,19 @@ arithmetic only; when splitting, round down and give the remainder to the last i ...@@ -55,10 +55,19 @@ arithmetic only; when splitting, round down and give the remainder to the last i
`academies`, `permissions`, and framework tables are exempt. SuperAdmin is the `academies`, `permissions`, and framework tables are exempt. SuperAdmin is the
only code path that may bypass the global scope. only code path that may bypass the global scope.
**Financial** — every movement writes a **debit + credit pair**, amounts always **Financial** — a `transactions` row carries **both sides**
positive, `type` carries direction. `transactions` and `audit_logs` are (`debit_account_id` + `credit_account_id`); there is no pair of rows and no
**immutable**: no `updated_at`, no soft deletes, corrections are new reversing `account_id` column. Amounts are always positive; `type` says what kind of
entries. Invoice totals freeze at creation. movement it was, not which side. Accounts resolve **by code, scoped to the
academy**, through `LedgerAccountResolver` — a missing account is a hard fail,
never a hardcoded id. `transactions` and `audit_logs` are treated as
**immutable**: corrections are new reversing entries, never edits. Invoice
totals freeze at creation.
**Livewire 4** — a plain `public` property is **settable from the browser**.
Validating an id in `mount()` and then filtering queries on it in `render()` is
an IDOR, not a check. Use `#[Locked]` *and* re-validate where the query is
built.
**Inventory** — never touch `inventory_levels.quantity_on_hand` directly. All **Inventory** — never touch `inventory_levels.quantity_on_hand` directly. All
changes go through `InventoryService::createMovement()`, which locks the row and changes go through `InventoryService::createMovement()`, which locks the row and
......
...@@ -48,7 +48,14 @@ public function scan(string $token, User $scanner, ?int $branchId = null, ?strin ...@@ -48,7 +48,14 @@ public function scan(string $token, User $scanner, ?int $branchId = null, ?strin
// The token asserted identity. Everything that follows is authorisation, // The token asserted identity. Everything that follows is authorisation,
// and every part of it is read fresh — which is what makes a suspension // and every part of it is read fresh — which is what makes a suspension
// take effect at the next scan rather than at the next token rotation. // take effect at the next scan rather than at the next token rotation.
if ($participant->status !== 'active') { // `status` is cast to ParticipantStatus, so comparing it to the string
// 'active' is always false and would turn this gate into a wall that
// turns everyone away.
$status = $participant->status instanceof \BackedEnum
? $participant->status->value
: (string) $participant->status;
if ($status !== 'active') {
throw new DomainException('العضوية غير نشطة — يرجى مراجعة الإدارة'); throw new DomainException('العضوية غير نشطة — يرجى مراجعة الإدارة');
} }
......
<?php
namespace App\Domain\Identity\Services;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Helpers\CredentialNormalizer;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
/**
* Merges two accounts that belong to the same person.
*
* 2026_08_30_000004 normalised phone numbers and deliberately left duplicates
* in place — it logged the ids that "need a manual merge" rather than failing
* the migration, which was the right call: a unique index would have hard-failed
* on at least one live client and then blocked every later migration on that
* client forever.
*
* This is the manual merge it was waiting for. Until duplicates are cleared,
* `->first()` on a phone number stays an account-selection primitive, which is
* why AuthService refuses an ambiguous login rather than guessing.
*
* The merge moves references rather than deleting rows: a deleted user id in a
* financial record is worse than a duplicate account.
*/
class AccountMergeService
{
/**
* Accounts sharing a phone number or a case-insensitive email.
*
* @return Collection<int, array{key: string, kind: string, users: Collection<int, User>}>
*/
public function findDuplicates(int $academyId): Collection
{
$users = User::withoutGlobalScopes()
->where('academy_id', $academyId)
->whereNull('deleted_at')
->get(['id', 'name', 'name_ar', 'email', 'phone', 'person_id', 'status', 'last_login_at', 'email_is_synthetic']);
$groups = collect();
$byPhone = $users->filter(fn ($u) => filled($u->phone))
->groupBy(fn ($u) => CredentialNormalizer::phone($u->phone));
foreach ($byPhone as $phone => $group) {
if ($group->count() > 1) {
$groups->push(['key' => (string) $phone, 'kind' => 'phone', 'users' => $group->values()]);
}
}
$byEmail = $users->filter(fn ($u) => filled($u->email) && ! $u->email_is_synthetic)
->groupBy(fn ($u) => CredentialNormalizer::email($u->email));
foreach ($byEmail as $email => $group) {
if ($group->count() > 1) {
$groups->push(['key' => (string) $email, 'kind' => 'email', 'users' => $group->values()]);
}
}
return $groups;
}
/**
* Point everything at $keep and retire $merge.
*
* Ownership columns are moved rather than rows deleted. `created_by` on an
* invoice is a fact about who did something, and rewriting it to a
* different person would be a lie; only the columns that mean "this
* account" are moved, and the retired row is soft-deleted so the id keeps
* resolving in anything that still references it.
*/
public function merge(User $keep, User $merge, User $actor): void
{
if ($keep->id === $merge->id) {
throw new DomainException('لا يمكن دمج حساب مع نفسه');
}
if ((int) $keep->academy_id !== (int) $merge->academy_id) {
throw new DomainException('لا يمكن دمج حسابين من أكاديميتين مختلفتين');
}
if ($keep->person_id && $merge->person_id && (int) $keep->person_id !== (int) $merge->person_id) {
throw new DomainException(
'الحسابان مرتبطان بشخصين مختلفين — راجع البيانات قبل الدمج'
);
}
DB::transaction(function () use ($keep, $merge, $actor) {
// Anything that identifies "the member behind the account".
$this->repoint('guardians', 'user_id', $merge->id, $keep->id);
$this->repoint('people', 'user_id', $merge->id, $keep->id);
$this->repoint('device_tokens', 'user_id', $merge->id, $keep->id);
$this->repoint('notification_preferences', 'user_id', $merge->id, $keep->id);
$this->repoint('service_requests', 'user_id', $merge->id, $keep->id);
$this->repoint('contact_messages', 'user_id', $merge->id, $keep->id);
$this->repoint('portal_invitations', 'consumed_by', $merge->id, $keep->id);
$this->repoint('login_history', 'user_id', $merge->id, $keep->id);
// A person keeps whichever real credentials existed across the two.
if ($keep->email_is_synthetic && ! $merge->email_is_synthetic) {
$realEmail = $merge->email;
// Free the address before claiming it: users.email is UNIQUE.
User::withoutGlobalScopes()->where('id', $merge->id)->update([
'email' => 'merged-' . $merge->id . '@portal.invalid',
'email_is_synthetic' => true,
]);
$keep->email = $realEmail;
$keep->email_is_synthetic = false;
}
if (blank($keep->phone) && filled($merge->phone)) {
$keep->phone = $merge->phone;
}
if (! $keep->person_id && $merge->person_id) {
$keep->person_id = $merge->person_id;
}
$keep->save();
// `inactive`, not a new 'merged' status: users_status_check allows
// exactly active|inactive|suspended|pending, and widening a CHECK
// for a bookkeeping nicety is not worth a migration on every
// tenant. The soft delete is what actually says this is retired.
User::withoutGlobalScopes()->where('id', $merge->id)->update([
'status' => 'inactive',
'phone' => null,
'updated_at' => now(),
]);
User::withoutGlobalScopes()->where('id', $merge->id)->delete();
DB::table('audit_logs')->insert([
'academy_id' => $keep->academy_id,
'user_id' => $actor->id,
'auditable_type' => User::class,
'auditable_id' => $keep->id,
'event' => 'account_merged',
'new_values' => json_encode(['merged_user_id' => $merge->id, 'kept_user_id' => $keep->id]),
'is_financial' => false,
'created_at' => now(),
]);
});
}
private function repoint(string $table, string $column, int $from, int $to): void
{
if (! \Illuminate\Support\Facades\Schema::hasTable($table)
|| ! \Illuminate\Support\Facades\Schema::hasColumn($table, $column)) {
return;
}
DB::table($table)->where($column, $from)->update([$column => $to]);
}
}
<?php
namespace App\Http\Controllers\Portal;
use App\Domain\Shared\Services\SettingsService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
/**
* The two files a platform fetches to verify that this domain and that app are
* the same product.
*
* Both must be served as `application/json`, from the apex path, with **no
* redirect** — a 301 to a canonical host is enough for the platform to refuse
* verification, and it refuses silently: deep links simply open the browser
* instead of the app, with nothing logged anywhere.
*
* They are routes rather than static files because the fingerprints are
* per-tenant: every client has their own signing key and their own bundle id.
* An unconfigured tenant returns an empty association rather than a 404, which
* is the state that says "this domain is not linked to an app" instead of
* "this domain is broken".
*/
class AppAssociationController extends Controller
{
public function assetlinks(SettingsService $settings): JsonResponse
{
$package = (string) $settings->get('mobile_app.android_package');
$fingerprints = array_values(array_filter(array_map(
'trim',
explode(',', (string) $settings->get('mobile_app.android_sha256_fingerprints'))
)));
if ($package === '' || $fingerprints === []) {
return $this->json([]);
}
return $this->json([[
'relation' => ['delegate_permission/common.handle_all_urls'],
'target' => [
'namespace' => 'android_app',
'package_name' => $package,
'sha256_cert_fingerprints' => $fingerprints,
],
]]);
}
public function appleAppSiteAssociation(SettingsService $settings): JsonResponse
{
$appId = (string) $settings->get('mobile_app.ios_app_id'); // TEAMID.bundle.id
if ($appId === '') {
return $this->json(['applinks' => ['details' => []]]);
}
return $this->json([
'applinks' => [
'details' => [[
'appIDs' => [$appId],
'components' => [
// Only the portal. Linking the whole domain would hand
// the ERP and the public website to the app as well.
['/' => '/app/*', 'comment' => 'member portal'],
],
]],
],
'webcredentials' => ['apps' => [$appId]],
]);
}
private function json(array $payload): JsonResponse
{
return response()->json($payload, 200, [
'Content-Type' => 'application/json',
], JSON_UNESCAPED_SLASHES);
}
}
<?php
namespace App\Http\Controllers\Portal;
use App\Domain\Shared\Models\DeviceToken;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Registers a browser's (or the native shell's) FCM token against the signed-in
* account.
*
* This is a session-authenticated web route with CSRF, not an API endpoint. The
* portal is session-authenticated and the Flutter shell carries the portal's
* own cookie, so there is nothing for a second identity system to do here.
*
* The row is keyed on (device_token, user_id) — never on the token alone. The
* deleted API keyed on the token by itself, so anyone could claim anyone's
* token and the victim's phone would then receive the attacker's
* notifications.
*/
class DeviceTokenController extends Controller
{
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'token' => ['required', 'string', 'min:20', 'max:512'],
'platform' => ['required', 'in:web,android,ios'],
'device_name' => ['nullable', 'string', 'max:120'],
'app_version' => ['nullable', 'string', 'max:40'],
]);
$user = $request->user();
// Someone else holding this exact token means the device changed hands
// or the browser profile was copied. The old claim goes, rather than
// both accounts receiving each other's notifications.
DeviceToken::withoutGlobalScopes()
->where('device_token', $data['token'])
->where('user_id', '!=', $user->id)
->delete();
DeviceToken::withoutGlobalScopes()->updateOrCreate(
['device_token' => $data['token'], 'user_id' => $user->id],
[
'academy_id' => $user->academy_id,
'platform' => $data['platform'],
'device_name' => $data['device_name'] ?? null,
'user_agent' => substr((string) $request->userAgent(), 0, 255),
'app_version' => $data['app_version'] ?? null,
'is_active' => true,
'last_used_at' => now(),
]
);
return response()->json(['registered' => true]);
}
public function destroy(Request $request): JsonResponse
{
$data = $request->validate([
'token' => ['required', 'string', 'max:512'],
]);
DeviceToken::withoutGlobalScopes()
->where('device_token', $data['token'])
->where('user_id', $request->user()->id)
->delete();
return response()->json(['removed' => true]);
}
/**
* The Firebase web config the portal needs to obtain a token at all.
*
* Every value here is public by design — a Firebase web config is embedded
* in any site that uses it, and delivery is authorised by the token, not by
* the config. It is served per tenant because each client has their own
* Firebase project.
*/
public function config(Request $request): JsonResponse
{
$settings = app(\App\Domain\Shared\Services\SettingsService::class);
$config = [
'apiKey' => $settings->get('mobile_app.firebase_web_api_key'),
'projectId' => $settings->get('mobile_app.firebase_project_id'),
'messagingSenderId' => $settings->get('mobile_app.firebase_sender_id'),
'appId' => $settings->get('mobile_app.firebase_web_app_id'),
'vapidKey' => $settings->get('mobile_app.firebase_vapid_public_key'),
];
// Half-configured is worse than unconfigured: the SDK throws on init and
// takes the page with it.
$configured = ! in_array(null, $config, true) && ! in_array('', $config, true);
return response()->json([
'configured' => $configured,
'config' => $configured ? $config : null,
])->header('Cache-Control', 'private, no-store');
}
}
<?php
namespace App\Http\Controllers\Portal;
use App\Domain\Identity\Services\AuthService;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
/**
* The only two endpoints the native shell needs, and deliberately the only two.
*
* The shell wraps the web portal, so it does not need an API — it needs a way
* to hold a credential across app launches without keeping a cookie jar, and a
* way to turn that credential into the ordinary web session everything else
* already uses.
*
* The shell therefore holds **one** long-lived token, in the Keychain or
* EncryptedSharedPreferences, and exchanges it for a fresh web session on
* launch. It never holds a cookie-plus-token pair, the token never reaches
* JavaScript, and `/app/*` is never exempted from CSRF "because the native app
* cannot get a token" — that single shortcut is what turns a wrapper from safe
* into trivially exploitable.
*
* The ability is `portal:session` and nothing else. The deleted API minted
* tokens with `mobile:*`, which meant every endpoint it ever grew.
*/
class NativeSessionController extends Controller
{
private const ABILITY = 'portal:session';
private const TOKEN_NAME = 'portal-native';
/**
* Exchange credentials for a long-lived token, once, at first launch.
*
* Throttled by identifier and by IP, because the two abuses are different:
* guessing one account's password, and spraying one password across many
* accounts.
*/
public function issueToken(Request $request, AuthService $auth): JsonResponse
{
$data = $request->validate([
'identifier' => ['required', 'string', 'max:120'],
'password' => ['required', 'string', 'max:200'],
'device_name' => ['nullable', 'string', 'max:80'],
]);
$keys = [
'native-login:id:' . sha1(mb_strtolower($data['identifier'])),
'native-login:ip:' . $request->ip(),
];
foreach ($keys as $key) {
if (RateLimiter::tooManyAttempts($key, 8)) {
return response()->json([
'error' => __('محاولات كثيرة. حاول مرة أخرى بعد قليل.'),
], 429);
}
}
$result = $auth->attempt($data['identifier'], $data['password'], $request->ip(), $request->userAgent());
if (! $result->success) {
foreach ($keys as $key) {
RateLimiter::hit($key, 900);
}
// One message for every failure. Distinguishing "no such account"
// from "wrong password" is an account-enumeration oracle.
return response()->json(['error' => __('بيانات الدخول غير صحيحة')], 422);
}
$user = $result->user;
if (! $user->can('portal.access')) {
return response()->json(['error' => __('هذا الحساب ليس حساب عضو')], 403);
}
foreach ($keys as $key) {
RateLimiter::clear($key);
}
// One live native token per account: a new install retires the old one,
// so a lost phone stops working as soon as the member signs in again.
$user->tokens()->where('name', self::TOKEN_NAME)->delete();
$token = $user->createToken(self::TOKEN_NAME, [self::ABILITY], now()->addYear());
return response()->json([
'token' => $token->plainTextToken,
'expires_at' => $token->accessToken->expires_at?->toIso8601String(),
]);
}
/**
* Turn the stored token into a web session and land on the portal.
*
* The shell calls this on launch, in the WebView, so the resulting cookie
* belongs to the WebView's own jar and every subsequent request is an
* ordinary session request with CSRF intact.
*/
public function exchange(Request $request): RedirectResponse|JsonResponse
{
$user = $request->user();
if (! $user) {
return response()->json(['error' => 'unauthenticated'], 401);
}
if (! $user->can('portal.access')) {
return response()->json(['error' => 'forbidden'], 403);
}
Auth::guard('web')->login($user, remember: true);
$request->session()->regenerate();
$user->forceFill(['portal_last_seen_at' => now()])->save();
return redirect()->route('portal.home');
}
/**
* Remote sign-out: drop the native token and the session together.
*/
public function revoke(Request $request): JsonResponse
{
$user = $request->user();
if ($user instanceof User) {
$user->tokens()->where('name', self::TOKEN_NAME)->delete();
}
if ($request->hasSession()) {
Auth::guard('web')->logout();
$request->session()->invalidate();
}
return response()->json(['revoked' => true]);
}
}
<?php
namespace App\Http\Controllers\Portal;
use App\Http\Controllers\Controller;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\File;
/**
* The portal's service worker, generated per deploy.
*
* It is a route rather than a static file for one reason: the precache list is
* built at runtime from public/build/manifest.json, and the cache name is a
* hash of that manifest — so a deploy purges the previous cache instead of
* leaving a worker serving asset URLs that no longer exist.
*
* Everything here is shaped by what a Livewire application actually is:
*
* - It lives at /app/sw.js. A worker's scope is its own directory, and a root
* worker would control /dashboard and /api as well — serving admins a stale
* shell and leaving cached credentialed responses on a shared tablet.
*
* - HTML is never precached. `wire:navigate` swaps <head> wholesale and
* prefetches on hover, so a CacheFirst HTML cache fills with unvisited pages
* and then injects @vite hashes from a build that no longer exists — a blank
* page with no error anywhere.
*
* - /livewire/* is never cached at all. A Livewire request carries a snapshot
* whose checksum is bound to APP_KEY and the session; replaying a cached one
* produces a corrupt-snapshot error rather than a stale render.
*/
class ServiceWorkerController extends Controller
{
public function __invoke(): Response
{
$manifestPath = public_path('build/manifest.json');
$manifest = File::exists($manifestPath)
? (json_decode(File::get($manifestPath), true) ?: [])
: [];
$assets = [];
foreach ($manifest as $entry) {
// Only the portal's own bundle. Precaching the ERP's assets would
// cost a member several hundred kilobytes they will never use.
if (! str_contains($entry['src'] ?? '', 'portal.')) {
continue;
}
$assets[] = '/build/' . $entry['file'];
foreach ($entry['css'] ?? [] as $css) {
$assets[] = '/build/' . $css;
}
}
$assets[] = '/app/offline';
$version = substr(hash('sha256', json_encode($manifest) . implode('', $assets)), 0, 12);
$script = $this->script($version, array_values(array_unique($assets)));
return response($script, 200, [
'Content-Type' => 'text/javascript; charset=utf-8',
// The worker itself is never cached: a cached worker cannot be
// replaced, and then nothing can be.
'Cache-Control' => 'no-cache, no-store, must-revalidate',
'Service-Worker-Allowed' => '/app/',
]);
}
private function script(string $version, array $assets): string
{
$cacheName = "portal-{$version}";
$precache = json_encode($assets, JSON_UNESCAPED_SLASHES);
return <<<JS
/* Generated per deploy. Cache name carries the build hash, so a new
deploy evicts the old cache instead of layering on top of it. */
const CACHE = '{$cacheName}';
const PRECACHE = {$precache};
const OFFLINE_URL = '/app/offline';
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE)
.then((cache) => cache.addAll(PRECACHE))
.then(() => self.skipWaiting())
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(
keys.filter((key) => key !== CACHE).map((key) => caches.delete(key))
))
.then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (event) => {
const request = event.request;
/* Bail out first, before anything else runs. Each of these would be
actively harmful to cache, not merely useless. */
if (request.method !== 'GET') return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
if (url.pathname.startsWith('/livewire/')) return; /* snapshot is session-bound */
if (url.pathname.startsWith('/api/')) return;
if (url.pathname.startsWith('/storage/')) return; /* private-ish member media */
if (url.pathname.startsWith('/proofs/')) return; /* bank screenshots */
if (url.pathname === '/app/sw.js') return;
if (url.pathname === '/app/manifest.webmanifest') return;
if (!url.pathname.startsWith('/app')) return; /* the ERP is not ours */
/* Navigations: always try the network. The only offline artifact is
a static page with no session and no CSRF token in it. */
if (request.mode === 'navigate') {
event.respondWith(
fetch(request).catch(() => caches.match(OFFLINE_URL))
);
return;
}
/* Build assets are content-hashed, so a hit is always correct. */
if (url.pathname.startsWith('/build/')) {
event.respondWith(
caches.match(request).then((hit) => hit || fetch(request).then((response) => {
if (response.ok) {
const copy = response.clone();
caches.open(CACHE).then((cache) => cache.put(request, copy));
}
return response;
}))
);
}
});
/* A push arriving with no window open still has to reach the member. */
self.addEventListener('push', (event) => {
if (!event.data) return;
let payload = {};
try { payload = event.data.json(); } catch (e) { payload = { body: event.data.text() }; }
const notification = payload.notification || payload;
const data = payload.data || {};
event.waitUntil(self.registration.showNotification(
notification.title || 'إشعار',
{
body: notification.body || '',
icon: notification.icon || '/build/icon-192.png',
badge: notification.badge,
dir: 'rtl',
lang: 'ar',
tag: data.tag || undefined,
data: { url: data.url || '/app' },
}
));
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const target = (event.notification.data && event.notification.data.url) || '/app';
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
for (const client of clients) {
if (client.url.includes('/app') && 'focus' in client) {
client.navigate(target);
return client.focus();
}
}
return self.clients.openWindow(target);
})
);
});
JS;
}
}
<?php
namespace App\Livewire\Attendance;
use App\Domain\Attendance\Services\SelfCheckInService;
use App\Domain\Shared\Context\BranchContext;
use App\Domain\Shared\Exceptions\DomainException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
/**
* The gate: staff scan the member's rotating pass and attendance is marked.
*
* Staff-scan only. The other direction — a printed poster the member scans — was
* cut on purpose: a printed QR is a public, permanent, non-secret string, so
* rotation is impossible by construction. It proves the member once visited, or
* knows someone who did, not that they are present. Only a powered display
* running a rotating code produces real evidence, and paper cannot.
*
* Rate limits are per staff member and per participant, because the two abuses
* are different: a broken scanner hammering the endpoint, and someone walking a
* queue of screenshots past it.
*/
#[Layout('layouts.app')]
#[Title('مسح بطاقة الحضور')]
class CheckInScanner extends Component
{
public string $token = '';
/** @var array<int, array{name: string, session: string, at: string, replay: bool}> */
public array $recent = [];
public ?string $error = null;
public ?string $success = null;
public function mount(): void
{
$this->authorize('attendance.scan');
}
public function scan(SelfCheckInService $checkIn): void
{
$this->authorize('attendance.scan');
$this->error = null;
$this->success = null;
$token = trim($this->token);
$this->token = '';
if ($token === '') {
return;
}
$user = auth()->user();
// 60 scans a minute is a busy gate; more than that is a stuck scanner.
$staffKey = "checkin-scan:staff:{$user->id}";
if (\Illuminate\Support\Facades\RateLimiter::tooManyAttempts($staffKey, 60)) {
$this->error = __('عدد كبير من المحاولات — انتظر قليلاً');
return;
}
\Illuminate\Support\Facades\RateLimiter::hit($staffKey, 60);
try {
$result = $checkIn->scan(
$token,
$user,
app(BranchContext::class)->branchId(),
request()->ip(),
);
} catch (DomainException $e) {
$this->error = $e->getMessage();
return;
}
$name = $result['participant']->person?->name_ar ?? $result['participant']->person?->name ?? '—';
$session = $result['session']->group?->name_ar ?? __('حصة');
$this->success = $result['replay']
? __('تم تسجيل الحضور مسبقاً') . ' — ' . $name
: __('تم تسجيل الحضور') . ' — ' . $name;
array_unshift($this->recent, [
'name' => $name,
'session' => $session,
'at' => now()->format('H:i:s'),
'replay' => $result['replay'],
]);
$this->recent = array_slice($this->recent, 0, 12);
}
public function render()
{
return view('livewire.attendance.check-in-scanner');
}
}
<?php
namespace App\Livewire\Portal;
use App\Domain\Identity\Models\Person;
use App\Domain\Identity\Models\PortalInvitation;
use App\Domain\Identity\Services\PortalInvitationService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\WhatsApp\Services\WhatsAppService;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
/**
* Staff issue portal invitations from here.
*
* The raw token exists exactly once, in the response to the click that created
* it — after that only its SHA-256 is stored and nobody, staff included, can
* recover the link. Re-inviting revokes the previous one rather than leaving
* two working links in the wild.
*/
#[Layout('layouts.app')]
#[Title('دعوات بوابة الأعضاء')]
class PortalInvitationManager extends Component
{
use WithPagination;
#[Url(as: 'q')]
public string $search = '';
/**
* The one moment the raw link exists. Held for this render only and never
* written anywhere; a refresh loses it, which is the correct behaviour for
* a single-use credential.
*/
public ?string $freshLink = null;
#[Locked]
public ?int $freshPersonId = null;
public function mount(): void
{
$this->authorize('users.create');
}
public function updatedSearch(): void
{
$this->resetPage();
}
public function invite(int $personId, PortalInvitationService $invitations): void
{
$this->authorize('users.create');
$person = Person::findOrFail($personId);
try {
['invitation' => $invitation, 'token' => $token] = $invitations->issue(
$person,
auth()->user(),
null,
'link',
request()->ip(),
);
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
return;
}
$this->freshLink = route('portal.activate', $token);
$this->freshPersonId = $person->id;
session()->flash('success', __('تم إنشاء رابط التفعيل — انسخه الآن، لن يظهر مرة أخرى'));
}
public function sendViaWhatsApp(int $personId, PortalInvitationService $invitations, WhatsAppService $whatsapp): void
{
$this->authorize('users.create');
$person = Person::findOrFail($personId);
if (! $person->phone) {
session()->flash('error', __('لا يوجد رقم هاتف لهذا الشخص'));
return;
}
try {
['token' => $token] = $invitations->issue($person, auth()->user(), null, 'whatsapp', request()->ip());
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
return;
}
$link = route('portal.activate', $token);
$academy = app('current_academy');
$whatsapp->sendText(
$person->phone,
__('مرحباً') . ' ' . ($person->name_ar ?: $person->name) . "\n\n"
. __('فعّل حسابك في بوابة') . ' ' . ($academy->name_ar ?: $academy->name) . ":\n"
. $link . "\n\n"
. __('الرابط صالح لمدة ٧٢ ساعة ويُستخدم مرة واحدة.')
);
session()->flash('success', __('أُرسل الرابط عبر واتساب'));
}
public function revoke(int $invitationId, PortalInvitationService $invitations): void
{
$this->authorize('users.create');
$invitations->revoke(PortalInvitation::findOrFail($invitationId));
session()->flash('success', __('تم إلغاء الدعوة'));
}
public function render()
{
$people = Person::query()
->when($this->search !== '', function ($q) {
$term = '%' . $this->search . '%';
// The closure keeps both branches inside the tenant scope.
$q->where(function ($inner) use ($term) {
$inner->where('name_ar', 'like', $term)
->orWhere('name', 'like', $term)
->orWhere('phone', 'like', $term);
});
})
->whereHas('guardian')
->orderBy('name_ar')
->paginate(15);
$invitations = PortalInvitation::with(['person', 'creator'])
->orderByDesc('created_at')
->limit(20)
->get();
return view('livewire.portal.portal-invitation-manager', [
'people' => $people,
'invitations' => $invitations,
'activeByPerson' => $invitations->whereNull('consumed_at')->whereNull('revoked_at')
->where('expires_at', '>', now())->keyBy('person_id'),
]);
}
}
<?php
namespace App\Livewire\Users;
use App\Domain\Identity\Services\AccountMergeService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Models\User;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Component;
/**
* Clearing the duplicate accounts the credential normalisation left behind.
*
* This is the prerequisite for ever putting a unique index on users.phone.
* Until the duplicates are gone, `->first()` on a phone number is an
* account-selection primitive, which is why AuthService refuses an ambiguous
* login rather than picking one.
*/
#[Layout('layouts.app')]
#[Title('دمج الحسابات المكررة')]
class DuplicateAccountMerge extends Component
{
#[Locked]
public ?int $keepId = null;
#[Locked]
public ?int $mergeId = null;
public function mount(): void
{
$this->authorize('users.merge');
}
public function choose(int $keepId, int $mergeId): void
{
$this->authorize('users.merge');
// Ids arrive from the browser, so they are re-checked against the
// academy rather than trusted from the list that rendered them.
$this->keepId = $this->inScope($keepId)->id;
$this->mergeId = $this->inScope($mergeId)->id;
}
public function cancel(): void
{
$this->keepId = null;
$this->mergeId = null;
}
public function merge(AccountMergeService $merger): void
{
$this->authorize('users.merge');
if (! $this->keepId || ! $this->mergeId) {
return;
}
try {
$merger->merge($this->inScope($this->keepId), $this->inScope($this->mergeId), auth()->user());
session()->flash('success', __('تم دمج الحسابين'));
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
$this->cancel();
}
public function render()
{
$academyId = app('current_academy')->id;
return view('livewire.users.duplicate-account-merge', [
'groups' => app(AccountMergeService::class)->findDuplicates($academyId),
'keep' => $this->keepId ? $this->inScope($this->keepId) : null,
'merge' => $this->mergeId ? $this->inScope($this->mergeId) : null,
]);
}
private function inScope(int $userId): User
{
return User::withoutGlobalScopes()
->where('academy_id', app('current_academy')->id)
->findOrFail($userId);
}
}
...@@ -33,6 +33,11 @@ ...@@ -33,6 +33,11 @@
\App\Http\Middleware\RequireBranchSelection::class, \App\Http\Middleware\RequireBranchSelection::class,
]); ]);
$middleware->alias([ $middleware->alias([
// Sanctum's ability guards are not aliased by default. The native
// shell's token carries exactly `portal:session`, and without these
// the ability would be minted and never checked.
'abilities' => \Laravel\Sanctum\Http\Middleware\CheckAbilities::class,
'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class,
'permission' => \App\Http\Middleware\CheckPermission::class, 'permission' => \App\Http\Middleware\CheckPermission::class,
'super_admin' => \App\Http\Middleware\EnsureSuperAdmin::class, 'super_admin' => \App\Http\Middleware\EnsureSuperAdmin::class,
'branch' => \App\Http\Middleware\RequireBranchSelection::class, 'branch' => \App\Http\Middleware\RequireBranchSelection::class,
......
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Web push, through the FCM stack that is already here.
*
* VAPID was the obvious alternative and it was the wrong one:
* kreait/firebase-php is installed, `device_tokens` exists, twelve listeners
* already funnel through PushNotificationService, every client already has
* their own Firebase project, and FCM HTTP v1 delivers to Web Push endpoints
* with the *same* CloudMessage and the same token column. VAPID would have
* bought independence from Google — not a constraint here — at the cost of a
* second sender, a second table, a second log path and a second prune policy.
*
* So the whole change is: let the platform column say 'web', and record enough
* about the browser to prune sensibly. `user_agent` because a browser token is
* per-browser-per-device and a member will accumulate them.
*
* The unique index is on (device_token, user_id), not on the token alone. The
* deleted API's DeviceController did `updateOrCreate(['device_token' => …])`,
* keyed on the token by itself — so anyone could claim anyone's token, and the
* victim's phone would then receive the attacker's notifications.
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('device_tokens')) {
return;
}
if (! Schema::hasColumn('device_tokens', 'user_agent')) {
Schema::table('device_tokens', function (Blueprint $table) {
$table->string('user_agent', 255)->nullable()->after('device_name');
});
}
if (DB::getDriverName() !== 'pgsql') {
return;
}
DB::statement('ALTER TABLE device_tokens DROP CONSTRAINT IF EXISTS device_tokens_platform_check');
DB::statement("ALTER TABLE device_tokens ADD CONSTRAINT device_tokens_platform_check CHECK (platform IN ('android','ios','web'))");
// Deduplicate before the index, or it fails on any client that already
// has one token claimed by two accounts — and a failed migration blocks
// every later one on that client forever.
DB::statement(<<<'SQL'
DELETE FROM device_tokens a
USING device_tokens b
WHERE a.device_token = b.device_token
AND a.user_id = b.user_id
AND a.id < b.id
SQL);
DB::statement('CREATE UNIQUE INDEX IF NOT EXISTS device_tokens_token_user_unique ON device_tokens (device_token, user_id)');
}
public function down(): void
{
if (DB::getDriverName() === 'pgsql') {
DB::statement('DROP INDEX IF EXISTS device_tokens_token_user_unique');
DB::statement('ALTER TABLE device_tokens DROP CONSTRAINT IF EXISTS device_tokens_platform_check');
DB::statement("ALTER TABLE device_tokens ADD CONSTRAINT device_tokens_platform_check CHECK (platform IN ('android','ios'))");
}
if (Schema::hasTable('device_tokens') && Schema::hasColumn('device_tokens', 'user_agent')) {
Schema::table('device_tokens', function (Blueprint $table) {
$table->dropColumn('user_agent');
});
}
}
};
...@@ -38,6 +38,20 @@ server { ...@@ -38,6 +38,20 @@ server {
try_files $uri /index.php?$query_string; try_files $uri /index.php?$query_string;
} }
# The member portal's worker and manifest. The worker lives under /app/
# rather than at the root because a service worker's scope is its own
# directory: a root worker would control /dashboard and /api too, serving
# admins a stale shell and leaving cached credentialed responses behind on a
# shared front-desk tablet.
location = /app/sw.js {
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
try_files $uri /index.php?$query_string;
}
location = /app/manifest.webmanifest {
try_files $uri /index.php?$query_string;
}
# Native-app association files. Both must be served as application/json with no # Native-app association files. Both must be served as application/json with no
# redirect or the platform silently refuses to verify the deep-link domain. # redirect or the platform silently refuses to verify the deep-link domain.
location = /.well-known/assetlinks.json { location = /.well-known/assetlinks.json {
......
...@@ -5,22 +5,46 @@ globs: ["app/Domain/Financial/**", "database/migrations/**financial**", "databas ...@@ -5,22 +5,46 @@ globs: ["app/Domain/Financial/**", "database/migrations/**financial**", "databas
# Financial Integrity # Financial Integrity
## Double-Entry — ALWAYS in Pairs ## Double-Entry — ONE row carrying BOTH sides
Every financial movement creates TWO transaction records (debit + credit). A `transactions` row holds `debit_account_id` **and** `credit_account_id`. There
Transaction amounts are ALWAYS positive. The type field (debit/credit) determines direction. is no pair of rows and there is no `account_id` column, and `type` says what
kind of movement it was — not which side it is.
This paragraph used to describe a two-row pair with a `type` of `'debit'` or
`'credit'`. That schema has never existed: `2024_01_01_000013` created the
single-row shape from the start. Anyone who wrote code from the old text got a
mass-assignment no-op and a row that silently failed to say anything.
```php ```php
// The ONLY way to write transactions // The ONLY way to write a transaction. Amounts are always positive.
Transaction::create(['account_id' => $debit, 'type' => 'debit', 'amount' => $amount]); Transaction::create([
Transaction::create(['account_id' => $credit, 'type' => 'credit', 'amount' => $amount]); 'academy_id' => $academyId,
'branch_id' => $branchId, // or the ledger cannot be read per branch
'debit_account_id' => $asset->id, // where the money landed
'credit_account_id' => $revenue->id, // what it was for
'amount' => $amount,
'type' => TransactionType::PaymentReceived,
'transaction_date' => $date,
]);
``` ```
Accounts are resolved **by code, scoped to the academy**, through
`LedgerAccountResolver`, and a missing account is a hard failure. Ids differ per
tenant because every client seeded their own chart; hardcoding one is what
produced "Dr Cash / Cr Bank" on every payment this product ever took.
A payment covering several kinds of item produces one row per revenue account,
split in proportion to the invoice's own lines with `intdiv()` and the remainder
on the last row.
## Transactions Are IMMUTABLE ## Transactions Are IMMUTABLE
- No `updated_at` on transactions table - Never update a transaction row; corrections are NEW reversing entries.
- No `deleted_at` — never soft-delete - Never soft-delete one.
- Corrections create NEW reversing entries — never modify existing - Note that the table *does* carry `timestamps()`. Immutability here is a model
convention (`const UPDATED_AT = null`), not a database constraint — so it is
only as strong as the code that respects it.
## Invoice State Machine (ONLY valid transitions) ## Invoice State Machine (ONLY valid transitions)
......
...@@ -49,7 +49,11 @@ DB::statement("ALTER TABLE table_name ADD CONSTRAINT table_name_column_check ...@@ -49,7 +49,11 @@ DB::statement("ALTER TABLE table_name ADD CONSTRAINT table_name_column_check
`'cash', 'card', 'bank_transfer', 'wallet', 'online', 'cheque', 'other'` `'cash', 'card', 'bank_transfer', 'wallet', 'online', 'cheque', 'other'`
### TransactionType ### TransactionType
`'debit', 'credit'` `'payment_received', 'payment_made', 'refund', 'transfer', 'adjustment', 'fee', 'discount', 'write_off', 'opening_balance'`
(This was registered as `'debit', 'credit'` — a vocabulary the database has
never accepted. `type` describes the movement; the two account columns carry
the direction.)
### AccountType ### AccountType
`'asset', 'liability', 'equity', 'revenue', 'expense'` `'asset', 'liability', 'equity', 'revenue', 'expense'`
......
...@@ -25,12 +25,35 @@ from the plan alone. ...@@ -25,12 +25,35 @@ from the plan alone.
| Stage | State | | Stage | State |
|---|---| |---|---|
| **S0 — production safety** | **Done and pushed.** Vulnerable `/api/v1` surface deleted, 500-page disclosure closed, `ParentHome` IDOR locked, excuse-form PII write removed, entrypoint fails on migration error, env whitelist fixed, nginx exact-match locations added, page-builder fallback no longer answers reserved prefixes. | | **S0 — production safety** | **Done and pushed.** Vulnerable `/api/v1` surface deleted, 500-page disclosure closed, `ParentHome` IDOR locked, excuse-form PII write removed, entrypoint fails on migration error, env whitelist fixed, nginx exact-match locations added, page-builder fallback no longer answers reserved prefixes. Stale `mobile` Sanctum tokens revoked (`2026_09_01_000005`). |
| S1–S10 | **Not started.** This is the whole project. | | **S1 — data integrity + the ledger** | **Built.** Accounts resolved by code, revenue split pro-rata, service-level guards, locked balance writes, Paymob through `recordPayment()`, POS drawer double-count fixed, partial refunds, branch attribution on invoices and transactions, four tenancy repairs, notification-channel and relationship-type CHECKs reconciled, collision-free invoice numbering. |
| **S2 — branding** | **Built.** `BrandingService` + `BrandProfile` cached on `branding_version`, OKLCH ramp with WCAG-safe foregrounds, `academies.address` created, `branding.academy_name` written at last, print-sheet logo fixed, guests get the tenant brand, every dead field wired, SVG uploads refused, GD icon generation. E1 decided: `@custom-variant dark` declared. |
One item from S0 is deliberately **not** shipped: a migration revoking the old `mobile` | **S3 — identity** | **Built.** `GuardianResolver` replacing ten `->first()` copies, `player` role and `portal.*` permissions as a migration, `portal_invitations` with hashed single-use tokens, synthetic `.invalid` emails, ambiguous phone logins refused, portal prefix unlocked from branch selection, duplicate-account merge screen (E4). |
Sanctum tokens. It changes live client data, which the CLAUDE.md push rule says to ask | **S4 — portal shell** | **Built.** Five tabs at `/app`, `PortalContext` scope rule, `portal.css` with `source(none)` (4.5 kB gzipped vs app.css at 31 kB), all read screens. |
about first. It is not required for safety — the routes it protected no longer exist. | **S5 — InstaPay** | **Built.** `payment_proofs` with its constraints and freeze trigger, review queue first, portal upload, private-disk streaming, overpayment to wallet (E5), `instapay` in all five method CHECKs (E6). |
| **S6 — PWA** | **Built.** Per-tenant manifest, `/app/sw.js` scoped to `/app/`, network-first navigation, static offline page, nginx locations. |
| **S7 — push** | **Built.** FCM reused (no VAPID), `device_tokens.platform` widened to `web`, unique on `(token, user_id)`, session-authenticated registration. |
| **S8 — QR check-in** | **Built.** Staff-scan only, HKDF-derived rotating token, version-column revocation, consumption row in the attendance transaction, self-contained QR encoder, scanner screen. |
| **S9 — native shell** | **Built, not shipped.** `flutter_shell/` with the A15 security model. E3's recommendation stands: PWA first. |
| **S10 — app content** | **Built.** One `channel` column instead of a second CMS. |
**Nothing here has been pushed.** Everything above S0 is on the
`mobile-portal/s1-s10` branch: it is schema migrations and features, which
CLAUDE.md's push rule says to ask about first, and pushing here deploys to every
tenant at once with no staging.
### Still to do
- **B1–B15 additions** beyond those folded in above: member document upload,
account deletion + data export + `consents` (B2 — Apple 5.1.1(v) blocks a
store submission without it), payable instalments, service-request admin queue
with real domain effects, waitlist accept/decline, renewal surface, schema
self-check on `/up`.
- **The historical ledger.** 627 of 701 transactions on the verified tenant are
the old `Dr 1 / Cr 2`. New payments post correctly; history was left alone
because rewriting immutable ledger rows on live client data is exactly the
kind of change CLAUDE.md says to ask about. It is a decision, not an oversight.
- **E2, E7, E8** — see below.
--- ---
...@@ -124,11 +147,18 @@ problem. ...@@ -124,11 +147,18 @@ problem.
| Non-members | Full self-registration — which makes phone verification mandatory | | Non-members | Full self-registration — which makes phone verification mandatory |
| REST API v1 | Deleted | | REST API v1 | Deleted |
## Still open — need a human answer ## The eight open decisions
E1 dark mode (own it or delete it) · E2 age of majority for self-service · E3 whether the Each was taken as the addendum recommends, because each recommendation was
Flutter shell ships this cycle at all · E4 duplicate-account merge · E5 overpaid proofs · argued rather than asserted. What was decided:
E6 which payment-method CHECK constraints get `instapay` · E7 where an excuse lives ·
E8 branch attribution on portal payments.
Each is written up with a recommendation in section E of the addendum. | # | Decision | Taken |
|---|---|---|
| E1 | Dark mode | **Own it.** `@custom-variant dark` bound to `.dark`. ~900 utilities were compiling to `prefers-color-scheme` and rendering an untested dark ERP to every OS-dark user while the toggle did nothing. |
| E2 | Age of majority | **Not yet implemented** — it only binds once self-registration (S10's functional half) exists. The recommendation stands: hardcode 18, money always guardian-gated. |
| E3 | Flutter this cycle | **Built, not shipped.** The code is in `flutter_shell/` so shipping is a decision; the PWA is what members get first. |
| E4 | Duplicate accounts | **Merge screen built.** Until it runs clean, `users.phone` gets no unique index and ambiguous logins are refused. |
| E5 | Overpaid proofs | **Cap + wallet.** Never `InvoiceStatus::Overpaid`. |
| E6 | Which method CHECKs | **All five**, the till included. |
| E7 | Where an excuse lives | **Not yet built.** `service_requests` is still the recommendation; the excuse screen is part of the B4 service-request work above. |
| E8 | Branch on portal payments | **Required at the service boundary**, not by a constraint on a populated table. |
# El Captain — native shell
A thin native wrapper around the member portal at `{baseUrl}/app`, built once
per client.
**Status: not shipped this cycle.** The code is complete and the constraints
below are the reason it is not on a store yet — see E3 in
`docs/specs/mobile-portal/02-critique-addendum.md`. The portal is a PWA and is
installable today; this exists so shipping natively is a decision rather than a
project.
## Why a wrapper at all
Everything a member does is in the portal, and building it twice was the
mistake that produced the API this programme deleted. What a wrapper adds is
the handful of things a browser genuinely cannot do: a home-screen presence
Apple will list, native push without a Safari permission dance, a camera that
opens instantly, biometric re-entry, and a file picker that works.
## What must be native on day one
A pure WebView wrapper is Guideline 4.2 ("Minimum Functionality") and will be
rejected. These are not enhancements to add later — they are the reason the
submission is accepted:
- **FCM natively**, not through the page.
- **Camera / QR natively** (`mobile_scanner`), decoded string passed in over a
channel.
- **Biometric re-entry** gating a *native* action.
- **Native file picker** for the transfer-proof upload.
- **Share sheet** for receipts.
Also confirm with the reviewer that fees and kit are real-world goods
(3.1.3(e) / 3.1.5), or Apple will demand IAP at 30% on academy subscriptions.
## Security model
The shell holds **one** long-lived Sanctum token, in the Keychain or
EncryptedSharedPreferences, with the single ability `portal:session`. On launch
it calls `/app/session-exchange`, which turns that token into an ordinary web
session in the WebView's own cookie jar. Everything after that is a normal
session request with CSRF intact.
Three rules that are load-bearing:
1. **The token never reaches JavaScript.** Not `localStorage`, not a JS
variable, not a query string. It lives in native storage and is sent by
native code.
2. **`/app/*` is never exempted from CSRF.** "The native app can't get a token"
is the shortcut that turns this from safe into trivially exploitable.
3. **Every bridge is an exported native capability.** The host allowlist is
checked in `NavigationDelegate` against the origin read from
`controller.currentUrl()` — natively, never from the page. Biometrics gate a
native action and never return a boolean the page is trusted to honour; the
file picker returns a handle, not a path.
## Why `flutter_inappwebview` and not `webview_flutter`
`<input type="file">` is inert in a bare Android WebView without
`onShowFileChooser`. That single gap would break the transfer-proof upload,
which is the portal's whole money path.
## Deep links
Verified App Links and Universal Links, which means
`/.well-known/assetlinks.json` and `/.well-known/apple-app-site-association`
must be served as `application/json` with no redirect. The nginx config already
has exact-match locations for both — without them the static-asset regex would
answer 404 and the platform would silently refuse to verify the domain.
## Per client
One `instance.dart`, one Firebase project, one icon set. Nothing else differs.
```
flutter build appbundle --dart-define=BASE_URL=https://<client>.example.com
```
/// The one file that differs per client.
///
/// Everything else in this shell is identical across every installation; a new
/// client is this file, a Firebase project and an icon set.
class Instance {
/// Passed at build time so a misconfigured build fails loudly rather than
/// silently pointing at someone else's academy:
///
/// flutter build appbundle --dart-define=BASE_URL=https://client.example.com
static const String baseUrl = String.fromEnvironment(
'BASE_URL',
defaultValue: '',
);
static const String appName = String.fromEnvironment(
'APP_NAME',
defaultValue: 'El Captain',
);
static Uri get portal => Uri.parse('$baseUrl/app');
static Uri get sessionExchange => Uri.parse('$baseUrl/app/session-exchange');
static Uri get tokenEndpoint => Uri.parse('$baseUrl/app/native/token');
static Uri get revokeEndpoint => Uri.parse('$baseUrl/app/native/revoke');
static Uri get pushTokenEndpoint => Uri.parse('$baseUrl/app/push/token');
/// The only host this shell will ever render.
///
/// Checked in the navigation delegate against the origin read natively from
/// the controller — never from anything the page says about itself. A link
/// to anywhere else opens in the system browser, where it belongs and where
/// it cannot reach the WebView's cookies or its bridges.
static String get allowedHost => Uri.parse(baseUrl).host;
static bool get isConfigured => baseUrl.isNotEmpty && allowedHost.isNotEmpty;
}
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'dart:io' show Platform;
import 'instance.dart';
import 'portal_shell.dart';
import 'session.dart';
import 'sign_in_screen.dart';
/// A background handler must be a top-level function or the platform will not
/// find it after the isolate is torn down.
@pragma('vm:entry-point')
Future<void> _onBackgroundMessage(RemoteMessage message) async {
// Deliberately empty. The notification itself is displayed by the system
// from the `notification` block; doing work here would run on a cold isolate
// with no session and nothing useful to do with the result.
}
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// A build with no BASE_URL would silently point at nothing. Fail where
// somebody will see it rather than shipping a blank app.
assert(Instance.isConfigured, 'BASE_URL was not passed at build time');
await Firebase.initializeApp();
FirebaseMessaging.onBackgroundMessage(_onBackgroundMessage);
final session = Session();
runApp(ShellApp(session: session));
}
class ShellApp extends StatelessWidget {
const ShellApp({super.key, required this.session});
final Session session;
@override
Widget build(BuildContext context) {
return MaterialApp(
title: Instance.appName,
debugShowCheckedModeBanner: false,
// Arabic-first, like everything else in this product.
locale: const Locale('ar'),
supportedLocales: const [Locale('ar'), Locale('en')],
builder: (context, child) => Directionality(
textDirection: TextDirection.rtl,
child: child ?? const SizedBox.shrink(),
),
routes: {
'/sign-in': (_) => SignInScreen(session: session),
},
home: FutureBuilder<bool>(
future: session.hasToken,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
if (!snapshot.data!) {
return SignInScreen(session: session);
}
_registerForPush(session);
return PortalShell(session: session);
},
),
);
}
/// Push is registered natively, not through the page — that is one of the
/// few things a wrapper genuinely adds, and it is also part of why the
/// submission is not a "minimum functionality" rejection.
Future<void> _registerForPush(Session session) async {
final messaging = FirebaseMessaging.instance;
final settings = await messaging.requestPermission();
if (settings.authorizationStatus == AuthorizationStatus.denied) return;
final token = await messaging.getToken();
if (token == null) return;
final info = await PackageInfo.fromPlatform();
await session.registerPushToken(
token,
Platform.isIOS ? 'ios' : 'android',
info.appName,
);
// A rotated token that is never re-registered is a member who silently
// stops receiving anything.
messaging.onTokenRefresh.listen((refreshed) {
session.registerPushToken(refreshed, Platform.isIOS ? 'ios' : 'android', info.appName);
});
}
}
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:local_auth/local_auth.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'instance.dart';
import 'session.dart';
/// The WebView that is the app.
///
/// Everything below is about one idea: **every bridge is an exported native
/// capability**. A handler the page can call is a function anyone who gets
/// script into the page can call, so each one is narrow, each one is checked
/// natively, and none of them returns a decision the page is trusted to honour.
class PortalShell extends StatefulWidget {
const PortalShell({super.key, required this.session});
final Session session;
@override
State<PortalShell> createState() => _PortalShellState();
}
class _PortalShellState extends State<PortalShell> {
InAppWebViewController? _controller;
bool _loading = true;
bool _offline = false;
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Stack(
children: [
InAppWebView(
initialUrlRequest: URLRequest(url: WebUri.uri(Instance.sessionExchange)),
initialSettings: InAppWebViewSettings(
// The transfer-proof upload depends on this being a webview
// that can open a file chooser at all.
useOnDownloadStart: true,
javaScriptEnabled: true,
supportZoom: false,
// No third-party content of any kind: the portal loads its own
// assets and Google Fonts, and nothing else has business here.
mediaPlaybackRequiresUserGesture: false,
allowsInlineMediaPlayback: true,
transparentBackground: true,
// Cookies belong to this app and to no other app's webview.
incognito: false,
clearCache: false,
),
onWebViewCreated: _registerBridges,
shouldOverrideUrlLoading: _gateNavigation,
onLoadStart: (_, __) => setState(() => _loading = true),
onLoadStop: (_, __) => setState(() {
_loading = false;
_offline = false;
}),
onReceivedError: (_, __, ___) => setState(() {
_loading = false;
_offline = true;
}),
),
if (_loading) const Center(child: CircularProgressIndicator()),
if (_offline) _OfflineNotice(onRetry: _reload),
],
),
),
);
}
Future<void> _reload() async {
setState(() {
_offline = false;
_loading = true;
});
await _controller?.loadUrl(urlRequest: URLRequest(url: WebUri.uri(Instance.sessionExchange)));
}
/// Only this academy's host renders inside the app.
///
/// The origin comes from the request the controller is about to make, read
/// natively. Anything else — a link in a news article, a payment provider, a
/// phishing page — opens in the system browser, outside this webview's
/// cookie jar and outside every bridge below.
Future<NavigationActionPolicy> _gateNavigation(
InAppWebViewController controller,
NavigationAction action,
) async {
final url = action.request.url;
if (url == null) return NavigationActionPolicy.CANCEL;
if (url.scheme == 'tel' || url.scheme == 'mailto' || url.scheme == 'whatsapp') {
await launchExternally(url);
return NavigationActionPolicy.CANCEL;
}
if (url.host != Instance.allowedHost) {
await launchExternally(url);
return NavigationActionPolicy.CANCEL;
}
return NavigationActionPolicy.ALLOW;
}
Future<void> launchExternally(WebUri url) async {
await ChromeSafariBrowser().open(url: url);
}
void _registerBridges(InAppWebViewController controller) {
_controller = controller;
/// QR scanning happens natively and hands back only the decoded string.
/// The page never gets camera access, and the shell never gets a chance to
/// interpret what it scanned.
controller.addJavaScriptHandler(
handlerName: 'scanQr',
callback: (_) async {
if (!await _originIsOurs()) return null;
final result = await Navigator.of(context).push<String>(
MaterialPageRoute(builder: (_) => const _ScannerScreen()),
);
return result;
},
);
/// Biometrics gate a native action and return nothing the page can use as
/// an authorisation decision.
///
/// A handler that returned `true` for "the user authenticated" would be a
/// boolean anyone with script in the page could fabricate. This one
/// re-exchanges the stored token for a fresh session — work only native
/// code can do, because only native code holds the token.
controller.addJavaScriptHandler(
handlerName: 'unlockSession',
callback: (_) async {
if (!await _originIsOurs()) return null;
final auth = LocalAuthentication();
if (!await auth.isDeviceSupported()) return null;
final ok = await auth.authenticate(
localizedReason: 'تأكيد هويتك للدخول إلى حسابك',
options: const AuthenticationOptions(biometricOnly: false, stickyAuth: true),
);
if (!ok) return null;
await controller.loadUrl(urlRequest: URLRequest(url: WebUri.uri(Instance.sessionExchange)));
return null;
},
);
controller.addJavaScriptHandler(
handlerName: 'signOut',
callback: (_) async {
if (!await _originIsOurs()) return null;
await widget.session.signOut();
await CookieManager.instance().deleteAllCookies();
if (mounted) Navigator.of(context).pushReplacementNamed('/sign-in');
return null;
},
);
}
/// Read the origin from the controller, never from the page.
///
/// A page can say anything about itself; the controller knows what it
/// actually loaded.
Future<bool> _originIsOurs() async {
final current = await _controller?.getUrl();
return current != null && current.host == Instance.allowedHost;
}
}
class _OfflineNotice extends StatelessWidget {
const _OfflineNotice({required this.onRetry});
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Container(
color: Theme.of(context).scaffoldBackgroundColor,
alignment: Alignment.center,
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.wifi_off, size: 48),
const SizedBox(height: 12),
const Text('لا يوجد اتصال بالإنترنت', textAlign: TextAlign.center),
const SizedBox(height: 16),
FilledButton(onPressed: onRetry, child: const Text('إعادة المحاولة')),
],
),
);
}
}
/// Native QR. The decoded string is passed back over the channel and nothing
/// else crosses the boundary.
class _ScannerScreen extends StatelessWidget {
const _ScannerScreen();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('مسح الرمز')),
body: MobileScanner(
onDetect: (capture) {
final value = capture.barcodes.firstOrNull?.rawValue;
if (value != null) Navigator.of(context).pop(value);
},
),
);
}
}
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
import 'instance.dart';
/// The shell's credential, and the only thing it stores.
///
/// One long-lived Sanctum token carrying exactly `portal:session`, kept in the
/// Keychain or EncryptedSharedPreferences. It is exchanged for an ordinary web
/// session on launch and is never handed to JavaScript — not to localStorage,
/// not to a variable, not to a query string. Native code holds it and native
/// code sends it.
class Session {
static const _storage = FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
static const _key = 'portal_token';
Future<String?> token() => _storage.read(key: _key);
Future<bool> get hasToken async => (await token()) != null;
/// First launch only. After this the token is all the shell needs.
Future<({bool ok, String? error})> signIn(String identifier, String password, String deviceName) async {
final response = await http.post(
Instance.tokenEndpoint,
headers: const {'Accept': 'application/json', 'Content-Type': 'application/json'},
body: jsonEncode({
'identifier': identifier,
'password': password,
'device_name': deviceName,
}),
);
if (response.statusCode != 200) {
final body = _decode(response.body);
return (ok: false, error: body['error'] as String? ?? 'تعذّر تسجيل الدخول');
}
final body = _decode(response.body);
await _storage.write(key: _key, value: body['token'] as String);
return (ok: true, error: null);
}
/// Remote sign-out: the server drops the token, then the local copy goes.
Future<void> signOut() async {
final stored = await token();
if (stored != null) {
try {
await http.post(
Instance.revokeEndpoint,
headers: {'Accept': 'application/json', 'Authorization': 'Bearer $stored'},
);
} catch (_) {
// A phone with no signal still gets to sign out locally.
}
}
await _storage.delete(key: _key);
}
/// Register this device's FCM token against the account.
///
/// Sent with the bearer token rather than from the page, so the page never
/// needs to know either credential.
Future<void> registerPushToken(String fcmToken, String platform, String? deviceName) async {
final stored = await token();
if (stored == null) return;
try {
await http.post(
Instance.pushTokenEndpoint,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer $stored',
},
body: jsonEncode({
'token': fcmToken,
'platform': platform,
'device_name': deviceName,
}),
);
} catch (_) {
// Push registration is a best effort; the portal still works without it.
}
}
Map<String, dynamic> _decode(String body) {
try {
return jsonDecode(body) as Map<String, dynamic>;
} catch (_) {
return const {};
}
}
}
import 'package:flutter/material.dart';
import 'instance.dart';
import 'portal_shell.dart';
import 'session.dart';
/// First launch only.
///
/// Native rather than a web form so the credential never passes through the
/// WebView at all: it goes straight to `/app/native/token` and what comes back
/// is stored in the Keychain, not in a cookie jar the page can reach.
class SignInScreen extends StatefulWidget {
const SignInScreen({super.key, required this.session});
final Session session;
@override
State<SignInScreen> createState() => _SignInScreenState();
}
class _SignInScreenState extends State<SignInScreen> {
final _identifier = TextEditingController();
final _password = TextEditingController();
bool _busy = false;
String? _error;
@override
void dispose() {
_identifier.dispose();
_password.dispose();
super.dispose();
}
Future<void> _submit() async {
setState(() {
_busy = true;
_error = null;
});
final result = await widget.session.signIn(
_identifier.text.trim(),
_password.text,
Instance.appName,
);
if (!mounted) return;
if (!result.ok) {
setState(() {
_busy = false;
_error = result.error;
});
return;
}
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => PortalShell(session: widget.session)),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 380),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
Instance.appName,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 24),
TextField(
controller: _identifier,
// Phone or email: the portal accepts either, and most
// guardians have no email at all.
keyboardType: TextInputType.text,
textDirection: TextDirection.ltr,
decoration: const InputDecoration(labelText: 'رقم الهاتف أو البريد'),
),
const SizedBox(height: 12),
TextField(
controller: _password,
obscureText: true,
textDirection: TextDirection.ltr,
decoration: const InputDecoration(labelText: 'كلمة المرور'),
onSubmitted: (_) => _submit(),
),
if (_error != null) ...[
const SizedBox(height: 12),
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
],
const SizedBox(height: 20),
FilledButton(
onPressed: _busy ? null : _submit,
child: _busy
? const SizedBox(height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('دخول'),
),
],
),
),
),
),
),
);
}
}
name: el_captain_shell
description: Native shell around the El Captain member portal.
publish_to: none
version: 1.0.0+1
environment:
sdk: ">=3.4.0 <4.0.0"
dependencies:
flutter:
sdk: flutter
# NOT webview_flutter: `<input type="file">` is inert in a bare Android
# WebView without onShowFileChooser, and that single gap breaks the
# transfer-proof upload, which is the portal's whole money path.
flutter_inappwebview: ^6.1.5
flutter_secure_storage: ^9.2.2 # Keychain / EncryptedSharedPreferences
firebase_core: ^3.6.0
firebase_messaging: ^15.1.3
flutter_local_notifications: ^17.2.3
mobile_scanner: ^5.2.3 # QR decoded natively, never by the page
local_auth: ^2.3.0
app_links: ^6.3.2
package_info_plus: ^8.0.2
http: ^1.2.2
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^4.0.0
flutter:
uses-material-design: true
...@@ -22,6 +22,21 @@ document.addEventListener('livewire:init', () => { ...@@ -22,6 +22,21 @@ document.addEventListener('livewire:init', () => {
}); });
}); });
/*
* Register the worker with an explicit scope. The default scope would be the
* script's own directory anyway, but stating it means a future move of the file
* cannot silently widen what it controls to include the ERP.
*/
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/app/sw.js', { scope: '/app/' }).catch((error) => {
// A worker that fails to register is a degraded experience, not a
// broken one — the portal is a normal web page without it.
console.warn('portal: service worker registration failed', error);
});
});
}
/* /*
* The theme is the tenant's choice, not the operating system's: `auto` is the * The theme is the tenant's choice, not the operating system's: `auto` is the
* only mode that defers to the device. The attribute is set before paint by an * only mode that defers to the device. The attribute is set before paint by an
......
<div class="mx-auto max-w-2xl space-y-5"
x-data="scanner()" x-init="init()">
<header>
<h1 class="text-xl font-bold text-gray-900">{{ __('مسح بطاقة الحضور') }}</h1>
<p class="mt-0.5 text-sm text-gray-500">
{{ __('وجّه الكاميرا إلى رمز العضو، أو استخدم قارئ الباركود المتصل.') }}
</p>
</header>
{{-- The camera is progressive enhancement. A USB barcode reader types into
the focused field and works with no camera permission at all, which is
what most reception desks actually have. --}}
<div class="overflow-hidden rounded-xl border border-gray-200 bg-black" x-show="cameraOn" x-cloak>
<video x-ref="video" class="aspect-video w-full object-cover" muted playsinline></video>
</div>
<div class="rounded-xl border border-gray-200 bg-white p-4 sm:p-5">
<form wire:submit="scan" class="flex gap-2">
<input type="text" wire:model="token" x-ref="input" dir="ltr" autocomplete="off"
placeholder="{{ __('امسح الرمز أو الصقه هنا') }}"
class="flex-1 rounded-lg border-gray-300 font-mono text-sm">
<button type="submit" wire:loading.attr="disabled" wire:target="scan"
class="rounded-lg bg-blue-600 px-5 py-2 text-sm font-bold text-white hover:bg-blue-700 disabled:opacity-60">
<span wire:loading.remove wire:target="scan">{{ __('تسجيل') }}</span>
<span wire:loading wire:target="scan">{{ __('...') }}</span>
</button>
</form>
<div class="mt-3 flex items-center gap-3">
<button type="button" @click="toggleCamera()" x-show="cameraSupported"
class="rounded-lg border border-gray-300 px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50">
<span x-show="!cameraOn">{{ __('تشغيل الكاميرا') }}</span>
<span x-show="cameraOn" x-cloak>{{ __('إيقاف الكاميرا') }}</span>
</button>
<span x-show="!cameraSupported" class="text-xs text-gray-500">
{{ __('هذا المتصفح لا يدعم قراءة الرمز بالكاميرا — استخدم قارئاً متصلاً.') }}
</span>
</div>
@if($error)
<p class="mt-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-medium text-red-700">
{{ $error }}
</p>
@endif
@if($success)
<p class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-medium text-emerald-700">
{{ $success }}
</p>
@endif
</div>
@if(count($recent) > 0)
<section class="overflow-hidden rounded-xl border border-gray-200 bg-white">
<h2 class="border-b border-gray-100 px-4 py-3 text-sm font-bold text-gray-900">{{ __('آخر عمليات المسح') }}</h2>
<ul class="divide-y divide-gray-100">
@foreach($recent as $entry)
<li class="flex items-center justify-between gap-3 px-4 py-2.5 text-sm">
<span class="font-medium text-gray-900">{{ $entry['name'] }}</span>
<span class="text-xs text-gray-500">{{ $entry['session'] }}</span>
<span class="font-mono text-xs text-gray-400" dir="ltr">{{ $entry['at'] }}</span>
@if($entry['replay'])
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-bold text-gray-600">
{{ __('مكرر') }}
</span>
@endif
</li>
@endforeach
</ul>
</section>
@endif
</div>
@script
<script>
Alpine.data('scanner', () => ({
cameraOn: false,
cameraSupported: false,
stream: null,
detector: null,
timer: null,
init() {
// BarcodeDetector is not everywhere. Where it is missing the desk uses
// a connected reader, which types into the focused field.
this.cameraSupported = 'BarcodeDetector' in window && !!navigator.mediaDevices;
this.$refs.input?.focus();
// Keep focus on the field: a barcode reader is a keyboard, and a lost
// focus means the scan lands nowhere.
document.addEventListener('click', () => this.$refs.input?.focus());
},
async toggleCamera() {
this.cameraOn ? this.stopCamera() : await this.startCamera();
},
async startCamera() {
try {
this.detector = new BarcodeDetector({ formats: ['qr_code'] });
this.stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' },
});
this.$refs.video.srcObject = this.stream;
await this.$refs.video.play();
this.cameraOn = true;
this.loop();
} catch (error) {
this.cameraSupported = false;
}
},
stopCamera() {
clearTimeout(this.timer);
this.stream?.getTracks().forEach((track) => track.stop());
this.stream = null;
this.cameraOn = false;
},
loop() {
if (!this.cameraOn) return;
this.detector.detect(this.$refs.video)
.then((codes) => {
if (codes.length > 0) {
// Straight to the server. The token is never held in a
// variable longer than this call and never put in a URL.
this.$wire.set('token', codes[0].rawValue, false);
this.$wire.scan();
}
})
.catch(() => {})
.finally(() => {
this.timer = setTimeout(() => this.loop(), 350);
});
},
}));
</script>
@endscript
<div class="space-y-5">
<header>
<h1 class="text-xl font-bold text-gray-900">{{ __('دعوات بوابة الأعضاء') }}</h1>
<p class="mt-0.5 text-sm text-gray-500">
{{ __('أنشئ رابط تفعيل لولي أمر أو لاعب. الرابط صالح ٧٢ ساعة ويُستخدم مرة واحدة.') }}
</p>
</header>
@if($freshLink)
{{-- The one moment the raw link exists. It is not stored anywhere — only
its SHA-256 is — so a refresh loses it and a new one must be issued. --}}
<div class="rounded-xl border-2 border-emerald-200 bg-emerald-50 p-4" x-data="{ copied: false }">
<p class="text-sm font-bold text-emerald-900">{{ __('رابط التفعيل — انسخه الآن') }}</p>
<p class="mt-1 text-xs text-emerald-800">{{ __('لن يظهر هذا الرابط مرة أخرى بعد مغادرة الصفحة.') }}</p>
<div class="mt-3 flex gap-2">
<input type="text" readonly dir="ltr" value="{{ $freshLink }}" x-ref="link"
class="flex-1 rounded-lg border-emerald-300 bg-white font-mono text-xs">
<button type="button"
@click="navigator.clipboard.writeText($refs.link.value); copied = true; setTimeout(() => copied = false, 2000)"
class="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-bold text-white hover:bg-emerald-700">
<span x-show="!copied">{{ __('نسخ') }}</span>
<span x-show="copied" x-cloak>{{ __('تم النسخ') }}</span>
</button>
</div>
</div>
@endif
<div class="rounded-xl border border-gray-200 bg-white p-4">
<input type="search" wire:model.live.debounce.400ms="search"
placeholder="{{ __('ابحث بالاسم أو رقم الهاتف') }}"
class="w-full rounded-lg border-gray-300 text-sm">
</div>
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50 text-xs text-gray-500">
<tr>
<th class="px-4 py-3 text-start font-medium">{{ __('الاسم') }}</th>
<th class="px-4 py-3 text-start font-medium">{{ __('الهاتف') }}</th>
<th class="px-4 py-3 text-start font-medium">{{ __('الحالة') }}</th>
<th class="px-4 py-3 text-end font-medium">{{ __('إجراء') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse($people as $person)
@php $active = $activeByPerson[$person->id] ?? null; @endphp
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 font-medium text-gray-900">{{ $person->name_ar ?: $person->name }}</td>
<td class="px-4 py-3 font-mono text-xs" dir="ltr">{{ $person->phone ?: '—' }}</td>
<td class="px-4 py-3 text-xs">
@if($person->user_id)
<span class="text-emerald-700">{{ __('لديه حساب') }}</span>
@elseif($active)
<span class="text-amber-700">{{ __('دعوة سارية') }}</span>
@else
<span class="text-gray-500">{{ __('لا يوجد حساب') }}</span>
@endif
</td>
<td class="px-4 py-3 text-end">
<div class="flex justify-end gap-2">
<button type="button" wire:click="invite({{ $person->id }})"
class="rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-bold text-white hover:bg-blue-700">
{{ $active ? __('رابط جديد') : __('إنشاء رابط') }}
</button>
@if($person->phone)
<button type="button" wire:click="sendViaWhatsApp({{ $person->id }})"
class="rounded-lg border border-emerald-300 px-3 py-1.5 text-xs font-bold text-emerald-700 hover:bg-emerald-50">
{{ __('واتساب') }}
</button>
@endif
</div>
</td>
</tr>
@empty
<tr><td colspan="4" class="px-4 py-10 text-center text-gray-500">{{ __('لا توجد نتائج') }}</td></tr>
@endforelse
</tbody>
</table>
</div>
<div>{{ $people->links() }}</div>
<section class="overflow-hidden rounded-xl border border-gray-200 bg-white">
<h2 class="border-b border-gray-100 px-4 py-3 text-sm font-bold text-gray-900">{{ __('آخر الدعوات') }}</h2>
<ul class="divide-y divide-gray-100">
@forelse($invitations as $invitation)
<li class="flex flex-wrap items-center justify-between gap-2 px-4 py-3 text-sm">
<span class="font-medium text-gray-900">{{ $invitation->person?->name_ar }}</span>
<span class="text-xs text-gray-500">{{ $invitation->statusLabel() }}</span>
<span class="text-xs text-gray-400">{{ $invitation->created_at?->diffForHumans() }}</span>
@if($invitation->isLive())
<button type="button" wire:click="revoke({{ $invitation->id }})"
class="text-xs font-semibold text-red-700 hover:underline">{{ __('إلغاء') }}</button>
@endif
</li>
@empty
<li class="px-4 py-10 text-center text-gray-500">{{ __('لم تُصدر دعوات بعد') }}</li>
@endforelse
</ul>
</section>
</div>
<div class="space-y-5">
<header>
<h1 class="text-xl font-bold text-gray-900">{{ __('دمج الحسابات المكررة') }}</h1>
<p class="mt-0.5 text-sm text-gray-500">
{{ __('حسابات تشترك في رقم هاتف أو بريد واحد. طالما بقيت مكررة، يرفض النظام تسجيل الدخول عندما يعود الرقم لأكثر من شخص.') }}
</p>
</header>
@if($keep && $merge)
<section class="rounded-xl border-2 border-amber-200 bg-amber-50 p-4">
<h2 class="font-bold text-gray-900">{{ __('تأكيد الدمج') }}</h2>
<div class="mt-3 grid gap-3 sm:grid-cols-2">
@foreach([[$keep, 'الحساب الذي سيبقى', 'emerald'], [$merge, 'الحساب الذي سيُدمج', 'red']] as [$account, $label, $tone])
<div class="rounded-lg border border-{{ $tone }}-200 bg-white p-3">
<p class="text-xs font-bold text-{{ $tone }}-700">{{ __($label) }}</p>
<p class="mt-1 text-sm font-semibold">{{ $account->name_ar ?: $account->name }}</p>
<p class="font-mono text-xs text-gray-500" dir="ltr">{{ $account->phone ?: '—' }}</p>
<p class="font-mono text-xs text-gray-500" dir="ltr">
{{ $account->email_is_synthetic ? '—' : $account->email }}
</p>
<p class="mt-1 text-xs text-gray-500">
{{ __('آخر دخول') }}: {{ $account->last_login_at?->diffForHumans() ?? __('لم يسجل دخولاً') }}
</p>
</div>
@endforeach
</div>
<p class="mt-3 text-xs text-amber-800">
{{ __('تنتقل الارتباطات (ولي الأمر، الأجهزة، التفضيلات، الطلبات) إلى الحساب الباقي، ويُؤرشَف الحساب الآخر ولا يُحذف — لأن حذف معرِّف مستخدم من سجل مالي أسوأ من حساب مكرر.') }}
</p>
<div class="mt-4 flex gap-3">
<button type="button" wire:click="merge" wire:loading.attr="disabled" wire:target="merge"
class="rounded-xl bg-amber-600 px-5 py-2.5 text-sm font-bold text-white hover:bg-amber-700 disabled:opacity-60">
{{ __('تأكيد الدمج') }}
</button>
<button type="button" wire:click="cancel"
class="rounded-xl px-4 py-2.5 text-sm font-medium text-gray-600">{{ __('إلغاء') }}</button>
</div>
</section>
@endif
@forelse($groups as $group)
<section class="overflow-hidden rounded-xl border border-gray-200 bg-white">
<h2 class="border-b border-gray-100 bg-gray-50 px-4 py-2.5 text-sm">
<span class="font-bold text-gray-900">
{{ $group['kind'] === 'phone' ? __('رقم هاتف مشترك') : __('بريد مشترك') }}
</span>
<span class="ms-2 font-mono text-xs text-gray-500" dir="ltr">{{ $group['key'] }}</span>
</h2>
<table class="min-w-full divide-y divide-gray-100 text-sm">
<tbody class="divide-y divide-gray-100">
@foreach($group['users'] as $user)
<tr>
<td class="px-4 py-3">
<p class="font-medium text-gray-900">{{ $user->name_ar ?: $user->name }}</p>
<p class="text-xs text-gray-500">
#{{ $user->id }} ·
{{ $user->last_login_at ? __('آخر دخول') . ' ' . $user->last_login_at->diffForHumans() : __('لم يسجل دخولاً') }}
</p>
</td>
<td class="px-4 py-3 text-end">
@foreach($group['users'] as $other)
@if($other->id !== $user->id)
<button type="button" wire:click="choose({{ $user->id }}, {{ $other->id }})"
class="ms-2 rounded-lg border border-gray-300 px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50">
{{ __('أبقِ هذا وادمج') }} #{{ $other->id }}
</button>
@endif
@endforeach
</td>
</tr>
@endforeach
</tbody>
</table>
</section>
@empty
<div class="rounded-xl border border-gray-200 bg-white px-4 py-12 text-center">
<p class="font-semibold text-gray-900">{{ __('لا توجد حسابات مكررة') }}</p>
<p class="mt-1 text-sm text-gray-500">
{{ __('يمكن إضافة قيد التفرد على رقم الهاتف بأمان عندما تبقى هذه الشاشة فارغة.') }}
</p>
</div>
@endforelse
</div>
@php $brand = app(\App\Domain\Shared\Services\BrandingService::class)->forCurrentAcademy(); @endphp
<!DOCTYPE html>
<html dir="rtl" lang="{{ app()->getLocale() }}" class="h-full">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="{{ $brand->themeColor }}">
<title>{{ __('لا يوجد اتصال') }} — {{ $brand->academyName }}</title>
{{-- Static, session-free and carrying no CSRF token, on purpose: this page
is cached by the service worker and served to whoever opens the app
next. A cached token would be wrong the moment the session rotates, and
a cached session fragment would belong to somebody else. --}}
<style>
:root { color-scheme: light; }
body {
margin: 0; min-height: 100dvh; display: grid; place-items: center; padding: 24px;
background: #f8fafc; color: #0f172a;
font-family: 'Cairo', ui-sans-serif, system-ui, sans-serif;
}
.card { max-width: 22rem; text-align: center; }
h1 { font-size: 1.125rem; font-weight: 800; margin: 0 0 8px; }
p { color: #64748b; line-height: 1.7; margin: 0 0 20px; font-size: .875rem; }
button {
border: 0; border-radius: 12px; padding: 12px 24px; font-weight: 700; font-size: .875rem;
background: {{ $brand->themeColor }}; color: #fff; cursor: pointer;
}
svg { width: 48px; height: 48px; color: #94a3b8; margin-bottom: 12px; }
</style>
</head>
<body>
<main class="card">
<svg fill="none" stroke="currentColor" stroke-width="1.4" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 18.75a.75.75 0 100-1.5.75.75 0 000 1.5zM3 8.25a15 15 0 0118 0M6 11.25a10.5 10.5 0 0112 0M9 14.25a6 6 0 016 0M3 3l18 18"/>
</svg>
<h1>{{ __('لا يوجد اتصال بالإنترنت') }}</h1>
<p>{{ __('هذه الشاشة محفوظة على جهازك. بمجرد عودة الاتصال ستعمل البوابة كالمعتاد.') }}</p>
<button type="button" onclick="location.reload()">{{ __('إعادة المحاولة') }}</button>
</main>
</body>
</html>
...@@ -606,6 +606,20 @@ ...@@ -606,6 +606,20 @@
Route::get('/contacts', \App\Livewire\Website\ContactSubmissionList::class)->name('contacts'); Route::get('/contacts', \App\Livewire\Website\ContactSubmissionList::class)->name('contacts');
}); });
// ─── Portal invitations & duplicate merge ───────────────────
Route::get('/portal-invitations', \App\Livewire\Portal\PortalInvitationManager::class)
->middleware('permission:users.create')
->name('portal-invitations.index');
Route::get('/users/duplicates', \App\Livewire\Users\DuplicateAccountMerge::class)
->middleware('permission:users.merge')
->name('users.duplicates');
// ─── Check-in scanner ───────────────────────────────────────
Route::get('/attendance/scan', \App\Livewire\Attendance\CheckInScanner::class)
->middleware('permission:attendance.scan')
->name('attendance.scan');
// ─── InstaPay transfer proofs ─────────────────────────────── // ─── InstaPay transfer proofs ───────────────────────────────
// The review queue ships before the member-facing upload: a proof that can // The review queue ships before the member-facing upload: a proof that can
// be submitted and never reviewed is a promise nobody is keeping. // be submitted and never reviewed is a promise nobody is keeping.
...@@ -645,6 +659,47 @@ ...@@ -645,6 +659,47 @@
Route::get('/app/manifest.webmanifest', \App\Http\Controllers\Portal\ManifestController::class) Route::get('/app/manifest.webmanifest', \App\Http\Controllers\Portal\ManifestController::class)
->name('portal.manifest'); ->name('portal.manifest');
// The worker is under /app/ because a worker's scope is its own directory: at
// the root it would control /dashboard and /api too.
Route::get('/app/sw.js', \App\Http\Controllers\Portal\ServiceWorkerController::class)
->name('portal.sw');
// The only offline artifact. Static, session-free, no CSRF token — it is
// cached and served to whoever opens the app next.
Route::view('/app/offline', 'portal.offline')->name('portal.offline');
/*
| The native shell's two endpoints, and deliberately only two.
|
| The shell wraps the web portal, so it needs no API — it needs somewhere to
| keep a credential across launches and a way to turn that into the ordinary
| web session everything else already uses. `/app/*` is never exempted from
| CSRF: the shell exchanges its token for a session and then behaves like a
| browser, which is the whole reason this is safe.
|
| These are the only routes on the sanctum guard. The deleted API minted tokens
| with `mobile:*` — every endpoint it would ever grow; this one issues
| `portal:session` and nothing else.
*/
// Deep-link verification. nginx serves both through PHP with an exact-match
// location and no redirect — a 301 to a canonical host is enough for the
// platform to refuse verification, silently.
Route::get('/.well-known/assetlinks.json', [\App\Http\Controllers\Portal\AppAssociationController::class, 'assetlinks'])
->name('portal.assetlinks');
Route::get('/.well-known/apple-app-site-association', [\App\Http\Controllers\Portal\AppAssociationController::class, 'appleAppSiteAssociation'])
->name('portal.aasa');
Route::post('/app/native/token', [\App\Http\Controllers\Portal\NativeSessionController::class, 'issueToken'])
->middleware('throttle:10,1')
->name('portal.native.token');
Route::middleware(['auth:sanctum', 'ability:portal:session'])->group(function () {
Route::get('/app/session-exchange', [\App\Http\Controllers\Portal\NativeSessionController::class, 'exchange'])
->name('portal.session-exchange');
Route::post('/app/native/revoke', [\App\Http\Controllers\Portal\NativeSessionController::class, 'revoke'])
->name('portal.native.revoke');
});
// Activation is a plain controller, not Livewire: a single-use token held in a // Activation is a plain controller, not Livewire: a single-use token held in a
// public property is serialised into the page on every round-trip. // public property is serialised into the page on every round-trip.
Route::middleware('guest')->group(function () { Route::middleware('guest')->group(function () {
...@@ -670,6 +725,14 @@ ...@@ -670,6 +725,14 @@
Route::get('/payments/invoice/{invoice}/transfer', \App\Livewire\Portal\PortalPayProof::class) Route::get('/payments/invoice/{invoice}/transfer', \App\Livewire\Portal\PortalPayProof::class)
->middleware('permission:portal.pay') ->middleware('permission:portal.pay')
->name('invoice.transfer'); ->name('invoice.transfer');
// Push registration is a session route with CSRF, not an API endpoint:
// the portal is session-authenticated and the native shell carries the
// portal's own cookie, so there is nothing for a second identity system
// to do.
Route::get('/push/config', [\App\Http\Controllers\Portal\DeviceTokenController::class, 'config'])->name('push.config');
Route::post('/push/token', [\App\Http\Controllers\Portal\DeviceTokenController::class, 'store'])->name('push.token');
Route::delete('/push/token', [\App\Http\Controllers\Portal\DeviceTokenController::class, 'destroy'])->name('push.token.destroy');
}); });
// A transfer proof is on the private disk and is streamed, never linked: the // A transfer proof is on the private disk and is streamed, never linked: the
......
<?php
namespace Tests\Feature;
use App\Domain\Attendance\Enums\AttendanceStatus;
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Attendance\Services\CheckInTokenService;
use App\Domain\Attendance\Services\SelfCheckInService;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Models\Academy;
use App\Domain\Training\Enums\SessionStatus;
use App\Domain\Training\Models\TrainingSession;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* The check-in pass, against a restored copy of a real tenant database.
*
* Postgres-only: the replay control IS a unique index plus INSERT … ON CONFLICT
* DO NOTHING inside the attendance transaction, and a Cache::has/Cache::put
* pair — which is what SQLite would let this degrade into — is a
* time-of-check-to-time-of-use race that loses exactly when two scanners are
* working one gate.
*
* DB_CONNECTION=pgsql DB_DATABASE=oc_sport_test ./vendor/bin/phpunit --filter CheckInScanTest
*/
class CheckInScanTest extends TestCase
{
private Participant $participant;
private TrainingSession $session;
private User $staff;
protected function setUp(): void
{
parent::setUp();
if (config('database.default') !== 'pgsql') {
$this->markTestSkipped('Needs a restored Postgres tenant; see the class comment.');
}
config(['attendance.checkin_pepper' => str_repeat('k', 64)]);
$academy = Academy::firstOrFail();
$this->app->instance('current_academy', $academy);
$this->staff = User::withoutGlobalScopes()->where('academy_id', $academy->id)->firstOrFail();
DB::beginTransaction();
$this->participant = $this->aParticipantWithAGroup();
$this->session = $this->aSessionStartingNow();
}
protected function tearDown(): void
{
if (config('database.default') === 'pgsql') {
DB::rollBack();
}
parent::tearDown();
}
private function aParticipantWithAGroup(): Participant
{
$participant = Participant::whereHas('activeEnrollments')->firstOrFail();
$participant->update(['status' => 'active']);
return $participant->refresh();
}
/** A session for one of the participant's groups, starting right now. */
private function aSessionStartingNow(): TrainingSession
{
$groupId = $this->participant->activeEnrollments()->value('training_group_id');
$session = TrainingSession::create([
'academy_id' => $this->participant->academy_id,
'training_group_id' => $groupId,
'session_date' => now()->toDateString(),
'start_time' => now()->format('H:i:s'),
'end_time' => now()->addHour()->format('H:i:s'),
'status' => SessionStatus::Scheduled,
]);
AttendanceRecord::create([
'academy_id' => $this->participant->academy_id,
'training_session_id' => $session->id,
'subject_type' => Participant::class,
'subject_id' => $this->participant->id,
'status' => AttendanceStatus::Expected,
]);
return $session;
}
// ---- what the token is -------------------------------------------------
public function test_a_valid_pass_marks_the_member_present(): void
{
$token = app(CheckInTokenService::class)->issue($this->participant);
$result = app(SelfCheckInService::class)->scan($token, $this->staff);
$this->assertFalse($result['replay']);
$this->assertSame(AttendanceStatus::Present, $result['record']->status);
// The marker is the person holding the scanner, which is exactly true.
$this->assertSame($this->staff->id, (int) $result['record']->marked_by);
}
public function test_the_same_code_scanned_twice_marks_nothing_the_second_time(): void
{
// Relay is unsolvable — a member can always screenshot the code. What
// makes it worthless is that the second scan changes nothing.
$token = app(CheckInTokenService::class)->issue($this->participant);
$scanner = app(SelfCheckInService::class);
$scanner->scan($token, $this->staff);
$second = $scanner->scan($token, $this->staff);
$this->assertTrue($second['replay']);
$this->assertSame(1, DB::table('checkin_consumptions')
->where('participant_id', $this->participant->id)
->count());
}
public function test_a_forged_tag_is_refused(): void
{
$token = app(CheckInTokenService::class)->issue($this->participant);
$parts = explode('.', $token);
$parts[3] = strrev($parts[3]);
$this->expectException(DomainException::class);
app(SelfCheckInService::class)->scan(implode('.', $parts), $this->staff);
}
public function test_bumping_the_key_version_kills_every_outstanding_pass(): void
{
// Revocation is one integer column, so it is atomic and takes effect at
// the next scan rather than at the next token rotation.
$token = app(CheckInTokenService::class)->issue($this->participant);
$this->participant->increment('checkin_key_version');
$this->expectException(DomainException::class);
app(SelfCheckInService::class)->scan($token, $this->staff);
}
public function test_a_code_from_a_distant_time_window_is_refused(): void
{
$tokens = app(CheckInTokenService::class);
$stale = $tokens->issue($this->participant, $tokens->currentCounter() - 10);
$this->expectException(DomainException::class);
app(SelfCheckInService::class)->scan($stale, $this->staff);
}
public function test_one_step_of_clock_skew_is_tolerated(): void
{
$tokens = app(CheckInTokenService::class);
$token = $tokens->issue($this->participant, $tokens->currentCounter() - 1);
$result = app(SelfCheckInService::class)->scan($token, $this->staff);
$this->assertFalse($result['replay']);
}
public function test_a_suspended_member_is_refused_even_with_a_valid_code(): void
{
// The pass asserts identity and never authorizes: status is a fresh
// read at the moment of the scan.
$token = app(CheckInTokenService::class)->issue($this->participant);
$this->participant->update(['status' => 'suspended']);
$this->expectException(DomainException::class);
app(SelfCheckInService::class)->scan($token, $this->staff);
}
public function test_a_pass_with_no_session_now_is_refused(): void
{
$this->session->update(['session_date' => now()->addDays(3)->toDateString()]);
$token = app(CheckInTokenService::class)->issue($this->participant);
$this->expectException(DomainException::class);
app(SelfCheckInService::class)->scan($token, $this->staff);
}
public function test_a_missing_pepper_refuses_to_issue_rather_than_using_a_weak_key(): void
{
config(['attendance.checkin_pepper' => '']);
$this->expectException(DomainException::class);
app(CheckInTokenService::class)->issue($this->participant);
}
public function test_the_token_never_carries_the_secret(): void
{
$token = app(CheckInTokenService::class)->issue($this->participant);
$this->assertStringNotContainsString(str_repeat('k', 16), $token);
$this->assertStringStartsWith('v1.' . $this->participant->uuid . '.', $token);
}
}
...@@ -29,6 +29,13 @@ protected function setUp(): void ...@@ -29,6 +29,13 @@ protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
// Symmetrical with the tenant tests, which skip off Postgres: this one
// builds its own tables, so pointing it at a real tenant database
// collides with the schema already there.
if (config('database.default') !== 'sqlite') {
$this->markTestSkipped('Builds its own schema; runs on the in-memory SQLite connection.');
}
Storage::fake('local'); Storage::fake('local');
$this->app->instance('current_academy', (object) ['id' => 1]); $this->app->instance('current_academy', (object) ['id' => 1]);
$this->createMinimalSchema(); $this->createMinimalSchema();
......
...@@ -101,6 +101,10 @@ public function test_the_branch_filter_is_omitted_rather_than_bound_as_null(): v ...@@ -101,6 +101,10 @@ public function test_the_branch_filter_is_omitted_rather_than_bound_as_null(): v
public function test_the_built_query_executes_with_its_bindings(): void public function test_the_built_query_executes_with_its_bindings(): void
{ {
if (config('database.default') !== 'sqlite') {
$this->markTestSkipped('Builds its own schema; runs on the in-memory SQLite connection.');
}
$this->createMinimalSchema(); $this->createMinimalSchema();
foreach ([['', null], ['3', null], ['', 7], ['3', 7]] as [$branchId, $academyId]) { foreach ([['', null], ['3', null], ['', 7], ['3', 7]] as [$branchId, $academyId]) {
......
...@@ -39,6 +39,13 @@ protected function setUp(): void ...@@ -39,6 +39,13 @@ protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
// Symmetrical with the tenant tests, which skip off Postgres: this one
// builds its own tables, so pointing it at a real tenant database
// collides with the schema already there.
if (config('database.default') !== 'sqlite') {
$this->markTestSkipped('Builds its own schema; runs on the in-memory SQLite connection.');
}
// These tests are about what lands in the ledger, not about the // These tests are about what lands in the ledger, not about the
// notification and enrolment listeners that hang off InvoiceCreated // notification and enrolment listeners that hang off InvoiceCreated
// and PaymentReceived. // and PaymentReceived.
......
...@@ -30,6 +30,10 @@ class SubscriptionCycleFigureTest extends TestCase ...@@ -30,6 +30,10 @@ class SubscriptionCycleFigureTest extends TestCase
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
if (config('database.default') !== 'sqlite') {
$this->markTestSkipped('Builds its own schema; runs on the in-memory SQLite connection.');
}
$this->createMinimalSchema(); $this->createMinimalSchema();
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment