diff --git a/apps/api/src/modules/users/users.service.ts b/apps/api/src/modules/users/users.service.ts index ebde5e5..f7ccd4e 100644 --- a/apps/api/src/modules/users/users.service.ts +++ b/apps/api/src/modules/users/users.service.ts @@ -57,7 +57,12 @@ export const usersService = { }); } - const updated = await usersRepository.updateUser(userId, patch); + // Role-only updates mutate membership only; `patch` stays empty. Running + // `updateUser` with `{}` produces invalid SQL (UPDATE … SET with no columns). + const updated = + Object.keys(patch).length > 0 + ? await usersRepository.updateUser(userId, patch) + : await usersRepository.getUserById(userId); if (!updated) { throw new NotFoundError("User not found"); } diff --git a/apps/api/tests/integration/users.endpoints.test.ts b/apps/api/tests/integration/users.endpoints.test.ts index 612a69a..4bd0a44 100644 --- a/apps/api/tests/integration/users.endpoints.test.ts +++ b/apps/api/tests/integration/users.endpoints.test.ts @@ -124,6 +124,28 @@ describe("PUT /api/v1/users/:userId", () => { }); expect(res.body.createdAt).toBeDefined(); }); + + it("returns 200 when only role changes (no user row patch)", async () => { + const admin = await registerUser(adminEmail); + adminId = admin.id; + const other = await registerUser(otherEmail); + otherId = other.id; + const adminSession = await loginUser(adminEmail); + + const res = await request(app) + .put(`${paths.users}/${other.id}`) + .set("Authorization", `Bearer ${adminSession.token}`) + .send({ role: "tutor" }) + .expect("Content-Type", /json/) + .expect(200); + + expect(res.body).toMatchObject({ + id: other.id, + email: other.email, + role: "tutor", + }); + expect(res.body.createdAt).toBeDefined(); + }); }); describe("DELETE /api/v1/users/:userId", () => { diff --git a/apps/api/tests/unit/users.service.test.ts b/apps/api/tests/unit/users.service.test.ts index 01149d9..49f7dea 100644 --- a/apps/api/tests/unit/users.service.test.ts +++ b/apps/api/tests/unit/users.service.test.ts @@ -122,9 +122,7 @@ describe("usersService updateUser", () => { vi.mocked(usersRepository.getUserByIdInOrganization).mockResolvedValue( existingUser, ); - vi.mocked(usersRepository.updateUser).mockResolvedValue({ - ...existingUser, - }); + vi.mocked(usersRepository.getUserById).mockResolvedValue(existingUser); vi.mocked(organizationsRepository.findMembership) .mockResolvedValueOnce({ organizationId: "org-1", @@ -152,7 +150,8 @@ describe("usersService updateUser", () => { role: "tutor", }); - expect(usersRepository.updateUser).toHaveBeenCalledWith("user-1", {}); + expect(usersRepository.updateUser).not.toHaveBeenCalled(); + expect(usersRepository.getUserById).toHaveBeenCalledWith("user-1"); expect(organizationsRepository.createMembership).toHaveBeenCalledWith({ organizationId: "org-1", userId: "user-1", diff --git a/apps/web/app/t/[tenantSlug]/(workspace)/organization/page.tsx b/apps/web/app/t/[tenantSlug]/(workspace)/organization/page.tsx new file mode 100644 index 0000000..99cc8b4 --- /dev/null +++ b/apps/web/app/t/[tenantSlug]/(workspace)/organization/page.tsx @@ -0,0 +1,5 @@ +import { TenantOrganizationAdminPage } from "./tenant-organization-admin-page"; + +export default function OrganizationRoutePage() { + return ; +} diff --git a/apps/web/app/t/[tenantSlug]/(workspace)/organization/tenant-organization-admin-page.tsx b/apps/web/app/t/[tenantSlug]/(workspace)/organization/tenant-organization-admin-page.tsx new file mode 100644 index 0000000..18decf5 --- /dev/null +++ b/apps/web/app/t/[tenantSlug]/(workspace)/organization/tenant-organization-admin-page.tsx @@ -0,0 +1,348 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import type { components } from "@studiqo/api-client/generated"; +import { isStudiqoApiError } from "@studiqo/api-client/errors"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; + +import { + useAddOrganizationMemberMutation, + useOrganizationMembersQuery, +} from "@/lib/api/organization-members-query"; +import { useOrganizationsQuery } from "@/lib/api/organizations-query"; +import { useUpdateUserMutation } from "@/lib/api/users-mutation"; +import { useSession } from "@/lib/auth/session"; +import { formatIsoDateTime } from "@/lib/datetime"; +import { isOrgAdminOrSuperadmin } from "@/lib/tenant-role"; +import { + addOrganizationMemberFormSchema, + type AddOrganizationMemberForm, + organizationMembershipRoleSchema, +} from "@/lib/validation/organization-admin-forms"; + +type OrgMember = components["schemas"]["OrganizationMembership"]; +type OrgRole = components["schemas"]["OrganizationMembershipRole"]; + +const ROLE_OPTIONS: { value: OrgRole; label: string }[] = [ + { value: "org_admin", label: "Organization admin" }, + { value: "tutor", label: "Tutor" }, + { value: "parent", label: "Parent" }, +]; + +function MemberRoleRow({ + member, + currentUserId, + adminCount, + isSaving, + onSaveRole, +}: { + member: OrgMember; + currentUserId: string | undefined; + adminCount: number; + isSaving: boolean; + onSaveRole: (userId: string, role: OrgRole) => void; +}) { + const [role, setRole] = useState(member.role); + + useEffect(() => { + setRole(member.role); + }, [member.role]); + + const isOnlyAdmin = + member.role === "org_admin" && adminCount === 1; + const locked = + currentUserId === member.userId || isOnlyAdmin; + const dirty = role !== member.role; + + return ( + + + {member.email} + + + + {locked ? ( + + {currentUserId === member.userId + ? "You cannot change your own role here." + : "This organization must keep at least one admin."} + + ) : null} + {!locked && dirty ? ( + + ) : null} + + + {formatIsoDateTime(member.createdAt)} + + + ); +} + +export function TenantOrganizationAdminPage() { + const params = useParams<{ tenantSlug: string }>(); + const tenantSlug = params.tenantSlug; + const { user } = useSession(); + const { data: orgs, isLoading: orgsLoading } = useOrganizationsQuery(); + + const organizationId = useMemo(() => { + return orgs?.find((o) => o.slug === tenantSlug)?.id ?? null; + }, [orgs, tenantSlug]); + + const canManage = isOrgAdminOrSuperadmin(user?.role, user?.isSuperadmin ?? false); + + const membersQ = useOrganizationMembersQuery( + organizationId, + canManage, + ); + + const addMember = useAddOrganizationMemberMutation(organizationId ?? ""); + const updateUser = useUpdateUserMutation(organizationId ?? ""); + + const [roleSaveError, setRoleSaveError] = useState(null); + const [addMemberError, setAddMemberError] = useState(null); + + const addForm = useForm({ + resolver: zodResolver(addOrganizationMemberFormSchema), + defaultValues: { userId: "", role: "tutor" }, + }); + + const adminCount = useMemo( + () => + (membersQ.data ?? []).filter((m) => m.role === "org_admin").length, + [membersQ.data], + ); + + async function handleSaveRole(userId: string, role: OrgRole) { + setRoleSaveError(null); + try { + await updateUser.mutateAsync({ userId, body: { role } }); + } catch (e) { + if (isStudiqoApiError(e)) setRoleSaveError(e.message); + else setRoleSaveError("Could not update role"); + } + } + + async function onAddMemberSubmit(values: AddOrganizationMemberForm) { + setAddMemberError(null); + try { + await addMember.mutateAsync({ + userId: values.userId.trim(), + role: values.role, + }); + addForm.reset({ userId: "", role: "tutor" }); + } catch (e) { + if (isStudiqoApiError(e)) setAddMemberError(e.message); + else setAddMemberError("Could not add member"); + } + } + + if (!canManage) { + return ( +
+

Organization members

+

+ Only organization admins can view or manage members. Contact an admin + if you need changes. +

+
+ ); + } + + if (orgsLoading) { + return ( +
+

Organization members

+

Loading…

+
+ ); + } + + if (!organizationId) { + return ( +
+

Organization members

+

+ This workspace does not match an organization in your account. +

+
+ ); + } + + const listError = + membersQ.error && isStudiqoApiError(membersQ.error) + ? membersQ.error.message + : membersQ.error + ? "Could not load members" + : null; + + const base = `/t/${tenantSlug}`; + + return ( +
+

Organization members

+

+ Manage roles for accounts already registered in Studiqo. To invite + parents by email, use{" "} + + Parent invitations + + . Adding a member below requires the user's account ID (UUID), not + their email. +

+ +
+

Add member by user ID

+
+ + + {addMemberError ? ( +

{addMemberError}

+ ) : null} + +
+
+ +
+

Members

+ {roleSaveError ? ( +

{roleSaveError}

+ ) : null} + {listError ? ( +

{listError}

+ ) : membersQ.isLoading ? ( +

Loading members…

+ ) : (membersQ.data?.length ?? 0) === 0 ? ( +

No members found.

+ ) : ( +
+ + + + + + + + + + {membersQ.data!.map((member) => ( + + ))} + +
+ Email + + Role + + Member since +
+
+ )} +
+
+ ); +} diff --git a/apps/web/app/t/[tenantSlug]/tenant-chrome.tsx b/apps/web/app/t/[tenantSlug]/tenant-chrome.tsx index b02e176..7ef5528 100644 --- a/apps/web/app/t/[tenantSlug]/tenant-chrome.tsx +++ b/apps/web/app/t/[tenantSlug]/tenant-chrome.tsx @@ -1,6 +1,9 @@ "use client"; +import { useMemo } from "react"; + import { TenantNav } from "@/components/tenant-nav"; +import { useOrganizationsQuery } from "@/lib/api/organizations-query"; import { useSession } from "@/lib/auth/session"; import { appShellUrl } from "@/lib/urls"; @@ -12,6 +15,11 @@ export function TenantChrome({ children: React.ReactNode; }) { const { user, logout } = useSession(); + const { data: orgs } = useOrganizationsQuery(); + const activeOrg = useMemo( + () => orgs?.find((o) => o.slug === tenantSlug), + [orgs, tenantSlug], + ); return (
@@ -27,7 +35,12 @@ export function TenantChrome({ }} >
- {tenantSlug} + + {activeOrg?.name ?? tenantSlug} + + {activeOrg ? ( + {activeOrg.slug} + ) : null} {user?.email ?? "—"} {user?.role ? ` · ${user.role}` : null} diff --git a/apps/web/components/tenant-nav.tsx b/apps/web/components/tenant-nav.tsx index c3ac334..7e4fc65 100644 --- a/apps/web/components/tenant-nav.tsx +++ b/apps/web/components/tenant-nav.tsx @@ -35,7 +35,7 @@ export function TenantNav({ Invites ) : null} {role === "org_admin" || isSuperadmin ? ( - More admin (Phase 4) + Members ) : null} ); diff --git a/apps/web/lib/api/invalidate-tenant-queries.ts b/apps/web/lib/api/invalidate-tenant-queries.ts new file mode 100644 index 0000000..10d3962 --- /dev/null +++ b/apps/web/lib/api/invalidate-tenant-queries.ts @@ -0,0 +1,24 @@ +import type { QueryClient } from "@tanstack/react-query"; + +/** + * Invalidates cached data that depends on the active organization JWT. + * Call after switching active org so lists cannot show the previous tenant. + */ +export function invalidateTenantScopedQueries( + queryClient: QueryClient, +): void { + void queryClient.invalidateQueries({ + predicate: (q) => { + const k = q.queryKey; + if (!Array.isArray(k) || k.length === 0) return false; + const root = k[0]; + if (root === "students" || root === "lessons" || root === "subjects") { + return true; + } + if (root === "organizations" && k.length > 1) { + return true; + } + return false; + }, + }); +} diff --git a/apps/web/lib/api/organization-members-query.ts b/apps/web/lib/api/organization-members-query.ts index 324995a..8773201 100644 --- a/apps/web/lib/api/organization-members-query.ts +++ b/apps/web/lib/api/organization-members-query.ts @@ -1,10 +1,15 @@ "use client"; +import type { components } from "@studiqo/api-client/generated"; import { unwrapStudiqoResponse } from "@studiqo/api-client/errors"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useSession } from "@/lib/auth/session"; +import { organizationQueryKey } from "./organizations-query"; + +type AddMemberBody = components["schemas"]["AddOrganizationMemberRequest"]; + export const organizationMembersQueryKey = (organizationId: string) => ["organizations", organizationId, "members"] as const; @@ -30,3 +35,23 @@ export function useOrganizationMembersQuery( Boolean(accessToken), }); } + +export function useAddOrganizationMemberMutation(organizationId: string) { + const { apiClient } = useSession(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body: AddMemberBody) => { + const r = await apiClient.POST("/organizations/{organizationId}/members", { + params: { path: { organizationId } }, + body, + }); + return unwrapStudiqoResponse(r); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: organizationMembersQueryKey(organizationId), + }); + void queryClient.invalidateQueries({ queryKey: organizationQueryKey }); + }, + }); +} diff --git a/apps/web/lib/api/organizations-query.ts b/apps/web/lib/api/organizations-query.ts index fefa0e4..07fa224 100644 --- a/apps/web/lib/api/organizations-query.ts +++ b/apps/web/lib/api/organizations-query.ts @@ -5,6 +5,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useSession } from "@/lib/auth/session"; +import { invalidateTenantScopedQueries } from "./invalidate-tenant-queries"; + export const organizationQueryKey = ["organizations"] as const; export function useOrganizationsQuery() { @@ -47,6 +49,7 @@ export function useSetActiveOrganizationMutation() { setAccessToken(data.token); await refetchUser(); void queryClient.invalidateQueries({ queryKey: organizationQueryKey }); + invalidateTenantScopedQueries(queryClient); }, }); } diff --git a/apps/web/lib/api/users-mutation.ts b/apps/web/lib/api/users-mutation.ts new file mode 100644 index 0000000..e47c35e --- /dev/null +++ b/apps/web/lib/api/users-mutation.ts @@ -0,0 +1,30 @@ +"use client"; + +import type { components } from "@studiqo/api-client/generated"; +import { unwrapStudiqoResponse } from "@studiqo/api-client/errors"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { useSession } from "@/lib/auth/session"; + +import { organizationMembersQueryKey } from "./organization-members-query"; + +type UpdateUserBody = components["schemas"]["UpdateUserRequest"]; + +export function useUpdateUserMutation(organizationId: string) { + const { apiClient } = useSession(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (args: { userId: string; body: UpdateUserBody }) => { + const r = await apiClient.PUT("/users/{userId}", { + params: { path: { userId: args.userId } }, + body: args.body, + }); + return unwrapStudiqoResponse(r); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: organizationMembersQueryKey(organizationId), + }); + }, + }); +} diff --git a/apps/web/lib/validation/organization-admin-forms.ts b/apps/web/lib/validation/organization-admin-forms.ts new file mode 100644 index 0000000..3439df8 --- /dev/null +++ b/apps/web/lib/validation/organization-admin-forms.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +export const organizationMembershipRoleSchema = z.enum([ + "org_admin", + "tutor", + "parent", +]); + +export const addOrganizationMemberFormSchema = z.object({ + userId: z.string().trim().uuid("Enter a valid user ID (UUID)"), + role: organizationMembershipRoleSchema, +}); + +export type AddOrganizationMemberForm = z.infer< + typeof addOrganizationMemberFormSchema +>;