18 KiB
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 |
| 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):
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
enum UserStatus {
ACTIVE // Normal active user
INACTIVE // Manually deactivated (login blocked)
SUSPENDED // Temporarily suspended (login blocked)
EXPIRED // Auto-expired temp user (login blocked)
}
UserCreatedVia
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
expiresAtset - 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
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)
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)
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
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
await prisma.user.update({
where: { id: userId },
data: { lastLoginAt: new Date() },
});
Store Refresh Token
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)
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)
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)
await prisma.refreshToken.deleteMany({
where: {
expiresAt: { lt: new Date() },
},
});
Data Flow
User Registration Flow
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
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
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
includeorselect - Use
findFirstinstead offindMany().take(1)for single record queries - Paginate user lists with
skip+take+ cursor-based pagination for large datasets
N+1 Prevention
// ❌ 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):
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/mereturns 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:
// 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
requireNonTempmiddleware) - 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 — Complete ER diagram
- Schema Reference — All model fields
- Influence Models — Campaign relations
- Map Models — Location relations
- Canvassing Models — Session relations
- Email Template Models — Template relations
- API Auth Routes — Authentication endpoints
- Security Audit — Security findings and fixes