Files
caddy-proxy-manager/app/(dashboard)/settings/OAuthProvidersSection.tsx
fuomag9 3a16d6e9b1 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>
2026-04-12 21:11:48 +02:00

465 lines
16 KiB
TypeScript

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