diff --git a/src/components/auth/require-role.tsx b/src/components/auth/require-role.tsx
new file mode 100644
index 0000000..300748e
--- /dev/null
+++ b/src/components/auth/require-role.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import type { ReactNode } from "react";
+
+import { useAuth } from "@/hooks/use-auth";
+import { hasMinRole, type AccountRole } from "@/lib/auth/roles";
+
+interface RequireRoleProps {
+ /** Minimum role to render `children`. Uses the standard hierarchy
+ * owner > admin > agent > viewer. */
+ min: AccountRole;
+ /** What to render while the role is below `min` OR while we don't
+ * yet know the role (`profileLoading` is true). Defaults to
+ * `null` — most call sites just want the gated element to be
+ * absent until we're sure. Pass a placeholder if a layout slot
+ * would collapse and re-flow when the role resolves. */
+ fallback?: ReactNode;
+ children: ReactNode;
+}
+
+/**
+ * `…` — conditional render
+ * helper for UI gated by account role.
+ *
+ * Three states:
+ * 1. profileLoading → render `fallback` (we don't know the role
+ * yet; fail closed so we never flash the gated content to an
+ * under-privileged user).
+ * 2. role ≥ min → render `children`.
+ * 3. role < min → render `fallback`.
+ *
+ * Mirrors the server-side `requireRole(min)` from `@/lib/auth/account`
+ * so client and server gates stay aligned by construction.
+ */
+export function RequireRole({
+ min,
+ fallback = null,
+ children,
+}: RequireRoleProps) {
+ const { profileLoading, accountRole } = useAuth();
+
+ if (profileLoading) return <>{fallback}>;
+ if (!accountRole) return <>{fallback}>;
+ if (!hasMinRole(accountRole, min)) return <>{fallback}>;
+
+ return <>{children}>;
+}
diff --git a/src/hooks/use-auth.tsx b/src/hooks/use-auth.tsx
index ded2ebb..4170512 100644
--- a/src/hooks/use-auth.tsx
+++ b/src/hooks/use-auth.tsx
@@ -6,10 +6,18 @@ import {
useEffect,
useState,
useCallback,
+ useMemo,
type ReactNode,
} from "react";
import { createClient } from "@/lib/supabase/client";
import type { User } from "@supabase/supabase-js";
+import {
+ canEditSettings as canEditSettingsFor,
+ canManageMembers as canManageMembersFor,
+ canSendMessages as canSendMessagesFor,
+ isAccountRole,
+ type AccountRole,
+} from "@/lib/auth/roles";
interface Profile {
id: string;
@@ -23,6 +31,13 @@ interface Profile {
* #134 — but the column survives for future beta gates.
*/
beta_features: string[];
+ account_id: string | null;
+ account_role: AccountRole | null;
+}
+
+interface AccountSummary {
+ id: string;
+ name: string;
}
interface AuthContextValue {
@@ -48,6 +63,36 @@ interface AuthContextValue {
* the settings form so header/sidebar reflect the change without a
* full page reload. */
refreshProfile: () => Promise;
+
+ // ----------------------------------------------------------
+ // Account-scoped context (added by the account-sharing series)
+ //
+ // All of these are nullable until `profileLoading` is false.
+ // After the profile resolves they're guaranteed to be set,
+ // because migration 017 made `account_id` / `account_role`
+ // NOT NULL on `profiles`.
+ // ----------------------------------------------------------
+
+ /** Account id the current user belongs to. Null while loading. */
+ accountId: string | null;
+ /** Role within that account. Null while loading. */
+ accountRole: AccountRole | null;
+ /** Lightweight account meta — id + name. Null while loading. */
+ account: AccountSummary | null;
+ /** True if `accountRole === 'owner'`. */
+ isOwner: boolean;
+ /** True if `accountRole === 'admin'` (does NOT include owner — use canManageMembers for "admin or above"). */
+ isAdmin: boolean;
+ /** True if `accountRole === 'agent'`. */
+ isAgent: boolean;
+ /** True if `accountRole === 'viewer'`. */
+ isViewer: boolean;
+ /** True if the caller can manage members (admin+). */
+ canManageMembers: boolean;
+ /** True if the caller can edit account-wide settings (admin+). */
+ canEditSettings: boolean;
+ /** True if the caller can send messages and edit operational data (agent+). */
+ canSendMessages: boolean;
}
const AuthContext = createContext(null);
@@ -60,6 +105,7 @@ const AuthContext = createContext(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState(null);
const [profile, setProfile] = useState(null);
+ const [account, setAccount] = useState(null);
const [loading, setLoading] = useState(true);
// Tracked separately from `loading`. The session settles fast (one
// local cookie read); the profile fetch crosses the network and
@@ -69,14 +115,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// Shared across init, auth-state-change listener, and the exposed
// refreshProfile() callback. Reads the current session's user id and
- // pulls the matching profile row.
+ // pulls the matching profile row along with its account summary.
const fetchProfile = useCallback(async (userId: string) => {
const supabase = createClient();
setProfileLoading(true);
try {
const { data, error } = await supabase
.from("profiles")
- .select("id, full_name, email, avatar_url, role, beta_features")
+ .select(
+ // `account:accounts!inner(id, name)` — explicit join on the
+ // single FK profiles.account_id → accounts.id. `!inner` so a
+ // missing account collapses to null rather than a half-
+ // populated row (shouldn't happen post-017 NOT NULL, but
+ // belt-and-braces against forks running older schemas).
+ "id, full_name, email, avatar_url, role, beta_features, account_id, account_role, account:accounts!inner(id, name)",
+ )
.eq("user_id", userId)
.maybeSingle();
@@ -91,14 +144,38 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
if (data) {
- // `beta_features` is `NOT NULL DEFAULT ARRAY[]` in the DB, but
- // narrow defensively in case the column hasn't been migrated yet
- // (older deployments running 011 lazily) — `null` reads as no
- // opt-ins, which is the safe default for any future beta gate.
+ // Supabase's typed client surfaces an embedded `!inner` row
+ // as either an object or a single-element array depending on
+ // the schema's inferred cardinality — normalise to the object
+ // form before reading.
+ const accountRow = Array.isArray(data.account)
+ ? data.account[0] ?? null
+ : (data.account as { id: string; name: string } | null);
+
+ // Narrow the DB enum into our AccountRole union. The DB
+ // constraint should make this unconditional, but a future
+ // migration that broadens the enum without updating TS would
+ // otherwise crash here — fall back to null and let UI gates
+ // treat the caller as least-privileged.
+ const accountRole = isAccountRole(data.account_role)
+ ? data.account_role
+ : null;
+
setProfile({
- ...data,
+ id: data.id,
+ full_name: data.full_name,
+ email: data.email,
+ avatar_url: data.avatar_url,
+ role: data.role,
+ // `beta_features` is `NOT NULL DEFAULT ARRAY[]` in the DB, but
+ // narrow defensively in case the column hasn't been migrated yet
+ // (older deployments running 011 lazily) — `null` reads as no
+ // opt-ins, which is the safe default for any future beta gate.
beta_features: data.beta_features ?? [],
+ account_id: data.account_id ?? null,
+ account_role: accountRole,
});
+ setAccount(accountRow);
}
} catch (err) {
console.error("[AuthProvider] fetchProfile threw:", err);
@@ -165,6 +242,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
fetchProfile(currentUser.id);
} else {
setProfile(null);
+ setAccount(null);
setProfileLoading(false);
}
@@ -176,13 +254,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
clearTimeout(safetyTimer);
subscription.unsubscribe();
};
- }, []);
+ }, [fetchProfile]);
const signOut = useCallback(async () => {
const supabase = createClient();
await supabase.auth.signOut();
setUser(null);
setProfile(null);
+ setAccount(null);
window.location.href = "/login";
}, []);
@@ -191,9 +270,37 @@ export function AuthProvider({ children }: { children: ReactNode }) {
await fetchProfile(user.id);
}, [user?.id, fetchProfile]);
+ // Derive the role booleans once per profile change rather than on
+ // every consumer render. Cheap regardless, but the memo also gives
+ // each derived value a stable identity for React.memo / useEffect
+ // dependencies downstream.
+ const derived = useMemo(() => {
+ const role = profile?.account_role ?? null;
+ return {
+ accountRole: role,
+ accountId: profile?.account_id ?? null,
+ isOwner: role === "owner",
+ isAdmin: role === "admin",
+ isAgent: role === "agent",
+ isViewer: role === "viewer",
+ canManageMembers: role ? canManageMembersFor(role) : false,
+ canEditSettings: role ? canEditSettingsFor(role) : false,
+ canSendMessages: role ? canSendMessagesFor(role) : false,
+ };
+ }, [profile?.account_role, profile?.account_id]);
+
return (
{children}
@@ -208,7 +315,9 @@ export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
// Fallback for components rendered outside the provider (shouldn't
- // happen in normal flow, but don't crash the page).
+ // happen in normal flow, but don't crash the page). Account state
+ // collapses to least-privileged null — every `canX` boolean is
+ // false so UI gates fail closed.
return {
user: null,
profile: null,
@@ -218,6 +327,16 @@ export function useAuth(): AuthContextValue {
window.location.href = "/login";
},
refreshProfile: async () => {},
+ account: null,
+ accountId: null,
+ accountRole: null,
+ isOwner: false,
+ isAdmin: false,
+ isAgent: false,
+ isViewer: false,
+ canManageMembers: false,
+ canEditSettings: false,
+ canSendMessages: false,
};
}
return ctx;
diff --git a/src/hooks/use-can.ts b/src/hooks/use-can.ts
new file mode 100644
index 0000000..9f9829b
--- /dev/null
+++ b/src/hooks/use-can.ts
@@ -0,0 +1,58 @@
+"use client";
+
+import { useAuth } from "@/hooks/use-auth";
+import {
+ canDeleteAccount,
+ canEditSettings,
+ canManageMembers,
+ canSendMessages,
+ canTransferOwnership,
+ canViewOnly,
+} from "@/lib/auth/roles";
+
+/**
+ * Typed action keys for `useCan`. Adding a capability = one new
+ * entry here + one new case in the switch below + (usually) one
+ * new predicate in `@/lib/auth/roles`. Keeping the list closed
+ * lets the compiler catch typos at every call site.
+ */
+export type CanAction =
+ | "manage-members"
+ | "edit-settings"
+ | "send-messages"
+ | "view-only"
+ | "delete-account"
+ | "transfer-ownership";
+
+/**
+ * Inline alternative to `` for places that need a
+ * boolean rather than a render conditional — typically disabled-
+ * state on buttons, the readOnly flag on inputs, or controlling
+ * tooltip copy ("Read-only" vs the action label).
+ *
+ * Returns `false` while `profileLoading` is true so transient
+ * "you can!" flashes never appear to under-privileged users.
+ *
+ * Example:
+ * const canEdit = useCan("edit-settings");
+ *
+ */
+export function useCan(action: CanAction): boolean {
+ const { profileLoading, accountRole } = useAuth();
+ if (profileLoading || !accountRole) return false;
+
+ switch (action) {
+ case "manage-members":
+ return canManageMembers(accountRole);
+ case "edit-settings":
+ return canEditSettings(accountRole);
+ case "send-messages":
+ return canSendMessages(accountRole);
+ case "view-only":
+ return canViewOnly(accountRole);
+ case "delete-account":
+ return canDeleteAccount(accountRole);
+ case "transfer-ownership":
+ return canTransferOwnership(accountRole);
+ }
+}