Orgs, Members, SSH and Admin page

This commit is contained in:
allanice001
2025-09-01 21:58:34 +01:00
parent 3f22521f49
commit 5425ed5dcc
61 changed files with 7138 additions and 819 deletions

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<title>AutoGlue</title>
</head>
<body>
<div id="root"></div>

View File

@@ -16,6 +16,7 @@
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.8",
@@ -47,6 +48,7 @@
"globals": "^16.3.0",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.6.14",
"rollup-plugin-visualizer": "^6.0.3",
"shadcn": "^3.0.0",
"tw-animate-css": "^1.3.7",
"typescript": "~5.9.2",

View File

@@ -2,18 +2,24 @@ import { Navigate, Route, Routes } from "react-router-dom"
import { DashboardLayout } from "@/components/dashboard-layout.tsx"
import { ProtectedRoute } from "@/components/protected-route.tsx"
import { RequireAdmin } from "@/components/require-admin.tsx"
import { AdminUsersPage } from "@/pages/admin/users.tsx"
import { ForgotPassword } from "@/pages/auth/forgot-password.tsx"
import { Login } from "@/pages/auth/login.tsx"
import { Me } from "@/pages/auth/me.tsx"
import { Register } from "@/pages/auth/register.tsx"
import { ResetPassword } from "@/pages/auth/reset-password.tsx"
import { VerifyEmail } from "@/pages/auth/verify-email.tsx"
import {NotFoundPage} from "@/pages/error/not-found.tsx";
import {OrgManagement} from "@/pages/settings/orgs.tsx";
import { Forbidden } from "@/pages/error/forbidden.tsx"
import { NotFoundPage } from "@/pages/error/not-found.tsx"
import { MemberManagement } from "@/pages/settings/members.tsx"
import { OrgManagement } from "@/pages/settings/orgs.tsx"
import {SshKeysPage} from "@/pages/security/ssh.tsx";
function App() {
return (
<Routes>
<Route path="/403" element={<Forbidden />} />
<Route path="/" element={<Navigate to="/auth/login" replace />} />
{/* Public/auth branch */}
<Route path="/auth">
@@ -22,18 +28,18 @@ function App() {
<Route path="forgot" element={<ForgotPassword />} />
<Route path="reset" element={<ResetPassword />} />
<Route path="verify" element={<VerifyEmail />} />
<Route element={<ProtectedRoute />}>
<Route element={<DashboardLayout />}>
<Route path="me" element={<Me />} />
</Route>
</Route>
</Route>
<Route element={<ProtectedRoute />}>
<Route element={<DashboardLayout />}>
<Route element={<RequireAdmin />}>
<Route path="/admin">
<Route path="users" element={<AdminUsersPage />} />
</Route>
</Route>
<Route path="/core">
{/*
{/*
<Route path="cluster" element={<ClusterListPage />} />
<Route path="node-pools" element={<NodePoolsPage />} />
<Route path="servers" element={<ServersPage />} />
@@ -42,12 +48,13 @@ function App() {
</Route>
<Route path="/security">
{/*<Route path="ssh" element={<SshKeysPage />} />*/}
<Route path="ssh" element={<SshKeysPage />} />
</Route>
<Route path="/settings">
<Route path="orgs" element={<OrgManagement />} />
{/*<Route path="members" element={<MemberManagement />} />*/}
<Route path="orgs" element={<OrgManagement />} />
<Route path="members" element={<MemberManagement />} />
<Route path="me" element={<Me />} />
</Route>
<Route path="*" element={<NotFoundPage />} />

View File

@@ -1,20 +1,21 @@
import { SidebarProvider } from "@/components/ui/sidebar.tsx";
import {Outlet} from "react-router-dom";
import {Footer} from "@/components/footer.tsx";
import {DashboardSidebar} from "@/components/dashboard-sidebar.tsx";
import { Outlet } from "react-router-dom"
import { SidebarProvider } from "@/components/ui/sidebar.tsx"
import { DashboardSidebar } from "@/components/dashboard-sidebar.tsx"
import { Footer } from "@/components/footer.tsx"
export function DashboardLayout() {
return (
<div className="flex h-screen">
<SidebarProvider>
<DashboardSidebar />
<div className="flex flex-col flex-1">
<main className="flex-1 p-4 overflow-auto">
<Outlet />
</main>
<Footer />
</div>
</SidebarProvider>
</div>
<div className="flex h-screen">
<SidebarProvider>
<DashboardSidebar />
<div className="flex flex-1 flex-col">
<main className="flex-1 overflow-auto p-4">
<Outlet />
</main>
<Footer />
</div>
</SidebarProvider>
</div>
)
}

View File

@@ -1,82 +1,150 @@
import { useEffect, useMemo, useState, type ComponentType, type FC } from "react"
import { ChevronDown } from "lucide-react"
import { Link, useLocation } from "react-router-dom"
import { authStore, isGlobalAdmin, isOrgAdmin, type MePayload } from "@/lib/auth.ts"
import { Button } from "@/components/ui/button.tsx"
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem
} from "@/components/ui/sidebar.tsx";
import type {ComponentType, FC} from "react";
import {Link, useLocation} from "react-router-dom";
import {Collapsible, CollapsibleContent, CollapsibleTrigger} from "@/components/ui/collapsible.tsx";
import {ChevronDown} from "lucide-react";
import {items} from "@/components/sidebar/items.ts";
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible.tsx"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar.tsx"
import { ModeToggle } from "@/components/mode-toggle.tsx"
import { OrgSwitcher } from "@/components/org-switcher.tsx"
import { items, type NavItem } from "@/components/sidebar/items.ts"
interface MenuItemProps {
label: string;
icon: ComponentType<{ className?: string }>;
to?: string;
items?: MenuItemProps[];
label: string
icon: ComponentType<{ className?: string }>
to?: string
items?: MenuItemProps[]
}
function filterItems(items: NavItem[], isAdmin: boolean, isOrgAdminFlag: boolean): NavItem[] {
return items
.filter((it) => {
if (it.requiresAdmin && !isAdmin) return false
if (it.requiresOrgAdmin && !isOrgAdminFlag) return false
return true
})
.map((it) => ({
...it,
items: it.items ? filterItems(it.items, isAdmin, isOrgAdminFlag) : undefined,
}))
.filter((it) => !it.items || it.items.length > 0)
}
const MenuItem: FC<{ item: MenuItemProps }> = ({ item }) => {
const location = useLocation();
const Icon = item.icon;
const location = useLocation()
const Icon = item.icon
if (item.to) {
return (
<Link to={item.to}
className={`flex items-center space-x-2 text-sm py-2 px-4 rounded-md hover:bg-accent hover:text-accent-foreground ${location.pathname === item.to ? "bg-accent text-accent-foreground" : ""}`}
if (item.to) {
return (
<Link
to={item.to}
className={`hover:bg-accent hover:text-accent-foreground flex items-center space-x-2 rounded-md px-4 py-2 text-sm ${location.pathname === item.to ? "bg-accent text-accent-foreground" : ""}`}
>
<Icon className="mr-4 h-4 w-4" />
{item.label}
</Link>
)
}
>
<Icon className="h-4 w-4 mr-4" />
{item.label}
</Link>
)
}
if (item.items) {
return (
<Collapsible defaultOpen className="group/collapsible">
<SidebarGroup>
<SidebarGroupLabel asChild>
<CollapsibleTrigger>
<Icon className="h-4 w-4 mr-4" />
{item.label}
<ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
<SidebarMenu>
{item.items.map((subitem, index) => (
<SidebarMenuItem key={index}>
<SidebarMenuButton asChild>
<MenuItem item={subitem} />
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
)
}
return null
if (item.items) {
return (
<Collapsible defaultOpen className="group/collapsible">
<SidebarGroup>
<SidebarGroupLabel asChild>
<CollapsibleTrigger>
<Icon className="mr-4 h-4 w-4" />
{item.label}
<ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
<SidebarMenu>
{item.items.map((subitem, index) => (
<SidebarMenuItem key={index}>
<SidebarMenuButton asChild>
<MenuItem item={subitem} />
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
)
}
return null
}
export const DashboardSidebar = () => {
return (
<Sidebar>
<SidebarHeader className='flex items-center justify-between p-4'>
<h1 className="text-xl font-bold">AutoGlue</h1>
</SidebarHeader>
<SidebarContent>
{items.map((item, index) => (
<MenuItem item={item} key={index} />
))}
</SidebarContent>
</Sidebar>
)
}
const [me, setMe] = useState<MePayload | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let alive = true
;(async () => {
try {
const data = await authStore.me()
if (!alive) return
setMe(data)
} catch {
// ignore; unauthenticated users shouldn't be here anyway under ProtectedRoute
} finally {
if (!alive) return
setLoading(false)
}
})()
return () => {
alive = false
}
}, [])
const visibleItems = useMemo(() => {
const admin = isGlobalAdmin(me)
const orgAdmin = isOrgAdmin(me)
return filterItems(items, admin, orgAdmin)
}, [me])
return (
<Sidebar>
<SidebarHeader className="flex items-center justify-between p-4">
<h1 className="text-xl font-bold">AutoGlue</h1>
</SidebarHeader>
<SidebarContent>
{(loading ? items : visibleItems).map((item, i) => (
<MenuItem item={item} key={i} />
))}
</SidebarContent>
<SidebarFooter className="space-y-2 p-4">
<OrgSwitcher />
<ModeToggle />
<Button
onClick={() => {
localStorage.clear()
window.location.reload()
}}
className="w-full"
>
Logout
</Button>
</SidebarFooter>
</Sidebar>
)
}

View File

@@ -1,38 +1,38 @@
import { FaGithub } from "react-icons/fa";
import { FaGithub } from "react-icons/fa"
export function Footer() {
return (
<footer className="border-t">
<div className="container flex flex-col items-center justify-between gap-4 py-10 md:h-24 md:flex-row md:py-0">
<div className="flex flex-col items-center gap-4 px-8 md:flex-row md:gap-2 md:px-0">
<p className="text-center text-sm leading-loose text-muted-foreground md:text-left">
Built for{" "}
<a
href="https://www.glueops.dev/"
target="_blank"
rel="noreferrer"
className="font-medium underline underline-offset-4"
>
GlueOps
</a>
. The source code is available on{" "}
<a
href="https://github.com/GlueOps/autoglue"
target="_blank"
rel="noreferrer"
className="font-medium underline underline-offset-4"
>
GitHub
</a>
.
</p>
</div>
<div className="flex items-center space-x-4">
<a href="https://github.com/GlueOps/autoglue" target="_blank" rel="noreferrer">
<FaGithub className="h-5 w-5" />
</a>
</div>
</div>
</footer>
);
return (
<footer className="border-t">
<div className="container flex flex-col items-center justify-between gap-4 py-10 md:h-24 md:flex-row md:py-0">
<div className="flex flex-col items-center gap-4 px-8 md:flex-row md:gap-2 md:px-0">
<p className="text-muted-foreground text-center text-sm leading-loose md:text-left">
Built for{" "}
<a
href="https://www.glueops.dev/"
target="_blank"
rel="noreferrer"
className="font-medium underline underline-offset-4"
>
GlueOps
</a>
. The source code is available on{" "}
<a
href="https://github.com/GlueOps/autoglue"
target="_blank"
rel="noreferrer"
className="font-medium underline underline-offset-4"
>
GitHub
</a>
.
</p>
</div>
<div className="flex items-center space-x-4">
<a href="https://github.com/GlueOps/autoglue" target="_blank" rel="noreferrer">
<FaGithub className="h-5 w-5" />
</a>
</div>
</div>
</footer>
)
}

View File

@@ -1,4 +1,4 @@
import { Laptop, Moon, Sun } from "lucide-react"
import { CheckIcon, Laptop, Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button.tsx"
@@ -25,9 +25,15 @@ export function ModeToggle() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>Light</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>Dark</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>System</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("light")}>
{theme === "light" && <CheckIcon />}Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
{theme === "dark" && <CheckIcon />}Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
{theme === "system" && <CheckIcon />}System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)

View File

@@ -0,0 +1,97 @@
import { useEffect, useState } from "react"
import { api, ApiError } from "@/lib/api.ts"
import {
EVT_ACTIVE_ORG_CHANGED,
EVT_ORGS_CHANGED,
getActiveOrgId,
setActiveOrgId,
} from "@/lib/orgs-sync.ts"
import { Button } from "@/components/ui/button.tsx"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu.tsx"
type OrgLite = { id: string; name: string }
export const OrgSwitcher = () => {
const [orgs, setOrgs] = useState<OrgLite[]>([])
const [activeOrgId, setActiveOrgIdState] = useState<string | null>(null)
async function fetchOrgs() {
try {
const data = await api.get<OrgLite[]>("/api/v1/orgs")
setOrgs(data)
if (!getActiveOrgId() && data.length > 0) {
// default to first org if none selected yet
setActiveOrgId(data[0].id)
setActiveOrgIdState(data[0].id)
}
} catch (err) {
const msg = err instanceof ApiError ? err.message : "Failed to load organizations"
// optional: toast.error(msg);
console.error(msg)
}
}
useEffect(() => {
// initial load
setActiveOrgIdState(getActiveOrgId())
void fetchOrgs()
// cross-tab sync
const onStorage = (e: StorageEvent) => {
if (e.key === "active_org_id") setActiveOrgIdState(e.newValue)
}
window.addEventListener("storage", onStorage)
// same-tab sync: active org + orgs list mutations
const onActive = (e: Event) =>
setActiveOrgIdState((e as CustomEvent<string | null>).detail ?? null)
const onOrgs = () => void fetchOrgs()
window.addEventListener(EVT_ACTIVE_ORG_CHANGED, onActive as EventListener)
window.addEventListener(EVT_ORGS_CHANGED, onOrgs)
return () => {
window.removeEventListener("storage", onStorage)
window.removeEventListener(EVT_ACTIVE_ORG_CHANGED, onActive as EventListener)
window.removeEventListener(EVT_ORGS_CHANGED, onOrgs)
}
}, [])
const switchOrg = (orgId: string) => {
setActiveOrgId(orgId)
setActiveOrgIdState(orgId)
}
const currentOrgName = orgs.find((o) => o.id === activeOrgId)?.name ?? "Select Org"
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="w-full justify-start">
{currentOrgName}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48">
{orgs.length === 0 ? (
<DropdownMenuItem disabled>No organizations</DropdownMenuItem>
) : (
orgs.map((org) => (
<DropdownMenuItem
key={org.id}
onClick={() => switchOrg(org.id)}
className={org.id === activeOrgId ? "font-semibold" : undefined}
>
{org.name}
</DropdownMenuItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,38 @@
import { useEffect, useState, type ReactNode } from "react"
import { Navigate, Outlet, useLocation } from "react-router-dom"
import { authStore, isGlobalAdmin, type MePayload } from "@/lib/auth.ts"
type Props = { children?: ReactNode }
export function RequireAdmin({ children }: Props) {
const [loading, setLoading] = useState(true)
const [allowed, setAllowed] = useState(false)
const location = useLocation()
useEffect(() => {
let alive = true
;(async () => {
try {
const me: MePayload = await authStore.me()
if (!alive) return
setAllowed(isGlobalAdmin(me))
} catch {
if (!alive) return
setAllowed(false)
} finally {
setLoading(false)
if (!alive) return
}
})()
return () => {
alive = false
}
}, [])
if (loading) return null
if (!allowed) return <Navigate to="/403" replace state={{ from: location }} />
return children ? <>{children}</> : <Outlet />
}

View File

@@ -1,105 +1,128 @@
import type { ComponentType } from "react"
import {
BoxesIcon,
BrainCogIcon,
Building2Icon,
BuildingIcon,
ComponentIcon,
FileKey2Icon,
HomeIcon,
KeyIcon,
ListTodoIcon,
LockKeyholeIcon,
ServerIcon,
SettingsIcon,
SprayCanIcon,
TagsIcon,
UsersIcon,
} from "lucide-react";
import { AiOutlineCluster } from "react-icons/ai";
BoxesIcon,
BrainCogIcon,
BuildingIcon,
ComponentIcon,
FileKey2Icon,
HomeIcon,
KeyIcon,
ListTodoIcon,
LockKeyholeIcon,
ServerIcon,
SettingsIcon,
ShieldCheckIcon,
SprayCanIcon,
TagsIcon,
UserIcon,
UsersIcon,
} from "lucide-react"
import { AiOutlineCluster } from "react-icons/ai"
export type NavItem = {
label: string
icon: ComponentType<{ className?: string }>
to?: string
items?: NavItem[]
requiresAdmin?: boolean
requiresOrgAdmin?: boolean
}
export const items = [
{
label: "Dashboard",
icon: HomeIcon,
to: "/dashboard",
},
{
label: "Core",
icon: BrainCogIcon,
items: [
{
label: "Cluster",
to: "/core/cluster",
icon: AiOutlineCluster,
},
{
label: "Node Pools",
icon: BoxesIcon,
to: "/core/node-pools",
},
{
label: "Labels",
icon: TagsIcon,
to: "/core/labels",
},
{
label: "Roles",
icon: ComponentIcon,
to: "/core/roles",
},
{
label: "Taints",
icon: SprayCanIcon,
to: "/core/taints",
},
{
label: "Servers",
icon: ServerIcon,
to: "/core/servers",
},
],
},
{
label: "Security",
icon: LockKeyholeIcon,
items: [
{
label: "Keys & Tokens",
icon: KeyIcon,
to: "/security/keys",
},
{
label: "SSH Keys",
to: "/security/ssh",
icon: FileKey2Icon,
},
],
},
{
label: "Tasks",
icon: ListTodoIcon,
items: [],
},
{
label: "Settings",
icon: SettingsIcon,
items: [
{
label: "Organizations",
icon: Building2Icon,
items: [
{
label: "Organizations",
to: "/settings/orgs",
icon: BuildingIcon,
},
{
label: "Members",
to: "/settings/members",
icon: UsersIcon,
},
],
},
],
},
];
{
label: "Dashboard",
icon: HomeIcon,
to: "/dashboard",
},
{
label: "Core",
icon: BrainCogIcon,
items: [
{
label: "Cluster",
to: "/core/cluster",
icon: AiOutlineCluster,
},
{
label: "Node Pools",
icon: BoxesIcon,
to: "/core/node-pools",
},
{
label: "Annotations",
icon: ComponentIcon,
to: "/core/annotations",
},
{
label: "Labels",
icon: TagsIcon,
to: "/core/labels",
},
{
label: "Taints",
icon: SprayCanIcon,
to: "/core/taints",
},
{
label: "Servers",
icon: ServerIcon,
to: "/core/servers",
},
],
},
{
label: "Security",
icon: LockKeyholeIcon,
items: [
{
label: "Keys & Tokens",
icon: KeyIcon,
to: "/security/keys",
},
{
label: "SSH Keys",
to: "/security/ssh",
icon: FileKey2Icon,
},
],
},
{
label: "Tasks",
icon: ListTodoIcon,
items: [],
},
{
label: "Settings",
icon: SettingsIcon,
items: [
{
label: "Organizations",
to: "/settings/orgs",
icon: BuildingIcon,
},
{
label: "Members",
to: "/settings/members",
icon: UsersIcon,
},
{
label: "Profile",
to: "/settings/me",
icon: UserIcon,
},
],
},
{
label: "Admin",
icon: ShieldCheckIcon,
requiresAdmin: true,
items: [
{
label: "Users",
to: "/admin/users",
icon: UsersIcon,
requiresAdmin: true,
},
],
},
]

View File

@@ -4,26 +4,18 @@ import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
}
function AlertDialogOverlay({
@@ -61,10 +53,7 @@ function AlertDialogContent({
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
@@ -74,17 +63,11 @@ function AlertDialogHeader({
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
/>
)
@@ -120,12 +103,7 @@ function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
return <AlertDialogPrimitive.Action className={cn(buttonVariants(), className)} {...props} />
}
function AlertDialogCancel({

View File

@@ -0,0 +1,37 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return <Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />
}
export { Badge, badgeVariants }

View File

@@ -9,16 +9,13 @@ const buttonVariants = cva(
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {

View File

@@ -1,31 +1,19 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
function Collapsible({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
return <CollapsiblePrimitive.CollapsibleTrigger data-slot="collapsible-trigger" {...props} />
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
return <CollapsiblePrimitive.CollapsibleContent data-slot="collapsible-content" {...props} />
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -4,27 +4,19 @@ import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
@@ -92,19 +84,13 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"

View File

@@ -0,0 +1,168 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -10,21 +10,15 @@ function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
@@ -101,10 +95,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"

View File

@@ -5,8 +5,8 @@ import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { useIsMobile } from "@/hooks/use-mobile"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
@@ -18,12 +18,7 @@ import {
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
@@ -96,10 +91,7 @@ function SidebarProvider({
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
toggleSidebar()
}
@@ -253,11 +245,7 @@ function Sidebar({
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
@@ -318,10 +306,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
@@ -354,10 +339,7 @@ function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
@@ -437,10 +419,7 @@ function SidebarGroupAction({
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
function SidebarGroupContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
@@ -577,10 +556,7 @@ function SidebarMenuAction({
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
@@ -618,12 +594,7 @@ function SidebarMenuSkeleton({
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
@@ -652,10 +623,7 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"

View File

@@ -0,0 +1,90 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div data-slot="table-container" className="relative w-full overflow-x-auto">
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return <thead data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} />
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption }

View File

@@ -16,9 +16,7 @@ function TooltipProvider({
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
@@ -26,9 +24,7 @@ function Tooltip({
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}

View File

@@ -37,6 +37,11 @@ function authHeaders(): Record<string, string> {
return headers
}
function orgContextHeaders(): Record<string, string> {
const id = localStorage.getItem("active_org_id")
return id ? { "X-Org-ID": id } : {}
}
async function request<T>(
path: string,
method: Method,
@@ -50,6 +55,7 @@ async function request<T>(
const merged: Record<string, string> = {
...baseHeaders,
...(opts.auth === false ? {} : authHeaders()),
...orgContextHeaders(),
...normalizeHeaders(opts.headers),
}
@@ -83,6 +89,8 @@ async function request<T>(
throw new ApiError(res.status, String(msg), payload)
}
console.debug("API ->", method, `${API_BASE_URL}${path}`, merged)
return isJSON ? (payload as T) : (undefined as T)
}

View File

@@ -1,5 +1,33 @@
import { api, API_BASE_URL } from "@/lib/api.ts"
export type MeUser = {
id: string
name?: string
email?: string
email_verified?: boolean
role: "admin" | "user" | string
created_at?: string
updated_at?: string
}
export type MePayload = {
user?: MeUser // preferred shape
user_id?: MeUser // fallback (older shape)
organization_id?: string | null
org_role?: "admin" | "member" | string | null
claims?: any
}
function getUser(me: MePayload | null | undefined): MeUser | undefined {
return (me && (me.user || me.user_id)) as MeUser | undefined
}
export function isGlobalAdmin(me: MePayload | null | undefined): boolean {
return getUser(me)?.role === "admin"
}
export function isOrgAdmin(me: MePayload | null | undefined): boolean {
return (me?.org_role ?? "") === "admin"
}
export const authStore = {
isAuthenticated(): boolean {
return !!localStorage.getItem("access_token")
@@ -19,9 +47,7 @@ export const authStore = {
},
async me() {
return await api.get<{ user_id: string; organization_id?: string; org_role?: string }>(
"/api/v1/auth/me"
)
return await api.get<MePayload>("/api/v1/auth/me")
},
async logout() {

17
ui/src/lib/orgs-sync.ts Normal file
View File

@@ -0,0 +1,17 @@
export const ACTIVE_ORG_KEY = "active_org_id"
export const EVT_ACTIVE_ORG_CHANGED = "active-org-changed"
export const EVT_ORGS_CHANGED = "orgs-changed"
export function getActiveOrgId(): string | null {
return localStorage.getItem(ACTIVE_ORG_KEY)
}
export function setActiveOrgId(id: string | null) {
if (id) localStorage.setItem(ACTIVE_ORG_KEY, id)
else localStorage.removeItem(ACTIVE_ORG_KEY)
window.dispatchEvent(new CustomEvent<string | null>(EVT_ACTIVE_ORG_CHANGED, { detail: id }))
}
export function emitOrgsChanged() {
window.dispatchEvent(new Event(EVT_ORGS_CHANGED))
}

View File

@@ -0,0 +1,406 @@
import { useEffect, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { PencilIcon, PlusIcon, TrashIcon } from "lucide-react"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import { api, ApiError } from "@/lib/api"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter as DialogFooterUI,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
type User = {
id: string
name: string
email: string
role: "admin" | "user" | string
email_verified: boolean
created_at: string
updated_at?: string
}
type ListRes = { users: User[]; page: number; page_size: number; total: number }
const CreateSchema = z.object({
name: z.string().min(1, "Name required"),
email: z.email("Enter a valid email"),
role: z.enum(["user", "admin"]),
password: z.string().min(8, "Min 8 characters"),
})
type CreateValues = z.infer<typeof CreateSchema>
const EditSchema = z.object({
name: z.string().min(1, "Name required"),
email: z.email("Enter a valid email"),
role: z.enum(["user", "admin"]),
password: z.string().min(8, "Min 8 characters").optional().or(z.literal("")),
})
type EditValues = z.infer<typeof EditSchema>
export function AdminUsersPage() {
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
const [createOpen, setCreateOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [editing, setEditing] = useState<User | null>(null)
const [deletingId, setDeletingId] = useState<string | null>(null)
const createForm = useForm<CreateValues>({
resolver: zodResolver(CreateSchema),
mode: "onChange",
defaultValues: { name: "", email: "", role: "user", password: "" },
})
const editForm = useForm<EditValues>({
resolver: zodResolver(EditSchema),
mode: "onChange",
defaultValues: { name: "", email: "", role: "user", password: "" },
})
async function fetchUsers() {
setLoading(true)
try {
const res = await api.get<ListRes>("/api/v1/admin/users?page=1&page_size=100")
setUsers(res.users ?? [])
} catch (e) {
toast.error(e instanceof ApiError ? e.message : "Failed to load users")
} finally {
setLoading(false)
}
}
useEffect(() => {
void fetchUsers()
}, [])
async function onCreate(values: CreateValues) {
try {
const newUser = await api.post<User>("/api/v1/admin/users", values)
setUsers((prev) => [newUser, ...prev])
setCreateOpen(false)
createForm.reset({ name: "", email: "", role: "user", password: "" })
toast.success(`Created ${newUser.email}`)
} catch (e) {
toast.error(e instanceof ApiError ? e.message : "Failed to create user")
}
}
function openEdit(u: User) {
setEditing(u)
editForm.reset({
name: u.name || "",
email: u.email,
role: (u.role as "user" | "admin") ?? "user",
password: "",
})
setEditOpen(true)
}
async function onEdit(values: EditValues) {
if (!editing) return
const payload: Record<string, unknown> = {
name: values.name,
email: values.email,
role: values.role,
}
if (values.password && values.password.length >= 8) {
payload.password = values.password
}
try {
const updated = await api.patch<User>(`/api/v1/admin/users/${editing.id}`, payload)
setUsers((prev) => prev.map((u) => (u.id === updated.id ? updated : u)))
setEditOpen(false)
setEditing(null)
toast.success(`Updated ${updated.email}`)
} catch (e) {
toast.error(e instanceof ApiError ? e.message : "Failed to update user")
}
}
async function onDelete(id: string) {
try {
setDeletingId(id)
await api.delete<void>(`/api/v1/admin/users/${id}`)
setUsers((prev) => prev.filter((u) => u.id !== id))
toast.success("User deleted")
} catch (e) {
toast.error(e instanceof ApiError ? e.message : "Failed to delete user")
} finally {
setDeletingId(null)
}
}
return (
<div className="space-y-4 p-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Users</h1>
<Button onClick={() => setCreateOpen(true)}>
<PlusIcon className="mr-2 h-4 w-4" />
New user
</Button>
</div>
<Separator />
{loading ? (
<div className="text-muted-foreground text-sm">Loading</div>
) : users.length === 0 ? (
<div className="text-muted-foreground text-sm">No users yet.</div>
) : (
<div className="grid grid-cols-1 gap-4 pr-2 md:grid-cols-2 lg:grid-cols-3">
{users.map((u) => (
<Card key={u.id} className="flex flex-col">
<CardHeader>
<CardTitle className="text-base">{u.name || u.email}</CardTitle>
</CardHeader>
<CardContent className="text-muted-foreground space-y-1 text-sm">
<div>Email: {u.email}</div>
<div>Role: {u.role}</div>
<div>Verified: {u.email_verified ? "Yes" : "No"}</div>
<div>Joined: {new Date(u.created_at).toLocaleString()}</div>
</CardContent>
<CardFooter className="mt-auto w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
<Button variant="outline" onClick={() => openEdit(u)}>
<PencilIcon className="mr-2 h-4 w-4" /> Edit
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" disabled={deletingId === u.id}>
<TrashIcon className="mr-2 h-4 w-4" />
{deletingId === u.id ? "Deleting…" : "Delete"}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete user?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete <b>{u.email}</b>.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="sm:justify-between">
<AlertDialogCancel disabled={deletingId === u.id}>Cancel</AlertDialogCancel>
<AlertDialogAction asChild disabled={deletingId === u.id}>
<Button variant="destructive" onClick={() => onDelete(u.id)}>
Confirm delete
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardFooter>
</Card>
))}
</div>
)}
{/* Create dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>Create user</DialogTitle>
<DialogDescription>Add a new user account.</DialogDescription>
</DialogHeader>
<Form {...createForm}>
<form onSubmit={createForm.handleSubmit(onCreate)} className="grid gap-4 py-2">
<FormField
name="name"
control={createForm.control}
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} placeholder="Jane Doe" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="email"
control={createForm.control}
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" {...field} placeholder="jane@example.com" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="role"
control={createForm.control}
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Select role" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="password"
control={createForm.control}
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" {...field} placeholder="••••••••" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooterUI className="mt-2 flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)}>
Cancel
</Button>
<Button
type="submit"
disabled={!createForm.formState.isValid || createForm.formState.isSubmitting}
>
{createForm.formState.isSubmitting ? "Creating…" : "Create"}
</Button>
</DialogFooterUI>
</form>
</Form>
</DialogContent>
</Dialog>
{/* Edit dialog */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>Edit user</DialogTitle>
<DialogDescription>
Update user details. Leave password blank to keep it unchanged.
</DialogDescription>
</DialogHeader>
<Form {...editForm}>
<form onSubmit={editForm.handleSubmit(onEdit)} className="grid gap-4 py-2">
<FormField
name="name"
control={editForm.control}
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="email"
control={editForm.control}
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="role"
control={editForm.control}
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Select role" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="password"
control={editForm.control}
render={({ field }) => (
<FormItem>
<FormLabel>New password (optional)</FormLabel>
<FormControl>
<Input type="password" {...field} placeholder="Leave blank to keep" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooterUI className="mt-2 flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
<Button type="button" variant="outline" onClick={() => setEditOpen(false)}>
Cancel
</Button>
<Button
type="submit"
disabled={!editForm.formState.isValid || editForm.formState.isSubmitting}
>
{editForm.formState.isSubmitting ? "Saving…" : "Save changes"}
</Button>
</DialogFooterUI>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -39,7 +39,7 @@ export function Login() {
try {
await authStore.login(values.email, values.password)
toast.success("Welcome back!")
const to = location.state?.from?.pathname ?? "/auth/me"
const to = location.state?.from?.pathname ?? "/settings/me"
navigate(to, { replace: true })
} catch (e: any) {
toast.error(e.message || "Login failed")

View File

@@ -0,0 +1,8 @@
export function Forbidden() {
return (
<div className="p-6">
<h1 className="text-2xl font-bold">403 Forbidden</h1>
<p className="text-muted-foreground text-sm">You dont have access to this area.</p>
</div>
)
}

View File

@@ -1,16 +1,15 @@
import {useNavigate} from "react-router-dom";
import {Button} from "@/components/ui/button.tsx";
import { useNavigate } from "react-router-dom"
import { Button } from "@/components/ui/button.tsx"
export const NotFoundPage = () => {
const navigate = useNavigate();
const navigate = useNavigate()
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-background text-foreground">
<h1 className="text-6xl font-bold mb-4">404</h1>
<p className="text-2xl mb-8">Oops! Page not found</p>
<Button onClick={() => navigate("/dashboard")}>
Go back to Dashboard
</Button>
</div>
);
};
return (
<div className="bg-background text-foreground flex min-h-screen flex-col items-center justify-center">
<h1 className="mb-4 text-6xl font-bold">404</h1>
<p className="mb-8 text-2xl">Oops! Page not found</p>
<Button onClick={() => navigate("/dashboard")}>Go back to Dashboard</Button>
</div>
)
}

View File

@@ -0,0 +1,428 @@
import { useEffect, useMemo, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { CloudDownload, Copy, Plus, Trash } from "lucide-react"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { api, API_BASE_URL } from "@/lib/api.ts"
import { Badge } from "@/components/ui/badge.tsx"
import { Button } from "@/components/ui/button.tsx"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog.tsx"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu.tsx"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form.tsx"
import { Input } from "@/components/ui/input.tsx"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table.tsx"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip.tsx"
type SshKey = {
id: string
name: string
public_keys: string
fingerprint: string
created_at: string
}
type Part = "public" | "private" | "both"
const CreateKeySchema = z.object({
name: z.string().min(1, "Name is required").max(100, "Max 100 characters"),
comment: z.string().trim().max(100, "Max 100 characters").default(""),
bits: z.enum(["2048", "3072", "4096"]),
})
type CreateKeyInput = z.input<typeof CreateKeySchema>
type CreateKeyOutput = z.output<typeof CreateKeySchema>
function filenameFromDisposition(disposition?: string, fallback = "download.bin") {
if (!disposition) return fallback
const star = /filename\*=UTF-8''([^;]+)/i.exec(disposition)
if (star?.[1]) return decodeURIComponent(star[1])
const basic = /filename="?([^"]+)"?/i.exec(disposition)
return basic?.[1] ?? fallback
}
function truncateMiddle(str: string, keep = 24) {
if (!str || str.length <= keep * 2 + 3) return str
return `${str.slice(0, keep)}${str.slice(-keep)}`
}
function getKeyType(publicKey: string) {
return publicKey?.split(/\s+/)?.[0] ?? "ssh-key"
}
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text)
} catch {
const el = document.createElement("textarea")
el.value = text
el.setAttribute("readonly", "")
el.style.position = "absolute"
el.style.left = "-9999px"
document.body.appendChild(el)
el.select()
document.execCommand("copy")
document.body.removeChild(el)
}
}
export const SshKeysPage = () => {
const [sshKeys, setSSHKeys] = useState<SshKey[]>([])
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState("")
const [createOpen, setCreateOpen] = useState(false)
const hasOrg = useMemo(() => !!localStorage.getItem("active_org_id"), [])
async function fetchSshKeys() {
setLoading(true)
setError(null)
try {
if (!hasOrg) {
setSSHKeys([])
setError("Select an organization first.")
return
}
// api wrapper returns the parsed body directly
const data = await api.get<SshKey[]>("/api/v1/ssh")
setSSHKeys(data ?? [])
} catch (err) {
console.error(err)
setError("Failed to fetch SSH keys")
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchSshKeys()
// re-fetch if active org changes in another tab
const onStorage = (e: StorageEvent) => {
if (e.key === "active_org_id") fetchSshKeys()
}
window.addEventListener("storage", onStorage)
return () => window.removeEventListener("storage", onStorage)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const filtered = sshKeys.filter((k) => {
const hay = `${k.name} ${k.public_keys} ${k.fingerprint}`.toLowerCase()
return hay.includes(filter.toLowerCase())
})
// Use raw fetch for download so we can read headers and blob
async function downloadKeyPair(id: string, part: Part = "both") {
const token = localStorage.getItem("access_token")
const orgId = localStorage.getItem("active_org_id")
const url = `${API_BASE_URL}/api/v1/ssh/${encodeURIComponent(id)}/download?part=${encodeURIComponent(part)}`
try {
const res = await fetch(url, {
method: "GET",
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(orgId ? { "X-Org-ID": orgId } : {}),
},
})
if (!res.ok) {
const msg = await res.text().catch(() => "")
throw new Error(msg || `HTTP ${res.status}`)
}
const blob = await res.blob()
const fallback =
part === "both"
? `ssh_key_${id}.zip`
: part === "public"
? `id_rsa_${id}.pub`
: `id_rsa_${id}.pem`
const filename = filenameFromDisposition(
res.headers.get("content-disposition") ?? undefined,
fallback
)
const objectUrl = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = objectUrl
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(objectUrl)
} catch (e) {
console.error(e)
alert(e instanceof Error ? e.message : "Download failed")
}
}
async function deleteKeyPair(id: string) {
try {
await api.delete<void>(`/api/v1/ssh/${encodeURIComponent(id)}`)
await fetchSshKeys()
} catch (e) {
console.error(e)
alert("Failed to delete key")
}
}
const form = useForm<CreateKeyInput, any, CreateKeyOutput>({
resolver: zodResolver(CreateKeySchema),
defaultValues: { name: "", comment: "deploy@autoglue", bits: "4096" },
})
async function onSubmit(values: CreateKeyInput) {
try {
await api.post<SshKey>("/api/v1/ssh", {
bits: Number(values.bits),
comment: values.comment?.trim() ?? "",
name: values.name.trim(),
download: "none",
})
setCreateOpen(false)
form.reset()
await fetchSshKeys()
} catch (e) {
console.error(e)
alert("Failed to create key")
}
}
if (loading) return <div className="p-6">Loading SSH Keys</div>
if (error) return <div className="p-6 text-red-500">{error}</div>
return (
<TooltipProvider>
<div className="space-y-4 p-6">
<div className="flex items-center justify-between gap-3">
<h1 className="text-2xl font-bold">SSH Keys</h1>
<div className="w-full max-w-sm">
<Input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Search by name, fingerprint or key"
/>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button onClick={() => setCreateOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Create New Keypair
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Create SSH Keypair</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="e.g., CI deploy key" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="comment"
render={({ field }) => (
<FormItem>
<FormLabel>Comment</FormLabel>
<FormControl>
<Input placeholder="e.g., deploy@autoglue" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bits"
render={({ field }) => (
<FormItem>
<FormLabel>Key size</FormLabel>
<FormControl>
<select
className="bg-background w-full rounded-md border px-3 py-2 text-sm"
value={field.value}
onChange={field.onChange}
>
<option value="2048">2048</option>
<option value="3072">3072</option>
<option value="4096">4096</option>
</select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="gap-2">
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? "Creating…" : "Create"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
<div className="bg-background overflow-hidden rounded-2xl border shadow-sm">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="min-w-[360px]">Public Key</TableHead>
<TableHead>Fingerprint</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-[160px] text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((sshKey) => {
const keyType = getKeyType(sshKey.public_keys)
const truncated = truncateMiddle(sshKey.public_keys, 18)
return (
<TableRow key={sshKey.id}>
<TableCell className="align-top">{sshKey.name}</TableCell>
<TableCell className="align-top">
<div className="flex items-start gap-2">
<Badge variant="secondary" className="whitespace-nowrap">
{keyType}
</Badge>
<Tooltip>
<TooltipTrigger asChild>
<code className="font-mono text-sm break-all md:max-w-[48ch] md:truncate md:break-normal">
{truncated}
</code>
</TooltipTrigger>
<TooltipContent className="max-w-[70vw]">
<div className="max-w-full">
<p className="font-mono text-xs break-all">{sshKey.public_keys}</p>
</div>
</TooltipContent>
</Tooltip>
</div>
</TableCell>
<TableCell className="align-top">
<code className="font-mono text-sm">{sshKey.fingerprint}</code>
</TableCell>
<TableCell className="align-top">
{new Date(sshKey.created_at).toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
</TableCell>
<TableCell className="align-top">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => copyToClipboard(sshKey.public_keys)}
title="Copy public key"
>
<Copy className="mr-2 h-4 w-4" />
Copy
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<CloudDownload className="mr-2 h-4 w-4" />
Download
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => downloadKeyPair(sshKey.id, "both")}>
Public + Private (.zip)
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => downloadKeyPair(sshKey.id, "public")}
>
Public only (.pub)
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => downloadKeyPair(sshKey.id, "private")}
>
Private only (.pem)
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="destructive"
size="sm"
onClick={() => deleteKeyPair(sshKey.id)}
>
<Trash className="mr-2 h-4 w-4" />
Delete
</Button>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
</div>
</TooltipProvider>
)
}

View File

@@ -0,0 +1,361 @@
// src/pages/settings/members.tsx
import { useEffect, useMemo, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { TrashIcon, UserPlus2 } from "lucide-react"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import { api, ApiError } from "@/lib/api.ts"
import { EVT_ACTIVE_ORG_CHANGED, getActiveOrgId } from "@/lib/orgs-sync.ts"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog.tsx"
import { Button } from "@/components/ui/button.tsx"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card.tsx"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter as DialogFooterUI,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog.tsx"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form.tsx"
import { Input } from "@/components/ui/input.tsx"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select.tsx"
import { Separator } from "@/components/ui/separator.tsx"
import { Skeleton } from "@/components/ui/skeleton.tsx"
type Me = { id: string; email?: string; name?: string }
// Backend shape can vary; normalize to a safe shape for UI.
type MemberDTO = any
type Member = {
userId: string
email?: string
name?: string
role: string
joinedAt?: string
}
function normalizeMember(m: MemberDTO): Member {
const userId = m?.user_id ?? m?.UserID ?? m?.user?.id ?? m?.User?.ID ?? ""
const email = m?.email ?? m?.Email ?? m?.user?.email ?? m?.User?.Email
const name = m?.name ?? m?.Name ?? m?.user?.name ?? m?.User?.Name
const role = m?.role ?? m?.Role ?? "member"
const joinedAt = m?.created_at ?? m?.CreatedAt
return { userId: String(userId), email, name, role: String(role), joinedAt }
}
const InviteSchema = z.object({
email: z.email("Enter a valid email"),
role: z.enum(["member", "admin"]),
})
type InviteValues = z.infer<typeof InviteSchema>
export const MemberManagement = () => {
const [loading, setLoading] = useState(true)
const [members, setMembers] = useState<Member[]>([])
const [me, setMe] = useState<Me | null>(null)
const [inviteOpen, setInviteOpen] = useState(false)
const [inviting, setInviting] = useState(false)
const [deletingId, setDeletingId] = useState<string | null>(null)
const activeOrgIdInitial = useMemo(() => getActiveOrgId(), [])
const form = useForm<InviteValues>({
resolver: zodResolver(InviteSchema),
defaultValues: { email: "", role: "member" },
mode: "onChange",
})
async function fetchMe() {
try {
const data = await api.get<Me>("/api/v1/auth/me")
setMe(data)
} catch {
// non-blocking
}
}
async function fetchMembers(orgId: string | null) {
if (!orgId) {
setMembers([])
setLoading(false)
return
}
setLoading(true)
try {
const data = await api.get<MemberDTO[]>("/api/v1/orgs/members")
setMembers((data ?? []).map(normalizeMember))
} catch (err) {
const msg = err instanceof ApiError ? err.message : "Failed to load members"
toast.error(msg)
} finally {
setLoading(false)
}
}
useEffect(() => {
void fetchMe()
void fetchMembers(activeOrgIdInitial)
}, [activeOrgIdInitial])
// Refetch when active org changes (same tab or across tabs)
useEffect(() => {
const onActiveOrg = () => void fetchMembers(getActiveOrgId())
const onStorage = (e: StorageEvent) => {
if (e.key === "active_org_id") onActiveOrg()
}
window.addEventListener(EVT_ACTIVE_ORG_CHANGED, onActiveOrg as EventListener)
window.addEventListener("storage", onStorage)
return () => {
window.removeEventListener(EVT_ACTIVE_ORG_CHANGED, onActiveOrg as EventListener)
window.removeEventListener("storage", onStorage)
}
}, [])
async function onInvite(values: InviteValues) {
const orgId = getActiveOrgId()
if (!orgId) {
toast.error("Select an organization first")
return
}
try {
setInviting(true)
await api.post("/api/v1/orgs/invite", values)
toast.success(`Invited ${values.email}`)
setInviteOpen(false)
form.reset({ email: "", role: "member" })
// If you later expose pending invites, update that list; for now just refresh members.
void fetchMembers(orgId)
} catch (err) {
const msg = err instanceof ApiError ? err.message : "Failed to invite member"
toast.error(msg)
} finally {
setInviting(false)
}
}
async function onRemove(userId: string) {
const orgId = getActiveOrgId()
if (!orgId) {
toast.error("Select an organization first")
return
}
try {
setDeletingId(userId)
await api.delete<void>(`/api/v1/orgs/members/${userId}`, {
headers: { "X-Org-ID": orgId },
})
setMembers((prev) => prev.filter((m) => m.userId !== userId))
toast.success("Member removed")
} catch (err) {
const msg = err instanceof ApiError ? err.message : "Failed to remove member"
toast.error(msg)
} finally {
setDeletingId(null)
}
}
const canManage = true // Server enforces admin; UI stays permissive.
if (loading) {
return (
<div className="space-y-4 p-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Members</h1>
<Button disabled>
<UserPlus2 className="mr-2 h-4 w-4" />
Invite
</Button>
</div>
<Separator />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[...Array(6)].map((_, i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-2">
<Skeleton className="h-4 w-56" />
<Skeleton className="h-4 w-40" />
</CardContent>
<CardFooter>
<Skeleton className="h-9 w-24" />
</CardFooter>
</Card>
))}
</div>
</div>
)
}
if (!getActiveOrgId()) {
return (
<div className="space-y-4 p-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Members</h1>
</div>
<Separator />
<p className="text-muted-foreground text-sm">
No organization selected. Choose an organization to manage its members.
</p>
</div>
)
}
return (
<div className="space-y-4 p-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<h1 className="text-2xl font-bold">Members</h1>
<Dialog open={inviteOpen} onOpenChange={setInviteOpen}>
<DialogTrigger asChild>
<Button>
<UserPlus2 className="mr-2 h-4 w-4" />
Invite
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>Invite member</DialogTitle>
<DialogDescription>Send an invite to join this organization.</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onInvite)} className="grid gap-4 py-2">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="jane@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Select role" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<DialogFooterUI className="mt-2 flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
<Button type="button" variant="outline" onClick={() => setInviteOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={!form.formState.isValid || inviting}>
{inviting ? "Sending…" : "Send invite"}
</Button>
</DialogFooterUI>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
<Separator />
{members.length === 0 ? (
<div className="text-muted-foreground text-sm">No members yet.</div>
) : (
<div className="grid grid-cols-1 gap-4 pr-2 sm:grid-cols-2 lg:grid-cols-3">
{members.map((m) => {
const isSelf = me?.id && m.userId === me.id
return (
<Card key={m.userId} className="flex flex-col">
<CardHeader>
<CardTitle className="text-base">{m.name || m.email || m.userId}</CardTitle>
</CardHeader>
<CardContent className="text-muted-foreground space-y-1 text-sm">
{m.email && <div>Email: {m.email}</div>}
<div>Role: {m.role}</div>
{m.joinedAt && <div>Joined: {new Date(m.joinedAt).toLocaleString()}</div>}
</CardContent>
<CardFooter className="mt-auto w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
<div />
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
disabled={!canManage || isSelf || deletingId === m.userId}
className="ml-auto"
>
<TrashIcon className="mr-2 h-5 w-5" />
{deletingId === m.userId ? "Removing…" : "Remove"}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove member?</AlertDialogTitle>
<AlertDialogDescription>
This will remove <b>{m.name || m.email || m.userId}</b> from the
organization.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="sm:justify-between">
<AlertDialogCancel disabled={deletingId === m.userId}>
Cancel
</AlertDialogCancel>
<AlertDialogAction asChild disabled={deletingId === m.userId}>
<Button variant="destructive" onClick={() => onRemove(m.userId)}>
Confirm remove
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardFooter>
</Card>
)
})}
</div>
)}
</div>
)
}

View File

@@ -1,281 +1,357 @@
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { useEffect, useRef, useState } from "react";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { api, ApiError } from "@/lib/api"; // <-- import ApiError
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { slugify } from "@/lib/utils";
import { toast } from "sonner";
import { useEffect, useRef, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { TrashIcon } from "lucide-react"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import { api, ApiError } from "@/lib/api.ts"
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle
} from "@/components/ui/dialog";
emitOrgsChanged,
EVT_ACTIVE_ORG_CHANGED,
EVT_ORGS_CHANGED,
getActiveOrgId,
setActiveOrgId as setActiveOrgIdLS,
} from "@/lib/orgs-sync.ts"
import { slugify } from "@/lib/utils.ts"
import {
Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog.tsx"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card.tsx"
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger
} from "@/components/ui/alert-dialog";
import { TrashIcon } from "lucide-react";
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog.tsx"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form.tsx"
import { Input } from "@/components/ui/input.tsx"
import { Separator } from "@/components/ui/separator.tsx"
import { Skeleton } from "@/components/ui/skeleton.tsx"
type Organization = {
id: string; // confirm with your API; change to number if needed
name: string;
slug: string;
created_at: string;
};
id: string // confirm with your API; change to number if needed
name: string
slug: string
created_at: string
}
const OrgSchema = z.object({
name: z.string().min(2).max(100),
slug: z.string()
.min(2).max(50)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Use lowercase letters, numbers, and hyphens."),
});
type OrgFormValues = z.infer<typeof OrgSchema>;
name: z.string().min(2).max(100),
slug: z
.string()
.min(2)
.max(50)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Use lowercase letters, numbers, and hyphens."),
})
type OrgFormValues = z.infer<typeof OrgSchema>
export const OrgManagement = () => {
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const slugEditedRef = useRef(false);
const [activeOrgId, setActiveOrgId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [organizations, setOrganizations] = useState<Organization[]>([])
const [loading, setLoading] = useState<boolean>(true)
const [createOpen, setCreateOpen] = useState<boolean>(false)
const [activeOrgId, setActiveOrgIdState] = useState<string | null>(null)
const [deletingId, setDeletingId] = useState<string | null>(null)
const slugEditedRef = useRef(false)
// initialize active org from localStorage once
useEffect(() => {
setActiveOrgId(localStorage.getItem("active_org_id"));
}, []);
const form = useForm<OrgFormValues>({
resolver: zodResolver(OrgSchema),
mode: "onChange",
defaultValues: {
name: "",
slug: "",
},
})
// keep active org in sync across tabs
useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key === "active_org_id") setActiveOrgId(e.newValue);
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, []);
const form = useForm<OrgFormValues>({
resolver: zodResolver(OrgSchema),
mode: "onChange",
defaultValues: { name: "", slug: "" },
});
// auto-generate slug from name unless user edited slug manually
const nameValue = form.watch("name");
useEffect(() => {
if (!slugEditedRef.current) {
form.setValue("slug", slugify(nameValue || ""), { shouldValidate: true });
}
}, [nameValue, form]);
// fetch orgs once
const getOrgs = async () => {
setLoading(true);
try {
const data = await api.get<Organization[]>("/api/v1/orgs");
setOrganizations(data);
setCreateOpen(data.length === 0);
} catch (err) {
const msg = err instanceof ApiError ? err.message : "Failed to load organizations";
toast.error(msg);
} finally {
setLoading(false);
}
};
useEffect(() => {
void getOrgs();
}, []);
async function onSubmit(values: OrgFormValues) {
try {
const newOrg = await api.post<Organization>("/api/v1/orgs", values);
setOrganizations(prev => [newOrg, ...prev]);
localStorage.setItem("active_org_id", newOrg.id);
setActiveOrgId(newOrg.id);
toast.success(`Created ${newOrg.name}`);
setCreateOpen(false);
form.reset({ name: "", slug: "" });
slugEditedRef.current = false;
} catch (err) {
const msg = err instanceof ApiError ? err.message : "Failed to create organization";
toast.error(msg);
}
// auto-generate slug from name unless user manually edited the slug
const nameValue = form.watch("name")
useEffect(() => {
if (!slugEditedRef.current) {
form.setValue("slug", slugify(nameValue || ""), { shouldValidate: true })
}
}, [nameValue, form])
function handleSelectOrg(org: Organization) {
localStorage.setItem("active_org_id", org.id);
setActiveOrgId(org.id);
toast.success(`Switched to ${org.name}`);
// fetch organizations
const getOrgs = async () => {
setLoading(true)
try {
const data = await api.get<Organization[]>("/api/v1/orgs")
setOrganizations(data)
setCreateOpen(data.length === 0)
} catch (err) {
const msg = err instanceof ApiError ? err.message : "Failed to load organizations"
toast.error(msg)
} finally {
setLoading(false)
}
}
async function handleDeleteOrg(org: Organization) {
try {
setDeletingId(org.id);
await api.delete<void>(`/api/v1/orgs/${org.id}`); // <-- correct path
setOrganizations(prev => {
const next = prev.filter(o => o.id !== org.id); // <-- fix shadow bug
if (activeOrgId === org.id) {
const nextId = next[0]?.id ?? null;
if (nextId) localStorage.setItem("active_org_id", nextId);
else localStorage.removeItem("active_org_id");
setActiveOrgId(nextId);
}
return next;
});
toast.success(`Deleted ${org.name}`);
} catch (err) {
const msg = err instanceof ApiError ? err.message : "Failed to delete organization";
toast.error(msg);
} finally {
setDeletingId(null);
// initial load + sync listeners
useEffect(() => {
// initialize active org from storage
setActiveOrgIdState(getActiveOrgId())
void getOrgs()
// cross-tab sync for active org
const onStorage = (e: StorageEvent) => {
if (e.key === "active_org_id") setActiveOrgIdState(e.newValue)
}
window.addEventListener("storage", onStorage)
// same-tab sync for active org (custom event)
const onActive = (e: Event) => {
const id = (e as CustomEvent<string | null>).detail ?? null
setActiveOrgIdState(id)
}
window.addEventListener(EVT_ACTIVE_ORG_CHANGED, onActive as EventListener)
// orgs list changes from elsewhere (custom event)
const onOrgs = () => void getOrgs()
window.addEventListener(EVT_ORGS_CHANGED, onOrgs)
return () => {
window.removeEventListener("storage", onStorage)
window.removeEventListener(EVT_ACTIVE_ORG_CHANGED, onActive as EventListener)
window.removeEventListener(EVT_ORGS_CHANGED, onOrgs)
}
}, [])
async function onSubmit(values: OrgFormValues) {
try {
const newOrg = await api.post<Organization>("/api/v1/orgs", values)
setOrganizations((prev) => [newOrg, ...prev])
// set as current org and broadcast
setActiveOrgIdLS(newOrg.id)
setActiveOrgIdState(newOrg.id)
emitOrgsChanged()
toast.success(`Created ${newOrg.name}`)
setCreateOpen(false)
form.reset({ name: "", slug: "" })
slugEditedRef.current = false
} catch (err) {
const msg = err instanceof ApiError ? err.message : "Failed to create organization"
toast.error(msg)
}
}
function handleSelectOrg(org: Organization) {
setActiveOrgIdLS(org.id) // updates localStorage + emits event
setActiveOrgIdState(org.id)
toast.success(`Switched to ${org.name}`)
}
async function handleDeleteOrg(org: Organization) {
try {
setDeletingId(org.id)
await api.delete<void>(`/api/v1/orgs/${org.id}`)
setOrganizations((prev) => {
const next = prev.filter((o) => o.id !== org.id)
// if we deleted the active org, move to the first remaining org (or clear)
if (activeOrgId === org.id) {
const nextId = next[0]?.id ?? null
setActiveOrgIdLS(nextId)
setActiveOrgIdState(nextId)
}
}
if (loading) {
return (
<div className="p-6 space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<h1 className="text-2xl font-bold mb-4">Organizations</h1>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(6)].map((_, i) => (
<Card key={i}>
<CardHeader><Skeleton className="h-5 w-40" /></CardHeader>
<CardContent>
<Skeleton className="h-4 w-24 mb-2" />
<Skeleton className="h-4 w-48" />
</CardContent>
<CardFooter><Skeleton className="h-9 w-24" /></CardFooter>
</Card>
))}
</div>
</div>
);
}
return next
})
emitOrgsChanged()
toast.success(`Deleted ${org.name}`)
} catch (err) {
const msg = err instanceof ApiError ? err.message : "Failed to delete organization"
toast.error(msg)
} finally {
setDeletingId(null)
}
}
if (loading) {
return (
<div className="p-6 space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<h1 className="text-2xl font-bold mb-4">Organizations</h1>
<Button onClick={() => setCreateOpen(true)}>New organization</Button>
</div>
<Separator />
{organizations.length === 0 ? (
<div className="text-sm text-muted-foreground">No organizations yet.</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 pr-2">
{organizations.map(org => (
<Card key={org.id} className="flex flex-col">
<CardHeader><CardTitle className="text-base">{org.name}</CardTitle></CardHeader>
<CardContent className="text-sm text-muted-foreground">
<div>Slug: {org.slug}</div>
<div className="mt-1">ID: {org.id}</div>
<div className="mt-1">Created: {new Date(org.created_at).toUTCString()}</div>
</CardContent>
<CardFooter className="mt-auto w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
<Button onClick={() => handleSelectOrg(org)}>
{org.id === activeOrgId ? "Selected" : "Select"}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="ml-auto">
<TrashIcon className="h-5 w-5 mr-2" />
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete organization?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete <b>{org.name}</b>. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="sm:justify-between">
<AlertDialogCancel disabled={deletingId === org.id}>Cancel</AlertDialogCancel>
<AlertDialogAction asChild disabled={deletingId === org.id}>
<Button variant="destructive" onClick={() => handleDeleteOrg(org)}>
{deletingId === org.id ? "Deleting…" : "Delete"}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardFooter>
</Card>
))}
</div>
)}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>Create organization</DialogTitle>
<DialogDescription>Set a name and a URL-friendly slug.</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl><Input placeholder="Acme Inc" autoFocus {...field} /></FormControl>
<FormDescription>This is your organizations display name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>Slug</FormLabel>
<FormControl>
<Input
placeholder="acme-inc"
{...field}
onChange={(e) => { slugEditedRef.current = true; field.onChange(e); }}
onBlur={(e) => {
const normalized = slugify(e.target.value);
form.setValue("slug", normalized, { shouldValidate: true });
field.onBlur();
}}
/>
</FormControl>
<FormDescription>Lowercase, numbers and hyphens only.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
<Button
type="button"
variant="outline"
onClick={() => { form.reset(); setCreateOpen(false); }}
>
Cancel
</Button>
<Button type="submit" disabled={!form.formState.isValid || form.formState.isSubmitting}>
{form.formState.isSubmitting ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
<div className="space-y-4 p-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<h1 className="mb-4 text-2xl font-bold">Organizations</h1>
</div>
);
};
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{[...Array(6)].map((_, i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent>
<Skeleton className="mb-2 h-4 w-24" />
<Skeleton className="h-4 w-48" />
</CardContent>
<CardFooter>
<Skeleton className="h-9 w-24" />
</CardFooter>
</Card>
))}
</div>
</div>
)
}
return (
<div className="space-y-4 p-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<h1 className="mb-4 text-2xl font-bold">Organizations</h1>
<Button onClick={() => setCreateOpen(true)}>New organization</Button>
</div>
<Separator />
{organizations.length === 0 ? (
<div className="text-muted-foreground text-sm">No organizations yet.</div>
) : (
<div className="grid grid-cols-1 gap-4 pr-2 sm:grid-cols-2 lg:grid-cols-3">
{organizations.map((org) => (
<Card key={org.id} className="flex flex-col">
<CardHeader>
<CardTitle className="text-base">{org.name}</CardTitle>
</CardHeader>
<CardContent className="text-muted-foreground text-sm">
<div>Slug: {org.slug}</div>
<div className="mt-1">ID: {org.id}</div>
</CardContent>
<CardFooter className="mt-auto w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
<Button onClick={() => handleSelectOrg(org)}>
{org.id === activeOrgId ? "Selected" : "Select"}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
className="ml-auto"
disabled={deletingId === org.id}
>
<TrashIcon className="mr-2 h-5 w-5" />
{deletingId === org.id ? "Deleting…" : "Delete"}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete organization?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete <b>{org.name}</b>. This action cannot be
undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="sm:justify-between">
<AlertDialogCancel disabled={deletingId === org.id}>Cancel</AlertDialogCancel>
<AlertDialogAction asChild disabled={deletingId === org.id}>
<Button variant="destructive" onClick={() => handleDeleteOrg(org)}>
Confirm delete
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardFooter>
</Card>
))}
</div>
)}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>Create organization</DialogTitle>
<DialogDescription>Set a name and a URL-friendly slug.</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Acme Inc" autoFocus {...field} />
</FormControl>
<FormDescription>This is your organizations display name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>Slug</FormLabel>
<FormControl>
<Input
placeholder="acme-inc"
{...field}
onChange={(e) => {
slugEditedRef.current = true // user manually edited slug
field.onChange(e)
}}
onBlur={(e) => {
// normalize on blur
const normalized = slugify(e.target.value)
form.setValue("slug", normalized, { shouldValidate: true })
field.onBlur()
}}
/>
</FormControl>
<FormDescription>Lowercase, numbers and hyphens only.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset()
setCreateOpen(false)
slugEditedRef.current = false
}}
>
Cancel
</Button>
<Button
type="submit"
disabled={!form.formState.isValid || form.formState.isSubmitting}
>
{form.formState.isSubmitting ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -1,11 +1,21 @@
import path from "path"
import tailwindcss from "@tailwindcss/vite"
import react from "@vitejs/plugin-react"
import { visualizer } from "rollup-plugin-visualizer"
import { defineConfig } from "vite"
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
plugins: [
react(),
tailwindcss(),
visualizer({
filename: "dist/stats.html",
template: "treemap",
gzipSize: true,
brotliSize: true,
}),
],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
@@ -20,7 +30,26 @@ export default defineConfig({
},
},
build: {
chunkSizeWarningLimit: 1000,
outDir: "../internal/ui/dist",
emptyOutDir: true,
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes("node_modules")) return
if (id.includes("react-router")) return "router"
if (id.includes("@radix-ui")) return "radix"
if (id.includes("lucide-react") || id.includes("react-icons")) return "icons"
if (id.includes("recharts") || id.includes("d3")) return "charts"
if (id.includes("date-fns") || id.includes("dayjs")) return "dates"
return "vendor"
},
},
},
},
optimizeDeps: {
include: ["react", "react-dom", "react-router-dom"],
},
})

View File

@@ -753,6 +753,11 @@
resolved "https://registry.yarnpkg.com/@open-draft/until/-/until-2.1.0.tgz#0acf32f470af2ceaf47f095cdecd40d68666efda"
integrity sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==
"@radix-ui/number@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/number/-/number-1.1.1.tgz#7b2c9225fbf1b126539551f5985769d0048d9090"
integrity sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==
"@radix-ui/primitive@1.1.3":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.3.tgz#e2dbc13bdc5e4168f4334f75832d7bdd3e2de5ba"
@@ -966,6 +971,33 @@
"@radix-ui/react-use-callback-ref" "1.1.1"
"@radix-ui/react-use-controllable-state" "1.2.2"
"@radix-ui/react-select@^2.2.6":
version "2.2.6"
resolved "https://registry.yarnpkg.com/@radix-ui/react-select/-/react-select-2.2.6.tgz#022cf8dab16bf05d0d1b4df9e53e4bea1b744fd9"
integrity sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==
dependencies:
"@radix-ui/number" "1.1.1"
"@radix-ui/primitive" "1.1.3"
"@radix-ui/react-collection" "1.1.7"
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-context" "1.1.2"
"@radix-ui/react-direction" "1.1.1"
"@radix-ui/react-dismissable-layer" "1.1.11"
"@radix-ui/react-focus-guards" "1.1.3"
"@radix-ui/react-focus-scope" "1.1.7"
"@radix-ui/react-id" "1.1.1"
"@radix-ui/react-popper" "1.2.8"
"@radix-ui/react-portal" "1.1.9"
"@radix-ui/react-primitive" "2.1.3"
"@radix-ui/react-slot" "1.2.3"
"@radix-ui/react-use-callback-ref" "1.1.1"
"@radix-ui/react-use-controllable-state" "1.2.2"
"@radix-ui/react-use-layout-effect" "1.1.1"
"@radix-ui/react-use-previous" "1.1.1"
"@radix-ui/react-visually-hidden" "1.2.3"
aria-hidden "^1.2.4"
react-remove-scroll "^2.6.3"
"@radix-ui/react-separator@^1.1.7":
version "1.1.7"
resolved "https://registry.yarnpkg.com/@radix-ui/react-separator/-/react-separator-1.1.7.tgz#a18bd7fd07c10fda1bba14f2a3032e7b1a2b3470"
@@ -1030,6 +1062,11 @@
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz#0c4230a9eed49d4589c967e2d9c0d9d60a23971e"
integrity sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==
"@radix-ui/react-use-previous@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz#1a1ad5568973d24051ed0af687766f6c7cb9b5b5"
integrity sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==
"@radix-ui/react-use-rect@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz#01443ca8ed071d33023c1113e5173b5ed8769152"
@@ -1832,6 +1869,11 @@ deepmerge@^4.3.1:
resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a"
integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==
define-lazy-prop@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f"
integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==
depd@2.0.0, depd@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
@@ -2528,6 +2570,11 @@ is-arrayish@^0.2.1:
resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==
is-docker@^2.0.0, is-docker@^2.1.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa"
integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==
is-extglob@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
@@ -2600,6 +2647,13 @@ is-unicode-supported@^2.0.0:
resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz#09f0ab0de6d3744d48d265ebb98f65d11f2a9b3a"
integrity sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==
is-wsl@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271"
integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==
dependencies:
is-docker "^2.0.0"
isexe@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
@@ -3032,6 +3086,15 @@ onetime@^7.0.0:
dependencies:
mimic-function "^5.0.0"
open@^8.0.0:
version "8.4.2"
resolved "https://registry.yarnpkg.com/open/-/open-8.4.2.tgz#5b5ffe2a8f793dcd2aad73e550cb87b59cb084f9"
integrity sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==
dependencies:
define-lazy-prop "^2.0.0"
is-docker "^2.1.1"
is-wsl "^2.2.0"
optionator@^0.9.3:
version "0.9.4"
resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734"
@@ -3342,6 +3405,16 @@ reusify@^1.0.4:
resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f"
integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==
rollup-plugin-visualizer@^6.0.3:
version "6.0.3"
resolved "https://registry.yarnpkg.com/rollup-plugin-visualizer/-/rollup-plugin-visualizer-6.0.3.tgz#d05bd17e358a6d04bf593cf73556219c9c6d8dad"
integrity sha512-ZU41GwrkDcCpVoffviuM9Clwjy5fcUxlz0oMoTXTYsK+tcIFzbdacnrr2n8TXcHxbGKKXtOdjxM2HUS4HjkwIw==
dependencies:
open "^8.0.0"
picomatch "^4.0.2"
source-map "^0.7.4"
yargs "^17.5.1"
rollup@^4.43.0:
version "4.50.0"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.50.0.tgz#6f237f598b7163ede33ce827af8534c929aaa186"
@@ -3563,6 +3636,11 @@ source-map-js@^1.2.1:
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
source-map@^0.7.4:
version "0.7.6"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.6.tgz#a3658ab87e5b6429c8a1f3ba0083d4c61ca3ef02"
integrity sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==
source-map@~0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
@@ -3941,7 +4019,7 @@ yargs-parser@^21.1.1:
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35"
integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==
yargs@^17.7.2:
yargs@^17.5.1, yargs@^17.7.2:
version "17.7.2"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269"
integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==