Skip to content

Impersonation for Helpdesk agents #601

@SvenVw

Description

@SvenVw

Context

Support agents frequently need to "see what the user sees" to debug issues (e.g., a farmer's parcel not showing on the map). The better-auth admin plugin provides impersonation, user management, ban/unban, and role management — all critical for a helpdesk team.

Depends on: #599 (MVP — agent roles must exist)

Scope

In Scope

  • Better-auth admin plugin integration in fdm-core/src/authentication.ts
  • Schema migration: role, banned, ban_reason, ban_expires columns on user table; impersonated_by on session table
  • Custom access control with createAccessControl() for helpdesk-specific permissions
  • Three-tier role system: user (default), helpdeskAgent, helpdeskAdmin
  • User-consent impersonation: agent requests access, user grants it for a chosen duration, only then can the agent impersonate
  • impersonation_grants table: tracks consent (ticket_id, agent_id, user_id, granted_until, revoked_at)
  • Consent request flow: agent clicks "Verzoek toegang" → user receives in-app prompt + email → user approves and picks an expiry date → agent can impersonate until that date
  • Impersonation guard: impersonateUser() checks for a valid (non-expired, non-revoked) grant before proceeding
  • Impersonation banner: persistent top-bar during impersonation session (visible on all pages)
  • Audit logging: log consent request, grant, revoke, impersonation start/stop in ticket_activity
  • User management dashboard (/admin/users): list users, search, set roles, ban/unban, revoke sessions
  • Client-side changes: add adminClient() plugin to fdm-app/app/lib/auth-client.ts
  • Integration with ticket view: user info panel showing role, ban status, sessions; request-access / impersonate button (depending on grant status)

Acceptance Criteria

  • Admin plugin added to fdm-core authentication (server + client)
  • Running db:generate-authn adds the new columns to user/session tables
  • Admins can assign helpdeskAgent role to users via /admin/users
  • Admins can ban/unban users with reason and optional expiry
  • Agent can request impersonation access from the ticket detail view ("Verzoek toegang")
  • User receives an in-app notification + email asking them to grant access
  • User can approve and choose an expiry date (presets: einde van vandaag, morgen, volgende week; or custom date picker; default: einde van vandaag)
  • User can decline the request
  • User can revoke a previously granted access at any time from the ticket view
  • Agent can only impersonate after the user has granted access and the grant has not expired
  • If no valid grant exists, the "Impersonate" button is disabled with tooltip "Wacht op toestemming van gebruiker"
  • During impersonation, a persistent amber banner shows "Impersonating: [name]" + "Stop" button + remaining time
  • Impersonation sessions expire at the earlier of: grant expiry or 1 hour
  • All events are logged in ticket_activity: access_requested, access_granted, access_declined, access_revoked, impersonation_started, impersonation_ended
  • Agents can see user context in ticket sidebar: name, email, role, ban status, session count
  • Only helpdeskAdmin can promote/demote agents and ban users
  • helpdeskAgent can request impersonation and list users (for context)
  • Custom access control enforces helpdesk-specific permissions

Technical Implementation

1. Server-Side (fdm-core/src/authentication.ts)

Add to plugins array:

import { admin } from "better-auth/plugins"
import { createAccessControl } from "better-auth/plugins/access"

const statement = createAccessControl({
    user: ["create", "list", "set-role", "ban", "impersonate", "delete", "set-password"],
    session: ["list", "revoke", "delete"],
    ticket: ["create", "read", "update", "assign", "close"],
    helpdesk: ["view-inbox", "manage-tickets", "manage-agents", "manage-kb"],
} as const)

plugins: [
    ...existingPlugins,
    admin({
        defaultRole: "user",
        adminRoles: ["helpdeskAdmin"],
        ac,
        impersonationSessionDuration: 60 * 60,  // 1 hour
    }),
]

2. Client-Side (fdm-app/app/lib/auth-client.ts)

import { adminClient } from "better-auth/client/plugins"

export const authClient = createAuthClient({
    plugins: [organizationClient(), magicLinkClient(), adminClient()],
})

3. Impersonation Consent Schema

// fdm-helpdesk schema — new table
export const impersonationGrants = fdmHelpdeskSchema.table("impersonation_grants", {
    grant_id: text().primaryKey(),
    ticket_id: text().notNull(),
    agent_id: text().notNull(),         // Agent requesting access
    user_id: text().notNull(),          // User granting access
    status: text().notNull().default("pending"),  // 'pending', 'granted', 'declined', 'revoked', 'expired'
    requested_at: timestamp({ withTimezone: true }).notNull().defaultNow(),
    granted_at: timestamp({ withTimezone: true }),
    granted_until: timestamp({ withTimezone: true }),  // When access expires
    revoked_at: timestamp({ withTimezone: true }),
    revoked_by: text(),                 // 'user' or 'agent' or 'system'
})

4. Consent Flow

Agent clicks "Verzoek toegang"
        │
        ▼
┌────────────────────────────────────────────────────┐
│  Insert impersonation_grant (status: 'pending')    │
│  Log ticket_activity: 'access_requested'           │
│  Send in-app notification + email to user           │
└────────────────────┬───────────────────────────────┘
                     │
                     ▼
┌────────────────────────────────────────────────────┐
│  User sees request in ticket view or email:         │
│                                                     │
│  "Medewerker Jan wil meekijken in uw account om uw       │
│   probleem op te lossen. Tot wanneer wilt u toegang │
│   verlenen?"                                        │
│                                                     │
│  [Vandaag]  [Morgen]  [1 week]  [📅 Kies datum]    │
│                                     [Weigeren]      │
└────────────────────┬───────────────────────────────┘
                     │
            ┌────────┴────────┐
            ▼                 ▼
     User approves       User declines
     (set granted_at,    (set status: 'declined',
      granted_until,      log activity)
      status: 'granted',
      log activity)
            │
            ▼
┌────────────────────────────────────────────────────┐
│  Agent sees "Impersonate" button become active      │
│  Badge: "Toegang t/m 7 mei"                         │
│  Agent clicks → impersonateUser() checks grant      │
│  → valid? proceed : reject with 403                 │
└────────────────────────────────────────────────────┘

5. Impersonation Guard

export async function startImpersonation(
    fdm: FdmType,
    agent_id: string,
    user_id: string,
    ticket_id: string,
): Promise<void> {
    // Check for a valid grant
    const grant = await fdm.select().from(impersonationGrants)
        .where(and(
            eq(impersonationGrants.agent_id, agent_id),
            eq(impersonationGrants.user_id, user_id),
            eq(impersonationGrants.ticket_id, ticket_id),
            eq(impersonationGrants.status, "granted"),
            gt(impersonationGrants.granted_until, new Date()),
        ))
        .limit(1)

    if (!grant.length) {
        throw new Error("No valid impersonation grant. User must approve access first.")
    }

    // Proceed with better-auth impersonation
    // The impersonation session duration = min(grant.granted_until - now, 1 hour per session)
}

6. UI — Agent Ticket Sidebar (consent states)

── No grant requested ──
┌─── Klantinfo ──────────────────┐
│  Piet de Boer                   │
│  piet@example.nl                │
│  Rol: user · Geblokkeerd: Nee   │
│                                 │
│  [🔑 Verzoek toegang]           │
└─────────────────────────────────┘

── Grant pending ──
┌─── Klantinfo ──────────────────┐
│  Piet de Boer                   │
│  ⏳ Wacht op toestemming...     │
│                                 │
│  [🔑 Verzoek toegang] (grayed)  │
└─────────────────────────────────┘

── Grant active ──
┌─── Klantinfo ──────────────────┐
│  Piet de Boer                   │
│  ✅ Toegang t/m 7 mei           │
│                                 │
│  [👤 Impersonate]  [🔒 Blokkeer]│
└─────────────────────────────────┘

── Grant expired / declined ──
┌─── Klantinfo ──────────────────┐
│  Piet de Boer                   │
│  ❌ Toegang verlopen/geweigerd  │
│                                 │
│  [🔑 Opnieuw verzoeken]         │
└─────────────────────────────────┘

7. UI — User Ticket View (consent prompt)

When a grant is pending, the user sees a banner on their ticket:

┌──────────────────────────────────────────────────────────────────┐
│  🔑 Supportmedewerker Jan wil meekijken in uw account           │
│     om uw probleem op te lossen.                                │
│                                                                  │
│  Tot wanneer wilt u toegang verlenen?                            │
│  [Vandaag]  [Morgen]  [1 week]  [📅 Kies datum]                 │
│                                              [Weigeren]          │
│                                                                  │
│  ℹ️ De medewerker kan uw account bekijken alsof hij u is.        │
│     U kunt de toegang op elk moment intrekken.                   │
└──────────────────────────────────────────────────────────────────┘

After granting:

┌──────────────────────────────────────────────────────────────────┐
│  ✅ U heeft Jan toegang verleend t/m 7 mei.                      │
│     [Toegang intrekken]                                          │
└──────────────────────────────────────────────────────────────────┘

8. Impersonation Banner Component

// fdm-app/app/components/blocks/support/impersonation-banner.tsx
// Persistent amber bar at top of viewport during impersonation
// Shows: "⚠️ Impersonating: [User Name] ([email]) — [Stop Impersonating]"
// Placed in root layout, rendered conditionally on session.impersonatedBy

4. User Management Page (/admin/users)

Feature API Call
List users authClient.admin.listUsers({ query: { limit, offset, searchField, searchValue } })
Set role authClient.admin.setRole({ userId, role })
Ban user authClient.admin.banUser({ userId, banReason, banExpiresIn })
Unban user authClient.admin.unbanUser({ userId })
Impersonate authClient.admin.impersonateUser({ userId })
Revoke sessions authClient.admin.revokeUserSession({ sessionToken })

10. Ticket Detail Integration

In the agent ticket detail sidebar, the "Klantinfo" section adapts based on grant status (see wireframes in section 6 above).

11. Audit Logging

// On consent request, grant, decline, revoke — log to ticket_activity:
await db.insert(ticketActivity).values({
    activity_id: createId(),
    ticket_id,
    actor_id: agentUserId,  // or userId for grant/decline/revoke
    action: "access_requested",  // or 'access_granted', 'access_declined', 'access_revoked'
    new_value: targetUserId,
    metadata: { grant_id, duration_minutes },
})

// On impersonation start/stop, also log:
await db.insert(ticketActivity).values({
    activity_id: createId(),
    ticket_id,
    actor_id: agentUserId,
    action: "impersonation_started",
    new_value: targetUserId,
    metadata: { grant_id },
})

UI Wireframes

See fdm-helpdesk.md → "Impersonation Workflow for Helpdesk" and "Admin User Management Dashboard" sections for full wireframes.

Testing Requirements

  • Integration tests: Request access → user grants → agent impersonates → verify session has impersonatedBy → stop → verify reverted
  • Integration tests: Request access → user declines → agent tries impersonate → verify 403
  • Integration tests: Grant expires → agent tries impersonate → verify 403
  • Integration tests: User revokes mid-session → verify impersonation ends
  • Unit tests: Access control permission checks (agent can request, user cannot impersonate)
  • Unit tests: Grant expiry calculation, status transitions
  • Manual test: Full consent flow from both agent and user perspectives

Definition of Done

An agent can request access to view a user's account. The user explicitly grants time-limited access from within their ticket. Only then can the agent impersonate. All consent and impersonation events are logged for audit. The user can revoke access at any time.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions