Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion apps/sim/app/api/auth/[...all]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,17 @@ vi.mock('@/lib/credential-groups/oauth-state', () => ({
}))

vi.mock('@/lib/credential-groups/providers', () => ({
CREDENTIAL_GROUP_PROVIDER_IDS: ['gmail', 'google-calendar', 'confluence', 'jira', 'slack'],
CREDENTIAL_GROUP_PROVIDER_IDS: [
'gmail',
'google-calendar',
'confluence',
'jira',
'slack',
'fireflies',
],
CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS: ['gmail', 'google-calendar', 'confluence', 'jira'],
CREDENTIAL_GROUP_API_KEY_PROVIDER_IDS: ['fireflies'],
isCredentialGroupApiKeyProvider: (provider: string) => provider === 'fireflies',
getCredentialGroupStandardOAuthProviderFromProviderId: (providerId: string) => {
const providers: Record<string, string> = {
'google-email': 'gmail',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { submitCredentialGroupApiKeyContract } from '@/lib/api/contracts/credential-groups'
import { parseRequest } from '@/lib/api/server'
import { asOrchestrationError } from '@/lib/core/orchestration/types'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { CredentialGroupApiKeyVerificationError } from '@/lib/credential-groups/api-key-providers/types'
import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth'
import { submitPublicCredentialGroupApiKey } from '@/lib/credential-groups/application/public-enrollment'
import { CredentialGroupOAuthError } from '@/lib/credential-groups/provider-adapter'
import {
enforceCredentialGroupEnrollmentOAuthRateLimit,
enforcePublicCredentialGroupIpRateLimit,
} from '@/lib/credential-groups/rate-limit'
import { ManagedApiKeyFormatError } from '@/lib/credentials/managed-api-key'

export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'

const UNAVAILABLE = 'This invitation is invalid, expired, or has been revoked.'

/**
* Accepts one API key from an invited person.
*
* A JSON route rather than the redirect-based flow its OAuth sibling uses: the submitting
* form is a client component that renders the rejection inline against the field, so the
* answer has to come back in the response instead of a query parameter.
*/
export const POST = withRouteHandler(
async (
request: NextRequest,
context: { params: Promise<{ token: string; optionId: string }> }
) => {
const limited = await enforcePublicCredentialGroupIpRateLimit(request, 'api-key-submit')
if (limited) return limited

const parsed = await parseRequest(submitCredentialGroupApiKeyContract, request, context)
if (!parsed.success) return parsed.response
const { token, optionId } = parsed.data.params

const principal = await authenticateCredentialGroupEnrollment(token)
if (!principal) return NextResponse.json({ error: UNAVAILABLE }, { status: 404 })

const enrollmentLimited = await enforceCredentialGroupEnrollmentOAuthRateLimit(
principal.enrollmentId
)
if (enrollmentLimited) return enrollmentLimited

try {
const result = await submitPublicCredentialGroupApiKey.execute({
principal,
input: { invitationToken: token, optionId, fields: parsed.data.body.fields },
request,
})
return NextResponse.json(result)
} catch (error) {
// The verifier's message names what the provider said and is written for the person
// holding the invitation, so it is surfaced verbatim rather than flattened to a 500.
if (error instanceof CredentialGroupApiKeyVerificationError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
if (error instanceof ManagedApiKeyFormatError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
if (error instanceof CredentialGroupOAuthError) {
return NextResponse.json({ error: error.message }, { status: error.statusCode })
}
const orchestration = asOrchestrationError(error)
if (orchestration?.code === 'not_found') {
return NextResponse.json({ error: UNAVAILABLE }, { status: 404 })
}
if (orchestration?.code === 'validation') {
return NextResponse.json({ error: orchestration.message }, { status: 400 })
}
throw error
}
}
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
'use client'

import { useState } from 'react'
import {
Chip,
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
} from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import { useRouter } from 'next/navigation'
import {
type CredentialGroupApiKeyProvider,
getCredentialGroupApiKeyFields,
getCredentialGroupApiKeyLocation,
getCredentialGroupProviderPresentation,
} from '@/lib/credential-groups/providers'
import { useSubmitCredentialGroupApiKey } from '@/hooks/queries/credential-group-enrollment'

interface ApiKeyConnectModalProps {
token: string
optionId: string
/**
* The provider id rather than its resolved presentation: this renders from a server
* component, and an icon is a function, which cannot cross that boundary. Everything the
* modal needs is derived here on the client from this one serializable value.
*/
provider: CredentialGroupApiKeyProvider
connected: boolean
}

/**
* Collects the values one API-key option needs.
*
* The row itself offers a plain Connect action so an API-key account reads the same as an
* OAuth one; the difference — that this service hands you a key instead of a sign-in — belongs
* inside the modal, next to the link explaining where to find it.
*/
export function ApiKeyConnectModal({
token,
optionId,
provider,
connected,
}: ApiKeyConnectModalProps) {
const { name: serviceName, icon: Icon } = getCredentialGroupProviderPresentation(provider)
const fields = getCredentialGroupApiKeyFields(provider)
const keyLocation = getCredentialGroupApiKeyLocation(provider)
const router = useRouter()
const submit = useSubmitCredentialGroupApiKey(token, optionId)
const [open, setOpen] = useState(false)
const [values, setValues] = useState<Record<string, string>>({})
const [error, setError] = useState<string | null>(null)

const handleOpenChange = (next: boolean) => {
if (submit.isPending) return
setOpen(next)
if (!next) {
setValues({})
setError(null)
}
}

const handleSubmit = async () => {
const missing = fields.find((field) => !(values[field.id] ?? '').trim())
if (missing) {
setError(`${missing.label} is required.`)
return
}
setError(null)
try {
await submit.mutateAsync({
fields: Object.fromEntries(fields.map((field) => [field.id, values[field.id].trim()])),
})
handleOpenChange(false)
router.refresh()
} catch (err) {
setError(getErrorMessage(err, 'Those credentials could not be verified. Please try again.'))
}
}

return (
<>
<Chip onClick={() => setOpen(true)}>{connected ? 'Reconnect' : 'Connect'}</Chip>
<ChipModal
open={open}
onOpenChange={handleOpenChange}
dismissDisabled={submit.isPending}
srTitle={`Connect ${serviceName}`}
size='md'
>
<ChipModalHeader
icon={Icon}
onClose={() => handleOpenChange(false)}
closeDisabled={submit.isPending}
>
Connect {serviceName}
</ChipModalHeader>
<ChipModalBody>
<p className='text-pretty px-2 text-[var(--text-muted)] text-small leading-relaxed'>
{keyLocation.steps}
{keyLocation.url && (
<>
{' '}
<a
href={keyLocation.url}
target='_blank'
rel='noreferrer'
className='underline underline-offset-2 hover:text-[var(--text-body)]'
>
Open {serviceName}
</a>
</>
)}
</p>
{fields.map((field) => (
<ChipModalField
key={field.id}
type='input'
inputType={field.secret ? 'password' : 'text'}
title={field.label}
value={values[field.id] ?? ''}
onChange={(value: string) =>
setValues((current) => ({ ...current, [field.id]: value }))
}
placeholder={field.placeholder}
autoComplete='off'
disabled={submit.isPending}
required
/>
))}
<ChipModalError>{error}</ChipModalError>
</ChipModalBody>
<ChipModalFooter
onCancel={() => handleOpenChange(false)}
cancelDisabled={submit.isPending}
primaryAction={{
label: submit.isPending ? 'Checking…' : 'Connect',
onClick: () => void handleSubmit(),
disabled: submit.isPending,
}}
/>
</ChipModal>
</>
)
}
33 changes: 30 additions & 3 deletions apps/sim/app/credential-groups/enroll/[token]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,14 @@ import { headers } from 'next/headers'
import { asOrchestrationError } from '@/lib/core/orchestration/types'
import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth'
import { readPublicCredentialGroupEnrollment } from '@/lib/credential-groups/application/public-enrollment'
import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers'
import {
getCredentialGroupProviderPresentation,
isCredentialGroupApiKeyProvider,
} from '@/lib/credential-groups/providers'
import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit'
import { SupportFooter } from '@/app/(auth)/components'
import { LogoShell } from '@/app/(landing)/components'
import { ApiKeyConnectModal } from '@/app/credential-groups/enroll/[token]/api-key-connect-modal'
import { OAuthConnectLink } from '@/app/credential-groups/enroll/[token]/oauth-reconnect-link'
import { CredentialGroupOAuthToast } from '@/app/credential-groups/enroll/[token]/oauth-toast'
import {
Expand Down Expand Up @@ -116,7 +120,7 @@ export default async function CredentialGroupEnrollmentPage({
: undefined
const notification = connectedOptionId
? {
message: `${connectedOption ? getCredentialGroupProviderService(connectedOption.provider).name : 'Account'} connected successfully.`,
message: `${connectedOption ? getCredentialGroupProviderPresentation(connectedOption.provider).name : 'Account'} connected successfully.`,
variant: 'success' as const,
}
: oauthMessage
Expand Down Expand Up @@ -151,8 +155,31 @@ export default async function CredentialGroupEnrollmentPage({
<SettingsSection label='Accounts'>
<div className={RESOURCE_LIST_STACK}>
{activeOptions.map((option) => {
const ProviderIcon = getCredentialGroupProviderService(option.provider).icon
const ProviderIcon = getCredentialGroupProviderPresentation(option.provider).icon
const connection = option.connections[0]
if (isCredentialGroupApiKeyProvider(option.provider)) {
const provider = option.provider
return (
<SettingsResourceRow
key={option.id}
icon={<ProviderIcon />}
title={option.label}
description={
connection
? `Connected${connection.email ? ` as ${connection.email}` : ''}`
: 'Not connected'
}
trailing={
<ApiKeyConnectModal
token={token}
optionId={option.id}
provider={provider}
connected={Boolean(connection)}
/>
}
/>
)
}
return (
<SettingsResourceRow
key={option.id}
Expand Down
Loading
Loading