tr]:last:border-b-0",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+ return (
+
+ )
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+ return (
+ [role=checkbox]]:translate-y-[2px]",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+ return (
+ | [role=checkbox]]:translate-y-[2px]",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableCaption({
+ className,
+ ...props
+}: React.ComponentProps<"caption">) {
+ return (
+
+ )
+}
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index b5c61c9..d53c8ae 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -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;
+}
diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx
deleted file mode 100644
index c2a155a..0000000
--- a/frontend/src/index.tsx
+++ /dev/null
@@ -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(
-
-
-
-
-
-);
-
-// 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();
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
new file mode 100644
index 0000000..e9c1fcf
--- /dev/null
+++ b/frontend/src/lib/api.ts
@@ -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 {
+ result: T;
+ error?: { code: number; message: string };
+}
+
+export function unwrap(data: ApiEnvelope): T {
+ return data.result;
+}
diff --git a/frontend/src/lib/auth.tsx b/frontend/src/lib/auth.tsx
new file mode 100644
index 0000000..ddb916a
--- /dev/null
+++ b/frontend/src/lib/auth.tsx
@@ -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;
+ setToken: (token: string) => void;
+ logout: () => void;
+}
+
+const AuthContext = createContext(null!);
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+ const [token, setTokenState] = useState(() =>
+ localStorage.getItem("token")
+ );
+ const [authConfig, setAuthConfig] = useState(null);
+ const [loadingConfig, setLoadingConfig] = useState(true);
+
+ useEffect(() => {
+ api
+ .get>("/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>(
+ "/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 {children};
+}
+
+export function useAuth() {
+ return useContext(AuthContext);
+}
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
new file mode 100644
index 0000000..a55549a
--- /dev/null
+++ b/frontend/src/lib/types.ts
@@ -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;
+}
diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts
new file mode 100644
index 0000000..a5ef193
--- /dev/null
+++ b/frontend/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..8bb1234
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -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(
+
+
+
+
+
+
+
+
+);
diff --git a/frontend/src/pages/Hosts.tsx b/frontend/src/pages/Hosts.tsx
index fdbb432..5fee4d8 100644
--- a/frontend/src/pages/Hosts.tsx
+++ b/frontend/src/pages/Hosts.tsx
@@ -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([]);
+ const [loading, setLoading] = useState(true);
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [editing, setEditing] = useState(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();
- const [loading, setLoading] = React.useState(false);
-
- const schema = Joi.object({
- 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>("/hosts");
+ setHosts(unwrap(res.data) ?? []);
+ } catch {
+ toast.error("Failed to load hosts");
+ } finally {
+ setLoading(false);
+ }
}, []);
- const getHosts = async () => {
- return await http.get(`/hosts`)
- .then((res) => {
- setHostData(res.data);
- });
+ useEffect(() => {
+ loadHosts();
+ }, [loadHosts]);
+
+ const openCreate = () => {
+ setEditing(null);
+ setDialogOpen(true);
};
- const createHost: SubmitHandler = 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 (
- <>
-
- |