[Frontend] Rewrite SPA with Vite, React, TypeScript, Tailwind and shadcn/ui

Replace the Create React App frontend with a modern Vite stack. Adds a
login page (local form or SSO depending on auth mode), an OIDC callback
page, and a hosts page with create/edit/delete. Build output is emitted
into backend/embed/assets for embedding in the binary.
This commit is contained in:
Pacerino
2026-06-14 02:35:41 +02:00
parent 2bc7cd5a98
commit 5e6c6b083c
42 changed files with 5342 additions and 37975 deletions

21
frontend/components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Caddy Proxy Manager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

31444
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,56 +1,42 @@
{
"name": "frontend",
"version": "0.1.0",
"name": "caddyproxymanager-frontend",
"private": true,
"dependencies": {
"@hookform/resolvers": "^2.9.8",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.11.59",
"@types/react": "^18.0.20",
"@types/react-dom": "^18.0.6",
"axios": "^0.27.2",
"flowbite": "^1.5.3",
"flowbite-react": "^0.1.11",
"joi": "^17.6.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.36.0",
"react-router-dom": "^6.4.0",
"react-scripts": "5.0.1",
"typescript": "^4.8.3",
"web-vitals": "^2.1.4"
},
"version": "0.1.0",
"type": "module",
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "tsc -b --noEmit"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-slot": "^1.1.1",
"axios": "^1.7.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.5.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.54.2",
"react-router-dom": "^6.28.1",
"sonner": "^1.7.4",
"tailwind-merge": "^2.6.0",
"tw-animate-css": "^1.2.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/joi": "^17.2.3",
"autoprefixer": "^10.4.11",
"postcss": "^8.4.16",
"tailwindcss": "^3.1.8"
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^22.10.5",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.2",
"vite": "^6.0.7"
}
}

View File

@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -1,43 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -1,25 +0,0 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@@ -1,3 +0,0 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View File

@@ -1,137 +1,35 @@
import Layout from "./components/Layout";
import {
Routes,
Route,
useNavigate,
useLocation,
Navigate,
} from "react-router-dom";
import React from "react";
import { localAuthProvider } from "./auth";
import { TextInput, Button, /* Alert */ } from "flowbite-react";
import { useForm, SubmitHandler } from "react-hook-form";
import { Navigate, Route, Routes } from "react-router-dom";
import { useAuth } from "@/lib/auth";
import { Layout } from "@/components/Layout";
import { LoginPage } from "@/pages/Login";
import { OIDCCallbackPage } from "@/pages/OIDCCallback";
import { HostsPage } from "@/pages/Hosts";
import type { ReactNode } from "react";
// Pages
import HostsPage from "./pages/Hosts";
function RequireAuth({ children }: { children: ReactNode }) {
const { token } = useAuth();
if (!token) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
function App() {
return (
<AuthProvider>
<Routes>
<Route element={<Layout />}>
<Route path="/login" element={<LoginPage />} />
<Route
path="/"
element={
<RequireAuth>
<HostsPage />
</RequireAuth>
}
/>
</Route>
</Routes>
</AuthProvider>
);
}
interface AuthContextType {
user: any;
signin: (mail: string, password: string, callback: VoidFunction) => void;
signout: (callback: VoidFunction) => void;
}
let AuthContext = React.createContext<AuthContextType>(null!);
function AuthProvider({ children }: { children: React.ReactNode }) {
let [user, setUser] = React.useState<any>(null);
let signin = (email: string, password: string, callback: VoidFunction) => {
return localAuthProvider.signin(email, password, () => {
setUser(email);
callback();
});
};
let signout = (callback: VoidFunction) => {
return localAuthProvider.signout(() => {
setUser(null);
callback();
});
};
let value = { user, signin, signout };
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
return React.useContext(AuthContext);
}
function RequireAuth({ children }: { children: JSX.Element }) {
let auth = useAuth();
let location = useLocation();
if (!auth.user) {
// Redirect them to the /login page, but save the current location they were
// trying to go to when they were redirected. This allows us to send them
// along to that page after they login, which is a nicer user experience
// than dropping them off on the home page.
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
interface FormValues {
email: string;
password: string;
}
function LoginPage() {
let navigate = useNavigate();
let location = useLocation();
let from = location.state?.from?.pathname || "/";
let auth = useAuth();
const { register, handleSubmit } = useForm<FormValues>();
const performLogin: SubmitHandler<FormValues> = async (data) => {
auth.signin(data.email, data.password, () => {
navigate(from, { replace: true });
})
};
return (
<form onSubmit={handleSubmit(performLogin)}>
<div className="flex justify-center">
<div className="px-8 pt-6 pb-8 mb-4 flex flex-col w-1/4">
<h1 className="text-white text-4xl pb-8">Please login</h1>
{/* {error && (
<div className="pb-8">
<Alert color="failure">{error}</Alert>
</div>
)} */}
<div className="mb-4">
<TextInput
type="email"
placeholder="name@caddyproxymanager.com"
required={true}
{...register("email", { required: true })}
/>
</div>
<div className="mb-6">
<TextInput
type="password"
required={true}
{...register("password", { required: true })}
/>
</div>
<div className="flex items-center justify-between">
<Button type="submit">Login</Button>
</div>
</div>
</div>
</form>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/login/callback" element={<OIDCCallbackPage />} />
<Route
element={
<RequireAuth>
<Layout />
</RequireAuth>
}
>
<Route path="/" element={<HostsPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}

View File

@@ -1,23 +0,0 @@
import http, { RequestResponse} from "./utils/axios"
const localAuthProvider = {
isAuthenticated: false,
signin(email: string, password: string, callback: VoidFunction) {
http
.post<RequestResponse>("/users/login", {
email: email,
secret: password,
})
.then(result => {
localStorage.setItem("token", result.data.result.token);
callback();
})
},
signout(callback: VoidFunction) {
localAuthProvider.isAuthenticated = false;
localStorage.setItem("token", "");
callback();
},
};
export { localAuthProvider };

View File

@@ -0,0 +1,189 @@
import { useEffect } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { Host } from "@/lib/types";
const schema = z.object({
matcher: z.string().optional(),
domains: z.array(z.object({ value: z.string().min(1) })).min(1),
upstreams: z.array(z.object({ backend: z.string().min(1) })).min(1),
});
export type HostFormValues = z.infer<typeof schema>;
export interface HostPayload {
domains: string;
matcher: string;
upstreams: { backend: string }[];
}
interface HostDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
host: Host | null;
submitting: boolean;
onSubmit: (payload: HostPayload) => void;
}
function toFormValues(host: Host | null): HostFormValues {
if (!host) {
return {
matcher: "",
domains: [{ value: "" }],
upstreams: [{ backend: "" }],
};
}
return {
matcher: host.matcher ?? "",
domains: host.domains
? host.domains.split(/[\s,]+/).filter(Boolean).map((value) => ({ value }))
: [{ value: "" }],
upstreams:
host.Upstreams?.length > 0
? host.Upstreams.map((u) => ({ backend: u.backend }))
: [{ backend: "" }],
};
}
export function HostDialog({
open,
onOpenChange,
host,
submitting,
onSubmit,
}: HostDialogProps) {
const {
register,
handleSubmit,
control,
reset,
formState: { errors },
} = useForm<HostFormValues>({
resolver: zodResolver(schema),
defaultValues: toFormValues(host),
});
useEffect(() => {
reset(toFormValues(host));
}, [host, open, reset]);
const domains = useFieldArray({ control, name: "domains" });
const upstreams = useFieldArray({ control, name: "upstreams" });
const submit = handleSubmit((data) => {
onSubmit({
matcher: data.matcher ?? "",
domains: data.domains.map((d) => d.value).join(" "),
upstreams: data.upstreams,
});
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{host ? "Edit host" : "Add host"}</DialogTitle>
<DialogDescription>
Configure domains, an optional matcher and upstream backends.
</DialogDescription>
</DialogHeader>
<form className="space-y-5" onSubmit={submit}>
<div className="space-y-2">
<Label>Domains</Label>
{domains.fields.map((field, index) => (
<div key={field.id} className="flex gap-2">
<Input
placeholder="example.com"
{...register(`domains.${index}.value` as const)}
/>
<Button
type="button"
variant="ghost"
size="icon"
disabled={domains.fields.length === 1}
onClick={() => domains.remove(index)}
>
<Trash2 className="size-4" />
</Button>
</div>
))}
{errors.domains && (
<p className="text-sm text-destructive">
At least one valid domain is required
</p>
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => domains.append({ value: "" })}
>
<Plus className="size-4" />
Add domain
</Button>
</div>
<div className="space-y-2">
<Label htmlFor="matcher">Matcher (optional)</Label>
<Input id="matcher" placeholder="/api/*" {...register("matcher")} />
</div>
<div className="space-y-2">
<Label>Upstreams</Label>
{upstreams.fields.map((field, index) => (
<div key={field.id} className="flex gap-2">
<Input
placeholder="127.0.0.1:8080"
{...register(`upstreams.${index}.backend` as const)}
/>
<Button
type="button"
variant="ghost"
size="icon"
disabled={upstreams.fields.length === 1}
onClick={() => upstreams.remove(index)}
>
<Trash2 className="size-4" />
</Button>
</div>
))}
{errors.upstreams && (
<p className="text-sm text-destructive">
At least one valid upstream is required
</p>
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => upstreams.append({ backend: "" })}
>
<Plus className="size-4" />
Add upstream
</Button>
</div>
<DialogFooter>
<Button type="submit" disabled={submitting}>
{submitting ? "Saving…" : "Save"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,43 +1,36 @@
import { Navbar, Button } from "flowbite-react";
import { Outlet, useNavigate } from "react-router-dom";
import { useAuth } from "../App";
function Layout() {
const auth = useAuth();
import { LogOut, Server } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/lib/auth";
export function Layout() {
const { logout } = useAuth();
const navigate = useNavigate();
return (
<div>
<Navbar fluid={true} rounded={false}>
<Navbar.Brand href="https://flowbite.com/">
<img
src="https://flowbite.com/docs/images/logo.svg"
className="mr-3 h-6 sm:h-9"
alt="Flowbite Logo"
/>
<span className="self-center whitespace-nowrap text-xl font-semibold dark:text-white">
Caddy Proxy Manager
</span>
</Navbar.Brand>
{auth.user && (
<div className="flex md:order-2">
<Button onClick={() => auth.signout(() => navigate("/"))}>
Logout
</Button>
<Navbar.Toggle />
<div className="min-h-screen bg-background text-foreground">
<header className="border-b border-border">
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
<div className="flex items-center gap-2">
<Server className="size-5" />
<span className="text-lg font-semibold">Caddy Proxy Manager</span>
</div>
)}
<Navbar.Collapse>
{/* <Navbar.Link href="/home">Home</Navbar.Link>
<Navbar.Link href="/hosts">Hosts</Navbar.Link> */}
</Navbar.Collapse>
</Navbar>
<div className="container mx-auto py-4">
<Button
variant="ghost"
size="sm"
onClick={() => {
logout();
navigate("/login");
}}
>
<LogOut className="size-4" />
Logout
</Button>
</div>
</header>
<main className="mx-auto max-w-6xl px-6 py-8">
<Outlet />
</div>
</main>
</div>
);
}
export default Layout;

View File

@@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,25 @@
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}
data-slot="input"
ref={ref}
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,37 @@
"use client"
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
theme="dark"
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View File

@@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -1,3 +1,82 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
* {
border-color: var(--border);
}
body {
background-color: var(--background);
color: var(--foreground);
font-family: ui-sans-serif, system-ui, sans-serif;
}

View File

@@ -1,25 +0,0 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
import { BrowserRouter } from "react-router-dom";
import reportWebVitals from "./reportWebVitals";
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
);
document.body.classList.add('bg-slate-900');
root.render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

38
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,38 @@
import axios from "axios";
// Same-origin in production (served by the Go binary); the Vite dev server
// proxies /api to the backend.
export const api = axios.create({
baseURL: "/api",
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem("token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem("token");
if (window.location.pathname !== "/login") {
window.location.href = "/login";
}
}
return Promise.reject(error);
}
);
// The backend wraps payloads as { result, error }.
export interface ApiEnvelope<T> {
result: T;
error?: { code: number; message: string };
}
export function unwrap<T>(data: ApiEnvelope<T>): T {
return data.result;
}

70
frontend/src/lib/auth.tsx Normal file
View File

@@ -0,0 +1,70 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { api, unwrap, type ApiEnvelope } from "@/lib/api";
import type { AuthConfig } from "@/lib/types";
interface AuthContextValue {
token: string | null;
authConfig: AuthConfig | null;
loadingConfig: boolean;
loginLocal: (email: string, password: string) => Promise<void>;
setToken: (token: string) => void;
logout: () => void;
}
const AuthContext = createContext<AuthContextValue>(null!);
export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setTokenState] = useState<string | null>(() =>
localStorage.getItem("token")
);
const [authConfig, setAuthConfig] = useState<AuthConfig | null>(null);
const [loadingConfig, setLoadingConfig] = useState(true);
useEffect(() => {
api
.get<ApiEnvelope<AuthConfig>>("/auth/config")
.then((res) => setAuthConfig(unwrap(res.data)))
.catch(() => setAuthConfig({ mode: "local" }))
.finally(() => setLoadingConfig(false));
}, []);
const setToken = useCallback((value: string) => {
localStorage.setItem("token", value);
setTokenState(value);
}, []);
const loginLocal = useCallback(
async (email: string, password: string) => {
const res = await api.post<ApiEnvelope<{ token: string }>>(
"/users/login",
{ email, secret: password }
);
setToken(unwrap(res.data).token);
},
[setToken]
);
const logout = useCallback(() => {
localStorage.removeItem("token");
setTokenState(null);
}, []);
const value = useMemo(
() => ({ token, authConfig, loadingConfig, loginLocal, setToken, logout }),
[token, authConfig, loadingConfig, loginLocal, setToken, logout]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
return useContext(AuthContext);
}

20
frontend/src/lib/types.ts Normal file
View File

@@ -0,0 +1,20 @@
export interface Upstream {
ID?: number;
hostId?: number;
backend: string;
}
export interface Host {
ID: number;
CreatedAt: string;
UpdatedAt: string;
domains: string;
matcher: string;
Upstreams: Upstream[];
}
export type AuthMode = "local" | "oidc";
export interface AuthConfig {
mode: AuthMode;
}

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

18
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,18 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import "./index.css";
import App from "./App";
import { AuthProvider } from "@/lib/auth";
import { Toaster } from "@/components/ui/sonner";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
<Toaster />
</AuthProvider>
</BrowserRouter>
</StrictMode>
);

View File

@@ -1,300 +1,170 @@
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { Pencil, Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Table,
Button,
Modal,
TextInput,
Label,
Spinner,
} from "flowbite-react";
import { useForm, useFieldArray, SubmitHandler } from "react-hook-form";
import { joiResolver } from "@hookform/resolvers/joi";
import Joi from "joi";
import { HiAdjustments, HiTrash, HiDocumentAdd } from "react-icons/hi";
import React from "react";
import http, { RequestResponse } from "../utils/axios";
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api, unwrap, type ApiEnvelope } from "@/lib/api";
import type { Host } from "@/lib/types";
import { HostDialog, type HostPayload } from "@/components/HostDialog";
type Domain = {
fqdn: string;
};
export function HostsPage() {
const [hosts, setHosts] = useState<Host[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<Host | null>(null);
const [submitting, setSubmitting] = useState(false);
type UpstreamForm = {
backend: string;
};
type FormValues = {
domains: Domain[];
upstreams: UpstreamForm[];
matcher: string | undefined;
};
interface Upstream {
ID: number;
CreatedAt: string;
UpdatedAt: string;
DeletedAt?: any;
hostId: number;
backend: string;
}
interface Hosts {
ID: number;
CreatedAt: string;
UpdatedAt: string;
DeletedAt?: any;
domains: string;
matcher: string;
Upstreams: Upstream[];
}
interface RootObject {
result: Hosts[];
}
function HostsPage() {
const [modal, setModal] = React.useState(false);
const [hostData, setHostData] = React.useState<RootObject>();
const [loading, setLoading] = React.useState(false);
const schema = Joi.object<FormValues>({
matcher: Joi.any().optional(),
upstreams: Joi.array().items(
Joi.object().keys({
backend: Joi.string().required(),
})
),
domains: Joi.array().items(
Joi.object().keys({
fqdn: Joi.string().required(),
})
),
});
const {
register,
handleSubmit,
control,
formState: { errors },
} = useForm({
resolver: joiResolver(schema),
defaultValues: {
matcher: "",
domains: [{ fqdn: "" }],
upstreams: [{ backend: "" }],
},
});
const {
fields: upstreamFields,
append: upstreamAppend,
remove: upstreamRemove,
} = useFieldArray({
control,
name: "upstreams",
});
const {
fields: domainFields,
append: domainAppend,
remove: domainRemove,
} = useFieldArray({
control,
name: "domains",
});
React.useEffect(() => {
getHosts();
const loadHosts = useCallback(async () => {
setLoading(true);
try {
const res = await api.get<ApiEnvelope<Host[]>>("/hosts");
setHosts(unwrap(res.data) ?? []);
} catch {
toast.error("Failed to load hosts");
} finally {
setLoading(false);
}
}, []);
const getHosts = async () => {
return await http.get<RequestResponse>(`/hosts`)
.then((res) => {
setHostData(res.data);
});
useEffect(() => {
loadHosts();
}, [loadHosts]);
const openCreate = () => {
setEditing(null);
setDialogOpen(true);
};
const createHost: SubmitHandler<FormValues> = async (data) => {
const jsonBody = {
matcher: data.matcher,
domains: data.domains
.map((e) => {
return e.fqdn;
})
.join(","),
upstreams: data.upstreams,
};
setLoading(true);
await http.post(`hosts`, jsonBody);
setLoading(false);
setModal(false);
getHosts();
const openEdit = (host: Host) => {
setEditing(host);
setDialogOpen(true);
};
const deleteHost = async (hostID: number) => {
await http.delete(`/hosts/${hostID}`);
getHosts();
const saveHost = async (payload: HostPayload) => {
setSubmitting(true);
try {
if (editing) {
await api.put("/hosts", {
...editing,
domains: payload.domains,
matcher: payload.matcher,
Upstreams: payload.upstreams.map((u) => ({ backend: u.backend })),
});
toast.success("Host updated");
} else {
await api.post("/hosts", {
domains: payload.domains,
matcher: payload.matcher,
Upstreams: payload.upstreams,
});
toast.success("Host created");
}
setDialogOpen(false);
loadHosts();
} catch {
toast.error("Failed to save host");
} finally {
setSubmitting(false);
}
};
const deleteHost = async (id: number) => {
try {
await api.delete(`/hosts/${id}`);
toast.success("Host deleted");
loadHosts();
} catch {
toast.error("Failed to delete host");
}
};
return (
<>
<div className="pb-4 bg-white dark:bg-gray-900">
<Button size="xs" color="gray" onClick={() => setModal(true)}>
<HiDocumentAdd className="mr-3 h-4 w-4" /> Add Host
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">Hosts</h1>
<p className="text-sm text-muted-foreground">
Manage your reverse proxy hosts.
</p>
</div>
<Button onClick={openCreate}>
<Plus className="size-4" />
Add host
</Button>
</div>
<Table>
<Table.Head>
<Table.HeadCell>ID</Table.HeadCell>
<Table.HeadCell>Created</Table.HeadCell>
<Table.HeadCell>Updated</Table.HeadCell>
<Table.HeadCell>Domains</Table.HeadCell>
<Table.HeadCell>Matcher</Table.HeadCell>
<Table.HeadCell>Upstreams</Table.HeadCell>
<Table.HeadCell>
<span className="sr-only">Edit</span>
</Table.HeadCell>
</Table.Head>
<Table.Body className="divide-y">
{hostData?.result.map((entry, i) => {
return (
<Table.Row
key={entry.ID}
className="bg-white dark:border-gray-700 dark:bg-gray-800"
>
<Table.Cell>{entry.ID}</Table.Cell>
<Table.Cell>{entry.CreatedAt}</Table.Cell>
<Table.Cell>{entry.UpdatedAt}</Table.Cell>
<Table.Cell>{entry.domains}</Table.Cell>
<Table.Cell>{entry.matcher}</Table.Cell>
<Table.Cell>{entry.Upstreams[0].backend}</Table.Cell>
<Table.Cell>
<Button.Group>
<Button size="xs" color="gray">
<HiAdjustments className="mr-3 h-4 w-4" /> Edit
</Button>
<Button
size="xs"
color="gray"
onClick={() => deleteHost(entry.ID)}
>
<HiTrash className="mr-3 h-4 w-4" /> Delete
</Button>
</Button.Group>
</Table.Cell>
</Table.Row>
);
})}
</Table.Body>
</Table>
<Modal show={modal} size="xl" onClose={() => setModal(false)}>
<Modal.Header>Add a new Host</Modal.Header>
<Modal.Body>
<form
className="flex flex-col gap-4"
onSubmit={handleSubmit(createHost)}
>
<div className="space-y-2">
<div className="mb-2 block">
<Label htmlFor="domain" value="Domain" />
</div>
<ul className="space-y-2">
{domainFields.map((field, index) => {
return (
<li key={field.id}>
<TextInput
type="text"
placeholder="example.com"
id={`domains.${index}.fqdn`}
key={field.id}
{...register(`domains.${index}.fqdn` as const)}
addon={
index > 0 && (
<Button size="xs" color="gray" onClick={() => domainRemove(index)}>
Delete
</Button>
)
}
helperText={
errors.domains?.[index] && (
<span>Please enter a valid FQDN</span>
)
}
/>
</li>
);
})}
</ul>
<div className="mb-2 block">
<Label value="Matcher" />
</div>
<TextInput
type="text"
id="matcher"
placeholder="/api/*"
{...register("matcher", { required: false })}
/>
<div className="mb-2 block">
<Label value="Upstreams" />
</div>
<ul className="space-y-2">
{upstreamFields.map((field, index) => {
return (
<li key={field.id}>
<TextInput
type="text"
placeholder="127.0.0.1:8080"
id={`upstreams.${index}.backend`}
key={field.id}
{...register(`upstreams.${index}.backend` as const)}
addon={
index > 0 && (
<Button size="xs" color="gray" onClick={() => upstreamRemove(index)}>
Delete
</Button>
)
}
helperText={
errors.upstreams?.[index] && (
<span>Please enter a valid IP Address</span>
)
}
/>
</li>
);
})}
</ul>
</div>
<Button.Group>
{loading ? (
<Button disabled={true}>
<div className="mr-3">
<Spinner size="sm" light={true} />
</div>
Loading ...
</Button>
) : (
<Button type="submit">Save</Button>
)}
<div className="rounded-lg border border-border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">ID</TableHead>
<TableHead>Domains</TableHead>
<TableHead>Matcher</TableHead>
<TableHead>Upstreams</TableHead>
<TableHead className="w-28 text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
Loading
</TableCell>
</TableRow>
) : hosts.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
No hosts yet.
</TableCell>
</TableRow>
) : (
hosts.map((host) => (
<TableRow key={host.ID}>
<TableCell>{host.ID}</TableCell>
<TableCell className="font-medium">{host.domains}</TableCell>
<TableCell>{host.matcher || "—"}</TableCell>
<TableCell>
{host.Upstreams?.map((u) => u.backend).join(", ") || "—"}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => openEdit(host)}
>
<Pencil className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => deleteHost(host.ID)}
>
<Trash2 className="size-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<Button
disabled={loading}
type="button"
onClick={() => domainAppend({ fqdn: "" })}
>
Add Domain
</Button>
<Button
disabled={loading}
type="button"
onClick={() => upstreamAppend({ backend: "" })}
>
Add Upstream
</Button>
</Button.Group>
</form>
</Modal.Body>
</Modal>
</>
<HostDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
host={editing}
submitting={submitting}
onSubmit={saveHost}
/>
</div>
);
}
export default HostsPage;

View File

@@ -0,0 +1,115 @@
import { useState } from "react";
import { Navigate, useNavigate, useSearchParams } from "react-router-dom";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { toast } from "sonner";
import { KeyRound, LogIn } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useAuth } from "@/lib/auth";
const schema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
type FormValues = z.infer<typeof schema>;
export function LoginPage() {
const { token, authConfig, loadingConfig, loginLocal } = useAuth();
const navigate = useNavigate();
const [params] = useSearchParams();
const [submitting, setSubmitting] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({ resolver: zodResolver(schema) });
if (token) {
return <Navigate to="/" replace />;
}
const error = params.get("error");
if (error) {
toast.error(error);
}
const onSubmit = handleSubmit(async (data) => {
setSubmitting(true);
try {
await loginLocal(data.email, data.password);
navigate("/");
} catch {
toast.error("Login incorrect");
} finally {
setSubmitting(false);
}
});
return (
<div className="flex min-h-screen items-center justify-center px-4">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Sign in</CardTitle>
<CardDescription>Access your Caddy Proxy Manager</CardDescription>
</CardHeader>
<CardContent>
{loadingConfig ? (
<p className="text-sm text-muted-foreground">Loading</p>
) : authConfig?.mode === "oidc" ? (
<Button
className="w-full"
onClick={() => {
window.location.href = "/api/auth/oidc/login";
}}
>
<KeyRound className="size-4" />
Login with SSO
</Button>
) : (
<form className="space-y-4" onSubmit={onSubmit}>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="admin@example.com"
{...register("email")}
/>
{errors.email && (
<p className="text-sm text-destructive">
Enter a valid email
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" {...register("password")} />
{errors.password && (
<p className="text-sm text-destructive">
Password is required
</p>
)}
</div>
<Button type="submit" className="w-full" disabled={submitting}>
<LogIn className="size-4" />
{submitting ? "Signing in…" : "Sign in"}
</Button>
</form>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "@/lib/auth";
// The backend redirects here with the CPM token in the URL fragment
// (#token=...). We store it and move into the app.
export function OIDCCallbackPage() {
const { setToken } = useAuth();
const navigate = useNavigate();
useEffect(() => {
const hash = window.location.hash.replace(/^#/, "");
const token = new URLSearchParams(hash).get("token");
if (token) {
setToken(token);
navigate("/", { replace: true });
} else {
navigate("/login?error=login%20failed", { replace: true });
}
}, [setToken, navigate]);
return (
<div className="flex min-h-screen items-center justify-center text-muted-foreground">
Signing you in
</div>
);
}

View File

@@ -1 +0,0 @@
/// <reference types="react-scripts" />

View File

@@ -1,15 +0,0 @@
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View File

@@ -1,5 +0,0 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

View File

@@ -1,37 +0,0 @@
import axios, { AxiosRequestConfig } from "axios";
export interface RequestResponse {
result: any;
error: Error;
}
interface Error {
code: number;
message: string;
}
axios.interceptors.request.use((config: AxiosRequestConfig) => {
if (!config) {
config = {};
}
if (!config.headers) {
config.headers = {};
}
const token = localStorage.getItem("token");
if (token) {
config.headers.authorization = `Bearer ${token}`;
}
config.baseURL = "http://localhost:3001/api/";
return config;
});
const methods = {
get: axios.get,
post: axios.post,
put: axios.put,
delete: axios.delete,
patch: axios.patch,
};
export default methods;

View File

@@ -1,15 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
'node_modules/flowbite-react/**/*.{js,jsx,ts,tsx}',
"./components/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
darkMode: 'media',
plugins: [
require('flowbite/plugin')
]
}

View File

@@ -0,0 +1,27 @@
{
"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,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

View File

@@ -1,26 +1,13 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true
},
"include": ["vite.config.ts"]
}

25
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,25 @@
import path from "node:path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
build: {
// Build straight into the Go embed directory so the binary picks it up.
outDir: "../backend/embed/assets",
emptyOutDir: true,
},
server: {
proxy: {
// Proxy API calls to the Go backend during development.
"/api": "http://localhost:3001",
},
},
});

File diff suppressed because it is too large Load Diff