Merge pull request #442 from agentrhq/feat/improve-ui

Feat/improve UI
This commit is contained in:
Manoj Bajaj
2026-06-17 05:39:56 +05:30
committed by GitHub
32 changed files with 1482 additions and 478 deletions
+2
View File
@@ -12,8 +12,10 @@
"@base-ui/react": "^1.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.40.0",
"lucide-react": "^1.17.0",
"next": "16.2.6",
"next-themes": "^0.4.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"shadcn": "^4.9.0",
+52
View File
@@ -17,12 +17,18 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
framer-motion:
specifier: ^12.40.0
version: 12.40.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
lucide-react:
specifier: ^1.17.0
version: 1.17.0(react@19.2.4)
next:
specifier: 16.2.6
version: 16.2.6(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react:
specifier: 19.2.4
version: 19.2.4
@@ -1667,6 +1673,20 @@ packages:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
framer-motion@12.40.0:
resolution: {integrity: sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
fresh@2.0.0:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'}
@@ -2260,6 +2280,12 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
motion-dom@12.40.0:
resolution: {integrity: sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==}
motion-utils@12.39.0:
resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -2294,6 +2320,12 @@ packages:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
next-themes@0.4.6:
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
peerDependencies:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
next@16.2.6:
resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==}
engines: {node: '>=20.9.0'}
@@ -4768,6 +4800,15 @@ snapshots:
forwarded@0.2.0: {}
framer-motion@12.40.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
motion-dom: 12.40.0
motion-utils: 12.39.0
tslib: 2.8.1
optionalDependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
fresh@2.0.0: {}
fs-extra@11.3.5:
@@ -5278,6 +5319,12 @@ snapshots:
minimist@1.2.8: {}
motion-dom@12.40.0:
dependencies:
motion-utils: 12.39.0
motion-utils@12.39.0: {}
ms@2.1.3: {}
msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3):
@@ -5315,6 +5362,11 @@ snapshots:
negotiator@1.0.0: {}
next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
next@16.2.6(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@next/env': 16.2.6
@@ -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}
/>
);
}
+49 -22
View File
@@ -1,6 +1,6 @@
"use client";
import { Suspense, useEffect } from "react";
import React, { Suspense, useEffect } from "react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { LogOut } from "lucide-react";
@@ -22,10 +22,10 @@ import {
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { Skeleton } from "@/components/ui/skeleton";
import { DashboardData, fetchDashboard, ProviderView } from "@/lib/authsome-api";
import { cn } from "@/lib/utils";
type CrumbItem = { label: string; href?: string };
@@ -35,7 +35,6 @@ function pathToView(pathname: string): View {
providers: "providers",
connections: "connections",
agents: "agents",
principal: "principals",
audit: "audit",
settings: "settings",
};
@@ -54,9 +53,16 @@ function buildBreadcrumbs(
providers: "Providers",
connections: "Connections",
agents: "Agents",
principal: "Principals",
audit: "Audit Log",
settings: "Settings",
principal: "Principals",
};
const settingsSubLabel: Record<string, string> = {
general: "General",
security: "Security",
about: "About",
principals: "Principals",
};
if (!first) return [{ label: "Dashboard" }];
@@ -64,6 +70,13 @@ function buildBreadcrumbs(
const parent = navLabel[first];
if (!parent) return [{ label: "Dashboard", href: "/" }];
if (first === "settings" && segments[1] && settingsSubLabel[segments[1]]) {
return [
{ label: "Settings", href: "/settings" },
{ label: settingsSubLabel[segments[1]] },
];
}
const isDetail = segments[1] === "detail";
if (!isDetail) return [{ label: parent }];
@@ -103,9 +116,9 @@ function AppBreadcrumb({ data }: { data: DashboardData }) {
{crumbs.map((crumb, i) => {
const isLast = i === crumbs.length - 1;
return (
<>
{i > 0 && <BreadcrumbSeparator key={`sep-${i}`} />}
<BreadcrumbItem key={i}>
<React.Fragment key={crumb.href ?? crumb.label}>
{i > 0 && <BreadcrumbSeparator />}
<BreadcrumbItem>
{isLast ? (
<BreadcrumbPage>{crumb.label}</BreadcrumbPage>
) : (
@@ -114,7 +127,7 @@ function AppBreadcrumb({ data }: { data: DashboardData }) {
</BreadcrumbLink>
)}
</BreadcrumbItem>
</>
</React.Fragment>
);
})}
</BreadcrumbList>
@@ -140,32 +153,46 @@ export default function AuthenticatedLayout({ children }: { children: React.Reac
if (error) return <ErrorState onRetry={() => void mutate()} />;
const activeView = pathToView(pathname);
const isSettingsRoute = pathname.startsWith("/settings");
return (
<SidebarProvider className="h-svh overflow-hidden">
<a className="skip-link" href="#main-content">
Skip to main content
</a>
<AppSidebar activeView={activeView} data={data} />
<SidebarInset className="min-h-0">
<header className="flex min-h-14 items-center gap-2 border-b bg-card px-4 py-3 md:px-6">
<SidebarTrigger />
<Separator orientation="vertical" className="mr-1 h-4" />
<Suspense fallback={<Skeleton className="h-4 w-32" />}>
<AppBreadcrumb data={data} />
</Suspense>
<div className="ml-auto">
<form action="/api/logout" method="post">
<header
className="flex h-14 shrink-0 items-center justify-between border-b bg-background/60 px-4 backdrop-blur-sm"
role="banner"
>
<div className="flex items-center gap-2">
<SidebarTrigger className="-ml-1" />
<span className="mx-1 hidden h-5 w-px bg-border md:block" aria-hidden="true" />
<Suspense fallback={<Skeleton className="h-5 w-36" />}>
<AppBreadcrumb data={data} />
</Suspense>
</div>
<div className="flex items-center gap-1">
<form action="/api/logout" method="post" aria-label="Sign out">
<input name="return_url" type="hidden" value="/" />
<Button size="sm" type="submit" variant="ghost">
<LogOut />
Sign out
<Button size="default" type="submit" variant="ghost" className="text-muted-foreground hover:text-foreground">
<LogOut className="size-4" />
<span className="hidden sm:inline">Sign out</span>
</Button>
</form>
</div>
</header>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto grid w-full max-w-[86rem] gap-6 p-4 md:p-6">
<main className="min-h-0 flex-1 overflow-y-auto" id="main-content">
<div
className={cn(
"grid w-full gap-5",
isSettingsRoute ? "min-h-full" : "mx-auto max-w-[86rem] p-4",
)}
>
{children}
</div>
</div>
</main>
</SidebarInset>
</SidebarProvider>
);
@@ -1,7 +1,5 @@
"use client";
import { PrincipalsView } from "@/components/dashboard/overview-views";
import { redirect } from "next/navigation";
export default function PrincipalPage() {
return <PrincipalsView />;
redirect("/settings/principals");
}
@@ -0,0 +1,12 @@
"use client";
import useSWR from "swr";
import { AboutSettingsContent } from "@/components/dashboard/settings-view";
import { fetchDashboard } from "@/lib/authsome-api";
export default function SettingsAboutPage() {
const { data } = useSWR("authsome-dashboard", fetchDashboard);
if (!data) return null;
return <AboutSettingsContent data={data} />;
}
@@ -0,0 +1,12 @@
"use client";
import useSWR from "swr";
import { GeneralSettingsContent } from "@/components/dashboard/settings-view";
import { fetchDashboard } from "@/lib/authsome-api";
export default function SettingsGeneralPage() {
const { data } = useSWR("authsome-dashboard", fetchDashboard);
if (!data) return null;
return <GeneralSettingsContent data={data} />;
}
@@ -0,0 +1,74 @@
"use client";
import { Info, Settings2, ShieldCheck, Users } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ReactNode } from "react";
import useSWR from "swr";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { fetchDashboard } from "@/lib/authsome-api";
type SettingsNavItem = {
href: string;
label: string;
icon: ReactNode;
adminOnly?: boolean;
};
const SETTINGS_NAV: SettingsNavItem[] = [
{ href: "/settings/general", label: "General", icon: <Settings2 /> },
{ href: "/settings/principals", label: "Principals", icon: <Users />, adminOnly: true },
{ href: "/settings/security", label: "Security", icon: <ShieldCheck /> },
{ href: "/settings/about", label: "About", icon: <Info /> },
];
export default function SettingsLayout({ children }: { children: ReactNode }) {
const pathname = usePathname();
const { data } = useSWR("authsome-dashboard", fetchDashboard);
const items = SETTINGS_NAV.filter((item) => !item.adminOnly || data?.account.isAdmin);
return (
<div className="flex min-h-full">
<Sidebar
collapsible="none"
className="sticky top-0 h-full w-44 shrink-0 border-r"
style={{ "--sidebar-width": "11rem" } as React.CSSProperties}
>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Settings</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton
render={<Link href={item.href} />}
isActive={pathname === item.href || pathname.startsWith(item.href + "/")}
>
{item.icon}
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
<div className="flex-1 min-w-0 p-4">
{children}
</div>
</div>
);
}
+2 -9
View File
@@ -1,12 +1,5 @@
"use client";
import useSWR from "swr";
import { SettingsView } from "@/components/dashboard/settings-view";
import { fetchDashboard } from "@/lib/authsome-api";
import { redirect } from "next/navigation";
export default function SettingsPage() {
const { data } = useSWR("authsome-dashboard", fetchDashboard);
if (!data) return null;
return <SettingsView data={data} />;
redirect("/settings/general");
}
@@ -0,0 +1,7 @@
"use client";
import { PrincipalsView } from "@/components/dashboard/overview-views";
export default function SettingsPrincipalsPage() {
return <PrincipalsView />;
}
@@ -0,0 +1,17 @@
"use client";
import { Suspense } from "react";
import useSWR from "swr";
import { SecuritySettingsContent } from "@/components/dashboard/settings-view";
import { fetchDashboard } from "@/lib/authsome-api";
export default function SettingsSecurityPage() {
const { data } = useSWR("authsome-dashboard", fetchDashboard);
if (!data) return null;
return (
<Suspense>
<SecuritySettingsContent data={data} />
</Suspense>
);
}
+103 -38
View File
@@ -48,72 +48,70 @@
--radius-4xl: calc(var(--radius) * 2.6);
}
/* ─── Light mode (fallback) ──────────────────────────────────────────────── */
/* ─── Light mode ─────────────────────────────────────────────────────────── */
:root {
--background: oklch(0.985 0 0);
--foreground: oklch(0.18 0.01 260);
--foreground: oklch(0.145 0.004 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.18 0.01 260);
--card-foreground: oklch(0.145 0.004 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.18 0.01 260);
--primary: oklch(0.65 0.16 163);
--primary-foreground: oklch(0.23 0.09 163);
--popover-foreground: oklch(0.145 0.004 285.823);
--primary: oklch(0.55 0.16 163);
--primary-foreground: oklch(0.985 0.002 163);
--secondary: oklch(0.96 0.015 163);
--secondary-foreground: oklch(0.28 0.06 163);
--muted: oklch(0.965 0.012 260);
--muted-foreground: oklch(0.5 0.03 260);
--accent: oklch(0.65 0.16 163);
--accent-foreground: oklch(0.23 0.09 163);
--muted: oklch(0.96 0.005 285.823);
--muted-foreground: oklch(0.556 0.02 285.823);
--accent: oklch(0.96 0.005 285.823);
--accent-foreground: oklch(0.145 0.004 285.823);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.91 0.015 260);
--input: oklch(0.91 0.015 260);
--ring: oklch(0.65 0.16 163);
--chart-1: oklch(0.65 0.16 163);
--border: oklch(0.907 0.006 285.823);
--input: oklch(0.907 0.006 285.823);
--ring: oklch(0.55 0.16 163);
--chart-1: oklch(0.55 0.16 163);
--chart-2: oklch(0.84 0.13 163);
--chart-3: oklch(0.74 0.14 82);
--chart-4: oklch(0.58 0.12 18);
--chart-5: oklch(0.48 0.04 260);
--radius: 0.375rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.18 0.01 260);
--sidebar-primary: oklch(0.65 0.16 163);
--sidebar-primary-foreground: oklch(0.23 0.09 163);
--sidebar-accent: oklch(0.94 0.02 163);
--sidebar-accent-foreground: oklch(0.18 0.01 260);
--sidebar-border: oklch(0.91 0.015 260);
--sidebar-ring: oklch(0.65 0.16 163);
--sidebar-foreground: oklch(0.145 0.004 285.823);
--sidebar-primary: oklch(0.55 0.16 163);
--sidebar-primary-foreground: oklch(0.985 0.002 163);
--sidebar-accent: oklch(0.96 0.005 285.823);
--sidebar-accent-foreground: oklch(0.145 0.004 285.823);
--sidebar-border: oklch(0.907 0.006 285.823);
--sidebar-ring: oklch(0.55 0.16 163);
}
/* ─── Dark mode — Authsome Secure Console design system ─────────────────── */
/* Palette: Deep Emerald (#10B981) + Obsidian (#09090B) */
/* Ref: ui/DESIGN.md */
.dark {
--background: oklch(0.088 0.002 277); /* #09090b obsidian */
--foreground: oklch(0.912 0.007 338); /* #e5e1e4 on-surface */
--background: oklch(0.088 0.002 277);
--foreground: oklch(0.912 0.007 338);
--card: oklch(0.118 0.003 285); /* #131315 surface panel */
--card: oklch(0.118 0.003 285);
--card-foreground: oklch(0.912 0.007 338);
--popover: oklch(0.118 0.003 285);
--popover-foreground: oklch(0.912 0.007 338);
/* Emerald primary */
--primary: oklch(0.69 0.162 162); /* #10b981 */
--primary-foreground: oklch(0.23 0.094 162); /* #003824 */
--primary: oklch(0.69 0.162 162);
--primary-foreground: oklch(0.23 0.094 162);
--secondary: oklch(0.17 0.007 308); /* #201f22 surface-high */
--secondary: oklch(0.17 0.007 308);
--secondary-foreground: oklch(0.912 0.007 338);
--muted: oklch(0.17 0.007 308); /* #201f22 */
--muted-foreground: oklch(0.60 0.019 152); /* #86948a */
--muted: oklch(0.17 0.007 308);
--muted-foreground: oklch(0.60 0.019 152);
--accent: oklch(0.205 0.004 292); /* #2a2a2c surface-container-high — neutral hover surface */
--accent-foreground: oklch(0.912 0.007 338); /* on-surface — NOT emerald; emerald is --primary only */
--accent: oklch(0.205 0.004 292);
--accent-foreground: oklch(0.912 0.007 338);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(0.21 0.005 300); /* #27272a */
--border: oklch(0.21 0.005 300);
--input: oklch(0.21 0.005 300);
--ring: oklch(0.69 0.162 162); /* emerald ring */
--ring: oklch(0.69 0.162 162);
--chart-1: oklch(0.69 0.162 162);
--chart-2: oklch(0.84 0.133 162);
@@ -121,12 +119,12 @@
--chart-4: oklch(0.58 0.12 18);
--chart-5: oklch(0.40 0.02 300);
--sidebar: oklch(0.09 0.003 285); /* #0e0e10 dim surface */
--sidebar: oklch(0.09 0.003 285);
--sidebar-foreground: oklch(0.912 0.007 338);
--sidebar-primary: oklch(0.69 0.162 162);
--sidebar-primary-foreground: oklch(0.23 0.094 162);
--sidebar-accent: oklch(0.205 0.004 292); /* matches --accent */
--sidebar-accent-foreground: oklch(0.80 0.021 153); /* #bbcabf */
--sidebar-accent: oklch(0.205 0.004 292);
--sidebar-accent-foreground: oklch(0.80 0.021 153);
--sidebar-border: oklch(0.21 0.005 300);
--sidebar-ring: oklch(0.69 0.162 162);
}
@@ -156,4 +154,71 @@
[role="button"][aria-disabled="true"] {
cursor: not-allowed;
}
/* ─── Enterprise-grade refinements ─────────────────────────────────────── */
/* Tighter card spacing for dense dashboards */
[data-slot="card"] {
@apply rounded-lg;
}
/* Consistent table density */
[data-slot="table-cell"] {
@apply py-2.5 px-3 text-sm;
}
[data-slot="table-head"] {
@apply py-2 px-3 text-xs font-medium uppercase tracking-wider text-muted-foreground;
}
/* Sharper badge styling */
[data-slot="badge"] {
@apply text-[0.7rem] font-medium tracking-wide;
}
/* Card header — keep title at base size, tighten the gap */
[data-slot="card-header"] {
@apply gap-1;
}
[data-slot="card-title"] {
@apply text-base font-semibold tracking-tight;
}
[data-slot="card-description"] {
@apply text-sm;
}
/* Tighter dialog spacing */
[data-slot="dialog-header"] {
@apply gap-1;
}
/* Improve focus-visible ring for accessibility */
:focus-visible {
@apply outline-2 outline-offset-2 outline-ring;
}
/* Skip-to-content link (screen readers and keyboard nav) */
.skip-link {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.skip-link:focus {
position: fixed;
left: 1rem;
top: 1rem;
z-index: 50;
width: auto;
height: auto;
margin: 0;
overflow: visible;
clip: auto;
white-space: normal;
@apply rounded-md bg-primary px-4 py-2 text-primary-foreground;
}
}
+12 -3
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Hanken_Grotesk, JetBrains_Mono } from "next/font/google";
import { ThemeProvider } from "@/components/theme-provider";
import { TooltipProvider } from "@/components/ui/tooltip";
import "./globals.css";
@@ -17,7 +18,7 @@ const jetbrainsMono = JetBrains_Mono({
export const metadata: Metadata = {
title: "Authsome Dashboard",
description: "Local dashboard for Authsome agents, providers, and connections.",
description: "Dashboard for Authsome agents, providers, and connections.",
};
export default function RootLayout({
@@ -28,10 +29,18 @@ export default function RootLayout({
return (
<html
lang="en"
className={`${hankenGrotesk.variable} ${jetbrainsMono.variable} dark h-full antialiased`}
className={`${hankenGrotesk.variable} ${jetbrainsMono.variable} h-full antialiased`}
suppressHydrationWarning
>
<body className="min-h-full bg-background text-foreground">
<TooltipProvider>{children}</TooltipProvider>
<ThemeProvider
attribute="class"
defaultTheme="dark"
disableTransitionOnChange
enableSystem
>
<TooltipProvider>{children}</TooltipProvider>
</ThemeProvider>
</body>
</html>
);
+1 -1
View File
@@ -9,7 +9,7 @@ export default function NotFound() {
<main className="flex min-h-screen items-center justify-center bg-background px-4 py-8">
<Card className="w-full max-w-md border-border/70 shadow-none">
<CardHeader>
<div className="mb-3 flex size-10 items-center justify-center rounded-lg border border-amber-800 bg-amber-950/40 text-amber-400">
<div className="mb-3 flex size-10 items-center justify-center rounded-lg border border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-400" aria-hidden="true">
<CircleAlert className="size-5" />
</div>
<CardTitle>Page not found</CardTitle>
+1 -7
View File
@@ -14,9 +14,8 @@ import {
isDashboardView,
} from "@/components/dashboard/dashboard-shell";
import type { View } from "@/components/dashboard/dashboard-shell";
import { AgentsView, AuditView, DashboardView, PrincipalsView } from "@/components/dashboard/overview-views";
import { AgentsView, AuditView, DashboardView } from "@/components/dashboard/overview-views";
import { ProvidersView } from "@/components/dashboard/provider-views";
import { SettingsView } from "@/components/dashboard/settings-view";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { DashboardData, fetchDashboard } from "@/lib/authsome-api";
@@ -46,7 +45,6 @@ export {
AgentsView,
AuditView,
DashboardView,
PrincipalsView,
} from "@/components/dashboard/overview-views";
export {
AuthsomeProviderDetail,
@@ -54,8 +52,6 @@ export {
ProviderDetailBody,
} from "@/components/dashboard/provider-detail-view";
export { ProvidersView } from "@/components/dashboard/provider-views";
export { SettingsView } from "@/components/dashboard/settings-view";
function ActiveView({
connectionFilter,
data,
@@ -82,9 +78,7 @@ function ActiveView({
);
}
if (view === "agents") return <AgentsView data={data} />;
if (view === "principals") return <PrincipalsView />;
if (view === "audit") return <AuditView data={data} />;
if (view === "settings") return <SettingsView data={data} />;
return <DashboardView data={data} />;
}
+111 -11
View File
@@ -1,10 +1,47 @@
"use client";
import { motion } from "framer-motion";
import { KeyRound, Shield, Cpu, Lock } from "lucide-react";
import Image from "next/image";
import { ReactNode } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
const FEATURES = [
{
icon: KeyRound,
title: "One CLI, every provider",
description: "GitHub, Google, Okta, Linear, OpenAI — connect any OAuth or API-key provider in seconds.",
},
{
icon: Shield,
title: "Encrypted at rest",
description: "Credentials are AES-encrypted in the vault. Secrets are protected at rest and in transit.",
},
{
icon: Cpu,
title: "Built for agents",
description: "Give AI agents scoped, revocable access to your accounts — without sharing raw tokens.",
},
{
icon: Lock,
title: "Proof-of-Possession auth",
description: "Every request is signed with Ed25519. No bearer tokens floating around.",
},
];
const containerVariants = {
hidden: {},
visible: {
transition: { staggerChildren: 0.1, delayChildren: 0.2 },
},
};
const itemVariants = {
hidden: { opacity: 0, y: 16 },
visible: { opacity: 1, y: 0, transition: { duration: 0.4, ease: "easeOut" as const } },
};
export function AuthFlowShell({
children,
description,
@@ -19,13 +56,71 @@ export function AuthFlowShell({
if (size === "wide") {
return (
<main className="flex min-h-screen items-center bg-background px-4 py-8 sm:px-6 lg:px-10">
<section className="mx-auto grid w-full max-w-6xl gap-10 lg:grid-cols-[0.9fr_1.1fr] lg:items-center">
<div className="max-w-md">
<Image alt="Authsome" className="mb-8 size-11" height={44} src="/logo.svg" width={44} />
<h1 className="text-4xl font-semibold leading-tight text-foreground">{title}</h1>
<p className="mt-4 text-base leading-7 text-muted-foreground">{description}</p>
</div>
<div>{children}</div>
<section className="mx-auto grid w-full max-w-6xl gap-12 lg:grid-cols-[1fr_1fr] lg:items-center">
<motion.div
className="max-w-lg"
initial="hidden"
animate="visible"
variants={containerVariants}
>
<motion.div variants={itemVariants}>
<Image
alt="Authsome"
className="mb-8 size-12 rounded-lg"
height={48}
src="/logo.svg"
width={48}
/>
</motion.div>
<motion.h1
className="text-3xl font-extrabold tracking-tight lg:text-4xl"
variants={itemVariants}
>
{title}
</motion.h1>
<motion.p
className="mt-3 text-base leading-7 text-muted-foreground"
variants={itemVariants}
>
{description}
</motion.p>
<motion.div
className="mt-10 grid gap-5"
variants={containerVariants}
initial="hidden"
animate="visible"
>
{FEATURES.map((feature) => (
<motion.div
key={feature.title}
className="flex gap-4"
variants={itemVariants}
>
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-primary/20 bg-primary/5 text-primary">
<feature.icon className="size-4" />
</div>
<div className="min-w-0">
<p className="text-sm font-semibold">{feature.title}</p>
<p className="mt-0.5 text-sm leading-relaxed text-muted-foreground">
{feature.description}
</p>
</div>
</motion.div>
))}
</motion.div>
</motion.div>
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5, delay: 0.3, ease: "easeOut" }}
>
{children}
</motion.div>
</section>
</main>
);
@@ -33,16 +128,21 @@ export function AuthFlowShell({
return (
<main className="flex min-h-screen items-center bg-background px-4 py-8 sm:px-6 lg:px-10">
<section className="mx-auto w-full max-w-md">
<motion.section
className="mx-auto w-full max-w-md"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: "easeOut" }}
>
<Card className="border-border/70 shadow-none">
<CardHeader>
<Image alt="Authsome" className="mb-4 size-9" height={36} src="/logo.svg" width={36} />
<CardTitle>{title}</CardTitle>
<Image alt="Authsome" className="mb-3 size-9 rounded-lg" height={36} src="/logo.svg" width={36} />
<CardTitle className="text-base">{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
{children ? <CardContent>{children}</CardContent> : null}
</Card>
</section>
</motion.section>
</main>
);
}
+111 -94
View File
@@ -1,6 +1,7 @@
"use client";
import { CheckCircle2, CircleAlert, LogIn, Plus, UserRound } from "lucide-react";
import { AnimatePresence, motion } from "framer-motion";
import { ArrowRight, CheckCircle2, CircleAlert, LogIn, UserRound } from "lucide-react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useState } from "react";
@@ -10,7 +11,6 @@ import { AuthFlowShell } from "@/components/dashboard/auth-flow-shell";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
SessionInputField,
fetchAuthSessionStatus,
@@ -43,72 +43,120 @@ export function AuthsomeLogin({ nextPath = NEXT_URL }: { nextPath?: string }) {
return tab === "register" ? "create" : "signin";
});
const [mode, setMode] = useState<"signin" | "create">(defaultTab === "create" ? "create" : "signin");
const isSignIn = mode === "signin";
return (
<AuthFlowShell
description="Sign in to manage account access, connected providers, and credential vaults."
description="Manage credentials, connected providers, and agent access from a single dashboard."
size="wide"
title="Authsome"
>
<Card className="border-border/70 shadow-none">
<CardHeader>
<CardTitle>Open Dashboard</CardTitle>
<CardDescription>Use your Authsome account to continue.</CardDescription>
<Card className="overflow-hidden border-border/50 shadow-lg shadow-black/5 dark:shadow-black/20">
<CardHeader className="space-y-1 pb-6">
<AnimatePresence mode="wait">
<motion.div
key={mode}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.15 }}
>
<CardTitle className="text-xl font-semibold tracking-tight">
{isSignIn ? "Sign in" : "Create account"}
</CardTitle>
<CardDescription className="mt-1">
{isSignIn
? "Enter your credentials to access the dashboard."
: "Set up a new account to get started."}
</CardDescription>
</motion.div>
</AnimatePresence>
</CardHeader>
<CardContent>
<Tabs className="gap-6" defaultValue={defaultTab}>
<TabsList className="grid h-9 w-full grid-cols-2">
<TabsTrigger value="signin">Sign in</TabsTrigger>
<TabsTrigger value="create">Create account</TabsTrigger>
</TabsList>
<TabsContent className="grid gap-5" value="signin">
<div>
<h2 className="text-base font-semibold">Sign in</h2>
<p className="mt-1 text-sm text-muted-foreground">
Continue with an existing Authsome account.
</p>
</div>
{defaultTab === "signin" && errorMessage ? (
<div className="flex items-center gap-2 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<CircleAlert className="size-4 shrink-0" />
{errorMessage}
<CardContent className="pb-8">
<AnimatePresence mode="wait">
<motion.div
key={mode}
initial={{ opacity: 0, x: isSignIn ? -12 : 12 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: isSignIn ? 12 : -12 }}
transition={{ duration: 0.2, ease: "easeInOut" as const }}
>
{errorMessage ? <ErrorBanner message={errorMessage} /> : null}
<form
action={isSignIn ? "/api/auth/login" : "/api/auth/register"}
className="grid gap-5"
method="post"
>
<input name="next" type="hidden" value={safeNextPath} />
<div className="grid gap-2">
<label htmlFor="email" className="text-sm font-medium">
Email
</label>
<Input
id="email"
autoComplete="email"
className="h-10"
name="email"
placeholder="you@example.com"
required
type="email"
/>
</div>
) : null}
<AccountForm
action="/api/auth/login"
autoComplete="current-password"
nextPath={safeNextPath}
submitIcon="login"
submitLabel="Sign in"
/>
</TabsContent>
<TabsContent className="grid gap-5" value="create">
<div>
<h2 className="text-base font-semibold">Create account</h2>
<p className="mt-1 text-sm text-muted-foreground">
Set up a new account for this dashboard.
</p>
</div>
{defaultTab === "create" && errorMessage ? (
<div className="flex items-center gap-2 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<CircleAlert className="size-4 shrink-0" />
{errorMessage}
<div className="grid gap-2">
<label htmlFor="password" className="text-sm font-medium">
Password
</label>
<Input
id="password"
autoComplete={isSignIn ? "current-password" : "new-password"}
className="h-10"
minLength={8}
name="password"
placeholder={isSignIn ? "Enter your password" : "Min. 8 characters"}
required
type="password"
/>
</div>
) : null}
<AccountForm
action="/api/auth/register"
autoComplete="new-password"
nextPath={safeNextPath}
submitIcon="plus"
submitLabel="Create account"
/>
</TabsContent>
</Tabs>
<Button className="mt-2 h-10 w-full text-sm font-semibold" type="submit" size="lg">
{isSignIn ? "Sign in" : "Create account"}
<ArrowRight className="size-4" />
</Button>
</form>
</motion.div>
</AnimatePresence>
<div className="mt-6 text-center text-sm text-muted-foreground">
{isSignIn ? "Dont have an account?" : "Already have an account?"}{" "}
<button
type="button"
className="font-medium text-primary underline-offset-4 hover:underline"
onClick={() => setMode(isSignIn ? "create" : "signin")}
>
{isSignIn ? "Create one" : "Sign in"}
</button>
</div>
</CardContent>
</Card>
</AuthFlowShell>
);
}
function ErrorBanner({ message }: { message: string }) {
return (
<motion.div
className="mb-1 flex items-center gap-2.5 rounded-lg border border-destructive/30 bg-destructive/5 px-3.5 py-3 text-sm text-destructive"
role="alert"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
transition={{ duration: 0.2 }}
>
<CircleAlert className="size-4 shrink-0" aria-hidden="true" />
{message}
</motion.div>
);
}
export function AuthsomeClaim({ token }: { token: string }) {
const { data, error } = useSWR(token ? ["authsome-claim", token] : null, () => fetchClaimStatus(token));
const nextPath = token ? `/claim?token=${encodeURIComponent(token)}` : "/claim";
@@ -233,14 +281,14 @@ function AuthsomeSessionInput({ sessionId }: { sessionId: string }) {
description="Enter the provider details required to continue this login flow."
title={data.display_name}
>
<form action={`/auth/input?session=${encodeURIComponent(sessionId)}`} className="grid gap-4" method="post">
<form action={`/auth/input?session=${encodeURIComponent(sessionId)}`} className="grid gap-3" method="post">
{data.warning ? (
<div className="rounded-lg border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
{data.warning}
</div>
) : null}
{data.callback_url ? (
<label className="grid gap-2 text-sm">
<label className="grid gap-1.5 text-sm">
<span className="text-muted-foreground">OAuth callback URL</span>
<Input readOnly value={data.callback_url} />
</label>
@@ -253,14 +301,14 @@ function AuthsomeSessionInput({ sessionId }: { sessionId: string }) {
<summary className="cursor-pointer text-sm font-medium text-muted-foreground">
Advanced
</summary>
<div className="mt-4 grid gap-4">
<div className="mt-3 grid gap-3">
{advancedFields.map((field) => (
<SessionInputFieldControl field={field} key={field.name} />
))}
</div>
</details>
) : null}
<Button className="mt-2 w-full" type="submit">
<Button className="mt-1 w-full" type="submit">
<LogIn />
Continue
</Button>
@@ -271,7 +319,7 @@ function AuthsomeSessionInput({ sessionId }: { sessionId: string }) {
function SessionInputFieldControl({ field }: { field: SessionInputField }) {
return (
<label className="grid gap-2 text-sm">
<label className="grid gap-1.5 text-sm">
<span className="text-muted-foreground">{field.label}</span>
<Input
defaultValue={field.default || ""}
@@ -349,11 +397,12 @@ function AuthsomeSessionSuccess({ errorCode, sessionId }: { errorCode?: string;
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-lg border",
isCompleted
? "border-emerald-800 bg-emerald-950/50 text-emerald-400"
? "border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-400"
: isFailed
? "border-destructive/40 bg-destructive/10 text-destructive"
: "border-amber-800 bg-amber-950/40 text-amber-400",
: "border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-400",
)}
aria-hidden="true"
>
{isCompleted ? <CheckCircle2 /> : <CircleAlert />}
</div>
@@ -407,7 +456,7 @@ function AuthsomeSessionDevice({ sessionId }: { sessionId: string }) {
description={`Use this code to finish signing in to ${data.display_name}.`}
title="Device login"
>
<div className="grid gap-4">
<div className="grid gap-3">
<div className="rounded-lg border bg-muted/30 px-4 py-3 text-center font-mono text-2xl font-semibold">
{data.user_code}
</div>
@@ -423,35 +472,3 @@ function AuthsomeSessionDevice({ sessionId }: { sessionId: string }) {
</AuthFlowShell>
);
}
function AccountForm({
action,
autoComplete,
nextPath,
submitIcon,
submitLabel,
}: {
action: string;
autoComplete: "current-password" | "new-password";
nextPath: string;
submitIcon: "login" | "plus";
submitLabel: string;
}) {
return (
<form action={action} className="grid gap-4" method="post">
<input name="next" type="hidden" value={nextPath} />
<label className="grid gap-2 text-sm">
<span className="text-muted-foreground">Email</span>
<Input autoComplete="email" name="email" required type="email" />
</label>
<label className="grid gap-2 text-sm">
<span className="text-muted-foreground">Password</span>
<Input autoComplete={autoComplete} minLength={8} name="password" required type="password" />
</label>
<Button className="mt-2 w-full" type="submit" variant={submitIcon === "plus" ? "outline" : "default"}>
{submitIcon === "plus" ? <Plus /> : <LogIn />}
{submitLabel}
</Button>
</form>
);
}
@@ -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>
</>
);
}
@@ -65,16 +65,16 @@ function providerLogoUrl(logo: string): string {
export function StatusBadge({ status }: { status: string }) {
if (status === "connected") {
return (
<Badge className="border-emerald-800 bg-emerald-950/50 text-emerald-400" variant="outline">
<CheckCircle2 />
<Badge className="border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-400" variant="outline">
<CheckCircle2 aria-hidden="true" />
Connected
</Badge>
);
}
if (status === "reauth" || status === "expired" || status === "error") {
return (
<Badge className="border-amber-800 bg-amber-950/50 text-amber-400" variant="outline">
<CircleAlert />
<Badge className="border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950/50 dark:text-amber-400" variant="outline">
<CircleAlert aria-hidden="true" />
Re-auth
</Badge>
);
@@ -118,8 +118,8 @@ export function SearchInput({
export function KeyValue({ label, value }: { label: string; value: string }) {
return (
<div className="grid gap-1">
<div className="text-xs font-medium uppercase text-muted-foreground">{label}</div>
<div className="grid gap-1.5">
<div className="text-sm text-muted-foreground">{label}</div>
<Tooltip>
<TooltipTrigger render={<div className="truncate rounded-lg border bg-muted px-3 py-2 font-mono text-sm" />}>
{value}
+25 -18
View File
@@ -12,12 +12,12 @@ import {
LogOut,
Settings,
UserRound,
Users,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { ReactNode } from "react";
import { ThemeToggle } from "@/components/theme-toggle";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
@@ -40,7 +40,7 @@ import {
} from "@/components/ui/sidebar";
import { DashboardData } from "@/lib/authsome-api";
export type View = "dashboard" | "providers" | "connections" | "agents" | "principals" | "audit" | "settings";
export type View = "dashboard" | "providers" | "connections" | "agents" | "audit" | "settings";
type NavItem = {
id: View;
@@ -55,7 +55,6 @@ export const NAV_ITEMS: NavItem[] = [
{ id: "providers", href: "/providers", label: "Providers", icon: <KeyRound /> },
{ id: "connections", href: "/connections", label: "Connections", icon: <Link2 /> },
{ id: "agents", href: "/agents", label: "Agents", icon: <UserRound /> },
{ id: "principals", href: "/principal", label: "Principals", icon: <Users />, adminOnly: true },
{ id: "audit", href: "/audit", label: "Audit Log", icon: <ClipboardList /> },
{ id: "settings", href: "/settings", label: "Settings", icon: <Settings /> },
];
@@ -142,7 +141,7 @@ export function DashboardDetailShell({
<SidebarInset className="min-h-0">
<Topbar />
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto grid w-full max-w-[86rem] gap-6 p-4 md:p-6">
<div className="mx-auto grid w-full max-w-[86rem] gap-5 py-4 pr-4">
{children}
</div>
</div>
@@ -214,11 +213,14 @@ export function AppSidebar({
</SidebarMenuItem>
</SidebarMenu>
<SidebarSeparator />
<div className="px-2 py-1">
<div className="truncate text-sm font-medium">{data.account.email || data.account.agent}</div>
{data.account.roleLabel ? (
<div className="mt-0.5 text-xs text-muted-foreground">{data.account.roleLabel}</div>
) : null}
<div className="flex items-center justify-between px-2 py-1">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{data.account.email || data.account.agent}</div>
{data.account.roleLabel ? (
<div className="mt-0.5 text-xs text-muted-foreground">{data.account.roleLabel}</div>
) : null}
</div>
<ThemeToggle />
</div>
</SidebarFooter>
<SidebarRail />
@@ -228,15 +230,20 @@ export function AppSidebar({
export function Topbar() {
return (
<header className="flex min-h-14 items-center justify-between gap-3 border-b bg-card px-4 py-3 md:px-6">
<SidebarTrigger />
<form action="/api/logout" method="post">
<input name="return_url" type="hidden" value={NEXT_URL} />
<Button size="sm" type="submit" variant="ghost">
<LogOut />
Sign out
</Button>
</form>
<header
className="flex h-14 shrink-0 items-center justify-between border-b bg-background/60 px-4 backdrop-blur-sm"
role="banner"
>
<SidebarTrigger className="-ml-1" />
<div className="flex items-center gap-1">
<form action="/api/logout" method="post" aria-label="Sign out">
<input name="return_url" type="hidden" value={NEXT_URL} />
<Button size="default" type="submit" variant="ghost" className="text-muted-foreground hover:text-foreground">
<LogOut className="size-4" />
<span className="hidden sm:inline">Sign out</span>
</Button>
</form>
</div>
</header>
);
}
+23 -20
View File
@@ -12,16 +12,17 @@ import { Badge } from "@/components/ui/badge";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { H4, Muted } from "@/components/ui/typography";
import { DashboardData, PrincipalRow, fetchAuditEvents, fetchPrincipals } from "@/lib/authsome-api";
export function DashboardView({ data }: { data: DashboardData }) {
const recentEvents = data.audit.events.slice(0, 5);
return (
<div className="grid gap-8">
<section>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold">Connected Apps</h2>
<div className="grid gap-5">
<section aria-labelledby="connected-apps-heading">
<div className="mb-3 flex items-center justify-between">
<H4 id="connected-apps-heading">Connected Apps</H4>
<Link className={buttonVariants({ size: "sm", variant: "outline" })} href="/providers">
Browse
</Link>
@@ -41,18 +42,18 @@ export function DashboardView({ data }: { data: DashboardData }) {
)}
</section>
<section>
<div className="mb-4">
<h2 className="text-base font-semibold">Agents</h2>
<section aria-labelledby="agents-heading">
<div className="mb-3">
<H4 id="agents-heading">Agents</H4>
</div>
{data.agents.length ? (
<div className="grid gap-2">
<div className="grid gap-1.5">
{data.agents.map((agent) => (
<div
className="flex items-center justify-between rounded-lg border bg-muted/30 px-4 py-3"
className="flex items-center justify-between rounded-lg border bg-muted/30 px-3 py-2.5"
key={agent.handle}
>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2.5">
<UserRound className="size-4 text-muted-foreground" />
<span className="text-sm font-medium">{agent.handle}</span>
</div>
@@ -66,9 +67,9 @@ export function DashboardView({ data }: { data: DashboardData }) {
</section>
{data.audit.canView && recentEvents.length > 0 ? (
<section>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold">Recent Events</h2>
<section aria-labelledby="recent-events-heading">
<div className="mb-3 flex items-center justify-between">
<H4 id="recent-events-heading">Recent Events</H4>
<Link className={buttonVariants({ size: "sm", variant: "outline" })} href="/audit">
View all
</Link>
@@ -82,8 +83,8 @@ export function DashboardView({ data }: { data: DashboardData }) {
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{event.time}
</TableCell>
<TableCell className="text-sm font-medium">{event.event}</TableCell>
<TableCell className="text-sm text-muted-foreground">{event.target}</TableCell>
<TableCell className="font-medium">{event.event}</TableCell>
<TableCell className="text-muted-foreground">{event.target}</TableCell>
</TableRow>
))}
</TableBody>
@@ -113,7 +114,7 @@ export function AgentsView({ data }: { data: DashboardData }) {
{data.agents.map((agent) => (
<TableRow key={agent.handle}>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2.5">
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted">
<UserRound className="size-3.5 text-muted-foreground" />
</span>
@@ -158,11 +159,11 @@ export function PrincipalsView() {
<TableRow key={principal.principal_id}>
<TableCell className="font-medium">{principal.email || "-"}</TableCell>
<TableCell>
<span className="font-mono text-xs text-muted-foreground">{principal.principal_id}</span>
<code className="font-mono text-xs text-muted-foreground">{principal.principal_id}</code>
</TableCell>
<TableCell>
<Badge
className={principal.role === "admin" ? "border-amber-800 bg-amber-950/50 text-amber-400" : ""}
className={principal.role === "admin" ? "border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950/50 dark:text-amber-400" : ""}
variant="outline"
>
{principal.role}
@@ -230,7 +231,9 @@ export function AuditView({ data }: { data: DashboardData }) {
return (
<div className="grid gap-5">
<SectionHeader description={description} title="Audit Log" />
{errorMessage ? <p className="text-sm text-destructive">{errorMessage}</p> : null}
{errorMessage ? (
<p className="text-sm text-destructive" role="alert">{errorMessage}</p>
) : null}
<Card className="shadow-none border-border/50">
<CardContent className="p-0">
{events.length ? (
@@ -253,7 +256,7 @@ export function AuditView({ data }: { data: DashboardData }) {
<TableCell className="text-muted-foreground">{event.target}</TableCell>
<TableCell>
{event.status === "success" ? (
<Badge className="border-emerald-800 bg-emerald-950/50 text-emerald-400" variant="outline">
<Badge className="border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-400" variant="outline">
{event.status}
</Badge>
) : event.status === "failure" || event.status === "error" ? (
+5 -5
View File
@@ -36,9 +36,9 @@ export function PageEmptyState({
: null;
return (
<div className="rounded-lg border border-dashed bg-muted/40 p-8 text-center">
<div className="font-medium">{title}</div>
{description ? <p className="mt-1 text-sm text-muted-foreground">{description}</p> : null}
<div className="rounded-lg border border-dashed bg-muted/40 p-6 text-center">
<div className="text-sm font-medium">{title}</div>
{description ? <p className="mt-1 text-xs text-muted-foreground">{description}</p> : null}
{action}
</div>
);
@@ -46,8 +46,8 @@ export function PageEmptyState({
export function PageErrorState({ title }: { title: string }) {
return (
<div className="flex items-center justify-center gap-2 rounded-lg border border-destructive/40 bg-destructive/10 p-8 text-center text-sm text-destructive">
<CircleAlert className="size-4 shrink-0" />
<div className="flex items-center justify-center gap-2 rounded-lg border border-destructive/40 bg-destructive/10 p-6 text-center text-sm text-destructive" role="alert">
<CircleAlert className="size-4 shrink-0" aria-hidden="true" />
{title}
</div>
);
@@ -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
@@ -1,8 +1,11 @@
import { H4 } from "@/components/ui/typography";
import { Muted } from "@/components/ui/typography";
export function SectionHeader({ description, title }: { description: string; title: string }) {
return (
<div>
<h1 className="text-2xl font-semibold leading-tight text-foreground">{title}</h1>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
<H4 className="leading-tight">{title}</H4>
<Muted className="mt-1">{description}</Muted>
</div>
);
}
+48 -44
View File
@@ -8,41 +8,45 @@ import { SectionHeader } from "@/components/dashboard/section-header";
import { Button, buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { DashboardData } from "@/lib/authsome-api";
const SETTINGS_TABS = new Set(["account", "about", "security"]);
export function GeneralSettingsContent({ data }: { data: DashboardData }) {
return (
<div className="grid gap-5">
<SectionHeader description="Account and vault context for this principal." title="General" />
<div className="grid gap-4 lg:grid-cols-2">
<SettingsAccountCard data={data} />
<SettingsVaultCard data={data} />
</div>
</div>
);
}
export function SettingsView({ data }: { data: DashboardData }) {
export function SecuritySettingsContent({ data }: { data: DashboardData }) {
const searchParams = useSearchParams();
const requestedTab = searchParams.get("tab") || "account";
const defaultTab = SETTINGS_TABS.has(requestedTab) ? requestedTab : "account";
const passwordChanged = searchParams.get("password_changed") === "1";
const passwordError = searchParams.get("password_error");
return (
<div className="grid gap-5">
<SectionHeader description="Account, runtime, and security context." title="Settings" />
<Tabs className="gap-5" defaultValue={defaultTab}>
<TabsList className="grid h-auto w-full grid-cols-3 md:w-fit">
<TabsTrigger value="account">General</TabsTrigger>
<TabsTrigger value="about">About</TabsTrigger>
<TabsTrigger value="security">Security</TabsTrigger>
</TabsList>
<TabsContent className="grid gap-4 lg:grid-cols-2" value="account">
<SettingsAccountCard data={data} />
<SettingsVaultCard data={data} />
</TabsContent>
<TabsContent className="grid gap-4 lg:grid-cols-2" value="about">
<SettingsDaemonCard data={data} />
<SettingsAboutCard />
</TabsContent>
<TabsContent className="grid gap-4 lg:grid-cols-2" value="security">
<SettingsSecurityCard data={data} />
<SettingsPasswordCard passwordChanged={passwordChanged} passwordError={passwordError} />
</TabsContent>
</Tabs>
<SectionHeader description="Credential protection and access controls." title="Security" />
<div className="grid gap-4 lg:grid-cols-2">
<SettingsSecurityCard data={data} />
<SettingsPasswordCard passwordChanged={passwordChanged} passwordError={passwordError} />
</div>
</div>
);
}
export function AboutSettingsContent({ data }: { data: DashboardData }) {
return (
<div className="grid gap-5">
<SectionHeader description="Runtime details and project references." title="About" />
<div className="grid gap-4 lg:grid-cols-2">
<SettingsDaemonCard data={data} />
<SettingsAboutCard />
</div>
</div>
);
}
@@ -57,7 +61,7 @@ function SettingsAccountCard({ data }: { data: DashboardData }) {
</CardTitle>
<CardDescription>Principal and dashboard session context.</CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
<CardContent className="grid gap-3">
<SettingsKeyValue label="Email" value={data.account.email || "-"} />
<SettingsKeyValue label="Role" value={data.account.roleLabel || "-"} />
<SettingsKeyValue label="Principal ID" value={data.account.principalId || "-"} />
@@ -76,7 +80,7 @@ function SettingsVaultCard({ data }: { data: DashboardData }) {
</CardTitle>
<CardDescription>Credential namespace used by this account.</CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
<CardContent className="grid gap-3">
<SettingsKeyValue label="Handle" value={data.vault.handle || "-"} />
<SettingsKeyValue label="Vault ID" value={data.vault.vaultId || "-"} />
<SettingsKeyValue label="Default Vault" value={data.vault.isDefault ? "Yes" : "No"} />
@@ -95,7 +99,7 @@ function SettingsDaemonCard({ data }: { data: DashboardData }) {
</CardTitle>
<CardDescription>Runtime and local service details.</CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
<CardContent className="grid gap-3">
<SettingsKeyValue label="Version" value={data.version} />
<SettingsKeyValue label="Latest Token Expiry" value={data.latestTokenExpiry || "-"} />
</CardContent>
@@ -115,7 +119,7 @@ function SettingsAboutCard() {
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
<Link
className={buttonVariants({ size: "sm", variant: "outline" })}
className={buttonVariants({ variant: "outline" })}
href="https://authsome.ai/docs"
rel="noreferrer"
target="_blank"
@@ -124,7 +128,7 @@ function SettingsAboutCard() {
<ExternalLink />
</Link>
<Link
className={buttonVariants({ size: "sm", variant: "outline" })}
className={buttonVariants({ variant: "outline" })}
href="https://github.com/agentrhq/authsome/releases"
rel="noreferrer"
target="_blank"
@@ -147,7 +151,7 @@ function SettingsSecurityCard({ data }: { data: DashboardData }) {
</CardTitle>
<CardDescription>Credential protection and administrative visibility.</CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
<CardContent className="grid gap-3">
<SettingsKeyValue label="Encryption" value={data.account.principalId ? "AES-256-GCM" : "-"} />
<SettingsKeyValue label="Audit Access" value={data.audit.canView ? "Available" : "Unavailable"} />
<SettingsKeyValue label="Audit Events" value={String(data.audit.total)} />
@@ -173,27 +177,27 @@ function SettingsPasswordCard({
<CardDescription>Hosted account credential.</CardDescription>
</CardHeader>
<CardContent>
<form action="/api/auth/password" className="grid gap-4" method="post">
<input name="next" type="hidden" value="/settings?tab=security" />
<form action="/api/auth/password" className="grid gap-3" method="post">
<input name="next" type="hidden" value="/settings/security?password_changed=1" />
{passwordChanged ? (
<div className="rounded-lg border border-emerald-800 bg-emerald-950/30 px-3 py-2 text-sm text-emerald-300">
<div className="rounded-lg border border-emerald-300 bg-emerald-50 px-3 py-2 text-sm text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-300" role="status">
Password updated.
</div>
) : null}
{passwordError ? (
<div className="rounded-lg border border-destructive/60 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<div className="rounded-lg border border-destructive/60 bg-destructive/10 px-3 py-2 text-sm text-destructive" role="alert">
{passwordError}
</div>
) : null}
<label className="grid gap-1 text-sm font-medium">
Current password
<label className="grid gap-1.5 text-sm">
<span className="text-muted-foreground">Current password</span>
<Input autoComplete="current-password" name="current_password" required type="password" />
</label>
<label className="grid gap-1 text-sm font-medium">
New password
<label className="grid gap-1.5 text-sm">
<span className="text-muted-foreground">New password</span>
<Input autoComplete="new-password" minLength={8} name="new_password" required type="password" />
</label>
<Button className="w-fit" size="sm" type="submit">
<Button className="w-fit" type="submit">
<KeyRound />
Change password
</Button>
@@ -203,12 +207,12 @@ function SettingsPasswordCard({
);
}
function SettingsKeyValue({ label, value }: { label: string; value: string }) {
export function SettingsKeyValue({ label, value }: { label: string; value: string }) {
return (
<div className="grid gap-1">
<div className="text-xs font-medium uppercase text-muted-foreground">{label}</div>
<div className="grid gap-1.5">
<div className="text-sm text-muted-foreground">{label}</div>
<Tooltip>
<TooltipTrigger render={<div className="truncate rounded-lg border bg-muted px-3 py-2 font-mono text-sm" />}>
<TooltipTrigger render={<div className="truncate rounded-md border bg-muted/50 px-3 py-2 font-mono text-sm" />}>
{value}
</TooltipTrigger>
<TooltipContent>{value}</TooltipContent>
+10
View File
@@ -0,0 +1,10 @@
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
+22
View File
@@ -0,0 +1,22 @@
"use client";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
export function ThemeToggle() {
const { setTheme, resolvedTheme } = useTheme();
return (
<Button
aria-label="Toggle theme"
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
size="icon"
variant="ghost"
>
<Sun className="size-[1.1rem] scale-100 rotate-0 transition-transform dark:scale-0 dark:-rotate-90" />
<Moon className="absolute size-[1.1rem] scale-0 rotate-90 transition-transform dark:scale-100 dark:rotate-0" />
</Button>
);
}
+1 -1
View File
@@ -23,7 +23,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b bg-muted/40", className)}
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
+96
View File
@@ -0,0 +1,96 @@
import React, { forwardRef, JSX } from "react";
import { cn } from "@/lib/utils";
const createComponent = <T extends HTMLElement>(
tag: keyof JSX.IntrinsicElements,
defaultClassName: string,
displayName: string,
) => {
const Component = forwardRef<T, React.HTMLAttributes<T>>((props, ref) => {
return React.createElement(
tag,
{ ...props, ref, className: cn(defaultClassName, props.className) },
props.children,
);
});
Component.displayName = displayName;
return Component;
};
export const H1 = createComponent<HTMLHeadingElement>(
"h1",
"scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl",
"H1",
);
export const H2 = createComponent<HTMLHeadingElement>(
"h2",
"scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight first:mt-0",
"H2",
);
export const H3 = createComponent<HTMLHeadingElement>(
"h3",
"scroll-m-20 text-2xl font-semibold tracking-tight",
"H3",
);
export const H4 = createComponent<HTMLHeadingElement>(
"h4",
"scroll-m-20 text-xl font-semibold tracking-tight",
"H4",
);
export const Lead = createComponent<HTMLParagraphElement>(
"p",
"text-xl text-muted-foreground",
"Lead",
);
export const P = createComponent<HTMLParagraphElement>(
"p",
"leading-7 [&:not(:first-child)]:mt-6",
"P",
);
export const Large = createComponent<HTMLDivElement>(
"div",
"text-lg font-semibold",
"Large",
);
export const Small = createComponent<HTMLParagraphElement>(
"p",
"text-sm font-medium leading-none",
"Small",
);
export const Muted = createComponent<HTMLSpanElement>(
"span",
"text-sm text-muted-foreground",
"Muted",
);
export const InlineCode = createComponent<HTMLSpanElement>(
"code",
"relative rounded-sm bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold",
"InlineCode",
);
export const MultilineCode = createComponent<HTMLPreElement>(
"pre",
"relative rounded-sm bg-muted p-4 font-mono text-sm font-semibold overflow-x-auto",
"MultilineCode",
);
export const List = createComponent<HTMLUListElement>(
"ul",
"my-6 ml-6 list-disc [&>li]:mt-2",
"List",
);
export const Quote = createComponent<HTMLQuoteElement>(
"blockquote",
"mt-6 border-l-2 pl-6 italic text-muted-foreground",
"Quote",
);