mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
Merge pull request #172 from ArnasDon/feat/account-sharing-useauth
feat(auth): extend useAuth with account context + RequireRole + useCan
This commit is contained in:
commit
94873e08a2
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,
|
useEffect,
|
||||||
useState,
|
useState,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useMemo,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { createClient } from "@/lib/supabase/client";
|
import { createClient } from "@/lib/supabase/client";
|
||||||
import type { User } from "@supabase/supabase-js";
|
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 {
|
interface Profile {
|
||||||
id: string;
|
id: string;
|
||||||
@ -23,6 +31,13 @@ interface Profile {
|
|||||||
* #134 — but the column survives for future beta gates.
|
* #134 — but the column survives for future beta gates.
|
||||||
*/
|
*/
|
||||||
beta_features: string[];
|
beta_features: string[];
|
||||||
|
account_id: string | null;
|
||||||
|
account_role: AccountRole | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AccountSummary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
@ -48,6 +63,36 @@ interface AuthContextValue {
|
|||||||
* the settings form so header/sidebar reflect the change without a
|
* the settings form so header/sidebar reflect the change without a
|
||||||
* full page reload. */
|
* full page reload. */
|
||||||
refreshProfile: () => Promise<void>;
|
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);
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
@ -60,6 +105,7 @@ const AuthContext = createContext<AuthContextValue | null>(null);
|
|||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [profile, setProfile] = useState<Profile | null>(null);
|
const [profile, setProfile] = useState<Profile | null>(null);
|
||||||
|
const [account, setAccount] = useState<AccountSummary | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
// Tracked separately from `loading`. The session settles fast (one
|
// Tracked separately from `loading`. The session settles fast (one
|
||||||
// local cookie read); the profile fetch crosses the network and
|
// 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
|
// Shared across init, auth-state-change listener, and the exposed
|
||||||
// refreshProfile() callback. Reads the current session's user id and
|
// 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 fetchProfile = useCallback(async (userId: string) => {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
setProfileLoading(true);
|
setProfileLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("profiles")
|
.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)
|
.eq("user_id", userId)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
@ -91,14 +144,38 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data) {
|
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
|
// `beta_features` is `NOT NULL DEFAULT ARRAY[]` in the DB, but
|
||||||
// narrow defensively in case the column hasn't been migrated yet
|
// narrow defensively in case the column hasn't been migrated yet
|
||||||
// (older deployments running 011 lazily) — `null` reads as no
|
// (older deployments running 011 lazily) — `null` reads as no
|
||||||
// opt-ins, which is the safe default for any future beta gate.
|
// opt-ins, which is the safe default for any future beta gate.
|
||||||
setProfile({
|
|
||||||
...data,
|
|
||||||
beta_features: data.beta_features ?? [],
|
beta_features: data.beta_features ?? [],
|
||||||
|
account_id: data.account_id ?? null,
|
||||||
|
account_role: accountRole,
|
||||||
});
|
});
|
||||||
|
setAccount(accountRow);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[AuthProvider] fetchProfile threw:", err);
|
console.error("[AuthProvider] fetchProfile threw:", err);
|
||||||
@ -165,6 +242,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
fetchProfile(currentUser.id);
|
fetchProfile(currentUser.id);
|
||||||
} else {
|
} else {
|
||||||
setProfile(null);
|
setProfile(null);
|
||||||
|
setAccount(null);
|
||||||
setProfileLoading(false);
|
setProfileLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -176,13 +254,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
clearTimeout(safetyTimer);
|
clearTimeout(safetyTimer);
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [fetchProfile]);
|
||||||
|
|
||||||
const signOut = useCallback(async () => {
|
const signOut = useCallback(async () => {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
await supabase.auth.signOut();
|
await supabase.auth.signOut();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setProfile(null);
|
setProfile(null);
|
||||||
|
setAccount(null);
|
||||||
window.location.href = "/login";
|
window.location.href = "/login";
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -191,9 +270,37 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
await fetchProfile(user.id);
|
await fetchProfile(user.id);
|
||||||
}, [user?.id, fetchProfile]);
|
}, [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 (
|
return (
|
||||||
<AuthContext.Provider
|
<AuthContext.Provider
|
||||||
value={{ user, profile, loading, profileLoading, signOut, refreshProfile }}
|
value={{
|
||||||
|
user,
|
||||||
|
profile,
|
||||||
|
loading,
|
||||||
|
profileLoading,
|
||||||
|
signOut,
|
||||||
|
refreshProfile,
|
||||||
|
account,
|
||||||
|
...derived,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
@ -208,7 +315,9 @@ export function useAuth(): AuthContextValue {
|
|||||||
const ctx = useContext(AuthContext);
|
const ctx = useContext(AuthContext);
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
// Fallback for components rendered outside the provider (shouldn't
|
// 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 {
|
return {
|
||||||
user: null,
|
user: null,
|
||||||
profile: null,
|
profile: null,
|
||||||
@ -218,6 +327,16 @@ export function useAuth(): AuthContextValue {
|
|||||||
window.location.href = "/login";
|
window.location.href = "/login";
|
||||||
},
|
},
|
||||||
refreshProfile: async () => {},
|
refreshProfile: async () => {},
|
||||||
|
account: null,
|
||||||
|
accountId: null,
|
||||||
|
accountRole: null,
|
||||||
|
isOwner: false,
|
||||||
|
isAdmin: false,
|
||||||
|
isAgent: false,
|
||||||
|
isViewer: false,
|
||||||
|
canManageMembers: false,
|
||||||
|
canEditSettings: false,
|
||||||
|
canSendMessages: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return ctx;
|
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