mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Merge pull request #169 from ArnasDon/feat/account-sharing-server-helpers
feat(auth): account-sharing server helpers + types
This commit is contained in:
commit
0d95272999
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 {
|
||||
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<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 {
|
||||
id: string;
|
||||
user_id: string;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user