diff --git a/app/(dashboard)/dead-hosts/DeadHostsClient.tsx b/app/(dashboard)/dead-hosts/DeadHostsClient.tsx index 485d6b78..51585ab3 100644 --- a/app/(dashboard)/dead-hosts/DeadHostsClient.tsx +++ b/app/(dashboard)/dead-hosts/DeadHostsClient.tsx @@ -12,18 +12,23 @@ import { Chip, FormControlLabel, Stack, + Switch, TextField, Typography, Checkbox } from "@mui/material"; import type { DeadHost } from "@/src/lib/models/dead-hosts"; -import { createDeadHostAction, deleteDeadHostAction, updateDeadHostAction } from "./actions"; +import { createDeadHostAction, deleteDeadHostAction, updateDeadHostAction, toggleDeadHostAction } from "./actions"; type Props = { hosts: DeadHost[]; }; export default function DeadHostsClient({ hosts }: Props) { + const handleToggleEnabled = async (id: number, enabled: boolean) => { + await toggleDeadHostAction(id, enabled); + }; + return ( @@ -46,10 +51,10 @@ export default function DeadHostsClient({ hosts }: Props) { {host.domains.join(", ")} - handleToggleEnabled(host.id, e.target.checked)} + color="success" /> diff --git a/app/(dashboard)/dead-hosts/actions.ts b/app/(dashboard)/dead-hosts/actions.ts index 0e22f171..abe9a62b 100644 --- a/app/(dashboard)/dead-hosts/actions.ts +++ b/app/(dashboard)/dead-hosts/actions.ts @@ -3,6 +3,7 @@ import { revalidatePath } from "next/cache"; import { requireAdmin } from "@/src/lib/auth"; import { createDeadHost, deleteDeadHost, updateDeadHost } from "@/src/lib/models/dead-hosts"; +import { actionSuccess, actionError, type ActionState } from "@/src/lib/actions"; function parseDomains(value: FormDataEntryValue | null): string[] { if (!value || typeof value !== "string") { @@ -54,3 +55,15 @@ export async function deleteDeadHostAction(id: number) { await deleteDeadHost(id, userId); revalidatePath("/dead-hosts"); } + +export async function toggleDeadHostAction(id: number, enabled: boolean): Promise { + try { + const session = await requireAdmin(); + const userId = Number(session.user.id); + await updateDeadHost(id, { enabled }, userId); + revalidatePath("/dead-hosts"); + return actionSuccess(`Dead host ${enabled ? "enabled" : "disabled"}.`); + } catch (error) { + return actionError(error, "Failed to toggle dead host"); + } +} diff --git a/app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx b/app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx index 68dc8519..252f35b3 100644 --- a/app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx +++ b/app/(dashboard)/proxy-hosts/ProxyHostsClient.tsx @@ -37,13 +37,15 @@ import { useFormState } from "react-dom"; import type { AccessList } from "@/src/lib/models/access-lists"; import type { Certificate } from "@/src/lib/models/certificates"; import type { ProxyHost } from "@/src/lib/models/proxy-hosts"; +import type { AuthentikSettings } from "@/src/lib/settings"; import { INITIAL_ACTION_STATE, type ActionState } from "@/src/lib/actions"; -import { createProxyHostAction, deleteProxyHostAction, updateProxyHostAction } from "./actions"; +import { createProxyHostAction, deleteProxyHostAction, updateProxyHostAction, toggleProxyHostAction } from "./actions"; type Props = { hosts: ProxyHost[]; certificates: Certificate[]; accessLists: AccessList[]; + authentikDefaults: AuthentikSettings | null; }; const AUTHENTIK_DEFAULT_HEADERS = [ @@ -63,11 +65,15 @@ const AUTHENTIK_DEFAULT_HEADERS = [ const AUTHENTIK_DEFAULT_TRUSTED_PROXIES = ["private_ranges"]; -export default function ProxyHostsClient({ hosts, certificates, accessLists }: Props) { +export default function ProxyHostsClient({ hosts, certificates, accessLists, authentikDefaults }: Props) { const [createOpen, setCreateOpen] = useState(false); const [editHost, setEditHost] = useState(null); const [deleteHost, setDeleteHost] = useState(null); + const handleToggleEnabled = async (id: number, enabled: boolean) => { + await toggleProxyHostAction(id, enabled); + }; + return ( @@ -161,16 +167,17 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists }: P - handleToggleEnabled(host.id, e.target.checked)} size="small" sx={{ - bgcolor: host.enabled ? "rgba(34, 197, 94, 0.15)" : "rgba(148, 163, 184, 0.15)", - color: host.enabled ? "rgba(34, 197, 94, 1)" : "rgba(148, 163, 184, 0.8)", - border: "1px solid", - borderColor: host.enabled ? "rgba(34, 197, 94, 0.3)" : "rgba(148, 163, 184, 0.3)", - fontWeight: 500, - fontSize: "0.75rem" + "& .MuiSwitch-switchBase.Mui-checked": { + color: "rgba(34, 197, 94, 1)" + }, + "& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track": { + backgroundColor: "rgba(34, 197, 94, 0.5)" + } }} /> @@ -215,6 +222,7 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists }: P onClose={() => setCreateOpen(false)} certificates={certificates} accessLists={accessLists} + authentikDefaults={authentikDefaults} /> {editHost && ( @@ -242,12 +250,14 @@ function CreateHostDialog({ open, onClose, certificates, - accessLists + accessLists, + authentikDefaults }: { open: boolean; onClose: () => void; certificates: Certificate[]; accessLists: AccessList[]; + authentikDefaults: AuthentikSettings | null; }) { const [state, formAction] = useFormState(createProxyHostAction, INITIAL_ACTION_STATE); @@ -347,7 +357,7 @@ function CreateHostDialog({ minRows={3} fullWidth /> - + @@ -569,7 +579,13 @@ function DeleteHostDialog({ ); } -function AuthentikFields({ authentik }: { authentik?: ProxyHost["authentik"] | null }) { +function AuthentikFields({ + authentik, + defaults +}: { + authentik?: ProxyHost["authentik"] | null; + defaults?: AuthentikSettings | null; +}) { const initial = authentik ?? null; const [enabled, setEnabled] = useState(initial?.enabled ?? false); @@ -615,7 +631,7 @@ function AuthentikFields({ authentik }: { authentik?: ProxyHost["authentik"] | n name="authentik_outpost_domain" label="Outpost Domain" placeholder="outpost.goauthentik.io" - defaultValue={initial?.outpostDomain ?? ""} + defaultValue={initial?.outpostDomain ?? defaults?.outpostDomain ?? ""} required={enabled} disabled={!enabled} size="small" @@ -625,7 +641,7 @@ function AuthentikFields({ authentik }: { authentik?: ProxyHost["authentik"] | n name="authentik_outpost_upstream" label="Outpost Upstream URL" placeholder="https://outpost.internal:9000" - defaultValue={initial?.outpostUpstream ?? ""} + defaultValue={initial?.outpostUpstream ?? defaults?.outpostUpstream ?? ""} required={enabled} disabled={!enabled} size="small" @@ -635,7 +651,7 @@ function AuthentikFields({ authentik }: { authentik?: ProxyHost["authentik"] | n name="authentik_auth_endpoint" label="Auth Endpoint (Optional)" placeholder="/outpost.goauthentik.io/auth/caddy" - defaultValue={initial?.authEndpoint ?? ""} + defaultValue={initial?.authEndpoint ?? defaults?.authEndpoint ?? ""} disabled={!enabled} size="small" fullWidth diff --git a/app/(dashboard)/proxy-hosts/actions.ts b/app/(dashboard)/proxy-hosts/actions.ts index 15012ec8..df15d6d4 100644 --- a/app/(dashboard)/proxy-hosts/actions.ts +++ b/app/(dashboard)/proxy-hosts/actions.ts @@ -166,3 +166,19 @@ export async function deleteProxyHostAction( return actionError(error, "Failed to delete proxy host. Please check the logs for details."); } } + +export async function toggleProxyHostAction( + id: number, + enabled: boolean +): Promise { + try { + const session = await requireAdmin(); + const userId = Number(session.user.id); + await updateProxyHost(id, { enabled }, userId); + revalidatePath("/proxy-hosts"); + return actionSuccess(`Proxy host ${enabled ? "enabled" : "disabled"}.`); + } catch (error) { + console.error(`Failed to toggle proxy host ${id}:`, error); + return actionError(error, "Failed to toggle proxy host. Please check the logs for details."); + } +} diff --git a/app/(dashboard)/proxy-hosts/page.tsx b/app/(dashboard)/proxy-hosts/page.tsx index 566f9220..8430606b 100644 --- a/app/(dashboard)/proxy-hosts/page.tsx +++ b/app/(dashboard)/proxy-hosts/page.tsx @@ -2,13 +2,15 @@ import ProxyHostsClient from "./ProxyHostsClient"; import { listProxyHosts } from "@/src/lib/models/proxy-hosts"; import { listCertificates } from "@/src/lib/models/certificates"; import { listAccessLists } from "@/src/lib/models/access-lists"; +import { getAuthentikSettings } from "@/src/lib/settings"; export default async function ProxyHostsPage() { - const [hosts, certificates, accessLists] = await Promise.all([ + const [hosts, certificates, accessLists, authentikDefaults] = await Promise.all([ listProxyHosts(), listCertificates(), - listAccessLists() + listAccessLists(), + getAuthentikSettings() ]); - return ; + return ; } diff --git a/app/(dashboard)/redirects/RedirectsClient.tsx b/app/(dashboard)/redirects/RedirectsClient.tsx index 4faa3f70..814a4c3a 100644 --- a/app/(dashboard)/redirects/RedirectsClient.tsx +++ b/app/(dashboard)/redirects/RedirectsClient.tsx @@ -16,6 +16,7 @@ import { FormControlLabel, IconButton, Stack, + Switch, Table, TableBody, TableCell, @@ -33,7 +34,7 @@ import CloseIcon from "@mui/icons-material/Close"; import { useFormState } from "react-dom"; import type { RedirectHost } from "@/src/lib/models/redirect-hosts"; import { INITIAL_ACTION_STATE } from "@/src/lib/actions"; -import { createRedirectAction, deleteRedirectAction, updateRedirectAction } from "./actions"; +import { createRedirectAction, deleteRedirectAction, updateRedirectAction, toggleRedirectAction } from "./actions"; type Props = { redirects: RedirectHost[]; @@ -44,6 +45,10 @@ export default function RedirectsClient({ redirects }: Props) { const [editRedirect, setEditRedirect] = useState(null); const [deleteRedirect, setDeleteRedirect] = useState(null); + const handleToggleEnabled = async (id: number, enabled: boolean) => { + await toggleRedirectAction(id, enabled); + }; + return ( @@ -137,16 +142,17 @@ export default function RedirectsClient({ redirects }: Props) { /> - handleToggleEnabled(redirect.id, e.target.checked)} size="small" sx={{ - bgcolor: redirect.enabled ? "rgba(34, 197, 94, 0.15)" : "rgba(148, 163, 184, 0.15)", - color: redirect.enabled ? "rgba(34, 197, 94, 1)" : "rgba(148, 163, 184, 0.8)", - border: "1px solid", - borderColor: redirect.enabled ? "rgba(34, 197, 94, 0.3)" : "rgba(148, 163, 184, 0.3)", - fontWeight: 500, - fontSize: "0.75rem" + "& .MuiSwitch-switchBase.Mui-checked": { + color: "rgba(34, 197, 94, 1)" + }, + "& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track": { + backgroundColor: "rgba(34, 197, 94, 0.5)" + } }} /> diff --git a/app/(dashboard)/redirects/actions.ts b/app/(dashboard)/redirects/actions.ts index 6d68fb76..54fd5f5f 100644 --- a/app/(dashboard)/redirects/actions.ts +++ b/app/(dashboard)/redirects/actions.ts @@ -72,3 +72,15 @@ export async function deleteRedirectAction(id: number, _prevState: ActionState): return actionError(error, "Failed to delete redirect"); } } + +export async function toggleRedirectAction(id: number, enabled: boolean): Promise { + try { + const session = await requireAdmin(); + const userId = Number(session.user.id); + await updateRedirectHost(id, { enabled }, userId); + revalidatePath("/redirects"); + return actionSuccess(`Redirect ${enabled ? "enabled" : "disabled"}.`); + } catch (error) { + return actionError(error, "Failed to toggle redirect"); + } +} diff --git a/app/(dashboard)/settings/SettingsClient.tsx b/app/(dashboard)/settings/SettingsClient.tsx index 4903960e..00251b73 100644 --- a/app/(dashboard)/settings/SettingsClient.tsx +++ b/app/(dashboard)/settings/SettingsClient.tsx @@ -2,10 +2,11 @@ import { useFormState } from "react-dom"; import { Alert, Box, Button, Card, CardContent, Checkbox, FormControlLabel, Stack, TextField, Typography } from "@mui/material"; -import type { GeneralSettings } from "@/src/lib/settings"; +import type { GeneralSettings, AuthentikSettings } from "@/src/lib/settings"; import { updateCloudflareSettingsAction, - updateGeneralSettingsAction + updateGeneralSettingsAction, + updateAuthentikSettingsAction } from "./actions"; type Props = { @@ -15,11 +16,13 @@ type Props = { zoneId?: string; accountId?: string; }; + authentik: AuthentikSettings | null; }; -export default function SettingsClient({ general, cloudflare }: Props) { +export default function SettingsClient({ general, cloudflare, authentik }: Props) { const [generalState, generalFormAction] = useFormState(updateGeneralSettingsAction, null); const [cloudflareState, cloudflareFormAction] = useFormState(updateCloudflareSettingsAction, null); + const [authentikState, authentikFormAction] = useFormState(updateAuthentikSettingsAction, null); return ( @@ -106,6 +109,55 @@ export default function SettingsClient({ general, cloudflare }: Props) { + + + + + Authentik Defaults + + + Set default Authentik forward authentication values. These will be pre-filled when creating new proxy hosts but can be customized per host. + + + {authentikState?.message && ( + + {authentikState.message} + + )} + + + + + + + + + ); } diff --git a/app/(dashboard)/settings/actions.ts b/app/(dashboard)/settings/actions.ts index 0979cad3..5e2947fb 100644 --- a/app/(dashboard)/settings/actions.ts +++ b/app/(dashboard)/settings/actions.ts @@ -3,7 +3,7 @@ import { revalidatePath } from "next/cache"; import { requireAdmin } from "@/src/lib/auth"; import { applyCaddyConfig } from "@/src/lib/caddy"; -import { getCloudflareSettings, saveCloudflareSettings, saveGeneralSettings } from "@/src/lib/settings"; +import { getCloudflareSettings, saveCloudflareSettings, saveGeneralSettings, saveAuthentikSettings } from "@/src/lib/settings"; type ActionResult = { success: boolean; @@ -61,3 +61,28 @@ export async function updateCloudflareSettingsAction(_prevState: ActionResult | return { success: false, message: error instanceof Error ? error.message : "Failed to save Cloudflare settings" }; } } + +export async function updateAuthentikSettingsAction(_prevState: ActionResult | null, formData: FormData): Promise { + try { + await requireAdmin(); + const outpostDomain = String(formData.get("outpostDomain") ?? "").trim(); + const outpostUpstream = String(formData.get("outpostUpstream") ?? "").trim(); + const authEndpoint = formData.get("authEndpoint") ? String(formData.get("authEndpoint")).trim() : undefined; + + if (!outpostDomain || !outpostUpstream) { + return { success: false, message: "Outpost domain and upstream are required" }; + } + + await saveAuthentikSettings({ + outpostDomain, + outpostUpstream, + authEndpoint: authEndpoint && authEndpoint.length > 0 ? authEndpoint : undefined + }); + + revalidatePath("/settings"); + return { success: true, message: "Authentik defaults saved successfully" }; + } catch (error) { + console.error("Failed to save Authentik settings:", error); + return { success: false, message: error instanceof Error ? error.message : "Failed to save Authentik settings" }; + } +} diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index a72d292a..c2c30fa3 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -1,13 +1,14 @@ import SettingsClient from "./SettingsClient"; -import { getCloudflareSettings, getGeneralSettings } from "@/src/lib/settings"; +import { getCloudflareSettings, getGeneralSettings, getAuthentikSettings } from "@/src/lib/settings"; import { requireAdmin } from "@/src/lib/auth"; export default async function SettingsPage() { await requireAdmin(); - const [general, cloudflare] = await Promise.all([ + const [general, cloudflare, authentik] = await Promise.all([ getGeneralSettings(), - getCloudflareSettings() + getCloudflareSettings(), + getAuthentikSettings() ]); return ( @@ -18,6 +19,7 @@ export default async function SettingsPage() { zoneId: cloudflare?.zoneId, accountId: cloudflare?.accountId }} + authentik={authentik} /> ); } diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 3ee4fb3e..ae2556eb 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -15,6 +15,12 @@ export type GeneralSettings = { acmeEmail?: string; }; +export type AuthentikSettings = { + outpostDomain: string; + outpostUpstream: string; + authEndpoint?: string; +}; + export async function getSetting(key: string): Promise> { const setting = await db.query.settings.findFirst({ where: (table, { eq }) => eq(table.key, key) @@ -67,3 +73,11 @@ export async function getGeneralSettings(): Promise { export async function saveGeneralSettings(settings: GeneralSettings): Promise { await setSetting("general", settings); } + +export async function getAuthentikSettings(): Promise { + return await getSetting("authentik"); +} + +export async function saveAuthentikSettings(settings: AuthentikSettings): Promise { + await setSetting("authentik", settings); +}