mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
feat(auth): account-sharing server helpers + types
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) <noreply@anthropic.com>
This commit is contained in:
parent
078df59016
commit
2a46d7deca
173
src/lib/auth/account.ts
Normal file
173
src/lib/auth/account.ts
Normal file
@ -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<AccountContext> {
|
||||||
|
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<AccountContext> {
|
||||||
|
const ctx = await getCurrentAccount();
|
||||||
|
if (!hasMinRole(ctx.role, min)) {
|
||||||
|
throw new ForbiddenError(
|
||||||
|
`This action requires the '${min}' role or higher`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
144
src/lib/auth/invitations.test.ts
Normal file
144
src/lib/auth/invitations.test.ts
Normal file
@ -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<string>();
|
||||||
|
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());
|
||||||
|
});
|
||||||
|
});
|
||||||
101
src/lib/auth/invitations.ts
Normal file
101
src/lib/auth/invitations.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
130
src/lib/auth/roles.test.ts
Normal file
130
src/lib/auth/roles.test.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
109
src/lib/auth/roles.ts
Normal file
109
src/lib/auth/roles.ts
Normal file
@ -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";
|
||||||
|
}
|
||||||
@ -1,9 +1,18 @@
|
|||||||
|
import type { AccountRole } from "@/lib/auth/roles";
|
||||||
|
|
||||||
export interface Profile {
|
export interface Profile {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
full_name: string;
|
full_name: string;
|
||||||
email: string;
|
email: string;
|
||||||
avatar_url?: 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;
|
role: string;
|
||||||
/**
|
/**
|
||||||
* Opted-in beta feature keys for this account. The column survives
|
* Opted-in beta feature keys for this account. The column survives
|
||||||
@ -13,9 +22,70 @@ export interface Profile {
|
|||||||
* the `profiles` row.
|
* the `profiles` row.
|
||||||
*/
|
*/
|
||||||
beta_features?: string[];
|
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;
|
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<AccountRole, "owner">;
|
||||||
|
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 {
|
export interface Contact {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user