# Soft-deleted `teams` row leaves the linked user able to log in

**Filed:** Batch 53, 2026-08-02 (investigation).
**Filed as ticket:** Batch 56, 2026-08-02.
**Status:** deferred to post-demo. Reported only per user instruction —
no auth code was changed.

## Problem

Soft-deleting a `teams` row (`teams.isDelete = 1`) does NOT prevent
the linked `users` row from logging in. The auth path never touches
`teams`. The staff member is invisible in staff pickers and staff
lists but their account is functionally alive: they can log in,
receive a Sanctum token, load the app, and — because
`SessionDataController` derives `staffId` from `Auth::user()` rather
than from a picker — they can even author sessions with themselves
as the technician of record.

## Auth path trace

[`UserController::login`](../../app/Http/Controllers/UserController.php#L122-L242):

1. Line 134 — user lookup: `User::where('isDelete', false)` +
   username/email match. **Only checks `users.isDelete`.**
2. Line 150 — password: `Hash::check`.
3. Line 169 — status gate: `($user->status ?? 'active') !== 'active'`.
4. Line 178 — setup gate: empty password.
5. Line 209 — token issued.

**Nowhere** in this chain is `teams` joined, `staffId` existence
checked, or `teams.isDelete` guarded.

[`UserController::userInfo`](../../app/Http/Controllers/UserController.php#L266)
(called by the FE on every navigation to refresh the current user):

- Re-checks `users.status` only (line 280).
- Line 292: `$userStaff = $user->staffId ? Team::find($user->staffId) : null`
  — **`Team::find()` does NOT filter `isDelete`.** A soft-deleted
  staff row is still returned. The `staff` field in the response
  (line 328) renders their name in the app header exactly as before.

Middleware (`ApiKeyMiddleware`, `CheckPermission`, `VerifyWebsiteKey`)
does not touch `teams` either.

## Session-recorder consequence

[`SessionDataController`](../../app/Http/Controllers/SessionDataController.php)
derives the recording technician's `staffId` from `Auth::user()->staffId`,
not from a picker that filters `teams.isDelete`. The staff picker
correctly excludes soft-deleted staff (Batch 53 verified), so nobody
ELSE can pick a deleted staff member as technician — but the deleted
staff member themselves, if they log in, will be recorded as the
technician of every session they create. Their name will render in
the header (userInfo), on the recorder as themselves, and be stamped
into `session_data_records.staffId`.

## Concrete incident — Kerthiga Manokumar

On 2026-08-02 at 14:36:46, `teams.id=538` (Kerthiga Manokumar) was
soft-deleted with no audit trail (see
[team-destroy-audit-log.md](team-destroy-audit-log.md)). Her linked
user account was unchanged:

```
users.id       = 283
users.staffId  = 538          -- points at now-soft-deleted teams row
users.status   = active
users.isDelete = 0
```

Applied to the auth path: **she can log in.** Token issued. Full
role permissions resolve. `userInfo` returns her user record and
still renders her name in the header via the unfiltered
`Team::find(538)`. She would not appear in the recorder's Staff
picker (that picker correctly filters `teams.isDelete=0`), but she
could still record sessions as herself because `staffId` comes from
`Auth::user()`, not the picker.

Kerthiga was NOT restored — that was a data decision left to the
client — but the fact that no code change is needed for her account
to keep working is what this ticket is about.

## Recommended post-demo fix

Two layers, both cheap:

1. **`UserController::login`** — after the password check, load the
   linked staff row and reject login when `teams.isDelete = 1`:
   ```php
   if ($user->staffId) {
       $staff = DB::table('teams')->where('id', $user->staffId)->first(['isDelete']);
       if ($staff && $staff->isDelete) {
           return response()->json([
               'code' => 403, 'status' => false,
               'message' => "Your staff record has been removed. Contact your administrator.",
               'reason' => 'staff_deleted', 'data' => null,
           ], 403);
       }
   }
   ```
   Same shape as the existing `status !== 'active'` gate at line 169.
   Runs AFTER credentials verify so it does not leak account state
   for wrong passwords.

2. **`UserController::userInfo`** — same guard, so an already-issued
   token can't outlive the staff row. Revoke the current token on
   failure (mirror the pattern at line 282).

Consider also filtering `Team::find()` to `->where('isDelete', 0)`
so the header display goes blank instead of showing the deleted
name — small nice-to-have.

## Not fixed today

User instruction was explicit: "Report only — do not change auth."
Auth changes are load-bearing and mid-demo is the wrong time. Land
this together with the audit log from
[team-destroy-audit-log.md](team-destroy-audit-log.md) so the "why
was this account destroyed" and "why can they still log in" answers
ship in the same release.

## Related

- [team-destroy-audit-log.md](team-destroy-audit-log.md) —
  `TeamController::destroy` writes no `activity_logs` row; the
  Kerthiga incident is the concrete case for both tickets.
- [aba-staff-designation.md](aba-staff-designation.md) — same batch,
  the staff-picker gap that made this incident visible.
