Skip to main content

Overview

OpenCouncil implements a flexible user management system that supports both public users and administrators with varying levels of access. The system uses magic link authentication and provides fine-grained administrative rights that can be scoped to specific cities, parties, or individual people.

User types

The system recognizes two primary user types:

Public users

Citizens who sign up to receive notifications about council meetings based on their location and topic preferences. They can also create and view highlights.

Administrative users

Users with special permissions to manage content, view admin interfaces, and perform privileged operations. Admins are created by super admins.

Authentication system

OpenCouncil uses Auth.js (NextAuth v5) with email-based magic link authentication powered by Resend.
1

User requests access

User enters their email address on the login page
2

Magic link sent

System sends a magic link to the user’s email via Resend
3

User clicks link

Clicking the link authenticates the user and creates a session
4

Session established

User is logged in and can access their personalized content
No passwords are used. This eliminates password management overhead and improves security.

User creation and onboarding

Public user creation

Public users are created automatically when they:
  • Sign up for notifications for a city
  • Submit a petition
  • Create a highlight (requires login)
The user receives a magic link to complete their registration.

Administrative user creation

Super admins can create administrative users through the admin interface:
1

Create user

Super admin provides email address and name
2

User receives invitation

System sends an invitation email with the user’s name
3

User signs in

User can sign in normally via magic link
4

Grant admin rights

Super admin assigns administrative rights (see below)
Administrative users must be explicitly created by super admins—they cannot self-register with admin privileges.

Administrative rights system

Administrative rights are managed through the Administers table, which creates granular permissions.

Permission scopes

Administrative rights can be scoped to different entities:
Scope: Specific cityPermissions:
  • Manage meetings for that city
  • Create and edit subjects
  • Manage city-specific settings
  • View admin interfaces for the city
Database: { userId, cityId, partyId: null, personId: null }

Authorization helpers

OpenCouncil provides two key authorization methods in src/lib/auth.ts:
// Returns boolean for conditional rendering
const editable = await isUserAuthorizedToEdit({ 
  cityId: data.meeting.cityId 
});

if (editable) {
  return <EditButton />;
}
CRITICAL: Both methods are async and must be awaited. Failing to await will bypass authorization checks and create security vulnerabilities.

User data model

The User model tracks essential information:
prisma/schema.prisma
model User {
  id            String   @id @default(cuid())
  name          String?
  email         String   @unique
  emailVerified DateTime?
  phone         String?
  onboarded     Boolean  @default(false)
  allowContact  Boolean  @default(false)
  isSuperAdmin  Boolean  @default(false)
  
  // Relations
  accounts      Account[]
  sessions      Session[]
  administers   Administers[]
  notificationPreferences NotificationPreference[]
  highlights    Highlight[]
  
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

Key fields

Tracks whether the user has completed initial setup (e.g., set notification preferences)
Indicates whether the user consents to being contacted by administrators
Boolean flag granting platform-wide administrative privileges
Optional phone number for WhatsApp/SMS notifications

Administers table

The Administers model creates many-to-many relationships between users and administrative scopes:
prisma/schema.prisma
model Administers {
  id        String   @id @default(cuid())
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  userId    String
  cityId    String?
  partyId   String?
  personId  String?
  
  city      City?    @relation(fields: [cityId], references: [id], onDelete: Cascade)
  party     Party?   @relation(fields: [partyId], references: [id], onDelete: Cascade)
  person    Person?  @relation(fields: [personId], references: [id], onDelete: Cascade)

  @@unique([userId, cityId, partyId, personId])
}
The unique constraint ensures a user can only have one administrative relationship per unique combination of city, party, and person.

Admin UI implementation

The admin interface should be implemented at /admin using shadcn components with good UX.

Required functionality

1

User list

Display all users with:
  • Email, name, onboarded status
  • Super admin badge
  • Administrative scopes (city, party, person)
2

Create user

Form to create new users:
  • Email input (required)
  • Name input (required)
  • Send invitation button
3

Manage admin rights

Interface to add/remove Administers records:
  • Select user
  • Choose scope type (city/party/person)
  • Select specific entity
  • Save relationship
4

Invite users

Send invitation emails to non-onboarded users:
  • Button to send invite
  • Includes user’s name in email
  • Uses existing email templates

UI components

User table

  • Sortable columns
  • Search/filter functionality
  • Status badges (onboarded, super admin)
  • Action buttons (edit, invite, delete)

Rights editor

  • Dropdown to select user
  • Radio buttons for scope type
  • Searchable select for entity
  • Add/remove buttons with confirmation

User profile management

Users can manage their own information through /profile:
  • Name
  • Email (read-only)
  • Phone number
  • Contact preferences
  • List of cities with active preferences
  • Edit button → redirects to /{cityId}/notifications
  • Unsubscribe button → deletes preference
  • Add notifications for another city
  • View assigned administrative scopes
  • Read-only display (managed by super admins)

Security best practices

// ✅ Correct
const authorized = await isUserAuthorizedToEdit({ cityId });

// ❌ Wrong - bypasses check!
const authorized = isUserAuthorizedToEdit({ cityId });
  • UI components: Use isUserAuthorizedToEdit() (returns boolean)
  • API routes: Use withUserAuthorizedToEdit() (throws error)
Never rely on client-side authorization. Always verify permissions on the server for sensitive operations.
Grant users the minimum permissions needed. Use scoped admin rights instead of super admin when possible.

Integration with other systems

Notifications

Users can sign up for notifications, which creates a NotificationPreference record. See the notifications system guide for details.

Highlights

Users can create highlights if:
  • They are logged in, AND
  • Either:
    • The city has highlightCreationPermission: EVERYONE, OR
    • The user is an admin for that city

Petitions

Anonymous users can submit petitions, which creates a User record if the email doesn’t exist. The petition system uses the same magic link flow.

File reference

Key implementation files

  • src/auth.ts - Auth.js configuration
  • src/lib/auth.ts - Authorization helper functions
  • src/lib/db/notifications.ts - User preference management
  • src/app/[locale]/(other)/profile/page.tsx - User profile page
  • prisma/schema.prisma - User and Administers models (lines 639-741)

Build docs developers (and LLMs) love