Skip to content

Refactor: api key creation/retrieval/deletion to use infra api #68

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

1 change: 0 additions & 1 deletion src/app/(auth)/sign-in/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 2 additions & 0 deletions src/configs/constants.ts → src/configs/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
2 changes: 1 addition & 1 deletion src/features/dashboard/keys/create-api-key-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ const CreateApiKeyDialog: FC<CreateApiKeyDialogProps> = ({
const { execute: createApiKey, isPending } = useAction(createApiKeyAction, {
onSuccess: ({ data }) => {
if (data?.createdApiKey) {
setCreatedApiKey(data.createdApiKey)
setCreatedApiKey(data.createdApiKey.key)
form.reset()
}
},
Expand Down
52 changes: 40 additions & 12 deletions src/features/dashboard/keys/table-body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -17,16 +20,17 @@ export default async function TableBodyContent({
const result = await getTeamApiKeys({ teamId })

if (!result?.data || result.serverError || result.validationErrors) {
;<TableRow>
<TableCell colSpan={5}>
<ErrorIndicator
description={'Could not load API keys'}
message={result?.serverError || 'Unknown error'}
className="bg-bg mt-2 w-full max-w-full"
/>
</TableCell>
</TableRow>
return
return (
<TableRow>
<TableCell colSpan={5}>
<ErrorIndicator
description={'Could not load API keys'}
message={result?.serverError || 'Unknown error'}
className="bg-bg mt-2 w-full max-w-full"
/>
</TableCell>
</TableRow>
)
}

const { apiKeys } = result.data
Expand All @@ -46,10 +50,34 @@ 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) => (
<ApiKeyTableRow key={key.id} apiKey={key} index={index} />
{normalKeys.map((key, index) => (
<ApiKeyTableRow
key={key.id}
apiKey={key}
index={index}
className={index === normalKeys.length - 1 ? 'border-none' : ''}
/>
))}
{cliKeys.length > 0 && normalKeys.length > 0 && (
<TableRow className="border-none">
<TableCell colSpan={5}>
<Separator className="my-3" />
</TableCell>
</TableRow>
)}
{cliKeys.map((key, index) => (
<ApiKeyTableRow
key={key.id}
apiKey={key}
index={index + normalKeys.length}
/>
))}
</>
)
Expand Down
22 changes: 16 additions & 6 deletions src/features/dashboard/keys/table-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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)
Expand Down Expand Up @@ -63,6 +68,8 @@ export default function ApiKeyTableRow({ apiKey, index }: TableRowProps) {
})
}

const concatedKeyMask = `${apiKey.mask.prefix}${apiKey.mask.maskedValuePrefix}......${apiKey.mask.maskedValueSuffix}`

return (
<>
<AlertDialog
Expand All @@ -82,13 +89,16 @@ export default function ApiKeyTableRow({ apiKey, index }: TableRowProps) {
key={`${apiKey.name}-${index}`}
onMouseEnter={() => setHoveredRowIndex(index)}
onMouseLeave={() => setHoveredRowIndex(-1)}
className={className}
>
<TableCell className="flex flex-col gap-1 text-left font-mono">
<TableCell className="text-lef flex flex-col gap-1">
{apiKey.name}
<span className="text-fg-500 pl-1">{apiKey.maskedKey}</span>
<span className="text-fg-500 pl-0.25 font-mono text-xs">
{concatedKeyMask}
</span>
</TableCell>
<TableCell className="text-fg-500 max-w-36 truncate overflow-hidden">
<span className="max-w-full truncate">{apiKey.createdBy}</span>
<span className="max-w-full truncate">{apiKey.createdBy?.email}</span>
</TableCell>
<TableCell className="text-fg-300 text-right">
{apiKey.createdAt
Expand Down
60 changes: 24 additions & 36 deletions src/lib/clients/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) }
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) {
Expand Down Expand Up @@ -60,54 +78,24 @@ 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<string, unknown>) }
const sanitizedObj = sanitizedInput as Record<string, unknown>

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<string, unknown>) }
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 ||
result.validationErrors ||
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,
})
}
Expand Down
2 changes: 1 addition & 1 deletion src/lib/utils/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/*
Expand Down
2 changes: 1 addition & 1 deletion src/server/billing/billing-actions.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
4 changes: 2 additions & 2 deletions src/server/billing/get-billing-limits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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`
)
}

Expand Down
2 changes: 1 addition & 1 deletion src/server/billing/get-invoices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
61 changes: 23 additions & 38 deletions src/server/keys/get-api-keys.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand All @@ -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 }
})
Loading