feat(auth): extend useAuth with account context + RequireRole + useCan

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.

`<RequireRole min="admin" fallback={…}>{children}</RequireRole>`
- 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
  `<RequireRole>` — 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) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-05-31 17:58:53 +03:00
parent e5d436e8e4
commit 7593253c85
No known key found for this signature in database
3 changed files with 234 additions and 10 deletions

View File

@ -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;
}
/**
* `<RequireRole min="admin">…</RequireRole>` — 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}</>;
}

View File

@ -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<void>;
// ----------------------------------------------------------
// 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<AuthContextValue | null>(null);
@ -60,6 +105,7 @@ const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [profile, setProfile] = useState<Profile | null>(null);
const [account, setAccount] = useState<AccountSummary | null>(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 (
<AuthContext.Provider
value={{ user, profile, loading, profileLoading, signOut, refreshProfile }}
value={{
user,
profile,
loading,
profileLoading,
signOut,
refreshProfile,
account,
...derived,
}}
>
{children}
</AuthContext.Provider>
@ -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;

58
src/hooks/use-can.ts Normal file
View File

@ -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 `<RequireRole>` 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");
* <Button disabled={!canEdit} title={canEdit ? "Save" : "Read-only"} />
*/
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);
}
}