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:
fuomag9
2026-04-12 21:11:48 +02:00
parent eb78b64c2f
commit 3a16d6e9b1
100 changed files with 3390 additions and 14495 deletions

View File

@@ -2,7 +2,7 @@
import { useState, FormEvent } from "react";
import { useRouter } from "next/navigation";
import { signIn } from "next-auth/react";
import { authClient } from "@/src/lib/auth-client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -45,7 +45,7 @@ export default function LinkAccountClient({
return;
}
await signIn(provider, { callbackUrl: "/" });
await authClient.signIn.social({ provider, callbackURL: "/" });
} catch {
setError("An error occurred while linking your account");
setLoading(false);

View File

@@ -2,7 +2,7 @@
import { useRouter } from "next/navigation";
import { FormEvent, useState } from "react";
import { signIn } from "next-auth/react";
import { authClient } from "@/src/lib/auth-client";
import { LogIn } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -36,28 +36,24 @@ export default function LoginClient({ enabledProviders = [] }: LoginClientProps)
return;
}
const result = await signIn("credentials", {
redirect: false,
callbackUrl: "/",
const { data, error } = await authClient.signIn.username({
username,
password,
});
if (!result || result.error || result.ok === false) {
if (error) {
let message: string | null = null;
if (result?.status === 429) {
message = result.error && result.error !== "CredentialsSignin"
? result.error
: "Too many login attempts. Try again in a few minutes.";
} else if (result?.error && result.error !== "CredentialsSignin") {
message = result.error;
if (error.status === 429) {
message = error.message || "Too many login attempts. Try again in a few minutes.";
} else if (error.message) {
message = error.message;
}
setLoginError(message ?? "Invalid username or password.");
setLoginPending(false);
return;
}
router.replace(result.url ?? "/");
router.replace("/");
router.refresh();
};
@@ -65,7 +61,7 @@ export default function LoginClient({ enabledProviders = [] }: LoginClientProps)
setLoginError(null);
setOauthPending(providerId);
try {
await signIn(providerId, { callbackUrl: "/" });
await authClient.signIn.social({ provider: providerId, callbackURL: "/" });
} catch {
setLoginError("Failed to sign in with OAuth");
setOauthPending(null);

View File

@@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
import { auth } from "@/src/lib/auth";
import { getEnabledOAuthProviders } from "@/src/lib/config";
import { getProviderDisplayList } from "@/src/lib/models/oauth-providers";
import LoginClient from "./LoginClient";
export default async function LoginPage() {
@@ -9,7 +9,7 @@ export default async function LoginPage() {
redirect("/");
}
const enabledProviders = getEnabledOAuthProviders();
const enabledProviders = await getProviderDisplayList();
return <LoginClient enabledProviders={enabledProviders} />;
}

View File

@@ -2,7 +2,7 @@
import { FormEvent, useEffect, useState } from "react";
import { Shield } from "lucide-react";
import { signIn } from "next-auth/react";
import { authClient } from "@/src/lib/auth-client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -97,7 +97,7 @@ export default function PortalLoginForm({
// Redirect back to this portal page after OAuth, with the rid param preserved.
// The rid is an opaque server-side ID — the actual redirect URI is never in the URL.
const callbackUrl = `/portal?rid=${encodeURIComponent(rid)}`;
signIn(providerId, { callbackUrl });
authClient.signIn.social({ provider: providerId, callbackURL: callbackUrl });
};
const disabled = pending || !!oauthPending;

View File

@@ -1,5 +1,5 @@
import { auth } from "@/src/lib/auth";
import { getEnabledOAuthProviders } from "@/src/lib/config";
import { getProviderDisplayList } from "@/src/lib/models/oauth-providers";
import { isForwardAuthDomain, createRedirectIntent } from "@/src/lib/models/forward-auth";
import PortalLoginForm from "./PortalLoginForm";
@@ -36,7 +36,7 @@ export default async function PortalPage({ searchParams }: PortalPageProps) {
}
const session = await auth();
const enabledProviders = getEnabledOAuthProviders();
const enabledProviders = await getProviderDisplayList();
return (
<PortalLoginForm

View File

@@ -14,7 +14,7 @@ type StatCard = {
type RecentEvent = {
summary: string;
created_at: string;
createdAt: string;
};
type TrafficSummary = {
@@ -156,14 +156,14 @@ export default function OverviewClient({
<div className="absolute left-[28px] top-4 bottom-4 w-px bg-border" />
{recentEvents.map((event, index) => (
<div
key={`${event.created_at}-${index}`}
key={`${event.createdAt}-${index}`}
className="relative flex items-start gap-4 px-5 py-3 hover:bg-muted/30 transition-colors"
>
{/* Dot */}
<div className={`relative z-10 mt-1 h-3 w-3 shrink-0 rounded-full ${getEventDotColor(event.summary)}`} />
<span className="flex-1 text-sm leading-snug">{event.summary}</span>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
{formatRelativeTime(event.created_at)}
{formatRelativeTime(event.createdAt)}
</span>
</div>
))}

View File

@@ -161,7 +161,7 @@ export default function AccessListsClient({ lists, pagination }: Props) {
<div>
<p className="text-sm font-medium font-mono leading-tight">{entry.username}</p>
<p className="text-xs text-muted-foreground">
Added {new Date(entry.created_at).toLocaleDateString()}
Added {new Date(entry.createdAt).toLocaleDateString()}
</p>
</div>
</div>

View File

@@ -10,7 +10,7 @@ import { PageHeader } from "@/components/ui/PageHeader";
type EventRow = {
id: number;
created_at: string;
createdAt: string;
user: string;
summary: string;
};
@@ -61,7 +61,7 @@ export default function AuditLogClient({ events, pagination, initialSearch }: Pr
width: 180,
render: (r: EventRow) => (
<span className="text-sm text-muted-foreground whitespace-nowrap">
{new Date(r.created_at).toLocaleString()}
{new Date(r.createdAt).toLocaleString()}
</span>
),
},
@@ -88,7 +88,7 @@ export default function AuditLogClient({ events, pagination, initialSearch }: Pr
<div className="flex justify-between items-center">
<Badge variant="outline">{r.user}</Badge>
<span className="text-xs text-muted-foreground">
{new Date(r.created_at).toLocaleString()}
{new Date(r.createdAt).toLocaleString()}
</span>
</div>
<p className="text-sm">{r.summary}</p>

View File

@@ -28,11 +28,11 @@ export default async function AuditLogPage({ searchParams }: PageProps) {
<AuditLogClient
events={events.map((event) => ({
id: event.id,
created_at: event.created_at,
summary: event.summary ?? `${event.action} on ${event.entity_type}`,
user: event.user_id
? userMap.get(event.user_id)?.name ??
userMap.get(event.user_id)?.email ??
createdAt: event.createdAt,
summary: event.summary ?? `${event.action} on ${event.entityType}`,
user: event.userId
? userMap.get(event.userId)?.name ??
userMap.get(event.userId)?.email ??
"System"
: "System",
}))}

View File

@@ -23,10 +23,10 @@ export async function createCertificateAction(formData: FormData) {
{
name: String(formData.get("name") ?? "Certificate"),
type,
domain_names: parseDomains(formData.get("domain_names")),
auto_renew: type === "managed" ? formData.get("auto_renew") === "on" : false,
certificate_pem: type === "imported" ? String(formData.get("certificate_pem") ?? "") : null,
private_key_pem: type === "imported" ? String(formData.get("private_key_pem") ?? "") : null
domainNames: parseDomains(formData.get("domain_names")),
autoRenew: type === "managed" ? formData.get("auto_renew") === "on" : false,
certificatePem: type === "imported" ? String(formData.get("certificate_pem") ?? "") : null,
privateKeyPem: type === "imported" ? String(formData.get("private_key_pem") ?? "") : null
},
userId
);
@@ -42,10 +42,10 @@ export async function updateCertificateAction(id: number, formData: FormData) {
{
name: formData.get("name") ? String(formData.get("name")) : undefined,
type,
domain_names: formData.get("domain_names") ? parseDomains(formData.get("domain_names")) : undefined,
auto_renew: formData.has("auto_renew_present") ? formData.get("auto_renew") === "on" : undefined,
certificate_pem: formData.get("certificate_pem") ? String(formData.get("certificate_pem")) : undefined,
private_key_pem: formData.get("private_key_pem") ? String(formData.get("private_key_pem")) : undefined
domainNames: formData.get("domain_names") ? parseDomains(formData.get("domain_names")) : undefined,
autoRenew: formData.has("auto_renew_present") ? formData.get("auto_renew") === "on" : undefined,
certificatePem: formData.get("certificate_pem") ? String(formData.get("certificate_pem")) : undefined,
privateKeyPem: formData.get("private_key_pem") ? String(formData.get("private_key_pem")) : undefined
},
userId
);

View File

@@ -25,7 +25,7 @@ export async function createCaCertificateAction(formData: FormData) {
if (!certificatePem) throw new Error("Certificate PEM is required");
validatePem(certificatePem);
await createCaCertificate({ name, certificate_pem: certificatePem }, userId);
await createCaCertificate({ name, certificatePem: certificatePem }, userId);
revalidatePath("/certificates");
}
@@ -41,7 +41,7 @@ export async function updateCaCertificateAction(id: number, formData: FormData)
await updateCaCertificate(id, {
...(name ? { name } : {}),
...(certificatePem ? { certificate_pem: certificatePem } : {})
...(certificatePem ? { certificatePem: certificatePem } : {})
}, userId);
revalidatePath("/certificates");
}
@@ -92,7 +92,7 @@ export async function generateCaCertificateAction(formData: FormData): Promise<{
const certificatePem = forge.pki.certificateToPem(cert);
const privateKeyPem = forge.pki.privateKeyToPem(keypair.privateKey);
const record = await createCaCertificate({ name, certificate_pem: certificatePem, private_key_pem: privateKeyPem }, userId);
const record = await createCaCertificate({ name, certificatePem: certificatePem, privateKeyPem: privateKeyPem }, userId);
revalidatePath("/certificates");
return { id: record.id };
}
@@ -125,7 +125,7 @@ export async function issueClientCertificateAction(
if (!caCertRecord) throw new Error("CA certificate not found");
const caKey = forge.pki.privateKeyFromPem(caPrivateKeyPem);
const caCert = forge.pki.certificateFromPem(caCertRecord.certificate_pem);
const caCert = forge.pki.certificateFromPem(caCertRecord.certificatePem);
const keypair = forge.pki.rsa.generateKeyPair({ bits: 2048 });
const cert = forge.pki.createCertificate();
@@ -149,13 +149,13 @@ export async function issueClientCertificateAction(
await createIssuedClientCertificate(
{
ca_certificate_id: caCertId,
common_name: commonName,
serial_number: cert.serialNumber.toUpperCase(),
fingerprint_sha256: certificate.fingerprint256,
certificate_pem: certificatePem,
valid_from: new Date(certificate.validFrom).toISOString(),
valid_to: new Date(certificate.validTo).toISOString()
caCertificateId: caCertId,
commonName: commonName,
serialNumber: cert.serialNumber.toUpperCase(),
fingerprintSha256: certificate.fingerprint256,
certificatePem: certificatePem,
validFrom: new Date(certificate.validFrom).toISOString(),
validTo: new Date(certificate.validTo).toISOString()
},
userId
);
@@ -184,5 +184,5 @@ export async function revokeIssuedClientCertificateAction(id: number): Promise<{
const userId = Number(session.user.id);
const record = await revokeIssuedClientCertificate(id, userId);
revalidatePath("/certificates");
return { revokedAt: record.revoked_at! };
return { revokedAt: record.revokedAt! };
}

View File

@@ -90,7 +90,7 @@ export function CaCertDrawer({ open, cert, onClose }: Props) {
id="edit-cert-pem"
name="certificate_pem"
required
defaultValue={cert.certificate_pem}
defaultValue={cert.certificatePem}
rows={8}
className="font-mono text-xs"
/>

View File

@@ -49,7 +49,7 @@ function IssuedCertsPanel({ ca }: { ca: CaCertificateView }) {
const [issueCaOpen, setIssueCaOpen] = useState(false);
const [manageOpen, setManageOpen] = useState(false);
const active = ca.issuedCerts.filter((c) => !c.revoked_at);
const active = ca.issuedCerts.filter((c) => !c.revokedAt);
return (
<div className="px-5 py-4 bg-muted/30 border-t">
@@ -62,7 +62,7 @@ function IssuedCertsPanel({ ca }: { ca: CaCertificateView }) {
</span>
</span>
<div className="flex gap-2">
{ca.has_private_key && (
{ca.hasPrivateKey && (
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => setIssueCaOpen(true)}>
Issue Cert
</Button>
@@ -80,10 +80,10 @@ function IssuedCertsPanel({ ca }: { ca: CaCertificateView }) {
) : (
<div className="flex flex-col divide-y divide-border rounded-md border overflow-hidden">
{active.slice(0, 5).map((issued) => {
const expired = new Date(issued.valid_to).getTime() < Date.now();
const expired = new Date(issued.validTo).getTime() < Date.now();
return (
<div key={issued.id} className="flex items-center justify-between gap-2 px-3 py-2 bg-background/60">
<span className="text-sm font-mono">{issued.common_name}</span>
<span className="text-sm font-mono">{issued.commonName}</span>
<Badge variant={expired ? "destructive" : "success"} className="text-[10px] px-1.5 py-0">
{expired ? "Expired" : "Active"}
</Badge>
@@ -135,7 +135,7 @@ function CaActionsMenu({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{ca.has_private_key && (
{ca.hasPrivateKey && (
<DropdownMenuItem onClick={() => { setOpen(false); setIssuedOpen(true); }}>
Issue Client Cert
</DropdownMenuItem>
@@ -186,7 +186,7 @@ export function CaTab({ caCertificates, search, statusFilter }: Props) {
</Card>
) : (
filtered.map((ca) => {
const activeCount = ca.issuedCerts.filter((c) => !c.revoked_at).length;
const activeCount = ca.issuedCerts.filter((c) => !c.revokedAt).length;
return (
<Card key={ca.id} className="border-l-2 border-l-violet-500">
<CardContent className="p-4 flex flex-col gap-2">
@@ -200,7 +200,7 @@ export function CaTab({ caCertificates, search, statusFilter }: Props) {
<CaActionsMenu ca={ca} onEdit={() => setDrawerCert(ca)} onDelete={() => setDeleteCert(ca)} />
</div>
<div className="flex flex-wrap gap-1.5">
{ca.has_private_key && (
{ca.hasPrivateKey && (
<Badge variant="success" className="text-[10px] px-1.5 py-0">
<KeyRound className="h-2.5 w-2.5 mr-0.5" />Key stored
</Badge>
@@ -210,7 +210,7 @@ export function CaTab({ caCertificates, search, statusFilter }: Props) {
{activeCount}/{ca.issuedCerts.length} active
</Badge>
)}
<span className="text-xs text-muted-foreground">{formatRelativeDate(ca.created_at)}</span>
<span className="text-xs text-muted-foreground">{formatRelativeDate(ca.createdAt)}</span>
</div>
</CardContent>
</Card>
@@ -241,7 +241,7 @@ export function CaTab({ caCertificates, search, statusFilter }: Props) {
</TableRow>
) : (
filtered.map((ca) => {
const activeCount = ca.issuedCerts.filter((c) => !c.revoked_at).length;
const activeCount = ca.issuedCerts.filter((c) => !c.revokedAt).length;
const expanded = expandedId === ca.id;
return (
<React.Fragment key={ca.id}>
@@ -267,7 +267,7 @@ export function CaTab({ caCertificates, search, statusFilter }: Props) {
</div>
</TableCell>
<TableCell>
{ca.has_private_key ? (
{ca.hasPrivateKey ? (
<Badge variant="success" className="text-[10px] px-1.5 py-0">
<KeyRound className="h-2.5 w-2.5 mr-0.5" />Stored
</Badge>
@@ -285,7 +285,7 @@ export function CaTab({ caCertificates, search, statusFilter }: Props) {
)}
</TableCell>
<TableCell>
<span className="text-sm text-muted-foreground">{formatRelativeDate(ca.created_at)}</span>
<span className="text-sm text-muted-foreground">{formatRelativeDate(ca.createdAt)}</span>
</TableCell>
<TableCell className="text-right">
<CaActionsMenu

View File

@@ -254,7 +254,7 @@ function LegacyManagedTable({ managedCerts }: { managedCerts: ManagedCertView[]
id: "domains",
label: "Domains",
render: (c: ManagedCertView) => (
<p className="text-sm text-muted-foreground font-mono">{c.domain_names.join(", ")}</p>
<p className="text-sm text-muted-foreground font-mono">{c.domainNames.join(", ")}</p>
),
},
{

View File

@@ -22,7 +22,7 @@ export type AcmeHost = {
id: number;
name: string;
domains: string[];
ssl_forced: boolean;
sslForced: boolean;
enabled: boolean;
};
@@ -37,7 +37,7 @@ export type ImportedCertView = {
usedBy: { id: number; name: string; domains: string[] }[];
};
export type ManagedCertView = { id: number; name: string; domain_names: string[] };
export type ManagedCertView = { id: number; name: string; domainNames: string[] };
const PER_PAGE = 25;
@@ -124,7 +124,7 @@ export default async function CertificatesPage({ searchParams }: PageProps) {
id: r.id,
name: r.name,
domains: JSON.parse(r.domains) as string[],
ssl_forced: r.sslForced,
sslForced: r.sslForced,
enabled: r.enabled,
}));
@@ -143,9 +143,9 @@ export default async function CertificatesPage({ searchParams }: PageProps) {
const importedCerts: ImportedCertView[] = [];
const managedCerts: ManagedCertView[] = [];
const issuedByCa = issuedClientCerts.reduce<Map<number, IssuedClientCertificate[]>>((map, cert) => {
const current = map.get(cert.ca_certificate_id) ?? [];
const current = map.get(cert.caCertificateId) ?? [];
current.push(cert);
map.set(cert.ca_certificate_id, current);
map.set(cert.caCertificateId, current);
return map;
}, new Map());
const caCertificateViews: CaCertificateView[] = caCerts.map((cert) => ({
@@ -168,7 +168,7 @@ export default async function CertificatesPage({ searchParams }: PageProps) {
usedBy: usageMap.get(cert.id) ?? [],
});
} else {
managedCerts.push({ id: cert.id, name: cert.name, domain_names: domainNames });
managedCerts.push({ id: cert.id, name: cert.name, domainNames: domainNames });
}
}

View File

@@ -17,10 +17,10 @@ import {
} from "./actions";
type GroupMember = {
user_id: number;
userId: number;
email: string;
name: string | null;
created_at: string;
createdAt: string;
};
type Group = {
@@ -28,8 +28,8 @@ type Group = {
name: string;
description: string | null;
members: GroupMember[];
created_at: string;
updated_at: string;
createdAt: string;
updatedAt: string;
};
type UserEntry = {
@@ -50,7 +50,7 @@ export default function GroupsClient({ groups, users }: Props) {
const [addMemberGroupId, setAddMemberGroupId] = useState<number | null>(null);
function getAvailableUsers(group: Group): UserEntry[] {
const memberIds = new Set(group.members.map((m) => m.user_id));
const memberIds = new Set(group.members.map((m) => m.userId));
return users.filter((u) => !memberIds.has(u.id));
}
@@ -204,7 +204,7 @@ export default function GroupsClient({ groups, users }: Props) {
<div className="space-y-1">
{group.members.map((member) => (
<div
key={member.user_id}
key={member.userId}
className="flex items-center justify-between py-1 px-2 rounded hover:bg-muted/50"
>
<div className="flex items-center gap-2">
@@ -223,7 +223,7 @@ export default function GroupsClient({ groups, users }: Props) {
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive"
onClick={async () => {
await removeGroupMemberAction(group.id, member.user_id);
await removeGroupMemberAction(group.id, member.userId);
router.refresh();
}}
title="Remove member"

View File

@@ -31,9 +31,9 @@ type Props = {
};
function formatMatcher(host: L4ProxyHost): string {
switch (host.matcher_type) {
case "tls_sni": return `SNI: ${host.matcher_value.join(", ")}`;
case "http_host": return `Host: ${host.matcher_value.join(", ")}`;
switch (host.matcherType) {
case "tls_sni": return `SNI: ${host.matcherValue.join(", ")}`;
case "http_host": return `Host: ${host.matcherValue.join(", ")}`;
case "proxy_protocol": return "Proxy Protocol";
default: return "None";
}
@@ -111,10 +111,10 @@ export default function L4ProxyHostsClient({ hosts, pagination, initialSearch, i
{
id: "listen",
label: "Listen",
sortKey: "listen_address",
sortKey: "listenAddress",
render: (host: L4ProxyHost) => (
<span className="text-sm font-mono font-medium tabular-nums text-foreground/80">
{host.listen_address}
{host.listenAddress}
</span>
),
},
@@ -190,7 +190,7 @@ export default function L4ProxyHostsClient({ hosts, pagination, initialSearch, i
<ProtocolBadge protocol={host.protocol} />
</div>
<p className="text-xs text-muted-foreground font-mono truncate">
{host.listen_address}
{host.listenAddress}
<span className="mx-1 text-muted-foreground"></span>
{host.upstreams[0]}{host.upstreams.length > 1 ? ` +${host.upstreams.length - 1}` : ""}
</p>

View File

@@ -178,17 +178,17 @@ export async function createL4ProxyHostAction(
const input: L4ProxyHostInput = {
name: String(formData.get("name") ?? "Untitled"),
protocol: parseProtocol(formData),
listen_address: String(formData.get("listen_address") ?? "").trim(),
listenAddress: String(formData.get("listen_address") ?? "").trim(),
upstreams: parseUpstreams(formData.get("upstreams")),
matcher_type: matcherType,
matcher_value: matcherValue,
tls_termination: parseCheckbox(formData.get("tls_termination")),
proxy_protocol_version: parseProxyProtocolVersion(formData),
proxy_protocol_receive: parseCheckbox(formData.get("proxy_protocol_receive")),
matcherType: matcherType,
matcherValue: matcherValue,
tlsTermination: parseCheckbox(formData.get("tls_termination")),
proxyProtocolVersion: parseProxyProtocolVersion(formData),
proxyProtocolReceive: parseCheckbox(formData.get("proxy_protocol_receive")),
enabled: parseCheckbox(formData.get("enabled")),
load_balancer: parseL4LoadBalancerConfig(formData),
dns_resolver: parseL4DnsResolverConfig(formData),
upstream_dns_resolution: parseL4UpstreamDnsResolutionConfig(formData),
loadBalancer: parseL4LoadBalancerConfig(formData),
dnsResolver: parseL4DnsResolverConfig(formData),
upstreamDnsResolution: parseL4UpstreamDnsResolutionConfig(formData),
...parseL4GeoBlockConfig(formData),
};
@@ -219,17 +219,17 @@ export async function updateL4ProxyHostAction(
const input: Partial<L4ProxyHostInput> = {
name: formData.get("name") ? String(formData.get("name")) : undefined,
protocol: parseProtocol(formData),
listen_address: formData.get("listen_address") ? String(formData.get("listen_address")).trim() : undefined,
listenAddress: formData.get("listen_address") ? String(formData.get("listen_address")).trim() : undefined,
upstreams: formData.get("upstreams") ? parseUpstreams(formData.get("upstreams")) : undefined,
matcher_type: matcherType,
matcher_value: matcherValue,
tls_termination: parseCheckbox(formData.get("tls_termination")),
proxy_protocol_version: parseProxyProtocolVersion(formData),
proxy_protocol_receive: parseCheckbox(formData.get("proxy_protocol_receive")),
matcherType: matcherType,
matcherValue: matcherValue,
tlsTermination: parseCheckbox(formData.get("tls_termination")),
proxyProtocolVersion: parseProxyProtocolVersion(formData),
proxyProtocolReceive: parseCheckbox(formData.get("proxy_protocol_receive")),
enabled: formData.has("enabled_present") ? parseCheckbox(formData.get("enabled")) : undefined,
load_balancer: parseL4LoadBalancerConfig(formData),
dns_resolver: parseL4DnsResolverConfig(formData),
upstream_dns_resolution: parseL4UpstreamDnsResolutionConfig(formData),
loadBalancer: parseL4LoadBalancerConfig(formData),
dnsResolver: parseL4DnsResolverConfig(formData),
upstreamDnsResolution: parseL4UpstreamDnsResolutionConfig(formData),
...parseL4GeoBlockConfig(formData),
};

View File

@@ -27,7 +27,7 @@ export default async function L4ProxyHostsPage({ searchParams }: PageProps) {
hosts={hosts}
pagination={{ total, page, perPage: PER_PAGE }}
initialSearch={search ?? ""}
initialSort={{ sortBy: sortBy ?? "created_at", sortDir }}
initialSort={{ sortBy: sortBy ?? "createdAt", sortDir }}
/>
);
}

View File

@@ -82,7 +82,7 @@ export default async function OverviewPage() {
isAdmin={true}
recentEvents={recentEventsRaw.map((event) => ({
summary: event.summary ?? `${event.action} on ${event.entityType}`,
created_at: toIso(event.createdAt)!
createdAt: toIso(event.createdAt)!
}))}
/>
);

View File

@@ -17,7 +17,7 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { signIn } from "next-auth/react";
import { authClient } from "@/src/lib/auth-client";
import { Camera, Check, Clock, Copy, Key, Link, LogIn, Lock, Plus, Trash2, Unlink, User, AlertTriangle } from "lucide-react";
import type { ApiToken } from "@/lib/models/api-tokens";
import { createApiTokenAction, deleteApiTokenAction } from "../api-tokens/actions";
@@ -26,11 +26,11 @@ interface UserData {
id: number;
email: string;
name: string | null;
provider: string;
subject: string;
password_hash: string | null;
provider: string | null;
subject: string | null;
passwordHash: string | null;
role: string;
avatar_url: string | null;
avatarUrl: string | null;
}
interface ProfileClientProps {
@@ -48,11 +48,11 @@ export default function ProfileClient({ user, enabledProviders, apiTokens }: Pro
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [avatarUrl, setAvatarUrl] = useState<string | null>(user.avatar_url);
const [avatarUrl, setAvatarUrl] = useState<string | null>(user.avatarUrl);
const [newToken, setNewToken] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const hasPassword = !!user.password_hash;
const hasPassword = !!user.passwordHash;
const hasOAuth = user.provider !== "credentials";
const handlePasswordChange = async () => {
@@ -158,9 +158,7 @@ export default function ProfileClient({ user, enabledProviders, apiTokens }: Pro
}
// Now initiate OAuth flow
await signIn(providerId, {
callbackUrl: "/profile"
});
await authClient.signIn.social({ provider: providerId, callbackURL: "/profile" });
} catch {
setError("An error occurred while linking OAuth");
setLoading(false);
@@ -382,7 +380,7 @@ export default function ProfileClient({ user, enabledProviders, apiTokens }: Pro
<div>
<p className="text-sm text-muted-foreground">Authentication Method</p>
<Badge variant={user.provider === "credentials" ? "secondary" : "default"}>
{getProviderName(user.provider)}
{getProviderName(user.provider ?? "")}
</Badge>
</div>
@@ -442,7 +440,7 @@ export default function ProfileClient({ user, enabledProviders, apiTokens }: Pro
{hasOAuth ? (
<div>
<p className="text-sm text-muted-foreground mb-2">
Your account is linked to {getProviderName(user.provider)}
Your account is linked to {getProviderName(user.provider ?? "")}
</p>
{hasPassword ? (
@@ -523,7 +521,7 @@ export default function ProfileClient({ user, enabledProviders, apiTokens }: Pro
{apiTokens.length > 0 && (
<div className="flex flex-col divide-y divide-border rounded-md border overflow-hidden">
{apiTokens.map((token) => {
const expired = isExpired(token.expires_at);
const expired = isExpired(token.expiresAt);
return (
<div
key={token.id}
@@ -543,15 +541,15 @@ export default function ProfileClient({ user, enabledProviders, apiTokens }: Pro
</div>
<div className="flex flex-wrap gap-x-3 gap-y-0">
<p className="text-xs text-muted-foreground">
Created {formatDate(token.created_at)}
Created {formatDate(token.createdAt)}
</p>
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="h-3 w-3" />
Used {formatDate(token.last_used_at)}
Used {formatDate(token.lastUsedAt)}
</p>
{token.expires_at && (
{token.expiresAt && (
<p className="text-xs text-muted-foreground">
{expired ? "Expired" : "Expires"} {formatDate(token.expires_at)}
{expired ? "Expired" : "Expires"} {formatDate(token.expiresAt)}
</p>
)}
</div>
@@ -656,7 +654,7 @@ export default function ProfileClient({ user, enabledProviders, apiTokens }: Pro
<DialogHeader>
<DialogTitle>Unlink OAuth Account</DialogTitle>
<DialogDescription>
Are you sure you want to unlink your {getProviderName(user.provider)} account?
Are you sure you want to unlink your {getProviderName(user.provider ?? "")} account?
You will only be able to sign in with your username and password after this.
</DialogDescription>
</DialogHeader>

View File

@@ -1,6 +1,6 @@
import { requireUser } from "@/src/lib/auth";
import { getUserById } from "@/src/lib/models/user";
import { getEnabledOAuthProviders } from "@/src/lib/config";
import { getProviderDisplayList } from "@/src/lib/models/oauth-providers";
import { listApiTokens } from "@/src/lib/models/api-tokens";
import ProfileClient from "./ProfileClient";
import { redirect } from "next/navigation";
@@ -15,7 +15,7 @@ export default async function ProfilePage() {
}
const [enabledProviders, apiTokens] = await Promise.all([
Promise.resolve(getEnabledOAuthProviders()),
getProviderDisplayList(),
listApiTokens(userId),
]);

View File

@@ -133,10 +133,10 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, caC
label: "Features",
render: (host: ProxyHost) => {
const badges = [
host.certificate_id && (
host.certificateId && (
<Badge key="tls" variant="info" className="text-[10px] px-1.5 py-0">TLS</Badge>
),
host.access_list_id && (
host.accessListId && (
<Badge key="auth" variant="warning" className="text-[10px] px-1.5 py-0">
<Shield className="h-2.5 w-2.5 mr-0.5" />Auth
</Badge>
@@ -156,7 +156,7 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, caC
<MapPin className="h-2.5 w-2.5 mr-0.5" />Geo
</Badge>
),
host.load_balancer?.enabled && (
host.loadBalancer?.enabled && (
<Badge key="lb" variant="secondary" className="text-[10px] px-1.5 py-0">
<Scale className="h-2.5 w-2.5 mr-0.5" />LB
</Badge>
@@ -244,7 +244,7 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, caC
</p>
<div className="flex items-center gap-1.5 mt-1">
<StatusChip status={host.enabled ? "active" : "inactive"} />
{host.certificate_id && <Badge variant="info" className="text-[10px] px-1.5 py-0">TLS</Badge>}
{host.certificateId && <Badge variant="info" className="text-[10px] px-1.5 py-0">TLS</Badge>}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">

View File

@@ -497,7 +497,7 @@ export async function createProxyHostAction(
const session = await requireAdmin();
const userId = Number(session.user.id);
// Parse certificate_id safely
// Parse certificateId safely
const parsedCertificateId = parseCertificateId(formData.get("certificate_id"));
// Validate certificate exists and get sanitized value
@@ -516,25 +516,25 @@ export async function createProxyHostAction(
name: String(formData.get("name") ?? "Untitled"),
domains: parseCsv(formData.get("domains")),
upstreams: parseUpstreams(formData.get("upstreams")),
certificate_id: certificateId,
access_list_id: parseAccessListId(formData.get("access_list_id")),
ssl_forced: formData.has("ssl_forced_present") ? parseCheckbox(formData.get("ssl_forced")) : undefined,
hsts_subdomains: parseCheckbox(formData.get("hsts_subdomains")),
skip_https_hostname_validation: parseCheckbox(formData.get("skip_https_hostname_validation")),
certificateId: certificateId,
accessListId: parseAccessListId(formData.get("access_list_id")),
sslForced: formData.has("ssl_forced_present") ? parseCheckbox(formData.get("ssl_forced")) : undefined,
hstsSubdomains: parseCheckbox(formData.get("hsts_subdomains")),
skipHttpsHostnameValidation: parseCheckbox(formData.get("skip_https_hostname_validation")),
enabled: parseCheckbox(formData.get("enabled")),
custom_pre_handlers_json: parseOptionalText(formData.get("custom_pre_handlers_json")),
custom_reverse_proxy_json: parseOptionalText(formData.get("custom_reverse_proxy_json")),
customPreHandlersJson: parseOptionalText(formData.get("custom_pre_handlers_json")),
customReverseProxyJson: parseOptionalText(formData.get("custom_reverse_proxy_json")),
authentik: parseAuthentikConfig(formData),
cpm_forward_auth: parseCpmForwardAuthConfig(formData),
load_balancer: parseLoadBalancerConfig(formData),
dns_resolver: parseDnsResolverConfig(formData),
upstream_dns_resolution: parseUpstreamDnsResolutionConfig(formData),
cpmForwardAuth: parseCpmForwardAuthConfig(formData),
loadBalancer: parseLoadBalancerConfig(formData),
dnsResolver: parseDnsResolverConfig(formData),
upstreamDnsResolution: parseUpstreamDnsResolutionConfig(formData),
...parseGeoBlockConfig(formData),
...parseWafConfig(formData),
mtls: parseMtlsConfig(formData),
redirects: parseRedirectsConfig(formData),
rewrite: parseRewriteConfig(formData),
location_rules: parseLocationRulesConfig(formData),
locationRules: parseLocationRulesConfig(formData),
},
userId
);
@@ -542,7 +542,7 @@ export async function createProxyHostAction(
// Save forward auth access if CPM forward auth is enabled
const faUserIds = formData.getAll("cpm_fa_user_id").map((v) => Number(v)).filter((n) => n > 0);
const faGroupIds = formData.getAll("cpm_fa_group_id").map((v) => Number(v)).filter((n) => n > 0);
if (host.cpm_forward_auth?.enabled && (faUserIds.length > 0 || faGroupIds.length > 0)) {
if (host.cpmForwardAuth?.enabled && (faUserIds.length > 0 || faGroupIds.length > 0)) {
await setForwardAuthAccess(host.id, { userIds: faUserIds, groupIds: faGroupIds }, userId);
}
@@ -597,30 +597,30 @@ export async function updateProxyHostAction(
name: formData.get("name") ? String(formData.get("name")) : undefined,
domains: formData.get("domains") ? parseCsv(formData.get("domains")) : undefined,
upstreams: formData.get("upstreams") ? parseUpstreams(formData.get("upstreams")) : undefined,
certificate_id: certificateId,
access_list_id: formData.has("access_list_id")
certificateId: certificateId,
accessListId: formData.has("access_list_id")
? parseAccessListId(formData.get("access_list_id"))
: undefined,
hsts_subdomains: boolField("hsts_subdomains"),
skip_https_hostname_validation: boolField("skip_https_hostname_validation"),
hstsSubdomains: boolField("hsts_subdomains"),
skipHttpsHostnameValidation: boolField("skip_https_hostname_validation"),
enabled: boolField("enabled"),
custom_pre_handlers_json: formData.has("custom_pre_handlers_json")
customPreHandlersJson: formData.has("custom_pre_handlers_json")
? parseOptionalText(formData.get("custom_pre_handlers_json"))
: undefined,
custom_reverse_proxy_json: formData.has("custom_reverse_proxy_json")
customReverseProxyJson: formData.has("custom_reverse_proxy_json")
? parseOptionalText(formData.get("custom_reverse_proxy_json"))
: undefined,
authentik: parseAuthentikConfig(formData),
cpm_forward_auth: parseCpmForwardAuthConfig(formData),
load_balancer: parseLoadBalancerConfig(formData),
dns_resolver: parseDnsResolverConfig(formData),
upstream_dns_resolution: parseUpstreamDnsResolutionConfig(formData),
cpmForwardAuth: parseCpmForwardAuthConfig(formData),
loadBalancer: parseLoadBalancerConfig(formData),
dnsResolver: parseDnsResolverConfig(formData),
upstreamDnsResolution: parseUpstreamDnsResolutionConfig(formData),
...parseGeoBlockConfig(formData),
...parseWafConfig(formData),
mtls: formData.has("mtls_present") ? parseMtlsConfig(formData) : undefined,
redirects: formData.has("redirects_json") ? parseRedirectsConfig(formData) : undefined,
rewrite: formData.has("rewrite_path_prefix") ? parseRewriteConfig(formData) : undefined,
location_rules: formData.has("location_rules_json") ? parseLocationRulesConfig(formData) : undefined,
locationRules: formData.has("location_rules_json") ? parseLocationRulesConfig(formData) : undefined,
},
userId
);

View File

@@ -43,7 +43,7 @@ export default async function ProxyHostsPage({ searchParams }: PageProps) {
]);
// Build forward auth access map for hosts that have CPM forward auth enabled
const faHosts = hosts.filter((h) => h.cpm_forward_auth?.enabled);
const faHosts = hosts.filter((h) => h.cpmForwardAuth?.enabled);
const faAccessEntries = await Promise.all(
faHosts.map((h) => getForwardAuthAccessForHost(h.id).catch(() => []))
);
@@ -51,8 +51,8 @@ export default async function ProxyHostsPage({ searchParams }: PageProps) {
faHosts.forEach((h, i) => {
const entries = faAccessEntries[i];
forwardAuthAccessMap[h.id] = {
userIds: entries.filter((e) => e.user_id !== null).map((e) => e.user_id!),
groupIds: entries.filter((e) => e.group_id !== null).map((e) => e.group_id!),
userIds: entries.filter((e) => e.userId !== null).map((e) => e.userId!),
groupIds: entries.filter((e) => e.groupId !== null).map((e) => e.groupId!),
};
});
@@ -78,7 +78,7 @@ export default async function ProxyHostsPage({ searchParams }: PageProps) {
authentikDefaults={authentikDefaults}
pagination={{ total, page, perPage: PER_PAGE }}
initialSearch={search ?? ""}
initialSort={{ sortBy: sortBy ?? "created_at", sortDir }}
initialSort={{ sortBy: sortBy ?? "createdAt", sortDir }}
mtlsRoles={mtlsRoles}
issuedClientCerts={issuedClientCerts}
forwardAuthUsers={forwardAuthUsers}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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();

View File

@@ -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,

View File

@@ -22,12 +22,12 @@ type UserEntry = {
email: string;
name: string | null;
role: "admin" | "user" | "viewer";
provider: string;
subject: string;
avatar_url: string | null;
provider: string | null;
subject: string | null;
avatarUrl: string | null;
status: string;
created_at: string;
updated_at: string;
createdAt: string;
updatedAt: string;
};
type Props = {

View File

@@ -7,6 +7,6 @@ export default async function UsersPage() {
const allUsers = await listUsers();
// Strip password hashes before sending to client
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const safeUsers = allUsers.map(({ password_hash, ...rest }) => rest);
const safeUsers = allUsers.map(({ passwordHash, ...rest }) => rest);
return <UsersClient users={safeUsers} />;
}

View File

@@ -0,0 +1,12 @@
import { getAuth } from "@/src/lib/auth-server";
import { toNextJsHandler } from "better-auth/next-js";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
return toNextJsHandler(getAuth()).GET(request);
}
export async function POST(request: Request) {
return toNextJsHandler(getAuth()).POST(request);
}

View File

@@ -1,97 +0,0 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { handlers } from "@/src/lib/auth";
import { isRateLimited, registerFailedAttempt, resetAttempts } from "@/src/lib/rate-limit";
export const dynamic = 'force-dynamic';
export const { GET } = handlers;
function getClientIp(request: NextRequest): string {
// Get client IP from headers
// In production, ensure your reverse proxy (Caddy) sets these headers correctly
const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) {
const parts = forwarded.split(",");
return parts[parts.length - 1]?.trim() || "unknown";
}
const real = request.headers.get("x-real-ip");
if (real) {
return real.trim();
}
return "unknown";
}
function buildRateLimitKey(ip: string, username: string) {
const normalizedUsername = username.trim().toLowerCase() || "unknown";
return `login:${ip}:${normalizedUsername}`;
}
function buildBlockedResponse(retryAfterMs?: number) {
const retryAfterSeconds = retryAfterMs ? Math.ceil(retryAfterMs / 1000) : 60;
const retryAfterMinutes = Math.max(1, Math.ceil(retryAfterSeconds / 60));
return NextResponse.json(
{
error: `Too many login attempts. Try again in about ${retryAfterMinutes} minute${retryAfterMinutes === 1 ? "" : "s"}.`
},
{
status: 429,
headers: {
"Retry-After": retryAfterSeconds.toString()
}
}
);
}
export async function POST(request: NextRequest) {
const formData = await request.clone().formData();
const username = String(formData.get("username") ?? "");
const ip = getClientIp(request);
const rateLimitKey = buildRateLimitKey(ip, username);
const limitation = isRateLimited(rateLimitKey);
if (limitation.blocked) {
return buildBlockedResponse(limitation.retryAfterMs);
}
const response = await handlers.POST(request);
// Determine success/failure by inspecting redirect destination, not status code.
// Auth.js returns 302 (direct form) or 200+JSON (X-Auth-Return-Redirect) on both
// success and failure — the error is signaled by the destination URL containing "error=".
const isFailure = await isAuthFailureResponse(response);
if (isFailure) {
const result = registerFailedAttempt(rateLimitKey);
if (result.blocked) {
return buildBlockedResponse(result.retryAfterMs);
}
} else {
resetAttempts(rateLimitKey);
}
return response;
}
async function isAuthFailureResponse(response: Response): Promise<boolean> {
// Redirect case: Auth.js sets Location header
const location = response.headers.get("location");
if (location) {
return location.includes("error=");
}
// JSON case (X-Auth-Return-Redirect: 1): body is {"url": "..."}
const contentType = response.headers.get("content-type") ?? "";
if (response.status === 200 && contentType.includes("application/json")) {
try {
const cloned = response.clone();
const body = await cloned.json() as { url?: string };
if (typeof body.url === "string") {
return body.url.includes("error=");
}
} catch {
// ignore parse errors
}
}
// Any 4xx/5xx is a failure
return response.status >= 400;
}

View File

@@ -1,10 +1,14 @@
import { NextRequest } from "next/server";
import { signOut, checkSameOrigin } from "@/src/lib/auth";
import { NextRequest, NextResponse } from "next/server";
import { getAuth } from "@/src/lib/auth-server";
import { checkSameOrigin } from "@/src/lib/auth";
import { headers } from "next/headers";
export const dynamic = 'force-dynamic';
export const dynamic = "force-dynamic";
export async function POST(request: NextRequest) {
const originCheck = checkSameOrigin(request);
if (originCheck) return originCheck;
await signOut({ redirectTo: "/login" });
await getAuth().api.signOut({ headers: await headers() });
return NextResponse.redirect(new URL("/login", request.url));
}

View File

@@ -60,7 +60,7 @@ export async function POST(request: NextRequest) {
}
// If user has a password, verify current password
if (user.password_hash) {
if (user.passwordHash) {
if (!currentPassword) {
return NextResponse.json(
{ error: "Current password is required" },
@@ -68,7 +68,7 @@ export async function POST(request: NextRequest) {
);
}
const isValid = bcrypt.compareSync(currentPassword, user.password_hash);
const isValid = bcrypt.compareSync(currentPassword, user.passwordHash);
if (!isValid) {
registerFailedAttempt(rateLimitKey);
return NextResponse.json(
@@ -90,10 +90,10 @@ export async function POST(request: NextRequest) {
// Audit log
await createAuditEvent({
userId,
action: user.password_hash ? "password_changed" : "password_set",
action: user.passwordHash ? "password_changed" : "password_set",
entityType: "user",
entityId: userId,
summary: user.password_hash ? "User changed their password" : "User set a password",
summary: user.passwordHash ? "User changed their password" : "User set a password",
});
return NextResponse.json({

View File

@@ -3,9 +3,8 @@ import { auth, checkSameOrigin } from "@/src/lib/auth";
import { getUserById } from "@/src/lib/models/user";
import { createAuditEvent } from "@/src/lib/models/audit";
import db from "@/src/lib/db";
import { users } from "@/src/lib/db/schema";
import { eq } from "drizzle-orm";
import { nowIso } from "@/src/lib/db";
import { accounts } from "@/src/lib/db/schema";
import { and, eq, ne } from "drizzle-orm";
export async function POST(request: NextRequest) {
const originCheck = checkSameOrigin(request);
@@ -25,35 +24,37 @@ export async function POST(request: NextRequest) {
}
// Must have a password before unlinking OAuth
if (!user.password_hash) {
if (!user.passwordHash) {
return NextResponse.json(
{ error: "Cannot unlink OAuth: You must set a password first" },
{ status: 400 }
);
}
// Must be using OAuth to unlink
if (user.provider === "credentials") {
// Check if user has any OAuth account links
const oauthAccounts = await db.select().from(accounts).where(
and(
eq(accounts.userId, userId),
ne(accounts.providerId, "credential")
)
);
if (oauthAccounts.length === 0) {
return NextResponse.json(
{ error: "No OAuth account to unlink" },
{ status: 400 }
);
}
const previousProvider = user.provider;
const previousProvider = oauthAccounts[0].providerId;
// Revert to credentials-only
const email = user.email;
const username = email.replace(/@localhost$/, "") || email.split("@")[0];
await db
.update(users)
.set({
provider: "credentials",
subject: `${username}@localhost`,
updatedAt: nowIso()
})
.where(eq(users.id, userId));
// Delete the OAuth account link(s)
await db.delete(accounts).where(
and(
eq(accounts.userId, userId),
ne(accounts.providerId, "credential")
)
);
// Audit log
await createAuditEvent({

View File

@@ -47,7 +47,7 @@ export async function POST(request: NextRequest) {
// Update user avatar
const updatedUser = await updateUserProfile(userId, {
avatar_url: avatarUrl
avatarUrl: avatarUrl
});
if (!updatedUser) {
@@ -69,7 +69,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({
success: true,
avatarUrl: updatedUser.avatar_url
avatarUrl: updatedUser.avatarUrl
});
} catch (error) {
console.error("Avatar update error:", error);

View File

@@ -0,0 +1,120 @@
import { NextRequest, NextResponse } from "next/server";
import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth";
import { getOAuthProvider, updateOAuthProvider, deleteOAuthProvider } from "@/src/lib/models/oauth-providers";
import type { OAuthProvider } from "@/src/lib/models/oauth-providers";
import { createAuditEvent } from "@/src/lib/models/audit";
import { invalidateProviderCache } from "@/src/lib/auth-server";
function redactSecrets(provider: OAuthProvider) {
const clientId = provider.clientId;
return {
...provider,
clientId: clientId.length > 4 ? "••••" + clientId.slice(-4) : "••••",
clientSecret: "••••••••",
};
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
await requireApiAdmin(request);
const { id } = await params;
const provider = await getOAuthProvider(id);
if (!provider) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(redactSecrets(provider));
} catch (error) {
return apiErrorResponse(error);
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { userId } = await requireApiAdmin(request);
const { id } = await params;
const body = await request.json();
const existing = await getOAuthProvider(id);
if (!existing) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
// Env-sourced providers can only have `enabled` toggled
if (existing.source === "env") {
const allowedKeys = ["enabled"];
const bodyKeys = Object.keys(body).filter((k) => body[k] !== undefined);
const disallowed = bodyKeys.filter((k) => !allowedKeys.includes(k));
if (disallowed.length > 0) {
return NextResponse.json(
{ error: `Environment-sourced providers can only update: ${allowedKeys.join(", ")}` },
{ status: 400 }
);
}
}
const updated = await updateOAuthProvider(id, body);
if (!updated) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
invalidateProviderCache();
await createAuditEvent({
userId,
action: "update",
entityType: "oauth_provider",
entityId: null,
summary: `Updated OAuth provider "${updated.name}"`,
data: JSON.stringify({ providerId: updated.id, fields: Object.keys(body) }),
});
return NextResponse.json(redactSecrets(updated));
} catch (error) {
return apiErrorResponse(error);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { userId } = await requireApiAdmin(request);
const { id } = await params;
const existing = await getOAuthProvider(id);
if (!existing) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (existing.source === "env") {
return NextResponse.json(
{ error: "Cannot delete an environment-sourced OAuth provider" },
{ status: 400 }
);
}
await deleteOAuthProvider(id);
invalidateProviderCache();
await createAuditEvent({
userId,
action: "delete",
entityType: "oauth_provider",
entityId: null,
summary: `Deleted OAuth provider "${existing.name}"`,
data: JSON.stringify({ providerId: existing.id, name: existing.name }),
});
return NextResponse.json({ ok: true });
} catch (error) {
return apiErrorResponse(error);
}
}

View File

@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth";
import { listOAuthProviders, createOAuthProvider } from "@/src/lib/models/oauth-providers";
import type { OAuthProvider } from "@/src/lib/models/oauth-providers";
import { createAuditEvent } from "@/src/lib/models/audit";
import { invalidateProviderCache } from "@/src/lib/auth-server";
function redactSecrets(provider: OAuthProvider) {
const clientId = provider.clientId;
return {
...provider,
clientId: clientId.length > 4 ? "••••" + clientId.slice(-4) : "••••",
clientSecret: "••••••••",
};
}
export async function GET(request: NextRequest) {
try {
await requireApiAdmin(request);
const providers = await listOAuthProviders();
return NextResponse.json(providers.map(redactSecrets));
} catch (error) {
return apiErrorResponse(error);
}
}
export async function POST(request: NextRequest) {
try {
const { userId } = await requireApiAdmin(request);
const body = await request.json();
if (!body.name || typeof body.name !== "string") {
return NextResponse.json({ error: "name is required" }, { status: 400 });
}
if (!body.clientId || typeof body.clientId !== "string") {
return NextResponse.json({ error: "clientId is required" }, { status: 400 });
}
if (!body.clientSecret || typeof body.clientSecret !== "string") {
return NextResponse.json({ error: "clientSecret is required" }, { status: 400 });
}
const provider = await createOAuthProvider({
name: body.name,
type: body.type ?? "oidc",
clientId: body.clientId,
clientSecret: body.clientSecret,
issuer: body.issuer ?? null,
authorizationUrl: body.authorizationUrl ?? null,
tokenUrl: body.tokenUrl ?? null,
userinfoUrl: body.userinfoUrl ?? null,
scopes: body.scopes ?? "openid email profile",
autoLink: body.autoLink ?? false,
source: "ui",
});
invalidateProviderCache();
await createAuditEvent({
userId,
action: "create",
entityType: "oauth_provider",
entityId: null,
summary: `Created OAuth provider "${provider.name}"`,
data: JSON.stringify({ providerId: provider.id, name: provider.name, type: provider.type }),
});
return NextResponse.json(redactSecrets(provider), { status: 201 });
} catch (error) {
return apiErrorResponse(error);
}
}

View File

@@ -1982,7 +1982,7 @@ const spec = {
},
User: {
type: "object",
description: "User account (password_hash is never exposed)",
description: "User account (passwordHash is never exposed)",
properties: {
id: { type: "integer" },
email: { type: "string" },
@@ -1990,12 +1990,12 @@ const spec = {
role: { type: "string", enum: ["admin", "user", "viewer"] },
provider: { type: "string", example: "credentials" },
subject: { type: "string" },
avatar_url: { type: ["string", "null"] },
avatarUrl: { type: ["string", "null"] },
status: { type: "string", enum: ["active", "inactive"] },
created_at: { type: "string", format: "date-time" },
updated_at: { type: "string", format: "date-time" },
createdAt: { type: "string", format: "date-time" },
updatedAt: { type: "string", format: "date-time" },
},
required: ["id", "email", "role", "provider", "subject", "status", "created_at", "updated_at"],
required: ["id", "email", "role", "provider", "subject", "status", "createdAt", "updatedAt"],
},
AuditLogEvent: {
type: "object",

View File

@@ -3,7 +3,7 @@ import { requireApiUser, requireApiAdmin, apiErrorResponse, ApiAuthError } from
import { getUserById, updateUserProfile, updateUserRole, updateUserStatus, deleteUser } from "@/src/lib/models/user";
function stripPasswordHash(user: Record<string, unknown>) {
const { password_hash: _, ...rest } = user;
const { passwordHash: _, ...rest } = user;
void _;
return rest;
}
@@ -62,9 +62,9 @@ export async function PUT(
const profileFields: Record<string, unknown> = {};
if (body.email !== undefined) profileFields.email = body.email;
if (body.name !== undefined) profileFields.name = body.name;
if (body.avatar_url !== undefined) profileFields.avatar_url = body.avatar_url;
if (body.avatarUrl !== undefined) profileFields.avatarUrl = body.avatarUrl;
if (Object.keys(profileFields).length > 0) {
await updateUserProfile(targetId, profileFields as { email?: string; name?: string | null; avatar_url?: string | null });
await updateUserProfile(targetId, profileFields as { email?: string; name?: string | null; avatarUrl?: string | null });
}
const user = await getUserById(targetId);

View File

@@ -3,7 +3,7 @@ import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth";
import { listUsers } from "@/src/lib/models/user";
function stripPasswordHash(user: Record<string, unknown>) {
const { password_hash: _, ...rest } = user;
const { passwordHash: _, ...rest } = user;
void _;
return rest;
}