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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
}))}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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! };
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)!
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
464
app/(dashboard)/settings/OAuthProvidersSection.tsx
Normal file
464
app/(dashboard)/settings/OAuthProvidersSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
12
app/api/auth/[...all]/route.ts
Normal file
12
app/api/auth/[...all]/route.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
120
app/api/v1/oauth-providers/[id]/route.ts
Normal file
120
app/api/v1/oauth-providers/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
71
app/api/v1/oauth-providers/route.ts
Normal file
71
app/api/v1/oauth-providers/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user