diff --git a/app/components/layout/app-shell.tsx b/app/components/layout/app-shell.tsx index d6c440d..919919f 100644 --- a/app/components/layout/app-shell.tsx +++ b/app/components/layout/app-shell.tsx @@ -29,6 +29,7 @@ import { Gauge, UserCheck, Network, + Building, ShieldCheck, Megaphone, AlertOctagon, @@ -128,6 +129,7 @@ const navGroups: NavGroup[] = [ items: [ { to: "/tenants", icon: Building2, label: "Tenants" }, { to: "/memberships", icon: UserCheck, label: "Memberships" }, + { to: "/organizations", icon: Building, label: "Organizations" }, { to: "/users", icon: UsersIcon, label: "Users" }, { to: "/sso", icon: ShieldCheck, label: "SSO" }, ], diff --git a/app/lib/arcadia/organizations.ts b/app/lib/arcadia/organizations.ts new file mode 100644 index 0000000..d33449f --- /dev/null +++ b/app/lib/arcadia/organizations.ts @@ -0,0 +1,180 @@ +// Organizations — end-user workspaces nested under a tenant. +// Backend: /api/v1/organizations + /api/v1/admin/organizations. +// +// Tenant admins (arcadia-admin) bypass per-org membership checks via the +// `OrganizationContext` plug, so the same per-org routes used by end-users +// are used here to mutate any org in the tenant. + +import type { ArcadiaClient } from "@crema/arcadia-client" + +export type OrgStatus = "active" | "frozen" | "pending_deletion" | string +export type OnOwnerRemoval = + | "delete" + | "require_transfer" + | "freeze_until_new_owner" +export type OrgRole = "owner" | "admin" | "member" +export type MembershipStatus = "active" | "suspended" | "invited" | string + +export interface Organization { + id: string + tenant_id: string + slug: string + name: string + status: OrgStatus + on_owner_removal: OnOwnerRemoval + settings: Record + metadata: Record + inserted_at: string + updated_at: string +} + +export interface OrgMembership { + id: string + organization_id: string + user_id: string + role: OrgRole + status: MembershipStatus + joined_at: string | null +} + +export interface CreateOrgInput { + name: string + slug: string + on_owner_removal?: OnOwnerRemoval + settings?: Record + metadata?: Record +} + +export interface UpdateOrgInput { + name?: string + status?: OrgStatus + on_owner_removal?: OnOwnerRemoval + settings?: Record + metadata?: Record +} + +export interface InviteByEmailInput { + email: string + role?: OrgRole +} + +export interface AddRestrictedUserInput { + email: string + password: string + first_name: string + last_name: string + role?: OrgRole +} + +const BASE = "/api/v1/organizations" +const ADMIN_BASE = "/api/v1/admin/organizations" + +// Tenant-wide list: every org in the current tenant. Admin-only. +export async function listAllOrganizations( + arcadia: ArcadiaClient, +): Promise { + const res = await arcadia.GET<{ data: Organization[] }>(ADMIN_BASE) + return res.data +} + +// End-user list: orgs the current user is a member of. +export async function listMyOrganizations( + arcadia: ArcadiaClient, +): Promise { + const res = await arcadia.GET<{ data: Organization[] }>(BASE) + return res.data +} + +export async function createOrganization( + arcadia: ArcadiaClient, + input: CreateOrgInput, +): Promise { + const res = await arcadia.POST<{ data: Organization }>(BASE, { body: input }) + return res.data +} + +export async function getOrganization( + arcadia: ArcadiaClient, + id: string, +): Promise { + const res = await arcadia.GET<{ data: Organization }>(`${BASE}/${id}`) + return res.data +} + +export async function updateOrganization( + arcadia: ArcadiaClient, + id: string, + input: UpdateOrgInput, +): Promise { + const res = await arcadia.PATCH<{ data: Organization }>(`${BASE}/${id}`, { + body: input, + }) + return res.data +} + +export async function listMembers( + arcadia: ArcadiaClient, + id: string, + status?: MembershipStatus, +): Promise { + const path = status + ? `${BASE}/${id}/members?status=${encodeURIComponent(status)}` + : `${BASE}/${id}/members` + const res = await arcadia.GET<{ data: OrgMembership[] }>(path) + return res.data +} + +export async function inviteMember( + arcadia: ArcadiaClient, + id: string, + input: InviteByEmailInput, +): Promise<{ type: "membership" | "email_invitation"; [k: string]: unknown }> { + const res = await arcadia.POST<{ + data: { type: "membership" | "email_invitation"; [k: string]: unknown } + }>(`${BASE}/${id}/members/invite`, { body: input }) + return res.data +} + +export async function addRestrictedMember( + arcadia: ArcadiaClient, + id: string, + input: AddRestrictedUserInput, +): Promise<{ user: { id: string; email: string; account_type: string }; membership: OrgMembership }> { + const res = await arcadia.POST<{ + data: { user: { id: string; email: string; account_type: string }; membership: OrgMembership } + }>(`${BASE}/${id}/members/add_restricted`, { body: input }) + return res.data +} + +export async function changeMemberRole( + arcadia: ArcadiaClient, + id: string, + userId: string, + role: "admin" | "member", +): Promise { + const res = await arcadia.PATCH<{ data: OrgMembership }>( + `${BASE}/${id}/members/${userId}/role`, + { body: { role } }, + ) + return res.data +} + +export async function removeMember( + arcadia: ArcadiaClient, + id: string, + userId: string, +): Promise { + await arcadia.DELETE(`${BASE}/${id}/members/${userId}`) +} + +export async function transferOwnership( + arcadia: ArcadiaClient, + id: string, + newOwnerUserId: string, +): Promise { + const res = await arcadia.POST<{ data: OrgMembership }>( + `${BASE}/${id}/transfer_ownership`, + { body: { new_owner_user_id: newOwnerUserId } }, + ) + return res.data +} diff --git a/app/routes.ts b/app/routes.ts index 66e4f16..7acf9bd 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -22,6 +22,7 @@ export default [ route("buckets", "routes/buckets.tsx"), route("monitoring", "routes/monitoring.tsx"), route("memberships", "routes/memberships.tsx"), + route("organizations", "routes/organizations.tsx"), route("networking", "routes/networking.tsx"), route("sso", "routes/sso.tsx"), route("announcements", "routes/announcements.tsx"), diff --git a/app/routes/organizations.tsx b/app/routes/organizations.tsx new file mode 100644 index 0000000..19b1b1f --- /dev/null +++ b/app/routes/organizations.tsx @@ -0,0 +1,885 @@ +import { useCallback, useEffect, useMemo, useState } from "react" +import { + Building, + Crown, + Mail, + RefreshCw, + Settings as SettingsIcon, + Trash2, + UserCog, + UserPlus, + Users as UsersIcon, +} from "lucide-react" + +import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client" +import { + ActionsCell, + BadgeCell, + DataTable, + DateCell, + Pagination, + useTable, + type ActionItem, + type BadgeTone, + type Column, +} from "@crema/table-ui" +import { SearchInput } from "@crema/search-ui" +import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui" + +import { AppShell } from "~/components/layout/app-shell" +import { Badge } from "~/components/ui/badge" +import { Button } from "~/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "~/components/ui/dialog" +import { Input } from "~/components/ui/input" +import { Label } from "~/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select" +import { + addRestrictedMember, + changeMemberRole, + inviteMember, + listAllOrganizations, + listMembers, + removeMember, + transferOwnership, + updateOrganization, + type OnOwnerRemoval, + type OrgMembership, + type OrgRole, + type OrgStatus, + type Organization, +} from "~/lib/arcadia/organizations" +import { pageTitle } from "~/lib/page-meta" +import { useSession } from "~/lib/session" + +export const meta = () => pageTitle("Organizations") + +type MembersDialogState = { org: Organization } | null +type SettingsDialogState = { org: Organization } | null + +const ON_OWNER_REMOVAL_LABEL: Record = { + delete: "Delete org", + require_transfer: "Require transfer", + freeze_until_new_owner: "Freeze until new owner", +} + +export default function OrganizationsRoute() { + const session = useSession() + const arcadia = useArcadiaClient() + + const [orgs, setOrgs] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [info, setInfo] = useState(null) + const [search, setSearch] = useState("") + const [statusFilter, setStatusFilter] = useState<"all" | OrgStatus>("all") + + const [membersDialog, setMembersDialog] = useState(null) + const [settingsDialog, setSettingsDialog] = useState(null) + + const refresh = useCallback(async () => { + setError(null) + setLoading(true) + try { + setOrgs(await listAllOrganizations(arcadia)) + } catch (err) { + setError(err instanceof ArcadiaError ? err.message : "Failed to load organizations.") + } finally { + setLoading(false) + } + }, [arcadia]) + + useEffect(() => { + if (session) refresh() + }, [session, refresh]) + + const filtered = useMemo( + () => (statusFilter === "all" ? orgs : orgs.filter((o) => o.status === statusFilter)), + [orgs, statusFilter], + ) + + const columns = useMemo[]>( + () => [ + { + id: "name", + header: "Organization", + accessor: "name", + sortable: true, + cell: (o) => ( +
+ {o.name} + + {o.slug} + +
+ ), + }, + { + id: "status", + header: "Status", + accessor: "status", + sortable: true, + cell: (o) => , + }, + { + id: "on_owner_removal", + header: "Owner-removal policy", + accessor: "on_owner_removal", + sortable: true, + cell: (o) => ( + + {ON_OWNER_REMOVAL_LABEL[o.on_owner_removal] ?? o.on_owner_removal} + + ), + }, + { + id: "updated_at", + header: "Updated", + accessor: "updated_at", + sortable: true, + cell: (o) => , + }, + { + id: "actions", + header: "", + align: "right", + cell: (o) => { + const items: ActionItem[] = [ + { + id: "members", + label: "Manage members", + icon: , + dataAction: `org-${o.id}-members`, + onSelect: () => setMembersDialog({ org: o }), + }, + { + id: "settings", + label: "Settings", + icon: , + dataAction: `org-${o.id}-settings`, + onSelect: () => setSettingsDialog({ org: o }), + }, + ] + return + }, + }, + ], + [], + ) + + const table = useTable({ + data: filtered, + columns, + getRowId: (o) => o.id, + initialPageSize: 25, + initialSearch: search, + }) + useEffect(() => { + table.setSearch(search) + }, [search, table]) + + return ( + +
+
+
+

Organizations

+

+ End-user workspaces inside this tenant. Each one is owned by a regular user; admins + here can manage members, change ownership policy, or freeze a workspace. +

+
+
+ +
+
+ + {error ? ( + setError(null)}> + {error} + + ) : null} + {info ? ( + setInfo(null)}> + {info} + + ) : null} + + + + + +
+ {table.total} of {orgs.length} +
+
+ + + + {table.total === 0 && !loading ? ( + } + title={ + search || statusFilter !== "all" + ? "No organizations match those filters." + : "No organizations yet." + } + description={ + search || statusFilter !== "all" + ? "Loosen the filter set." + : "End-users create these from inside the app; nothing to do here yet." + } + className="py-12" + /> + ) : ( + <> + o.id} + sort={table.sort} + onSortToggle={table.toggleSort} + loading={loading && orgs.length > 0} + stickyHeader + /> + + + )} + +
+
+ + setMembersDialog(null)} + onInfo={setInfo} + onError={setError} + /> + setSettingsDialog(null)} + onSaved={async (msg) => { + setSettingsDialog(null) + if (msg) setInfo(msg) + await refresh() + }} + onError={setError} + /> +
+ ) +} + +function statusTone(s: OrgStatus): BadgeTone { + if (s === "active") return "success" + if (s === "frozen") return "warning" + if (s === "pending_deletion") return "danger" + return "default" +} + +function roleBadgeVariant(r: OrgRole): "default" | "secondary" | "destructive" | "outline" { + if (r === "owner") return "default" + if (r === "admin") return "secondary" + return "outline" +} + +// ============================================================================ +// Members dialog +// ============================================================================ + +type InvitePane = "none" | "invite_existing" | "add_restricted" + +function MembersDialog({ + state, + onClose, + onInfo, + onError, +}: { + state: MembersDialogState + onClose: () => void + onInfo: (msg: string | null) => void + onError: (msg: string | null) => void +}) { + const arcadia = useArcadiaClient() + const open = state !== null + const org = state?.org + + const [members, setMembers] = useState([]) + const [loading, setLoading] = useState(false) + const [pendingRemove, setPendingRemove] = useState(null) + const [transferTarget, setTransferTarget] = useState(null) + const [pane, setPane] = useState("none") + + const refresh = useCallback(async () => { + if (!org) return + setLoading(true) + try { + setMembers(await listMembers(arcadia, org.id)) + } catch (err) { + onError(err instanceof ArcadiaError ? err.message : "Failed to load members.") + } finally { + setLoading(false) + } + }, [arcadia, org, onError]) + + useEffect(() => { + if (open) refresh() + }, [open, refresh]) + + return ( + !o && onClose()}> + + + {org ? `Members — ${org.name}` : "Members"} + + {org + ? `Manage who can act inside ${org.name}. The owner can be changed via transfer; one active owner at a time.` + : ""} + + + + {pane === "none" ? ( +
+ + +
+ ) : pane === "invite_existing" ? ( + setPane("none")} + onSaved={async (msg) => { + setPane("none") + onInfo(msg) + await refresh() + }} + onError={onError} + /> + ) : ( + setPane("none")} + onSaved={async (msg) => { + setPane("none") + onInfo(msg) + await refresh() + }} + onError={onError} + /> + )} + +
+ + {members.length === 0 && !loading ? ( + } + title="No members yet." + description="Invite someone or add a restricted sub-user to get started." + className="py-8" + /> + ) : ( +
+ + + + + + + + + + + {members.map((m) => ( + + + + + + + + ))} + +
UserRoleStatusJoined +
{m.user_id.slice(0, 8)}… + {m.role} + + {m.status} + + {m.joined_at ? new Date(m.joined_at).toLocaleDateString() : "—"} + + setTransferTarget(m)} + onRemove={() => setPendingRemove(m)} + onRoleChanged={async (msg) => { + onInfo(msg) + await refresh() + }} + onError={onError} + /> +
+
+ )} +
+ + + + + + !o && setPendingRemove(null)} + title="Remove member?" + description={ + pendingRemove + ? pendingRemove.role === "owner" + ? "This member is the owner. Removal will follow the org's owner-removal policy." + : "They will lose access to this organization." + : "" + } + confirmLabel="Remove" + variant="danger" + onConfirm={async () => { + if (!pendingRemove || !org) return + try { + await removeMember(arcadia, org.id, pendingRemove.user_id) + setPendingRemove(null) + onInfo("Member removed.") + await refresh() + } catch (err) { + onError(err instanceof ArcadiaError ? err.message : "Remove failed.") + setPendingRemove(null) + } + }} + /> + + !o && setTransferTarget(null)} + title="Transfer ownership?" + description={ + transferTarget + ? `The current owner will be demoted to admin. ${transferTarget.user_id.slice(0, 8)}… will become owner.` + : "" + } + confirmLabel="Transfer" + variant="default" + onConfirm={async () => { + if (!transferTarget || !org) return + try { + await transferOwnership(arcadia, org.id, transferTarget.user_id) + setTransferTarget(null) + onInfo("Ownership transferred.") + await refresh() + } catch (err) { + onError(err instanceof ArcadiaError ? err.message : "Transfer failed.") + setTransferTarget(null) + } + }} + /> +
+
+ ) +} + +function MemberRowActions({ + member, + orgId, + onTransfer, + onRemove, + onRoleChanged, + onError, +}: { + member: OrgMembership + orgId: string + onTransfer: () => void + onRemove: () => void + onRoleChanged: (msg: string) => Promise + onError: (msg: string | null) => void +}) { + const arcadia = useArcadiaClient() + + const items: ActionItem[] = [] + + if (member.role !== "owner") { + items.push({ + id: "promote-admin", + label: member.role === "admin" ? "Demote to member" : "Promote to admin", + icon: , + dataAction: `org-${orgId}-member-${member.id}-role`, + onSelect: async () => { + const next = member.role === "admin" ? "member" : "admin" + try { + await changeMemberRole(arcadia, orgId, member.user_id, next) + await onRoleChanged(`Role set to ${next}.`) + } catch (err) { + onError(err instanceof ArcadiaError ? err.message : "Role change failed.") + } + }, + }) + items.push({ + id: "transfer", + label: "Transfer ownership to this user", + icon: , + dataAction: `org-${orgId}-member-${member.id}-transfer`, + onSelect: onTransfer, + }) + } + + items.push({ + id: "remove", + label: "Remove", + icon: , + destructive: true, + dataAction: `org-${orgId}-member-${member.id}-remove`, + onSelect: onRemove, + }) + + return +} + +// ============================================================================ +// Invite-by-email and add-restricted forms +// ============================================================================ + +function InviteByEmailForm({ + orgId, + onCancel, + onSaved, + onError, +}: { + orgId: string + onCancel: () => void + onSaved: (msg: string) => Promise + onError: (msg: string | null) => void +}) { + const arcadia = useArcadiaClient() + const [email, setEmail] = useState("") + const [role, setRole] = useState("member") + const [saving, setSaving] = useState(false) + + return ( +
+
+ setEmail(e.target.value)} + /> + + + +
+

+ If an account with that email already exists in this tenant, an invited membership is + created; otherwise an email invitation is sent and the user is materialized on accept. +

+
+ ) +} + +function AddRestrictedForm({ + orgId, + onCancel, + onSaved, + onError, +}: { + orgId: string + onCancel: () => void + onSaved: (msg: string) => Promise + onError: (msg: string | null) => void +}) { + const arcadia = useArcadiaClient() + const [email, setEmail] = useState("") + const [firstName, setFirstName] = useState("") + const [lastName, setLastName] = useState("") + const [password, setPassword] = useState("") + const [role, setRole] = useState("member") + const [saving, setSaving] = useState(false) + + return ( +
+
+
+ + setEmail(e.target.value)} /> +
+
+ + setPassword(e.target.value)} + /> +
+
+ + setFirstName(e.target.value)} /> +
+
+ + setLastName(e.target.value)} /> +
+
+ + +
+
+
+ + +
+

+ Restricted users exist only inside this org — they can never act in personal mode and have + no plan of their own. +

+
+ ) +} + +// ============================================================================ +// Settings dialog +// ============================================================================ + +function SettingsDialog({ + state, + onClose, + onSaved, + onError, +}: { + state: SettingsDialogState + onClose: () => void + onSaved: (msg?: string) => Promise + onError: (msg: string | null) => void +}) { + const arcadia = useArcadiaClient() + const open = state !== null + const org = state?.org + + const [name, setName] = useState("") + const [status, setStatus] = useState("active") + const [onOwnerRemoval, setOnOwnerRemoval] = useState("require_transfer") + const [saving, setSaving] = useState(false) + + useEffect(() => { + if (org) { + setName(org.name) + setStatus(org.status) + setOnOwnerRemoval(org.on_owner_removal) + } + }, [org]) + + return ( + !o && onClose()}> + + + {org ? `Settings — ${org.name}` : "Settings"} + Change name, status, or owner-removal policy. + + +
+
+ + setName(e.target.value)} /> +
+ +
+ + +
+ +
+ + +

+ Decides what happens when the owner's membership is removed. +

+
+
+ + + + + +
+
+ ) +}