Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 25 additions & 34 deletions src/components/layout/top-bar.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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,
Expand Down Expand Up @@ -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 || "")
Expand Down Expand Up @@ -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<UserRole, number> = {
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

Expand All @@ -212,9 +203,9 @@ export function TopBar() {
<ChevronDown className="w-4 h-4 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-40">
{getAvailableRoles().length > 0 ? (
{availableRoles.length > 0 ? (
<>
{getAvailableRoles().map((role) => {
{availableRoles.map((role) => {
const isCurrent = role === user.role
return (
<DropdownMenuItem
Expand Down
133 changes: 133 additions & 0 deletions src/hooks/useProjectRole.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createElement } from "react";

vi.mock("@/lib/supabase/client", () => ({
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<string, unknown> = {};
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([]);
});
});
68 changes: 68 additions & 0 deletions src/hooks/useProjectRole.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserRole[]> => {
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)
Expand Down