diff --git a/apps/backend/src/app/api/latest/internal/cli-auth/route.tsx b/apps/backend/src/app/api/latest/internal/cli-auth/route.tsx new file mode 100644 index 000000000..6cab88c15 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/cli-auth/route.tsx @@ -0,0 +1,239 @@ +import { Prisma } from "@/generated/prisma/client"; +import { getPrismaClientForTenancy, getPrismaSchemaForTenancy, globalPrismaClient, sqlQuoteIdent } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, adminAuthTypeSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields"; + +type CliAuthAttemptRow = { + id: string, + pollingCode: string, + loginCode: string, + refreshToken: string | null, + expiresAt: Date, + usedAt: Date | null, + createdAt: Date, +}; + +type ActiveCliTokenRow = { + id: string, + projectUserId: string, + createdAt: Date, + lastActiveAt: Date, + expiresAt: Date | null, +}; + +type ProjectUserRow = { + projectUserId: string, + displayName: string | null, + primaryEmail: string | null, +}; + +export const GET = createSmartRouteHandler({ + metadata: { + hidden: true, + }, + request: yupObject({ + auth: yupObject({ + type: adminAuthTypeSchema.defined(), + tenancy: adaptSchema.defined(), + }), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + summary: yupObject({ + total_attempts: yupNumber().integer().defined(), + completed_attempts: yupNumber().integer().defined(), + used_attempts: yupNumber().integer().defined(), + expired_attempts: yupNumber().integer().defined(), + pending_attempts: yupNumber().integer().defined(), + active_tokens: yupNumber().integer().defined(), + }).defined(), + recent_attempts: yupArray(yupObject({ + id: yupString().defined(), + status: yupString().oneOf(["pending", "completed", "expired", "used"]).defined(), + created_at: yupString().defined(), + expires_at: yupString().defined(), + used_at: yupString().nullable().defined(), + }).defined()).defined(), + active_cli_users: yupArray(yupObject({ + user_id: yupString().defined(), + display_name: yupString().nullable().defined(), + primary_email: yupString().nullable().defined(), + token_created_at: yupString().defined(), + last_active_at: yupString().defined(), + expires_at: yupString().nullable().defined(), + is_expired: yupBoolean().defined(), + }).defined()).defined(), + }).defined(), + }), + handler: async (req) => { + const tenancy = req.auth.tenancy; + const prisma = await getPrismaClientForTenancy(tenancy); + const schema = await getPrismaSchemaForTenancy(tenancy); + const now = new Date(); + + // Fetch recent CLI auth attempts (last 50) + const recentAttempts = await prisma.$replica().$queryRaw(Prisma.sql` + SELECT + "id", + "pollingCode", + "loginCode", + "refreshToken", + "expiresAt", + "usedAt", + "createdAt" + FROM ${sqlQuoteIdent(schema)}."CliAuthAttempt" + WHERE "tenancyId" = ${tenancy.id}::UUID + ORDER BY "createdAt" DESC + LIMIT 50 + `); + + // Compute summary stats + let used = 0; + let completed = 0; + let expired = 0; + let pending = 0; + for (const attempt of recentAttempts) { + if (attempt.usedAt != null) { + used++; + } else if (attempt.expiresAt < now) { + expired++; + } else if (attempt.refreshToken != null) { + completed++; + } else { + pending++; + } + } + + // Format recent attempts with status + const formattedAttempts = recentAttempts.map((attempt) => { + let status: "pending" | "completed" | "expired" | "used"; + if (attempt.usedAt != null) { + status = "used"; + } else if (attempt.refreshToken != null && attempt.expiresAt >= now) { + status = "completed"; + } else if (attempt.expiresAt < now) { + status = "expired"; + } else { + status = "pending"; + } + return { + id: attempt.id, + status, + created_at: attempt.createdAt.toISOString(), + expires_at: attempt.expiresAt.toISOString(), + used_at: attempt.usedAt?.toISOString() ?? null, + }; + }); + + // Find active CLI tokens: tokens that were created via CLI auth (by looking + // at CliAuthAttempt rows that have a non-null refreshToken and usedAt). + // We join with the global ProjectUserRefreshToken table to find active sessions. + // Bounded to most recent 200 to avoid scanning entire history for high-usage tenants. + const cliRefreshTokens = await prisma.$replica().$queryRaw<{ refreshToken: string }[]>(Prisma.sql` + SELECT "refreshToken" + FROM ${sqlQuoteIdent(schema)}."CliAuthAttempt" + WHERE "tenancyId" = ${tenancy.id}::UUID + AND "refreshToken" IS NOT NULL + AND "usedAt" IS NOT NULL + ORDER BY "createdAt" DESC + LIMIT 200 + `); + + let activeCliUsers: Array<{ + user_id: string, + display_name: string | null, + primary_email: string | null, + token_created_at: string, + last_active_at: string, + expires_at: string | null, + is_expired: boolean, + }> = []; + + let activeTokenCount = 0; + + if (cliRefreshTokens.length > 0) { + const tokenValues = cliRefreshTokens.map((r) => r.refreshToken); + + // Get accurate total count of active (non-expired) tokens separately from the display list + const countResult = await globalPrismaClient.$replica().$queryRaw<{ count: bigint }[]>(Prisma.sql` + SELECT COUNT(*) as count + FROM "ProjectUserRefreshToken" + WHERE "tenancyId" = ${tenancy.id}::UUID + AND "refreshToken" = ANY(${tokenValues}) + AND ("expiresAt" IS NULL OR "expiresAt" >= ${now}) + `); + activeTokenCount = Number(countResult[0]?.count ?? 0n); + + // Look up which tokens are still active in the global refresh token table (limited for display) + const activeTokens = await globalPrismaClient.$replica().$queryRaw(Prisma.sql` + SELECT + "id", + "projectUserId", + "createdAt", + "lastActiveAt", + "expiresAt" + FROM "ProjectUserRefreshToken" + WHERE "tenancyId" = ${tenancy.id}::UUID + AND "refreshToken" = ANY(${tokenValues}) + ORDER BY "lastActiveAt" DESC + LIMIT 50 + `); + + if (activeTokens.length > 0) { + // Fetch user info for the active tokens + const userIds = [...new Set(activeTokens.map((t) => t.projectUserId))]; + // ProjectUser has no email column; the primary email lives in ContactChannel + // (type = EMAIL, isPrimary = TRUE). isPrimary/type are Postgres enums, so we + // cast to text for a schema-agnostic comparison. + const userRows = await prisma.$replica().$queryRaw(Prisma.sql` + SELECT + pu."projectUserId", + pu."displayName", + cc."value" AS "primaryEmail" + FROM ${sqlQuoteIdent(schema)}."ProjectUser" pu + LEFT JOIN ${sqlQuoteIdent(schema)}."ContactChannel" cc + ON cc."tenancyId" = pu."tenancyId" + AND cc."projectUserId" = pu."projectUserId" + AND cc."type"::text = 'EMAIL' + AND cc."isPrimary"::text = 'TRUE' + WHERE pu."tenancyId" = ${tenancy.id}::UUID + AND pu."projectUserId" = ANY(${userIds}::UUID[]) + `); + const userMap = new Map(userRows.map((u) => [u.projectUserId, u])); + + activeCliUsers = activeTokens.map((token) => { + const user = userMap.get(token.projectUserId); + const isExpired = token.expiresAt != null && token.expiresAt < now; + return { + user_id: token.projectUserId, + display_name: user?.displayName ?? null, + primary_email: user?.primaryEmail ?? null, + token_created_at: token.createdAt.toISOString(), + last_active_at: token.lastActiveAt.toISOString(), + expires_at: token.expiresAt?.toISOString() ?? null, + is_expired: isExpired, + }; + }); + } + } + + return { + statusCode: 200 as const, + bodyType: "json" as const, + body: { + summary: { + total_attempts: recentAttempts.length, + completed_attempts: completed, + used_attempts: used, + expired_attempts: expired, + pending_attempts: pending, + active_tokens: activeTokenCount, + }, + recent_attempts: formattedAttempts, + active_cli_users: activeCliUsers, + }, + }; + }, +}); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/cli-auth/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/cli-auth/page-client.tsx new file mode 100644 index 000000000..4357a0488 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/cli-auth/page-client.tsx @@ -0,0 +1,337 @@ +"use client"; + +import { + DesignAlert, + DesignBadge, + DesignCard, +} from "@/components/design-components"; +import { Skeleton, Typography } from "@/components/ui"; +import { Card, CardContent } from "@/components/ui/card"; +import { hexclaveAppInternalsSymbol } from "@/lib/hexclave-app-internals"; +import { + CheckCircleIcon, + ClockIcon, + TerminalWindowIcon, + UserIcon, + WarningCircleIcon, + XCircleIcon, +} from "@phosphor-icons/react"; +import { captureError } from "@hexclave/shared/dist/utils/errors"; +import { runAsynchronously } from "@hexclave/shared/dist/utils/promises"; +import { useEffect, useMemo, useState } from "react"; +import { AppEnabledGuard } from "../app-enabled-guard"; +import { PageLayout } from "../page-layout"; +import { useAdminApp } from "../use-admin-app"; + +type CliAuthSummary = { + total_attempts: number, + completed_attempts: number, + used_attempts: number, + expired_attempts: number, + pending_attempts: number, + active_tokens: number, +}; + +type CliAuthAttempt = { + id: string, + status: "pending" | "completed" | "expired" | "used", + created_at: string, + expires_at: string, + used_at: string | null, +}; + +type CliAuthUser = { + user_id: string, + display_name: string | null, + primary_email: string | null, + token_created_at: string, + last_active_at: string, + expires_at: string | null, + is_expired: boolean, +}; + +type CliAuthData = { + summary: CliAuthSummary, + recent_attempts: CliAuthAttempt[], + active_cli_users: CliAuthUser[], +}; + +type LoadState = + | { status: "loading" } + | { status: "error" } + | { status: "ok", data: CliAuthData }; + +type HexclaveAppInternals = { + sendRequest: (path: string, requestOptions: RequestInit, requestType?: "client" | "server" | "admin") => Promise, +}; + +function getStackAppInternals(appValue: unknown): HexclaveAppInternals { + if (appValue == null || typeof appValue !== "object") { + throw new Error("The Stack app instance is unavailable."); + } + const internals = Reflect.get(appValue, hexclaveAppInternalsSymbol); + if ( + internals == null || + typeof internals !== "object" || + !("sendRequest" in internals) || + typeof (internals as HexclaveAppInternals).sendRequest !== "function" + ) { + throw new Error("The Stack client app cannot send internal requests."); + } + return internals as HexclaveAppInternals; +} + +function formatRelativeTime(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime(); + const seconds = Math.floor(diff / 1000); + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +export default function PageClient() { + return ( + + + + + + ); +} + +function CliAuthContent() { + // Use the admin app for the currently-viewed project (not useStackApp(), which + // returns the dashboard's own internal-project client app): this both carries + // admin credentials and scopes the /internal/cli-auth request to this project's + // tenancy. Mirrors the external-db-sync page pattern. + const app = useAdminApp(); + const appInternals = useMemo(() => getStackAppInternals(app), [app]); + const [state, setState] = useState({ status: "loading" }); + + useEffect(() => { + let cancelled = false; + runAsynchronously(async () => { + setState({ status: "loading" }); + try { + const response = await appInternals.sendRequest("/internal/cli-auth", { method: "GET" }, "admin"); + if (!response.ok) { + throw new Error(`Failed to load CLI auth data: ${response.status}`); + } + const body = await response.json() as CliAuthData; + if (!cancelled) setState({ status: "ok", data: body }); + } catch (e) { + if (cancelled) return; + setState({ status: "error" }); + captureError("cli-auth-load", e); + } + }); + return () => { + cancelled = true; + }; + }, [appInternals]); + + if (state.status === "loading") { + return ( +
+
+ {Array.from({ length: 5 }).map((_, i) => )} +
+ + +
+ ); + } + + if (state.status === "error") { + return ( + + + Could not load CLI auth data. Please try again. + + + ); + } + + return ; +} + +function CliAuthDashboard({ data }: { data: CliAuthData }) { + const { summary, recent_attempts, active_cli_users } = data; + const activeUsers = active_cli_users.filter((u) => !u.is_expired); + const expiredUsers = active_cli_users.filter((u) => u.is_expired); + + return ( +
+ + CLI Auth allows users to authenticate from command-line tools using a browser-based login flow. + The CLI initiates a session, the user confirms in a browser, and a refresh token is issued to the CLI. + } + /> + + {/* Summary KPIs */} +
+ } /> + } /> + } /> + } /> + } /> +
+ + {/* Active CLI Users */} + + {activeUsers.length === 0 ? ( +
+ No active CLI sessions. +
+ ) : ( +
+ {activeUsers.map((user) => ( +
+
+ + {user.display_name ?? user.primary_email ?? user.user_id} + + {user.primary_email && user.display_name && ( + {user.primary_email} + )} +
+
+
+ + Active {formatRelativeTime(user.last_active_at)} + + + {user.expires_at == null ? "No expiry" : `Expires ${new Date(user.expires_at).toLocaleDateString()}`} + +
+ +
+
+ ))} +
+ )} + {expiredUsers.length > 0 && ( +
+ + {expiredUsers.length} expired session{expiredUsers.length === 1 ? "" : "s"} + +
+ {expiredUsers.map((user) => ( +
+
+ + {user.display_name ?? user.primary_email ?? user.user_id} + +
+
+ + Expired {user.expires_at != null ? formatRelativeTime(user.expires_at) : ""} + + +
+
+ ))} +
+
+ )} +
+ + {/* Recent Attempts */} + + {recent_attempts.length === 0 ? ( +
+ No CLI auth attempts yet. +
+ ) : ( +
+ {recent_attempts.map((attempt) => ( +
+
+ +
+ {attempt.id.slice(0, 8)} + + {formatRelativeTime(attempt.created_at)} + +
+
+ +
+ ))} +
+ )} +
+
+ ); +} + +function KpiCard({ label, value, icon }: { label: string, value: number, icon: React.ReactNode }) { + return ( + + +
+ {icon} + {label} +
+ {value} +
+
+ ); +} + +function AttemptStatusIcon({ status }: { status: CliAuthAttempt["status"] }) { + switch (status) { + case "used": { + return ; + } + case "completed": { + return ; + } + case "expired": { + return ; + } + case "pending": { + return ; + } + default: { + return ; + } + } +} + +function AttemptStatusBadge({ status }: { status: CliAuthAttempt["status"] }) { + switch (status) { + case "used": { + return ; + } + case "completed": { + return ; + } + case "expired": { + return ; + } + case "pending": { + return ; + } + default: { + return ; + } + } +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/cli-auth/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/cli-auth/page.tsx new file mode 100644 index 000000000..2e8bced34 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/cli-auth/page.tsx @@ -0,0 +1,11 @@ +import PageClient from "./page-client"; + +export const metadata = { + title: "CLI Auth", +}; + +export default function Page() { + return ( + + ); +} diff --git a/apps/dashboard/src/lib/apps-frontend.tsx b/apps/dashboard/src/lib/apps-frontend.tsx index 7f592e49d..ad47392af 100644 --- a/apps/dashboard/src/lib/apps-frontend.tsx +++ b/apps/dashboard/src/lib/apps-frontend.tsx @@ -1,6 +1,6 @@ import type { JSX } from "react"; import { Link } from "@/components/link"; -import { ChartLineIcon, ChatCircleDotsIcon, ClipboardTextIcon, CodeIcon, CreditCardIcon, CursorClickIcon, EnvelopeSimpleIcon, FingerprintSimpleIcon, KeyIcon, MailboxIcon, MonitorPlayIcon, RocketIcon, ShieldCheckIcon, SparkleIcon, TelevisionSimpleIcon, TriangleIcon, UserGearIcon, UsersIcon, VaultIcon, WebhooksLogoIcon } from "@phosphor-icons/react"; +import { ChartLineIcon, ChatCircleDotsIcon, ClipboardTextIcon, CodeIcon, CreditCardIcon, CursorClickIcon, EnvelopeSimpleIcon, FingerprintSimpleIcon, KeyIcon, MailboxIcon, MonitorPlayIcon, RocketIcon, ShieldCheckIcon, SparkleIcon, TelevisionSimpleIcon, TerminalWindowIcon, TriangleIcon, UserGearIcon, UsersIcon, VaultIcon, WebhooksLogoIcon } from "@phosphor-icons/react"; import { StackAdminApp } from "@hexclave/next"; import type { AppId } from "@hexclave/shared/dist/apps/apps-config"; import { getRelativePart, isChildUrl } from "@hexclave/shared/dist/utils/urls"; @@ -422,6 +422,21 @@ export const ALL_APPS_FRONTEND = { ), }, + "cli-auth": { + icon: TerminalWindowIcon, + href: "cli-auth", + documentationHref: "https://docs.hexclave.com/guides/others/cli-authentication", + navigationItems: [ + { displayName: "CLI Auth", href: "." }, + ], + screenshots: [], + storeDescription: ( + <> +

CLI Auth shows real-time insight into how CLI authentication is used in your project.

+

Monitor recent login attempts, see which users have active CLI refresh tokens, and track session health at a glance.

+ + ), + }, } as const satisfies Record; function createSvgIcon(ChildrenComponent: () => React.ReactNode): (props: any) => React.ReactNode { diff --git a/docs/src/components/mdx/app-card.tsx b/docs/src/components/mdx/app-card.tsx index 93c3c4419..6ba7fab76 100644 --- a/docs/src/components/mdx/app-card.tsx +++ b/docs/src/components/mdx/app-card.tsx @@ -45,6 +45,7 @@ const APP_ICONS: Record React.ReactNode): (props: React.SVGProps) => React.ReactNode { diff --git a/packages/shared/src/apps/apps-config.ts b/packages/shared/src/apps/apps-config.ts index a5b85e513..f427ea205 100644 --- a/packages/shared/src/apps/apps-config.ts +++ b/packages/shared/src/apps/apps-config.ts @@ -186,6 +186,13 @@ export const ALL_APPS = { stage: "stable", parentAppId: "analytics", }, + "cli-auth": { + displayName: "CLI Auth", + subtitle: "Monitor CLI authentication sessions and active tokens", + tags: ["auth", "developers"], + stage: "alpha", + parentAppId: "authentication", + }, } as const satisfies Record; export function getParentAppId(appId: AppId): AppId | null {