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
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import Users from "./pages/Users";
import Sessions from "./pages/Sessions";
import Events from "./pages/Events";
import Security from "./pages/Security";
import Organizations from "./pages/Organizations";
import UserDetail from "./pages/UserDetail";
import SystemConfig from "./pages/SystemConfig";
import { AuthProvider } from "@seamless-auth/react";
Expand All @@ -35,6 +36,7 @@ export default function App() {
>
<Route path="/" element={<Overview />} />
<Route path="/users" element={<Users />} />
<Route path="/organizations" element={<Organizations />} />
<Route path="/sessions" element={<Sessions />} />
<Route path="/events" element={<Events />} />
<Route path="/security" element={<Security />} />
Expand Down
3 changes: 3 additions & 0 deletions src/components/Sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ describe("Sidebar", () => {

expect(screen.getByText("Seamless Auth")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /Overview/i })).toBeInTheDocument();
expect(
screen.getByRole("link", { name: /Organizations/i }),
).toBeInTheDocument();
expect(screen.getByRole("link", { name: /System/i })).toBeInTheDocument();
});

Expand Down
2 changes: 2 additions & 0 deletions src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ import {
Activity,
KeyRound,
Settings,
Building2,
} from "lucide-react";
import packageJson from "../../package.json";

const navItems = [
{ name: "Overview", path: "/", icon: LayoutDashboard },
{ name: "Users", path: "/users", icon: Users },
{ name: "Organizations", path: "/organizations", icon: Building2 },
{ name: "Sessions", path: "/sessions", icon: KeyRound },
{ name: "Events", path: "/events", icon: Activity },
{ name: "Security", path: "/security", icon: Shield },
Expand Down
204 changes: 204 additions & 0 deletions src/hooks/useOrganizations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
/*
* Copyright © 2026 Fells Code, LLC
* Licensed under the GNU Affero General Public License v3.0
* See LICENSE file in the project root for full license information
*/

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "../lib/api";

export type OrganizationMembership = {
id: string;
organizationId: string;
userId: string;
roles: string[];
scopes: string[];
createdAt: string;
updatedAt: string;
user?: {
id: string;
email: string;
phone?: string | null;
roles: string[];
};
};

export type Organization = {
id: string;
name: string;
slug: string;
createdByUserId: string | null;
metadata: Record<string, unknown> | null;
createdAt: string;
updatedAt: string;
memberCount?: number;
membership?: OrganizationMembership;
};

export type CreateOrganizationInput = {
name: string;
slug?: string;
};

export type UpdateOrganizationInput = {
organizationId: string;
name?: string;
slug?: string;
};

export type OrganizationMemberInput = {
organizationId: string;
email: string;
roles?: string[];
scopes?: string[];
};

export type OrganizationMemberUpdateInput = {
organizationId: string;
userId: string;
roles?: string[];
scopes?: string[];
};

export function useOrganizations() {
return useQuery({
queryKey: ["organizations"],
queryFn: () =>
apiFetch<{ organizations: Organization[]; total: number }>(
"/admin/organizations",
),
});
}

export function useCreateOrganization() {
const qc = useQueryClient();

return useMutation<
{ organization: Organization },
Error,
CreateOrganizationInput
>({
mutationFn: (data) =>
apiFetch<{ organization: Organization }>("/admin/organizations", {
method: "POST",
body: JSON.stringify(data),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["organizations"] });
},
});
}

export function useUpdateOrganization() {
const qc = useQueryClient();

return useMutation<
{ organization: Organization },
Error,
UpdateOrganizationInput
>({
mutationFn: ({ organizationId, ...data }) =>
apiFetch<{ organization: Organization }>(
`/admin/organizations/${organizationId}`,
{
method: "PATCH",
body: JSON.stringify(data),
},
),
onSuccess: (data) => {
qc.invalidateQueries({ queryKey: ["organizations"] });
qc.invalidateQueries({
queryKey: ["organization-members", data.organization.id],
});
},
});
}

export function useOrganizationMembers(organizationId?: string | null) {
return useQuery({
queryKey: ["organization-members", organizationId],
enabled: Boolean(organizationId),
queryFn: () => {
if (!organizationId) {
throw new Error("Organization ID is required");
}

return apiFetch<{ members: OrganizationMembership[]; total: number }>(
`/admin/organizations/${organizationId}/members`,
);
},
});
}

export function useAddOrganizationMember() {
const qc = useQueryClient();

return useMutation<
{ membership: OrganizationMembership },
Error,
OrganizationMemberInput
>({
mutationFn: ({ organizationId, ...data }) =>
apiFetch<{ membership: OrganizationMembership }>(
`/admin/organizations/${organizationId}/members`,
{
method: "POST",
body: JSON.stringify(data),
},
),
onSuccess: (_data, variables) => {
qc.invalidateQueries({ queryKey: ["organizations"] });
qc.invalidateQueries({
queryKey: ["organization-members", variables.organizationId],
});
},
});
}

export function useUpdateOrganizationMember() {
const qc = useQueryClient();

return useMutation<
{ membership: OrganizationMembership },
Error,
OrganizationMemberUpdateInput
>({
mutationFn: ({ organizationId, userId, ...data }) =>
apiFetch<{ membership: OrganizationMembership }>(
`/admin/organizations/${organizationId}/members/${userId}`,
{
method: "PATCH",
body: JSON.stringify(data),
},
),
onSuccess: (_data, variables) => {
qc.invalidateQueries({
queryKey: ["organization-members", variables.organizationId],
});
},
});
}

export function useRemoveOrganizationMember() {
const qc = useQueryClient();

return useMutation<
{ message: string },
Error,
{ organizationId: string; userId: string }
>({
mutationFn: ({ organizationId, userId }) =>
apiFetch<{ message: string }>(
`/admin/organizations/${organizationId}/members/${userId}`,
{
method: "DELETE",
},
),
onSuccess: (_data, variables) => {
qc.invalidateQueries({ queryKey: ["organizations"] });
qc.invalidateQueries({
queryKey: ["organization-members", variables.organizationId],
});
},
});
}
145 changes: 145 additions & 0 deletions src/pages/Organizations.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/*
* Copyright © 2026 Fells Code, LLC
* Licensed under the GNU Affero General Public License v3.0
* See LICENSE file in the project root for full license information
*/

import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import Organizations from "./Organizations";

const mocks = vi.hoisted(() => ({
addMember: vi.fn(),
createOrganization: vi.fn(),
removeMember: vi.fn(),
updateOrganization: vi.fn(),
useAddOrganizationMember: vi.fn(),
useCreateOrganization: vi.fn(),
useOrganizationMembers: vi.fn(),
useOrganizations: vi.fn(),
useRemoveOrganizationMember: vi.fn(),
useUpdateOrganization: vi.fn(),
}));

vi.mock("../hooks/useOrganizations", () => ({
useAddOrganizationMember: mocks.useAddOrganizationMember,
useCreateOrganization: mocks.useCreateOrganization,
useOrganizationMembers: mocks.useOrganizationMembers,
useOrganizations: mocks.useOrganizations,
useRemoveOrganizationMember: mocks.useRemoveOrganizationMember,
useUpdateOrganization: mocks.useUpdateOrganization,
}));

const organization = {
id: "org-1",
name: "Acme",
slug: "acme",
createdByUserId: "user-1",
metadata: null,
createdAt: "2026-05-18T12:00:00.000Z",
updatedAt: "2026-05-18T12:00:00.000Z",
memberCount: 1,
};

describe("Organizations", () => {
beforeEach(() => {
mocks.addMember.mockReset();
mocks.createOrganization.mockReset();
mocks.removeMember.mockReset();
mocks.updateOrganization.mockReset();

mocks.useOrganizations.mockReturnValue({
data: { organizations: [organization], total: 1 },
isLoading: false,
});
mocks.useOrganizationMembers.mockReturnValue({
data: {
members: [
{
id: "membership-1",
organizationId: "org-1",
userId: "user-1",
roles: ["owner"],
scopes: ["organization:read"],
createdAt: "2026-05-18T12:00:00.000Z",
updatedAt: "2026-05-18T12:00:00.000Z",
user: {
id: "user-1",
email: "owner@example.com",
roles: ["admin"],
},
},
],
},
isLoading: false,
});
mocks.useCreateOrganization.mockReturnValue({
mutate: mocks.createOrganization,
isPending: false,
});
mocks.useUpdateOrganization.mockReturnValue({
mutate: mocks.updateOrganization,
isPending: false,
});
mocks.useAddOrganizationMember.mockReturnValue({
mutate: mocks.addMember,
isPending: false,
});
mocks.useRemoveOrganizationMember.mockReturnValue({
mutate: mocks.removeMember,
isPending: false,
});
});

it("renders organizations and members", () => {
render(<Organizations />);

expect(
screen.getByRole("heading", { name: "Organizations" }),
).toBeVisible();
expect(screen.getAllByText("Acme").length).toBeGreaterThan(0);
expect(screen.getByText("owner@example.com")).toBeVisible();
});

it("creates organizations from the directory form", () => {
render(<Organizations />);

fireEvent.change(screen.getByPlaceholderText("Organization name"), {
target: { value: "Beta" },
});
fireEvent.change(screen.getByPlaceholderText("Slug"), {
target: { value: "beta" },
});
fireEvent.click(screen.getByRole("button", { name: /^create$/i }));

expect(mocks.createOrganization).toHaveBeenCalledWith(
{ name: "Beta", slug: "beta" },
expect.any(Object),
);
});

it("adds organization members", () => {
render(<Organizations />);

fireEvent.change(screen.getByPlaceholderText("member@example.com"), {
target: { value: "member@example.com" },
});
fireEvent.change(screen.getByPlaceholderText("Roles"), {
target: { value: "admin, member" },
});
fireEvent.change(screen.getByPlaceholderText("Scopes"), {
target: { value: "organization:read, members:write" },
});
fireEvent.click(screen.getByRole("button", { name: /^add$/i }));

expect(mocks.addMember).toHaveBeenCalledWith(
{
organizationId: "org-1",
email: "member@example.com",
roles: ["admin", "member"],
scopes: ["organization:read", "members:write"],
},
expect.any(Object),
);
});
});
Loading
Loading