mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-22 18:43:45 +00:00
f9d3244660
* 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.
84 lines
3.4 KiB
Go
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
|
|
}
|