mirror of
https://github.com/Pacerino/CaddyProxyManager.git
synced 2026-07-22 05:31:41 -04:00
[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:
21
frontend/components.json
Normal file
21
frontend/components.json
Normal 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
12
frontend/index.html
Normal 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
31444
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.8 KiB |
@@ -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 |
@@ -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"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
189
frontend/src/components/HostDialog.tsx
Normal file
189
frontend/src/components/HostDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
64
frontend/src/components/ui/button.tsx
Normal file
64
frontend/src/components/ui/button.tsx
Normal 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 }
|
||||
92
frontend/src/components/ui/card.tsx
Normal file
92
frontend/src/components/ui/card.tsx
Normal 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,
|
||||
}
|
||||
158
frontend/src/components/ui/dialog.tsx
Normal file
158
frontend/src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
25
frontend/src/components/ui/input.tsx
Normal file
25
frontend/src/components/ui/input.tsx
Normal 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 }
|
||||
22
frontend/src/components/ui/label.tsx
Normal file
22
frontend/src/components/ui/label.tsx
Normal 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 }
|
||||
37
frontend/src/components/ui/sonner.tsx
Normal file
37
frontend/src/components/ui/sonner.tsx
Normal 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 }
|
||||
114
frontend/src/components/ui/table.tsx
Normal file
114
frontend/src/components/ui/table.tsx
Normal 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,
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
38
frontend/src/lib/api.ts
Normal 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
70
frontend/src/lib/auth.tsx
Normal 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
20
frontend/src/lib/types.ts
Normal 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;
|
||||
}
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal 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
18
frontend/src/main.tsx
Normal 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>
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
115
frontend/src/pages/Login.tsx
Normal file
115
frontend/src/pages/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
frontend/src/pages/OIDCCallback.tsx
Normal file
27
frontend/src/pages/OIDCCallback.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
frontend/src/react-app-env.d.ts
vendored
1
frontend/src/react-app-env.d.ts
vendored
@@ -1 +0,0 @@
|
||||
/// <reference types="react-scripts" />
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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')
|
||||
]
|
||||
}
|
||||
27
frontend/tsconfig.app.json
Normal file
27
frontend/tsconfig.app.json
Normal 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"]
|
||||
}
|
||||
@@ -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/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
15
frontend/tsconfig.node.json
Normal file
15
frontend/tsconfig.node.json
Normal 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
25
frontend/vite.config.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
});
|
||||
9729
frontend/yarn.lock
9729
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user