Replace next-auth with Better Auth, migrate DB columns to camelCase
- Replace next-auth v5 beta with better-auth v1.6.2 (stable releases)
- Add multi-provider OAuth support with admin UI configuration
- New oauthProviders table with encrypted secrets (AES-256-GCM)
- Env var bootstrap (OAUTH_*) syncs to DB, UI-created providers fully editable
- OAuth provider REST API: GET/POST/PUT/DELETE /api/v1/oauth-providers
- Settings page "Authentication Providers" section for admin management
- Account linking uses new accounts table (multi-provider per user)
- Username plugin for credentials sign-in (replaces email@localhost pattern)
- bcrypt password compatibility (existing hashes work)
- Database-backed sessions via Kysely adapter (bun:sqlite direct)
- Configurable rate limiting via AUTH_RATE_LIMIT_* env vars
- All DB columns migrated from snake_case to camelCase
- All TypeScript types/models migrated to camelCase properties
- Removed casing: "snake_case" from Drizzle config
- Callback URL format: {baseUrl}/api/auth/oauth2/callback/{providerId}
- package-lock.json removed and gitignored (using bun.lock)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,464 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Copy, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { OAuthProvider } from "@/src/lib/models/oauth-providers";
|
||||
import {
|
||||
createOAuthProviderAction,
|
||||
updateOAuthProviderAction,
|
||||
deleteOAuthProviderAction,
|
||||
} from "./actions";
|
||||
|
||||
interface OAuthProvidersSectionProps {
|
||||
initialProviders: OAuthProvider[];
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
type FormData = {
|
||||
name: string;
|
||||
type: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
issuer: string;
|
||||
authorizationUrl: string;
|
||||
tokenUrl: string;
|
||||
userinfoUrl: string;
|
||||
scopes: string;
|
||||
autoLink: boolean;
|
||||
};
|
||||
|
||||
const emptyForm: FormData = {
|
||||
name: "",
|
||||
type: "oidc",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
issuer: "",
|
||||
authorizationUrl: "",
|
||||
tokenUrl: "",
|
||||
userinfoUrl: "",
|
||||
scopes: "openid email profile",
|
||||
autoLink: false,
|
||||
};
|
||||
|
||||
export default function OAuthProvidersSection({ initialProviders, baseUrl }: OAuthProvidersSectionProps) {
|
||||
const [providers, setProviders] = useState(initialProviders);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingProvider, setEditingProvider] = useState<OAuthProvider | null>(null);
|
||||
const [form, setForm] = useState<FormData>(emptyForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
|
||||
const callbackUrl = useCallback(
|
||||
(providerId: string) => `${baseUrl}/api/auth/oauth2/callback/${providerId}`,
|
||||
[baseUrl]
|
||||
);
|
||||
|
||||
function openAddDialog() {
|
||||
setEditingProvider(null);
|
||||
setForm(emptyForm);
|
||||
setError(null);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
function openEditDialog(provider: OAuthProvider) {
|
||||
setEditingProvider(provider);
|
||||
setForm({
|
||||
name: provider.name,
|
||||
type: provider.type,
|
||||
clientId: provider.clientId,
|
||||
clientSecret: provider.clientSecret,
|
||||
issuer: provider.issuer ?? "",
|
||||
authorizationUrl: provider.authorizationUrl ?? "",
|
||||
tokenUrl: provider.tokenUrl ?? "",
|
||||
userinfoUrl: provider.userinfoUrl ?? "",
|
||||
scopes: provider.scopes,
|
||||
autoLink: provider.autoLink,
|
||||
});
|
||||
setError(null);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.name.trim() || !form.clientId.trim() || !form.clientSecret.trim()) {
|
||||
setError("Name, Client ID, and Client Secret are required.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (editingProvider) {
|
||||
const updated = await updateOAuthProviderAction(editingProvider.id, {
|
||||
name: form.name.trim(),
|
||||
type: form.type,
|
||||
clientId: form.clientId.trim(),
|
||||
clientSecret: form.clientSecret.trim(),
|
||||
issuer: form.issuer.trim() || null,
|
||||
authorizationUrl: form.authorizationUrl.trim() || null,
|
||||
tokenUrl: form.tokenUrl.trim() || null,
|
||||
userinfoUrl: form.userinfoUrl.trim() || null,
|
||||
scopes: form.scopes.trim() || "openid email profile",
|
||||
autoLink: form.autoLink,
|
||||
});
|
||||
if (updated) {
|
||||
setProviders((prev) =>
|
||||
prev.map((p) => (p.id === editingProvider.id ? updated : p))
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const created = await createOAuthProviderAction({
|
||||
name: form.name.trim(),
|
||||
type: form.type,
|
||||
clientId: form.clientId.trim(),
|
||||
clientSecret: form.clientSecret.trim(),
|
||||
issuer: form.issuer.trim() || undefined,
|
||||
authorizationUrl: form.authorizationUrl.trim() || undefined,
|
||||
tokenUrl: form.tokenUrl.trim() || undefined,
|
||||
userinfoUrl: form.userinfoUrl.trim() || undefined,
|
||||
scopes: form.scopes.trim() || undefined,
|
||||
autoLink: form.autoLink,
|
||||
});
|
||||
setProviders((prev) => [...prev, created]);
|
||||
}
|
||||
setDialogOpen(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An error occurred");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleEnabled(provider: OAuthProvider) {
|
||||
try {
|
||||
const updated = await updateOAuthProviderAction(provider.id, {
|
||||
enabled: !provider.enabled,
|
||||
});
|
||||
if (updated) {
|
||||
setProviders((prev) =>
|
||||
prev.map((p) => (p.id === provider.id ? updated : p))
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to toggle provider:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
try {
|
||||
await deleteOAuthProviderAction(id);
|
||||
setProviders((prev) => prev.filter((p) => p.id !== id));
|
||||
setDeleteConfirmId(null);
|
||||
} catch (err) {
|
||||
console.error("Failed to delete provider:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function copyToClipboard(text: string, providerId: string) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopiedId(providerId);
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function updateField<K extends keyof FormData>(field: K, value: FormData[K]) {
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{providers.length === 0 && (
|
||||
<Alert className="border-blue-500/30 bg-blue-500/5 text-blue-700 dark:text-blue-400 [&>svg]:text-blue-500">
|
||||
<AlertDescription>
|
||||
No OAuth providers configured. Add a provider to enable single sign-on.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{providers.map((provider) => (
|
||||
<div
|
||||
key={provider.id}
|
||||
className="flex flex-col gap-2 rounded-md border px-4 py-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-semibold">{provider.name}</p>
|
||||
<Badge variant="muted">{provider.type.toUpperCase()}</Badge>
|
||||
<Badge variant={provider.source === "env" ? "info" : "secondary"}>
|
||||
{provider.source === "env" ? "ENV" : "UI"}
|
||||
</Badge>
|
||||
{!provider.enabled && (
|
||||
<Badge variant="warning">Disabled</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor={`toggle-${provider.id}`} className="text-xs text-muted-foreground">
|
||||
Enabled
|
||||
</Label>
|
||||
<Switch
|
||||
id={`toggle-${provider.id}`}
|
||||
checked={provider.enabled}
|
||||
onCheckedChange={() => handleToggleEnabled(provider)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openEditDialog(provider)}
|
||||
disabled={provider.source === "env"}
|
||||
title={provider.source === "env" ? "Environment-sourced providers cannot be edited" : "Edit provider"}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{deleteConfirmId === provider.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(provider.id)}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive border-destructive/50"
|
||||
onClick={() => setDeleteConfirmId(provider.id)}
|
||||
disabled={provider.source === "env"}
|
||||
title={provider.source === "env" ? "Environment-sourced providers cannot be deleted" : "Delete provider"}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs font-mono text-muted-foreground break-all">
|
||||
{callbackUrl(provider.id)}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 shrink-0"
|
||||
onClick={() => copyToClipboard(callbackUrl(provider.id), provider.id)}
|
||||
title="Copy callback URL"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
{copiedId === provider.id && (
|
||||
<span className="text-xs text-emerald-600">Copied!</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={openAddDialog}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Provider
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Add / Edit Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingProvider ? "Edit OAuth Provider" : "Add OAuth Provider"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingProvider
|
||||
? "Update the OAuth provider configuration."
|
||||
: "Configure a new OAuth or OIDC provider for single sign-on."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="oauth-name">Name *</Label>
|
||||
<Input
|
||||
id="oauth-name"
|
||||
value={form.name}
|
||||
onChange={(e) => updateField("name", e.target.value)}
|
||||
placeholder="e.g. Google, Keycloak"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="oauth-type">Type</Label>
|
||||
<Select
|
||||
value={form.type}
|
||||
onValueChange={(v) => updateField("type", v)}
|
||||
>
|
||||
<SelectTrigger id="oauth-type" className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="oidc">OIDC (OpenID Connect)</SelectItem>
|
||||
<SelectItem value="oauth2">OAuth2</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="oauth-client-id">Client ID *</Label>
|
||||
<Input
|
||||
id="oauth-client-id"
|
||||
value={form.clientId}
|
||||
onChange={(e) => updateField("clientId", e.target.value)}
|
||||
className="h-8 text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="oauth-client-secret">Client Secret *</Label>
|
||||
<Input
|
||||
id="oauth-client-secret"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={form.clientSecret}
|
||||
onChange={(e) => updateField("clientSecret", e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="oauth-issuer">Issuer URL</Label>
|
||||
<Input
|
||||
id="oauth-issuer"
|
||||
value={form.issuer}
|
||||
onChange={(e) => updateField("issuer", e.target.value)}
|
||||
placeholder="https://accounts.google.com"
|
||||
className="h-8 text-sm font-mono"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
For OIDC providers, the issuer URL enables automatic discovery of endpoints.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="oauth-auth-url">Authorization URL</Label>
|
||||
<Input
|
||||
id="oauth-auth-url"
|
||||
value={form.authorizationUrl}
|
||||
onChange={(e) => updateField("authorizationUrl", e.target.value)}
|
||||
placeholder="Override discovered endpoint"
|
||||
className="h-8 text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="oauth-token-url">Token URL</Label>
|
||||
<Input
|
||||
id="oauth-token-url"
|
||||
value={form.tokenUrl}
|
||||
onChange={(e) => updateField("tokenUrl", e.target.value)}
|
||||
placeholder="Override discovered endpoint"
|
||||
className="h-8 text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="oauth-userinfo-url">Userinfo URL</Label>
|
||||
<Input
|
||||
id="oauth-userinfo-url"
|
||||
value={form.userinfoUrl}
|
||||
onChange={(e) => updateField("userinfoUrl", e.target.value)}
|
||||
placeholder="Override discovered endpoint"
|
||||
className="h-8 text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="oauth-scopes">Scopes</Label>
|
||||
<Input
|
||||
id="oauth-scopes"
|
||||
value={form.scopes}
|
||||
onChange={(e) => updateField("scopes", e.target.value)}
|
||||
placeholder="openid email profile"
|
||||
className="h-8 text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Switch
|
||||
id="oauth-auto-link"
|
||||
checked={form.autoLink}
|
||||
onCheckedChange={(v) => updateField("autoLink", v)}
|
||||
/>
|
||||
<Label htmlFor="oauth-auto-link">
|
||||
Auto-link accounts
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground -mt-1">
|
||||
Automatically link OAuth accounts to existing users with the same email address.
|
||||
</p>
|
||||
|
||||
{editingProvider && (
|
||||
<div className="flex flex-col gap-1.5 pt-1">
|
||||
<Label className="text-xs text-muted-foreground">Callback URL</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs font-mono text-muted-foreground break-all">
|
||||
{callbackUrl(editingProvider.id)}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 shrink-0"
|
||||
onClick={() => copyToClipboard(callbackUrl(editingProvider.id), editingProvider.id)}
|
||||
title="Copy callback URL"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? "Saving..." : editingProvider ? "Update Provider" : "Create Provider"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import { useFormState } from "react-dom";
|
||||
import {
|
||||
Cloud, Globe, Network, Pin, Activity,
|
||||
ScrollText, Settings2, UserCheck, MapPin,
|
||||
ScrollText, Settings2, UserCheck, MapPin, KeyRound,
|
||||
} from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -24,6 +24,8 @@ import type {
|
||||
GeoBlockSettings,
|
||||
} from "@/lib/settings";
|
||||
import { GeoBlockFields } from "@/components/proxy-hosts/GeoBlockFields";
|
||||
import OAuthProvidersSection from "./OAuthProvidersSection";
|
||||
import type { OAuthProvider } from "@/src/lib/models/oauth-providers";
|
||||
import {
|
||||
updateCloudflareSettingsAction,
|
||||
updateGeneralSettingsAction,
|
||||
@@ -117,6 +119,7 @@ const A: Record<string, AccentConfig> = {
|
||||
metrics: { border: "border-l-rose-500", icon: "border-rose-500/30 bg-rose-500/10 text-rose-500" },
|
||||
logging: { border: "border-l-amber-500", icon: "border-amber-500/30 bg-amber-500/10 text-amber-500" },
|
||||
geoblock: { border: "border-l-teal-500", icon: "border-teal-500/30 bg-teal-500/10 text-teal-500" },
|
||||
oauth: { border: "border-l-indigo-500", icon: "border-indigo-500/30 bg-indigo-500/10 text-indigo-500" },
|
||||
};
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
@@ -134,6 +137,8 @@ type Props = {
|
||||
dns: DnsSettings | null;
|
||||
upstreamDnsResolution: UpstreamDnsResolutionSettings | null;
|
||||
globalGeoBlock?: GeoBlockSettings | null;
|
||||
oauthProviders: OAuthProvider[];
|
||||
baseUrl: string;
|
||||
instanceSync: {
|
||||
mode: "standalone" | "master" | "slave";
|
||||
modeFromEnv: boolean;
|
||||
@@ -156,10 +161,10 @@ type Props = {
|
||||
instances: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
base_url: string;
|
||||
baseUrl: string;
|
||||
enabled: boolean;
|
||||
last_sync_at: string | null;
|
||||
last_sync_error: string | null;
|
||||
lastSyncAt: string | null;
|
||||
lastSyncError: string | null;
|
||||
}>;
|
||||
envInstances: Array<{
|
||||
name: string;
|
||||
@@ -180,6 +185,8 @@ export default function SettingsClient({
|
||||
dns,
|
||||
upstreamDnsResolution,
|
||||
globalGeoBlock,
|
||||
oauthProviders,
|
||||
baseUrl,
|
||||
instanceSync
|
||||
}: Props) {
|
||||
const [generalState, generalFormAction] = useFormState(updateGeneralSettingsAction, null);
|
||||
@@ -376,12 +383,12 @@ export default function SettingsClient({
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{instance.name}</p>
|
||||
<p className="text-xs text-muted-foreground font-mono">{instance.base_url}</p>
|
||||
<p className="text-xs text-muted-foreground font-mono">{instance.baseUrl}</p>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{instance.last_sync_at ? `Last sync: ${instance.last_sync_at}` : "No sync yet"}
|
||||
{instance.lastSyncAt ? `Last sync: ${instance.lastSyncAt}` : "No sync yet"}
|
||||
</span>
|
||||
{instance.last_sync_error && (
|
||||
<span className="block text-xs text-destructive">{instance.last_sync_error}</span>
|
||||
{instance.lastSyncError && (
|
||||
<span className="block text-xs text-destructive">{instance.lastSyncError}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -853,6 +860,16 @@ export default function SettingsClient({
|
||||
</div>
|
||||
</form>
|
||||
</SettingSection>
|
||||
|
||||
{/* ── OAuth Providers ── */}
|
||||
<SettingSection
|
||||
icon={<KeyRound className="h-4 w-4" />}
|
||||
title="OAuth Providers"
|
||||
description="Configure OAuth/OIDC providers for single sign-on. Users can log in via these providers in addition to local credentials."
|
||||
accent={A.oauth}
|
||||
>
|
||||
<OAuthProvidersSection initialProviders={oauthProviders} baseUrl={baseUrl} />
|
||||
</SettingSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -676,6 +676,76 @@ export async function suppressWafRuleGloballyAction(ruleId: number): Promise<Act
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOAuthProvidersAction() {
|
||||
await requireAdmin();
|
||||
const { listOAuthProviders } = await import("@/src/lib/models/oauth-providers");
|
||||
return listOAuthProviders();
|
||||
}
|
||||
|
||||
export async function createOAuthProviderAction(data: {
|
||||
name: string;
|
||||
type: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
issuer?: string;
|
||||
authorizationUrl?: string;
|
||||
tokenUrl?: string;
|
||||
userinfoUrl?: string;
|
||||
scopes?: string;
|
||||
autoLink?: boolean;
|
||||
}) {
|
||||
const session = await requireAdmin();
|
||||
const { createOAuthProvider } = await import("@/src/lib/models/oauth-providers");
|
||||
const { invalidateProviderCache } = await import("@/src/lib/auth-server");
|
||||
const provider = await createOAuthProvider({ ...data, source: "ui" });
|
||||
invalidateProviderCache();
|
||||
const { createAuditEvent } = await import("@/src/lib/models/audit");
|
||||
await createAuditEvent({
|
||||
userId: Number(session.user.id),
|
||||
action: "oauth_provider_created",
|
||||
entityType: "oauth_provider",
|
||||
entityId: null,
|
||||
summary: `OAuth provider "${data.name}" created`,
|
||||
data: JSON.stringify({ providerId: provider.id }),
|
||||
});
|
||||
revalidatePath("/settings");
|
||||
return provider;
|
||||
}
|
||||
|
||||
export async function updateOAuthProviderAction(
|
||||
id: string,
|
||||
data: Partial<{
|
||||
name: string;
|
||||
type: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
issuer: string | null;
|
||||
authorizationUrl: string | null;
|
||||
tokenUrl: string | null;
|
||||
userinfoUrl: string | null;
|
||||
scopes: string;
|
||||
autoLink: boolean;
|
||||
enabled: boolean;
|
||||
}>
|
||||
) {
|
||||
await requireAdmin();
|
||||
const { updateOAuthProvider } = await import("@/src/lib/models/oauth-providers");
|
||||
const { invalidateProviderCache } = await import("@/src/lib/auth-server");
|
||||
const updated = await updateOAuthProvider(id, data);
|
||||
invalidateProviderCache();
|
||||
revalidatePath("/settings");
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function deleteOAuthProviderAction(id: string) {
|
||||
await requireAdmin();
|
||||
const { deleteOAuthProvider } = await import("@/src/lib/models/oauth-providers");
|
||||
const { invalidateProviderCache } = await import("@/src/lib/auth-server");
|
||||
await deleteOAuthProvider(id);
|
||||
invalidateProviderCache();
|
||||
revalidatePath("/settings");
|
||||
}
|
||||
|
||||
export async function suppressWafRuleForHostAction(ruleId: number, hostname: string): Promise<ActionResult> {
|
||||
try {
|
||||
const session = await requireAdmin();
|
||||
|
||||
@@ -2,6 +2,8 @@ import SettingsClient from "./SettingsClient";
|
||||
import { getCloudflareSettings, getGeneralSettings, getAuthentikSettings, getMetricsSettings, getLoggingSettings, getDnsSettings, getSetting, getUpstreamDnsResolutionSettings, getGeoBlockSettings } from "@/src/lib/settings";
|
||||
import { getInstanceMode, getSlaveLastSync, getSlaveMasterToken, isInstanceModeFromEnv, isSyncTokenFromEnv, getEnvSlaveInstances } from "@/src/lib/instance-sync";
|
||||
import { listInstances } from "@/src/lib/models/instances";
|
||||
import { listOAuthProviders } from "@/src/lib/models/oauth-providers";
|
||||
import { config } from "@/src/lib/config";
|
||||
import { requireAdmin } from "@/src/lib/auth";
|
||||
|
||||
export default async function SettingsPage() {
|
||||
@@ -11,7 +13,7 @@ export default async function SettingsPage() {
|
||||
const modeFromEnv = isInstanceModeFromEnv();
|
||||
const tokenFromEnv = isSyncTokenFromEnv();
|
||||
|
||||
const [general, cloudflare, authentik, metrics, logging, dns, upstreamDnsResolution, instanceMode, globalGeoBlock] = await Promise.all([
|
||||
const [general, cloudflare, authentik, metrics, logging, dns, upstreamDnsResolution, instanceMode, globalGeoBlock, oauthProviders] = await Promise.all([
|
||||
getGeneralSettings(),
|
||||
getCloudflareSettings(),
|
||||
getAuthentikSettings(),
|
||||
@@ -21,6 +23,7 @@ export default async function SettingsPage() {
|
||||
getUpstreamDnsResolutionSettings(),
|
||||
getInstanceMode(),
|
||||
getGeoBlockSettings(),
|
||||
listOAuthProviders(),
|
||||
]);
|
||||
|
||||
const [overrideGeneral, overrideCloudflare, overrideAuthentik, overrideMetrics, overrideLogging, overrideDns, overrideUpstreamDnsResolution] =
|
||||
@@ -57,6 +60,8 @@ export default async function SettingsPage() {
|
||||
dns={dns}
|
||||
upstreamDnsResolution={upstreamDnsResolution}
|
||||
globalGeoBlock={globalGeoBlock}
|
||||
oauthProviders={oauthProviders}
|
||||
baseUrl={config.baseUrl}
|
||||
instanceSync={{
|
||||
mode: instanceMode,
|
||||
modeFromEnv,
|
||||
|
||||
Reference in New Issue
Block a user