diff --git a/src/app/(auth)/sign-in/page.tsx b/src/app/(auth)/sign-in/page.tsx index e0ad7d62..4ef84e4d 100644 --- a/src/app/(auth)/sign-in/page.tsx +++ b/src/app/(auth)/sign-in/page.tsx @@ -6,7 +6,6 @@ import { OAuthProviders } from '@/features/auth/oauth-provider-buttons' import TextSeparator from '@/ui/text-separator' import { Button } from '@/ui/primitives/button' import { Input } from '@/ui/primitives/input' -import { Label } from '@/ui/primitives/label' import { AUTH_URLS } from '@/configs/urls' import Link from 'next/link' import { useSearchParams } from 'next/navigation' diff --git a/src/configs/constants.ts b/src/configs/api.ts similarity index 85% rename from src/configs/constants.ts rename to src/configs/api.ts index 6796ce98..fc3406b5 100644 --- a/src/configs/constants.ts +++ b/src/configs/api.ts @@ -7,3 +7,5 @@ export const SUPABASE_AUTH_HEADERS = (token: string, teamId?: string) => ({ [SUPABASE_TOKEN_HEADER]: token, ...(teamId && { [SUPABASE_TEAM_HEADER]: teamId }), }) + +export const CLI_GENERATED_KEY_NAME = 'CLI login/configure' diff --git a/src/features/dashboard/keys/create-api-key-dialog.tsx b/src/features/dashboard/keys/create-api-key-dialog.tsx index bb055d8a..123b685c 100644 --- a/src/features/dashboard/keys/create-api-key-dialog.tsx +++ b/src/features/dashboard/keys/create-api-key-dialog.tsx @@ -68,7 +68,7 @@ const CreateApiKeyDialog: FC = ({ const { execute: createApiKey, isPending } = useAction(createApiKeyAction, { onSuccess: ({ data }) => { if (data?.createdApiKey) { - setCreatedApiKey(data.createdApiKey) + setCreatedApiKey(data.createdApiKey.key) form.reset() } }, diff --git a/src/features/dashboard/keys/table-body.tsx b/src/features/dashboard/keys/table-body.tsx index 319646fb..3f6b74d5 100644 --- a/src/features/dashboard/keys/table-body.tsx +++ b/src/features/dashboard/keys/table-body.tsx @@ -4,6 +4,9 @@ import { TableCell, TableRow } from '@/ui/primitives/table' import ApiKeyTableRow from './table-row' import { bailOutFromPPR } from '@/lib/utils/server' import { ErrorIndicator } from '@/ui/error-indicator' +import { CLI_GENERATED_KEY_NAME } from '@/configs/api' +import { Separator } from '@/ui/primitives/separator' +import TextSeparator from '@/ui/text-separator' interface TableBodyContentProps { teamId: string @@ -17,16 +20,17 @@ export default async function TableBodyContent({ const result = await getTeamApiKeys({ teamId }) if (!result?.data || result.serverError || result.validationErrors) { - ; - - - - - return + return ( + + + + + + ) } const { apiKeys } = result.data @@ -46,11 +50,23 @@ export default async function TableBodyContent({ ) } + const normalKeys = apiKeys.filter( + (key) => key.name !== CLI_GENERATED_KEY_NAME + ) + const cliKeys = apiKeys.filter((key) => key.name === CLI_GENERATED_KEY_NAME) + return ( <> - {apiKeys.map((key, index) => ( + {normalKeys.map((key, index) => ( ))} + {cliKeys.map((key, index) => ( + + ))} ) } diff --git a/src/features/dashboard/keys/table-row.tsx b/src/features/dashboard/keys/table-row.tsx index 670d7d1b..678672d0 100644 --- a/src/features/dashboard/keys/table-row.tsx +++ b/src/features/dashboard/keys/table-row.tsx @@ -12,7 +12,6 @@ import { DropdownMenuLabel, DropdownMenuTrigger, } from '@/ui/primitives/dropdown-menu' -import { ObscuredApiKey } from '@/server/keys/types' import { deleteApiKeyAction } from '@/server/keys/key-actions' import { AlertDialog } from '@/ui/alert-dialog' import { useState } from 'react' @@ -21,13 +20,19 @@ import { motion } from 'motion/react' import { exponentialSmoothing } from '@/lib/utils' import { useAction } from 'next-safe-action/hooks' import { defaultSuccessToast, defaultErrorToast } from '@/lib/hooks/use-toast' +import { TeamAPIKey } from '@/types/api' interface TableRowProps { - apiKey: ObscuredApiKey + apiKey: TeamAPIKey index: number + className?: string } -export default function ApiKeyTableRow({ apiKey, index }: TableRowProps) { +export default function ApiKeyTableRow({ + apiKey, + index, + className, +}: TableRowProps) { const { toast } = useToast() const selectedTeam = useSelectedTeam() const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) @@ -63,6 +68,8 @@ export default function ApiKeyTableRow({ apiKey, index }: TableRowProps) { }) } + const concatedKeyMask = `${apiKey.mask.prefix}${apiKey.mask.maskedValuePrefix}......${apiKey.mask.maskedValueSuffix}` + return ( <> setHoveredRowIndex(index)} onMouseLeave={() => setHoveredRowIndex(-1)} + className={className} > - + {apiKey.name} - {apiKey.maskedKey} + + {concatedKeyMask} + - {apiKey.createdBy} + {apiKey.createdBy?.email} {apiKey.createdAt diff --git a/src/lib/clients/action.ts b/src/lib/clients/action.ts index eed8a28b..af866871 100644 --- a/src/lib/clients/action.ts +++ b/src/lib/clients/action.ts @@ -14,8 +14,26 @@ const BLACKLISTED_INPUT_KEYS = [ 'secret', 'token', 'apiKey', + 'key', ] +function sanitizeObject(data: unknown, blacklist: string[]): unknown { + if (typeof data === 'object' && data !== null && !Array.isArray(data)) { + const sanitized = { ...(data as Record) } + for (const key in sanitized) { + if (blacklist.includes(key)) { + sanitized[key] = '[REDACTED]' + } else { + sanitized[key] = sanitizeObject(sanitized[key], blacklist) + } + } + return sanitized + } else if (Array.isArray(data)) { + return data.map((item) => sanitizeObject(item, blacklist)) + } + return data +} + export const actionClient = createSafeActionClient({ handleServerError(e) { if (e instanceof ActionError) { @@ -60,41 +78,11 @@ export const actionClient = createSafeActionClient({ // filter out blacklisted keys from clientInput for logging let sanitizedInput: unknown = clientInput + sanitizedInput = sanitizeObject(clientInput, BLACKLISTED_INPUT_KEYS) - // handle object case - if ( - typeof clientInput === 'object' && - clientInput !== null && - !Array.isArray(clientInput) - ) { - sanitizedInput = { ...(clientInput as Record) } - const sanitizedObj = sanitizedInput as Record - - for (const key of BLACKLISTED_INPUT_KEYS) { - if (key in sanitizedObj) { - sanitizedObj[key] = '[REDACTED]' - } - } - } - // handle array case - else if (Array.isArray(clientInput)) { - sanitizedInput = [...clientInput] - const sanitizedArray = sanitizedInput as unknown[] - - // check if any array elements are objects that need sanitizing - for (let i = 0; i < sanitizedArray.length; i++) { - const item = sanitizedArray[i] - if (typeof item === 'object' && item !== null) { - const sanitizedItem = { ...(item as Record) } - for (const key of BLACKLISTED_INPUT_KEYS) { - if (key in sanitizedItem) { - sanitizedItem[key] = '[REDACTED]' - } - } - sanitizedArray[i] = sanitizedItem - } - } - } + // Sanitize result object + let sanitizedRest: unknown = rest + sanitizedRest = sanitizeObject(rest, BLACKLISTED_INPUT_KEYS) if ( result.serverError || @@ -102,12 +90,12 @@ export const actionClient = createSafeActionClient({ result.success === false ) { logError(`${actionOrFunction} '${actionOrFunctionName}' failed:`, { - result: rest, + result: sanitizedRest, input: sanitizedInput, }) } else if (VERBOSE) { logSuccess(`${actionOrFunction} '${actionOrFunctionName}' succeeded:`, { - result: rest, + result: sanitizedRest, input: sanitizedInput, }) } diff --git a/src/lib/utils/server.ts b/src/lib/utils/server.ts index 26ba6c93..be7d6966 100644 --- a/src/lib/utils/server.ts +++ b/src/lib/utils/server.ts @@ -17,7 +17,7 @@ import { kv } from '@/lib/clients/kv' import { KV_KEYS } from '@/configs/keys' import { ERROR_CODES, INFO_CODES } from '@/configs/logs' import { getEncryptedCookie } from './cookies' -import { SUPABASE_AUTH_HEADERS } from '@/configs/constants' +import { SUPABASE_AUTH_HEADERS } from '@/configs/api' import { CreatedAccessToken } from '@/types/api' /* diff --git a/src/server/billing/billing-actions.ts b/src/server/billing/billing-actions.ts index d81ae77d..9b61b647 100644 --- a/src/server/billing/billing-actions.ts +++ b/src/server/billing/billing-actions.ts @@ -1,6 +1,6 @@ 'use server' -import { SUPABASE_AUTH_HEADERS } from '@/configs/constants' +import { SUPABASE_AUTH_HEADERS } from '@/configs/api' import { authActionClient } from '@/lib/clients/action' import { returnServerError } from '@/lib/utils/action' import { CustomerPortalResponse } from '@/types/billing' diff --git a/src/server/billing/get-billing-limits.ts b/src/server/billing/get-billing-limits.ts index 518ab777..ab3d87bc 100644 --- a/src/server/billing/get-billing-limits.ts +++ b/src/server/billing/get-billing-limits.ts @@ -2,7 +2,7 @@ import 'server-only' import { z } from 'zod' import { BillingLimit } from '@/types/billing' -import { SUPABASE_AUTH_HEADERS } from '@/configs/constants' +import { SUPABASE_AUTH_HEADERS } from '@/configs/api' import { authActionClient } from '@/lib/clients/action' const GetBillingLimitsParamsSchema = z.object({ @@ -32,7 +32,7 @@ export const getBillingLimits = authActionClient throw new Error( text ?? - `Failed to fetch billing endpoint: /teams/${teamId}/billing-limits` + `Failed to fetch billing endpoint: /teams/${teamId}/billing-limits` ) } diff --git a/src/server/billing/get-invoices.ts b/src/server/billing/get-invoices.ts index 19903125..f2094577 100644 --- a/src/server/billing/get-invoices.ts +++ b/src/server/billing/get-invoices.ts @@ -2,7 +2,7 @@ import 'server-only' import { Invoice } from '@/types/billing' import { z } from 'zod' -import { SUPABASE_AUTH_HEADERS } from '@/configs/constants' +import { SUPABASE_AUTH_HEADERS } from '@/configs/api' import { authActionClient } from '@/lib/clients/action' const GetInvoicesParamsSchema = z.object({ diff --git a/src/server/keys/get-api-keys.ts b/src/server/keys/get-api-keys.ts index 6920e7d0..ea080d7c 100644 --- a/src/server/keys/get-api-keys.ts +++ b/src/server/keys/get-api-keys.ts @@ -1,12 +1,13 @@ import 'server-only' import { z } from 'zod' -import { supabaseAdmin } from '@/lib/clients/supabase/admin' -import { maskApiKey } from '@/lib/utils/server' -import { checkUserTeamAuthorization } from '@/lib/utils/server' -import { ObscuredApiKey } from './types' +import { getApiUrl } from '@/lib/utils/server' import { authActionClient } from '@/lib/clients/action' import { returnServerError } from '@/lib/utils/action' +import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { logError } from '@/lib/clients/logger' +import { ERROR_CODES } from '@/configs/logs' +import { TeamAPIKey } from '@/types/api' const GetApiKeysSchema = z.object({ teamId: z.string({ required_error: 'Team ID is required' }).uuid(), @@ -17,45 +18,29 @@ export const getTeamApiKeys = authActionClient .metadata({ serverFunctionName: 'getTeamApiKeys' }) .action(async ({ parsedInput, ctx }) => { const { teamId } = parsedInput - const { user } = ctx + const { session } = ctx - const isAuthorized = await checkUserTeamAuthorization(user.id, teamId) + const accessToken = session.access_token - if (!isAuthorized) - return returnServerError('Not authorized to edit team api keys') + const { url } = await getApiUrl() - const { data, error } = await supabaseAdmin - .from('team_api_keys') - .select('*') - .eq('team_id', teamId) - .order('created_at', { ascending: true }) + const response = await fetch(`${url}/api-keys`, { + headers: { + 'Content-Type': 'application/json', + ...SUPABASE_AUTH_HEADERS(accessToken, teamId), + }, + }) - if (error) throw error - - const resultApiKeys: ObscuredApiKey[] = [] - - for (const apiKey of data) { - let userEmail: string | null = null - - if (apiKey.created_by) { - const { data: keyUserData } = await supabaseAdmin - .from('auth_users') - .select('email') - .eq('id', apiKey.created_by) - - if (keyUserData && keyUserData[0]) { - userEmail = keyUserData[0].email - } - } - - resultApiKeys.push({ - id: apiKey.id, - name: apiKey.name, - maskedKey: maskApiKey(apiKey), - createdAt: apiKey.created_at, - createdBy: userEmail, + if (!response.ok) { + const text = await response.text() + logError(ERROR_CODES.INFRA, 'Failed to get api keys', { + teamId, + error: text, }) + return returnServerError('Failed to get api keys') } - return { apiKeys: resultApiKeys } + const data = (await response.json()) as TeamAPIKey[] + + return { apiKeys: data } }) diff --git a/src/server/keys/key-actions.ts b/src/server/keys/key-actions.ts index 58549354..b08ffe33 100644 --- a/src/server/keys/key-actions.ts +++ b/src/server/keys/key-actions.ts @@ -1,13 +1,15 @@ 'use server' -import { API_KEY_PREFIX } from '@/configs/constants' -import { checkUserTeamAuthorization } from '@/lib/utils/server' +import { checkUserTeamAuthorization, getApiUrl } from '@/lib/utils/server' import { supabaseAdmin } from '@/lib/clients/supabase/admin' import { z } from 'zod' import { revalidatePath } from 'next/cache' -import { InvalidParametersError } from '@/types/errors' import { authActionClient } from '@/lib/clients/action' import { returnServerError } from '@/lib/utils/action' +import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { logError } from '@/lib/clients/logger' +import { ERROR_CODES } from '@/configs/logs' +import { CreatedTeamAPIKey } from '@/types/api' // Create API Key @@ -20,49 +22,42 @@ const CreateApiKeySchema = z.object({ .trim(), }) -export async function generateTeamApiKey(): Promise { - const randomBytes = crypto.getRandomValues(new Uint8Array(20)) - const hexString = Array.from(randomBytes) - .map((b) => b.toString(16).padStart(2, '0')) - .join('') - - return API_KEY_PREFIX + hexString -} - export const createApiKeyAction = authActionClient .schema(CreateApiKeySchema) .metadata({ actionName: 'createApiKey' }) .action(async ({ parsedInput, ctx }) => { const { teamId, name } = parsedInput - const { user, supabase } = ctx - - const isAuthorized = await checkUserTeamAuthorization(user.id, teamId) - - if (!isAuthorized) - return returnServerError('Not authorized to create team api keys') - - const apiKeyValue = await generateTeamApiKey() - - const { error } = await supabaseAdmin - .from('team_api_keys') - .insert({ - team_id: teamId, - name: name, - api_key: apiKeyValue, - created_by: user.id, - created_at: new Date().toISOString(), + const { session } = ctx + + const accessToken = session.access_token + + const { url } = await getApiUrl() + + const apiKeyResponse = await fetch(`${url}/api-keys`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...SUPABASE_AUTH_HEADERS(accessToken, teamId), + }, + body: JSON.stringify({ name }), + }) + + if (!apiKeyResponse.ok) { + const text = await apiKeyResponse.text() + logError(ERROR_CODES.INFRA, 'Failed to create api key', { + teamId, + name, + error: text, }) - .select() - .single() - - if (error) { - throw error + return returnServerError('Failed to create api key') } + const apiKeyData = (await apiKeyResponse.json()) as CreatedTeamAPIKey + revalidatePath(`/dashboard/[teamIdOrSlug]/keys`, 'page') return { - createdApiKey: apiKeyValue, + createdApiKey: apiKeyData, } }) @@ -78,37 +73,29 @@ export const deleteApiKeyAction = authActionClient .metadata({ actionName: 'deleteApiKey' }) .action(async ({ parsedInput, ctx }) => { const { teamId, apiKeyId } = parsedInput - const { user, supabase } = ctx - - const isAuthorized = await checkUserTeamAuthorization(user.id, teamId) - - if (!isAuthorized) { - return returnServerError('Not authorized to delete team api keys') - } - - const { data: apiKeys, error: fetchError } = await supabaseAdmin - .from('team_api_keys') - .select('id') - .eq('team_id', teamId) - - if (fetchError) { - throw fetchError - } - - if (apiKeys.length === 1) { - return returnServerError( - 'A team must have at least one API key. Please create a new API key before deleting this one.' - ) - } - - const { error } = await supabaseAdmin - .from('team_api_keys') - .delete() - .eq('team_id', teamId) - .eq('id', apiKeyId) + const { session } = ctx + + const accessToken = session.access_token + + const { url } = await getApiUrl() + + const response = await fetch(`${url}/api-keys/${apiKeyId}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + ...SUPABASE_AUTH_HEADERS(accessToken, teamId), + }, + }) + + if (!response.ok) { + const text = await response.text() + logError(ERROR_CODES.INFRA, 'Failed to delete api key', { + teamId, + apiKeyId, + error: text, + }) - if (error) { - throw error + return returnServerError(text) } revalidatePath(`/dashboard/[teamIdOrSlug]/keys`, 'page') diff --git a/src/server/sandboxes/get-team-sandboxes.ts b/src/server/sandboxes/get-team-sandboxes.ts index 0aea801d..b5f0a877 100644 --- a/src/server/sandboxes/get-team-sandboxes.ts +++ b/src/server/sandboxes/get-team-sandboxes.ts @@ -8,7 +8,7 @@ import { authActionClient } from '@/lib/clients/action' import { returnServerError } from '@/lib/utils/action' import { getApiUrl } from '@/lib/utils/server' import { Sandbox } from '@/types/api' -import { SUPABASE_AUTH_HEADERS } from '@/configs/constants' +import { SUPABASE_AUTH_HEADERS } from '@/configs/api' const GetTeamSandboxesSchema = z.object({ teamId: z.string().uuid(), diff --git a/src/server/team/team-actions.ts b/src/server/team/team-actions.ts index feae921a..63de5aae 100644 --- a/src/server/team/team-actions.ts +++ b/src/server/team/team-actions.ts @@ -14,7 +14,7 @@ import { zfd } from 'zod-form-data' import { logWarning } from '@/lib/clients/logger' import { returnValidationErrors } from 'next-safe-action' import { getTeam } from './get-team' -import { SUPABASE_AUTH_HEADERS } from '@/configs/constants' +import { SUPABASE_AUTH_HEADERS } from '@/configs/api' import { CreateTeamSchema, UpdateTeamNameSchema } from '@/server/team/types' import { CreateTeamsResponse } from '@/types/billing' diff --git a/src/server/templates/get-team-templates.ts b/src/server/templates/get-team-templates.ts index 50c248e6..ed3eb1a5 100644 --- a/src/server/templates/get-team-templates.ts +++ b/src/server/templates/get-team-templates.ts @@ -12,7 +12,7 @@ import { ERROR_CODES } from '@/configs/logs' import { supabaseAdmin } from '@/lib/clients/supabase/admin' import { actionClient, authActionClient } from '@/lib/clients/action' import { returnServerError } from '@/lib/utils/action' -import { SUPABASE_AUTH_HEADERS } from '@/configs/constants' +import { SUPABASE_AUTH_HEADERS } from '@/configs/api' const GetTeamTemplatesSchema = z.object({ teamId: z.string().uuid(), diff --git a/src/server/templates/templates-actions.ts b/src/server/templates/templates-actions.ts index 9df52460..75da2d3f 100644 --- a/src/server/templates/templates-actions.ts +++ b/src/server/templates/templates-actions.ts @@ -5,7 +5,7 @@ import { getApiUrl } from '@/lib/utils/server' import { revalidatePath } from 'next/cache' import { authActionClient } from '@/lib/clients/action' import { returnServerError } from '@/lib/utils/action' -import { SUPABASE_AUTH_HEADERS } from '@/configs/constants' +import { SUPABASE_AUTH_HEADERS } from '@/configs/api' const DeleteTemplateParamsSchema = z.object({ templateId: z.string(), diff --git a/src/server/usage/get-usage.ts b/src/server/usage/get-usage.ts index a4f89c79..3d172997 100644 --- a/src/server/usage/get-usage.ts +++ b/src/server/usage/get-usage.ts @@ -9,7 +9,7 @@ import { import { z } from 'zod' import { authActionClient } from '@/lib/clients/action' import { returnServerError } from '@/lib/utils/action' -import { SUPABASE_AUTH_HEADERS } from '@/configs/constants' +import { SUPABASE_AUTH_HEADERS } from '@/configs/api' import { UsageResponse } from '@/types/billing' const GetUsageAuthActionSchema = z.object({ diff --git a/src/types/api.d.ts b/src/types/api.d.ts index 70722dd8..909bcd04 100644 --- a/src/types/api.d.ts +++ b/src/types/api.d.ts @@ -38,12 +38,44 @@ interface SandboxMetrics { timestamp: string } +interface TeamUser { + id: string + email: string +} + +interface IdentifierMaskingDetails { + prefix: string + valueLength: number + maskedValuePrefix: string + maskedValueSuffix: string +} + interface CreatedAccessToken { id: string name: string token: string - tokenMask: string + mask: IdentifierMaskingDetails + createdAt: string + createdBy: TeamUser | null +} + +interface CreatedTeamAPIKey { + id: string + name: string + key: string + mask: IdentifierMaskingDetails + createdAt: string + createdBy: TeamUser | null + lastUsed: string | null +} + +interface TeamAPIKey { + id: string + name: string + mask: IdentifierMaskingDetails createdAt: string + createdBy: TeamUser | null + lastUsed: string | null } export type { @@ -52,4 +84,8 @@ export type { SandboxMetrics, DefaultTemplate, CreatedAccessToken, + CreatedTeamAPIKey, + TeamAPIKey, + TeamUser, + IdentifierMaskingDetails, } diff --git a/src/ui/text-separator.tsx b/src/ui/text-separator.tsx index 06bb106f..c806b36b 100644 --- a/src/ui/text-separator.tsx +++ b/src/ui/text-separator.tsx @@ -1,15 +1,24 @@ +import { cn } from '@/lib/utils' import { Separator } from './primitives/separator' interface TextSeparatorProps { text: string + classNames?: { + text?: string + } } -export default function TextSeparator({ text }: TextSeparatorProps) { +export default function TextSeparator({ + text, + classNames, +}: TextSeparatorProps) { return (
- - {text} - + + + {text} + +
) }