From 8a022302dda2e8251e78f11e593eee0161aca102 Mon Sep 17 00:00:00 2001 From: Mason Hall Date: Tue, 30 Sep 2025 15:29:57 -0400 Subject: [PATCH 1/6] init --- .../_components/existing-user/scopes.tsx | 6 +- .../(oauth)/api/oauth/token/_lib/issue.ts | 46 +++++ .../oauth-api-key-scope.test.ts | 177 ++++++++++++++++++ 3 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 packages/tests/integration/tests/oauth-protocol/oauth-api-key-scope.test.ts diff --git a/packages/app/control/src/app/(auth)/(oauth)/(authorize)/oauth/authorize/_components/existing-user/scopes.tsx b/packages/app/control/src/app/(auth)/(oauth)/(authorize)/oauth/authorize/_components/existing-user/scopes.tsx index 7f612fc19..dca932532 100644 --- a/packages/app/control/src/app/(auth)/(oauth)/(authorize)/oauth/authorize/_components/existing-user/scopes.tsx +++ b/packages/app/control/src/app/(auth)/(oauth)/(authorize)/oauth/authorize/_components/existing-user/scopes.tsx @@ -1,4 +1,4 @@ -import { BrainCircuit, User } from 'lucide-react'; +import { BrainCircuit, Key, User } from 'lucide-react'; interface Props { scopes: string[]; @@ -38,4 +38,8 @@ const scopeData = { name: 'Connect your user profile', icon: User, }, + 'api_key:create': { + name: 'Create API keys', + icon: Key, + }, }; diff --git a/packages/app/control/src/app/(auth)/(oauth)/api/oauth/token/_lib/issue.ts b/packages/app/control/src/app/(auth)/(oauth)/api/oauth/token/_lib/issue.ts index 4e83d9e94..d0d673372 100644 --- a/packages/app/control/src/app/(auth)/(oauth)/api/oauth/token/_lib/issue.ts +++ b/packages/app/control/src/app/(auth)/(oauth)/api/oauth/token/_lib/issue.ts @@ -25,6 +25,7 @@ import { getApp } from '@/services/db/apps/get'; import { createAppMembership } from '@/services/db/apps/membership'; import { issueOAuthToken } from '@/services/db/auth/oauth-token'; +import { createApiKey } from '@/services/db/api-keys'; import type { TokenMetadata } from '@/types/token-metadata'; @@ -178,6 +179,10 @@ export async function handleIssueToken( } } + /* 🔟 Check if api_key:create scope is present */ + const scopes = scope.split(' '); + const shouldCreateApiKey = scopes.includes('api_key:create'); + const { session, refreshToken } = await issueOAuthToken({ userId: user.id, appId: app.id, @@ -185,6 +190,47 @@ export async function handleIssueToken( metadata, }); + if (shouldCreateApiKey) { + logger.emit({ + severityText: 'INFO', + body: 'Creating API key for user with api_key:create scope', + attributes: { + userId: user.id, + echoAppId: app.id, + function: 'handleInitialTokenIssuance', + }, + }); + + /* Generate an API key instead of a temporary JWT token */ + const apiKey = await createApiKey(user.id, { + echoAppId: app.id, + name: 'OAuth Generated API Key', + }); + + logger.emit({ + severityText: 'INFO', + body: 'API key generated for OAuth flow', + attributes: { + userId: user.id, + echoAppId: app.id, + apiKeyId: apiKey.id, + function: 'handleInitialTokenIssuance', + }, + }); + + /* Return API key as access token with a very long expiration (100 years) */ + return tokenResponse({ + accessToken: { + access_token: apiKey.key, + scope, + access_token_expiry: new Date( + Date.now() + 100 * 365 * 24 * 60 * 60 * 1000 + ), // 100 years + }, + refreshToken, + }); + } + const accessToken = await createEchoAccessJwt({ user_id: user.id, app_id: app.id, diff --git a/packages/tests/integration/tests/oauth-protocol/oauth-api-key-scope.test.ts b/packages/tests/integration/tests/oauth-protocol/oauth-api-key-scope.test.ts new file mode 100644 index 000000000..0e02439fd --- /dev/null +++ b/packages/tests/integration/tests/oauth-protocol/oauth-api-key-scope.test.ts @@ -0,0 +1,177 @@ +import { describe, test, expect, beforeAll } from 'vitest'; +import { + TEST_CLIENT_IDS, + TEST_USER_IDS, + generateCodeVerifier, + generateCodeChallenge, + generateState, + echoControlApi, + TEST_CONFIG, +} from '../../utils/index.js'; + +describe('OAuth API Key Creation with api_key:create Scope', () => { + beforeAll(async () => { + // Verify test environment + expect(TEST_CONFIG.services.echoControl).toBeTruthy(); + expect(process.env.INTEGRATION_TEST_JWT).toBeTruthy(); + }); + + describe('Token Exchange with api_key:create scope', () => { + test('should return an API key as access token when api_key:create scope is requested', async () => { + const codeVerifier = generateCodeVerifier(); + const codeChallenge = generateCodeChallenge(codeVerifier); + const state = generateState(); + + console.log('Getting authorization code with api_key:create scope...'); + + const redirectUrl = await echoControlApi.validateOAuthAuthorizeRequest({ + client_id: TEST_CLIENT_IDS.primary, + redirect_uri: 'http://localhost:3000/callback', + state, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + scope: 'llm:invoke offline_access api_key:create', + prompt: 'none', // Skip consent page for automated testing + }); + + // Extract authorization code from callback URL + const callbackUrl = new URL(redirectUrl); + const authCode = callbackUrl.searchParams.get('code'); + const returnedState = callbackUrl.searchParams.get('state'); + + expect(authCode).toBeTruthy(); + expect(returnedState).toBe(state); + + console.log('✅ Got authorization code with api_key:create scope'); + + // Exchange authorization code for tokens + console.log('Exchanging auth code for API key...'); + + const tokenResponse = await echoControlApi.exchangeCodeForToken({ + code: authCode!, + client_id: TEST_CLIENT_IDS.primary, + redirect_uri: 'http://localhost:3000/callback', + code_verifier: codeVerifier, + }); + + console.log('Token response:', { + access_token_preview: + tokenResponse.access_token.substring(0, 50) + '...', + token_type: tokenResponse.token_type, + expires_in: tokenResponse.expires_in, + scope: tokenResponse.scope, + has_refresh_token: !!tokenResponse.refresh_token, + }); + + // Verify token response structure + expect(tokenResponse.access_token).toBeTruthy(); + expect(tokenResponse.token_type).toBe('Bearer'); + expect(tokenResponse.scope).toContain('api_key:create'); + expect(tokenResponse.refresh_token).toBeTruthy(); + + // Verify the access token is an API key (not a JWT) + // API keys should NOT have JWT structure (3 parts separated by dots) + const accessTokenParts = tokenResponse.access_token.split('.'); + expect(accessTokenParts.length).not.toBe(3); // Not a JWT + + // API keys should start with the API key prefix (typically 'echo_') + expect(tokenResponse.access_token).toMatch(/^echo_/); + + // Verify the expires_in is very long (100 years = ~3.15B seconds) + // We'll check it's greater than 10 years (315M seconds) + expect(tokenResponse.expires_in).toBeGreaterThan(315_000_000); + + console.log('✅ Received API key as access token'); + console.log(`API key length: ${tokenResponse.access_token.length}`); + console.log( + `Expires in: ${tokenResponse.expires_in} seconds (~${Math.floor(tokenResponse.expires_in / 31536000)} years)` + ); + + // Verify the API key can be used to authenticate + console.log('Testing API key authentication...'); + const balance = await echoControlApi.getBalance( + tokenResponse.access_token + ); + + expect(balance).toBeDefined(); + expect(typeof balance.balance).toBe('number'); + + console.log('✅ API key successfully used for authentication'); + }); + + test('should return normal JWT when api_key:create scope is NOT requested', async () => { + const codeVerifier = generateCodeVerifier(); + const codeChallenge = generateCodeChallenge(codeVerifier); + const state = generateState(); + + console.log('Getting authorization code WITHOUT api_key:create scope...'); + + const redirectUrl = await echoControlApi.validateOAuthAuthorizeRequest({ + client_id: TEST_CLIENT_IDS.primary, + redirect_uri: 'http://localhost:3000/callback', + state, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + scope: 'llm:invoke offline_access', // No api_key:create + prompt: 'none', + }); + + const callbackUrl = new URL(redirectUrl); + const authCode = callbackUrl.searchParams.get('code'); + + expect(authCode).toBeTruthy(); + + // Exchange authorization code for tokens + const tokenResponse = await echoControlApi.exchangeCodeForToken({ + code: authCode!, + client_id: TEST_CLIENT_IDS.primary, + redirect_uri: 'http://localhost:3000/callback', + code_verifier: codeVerifier, + }); + + // Verify the access token IS a JWT (has 3 parts) + const accessTokenParts = tokenResponse.access_token.split('.'); + expect(accessTokenParts.length).toBe(3); // Is a JWT + + // JWT expiration should be short (not 100 years) + expect(tokenResponse.expires_in).toBeLessThan(31_536_000); // Less than 1 year + + console.log('✅ Received normal JWT token without api_key:create scope'); + }); + + test('should handle mixed scopes correctly (llm:invoke + api_key:create)', async () => { + const codeVerifier = generateCodeVerifier(); + const codeChallenge = generateCodeChallenge(codeVerifier); + const state = generateState(); + + const redirectUrl = await echoControlApi.validateOAuthAuthorizeRequest({ + client_id: TEST_CLIENT_IDS.primary, + redirect_uri: 'http://localhost:3000/callback', + state, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + scope: 'llm:invoke api_key:create', // Mixed scopes + prompt: 'none', + }); + + const callbackUrl = new URL(redirectUrl); + const authCode = callbackUrl.searchParams.get('code'); + + expect(authCode).toBeTruthy(); + + const tokenResponse = await echoControlApi.exchangeCodeForToken({ + code: authCode!, + client_id: TEST_CLIENT_IDS.primary, + redirect_uri: 'http://localhost:3000/callback', + code_verifier: codeVerifier, + }); + + // Should return API key because api_key:create is present + expect(tokenResponse.access_token).toMatch(/^echo_/); + expect(tokenResponse.scope).toContain('llm:invoke'); + expect(tokenResponse.scope).toContain('api_key:create'); + + console.log('✅ Mixed scopes handled correctly'); + }); + }); +}); From 33b503bf743c432358999eb3dfe59fa092c6acec Mon Sep 17 00:00:00 2001 From: Mason Hall Date: Tue, 21 Oct 2025 13:16:53 -0400 Subject: [PATCH 2/6] add styling to make it more obvious --- .../authorize/_components/existing-user/scopes.tsx | 8 ++++++-- packages/app/server/src/handlers.ts | 10 +++++----- .../app/server/src/providers/OpenAIVideoProvider.ts | 4 +++- packages/app/server/src/schema/descriptionForRoute.ts | 11 +++++------ packages/app/server/src/utils.ts | 3 +-- 5 files changed, 20 insertions(+), 16 deletions(-) diff --git a/packages/app/control/src/app/(auth)/(oauth)/(authorize)/oauth/authorize/_components/existing-user/scopes.tsx b/packages/app/control/src/app/(auth)/(oauth)/(authorize)/oauth/authorize/_components/existing-user/scopes.tsx index dca932532..d9957373c 100644 --- a/packages/app/control/src/app/(auth)/(oauth)/(authorize)/oauth/authorize/_components/existing-user/scopes.tsx +++ b/packages/app/control/src/app/(auth)/(oauth)/(authorize)/oauth/authorize/_components/existing-user/scopes.tsx @@ -20,9 +20,10 @@ const Scope = ({ scope }: { scope: string }) => { if (!data) { return null; } - return ( -
  • +
  • {data.name}
  • @@ -33,13 +34,16 @@ const scopeData = { 'llm:invoke': { name: 'Make AI requests', icon: BrainCircuit, + level: 'info', }, offline_access: { name: 'Connect your user profile', icon: User, + level: 'info', }, 'api_key:create': { name: 'Create API keys', icon: Key, + level: 'warn', }, }; diff --git a/packages/app/server/src/handlers.ts b/packages/app/server/src/handlers.ts index 540b5f26e..245e52da4 100644 --- a/packages/app/server/src/handlers.ts +++ b/packages/app/server/src/handlers.ts @@ -157,7 +157,6 @@ export async function handleX402Request({ ); const transaction = transactionResult.transaction; - if (provider.getType() === ProviderType.OPENAI_VIDEOS) { await prisma.videoGenerationX402.create({ data: { @@ -226,17 +225,18 @@ export async function handleApiKeyRequest({ isStream ); - - // There is no actual refund, this logs if we underestimate the raw cost calculateRefundAmount(maxCost, transaction.rawTransactionCost); modelRequestService.handleResolveResponse(res, isStream, data); await echoControlService.createTransaction(transaction, maxCost); - + if (provider.getType() === ProviderType.OPENAI_VIDEOS) { - const transactionCost = await echoControlService.computeTransactionCosts(transaction, null); + const transactionCost = await echoControlService.computeTransactionCosts( + transaction, + null + ); await prisma.videoGenerationX402.create({ data: { videoId: transaction.metadata.providerId, diff --git a/packages/app/server/src/providers/OpenAIVideoProvider.ts b/packages/app/server/src/providers/OpenAIVideoProvider.ts index 155dbf050..06158d5a3 100644 --- a/packages/app/server/src/providers/OpenAIVideoProvider.ts +++ b/packages/app/server/src/providers/OpenAIVideoProvider.ts @@ -228,7 +228,9 @@ export class OpenAIVideoProvider extends BaseProvider { } if (video.userId) { // Proccess the refund to the user. There is some level of complexity here since there is a markup. Not as simple as just credit grant. - logger.info(`Refunding video generation ${video.videoId} to user ${video.userId} on app ${video.echoAppId}`); + logger.info( + `Refunding video generation ${video.videoId} to user ${video.userId} on app ${video.echoAppId}` + ); } await tx.videoGenerationX402.update({ where: { diff --git a/packages/app/server/src/schema/descriptionForRoute.ts b/packages/app/server/src/schema/descriptionForRoute.ts index 60c118a71..4b9412b37 100644 --- a/packages/app/server/src/schema/descriptionForRoute.ts +++ b/packages/app/server/src/schema/descriptionForRoute.ts @@ -1,24 +1,24 @@ export function getDescriptionForRoute(path: string): string | undefined { if (path.endsWith('/videos')) { - return 'Generates videos using OpenAI\'s video generation models. Accepts various parameters to customize video output including prompts and configuration options.'; + return "Generates videos using OpenAI's video generation models. Accepts various parameters to customize video output including prompts and configuration options."; } if (path.endsWith('/images/generations')) { - return 'Creates images using OpenAI\'s image generation models. Supports text-to-image generation with customizable size, quality, and style parameters.'; + return "Creates images using OpenAI's image generation models. Supports text-to-image generation with customizable size, quality, and style parameters."; } if (path.endsWith(':generateContent')) { - return 'Generates images using Gemini\'s image generation models. Processes multimodal input including text and images to produce AI-generated visual content.'; + return "Generates images using Gemini's image generation models. Processes multimodal input including text and images to produce AI-generated visual content."; } if (path.endsWith('/chat/completions')) { return 'Generates conversational AI responses using various language models. Supports streaming, function calling, and multi-turn conversations with customizable parameters.'; } if (path.endsWith('/tavily/search')) { - return 'Performs web searches using Tavily\'s search API. Returns relevant search results with content snippets and metadata for research and information retrieval.'; + return "Performs web searches using Tavily's search API. Returns relevant search results with content snippets and metadata for research and information retrieval."; } if (path.endsWith('/tavily/extract')) { return 'Extracts structured content from web pages using Tavily. Cleans and processes raw HTML into readable, structured text data.'; } if (path.endsWith('/tavily/crawl')) { - return 'Crawls websites and extracts content using Tavily\'s web crawler. Retrieves and processes multiple pages from a domain with configurable depth and filters.'; + return "Crawls websites and extracts content using Tavily's web crawler. Retrieves and processes multiple pages from a domain with configurable depth and filters."; } if (path.endsWith('/e2b/execute')) { return 'Executes code in secure sandboxed environments using E2B. Supports multiple programming languages with isolated execution and real-time output capture.'; @@ -26,4 +26,3 @@ export function getDescriptionForRoute(path: string): string | undefined { return undefined; } - diff --git a/packages/app/server/src/utils.ts b/packages/app/server/src/utils.ts index 01080e88d..f1944774d 100644 --- a/packages/app/server/src/utils.ts +++ b/packages/app/server/src/utils.ts @@ -169,8 +169,7 @@ export async function buildX402Response( nonce: generateRandomNonce(), scheme: X402_SCHEME, resource: resourceUrl, - description: - getDescriptionForRoute(req.path) ?? ECHO_DESCRIPTION, + description: getDescriptionForRoute(req.path) ?? ECHO_DESCRIPTION, mimeType: MIME_TYPE, maxTimeoutSeconds: MAX_TIMEOUT_SECONDS, discoverable: DISCOVERABLE, From ace0f542f1f80eec3394dc142abe584501ed5a83 Mon Sep 17 00:00:00 2001 From: Mason Hall Date: Tue, 21 Oct 2025 13:23:19 -0400 Subject: [PATCH 3/6] add tooltips and styling --- packages/app/control/package.json | 5 ++- .../_components/existing-user/scopes.tsx | 25 ++++++++++- pnpm-lock.yaml | 41 +++++++++++++++---- 3 files changed, 59 insertions(+), 12 deletions(-) diff --git a/packages/app/control/package.json b/packages/app/control/package.json index 047feaf6a..2d7b1c74c 100644 --- a/packages/app/control/package.json +++ b/packages/app/control/package.json @@ -36,6 +36,7 @@ "dependencies": { "@auth/core": "^0.40.0", "@auth/prisma-adapter": "^2.10.0", + "@coinbase/x402": "^0.6.4", "@hookform/resolvers": "^5.2.1", "@icons-pack/react-simple-icons": "^13.7.0", "@merit-systems/sdk": "0.0.8", @@ -65,6 +66,7 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", "@react-email/render": "^1.2.3", "@shikijs/core": "^3.12.2", "@shikijs/engine-javascript": "^3.12.2", @@ -88,7 +90,6 @@ "autonumeric": "^4.10.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "@coinbase/x402": "^0.6.4", "cors": "^2.8.5", "date-fns": "^4.1.0", "dotenv": "^16.4.5", @@ -137,9 +138,9 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@faker-js/faker": "^9.9.0", + "@next/eslint-plugin-next": "^15.5.3", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", - "@next/eslint-plugin-next": "^15.5.3", "@types/node": "^20", "@types/react": "19.1.10", "@types/react-dom": "19.1.7", diff --git a/packages/app/control/src/app/(auth)/(oauth)/(authorize)/oauth/authorize/_components/existing-user/scopes.tsx b/packages/app/control/src/app/(auth)/(oauth)/(authorize)/oauth/authorize/_components/existing-user/scopes.tsx index d9957373c..239f664ab 100644 --- a/packages/app/control/src/app/(auth)/(oauth)/(authorize)/oauth/authorize/_components/existing-user/scopes.tsx +++ b/packages/app/control/src/app/(auth)/(oauth)/(authorize)/oauth/authorize/_components/existing-user/scopes.tsx @@ -1,4 +1,9 @@ -import { BrainCircuit, Key, User } from 'lucide-react'; +import { BrainCircuit, Info, Key, User } from 'lucide-react'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; interface Props { scopes: string[]; @@ -26,6 +31,18 @@ const Scope = ({ scope }: { scope: string }) => { > {data.name} + {data.description && ( + + + + + + + +

    {data.description}

    +
    +
    + )} ); }; @@ -35,15 +52,21 @@ const scopeData = { name: 'Make AI requests', icon: BrainCircuit, level: 'info', + description: + 'You are allowing this app to make AI requests on your behalf.', }, offline_access: { name: 'Connect your user profile', icon: User, level: 'info', + description: + 'You are allowing this app to connect your user profile to your account.', }, 'api_key:create': { name: 'Create API keys', icon: Key, level: 'warn', + description: + 'You are allowing this app to create a long lived access token, which can be revoked at any time in your Echo dashboard.', }, }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd9d05738..6f10403a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -155,6 +155,9 @@ importers: '@radix-ui/react-tabs': specifier: ^1.1.13 version: 1.1.13(@types/react-dom@19.1.7(@types/react@19.1.10))(@types/react@19.1.10)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-tooltip': + specifier: ^1.2.8 + version: 1.2.8(@types/react-dom@19.1.7(@types/react@19.1.10))(@types/react@19.1.10)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@react-email/render': specifier: ^1.2.3 version: 1.2.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -15810,6 +15813,26 @@ snapshots: '@types/react': 19.1.10 '@types/react-dom': 19.1.7(@types/react@19.1.10) + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.1.7(@types/react@19.1.10))(@types/react@19.1.10)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.10)(react@19.1.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.10)(react@19.1.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.7(@types/react@19.1.10))(@types/react@19.1.10)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.10)(react@19.1.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.7(@types/react@19.1.10))(@types/react@19.1.10)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.7(@types/react@19.1.10))(@types/react@19.1.10)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.7(@types/react@19.1.10))(@types/react@19.1.10)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.7(@types/react@19.1.10))(@types/react@19.1.10)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.10)(react@19.1.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.10)(react@19.1.1) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.7(@types/react@19.1.10))(@types/react@19.1.10)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.10 + '@types/react-dom': 19.1.7(@types/react@19.1.10) + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.1.10)(react@19.1.0)': dependencies: react: 19.1.0 @@ -19143,14 +19166,14 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.3(msw@2.11.2(@types/node@20.19.16)(typescript@5.9.2))(vite@6.3.5(@types/node@20.19.16)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.42.0)(tsx@4.20.5)(yaml@2.8.0))': + '@vitest/mocker@3.2.3(msw@2.11.2(@types/node@20.19.16)(typescript@5.9.2))(vite@6.3.5(@types/node@24.3.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.42.0)(tsx@4.20.5)(yaml@2.8.0))': dependencies: '@vitest/spy': 3.2.3 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: msw: 2.11.2(@types/node@20.19.16)(typescript@5.9.2) - vite: 6.3.5(@types/node@20.19.16)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.42.0)(tsx@4.20.5)(yaml@2.8.0) + vite: 6.3.5(@types/node@24.3.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.42.0)(tsx@4.20.5)(yaml@2.8.0) '@vitest/mocker@3.2.3(msw@2.11.2(@types/node@24.3.1)(typescript@5.9.2))(vite@6.3.5(@types/node@24.3.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.42.0)(tsx@4.20.5)(yaml@2.8.0))': dependencies: @@ -22025,7 +22048,7 @@ snapshots: '@typescript-eslint/parser': 8.34.1(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) eslint: 9.35.0(jiti@2.5.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.34.1(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.35.0(jiti@2.5.1)))(eslint@9.35.0(jiti@2.5.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@9.35.0(jiti@2.5.1)) eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.34.1(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0(jiti@2.5.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.35.0(jiti@2.5.1)) eslint-plugin-react: 7.37.5(eslint@9.35.0(jiti@2.5.1)) @@ -22045,7 +22068,7 @@ snapshots: '@typescript-eslint/parser': 8.34.1(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) eslint: 9.35.0(jiti@2.5.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.34.1(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.35.0(jiti@2.5.1)))(eslint@9.35.0(jiti@2.5.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@9.35.0(jiti@2.5.1)) eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.34.1(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.35.0(jiti@2.5.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.35.0(jiti@2.5.1)) eslint-plugin-react: 7.37.5(eslint@9.35.0(jiti@2.5.1)) @@ -22069,7 +22092,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.34.1(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.35.0(jiti@2.5.1)))(eslint@9.35.0(jiti@2.5.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0)(eslint@9.35.0(jiti@2.5.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.1 @@ -22084,14 +22107,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.34.1(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.34.1(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.35.0(jiti@2.5.1)))(eslint@9.35.0(jiti@2.5.1)))(eslint@9.35.0(jiti@2.5.1)): + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.34.1(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0)(eslint@9.35.0(jiti@2.5.1)))(eslint@9.35.0(jiti@2.5.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.34.1(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) eslint: 9.35.0(jiti@2.5.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.34.1(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.35.0(jiti@2.5.1)))(eslint@9.35.0(jiti@2.5.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@9.35.0(jiti@2.5.1)) transitivePeerDependencies: - supports-color @@ -22106,7 +22129,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.35.0(jiti@2.5.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.34.1(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.34.1(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.35.0(jiti@2.5.1)))(eslint@9.35.0(jiti@2.5.1)))(eslint@9.35.0(jiti@2.5.1)) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.34.1(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0)(eslint@9.35.0(jiti@2.5.1)))(eslint@9.35.0(jiti@2.5.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -27880,7 +27903,7 @@ snapshots: dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.3 - '@vitest/mocker': 3.2.3(msw@2.11.2(@types/node@20.19.16)(typescript@5.9.2))(vite@6.3.5(@types/node@20.19.16)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.42.0)(tsx@4.20.5)(yaml@2.8.0)) + '@vitest/mocker': 3.2.3(msw@2.11.2(@types/node@20.19.16)(typescript@5.9.2))(vite@6.3.5(@types/node@24.3.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.42.0)(tsx@4.20.5)(yaml@2.8.0)) '@vitest/pretty-format': 3.2.3 '@vitest/runner': 3.2.3 '@vitest/snapshot': 3.2.3 From b4d4d5c15c00175fbb6e292963e5ff1ea9e1feda Mon Sep 17 00:00:00 2001 From: Mason Hall Date: Tue, 21 Oct 2025 13:23:28 -0400 Subject: [PATCH 4/6] add tooltips and styling --- .../app/control/src/components/ui/tooltip.tsx | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 packages/app/control/src/components/ui/tooltip.tsx diff --git a/packages/app/control/src/components/ui/tooltip.tsx b/packages/app/control/src/components/ui/tooltip.tsx new file mode 100644 index 000000000..887962254 --- /dev/null +++ b/packages/app/control/src/components/ui/tooltip.tsx @@ -0,0 +1,61 @@ +'use client'; + +import * as React from 'react'; +import * as TooltipPrimitive from '@radix-ui/react-tooltip'; + +import { cn } from '@/lib/utils'; + +function TooltipProvider({ + delayDuration = 0, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function Tooltip({ + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function TooltipTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ); +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; From 8957fd0cbaf9fd83d909fbc8a7ac7d5a2ea44037 Mon Sep 17 00:00:00 2001 From: Mason Hall Date: Tue, 21 Oct 2025 13:52:21 -0400 Subject: [PATCH 5/6] add revokability --- .../(app)/_components/keys/table/table.tsx | 81 ++++++++++++++++++- .../src/components/ui/alert-dialog.tsx | 46 ++++++----- 2 files changed, 107 insertions(+), 20 deletions(-) diff --git a/packages/app/control/src/app/(app)/_components/keys/table/table.tsx b/packages/app/control/src/app/(app)/_components/keys/table/table.tsx index ab117ad68..bac75a515 100644 --- a/packages/app/control/src/app/(app)/_components/keys/table/table.tsx +++ b/packages/app/control/src/app/(app)/_components/keys/table/table.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Loader2 } from 'lucide-react'; +import { Loader2, Trash2 } from 'lucide-react'; import { format } from 'date-fns'; @@ -14,11 +14,25 @@ import { TableCell, TableEmpty, } from '@/components/ui/table'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; import { KeyStatus, LoadingKeyStatus } from './status'; import { Skeleton } from '@/components/ui/skeleton'; import { UserAvatar } from '@/components/utils/user-avatar'; +import { api } from '@/trpc/client'; +import { useState } from 'react'; +import { toast } from 'sonner'; interface Key { id: string; @@ -50,7 +64,7 @@ export const KeysTable: React.FC = ({ keys, pagination }) => { {keys.length > 0 ? ( ) : ( - No keys found + No keys found )} ); @@ -70,6 +84,23 @@ const KeyRows = ({ keys }: { keys: Key[] }) => { }; const KeyRow = ({ apiKey }: { apiKey: Key }) => { + const [isOpen, setIsOpen] = useState(false); + const utils = api.useUtils(); + const deleteApiKey = api.user.apiKeys.delete.useMutation({ + onSuccess: () => { + toast.success('API key revoked successfully'); + void utils.user.apiKeys.list.invalidate(); + }, + onError: error => { + toast.error(error.message || 'Failed to revoke API key'); + }, + }); + + const handleRevoke = () => { + deleteApiKey.mutate(apiKey.id); + setIsOpen(false); + }; + return ( {apiKey.name} @@ -89,6 +120,48 @@ const KeyRow = ({ apiKey }: { apiKey: Key }) => { + + + + + + + + Revoke API Key + + Are you sure you want to revoke this API key? This action cannot + be undone. Any applications using this key will no longer be + able to authenticate. + + + + Cancel + + {deleteApiKey.isPending ? ( + <> + + Revoking... + + ) : ( + 'Revoke' + )} + + + + + ); }; @@ -111,6 +184,9 @@ const LoadingKeyRow = () => { + + + ); }; @@ -131,6 +207,7 @@ const BaseKeysTable = ({ children, pagination }: BaseKeysTableProps) => { Last Used Created At Status + {children} diff --git a/packages/app/control/src/components/ui/alert-dialog.tsx b/packages/app/control/src/components/ui/alert-dialog.tsx index e2e20e992..f4a651e13 100644 --- a/packages/app/control/src/components/ui/alert-dialog.tsx +++ b/packages/app/control/src/components/ui/alert-dialog.tsx @@ -12,13 +12,19 @@ function AlertDialog({ return ; } -// function AlertDialogTrigger({ -// ...props -// }: React.ComponentProps) { -// return ( -// -// ); -// } +const AlertDialogTrigger = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ ...props }, ref) => { + return ( + + ); +}); +AlertDialogTrigger.displayName = 'AlertDialogTrigger'; function AlertDialogPortal({ ...props @@ -130,24 +136,28 @@ function AlertDialogAction({ ); } -// function AlertDialogCancel({ -// className, -// ...props -// }: React.ComponentProps) { -// return ( -// -// ); -// } +const AlertDialogCancel = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ); +}); +AlertDialogCancel.displayName = 'AlertDialogCancel'; export { AlertDialog, + AlertDialogTrigger, AlertDialogContent, AlertDialogHeader, AlertDialogFooter, AlertDialogTitle, AlertDialogDescription, AlertDialogAction, + AlertDialogCancel, }; From ee36adc18f6a96d92519a7d2dd776ecb1833ca36 Mon Sep 17 00:00:00 2001 From: Mason Hall Date: Tue, 21 Oct 2025 14:00:52 -0400 Subject: [PATCH 6/6] knip --- packages/app/control/src/components/ui/tooltip.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/control/src/components/ui/tooltip.tsx b/packages/app/control/src/components/ui/tooltip.tsx index 887962254..b86605bb4 100644 --- a/packages/app/control/src/components/ui/tooltip.tsx +++ b/packages/app/control/src/components/ui/tooltip.tsx @@ -58,4 +58,4 @@ function TooltipContent({ ); } -export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; +export { Tooltip, TooltipTrigger, TooltipContent };