Commit 500806ca authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(portal): self-registration behind a closed door, and the accessibility gate

The last of the programme, plus the exit gate CLAUDE.md requires before any UI
work counts as finished.

Self-registration (W10) — shipped, and closed
---------------------------------------------
This is the only path in the product that writes into `people` and
`participants` with no member of staff in the loop, so it ships **off**:
`portal.self_registration_enabled` defaults to false on every tenant including
new ones, and the middleware answers 404 — never 403, because a 403 advertises
that there is a signup form here and invites someone to look for the setting.
A public form that starts accepting strangers because a deploy happened is not
a decision anybody made.

Phone verification is the precondition, not a feature. The flow it replaces was
a study in how not to do this: `verify()` accepted the constant '0000' whenever
a seeded setting said 'demo', then resolved *any* active user by phone —
academy owners included — and minted a token with `mobile:*`; and in the other
mode it generated a code, cached it, and never sent it anywhere, so turning the
bypass off locked everyone out rather than securing anything.

So: no bypass exists, in any mode, behind any flag. Only the SHA-256 is stored,
with a bounded attempt count, in a table rather than the cache — a code you
cannot audit is a code you cannot investigate. A send that fails deletes the
record, because a stored code nobody received is precisely the old failure.
There is a test asserting '0000' and '1234' are refused.

Registration goes **through** ParticipantService rather than around it. Writing
the row directly skipped the participant number, the already-a-member check,
the audit columns and ParticipantRegistered — a second creation path that
looked identical and was not. It does not enrol and it does not take money:
EnrollmentService::enroll() needs an actor authorised to enrol and a
self-registering guardian is not one. The member asks; staff enrol.

`people.created_by` is NOT NULL and there is no staff member here, so the
account is created first with no person attached and becomes the author of its
own records — which is also the truth about who typed them. Loosening the
column would have weakened it for every other path.

DuplicateDetectionService runs on every signup and its findings are stored on
the row and shown in the approval queue. It has existed for a long time with
nothing surfacing what it found, so a second Person for an existing member
appeared silently and the two drifted apart forever.

The accessibility gate
----------------------
Eleven checks against the HTML the portal actually renders for a real member,
not against the templates: language matching direction, image alternatives,
accessible names on every icon-only control, a label for every form control,
named landmarks, focus never globally removed, reduced motion honoured, a
stated focus ring, announced errors, and dir=ltr on numeric inputs.

Two things it found. There was no explicit focus-visible style, so the ring was
the browser default — a thin blue line that disappears against a tenant whose
brand is blue; it is now `currentColor`, which inherits an already
contrast-checked colour and is legible on every surface in both themes.
And validation messages were rendered as plain text: a screen-reader user
submitted a form and heard nothing. Every one is a live region now, asserted at
the source, because an error block only renders when there is an error and a
clean page proves nothing either way.

Also: `:focus` gets scroll-margin so a focused control is never left under the
sticky header or the bottom tab bar (2.4.11).

Verification
------------
- 145 migrations from zero on an empty database, seeded, booted a second time:
  every portal grant intact, self-registration absent and therefore closed
  (SettingsService returns the default, which is false — it fails closed).
- The restored oc_sport tenant: nothing to migrate, seeders clean, health 200,
  713 invoices / 356 participants / 649 payments untouched.
- Suite: 76 pass on SQLite; on the tenant PortalSmoke 3/3, AdminScreens 2/2,
  PaymentProof 11/11, CheckInScan 10/10, ServiceRequestEffect 14/14,
  SelfRegistration 13/13, PortalAccessibility 11/11.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 8d392c04
<?php
namespace App\Domain\Identity\Services;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Helpers\CredentialNormalizer;
use App\Domain\WhatsApp\Services\WhatsAppService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
/**
* One-time codes over WhatsApp, for the one place they are genuinely needed:
* proving a stranger's phone number before anything is written into `people`
* or `participants`.
*
* The flow this replaces was a case study in how not to do it. Its `verify()`
* accepted the constant `'0000'` whenever a system setting said 'demo' — which
* was the seeded default — and resolved *any* active user by phone, academy
* owners included, then minted a token with `mobile:*`. And in the non-demo
* mode it generated a code, cached it, and **never sent it anywhere**, so
* turning the bypass off locked everybody out rather than securing anything.
*
* So, deliberately:
*
* - **No bypass exists.** Not behind a flag, not in an environment, not for
* testing. A code is sent or the flow fails.
* - **Fail closed.** If WhatsApp is not configured, this refuses rather than
* storing a code nobody can receive.
* - **Only the hash is stored**, with a bounded attempt count, in a table
* rather than the cache — a code you cannot audit is a code you cannot
* investigate.
*/
class PhoneVerificationService
{
private const CODE_TTL_MINUTES = 10;
private const MAX_ATTEMPTS = 5;
/** Six digits is the usual shape; the attempt cap is what makes it safe. */
private const CODE_LENGTH = 6;
public function __construct(private readonly WhatsAppService $whatsapp) {}
/**
* Send a code, or say why not.
*/
public function send(int $academyId, string $phone, string $purpose = 'registration', ?string $ip = null): void
{
$normalized = CredentialNormalizer::phone($phone);
if (! $normalized || strlen($normalized) < 10) {
throw new DomainException('رقم الهاتف غير صالح');
}
// Two separate limits, because the two abuses are different: hammering
// one number to burn its attempts, and walking a range of numbers.
foreach ([
'otp-send:phone:' . $normalized => 5,
'otp-send:ip:' . ($ip ?: 'unknown') => 15,
] as $key => $limit) {
if (RateLimiter::tooManyAttempts($key, $limit)) {
throw new DomainException('محاولات كثيرة. حاول مرة أخرى بعد قليل.');
}
}
$code = str_pad((string) random_int(0, 999999), self::CODE_LENGTH, '0', STR_PAD_LEFT);
DB::transaction(function () use ($academyId, $normalized, $purpose, $code, $ip) {
// Requesting again supersedes the previous code rather than leaving
// several alive — enforced by a partial unique index as well.
DB::table('otp_verifications')
->where('academy_id', $academyId)
->where('phone', $normalized)
->where('purpose', $purpose)
->whereNull('verified_at')
->delete();
DB::table('otp_verifications')->insert([
'academy_id' => $academyId,
'phone' => $normalized,
'code_hash' => hash('sha256', $code),
'purpose' => $purpose,
'attempts' => 0,
'expires_at' => now()->addMinutes(self::CODE_TTL_MINUTES),
'request_ip' => $ip,
'created_at' => now(),
'updated_at' => now(),
]);
});
try {
$this->whatsapp->sendText(
$normalized,
__('رمز التحقق') . ': ' . $code . "\n\n"
. __('صالح لمدة') . ' ' . self::CODE_TTL_MINUTES . ' ' . __('دقائق') . '. '
. __('لا تشارك هذا الرمز مع أحد.')
);
} catch (\Throwable $e) {
Log::error('OTP send failed', ['phone' => $normalized, 'error' => $e->getMessage()]);
// The record goes with it. A code stored but never delivered is the
// exact failure the old implementation shipped: a flow that looks
// like it is working and locks everybody out.
DB::table('otp_verifications')
->where('academy_id', $academyId)
->where('phone', $normalized)
->where('purpose', $purpose)
->whereNull('verified_at')
->delete();
throw new DomainException('تعذّر إرسال رمز التحقق — تواصل مع الأكاديمية');
}
foreach (['otp-send:phone:' . $normalized, 'otp-send:ip:' . ($ip ?: 'unknown')] as $key) {
RateLimiter::hit($key, 3600);
}
}
/**
* Check a code. Consumed on success; counted on failure.
*/
public function verify(int $academyId, string $phone, string $code, string $purpose = 'registration'): bool
{
$normalized = CredentialNormalizer::phone($phone);
if (! $normalized) {
return false;
}
return DB::transaction(function () use ($academyId, $normalized, $code, $purpose) {
$row = DB::table('otp_verifications')
->where('academy_id', $academyId)
->where('phone', $normalized)
->where('purpose', $purpose)
->whereNull('verified_at')
->lockForUpdate()
->first();
if (! $row) {
return false;
}
if ($row->attempts >= self::MAX_ATTEMPTS || now()->gt($row->expires_at)) {
DB::table('otp_verifications')->where('id', $row->id)->delete();
return false;
}
// hash_equals, not ===: a length-independent comparison here would
// leak how much of the code was right.
if (! hash_equals($row->code_hash, hash('sha256', trim($code)))) {
DB::table('otp_verifications')->where('id', $row->id)
->update(['attempts' => $row->attempts + 1, 'updated_at' => now()]);
return false;
}
DB::table('otp_verifications')->where('id', $row->id)
->update(['verified_at' => now(), 'updated_at' => now()]);
return true;
});
}
/**
* Whether a phone was verified recently enough to act on.
*
* Checked again at the moment a row is written, not merely at the moment
* the member typed the code: a session that sat open for an hour is not
* evidence of anything.
*/
public function isVerified(int $academyId, string $phone, string $purpose = 'registration'): bool
{
$normalized = CredentialNormalizer::phone($phone);
if (! $normalized) {
return false;
}
return DB::table('otp_verifications')
->where('academy_id', $academyId)
->where('phone', $normalized)
->where('purpose', $purpose)
->whereNotNull('verified_at')
->where('verified_at', '>', now()->subMinutes(30))
->exists();
}
/** Housekeeping for the scheduler. */
public function pruneExpired(): int
{
return DB::table('otp_verifications')
->where('expires_at', '<', now()->subDay())
->delete();
}
}
This diff is collapsed.
<?php
namespace App\Http\Middleware;
use App\Domain\Shared\Services\SettingsService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Self-registration is off unless an academy has deliberately turned it on.
*
* This is the one route group in the product where a stranger writes into
* `people` and `participants`, so it does not become reachable because a deploy
* happened. Every tenant gets the code and nobody gets the open form until
* somebody decides.
*
* 404, not 403: a disabled feature should be indistinguishable from a feature
* that does not exist. A 403 advertises that there is a signup form here and
* invites someone to find the setting.
*/
class EnsureSelfRegistrationEnabled
{
public function handle(Request $request, Closure $next): Response
{
if (! app()->has('current_academy')) {
abort(404);
}
$enabled = app(SettingsService::class)->get('portal.self_registration_enabled', false);
abort_unless(filter_var($enabled, FILTER_VALIDATE_BOOLEAN), 404);
return $next($request);
}
}
<?php
namespace App\Livewire\Participants;
use App\Domain\Participant\Models\Participant;
use App\Domain\Portal\Services\PortalRegistrationService;
use App\Domain\Shared\Exceptions\DomainException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
use Livewire\WithPagination;
/**
* Self-registered members waiting for a human to look at them.
*
* The duplicate candidates found at signup are shown beside each row, which is
* the entire point: DuplicateDetectionService has existed for a long time and
* nothing surfaced its findings, so a second Person for an existing member
* would appear silently and the two would drift apart forever.
*
* Approving is deliberately not automatic and deliberately not something the
* member can trigger. A stranger's typing is a request, not a membership.
*/
#[Layout('layouts.app')]
#[Title('تسجيلات في انتظار المراجعة')]
class PendingRegistrations extends Component
{
use WithPagination;
public function mount(): void
{
$this->authorize('participants.update');
}
public function approve(int $participantId, PortalRegistrationService $registrations): void
{
$this->authorize('participants.update');
$participant = Participant::whereNull('approved_at')->findOrFail($participantId);
try {
$registrations->approve($participant, auth()->user());
session()->flash('success', __('تم اعتماد التسجيل'));
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
}
public function reject(int $participantId): void
{
$this->authorize('participants.update');
// Rejected, not deleted: the person may reapply, staff may want to see
// that they tried, and deleting a row a guardian account points at
// orphans the account.
Participant::whereNull('approved_at')
->where('id', $participantId)
->update(['status' => 'inactive', 'updated_at' => now()]);
session()->flash('success', __('تم رفض التسجيل'));
}
public function render()
{
return view('livewire.participants.pending-registrations', [
'pending' => Participant::whereNull('approved_at')
->where('registration_source', 'online')
->with(['person', 'branch', 'guardians.person'])
->orderBy('created_at')
->paginate(20),
]);
}
}
<?php
namespace App\Livewire\Portal;
use App\Domain\Compliance\Enums\ConsentType;
use App\Domain\Compliance\Services\ConsentService;
use App\Domain\Identity\Models\Branch;
use App\Domain\Identity\Services\PhoneVerificationService;
use App\Domain\Portal\Services\PortalRegistrationService;
use App\Domain\Shared\Exceptions\DomainException;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Component;
/**
* Public signup: guardian details, phone verification, child, consent.
*
* Reachable only where an academy has turned self-registration on. The form is
* four steps because the phone must be proven before anything is written, and
* a member who abandons after step two must leave nothing behind.
*
* Note what is `#[Locked]` and what is not. The step counter and the verified
* flag are server state — in Livewire v4 a plain public property is settable
* from the browser, so an unlocked `$verified` would be a signup form with an
* optional verification step.
*/
#[Layout('layouts.portal-public')]
#[Title('إنشاء حساب')]
class PortalSignUp extends Component
{
#[Locked]
public int $step = 1;
#[Locked]
public bool $codeSent = false;
#[Locked]
public bool $verified = false;
public string $guardianName = '';
public string $phone = '';
public string $email = '';
public string $password = '';
public string $passwordConfirmation = '';
public string $code = '';
public string $childName = '';
public string $childDateOfBirth = '';
public string $childGender = '';
public ?int $branchId = null;
/** @var array<string, bool> */
public array $consents = [];
public function mount(): void
{
foreach (ConsentType::cases() as $type) {
$this->consents[$type->value] = $type->isRequired() ? false : false;
}
}
public function sendCode(PhoneVerificationService $verification): void
{
$this->validate([
'guardianName' => ['required', 'string', 'min:3', 'max:120'],
'phone' => ['required', 'string', 'min:10', 'max:20'],
'email' => ['nullable', 'email', 'max:255'],
'password' => ['required', 'string', 'min:8', 'max:200', 'same:passwordConfirmation'],
], [
'guardianName.required' => __('الاسم مطلوب'),
'guardianName.min' => __('الاسم قصير جداً'),
'phone.required' => __('رقم الهاتف مطلوب'),
'password.required' => __('كلمة المرور مطلوبة'),
'password.min' => __('كلمة المرور يجب ألا تقل عن ٨ أحرف'),
'password.same' => __('تأكيد كلمة المرور غير مطابق'),
]);
try {
$verification->send(app('current_academy')->id, $this->phone, 'registration', request()->ip());
} catch (DomainException $e) {
$this->addError('phone', $e->getMessage());
return;
}
$this->codeSent = true;
$this->step = 2;
}
public function verifyCode(PhoneVerificationService $verification): void
{
$this->validate(
['code' => ['required', 'digits:6']],
['code.required' => __('أدخل رمز التحقق'), 'code.digits' => __('الرمز مكوّن من ٦ أرقام')],
);
if (! $verification->verify(app('current_academy')->id, $this->phone, $this->code)) {
// One message for every failure — wrong, expired, or too many
// attempts. Telling them apart tells someone guessing which it was.
$this->addError('code', __('الرمز غير صحيح أو انتهت صلاحيته'));
return;
}
$this->verified = true;
$this->step = 3;
}
public function goToConsents(): void
{
$this->validate([
'childName' => ['required', 'string', 'min:3', 'max:120'],
'childDateOfBirth' => ['required', 'date', 'before:today'],
'childGender' => ['required', 'in:male,female'],
'branchId' => ['nullable', 'integer'],
], [
'childName.required' => __('اسم العضو مطلوب'),
'childDateOfBirth.required' => __('تاريخ الميلاد مطلوب'),
'childDateOfBirth.before' => __('تاريخ الميلاد يجب أن يكون في الماضي'),
'childGender.required' => __('النوع مطلوب'),
]);
$this->step = 4;
}
public function submit(PortalRegistrationService $registration, ConsentService $consents): void
{
abort_unless($this->verified, 403);
foreach (ConsentType::required() as $type) {
if (! ($this->consents[$type->value] ?? false)) {
$this->addError('consents', __('لا يمكن إنشاء الحساب دون الموافقة على: ') . $type->label());
return;
}
}
try {
['user' => $user, 'participant' => $participant] = $registration->register([
'guardian_name' => $this->guardianName,
'phone' => $this->phone,
'email' => $this->email ?: null,
'password' => $this->password,
'child_name' => $this->childName,
'child_date_of_birth' => $this->childDateOfBirth,
'child_gender' => $this->childGender,
'branch_id' => $this->branchId,
], app('current_academy')->id, request()->ip());
} catch (DomainException $e) {
$this->addError('phone', $e->getMessage());
return;
}
Auth::login($user, remember: true);
request()->session()->regenerate();
$consents->recordMany($this->consents, $user, $participant, request()->ip(), request()->userAgent());
session()->flash('success', __('تم إنشاء حسابك. تسجيل العضو قيد المراجعة من الأكاديمية.'));
$this->redirect(route('portal.home'), navigate: false);
}
public function render()
{
return view('livewire.portal.portal-sign-up', [
'branches' => Branch::where('is_active', true)->get(),
'consentTypes' => ConsentType::cases(),
]);
}
}
...@@ -45,6 +45,10 @@ ...@@ -45,6 +45,10 @@
'abilities' => \Laravel\Sanctum\Http\Middleware\CheckAbilities::class, 'abilities' => \Laravel\Sanctum\Http\Middleware\CheckAbilities::class,
'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class, 'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class,
'permission' => \App\Http\Middleware\CheckPermission::class, 'permission' => \App\Http\Middleware\CheckPermission::class,
// Self-registration is the one route group where a stranger writes
// into people and participants. It stays unreachable until an
// academy turns it on, so a deploy never opens it.
'self_registration' => \App\Http\Middleware\EnsureSelfRegistrationEnabled::class,
'super_admin' => \App\Http\Middleware\EnsureSuperAdmin::class, 'super_admin' => \App\Http\Middleware\EnsureSuperAdmin::class,
'branch' => \App\Http\Middleware\RequireBranchSelection::class, 'branch' => \App\Http\Middleware\RequireBranchSelection::class,
]); ]);
......
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Public self-registration, shipped **off**.
*
* This is the workstream that writes into `people` and `participants` with no
* member of staff in the loop — the tables the whole ERP hangs off. An open
* signup form with no verification is a spam and duplicate-record generator,
* so phone verification is not an enhancement here, it is the precondition.
*
* `portal.self_registration_enabled` defaults to **false** on every tenant,
* including new ones. The code ships; nothing changes for anybody until an
* academy turns it on deliberately. A public form that starts accepting
* strangers because a deploy happened is not a decision anyone made.
*
* `auth_otp_mode` is re-seeded to 'sms' here. It was seeded as 'demo' by
* 2026_07_27_000004, and 'demo' was the setting the deleted API's `0000`
* bypass keyed on. The bypass is gone, but leaving a setting called demo mode
* in place for a new tenant to inherit is an invitation.
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('otp_verifications')) {
Schema::create('otp_verifications', function (Blueprint $table) {
$table->id();
$table->foreignId('academy_id')->constrained()->cascadeOnDelete();
$table->string('phone', 20);
// Only the hash. A one-time code sitting in plaintext in a
// table is a password sitting in plaintext in a table, and the
// cache is not a safer place for it — it is a less auditable one.
$table->string('code_hash', 64);
$table->string('purpose', 24)->default('registration');
$table->unsignedTinyInteger('attempts')->default(0);
$table->timestamp('expires_at');
$table->timestamp('verified_at')->nullable();
$table->string('request_ip', 45)->nullable();
$table->timestamps();
$table->index(['academy_id', 'phone', 'purpose']);
$table->index('expires_at');
});
if (DB::getDriverName() === 'pgsql') {
DB::statement("ALTER TABLE otp_verifications ADD CONSTRAINT otp_verifications_purpose_check CHECK (purpose IN ('registration','password_reset','phone_change'))");
// One live code per phone per purpose. Requesting again
// supersedes rather than leaving several valid codes alive.
DB::statement("CREATE UNIQUE INDEX otp_verifications_one_live ON otp_verifications (academy_id, phone, purpose) WHERE verified_at IS NULL");
}
}
// A self-registered participant is not a member yet.
if (Schema::hasTable('participants') && ! Schema::hasColumn('participants', 'approved_at')) {
Schema::table('participants', function (Blueprint $table) {
$table->timestamp('approved_at')->nullable()->after('registration_source');
$table->foreignId('approved_by')->nullable()->after('approved_at')
->constrained('users')->nullOnDelete();
$table->jsonb('duplicate_candidates')->nullable()->after('approved_by');
});
// Everything that already exists was created by staff, so it is
// approved by definition — backfilled rather than left null, which
// would make every existing member look like a pending signup.
DB::statement('UPDATE participants SET approved_at = created_at WHERE approved_at IS NULL');
Schema::table('participants', function (Blueprint $table) {
$table->index(['academy_id', 'approved_at'], 'participants_academy_approved_index');
});
}
if (! Schema::hasTable('system_settings') || ! Schema::hasTable('academies')) {
return;
}
foreach (DB::table('academies')->pluck('id') as $academyId) {
$this->seed($academyId, 'portal', 'portal.self_registration_enabled', '0', 'boolean');
$this->seed($academyId, 'portal', 'portal.self_registration_requires_approval', '1', 'boolean');
// Re-seeded rather than assumed: 2026_07_27_000004 only touched
// academies that existed when it ran, so a tenant created since
// then would otherwise inherit the old default.
DB::table('system_settings')
->where('academy_id', $academyId)
->where('key', 'auth_otp_mode')
->where('value', 'demo')
->update(['value' => 'sms', 'updated_at' => now()]);
$this->seed($academyId, 'mobile_app', 'auth_otp_mode', 'sms', 'string');
}
}
private function seed(int $academyId, string $group, string $key, string $value, string $type): void
{
$exists = DB::table('system_settings')
->where('academy_id', $academyId)
->where('key', $key)
->exists();
if ($exists) {
return;
}
DB::table('system_settings')->insert([
'academy_id' => $academyId,
'key' => $key,
'value' => $value,
'group' => $group,
'type' => $type,
'created_at' => now(),
'updated_at' => now(),
]);
}
public function down(): void
{
Schema::dropIfExists('otp_verifications');
if (Schema::hasTable('participants') && Schema::hasColumn('participants', 'approved_at')) {
Schema::table('participants', function (Blueprint $table) {
$table->dropIndex('participants_academy_approved_index');
$table->dropConstrainedForeignId('approved_by');
$table->dropColumn(['approved_at', 'duplicate_candidates']);
});
}
}
};
...@@ -66,6 +66,29 @@ per tenant, per request. ...@@ -66,6 +66,29 @@ per tenant, per request.
color-scheme: light; color-scheme: light;
} }
/*
Focus is never removed here, and it is stated rather than left to the
browser: the default ring is a thin blue line that vanishes against a
tenant whose brand is blue. `currentColor` inherits the text colour, which
has already been contrast-checked against its own background, so the ring
is legible on every surface in the app and in either theme.
:focus-visible, not :focus — a mouse user tapping a card should not get a
ring, and a keyboard user always should.
*/
:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
border-radius: 4px;
}
/* A focused control must not end up underneath the sticky header or the
bottom tab bar when the page scrolls to it (WCAG 2.4.11). */
:focus {
scroll-margin-top: 5rem;
scroll-margin-bottom: 6rem;
}
[data-theme='dark'] { [data-theme='dark'] {
color-scheme: dark; color-scheme: dark;
} }
...@@ -95,6 +118,20 @@ per tenant, per request. ...@@ -95,6 +118,20 @@ per tenant, per request.
border: 1px solid var(--portal-border); border: 1px solid var(--portal-border);
} }
/* Visually hidden but available to a screen reader — for the text that
names an icon-only control where an aria-label would be the wrong tool. */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* 44x44 minimum with 8px between, per the accessibility gate. */ /* 44x44 minimum with 8px between, per the accessibility gate. */
.tap-target { .tap-target {
min-width: 2.75rem; min-width: 2.75rem;
......
@php
$brand = app(\App\Domain\Shared\Services\BrandingService::class)->forCurrentAcademy();
$locale = app()->getLocale();
$dir = in_array($locale, ['ar', 'he', 'fa', 'ur'], true) ? 'rtl' : 'ltr';
@endphp
<!DOCTYPE html>
<html dir="{{ $dir }}" lang="{{ $locale }}" class="h-full">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="csrf-token" content="{{ csrf_token() }}">
<meta name="theme-color" content="{{ $brand->themeColor }}">
{{-- A public signup page has no business in a search index: it names the
academy and invites strangers who did not come from the academy. --}}
<meta name="robots" content="noindex, nofollow">
<title>{{ $title ?? __('إنشاء حساب') }} — {{ $brand->academyName }}</title>
@if($brand->faviconUrl)<link rel="icon" href="{{ $brand->faviconUrl }}">@endif
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family={{ urlencode($brand->fontAr) }}:wght@400;500;600;700;800&display=swap" rel="stylesheet">
@vite(['resources/css/portal.css', 'resources/js/portal.js'])
@livewireStyles
<style>
:root {
{!! $brand->cssVariableBlock() !!}
--portal-bg: oklch(0.985 0.002 265);
--portal-surface: #ffffff;
--portal-border: oklch(0.92 0.004 265);
--portal-ink: oklch(0.22 0.02 265);
--portal-muted: oklch(0.55 0.015 265);
}
html, body { background: var(--portal-bg); color: var(--portal-ink); }
[x-cloak] { display: none !important; }
</style>
</head>
<body class="min-h-full antialiased">
<div class="mx-auto flex min-h-dvh max-w-lg flex-col px-4 py-8">
<header 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">{{ $brand->academyName }}</h1>
</header>
<main class="flex-1">
@if(session('error'))
<div class="portal-card mb-4 p-3 text-sm"
style="background: color-mix(in oklab, var(--brand-danger) 12%, var(--portal-surface));
border-color: color-mix(in oklab, var(--brand-danger) 35%, var(--portal-surface));">
{{ session('error') }}
</div>
@endif
{{ $slot }}
</main>
<footer class="mt-6 text-center">
<a href="{{ route('login') }}" class="text-xs font-semibold" style="color: var(--brand-600);">
{{ __('لديك حساب بالفعل؟ سجّل الدخول') }}
</a>
</footer>
</div>
@livewireScripts
</body>
</html>
...@@ -138,14 +138,16 @@ class="tap-target relative rounded-xl" ...@@ -138,14 +138,16 @@ class="tap-target relative rounded-xl"
<main class="flex-1 px-4 pb-28 pt-4"> <main class="flex-1 px-4 pb-28 pt-4">
@if(session('success')) @if(session('success'))
<div class="portal-card mb-4 p-3 text-sm" {{-- A message that appears after an action has to be announced, or
a screen-reader user submits a form and hears nothing. --}}
<div role="status" aria-live="polite" class="portal-card mb-4 p-3 text-sm"
style="background: color-mix(in oklab, var(--brand-success) 12%, var(--portal-surface)); style="background: color-mix(in oklab, var(--brand-success) 12%, var(--portal-surface));
border-color: color-mix(in oklab, var(--brand-success) 35%, var(--portal-surface));"> border-color: color-mix(in oklab, var(--brand-success) 35%, var(--portal-surface));">
{{ session('success') }} {{ session('success') }}
</div> </div>
@endif @endif
@if(session('error')) @if(session('error'))
<div class="portal-card mb-4 p-3 text-sm" <div role="alert" class="portal-card mb-4 p-3 text-sm"
style="background: color-mix(in oklab, var(--brand-danger) 12%, var(--portal-surface)); style="background: color-mix(in oklab, var(--brand-danger) 12%, var(--portal-surface));
border-color: color-mix(in oklab, var(--brand-danger) 35%, var(--portal-surface));"> border-color: color-mix(in oklab, var(--brand-danger) 35%, var(--portal-surface));">
{{ session('error') }} {{ session('error') }}
......
<div class="space-y-5">
<header>
<h1 class="text-xl font-bold text-gray-900">{{ __('تسجيلات في انتظار المراجعة') }}</h1>
<p class="mt-0.5 text-sm text-gray-500">
{{ __('أعضاء سجّلوا أنفسهم عبر البوابة. راجع المطابقات المحتملة قبل الاعتماد حتى لا يُنشأ ملف ثانٍ لعضو قائم.') }}
</p>
</header>
@forelse($pending as $participant)
<article class="rounded-xl border border-gray-200 bg-white p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<p class="font-bold text-gray-900">{{ $participant->person?->name_ar }}</p>
<p class="text-xs text-gray-500">
{{ $participant->person?->date_of_birth?->translatedFormat('j M Y') }}
@if($participant->branch) · {{ $participant->branch->name_ar ?? $participant->branch->name }} @endif
· {{ __('سجّل') }} {{ $participant->created_at?->diffForHumans() }}
</p>
@if($participant->guardians->isNotEmpty())
<p class="mt-1 text-xs text-gray-600">
{{ __('ولي الأمر') }}:
{{ $participant->guardians->first()?->person?->name_ar }}
<span class="font-mono" dir="ltr">{{ $participant->guardians->first()?->person?->phone }}</span>
</p>
@endif
</div>
<div class="flex gap-2">
<button type="button" wire:click="approve({{ $participant->id }})"
class="rounded-lg bg-emerald-600 px-4 py-2 text-xs font-bold text-white hover:bg-emerald-700">
{{ __('اعتماد') }}
</button>
<button type="button" wire:click="reject({{ $participant->id }})"
class="rounded-lg border border-red-300 px-4 py-2 text-xs font-bold text-red-700 hover:bg-red-50">
{{ __('رفض') }}
</button>
</div>
</div>
@if(!empty($participant->duplicate_candidates))
{{-- The findings DuplicateDetectionService has always produced
and nothing ever showed anyone. --}}
<div class="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2">
<p class="text-xs font-bold text-amber-900">{{ __('قد يكون عضواً قائماً بالفعل:') }}</p>
<ul class="mt-1 space-y-0.5">
@foreach($participant->duplicate_candidates as $candidate)
<li class="text-xs text-amber-800">• {{ $candidate['name'] ?? '—' }}</li>
@endforeach
</ul>
</div>
@endif
</article>
@empty
<div class="rounded-xl border border-gray-200 bg-white px-4 py-12 text-center">
<p class="font-semibold text-gray-900">{{ __('لا توجد تسجيلات في الانتظار') }}</p>
</div>
@endforelse
<div>{{ $pending->links() }}</div>
</div>
...@@ -26,7 +26,7 @@ class="mt-1 w-full rounded-xl border px-3 py-2.5 text-sm" ...@@ -26,7 +26,7 @@ class="mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
<input id="expires" type="date" dir="ltr" wire:model="expiresAt" <input id="expires" type="date" dir="ltr" wire:model="expiresAt"
class="num mt-1 w-full rounded-xl border px-3 py-2.5 text-sm" class="num mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
style="border-color: var(--portal-border); background: var(--portal-surface);"> style="border-color: var(--portal-border); background: var(--portal-surface);">
@error('expiresAt') <p class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror @error('expiresAt') <p role="alert" class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
</div> </div>
<div> <div>
...@@ -36,7 +36,7 @@ class="mt-1 block w-full text-xs"> ...@@ -36,7 +36,7 @@ class="mt-1 block w-full text-xs">
<div wire:loading wire:target="file" class="mt-1 text-[11px]" style="color: var(--portal-muted);"> <div wire:loading wire:target="file" class="mt-1 text-[11px]" style="color: var(--portal-muted);">
{{ __('جارٍ الرفع...') }} {{ __('جارٍ الرفع...') }}
</div> </div>
@error('file') <p class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror @error('file') <p role="alert" class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
</div> </div>
<button type="submit" wire:loading.attr="disabled" wire:target="upload,file" <button type="submit" wire:loading.attr="disabled" wire:target="upload,file"
......
...@@ -2,8 +2,8 @@ ...@@ -2,8 +2,8 @@
<x-portal.tabs :tabs="['installments' => __('الأقساط'), 'offers' => __('العروض')]" :active="$tab" /> <x-portal.tabs :tabs="['installments' => __('الأقساط'), 'offers' => __('العروض')]" :active="$tab" />
@error('pay') <div class="portal-card px-4 py-3 text-xs" style="color: var(--brand-danger);">{{ $message }}</div> @enderror @error('pay') <div role="alert" class="portal-card px-4 py-3 text-xs" style="color: var(--brand-danger);">{{ $message }}</div> @enderror
@error('offer') <div class="portal-card px-4 py-3 text-xs" style="color: var(--brand-danger);">{{ $message }}</div> @enderror @error('offer') <div role="alert" class="portal-card px-4 py-3 text-xs" style="color: var(--brand-danger);">{{ $message }}</div> @enderror
@if($tab === 'installments') @if($tab === 'installments')
@forelse($installments as $installment) @forelse($installments as $installment)
......
...@@ -44,7 +44,7 @@ ...@@ -44,7 +44,7 @@
<input id="amount" type="text" inputmode="decimal" dir="ltr" wire:model="amount" <input id="amount" type="text" inputmode="decimal" dir="ltr" wire:model="amount"
class="num mt-1 w-full rounded-xl border px-3 py-2.5 text-sm" class="num mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
style="border-color: var(--portal-border); background: var(--portal-surface);"> style="border-color: var(--portal-border); background: var(--portal-surface);">
@error('amount') <p class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror @error('amount') <p role="alert" class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
</div> </div>
<div> <div>
...@@ -55,7 +55,7 @@ class="num mt-1 w-full rounded-xl border px-3 py-2.5 text-sm" ...@@ -55,7 +55,7 @@ class="num mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
<p class="mt-1 text-[11px]" style="color: var(--portal-muted);"> <p class="mt-1 text-[11px]" style="color: var(--portal-muted);">
{{ __('الرقم الذي يظهر في رسالة التأكيد — به تُطابق الإدارة التحويل مع كشف الحساب') }} {{ __('الرقم الذي يظهر في رسالة التأكيد — به تُطابق الإدارة التحويل مع كشف الحساب') }}
</p> </p>
@error('senderReference') <p class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror @error('senderReference') <p role="alert" class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
</div> </div>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
...@@ -70,7 +70,7 @@ class="num mt-1 w-full rounded-xl border px-3 py-2.5 text-sm" ...@@ -70,7 +70,7 @@ class="num mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
<input id="date" type="date" dir="ltr" wire:model="transferredAt" <input id="date" type="date" dir="ltr" wire:model="transferredAt"
class="num mt-1 w-full rounded-xl border px-3 py-2.5 text-sm" class="num mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
style="border-color: var(--portal-border); background: var(--portal-surface);"> style="border-color: var(--portal-border); background: var(--portal-surface);">
@error('transferredAt') <p class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror @error('transferredAt') <p role="alert" class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
</div> </div>
</div> </div>
...@@ -81,7 +81,7 @@ class="mt-1 block w-full text-xs"> ...@@ -81,7 +81,7 @@ class="mt-1 block w-full text-xs">
<div wire:loading wire:target="proof" class="mt-1 text-[11px]" style="color: var(--portal-muted);"> <div wire:loading wire:target="proof" class="mt-1 text-[11px]" style="color: var(--portal-muted);">
{{ __('جارٍ رفع الملف...') }} {{ __('جارٍ رفع الملف...') }}
</div> </div>
@error('proof') <p class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror @error('proof') <p role="alert" class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
</div> </div>
<button type="submit" wire:loading.attr="disabled" wire:target="submit,proof" <button type="submit" wire:loading.attr="disabled" wire:target="submit,proof"
......
...@@ -32,7 +32,7 @@ class="peer sr-only"> ...@@ -32,7 +32,7 @@ class="peer sr-only">
@endforeach @endforeach
</div> </div>
@error('consents') <p class="mt-3 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror @error('consents') <p role="alert" class="mt-3 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
<button type="button" wire:click="saveConsents" <button type="button" wire:click="saveConsents"
class="mt-4 w-full rounded-xl px-4 py-2.5 text-sm font-bold" class="mt-4 w-full rounded-xl px-4 py-2.5 text-sm font-bold"
...@@ -104,7 +104,7 @@ class="mt-1 w-full rounded-xl border px-3 py-2.5 text-sm" ...@@ -104,7 +104,7 @@ class="mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
<input id="delpass" type="password" wire:model="deletionPassword" autocomplete="current-password" <input id="delpass" type="password" wire:model="deletionPassword" autocomplete="current-password"
class="mt-1 w-full rounded-xl border px-3 py-2.5 text-sm" class="mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
style="border-color: var(--portal-border); background: var(--portal-surface);"> style="border-color: var(--portal-border); background: var(--portal-surface);">
@error('deletionPassword') <p class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror @error('deletionPassword') <p role="alert" class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<button type="button" wire:click="requestDeletion" <button type="button" wire:click="requestDeletion"
......
...@@ -30,7 +30,7 @@ class="mt-1 w-full rounded-xl border px-3 py-2.5 text-sm" ...@@ -30,7 +30,7 @@ class="mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
</option> </option>
@endforeach @endforeach
</select> </select>
@error('sessionId') <p class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror @error('sessionId') <p role="alert" class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
<p class="mt-1 text-[11px]" style="color: var(--portal-muted);"> <p class="mt-1 text-[11px]" style="color: var(--portal-muted);">
{{ __('تُقبل الأعذار حتى ٧ أيام بعد الحصة.') }} {{ __('تُقبل الأعذار حتى ٧ أيام بعد الحصة.') }}
</p> </p>
...@@ -56,7 +56,7 @@ class="mt-1 w-full rounded-xl border px-3 py-2.5 text-sm" ...@@ -56,7 +56,7 @@ class="mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
<textarea id="reason" rows="3" wire:model="reason" <textarea id="reason" rows="3" wire:model="reason"
class="mt-1 w-full rounded-xl border px-3 py-2.5 text-sm" class="mt-1 w-full rounded-xl border px-3 py-2.5 text-sm"
style="border-color: var(--portal-border); background: var(--portal-surface);"></textarea> style="border-color: var(--portal-border); background: var(--portal-surface);"></textarea>
@error('reason') <p class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror @error('reason') <p role="alert" class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
</div> </div>
<div> <div>
...@@ -71,7 +71,7 @@ class="mt-1 block w-full text-xs"> ...@@ -71,7 +71,7 @@ class="mt-1 block w-full text-xs">
<div wire:loading wire:target="attachment" class="mt-1 text-[11px]" style="color: var(--portal-muted);"> <div wire:loading wire:target="attachment" class="mt-1 text-[11px]" style="color: var(--portal-muted);">
{{ __('جارٍ الرفع...') }} {{ __('جارٍ الرفع...') }}
</div> </div>
@error('attachment') <p class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror @error('attachment') <p role="alert" class="mt-1 text-[11px]" style="color: var(--brand-danger);">{{ $message }}</p> @enderror
</div> </div>
<button type="submit" wire:loading.attr="disabled" wire:target="submit,attachment" <button type="submit" wire:loading.attr="disabled" wire:target="submit,attachment"
......
This diff is collapsed.
...@@ -181,6 +181,14 @@ ...@@ -181,6 +181,14 @@
->middleware('permission:participants.create'); ->middleware('permission:participants.create');
Route::get('/participants/create', ParticipantForm::class)->name('participants.create') Route::get('/participants/create', ParticipantForm::class)->name('participants.create')
->middleware('permission:participants.create'); ->middleware('permission:participants.create');
// Must stay above /participants/{participant}: a literal segment
// registered after a wildcard never matches — the wildcard takes 'pending'
// and fails resolving it as a model, which is a 500 rather than a 404 and
// so does not even look like a routing problem.
Route::get('/participants/pending', \App\Livewire\Participants\PendingRegistrations::class)
->middleware('permission:participants.update')
->name('participants.pending');
Route::get('/participants/{participant}', ParticipantShow::class)->name('participants.show') Route::get('/participants/{participant}', ParticipantShow::class)->name('participants.show')
->middleware('permission:participants.view'); ->middleware('permission:participants.view');
Route::get('/participants/{participant}/edit', ParticipantForm::class)->name('participants.edit') Route::get('/participants/{participant}/edit', ParticipantForm::class)->name('participants.edit')
...@@ -738,6 +746,14 @@ ...@@ -738,6 +746,14 @@
->name('portal.native.revoke'); ->name('portal.native.revoke');
}); });
// Public self-registration. Shipped OFF: the middleware 404s unless an academy
// has deliberately enabled it, so no client's signup form opens because a
// deploy happened. 404 rather than 403 — a disabled feature should be
// indistinguishable from one that does not exist.
Route::middleware(['guest', 'self_registration', 'throttle:20,1'])
->get('/app/join', \App\Livewire\Portal\PortalSignUp::class)
->name('portal.join');
// Activation is a plain controller, not Livewire: a single-use token held in a // Activation is a plain controller, not Livewire: a single-use token held in a
// public property is serialised into the page on every round-trip. // public property is serialised into the page on every round-trip.
Route::middleware('guest')->group(function () { Route::middleware('guest')->group(function () {
......
...@@ -35,6 +35,7 @@ public function test_the_new_staff_screens_render_for_an_owner(): void ...@@ -35,6 +35,7 @@ public function test_the_new_staff_screens_render_for_an_owner(): void
'attendance.scan', 'attendance.scan',
'service-requests.index', 'service-requests.index',
'reports.transfer-reconciliation', 'reports.transfer-reconciliation',
'participants.pending',
] as $name) { ] as $name) {
$response = $this->actingAs($owner)->get(route($name)); $response = $this->actingAs($owner)->get(route($name));
......
This diff is collapsed.
This diff is collapsed.
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