diff --git a/src/App.tsx b/src/App.tsx index 17ec6d0..2625362 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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"; @@ -35,6 +36,7 @@ export default function App() { > } /> } /> + } /> } /> } /> } /> diff --git a/src/components/Sidebar.test.tsx b/src/components/Sidebar.test.tsx index 739d483..4f5f898 100644 --- a/src/components/Sidebar.test.tsx +++ b/src/components/Sidebar.test.tsx @@ -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(); }); diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index cae9b42..f8c9ead 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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 }, diff --git a/src/hooks/useOrganizations.ts b/src/hooks/useOrganizations.ts new file mode 100644 index 0000000..6b3e83d --- /dev/null +++ b/src/hooks/useOrganizations.ts @@ -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 | 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], + }); + }, + }); +} diff --git a/src/pages/Organizations.test.tsx b/src/pages/Organizations.test.tsx new file mode 100644 index 0000000..feb4545 --- /dev/null +++ b/src/pages/Organizations.test.tsx @@ -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(); + + 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(); + + 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(); + + 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), + ); + }); +}); diff --git a/src/pages/Organizations.tsx b/src/pages/Organizations.tsx new file mode 100644 index 0000000..140d10c --- /dev/null +++ b/src/pages/Organizations.tsx @@ -0,0 +1,561 @@ +/* + * 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 type { ComponentType, FormEvent, ReactNode } from "react"; +import { useMemo, useState } from "react"; +import { Building2, Plus, Save, Trash2, Users2 } from "lucide-react"; +import Table from "../components/Table"; +import Skeleton from "../components/Skeleton"; +import StatCard from "../components/StatCard"; +import { Section } from "../components/Section"; +import { + useAddOrganizationMember, + useCreateOrganization, + useOrganizationMembers, + useOrganizations, + useRemoveOrganizationMember, + useUpdateOrganization, + type Organization, + type OrganizationMembership, +} from "../hooks/useOrganizations"; + +type OrganizationRow = Organization & Record; +type OrganizationMembershipRow = OrganizationMembership & + Record; + +function parseCsv(value: string) { + return Array.from( + new Set( + value + .split(",") + .map((item) => item.trim()) + .filter(Boolean), + ), + ); +} + +function formatDate(value?: string | null) { + if (!value) return "Unknown"; + return new Date(value).toLocaleDateString(); +} + +function formatList(values?: string[]) { + if (!values?.length) return "None"; + return values.join(", "); +} + +export default function Organizations() { + const { data, isLoading } = useOrganizations(); + const createOrganization = useCreateOrganization(); + const updateOrganization = useUpdateOrganization(); + const addMember = useAddOrganizationMember(); + const removeMember = useRemoveOrganizationMember(); + + const organizations = useMemo(() => data?.organizations ?? [], [data]); + const total = data?.total ?? organizations.length; + const memberTotal = organizations.reduce( + (sum, organization) => sum + (organization.memberCount ?? 0), + 0, + ); + + const [selectedOrganizationId, setSelectedOrganizationId] = useState< + string | null + >(null); + const selectedOrganization = useMemo( + () => + organizations.find( + (organization) => organization.id === selectedOrganizationId, + ) ?? + organizations[0] ?? + null, + [organizations, selectedOrganizationId], + ); + + const [createName, setCreateName] = useState(""); + const [createSlug, setCreateSlug] = useState(""); + const [memberEmail, setMemberEmail] = useState(""); + const [memberRoles, setMemberRoles] = useState("member"); + const [memberScopes, setMemberScopes] = useState(""); + + const { data: memberData, isLoading: membersLoading } = + useOrganizationMembers(selectedOrganization?.id); + const members = memberData?.members ?? []; + + const handleCreateOrganization = (event: FormEvent) => { + event.preventDefault(); + + const name = createName.trim(); + const slug = createSlug.trim(); + + if (!name) return; + + createOrganization.mutate( + { name, ...(slug ? { slug } : {}) }, + { + onSuccess: ({ organization }) => { + setSelectedOrganizationId(organization.id); + setCreateName(""); + setCreateSlug(""); + }, + }, + ); + }; + + const handleAddMember = (event: FormEvent) => { + event.preventDefault(); + if (!selectedOrganization) return; + + const email = memberEmail.trim(); + if (!email) return; + + addMember.mutate( + { + organizationId: selectedOrganization.id, + email, + roles: parseCsv(memberRoles), + scopes: parseCsv(memberScopes), + }, + { + onSuccess: () => { + setMemberEmail(""); + setMemberRoles("member"); + setMemberScopes(""); + }, + }, + ); + }; + + const handleRemoveMember = (membership: OrganizationMembership) => { + if (!selectedOrganization) return; + + const label = membership.user?.email ?? membership.userId; + if (!confirm(`Remove ${label} from ${selectedOrganization.name}?`)) { + return; + } + + removeMember.mutate({ + organizationId: selectedOrganization.id, + userId: membership.userId, + }); + }; + + if (isLoading) { + return ( +
+ +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ +
+ ); + } + + return ( +
+
+
+
+
+
+ Tenant Directory +
+

Organizations

+

+ Manage tenant containers, membership roles, and scoped access + for users across this Seamless Auth deployment. +

+
+ +
+ + +
+
+
+
+ +
+ + + + +
+ +
+
+ setCreateName(event.target.value)} + placeholder="Organization name" + className="min-w-0 rounded-md border border-subtle bg-surface-alt px-3 py-2 text-sm outline-none transition focus:border-[var(--primary)] focus:ring-1 focus:ring-[var(--primary)]" + /> + setCreateSlug(event.target.value)} + placeholder="Slug" + className="min-w-0 rounded-md border border-subtle bg-surface-alt px-3 py-2 text-sm outline-none transition focus:border-[var(--primary)] focus:ring-1 focus:ring-[var(--primary)]" + /> + + + } + > + + data={organizations as OrganizationRow[]} + total={total} + emptyTitle="No organizations" + emptyDescription="Create an organization to start grouping users." + columns={[ + { + key: "name", + label: "Organization", + sortable: true, + render: (value, row) => ( + + ), + }, + { + key: "memberCount", + label: "Members", + sortable: true, + render: (value) => ( + + {Number(value ?? 0)} + + ), + }, + { + key: "createdAt", + label: "Created", + sortable: true, + render: (value) => ( + + {formatDate(value as string)} + + ), + }, + ]} + actions={[ + { + icon: Building2, + label: "Manage", + onClick: (row) => setSelectedOrganizationId(row.id), + }, + ]} + /> +
+ +
+ {selectedOrganization ? ( + updateOrganization.mutate(input)} + /> + ) : ( +
+ No organization selected. +
+ )} +
+
+ +
+ setMemberEmail(event.target.value)} + placeholder="member@example.com" + disabled={!selectedOrganization} + className="min-w-0 rounded-md border border-subtle bg-surface-alt px-3 py-2 text-sm outline-none transition focus:border-[var(--primary)] focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-60" + /> + setMemberRoles(event.target.value)} + placeholder="Roles" + disabled={!selectedOrganization} + className="min-w-0 rounded-md border border-subtle bg-surface-alt px-3 py-2 text-sm outline-none transition focus:border-[var(--primary)] focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-60" + /> + setMemberScopes(event.target.value)} + placeholder="Scopes" + disabled={!selectedOrganization} + className="min-w-0 rounded-md border border-subtle bg-surface-alt px-3 py-2 text-sm outline-none transition focus:border-[var(--primary)] focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-60" + /> + + + } + > + {membersLoading ? ( + + ) : ( + + data={members as OrganizationMembershipRow[]} + total={members.length} + emptyTitle="No members" + emptyDescription="Add a user to this organization to grant scoped tenant access." + columns={[ + { + key: "user", + label: "User", + render: (_value, row) => ( +
+ + {row.user?.email ?? row.userId} + + + {row.userId} + +
+ ), + }, + { + key: "roles", + label: "Roles", + render: (value) => , + }, + { + key: "scopes", + label: "Scopes", + render: (value) => ( + + {formatList(value as string[])} + + ), + }, + { + key: "createdAt", + label: "Joined", + sortable: true, + render: (value) => ( + + {formatDate(value as string)} + + ), + }, + ]} + actions={[ + { + icon: Trash2, + label: "Remove", + variant: "danger", + onClick: (row) => handleRemoveMember(row), + }, + ]} + /> + )} +
+
+ ); +} + +function StatusPanel({ + icon: Icon, + label, + value, +}: { + icon: ComponentType<{ size?: number }>; + label: string; + value: string; +}) { + return ( +
+
+ +
+
+
+ {value} +
+
+ {label} +
+
+
+ ); +} + +function SelectedOrganizationForm({ + organization, + isPending, + onSave, +}: { + organization: Organization; + isPending: boolean; + onSave: (input: { + organizationId: string; + name: string; + slug: string; + }) => void; +}) { + const [editName, setEditName] = useState(organization.name); + const [editSlug, setEditSlug] = useState(organization.slug); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + + onSave({ + organizationId: organization.id, + name: editName.trim(), + slug: editSlug.trim(), + }); + }; + + return ( +
+ + setEditName(event.target.value)} + className="w-full rounded-md border border-subtle bg-surface-alt px-3 py-2 text-sm outline-none transition focus:border-[var(--primary)] focus:ring-1 focus:ring-[var(--primary)]" + /> + + + + setEditSlug(event.target.value)} + className="w-full rounded-md border border-subtle bg-surface-alt px-3 py-2 text-sm outline-none transition focus:border-[var(--primary)] focus:ring-1 focus:ring-[var(--primary)]" + /> + + +
+ + + + +
+ + +
+ ); +} + +function Field({ label, children }: { label: string; children: ReactNode }) { + return ( + + ); +} + +function Detail({ label, value }: { label: string; value: string }) { + return ( +
+
+ {label} +
+
{value}
+
+ ); +} + +function BadgeList({ values }: { values: string[] }) { + if (!values.length) { + return None; + } + + return ( +
+ {values.map((value) => ( + + {value} + + ))} +
+ ); +}