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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user