Danvas
Danvas
DashboardSupportWelcome

👤 USER DOCS

User Guides

Getting Started

Getting StartedDashboard & OnboardingApp Settings

Tutorials

Tutorial: Setting Up Shift Tasks & ClosersTutorial: Managing Incidents in the InboxTutorial: Tracking Compliance & Sync StatusTutorial: Operational Workflows with the AI AssistantTutorial: Building & Deploying Custom Checklists

Daily Operations (Staff)

Shift Workspace & TasksService Day SetupDaily Line-UpStaff Service Day ReportsForms

Communication & Chat

Messages & AnnouncementsUnified Operations InboxAI Assistant

Manager & Admin Guides

Daily Line-Up SetupStaff SchedulingManaging LocationsNPS and Guest FeedbackCouponsContacts and Guest HistoryManager CloseoutsDaily Line-Up & ComplianceAnalyticsIncident ReportingWhistleblower Concerns & FeedbackAdmin Tools

⚙️ DEVELOPER DOCS

Getting Started

Getting StartedDevelopmentDeployment Guide

Architecture

Architecture OverviewExplanation: AI Integration & Tenant SecurityExplanation: Dynamic Forms Engine DesignExplanation: Compliance Ledger DesignExplanation: Live Sync & Data FreshnessData FlowArchitecture Decision Records

Core Domain

Core DomainDatabase ReferenceLocations DomainAuth & RBACScheduling DomainReports DomainIncidents DomainUnified Operations InboxLive Sync Data FreshnessToast Sync PipelineNotifications DomainCoupons and Guest NPSAudit Log & Compliance ArchitectureDesign Audit FindingsAI Chat IntegrationAnalytics & Tips Integration

Frontend

Frontend ArchitectureFormsLoading SkeletonsComponentsPWA & Offline ShellScreenshots

API Reference

API Reference

Endpoints

POS Sales APIOptimization Data APISchedule Shifts APIEmployee Export APIReports APIIncidents APIAI Chat APIPush Notifications APIWebhooks APICron API

Contributing

ContributingCode Examples

Security

Security & Compliance

Release Notes

What's New

Auth & RBAC

Technical design of Clerk integration and Role-Based Access Control

Overview

Danvas uses Clerk for authentication and identity management. Clerk authenticates an external identity and nothing more: team membership, role, location access, lifecycle, and employee mapping are owned by the local users table. Clerk Organizations and publicMetadata are never an authorization source — see docs/adr/0018-local-authorization-boundary.md.

The canonical picture of the three records involved — Clerk identity, local users row, optional Employee link — is drawn once in that ADR, under the heading "Canonical identity map". Read it before this page; everything here assumes it.

Clerk Integration

User Provisioning

Access is invitation-only. Signing in to Clerk creates no local account on its own: provisionUser() in @repo/auth claims an outstanding invitation or grants nothing. A verified email that matches a row on the employee roster is not an invitation — the roster records who works here, not who an administrator asked to sign in. An identity with no invitation lands on /account-inactive.

  1. Webhook Sync: Clerk webhooks dispatch to handleClerkUserCreated, handleClerkUserProfileUpdated, and handleClerkUserDeleted, each with its own allowed-field projection.
  2. Metadata: Clerk publicMetadata is not written or read for authorization. Local role, team, location, lifecycle, and employee-link fields remain in PostgreSQL; provider profile events update only approved identity fields.
  3. Setup Flow: /onboarding collects an optional profile photo and phone number, then walks the user through PWA install instructions for iOS/Android and marks users.onboardingComplete in the local database. Profile setup is separate from team assignment.

Role Provisioning Trust Boundary

No role of any kind can originate from the provider. A signup with no invitation gets no local row at all, so a role claim on the Clerk session or in publicMetadata has nothing to land on. Every role — member included — comes from:

  • an admin-authored pending invitation row in the users table (created via the admin Users UI), promoted on first sign-in by promotePendingInvitation, or
  • a subsequent scoped, audited Server Action — updateUserAccess, which commits role, accessible locations, and primary location as one transaction, or updateMemberRole for a role-only change from the roster row menu.

Profile events never touch role, teamId, locationIds, lifecycle state, onboarding state, or the employee link. An established account whose email now matches a different local row is reported as an identity conflict rather than rebound — see handleClerkUserProfileUpdated in packages/auth/clerk-user-sync.ts.

Middleware

The @repo/auth package provides middleware that handles:

  • Session validation.
  • Redirecting unauthenticated users to sign-in.
  • Ensuring users belong to an active team.

The teamId is not resolved from a Clerk organization. It comes from the local users row (CANVAS_TEAM_ID names the tenant), and Clerk Organizations are not used at all. apps/app/src/proxy.ts does read orgId/orgSlug, but only to label request logs — that value never reaches an authorization decision.

Account Lifecycle

users.status is canonical and has five values. Runtime auth requires active; the retained isActive column is a constrained compatibility projection, not a second state machine.

StatusWhat it meansWhat it forecloses
invitedAn admin staged a role and locations; no provider identity exists yetPassword recovery, impersonation, session review
activePromoted and able to sign in—
deactivatedSign-in blocked; the access record is intact and reactivation restores it unchangedSign-in, impersonation
provider_deletedClerk no longer has a sign-in for this account; the local row and its history remainPassword recovery, impersonation; normal reactivation cannot recover it
reconciliation_requiredLocal record and provider disagree and need bounded operator repairSign-in, impersonation

The admin drawer states the consequence, not just the badge — an account in a non-active state says what it forecloses rather than showing a coloured word and a hidden set of controls.

Why a Blocked Sign-in Is Blocked

/account-inactive catches every identity the authenticated layout turns away. diagnoseBlockedAccount() in @repo/auth/account-state classifies the caller's own situation from committed local state — never from a provider claim — into one of unprovisioned, invitation_pending, deactivated, provider_deleted, reconciliation_required, identity_conflict, or unavailable, and the page renders wording specific to that state.

Two rules keep it safe. The caller has already authenticated, so describing their own account is not enumeration; nothing on the page describes any other account, and an anonymous visitor gets the generic unavailable copy. And an identity with no local row is never told it was deactivated — there was nothing to switch off — while unavailable hedges rather than guessing.

Every render also shows a reference code: CF- plus the leading eight hex characters of the same sha256(clerkId) digest the bootstrap and lifecycle logs record as providerIdHash. An operator can correlate a quoted code against those logs without the person sending an email address or a Clerk ID, and the code carries no account contents and grants nothing.

The User-Owned Half

Clerk owns the sign-in email, password, second factors, and sessions, so the app does not rebuild those forms. /settings/security renders Clerk's own <UserProfile/> through @repo/auth/components/account-security; /settings keeps the display profile Canvas Forge owns and links across to it. The split follows ownership, so the email field on /settings is read-only and points somewhere that can actually change it.

We support three primary roles, stored in the users table. Clerk metadata and session claims are not authorization inputs.

RolePermissions
adminFull access to all team locations, billing, and settings.
managerAccess to specific locations, reports, and scheduling.
memberEnd-user access (staff) to filing reports and viewing schedules.

Auth Guards

Use getActionContext() for the current source of truth on role + location checks. The raw requireUserAuth() / requireAdmin() shims are still exported from @repo/auth/get-user-auth but are being phased out in favor of the context object, which bundles auth, role, and location scope in one call.

// apps/app/src/lib/action-context.ts
import { getActionContext } from "@/lib/action-context";

export async function adminAction() {
  const { auth } = await getActionContext("admin");
  // auth.userId, auth.teamId, auth.role, auth.locationIds
  // ...
}

The four modes behave differently on failure:

ModeFailure behavior
"admin"requireAdmin() redirects to /403 if the caller isn't an admin.
"manager"Throws Error("Insufficient permissions: manager role required").
"adminOrManager"Throws if the caller is neither admin nor manager.
"member"requireUserAuth() redirects to /sign-in if the caller is unauthenticated.

Pick throw-mode helpers ("manager" / "adminOrManager") in API routes and "admin" / "member" in Server Components, where the redirect is the correct response. The matching minRole field on a navigation section is what hides or reveals a sidebar entry. The page-level server guard is the source of truth — the navigation stays in lockstep with it so a manager never sees a link that 403s on click.

Session Data

The get-user-auth.ts helper authenticates through Clerk and then reads the local users row. Every field below the first comes from that row, not from the Clerk session:

  • userId: The Clerk ID — the one provider-owned value here.
  • teamId: users.teamId, the local tenant identifier.
  • role: users.role.
  • locationIds: users.locationIds, the array of authorized locations.

Related

Database Schema

Locations Domain

Frontend Architecture

Locations Domain

Technical design of multi-location hierarchy and data isolation

Scheduling Domain

Current technical architecture of read-only 7shifts scheduling and service-day planning

On this page

OverviewClerk IntegrationUser ProvisioningRole Provisioning Trust BoundaryMiddlewareAccount LifecycleWhy a Blocked Sign-in Is BlockedThe User-Owned HalfAuth GuardsSession DataRelated