Skip to content
Merged
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
24 changes: 15 additions & 9 deletions src/api/auth/useUserPermissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@ import { queryOptions, useQuery } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { userPermissionsKeys } from "./types";

async function checkUserPermissions(
userId: string,
permission: "edit_artists" | "is_admin",
) {
type Permission = "edit_artists" | "is_admin" | "is_super_admin";

async function checkUserPermissions(userId: string, permission: Permission) {
try {
// Use new admin roles system
if (permission === "edit_artists") {
Expand All @@ -26,6 +25,16 @@ async function checkUserPermissions(
return false;
}
return data || false;
} else if (permission === "is_super_admin") {
const { data, error } = await supabase.rpc("has_admin_role", {
check_user_id: userId,
check_role: "super_admin",
});
if (error) {
console.error("Error checking is_super_admin permission:", error);
return false;
}
return data || false;
}

return false;
Expand All @@ -35,10 +44,7 @@ async function checkUserPermissions(
}
}

export function userPermissionsQuery(
userId: string,
permission: "edit_artists" | "is_admin",
) {
export function userPermissionsQuery(userId: string, permission: Permission) {
return queryOptions({
queryKey: userPermissionsKeys.user(userId, permission),
queryFn: () => checkUserPermissions(userId, permission),
Expand All @@ -48,7 +54,7 @@ export function userPermissionsQuery(

export function useUserPermissionsQuery(
userId: string | undefined,
permission: "edit_artists" | "is_admin",
permission: Permission,
) {
return useQuery({
...userPermissionsQuery(userId!, permission),
Expand Down
4 changes: 2 additions & 2 deletions src/api/groups/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ export type GroupMember =

export const groupsKeys = {
all: ["groups"] as const,
user: (userId: string, params: unknown = {}) =>
[...groupsKeys.all, "user", userId, params] as const,
myGroups: (userId: string) => [...groupsKeys.all, "my", userId] as const,
allGroups: (userId: string) => [...groupsKeys.all, "all", userId] as const,
details: () => [...groupsKeys.all, "detail"] as const,
detail: (groupId: string) => [...groupsKeys.details(), groupId] as const,
bySlug: () => [...groupsKeys.all, "by-slug"] as const,
Expand Down
51 changes: 51 additions & 0 deletions src/api/groups/useAllGroups.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { useQuery } from "@tanstack/react-query";
import { allGroupsQuery } from "./useAllGroups";
import { createQueryWrapper } from "@/test/integration/harness";
import { signInAsTestUser } from "@/test/integration/fixtures/auth";
import { grantAdminRole } from "@/test/integration/fixtures/adminRoles";
import {
addGroupMember,
createGroup,
} from "@/test/integration/fixtures/groups";

describe("allGroupsQuery", () => {
it("falls back to member-only results for a non-admin caller", async () => {
const userId = await signInAsTestUser();
const myGroupId = await createGroup(userId);
await addGroupMember(myGroupId, userId);
// A group the caller has no membership row in at all.
const otherGroupId = await createGroup(crypto.randomUUID());
await addGroupMember(otherGroupId, crypto.randomUUID());

const { result } = renderHook(() => useQuery(allGroupsQuery(userId)), {
wrapper: createQueryWrapper(),
});

await waitFor(() => expect(result.current.isSuccess).toBe(true));

expect(result.current.data?.map((group) => group.id)).toEqual([myGroupId]);
});

it("returns every non-archived group for a super-admin caller, with is_member computed per group", async () => {
const userId = await signInAsTestUser();
await grantAdminRole(userId, "super_admin");
const myGroupId = await createGroup(userId);
await addGroupMember(myGroupId, userId);
const otherGroupId = await createGroup(crypto.randomUUID());
await addGroupMember(otherGroupId, crypto.randomUUID());

const { result } = renderHook(() => useQuery(allGroupsQuery(userId)), {
wrapper: createQueryWrapper(),
});

await waitFor(() => expect(result.current.isSuccess).toBe(true));

const myGroup = result.current.data?.find((g) => g.id === myGroupId);
const otherGroup = result.current.data?.find((g) => g.id === otherGroupId);
expect(myGroup?.is_member).toBe(true);
expect(otherGroup).toBeDefined();
expect(otherGroup?.is_member).toBe(false);
});
});
55 changes: 55 additions & 0 deletions src/api/groups/useAllGroups.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { queryOptions } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import type { Group } from "./types";
import { groupsKeys } from "./types";
import { attachGroupMeta, fetchMyGroups, getUserGroupIds } from "./useMyGroups";

export function allGroupsQuery(userId: string) {
return queryOptions({
queryKey: groupsKeys.allGroups(userId),
queryFn: () => fetchAllGroups(userId),
});
}

/**
* Every non-archived group, for a super-admin caller (the only role `groups` RLS
* lets read them all). Any other caller falls
* back to exactly the member-only result (never an error, never a leaked
* full group list).
*/
async function fetchAllGroups(userId: string): Promise<Group[]> {
const canViewAll = await isSuperAdmin(userId);

if (!canViewAll) {
return fetchMyGroups(userId);
}

const [userGroupIds, { data: groupsData, error }] = await Promise.all([
getUserGroupIds(userId),
supabase
.from("groups")
.select("*")
.eq("archived", false)
.order("created_at", { ascending: false }),
]);

if (error) {
throw new Error(error.message || "Failed to fetch groups");
}

return attachGroupMeta(groupsData || [], userId, userGroupIds);
}

async function isSuperAdmin(userId: string): Promise<boolean> {
const { data, error } = await supabase.rpc("has_admin_role", {
check_user_id: userId,
check_role: "super_admin",
});

if (error) {
console.error("Error checking super admin role:", error);
return false;
}

return data === true;
}
5 changes: 1 addition & 4 deletions src/api/groups/useCreateGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,7 @@ export function useCreateGroupMutation() {

return useMutation({
mutationFn: createGroup,
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: groupsKeys.user(variables.userId),
});
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: groupsKeys.all,
});
Expand Down
5 changes: 1 addition & 4 deletions src/api/groups/useDeleteGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,8 @@ export function useDeleteGroupMutation() {

return useMutation({
mutationFn: deleteGroup,
onSuccess: (_data, variables) => {
onSuccess: () => {
// Invalidate all group-related queries
queryClient.invalidateQueries({
queryKey: groupsKeys.user(variables.userId),
});
queryClient.invalidateQueries({
queryKey: groupsKeys.all,
});
Expand Down
3 changes: 1 addition & 2 deletions src/api/groups/useGroupBySlug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,11 @@ async function fetchGroupBySlug(slug: string, userId: string): Promise<Group> {
return membership.groups as Group;
}

// If not found as a member, check if user is the creator
// Not a member: let `groups` RLS decide (creator, or super admin who can read every group)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove comment

const { data, error } = await supabase
.from("groups")
.select("*")
.eq("slug", slug)
.eq("created_by", userId)
.eq("archived", false)
.single();

Expand Down
5 changes: 4 additions & 1 deletion src/api/groups/useJoinGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ export function useJoinGroupMutation() {
mutationFn: joinGroup,
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: groupsKeys.user(variables.userId),
queryKey: groupsKeys.myGroups(variables.userId),
});
queryClient.invalidateQueries({
queryKey: groupsKeys.allGroups(variables.userId),
});
toast({
title: "Success",
Expand Down
5 changes: 4 additions & 1 deletion src/api/groups/useLeaveGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ export function useLeaveGroupMutation() {
mutationFn: leaveGroup,
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: groupsKeys.user(variables.userId),
queryKey: groupsKeys.myGroups(variables.userId),
});
queryClient.invalidateQueries({
queryKey: groupsKeys.allGroups(variables.userId),
});
toast({
title: "Success",
Expand Down
79 changes: 79 additions & 0 deletions src/api/groups/useMyGroups.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { useQuery } from "@tanstack/react-query";
import { myGroupsQuery } from "./useMyGroups";
import { createQueryWrapper } from "@/test/integration/harness";
import { signInAsTestUser } from "@/test/integration/fixtures/auth";
import {
addGroupMember,
createGroup,
} from "@/test/integration/fixtures/groups";

describe("myGroupsQuery", () => {
it("returns only the groups the caller is a member of", async () => {
const userId = await signInAsTestUser();
const myGroupId = await createGroup(userId);
await addGroupMember(myGroupId, userId);
// A group the caller has no membership row in at all.
const otherGroupId = await createGroup(crypto.randomUUID());
await addGroupMember(otherGroupId, crypto.randomUUID());

const { result } = renderHook(() => useQuery(myGroupsQuery(userId)), {
wrapper: createQueryWrapper(),
});

await waitFor(() => expect(result.current.isSuccess).toBe(true));

expect(result.current.data?.map((group) => group.id)).toEqual([myGroupId]);
});

it("marks is_member true and is_creator correctly, without an admin check", async () => {
const userId = await signInAsTestUser();
const ownGroupId = await createGroup(userId);
await addGroupMember(ownGroupId, userId);
const joinedGroupId = await createGroup(crypto.randomUUID());
await addGroupMember(joinedGroupId, userId);

const { result } = renderHook(() => useQuery(myGroupsQuery(userId)), {
wrapper: createQueryWrapper(),
});

await waitFor(() => expect(result.current.isSuccess).toBe(true));

const ownGroup = result.current.data?.find((g) => g.id === ownGroupId);
const joinedGroup = result.current.data?.find(
(g) => g.id === joinedGroupId,
);
expect(ownGroup).toMatchObject({ is_member: true, is_creator: true });
expect(joinedGroup).toMatchObject({ is_member: true, is_creator: false });
});

it("reflects every member in member_count, not just the caller", async () => {
const userId = await signInAsTestUser();
const groupId = await createGroup(userId);
await addGroupMember(groupId, userId);
await addGroupMember(groupId, crypto.randomUUID());

const { result } = renderHook(() => useQuery(myGroupsQuery(userId)), {
wrapper: createQueryWrapper(),
});

await waitFor(() => expect(result.current.isSuccess).toBe(true));

expect(
result.current.data?.find((g) => g.id === groupId)?.member_count,
).toBe(2);
});

it("returns an empty array for a user with no groups", async () => {
const userId = await signInAsTestUser();

const { result } = renderHook(() => useQuery(myGroupsQuery(userId)), {
wrapper: createQueryWrapper(),
});

await waitFor(() => expect(result.current.isSuccess).toBe(true));

expect(result.current.data).toEqual([]);
});
});
Loading
Loading