@@ -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
+>;