- 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>
287 lines
8.9 KiB
TypeScript
287 lines
8.9 KiB
TypeScript
"use client";
|
|
|
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { DataTable } from "@/components/ui/DataTable";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip";
|
|
import { AlertTriangle, FileKey, MoreVertical, Plus } from "lucide-react";
|
|
import { useState, useTransition } from "react";
|
|
import { deleteCertificateAction } from "../actions";
|
|
import type { ImportedCertView, ManagedCertView } from "../page";
|
|
import { RelativeTime } from "./RelativeTime";
|
|
import { ImportCertDrawer } from "./ImportCertDrawer";
|
|
|
|
type Props = {
|
|
importedCerts: ImportedCertView[];
|
|
managedCerts: ManagedCertView[];
|
|
search: string;
|
|
statusFilter: string | null;
|
|
};
|
|
|
|
function DomainsCell({ domains }: { domains: string[] }) {
|
|
const visible = domains.slice(0, 2);
|
|
const rest = domains.slice(2);
|
|
return (
|
|
<div className="flex flex-wrap gap-1">
|
|
{visible.map((d) => (
|
|
<Badge key={d} variant="info" className="text-[10px] px-1.5 py-0 font-mono">{d}</Badge>
|
|
))}
|
|
{rest.length > 0 && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 cursor-default">+{rest.length}</Badge>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{rest.join(", ")}</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ActionsMenu({ cert, onEdit }: { cert: ImportedCertView; onEdit: () => void }) {
|
|
const [open, setOpen] = useState(false);
|
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
const [isPending, startTransition] = useTransition();
|
|
|
|
function handleDelete() {
|
|
startTransition(async () => {
|
|
await deleteCertificateAction(cert.id);
|
|
setOpen(false);
|
|
});
|
|
}
|
|
|
|
return (
|
|
<DropdownMenu
|
|
open={open}
|
|
onOpenChange={(v) => {
|
|
setOpen(v);
|
|
if (!v) setConfirmDelete(false);
|
|
}}
|
|
>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
|
<MoreVertical className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem onClick={() => { setOpen(false); onEdit(); }}>Edit</DropdownMenuItem>
|
|
{confirmDelete ? (
|
|
<DropdownMenuItem
|
|
className="text-destructive focus:text-destructive"
|
|
disabled={isPending}
|
|
onClick={handleDelete}
|
|
>
|
|
{isPending ? "Deleting..." : "Confirm Delete"}
|
|
</DropdownMenuItem>
|
|
) : (
|
|
<DropdownMenuItem
|
|
className="text-destructive focus:text-destructive"
|
|
onClick={() => setConfirmDelete(true)}
|
|
>
|
|
Delete
|
|
</DropdownMenuItem>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|
|
|
|
function importedMobileCard(c: ImportedCertView, onEdit: () => void) {
|
|
return (
|
|
<Card className={[
|
|
"border-l-2",
|
|
c.expiryStatus === "expired" ? "border-l-rose-500"
|
|
: c.expiryStatus === "expiring_soon" ? "border-l-amber-500"
|
|
: "border-l-emerald-500",
|
|
].join(" ")}>
|
|
<CardContent className="p-4 flex flex-col gap-1.5">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-emerald-500/30 bg-emerald-500/10 text-emerald-500">
|
|
<FileKey className="h-3.5 w-3.5" />
|
|
</div>
|
|
<span className="text-sm font-semibold">{c.name}</span>
|
|
</div>
|
|
<ActionsMenu cert={c} onEdit={onEdit} />
|
|
</div>
|
|
<p className="text-xs text-muted-foreground font-mono">
|
|
{c.domains.slice(0, 2).join(", ")}{c.domains.length > 2 ? ` +${c.domains.length - 2}` : ""}
|
|
</p>
|
|
<RelativeTime validTo={c.validTo} status={c.expiryStatus} />
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export function ImportedTab({ importedCerts, managedCerts, search, statusFilter }: Props) {
|
|
const [drawerCert, setDrawerCert] = useState<ImportedCertView | null | false>(false);
|
|
const mobileCardRenderer = (c: ImportedCertView) => importedMobileCard(c, () => setDrawerCert(c));
|
|
|
|
const filtered = importedCerts.filter((c) => {
|
|
if (statusFilter && c.expiryStatus !== statusFilter) return false;
|
|
if (search) {
|
|
const q = search.toLowerCase();
|
|
return (
|
|
c.name.toLowerCase().includes(q) ||
|
|
c.domains.some((d) => d.toLowerCase().includes(q))
|
|
);
|
|
}
|
|
return true;
|
|
});
|
|
|
|
const columns = [
|
|
{
|
|
id: "name",
|
|
label: "Name",
|
|
render: (c: ImportedCertView) => (
|
|
<div className="flex items-start gap-3">
|
|
<div className={[
|
|
"mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md border",
|
|
c.expiryStatus === "expired"
|
|
? "border-rose-500/30 bg-rose-500/10 text-rose-500"
|
|
: c.expiryStatus === "expiring_soon"
|
|
? "border-amber-500/30 bg-amber-500/10 text-amber-500"
|
|
: "border-emerald-500/30 bg-emerald-500/10 text-emerald-500",
|
|
].join(" ")}>
|
|
<FileKey className="h-3.5 w-3.5" />
|
|
</div>
|
|
<span className="text-sm font-semibold">{c.name}</span>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
id: "domains",
|
|
label: "Domains",
|
|
render: (c: ImportedCertView) => <DomainsCell domains={c.domains} />,
|
|
},
|
|
{
|
|
id: "expiry",
|
|
label: "Expires",
|
|
render: (c: ImportedCertView) => <RelativeTime validTo={c.validTo} status={c.expiryStatus} />,
|
|
},
|
|
{
|
|
id: "usedBy",
|
|
label: "Used by",
|
|
render: (c: ImportedCertView) =>
|
|
c.usedBy.length === 0 ? (
|
|
<span className="text-sm text-muted-foreground">—</span>
|
|
) : (
|
|
<div className="flex flex-wrap gap-1">
|
|
{c.usedBy.map((h) => (
|
|
<Badge key={h.id} variant="secondary" className="text-[10px] px-1.5 py-0">{h.name}</Badge>
|
|
))}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
id: "actions",
|
|
label: "",
|
|
align: "right" as const,
|
|
render: (c: ImportedCertView) => (
|
|
<ActionsMenu cert={c} onEdit={() => setDrawerCert(c)} />
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
<div className="flex justify-end">
|
|
<Button variant="outline" size="sm" onClick={() => setDrawerCert(null)}>
|
|
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
|
Import Certificate
|
|
</Button>
|
|
</div>
|
|
|
|
<DataTable
|
|
columns={columns}
|
|
data={filtered}
|
|
keyField="id"
|
|
emptyMessage="No imported certificates match"
|
|
mobileCard={mobileCardRenderer}
|
|
rowClassName={(c) =>
|
|
c.expiryStatus === "expired" ? "opacity-70"
|
|
: c.expiryStatus === "expiring_soon" ? "bg-amber-500/5"
|
|
: ""
|
|
}
|
|
/>
|
|
|
|
{managedCerts.length > 0 && (
|
|
<div className="flex flex-col gap-2">
|
|
<Alert className="border-amber-500/30 bg-amber-500/5 text-amber-700 dark:text-amber-400">
|
|
<AlertTriangle className="h-4 w-4" />
|
|
<AlertDescription>
|
|
Legacy "managed" certificate entries detected. These are redundant — Caddy handles
|
|
HTTPS automatically. Consider deleting them.
|
|
</AlertDescription>
|
|
</Alert>
|
|
<LegacyManagedTable managedCerts={managedCerts} />
|
|
</div>
|
|
)}
|
|
|
|
<ImportCertDrawer
|
|
open={drawerCert !== false}
|
|
cert={drawerCert || null}
|
|
onClose={() => setDrawerCert(false)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function LegacyManagedTable({ managedCerts }: { managedCerts: ManagedCertView[] }) {
|
|
const [isPending, startTransition] = useTransition();
|
|
|
|
const columns = [
|
|
{
|
|
id: "name",
|
|
label: "Name",
|
|
render: (c: ManagedCertView) => (
|
|
<span className="text-sm font-semibold">{c.name}</span>
|
|
),
|
|
},
|
|
{
|
|
id: "domains",
|
|
label: "Domains",
|
|
render: (c: ManagedCertView) => (
|
|
<p className="text-sm text-muted-foreground font-mono">{c.domainNames.join(", ")}</p>
|
|
),
|
|
},
|
|
{
|
|
id: "actions",
|
|
label: "",
|
|
align: "right" as const,
|
|
render: (c: ManagedCertView) => (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-7 text-xs border-destructive/50 text-destructive hover:bg-destructive/10"
|
|
disabled={isPending}
|
|
onClick={() => startTransition(async () => { await deleteCertificateAction(c.id); })}
|
|
>
|
|
Delete
|
|
</Button>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<DataTable
|
|
columns={columns}
|
|
data={managedCerts}
|
|
keyField="id"
|
|
emptyMessage="No legacy managed certificates"
|
|
/>
|
|
);
|
|
}
|