Add urapt-web: web UI for urapt-server instances
A Vite + React 18 + TypeScript SPA, same-origin with the API (no CORS, no Go server changes). Full management parity with the urapt CLI: - First-run setup (admin bootstrap via needs_setup), login/logout - Repositories: list, create, tabbed detail (overview edit/delete, members CRUD, distributions/components/architectures manager, package list + .deb upload + detail + delete, apt-config panel) - API tokens: show-once creation + revoke - Admin users: toggle admin / delete - Settings + dark mode Stack: Tailwind + shadcn/ui (New York), React Router v7 (lazy routes), TanStack Query v5, Zustand, Vitest. Hand-written typed API client mirroring shared/api + shared/models. Dev proxy forwards /api + /apt to a local urapt-server; deploy behind nginx/Caddy (see urapt-web/README).
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# API base URL. Defaults to same-origin /api/v1 for same-origin deployments.
|
||||
# Override only for cross-origin dev against a remote server (requires CORS).
|
||||
VITE_API_BASE_URL=/api/v1
|
||||
@@ -0,0 +1,35 @@
|
||||
# dependencies
|
||||
node_modules
|
||||
|
||||
# build
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
*.tsbuildinfo
|
||||
|
||||
# logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# editor / os
|
||||
.DS_Store
|
||||
.idea
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# test
|
||||
coverage
|
||||
|
||||
# env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -0,0 +1 @@
|
||||
20
|
||||
@@ -0,0 +1,4 @@
|
||||
dist
|
||||
node_modules
|
||||
coverage
|
||||
pnpm-lock.yaml
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 90,
|
||||
"tabWidth": 2,
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
# urapt-web
|
||||
|
||||
The web UI for [urapt](https://github.com/owenqwenstarsky/urapt), a self-hostable
|
||||
APT repository server. A React single-page app that talks to a urapt-server's
|
||||
REST API (`/api/v1`) to manage repositories, members, distributions, packages,
|
||||
tokens, and users — full management parity with the `urapt` CLI.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Vite** + **React 18** + **TypeScript**
|
||||
- **Tailwind CSS** + **shadcn/ui** (New York, neutral)
|
||||
- **React Router v7**, **TanStack Query v5**, **Zustand**, **Vitest**
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 20+ (see `.nvmrc`) and pnpm 10+
|
||||
|
||||
## Development
|
||||
|
||||
Start a local urapt-server (from the repo root):
|
||||
|
||||
```bash
|
||||
make run-server # listens on 0.0.0.0:8080
|
||||
```
|
||||
|
||||
Then in `urapt-web/`:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev # http://localhost:5173
|
||||
```
|
||||
|
||||
Vite proxies `/api` and `/apt` to `http://localhost:8080` (override with the
|
||||
`URAPT_SERVER` env var), so the UI is same-origin with the API in dev — no CORS
|
||||
needed. If `needs_setup` is true (no users yet), you'll be sent to the first-run
|
||||
setup screen to create the admin account.
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Description |
|
||||
| ---------------- | ------------------------------------ |
|
||||
| `pnpm dev` | Start the Vite dev server |
|
||||
| `pnpm build` | Type-check and build to `dist/` |
|
||||
| `pnpm preview` | Preview the production build locally |
|
||||
| `pnpm typecheck` | Run `tsc` with no emit |
|
||||
| `pnpm lint` | Run ESLint |
|
||||
| `pnpm format` | Run Prettier (write) |
|
||||
| `pnpm test` | Run Vitest once |
|
||||
|
||||
## Configuration
|
||||
|
||||
| Env var | Default | Notes |
|
||||
| ------------------- | ----------------------- | -------------------------------------------------------------------------------- |
|
||||
| `VITE_API_BASE_URL` | `/api/v1` | REST API base. Override only for cross-origin dev (requires CORS on the server). |
|
||||
| `URAPT_SERVER` | `http://localhost:8080` | Dev proxy target only. |
|
||||
|
||||
## Deployment (same-origin)
|
||||
|
||||
The UI is designed to be served from the **same origin** as a urapt-server,
|
||||
behind a reverse proxy that serves the SPA from `dist/` and forwards `/api/v1`
|
||||
and `/apt` to `urapt-server`. No CORS, no server changes — the API base is just
|
||||
`/api/v1`.
|
||||
|
||||
```bash
|
||||
pnpm build # outputs dist/
|
||||
```
|
||||
|
||||
### Caddy
|
||||
|
||||
```caddy
|
||||
apt.example.com {
|
||||
# SPA
|
||||
root * /srv/urapt-web/dist
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
|
||||
# REST API + APT endpoint
|
||||
reverse_proxy /api/* 127.0.0.1:8080
|
||||
reverse_proxy /apt/* 127.0.0.1:8080
|
||||
}
|
||||
```
|
||||
|
||||
### nginx
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name apt.example.com;
|
||||
root /srv/urapt-web/dist;
|
||||
|
||||
location / { try_files $uri /index.html; }
|
||||
|
||||
location /api/ { proxy_pass http://127.0.0.1:8080; }
|
||||
location /apt/ { proxy_pass http://127.0.0.1:8080; }
|
||||
}
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
lib/api/ typed client mirroring urapt's shared/api + shared/models
|
||||
(client, errors, endpoints, types)
|
||||
stores/ Zustand auth store (persisted token)
|
||||
hooks/ shared server/user queries, logout
|
||||
components/ app shell, common UI (copy button, confirm, states), shadcn ui
|
||||
features/ auth, setup, repos (tabs), tokens, users, settings
|
||||
routes.tsx route tree with lazy-loaded pages + protected/admin guards
|
||||
```
|
||||
|
||||
The API client (`src/lib/api/`) is hand-written and typed against urapt's Go
|
||||
DTOs in `shared/api/api.go` and `shared/models/models.go`. A `401` anywhere
|
||||
clears the local session and redirects to `/login`.
|
||||
|
||||
## License
|
||||
|
||||
GPL-3.0-or-later, matching the urapt project.
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist", "node_modules", "coverage"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>urapt</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"name": "urapt-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Web UI for urapt-server (self-hosted APT repository server).",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b --noEmit",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-avatar": "^1.1.1",
|
||||
"@radix-ui/react-checkbox": "^1.1.2",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-popover": "^1.1.2",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-separator": "^1.1.10",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"@radix-ui/react-toast": "^1.2.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.3",
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.451.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.0.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^2.5.3",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.11.1",
|
||||
"@testing-library/jest-dom": "^6.5.0",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/node": "^22.7.4",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.8.0",
|
||||
"@typescript-eslint/parser": "^8.8.0",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.11.1",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.12",
|
||||
"globals": "^15.10.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.8",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"typescript": "^5.6.2",
|
||||
"typescript-eslint": "^8.8.0",
|
||||
"vite": "^5.4.8",
|
||||
"vitest": "^2.1.1"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
Generated
+4795
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="7" fill="#171717" />
|
||||
<path d="M9 11h14M16 11v12" stroke="#fafafa" stroke-width="2.5" stroke-linecap="round" />
|
||||
<circle cx="16" cy="23" r="1.6" fill="#fafafa" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 280 B |
@@ -0,0 +1,11 @@
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
import { Providers } from "./providers";
|
||||
import { router } from "./routes";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Providers>
|
||||
<RouterProvider router={router} />
|
||||
</Providers>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { NavLink as RRNavLink, Outlet } from "react-router-dom";
|
||||
import {
|
||||
LogOut,
|
||||
Package,
|
||||
Settings,
|
||||
Terminal,
|
||||
User as UserIcon,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { ThemeToggle } from "@/components/common/theme-toggle";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useMe, useServerInfo } from "@/hooks/use-server";
|
||||
import { useLogout } from "@/hooks/use-logout";
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: typeof Package;
|
||||
adminOnly?: boolean;
|
||||
}
|
||||
|
||||
const NAV: NavItem[] = [
|
||||
{ to: "/", label: "Repositories", icon: Package },
|
||||
{ to: "/tokens", label: "Tokens", icon: Terminal },
|
||||
{ to: "/users", label: "Users", icon: Users, adminOnly: true },
|
||||
{ to: "/settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
export function AppShell() {
|
||||
const signOut = useLogout();
|
||||
const token = useAuthStore((s) => s.token);
|
||||
const me = useMe(!!token);
|
||||
const serverInfo = useServerInfo();
|
||||
|
||||
const user = me.data;
|
||||
const isAdmin = user?.is_admin ?? false;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh">
|
||||
<aside className="hidden w-60 shrink-0 flex-col border-r bg-card md:flex">
|
||||
<div className="flex h-14 items-center gap-2 border-b px-5">
|
||||
<div className="flex size-7 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
||||
<Terminal className="size-4" />
|
||||
</div>
|
||||
<span className="font-semibold tracking-tight">urapt</span>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-1 p-3">
|
||||
{NAV.filter((item) => !item.adminOnly || isAdmin).map((item) => (
|
||||
<NavLink key={item.to} item={item} />
|
||||
))}
|
||||
</nav>
|
||||
<div className="border-t p-3 text-xs text-muted-foreground">
|
||||
{serverInfo.data ? <span>urapt v{serverInfo.data.version}</span> : null}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex h-14 items-center justify-between gap-3 border-b px-4">
|
||||
<div className="flex items-center gap-2 md:hidden">
|
||||
<div className="flex size-7 items-center justify-center rounded-md bg-primary text-primary-foreground">
|
||||
<Terminal className="size-4" />
|
||||
</div>
|
||||
<span className="font-semibold tracking-tight">urapt</span>
|
||||
</div>
|
||||
<div className="hidden md:block" />
|
||||
<div className="flex items-center gap-1">
|
||||
<ThemeToggle />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="gap-2">
|
||||
<Avatar className="size-6">
|
||||
<AvatarFallback className="text-xs">
|
||||
{user?.username?.slice(0, 2).toUpperCase() ?? "?"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="max-w-[10rem] truncate">{user?.username ?? "…"}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuLabel className="flex items-center gap-2">
|
||||
<UserIcon className="size-4" />
|
||||
<span className="truncate">{user?.username}</span>
|
||||
{isAdmin ? (
|
||||
<span className="ml-auto rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium">
|
||||
admin
|
||||
</span>
|
||||
) : null}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={signOut}>
|
||||
<LogOut className="size-4" />
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-y-auto bg-muted/30 p-4 md:p-6">
|
||||
{/* Mobile nav */}
|
||||
<nav className="mb-4 flex gap-1 overflow-x-auto md:hidden">
|
||||
{NAV.filter((item) => !item.adminOnly || isAdmin).map((item) => (
|
||||
<NavLink key={item.to} item={item} compact />
|
||||
))}
|
||||
</nav>
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavLink({ item, compact }: { item: NavItem; compact?: boolean }) {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<RRNavLink
|
||||
to={item.to}
|
||||
end={item.to === "/"}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
||||
"text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
isActive && "bg-accent text-foreground",
|
||||
compact && "shrink-0",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
{item.label}
|
||||
</RRNavLink>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useMe } from "@/hooks/use-server";
|
||||
|
||||
/** Wraps admin-only routes; redirects non-admins home. */
|
||||
export function AdminRoute({ children }: { children: ReactNode }) {
|
||||
const token = useAuthStore((s) => s.token);
|
||||
const me = useMe(!!token);
|
||||
if (me.data && !me.data.is_admin) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Access, Visibility } from "@/lib/api/types";
|
||||
|
||||
export function VisibilityBadge({ visibility }: { visibility: Visibility }) {
|
||||
return (
|
||||
<Badge
|
||||
variant={visibility === "public" ? "secondary" : "outline"}
|
||||
className="capitalize"
|
||||
>
|
||||
{visibility}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
const ACCESS_STYLES: Record<Access, string> = {
|
||||
read: "bg-muted text-muted-foreground",
|
||||
write: "bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-300",
|
||||
"read-write": "bg-violet-100 text-violet-800 dark:bg-violet-950 dark:text-violet-300",
|
||||
admin: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
|
||||
};
|
||||
|
||||
export function AccessBadge({ access }: { access: Access }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium capitalize",
|
||||
ACCESS_STYLES[access],
|
||||
)}
|
||||
>
|
||||
{access}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description: ReactNode;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
destructive?: boolean;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = "Confirm",
|
||||
cancelLabel = "Cancel",
|
||||
destructive = false,
|
||||
onConfirm,
|
||||
}: ConfirmDialogProps) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function handleConfirm() {
|
||||
try {
|
||||
setBusy(true);
|
||||
await onConfirm();
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
variant={destructive ? "destructive" : "default"}
|
||||
onClick={handleConfirm}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? "…" : confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState } from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn, copyToClipboard } from "@/lib/utils";
|
||||
|
||||
interface CopyButtonProps {
|
||||
value: string;
|
||||
label?: string;
|
||||
className?: string;
|
||||
size?: "default" | "sm" | "icon";
|
||||
variant?: "default" | "outline" | "ghost" | "secondary";
|
||||
}
|
||||
|
||||
export function CopyButton({
|
||||
value,
|
||||
label = "Copy",
|
||||
className,
|
||||
size = "sm",
|
||||
variant = "outline",
|
||||
}: CopyButtonProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function onCopy() {
|
||||
try {
|
||||
await copyToClipboard(value);
|
||||
setCopied(true);
|
||||
toast.success("Copied to clipboard");
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
toast.error("Couldn't copy to clipboard");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(className)}
|
||||
onClick={onCopy}
|
||||
>
|
||||
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
||||
{size === "icon" ? null : <span>{copied ? "Copied" : label}</span>}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-10 text-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon ? <div className="text-muted-foreground">{icon}</div> : null}
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{title}</p>
|
||||
{description ? (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{action ? <div className="mt-1">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ApiError } from "@/lib/api";
|
||||
|
||||
interface ErrorStateProps {
|
||||
error: unknown;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export function ErrorState({ error, onRetry }: ErrorStateProps) {
|
||||
const message = ApiError.isApiError(error) ? error.display() : "Something went wrong.";
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="size-4" />
|
||||
<AlertTitle>Failed to load</AlertTitle>
|
||||
<AlertDescription className="flex items-center justify-between gap-4">
|
||||
<span>{message}</span>
|
||||
{onRetry ? (
|
||||
<Button size="sm" variant="outline" onClick={onRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
) : null}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function NotFound() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-24 text-center">
|
||||
<p className="text-4xl font-semibold tracking-tight">404</p>
|
||||
<p className="text-muted-foreground">This page doesn't exist.</p>
|
||||
<Button asChild>
|
||||
<Link to="/">Back to repositories</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 pb-2">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
{description ? (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { FullPageSpinner } from "@/components/common/spinner";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useMe, useServerInfo } from "@/hooks/use-server";
|
||||
|
||||
/**
|
||||
* Root guard: boots server info + current session before rendering the app
|
||||
* shell. Redirects to /setup on first run and /login when unauthenticated.
|
||||
*/
|
||||
export function RootGuard() {
|
||||
const token = useAuthStore((s) => s.token);
|
||||
const serverInfo = useServerInfo();
|
||||
const me = useMe(!!token);
|
||||
|
||||
if (serverInfo.isLoading || serverInfo.isPending) {
|
||||
return <FullPageSpinner label="Connecting to urapt…" />;
|
||||
}
|
||||
|
||||
// First-run setup takes priority: no admin exists yet.
|
||||
if (serverInfo.data?.needs_setup) {
|
||||
return <Navigate to="/setup" replace />;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (me.isLoading || me.isPending) {
|
||||
return <FullPageSpinner label="Loading session…" />;
|
||||
}
|
||||
|
||||
// Token present but invalid (401 already cleared the store; handle races).
|
||||
if (me.isError || !me.data) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <AppShell />;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Spinner({ className }: { className?: string }) {
|
||||
return <Loader2 className={cn("size-4 animate-spin", className)} />;
|
||||
}
|
||||
|
||||
export function FullPageSpinner({ label }: { label?: string }) {
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col items-center justify-center gap-3">
|
||||
<Spinner className="size-6" />
|
||||
{label ? <p className="text-sm text-muted-foreground">{label}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { setTheme, resolvedTheme } = useTheme();
|
||||
const isDark = resolvedTheme === "dark";
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Toggle theme"
|
||||
title={isDark ? "Switch to light" : "Switch to dark"}
|
||||
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||
>
|
||||
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Alert.displayName = "Alert";
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertTitle.displayName = "AlertTitle";
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
|
||||
));
|
||||
AlertDescription.displayName = "AlertDescription";
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,57 @@
|
||||
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 buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,61 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer grid h-4 w-4 shrink-0 place-content-center rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className={cn("grid place-content-center text-current")}>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox };
|
||||
@@ -0,0 +1,108 @@
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { Check, ChevronRight, Circle } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] origin-[--radix-dropdown-menu-content-transform-origin] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"origin-[--radix-dropdown-menu-content-transform-origin] 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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,150 @@
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground [&>span]:line-clamp-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] origin-[--radix-select-content-transform-origin] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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",
|
||||
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)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
@@ -0,0 +1,9 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={cn("animate-pulse rounded-md bg-primary/10", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useTheme } from "next-themes";
|
||||
import { Toaster as Sonner } from "sonner";
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster };
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
@@ -0,0 +1,112 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
Table.displayName = "Table";
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
));
|
||||
TableHeader.displayName = "TableHeader";
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
|
||||
));
|
||||
TableBody.displayName = "TableBody";
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableFooter.displayName = "TableFooter";
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHead.displayName = "TableHead";
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCell.displayName = "TableCell";
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCaption.displayName = "TableCaption";
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 origin-[--radix-tooltip-content-transform-origin] overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { AuthLayout } from "./auth-layout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Spinner } from "@/components/common/spinner";
|
||||
import { ApiError, login } from "@/lib/api";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
export function Login() {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const setSession = useAuthStore((s) => s.setSession);
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await login({ username: username.trim(), password });
|
||||
setSession(res.token, res.user);
|
||||
await qc.invalidateQueries({ queryKey: ["me"] });
|
||||
qc.setQueryData(["me"], res.user);
|
||||
navigate("/");
|
||||
} catch (err) {
|
||||
toast.error(ApiError.isApiError(err) ? err.display() : "Login failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout title="urapt" description="Sign in to manage your repositories">
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
autoComplete="username"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={busy}>
|
||||
{busy ? <Spinner className="size-4" /> : null}
|
||||
Sign in
|
||||
</Button>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Terminal } from "lucide-react";
|
||||
|
||||
interface AuthLayoutProps {
|
||||
title: string;
|
||||
description: string;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthLayout({ title, description, children, footer }: AuthLayoutProps) {
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center bg-muted/40 p-4">
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
<Terminal className="size-5" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
{footer ? (
|
||||
<div className="text-center text-xs text-muted-foreground">{footer}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Spinner } from "@/components/common/spinner";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import type { Visibility } from "@/lib/api/types";
|
||||
import { useCreateRepository } from "./repos-hooks";
|
||||
|
||||
interface CreateRepoDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const NAME_RE = /^[a-z0-9_-]{3,64}$/;
|
||||
|
||||
export function CreateRepoDialog({ open, onOpenChange }: CreateRepoDialogProps) {
|
||||
const navigate = useNavigate();
|
||||
const create = useCreateRepository();
|
||||
const [name, setName] = useState("");
|
||||
const [visibility, setVisibility] = useState<Visibility>("private");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName("");
|
||||
setVisibility("private");
|
||||
setDescription("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const nameInvalid = name.length > 0 && !NAME_RE.test(name);
|
||||
const canSubmit = NAME_RE.test(name) && !create.isPending;
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
try {
|
||||
const repo = await create.mutateAsync({
|
||||
name,
|
||||
visibility,
|
||||
description: description.trim() || undefined,
|
||||
});
|
||||
toast.success(`Repository "${repo.name}" created`);
|
||||
onOpenChange(false);
|
||||
navigate(`/repos/${repo.name}`);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
ApiError.isApiError(err) ? err.display() : "Failed to create repository",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New repository</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new APT repository to push packages into.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="repo-name">Name</Label>
|
||||
<Input
|
||||
id="repo-name"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
placeholder="myrepo"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
{nameInvalid ? (
|
||||
<p className="text-xs text-destructive">
|
||||
Lowercase letters, digits, <code>-</code> and <code>_</code>. 3–64 chars.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Used in the apt URL: <code>/apt/<name>/</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="repo-visibility">Visibility</Label>
|
||||
<Select
|
||||
value={visibility}
|
||||
onValueChange={(v) => setVisibility(v as Visibility)}
|
||||
>
|
||||
<SelectTrigger id="repo-visibility">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="private">Private — members only</SelectItem>
|
||||
<SelectItem value="public">Public — anonymous reads</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="repo-desc">Description (optional)</Label>
|
||||
<Input
|
||||
id="repo-desc"
|
||||
placeholder="What is this repo for?"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
maxLength={280}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!canSubmit}>
|
||||
{create.isPending ? <Spinner className="size-4" /> : null}
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { PageHeader } from "@/components/common/page-header";
|
||||
import { ErrorState } from "@/components/common/error-state";
|
||||
import { Spinner } from "@/components/common/spinner";
|
||||
import { VisibilityBadge } from "@/components/common/badges";
|
||||
import { useRepository, useDeleteRepository } from "./repos-hooks";
|
||||
import { useMembers } from "./repo-detail-hooks";
|
||||
import { RepoProvider } from "./repo-context.tsx";
|
||||
import { computeAccess, canWriteWith, canManageWith } from "./repo-context";
|
||||
import { RepoOverview } from "./tabs/RepoOverview";
|
||||
import { Members } from "./tabs/Members";
|
||||
import { Structure } from "./tabs/Structure";
|
||||
import { Packages } from "./tabs/Packages";
|
||||
import { AptConfig } from "./tabs/AptConfig";
|
||||
import { useMe } from "@/hooks/use-server";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
export function RepoDetail() {
|
||||
const { repo, tab } = useParams<{ repo: string; tab?: string }>();
|
||||
const token = useAuthStore((s) => s.token);
|
||||
const me = useMe(!!token);
|
||||
const { data, isLoading, isError, error } = useRepository(repo ?? "");
|
||||
const members = useMembers(repo ?? "");
|
||||
const del = useDeleteRepository();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-16">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isError) {
|
||||
return <ErrorState error={error} />;
|
||||
}
|
||||
if (!data) return null;
|
||||
|
||||
const myMember = members.data?.find((m) => m.user_id === me.data?.id);
|
||||
const access = computeAccess(data, me.data ?? null, myMember?.access ?? null);
|
||||
const canWrite = canWriteWith(access);
|
||||
const canManage = canManageWith(access);
|
||||
|
||||
return (
|
||||
<RepoProvider
|
||||
value={{
|
||||
repo: data,
|
||||
me: me.data ?? null,
|
||||
access,
|
||||
canWrite,
|
||||
canManage,
|
||||
}}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<Button asChild variant="ghost" size="sm" className="-ml-2">
|
||||
<Link to="/">
|
||||
<ArrowLeft className="size-4" /> Repositories
|
||||
</Link>
|
||||
</Button>
|
||||
<PageHeader
|
||||
title={data.name}
|
||||
description={data.description || "No description"}
|
||||
actions={<VisibilityBadge visibility={data.visibility} />}
|
||||
/>
|
||||
|
||||
<Tabs value={tab ?? "overview"}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview" asChild>
|
||||
<Link to={`/repos/${data.name}`}>Overview</Link>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="members" asChild>
|
||||
<Link to={`/repos/${data.name}/members`}>Members</Link>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="structure" asChild>
|
||||
<Link to={`/repos/${data.name}/structure`}>Distributions</Link>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="packages" asChild>
|
||||
<Link to={`/repos/${data.name}/packages`}>Packages</Link>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="apt-config" asChild>
|
||||
<Link to={`/repos/${data.name}/apt-config`}>Apt config</Link>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="mt-2">
|
||||
{tab === "members" ? (
|
||||
<Members />
|
||||
) : tab === "structure" ? (
|
||||
<Structure />
|
||||
) : tab === "packages" ? (
|
||||
<Packages />
|
||||
) : tab === "apt-config" ? (
|
||||
<AptConfig />
|
||||
) : (
|
||||
<RepoOverview onDelete={del} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</RepoProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Package, Plus } from "lucide-react";
|
||||
import { PageHeader } from "@/components/common/page-header";
|
||||
import { EmptyState } from "@/components/common/empty-state";
|
||||
import { ErrorState } from "@/components/common/error-state";
|
||||
import { Spinner } from "@/components/common/spinner";
|
||||
import { VisibilityBadge } from "@/components/common/badges";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { useRepositories } from "./repos-hooks";
|
||||
import { CreateRepoDialog } from "./CreateRepoDialog";
|
||||
|
||||
export function ReposList() {
|
||||
const { data, isLoading, isError, error, refetch } = useRepositories();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const repos = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Repositories"
|
||||
description="Your APT repositories and those shared with you"
|
||||
actions={
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
New repository
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<ErrorState error={error} onRetry={() => refetch()} />
|
||||
) : repos.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Package className="size-8" />}
|
||||
title="No repositories yet"
|
||||
description="Create your first repository to start pushing .deb packages."
|
||||
action={
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
New repository
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Visibility</TableHead>
|
||||
<TableHead>Owner</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{repos.map((repo) => (
|
||||
<TableRow
|
||||
key={repo.id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => navigate(`/repos/${repo.name}`)}
|
||||
>
|
||||
<TableCell className="font-mono font-medium">{repo.name}</TableCell>
|
||||
<TableCell>
|
||||
<VisibilityBadge visibility={repo.visibility} />
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{repo.owner?.username ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[20rem] truncate text-muted-foreground">
|
||||
{repo.description || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{formatDate(repo.created_at)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CreateRepoDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { Repository, User } from "@/lib/api/types";
|
||||
|
||||
export type EffectiveAccess = "read" | "write" | "read-write" | "admin" | "owner" | null;
|
||||
|
||||
export interface RepoContextValue {
|
||||
repo: Repository;
|
||||
me: User | null;
|
||||
access: EffectiveAccess;
|
||||
canWrite: boolean;
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
export const RepoCtx = createContext<RepoContextValue | null>(null);
|
||||
|
||||
export function useRepoContext(): RepoContextValue {
|
||||
const v = useContext(RepoCtx);
|
||||
if (!v) throw new Error("useRepoContext must be used within a RepoProvider");
|
||||
return v;
|
||||
}
|
||||
|
||||
/** Compute the current user's effective access on a repo. */
|
||||
export function computeAccess(
|
||||
repo: Repository,
|
||||
me: User | null,
|
||||
myMemberAccess: string | null,
|
||||
): EffectiveAccess {
|
||||
if (!me) return null;
|
||||
if (me.is_admin || repo.owner_user_id === me.id) return "owner";
|
||||
return (myMemberAccess as EffectiveAccess) ?? null;
|
||||
}
|
||||
|
||||
export function canWriteWith(access: EffectiveAccess): boolean {
|
||||
return (
|
||||
access === "owner" ||
|
||||
access === "admin" ||
|
||||
access === "write" ||
|
||||
access === "read-write"
|
||||
);
|
||||
}
|
||||
|
||||
export function canManageWith(access: EffectiveAccess): boolean {
|
||||
return access === "owner" || access === "admin";
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { RepoCtx, type RepoContextValue } from "./repo-context";
|
||||
|
||||
export function RepoProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: RepoContextValue;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <RepoCtx.Provider value={value}>{children}</RepoCtx.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
addMember,
|
||||
createArchitecture,
|
||||
createComponent,
|
||||
createDistribution,
|
||||
deleteArchitecture,
|
||||
deleteComponent,
|
||||
deleteDistribution,
|
||||
deletePackage,
|
||||
getPackage,
|
||||
listArchitectures,
|
||||
listComponents,
|
||||
listDistributions,
|
||||
listMembers,
|
||||
listPackages,
|
||||
pushPackage,
|
||||
removeMember,
|
||||
updateMember,
|
||||
updateRepository,
|
||||
} from "@/lib/api";
|
||||
import type {
|
||||
AddMemberRequest,
|
||||
CreateNamedRequest,
|
||||
UpdateMemberRequest,
|
||||
} from "@/lib/api/types";
|
||||
import { REPO_KEY } from "./repos-hooks";
|
||||
|
||||
const DIST_KEY = (repo: string) => ["repo", repo, "distributions"] as const;
|
||||
const COMP_KEY = (repo: string, dist: string) =>
|
||||
["repo", repo, "distributions", dist, "components"] as const;
|
||||
const ARCH_KEY = (repo: string, dist: string) =>
|
||||
["repo", repo, "distributions", dist, "architectures"] as const;
|
||||
const PKGS_KEY = (repo: string, dist: string) =>
|
||||
["repo", repo, "distributions", dist, "packages"] as const;
|
||||
const MEMBERS_KEY = (repo: string) => ["repo", repo, "members"] as const;
|
||||
|
||||
// --- members ---
|
||||
|
||||
export function useMembers(repo: string) {
|
||||
return useQuery({ queryKey: MEMBERS_KEY(repo), queryFn: () => listMembers(repo) });
|
||||
}
|
||||
|
||||
export function useAddMember(repo: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: AddMemberRequest) => addMember(repo, body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: MEMBERS_KEY(repo) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateMember(repo: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ username, body }: { username: string; body: UpdateMemberRequest }) =>
|
||||
updateMember(repo, username, body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: MEMBERS_KEY(repo) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveMember(repo: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (username: string) => removeMember(repo, username),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: MEMBERS_KEY(repo) }),
|
||||
});
|
||||
}
|
||||
|
||||
// --- distributions ---
|
||||
|
||||
export function useDistributions(repo: string) {
|
||||
return useQuery({ queryKey: DIST_KEY(repo), queryFn: () => listDistributions(repo) });
|
||||
}
|
||||
|
||||
export function useCreateDistribution(repo: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: CreateNamedRequest) => createDistribution(repo, body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: DIST_KEY(repo) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteDistribution(repo: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (dist: string) => deleteDistribution(repo, dist),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: DIST_KEY(repo) }),
|
||||
});
|
||||
}
|
||||
|
||||
// --- components ---
|
||||
|
||||
export function useComponents(repo: string, dist: string) {
|
||||
return useQuery({
|
||||
queryKey: COMP_KEY(repo, dist),
|
||||
queryFn: () => listComponents(repo, dist),
|
||||
enabled: !!dist,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateComponent(repo: string, dist: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: CreateNamedRequest) => createComponent(repo, dist, body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: COMP_KEY(repo, dist) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteComponent(repo: string, dist: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (comp: string) => deleteComponent(repo, dist, comp),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: COMP_KEY(repo, dist) }),
|
||||
});
|
||||
}
|
||||
|
||||
// --- architectures ---
|
||||
|
||||
export function useArchitectures(repo: string, dist: string) {
|
||||
return useQuery({
|
||||
queryKey: ARCH_KEY(repo, dist),
|
||||
queryFn: () => listArchitectures(repo, dist),
|
||||
enabled: !!dist,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateArchitecture(repo: string, dist: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: CreateNamedRequest) => createArchitecture(repo, dist, body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ARCH_KEY(repo, dist) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteArchitecture(repo: string, dist: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (arch: string) => deleteArchitecture(repo, dist, arch),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ARCH_KEY(repo, dist) }),
|
||||
});
|
||||
}
|
||||
|
||||
// --- packages ---
|
||||
|
||||
export function usePackages(repo: string, dist: string) {
|
||||
return useQuery({
|
||||
queryKey: PKGS_KEY(repo, dist),
|
||||
queryFn: () => listPackages(repo, dist, { per_page: 500 }),
|
||||
enabled: !!dist,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePushPackage(repo: string, dist: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ file, component }: { file: File; component: string }) =>
|
||||
pushPackage(repo, dist, file, component),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: PKGS_KEY(repo, dist) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePackage(repo: string, id: string) {
|
||||
return useQuery({
|
||||
queryKey: ["repo", repo, "package", id],
|
||||
queryFn: () => getPackage(repo, id),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeletePackage(repo: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id }: { id: string; dist: string }) => deletePackage(repo, id),
|
||||
onSuccess: (_data, { dist }) =>
|
||||
qc.invalidateQueries({ queryKey: PKGS_KEY(repo, dist) }),
|
||||
});
|
||||
}
|
||||
|
||||
// re-export repo mutations used by detail
|
||||
export { updateRepository };
|
||||
export { REPO_KEY };
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
createRepository,
|
||||
deleteRepository,
|
||||
getRepository,
|
||||
listRepositories,
|
||||
updateRepository,
|
||||
} from "@/lib/api";
|
||||
import type { CreateRepoRequest, UpdateRepoRequest } from "@/lib/api/types";
|
||||
|
||||
export const REPO_KEY = (name: string) => ["repo", name] as const;
|
||||
export const REPO_LIST_KEY = ["repos", "list"] as const;
|
||||
|
||||
export function useRepositories() {
|
||||
return useQuery({
|
||||
queryKey: REPO_LIST_KEY,
|
||||
queryFn: () => listRepositories({ per_page: 200 }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRepository(name: string) {
|
||||
return useQuery({
|
||||
queryKey: REPO_KEY(name),
|
||||
queryFn: () => getRepository(name),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateRepository() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: CreateRepoRequest) => createRepository(body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: REPO_LIST_KEY }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateRepository(name: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: UpdateRepoRequest) => updateRepository(name, body),
|
||||
onSuccess: (updated) => {
|
||||
qc.setQueryData(REPO_KEY(name), updated);
|
||||
if (updated.name && updated.name !== name) {
|
||||
qc.invalidateQueries({ queryKey: REPO_LIST_KEY });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteRepository() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (name: string) => deleteRepository(name),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: REPO_LIST_KEY }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { KeyRound, ShieldAlert } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CopyButton } from "@/components/common/copy-button";
|
||||
import { EmptyState } from "@/components/common/empty-state";
|
||||
import { Spinner } from "@/components/common/spinner";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useRepoContext } from "../repo-context";
|
||||
import { useArchitectures, useComponents, useDistributions } from "../repo-detail-hooks";
|
||||
|
||||
export function AptConfig() {
|
||||
const { repo } = useRepoContext();
|
||||
const dists = useDistributions(repo.name);
|
||||
const [dist, setDist] = useState("");
|
||||
const [useMyToken, setUseMyToken] = useState(false);
|
||||
const sessionToken = useAuthStore((s) => s.token);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dist && dists.data && dists.data.length > 0) setDist(dists.data[0].name);
|
||||
}, [dists.data, dist]);
|
||||
|
||||
const comps = useComponents(repo.name, dist);
|
||||
const arches = useArchitectures(repo.name, dist);
|
||||
|
||||
const isPrivate = repo.visibility === "private";
|
||||
const origin = window.location.origin;
|
||||
const signedBy = `/usr/share/keyrings/urapt-${repo.name}.gpg`;
|
||||
|
||||
const block = useMemo(() => {
|
||||
if (!dist) return "";
|
||||
const compList = (comps.data ?? []).map((c) => c.name).join(" ") || "main";
|
||||
const archStr = (arches.data ?? []).map((a) => a.name).join(",");
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push("# 1. Install the repository signing key:");
|
||||
lines.push(
|
||||
`curl -fsSL ${origin}/api/v1/server/pubkey | sudo gpg --dearmor -o ${signedBy}`,
|
||||
);
|
||||
lines.push("");
|
||||
lines.push("# 2. Add the repository to apt:");
|
||||
const archPart = archStr ? `arch=${archStr} ` : "";
|
||||
const debLine = `deb [${archPart}signed-by=${signedBy}] ${origin}/apt/${repo.name}/ ${dist} ${compList}`;
|
||||
lines.push(`echo '${debLine}' | sudo tee /etc/apt/sources.list.d/${repo.name}.list`);
|
||||
lines.push("");
|
||||
lines.push("# 3. Update apt:");
|
||||
lines.push("sudo apt update");
|
||||
|
||||
if (isPrivate) {
|
||||
const host = hostOf(origin);
|
||||
const password = useMyToken && sessionToken ? sessionToken : "<your-api-token>";
|
||||
lines.push("");
|
||||
lines.push("# 4. This repository is private. Configure apt credentials:");
|
||||
lines.push(`sudo tee /etc/apt/auth.conf.d/${repo.name}.conf <<EOF`);
|
||||
lines.push(`machine ${host}`);
|
||||
lines.push(`login ${"<your-username>"}`);
|
||||
lines.push(`password ${password}`);
|
||||
lines.push("EOF");
|
||||
}
|
||||
return lines.join("\n");
|
||||
}, [
|
||||
dist,
|
||||
comps.data,
|
||||
arches.data,
|
||||
origin,
|
||||
signedBy,
|
||||
repo.name,
|
||||
isPrivate,
|
||||
useMyToken,
|
||||
sessionToken,
|
||||
]);
|
||||
|
||||
if (dists.isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if ((dists.data ?? []).length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<KeyRound className="size-8" />}
|
||||
title="No distributions yet"
|
||||
description="Create a distribution on the Distributions tab to generate apt config."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Run these commands on a Debian/Ubuntu client to install packages from this
|
||||
repository.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-muted-foreground">Distribution</Label>
|
||||
<Select value={dist} onValueChange={setDist}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="Select…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{dists.data!.map((d) => (
|
||||
<SelectItem key={d.id} value={d.name}>
|
||||
{d.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isPrivate ? (
|
||||
<Alert>
|
||||
<ShieldAlert className="size-4" />
|
||||
<AlertTitle>Private repository</AlertTitle>
|
||||
<AlertDescription className="space-y-2">
|
||||
<p>
|
||||
Clients must authenticate with an API token. Create one on the{" "}
|
||||
<Link to="/tokens" className="underline">
|
||||
Tokens
|
||||
</Link>{" "}
|
||||
page and use it as the password.
|
||||
</p>
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useMyToken}
|
||||
onChange={(e) => setUseMyToken(e.target.checked)}
|
||||
/>
|
||||
Insert my current session token as the password
|
||||
</label>
|
||||
{useMyToken ? (
|
||||
<p className="text-xs text-destructive">
|
||||
Warning: this is your login session token. Anyone with it has full account
|
||||
access. Prefer a dedicated token.
|
||||
</p>
|
||||
) : null}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="relative rounded-lg border bg-card p-4">
|
||||
<div className="absolute right-3 top-3">
|
||||
<CopyButton value={block} />
|
||||
</div>
|
||||
<pre className="overflow-x-auto whitespace-pre pr-20 font-mono text-xs leading-relaxed">
|
||||
{block}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/tokens">
|
||||
<KeyRound className="size-4" />
|
||||
Manage tokens
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function hostOf(url: string): string {
|
||||
try {
|
||||
return new URL(url).host;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { UserPlus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { EmptyState } from "@/components/common/empty-state";
|
||||
import { ErrorState } from "@/components/common/error-state";
|
||||
import { Spinner } from "@/components/common/spinner";
|
||||
import { ConfirmDialog } from "@/components/common/confirm-dialog";
|
||||
import { AccessBadge } from "@/components/common/badges";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import type { Access } from "@/lib/api/types";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { useRepoContext } from "../repo-context";
|
||||
import {
|
||||
useAddMember,
|
||||
useMembers,
|
||||
useRemoveMember,
|
||||
useUpdateMember,
|
||||
} from "../repo-detail-hooks";
|
||||
|
||||
const ACCESS_OPTIONS: Access[] = ["read", "write", "read-write", "admin"];
|
||||
|
||||
export function Members() {
|
||||
const { repo, canManage } = useRepoContext();
|
||||
const { data, isLoading, isError, error } = useMembers(repo.name);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [removeTarget, setRemoveTarget] = useState<string | null>(null);
|
||||
const remove = useRemoveMember(repo.name);
|
||||
const update = useUpdateMember(repo.name);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isError) {
|
||||
return <ErrorState error={error} />;
|
||||
}
|
||||
|
||||
const members = data ?? [];
|
||||
|
||||
async function doRemove(username: string) {
|
||||
try {
|
||||
await remove.mutateAsync(username);
|
||||
toast.success(`Removed ${username}`);
|
||||
} catch (err) {
|
||||
toast.error(ApiError.isApiError(err) ? err.display() : "Failed to remove member");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Members can read or push packages based on their access level.
|
||||
</p>
|
||||
{canManage ? (
|
||||
<Button size="sm" onClick={() => setAddOpen(true)}>
|
||||
<UserPlus className="size-4" />
|
||||
Add member
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{members.length === 0 && !repo.owner ? (
|
||||
<EmptyState title="No members" description="Add a member to grant access." />
|
||||
) : (
|
||||
<div className="rounded-lg border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Access</TableHead>
|
||||
<TableHead>Added</TableHead>
|
||||
<TableHead className="w-24" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{repo.owner ? (
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">
|
||||
{repo.owner.username}
|
||||
<span className="ml-2 text-xs text-muted-foreground">(owner)</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AccessBadge access="admin" />
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">—</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
) : null}
|
||||
{members.map((m) => (
|
||||
<TableRow key={m.user_id}>
|
||||
<TableCell className="font-medium">
|
||||
{m.user?.username ?? m.user_id}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{canManage ? (
|
||||
<AccessSelect
|
||||
value={m.access}
|
||||
onChange={(access) =>
|
||||
onChangeAccess(m.user?.username ?? "", access)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<AccessBadge access={m.access} />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{formatDate(m.created_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{canManage ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive"
|
||||
onClick={() => setRemoveTarget(m.user?.username ?? "")}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canManage ? <AddMemberDialog open={addOpen} onOpenChange={setAddOpen} /> : null}
|
||||
<ConfirmDialog
|
||||
open={!!removeTarget}
|
||||
onOpenChange={(o) => !o && setRemoveTarget(null)}
|
||||
title="Remove member?"
|
||||
description={`Remove ${removeTarget} from this repository? They will lose access immediately.`}
|
||||
confirmLabel="Remove"
|
||||
destructive
|
||||
onConfirm={() => {
|
||||
if (removeTarget) void doRemove(removeTarget);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
async function onChangeAccess(username: string, access: Access) {
|
||||
try {
|
||||
await update.mutateAsync({ username, body: { access } });
|
||||
toast.success(`Updated ${username} to ${access}`);
|
||||
} catch (err) {
|
||||
toast.error(ApiError.isApiError(err) ? err.display() : "Failed to update member");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function AccessSelect({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: Access;
|
||||
onChange: (a: Access) => void;
|
||||
}) {
|
||||
return (
|
||||
<Select value={value} onValueChange={(v) => onChange(v as Access)}>
|
||||
<SelectTrigger className="h-8 w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCESS_OPTIONS.map((a) => (
|
||||
<SelectItem key={a} value={a}>
|
||||
{a}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
function AddMemberDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (o: boolean) => void;
|
||||
}) {
|
||||
const { repo } = useRepoContext();
|
||||
const add = useAddMember(repo.name);
|
||||
const [username, setUsername] = useState("");
|
||||
const [access, setAccess] = useState<Access>("read");
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setUsername("");
|
||||
setAccess("read");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!username.trim()) return;
|
||||
try {
|
||||
await add.mutateAsync({ username: username.trim(), access });
|
||||
toast.success(`Added ${username} as ${access}`);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
toast.error(ApiError.isApiError(err) ? err.display() : "Failed to add member");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add member</DialogTitle>
|
||||
<DialogDescription>Grant a user access to this repository.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="member-username">Username</Label>
|
||||
<Input
|
||||
id="member-username"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="member-access">Access</Label>
|
||||
<Select value={access} onValueChange={(v) => setAccess(v as Access)}>
|
||||
<SelectTrigger id="member-access">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="read">read — pull packages</SelectItem>
|
||||
<SelectItem value="write">write — push packages</SelectItem>
|
||||
<SelectItem value="read-write">read-write — pull & push</SelectItem>
|
||||
<SelectItem value="admin">admin — manage repo</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={add.isPending || !username.trim()}>
|
||||
{add.isPending ? <Spinner className="size-4" /> : null}
|
||||
Add
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Download, Package as PackageIcon, Trash2, Upload } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { EmptyState } from "@/components/common/empty-state";
|
||||
import { ErrorState } from "@/components/common/error-state";
|
||||
import { Spinner } from "@/components/common/spinner";
|
||||
import { ConfirmDialog } from "@/components/common/confirm-dialog";
|
||||
import { ApiError, getPackageFile } from "@/lib/api";
|
||||
import type { Package } from "@/lib/api/types";
|
||||
import { formatBytes, formatDate } from "@/lib/utils";
|
||||
import { useRepoContext } from "../repo-context";
|
||||
import {
|
||||
useComponents,
|
||||
useDeletePackage,
|
||||
useDistributions,
|
||||
usePackages,
|
||||
usePushPackage,
|
||||
} from "../repo-detail-hooks";
|
||||
|
||||
export function Packages() {
|
||||
const { repo, canWrite } = useRepoContext();
|
||||
const dists = useDistributions(repo.name);
|
||||
const [dist, setDist] = useState<string>("");
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Package | null>(null);
|
||||
const [detail, setDetail] = useState<Package | null>(null);
|
||||
const delPkg = useDeletePackage(repo.name);
|
||||
|
||||
// Auto-select the first distribution once loaded.
|
||||
useEffect(() => {
|
||||
if (!dist && dists.data && dists.data.length > 0) {
|
||||
setDist(dists.data[0].name);
|
||||
}
|
||||
}, [dists.data, dist]);
|
||||
|
||||
const packages = usePackages(repo.name, dist);
|
||||
|
||||
async function doDelete() {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await delPkg.mutateAsync({ id: deleteTarget.id, dist });
|
||||
toast.success(`Deleted ${deleteTarget.name} ${deleteTarget.version}`);
|
||||
setDeleteTarget(null);
|
||||
} catch (err) {
|
||||
toast.error(ApiError.isApiError(err) ? err.display() : "Failed to delete package");
|
||||
}
|
||||
}
|
||||
|
||||
async function download(pkg: Package) {
|
||||
try {
|
||||
const blob = await getPackageFile(repo.name, pkg.id);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = pkg.filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
toast.error(ApiError.isApiError(err) ? err.display() : "Download failed");
|
||||
}
|
||||
}
|
||||
|
||||
if (dists.isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (dists.isError) {
|
||||
return <ErrorState error={dists.error} />;
|
||||
}
|
||||
|
||||
if ((dists.data ?? []).length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<PackageIcon className="size-8" />}
|
||||
title="No distributions yet"
|
||||
description="Create a distribution on the Distributions tab before pushing packages."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-muted-foreground">Distribution</Label>
|
||||
<Select value={dist} onValueChange={setDist}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="Select…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{dists.data!.map((d) => (
|
||||
<SelectItem key={d.id} value={d.name}>
|
||||
{d.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{canWrite && dist ? (
|
||||
<Button size="sm" onClick={() => setUploadOpen(true)}>
|
||||
<Upload className="size-4" />
|
||||
Upload package
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!dist ? null : packages.isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
) : packages.isError ? (
|
||||
<ErrorState error={packages.error} />
|
||||
) : (packages.data?.items ?? []).length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<PackageIcon className="size-8" />}
|
||||
title="No packages"
|
||||
description={`No .deb packages in ${dist} yet.`}
|
||||
action={
|
||||
canWrite ? (
|
||||
<Button size="sm" onClick={() => setUploadOpen(true)}>
|
||||
<Upload className="size-4" />
|
||||
Upload package
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Version</TableHead>
|
||||
<TableHead>Arch</TableHead>
|
||||
<TableHead>Size</TableHead>
|
||||
<TableHead>Uploaded</TableHead>
|
||||
<TableHead className="w-24" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{packages.data!.items.map((pkg) => (
|
||||
<TableRow
|
||||
key={pkg.id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => setDetail(pkg)}
|
||||
>
|
||||
<TableCell className="font-mono font-medium">{pkg.name}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{pkg.version}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{pkg.architecture}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{formatBytes(pkg.size)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{formatDate(pkg.created_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
title="Download .deb"
|
||||
onClick={() => download(pkg)}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
</Button>
|
||||
{canWrite ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-destructive"
|
||||
title="Delete"
|
||||
onClick={() => setDeleteTarget(pkg)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canWrite && dist ? (
|
||||
<UploadDialog dist={dist} open={uploadOpen} onOpenChange={setUploadOpen} />
|
||||
) : null}
|
||||
|
||||
<PackageDetailDialog pkg={detail} onClose={() => setDetail(null)} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(o) => !o && setDeleteTarget(null)}
|
||||
title="Delete package?"
|
||||
description={
|
||||
deleteTarget
|
||||
? `Delete ${deleteTarget.name} ${deleteTarget.version} ${deleteTarget.architecture}?`
|
||||
: ""
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
destructive
|
||||
onConfirm={doDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadDialog({
|
||||
dist,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
dist: string;
|
||||
open: boolean;
|
||||
onOpenChange: (o: boolean) => void;
|
||||
}) {
|
||||
const { repo } = useRepoContext();
|
||||
const comps = useComponents(repo.name, dist);
|
||||
const push = usePushPackage(repo.name, dist);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [component, setComponent] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFile(null);
|
||||
setComponent("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Auto-pick the first component if only one exists.
|
||||
useEffect(() => {
|
||||
if (open && !component && comps.data && comps.data.length === 1) {
|
||||
setComponent(comps.data[0].name);
|
||||
}
|
||||
}, [open, comps.data, component]);
|
||||
|
||||
const noComponents = (comps.data ?? []).length === 0;
|
||||
const canSubmit = !!file && !!component && !push.isPending;
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!file || !component) return;
|
||||
try {
|
||||
const pkg = await push.mutateAsync({ file, component });
|
||||
toast.success(`Uploaded ${pkg.name} ${pkg.version}`);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
toast.error(ApiError.isApiError(err) ? err.display() : "Upload failed");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Upload package to {dist}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select a <code>.deb</code> file and the target component.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pkg-file">Package file</Label>
|
||||
<Input
|
||||
id="pkg-file"
|
||||
type="file"
|
||||
accept=".deb,application/vnd.debian.binary-package"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
required
|
||||
/>
|
||||
{file ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{file.name} ({formatBytes(file.size)})
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pkg-component">Component</Label>
|
||||
{noComponents ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No components in {dist}. Add one on the Distributions tab first.
|
||||
</p>
|
||||
) : (
|
||||
<Select value={component} onValueChange={setComponent}>
|
||||
<SelectTrigger id="pkg-component">
|
||||
<SelectValue placeholder="Select component…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{comps.data!.map((c) => (
|
||||
<SelectItem key={c.id} value={c.name}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!canSubmit}>
|
||||
{push.isPending ? <Spinner className="size-4" /> : null}
|
||||
Upload
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PackageDetailDialog({
|
||||
pkg,
|
||||
onClose,
|
||||
}: {
|
||||
pkg: Package | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const fields = useMemo(() => {
|
||||
if (!pkg) return [];
|
||||
const entries: [string, string][] = [
|
||||
["Name", pkg.name],
|
||||
["Version", pkg.version],
|
||||
["Architecture", pkg.architecture],
|
||||
["Component", pkg.component_id],
|
||||
["Source", pkg.source ?? ""],
|
||||
["Maintainer", pkg.maintainer ?? ""],
|
||||
["Priority", pkg.priority ?? ""],
|
||||
["Section", pkg.section ?? ""],
|
||||
["Origin", pkg.origin ?? ""],
|
||||
["Homepage", pkg.homepage ?? ""],
|
||||
["Installed-Size", pkg.installed_size ? String(pkg.installed_size) : ""],
|
||||
["Depends", pkg.depends ?? ""],
|
||||
["Pre-Depends", pkg.pre_depends ?? ""],
|
||||
["Recommends", pkg.recommends ?? ""],
|
||||
["Suggests", pkg.suggests ?? ""],
|
||||
["Conflicts", pkg.conflicts ?? ""],
|
||||
["Breaks", pkg.breaks ?? ""],
|
||||
["Provides", pkg.provides ?? ""],
|
||||
["Replaces", pkg.replaces ?? ""],
|
||||
["Enhances", pkg.enhances ?? ""],
|
||||
];
|
||||
return entries.filter(([, v]) => v);
|
||||
}, [pkg]);
|
||||
|
||||
return (
|
||||
<Dialog open={!!pkg} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-mono">
|
||||
{pkg?.name} {pkg?.version}
|
||||
</DialogTitle>
|
||||
<DialogDescription>Package control metadata</DialogDescription>
|
||||
</DialogHeader>
|
||||
{pkg ? (
|
||||
<div className="max-h-[60vh] space-y-2 overflow-y-auto">
|
||||
<div className="space-y-1 rounded-md border p-3 text-sm">
|
||||
{fields.map(([k, v]) => (
|
||||
<div key={k} className="flex gap-3">
|
||||
<span className="w-28 shrink-0 text-muted-foreground">{k}</span>
|
||||
<span className="break-all font-mono text-xs">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-1 rounded-md border p-3 text-sm">
|
||||
<div className="flex gap-3">
|
||||
<span className="w-28 shrink-0 text-muted-foreground">SHA256</span>
|
||||
<span className="break-all font-mono text-xs">{pkg.sha256}</span>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<span className="w-28 shrink-0 text-muted-foreground">Size</span>
|
||||
<span className="font-mono text-xs">{formatBytes(pkg.size)}</span>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<span className="w-28 shrink-0 text-muted-foreground">Filename</span>
|
||||
<span className="break-all font-mono text-xs">{pkg.filename}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Spinner } from "@/components/common/spinner";
|
||||
import { ConfirmDialog } from "@/components/common/confirm-dialog";
|
||||
import { CopyButton } from "@/components/common/copy-button";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import type { Visibility } from "@/lib/api/types";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { useRepoContext } from "../repo-context";
|
||||
import { useUpdateRepository, useDeleteRepository } from "../repos-hooks";
|
||||
|
||||
type DeleteHook = ReturnType<typeof useDeleteRepository>;
|
||||
|
||||
export function RepoOverview({ onDelete }: { onDelete: DeleteHook }) {
|
||||
const { repo, canManage } = useRepoContext();
|
||||
const navigate = useNavigate();
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
async function doDelete() {
|
||||
try {
|
||||
await onDelete.mutateAsync(repo.name);
|
||||
toast.success("Repository deleted");
|
||||
navigate("/");
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
ApiError.isApiError(err) ? err.display() : "Failed to delete repository",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle>Details</CardTitle>
|
||||
{canManage ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
|
||||
Edit
|
||||
</Button>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<Row label="Name" value={repo.name} mono />
|
||||
<Row label="Owner" value={repo.owner?.username ?? "—"} />
|
||||
<Row label="Visibility" value={repo.visibility} />
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<span className="text-muted-foreground">Description</span>
|
||||
<span className="max-w-[24rem] text-right">{repo.description || "—"}</span>
|
||||
</div>
|
||||
<Row label="Repository ID" value={repo.id} mono />
|
||||
<Row label="Created" value={formatDate(repo.created_at)} />
|
||||
<Row label="Updated" value={formatDate(repo.updated_at)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{canManage ? (
|
||||
<Card className="border-destructive/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">Danger zone</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between gap-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Deleting this repository removes all its distributions, packages, and
|
||||
members. This cannot be undone.
|
||||
</p>
|
||||
<Button variant="destructive" onClick={() => setDeleteOpen(true)}>
|
||||
Delete repository
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<EditDialog open={editOpen} onOpenChange={setEditOpen} />
|
||||
<ConfirmDialog
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
title={`Delete ${repo.name}?`}
|
||||
description="This permanently deletes the repository and all packages within it."
|
||||
confirmLabel="Delete"
|
||||
destructive
|
||||
onConfirm={doDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={mono ? "font-mono text-xs" : ""}>{value}</span>
|
||||
{mono ? <CopyButton value={value} size="icon" /> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (o: boolean) => void;
|
||||
}) {
|
||||
const { repo } = useRepoContext();
|
||||
const update = useUpdateRepository(repo.name);
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = useState(repo.name);
|
||||
const [visibility, setVisibility] = useState<Visibility>(repo.visibility);
|
||||
const [description, setDescription] = useState(repo.description);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(repo.name);
|
||||
setVisibility(repo.visibility);
|
||||
setDescription(repo.description);
|
||||
}
|
||||
}, [open, repo]);
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const updated = await update.mutateAsync({
|
||||
name: name !== repo.name ? name : undefined,
|
||||
visibility: visibility !== repo.visibility ? visibility : undefined,
|
||||
description: description !== repo.description ? description : undefined,
|
||||
});
|
||||
toast.success("Repository updated");
|
||||
onOpenChange(false);
|
||||
if (updated.name && updated.name !== repo.name) {
|
||||
navigate(`/repos/${updated.name}`);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
ApiError.isApiError(err) ? err.display() : "Failed to update repository",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit repository</DialogTitle>
|
||||
<DialogDescription>Update repository settings.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-name">Name</Label>
|
||||
<Input
|
||||
id="edit-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{name !== repo.name ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Renaming changes the apt URL. Existing clients will need reconfiguration.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-visibility">Visibility</Label>
|
||||
<Select
|
||||
value={visibility}
|
||||
onValueChange={(v) => setVisibility(v as Visibility)}
|
||||
>
|
||||
<SelectTrigger id="edit-visibility">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="private">Private — members only</SelectItem>
|
||||
<SelectItem value="public">Public — anonymous reads</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-desc">Description</Label>
|
||||
<Input
|
||||
id="edit-desc"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
maxLength={280}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={update.isPending}>
|
||||
{update.isPending ? <Spinner className="size-4" /> : null}
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Layers, Plus, Trash2, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { EmptyState } from "@/components/common/empty-state";
|
||||
import { ErrorState } from "@/components/common/error-state";
|
||||
import { Spinner } from "@/components/common/spinner";
|
||||
import { ConfirmDialog } from "@/components/common/confirm-dialog";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useRepoContext } from "../repo-context";
|
||||
import {
|
||||
useArchitectures,
|
||||
useComponents,
|
||||
useCreateArchitecture,
|
||||
useCreateComponent,
|
||||
useCreateDistribution,
|
||||
useDeleteArchitecture,
|
||||
useDeleteComponent,
|
||||
useDeleteDistribution,
|
||||
useDistributions,
|
||||
} from "../repo-detail-hooks";
|
||||
|
||||
export function Structure() {
|
||||
const { repo, canWrite } = useRepoContext();
|
||||
const { data, isLoading, isError, error } = useDistributions(repo.name);
|
||||
const createDist = useCreateDistribution(repo.name);
|
||||
const deleteDist = useDeleteDistribution(repo.name);
|
||||
const [newDist, setNewDist] = useState("");
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
|
||||
async function addDist(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const name = newDist.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
await createDist.mutateAsync({ name });
|
||||
setNewDist("");
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
ApiError.isApiError(err) ? err.display() : "Failed to create distribution",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function doDeleteDist() {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await deleteDist.mutateAsync(deleteTarget);
|
||||
toast.success(`Deleted distribution ${deleteTarget}`);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
ApiError.isApiError(err) ? err.display() : "Failed to delete distribution",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isError) {
|
||||
return <ErrorState error={error} />;
|
||||
}
|
||||
|
||||
const dists = data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Distributions (suites) group packages by release, with components and
|
||||
architectures.
|
||||
</p>
|
||||
{canWrite ? (
|
||||
<form onSubmit={addDist} className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="e.g. stable"
|
||||
value={newDist}
|
||||
onChange={(e) => setNewDist(e.target.value)}
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={createDist.isPending || !newDist.trim()}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Add distribution
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{dists.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Layers className="size-8" />}
|
||||
title="No distributions yet"
|
||||
description="Add a distribution like 'stable' to start organizing packages."
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{dists.map((dist) => (
|
||||
<DistributionCard
|
||||
key={dist.id}
|
||||
name={dist.name}
|
||||
canWrite={canWrite}
|
||||
onDelete={() => setDeleteTarget(dist.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(o) => !o && setDeleteTarget(null)}
|
||||
title="Delete distribution?"
|
||||
description={`Delete ${deleteTarget} and all of its components, architectures, and packages?`}
|
||||
confirmLabel="Delete"
|
||||
destructive
|
||||
onConfirm={doDeleteDist}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DistributionCard({
|
||||
name,
|
||||
canWrite,
|
||||
onDelete,
|
||||
}: {
|
||||
name: string;
|
||||
canWrite: boolean;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const { repo } = useRepoContext();
|
||||
const comps = useComponents(repo.name, name);
|
||||
const arches = useArchitectures(repo.name, name);
|
||||
const createComp = useCreateComponent(repo.name, name);
|
||||
const deleteComp = useDeleteComponent(repo.name, name);
|
||||
const createArch = useCreateArchitecture(repo.name, name);
|
||||
const deleteArch = useDeleteArchitecture(repo.name, name);
|
||||
const [newComp, setNewComp] = useState("");
|
||||
const [newArch, setNewArch] = useState("");
|
||||
|
||||
async function addComp(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const v = newComp.trim();
|
||||
if (!v) return;
|
||||
try {
|
||||
await createComp.mutateAsync({ name: v });
|
||||
setNewComp("");
|
||||
} catch (err) {
|
||||
toast.error(ApiError.isApiError(err) ? err.display() : "Failed to add component");
|
||||
}
|
||||
}
|
||||
|
||||
async function addArch(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const v = newArch.trim();
|
||||
if (!v) return;
|
||||
try {
|
||||
await createArch.mutateAsync({ name: v });
|
||||
setNewArch("");
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
ApiError.isApiError(err) ? err.display() : "Failed to add architecture",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function delComp(c: string) {
|
||||
try {
|
||||
await deleteComp.mutateAsync(c);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
ApiError.isApiError(err) ? err.display() : "Failed to delete component",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function delArch(a: string) {
|
||||
try {
|
||||
await deleteArch.mutateAsync(a);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
ApiError.isApiError(err) ? err.display() : "Failed to delete architecture",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="font-mono">{name}</CardTitle>
|
||||
{canWrite ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive"
|
||||
title="Delete distribution"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">Components</p>
|
||||
<ChipList
|
||||
items={(comps.data ?? []).map((c) => c.name)}
|
||||
onRemove={canWrite ? delComp : undefined}
|
||||
emptyText="No components"
|
||||
/>
|
||||
{canWrite ? (
|
||||
<form onSubmit={addComp} className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="e.g. main"
|
||||
value={newComp}
|
||||
onChange={(e) => setNewComp(e.target.value)}
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
className="h-8 max-w-[12rem]"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!newComp.trim()}
|
||||
>
|
||||
<Plus className="size-3.5" /> Add
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">Architectures</p>
|
||||
<ChipList
|
||||
items={(arches.data ?? []).map((a) => a.name)}
|
||||
extras={["all"]}
|
||||
onRemove={canWrite ? delArch : undefined}
|
||||
emptyText="No architectures"
|
||||
/>
|
||||
{canWrite ? (
|
||||
<form onSubmit={addArch} className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="e.g. amd64"
|
||||
value={newArch}
|
||||
onChange={(e) => setNewArch(e.target.value)}
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
className="h-8 max-w-[12rem]"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!newArch.trim()}
|
||||
>
|
||||
<Plus className="size-3.5" /> Add
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<code>all</code> is implicit and available to every distribution.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ChipList({
|
||||
items,
|
||||
extras = [],
|
||||
onRemove,
|
||||
emptyText,
|
||||
}: {
|
||||
items: string[];
|
||||
extras?: string[];
|
||||
onRemove?: (item: string) => void;
|
||||
emptyText: string;
|
||||
}) {
|
||||
if (items.length === 0 && extras.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">{emptyText}</p>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{extras.map((e) => (
|
||||
<Badge key={e} variant="secondary" className="font-mono">
|
||||
{e}
|
||||
</Badge>
|
||||
))}
|
||||
{items.map((item) => (
|
||||
<Badge
|
||||
key={item}
|
||||
variant="outline"
|
||||
className={cn("gap-1 font-mono", onRemove && "pr-1")}
|
||||
>
|
||||
{item}
|
||||
{onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-sm hover:bg-destructive/20"
|
||||
onClick={() => onRemove(item)}
|
||||
aria-label={`Remove ${item}`}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { PageHeader } from "@/components/common/page-header";
|
||||
import { ErrorState } from "@/components/common/error-state";
|
||||
import { Spinner } from "@/components/common/spinner";
|
||||
import { CopyButton } from "@/components/common/copy-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ApiError, getServerPubkey } from "@/lib/api";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useMe, useServerInfo } from "@/hooks/use-server";
|
||||
import { useLogout } from "@/hooks/use-logout";
|
||||
|
||||
export function Settings() {
|
||||
const signOut = useLogout();
|
||||
const token = useAuthStore((s) => s.token);
|
||||
const me = useMe(!!token);
|
||||
const serverInfo = useServerInfo();
|
||||
|
||||
async function downloadPubkey() {
|
||||
try {
|
||||
const armored = await getServerPubkey();
|
||||
const blob = new Blob([armored], { type: "application/pgp-keys" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "urapt-signing-key.asc";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
toast.error(ApiError.isApiError(err) ? err.display() : "Failed to fetch pubkey");
|
||||
}
|
||||
}
|
||||
|
||||
if (me.isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-16">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (me.isError) {
|
||||
return <ErrorState error={me.error} />;
|
||||
}
|
||||
|
||||
const user = me.data;
|
||||
const info = serverInfo.data;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Settings" description="Your account and this urapt instance" />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Account</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<Row label="Username" value={user?.username ?? "—"} />
|
||||
<Row label="Role" value={user?.is_admin ? "Server admin" : "Member"} />
|
||||
<Row label="User ID" value={user?.id ?? "—"} mono />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Instance</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<Row label="Version" value={info ? `urapt v${info.version}` : "—"} />
|
||||
<Row
|
||||
label="Registration"
|
||||
value={info ? (info.open_registration ? "Open" : "Closed") : "—"}
|
||||
/>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground">Signing key fingerprint</p>
|
||||
<p className="break-all font-mono text-xs">
|
||||
{info?.default_key_fingerprint || "—"}
|
||||
</p>
|
||||
</div>
|
||||
{info?.default_key_fingerprint ? (
|
||||
<CopyButton value={info.default_key_fingerprint} />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
<Button variant="outline" onClick={downloadPubkey}>
|
||||
<Download className="size-4" />
|
||||
Download public key
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Session</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="destructive" onClick={signOut}>
|
||||
Sign out
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className={mono ? "font-mono text-xs" : ""}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { AuthLayout } from "@/features/auth/auth-layout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Spinner } from "@/components/common/spinner";
|
||||
import { ApiError, register } from "@/lib/api";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
export function FirstRun() {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const setSession = useAuthStore((s) => s.setSession);
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const tooShort = password.length > 0 && password.length < 8;
|
||||
const mismatch = confirm.length > 0 && password !== confirm;
|
||||
const canSubmit =
|
||||
username.trim().length >= 3 && password.length >= 8 && password === confirm && !busy;
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await register({ username: username.trim(), password });
|
||||
setSession(res.token, res.user);
|
||||
qc.setQueryData(["me"], res.user);
|
||||
qc.invalidateQueries({ queryKey: ["server-info"] });
|
||||
toast.success("Admin account created. Welcome to urapt.");
|
||||
navigate("/");
|
||||
} catch (err) {
|
||||
toast.error(ApiError.isApiError(err) ? err.display() : "Registration failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
title="Set up urapt"
|
||||
description="Create the first admin account for this instance"
|
||||
>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Admin username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
autoComplete="username"
|
||||
placeholder="admin"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={32}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Lowercase letters, digits, <code>-</code> and <code>_</code>. 3–32 chars.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
{tooShort ? (
|
||||
<p className="text-xs text-destructive">At least 8 characters.</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm">Confirm password</Label>
|
||||
<Input
|
||||
id="confirm"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
required
|
||||
/>
|
||||
{mismatch ? (
|
||||
<p className="text-xs text-destructive">Passwords don't match.</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={!canSubmit}>
|
||||
{busy ? <Spinner className="size-4" /> : null}
|
||||
Create admin account
|
||||
</Button>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { KeyRound, Plus } from "lucide-react";
|
||||
import { PageHeader } from "@/components/common/page-header";
|
||||
import { EmptyState } from "@/components/common/empty-state";
|
||||
import { ErrorState } from "@/components/common/error-state";
|
||||
import { Spinner } from "@/components/common/spinner";
|
||||
import { CopyButton } from "@/components/common/copy-button";
|
||||
import { ConfirmDialog } from "@/components/common/confirm-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { useCreateToken, useRevokeToken, useTokens } from "./tokens-hooks";
|
||||
|
||||
export function Tokens() {
|
||||
const { data, isLoading, isError, error } = useTokens();
|
||||
const revoke = useRevokeToken();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [revokeTarget, setRevokeTarget] = useState<{ id: string; name: string } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
async function doRevoke() {
|
||||
if (!revokeTarget) return;
|
||||
try {
|
||||
await revoke.mutateAsync(revokeTarget.id);
|
||||
toast.success(`Revoked ${revokeTarget.name}`);
|
||||
setRevokeTarget(null);
|
||||
} catch (err) {
|
||||
toast.error(ApiError.isApiError(err) ? err.display() : "Failed to revoke token");
|
||||
}
|
||||
}
|
||||
|
||||
const tokens = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="API tokens"
|
||||
description="Tokens authenticate the urapt CLI and private-repo apt clients"
|
||||
actions={
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
New token
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Alert>
|
||||
<KeyRound className="size-4" />
|
||||
<AlertTitle>Treat tokens like passwords</AlertTitle>
|
||||
<AlertDescription>
|
||||
A token grants full access to your account. Use it as the password in apt's
|
||||
<code> auth.conf</code> for private repositories, or with{" "}
|
||||
<code>urapt login</code>.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<ErrorState error={error} />
|
||||
) : tokens.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<KeyRound className="size-8" />}
|
||||
title="No tokens"
|
||||
description="Create a token to use the urapt CLI or authenticate apt clients."
|
||||
action={
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
New token
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Prefix</TableHead>
|
||||
<TableHead>Last used</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="w-24" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tokens.map((t) => (
|
||||
<TableRow key={t.id}>
|
||||
<TableCell className="font-medium">{t.name}</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{t.prefix}…
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{formatDate(t.last_used_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{formatDate(t.created_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{t.revoked_at ? (
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
revoked
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">active</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{!t.revoked_at ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive"
|
||||
onClick={() => setRevokeTarget({ id: t.id, name: t.name })}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CreateTokenDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||||
<ConfirmDialog
|
||||
open={!!revokeTarget}
|
||||
onOpenChange={(o) => !o && setRevokeTarget(null)}
|
||||
title="Revoke token?"
|
||||
description={`Revoke "${revokeTarget?.name}"? Any client using it will lose access immediately.`}
|
||||
confirmLabel="Revoke"
|
||||
destructive
|
||||
onConfirm={doRevoke}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateTokenDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (o: boolean) => void;
|
||||
}) {
|
||||
const create = useCreateToken();
|
||||
const [name, setName] = useState("");
|
||||
const [createdToken, setCreatedToken] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName("");
|
||||
setCreatedToken(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
try {
|
||||
const token = await create.mutateAsync({ name: name.trim() });
|
||||
setCreatedToken(token.token ?? null);
|
||||
} catch (err) {
|
||||
toast.error(ApiError.isApiError(err) ? err.display() : "Failed to create token");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
{createdToken ? (
|
||||
<div className="space-y-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Token created</DialogTitle>
|
||||
<DialogDescription>
|
||||
Copy this token now. It won't be shown again.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Store it safely</AlertTitle>
|
||||
<AlertDescription>
|
||||
The server stores only a hash of the token. You can't recover it later.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="flex items-center gap-2 rounded-md border bg-muted p-3">
|
||||
<code className="flex-1 break-all font-mono text-xs">{createdToken}</code>
|
||||
<CopyButton value={createdToken} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => onOpenChange(false)}>Done</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New API token</DialogTitle>
|
||||
<DialogDescription>
|
||||
Name it so you can recognize it later.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="token-name">Name</Label>
|
||||
<Input
|
||||
id="token-name"
|
||||
placeholder="e.g. ci-builder"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoFocus
|
||||
maxLength={64}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={create.isPending || !name.trim()}>
|
||||
{create.isPending ? <Spinner className="size-4" /> : null}
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { createToken, listTokens, revokeToken } from "@/lib/api";
|
||||
import type { CreateTokenRequest } from "@/lib/api/types";
|
||||
|
||||
export const TOKENS_KEY = ["me", "tokens"] as const;
|
||||
|
||||
export function useTokens() {
|
||||
return useQuery({ queryKey: TOKENS_KEY, queryFn: listTokens });
|
||||
}
|
||||
|
||||
export function useCreateToken() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: CreateTokenRequest) => createToken(body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: TOKENS_KEY }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevokeToken() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => revokeToken(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: TOKENS_KEY }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { ShieldCheck, Trash2 } from "lucide-react";
|
||||
import { PageHeader } from "@/components/common/page-header";
|
||||
import { EmptyState } from "@/components/common/empty-state";
|
||||
import { ErrorState } from "@/components/common/error-state";
|
||||
import { Spinner } from "@/components/common/spinner";
|
||||
import { ConfirmDialog } from "@/components/common/confirm-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { ApiError, deleteUser, listUsers, updateUser } from "@/lib/api";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useMe } from "@/hooks/use-server";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
|
||||
const USERS_KEY = ["users"] as const;
|
||||
|
||||
export function Users() {
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: USERS_KEY,
|
||||
queryFn: () => listUsers({ per_page: 500 }),
|
||||
});
|
||||
const qc = useQueryClient();
|
||||
const me = useMe(!!useAuthStore.getState().token);
|
||||
const myId = me.data?.id;
|
||||
|
||||
const toggleAdmin = useMutation({
|
||||
mutationFn: ({ id, isAdmin }: { id: string; isAdmin: boolean }) =>
|
||||
updateUser(id, { is_admin: isAdmin }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: USERS_KEY }),
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => deleteUser(id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: USERS_KEY });
|
||||
qc.invalidateQueries({ queryKey: ["me"] });
|
||||
},
|
||||
});
|
||||
|
||||
const [deleteTarget, setDeleteTarget] = useState<{
|
||||
id: string;
|
||||
username: string;
|
||||
} | null>(null);
|
||||
const users = data?.items ?? [];
|
||||
|
||||
async function doDelete() {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await remove.mutateAsync(deleteTarget.id);
|
||||
toast.success(`Deleted user ${deleteTarget.username}`);
|
||||
setDeleteTarget(null);
|
||||
} catch (err) {
|
||||
toast.error(ApiError.isApiError(err) ? err.display() : "Failed to delete user");
|
||||
}
|
||||
}
|
||||
|
||||
async function onToggle(id: string, isAdmin: boolean, username: string) {
|
||||
try {
|
||||
await toggleAdmin.mutateAsync({ id, isAdmin });
|
||||
toast.success(`${username} is ${isAdmin ? "now an admin" : "no longer an admin"}`);
|
||||
} catch (err) {
|
||||
toast.error(ApiError.isApiError(err) ? err.display() : "Failed to update user");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Users"
|
||||
description="All accounts on this urapt instance (server admin only)"
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<ErrorState error={error} />
|
||||
) : users.length === 0 ? (
|
||||
<EmptyState title="No users" />
|
||||
) : (
|
||||
<div className="rounded-lg border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Username</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Admin</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="w-24" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((u) => {
|
||||
const isSelf = u.id === myId;
|
||||
return (
|
||||
<TableRow key={u.id}>
|
||||
<TableCell className="font-medium">
|
||||
{u.username}
|
||||
{isSelf ? (
|
||||
<span className="ml-2 text-xs text-muted-foreground">(you)</span>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{u.is_admin ? (
|
||||
<Badge variant="secondary">
|
||||
<ShieldCheck className="mr-1 size-3" />
|
||||
admin
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
member
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={u.is_admin}
|
||||
disabled={isSelf || toggleAdmin.isPending}
|
||||
onCheckedChange={(v) => onToggle(u.id, v, u.username)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{formatDate(u.created_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{!isSelf ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive"
|
||||
title="Delete user"
|
||||
onClick={() =>
|
||||
setDeleteTarget({ id: u.id, username: u.username })
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(o) => !o && setDeleteTarget(null)}
|
||||
title="Delete user?"
|
||||
description={`Delete ${deleteTarget?.username}? Their API tokens are revoked and they lose access to all repositories.`}
|
||||
confirmLabel="Delete"
|
||||
destructive
|
||||
onConfirm={doDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { logout } from "@/lib/api";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { ME_KEY, SERVER_INFO_KEY } from "@/hooks/use-server";
|
||||
|
||||
/** Shared sign-out handler: revokes server-side token, clears local state. */
|
||||
export function useLogout() {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const clear = useAuthStore((s) => s.clear);
|
||||
|
||||
return async function signOut() {
|
||||
try {
|
||||
await logout().catch(() => {
|
||||
// Best-effort: clear locally even if the server call fails.
|
||||
});
|
||||
} finally {
|
||||
clear();
|
||||
qc.setQueryData(ME_KEY, null);
|
||||
qc.removeQueries({ queryKey: ["repos"] });
|
||||
qc.removeQueries({ queryKey: SERVER_INFO_KEY });
|
||||
toast.success("Signed out");
|
||||
navigate("/login");
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getMe, getServerInfo } from "@/lib/api";
|
||||
|
||||
export const SERVER_INFO_KEY = ["server-info"] as const;
|
||||
export const ME_KEY = ["me"] as const;
|
||||
|
||||
export function useServerInfo() {
|
||||
return useQuery({ queryKey: SERVER_INFO_KEY, queryFn: getServerInfo });
|
||||
}
|
||||
|
||||
/** Current user. Pass `enabled` based on whether a token exists. */
|
||||
export function useMe(enabled: boolean) {
|
||||
return useQuery({ queryKey: ME_KEY, queryFn: getMe, enabled, retry: false });
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 72.2% 50.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-feature-settings:
|
||||
"rlig" 1,
|
||||
"calt" 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { ApiError, NETWORK_ERROR, toApiError } from "./errors";
|
||||
|
||||
const DEFAULT_BASE = "/api/v1";
|
||||
|
||||
export function getApiBase(): string {
|
||||
const fromEnv = import.meta.env.VITE_API_BASE_URL as string | undefined;
|
||||
return (fromEnv && fromEnv.trim()) || DEFAULT_BASE;
|
||||
}
|
||||
|
||||
type TokenGetter = () => string | null;
|
||||
type UnauthorizedHandler = () => void;
|
||||
|
||||
let tokenGetter: TokenGetter = () => null;
|
||||
let onUnauthorized: UnauthorizedHandler = () => {};
|
||||
|
||||
/** Wire the auth store's token into the client (called once at boot). */
|
||||
export function configureAuth(getter: TokenGetter, handler: UnauthorizedHandler) {
|
||||
tokenGetter = getter;
|
||||
onUnauthorized = handler;
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = tokenGetter();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
function buildUrl(path: string, params?: Record<string, unknown>): string {
|
||||
const base = getApiBase().replace(/\/$/, "");
|
||||
const url = `${base}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
if (!params) return url;
|
||||
const search = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v === undefined || v === null || v === "") continue;
|
||||
search.append(k, String(v));
|
||||
}
|
||||
const qs = search.toString();
|
||||
return qs ? `${url}?${qs}` : url;
|
||||
}
|
||||
|
||||
async function handle<T>(res: Response): Promise<T> {
|
||||
if (res.status === 204) return undefined as T;
|
||||
if (res.status === 401) {
|
||||
// Defer to avoid throwing during render of a reactively-cleared store.
|
||||
try {
|
||||
onUnauthorized();
|
||||
} catch {
|
||||
// ignore handler errors
|
||||
}
|
||||
}
|
||||
if (!res.ok) throw await toApiError(res);
|
||||
if (res.status === 204 || res.status === 205) return undefined as T;
|
||||
const text = await res.text();
|
||||
if (!text) return undefined as T;
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
||||
export const http = {
|
||||
async get<T>(path: string, params?: Record<string, unknown>): Promise<T> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(buildUrl(path, params), {
|
||||
headers: { Accept: "application/json", ...authHeaders() },
|
||||
});
|
||||
} catch {
|
||||
throw NETWORK_ERROR;
|
||||
}
|
||||
return handle<T>(res);
|
||||
},
|
||||
|
||||
async sendJson<T>(
|
||||
method: "POST" | "PATCH" | "PUT" | "DELETE",
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(buildUrl(path), {
|
||||
method,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
||||
...authHeaders(),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
} catch {
|
||||
throw NETWORK_ERROR;
|
||||
}
|
||||
return handle<T>(res);
|
||||
},
|
||||
|
||||
async post<T>(path: string, body?: unknown): Promise<T> {
|
||||
return this.sendJson<T>("POST", path, body);
|
||||
},
|
||||
|
||||
async patch<T>(path: string, body?: unknown): Promise<T> {
|
||||
return this.sendJson<T>("PATCH", path, body);
|
||||
},
|
||||
|
||||
async put<T>(path: string, body?: unknown): Promise<T> {
|
||||
return this.sendJson<T>("PUT", path, body);
|
||||
},
|
||||
|
||||
async del<T>(path: string): Promise<T> {
|
||||
return this.sendJson<T>("DELETE", path);
|
||||
},
|
||||
|
||||
/** Multipart upload (file + optional form fields). Returns parsed JSON. */
|
||||
async postForm<T>(
|
||||
path: string,
|
||||
fields: Record<string, string>,
|
||||
file: File,
|
||||
fileField = "file",
|
||||
): Promise<T> {
|
||||
const form = new FormData();
|
||||
for (const [k, v] of Object.entries(fields)) form.append(k, v);
|
||||
form.append(fileField, file);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(buildUrl(path), {
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json", ...authHeaders() },
|
||||
body: form,
|
||||
});
|
||||
} catch {
|
||||
throw NETWORK_ERROR;
|
||||
}
|
||||
return handle<T>(res);
|
||||
},
|
||||
|
||||
/** Fetch a non-JSON resource (text, blob) with auth headers attached. */
|
||||
async raw(path: string, init?: RequestInit): Promise<Response> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(buildUrl(path), {
|
||||
...init,
|
||||
headers: { ...authHeaders(), ...(init?.headers ?? {}) },
|
||||
});
|
||||
} catch {
|
||||
throw NETWORK_ERROR;
|
||||
}
|
||||
if (!res.ok) throw await toApiError(res);
|
||||
return res;
|
||||
},
|
||||
};
|
||||
|
||||
export { ApiError };
|
||||
@@ -0,0 +1,190 @@
|
||||
import { http } from "./client";
|
||||
import type {
|
||||
APIToken,
|
||||
AddMemberRequest,
|
||||
Architecture,
|
||||
AuthResponse,
|
||||
Component,
|
||||
CreateNamedRequest,
|
||||
CreateRepoRequest,
|
||||
CreateTokenRequest,
|
||||
Distribution,
|
||||
ListParams,
|
||||
ListResponse,
|
||||
LoginRequest,
|
||||
Package,
|
||||
RegisterRequest,
|
||||
Repository,
|
||||
RepositoryMember,
|
||||
ServerInfo,
|
||||
UpdateMemberRequest,
|
||||
UpdateRepoRequest,
|
||||
UpdateUserRequest,
|
||||
User,
|
||||
} from "./types";
|
||||
|
||||
// --- server / setup ---
|
||||
|
||||
export const getServerInfo = () => http.get<ServerInfo>("/server/info");
|
||||
|
||||
export const getServerPubkey = () =>
|
||||
http
|
||||
.raw("/server/pubkey", { headers: { Accept: "application/pgp-keys" } })
|
||||
.then((r) => r.text());
|
||||
|
||||
// --- auth ---
|
||||
|
||||
export const register = (body: RegisterRequest) =>
|
||||
http.post<AuthResponse>("/auth/register", body);
|
||||
|
||||
export const login = (body: LoginRequest) => http.post<AuthResponse>("/auth/login", body);
|
||||
|
||||
export const logout = () => http.post<void>("/auth/logout");
|
||||
|
||||
export const getMe = () => http.get<User>("/me");
|
||||
|
||||
// --- tokens ---
|
||||
|
||||
export const listTokens = () => http.get<ListResponse<APIToken>>("/me/tokens");
|
||||
|
||||
export const createToken = (body: CreateTokenRequest) =>
|
||||
http.post<APIToken>("/me/tokens", body);
|
||||
|
||||
export const revokeToken = (id: string) => http.del<void>(`/me/tokens/${id}`);
|
||||
|
||||
// --- repositories ---
|
||||
|
||||
export const listRepositories = (params?: ListParams) =>
|
||||
http.get<ListResponse<Repository>>("/repositories", params);
|
||||
|
||||
export const createRepository = (body: CreateRepoRequest) =>
|
||||
http.post<Repository>("/repositories", body);
|
||||
|
||||
export const getRepository = (name: string) =>
|
||||
http.get<Repository>(`/repositories/${encodeURIComponent(name)}`);
|
||||
|
||||
export const updateRepository = (name: string, body: UpdateRepoRequest) =>
|
||||
http.patch<Repository>(`/repositories/${encodeURIComponent(name)}`, body);
|
||||
|
||||
export const deleteRepository = (name: string) =>
|
||||
http.del<void>(`/repositories/${encodeURIComponent(name)}`);
|
||||
|
||||
export const getRepoPubkey = (name: string) =>
|
||||
http
|
||||
.raw(`/repositories/${encodeURIComponent(name)}/pubkey`, {
|
||||
headers: { Accept: "application/pgp-keys" },
|
||||
})
|
||||
.then((r) => r.text());
|
||||
|
||||
// --- members ---
|
||||
|
||||
export const listMembers = (repo: string) =>
|
||||
http.get<RepositoryMember[]>(`/repositories/${encodeURIComponent(repo)}/members`);
|
||||
|
||||
export const addMember = (repo: string, body: AddMemberRequest) =>
|
||||
http.post<RepositoryMember>(`/repositories/${encodeURIComponent(repo)}/members`, body);
|
||||
|
||||
export const updateMember = (repo: string, username: string, body: UpdateMemberRequest) =>
|
||||
http.patch<RepositoryMember>(
|
||||
`/repositories/${encodeURIComponent(repo)}/members/${encodeURIComponent(username)}`,
|
||||
body,
|
||||
);
|
||||
|
||||
export const removeMember = (repo: string, username: string) =>
|
||||
http.del<void>(
|
||||
`/repositories/${encodeURIComponent(repo)}/members/${encodeURIComponent(username)}`,
|
||||
);
|
||||
|
||||
// --- distributions ---
|
||||
|
||||
export const listDistributions = (repo: string) =>
|
||||
http.get<Distribution[]>(`/repositories/${encodeURIComponent(repo)}/distributions`);
|
||||
|
||||
export const createDistribution = (repo: string, body: CreateNamedRequest) =>
|
||||
http.post<Distribution>(
|
||||
`/repositories/${encodeURIComponent(repo)}/distributions`,
|
||||
body,
|
||||
);
|
||||
|
||||
export const deleteDistribution = (repo: string, dist: string) =>
|
||||
http.del<void>(
|
||||
`/repositories/${encodeURIComponent(repo)}/distributions/${encodeURIComponent(dist)}`,
|
||||
);
|
||||
|
||||
// --- components ---
|
||||
|
||||
export const listComponents = (repo: string, dist: string) =>
|
||||
http.get<Component[]>(
|
||||
`/repositories/${encodeURIComponent(repo)}/distributions/${encodeURIComponent(dist)}/components`,
|
||||
);
|
||||
|
||||
export const createComponent = (repo: string, dist: string, body: CreateNamedRequest) =>
|
||||
http.post<Component>(
|
||||
`/repositories/${encodeURIComponent(repo)}/distributions/${encodeURIComponent(dist)}/components`,
|
||||
body,
|
||||
);
|
||||
|
||||
export const deleteComponent = (repo: string, dist: string, comp: string) =>
|
||||
http.del<void>(
|
||||
`/repositories/${encodeURIComponent(repo)}/distributions/${encodeURIComponent(dist)}/components/${encodeURIComponent(comp)}`,
|
||||
);
|
||||
|
||||
// --- architectures ---
|
||||
|
||||
export const listArchitectures = (repo: string, dist: string) =>
|
||||
http.get<Architecture[]>(
|
||||
`/repositories/${encodeURIComponent(repo)}/distributions/${encodeURIComponent(dist)}/architectures`,
|
||||
);
|
||||
|
||||
export const createArchitecture = (
|
||||
repo: string,
|
||||
dist: string,
|
||||
body: CreateNamedRequest,
|
||||
) =>
|
||||
http.post<Architecture>(
|
||||
`/repositories/${encodeURIComponent(repo)}/distributions/${encodeURIComponent(dist)}/architectures`,
|
||||
body,
|
||||
);
|
||||
|
||||
export const deleteArchitecture = (repo: string, dist: string, arch: string) =>
|
||||
http.del<void>(
|
||||
`/repositories/${encodeURIComponent(repo)}/distributions/${encodeURIComponent(dist)}/architectures/${encodeURIComponent(arch)}`,
|
||||
);
|
||||
|
||||
// --- packages ---
|
||||
|
||||
export const listPackages = (repo: string, dist: string, params?: ListParams) =>
|
||||
http.get<ListResponse<Package>>(
|
||||
`/repositories/${encodeURIComponent(repo)}/distributions/${encodeURIComponent(dist)}/packages`,
|
||||
params,
|
||||
);
|
||||
|
||||
export const pushPackage = (repo: string, dist: string, file: File, component: string) =>
|
||||
http.postForm<Package>(
|
||||
`/repositories/${encodeURIComponent(repo)}/distributions/${encodeURIComponent(dist)}/packages`,
|
||||
{ component },
|
||||
file,
|
||||
);
|
||||
|
||||
export const getPackage = (repo: string, id: string) =>
|
||||
http.get<Package>(`/repositories/${encodeURIComponent(repo)}/packages/${id}`);
|
||||
|
||||
export const getPackageFile = (repo: string, id: string) =>
|
||||
http
|
||||
.raw(`/repositories/${encodeURIComponent(repo)}/packages/${id}/file`)
|
||||
.then((r) => r.blob());
|
||||
|
||||
export const deletePackage = (repo: string, id: string) =>
|
||||
http.del<void>(`/repositories/${encodeURIComponent(repo)}/packages/${id}`);
|
||||
|
||||
// --- users (admin) ---
|
||||
|
||||
export const listUsers = (params?: ListParams) =>
|
||||
http.get<ListResponse<User>>("/users", params);
|
||||
|
||||
export const getUser = (id: string) => http.get<User>(`/users/${id}`);
|
||||
|
||||
export const updateUser = (id: string, body: UpdateUserRequest) =>
|
||||
http.patch<User>(`/users/${id}`, body);
|
||||
|
||||
export const deleteUser = (id: string) => http.del<void>(`/users/${id}`);
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { toApiError, ApiError } from "./errors";
|
||||
|
||||
function mockRes(body: unknown, status: number, statusText = ""): Response {
|
||||
return {
|
||||
status,
|
||||
statusText,
|
||||
ok: status >= 200 && status < 300,
|
||||
json: () => Promise.resolve(body),
|
||||
text: () => Promise.resolve(JSON.stringify(body)),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
describe("toApiError", () => {
|
||||
it("parses a uniform error envelope", async () => {
|
||||
const err = await toApiError(
|
||||
mockRes({ error: { code: "not_found", message: "no such repo" } }, 404),
|
||||
);
|
||||
expect(err).toBeInstanceOf(ApiError);
|
||||
expect(err.status).toBe(404);
|
||||
expect(err.code).toBe("not_found");
|
||||
expect(err.message).toBe("no such repo");
|
||||
});
|
||||
|
||||
it("falls back to statusText for non-JSON bodies", async () => {
|
||||
const res = {
|
||||
...mockRes({}, 500, "Internal Server Error"),
|
||||
json: () => Promise.reject(new Error("bad")),
|
||||
};
|
||||
const err = await toApiError(res as Response);
|
||||
expect(err.status).toBe(500);
|
||||
expect(err.code).toBe("internal");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ApiErrorBody, ErrorCode } from "./types";
|
||||
|
||||
/**
|
||||
* ApiError wraps a uniform urapt error response:
|
||||
* {"error":{"code":"...","message":"...","details":...}}
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: ErrorCode | string;
|
||||
readonly details?: unknown;
|
||||
|
||||
constructor(status: number, code: string, message: string, details?: unknown) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
static isApiError(e: unknown): e is ApiError {
|
||||
return e instanceof ApiError;
|
||||
}
|
||||
|
||||
/** A human-friendly message suitable for toast/UI display. */
|
||||
display(): string {
|
||||
return this.message || this.code || "request failed";
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse a Response into an ApiError, falling back to a network error. */
|
||||
export async function toApiError(res: Response): Promise<ApiError> {
|
||||
let body: ApiErrorBody | undefined;
|
||||
try {
|
||||
body = (await res.json()) as ApiErrorBody;
|
||||
} catch {
|
||||
// non-JSON response
|
||||
}
|
||||
if (body?.error) {
|
||||
return new ApiError(
|
||||
res.status,
|
||||
body.error.code,
|
||||
body.error.message,
|
||||
body.error.details,
|
||||
);
|
||||
}
|
||||
return new ApiError(res.status, "internal", res.statusText || "request failed");
|
||||
}
|
||||
|
||||
export const NETWORK_ERROR = new ApiError(0, "internal", "network error");
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./types";
|
||||
export * from "./errors";
|
||||
export { http, getApiBase, configureAuth, ApiError } from "./client";
|
||||
export * from "./endpoints";
|
||||
@@ -0,0 +1,204 @@
|
||||
// DTOs mirroring urapt's shared/models and shared/api packages.
|
||||
// Field names match the server's JSON tags exactly.
|
||||
|
||||
export type Visibility = "public" | "private";
|
||||
|
||||
export type Access = "read" | "write" | "read-write" | "admin";
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface APIToken {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
prefix: string;
|
||||
/** Plaintext token. Only present on creation/login. */
|
||||
token?: string;
|
||||
created_at: string;
|
||||
last_used_at?: string | null;
|
||||
revoked_at?: string | null;
|
||||
}
|
||||
|
||||
export interface Repository {
|
||||
id: string;
|
||||
name: string;
|
||||
owner_user_id: string;
|
||||
owner?: User;
|
||||
visibility: Visibility;
|
||||
description: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface RepositoryMember {
|
||||
repository_id: string;
|
||||
user_id: string;
|
||||
user?: User;
|
||||
access: Access;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Distribution {
|
||||
id: string;
|
||||
repository_id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Component {
|
||||
id: string;
|
||||
distribution_id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Architecture {
|
||||
id: string;
|
||||
distribution_id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Package {
|
||||
id: string;
|
||||
repository_id: string;
|
||||
distribution_id: string;
|
||||
component_id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
architecture: string;
|
||||
source?: string;
|
||||
maintainer?: string;
|
||||
priority?: string;
|
||||
section?: string;
|
||||
origin?: string;
|
||||
homepage?: string;
|
||||
description?: string;
|
||||
description_md5?: string;
|
||||
depends?: string;
|
||||
pre_depends?: string;
|
||||
recommends?: string;
|
||||
suggests?: string;
|
||||
conflicts?: string;
|
||||
breaks?: string;
|
||||
provides?: string;
|
||||
replaces?: string;
|
||||
enhances?: string;
|
||||
installed_size?: number;
|
||||
essential?: string;
|
||||
built_using?: string;
|
||||
tag?: string;
|
||||
raw_control: string;
|
||||
filename: string;
|
||||
pool_path: string;
|
||||
size: number;
|
||||
md5sum: string;
|
||||
sha1: string;
|
||||
sha256: string;
|
||||
uploaded_by_user_id: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// --- server / setup ---
|
||||
|
||||
export interface ServerInfo {
|
||||
version: string;
|
||||
needs_setup: boolean;
|
||||
default_key_fingerprint: string;
|
||||
open_registration: boolean;
|
||||
}
|
||||
|
||||
// --- auth DTOs ---
|
||||
|
||||
export interface RegisterRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
user: User;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface CreateTokenRequest {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// --- repository DTOs ---
|
||||
|
||||
export interface CreateRepoRequest {
|
||||
name: string;
|
||||
visibility: Visibility;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateRepoRequest {
|
||||
name?: string;
|
||||
visibility?: Visibility;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface AddMemberRequest {
|
||||
username: string;
|
||||
access: Access;
|
||||
}
|
||||
|
||||
export interface UpdateMemberRequest {
|
||||
access: Access;
|
||||
}
|
||||
|
||||
// --- structure DTOs ---
|
||||
|
||||
export interface CreateNamedRequest {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// --- users (admin) ---
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
is_admin?: boolean;
|
||||
}
|
||||
|
||||
// --- list envelope ---
|
||||
|
||||
export interface ListResponse<T> {
|
||||
items: T[];
|
||||
page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ListParams extends Record<string, unknown> {
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
}
|
||||
|
||||
// --- error envelope ---
|
||||
|
||||
export type ErrorCode =
|
||||
| "bad_request"
|
||||
| "unauthorized"
|
||||
| "forbidden"
|
||||
| "not_found"
|
||||
| "conflict"
|
||||
| "payload_too_large"
|
||||
| "internal";
|
||||
|
||||
export interface ApiErrorBody {
|
||||
error: {
|
||||
code: ErrorCode | string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export function formatDate(iso: string | null | undefined): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
return d.toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function copyToClipboard(text: string): Promise<void> {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
return navigator.clipboard.writeText(text);
|
||||
}
|
||||
return Promise.reject(new Error("clipboard unavailable"));
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { configureAuth } from "@/lib/api";
|
||||
import { getToken, useAuthStore } from "@/stores/auth";
|
||||
|
||||
/** Wire the auth store's token + 401 handler into the API client once. */
|
||||
configureAuth(getToken, () => {
|
||||
// A 401 means the token is invalid/expired. Clear local session; the root
|
||||
// guard will redirect to /login on the next render cycle.
|
||||
useAuthStore.getState().clear();
|
||||
});
|
||||
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
const [client] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: (failureCount, error) => {
|
||||
// Don't retry on auth errors; the user must re-authenticate.
|
||||
const status = (error as { status?: number })?.status;
|
||||
if (status === 401 || status === 403 || status === 404) return false;
|
||||
return failureCount < 2;
|
||||
},
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<QueryClientProvider client={client}>
|
||||
{children}
|
||||
<Toaster richColors position="top-right" />
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { createBrowserRouter } from "react-router-dom";
|
||||
import { RootGuard } from "@/components/common/root-guard";
|
||||
import { AdminRoute } from "@/components/common/admin-route";
|
||||
import { NotFound } from "@/components/common/not-found";
|
||||
import { FullPageSpinner } from "@/components/common/spinner";
|
||||
import { ReposList } from "@/features/repos/ReposList";
|
||||
|
||||
const RepoDetail = lazy(() =>
|
||||
import("@/features/repos/RepoDetail").then((m) => ({ default: m.RepoDetail })),
|
||||
);
|
||||
const Tokens = lazy(() =>
|
||||
import("@/features/tokens/Tokens").then((m) => ({ default: m.Tokens })),
|
||||
);
|
||||
const Users = lazy(() =>
|
||||
import("@/features/users/Users").then((m) => ({ default: m.Users })),
|
||||
);
|
||||
const Settings = lazy(() =>
|
||||
import("@/features/settings/Settings").then((m) => ({ default: m.Settings })),
|
||||
);
|
||||
const Login = lazy(() =>
|
||||
import("@/features/auth/Login").then((m) => ({ default: m.Login })),
|
||||
);
|
||||
const FirstRun = lazy(() =>
|
||||
import("@/features/setup/FirstRun").then((m) => ({ default: m.FirstRun })),
|
||||
);
|
||||
|
||||
const fallback = <FullPageSpinner />;
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: "/login",
|
||||
element: (
|
||||
<Suspense fallback={fallback}>
|
||||
<Login />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/setup",
|
||||
element: (
|
||||
<Suspense fallback={fallback}>
|
||||
<FirstRun />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
element: <RootGuard />,
|
||||
children: [
|
||||
{ index: true, element: <ReposList /> },
|
||||
{
|
||||
path: "repos/:repo",
|
||||
element: (
|
||||
<Suspense fallback={fallback}>
|
||||
<RepoDetail />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "repos/:repo/:tab",
|
||||
element: (
|
||||
<Suspense fallback={fallback}>
|
||||
<RepoDetail />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "tokens",
|
||||
element: (
|
||||
<Suspense fallback={fallback}>
|
||||
<Tokens />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "users",
|
||||
element: (
|
||||
<AdminRoute>
|
||||
<Suspense fallback={fallback}>
|
||||
<Users />
|
||||
</Suspense>
|
||||
</AdminRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "settings",
|
||||
element: (
|
||||
<Suspense fallback={fallback}>
|
||||
<Settings />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{ path: "*", element: <NotFound /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,35 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { User } from "@/lib/api/types";
|
||||
|
||||
const STORAGE_KEY = "urapt.auth";
|
||||
|
||||
interface AuthState {
|
||||
token: string | null;
|
||||
user: User | null;
|
||||
setSession: (token: string, user: User) => void;
|
||||
setUser: (user: User | null) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
token: null,
|
||||
user: null,
|
||||
setSession: (token, user) => set({ token, user }),
|
||||
setUser: (user) => set({ user }),
|
||||
clear: () => set({ token: null, user: null }),
|
||||
}),
|
||||
{
|
||||
name: STORAGE_KEY,
|
||||
// Only persist the token; user is re-fetched on boot via GET /me.
|
||||
partialize: (s) => ({ token: s.token }) as AuthState,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/** Synchronous token getter for the API client (avoids React render cycles). */
|
||||
export function getToken(): string | null {
|
||||
return useAuthStore.getState().token;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach } from "vitest";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vitest" />
|
||||
@@ -0,0 +1,69 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: { "2xl": "1400px" },
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: "0" },
|
||||
to: { height: "var(--radix-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--radix-accordion-content-height)" },
|
||||
to: { height: "0" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
"types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/// <reference types="vitest/config" />
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "node:path";
|
||||
|
||||
const SERVER_TARGET = process.env.URAPT_SERVER ?? "http://localhost:8080";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
// Same-origin dev: forward API + APT endpoints to a local urapt-server.
|
||||
"/api": { target: SERVER_TARGET, changeOrigin: true },
|
||||
"/apt": { target: SERVER_TARGET, changeOrigin: true },
|
||||
},
|
||||
},
|
||||
build: {
|
||||
chunkSizeWarningLimit: 600,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
"react-vendor": ["react", "react-dom", "react-router-dom"],
|
||||
"query-vendor": ["@tanstack/react-query"],
|
||||
"radix-vendor": [
|
||||
"@radix-ui/react-dialog",
|
||||
"@radix-ui/react-dropdown-menu",
|
||||
"@radix-ui/react-select",
|
||||
"@radix-ui/react-tabs",
|
||||
"@radix-ui/react-toast",
|
||||
"@radix-ui/react-tooltip",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./src/test/setup.ts"],
|
||||
css: false,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user