mirror of
https://github.com/agentrhq/authsome.git
synced 2026-09-19 01:34:19 +08:00
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:
@@ -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 & 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 “{data.connection_name}” on {data.provider_display_name}.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setOpen(false)} type="button" variant="outline">
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user