8 Commits
Author SHA1 Message Date
owen 2a1ed3af04 Normalize Railway upstream URL for web proxy
CI / Test (linux/amd64) (push) Waiting to run
CI / Lint (push) Waiting to run
CI / Cross-build (darwin/amd64) (push) Waiting to run
CI / Cross-build (linux/amd64) (push) Waiting to run
CI / Cross-build (darwin/arm64) (push) Waiting to run
CI / Cross-build (linux/arm64) (push) Waiting to run
2026-06-29 20:37:33 -05:00
owen 59a8580ee1 Pin pnpm version for Railway web build 2026-06-29 20:35:33 -05:00
owen d9d997d912 Add Railway deployment for web UI 2026-06-29 20:33:17 -05:00
owen 11eb1d54d6 Use Go 1.26.1 in Docker build 2026-06-29 20:16:48 -05:00
owen d9efa5f06d Fix Railway Docker volume build 2026-06-29 20:14:43 -05:00
owen 1b402f586a Bump CHANGELOG for v0.1.1
CI / Test (linux/amd64) (push) Waiting to run
CI / Lint (push) Waiting to run
CI / Cross-build (darwin/amd64) (push) Waiting to run
CI / Cross-build (linux/amd64) (push) Waiting to run
CI / Cross-build (darwin/arm64) (push) Waiting to run
CI / Cross-build (linux/arm64) (push) Waiting to run
Release notes for v0.1.1: urapt-web web UI and the Repository.Owner
list/get fix.
2026-06-28 18:44:52 -05:00
owen 39525bd5f0 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).
2026-06-28 18:42:46 -05:00
owen bff58fd842 Populate Repository.Owner in list/get store queries
ListReposVisible, GetRepositoryByName, and GetRepositoryByID left
Repository.Owner as nil because they only selected repository columns.
LEFT JOIN users in all three and populate Owner, so the API (and the
web UI's repositories table) can show the owner's username.
2026-06-28 18:42:31 -05:00
92 changed files with 10818 additions and 14 deletions
+26 -1
View File
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.1.1] - 2026-06-28
### Added
- **Web UI** (`urapt-web/`): a Vite + React 18 + TypeScript SPA for managing
urapt-server instances, with full management parity with the CLI. Same-origin
with the API (no CORS, no server changes required).
- 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` multipart upload + detail + delete, and an apt-config panel that
generates the exact `apt` setup commands client-side.
- API tokens (show-once creation + revoke), admin users (toggle admin/delete),
settings, and 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`. Deploy behind nginx/Caddy (see
`urapt-web/README.md`).
### Fixed
- `ListReposVisible`, `GetRepositoryByName`, and `GetRepositoryByID` now
populate `Repository.Owner` via a LEFT JOIN on `users`. Previously the owner
`User` was always `nil` in list/get responses, so consumers (the web UI's
repositories table) could not display the owner's username.
## [0.1.0] - 2026-06-28
First public release. urapt is a self-hostable APT repository server with a
@@ -58,5 +82,6 @@ companion CLI for pushing and managing Debian `.deb` packages.
- No rate limiting or brute-force protection on login endpoints.
- No source packages (`.dsc`/`.orig.tar.*`) or AppStream metadata.
[Unreleased]: https://github.com/owenqwenstarsky/urapt/compare/v0.1.0...HEAD
[Unreleased]: https://github.com/owenqwenstarsky/urapt/compare/v0.1.1...HEAD
[0.1.1]: https://github.com/owenqwenstarsky/urapt/releases/tag/v0.1.1
[0.1.0]: https://github.com/owenqwenstarsky/urapt/releases/tag/v0.1.0
+4 -3
View File
@@ -1,17 +1,18 @@
# syntax=docker/dockerfile:1
FROM golang:1.22-bookworm AS build
FROM golang:1.26.1-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/urapt-server ./cmd/urapt-server \
RUN mkdir -p /out/data/store \
&& CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/urapt-server ./cmd/urapt-server \
&& CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/urapt ./cmd/urapt
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/urapt-server /usr/local/bin/urapt-server
COPY --from=build --chown=nonroot:nonroot /out/data /data
EXPOSE 8080
VOLUME ["/data"]
ENV URAPT_STORE_DIR=/data/store
ENV URAPT_BASE_URL=http://localhost:8080
USER nonroot:nonroot
+50 -10
View File
@@ -24,11 +24,13 @@ func (s *Store) CreateRepository(ctx context.Context, name, ownerUserID string,
}
// ListReposVisible returns repositories visible to userID: owned, public, or
// where the user is a member.
// where the user is a member. Each repository's Owner is populated.
func (s *Store) ListReposVisible(ctx context.Context, userID string) ([]*models.Repository, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT DISTINCT r.id, r.name, r.owner_user_id, r.visibility, r.description, r.created_at, r.updated_at
SELECT DISTINCT r.id, r.name, r.owner_user_id, r.visibility, r.description, r.created_at, r.updated_at,
u.id, u.username, u.is_admin, u.created_at, u.updated_at
FROM repositories r
LEFT JOIN users u ON u.id = r.owner_user_id
WHERE r.owner_user_id = ?
OR r.visibility = 'public'
OR EXISTS (SELECT 1 FROM repository_members m WHERE m.repository_id = r.id AND m.user_id = ?)
@@ -40,9 +42,25 @@ func (s *Store) ListReposVisible(ctx context.Context, userID string) ([]*models.
var out []*models.Repository
for rows.Next() {
r := &models.Repository{}
if err := rows.Scan(&r.ID, &r.Name, &r.OwnerUserID, &r.Visibility, &r.Description, &r.CreatedAt, &r.UpdatedAt); err != nil {
var (
ownerID sql.NullString
ownerUser sql.NullString
ownerAdmin sql.NullBool
ownerCreated sql.NullString
ownerUpdated sql.NullString
)
if err := rows.Scan(
&r.ID, &r.Name, &r.OwnerUserID, &r.Visibility, &r.Description, &r.CreatedAt, &r.UpdatedAt,
&ownerID, &ownerUser, &ownerAdmin, &ownerCreated, &ownerUpdated,
); err != nil {
return nil, err
}
if ownerID.Valid {
r.Owner = &models.User{
ID: ownerID.String, Username: ownerUser.String, IsAdmin: ownerAdmin.Bool,
CreatedAt: ownerCreated.String, UpdatedAt: ownerUpdated.String,
}
}
out = append(out, r)
}
return out, rows.Err()
@@ -79,31 +97,53 @@ func (s *Store) DeleteRepository(ctx context.Context, id string) error {
return nil
}
// GetRepositoryByName returns a repository by its unique name.
// GetRepositoryByName returns a repository by its unique name, with Owner populated.
func (s *Store) GetRepositoryByName(ctx context.Context, name string) (*models.Repository, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, name, owner_user_id, visibility, description, created_at, updated_at
FROM repositories WHERE name = ?`, name)
`SELECT r.id, r.name, r.owner_user_id, r.visibility, r.description, r.created_at, r.updated_at,
u.id, u.username, u.is_admin, u.created_at, u.updated_at
FROM repositories r
LEFT JOIN users u ON u.id = r.owner_user_id
WHERE r.name = ?`, name)
return scanRepo(row)
}
// GetRepositoryByID returns a repository by id.
// GetRepositoryByID returns a repository by id, with Owner populated.
func (s *Store) GetRepositoryByID(ctx context.Context, id string) (*models.Repository, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, name, owner_user_id, visibility, description, created_at, updated_at
FROM repositories WHERE id = ?`, id)
`SELECT r.id, r.name, r.owner_user_id, r.visibility, r.description, r.created_at, r.updated_at,
u.id, u.username, u.is_admin, u.created_at, u.updated_at
FROM repositories r
LEFT JOIN users u ON u.id = r.owner_user_id
WHERE r.id = ?`, id)
return scanRepo(row)
}
func scanRepo(row *sql.Row) (*models.Repository, error) {
r := &models.Repository{}
err := row.Scan(&r.ID, &r.Name, &r.OwnerUserID, &r.Visibility, &r.Description, &r.CreatedAt, &r.UpdatedAt)
var (
ownerID sql.NullString
ownerUser sql.NullString
ownerAdmin sql.NullBool
ownerCreated sql.NullString
ownerUpdated sql.NullString
)
err := row.Scan(
&r.ID, &r.Name, &r.OwnerUserID, &r.Visibility, &r.Description, &r.CreatedAt, &r.UpdatedAt,
&ownerID, &ownerUser, &ownerAdmin, &ownerCreated, &ownerUpdated,
)
if isErrNoRows(err) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
if ownerID.Valid {
r.Owner = &models.User{
ID: ownerID.String, Username: ownerUser.String, IsAdmin: ownerAdmin.Bool,
CreatedAt: ownerCreated.String, UpdatedAt: ownerUpdated.String,
}
}
return r, nil
}
+9
View File
@@ -0,0 +1,9 @@
node_modules
dist
.git
.gitignore
Dockerfile
README.md
*.tsbuildinfo
.env
.env.*
+3
View File
@@ -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
+35
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
20
+4
View File
@@ -0,0 +1,4 @@
dist
node_modules
coverage
pnpm-lock.yaml
+8
View File
@@ -0,0 +1,8 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 90,
"tabWidth": 2,
"plugins": ["prettier-plugin-tailwindcss"]
}
+24
View File
@@ -0,0 +1,24 @@
{
auto_https off
}
:{$PORT} {
root * /srv
handle /api/* {
reverse_proxy {$URAPT_UPSTREAM} {
header_up Host {upstream_hostport}
}
}
handle /apt/* {
reverse_proxy {$URAPT_UPSTREAM} {
header_up Host {upstream_hostport}
}
}
handle {
try_files {path} /index.html
file_server
}
}
+17
View File
@@ -0,0 +1,17 @@
FROM node:20-alpine AS build
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@10.32.1 --activate
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM caddy:2-alpine
COPY --from=build /app/dist /srv
COPY Caddyfile /etc/caddy/Caddyfile
COPY docker-entrypoint.sh /usr/local/bin/urapt-web-entrypoint
RUN chmod +x /usr/local/bin/urapt-web-entrypoint
ENTRYPOINT ["/usr/local/bin/urapt-web-entrypoint"]
+134
View File
@@ -0,0 +1,134 @@
# 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/
```
### Railway
`urapt-web/` includes a Dockerfile for deploying the UI as a separate Railway
service. The container builds the Vite app and serves it with Caddy, proxying
`/api/*` and `/apt/*` to an existing `urapt-server` service.
In Railway, create a new service from this repo with:
- Root Directory: `urapt-web`
- Environment variable: `URAPT_SERVER_URL=https://your-urapt-server.up.railway.app`
A trailing slash is okay; the container normalizes it before starting Caddy.
Do not set `VITE_API_BASE_URL` for this deployment; the UI should keep using the
default same-origin `/api/v1` path.
### 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.
+21
View File
@@ -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"
}
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
set -eu
if [ -z "${URAPT_SERVER_URL:-}" ]; then
echo "URAPT_SERVER_URL is required, e.g. https://urapt-production.up.railway.app" >&2
exit 1
fi
# Caddy reverse_proxy upstreams may only contain scheme, host, and port.
# Railway/public URLs are often copied with a trailing slash; normalize that.
URAPT_UPSTREAM="$URAPT_SERVER_URL"
while [ "${URAPT_UPSTREAM%/}" != "$URAPT_UPSTREAM" ]; do
URAPT_UPSTREAM="${URAPT_UPSTREAM%/}"
done
export URAPT_UPSTREAM
exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
+29
View File
@@ -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: "^_" },
],
},
},
);
+14
View File
@@ -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>
+78
View File
@@ -0,0 +1,78 @@
{
"name": "urapt-web",
"private": true,
"version": "0.1.0",
"packageManager": "pnpm@10.32.1",
"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"
]
}
}
+4795
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+5
View File
@@ -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

+11
View File
@@ -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>
);
}
+145
View File
@@ -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>;
}
+55
View File
@@ -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 };
+50
View File
@@ -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 };
+33
View File
@@ -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 };
+57
View File
@@ -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 };
+61
View File
@@ -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 };
+26
View File
@@ -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 };
+108
View File
@@ -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,
};
+22
View File
@@ -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 };
+20
View File
@@ -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 };
+150
View File
@@ -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,
};
+26
View File
@@ -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 };
+9
View File
@@ -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 };
+28
View File
@@ -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 };
+29
View File
@@ -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 };
+112
View File
@@ -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,
};
+53
View File
@@ -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 };
+30
View File
@@ -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 };
+72
View File
@@ -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/&lt;name&gt;/</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>
);
}
+104
View File
@@ -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>
);
}
+102
View File
@@ -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>
);
}
+106
View File
@@ -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>
);
}
+252
View File
@@ -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 }),
});
}
+170
View File
@@ -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>
);
}
+28
View File
@@ -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");
}
};
}
+14
View File
@@ -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 });
}
+62
View File
@@ -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;
}
}
+147
View File
@@ -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 };
+190
View File
@@ -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}`);
+34
View File
@@ -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");
});
});
+49
View File
@@ -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");
+4
View File
@@ -0,0 +1,4 @@
export * from "./types";
export * from "./errors";
export { http, getApiBase, configureAuth, ApiError } from "./client";
export * from "./endpoints";
+204
View File
@@ -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;
};
}
+34
View File
@@ -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"));
}
+10
View File
@@ -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>,
);
+47
View File
@@ -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>
);
}
+97
View File
@@ -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 /> },
],
},
]);
+35
View File
@@ -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;
}
+7
View File
@@ -0,0 +1,7 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => {
cleanup();
});
+1
View File
@@ -0,0 +1 @@
/// <reference types="vitest" />
+69
View File
@@ -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")],
};
+31
View File
@@ -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"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
+20
View File
@@ -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"]
}
+49
View File
@@ -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,
},
});