From 2a46d7deca2fd4761161b60d8e31268d22b67d30 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Sun, 31 May 2026 14:51:21 +0300 Subject: [PATCH] feat(auth): account-sharing server helpers + types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 2 of the multi-user accounts series. Pure helpers and types that the upcoming API routes (account, members, invitations, redeem) all depend on. No new API surface yet — this commit is intentionally a self-contained library + tests so the next PR can focus on routes without re-litigating the foundations. What landed - `src/lib/auth/roles.ts` — `AccountRole` type mirroring the Postgres enum, `roleRank` / `hasMinRole`, plus the capability predicates (`canManageMembers`, `canEditSettings`, `canSendMessages`, `canViewOnly`, `canDeleteAccount`, `canTransferOwnership`). Pure functions; no I/O. - `src/lib/auth/invitations.ts` — token generation (`crypto.randomBytes(32).toString('base64url')`), SHA-256 hashing for at-rest storage, `inviteUrl` builder, and `inviteExpiresAt` / `clampExpiryDays` for the redeem deadline. Pure; no Supabase. - `src/lib/auth/account.ts` — server-only. `getCurrentAccount()` resolves caller's user + profile + account in one Supabase round trip; `requireRole(min)` wraps it with a minimum-role check. Custom `UnauthorizedError` / `ForbiddenError` classes and a `toErrorResponse(err)` helper that maps them to `NextResponse` 401/403 with a generic 500 fallback (no uncategorised error leakage on the wire). - `src/types/index.ts` — extends `Profile` with `account_id` + `account_role`; adds `Account`, `AccountMember`, and `AccountInvitation` interfaces matching migration 017's shapes. The legacy `profile.role TEXT` column is annotated for future cleanup. Tests - `roles.test.ts` — 16-row hierarchy matrix + every predicate's boundaries. Includes a "matches the SQL helper's numeric mapping" check so the JS rank and Postgres `is_account_member` CASE can't silently drift. - `invitations.test.ts` — token length / charset / uniqueness, hash determinism, a known SHA-256 vector (regression net against the hash function ever changing and orphaning every stored token_hash), URL formation edge cases, and the `clampExpiryDays` envelope (default / non-finite / negative / oversize). Verification - `npm run lint` → 0 errors (same 20 pre-existing warnings). - `npm run typecheck` → clean. - `npm test` → 366/366 pass (319 prior + 47 new). - `npm run build` → succeeds. What's NOT in this PR - No API routes yet — `/api/account/*` and `/api/invitations/*` land in PR 3 + 4 of the series. - No UI changes — useAuth extension, Members tab, and Join page come later (PRs 5-7). - No WhatsApp webhook account routing (PR 8). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/auth/account.ts | 173 +++++++++++++++++++++++++++++++ src/lib/auth/invitations.test.ts | 144 +++++++++++++++++++++++++ src/lib/auth/invitations.ts | 101 ++++++++++++++++++ src/lib/auth/roles.test.ts | 130 +++++++++++++++++++++++ src/lib/auth/roles.ts | 109 +++++++++++++++++++ src/types/index.ts | 70 +++++++++++++ 6 files changed, 727 insertions(+) create mode 100644 src/lib/auth/account.ts create mode 100644 src/lib/auth/invitations.test.ts create mode 100644 src/lib/auth/invitations.ts create mode 100644 src/lib/auth/roles.test.ts create mode 100644 src/lib/auth/roles.ts diff --git a/src/lib/auth/account.ts b/src/lib/auth/account.ts new file mode 100644 index 0000000..a465075 --- /dev/null +++ b/src/lib/auth/account.ts @@ -0,0 +1,173 @@ +// ============================================================ +// Server-side account context — for API routes and server +// components. Reads the caller's profile + account in one round +// trip and verifies role on demand. +// +// IMPORTANT: this module is server-only. It imports the Supabase +// SSR client (`@/lib/supabase/server`), which reads `next/headers` +// cookies. Importing it from a client component will fail at +// build time with the standard Next.js "You're importing a +// component that needs `next/headers`" error — that's the +// boundary check; we don't need the `server-only` package. +// +// Calling convention +// ------------------ +// API routes don't need to redo `supabase.auth.getUser()` — they +// receive a fully-loaded context from `requireRole`: +// +// try { +// const ctx = await requireRole("admin"); +// // ctx.supabase — the SSR client (RLS scoped to this user) +// // ctx.userId — auth.uid() +// // ctx.accountId / ctx.role / ctx.account +// } catch (err) { +// return errorResponse(err); // see toErrorResponse() below +// } +// ============================================================ + +import { NextResponse } from "next/server"; +import type { SupabaseClient } from "@supabase/supabase-js"; + +import { createClient } from "@/lib/supabase/server"; +import { hasMinRole, isAccountRole, type AccountRole } from "./roles"; + +// ------------------------------------------------------------ +// Errors +// +// Custom classes so API routes can map a single `catch` to the +// right HTTP status without sprinkling 401/403 strings everywhere. +// ------------------------------------------------------------ + +export class UnauthorizedError extends Error { + readonly status = 401 as const; + constructor(message = "Unauthorized") { + super(message); + this.name = "UnauthorizedError"; + } +} + +export class ForbiddenError extends Error { + readonly status = 403 as const; + constructor(message = "Forbidden") { + super(message); + this.name = "ForbiddenError"; + } +} + +/** + * Convert one of the typed errors above (or anything else) into a + * `NextResponse`. Routes can do: + * + * } catch (err) { + * return toErrorResponse(err); + * } + * + * Unknown errors collapse to 500 with the generic message — we + * never leak `err.message` for non-classified errors to keep + * server internals out of the wire. + */ +export function toErrorResponse(err: unknown): NextResponse { + if (err instanceof UnauthorizedError || err instanceof ForbiddenError) { + return NextResponse.json({ error: err.message }, { status: err.status }); + } + console.error("[toErrorResponse] uncategorized error:", err); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); +} + +// ------------------------------------------------------------ +// Account context +// ------------------------------------------------------------ + +export interface AccountContext { + /** Supabase SSR client, RLS scoped to the calling user. */ + supabase: SupabaseClient; + /** `auth.uid()` for the caller. Always defined when this resolves. */ + userId: string; + /** Caller's account_id from their profile row. */ + accountId: string; + /** Caller's role within their account. */ + role: AccountRole; + /** Lightweight account meta — id + name. */ + account: { id: string; name: string }; +} + +/** + * Resolve the caller's user + account + role in one round trip. + * + * Throws `UnauthorizedError` if there's no Supabase session. + * Throws `ForbiddenError` if the profile is missing account + * fields (shouldn't happen post-017 migration; defensive guard + * against profile rows that pre-date the backfill or were + * inserted by hand). + * + * Use `requireRole(min)` instead when the route also needs a + * minimum-role check — it's a thin wrapper over this. + */ +export async function getCurrentAccount(): Promise { + const supabase = await createClient(); + + const { + data: { user }, + error: userErr, + } = await supabase.auth.getUser(); + if (userErr || !user) { + throw new UnauthorizedError(); + } + + // Selecting through the FK gives us the account name in one + // query — `account:accounts!inner(id,name)` is Supabase's + // explicit-join syntax. `!inner` so a NULL account_id (which + // shouldn't exist) yields no row and trips the guard below + // rather than silently returning a half-populated profile. + const { data, error } = await supabase + .from("profiles") + .select("account_id, account_role, account:accounts!inner(id, name)") + .eq("user_id", user.id) + .maybeSingle(); + + if (error) { + console.error("[getCurrentAccount] profile fetch error:", error); + throw new ForbiddenError("Could not load account context"); + } + if (!data || !data.account_id || !data.account_role || !data.account) { + // Pre-migration profile, or a manual insert that skipped the + // signup trigger. The user is authenticated but the app has + // no way to scope their queries — treat as forbidden. + throw new ForbiddenError("Profile is not linked to an account"); + } + if (!isAccountRole(data.account_role)) { + // The DB enum should make this impossible, but a future + // migration that broadens the enum without updating TS would + // hit this — surface it rather than silently widening. + throw new ForbiddenError(`Unknown account role: ${data.account_role}`); + } + + // Supabase's typed client returns related rows as an array even + // for `!inner` single-record joins; normalise to a single object. + const accountRow = Array.isArray(data.account) ? data.account[0] : data.account; + + return { + supabase, + userId: user.id, + accountId: data.account_id, + role: data.account_role, + account: { id: accountRow.id, name: accountRow.name }, + }; +} + +/** + * Resolve the caller's account context and enforce a minimum role. + * + * Throws `UnauthorizedError` / `ForbiddenError` as documented on + * `getCurrentAccount`, plus `ForbiddenError("Insufficient role")` + * when the caller is below `min`. + */ +export async function requireRole(min: AccountRole): Promise { + const ctx = await getCurrentAccount(); + if (!hasMinRole(ctx.role, min)) { + throw new ForbiddenError( + `This action requires the '${min}' role or higher`, + ); + } + return ctx; +} diff --git a/src/lib/auth/invitations.test.ts b/src/lib/auth/invitations.test.ts new file mode 100644 index 0000000..ea794a4 --- /dev/null +++ b/src/lib/auth/invitations.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; +import { + clampExpiryDays, + DEFAULT_INVITE_EXPIRY_DAYS, + generateInviteToken, + hashInviteToken, + inviteExpiresAt, + inviteUrl, + MAX_INVITE_EXPIRY_DAYS, +} from "./invitations"; + +describe("generateInviteToken", () => { + it("returns a 43-character base64url token (32 raw bytes)", () => { + const { token } = generateInviteToken(); + expect(token).toHaveLength(43); + // base64url alphabet: A-Z a-z 0-9 - _ (no +, /, or =) + expect(token).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it("returns a 64-char hex hash matching SHA-256 of the token", () => { + const { token, hash } = generateInviteToken(); + expect(hash).toHaveLength(64); + expect(hash).toMatch(/^[0-9a-f]+$/); + expect(hash).toBe(hashInviteToken(token)); + }); + + it("produces distinct tokens across calls", () => { + // 32 bytes of CSPRNG entropy — a collision in 1000 draws would + // be a thermodynamic miracle. This is a sanity guard for "did + // someone accidentally swap randomBytes for a constant?". + const seen = new Set(); + for (let i = 0; i < 1000; i++) { + seen.add(generateInviteToken().token); + } + expect(seen.size).toBe(1000); + }); +}); + +describe("hashInviteToken", () => { + it("is deterministic for the same input", () => { + expect(hashInviteToken("hello")).toBe(hashInviteToken("hello")); + }); + + it("differs for different inputs", () => { + expect(hashInviteToken("a")).not.toBe(hashInviteToken("b")); + }); + + it("matches a known SHA-256 hex digest", () => { + // Known fixture — `sha256("invite-token-abc")` hex digest. + // If this assertion ever flips, the hash function changed and + // every stored token_hash in the DB is suddenly orphaned. + expect(hashInviteToken("invite-token-abc")).toBe( + "51481b404112f61a4e1171ff116d52068c429737863181bef089df7cb607352f", + ); + }); +}); + +describe("inviteUrl", () => { + it("joins path correctly with no trailing slash", () => { + expect(inviteUrl("abc", "https://wacrm.example")).toBe( + "https://wacrm.example/join/abc", + ); + }); + + it("tolerates a trailing slash on baseUrl", () => { + expect(inviteUrl("abc", "https://wacrm.example/")).toBe( + "https://wacrm.example/join/abc", + ); + }); + + it("tolerates multiple trailing slashes", () => { + expect(inviteUrl("abc", "https://wacrm.example///")).toBe( + "https://wacrm.example/join/abc", + ); + }); + + it("preserves the entire token verbatim — including base64url symbols", () => { + // The token may contain `-` and `_`. Both are URL-safe; the + // function must NOT percent-encode them. + expect(inviteUrl("a-b_c", "https://x")).toBe("https://x/join/a-b_c"); + }); +}); + +describe("clampExpiryDays", () => { + it("defaults to DEFAULT_INVITE_EXPIRY_DAYS when undefined", () => { + expect(clampExpiryDays(undefined)).toBe(DEFAULT_INVITE_EXPIRY_DAYS); + }); + + it("defaults when given a non-finite value", () => { + // Non-finite values (NaN, ±Infinity) are always programmer errors, + // never legitimate input. We collapse them to the safe default + // rather than to MAX — a buggy Infinity passing through and + // silently producing a year-long invite would be worse than a + // default 7-day one that the admin can re-issue. + expect(clampExpiryDays(NaN)).toBe(DEFAULT_INVITE_EXPIRY_DAYS); + expect(clampExpiryDays(Infinity)).toBe(DEFAULT_INVITE_EXPIRY_DAYS); + expect(clampExpiryDays(-Infinity)).toBe(DEFAULT_INVITE_EXPIRY_DAYS); + }); + + it("rejects zero / negative", () => { + expect(clampExpiryDays(0)).toBe(DEFAULT_INVITE_EXPIRY_DAYS); + expect(clampExpiryDays(-5)).toBe(DEFAULT_INVITE_EXPIRY_DAYS); + }); + + it("clamps above MAX_INVITE_EXPIRY_DAYS", () => { + expect(clampExpiryDays(99999)).toBe(MAX_INVITE_EXPIRY_DAYS); + }); + + it("passes valid values through", () => { + expect(clampExpiryDays(1)).toBe(1); + expect(clampExpiryDays(7)).toBe(7); + expect(clampExpiryDays(30)).toBe(30); + }); + + it("floors fractional days", () => { + expect(clampExpiryDays(7.9)).toBe(7); + }); +}); + +describe("inviteExpiresAt", () => { + it("adds the requested days to `now`", () => { + const now = new Date("2026-01-01T00:00:00Z"); + const out = inviteExpiresAt(7, now); + expect(out.toISOString()).toBe("2026-01-08T00:00:00.000Z"); + }); + + it("uses the default when expiresInDays is omitted", () => { + const now = new Date("2026-01-01T00:00:00Z"); + const out = inviteExpiresAt(undefined, now); + const expected = new Date( + now.getTime() + DEFAULT_INVITE_EXPIRY_DAYS * 24 * 60 * 60 * 1000, + ); + expect(out.toISOString()).toBe(expected.toISOString()); + }); + + it("respects the max clamp", () => { + const now = new Date("2026-01-01T00:00:00Z"); + const out = inviteExpiresAt(99999, now); + const expected = new Date( + now.getTime() + MAX_INVITE_EXPIRY_DAYS * 24 * 60 * 60 * 1000, + ); + expect(out.toISOString()).toBe(expected.toISOString()); + }); +}); diff --git a/src/lib/auth/invitations.ts b/src/lib/auth/invitations.ts new file mode 100644 index 0000000..848e9d0 --- /dev/null +++ b/src/lib/auth/invitations.ts @@ -0,0 +1,101 @@ +// ============================================================ +// Invitation token utilities — pure, server-side, no Supabase. +// +// Why we hash tokens at rest +// -------------------------- +// The DB stores only `account_invitations.token_hash` (SHA-256 +// of the random token), never the plaintext. A leaked DB snapshot +// (logs, backups, support exports) therefore can't be used to +// redeem invites — the attacker would need the original token, +// which is returned exactly once at creation time. +// +// Why 32 bytes +// ------------ +// 32 bytes of CSPRNG entropy is the standard for opaque session- +// style tokens. base64url-encodes to a 43-char string, fits +// comfortably in a URL, and is well past the practical brute- +// force boundary even with SHA-256 collisions (256 bits >> any +// realistic adversary). +// +// Why base64url (not hex) +// ----------------------- +// URL-safe and shorter than hex. `crypto.randomBytes(32).toString +// ('base64url')` lands at 43 characters; hex would be 64. +// ============================================================ + +import { createHash, randomBytes } from "node:crypto"; + +/** Default invite link lifetime if the caller doesn't specify. */ +export const DEFAULT_INVITE_EXPIRY_DAYS = 7; + +/** Hard ceiling on user-supplied `expiresInDays` (1 year). */ +export const MAX_INVITE_EXPIRY_DAYS = 365; + +export interface GeneratedToken { + /** Plaintext token — return to the creator ONCE, never persist. */ + token: string; + /** SHA-256 hex digest of the token. Persist this in the DB. */ + hash: string; +} + +/** + * Generate a fresh invite token + its hash. Call once per invite + * creation; the plaintext is shown to the admin in the UI and + * embedded in the shareable link, the hash is stored in + * `account_invitations.token_hash`. + */ +export function generateInviteToken(): GeneratedToken { + const token = randomBytes(32).toString("base64url"); + return { token, hash: hashInviteToken(token) }; +} + +/** + * Deterministic SHA-256 of a plaintext token. Used at redeem time + * to look up the matching `account_invitations` row by `token_hash`. + * Pure function — same input always produces the same output. + */ +export function hashInviteToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +/** + * Build the public invite URL the admin will share. The token is + * carried in the path (not the query) so referrer-policy noise + * and browser autocomplete don't trip up token preservation. + * + * `baseUrl` must NOT have a trailing slash. The function tolerates + * one anyway (so callers can pass `NEXT_PUBLIC_APP_URL` verbatim + * without sweating slash hygiene). + */ +export function inviteUrl(token: string, baseUrl: string): string { + const trimmed = baseUrl.replace(/\/+$/, ""); + return `${trimmed}/join/${token}`; +} + +/** + * Compute the `expires_at` timestamp for a new invite. + * + * - Clamps `expiresInDays` to `[1, MAX_INVITE_EXPIRY_DAYS]`. + * - Falls back to `DEFAULT_INVITE_EXPIRY_DAYS` for missing input. + * - `now` is injectable so tests don't need timer mocking. + */ +export function inviteExpiresAt( + expiresInDays: number | undefined, + now: Date = new Date(), +): Date { + const days = clampExpiryDays(expiresInDays); + const ms = days * 24 * 60 * 60 * 1000; + return new Date(now.getTime() + ms); +} + +/** Exposed for tests and for the API route that echoes the clamped value back. */ +export function clampExpiryDays(expiresInDays: number | undefined): number { + if ( + expiresInDays === undefined || + !Number.isFinite(expiresInDays) || + expiresInDays <= 0 + ) { + return DEFAULT_INVITE_EXPIRY_DAYS; + } + return Math.min(Math.floor(expiresInDays), MAX_INVITE_EXPIRY_DAYS); +} diff --git a/src/lib/auth/roles.test.ts b/src/lib/auth/roles.test.ts new file mode 100644 index 0000000..775c10e --- /dev/null +++ b/src/lib/auth/roles.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { + ACCOUNT_ROLES, + type AccountRole, + canDeleteAccount, + canEditSettings, + canManageMembers, + canSendMessages, + canTransferOwnership, + canViewOnly, + hasMinRole, + isAccountRole, + roleRank, +} from "./roles"; + +describe("roleRank", () => { + it("orders owner > admin > agent > viewer", () => { + expect(roleRank("owner")).toBeGreaterThan(roleRank("admin")); + expect(roleRank("admin")).toBeGreaterThan(roleRank("agent")); + expect(roleRank("agent")).toBeGreaterThan(roleRank("viewer")); + }); + + it("matches the SQL helper's numeric mapping", () => { + // Keep these in lockstep with `is_account_member`'s CASE expression + // in supabase/migrations/017_account_sharing.sql — any change here + // means the SQL helper needs the same change. + expect(roleRank("owner")).toBe(4); + expect(roleRank("admin")).toBe(3); + expect(roleRank("agent")).toBe(2); + expect(roleRank("viewer")).toBe(1); + }); +}); + +describe("hasMinRole", () => { + it("returns true when role meets the threshold", () => { + expect(hasMinRole("owner", "viewer")).toBe(true); + expect(hasMinRole("admin", "agent")).toBe(true); + expect(hasMinRole("agent", "agent")).toBe(true); + }); + + it("returns false when role is below the threshold", () => { + expect(hasMinRole("viewer", "agent")).toBe(false); + expect(hasMinRole("agent", "admin")).toBe(false); + expect(hasMinRole("admin", "owner")).toBe(false); + }); + + // The full matrix — useful as a regression net if anyone reshuffles + // the rank table. + it.each<[AccountRole, AccountRole, boolean]>([ + ["owner", "owner", true], + ["owner", "admin", true], + ["owner", "agent", true], + ["owner", "viewer", true], + ["admin", "owner", false], + ["admin", "admin", true], + ["admin", "agent", true], + ["admin", "viewer", true], + ["agent", "owner", false], + ["agent", "admin", false], + ["agent", "agent", true], + ["agent", "viewer", true], + ["viewer", "owner", false], + ["viewer", "admin", false], + ["viewer", "agent", false], + ["viewer", "viewer", true], + ])("%s vs min %s → %s", (role, min, expected) => { + expect(hasMinRole(role, min)).toBe(expected); + }); +}); + +describe("isAccountRole", () => { + it("accepts every value in ACCOUNT_ROLES", () => { + for (const role of ACCOUNT_ROLES) { + expect(isAccountRole(role)).toBe(true); + } + }); + + it("rejects garbage / case mismatch / non-strings", () => { + expect(isAccountRole("Owner")).toBe(false); + expect(isAccountRole("")).toBe(false); + expect(isAccountRole(null)).toBe(false); + expect(isAccountRole(undefined)).toBe(false); + expect(isAccountRole(123)).toBe(false); + expect(isAccountRole("superuser")).toBe(false); + }); +}); + +describe("capability predicates", () => { + it("canManageMembers: admin+ only", () => { + expect(canManageMembers("owner")).toBe(true); + expect(canManageMembers("admin")).toBe(true); + expect(canManageMembers("agent")).toBe(false); + expect(canManageMembers("viewer")).toBe(false); + }); + + it("canEditSettings: admin+ only", () => { + expect(canEditSettings("owner")).toBe(true); + expect(canEditSettings("admin")).toBe(true); + expect(canEditSettings("agent")).toBe(false); + expect(canEditSettings("viewer")).toBe(false); + }); + + it("canSendMessages: agent+ only", () => { + expect(canSendMessages("owner")).toBe(true); + expect(canSendMessages("admin")).toBe(true); + expect(canSendMessages("agent")).toBe(true); + expect(canSendMessages("viewer")).toBe(false); + }); + + it("canViewOnly: viewer only", () => { + expect(canViewOnly("owner")).toBe(false); + expect(canViewOnly("admin")).toBe(false); + expect(canViewOnly("agent")).toBe(false); + expect(canViewOnly("viewer")).toBe(true); + }); + + it("canDeleteAccount: owner only", () => { + expect(canDeleteAccount("owner")).toBe(true); + expect(canDeleteAccount("admin")).toBe(false); + expect(canDeleteAccount("agent")).toBe(false); + expect(canDeleteAccount("viewer")).toBe(false); + }); + + it("canTransferOwnership: owner only", () => { + expect(canTransferOwnership("owner")).toBe(true); + expect(canTransferOwnership("admin")).toBe(false); + expect(canTransferOwnership("agent")).toBe(false); + expect(canTransferOwnership("viewer")).toBe(false); + }); +}); diff --git a/src/lib/auth/roles.ts b/src/lib/auth/roles.ts new file mode 100644 index 0000000..9880ef8 --- /dev/null +++ b/src/lib/auth/roles.ts @@ -0,0 +1,109 @@ +// ============================================================ +// Account role helpers — pure, unit-testable, no I/O. +// +// Mirrors the `account_role_enum` Postgres type from migration +// 017_account_sharing.sql. The hierarchy is intentionally a flat +// ordinal (owner=4 … viewer=1) — it matches the same CASE +// expression the `is_account_member(account_id, min_role)` SQL +// helper uses, so server-side TypeScript guards and database-side +// RLS speak the same language. +// +// Predicates (`canManageMembers`, `canEditSettings`, …) are the +// single source of truth for "what can this role do?" — both +// API route guards and UI gates should call them rather than +// open-coding their own role checks. That keeps role-policy +// changes a one-file diff. +// ============================================================ + +export type AccountRole = "owner" | "admin" | "agent" | "viewer"; + +/** Ordered list of every valid role, lowest privilege first. */ +export const ACCOUNT_ROLES: readonly AccountRole[] = [ + "viewer", + "agent", + "admin", + "owner", +] as const; + +/** + * Numeric rank of a role. Higher = more privileged. Mirrors the + * CASE expression in `is_account_member` so JS/SQL stay aligned. + */ +export function roleRank(role: AccountRole): number { + switch (role) { + case "owner": + return 4; + case "admin": + return 3; + case "agent": + return 2; + case "viewer": + return 1; + } +} + +/** + * True iff `role` is at least as privileged as `min`. Use this + * for any "user has at least admin" / "at least agent" checks. + */ +export function hasMinRole(role: AccountRole, min: AccountRole): boolean { + return roleRank(role) >= roleRank(min); +} + +/** Type-narrow an unknown string into a valid `AccountRole`. */ +export function isAccountRole(value: unknown): value is AccountRole { + return ( + typeof value === "string" && + (ACCOUNT_ROLES as readonly string[]).includes(value) + ); +} + +// ============================================================ +// Capability predicates +// +// Every UI gate and API route guard should call one of these +// instead of comparing role strings inline. Adding a capability +// = one new predicate here + one call site change per consumer. +// ============================================================ + +/** Owner / admin: invite, remove, change roles. */ +export function canManageMembers(role: AccountRole): boolean { + return hasMinRole(role, "admin"); +} + +/** + * Owner / admin: edit account-wide settings (WhatsApp config, + * message templates, pipelines, tags, custom fields, account + * name). Excludes per-user settings like avatar or own password. + */ +export function canEditSettings(role: AccountRole): boolean { + return hasMinRole(role, "admin"); +} + +/** + * Owner / admin / agent: write operational data — send messages, + * create contacts, move deals, run broadcasts, edit automations. + * Viewers are read-only. + */ +export function canSendMessages(role: AccountRole): boolean { + return hasMinRole(role, "agent"); +} + +/** + * Viewer: read-only across everything. Provided as a positive + * predicate so UI gates read naturally (`if (canViewOnly(role))` + * shows the "Read-only" tooltip without inverting `canSendMessages`). + */ +export function canViewOnly(role: AccountRole): boolean { + return role === "viewer"; +} + +/** Owner only: irreversible destructive operations. */ +export function canDeleteAccount(role: AccountRole): boolean { + return role === "owner"; +} + +/** Owner only: hand the account to another member. */ +export function canTransferOwnership(role: AccountRole): boolean { + return role === "owner"; +} diff --git a/src/types/index.ts b/src/types/index.ts index 4685d2a..38e6473 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,9 +1,18 @@ +import type { AccountRole } from "@/lib/auth/roles"; + export interface Profile { id: string; user_id: string; full_name: string; email: string; avatar_url?: string; + /** + * Legacy free-form role column from migration 001. Never read + * by the app since 017_account_sharing.sql introduced the typed + * `account_role` enum. Flagged for removal in a later cleanup + * migration — kept on the type so existing destructures don't + * break. + */ role: string; /** * Opted-in beta feature keys for this account. The column survives @@ -13,9 +22,70 @@ export interface Profile { * the `profiles` row. */ beta_features?: string[]; + /** + * Account this profile is a member of. Added by + * `017_account_sharing.sql`; NOT NULL in the DB post-backfill. + * Optional on the type only because older serialised payloads + * (cached client state, test fixtures) may not have it yet. + */ + account_id?: string; + /** + * Caller's role within their account. Source of truth for every + * role-gated UI / API check — call `hasMinRole` from + * `@/lib/auth/roles` rather than comparing this string directly. + */ + account_role?: AccountRole; created_at: string; } +// ============================================================ +// Account-sharing entities (017_account_sharing.sql) +// ============================================================ + +export interface Account { + id: string; + name: string; + /** auth.users.id of the immutable owner. */ + owner_user_id: string; + created_at: string; + updated_at: string; +} + +/** + * Hydrated member row for the Settings → Members tab. Combines + * the profile and its account_role for a single member of the + * caller's account. Sensitive fields (email) are populated only + * when the caller has admin+ — agents and viewers see name + + * avatar + role only. + */ +export interface AccountMember { + user_id: string; + full_name: string; + email: string | null; + avatar_url: string | null; + role: AccountRole; + joined_at: string; +} + +/** + * Outstanding invite link row. `token_hash` is intentionally + * absent — it lives only in the DB and on the server. The + * plaintext token is returned once at creation time and surfaced + * via the invite URL; never re-emitted. + */ +export interface AccountInvitation { + id: string; + account_id: string; + /** Roles offered via invite — owner is never offered. */ + role: Exclude; + created_by_user_id: string | null; + label: string | null; + created_at: string; + expires_at: string; + accepted_at: string | null; + accepted_by_user_id: string | null; +} + export interface Contact { id: string; user_id: string;