feat: improve connections and providers page design

Redesign connections list with provider logos, filter counts, grouped
global section with left-border accent, and confirmation dialogs.
Restructure connection detail into grouped cards (Identity, Token,
Endpoints, Secrets) with status badges and copyable fields. Add filter
chips (OAuth/API Key, Connected/Available) to providers list. Improve
provider cards with inline logo+name+tag layout. Refactor provider
detail with DetailField pattern, copyable callback URL, separated
revoke card, and better empty states.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Manoj Bajaj
2026-06-16 16:49:20 -07:00
parent 3e04eff5a4
commit ab122f60d7
6 changed files with 673 additions and 193 deletions
@@ -20,7 +20,7 @@ function ConnectionDetailContent() {
provider && connection ? ["authsome-connection-detail", provider, connection, principal] : null,
() => fetchConnectionDetail(provider, connection, principal),
);
const { mutate: mutateDashboard } = useSWR("authsome-dashboard", fetchDashboard);
const { data: dashboard, mutate: mutateDashboard } = useSWR("authsome-dashboard", fetchDashboard);
if (!provider || !connection) {
return (
@@ -45,6 +45,7 @@ function ConnectionDetailContent() {
data={data}
onRefresh={() => { void mutate(); void mutateDashboard(); }}
principal={principal}
providers={dashboard?.providers}
/>
);
}
@@ -14,6 +14,7 @@ export default function ConnectionsPage() {
globalConnections={data.globalConnections}
isAdmin={data.account.isAdmin}
onRefresh={() => void mutate()}
providers={data.providers}
/>
);
}
@@ -1,20 +1,22 @@
"use client";
import { ArrowLeft, Check, Clipboard, Eye, EyeOff, Globe2, LogOut } from "lucide-react";
import { Check, Clipboard, Clock, Eye, EyeOff, Globe2, KeyRound, Link2, LogOut, Server, Shield } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { type ReactNode, useEffect, useState } from "react";
import useSWR from "swr";
import {
KeyValue,
ProviderLogo,
StatusBadge,
connectionDetailHref,
providerDetailHref,
} from "@/components/dashboard/dashboard-primitives";
import { currentBrowserPath, isUnauthorized } from "@/components/dashboard/dashboard-routing";
import { DashboardDetailShell, ErrorState, LoadingScreen } from "@/components/dashboard/dashboard-shell";
import { Badge } from "@/components/ui/badge";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
@@ -23,8 +25,10 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { H3 } from "@/components/ui/typography";
import {
ConnectionDetail,
DashboardData,
fetchConnectionDetail,
fetchDashboard,
logoutConnection,
@@ -87,7 +91,7 @@ export function AuthsomeConnectionDetail({
return (
<DashboardDetailShell activeView="connections" data={dashboard}>
<ConnectionDetailBody data={data} onRefresh={() => { void mutate(); void mutateDashboard(); }} principal={principal} />
<ConnectionDetailBody data={data} onRefresh={() => { void mutate(); void mutateDashboard(); }} principal={principal} providers={dashboard.providers} />
</DashboardDetailShell>
);
}
@@ -96,58 +100,173 @@ export function ConnectionDetailBody({
data,
onRefresh,
principal,
providers,
}: {
data: ConnectionDetail;
onRefresh: () => void;
principal?: string;
providers?: DashboardData["providers"];
}) {
const provider = providers?.find((p) => p.name === data.provider);
const hasSecrets = data.secrets.access_token || data.secrets.refresh_token || data.secrets.api_key || Object.keys(data.secrets.credentials).length > 0;
const hasEndpoints = data.base_url || data.api_url;
return (
<div className="grid gap-6">
<Link className={cn(buttonVariants({ size: "sm", variant: "outline" }), "w-fit")} href="/connections">
<ArrowLeft />
Back to connections
</Link>
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-border/50 pb-6">
<div className="min-w-0">
<p className="text-sm text-muted-foreground">{data.provider_display_name}</p>
<h1 className="mt-0.5 text-2xl font-semibold leading-tight text-foreground">{data.connection_name}</h1>
<div className="grid gap-5">
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-border/50 pb-4">
<div className="flex items-center gap-3 min-w-0">
{provider ? (
<ProviderLogo className="size-10 shrink-0" initial={provider.logoInitial} logo={provider.logo} />
) : null}
<div className="min-w-0">
<div className="flex items-center gap-2">
<Link className="text-sm text-muted-foreground hover:text-foreground hover:underline" href={providerDetailHref(data.provider)}>
{data.provider_display_name}
</Link>
{data.is_global ? (
<Badge className="border-primary/30 bg-primary/10 text-primary" variant="outline">
<Globe2 className="size-3" />
Global
</Badge>
) : null}
</div>
<H3 className="mt-0.5 leading-tight">{data.connection_name}</H3>
</div>
</div>
<ConnectionActions data={data} onRefresh={onRefresh} principal={principal} />
</div>
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_400px]">
<Card className="border-border/50 shadow-none">
<CardContent className="grid gap-4 p-5 md:grid-cols-2">
<KeyValue label="Status" value={data.status} />
<KeyValue label="Auth Type" value={data.auth_type} />
<KeyValue label="Principal ID" value={data.principal_id || "-"} />
<KeyValue label="Agent" value={data.identity || "-"} />
<KeyValue label="Scopes" value={data.scopes.join(", ") || "-"} />
<KeyValue label="Token Type" value={data.token_type || "-"} />
<KeyValue label="Obtained" value={data.obtained_at || "-"} />
<KeyValue label="Expires" value={data.expires_at || "-"} />
<KeyValue label="Base URL" value={data.base_url || "-"} />
<KeyValue label="API URL" value={data.api_url || "-"} />
</CardContent>
</Card>
<Card className="border-border/50 shadow-none">
<CardHeader>
<CardTitle>Secrets</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<SecretValue label="Access Token" value={data.secrets.access_token} />
<SecretValue label="Refresh Token" value={data.secrets.refresh_token} />
<SecretValue label="API Key" value={data.secrets.api_key} />
{Object.entries(data.secrets.credentials).map(([key, value]) => (
<SecretValue key={key} label={key} value={value} />
))}
</CardContent>
</Card>
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_400px]">
<div className="grid gap-4">
<Card className="border-border/50 shadow-none">
<CardHeader className="pb-0">
<CardTitle className="flex items-center gap-2">
<Shield className="size-4 text-muted-foreground" />
Identity &amp; Access
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 pt-4 md:grid-cols-2">
<DetailField label="Status">
<StatusBadge status={data.status} />
</DetailField>
<DetailField label="Auth Type">
<span className="text-sm">{authTypeLabel(data.auth_type)}</span>
</DetailField>
<DetailField label="Principal ID">
<code className="text-xs font-mono text-muted-foreground">{data.principal_id || "-"}</code>
</DetailField>
<DetailField label="Agent">
<span className="text-sm">{data.identity || "-"}</span>
</DetailField>
{data.scopes.length > 0 ? (
<div className="md:col-span-2">
<DetailField label="Scopes">
<div className="flex flex-wrap gap-1.5">
{data.scopes.map((scope) => (
<Badge key={scope} variant="outline">{scope}</Badge>
))}
</div>
</DetailField>
</div>
) : null}
</CardContent>
</Card>
<Card className="border-border/50 shadow-none">
<CardHeader className="pb-0">
<CardTitle className="flex items-center gap-2">
<Clock className="size-4 text-muted-foreground" />
Token
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 pt-4 md:grid-cols-2">
<DetailField label="Token Type">
<span className="text-sm">{data.token_type || "-"}</span>
</DetailField>
<DetailField label="Obtained">
<span className="text-sm">{formatTimestamp(data.obtained_at)}</span>
</DetailField>
<DetailField label="Expires">
<span className="text-sm">{formatTimestamp(data.expires_at)}</span>
</DetailField>
</CardContent>
</Card>
{hasEndpoints ? (
<Card className="border-border/50 shadow-none">
<CardHeader className="pb-0">
<CardTitle className="flex items-center gap-2">
<Server className="size-4 text-muted-foreground" />
Endpoints
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 pt-4">
{data.base_url ? (
<DetailField label="Base URL">
<code className="text-xs font-mono break-all">{data.base_url}</code>
</DetailField>
) : null}
{data.api_url ? (
<DetailField label="API URL">
<code className="text-xs font-mono break-all">{data.api_url}</code>
</DetailField>
) : null}
</CardContent>
</Card>
) : null}
</div>
{hasSecrets ? (
<Card className="border-border/50 shadow-none h-fit">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<KeyRound className="size-4 text-muted-foreground" />
Secrets
</CardTitle>
<CardDescription>Encrypted at rest in the vault.</CardDescription>
</CardHeader>
<CardContent className="grid gap-3">
<SecretValue label="Access Token" value={data.secrets.access_token} />
<SecretValue label="Refresh Token" value={data.secrets.refresh_token} />
<SecretValue label="API Key" value={data.secrets.api_key} />
{Object.entries(data.secrets.credentials).map(([key, value]) => (
<SecretValue key={key} label={key} value={value} />
))}
</CardContent>
</Card>
) : null}
</div>
</div>
);
}
function DetailField({ children, label }: { children: ReactNode; label: string }) {
return (
<div className="grid gap-1">
<div className="text-xs font-medium uppercase tracking-wider text-muted-foreground">{label}</div>
<div>{children}</div>
</div>
);
}
function authTypeLabel(authType: string): string {
return authType === "oauth2" ? "OAuth 2.0" : authType === "api_key" ? "API Key" : authType || "-";
}
function formatTimestamp(value: string | null): string {
if (!value) return "-";
const parsed = new Date(value);
if (Number.isNaN(parsed.valueOf())) return value;
return parsed.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZoneName: "short",
});
}
function redactedValue(value: string): string {
return value.length <= 8 ? "********" : `${value.slice(0, 4)}...${value.slice(-4)}`;
}
@@ -164,10 +283,10 @@ function SecretValue({ label, value }: { label: string; value: string | null })
}
return (
<div className="grid gap-2">
<div className="text-xs font-medium uppercase text-muted-foreground">{label}</div>
<div className="flex min-w-0 items-start gap-2 rounded-lg border bg-muted p-3">
<code className="min-w-0 flex-1 break-all text-xs">{revealed ? value : redactedValue(value)}</code>
<div className="grid gap-1.5">
<div className="text-xs font-medium uppercase tracking-wider text-muted-foreground">{label}</div>
<div className="flex min-w-0 items-start gap-1.5 rounded-md border bg-muted/50 p-2.5">
<code className="min-w-0 flex-1 break-all pt-0.5 text-xs">{revealed ? value : redactedValue(value)}</code>
<Button
aria-label={revealed ? "Hide secret" : "Reveal secret"}
onClick={() => setRevealed((current) => !current)}
@@ -179,13 +298,12 @@ function SecretValue({ label, value }: { label: string; value: string | null })
</Button>
<Button
aria-label="Copy secret"
disabled={!revealed}
onClick={() => void copy()}
size="icon-sm"
type="button"
variant="ghost"
>
{copied ? <Check /> : <Clipboard />}
{copied ? <Check className="text-emerald-600 dark:text-emerald-400" /> : <Clipboard />}
</Button>
</div>
</div>
@@ -243,7 +361,7 @@ function ConnectionActions({
return (
<>
<div className="flex flex-col items-start gap-1 sm:items-end">
<div className="flex flex-col items-start gap-2 sm:items-end">
<div className="flex flex-wrap items-center gap-2">
{data.can_set_default ? (
<form
@@ -251,12 +369,13 @@ function ConnectionActions({
method="post"
>
<Button size="sm" type="submit" variant="outline">
<Link2 />
Set as default
</Button>
</form>
) : null}
{data.can_set_global ? (
<Button disabled={globalWorking} onClick={() => void toggleGlobal()} type="button" variant="outline">
<Button disabled={globalWorking} onClick={() => void toggleGlobal()} size="sm" type="button" variant="outline">
<Globe2 />
{data.is_global ? "Unset global" : "Make global"}
</Button>
@@ -269,21 +388,25 @@ function ConnectionActions({
Logout
</Button>
</div>
<div
aria-live="polite"
className={cn(
"min-h-5 text-sm",
globalMessage?.tone === "success" ? "text-emerald-400" : "text-destructive"
)}
>
{globalMessage?.text}
</div>
{globalMessage ? (
<div
aria-live="polite"
className={cn(
"text-sm",
globalMessage.tone === "success" ? "text-emerald-700 dark:text-emerald-400" : "text-destructive"
)}
>
{globalMessage.text}
</div>
) : null}
</div>
<Dialog onOpenChange={setOpen} open={open}>
<DialogContent>
<DialogHeader>
<DialogTitle>Logout connection</DialogTitle>
<DialogDescription>This removes the stored credentials for this connection.</DialogDescription>
<DialogDescription>
This removes the stored credentials for &ldquo;{data.connection_name}&rdquo; on {data.provider_display_name}.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => setOpen(false)} type="button" variant="outline">
+151 -19
View File
@@ -1,21 +1,31 @@
"use client";
import { Globe2 } from "lucide-react";
import { Globe2, Link2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import {
INTERACTIVE_ROW_CLASS,
ProviderLogo,
SearchInput,
StatusBadge,
connectionDetailHref,
} from "@/components/dashboard/dashboard-primitives";
import { PageEmptyState } from "@/components/dashboard/page-state";
import { SectionHeader } from "@/components/dashboard/section-header";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { DashboardData, GlobalConnectionRow, unsetGlobalConnection } from "@/lib/authsome-api";
import { DashboardData, GlobalConnectionRow, ProviderView, unsetGlobalConnection } from "@/lib/authsome-api";
export function ConnectionsView({
connections,
@@ -23,15 +33,21 @@ export function ConnectionsView({
initialFilter,
isAdmin,
onRefresh,
providers,
}: {
connections: DashboardData["connections"];
globalConnections: DashboardData["globalConnections"];
initialFilter?: string;
isAdmin: boolean;
onRefresh: () => void;
providers?: ProviderView[];
}) {
const router = useRouter();
const [query, setQuery] = useState(initialFilter ?? "");
const providerMap = useMemo(
() => new Map((providers ?? []).map((p) => [p.name, p])),
[providers],
);
const filteredConnections = useMemo(() => {
const normalized = query.trim().toLowerCase();
if (!normalized) return connections;
@@ -49,15 +65,31 @@ export function ConnectionsView({
);
}, [globalConnections, query]);
const isFiltering = query.trim().length > 0;
return (
<div className="grid gap-5">
<SectionHeader description="Connection fallbacks and named connections in the current vault." title="Connections" />
<SectionHeader description="Named connections and deployment-wide fallbacks in the current vault." title="Connections" />
<SearchInput onChange={setQuery} placeholder="Search connections..." value={query} />
<GlobalConnectionsSection connections={filteredGlobalConnections} isAdmin={isAdmin} onRefresh={onRefresh} />
<Card className="shadow-none border-border/50">
<CardHeader>
<CardTitle>Your Connections</CardTitle>
<CardDescription>Named connections in the current vault.</CardDescription>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Link2 className="size-4 text-muted-foreground" />
Your Connections
</CardTitle>
<CardDescription>Named connections in the current vault.</CardDescription>
</div>
{isFiltering ? (
<Badge variant="outline">
{filteredConnections.length} of {connections.length}
</Badge>
) : connections.length ? (
<Badge variant="outline">{connections.length}</Badge>
) : null}
</div>
</CardHeader>
<CardContent className="p-0">
{filteredConnections.length ? (
@@ -73,6 +105,7 @@ export function ConnectionsView({
<TableBody>
{filteredConnections.map((row) => {
const href = connectionDetailHref(row.providerName, row.connectionName);
const provider = providerMap.get(row.providerName);
return (
<TableRow
className={INTERACTIVE_ROW_CLASS}
@@ -88,7 +121,12 @@ export function ConnectionsView({
tabIndex={0}
>
<TableCell>
<span className="font-medium">{row.connectionName}</span>
<div className="flex min-w-0 items-center gap-2.5">
{provider ? (
<ProviderLogo className="size-7 shrink-0" initial={provider.logoInitial} logo={provider.logo} />
) : null}
<span className="truncate font-medium">{row.connectionName}</span>
</div>
</TableCell>
<TableCell className="text-muted-foreground">{row.providerDisplayName}</TableCell>
<TableCell className="text-muted-foreground">{row.authTypeLabel}</TableCell>
@@ -100,9 +138,31 @@ export function ConnectionsView({
})}
</TableBody>
</Table>
) : <PageEmptyState title="No connections found" />}
) : (
<div className="p-4">
{isFiltering ? (
<PageEmptyState title="No matching connections" />
) : (
<PageEmptyState
actionLabel="Browse providers"
description="Connect a provider to create your first connection."
href="/providers"
title="No connections yet"
/>
)}
</div>
)}
</CardContent>
</Card>
<GlobalConnectionsSection
connections={filteredGlobalConnections}
isAdmin={isAdmin}
isFiltering={isFiltering}
onRefresh={onRefresh}
providerMap={providerMap}
totalCount={globalConnections.length}
/>
</div>
);
}
@@ -110,19 +170,39 @@ export function ConnectionsView({
function GlobalConnectionsSection({
connections,
isAdmin,
isFiltering,
onRefresh,
providerMap,
totalCount,
}: {
connections: GlobalConnectionRow[];
isAdmin: boolean;
isFiltering: boolean;
onRefresh: () => void;
providerMap: Map<string, ProviderView>;
totalCount: number;
}) {
const router = useRouter();
return (
<Card className="shadow-none border-border/50">
<Card className="shadow-none border-border/50 border-l-primary/40 border-l-2">
<CardHeader>
<CardTitle>Global Connections</CardTitle>
<CardDescription>Deployment-wide fallback connections available to accepted agents.</CardDescription>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Globe2 className="size-4 text-primary/70" />
Global Connections
</CardTitle>
<CardDescription>Deployment-wide fallback connections available to accepted agents.</CardDescription>
</div>
{isFiltering ? (
<Badge variant="outline">
{connections.length} of {totalCount}
</Badge>
) : totalCount ? (
<Badge variant="outline">{totalCount}</Badge>
) : null}
</div>
</CardHeader>
<CardContent className="p-0">
{connections.length ? (
@@ -139,6 +219,7 @@ function GlobalConnectionsSection({
<TableBody>
{connections.map((row) => {
const href = connectionDetailHref(row.providerName, row.connectionName);
const provider = providerMap.get(row.providerName);
return (
<TableRow
className={INTERACTIVE_ROW_CLASS}
@@ -154,8 +235,10 @@ function GlobalConnectionsSection({
tabIndex={0}
>
<TableCell>
<div className="flex min-w-0 items-center gap-2">
<Globe2 className="size-4 shrink-0 text-muted-foreground" />
<div className="flex min-w-0 items-center gap-2.5">
{provider ? (
<ProviderLogo className="size-7 shrink-0" initial={provider.logoInitial} logo={provider.logo} />
) : null}
<div className="min-w-0">
<div className="truncate font-medium">{row.connectionName}</div>
{row.accountLabel ? (
@@ -171,7 +254,12 @@ function GlobalConnectionsSection({
</TableCell>
{isAdmin ? (
<TableCell onClick={(event) => event.stopPropagation()}>
<RemoveGlobalConnectionButton onRefresh={onRefresh} provider={row.providerName} />
<RemoveGlobalConnectionButton
connectionName={row.connectionName}
onRefresh={onRefresh}
provider={row.providerName}
providerDisplayName={row.providerDisplayName}
/>
</TableCell>
) : null}
</TableRow>
@@ -179,19 +267,42 @@ function GlobalConnectionsSection({
})}
</TableBody>
</Table>
) : <PageEmptyState title="No global connections found" />}
) : (
<div className="p-4">
{isFiltering ? (
<PageEmptyState title="No matching global connections" />
) : (
<PageEmptyState
description="Admins can promote a connection to global from its detail page."
title="No global connections"
/>
)}
</div>
)}
</CardContent>
</Card>
);
}
function RemoveGlobalConnectionButton({ onRefresh, provider }: { onRefresh: () => void; provider: string }) {
function RemoveGlobalConnectionButton({
connectionName,
onRefresh,
provider,
providerDisplayName,
}: {
connectionName: string;
onRefresh: () => void;
provider: string;
providerDisplayName: string;
}) {
const [open, setOpen] = useState(false);
const [working, setWorking] = useState(false);
async function remove() {
setWorking(true);
try {
await unsetGlobalConnection(provider);
setOpen(false);
onRefresh();
} finally {
setWorking(false);
@@ -199,8 +310,29 @@ function RemoveGlobalConnectionButton({ onRefresh, provider }: { onRefresh: () =
}
return (
<Button disabled={working} onClick={() => void remove()} size="sm" type="button" variant="outline">
Remove
</Button>
<>
<Button onClick={() => setOpen(true)} size="sm" type="button" variant="outline">
Remove
</Button>
<Dialog onOpenChange={setOpen} open={open}>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove global connection</DialogTitle>
<DialogDescription>
This will remove the global fallback for {providerDisplayName} ({connectionName}).
Agents without their own connection will no longer have access.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => setOpen(false)} type="button" variant="outline">
Cancel
</Button>
<Button disabled={working} onClick={() => void remove()} type="button" variant="destructive">
Remove
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -1,24 +1,25 @@
"use client";
import { ArrowLeft, Eye, EyeOff, LogIn, Save } from "lucide-react";
import { Check, Clipboard, ExternalLink, Eye, EyeOff, Link2, LogIn, Save, Settings, UserRound } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { type ReactNode, useEffect, useMemo, useState } from "react";
import useSWR from "swr";
import {
KeyValue,
ProviderLogo,
StatusBadge,
connectionDetailHref,
providerDetailHref,
} from "@/components/dashboard/dashboard-primitives";
import { PageEmptyState } from "@/components/dashboard/page-state";
import { currentBrowserPath, isUnauthorized } from "@/components/dashboard/dashboard-routing";
import { DashboardDetailShell, ErrorState, LoadingScreen } from "@/components/dashboard/dashboard-shell";
import {
NamedConnectionDialog,
NamedConnectionProvider,
} from "@/components/dashboard/provider-views";
import { Badge } from "@/components/ui/badge";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
@@ -30,6 +31,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { H3 } from "@/components/ui/typography";
import {
ProviderDetail,
fetchDashboard,
@@ -37,7 +39,6 @@ import {
revokeProvider,
updateProviderConfiguration,
} from "@/lib/authsome-api";
import { cn } from "@/lib/utils";
function detailProviderDisplayName(data: ProviderDetail): string {
return data.provider.display_name || data.provider.name;
@@ -108,25 +109,26 @@ export function ProviderDetailBody({ data, onRefresh }: { data: ProviderDetail;
const [dialogProvider, setDialogProvider] = useState<NamedConnectionProvider | null>(null);
const hasDefaultConnection = data.connections.some((connection) => connection.connection_name === "default");
const dialogData = { displayName, name: data.provider.name };
const hasConnections = data.connections.length > 0 || data.principal_usage.some((g) => g.connections.length > 0);
const providerStatus = hasConnections ? "connected" : "available";
return (
<div className="grid gap-6">
<Link className={cn(buttonVariants({ size: "sm", variant: "outline" }), "w-fit")} href="/providers">
<ArrowLeft />
Back to providers
</Link>
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-border/50 pb-6">
<div className="flex items-center gap-4">
<ProviderLogo className="size-12 shrink-0" initial={initial} logo={data.provider.logo || null} />
<div className="grid gap-5">
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-border/50 pb-4">
<div className="flex items-center gap-3 min-w-0">
<ProviderLogo className="size-10 shrink-0" initial={initial} logo={data.provider.logo || null} />
<div className="min-w-0">
<h1 className="text-2xl font-semibold leading-tight text-foreground">{displayName}</h1>
<p className="mt-1 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<H3 className="leading-tight">{displayName}</H3>
<StatusBadge status={providerStatus} />
</div>
<span className="mt-0.5 text-sm text-muted-foreground">
{description || detailProviderApiUrl(data)}
</p>
</span>
</div>
</div>
{hasDefaultConnection ? (
<Button onClick={() => setDialogProvider(dialogData)} type="button">
<Button onClick={() => setDialogProvider(dialogData)} size="sm" type="button">
<LogIn />
New connection
</Button>
@@ -134,7 +136,7 @@ export function ProviderDetailBody({ data, onRefresh }: { data: ProviderDetail;
<form action={`/api/auth/providers/${data.provider.name}/connect`} method="post">
<input name="connection" type="hidden" value="default" />
<input name="return_url" type="hidden" value="/connections" />
<Button type="submit">
<Button size="sm" type="submit">
<LogIn />
New connection
</Button>
@@ -143,22 +145,48 @@ export function ProviderDetailBody({ data, onRefresh }: { data: ProviderDetail;
</div>
<NamedConnectionDialog onOpenChange={setDialogProvider} provider={dialogProvider} />
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_320px]">
<div className="grid gap-5">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_320px]">
<div className="grid gap-4">
<Card className="border-border/50 shadow-none">
<CardContent className="grid gap-4 p-5 md:grid-cols-2">
<KeyValue label="Provider" value={data.provider.name} />
<KeyValue label="Auth Type" value={detailAuthTypeLabel(data.provider.auth_type)} />
<KeyValue label="API URL" value={detailProviderApiUrl(data) || "-"} />
{data.provider.docs_url ? <KeyValue label="Docs" value={data.provider.docs_url} /> : null}
{data.show_callback_helper && data.callback_url ? (
<KeyValue label="OAuth Callback URL" value={data.callback_url} />
<CardHeader className="pb-0">
<CardTitle className="flex items-center gap-2">
<Settings className="size-4 text-muted-foreground" />
Provider Details
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 pt-4 md:grid-cols-2">
<DetailField label="Provider ID">
<code className="text-xs font-mono text-muted-foreground">{data.provider.name}</code>
</DetailField>
<DetailField label="Auth Type">
<span className="text-sm">{detailAuthTypeLabel(data.provider.auth_type)}</span>
</DetailField>
<DetailField label="API URL">
<code className="text-xs font-mono break-all">{detailProviderApiUrl(data) || "-"}</code>
</DetailField>
{data.provider.docs_url ? (
<DetailField label="Documentation">
<a
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
href={data.provider.docs_url}
rel="noreferrer"
target="_blank"
>
{data.provider.docs_url.replace(/^https?:\/\//, "").replace(/\/$/, "")}
<ExternalLink className="size-3 shrink-0" />
</a>
</DetailField>
) : null}
{data.provider.auth_type !== "api_key" && data.show_callback_helper && data.callback_url ? (
<div className="md:col-span-2">
<CopyableField label="OAuth Callback URL" value={data.callback_url} />
</div>
) : null}
</CardContent>
</Card>
<ProviderUsage data={data} />
</div>
<div className="grid content-start gap-5">
<div className="grid content-start gap-4">
{showsConfiguration ? (
data.account.is_admin ? (
<ProviderConfigurationForm
@@ -169,7 +197,10 @@ export function ProviderDetailBody({ data, onRefresh }: { data: ProviderDetail;
) : (
<Card className="border-border/50 shadow-none">
<CardHeader>
<CardTitle>Configuration</CardTitle>
<CardTitle className="flex items-center gap-2">
<Settings className="size-4 text-muted-foreground" />
Configuration
</CardTitle>
<CardDescription>Managed by the admin.</CardDescription>
</CardHeader>
</Card>
@@ -181,6 +212,43 @@ export function ProviderDetailBody({ data, onRefresh }: { data: ProviderDetail;
);
}
function DetailField({ children, label }: { children: ReactNode; label: string }) {
return (
<div className="grid gap-1">
<div className="text-xs font-medium uppercase tracking-wider text-muted-foreground">{label}</div>
<div>{children}</div>
</div>
);
}
function CopyableField({ label, value }: { label: string; value: string }) {
const [copied, setCopied] = useState(false);
async function copy() {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
}
return (
<div className="grid gap-1">
<div className="text-xs font-medium uppercase tracking-wider text-muted-foreground">{label}</div>
<div className="flex min-w-0 items-center gap-1.5 rounded-md border bg-muted/50 p-2.5">
<code className="min-w-0 flex-1 break-all text-xs">{value}</code>
<Button
aria-label={`Copy ${label}`}
onClick={() => void copy()}
size="icon-sm"
type="button"
variant="ghost"
>
{copied ? <Check className="text-emerald-600 dark:text-emerald-400" /> : <Clipboard />}
</Button>
</div>
</div>
);
}
function ProviderConfigurationForm({ data, onRefresh }: { data: ProviderDetail; onRefresh: () => void }) {
const initialValues = useMemo(() => {
const values: Record<string, string> = {};
@@ -191,54 +259,75 @@ function ProviderConfigurationForm({ data, onRefresh }: { data: ProviderDetail;
}, [data.configuration_fields]);
const [values, setValues] = useState(initialValues);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState("");
const [message, setMessage] = useState<{ text: string; tone: "error" | "success" | "muted" } | null>(null);
async function save() {
setSaving(true);
setMessage("");
setMessage(null);
try {
const result = await updateProviderConfiguration(data.provider.name, values);
setMessage(result.changed ? "Configuration updated." : "No changes to save.");
setMessage({
text: result.changed ? "Configuration updated." : "No changes to save.",
tone: result.changed ? "success" : "muted",
});
onRefresh();
} catch (error) {
setMessage(error instanceof Error ? error.message : "Configuration could not be saved.");
setMessage({
text: error instanceof Error ? error.message : "Configuration could not be saved.",
tone: "error",
});
} finally {
setSaving(false);
}
}
return (
<Card className="border-border/50 shadow-none">
<CardHeader>
<CardTitle>Configuration</CardTitle>
<CardDescription>Provider-level inputs required before users can connect.</CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
{data.configuration_warning ? (
<div className="rounded-lg border border-amber-800 bg-amber-950/30 px-3 py-2 text-sm text-amber-300">
{data.configuration_warning}
</div>
) : null}
{data.configuration_fields.map((field) => (
<ConfigurationFieldInput
field={field}
key={field.name}
onChange={(value) => setValues((current) => ({ ...current, [field.name]: value }))}
value={values[field.name] || ""}
/>
))}
{message ? <div className="text-sm text-muted-foreground">{message}</div> : null}
<Button disabled={saving} onClick={() => void save()} type="button">
<Save />
Save
</Button>
<RevokeProviderButton data={data} onRefresh={onRefresh} />
</CardContent>
</Card>
<>
<Card className="border-border/50 shadow-none">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Settings className="size-4 text-muted-foreground" />
Configuration
</CardTitle>
<CardDescription>Provider-level inputs required before users can connect.</CardDescription>
</CardHeader>
<CardContent className="grid gap-3">
{data.configuration_warning ? (
<div className="rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-700 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300" role="alert">
{data.configuration_warning}
</div>
) : null}
{data.configuration_fields.map((field) => (
<ConfigurationFieldInput
field={field}
key={field.name}
onChange={(value) => setValues((current) => ({ ...current, [field.name]: value }))}
value={values[field.name] || ""}
/>
))}
{message ? (
<div className={
message.tone === "success" ? "text-sm text-emerald-700 dark:text-emerald-400"
: message.tone === "error" ? "text-sm text-destructive"
: "text-sm text-muted-foreground"
}>
{message.text}
</div>
) : null}
<Button disabled={saving} onClick={() => void save()} type="button">
<Save />
Save
</Button>
</CardContent>
</Card>
<RevokeProviderButton data={data} onRefresh={onRefresh} />
</>
);
}
function RevokeProviderButton({ data, onRefresh }: { data: ProviderDetail; onRefresh: () => void }) {
const displayName = detailProviderDisplayName(data);
const connectionCount = data.connections.length;
const [open, setOpen] = useState(false);
const [working, setWorking] = useState(false);
const [message, setMessage] = useState("");
@@ -259,14 +348,26 @@ function RevokeProviderButton({ data, onRefresh }: { data: ProviderDetail; onRef
return (
<>
<Button onClick={() => setOpen(true)} type="button" variant="destructive">
Revoke app
</Button>
<Card className="border-destructive/30 shadow-none">
<CardContent className="flex items-center justify-between gap-4 py-3">
<div className="min-w-0">
<div className="text-sm font-medium">Revoke app</div>
<div className="text-xs text-muted-foreground">Remove all credentials for this provider.</div>
</div>
<Button onClick={() => setOpen(true)} size="sm" type="button" variant="destructive">
Revoke
</Button>
</CardContent>
</Card>
<Dialog onOpenChange={setOpen} open={open}>
<DialogContent>
<DialogHeader>
<DialogTitle>Revoke app</DialogTitle>
<DialogDescription>All connections for this provider will be revoked.</DialogDescription>
<DialogTitle>Revoke {displayName}</DialogTitle>
<DialogDescription>
{connectionCount
? `This will revoke all ${connectionCount} connection${connectionCount === 1 ? "" : "s"} for ${displayName}. Stored credentials will be removed.`
: `This will revoke the app registration for ${displayName}.`}
</DialogDescription>
</DialogHeader>
{message ? <div className="text-sm text-destructive">{message}</div> : null}
<DialogFooter>
@@ -287,20 +388,34 @@ function ProviderUsage({ data }: { data: ProviderDetail }) {
const groups = data.account.is_admin
? data.principal_usage
: [{ principal_id: data.account.principal_id || "current", email: null, connections: data.connections }];
const totalConnections = groups.reduce((sum, group) => sum + group.connections.length, 0);
return (
<Card className="border-border/50 shadow-none">
<CardHeader>
<CardTitle>Connections</CardTitle>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Link2 className="size-4 text-muted-foreground" />
Connections
</CardTitle>
{totalConnections > 0 ? (
<Badge variant="outline">{totalConnections}</Badge>
) : null}
</div>
</CardHeader>
<CardContent className="grid gap-4">
{groups.length ? (
groups.map((group) => (
{groups.length && totalConnections > 0 ? (
groups.filter((g) => g.connections.length > 0).map((group) => (
<div className="grid gap-2" key={group.principal_id}>
<div className="text-sm font-medium">{group.email || group.principal_id}</div>
{data.account.is_admin ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<UserRound className="size-3.5" />
<span className="font-medium">{group.email || group.principal_id}</span>
</div>
) : null}
{group.connections.map((connection) => (
<Link
className="flex min-w-0 items-center justify-between gap-3 rounded-lg border bg-muted/30 px-3 py-2 text-sm"
className="flex min-w-0 items-center justify-between gap-3 rounded-lg border border-border/50 bg-muted/30 px-3 py-2.5 text-sm transition-colors hover:border-primary/50 hover:bg-muted/50"
href={connectionDetailHref(
connection.provider,
connection.connection_name,
@@ -308,14 +423,22 @@ function ProviderUsage({ data }: { data: ProviderDetail }) {
)}
key={`${group.principal_id}:${connection.connection_name}`}
>
<span className="truncate">{connection.connection_name}</span>
<div className="min-w-0">
<div className="truncate font-medium">{connection.connection_name}</div>
{connection.account_label ? (
<div className="truncate text-xs text-muted-foreground">{connection.account_label}</div>
) : null}
</div>
<StatusBadge status={connection.status} />
</Link>
))}
</div>
))
) : (
<div className="text-sm text-muted-foreground">No connections found.</div>
<PageEmptyState
description="Use the button above to create a connection."
title="No connections yet"
/>
)}
</CardContent>
</Card>
@@ -335,9 +458,9 @@ function ConfigurationFieldInput({
const isSecret = field.secret;
return (
<label className="grid gap-2 text-sm">
<label className="grid gap-1.5 text-sm">
<span className="text-muted-foreground">{field.label}</span>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<Input
className="min-w-0 flex-1"
onChange={(event) => onChange(event.target.value)}
+140 -40
View File
@@ -1,6 +1,6 @@
"use client";
import { LogIn, Pencil, Plus, Trash2 } from "lucide-react";
import { Globe2, KeyRound, Link2, LogIn, Pencil, Plus, Shield, Trash2 } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { FormEvent, useMemo, useState } from "react";
@@ -49,6 +49,21 @@ export function ProviderSummary({ provider }: { provider: ProviderView }) {
);
}
type AuthTypeFilter = "all" | "oauth2" | "api_key";
type StatusFilter = "all" | "connected" | "available";
const AUTH_TYPE_FILTERS: { label: string; value: AuthTypeFilter }[] = [
{ label: "All Types", value: "all" },
{ label: "OAuth", value: "oauth2" },
{ label: "API Key", value: "api_key" },
];
const STATUS_FILTERS: { label: string; value: StatusFilter }[] = [
{ label: "All", value: "all" },
{ label: "Connected", value: "connected" },
{ label: "Available", value: "available" },
];
export function ProvidersView({
isAdmin = false,
onRefresh,
@@ -59,25 +74,33 @@ export function ProvidersView({
providers: ProviderView[];
}) {
const [query, setQuery] = useState("");
const [authTypeFilter, setAuthTypeFilter] = useState<AuthTypeFilter>("all");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [dialogProvider, setDialogProvider] = useState<NamedConnectionProvider | null>(null);
const [formState, setFormState] = useState<{ mode: "create" | "edit"; provider?: ProviderView } | null>(null);
const [deleteProvider, setDeleteProvider] = useState<ProviderView | null>(null);
const filteredProviders = useMemo(() => {
const normalized = query.trim().toLowerCase();
const matches = normalized
? providers.filter((provider) =>
`${provider.displayName} ${provider.name} ${provider.authTypeLabel} ${provider.description}`
.toLowerCase()
.includes(normalized),
)
: providers;
const matches = providers.filter((provider) => {
if (normalized && !`${provider.displayName} ${provider.name} ${provider.authTypeLabel} ${provider.description}`
.toLowerCase()
.includes(normalized)) {
return false;
}
if (authTypeFilter !== "all" && provider.authType !== authTypeFilter) return false;
if (statusFilter === "connected" && provider.status === "available") return false;
if (statusFilter === "available" && provider.status !== "available") return false;
return true;
});
return [...matches].sort((a, b) =>
providerSortRank(a) - providerSortRank(b)
|| a.displayName.localeCompare(b.displayName),
);
}, [providers, query]);
}, [providers, query, authTypeFilter, statusFilter]);
const hasActiveFilters = authTypeFilter !== "all" || statusFilter !== "all" || query.trim().length > 0;
return (
<div className="grid gap-5">
@@ -90,7 +113,52 @@ export function ProvidersView({
</Button>
) : null}
</div>
<SearchInput onChange={setQuery} placeholder="Search providers..." value={query} />
<div className="grid gap-3">
<SearchInput onChange={setQuery} placeholder="Search providers..." value={query} />
<div className="flex flex-wrap items-center gap-2">
{AUTH_TYPE_FILTERS.map((filter) => (
<button
className={cn(
"inline-flex h-7 items-center gap-1.5 rounded-md border px-2.5 text-xs font-medium transition-colors",
authTypeFilter === filter.value
? "border-primary/50 bg-primary/10 text-primary"
: "border-border/50 bg-background text-muted-foreground hover:border-border hover:text-foreground",
)}
key={filter.value}
onClick={() => setAuthTypeFilter(filter.value)}
type="button"
>
{filter.value === "oauth2" ? <Shield className="size-3" /> : null}
{filter.value === "api_key" ? <KeyRound className="size-3" /> : null}
{filter.label}
</button>
))}
<span className="mx-1 h-4 w-px bg-border/50" />
{STATUS_FILTERS.map((filter) => (
<button
className={cn(
"inline-flex h-7 items-center gap-1.5 rounded-md border px-2.5 text-xs font-medium transition-colors",
statusFilter === filter.value
? "border-primary/50 bg-primary/10 text-primary"
: "border-border/50 bg-background text-muted-foreground hover:border-border hover:text-foreground",
)}
key={filter.value}
onClick={() => setStatusFilter(filter.value)}
type="button"
>
{filter.label}
</button>
))}
{hasActiveFilters ? (
<>
<span className="mx-1 h-4 w-px bg-border/50" />
<span className="text-xs text-muted-foreground">
{filteredProviders.length} of {providers.length}
</span>
</>
) : null}
</div>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{filteredProviders.map((provider) => (
<ProviderCard
@@ -104,11 +172,11 @@ export function ProvidersView({
))}
</div>
{!filteredProviders.length ? (
query.trim() ? (
hasActiveFilters ? (
<PageEmptyState
actionLabel="Clear search"
onAction={() => setQuery("")}
title="No providers found"
actionLabel="Clear filters"
onAction={() => { setQuery(""); setAuthTypeFilter("all"); setStatusFilter("all"); }}
title="No matching providers"
/>
) : (
<PageEmptyState title="No providers available" />
@@ -162,9 +230,29 @@ function ProviderCard({
onClick={() => router.push(providerDetailHref(provider.name))}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<ProviderLogo className="size-10 shrink-0" initial={provider.logoInitial} logo={provider.logo} />
<div className="flex items-center gap-1.5">
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-3 min-w-0">
<ProviderLogo className="size-11 shrink-0" initial={provider.logoInitial} logo={provider.logo} />
<div className="min-w-0">
<CardTitle className="truncate text-base leading-tight">{provider.displayName}</CardTitle>
<div className="mt-1 flex flex-wrap items-center gap-1.5">
<Badge variant="outline" className="gap-1">
{provider.authType === "oauth2" ? <Shield className="size-3" /> : <KeyRound className="size-3" />}
{provider.authTypeLabel}
</Badge>
{provider.globalConnectionCount ? (
<Badge className="gap-1 border-primary/30 bg-primary/10 text-primary" variant="outline">
<Globe2 className="size-3" />
Global
</Badge>
) : null}
{provider.source === "custom" ? (
<Badge variant="outline">Custom</Badge>
) : null}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-1.5">
{canManage ? (
<>
<Button
@@ -198,31 +286,43 @@ function ProviderCard({
<StatusBadge status={provider.status} />
</div>
</div>
<div className="mt-1">
<CardTitle className="text-base leading-tight">{provider.displayName}</CardTitle>
<CardDescription className="mt-0.5 text-xs">
{provider.source === "custom" ? "Custom provider" : "Bundled provider"}
</CardDescription>
</div>
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-4 pt-0">
<p className="line-clamp-2 min-h-12 text-sm leading-relaxed text-muted-foreground">
{provider.description || "Connect this provider to store and inject credentials from your Authsome vault."}
<CardContent className="flex flex-1 flex-col gap-3 pt-0">
<p className="line-clamp-2 text-xs leading-relaxed text-muted-foreground">
{provider.description || "Connect to store and inject credentials from your vault."}
</p>
<div className="flex min-h-7 flex-wrap content-start gap-1.5">
<Badge variant="outline">{provider.authTypeLabel}</Badge>
{provider.connectionCount ? (
<Badge variant="outline">
{provider.connectionCount} connection{provider.connectionCount !== 1 ? "s" : ""}
</Badge>
) : null}
{provider.globalConnectionCount ? <Badge variant="outline">Global</Badge> : null}
</div>
<div className="mt-auto" onClick={(e) => e.stopPropagation()}>
{provider.requiresNamedLogin ? (
<div className="mt-auto border-t border-border/40 pt-3" onClick={(e) => e.stopPropagation()}>
{provider.status !== "available" ? (
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{provider.connectionCount} connection{provider.connectionCount !== 1 ? "s" : ""}
</span>
{provider.requiresNamedLogin ? (
<Button
onClick={(e) => { e.preventDefault(); onNamedLogin(); }}
size="sm"
type="button"
variant="ghost"
>
<Plus className="size-3.5" />
Add
</Button>
) : (
<form action={`/api/auth/providers/${provider.name}/connect`} method="post">
<input name="connection" type="hidden" value="default" />
<input name="return_url" type="hidden" value="/connections" />
<Button size="sm" type="submit" variant="ghost">
<Plus className="size-3.5" />
Add
</Button>
</form>
)}
</div>
) : provider.requiresNamedLogin ? (
<Button
className="w-full"
onClick={(e) => { e.preventDefault(); onNamedLogin(); }}
size="sm"
type="button"
>
<LogIn />
@@ -232,7 +332,7 @@ function ProviderCard({
<form action={`/api/auth/providers/${provider.name}/connect`} method="post">
<input name="connection" type="hidden" value="default" />
<input name="return_url" type="hidden" value="/connections" />
<Button className="w-full" type="submit">
<Button className="w-full" size="sm" type="submit">
<LogIn />
Connect
</Button>
@@ -330,12 +430,12 @@ export function NamedConnectionDialog({
</DialogHeader>
<form
action={provider ? `/api/auth/providers/${provider.name}/connect` : "#"}
className="grid gap-4"
className="grid gap-3"
method="post"
onSubmit={handleSubmit}
>
<input name="return_url" type="hidden" value="/connections" />
<label className="grid gap-2 text-sm">
<label className="grid gap-1.5 text-sm">
<span className="text-muted-foreground">Connection name</span>
<Input
autoFocus