+>(({ className, ...props }, ref) => (
+ [role=checkbox]]:translate-y-[2px]",
+ className,
+ )}
+ {...props}
+ />
+));
+TableCell.displayName = "TableCell";
+
+const TableCaption = React.forwardRef<
+ HTMLTableCaptionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+TableCaption.displayName = "TableCaption";
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+};
diff --git a/urapt-web/src/components/ui/tabs.tsx b/urapt-web/src/components/ui/tabs.tsx
new file mode 100644
index 0000000..9aa22a0
--- /dev/null
+++ b/urapt-web/src/components/ui/tabs.tsx
@@ -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,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+TabsList.displayName = TabsPrimitive.List.displayName;
+
+const TabsTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
+
+const TabsContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+TabsContent.displayName = TabsPrimitive.Content.displayName;
+
+export { Tabs, TabsList, TabsTrigger, TabsContent };
diff --git a/urapt-web/src/components/ui/tooltip.tsx b/urapt-web/src/components/ui/tooltip.tsx
new file mode 100644
index 0000000..cccf46f
--- /dev/null
+++ b/urapt-web/src/components/ui/tooltip.tsx
@@ -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,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+
+
+));
+TooltipContent.displayName = TooltipPrimitive.Content.displayName;
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
diff --git a/urapt-web/src/features/auth/Login.tsx b/urapt-web/src/features/auth/Login.tsx
new file mode 100644
index 0000000..c20950a
--- /dev/null
+++ b/urapt-web/src/features/auth/Login.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/urapt-web/src/features/auth/auth-layout.tsx b/urapt-web/src/features/auth/auth-layout.tsx
new file mode 100644
index 0000000..64cf17f
--- /dev/null
+++ b/urapt-web/src/features/auth/auth-layout.tsx
@@ -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 (
+
+
+
+
+
+
+
+
{title}
+
{description}
+
+
+ {children}
+ {footer ? (
+
{footer}
+ ) : null}
+
+
+ );
+}
diff --git a/urapt-web/src/features/repos/CreateRepoDialog.tsx b/urapt-web/src/features/repos/CreateRepoDialog.tsx
new file mode 100644
index 0000000..93186f8
--- /dev/null
+++ b/urapt-web/src/features/repos/CreateRepoDialog.tsx
@@ -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("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 (
+
+
+
+ New repository
+
+ Create a new APT repository to push packages into.
+
+
+
+
+
+ );
+}
diff --git a/urapt-web/src/features/repos/RepoDetail.tsx b/urapt-web/src/features/repos/RepoDetail.tsx
new file mode 100644
index 0000000..f61ff7e
--- /dev/null
+++ b/urapt-web/src/features/repos/RepoDetail.tsx
@@ -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 (
+
+
+
+ );
+ }
+ if (isError) {
+ return ;
+ }
+ 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 (
+
+
+
+
+ Repositories
+
+
+
}
+ />
+
+
+
+
+ Overview
+
+
+ Members
+
+
+ Distributions
+
+
+ Packages
+
+
+ Apt config
+
+
+
+
+
+ {tab === "members" ? (
+
+ ) : tab === "structure" ? (
+
+ ) : tab === "packages" ? (
+
+ ) : tab === "apt-config" ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
diff --git a/urapt-web/src/features/repos/ReposList.tsx b/urapt-web/src/features/repos/ReposList.tsx
new file mode 100644
index 0000000..234deb8
--- /dev/null
+++ b/urapt-web/src/features/repos/ReposList.tsx
@@ -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 (
+
+
setCreateOpen(true)}>
+
+ New repository
+
+ }
+ />
+
+ {isLoading ? (
+
+
+
+ ) : isError ? (
+ refetch()} />
+ ) : repos.length === 0 ? (
+ }
+ title="No repositories yet"
+ description="Create your first repository to start pushing .deb packages."
+ action={
+ setCreateOpen(true)}>
+
+ New repository
+
+ }
+ />
+ ) : (
+
+
+
+
+ Name
+ Visibility
+ Owner
+ Description
+ Created
+
+
+
+ {repos.map((repo) => (
+ navigate(`/repos/${repo.name}`)}
+ >
+ {repo.name}
+
+
+
+
+ {repo.owner?.username ?? "—"}
+
+
+ {repo.description || "—"}
+
+
+ {formatDate(repo.created_at)}
+
+
+ ))}
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/urapt-web/src/features/repos/repo-context.ts b/urapt-web/src/features/repos/repo-context.ts
new file mode 100644
index 0000000..06e1156
--- /dev/null
+++ b/urapt-web/src/features/repos/repo-context.ts
@@ -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(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";
+}
diff --git a/urapt-web/src/features/repos/repo-context.tsx b/urapt-web/src/features/repos/repo-context.tsx
new file mode 100644
index 0000000..35c1226
--- /dev/null
+++ b/urapt-web/src/features/repos/repo-context.tsx
@@ -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 {children} ;
+}
diff --git a/urapt-web/src/features/repos/repo-detail-hooks.ts b/urapt-web/src/features/repos/repo-detail-hooks.ts
new file mode 100644
index 0000000..d70e9e6
--- /dev/null
+++ b/urapt-web/src/features/repos/repo-detail-hooks.ts
@@ -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 };
diff --git a/urapt-web/src/features/repos/repos-hooks.ts b/urapt-web/src/features/repos/repos-hooks.ts
new file mode 100644
index 0000000..2603312
--- /dev/null
+++ b/urapt-web/src/features/repos/repos-hooks.ts
@@ -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 }),
+ });
+}
diff --git a/urapt-web/src/features/repos/tabs/AptConfig.tsx b/urapt-web/src/features/repos/tabs/AptConfig.tsx
new file mode 100644
index 0000000..2389f21
--- /dev/null
+++ b/urapt-web/src/features/repos/tabs/AptConfig.tsx
@@ -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 : "";
+ lines.push("");
+ lines.push("# 4. This repository is private. Configure apt credentials:");
+ lines.push(`sudo tee /etc/apt/auth.conf.d/${repo.name}.conf <"}`);
+ 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 (
+
+
+
+ );
+ }
+ if ((dists.data ?? []).length === 0) {
+ return (
+ }
+ title="No distributions yet"
+ description="Create a distribution on the Distributions tab to generate apt config."
+ />
+ );
+ }
+
+ return (
+
+
+ Run these commands on a Debian/Ubuntu client to install packages from this
+ repository.
+
+
+
+ Distribution
+
+
+
+
+
+ {dists.data!.map((d) => (
+
+ {d.name}
+
+ ))}
+
+
+
+
+ {isPrivate ? (
+
+
+ Private repository
+
+
+ Clients must authenticate with an API token. Create one on the{" "}
+
+ Tokens
+ {" "}
+ page and use it as the password.
+
+
+ setUseMyToken(e.target.checked)}
+ />
+ Insert my current session token as the password
+
+ {useMyToken ? (
+
+ Warning: this is your login session token. Anyone with it has full account
+ access. Prefer a dedicated token.
+
+ ) : null}
+
+
+ ) : null}
+
+
+
+
+
+
+ Manage tokens
+
+
+
+ );
+}
+
+function hostOf(url: string): string {
+ try {
+ return new URL(url).host;
+ } catch {
+ return url;
+ }
+}
diff --git a/urapt-web/src/features/repos/tabs/Members.tsx b/urapt-web/src/features/repos/tabs/Members.tsx
new file mode 100644
index 0000000..85ac28d
--- /dev/null
+++ b/urapt-web/src/features/repos/tabs/Members.tsx
@@ -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(null);
+ const remove = useRemoveMember(repo.name);
+ const update = useUpdateMember(repo.name);
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+ if (isError) {
+ return ;
+ }
+
+ 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 (
+
+
+
+ Members can read or push packages based on their access level.
+
+ {canManage ? (
+
setAddOpen(true)}>
+
+ Add member
+
+ ) : null}
+
+
+ {members.length === 0 && !repo.owner ? (
+
+ ) : (
+
+
+
+
+ User
+ Access
+ Added
+
+
+
+
+ {repo.owner ? (
+
+
+ {repo.owner.username}
+ (owner)
+
+
+
+
+ —
+
+
+ ) : null}
+ {members.map((m) => (
+
+
+ {m.user?.username ?? m.user_id}
+
+
+ {canManage ? (
+
+ onChangeAccess(m.user?.username ?? "", access)
+ }
+ />
+ ) : (
+
+ )}
+
+
+ {formatDate(m.created_at)}
+
+
+ {canManage ? (
+ setRemoveTarget(m.user?.username ?? "")}
+ >
+ Remove
+
+ ) : null}
+
+
+ ))}
+
+
+
+ )}
+
+ {canManage ?
: null}
+
!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);
+ }}
+ />
+
+ );
+
+ 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 (
+ onChange(v as Access)}>
+
+
+
+
+ {ACCESS_OPTIONS.map((a) => (
+
+ {a}
+
+ ))}
+
+
+ );
+}
+
+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("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 (
+
+
+
+ Add member
+ Grant a user access to this repository.
+
+
+
+
+ );
+}
diff --git a/urapt-web/src/features/repos/tabs/Packages.tsx b/urapt-web/src/features/repos/tabs/Packages.tsx
new file mode 100644
index 0000000..040bf6e
--- /dev/null
+++ b/urapt-web/src/features/repos/tabs/Packages.tsx
@@ -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("");
+ const [uploadOpen, setUploadOpen] = useState(false);
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [detail, setDetail] = useState(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 (
+
+
+
+ );
+ }
+ if (dists.isError) {
+ return ;
+ }
+
+ if ((dists.data ?? []).length === 0) {
+ return (
+ }
+ title="No distributions yet"
+ description="Create a distribution on the Distributions tab before pushing packages."
+ />
+ );
+ }
+
+ return (
+
+
+
+ Distribution
+
+
+
+
+
+ {dists.data!.map((d) => (
+
+ {d.name}
+
+ ))}
+
+
+
+ {canWrite && dist ? (
+
setUploadOpen(true)}>
+
+ Upload package
+
+ ) : null}
+
+
+ {!dist ? null : packages.isLoading ? (
+
+
+
+ ) : packages.isError ? (
+
+ ) : (packages.data?.items ?? []).length === 0 ? (
+
}
+ title="No packages"
+ description={`No .deb packages in ${dist} yet.`}
+ action={
+ canWrite ? (
+
setUploadOpen(true)}>
+
+ Upload package
+
+ ) : undefined
+ }
+ />
+ ) : (
+
+
+
+
+ Name
+ Version
+ Arch
+ Size
+ Uploaded
+
+
+
+
+ {packages.data!.items.map((pkg) => (
+ setDetail(pkg)}
+ >
+ {pkg.name}
+ {pkg.version}
+ {pkg.architecture}
+
+ {formatBytes(pkg.size)}
+
+
+ {formatDate(pkg.created_at)}
+
+
+ e.stopPropagation()}
+ >
+ download(pkg)}
+ >
+
+
+ {canWrite ? (
+ setDeleteTarget(pkg)}
+ >
+
+
+ ) : null}
+
+
+
+ ))}
+
+
+
+ )}
+
+ {canWrite && dist ? (
+
+ ) : null}
+
+
setDetail(null)} />
+
+ !o && setDeleteTarget(null)}
+ title="Delete package?"
+ description={
+ deleteTarget
+ ? `Delete ${deleteTarget.name} ${deleteTarget.version} ${deleteTarget.architecture}?`
+ : ""
+ }
+ confirmLabel="Delete"
+ destructive
+ onConfirm={doDelete}
+ />
+
+ );
+}
+
+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(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 (
+
+
+
+ Upload package to {dist}
+
+ Select a .deb file and the target component.
+
+
+
+
+
+ );
+}
+
+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 (
+ !o && onClose()}>
+
+
+
+ {pkg?.name} {pkg?.version}
+
+ Package control metadata
+
+ {pkg ? (
+
+
+ {fields.map(([k, v]) => (
+
+ {k}
+ {v}
+
+ ))}
+
+
+
+ SHA256
+ {pkg.sha256}
+
+
+ Size
+ {formatBytes(pkg.size)}
+
+
+ Filename
+ {pkg.filename}
+
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/urapt-web/src/features/repos/tabs/RepoOverview.tsx b/urapt-web/src/features/repos/tabs/RepoOverview.tsx
new file mode 100644
index 0000000..162fd59
--- /dev/null
+++ b/urapt-web/src/features/repos/tabs/RepoOverview.tsx
@@ -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;
+
+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 (
+
+
+
+ Details
+ {canManage ? (
+ setEditOpen(true)}>
+ Edit
+
+ ) : null}
+
+
+
+
+
+
+ Description
+ {repo.description || "—"}
+
+
+
+
+
+
+
+ {canManage ? (
+
+
+ Danger zone
+
+
+
+ Deleting this repository removes all its distributions, packages, and
+ members. This cannot be undone.
+
+ setDeleteOpen(true)}>
+ Delete repository
+
+
+
+ ) : null}
+
+
+
+
+ );
+}
+
+function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
+ return (
+
+
{label}
+
+ {value}
+ {mono ? : null}
+
+
+ );
+}
+
+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(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 (
+
+
+
+ Edit repository
+ Update repository settings.
+
+
+
+
+ );
+}
diff --git a/urapt-web/src/features/repos/tabs/Structure.tsx b/urapt-web/src/features/repos/tabs/Structure.tsx
new file mode 100644
index 0000000..036ccd4
--- /dev/null
+++ b/urapt-web/src/features/repos/tabs/Structure.tsx
@@ -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(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 (
+
+
+
+ );
+ }
+ if (isError) {
+ return ;
+ }
+
+ const dists = data ?? [];
+
+ return (
+
+
+
+ Distributions (suites) group packages by release, with components and
+ architectures.
+
+ {canWrite ? (
+
+ ) : null}
+
+
+ {dists.length === 0 ? (
+
}
+ title="No distributions yet"
+ description="Add a distribution like 'stable' to start organizing packages."
+ />
+ ) : (
+
+ {dists.map((dist) => (
+ setDeleteTarget(dist.name)}
+ />
+ ))}
+
+ )}
+
+
!o && setDeleteTarget(null)}
+ title="Delete distribution?"
+ description={`Delete ${deleteTarget} and all of its components, architectures, and packages?`}
+ confirmLabel="Delete"
+ destructive
+ onConfirm={doDeleteDist}
+ />
+
+ );
+}
+
+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 (
+
+
+ {name}
+ {canWrite ? (
+
+
+
+ ) : null}
+
+
+
+
Components
+
c.name)}
+ onRemove={canWrite ? delComp : undefined}
+ emptyText="No components"
+ />
+ {canWrite ? (
+
+ ) : null}
+
+
+
+
Architectures
+
a.name)}
+ extras={["all"]}
+ onRemove={canWrite ? delArch : undefined}
+ emptyText="No architectures"
+ />
+ {canWrite ? (
+
+ ) : null}
+
+ all is implicit and available to every distribution.
+
+
+
+
+ );
+}
+
+function ChipList({
+ items,
+ extras = [],
+ onRemove,
+ emptyText,
+}: {
+ items: string[];
+ extras?: string[];
+ onRemove?: (item: string) => void;
+ emptyText: string;
+}) {
+ if (items.length === 0 && extras.length === 0) {
+ return {emptyText}
;
+ }
+ return (
+
+ {extras.map((e) => (
+
+ {e}
+
+ ))}
+ {items.map((item) => (
+
+ {item}
+ {onRemove ? (
+ onRemove(item)}
+ aria-label={`Remove ${item}`}
+ >
+
+
+ ) : null}
+
+ ))}
+
+ );
+}
diff --git a/urapt-web/src/features/settings/Settings.tsx b/urapt-web/src/features/settings/Settings.tsx
new file mode 100644
index 0000000..e1aad9d
--- /dev/null
+++ b/urapt-web/src/features/settings/Settings.tsx
@@ -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 (
+
+
+
+ );
+ }
+ if (me.isError) {
+ return ;
+ }
+
+ const user = me.data;
+ const info = serverInfo.data;
+
+ return (
+
+
+
+
+
+ Account
+
+
+
+
+
+
+
+
+
+
+ Instance
+
+
+
+
+
+
+
Signing key fingerprint
+
+ {info?.default_key_fingerprint || "—"}
+
+
+ {info?.default_key_fingerprint ? (
+
+ ) : null}
+
+
+
+
+ Download public key
+
+
+
+
+
+
+
+ Session
+
+
+
+ Sign out
+
+
+
+
+ );
+}
+
+function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
diff --git a/urapt-web/src/features/setup/FirstRun.tsx b/urapt-web/src/features/setup/FirstRun.tsx
new file mode 100644
index 0000000..8dc9853
--- /dev/null
+++ b/urapt-web/src/features/setup/FirstRun.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/urapt-web/src/features/tokens/Tokens.tsx b/urapt-web/src/features/tokens/Tokens.tsx
new file mode 100644
index 0000000..a6c5623
--- /dev/null
+++ b/urapt-web/src/features/tokens/Tokens.tsx
@@ -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 (
+
+
setCreateOpen(true)}>
+
+ New token
+
+ }
+ />
+
+
+
+ Treat tokens like passwords
+
+ A token grants full access to your account. Use it as the password in apt's
+ auth.conf for private repositories, or with{" "}
+ urapt login.
+
+
+
+ {isLoading ? (
+
+
+
+ ) : isError ? (
+
+ ) : tokens.length === 0 ? (
+ }
+ title="No tokens"
+ description="Create a token to use the urapt CLI or authenticate apt clients."
+ action={
+ setCreateOpen(true)}>
+
+ New token
+
+ }
+ />
+ ) : (
+
+
+
+
+ Name
+ Prefix
+ Last used
+ Created
+ Status
+
+
+
+
+ {tokens.map((t) => (
+
+ {t.name}
+
+ {t.prefix}…
+
+
+ {formatDate(t.last_used_at)}
+
+
+ {formatDate(t.created_at)}
+
+
+ {t.revoked_at ? (
+
+ revoked
+
+ ) : (
+ active
+ )}
+
+
+ {!t.revoked_at ? (
+ setRevokeTarget({ id: t.id, name: t.name })}
+ >
+ Revoke
+
+ ) : null}
+
+
+ ))}
+
+
+
+ )}
+
+
+ !o && setRevokeTarget(null)}
+ title="Revoke token?"
+ description={`Revoke "${revokeTarget?.name}"? Any client using it will lose access immediately.`}
+ confirmLabel="Revoke"
+ destructive
+ onConfirm={doRevoke}
+ />
+
+ );
+}
+
+function CreateTokenDialog({
+ open,
+ onOpenChange,
+}: {
+ open: boolean;
+ onOpenChange: (o: boolean) => void;
+}) {
+ const create = useCreateToken();
+ const [name, setName] = useState("");
+ const [createdToken, setCreatedToken] = useState(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 (
+
+
+ {createdToken ? (
+
+
+ Token created
+
+ Copy this token now. It won't be shown again.
+
+
+
+ Store it safely
+
+ The server stores only a hash of the token. You can't recover it later.
+
+
+
+ {createdToken}
+
+
+
+ onOpenChange(false)}>Done
+
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/urapt-web/src/features/tokens/tokens-hooks.ts b/urapt-web/src/features/tokens/tokens-hooks.ts
new file mode 100644
index 0000000..fa43e60
--- /dev/null
+++ b/urapt-web/src/features/tokens/tokens-hooks.ts
@@ -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 }),
+ });
+}
diff --git a/urapt-web/src/features/users/Users.tsx b/urapt-web/src/features/users/Users.tsx
new file mode 100644
index 0000000..db91b5b
--- /dev/null
+++ b/urapt-web/src/features/users/Users.tsx
@@ -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 (
+
+
+
+ {isLoading ? (
+
+
+
+ ) : isError ? (
+
+ ) : users.length === 0 ? (
+
+ ) : (
+
+
+
+
+ Username
+ Role
+ Admin
+ Created
+
+
+
+
+ {users.map((u) => {
+ const isSelf = u.id === myId;
+ return (
+
+
+ {u.username}
+ {isSelf ? (
+ (you)
+ ) : null}
+
+
+ {u.is_admin ? (
+
+
+ admin
+
+ ) : (
+
+ member
+
+ )}
+
+
+ onToggle(u.id, v, u.username)}
+ />
+
+
+ {formatDate(u.created_at)}
+
+
+ {!isSelf ? (
+
+ setDeleteTarget({ id: u.id, username: u.username })
+ }
+ >
+
+
+ ) : null}
+
+
+ );
+ })}
+
+
+
+ )}
+
+
!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}
+ />
+
+ );
+}
diff --git a/urapt-web/src/hooks/use-logout.ts b/urapt-web/src/hooks/use-logout.ts
new file mode 100644
index 0000000..33602bf
--- /dev/null
+++ b/urapt-web/src/hooks/use-logout.ts
@@ -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");
+ }
+ };
+}
diff --git a/urapt-web/src/hooks/use-server.ts b/urapt-web/src/hooks/use-server.ts
new file mode 100644
index 0000000..a8f4850
--- /dev/null
+++ b/urapt-web/src/hooks/use-server.ts
@@ -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 });
+}
diff --git a/urapt-web/src/index.css b/urapt-web/src/index.css
new file mode 100644
index 0000000..df4a712
--- /dev/null
+++ b/urapt-web/src/index.css
@@ -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;
+ }
+}
diff --git a/urapt-web/src/lib/api/client.ts b/urapt-web/src/lib/api/client.ts
new file mode 100644
index 0000000..a43166b
--- /dev/null
+++ b/urapt-web/src/lib/api/client.ts
@@ -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 {
+ const token = tokenGetter();
+ return token ? { Authorization: `Bearer ${token}` } : {};
+}
+
+function buildUrl(path: string, params?: Record): 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(res: Response): Promise {
+ 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(path: string, params?: Record): Promise {
+ let res: Response;
+ try {
+ res = await fetch(buildUrl(path, params), {
+ headers: { Accept: "application/json", ...authHeaders() },
+ });
+ } catch {
+ throw NETWORK_ERROR;
+ }
+ return handle(res);
+ },
+
+ async sendJson(
+ method: "POST" | "PATCH" | "PUT" | "DELETE",
+ path: string,
+ body?: unknown,
+ ): Promise {
+ 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(res);
+ },
+
+ async post(path: string, body?: unknown): Promise {
+ return this.sendJson("POST", path, body);
+ },
+
+ async patch(path: string, body?: unknown): Promise {
+ return this.sendJson("PATCH", path, body);
+ },
+
+ async put(path: string, body?: unknown): Promise {
+ return this.sendJson("PUT", path, body);
+ },
+
+ async del(path: string): Promise {
+ return this.sendJson("DELETE", path);
+ },
+
+ /** Multipart upload (file + optional form fields). Returns parsed JSON. */
+ async postForm(
+ path: string,
+ fields: Record,
+ file: File,
+ fileField = "file",
+ ): Promise {
+ 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(res);
+ },
+
+ /** Fetch a non-JSON resource (text, blob) with auth headers attached. */
+ async raw(path: string, init?: RequestInit): Promise {
+ 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 };
diff --git a/urapt-web/src/lib/api/endpoints.ts b/urapt-web/src/lib/api/endpoints.ts
new file mode 100644
index 0000000..ecd911f
--- /dev/null
+++ b/urapt-web/src/lib/api/endpoints.ts
@@ -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("/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("/auth/register", body);
+
+export const login = (body: LoginRequest) => http.post("/auth/login", body);
+
+export const logout = () => http.post("/auth/logout");
+
+export const getMe = () => http.get("/me");
+
+// --- tokens ---
+
+export const listTokens = () => http.get>("/me/tokens");
+
+export const createToken = (body: CreateTokenRequest) =>
+ http.post("/me/tokens", body);
+
+export const revokeToken = (id: string) => http.del(`/me/tokens/${id}`);
+
+// --- repositories ---
+
+export const listRepositories = (params?: ListParams) =>
+ http.get>("/repositories", params);
+
+export const createRepository = (body: CreateRepoRequest) =>
+ http.post("/repositories", body);
+
+export const getRepository = (name: string) =>
+ http.get(`/repositories/${encodeURIComponent(name)}`);
+
+export const updateRepository = (name: string, body: UpdateRepoRequest) =>
+ http.patch(`/repositories/${encodeURIComponent(name)}`, body);
+
+export const deleteRepository = (name: string) =>
+ http.del(`/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(`/repositories/${encodeURIComponent(repo)}/members`);
+
+export const addMember = (repo: string, body: AddMemberRequest) =>
+ http.post(`/repositories/${encodeURIComponent(repo)}/members`, body);
+
+export const updateMember = (repo: string, username: string, body: UpdateMemberRequest) =>
+ http.patch(
+ `/repositories/${encodeURIComponent(repo)}/members/${encodeURIComponent(username)}`,
+ body,
+ );
+
+export const removeMember = (repo: string, username: string) =>
+ http.del(
+ `/repositories/${encodeURIComponent(repo)}/members/${encodeURIComponent(username)}`,
+ );
+
+// --- distributions ---
+
+export const listDistributions = (repo: string) =>
+ http.get(`/repositories/${encodeURIComponent(repo)}/distributions`);
+
+export const createDistribution = (repo: string, body: CreateNamedRequest) =>
+ http.post(
+ `/repositories/${encodeURIComponent(repo)}/distributions`,
+ body,
+ );
+
+export const deleteDistribution = (repo: string, dist: string) =>
+ http.del(
+ `/repositories/${encodeURIComponent(repo)}/distributions/${encodeURIComponent(dist)}`,
+ );
+
+// --- components ---
+
+export const listComponents = (repo: string, dist: string) =>
+ http.get(
+ `/repositories/${encodeURIComponent(repo)}/distributions/${encodeURIComponent(dist)}/components`,
+ );
+
+export const createComponent = (repo: string, dist: string, body: CreateNamedRequest) =>
+ http.post(
+ `/repositories/${encodeURIComponent(repo)}/distributions/${encodeURIComponent(dist)}/components`,
+ body,
+ );
+
+export const deleteComponent = (repo: string, dist: string, comp: string) =>
+ http.del(
+ `/repositories/${encodeURIComponent(repo)}/distributions/${encodeURIComponent(dist)}/components/${encodeURIComponent(comp)}`,
+ );
+
+// --- architectures ---
+
+export const listArchitectures = (repo: string, dist: string) =>
+ http.get(
+ `/repositories/${encodeURIComponent(repo)}/distributions/${encodeURIComponent(dist)}/architectures`,
+ );
+
+export const createArchitecture = (
+ repo: string,
+ dist: string,
+ body: CreateNamedRequest,
+) =>
+ http.post(
+ `/repositories/${encodeURIComponent(repo)}/distributions/${encodeURIComponent(dist)}/architectures`,
+ body,
+ );
+
+export const deleteArchitecture = (repo: string, dist: string, arch: string) =>
+ http.del(
+ `/repositories/${encodeURIComponent(repo)}/distributions/${encodeURIComponent(dist)}/architectures/${encodeURIComponent(arch)}`,
+ );
+
+// --- packages ---
+
+export const listPackages = (repo: string, dist: string, params?: ListParams) =>
+ http.get>(
+ `/repositories/${encodeURIComponent(repo)}/distributions/${encodeURIComponent(dist)}/packages`,
+ params,
+ );
+
+export const pushPackage = (repo: string, dist: string, file: File, component: string) =>
+ http.postForm(
+ `/repositories/${encodeURIComponent(repo)}/distributions/${encodeURIComponent(dist)}/packages`,
+ { component },
+ file,
+ );
+
+export const getPackage = (repo: string, id: string) =>
+ http.get(`/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(`/repositories/${encodeURIComponent(repo)}/packages/${id}`);
+
+// --- users (admin) ---
+
+export const listUsers = (params?: ListParams) =>
+ http.get>("/users", params);
+
+export const getUser = (id: string) => http.get(`/users/${id}`);
+
+export const updateUser = (id: string, body: UpdateUserRequest) =>
+ http.patch(`/users/${id}`, body);
+
+export const deleteUser = (id: string) => http.del(`/users/${id}`);
diff --git a/urapt-web/src/lib/api/errors.test.ts b/urapt-web/src/lib/api/errors.test.ts
new file mode 100644
index 0000000..b31744b
--- /dev/null
+++ b/urapt-web/src/lib/api/errors.test.ts
@@ -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");
+ });
+});
diff --git a/urapt-web/src/lib/api/errors.ts b/urapt-web/src/lib/api/errors.ts
new file mode 100644
index 0000000..65ff4a4
--- /dev/null
+++ b/urapt-web/src/lib/api/errors.ts
@@ -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 {
+ 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");
diff --git a/urapt-web/src/lib/api/index.ts b/urapt-web/src/lib/api/index.ts
new file mode 100644
index 0000000..f40a6e5
--- /dev/null
+++ b/urapt-web/src/lib/api/index.ts
@@ -0,0 +1,4 @@
+export * from "./types";
+export * from "./errors";
+export { http, getApiBase, configureAuth, ApiError } from "./client";
+export * from "./endpoints";
diff --git a/urapt-web/src/lib/api/types.ts b/urapt-web/src/lib/api/types.ts
new file mode 100644
index 0000000..abec77e
--- /dev/null
+++ b/urapt-web/src/lib/api/types.ts
@@ -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 {
+ items: T[];
+ page: number;
+ per_page: number;
+ total: number;
+}
+
+export interface ListParams extends Record {
+ 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;
+ };
+}
diff --git a/urapt-web/src/lib/utils.ts b/urapt-web/src/lib/utils.ts
new file mode 100644
index 0000000..13f18d9
--- /dev/null
+++ b/urapt-web/src/lib/utils.ts
@@ -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 {
+ if (navigator.clipboard?.writeText) {
+ return navigator.clipboard.writeText(text);
+ }
+ return Promise.reject(new Error("clipboard unavailable"));
+}
diff --git a/urapt-web/src/main.tsx b/urapt-web/src/main.tsx
new file mode 100644
index 0000000..c2a145c
--- /dev/null
+++ b/urapt-web/src/main.tsx
@@ -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(
+
+
+ ,
+);
diff --git a/urapt-web/src/providers.tsx b/urapt-web/src/providers.tsx
new file mode 100644
index 0000000..ff2d09f
--- /dev/null
+++ b/urapt-web/src/providers.tsx
@@ -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 (
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/urapt-web/src/routes.tsx b/urapt-web/src/routes.tsx
new file mode 100644
index 0000000..a2c10ac
--- /dev/null
+++ b/urapt-web/src/routes.tsx
@@ -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 = ;
+
+export const router = createBrowserRouter([
+ {
+ path: "/login",
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: "/setup",
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: "/",
+ element: ,
+ children: [
+ { index: true, element: },
+ {
+ path: "repos/:repo",
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: "repos/:repo/:tab",
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: "tokens",
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: "users",
+ element: (
+
+
+
+
+
+ ),
+ },
+ {
+ path: "settings",
+ element: (
+
+
+
+ ),
+ },
+ { path: "*", element: },
+ ],
+ },
+]);
diff --git a/urapt-web/src/stores/auth.ts b/urapt-web/src/stores/auth.ts
new file mode 100644
index 0000000..88465fd
--- /dev/null
+++ b/urapt-web/src/stores/auth.ts
@@ -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()(
+ 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;
+}
diff --git a/urapt-web/src/test/setup.ts b/urapt-web/src/test/setup.ts
new file mode 100644
index 0000000..e262193
--- /dev/null
+++ b/urapt-web/src/test/setup.ts
@@ -0,0 +1,7 @@
+import "@testing-library/jest-dom/vitest";
+import { cleanup } from "@testing-library/react";
+import { afterEach } from "vitest";
+
+afterEach(() => {
+ cleanup();
+});
diff --git a/urapt-web/src/vite-env.d.ts b/urapt-web/src/vite-env.d.ts
new file mode 100644
index 0000000..09abe03
--- /dev/null
+++ b/urapt-web/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/urapt-web/tailwind.config.js b/urapt-web/tailwind.config.js
new file mode 100644
index 0000000..49fea15
--- /dev/null
+++ b/urapt-web/tailwind.config.js
@@ -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")],
+};
diff --git a/urapt-web/tsconfig.app.json b/urapt-web/tsconfig.app.json
new file mode 100644
index 0000000..2376b35
--- /dev/null
+++ b/urapt-web/tsconfig.app.json
@@ -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"]
+}
diff --git a/urapt-web/tsconfig.json b/urapt-web/tsconfig.json
new file mode 100644
index 0000000..2b78387
--- /dev/null
+++ b/urapt-web/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "files": [],
+ "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }],
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ }
+}
diff --git a/urapt-web/tsconfig.node.json b/urapt-web/tsconfig.node.json
new file mode 100644
index 0000000..b3fc13e
--- /dev/null
+++ b/urapt-web/tsconfig.node.json
@@ -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"]
+}
diff --git a/urapt-web/vite.config.ts b/urapt-web/vite.config.ts
new file mode 100644
index 0000000..45c12ac
--- /dev/null
+++ b/urapt-web/vite.config.ts
@@ -0,0 +1,49 @@
+///
+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,
+ },
+});