From f888478b9f5512ee5f5279aa349c7ee718b5c84c Mon Sep 17 00:00:00 2001 From: Githelp AI Aagent Date: Wed, 19 Aug 2026 17:13:46 +0000 Subject: [PATCH] feat: What to change: The dropdown menu, where one can pick user category. Task: 9f36b8f9 Source: slack Requested by: U04E9DZ5ELF --- src/components/layout/top-bar.tsx | 59 ++++++------- src/hooks/useProjectRole.test.ts | 133 ++++++++++++++++++++++++++++++ src/hooks/useProjectRole.ts | 68 +++++++++++++++ 3 files changed, 226 insertions(+), 34 deletions(-) create mode 100644 src/hooks/useProjectRole.test.ts diff --git a/src/components/layout/top-bar.tsx b/src/components/layout/top-bar.tsx index 567a6b8..ce4bef9 100644 --- a/src/components/layout/top-bar.tsx +++ b/src/components/layout/top-bar.tsx @@ -1,7 +1,7 @@ "use client" import { Bell, ChevronDown, Check, Plus } from "lucide-react" -import { useRef, useState } from "react" +import { useEffect, useMemo, useRef, useState } from "react" import { useRouter } from "next/navigation" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { ProfileAvatar } from "@/components/ui/profile-avatar" @@ -18,7 +18,7 @@ import { NotificationsPanel, type Notification } from "./notifications-panel" import { useUser, type UserRole } from "@/contexts/user-context" import { useProjectSelection } from "@/contexts/project-context" import { useUserProjects, useProjectBranding } from "@/hooks/useProject" -import { useUserMaxRole } from "@/hooks/useProjectRole" +import { useUserRoles } from "@/hooks/useProjectRole" import { useNotifications, useMarkNotificationRead, @@ -67,7 +67,7 @@ export function TopBar() { const { user, switchRole } = useUser() const { selectedProjectId, setSelectedProjectId } = useProjectSelection() const { data: userProjects = [], isLoading: projectsLoading } = useUserProjects() - const { data: maxUserRole } = useUserMaxRole() + const { data: userRoles, isSuccess: rolesLoaded } = useUserRoles() const selectedProject = userProjects.find((p) => p.project_id === selectedProjectId) || userProjects[0] const { data: selectedProjectBranding } = useProjectBranding(selectedProject?.project_id || "") @@ -164,37 +164,28 @@ export function TopBar() { } } - const getAvailableRoles = (): UserRole[] => { - // Available roles must reflect what the profile is permitted to assume - // across ALL of their projects — not the role for the currently selected - // project (which can be cleared/lowered when navigating, e.g. to /support - // pages where the user is acting as "user"). Using a per-page projectRole - // here previously caused other roles to disappear after switching to user, - // making it impossible to switch back. - const profileMaxRole: UserRole | null = maxUserRole ?? user.projectRole ?? null - - // If the profile has no project membership at all, only "user" is offered - // (support users without projects). - if (!profileMaxRole) { - return ["user"] + // Available roles reflect the role categories the profile is ACTUALLY + // registered for across ALL of their projects (no implied roles), ordered + // admin → helper → user. While the query is loading, fall back to the + // current role so the dropdown is never empty. + const availableRoles: UserRole[] = useMemo(() => { + const order: UserRole[] = ["admin", "helper", "user"] + if (rolesLoaded && userRoles && userRoles.length > 0) { + return order.filter((role) => userRoles.includes(role)) } - - // Define role hierarchy: admin > helper > user - const roleHierarchy: Record = { - admin: 2, - helper: 1, - user: 0, + return [user.role] + }, [rolesLoaded, userRoles, user.role]) + + // Once roles are loaded, if the current role isn't one the profile holds, + // switch to the first available role (e.g. a freshly registered helper + // lands in the Helper view instead of the default User view). + useEffect(() => { + if (!isSignedIn || !rolesLoaded || availableRoles.length === 0) return + if (!availableRoles.includes(user.role)) { + handleSwitchRole(availableRoles[0]) } - - const profileRoleLevel = roleHierarchy[profileMaxRole] || 0 - - const allowedRoles: UserRole[] = [] - if (profileRoleLevel >= 2) allowedRoles.push("admin") - if (profileRoleLevel >= 1) allowedRoles.push("helper") - allowedRoles.push("user") - - return allowedRoles - } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSignedIn, rolesLoaded, availableRoles, user.role]) if (!isSignedIn) return null @@ -212,9 +203,9 @@ export function TopBar() { - {getAvailableRoles().length > 0 ? ( + {availableRoles.length > 0 ? ( <> - {getAvailableRoles().map((role) => { + {availableRoles.map((role) => { const isCurrent = role === user.role return ( ({ + supabase: { + auth: { getUser: vi.fn() }, + from: vi.fn(), + }, +})); + +import { supabase } from "@/lib/supabase/client"; +import { useUserRoles } from "./useProjectRole"; + +type Registrations = { + admin?: boolean; + helper?: boolean; + member?: boolean; + ticketCreator?: boolean; +}; + +/** + * Builds a chainable supabase query mock. The builder records which filters + * were applied and resolves (when awaited) with rows according to the + * configured registrations: + * - projects_members + eq("role","admin") -> admin row + * - projects_members + neq("role","admin") -> non-admin member row + * - projects_helpers -> helper row + * - tickets -> ticket row + */ +function setupSupabase(regs: Registrations) { + vi.mocked(supabase.auth.getUser).mockResolvedValue({ + data: { user: { id: "user-1" } }, + error: null, + } as never); + + vi.mocked(supabase.from).mockImplementation(((table: string) => { + const filters: { op: string; col: string; val: unknown }[] = []; + + const resolveRows = (): unknown[] => { + if (table === "projects_members") { + const isAdminQuery = filters.some( + (f) => f.op === "eq" && f.col === "role" && f.val === "admin", + ); + const isNonAdminQuery = filters.some( + (f) => f.op === "neq" && f.col === "role" && f.val === "admin", + ); + if (isAdminQuery) return regs.admin ? [{ role: "admin" }] : []; + if (isNonAdminQuery) return regs.member ? [{ role: "member" }] : []; + return []; + } + if (table === "projects_helpers") { + return regs.helper ? [{ helper_id: "helper-1" }] : []; + } + if (table === "tickets") { + return regs.ticketCreator ? [{ id: "ticket-1" }] : []; + } + return []; + }; + + const builder: Record = {}; + const chain = (op: string) => (col: string, val?: unknown) => { + filters.push({ op, col, val }); + return builder; + }; + builder.select = () => builder; + builder.eq = chain("eq"); + builder.neq = chain("neq"); + builder.is = chain("is"); + builder.limit = () => builder; + builder.then = ( + onFulfilled: (v: { data: unknown[]; error: null }) => unknown, + onRejected?: (e: unknown) => unknown, + ) => + Promise.resolve({ data: resolveRows(), error: null }).then( + onFulfilled, + onRejected, + ); + return builder; + }) as never); +} + +function makeWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + function Wrapper({ children }: { children: React.ReactNode }) { + return createElement(QueryClientProvider, { client: queryClient }, children); + } + return Wrapper; +} + +async function renderRoles() { + const { result } = renderHook(() => useUserRoles(), { + wrapper: makeWrapper(), + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + return result.current.data; +} + +describe("useUserRoles", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns ['helper'] for a helper-only registration", async () => { + setupSupabase({ helper: true }); + expect(await renderRoles()).toEqual(["helper"]); + }); + + it("returns ['admin'] for an admin-only registration", async () => { + setupSupabase({ admin: true }); + expect(await renderRoles()).toEqual(["admin"]); + }); + + it("returns ['admin','helper','user'] for admin + helper + ticket creator", async () => { + setupSupabase({ admin: true, helper: true, ticketCreator: true }); + expect(await renderRoles()).toEqual(["admin", "helper", "user"]); + }); + + it("falls back to ['user'] when there are no registrations", async () => { + setupSupabase({}); + expect(await renderRoles()).toEqual(["user"]); + }); + + it("returns [] when not signed in", async () => { + setupSupabase({}); + vi.mocked(supabase.auth.getUser).mockResolvedValue({ + data: { user: null }, + error: null, + } as never); + expect(await renderRoles()).toEqual([]); + }); +}); diff --git a/src/hooks/useProjectRole.ts b/src/hooks/useProjectRole.ts index c5a28a4..2fffda4 100644 --- a/src/hooks/useProjectRole.ts +++ b/src/hooks/useProjectRole.ts @@ -64,6 +64,74 @@ export function useUserMaxRole() { }) } +/** + * Gets the set of role categories the user is ACTUALLY registered for + * across all projects (not a hierarchy): + * - "admin" if any active projects_members row has role "admin" + * - "helper" if any projects_helpers row exists for the user + * - "user" if any active projects_members row has a non-admin role, + * OR the user has created at least one ticket + * + * Falls back to ["user"] if the user is signed in but has no registrations. + * Returns [] if not signed in. + */ +export function useUserRoles() { + return useQuery({ + queryKey: ["user-roles"], + queryFn: async (): Promise => { + const { data: { user } } = await supabase.auth.getUser() + if (!user) return [] + + const [ + { data: adminMemberships }, + { data: helperRows }, + { data: memberRows }, + { data: ticketRows }, + ] = await Promise.all([ + supabase + .from("projects_members") + .select("role") + .eq("user_id", user.id) + .eq("role", "admin") + .is("deleted_at", null) + .limit(1), + supabase + .from("projects_helpers") + .select("helper_id") + .eq("user_id", user.id) + .limit(1), + supabase + .from("projects_members") + .select("role") + .eq("user_id", user.id) + .neq("role", "admin") + .is("deleted_at", null) + .limit(1), + supabase + .from("tickets") + .select("id") + .eq("created_by", user.id) + .limit(1), + ]) + + const roles: UserRole[] = [] + if (adminMemberships && adminMemberships.length > 0) roles.push("admin") + if (helperRows && helperRows.length > 0) roles.push("helper") + if ( + (memberRows && memberRows.length > 0) || + (ticketRows && ticketRows.length > 0) + ) { + roles.push("user") + } + + return roles.length > 0 ? roles : ["user"] + }, + staleTime: 1800000, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + }) +} + /** * Gets the user's role in a project based on: * 1. projects_members table (admin or member)