# Auth & Users Models ## Overview The Auth & Users module provides JWT-based authentication with role-based access control (RBAC), temporary user support for public shift signups, and refresh token rotation for enhanced security. **Models:** - **User** — User accounts with roles and permissions - **RefreshToken** — JWT refresh token storage with expiration **Key Features:** - bcrypt password hashing (12+ character policy enforced at schema level) - JWT access tokens (15min) + refresh tokens (7 days) - Refresh token rotation with atomic transactions - Role hierarchy: SUPER_ADMIN > INFLUENCE_ADMIN > MAP_ADMIN > USER > TEMP - Temporary user support with auto-expiration - Email verification workflow - User enumeration prevention (401 not 404) --- ## Models Summary | Model | Table | Description | |-------|-------|-------------| | User | `users` | User accounts with RBAC, permissions, temp user support | | RefreshToken | `refresh_tokens` | JWT refresh tokens with expiration tracking | --- ## User Model ### Purpose The User model represents all system users, from super admins to temporary volunteers created via public shift signup. It supports role-based access control, granular permissions, temporary user expiration, and comprehensive audit tracking via 33 relation fields. ### Fields | Field | Type | Required | Default | Description | |-------|------|----------|---------|-------------| | **Identity** | | | | | | id | String | ✓ | `cuid()` | Primary key | | email | String | ✓ | — | Unique email address (lowercase) | | password | String | ✓ | — | bcrypt hashed (12+ chars, 1 uppercase, 1 lowercase, 1 digit) | | name | String | ✗ | `null` | User display name | | phone | String | ✗ | `null` | Phone number | | **Authorization** | | | | | | role | UserRole | ✓ | `USER` | User role (see enum below) | | status | UserStatus | ✓ | `ACTIVE` | Account status (see enum below) | | permissions | Json | ✗ | `null` | Granular per-app permissions object | | **User Lifecycle** | | | | | | createdVia | UserCreatedVia | ✓ | `STANDARD` | Creation source (see enum below) | | expiresAt | DateTime | ✗ | `null` | Expiration date for TEMP users | | expireDays | Int | ✗ | `null` | Days until expiration (for TEMP users) | | lastLoginAt | DateTime | ✗ | `null` | Last login timestamp | | emailVerified | Boolean | ✓ | `false` | Email verification status | | **Audit** | | | | | | createdAt | DateTime | ✓ | `now()` | Creation timestamp | | updatedAt | DateTime | ✓ | Auto | Last update timestamp | ### Enums #### UserRole Role hierarchy (descending): ```prisma enum UserRole { SUPER_ADMIN // Full system access, can manage all users INFLUENCE_ADMIN // Manage influence module (campaigns, responses) MAP_ADMIN // Manage map module (locations, shifts, cuts) USER // Standard volunteer with assigned permissions TEMP // Temporary user (auto-expires, restricted access) } ``` **Role Capabilities:** - **SUPER_ADMIN:** Full access to all modules, user management, settings - **INFLUENCE_ADMIN:** Campaign CRUD, response moderation, email queue admin - **MAP_ADMIN:** Location CRUD, shift management, cut creation, canvass oversight - **USER:** Public campaign actions, shift signup, canvass sessions (via assigned cut) - **TEMP:** Canvass sessions only (via shift signup), auto-expires after X days #### UserStatus ```prisma enum UserStatus { ACTIVE // Normal active user INACTIVE // Manually deactivated (login blocked) SUSPENDED // Temporarily suspended (login blocked) EXPIRED // Auto-expired temp user (login blocked) } ``` #### UserCreatedVia ```prisma enum UserCreatedVia { ADMIN // Created by admin in user management PUBLIC_SHIFT_SIGNUP // Auto-created via public shift signup STANDARD // Self-registered (if enabled) } ``` ### Relations (33 total) **Authentication:** - `refreshTokens` → RefreshToken[] (onDelete: Cascade) **Influence Module (6):** - `campaignsCreated` → Campaign[] (creator, onDelete: SetNull) - `campaignEmails` → CampaignEmail[] (sender, onDelete: SetNull) - `responses` → RepresentativeResponse[] (submitter, onDelete: SetNull) - `responseUpvotes` → ResponseUpvote[] (onDelete: SetNull) **Map Module (8):** - `locationsCreated` → Location[] (creator, onDelete: SetNull) - `locationsUpdated` → Location[] (updater, onDelete: SetNull) - `addressesCreated` → Address[] (creator, onDelete: SetNull) - `addressesUpdated` → Address[] (updater, onDelete: SetNull) - `locationEdits` → LocationHistory[] (editor, onDelete: SetNull) - `cutsCreated` → Cut[] (creator, onDelete: SetNull) - `shiftSignups` → ShiftSignup[] (onDelete: SetNull) **Canvassing Module (4):** - `canvassVisits` → CanvassVisit[] (visitor, onDelete: Cascade) - `canvassSessions` → CanvassSession[] (onDelete: Cascade) - `trackingSessions` → TrackingSession[] (onDelete: Cascade) **Email Templates Module (4):** - `templatesCreated` → EmailTemplate[] (creator) - `templatesUpdated` → EmailTemplate[] (updater) - `templateVersionsCreated` → EmailTemplateVersion[] - `templateTestsSent` → EmailTemplateTestLog[] ### Indexes - **Unique:** `email` (case-insensitive via Prisma transform) ### Constraints - Email must be unique across all users - Password must meet policy: 12+ chars, 1 uppercase, 1 lowercase, 1 digit (enforced by Zod schema) - TEMP users must have `expiresAt` set - EXPIRED status auto-applied when `expiresAt` < now() --- ## RefreshToken Model ### Purpose The RefreshToken model stores JWT refresh tokens for token rotation. When a user logs in, both an access token (15min expiry, stored client-side) and a refresh token (7 day expiry, stored in DB) are issued. When the access token expires, the client uses the refresh token to obtain a new access token. For security, refresh tokens are rotated on each refresh (old token deleted, new token issued) using atomic Prisma transactions. ### Fields | Field | Type | Required | Default | Description | |-------|------|----------|---------|-------------| | id | String | ✓ | `cuid()` | Primary key | | token | String | ✓ | — | JWT refresh token string (unique, 512 chars) | | userId | String | ✓ | — | Foreign key to User | | expiresAt | DateTime | ✓ | — | Token expiration timestamp (7 days from issue) | | createdAt | DateTime | ✓ | `now()` | Token creation timestamp | ### Relations - `user` → User (onDelete: Cascade) — deleting user deletes all refresh tokens ### Indexes - **Unique:** `token` (fast lookup for refresh endpoint) - **Foreign Key:** `userId` (join to User) ### Constraints - Token must be unique (prevents replay attacks) - ExpiresAt must be > now() for valid tokens - Expired tokens cleaned up via cron job (daily) --- ## Relationships Diagram ```mermaid erDiagram User ||--o{ RefreshToken : has User ||--o{ Campaign : creates User ||--o{ CampaignEmail : sends User ||--o{ RepresentativeResponse : submits User ||--o{ ResponseUpvote : upvotes User ||--o{ ShiftSignup : "signs up for" User ||--o{ Location : creates User ||--o{ Location : updates User ||--o{ Address : "creates (addresses)" User ||--o{ Address : "updates (addresses)" User ||--o{ LocationHistory : edits User ||--o{ Cut : "creates (cuts)" User ||--o{ CanvassVisit : visits User ||--o{ CanvassSession : "has (sessions)" User ||--o{ TrackingSession : "tracks (gps)" User ||--o{ EmailTemplate : "creates (templates)" User ||--o{ EmailTemplate : "updates (templates)" User ||--o{ EmailTemplateVersion : "versions (templates)" User ||--o{ EmailTemplateTestLog : "tests (templates)" User { String id PK String email UK "unique, lowercase" String password "bcrypt hashed" String name String phone UserRole role "SUPER_ADMIN | INFLUENCE_ADMIN | MAP_ADMIN | USER | TEMP" UserStatus status "ACTIVE | INACTIVE | SUSPENDED | EXPIRED" Json permissions "granular per-app" UserCreatedVia createdVia DateTime expiresAt "TEMP user expiration" Int expireDays DateTime lastLoginAt Boolean emailVerified DateTime createdAt DateTime updatedAt } RefreshToken { String id PK String token UK String userId FK DateTime expiresAt DateTime createdAt } ``` --- ## Common Queries ### Create User (Admin) ```typescript const user = await prisma.user.create({ data: { email: 'volunteer@example.com', password: await bcrypt.hash('SecurePass123!', 10), name: 'Jane Volunteer', phone: '555-0100', role: UserRole.USER, status: UserStatus.ACTIVE, createdVia: UserCreatedVia.ADMIN, emailVerified: true, }, }); ``` ### Create Temp User (Public Shift Signup) ```typescript const tempUser = await prisma.user.create({ data: { email: 'temp@example.com', password: await bcrypt.hash(randomPassword, 10), // Generated password name: 'Temp Volunteer', role: UserRole.TEMP, status: UserStatus.ACTIVE, createdVia: UserCreatedVia.PUBLIC_SHIFT_SIGNUP, expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days expireDays: 30, }, }); ``` ### Find User with Relations ```typescript const user = await prisma.user.findUnique({ where: { email: 'admin@example.com' }, include: { campaignsCreated: { take: 5, orderBy: { createdAt: 'desc' } }, canvassSessions: { take: 10, orderBy: { startedAt: 'desc' } }, shiftSignups: { include: { shift: true } }, }, }); ``` ### Update Last Login ```typescript await prisma.user.update({ where: { id: userId }, data: { lastLoginAt: new Date() }, }); ``` ### Store Refresh Token ```typescript const refreshToken = await prisma.refreshToken.create({ data: { token: jwtRefreshToken, userId: user.id, expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days }, }); ``` ### Refresh Token Rotation (Atomic) ```typescript const newTokens = await prisma.$transaction(async (tx) => { // 1. Verify old token exists and is valid const oldToken = await tx.refreshToken.findUnique({ where: { token: oldRefreshToken }, include: { user: true }, }); if (!oldToken || oldToken.expiresAt < new Date()) { throw new Error('Invalid or expired refresh token'); } // 2. Delete old token await tx.refreshToken.delete({ where: { id: oldToken.id }, }); // 3. Generate new access + refresh tokens const newAccessToken = jwt.sign({ userId: oldToken.userId }, ACCESS_SECRET, { expiresIn: '15m' }); const newRefreshToken = jwt.sign({ userId: oldToken.userId }, REFRESH_SECRET, { expiresIn: '7d' }); // 4. Store new refresh token await tx.refreshToken.create({ data: { token: newRefreshToken, userId: oldToken.userId, expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), }, }); return { accessToken: newAccessToken, refreshToken: newRefreshToken }; }); ``` ### Expire Temp Users (Cron) ```typescript await prisma.user.updateMany({ where: { role: UserRole.TEMP, expiresAt: { lt: new Date() }, status: { not: UserStatus.EXPIRED }, }, data: { status: UserStatus.EXPIRED, }, }); ``` ### Clean Expired Refresh Tokens (Cron) ```typescript await prisma.refreshToken.deleteMany({ where: { expiresAt: { lt: new Date() }, }, }); ``` --- ## Data Flow ### User Registration Flow ```mermaid sequenceDiagram participant Client participant API participant Prisma participant bcrypt participant JWT Client->>API: POST /api/auth/register (email, password, name) API->>bcrypt: hash(password) bcrypt-->>API: hashedPassword API->>Prisma: user.create({ email, password: hashed, role: USER }) Prisma-->>API: user API->>JWT: sign(accessToken, { userId, email, role }) API->>JWT: sign(refreshToken, { userId }) JWT-->>API: tokens API->>Prisma: refreshToken.create({ token, userId, expiresAt }) Prisma-->>API: refreshToken API-->>Client: { user, accessToken, refreshToken } ``` ### Login Flow ```mermaid sequenceDiagram participant Client participant API participant Prisma participant bcrypt participant JWT Client->>API: POST /api/auth/login (email, password) API->>Prisma: user.findUnique({ where: { email } }) Prisma-->>API: user | null API->>bcrypt: compare(password, user.password) bcrypt-->>API: isValid alt Invalid credentials API-->>Client: 401 Unauthorized else Valid credentials API->>Prisma: user.update({ lastLoginAt: now() }) API->>JWT: sign(accessToken, { userId, email, role }) API->>JWT: sign(refreshToken, { userId }) JWT-->>API: tokens API->>Prisma: refreshToken.create({ token, userId, expiresAt }) Prisma-->>API: refreshToken API-->>Client: { user, accessToken, refreshToken } end ``` ### Token Refresh Flow ```mermaid sequenceDiagram participant Client participant API participant Prisma participant JWT Client->>API: POST /api/auth/refresh (refreshToken) API->>JWT: verify(refreshToken) JWT-->>API: payload | error alt Invalid token API-->>Client: 401 Unauthorized else Valid token API->>Prisma: $transaction start API->>Prisma: refreshToken.findUnique({ where: { token } }) Prisma-->>API: oldToken | null alt Token not found or expired API->>Prisma: $transaction rollback API-->>Client: 401 Unauthorized else Token valid API->>Prisma: refreshToken.delete({ where: { id: oldToken.id } }) API->>JWT: sign(newAccessToken, { userId, email, role }) API->>JWT: sign(newRefreshToken, { userId }) JWT-->>API: newTokens API->>Prisma: refreshToken.create({ token: newRefreshToken, userId, expiresAt }) API->>Prisma: $transaction commit Prisma-->>API: success API-->>Client: { accessToken, refreshToken } end end ``` --- ## Performance Notes ### Index Usage - **email unique index:** Used for login lookups (`WHERE email = ?`) - **refreshToken.token unique index:** Used for refresh endpoint (`WHERE token = ?`) - **refreshToken.userId index:** Used for user deletion cascades ### Query Optimization - Avoid loading all 33 user relations by default — use selective `include` or `select` - Use `findFirst` instead of `findMany().take(1)` for single record queries - Paginate user lists with `skip` + `take` + cursor-based pagination for large datasets ### N+1 Prevention ```typescript // ❌ N+1 query (loads campaigns one-by-one) const users = await prisma.user.findMany(); for (const user of users) { const campaigns = await prisma.campaign.findMany({ where: { createdByUserId: user.id } }); } // ✅ Single query with include const users = await prisma.user.findMany({ include: { campaignsCreated: true, }, }); ``` --- ## Security Considerations ### Password Policy Enforced at API schema level (`auth.schemas.ts`): ```typescript password: z.string() .min(12, 'Password must be at least 12 characters') .regex(/[A-Z]/, 'Password must contain at least one uppercase letter') .regex(/[a-z]/, 'Password must contain at least one lowercase letter') .regex(/[0-9]/, 'Password must contain at least one digit') ``` ### User Enumeration Prevention - `/api/auth/me` returns 401 (not 404) for missing users - Login endpoint returns generic "Invalid credentials" (not "Email not found") - Registration endpoint returns generic "Email already exists" (no user details) ### Refresh Token Security - Tokens stored in database (not just signed JWTs) - Rotation on every refresh (old token deleted) - Atomic transaction prevents race conditions - 7-day expiration with daily cleanup cron ### Role-Based Access Control Middleware enforces role requirements: ```typescript // Requires SUPER_ADMIN or MAP_ADMIN router.get('/api/locations', requireRole(UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN), ...) // Requires any non-TEMP user router.get('/api/campaigns', requireNonTemp, ...) // Requires any authenticated user router.get('/api/profile', authenticate, ...) ``` ### TEMP User Restrictions - Cannot access admin routes (blocked by `requireNonTemp` middleware) - Cannot create campaigns, locations, or templates - Can only canvass within assigned cut (verified by canvass service) - Auto-expire after `expireDays` (default 30) --- ## Troubleshooting ### "Email already exists" on registration **Cause:** Email uniqueness constraint violated **Solution:** Check for existing user: `prisma.user.findUnique({ where: { email } })` ### "Invalid refresh token" on refresh **Cause:** Token already used (rotation), expired, or manually deleted **Solution:** User must re-login to obtain new token pair ### "Password does not meet policy" on update **Cause:** Password validation regex mismatch **Solution:** Ensure new password has 12+ chars, 1 uppercase, 1 lowercase, 1 digit ### TEMP user cannot access route **Cause:** Route uses `requireNonTemp` middleware **Solution:** Upgrade user to `USER` role via admin panel ### Circular dependency: auth store ↔ api client **Cause:** Both modules import each other **Solution:** Use callback registration pattern (see `admin/src/lib/api.ts` + `admin/src/stores/auth.store.ts`) --- ## Related Documentation - [Database Overview](../index.md) — Complete ER diagram - [Schema Reference](../schema.md) — All model fields - [Influence Models](./influence.md) — Campaign relations - [Map Models](./map.md) — Location relations - [Canvassing Models](./canvass.md) — Session relations - [Email Template Models](./email-templates.md) — Template relations - [API Auth Routes](../../api/auth.md) — Authentication endpoints - [Security Audit](../../deployment/security.md) — Security findings and fixes