mirror of
https://github.com/GlueOps/autoglue.git
synced 2026-02-13 12:50:05 +01:00
Orgs, Members, SSH and Admin page
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
97
ui/src/components/org-switcher.tsx
Normal file
97
ui/src/components/org-switcher.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
38
ui/src/components/require-admin.tsx
Normal file
38
ui/src/components/require-admin.tsx
Normal 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 />
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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({
|
||||
|
||||
37
ui/src/components/ui/badge.tsx
Normal file
37
ui/src/components/ui/badge.tsx
Normal 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 }
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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"
|
||||
|
||||
168
ui/src/components/ui/select.tsx
Normal file
168
ui/src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
90
ui/src/components/ui/table.tsx
Normal file
90
ui/src/components/ui/table.tsx
Normal 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 }
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
17
ui/src/lib/orgs-sync.ts
Normal 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))
|
||||
}
|
||||
406
ui/src/pages/admin/users.tsx
Normal file
406
ui/src/pages/admin/users.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
8
ui/src/pages/error/forbidden.tsx
Normal file
8
ui/src/pages/error/forbidden.tsx
Normal 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 don’t have access to this area.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
428
ui/src/pages/security/ssh.tsx
Normal file
428
ui/src/pages/security/ssh.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
361
ui/src/pages/settings/members.tsx
Normal file
361
ui/src/pages/settings/members.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 organization’s 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 organization’s 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
})
|
||||
|
||||
80
ui/yarn.lock
80
ui/yarn.lock
@@ -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==
|
||||
|
||||
Reference in New Issue
Block a user