Commit acca60b0 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(portal): identity, the member portal shell, and the check-in pass

S3, S4 and the core of S8.

Identity (S3)
-------------
GuardianResolver replaces ten hand-copied
`Guardian::where('person_id', …)->first()` lookups, every one wrong in the
same two ways: `->first()` on a column with no unique constraint, so a
guardian holding two rows saw one set of children and was 403'd on the rest,
silently; and no answer at all for an adult member, because all eleven
app/Livewire/Parent/* components end in ->firstOrFail() and a player has no
guardian row. That is why a player given the `parent` role saw empty lists —
the domain had no path from a user to his own participant.

PermissionService::getChildParticipantIds() also carried
`->where('person_id', …)->orWhere('user_id', …)`, which with the tenant
global scope appended compiles to `person_id = ? OR (user_id = ? AND
academy_id = ?)` — the first branch escaping the tenant filter entirely. The
closure is what keeps both branches inside it.

A real `player` role and the portal.* permissions ship as a guarded
migration, not a seeder: db:seed only runs when RUN_SEED_ON_FIRST_DEPLOY is
true, so a client deployed outside the one-click template would never receive
them. Same pattern as 2026_09_01_000001.

portal_invitations stores only the SHA-256 of its token — a raw token in a
row is a password in a row — and consumption is one conditional UPDATE whose
WHERE clause carries every condition, so two taps on the same link on a phone
cannot both create an account. Activation lives in a plain controller, never
Livewire: a single-use token in a public property is serialised into the page
on every round-trip.

users.email stays NOT NULL UNIQUE, deliberately. 2024_01_01_000002 declares
it inside Schema::create, so Postgres emits a UNIQUE CONSTRAINT that cannot
be made partial without a DROP CONSTRAINT in up(); CREATE INDEX CONCURRENTLY
cannot run in a migration transaction; and password_reset_tokens.email is the
primary key the broker keys on. Portal accounts get p{uuid}@portal.invalid
(RFC 2606, never routable) plus an email_is_synthetic flag every mail path
checks. No unique index on users.phone either: 2026_08_30_000004 logged that
it left duplicates in place, so one would hard-fail on at least one live
client and then block that client's migrations forever.

Phone login now refuses when one number matches several different people —
signing someone into a stranger's account — while still resolving a genuine
duplicate pair for the same person.

config/branch_lock.php gains portal.* and parent.*: RequireBranchSelection
runs on the whole web group, so without it any user holding branches.view_all
in all-branches mode is bounced out of the portal by middleware.

The portal (S4)
---------------
Five tabs at /app — الرئيسية, التدريب, المدفوعات, الأكاديمية, حسابي — with
the pass as a header affordance because it is per active profile: a guardian
with three children needs three.

PortalContext is the scope rule the IA turns on, decided once instead of
eleven times: training is member-scoped, money is family-scoped. The old
components each re-read session('active_child_id') independently while
ParentFinances ignored it and aggregated everyone — the domain saying out
loud that a household has one balance. The active id is re-validated against
GuardianResolver on every read, so a value put into the session, or left
there after a withdrawal, cannot widen what an account sees.

No participant id is held in a public property anywhere in the namespace.
This is Livewire v4, where a plain public property is settable from the
browser, so a check in mount() that is not repeated in render() is
decoration, not a check.

portal.css is the only entrypoint built with `source(none)`. app.css and
website.css are each a bare `@import 'tailwindcss'`, so v4 auto-detects from
the project root and both emit the identical complete utility set — a third
file written the same way would have been a third identical copy. Measured:
portal.css 17.11 kB / 4.54 kB gzipped against app.css at 208 kB / 31 kB.

Screens surface what was always one join away and never loaded: the coach
taking each session and the reason for a substitution, cancelled_reason so an
empty week does not read the same as Eid, and per-event registration for the
right child — answerable only since event_registrations gained participant_id
in S1.

The check-in pass (S8 core)
---------------------------
qr_check_in_enabled has been a toggle in system settings with zero functional
readers since 2026_07_27: the product advertised a feature that did not exist.

The pass asserts identity and never authorizes. Enrolment, participant
status, session existence and branch are fresh reads at every scan, which is
what makes a suspension take effect at the next scan rather than the next
token rotation. The secret is derived by HKDF from a pepper that is
deliberately not APP_KEY, revocation is one integer column, and a scanned
code is consumed by INSERT … ON CONFLICT DO NOTHING inside the same
transaction as the attendance write — a Cache::has/put pair would be a
time-of-check race, and two scanners at one gate is exactly when it loses.
Relay is not solvable; it is made worthless instead.

SelfCheckInService writes through AttendanceMarkingService with the scanning
staff as the marker rather than adding a second attendance write path. The
deleted API had one of those: POST /v1/absences/report wrote status='excused'
with no marker, no transition check, no audit and no check that the session
belonged to the participant.

QrCode is written rather than pulled in — there is no Composer step here that
can add to the committed lock file, and the alternative was the existing
pattern of an <img> pointing at api.qrserver.com, which sends the member's
token to a third party and fails when the venue's wifi does.

It was verified module-for-module against an independent implementation
across versions 1-10 and all eight masks, given identical codewords. That
found two bugs neither visible nor throwing: a Reed-Solomon generator
polynomial built with its terms reversed, and missing version-information
blocks for versions 7 and up, whose 36 modules were being filled with payload
and shifting the whole stream. Both produced a plausible square of black and
white that no scanner accepts. tests/Fixtures/qr_golden.php freezes that
verification.

Verified against a restored copy of backups/oc_sport-20260831-081053.dump:
all seven portal screens render 200 for a real member account, the manifest
is tenant-branded and no-store, and a member opening another family's invoice
gets 403.

Suite: 76 passed, 3 skipped (the tenant smoke test skips off Postgres rather
than pretending SQLite is production).
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 2dff900b
<?php
namespace App\Domain\Attendance\Services;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Models\Academy;
/**
* The rotating pass a member shows at the gate.
*
* **The token asserts identity. It never authorizes.** Enrolment status,
* participant status, whether a session exists in this window and any balance
* gate are all fresh database reads at the moment of the scan — that is what
* makes revocation instant rather than "instant once the current token
* expires".
*
* Design decisions worth stating, because each rules out something plausible:
*
* - The participant uuid is in the clear. The server does one indexed lookup
* and one HMAC. An opaque token would force it to iterate every participant
* trying HMACs, which is both a denial-of-service lever and a timing oracle.
* - The secret is derived, never stored: HKDF over a pepper that lives in the
* environment. There is no per-participant secret column to leak, and the
* pepper is deliberately NOT APP_KEY — APP_KEY decrypts sessions and
* cookies, and a check-in pepper does not deserve that blast radius.
* - Revocation is one integer, `participants.checkin_key_version`. Bumping it
* changes the derived secret, so every outstanding pass for that member dies
* at once, atomically and auditably.
* - Relay is not solved here, because relay is not solvable: a member can
* always screenshot a code and send it to a friend. What the design does is
* make it worthless — the consumption record and the unique key on
* (participant, session) mean the second scan marks nothing.
*/
class CheckInTokenService
{
/** One rotation every 30 seconds, with one step of tolerance either way. */
public const PERIOD_SECONDS = 30;
private const VERSION = 'v1';
private const TAG_BYTES = 16; // 128-bit truncation
public function pepper(): string
{
$pepper = (string) config('attendance.checkin_pepper');
if (strlen($pepper) < 32) {
throw new DomainException(
'مفتاح تسجيل الحضور غير مُهيّأ على الخادم (CHECKIN_PEPPER)'
);
}
return $pepper;
}
public function currentCounter(?int $at = null): int
{
return intdiv($at ?? time(), self::PERIOD_SECONDS);
}
/**
* The string encoded into the QR image.
*
* Format: v1.<participant_uuid>.<version>.<base32 tag>
*/
public function issue(Participant $participant, ?int $counter = null): string
{
$counter ??= $this->currentCounter();
$version = (int) ($participant->checkin_key_version ?? 1);
$tag = $this->tag($participant, $version, $counter);
return implode('.', [self::VERSION, $participant->uuid, $version, $tag]);
}
/**
* Verify a scanned string and return the participant it identifies.
*
* Returns the participant and the counter that matched, so the caller can
* record the consumption against that exact counter — recording "now"
* instead would leave the accepted neighbouring counter reusable.
*
* @return array{participant: Participant, counter: int}
*/
public function verify(string $token): array
{
$parts = explode('.', trim($token));
if (count($parts) !== 4 || $parts[0] !== self::VERSION) {
throw new DomainException('رمز غير صالح');
}
[, $uuid, $version, $tag] = $parts;
if (! preg_match('/^[0-9a-fA-F-]{36}$/', $uuid) || ! ctype_digit($version)) {
throw new DomainException('رمز غير صالح');
}
$participant = Participant::where('uuid', $uuid)->first();
if (! $participant) {
throw new DomainException('رمز غير صالح');
}
// A token minted under an older key version is dead the moment the
// version is bumped — that is the revocation mechanism.
$currentVersion = (int) ($participant->checkin_key_version ?? 1);
if ((int) $version !== $currentVersion) {
throw new DomainException('انتهت صلاحية بطاقة العضو — اطلب منه تحديث البطاقة');
}
$now = $this->currentCounter();
// {C-1, C, C+1}: one step covers clock skew and the second it takes to
// hold a phone up to a scanner. Widening this is never the fix — log
// the observed skew instead.
foreach ([$now, $now - 1, $now + 1] as $candidate) {
if (hash_equals($this->tag($participant, $currentVersion, $candidate), $tag)) {
return ['participant' => $participant, 'counter' => $candidate];
}
}
throw new DomainException('انتهت صلاحية الرمز — اطلب من العضو تحديث الشاشة');
}
private function tag(Participant $participant, int $version, int $counter): string
{
$academyUuid = $this->academyUuid($participant->academy_id);
// HKDF: one derived key per (academy, participant, version). The
// counter is the message, not part of the key, so rotating the code
// does not re-derive a key thirty times a minute.
$key = hash_hkdf(
'sha256',
$this->pepper(),
32,
$participant->uuid . '|' . $version,
$academyUuid
);
$mac = hash_hmac('sha256', $academyUuid . '|' . $participant->uuid . '|' . $version . '|' . $counter, $key, true);
return $this->base32(substr($mac, 0, self::TAG_BYTES));
}
private function academyUuid(int $academyId): string
{
static $cache = [];
return $cache[$academyId] ??= (string) Academy::whereKey($academyId)->value('uuid');
}
/** Crockford-ish base32: no padding, no characters a scanner confuses. */
private function base32(string $bytes): string
{
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$bits = '';
foreach (str_split($bytes) as $byte) {
$bits .= str_pad(decbin(ord($byte)), 8, '0', STR_PAD_LEFT);
}
$out = '';
foreach (str_split($bits, 5) as $chunk) {
$out .= $alphabet[bindec(str_pad($chunk, 5, '0', STR_PAD_RIGHT))];
}
return $out;
}
}
<?php
namespace App\Domain\Attendance\Services;
use App\Domain\Attendance\Enums\AttendanceStatus;
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Training\Enums\SessionStatus;
use App\Domain\Training\Models\TrainingSession;
use App\Models\User;
use Illuminate\Support\Facades\DB;
/**
* Turns a scanned pass into an attendance mark.
*
* This does **not** loosen AttendanceMarkingService. That service requires a
* staff `User $marker` on every method, and that requirement is correct: an
* attendance record says a member of staff observed someone. Here the marker is
* the person holding the scanner, which is exactly true — so the existing
* service is called with a real marker rather than bypassed.
*
* The one attendance write path stays one path. `POST /api/v1/absences/report`
* was a second one, writing status='excused' with no marker, no transition
* check, no audit and no check that the session even belonged to the
* participant — a player could excuse himself. That is what a second write path
* costs, and it is why this one goes through the front door.
*/
class SelfCheckInService
{
/** How far either side of a session's start a scan still counts as arrival. */
private const EARLY_MINUTES = 45;
private const LATE_MINUTES = 30;
public function __construct(
private readonly CheckInTokenService $tokens,
private readonly AttendanceMarkingService $marking,
) {}
/**
* @return array{record: AttendanceRecord, participant: Participant, session: TrainingSession, replay: bool}
*/
public function scan(string $token, User $scanner, ?int $branchId = null, ?string $ip = null): array
{
['participant' => $participant, 'counter' => $counter] = $this->tokens->verify($token);
// The token asserted identity. Everything that follows is authorisation,
// 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.
if ($participant->status !== 'active') {
throw new DomainException('العضوية غير نشطة — يرجى مراجعة الإدارة');
}
$session = $this->resolveSession($participant);
if (! $session) {
throw new DomainException('لا توجد حصة مجدولة لهذا العضو الآن');
}
if ($branchId && $session->facility?->branch_id && (int) $session->facility->branch_id !== (int) $branchId) {
throw new DomainException('هذه الحصة في فرع آخر');
}
return DB::transaction(function () use ($participant, $session, $scanner, $counter, $branchId, $ip) {
// The consumption record and the attendance write are one atomic
// act. Zero rows inserted means this exact code was already used —
// a screenshot passed to a friend marks nothing.
$inserted = DB::table('checkin_consumptions')->insertOrIgnore([
'academy_id' => $participant->academy_id,
'participant_id' => $participant->id,
'training_session_id' => $session->id,
'scanned_by' => $scanner->id,
'branch_id' => $branchId,
'counter' => $counter,
'skew_steps' => $counter - $this->tokens->currentCounter(),
'scanner_ip' => $ip,
'created_at' => now(),
]);
$record = AttendanceRecord::where('training_session_id', $session->id)
->where('subject_type', Participant::class)
->where('subject_id', $participant->id)
->lockForUpdate()
->first();
if (! $record) {
throw new DomainException('لا يوجد سجل حضور لهذا العضو في هذه الحصة');
}
if ($inserted === 0) {
return [
'record' => $record,
'participant' => $participant,
'session' => $session,
'replay' => true,
];
}
// Already marked by a coach on the sheet: the scan confirms rather
// than overwrites. Re-marking would discard the human observation.
if (in_array($record->status, [
AttendanceStatus::Present,
AttendanceStatus::Late,
AttendanceStatus::Partial,
AttendanceStatus::LeftEarly,
], true)) {
return [
'record' => $record,
'participant' => $participant,
'session' => $session,
'replay' => true,
];
}
$record = $this->marking->markPresent($record, $scanner, now());
$record->forceFill([
'metadata' => array_merge($record->metadata ?? [], [
'marked_via' => 'qr_scan',
'scanned_by' => $scanner->id,
]),
])->save();
return [
'record' => $record,
'participant' => $participant,
'session' => $session,
'replay' => false,
];
});
}
/**
* The session this member is plausibly arriving for: one of their groups,
* today, inside the arrival window, nearest start time first.
*/
private function resolveSession(Participant $participant): ?TrainingSession
{
$groupIds = $participant->activeEnrollments()->pluck('training_group_id')->all();
if ($groupIds === []) {
return null;
}
$from = now()->subMinutes(self::LATE_MINUTES)->format('H:i:s');
$to = now()->addMinutes(self::EARLY_MINUTES)->format('H:i:s');
return TrainingSession::whereIn('training_group_id', $groupIds)
->where('session_date', now()->toDateString())
->where('status', SessionStatus::Scheduled)
->whereBetween('start_time', [$from, $to])
->with('facility')
->orderByRaw('ABS(EXTRACT(EPOCH FROM (start_time - ?::time)))', [now()->format('H:i:s')])
->first();
}
}
<?php
namespace App\Domain\Identity\Models;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Traits\Auditable;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PortalInvitation extends Model
{
use HasUuid, BelongsToAcademy, Auditable;
protected $fillable = [
'academy_id',
'person_id',
'participant_id',
'token_hash',
'channel',
'sent_to',
'expires_at',
'consumed_at',
'consumed_by',
'revoked_at',
'created_by',
'created_ip',
];
/**
* The raw token is never an attribute — only its hash is stored. It is
* returned once, from PortalInvitationService::issue(), and after that the
* only copy is in the link the member was sent.
*/
protected $hidden = ['token_hash'];
protected function casts(): array
{
return [
'expires_at' => 'datetime',
'consumed_at' => 'datetime',
'revoked_at' => 'datetime',
];
}
public function person(): BelongsTo
{
return $this->belongsTo(Person::class);
}
public function participant(): BelongsTo
{
return $this->belongsTo(Participant::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function isLive(): bool
{
return $this->consumed_at === null
&& $this->revoked_at === null
&& $this->expires_at->isFuture();
}
public function statusLabel(): string
{
return match (true) {
$this->consumed_at !== null => 'مُفعّلة',
$this->revoked_at !== null => 'ملغاة',
$this->expires_at->isPast() => 'منتهية',
default => 'في الانتظار',
};
}
}
......@@ -7,6 +7,7 @@
use App\Domain\Shared\Helpers\CredentialNormalizer;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
class AuthService
{
......@@ -103,11 +104,36 @@ private function findByPhone(string $phone): ?User
return null;
}
return User::whereIn('phone', $variants)
$candidates = User::whereIn('phone', $variants)
->orderByRaw('last_login_at IS NULL')
->orderByDesc('last_login_at')
->orderByDesc('id')
->first();
->get();
if ($candidates->isEmpty()) {
return null;
}
// 2026_08_30_000004 deliberately left duplicate phone numbers in place
// — it logged the ids that needed a manual merge rather than failing —
// so ->first() on this column is an account-selection primitive.
//
// Two accounts for the same PERSON are a duplicate: picking the one in
// active use is right, and the merge screen exists to clear the pair.
// Two accounts for DIFFERENT people sharing a number is not a
// duplicate, and choosing between them silently would sign someone into
// a stranger's account. That case refuses.
$people = $candidates->pluck('person_id')->filter()->unique();
if ($people->count() > 1) {
Log::warning('Ambiguous phone login refused: one number, several people', [
'user_ids' => $candidates->pluck('id')->all(),
]);
return null;
}
return $candidates->first();
}
private function recordLogin(User $user, string $ip, ?string $userAgent, string $status, ?string $reason = null): void
......
<?php
namespace App\Domain\Identity\Services;
use App\Domain\Identity\Models\Guardian;
use App\Domain\Participant\Models\Participant;
use App\Models\User;
use Illuminate\Support\Collection;
/**
* Resolves which participants an account speaks for.
*
* Ten places did their own version of this, all shaped
* `Guardian::where('person_id', …)->first()`, and every one of them was wrong
* in the same two ways:
*
* 1. `->first()` on a non-unique column. No UNIQUE(academy_id, person_id)
* exists on `guardians`, so a guardian with two rows sees one set of
* children and is 403'd on the rest — silently, with no error anywhere.
*
* 2. No answer for an adult with no guardian row. All eleven
* app/Livewire/Parent/* components end in `->firstOrFail()`, so a player
* account hard-fails on every screen. That is why a player given the
* `parent` role sees empty lists: the domain has no path from a user to
* his own participant row.
*
* Both are fixed the same way: return the union of every guardian row this
* account holds, plus the account's own participant row when it has one.
* That makes `own_children` mean "the participants this account speaks for",
* which for a guardian is the children and for a player is himself.
*/
class GuardianResolver
{
/** @var array<int, Collection<int, Guardian>> */
private array $guardianCache = [];
/** @var array<int, array<int, int>> */
private array $participantCache = [];
/** @var array<int, array<int, int>> */
private array $payableCache = [];
/**
* Every guardian row this user holds — not the first one.
*
* @return Collection<int, Guardian>
*/
public function guardiansFor(User $user): Collection
{
if (isset($this->guardianCache[$user->id])) {
return $this->guardianCache[$user->id];
}
// PermissionService wrote this as
// ->where('person_id', $x)->orWhere('user_id', $y)
// which, with the tenant global scope appended, compiles to
// person_id = ? OR (user_id = ? AND academy_id = ?)
// so the person_id branch escapes the tenant filter entirely. The
// closure is what keeps both branches inside it.
$guardians = Guardian::query()
->where(function ($q) use ($user) {
if ($user->person_id) {
$q->where('person_id', $user->person_id);
}
$q->orWhere('user_id', $user->id);
})
->get();
return $this->guardianCache[$user->id] = $guardians;
}
/**
* The participant ids this account may see: every linked child, plus the
* account holder's own participant row if he is a member himself.
*
* @return array<int, int>
*/
public function participantIdsFor(User $user): array
{
if (isset($this->participantCache[$user->id])) {
return $this->participantCache[$user->id];
}
$ids = [];
foreach ($this->guardiansFor($user) as $guardian) {
foreach ($guardian->participants()->pluck('participants.id') as $id) {
$ids[] = (int) $id;
}
}
// The adult-member case. The mobile API already solved this by
// comparing $participant->person_id to the user's person_id; this is
// that answer, in one place, rather than a third resolution path.
if ($user->person_id) {
foreach (Participant::where('person_id', $user->person_id)->pluck('id') as $id) {
$ids[] = (int) $id;
}
}
return $this->participantCache[$user->id] = array_values(array_unique($ids));
}
/**
* The subset this account may act on financially.
*
* `guardian_participant.can_authorize_payment` has existed since
* 2024_01_01_000018 with zero readers, so every guardian currently has full
* financial access to every linked child. It is the natural gate for
* submitting a transfer proof or starting a payment, so the portal reads it.
* A member acting for himself always qualifies.
*
* @return array<int, int>
*/
public function payableParticipantIdsFor(User $user): array
{
if (isset($this->payableCache[$user->id])) {
return $this->payableCache[$user->id];
}
$ids = [];
foreach ($this->guardiansFor($user) as $guardian) {
$rows = $guardian->participants()
->wherePivot('can_authorize_payment', true)
->pluck('participants.id');
foreach ($rows as $id) {
$ids[] = (int) $id;
}
}
if ($user->person_id) {
foreach (Participant::where('person_id', $user->person_id)->pluck('id') as $id) {
$ids[] = (int) $id;
}
}
return $this->payableCache[$user->id] = array_values(array_unique($ids));
}
public function mayView(User $user, int $participantId): bool
{
return in_array($participantId, $this->participantIdsFor($user), true);
}
public function mayPayFor(User $user, int $participantId): bool
{
return in_array($participantId, $this->payableParticipantIdsFor($user), true);
}
/**
* The participants themselves, ordered so a switcher renders predictably.
*
* @return Collection<int, Participant>
*/
public function participantsFor(User $user): Collection
{
$ids = $this->participantIdsFor($user);
if ($ids === []) {
return collect();
}
return Participant::whereIn('id', $ids)
->with('person')
->get()
->sortBy(fn (Participant $p) => $p->person?->name_ar ?? '')
->values();
}
/** Drop the memo — for a long-running process, or after a link changes. */
public function forget(User $user): void
{
unset(
$this->guardianCache[$user->id],
$this->participantCache[$user->id],
$this->payableCache[$user->id],
);
}
}
......@@ -5,6 +5,7 @@
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Identity\Models\Guardian;
use App\Domain\Identity\Services\GuardianResolver;
use App\Domain\Participant\Models\Participant;
use App\Domain\Scheduling\Models\Assignment;
use App\Domain\Training\Models\Enrollment;
......@@ -221,17 +222,16 @@ private function getChildParticipantIds(User $user): array
return $this->cachedChildIds[$user->id];
}
$guardian = Guardian::where('person_id', $user->person_id)
->orWhere('user_id', $user->id)
->first();
// This was `->where('person_id', …)->orWhere('user_id', …)->first()`.
// Two bugs in one line: the orWhere let the person_id branch escape the
// tenant global scope, and ->first() on a non-unique column meant a
// guardian with two rows saw one set of children and was 403'd on the
// rest. GuardianResolver returns the union of every row, and includes
// the account's own participant so a player is not permanently empty.
$ids = app(GuardianResolver::class)->participantIdsFor($user);
if (!$guardian) {
$this->cachedChildIds[$user->id] = [];
return [];
}
$ids = $guardian->participants()->pluck('participants.id')->toArray();
$this->cachedChildIds[$user->id] = $ids;
return $ids;
}
......
<?php
namespace App\Domain\Identity\Services;
use App\Domain\Identity\Models\Person;
use App\Domain\Identity\Models\PortalInvitation;
use App\Domain\Identity\Models\Role;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Helpers\CredentialNormalizer;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* Issues and consumes the invitations that give a member a portal account.
*
* Two things are load-bearing here and both are about the token:
*
* - Only its SHA-256 is stored. A raw token in a database row is a password
* in a database row.
* - Consumption is one conditional UPDATE, not read-then-write. Two taps on
* the same link — which happens constantly on a phone — would otherwise
* both pass a `consumed_at IS NULL` check and both create an account.
*/
class PortalInvitationService
{
/** Long enough that guessing is not a strategy; expiry is a second line. */
private const TOKEN_BYTES = 32;
private const LIFETIME_HOURS = 72;
/**
* @return array{invitation: PortalInvitation, token: string}
* The raw token is returned exactly once and never stored.
*/
public function issue(Person $person, User $actor, ?Participant $participant = null, string $channel = 'link', ?string $ip = null): array
{
if (! in_array($channel, ['link', 'whatsapp', 'email'], true)) {
throw new DomainException('قناة إرسال غير معروفة');
}
return DB::transaction(function () use ($person, $actor, $participant, $channel, $ip) {
// One live invitation per person: re-inviting revokes the previous
// link rather than leaving two working links in the wild. Enforced
// by a partial unique index as well, so a race cannot beat it.
PortalInvitation::where('academy_id', $person->academy_id)
->where('person_id', $person->id)
->whereNull('consumed_at')
->whereNull('revoked_at')
->update(['revoked_at' => now()]);
$token = Str::random(8) . bin2hex(random_bytes(self::TOKEN_BYTES));
$invitation = PortalInvitation::create([
'academy_id' => $person->academy_id,
'person_id' => $person->id,
'participant_id' => $participant?->id,
'token_hash' => hash('sha256', $token),
'channel' => $channel,
'sent_to' => $channel === 'email' ? $person->email : $person->phone,
'expires_at' => now()->addHours(self::LIFETIME_HOURS),
'created_by' => $actor->id,
'created_ip' => $ip,
]);
return ['invitation' => $invitation, 'token' => $token];
});
}
public function revoke(PortalInvitation $invitation): void
{
PortalInvitation::where('id', $invitation->id)
->whereNull('consumed_at')
->whereNull('revoked_at')
->update(['revoked_at' => now()]);
}
/**
* Look the token up without consuming it, so the activation form can be
* shown before a password is typed.
*
* Returns null for missing, expired, revoked and already-used alike: the
* caller must render one message for all four, or the response tells an
* attacker which tokens exist.
*/
public function findLive(string $token): ?PortalInvitation
{
if ($token === '') {
return null;
}
return PortalInvitation::withoutGlobalScopes()
->where('token_hash', hash('sha256', $token))
->whereNull('consumed_at')
->whereNull('revoked_at')
->where('expires_at', '>', now())
->first();
}
/**
* Consume the invitation and give the person a portal account.
*
* The claim is a conditional UPDATE whose WHERE clause carries every
* condition — unconsumed, unrevoked, unexpired. Zero affected rows means
* somebody else got there first, and the second request creates nothing.
*/
public function activate(string $token, string $password, ?string $email = null): User
{
return DB::transaction(function () use ($token, $password, $email) {
$hash = hash('sha256', $token);
$invitation = PortalInvitation::withoutGlobalScopes()
->where('token_hash', $hash)
->lockForUpdate()
->first();
$claimed = PortalInvitation::withoutGlobalScopes()
->where('token_hash', $hash)
->whereNull('consumed_at')
->whereNull('revoked_at')
->where('expires_at', '>', now())
->update(['consumed_at' => now()]);
if ($claimed === 0 || ! $invitation) {
throw new DomainException('رابط التفعيل غير صالح أو انتهت صلاحيته');
}
$person = Person::withoutGlobalScopes()->findOrFail($invitation->person_id);
$user = $this->resolveOrCreateUser($person, $invitation->academy_id, $email);
$user->password = Hash::make($password);
$user->status = 'active';
$user->failed_login_attempts = 0;
$user->locked_until = null;
$user->save();
PortalInvitation::withoutGlobalScopes()
->where('id', $invitation->id)
->update(['consumed_by' => $user->id]);
// people.user_id is the canonical link; guardians.user_id is a
// redundant second one that has to be kept in step.
if (! $person->user_id) {
$person->forceFill(['user_id' => $user->id])->save();
}
DB::table('guardians')
->where('person_id', $person->id)
->whereNull('user_id')
->update(['user_id' => $user->id]);
return $user;
});
}
private function resolveOrCreateUser(Person $person, int $academyId, ?string $email): User
{
$existing = User::withoutGlobalScopes()
->where('academy_id', $academyId)
->where(function ($q) use ($person) {
$q->where('person_id', $person->id);
if ($person->user_id) {
$q->orWhere('id', $person->user_id);
}
})
->first();
if ($existing) {
if ($email && $existing->email_is_synthetic) {
$this->assignRealEmail($existing, $email);
}
$this->ensureMemberRole($existing, $academyId, $person);
return $existing;
}
$user = new User();
$user->academy_id = $academyId;
$user->person_id = $person->id;
$user->name = $person->name ?: $person->name_ar;
$user->name_ar = $person->name_ar;
$user->phone = CredentialNormalizer::phone($person->phone);
$user->status = 'active';
// users.email is NOT NULL UNIQUE and cannot be relaxed (see the
// migration's note), and most guardians have no email. A .invalid
// address is guaranteed by RFC 2606 never to resolve, so it can never
// bounce off the mail server — and email_is_synthetic is what every
// mail path checks before trying to send.
if ($email && ! User::withoutGlobalScopes()->where('email', CredentialNormalizer::email($email))->exists()) {
$user->email = CredentialNormalizer::email($email);
$user->email_is_synthetic = false;
} else {
$user->email = 'p' . ($person->uuid ?: Str::uuid()) . '@portal.invalid';
$user->email_is_synthetic = true;
}
$user->password = Hash::make(Str::random(40));
$user->save();
$this->ensureMemberRole($user, $academyId, $person);
return $user;
}
private function assignRealEmail(User $user, string $email): void
{
$normalized = CredentialNormalizer::email($email);
if (! $normalized) {
return;
}
$taken = User::withoutGlobalScopes()
->where('email', $normalized)
->where('id', '!=', $user->id)
->exists();
if (! $taken) {
$user->email = $normalized;
$user->email_is_synthetic = false;
}
}
/**
* A member who plays gets `player`; a member who only has children gets
* `parent`. The distinction matters because the parent role's scope
* resolves through a Guardian row, and a player has none.
*/
private function ensureMemberRole(User $user, int $academyId, Person $person): void
{
if ($user->role_id) {
return;
}
$playsForHimself = Participant::withoutGlobalScopes()
->where('academy_id', $academyId)
->where('person_id', $person->id)
->exists();
$slug = $playsForHimself ? 'player' : 'parent';
$role = Role::withoutGlobalScopes()
->where('academy_id', $academyId)
->where('slug', $slug)
->first();
if (! $role) {
return;
}
$user->role_id = $role->id;
$user->save();
$alreadyAttached = DB::table('role_user')
->where('user_id', $user->id)
->where('role_id', $role->id)
->exists();
if (! $alreadyAttached) {
DB::table('role_user')->insert([
'user_id' => $user->id,
'role_id' => $role->id,
'created_at' => now(),
]);
}
}
}
<?php
namespace App\Domain\Shared\Context;
use App\Domain\Identity\Services\GuardianResolver;
use App\Domain\Participant\Models\Participant;
use App\Models\User;
use Illuminate\Support\Collection;
/**
* Which member the portal is currently showing.
*
* Modelled on BranchContext deliberately, including the three-state session
* key, because it is the same shape of problem: a selection that scopes some
* surfaces and not others.
*
* key absent -> not resolved; fall back to the account's sole or
* first member
* key present, null -> "the whole family"
* key present, int -> that member
*
* The distinction that makes this a scope rule rather than a screen: **training
* is member-scoped and money is family-scoped.** A guardian looking at the
* schedule is asking about one child; a guardian looking at what is owed is
* asking about the household. The current `/parent` components each re-read
* `session('active_child_id')` independently and ParentFinances ignores it and
* aggregates everyone — which is the domain saying this out loud. Deciding it
* here means the eleven screens do not each re-implement it.
*
* The id is validated against GuardianResolver on every read, so a value put
* into the session — or left there after a withdrawal — cannot widen what the
* account can see.
*/
class PortalContext
{
public const KEY = 'portal_active_participant_id';
private ?array $allowed = null;
public function __construct(private readonly GuardianResolver $guardians) {}
/** @return array<int, int> */
public function allowedParticipantIds(): array
{
if ($this->allowed !== null) {
return $this->allowed;
}
$user = auth()->user();
return $this->allowed = $user ? $this->guardians->participantIdsFor($user) : [];
}
public function isResolved(): bool
{
// session()->exists(), not has(): has() is `! is_null(get($key))`, so it
// reports false for the key holding null — which is exactly how "the
// whole family" is stored.
return app()->bound('session') && session()->exists(self::KEY);
}
public function isWholeFamily(): bool
{
return $this->isResolved() && session(self::KEY) === null && count($this->allowedParticipantIds()) > 1;
}
/**
* The member a member-scoped surface should show. Null only when the
* account speaks for nobody.
*/
public function participantId(): ?int
{
$allowed = $this->allowedParticipantIds();
if ($allowed === []) {
return null;
}
if (! $this->isResolved()) {
return $this->set($allowed[0]);
}
$value = session(self::KEY);
// "Whole family" is not a valid answer for a member-scoped surface, and
// neither is an id the account no longer speaks for — a withdrawal
// between requests leaves exactly that.
if ($value === null || ! in_array((int) $value, $allowed, true)) {
return $allowed[0];
}
return (int) $value;
}
/**
* The ids a family-scoped surface should cover: everyone when the whole
* family is selected, otherwise just the active member.
*
* @return array<int, int>
*/
public function familyParticipantIds(): array
{
$allowed = $this->allowedParticipantIds();
if ($allowed === []) {
return [];
}
if ($this->isWholeFamily()) {
return $allowed;
}
$active = $this->participantId();
return $active ? [$active] : [];
}
/**
* Money is family-scoped by default, so an unresolved or whole-family
* selection covers the household rather than one child.
*
* @return array<int, int>
*/
public function moneyParticipantIds(): array
{
return $this->allowedParticipantIds();
}
public function set(?int $participantId): ?int
{
if ($participantId !== null && ! in_array($participantId, $this->allowedParticipantIds(), true)) {
return $this->participantId();
}
session([self::KEY => $participantId]);
return $participantId;
}
public function clear(): void
{
session()->forget(self::KEY);
$this->allowed = null;
}
/** @return Collection<int, Participant> */
public function switchableProfiles(): Collection
{
$user = auth()->user();
return $user ? $this->guardians->participantsFor($user) : collect();
}
public function activeParticipant(): ?Participant
{
$id = $this->participantId();
return $id ? $this->switchableProfiles()->firstWhere('id', $id) : null;
}
public function speaksForAnyone(): bool
{
return $this->allowedParticipantIds() !== [];
}
public function mayPayFor(int $participantId): bool
{
$user = auth()->user();
return $user !== null && $this->guardians->mayPayFor($user, $participantId);
}
/** @return array<int, int> */
public function payableParticipantIds(): array
{
$user = auth()->user();
return $user ? $this->guardians->payableParticipantIdsFor($user) : [];
}
public function forget(?User $user = null): void
{
$this->allowed = null;
if ($user) {
$this->guardians->forget($user);
}
}
}
This diff is collapsed.
......@@ -18,6 +18,7 @@ class WebsiteNews extends Model
protected $fillable = [
'academy_id',
'channel',
'title',
'title_en',
'slug',
......@@ -39,6 +40,19 @@ class WebsiteNews extends Model
'sort_order' => 'integer',
];
/**
* Content published to a given surface.
*
* `both` is the default and the common case: the portal and the public
* site read the same tables, and this is the one column that decides where
* an item appears — instead of a second CMS with a second migration
* surface to keep in step.
*/
public function scopeForChannel(\Illuminate\Database\Eloquent\Builder $query, string $channel): \Illuminate\Database\Eloquent\Builder
{
return $query->whereIn('channel', [$channel, 'both']);
}
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
......
<?php
namespace App\Http\Controllers\Portal;
use App\Domain\Identity\Services\PortalInvitationService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
/**
* Activation lives in a plain controller on purpose.
*
* A Livewire component holds its state in public properties, and every one of
* those is serialised into the page and sent back on every round-trip. A
* single-use activation token in a public property is a token in the DOM, in
* the browser's history, in any proxy log — and, before that page was gated, in
* the 500 error page's request dump.
*/
class InvitationController extends Controller
{
public function __construct(private readonly PortalInvitationService $invitations) {}
public function show(Request $request, string $token)
{
// Missing, expired, revoked and already-used all render the same page.
// Distinguishing them tells whoever is guessing which tokens exist.
$invitation = $this->invitations->findLive($token);
if (! $invitation) {
return response()->view('portal.invitation-invalid', [], 410);
}
return view('portal.invitation-activate', [
'token' => $token,
'personName' => $invitation->person?->name_ar ?? $invitation->person?->name,
'needsEmail' => true,
]);
}
public function store(Request $request, string $token)
{
// Guessing is throttled by IP, since there is no account to throttle by
// until the token resolves.
$key = 'portal-activate:' . $request->ip();
if (RateLimiter::tooManyAttempts($key, 10)) {
return response()->view('portal.invitation-invalid', [
'message' => __('محاولات كثيرة. حاول مرة أخرى بعد قليل.'),
], 429);
}
RateLimiter::hit($key, 900);
$data = $request->validate([
'password' => ['required', 'string', 'min:8', 'max:200', 'confirmed'],
'email' => ['nullable', 'email', 'max:255'],
], [
'password.required' => __('كلمة المرور مطلوبة'),
'password.min' => __('كلمة المرور يجب ألا تقل عن ٨ أحرف'),
'password.confirmed' => __('تأكيد كلمة المرور غير مطابق'),
'email.email' => __('البريد الإلكتروني غير صالح'),
]);
try {
$user = $this->invitations->activate($token, $data['password'], $data['email'] ?? null);
} catch (DomainException $e) {
throw ValidationException::withMessages(['password' => $e->getMessage()]);
}
Auth::login($user, remember: true);
$request->session()->regenerate();
RateLimiter::clear($key);
return redirect()->route('portal.home');
}
}
<?php
namespace App\Http\Controllers\Portal;
use App\Domain\Shared\Services\BrandingService;
use App\Domain\Shared\Services\SettingsService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
/**
* The per-tenant web app manifest.
*
* Three things here are not decoration:
*
* - `Cache-Control: private, no-store`. This is a per-tenant response on a
* per-tenant hostname; a shared proxy that cached it would hand one
* academy's branding to another's members.
* - Icon paths are relative. config/filesystems.php builds public URLs from
* env('APP_URL') verbatim with no https coercion, and a single http URL
* makes the manifest invalid — at which point the install prompt simply
* never appears, with no error anywhere.
* - `start_url` and `scope` are both `/app/`, so installing the portal does
* not capture the ERP or the public website.
*/
class ManifestController extends Controller
{
public function __invoke(BrandingService $branding, SettingsService $settings): JsonResponse
{
$brand = $branding->forCurrentAcademy();
$icons = [];
$stored = json_decode((string) $settings->get('branding.icon_set', '{}'), true) ?: [];
foreach ($stored as $size => $path) {
$icons[] = [
'src' => '/storage/' . ltrim((string) $path, '/'),
'sizes' => "{$size}x{$size}",
'type' => 'image/png',
'purpose' => (int) $size >= 192 ? 'any maskable' : 'any',
];
}
if ($icons === [] && $brand->logoUrl) {
$icons[] = [
'src' => $this->relative($brand->logoUrl),
'sizes' => '512x512',
'type' => 'image/png',
'purpose' => 'any',
];
}
$manifest = [
'id' => '/app/',
'name' => $brand->academyName,
'short_name' => $brand->appShortName,
'description' => __('بوابة أعضاء') . ' ' . $brand->academyName,
'start_url' => '/app/',
'scope' => '/app/',
'display' => 'standalone',
'orientation' => 'portrait',
'dir' => 'rtl',
'lang' => app()->getLocale(),
'background_color' => $brand->themeMode === 'dark' ? '#111827' : '#ffffff',
'theme_color' => $brand->themeColor,
'icons' => $icons,
'shortcuts' => [
[
'name' => __('المدفوعات'),
'url' => '/app/payments',
],
[
'name' => __('التدريب'),
'url' => '/app/training',
],
],
];
return response()
->json($manifest, 200, [
'Content-Type' => 'application/manifest+json; charset=utf-8',
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
->header('Cache-Control', 'private, no-store');
}
private function relative(string $url): string
{
$path = parse_url($url, PHP_URL_PATH);
return $path ?: $url;
}
}
......@@ -5,6 +5,7 @@
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Identity\Models\Guardian;
use App\Domain\Identity\Services\GuardianResolver;
use App\Domain\Participant\Models\Participant;
use App\Domain\Training\Enums\SessionStatus;
use App\Domain\Training\Models\TrainingSession;
......@@ -20,10 +21,11 @@ public function mount(): void
{
$user = auth()->user();
$guardian = Guardian::where('person_id', $user->person_id)->first()
?? Guardian::where('user_id', $user->id)->first();
// Any account that speaks for at least one participant belongs in the
// member portal, guardian row or not — an adult member has none.
$speaksFor = app(GuardianResolver::class)->participantIdsFor($user);
if ($guardian) {
if ($speaksFor !== []) {
$this->redirect(route('parent.home'), navigate: true);
return;
}
......@@ -35,12 +37,7 @@ public function render()
{
$user = auth()->user();
// Find guardian record for this user
$guardian = Guardian::where('person_id', $user->person_id)->first()
?? Guardian::where('user_id', $user->id)->first();
// Get participant IDs for this guardian's children
$participantIds = $guardian->participants()->pluck('participants.id');
$participantIds = collect(app(GuardianResolver::class)->participantIdsFor($user));
// Load children with their person data and active enrollments
$children = Participant::whereIn('id', $participantIds)
......
......@@ -4,6 +4,7 @@
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Identity\Models\Guardian;
use App\Domain\Identity\Services\GuardianResolver;
use App\Domain\Participant\Models\Participant;
use Carbon\Carbon;
use Livewire\Attributes\Layout;
......@@ -104,12 +105,16 @@ private function getGuardian(): Guardian
{
$user = auth()->user();
return Guardian::where('person_id', $user->person_id)->first()
// Display only. Authorisation and scoping go through GuardianResolver:
// ->first() on a non-unique column meant a guardian holding two rows
// saw one set of children and was 403'd on the rest, and an adult
// member with no guardian row hard-failed on every screen here.
return app(GuardianResolver::class)->guardiansFor($user)->first()
?? Guardian::where('user_id', $user->id)->firstOrFail();
}
private function getChildrenIds(): array
{
return $this->getGuardian()->participants()->pluck('participants.id')->toArray();
return app(GuardianResolver::class)->participantIdsFor(auth()->user());
}
}
......@@ -3,6 +3,7 @@
namespace App\Livewire\Parent;
use App\Domain\Identity\Models\Guardian;
use App\Domain\Identity\Services\GuardianResolver;
use App\Domain\Participant\Models\Participant;
use App\Domain\Training\Enums\EvaluationStatus;
use App\Domain\Training\Models\Evaluation;
......@@ -69,12 +70,16 @@ private function getGuardian(): Guardian
{
$user = auth()->user();
return Guardian::where('person_id', $user->person_id)->first()
// Display only. Authorisation and scoping go through GuardianResolver:
// ->first() on a non-unique column meant a guardian holding two rows
// saw one set of children and was 403'd on the rest, and an adult
// member with no guardian row hard-failed on every screen here.
return app(GuardianResolver::class)->guardiansFor($user)->first()
?? Guardian::where('user_id', $user->id)->firstOrFail();
}
private function getChildrenIds(): array
{
return $this->getGuardian()->participants()->pluck('participants.id')->toArray();
return app(GuardianResolver::class)->participantIdsFor(auth()->user());
}
}
......@@ -3,6 +3,7 @@
namespace App\Livewire\Parent;
use App\Domain\Identity\Models\Guardian;
use App\Domain\Identity\Services\GuardianResolver;
use App\Domain\Training\Enums\EvaluationStatus;
use App\Domain\Training\Models\Evaluation;
use Livewire\Attributes\Layout;
......@@ -49,12 +50,16 @@ private function getGuardian(): Guardian
{
$user = auth()->user();
return Guardian::where('person_id', $user->person_id)->first()
// Display only. Authorisation and scoping go through GuardianResolver:
// ->first() on a non-unique column meant a guardian holding two rows
// saw one set of children and was 403'd on the rest, and an adult
// member with no guardian row hard-failed on every screen here.
return app(GuardianResolver::class)->guardiansFor($user)->first()
?? Guardian::where('user_id', $user->id)->firstOrFail();
}
private function getChildrenIds(): array
{
return $this->getGuardian()->participants()->pluck('participants.id')->toArray();
return app(GuardianResolver::class)->participantIdsFor(auth()->user());
}
}
......@@ -3,6 +3,7 @@
namespace App\Livewire\Parent;
use App\Domain\Identity\Models\Guardian;
use App\Domain\Identity\Services\GuardianResolver;
use App\Domain\Participant\Models\Participant;
use App\Domain\Training\Models\TrainingSession;
use Livewire\Attributes\Layout;
......@@ -153,12 +154,16 @@ private function getGuardian(): Guardian
{
$user = auth()->user();
return Guardian::where('person_id', $user->person_id)->first()
// Display only. Authorisation and scoping go through GuardianResolver:
// ->first() on a non-unique column meant a guardian holding two rows
// saw one set of children and was 403'd on the rest, and an adult
// member with no guardian row hard-failed on every screen here.
return app(GuardianResolver::class)->guardiansFor($user)->first()
?? Guardian::where('user_id', $user->id)->firstOrFail();
}
private function getChildrenIds(): array
{
return $this->getGuardian()->participants()->pluck('participants.id')->toArray();
return app(GuardianResolver::class)->participantIdsFor(auth()->user());
}
}
......@@ -4,6 +4,7 @@
use App\Domain\Financial\Models\Invoice;
use App\Domain\Identity\Models\Guardian;
use App\Domain\Identity\Services\GuardianResolver;
use App\Domain\Participant\Models\Participant;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
......@@ -68,12 +69,16 @@ private function getGuardian(): Guardian
{
$user = auth()->user();
return Guardian::where('person_id', $user->person_id)->first()
// Display only. Authorisation and scoping go through GuardianResolver:
// ->first() on a non-unique column meant a guardian holding two rows
// saw one set of children and was 403'd on the rest, and an adult
// member with no guardian row hard-failed on every screen here.
return app(GuardianResolver::class)->guardiansFor($user)->first()
?? Guardian::where('user_id', $user->id)->firstOrFail();
}
private function getChildrenIds(): array
{
return $this->getGuardian()->participants()->pluck('participants.id')->toArray();
return app(GuardianResolver::class)->participantIdsFor(auth()->user());
}
}
......@@ -5,6 +5,7 @@
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Identity\Models\Guardian;
use App\Domain\Identity\Services\GuardianResolver;
use App\Domain\Participant\Models\Participant;
use App\Domain\Training\Enums\EvaluationStatus;
use App\Domain\Training\Enums\SessionStatus;
......@@ -197,12 +198,16 @@ private function getGuardian(): Guardian
{
$user = auth()->user();
return Guardian::where('person_id', $user->person_id)->first()
// Display only. Authorisation and scoping go through GuardianResolver:
// ->first() on a non-unique column meant a guardian holding two rows
// saw one set of children and was 403'd on the rest, and an adult
// member with no guardian row hard-failed on every screen here.
return app(GuardianResolver::class)->guardiansFor($user)->first()
?? Guardian::where('user_id', $user->id)->firstOrFail();
}
private function getChildrenIds(): array
{
return $this->getGuardian()->participants()->pluck('participants.id')->toArray();
return app(GuardianResolver::class)->participantIdsFor(auth()->user());
}
}
......@@ -4,6 +4,7 @@
use App\Domain\Financial\Models\Invoice;
use App\Domain\Identity\Models\Guardian;
use App\Domain\Identity\Services\GuardianResolver;
use App\Domain\Participant\Models\Participant;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
......@@ -43,12 +44,16 @@ private function getGuardian(): Guardian
{
$user = auth()->user();
return Guardian::where('person_id', $user->person_id)->first()
// Display only. Authorisation and scoping go through GuardianResolver:
// ->first() on a non-unique column meant a guardian holding two rows
// saw one set of children and was 403'd on the rest, and an adult
// member with no guardian row hard-failed on every screen here.
return app(GuardianResolver::class)->guardiansFor($user)->first()
?? Guardian::where('user_id', $user->id)->firstOrFail();
}
private function getChildrenIds(): array
{
return $this->getGuardian()->participants()->pluck('participants.id')->toArray();
return app(GuardianResolver::class)->participantIdsFor(auth()->user());
}
}
......@@ -3,6 +3,7 @@
namespace App\Livewire\Parent;
use App\Domain\Identity\Models\Guardian;
use App\Domain\Identity\Services\GuardianResolver;
use App\Domain\Participant\Models\Participant;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
......@@ -46,12 +47,16 @@ private function getGuardian(): Guardian
{
$user = auth()->user();
return Guardian::where('person_id', $user->person_id)->first()
// Display only. Authorisation and scoping go through GuardianResolver:
// ->first() on a non-unique column meant a guardian holding two rows
// saw one set of children and was 403'd on the rest, and an adult
// member with no guardian row hard-failed on every screen here.
return app(GuardianResolver::class)->guardiansFor($user)->first()
?? Guardian::where('user_id', $user->id)->firstOrFail();
}
private function getChildrenIds(): array
{
return $this->getGuardian()->participants()->pluck('participants.id')->toArray();
return app(GuardianResolver::class)->participantIdsFor(auth()->user());
}
}
......@@ -3,6 +3,7 @@
namespace App\Livewire\Parent;
use App\Domain\Identity\Models\Guardian;
use App\Domain\Identity\Services\GuardianResolver;
use App\Domain\Participant\Models\Participant;
use App\Domain\Training\Enums\SessionStatus;
use App\Domain\Training\Models\TrainingSchedule;
......@@ -122,12 +123,16 @@ private function getGuardian(): Guardian
{
$user = auth()->user();
return Guardian::where('person_id', $user->person_id)->first()
// Display only. Authorisation and scoping go through GuardianResolver:
// ->first() on a non-unique column meant a guardian holding two rows
// saw one set of children and was 403'd on the rest, and an adult
// member with no guardian row hard-failed on every screen here.
return app(GuardianResolver::class)->guardiansFor($user)->first()
?? Guardian::where('user_id', $user->id)->firstOrFail();
}
private function getChildrenIds(): array
{
return $this->getGuardian()->participants()->pluck('participants.id')->toArray();
return app(GuardianResolver::class)->participantIdsFor(auth()->user());
}
}
<?php
namespace App\Livewire\Portal\Concerns;
use App\Domain\Shared\Context\PortalContext;
/**
* What every portal screen shares: it authorises in mount(), and it reads its
* scope from PortalContext rather than from a route parameter or a public
* property.
*
* The `#[Locked]` distinction is not optional here. This project runs Livewire
* v4, where a plain `public` property is settable from the browser — so
* validating an id in mount() and then filtering queries on it in render() is
* an IDOR, not a check. Nothing in this namespace holds a participant id in a
* public property at all: the active member comes from the session through
* PortalContext, which re-validates it against GuardianResolver on every read.
*/
trait PortalScreen
{
protected function portal(): PortalContext
{
return app(PortalContext::class);
}
/**
* Gate the screen. Called from every mount().
*
* Two separate questions, and both have to be asked: does this account hold
* the permission, and does it speak for anybody at all. A staff account
* with portal.access but no children must not land in an empty portal
* pretending to be a member.
*/
protected function authorizePortal(?string $permission = null): void
{
$this->authorize($permission ?? 'portal.access');
if (! $this->portal()->speaksForAnyone()) {
abort(403, __('هذا الحساب غير مرتبط بأي عضو في الأكاديمية'));
}
}
/** The member a member-scoped surface is showing. */
protected function activeParticipantId(): int
{
$id = $this->portal()->participantId();
abort_if($id === null, 403, __('هذا الحساب غير مرتبط بأي عضو في الأكاديمية'));
return $id;
}
/**
* Every participant this account speaks for.
*
* Money is family-scoped and training is member-scoped — that split is the
* switcher's whole meaning, and it is decided here rather than eleven times
* over.
*
* @return array<int, int>
*/
protected function familyParticipantIds(): array
{
return $this->portal()->moneyParticipantIds();
}
}
<?php
namespace App\Livewire\Portal;
use App\Domain\Event\Models\Event;
use App\Domain\Event\Models\EventRegistration;
use App\Domain\Identity\Models\Branch;
use App\Domain\Training\Models\TrainingProgram;
use App\Domain\Website\Models\WebsiteNews;
use App\Livewire\Portal\Concerns\PortalScreen;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
/**
* Everything the academy publishes: programmes, events, news, branches.
*
* The content here is the same `website_news` and `events` the public site
* reads — deliberately. A second CMS for the app would be a second migration
* surface forever, and the channel column (S10) is what decides which of the
* two a given item appears on.
*/
#[Layout('layouts.portal')]
#[Title('الأكاديمية')]
class PortalAcademy extends Component
{
use PortalScreen;
/** news | events | programs | branches */
#[Url(as: 'tab')]
public string $tab = 'news';
public function mount(): void
{
$this->authorizePortal();
}
public function updatedTab(): void
{
if (! in_array($this->tab, ['news', 'events', 'programs', 'branches'], true)) {
$this->tab = 'news';
}
}
public function render()
{
$familyIds = $this->familyParticipantIds();
return view('livewire.portal.portal-academy', [
'news' => $this->tab === 'news'
? WebsiteNews::whereNotNull('published_at')
->where('published_at', '<=', now())
->forChannel('app')
->orderByDesc('published_at')
->limit(20)
->get()
: collect(),
'events' => $this->tab === 'events'
? Event::where('status', 'published')
->where(fn ($q) => $q->whereNull('ends_at')->orWhere('ends_at', '>=', now()))
->orderBy('starts_at')
->limit(20)
->get()
: collect(),
// event_registrations linked only a person_id, so "which of my
// children is registered" was unanswerable as modelled. S1 added
// participant_id; this is the first screen that needs it.
'myRegistrations' => $this->tab === 'events'
? EventRegistration::whereIn('participant_id', $familyIds)
->pluck('status', 'event_id')
: collect(),
'programs' => $this->tab === 'programs'
? TrainingProgram::where('is_active', true)
->with('activity')
->orderBy('name_ar')
->limit(30)
->get()
: collect(),
'branches' => $this->tab === 'branches'
? Branch::where('is_active', true)->orderByDesc('is_main')->get()
: collect(),
]);
}
}
<?php
namespace App\Livewire\Portal;
use App\Domain\Document\Models\Document;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Models\ServiceRequest;
use App\Livewire\Portal\Concerns\PortalScreen;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
/**
* The account: who this is, what documents are on file, what has been asked
* for, and the controls that belong to the person rather than the academy.
*/
#[Layout('layouts.portal')]
#[Title('حسابي')]
class PortalAccount extends Component
{
use PortalScreen;
/** profile | documents | requests | settings */
#[Url(as: 'tab')]
public string $tab = 'profile';
public function mount(): void
{
$this->authorizePortal();
}
public function updatedTab(): void
{
if (! in_array($this->tab, ['profile', 'documents', 'requests', 'settings'], true)) {
$this->tab = 'profile';
}
}
public function render()
{
$familyIds = $this->familyParticipantIds();
$user = auth()->user();
return view('livewire.portal.portal-account', [
'profiles' => $this->portal()->switchableProfiles(),
'user' => $user,
'documents' => $this->tab === 'documents'
? Document::where('documentable_type', Participant::class)
->whereIn('documentable_id', $familyIds)
->with('documentable.person')
->orderByDesc('created_at')
->get()
: collect(),
'requests' => $this->tab === 'requests'
// The closure is load-bearing: an unwrapped orWhere lets the
// first branch escape the tenant global scope, which is the
// exact shape of the bug in PermissionService::224.
? ServiceRequest::where(function ($q) use ($user, $familyIds) {
$q->where('user_id', $user?->id)->orWhereIn('participant_id', $familyIds);
})
->with('participant.person')
->orderByDesc('created_at')
->limit(30)
->get()
: collect(),
]);
}
}
<?php
namespace App\Livewire\Portal;
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Document\Models\Document;
use App\Domain\Financial\Models\Installment;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Participant\Models\Participant;
use App\Domain\Training\Enums\SessionStatus;
use App\Domain\Training\Models\TrainingSession;
use App\Livewire\Portal\Concerns\PortalScreen;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
/**
* Today, the next session, and one action list.
*
* The action list is the point of the screen: مستحقات, مستند منتهي, قسط
* مستحق. A member opens this app to find out whether anything needs doing,
* and everything that does is on one surface rather than spread across five
* tabs waiting to be discovered.
*/
#[Layout('layouts.portal')]
#[Title('الرئيسية')]
class PortalHome extends Component
{
use PortalScreen;
public function mount(): void
{
$this->authorizePortal();
}
public function render()
{
$portal = $this->portal();
$activeId = $this->activeParticipantId();
$familyIds = $this->familyParticipantIds();
$active = $portal->activeParticipant();
$groupIds = $active
? $active->activeEnrollments()->pluck('training_group_id')->all()
: [];
return view('livewire.portal.portal-home', [
'greeting' => now()->hour < 12 ? __('صباح الخير') : __('مساء الخير'),
'activeChild' => $active,
'nextSession' => $this->nextSession($groupIds),
'attendanceRate' => $this->attendanceRateThisMonth($activeId),
'streak' => $this->currentStreak($activeId),
// Money is family-scoped: a guardian asking what is owed is asking
// about the household, not about whichever child is selected.
'outstanding' => (int) Invoice::whereIn('billable_id', $familyIds)
->where('billable_type', Participant::class)
->whereIn('status', ['sent', 'partially_paid', 'overdue'])
->sum('due_amount'),
'actions' => $this->actionList($activeId, $familyIds),
]);
}
private function nextSession(array $groupIds): ?TrainingSession
{
if ($groupIds === []) {
return null;
}
return TrainingSession::whereIn('training_group_id', $groupIds)
->where('status', SessionStatus::Scheduled)
->where(function ($q) {
$q->where('session_date', '>', now()->toDateString())
->orWhere(function ($q2) {
$q2->where('session_date', now()->toDateString())
->where('start_time', '>=', now()->format('H:i:s'));
});
})
->with(['group.program', 'facility', 'trainer.person'])
->orderBy('session_date')
->orderBy('start_time')
->first();
}
private function attendanceRateThisMonth(int $participantId): ?float
{
$from = now()->startOfMonth()->toDateString();
$to = now()->endOfMonth()->toDateString();
$base = fn () => AttendanceRecord::where('subject_type', Participant::class)
->where('subject_id', $participantId)
->whereHas('session', fn ($q) => $q->whereBetween('session_date', [$from, $to]));
// Cancelled and exempt sessions are not a chance to attend, so counting
// them would report a rate the member could not have changed.
$total = $base()->whereNotIn('status', ['cancelled', 'exempt'])->count();
if ($total === 0) {
return null;
}
$positive = $base()->whereIn('status', ['present', 'late', 'partial'])->count();
return round($positive / $total * 100, 1);
}
/** Consecutive attended sessions, most recent first, stopping at the first miss. */
private function currentStreak(int $participantId): int
{
$recent = AttendanceRecord::where('subject_type', Participant::class)
->where('subject_id', $participantId)
->whereIn('status', ['present', 'late', 'absent', 'no_show'])
->orderByDesc('marked_at')
->limit(40)
->pluck('status');
$streak = 0;
foreach ($recent as $status) {
$value = $status instanceof \BackedEnum ? $status->value : (string) $status;
if (! in_array($value, ['present', 'late'], true)) {
break;
}
$streak++;
}
return $streak;
}
/**
* Everything that needs the member to do something, newest problem first.
*
* @return array<int, array{tone: string, label: string, detail: string, route: ?string}>
*/
private function actionList(int $activeId, array $familyIds): array
{
$actions = [];
$due = (int) Invoice::whereIn('billable_id', $familyIds)
->where('billable_type', Participant::class)
->whereIn('status', ['sent', 'partially_paid', 'overdue'])
->sum('due_amount');
if ($due > 0) {
$overdue = Invoice::whereIn('billable_id', $familyIds)
->where('billable_type', Participant::class)
->where('status', 'overdue')
->exists();
$actions[] = [
'tone' => $overdue ? 'danger' : 'warning',
'label' => $overdue ? __('مستحقات متأخرة') : __('مستحقات'),
'detail' => format_money($due),
'route' => route('portal.payments'),
];
}
$nextInstallment = Installment::whereHas('paymentPlan.invoice', function ($q) use ($familyIds) {
$q->whereIn('billable_id', $familyIds)->where('billable_type', Participant::class);
})
->whereIn('status', ['pending', 'overdue'])
->orderBy('due_date')
->first();
if ($nextInstallment) {
$actions[] = [
'tone' => $nextInstallment->due_date?->isPast() ? 'danger' : 'info',
'label' => __('قسط مستحق'),
'detail' => format_money((int) $nextInstallment->amount) . ' — ' . $nextInstallment->due_date?->translatedFormat('j M'),
'route' => route('portal.payments'),
];
}
// A medical certificate expires at 06:00 on a nightly job and the
// member had no in-product way to fix it. This is the first half of
// that: telling them before a coach turns them away at the gate.
$expiring = Document::where('documentable_type', Participant::class)
->whereIn('documentable_id', $familyIds)
->whereNotNull('expires_at')
->where('expires_at', '<=', now()->addDays(30)->toDateString())
->orderBy('expires_at')
->first();
if ($expiring) {
$expired = $expiring->expires_at?->isPast();
$actions[] = [
'tone' => $expired ? 'danger' : 'warning',
'label' => $expired ? __('مستند منتهي') : __('مستند على وشك الانتهاء'),
'detail' => $expiring->document_type->label() . ' — ' . $expiring->expires_at?->translatedFormat('j M Y'),
'route' => route('portal.account'),
];
}
return $actions;
}
}
<?php
namespace App\Livewire\Portal;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Participant\Models\Participant;
use App\Livewire\Portal\Concerns\PortalScreen;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Component;
/**
* One invoice, with the line items frozen as they were issued.
*
* The invoice arrives by route-model binding on its uuid and is re-authorised
* against the account's own participants — a route parameter is a browser input
* like any other. The id is `#[Locked]` on top of that: in Livewire v4 a plain
* public property is settable from the page, so a check in mount() that is not
* repeated in render() is decoration.
*/
#[Layout('layouts.portal')]
#[Title('الفاتورة')]
class PortalInvoice extends Component
{
use PortalScreen;
#[Locked]
public int $invoiceId;
public function mount(string $invoice): void
{
$this->authorizePortal();
$found = Invoice::where('uuid', $invoice)->firstOrFail();
$this->assertOwned($found);
$this->invoiceId = $found->id;
}
public function render()
{
$invoice = Invoice::with(['items', 'payments'])->findOrFail($this->invoiceId);
// Re-checked here, not only in mount(): ownership can change between
// requests, and this is the query the page actually renders from.
$this->assertOwned($invoice);
return view('livewire.portal.portal-invoice', [
'invoice' => $invoice,
'canPay' => $invoice->billable_type === Participant::class
&& $this->portal()->mayPayFor((int) $invoice->billable_id),
]);
}
private function assertOwned(Invoice $invoice): void
{
abort_unless(
$invoice->billable_type === Participant::class
&& in_array((int) $invoice->billable_id, $this->familyParticipantIds(), true),
403
);
}
}
<?php
namespace App\Livewire\Portal;
use App\Domain\Notification\Models\NotificationLog;
use App\Livewire\Portal\Concerns\PortalScreen;
use App\Models\User;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
use Livewire\WithPagination;
/**
* Notification history and read state.
*
* Reads notification_logs directly rather than inventing a second store: the
* twelve existing listeners already write here, and until S1 widened the
* channel CHECK every one of those writes with channel 'push' raised a 23514.
*/
#[Layout('layouts.portal')]
#[Title('الإشعارات')]
class PortalNotifications extends Component
{
use PortalScreen, WithPagination;
public function mount(): void
{
$this->authorizePortal();
}
public function markRead(int $id): void
{
// Scoped by recipient, so an id from the browser cannot reach another
// member's notification.
NotificationLog::where('id', $id)
->where('recipient_type', User::class)
->where('recipient_id', auth()->id())
->whereNull('read_at')
->update(['read_at' => now()]);
}
public function markAllRead(): void
{
NotificationLog::where('recipient_type', User::class)
->where('recipient_id', auth()->id())
->whereNull('read_at')
->update(['read_at' => now()]);
}
public function render()
{
$query = NotificationLog::where('recipient_type', User::class)
->where('recipient_id', auth()->id())
->orderByDesc('created_at');
return view('livewire.portal.portal-notifications', [
'logs' => $query->paginate(20),
'unread' => (clone $query)->whereNull('read_at')->count(),
]);
}
}
<?php
namespace App\Livewire\Portal;
use App\Domain\Attendance\Services\CheckInTokenService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Support\QrCode;
use App\Livewire\Portal\Concerns\PortalScreen;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
/**
* The member's rotating check-in pass.
*
* Rendered server-side as an inline SVG — no CDN, no third party, and it keeps
* working when the venue's wifi does not. The one QR that exists in this
* codebase today is an <img> pointing at api.qrserver.com on an admin event
* page, which would send every member's identity token to someone else's server.
*
* The code is fetched over the Livewire round-trip and never appears in a URL,
* so it stays out of browser history, referrer headers and proxy logs.
*
* It is per active profile: a guardian with three children needs three passes,
* which is why this is an affordance in the header rather than a bottom tab.
*/
#[Layout('layouts.portal')]
#[Title('بطاقة الدخول')]
class PortalPass extends Component
{
use PortalScreen;
public ?string $error = null;
public function mount(): void
{
$this->authorizePortal();
}
public function render()
{
$participant = $this->portal()->activeParticipant();
$svg = null;
$secondsLeft = null;
if ($participant) {
try {
$tokens = app(CheckInTokenService::class);
$token = $tokens->issue($participant);
$svg = QrCode::svg($token, 260, 4, __('رمز دخول العضو'));
// How long this code stays valid, so the screen can count down
// rather than silently going stale in someone's hand.
$secondsLeft = CheckInTokenService::PERIOD_SECONDS
- (time() % CheckInTokenService::PERIOD_SECONDS);
} catch (DomainException $e) {
$this->error = $e->getMessage();
}
}
return view('livewire.portal.portal-pass', [
'participant' => $participant,
'svg' => $svg,
'secondsLeft' => $secondsLeft,
]);
}
}
<?php
namespace App\Livewire\Portal;
use App\Domain\Financial\Models\Installment;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\Payment;
use App\Domain\Financial\Models\Wallet;
use App\Domain\Participant\Models\Participant;
use App\Livewire\Portal\Concerns\PortalScreen;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
/**
* Money, and money is family-scoped.
*
* Three sections, not three screens: عليك (what is owed — invoices and
* instalments), السجل (what has been paid), المحفظة (the balance held). The
* old portal had ParentFinances quietly ignoring the child switcher and
* aggregating every child, which is the domain saying that a household has one
* balance even when it has three players.
*/
#[Layout('layouts.portal')]
#[Title('المدفوعات')]
class PortalPayments extends Component
{
use PortalScreen, WithPagination;
/** due | history | wallet */
#[Url(as: 'tab')]
public string $tab = 'due';
public function mount(): void
{
$this->authorizePortal();
}
public function updatedTab(): void
{
if (! in_array($this->tab, ['due', 'history', 'wallet'], true)) {
$this->tab = 'due';
}
$this->resetPage();
}
public function render()
{
$familyIds = $this->familyParticipantIds();
$outstanding = Invoice::whereIn('billable_id', $familyIds)
->where('billable_type', Participant::class)
->whereIn('status', ['sent', 'partially_paid', 'overdue'])
->with('items')
->orderBy('due_date');
$history = Payment::whereIn('payer_id', $familyIds)
->where('payer_type', Participant::class)
->where('direction', 'inbound')
->with('invoice')
->orderByDesc('payment_date')
->orderByDesc('id');
return view('livewire.portal.portal-payments', [
'totalDue' => (int) Invoice::whereIn('billable_id', $familyIds)
->where('billable_type', Participant::class)
->whereIn('status', ['sent', 'partially_paid', 'overdue'])
->sum('due_amount'),
'invoices' => $this->tab === 'due' ? $outstanding->paginate(10) : collect(),
'payments' => $this->tab === 'history' ? $history->paginate(10) : collect(),
'installments' => $this->tab === 'due'
? Installment::whereHas('paymentPlan.invoice', function ($q) use ($familyIds) {
$q->whereIn('billable_id', $familyIds)->where('billable_type', Participant::class);
})
->whereIn('status', ['pending', 'overdue'])
->with('paymentPlan.invoice')
->orderBy('due_date')
->get()
: collect(),
'wallets' => $this->tab === 'wallet'
? Wallet::where('owner_type', Participant::class)
->whereIn('owner_id', $familyIds)
->with('owner.person')
->get()
: collect(),
// can_authorize_payment has existed since 2024 with zero readers,
// so every guardian has had full financial access to every linked
// child. The portal is where that column finally gates something.
'payableIds' => $this->portal()->payableParticipantIds(),
]);
}
}
<?php
namespace App\Livewire\Portal;
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Participant\Models\Participant;
use App\Domain\Training\Enums\EvaluationStatus;
use App\Domain\Training\Models\Evaluation;
use App\Domain\Training\Models\TrainingSession;
use App\Livewire\Portal\Concerns\PortalScreen;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
/**
* One session-centric timeline rather than separate schedule and attendance
* screens.
*
* A member does not think "my schedule" and "my attendance" — they think about
* a session: when it is, who is taking it, whether they were there, whether
* they can be excused from it. Splitting that across two tabs is what made the
* old portal feel like a database.
*
* Trainer identity and `substitute_reason` are loaded because a parent whose
* child had a different coach on Tuesday is owed the reason, and the columns
* have always been there unread. Cancellations carry `cancelled_reason` for
* the same reason: an empty week must not read the same as Eid.
*/
#[Layout('layouts.portal')]
#[Title('التدريب')]
class PortalTraining extends Component
{
use PortalScreen;
/** upcoming | past */
#[Url(as: 'v')]
public string $view = 'upcoming';
public function mount(): void
{
$this->authorizePortal();
}
public function updatedView(): void
{
if (! in_array($this->view, ['upcoming', 'past'], true)) {
$this->view = 'upcoming';
}
}
public function render()
{
$participantId = $this->activeParticipantId();
$participant = $this->portal()->activeParticipant();
$groupIds = $participant
? $participant->activeEnrollments()->pluck('training_group_id')->all()
: [];
$sessions = collect();
$attendance = collect();
if ($groupIds !== []) {
$query = TrainingSession::whereIn('training_group_id', $groupIds)
->with(['group.program', 'facility', 'trainer.person', 'assistantTrainer.person']);
$sessions = $this->view === 'upcoming'
? $query->where('session_date', '>=', now()->toDateString())
->orderBy('session_date')->orderBy('start_time')->limit(30)->get()
: $query->where('session_date', '<', now()->toDateString())
->orderByDesc('session_date')->orderByDesc('start_time')->limit(30)->get();
// One query for every session on screen rather than one per row.
$attendance = AttendanceRecord::where('subject_type', Participant::class)
->where('subject_id', $participantId)
->whereIn('training_session_id', $sessions->pluck('id'))
->get()
->keyBy('training_session_id');
}
return view('livewire.portal.portal-training', [
'sessions' => $sessions,
'attendance' => $attendance,
'evaluations' => Evaluation::where('participant_id', $participantId)
->where('status', EvaluationStatus::Shared)
->with('group')
->orderByDesc('evaluation_date')
->limit(5)
->get(),
]);
}
}
<?php
namespace App\Livewire\Portal;
use App\Domain\Shared\Context\PortalContext;
use Livewire\Component;
/**
* The profile switcher is a scope control, not a screen.
*
* It writes one session key and dispatches a browser event; every screen reads
* the value back through PortalContext, which re-validates it against
* GuardianResolver. Nothing here trusts the id it was handed.
*/
class ProfileSwitcher extends Component
{
public function select(int $participantId): void
{
// PortalContext::set() ignores an id this account does not speak for,
// so a crafted payload changes nothing.
app(PortalContext::class)->set($participantId);
$this->dispatch('portal-profile-changed');
$this->redirect(request()->header('Referer') ?: route('portal.home'), navigate: true);
}
public function render()
{
$portal = app(PortalContext::class);
return view('livewire.portal.profile-switcher', [
'profiles' => $portal->switchableProfiles(),
'activeId' => $portal->participantId(),
]);
}
}
......@@ -27,6 +27,12 @@ public function register(): void
// One account resolver too — it caches chart-of-accounts lookups by
// code for the life of the request.
$this->app->scoped(\App\Domain\Financial\Services\LedgerAccountResolver::class);
// The resolver memoises which participants an account speaks for, and
// the portal context memoises which of them is selected. Both are
// per-request, and a Livewire round-trip is its own request.
$this->app->scoped(\App\Domain\Identity\Services\GuardianResolver::class);
$this->app->scoped(\App\Domain\Shared\Context\PortalContext::class);
}
/**
......
<?php
return [
/*
|--------------------------------------------------------------------------
| Check-in pass secret
|--------------------------------------------------------------------------
|
| The pepper the rotating member pass is derived from. Deliberately NOT
| APP_KEY: APP_KEY decrypts sessions and cookies, and a check-in secret does
| not deserve that blast radius — if one leaks the other must survive.
|
| 32 bytes minimum. CheckInTokenService refuses to issue or verify anything
| without it rather than falling back to a weaker key, because a pass that
| anyone can forge is worse than no pass at all.
|
| Generate with: php -r "echo bin2hex(random_bytes(32));"
|
*/
'checkin_pepper' => env('CHECKIN_PEPPER', ''),
];
......@@ -48,6 +48,13 @@
'reports.*',
'export.*',
// The member-facing portal. It is scoped by membership, not by branch,
// and RequireBranchSelection runs on the whole web group — so without
// this any user holding branches.view_all in all-branches mode is
// bounced straight back out of the portal by middleware.
'portal.*',
'parent.*',
// Academy-level administration.
'branches.*',
'users.*',
......
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* There is no `player` role. A player given the `parent` role gets empty lists
* everywhere, because PermissionService::getChildParticipantIds() resolves the
* user's scope only through a Guardian row — and an adult member playing for
* himself has no guardian.
*
* Delivered as a migration, not a seeder: db:seed only runs when
* RUN_SEED_ON_FIRST_DEPLOY is true (docker/entrypoint.sh), so any client
* deployed outside the one-click template would never receive it. Same pattern
* and same reasoning as 2026_09_01_000001_add_branches_view_all_permission.
*
* Idempotent: every insert checks for its row first.
*/
return new class extends Migration
{
/** name => [module, action, description, description_ar] */
private const PERMISSIONS = [
'portal.access' => ['portal', 'access', 'Sign in to the member portal', 'الدخول إلى بوابة الأعضاء'],
'portal.pay' => ['portal', 'pay', 'Submit a payment or a payment proof', 'تسجيل دفعة أو إرسال إثبات تحويل'],
'portal.documents' => ['portal', 'documents', 'Upload and renew own documents', 'رفع وتجديد المستندات'],
'portal.requests' => ['portal', 'requests', 'Open a service request or an excuse', 'تقديم طلب أو عذر'],
'payments.approve_proof' => ['payments', 'approve_proof', 'Approve a transfer proof into a real payment', 'اعتماد إثبات التحويل وتحويله إلى دفعة'],
'attendance.scan' => ['attendance', 'scan', 'Scan a member pass to mark attendance', 'مسح بطاقة العضو لتسجيل الحضور'],
'users.merge' => ['users', 'merge', 'Merge duplicate member accounts', 'دمج الحسابات المكررة'],
];
/**
* Everything the `parent` role already holds, which a player needs for
* himself. The scope stays `own_children` rather than `own`: `own` means
* `created_by = user.id`, which is about authorship, not membership — a
* player did not create his own attendance records. GuardianResolver makes
* `own_children` mean "the participants this account speaks for", which for
* a player is himself.
*/
private const PLAYER_SCOPED = [
'attendance.list', 'evaluations.list', 'excuses.submit',
'invoices.list', 'invoices.view', 'participants.show', 'participants.view',
'payments.list', 'schedules.view', 'wallets.view',
'portal.access', 'portal.pay', 'portal.documents', 'portal.requests',
];
private const PLAYER_ACADEMY = ['dashboard.view', 'programs.list'];
/** The member-facing permissions the existing parent role also gains. */
private const PARENT_ADDITIONS = ['portal.access', 'portal.pay', 'portal.documents', 'portal.requests'];
private const STAFF_APPROVALS = ['payments.approve_proof', 'users.merge'];
private const STAFF_SCAN = ['attendance.scan'];
public function up(): void
{
if (! Schema::hasTable('permissions') || ! Schema::hasTable('roles') || ! Schema::hasTable('permission_role')) {
return;
}
$ids = [];
foreach (self::PERMISSIONS as $name => [$module, $action, $description, $descriptionAr]) {
$ids[$name] = DB::table('permissions')->where('name', $name)->value('id')
?: DB::table('permissions')->insertGetId([
'name' => $name,
'module' => $module,
'action' => $action,
'description' => $description,
'description_ar' => $descriptionAr,
'created_at' => now(),
]);
}
// Roles are per-academy rows, so this creates one player role per tenant.
foreach (DB::table('academies')->pluck('id') as $academyId) {
$playerId = DB::table('roles')
->where('academy_id', $academyId)
->where('slug', 'player')
->value('id');
if (! $playerId) {
$playerId = DB::table('roles')->insertGetId([
'academy_id' => $academyId,
'name' => 'Player',
'name_ar' => 'لاعب',
'slug' => 'player',
// Same level as parent: both are member-facing, neither
// outranks the other.
'level' => 5,
'description' => 'A member who plays for himself',
'is_system' => true,
'created_at' => now(),
'updated_at' => now(),
]);
}
$this->grant($playerId, self::PLAYER_SCOPED, 'own_children');
$this->grant($playerId, self::PLAYER_ACADEMY, 'academy');
$parentId = DB::table('roles')
->where('academy_id', $academyId)
->where('slug', 'parent')
->value('id');
if ($parentId) {
$this->grant($parentId, self::PARENT_ADDITIONS, 'own_children');
}
foreach (['academy_owner', 'academy_admin', 'accountant'] as $slug) {
$roleId = DB::table('roles')->where('academy_id', $academyId)->where('slug', $slug)->value('id');
if ($roleId) {
$this->grant($roleId, self::STAFF_APPROVALS, 'academy');
}
}
foreach (['academy_owner', 'academy_admin', 'branch_manager', 'receptionist', 'head_trainer', 'trainer'] as $slug) {
$roleId = DB::table('roles')->where('academy_id', $academyId)->where('slug', $slug)->value('id');
if ($roleId) {
$this->grant($roleId, self::STAFF_SCAN, 'branch');
}
}
}
// super_admin rows are not academy-scoped in the same way; grant everything.
foreach (DB::table('roles')->where('slug', 'super_admin')->pluck('id') as $roleId) {
$this->grant($roleId, array_keys(self::PERMISSIONS), 'all');
}
}
private function grant(int $roleId, array $permissionNames, string $scope): void
{
foreach ($permissionNames as $name) {
$permissionId = DB::table('permissions')->where('name', $name)->value('id');
if (! $permissionId) {
continue;
}
$exists = DB::table('permission_role')
->where('role_id', $roleId)
->where('permission_id', $permissionId)
->exists();
if (! $exists) {
DB::table('permission_role')->insert([
'role_id' => $roleId,
'permission_id' => $permissionId,
'scope' => $scope,
'created_at' => now(),
]);
}
}
}
public function down(): void
{
if (! Schema::hasTable('permissions')) {
return;
}
$ids = DB::table('permissions')->whereIn('name', array_keys(self::PERMISSIONS))->pluck('id');
if (Schema::hasTable('permission_role')) {
DB::table('permission_role')->whereIn('permission_id', $ids)->delete();
}
DB::table('permissions')->whereIn('id', $ids)->delete();
if (Schema::hasTable('roles')) {
$playerIds = DB::table('roles')->where('slug', 'player')->pluck('id');
DB::table('permission_role')->whereIn('role_id', $playerIds)->delete();
DB::table('roles')->whereIn('id', $playerIds)->delete();
}
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Admin-issued invitations are how a member gets a portal account: staff issue
* a link, the member sets a password, and phone-or-email plus password works
* from then on.
*
* Only the SHA-256 of the token is stored. A raw token in the database is a
* password in the database — and this one would also have rendered into the
* 500 error page's `$inputData` dump before that was gated.
*
* `users.email_is_synthetic` exists because most guardians have no email while
* `users.email` is NOT NULL UNIQUE — and that constraint cannot be relaxed:
* 2024_01_01_000002 declares it inside Schema::create, so Postgres emits a
* UNIQUE CONSTRAINT, which cannot be made partial without a DROP CONSTRAINT in
* up(); CREATE INDEX CONCURRENTLY cannot run inside a migration transaction;
* and password_reset_tokens.email is the primary key the broker keys on.
* So portal accounts get p{uuid}@portal.invalid — RFC 2606, guaranteed
* non-routable, so it can never bounce off the mail server — and every mail
* path skips a synthetic address.
*
* Deliberately NOT added: UNIQUE(academy_id, phone) on users.
* 2026_08_30_000004 logged that it left duplicates in place on live data and
* that they need a manual merge. A unique index would hard-fail on at least one
* client, and a failed migration then blocks every later migration on that
* client forever. Phone login rejects an ambiguous match instead, and the merge
* screen exists to clear the duplicates first.
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('portal_invitations')) {
Schema::create('portal_invitations', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->foreignId('academy_id')->constrained()->cascadeOnDelete();
$table->foreignId('person_id')->constrained('people')->cascadeOnDelete();
$table->foreignId('participant_id')->nullable()->constrained()->nullOnDelete();
// The token itself never touches the database.
$table->string('token_hash', 64)->unique();
$table->string('channel', 16)->default('link'); // link | whatsapp | email
$table->string('sent_to', 120)->nullable();
$table->timestamp('expires_at');
$table->timestamp('consumed_at')->nullable();
$table->foreignId('consumed_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamp('revoked_at')->nullable();
$table->foreignId('created_by')->constrained('users');
$table->string('created_ip', 45)->nullable();
$table->timestamps();
$table->index(['academy_id', 'person_id']);
$table->index(['academy_id', 'expires_at']);
});
if (DB::getDriverName() === 'pgsql') {
DB::statement("ALTER TABLE portal_invitations ADD CONSTRAINT portal_invitations_channel_check CHECK (channel IN ('link','whatsapp','email'))");
// One live invitation per person. Re-inviting revokes the old
// one rather than leaving two working links in the wild.
DB::statement('CREATE UNIQUE INDEX portal_invitations_one_live ON portal_invitations (academy_id, person_id) WHERE consumed_at IS NULL AND revoked_at IS NULL');
}
}
if (Schema::hasTable('users') && ! Schema::hasColumn('users', 'email_is_synthetic')) {
Schema::table('users', function (Blueprint $table) {
$table->boolean('email_is_synthetic')->default(false)->after('email');
$table->timestamp('portal_last_seen_at')->nullable()->after('email_is_synthetic');
});
// Anything already carrying a .invalid address predates this flag.
DB::table('users')->where('email', 'like', '%@portal.invalid')->update(['email_is_synthetic' => true]);
}
}
public function down(): void
{
Schema::dropIfExists('portal_invitations');
if (Schema::hasTable('users') && Schema::hasColumn('users', 'email_is_synthetic')) {
Schema::table('users', function (Blueprint $table) {
$table->dropColumn(['email_is_synthetic', 'portal_last_seen_at']);
});
}
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* One `channel` column instead of a second CMS.
*
* The original plan had a whole workstream for "Mobile App Content": new
* app_screens and app_blocks tables, a MobileBlockRegistry, mobile block
* classes, an editor. But website_sections, website_news, website_menus, media,
* the page builder and `website:blueprint export|import` all already exist and
* already do that job — a parallel CMS would be a second migration surface, a
* second editor to keep in step, and a second place for content to go missing,
* forever.
*
* So: the portal reads the same tables the public site does, and this column
* decides where an item appears. `both` is the default, because every piece of
* content that exists today was written for an audience that now includes the
* app.
*/
return new class extends Migration
{
private const TABLES = ['website_news', 'website_sections'];
public function up(): void
{
foreach (self::TABLES as $table) {
if (! Schema::hasTable($table) || Schema::hasColumn($table, 'channel')) {
continue;
}
Schema::table($table, function (Blueprint $t) {
$t->string('channel', 10)->default('both')->after('academy_id');
});
if (DB::getDriverName() === 'pgsql') {
DB::statement("ALTER TABLE {$table} ADD CONSTRAINT {$table}_channel_check CHECK (channel IN ('website','app','both'))");
}
Schema::table($table, function (Blueprint $t) use ($table) {
$t->index(['academy_id', 'channel'], "{$table}_academy_channel_index");
});
}
}
public function down(): void
{
foreach (self::TABLES as $table) {
if (Schema::hasTable($table) && Schema::hasColumn($table, 'channel')) {
if (DB::getDriverName() === 'pgsql') {
DB::statement("ALTER TABLE {$table} DROP CONSTRAINT IF EXISTS {$table}_channel_check");
}
Schema::table($table, function (Blueprint $t) {
$t->dropColumn('channel');
});
}
}
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Staff-scan check-in.
*
* `qr_check_in_enabled` has been a toggle in system settings and a seeded row
* since 2026_07_27 with **zero functional readers** — the product advertised a
* feature that did not exist. This is the schema half of building it.
*
* `participants.checkin_key_version` is the revocation mechanism: the pass
* secret is derived from it, so incrementing it kills every outstanding code
* for that member at once. One integer, atomic, auditable — no secret column to
* leak and no per-token revocation list to sweep.
*
* `checkin_consumptions` is what makes a screenshot worthless. The unique key
* on (academy, participant, counter) is checked by an INSERT … ON CONFLICT DO
* NOTHING inside the same transaction as the attendance write: zero rows
* inserted means the code was already used. A Cache::has/Cache::put pair would
* be a time-of-check-to-time-of-use race, and two scanners at one gate is
* exactly when it would lose.
*/
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('participants') && ! Schema::hasColumn('participants', 'checkin_key_version')) {
Schema::table('participants', function (Blueprint $table) {
$table->unsignedInteger('checkin_key_version')->default(1)->after('uuid');
});
}
if (! Schema::hasTable('checkin_consumptions')) {
Schema::create('checkin_consumptions', function (Blueprint $table) {
$table->id();
$table->foreignId('academy_id')->constrained()->cascadeOnDelete();
$table->foreignId('participant_id')->constrained()->cascadeOnDelete();
$table->foreignId('training_session_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('scanned_by')->constrained('users');
$table->foreignId('branch_id')->nullable()->constrained('branches')->nullOnDelete();
// The 30-second window the accepted code belonged to — not
// "now". Recording now would leave the accepted neighbouring
// counter reusable.
$table->bigInteger('counter');
$table->integer('skew_steps')->default(0);
$table->string('scanner_ip', 45)->nullable();
$table->timestamp('created_at')->nullable();
$table->unique(['academy_id', 'participant_id', 'counter'], 'checkin_consumptions_once');
$table->index(['academy_id', 'created_at']);
});
}
}
public function down(): void
{
Schema::dropIfExists('checkin_consumptions');
if (Schema::hasTable('participants') && Schema::hasColumn('participants', 'checkin_key_version')) {
Schema::table('participants', function (Blueprint $table) {
$table->dropColumn('checkin_key_version');
});
}
}
};
/*
The member portal's own bundle.
`source(none)` is the whole point. app.css and website.css are each a bare
`@import 'tailwindcss'` plus one @theme block, so Tailwind v4 auto-detects from
the project root and both emit the identical complete utility set — website.css
is app.css plus components. A third file written the same way would be a third
identical copy, and "its own bundle" would isolate nothing at all.
With source(none) plus explicit @source lines, this file contains only what the
portal's own templates use.
Never load this alongside app.css on one document: two Tailwind entrypoints mean
two preflights and a non-deterministic @layer order. The QR scanner screen is an
admin screen and builds with app.css.
*/
@import 'tailwindcss' source(none);
@source '../views/portal/**/*.blade.php';
@source '../views/livewire/portal/**/*.blade.php';
@source '../views/components/portal/**/*.blade.php';
@source '../../app/Livewire/Portal/**/*.php';
/*
Classes built by interpolation never appear whole in a template, so the scanner
cannot see them. These are the status and tone variants the portal composes.
*/
@source inline('bg-brand-{50,100,500,600,700} text-brand-{600,700,fg} border-brand-{200,300} ring-brand-500');
@source inline('bg-{emerald,amber,rose,slate,sky}-{50,100,500,600} text-{emerald,amber,rose,slate,sky}-{600,700,800} border-{emerald,amber,rose,slate,sky}-200');
/*
`inline` matters: without it Tailwind copies the variable's value at build time
and every tenant would get whatever colour was in the build. With it the utility
emits var(--brand-500), which the layout's server-rendered :root block fills in
per tenant, per request.
*/
@theme inline {
--color-brand-50: var(--brand-50);
--color-brand-100: var(--brand-100);
--color-brand-200: var(--brand-200);
--color-brand-300: var(--brand-300);
--color-brand-400: var(--brand-400);
--color-brand-500: var(--brand-500);
--color-brand-600: var(--brand-600);
--color-brand-700: var(--brand-700);
--color-brand-800: var(--brand-800);
--color-brand-900: var(--brand-900);
--color-brand-fg: var(--brand-fg);
--color-brand-success: var(--brand-success);
--color-brand-warning: var(--brand-warning);
--color-brand-danger: var(--brand-danger);
--font-sans: var(--brand-font-ar), var(--brand-font-en), ui-sans-serif, system-ui, sans-serif;
--radius-card: 1rem;
--radius-sheet: 1.5rem;
}
/* Bound to the class, not to prefers-color-scheme: the tenant's theme_mode
setting decides, and the OS does not get a vote it was never given. */
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
@layer base {
:root {
color-scheme: light;
}
[data-theme='dark'] {
color-scheme: dark;
}
body {
/* A phone-shaped surface, not a page. */
-webkit-tap-highlight-color: transparent;
overscroll-behavior-y: none;
}
/* Numbers read left-to-right even inside an RTL document. */
.num {
direction: ltr;
unicode-bidi: isolate;
font-variant-numeric: tabular-nums;
}
}
@layer components {
/* Safe areas: this runs full-bleed inside a WebView and under a notch. */
.pt-safe { padding-top: env(safe-area-inset-top, 0px); }
.pb-safe { padding-bottom: env(safe-area-inset-bottom, 0px); }
.portal-card {
border-radius: var(--radius-card);
background: var(--portal-surface);
border: 1px solid var(--portal-border);
}
/* 44x44 minimum with 8px between, per the accessibility gate. */
.tap-target {
min-width: 2.75rem;
min-height: 2.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
}
.rail {
display: flex;
gap: 0.75rem;
overflow-x: auto;
scroll-snap-type: x mandatory;
scrollbar-width: none;
padding-inline: 1rem;
margin-inline: -1rem;
}
.rail::-webkit-scrollbar { display: none; }
.rail > * { scroll-snap-align: start; flex: 0 0 auto; }
}
/*
Every animation here is a spatial cue — where a card went — and every one of
them stops for a viewer who has asked for less motion.
*/
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
}
@layer utilities {
.sheet-enter {
animation: portal-sheet-in 260ms cubic-bezier(0.32, 0.72, 0, 1);
}
@keyframes portal-sheet-in {
from { transform: translateY(8%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.stagger-in > * {
animation: portal-fade-up 320ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.stagger-in > *:nth-child(1) { animation-delay: 0ms; }
.stagger-in > *:nth-child(2) { animation-delay: 40ms; }
.stagger-in > *:nth-child(3) { animation-delay: 80ms; }
.stagger-in > *:nth-child(4) { animation-delay: 120ms; }
.stagger-in > *:nth-child(n+5) { animation-delay: 160ms; }
@keyframes portal-fade-up {
from { transform: translateY(6px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
}
import focus from '@alpinejs/focus';
document.addEventListener('livewire:init', () => {
Livewire.hook('alpine:init', ({ Alpine }) => {
Alpine.plugin(focus);
});
/*
* A cached app shell outlives the session that produced it. When the
* session has rotated, the next Livewire request comes back 419 and the
* page becomes a dead surface: buttons stop responding and nothing on
* screen says why. Reloading is the only honest recovery — the server
* decides where the member goes next.
*/
Livewire.hook('request', ({ fail }) => {
fail(({ status, preventDefault }) => {
if (status === 419) {
preventDefault();
window.location.reload();
}
});
});
});
/*
* 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
* inline script in the layout; this only keeps it in step when the OS changes
* while the app is open.
*/
document.addEventListener('DOMContentLoaded', () => {
const root = document.documentElement;
if (root.dataset.themeMode !== 'auto' || !window.matchMedia) {
return;
}
const query = window.matchMedia('(prefers-color-scheme: dark)');
const apply = () => {
root.setAttribute('data-theme', query.matches ? 'dark' : 'light');
};
apply();
query.addEventListener('change', apply);
});
@props(['title' => null, 'action' => null, 'actionLabel' => null, 'padded' => true])
<section {{ $attributes->merge(['class' => 'portal-card overflow-hidden']) }}>
@if($title)
<header class="flex items-center justify-between gap-3 px-4 pt-4">
<h2 class="text-sm font-bold">{{ $title }}</h2>
@if($action)
<a href="{{ $action }}" wire:navigate class="text-xs font-semibold" style="color: var(--brand-600);">
{{ $actionLabel ?? __('عرض الكل') }}
</a>
@endif
</header>
@endif
<div @class(['px-4 pb-4', 'pt-3' => $title, 'pt-4' => ! $title, 'p-0' => ! $padded])>
{{ $slot }}
</div>
</section>
@props(['title', 'body' => null, 'icon' => 'M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25'])
{{-- An empty screen must say why it is empty. "No sessions" and "no sessions
because it is Eid" are different facts, and a blank panel reads as a bug. --}}
<div class="flex flex-col items-center gap-2 py-10 text-center">
<svg class="h-10 w-10" fill="none" stroke="currentColor" stroke-width="1.3"
viewBox="0 0 24 24" aria-hidden="true" style="color: var(--portal-muted); opacity: .6;">
<path stroke-linecap="round" stroke-linejoin="round" d="{{ $icon }}"/>
</svg>
<p class="text-sm font-semibold">{{ $title }}</p>
@if($body)
<p class="max-w-64 text-xs leading-relaxed" style="color: var(--portal-muted);">{{ $body }}</p>
@endif
{{ $slot }}
</div>
@props(['label', 'value', 'tone' => 'neutral', 'sub' => null])
@php
$color = match ($tone) {
'danger' => 'var(--brand-danger)',
'warning' => 'var(--brand-warning)',
'success' => 'var(--brand-success)',
'brand' => 'var(--brand-600)',
default => 'var(--portal-ink)',
};
@endphp
<div class="portal-card px-3 py-3">
<p class="text-[11px] font-medium" style="color: var(--portal-muted);">{{ $label }}</p>
{{-- Large type for the things members actually open the app to check. --}}
<p class="num mt-1 text-2xl font-extrabold leading-none" style="color: {{ $color }};">{{ $value }}</p>
@if($sub)
<p class="mt-1 text-[11px]" style="color: var(--portal-muted);">{{ $sub }}</p>
@endif
</div>
@props(['tabs', 'active', 'property' => 'tab'])
{{-- Segmented control. Real state, not links: each writes a #[Url] property so
the tab survives a refresh and a shared link opens where the sender was. --}}
<div role="tablist" class="mb-4 flex gap-1 rounded-xl p-1" style="background: color-mix(in oklab, var(--portal-border) 45%, transparent);">
@foreach($tabs as $key => $label)
<button type="button" role="tab"
wire:click="$set('{{ $property }}', '{{ $key }}')"
aria-selected="{{ $active === $key ? 'true' : 'false' }}"
class="flex-1 rounded-lg px-2 py-2 text-xs font-semibold transition-colors"
style="{{ $active === $key
? 'background: var(--portal-surface); color: var(--brand-700); box-shadow: 0 1px 2px rgba(0,0,0,.06);'
: 'color: var(--portal-muted);' }}">
{{ $label }}
</button>
@endforeach
</div>
This diff is collapsed.
<div class="space-y-4">
<x-portal.tabs :tabs="[
'news' => __('الأخبار'),
'events' => __('الفعاليات'),
'programs' => __('البرامج'),
'branches' => __('الفروع'),
]" :active="$tab" />
@if($tab === 'news')
@forelse($news as $item)
<article class="portal-card px-4 py-3">
<p class="text-sm font-bold">{{ $item->title }}</p>
<p class="mt-0.5 text-[11px]" style="color: var(--portal-muted);">
{{ $item->published_at?->translatedFormat('j M Y') }}
@if($item->category) · {{ $item->category }} @endif
</p>
@if($item->excerpt)
<p class="mt-2 text-xs leading-relaxed" style="color: var(--portal-muted);">{{ $item->excerpt }}</p>
@endif
</article>
@empty
<x-portal.card><x-portal.empty :title="__('لا توجد أخبار منشورة')" /></x-portal.card>
@endforelse
@elseif($tab === 'events')
@forelse($events as $event)
@php $registration = $myRegistrations[$event->id] ?? null; @endphp
<article class="portal-card px-4 py-3">
<div class="flex items-start justify-between gap-2">
<p class="min-w-0 text-sm font-bold">{{ $event->title }}</p>
@if($registration)
{{-- Answerable only since event_registrations gained a
participant_id: it linked a person, so "which of my
children is registered" had no answer. --}}
<span class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold"
style="background: color-mix(in oklab, var(--brand-success) 14%, var(--portal-surface)); color: var(--brand-success);">
{{ __('مسجَّل') }}
</span>
@endif
</div>
<p class="num mt-0.5 text-[11px]" style="color: var(--portal-muted);">
{{ $event->starts_at?->translatedFormat('j M Y — H:i') }}
@if($event->location_name) · {{ $event->location_name }} @endif
</p>
@if($event->description)
<p class="mt-2 line-clamp-3 text-xs leading-relaxed" style="color: var(--portal-muted);">{{ strip_tags($event->description) }}</p>
@endif
</article>
@empty
<x-portal.card><x-portal.empty :title="__('لا توجد فعاليات قادمة')" /></x-portal.card>
@endforelse
@elseif($tab === 'programs')
@forelse($programs as $program)
<article class="portal-card px-4 py-3">
<p class="text-sm font-bold">{{ $program->name_ar ?? $program->name }}</p>
@if($program->activity)
<p class="mt-0.5 text-[11px]" style="color: var(--portal-muted);">
{{ $program->activity->name_ar ?? $program->activity->name }}
</p>
@endif
{{-- No price is shown here on purpose. Every price is computed at
sale time by PricingService, and there is no active base
price to fall back on — showing a guess would be worse than
showing none. --}}
</article>
@empty
<x-portal.card><x-portal.empty :title="__('لا توجد برامج متاحة')" /></x-portal.card>
@endforelse
@else
@forelse($branches as $branch)
<article class="portal-card px-4 py-3">
<p class="text-sm font-bold">{{ $branch->name_ar ?? $branch->name }}</p>
@if($branch->address)
<p class="mt-0.5 text-xs" style="color: var(--portal-muted);">{{ $branch->address }}</p>
@endif
<div class="mt-2 flex flex-wrap gap-2">
@if($branch->phone)
<a href="tel:{{ $branch->phone }}"
class="num rounded-lg px-3 py-1.5 text-xs font-semibold"
style="background: var(--brand-50); color: var(--brand-700);">
{{ $branch->phone }}
</a>
@endif
@if($branch->latitude && $branch->longitude)
<a href="https://www.google.com/maps/search/?api=1&query={{ $branch->latitude }},{{ $branch->longitude }}"
target="_blank" rel="noopener noreferrer"
class="rounded-lg px-3 py-1.5 text-xs font-semibold"
style="background: var(--brand-50); color: var(--brand-700);">
{{ __('الاتجاهات') }}
</a>
@endif
</div>
</article>
@empty
<x-portal.card><x-portal.empty :title="__('لا توجد فروع')" /></x-portal.card>
@endforelse
@endif
</div>
<div class="space-y-4">
<x-portal.tabs :tabs="[
'profile' => __('الملف'),
'documents' => __('المستندات'),
'requests' => __('طلباتي'),
'settings' => __('الإعدادات'),
]" :active="$tab" />
@if($tab === 'profile')
<x-portal.card :title="__('الحساب')">
<dl class="space-y-2 text-sm">
<div class="flex justify-between gap-3">
<dt style="color: var(--portal-muted);">{{ __('الاسم') }}</dt>
<dd class="truncate font-semibold">{{ $user?->name_ar ?: $user?->name }}</dd>
</div>
@if($user?->phone)
<div class="flex justify-between gap-3">
<dt style="color: var(--portal-muted);">{{ __('الهاتف') }}</dt>
<dd class="num font-semibold">{{ $user->phone }}</dd>
</div>
@endif
@if($user && ! $user->email_is_synthetic)
{{-- A synthetic .invalid address is an internal placeholder,
not something to show someone as their email. --}}
<div class="flex justify-between gap-3">
<dt style="color: var(--portal-muted);">{{ __('البريد') }}</dt>
<dd class="num truncate font-semibold">{{ $user->email }}</dd>
</div>
@endif
</dl>
</x-portal.card>
<x-portal.card :title="__('الأعضاء المرتبطون')">
<ul class="space-y-2">
@foreach($profiles as $profile)
<li class="flex items-center gap-3">
<span class="grid h-9 w-9 shrink-0 place-items-center rounded-full text-xs font-bold"
style="background: var(--brand-50); color: var(--brand-700);">
{{ mb_substr($profile->person?->name_ar ?? '—', 0, 1) }}
</span>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold">{{ $profile->person?->name_ar ?? $profile->person?->name }}</p>
<p class="text-[11px]" style="color: var(--portal-muted);">
{{ $profile->membership_type === 'member' ? __('عضو') : __('غير عضو') }}
</p>
</div>
</li>
@endforeach
</ul>
</x-portal.card>
@elseif($tab === 'documents')
@forelse($documents as $document)
@php
$expired = $document->expires_at?->isPast();
$soon = ! $expired && $document->expires_at && $document->expires_at->lte(now()->addDays(30));
$tone = $expired ? 'var(--brand-danger)' : ($soon ? 'var(--brand-warning)' : 'var(--brand-success)');
@endphp
<article class="portal-card px-4 py-3">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate text-sm font-bold">{{ $document->document_type->label() }}</p>
<p class="truncate text-[11px]" style="color: var(--portal-muted);">
{{ $document->documentable?->person?->name_ar }}
</p>
</div>
<span class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold"
style="background: color-mix(in oklab, {{ $tone }} 14%, var(--portal-surface)); color: {{ $tone }};">
{{ $document->status->label() }}
</span>
</div>
@if($document->expires_at)
<p class="num mt-1 text-[11px]" style="color: {{ $tone }};">
{{ $expired ? __('انتهت في') : __('تنتهي في') }} {{ $document->expires_at->translatedFormat('j M Y') }}
</p>
@endif
</article>
@empty
<x-portal.card>
<x-portal.empty :title="__('لا توجد مستندات')"
:body="__('تظهر هنا المستندات المرفوعة وحالة اعتمادها وتاريخ انتهائها')" />
</x-portal.card>
@endforelse
@elseif($tab === 'requests')
@forelse($requests as $request)
@php
$tone = match ($request->status) {
'approved' => 'var(--brand-success)',
'rejected' => 'var(--brand-danger)',
default => 'var(--brand-warning)',
};
@endphp
<article class="portal-card px-4 py-3">
<div class="flex items-start justify-between gap-2">
<p class="min-w-0 truncate text-sm font-bold">{{ $request->type }}</p>
<span class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold"
style="background: color-mix(in oklab, {{ $tone }} 14%, var(--portal-surface)); color: {{ $tone }};">
{{ $request->status }}
</span>
</div>
@if($request->participant)
<p class="mt-0.5 truncate text-[11px]" style="color: var(--portal-muted);">
{{ $request->participant->person?->name_ar }}
</p>
@endif
@if($request->reason)
<p class="mt-1 text-xs leading-relaxed" style="color: var(--portal-muted);">{{ $request->reason }}</p>
@endif
@if($request->admin_notes)
<p class="mt-2 rounded-lg px-2 py-1 text-[11px]"
style="background: color-mix(in oklab, {{ $tone }} 10%, var(--portal-surface)); color: {{ $tone }};">
{{ $request->admin_notes }}
</p>
@endif
<p class="mt-1 text-[11px]" style="color: var(--portal-muted);">{{ $request->created_at?->diffForHumans() }}</p>
</article>
@empty
<x-portal.card><x-portal.empty :title="__('لا توجد طلبات')" /></x-portal.card>
@endforelse
@else
<x-portal.card :title="__('الإعدادات')">
<ul class="divide-y" style="border-color: var(--portal-border);">
<li>
<a href="{{ route('portal.notifications') }}" wire:navigate
class="flex items-center justify-between py-3 text-sm font-semibold">
{{ __('الإشعارات') }}
<svg class="h-4 w-4 rtl:rotate-180" fill="none" stroke="currentColor" stroke-width="2"
viewBox="0 0 24 24" aria-hidden="true" style="color: var(--portal-muted);">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/>
</svg>
</a>
</li>
<li>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="w-full py-3 text-start text-sm font-semibold" style="color: var(--brand-danger);">
{{ __('تسجيل الخروج') }}
</button>
</form>
</li>
</ul>
</x-portal.card>
@endif
</div>
<div class="stagger-in space-y-4">
<header>
<p class="text-xs" style="color: var(--portal-muted);">{{ $greeting }}</p>
<h1 class="text-xl font-extrabold leading-tight">
{{ $activeChild?->person?->name_ar ?? $activeChild?->person?->name ?? __('عضو') }}
</h1>
</header>
{{-- The action list first. A member opens this to find out whether anything
needs doing; burying that under statistics is the whole failure of the
screen it replaces. --}}
@if(count($actions) > 0)
<div class="space-y-2">
@foreach($actions as $action)
@php
$tone = match($action['tone']) {
'danger' => 'var(--brand-danger)',
'warning' => 'var(--brand-warning)',
default => 'var(--brand-500)',
};
@endphp
<a href="{{ $action['route'] }}" wire:navigate
class="portal-card flex items-center gap-3 px-4 py-3"
style="border-color: color-mix(in oklab, {{ $tone }} 40%, var(--portal-border));">
<span class="h-9 w-1.5 shrink-0 rounded-full" style="background: {{ $tone }};" aria-hidden="true"></span>
<span class="min-w-0 flex-1">
<span class="block text-sm font-bold">{{ $action['label'] }}</span>
<span class="num block text-xs" style="color: var(--portal-muted);">{{ $action['detail'] }}</span>
</span>
<svg class="h-4 w-4 shrink-0 rtl:rotate-180" fill="none" stroke="currentColor" stroke-width="2"
viewBox="0 0 24 24" aria-hidden="true" style="color: var(--portal-muted);">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/>
</svg>
</a>
@endforeach
</div>
@endif
<x-portal.card :title="__('الحصة القادمة')" :action="route('portal.training')">
@if($nextSession)
<div class="flex items-start gap-3">
<div class="shrink-0 rounded-xl px-3 py-2 text-center"
style="background: var(--brand-50); color: var(--brand-700);">
<p class="text-[10px] font-semibold">{{ $nextSession->session_date?->translatedFormat('D') }}</p>
<p class="num text-xl font-extrabold leading-none">{{ $nextSession->session_date?->format('j') }}</p>
<p class="text-[10px]">{{ $nextSession->session_date?->translatedFormat('M') }}</p>
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-bold">
{{ $nextSession->group?->name_ar ?? $nextSession->group?->program?->name_ar ?? __('حصة تدريب') }}
</p>
<p class="num mt-0.5 text-xs" style="color: var(--portal-muted);">
{{ \Carbon\Carbon::parse($nextSession->start_time)->format('H:i') }}
{{ \Carbon\Carbon::parse($nextSession->end_time)->format('H:i') }}
</p>
@if($nextSession->facility)
<p class="mt-0.5 truncate text-xs" style="color: var(--portal-muted);">
{{ $nextSession->facility->name_ar ?? $nextSession->facility->name }}
</p>
@endif
@if($nextSession->trainer)
{{-- The coach's name was always one join away and never
loaded. A parent wants to know who is taking it. --}}
<p class="mt-1 text-xs font-medium" style="color: var(--brand-600);">
{{ __('المدرب') }}: {{ $nextSession->trainer->person?->name_ar ?? $nextSession->trainer->person?->name }}
</p>
@endif
</div>
</div>
@else
<x-portal.empty :title="__('لا توجد حصص قادمة')"
:body="__('سيظهر هنا موعد الحصة التالية فور جدولتها')" />
@endif
</x-portal.card>
<div class="grid grid-cols-3 gap-2">
<x-portal.stat :label="__('المستحق')"
:value="format_money($outstanding)"
:tone="$outstanding > 0 ? 'danger' : 'success'" />
<x-portal.stat :label="__('الحضور هذا الشهر')"
:value="$attendanceRate === null ? '—' : $attendanceRate . '%'"
tone="brand" />
<x-portal.stat :label="__('حصص متتالية')"
:value="$streak"
:sub="$streak > 0 ? __('استمر') : null" />
</div>
<div class="grid grid-cols-2 gap-2">
@foreach([
['portal.training', 'التدريب', 'M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25'],
['portal.payments', 'المدفوعات', 'M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12'],
['portal.pass', 'بطاقة الدخول', 'M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5z'],
['portal.academy', 'الأكاديمية', 'M12 21v-8.25M3 9l9-6 9 6'],
] as [$route, $label, $path])
<a href="{{ route($route) }}" wire:navigate
class="portal-card flex items-center gap-2.5 px-3 py-3">
<span class="grid h-9 w-9 shrink-0 place-items-center rounded-xl"
style="background: var(--brand-50); color: var(--brand-600);">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.7" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="{{ $path }}"/>
</svg>
</span>
<span class="truncate text-xs font-semibold">{{ __($label) }}</span>
</a>
@endforeach
</div>
</div>
<div class="space-y-4">
<div class="portal-card px-4 py-4">
<p class="num text-sm font-bold">{{ $invoice->number }}</p>
<p class="mt-0.5 text-xs" style="color: var(--portal-muted);">
{{ $invoice->issue_date?->translatedFormat('j M Y') }} · {{ $invoice->status->label() }}
</p>
<p class="num mt-3 text-3xl font-extrabold leading-none"
style="color: {{ $invoice->due_amount > 0 ? 'var(--brand-danger)' : 'var(--brand-success)' }};">
{{ format_money((int) $invoice->due_amount) }}
</p>
<p class="num mt-1 text-[11px]" style="color: var(--portal-muted);">
{{ __('الإجمالي') }} {{ format_money((int) $invoice->total_amount) }} ·
{{ __('المسدَّد') }} {{ format_money((int) $invoice->paid_amount) }}
</p>
</div>
<x-portal.card :title="__('البنود')">
{{-- Frozen at issue. An invoice does not re-price itself. --}}
<ul class="space-y-2">
@foreach($invoice->items as $item)
<li class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="truncate text-sm">{{ $item->description }}</p>
<p class="num text-[11px]" style="color: var(--portal-muted);">
{{ $item->quantity }} × {{ format_money((int) $item->unit_price) }}
</p>
</div>
<span class="num shrink-0 text-sm font-semibold">{{ format_money((int) $item->total_amount) }}</span>
</li>
@endforeach
</ul>
</x-portal.card>
@if($invoice->payments->isNotEmpty())
<x-portal.card :title="__('المدفوعات')">
<ul class="space-y-2">
@foreach($invoice->payments as $payment)
<li class="flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="num truncate text-sm">{{ $payment->reference }}</p>
<p class="text-[11px]" style="color: var(--portal-muted);">
{{ $payment->payment_date?->translatedFormat('j M Y') }} · {{ $payment->method?->label() }}
</p>
</div>
<span class="num shrink-0 text-sm font-semibold">{{ format_money((int) $payment->amount) }}</span>
</li>
@endforeach
</ul>
</x-portal.card>
@endif
</div>
<div class="space-y-3">
@if($unread > 0)
<button type="button" wire:click="markAllRead"
class="w-full rounded-xl px-4 py-2.5 text-xs font-bold"
style="background: var(--brand-500); color: var(--brand-fg);">
{{ __('تعليم الكل كمقروء') }} ({{ $unread }})
</button>
@endif
@forelse($logs as $log)
<article @class(['portal-card px-4 py-3'])
style="{{ $log->read_at ? '' : 'border-color: color-mix(in oklab, var(--brand-500) 45%, var(--portal-border));' }}"
@if(! $log->read_at) wire:click="markRead({{ $log->id }})" @endif>
<div class="flex items-start justify-between gap-2">
<p class="min-w-0 text-sm font-bold">{{ $log->subject ?: __('إشعار') }}</p>
@unless($log->read_at)
<span class="mt-1 h-2 w-2 shrink-0 rounded-full" style="background: var(--brand-500);" aria-label="{{ __('غير مقروء') }}"></span>
@endunless
</div>
@if($log->body)
<p class="mt-1 text-xs leading-relaxed" style="color: var(--portal-muted);">{{ $log->body }}</p>
@endif
<p class="mt-1 text-[11px]" style="color: var(--portal-muted);">
{{ $log->created_at?->diffForHumans() }}
</p>
</article>
@empty
<x-portal.card><x-portal.empty :title="__('لا توجد إشعارات')" /></x-portal.card>
@endforelse
<div>{{ $logs->links() }}</div>
</div>
<div class="space-y-4"
@if($svg)
{{-- Refresh a little before the code rotates, so the screen in someone's
hand is never the stale one. --}}
wire:poll.{{ max(5, $secondsLeft) }}s
@endif>
@if($error)
<x-portal.card>
<x-portal.empty :title="__('البطاقة غير متاحة')" :body="$error" />
</x-portal.card>
@elseif(! $svg)
<x-portal.card>
<x-portal.empty :title="__('لا يوجد عضو محدد')" />
</x-portal.card>
@else
<div class="portal-card flex flex-col items-center gap-3 px-4 py-6">
<p class="text-sm font-bold">{{ $participant?->person?->name_ar ?? $participant?->person?->name }}</p>
{{-- Rendered server-side, inline. No CDN, no third party sees the
token, and it works when the venue's wifi does not. --}}
<div class="rounded-2xl bg-white p-3" style="box-shadow: 0 1px 3px rgba(0,0,0,.08);">
{!! $svg !!}
</div>
<p class="text-xs" style="color: var(--portal-muted);">
{{ __('اعرض هذا الرمز على موظف الاستقبال لتسجيل الحضور') }}
</p>
<p class="num text-[11px]" style="color: var(--portal-muted);">
{{ __('يتجدد الرمز خلال') }} {{ $secondsLeft }} {{ __('ثانية') }}
</p>
</div>
<x-portal.card>
<p class="text-xs leading-relaxed" style="color: var(--portal-muted);">
{{ __('هذا الرمز يثبت هويتك فقط، ولا يمنح أي صلاحية. يتم التحقق من التسجيل والحالة والحصة عند المسح، ويصلح الرمز لمرة واحدة.') }}
</p>
</x-portal.card>
@endif
</div>
<div class="space-y-4">
<div class="portal-card px-4 py-4 text-center">
<p class="text-xs" style="color: var(--portal-muted);">{{ __('إجمالي المستحق') }}</p>
{{-- Family-scoped on purpose: a household has one balance even when it
has three players, which is why the profile switcher does not
narrow this figure. --}}
<p class="num mt-1 text-3xl font-extrabold leading-none"
style="color: {{ $totalDue > 0 ? 'var(--brand-danger)' : 'var(--brand-success)' }};">
{{ format_money($totalDue) }}
</p>
<p class="mt-1 text-[11px]" style="color: var(--portal-muted);">{{ __('لكل الأعضاء المرتبطين بحسابك') }}</p>
</div>
<x-portal.tabs :tabs="['due' => __('عليك'), 'history' => __('السجل'), 'wallet' => __('المحفظة')]"
:active="$tab" />
@if($tab === 'due')
@if($installments->isNotEmpty())
<x-portal.card :title="__('الأقساط')">
<ul class="space-y-2">
@foreach($installments as $installment)
@php $overdue = $installment->due_date?->isPast(); @endphp
<li class="flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-semibold">
{{ __('قسط') }} #{{ $installment->sequence }}
</p>
<p class="num text-xs" style="color: {{ $overdue ? 'var(--brand-danger)' : 'var(--portal-muted)' }};">
{{ $installment->due_date?->translatedFormat('j M Y') }}
@if($overdue) · {{ __('متأخر') }} @endif
</p>
</div>
<span class="num shrink-0 text-sm font-bold">{{ format_money((int) $installment->amount) }}</span>
</li>
@endforeach
</ul>
</x-portal.card>
@endif
@if($invoices->isEmpty())
<x-portal.card>
<x-portal.empty :title="__('لا توجد مستحقات')"
:body="__('كل الفواتير مسددة — سيظهر هنا أي مبلغ جديد فور صدوره')" />
</x-portal.card>
@else
<ol class="stagger-in space-y-2">
@foreach($invoices as $invoice)
<li>
<a href="{{ route('portal.invoice', $invoice->uuid) }}" wire:navigate
class="portal-card flex items-center gap-3 px-4 py-3">
<div class="min-w-0 flex-1">
<p class="num truncate text-sm font-bold">{{ $invoice->number }}</p>
<p class="text-xs" style="color: var(--portal-muted);">
{{ $invoice->status->label() }}
@if($invoice->due_date)
· {{ __('تاريخ الاستحقاق') }} {{ $invoice->due_date->translatedFormat('j M') }}
@endif
</p>
</div>
<span class="num shrink-0 text-sm font-extrabold" style="color: var(--brand-danger);">
{{ format_money((int) $invoice->due_amount) }}
</span>
</a>
</li>
@endforeach
</ol>
<div>{{ $invoices->links() }}</div>
@endif
@elseif($tab === 'history')
@if($payments->isEmpty())
<x-portal.card>
<x-portal.empty :title="__('لا توجد مدفوعات بعد')" />
</x-portal.card>
@else
<ol class="stagger-in space-y-2">
@foreach($payments as $payment)
<li class="portal-card flex items-center gap-3 px-4 py-3">
<span class="grid h-9 w-9 shrink-0 place-items-center rounded-xl"
style="background: color-mix(in oklab, var(--brand-success) 14%, var(--portal-surface)); color: var(--brand-success);">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/>
</svg>
</span>
<div class="min-w-0 flex-1">
<p class="num truncate text-sm font-semibold">{{ $payment->reference }}</p>
<p class="text-xs" style="color: var(--portal-muted);">
{{ $payment->payment_date?->translatedFormat('j M Y') }} · {{ $payment->method?->label() }}
</p>
</div>
<span class="num shrink-0 text-sm font-bold">{{ format_money((int) $payment->amount) }}</span>
</li>
@endforeach
</ol>
<div>{{ $payments->links() }}</div>
@endif
@else
@if($wallets->isEmpty())
<x-portal.card>
<x-portal.empty :title="__('لا توجد محفظة')"
:body="__('تُنشأ المحفظة تلقائياً عند أول رصيد يُضاف لحسابك')" />
</x-portal.card>
@else
<div class="space-y-2">
@foreach($wallets as $wallet)
<div class="portal-card flex items-center justify-between gap-3 px-4 py-4">
<div class="min-w-0">
<p class="truncate text-sm font-semibold">
{{ $wallet->owner?->person?->name_ar ?? __('محفظة') }}
</p>
<p class="text-xs" style="color: var(--portal-muted);">{{ __('الرصيد المتاح') }}</p>
</div>
<span class="num shrink-0 text-lg font-extrabold" style="color: var(--brand-600);">
{{ format_money((int) $wallet->balance) }}
</span>
</div>
@endforeach
</div>
@endif
@endif
</div>
<div class="space-y-4">
<x-portal.tabs :tabs="['upcoming' => __('القادمة'), 'past' => __('السابقة')]"
:active="$view" property="view" />
@if($sessions->isEmpty())
<x-portal.card>
<x-portal.empty :title="$view === 'upcoming' ? __('لا توجد حصص قادمة') : __('لا يوجد سجل حصص')"
:body="__('تظهر الحصص هنا بمجرد جدولتها للمجموعة المسجَّل بها العضو')" />
</x-portal.card>
@else
<ol class="stagger-in space-y-2">
@foreach($sessions as $session)
@php
$record = $attendance[$session->id] ?? null;
$status = $record?->status;
$statusValue = $status instanceof \BackedEnum ? $status->value : $status;
$cancelled = ($session->status instanceof \BackedEnum ? $session->status->value : $session->status) === 'cancelled';
[$tone, $statusLabel] = match (true) {
$cancelled => ['var(--brand-warning)', __('ملغاة')],
$statusValue === 'present' => ['var(--brand-success)', __('حاضر')],
$statusValue === 'late' => ['var(--brand-warning)', __('متأخر')],
$statusValue === 'excused' => ['var(--portal-muted)', __('معذور')],
in_array($statusValue, ['absent', 'no_show'], true) => ['var(--brand-danger)', __('غائب')],
default => [null, null],
};
@endphp
<li class="portal-card px-4 py-3">
<div class="flex items-start gap-3">
<div class="shrink-0 text-center">
<p class="num text-lg font-extrabold leading-none">{{ $session->session_date?->format('j') }}</p>
<p class="text-[10px]" style="color: var(--portal-muted);">{{ $session->session_date?->translatedFormat('M') }}</p>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-2">
<p class="min-w-0 truncate text-sm font-bold">
{{ $session->topic ?: ($session->group?->name_ar ?? __('حصة تدريب')) }}
</p>
@if($statusLabel)
<span class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold"
style="background: color-mix(in oklab, {{ $tone }} 14%, var(--portal-surface)); color: {{ $tone }};">
{{ $statusLabel }}
</span>
@endif
</div>
<p class="num mt-0.5 text-xs" style="color: var(--portal-muted);">
{{ \Carbon\Carbon::parse($session->start_time)->format('H:i') }}
– {{ \Carbon\Carbon::parse($session->end_time)->format('H:i') }}
@if($session->facility)
· {{ $session->facility->name_ar ?? $session->facility->name }}
@endif
</p>
@if($session->trainer)
<p class="mt-1 text-xs" style="color: var(--portal-muted);">
{{ __('المدرب') }}:
<span class="font-medium" style="color: var(--brand-600);">
{{ $session->trainer->person?->name_ar ?? $session->trainer->person?->name }}
</span>
@if($session->substitute_reason)
{{-- A substitution without its reason reads as a
mistake. The column has always been there. --}}
<span class="block text-[11px]">{{ __('بديلاً') }} — {{ $session->substitute_reason }}</span>
@endif
</p>
@endif
@if($cancelled)
<p class="mt-1 rounded-lg px-2 py-1 text-[11px]"
style="background: color-mix(in oklab, var(--brand-warning) 12%, var(--portal-surface)); color: var(--brand-warning);">
{{ $session->cancelled_reason ?: __('أُلغيت هذه الحصة') }}
</p>
@endif
@if($session->objectives)
<p class="mt-1 line-clamp-2 text-[11px]" style="color: var(--portal-muted);">{{ $session->objectives }}</p>
@endif
</div>
</div>
</li>
@endforeach
</ol>
@endif
@if($evaluations->isNotEmpty())
<x-portal.card :title="__('التقييمات')">
<ul class="space-y-2">
@foreach($evaluations as $evaluation)
<li class="flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="truncate text-sm font-semibold">{{ $evaluation->group?->name_ar ?? __('تقييم') }}</p>
<p class="text-xs" style="color: var(--portal-muted);">
{{ $evaluation->evaluation_date?->translatedFormat('j M Y') }}
</p>
</div>
<span class="num shrink-0 text-lg font-extrabold" style="color: var(--brand-600);">
{{ $evaluation->overall_score }}
</span>
</li>
@endforeach
</ul>
</x-portal.card>
@endif
</div>
<div class="rail border-t px-4 py-2" style="border-color: var(--portal-border);">
@foreach($profiles as $profile)
@php
$isActive = $profile->id === $activeId;
$name = $profile->person?->name_ar ?? $profile->person?->name ?? '—';
@endphp
<button type="button"
wire:click="select({{ $profile->id }})"
wire:loading.attr="disabled"
@class(['flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold transition-colors'])
style="{{ $isActive
? 'background: var(--brand-500); color: var(--brand-fg); border-color: var(--brand-500);'
: 'background: var(--portal-surface); color: var(--portal-muted); border-color: var(--portal-border);' }}"
@if($isActive) aria-current="true" @endif>
<span class="grid h-6 w-6 shrink-0 place-items-center rounded-full text-[10px] font-bold"
style="{{ $isActive
? 'background: color-mix(in oklab, var(--brand-fg) 22%, transparent);'
: 'background: var(--brand-100); color: var(--brand-700);' }}">
{{ mb_substr($name, 0, 1) }}
</span>
<span class="max-w-28 truncate">{{ $name }}</span>
</button>
@endforeach
</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>
<link href="https://fonts.googleapis.com/css2?family={{ urlencode($brand->fontAr) }}:wght@400;600;700;800&display=swap" rel="stylesheet">
@vite(['resources/css/portal.css'])
<style>:root { {!! $brand->cssVariableBlock() !!} --portal-surface:#fff; --portal-border:#e5e7eb; --portal-ink:#111827; --portal-muted:#6b7280; }
body { background:#f8fafc; color: var(--portal-ink); font-family: var(--brand-font-ar), sans-serif; }</style>
</head>
<body class="grid min-h-dvh place-items-center px-6 py-10">
<main class="w-full max-w-sm">
<div class="mb-6 text-center">
@if($brand->logoUrl)
<img src="{{ $brand->logoUrl }}" alt="{{ $brand->academyName }}" class="mx-auto h-14 object-contain">
@endif
<h1 class="mt-3 text-lg font-extrabold">{{ __('تفعيل حسابك') }}</h1>
@if($personName)
<p class="mt-1 text-sm" style="color: var(--portal-muted);">{{ $personName }}</p>
@endif
</div>
<form method="POST" action="{{ route('portal.activate.store', $token) }}"
class="portal-card space-y-4 px-5 py-6" style="background:#fff;">
@csrf
@if($errors->any())
<div class="rounded-xl px-3 py-2 text-xs"
style="background: color-mix(in oklab, var(--brand-danger) 12%, #fff); color: var(--brand-danger);">
{{ $errors->first() }}
</div>
@endif
<div>
<label for="password" class="block text-xs font-semibold">{{ __('كلمة المرور') }}</label>
<input id="password" name="password" type="password" required minlength="8" autocomplete="new-password"
class="mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
style="border-color: var(--portal-border);">
<p class="mt-1 text-[11px]" style="color: var(--portal-muted);">{{ __('٨ أحرف على الأقل') }}</p>
</div>
<div>
<label for="password_confirmation" class="block text-xs font-semibold">{{ __('تأكيد كلمة المرور') }}</label>
<input id="password_confirmation" name="password_confirmation" type="password" required autocomplete="new-password"
class="mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
style="border-color: var(--portal-border);">
</div>
@if($needsEmail)
<div>
<label for="email" class="block text-xs font-semibold">
{{ __('البريد الإلكتروني') }}
<span class="font-normal" style="color: var(--portal-muted);">— {{ __('اختياري') }}</span>
</label>
<input id="email" name="email" type="email" dir="ltr" autocomplete="email"
class="mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
style="border-color: var(--portal-border);">
<p class="mt-1 text-[11px]" style="color: var(--portal-muted);">
{{ __('يمكنك تسجيل الدخول برقم الهاتف أيضاً') }}
</p>
</div>
@endif
<button type="submit" class="w-full rounded-xl px-4 py-3 text-sm font-bold"
style="background: var(--brand-500); color: var(--brand-fg);">
{{ __('تفعيل ودخول') }}
</button>
</form>
</main>
</body>
</html>
@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">
<title>{{ __('رابط غير صالح') }} — {{ $brand->academyName }}</title>
<link href="https://fonts.googleapis.com/css2?family={{ urlencode($brand->fontAr) }}:wght@400;600;700&display=swap" rel="stylesheet">
@vite(['resources/css/portal.css'])
<style>:root { {!! $brand->cssVariableBlock() !!} --portal-surface:#fff; --portal-border:#e5e7eb; --portal-ink:#111827; --portal-muted:#6b7280; }
body { background:#f8fafc; color: var(--portal-ink); font-family: var(--brand-font-ar), sans-serif; }</style>
</head>
<body class="grid min-h-dvh place-items-center px-6">
<main class="portal-card w-full max-w-sm px-6 py-8 text-center" style="background:#fff;">
<h1 class="text-lg font-extrabold">{{ __('رابط غير صالح') }}</h1>
{{-- Missing, expired, revoked and already-used all say the same thing.
Telling them apart would tell whoever is guessing which tokens exist. --}}
<p class="mt-2 text-sm leading-relaxed" style="color: var(--portal-muted);">
{{ $message ?? __('هذا الرابط غير صالح أو انتهت صلاحيته أو تم استخدامه بالفعل. تواصل مع الأكاديمية للحصول على رابط جديد.') }}
</p>
</main>
</body>
</html>
......@@ -622,6 +622,45 @@
});
});
/*
|--------------------------------------------------------------------------
| Member portal (/app)
|--------------------------------------------------------------------------
| The member-facing portal — players and guardians — session-authenticated on
| the web guard, so CSRF and one logout path come free.
|
| It replaces /parent, which stays live and redirects until the portal reaches
| parity. Two member portals must not coexist any longer than that.
|
| The manifest is deliberately outside the auth group: a PWA fetches it before
| anyone signs in, and it carries only public branding.
*/
Route::get('/app/manifest.webmanifest', \App\Http\Controllers\Portal\ManifestController::class)
->name('portal.manifest');
// 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.
Route::middleware('guest')->group(function () {
Route::get('/app/activate/{token}', [\App\Http\Controllers\Portal\InvitationController::class, 'show'])
->name('portal.activate');
Route::post('/app/activate/{token}', [\App\Http\Controllers\Portal\InvitationController::class, 'store'])
->name('portal.activate.store');
});
Route::middleware(['auth', 'permission:portal.access'])
->prefix('app')
->name('portal.')
->group(function () {
Route::get('/', \App\Livewire\Portal\PortalHome::class)->name('home');
Route::get('/training', \App\Livewire\Portal\PortalTraining::class)->name('training');
Route::get('/payments', \App\Livewire\Portal\PortalPayments::class)->name('payments');
Route::get('/payments/invoice/{invoice}', \App\Livewire\Portal\PortalInvoice::class)->name('invoice');
Route::get('/academy', \App\Livewire\Portal\PortalAcademy::class)->name('academy');
Route::get('/account', \App\Livewire\Portal\PortalAccount::class)->name('account');
Route::get('/notifications', \App\Livewire\Portal\PortalNotifications::class)->name('notifications');
Route::get('/pass', \App\Livewire\Portal\PortalPass::class)->name('pass');
});
/*
|--------------------------------------------------------------------------
| Website builder pages (must stay last)
......
<?php
namespace Tests\Feature;
use App\Models\User;
use Tests\TestCase;
/**
* Renders every portal screen against a restored copy of a real tenant
* database — real people, real invoices, real sessions — rather than fixtures.
*
* The default suite runs on SQLite, where the schema does not exist and the
* CHECK constraints cannot, so this skips unless it is pointed at a restored
* Postgres tenant:
*
* DB_CONNECTION=pgsql DB_DATABASE=oc_sport_test ./vendor/bin/phpunit --filter PortalSmokeTest
*
* Skipping is the honest behaviour: a suite that silently runs on a different
* database engine than production is blind by construction, and pretending
* otherwise is worse than saying so.
*/
class PortalSmokeTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
if (config('database.default') !== 'pgsql') {
$this->markTestSkipped('Needs a restored Postgres tenant; see the class comment.');
}
}
public function test_every_portal_screen_renders_for_a_real_member(): void
{
$user = $this->aMemberAccount();
$routes = [
'portal.home' => 'الرئيسية',
'portal.training' => null,
'portal.payments' => 'إجمالي المستحق',
'portal.academy' => null,
'portal.account' => null,
'portal.notifications' => null,
'portal.pass' => null,
];
foreach ($routes as $name => $needle) {
$response = $this->actingAs($user)->get(route($name));
$status = $response->getStatusCode();
$body = $response->getContent();
$len = strlen($body);
fwrite(STDERR, sprintf(" %-22s %d %6d bytes%s\n", $name, $status, $len,
$needle && ! str_contains($body, $needle) ? " MISSING: {$needle}" : ''));
if ($status !== 200) {
fwrite(STDERR, " " . substr(strip_tags($body), 0, 400) . "\n");
}
$this->assertSame(200, $status, "{$name} did not render");
}
}
public function test_the_manifest_is_served_per_tenant_and_uncacheable_by_a_proxy(): void
{
$response = $this->get(route('portal.manifest'));
$response->assertOk();
$response->assertHeader('Cache-Control', 'no-store, private');
$manifest = $response->json();
fwrite(STDERR, " manifest: " . json_encode($manifest, JSON_UNESCAPED_UNICODE) . "\n");
$this->assertSame('/app/', $manifest['scope']);
$this->assertSame('/app/', $manifest['start_url']);
}
public function test_a_member_cannot_open_another_familys_invoice(): void
{
$user = $this->aMemberAccount();
$foreign = \App\Domain\Financial\Models\Invoice::withoutGlobalScopes()
->where('billable_type', \App\Domain\Participant\Models\Participant::class)
->whereNotIn('billable_id', app(\App\Domain\Identity\Services\GuardianResolver::class)->participantIdsFor($user))
->firstOrFail();
fwrite(STDERR, " foreign invoice {$foreign->number} (participant {$foreign->billable_id})\n");
$this->actingAs($user)->get(route('portal.invoice', $foreign->uuid))->assertForbidden();
}
/** The first account on this tenant that actually speaks for a member. */
private function aMemberAccount(): User
{
$resolver = app(\App\Domain\Identity\Services\GuardianResolver::class);
foreach (User::withoutGlobalScopes()->whereNotNull('person_id')->limit(200)->get() as $candidate) {
if ($resolver->participantIdsFor($candidate) !== []) {
return $candidate;
}
}
$this->markTestSkipped('No account on this tenant speaks for a participant.');
}
}
This diff is collapsed.
This diff is collapsed.
......@@ -11,6 +11,10 @@ export default defineConfig({
'resources/js/app.js',
'resources/css/website.css',
'resources/js/website.js',
// The member portal. Its own entrypoint, and the only one built
// with `source(none)` — see the note at the top of portal.css.
'resources/css/portal.css',
'resources/js/portal.js',
],
refresh: true,
fonts: [
......
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