feat: add CLI Auth dashboard app (#1739)

## Summary

New "CLI Auth" app registered in `apps-config.ts` (alpha, parent:
authentication) and `apps-frontend.tsx` (TerminalWindowIcon, `/cli-auth`
route).

**Backend** — `GET /internal/cli-auth`:
- Queries `CliAuthAttempt` (last 50) with computed status from
`usedAt`/`refreshToken`/`expiresAt`
- Joins claimed `refreshToken` values against `ProjectUserRefreshToken`
to find active CLI sessions + user info via `ProjectUser`
- Returns `{ summary, recent_attempts, active_cli_users }`

**Dashboard** — `/cli-auth/page-client.tsx`:
- Fetches via `hexclaveAppInternalsSymbol` →
`sendRequest("/internal/cli-auth", {}, "admin")`
- Renders KPI cards (total/completed/expired/active), active sessions
list with last-active time, and recent attempts with status badges
- Expired sessions collapsed by default under `<details>`

Link to Devin session:
https://app.devin.ai/sessions/0868d1452a024b9da36b9d6a45044ff3
Requested by: @N2D4

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Adds an alpha CLI Auth dashboard to track CLI login attempts and active
refresh tokens, powered by a hidden admin-only endpoint.

- **New Features**
- Registers `cli-auth` under Authentication; adds `/cli-auth` route with
TerminalWindowIcon and docs link.
- Backend `GET /internal/cli-auth`: summary stats (incl. used_attempts),
last 50 attempts with computed status, and active CLI users by joining
CLI-issued refresh tokens (bounded).
- Dashboard fetches via admin request and shows KPIs, active sessions
(last-active/expiry), and recent attempts; expired sessions are
collapsed.

- **Bug Fixes**
- Use per-project admin app (`useAdminApp`) to avoid
ADMIN_AUTHENTICATION_REQUIRED and ensure correct tenancy scoping.
- Resolve primary emails for active sessions via `ContactChannel` join
to prevent 500s.
- Fix badges by using `DesignBadge` label prop and set expired to red;
add default cases in status switches.
- Bound token lookup to last 200 CLI-issued tokens and use a separate
COUNT(*) for accurate `active_tokens`.
  - Align loading skeleton grid with the KPI layout.
  - Docs: add `cli-auth` icon.

<sup>Written for commit 292859e921.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/hexclave/hexclave/pull/1739?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added a **CLI Auth** dashboard page with metrics for active sessions,
an expandable expired session list, and recent login attempts.
* Added a hidden internal analytics endpoint powering the dashboard
(summary, recent attempts, and active users).
* Registered **CLI Auth** in the app catalog/navigation (alpha) and
added its icon to the docs UI.
* **Bug Fixes**
* Improved request/response validation, loading/error states, and
avoided state updates after unmount.
* **Documentation**
* Updated docs indexing settings and added a redirect for a related
guide.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Konsti Wohlwend 2026-07-08 12:34:34 -07:00 committed by GitHub
parent a14b595df3
commit bf93740c7e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 611 additions and 1 deletions

View File

@ -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<CliAuthAttemptRow[]>(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<ActiveCliTokenRow[]>(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<ProjectUserRow[]>(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,
},
};
},
});

View File

@ -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<Response>,
};
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 (
<AppEnabledGuard appId="cli-auth">
<PageLayout title="CLI Auth" description="Monitor CLI authentication sessions and active tokens">
<CliAuthContent />
</PageLayout>
</AppEnabledGuard>
);
}
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<LoadState>({ 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 (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
{Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />)}
</div>
<Skeleton className="h-64 w-full rounded-xl" />
<Skeleton className="h-64 w-full rounded-xl" />
</div>
);
}
if (state.status === "error") {
return (
<Card>
<CardContent className="py-10 text-center">
<Typography variant="secondary">Could not load CLI auth data. Please try again.</Typography>
</CardContent>
</Card>
);
}
return <CliAuthDashboard data={state.data} />;
}
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 (
<div className="flex flex-col gap-4">
<DesignAlert
variant="info"
title="About CLI Auth"
description={<>
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 */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<KpiCard label="Total" value={summary.total_attempts} icon={<TerminalWindowIcon className="h-4 w-4" />} />
<KpiCard label="Used" value={summary.used_attempts} icon={<CheckCircleIcon className="h-4 w-4 text-emerald-500" />} />
<KpiCard label="Ready" value={summary.completed_attempts} icon={<CheckCircleIcon className="h-4 w-4 text-blue-500" />} />
<KpiCard label="Expired" value={summary.expired_attempts} icon={<XCircleIcon className="h-4 w-4 text-red-500" />} />
<KpiCard label="Active tokens" value={summary.active_tokens} icon={<UserIcon className="h-4 w-4 text-blue-500" />} />
</div>
{/* Active CLI Users */}
<DesignCard
title="Active CLI Sessions"
subtitle={`${activeUsers.length} user${activeUsers.length === 1 ? "" : "s"} with active CLI refresh tokens`}
icon={UserIcon}
glassmorphic
>
{activeUsers.length === 0 ? (
<div className="py-6 text-center">
<Typography variant="secondary" className="text-xs">No active CLI sessions.</Typography>
</div>
) : (
<div className="divide-y divide-border/40">
{activeUsers.map((user) => (
<div key={`${user.user_id}-${user.token_created_at}`} className="flex items-center justify-between gap-3 py-2.5">
<div className="flex flex-col gap-0.5 min-w-0">
<Typography className="text-sm font-medium truncate">
{user.display_name ?? user.primary_email ?? user.user_id}
</Typography>
{user.primary_email && user.display_name && (
<Typography variant="secondary" className="text-xs truncate">{user.primary_email}</Typography>
)}
</div>
<div className="flex items-center gap-3 shrink-0">
<div className="flex flex-col items-end gap-0.5">
<Typography variant="secondary" className="text-[11px]">
Active {formatRelativeTime(user.last_active_at)}
</Typography>
<Typography variant="secondary" className="text-[11px]">
{user.expires_at == null ? "No expiry" : `Expires ${new Date(user.expires_at).toLocaleDateString()}`}
</Typography>
</div>
<DesignBadge label="Active" color="green" size="sm" />
</div>
</div>
))}
</div>
)}
{expiredUsers.length > 0 && (
<details className="mt-3">
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground transition-colors hover:transition-none">
{expiredUsers.length} expired session{expiredUsers.length === 1 ? "" : "s"}
</summary>
<div className="mt-2 divide-y divide-border/40">
{expiredUsers.map((user) => (
<div key={`${user.user_id}-${user.token_created_at}`} className="flex items-center justify-between gap-3 py-2.5 opacity-60">
<div className="flex flex-col gap-0.5 min-w-0">
<Typography className="text-sm truncate">
{user.display_name ?? user.primary_email ?? user.user_id}
</Typography>
</div>
<div className="flex items-center gap-3 shrink-0">
<Typography variant="secondary" className="text-[11px]">
Expired {user.expires_at != null ? formatRelativeTime(user.expires_at) : ""}
</Typography>
<DesignBadge label="Expired" color="red" size="sm" />
</div>
</div>
))}
</div>
</details>
)}
</DesignCard>
{/* Recent Attempts */}
<DesignCard
title="Recent Login Attempts"
subtitle="Last 50 CLI authentication attempts"
icon={ClockIcon}
glassmorphic
>
{recent_attempts.length === 0 ? (
<div className="py-6 text-center">
<Typography variant="secondary" className="text-xs">No CLI auth attempts yet.</Typography>
</div>
) : (
<div className="divide-y divide-border/40">
{recent_attempts.map((attempt) => (
<div key={attempt.id} className="flex items-center justify-between gap-3 py-2.5">
<div className="flex items-center gap-2 min-w-0">
<AttemptStatusIcon status={attempt.status} />
<div className="flex flex-col gap-0.5 min-w-0">
<Typography className="text-xs font-mono truncate">{attempt.id.slice(0, 8)}</Typography>
<Typography variant="secondary" className="text-[11px]">
{formatRelativeTime(attempt.created_at)}
</Typography>
</div>
</div>
<AttemptStatusBadge status={attempt.status} />
</div>
))}
</div>
)}
</DesignCard>
</div>
);
}
function KpiCard({ label, value, icon }: { label: string, value: number, icon: React.ReactNode }) {
return (
<Card>
<CardContent className="flex flex-col gap-1 py-4">
<div className="flex items-center gap-1.5">
{icon}
<Typography variant="secondary" className="truncate text-[11px] uppercase tracking-wide">{label}</Typography>
</div>
<span className="text-2xl font-semibold tabular-nums text-foreground">{value}</span>
</CardContent>
</Card>
);
}
function AttemptStatusIcon({ status }: { status: CliAuthAttempt["status"] }) {
switch (status) {
case "used": {
return <CheckCircleIcon className="h-4 w-4 shrink-0 text-emerald-500" />;
}
case "completed": {
return <CheckCircleIcon className="h-4 w-4 shrink-0 text-blue-500" />;
}
case "expired": {
return <XCircleIcon className="h-4 w-4 shrink-0 text-red-500" />;
}
case "pending": {
return <WarningCircleIcon className="h-4 w-4 shrink-0 text-amber-500" />;
}
default: {
return <WarningCircleIcon className="h-4 w-4 shrink-0 text-gray-400" />;
}
}
}
function AttemptStatusBadge({ status }: { status: CliAuthAttempt["status"] }) {
switch (status) {
case "used": {
return <DesignBadge label="Used" color="green" size="sm" />;
}
case "completed": {
return <DesignBadge label="Ready" color="blue" size="sm" />;
}
case "expired": {
return <DesignBadge label="Expired" color="red" size="sm" />;
}
case "pending": {
return <DesignBadge label="Pending" color="orange" size="sm" />;
}
default: {
return <DesignBadge label={status} color="orange" size="sm" />;
}
}
}

View File

@ -0,0 +1,11 @@
import PageClient from "./page-client";
export const metadata = {
title: "CLI Auth",
};
export default function Page() {
return (
<PageClient />
);
}

View File

@ -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: (
<>
<p>CLI Auth shows real-time insight into how CLI authentication is used in your project.</p>
<p>Monitor recent login attempts, see which users have active CLI refresh tokens, and track session health at a glance.</p>
</>
),
},
} as const satisfies Record<AppId, AppFrontend>;
function createSvgIcon(ChildrenComponent: () => React.ReactNode): (props: any) => React.ReactNode {

View File

@ -45,6 +45,7 @@ const APP_ICONS: Record<AppId, React.FunctionComponent<React.SVGProps<SVGSVGElem
analytics: BarChart3,
clickmaps: MousePointerClick,
"session-replays": PlayCircle,
"cli-auth": Code,
};
function createSvgIcon(ChildrenComponent: () => React.ReactNode): (props: React.SVGProps<SVGSVGElement>) => React.ReactNode {

View File

@ -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<string, App>;
export function getParentAppId(appId: AppId): AppId | null {