Context
Derived from the audit in #205. Depends on #228 (schema foundation).
Currently AdminAuthService reads and writes lockout state (failed_attempts, locked_until, last_login_at) to the shared auth_metadata table — the same table used by the app platform. This means a lockout on the admin platform locks the user out of the app too, and admin login history is mixed with app login history. last_active_at for admin platform activity does not exist anywhere.
What's needed
1. AdminAuthMetadata entity + model action
2. Migrate lockout logic in AdminAuthService
Move all reads/writes from auth_metadata → admin_auth_metadata:
ensureAuthMetadata → uses AdminAuthMetadataModelAction
recordFailedLogin → increments admin_auth_metadata.failed_attempts, sets locked_until
recordSuccessfulLogin → resets failed_attempts, writes last_login_at
throwIfLocked → reads from admin_auth_metadata
auth_metadata must not be touched by any admin auth path after this change.
3. AdminJwtGuard — write last_active_at with Redis debounce
After a request passes auth, fire-and-forget update admin_auth_metadata.last_active_at = now() for the authenticated user. To avoid a DB write on every request:
- Redis key:
admin:active:<userId> with TTL of 5 minutes
- If key exists → skip DB write
- If key missing → write to DB, set Redis key
The update must not block the request — wrap in a detached promise with silent error logging.
Acceptance criteria
Depends on
#228
Context
Derived from the audit in #205. Depends on #228 (schema foundation).
Currently
AdminAuthServicereads and writes lockout state (failed_attempts,locked_until,last_login_at) to the sharedauth_metadatatable — the same table used by the app platform. This means a lockout on the admin platform locks the user out of the app too, and admin login history is mixed with app login history.last_active_atfor admin platform activity does not exist anywhere.What's needed
1.
AdminAuthMetadataentity + model actionadmin_auth_metadata(created in feat(admin-teams): schema foundation migrations #228)AdminAuthMetadataModelActionextendingAbstractModelAction<AdminAuthMetadata>ensureForUser(userId)— upsert a row if one doesn't exist yet2. Migrate lockout logic in
AdminAuthServiceMove all reads/writes from
auth_metadata→admin_auth_metadata:ensureAuthMetadata→ usesAdminAuthMetadataModelActionrecordFailedLogin→ incrementsadmin_auth_metadata.failed_attempts, setslocked_untilrecordSuccessfulLogin→ resetsfailed_attempts, writeslast_login_atthrowIfLocked→ reads fromadmin_auth_metadataauth_metadatamust not be touched by any admin auth path after this change.3.
AdminJwtGuard— writelast_active_atwith Redis debounceAfter a request passes auth, fire-and-forget update
admin_auth_metadata.last_active_at = now()for the authenticated user. To avoid a DB write on every request:admin:active:<userId>with TTL of 5 minutesThe update must not block the request — wrap in a detached promise with silent error logging.
Acceptance criteria
auth_metadataadmin_auth_metadata.last_login_atis updated on every successful admin loginadmin_auth_metadata.last_active_atis updated at most once per 5 minutes per user via the guardAdminAuthMetadataModelActioninstead ofAuthMetadataModelActionDepends on
#228