Files
pad/internal/models/connected_apps.go
T
xarmian f9d3244660 feat(connected-apps): user-facing OAuth connection management page (TASK-954) (#390)
* feat(connected-apps): user-facing OAuth connection management page (TASK-954)

Adds /console/connected-apps where a logged-in user can see every
OAuth grant chain they've authorized via the MCP consent flow
(Claude Desktop, Cursor, …) and revoke any of them. Joins to the
DCR client metadata for the display name + logo, and to the MCP
audit log (TASK-960) for the "last used" + "30-day calls" columns.

Pieces:

- internal/store/connected_apps.go — ListUserOAuthConnections walks
  oauth_access_tokens + oauth_refresh_tokens, dedups by request_id,
  hydrates client metadata, parses session_data for the workspace
  allow-list, classifies granted_scopes into a coarse capability
  tier. RevokeUserOAuthConnection verifies ownership (ErrConnection
  NotFound for stranger's chains — anti-enumeration; same shape as
  for unknown chains) then calls the existing RevokeRefreshTokenFamily
  + RevokeAccessTokenFamily so the next /mcp call gets 401.

- internal/models/connected_apps.go — OAuthConnection + CapabilityTier
  models.

- internal/server/handlers_connected_apps.go — REST endpoints:
  GET /api/v1/connected-apps (list) + DELETE /api/v1/connected-apps/{id}
  (revoke, idempotent, 204). Wrapped in requireCloudMode group.
  List enriches with MCPConnectionStatsForUser (audit aggregates) —
  soft-fails on the audit lookup so a broken audit table degrades
  to "no last-used data" instead of a broken page. Revoke records
  an "oauth_connection_revoked" entry in audit_trail via the
  existing CreateActivity path.

- web/src/routes/console/connected-apps/+page.svelte — list with
  per-app card (logo, name, capability badge, workspace chips with
  +N expander, connected/last-used relative times, 30-day count),
  Details expander showing scope_string + workspace list + redirect
  URIs, Revoke button → confirm modal → optimistic refresh, friendly
  empty state linking to /connect.

- web/src/routes/console/+layout.svelte — Connected Apps nav link
  (cloud-mode-gated, between Settings and Billing).

- web/src/lib/api/client.ts + types/index.ts — typed client +
  ConnectedApp interface.

Tests cover:
- Store: chain dedup across rotation siblings, subject filtering
  (Bob can't see Alice's), inactive chains excluded, ownership
  check on revoke, idempotent re-revoke, capability tier mapping,
  session-data allowed_workspaces parsing (both []string and JSON
  []interface{} round-trips).
- Handler: cloud-mode gate (404 outside), owner-only filtering,
  DTO field shape + audit enrichment populating last_used_at +
  calls_30d, revoke ownership 404 (not 403 — anti-enumeration),
  idempotent 204, audit_trail row written.

`make check` clean (lint + go test ./... + svelte-kit build).

Parent: PLAN-943.

* fix(connected-apps): point empty-state link at getpad.dev (Codex review round 1)

Codex caught: the empty-state link to /connect 404s because /connect is a
pad-web (marketing site) route, not a docapp route. From inside the
authenticated console at app.getpad.dev, the right target is the
absolute https://getpad.dev/connect URL — same pattern the +error.svelte
page uses for its "Back to getpad.dev" + "/docs" links.

* fix(console nav): exclude /console/connected-apps from Workspaces active match (Codex round 2)

Codex caught: the Workspaces nav predicate `isActive('/console') && !isActive('/console/settings') && ...` was missing the new /console/connected-apps prefix, so both Workspaces AND Connected Apps lit up when viewing the connected-apps page.

Same shape as the existing exclusions for settings / billing / admin.
2026-05-02 23:21:07 -04:00

84 lines
3.4 KiB
Go

package models
import "time"
// Connected-apps models (PLAN-943 TASK-954).
//
// A "connected app" is one OAuth client (Claude Desktop, Cursor, …)
// that the user has authorized to act on their behalf via the MCP
// surface. Each user-authorized OAuth flow produces one connection,
// identified by the OAuth grant chain's request_id (preserved across
// refresh-token rotations — see internal/store/oauth.go for the
// chain semantics). Revoking a connection invalidates every token in
// that chain so the next /mcp call from that client gets 401.
// CapabilityTier classifies the granted scopes into a coarse,
// user-readable bucket. Used by the connected-apps page so users
// don't have to read raw OAuth scope strings to understand what a
// given client can do.
type CapabilityTier string
const (
CapabilityTierReadOnly CapabilityTier = "read_only"
CapabilityTierReadWrite CapabilityTier = "read_write"
CapabilityTierFullAccess CapabilityTier = "full_access" // includes pad:admin
CapabilityTierUnknown CapabilityTier = "unknown" // empty / unparseable scopes
)
// OAuthConnection is one row of the connected-apps page. Joins
// oauth_access_tokens (the active grant) → oauth_clients (the DCR
// metadata). The audit-log enrichments (LastUsedAt + Calls30d) come
// from a separate aggregate query against mcp_audit_log so the page
// loads in two queries instead of N+1.
//
// One value per OAuth grant chain — the connections list deduplicates
// across refresh-token rotations because every chain member shares
// the same RequestID.
type OAuthConnection struct {
// RequestID is the OAuth grant chain identifier — preserved
// across refresh-token rotations and used as the public ID for
// revoke + audit drilldown ("connection_id" on the wire).
RequestID string
// ClientID / ClientName / LogoURL / RedirectURIs come from the
// DCR registration metadata (oauth_clients table). ClientName is
// what shows on the consent screen + connected-apps cards
// (e.g. "Claude Desktop").
ClientID string
ClientName string
LogoURL string
RedirectURIs []string
// AllowedWorkspaces is the workspace allow-list set at consent
// time (TASK-952). Three shapes:
// - nil — pre-TASK-952 token (no consent payload). Treated as
// "any workspace the user belongs to."
// - ["*"] — wildcard (user explicitly granted any).
// - explicit slugs — the user's selection.
// The page renders chips per slug, or an "Any workspace" badge
// for nil / wildcard.
AllowedWorkspaces []string
// GrantedScopes is the raw space-separated scope string fosite
// recorded at grant time. Surfaced in the per-connection expand
// drilldown alongside CapabilityTier so power users can see the
// exact policy.
GrantedScopes string
// CapabilityTier is the human-readable bucket derived from
// GrantedScopes (read_only / read_write / full_access / unknown).
CapabilityTier CapabilityTier
// ConnectedAt is the earliest requested_at across the chain —
// when the user originally authorized this connection. Survives
// refresh-token rotation because every chain member shares the
// same request_id and the MIN over the chain stays anchored.
ConnectedAt time.Time
// LastUsedAt + Calls30d come from MCPConnectionStatsForUser
// (TASK-960's audit-log aggregate). Nil LastUsedAt means "no
// audit entries yet" — the page renders "—" instead of a date.
LastUsedAt *time.Time
Calls30d int
}