mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
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:
parent
a14b595df3
commit
bf93740c7e
239
apps/backend/src/app/api/latest/internal/cli-auth/route.tsx
Normal file
239
apps/backend/src/app/api/latest/internal/cli-auth/route.tsx
Normal 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,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -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" />;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
import PageClient from "./page-client";
|
||||
|
||||
export const metadata = {
|
||||
title: "CLI Auth",
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<PageClient />
|
||||
);
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user