mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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:
parent
e5d436e8e4
commit
7593253c85
47
src/components/auth/require-role.tsx
Normal file
47
src/components/auth/require-role.tsx
Normal 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}</>;
|
||||
}
|
||||
@ -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) {
|
||||
// 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({
|
||||
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.
|
||||
setProfile({
|
||||
...data,
|
||||
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
58
src/hooks/use-can.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user