Now when users configure Authentik defaults in Settings, those values will automatically pre-fill when creating new proxy hosts, but can still be customized per host

also allow instant enable/disable of hosts directly from the table/list views without needing to edit each host
This commit is contained in:
fuomag9
2025-11-08 14:15:55 +01:00
parent b17ae54fbd
commit 70c5fa831c
11 changed files with 203 additions and 40 deletions
+10 -5
View File
@@ -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 (
<Stack spacing={4} sx={{ width: "100%" }}>
<Stack spacing={1}>
@@ -46,10 +51,10 @@ export default function DeadHostsClient({ hosts }: Props) {
{host.domains.join(", ")}
</Typography>
</Box>
<Chip
label={host.enabled ? "Enabled" : "Disabled"}
color={host.enabled ? "success" : "warning"}
variant={host.enabled ? "filled" : "outlined"}
<Switch
checked={host.enabled}
onChange={(e) => handleToggleEnabled(host.id, e.target.checked)}
color="success"
/>
</Box>
+13
View File
@@ -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<ActionState> {
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");
}
}
@@ -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<ProxyHost | null>(null);
const [deleteHost, setDeleteHost] = useState<ProxyHost | null>(null);
const handleToggleEnabled = async (id: number, enabled: boolean) => {
await toggleProxyHostAction(id, enabled);
};
return (
<Stack spacing={4} sx={{ width: "100%" }}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" spacing={2}>
@@ -161,16 +167,17 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists }: P
</Typography>
</TableCell>
<TableCell>
<Chip
label={host.enabled ? "Enabled" : "Disabled"}
<Switch
checked={host.enabled}
onChange={(e) => 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)"
}
}}
/>
</TableCell>
@@ -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
/>
<AuthentikFields />
<AuthentikFields defaults={authentikDefaults} />
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>
@@ -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
+16
View File
@@ -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<ActionState> {
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.");
}
}
+5 -3
View File
@@ -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 <ProxyHostsClient hosts={hosts} certificates={certificates} accessLists={accessLists} />;
return <ProxyHostsClient hosts={hosts} certificates={certificates} accessLists={accessLists} authentikDefaults={authentikDefaults} />;
}
+15 -9
View File
@@ -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<RedirectHost | null>(null);
const [deleteRedirect, setDeleteRedirect] = useState<RedirectHost | null>(null);
const handleToggleEnabled = async (id: number, enabled: boolean) => {
await toggleRedirectAction(id, enabled);
};
return (
<Stack spacing={4} sx={{ width: "100%" }}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" spacing={2}>
@@ -137,16 +142,17 @@ export default function RedirectsClient({ redirects }: Props) {
/>
</TableCell>
<TableCell>
<Chip
label={redirect.enabled ? "Enabled" : "Disabled"}
<Switch
checked={redirect.enabled}
onChange={(e) => 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)"
}
}}
/>
</TableCell>
+12
View File
@@ -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<ActionState> {
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");
}
}
+55 -3
View File
@@ -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 (
<Stack spacing={4} sx={{ width: "100%" }}>
@@ -106,6 +109,55 @@ export default function SettingsClient({ general, cloudflare }: Props) {
</Stack>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="h6" fontWeight={600} gutterBottom>
Authentik Defaults
</Typography>
<Typography color="text.secondary" variant="body2" sx={{ mb: 2 }}>
Set default Authentik forward authentication values. These will be pre-filled when creating new proxy hosts but can be customized per host.
</Typography>
<Stack component="form" action={authentikFormAction} spacing={2}>
{authentikState?.message && (
<Alert severity={authentikState.success ? "success" : "error"}>
{authentikState.message}
</Alert>
)}
<TextField
name="outpostDomain"
label="Outpost Domain"
placeholder="auth.example.com"
defaultValue={authentik?.outpostDomain ?? ""}
helperText="Domain where Authentik is hosted"
required
fullWidth
/>
<TextField
name="outpostUpstream"
label="Outpost Upstream"
placeholder="http://authentik-server:9000"
defaultValue={authentik?.outpostUpstream ?? ""}
helperText="Internal URL of Authentik outpost"
required
fullWidth
/>
<TextField
name="authEndpoint"
label="Auth Endpoint (Optional)"
placeholder="/outpost.goauthentik.io/auth/caddy"
defaultValue={authentik?.authEndpoint ?? ""}
helperText="Custom authentication endpoint path"
fullWidth
/>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button type="submit" variant="contained">
Save Authentik defaults
</Button>
</Box>
</Stack>
</CardContent>
</Card>
</Stack>
);
}
+26 -1
View File
@@ -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<ActionResult> {
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" };
}
}
+5 -3
View File
@@ -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}
/>
);
}
+14
View File
@@ -15,6 +15,12 @@ export type GeneralSettings = {
acmeEmail?: string;
};
export type AuthentikSettings = {
outpostDomain: string;
outpostUpstream: string;
authEndpoint?: string;
};
export async function getSetting<T>(key: string): Promise<SettingValue<T>> {
const setting = await db.query.settings.findFirst({
where: (table, { eq }) => eq(table.key, key)
@@ -67,3 +73,11 @@ export async function getGeneralSettings(): Promise<GeneralSettings | null> {
export async function saveGeneralSettings(settings: GeneralSettings): Promise<void> {
await setSetting("general", settings);
}
export async function getAuthentikSettings(): Promise<AuthentikSettings | null> {
return await getSetting<AuthentikSettings>("authentik");
}
export async function saveAuthentikSettings(settings: AuthentikSettings): Promise<void> {
await setSetting("authentik", settings);
}