mirror of
https://github.com/agentrhq/authsome.git
synced 2026-09-19 01:34:19 +08:00
@@ -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",
|
||||
|
||||
Generated
+52
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ? "Don’t 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 & Access
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 pt-4 md:grid-cols-2">
|
||||
<DetailField label="Status">
|
||||
<StatusBadge status={data.status} />
|
||||
</DetailField>
|
||||
<DetailField label="Auth Type">
|
||||
<span className="text-sm">{authTypeLabel(data.auth_type)}</span>
|
||||
</DetailField>
|
||||
<DetailField label="Principal ID">
|
||||
<code className="text-xs font-mono text-muted-foreground">{data.principal_id || "-"}</code>
|
||||
</DetailField>
|
||||
<DetailField label="Agent">
|
||||
<span className="text-sm">{data.identity || "-"}</span>
|
||||
</DetailField>
|
||||
{data.scopes.length > 0 ? (
|
||||
<div className="md:col-span-2">
|
||||
<DetailField label="Scopes">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{data.scopes.map((scope) => (
|
||||
<Badge key={scope} variant="outline">{scope}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</DetailField>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/50 shadow-none">
|
||||
<CardHeader className="pb-0">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Clock className="size-4 text-muted-foreground" />
|
||||
Token
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 pt-4 md:grid-cols-2">
|
||||
<DetailField label="Token Type">
|
||||
<span className="text-sm">{data.token_type || "-"}</span>
|
||||
</DetailField>
|
||||
<DetailField label="Obtained">
|
||||
<span className="text-sm">{formatTimestamp(data.obtained_at)}</span>
|
||||
</DetailField>
|
||||
<DetailField label="Expires">
|
||||
<span className="text-sm">{formatTimestamp(data.expires_at)}</span>
|
||||
</DetailField>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{hasEndpoints ? (
|
||||
<Card className="border-border/50 shadow-none">
|
||||
<CardHeader className="pb-0">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Server className="size-4 text-muted-foreground" />
|
||||
Endpoints
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 pt-4">
|
||||
{data.base_url ? (
|
||||
<DetailField label="Base URL">
|
||||
<code className="text-xs font-mono break-all">{data.base_url}</code>
|
||||
</DetailField>
|
||||
) : null}
|
||||
{data.api_url ? (
|
||||
<DetailField label="API URL">
|
||||
<code className="text-xs font-mono break-all">{data.api_url}</code>
|
||||
</DetailField>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{hasSecrets ? (
|
||||
<Card className="border-border/50 shadow-none h-fit">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<KeyRound className="size-4 text-muted-foreground" />
|
||||
Secrets
|
||||
</CardTitle>
|
||||
<CardDescription>Encrypted at rest in the vault.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3">
|
||||
<SecretValue label="Access Token" value={data.secrets.access_token} />
|
||||
<SecretValue label="Refresh Token" value={data.secrets.refresh_token} />
|
||||
<SecretValue label="API Key" value={data.secrets.api_key} />
|
||||
{Object.entries(data.secrets.credentials).map(([key, value]) => (
|
||||
<SecretValue key={key} label={key} value={value} />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailField({ children, label }: { children: ReactNode; label: string }) {
|
||||
return (
|
||||
<div className="grid gap-1">
|
||||
<div className="text-xs font-medium uppercase tracking-wider text-muted-foreground">{label}</div>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function authTypeLabel(authType: string): string {
|
||||
return authType === "oauth2" ? "OAuth 2.0" : authType === "api_key" ? "API Key" : authType || "-";
|
||||
}
|
||||
|
||||
function formatTimestamp(value: string | null): string {
|
||||
if (!value) return "-";
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.valueOf())) return value;
|
||||
return parsed.toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZoneName: "short",
|
||||
});
|
||||
}
|
||||
|
||||
function redactedValue(value: string): string {
|
||||
return value.length <= 8 ? "********" : `${value.slice(0, 4)}...${value.slice(-4)}`;
|
||||
}
|
||||
@@ -164,10 +283,10 @@ function SecretValue({ label, value }: { label: string; value: string | null })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<div className="text-xs font-medium uppercase text-muted-foreground">{label}</div>
|
||||
<div className="flex min-w-0 items-start gap-2 rounded-lg border bg-muted p-3">
|
||||
<code className="min-w-0 flex-1 break-all text-xs">{revealed ? value : redactedValue(value)}</code>
|
||||
<div className="grid gap-1.5">
|
||||
<div className="text-xs font-medium uppercase tracking-wider text-muted-foreground">{label}</div>
|
||||
<div className="flex min-w-0 items-start gap-1.5 rounded-md border bg-muted/50 p-2.5">
|
||||
<code className="min-w-0 flex-1 break-all pt-0.5 text-xs">{revealed ? value : redactedValue(value)}</code>
|
||||
<Button
|
||||
aria-label={revealed ? "Hide secret" : "Reveal secret"}
|
||||
onClick={() => setRevealed((current) => !current)}
|
||||
@@ -179,13 +298,12 @@ function SecretValue({ label, value }: { label: string; value: string | null })
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Copy secret"
|
||||
disabled={!revealed}
|
||||
onClick={() => void copy()}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{copied ? <Check /> : <Clipboard />}
|
||||
{copied ? <Check className="text-emerald-600 dark:text-emerald-400" /> : <Clipboard />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -243,7 +361,7 @@ function ConnectionActions({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<div className="flex flex-col items-start gap-2 sm:items-end">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{data.can_set_default ? (
|
||||
<form
|
||||
@@ -251,12 +369,13 @@ function ConnectionActions({
|
||||
method="post"
|
||||
>
|
||||
<Button size="sm" type="submit" variant="outline">
|
||||
<Link2 />
|
||||
Set as default
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
{data.can_set_global ? (
|
||||
<Button disabled={globalWorking} onClick={() => void toggleGlobal()} type="button" variant="outline">
|
||||
<Button disabled={globalWorking} onClick={() => void toggleGlobal()} size="sm" type="button" variant="outline">
|
||||
<Globe2 />
|
||||
{data.is_global ? "Unset global" : "Make global"}
|
||||
</Button>
|
||||
@@ -269,21 +388,25 @@ function ConnectionActions({
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
"min-h-5 text-sm",
|
||||
globalMessage?.tone === "success" ? "text-emerald-400" : "text-destructive"
|
||||
)}
|
||||
>
|
||||
{globalMessage?.text}
|
||||
</div>
|
||||
{globalMessage ? (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
"text-sm",
|
||||
globalMessage.tone === "success" ? "text-emerald-700 dark:text-emerald-400" : "text-destructive"
|
||||
)}
|
||||
>
|
||||
{globalMessage.text}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Dialog onOpenChange={setOpen} open={open}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Logout connection</DialogTitle>
|
||||
<DialogDescription>This removes the stored credentials for this connection.</DialogDescription>
|
||||
<DialogDescription>
|
||||
This removes the stored credentials for “{data.connection_name}” on {data.provider_display_name}.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setOpen(false)} type="button" variant="outline">
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { Globe2 } from "lucide-react";
|
||||
import { Globe2, Link2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
INTERACTIVE_ROW_CLASS,
|
||||
ProviderLogo,
|
||||
SearchInput,
|
||||
StatusBadge,
|
||||
connectionDetailHref,
|
||||
} from "@/components/dashboard/dashboard-primitives";
|
||||
import { PageEmptyState } from "@/components/dashboard/page-state";
|
||||
import { SectionHeader } from "@/components/dashboard/section-header";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { DashboardData, GlobalConnectionRow, unsetGlobalConnection } from "@/lib/authsome-api";
|
||||
import { DashboardData, GlobalConnectionRow, ProviderView, unsetGlobalConnection } from "@/lib/authsome-api";
|
||||
|
||||
export function ConnectionsView({
|
||||
connections,
|
||||
@@ -23,15 +33,21 @@ export function ConnectionsView({
|
||||
initialFilter,
|
||||
isAdmin,
|
||||
onRefresh,
|
||||
providers,
|
||||
}: {
|
||||
connections: DashboardData["connections"];
|
||||
globalConnections: DashboardData["globalConnections"];
|
||||
initialFilter?: string;
|
||||
isAdmin: boolean;
|
||||
onRefresh: () => void;
|
||||
providers?: ProviderView[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [query, setQuery] = useState(initialFilter ?? "");
|
||||
const providerMap = useMemo(
|
||||
() => new Map((providers ?? []).map((p) => [p.name, p])),
|
||||
[providers],
|
||||
);
|
||||
const filteredConnections = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return connections;
|
||||
@@ -49,15 +65,31 @@ export function ConnectionsView({
|
||||
);
|
||||
}, [globalConnections, query]);
|
||||
|
||||
const isFiltering = query.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="grid gap-5">
|
||||
<SectionHeader description="Connection fallbacks and named connections in the current vault." title="Connections" />
|
||||
<SectionHeader description="Named connections and deployment-wide fallbacks in the current vault." title="Connections" />
|
||||
<SearchInput onChange={setQuery} placeholder="Search connections..." value={query} />
|
||||
<GlobalConnectionsSection connections={filteredGlobalConnections} isAdmin={isAdmin} onRefresh={onRefresh} />
|
||||
|
||||
<Card className="shadow-none border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle>Your Connections</CardTitle>
|
||||
<CardDescription>Named connections in the current vault.</CardDescription>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Link2 className="size-4 text-muted-foreground" />
|
||||
Your Connections
|
||||
</CardTitle>
|
||||
<CardDescription>Named connections in the current vault.</CardDescription>
|
||||
</div>
|
||||
{isFiltering ? (
|
||||
<Badge variant="outline">
|
||||
{filteredConnections.length} of {connections.length}
|
||||
</Badge>
|
||||
) : connections.length ? (
|
||||
<Badge variant="outline">{connections.length}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{filteredConnections.length ? (
|
||||
@@ -73,6 +105,7 @@ export function ConnectionsView({
|
||||
<TableBody>
|
||||
{filteredConnections.map((row) => {
|
||||
const href = connectionDetailHref(row.providerName, row.connectionName);
|
||||
const provider = providerMap.get(row.providerName);
|
||||
return (
|
||||
<TableRow
|
||||
className={INTERACTIVE_ROW_CLASS}
|
||||
@@ -88,7 +121,12 @@ export function ConnectionsView({
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell>
|
||||
<span className="font-medium">{row.connectionName}</span>
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
{provider ? (
|
||||
<ProviderLogo className="size-7 shrink-0" initial={provider.logoInitial} logo={provider.logo} />
|
||||
) : null}
|
||||
<span className="truncate font-medium">{row.connectionName}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{row.providerDisplayName}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{row.authTypeLabel}</TableCell>
|
||||
@@ -100,9 +138,31 @@ export function ConnectionsView({
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : <PageEmptyState title="No connections found" />}
|
||||
) : (
|
||||
<div className="p-4">
|
||||
{isFiltering ? (
|
||||
<PageEmptyState title="No matching connections" />
|
||||
) : (
|
||||
<PageEmptyState
|
||||
actionLabel="Browse providers"
|
||||
description="Connect a provider to create your first connection."
|
||||
href="/providers"
|
||||
title="No connections yet"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<GlobalConnectionsSection
|
||||
connections={filteredGlobalConnections}
|
||||
isAdmin={isAdmin}
|
||||
isFiltering={isFiltering}
|
||||
onRefresh={onRefresh}
|
||||
providerMap={providerMap}
|
||||
totalCount={globalConnections.length}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -110,19 +170,39 @@ export function ConnectionsView({
|
||||
function GlobalConnectionsSection({
|
||||
connections,
|
||||
isAdmin,
|
||||
isFiltering,
|
||||
onRefresh,
|
||||
providerMap,
|
||||
totalCount,
|
||||
}: {
|
||||
connections: GlobalConnectionRow[];
|
||||
isAdmin: boolean;
|
||||
isFiltering: boolean;
|
||||
onRefresh: () => void;
|
||||
providerMap: Map<string, ProviderView>;
|
||||
totalCount: number;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Card className="shadow-none border-border/50">
|
||||
<Card className="shadow-none border-border/50 border-l-primary/40 border-l-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Global Connections</CardTitle>
|
||||
<CardDescription>Deployment-wide fallback connections available to accepted agents.</CardDescription>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Globe2 className="size-4 text-primary/70" />
|
||||
Global Connections
|
||||
</CardTitle>
|
||||
<CardDescription>Deployment-wide fallback connections available to accepted agents.</CardDescription>
|
||||
</div>
|
||||
{isFiltering ? (
|
||||
<Badge variant="outline">
|
||||
{connections.length} of {totalCount}
|
||||
</Badge>
|
||||
) : totalCount ? (
|
||||
<Badge variant="outline">{totalCount}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{connections.length ? (
|
||||
@@ -139,6 +219,7 @@ function GlobalConnectionsSection({
|
||||
<TableBody>
|
||||
{connections.map((row) => {
|
||||
const href = connectionDetailHref(row.providerName, row.connectionName);
|
||||
const provider = providerMap.get(row.providerName);
|
||||
return (
|
||||
<TableRow
|
||||
className={INTERACTIVE_ROW_CLASS}
|
||||
@@ -154,8 +235,10 @@ function GlobalConnectionsSection({
|
||||
tabIndex={0}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Globe2 className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
{provider ? (
|
||||
<ProviderLogo className="size-7 shrink-0" initial={provider.logoInitial} logo={provider.logo} />
|
||||
) : null}
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{row.connectionName}</div>
|
||||
{row.accountLabel ? (
|
||||
@@ -171,7 +254,12 @@ function GlobalConnectionsSection({
|
||||
</TableCell>
|
||||
{isAdmin ? (
|
||||
<TableCell onClick={(event) => event.stopPropagation()}>
|
||||
<RemoveGlobalConnectionButton onRefresh={onRefresh} provider={row.providerName} />
|
||||
<RemoveGlobalConnectionButton
|
||||
connectionName={row.connectionName}
|
||||
onRefresh={onRefresh}
|
||||
provider={row.providerName}
|
||||
providerDisplayName={row.providerDisplayName}
|
||||
/>
|
||||
</TableCell>
|
||||
) : null}
|
||||
</TableRow>
|
||||
@@ -179,19 +267,42 @@ function GlobalConnectionsSection({
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : <PageEmptyState title="No global connections found" />}
|
||||
) : (
|
||||
<div className="p-4">
|
||||
{isFiltering ? (
|
||||
<PageEmptyState title="No matching global connections" />
|
||||
) : (
|
||||
<PageEmptyState
|
||||
description="Admins can promote a connection to global from its detail page."
|
||||
title="No global connections"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoveGlobalConnectionButton({ onRefresh, provider }: { onRefresh: () => void; provider: string }) {
|
||||
function RemoveGlobalConnectionButton({
|
||||
connectionName,
|
||||
onRefresh,
|
||||
provider,
|
||||
providerDisplayName,
|
||||
}: {
|
||||
connectionName: string;
|
||||
onRefresh: () => void;
|
||||
provider: string;
|
||||
providerDisplayName: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [working, setWorking] = useState(false);
|
||||
|
||||
async function remove() {
|
||||
setWorking(true);
|
||||
try {
|
||||
await unsetGlobalConnection(provider);
|
||||
setOpen(false);
|
||||
onRefresh();
|
||||
} finally {
|
||||
setWorking(false);
|
||||
@@ -199,8 +310,29 @@ function RemoveGlobalConnectionButton({ onRefresh, provider }: { onRefresh: () =
|
||||
}
|
||||
|
||||
return (
|
||||
<Button disabled={working} onClick={() => void remove()} size="sm" type="button" variant="outline">
|
||||
Remove
|
||||
</Button>
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)} size="sm" type="button" variant="outline">
|
||||
Remove
|
||||
</Button>
|
||||
<Dialog onOpenChange={setOpen} open={open}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove global connection</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will remove the global fallback for {providerDisplayName} ({connectionName}).
|
||||
Agents without their own connection will no longer have access.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setOpen(false)} type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={working} onClick={() => void remove()} type="button" variant="destructive">
|
||||
Remove
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
Reference in New Issue
Block a user