diff --git a/app/settings/_components/AgentsTab.tsx b/app/settings/_components/AgentsTab.tsx index 4ccf4a0..7f89dac 100644 --- a/app/settings/_components/AgentsTab.tsx +++ b/app/settings/_components/AgentsTab.tsx @@ -3,10 +3,12 @@ import { useCallback, useMemo, useState, useTransition } from "react"; import { listOAuthSessionsAction, + revokeAllOAuthSessionsAction, type OAuthSessionView, } from "@/lib/actions/oauth-session"; import { formatOAuthClientName } from "@/lib/ui/oauth-client-name"; import { AgentSection } from "./AgentSection"; +import { InlineConfirm } from "./InlineConfirm"; interface AgentsTabProps { /** Initial session list, hydrated from the server component. */ @@ -14,7 +16,7 @@ interface AgentsTabProps { } /** Canonical brands rendered as fixed sections, in display order. */ -const KNOWN_BRANDS = ["Claude Code", "Codex", "Cursor", "Gemini"] as const; +const KNOWN_BRANDS = ["Claude Code", "Codex", "Gemini", "Cursor"] as const; type KnownBrand = (typeof KNOWN_BRANDS)[number]; const KNOWN_BRAND_SET: ReadonlySet = new Set(KNOWN_BRANDS); @@ -32,8 +34,8 @@ function groupSessions(sessions: OAuthSessionView[]): { const byBrand: Record = { "Claude Code": [], Codex: [], - Cursor: [], Gemini: [], + Cursor: [], }; const otherSessions: OAuthSessionView[] = []; @@ -83,6 +85,16 @@ export function AgentsTab({ initialSessions }: AgentsTabProps) { }); }; + const handleRevokeAll = async () => { + setError(null); + const result = await revokeAllOAuthSessionsAction(); + if (result.ok) { + setSessions([]); + } else { + setError(result.message); + } + }; + const { byBrand, otherSessions } = useMemo( () => groupSessions(sessions), [sessions], @@ -96,7 +108,8 @@ export function AgentsTab({ initialSessions }: AgentsTabProps) { Agents & devices

- Sessions authorized to run via MCP. Revoke any time. + Sessions authorized to run via MCP. Changing your password revokes + every session automatically.

+ } + prompt="Revoke all sessions?" + body="Every connected agent will need to re-authorize." + confirmLabel="Revoke all" + destructive + onConfirm={handleRevokeAll} + /> + + ) : null} {KNOWN_BRANDS.map((brand) => ( { + let userId: string; + try { + const session = await requireSession(); + userId = session.user.id; + } catch { + return teamFail("unauthorized"); + } + + const limit = await checkActionRateLimit( + { + action: "oauth.revoke_all", + windowSeconds: 60, + perUserMax: 3, + perIpMax: 10, + }, + userId, + ); + if (!limit.ok) return teamFail("rate_limited"); + + try { + await clearUserOAuthArtifacts(userId); + revalidatePath("/settings"); + return { ok: true }; + } catch (err) { + console.error("revokeAllOAuthSessionsAction failed", err); + return teamFail("unknown"); + } +} diff --git a/lib/auth.ts b/lib/auth.ts index 996a56d..91c3740 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -5,14 +5,15 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle"; import * as authSchema from "@/lib/db/auth-schema"; import { authDb } from "@/lib/db/connection"; import { clearOrgMembershipArtifacts } from "@/lib/data/account"; +import { clearUserOAuthArtifacts } from "@/lib/data/oauth-session"; import { ac, owner, admin, member as memberRole } from "@/lib/auth/permissions"; import { findOrgMemberUserIdsAsAdmin } from "@/lib/data/membership"; import { grantOrgAccess, revokeOrgAccess } from "@/lib/realtime/access"; /** - * Better Auth server instance. - * Uses Neon Auth's existing schema (neon_auth) via drizzleAdapter. - * Provides email/password auth and organization-based team management. + * Better Auth server instance with email/password auth and + * organization-based team management. Adapts the `neon_auth` schema via + * drizzleAdapter. */ export const auth = betterAuth({ database: drizzleAdapter(authDb, { @@ -117,6 +118,14 @@ export const auth = betterAuth({ consentPage: "/consent", allowDynamicClientRegistration: true, allowUnauthenticatedClientRegistration: true, + accessTokenExpiresIn: 60 * 60, // 1h + refreshTokenExpiresIn: 60 * 60 * 24 * 7, // 7 days + clientRegistrationAllowedScopes: [ + "openid", + "profile", + "email", + "offline_access", + ], validAudiences: process.env.BETTER_AUTH_URL ? [ process.env.BETTER_AUTH_URL, @@ -139,6 +148,23 @@ export const auth = betterAuth({ silenceWarnings: { oauthAuthServerConfig: true }, }), ], + databaseHooks: { + account: { + update: { + after: async (account) => { + if (account.providerId !== "credential") return; + try { + await clearUserOAuthArtifacts(account.userId); + } catch (err) { + console.error("account.update.after cascade failure", { + userId: account.userId, + err, + }); + } + }, + }, + }, + }, }); export type Session = typeof auth.$Infer.Session; diff --git a/lib/data/oauth-session.ts b/lib/data/oauth-session.ts index 785293e..c1e8cd2 100644 --- a/lib/data/oauth-session.ts +++ b/lib/data/oauth-session.ts @@ -137,6 +137,23 @@ export async function revokeOAuthSession( }); } +/** + * Hard-delete every OAuth refresh and access token owned by a user. + * + * @param userId - Verified user id. + * @returns Resolves once both deletes commit. + */ +export async function clearUserOAuthArtifacts(userId: string): Promise { + await db.transaction(async (tx) => { + await tx + .delete(oauthAccessToken) + .where(eq(oauthAccessToken.userId, userId)); + await tx + .delete(oauthRefreshToken) + .where(eq(oauthRefreshToken.userId, userId)); + }); +} + /** * Check whether a user has previously approved a specific OAuth client. * Drives the consent page's first-time warning. Uses `oauthConsent` rather