diff --git a/README.md b/README.md index 809ba4f..f9faa6d 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ GitHub Copilot is transforming how teams write code — but without visibility i | Page | Route | Description | |---|---|---| | **AI Analyst** | `/ai-analyst` | AI-generated executive, cost, adoption, delivery, ROI, and team analysis grounded in synced dashboard data | +| **My Usage** | `/my-usage` | A signed-in developer's **own** AI Credit consumption, broken down by model, with a month selector and trend (identity mode only) | | **Copilot Usage** | `/metrics` | Daily/weekly active users, code completions, chat mode breakdown, model & language analytics | | **Code Generation** | `/code-generation` | LOC added/deleted by user vs agent, breakdowns by feature, model, and language | | **PR & Autofix** | `/pull-requests` | AI-assisted PR creation, Copilot code review suggestions, autofix analytics, and merge metrics | @@ -187,7 +188,7 @@ prompts it uses, and how analysis is generated, cached, and secured. - **Node.js** 24+ and **pnpm** (`corepack enable`) - **PostgreSQL** 18+ (local or cloud) - **GitHub Enterprise Cloud** with Copilot enabled -- **GitHub Personal Access Token** with `manage_billing:copilot`, `read:enterprise`, `read:org` scopes +- **GitHub Personal Access Token** with `manage_billing:copilot`, `manage_billing:enterprise`, `read:enterprise`, `read:org` scopes ## 🚀 Quick Start @@ -220,6 +221,10 @@ Open [http://localhost:3000](http://localhost:3000) and navigate to **Settings** | `DATABASE_URL` | Yes | PostgreSQL connection string (e.g. `postgresql://user:pass@host:5432/db`) | | `ADMIN_PASSWORD` | No | Password for Settings page access. If unset, settings are open to all. Only leave this unset for local development on a trusted machine | | `DASHBOARD_PASSWORD` | No | Password gate for all dashboard pages. If unset, dashboards are open to all. Only leave this unset for local development or intentionally public dashboards | +| `GITHUB_OAUTH_CLIENT_ID` | No | GitHub OAuth App client id. Enables **identity mode** when set together with the two vars below | +| `GITHUB_OAUTH_CLIENT_SECRET` | No | GitHub OAuth App client secret (pair with `GITHUB_OAUTH_CLIENT_ID`) | +| `SESSION_SECRET` | No | Long random string used to HMAC-sign identity session cookies. Required for identity mode | +| `ADMIN_LOGINS` | No | Comma-separated GitHub logins granted the **admin** role in identity mode (case-insensitive). Everyone else signs in as a **developer** | | `NEXT_PUBLIC_BUILD_ID` | No | Git commit SHA shown in sidebar footer (auto-set in Docker) | | `NEXT_PUBLIC_BUILD_TIME` | No | Build timestamp shown in sidebar footer (auto-set in Docker) | @@ -227,6 +232,36 @@ Open [http://localhost:3000](http://localhost:3000) and navigate to **Settings** > For any non-local deployment, set `ADMIN_PASSWORD` to protect the **Settings** page. The Settings UI can configure the GitHub token, Enterprise slug, and sync interval. If dashboard pages should not be publicly accessible, also set `DASHBOARD_PASSWORD`. Leaving these unset is only recommended for local development on a trusted machine. The **GitHub token**, **Enterprise slug**, and **sync interval** are configured via the Settings UI and stored in the database. +A complete, annotated template of every variable lives in [`app/.env.example`](app/.env.example) — copy it to `app/.env` and fill in the values. + +## 🔐 Authentication & Access Modes + +The dashboard supports three **additive** authentication modes. They are selected purely by which environment variables are set, so you can adopt them incrementally without code changes: + +| Mode | How it's enabled | Who can access what | +|---|---|---| +| **Open** | No auth vars set | Everyone sees every page. Use only for local development on a trusted machine | +| **Shared-password** | `DASHBOARD_PASSWORD` and/or `ADMIN_PASSWORD` set | A single password gates the dashboard (`DASHBOARD_PASSWORD`) and/or the Settings pages (`ADMIN_PASSWORD`). There is no per-user identity | +| **Identity (GitHub OAuth)** | `GITHUB_OAUTH_CLIENT_ID` **+** `GITHUB_OAUTH_CLIENT_SECRET` **+** `SESSION_SECRET` all set | Each user signs in with GitHub and receives a signed, stateless session cookie carrying their **login** and **role** (`admin` or `developer`, resolved from `ADMIN_LOGINS`) | + +In **identity mode**: + +- **Developers** see only the **My Usage** page (`/my-usage`) — their own AI Credit consumption, broken down by model, scoped server-side to their signed-in login. Org-wide and cross-user reports (`/ai-credits`, `/seats`, `/users`, etc.) and the Settings pages are hidden from their navigation **and** enforced server-side: a crafted `?user=` can never read another user's rows. +- **Admins** (logins listed in `ADMIN_LOGINS`) see everything, exactly as before. + +### GitHub OAuth App setup + +1. Create a GitHub **OAuth App** (Settings → Developer settings → OAuth Apps → *New OAuth App*). +2. Set the **Authorization callback URL** to `https:///api/auth/github/callback` (use `http://localhost:3000/api/auth/github/callback` for local development). +3. Copy the generated **Client ID** and a **Client secret** into `GITHUB_OAUTH_CLIENT_ID` and `GITHUB_OAUTH_CLIENT_SECRET`. +4. Set `SESSION_SECRET` to a long random string and list your admin GitHub logins in `ADMIN_LOGINS`. + +> The OAuth App only reads each user's GitHub identity (login + id) to establish a session — developers never receive billing or enterprise scopes. + +### Billing data source & ingestion + +AI Credit data is sourced from the validated **async `ai_credit` report export** — `POST /enterprises/{enterprise}/settings/billing/reports` — which returns a download URL once GitHub finishes generating the report. This export requires an **admin enterprise PAT** (classic PAT with `manage_billing:enterprise` read); without that scope GitHub returns `404`. Ingestion is decoupled into a **scheduled Azure Container Apps cron Job** that runs the export, downloads the report, and upserts per-user rows into the database. The dashboard (including **My Usage**) only reads those pre-ingested rows — developers never receive billing scopes and only ever see rows scoped to themselves. + ## 🚢 Deployment **Azure is the recommended — and simplest — way to get the dashboard up and running.** A single `azd up` provisions every resource, wires up secrets and managed identity, and deploys the app. If you'd rather run it yourself, a self-hosted option is fully supported too. @@ -266,7 +301,7 @@ Run the dashboard anywhere you can host a container — your own VM, Kubernetes, - **PostgreSQL** 18+ (managed or self-run), reachable from the app - **Docker** — the app ships a production `Dockerfile` with Next.js standalone output (or use **Node.js** 24+ and **pnpm** for local development) - **TLS / reverse proxy** (e.g. nginx, Caddy) in front of the app for any shared deployment -- **GitHub Personal Access Token** with `manage_billing:copilot`, `read:enterprise`, `read:org` (added later via the Settings UI) +- **GitHub Personal Access Token** with `manage_billing:copilot`, `manage_billing:enterprise`, `read:enterprise`, `read:org` (added later via the Settings UI) - Environment variables (see [Environment Variables](#environment-variables)): - `DATABASE_URL` — **required** - `ADMIN_PASSWORD` — **required** for any shared/non-local deployment diff --git a/app/.env.example b/app/.env.example index 8e0dc5e..d9179d2 100644 --- a/app/.env.example +++ b/app/.env.example @@ -6,3 +6,18 @@ DATABASE_URL=postgresql://user:password@localhost:5432/copilot_insights # Admin password for the Settings page (minimum 8 characters) ADMIN_PASSWORD=changeme123 + +# ── Identity mode (optional) ── +# When all of GITHUB_OAUTH_CLIENT_ID, GITHUB_OAUTH_CLIENT_SECRET and +# SESSION_SECRET are set, the dashboard enables GitHub OAuth sign-in and gives +# each session an identity (login) and role (admin/developer). Leave these +# unset to keep the existing open or shared-password behavior unchanged. + +# GitHub OAuth App client id (callback: https:///api/auth/github/callback) +GITHUB_OAUTH_CLIENT_ID= +# GitHub OAuth App client secret +GITHUB_OAUTH_CLIENT_SECRET= +# Secret used to sign identity session cookies (use a long random string) +SESSION_SECRET= +# Comma-separated GitHub logins granted the admin role (case-insensitive) +ADMIN_LOGINS= diff --git a/app/src/app/api/auth/github/callback/route.ts b/app/src/app/api/auth/github/callback/route.ts new file mode 100644 index 0000000..2819c4c --- /dev/null +++ b/app/src/app/api/auth/github/callback/route.ts @@ -0,0 +1,170 @@ +import { NextRequest, NextResponse } from "next/server"; +import { logAudit, getClientIp } from "@/lib/audit"; +import { + isIdentityModeEnabled, + resolveRole, + createIdentitySession, + sessionCookieOptions, + safeCompare, + COOKIE_NAMES, +} from "@/lib/auth"; + +const COOKIE_OAUTH_STATE = "oauth_state"; + +/** Clear the transient OAuth state cookie on the given response. */ +function clearStateCookie(response: NextResponse): void { + response.cookies.set(COOKIE_OAUTH_STATE, "", { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 0, + }); +} + +/** + * GitHub OAuth callback. Validates the CSRF `state`, exchanges the `code` for an + * access token, fetches the user's GitHub identity, resolves their role, and + * mints a signed identity session cookie. Only available in identity mode. + */ +export async function GET(request: NextRequest) { + if (!isIdentityModeEnabled()) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + const ip = getClientIp(request) ?? "unknown"; + + try { + const sp = request.nextUrl.searchParams; + const code = sp.get("code"); + const state = sp.get("state"); + const oauthError = sp.get("error"); + const expectedState = request.cookies.get(COOKIE_OAUTH_STATE)?.value; + + // Validate the CSRF state on every callback, including error returns. + if (!state || !expectedState || !safeCompare(state, expectedState)) { + logAudit({ action: "identity_login_invalid_state", category: "auth", ipAddress: ip }); + const response = NextResponse.json( + { error: "Invalid OAuth state" }, + { status: 400 }, + ); + clearStateCookie(response); + return response; + } + + // GitHub returned an error (e.g. the user denied consent) instead of a code. + if (oauthError) { + logAudit({ + action: "identity_login_denied", + category: "auth", + details: { error: oauthError }, + ipAddress: ip, + }); + const response = NextResponse.json( + { error: "GitHub authorization was denied or cancelled" }, + { status: 401 }, + ); + clearStateCookie(response); + return response; + } + + if (!code) { + logAudit({ action: "identity_login_missing_code", category: "auth", ipAddress: ip }); + const response = NextResponse.json( + { error: "Missing authorization code" }, + { status: 400 }, + ); + clearStateCookie(response); + return response; + } + + const clientId = process.env.GITHUB_OAUTH_CLIENT_ID!; + const clientSecret = process.env.GITHUB_OAUTH_CLIENT_SECRET!; + const redirectUri = `${request.nextUrl.origin}/api/auth/github/callback`; + + // Exchange the authorization code for an access token. + const tokenRes = await fetch( + "https://github.com/login/oauth/access_token", + { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + client_id: clientId, + client_secret: clientSecret, + code, + redirect_uri: redirectUri, + }), + }, + ); + + if (!tokenRes.ok) { + throw new Error(`Token exchange failed with status ${tokenRes.status}`); + } + + const tokenData = (await tokenRes.json()) as { + access_token?: string; + error?: string; + }; + + if (!tokenData.access_token) { + logAudit({ action: "identity_login_token_error", category: "auth", ipAddress: ip }); + const response = NextResponse.json( + { error: "OAuth authorization failed" }, + { status: 401 }, + ); + clearStateCookie(response); + return response; + } + + // Fetch the authenticated user's GitHub identity. + const userRes = await fetch("https://api.github.com/user", { + headers: { + Authorization: "Bearer " + tokenData.access_token, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2026-03-10", + }, + }); + + if (!userRes.ok) { + throw new Error(`User fetch failed with status ${userRes.status}`); + } + + const user = (await userRes.json()) as { login?: string; id?: number }; + + if (!user.login || typeof user.id !== "number") { + throw new Error("GitHub user response missing login or id"); + } + + const role = resolveRole(user.login); + const token = createIdentitySession({ + login: user.login, + id: user.id, + role, + }); + + logAudit({ + action: "identity_login_success", + category: "auth", + actor: user.login, + details: { role }, + ipAddress: ip, + }); + + const response = NextResponse.redirect(new URL("/", request.url)); + response.cookies.set(COOKIE_NAMES.identity, token, sessionCookieOptions()); + clearStateCookie(response); + return response; + } catch (err) { + console.error("GitHub OAuth callback error:", err); + logAudit({ action: "identity_login_error", category: "auth", ipAddress: ip }); + const response = NextResponse.json( + { error: "Authentication failed" }, + { status: 500 }, + ); + clearStateCookie(response); + return response; + } +} diff --git a/app/src/app/api/auth/github/login/route.ts b/app/src/app/api/auth/github/login/route.ts new file mode 100644 index 0000000..ab07272 --- /dev/null +++ b/app/src/app/api/auth/github/login/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; +import { randomBytes } from "crypto"; +import { isIdentityModeEnabled } from "@/lib/auth"; + +const COOKIE_OAUTH_STATE = "oauth_state"; +const STATE_MAX_AGE_S = 600; // 10 minutes + +/** + * Begin the GitHub OAuth web flow. Redirects the browser to GitHub's + * authorization screen with a CSRF `state` value stored in a short-lived cookie. + * Only available in identity mode (GitHub OAuth env vars configured). + */ +export async function GET(request: NextRequest) { + if (!isIdentityModeEnabled()) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + try { + const clientId = process.env.GITHUB_OAUTH_CLIENT_ID!; + const redirectUri = `${request.nextUrl.origin}/api/auth/github/callback`; + const state = randomBytes(16).toString("hex"); + + const authorizeUrl = new URL("https://github.com/login/oauth/authorize"); + authorizeUrl.searchParams.set("client_id", clientId); + authorizeUrl.searchParams.set("redirect_uri", redirectUri); + authorizeUrl.searchParams.set("scope", "read:user"); + authorizeUrl.searchParams.set("state", state); + + const response = NextResponse.redirect(authorizeUrl.toString()); + response.cookies.set(COOKIE_OAUTH_STATE, state, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: STATE_MAX_AGE_S, + }); + return response; + } catch (error) { + console.error("GitHub OAuth login error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/app/src/app/api/auth/session/__tests__/route.test.ts b/app/src/app/api/auth/session/__tests__/route.test.ts new file mode 100644 index 0000000..e6cb4e9 --- /dev/null +++ b/app/src/app/api/auth/session/__tests__/route.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { NextRequest } from "next/server"; +import { createIdentitySession } from "@/lib/auth"; +import { GET } from "@/app/api/auth/session/route"; + +/** + * Tests for `/api/auth/session`, the client-facing endpoint the sidebar uses to + * gate navigation by role. It must never leak secrets and must reflect the + * active auth mode + the signed-in user's role. + */ + +function buildRequest(identityToken?: string): NextRequest { + const headers: Record = {}; + if (identityToken) headers.cookie = `identity_session=${identityToken}`; + return new NextRequest("https://example.test/api/auth/session", { headers }); +} + +describe("/api/auth/session", () => { + const original = { ...process.env }; + + afterEach(() => { + process.env = { ...original }; + }); + + describe("when identity mode is disabled", () => { + beforeEach(() => { + delete process.env.GITHUB_OAUTH_CLIENT_ID; + delete process.env.GITHUB_OAUTH_CLIENT_SECRET; + delete process.env.SESSION_SECRET; + }); + + it("reports identityMode false and no session", async () => { + const res = await GET(buildRequest()); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ + identityMode: false, + authenticated: false, + login: null, + role: null, + }); + }); + }); + + describe("when identity mode is enabled", () => { + beforeEach(() => { + process.env.GITHUB_OAUTH_CLIENT_ID = "id"; + process.env.GITHUB_OAUTH_CLIENT_SECRET = "secret"; + process.env.SESSION_SECRET = "test-session-secret"; + }); + + it("returns the developer role for a signed-in developer", async () => { + const token = createIdentitySession({ login: "alice", id: 1, role: "developer" }); + const res = await GET(buildRequest(token)); + const body = await res.json(); + expect(body).toEqual({ + identityMode: true, + authenticated: true, + login: "alice", + role: "developer", + }); + }); + + it("returns the admin role for a signed-in admin", async () => { + const token = createIdentitySession({ login: "boss", id: 9, role: "admin" }); + const res = await GET(buildRequest(token)); + const body = await res.json(); + expect(body.role).toBe("admin"); + expect(body.login).toBe("boss"); + }); + + it("reports unauthenticated when no cookie is present", async () => { + const res = await GET(buildRequest()); + const body = await res.json(); + expect(body).toEqual({ + identityMode: true, + authenticated: false, + login: null, + role: null, + }); + }); + }); +}); diff --git a/app/src/app/api/auth/session/route.ts b/app/src/app/api/auth/session/route.ts new file mode 100644 index 0000000..5924ee1 --- /dev/null +++ b/app/src/app/api/auth/session/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + isIdentityModeEnabled, + getIdentitySessionFromRequest, + safeErrorMessage, +} from "@/lib/auth"; + +export const dynamic = "force-dynamic"; + +/** + * Expose the current identity session to client components so the UI can gate + * navigation by role. Only meaningful in identity mode (GitHub OAuth); in open + * and shared-password modes `identityMode` is false and the client keeps its + * existing behavior. This endpoint never returns secrets — only the signed-in + * login and resolved role. Server-side row-level scoping is enforced + * independently in each data route (see `resolveUserScope`). + */ +export async function GET(request: NextRequest) { + try { + const identityMode = isIdentityModeEnabled(); + const session = identityMode ? getIdentitySessionFromRequest(request) : null; + + return NextResponse.json({ + identityMode, + authenticated: !!session, + login: session?.login ?? null, + role: session?.role ?? null, + }); + } catch (error) { + console.error("Failed to resolve identity session:", error); + return NextResponse.json( + { error: safeErrorMessage(error, "Internal server error") }, + { status: 500 }, + ); + } +} diff --git a/app/src/app/api/metrics/ai-credits/__tests__/route.test.ts b/app/src/app/api/metrics/ai-credits/__tests__/route.test.ts new file mode 100644 index 0000000..6f11845 --- /dev/null +++ b/app/src/app/api/metrics/ai-credits/__tests__/route.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { NextRequest } from "next/server"; +import { createIdentitySession } from "@/lib/auth"; +import type { NormalizedAiCreditItem } from "@/lib/db/ai-credit-usage"; + +/** + * Server-side row-level scoping tests for `/api/metrics/ai-credits`. + * + * The DB reader is mocked to mirror the real `userScope` filtering so we can + * prove the route never returns another user's rows to a `developer`, while an + * `admin` retains cross-user access. + */ + +const SAMPLE_ITEMS: NormalizedAiCreditItem[] = [ + { + usageDate: "2026-06-01", + product: "Copilot", + sku: "copilot", + model: "gpt-4o", + costCenter: null, + orgName: "acme", + userLogin: "alice", + teamName: null, + unitType: "ai-credits", + pricePerUnit: 0.1, + grossQuantity: 10, + discountQuantity: 4, + netQuantity: 6, + grossAmount: 1, + discountAmount: 0.4, + netAmount: 0.6, + }, + { + usageDate: "2026-06-01", + product: "Copilot", + sku: "copilot", + model: "claude", + costCenter: null, + orgName: "acme", + userLogin: "bob", + teamName: null, + unitType: "ai-credits", + pricePerUnit: 0.1, + grossQuantity: 20, + discountQuantity: 0, + netQuantity: 20, + grossAmount: 2, + discountAmount: 0, + netAmount: 2, + }, +]; + +const getAiCreditItemsByMonthFromDb = vi.fn(); + +vi.mock("@/lib/db/ai-credit-usage", async () => { + const actual = await vi.importActual( + "@/lib/db/ai-credit-usage", + ); + return { + ...actual, + getAiCreditItemsByMonthFromDb: (...args: unknown[]) => + getAiCreditItemsByMonthFromDb(...args), + }; +}); + +vi.mock("@/lib/db/settings", () => ({ + getGitHubConfig: vi.fn(async () => ({ token: "t", enterpriseSlug: "acme-inc" })), +})); + +vi.mock("@/lib/github/resolve-display-names", () => ({ + resolveUserNames: vi.fn(async () => ({ + name: (login: string) => login, + label: (login: string) => login, + map: new Map(), + })), +})); + +// The org-wide consumption layer (upstream) is suppressed for forced developer +// scopes by the route's RLS guard; mock it so tests are deterministic and never +// touch a real DB connection. +vi.mock("@/lib/db/ai-credit-consumption", () => { + const empty = () => ({ + available: false, + totalCreditsUsed: 0, + activeUsers: 0, + perUser: [], + perOrg: [], + perTeam: [], + options: { users: [], orgs: [], teams: [] }, + }); + return { + getCreditConsumption: vi.fn(async () => empty()), + emptyConsumption: vi.fn(() => empty()), + }; +}); + +let GET: typeof import("@/app/api/metrics/ai-credits/route").GET; + +function buildRequest(query: string, identityToken?: string): NextRequest { + const headers: Record = {}; + if (identityToken) headers.cookie = `identity_session=${identityToken}`; + return new NextRequest(`https://example.test/api/metrics/ai-credits${query}`, { + headers, + }); +} + +describe("/api/metrics/ai-credits row-level scoping", () => { + const original = { ...process.env }; + + beforeEach(async () => { + process.env.GITHUB_OAUTH_CLIENT_ID = "id"; + process.env.GITHUB_OAUTH_CLIENT_SECRET = "secret"; + process.env.SESSION_SECRET = "test-session-secret"; + + // Seats fetch — return not-ok so the pagination loop exits immediately. + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("", { status: 404 })), + ); + + // Mirror the real DB scope filter: when userScope is set, only that user's rows. + getAiCreditItemsByMonthFromDb.mockImplementation( + async ( + _slug: string, + points: Array<{ year: number; month: number }>, + userScope?: string | null, + ) => { + const map = new Map(); + const scope = userScope ? userScope.toLowerCase() : null; + for (const p of points) { + if (p.year === 2026 && p.month === 6) { + const rows = scope + ? SAMPLE_ITEMS.filter((i) => (i.userLogin ?? "") === scope) + : SAMPLE_ITEMS; + map.set(`${p.year}-06`, rows); + } + } + return map; + }, + ); + + ({ GET } = await import("@/app/api/metrics/ai-credits/route")); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + process.env = { ...original }; + }); + + it("forces a developer to their own rows even with a crafted ?user=", async () => { + const token = createIdentitySession({ login: "alice", id: 1, role: "developer" }); + const res = await GET(buildRequest("?year=2026&month=6&user=bob", token)); + expect(res.status).toBe(200); + const body = await res.json(); + + // DB read was scoped to the session login, ignoring ?user=bob. + const [, , userScope] = getAiCreditItemsByMonthFromDb.mock.calls[0]; + expect(userScope).toBe("alice"); + + const users = body.perUserBreakdown.map((u: { user: string }) => u.user); + expect(users).toEqual(["alice"]); + expect(users).not.toContain("bob"); + // A scoped developer gets no cross-user filter options — the org-wide + // consumption layer (which feeds the user dropdown) is suppressed for + // forced scopes, so it can never surface another user. + expect(body.filters.options.users).toEqual([]); + }); + + it("scopes a developer with no ?user= to their own rows", async () => { + const token = createIdentitySession({ login: "alice", id: 1, role: "developer" }); + const res = await GET(buildRequest("?year=2026&month=6", token)); + const body = await res.json(); + const [, , userScope] = getAiCreditItemsByMonthFromDb.mock.calls[0]; + expect(userScope).toBe("alice"); + expect(body.perUserBreakdown.map((u: { user: string }) => u.user)).toEqual(["alice"]); + }); + + it("lets an admin read every user's rows (cross-user access retained)", async () => { + const token = createIdentitySession({ login: "boss", id: 9, role: "admin" }); + const res = await GET(buildRequest("?year=2026&month=6", token)); + expect(res.status).toBe(200); + const body = await res.json(); + + // Admin reads are not user-scoped at the DB layer. + const [, , userScope] = getAiCreditItemsByMonthFromDb.mock.calls[0]; + expect(userScope).toBeNull(); + + // Cross-user access retained: the admin sees every user's billing rows. + const users = body.perUserBreakdown.map((u: { user: string }) => u.user); + expect(users).toContain("alice"); + expect(users).toContain("bob"); + }); +}); diff --git a/app/src/app/api/metrics/ai-credits/route.ts b/app/src/app/api/metrics/ai-credits/route.ts index ae80758..db8ed73 100644 --- a/app/src/app/api/metrics/ai-credits/route.ts +++ b/app/src/app/api/metrics/ai-credits/route.ts @@ -3,54 +3,23 @@ import { z } from "zod"; import { getGitHubConfig } from "@/lib/db/settings"; import { resolveUserNames } from "@/lib/github/resolve-display-names"; import { getModelDisplayName } from "@/lib/utils/model-display-names"; -import { safeErrorMessage } from "@/lib/auth"; import { - persistAiCreditSnapshot, - getAiCreditMonthlyTotalsFromDb, + safeErrorMessage, + getIdentitySessionFromRequest, + resolveUserScope, +} from "@/lib/auth"; +import { + getAiCreditItemsByMonthFromDb, monthKey, type NormalizedAiCreditItem, } from "@/lib/db/ai-credit-usage"; -import { getCreditConsumption } from "@/lib/db/ai-credit-consumption"; +import { getCreditConsumption, emptyConsumption } from "@/lib/db/ai-credit-consumption"; export const dynamic = "force-dynamic"; const GITHUB_API_BASE = "https://api.github.com"; const API_VERSION = "2026-03-10"; -/** - * Raw AI Credit usage item as returned by - * `/enterprises/{enterprise}/settings/billing/ai_credit/usage`. - * The aggregated form keys on (model, sku); enriched line items may also carry - * date / organization / user / team / cost-center dimensions. - */ -interface AiCreditUsageItem { - product?: string; - sku?: string; - model?: string; - unitType?: string; - unitTypeString?: string; - pricePerUnit?: number; - grossQuantity?: number; - grossAmount?: number; - discountQuantity?: number; - discountAmount?: number; - netQuantity?: number; - netAmount?: number; - // Optional dimensions present on enriched (non-aggregated) responses. - date?: string; - organizationName?: string; - user?: string; - team?: string; - costCenterName?: string; - costCenter?: string; -} - -interface AiCreditUsageResponse { - timePeriod?: { year: number; month: number }; - enterprise?: string; - usageItems: AiCreditUsageItem[]; -} - interface SeatInfo { plan_type: string; assignee: { login: string }; @@ -70,20 +39,6 @@ const querySchema = z.object({ teamId: z.string().optional(), }); -class GitHubHttpError extends Error { - status: number; - statusText: string; - body: string; - - constructor(status: number, statusText: string, body: string) { - super(`GitHub API error: ${status} ${statusText}`); - this.name = "GitHubHttpError"; - this.status = status; - this.statusText = statusText; - this.body = body; - } -} - function parseCsvSet(value?: string): Set | null { if (!value) return null; const values = value @@ -98,10 +53,6 @@ function shiftMonth(year: number, month: number, delta: number): { year: number; return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1 }; } -function num(v: number | undefined): number { - return typeof v === "number" && Number.isFinite(v) ? v : 0; -} - /** * Per-seat monthly included AI Credit entitlement (USD) under usage-based * billing, as published in the GitHub announcement: @@ -153,52 +104,6 @@ function computeCreditPool( } /** Normalize a raw API item into the shared snapshot shape. */ -function normalizeItem(item: AiCreditUsageItem): NormalizedAiCreditItem { - return { - usageDate: item.date ?? null, - product: item.product ?? "Copilot", - sku: item.sku ?? "", - model: item.model ?? item.sku ?? "", - costCenter: item.costCenterName ?? item.costCenter ?? null, - orgName: item.organizationName ?? null, - userLogin: item.user ?? null, - teamName: item.team ?? null, - unitType: item.unitTypeString ?? item.unitType ?? "ai-credits", - pricePerUnit: num(item.pricePerUnit), - grossQuantity: num(item.grossQuantity), - discountQuantity: num(item.discountQuantity), - netQuantity: num(item.netQuantity), - grossAmount: num(item.grossAmount), - discountAmount: num(item.discountAmount), - netAmount: num(item.netAmount), - }; -} - -async function fetchUsageItems( - token: string, - enterpriseSlug: string, - year: number, - month: number -): Promise { - const usageUrl = `${GITHUB_API_BASE}/enterprises/${encodeURIComponent(enterpriseSlug)}/settings/billing/ai_credit/usage?year=${year}&month=${month}`; - const usageRes = await fetch(usageUrl, { - headers: { - Accept: "application/vnd.github+json", - Authorization: "Bearer " + token, - "X-GitHub-Api-Version": API_VERSION, - }, - next: { revalidate: 0 }, - }); - - if (!usageRes.ok) { - const body = await usageRes.text(); - throw new GitHubHttpError(usageRes.status, usageRes.statusText, body); - } - - const data: AiCreditUsageResponse = await usageRes.json(); - return (data.usageItems ?? []).map(normalizeItem); -} - interface MatchFilters { model: Set | null; costCenter: Set | null; @@ -285,40 +190,29 @@ export async function GET(request: NextRequest) { const year = parsed.year ?? now.getFullYear(); const month = parsed.month ?? now.getMonth() + 1; + // Server-side row-level scoping: a `developer` is forced to their own login + // at the billing layer (derived from their identity session, never the UI). + // Admins and open/shared-password modes read all users' rows; per-user + // filtering for those roles is served by the consumption layer (userId) below. + const session = getIdentitySessionFromRequest(request); + const scope = resolveUserScope(session, null); + const scopedUser = scope.forced ? scope.user : null; + const selectedFilters: MatchFilters = { model: parseCsvSet(parsed.model), costCenter: parseCsvSet(parsed.costCenter), }; - // 1. Fetch selected-month AI Credit usage. - let usageItems: NormalizedAiCreditItem[] = []; - try { - usageItems = await fetchUsageItems(token, enterpriseSlug, year, month); - } catch (err) { - if (err instanceof GitHubHttpError) { - console.error(`AI Credit billing API error: ${err.status}`, err.body); - if (err.status === 403) { - return NextResponse.json( - { error: "Access denied. Your PAT may not have the required scopes. Please ensure it has: manage_billing:copilot (read) or manage_billing:enterprise (read). Update scopes at https://github.com/settings/tokens" }, - { status: 403 } - ); - } - if (err.status === 404) { - return NextResponse.json( - { error: "Enterprise not found, or AI Credit usage is not yet available for this period (the ai_credit/usage endpoint only returns activity after June 1, 2026). Verify the enterprise slug in Settings." }, - { status: 404 } - ); - } - return NextResponse.json( - { error: `GitHub AI Credit Billing API error: ${err.status} ${err.statusText}` }, - { status: err.status } - ); - } - throw err; - } - - // Persist this month's snapshot (best-effort) for trend continuity. - await persistAiCreditSnapshot(enterpriseSlug, year, month, usageItems); + // 1. Read AI Credit usage from persisted rows (no live export on page load). + // The selected month + a trailing window are read in one pass. When a + // developer scope is active, only that user's rows are returned from the DB. + const monthPoints = Array.from({ length: 6 }, (_, idx) => shiftMonth(year, month, idx - 5)); + const itemsByMonth = await getAiCreditItemsByMonthFromDb( + enterpriseSlug, + monthPoints, + scopedUser + ); + const usageItems: NormalizedAiCreditItem[] = itemsByMonth.get(monthKey(year, month)) ?? []; // 2. Fetch seat data for "credits per seat" context (deduped, highest plan wins). const allSeats: SeatInfo[] = []; @@ -416,14 +310,19 @@ export async function GET(request: NextRequest) { const windowStart = `${year}-${String(month).padStart(2, "0")}-01`; const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate(); const windowEnd = `${year}-${String(month).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`; - const consumption = await getCreditConsumption(windowStart, windowEnd, { - userIds: (parsed.userId ?? "") - .split(",") - .map((s) => parseInt(s.trim(), 10)) - .filter((n) => Number.isInteger(n)), - orgId: parsed.orgId, - teamId: parsed.teamId, - }); + // Row-level security: the consumption layer is org-wide (per-user across the + // enterprise), so a forced `developer` scope never receives it — they only + // ever see their own billing rows (scoped above). Admins/open read the full set. + const consumption = scope.forced + ? emptyConsumption() + : await getCreditConsumption(windowStart, windowEnd, { + userIds: (parsed.userId ?? "") + .split(",") + .map((s) => parseInt(s.trim(), 10)) + .filter((n) => Number.isInteger(n)), + orgId: parsed.orgId, + teamId: parsed.teamId, + }); // Resolve display names for every login surfaced in the response: the // rendered breakdown rows (billing + consumption) and the user filter @@ -464,32 +363,21 @@ export async function GET(request: NextRequest) { poolBase.total > 0 ? Math.round((poolConsumedAmount / poolBase.total) * 100) : 0, }; - // 6. Trailing 6-month trend — DB snapshots first, live API fallback per month. - const monthPoints = Array.from({ length: 6 }, (_, idx) => shiftMonth(year, month, idx - 5)); - const dbTotals = await getAiCreditMonthlyTotalsFromDb(enterpriseSlug, monthPoints); - const hasActiveFilters = Object.values(selectedFilters).some((f) => f !== null); - - const monthlyTrend = await Promise.all( - monthPoints.map(async (point) => { + // 6. Trailing 6-month trend — computed from persisted rows (already scoped). + const monthlyTrend = monthPoints.map((point) => { const key = monthKey(point.year, point.month); const isCurrent = point.year === year && point.month === month; - let bucket: CreditBucket | null = null; + let bucket: CreditBucket; if (isCurrent) { bucket = totals; - } else if (!hasActiveFilters && dbTotals.has(key)) { - // Snapshots store unfiltered totals; only use them when no filter is applied. - bucket = dbTotals.get(key)!; } else { - try { - const monthItems = await fetchUsageItems(token, enterpriseSlug, point.year, point.month); - const filtered = monthItems.filter((item) => usageMatchesFilters(item, selectedFilters)); - const b = emptyBucket(); - for (const item of filtered) addToBucket(b, item); - bucket = b; - } catch { - bucket = emptyBucket(); - } + const monthItems = (itemsByMonth.get(key) ?? []).filter((item) => + usageMatchesFilters(item, selectedFilters) + ); + const b = emptyBucket(); + for (const item of monthItems) addToBucket(b, item); + bucket = b; } return { @@ -501,8 +389,7 @@ export async function GET(request: NextRequest) { grossAmount: round2(bucket.grossAmount), netAmount: round2(bucket.netAmount), }; - }) - ); + }); const currentTrendPoint = monthlyTrend[monthlyTrend.length - 1]; const previousTrendPoint = monthlyTrend[monthlyTrend.length - 2] ?? null; diff --git a/app/src/app/api/metrics/premium-requests/route.ts b/app/src/app/api/metrics/premium-requests/route.ts index 70cd0ab..2a918c1 100644 --- a/app/src/app/api/metrics/premium-requests/route.ts +++ b/app/src/app/api/metrics/premium-requests/route.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { getGitHubConfig } from "@/lib/db/settings"; import { resolveUserNames } from "@/lib/github/resolve-display-names"; import { getModelDisplayName } from "@/lib/utils/model-display-names"; -import { safeErrorMessage } from "@/lib/auth"; +import { safeErrorMessage, getIdentitySessionFromRequest, resolveUserScope } from "@/lib/auth"; export const dynamic = "force-dynamic"; @@ -142,10 +142,16 @@ export async function GET(request: NextRequest) { const year = parsed.year ?? now.getFullYear(); const month = parsed.month ?? now.getMonth() + 1; + // Server-side row-level scoping: a `developer` is forced to their own login + // regardless of any client-supplied `user=`; admins (and open/shared-password + // modes) keep cross-user access. Enforced here, never in the UI alone. + const session = getIdentitySessionFromRequest(request); + const scope = resolveUserScope(session, parsed.user ?? null); + const selectedFilters = { model: parseCsvSet(parsed.model), org: parseCsvSet(parsed.org), - user: parseCsvSet(parsed.user), + user: scope.forced ? new Set([scope.user!]) : parseCsvSet(parsed.user), team: parseCsvSet(parsed.team), }; @@ -176,6 +182,14 @@ export async function GET(request: NextRequest) { throw err; } + // Scope live usage rows to the developer's own login so neither the filter + // option lists nor the breakdowns can expose another user's data. + if (scope.forced) { + usageItems = usageItems.filter( + (item) => (item.user ?? "").toLowerCase() === scope.user + ); + } + // 2. Fetch seat data for plan quotas (deduplicated by user — highest plan wins) const allSeats: SeatInfo[] = []; let page = 1; @@ -388,7 +402,7 @@ export async function GET(request: NextRequest) { selected: { model: parsed.model ?? "", org: parsed.org ?? "", - user: parsed.user ?? "", + user: scope.forced ? scope.user! : (parsed.user ?? ""), team: parsed.team ?? "", }, }, diff --git a/app/src/app/api/users/route.ts b/app/src/app/api/users/route.ts index cc5e2e2..c7c2ca9 100644 --- a/app/src/app/api/users/route.ts +++ b/app/src/app/api/users/route.ts @@ -1,11 +1,11 @@ import { NextRequest, NextResponse } from "next/server"; import { db } from "@/lib/db"; import { factCopilotUsageDaily, dimUser, dimOrg } from "@/lib/db/schema"; -import { sql, and, gte, lte, eq, ilike } from "drizzle-orm"; +import { sql, and, gte, lte, eq } from "drizzle-orm"; import { daysAgo, isValidDate } from "@/lib/utils"; import { z } from "zod"; import { resolveUserNames } from "@/lib/github/resolve-display-names"; -import { safeErrorMessage } from "@/lib/auth"; +import { safeErrorMessage, getIdentitySessionFromRequest } from "@/lib/auth"; const querySchema = z.object({ start: z.string().refine(isValidDate).optional(), @@ -50,6 +50,18 @@ export async function GET(request: NextRequest) { conditions.push(eq(factCopilotUsageDaily.orgId, params.orgId)); } + // Server-side row-level scoping: a `developer` may only ever read their own + // row. Enforced here (not the UI); admins and open/shared-password modes + // retain full cross-user access. + const session = getIdentitySessionFromRequest(request); + if (session?.role === "developer") { + // Exact, case-insensitive match (never a LIKE pattern), so a login + // containing `_`/`%` can't widen the scope to other users' rows. + conditions.push( + eq(sql`lower(${factCopilotUsageDaily.userLogin})`, session.login.toLowerCase()) + ); + } + const users = await db .select({ userId: factCopilotUsageDaily.userId, diff --git a/app/src/app/my-usage/page.tsx b/app/src/app/my-usage/page.tsx new file mode 100644 index 0000000..8fa0c96 --- /dev/null +++ b/app/src/app/my-usage/page.tsx @@ -0,0 +1,299 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import "@/lib/chart-registry"; +import { Bar, Line } from "react-chartjs-2"; +import { useChartOptions } from "@/lib/theme/chart-theme"; +import { useTranslation } from "@/lib/i18n/locale-provider"; +import { PageHeader } from "@/components/layout/page-header"; +import { ReportBanner } from "@/components/layout/report-banner"; +import { DataTable } from "@/components/ui/data-table"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { EmptyState } from "@/components/ui/empty-state"; +import { ConfigurationBanner } from "@/components/layout/configuration-banner"; +import { DataSourceBanner } from "@/components/layout/report-filters"; + +interface CreditBucket { + grossQuantity: number; + discountQuantity: number; + netQuantity: number; + grossAmount: number; + discountAmount: number; + netAmount: number; +} + +interface MyUsageTotals { + grossCredits: number; + includedCredits: number; + billableCredits: number; + grossAmount: number; + discountAmount: number; + netAmount: number; + discountCoveragePct: number; + effectivePricePerCredit: number; +} + +type ModelBreakdown = CreditBucket & { model: string }; + +interface MonthlyTrendPoint { + year: number; + month: number; + label: string; + grossCredits: number; + billableCredits: number; + grossAmount: number; + netAmount: number; +} + +interface AiCreditData { + period: { year: number; month: number }; + totals: MyUsageTotals; + perModelBreakdown: ModelBreakdown[]; + monthlyTrend: MonthlyTrendPoint[]; +} + +// Lower bound supported by /api/metrics/ai-credits (the query schema enforces +// year >= 2020). Month navigation is clamped to this so the page never requests +// a period the API rejects. +const MIN_YEAR = 2020; + +const MODEL_COLORS = [ + "#8b5cf6", "#a855f7", "#c084fc", "#d8b4fe", "#7c3aed", + "#6d28d9", "#5b21b6", "#4c1d95", "#ec4899", "#f43f5e", + "#3b82f6", "#6366f1", "#14b8a6", "#f59e0b", "#10b981", +]; + +export default function MyUsagePage() { + const { commonOptions: barOpts } = useChartOptions(); + const { t, locale } = useTranslation(); + const now = new Date(); + const [year, setYear] = useState(now.getFullYear()); + const [month, setMonth] = useState(now.getMonth() + 1); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [notConfigured, setNotConfigured] = useState(false); + + useEffect(() => { + setLoading(true); + setError(null); + setNotConfigured(false); + + // Identity is implicit: no `user` param is sent. The server forces the + // scope to the signed-in developer's own login (issue C1), so this page + // can never read another user's rows. + const params = new URLSearchParams({ year: String(year), month: String(month) }); + + fetch(`/api/metrics/ai-credits?${params.toString()}`) + .then(async (res) => { + if (res.status === 400) { + setNotConfigured(true); + return null; + } + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `HTTP ${res.status}`); + } + return res.json() as Promise; + }) + .then((credits) => setData(credits)) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }, [year, month]); + + // Locale-aware formatters follow the dashboard language selected via + // LocaleProvider (en/ar/es/fr) instead of a hardcoded en-US format. + const currencyFmt = useMemo( + () => + new Intl.NumberFormat(locale, { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }), + [locale] + ); + const numberFmt = useMemo( + () => new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }), + [locale] + ); + const fmt$ = (v: number) => currencyFmt.format(v); + const fmtNum = (v: number) => numberFmt.format(v); + + const monthLabel = useMemo( + () => + new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format( + new Date(year, month - 1, 1) + ), + [locale, year, month] + ); + + const modelBar = useMemo(() => { + if (!data || data.perModelBreakdown.length === 0) return null; + const top = data.perModelBreakdown.slice(0, 15); + return { + labels: top.map((m) => m.model), + datasets: [{ + label: t("myUsage.creditsLabel"), + data: top.map((m) => m.grossQuantity), + backgroundColor: top.map((_, i) => MODEL_COLORS[i % MODEL_COLORS.length]), + borderRadius: 6, + }], + }; + }, [data, t]); + + const trendLine = useMemo(() => { + if (!data || data.monthlyTrend.length === 0) return null; + return { + labels: data.monthlyTrend.map((m) => m.label), + datasets: [{ + label: t("myUsage.netSpend"), + data: data.monthlyTrend.map((m) => m.netAmount), + borderColor: "#6366f1", + backgroundColor: "rgba(99,102,241,0.2)", + fill: true, + tension: 0.3, + }], + }; + }, [data, t]); + + const goMonth = (delta: number) => { + let m = month + delta; + let y = year; + if (m < 1) { m = 12; y--; } + if (m > 12) { m = 1; y++; } + // Clamp to the API's supported lower bound so navigation can't request a + // year the query schema rejects (which would surface as an error state). + if (y < MIN_YEAR) { y = MIN_YEAR; m = 1; } + setYear(y); + setMonth(m); + }; + + if (loading) { + return ; + } + + if (notConfigured) { + return ( +
+ + +
+ ); + } + + if (error) { + return ( +
+ + +
+ ); + } + + if (!data) return null; + + const { totals } = data; + const hasUsage = data.perModelBreakdown.length > 0 || totals.grossCredits > 0; + + return ( +
+ + + + + +
+ + + {monthLabel} + + +
+ + {hasUsage ? ( + <> +
+ + + 0 ? "text-indigo-600" : "text-gray-900"} /> + 0 ? "text-indigo-600" : "text-gray-900"} /> +
+ +
+ + {modelBar ? ( +
+ ) : ( +

{t("myUsage.noModelData")}

+ )} +
+ + {trendLine ? ( +
+ ) : ( +

{t("myUsage.noTrend")}

+ )} +
+
+ + {data.perModelBreakdown.length > 0 && ( + + {String(value)} }, + { key: "grossQuantity", header: t("myUsage.grossCredits"), align: "right", render: (value: unknown) => fmtNum(Number(value)) }, + { key: "netQuantity", header: t("myUsage.billableCredits"), align: "right", render: (value: unknown) => fmtNum(Number(value)) }, + { key: "grossAmount", header: t("myUsage.grossAmount"), align: "right", render: (value: unknown) => fmt$(Number(value)) }, + { key: "netAmount", header: t("myUsage.netAmount"), align: "right", render: (value: unknown) => {fmt$(Number(value))} }, + ]} + data={(data.perModelBreakdown) as unknown as Record[]} + emptyMessage={t("myUsage.noModelData")} + pageSize={25} + defaultSortKey="grossQuantity" + defaultSortDir="desc" + /> + + )} + + ) : ( + + )} +
+ ); +} + +function Card({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) { + return ( +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+
{children}
+
+ ); +} + +function Kpi({ label, value, color }: { label: string; value: string | number; color?: string }) { + return ( +
+

{label}

+

+ {typeof value === "number" ? value.toLocaleString() : value} +

+
+ ); +} diff --git a/app/src/app/settings/page.tsx b/app/src/app/settings/page.tsx index 0ff8d54..9a2b6f5 100644 --- a/app/src/app/settings/page.tsx +++ b/app/src/app/settings/page.tsx @@ -207,10 +207,11 @@ export default function ConfigurationPage() {

Classic (recommended for enterprise):{" "} manage_billing:copilot,{" "} + manage_billing:enterprise,{" "} read:enterprise,{" "} read:org.{" "} 2026-03-10 No scope required (any valid token) + + POST/GET /enterprises/{"{slug}"}/settings/billing/reports + AI Credit usage report export (async; persists per-user rows after June 1, 2026) + 2026-03-10 + manage_billing:enterprise + GET /user/orgs Discover organizations accessible to the token @@ -451,6 +458,7 @@ export default function ConfigurationPage() { All endpoints use Bearer token authentication. For full coverage, the recommended classic PAT scopes are:{" "} manage_billing:copilot,{" "} + manage_billing:enterprise,{" "} read:enterprise,{" "} read:org.

diff --git a/app/src/components/layout/sidebar.tsx b/app/src/components/layout/sidebar.tsx index a31a1b7..17b4135 100644 --- a/app/src/components/layout/sidebar.tsx +++ b/app/src/components/layout/sidebar.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import Image from "next/image"; import { usePathname } from "next/navigation"; -import { useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { useGSAP } from "@gsap/react"; import gsap from "gsap"; import { cn } from "@/lib/utils"; @@ -27,29 +27,48 @@ import { Moon, Monitor, Globe, + UserCircle, } from "lucide-react"; import { AgentIcon } from "@/components/icons/agent-icon"; import { CliIcon } from "@/components/icons/cli-icon"; -// Reports are ordered from highest impact (top) to lowest. The deprecated -// Premium Requests report sits at the bottom, followed by a separator and the -// Metrics Reference. +/** + * Navigation entries. Flags drive role-based gating in identity mode: + * - `identityOnly`: only shown when identity mode is enabled (e.g. "My Usage", + * which scopes to the signed-in developer's own rows). + * - `orgWide`: a cross-user / org-wide report hidden from developers; admins + * (and open / shared-password modes) keep the current behavior. + * Server-side enforcement (issue C1) is the source of truth — this only hides + * nav entries that a developer cannot meaningfully use. + * + * Reports are ordered from highest impact (top) to lowest. The deprecated + * Premium Requests report sits at the bottom, followed by a separator and the + * Metrics Reference. + */ const NAV_KEYS = [ - { key: "aiAnalyst.nav", href: "/ai-analyst", icon: Sparkles }, - { key: "nav.copilotUsage", href: "/metrics", icon: BarChart3 }, - { key: "nav.codeGeneration", href: "/code-generation", icon: Code }, - { key: "nav.pullRequests", href: "/pull-requests", icon: GitPullRequest }, - { key: "nav.agentImpact", href: "/agents", icon: AgentIcon }, - { key: "nav.aiAdoption", href: "/ai-adoption", icon: Layers }, - { key: "nav.cliImpact", href: "/cli", icon: CliIcon }, - { key: "nav.copilotLicensing", href: "/seats", icon: CreditCard }, - { key: "nav.aiCredits", href: "/ai-credits", icon: Coins }, - { key: "nav.usersData", href: "/users", icon: Contact }, - { key: "nav.enterpriseTeams", href: "/enterprise-teams", icon: Network }, - { key: "nav.premiumRequests", href: "/premium-requests", icon: Gem }, - { key: "nav.metricsReference", href: "/reference", icon: BookOpen, separatorBefore: true }, + { key: "aiAnalyst.nav", href: "/ai-analyst", icon: Sparkles, orgWide: true }, + { key: "nav.myUsage", href: "/my-usage", icon: UserCircle, identityOnly: true }, + { key: "nav.copilotUsage", href: "/metrics", icon: BarChart3, orgWide: true }, + { key: "nav.codeGeneration", href: "/code-generation", icon: Code, orgWide: true }, + { key: "nav.pullRequests", href: "/pull-requests", icon: GitPullRequest, orgWide: true }, + { key: "nav.agentImpact", href: "/agents", icon: AgentIcon, orgWide: true }, + { key: "nav.aiAdoption", href: "/ai-adoption", icon: Layers, orgWide: true }, + { key: "nav.cliImpact", href: "/cli", icon: CliIcon, orgWide: true }, + { key: "nav.copilotLicensing", href: "/seats", icon: CreditCard, orgWide: true }, + { key: "nav.aiCredits", href: "/ai-credits", icon: Coins, orgWide: true }, + { key: "nav.usersData", href: "/users", icon: Contact, orgWide: true }, + { key: "nav.enterpriseTeams", href: "/enterprise-teams", icon: Network, orgWide: true }, + { key: "nav.premiumRequests", href: "/premium-requests", icon: Gem, orgWide: true }, + { key: "nav.metricsReference", href: "/reference", icon: BookOpen, orgWide: true, separatorBefore: true }, ]; +interface SessionInfo { + identityMode: boolean; + authenticated: boolean; + login: string | null; + role: "admin" | "developer" | null; +} + const THEME_ICONS = { light: Sun, dark: Moon, system: Monitor } as const; export function Sidebar() { @@ -57,9 +76,35 @@ export function Sidebar() { const { theme, setTheme } = useTheme(); const { t, locale, setLocale, locales, dir } = useTranslation(); const navRef = useRef(null); + const [session, setSession] = useState(null); + const [sessionLoaded, setSessionLoaded] = useState(false); + + useEffect(() => { + fetch("/api/auth/session") + .then((r) => (r.ok ? (r.json() as Promise) : null)) + .then((data) => { + if (data) setSession(data); + }) + .catch(() => { + // Fail open: keep current (non-gated) behavior if the check fails. + }) + .finally(() => setSessionLoaded(true)); + }, []); + + // Gating only applies in identity mode. A developer sees "My Usage" and no + // org-wide reports; admins (and open / shared-password modes) see everything. + const isIdentityMode = session?.identityMode === true; + const isDeveloper = isIdentityMode && session?.role === "developer"; + + const navItems = NAV_KEYS.filter((item) => { + if ("identityOnly" in item && item.identityOnly && !isIdentityMode) return false; + if ("orgWide" in item && item.orgWide && isDeveloper) return false; + return true; + }); // Stagger the navigation links into view on mount. They slide in from the - // inline-start edge so the motion reads naturally in both LTR and RTL. + // inline-start edge so the motion reads naturally in both LTR and RTL. Re-runs + // when the gated nav list resolves so the real items animate (not the skeleton). useGSAP( () => { const root = navRef.current; @@ -73,7 +118,7 @@ export function Sidebar() { stagger: 0.04, }); }, - { scope: navRef, dependencies: [dir] } + { scope: navRef, dependencies: [dir, sessionLoaded] } ); const cycleTheme = () => { @@ -94,7 +139,7 @@ export function Sidebar() {
- - - {t("common.settings")} - + {session?.login && ( +
+ +
+

+ {t("common.signedInAs")} +

+

+ {session.login} +

+
+
+ )} + {sessionLoaded && !isDeveloper && ( + + + {t("common.settings")} + + )}