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