)}
diff --git a/web/default/src/features/auth/secure-verification/api.ts b/web/default/src/features/auth/secure-verification/api.ts
index 4d5415e7..3c51e5ef 100644
--- a/web/default/src/features/auth/secure-verification/api.ts
+++ b/web/default/src/features/auth/secure-verification/api.ts
@@ -50,30 +50,28 @@ export async function checkVerificationMethods(
const [methodsResponse, passkeySupported] = await Promise.all([
api.get('/api/verify/methods', {
params: scope ? { scope } : undefined,
+ skipBusinessError: true,
+ skipErrorHandler: true,
}),
detectPasskeySupport(),
])
const methods = methodsResponse.data?.data
+ if (!methodsResponse.data?.success || !methods) {
+ throw new Error(
+ methodsResponse.data?.message || i18next.t('Verification unavailable')
+ )
+ }
return {
- has2FA:
- Boolean(methodsResponse.data?.success) && Boolean(methods?.has_2fa),
- hasPasskey:
- Boolean(methodsResponse.data?.success) && Boolean(methods?.has_passkey),
- hasPassword:
- Boolean(methodsResponse.data?.success) &&
- Boolean(methods?.has_password),
+ has2FA: Boolean(methods.has_2fa),
+ hasPasskey: Boolean(methods.has_passkey),
+ hasPassword: Boolean(methods.has_password),
passkeySupported,
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('[Secure Verification] Failed to check methods', error)
- return {
- has2FA: false,
- hasPasskey: false,
- hasPassword: false,
- passkeySupported: false,
- }
+ throw error
}
}
diff --git a/web/default/src/features/auth/secure-verification/components/secure-verification-dialog.tsx b/web/default/src/features/auth/secure-verification/components/secure-verification-dialog.tsx
index 034167f7..c9c38cf6 100644
--- a/web/default/src/features/auth/secure-verification/components/secure-verification-dialog.tsx
+++ b/web/default/src/features/auth/secure-verification/components/secure-verification-dialog.tsx
@@ -72,14 +72,14 @@ export function SecureVerificationDialog({
const title =
state.title ??
(availableTabs.length
- ? 'Additional verification required'
- : 'Verification unavailable')
+ ? t('Additional verification required')
+ : t('Verification unavailable'))
const description =
state.description ??
(availableTabs.length
- ? 'Confirm your identity before accessing this sensitive action.'
- : 'Enable Two-factor Authentication or Passkey in your profile settings to continue.')
+ ? t('Confirm your identity before accessing this sensitive action.')
+ : t('Add a password or sign in again with a linked account to continue.'))
const handleVerify = () => {
if (!activeMethod) return
@@ -117,7 +117,7 @@ export function SecureVerificationDialog({
{t(
- 'Enable Two-factor Authentication or Passkey in your profile to unlock sensitive operations.'
+ 'Add a password or sign in again with a linked account to unlock sensitive operations.'
)}
diff --git a/web/default/src/features/auth/secure-verification/components/secure-verification-provider.tsx b/web/default/src/features/auth/secure-verification/components/secure-verification-provider.tsx
new file mode 100644
index 00000000..21275666
--- /dev/null
+++ b/web/default/src/features/auth/secure-verification/components/secure-verification-provider.tsx
@@ -0,0 +1,91 @@
+/*
+Copyright (C) 2023-2026 MAX-API-Next
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues
+*/
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ type ReactElement,
+ type ReactNode,
+} from 'react'
+import { useSecureVerification } from '../hooks/use-secure-verification'
+import type { StartVerificationOptions, VerificationMethod } from '../types'
+import { SecureVerificationDialog } from './secure-verification-dialog'
+
+type SecureVerificationProviderProps = {
+ children: ReactNode
+}
+
+interface SecureVerificationContextValue {
+ withVerification: (
+ apiCall: () => Promise,
+ config?: StartVerificationOptions
+ ) => Promise
+}
+
+export const SecureVerificationContext =
+ createContext(null)
+
+export function useSecureVerificationGate(): SecureVerificationContextValue {
+ const context = useContext(SecureVerificationContext)
+ if (!context) {
+ throw new Error(
+ 'useSecureVerificationGate must be used within SecureVerificationProvider'
+ )
+ }
+ return context
+}
+
+export function SecureVerificationProvider(
+ props: SecureVerificationProviderProps
+): ReactElement {
+ const secureVerification = useSecureVerification()
+
+ const handleVerification = useCallback(
+ async (method: VerificationMethod, code?: string): Promise => {
+ try {
+ await secureVerification.executeVerification(method, code)
+ } catch {
+ // The shared verification hook reports the error and keeps retryable
+ // verification failures inside the dialog.
+ }
+ },
+ [secureVerification.executeVerification]
+ )
+ const contextValue = useMemo(
+ () => ({ withVerification: secureVerification.withVerification }),
+ [secureVerification.withVerification]
+ )
+
+ return (
+
+ {props.children}
+
+
+ )
+}
diff --git a/web/default/src/features/auth/secure-verification/hooks/use-api-token-verification.ts b/web/default/src/features/auth/secure-verification/hooks/use-api-token-verification.ts
new file mode 100644
index 00000000..2ea25b09
--- /dev/null
+++ b/web/default/src/features/auth/secure-verification/hooks/use-api-token-verification.ts
@@ -0,0 +1,41 @@
+/*
+Copyright (C) 2023-2026 MAX-API-Next
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues
+*/
+import { useCallback } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useSecureVerificationGate } from '../components/secure-verification-provider'
+
+const defaultApiTokenVerificationDescription =
+ 'Confirm your identity before sending an API key to a chat client.'
+
+export function useApiTokenVerification(
+ description?: string
+): (apiCall: () => Promise) => Promise {
+ const { t } = useTranslation()
+ const secureVerificationGate = useSecureVerificationGate()
+
+ return useCallback(
+ (apiCall: () => Promise): Promise =>
+ secureVerificationGate.withVerification(apiCall, {
+ scope: 'api_token',
+ title: t('Security verification'),
+ description: description ?? t(defaultApiTokenVerificationDescription),
+ }),
+ [description, t, secureVerificationGate.withVerification]
+ )
+}
diff --git a/web/default/src/features/auth/secure-verification/hooks/use-secure-verification.ts b/web/default/src/features/auth/secure-verification/hooks/use-secure-verification.ts
index eaf4b740..73c0688e 100644
--- a/web/default/src/features/auth/secure-verification/hooks/use-secure-verification.ts
+++ b/web/default/src/features/auth/secure-verification/hooks/use-secure-verification.ts
@@ -16,12 +16,14 @@ along with this program. If not, see .
For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues
*/
-import { useCallback, useMemo, useState } from 'react'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import i18next from 'i18next'
import { toast } from 'sonner'
+import { handleServerError } from '@/lib/handle-server-error'
import {
extractVerificationInfo,
isVerificationRequiredError,
+ markSecureVerificationErrorReported,
} from '@/lib/secure-verification'
import { checkVerificationMethods, verify } from '../api'
import { selectVerificationMethod } from '../method-selection'
@@ -36,8 +38,15 @@ import type {
type ApiCall = (() => Promise) | null
+interface PendingVerification {
+ attemptId: number
+ resolve: (value: unknown | null) => void
+ reject: (reason?: unknown) => void
+}
+
interface InternalState extends SecureVerificationState {
apiCall: ApiCall
+ attemptId: number | null
}
const defaultMethods: VerificationMethods = {
@@ -54,6 +63,21 @@ const initialState: InternalState = {
title: undefined,
description: undefined,
apiCall: null,
+ attemptId: null,
+}
+
+function restrictVerificationMethods(
+ methods: VerificationMethods,
+ allowedMethods?: VerificationMethod[]
+): VerificationMethods {
+ if (!allowedMethods?.length) return methods
+ const allowed = new Set(allowedMethods)
+ return {
+ has2FA: methods.has2FA && allowed.has('2fa'),
+ hasPasskey: methods.hasPasskey && allowed.has('passkey'),
+ hasPassword: methods.hasPassword && allowed.has('password'),
+ passkeySupported: methods.passkeySupported,
+ }
}
export function useSecureVerification(
@@ -63,29 +87,99 @@ export function useSecureVerification(
const [methods, setMethods] = useState(defaultMethods)
const [state, setState] = useState(initialState)
- const [open, setOpen] = useState(false)
+ const [open, setDialogOpen] = useState(false)
+ const pendingVerificationRef = useRef(null)
+ const verificationAttemptRef = useRef(0)
+ const mountedRef = useRef(true)
+
+ const loadVerificationMethods = useCallback(
+ async (scope?: VerificationScope): Promise =>
+ checkVerificationMethods(scope),
+ []
+ )
const fetchVerificationMethods = useCallback(
- async (scope?: VerificationScope) => {
- const result = await checkVerificationMethods(scope)
- setMethods(result)
+ async (scope?: VerificationScope): Promise => {
+ const result = await loadVerificationMethods(scope)
+ if (mountedRef.current) {
+ setMethods(result)
+ }
return result
},
+ [loadVerificationMethods]
+ )
+
+ const settlePendingVerification = useCallback(
+ (value: unknown | null, error?: unknown, attemptId?: number): void => {
+ const pending = pendingVerificationRef.current
+ if (attemptId !== undefined && pending?.attemptId !== attemptId) return
+ pendingVerificationRef.current = null
+ if (!pending) return
+ if (error !== undefined) {
+ pending.reject(error)
+ } else {
+ pending.resolve(value)
+ }
+ },
+ []
+ )
+
+ const isVerificationAttemptActive = useCallback(
+ (attemptId: number): boolean =>
+ mountedRef.current && verificationAttemptRef.current === attemptId,
[]
)
- const reset = useCallback(() => {
+ const beginVerificationAttempt = useCallback((): number | null => {
+ if (!mountedRef.current) return null
+ verificationAttemptRef.current += 1
+ settlePendingVerification(null)
setState(initialState)
- setOpen(false)
- }, [])
+ setDialogOpen(false)
+ return verificationAttemptRef.current
+ }, [settlePendingVerification])
- const startVerification = useCallback(
+ const reset = useCallback((): void => {
+ verificationAttemptRef.current += 1
+ settlePendingVerification(null)
+ if (!mountedRef.current) return
+ setState(initialState)
+ setDialogOpen(false)
+ }, [settlePendingVerification])
+
+ useEffect(() => {
+ mountedRef.current = true
+ return () => {
+ mountedRef.current = false
+ verificationAttemptRef.current += 1
+ settlePendingVerification(null)
+ }
+ }, [settlePendingVerification])
+
+ const startVerificationAttempt = useCallback(
async (
apiCall: () => Promise,
- config: StartVerificationOptions = {}
- ) => {
- const { preferredMethod, title, description, scope } = config
- const availableMethods = await fetchVerificationMethods(scope)
+ config: StartVerificationOptions,
+ attemptId: number
+ ): Promise => {
+ let availableMethods: VerificationMethods
+ try {
+ availableMethods = restrictVerificationMethods(
+ await loadVerificationMethods(config.scope),
+ config.allowedMethods
+ )
+ } catch (error) {
+ if (!isVerificationAttemptActive(attemptId)) return false
+ handleServerError(error, {
+ fallback: i18next.t('Verification unavailable'),
+ })
+ markSecureVerificationErrorReported(error)
+ onError?.(error)
+ throw error
+ }
+
+ if (!isVerificationAttemptActive(attemptId)) return false
+ setMethods(availableMethods)
if (
!availableMethods.has2FA &&
@@ -94,12 +188,12 @@ export function useSecureVerification(
) {
toast.error(
i18next.t(
- 'Please enable Two-factor Authentication or Passkey before proceeding'
+ 'No verification method is available. Add a password or sign in again with a linked account.'
)
)
onError?.(
new Error(
- 'No verification methods available. Enable 2FA or Passkey to continue.'
+ 'No verification method is available. Add a password or sign in again with a linked account.'
)
)
return false
@@ -107,41 +201,76 @@ export function useSecureVerification(
const defaultMethod = selectVerificationMethod(
availableMethods,
- preferredMethod
+ config.preferredMethod
)
setState((prev) => ({
...prev,
apiCall,
+ attemptId,
method: defaultMethod,
- title,
- description,
- scope,
+ title: config.title,
+ description: config.description,
+ scope: config.scope,
}))
- setOpen(true)
+ setDialogOpen(true)
return true
},
- [fetchVerificationMethods, onError]
+ [isVerificationAttemptActive, loadVerificationMethods, onError]
+ )
+
+ const startVerification = useCallback(
+ async (
+ apiCall: () => Promise,
+ config: StartVerificationOptions = {}
+ ): Promise => {
+ const attemptId = beginVerificationAttempt()
+ if (attemptId === null) return false
+ return startVerificationAttempt(apiCall, config, attemptId)
+ },
+ [beginVerificationAttempt, startVerificationAttempt]
)
const executeVerification = useCallback(
- async (method?: VerificationMethod, code?: string) => {
- if (!state.apiCall) {
+ async (
+ method?: VerificationMethod,
+ code?: string
+ ): Promise => {
+ if (!state.apiCall || state.attemptId === null) {
toast.error(i18next.t('Verification is not configured properly'))
return
}
+ const attemptId = state.attemptId
+ const apiCall = state.apiCall
const actualMethod = method ?? state.method
if (!actualMethod) {
toast.error(i18next.t('Select a verification method first'))
return
}
+ if (!isVerificationAttemptActive(attemptId)) return
setState((prev) => ({ ...prev, loading: true }))
try {
await verify(actualMethod, code ?? state.code, state.scope)
- const result = await state.apiCall()
+ } catch (error) {
+ if (!isVerificationAttemptActive(attemptId)) return
+ setState((prev) => ({ ...prev, loading: false }))
+ handleServerError(error, {
+ fallback: i18next.t('Verification failed'),
+ })
+ markSecureVerificationErrorReported(error)
+ onError?.(error)
+ throw error
+ }
+
+ if (!isVerificationAttemptActive(attemptId)) return
+
+ try {
+ const result = await apiCall()
+ if (!isVerificationAttemptActive(attemptId)) return
+ settlePendingVerification(result, undefined, attemptId)
if (successMessage) {
toast.success(successMessage)
@@ -155,18 +284,33 @@ export function useSecureVerification(
return result
} catch (error) {
- const message =
- error instanceof Error
- ? error.message
- : i18next.t('Verification failed')
- toast.error(message)
+ if (!isVerificationAttemptActive(attemptId)) return
+ settlePendingVerification(null, error, attemptId)
+ handleServerError(error, {
+ fallback: i18next.t('Verification failed'),
+ })
+ markSecureVerificationErrorReported(error)
onError?.(error)
+ reset()
throw error
} finally {
- setState((prev) => ({ ...prev, loading: false }))
+ if (isVerificationAttemptActive(attemptId)) {
+ setState((prev) =>
+ prev.attemptId === attemptId ? { ...prev, loading: false } : prev
+ )
+ }
}
},
- [state, successMessage, onSuccess, onError, autoReset, reset]
+ [
+ state,
+ isVerificationAttemptActive,
+ successMessage,
+ onSuccess,
+ onError,
+ autoReset,
+ reset,
+ settlePendingVerification,
+ ]
)
const setCode = useCallback((code: string) => {
@@ -177,28 +321,98 @@ export function useSecureVerification(
setState((prev) => ({ ...prev, method, code: '' }))
}, [])
- const cancel = useCallback(() => {
+ const cancel = useCallback((): void => {
reset()
}, [reset])
+ const setOpen = useCallback(
+ (nextOpen: boolean): void => {
+ if (!nextOpen) {
+ cancel()
+ return
+ }
+ setDialogOpen(true)
+ },
+ [cancel]
+ )
+
+ /**
+ * Returns null when the user cancels. Callers must handle rejections from
+ * method discovery, the original action, and the post-verification retry.
+ */
const withVerification = useCallback(
- async (
- apiCall: () => Promise,
+ (
+ apiCall: () => Promise,
config: StartVerificationOptions = {}
- ) => {
- try {
- return await apiCall()
- } catch (error) {
- if (isVerificationRequiredError(error)) {
+ ): Promise => {
+ const attemptId = beginVerificationAttempt()
+ if (attemptId === null) return Promise.resolve(null)
+ const continuation = new Promise((resolve, reject) => {
+ pendingVerificationRef.current = {
+ attemptId,
+ resolve: (value) => resolve(value as T | null),
+ reject,
+ }
+ })
+ // Keep the original promise and rejection contract for awaiting callers,
+ // while ensuring fire-and-forget UI effects do not produce an unhandled
+ // rejection after the shared flow has already reported the failure.
+ void continuation.catch(() => undefined)
+
+ void (async (): Promise => {
+ if (config.forceVerification) {
+ try {
+ const started = await startVerificationAttempt(
+ apiCall,
+ config,
+ attemptId
+ )
+ if (!started) {
+ settlePendingVerification(null, undefined, attemptId)
+ }
+ } catch (error) {
+ settlePendingVerification(null, error, attemptId)
+ }
+ return
+ }
+ try {
+ const result = await apiCall()
+ if (!isVerificationAttemptActive(attemptId)) return
+ settlePendingVerification(result, undefined, attemptId)
+ return
+ } catch (error) {
+ if (!isVerificationAttemptActive(attemptId)) return
+ if (!isVerificationRequiredError(error)) {
+ settlePendingVerification(null, error, attemptId)
+ return
+ }
+
const info = extractVerificationInfo(error)
toast.info(info.message)
- await startVerification(apiCall, config)
- return null
}
- throw error
- }
+
+ try {
+ const started = await startVerificationAttempt(
+ apiCall,
+ config,
+ attemptId
+ )
+ if (!started) {
+ settlePendingVerification(null, undefined, attemptId)
+ }
+ } catch (error) {
+ settlePendingVerification(null, error, attemptId)
+ }
+ })()
+
+ return continuation
},
- [startVerification]
+ [
+ beginVerificationAttempt,
+ isVerificationAttemptActive,
+ settlePendingVerification,
+ startVerificationAttempt,
+ ]
)
const canUseMethod = useCallback(
diff --git a/web/default/src/features/auth/secure-verification/index.ts b/web/default/src/features/auth/secure-verification/index.ts
index 1cb20155..4568b0e7 100644
--- a/web/default/src/features/auth/secure-verification/index.ts
+++ b/web/default/src/features/auth/secure-verification/index.ts
@@ -18,5 +18,7 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API
*/
export * from './api'
export * from './types'
+export * from './hooks/use-api-token-verification'
export * from './hooks/use-secure-verification'
export * from './components/secure-verification-dialog'
+export * from './components/secure-verification-provider'
diff --git a/web/default/src/features/auth/secure-verification/types.ts b/web/default/src/features/auth/secure-verification/types.ts
index c3b7e99a..a3529794 100644
--- a/web/default/src/features/auth/secure-verification/types.ts
+++ b/web/default/src/features/auth/secure-verification/types.ts
@@ -17,7 +17,12 @@ along with this program. If not, see .
For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues
*/
export type VerificationMethod = '2fa' | 'passkey' | 'password'
-export type VerificationScope = 'access_token' | 'account_delete'
+export type VerificationScope =
+ | 'access_token'
+ | 'account_delete'
+ | 'credentials'
+ | 'api_token'
+ | 'passkey_register'
export interface VerificationMethods {
has2FA: boolean
@@ -44,6 +49,8 @@ export interface UseSecureVerificationOptions {
export interface StartVerificationOptions {
preferredMethod?: VerificationMethod
+ allowedMethods?: VerificationMethod[]
+ forceVerification?: boolean
title?: string
description?: string
scope?: VerificationScope
diff --git a/web/default/src/features/chat/hooks/use-active-chat-key.ts b/web/default/src/features/chat/hooks/use-active-chat-key.ts
index 6baef163..2e8d1cde 100644
--- a/web/default/src/features/chat/hooks/use-active-chat-key.ts
+++ b/web/default/src/features/chat/hooks/use-active-chat-key.ts
@@ -16,8 +16,11 @@ along with this program. If not, see .
For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues
*/
+import { useCallback } from 'react'
import { useQuery } from '@tanstack/react-query'
+import i18next from 'i18next'
import { useAuthStore } from '@/stores/auth-store'
+import { useApiTokenVerification } from '@/features/auth/secure-verification'
import { fetchTokenKey, getApiKeys } from '@/features/keys/api'
import { API_KEY_STATUS } from '@/features/keys/constants'
@@ -46,12 +49,23 @@ export async function fetchActiveChatKey() {
*/
export function useActiveChatKey(enabled: boolean) {
const userId = useAuthStore((state) => state.auth.user?.id)
+ const withApiTokenVerification = useApiTokenVerification()
+ const queryFn = useCallback(async (): Promise => {
+ const key = await withApiTokenVerification(fetchActiveChatKey)
+ if (!key) {
+ throw new Error(i18next.t('API key verification was cancelled'))
+ }
+ return key
+ }, [withApiTokenVerification])
return useQuery({
queryKey: ['chat-active-key', userId],
- queryFn: fetchActiveChatKey,
+ queryFn,
enabled: enabled && Boolean(userId),
- staleTime: 5 * 60 * 1000,
- gcTime: 10 * 60 * 1000,
+ staleTime: Number.POSITIVE_INFINITY,
+ gcTime: 0,
+ retry: false,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
})
}
diff --git a/web/default/src/features/dashboard/components/overview/overview-dashboard.tsx b/web/default/src/features/dashboard/components/overview/overview-dashboard.tsx
index 0df546f1..87b202ba 100644
--- a/web/default/src/features/dashboard/components/overview/overview-dashboard.tsx
+++ b/web/default/src/features/dashboard/components/overview/overview-dashboard.tsx
@@ -16,7 +16,7 @@ along with this program. If not, see .
For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues
*/
-import { useMemo, useState, type ReactNode } from 'react'
+import { useMemo, useState, type JSX, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import {
@@ -45,8 +45,10 @@ import { toast } from 'sonner'
import { useAuthStore } from '@/stores/auth-store'
import { getUserModels } from '@/lib/api'
import { formatNumber, formatQuota } from '@/lib/format'
+import { handleServerError } from '@/lib/handle-server-error'
import { MOTION_TRANSITION } from '@/lib/motion'
import { ROLE } from '@/lib/roles'
+import { wasSecureVerificationErrorReported } from '@/lib/secure-verification'
import { cn } from '@/lib/utils'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@/components/ui/button'
@@ -54,6 +56,7 @@ import {
CardStaggerContainer,
CardStaggerItem,
} from '@/components/page-transition'
+import { useApiTokenVerification } from '@/features/auth/secure-verification'
import { fetchTokenKey, getApiKeys } from '@/features/keys/api'
import type { ApiKey } from '@/features/keys/types'
import {
@@ -373,14 +376,19 @@ function StartStepItem(props: {
)
}
-function RequestPreview(props: {
+type RequestPreviewProps = {
example: RequestExample
signals: HeroSignal[]
isRetrying: boolean
onRetry: () => void
-}) {
+}
+
+export function RequestPreview(props: RequestPreviewProps): JSX.Element {
const { t } = useTranslation()
const shouldReduceMotion = useReducedMotion()
+ const withApiTokenVerification = useApiTokenVerification(
+ t('Confirm your identity before copying a request with your API key.')
+ )
const [isCopying, setIsCopying] = useState(false)
const { copyToClipboard } = useCopyToClipboard({ notify: false })
const previewCurl = buildCurlCommand({
@@ -389,12 +397,14 @@ function RequestPreview(props: {
model: props.example.model,
})
const previewLines = previewCurl.split('\n')
- const handleCopyRequest = async () => {
+ const handleCopyRequest = async (): Promise => {
if (!props.example.keyId || isCopying) return
+ const keyId = props.example.keyId
setIsCopying(true)
try {
- const result = await fetchTokenKey(props.example.keyId)
+ const result = await withApiTokenVerification(() => fetchTokenKey(keyId))
+ if (!result) return
const key = result.success && result.data?.key ? result.data.key : ''
if (!key) {
toast.error(result.message || t('Failed to copy to clipboard'))
@@ -412,6 +422,12 @@ function RequestPreview(props: {
} else {
toast.error(t('Failed to copy to clipboard'))
}
+ } catch (error) {
+ if (!wasSecureVerificationErrorReported(error)) {
+ handleServerError(error, {
+ fallback: t('Failed to copy to clipboard'),
+ })
+ }
} finally {
setIsCopying(false)
}
diff --git a/web/default/src/features/keys/api.ts b/web/default/src/features/keys/api.ts
index ca5bceaa..f86e8a70 100644
--- a/web/default/src/features/keys/api.ts
+++ b/web/default/src/features/keys/api.ts
@@ -16,7 +16,7 @@ along with this program. If not, see .
For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues
*/
-import { api } from '@/lib/api'
+import { api, sensitiveActionConfig } from '@/lib/api'
import type {
ApiKey,
ApiResponse,
@@ -63,7 +63,7 @@ export async function getApiKey(id: number): Promise> {
export async function createApiKey(
data: ApiKeyFormData
): Promise> {
- const res = await api.post('/api/token/', data)
+ const res = await api.post('/api/token/', data, sensitiveActionConfig)
return res.data
}
@@ -102,7 +102,11 @@ export async function updateApiKeyStatus(
export async function fetchTokenKey(
id: number
): Promise<{ success: boolean; message?: string; data?: { key: string } }> {
- const res = await api.post(`/api/token/${id}/key`)
+ const res = await api.post(
+ `/api/token/${id}/key`,
+ undefined,
+ sensitiveActionConfig
+ )
return res.data
}
@@ -112,6 +116,10 @@ export async function fetchTokenKeysBatch(ids: number[]): Promise<{
message?: string
data?: { keys: Record }
}> {
- const res = await api.post('/api/token/batch/keys', { ids })
+ const res = await api.post(
+ '/api/token/batch/keys',
+ { ids },
+ sensitiveActionConfig
+ )
return res.data
}
diff --git a/web/default/src/features/keys/components/api-keys-cells.tsx b/web/default/src/features/keys/components/api-keys-cells.tsx
index cfd473f3..fc838faa 100644
--- a/web/default/src/features/keys/components/api-keys-cells.tsx
+++ b/web/default/src/features/keys/components/api-keys-cells.tsx
@@ -41,6 +41,7 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
const {
resolveRealKey,
resolvedKeys,
+ clearResolvedKey,
loadingKeys,
copiedKeyId,
markKeyCopied,
@@ -53,27 +54,27 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
const maskedKey = `sk-${apiKey.key}`
const handlePopoverOpen = useCallback(
- (open: boolean) => {
+ (open: boolean): void => {
setPopoverOpen(open)
if (open && !resolvedFullKey) {
- resolveRealKey(apiKey.id)
+ void resolveRealKey(apiKey.id, { cache: true })
+ } else if (!open) {
+ clearResolvedKey(apiKey.id)
}
},
- [resolvedFullKey, resolveRealKey, apiKey.id]
+ [clearResolvedKey, resolvedFullKey, resolveRealKey, apiKey.id]
)
- const handleCopy = useCallback(async () => {
- const realKey = resolvedFullKey
- if (!realKey) {
- void resolveRealKey(apiKey.id)
- toast.info(t('API key is loading, please try again in a moment'))
- return
+ const handleCopy = useCallback(async (): Promise => {
+ const realKey = await resolveRealKey(apiKey.id)
+ if (!realKey) return
+ const ok = await copyToClipboard(realKey)
+ if (ok) {
+ markKeyCopied(apiKey.id)
+ } else {
+ toast.error(t('Failed to copy to clipboard'))
}
- if (realKey) {
- const ok = await copyToClipboard(realKey)
- if (ok) markKeyCopied(apiKey.id)
- }
- }, [resolvedFullKey, resolveRealKey, apiKey.id, markKeyCopied, t])
+ }, [resolveRealKey, apiKey.id, markKeyCopied, t])
return (
@@ -122,13 +123,8 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
size='icon'
className='size-7 shrink-0'
onClick={handleCopy}
- onFocus={() => {
- if (!resolvedFullKey) void resolveRealKey(apiKey.id)
- }}
- onPointerEnter={() => {
- if (!resolvedFullKey) void resolveRealKey(apiKey.id)
- }}
disabled={isLoading}
+ aria-label={t('Copy API key')}
/>
}
>
diff --git a/web/default/src/features/keys/components/api-keys-dialogs.tsx b/web/default/src/features/keys/components/api-keys-dialogs.tsx
index 7e448e8b..76bcf78c 100644
--- a/web/default/src/features/keys/components/api-keys-dialogs.tsx
+++ b/web/default/src/features/keys/components/api-keys-dialogs.tsx
@@ -16,25 +16,34 @@ along with this program. If not, see .
For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues
*/
+import type { ReactElement } from 'react'
import { ApiKeysDeleteDialog } from './api-keys-delete-dialog'
import { ApiKeysMutateDrawer } from './api-keys-mutate-drawer'
import { useApiKeys } from './api-keys-provider'
import { CCSwitchDialog } from './dialogs/cc-switch-dialog'
-export function ApiKeysDialogs() {
- const { open, setOpen, currentRow, resolvedKey } = useApiKeys()
+export function ApiKeysDialogs(): ReactElement {
+ const { open, setOpen, currentRow, resolvedKey, setResolvedKey } =
+ useApiKeys()
return (
<>
!isOpen && setOpen(null)}
+ onOpenChange={(isOpen: boolean): void => {
+ if (!isOpen) setOpen(null)
+ }}
currentRow={open === 'update' ? currentRow || undefined : undefined}
/>
!isOpen && setOpen(null)}
+ onOpenChange={(isOpen: boolean): void => {
+ if (!isOpen) {
+ setResolvedKey('')
+ setOpen(null)
+ }
+ }}
tokenKey={resolvedKey}
/>
>
diff --git a/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx b/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx
index 7b13a990..95f09d50 100644
--- a/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx
+++ b/web/default/src/features/keys/components/api-keys-mutate-drawer.tsx
@@ -16,7 +16,7 @@ along with this program. If not, see .
For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues
*/
-import { useEffect, useMemo, useRef, useState } from 'react'
+import { useEffect, useMemo, useRef, useState, type ReactElement } from 'react'
import { useForm, type SubmitErrorHandler } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useQuery } from '@tanstack/react-query'
@@ -27,6 +27,7 @@ import { getUserModels, getUserGroups } from '@/lib/api'
import { DEFAULT_AUTO_ROUTE_KEY } from '@/lib/auto-routes'
import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency'
import { handleServerError } from '@/lib/handle-server-error'
+import { wasSecureVerificationErrorReported } from '@/lib/secure-verification'
import { cn } from '@/lib/utils'
import { useStatus } from '@/hooks/use-status'
import { Button } from '@/components/ui/button'
@@ -92,14 +93,12 @@ type ApiKeyMutateDrawerProps = {
currentRow?: ApiKey
}
-export function ApiKeysMutateDrawer({
- open,
- onOpenChange,
- currentRow,
-}: ApiKeyMutateDrawerProps) {
+export function ApiKeysMutateDrawer(
+ props: ApiKeyMutateDrawerProps
+): ReactElement {
const { t } = useTranslation()
- const isUpdate = !!currentRow
- const { triggerRefresh } = useApiKeys()
+ const isUpdate = !!props.currentRow
+ const { triggerRefresh, withApiTokenVerification } = useApiKeys()
const { status } = useStatus()
const [isSubmitting, setIsSubmitting] = useState(false)
const [advancedOpen, setAdvancedOpen] = useState(false)
@@ -108,7 +107,7 @@ export function ApiKeysMutateDrawer({
const [preservedManualGroups, setPreservedManualGroups] = useState(
[]
)
- const currentRowId = currentRow?.id
+ const currentRowId = props.currentRow?.id
const defaultAutoRoute =
typeof status?.default_auto_route === 'string'
? status.default_auto_route
@@ -221,7 +220,7 @@ export function ApiKeysMutateDrawer({
// Load existing data when updating
useEffect(() => {
- if (!open || groupsLoading) return
+ if (!props.open || groupsLoading) return
let cancelled = false
const {
@@ -293,10 +292,11 @@ export function ApiKeysMutateDrawer({
return () => {
cancelled = true
}
- }, [open, isUpdate, currentRowId, form, groupsLoading, t])
+ }, [props.open, isUpdate, currentRowId, form, groupsLoading, t])
- const onSubmit = async (data: ApiKeyFormValues) => {
+ const onSubmit = async (data: ApiKeyFormValues): Promise => {
setIsSubmitting(true)
+ let created = 0
try {
const dirtyFields = form.formState.dirtyFields
const routingChanged = Boolean(
@@ -315,14 +315,14 @@ export function ApiKeysMutateDrawer({
),
})
- if (isUpdate && currentRow) {
+ if (isUpdate && props.currentRow) {
const result = await updateApiKey({
...basePayload,
- id: currentRow.id,
+ id: props.currentRow.id,
})
if (result.success) {
toast.success(t(SUCCESS_MESSAGES.API_KEY_UPDATED))
- onOpenChange(false)
+ props.onOpenChange(false)
triggerRefresh()
} else {
toast.error(result.message || t(ERROR_MESSAGES.UPDATE_FAILED))
@@ -330,38 +330,43 @@ export function ApiKeysMutateDrawer({
} else {
// Create mode - handle batch creation
const count = data.tokenCount || 1
- let successCount = 0
-
- for (let i = 0; i < count; i++) {
- const result = await createApiKey({
- ...basePayload,
- name:
- i === 0 && data.name
- ? data.name
- : `${data.name || 'default'}-${Math.random().toString(36).slice(2, 8)}`,
- })
- if (result.success) {
- successCount++
- } else {
- toast.error(result.message || t(ERROR_MESSAGES.CREATE_FAILED))
- break
+ await withApiTokenVerification(async (): Promise => {
+ for (let i = created; i < count; i++) {
+ const result = await createApiKey({
+ ...basePayload,
+ name:
+ i === 0 && data.name
+ ? data.name
+ : `${data.name || 'default'}-${Math.random().toString(36).slice(2, 8)}`,
+ })
+ if (result.success) {
+ created++
+ } else {
+ toast.error(result.message || t(ERROR_MESSAGES.CREATE_FAILED))
+ break
+ }
}
- }
- if (successCount > 0) {
- toast.success(
- t('Successfully created {{count}} API Key(s)', {
- count: successCount,
- })
- )
- onOpenChange(false)
- triggerRefresh()
- }
+ return created
+ })
+ }
+ } catch (error) {
+ if (!wasSecureVerificationErrorReported(error)) {
+ handleServerError(error, {
+ fallback: t(ERROR_MESSAGES.UNEXPECTED),
+ })
}
- } catch (_error) {
- toast.error(t(ERROR_MESSAGES.UNEXPECTED))
} finally {
setIsSubmitting(false)
+ if (!isUpdate && created > 0) {
+ toast.success(
+ t('Successfully created {{count}} API Key(s)', {
+ count: created,
+ })
+ )
+ props.onOpenChange(false)
+ triggerRefresh()
+ }
}
}
@@ -399,9 +404,9 @@ export function ApiKeysMutateDrawer({
return (
{
- onOpenChange(v)
+ open={props.open}
+ onOpenChange={(v: boolean): void => {
+ props.onOpenChange(v)
if (!v) {
setEditingLegacyRouting(false)
setPreservedSmartRoute(undefined)
diff --git a/web/default/src/features/keys/components/api-keys-provider.tsx b/web/default/src/features/keys/components/api-keys-provider.tsx
index 0207d5ee..36f8a025 100644
--- a/web/default/src/features/keys/components/api-keys-provider.tsx
+++ b/web/default/src/features/keys/components/api-keys-provider.tsx
@@ -19,7 +19,10 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API
import React, { useState, useCallback, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
+import { handleServerError } from '@/lib/handle-server-error'
+import { wasSecureVerificationErrorReported } from '@/lib/secure-verification'
import useDialogState from '@/hooks/use-dialog'
+import { useApiTokenVerification } from '@/features/auth/secure-verification'
import { fetchTokenKey, fetchTokenKeysBatch } from '../api'
import { ERROR_MESSAGES } from '../constants'
import { type ApiKey, type ApiKeysDialogType } from '../types'
@@ -33,18 +36,32 @@ type ApiKeysContextType = {
triggerRefresh: () => void
resolvedKey: string
setResolvedKey: React.Dispatch>
- resolveRealKey: (id: number) => Promise
+ resolveRealKey: (
+ id: number,
+ options?: { cache?: boolean }
+ ) => Promise
resolveRealKeysBatch: (ids: number[]) => Promise>
resolvedKeys: Record
+ clearResolvedKey: (id: number) => void
loadingKeys: Record
copiedKeyId: number | null
markKeyCopied: (id: number) => void
+ withApiTokenVerification: (apiCall: () => Promise) => Promise
}
const ApiKeysContext = React.createContext(null)
-export function ApiKeysProvider({ children }: { children: React.ReactNode }) {
+type ApiKeysProviderProps = {
+ children: React.ReactNode
+}
+
+export function ApiKeysProvider(
+ props: ApiKeysProviderProps
+): React.JSX.Element {
const { t } = useTranslation()
+ const withApiTokenVerification = useApiTokenVerification(
+ t('Confirm your identity before creating or revealing API keys.')
+ )
const [open, setOpen] = useDialogState(null)
const [currentRow, setCurrentRow] = useState(null)
const [refreshTrigger, setRefreshTrigger] = useState(0)
@@ -52,7 +69,9 @@ export function ApiKeysProvider({ children }: { children: React.ReactNode }) {
const [resolvedKeys, setResolvedKeys] = useState>({})
const [loadingKeys, setLoadingKeys] = useState>({})
- const pendingRequests = useRef>>({})
+ const pendingRequests = useRef<
+ Record; cacheRequested: boolean }>
+ >({})
const [copiedKeyId, setCopiedKeyId] = useState(null)
const copiedTimerRef = useRef>(undefined)
@@ -72,23 +91,37 @@ export function ApiKeysProvider({ children }: { children: React.ReactNode }) {
}, [])
const resolveRealKey = useCallback(
- async (id: number): Promise => {
+ async (
+ id: number,
+ options: { cache?: boolean } = {}
+ ): Promise => {
if (resolvedKeys[id]) return resolvedKeys[id]
- if (id in pendingRequests.current) return pendingRequests.current[id]
+ const pending = pendingRequests.current[id]
+ if (pending) {
+ pending.cacheRequested ||= Boolean(options.cache)
+ return pending.promise
+ }
const request = (async () => {
setLoadingKeys((prev) => ({ ...prev, [id]: true }))
try {
- const res = await fetchTokenKey(id)
+ const res = await withApiTokenVerification(() => fetchTokenKey(id))
+ if (!res) return null
if (res.success && res.data?.key) {
const fullKey = `sk-${res.data.key}`
- setResolvedKeys((prev) => ({ ...prev, [id]: fullKey }))
+ if (pendingRequests.current[id]?.cacheRequested) {
+ setResolvedKeys((prev) => ({ ...prev, [id]: fullKey }))
+ }
return fullKey
}
toast.error(res.message || t(ERROR_MESSAGES.UNEXPECTED))
return null
- } catch {
- toast.error(t(ERROR_MESSAGES.UNEXPECTED))
+ } catch (error) {
+ if (!wasSecureVerificationErrorReported(error)) {
+ handleServerError(error, {
+ fallback: t(ERROR_MESSAGES.UNEXPECTED),
+ })
+ }
return null
} finally {
delete pendingRequests.current[id]
@@ -100,47 +133,55 @@ export function ApiKeysProvider({ children }: { children: React.ReactNode }) {
}
})()
- pendingRequests.current[id] = request
+ pendingRequests.current[id] = {
+ promise: request,
+ cacheRequested: Boolean(options.cache),
+ }
return request
},
- [resolvedKeys, t]
+ [resolvedKeys, t, withApiTokenVerification]
)
+ const clearResolvedKey = useCallback((id: number): void => {
+ const pending = pendingRequests.current[id]
+ if (pending) pending.cacheRequested = false
+ setResolvedKeys((prev) => {
+ if (!(id in prev)) return prev
+ const next = { ...prev }
+ delete next[id]
+ return next
+ })
+ }, [])
+
const resolveRealKeysBatch = useCallback(
async (ids: number[]): Promise> => {
- const uncachedIds = ids.filter((id) => !resolvedKeys[id])
- if (uncachedIds.length === 0) {
- const result: Record = {}
- for (const id of ids) result[id] = resolvedKeys[id]
- return result
- }
-
- for (const id of uncachedIds) {
+ for (const id of ids) {
setLoadingKeys((prev) => ({ ...prev, [id]: true }))
}
try {
- const res = await fetchTokenKeysBatch(uncachedIds)
+ const res = await withApiTokenVerification(() =>
+ fetchTokenKeysBatch(ids)
+ )
+ if (!res) return {}
if (res.success && res.data?.keys) {
const newKeys: Record = {}
for (const [idStr, key] of Object.entries(res.data.keys)) {
newKeys[Number(idStr)] = `sk-${key}`
}
- setResolvedKeys((prev) => ({ ...prev, ...newKeys }))
-
- const result: Record = { ...newKeys }
- for (const id of ids) {
- if (resolvedKeys[id]) result[id] = resolvedKeys[id]
- }
- return result
+ return newKeys
}
toast.error(res.message || t(ERROR_MESSAGES.UNEXPECTED))
return {}
- } catch {
- toast.error(t(ERROR_MESSAGES.UNEXPECTED))
+ } catch (error) {
+ if (!wasSecureVerificationErrorReported(error)) {
+ handleServerError(error, {
+ fallback: t(ERROR_MESSAGES.UNEXPECTED),
+ })
+ }
return {}
} finally {
- for (const id of uncachedIds) {
+ for (const id of ids) {
setLoadingKeys((prev) => {
const next = { ...prev }
delete next[id]
@@ -149,7 +190,7 @@ export function ApiKeysProvider({ children }: { children: React.ReactNode }) {
}
}
},
- [resolvedKeys, t]
+ [t, withApiTokenVerification]
)
return (
@@ -166,12 +207,14 @@ export function ApiKeysProvider({ children }: { children: React.ReactNode }) {
resolveRealKey,
resolveRealKeysBatch,
resolvedKeys,
+ clearResolvedKey,
loadingKeys,
copiedKeyId,
markKeyCopied,
+ withApiTokenVerification,
}}
>
- {children}
+ {props.children}
)
}
diff --git a/web/default/src/features/keys/components/data-table-row-actions.tsx b/web/default/src/features/keys/components/data-table-row-actions.tsx
index 4eca6280..e975ff0c 100644
--- a/web/default/src/features/keys/components/data-table-row-actions.tsx
+++ b/web/default/src/features/keys/components/data-table-row-actions.tsx
@@ -52,7 +52,11 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useChatPresets } from '@/features/chat/hooks/use-chat-presets'
-import { resolveChatUrl, type ChatPreset } from '@/features/chat/lib/chat-links'
+import {
+ chatLinkRequiresApiKey,
+ resolveChatUrl,
+ type ChatPreset,
+} from '@/features/chat/lib/chat-links'
import { sendToFluent } from '@/features/chat/lib/send-to-fluent'
import { updateApiKeyStatus } from '../api'
import { API_KEY_STATUS, ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
@@ -87,39 +91,37 @@ export function DataTableRowActions({
triggerRefresh,
setResolvedKey,
resolveRealKey,
- resolvedKeys,
- loadingKeys,
} = useApiKeys()
const isEnabled = apiKey.status === API_KEY_STATUS.ENABLED
const { chatPresets, serverAddress } = useChatPresets()
const [isTogglingStatus, setIsTogglingStatus] = useState(false)
- const resolvedRealKey = resolvedKeys[apiKey.id]
- const isRealKeyLoading = Boolean(loadingKeys[apiKey.id])
-
const hasChatPresets = chatPresets.length > 0
- const handleMenuOpenChange = useCallback(
- (open: boolean) => {
- if (open && !resolvedRealKey && !isRealKeyLoading) {
- void resolveRealKey(apiKey.id)
- }
- },
- [apiKey.id, isRealKeyLoading, resolvedRealKey, resolveRealKey]
- )
-
- const getCachedRealKey = useCallback(() => {
- if (resolvedRealKey) return resolvedRealKey
- void resolveRealKey(apiKey.id)
- toast.info(t('API key is loading, please try again in a moment'))
- return null
- }, [apiKey.id, resolvedRealKey, resolveRealKey, t])
-
const handleOpenChatPreset = useCallback(
async (preset: ChatPreset) => {
- const realKey = await resolveRealKey(apiKey.id)
- if (!realKey) return
+ let chatWindow: Window | null = null
+ if (preset.type !== 'fluent' && typeof window !== 'undefined') {
+ try {
+ chatWindow = window.open('about:blank', '_blank')
+ if (chatWindow) chatWindow.opener = null
+ } catch {
+ chatWindow = null
+ }
+ }
+
+ const needsApiKey = chatLinkRequiresApiKey(preset.url)
+ let realKey: string | undefined
+ if (needsApiKey || preset.type === 'fluent') {
+ const resolvedKey = await resolveRealKey(apiKey.id)
+ if (!resolvedKey) {
+ chatWindow?.close()
+ return
+ }
+ realKey = resolvedKey
+ }
if (preset.type === 'fluent') {
+ if (!realKey) return
const success = sendToFluent(realKey, serverAddress)
if (success) {
toast.success(t('Sent the API key to FluentRead.'))
@@ -135,11 +137,12 @@ export function DataTableRowActions({
const resolvedUrl = resolveChatUrl({
template: preset.url,
- apiKey: realKey,
+ apiKey: needsApiKey ? realKey : undefined,
serverAddress,
})
if (!resolvedUrl) {
+ chatWindow?.close()
toast.error(t('Invalid chat link. Please contact your administrator.'))
return
}
@@ -147,8 +150,13 @@ export function DataTableRowActions({
if (typeof window === 'undefined') return
try {
- window.open(resolvedUrl, '_blank', 'noopener,noreferrer')
+ if (chatWindow) {
+ chatWindow.location.replace(resolvedUrl)
+ return
+ }
+ window.location.href = resolvedUrl
} catch {
+ chatWindow?.close()
window.location.href = resolvedUrl
}
},
@@ -214,7 +222,7 @@ export function DataTableRowActions({
-
+ ({
{
- const realKey = getCachedRealKey()
+ const realKey = await resolveRealKey(apiKey.id)
if (!realKey) return
const ok = await copyToClipboard(realKey)
- if (ok) toast.success(t('Copied'))
+ if (ok) {
+ toast.success(t('Copied'))
+ } else {
+ toast.error(t('Failed to copy to clipboard'))
+ }
}}
>
{t('Copy Key')}
@@ -242,14 +254,18 @@ export function DataTableRowActions({
{
- const realKey = getCachedRealKey()
+ const realKey = await resolveRealKey(apiKey.id)
if (!realKey) return
const connStr = encodeChannelConnectionInfo(
realKey,
getServerAddress()
)
const ok = await copyToClipboard(connStr)
- if (ok) toast.success(t('Copied'))
+ if (ok) {
+ toast.success(t('Copied'))
+ } else {
+ toast.error(t('Failed to copy to clipboard'))
+ }
}}
>
{t('Copy Connection Info')}
diff --git a/web/default/src/features/profile/api.ts b/web/default/src/features/profile/api.ts
index b4624736..316b8d74 100644
--- a/web/default/src/features/profile/api.ts
+++ b/web/default/src/features/profile/api.ts
@@ -16,7 +16,7 @@ along with this program. If not, see .
For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues
*/
-import { api, type ApiRequestConfig } from '@/lib/api'
+import { api, sensitiveActionConfig } from '@/lib/api'
import { selfCustomOAuthUnbindPath, type CustomOAuthBinding } from '@/lib/oauth'
import { getTurnstileHeaders } from '@/features/auth/lib/turnstile-request'
import type {
@@ -29,11 +29,6 @@ import type {
CheckinResponse,
} from './types'
-const sensitiveActionConfig: ApiRequestConfig = {
- skipBusinessError: true,
- skipErrorHandler: true,
-}
-
// ============================================================================
// User Profile APIs
// ============================================================================
@@ -141,6 +136,47 @@ export async function bindWeChat(code: string): Promise {
return res.data
}
+export interface TelegramAuthorizationPayload {
+ id: number
+ first_name?: string
+ last_name?: string
+ username?: string
+ photo_url?: string
+ auth_date: number
+ hash: string
+}
+
+export async function createTelegramBindState(): Promise<
+ ApiResponse<{ state: string }>
+> {
+ const res = await api.post>(
+ '/api/oauth/telegram/bind/state',
+ undefined,
+ sensitiveActionConfig
+ )
+ return res.data
+}
+
+export async function bindTelegramAccount(
+ payload: TelegramAuthorizationPayload & { state: string }
+): Promise {
+ const res = await api.post(
+ '/api/oauth/telegram/bind',
+ payload,
+ sensitiveActionConfig
+ )
+ return res.data
+}
+
+export async function revokeOtherSessions(): Promise {
+ const res = await api.post(
+ '/api/user/sessions/revoke',
+ undefined,
+ sensitiveActionConfig
+ )
+ return res.data
+}
+
// ============================================================================
// Custom OAuth Binding APIs
// ============================================================================
diff --git a/web/default/src/features/profile/components/dialogs/telegram-bind-dialog.tsx b/web/default/src/features/profile/components/dialogs/telegram-bind-dialog.tsx
index 20d6262b..ec2268a6 100644
--- a/web/default/src/features/profile/components/dialogs/telegram-bind-dialog.tsx
+++ b/web/default/src/features/profile/components/dialogs/telegram-bind-dialog.tsx
@@ -16,9 +16,12 @@ along with this program. If not, see .
For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues
*/
-import { Send } from 'lucide-react'
+import type { ReactElement } from 'react'
+import { RefreshCw, Send } from 'lucide-react'
import { useTranslation } from 'react-i18next'
+import { cn } from '@/lib/utils'
import { Alert, AlertDescription } from '@/components/ui/alert'
+import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
@@ -26,6 +29,8 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
+import { Spinner } from '@/components/ui/spinner'
+import { useTelegramLoginWidget } from '../../hooks/use-telegram-login-widget'
// ============================================================================
// Telegram Bind Dialog Component
@@ -38,14 +43,20 @@ interface TelegramBindDialogProps {
onSuccess: () => void
}
-export function TelegramBindDialog({
- open,
- onOpenChange,
- botName,
-}: TelegramBindDialogProps) {
+export function TelegramBindDialog(
+ props: TelegramBindDialogProps
+): ReactElement {
const { t } = useTranslation()
+ const { widgetContainerRef, status, errorMessage, retry, handleOpenChange } =
+ useTelegramLoginWidget({
+ open: props.open,
+ onOpenChange: props.onOpenChange,
+ botName: props.botName,
+ onSuccess: props.onSuccess,
+ })
+
return (
-