Add Authelia as centralized authentication and OIDC provider

Authelia runs as a managed native service on Wild Central, providing
two integration patterns for network services:

- Forward-auth via HAProxy for apps without native SSO (Lua auth-request
  script intercepts requests, redirects unauthenticated users to login portal)
- OIDC provider for apps with native support (Gitea, Grafana, etc.)

Backend: new internal/authelia/ package with service manager, config
generation, file-based user management (argon2id), and OIDC client
management. API endpoints for status, config, users CRUD, and OIDC
clients CRUD.

HAProxy: config generator extended with lua-load, auth-request
directives, and Authelia backend. Auth directives scoped to session
cookie domain — only subdomains of the auth portal's parent are
eligible for forward-auth.

Frontend: Authentication page with enable/disable, user management,
OIDC client management (add/edit/delete), and protected domains
toggles. Advanced subsystem page for raw config view. Dashboard
service card and sidebar entries.
This commit is contained in:
2026-07-12 11:47:37 +00:00
parent 24bb976652
commit d796a79f79
27 changed files with 3097 additions and 11 deletions

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { NavLink } from 'react-router';
import { Sun, Moon, Monitor, Shield, Lock, ShieldAlert, Wifi, LayoutDashboard, Globe, Network, ChevronRight } from 'lucide-react';
import { Sun, Moon, Monitor, Shield, Lock, ShieldAlert, ShieldBan, Wifi, LayoutDashboard, Globe, Network, ChevronRight, KeyRound } from 'lucide-react';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
import {
Sidebar,
@@ -89,10 +89,12 @@ export function AppSidebar() {
];
const centralItems = [
{ to: '/auth', icon: KeyRound, label: 'Authentication', daemon: 'authelia' as const },
{ to: '/vpn', icon: Lock, label: 'VPN', daemon: 'wireguard' as const },
{ to: '/firewall', icon: Shield, label: 'Firewall', daemon: 'nftables' as const },
{ to: '/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const },
{ to: '/dhcp', icon: Wifi, label: 'DHCP', daemon: 'dnsmasq' as const },
{ to: '/dns-filter', icon: ShieldBan, label: 'DNS Filter', daemon: 'dnsmasq' as const },
];
const advancedItems = [
@@ -101,6 +103,7 @@ export function AppSidebar() {
{ to: '/advanced/nftables', icon: Shield, label: 'nftables', daemon: 'nftables' as const },
{ to: '/advanced/wireguard', icon: Lock, label: 'WireGuard', daemon: 'wireguard' as const },
{ to: '/advanced/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const },
{ to: '/advanced/authelia', icon: KeyRound, label: 'Authelia', daemon: 'authelia' as const },
];
return (

View File

@@ -0,0 +1,633 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { KeyRound, BookOpen, CheckCircle, AlertCircle, Plus, Trash2, Loader2, RotateCw, ExternalLink, Copy, X, UserPlus, Globe, Pencil } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Badge } from './ui/badge';
import { Alert, AlertDescription } from './ui/alert';
import { Switch } from './ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { useAuthelia } from '../hooks/useAuthelia';
import { domainsApi } from '../services/api';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
export function AutheliaComponent() {
const {
status, isLoadingStatus,
users, isLoadingUsers,
oidcClients, isLoadingClients,
updateConfig, restart,
createUser, deleteUser,
createOIDCClient, updateOIDCClient, deleteOIDCClient,
} = useAuthelia();
const [successMsg, setSuccessMsg] = useState<string | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [showAddUser, setShowAddUser] = useState(false);
const [showAddClient, setShowAddClient] = useState(false);
const [newClientSecret, setNewClientSecret] = useState<string | null>(null);
const [editingClientId, setEditingClientId] = useState<string | null>(null);
const showSuccess = (msg: string) => {
setSuccessMsg(msg);
setErrorMsg(null);
setTimeout(() => setSuccessMsg(null), 5000);
};
const showError = (msg: string) => {
setErrorMsg(msg);
setSuccessMsg(null);
};
if (isLoadingStatus) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4 mb-6">
<div className="p-2 bg-primary/10 rounded-lg">
<KeyRound className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">Authentication</h2>
<p className="text-muted-foreground">Centralized authentication for network services via Authelia</p>
</div>
</div>
{/* Alerts */}
{successMsg && (
<Alert className="border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/20">
<CheckCircle className="h-4 w-4 text-green-600" />
<AlertDescription className="text-green-700 dark:text-green-300">{successMsg}</AlertDescription>
<Button variant="ghost" size="sm" className="absolute right-2 top-2 h-6 w-6 p-0" onClick={() => setSuccessMsg(null)}>
<X className="h-3 w-3" />
</Button>
</Alert>
)}
{errorMsg && (
<Alert variant="error">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{errorMsg}</AlertDescription>
<Button variant="ghost" size="sm" className="absolute right-2 top-2 h-6 w-6 p-0" onClick={() => setErrorMsg(null)}>
<X className="h-3 w-3" />
</Button>
</Alert>
)}
{/* Educational Card */}
<Card className="bg-gradient-to-r from-cyan-50 to-blue-50 dark:from-cyan-900/20 dark:to-blue-900/20 border-cyan-200 dark:border-cyan-800">
<CardContent className="p-4 flex items-start gap-3">
<BookOpen className="h-5 w-5 text-cyan-600 dark:text-cyan-400 mt-0.5 shrink-0" />
<div className="text-sm text-cyan-800 dark:text-cyan-200">
<p className="font-medium mb-1">Centralized Authentication for Your Network</p>
<p>Authelia provides two ways to protect services: <strong>Forward-auth</strong> intercepts requests at HAProxy for apps without native login. <strong>OIDC</strong> lets apps like Gitea and Grafana use Authelia as their identity provider. Both share one user database and login portal.</p>
</div>
</CardContent>
</Card>
{/* Configuration */}
<ConfigCard
status={status}
onUpdate={updateConfig}
showSuccess={showSuccess}
showError={showError}
/>
{/* Service Status (only if enabled) */}
{status?.enabled && (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">Service Status</CardTitle>
<div className="flex items-center gap-2">
<Badge variant={status.active ? 'success' : 'destructive'} className="gap-1">
{status.active ? <CheckCircle className="h-3 w-3" /> : <AlertCircle className="h-3 w-3" />}
{status.active ? 'Running' : 'Stopped'}
</Badge>
<Button variant="outline" size="sm" className="gap-1" onClick={() => restart.mutate(undefined, {
onSuccess: () => showSuccess('Authelia restarted'),
onError: (e) => showError(e instanceof Error ? e.message : 'Failed to restart'),
})} disabled={restart.isPending}>
{restart.isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCw className="h-3 w-3" />}
Restart
</Button>
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
{status.version && <div><span className="text-muted-foreground">Version</span><div className="font-mono mt-0.5">{status.version}</div></div>}
<div><span className="text-muted-foreground">Users</span><div className="font-mono mt-0.5">{status.userCount}</div></div>
<div><span className="text-muted-foreground">OIDC Clients</span><div className="font-mono mt-0.5">{status.clientCount}</div></div>
{status.domain && (
<div>
<span className="text-muted-foreground">Login Portal</span>
<div className="font-mono mt-0.5">
<a href={`https://${status.domain}`} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline inline-flex items-center gap-1">
{status.domain}<ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* User Management */}
{status?.enabled && (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">Users</CardTitle>
<Button size="sm" className="gap-1" onClick={() => setShowAddUser(!showAddUser)}>
{showAddUser ? <X className="h-3 w-3" /> : <UserPlus className="h-3 w-3" />}
{showAddUser ? 'Cancel' : 'Add User'}
</Button>
</div>
</CardHeader>
<CardContent className="pt-0 space-y-3">
{showAddUser && (
<AddUserForm
onSubmit={(data) => {
createUser.mutate(data, {
onSuccess: () => { showSuccess(`User "${data.username}" created`); setShowAddUser(false); },
onError: (e) => showError(e instanceof Error ? e.message : 'Failed to create user'),
});
}}
isSubmitting={createUser.isPending}
/>
)}
{isLoadingUsers ? (
<div className="text-center py-4"><Loader2 className="h-5 w-5 animate-spin mx-auto text-muted-foreground" /></div>
) : users.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">No users yet. Add a user to get started.</p>
) : (
<div className="space-y-2">
{users.map((user) => (
<div key={user.username} className="flex items-center justify-between p-3 bg-muted/50 rounded-md">
<div className="flex items-center gap-3">
<div>
<span className="font-medium text-sm">{user.displayname || user.username}</span>
<span className="text-muted-foreground text-sm ml-2">({user.username})</span>
{user.email && <span className="text-muted-foreground text-xs ml-2">{user.email}</span>}
</div>
{user.disabled && <Badge variant="secondary">Disabled</Badge>}
</div>
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive h-7 w-7 p-0"
onClick={() => {
if (confirm(`Delete user "${user.username}"?`)) {
deleteUser.mutate(user.username, {
onSuccess: () => showSuccess(`User "${user.username}" deleted`),
onError: (e) => showError(e instanceof Error ? e.message : 'Failed to delete user'),
});
}
}}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
)}
{/* OIDC Clients */}
{status?.enabled && (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">OIDC Clients</CardTitle>
<Button size="sm" className="gap-1" onClick={() => { setShowAddClient(!showAddClient); setNewClientSecret(null); }}>
{showAddClient ? <X className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
{showAddClient ? 'Cancel' : 'Add Client'}
</Button>
</div>
</CardHeader>
<CardContent className="pt-0 space-y-3">
{newClientSecret && (
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/20">
<AlertDescription className="text-amber-800 dark:text-amber-200">
<p className="font-medium mb-1">Client secret save this now, it won't be shown again:</p>
<div className="flex items-center gap-2">
<code className="bg-muted px-2 py-1 rounded text-xs font-mono break-all">{newClientSecret}</code>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0" onClick={() => navigator.clipboard.writeText(newClientSecret)}>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
</AlertDescription>
</Alert>
)}
{showAddClient && (
<AddOIDCClientForm
onSubmit={(data) => {
createOIDCClient.mutate(data, {
onSuccess: (result) => {
setNewClientSecret(result.clientSecret);
setShowAddClient(false);
showSuccess(`OIDC client "${data.clientId}" created`);
},
onError: (e) => showError(e instanceof Error ? e.message : 'Failed to create client'),
});
}}
isSubmitting={createOIDCClient.isPending}
/>
)}
{isLoadingClients ? (
<div className="text-center py-4"><Loader2 className="h-5 w-5 animate-spin mx-auto text-muted-foreground" /></div>
) : oidcClients.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">No OIDC clients. Apps with native SSO support (Gitea, Grafana, etc.) need a client registration here.</p>
) : (
<div className="space-y-2">
{oidcClients.map((client) => (
editingClientId === client.clientId ? (
<EditOIDCClientForm
key={client.clientId}
client={client}
onSubmit={(data) => {
updateOIDCClient.mutate({ clientId: client.clientId, ...data }, {
onSuccess: () => { showSuccess(`Client "${client.clientName}" updated`); setEditingClientId(null); },
onError: (e) => showError(e instanceof Error ? e.message : 'Failed to update client'),
});
}}
onCancel={() => setEditingClientId(null)}
isSubmitting={updateOIDCClient.isPending}
/>
) : (
<div key={client.clientId} className="flex items-center justify-between p-3 bg-muted/50 rounded-md">
<div>
<span className="font-medium text-sm">{client.clientName}</span>
<span className="text-muted-foreground text-xs ml-2 font-mono">{client.clientId}</span>
<div className="text-xs text-muted-foreground mt-0.5 font-mono">
{client.redirectUris.join(', ')}
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary">{client.authorizationPolicy}</Badge>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setEditingClientId(client.clientId)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive h-7 w-7 p-0"
onClick={() => {
if (confirm(`Delete OIDC client "${client.clientName}"?`)) {
deleteOIDCClient.mutate(client.clientId, {
onSuccess: () => showSuccess(`Client "${client.clientName}" deleted`),
onError: (e) => showError(e instanceof Error ? e.message : 'Failed to delete client'),
});
}
}}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)
))}
</div>
)}
</CardContent>
</Card>
)}
{/* Protected Domains */}
{status?.enabled && <ProtectedDomainsCard authDomain={status.domain} />}
</div>
);
}
// --- Sub-components ---
function ConfigCard({ status, onUpdate, showSuccess, showError }: {
status: AutheliaStatus | undefined;
onUpdate: ReturnType<typeof useAuthelia>['updateConfig'];
showSuccess: (msg: string) => void;
showError: (msg: string) => void;
}) {
const [editing, setEditing] = useState(false);
const { register, handleSubmit, watch, setValue } = useForm({
defaultValues: {
domain: status?.domain ?? '',
defaultPolicy: status?.defaultPolicy || 'one_factor',
},
values: status ? {
domain: status.domain ?? '',
defaultPolicy: status.defaultPolicy || 'one_factor',
} : undefined,
});
const enabled = status?.enabled ?? false;
const isUpdating = onUpdate.isPending;
const doUpdate = (data: { enabled?: boolean; domain?: string; defaultPolicy?: string }) => {
onUpdate.mutate(data, {
onSuccess: () => {
setEditing(false);
showSuccess('Configuration updated');
},
onError: (e) => showError(e instanceof Error ? e.message : 'Failed to update config'),
});
};
return (
<Card className="border-l-4 border-l-blue-500">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">Configuration</CardTitle>
<div className="flex items-center gap-3">
<Label htmlFor="authelia-enabled" className="text-sm">Enabled</Label>
<Switch
id="authelia-enabled"
checked={enabled}
onCheckedChange={(checked) => {
if (!checked || status?.domain) {
doUpdate({ enabled: checked });
} else {
setEditing(true);
}
}}
disabled={isUpdating || (!status?.installed && !enabled)}
/>
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
{!status?.installed && !enabled && (
<p className="text-sm text-muted-foreground">Authelia is not installed. Install it first: <code className="bg-muted px-1 rounded">apt install authelia</code></p>
)}
{(enabled || editing) && (
<form onSubmit={handleSubmit((data) => doUpdate({ ...data, enabled: true }))} className="space-y-3">
<div>
<Label htmlFor="auth-domain">Auth Portal Domain</Label>
<Input {...register('domain', { required: 'Required' })} id="auth-domain" placeholder="auth.example.com" className="mt-1 font-mono" />
</div>
<div>
<Label htmlFor="default-policy">Default Policy</Label>
<Select value={watch('defaultPolicy')} onValueChange={(v) => setValue('defaultPolicy', v)}>
<SelectTrigger className="mt-1" id="default-policy">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="one_factor">One Factor (password only)</SelectItem>
<SelectItem value="two_factor">Two Factor (password + TOTP)</SelectItem>
</SelectContent>
</Select>
</div>
{editing && (
<div className="flex gap-2">
<Button type="submit" size="sm" disabled={isUpdating}>
{isUpdating && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
Enable & Save
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => setEditing(false)} disabled={isUpdating}>Cancel</Button>
</div>
)}
{enabled && !editing && (
<Button type="submit" size="sm" disabled={isUpdating}>
{isUpdating && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
Save Changes
</Button>
)}
</form>
)}
</CardContent>
</Card>
);
}
function AddUserForm({ onSubmit, isSubmitting }: {
onSubmit: (data: { username: string; displayname: string; password: string; email?: string }) => void;
isSubmitting: boolean;
}) {
const { register, handleSubmit, formState: { errors } } = useForm({
defaultValues: { username: '', displayname: '', password: '', email: '' },
});
return (
<form onSubmit={handleSubmit(onSubmit)} className="p-3 bg-muted/30 rounded-md space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="new-username">Username</Label>
<Input {...register('username', { required: 'Required' })} id="new-username" className="mt-1" />
{errors.username && <p className="text-sm text-red-600 mt-1">{errors.username.message}</p>}
</div>
<div>
<Label htmlFor="new-displayname">Display Name</Label>
<Input {...register('displayname', { required: 'Required' })} id="new-displayname" className="mt-1" />
{errors.displayname && <p className="text-sm text-red-600 mt-1">{errors.displayname.message}</p>}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="new-email">Email</Label>
<Input {...register('email')} id="new-email" type="email" className="mt-1" />
</div>
<div>
<Label htmlFor="new-password">Password</Label>
<Input {...register('password', { required: 'Required', minLength: { value: 8, message: 'Min 8 characters' } })} id="new-password" type="password" className="mt-1" />
{errors.password && <p className="text-sm text-red-600 mt-1">{errors.password.message}</p>}
</div>
</div>
<Button type="submit" size="sm" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
Create User
</Button>
</form>
);
}
function AddOIDCClientForm({ onSubmit, isSubmitting }: {
onSubmit: (data: { clientId: string; clientName: string; redirectUris: string[]; authorizationPolicy?: string }) => void;
isSubmitting: boolean;
}) {
const { register, handleSubmit, formState: { errors }, watch, setValue } = useForm({
defaultValues: { clientId: '', clientName: '', redirectUri: '', authorizationPolicy: 'one_factor' },
});
return (
<form onSubmit={handleSubmit((data) => {
onSubmit({
clientId: data.clientId,
clientName: data.clientName,
redirectUris: [data.redirectUri],
authorizationPolicy: data.authorizationPolicy,
});
})} className="p-3 bg-muted/30 rounded-md space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="client-id">Client ID</Label>
<Input {...register('clientId', { required: 'Required' })} id="client-id" placeholder="gitea" className="mt-1 font-mono" />
{errors.clientId && <p className="text-sm text-red-600 mt-1">{errors.clientId.message}</p>}
</div>
<div>
<Label htmlFor="client-name">Client Name</Label>
<Input {...register('clientName', { required: 'Required' })} id="client-name" placeholder="Gitea" className="mt-1" />
{errors.clientName && <p className="text-sm text-red-600 mt-1">{errors.clientName.message}</p>}
</div>
</div>
<div>
<Label htmlFor="redirect-uri">Redirect URI</Label>
<Input {...register('redirectUri', { required: 'Required' })} id="redirect-uri" placeholder="https://git.example.com/user/oauth2/authelia/callback" className="mt-1 font-mono text-sm" />
{errors.redirectUri && <p className="text-sm text-red-600 mt-1">{errors.redirectUri.message}</p>}
</div>
<div>
<Label htmlFor="client-policy">Authorization Policy</Label>
<Select value={watch('authorizationPolicy')} onValueChange={(v) => setValue('authorizationPolicy', v)}>
<SelectTrigger className="mt-1" id="client-policy">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="one_factor">One Factor</SelectItem>
<SelectItem value="two_factor">Two Factor</SelectItem>
</SelectContent>
</Select>
</div>
<Button type="submit" size="sm" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
Create Client
</Button>
</form>
);
}
function EditOIDCClientForm({ client, onSubmit, onCancel, isSubmitting }: {
client: { clientId: string; clientName: string; redirectUris: string[]; authorizationPolicy: string };
onSubmit: (data: { clientName?: string; redirectUris?: string[]; authorizationPolicy?: string }) => void;
onCancel: () => void;
isSubmitting: boolean;
}) {
const { register, handleSubmit, watch, setValue } = useForm({
defaultValues: {
clientName: client.clientName,
redirectUri: client.redirectUris[0] ?? '',
authorizationPolicy: client.authorizationPolicy || 'one_factor',
},
});
return (
<form onSubmit={handleSubmit((data) => {
onSubmit({
clientName: data.clientName,
redirectUris: [data.redirectUri],
authorizationPolicy: data.authorizationPolicy,
});
})} className="p-3 bg-muted/30 rounded-md space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Client ID</Label>
<Input value={client.clientId} disabled className="mt-1 font-mono" />
</div>
<div>
<Label htmlFor="edit-client-name">Client Name</Label>
<Input {...register('clientName')} id="edit-client-name" className="mt-1" />
</div>
</div>
<div>
<Label htmlFor="edit-redirect-uri">Redirect URI</Label>
<Input {...register('redirectUri')} id="edit-redirect-uri" className="mt-1 font-mono text-sm" />
</div>
<div>
<Label htmlFor="edit-client-policy">Authorization Policy</Label>
<Select value={watch('authorizationPolicy')} onValueChange={(v) => setValue('authorizationPolicy', v)}>
<SelectTrigger className="mt-1" id="edit-client-policy">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="one_factor">One Factor</SelectItem>
<SelectItem value="two_factor">Two Factor</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex gap-2">
<Button type="submit" size="sm" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
Save
</Button>
<Button type="button" variant="outline" size="sm" onClick={onCancel} disabled={isSubmitting}>Cancel</Button>
</div>
</form>
);
}
interface AutheliaStatus {
active: boolean;
version?: string;
enabled: boolean;
domain: string;
defaultPolicy: string;
userCount: number;
clientCount: number;
installed: boolean;
}
function ProtectedDomainsCard({ authDomain }: { authDomain: string }) {
const queryClient = useQueryClient();
const domainsQuery = useQuery({
queryKey: ['domains'],
queryFn: () => domainsApi.list(),
});
const toggleAuthMutation = useMutation({
mutationFn: async ({ domain, enabled }: { domain: string; enabled: boolean }) => {
return apiClient.patch(`/api/v1/domains/${domain}`, { auth: { enabled, policy: 'one_factor' } });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['domains'] });
},
});
const httpDomains = (domainsQuery.data?.domains ?? []).filter(
(d) => d.backend.type === 'http' && d.source !== 'authelia' && d.source !== 'central' && d.domain !== authDomain
);
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<CardTitle className="text-base">Protected Domains</CardTitle>
<Badge variant="secondary" className="text-xs">Forward-Auth</Badge>
</div>
</CardHeader>
<CardContent className="pt-0 space-y-3">
<p className="text-sm text-muted-foreground">
Toggle forward-auth protection for L7 HTTP services. Apps with native OIDC support (Gitea, Grafana, etc.) should use an OIDC client above instead.
</p>
{httpDomains.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4 flex items-center justify-center gap-2">
<Globe className="h-4 w-4" />
No L7 HTTP domains registered yet.
</p>
) : (
<div className="space-y-2">
{httpDomains.map((domain) => {
const authEnabled = (domain as any).auth?.enabled ?? false;
return (
<div key={domain.domain} className="flex items-center justify-between p-3 bg-muted/50 rounded-md">
<div>
<span className="font-mono text-sm">{domain.domain}</span>
<span className="text-muted-foreground text-xs ml-2">({domain.source})</span>
</div>
<Switch
checked={authEnabled}
onCheckedChange={(checked) => toggleAuthMutation.mutate({ domain: domain.domain, enabled: checked })}
disabled={toggleAuthMutation.isPending}
/>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}
// Import apiClient for the PATCH call in ProtectedDomainsCard
import { apiClient } from '../services/api/client';

View File

@@ -7,7 +7,7 @@ import { Alert, AlertDescription } from './ui/alert';
import { Badge } from './ui/badge';
import {
LayoutDashboard, Cloud, CheckCircle, XCircle, AlertCircle, Loader2,
BookOpen, Globe, Router, RotateCw, Key,
BookOpen, Globe, Router, RotateCw, Key, KeyRound,
Shield, Eye, ArrowLeftRight, Lock,
} from 'lucide-react';
import { useCloudflare } from '../hooks/useCloudflare';
@@ -22,6 +22,7 @@ const SERVICES = [
{ key: 'nftables', label: 'Firewall', icon: Shield },
{ key: 'wireguard', label: 'VPN', icon: Lock },
{ key: 'crowdsec', label: 'CrowdSec', icon: Eye },
{ key: 'authelia', label: 'Auth', icon: KeyRound },
];
function formatUptime(totalSeconds: number): string {

View File

@@ -0,0 +1,41 @@
import { useState } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { KeyRound } from 'lucide-react';
import { autheliaApi } from '../../services/api/authelia';
import { SubsystemPage } from '../SubsystemPage';
export function AutheliaSubsystem() {
const [successMsg, setSuccessMsg] = useState<string | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const configQuery = useQuery({
queryKey: ['authelia', 'config', 'raw'],
queryFn: () => autheliaApi.getConfig(),
});
const restartMutation = useMutation({
mutationFn: () => autheliaApi.restart(),
onSuccess: () => {
setSuccessMsg('Authelia restarted');
setErrorMsg(null);
setTimeout(() => setSuccessMsg(null), 5000);
},
onError: (e: Error) => setErrorMsg(e.message),
});
return (
<SubsystemPage
title="Authelia"
description="Authentication and identity provider"
icon={KeyRound}
daemonKey="authelia"
configContent={configQuery.data?.content}
isLoadingConfig={configQuery.isLoading}
onRestart={() => restartMutation.mutate()}
isRestarting={restartMutation.isPending}
restartLabel="Restart Authelia"
successMessage={successMsg}
errorMessage={errorMsg}
/>
);
}

View File

@@ -3,3 +3,4 @@ export { DnsmasqSubsystem } from './DnsmasqSubsystem';
export { NftablesSubsystem } from './NftablesSubsystem';
export { WireguardSubsystem } from './WireguardSubsystem';
export { CrowdsecSubsystem } from './CrowdsecSubsystem';
export { AutheliaSubsystem } from './AutheliaSubsystem';

View File

@@ -0,0 +1,90 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { autheliaApi } from '../services/api/authelia';
import type { CreateUserRequest, UpdateUserRequest, CreateOIDCClientRequest, UpdateOIDCClientRequest } from '../services/api/authelia';
export function useAuthelia() {
const queryClient = useQueryClient();
const statusQuery = useQuery({
queryKey: ['authelia', 'status'],
queryFn: () => autheliaApi.getStatus(),
staleTime: 10000,
});
const usersQuery = useQuery({
queryKey: ['authelia', 'users'],
queryFn: () => autheliaApi.listUsers(),
enabled: !!statusQuery.data?.enabled,
});
const oidcClientsQuery = useQuery({
queryKey: ['authelia', 'oidc', 'clients'],
queryFn: () => autheliaApi.listOIDCClients(),
enabled: !!statusQuery.data?.active,
});
const invalidateAll = () => {
queryClient.invalidateQueries({ queryKey: ['authelia'] });
queryClient.invalidateQueries({ queryKey: ['central', 'status'] });
};
const updateConfigMutation = useMutation({
mutationFn: (data: { enabled?: boolean; domain?: string; defaultPolicy?: string }) =>
autheliaApi.updateConfig(data),
onSuccess: invalidateAll,
});
const restartMutation = useMutation({
mutationFn: () => autheliaApi.restart(),
onSuccess: invalidateAll,
});
const createUserMutation = useMutation({
mutationFn: (req: CreateUserRequest) => autheliaApi.createUser(req),
onSuccess: invalidateAll,
});
const updateUserMutation = useMutation({
mutationFn: ({ username, ...req }: UpdateUserRequest & { username: string }) =>
autheliaApi.updateUser(username, req),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['authelia', 'users'] }),
});
const deleteUserMutation = useMutation({
mutationFn: (username: string) => autheliaApi.deleteUser(username),
onSuccess: invalidateAll,
});
const createOIDCClientMutation = useMutation({
mutationFn: (req: CreateOIDCClientRequest) => autheliaApi.createOIDCClient(req),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['authelia', 'oidc', 'clients'] }),
});
const updateOIDCClientMutation = useMutation({
mutationFn: ({ clientId, ...req }: UpdateOIDCClientRequest & { clientId: string }) =>
autheliaApi.updateOIDCClient(clientId, req),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['authelia', 'oidc', 'clients'] }),
});
const deleteOIDCClientMutation = useMutation({
mutationFn: (clientId: string) => autheliaApi.deleteOIDCClient(clientId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['authelia', 'oidc', 'clients'] }),
});
return {
status: statusQuery.data,
isLoadingStatus: statusQuery.isLoading,
users: usersQuery.data?.users ?? [],
isLoadingUsers: usersQuery.isLoading,
oidcClients: oidcClientsQuery.data?.clients ?? [],
isLoadingClients: oidcClientsQuery.isLoading,
updateConfig: updateConfigMutation,
restart: restartMutation,
createUser: createUserMutation,
updateUser: updateUserMutation,
deleteUser: deleteUserMutation,
createOIDCClient: createOIDCClientMutation,
updateOIDCClient: updateOIDCClientMutation,
deleteOIDCClient: deleteOIDCClientMutation,
};
}

View File

@@ -0,0 +1,10 @@
import { ErrorBoundary } from '../../components';
import { AutheliaComponent } from '../../components/AutheliaComponent';
export function AutheliaPage() {
return (
<ErrorBoundary>
<AutheliaComponent />
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,10 @@
import { ErrorBoundary } from '../../../components';
import { AutheliaSubsystem } from '../../../components/advanced';
export function AutheliaAdvancedPage() {
return (
<ErrorBoundary>
<AutheliaSubsystem />
</ErrorBoundary>
);
}

View File

@@ -3,3 +3,4 @@ export { DnsmasqPage } from './DnsmasqPage';
export { NftablesPage } from './NftablesPage';
export { WireguardPage } from './WireguardPage';
export { CrowdsecPage } from './CrowdsecPage';
export { AutheliaAdvancedPage } from './AutheliaPage';

View File

@@ -7,9 +7,12 @@ import { FirewallPage } from './pages/FirewallPage';
import { VpnPage } from './pages/VpnPage';
import { CrowdSecPage } from './pages/CrowdSecPage';
import { DhcpPage } from './pages/DhcpPage';
import { DnsFilterPage } from './pages/DnsFilterPage';
import { AutheliaPage } from './pages/AutheliaPage';
import {
HaproxyPage, DnsmasqPage, NftablesPage, WireguardPage,
CrowdsecPage as CrowdsecAdvancedPage,
AutheliaAdvancedPage,
} from './pages/advanced';
export const routes: RouteObject[] = [
@@ -22,12 +25,15 @@ export const routes: RouteObject[] = [
{ path: 'firewall', element: <FirewallPage /> },
{ path: 'vpn', element: <VpnPage /> },
{ path: 'crowdsec', element: <CrowdSecPage /> },
{ path: 'auth', element: <AutheliaPage /> },
{ path: 'dhcp', element: <DhcpPage /> },
{ path: 'dns-filter', element: <DnsFilterPage /> },
{ path: 'advanced/haproxy', element: <HaproxyPage /> },
{ path: 'advanced/dnsmasq', element: <DnsmasqPage /> },
{ path: 'advanced/nftables', element: <NftablesPage /> },
{ path: 'advanced/wireguard', element: <WireguardPage /> },
{ path: 'advanced/crowdsec', element: <CrowdsecAdvancedPage /> },
{ path: 'advanced/authelia', element: <AutheliaAdvancedPage /> },
],
},
{

View File

@@ -0,0 +1,111 @@
import { apiClient } from './client';
// Types
export interface AutheliaStatus {
active: boolean;
version?: string;
enabled: boolean;
domain: string;
defaultPolicy: string;
userCount: number;
clientCount: number;
installed: boolean;
}
export interface AutheliaUser {
username: string;
displayname: string;
email?: string;
groups?: string[];
disabled?: boolean;
}
export interface CreateUserRequest {
username: string;
displayname: string;
password: string;
email?: string;
groups?: string[];
}
export interface UpdateUserRequest {
displayname?: string;
password?: string;
email?: string;
groups?: string[];
disabled?: boolean;
}
export interface OIDCClient {
clientId: string;
clientName: string;
clientSecret?: string;
redirectUris: string[];
scopes: string[];
authorizationPolicy: string;
consentMode?: string;
}
export interface CreateOIDCClientRequest {
clientId: string;
clientName: string;
redirectUris: string[];
scopes?: string[];
authorizationPolicy?: string;
consentMode?: string;
}
export interface UpdateOIDCClientRequest {
clientName?: string;
redirectUris?: string[];
scopes?: string[];
authorizationPolicy?: string;
consentMode?: string;
}
// API
export const autheliaApi = {
// Status & Config
async getStatus(): Promise<AutheliaStatus> {
return apiClient.get('/api/v1/authelia/status');
},
async getConfig(): Promise<{ content: string }> {
return apiClient.get('/api/v1/authelia/config');
},
async updateConfig(data: { enabled?: boolean; domain?: string; defaultPolicy?: string }): Promise<{ message: string }> {
return apiClient.put('/api/v1/authelia/config', data);
},
async restart(): Promise<{ message: string }> {
return apiClient.post('/api/v1/authelia/restart');
},
// Users
async listUsers(): Promise<{ users: AutheliaUser[] }> {
return apiClient.get('/api/v1/authelia/users');
},
async createUser(req: CreateUserRequest): Promise<{ message: string }> {
return apiClient.post('/api/v1/authelia/users', req);
},
async updateUser(username: string, req: UpdateUserRequest): Promise<{ message: string }> {
return apiClient.put(`/api/v1/authelia/users/${username}`, req);
},
async deleteUser(username: string): Promise<{ message: string }> {
return apiClient.delete(`/api/v1/authelia/users/${username}`);
},
// OIDC Clients
async listOIDCClients(): Promise<{ clients: OIDCClient[] }> {
return apiClient.get('/api/v1/authelia/oidc/clients');
},
async createOIDCClient(req: CreateOIDCClientRequest): Promise<{ clientId: string; clientSecret: string; message: string }> {
return apiClient.post('/api/v1/authelia/oidc/clients', req);
},
async updateOIDCClient(clientId: string, req: UpdateOIDCClientRequest): Promise<{ message: string }> {
return apiClient.put(`/api/v1/authelia/oidc/clients/${clientId}`, req);
},
async deleteOIDCClient(clientId: string): Promise<{ message: string }> {
return apiClient.delete(`/api/v1/authelia/oidc/clients/${clientId}`);
},
};

View File

@@ -11,3 +11,7 @@ export { cloudflareApi } from './cloudflare';
export type { CloudflareVerifyResponse, CloudflareZone } from './cloudflare';
export { dnsSettingsApi, ddnsConfigApi, dhcpConfigApi, nftablesConfigApi, haproxyRoutesApi } from './settings';
export type { OperatorSettings, CentralDomainSettings, DNSSettings, DDNSConfig, DHCPConfig, NftablesConfig, HAProxyCustomRoute, HAProxyRoutes } from './settings';
export { autheliaApi } from './authelia';
export type { AutheliaStatus, AutheliaUser, OIDCClient } from './authelia';
export { dnsFilterApi } from './dnsfilter';
export type { DNSFilterStats, DNSFilterConfig, Blocklist, CustomEntry } from './dnsfilter';

View File

@@ -30,6 +30,10 @@ export interface RegisteredDomain {
public: boolean;
tls?: string;
routes?: Route[];
auth?: {
enabled: boolean;
policy?: string;
};
}
export const domainsApi = {