From 7593253c85805829921f4ee852e3cfc9bf2e962f Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Sun, 31 May 2026 17:58:53 +0300 Subject: [PATCH] feat(auth): extend useAuth with account context + RequireRole + useCan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 5 of the multi-user accounts series. Surfaces the account + role state from the profile row through useAuth so every UI gate downstream can read it without its own fetch, and lands two reusable patterns the upcoming Members tab / Join page / sidebar gating PRs will all consume. What's new in useAuth - Profile SELECT now includes `account_id`, `account_role`, and the joined `accounts(id, name)` via the existing `account_id` FK. `!inner` mirrors the server-side `getCurrentAccount` helper from PR #169. - Context value adds `accountId`, `accountRole`, `account` (`{id, name}` summary), and six derived booleans: `isOwner`, `isAdmin`, `isAgent`, `isViewer`, plus the capability-level `canManageMembers`, `canEditSettings`, `canSendMessages`. - All derived booleans memoise off `profile.account_role`, so consumers can use them as React dep-array entries without re-renders cascading on unrelated profile fields. - The no-provider fallback returns every new field at its least-privileged value (null / false) — UI gates fail closed if a component is somehow rendered outside the AuthProvider. `{children}` - Renders `children` iff `accountRole >= min`, else `fallback`. - Treats `profileLoading` as "below threshold" so under- privileged users never see a flash of gated content during the initial profile fetch. `useCan(action)` hook - Typed action union: `manage-members` | `edit-settings` | `send-messages` | `view-only` | `delete-account` | `transfer-ownership`. Closed set lets the compiler catch typos at every call site. - Returns `false` while loading. The boolean alternative to `` — useful for `disabled={!useCan('edit-settings')}` on buttons / inputs / tooltip copy without restructuring JSX. Why no React-renderer tests - The wacrm test convention is pure unit tests (`vitest` without any React testing library). The role predicates that back every new piece here are already covered by the test suite added in PR #169 — the new code is thin glue, and adding a React testing harness for it would be a much larger change than this PR warrants. Verification - `npm run lint` → 0 errors. Warnings drop from 20 → 19 because the rewritten useEffect now lists `fetchProfile` in its deps. - `npm run typecheck` → clean - `npm test` → 366/366 pass - `npm run build` → succeeds Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/auth/require-role.tsx | 47 +++++++++ src/hooks/use-auth.tsx | 139 +++++++++++++++++++++++++-- src/hooks/use-can.ts | 58 +++++++++++ 3 files changed, 234 insertions(+), 10 deletions(-) create mode 100644 src/components/auth/require-role.tsx create mode 100644 src/hooks/use-can.ts 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"); + *