# M11 — Client Portal

Phase 6. The client-facing surface. Highest-scrutiny security work in the
project — clients logging in must see only their own data, with zero
possibility of leakage between clients or into staff-only territory.

The public marketing website is out of scope for this build (may be revisited
later as a separate, standalone project — the firm's branding config in M01
already supports it if that changes).

## Dependencies
M02 (clients), M10 (`ClientMatterReportService`), M08 (shared documents).

## Design

Own guard `portal`, own layout, own route file (`routes/portal.php`).
Session-based auth (Sanctum only if a future mobile client needs API tokens —
v1 is session-only).

### `portal_users`
```php
$table->id(); $table->ulid('ulid')->unique();
$table->foreignId('firm_id')->constrained()->restrictOnDelete();
$table->foreignId('client_id')->constrained()->cascadeOnDelete();
$table->string('name'); $table->string('email')->unique();
$table->string('password');
$table->string('status')->default('active');  // active/disabled
$table->dateTime('last_login_at')->nullable();
$table->rememberToken();
$table->timestamps();
```
No self-registration — staff-side "Grant portal access" action (M02 client
page) sends a signed, expiring invite link to set a password. Staff can
disable access instantly.

### `shared_documents`
```php
$table->foreignId('document_id')->constrained()->cascadeOnDelete();
$table->foreignId('client_id')->constrained()->cascadeOnDelete();
$table->foreignId('shared_by')->constrained('users');
$table->dateTime('shared_at');
$table->unique(['document_id', 'client_id']);
```
Sharing is an explicit staff action (M08 document page → "Share with client").
**`confidentiality != standard` documents cannot be shared — hard-blocked in
the `ShareDocumentWithClient` Action itself**, not merely hidden in the UI, so
there is no code path that leaks a confidential document to the portal.

### `portal_requests` (appointment/inquiry requests, staff-actioned)
```php
$table->id();
$table->foreignId('client_id')->constrained()->cascadeOnDelete();
$table->foreignId('matter_id')->nullable()->constrained()->nullOnDelete();
$table->string('type');  // appointment / inquiry
$table->text('message');
$table->string('status')->default('open'); // open/actioned
$table->timestamps();
```
Notifies the matter's assigned lawyers (or firm-wide if no matter); portal
users cannot write anything else — no direct writes to matters/invoices.

### Portal pages (`middleware: auth:portal`)

| Page | Content |
|---|---|
| Dashboard | matter count, next hearing dates, outstanding balance, retainer balance |
| My matters | list → detail via `ClientMatterReportService` (M10) — sanitized: stage, hearing dates + outcomes only, shared documents |
| Invoices & receipts | own invoices with status + PDF download, receipts, retainer statement |
| Documents | only rows in `shared_documents` |
| Requests | submit appointment/inquiry |

### Security rules (build the tests before the views)

- Every portal controller extends a `PortalController` base that injects
  `auth('portal')->user()->client_id` — **never** trusts a route parameter for
  scoping.
- IDOR test suite: for every portal route, assert portal-user A cannot reach
  client B's matter/invoice/document by ID/ULID guessing.
- Portal serialization uses dedicated Resource/DTO classes distinct from staff
  serializers — internal remarks, comments, other parties' contacts, and
  non-client-facing financials are structurally absent from the response, not
  filtered at render time.
- Rate limiting on all portal POST routes; portal session separate cookie name
  from staff session; portal login never resolves a staff `User` model under
  any circumstance (test this explicitly — a classic guard-confusion bug).

## Acceptance criteria

- [ ] Portal invite → set password → login flow works; disabled accounts
      blocked immediately
- [ ] Full IDOR test suite green across every portal route
- [ ] Document sharing blocks non-standard confidentiality at the Action layer
      (test attempts to bypass via direct Action call, not just UI)
- [ ] Portal guard/session never resolves a staff account under any input
      (explicit test)
- [ ] Client dashboard/matter view exactly matches `ClientMatterReportService`
      sanitization (shared implementation with M10, not a re-serialization)
- [ ] Appointment/inquiry requests notify the correct lawyers and never allow
      a portal user to write to matters/invoices directly

## Claude Code kickoff prompt

> Read CLAUDE.md, docs/04-SECURITY.md §6, docs/modules/M10-reporting.md (for
> `ClientMatterReportService`), and this file. Write the full IDOR test suite
> and the guard-confusion test FIRST, before a single portal view exists — the
> portal is the highest-risk surface in the product and must be provably safe
> before it's usable. Then build: portal_users + invite flow → PortalController
> base with forced client_id scoping → matters/invoices/documents views (read-
> only, sanitized) → shared_documents + ShareDocumentWithClient Action (hard
> block on non-standard confidentiality) → portal_requests.
