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
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):

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 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

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 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

// ❌ 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/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:

// 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)