feat: publish v1.0.0-rc.36-custom.1
This commit is contained in:
commit
3a7e8f3b54
2381 files changed
+551494
No files matched your search
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
import type { AboutResponse } from './types'
|
||||
|
||||
export async function getAboutContent(): Promise<AboutResponse> {
|
||||
// See getNotice in @/lib/api: the global `Cache-Control: no-store` is dropped
|
||||
// so the browser can hold an ETag and revalidate, letting the server answer
|
||||
// 304. Server-side `no-cache` keeps admin edits immediate.
|
||||
const res = await api.get<AboutResponse>('/api/about', {
|
||||
headers: { 'Cache-Control': null },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Construction } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { PublicLayout } from '@/components/layout'
|
||||
import { RichContent } from '@/components/rich-content'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { isHttpUrl, isLikelyHtml } from '@/lib/content-format'
|
||||
|
||||
import { getAboutContent } from './api'
|
||||
|
||||
function EmptyAboutState() {
|
||||
const { t } = useTranslation()
|
||||
const currentYear = new Date().getFullYear()
|
||||
|
||||
return (
|
||||
<div className='flex min-h-[60vh] items-center justify-center p-8'>
|
||||
<div className='max-w-2xl space-y-6 text-center'>
|
||||
<div className='flex justify-center'>
|
||||
<Construction className='text-muted-foreground h-24 w-24' />
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<h2 className='text-2xl font-bold'>{t('No About Content Set')}</h2>
|
||||
<p className='text-muted-foreground'>
|
||||
{t(
|
||||
'The administrator has not configured any about content yet. You can set it in the settings page, supporting HTML or URL.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className='space-y-4 text-sm'>
|
||||
<p>
|
||||
{t('New API Project Repository:')}{' '}
|
||||
<a
|
||||
href='https://github.com/QuantumNous/new-api'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-primary hover:underline'
|
||||
>
|
||||
{t('https://github.com/QuantumNous/new-api')}
|
||||
</a>
|
||||
</p>
|
||||
<p className='text-muted-foreground'>
|
||||
<a
|
||||
href='https://github.com/QuantumNous/new-api'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-primary hover:underline'
|
||||
>
|
||||
{t('NewAPI')}
|
||||
</a>{' '}
|
||||
© {currentYear}{' '}
|
||||
<a
|
||||
href='https://github.com/QuantumNous'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-primary hover:underline'
|
||||
>
|
||||
{t('QuantumNous')}
|
||||
</a>{' '}
|
||||
{t('| Based on')}{' '}
|
||||
<a
|
||||
href='https://github.com/songquanpeng/one-api'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-primary hover:underline'
|
||||
>
|
||||
{t('One API')}
|
||||
</a>{' '}
|
||||
© 2023{' '}
|
||||
<a
|
||||
href='https://github.com/songquanpeng'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-primary hover:underline'
|
||||
>
|
||||
{t('JustSong')}
|
||||
</a>
|
||||
</p>
|
||||
<p className='text-muted-foreground'>
|
||||
{t('This project must be used in compliance with the')}{' '}
|
||||
<a
|
||||
href='https://github.com/QuantumNous/new-api/blob/main/LICENSE'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-primary hover:underline'
|
||||
>
|
||||
{t('AGPL v3.0 License')}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function About() {
|
||||
const { t } = useTranslation()
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['about-content'],
|
||||
queryFn: getAboutContent,
|
||||
})
|
||||
|
||||
const rawContent = data?.data?.trim() ?? ''
|
||||
const hasContent = rawContent.length > 0
|
||||
const isUrl = hasContent && isHttpUrl(rawContent)
|
||||
const contentIsHtml = hasContent && isLikelyHtml(rawContent)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PublicLayout>
|
||||
<div className='mx-auto flex max-w-4xl flex-col gap-4 py-12'>
|
||||
<Skeleton className='h-8 w-[45%]' />
|
||||
<Skeleton className='h-4 w-full' />
|
||||
<Skeleton className='h-4 w-[90%]' />
|
||||
<Skeleton className='h-4 w-[80%]' />
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasContent) {
|
||||
return (
|
||||
<PublicLayout>
|
||||
<EmptyAboutState />
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (isUrl) {
|
||||
return (
|
||||
<PublicLayout showMainContainer={false}>
|
||||
<iframe
|
||||
src={rawContent}
|
||||
className='h-[calc(100vh-3.5rem)] w-full border-0'
|
||||
title={t('About')}
|
||||
sandbox='allow-forms allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts'
|
||||
/>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (contentIsHtml) {
|
||||
return (
|
||||
<PublicLayout showMainContainer={false}>
|
||||
<RichContent
|
||||
mode='html'
|
||||
htmlVariant='isolated'
|
||||
content={rawContent}
|
||||
className='prose-neutral dark:prose-invert max-w-none'
|
||||
/>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicLayout>
|
||||
<div className='mx-auto max-w-6xl px-4 py-8'>
|
||||
<RichContent
|
||||
mode='markdown'
|
||||
content={rawContent}
|
||||
className='prose-neutral dark:prose-invert max-w-none'
|
||||
/>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
export type AboutResponse = {
|
||||
success: boolean
|
||||
message: string
|
||||
data?: string
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { toast } from 'sonner'
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { api, type RefreshOutcome } from '@/lib/api'
|
||||
import type { AuthBundle } from '@/stores/auth-store'
|
||||
|
||||
import { executeLogout } from './api'
|
||||
import { useOAuthLogin } from './hooks/use-oauth-login'
|
||||
import { consumeOAuthLoginRedirect } from './lib/oauth-callback-mode'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
test.each([true, false])(
|
||||
'starts Telegram OAuth only when configuration is ready: %s',
|
||||
async (configured) => {
|
||||
vi.spyOn(window, 'localStorage', 'get').mockReturnValue(
|
||||
window.sessionStorage
|
||||
)
|
||||
const post = vi.spyOn(api, 'post').mockImplementation(async (url) => {
|
||||
if (url === '/api/oauth/state') {
|
||||
return {
|
||||
data: {
|
||||
success: true,
|
||||
data: {
|
||||
flow_token: 'telegram-state',
|
||||
authorization_url: 'https://oauth.telegram.org/auth?server=pkce',
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
if (url === '/api/user/auth/logout') return { data: { success: true } }
|
||||
throw new Error(`Unexpected POST ${url}`)
|
||||
})
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
const error = vi.spyOn(toast, 'error')
|
||||
const { result } = renderHook(() =>
|
||||
useOAuthLogin(
|
||||
{ telegram_oauth: true, telegram_oauth_configured: configured },
|
||||
'/console/personal'
|
||||
)
|
||||
)
|
||||
await act(() => result.current.handleTelegramLogin())
|
||||
if (configured) {
|
||||
expect(post.mock.calls.map(([url]) => url)).toEqual([
|
||||
'/api/oauth/state',
|
||||
'/api/user/auth/logout',
|
||||
])
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
'/api/oauth/state',
|
||||
expect.objectContaining({ provider: 'telegram', intent: 'login' }),
|
||||
expect.anything()
|
||||
)
|
||||
expect(open).toHaveBeenCalledWith(
|
||||
'https://oauth.telegram.org/auth?server=pkce',
|
||||
'_self'
|
||||
)
|
||||
expect(consumeOAuthLoginRedirect('telegram-state')).toBe(
|
||||
'/console/personal'
|
||||
)
|
||||
} else {
|
||||
expect(post).not.toHaveBeenCalled()
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
'Telegram OAuth is not configured or enabled. Please contact your administrator.'
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const bundle: AuthBundle = {
|
||||
access_token: 'access-token',
|
||||
token_type: 'Bearer',
|
||||
access_expires_at: 1_900_000_000,
|
||||
user: { id: 1, username: 'test-user', role: 1 },
|
||||
session: {
|
||||
sid: 'session-b',
|
||||
current: true,
|
||||
login_method: 'password',
|
||||
ip: '127.0.0.1',
|
||||
user_agent: 'test',
|
||||
created_at: 1,
|
||||
last_active_at: 1,
|
||||
expires_at: 1_900_000_000,
|
||||
},
|
||||
}
|
||||
|
||||
function mismatchError() {
|
||||
return {
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status: 409,
|
||||
data: { code: 'AUTH_SESSION_MISMATCH' },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('logout coordination', () => {
|
||||
test('returns an unsuccessful response without pretending to sign out', async () => {
|
||||
let refreshCount = 0
|
||||
const result = await executeLogout({
|
||||
getExpectedSID: () => 'session-a',
|
||||
request: async () => ({ success: false, message: 'not revoked' }),
|
||||
refresh: async () => {
|
||||
refreshCount += 1
|
||||
return { kind: 'anonymous' }
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual({ success: false, message: 'not revoked' })
|
||||
expect(refreshCount).toBe(0)
|
||||
})
|
||||
|
||||
test('recovers a cookie mismatch and retries with the refreshed SID', async () => {
|
||||
let sid = 'session-a'
|
||||
const requestedSIDs: Array<string | undefined> = []
|
||||
const result = await executeLogout({
|
||||
getExpectedSID: () => sid,
|
||||
request: async (expectedSID) => {
|
||||
requestedSIDs.push(expectedSID)
|
||||
if (requestedSIDs.length === 1) throw mismatchError()
|
||||
return { success: true, message: '' }
|
||||
},
|
||||
refresh: async () => {
|
||||
sid = bundle.session.sid
|
||||
return { kind: 'authenticated', bundle }
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual({ success: true, message: '' })
|
||||
expect(requestedSIDs).toEqual(['session-a', 'session-b'])
|
||||
})
|
||||
|
||||
test('treats a mismatch that refresh confirms anonymous as signed out', async () => {
|
||||
const result = await executeLogout({
|
||||
getExpectedSID: () => 'session-a',
|
||||
request: async () => {
|
||||
throw mismatchError()
|
||||
},
|
||||
refresh: async () => ({ kind: 'anonymous' }),
|
||||
})
|
||||
|
||||
expect(result).toEqual({ success: true, message: '' })
|
||||
})
|
||||
|
||||
test('preserves the active session when mismatch recovery is temporary', async () => {
|
||||
const originalError = mismatchError()
|
||||
const transient: RefreshOutcome = {
|
||||
kind: 'transient_error',
|
||||
error: new Error('offline'),
|
||||
}
|
||||
|
||||
await expect(
|
||||
executeLogout({
|
||||
getExpectedSID: () => 'session-a',
|
||||
request: async () => {
|
||||
throw originalError
|
||||
},
|
||||
refresh: async () => transient,
|
||||
})
|
||||
).rejects.toBe(originalError)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,282 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import axios from 'axios'
|
||||
|
||||
import { api, refreshAuthentication, type RefreshOutcome } from '@/lib/api'
|
||||
import { AuthOperationError } from '@/lib/secure-verification'
|
||||
import { getServerErrorMessageKey } from '@/lib/server-error-message'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
import {
|
||||
clearPasswordEncryptionCache,
|
||||
encryptPassword,
|
||||
} from './lib/password-encryption'
|
||||
import { getAffiliateCode } from './lib/storage'
|
||||
import type { TelegramAuthorization } from './lib/telegram-login'
|
||||
import type { VerificationOperation } from './secure-verification/types'
|
||||
import type {
|
||||
LoginPayload,
|
||||
LoginResponse,
|
||||
Login2FAResponse,
|
||||
TwoFAPayload,
|
||||
RegisterPayload,
|
||||
ApiResponse,
|
||||
} from './types'
|
||||
|
||||
// ============================================================================
|
||||
// Authentication APIs
|
||||
// ============================================================================
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Login & Logout
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// User login with username and password
|
||||
export async function login(payload: LoginPayload): Promise<LoginResponse> {
|
||||
const turnstile = payload.turnstile ?? ''
|
||||
try {
|
||||
let passwordFields:
|
||||
| { password: string }
|
||||
| { password_encrypted: string; encryption_key_id: string }
|
||||
if (payload.passwordEncryptionEnabled) {
|
||||
const encryptedPassword = await encryptPassword(payload.password)
|
||||
passwordFields = {
|
||||
password_encrypted: encryptedPassword.password_encrypted,
|
||||
encryption_key_id: encryptedPassword.encryption_key_id,
|
||||
}
|
||||
} else {
|
||||
passwordFields = { password: payload.password }
|
||||
}
|
||||
const res = await api.post<LoginResponse>(
|
||||
`/api/user/login?turnstile=${turnstile}`,
|
||||
{
|
||||
username: payload.username,
|
||||
...passwordFields,
|
||||
},
|
||||
{ skipAuthRefresh: true }
|
||||
)
|
||||
if (payload.passwordEncryptionEnabled && !res.data?.success) {
|
||||
clearPasswordEncryptionCache()
|
||||
}
|
||||
return res.data
|
||||
} catch (error: unknown) {
|
||||
if (payload.passwordEncryptionEnabled) {
|
||||
clearPasswordEncryptionCache()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Two-factor authentication login
|
||||
export async function login2fa(payload: TwoFAPayload) {
|
||||
const res = await api.post<Login2FAResponse>('/api/user/login/2fa', payload, {
|
||||
skipAuthRefresh: true,
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
interface LogoutRuntime {
|
||||
getExpectedSID: () => string | undefined
|
||||
request: (expectedSID?: string) => Promise<ApiResponse>
|
||||
refresh: () => Promise<RefreshOutcome>
|
||||
}
|
||||
|
||||
export async function executeLogout(
|
||||
runtime: LogoutRuntime,
|
||||
allowMismatchRecovery = true
|
||||
): Promise<ApiResponse> {
|
||||
try {
|
||||
return await runtime.request(runtime.getExpectedSID())
|
||||
} catch (error: unknown) {
|
||||
const code = axios.isAxiosError(error)
|
||||
? error.response?.data?.code
|
||||
: undefined
|
||||
if (
|
||||
allowMismatchRecovery &&
|
||||
axios.isAxiosError(error) &&
|
||||
error.response?.status === 409 &&
|
||||
code === 'AUTH_SESSION_MISMATCH'
|
||||
) {
|
||||
const outcome = await runtime.refresh()
|
||||
if (outcome.kind === 'authenticated') {
|
||||
return executeLogout(runtime, false)
|
||||
}
|
||||
if (outcome.kind === 'anonymous') {
|
||||
return { success: true, message: '' }
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// User logout
|
||||
export async function logout(): Promise<ApiResponse> {
|
||||
return executeLogout({
|
||||
getExpectedSID: () => useAuthStore.getState().auth.session?.sid,
|
||||
request: async (sid) => {
|
||||
const res = await api.post('/api/user/auth/logout', undefined, {
|
||||
headers: sid ? { 'X-Auth-Session': sid } : undefined,
|
||||
skipAuthRefresh: true,
|
||||
skipErrorHandler: true,
|
||||
})
|
||||
return res.data
|
||||
},
|
||||
refresh: refreshAuthentication,
|
||||
})
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Password Management
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Send password reset email
|
||||
export async function sendPasswordResetEmail(
|
||||
email: string,
|
||||
turnstile?: string
|
||||
): Promise<ApiResponse> {
|
||||
const res = await api.get('/api/reset_password', {
|
||||
params: { email, turnstile },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// OAuth
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Start GitHub OAuth flow
|
||||
export async function githubOAuthStart(clientId: string, state: string) {
|
||||
const url = `https://github.com/login/oauth/authorize?client_id=${clientId}&state=${state}&scope=user:email`
|
||||
window.open(url)
|
||||
}
|
||||
|
||||
// Get OAuth state for CSRF protection
|
||||
export async function createOAuthAuthorization(
|
||||
provider: string,
|
||||
intent: 'login' | 'bind' | 'verify',
|
||||
operation?: VerificationOperation,
|
||||
signal?: AbortSignal,
|
||||
proofToken?: string
|
||||
): Promise<{ state: string; authorizationUrl?: string }> {
|
||||
const aff = intent === 'login' ? getAffiliateCode() : ''
|
||||
const res = await api.post(
|
||||
'/api/oauth/state',
|
||||
{
|
||||
provider,
|
||||
intent,
|
||||
aff: aff || undefined,
|
||||
scope: operation?.scope,
|
||||
...(operation?.context ? { context: operation.context } : {}),
|
||||
},
|
||||
{
|
||||
skipAuthRefresh: intent === 'login',
|
||||
...(proofToken ? { headers: { 'X-Security-Proof': proofToken } } : {}),
|
||||
singleUseAuthorization: intent === 'bind',
|
||||
signal,
|
||||
skipBusinessError: true,
|
||||
skipErrorHandler: true,
|
||||
}
|
||||
)
|
||||
if (res.data?.success) {
|
||||
if (typeof res.data.data === 'string') return { state: res.data.data }
|
||||
if (typeof res.data.data?.flow_token === 'string') {
|
||||
return {
|
||||
state: res.data.data.flow_token,
|
||||
authorizationUrl: res.data.data.authorization_url,
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new AuthOperationError(
|
||||
getServerErrorMessageKey(res.data) ||
|
||||
res.data?.message ||
|
||||
'Failed to initialize OAuth',
|
||||
res.data?.code
|
||||
)
|
||||
}
|
||||
|
||||
export async function createOAuthFlow(
|
||||
provider: string,
|
||||
intent: 'login' | 'bind' | 'verify',
|
||||
operation?: VerificationOperation,
|
||||
signal?: AbortSignal
|
||||
): Promise<string> {
|
||||
return (await createOAuthAuthorization(provider, intent, operation, signal))
|
||||
.state
|
||||
}
|
||||
|
||||
// WeChat login by authorization code
|
||||
export async function wechatLoginByCode(code: string): Promise<ApiResponse> {
|
||||
const res = await api.get('/api/oauth/wechat', { params: { code } })
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function telegramLogin(
|
||||
authorization: TelegramAuthorization
|
||||
): Promise<ApiResponse> {
|
||||
const res = await api.get('/api/oauth/telegram/login', {
|
||||
params: authorization,
|
||||
disableDuplicate: true,
|
||||
skipAuthRefresh: true,
|
||||
skipBusinessError: true,
|
||||
skipErrorHandler: true,
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Registration
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// User registration
|
||||
export async function register(payload: RegisterPayload): Promise<ApiResponse> {
|
||||
const res = await api.post(`/api/user/register`, payload, {
|
||||
params: { turnstile: payload.turnstile ?? '' },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
// Send email verification code
|
||||
export async function sendEmailVerification(
|
||||
email: string,
|
||||
turnstile?: string
|
||||
): Promise<ApiResponse> {
|
||||
const res = await api.get('/api/verification', {
|
||||
params: { email, turnstile },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
// Confirm an authenticated, server-owned email binding flow.
|
||||
export async function bindEmail(
|
||||
flowToken: string,
|
||||
newCode: string,
|
||||
oldCode = '',
|
||||
signal?: AbortSignal
|
||||
): Promise<ApiResponse> {
|
||||
const res = await api.post(
|
||||
'/api/oauth/email/bind',
|
||||
{
|
||||
flow_token: flowToken,
|
||||
new_code: newCode,
|
||||
old_code: oldCode,
|
||||
},
|
||||
{ singleUseAuthorization: true, signal }
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useSystemConfig } from '@/hooks/use-system-config'
|
||||
|
||||
type AuthLayoutProps = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function AuthLayout({ children }: AuthLayoutProps) {
|
||||
const { t } = useTranslation()
|
||||
const { systemName, logo, loading } = useSystemConfig()
|
||||
|
||||
return (
|
||||
<div className='relative grid h-svh max-w-none'>
|
||||
<Link
|
||||
to='/'
|
||||
className='absolute top-4 left-4 z-10 flex items-center gap-2 transition-opacity hover:opacity-80 sm:top-8 sm:left-8'
|
||||
>
|
||||
<div className='relative h-8 w-8'>
|
||||
{loading ? (
|
||||
<Skeleton className='absolute inset-0 rounded-full' />
|
||||
) : (
|
||||
<img
|
||||
src={logo}
|
||||
alt={t('Logo')}
|
||||
className='h-8 w-8 rounded-full object-cover'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{loading ? (
|
||||
<Skeleton className='h-6 w-24' />
|
||||
) : (
|
||||
<h1 className='text-xl font-medium'>{systemName}</h1>
|
||||
)}
|
||||
</Link>
|
||||
<div className='container flex items-center pt-16 sm:pt-0'>
|
||||
<div className='mx-auto flex w-full flex-col justify-center space-y-2 px-4 py-8 sm:w-[480px] sm:p-8'>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { SystemStatus } from '../types'
|
||||
|
||||
interface LegalConsentProps {
|
||||
status: SystemStatus | null
|
||||
checked: boolean
|
||||
onCheckedChange: (nextValue: boolean) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function LegalConsent({
|
||||
status,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
className,
|
||||
}: LegalConsentProps) {
|
||||
const { t } = useTranslation()
|
||||
const hasUserAgreement = Boolean(status?.user_agreement_enabled)
|
||||
const hasPrivacyPolicy = Boolean(status?.privacy_policy_enabled)
|
||||
|
||||
if (!hasUserAgreement && !hasPrivacyPolicy) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleChange = (value: boolean) => {
|
||||
onCheckedChange(value === true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-border/60 bg-muted/40 flex items-start gap-3 rounded-md border p-3',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id='legal-consent'
|
||||
checked={checked}
|
||||
onCheckedChange={handleChange}
|
||||
className='mt-0.5'
|
||||
/>
|
||||
<Label
|
||||
htmlFor='legal-consent'
|
||||
className='text-muted-foreground items-start gap-1 text-left text-xs leading-5 font-normal'
|
||||
>
|
||||
<span>
|
||||
{t('I have read and agree to the')}{' '}
|
||||
{hasUserAgreement && (
|
||||
<a
|
||||
href='/user-agreement'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-primary hover:underline'
|
||||
>
|
||||
{t('User Agreement')}
|
||||
</a>
|
||||
)}
|
||||
{hasUserAgreement && hasPrivacyPolicy && ' and the '}
|
||||
{hasPrivacyPolicy && (
|
||||
<a
|
||||
href='/privacy-policy'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-primary hover:underline'
|
||||
>
|
||||
{t('Privacy Policy')}
|
||||
</a>
|
||||
)}
|
||||
.
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Loader2, Send, Shield, UserRound, type LucideIcon } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SiGithub, SiLinux, SiWechat } from 'react-icons/si'
|
||||
|
||||
import { AuthLayout } from '../auth-layout'
|
||||
|
||||
type OAuthCallbackScreenProps = {
|
||||
provider: string
|
||||
mode: 'login' | 'bind' | 'verify'
|
||||
}
|
||||
|
||||
type ProviderMeta = {
|
||||
label: string
|
||||
Icon: LucideIcon | ((props: { className?: string }) => React.JSX.Element)
|
||||
}
|
||||
|
||||
const providerDictionary: Record<string, ProviderMeta> = {
|
||||
github: {
|
||||
label: 'GitHub',
|
||||
Icon: (props: { className?: string }) => (
|
||||
<SiGithub className={props.className} focusable='false' />
|
||||
),
|
||||
},
|
||||
oidc: { label: 'OIDC', Icon: Shield },
|
||||
linuxdo: {
|
||||
label: 'LinuxDO',
|
||||
Icon: (props: { className?: string }) => (
|
||||
<SiLinux className={props.className} focusable='false' />
|
||||
),
|
||||
},
|
||||
telegram: { label: 'Telegram', Icon: Send },
|
||||
wechat: {
|
||||
label: 'WeChat',
|
||||
Icon: (props: { className?: string }) => (
|
||||
<SiWechat className={props.className} focusable='false' />
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
export function OAuthCallbackScreen({
|
||||
provider,
|
||||
mode,
|
||||
}: OAuthCallbackScreenProps) {
|
||||
const { t } = useTranslation()
|
||||
const { label, Icon } = useMemo(() => {
|
||||
const normalized = provider?.toLowerCase() ?? ''
|
||||
return (
|
||||
providerDictionary[normalized] || {
|
||||
label: 'account',
|
||||
Icon: UserRound,
|
||||
}
|
||||
)
|
||||
}, [provider])
|
||||
|
||||
const providerLabel = t(label)
|
||||
let headline = t('Signing you in with {{provider}}', {
|
||||
provider: providerLabel,
|
||||
})
|
||||
let description = t('Hang tight while we finish connecting your account.')
|
||||
let secondaryNote = t(
|
||||
"You'll be redirected automatically. You can return to the previous page if nothing happens after a few seconds."
|
||||
)
|
||||
if (mode === 'bind') {
|
||||
headline = t('Binding your {{provider}} account', {
|
||||
provider: providerLabel,
|
||||
})
|
||||
description = t(
|
||||
'Hang tight while we securely link this account to your profile.'
|
||||
)
|
||||
}
|
||||
if (mode === 'verify') {
|
||||
headline = t('Verifying your {{provider}} account', {
|
||||
provider: providerLabel,
|
||||
})
|
||||
description = t('Confirming the account linked to your profile.')
|
||||
}
|
||||
if (mode !== 'login') {
|
||||
secondaryNote = t('Return to the original window to continue.')
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<div className='w-full space-y-8'>
|
||||
<div className='flex flex-col items-center space-y-4 text-center'>
|
||||
<div className='bg-muted flex h-16 w-16 items-center justify-center rounded-2xl'>
|
||||
<Icon className='h-8 w-8' />
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<h2 className='text-center text-2xl font-semibold tracking-tight'>
|
||||
{headline}
|
||||
</h2>
|
||||
<p className='text-muted-foreground text-sm sm:text-base'>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-4 text-center'>
|
||||
<div className='flex items-center justify-center gap-2 text-sm font-medium'>
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
<span>{t('Processing OAuth response...')}</span>
|
||||
</div>
|
||||
<p className='text-muted-foreground text-sm'>{secondaryNote}</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'This may take a few moments while we validate the request and update your session.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
IconDiscord,
|
||||
IconGithub,
|
||||
IconLinuxDo,
|
||||
IconTelegram,
|
||||
IconWeChat,
|
||||
} from '@/assets/brand-icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { useOAuthLogin } from '../hooks/use-oauth-login'
|
||||
import type { SystemStatus } from '../types'
|
||||
|
||||
type OAuthProvidersProps = {
|
||||
status: SystemStatus | null
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
onWeChatLogin?: () => void
|
||||
isWeChatLoading?: boolean
|
||||
redirectTo?: string
|
||||
}
|
||||
|
||||
type ProviderButton = {
|
||||
key: string
|
||||
label: string
|
||||
onClick: () => void
|
||||
icon?: ReactNode
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function OAuthProviders({
|
||||
status,
|
||||
disabled = false,
|
||||
className,
|
||||
onWeChatLogin,
|
||||
isWeChatLoading = false,
|
||||
redirectTo,
|
||||
}: OAuthProvidersProps) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
isLoading,
|
||||
githubButtonText,
|
||||
githubButtonDisabled,
|
||||
handleGitHubLogin,
|
||||
handleDiscordLogin,
|
||||
handleOIDCLogin,
|
||||
handleLinuxDOLogin,
|
||||
handleTelegramLogin,
|
||||
handleCustomOAuthLogin,
|
||||
} = useOAuthLogin(status, redirectTo)
|
||||
|
||||
const providerButtons: ProviderButton[] = []
|
||||
|
||||
if (status?.wechat_login && onWeChatLogin) {
|
||||
providerButtons.push({
|
||||
key: 'wechat',
|
||||
label: t('Continue with WeChat'),
|
||||
onClick: onWeChatLogin,
|
||||
icon: <IconWeChat className='h-4 w-4' />,
|
||||
disabled: isWeChatLoading,
|
||||
})
|
||||
}
|
||||
|
||||
if (status?.github_oauth) {
|
||||
providerButtons.push({
|
||||
key: 'github',
|
||||
label: githubButtonText || t('Continue with GitHub'),
|
||||
onClick: handleGitHubLogin,
|
||||
icon: <IconGithub className='h-4 w-4' />,
|
||||
disabled: githubButtonDisabled,
|
||||
})
|
||||
}
|
||||
|
||||
if (status?.discord_oauth) {
|
||||
providerButtons.push({
|
||||
key: 'discord',
|
||||
label: t('Continue with Discord'),
|
||||
onClick: handleDiscordLogin,
|
||||
icon: <IconDiscord className='h-4 w-4' />,
|
||||
})
|
||||
}
|
||||
|
||||
if (status?.oidc_enabled) {
|
||||
const oidcDisplayName = status.oidc_display_name?.trim() || 'OIDC'
|
||||
providerButtons.push({
|
||||
key: 'oidc',
|
||||
label: t('Continue with {{name}}', {
|
||||
name: oidcDisplayName,
|
||||
}),
|
||||
onClick: handleOIDCLogin,
|
||||
})
|
||||
}
|
||||
|
||||
if (status?.linuxdo_oauth) {
|
||||
providerButtons.push({
|
||||
key: 'linuxdo',
|
||||
label: t('Continue with LinuxDO'),
|
||||
onClick: handleLinuxDOLogin,
|
||||
icon: <IconLinuxDo className='h-4 w-4' />,
|
||||
})
|
||||
}
|
||||
|
||||
if (status?.telegram_oauth) {
|
||||
providerButtons.push({
|
||||
key: 'telegram',
|
||||
label: t('Continue with Telegram'),
|
||||
onClick: handleTelegramLogin,
|
||||
icon: <IconTelegram data-icon='inline-start' />,
|
||||
})
|
||||
}
|
||||
|
||||
// Custom OAuth providers
|
||||
const customProviders = status?.custom_oauth_providers
|
||||
if (customProviders && customProviders.length > 0) {
|
||||
for (const provider of customProviders) {
|
||||
providerButtons.push({
|
||||
key: `custom-${provider.slug}`,
|
||||
label: t('Continue with {{name}}', { name: provider.name }),
|
||||
onClick: () => handleCustomOAuthLogin(provider),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (providerButtons.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-3', className)}>
|
||||
<div className='relative'>
|
||||
<div className='absolute inset-0 flex items-center'>
|
||||
<span className='w-full border-t' />
|
||||
</div>
|
||||
<div className='relative flex justify-center text-xs uppercase'>
|
||||
<span className='bg-background text-muted-foreground px-2'>
|
||||
{t('Or continue with')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-2'>
|
||||
{providerButtons.map(
|
||||
({ key, label, onClick, icon, disabled: extraDisabled }) => (
|
||||
<Button
|
||||
key={key}
|
||||
variant='outline'
|
||||
type='button'
|
||||
disabled={disabled || isLoading || extraDisabled}
|
||||
onClick={onClick}
|
||||
className='h-11 w-full justify-center gap-2 rounded-lg'
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
|
||||
type TelegramLoginDialogProps = {
|
||||
open: boolean
|
||||
botName: string
|
||||
pending: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onAuthorization: (authorization: unknown) => void
|
||||
}
|
||||
|
||||
let telegramCallbackSequence = 0
|
||||
|
||||
export function TelegramLoginDialog(props: TelegramLoginDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const widgetContainer = useRef<HTMLDivElement | null>(null)
|
||||
const authorizationHandler = useRef(props.onAuthorization)
|
||||
const [callbackName] = useState(
|
||||
() => `newApiTelegramLogin${++telegramCallbackSequence}`
|
||||
)
|
||||
const [widgetState, setWidgetState] = useState<
|
||||
'idle' | 'loading' | 'ready' | 'failed'
|
||||
>('idle')
|
||||
|
||||
useEffect(() => {
|
||||
authorizationHandler.current = props.onAuthorization
|
||||
}, [props.onAuthorization])
|
||||
|
||||
useEffect(() => {
|
||||
const container = widgetContainer.current
|
||||
const botName = props.botName.trim()
|
||||
if (!props.open || !container || !botName) return
|
||||
|
||||
setWidgetState('loading')
|
||||
const callback = (authorization: unknown) => {
|
||||
authorizationHandler.current(authorization)
|
||||
}
|
||||
const browserWindow = window as unknown as Record<string, unknown>
|
||||
browserWindow[callbackName] = callback
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.async = true
|
||||
script.src = 'https://telegram.org/js/telegram-widget.js?22'
|
||||
script.dataset.telegramLogin = botName
|
||||
script.dataset.size = 'large'
|
||||
script.dataset.radius = '8'
|
||||
script.dataset.onauth = `${callbackName}(user)`
|
||||
const handleLoad = () => setWidgetState('ready')
|
||||
const handleError = () => setWidgetState('failed')
|
||||
script.addEventListener('load', handleLoad)
|
||||
script.addEventListener('error', handleError)
|
||||
container.replaceChildren(script)
|
||||
|
||||
return () => {
|
||||
script.removeEventListener('load', handleLoad)
|
||||
script.removeEventListener('error', handleError)
|
||||
container.replaceChildren()
|
||||
delete browserWindow[callbackName]
|
||||
}
|
||||
}, [callbackName, props.botName, props.open])
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
title={t('Telegram Login Widget')}
|
||||
description={t('Continue with Telegram')}
|
||||
contentClassName='max-w-sm'
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
>
|
||||
<div
|
||||
className='flex min-h-12 items-center justify-center'
|
||||
aria-busy={widgetState === 'loading' || props.pending}
|
||||
>
|
||||
{(widgetState === 'loading' || props.pending) && <Spinner />}
|
||||
{widgetState === 'failed' && (
|
||||
<p className='text-destructive text-sm'>{t('Login failed')}</p>
|
||||
)}
|
||||
<div
|
||||
ref={widgetContainer}
|
||||
className={
|
||||
widgetState === 'ready' && !props.pending ? 'block' : 'hidden'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { SystemStatus } from '../types'
|
||||
|
||||
interface TermsFooterProps {
|
||||
variant?: 'sign-in' | 'sign-up'
|
||||
className?: string
|
||||
status?: SystemStatus | null
|
||||
}
|
||||
|
||||
export function TermsFooter({
|
||||
variant = 'sign-in',
|
||||
className,
|
||||
status,
|
||||
}: TermsFooterProps) {
|
||||
const { t } = useTranslation()
|
||||
const text =
|
||||
variant === 'sign-in'
|
||||
? 'By clicking sign in, you agree to our'
|
||||
: 'By creating an account, you agree to our'
|
||||
|
||||
const hasUserAgreement = Boolean(status?.user_agreement_enabled)
|
||||
const hasPrivacyPolicy = Boolean(status?.privacy_policy_enabled)
|
||||
|
||||
if (!hasUserAgreement && !hasPrivacyPolicy) {
|
||||
return null
|
||||
}
|
||||
|
||||
const agreementLink = {
|
||||
label: 'User Agreement',
|
||||
href: '/user-agreement',
|
||||
}
|
||||
const privacyLink = {
|
||||
label: 'Privacy Policy',
|
||||
href: '/privacy-policy',
|
||||
}
|
||||
|
||||
const activeLinks =
|
||||
hasUserAgreement || hasPrivacyPolicy
|
||||
? ([
|
||||
hasUserAgreement ? agreementLink : null,
|
||||
hasPrivacyPolicy ? privacyLink : null,
|
||||
].filter(Boolean) as Array<{ label: string; href: string }>)
|
||||
: [agreementLink, privacyLink]
|
||||
|
||||
const [firstLink, secondLink] = activeLinks
|
||||
|
||||
return (
|
||||
<p className={cn('text-muted-foreground text-center text-xs', className)}>
|
||||
{text}{' '}
|
||||
{firstLink && (
|
||||
<a
|
||||
href={firstLink.href}
|
||||
className='hover:text-primary underline underline-offset-4'
|
||||
>
|
||||
{firstLink.label}
|
||||
</a>
|
||||
)}
|
||||
{secondLink && (
|
||||
<>
|
||||
{' '}
|
||||
{t('and')}{' '}
|
||||
<a
|
||||
href={secondLink.href}
|
||||
className='hover:text-primary underline underline-offset-4'
|
||||
>
|
||||
{secondLink.label}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
|
||||
import { accountPasswordSchema } from '@/lib/password-policy'
|
||||
|
||||
// ============================================================================
|
||||
// Form Schemas
|
||||
// ============================================================================
|
||||
|
||||
export const loginFormSchema = z.object({
|
||||
username: z.string().min(1, 'Please enter your username or email'),
|
||||
password: z.string().min(1, 'Please enter your password'),
|
||||
})
|
||||
|
||||
export const registerFormSchema = z
|
||||
.object({
|
||||
username: z.string().min(1, 'Please enter your username'),
|
||||
email: z.string().optional(),
|
||||
password: accountPasswordSchema,
|
||||
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords don't match.",
|
||||
path: ['confirmPassword'],
|
||||
})
|
||||
|
||||
export const forgotPasswordFormSchema = z.object({
|
||||
email: z.string().email({
|
||||
message: 'Please enter a valid email address',
|
||||
}),
|
||||
})
|
||||
|
||||
export const otpFormSchema = z.object({
|
||||
otp: z.string().min(1, 'Please enter a code.'),
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Validation Constants
|
||||
// ============================================================================
|
||||
|
||||
export const PASSWORD_MIN_LENGTH = 8
|
||||
export const PASSWORD_MAX_LENGTH = 20
|
||||
export const OTP_LENGTH = 6
|
||||
export const BACKUP_CODE_LENGTH = 9 // XXXX-XXXX format
|
||||
export const BACKUP_CODE_REGEX = /^[A-Z0-9]{4}-[A-Z0-9]{4}$/i
|
||||
export const OTP_REGEX = /^\d{6}$/
|
||||
|
||||
// ============================================================================
|
||||
// Countdown Constants
|
||||
// ============================================================================
|
||||
|
||||
export const EMAIL_VERIFICATION_COUNTDOWN = 30 // seconds
|
||||
export const PASSWORD_RESET_COUNTDOWN = 30 // seconds
|
||||
|
||||
// ============================================================================
|
||||
// OAuth Constants
|
||||
// ============================================================================
|
||||
|
||||
export const OAUTH_POPUP_CALLBACK_MESSAGE = 'oauth:popup:callback'
|
||||
export const OAUTH_POPUP_RESULT_MESSAGE = 'oauth:popup:result'
|
||||
export const TELEGRAM_BIND_RESULT_MESSAGE = 'telegram:binding:result'
|
||||
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { ArrowRight, Loader2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import type { z } from 'zod'
|
||||
|
||||
import { Turnstile } from '@/components/turnstile'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { sendPasswordResetEmail } from '@/features/auth/api'
|
||||
import {
|
||||
forgotPasswordFormSchema,
|
||||
PASSWORD_RESET_COUNTDOWN,
|
||||
} from '@/features/auth/constants'
|
||||
import { useTurnstile } from '@/features/auth/hooks/use-turnstile'
|
||||
import { useCountdown } from '@/hooks/use-countdown'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function ForgotPasswordForm({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLFormElement>) {
|
||||
const { t } = useTranslation()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const {
|
||||
isTurnstileEnabled,
|
||||
turnstileSiteKey,
|
||||
turnstileToken,
|
||||
setTurnstileToken,
|
||||
validateTurnstile,
|
||||
} = useTurnstile()
|
||||
const {
|
||||
secondsLeft,
|
||||
isActive,
|
||||
start: startCountdown,
|
||||
} = useCountdown({ initialSeconds: PASSWORD_RESET_COUNTDOWN })
|
||||
|
||||
const form = useForm<z.infer<typeof forgotPasswordFormSchema>>({
|
||||
resolver: zodResolver(forgotPasswordFormSchema),
|
||||
defaultValues: { email: '' },
|
||||
})
|
||||
const turnstileReady = !isTurnstileEnabled || Boolean(turnstileToken)
|
||||
|
||||
async function onSubmit(data: z.infer<typeof forgotPasswordFormSchema>) {
|
||||
if (!validateTurnstile()) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await sendPasswordResetEmail(data.email, turnstileToken)
|
||||
if (res?.success) {
|
||||
form.reset()
|
||||
startCountdown()
|
||||
toast.success(t('Reset email sent, please check your inbox'))
|
||||
} else {
|
||||
toast.error(res?.message || t('Failed to send reset email'))
|
||||
}
|
||||
} catch (_error) {
|
||||
// Errors are handled by global interceptor
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className={cn('grid gap-2', className)}
|
||||
{...props}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='name@example.com' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
className='mt-2'
|
||||
disabled={isLoading || isActive || !turnstileReady}
|
||||
>
|
||||
{isActive
|
||||
? t('Resend ({{seconds}}s)', { seconds: secondsLeft })
|
||||
: t('Send reset email')}
|
||||
{isLoading ? <Loader2 className='animate-spin' /> : <ArrowRight />}
|
||||
</Button>
|
||||
|
||||
{isTurnstileEnabled && (
|
||||
<div className='mt-2'>
|
||||
<Turnstile
|
||||
siteKey={turnstileSiteKey}
|
||||
onVerify={setTurnstileToken}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { AuthLayout } from '../auth-layout'
|
||||
import { ForgotPasswordForm } from './components/forgot-password-form'
|
||||
|
||||
export function ForgotPassword() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<AuthLayout>
|
||||
<div className='w-full space-y-8'>
|
||||
<div className='space-y-3'>
|
||||
<h2 className='text-center text-2xl font-semibold tracking-tight sm:text-left'>
|
||||
{t('Forgot password')}
|
||||
</h2>
|
||||
<p className='text-muted-foreground text-left text-sm sm:text-base'>
|
||||
{t(
|
||||
'Enter your registered email and we will send you a link to reset your password.'
|
||||
)}
|
||||
</p>
|
||||
<p className='text-muted-foreground text-left text-sm sm:text-base'>
|
||||
{t("Don't have an account?")}{' '}
|
||||
<Link
|
||||
to='/sign-up'
|
||||
className='hover:text-primary font-medium underline underline-offset-4'
|
||||
>
|
||||
{t('Sign up')}
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ForgotPasswordForm className='space-y-0' />
|
||||
</div>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import i18n from 'i18next'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import {
|
||||
getSavedLanguage,
|
||||
sanitizeAuthRedirect,
|
||||
} from '@/features/auth/lib/auth-redirect'
|
||||
import { applyAuthBundle, isAuthBundle } from '@/lib/api'
|
||||
import { AuthOperationError } from '@/lib/secure-verification'
|
||||
import { useAuthStore, type AuthBundle } from '@/stores/auth-store'
|
||||
|
||||
import { isLoginChallenge } from '../secure-verification/api'
|
||||
|
||||
/**
|
||||
* Hook for handling authentication redirects and user data management
|
||||
*/
|
||||
export function useAuthRedirect() {
|
||||
const navigate = useNavigate()
|
||||
const sessionID = useAuthStore((state) => state.auth.session?.sid)
|
||||
const mounted = useRef(true)
|
||||
useEffect(() => {
|
||||
mounted.current = true
|
||||
return () => {
|
||||
mounted.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Handle successful login
|
||||
* @param userData - Optional user data from login response
|
||||
* @param redirectTo - Redirect path after login
|
||||
*/
|
||||
const handleLoginSuccess = useCallback(
|
||||
async (bundle: AuthBundle, redirectTo?: string) => {
|
||||
if (
|
||||
!mounted.current ||
|
||||
useAuthStore.getState().auth.session?.sid !== sessionID
|
||||
) {
|
||||
return
|
||||
}
|
||||
applyAuthBundle(bundle)
|
||||
const savedLang = getSavedLanguage(bundle.user)
|
||||
if (savedLang && savedLang !== i18n.language) {
|
||||
await i18n.changeLanguage(savedLang)
|
||||
}
|
||||
|
||||
const targetPath =
|
||||
sanitizeAuthRedirect(redirectTo, window.location.origin) ?? '/dashboard'
|
||||
await navigate({ href: targetPath, replace: true })
|
||||
},
|
||||
[navigate, sessionID]
|
||||
)
|
||||
|
||||
/**
|
||||
* Every primary login transport returns the same bundle-or-challenge contract.
|
||||
*/
|
||||
const handleLoginResult = useCallback(
|
||||
async (result: unknown, redirectTo?: string): Promise<boolean> => {
|
||||
if (
|
||||
!mounted.current ||
|
||||
useAuthStore.getState().auth.session?.sid !== sessionID
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (isAuthBundle(result)) {
|
||||
await handleLoginSuccess(result, redirectTo)
|
||||
return true
|
||||
}
|
||||
if (!isLoginChallenge(result)) {
|
||||
throw new AuthOperationError('Login failed')
|
||||
}
|
||||
if (result.expires_at * 1000 <= Date.now()) {
|
||||
throw new AuthOperationError(
|
||||
'Login flow expired. Please sign in again.'
|
||||
)
|
||||
}
|
||||
useAuthStore.getState().auth.setPendingLoginVerification({
|
||||
challenge: result,
|
||||
redirectTo:
|
||||
sanitizeAuthRedirect(redirectTo, window.location.origin) ?? undefined,
|
||||
})
|
||||
await navigate({ to: '/otp', replace: true })
|
||||
return false
|
||||
},
|
||||
[handleLoginSuccess, navigate, sessionID]
|
||||
)
|
||||
|
||||
/**
|
||||
* Redirect to login page
|
||||
*/
|
||||
const redirectToLogin = useCallback(() => {
|
||||
void navigate({ to: '/sign-in', replace: true })
|
||||
}, [navigate])
|
||||
|
||||
/**
|
||||
* Redirect to register page
|
||||
*/
|
||||
const redirectToRegister = useCallback(() => {
|
||||
void navigate({ to: '/sign-up', replace: true })
|
||||
}, [navigate])
|
||||
|
||||
return {
|
||||
handleLoginSuccess,
|
||||
handleLoginResult,
|
||||
redirectToLogin,
|
||||
redirectToRegister,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import i18next from 'i18next'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useCountdown } from '@/hooks/use-countdown'
|
||||
|
||||
import { sendEmailVerification } from '../api'
|
||||
import { EMAIL_VERIFICATION_COUNTDOWN } from '../constants'
|
||||
|
||||
interface UseEmailVerificationOptions {
|
||||
turnstileToken?: string
|
||||
validateTurnstile?: () => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing email verification code sending
|
||||
*/
|
||||
export function useEmailVerification(options?: UseEmailVerificationOptions) {
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
const {
|
||||
secondsLeft,
|
||||
isActive,
|
||||
start: startCountdown,
|
||||
} = useCountdown({ initialSeconds: EMAIL_VERIFICATION_COUNTDOWN })
|
||||
|
||||
/**
|
||||
* Send verification code to email
|
||||
*/
|
||||
const sendCode = async (email: string) => {
|
||||
if (!email) {
|
||||
toast.error(i18next.t('Please enter your email first'))
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate turnstile if validation function is provided
|
||||
if (options?.validateTurnstile && !options.validateTurnstile()) {
|
||||
return false
|
||||
}
|
||||
|
||||
setIsSending(true)
|
||||
try {
|
||||
const res = await sendEmailVerification(email, options?.turnstileToken)
|
||||
if (res?.success) {
|
||||
startCountdown()
|
||||
toast.success(i18next.t('Verification email sent'))
|
||||
return true
|
||||
}
|
||||
toast.error(
|
||||
res?.message || i18next.t('Failed to send verification email')
|
||||
)
|
||||
return false
|
||||
} catch (_error) {
|
||||
// Errors are handled by global interceptor
|
||||
return false
|
||||
} finally {
|
||||
setIsSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isSending,
|
||||
secondsLeft,
|
||||
isActive,
|
||||
sendCode,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { clearAuthentication } from '@/lib/api'
|
||||
import { AuthOperationError } from '@/lib/secure-verification'
|
||||
|
||||
import { createOAuthAuthorization, createOAuthFlow, logout } from '../api'
|
||||
import {
|
||||
buildGitHubOAuthUrl,
|
||||
buildDiscordOAuthUrl,
|
||||
buildOIDCOAuthUrl,
|
||||
buildLinuxDOOAuthUrl,
|
||||
} from '../lib/oauth'
|
||||
import { rememberOAuthLoginRedirect } from '../lib/oauth-callback-mode'
|
||||
import type { SystemStatus, CustomOAuthProviderInfo } from '../types'
|
||||
|
||||
/**
|
||||
* Hook for managing OAuth login
|
||||
*/
|
||||
export function useOAuthLogin(
|
||||
status: SystemStatus | null,
|
||||
redirectTo?: string
|
||||
) {
|
||||
const { t } = useTranslation()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [githubButtonText, setGithubButtonText] = useState('')
|
||||
const [githubButtonDisabled, setGithubButtonDisabled] = useState(false)
|
||||
const githubTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setGithubButtonText(t('Continue with GitHub'))
|
||||
|
||||
return () => {
|
||||
if (githubTimeoutRef.current) {
|
||||
clearTimeout(githubTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [t])
|
||||
|
||||
const resetSession = async () => {
|
||||
const response = await logout()
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || t('Failed to sign out session'))
|
||||
}
|
||||
clearAuthentication()
|
||||
}
|
||||
|
||||
const handleGitHubLogin = async () => {
|
||||
if (!status?.github_client_id) return
|
||||
if (githubButtonDisabled) return
|
||||
|
||||
setIsLoading(true)
|
||||
setGithubButtonDisabled(true)
|
||||
setGithubButtonText(t('Redirecting to GitHub...'))
|
||||
|
||||
if (githubTimeoutRef.current) {
|
||||
clearTimeout(githubTimeoutRef.current)
|
||||
}
|
||||
|
||||
githubTimeoutRef.current = setTimeout(() => {
|
||||
setIsLoading(false)
|
||||
setGithubButtonText(
|
||||
t('Request timed out, please refresh and restart GitHub login')
|
||||
)
|
||||
setGithubButtonDisabled(true)
|
||||
}, 20000)
|
||||
|
||||
try {
|
||||
await resetSession()
|
||||
const state = await createOAuthFlow('github', 'login')
|
||||
rememberOAuthLoginRedirect(state, redirectTo)
|
||||
|
||||
const url = buildGitHubOAuthUrl(status.github_client_id, state)
|
||||
window.open(url, '_self')
|
||||
} catch {
|
||||
toast.error(t('Failed to start GitHub login'))
|
||||
if (githubTimeoutRef.current) {
|
||||
clearTimeout(githubTimeoutRef.current)
|
||||
}
|
||||
setIsLoading(false)
|
||||
setGithubButtonText(t('Continue with GitHub'))
|
||||
setGithubButtonDisabled(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDiscordLogin = async () => {
|
||||
if (!status?.discord_client_id) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await resetSession()
|
||||
const state = await createOAuthFlow('discord', 'login')
|
||||
rememberOAuthLoginRedirect(state, redirectTo)
|
||||
|
||||
const url = buildDiscordOAuthUrl(status.discord_client_id, state)
|
||||
window.open(url, '_self')
|
||||
} catch {
|
||||
toast.error(t('Failed to start Discord login'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOIDCLogin = async () => {
|
||||
if (!status?.oidc_authorization_endpoint || !status?.oidc_client_id) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await resetSession()
|
||||
const state = await createOAuthFlow('oidc', 'login')
|
||||
rememberOAuthLoginRedirect(state, redirectTo)
|
||||
|
||||
const url = buildOIDCOAuthUrl(
|
||||
status.oidc_authorization_endpoint,
|
||||
status.oidc_client_id,
|
||||
state
|
||||
)
|
||||
window.open(url, '_self')
|
||||
} catch {
|
||||
toast.error(t('Failed to start OIDC login'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLinuxDOLogin = async () => {
|
||||
if (!status?.linuxdo_client_id) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await resetSession()
|
||||
const state = await createOAuthFlow('linuxdo', 'login')
|
||||
rememberOAuthLoginRedirect(state, redirectTo)
|
||||
|
||||
const url = buildLinuxDOOAuthUrl(status.linuxdo_client_id, state)
|
||||
window.open(url, '_self')
|
||||
} catch {
|
||||
toast.error(t('Failed to start LinuxDO login'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTelegramLogin = async () => {
|
||||
if (!status?.telegram_oauth_configured) {
|
||||
toast.error(
|
||||
t(
|
||||
'Telegram OAuth is not configured or enabled. Please contact your administrator.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const authorization = await createOAuthAuthorization('telegram', 'login')
|
||||
if (!authorization.authorizationUrl) {
|
||||
throw new AuthOperationError('Failed to initialize OAuth')
|
||||
}
|
||||
await resetSession()
|
||||
rememberOAuthLoginRedirect(authorization.state, redirectTo)
|
||||
window.open(authorization.authorizationUrl, '_self')
|
||||
} catch (error) {
|
||||
toast.error(t(AuthOperationError.from(error).message))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCustomOAuthLogin = async (provider: CustomOAuthProviderInfo) => {
|
||||
if (!provider.authorization_endpoint || !provider.client_id) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await resetSession()
|
||||
const state = await createOAuthFlow(provider.slug, 'login')
|
||||
rememberOAuthLoginRedirect(state, redirectTo)
|
||||
|
||||
const redirectUri = `${window.location.origin}/oauth/${provider.slug}`
|
||||
const url = new URL(provider.authorization_endpoint)
|
||||
url.searchParams.set('client_id', provider.client_id)
|
||||
url.searchParams.set('redirect_uri', redirectUri)
|
||||
url.searchParams.set('response_type', 'code')
|
||||
url.searchParams.set('state', state)
|
||||
if (provider.scopes) {
|
||||
url.searchParams.set('scope', provider.scopes)
|
||||
}
|
||||
|
||||
window.open(url.toString(), '_self')
|
||||
} catch {
|
||||
toast.error(
|
||||
t('Failed to start {{provider}} login', { provider: provider.name })
|
||||
)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
githubButtonText,
|
||||
githubButtonDisabled,
|
||||
handleGitHubLogin,
|
||||
handleDiscordLogin,
|
||||
handleOIDCLogin,
|
||||
handleLinuxDOLogin,
|
||||
handleTelegramLogin,
|
||||
handleCustomOAuthLogin,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import i18next from 'i18next'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
|
||||
/**
|
||||
* Hook for managing Turnstile verification
|
||||
*/
|
||||
export function useTurnstile() {
|
||||
const { status } = useStatus()
|
||||
const [turnstileToken, setTurnstileToken] = useState('')
|
||||
|
||||
const isTurnstileEnabled = !!(
|
||||
status?.turnstile_check && status?.turnstile_site_key
|
||||
)
|
||||
const turnstileSiteKey = status?.turnstile_site_key || ''
|
||||
|
||||
/**
|
||||
* Validate if turnstile is ready when required
|
||||
*/
|
||||
const validateTurnstile = (): boolean => {
|
||||
if (isTurnstileEnabled && !turnstileToken) {
|
||||
toast.info(
|
||||
i18next.t('Please wait a moment, human check is initializing...')
|
||||
)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
isTurnstileEnabled,
|
||||
turnstileSiteKey,
|
||||
turnstileToken,
|
||||
setTurnstileToken,
|
||||
validateTurnstile,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
// ============================================================================
|
||||
// API Functions
|
||||
// ============================================================================
|
||||
|
||||
export {
|
||||
login,
|
||||
login2fa,
|
||||
logout,
|
||||
register,
|
||||
sendPasswordResetEmail,
|
||||
sendEmailVerification,
|
||||
bindEmail,
|
||||
createOAuthFlow,
|
||||
githubOAuthStart,
|
||||
wechatLoginByCode,
|
||||
telegramLogin,
|
||||
} from './api'
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export type {
|
||||
LoginPayload,
|
||||
LoginResponse,
|
||||
Login2FAResponse,
|
||||
TwoFAPayload,
|
||||
RegisterPayload,
|
||||
PasswordResetPayload,
|
||||
EmailVerificationPayload,
|
||||
BindEmailPayload,
|
||||
ApiResponse,
|
||||
SystemStatus,
|
||||
OAuthProvider,
|
||||
AuthFormProps,
|
||||
} from './types'
|
||||
|
||||
// ============================================================================
|
||||
// Constants & Schemas
|
||||
// ============================================================================
|
||||
|
||||
export {
|
||||
loginFormSchema,
|
||||
registerFormSchema,
|
||||
forgotPasswordFormSchema,
|
||||
otpFormSchema,
|
||||
PASSWORD_MIN_LENGTH,
|
||||
PASSWORD_MAX_LENGTH,
|
||||
OTP_LENGTH,
|
||||
BACKUP_CODE_LENGTH,
|
||||
BACKUP_CODE_REGEX,
|
||||
OTP_REGEX,
|
||||
EMAIL_VERIFICATION_COUNTDOWN,
|
||||
PASSWORD_RESET_COUNTDOWN,
|
||||
} from './constants'
|
||||
|
||||
// ============================================================================
|
||||
// Utilities
|
||||
// ============================================================================
|
||||
|
||||
export {
|
||||
buildGitHubOAuthUrl,
|
||||
buildDiscordOAuthUrl,
|
||||
buildOIDCOAuthUrl,
|
||||
buildLinuxDOOAuthUrl,
|
||||
getAvailableOAuthProviders,
|
||||
hasOAuthProviders,
|
||||
} from './lib/oauth'
|
||||
|
||||
export { getAffiliateCode, saveAffiliateCode } from './lib/storage'
|
||||
|
||||
export {
|
||||
isValidOTP,
|
||||
isValidBackupCode,
|
||||
formatBackupCode,
|
||||
cleanBackupCode,
|
||||
isValidEmail,
|
||||
} from './lib/validation'
|
||||
|
||||
// ============================================================================
|
||||
// Hooks
|
||||
// ============================================================================
|
||||
|
||||
export { useTurnstile } from './hooks/use-turnstile'
|
||||
export { useOAuthLogin } from './hooks/use-oauth-login'
|
||||
export { useAuthRedirect } from './hooks/use-auth-redirect'
|
||||
export { useEmailVerification } from './hooks/use-email-verification'
|
||||
|
||||
// ============================================================================
|
||||
// Components
|
||||
// ============================================================================
|
||||
|
||||
export { AuthLayout } from './auth-layout'
|
||||
export { OAuthProviders } from './components/oauth-providers'
|
||||
export { TermsFooter } from './components/terms-footer'
|
||||
export { LegalConsent } from './components/legal-consent'
|
||||
export { SignIn } from './sign-in'
|
||||
export { SignUp } from './sign-up'
|
||||
export { ForgotPassword } from './forgot-password'
|
||||
export { Otp } from './otp'
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import {
|
||||
getOAuthSessionStorage,
|
||||
markOAuthPopup,
|
||||
resolveOAuthCallbackMode,
|
||||
type OAuthModeStorage,
|
||||
} from '../oauth-callback-mode'
|
||||
|
||||
function fakeStorage(initial: Record<string, string> = {}): OAuthModeStorage {
|
||||
const data = new Map(Object.entries(initial))
|
||||
return {
|
||||
getItem: (key: string) => data.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => void data.set(key, value),
|
||||
}
|
||||
}
|
||||
|
||||
const openOpener = { closed: false }
|
||||
const bindState = 'bind-state'
|
||||
|
||||
describe('resolveOAuthCallbackMode', () => {
|
||||
test('matching provider and state mark is treated as a bind flow', () => {
|
||||
const storage = fakeStorage()
|
||||
expect(markOAuthPopup(storage, 'oidc', bindState, 'bind')).toBe(true)
|
||||
|
||||
expect(
|
||||
resolveOAuthCallbackMode('oidc', bindState, {
|
||||
opener: openOpener,
|
||||
storage,
|
||||
})
|
||||
).toBe('bind')
|
||||
})
|
||||
|
||||
test('verification markers cannot be confused with account binding', () => {
|
||||
const storage = fakeStorage()
|
||||
expect(
|
||||
markOAuthPopup(storage, 'oidc', 'verification-state', 'verify')
|
||||
).toBe(true)
|
||||
expect(
|
||||
resolveOAuthCallbackMode('oidc', 'verification-state', {
|
||||
opener: openOpener,
|
||||
storage,
|
||||
})
|
||||
).toBe('verify')
|
||||
expect(
|
||||
resolveOAuthCallbackMode('oidc', bindState, {
|
||||
opener: openOpener,
|
||||
storage,
|
||||
})
|
||||
).toBe('login')
|
||||
})
|
||||
|
||||
// Regression: a tab opened from an external link (Slack, e-mail, another
|
||||
// site) keeps a live window.opener across the cross-origin round trip to the
|
||||
// identity provider. Treating that opener as proof of a bind flow made every
|
||||
// such login hang on the binding screen until the 30s handshake deadline.
|
||||
test('login redirect in a tab with a foreign opener stays a login flow', () => {
|
||||
const storage = fakeStorage()
|
||||
|
||||
expect(
|
||||
resolveOAuthCallbackMode('oidc', bindState, {
|
||||
opener: openOpener,
|
||||
storage,
|
||||
})
|
||||
).toBe('login')
|
||||
})
|
||||
|
||||
test('bind marker for another provider does not hijack this callback', () => {
|
||||
const storage = fakeStorage()
|
||||
markOAuthPopup(storage, 'github', bindState, 'bind')
|
||||
|
||||
expect(
|
||||
resolveOAuthCallbackMode('oidc', bindState, {
|
||||
opener: openOpener,
|
||||
storage,
|
||||
})
|
||||
).toBe('login')
|
||||
})
|
||||
|
||||
test('stale bind marker does not hijack a later callback', () => {
|
||||
const storage = fakeStorage()
|
||||
markOAuthPopup(storage, 'oidc', 'previous-state', 'bind')
|
||||
|
||||
expect(
|
||||
resolveOAuthCallbackMode('oidc', bindState, {
|
||||
opener: openOpener,
|
||||
storage,
|
||||
})
|
||||
).toBe('login')
|
||||
})
|
||||
|
||||
test('bind marker without an opener falls back to login', () => {
|
||||
const storage = fakeStorage()
|
||||
markOAuthPopup(storage, 'oidc', bindState, 'bind')
|
||||
|
||||
expect(
|
||||
resolveOAuthCallbackMode('oidc', bindState, {
|
||||
opener: null,
|
||||
storage,
|
||||
})
|
||||
).toBe('login')
|
||||
})
|
||||
|
||||
test('closed opener falls back to login', () => {
|
||||
const storage = fakeStorage()
|
||||
markOAuthPopup(storage, 'oidc', bindState, 'bind')
|
||||
|
||||
expect(
|
||||
resolveOAuthCallbackMode('oidc', bindState, {
|
||||
opener: { closed: true },
|
||||
storage,
|
||||
})
|
||||
).toBe('login')
|
||||
})
|
||||
|
||||
test('missing storage degrades to login instead of throwing', () => {
|
||||
expect(
|
||||
resolveOAuthCallbackMode('oidc', bindState, {
|
||||
opener: openOpener,
|
||||
storage: null,
|
||||
})
|
||||
).toBe('login')
|
||||
})
|
||||
|
||||
test('storage read failure degrades to login instead of throwing', () => {
|
||||
const storage: OAuthModeStorage = {
|
||||
getItem: () => {
|
||||
throw new Error('blocked')
|
||||
},
|
||||
setItem: () => undefined,
|
||||
}
|
||||
|
||||
expect(
|
||||
resolveOAuthCallbackMode('oidc', bindState, {
|
||||
opener: openOpener,
|
||||
storage,
|
||||
})
|
||||
).toBe('login')
|
||||
})
|
||||
})
|
||||
|
||||
describe('OAuth bind popup storage', () => {
|
||||
test('blocked sessionStorage getter is contained', () => {
|
||||
const owner = {
|
||||
get sessionStorage(): OAuthModeStorage {
|
||||
throw new Error('blocked')
|
||||
},
|
||||
}
|
||||
|
||||
expect(getOAuthSessionStorage(owner)).toBe(null)
|
||||
})
|
||||
|
||||
test('marking reports unavailable or unwritable storage', () => {
|
||||
const storage: OAuthModeStorage = {
|
||||
getItem: () => null,
|
||||
setItem: () => {
|
||||
throw new Error('blocked')
|
||||
},
|
||||
}
|
||||
|
||||
expect(markOAuthPopup(null, 'oidc', bindState, 'bind')).toBe(false)
|
||||
expect(markOAuthPopup(storage, 'oidc', bindState, 'bind')).toBe(false)
|
||||
expect(
|
||||
markOAuthPopup(
|
||||
{
|
||||
getItem: () => null,
|
||||
setItem: () => undefined,
|
||||
},
|
||||
'oidc',
|
||||
bindState,
|
||||
'bind'
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,237 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
|
||||
import { AuthOperationError } from '@/lib/secure-verification'
|
||||
|
||||
import { OAUTH_POPUP_CALLBACK_MESSAGE } from '../../constants'
|
||||
import { openOAuthPopup } from '../oauth-popup'
|
||||
|
||||
function popupWindow() {
|
||||
const storage = new Map<string, string>()
|
||||
const popup = {
|
||||
closed: false,
|
||||
location: { replace: vi.fn() },
|
||||
sessionStorage: {
|
||||
setItem: (key: string, value: string) => storage.set(key, value),
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
},
|
||||
close: vi.fn(() => {
|
||||
popup.closed = true
|
||||
}),
|
||||
postMessage: vi.fn(),
|
||||
}
|
||||
vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window)
|
||||
return popup
|
||||
}
|
||||
|
||||
function callbackMessage(
|
||||
popup: unknown,
|
||||
overrides: Record<string, unknown> = {},
|
||||
origin = window.location.origin
|
||||
) {
|
||||
const event = new MessageEvent('message', {
|
||||
origin,
|
||||
data: {
|
||||
type: OAUTH_POPUP_CALLBACK_MESSAGE,
|
||||
intent: 'verify',
|
||||
provider: 'github',
|
||||
state: 'state',
|
||||
code: 'code',
|
||||
...overrides,
|
||||
},
|
||||
})
|
||||
Object.defineProperty(event, 'source', { value: popup })
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('accepts only the matching popup, origin, intent, provider and state', async () => {
|
||||
const popup = popupWindow()
|
||||
const controller = new AbortController()
|
||||
const prepared = Promise.resolve({
|
||||
state: 'state',
|
||||
url: 'https://example.com/authorize',
|
||||
})
|
||||
const resolved = vi.fn()
|
||||
const result = openOAuthPopup({
|
||||
provider: 'github',
|
||||
intent: 'verify',
|
||||
signal: controller.signal,
|
||||
prepare: () => prepared,
|
||||
}).then((exchange) => {
|
||||
resolved(exchange)
|
||||
return exchange
|
||||
})
|
||||
await prepared
|
||||
expect(popup.location.replace).toHaveBeenCalledWith(
|
||||
'https://example.com/authorize'
|
||||
)
|
||||
callbackMessage({}, {})
|
||||
callbackMessage(popup, {}, 'https://untrusted.example')
|
||||
callbackMessage(popup, { intent: 'bind' })
|
||||
callbackMessage(popup, { provider: 'discord' })
|
||||
callbackMessage(popup, { state: 'old-state' })
|
||||
await Promise.resolve()
|
||||
expect(resolved).not.toHaveBeenCalled()
|
||||
callbackMessage(popup)
|
||||
callbackMessage(popup)
|
||||
const exchange = await result
|
||||
expect(resolved).toHaveBeenCalledTimes(1)
|
||||
expect(exchange.callback).toEqual({
|
||||
provider: 'github',
|
||||
state: 'state',
|
||||
code: 'code',
|
||||
error: undefined,
|
||||
errorDescription: undefined,
|
||||
})
|
||||
exchange.finish({ success: true })
|
||||
expect(popup.closed).toBe(true)
|
||||
})
|
||||
|
||||
it('closes an aborted popup and ignores a late authorization response', async () => {
|
||||
const popup = popupWindow()
|
||||
const controller = new AbortController()
|
||||
let complete!: (value: { state: string; url: string }) => void
|
||||
const prepared = new Promise<{ state: string; url: string }>((resolve) => {
|
||||
complete = resolve
|
||||
})
|
||||
const result = openOAuthPopup({
|
||||
provider: 'github',
|
||||
intent: 'bind',
|
||||
signal: controller.signal,
|
||||
prepare: () => prepared,
|
||||
})
|
||||
const rejected = expect(result).rejects.toBeInstanceOf(AuthOperationError)
|
||||
controller.abort()
|
||||
await rejected
|
||||
complete({ state: 'state', url: 'https://example.com/authorize' })
|
||||
await prepared
|
||||
expect(popup.closed).toBe(true)
|
||||
expect(popup.location.replace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['bind', 'verify'] as const)(
|
||||
'keeps the %s callback request alive after the popup closes',
|
||||
async (intent) => {
|
||||
vi.useFakeTimers()
|
||||
const popup = popupWindow()
|
||||
const prepared = Promise.resolve({
|
||||
state: 'state',
|
||||
url: 'https://example.com/authorize',
|
||||
})
|
||||
const result = openOAuthPopup({
|
||||
provider: 'github',
|
||||
intent,
|
||||
signal: new AbortController().signal,
|
||||
prepare: () => prepared,
|
||||
})
|
||||
await prepared
|
||||
callbackMessage(popup, { intent })
|
||||
const exchange = await result
|
||||
popup.closed = true
|
||||
await vi.advanceTimersByTimeAsync(11 * 60_000)
|
||||
expect(exchange.signal.aborted).toBe(false)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
exchange.finish({ success: true })
|
||||
}
|
||||
)
|
||||
|
||||
it('still cancels when the caller aborts after receiving a callback', async () => {
|
||||
const popup = popupWindow()
|
||||
const controller = new AbortController()
|
||||
const prepared = Promise.resolve({
|
||||
state: 'state',
|
||||
url: 'https://example.com/authorize',
|
||||
})
|
||||
const result = openOAuthPopup({
|
||||
provider: 'github',
|
||||
intent: 'verify',
|
||||
signal: controller.signal,
|
||||
prepare: () => prepared,
|
||||
})
|
||||
await prepared
|
||||
callbackMessage(popup)
|
||||
const exchange = await result
|
||||
controller.abort(new AuthOperationError('Cancelled', 'AUTH_CANCELLED'))
|
||||
expect(exchange.signal.aborted).toBe(true)
|
||||
expect(popup.closed).toBe(true)
|
||||
})
|
||||
|
||||
it('cancels when the popup closes before receiving a callback', async () => {
|
||||
vi.useFakeTimers()
|
||||
const popup = popupWindow()
|
||||
const result = openOAuthPopup({
|
||||
provider: 'github',
|
||||
intent: 'verify',
|
||||
signal: new AbortController().signal,
|
||||
prepare: async () => ({
|
||||
state: 'state',
|
||||
url: 'https://example.com/authorize',
|
||||
}),
|
||||
})
|
||||
const rejected = expect(result).rejects.toMatchObject({
|
||||
code: 'AUTH_CANCELLED',
|
||||
})
|
||||
popup.closed = true
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await rejected
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('reports a blocked popup without starting authorization', async () => {
|
||||
vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
const prepare = vi.fn()
|
||||
await expect(
|
||||
openOAuthPopup({
|
||||
provider: 'github',
|
||||
intent: 'verify',
|
||||
signal: new AbortController().signal,
|
||||
prepare,
|
||||
})
|
||||
).rejects.toThrow('OAuth pop-up was blocked')
|
||||
expect(prepare).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('times out an unfinished authorization and clears its listeners and timers', async () => {
|
||||
vi.useFakeTimers()
|
||||
const popup = popupWindow()
|
||||
const prepared = Promise.resolve({
|
||||
state: 'state',
|
||||
url: 'https://example.com/authorize',
|
||||
})
|
||||
const result = openOAuthPopup({
|
||||
provider: 'github',
|
||||
intent: 'verify',
|
||||
signal: new AbortController().signal,
|
||||
prepare: () => prepared,
|
||||
})
|
||||
const rejected = expect(result).rejects.toThrow(
|
||||
'OAuth authorization timed out. Please try again.'
|
||||
)
|
||||
await prepared
|
||||
await vi.advanceTimersByTimeAsync(10 * 60_000)
|
||||
await rejected
|
||||
expect(popup.closed).toBe(true)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import type { AuthUser } from '@/stores/auth-store'
|
||||
|
||||
import { getSavedLanguage, sanitizeAuthRedirect } from './auth-redirect'
|
||||
|
||||
const origin = 'https://dashboard.example.com'
|
||||
|
||||
describe('authentication redirect validation', () => {
|
||||
test('preserves safe internal paths, search parameters, and fragments', () => {
|
||||
expect(sanitizeAuthRedirect('/console?tab=usage#recent', origin)).toBe(
|
||||
'/console?tab=usage#recent'
|
||||
)
|
||||
expect(
|
||||
sanitizeAuthRedirect(
|
||||
'https://dashboard.example.com/dashboard?tab=quota#daily',
|
||||
origin
|
||||
)
|
||||
).toBe('/dashboard?tab=quota#daily')
|
||||
})
|
||||
|
||||
test('rejects external and ambiguously parsed redirect targets', () => {
|
||||
const unsafeTargets: unknown[] = [
|
||||
undefined,
|
||||
'',
|
||||
'dashboard',
|
||||
'//attacker.example/path',
|
||||
'https://attacker.example/path',
|
||||
'javascript:alert(1)',
|
||||
'/\\attacker.example/path',
|
||||
'https:\\attacker.example/path',
|
||||
]
|
||||
|
||||
for (const target of unsafeTargets) {
|
||||
expect(sanitizeAuthRedirect(target, origin)).toBe(null)
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects invalid or non-HTTP application origins', () => {
|
||||
expect(sanitizeAuthRedirect('/dashboard', 'not-an-origin')).toBe(null)
|
||||
expect(sanitizeAuthRedirect('/dashboard', 'file:///tmp/app')).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('saved authentication language', () => {
|
||||
const user: AuthUser = { id: 1, username: 'user', role: 1 }
|
||||
|
||||
test('prefers the explicit user language', () => {
|
||||
expect(
|
||||
getSavedLanguage({
|
||||
...user,
|
||||
language: 'ja',
|
||||
setting: { language: 'fr' },
|
||||
})
|
||||
).toBe('ja')
|
||||
})
|
||||
|
||||
test('reads object and JSON string settings', () => {
|
||||
expect(getSavedLanguage({ ...user, setting: { language: 'fr' } })).toBe(
|
||||
'fr'
|
||||
)
|
||||
expect(getSavedLanguage({ ...user, setting: '{"language":"ru"}' })).toBe(
|
||||
'ru'
|
||||
)
|
||||
})
|
||||
|
||||
test('ignores malformed and non-string setting languages', () => {
|
||||
expect(getSavedLanguage({ ...user, setting: '{' })).toBe(undefined)
|
||||
expect(getSavedLanguage({ ...user, setting: { language: 123 } })).toBe(
|
||||
undefined
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { AuthUser } from '@/stores/auth-store'
|
||||
|
||||
const allowedRedirectProtocols = new Set(['http:', 'https:'])
|
||||
|
||||
export function getSavedLanguage(user: AuthUser): string | undefined {
|
||||
if (typeof user.language === 'string') {
|
||||
return user.language
|
||||
}
|
||||
|
||||
if (user.setting && typeof user.setting === 'object') {
|
||||
return typeof user.setting.language === 'string'
|
||||
? user.setting.language
|
||||
: undefined
|
||||
}
|
||||
|
||||
if (typeof user.setting !== 'string') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const setting = JSON.parse(user.setting) as { language?: unknown }
|
||||
return typeof setting.language === 'string' ? setting.language : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeAuthRedirect(
|
||||
value: unknown,
|
||||
origin: string
|
||||
): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
|
||||
const target = value.trim()
|
||||
if (!target || target.includes('\\') || target.startsWith('//')) return null
|
||||
|
||||
let trustedOrigin: URL
|
||||
try {
|
||||
trustedOrigin = new URL(origin)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!allowedRedirectProtocols.has(trustedOrigin.protocol)) return null
|
||||
|
||||
let redirectURL: URL
|
||||
try {
|
||||
redirectURL = target.startsWith('/')
|
||||
? new URL(target, trustedOrigin.origin)
|
||||
: new URL(target)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (
|
||||
!allowedRedirectProtocols.has(redirectURL.protocol) ||
|
||||
redirectURL.origin !== trustedOrigin.origin
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return `${redirectURL.pathname}${redirectURL.search}${redirectURL.hash}`
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import {
|
||||
parseTelegramBindCallback,
|
||||
postTelegramBindResult,
|
||||
startOAuthBindResponseDeadline,
|
||||
watchOAuthPopupClosed,
|
||||
} from './oauth-bind-window'
|
||||
|
||||
function fakeTimerRuntime() {
|
||||
let callback: (() => void) | undefined
|
||||
let delay = 0
|
||||
const cancelled: unknown[] = []
|
||||
const handle = Symbol('timer')
|
||||
return {
|
||||
runtime: {
|
||||
schedule: (scheduled: () => void, scheduledDelay: number) => {
|
||||
callback = scheduled
|
||||
delay = scheduledDelay
|
||||
return handle
|
||||
},
|
||||
cancel: (cancelledHandle: unknown) => cancelled.push(cancelledHandle),
|
||||
},
|
||||
fire: () => callback?.(),
|
||||
get delay() {
|
||||
return delay
|
||||
},
|
||||
cancelled,
|
||||
handle,
|
||||
}
|
||||
}
|
||||
|
||||
describe('OAuth bind popup lifecycle', () => {
|
||||
test('parses Telegram success and stable error callbacks', () => {
|
||||
expect(
|
||||
parseTelegramBindCallback({
|
||||
telegram_bind: 'success',
|
||||
flow_token: 'flow-success',
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'result',
|
||||
flowToken: 'flow-success',
|
||||
success: true,
|
||||
})
|
||||
expect(
|
||||
parseTelegramBindCallback({
|
||||
telegram_bind: 'error',
|
||||
flow_token: 'flow-error',
|
||||
error_code: 'TELEGRAM_BIND_ALREADY_BOUND',
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'result',
|
||||
flowToken: 'flow-error',
|
||||
success: false,
|
||||
code: 'TELEGRAM_BIND_ALREADY_BOUND',
|
||||
})
|
||||
})
|
||||
|
||||
test('rejects Telegram callbacks without a flow token and ignores descriptions', () => {
|
||||
expect(parseTelegramBindCallback({ telegram_bind: 'error' })).toEqual({
|
||||
kind: 'invalid',
|
||||
})
|
||||
expect(
|
||||
parseTelegramBindCallback({
|
||||
telegram_bind: 'error',
|
||||
flow_token: 'flow-error',
|
||||
error_code: 'UNKNOWN_CODE',
|
||||
error_description: 'untrusted message',
|
||||
} as Parameters<typeof parseTelegramBindCallback>[0])
|
||||
).toEqual({
|
||||
kind: 'result',
|
||||
flowToken: 'flow-error',
|
||||
success: false,
|
||||
code: 'UNKNOWN_CODE',
|
||||
})
|
||||
expect(parseTelegramBindCallback({})).toBe(null)
|
||||
})
|
||||
|
||||
test('posts only complete Telegram bind results to an available opener', () => {
|
||||
const messages: Array<{ message: unknown; targetOrigin: string }> = []
|
||||
const opener = {
|
||||
closed: false,
|
||||
postMessage: (message: unknown, targetOrigin: string) => {
|
||||
messages.push({ message, targetOrigin })
|
||||
},
|
||||
} as Pick<Window, 'closed' | 'postMessage'>
|
||||
const callback = parseTelegramBindCallback({
|
||||
telegram_bind: 'error',
|
||||
flow_token: 'flow-error',
|
||||
error_code: 'UNKNOWN_CODE',
|
||||
})
|
||||
|
||||
expect(
|
||||
postTelegramBindResult(callback, opener, 'https://dashboard.example.com')
|
||||
).toBe(true)
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
message: {
|
||||
type: 'telegram:binding:result',
|
||||
flow_token: 'flow-error',
|
||||
success: false,
|
||||
code: 'UNKNOWN_CODE',
|
||||
},
|
||||
targetOrigin: 'https://dashboard.example.com',
|
||||
},
|
||||
])
|
||||
|
||||
expect(
|
||||
postTelegramBindResult({ kind: 'invalid' }, opener, 'https://example.com')
|
||||
).toBe(false)
|
||||
expect(
|
||||
postTelegramBindResult(
|
||||
callback,
|
||||
{ ...opener, closed: true },
|
||||
'https://example.com'
|
||||
)
|
||||
).toBe(false)
|
||||
expect(messages.length).toBe(1)
|
||||
})
|
||||
|
||||
test('waits 30 seconds for the opener response and can be cancelled', () => {
|
||||
const timer = fakeTimerRuntime()
|
||||
let timedOut = false
|
||||
const cancel = startOAuthBindResponseDeadline(
|
||||
() => {
|
||||
timedOut = true
|
||||
},
|
||||
undefined,
|
||||
timer.runtime
|
||||
)
|
||||
|
||||
expect(timer.delay).toBe(30_000)
|
||||
cancel()
|
||||
timer.fire()
|
||||
expect(timedOut).toBe(false)
|
||||
expect(timer.cancelled).toEqual([timer.handle])
|
||||
})
|
||||
|
||||
test('reports a closed popup once and clears its poller', () => {
|
||||
const timer = fakeTimerRuntime()
|
||||
const popup = { closed: false }
|
||||
let closedCount = 0
|
||||
watchOAuthPopupClosed(
|
||||
popup,
|
||||
() => {
|
||||
closedCount += 1
|
||||
},
|
||||
undefined,
|
||||
timer.runtime
|
||||
)
|
||||
|
||||
expect(timer.delay).toBe(500)
|
||||
timer.fire()
|
||||
expect(closedCount).toBe(0)
|
||||
popup.closed = true
|
||||
timer.fire()
|
||||
timer.fire()
|
||||
expect(closedCount).toBe(1)
|
||||
expect(timer.cancelled).toEqual([timer.handle])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { TELEGRAM_BIND_RESULT_MESSAGE } from '@/features/auth/constants'
|
||||
|
||||
interface TimerRuntime {
|
||||
schedule: (callback: () => void, delay: number) => unknown
|
||||
cancel: (handle: unknown) => void
|
||||
}
|
||||
|
||||
const timeoutRuntime: TimerRuntime = {
|
||||
schedule: (callback, delay) => globalThis.setTimeout(callback, delay),
|
||||
cancel: (handle) =>
|
||||
globalThis.clearTimeout(handle as ReturnType<typeof globalThis.setTimeout>),
|
||||
}
|
||||
|
||||
const intervalRuntime: TimerRuntime = {
|
||||
schedule: (callback, delay) => globalThis.setInterval(callback, delay),
|
||||
cancel: (handle) =>
|
||||
globalThis.clearInterval(
|
||||
handle as ReturnType<typeof globalThis.setInterval>
|
||||
),
|
||||
}
|
||||
|
||||
interface TelegramBindCallbackSearch {
|
||||
telegram_bind?: string
|
||||
flow_token?: string
|
||||
error_code?: string
|
||||
}
|
||||
|
||||
export type TelegramBindCallback =
|
||||
| {
|
||||
kind: 'result'
|
||||
flowToken: string
|
||||
success: boolean
|
||||
code?: string
|
||||
}
|
||||
| { kind: 'invalid' }
|
||||
| null
|
||||
|
||||
export function parseTelegramBindCallback(
|
||||
search: TelegramBindCallbackSearch
|
||||
): TelegramBindCallback {
|
||||
if (search.telegram_bind !== 'success' && search.telegram_bind !== 'error') {
|
||||
return null
|
||||
}
|
||||
if (!search.flow_token) return { kind: 'invalid' }
|
||||
|
||||
if (search.telegram_bind === 'success') {
|
||||
return {
|
||||
kind: 'result',
|
||||
flowToken: search.flow_token,
|
||||
success: true,
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: 'result',
|
||||
flowToken: search.flow_token,
|
||||
success: false,
|
||||
code: search.error_code,
|
||||
}
|
||||
}
|
||||
|
||||
export function postTelegramBindResult(
|
||||
callback: TelegramBindCallback,
|
||||
opener: Pick<Window, 'closed' | 'postMessage'> | null,
|
||||
targetOrigin: string
|
||||
): boolean {
|
||||
if (callback?.kind !== 'result' || !opener || opener.closed) return false
|
||||
|
||||
opener.postMessage(
|
||||
{
|
||||
type: TELEGRAM_BIND_RESULT_MESSAGE,
|
||||
flow_token: callback.flowToken,
|
||||
success: callback.success,
|
||||
code: callback.code,
|
||||
},
|
||||
targetOrigin
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
export function startOAuthBindResponseDeadline(
|
||||
onTimeout: () => void,
|
||||
delay = 30_000,
|
||||
runtime: TimerRuntime = timeoutRuntime
|
||||
): () => void {
|
||||
let active = true
|
||||
const handle = runtime.schedule(() => {
|
||||
if (!active) return
|
||||
active = false
|
||||
onTimeout()
|
||||
}, delay)
|
||||
return () => {
|
||||
if (!active) return
|
||||
active = false
|
||||
runtime.cancel(handle)
|
||||
}
|
||||
}
|
||||
|
||||
export function watchOAuthPopupClosed(
|
||||
popup: Pick<Window, 'closed'>,
|
||||
onClosed: () => void,
|
||||
interval = 500,
|
||||
runtime: TimerRuntime = intervalRuntime
|
||||
): () => void {
|
||||
let active = true
|
||||
const handle = runtime.schedule(() => {
|
||||
if (!active || !popup.closed) return
|
||||
active = false
|
||||
runtime.cancel(handle)
|
||||
onClosed()
|
||||
}, interval)
|
||||
return () => {
|
||||
if (!active) return
|
||||
active = false
|
||||
runtime.cancel(handle)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
const OAUTH_POPUP_FLOW_KEY_PREFIX = 'oauth_popup_flow:'
|
||||
|
||||
export function rememberOAuthLoginRedirect(
|
||||
state: string,
|
||||
redirect?: string
|
||||
): void {
|
||||
if (!redirect) return
|
||||
try {
|
||||
window.sessionStorage.setItem(`oauth_login_redirect:${state}`, redirect)
|
||||
} catch {
|
||||
// Login can still complete using the default destination.
|
||||
}
|
||||
}
|
||||
|
||||
export function consumeOAuthLoginRedirect(state: string): string | null {
|
||||
try {
|
||||
const key = `oauth_login_redirect:${state}`
|
||||
const redirect = window.sessionStorage.getItem(key)
|
||||
window.sessionStorage.removeItem(key)
|
||||
return redirect
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal shape of `sessionStorage`, kept structural so tests can fake it. */
|
||||
export interface OAuthModeStorage {
|
||||
getItem: (key: string) => string | null
|
||||
setItem: (key: string, value: string) => void
|
||||
}
|
||||
|
||||
/** Minimal owner shape for safely accessing `sessionStorage`. */
|
||||
export interface OAuthSessionStorageOwner {
|
||||
readonly sessionStorage: OAuthModeStorage
|
||||
}
|
||||
|
||||
/** Minimal shape of `window.opener`. */
|
||||
export interface OAuthModeOpener {
|
||||
closed: boolean
|
||||
}
|
||||
|
||||
export interface OAuthCallbackModeContext {
|
||||
opener: OAuthModeOpener | null | undefined
|
||||
storage: OAuthModeStorage | null | undefined
|
||||
}
|
||||
|
||||
export type OAuthCallbackMode = 'login' | 'bind' | 'verify'
|
||||
|
||||
/**
|
||||
* Access `sessionStorage` without letting browser privacy settings crash the
|
||||
* OAuth page or binding action.
|
||||
*/
|
||||
export function getOAuthSessionStorage(
|
||||
owner: OAuthSessionStorageOwner | null | undefined
|
||||
): OAuthModeStorage | null {
|
||||
try {
|
||||
return owner?.sessionStorage ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp a freshly opened, still same-origin popup as an OAuth popup flow.
|
||||
* Call this before navigating the popup to the provider.
|
||||
*/
|
||||
export function markOAuthPopup(
|
||||
storage: OAuthModeStorage | null | undefined,
|
||||
provider: string,
|
||||
state: string,
|
||||
intent: 'bind' | 'verify'
|
||||
): boolean {
|
||||
if (!storage || !provider || !state) return false
|
||||
|
||||
try {
|
||||
const key = `${OAUTH_POPUP_FLOW_KEY_PREFIX}${provider}`
|
||||
const marker = JSON.stringify({ state, intent })
|
||||
storage.setItem(key, marker)
|
||||
return storage.getItem(key) === marker
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve how a callback on `/oauth/:provider` should be handled.
|
||||
*
|
||||
* A bind requires all three pieces of evidence: our own stamp for this exact
|
||||
* provider and state, plus a live opener to hand the result back to. Anything
|
||||
* else is a login, which is also the safe default — a login callback recovers
|
||||
* on its own, while a wrongly assumed bind can only time out.
|
||||
*/
|
||||
export function resolveOAuthCallbackMode(
|
||||
provider: string,
|
||||
state: string,
|
||||
{ opener, storage }: OAuthCallbackModeContext
|
||||
): OAuthCallbackMode {
|
||||
if (!opener || opener.closed || !storage || !state) return 'login'
|
||||
|
||||
try {
|
||||
const value = storage.getItem(`${OAUTH_POPUP_FLOW_KEY_PREFIX}${provider}`)
|
||||
if (!value) return 'login'
|
||||
const marker: unknown = JSON.parse(value)
|
||||
if (
|
||||
!marker ||
|
||||
typeof marker !== 'object' ||
|
||||
!('state' in marker) ||
|
||||
!('intent' in marker)
|
||||
) {
|
||||
return 'login'
|
||||
}
|
||||
if (marker.state !== state) return 'login'
|
||||
if (marker.intent === 'bind' || marker.intent === 'verify') {
|
||||
return marker.intent
|
||||
}
|
||||
} catch {
|
||||
return 'login'
|
||||
}
|
||||
return 'login'
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { AuthOperationError } from '@/lib/secure-verification'
|
||||
|
||||
import {
|
||||
OAUTH_POPUP_CALLBACK_MESSAGE,
|
||||
OAUTH_POPUP_RESULT_MESSAGE,
|
||||
} from '../constants'
|
||||
import { watchOAuthPopupClosed } from './oauth-bind-window'
|
||||
import { getOAuthSessionStorage, markOAuthPopup } from './oauth-callback-mode'
|
||||
|
||||
export interface OAuthPopupCallback {
|
||||
provider: string
|
||||
state: string
|
||||
code?: string
|
||||
error?: string
|
||||
errorDescription?: string
|
||||
}
|
||||
|
||||
export interface OAuthPopupExchange {
|
||||
callback: OAuthPopupCallback
|
||||
signal: AbortSignal
|
||||
finish: (result: { success: boolean; message?: string }) => void
|
||||
}
|
||||
|
||||
interface OAuthPopupOptions {
|
||||
provider: string
|
||||
intent: 'bind' | 'verify'
|
||||
signal: AbortSignal
|
||||
prepare: (signal: AbortSignal) => Promise<{ state: string; url: string }>
|
||||
}
|
||||
|
||||
// Window transport only: account binding and security verification each submit
|
||||
// their own authenticated callback request after receiving the exchange.
|
||||
export function openOAuthPopup(
|
||||
options: OAuthPopupOptions
|
||||
): Promise<OAuthPopupExchange> {
|
||||
if (options.signal.aborted) return Promise.reject(options.signal.reason)
|
||||
const popup = window.open('', '_blank')
|
||||
if (!popup) {
|
||||
return Promise.reject(new AuthOperationError('OAuth pop-up was blocked'))
|
||||
}
|
||||
const controller = new AbortController()
|
||||
return new Promise((resolve, reject) => {
|
||||
let state = ''
|
||||
let received = false
|
||||
let finished = false
|
||||
let stopCloseWatcher: () => void = () => undefined
|
||||
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('message', onMessage)
|
||||
options.signal.removeEventListener('abort', onAbort)
|
||||
stopCloseWatcher()
|
||||
clearTimeout(deadline)
|
||||
}
|
||||
const fail = (error: unknown) => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
const failure = AuthOperationError.from(error)
|
||||
controller.abort(failure)
|
||||
cleanup()
|
||||
if (!popup.closed) popup.close()
|
||||
reject(failure)
|
||||
}
|
||||
const onAbort = () => fail(options.signal.reason)
|
||||
const finish: OAuthPopupExchange['finish'] = (result) => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
cleanup()
|
||||
if (!popup.closed) {
|
||||
popup.postMessage(
|
||||
{
|
||||
type: OAUTH_POPUP_RESULT_MESSAGE,
|
||||
intent: options.intent,
|
||||
provider: options.provider,
|
||||
state,
|
||||
...result,
|
||||
},
|
||||
window.location.origin
|
||||
)
|
||||
popup.close()
|
||||
}
|
||||
}
|
||||
const onMessage = (event: MessageEvent<unknown>) => {
|
||||
if (
|
||||
received ||
|
||||
finished ||
|
||||
!state ||
|
||||
event.origin !== window.location.origin ||
|
||||
event.source !== popup
|
||||
) {
|
||||
return
|
||||
}
|
||||
const message = event.data as
|
||||
| (Partial<OAuthPopupCallback> & { type?: string; intent?: string })
|
||||
| null
|
||||
if (
|
||||
!message ||
|
||||
message.type !== OAUTH_POPUP_CALLBACK_MESSAGE ||
|
||||
message.intent !== options.intent ||
|
||||
message.provider !== options.provider ||
|
||||
message.state !== state
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
typeof message.code !== 'string' &&
|
||||
typeof message.error !== 'string'
|
||||
) {
|
||||
return
|
||||
}
|
||||
received = true
|
||||
stopCloseWatcher()
|
||||
clearTimeout(deadline)
|
||||
window.removeEventListener('message', onMessage)
|
||||
resolve({
|
||||
callback: {
|
||||
provider: options.provider,
|
||||
state,
|
||||
code: message.code,
|
||||
error: message.error,
|
||||
errorDescription:
|
||||
typeof message.errorDescription === 'string'
|
||||
? message.errorDescription
|
||||
: undefined,
|
||||
},
|
||||
signal: controller.signal,
|
||||
finish,
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('message', onMessage)
|
||||
options.signal.addEventListener('abort', onAbort, { once: true })
|
||||
stopCloseWatcher = watchOAuthPopupClosed(popup, () =>
|
||||
fail(
|
||||
new AuthOperationError(
|
||||
'OAuth authorization was cancelled.',
|
||||
'AUTH_CANCELLED'
|
||||
)
|
||||
)
|
||||
)
|
||||
const deadline = setTimeout(
|
||||
() =>
|
||||
fail(
|
||||
new AuthOperationError(
|
||||
'OAuth authorization timed out. Please try again.'
|
||||
)
|
||||
),
|
||||
10 * 60_000
|
||||
)
|
||||
void options
|
||||
.prepare(controller.signal)
|
||||
.then((authorization) => {
|
||||
if (finished || popup.closed) return
|
||||
state = authorization.state
|
||||
if (
|
||||
!markOAuthPopup(
|
||||
getOAuthSessionStorage(popup),
|
||||
options.provider,
|
||||
state,
|
||||
options.intent
|
||||
)
|
||||
) {
|
||||
throw new AuthOperationError('OAuth popup storage is unavailable.')
|
||||
}
|
||||
popup.location.replace(authorization.url)
|
||||
})
|
||||
.catch(fail)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { SystemStatus, OAuthProvider } from '../types'
|
||||
|
||||
export {
|
||||
buildGitHubOAuthUrl,
|
||||
buildDiscordOAuthUrl,
|
||||
buildOIDCOAuthUrl,
|
||||
buildLinuxDOOAuthUrl,
|
||||
} from '@/lib/oauth'
|
||||
|
||||
// ============================================================================
|
||||
// OAuth Providers Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get available OAuth providers from system status
|
||||
*/
|
||||
export function getAvailableOAuthProviders(
|
||||
status: SystemStatus | null
|
||||
): OAuthProvider[] {
|
||||
if (!status) return []
|
||||
|
||||
const providers: OAuthProvider[] = []
|
||||
|
||||
if (status.github_oauth) {
|
||||
providers.push({
|
||||
name: 'GitHub',
|
||||
type: 'github',
|
||||
enabled: true,
|
||||
clientId: status.github_client_id,
|
||||
})
|
||||
}
|
||||
|
||||
if (status.discord_oauth) {
|
||||
providers.push({
|
||||
name: 'Discord',
|
||||
type: 'discord',
|
||||
enabled: true,
|
||||
clientId: status.discord_client_id,
|
||||
})
|
||||
}
|
||||
|
||||
if (status.oidc_enabled) {
|
||||
providers.push({
|
||||
name: 'OIDC',
|
||||
type: 'oidc',
|
||||
enabled: true,
|
||||
clientId: status.oidc_client_id,
|
||||
authEndpoint: status.oidc_authorization_endpoint,
|
||||
})
|
||||
}
|
||||
|
||||
if (status.linuxdo_oauth) {
|
||||
providers.push({
|
||||
name: 'LinuxDO',
|
||||
type: 'linuxdo',
|
||||
enabled: true,
|
||||
clientId: status.linuxdo_client_id,
|
||||
})
|
||||
}
|
||||
|
||||
if (status.telegram_oauth) {
|
||||
providers.push({
|
||||
name: 'Telegram',
|
||||
type: 'telegram',
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
return providers
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any OAuth provider is available
|
||||
*/
|
||||
export function hasOAuthProviders(status: SystemStatus | null): boolean {
|
||||
if (!status) return false
|
||||
return !!(
|
||||
status.github_oauth ||
|
||||
status.discord_oauth ||
|
||||
status.oidc_enabled ||
|
||||
status.linuxdo_oauth ||
|
||||
status.telegram_oauth ||
|
||||
status.wechat_login
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { t } from 'i18next'
|
||||
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
interface PasswordEncryptionKey {
|
||||
kid: string
|
||||
public_key: string
|
||||
}
|
||||
|
||||
export interface EncryptedPassword {
|
||||
password_encrypted: string
|
||||
encryption_key_id: string
|
||||
}
|
||||
|
||||
const KEY_CACHE_TTL_MS = 5 * 60_000
|
||||
|
||||
let cachedKey: PasswordEncryptionKey | null = null
|
||||
let cachedAt = 0
|
||||
|
||||
export function clearPasswordEncryptionCache(): void {
|
||||
cachedKey = null
|
||||
cachedAt = 0
|
||||
}
|
||||
|
||||
export async function encryptPassword(
|
||||
password: string
|
||||
): Promise<EncryptedPassword> {
|
||||
try {
|
||||
const key = await getPasswordEncryptionKey()
|
||||
const ciphertext = await rsaOaepEncrypt(password, key.public_key, key.kid)
|
||||
return {
|
||||
password_encrypted: ciphertext,
|
||||
encryption_key_id: key.kid,
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
clearPasswordEncryptionCache()
|
||||
throw new Error(t('Login failed'), { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
async function getPasswordEncryptionKey(): Promise<PasswordEncryptionKey> {
|
||||
const now = Date.now()
|
||||
if (cachedKey && now - cachedAt < KEY_CACHE_TTL_MS) {
|
||||
return cachedKey
|
||||
}
|
||||
|
||||
const response = await api.get<{
|
||||
success: boolean
|
||||
data?: PasswordEncryptionKey
|
||||
}>('/api/user/login/encryption-key')
|
||||
const key = response.data?.data
|
||||
if (!response.data?.success || !key?.kid || !key.public_key) {
|
||||
throw new Error('Password encryption key is unavailable')
|
||||
}
|
||||
cachedKey = key
|
||||
cachedAt = now
|
||||
return key
|
||||
}
|
||||
|
||||
async function rsaOaepEncrypt(
|
||||
password: string,
|
||||
publicKeyPEM: string,
|
||||
keyId: string
|
||||
): Promise<string> {
|
||||
if (typeof globalThis.crypto?.subtle !== 'undefined') {
|
||||
try {
|
||||
const publicKey = await globalThis.crypto.subtle.importKey(
|
||||
'spki',
|
||||
pemToDER(publicKeyPEM),
|
||||
{ name: 'RSA-OAEP', hash: 'SHA-256' },
|
||||
false,
|
||||
['encrypt']
|
||||
)
|
||||
const plaintext = new TextEncoder().encode(password)
|
||||
const algorithm = publicKey.algorithm as RsaHashedKeyAlgorithm
|
||||
if (plaintext.byteLength > algorithm.modulusLength / 8 - 66) {
|
||||
const secret = globalThis.crypto.getRandomValues(new Uint8Array(32))
|
||||
const nonce = globalThis.crypto.getRandomValues(new Uint8Array(12))
|
||||
const key = await globalThis.crypto.subtle.importKey(
|
||||
'raw',
|
||||
secret,
|
||||
'AES-GCM',
|
||||
false,
|
||||
['encrypt']
|
||||
)
|
||||
const [wrappedKey, ciphertext] = await Promise.all([
|
||||
globalThis.crypto.subtle.encrypt(
|
||||
{
|
||||
name: 'RSA-OAEP',
|
||||
label: new TextEncoder().encode('password-v2'),
|
||||
},
|
||||
publicKey,
|
||||
secret
|
||||
),
|
||||
globalThis.crypto.subtle.encrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv: nonce,
|
||||
additionalData: new TextEncoder().encode(`password-v2:${keyId}`),
|
||||
},
|
||||
key,
|
||||
plaintext
|
||||
),
|
||||
])
|
||||
return [
|
||||
'v2',
|
||||
arrayBufferToBase64(wrappedKey),
|
||||
arrayBufferToBase64(nonce.buffer),
|
||||
arrayBufferToBase64(ciphertext),
|
||||
].join('.')
|
||||
}
|
||||
const ciphertext = await globalThis.crypto.subtle.encrypt(
|
||||
{ name: 'RSA-OAEP' },
|
||||
publicKey,
|
||||
plaintext
|
||||
)
|
||||
return arrayBufferToBase64(ciphertext)
|
||||
} catch {
|
||||
// Older implementations may expose SubtleCrypto without supporting the
|
||||
// required RSA-OAEP parameters; the HTTP-compatible fallback handles it.
|
||||
}
|
||||
}
|
||||
|
||||
// Web Crypto is restricted to secure contexts in browsers. Lazy-loading
|
||||
// forge keeps the normal HTTPS bundle small while supporting HTTP intranets.
|
||||
const forge = await import('node-forge')
|
||||
const publicKey = forge.pki.publicKeyFromPem(publicKeyPEM)
|
||||
const plaintext = forge.util.encodeUtf8(password)
|
||||
if (plaintext.length > publicKey.n.bitLength() / 8 - 66) {
|
||||
const secret = forge.random.getBytesSync(32)
|
||||
const nonce = forge.random.getBytesSync(12)
|
||||
const wrappedKey = publicKey.encrypt(secret, 'RSA-OAEP', {
|
||||
md: forge.md.sha256.create(),
|
||||
label: 'password-v2',
|
||||
})
|
||||
const cipher = forge.cipher.createCipher('AES-GCM', secret)
|
||||
cipher.start({
|
||||
iv: nonce,
|
||||
additionalData: `password-v2:${keyId}`,
|
||||
tagLength: 128,
|
||||
})
|
||||
cipher.update(forge.util.createBuffer(plaintext))
|
||||
if (!cipher.finish()) throw new Error('Password encryption failed')
|
||||
const ciphertext = cipher.output.getBytes() + cipher.mode.tag.getBytes()
|
||||
return [
|
||||
'v2',
|
||||
forge.util.encode64(wrappedKey),
|
||||
forge.util.encode64(nonce),
|
||||
forge.util.encode64(ciphertext),
|
||||
].join('.')
|
||||
}
|
||||
const ciphertext = publicKey.encrypt(plaintext, 'RSA-OAEP', {
|
||||
md: forge.md.sha256.create(),
|
||||
})
|
||||
return forge.util.encode64(ciphertext)
|
||||
}
|
||||
|
||||
function pemToDER(pem: string): ArrayBuffer {
|
||||
const body = pem
|
||||
.replace('-----BEGIN PUBLIC KEY-----', '')
|
||||
.replace('-----END PUBLIC KEY-----', '')
|
||||
.replaceAll(/\s+/g, '')
|
||||
const binary = atob(body)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index)
|
||||
}
|
||||
return bytes.buffer
|
||||
}
|
||||
|
||||
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer)
|
||||
let binary = ''
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte)
|
||||
}
|
||||
return btoa(binary)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
/**
|
||||
* Utilities for managing authentication-related browser storage
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// LocalStorage Keys
|
||||
// ============================================================================
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
AFFILIATE: 'aff',
|
||||
STATUS: 'status',
|
||||
} as const
|
||||
|
||||
// ============================================================================
|
||||
// Affiliate Code Storage
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get affiliate code from localStorage
|
||||
*/
|
||||
export function getAffiliateCode(): string {
|
||||
if (typeof window === 'undefined') return ''
|
||||
try {
|
||||
return window.localStorage.getItem(STORAGE_KEYS.AFFILIATE) ?? ''
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to get affiliate code:', error)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save affiliate code to localStorage
|
||||
*/
|
||||
export function saveAffiliateCode(code: string): void {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEYS.AFFILIATE, code)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to save affiliate code:', error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { pickTelegramAuthorization } from './telegram-login'
|
||||
|
||||
describe('Telegram login authorization', () => {
|
||||
test('keeps only fields signed by the Telegram login contract', () => {
|
||||
expect(
|
||||
pickTelegramAuthorization({
|
||||
id: 12345,
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
username: 'test_user',
|
||||
photo_url: 'https://t.me/i/userpic/320/test.jpg',
|
||||
auth_date: 1_900_000_000,
|
||||
hash: 'signed-hash',
|
||||
lang: 'en',
|
||||
admin: true,
|
||||
redirect: 'https://attacker.example',
|
||||
})
|
||||
).toEqual({
|
||||
id: 12345,
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
username: 'test_user',
|
||||
photo_url: 'https://t.me/i/userpic/320/test.jpg',
|
||||
auth_date: 1_900_000_000,
|
||||
hash: 'signed-hash',
|
||||
lang: 'en',
|
||||
})
|
||||
})
|
||||
|
||||
test('rejects incomplete or structurally invalid callbacks', () => {
|
||||
expect(pickTelegramAuthorization(null)).toBe(null)
|
||||
expect(pickTelegramAuthorization({ auth_date: 1, hash: 'hash' })).toBe(null)
|
||||
expect(pickTelegramAuthorization({ id: 1, auth_date: 1, hash: '' })).toBe(
|
||||
null
|
||||
)
|
||||
expect(
|
||||
pickTelegramAuthorization({ id: {}, auth_date: 1, hash: 'hash' })
|
||||
).toBe(null)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
export type TelegramAuthorization = {
|
||||
id: string | number
|
||||
auth_date: string | number
|
||||
hash: string
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
username?: string
|
||||
photo_url?: string
|
||||
lang?: string
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object'
|
||||
}
|
||||
|
||||
function readTelegramNumber(value: unknown): string | number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
if (typeof value === 'string' && value.trim()) return value
|
||||
return null
|
||||
}
|
||||
|
||||
export function pickTelegramAuthorization(
|
||||
value: unknown
|
||||
): TelegramAuthorization | null {
|
||||
if (!isRecord(value)) return null
|
||||
|
||||
const id = readTelegramNumber(value.id)
|
||||
const authDate = readTelegramNumber(value.auth_date)
|
||||
const hash = typeof value.hash === 'string' ? value.hash.trim() : ''
|
||||
if (id === null || authDate === null || !hash) return null
|
||||
|
||||
const authorization: TelegramAuthorization = {
|
||||
id,
|
||||
auth_date: authDate,
|
||||
hash,
|
||||
}
|
||||
const optionalFields = [
|
||||
'first_name',
|
||||
'last_name',
|
||||
'username',
|
||||
'photo_url',
|
||||
'lang',
|
||||
] as const
|
||||
|
||||
for (const field of optionalFields) {
|
||||
const fieldValue = value[field]
|
||||
if (typeof fieldValue === 'string' && fieldValue) {
|
||||
authorization[field] = fieldValue
|
||||
}
|
||||
}
|
||||
|
||||
return authorization
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { BACKUP_CODE_REGEX, OTP_REGEX } from '../constants'
|
||||
|
||||
/**
|
||||
* Validation utilities for authentication forms
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// OTP Validation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Validate OTP code (6 digits)
|
||||
*/
|
||||
export function isValidOTP(code: string): boolean {
|
||||
return OTP_REGEX.test(code)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate backup code (XXXX-XXXX format)
|
||||
*/
|
||||
export function isValidBackupCode(code: string): boolean {
|
||||
return BACKUP_CODE_REGEX.test(code)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format backup code with hyphen (XXXX-XXXX)
|
||||
*/
|
||||
export function formatBackupCode(value: string): string {
|
||||
// Remove all non-alphanumeric characters and convert to uppercase
|
||||
let cleaned = value.toUpperCase().replace(/[^A-Z0-9]/g, '')
|
||||
|
||||
// Limit to 8 characters
|
||||
if (cleaned.length > 8) {
|
||||
cleaned = cleaned.slice(0, 8)
|
||||
}
|
||||
|
||||
// Add hyphen after 4th character
|
||||
if (cleaned.length > 4) {
|
||||
return cleaned.slice(0, 4) + '-' + cleaned.slice(4)
|
||||
}
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove hyphens from backup code before sending to server
|
||||
*/
|
||||
export function cleanBackupCode(code: string): string {
|
||||
return code.replace(/-/g, '')
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Email Validation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Basic email validation
|
||||
*/
|
||||
export function isValidEmail(email: string): boolean {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
return emailRegex.test(email)
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import {
|
||||
createMemoryHistory,
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createRouter,
|
||||
Outlet,
|
||||
RouterProvider,
|
||||
} from '@tanstack/react-router'
|
||||
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
|
||||
import { api } from '@/lib/api'
|
||||
import { useAuthStore, type AuthBundle } from '@/stores/auth-store'
|
||||
|
||||
import { useAuthRedirect } from '../../hooks/use-auth-redirect'
|
||||
import type { LoginResult } from '../../secure-verification/types'
|
||||
import { OtpForm } from '../components/otp-form'
|
||||
|
||||
const bundle: AuthBundle = {
|
||||
access_token: 'completed-login',
|
||||
token_type: 'Bearer',
|
||||
access_expires_at: 9999999999,
|
||||
user: { id: 42, username: 'verified-user', role: 1 },
|
||||
session: {
|
||||
sid: 'verified-session',
|
||||
current: true,
|
||||
login_method: 'password',
|
||||
ip: '',
|
||||
user_agent: '',
|
||||
created_at: 1,
|
||||
last_active_at: 1,
|
||||
expires_at: 9999999999,
|
||||
},
|
||||
}
|
||||
|
||||
function PrimaryLoginHarness(props: { result: LoginResult }) {
|
||||
const { handleLoginResult } = useAuthRedirect()
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => void handleLoginResult(props.result, '/pricing?view=grid')}
|
||||
>
|
||||
Primary authentication succeeded
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function renderLoginVerification(
|
||||
hasChallenge = true,
|
||||
primaryResult?: LoginResult
|
||||
) {
|
||||
useAuthStore.getState().auth.reset('complete')
|
||||
if (hasChallenge) {
|
||||
useAuthStore.getState().auth.setPendingLoginVerification({
|
||||
challenge: {
|
||||
require_verification: true,
|
||||
flow_token: 'login-flow',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 300,
|
||||
methods: [{ method: '2fa', available: true }],
|
||||
},
|
||||
redirectTo: '/pricing?view=grid',
|
||||
})
|
||||
}
|
||||
const root = createRootRoute({ component: Outlet })
|
||||
const routes = [
|
||||
createRoute({
|
||||
getParentRoute: () => root,
|
||||
path: '/otp',
|
||||
component: OtpForm,
|
||||
}),
|
||||
createRoute({
|
||||
getParentRoute: () => root,
|
||||
path: '/sign-in',
|
||||
component: () => <div>Sign-in page</div>,
|
||||
}),
|
||||
createRoute({
|
||||
getParentRoute: () => root,
|
||||
path: '/pricing',
|
||||
component: () => <div>Pricing destination</div>,
|
||||
}),
|
||||
createRoute({
|
||||
getParentRoute: () => root,
|
||||
path: '/primary-login',
|
||||
component: () =>
|
||||
primaryResult ? <PrimaryLoginHarness result={primaryResult} /> : null,
|
||||
}),
|
||||
]
|
||||
const router = createRouter({
|
||||
routeTree: root.addChildren(routes),
|
||||
history: createMemoryHistory({
|
||||
initialEntries: [primaryResult ? '/primary-login' : '/otp'],
|
||||
}),
|
||||
})
|
||||
const view = render(<RouterProvider router={router} />)
|
||||
return { router, ...view }
|
||||
}
|
||||
|
||||
function pendingLoginResponse() {
|
||||
let resolve!: (value: {
|
||||
data: { success: boolean; data: AuthBundle }
|
||||
}) => void
|
||||
const promise = new Promise<{ data: { success: boolean; data: AuthBundle } }>(
|
||||
(finish) => {
|
||||
resolve = finish
|
||||
}
|
||||
)
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
useAuthStore.getState().auth.reset('idle')
|
||||
})
|
||||
|
||||
it('routes a primary-authenticated challenge into shared verification without applying authentication', async () => {
|
||||
const post = vi.spyOn(api, 'post')
|
||||
const { router } = renderLoginVerification(false, {
|
||||
require_verification: true,
|
||||
flow_token: 'primary-challenge',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 300,
|
||||
methods: [{ method: '2fa', available: true }],
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'Primary authentication succeeded',
|
||||
})
|
||||
)
|
||||
expect(
|
||||
await screen.findByLabelText('Authenticator code or backup code')
|
||||
).toBeVisible()
|
||||
expect(router.state.location.pathname).toBe('/otp')
|
||||
expect(useAuthStore.getState().auth.user).toBeNull()
|
||||
expect(post).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts a completed Passkey login without opening another verification dialog', async () => {
|
||||
const post = vi.spyOn(api, 'post')
|
||||
const { router } = renderLoginVerification(false, {
|
||||
...bundle,
|
||||
session: { ...bundle.session, login_method: 'passkey' },
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'Primary authentication succeeded',
|
||||
})
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(router.state.location.href).toBe('/pricing?view=grid')
|
||||
)
|
||||
expect(useAuthStore.getState().auth.user?.id).toBe(42)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(post).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('writes authentication only after verification and preserves the original destination', async () => {
|
||||
const pending = pendingLoginResponse()
|
||||
const post = vi.spyOn(api, 'post').mockReturnValue(pending.promise)
|
||||
const { router } = renderLoginVerification()
|
||||
const user = userEvent.setup()
|
||||
await user.type(
|
||||
await screen.findByLabelText('Authenticator code or backup code'),
|
||||
'123456'
|
||||
)
|
||||
expect(useAuthStore.getState().auth.pendingLoginVerification).toBeNull()
|
||||
expect(useAuthStore.getState().auth.user).toBeNull()
|
||||
await user.dblClick(screen.getByRole('button', { name: 'Verify' }))
|
||||
expect(post).toHaveBeenCalledTimes(1)
|
||||
expect(useAuthStore.getState().auth.user).toBeNull()
|
||||
await act(async () => {
|
||||
pending.resolve({ data: { success: true, data: bundle } })
|
||||
await pending.promise
|
||||
})
|
||||
await waitFor(() =>
|
||||
expect(router.state.location.href).toBe('/pricing?view=grid')
|
||||
)
|
||||
expect(useAuthStore.getState().auth.user?.id).toBe(42)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('cancels verification with Escape and returns to sign-in without authenticating', async () => {
|
||||
const post = vi.spyOn(api, 'post')
|
||||
const { router } = renderLoginVerification()
|
||||
const user = userEvent.setup()
|
||||
await screen.findByLabelText('Authenticator code or backup code')
|
||||
await user.keyboard('{Escape}')
|
||||
await waitFor(() => expect(router.state.location.pathname).toBe('/sign-in'))
|
||||
expect(post).not.toHaveBeenCalled()
|
||||
expect(useAuthStore.getState().auth.user).toBeNull()
|
||||
expect(useAuthStore.getState().auth.pendingLoginVerification).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['unmount', 'account switch'] as const)(
|
||||
'aborts an in-flight verification on %s and ignores its late result',
|
||||
async (change) => {
|
||||
const pending = pendingLoginResponse()
|
||||
const post = vi.spyOn(api, 'post').mockReturnValue(pending.promise)
|
||||
const { router } = renderLoginVerification()
|
||||
const user = userEvent.setup()
|
||||
await user.type(
|
||||
await screen.findByLabelText('Authenticator code or backup code'),
|
||||
'123456'
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Verify' }))
|
||||
await act(async () => {
|
||||
if (change === 'unmount') {
|
||||
await router.navigate({ to: '/sign-in' })
|
||||
} else {
|
||||
useAuthStore.getState().auth.setBundle({
|
||||
...bundle,
|
||||
user: { ...bundle.user, id: 99 },
|
||||
session: { ...bundle.session, sid: 'other-session' },
|
||||
})
|
||||
}
|
||||
})
|
||||
expect(post.mock.calls[0][2]?.signal?.aborted).toBe(true)
|
||||
await act(async () => {
|
||||
pending.resolve({ data: { success: true, data: bundle } })
|
||||
await pending.promise
|
||||
})
|
||||
expect(useAuthStore.getState().auth.user?.id).toBe(
|
||||
change === 'account switch' ? 99 : undefined
|
||||
)
|
||||
expect(useAuthStore.getState().auth.pendingLoginVerification).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('requires a new sign-in when the verification page has no in-memory challenge', async () => {
|
||||
const post = vi.spyOn(api, 'post')
|
||||
const { router } = renderLoginVerification(false)
|
||||
await waitFor(() => expect(router.state.location.pathname).toBe('/sign-in'))
|
||||
expect(post).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects submission after the five-minute challenge deadline without sending a request', async () => {
|
||||
const post = vi.spyOn(api, 'post')
|
||||
renderLoginVerification()
|
||||
const user = userEvent.setup()
|
||||
await user.type(
|
||||
await screen.findByLabelText('Authenticator code or backup code'),
|
||||
'123456'
|
||||
)
|
||||
const later = Date.now() + 301000
|
||||
vi.spyOn(Date, 'now').mockReturnValue(later)
|
||||
await user.click(screen.getByRole('button', { name: 'Verify' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'Login flow expired. Please sign in again.'
|
||||
)
|
||||
expect(post).not.toHaveBeenCalled()
|
||||
expect(useAuthStore.getState().auth.user).toBeNull()
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { AuthOperationError } from '@/lib/secure-verification'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
import { useAuthRedirect } from '../../hooks/use-auth-redirect'
|
||||
import {
|
||||
SecureVerificationDialog,
|
||||
useSecureVerification,
|
||||
} from '../../secure-verification'
|
||||
|
||||
export function OtpForm() {
|
||||
const { t } = useTranslation()
|
||||
// Transfer the pending challenge from the navigation handoff into this page's
|
||||
// lifetime. Reloading or leaving the page cannot resume it from browser storage.
|
||||
const pending = useRef(
|
||||
useAuthStore.getState().auth.pendingLoginVerification
|
||||
).current
|
||||
const initialSessionID = useRef(
|
||||
useAuthStore.getState().auth.session?.sid
|
||||
).current
|
||||
const sessionID = useAuthStore((state) => state.auth.session?.sid)
|
||||
const verification = useSecureVerification()
|
||||
const { requestLoginVerification, cancel } = verification
|
||||
const { handleLoginSuccess, redirectToLogin } = useAuthRedirect()
|
||||
const completed = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (completed.current) return
|
||||
if (!pending) {
|
||||
redirectToLogin()
|
||||
return
|
||||
}
|
||||
if (sessionID !== initialSessionID) {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
if (useAuthStore.getState().auth.pendingLoginVerification === pending) {
|
||||
useAuthStore.getState().auth.setPendingLoginVerification(null)
|
||||
}
|
||||
let active = true
|
||||
void (async () => {
|
||||
try {
|
||||
const bundle = await requestLoginVerification(pending.challenge)
|
||||
if (
|
||||
!active ||
|
||||
useAuthStore.getState().auth.session?.sid !== initialSessionID
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!bundle) {
|
||||
redirectToLogin()
|
||||
return
|
||||
}
|
||||
completed.current = true
|
||||
await handleLoginSuccess(bundle, pending.redirectTo)
|
||||
toast.success(t('Signed in'))
|
||||
} catch (error) {
|
||||
if (!active) return
|
||||
toast.error(t(AuthOperationError.from(error).message))
|
||||
redirectToLogin()
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
active = false
|
||||
cancel()
|
||||
}
|
||||
}, [
|
||||
pending,
|
||||
initialSessionID,
|
||||
sessionID,
|
||||
requestLoginVerification,
|
||||
cancel,
|
||||
handleLoginSuccess,
|
||||
redirectToLogin,
|
||||
t,
|
||||
])
|
||||
|
||||
return <SecureVerificationDialog {...verification.dialogProps} />
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { AuthLayout } from '../auth-layout'
|
||||
import { OtpForm } from './components/otp-form'
|
||||
|
||||
export function Otp() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<AuthLayout>
|
||||
<div className='w-full space-y-8'>
|
||||
<div className='space-y-3'>
|
||||
<h2 className='text-center text-2xl font-semibold tracking-tight sm:text-left'>
|
||||
{t('Security verification')}
|
||||
</h2>
|
||||
<p className='text-muted-foreground text-left text-sm sm:text-base'>
|
||||
{t('Verify your identity to finish signing in.')}
|
||||
</p>
|
||||
<p className='text-muted-foreground text-left text-sm sm:text-base'>
|
||||
{t('Session expired?')}{' '}
|
||||
<Link
|
||||
to='/sign-in'
|
||||
className='hover:text-primary font-medium underline underline-offset-4'
|
||||
>
|
||||
{t('Re-login')}
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<OtpForm />
|
||||
</div>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { api } from '@/lib/api'
|
||||
import { authRequestOptions, authResult } from '@/lib/secure-verification'
|
||||
|
||||
import type {
|
||||
SecurityProof,
|
||||
VerificationOperation,
|
||||
} from '../secure-verification/types'
|
||||
import type { ApiResponse, PasskeyOptionsPayload, PasskeyStatus } from './types'
|
||||
|
||||
function proofHeaders(proofToken?: string): Record<string, string> | undefined {
|
||||
return proofToken ? { 'X-Security-Proof': proofToken } : undefined
|
||||
}
|
||||
|
||||
export async function getPasskeyStatus(): Promise<ApiResponse<PasskeyStatus>> {
|
||||
const res = await api.get<ApiResponse<PasskeyStatus>>('/api/user/passkey')
|
||||
return res.data
|
||||
}
|
||||
|
||||
export function beginPasskeyRegistration(
|
||||
proofToken: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<PasskeyOptionsPayload> {
|
||||
return authResult(
|
||||
api.post('/api/user/passkey/register/begin', undefined, {
|
||||
...authRequestOptions,
|
||||
headers: proofHeaders(proofToken),
|
||||
signal,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export function finishPasskeyRegistration(
|
||||
flowToken: string,
|
||||
payload: Record<string, unknown>,
|
||||
signal?: AbortSignal
|
||||
): Promise<unknown> {
|
||||
return authResult(
|
||||
api.post(
|
||||
'/api/user/passkey/register/finish',
|
||||
{ flow_token: flowToken, credential: payload },
|
||||
{
|
||||
...authRequestOptions,
|
||||
acceptAuthRotation: true,
|
||||
singleUseAuthorization: true,
|
||||
signal,
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function deletePasskey(
|
||||
proofToken: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<unknown> {
|
||||
return authResult(
|
||||
api.delete('/api/user/passkey', {
|
||||
...authRequestOptions,
|
||||
headers: proofHeaders(proofToken),
|
||||
acceptAuthRotation: true,
|
||||
signal,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export async function beginPasskeyLogin(): Promise<
|
||||
ApiResponse<PasskeyOptionsPayload>
|
||||
> {
|
||||
const res = await api.post<ApiResponse<PasskeyOptionsPayload>>(
|
||||
'/api/user/passkey/login/begin'
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function finishPasskeyLogin(
|
||||
flowToken: string,
|
||||
payload: Record<string, unknown>
|
||||
): Promise<ApiResponse> {
|
||||
const res = await api.post<ApiResponse>(
|
||||
'/api/user/passkey/login/finish',
|
||||
{ flow_token: flowToken, credential: payload },
|
||||
{ skipAuthRefresh: true }
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export function beginPasskeyVerification(
|
||||
operation: VerificationOperation,
|
||||
signal?: AbortSignal
|
||||
): Promise<PasskeyOptionsPayload> {
|
||||
return authResult(
|
||||
api.post(
|
||||
'/api/user/passkey/verify/begin',
|
||||
{
|
||||
scope: operation.scope,
|
||||
...(operation.context ? { context: operation.context } : {}),
|
||||
},
|
||||
{ ...authRequestOptions, signal }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function finishPasskeyVerification(
|
||||
flowToken: string,
|
||||
payload: Record<string, unknown>,
|
||||
signal?: AbortSignal
|
||||
): Promise<SecurityProof> {
|
||||
return authResult(
|
||||
api.post(
|
||||
'/api/user/passkey/verify/finish',
|
||||
{ flow_token: flowToken, credential: payload },
|
||||
{ ...authRequestOptions, singleUseAuthorization: true, signal }
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import {
|
||||
buildRegistrationResult,
|
||||
createCredential,
|
||||
isPasskeySupported,
|
||||
prepareCredentialCreationOptions,
|
||||
} from '@/lib/passkey'
|
||||
import { AuthOperationError } from '@/lib/secure-verification'
|
||||
|
||||
import {
|
||||
beginPasskeyRegistration,
|
||||
deletePasskey,
|
||||
finishPasskeyRegistration,
|
||||
getPasskeyStatus,
|
||||
} from '../api'
|
||||
import type { PasskeyStatus } from '../types'
|
||||
|
||||
export function usePasskeyManagement() {
|
||||
const [status, setStatus] = useState<PasskeyStatus | null>(null)
|
||||
const [statusError, setStatusError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [registering, setRegistering] = useState(false)
|
||||
const [removing, setRemoving] = useState(false)
|
||||
const [supported, setSupported] = useState(false)
|
||||
const operation = useRef<AbortController | null>(null)
|
||||
const mounted = useRef(true)
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await getPasskeyStatus()
|
||||
if (!response.success || !response.data) {
|
||||
throw new AuthOperationError(
|
||||
response.message || 'Failed to load Passkey status'
|
||||
)
|
||||
}
|
||||
if (!mounted.current) return
|
||||
setStatus(response.data)
|
||||
setStatusError(null)
|
||||
} catch (error) {
|
||||
if (mounted.current) {
|
||||
setStatusError(AuthOperationError.from(error).message)
|
||||
}
|
||||
} finally {
|
||||
if (mounted.current) setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true
|
||||
void fetchStatus()
|
||||
void isPasskeySupported().then((value) => {
|
||||
if (mounted.current) setSupported(value)
|
||||
})
|
||||
return () => {
|
||||
mounted.current = false
|
||||
operation.current?.abort()
|
||||
}
|
||||
}, [fetchStatus])
|
||||
|
||||
const register = useCallback(
|
||||
async (proofToken: string) => {
|
||||
if (!supported || !navigator.credentials) {
|
||||
throw new AuthOperationError('This device does not support Passkey')
|
||||
}
|
||||
if (operation.current) {
|
||||
throw new AuthOperationError(
|
||||
'A security operation is already in progress.'
|
||||
)
|
||||
}
|
||||
const controller = new AbortController()
|
||||
operation.current = controller
|
||||
setRegistering(true)
|
||||
try {
|
||||
const begin = await beginPasskeyRegistration(
|
||||
proofToken,
|
||||
controller.signal
|
||||
)
|
||||
if (!begin.flow_token) {
|
||||
throw new AuthOperationError(
|
||||
'Registration flow expired. Please try again.'
|
||||
)
|
||||
}
|
||||
const credential = (await createCredential(
|
||||
prepareCredentialCreationOptions(begin.options ?? begin),
|
||||
controller.signal
|
||||
)) as PublicKeyCredential | null
|
||||
controller.signal.throwIfAborted()
|
||||
if (!credential) {
|
||||
throw new AuthOperationError(
|
||||
'Passkey registration was cancelled',
|
||||
'AUTH_CANCELLED'
|
||||
)
|
||||
}
|
||||
const attestation = buildRegistrationResult(credential)
|
||||
if (!attestation) {
|
||||
throw new AuthOperationError('Invalid Passkey registration response')
|
||||
}
|
||||
await finishPasskeyRegistration(
|
||||
begin.flow_token,
|
||||
attestation,
|
||||
controller.signal
|
||||
)
|
||||
controller.signal.throwIfAborted()
|
||||
await fetchStatus()
|
||||
} catch (error) {
|
||||
if (mounted.current && !controller.signal.aborted) await fetchStatus()
|
||||
if (
|
||||
controller.signal.aborted ||
|
||||
(error instanceof DOMException && error.name === 'NotAllowedError')
|
||||
) {
|
||||
throw new AuthOperationError(
|
||||
'Passkey registration was cancelled',
|
||||
'AUTH_CANCELLED',
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
throw AuthOperationError.from(error, 'Failed to register Passkey')
|
||||
} finally {
|
||||
if (operation.current === controller) operation.current = null
|
||||
if (mounted.current) setRegistering(false)
|
||||
}
|
||||
},
|
||||
[fetchStatus, supported]
|
||||
)
|
||||
|
||||
const remove = useCallback(
|
||||
async (proofToken: string) => {
|
||||
if (operation.current) {
|
||||
throw new AuthOperationError(
|
||||
'A security operation is already in progress.'
|
||||
)
|
||||
}
|
||||
const controller = new AbortController()
|
||||
operation.current = controller
|
||||
setRemoving(true)
|
||||
try {
|
||||
await deletePasskey(proofToken, controller.signal)
|
||||
controller.signal.throwIfAborted()
|
||||
await fetchStatus()
|
||||
} catch (error) {
|
||||
if (mounted.current && !controller.signal.aborted) await fetchStatus()
|
||||
if (controller.signal.aborted) {
|
||||
throw new AuthOperationError(
|
||||
'Operation cancelled',
|
||||
'AUTH_CANCELLED',
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
throw AuthOperationError.from(error, 'Failed to remove Passkey')
|
||||
} finally {
|
||||
if (operation.current === controller) operation.current = null
|
||||
if (mounted.current) setRemoving(false)
|
||||
}
|
||||
},
|
||||
[fetchStatus]
|
||||
)
|
||||
|
||||
return {
|
||||
status,
|
||||
statusError,
|
||||
loading,
|
||||
registering,
|
||||
removing,
|
||||
supported,
|
||||
enabled: Boolean(status?.enabled),
|
||||
lastUsed: status?.last_used_at ?? null,
|
||||
fetchStatus,
|
||||
register,
|
||||
remove,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
export * from './api'
|
||||
export * from './types'
|
||||
export * from './hooks/use-passkey-management'
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
export interface ApiResponse<T = unknown> {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: T
|
||||
}
|
||||
|
||||
export interface PasskeyStatus {
|
||||
enabled: boolean
|
||||
last_used_at?: string | null
|
||||
backup_eligible?: boolean
|
||||
backup_state?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface PasskeyOptionsPayload {
|
||||
options?: unknown
|
||||
flow_token?: string
|
||||
expires_at?: number
|
||||
publicKey?: unknown
|
||||
response?: unknown
|
||||
Response?: unknown
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { CheckIcon, CopyIcon } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useCountdown } from '@/hooks/use-countdown'
|
||||
import { api } from '@/lib/api'
|
||||
import { copyToClipboard } from '@/lib/copy-to-clipboard'
|
||||
|
||||
import { AuthLayout } from '../auth-layout'
|
||||
|
||||
export type ResetPasswordSearchParams = {
|
||||
email?: string
|
||||
token?: string
|
||||
}
|
||||
|
||||
type ResetPasswordConfirmProps = ResetPasswordSearchParams
|
||||
|
||||
export function ResetPasswordConfirm({
|
||||
email,
|
||||
token,
|
||||
}: ResetPasswordConfirmProps) {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const {
|
||||
secondsLeft,
|
||||
isActive,
|
||||
start: startCountdown,
|
||||
} = useCountdown({ initialSeconds: 30 })
|
||||
|
||||
const isValidResetLink = Boolean(email && token)
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!isValidResetLink || !email || !token) {
|
||||
toast.error(t('Invalid reset link, please request a new password reset'))
|
||||
return
|
||||
}
|
||||
|
||||
startCountdown()
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.post('/api/user/reset', { email, token }, {
|
||||
skipBusinessError: true,
|
||||
} as Record<string, unknown>)
|
||||
|
||||
if (res?.data?.success) {
|
||||
const password = res.data.data
|
||||
setNewPassword(password)
|
||||
const copySuccess = await copyToClipboard(password)
|
||||
if (copySuccess) {
|
||||
toast.success(
|
||||
t('Password reset and copied to clipboard: {{password}}', {
|
||||
password,
|
||||
})
|
||||
)
|
||||
} else {
|
||||
toast.success(t('Password reset: {{password}}', { password }))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Errors handled by global interceptor
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
if (!newPassword) return
|
||||
|
||||
const copySuccess = await copyToClipboard(newPassword)
|
||||
if (copySuccess) {
|
||||
setCopied(true)
|
||||
toast.success(
|
||||
t('Password copied to clipboard: {{password}}', {
|
||||
password: newPassword,
|
||||
})
|
||||
)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<div className='w-full space-y-8'>
|
||||
<div className='space-y-2'>
|
||||
<h2 className='text-center text-2xl font-semibold tracking-tight sm:text-left'>
|
||||
{t('Reset password')}
|
||||
</h2>
|
||||
<p className='text-muted-foreground text-left text-sm sm:text-base'>
|
||||
{newPassword
|
||||
? t('auth.resetPasswordConfirm.success')
|
||||
: t('auth.resetPasswordConfirm.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='space-y-4'>
|
||||
{!isValidResetLink && (
|
||||
<Alert variant='destructive'>
|
||||
<AlertDescription>
|
||||
{t('Invalid reset link, please request a new password reset.')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='email'>{t('Email')}</Label>
|
||||
<Input
|
||||
id='email'
|
||||
type='email'
|
||||
value={email || ''}
|
||||
disabled
|
||||
placeholder={t('Waiting for email...')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{newPassword && (
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='password'>{t('New password')}</Label>
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
id='password'
|
||||
value={newPassword}
|
||||
disabled
|
||||
className='font-mono'
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
size='icon'
|
||||
variant='outline'
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className='h-4 w-4' />
|
||||
) : (
|
||||
<CopyIcon className='h-4 w-4' />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Password has been copied to clipboard')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className='w-full'
|
||||
onClick={
|
||||
newPassword
|
||||
? () => navigate({ to: '/sign-in', replace: true })
|
||||
: handleSubmit
|
||||
}
|
||||
disabled={
|
||||
newPassword ? false : loading || isActive || !isValidResetLink
|
||||
}
|
||||
>
|
||||
{newPassword
|
||||
? t('auth.resetPasswordConfirm.backToLogin')
|
||||
: isActive
|
||||
? t('auth.resetPasswordConfirm.retry', {
|
||||
seconds: secondsLeft,
|
||||
})
|
||||
: t('auth.resetPasswordConfirm.confirm')}
|
||||
</Button>
|
||||
|
||||
{!newPassword && (
|
||||
<Button
|
||||
variant='link'
|
||||
className='w-full'
|
||||
onClick={() => navigate({ to: '/sign-in', replace: true })}
|
||||
>
|
||||
{t('Back to login')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import {
|
||||
createDecipheriv,
|
||||
generateKeyPairSync,
|
||||
privateDecrypt,
|
||||
webcrypto,
|
||||
} from 'node:crypto'
|
||||
|
||||
import { waitFor } from '@testing-library/react'
|
||||
import { AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
|
||||
import { api } from '@/lib/api'
|
||||
import { AuthOperationError, authResult } from '@/lib/secure-verification'
|
||||
import { useAuthStore, type AuthBundle } from '@/stores/auth-store'
|
||||
|
||||
import { createOAuthFlow } from '../../api'
|
||||
import { OAUTH_POPUP_CALLBACK_MESSAGE } from '../../constants'
|
||||
import {
|
||||
clearPasswordEncryptionCache,
|
||||
encryptPassword,
|
||||
} from '../../lib/password-encryption'
|
||||
import { checkVerificationMethods, verify } from '../api'
|
||||
import type { SecurityProof } from '../types'
|
||||
|
||||
const originalAdapter = api.defaults.adapter
|
||||
const originalLocation = window.location.href
|
||||
|
||||
it('allows a security key when the browser has WebAuthn but no platform authenticator', async () => {
|
||||
vi.stubGlobal(
|
||||
'PublicKeyCredential',
|
||||
class {
|
||||
static isUserVerifyingPlatformAuthenticatorAvailable() {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
}
|
||||
)
|
||||
vi.spyOn(api, 'get').mockResolvedValue({
|
||||
data: {
|
||||
success: true,
|
||||
data: {
|
||||
scope: 'passkey.delete',
|
||||
methods: [{ method: 'passkey', available: true }],
|
||||
oauth_providers: [],
|
||||
password_encryption_enabled: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
const requirements = await checkVerificationMethods('passkey.delete')
|
||||
expect(requirements.methods).toEqual([{ method: 'passkey', available: true }])
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'retains the Telegram verification request after popup close and honors caller cancellation: %s',
|
||||
async (cancel) => {
|
||||
const popup = {
|
||||
closed: false,
|
||||
location: { replace: vi.fn() },
|
||||
sessionStorage: window.sessionStorage,
|
||||
close: vi.fn(),
|
||||
postMessage: vi.fn(),
|
||||
}
|
||||
vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window)
|
||||
vi.spyOn(api, 'post').mockResolvedValue({
|
||||
data: {
|
||||
success: true,
|
||||
data: {
|
||||
flow_token: 'verification-state',
|
||||
authorization_url: 'https://oauth.telegram.org/auth?server=pkce',
|
||||
},
|
||||
},
|
||||
})
|
||||
const proof: SecurityProof = {
|
||||
proof_token: 'telegram-proof',
|
||||
method: 'oauth',
|
||||
scope: '2fa.setup',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 300,
|
||||
}
|
||||
let resolve!: (response: {
|
||||
data: { success: boolean; data: SecurityProof }
|
||||
}) => void
|
||||
const response = new Promise<{
|
||||
data: { success: boolean; data: SecurityProof }
|
||||
}>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
const get = vi
|
||||
.spyOn(api, 'get')
|
||||
.mockImplementation((url) =>
|
||||
url === '/api/status'
|
||||
? Promise.resolve({ data: { success: true, data: {} } })
|
||||
: response
|
||||
)
|
||||
const controller = new AbortController()
|
||||
const result = verify(
|
||||
{ method: 'oauth', provider: 'telegram' },
|
||||
{ scope: '2fa.setup' },
|
||||
false,
|
||||
controller.signal
|
||||
)
|
||||
const outcome = cancel
|
||||
? expect(result).rejects.toMatchObject({ code: 'AUTH_CANCELLED' })
|
||||
: expect(result).resolves.toEqual(proof)
|
||||
await waitFor(() =>
|
||||
expect(popup.location.replace).toHaveBeenCalledWith(
|
||||
'https://oauth.telegram.org/auth?server=pkce'
|
||||
)
|
||||
)
|
||||
const event = new MessageEvent('message', {
|
||||
origin: window.location.origin,
|
||||
data: {
|
||||
type: OAUTH_POPUP_CALLBACK_MESSAGE,
|
||||
intent: 'verify',
|
||||
provider: 'telegram',
|
||||
state: 'verification-state',
|
||||
code: 'code',
|
||||
},
|
||||
})
|
||||
Object.defineProperty(event, 'source', { value: popup })
|
||||
window.dispatchEvent(event)
|
||||
popup.closed = true
|
||||
await waitFor(() =>
|
||||
expect(get).toHaveBeenCalledWith(
|
||||
'/api/oauth/telegram',
|
||||
expect.objectContaining({ singleUseAuthorization: true })
|
||||
)
|
||||
)
|
||||
const signal = get.mock.calls.find(
|
||||
([url]) => url === '/api/oauth/telegram'
|
||||
)?.[1]?.signal
|
||||
expect(signal?.aborted).toBe(false)
|
||||
if (cancel) {
|
||||
controller.abort(new AuthOperationError('Cancelled', 'AUTH_CANCELLED'))
|
||||
}
|
||||
expect(signal?.aborted).toBe(cancel)
|
||||
resolve({ data: { success: true, data: proof } })
|
||||
await outcome
|
||||
}
|
||||
)
|
||||
const sessionBundle = {
|
||||
access_token: 'access-token',
|
||||
token_type: 'Bearer' as const,
|
||||
access_expires_at: Math.floor(Date.now() / 1000) + 600,
|
||||
user: { id: 42, username: 'user', role: 1 },
|
||||
session: {
|
||||
sid: 'session',
|
||||
current: true,
|
||||
login_method: 'password',
|
||||
ip: '127.0.0.1',
|
||||
user_agent: 'test',
|
||||
created_at: 1,
|
||||
last_active_at: 1,
|
||||
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||
},
|
||||
}
|
||||
|
||||
function mockRefreshResponse(bundle: AuthBundle, onRequest?: () => void) {
|
||||
const requests = vi.fn()
|
||||
vi.stubGlobal(
|
||||
'XMLHttpRequest',
|
||||
class {
|
||||
status = 200
|
||||
statusText = 'OK'
|
||||
readyState = 4
|
||||
responseText = JSON.stringify({ success: true, data: bundle })
|
||||
onloadend: (() => void) | null = null
|
||||
url = ''
|
||||
open(_method: string, url: string) {
|
||||
this.url = url
|
||||
}
|
||||
setRequestHeader() {}
|
||||
getAllResponseHeaders() {
|
||||
return 'content-type: application/json'
|
||||
}
|
||||
abort() {}
|
||||
send() {
|
||||
requests(this.url)
|
||||
onRequest?.()
|
||||
queueMicrotask(() => this.onloadend?.())
|
||||
}
|
||||
}
|
||||
)
|
||||
return requests
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
clearPasswordEncryptionCache()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
api.defaults.adapter = originalAdapter
|
||||
useAuthStore.getState().auth.reset('idle')
|
||||
window.history.replaceState(null, '', originalLocation)
|
||||
})
|
||||
|
||||
it.each([true, false])(
|
||||
'encrypts short and 128-character Unicode passwords with Web Crypto enabled: %s',
|
||||
async (useWebCrypto) => {
|
||||
vi.stubGlobal(
|
||||
'crypto',
|
||||
useWebCrypto
|
||||
? webcrypto
|
||||
: { getRandomValues: webcrypto.getRandomValues.bind(webcrypto) }
|
||||
)
|
||||
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
})
|
||||
vi.spyOn(api, 'get').mockResolvedValue({
|
||||
data: {
|
||||
success: true,
|
||||
data: {
|
||||
kid: 'test-encryption-key',
|
||||
public_key: publicKey
|
||||
.export({ type: 'spki', format: 'pem' })
|
||||
.toString(),
|
||||
},
|
||||
},
|
||||
})
|
||||
for (const password of ['legacy-password', '🔒'.repeat(128)]) {
|
||||
const encrypted = await encryptPassword(password)
|
||||
const parts = encrypted.password_encrypted.split('.')
|
||||
let decrypted: Buffer
|
||||
if (parts[0] === 'v2') {
|
||||
const key = privateDecrypt(
|
||||
{
|
||||
key: privateKey,
|
||||
oaepHash: 'sha256',
|
||||
oaepLabel: Buffer.from('password-v2'),
|
||||
},
|
||||
Buffer.from(parts[1], 'base64')
|
||||
)
|
||||
const ciphertext = Buffer.from(parts[3], 'base64')
|
||||
const decipher = createDecipheriv(
|
||||
'aes-256-gcm',
|
||||
key,
|
||||
Buffer.from(parts[2], 'base64')
|
||||
)
|
||||
decipher.setAAD(Buffer.from('password-v2:test-encryption-key'))
|
||||
decipher.setAuthTag(ciphertext.subarray(-16))
|
||||
decrypted = Buffer.concat([
|
||||
decipher.update(ciphertext.subarray(0, -16)),
|
||||
decipher.final(),
|
||||
])
|
||||
} else {
|
||||
decrypted = privateDecrypt(
|
||||
{ key: privateKey, oaepHash: 'sha256' },
|
||||
Buffer.from(encrypted.password_encrypted, 'base64')
|
||||
)
|
||||
}
|
||||
expect(decrypted.toString('utf8')).toBe(password)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['proof', 'flow'] as const)(
|
||||
'never replays a %s request after a 401 response',
|
||||
async (kind) => {
|
||||
window.history.replaceState(null, '', '/sign-in')
|
||||
useAuthStore.getState().auth.setBundle(sessionBundle)
|
||||
const refresh = mockRefreshResponse(sessionBundle)
|
||||
const adapter = vi.fn(async (config: InternalAxiosRequestConfig) => {
|
||||
throw new AxiosError(
|
||||
'Unauthorized',
|
||||
'ERR_BAD_REQUEST',
|
||||
config,
|
||||
undefined,
|
||||
{
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
config,
|
||||
headers: {},
|
||||
data: { code: 'AUTH_SESSION_REVOKED' },
|
||||
}
|
||||
)
|
||||
})
|
||||
api.defaults.adapter = adapter
|
||||
await expect(
|
||||
api.post(
|
||||
'/protected',
|
||||
{},
|
||||
{
|
||||
skipErrorHandler: true,
|
||||
...(kind === 'proof'
|
||||
? { headers: { 'X-Security-Proof': 'one-use-proof' } }
|
||||
: { singleUseAuthorization: true }),
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('Unauthorized')
|
||||
expect(adapter).toHaveBeenCalledTimes(1)
|
||||
expect(refresh).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('refreshes an expiring login token before submitting a one-time proof', async () => {
|
||||
useAuthStore.getState().auth.setBundle({
|
||||
...sessionBundle,
|
||||
access_expires_at: Math.floor(Date.now() / 1000) + 10,
|
||||
})
|
||||
const order: string[] = []
|
||||
mockRefreshResponse({ ...sessionBundle, access_token: 'fresh-access' }, () =>
|
||||
order.push('refresh')
|
||||
)
|
||||
const adapter = vi.fn(async (config: InternalAxiosRequestConfig) => {
|
||||
order.push('action')
|
||||
return {
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
config,
|
||||
headers: {},
|
||||
data: { success: true },
|
||||
}
|
||||
})
|
||||
api.defaults.adapter = adapter
|
||||
await api.post(
|
||||
'/protected',
|
||||
{},
|
||||
{ headers: { 'X-Security-Proof': 'one-use-proof' } }
|
||||
)
|
||||
expect(order).toEqual(['refresh', 'action'])
|
||||
expect(adapter).toHaveBeenCalledTimes(1)
|
||||
expect(adapter.mock.calls[0]?.[0].headers.Authorization).toBe(
|
||||
'Bearer fresh-access'
|
||||
)
|
||||
})
|
||||
|
||||
it('binds a channel verification to the requested channel context', async () => {
|
||||
const proof = {
|
||||
proof_token: 'channel-proof',
|
||||
method: '2fa',
|
||||
scope: 'channel.key.read',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 60,
|
||||
}
|
||||
const post = vi.spyOn(api, 'post').mockResolvedValue({
|
||||
data: { success: true, data: proof },
|
||||
})
|
||||
await expect(
|
||||
verify(
|
||||
{ method: '2fa', code: '123456' },
|
||||
{ scope: 'channel.key.read', context: { channel_id: 123 } },
|
||||
false,
|
||||
new AbortController().signal
|
||||
)
|
||||
).resolves.toEqual(proof)
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
'/api/verify',
|
||||
{
|
||||
method: '2fa',
|
||||
code: '123456',
|
||||
scope: 'channel.key.read',
|
||||
context: { channel_id: 123 },
|
||||
},
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('passes the operation context to Passkey begin and completes with only its flow and assertion', async () => {
|
||||
vi.stubGlobal('navigator', {
|
||||
credentials: {
|
||||
get: vi.fn().mockResolvedValue({
|
||||
id: 'credential',
|
||||
rawId: new Uint8Array([1, 2, 3]).buffer,
|
||||
type: 'public-key',
|
||||
response: {
|
||||
clientDataJSON: new Uint8Array([1]).buffer,
|
||||
authenticatorData: new Uint8Array([2]).buffer,
|
||||
signature: new Uint8Array([3]).buffer,
|
||||
userHandle: null,
|
||||
},
|
||||
getClientExtensionResults: () => ({}),
|
||||
}),
|
||||
},
|
||||
})
|
||||
const proof = {
|
||||
proof_token: 'passkey-proof',
|
||||
scope: 'channel.key.read',
|
||||
method: 'passkey',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 60,
|
||||
}
|
||||
const post = vi
|
||||
.spyOn(api, 'post')
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
success: true,
|
||||
data: {
|
||||
flow_token: 'flow',
|
||||
options: { publicKey: { challenge: 'AQID', allowCredentials: [] } },
|
||||
},
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({ data: { success: true, data: proof } })
|
||||
await expect(
|
||||
verify(
|
||||
{ method: 'passkey' },
|
||||
{ scope: 'channel.key.read', context: { channel_id: 123 } },
|
||||
false,
|
||||
new AbortController().signal
|
||||
)
|
||||
).resolves.toEqual(proof)
|
||||
expect(post).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/user/passkey/verify/begin',
|
||||
{
|
||||
scope: 'channel.key.read',
|
||||
context: { channel_id: 123 },
|
||||
},
|
||||
expect.anything()
|
||||
)
|
||||
expect(post.mock.calls[1]?.[1]).toEqual({
|
||||
flow_token: 'flow',
|
||||
credential: expect.anything(),
|
||||
})
|
||||
})
|
||||
|
||||
it('passes an enrollment operation through OAuth state creation', async () => {
|
||||
const post = vi.spyOn(api, 'post').mockResolvedValue({
|
||||
data: { success: true, data: { flow_token: 'oauth-flow' } },
|
||||
})
|
||||
await expect(
|
||||
createOAuthFlow('github', 'verify', {
|
||||
scope: 'passkey.register',
|
||||
context: {},
|
||||
})
|
||||
).resolves.toBe('oauth-flow')
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
'/api/oauth/state',
|
||||
expect.objectContaining({
|
||||
provider: 'github',
|
||||
intent: 'verify',
|
||||
scope: 'passkey.register',
|
||||
context: {},
|
||||
}),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'SECURITY_PROOF_CONSUMED',
|
||||
'This verification has already been used. Please verify again.',
|
||||
],
|
||||
[
|
||||
'SECURITY_PROOF_CONTEXT_MISMATCH',
|
||||
"Verification does not match this action's details. Please verify again.",
|
||||
],
|
||||
])('provides a re-verification message for %s', async (code, message) => {
|
||||
await expect(
|
||||
authResult(
|
||||
Promise.resolve({
|
||||
data: { success: false, code, message: 'untranslated backend text' },
|
||||
})
|
||||
)
|
||||
).rejects.toMatchObject({ code, message })
|
||||
})
|
||||
|
||||
it('reports a failed method query instead of treating the account as unenrolled', async () => {
|
||||
vi.spyOn(api, 'get').mockRejectedValue(
|
||||
new Error('Unable to load verification methods')
|
||||
)
|
||||
await expect(checkVerificationMethods('passkey.register')).rejects.toThrow(
|
||||
'Unable to load verification methods'
|
||||
)
|
||||
})
|
||||
|
||||
it('displays a generic internal error even if the server includes database details', async () => {
|
||||
await expect(
|
||||
authResult(
|
||||
Promise.resolve({
|
||||
data: {
|
||||
success: false,
|
||||
code: 'AUTH_INTERNAL_ERROR',
|
||||
message: 'SELECT private_table at private-db-host',
|
||||
},
|
||||
})
|
||||
)
|
||||
).rejects.toThrow('Please try again later.')
|
||||
const failure = AuthOperationError.from({
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status: 500,
|
||||
data: { message: 'SELECT private_table at private-db-host' },
|
||||
},
|
||||
})
|
||||
expect(failure.message).toBe('Please try again later.')
|
||||
})
|
||||
@@ -0,0 +1,420 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
|
||||
import { api } from '@/lib/api'
|
||||
import type { AuthBundle } from '@/stores/auth-store'
|
||||
|
||||
import { SecureVerificationDialog } from '../components/secure-verification-dialog'
|
||||
import { useSecureVerification } from '../hooks/use-secure-verification'
|
||||
import type {
|
||||
RequestVerificationOptions,
|
||||
SecurityProof,
|
||||
VerificationRequirements,
|
||||
} from '../types'
|
||||
|
||||
const passwordRequirements: VerificationRequirements = {
|
||||
scope: 'passkey.register',
|
||||
methods: [{ method: 'password', available: true }],
|
||||
oauth_providers: [],
|
||||
password_encryption_enabled: false,
|
||||
}
|
||||
|
||||
function Harness(props: {
|
||||
onResult: (proof: SecurityProof | null) => void
|
||||
operation?: RequestVerificationOptions
|
||||
}) {
|
||||
const verification = useSecureVerification()
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type='button'
|
||||
onClick={async () =>
|
||||
props.onResult(
|
||||
await verification.requestVerification(
|
||||
props.operation ?? {
|
||||
scope: 'passkey.register',
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
>
|
||||
Protected action
|
||||
</button>
|
||||
<SecureVerificationDialog {...verification.dialogProps} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function pendingResponse<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((finish) => {
|
||||
resolve = finish
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function LoginHarness(props: {
|
||||
onResult: (bundle: AuthBundle | null) => void
|
||||
}) {
|
||||
const verification = useSecureVerification()
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type='button'
|
||||
onClick={async () =>
|
||||
props.onResult(
|
||||
await verification.requestLoginVerification({
|
||||
require_verification: true,
|
||||
flow_token: 'pending-login',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 300,
|
||||
methods: [
|
||||
{ method: '2fa', available: true },
|
||||
{ method: 'passkey', available: true },
|
||||
],
|
||||
})
|
||||
)
|
||||
}
|
||||
>
|
||||
Continue sign-in
|
||||
</button>
|
||||
<SecureVerificationDialog {...verification.dialogProps} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
it('lets a pending login switch from Passkey to 2FA without using authenticated verification endpoints', async () => {
|
||||
vi.stubGlobal('PublicKeyCredential', class {})
|
||||
const get = vi.spyOn(api, 'get')
|
||||
const bundle: AuthBundle = {
|
||||
access_token: 'verified-login',
|
||||
token_type: 'Bearer',
|
||||
access_expires_at: Math.floor(Date.now() / 1000) + 900,
|
||||
user: { id: 42, username: 'user', role: 1 },
|
||||
session: {
|
||||
sid: 'new-session',
|
||||
current: true,
|
||||
login_method: 'password',
|
||||
ip: '',
|
||||
user_agent: '',
|
||||
created_at: 1,
|
||||
last_active_at: 1,
|
||||
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||
},
|
||||
}
|
||||
const post = vi
|
||||
.spyOn(api, 'post')
|
||||
.mockResolvedValue({ data: { success: true, data: bundle } })
|
||||
const result = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<LoginHarness onResult={result} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Continue sign-in' }))
|
||||
expect(await screen.findByRole('tab', { name: 'Passkey' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
expect(result).not.toHaveBeenCalled()
|
||||
await user.click(screen.getByRole('tab', { name: 'Authenticator code' }))
|
||||
await user.type(
|
||||
screen.getByLabelText('Authenticator code or backup code'),
|
||||
'123456'
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Verify' }))
|
||||
await waitFor(() => expect(result).toHaveBeenCalledExactlyOnceWith(bundle))
|
||||
expect(post).toHaveBeenCalledExactlyOnceWith(
|
||||
'/api/user/login/verify',
|
||||
{ flow_token: 'pending-login', method: '2fa', code: '123456' },
|
||||
expect.objectContaining({
|
||||
skipAuthRefresh: true,
|
||||
signal: expect.any(AbortSignal),
|
||||
})
|
||||
)
|
||||
expect(get).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['success', 'cancel', 'retry'] as const)(
|
||||
'automatically obtains the first-enrollment session proof and handles %s',
|
||||
async (outcome) => {
|
||||
vi.spyOn(api, 'get').mockResolvedValue({
|
||||
data: {
|
||||
success: true,
|
||||
data: {
|
||||
...passwordRequirements,
|
||||
methods: [{ method: 'session', available: true }],
|
||||
},
|
||||
},
|
||||
})
|
||||
const proof: SecurityProof = {
|
||||
proof_token: 'session-proof',
|
||||
method: 'session',
|
||||
scope: 'passkey.register',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 300,
|
||||
}
|
||||
const reply = pendingResponse<{
|
||||
data: { success: boolean; data: SecurityProof }
|
||||
}>()
|
||||
const post = vi.spyOn(api, 'post').mockReturnValue(reply.promise)
|
||||
if (outcome === 'retry') {
|
||||
post.mockRejectedValueOnce(new Error('Session check failed'))
|
||||
}
|
||||
const result = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<Harness onResult={result} />)
|
||||
await user.click(screen.getByText('Protected action'))
|
||||
if (outcome === 'retry') {
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'Session check failed'
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
}
|
||||
await waitFor(() =>
|
||||
expect(post).toHaveBeenCalledTimes(outcome === 'retry' ? 2 : 1)
|
||||
)
|
||||
expect(post).toHaveBeenLastCalledWith(
|
||||
'/api/verify',
|
||||
{ method: 'session', scope: 'passkey.register' },
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Verify' })).toBeDisabled()
|
||||
if (outcome === 'cancel') {
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(post.mock.calls[0][2]?.signal?.aborted).toBe(true)
|
||||
}
|
||||
await act(async () => {
|
||||
reply.resolve({ data: { success: true, data: proof } })
|
||||
await reply.promise
|
||||
})
|
||||
await waitFor(() =>
|
||||
expect(result).toHaveBeenCalledExactlyOnceWith(
|
||||
outcome === 'cancel' ? null : proof
|
||||
)
|
||||
)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps the requested channel context fixed while verification is open', async () => {
|
||||
vi.spyOn(api, 'get').mockResolvedValue({
|
||||
data: {
|
||||
success: true,
|
||||
data: {
|
||||
scope: 'channel.key.read',
|
||||
methods: [{ method: '2fa', available: true }],
|
||||
oauth_providers: [],
|
||||
password_encryption_enabled: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
const post = vi.spyOn(api, 'post').mockResolvedValue({
|
||||
data: {
|
||||
success: true,
|
||||
data: {
|
||||
proof_token: 'channel-proof',
|
||||
scope: 'channel.key.read',
|
||||
method: '2fa',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 60,
|
||||
},
|
||||
},
|
||||
})
|
||||
const operation: RequestVerificationOptions = {
|
||||
scope: 'channel.key.read',
|
||||
context: { channel_id: 123 },
|
||||
}
|
||||
const user = userEvent.setup()
|
||||
const result = vi.fn()
|
||||
render(<Harness operation={operation} onResult={result} />)
|
||||
await user.click(screen.getByText('Protected action'))
|
||||
await user.type(
|
||||
await screen.findByLabelText('Authenticator code or backup code'),
|
||||
'123456'
|
||||
)
|
||||
operation.context.channel_id = 456
|
||||
await user.click(screen.getByRole('button', { name: 'Verify' }))
|
||||
await waitFor(() => expect(result).toHaveBeenCalledTimes(1))
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
'/api/verify',
|
||||
{
|
||||
scope: 'channel.key.read',
|
||||
context: { channel_id: 123 },
|
||||
method: '2fa',
|
||||
code: '123456',
|
||||
},
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('shows query errors and reloads requirements only when the user retries', async () => {
|
||||
const requests = vi
|
||||
.spyOn(api, 'get')
|
||||
.mockRejectedValueOnce(new Error('Unable to load methods'))
|
||||
.mockResolvedValue({ data: { success: true, data: passwordRequirements } })
|
||||
const result = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<Harness onResult={result} />)
|
||||
await user.click(screen.getByText('Protected action'))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'Unable to load methods'
|
||||
)
|
||||
expect(
|
||||
screen.queryByText('No verification method is available for this action.')
|
||||
).not.toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(
|
||||
await screen.findByLabelText('Password', { selector: 'input' })
|
||||
).toBeVisible()
|
||||
expect(requests).toHaveBeenCalledTimes(2)
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
await waitFor(() => expect(result).toHaveBeenCalledWith(null))
|
||||
})
|
||||
|
||||
it('returns a proof only after successful verification and clears a rejected password', async () => {
|
||||
vi.spyOn(api, 'get').mockResolvedValue({
|
||||
data: { success: true, data: passwordRequirements },
|
||||
})
|
||||
const proof: SecurityProof = {
|
||||
proof_token: 'proof',
|
||||
method: 'password',
|
||||
scope: 'passkey.register',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 300,
|
||||
}
|
||||
const posts = vi
|
||||
.spyOn(api, 'post')
|
||||
.mockResolvedValueOnce({
|
||||
data: { success: false, message: 'Wrong password' },
|
||||
})
|
||||
.mockResolvedValueOnce({ data: { success: true, data: proof } })
|
||||
const result = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<Harness onResult={result} />)
|
||||
await user.click(screen.getByText('Protected action'))
|
||||
await user.type(
|
||||
await screen.findByLabelText('Password', { selector: 'input' }),
|
||||
'incorrect'
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Verify' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Wrong password')
|
||||
expect(screen.getByLabelText('Password', { selector: 'input' })).toHaveValue(
|
||||
''
|
||||
)
|
||||
expect(result).not.toHaveBeenCalled()
|
||||
await user.type(
|
||||
screen.getByLabelText('Password', { selector: 'input' }),
|
||||
'correct'
|
||||
)
|
||||
await user.keyboard('{Enter}')
|
||||
await waitFor(() => expect(result).toHaveBeenCalledWith(proof))
|
||||
expect(posts).toHaveBeenLastCalledWith(
|
||||
'/api/verify',
|
||||
{ method: 'password', scope: 'passkey.register', password: 'correct' },
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) })
|
||||
)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('discards a late proof after cancellation and prevents a duplicate submission', async () => {
|
||||
vi.spyOn(api, 'get').mockResolvedValue({
|
||||
data: { success: true, data: passwordRequirements },
|
||||
})
|
||||
const reply = pendingResponse<{
|
||||
data: { success: boolean; data: SecurityProof }
|
||||
}>()
|
||||
const posts = vi.spyOn(api, 'post').mockReturnValue(reply.promise)
|
||||
const result = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<Harness onResult={result} />)
|
||||
await user.click(screen.getByText('Protected action'))
|
||||
await user.type(
|
||||
await screen.findByLabelText('Password', { selector: 'input' }),
|
||||
'password'
|
||||
)
|
||||
const submit = screen.getByRole('button', { name: 'Verify' })
|
||||
fireEvent.click(submit)
|
||||
fireEvent.click(submit)
|
||||
await waitFor(() => expect(posts).toHaveBeenCalledTimes(1))
|
||||
expect(submit).toBeDisabled()
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
await waitFor(() => expect(result).toHaveBeenCalledExactlyOnceWith(null))
|
||||
await act(async () => {
|
||||
reply.resolve({
|
||||
data: {
|
||||
success: true,
|
||||
data: {
|
||||
proof_token: 'late-proof',
|
||||
method: 'password',
|
||||
scope: 'passkey.register',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 300,
|
||||
},
|
||||
},
|
||||
})
|
||||
await reply.promise
|
||||
})
|
||||
expect(result).toHaveBeenCalledTimes(1)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not reopen after a cancelled method query finishes', async () => {
|
||||
const reply = pendingResponse<{
|
||||
data: { success: boolean; data: VerificationRequirements }
|
||||
}>()
|
||||
vi.spyOn(api, 'get').mockReturnValue(reply.promise)
|
||||
const result = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<Harness onResult={result} />)
|
||||
await user.click(screen.getByText('Protected action'))
|
||||
expect(screen.getByRole('status')).toHaveTextContent(
|
||||
'Loading verification methods...'
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
await act(async () => {
|
||||
reply.resolve({ data: { success: true, data: passwordRequirements } })
|
||||
await reply.promise
|
||||
})
|
||||
expect(result).toHaveBeenCalledExactlyOnceWith(null)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps an enrolled Passkey unavailable when this browser cannot use it', async () => {
|
||||
vi.stubGlobal('PublicKeyCredential', undefined)
|
||||
vi.spyOn(api, 'get').mockResolvedValue({
|
||||
data: {
|
||||
success: true,
|
||||
data: {
|
||||
...passwordRequirements,
|
||||
methods: [{ method: 'passkey', available: true }],
|
||||
},
|
||||
},
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
render(<Harness onResult={vi.fn()} />)
|
||||
await user.click(screen.getByText('Protected action'))
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'This device does not support Passkey verification.'
|
||||
)
|
||||
).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: 'Verify' })).toBeDisabled()
|
||||
expect(screen.queryByLabelText('Password')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -0,0 +1,368 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { api, isAuthBundle } from '@/lib/api'
|
||||
import { buildOAuthAuthorizationUrl } from '@/lib/oauth'
|
||||
import {
|
||||
buildAssertionResult,
|
||||
isPasskeySupported,
|
||||
prepareCredentialRequestOptions,
|
||||
} from '@/lib/passkey'
|
||||
import {
|
||||
AuthOperationError,
|
||||
authRequestOptions,
|
||||
authResult,
|
||||
} from '@/lib/secure-verification'
|
||||
import type { AuthBundle } from '@/stores/auth-store'
|
||||
|
||||
import { createOAuthAuthorization } from '../api'
|
||||
import { openOAuthPopup } from '../lib/oauth-popup'
|
||||
import { encryptPassword } from '../lib/password-encryption'
|
||||
import {
|
||||
beginPasskeyVerification,
|
||||
finishPasskeyVerification,
|
||||
} from '../passkey/api'
|
||||
import type { SystemStatus } from '../types'
|
||||
import type {
|
||||
SecurityProof,
|
||||
LoginChallenge,
|
||||
SecurityProofScope,
|
||||
VerificationInput,
|
||||
VerificationOperation,
|
||||
VerificationRequirements,
|
||||
} from './types'
|
||||
|
||||
export async function checkVerificationMethods(
|
||||
scope: SecurityProofScope,
|
||||
signal?: AbortSignal
|
||||
): Promise<VerificationRequirements> {
|
||||
const [requirements, passkeySupported] = await Promise.all([
|
||||
authResult<VerificationRequirements>(
|
||||
api.get('/api/verify/methods', {
|
||||
...authRequestOptions,
|
||||
params: { scope },
|
||||
signal,
|
||||
disableDuplicate: true,
|
||||
})
|
||||
),
|
||||
isPasskeySupported(),
|
||||
])
|
||||
return withDeviceAvailability(requirements, passkeySupported)
|
||||
}
|
||||
|
||||
function withDeviceAvailability(
|
||||
requirements: VerificationRequirements,
|
||||
passkeySupported: boolean
|
||||
): VerificationRequirements {
|
||||
return {
|
||||
...requirements,
|
||||
methods: requirements.methods.map((option) => {
|
||||
if (
|
||||
option.method === 'passkey' &&
|
||||
option.available &&
|
||||
!passkeySupported
|
||||
) {
|
||||
return {
|
||||
...option,
|
||||
available: false,
|
||||
reason: 'This device does not support Passkey verification.',
|
||||
}
|
||||
}
|
||||
return option
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export function isLoginChallenge(value: unknown): value is LoginChallenge {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const challenge = value as Partial<LoginChallenge>
|
||||
return (
|
||||
challenge.require_verification === true &&
|
||||
typeof challenge.flow_token === 'string' &&
|
||||
challenge.flow_token.length > 0 &&
|
||||
typeof challenge.expires_at === 'number' &&
|
||||
Number.isFinite(challenge.expires_at) &&
|
||||
Array.isArray(challenge.methods) &&
|
||||
challenge.methods.length > 0 &&
|
||||
challenge.methods.every(
|
||||
(option) =>
|
||||
option &&
|
||||
(option.method === '2fa' || option.method === 'passkey') &&
|
||||
typeof option.available === 'boolean'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export async function getLoginVerificationRequirements(
|
||||
challenge: LoginChallenge,
|
||||
signal: AbortSignal
|
||||
): Promise<VerificationRequirements> {
|
||||
if (challenge.expires_at * 1000 <= Date.now()) {
|
||||
throw new AuthOperationError(
|
||||
'Login flow expired. Please sign in again.',
|
||||
'AUTH_FLOW_INVALID'
|
||||
)
|
||||
}
|
||||
const supported = await isPasskeySupported()
|
||||
signal.throwIfAborted()
|
||||
return withDeviceAvailability(
|
||||
{
|
||||
scope: 'auth.login',
|
||||
methods: challenge.methods,
|
||||
oauth_providers: [],
|
||||
password_encryption_enabled: false,
|
||||
},
|
||||
supported
|
||||
)
|
||||
}
|
||||
|
||||
export async function verifyLogin(
|
||||
input: VerificationInput,
|
||||
challenge: LoginChallenge,
|
||||
signal: AbortSignal
|
||||
): Promise<AuthBundle> {
|
||||
if (challenge.expires_at * 1000 <= Date.now()) {
|
||||
throw new AuthOperationError(
|
||||
'Login flow expired. Please sign in again.',
|
||||
'AUTH_FLOW_INVALID'
|
||||
)
|
||||
}
|
||||
const options = { ...authRequestOptions, skipAuthRefresh: true, signal }
|
||||
let result: unknown
|
||||
if (input.method === '2fa') {
|
||||
result = await authResult(
|
||||
api.post(
|
||||
'/api/user/login/verify',
|
||||
{
|
||||
flow_token: challenge.flow_token,
|
||||
method: '2fa',
|
||||
code: input.code.trim(),
|
||||
},
|
||||
options
|
||||
)
|
||||
)
|
||||
} else if (input.method === 'passkey') {
|
||||
const begin = await authResult<{ flow_token: string; options: unknown }>(
|
||||
api.post(
|
||||
'/api/user/login/passkey/begin',
|
||||
{ flow_token: challenge.flow_token },
|
||||
options
|
||||
)
|
||||
)
|
||||
if (!begin.flow_token) {
|
||||
throw new AuthOperationError('Verification flow expired')
|
||||
}
|
||||
const assertion = await requestPasskeyAssertion(begin.options, signal)
|
||||
result = await authResult(
|
||||
api.post(
|
||||
'/api/user/login/passkey/finish',
|
||||
{
|
||||
flow_token: challenge.flow_token,
|
||||
passkey_flow_token: begin.flow_token,
|
||||
credential: assertion,
|
||||
},
|
||||
options
|
||||
)
|
||||
)
|
||||
} else {
|
||||
throw new AuthOperationError(
|
||||
'This verification method is not allowed for this action.'
|
||||
)
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
if (!isAuthBundle(result)) throw new AuthOperationError('Login failed')
|
||||
return result
|
||||
}
|
||||
|
||||
export async function verify(
|
||||
input: VerificationInput,
|
||||
operation: VerificationOperation,
|
||||
passwordEncryptionEnabled: boolean,
|
||||
signal: AbortSignal
|
||||
): Promise<SecurityProof> {
|
||||
try {
|
||||
const operationFields = {
|
||||
scope: operation.scope,
|
||||
...(operation.context ? { context: operation.context } : {}),
|
||||
}
|
||||
let proof: SecurityProof
|
||||
switch (input.method) {
|
||||
case 'session':
|
||||
proof = await authResult<SecurityProof>(
|
||||
api.post(
|
||||
'/api/verify',
|
||||
{ method: 'session', ...operationFields },
|
||||
{
|
||||
...authRequestOptions,
|
||||
signal,
|
||||
}
|
||||
)
|
||||
)
|
||||
break
|
||||
case '2fa':
|
||||
proof = await authResult<SecurityProof>(
|
||||
api.post(
|
||||
'/api/verify',
|
||||
{
|
||||
method: input.method,
|
||||
...operationFields,
|
||||
code: input.code.trim(),
|
||||
},
|
||||
{ ...authRequestOptions, signal }
|
||||
)
|
||||
)
|
||||
break
|
||||
case 'password': {
|
||||
const passwordFields = passwordEncryptionEnabled
|
||||
? await encryptPassword(input.password)
|
||||
: { password: input.password }
|
||||
signal.throwIfAborted()
|
||||
proof = await authResult<SecurityProof>(
|
||||
api.post(
|
||||
'/api/verify',
|
||||
{ method: input.method, ...operationFields, ...passwordFields },
|
||||
{ ...authRequestOptions, signal }
|
||||
)
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'passkey':
|
||||
proof = await verifyPasskey(operation, signal)
|
||||
break
|
||||
case 'oauth':
|
||||
proof = await verifyOAuth(input.provider, operation, signal)
|
||||
break
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
if (
|
||||
!proof.proof_token ||
|
||||
proof.scope !== operation.scope ||
|
||||
proof.method !== input.method ||
|
||||
proof.expires_at * 1000 <= Date.now()
|
||||
) {
|
||||
throw new AuthOperationError('Verification proof was not returned')
|
||||
}
|
||||
return proof
|
||||
} catch (error) {
|
||||
throw AuthOperationError.from(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyPasskey(
|
||||
operation: VerificationOperation,
|
||||
signal: AbortSignal
|
||||
): Promise<SecurityProof> {
|
||||
const begin = await beginPasskeyVerification(operation, signal)
|
||||
if (!begin.flow_token) {
|
||||
throw new AuthOperationError('Verification flow expired')
|
||||
}
|
||||
const assertion = await requestPasskeyAssertion(
|
||||
begin.options ?? begin,
|
||||
signal
|
||||
)
|
||||
return finishPasskeyVerification(begin.flow_token, assertion, signal)
|
||||
}
|
||||
|
||||
async function requestPasskeyAssertion(
|
||||
options: unknown,
|
||||
signal: AbortSignal
|
||||
): Promise<Record<string, unknown>> {
|
||||
const publicKey = prepareCredentialRequestOptions(options)
|
||||
let credential: PublicKeyCredential | null
|
||||
try {
|
||||
credential = (await navigator.credentials.get({
|
||||
publicKey,
|
||||
signal,
|
||||
})) as PublicKeyCredential | null
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'NotAllowedError') {
|
||||
throw new AuthOperationError(
|
||||
'Passkey verification was cancelled or timed out'
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
if (!credential) {
|
||||
throw new AuthOperationError(
|
||||
'Passkey verification was cancelled',
|
||||
'AUTH_CANCELLED'
|
||||
)
|
||||
}
|
||||
const assertion = buildAssertionResult(credential)
|
||||
if (!assertion) {
|
||||
throw new AuthOperationError('Unable to build Passkey assertion')
|
||||
}
|
||||
return assertion
|
||||
}
|
||||
|
||||
async function verifyOAuth(
|
||||
provider: string,
|
||||
operation: VerificationOperation,
|
||||
signal: AbortSignal
|
||||
): Promise<SecurityProof> {
|
||||
const exchange = await openOAuthPopup({
|
||||
provider,
|
||||
intent: 'verify',
|
||||
signal,
|
||||
prepare: async (popupSignal) => {
|
||||
const [authorization, status] = await Promise.all([
|
||||
createOAuthAuthorization(provider, 'verify', operation, popupSignal),
|
||||
authResult<SystemStatus>(
|
||||
api.get('/api/status', {
|
||||
...authRequestOptions,
|
||||
signal: popupSignal,
|
||||
disableDuplicate: true,
|
||||
})
|
||||
),
|
||||
])
|
||||
return {
|
||||
state: authorization.state,
|
||||
url:
|
||||
authorization.authorizationUrl ??
|
||||
buildOAuthAuthorizationUrl(provider, authorization.state, status),
|
||||
}
|
||||
},
|
||||
})
|
||||
try {
|
||||
const callback = exchange.callback
|
||||
const proof = await authResult<SecurityProof>(
|
||||
api.get(`/api/oauth/${provider}`, {
|
||||
...authRequestOptions,
|
||||
singleUseAuthorization: true,
|
||||
disableDuplicate: true,
|
||||
signal: exchange.signal,
|
||||
params: {
|
||||
state: callback.state,
|
||||
code: callback.code,
|
||||
error: callback.error,
|
||||
error_description: callback.errorDescription,
|
||||
},
|
||||
})
|
||||
)
|
||||
exchange.signal.throwIfAborted()
|
||||
exchange.finish({ success: true })
|
||||
return proof
|
||||
} catch (error) {
|
||||
const failure = AuthOperationError.from(
|
||||
exchange.signal.aborted ? exchange.signal.reason : error
|
||||
)
|
||||
exchange.finish({ success: false, message: failure.message })
|
||||
throw failure
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { KeyRound, Loader2, ShieldCheck } from 'lucide-react'
|
||||
import { useId } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
|
||||
import type {
|
||||
SecureVerificationState,
|
||||
VerificationInput,
|
||||
VerificationMethod,
|
||||
} from '../types'
|
||||
|
||||
interface SecureVerificationDialogProps {
|
||||
state: SecureVerificationState
|
||||
onVerify: () => void | Promise<void>
|
||||
onCancel: () => void
|
||||
onRetry: () => void
|
||||
onInputChange: (input: VerificationInput) => void
|
||||
}
|
||||
|
||||
const methodLabels: Record<VerificationMethod, string> = {
|
||||
'2fa': 'Authenticator code',
|
||||
passkey: 'Passkey',
|
||||
password: 'Password',
|
||||
oauth: 'Linked account',
|
||||
session: 'Login session',
|
||||
}
|
||||
|
||||
export function SecureVerificationDialog(props: SecureVerificationDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const inputId = useId()
|
||||
const state = props.state
|
||||
if (state.phase === 'idle') return null
|
||||
const ready =
|
||||
state.phase === 'ready' || state.phase === 'verifying' ? state : null
|
||||
const input = ready?.input
|
||||
const verifying = state.phase === 'verifying'
|
||||
const login = state.request.scope === 'auth.login'
|
||||
const acceptsBackupCode =
|
||||
state.request.scope !== '2fa.backup_codes.regenerate'
|
||||
let canVerify = state.phase === 'ready' && Boolean(input)
|
||||
if (input?.method === 'password') {
|
||||
canVerify = canVerify && input.password.length > 0
|
||||
}
|
||||
if (input?.method === '2fa') {
|
||||
canVerify = canVerify && input.code.trim().length >= 6
|
||||
}
|
||||
if (input?.method === 'oauth') {
|
||||
canVerify = canVerify && Boolean(input.provider)
|
||||
}
|
||||
const error = 'error' in state ? state.error : undefined
|
||||
const formId = `${inputId}-form`
|
||||
|
||||
const selectMethod = (method: string) => {
|
||||
switch (method) {
|
||||
case '2fa':
|
||||
props.onInputChange({ method, code: '' })
|
||||
break
|
||||
case 'password':
|
||||
props.onInputChange({ method, password: '' })
|
||||
break
|
||||
case 'passkey':
|
||||
props.onInputChange({ method })
|
||||
break
|
||||
case 'oauth':
|
||||
props.onInputChange({
|
||||
method,
|
||||
provider: ready?.requirements.oauth_providers[0]?.slug ?? '',
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) props.onCancel()
|
||||
}}
|
||||
title={
|
||||
<>
|
||||
<ShieldCheck className='size-5' />
|
||||
{state.request.title ??
|
||||
(login ? t('Complete sign-in') : t('Security verification'))}
|
||||
</>
|
||||
}
|
||||
description={
|
||||
state.request.description ??
|
||||
(login
|
||||
? t('Verify your identity to finish signing in.')
|
||||
: t('Confirm your identity before accessing this sensitive action.'))
|
||||
}
|
||||
contentClassName='sm:max-w-md'
|
||||
contentHeight='auto'
|
||||
titleClassName='flex items-center gap-2'
|
||||
footer={
|
||||
<>
|
||||
<Button type='button' variant='outline' onClick={props.onCancel}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
{state.phase === 'error' ? (
|
||||
<Button type='button' onClick={props.onRetry}>
|
||||
{t('Retry')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type='submit' form={formId} disabled={!canVerify}>
|
||||
{verifying && <Loader2 className='size-4 animate-spin' />}
|
||||
{t('Verify')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{state.phase === 'loading' && (
|
||||
<div role='status' className='flex items-center gap-2 py-4'>
|
||||
<Loader2 className='size-4 animate-spin' />
|
||||
{t('Loading verification methods...')}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p role='alert' className='text-destructive text-sm'>
|
||||
{t(error)}
|
||||
</p>
|
||||
)}
|
||||
{ready && (
|
||||
<form
|
||||
id={formId}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (canVerify) void props.onVerify()
|
||||
}}
|
||||
className='space-y-4 py-2'
|
||||
>
|
||||
{!input ? (
|
||||
<div className='space-y-2 text-sm'>
|
||||
<p>{t('No verification method is available for this action.')}</p>
|
||||
{ready.requirements.methods.map(
|
||||
(option) =>
|
||||
option.reason && (
|
||||
<p className='text-muted-foreground' key={option.method}>
|
||||
{t(option.reason)}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Tabs value={input.method} onValueChange={selectMethod}>
|
||||
<TabsList>
|
||||
{ready.requirements.methods.map((option) => (
|
||||
<TabsTrigger
|
||||
key={option.method}
|
||||
value={option.method}
|
||||
disabled={!option.available || verifying}
|
||||
>
|
||||
{t(methodLabels[option.method])}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<TabsContent value='password' className='space-y-2'>
|
||||
<Label htmlFor={inputId}>{t('Password')}</Label>
|
||||
<Input
|
||||
id={inputId}
|
||||
type='password'
|
||||
autoComplete='current-password'
|
||||
autoFocus
|
||||
disabled={verifying}
|
||||
value={input.method === 'password' ? input.password : ''}
|
||||
onChange={(event) =>
|
||||
props.onInputChange({
|
||||
method: 'password',
|
||||
password: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value='2fa' className='space-y-2'>
|
||||
<Label htmlFor={inputId}>
|
||||
{acceptsBackupCode
|
||||
? t('Authenticator code or backup code')
|
||||
: t('Authenticator code')}
|
||||
</Label>
|
||||
<Input
|
||||
id={inputId}
|
||||
autoComplete='one-time-code'
|
||||
maxLength={acceptsBackupCode ? 9 : 6}
|
||||
autoFocus
|
||||
disabled={verifying}
|
||||
value={input.method === '2fa' ? input.code : ''}
|
||||
onChange={(event) =>
|
||||
props.onInputChange({
|
||||
method: '2fa',
|
||||
code: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{acceptsBackupCode
|
||||
? t(
|
||||
'Enter the 6-digit authenticator code or an unused backup code.'
|
||||
)
|
||||
: t('Enter the 6-digit authenticator code.')}
|
||||
</p>
|
||||
</TabsContent>
|
||||
<TabsContent value='passkey'>
|
||||
<p className='text-muted-foreground flex items-center gap-2 text-sm'>
|
||||
<KeyRound className='size-5' />
|
||||
{t(
|
||||
'We will prompt your device to confirm using biometrics or your hardware key.'
|
||||
)}
|
||||
</p>
|
||||
</TabsContent>
|
||||
<TabsContent value='oauth' className='space-y-2'>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t(
|
||||
'Continue with an account already linked to your profile.'
|
||||
)}
|
||||
</p>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{ready.requirements.oauth_providers.map((provider) => (
|
||||
<Button
|
||||
key={provider.slug}
|
||||
type='button'
|
||||
variant={
|
||||
input.method === 'oauth' &&
|
||||
input.provider === provider.slug
|
||||
? 'default'
|
||||
: 'outline'
|
||||
}
|
||||
aria-pressed={
|
||||
input.method === 'oauth' &&
|
||||
input.provider === provider.slug
|
||||
}
|
||||
disabled={verifying}
|
||||
onClick={() =>
|
||||
props.onInputChange({
|
||||
method: 'oauth',
|
||||
provider: provider.slug,
|
||||
})
|
||||
}
|
||||
>
|
||||
{provider.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useCallback, useEffect, useReducer, useRef } from 'react'
|
||||
|
||||
import { AuthOperationError } from '@/lib/secure-verification'
|
||||
import type { AuthBundle } from '@/stores/auth-store'
|
||||
|
||||
import {
|
||||
checkVerificationMethods,
|
||||
getLoginVerificationRequirements,
|
||||
verify,
|
||||
verifyLogin,
|
||||
} from '../api'
|
||||
import type {
|
||||
RequestVerificationOptions,
|
||||
RequestLoginVerificationOptions,
|
||||
VerificationRequest,
|
||||
LoginChallenge,
|
||||
SecureVerificationState,
|
||||
SecurityProof,
|
||||
VerificationInput,
|
||||
VerificationRequirements,
|
||||
} from '../types'
|
||||
|
||||
type VerificationAction =
|
||||
| { type: 'reset' }
|
||||
| { type: 'loading'; request: VerificationRequest }
|
||||
| { type: 'loaded'; requirements: VerificationRequirements }
|
||||
| { type: 'input'; input: VerificationInput }
|
||||
| { type: 'submit' }
|
||||
| { type: 'error'; error: string }
|
||||
|
||||
function verificationReducer(
|
||||
state: SecureVerificationState,
|
||||
action: VerificationAction
|
||||
): SecureVerificationState {
|
||||
switch (action.type) {
|
||||
case 'reset':
|
||||
return { phase: 'idle' }
|
||||
case 'loading':
|
||||
return { phase: 'loading', request: action.request }
|
||||
case 'loaded': {
|
||||
if (state.phase !== 'loading') return state
|
||||
const methods = action.requirements.methods.filter(
|
||||
(option) => option.available
|
||||
)
|
||||
const preferred =
|
||||
methods.find((option) => option.method === 'passkey') ?? methods[0]
|
||||
let input: VerificationInput | null = null
|
||||
switch (preferred?.method) {
|
||||
case '2fa':
|
||||
input = { method: '2fa', code: '' }
|
||||
break
|
||||
case 'password':
|
||||
input = { method: 'password', password: '' }
|
||||
break
|
||||
case 'passkey':
|
||||
input = { method: 'passkey' }
|
||||
break
|
||||
case 'oauth':
|
||||
input = {
|
||||
method: 'oauth',
|
||||
provider: action.requirements.oauth_providers[0]?.slug ?? '',
|
||||
}
|
||||
break
|
||||
}
|
||||
return {
|
||||
phase: 'ready',
|
||||
request: state.request,
|
||||
requirements: action.requirements,
|
||||
input,
|
||||
}
|
||||
}
|
||||
case 'input':
|
||||
if (
|
||||
state.phase !== 'ready' ||
|
||||
!state.requirements.methods.some(
|
||||
(option) => option.method === action.input.method && option.available
|
||||
)
|
||||
) {
|
||||
return state
|
||||
}
|
||||
return { ...state, input: action.input, error: undefined }
|
||||
case 'submit': {
|
||||
if (state.phase !== 'ready') return state
|
||||
let input = state.input
|
||||
if (input?.method === 'password') {
|
||||
input = { method: 'password', password: '' }
|
||||
}
|
||||
if (input?.method === '2fa') input = { method: '2fa', code: '' }
|
||||
return { ...state, phase: 'verifying', input, error: undefined }
|
||||
}
|
||||
case 'error':
|
||||
if (state.phase === 'idle') return state
|
||||
if (state.phase === 'loading' || state.phase === 'error') {
|
||||
return { phase: 'error', request: state.request, error: action.error }
|
||||
}
|
||||
return { ...state, phase: 'ready', error: action.error }
|
||||
}
|
||||
}
|
||||
|
||||
interface PendingVerificationBase {
|
||||
controller: AbortController
|
||||
reject: (error: unknown) => void
|
||||
submitting: boolean
|
||||
}
|
||||
|
||||
type PendingVerification = PendingVerificationBase &
|
||||
(
|
||||
| {
|
||||
kind: 'operation'
|
||||
request: RequestVerificationOptions
|
||||
resolve: (proof: SecurityProof | null) => void
|
||||
initialPassword?: string
|
||||
}
|
||||
| {
|
||||
kind: 'login'
|
||||
request: RequestLoginVerificationOptions
|
||||
resolve: (bundle: AuthBundle | null) => void
|
||||
initialPassword?: never
|
||||
}
|
||||
)
|
||||
|
||||
export function useSecureVerification() {
|
||||
const [state, dispatch] = useReducer(verificationReducer, { phase: 'idle' })
|
||||
const pending = useRef<PendingVerification | null>(null)
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
const current = pending.current
|
||||
pending.current = null
|
||||
current?.controller.abort()
|
||||
if (current) current.initialPassword = undefined
|
||||
current?.resolve(null)
|
||||
dispatch({ type: 'reset' })
|
||||
}, [])
|
||||
|
||||
useEffect(() => cancel, [cancel])
|
||||
|
||||
const loadRequirements = useCallback(async (current: PendingVerification) => {
|
||||
dispatch({ type: 'loading', request: current.request })
|
||||
try {
|
||||
const requirements =
|
||||
current.kind === 'login'
|
||||
? await getLoginVerificationRequirements(
|
||||
current.request.challenge,
|
||||
current.controller.signal
|
||||
)
|
||||
: await checkVerificationMethods(
|
||||
current.request.scope,
|
||||
current.controller.signal
|
||||
)
|
||||
if (pending.current !== current) return
|
||||
const initialPassword = current.initialPassword
|
||||
current.initialPassword = undefined
|
||||
if (
|
||||
current.kind === 'operation' &&
|
||||
initialPassword !== undefined &&
|
||||
requirements.methods.length === 1 &&
|
||||
requirements.methods[0].method === 'password' &&
|
||||
requirements.methods[0].available
|
||||
) {
|
||||
try {
|
||||
const proof = await verify(
|
||||
{ method: 'password', password: initialPassword },
|
||||
current.request,
|
||||
requirements.password_encryption_enabled,
|
||||
current.controller.signal
|
||||
)
|
||||
if (pending.current !== current) return
|
||||
pending.current = null
|
||||
dispatch({ type: 'reset' })
|
||||
current.resolve(proof)
|
||||
} catch (error) {
|
||||
if (pending.current !== current) return
|
||||
pending.current = null
|
||||
dispatch({ type: 'reset' })
|
||||
current.reject(error)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (
|
||||
current.kind === 'operation' &&
|
||||
requirements.methods.length === 1 &&
|
||||
requirements.methods[0].method === 'session' &&
|
||||
requirements.methods[0].available
|
||||
) {
|
||||
const proof = await verify(
|
||||
{ method: 'session' },
|
||||
current.request,
|
||||
requirements.password_encryption_enabled,
|
||||
current.controller.signal
|
||||
)
|
||||
if (pending.current !== current) return
|
||||
pending.current = null
|
||||
dispatch({ type: 'reset' })
|
||||
current.resolve(proof)
|
||||
return
|
||||
}
|
||||
if (pending.current === current) {
|
||||
dispatch({ type: 'loaded', requirements })
|
||||
}
|
||||
} catch (error) {
|
||||
if (pending.current === current) {
|
||||
dispatch({
|
||||
type: 'error',
|
||||
error: AuthOperationError.from(error).message,
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const requestVerification = useCallback(
|
||||
(
|
||||
request: RequestVerificationOptions,
|
||||
initialPassword?: string
|
||||
): Promise<SecurityProof | null> => {
|
||||
if (pending.current) return Promise.resolve(null)
|
||||
return new Promise((resolve, reject) => {
|
||||
const current: PendingVerification = {
|
||||
kind: 'operation',
|
||||
request: structuredClone(request),
|
||||
resolve,
|
||||
reject,
|
||||
initialPassword,
|
||||
controller: new AbortController(),
|
||||
submitting: false,
|
||||
}
|
||||
pending.current = current
|
||||
void loadRequirements(current)
|
||||
})
|
||||
},
|
||||
[loadRequirements]
|
||||
)
|
||||
|
||||
const requestLoginVerification = useCallback(
|
||||
(challenge: LoginChallenge): Promise<AuthBundle | null> => {
|
||||
if (pending.current) return Promise.resolve(null)
|
||||
return new Promise((resolve, reject) => {
|
||||
const current: PendingVerification = {
|
||||
kind: 'login',
|
||||
request: {
|
||||
scope: 'auth.login',
|
||||
challenge: structuredClone(challenge),
|
||||
},
|
||||
resolve,
|
||||
reject,
|
||||
controller: new AbortController(),
|
||||
submitting: false,
|
||||
}
|
||||
pending.current = current
|
||||
void loadRequirements(current)
|
||||
})
|
||||
},
|
||||
[loadRequirements]
|
||||
)
|
||||
|
||||
const executeVerification = useCallback(async () => {
|
||||
const current = pending.current
|
||||
if (
|
||||
!current ||
|
||||
current.submitting ||
|
||||
state.phase !== 'ready' ||
|
||||
!state.input
|
||||
) {
|
||||
return
|
||||
}
|
||||
current.submitting = true
|
||||
const input = state.input
|
||||
dispatch({ type: 'submit' })
|
||||
try {
|
||||
if (current.kind === 'login') {
|
||||
const bundle = await verifyLogin(
|
||||
input,
|
||||
current.request.challenge,
|
||||
current.controller.signal
|
||||
)
|
||||
if (pending.current !== current) return
|
||||
current.resolve(bundle)
|
||||
} else {
|
||||
const proof = await verify(
|
||||
input,
|
||||
current.request,
|
||||
state.requirements.password_encryption_enabled,
|
||||
current.controller.signal
|
||||
)
|
||||
if (pending.current !== current) return
|
||||
current.resolve(proof)
|
||||
}
|
||||
pending.current = null
|
||||
dispatch({ type: 'reset' })
|
||||
} catch (error) {
|
||||
if (pending.current !== current) return
|
||||
const failure = AuthOperationError.from(error)
|
||||
if (failure.code === 'AUTH_CANCELLED') {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
dispatch({ type: 'error', error: failure.message })
|
||||
} finally {
|
||||
current.submitting = false
|
||||
}
|
||||
}, [cancel, state])
|
||||
|
||||
const retry = useCallback(() => {
|
||||
if (pending.current && state.phase === 'error') {
|
||||
void loadRequirements(pending.current)
|
||||
}
|
||||
}, [loadRequirements, state.phase])
|
||||
const setInput = useCallback(
|
||||
(input: VerificationInput) => dispatch({ type: 'input', input }),
|
||||
[]
|
||||
)
|
||||
|
||||
return {
|
||||
requestVerification,
|
||||
requestLoginVerification,
|
||||
cancel,
|
||||
isActive: state.phase !== 'idle',
|
||||
dialogProps: {
|
||||
state,
|
||||
onCancel: cancel,
|
||||
onRetry: retry,
|
||||
onInputChange: setInput,
|
||||
onVerify: executeVerification,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
export * from './api'
|
||||
export * from './types'
|
||||
export * from './hooks/use-secure-verification'
|
||||
export * from './components/secure-verification-dialog'
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { AuthBundle } from '@/stores/auth-store'
|
||||
|
||||
export type VerificationMethod =
|
||||
| '2fa'
|
||||
| 'passkey'
|
||||
| 'password'
|
||||
| 'oauth'
|
||||
| 'session'
|
||||
export type SecurityProofScope =
|
||||
| 'channel.key.read'
|
||||
| 'passkey.register'
|
||||
| 'passkey.delete'
|
||||
| '2fa.setup'
|
||||
| '2fa.disable'
|
||||
| '2fa.backup_codes.regenerate'
|
||||
| 'access_token.generate'
|
||||
| 'access_token.revoke'
|
||||
| 'account.binding.bind'
|
||||
| 'account.binding.unbind'
|
||||
| 'account.password.set'
|
||||
| 'account.password.change'
|
||||
| 'account.delete'
|
||||
|
||||
export type VerificationOperation =
|
||||
| { scope: 'channel.key.read'; context: { channel_id: number } }
|
||||
| {
|
||||
scope: 'account.binding.bind'
|
||||
context: { provider: string; email?: string; code?: string }
|
||||
}
|
||||
| { scope: 'account.binding.unbind'; context: { provider_id: number } }
|
||||
| {
|
||||
scope: Exclude<
|
||||
SecurityProofScope,
|
||||
'channel.key.read' | 'account.binding.bind' | 'account.binding.unbind'
|
||||
>
|
||||
context?: Record<string, never>
|
||||
}
|
||||
|
||||
export interface SecurityProof {
|
||||
proof_token: string
|
||||
expires_at: number
|
||||
method: VerificationMethod
|
||||
scope: SecurityProofScope
|
||||
}
|
||||
|
||||
export interface VerificationRequirements {
|
||||
scope: SecurityProofScope | 'auth.login'
|
||||
methods: { method: VerificationMethod; available: boolean; reason?: string }[]
|
||||
oauth_providers: { slug: string; name: string }[]
|
||||
password_encryption_enabled: boolean
|
||||
}
|
||||
|
||||
export type VerificationInput =
|
||||
| { method: '2fa'; code: string }
|
||||
| { method: 'password'; password: string }
|
||||
| { method: 'passkey' }
|
||||
| { method: 'oauth'; provider: string }
|
||||
| { method: 'session' }
|
||||
|
||||
export type RequestVerificationOptions = VerificationOperation & {
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface LoginChallenge {
|
||||
require_verification: true
|
||||
flow_token: string
|
||||
expires_at: number
|
||||
methods: VerificationRequirements['methods']
|
||||
}
|
||||
|
||||
export interface RequestLoginVerificationOptions {
|
||||
scope: 'auth.login'
|
||||
challenge: LoginChallenge
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type VerificationRequest =
|
||||
| RequestVerificationOptions
|
||||
| RequestLoginVerificationOptions
|
||||
export type LoginResult = AuthBundle | LoginChallenge
|
||||
|
||||
export type SecureVerificationState =
|
||||
| { phase: 'idle' }
|
||||
| { phase: 'loading'; request: VerificationRequest }
|
||||
| { phase: 'error'; request: VerificationRequest; error: string }
|
||||
| {
|
||||
phase: 'ready' | 'verifying'
|
||||
request: VerificationRequest
|
||||
requirements: VerificationRequirements
|
||||
input: VerificationInput | null
|
||||
error?: string
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import axios from 'axios'
|
||||
import { Loader2, LogIn, KeyRound } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import type { z } from 'zod'
|
||||
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { PasswordInput } from '@/components/password-input'
|
||||
import { Turnstile } from '@/components/turnstile'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { login, wechatLoginByCode } from '@/features/auth/api'
|
||||
import { LegalConsent } from '@/features/auth/components/legal-consent'
|
||||
import { OAuthProviders } from '@/features/auth/components/oauth-providers'
|
||||
import { loginFormSchema } from '@/features/auth/constants'
|
||||
import { useAuthRedirect } from '@/features/auth/hooks/use-auth-redirect'
|
||||
import { useTurnstile } from '@/features/auth/hooks/use-turnstile'
|
||||
import { beginPasskeyLogin, finishPasskeyLogin } from '@/features/auth/passkey'
|
||||
import type { AuthFormProps } from '@/features/auth/types'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import {
|
||||
buildAssertionResult,
|
||||
prepareCredentialRequestOptions,
|
||||
isPasskeySupported as detectPasskeySupport,
|
||||
} from '@/lib/passkey'
|
||||
import { getServerErrorMessageKey } from '@/lib/server-error-message'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function UserAuthForm({
|
||||
className,
|
||||
redirectTo,
|
||||
...props
|
||||
}: AuthFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [wechatCode, setWeChatCode] = useState('')
|
||||
const [agreedToLegal, setAgreedToLegal] = useState(false)
|
||||
const [passkeySupported, setPasskeySupported] = useState(false)
|
||||
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false)
|
||||
const [isWeChatDialogOpen, setIsWeChatDialogOpen] = useState(false)
|
||||
const [isWeChatSubmitting, setIsWeChatSubmitting] = useState(false)
|
||||
const [turnstileWidgetKey, setTurnstileWidgetKey] = useState(0)
|
||||
const legalConsentErrorMessage = t('Please agree to the legal terms first')
|
||||
const loginFailedMessage = t('Login failed')
|
||||
|
||||
const { status } = useStatus()
|
||||
const passkeyLoginEnabled = Boolean(
|
||||
status?.passkey_login ?? status?.data?.passkey_login
|
||||
)
|
||||
const passwordLoginEnabled =
|
||||
(status?.password_login_enabled ??
|
||||
status?.data?.password_login_enabled ??
|
||||
true) !== false
|
||||
const passwordLoginEncryptionEnabled =
|
||||
(status?.password_login_encryption_enabled ??
|
||||
status?.data?.password_login_encryption_enabled ??
|
||||
false) === true
|
||||
const {
|
||||
isTurnstileEnabled,
|
||||
turnstileSiteKey,
|
||||
turnstileToken,
|
||||
setTurnstileToken,
|
||||
validateTurnstile,
|
||||
} = useTurnstile()
|
||||
const { handleLoginResult } = useAuthRedirect()
|
||||
|
||||
const hasUserAgreement = Boolean(status?.user_agreement_enabled)
|
||||
const hasPrivacyPolicy = Boolean(status?.privacy_policy_enabled)
|
||||
const requiresLegalConsent = hasUserAgreement || hasPrivacyPolicy
|
||||
const passkeyButtonDisabled =
|
||||
isPasskeyLoading ||
|
||||
!passkeySupported ||
|
||||
(requiresLegalConsent && !agreedToLegal)
|
||||
const hasWeChatLogin = Boolean(status?.wechat_login)
|
||||
const hasOAuthLogin = Boolean(
|
||||
status?.github_oauth ||
|
||||
status?.discord_oauth ||
|
||||
status?.oidc_enabled ||
|
||||
status?.linuxdo_oauth ||
|
||||
status?.telegram_oauth ||
|
||||
(status?.custom_oauth_providers?.length ?? 0) > 0
|
||||
)
|
||||
const hasAlternativeLogin =
|
||||
passkeyLoginEnabled || hasWeChatLogin || hasOAuthLogin
|
||||
|
||||
useEffect(() => {
|
||||
if (requiresLegalConsent) {
|
||||
setAgreedToLegal(false)
|
||||
} else {
|
||||
setAgreedToLegal(true)
|
||||
}
|
||||
}, [requiresLegalConsent])
|
||||
|
||||
useEffect(() => {
|
||||
detectPasskeySupport()
|
||||
.then(setPasskeySupported)
|
||||
.catch(() => setPasskeySupported(false))
|
||||
}, [])
|
||||
|
||||
const form = useForm<z.infer<typeof loginFormSchema>>({
|
||||
resolver: zodResolver(loginFormSchema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
})
|
||||
|
||||
const wechatQrCodeUrl = useMemo(() => {
|
||||
return (
|
||||
status?.wechat_qrcode ||
|
||||
status?.wechat_qr_code ||
|
||||
status?.wechat_qrcode_image_url ||
|
||||
status?.wechat_qr_code_image_url ||
|
||||
status?.wechat_account_qrcode_image_url ||
|
||||
status?.WeChatAccountQRCodeImageURL ||
|
||||
status?.data?.wechat_qrcode ||
|
||||
status?.data?.WeChatAccountQRCodeImageURL ||
|
||||
''
|
||||
)
|
||||
}, [status])
|
||||
|
||||
async function onSubmit(data: z.infer<typeof loginFormSchema>) {
|
||||
if (requiresLegalConsent && !agreedToLegal) {
|
||||
toast.error(legalConsentErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
if (!validateTurnstile()) return
|
||||
|
||||
const submittedTurnstileToken = turnstileToken
|
||||
if (isTurnstileEnabled) {
|
||||
setTurnstileToken('')
|
||||
setTurnstileWidgetKey((current) => current + 1)
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await login({
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
turnstile: submittedTurnstileToken,
|
||||
passwordEncryptionEnabled: passwordLoginEncryptionEnabled,
|
||||
})
|
||||
|
||||
if (res.success) {
|
||||
form.setValue('password', '')
|
||||
if (await handleLoginResult(res.data, redirectTo)) {
|
||||
toast.success(t('Welcome back!'))
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (axios.isAxiosError(error)) return
|
||||
toast.error(error instanceof Error ? error.message : loginFailedMessage)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenWeChatDialog = () => {
|
||||
if (requiresLegalConsent && !agreedToLegal) {
|
||||
toast.error(legalConsentErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
setIsWeChatDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleWeChatDialogChange = (open: boolean) => {
|
||||
setIsWeChatDialogOpen(open)
|
||||
if (!open) {
|
||||
setWeChatCode('')
|
||||
setIsWeChatSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWeChatLogin() {
|
||||
if (!wechatCode.trim()) {
|
||||
toast.error(t('Please enter the verification code'))
|
||||
return
|
||||
}
|
||||
|
||||
setIsWeChatSubmitting(true)
|
||||
try {
|
||||
const res = await wechatLoginByCode(wechatCode)
|
||||
if (res?.success) {
|
||||
handleWeChatDialogChange(false)
|
||||
if (await handleLoginResult(res.data, redirectTo)) {
|
||||
toast.success(t('Signed in via WeChat'))
|
||||
}
|
||||
} else {
|
||||
if (getServerErrorMessageKey(res)) return
|
||||
toast.error(res?.message || loginFailedMessage)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (getServerErrorMessageKey(error)) return
|
||||
toast.error(loginFailedMessage)
|
||||
} finally {
|
||||
setIsWeChatSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasskeyLogin() {
|
||||
if (requiresLegalConsent && !agreedToLegal) {
|
||||
toast.error(legalConsentErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
if (!passkeySupported) {
|
||||
toast.error(t('Passkey is not supported on this device'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!navigator?.credentials) {
|
||||
toast.error(t('Passkey is not available in this browser'))
|
||||
return
|
||||
}
|
||||
|
||||
setIsPasskeyLoading(true)
|
||||
try {
|
||||
const begin = await beginPasskeyLogin()
|
||||
if (!begin.success) {
|
||||
if (getServerErrorMessageKey(begin)) return
|
||||
throw new Error(begin.message || t('Failed to start Passkey login'))
|
||||
}
|
||||
|
||||
const publicKey = prepareCredentialRequestOptions(
|
||||
begin.data?.options ?? begin.data
|
||||
)
|
||||
const flowToken = begin.data?.flow_token
|
||||
if (!flowToken) {
|
||||
throw new Error(t('Login flow expired. Please sign in again.'))
|
||||
}
|
||||
|
||||
const credential = (await navigator.credentials.get({
|
||||
publicKey,
|
||||
})) as PublicKeyCredential | null
|
||||
|
||||
if (!credential) {
|
||||
toast.info(t('Passkey login was cancelled'))
|
||||
return
|
||||
}
|
||||
|
||||
const assertion = buildAssertionResult(credential)
|
||||
if (!assertion) {
|
||||
throw new Error(t('Invalid Passkey response'))
|
||||
}
|
||||
|
||||
const finish = await finishPasskeyLogin(flowToken, assertion)
|
||||
if (!finish.success) {
|
||||
if (getServerErrorMessageKey(finish)) return
|
||||
throw new Error(finish.message || t('Failed to complete Passkey login'))
|
||||
}
|
||||
|
||||
if (await handleLoginResult(finish.data, redirectTo)) {
|
||||
toast.success(t('Signed in with Passkey'))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (getServerErrorMessageKey(error)) return
|
||||
if (error instanceof DOMException && error.name === 'NotAllowedError') {
|
||||
toast.info(t('Passkey login was cancelled or timed out'))
|
||||
} else if (error instanceof Error) {
|
||||
toast.error(error.message)
|
||||
} else {
|
||||
toast.error(t('Passkey login failed'))
|
||||
}
|
||||
} finally {
|
||||
setIsPasskeyLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const alternativeLoginMethods = (
|
||||
<>
|
||||
{passkeyLoginEnabled && (
|
||||
<div className='mt-2 space-y-1'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
disabled={passkeyButtonDisabled}
|
||||
onClick={handlePasskeyLogin}
|
||||
className='h-11 w-full justify-center gap-2 rounded-lg'
|
||||
>
|
||||
{isPasskeyLoading ? (
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<KeyRound className='h-4 w-4' />
|
||||
)}
|
||||
{t('Sign in with Passkey')}
|
||||
</Button>
|
||||
{!passkeySupported && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Passkey is not supported on this device.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OAuth Providers */}
|
||||
<OAuthProviders
|
||||
status={status}
|
||||
redirectTo={redirectTo}
|
||||
disabled={isLoading || (requiresLegalConsent && !agreedToLegal)}
|
||||
onWeChatLogin={hasWeChatLogin ? handleOpenWeChatDialog : undefined}
|
||||
isWeChatLoading={isWeChatSubmitting}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className={cn('grid gap-4', className)}
|
||||
{...props}
|
||||
>
|
||||
{hasAlternativeLogin && alternativeLoginMethods}
|
||||
|
||||
{passwordLoginEnabled && (
|
||||
<>
|
||||
{/* Username Field */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='username'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Username or Email')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('Enter your username or email')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Password Field */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem className='relative'>
|
||||
<FormLabel>{t('Password')}</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
placeholder={t('Enter password')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<Link
|
||||
to='/forgot-password'
|
||||
className='text-muted-foreground absolute end-0 -top-0.5 z-10 text-sm font-medium hover:opacity-75'
|
||||
>
|
||||
{t('Forgot password?')}
|
||||
</Link>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type='submit'
|
||||
className='mt-2 w-full justify-center gap-2'
|
||||
disabled={isLoading || (requiresLegalConsent && !agreedToLegal)}
|
||||
>
|
||||
{isLoading ? <Loader2 className='animate-spin' /> : <LogIn />}
|
||||
{t('Sign in')}
|
||||
</Button>
|
||||
|
||||
{/* Turnstile */}
|
||||
{isTurnstileEnabled && (
|
||||
<div className='mt-2'>
|
||||
<Turnstile
|
||||
key={turnstileWidgetKey}
|
||||
siteKey={turnstileSiteKey}
|
||||
onVerify={setTurnstileToken}
|
||||
onExpire={() => setTurnstileToken('')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<LegalConsent
|
||||
status={status}
|
||||
checked={agreedToLegal}
|
||||
onCheckedChange={setAgreedToLegal}
|
||||
className='mt-1'
|
||||
/>
|
||||
|
||||
{!hasAlternativeLogin && alternativeLoginMethods}
|
||||
</form>
|
||||
|
||||
{hasWeChatLogin && (
|
||||
<Dialog
|
||||
open={isWeChatDialogOpen}
|
||||
onOpenChange={handleWeChatDialogChange}
|
||||
title={t('WeChat sign in')}
|
||||
description={t(
|
||||
'Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.'
|
||||
)}
|
||||
contentClassName='max-w-sm'
|
||||
headerClassName='text-left'
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => handleWeChatDialogChange(false)}
|
||||
disabled={isWeChatSubmitting}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={handleWeChatLogin}
|
||||
disabled={
|
||||
isWeChatSubmitting ||
|
||||
!wechatCode.trim() ||
|
||||
(requiresLegalConsent && !agreedToLegal)
|
||||
}
|
||||
className='gap-2'
|
||||
>
|
||||
{isWeChatSubmitting ? (
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
) : null}
|
||||
{t('Confirm')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{wechatQrCodeUrl ? (
|
||||
<div className='flex justify-center'>
|
||||
<img
|
||||
src={wechatQrCodeUrl}
|
||||
alt={t('WeChat login QR code')}
|
||||
className='h-40 w-40 rounded-md border object-contain'
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t('QR code is not configured. Please contact support.')}
|
||||
</p>
|
||||
)}
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='wechat-code'>{t('Verification code')}</Label>
|
||||
<Input
|
||||
id='wechat-code'
|
||||
placeholder={t('Enter the verification code')}
|
||||
value={wechatCode}
|
||||
onChange={(event) => setWeChatCode(event.target.value)}
|
||||
autoComplete='one-time-code'
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
)}
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Link, useSearch } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
|
||||
import { AuthLayout } from '../auth-layout'
|
||||
import { TermsFooter } from '../components/terms-footer'
|
||||
import { UserAuthForm } from './components/user-auth-form'
|
||||
|
||||
export function SignIn() {
|
||||
const { t } = useTranslation()
|
||||
const { redirect } = useSearch({ from: '/(auth)/sign-in' })
|
||||
const { status } = useStatus()
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<div className='w-full space-y-8'>
|
||||
<div className='space-y-2'>
|
||||
<h2 className='text-center text-2xl font-semibold tracking-tight sm:text-left'>
|
||||
{t('Sign in')}
|
||||
</h2>
|
||||
{!status?.self_use_mode_enabled &&
|
||||
status?.register_enabled !== false && (
|
||||
<p className='text-muted-foreground text-left text-sm sm:text-base'>
|
||||
{t("Don't have an account?")}{' '}
|
||||
<Link
|
||||
to='/sign-up'
|
||||
className='hover:text-primary font-medium underline underline-offset-4'
|
||||
>
|
||||
{t('Sign up')}
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<UserAuthForm redirectTo={redirect} />
|
||||
|
||||
<TermsFooter
|
||||
variant='sign-in'
|
||||
status={status}
|
||||
className='text-center'
|
||||
/>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import type { z } from 'zod'
|
||||
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { PasswordInput } from '@/components/password-input'
|
||||
import { Turnstile } from '@/components/turnstile'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { register, wechatLoginByCode } from '@/features/auth/api'
|
||||
import { LegalConsent } from '@/features/auth/components/legal-consent'
|
||||
import { OAuthProviders } from '@/features/auth/components/oauth-providers'
|
||||
import { registerFormSchema } from '@/features/auth/constants'
|
||||
import { useAuthRedirect } from '@/features/auth/hooks/use-auth-redirect'
|
||||
import { useEmailVerification } from '@/features/auth/hooks/use-email-verification'
|
||||
import { useTurnstile } from '@/features/auth/hooks/use-turnstile'
|
||||
import {
|
||||
getAffiliateCode,
|
||||
saveAffiliateCode,
|
||||
} from '@/features/auth/lib/storage'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import { getServerErrorMessageKey } from '@/lib/server-error-message'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function SignUpForm({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLFormElement>) {
|
||||
const { t } = useTranslation()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [verificationCode, setVerificationCode] = useState('')
|
||||
const [agreedToLegal, setAgreedToLegal] = useState(false)
|
||||
const [wechatCode, setWeChatCode] = useState('')
|
||||
const [isWeChatDialogOpen, setIsWeChatDialogOpen] = useState(false)
|
||||
const [isWeChatSubmitting, setIsWeChatSubmitting] = useState(false)
|
||||
const [turnstileWidgetKey, setTurnstileWidgetKey] = useState(0)
|
||||
const legalConsentErrorMessage = t('Please agree to the legal terms first')
|
||||
|
||||
const { status } = useStatus()
|
||||
const {
|
||||
isTurnstileEnabled,
|
||||
turnstileSiteKey,
|
||||
turnstileToken,
|
||||
setTurnstileToken,
|
||||
validateTurnstile,
|
||||
} = useTurnstile()
|
||||
const { redirectToLogin, handleLoginResult } = useAuthRedirect()
|
||||
const {
|
||||
isSending: isSendingCode,
|
||||
secondsLeft,
|
||||
isActive,
|
||||
sendCode,
|
||||
} = useEmailVerification({
|
||||
turnstileToken,
|
||||
validateTurnstile,
|
||||
})
|
||||
|
||||
const form = useForm<z.infer<typeof registerFormSchema>>({
|
||||
resolver: zodResolver(registerFormSchema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
},
|
||||
})
|
||||
|
||||
const emailValue = form.watch('email')
|
||||
const emailVerificationRequired = !!status?.email_verification
|
||||
const hasUserAgreement = Boolean(status?.user_agreement_enabled)
|
||||
const hasPrivacyPolicy = Boolean(status?.privacy_policy_enabled)
|
||||
const requiresLegalConsent = hasUserAgreement || hasPrivacyPolicy
|
||||
const oauthRegisterEnabled =
|
||||
status?.oauth_register_enabled ??
|
||||
status?.data?.oauth_register_enabled ??
|
||||
true
|
||||
const hasWeChatLogin = Boolean(status?.wechat_login)
|
||||
const turnstileReady = !isTurnstileEnabled || Boolean(turnstileToken)
|
||||
|
||||
const wechatQrCodeUrl = useMemo(() => {
|
||||
return (
|
||||
status?.wechat_qrcode ||
|
||||
status?.wechat_qr_code ||
|
||||
status?.wechat_qrcode_image_url ||
|
||||
status?.wechat_qr_code_image_url ||
|
||||
status?.wechat_account_qrcode_image_url ||
|
||||
status?.WeChatAccountQRCodeImageURL ||
|
||||
status?.data?.wechat_qrcode ||
|
||||
status?.data?.WeChatAccountQRCodeImageURL ||
|
||||
''
|
||||
)
|
||||
}, [status])
|
||||
|
||||
useEffect(() => {
|
||||
if (requiresLegalConsent) {
|
||||
setAgreedToLegal(false)
|
||||
} else {
|
||||
setAgreedToLegal(true)
|
||||
}
|
||||
}, [requiresLegalConsent])
|
||||
|
||||
useEffect(() => {
|
||||
const aff = new URLSearchParams(window.location.search).get('aff')?.trim()
|
||||
if (aff) {
|
||||
saveAffiliateCode(aff)
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function onSubmit(data: z.infer<typeof registerFormSchema>) {
|
||||
if (requiresLegalConsent && !agreedToLegal) {
|
||||
toast.error(legalConsentErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate email verification if required
|
||||
if (emailVerificationRequired) {
|
||||
if (!data.email) {
|
||||
toast.error(t('Please enter your email'))
|
||||
return
|
||||
}
|
||||
if (!verificationCode) {
|
||||
toast.error(t('Please enter the verification code'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!validateTurnstile()) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await register({
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
email: data.email || undefined,
|
||||
verification_code: verificationCode || undefined,
|
||||
aff_code: getAffiliateCode(),
|
||||
turnstile: turnstileToken,
|
||||
})
|
||||
|
||||
if (res?.success) {
|
||||
toast.success(t('Account created! Please sign in'))
|
||||
redirectToLogin()
|
||||
} else {
|
||||
toast.error(res?.message || t('Failed to create account'))
|
||||
}
|
||||
} catch {
|
||||
// Errors are handled by global interceptor
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSendVerificationCode() {
|
||||
if (await sendCode(emailValue || '')) {
|
||||
setTurnstileToken('')
|
||||
setTurnstileWidgetKey((current) => current + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenWeChatDialog = () => {
|
||||
if (requiresLegalConsent && !agreedToLegal) {
|
||||
toast.error(legalConsentErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
setIsWeChatDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleWeChatDialogChange = (open: boolean) => {
|
||||
setIsWeChatDialogOpen(open)
|
||||
if (!open) {
|
||||
setWeChatCode('')
|
||||
setIsWeChatSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWeChatLogin() {
|
||||
if (!wechatCode.trim()) {
|
||||
toast.error(t('Please enter the verification code'))
|
||||
return
|
||||
}
|
||||
|
||||
setIsWeChatSubmitting(true)
|
||||
try {
|
||||
const res = await wechatLoginByCode(wechatCode)
|
||||
if (res?.success) {
|
||||
handleWeChatDialogChange(false)
|
||||
if (await handleLoginResult(res.data)) {
|
||||
toast.success(t('Signed in via WeChat'))
|
||||
}
|
||||
} else {
|
||||
if (getServerErrorMessageKey(res)) return
|
||||
toast.error(res?.message || t('Login failed'))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (getServerErrorMessageKey(error)) return
|
||||
toast.error(t('Login failed'))
|
||||
} finally {
|
||||
setIsWeChatSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
let verificationCodeAction: ReactNode = t('Send code')
|
||||
if (isActive) {
|
||||
verificationCodeAction = t('Resend ({{seconds}}s)', {
|
||||
seconds: secondsLeft,
|
||||
})
|
||||
} else if (isSendingCode) {
|
||||
verificationCodeAction = <Loader2 className='h-4 w-4 animate-spin' />
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className={cn('grid gap-4', className)}
|
||||
{...props}
|
||||
>
|
||||
{/* Username Field */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='username'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Username')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t('Enter your username')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Password Field */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Password')}</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
placeholder={t('Enter password (8–128 characters)')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Confirm Password Field */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='confirmPassword'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Confirm password')}</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput placeholder={t('Confirm password')} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Email Verification Section */}
|
||||
{emailVerificationRequired && (
|
||||
<>
|
||||
{/* Email Field */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('Email (required for verification)')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t('name@example.com')}
|
||||
type='email'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Verification Code Field */}
|
||||
<div className='flex items-end gap-2'>
|
||||
<div className='flex-1'>
|
||||
<Input
|
||||
placeholder={t('Verification code')}
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant='outline'
|
||||
type='button'
|
||||
disabled={
|
||||
isLoading ||
|
||||
isSendingCode ||
|
||||
isActive ||
|
||||
!emailValue ||
|
||||
!turnstileReady
|
||||
}
|
||||
onClick={handleSendVerificationCode}
|
||||
>
|
||||
{verificationCodeAction}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Turnstile */}
|
||||
{isTurnstileEnabled && (
|
||||
<div className='mt-2'>
|
||||
<Turnstile
|
||||
key={turnstileWidgetKey}
|
||||
siteKey={turnstileSiteKey}
|
||||
onVerify={setTurnstileToken}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LegalConsent
|
||||
status={status}
|
||||
checked={agreedToLegal}
|
||||
onCheckedChange={setAgreedToLegal}
|
||||
className='mt-1'
|
||||
/>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type='submit'
|
||||
className='mt-2 w-full justify-center gap-2'
|
||||
disabled={
|
||||
isLoading ||
|
||||
(requiresLegalConsent && !agreedToLegal) ||
|
||||
!turnstileReady
|
||||
}
|
||||
>
|
||||
{isLoading ? <Loader2 className='h-4 w-4 animate-spin' /> : null}
|
||||
{t('Create account')}
|
||||
</Button>
|
||||
|
||||
{oauthRegisterEnabled && (
|
||||
<OAuthProviders
|
||||
status={status}
|
||||
disabled={isLoading || (requiresLegalConsent && !agreedToLegal)}
|
||||
onWeChatLogin={hasWeChatLogin ? handleOpenWeChatDialog : undefined}
|
||||
isWeChatLoading={isWeChatSubmitting}
|
||||
className='pt-2'
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{hasWeChatLogin && (
|
||||
<Dialog
|
||||
open={isWeChatDialogOpen}
|
||||
onOpenChange={handleWeChatDialogChange}
|
||||
title={t('WeChat sign in')}
|
||||
description={t(
|
||||
'Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.'
|
||||
)}
|
||||
contentClassName='max-w-sm'
|
||||
headerClassName='text-left'
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => handleWeChatDialogChange(false)}
|
||||
disabled={isWeChatSubmitting}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={handleWeChatLogin}
|
||||
disabled={
|
||||
isWeChatSubmitting ||
|
||||
!wechatCode.trim() ||
|
||||
(requiresLegalConsent && !agreedToLegal)
|
||||
}
|
||||
className='gap-2'
|
||||
>
|
||||
{isWeChatSubmitting ? (
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
) : null}
|
||||
{t('Confirm')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{wechatQrCodeUrl ? (
|
||||
<div className='flex justify-center'>
|
||||
<img
|
||||
src={wechatQrCodeUrl}
|
||||
alt={t('WeChat login QR code')}
|
||||
className='h-40 w-40 rounded-md border object-contain'
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t('QR code is not configured. Please contact support.')}
|
||||
</p>
|
||||
)}
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='wechat-code'>{t('Verification code')}</Label>
|
||||
<Input
|
||||
id='wechat-code'
|
||||
placeholder={t('Enter the verification code')}
|
||||
value={wechatCode}
|
||||
onChange={(event) => setWeChatCode(event.target.value)}
|
||||
autoComplete='one-time-code'
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
)}
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
|
||||
import { AuthLayout } from '../auth-layout'
|
||||
import { TermsFooter } from '../components/terms-footer'
|
||||
import { SignUpForm } from './components/sign-up-form'
|
||||
|
||||
export function SignUp() {
|
||||
const { t } = useTranslation()
|
||||
const { status } = useStatus()
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<div className='w-full space-y-8'>
|
||||
<div className='space-y-2'>
|
||||
<h2 className='text-center text-2xl font-semibold tracking-tight sm:text-left'>
|
||||
{t('Create an account')}
|
||||
</h2>
|
||||
<p className='text-muted-foreground text-left text-sm sm:text-base'>
|
||||
{t('Already have an account?')}{' '}
|
||||
<Link
|
||||
to='/sign-in'
|
||||
className='hover:text-primary font-medium underline underline-offset-4'
|
||||
>
|
||||
{t('Sign in')}
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SignUpForm />
|
||||
|
||||
<TermsFooter
|
||||
variant='sign-up'
|
||||
status={status}
|
||||
className='text-center'
|
||||
/>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { AuthBundle } from '@/stores/auth-store'
|
||||
|
||||
import type { LoginResult } from './secure-verification/types'
|
||||
|
||||
// ============================================================================
|
||||
// API Payloads
|
||||
// ============================================================================
|
||||
|
||||
export interface LoginPayload {
|
||||
username: string
|
||||
password: string
|
||||
turnstile?: string
|
||||
passwordEncryptionEnabled?: boolean
|
||||
}
|
||||
|
||||
export interface TwoFAPayload {
|
||||
code: string
|
||||
flow_token: string
|
||||
}
|
||||
|
||||
export interface RegisterPayload {
|
||||
username: string
|
||||
password: string
|
||||
email?: string
|
||||
verification_code?: string
|
||||
aff_code?: string
|
||||
turnstile?: string
|
||||
}
|
||||
|
||||
export interface PasswordResetPayload {
|
||||
email: string
|
||||
turnstile?: string
|
||||
}
|
||||
|
||||
export interface EmailVerificationPayload {
|
||||
email: string
|
||||
turnstile?: string
|
||||
}
|
||||
|
||||
export interface BindEmailPayload {
|
||||
flow_token: string
|
||||
new_code: string
|
||||
old_code?: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Responses
|
||||
// ============================================================================
|
||||
|
||||
export interface LoginResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
data?: LoginResult
|
||||
}
|
||||
|
||||
export interface Login2FAResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
data?: AuthBundle
|
||||
}
|
||||
|
||||
export interface ApiResponse<T = unknown> {
|
||||
success: boolean
|
||||
message: string
|
||||
data?: T
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// System Status
|
||||
// ============================================================================
|
||||
|
||||
export interface SystemStatus {
|
||||
success?: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
version?: string
|
||||
system_name?: string
|
||||
logo?: string
|
||||
github_oauth?: boolean
|
||||
github_client_id?: string
|
||||
discord_oauth?: boolean
|
||||
discord_client_id?: string
|
||||
oidc_enabled?: boolean
|
||||
oidc_authorization_endpoint?: string
|
||||
oidc_client_id?: string
|
||||
oidc_display_name?: string
|
||||
linuxdo_oauth?: boolean
|
||||
linuxdo_client_id?: string
|
||||
telegram_oauth?: boolean
|
||||
telegram_oauth_configured?: boolean
|
||||
telegram_bot_name?: string
|
||||
passkey_login?: boolean
|
||||
wechat_login?: boolean
|
||||
wechat_qrcode?: string
|
||||
wechat_qr_code?: string
|
||||
wechat_qrcode_image_url?: string
|
||||
wechat_qr_code_image_url?: string
|
||||
wechat_account_qrcode_image_url?: string
|
||||
WeChatAccountQRCodeImageURL?: string
|
||||
turnstile_check?: boolean
|
||||
turnstile_site_key?: string
|
||||
email_verification?: boolean
|
||||
self_use_mode_enabled?: boolean
|
||||
display_in_currency?: boolean
|
||||
display_token_stat_enabled?: boolean
|
||||
quota_per_unit?: number
|
||||
quota_display_type?: string
|
||||
usd_exchange_rate?: number
|
||||
custom_currency_symbol?: string
|
||||
custom_currency_exchange_rate?: number
|
||||
demo_site_enabled?: boolean
|
||||
user_agreement_enabled?: boolean
|
||||
privacy_policy_enabled?: boolean
|
||||
oauth_register_enabled?: boolean
|
||||
register_enabled?: boolean
|
||||
password_login_enabled?: boolean
|
||||
password_login_encryption_enabled?: boolean
|
||||
password_register_enabled?: boolean
|
||||
custom_oauth_providers?: CustomOAuthProviderInfo[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
// Allow direct access to common properties
|
||||
version?: string
|
||||
system_name?: string
|
||||
logo?: string
|
||||
github_oauth?: boolean
|
||||
github_client_id?: string
|
||||
discord_oauth?: boolean
|
||||
discord_client_id?: string
|
||||
oidc_enabled?: boolean
|
||||
oidc_authorization_endpoint?: string
|
||||
oidc_client_id?: string
|
||||
oidc_display_name?: string
|
||||
linuxdo_oauth?: boolean
|
||||
linuxdo_client_id?: string
|
||||
telegram_oauth?: boolean
|
||||
telegram_oauth_configured?: boolean
|
||||
telegram_bot_name?: string
|
||||
passkey_login?: boolean
|
||||
wechat_login?: boolean
|
||||
wechat_qrcode?: string
|
||||
wechat_qr_code?: string
|
||||
wechat_qrcode_image_url?: string
|
||||
wechat_qr_code_image_url?: string
|
||||
wechat_account_qrcode_image_url?: string
|
||||
WeChatAccountQRCodeImageURL?: string
|
||||
turnstile_check?: boolean
|
||||
turnstile_site_key?: string
|
||||
email_verification?: boolean
|
||||
self_use_mode_enabled?: boolean
|
||||
display_in_currency?: boolean
|
||||
display_token_stat_enabled?: boolean
|
||||
quota_per_unit?: number
|
||||
quota_display_type?: string
|
||||
usd_exchange_rate?: number
|
||||
custom_currency_symbol?: string
|
||||
custom_currency_exchange_rate?: number
|
||||
demo_site_enabled?: boolean
|
||||
user_agreement_enabled?: boolean
|
||||
privacy_policy_enabled?: boolean
|
||||
oauth_register_enabled?: boolean
|
||||
register_enabled?: boolean
|
||||
password_login_enabled?: boolean
|
||||
password_login_encryption_enabled?: boolean
|
||||
password_register_enabled?: boolean
|
||||
custom_oauth_providers?: CustomOAuthProviderInfo[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OAuth
|
||||
// ============================================================================
|
||||
|
||||
export interface OAuthProvider {
|
||||
name: string
|
||||
type: 'github' | 'discord' | 'oidc' | 'linuxdo' | 'telegram' | 'wechat'
|
||||
enabled: boolean
|
||||
clientId?: string
|
||||
authEndpoint?: string
|
||||
}
|
||||
|
||||
export interface CustomOAuthProviderInfo {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
icon: string
|
||||
client_id: string
|
||||
authorization_endpoint: string
|
||||
scopes: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Form Props
|
||||
// ============================================================================
|
||||
|
||||
export interface AuthFormProps extends React.HTMLAttributes<HTMLFormElement> {
|
||||
redirectTo?: string
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { getGroups as getUserGroups } from '@/features/users/api'
|
||||
import { api, type ApiRequestConfig } from '@/lib/api'
|
||||
|
||||
import type {
|
||||
AddChannelRequest,
|
||||
BatchDeleteParams,
|
||||
BatchSetTagParams,
|
||||
Channel,
|
||||
ChannelBalanceResponse,
|
||||
ChannelOpsResponse,
|
||||
ChannelTestResponse,
|
||||
CopyChannelParams,
|
||||
CopyChannelResponse,
|
||||
FetchModelsResponse,
|
||||
GetChannelResponse,
|
||||
GetChannelsParams,
|
||||
GetChannelsResponse,
|
||||
MultiKeyManageParams,
|
||||
MultiKeyStatusResponse,
|
||||
SearchChannelsParams,
|
||||
SearchChannelsResponse,
|
||||
TagOperationParams,
|
||||
} from './types'
|
||||
|
||||
const channelActionConfig = (
|
||||
config: ApiRequestConfig = {}
|
||||
): ApiRequestConfig => ({
|
||||
...config,
|
||||
skipBusinessError: true,
|
||||
skipErrorHandler: true,
|
||||
})
|
||||
|
||||
export type TaskPluginOption = {
|
||||
sortPriority?: number
|
||||
website?: string
|
||||
key: string
|
||||
name: string
|
||||
icon?: string
|
||||
hasIcon?: boolean
|
||||
baseUrl?: string
|
||||
models: string[]
|
||||
}
|
||||
|
||||
export async function getTaskPluginOptions(): Promise<TaskPluginOption[]> {
|
||||
const response = await api.get<{
|
||||
success: boolean
|
||||
data: TaskPluginOption[]
|
||||
}>('/api/task_plugin_options')
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export type CodexUsageResponse = {
|
||||
success: boolean
|
||||
message?: string
|
||||
upstream_status?: number
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type CodexResetCreditsResponse = CodexUsageResponse
|
||||
|
||||
export type CodexUsageResetResponse = CodexUsageResponse
|
||||
|
||||
export type CodexCredentialRefreshResponse = {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: {
|
||||
expires_at?: string
|
||||
last_refresh?: string
|
||||
account_id?: string
|
||||
email?: string
|
||||
channel_id?: number
|
||||
channel_type?: number
|
||||
channel_name?: string
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Base Channel CRUD Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get paginated list of channels
|
||||
*/
|
||||
export async function getChannels(
|
||||
params: GetChannelsParams = {}
|
||||
): Promise<GetChannelsResponse> {
|
||||
const res = await api.get('/api/channel', { params })
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Search channels with filters
|
||||
*/
|
||||
export async function searchChannels(
|
||||
params: SearchChannelsParams
|
||||
): Promise<SearchChannelsResponse> {
|
||||
const res = await api.get('/api/channel/search', { params })
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single channel by ID
|
||||
*/
|
||||
export async function getChannel(id: number): Promise<GetChannelResponse> {
|
||||
const res = await api.get(`/api/channel/${id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get channel operations summary for administrators
|
||||
*/
|
||||
export async function getChannelOps(): Promise<ChannelOpsResponse> {
|
||||
const res = await api.get('/api/channel/ops', channelActionConfig())
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new channel(s)
|
||||
* Supports single, batch, and multi-key modes
|
||||
*/
|
||||
export async function createChannel(
|
||||
data: AddChannelRequest
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const res = await api.post('/api/channel', data, channelActionConfig())
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing channel
|
||||
*/
|
||||
export async function updateChannel(
|
||||
id: number,
|
||||
data: Partial<Channel>
|
||||
): Promise<{ success: boolean; message?: string; data?: Channel }> {
|
||||
const res = await api.put(
|
||||
'/api/channel/',
|
||||
{ id, ...data },
|
||||
channelActionConfig()
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update channel enabled/disabled status.
|
||||
*/
|
||||
export async function updateChannelStatus(
|
||||
id: number,
|
||||
status: number
|
||||
): Promise<{ success: boolean; message?: string; data?: boolean }> {
|
||||
const res = await api.post(
|
||||
`/api/channel/${id}/status`,
|
||||
{ status },
|
||||
channelActionConfig()
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch update channel enabled/disabled status.
|
||||
*/
|
||||
export async function batchUpdateChannelStatus(
|
||||
ids: number[],
|
||||
status: number
|
||||
): Promise<{ success: boolean; message?: string; data?: number }> {
|
||||
const res = await api.post(
|
||||
'/api/channel/status/batch',
|
||||
{ ids, status },
|
||||
channelActionConfig()
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete single channel
|
||||
*/
|
||||
export async function deleteChannel(
|
||||
id: number
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const res = await api.delete(`/api/channel/${id}`, channelActionConfig())
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch delete channels
|
||||
*/
|
||||
export async function batchDeleteChannels(
|
||||
data: BatchDeleteParams
|
||||
): Promise<{ success: boolean; message?: string; data?: number }> {
|
||||
const res = await api.post('/api/channel/batch', data, channelActionConfig())
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch set tag for channels
|
||||
*/
|
||||
export async function batchSetChannelTag(
|
||||
data: BatchSetTagParams
|
||||
): Promise<{ success: boolean; message?: string; data?: number }> {
|
||||
const res = await api.post(
|
||||
'/api/channel/batch/tag',
|
||||
data,
|
||||
channelActionConfig()
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Channel Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Test channel connectivity
|
||||
*/
|
||||
export async function testChannel(
|
||||
id: number,
|
||||
params?: { model?: string; endpoint_type?: string; stream?: boolean }
|
||||
): Promise<ChannelTestResponse> {
|
||||
const res = await api.get(
|
||||
`/api/channel/test/${id}`,
|
||||
channelActionConfig({ params })
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update channel balance
|
||||
*/
|
||||
export async function updateChannelBalance(
|
||||
id: number
|
||||
): Promise<ChannelBalanceResponse> {
|
||||
const res = await api.get(
|
||||
`/api/channel/update_balance/${id}`,
|
||||
channelActionConfig()
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch available models from upstream provider
|
||||
*/
|
||||
export async function fetchUpstreamModels(
|
||||
id: number
|
||||
): Promise<FetchModelsResponse> {
|
||||
const res = await api.get(
|
||||
`/api/channel/fetch_models/${id}`,
|
||||
channelActionConfig()
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy/clone a channel
|
||||
*/
|
||||
export async function copyChannel(
|
||||
id: number,
|
||||
params: CopyChannelParams = {}
|
||||
): Promise<CopyChannelResponse> {
|
||||
const res = await api.post(
|
||||
`/api/channel/copy/${id}`,
|
||||
null,
|
||||
channelActionConfig({ params })
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix channel abilities
|
||||
*/
|
||||
export async function fixChannelAbilities(): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: { success: number; fails: number }
|
||||
}> {
|
||||
const res = await api.post(
|
||||
'/api/channel/fix',
|
||||
undefined,
|
||||
channelActionConfig()
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all disabled channels
|
||||
*/
|
||||
export async function deleteDisabledChannels(): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: number
|
||||
}> {
|
||||
const res = await api.delete('/api/channel/disabled', channelActionConfig())
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get channel key (requires 2FA verification)
|
||||
*/
|
||||
export async function getChannelKey(
|
||||
id: number,
|
||||
proofToken: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ success: boolean; message?: string; data?: { key: string } }> {
|
||||
const res = await api.post(
|
||||
`/api/channel/${id}/key`,
|
||||
undefined,
|
||||
channelActionConfig({
|
||||
headers: { 'X-Security-Proof': proofToken },
|
||||
signal,
|
||||
})
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Codex Channel Operations
|
||||
// ============================================================================
|
||||
|
||||
export async function refreshCodexCredential(
|
||||
channelId: number
|
||||
): Promise<CodexCredentialRefreshResponse> {
|
||||
const res = await api.post(
|
||||
`/api/channel/${channelId}/codex/refresh`,
|
||||
{},
|
||||
channelActionConfig()
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getCodexUsage(
|
||||
channelId: number
|
||||
): Promise<CodexUsageResponse> {
|
||||
const res = await api.get(
|
||||
`/api/channel/${channelId}/codex/usage`,
|
||||
channelActionConfig({ disableDuplicate: true })
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getCodexResetCredits(
|
||||
channelId: number
|
||||
): Promise<CodexResetCreditsResponse> {
|
||||
const res = await api.get(
|
||||
`/api/channel/${channelId}/codex/usage/reset-credits`,
|
||||
channelActionConfig({ disableDuplicate: true })
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function resetCodexUsage(
|
||||
channelId: number
|
||||
): Promise<CodexUsageResetResponse> {
|
||||
const res = await api.post(
|
||||
`/api/channel/${channelId}/codex/usage/reset`,
|
||||
{},
|
||||
channelActionConfig({ disableDuplicate: true })
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Multi-Key Management
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Manage multi-key channel operations
|
||||
*/
|
||||
export async function manageMultiKeys(
|
||||
params: MultiKeyManageParams
|
||||
): Promise<MultiKeyStatusResponse | { success: boolean; message?: string }> {
|
||||
const res = await api.post(
|
||||
'/api/channel/multi_key/manage',
|
||||
params,
|
||||
channelActionConfig()
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get key status for multi-key channel
|
||||
*/
|
||||
export async function getMultiKeyStatus(
|
||||
channelId: number,
|
||||
page = 1,
|
||||
pageSize = 50,
|
||||
status?: number
|
||||
): Promise<MultiKeyStatusResponse> {
|
||||
return manageMultiKeys({
|
||||
channel_id: channelId,
|
||||
action: 'get_key_status',
|
||||
page,
|
||||
page_size: pageSize,
|
||||
status,
|
||||
}) as Promise<MultiKeyStatusResponse>
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable a specific key in multi-key channel
|
||||
*/
|
||||
export async function enableMultiKey(
|
||||
channelId: number,
|
||||
keyIndex: number
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
return manageMultiKeys({
|
||||
channel_id: channelId,
|
||||
action: 'enable_key',
|
||||
key_index: keyIndex,
|
||||
}) as Promise<{ success: boolean; message?: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a specific key in multi-key channel
|
||||
*/
|
||||
export async function disableMultiKey(
|
||||
channelId: number,
|
||||
keyIndex: number
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
return manageMultiKeys({
|
||||
channel_id: channelId,
|
||||
action: 'disable_key',
|
||||
key_index: keyIndex,
|
||||
}) as Promise<{ success: boolean; message?: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a specific key in multi-key channel
|
||||
*/
|
||||
export async function deleteMultiKey(
|
||||
channelId: number,
|
||||
keyIndex: number
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
return manageMultiKeys({
|
||||
channel_id: channelId,
|
||||
action: 'delete_key',
|
||||
key_index: keyIndex,
|
||||
}) as Promise<{ success: boolean; message?: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable all keys in multi-key channel
|
||||
*/
|
||||
export async function enableAllMultiKeys(
|
||||
channelId: number
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
return manageMultiKeys({
|
||||
channel_id: channelId,
|
||||
action: 'enable_all_keys',
|
||||
}) as Promise<{ success: boolean; message?: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable all keys in multi-key channel
|
||||
*/
|
||||
export async function disableAllMultiKeys(
|
||||
channelId: number
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
return manageMultiKeys({
|
||||
channel_id: channelId,
|
||||
action: 'disable_all_keys',
|
||||
}) as Promise<{ success: boolean; message?: string }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all disabled keys in multi-key channel
|
||||
*/
|
||||
export async function deleteDisabledMultiKeys(
|
||||
channelId: number
|
||||
): Promise<{ success: boolean; message?: string; data?: number }> {
|
||||
return manageMultiKeys({
|
||||
channel_id: channelId,
|
||||
action: 'delete_disabled_keys',
|
||||
}) as Promise<{ success: boolean; message?: string; data?: number }>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tag Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Enable all channels with a specific tag
|
||||
*/
|
||||
export async function enableTagChannels(
|
||||
tag: string
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const res = await api.post(
|
||||
'/api/channel/tag/enabled',
|
||||
{ tag },
|
||||
channelActionConfig()
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable all channels with a specific tag
|
||||
*/
|
||||
export async function disableTagChannels(
|
||||
tag: string
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const res = await api.post(
|
||||
'/api/channel/tag/disabled',
|
||||
{ tag },
|
||||
channelActionConfig()
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit all channels with a specific tag
|
||||
*/
|
||||
export async function editTagChannels(
|
||||
params: TagOperationParams
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const res = await api.put('/api/channel/tag', params, channelActionConfig())
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get models for a specific tag
|
||||
*/
|
||||
export async function getTagModels(
|
||||
tag: string
|
||||
): Promise<{ success: boolean; message?: string; data?: string }> {
|
||||
const res = await api.get('/api/channel/tag/models', { params: { tag } })
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utility Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch models from the current unsaved channel form configuration.
|
||||
*/
|
||||
export async function fetchModels(data: {
|
||||
base_url: string
|
||||
type: number
|
||||
key?: string
|
||||
channel_id?: number
|
||||
advanced_custom?: string
|
||||
header_override?: string
|
||||
proxy?: string
|
||||
}): Promise<FetchModelsResponse> {
|
||||
const res = await api.post(
|
||||
'/api/channel/fetch_models',
|
||||
data,
|
||||
channelActionConfig()
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an Ollama model from a channel
|
||||
*/
|
||||
export async function deleteOllamaModel(params: {
|
||||
channel_id: number
|
||||
model_name: string
|
||||
}): Promise<{ success: boolean; message?: string }> {
|
||||
const res = await api.delete(
|
||||
'/api/channel/ollama/delete',
|
||||
channelActionConfig({ data: params })
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Test all enabled channels
|
||||
*/
|
||||
export async function testAllChannels(): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
}> {
|
||||
const res = await api.get('/api/channel/test', channelActionConfig())
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Update balance for all enabled channels
|
||||
*/
|
||||
export async function updateAllChannelsBalance(): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
}> {
|
||||
const res = await api.get(
|
||||
'/api/channel/update_balance',
|
||||
channelActionConfig()
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available models
|
||||
*/
|
||||
export async function getAllModels(): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: Array<{ id: string; [key: string]: unknown }>
|
||||
}> {
|
||||
const res = await api.get('/api/channel/models')
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all enabled models
|
||||
*/
|
||||
export async function getEnabledModels(): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: string[]
|
||||
}> {
|
||||
const res = await api.get('/api/channel/models_enabled')
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Ollama Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check Ollama version for a given channel
|
||||
*/
|
||||
export async function getOllamaVersion(
|
||||
channelId: number
|
||||
): Promise<{ success: boolean; message?: string; data?: { version: string } }> {
|
||||
const res = await api.get(`/api/channel/ollama/version/${channelId}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Group Management
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all available groups (re-exported from users API for convenience)
|
||||
*/
|
||||
export const getGroups = getUserGroups
|
||||
|
||||
// ============================================================================
|
||||
// Prefill Groups (Model Groups)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get prefill groups for quick model selection
|
||||
*/
|
||||
export async function getPrefillGroups(
|
||||
type: 'model' | 'group' = 'model'
|
||||
): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: Array<{ id: number; name: string; items: string | string[] }>
|
||||
}> {
|
||||
const res = await api.get('/api/prefill_group', { params: { type } })
|
||||
return res.data
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { flexRender, type Row } from '@tanstack/react-table'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { CHANNEL_STATUS } from '../constants'
|
||||
import { isTagAggregateRow, parseGroupsList } from '../lib'
|
||||
import type { Channel } from '../types'
|
||||
import { ChannelRowActionsLayoutContext } from './channel-row-actions-context'
|
||||
import { useChannels } from './channels-provider'
|
||||
|
||||
const SENSITIVE_MASK = '••••'
|
||||
|
||||
/**
|
||||
* Bespoke channel card for the card view. Reuses every column's existing cell
|
||||
* renderer via `flexRender`, so the table's information and interactions are
|
||||
* preserved: row selection, provider/multi-key/IO.NET type badge, id,
|
||||
* name/remark + warning icons, status (with tooltips), groups, inline
|
||||
* priority/weight spinners, balance refresh, response/test times, tag
|
||||
* expand-collapse, and the per-row (or per-tag) actions menu.
|
||||
*/
|
||||
function ChannelCardComponent({
|
||||
row,
|
||||
isSelected,
|
||||
}: {
|
||||
row: Row<Channel>
|
||||
isSelected: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { sensitiveVisible } = useChannels()
|
||||
const isTagRow = isTagAggregateRow(row.original)
|
||||
const cells = row.getAllCells()
|
||||
|
||||
const renderCell = (id: string) => {
|
||||
const cell = cells.find((c) => c.column.id === id)
|
||||
if (!cell || !cell.column.columnDef.cell) {
|
||||
return null
|
||||
}
|
||||
return flexRender(cell.column.columnDef.cell, cell.getContext())
|
||||
}
|
||||
|
||||
const fieldLabels: Record<string, string> = {
|
||||
balance: t('Used / Remaining'),
|
||||
response_time: t('Response'),
|
||||
test_time: t('Last Tested'),
|
||||
}
|
||||
|
||||
const groups = parseGroupsList(row.original.group ?? '')
|
||||
|
||||
const selectCell = renderCell('select')
|
||||
const typeCell = renderCell('type')
|
||||
const nameCell = renderCell('name')
|
||||
const statusCell = renderCell('status')
|
||||
const actionsCell = renderCell('actions')
|
||||
const priorityCell = renderCell('priority')
|
||||
const weightCell = renderCell('weight')
|
||||
const balanceCell = renderCell('balance')
|
||||
const responseCell = renderCell('response_time')
|
||||
const testCell = renderCell('test_time')
|
||||
|
||||
const labelClass = 'text-muted-foreground text-[11px] font-medium select-none'
|
||||
|
||||
// In card view the enable/disable state is already conveyed by the inline
|
||||
// power toggle, so the plain "Enabled"/"Disabled" badge is redundant. Keep
|
||||
// only the informative states (e.g. auto-disabled, unknown) and tag rows.
|
||||
const showStatusBadge =
|
||||
isTagRow ||
|
||||
(row.original.status !== CHANNEL_STATUS.ENABLED &&
|
||||
row.original.status !== CHANNEL_STATUS.MANUAL_DISABLED)
|
||||
|
||||
return (
|
||||
<ChannelRowActionsLayoutContext.Provider value='card'>
|
||||
<div
|
||||
data-state={isSelected ? 'selected' : undefined}
|
||||
className='flex flex-col gap-3'
|
||||
>
|
||||
{/* Row 1: selection + type, with status badge + actions menu */}
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-2'>
|
||||
{!isTagRow && selectCell && (
|
||||
<span className='shrink-0'>{selectCell}</span>
|
||||
)}
|
||||
<div className='min-w-0 overflow-hidden'>{typeCell}</div>
|
||||
</div>
|
||||
<div className='flex shrink-0 items-center gap-1.5'>
|
||||
{showStatusBadge && statusCell}
|
||||
{actionsCell}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body: left column (id/name + balance) paired with a right-aligned
|
||||
column (priority/weight + response/test time). */}
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
{/* Left column */}
|
||||
<div className='flex min-w-0 flex-1 flex-col gap-3 overflow-hidden'>
|
||||
<div className='min-w-0 text-sm'>
|
||||
{!isTagRow && (
|
||||
<div className={labelClass}>
|
||||
#{sensitiveVisible ? row.original.id : SENSITIVE_MASK}
|
||||
</div>
|
||||
)}
|
||||
{nameCell}
|
||||
</div>
|
||||
<div className='min-w-0'>
|
||||
<div className={cn('mb-1', labelClass)}>
|
||||
{fieldLabels.balance}
|
||||
</div>
|
||||
<div className='min-w-0 overflow-hidden text-sm'>
|
||||
{balanceCell ?? (
|
||||
<span className='text-muted-foreground'>-</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column (sits on the right, content left-aligned). A single
|
||||
grid with content-sized columns keeps Priority/Weight and
|
||||
Response/Last Tested aligned without wasting horizontal space. */}
|
||||
<div className='grid shrink-0 grid-cols-[auto_auto] items-center gap-x-3 gap-y-1'>
|
||||
<span className={labelClass}>{t('Priority')}</span>
|
||||
<span className={labelClass}>{t('Weight')}</span>
|
||||
<div className='flex justify-start'>{priorityCell}</div>
|
||||
<div className='flex justify-start'>{weightCell}</div>
|
||||
<span className={cn('mt-2', labelClass)}>
|
||||
{fieldLabels.response_time}
|
||||
</span>
|
||||
<span className={cn('mt-2', labelClass)}>
|
||||
{fieldLabels.test_time}
|
||||
</span>
|
||||
<div className='overflow-hidden text-sm'>
|
||||
{responseCell ?? <span className='text-muted-foreground'>-</span>}
|
||||
</div>
|
||||
<div className='overflow-hidden text-sm'>
|
||||
{testCell ?? <span className='text-muted-foreground'>-</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last row: groups span the full width, showing every group (no label) */}
|
||||
<div className='min-w-0'>
|
||||
{groups.length > 0 ? (
|
||||
<div className='-ml-1.5 flex flex-wrap gap-1'>
|
||||
{groups.map((g) => (
|
||||
<GroupBadge
|
||||
key={g}
|
||||
group={g}
|
||||
label={sensitiveVisible ? undefined : SENSITIVE_MASK}
|
||||
size='sm'
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className='text-muted-foreground text-sm'>-</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ChannelRowActionsLayoutContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoized so each card only re-renders when its own react-table row reference
|
||||
* changes, instead of every card re-rendering whenever the parent table state
|
||||
* (filters, pagination, sensitive toggle, etc.) updates.
|
||||
*/
|
||||
export const ChannelCard = memo(ChannelCardComponent)
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { createContext } from 'react'
|
||||
|
||||
/**
|
||||
* Where channel row-derived controls are being rendered. Card view can tune
|
||||
* compact display details while table view keeps the full desktop treatment.
|
||||
*/
|
||||
export type ChannelRowActionsLayout = 'table' | 'card'
|
||||
|
||||
export const ChannelRowActionsLayoutContext =
|
||||
createContext<ChannelRowActionsLayout>('table')
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
|
||||
|
||||
import { ROLE } from '@/lib/roles'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
import { getTaskPluginOptions } from '../api'
|
||||
import { CHANNEL_TYPE_TASK_PLUGIN } from '../constants'
|
||||
import { ChannelTypeLogo, TaskPluginChannelBadge } from './channel-type-badge'
|
||||
|
||||
vi.mock('../api', () => ({ getTaskPluginOptions: vi.fn() }))
|
||||
vi.mock('@/lib/lobe-icon', () => ({
|
||||
getLobeIcon: (name: string) => <svg data-testid={name} />,
|
||||
}))
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
const originalAuth = useAuthStore.getState().auth
|
||||
let client: QueryClient
|
||||
beforeEach(() => {
|
||||
client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
useAuthStore.setState({
|
||||
auth: {
|
||||
...originalAuth,
|
||||
user: { id: 1, username: 'root', role: ROLE.SUPER_ADMIN },
|
||||
},
|
||||
})
|
||||
vi.mocked(getTaskPluginOptions).mockReset()
|
||||
})
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
client.clear()
|
||||
useAuthStore.setState({ auth: originalAuth })
|
||||
})
|
||||
|
||||
function ChannelTypeHarness(props: { pluginKey?: string }) {
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<TaskPluginChannelBadge pluginKey={props.pluginKey} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
test('identifies a bound plugin by its metadata and retains the task-plugin type', async () => {
|
||||
vi.mocked(getTaskPluginOptions).mockResolvedValue([
|
||||
{ key: 'incho', name: 'Incho AI', icon: 'text:IA', models: [] },
|
||||
])
|
||||
render(<ChannelTypeHarness pluginKey='incho' />)
|
||||
expect(await screen.findByText('Incho AI')).toBeInTheDocument()
|
||||
expect(screen.getByText('IA')).toBeInTheDocument()
|
||||
expect(screen.getByText('Task Plugin')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('OpenAI.Color')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('keeps the binding key when the plugin is unavailable', async () => {
|
||||
vi.mocked(getTaskPluginOptions).mockResolvedValue([])
|
||||
render(<ChannelTypeHarness pluginKey='removed-plugin' />)
|
||||
expect(await screen.findByText('removed-plugin')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('OpenAI.Color')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('does not request plugin metadata without bind permission', () => {
|
||||
useAuthStore.setState({
|
||||
auth: {
|
||||
...originalAuth,
|
||||
user: { id: 2, username: 'admin', role: ROLE.ADMIN },
|
||||
},
|
||||
})
|
||||
render(<ChannelTypeHarness pluginKey='incho' />)
|
||||
expect(screen.getByText('incho')).toBeInTheDocument()
|
||||
expect(getTaskPluginOptions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('unbound task channels use a neutral icon while regular providers keep their logo', () => {
|
||||
const { rerender } = render(
|
||||
<ChannelTypeLogo type={CHANNEL_TYPE_TASK_PLUGIN} />
|
||||
)
|
||||
expect(screen.queryByTestId('OpenAI.Color')).not.toBeInTheDocument()
|
||||
rerender(<ChannelTypeLogo type={1} />)
|
||||
expect(screen.getByTestId('OpenAI.Color')).toBeInTheDocument()
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Puzzle, Server } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { ProviderBadge } from '@/components/provider-badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { PluginIcon } from '@/features/task-plugins/components/plugin-icon'
|
||||
import type { PluginIconInput } from '@/features/task-plugins/lib/plugin-icon'
|
||||
import {
|
||||
ADMIN_PERMISSION_ACTIONS,
|
||||
ADMIN_PERMISSION_RESOURCES,
|
||||
hasPermission,
|
||||
} from '@/lib/admin-permissions'
|
||||
import { getLobeIcon } from '@/lib/lobe-icon'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
import { getTaskPluginOptions } from '../api'
|
||||
import { CHANNEL_TYPE_OPTIONS, CHANNEL_TYPE_TASK_PLUGIN } from '../constants'
|
||||
import { getChannelTypeIcon } from '../lib/channel-utils'
|
||||
|
||||
export function ChannelTypeLogo(props: {
|
||||
type: number
|
||||
plugin?: PluginIconInput
|
||||
size?: number
|
||||
className?: string
|
||||
}) {
|
||||
const size = props.size ?? 16
|
||||
if (props.type === CHANNEL_TYPE_TASK_PLUGIN && props.plugin) {
|
||||
return <PluginIcon plugin={props.plugin} size={size} />
|
||||
}
|
||||
const isKnownType = CHANNEL_TYPE_OPTIONS.some(
|
||||
(option) => option.value === props.type
|
||||
)
|
||||
if (props.type === CHANNEL_TYPE_TASK_PLUGIN || !isKnownType) {
|
||||
const Icon = props.type === CHANNEL_TYPE_TASK_PLUGIN ? Puzzle : Server
|
||||
return (
|
||||
<Icon
|
||||
className={cn('text-muted-foreground shrink-0', props.className)}
|
||||
size={size}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className={cn('inline-flex shrink-0', props.className)}>
|
||||
{getLobeIcon(`${getChannelTypeIcon(props.type)}.Color`, size)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function TaskPluginChannelBadge(props: { pluginKey?: string }) {
|
||||
const { t } = useTranslation()
|
||||
const user = useAuthStore((s) => s.auth.user)
|
||||
const canBind = hasPermission(
|
||||
user,
|
||||
ADMIN_PERMISSION_RESOURCES.TASK_PLUGIN,
|
||||
ADMIN_PERMISSION_ACTIONS.BIND
|
||||
)
|
||||
const query = useQuery({
|
||||
queryKey: ['task-plugin-options'],
|
||||
queryFn: getTaskPluginOptions,
|
||||
enabled: Boolean(props.pluginKey) && canBind,
|
||||
staleTime: 60 * 1000,
|
||||
})
|
||||
const plugin = query.data?.find((item) => item.key === props.pluginKey)
|
||||
const label = plugin?.name || props.pluginKey || t('Task Plugin')
|
||||
const iconInput =
|
||||
plugin ?? (props.pluginKey ? { key: props.pluginKey } : undefined)
|
||||
|
||||
return (
|
||||
<div
|
||||
className='flex max-w-full min-w-0 items-center gap-1.5'
|
||||
title={
|
||||
props.pluginKey
|
||||
? `${t('Task Plugin')} · ${label} (${props.pluginKey})`
|
||||
: label
|
||||
}
|
||||
>
|
||||
<ProviderBadge
|
||||
iconNode={
|
||||
<ChannelTypeLogo
|
||||
type={CHANNEL_TYPE_TASK_PLUGIN}
|
||||
plugin={iconInput}
|
||||
size={18}
|
||||
/>
|
||||
}
|
||||
label={label}
|
||||
colorText={false}
|
||||
copyable={false}
|
||||
showDot={false}
|
||||
className='min-w-0 overflow-hidden'
|
||||
/>
|
||||
{props.pluginKey && (
|
||||
<StatusBadge
|
||||
label={t('Task Plugin')}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
showDot={false}
|
||||
className='shrink-0 text-[10px]'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useChannels } from './channels-provider'
|
||||
import { BalanceQueryDialog } from './dialogs/balance-query-dialog'
|
||||
import { ChannelTestDialog } from './dialogs/channel-test-dialog'
|
||||
import { CopyChannelDialog } from './dialogs/copy-channel-dialog'
|
||||
import { EditTagDialog } from './dialogs/edit-tag-dialog'
|
||||
import { FetchModelsDialog } from './dialogs/fetch-models-dialog'
|
||||
import { MultiKeyManageDialog } from './dialogs/multi-key-manage-dialog'
|
||||
import { OllamaModelsDialog } from './dialogs/ollama-models-dialog'
|
||||
import { TagBatchEditDialog } from './dialogs/tag-batch-edit-dialog'
|
||||
import { UpstreamUpdateDialog } from './dialogs/upstream-update-dialog'
|
||||
import { ChannelMutateDrawer } from './drawers/channel-mutate-drawer'
|
||||
|
||||
export function ChannelsDialogs() {
|
||||
const { open, setOpen, currentRow, upstream } = useChannels()
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Channel Create/Update Drawer */}
|
||||
<ChannelMutateDrawer
|
||||
open={open === 'create-channel' || open === 'update-channel'}
|
||||
onOpenChange={(v) => !v && setOpen(null)}
|
||||
currentRow={open === 'update-channel' ? currentRow : null}
|
||||
/>
|
||||
|
||||
{/* Test Channel Dialog */}
|
||||
<ChannelTestDialog
|
||||
open={open === 'test-channel'}
|
||||
onOpenChange={(v) => !v && setOpen(null)}
|
||||
/>
|
||||
|
||||
{/* Balance Query Dialog */}
|
||||
<BalanceQueryDialog
|
||||
open={open === 'balance-query'}
|
||||
onOpenChange={(v) => !v && setOpen(null)}
|
||||
/>
|
||||
|
||||
{/* Fetch Models Dialog */}
|
||||
<FetchModelsDialog
|
||||
open={open === 'fetch-models'}
|
||||
onOpenChange={(v) => !v && setOpen(null)}
|
||||
/>
|
||||
|
||||
{/* Ollama Models Dialog */}
|
||||
<OllamaModelsDialog
|
||||
open={open === 'ollama-models'}
|
||||
onOpenChange={(v) => !v && setOpen(null)}
|
||||
/>
|
||||
|
||||
{/* Copy Channel Dialog */}
|
||||
<CopyChannelDialog
|
||||
open={open === 'copy-channel'}
|
||||
onOpenChange={(v) => !v && setOpen(null)}
|
||||
/>
|
||||
|
||||
{/* Multi-Key Management Dialog */}
|
||||
<MultiKeyManageDialog
|
||||
open={open === 'multi-key-manage'}
|
||||
onOpenChange={(v) => !v && setOpen(null)}
|
||||
/>
|
||||
|
||||
{/* Tag Batch Edit Dialog */}
|
||||
<TagBatchEditDialog
|
||||
open={open === 'tag-batch-edit'}
|
||||
onOpenChange={(v) => !v && setOpen(null)}
|
||||
/>
|
||||
|
||||
{/* Edit Tag Dialog */}
|
||||
<EditTagDialog
|
||||
open={open === 'edit-tag'}
|
||||
onOpenChange={(v) => !v && setOpen(null)}
|
||||
/>
|
||||
|
||||
{/* Upstream Model Update Dialog */}
|
||||
<UpstreamUpdateDialog
|
||||
open={upstream.showModal}
|
||||
addModels={upstream.addModels}
|
||||
removeModels={upstream.removeModels}
|
||||
preferredTab={upstream.preferredTab}
|
||||
confirmLoading={upstream.applyLoading}
|
||||
onConfirm={upstream.applyUpdates}
|
||||
onCancel={upstream.closeModal}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Plus,
|
||||
MoreHorizontal,
|
||||
Settings2,
|
||||
Trash2,
|
||||
Tags,
|
||||
TestTube,
|
||||
DollarSign,
|
||||
ListChecks,
|
||||
SortAsc,
|
||||
RefreshCw,
|
||||
ArrowUpFromLine,
|
||||
} from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import {
|
||||
ADMIN_PERMISSION_ACTIONS,
|
||||
ADMIN_PERMISSION_RESOURCES,
|
||||
hasPermission,
|
||||
} from '@/lib/admin-permissions'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
import {
|
||||
handleDeleteAllDisabled,
|
||||
handleFixAbilities,
|
||||
handleTestAllChannels,
|
||||
handleUpdateAllBalances,
|
||||
} from '../lib'
|
||||
import { useChannels } from './channels-provider'
|
||||
|
||||
export function ChannelsPrimaryButtons() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
setOpen,
|
||||
setCurrentRow,
|
||||
enableTagMode,
|
||||
setEnableTagMode,
|
||||
idSort,
|
||||
setIdSort,
|
||||
batchMode,
|
||||
setBatchMode,
|
||||
upstream,
|
||||
} = useChannels()
|
||||
const queryClient = useQueryClient()
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [showConsistencyDialog, setShowConsistencyDialog] = useState(false)
|
||||
const [isRepairingConsistency, setIsRepairingConsistency] = useState(false)
|
||||
const currentUser = useAuthStore((s) => s.auth.user)
|
||||
const canEditSensitive = hasPermission(
|
||||
currentUser,
|
||||
ADMIN_PERMISSION_RESOURCES.CHANNEL,
|
||||
ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
|
||||
)
|
||||
|
||||
const handleTagModeToggle = (checked: boolean) => {
|
||||
localStorage.setItem('enable-tag-mode', String(checked))
|
||||
setEnableTagMode(checked)
|
||||
}
|
||||
|
||||
const handleIdSortToggle = (checked: boolean) => {
|
||||
localStorage.setItem('channels-id-sort', String(checked))
|
||||
setIdSort(checked)
|
||||
}
|
||||
|
||||
const handleBatchModeToggle = (checked: boolean) => {
|
||||
setBatchMode(checked)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex items-center gap-2'>
|
||||
{/* Desktop: Toggle switches visible */}
|
||||
<div className='hidden items-center gap-2 rounded-md border px-3 py-1.5 sm:flex'>
|
||||
<ListChecks className='text-muted-foreground h-4 w-4' />
|
||||
<Label
|
||||
htmlFor='channel-batch-mode'
|
||||
className='cursor-pointer text-sm'
|
||||
>
|
||||
{t('Batch Operations')}
|
||||
</Label>
|
||||
<Switch
|
||||
id='channel-batch-mode'
|
||||
checked={batchMode}
|
||||
onCheckedChange={handleBatchModeToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='hidden items-center gap-2 rounded-md border px-3 py-1.5 sm:flex'>
|
||||
<Tags className='text-muted-foreground h-4 w-4' />
|
||||
<Label htmlFor='tag-mode' className='cursor-pointer text-sm'>
|
||||
{t('Tag Mode')}
|
||||
</Label>
|
||||
<Switch
|
||||
id='tag-mode'
|
||||
checked={enableTagMode}
|
||||
onCheckedChange={handleTagModeToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='hidden items-center gap-2 rounded-md border px-3 py-1.5 sm:flex'>
|
||||
<SortAsc className='text-muted-foreground h-4 w-4' />
|
||||
<Label htmlFor='id-sort' className='cursor-pointer text-sm'>
|
||||
{t('Sort by ID')}
|
||||
</Label>
|
||||
<Switch
|
||||
id='id-sort'
|
||||
checked={idSort}
|
||||
onCheckedChange={handleIdSortToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Create Channel */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<span className='inline-flex' />}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!canEditSensitive) return
|
||||
setCurrentRow(null)
|
||||
setOpen('create-channel')
|
||||
}}
|
||||
size='sm'
|
||||
disabled={!canEditSensitive}
|
||||
>
|
||||
<Plus className='h-4 w-4' />
|
||||
<span className='max-sm:hidden'>{t('Create Channel')}</span>
|
||||
<span className='sm:hidden'>{t('Create')}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{!canEditSensitive && (
|
||||
<TooltipContent>
|
||||
{t('No permission to perform this action')}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
|
||||
{/* More Actions */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant='outline' size='sm' />}>
|
||||
<MoreHorizontal className='h-4 w-4' />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-56'>
|
||||
{/* Mobile-only: toggle switches */}
|
||||
<DropdownMenuCheckboxItem
|
||||
className='sm:hidden'
|
||||
checked={batchMode}
|
||||
onCheckedChange={handleBatchModeToggle}
|
||||
>
|
||||
<ListChecks className='mr-2 h-4 w-4' />
|
||||
{t('Batch Operations')}
|
||||
</DropdownMenuCheckboxItem>
|
||||
|
||||
<DropdownMenuCheckboxItem
|
||||
className='sm:hidden'
|
||||
checked={enableTagMode}
|
||||
onCheckedChange={handleTagModeToggle}
|
||||
>
|
||||
<Tags className='mr-2 h-4 w-4' />
|
||||
{t('Tag Mode')}
|
||||
</DropdownMenuCheckboxItem>
|
||||
|
||||
<DropdownMenuCheckboxItem
|
||||
className='sm:hidden'
|
||||
checked={idSort}
|
||||
onCheckedChange={handleIdSortToggle}
|
||||
>
|
||||
<SortAsc className='mr-2 h-4 w-4' />
|
||||
{t('Sort by ID')}
|
||||
</DropdownMenuCheckboxItem>
|
||||
|
||||
<DropdownMenuSeparator className='sm:hidden' />
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
handleTestAllChannels(queryClient)
|
||||
}}
|
||||
>
|
||||
{t('Test All Channels')}
|
||||
<DropdownMenuShortcut>
|
||||
<TestTube className='h-4 w-4' />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
handleUpdateAllBalances(queryClient)
|
||||
}}
|
||||
>
|
||||
{t('Update All Balances')}
|
||||
<DropdownMenuShortcut>
|
||||
<DollarSign className='h-4 w-4' />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => upstream.detectAllUpdates()}
|
||||
disabled={upstream.detectAllLoading}
|
||||
>
|
||||
{t('Detect All Upstream Updates')}
|
||||
<DropdownMenuShortcut>
|
||||
<RefreshCw className='h-4 w-4' />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => upstream.applyAllUpdates()}
|
||||
disabled={upstream.applyAllLoading}
|
||||
>
|
||||
{t('Apply All Upstream Updates')}
|
||||
<DropdownMenuShortcut>
|
||||
<ArrowUpFromLine className='h-4 w-4' />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault()
|
||||
setShowConsistencyDialog(true)
|
||||
}}
|
||||
>
|
||||
{t('Repair Channel Consistency')}
|
||||
<DropdownMenuShortcut>
|
||||
<Settings2 className='h-4 w-4' />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault()
|
||||
if (!canEditSensitive) return
|
||||
setShowDeleteDialog(true)
|
||||
}}
|
||||
disabled={!canEditSensitive}
|
||||
className='text-destructive focus:text-destructive'
|
||||
>
|
||||
{t('Delete All Disabled')}
|
||||
<DropdownMenuShortcut>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
title={t('Delete All Disabled Channels?')}
|
||||
desc={t(
|
||||
'This will permanently delete all manually and automatically disabled channels. This action cannot be undone.'
|
||||
)}
|
||||
destructive
|
||||
handleConfirm={() => {
|
||||
if (!canEditSensitive) return
|
||||
handleDeleteAllDisabled(queryClient, (_count) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Deleted ${_count} channels`)
|
||||
})
|
||||
setShowDeleteDialog(false)
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showConsistencyDialog}
|
||||
onOpenChange={setShowConsistencyDialog}
|
||||
title={t('Repair channel consistency?')}
|
||||
desc={t(
|
||||
'This will rebuild the channel routing index from every channel configuration, including supported models, groups, priorities, and weights. Routing may be briefly incomplete while the rebuild is running. Continue?'
|
||||
)}
|
||||
confirmText={t('Repair')}
|
||||
isLoading={isRepairingConsistency}
|
||||
handleConfirm={async () => {
|
||||
setIsRepairingConsistency(true)
|
||||
try {
|
||||
await handleFixAbilities(queryClient, (_result) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Repair channel consistency result:', _result)
|
||||
})
|
||||
setShowConsistencyDialog(false)
|
||||
} finally {
|
||||
setIsRepairingConsistency(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
|
||||
import { useChannelUpstreamUpdates } from '../hooks/use-channel-upstream-updates'
|
||||
import { channelsQueryKeys } from '../lib'
|
||||
import type { Channel } from '../types'
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
type DialogType =
|
||||
| 'create-channel'
|
||||
| 'update-channel'
|
||||
| 'test-channel'
|
||||
| 'balance-query'
|
||||
| 'fetch-models'
|
||||
| 'ollama-models'
|
||||
| 'multi-key-manage'
|
||||
| 'tag-batch-edit'
|
||||
| 'edit-tag'
|
||||
| 'copy-channel'
|
||||
| null
|
||||
|
||||
type UpstreamUpdateState = ReturnType<typeof useChannelUpstreamUpdates>
|
||||
|
||||
type ChannelsContextType = {
|
||||
open: DialogType
|
||||
setOpen: (open: DialogType) => void
|
||||
currentRow: Channel | null
|
||||
setCurrentRow: (row: Channel | null) => void
|
||||
currentTag: string | null
|
||||
setCurrentTag: (tag: string | null) => void
|
||||
enableTagMode: boolean
|
||||
setEnableTagMode: (enabled: boolean) => void
|
||||
idSort: boolean
|
||||
setIdSort: (enabled: boolean) => void
|
||||
batchMode: boolean
|
||||
setBatchMode: (enabled: boolean) => void
|
||||
sensitiveVisible: boolean
|
||||
setSensitiveVisible: (visible: boolean) => void
|
||||
upstream: UpstreamUpdateState
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Context
|
||||
// ============================================================================
|
||||
|
||||
const ChannelsContext = createContext<ChannelsContextType | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Provider
|
||||
// ============================================================================
|
||||
|
||||
export function ChannelsProvider({ children }: { children: React.ReactNode }) {
|
||||
const [open, setOpen] = useState<DialogType>(null)
|
||||
const [currentRow, setCurrentRow] = useState<Channel | null>(null)
|
||||
const [currentTag, setCurrentTag] = useState<string | null>(null)
|
||||
const [enableTagMode, setEnableTagMode] = useState(() => {
|
||||
return localStorage.getItem('enable-tag-mode') === 'true'
|
||||
})
|
||||
const [idSort, setIdSort] = useState(() => {
|
||||
return localStorage.getItem('channels-id-sort') === 'true'
|
||||
})
|
||||
const [batchMode, setBatchMode] = useState(false)
|
||||
const [sensitiveVisible, setSensitiveVisible] = useState(true)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const refreshChannels = useCallback(async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: channelsQueryKeys.all })
|
||||
}, [queryClient])
|
||||
const upstream = useChannelUpstreamUpdates(refreshChannels)
|
||||
|
||||
// useState setters are stable, so the context value only needs to change when
|
||||
// an actual state value changes. Memoizing avoids handing every consumer
|
||||
// (including all channel cards/cells) a brand-new object on each render.
|
||||
const value = useMemo<ChannelsContextType>(
|
||||
() => ({
|
||||
open,
|
||||
setOpen,
|
||||
currentRow,
|
||||
setCurrentRow,
|
||||
currentTag,
|
||||
setCurrentTag,
|
||||
enableTagMode,
|
||||
setEnableTagMode,
|
||||
idSort,
|
||||
setIdSort,
|
||||
batchMode,
|
||||
setBatchMode,
|
||||
sensitiveVisible,
|
||||
setSensitiveVisible,
|
||||
upstream,
|
||||
}),
|
||||
[
|
||||
open,
|
||||
currentRow,
|
||||
currentTag,
|
||||
enableTagMode,
|
||||
idSort,
|
||||
batchMode,
|
||||
sensitiveVisible,
|
||||
upstream,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<ChannelsContext.Provider value={value}>
|
||||
{children}
|
||||
</ChannelsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hook
|
||||
// ============================================================================
|
||||
|
||||
export function useChannels() {
|
||||
const context = useContext(ChannelsContext)
|
||||
if (!context) {
|
||||
throw new Error('useChannels must be used within ChannelsProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import type {
|
||||
ColumnFiltersState,
|
||||
OnChangeFn,
|
||||
SortingState,
|
||||
Row,
|
||||
} from '@tanstack/react-table'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
DISABLED_ROW_DESKTOP,
|
||||
DISABLED_ROW_MOBILE,
|
||||
DataTablePage,
|
||||
useDebouncedColumnFilter,
|
||||
useDataTable,
|
||||
} from '@/components/data-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { useTableUrlState } from '@/hooks/use-table-url-state'
|
||||
|
||||
import { getChannels, searchChannels, getGroups } from '../api'
|
||||
import {
|
||||
DEFAULT_PAGE_SIZE,
|
||||
CHANNEL_STATUS,
|
||||
CHANNEL_STATUS_OPTIONS,
|
||||
} from '../constants'
|
||||
import {
|
||||
channelsQueryKeys,
|
||||
aggregateChannelsByTag,
|
||||
getChannelTableRowId,
|
||||
isTagAggregateRow,
|
||||
getChannelTypeLabel,
|
||||
} from '../lib'
|
||||
import type { Channel, ChannelSortBy } from '../types'
|
||||
import { ChannelCard } from './channel-card'
|
||||
import { ChannelTypeLogo } from './channel-type-badge'
|
||||
import { useChannelsColumns } from './channels-columns'
|
||||
import { useChannels } from './channels-provider'
|
||||
import { DataTableBulkActions } from './data-table-bulk-actions'
|
||||
|
||||
const route = getRouteApi('/_authenticated/channels/')
|
||||
const CHANNELS_COLUMN_VISIBILITY_STORAGE_KEY = 'channels:column-visibility'
|
||||
const CHANNELS_COLUMN_SIZING_STORAGE_KEY = 'channels:column-sizing'
|
||||
const CHANNELS_VIEW_MODE_STORAGE_KEY = 'channels:view-mode'
|
||||
const CHANNELS_STATUS_FILTER_STORAGE_KEY = 'channel-status-filter'
|
||||
|
||||
const CHANNEL_SORTABLE_COLUMNS = new Set<ChannelSortBy>([
|
||||
'id',
|
||||
'name',
|
||||
'priority',
|
||||
'balance',
|
||||
'response_time',
|
||||
'test_time',
|
||||
])
|
||||
|
||||
function isDisabledChannelRow(channel: Channel) {
|
||||
return (
|
||||
!isTagAggregateRow(channel) && channel.status !== CHANNEL_STATUS.ENABLED
|
||||
)
|
||||
}
|
||||
|
||||
export function ChannelsTable() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
enableTagMode,
|
||||
idSort,
|
||||
batchMode,
|
||||
sensitiveVisible,
|
||||
setSensitiveVisible,
|
||||
} = useChannels()
|
||||
const isMobile = useMediaQuery('(max-width: 640px)')
|
||||
|
||||
// Table state
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
|
||||
// URL state management
|
||||
const {
|
||||
globalFilter,
|
||||
onGlobalFilterChange,
|
||||
columnFilters,
|
||||
onColumnFiltersChange,
|
||||
pagination,
|
||||
onPaginationChange,
|
||||
ensurePageInRange,
|
||||
} = useTableUrlState({
|
||||
search: route.useSearch(),
|
||||
navigate: route.useNavigate(),
|
||||
pagination: {
|
||||
defaultPage: 1,
|
||||
defaultPageSize: isMobile ? 10 : DEFAULT_PAGE_SIZE,
|
||||
},
|
||||
globalFilter: { enabled: true, key: 'filter' },
|
||||
columnFilters: [
|
||||
{
|
||||
columnId: 'status',
|
||||
searchKey: 'status',
|
||||
type: 'array',
|
||||
deserialize: (value) => {
|
||||
if (value !== undefined) return value
|
||||
const stored = localStorage.getItem(
|
||||
CHANNELS_STATUS_FILTER_STORAGE_KEY
|
||||
)
|
||||
return stored === 'enabled' || stored === 'disabled' ? [stored] : []
|
||||
},
|
||||
},
|
||||
{ columnId: 'type', searchKey: 'type', type: 'array' },
|
||||
{ columnId: 'group', searchKey: 'group', type: 'array' },
|
||||
{ columnId: 'model', searchKey: 'model', type: 'string' },
|
||||
],
|
||||
})
|
||||
|
||||
const handleColumnFiltersChange: OnChangeFn<ColumnFiltersState> = (
|
||||
updater
|
||||
) => {
|
||||
onColumnFiltersChange((previous) => {
|
||||
const next = typeof updater === 'function' ? updater(previous) : updater
|
||||
const status = next.find((f) => f.id === 'status')?.value as
|
||||
| string[]
|
||||
| undefined
|
||||
localStorage.setItem(
|
||||
CHANNELS_STATUS_FILTER_STORAGE_KEY,
|
||||
status?.[0] ?? 'all'
|
||||
)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Extract filters from column filters
|
||||
const statusFilter =
|
||||
(columnFilters.find((f) => f.id === 'status')?.value as string[]) || []
|
||||
const typeFilter = useMemo(
|
||||
() => (columnFilters.find((f) => f.id === 'type')?.value as string[]) || [],
|
||||
[columnFilters]
|
||||
)
|
||||
const groupFilter =
|
||||
(columnFilters.find((f) => f.id === 'group')?.value as string[]) || []
|
||||
const {
|
||||
value: modelFilter,
|
||||
inputValue: modelFilterInput,
|
||||
onChange: onModelFilterInputChange,
|
||||
onCompositionStart: onModelFilterCompositionStart,
|
||||
onCompositionEnd: onModelFilterCompositionEnd,
|
||||
resetInput: resetModelFilterInput,
|
||||
} = useDebouncedColumnFilter({
|
||||
columnFilters,
|
||||
columnId: 'model',
|
||||
onColumnFiltersChange,
|
||||
})
|
||||
|
||||
// Determine whether to use search or regular list API
|
||||
const shouldSearch = Boolean(globalFilter?.trim() || modelFilter.trim())
|
||||
|
||||
const sortParams = useMemo(() => {
|
||||
const activeSort = sorting[0]
|
||||
if (
|
||||
!activeSort ||
|
||||
!CHANNEL_SORTABLE_COLUMNS.has(activeSort.id as ChannelSortBy)
|
||||
) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
sort_by: activeSort.id as ChannelSortBy,
|
||||
sort_order: activeSort.desc ? 'desc' : 'asc',
|
||||
} as const
|
||||
}, [sorting])
|
||||
|
||||
const handleSortingChange: OnChangeFn<SortingState> = (updater) => {
|
||||
setSorting((previous) => {
|
||||
const next = typeof updater === 'function' ? updater(previous) : updater
|
||||
if (pagination.pageIndex > 0) {
|
||||
onPaginationChange({ ...pagination, pageIndex: 0 })
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Fetch groups for filter
|
||||
const { data: groupsData } = useQuery({
|
||||
queryKey: ['groups'],
|
||||
queryFn: getGroups,
|
||||
})
|
||||
|
||||
const groupOptions = useMemo(
|
||||
() =>
|
||||
(groupsData?.data || []).map((g) => ({
|
||||
label: g,
|
||||
value: g,
|
||||
})),
|
||||
[groupsData]
|
||||
)
|
||||
|
||||
// Fetch channels data
|
||||
// eslint-disable-next-line @tanstack/query/exhaustive-deps
|
||||
const { data, isLoading, isFetching } = useQuery({
|
||||
queryKey: channelsQueryKeys.list({
|
||||
keyword: globalFilter,
|
||||
model: modelFilter,
|
||||
group:
|
||||
groupFilter.length > 0 && !groupFilter.includes('all')
|
||||
? groupFilter[0]
|
||||
: undefined,
|
||||
status:
|
||||
statusFilter.length > 0 && !statusFilter.includes('all')
|
||||
? statusFilter[0]
|
||||
: undefined,
|
||||
type:
|
||||
typeFilter.length > 0 && !typeFilter.includes('all')
|
||||
? Number(typeFilter[0])
|
||||
: undefined,
|
||||
tag_mode: enableTagMode,
|
||||
id_sort: idSort,
|
||||
...sortParams,
|
||||
p: pagination.pageIndex + 1,
|
||||
page_size: pagination.pageSize,
|
||||
}),
|
||||
queryFn: async () => {
|
||||
if (shouldSearch) {
|
||||
return searchChannels({
|
||||
keyword: globalFilter,
|
||||
model: modelFilter,
|
||||
group:
|
||||
groupFilter.length > 0 && !groupFilter.includes('all')
|
||||
? groupFilter[0]
|
||||
: undefined,
|
||||
status:
|
||||
statusFilter.length > 0 && !statusFilter.includes('all')
|
||||
? statusFilter[0]
|
||||
: undefined,
|
||||
type:
|
||||
typeFilter.length > 0 && !typeFilter.includes('all')
|
||||
? Number(typeFilter[0])
|
||||
: undefined,
|
||||
tag_mode: enableTagMode,
|
||||
id_sort: idSort,
|
||||
...sortParams,
|
||||
p: pagination.pageIndex + 1,
|
||||
page_size: pagination.pageSize,
|
||||
})
|
||||
} else {
|
||||
return getChannels({
|
||||
group:
|
||||
groupFilter.length > 0 && !groupFilter.includes('all')
|
||||
? groupFilter[0]
|
||||
: undefined,
|
||||
status:
|
||||
statusFilter.length > 0 && !statusFilter.includes('all')
|
||||
? statusFilter[0]
|
||||
: undefined,
|
||||
type:
|
||||
typeFilter.length > 0 && !typeFilter.includes('all')
|
||||
? Number(typeFilter[0])
|
||||
: undefined,
|
||||
tag_mode: enableTagMode,
|
||||
id_sort: idSort,
|
||||
...sortParams,
|
||||
p: pagination.pageIndex + 1,
|
||||
page_size: pagination.pageSize,
|
||||
})
|
||||
}
|
||||
},
|
||||
placeholderData: (previousData) => previousData,
|
||||
})
|
||||
|
||||
// Apply tag aggregation if tag mode is enabled
|
||||
const channels = useMemo(() => {
|
||||
const rawChannels = data?.data?.items || []
|
||||
|
||||
if (enableTagMode && rawChannels.length > 0) {
|
||||
return aggregateChannelsByTag(rawChannels)
|
||||
}
|
||||
|
||||
return rawChannels
|
||||
}, [data, enableTagMode])
|
||||
|
||||
const totalCount = data?.data?.total || 0
|
||||
const typeCounts = data?.data?.type_counts
|
||||
|
||||
// Columns configuration
|
||||
const columns = useChannelsColumns({ enableSelection: batchMode })
|
||||
|
||||
// React Table instance
|
||||
const { table } = useDataTable({
|
||||
data: channels,
|
||||
columns,
|
||||
totalCount,
|
||||
sorting,
|
||||
initialColumnVisibility: {
|
||||
models: false,
|
||||
tag: false,
|
||||
},
|
||||
columnVisibilityStorageKey: CHANNELS_COLUMN_VISIBILITY_STORAGE_KEY,
|
||||
columnSizingStorageKey: isMobile
|
||||
? false
|
||||
: CHANNELS_COLUMN_SIZING_STORAGE_KEY,
|
||||
columnFilters,
|
||||
pagination,
|
||||
globalFilter,
|
||||
enableRowSelection: batchMode
|
||||
? (row: Row<Channel>) => !isTagAggregateRow(row.original)
|
||||
: false,
|
||||
onSortingChange: handleSortingChange,
|
||||
onColumnFiltersChange: handleColumnFiltersChange,
|
||||
onPaginationChange,
|
||||
onGlobalFilterChange,
|
||||
getRowId: getChannelTableRowId,
|
||||
getSubRows: (row: Channel & { children?: Channel[] }) => row.children,
|
||||
manualPagination: true,
|
||||
manualSorting: true,
|
||||
manualFiltering: true,
|
||||
withExpandedRowModel: true,
|
||||
enableColumnResizing: !isMobile,
|
||||
ensurePageInRange,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!batchMode) {
|
||||
table.resetRowSelection()
|
||||
}
|
||||
}, [batchMode, table])
|
||||
|
||||
// Prepare filter options from existing channel types only.
|
||||
const typeFilterOptions = useMemo(() => {
|
||||
const counts = typeCounts || {}
|
||||
const typeIds = Object.entries(counts)
|
||||
.map(([type, count]) => ({
|
||||
type: Number(type),
|
||||
count: Number(count) || 0,
|
||||
}))
|
||||
.filter((item) => item.type > 0 && item.count > 0)
|
||||
.sort((a, b) => {
|
||||
const labelA = t(getChannelTypeLabel(a.type))
|
||||
const labelB = t(getChannelTypeLabel(b.type))
|
||||
return labelA.localeCompare(labelB)
|
||||
})
|
||||
|
||||
const selectedType = typeFilter.find((value) => value !== 'all')
|
||||
if (selectedType) {
|
||||
const selectedTypeId = Number(selectedType)
|
||||
const alreadyIncluded = typeIds.some(
|
||||
(item) => item.type === selectedTypeId
|
||||
)
|
||||
if (selectedTypeId > 0 && !alreadyIncluded) {
|
||||
typeIds.push({
|
||||
type: selectedTypeId,
|
||||
count: Number(counts[selectedType]) || 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const totalTypes = Object.values(counts).reduce(
|
||||
(sum, count) => sum + (Number(count) || 0),
|
||||
0
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'All Types',
|
||||
value: 'all',
|
||||
count: totalTypes,
|
||||
},
|
||||
...typeIds.map((item) => {
|
||||
return {
|
||||
label: getChannelTypeLabel(item.type),
|
||||
value: String(item.type),
|
||||
count: item.count,
|
||||
iconNode: <ChannelTypeLogo type={item.type} size={16} />,
|
||||
}
|
||||
}),
|
||||
]
|
||||
}, [t, typeCounts, typeFilter])
|
||||
|
||||
const groupFilterOptions = [
|
||||
{ label: t('All Groups'), value: 'all' },
|
||||
...groupOptions.map((option) => ({
|
||||
...option,
|
||||
label: sensitiveVisible ? option.label : '••••',
|
||||
})),
|
||||
]
|
||||
|
||||
return (
|
||||
<DataTablePage
|
||||
table={table}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
emptyTitle={t('No Channels Found')}
|
||||
emptyDescription={t(
|
||||
'No channels available. Create your first channel to get started.'
|
||||
)}
|
||||
skeletonKeyPrefix='channel-skeleton'
|
||||
enableCardView
|
||||
viewModeStorageKey={CHANNELS_VIEW_MODE_STORAGE_KEY}
|
||||
renderCard={(row, { isSelected }) => (
|
||||
<ChannelCard row={row} isSelected={isSelected} />
|
||||
)}
|
||||
cardGridClassName='grid grid-cols-1 gap-3 sm:gap-4 lg:grid-cols-3'
|
||||
applyHeaderSize
|
||||
toolbarProps={{
|
||||
searchPlaceholder: t('Filter by name, ID, or key...'),
|
||||
searchDebounceMs: 500,
|
||||
onReset: () => {
|
||||
resetModelFilterInput()
|
||||
},
|
||||
additionalSearch: (
|
||||
<Input
|
||||
placeholder={t('Filter by model...')}
|
||||
value={modelFilterInput}
|
||||
onChange={onModelFilterInputChange}
|
||||
onCompositionStart={onModelFilterCompositionStart}
|
||||
onCompositionEnd={onModelFilterCompositionEnd}
|
||||
className='w-full sm:w-[150px] lg:w-[180px]'
|
||||
/>
|
||||
),
|
||||
filters: [
|
||||
{
|
||||
columnId: 'status',
|
||||
title: t('Status'),
|
||||
options: [...CHANNEL_STATUS_OPTIONS],
|
||||
singleSelect: true,
|
||||
},
|
||||
{
|
||||
columnId: 'type',
|
||||
title: t('Type'),
|
||||
options: typeFilterOptions,
|
||||
singleSelect: true,
|
||||
},
|
||||
{
|
||||
columnId: 'group',
|
||||
title: t('Group'),
|
||||
options: groupFilterOptions,
|
||||
singleSelect: true,
|
||||
},
|
||||
],
|
||||
preActions: (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() => setSensitiveVisible(!sensitiveVisible)}
|
||||
aria-label={sensitiveVisible ? t('Hide') : t('Show')}
|
||||
className='text-muted-foreground hover:text-foreground size-8'
|
||||
/>
|
||||
}
|
||||
>
|
||||
{sensitiveVisible ? <Eye /> : <EyeOff />}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{sensitiveVisible ? t('Hide') : t('Show')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
}}
|
||||
getRowClassName={(row, { isMobile }) => {
|
||||
if (!isDisabledChannelRow(row.original)) {
|
||||
return undefined
|
||||
}
|
||||
if (isMobile) {
|
||||
return DISABLED_ROW_MOBILE
|
||||
}
|
||||
return DISABLED_ROW_DESKTOP
|
||||
}}
|
||||
bulkActions={batchMode ? <DataTableBulkActions table={table} /> : null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { Power, PowerOff, Tag, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import {
|
||||
ADMIN_PERMISSION_ACTIONS,
|
||||
ADMIN_PERMISSION_RESOURCES,
|
||||
hasPermission,
|
||||
} from '@/lib/admin-permissions'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
import {
|
||||
handleBatchDelete,
|
||||
handleBatchDisable,
|
||||
handleBatchEnable,
|
||||
handleBatchSetTag,
|
||||
} from '../lib'
|
||||
import type { Channel } from '../types'
|
||||
|
||||
interface DataTableBulkActionsProps<TData> {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
export function DataTableBulkActions<TData>({
|
||||
table,
|
||||
}: DataTableBulkActionsProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [showTagDialog, setShowTagDialog] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [tagValue, setTagValue] = useState('')
|
||||
const currentUser = useAuthStore((s) => s.auth.user)
|
||||
const canEditSensitive = hasPermission(
|
||||
currentUser,
|
||||
ADMIN_PERMISSION_RESOURCES.CHANNEL,
|
||||
ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
|
||||
)
|
||||
|
||||
const selectedRows = table.getFilteredSelectedRowModel().rows
|
||||
const selectedIds = selectedRows.reduce<number[]>((ids, row) => {
|
||||
const id = (row.original as Channel).id
|
||||
|
||||
if (typeof id === 'number') {
|
||||
ids.push(id)
|
||||
}
|
||||
|
||||
return ids
|
||||
}, [])
|
||||
|
||||
const handleClearSelection = () => {
|
||||
table.resetRowSelection()
|
||||
}
|
||||
|
||||
const handleEnableAll = () => {
|
||||
handleBatchEnable(selectedIds, queryClient, handleClearSelection)
|
||||
}
|
||||
|
||||
const handleDisableAll = () => {
|
||||
handleBatchDisable(selectedIds, queryClient, handleClearSelection)
|
||||
}
|
||||
|
||||
const handleDeleteAll = () => {
|
||||
if (!canEditSensitive) return
|
||||
handleBatchDelete(selectedIds, queryClient, () => {
|
||||
setShowDeleteConfirm(false)
|
||||
handleClearSelection()
|
||||
})
|
||||
}
|
||||
|
||||
const handleSetTag = () => {
|
||||
handleBatchSetTag(selectedIds, tagValue || null, queryClient, () => {
|
||||
setShowTagDialog(false)
|
||||
setTagValue('')
|
||||
handleClearSelection()
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<BulkActionsToolbar table={table} entityName='channel'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={handleEnableAll}
|
||||
className='size-8'
|
||||
aria-label={t('Enable selected channels')}
|
||||
title={t('Enable selected channels')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Power />
|
||||
<span className='sr-only'>{t('Enable selected channels')}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('Enable selected channels')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={handleDisableAll}
|
||||
className='size-8'
|
||||
aria-label={t('Disable selected channels')}
|
||||
title={t('Disable selected channels')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PowerOff />
|
||||
<span className='sr-only'>{t('Disable selected channels')}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('Disable selected channels')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={() => setShowTagDialog(true)}
|
||||
className='size-8'
|
||||
aria-label={t('Set tag for selected channels')}
|
||||
title={t('Set tag for selected channels')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Tag />
|
||||
<span className='sr-only'>
|
||||
{t('Set tag for selected channels')}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('Set tag for selected channels')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='icon'
|
||||
onClick={() => {
|
||||
if (!canEditSensitive) return
|
||||
setShowDeleteConfirm(true)
|
||||
}}
|
||||
aria-disabled={!canEditSensitive}
|
||||
className={cn(
|
||||
'size-8',
|
||||
!canEditSensitive && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
aria-label={t('Delete selected channels')}
|
||||
title={
|
||||
canEditSensitive
|
||||
? t('Delete selected channels')
|
||||
: t('No permission to perform this action')
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 />
|
||||
<span className='sr-only'>{t('Delete selected channels')}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{canEditSensitive
|
||||
? t('Delete selected channels')
|
||||
: t('No permission to perform this action')}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</BulkActionsToolbar>
|
||||
|
||||
{/* Set Tag Dialog */}
|
||||
<Dialog
|
||||
open={showTagDialog}
|
||||
onOpenChange={setShowTagDialog}
|
||||
title={t('Set Tag')}
|
||||
description={
|
||||
<>
|
||||
{t('Set a tag for')}
|
||||
{selectedIds.length}{' '}
|
||||
{t('selected channel(s). Leave empty to remove tag.')}
|
||||
</>
|
||||
}
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => {
|
||||
setShowTagDialog(false)
|
||||
setTagValue('')
|
||||
}}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSetTag}>{t('Set Tag')}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className='grid gap-4 py-4'>
|
||||
<div className='grid gap-2'>
|
||||
<Label htmlFor='tag'>{t('Tag')}</Label>
|
||||
<Input
|
||||
id='tag'
|
||||
placeholder={t('Enter tag name (optional)')}
|
||||
value={tagValue}
|
||||
onChange={(e) => setTagValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={showDeleteConfirm}
|
||||
onOpenChange={setShowDeleteConfirm}
|
||||
title={t('Delete Channels?')}
|
||||
description={
|
||||
<>
|
||||
{t('Are you sure you want to delete')}
|
||||
{selectedIds.length}{' '}
|
||||
{t('channel(s)? This action cannot be undone.')}
|
||||
</>
|
||||
}
|
||||
contentHeight='auto'
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='destructive'
|
||||
onClick={handleDeleteAll}
|
||||
disabled={!canEditSensitive}
|
||||
>
|
||||
{t('Delete')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{' '}
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import type { Row } from '@tanstack/react-table'
|
||||
import {
|
||||
MoreHorizontal,
|
||||
Boxes,
|
||||
Pencil,
|
||||
PlugZap,
|
||||
Gauge,
|
||||
DollarSign,
|
||||
Download,
|
||||
Copy,
|
||||
Power,
|
||||
PowerOff,
|
||||
Key,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import { useContext, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import {
|
||||
ADMIN_PERMISSION_ACTIONS,
|
||||
ADMIN_PERMISSION_RESOURCES,
|
||||
hasPermission,
|
||||
} from '@/lib/admin-permissions'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
import { MODEL_FETCHABLE_TYPES } from '../constants'
|
||||
import {
|
||||
channelsQueryKeys,
|
||||
handleDeleteChannel,
|
||||
handleTestChannel,
|
||||
handleToggleChannelStatus,
|
||||
isChannelEnabled,
|
||||
isMultiKeyChannel,
|
||||
} from '../lib'
|
||||
import { parseUpstreamUpdateMeta } from '../lib/upstream-update-utils'
|
||||
import type { Channel } from '../types'
|
||||
import { ChannelRowActionsLayoutContext } from './channel-row-actions-context'
|
||||
import { useChannels } from './channels-provider'
|
||||
|
||||
interface DataTableRowActionsProps {
|
||||
row: Row<Channel>
|
||||
}
|
||||
|
||||
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
const { t } = useTranslation()
|
||||
const layout = useContext(ChannelRowActionsLayoutContext)
|
||||
const channel = row.original
|
||||
const { setOpen, setCurrentRow, upstream } = useChannels()
|
||||
const queryClient = useQueryClient()
|
||||
const currentUser = useAuthStore((s) => s.auth.user)
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
|
||||
const [isTesting, setIsTesting] = useState(false)
|
||||
const [isTogglingStatus, setIsTogglingStatus] = useState(false)
|
||||
|
||||
const isEnabled = isChannelEnabled(channel)
|
||||
const isMultiKey = isMultiKeyChannel(channel)
|
||||
const canEditSensitive = hasPermission(
|
||||
currentUser,
|
||||
ADMIN_PERMISSION_RESOURCES.CHANNEL,
|
||||
ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
|
||||
)
|
||||
|
||||
const handleEdit = () => {
|
||||
setCurrentRow(channel)
|
||||
setOpen('update-channel')
|
||||
}
|
||||
|
||||
const handleTest = () => {
|
||||
setCurrentRow(channel)
|
||||
setOpen('test-channel')
|
||||
}
|
||||
|
||||
const handleDirectTest = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
setIsTesting(true)
|
||||
try {
|
||||
await handleTestChannel(channel.id, { channelName: channel.name }, () => {
|
||||
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
})
|
||||
} finally {
|
||||
setIsTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleQueryBalance = () => {
|
||||
setCurrentRow(channel)
|
||||
setOpen('balance-query')
|
||||
}
|
||||
|
||||
const handleFetchModels = () => {
|
||||
setCurrentRow(channel)
|
||||
setOpen('fetch-models')
|
||||
}
|
||||
|
||||
const handleManageOllamaModels = () => {
|
||||
setCurrentRow(channel)
|
||||
setOpen('ollama-models')
|
||||
}
|
||||
|
||||
const handleCopy = () => {
|
||||
setCurrentRow(channel)
|
||||
setOpen('copy-channel')
|
||||
}
|
||||
|
||||
const handleManageKeys = () => {
|
||||
setCurrentRow(channel)
|
||||
setOpen('multi-key-manage')
|
||||
}
|
||||
|
||||
const handleToggleStatus = async (
|
||||
e?: React.MouseEvent<HTMLButtonElement>
|
||||
) => {
|
||||
e?.stopPropagation()
|
||||
setIsTogglingStatus(true)
|
||||
try {
|
||||
await handleToggleChannelStatus(channel.id, channel.status, queryClient)
|
||||
} finally {
|
||||
setIsTogglingStatus(false)
|
||||
}
|
||||
}
|
||||
|
||||
let statusIcon = <Power className='size-4' />
|
||||
if (isTogglingStatus) {
|
||||
statusIcon = <Loader2 className='size-4 animate-spin' />
|
||||
} else if (isEnabled) {
|
||||
statusIcon = <PowerOff className='size-4' />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='-ml-1.5 flex items-center gap-1'>
|
||||
{layout !== 'card' && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleEdit()
|
||||
}}
|
||||
aria-label={t('Edit')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Pencil className='size-4' />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('Edit')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
onClick={handleDirectTest}
|
||||
disabled={isTesting}
|
||||
aria-label={t('Test Connection')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{isTesting ? (
|
||||
<Loader2 className='size-4 animate-spin' />
|
||||
) : (
|
||||
<Gauge className='size-4' />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('Test Connection')}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{layout === 'card' && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleTest()
|
||||
}}
|
||||
aria-label={t('Test Channel Connection')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PlugZap className='size-4' />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('Test Channel Connection')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
onClick={handleToggleStatus}
|
||||
disabled={isTogglingStatus}
|
||||
aria-label={isEnabled ? t('Disable') : t('Enable')}
|
||||
className={
|
||||
isEnabled
|
||||
? 'text-destructive hover:text-destructive'
|
||||
: 'text-success hover:text-success'
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{statusIcon}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isEnabled ? t('Disable') : t('Enable')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='data-popup-open:bg-muted flex h-8 w-8 p-0'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontal className='h-4 w-4' />
|
||||
<span className='sr-only'>{t('Open menu')}</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-48'>
|
||||
{layout === 'card' && (
|
||||
<DropdownMenuItem onClick={handleEdit}>
|
||||
{t('Edit')}
|
||||
<DropdownMenuShortcut>
|
||||
<Pencil size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{/* Test Connection */}
|
||||
<DropdownMenuItem onClick={handleTest}>
|
||||
{t('Test Connection')}
|
||||
<DropdownMenuShortcut>
|
||||
<PlugZap size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Query Balance */}
|
||||
<DropdownMenuItem onClick={handleQueryBalance}>
|
||||
{t('Query Balance')}
|
||||
<DropdownMenuShortcut>
|
||||
<DollarSign size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Fetch Models */}
|
||||
<DropdownMenuItem onClick={handleFetchModels}>
|
||||
{t('Fetch Models')}
|
||||
<DropdownMenuShortcut>
|
||||
<Download size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Detect Upstream Updates (only for fetchable channel types) */}
|
||||
{MODEL_FETCHABLE_TYPES.has(channel.type) && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
const meta = parseUpstreamUpdateMeta(channel.settings)
|
||||
if (
|
||||
meta.pendingAddModels.length > 0 ||
|
||||
meta.pendingRemoveModels.length > 0
|
||||
) {
|
||||
upstream.openModal(
|
||||
channel,
|
||||
meta.pendingAddModels,
|
||||
meta.pendingRemoveModels,
|
||||
meta.pendingAddModels.length > 0 ? 'add' : 'remove'
|
||||
)
|
||||
} else {
|
||||
upstream.detectChannelUpdates(channel)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('Upstream Updates')}
|
||||
<DropdownMenuShortcut>
|
||||
<RefreshCw size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{/* Ollama Models (only for Ollama channels) */}
|
||||
{channel.type === 4 && (
|
||||
<DropdownMenuItem onClick={handleManageOllamaModels}>
|
||||
{t('Manage Ollama Models')}
|
||||
<DropdownMenuShortcut>
|
||||
<Boxes size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Copy Channel */}
|
||||
<DropdownMenuItem
|
||||
disabled={!canEditSensitive}
|
||||
onClick={canEditSensitive ? handleCopy : undefined}
|
||||
>
|
||||
{t('Copy Channel')}
|
||||
<DropdownMenuShortcut>
|
||||
<Copy size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
{!canEditSensitive && (
|
||||
<DropdownMenuItem disabled className='text-xs normal-case'>
|
||||
{t('No permission to perform this action')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{/* Manage Keys (only for multi-key channels) */}
|
||||
{isMultiKey && (
|
||||
<DropdownMenuItem onClick={handleManageKeys}>
|
||||
{t('Manage Keys')}
|
||||
<DropdownMenuShortcut>
|
||||
<Key size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Delete */}
|
||||
<DropdownMenuItem
|
||||
disabled={!canEditSensitive}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault()
|
||||
if (!canEditSensitive) return
|
||||
setDeleteConfirmOpen(true)
|
||||
}}
|
||||
className='text-destructive focus:text-destructive'
|
||||
>
|
||||
{t('Delete')}
|
||||
<DropdownMenuShortcut>
|
||||
<Trash2 size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteConfirmOpen}
|
||||
onOpenChange={setDeleteConfirmOpen}
|
||||
title={t('Delete Channel')}
|
||||
desc={t(
|
||||
'Are you sure you want to delete channel "{{name}}"? This action cannot be undone.',
|
||||
{ name: channel.name }
|
||||
)}
|
||||
confirmText={t('Delete')}
|
||||
destructive
|
||||
handleConfirm={() => {
|
||||
if (!canEditSensitive) return
|
||||
handleDeleteChannel(channel.id, queryClient)
|
||||
setDeleteConfirmOpen(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import type { Row } from '@tanstack/react-table'
|
||||
import { Power, PowerOff, Pencil, Edit } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { DataTableRowActionMenu } from '@/components/data-table/core/row-action-menu'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
import { handleEnableTagChannels, handleDisableTagChannels } from '../lib'
|
||||
import type { Channel } from '../types'
|
||||
import { useChannels } from './channels-provider'
|
||||
|
||||
interface DataTableTagRowActionsProps {
|
||||
row: Row<Channel & { tag?: string }>
|
||||
}
|
||||
|
||||
export function DataTableTagRowActions({ row }: DataTableTagRowActionsProps) {
|
||||
const { t } = useTranslation()
|
||||
const tag = row.original.tag
|
||||
const { setOpen, setCurrentTag } = useChannels()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
if (!tag) return null
|
||||
|
||||
const handleEnableAll = () => {
|
||||
handleEnableTagChannels(tag, queryClient)
|
||||
}
|
||||
|
||||
const handleDisableAll = () => {
|
||||
handleDisableTagChannels(tag, queryClient)
|
||||
}
|
||||
|
||||
const handleBatchEdit = () => {
|
||||
setCurrentTag(tag)
|
||||
setOpen('tag-batch-edit')
|
||||
}
|
||||
|
||||
const handleEditTag = () => {
|
||||
setCurrentTag(tag)
|
||||
setOpen('edit-tag')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='-ml-1.5 flex items-center gap-1'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
onClick={handleEditTag}
|
||||
aria-label={t('Edit Tag')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Edit />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('Edit Tag')}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<DataTableRowActionMenu ariaLabel={t('Open menu')}>
|
||||
{/* Batch Edit */}
|
||||
<DropdownMenuItem onClick={handleBatchEdit}>
|
||||
{t('Batch Edit')}
|
||||
<DropdownMenuShortcut>
|
||||
<Pencil size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Enable All */}
|
||||
<DropdownMenuItem onClick={handleEnableAll}>
|
||||
{t('Enable All')}
|
||||
<DropdownMenuShortcut>
|
||||
<Power size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Disable All */}
|
||||
<DropdownMenuItem onClick={handleDisableAll}>
|
||||
{t('Disable All')}
|
||||
<DropdownMenuShortcut>
|
||||
<PowerOff size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DataTableRowActionMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,247 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2, RefreshCw, DollarSign } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
CodeBlock,
|
||||
CodeBlockCopyButton,
|
||||
} from '@/components/ai-elements/code-block'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { IconBadge } from '@/components/ui/icon-badge'
|
||||
import { formatCurrencyFromUSD } from '@/lib/currency'
|
||||
import { formatTimestampToDate } from '@/lib/format'
|
||||
|
||||
import { getCodexUsage, updateChannelBalance } from '../../api'
|
||||
import { channelsQueryKeys } from '../../lib'
|
||||
import { useChannels } from '../channels-provider'
|
||||
import {
|
||||
CodexUsageDialog,
|
||||
type CodexUsageDialogData,
|
||||
} from './codex-usage-dialog'
|
||||
|
||||
type BalanceQueryDialogProps = {
|
||||
initialRawResponse?: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function BalanceQueryDialog(props: BalanceQueryDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const { currentRow, setCurrentRow } = useChannels()
|
||||
const queryClient = useQueryClient()
|
||||
const [isQuerying, setIsQuerying] = useState(false)
|
||||
const [balance, setBalance] = useState<number | null>(null)
|
||||
const [balanceUpdatedTime, setBalanceUpdatedTime] = useState<number | null>(
|
||||
null
|
||||
)
|
||||
const [rawResponse, setRawResponse] = useState<string | null>(
|
||||
props.initialRawResponse ?? null
|
||||
)
|
||||
const [codexUsageResponse, setCodexUsageResponse] =
|
||||
useState<CodexUsageDialogData | null>(null)
|
||||
|
||||
const isCodex = currentRow?.type === 57
|
||||
|
||||
const handleQueryCodexUsage = async () => {
|
||||
const row = currentRow
|
||||
if (!row) return
|
||||
setIsQuerying(true)
|
||||
try {
|
||||
const res = await getCodexUsage(row.id)
|
||||
if (!res.success) {
|
||||
throw new Error(res.message || t('Failed to fetch usage'))
|
||||
}
|
||||
setCodexUsageResponse(res)
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('Failed to fetch usage')
|
||||
)
|
||||
} finally {
|
||||
setIsQuerying(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCodex) return
|
||||
if (!props.open) return
|
||||
handleQueryCodexUsage()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.open, isCodex])
|
||||
|
||||
if (!currentRow) return null
|
||||
|
||||
const handleQueryBalance = async () => {
|
||||
setIsQuerying(true)
|
||||
try {
|
||||
const response = await updateChannelBalance(currentRow.id)
|
||||
if (response.success && response.balance !== undefined) {
|
||||
const newBalance = response.balance
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
|
||||
setBalance(newBalance)
|
||||
setBalanceUpdatedTime(now)
|
||||
toast.success(t('Balance updated successfully'))
|
||||
|
||||
// Update currentRow immediately with new balance and timestamp
|
||||
setCurrentRow({
|
||||
...currentRow,
|
||||
balance: newBalance,
|
||||
balance_updated_time: now,
|
||||
})
|
||||
|
||||
// Invalidate queries to refresh the table
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: channelsQueryKeys.lists(),
|
||||
})
|
||||
setRawResponse(null)
|
||||
} else if (response.success && response.raw_response !== undefined) {
|
||||
setRawResponse(response.raw_response)
|
||||
} else {
|
||||
toast.error(response.message || t('Failed to query balance'))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('Failed to query balance')
|
||||
)
|
||||
} finally {
|
||||
setIsQuerying(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setBalance(null)
|
||||
setBalanceUpdatedTime(null)
|
||||
setRawResponse(null)
|
||||
setCodexUsageResponse(null)
|
||||
props.onOpenChange(false)
|
||||
}
|
||||
|
||||
const formatBalance = (bal: number) =>
|
||||
formatCurrencyFromUSD(bal, {
|
||||
digitsLarge: 2,
|
||||
digitsSmall: 4,
|
||||
abbreviate: false,
|
||||
})
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
if (!timestamp) return 'Never'
|
||||
return formatTimestampToDate(timestamp)
|
||||
}
|
||||
|
||||
if (isCodex) {
|
||||
return (
|
||||
<CodexUsageDialog
|
||||
open={props.open}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) handleClose()
|
||||
}}
|
||||
channelName={currentRow.name}
|
||||
channelId={currentRow.id}
|
||||
response={codexUsageResponse}
|
||||
onRefresh={handleQueryCodexUsage}
|
||||
isRefreshing={isQuerying}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={props.open}
|
||||
onOpenChange={handleClose}
|
||||
title={t('Query Balance')}
|
||||
description={
|
||||
<>
|
||||
{t('Update balance for:')}
|
||||
<strong>{currentRow.name}</strong>
|
||||
</>
|
||||
}
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
<Button variant='outline' onClick={handleClose} disabled={isQuerying}>
|
||||
{t('Close')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className='space-y-4 py-4'>
|
||||
{rawResponse !== null ? (
|
||||
<>
|
||||
<Alert>
|
||||
<AlertTitle>{t('Balance response not recognized')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
'The upstream response is valid JSON, but it does not match the OpenAI credit_summary format. The channel balance was not updated.'
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<CodeBlock
|
||||
code={rawResponse}
|
||||
language='json'
|
||||
maxExpandedLines={24}
|
||||
showLineNumbers
|
||||
title={t('Upstream JSON response')}
|
||||
>
|
||||
<CodeBlockCopyButton />
|
||||
</CodeBlock>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Current Balance Display */}
|
||||
<div className='bg-muted/50 rounded-lg border p-4'>
|
||||
<div className='text-muted-foreground mb-2 flex items-center gap-2 text-sm'>
|
||||
<IconBadge tone='success' size='xs'>
|
||||
<DollarSign />
|
||||
</IconBadge>
|
||||
<span>{t('Current Balance')}</span>
|
||||
</div>
|
||||
<div className='text-2xl font-bold'>
|
||||
{balance !== null
|
||||
? formatBalance(balance)
|
||||
: formatBalance(currentRow.balance)}
|
||||
</div>
|
||||
<div className='text-muted-foreground mt-2 text-xs'>
|
||||
{t('Last updated:')}{' '}
|
||||
{formatDate(
|
||||
balanceUpdatedTime ?? currentRow.balance_updated_time
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Balance Update Button */}
|
||||
<Button
|
||||
className='w-full'
|
||||
onClick={handleQueryBalance}
|
||||
disabled={isQuerying}
|
||||
>
|
||||
{isQuerying && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
|
||||
{!isQuerying && <RefreshCw className='mr-2 h-4 w-4' />}
|
||||
{isQuerying ? t('Querying...') : t('Update Balance')}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
import { handleCopyChannel } from '../../lib'
|
||||
import { useChannels } from '../channels-provider'
|
||||
|
||||
type CopyChannelDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function CopyChannelDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: CopyChannelDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const { currentRow } = useChannels()
|
||||
const queryClient = useQueryClient()
|
||||
const [suffix, setSuffix] = useState('_copy')
|
||||
const [resetBalance, setResetBalance] = useState(true)
|
||||
const [isCopying, setIsCopying] = useState(false)
|
||||
|
||||
if (!currentRow) return null
|
||||
|
||||
const handleCopy = async () => {
|
||||
setIsCopying(true)
|
||||
|
||||
await handleCopyChannel(
|
||||
currentRow.id,
|
||||
{
|
||||
suffix,
|
||||
reset_balance: resetBalance,
|
||||
},
|
||||
queryClient,
|
||||
() => {
|
||||
onOpenChange(false)
|
||||
setSuffix('_copy')
|
||||
setResetBalance(true)
|
||||
}
|
||||
)
|
||||
|
||||
setIsCopying(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t('Copy Channel')}
|
||||
description={
|
||||
<>
|
||||
{t('Create a copy of:')}
|
||||
<strong>{currentRow.name}</strong>
|
||||
</>
|
||||
}
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isCopying}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleCopy} disabled={isCopying}>
|
||||
{isCopying && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
|
||||
{isCopying ? t('Copying...') : t('Copy Channel')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className='space-y-4 py-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='suffix'>{t('Name Suffix')}</Label>
|
||||
<Input
|
||||
id='suffix'
|
||||
placeholder={t('_copy')}
|
||||
value={suffix}
|
||||
onChange={(e) => setSuffix(e.target.value)}
|
||||
disabled={isCopying}
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('New name will be:')} {currentRow.name}
|
||||
{suffix}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Checkbox
|
||||
id='reset-balance'
|
||||
checked={resetBalance}
|
||||
onCheckedChange={(checked) => setResetBalance(!!checked)}
|
||||
disabled={isCopying}
|
||||
/>
|
||||
<Label htmlFor='reset-balance' className='text-sm font-normal'>
|
||||
{t('Reset balance and used quota')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Combobox } from '@/components/ui/combobox'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { JsonCodeEditor } from '@/components/json-code-editor'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
|
||||
import {
|
||||
editTagChannels,
|
||||
getTagModels,
|
||||
getAllModels,
|
||||
getGroups,
|
||||
} from '../../api'
|
||||
import { channelsQueryKeys } from '../../lib'
|
||||
import type { TagOperationParams } from '../../types'
|
||||
import { useChannels } from '../channels-provider'
|
||||
|
||||
type EditTagDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const { currentTag } = useChannels()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Form state
|
||||
const [newTag, setNewTag] = useState('')
|
||||
const [selectedModels, setSelectedModels] = useState<string[]>([])
|
||||
const [customModel, setCustomModel] = useState('')
|
||||
const [modelMapping, setModelMapping] = useState('')
|
||||
const [selectedGroups, setSelectedGroups] = useState<string[]>([])
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
// Fetch tag models
|
||||
const { data: tagModelsData, isLoading: isLoadingTagModels } = useQuery({
|
||||
queryKey: ['tag-models', currentTag],
|
||||
queryFn: () => (currentTag ? getTagModels(currentTag) : null),
|
||||
enabled: open && !!currentTag,
|
||||
})
|
||||
|
||||
// Fetch all available models
|
||||
const { data: allModelsData } = useQuery({
|
||||
queryKey: ['all-models'],
|
||||
queryFn: getAllModels,
|
||||
enabled: open,
|
||||
})
|
||||
|
||||
// Fetch groups
|
||||
const { data: groupsData } = useQuery({
|
||||
queryKey: ['groups'],
|
||||
queryFn: getGroups,
|
||||
enabled: open,
|
||||
})
|
||||
|
||||
const availableModels =
|
||||
allModelsData?.data?.map((m) => m.id).filter(Boolean) || []
|
||||
const availableGroups = groupsData?.data || []
|
||||
|
||||
// Initialize form when tag changes
|
||||
useEffect(() => {
|
||||
if (open && currentTag) {
|
||||
setNewTag(currentTag)
|
||||
setModelMapping('')
|
||||
setSelectedGroups([])
|
||||
setCustomModel('')
|
||||
|
||||
// Load tag models
|
||||
if (tagModelsData?.data) {
|
||||
const models = tagModelsData.data.split(',').filter(Boolean)
|
||||
setSelectedModels(models)
|
||||
} else {
|
||||
setSelectedModels([])
|
||||
}
|
||||
}
|
||||
}, [open, currentTag, tagModelsData])
|
||||
|
||||
const handleAddCustomModel = () => {
|
||||
if (!customModel.trim()) return
|
||||
|
||||
const modelsToAdd = customModel
|
||||
.split(',')
|
||||
.map((m) => m.trim())
|
||||
.filter(Boolean)
|
||||
.filter((m) => !selectedModels.includes(m))
|
||||
|
||||
if (modelsToAdd.length > 0) {
|
||||
setSelectedModels([...selectedModels, ...modelsToAdd])
|
||||
toast.success(
|
||||
t('Added {{count}} model(s)', { count: modelsToAdd.length })
|
||||
)
|
||||
setCustomModel('')
|
||||
} else {
|
||||
toast.info(t('No new models to add'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveModel = (model: string) => {
|
||||
setSelectedModels(selectedModels.filter((m) => m !== model))
|
||||
}
|
||||
|
||||
const handleToggleGroup = (group: string) => {
|
||||
setSelectedGroups((prev) =>
|
||||
prev.includes(group) ? prev.filter((g) => g !== group) : [...prev, group]
|
||||
)
|
||||
}
|
||||
|
||||
const validateForm = () => {
|
||||
// Validate model mapping if provided
|
||||
if (modelMapping.trim()) {
|
||||
try {
|
||||
JSON.parse(modelMapping)
|
||||
} catch {
|
||||
toast.error(t('Model mapping must be valid JSON'))
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!currentTag) return
|
||||
if (!validateForm()) return
|
||||
|
||||
// Check if anything changed
|
||||
const hasChanges =
|
||||
newTag !== currentTag ||
|
||||
modelMapping.trim() ||
|
||||
selectedModels.length > 0 ||
|
||||
selectedGroups.length > 0
|
||||
|
||||
if (!hasChanges) {
|
||||
toast.warning(t('No changes to save'))
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const params: Record<string, string | null> = { tag: currentTag }
|
||||
|
||||
if (newTag && newTag !== currentTag) {
|
||||
params.new_tag = newTag || null
|
||||
}
|
||||
|
||||
if (modelMapping.trim()) {
|
||||
params.model_mapping = modelMapping
|
||||
}
|
||||
|
||||
if (selectedModels.length > 0) {
|
||||
params.models = selectedModels.join(',')
|
||||
}
|
||||
|
||||
if (selectedGroups.length > 0) {
|
||||
params.groups = selectedGroups.join(',')
|
||||
}
|
||||
|
||||
const response = await editTagChannels(
|
||||
params as unknown as TagOperationParams
|
||||
)
|
||||
|
||||
if (response.success) {
|
||||
toast.success(t('Tag updated successfully'))
|
||||
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onOpenChange(false)
|
||||
} else {
|
||||
toast.error(response.message || t('Failed to update tag'))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('Failed to update tag')
|
||||
)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
if (!currentTag) return null
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={handleClose}
|
||||
title={
|
||||
<>
|
||||
{t('Edit Tag:')}
|
||||
{currentTag}
|
||||
</>
|
||||
}
|
||||
description={t(
|
||||
'Batch edit all channels with this tag. Leave fields empty to keep current values.'
|
||||
)}
|
||||
contentClassName='max-h-[90vh] max-w-2xl'
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
<>
|
||||
<Button variant='outline' onClick={handleClose}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
|
||||
{t('Save Changes')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ScrollArea className='max-h-[60vh] pr-4'>
|
||||
<div className='space-y-6'>
|
||||
{/* Tag Name */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='new-tag'>
|
||||
{t('Tag Name')}
|
||||
<span className='text-muted-foreground ml-2 text-xs'>
|
||||
{t('(Leave empty to dissolve tag)')}
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id='new-tag'
|
||||
value={newTag}
|
||||
onChange={(e) => setNewTag(e.target.value)}
|
||||
placeholder={t('Enter new tag name or leave empty')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Models */}
|
||||
<div className='space-y-2'>
|
||||
<Label>
|
||||
{t('Models')}
|
||||
<span className='text-muted-foreground ml-2 text-xs'>
|
||||
{t("(Override all channels' models)")}
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
{isLoadingTagModels ? (
|
||||
<div className='flex items-center gap-2 py-4'>
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{t('Loading current models...')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className='flex min-h-[60px] flex-wrap gap-2 rounded-md border p-3'>
|
||||
{selectedModels.length > 0 ? (
|
||||
selectedModels.map((model) => (
|
||||
<StatusBadge
|
||||
key={model}
|
||||
variant='neutral'
|
||||
className='cursor-pointer transition-opacity hover:opacity-70'
|
||||
copyable={false}
|
||||
onClick={() => handleRemoveModel(model)}
|
||||
>
|
||||
{model} ×
|
||||
</StatusBadge>
|
||||
))
|
||||
) : (
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{t('No models selected')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex gap-2'>
|
||||
<Combobox
|
||||
options={availableModels.map((model) => ({ value: model, label: model }))}
|
||||
onValueChange={(value: string | null) => {
|
||||
if (value !== null && !selectedModels.includes(value)) setSelectedModels([...selectedModels, value])
|
||||
}}
|
||||
className='flex-1'
|
||||
placeholder={t('Add from available models...')}
|
||||
aria-label={t('Add from available models...')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
placeholder={t('Custom model (comma-separated)')}
|
||||
value={customModel}
|
||||
onChange={(e) => setCustomModel(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleAddCustomModel()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
variant='secondary'
|
||||
onClick={handleAddCustomModel}
|
||||
>
|
||||
{t('Add')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Model Mapping */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='model-mapping'>
|
||||
{t('Model Mapping (JSON)')}
|
||||
<span className='text-muted-foreground ml-2 text-xs'>
|
||||
{t('(Optional: redirect model names)')}
|
||||
</span>
|
||||
</Label>
|
||||
<JsonCodeEditor
|
||||
id='model-mapping'
|
||||
value={modelMapping}
|
||||
onChange={setModelMapping}
|
||||
placeholder={'{\n "gpt-3.5-turbo": "gpt-3.5-turbo-0125"\n}'}
|
||||
heightClassName='h-40 min-h-40 max-h-40'
|
||||
/>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
setModelMapping(
|
||||
JSON.stringify(
|
||||
{ 'gpt-3.5-turbo': 'gpt-3.5-turbo-0125' },
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('Example')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setModelMapping(JSON.stringify({}, null, 2))}
|
||||
>
|
||||
{t('Clear Mapping')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setModelMapping('')}
|
||||
>
|
||||
{t('No Change')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Groups */}
|
||||
<div className='space-y-2'>
|
||||
<Label>
|
||||
{t('Groups')}
|
||||
<span className='text-muted-foreground ml-2 text-xs'>
|
||||
{t("(Override all channels' groups)")}
|
||||
</span>
|
||||
</Label>
|
||||
<div className='flex min-h-[60px] flex-wrap gap-2 rounded-md border p-3'>
|
||||
{availableGroups.map((group) => (
|
||||
<GroupBadge
|
||||
key={group}
|
||||
group={group}
|
||||
className={`cursor-pointer rounded-sm transition-opacity hover:opacity-70 ${
|
||||
selectedGroups.includes(group) ? 'bg-muted/70 px-1' : ''
|
||||
}`}
|
||||
onClick={() => handleToggleGroup(group)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2, Search, Info, ChevronDown } from 'lucide-react'
|
||||
import { useState, useEffect, useMemo, type ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
import { fetchUpstreamModels, updateChannel } from '../../api'
|
||||
import {
|
||||
categorizeModels,
|
||||
categorizeModelsWithRedirect,
|
||||
channelsQueryKeys,
|
||||
normalizeModelName,
|
||||
parseModelsString,
|
||||
} from '../../lib'
|
||||
import { useChannels } from '../channels-provider'
|
||||
|
||||
function normalizeModelNameList(models: readonly string[]): string[] {
|
||||
return [...new Set(models.map((m) => normalizeModelName(m)).filter(Boolean))]
|
||||
}
|
||||
|
||||
type FetchModelsDialogBaseProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
redirectModels?: string[]
|
||||
redirectSourceModels?: string[]
|
||||
customFetcher?: () => Promise<string[]>
|
||||
channelName?: string | null
|
||||
}
|
||||
|
||||
type FetchModelsDialogProps = FetchModelsDialogBaseProps &
|
||||
(
|
||||
| {
|
||||
onModelsSelected: (models: string[]) => void
|
||||
existingModelsOverride: string[]
|
||||
}
|
||||
| {
|
||||
onModelsSelected?: undefined
|
||||
existingModelsOverride?: undefined
|
||||
}
|
||||
)
|
||||
|
||||
export function FetchModelsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onModelsSelected,
|
||||
redirectModels = [],
|
||||
redirectSourceModels = [],
|
||||
customFetcher,
|
||||
existingModelsOverride,
|
||||
channelName,
|
||||
}: FetchModelsDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const { currentRow } = useChannels()
|
||||
const activeChannel = customFetcher ? null : currentRow
|
||||
const queryClient = useQueryClient()
|
||||
const [isFetching, setIsFetching] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [fetchedModels, setFetchedModels] = useState<string[]>([])
|
||||
const [selectedModels, setSelectedModels] = useState<string[]>([])
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
|
||||
// Parse existing models
|
||||
const existingModels = useMemo(
|
||||
() =>
|
||||
existingModelsOverride ?? parseModelsString(activeChannel?.models || ''),
|
||||
[existingModelsOverride, activeChannel?.models]
|
||||
)
|
||||
|
||||
// Categorize models with redirect models
|
||||
const modelCategories = useMemo(
|
||||
() => categorizeModelsWithRedirect(existingModels, redirectModels),
|
||||
[existingModels, redirectModels]
|
||||
)
|
||||
|
||||
const { classificationSet, redirectOnlySet } = modelCategories
|
||||
|
||||
const fetchedModelSet = useMemo(
|
||||
() => new Set(normalizeModelNameList(fetchedModels)),
|
||||
[fetchedModels]
|
||||
)
|
||||
|
||||
// Source keys in model_mapping are aliases, not real upstream IDs, so we
|
||||
// must skip them when computing "removed upstream" entries to avoid false
|
||||
// positives.
|
||||
const redirectSourceKeysSet = useMemo(
|
||||
() => new Set(normalizeModelNameList(redirectSourceModels)),
|
||||
[redirectSourceModels]
|
||||
)
|
||||
|
||||
const removedModels = useMemo(() => {
|
||||
const kw = searchKeyword.toLowerCase().trim()
|
||||
return normalizeModelNameList(selectedModels).filter((model) => {
|
||||
if (fetchedModelSet.has(model)) return false
|
||||
if (redirectSourceKeysSet.has(model)) return false
|
||||
if (!kw) return true
|
||||
return model.toLowerCase().includes(kw)
|
||||
})
|
||||
}, [fetchedModelSet, redirectSourceKeysSet, searchKeyword, selectedModels])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && (activeChannel || customFetcher)) {
|
||||
handleFetchModels()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, activeChannel?.id, customFetcher])
|
||||
|
||||
const handleFetchModels = async () => {
|
||||
if (!activeChannel && !customFetcher) return
|
||||
|
||||
setIsFetching(true)
|
||||
try {
|
||||
if (customFetcher) {
|
||||
const list = await customFetcher()
|
||||
setFetchedModels(list)
|
||||
setSelectedModels(existingModels)
|
||||
toast.success(t('Fetched {{count}} models', { count: list.length }))
|
||||
} else if (activeChannel) {
|
||||
const response = await fetchUpstreamModels(activeChannel.id)
|
||||
if (response.success) {
|
||||
const list = Array.isArray(response.data) ? response.data : []
|
||||
setFetchedModels(list)
|
||||
setSelectedModels(existingModels)
|
||||
toast.success(t('Fetched {{count}} models', { count: list.length }))
|
||||
} else {
|
||||
toast.error(response.message || t('Failed to fetch models'))
|
||||
setFetchedModels([])
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('Failed to fetch models')
|
||||
)
|
||||
setFetchedModels([])
|
||||
} finally {
|
||||
setIsFetching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
// If onModelsSelected callback is provided, use it (form filling mode)
|
||||
if (onModelsSelected) {
|
||||
onModelsSelected(selectedModels)
|
||||
toast.success(t('Models filled to form'))
|
||||
onOpenChange(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, directly save to API (standalone mode)
|
||||
if (!activeChannel) return
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const modelsString = selectedModels.join(',')
|
||||
const response = await updateChannel(activeChannel.id, {
|
||||
models: modelsString,
|
||||
})
|
||||
if (response.success) {
|
||||
toast.success(t('Models updated successfully'))
|
||||
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
onOpenChange(false)
|
||||
} else {
|
||||
toast.error(response.message || t('Failed to update models'))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('Failed to update models')
|
||||
)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setFetchedModels([])
|
||||
setSelectedModels([])
|
||||
setSearchKeyword('')
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
// Filter models by search
|
||||
const filteredModels = useMemo(() => {
|
||||
if (!searchKeyword) return fetchedModels
|
||||
return fetchedModels.filter((model) =>
|
||||
model.toLowerCase().includes(searchKeyword.toLowerCase())
|
||||
)
|
||||
}, [fetchedModels, searchKeyword])
|
||||
|
||||
const {
|
||||
newModels,
|
||||
existingFilteredModels,
|
||||
newModelsByCategory,
|
||||
existingModelsByCategory,
|
||||
} = useMemo(() => {
|
||||
const newModels: string[] = []
|
||||
const existingFilteredModels: string[] = []
|
||||
|
||||
for (const model of filteredModels) {
|
||||
if (classificationSet.has(normalizeModelName(model))) {
|
||||
existingFilteredModels.push(model)
|
||||
} else {
|
||||
newModels.push(model)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
newModels,
|
||||
existingFilteredModels,
|
||||
newModelsByCategory: categorizeModels(newModels),
|
||||
existingModelsByCategory: categorizeModels(existingFilteredModels),
|
||||
}
|
||||
}, [classificationSet, filteredModels])
|
||||
|
||||
// 厂商分类按 a-z 排序,Other 放最后,便于查找
|
||||
const getSortedCategoryEntries = (
|
||||
categories: Record<string, string[]>
|
||||
): [string, string[]][] =>
|
||||
Object.entries(categories).sort(([a], [b]) => {
|
||||
if (a === 'Other') return 1
|
||||
if (b === 'Other') return -1
|
||||
return a.localeCompare(b, undefined, { sensitivity: 'base' })
|
||||
})
|
||||
|
||||
const toggleModel = (model: string) => {
|
||||
setSelectedModels((prev) =>
|
||||
prev.includes(model) ? prev.filter((m) => m !== model) : [...prev, model]
|
||||
)
|
||||
}
|
||||
|
||||
const toggleCategory = (categoryModels: string[], isChecked: boolean) => {
|
||||
setSelectedModels((prev) => {
|
||||
if (isChecked) {
|
||||
const newSelected = [...prev]
|
||||
categoryModels.forEach((model) => {
|
||||
if (!newSelected.includes(model)) {
|
||||
newSelected.push(model)
|
||||
}
|
||||
})
|
||||
return newSelected
|
||||
} else {
|
||||
return prev.filter((m) => !categoryModels.includes(m))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const isCategorySelected = (categoryModels: string[]) => {
|
||||
return categoryModels.every((m) => selectedModels.includes(m))
|
||||
}
|
||||
|
||||
const renderModelCategory = (
|
||||
categoryName: string,
|
||||
categoryModels: string[]
|
||||
) => {
|
||||
const allSelected = isCategorySelected(categoryModels)
|
||||
|
||||
return (
|
||||
<Collapsible key={categoryName} defaultOpen>
|
||||
<CollapsibleTrigger className='hover:bg-muted/50 flex w-full items-center justify-between rounded-lg border p-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<ChevronDown className='h-4 w-4' />
|
||||
<span className='font-medium'>
|
||||
{categoryName} ({categoryModels.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{categoryModels.filter((m) => selectedModels.includes(m)).length}{' '}
|
||||
/ {categoryModels.length} selected
|
||||
</span>
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleCategory(categoryModels, !!checked)
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className='px-4 py-2'>
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
{categoryModels.map((model) => (
|
||||
<div key={model} className='flex items-center space-x-2'>
|
||||
<Checkbox
|
||||
id={model}
|
||||
checked={selectedModels.includes(model)}
|
||||
onCheckedChange={() => toggleModel(model)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={model}
|
||||
className='flex cursor-pointer items-center gap-1.5 text-sm font-normal'
|
||||
>
|
||||
<span>{model}</span>
|
||||
{redirectOnlySet.has(normalizeModelName(model)) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<Info className='h-3.5 w-3.5 text-amber-500' />}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('From model redirect, not yet added to models list')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
const showFooterActions =
|
||||
!!(activeChannel || customFetcher) &&
|
||||
!isFetching &&
|
||||
(fetchedModels.length > 0 || removedModels.length > 0)
|
||||
|
||||
let dialogDescription: ReactNode = t('Fetch available models from upstream')
|
||||
if (activeChannel) {
|
||||
dialogDescription = (
|
||||
<>
|
||||
{t('Channel:')} <strong>{activeChannel.name}</strong>
|
||||
</>
|
||||
)
|
||||
} else if (channelName) {
|
||||
dialogDescription = (
|
||||
<>
|
||||
{t('Channel:')} <strong>{channelName}</strong>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
let defaultTab = 'existing'
|
||||
if (newModels.length > 0) {
|
||||
defaultTab = 'new'
|
||||
} else if (removedModels.length > 0) {
|
||||
defaultTab = 'removed'
|
||||
}
|
||||
|
||||
let dialogBody: ReactNode
|
||||
if (!activeChannel && !customFetcher) {
|
||||
dialogBody = (
|
||||
<div className='text-muted-foreground py-8 text-center'>
|
||||
{t('No channel selected')}
|
||||
</div>
|
||||
)
|
||||
} else if (isFetching) {
|
||||
dialogBody = (
|
||||
<div className='flex items-center justify-center py-12'>
|
||||
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
|
||||
</div>
|
||||
)
|
||||
} else if (fetchedModels.length === 0 && removedModels.length === 0) {
|
||||
dialogBody = (
|
||||
<div className='text-muted-foreground py-8 text-center'>
|
||||
<p>{t('No models fetched yet.')}</p>
|
||||
<Button
|
||||
className='mt-4'
|
||||
onClick={handleFetchModels}
|
||||
disabled={isFetching}
|
||||
>
|
||||
{t('Fetch Models')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
dialogBody = (
|
||||
<div className='space-y-4'>
|
||||
{/* Search Bar */}
|
||||
<div className='relative'>
|
||||
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
|
||||
<Input
|
||||
placeholder={t('Search models...')}
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
className='pl-9'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tabs for New vs Existing vs Removed */}
|
||||
<Tabs
|
||||
key={`${activeChannel?.id ?? 'custom'}-${fetchedModels.length}-${removedModels.length}`}
|
||||
defaultValue={defaultTab}
|
||||
>
|
||||
<TabsList
|
||||
className={`grid w-full ${removedModels.length > 0 ? 'grid-cols-3' : 'grid-cols-2'}`}
|
||||
>
|
||||
<TabsTrigger value='new' disabled={newModels.length === 0}>
|
||||
{t('New Models ({{count}})', { count: newModels.length })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='existing'
|
||||
disabled={existingFilteredModels.length === 0}
|
||||
>
|
||||
{t('Existing Models ({{count}})', {
|
||||
count: existingFilteredModels.length,
|
||||
})}
|
||||
</TabsTrigger>
|
||||
{removedModels.length > 0 && (
|
||||
<TabsTrigger value='removed'>
|
||||
{t('Removed Models ({{count}})', {
|
||||
count: removedModels.length,
|
||||
})}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent
|
||||
value='new'
|
||||
className='max-h-96 space-y-2 overflow-y-auto'
|
||||
>
|
||||
{getSortedCategoryEntries(newModelsByCategory).map(
|
||||
([category, models]) => renderModelCategory(category, models)
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value='existing'
|
||||
className='max-h-96 space-y-2 overflow-y-auto'
|
||||
>
|
||||
{getSortedCategoryEntries(existingModelsByCategory).map(
|
||||
([category, models]) => renderModelCategory(category, models)
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{removedModels.length > 0 && (
|
||||
<TabsContent
|
||||
value='removed'
|
||||
className='max-h-96 space-y-2 overflow-y-auto'
|
||||
>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'These models are still in your selection but were not returned by the upstream listing. Entries that are only model_mapping source aliases are omitted. Toggle to adjust before saving.'
|
||||
)}
|
||||
</p>
|
||||
{renderModelCategory(t('Removed'), removedModels)}
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
{/* Selection Summary */}
|
||||
<div className='bg-muted/50 rounded-lg border p-3 text-sm'>
|
||||
{t('{{n}} model(s) selected', { n: selectedModels.length })}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={handleClose}
|
||||
title={t('Fetch Models')}
|
||||
description={dialogDescription}
|
||||
contentClassName='max-w-3xl'
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
showFooterActions ? (
|
||||
<>
|
||||
<Button variant='outline' onClick={handleClose} disabled={isSaving}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
|
||||
{isSaving ? t('Saving...') : t('Save Models')}
|
||||
</Button>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{dialogBody}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export type MissingModelsAction = 'cancel' | 'submit' | 'add'
|
||||
|
||||
type MissingModelsConfirmationDialogProps = {
|
||||
open: boolean
|
||||
missingModels: string[]
|
||||
onConfirm: (action: MissingModelsAction) => void
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmation dialog shown when models in model_mapping are missing from the models list
|
||||
* Provides three options:
|
||||
* 1. Cancel - Go back to edit
|
||||
* 2. Submit - Submit anyway without adding missing models
|
||||
* 3. Add - Automatically add missing models and submit
|
||||
*/
|
||||
export function MissingModelsConfirmationDialog({
|
||||
open,
|
||||
missingModels,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
}: MissingModelsConfirmationDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
onConfirm('cancel')
|
||||
}
|
||||
onOpenChange?.(newOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={handleOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t('Models not in list, may fail to invoke')}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription
|
||||
render={<div className='space-y-3 text-sm' />}
|
||||
>
|
||||
<div>
|
||||
{t(
|
||||
'The following models in the model redirect have not been added to the "Models" list and may fail during invocation due to missing available models:'
|
||||
)}
|
||||
</div>
|
||||
<div className='rounded-md bg-red-50 p-2 font-mono text-xs break-all text-red-600 dark:bg-red-950/50 dark:text-red-400'>
|
||||
{missingModels.join(', ')}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
'You can manually add them in "Custom Model Names", click "Fill" and then submit, or use the operations below to handle automatically.'
|
||||
)}
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className='flex-col gap-2 sm:flex-row'>
|
||||
<AlertDialogCancel onClick={() => onConfirm('cancel')}>
|
||||
{t('Go back and edit')}
|
||||
</AlertDialogCancel>
|
||||
<Button variant='secondary' onClick={() => onConfirm('submit')}>
|
||||
{t('Submit directly')}
|
||||
</Button>
|
||||
<AlertDialogAction onClick={() => onConfirm('add')}>
|
||||
{t('Add and submit')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2, RefreshCw, Trash2, Power, PowerOff } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
ADMIN_PERMISSION_ACTIONS,
|
||||
ADMIN_PERMISSION_RESOURCES,
|
||||
hasPermission,
|
||||
} from '@/lib/admin-permissions'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
import {
|
||||
getMultiKeyStatus,
|
||||
enableMultiKey,
|
||||
disableMultiKey,
|
||||
deleteMultiKey,
|
||||
enableAllMultiKeys,
|
||||
disableAllMultiKeys,
|
||||
deleteDisabledMultiKeys,
|
||||
} from '../../api'
|
||||
import { MULTI_KEY_FILTER_OPTIONS } from '../../constants'
|
||||
import {
|
||||
channelsQueryKeys,
|
||||
formatTimestamp,
|
||||
getMultiKeyStatusConfig,
|
||||
getMultiKeyConfirmMessage,
|
||||
isDestructiveAction,
|
||||
} from '../../lib'
|
||||
import type { KeyStatus, MultiKeyConfirmAction } from '../../types'
|
||||
import { useChannels } from '../channels-provider'
|
||||
import { StatisticsCard } from './multi-key-statistics-card'
|
||||
import { MultiKeyTableRowActions } from './multi-key-table-row-actions'
|
||||
|
||||
type MultiKeyManageDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function MultiKeyManageDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: MultiKeyManageDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const { currentRow } = useChannels()
|
||||
const queryClient = useQueryClient()
|
||||
const currentUser = useAuthStore((s) => s.auth.user)
|
||||
const canEditSensitive = hasPermission(
|
||||
currentUser,
|
||||
ADMIN_PERMISSION_RESOURCES.CHANNEL,
|
||||
ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
|
||||
)
|
||||
|
||||
// Data state
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [keys, setKeys] = useState<KeyStatus[]>([])
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [totalPages, setTotalPages] = useState(0)
|
||||
const [enabledCount, setEnabledCount] = useState(0)
|
||||
const [manualDisabledCount, setManualDisabledCount] = useState(0)
|
||||
const [autoDisabledCount, setAutoDisabledCount] = useState(0)
|
||||
|
||||
// UI state
|
||||
const [statusFilter, setStatusFilter] = useState<number | null>(null)
|
||||
const [confirmAction, setConfirmAction] =
|
||||
useState<MultiKeyConfirmAction | null>(null)
|
||||
const [isPerformingAction, setIsPerformingAction] = useState(false)
|
||||
|
||||
// Reset and load data when dialog opens
|
||||
useEffect(() => {
|
||||
if (open && currentRow) {
|
||||
setCurrentPage(1)
|
||||
setStatusFilter(null)
|
||||
loadKeyStatus(1, pageSize, null)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, currentRow?.id])
|
||||
|
||||
const loadKeyStatus = async (
|
||||
page: number = currentPage,
|
||||
size: number = pageSize,
|
||||
status: number | null = statusFilter
|
||||
) => {
|
||||
if (!currentRow) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await getMultiKeyStatus(
|
||||
currentRow.id,
|
||||
page,
|
||||
size,
|
||||
status === null ? undefined : status
|
||||
)
|
||||
|
||||
if (response.success && response.data) {
|
||||
setKeys(response.data.keys || [])
|
||||
setTotal(response.data.total || 0)
|
||||
setCurrentPage(response.data.page || 1)
|
||||
setPageSize(response.data.page_size || 10)
|
||||
setTotalPages(response.data.total_pages || 0)
|
||||
setEnabledCount(response.data.enabled_count || 0)
|
||||
setManualDisabledCount(response.data.manual_disabled_count || 0)
|
||||
setAutoDisabledCount(response.data.auto_disabled_count || 0)
|
||||
} else {
|
||||
toast.error(response.message || t('Failed to load key status'))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('Failed to load key status')
|
||||
)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStatusFilterChange = (value: string) => {
|
||||
const newFilter = value === 'all' ? null : parseInt(value)
|
||||
setStatusFilter(newFilter)
|
||||
setCurrentPage(1)
|
||||
loadKeyStatus(1, pageSize, newFilter)
|
||||
}
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setCurrentPage(newPage)
|
||||
loadKeyStatus(newPage, pageSize)
|
||||
}
|
||||
|
||||
const performAction = async () => {
|
||||
if (!confirmAction || !currentRow) return
|
||||
if (
|
||||
!canEditSensitive &&
|
||||
(confirmAction.type === 'delete' ||
|
||||
confirmAction.type === 'delete-disabled')
|
||||
) {
|
||||
setConfirmAction(null)
|
||||
return
|
||||
}
|
||||
|
||||
setIsPerformingAction(true)
|
||||
try {
|
||||
const { type, keyIndex } = confirmAction
|
||||
let response
|
||||
|
||||
// Execute the appropriate action
|
||||
if (type === 'enable' && keyIndex !== undefined) {
|
||||
response = await enableMultiKey(currentRow.id, keyIndex)
|
||||
} else if (type === 'disable' && keyIndex !== undefined) {
|
||||
response = await disableMultiKey(currentRow.id, keyIndex)
|
||||
} else if (type === 'delete' && keyIndex !== undefined) {
|
||||
response = await deleteMultiKey(currentRow.id, keyIndex)
|
||||
} else if (type === 'enable-all') {
|
||||
response = await enableAllMultiKeys(currentRow.id)
|
||||
} else if (type === 'disable-all') {
|
||||
response = await disableAllMultiKeys(currentRow.id)
|
||||
} else if (type === 'delete-disabled') {
|
||||
response = await deleteDisabledMultiKeys(currentRow.id)
|
||||
}
|
||||
|
||||
if (response?.success) {
|
||||
toast.success(response.message || t('Operation successful'))
|
||||
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
|
||||
// Reload data - reset to page 1 for bulk actions
|
||||
const isBulkAction = type.includes('all') || type === 'delete-disabled'
|
||||
if (isBulkAction) {
|
||||
setCurrentPage(1)
|
||||
loadKeyStatus(1, pageSize)
|
||||
} else {
|
||||
loadKeyStatus(currentPage, pageSize)
|
||||
}
|
||||
} else {
|
||||
toast.error(response?.message || t('Operation failed'))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('Operation failed')
|
||||
)
|
||||
} finally {
|
||||
setIsPerformingAction(false)
|
||||
setConfirmAction(null)
|
||||
}
|
||||
}
|
||||
|
||||
const renderStatusBadge = (status: number) => {
|
||||
const config = getMultiKeyStatusConfig(status)
|
||||
return (
|
||||
<StatusBadge
|
||||
label={t(config.label)}
|
||||
variant={config.variant}
|
||||
showDot
|
||||
copyable={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const formatKeyTimestamp = (timestamp?: number) => {
|
||||
if (!timestamp) return '-'
|
||||
return formatTimestamp(timestamp)
|
||||
}
|
||||
|
||||
if (!currentRow) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={
|
||||
<>
|
||||
{t('Multi-Key Management')}
|
||||
<StatusBadge
|
||||
label={currentRow.name}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
{currentRow.channel_info?.multi_key_mode && (
|
||||
<StatusBadge
|
||||
label={
|
||||
currentRow.channel_info.multi_key_mode === 'random'
|
||||
? t('Random')
|
||||
: t('Polling')
|
||||
}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
description={t(
|
||||
'Manage multi-key status and configuration for this channel'
|
||||
)}
|
||||
contentClassName='flex max-h-[90vh] max-w-5xl flex-col'
|
||||
titleClassName='flex items-center gap-2'
|
||||
contentHeight='min(72vh, 720px)'
|
||||
bodyClassName='space-y-4'
|
||||
>
|
||||
<div className='flex min-h-0 flex-1 flex-col space-y-4 overflow-hidden'>
|
||||
{/* Statistics */}
|
||||
<div className='grid shrink-0 grid-cols-3 gap-3'>
|
||||
<StatisticsCard
|
||||
label={t('Enabled')}
|
||||
count={enabledCount}
|
||||
total={total}
|
||||
/>
|
||||
<StatisticsCard
|
||||
label={t('Manual Disabled')}
|
||||
count={manualDisabledCount}
|
||||
total={total}
|
||||
/>
|
||||
<StatisticsCard
|
||||
label={t('Auto Disabled')}
|
||||
count={autoDisabledCount}
|
||||
total={total}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator className='shrink-0' />
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className='flex shrink-0 items-center justify-between'>
|
||||
<Select
|
||||
items={[
|
||||
...MULTI_KEY_FILTER_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.label),
|
||||
})),
|
||||
]}
|
||||
value={statusFilter === null ? 'all' : statusFilter.toString()}
|
||||
onValueChange={(v) => v !== null && handleStatusFilterChange(v)}
|
||||
>
|
||||
<SelectTrigger className='w-40'>
|
||||
<SelectValue placeholder={t('All Status')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
<SelectGroup>
|
||||
{MULTI_KEY_FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{t(option.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => loadKeyStatus()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className='h-4 w-4' />
|
||||
</Button>
|
||||
|
||||
{manualDisabledCount + autoDisabledCount > 0 && (
|
||||
<Button
|
||||
variant='default'
|
||||
size='sm'
|
||||
onClick={() => setConfirmAction({ type: 'enable-all' })}
|
||||
>
|
||||
<Power className='mr-2 h-4 w-4' />
|
||||
{t('Enable All')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{enabledCount > 0 && (
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
onClick={() => setConfirmAction({ type: 'disable-all' })}
|
||||
>
|
||||
<PowerOff className='mr-2 h-4 w-4' />
|
||||
{t('Disable All')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{autoDisabledCount > 0 && (
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
if (!canEditSensitive) return
|
||||
setConfirmAction({ type: 'delete-disabled' })
|
||||
}}
|
||||
disabled={!canEditSensitive}
|
||||
title={
|
||||
canEditSensitive
|
||||
? undefined
|
||||
: t('No permission to perform this action')
|
||||
}
|
||||
>
|
||||
<Trash2 className='mr-2 h-4 w-4' />
|
||||
{t('Delete Auto-Disabled')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!canEditSensitive && (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('No permission to perform this action')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className='min-h-0 flex-1 overflow-auto rounded-md border'>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center py-12'>
|
||||
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
|
||||
</div>
|
||||
) : keys.length === 0 ? (
|
||||
<div className='text-muted-foreground py-12 text-center'>
|
||||
{t('No keys found')}
|
||||
</div>
|
||||
) : (
|
||||
<StaticDataTable
|
||||
className='rounded-none border-0'
|
||||
tableClassName='min-w-[800px]'
|
||||
data={keys}
|
||||
getRowKey={(key) => key.index}
|
||||
columns={[
|
||||
{
|
||||
id: 'index',
|
||||
header: t('Index'),
|
||||
className: 'w-20',
|
||||
cellClassName: 'font-mono text-sm',
|
||||
cell: (key) => `#${key.index + 1}`,
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: t('Status'),
|
||||
className: 'w-32',
|
||||
cell: (key) => renderStatusBadge(key.status),
|
||||
},
|
||||
{
|
||||
id: 'reason',
|
||||
header: t('Disabled Reason'),
|
||||
className: 'min-w-[200px]',
|
||||
cellClassName: 'max-w-xs truncate text-sm',
|
||||
cell: (key) => key.reason || '-',
|
||||
},
|
||||
{
|
||||
id: 'disabled-time',
|
||||
header: t('Disabled Time'),
|
||||
className: 'w-44',
|
||||
cellClassName: 'text-muted-foreground text-sm',
|
||||
cell: (key) => formatKeyTimestamp(key.disabled_time),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('Actions'),
|
||||
className: 'text-right',
|
||||
cell: (key) => (
|
||||
<MultiKeyTableRowActions
|
||||
keyIndex={key.index}
|
||||
status={key.status}
|
||||
canDelete={canEditSensitive}
|
||||
onAction={setConfirmAction}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className='flex shrink-0 items-center justify-between'>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{t('Page {{current}} of {{total}}', {
|
||||
current: currentPage,
|
||||
total: totalPages,
|
||||
})}
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1 || isLoading}
|
||||
>
|
||||
{t('Previous')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages || isLoading}
|
||||
>
|
||||
{t('Next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
<ConfirmDialog
|
||||
open={confirmAction !== null}
|
||||
onOpenChange={(open) => !open && setConfirmAction(null)}
|
||||
title={t('Confirm Action')}
|
||||
desc={t(getMultiKeyConfirmMessage(confirmAction))}
|
||||
destructive={isDestructiveAction(confirmAction)}
|
||||
isLoading={isPerformingAction}
|
||||
handleConfirm={performAction}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type StatisticsCardProps = {
|
||||
label: string
|
||||
count: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export function StatisticsCard({ label, count, total }: StatisticsCardProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className='rounded-md border p-3'>
|
||||
<div className='text-muted-foreground mb-1 text-xs font-medium'>
|
||||
{label}
|
||||
</div>
|
||||
<div className='flex items-baseline gap-2'>
|
||||
<span className='text-foreground text-2xl font-semibold'>{count}</span>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{t('of')} {total}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
import type { MultiKeyConfirmAction } from '../../types'
|
||||
|
||||
type MultiKeyTableRowActionsProps = {
|
||||
keyIndex: number
|
||||
status: number
|
||||
canDelete: boolean
|
||||
onAction: (action: MultiKeyConfirmAction) => void
|
||||
}
|
||||
|
||||
export function MultiKeyTableRowActions({
|
||||
keyIndex,
|
||||
status,
|
||||
canDelete,
|
||||
onAction,
|
||||
}: MultiKeyTableRowActionsProps) {
|
||||
const { t } = useTranslation()
|
||||
const isEnabled = status === 1
|
||||
|
||||
return (
|
||||
<div className='flex justify-end gap-2'>
|
||||
{isEnabled ? (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => onAction({ type: 'disable', keyIndex })}
|
||||
>
|
||||
{t('Disable')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => onAction({ type: 'enable', keyIndex })}
|
||||
>
|
||||
{t('Enable')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
if (!canDelete) return
|
||||
onAction({ type: 'delete', keyIndex })
|
||||
}}
|
||||
disabled={!canDelete}
|
||||
title={
|
||||
canDelete ? undefined : t('No permission to perform this action')
|
||||
}
|
||||
>
|
||||
{t('Delete')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2, RefreshCw, Trash2, Download, Search } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { getFreshAuthHeaders } from '@/lib/api'
|
||||
|
||||
import {
|
||||
deleteOllamaModel,
|
||||
fetchModels as fetchModelsFromEndpoint,
|
||||
fetchUpstreamModels,
|
||||
updateChannel,
|
||||
} from '../../api'
|
||||
import { channelsQueryKeys, parseModelsString } from '../../lib'
|
||||
import {
|
||||
formatBytes,
|
||||
normalizeOllamaModels,
|
||||
resolveOllamaBaseUrl,
|
||||
type OllamaModel,
|
||||
type PullProgress,
|
||||
} from '../../lib/ollama-utils'
|
||||
import { useChannels } from '../channels-provider'
|
||||
|
||||
const CHANNEL_TYPE_OLLAMA = 4
|
||||
|
||||
export function OllamaModelsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { currentRow } = useChannels()
|
||||
|
||||
const isOllamaChannel = currentRow?.type === CHANNEL_TYPE_OLLAMA
|
||||
const channelId = currentRow?.id
|
||||
|
||||
const [isFetching, setIsFetching] = useState(false)
|
||||
const [models, setModels] = useState<OllamaModel[]>([])
|
||||
const [selected, setSelected] = useState<string[]>([])
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const [pullName, setPullName] = useState('')
|
||||
const [isPulling, setIsPulling] = useState(false)
|
||||
const [pullProgress, setPullProgress] = useState<PullProgress | null>(null)
|
||||
const pullAbortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
if (!search.trim()) return models
|
||||
const keyword = search.trim().toLowerCase()
|
||||
return models.filter((m) => m.id.toLowerCase().includes(keyword))
|
||||
}, [models, search])
|
||||
|
||||
const existingModels = useMemo(
|
||||
() => parseModelsString(currentRow?.models ?? ''),
|
||||
[currentRow?.models]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setModels([])
|
||||
setSelected([])
|
||||
setSearch('')
|
||||
setPullName('')
|
||||
setIsPulling(false)
|
||||
setPullProgress(null)
|
||||
pullAbortRef.current?.abort()
|
||||
pullAbortRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
if (open && isOllamaChannel && channelId) {
|
||||
void fetchOllamaModels()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, isOllamaChannel, channelId])
|
||||
|
||||
const fetchOllamaModels = useCallback(async () => {
|
||||
if (!channelId) return
|
||||
setIsFetching(true)
|
||||
try {
|
||||
let normalized: OllamaModel[] = []
|
||||
let lastErr = ''
|
||||
|
||||
// 1) Prefer live fetch for Ollama if base_url is set (more accurate / supports unsaved changes)
|
||||
const baseUrl = resolveOllamaBaseUrl(currentRow ?? null)
|
||||
if (isOllamaChannel && baseUrl) {
|
||||
try {
|
||||
const payloadLive = await fetchModelsFromEndpoint({
|
||||
base_url: baseUrl,
|
||||
type: CHANNEL_TYPE_OLLAMA,
|
||||
key: typeof currentRow?.key === 'string' ? currentRow.key : '',
|
||||
})
|
||||
if (payloadLive?.success) {
|
||||
normalized = normalizeOllamaModels(payloadLive.data)
|
||||
} else if (payloadLive?.message) {
|
||||
lastErr = String(payloadLive.message)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
lastErr = err instanceof Error ? err.message : ''
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Fallback to server-side fetch by channelId
|
||||
if (!normalized.length) {
|
||||
const payload = await fetchUpstreamModels(Number(channelId))
|
||||
if (payload?.success) {
|
||||
normalized = normalizeOllamaModels(payload.data)
|
||||
lastErr = ''
|
||||
} else {
|
||||
lastErr = String(payload?.message || '')
|
||||
}
|
||||
}
|
||||
|
||||
if (!normalized.length && lastErr) {
|
||||
toast.error(lastErr || t('Failed to fetch models'))
|
||||
}
|
||||
|
||||
setModels(normalized)
|
||||
setSelected((prev) => {
|
||||
if (!prev.length) return normalized.map((m) => m.id)
|
||||
const stillAvailable = prev.filter((id) =>
|
||||
normalized.some((m) => m.id === id)
|
||||
)
|
||||
return stillAvailable.length
|
||||
? stillAvailable
|
||||
: normalized.map((m) => m.id)
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : undefined
|
||||
toast.error(msg || t('Failed to fetch models'))
|
||||
setModels([])
|
||||
} finally {
|
||||
setIsFetching(false)
|
||||
}
|
||||
}, [channelId, currentRow, isOllamaChannel, t])
|
||||
|
||||
const toggleSelected = (modelId: string, checked: boolean) => {
|
||||
setSelected((prev) => {
|
||||
if (checked) return prev.includes(modelId) ? prev : [...prev, modelId]
|
||||
return prev.filter((id) => id !== modelId)
|
||||
})
|
||||
}
|
||||
|
||||
const selectAllFiltered = () => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
filteredModels.forEach((m) => next.add(m.id))
|
||||
return [...next]
|
||||
})
|
||||
}
|
||||
|
||||
const clearSelection = () => setSelected([])
|
||||
|
||||
const applySelection = async (mode: 'append' | 'replace') => {
|
||||
if (!currentRow) return
|
||||
if (!selected.length) {
|
||||
toast.info(t('No models selected'))
|
||||
return
|
||||
}
|
||||
|
||||
const next =
|
||||
mode === 'replace'
|
||||
? [...new Set(selected)]
|
||||
: [...new Set([...existingModels, ...selected])]
|
||||
|
||||
try {
|
||||
const res = await updateChannel(currentRow.id, { models: next.join(',') })
|
||||
if (res.success) {
|
||||
toast.success(
|
||||
mode === 'replace'
|
||||
? t('Models updated successfully')
|
||||
: t('Models appended successfully')
|
||||
)
|
||||
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
} else {
|
||||
toast.error(res.message || t('Failed to update models'))
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t('Failed to update models')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const pullModel = async () => {
|
||||
if (!channelId) return
|
||||
if (!pullName.trim()) {
|
||||
toast.error(t('Please enter model name'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!resolveOllamaBaseUrl(currentRow)) {
|
||||
toast.error(t('Please set Ollama API Base URL first'))
|
||||
return
|
||||
}
|
||||
|
||||
pullAbortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
pullAbortRef.current = controller
|
||||
|
||||
setIsPulling(true)
|
||||
setPullProgress({ status: 'starting', completed: 0, total: 0 })
|
||||
|
||||
try {
|
||||
const authHeaders = await getFreshAuthHeaders()
|
||||
const response = await fetch('/api/channel/ollama/pull/stream', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
...authHeaders,
|
||||
Accept: 'text/event-stream',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
channel_id: channelId,
|
||||
model_name: pullName.trim(),
|
||||
}),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue
|
||||
const eventData = line.slice(6)
|
||||
if (!eventData) continue
|
||||
|
||||
if (eventData === '[DONE]') {
|
||||
setIsPulling(false)
|
||||
setPullProgress(null)
|
||||
pullAbortRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(eventData)
|
||||
if (data?.status) {
|
||||
setPullProgress(data)
|
||||
} else if (data?.error) {
|
||||
toast.error(String(data.error))
|
||||
setIsPulling(false)
|
||||
setPullProgress(null)
|
||||
pullAbortRef.current = null
|
||||
return
|
||||
} else if (data?.message) {
|
||||
toast.success(String(data.message))
|
||||
setPullName('')
|
||||
setIsPulling(false)
|
||||
setPullProgress(null)
|
||||
pullAbortRef.current = null
|
||||
await fetchOllamaModels()
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: channelsQueryKeys.lists(),
|
||||
})
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed events
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setIsPulling(false)
|
||||
setPullProgress(null)
|
||||
pullAbortRef.current = null
|
||||
await fetchOllamaModels()
|
||||
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
} catch (err: unknown) {
|
||||
const isAbort =
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'name' in err &&
|
||||
(err as { name?: unknown }).name === 'AbortError'
|
||||
if (!isAbort) {
|
||||
const msg = err instanceof Error ? err.message : ''
|
||||
toast.error(t('Model pull failed: {{msg}}', { msg }))
|
||||
}
|
||||
setIsPulling(false)
|
||||
setPullProgress(null)
|
||||
pullAbortRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const deleteModel = async (modelName: string) => {
|
||||
if (!channelId) return
|
||||
try {
|
||||
setIsDeleting(true)
|
||||
const payload = await deleteOllamaModel({
|
||||
channel_id: Number(channelId),
|
||||
model_name: modelName,
|
||||
})
|
||||
if (payload?.success) {
|
||||
toast.success(t('Model deleted'))
|
||||
await fetchOllamaModels()
|
||||
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
setDeleteOpen(false)
|
||||
setDeleteTarget(null)
|
||||
} else {
|
||||
toast.error(payload?.message || t('Failed to delete model'))
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : undefined
|
||||
toast.error(msg || t('Failed to delete model'))
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
pullAbortRef.current?.abort()
|
||||
pullAbortRef.current = null
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={close}
|
||||
title={t('Ollama Models')}
|
||||
description={
|
||||
<>
|
||||
{t('Manage local models for:')} <strong>{currentRow?.name}</strong>
|
||||
</>
|
||||
}
|
||||
contentClassName='sm:max-w-3xl'
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
<Button variant='outline' onClick={close}>
|
||||
{t('Close')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{!isOllamaChannel ? (
|
||||
<div className='text-muted-foreground py-8 text-center'>
|
||||
{t('This channel is not an Ollama channel.')}
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-4 py-2 pr-1'>
|
||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between'>
|
||||
<div className='flex-1 space-y-2'>
|
||||
<Label htmlFor='ollama-pull'>{t('Pull model')}</Label>
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
id='ollama-pull'
|
||||
placeholder={t('e.g. llama3.1:8b')}
|
||||
value={pullName}
|
||||
onChange={(e) => setPullName(e.target.value)}
|
||||
disabled={!channelId || isPulling}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => void pullModel()}
|
||||
disabled={!channelId || isPulling}
|
||||
>
|
||||
{isPulling ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
{t('Pulling...')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className='mr-2 h-4 w-4' />
|
||||
{t('Pull')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{pullProgress && (
|
||||
<div className='space-y-2'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{t('Status:')} {String(pullProgress.status || '-')}
|
||||
</div>
|
||||
<Progress
|
||||
value={
|
||||
typeof pullProgress.completed === 'number' &&
|
||||
typeof pullProgress.total === 'number' &&
|
||||
pullProgress.total > 0
|
||||
? Math.min(
|
||||
100,
|
||||
Math.round(
|
||||
(pullProgress.completed / pullProgress.total) *
|
||||
100
|
||||
)
|
||||
)
|
||||
: 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => void fetchOllamaModels()}
|
||||
disabled={!channelId || isFetching}
|
||||
>
|
||||
{isFetching ? (
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<RefreshCw className='mr-2 h-4 w-4' />
|
||||
)}
|
||||
{t('Refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className='space-y-3'>
|
||||
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div>
|
||||
<p className='text-sm font-medium'>{t('Local models')}</p>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Select models and apply to channel models list.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='relative sm:w-72'>
|
||||
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
|
||||
<Input
|
||||
placeholder={t('Search models...')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className='pl-9'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button variant='outline' size='sm' onClick={selectAllFiltered}>
|
||||
{t('Select all (filtered)')}
|
||||
</Button>
|
||||
<Button variant='outline' size='sm' onClick={clearSelection}>
|
||||
{t('Clear selection')}
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={() => void applySelection('append')}
|
||||
disabled={!selected.length}
|
||||
>
|
||||
{t('Append to channel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
onClick={() => void applySelection('replace')}
|
||||
disabled={!selected.length}
|
||||
>
|
||||
{t('Replace channel models')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='overflow-hidden rounded-md border'>
|
||||
<div className='max-h-[420px] overflow-y-auto'>
|
||||
{filteredModels.length === 0 ? (
|
||||
<div className='text-muted-foreground p-6 text-center text-sm'>
|
||||
{t('No models found.')}
|
||||
</div>
|
||||
) : (
|
||||
<div className='divide-y'>
|
||||
{filteredModels.map((m) => {
|
||||
const checked = selected.includes(m.id)
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className='flex items-center justify-between gap-3 p-3'
|
||||
>
|
||||
<div className='flex min-w-0 items-start gap-3'>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => toggleSelected(m.id, !!v)}
|
||||
aria-label={`Select model ${m.id}`}
|
||||
/>
|
||||
<div className='min-w-0'>
|
||||
<div className='truncate font-mono text-sm'>
|
||||
{m.id}
|
||||
</div>
|
||||
<div className='text-muted-foreground flex flex-wrap gap-x-3 gap-y-1 text-xs'>
|
||||
<span>
|
||||
{t('Size:')} {formatBytes(m.size)}
|
||||
</span>
|
||||
{m.digest && (
|
||||
<span className='truncate'>
|
||||
{t('Digest:')} {String(m.digest)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='text-destructive hover:text-destructive'
|
||||
onClick={() => {
|
||||
setDeleteTarget(m.id)
|
||||
setDeleteOpen(true)
|
||||
}}
|
||||
disabled={!channelId}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertDialog
|
||||
open={deleteOpen}
|
||||
onOpenChange={(v) => {
|
||||
setDeleteOpen(v)
|
||||
if (!v) setDeleteTarget(null)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('Confirm delete')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('Delete model "{{name}}"? This cannot be undone.', {
|
||||
name: deleteTarget || '',
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>
|
||||
{t('Cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant='destructive'
|
||||
disabled={isDeleting || !deleteTarget}
|
||||
onClick={() => {
|
||||
if (!deleteTarget) return
|
||||
void deleteModel(deleteTarget)
|
||||
}}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
) : null}
|
||||
{t('Delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,187 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { lazy, Suspense, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
const Markdown = lazy(() =>
|
||||
import('@/components/ui/markdown').then((module) => ({
|
||||
default: module.Markdown,
|
||||
}))
|
||||
)
|
||||
|
||||
interface StatusCodeRiskDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
detailItems: string[]
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
const CHECKLIST_KEYS = [
|
||||
'High-risk status code retry risk check 1',
|
||||
'High-risk status code retry risk check 2',
|
||||
'High-risk status code retry risk check 3',
|
||||
'High-risk status code retry risk check 4',
|
||||
] as const
|
||||
|
||||
export function StatusCodeRiskDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
detailItems,
|
||||
onConfirm,
|
||||
}: StatusCodeRiskDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const [checkedItems, setCheckedItems] = useState<Set<number>>(new Set())
|
||||
const [confirmText, setConfirmText] = useState('')
|
||||
|
||||
const requiredText = t('High-risk status code retry confirmation text')
|
||||
const allChecked = checkedItems.size === CHECKLIST_KEYS.length
|
||||
const textMatches = confirmText.trim() === requiredText.trim()
|
||||
const canConfirm = allChecked && textMatches
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!canConfirm) return
|
||||
setCheckedItems(new Set())
|
||||
setConfirmText('')
|
||||
onConfirm()
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setCheckedItems(new Set())
|
||||
setConfirmText('')
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const toggleCheck = (idx: number) => {
|
||||
setCheckedItems((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(idx)) next.delete(idx)
|
||||
else next.add(idx)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={
|
||||
<>
|
||||
<AlertTriangle className='h-5 w-5' />
|
||||
{t('High-risk operation confirmation')}
|
||||
</>
|
||||
}
|
||||
contentClassName='sm:max-w-3xl'
|
||||
titleClassName='text-destructive flex items-center gap-2'
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
<>
|
||||
<Button variant='outline' onClick={handleCancel}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='destructive'
|
||||
disabled={!canConfirm}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
{t('I confirm enabling high-risk retry')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
{open ? (
|
||||
<div className='border-warning/40 bg-warning/5 rounded-lg border p-3 sm:p-4'>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div
|
||||
aria-hidden='true'
|
||||
className='bg-warning/10 h-32 animate-pulse rounded-md'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Markdown className='[&_h3]:text-warning text-sm [&_h3]:text-base'>
|
||||
{t('High-risk status code retry risk disclaimer')}
|
||||
</Markdown>
|
||||
</Suspense>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{detailItems.length > 0 && (
|
||||
<div className='border-destructive/30 bg-destructive/5 rounded-lg border p-3'>
|
||||
<p className='mb-2 text-sm font-medium'>
|
||||
{t('Detected high-risk status code redirect rules')}
|
||||
</p>
|
||||
<ul className='list-inside list-disc text-sm'>
|
||||
{detailItems.map((item) => (
|
||||
<li key={item} className='font-mono text-xs'>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='space-y-2'>
|
||||
{CHECKLIST_KEYS.map((key, idx) => (
|
||||
<div key={key} className='flex items-start gap-2'>
|
||||
<Checkbox
|
||||
id={`risk-check-${idx}`}
|
||||
checked={checkedItems.has(idx)}
|
||||
onCheckedChange={() => toggleCheck(idx)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`risk-check-${idx}`}
|
||||
className='text-sm leading-tight'
|
||||
>
|
||||
{t(key)}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='text-sm'>
|
||||
{t('Action confirmation')}:{' '}
|
||||
<code className='bg-muted rounded px-1 text-xs'>
|
||||
{requiredText}
|
||||
</code>
|
||||
</Label>
|
||||
<Input
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
placeholder={t('High-risk status code retry input placeholder')}
|
||||
/>
|
||||
{confirmText && !textMatches && (
|
||||
<p className='text-destructive text-xs'>
|
||||
{t('High-risk status code retry input mismatch')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2, AlertCircle } from 'lucide-react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { MultiSelect } from '@/components/multi-select'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
import {
|
||||
getTagModels,
|
||||
editTagChannels,
|
||||
getAllModels,
|
||||
getGroups,
|
||||
} from '../../api'
|
||||
import { channelsQueryKeys } from '../../lib'
|
||||
import type { TagOperationParams } from '../../types'
|
||||
import { useChannels } from '../channels-provider'
|
||||
import { ModelMappingEditor } from '../model-mapping-editor'
|
||||
|
||||
type TagBatchEditDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function TagBatchEditDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: TagBatchEditDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const { currentTag } = useChannels()
|
||||
const queryClient = useQueryClient()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
// Form fields
|
||||
const [newTag, setNewTag] = useState('')
|
||||
const [models, setModels] = useState('')
|
||||
const [modelMapping, setModelMapping] = useState('')
|
||||
const [groups, setGroups] = useState<string[]>([])
|
||||
|
||||
// Fetch available groups
|
||||
const { data: groupsData, isLoading: isLoadingGroups } = useQuery({
|
||||
queryKey: ['groups'],
|
||||
queryFn: getGroups,
|
||||
})
|
||||
|
||||
// Transform groups to multi-select options
|
||||
const groupOptions = useMemo(() => {
|
||||
if (!groupsData?.data) return []
|
||||
const allGroups = new Set([...groupsData.data, ...groups])
|
||||
return Array.from(allGroups).map((group) => ({
|
||||
value: group,
|
||||
label: group,
|
||||
}))
|
||||
}, [groupsData, groups])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && currentTag) {
|
||||
loadTagData()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, currentTag])
|
||||
|
||||
const loadTagData = async () => {
|
||||
if (!currentTag) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// Fetch current tag models
|
||||
const tagModelsResponse = await getTagModels(currentTag)
|
||||
if (tagModelsResponse.success && tagModelsResponse.data) {
|
||||
setModels(tagModelsResponse.data)
|
||||
}
|
||||
|
||||
// Fetch all available models (for future use if needed)
|
||||
const allModelsResponse = await getAllModels()
|
||||
if (allModelsResponse.success && allModelsResponse.data) {
|
||||
// Available models could be used for autocomplete in the future
|
||||
}
|
||||
|
||||
// Initialize new tag with current tag name
|
||||
setNewTag(currentTag)
|
||||
} catch (_error: unknown) {
|
||||
toast.error(
|
||||
_error instanceof Error ? _error.message : t('Failed to load tag data')
|
||||
)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!currentTag) return
|
||||
|
||||
// Validate model mapping JSON if provided
|
||||
if (modelMapping.trim()) {
|
||||
try {
|
||||
JSON.parse(modelMapping)
|
||||
} catch (_error) {
|
||||
toast.error(t('Model mapping must be valid JSON'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const params: Record<string, string | undefined> = {
|
||||
tag: currentTag,
|
||||
}
|
||||
|
||||
if (newTag !== currentTag) {
|
||||
params.new_tag = newTag || undefined
|
||||
}
|
||||
|
||||
if (models.trim()) {
|
||||
params.models = models
|
||||
}
|
||||
|
||||
if (modelMapping.trim()) {
|
||||
params.model_mapping = modelMapping
|
||||
}
|
||||
|
||||
if (groups.length > 0) {
|
||||
params.groups = groups.join(',')
|
||||
}
|
||||
|
||||
// Check if there are any changes
|
||||
if (Object.keys(params).length === 1) {
|
||||
toast.warning(t('No changes made'))
|
||||
return
|
||||
}
|
||||
|
||||
const response = await editTagChannels(
|
||||
params as unknown as TagOperationParams
|
||||
)
|
||||
if (response.success) {
|
||||
toast.success(t('Tag updated successfully'))
|
||||
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
||||
handleClose()
|
||||
} else {
|
||||
toast.error(response.message || t('Failed to update tag'))
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('Failed to update tag')
|
||||
)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setNewTag('')
|
||||
setModels('')
|
||||
setModelMapping('')
|
||||
setGroups([])
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
if (!currentTag) return null
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={handleClose}
|
||||
title={t('Batch Edit by Tag')}
|
||||
description={
|
||||
<>
|
||||
{t('Edit all channels with tag:')}
|
||||
<strong>{currentTag}</strong>
|
||||
</>
|
||||
}
|
||||
contentClassName='max-w-2xl'
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
!isLoading ? (
|
||||
<>
|
||||
<Button variant='outline' onClick={handleClose} disabled={isSaving}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
) : null}
|
||||
{isSaving ? t('Saving...') : t('Save Changes')}
|
||||
</Button>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center justify-center py-12'>
|
||||
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className='space-y-4 py-4'>
|
||||
<Alert>
|
||||
<AlertCircle className='h-4 w-4' />
|
||||
<AlertDescription>
|
||||
{t(
|
||||
'All edits are overwrite operations. Leave fields empty to keep current values unchanged.'
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Tag Name */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='new-tag'>{t('Tag Name')}</Label>
|
||||
<Input
|
||||
id='new-tag'
|
||||
placeholder={t(
|
||||
'Enter new tag name (leave empty to disband tag)'
|
||||
)}
|
||||
value={newTag}
|
||||
onChange={(e) => setNewTag(e.target.value)}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Leave empty to disband the tag')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Models */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='models'>{t('Models')}</Label>
|
||||
<Textarea
|
||||
id='models'
|
||||
placeholder={t(
|
||||
'Comma-separated model names (leave empty to keep current)'
|
||||
)}
|
||||
value={models}
|
||||
onChange={(e) => setModels(e.target.value)}
|
||||
disabled={isSaving}
|
||||
rows={3}
|
||||
/>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t(
|
||||
'Current models for the longest channel in this tag. May not include all models from all channels.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Model Mapping */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='model-mapping'>{t('Model Mapping')}</Label>
|
||||
<ModelMappingEditor
|
||||
value={modelMapping}
|
||||
onChange={setModelMapping}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Groups */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='groups'>{t('Groups')}</Label>
|
||||
{isLoadingGroups ? (
|
||||
<Skeleton className='h-10 w-full' />
|
||||
) : (
|
||||
<MultiSelect
|
||||
options={groupOptions}
|
||||
selected={groups}
|
||||
onChange={setGroups}
|
||||
placeholder={t('Select groups (leave empty to keep current)')}
|
||||
/>
|
||||
)}
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('User groups that can access channels with this tag')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Search } from 'lucide-react'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
|
||||
interface UpstreamUpdateDialogProps {
|
||||
open: boolean
|
||||
addModels: string[]
|
||||
removeModels: string[]
|
||||
preferredTab: 'add' | 'remove'
|
||||
confirmLoading: boolean
|
||||
onConfirm: (data: { addModels: string[]; removeModels: string[] }) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function UpstreamUpdateDialog(props: UpstreamUpdateDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const [activeTab, setActiveTab] = useState(props.preferredTab)
|
||||
const [searchAdd, setSearchAdd] = useState('')
|
||||
const [searchRemove, setSearchRemove] = useState('')
|
||||
const [selectedAdd, setSelectedAdd] = useState<Set<string>>(
|
||||
() => new Set(props.addModels)
|
||||
)
|
||||
const [selectedRemove, setSelectedRemove] = useState<Set<string>>(
|
||||
() => new Set(props.removeModels)
|
||||
)
|
||||
const [partialConfirmOpen, setPartialConfirmOpen] = useState(false)
|
||||
|
||||
const filteredAdd = useMemo(
|
||||
() =>
|
||||
props.addModels.filter((m) =>
|
||||
m.toLowerCase().includes(searchAdd.toLowerCase())
|
||||
),
|
||||
[props.addModels, searchAdd]
|
||||
)
|
||||
|
||||
const filteredRemove = useMemo(
|
||||
() =>
|
||||
props.removeModels.filter((m) =>
|
||||
m.toLowerCase().includes(searchRemove.toLowerCase())
|
||||
),
|
||||
[props.removeModels, searchRemove]
|
||||
)
|
||||
|
||||
const toggleModel = (
|
||||
model: string,
|
||||
set: Set<string>,
|
||||
setter: (s: Set<string>) => void
|
||||
) => {
|
||||
const next = new Set(set)
|
||||
if (next.has(model)) next.delete(model)
|
||||
else next.add(model)
|
||||
setter(next)
|
||||
}
|
||||
|
||||
const toggleAllVisible = (
|
||||
models: string[],
|
||||
set: Set<string>,
|
||||
setter: (s: Set<string>) => void
|
||||
) => {
|
||||
const allSelected = models.every((m) => set.has(m))
|
||||
const next = new Set(set)
|
||||
if (allSelected) {
|
||||
models.forEach((m) => next.delete(m))
|
||||
} else {
|
||||
models.forEach((m) => next.add(m))
|
||||
}
|
||||
setter(next)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
const hasAdd = props.addModels.length > 0
|
||||
const hasRemove = props.removeModels.length > 0
|
||||
const selectedAddArr = Array.from(selectedAdd)
|
||||
const selectedRemoveArr = Array.from(selectedRemove)
|
||||
const anyAdd = selectedAddArr.length > 0
|
||||
const anyRemove = selectedRemoveArr.length > 0
|
||||
|
||||
if (hasAdd && hasRemove && anyAdd !== anyRemove) {
|
||||
setPartialConfirmOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
props.onConfirm({
|
||||
addModels: selectedAddArr,
|
||||
removeModels: selectedRemoveArr,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={props.open}
|
||||
onOpenChange={(v) => !v && props.onCancel()}
|
||||
title={t('Upstream Model Updates')}
|
||||
contentClassName='sm:max-w-lg'
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
<>
|
||||
<Button variant='outline' onClick={props.onCancel}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={
|
||||
props.confirmLoading ||
|
||||
(props.addModels.length === 0 &&
|
||||
props.removeModels.length === 0)
|
||||
}
|
||||
>
|
||||
{t('Confirm')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t(
|
||||
'Select models to process. Unselected "add" models will be ignored.'
|
||||
)}
|
||||
</p>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as 'add' | 'remove')}
|
||||
>
|
||||
<TabsList className='grid w-full grid-cols-2'>
|
||||
<TabsTrigger value='add' className='gap-1'>
|
||||
{t('Add Models')}
|
||||
<StatusBadge variant='neutral' className='ml-1' copyable={false}>
|
||||
{selectedAdd.size}/{props.addModels.length}
|
||||
</StatusBadge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value='remove' className='gap-1'>
|
||||
{t('Remove Models')}
|
||||
<StatusBadge variant='neutral' className='ml-1' copyable={false}>
|
||||
{selectedRemove.size}/{props.removeModels.length}
|
||||
</StatusBadge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='add' className='space-y-3'>
|
||||
<div className='relative'>
|
||||
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
|
||||
<Input
|
||||
placeholder={t('Search models...')}
|
||||
className='pl-8'
|
||||
value={searchAdd}
|
||||
onChange={(e) => setSearchAdd(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{filteredAdd.length > 0 && (
|
||||
<div className='flex items-center gap-2'>
|
||||
<Checkbox
|
||||
checked={filteredAdd.every((m) => selectedAdd.has(m))}
|
||||
onCheckedChange={() =>
|
||||
toggleAllVisible(filteredAdd, selectedAdd, setSelectedAdd)
|
||||
}
|
||||
/>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{t('Select All Visible')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<ScrollArea className='h-[280px] rounded-md border p-2'>
|
||||
{filteredAdd.length > 0 ? (
|
||||
<div className='space-y-1'>
|
||||
{filteredAdd.map((model) => (
|
||||
<label
|
||||
key={model}
|
||||
className='hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-2 py-1.5'
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedAdd.has(model)}
|
||||
onCheckedChange={() =>
|
||||
toggleModel(model, selectedAdd, setSelectedAdd)
|
||||
}
|
||||
/>
|
||||
<span className='truncate text-sm'>{model}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-muted-foreground py-8 text-center text-sm'>
|
||||
{props.addModels.length === 0
|
||||
? t('No models to add')
|
||||
: t('No matching results')}
|
||||
</p>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='remove' className='space-y-3'>
|
||||
<div className='relative'>
|
||||
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
|
||||
<Input
|
||||
placeholder={t('Search models...')}
|
||||
className='pl-8'
|
||||
value={searchRemove}
|
||||
onChange={(e) => setSearchRemove(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{filteredRemove.length > 0 && (
|
||||
<div className='flex items-center gap-2'>
|
||||
<Checkbox
|
||||
checked={filteredRemove.every((m) => selectedRemove.has(m))}
|
||||
onCheckedChange={() =>
|
||||
toggleAllVisible(
|
||||
filteredRemove,
|
||||
selectedRemove,
|
||||
setSelectedRemove
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{t('Select All Visible')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<ScrollArea className='h-[280px] rounded-md border p-2'>
|
||||
{filteredRemove.length > 0 ? (
|
||||
<div className='space-y-1'>
|
||||
{filteredRemove.map((model) => (
|
||||
<label
|
||||
key={model}
|
||||
className='hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-2 py-1.5'
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedRemove.has(model)}
|
||||
onCheckedChange={() =>
|
||||
toggleModel(model, selectedRemove, setSelectedRemove)
|
||||
}
|
||||
/>
|
||||
<span className='truncate text-sm'>{model}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-muted-foreground py-8 text-center text-sm'>
|
||||
{props.removeModels.length === 0
|
||||
? t('No models to remove')
|
||||
: t('No matching results')}
|
||||
</p>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={partialConfirmOpen}
|
||||
onOpenChange={setPartialConfirmOpen}
|
||||
title={t('Partial Submission')}
|
||||
desc={t(
|
||||
'There are both add and remove models pending, but you only selected one type. Confirm submitting only the selected items?'
|
||||
)}
|
||||
handleConfirm={() => {
|
||||
setPartialConfirmOpen(false)
|
||||
props.onConfirm({
|
||||
addModels: Array.from(selectedAdd),
|
||||
removeModels: Array.from(selectedRemove),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { ChevronDown, Settings } from 'lucide-react'
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type ChannelAdvancedSectionProps = {
|
||||
children: ReactNode
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
summary?: ReactNode
|
||||
}
|
||||
|
||||
export function ChannelAdvancedSection(props: ChannelAdvancedSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Collapsible open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<CollapsibleTrigger
|
||||
render={
|
||||
<button
|
||||
type='button'
|
||||
className='hover:bg-muted/40 border-border/60 flex w-full items-center justify-between rounded-lg border px-3 py-3 text-left transition-colors'
|
||||
aria-expanded={props.open}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className='flex items-start gap-3'>
|
||||
<span className='bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-md'>
|
||||
<Settings className='h-4 w-4' aria-hidden='true' />
|
||||
</span>
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<div className='text-[13px] font-semibold'>
|
||||
{t('Advanced Settings')}
|
||||
</div>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{props.summary ??
|
||||
t(
|
||||
'Request overrides, routing behavior, and upstream model automation'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'text-muted-foreground h-4 w-4 shrink-0 transition-transform',
|
||||
props.open && 'rotate-180'
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent className='mt-5 flex flex-col gap-5'>
|
||||
{props.children}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { KeyRound } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
SideDrawerSection,
|
||||
SideDrawerSectionHeader,
|
||||
} from '@/components/drawer-layout'
|
||||
|
||||
type ChannelApiAccessSectionProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function ChannelApiAccessSection(props: ChannelApiAccessSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<SideDrawerSection>
|
||||
<SideDrawerSectionHeader
|
||||
title={t('Credentials')}
|
||||
description={t('Authentication')}
|
||||
icon={<KeyRound className='h-4 w-4' aria-hidden='true' />}
|
||||
iconTone='success'
|
||||
/>
|
||||
{props.children}
|
||||
</SideDrawerSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { KeyRound } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type ChannelAuthSectionProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function ChannelAuthSection(props: ChannelAuthSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='border-border/60 flex flex-col gap-3 border-t pt-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<KeyRound
|
||||
className='text-muted-foreground h-3.5 w-3.5'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<h4 className='text-muted-foreground text-xs font-medium tracking-wide uppercase'>
|
||||
{t('Authentication')}
|
||||
</h4>
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Server } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
SideDrawerSection,
|
||||
SideDrawerSectionHeader,
|
||||
} from '@/components/drawer-layout'
|
||||
|
||||
type ChannelBasicSectionProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function ChannelBasicSection(props: ChannelBasicSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<SideDrawerSection>
|
||||
<SideDrawerSectionHeader
|
||||
title={t('Basic Information')}
|
||||
description={t('Name, provider type, and availability.')}
|
||||
icon={<Server className='h-4 w-4' aria-hidden='true' />}
|
||||
iconTone='info'
|
||||
/>
|
||||
{props.children}
|
||||
</SideDrawerSection>
|
||||
)
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export function ChannelEditorLoadingState() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div
|
||||
className='border-border/60 flex flex-col gap-4 rounded-lg border p-4'
|
||||
aria-live='polite'
|
||||
>
|
||||
<div>
|
||||
<p className='text-sm font-medium'>{t('Loading channel details')}</p>
|
||||
<p className='text-muted-foreground mt-1 text-xs'>
|
||||
{t('Please wait before editing to avoid overwriting saved values.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='grid gap-4 sm:grid-cols-2'>
|
||||
<Skeleton className='h-10 w-full' />
|
||||
<Skeleton className='h-10 w-full' />
|
||||
</div>
|
||||
<Skeleton className='h-24 w-full' />
|
||||
<Skeleton className='h-32 w-full' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Boxes } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
SideDrawerSection,
|
||||
SideDrawerSectionHeader,
|
||||
} from '@/components/drawer-layout'
|
||||
|
||||
type ChannelModelsSectionProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function ChannelModelsSection(props: ChannelModelsSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<SideDrawerSection>
|
||||
<SideDrawerSectionHeader
|
||||
title={t('Models & Groups')}
|
||||
description={t('Published models, groups, and model remapping rules.')}
|
||||
icon={<Boxes className='h-4 w-4' aria-hidden='true' />}
|
||||
iconTone='chart-4'
|
||||
/>
|
||||
{props.children}
|
||||
</SideDrawerSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
export * from './channel-advanced-section'
|
||||
export * from './channel-api-access-section'
|
||||
export * from './channel-auth-section'
|
||||
export * from './channel-basic-section'
|
||||
export * from './channel-editor-loading-state'
|
||||
export * from './channel-models-section'
|
||||
@@ -0,0 +1,357 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Code, Plus, Table, Trash2 } from 'lucide-react'
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { JsonCodeEditor } from '@/components/json-code-editor'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
|
||||
type ModelMappingEditorProps = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
sourceModelOptions?: string[]
|
||||
targetModelOptions?: string[]
|
||||
}
|
||||
|
||||
type MappingRow = {
|
||||
id: string
|
||||
from: string
|
||||
to: string
|
||||
}
|
||||
|
||||
const DUPLICATE_MAPPING_SENTINEL = '{ "duplicate_source_models": '
|
||||
|
||||
function getDuplicateSources(rows: MappingRow[]): string[] {
|
||||
const seen = new Set<string>()
|
||||
const duplicates = new Set<string>()
|
||||
|
||||
for (const row of rows) {
|
||||
const source = row.from.trim()
|
||||
if (!source) continue
|
||||
if (seen.has(source)) {
|
||||
duplicates.add(source)
|
||||
} else {
|
||||
seen.add(source)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(duplicates)
|
||||
}
|
||||
|
||||
export function ModelMappingEditor(props: ModelMappingEditorProps) {
|
||||
const { t } = useTranslation()
|
||||
const sourceListId = useId()
|
||||
const targetListId = useId()
|
||||
const [mode, setMode] = useState<'visual' | 'json'>('visual')
|
||||
const [rows, setRows] = useState<MappingRow[]>([])
|
||||
const [jsonValue, setJsonValue] = useState(props.value)
|
||||
const [jsonError, setJsonError] = useState<string | null>(null)
|
||||
const nextRowIdRef = useRef(0)
|
||||
const duplicateSources = useMemo(() => getDuplicateSources(rows), [rows])
|
||||
|
||||
const createRowId = () => {
|
||||
nextRowIdRef.current += 1
|
||||
return `mapping-${nextRowIdRef.current}`
|
||||
}
|
||||
|
||||
const parseJsonToRows = (json: string): boolean => {
|
||||
try {
|
||||
if (!json.trim()) {
|
||||
setRows([])
|
||||
setJsonError(null)
|
||||
return true
|
||||
}
|
||||
const parsed = JSON.parse(json)
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
setJsonError(t('Model mapping must be a valid JSON object'))
|
||||
return false
|
||||
}
|
||||
const entries = Object.entries(parsed)
|
||||
const invalidValue = entries.find(([, to]) => typeof to !== 'string')
|
||||
if (invalidValue) {
|
||||
setJsonError(t('Model mapping values must be strings'))
|
||||
return false
|
||||
}
|
||||
setRows((previousRows) => {
|
||||
const remainingRows = [...previousRows]
|
||||
return entries.map(([from, to], index) => {
|
||||
const toString = String(to)
|
||||
const existingIndex = remainingRows.findIndex(
|
||||
(row) =>
|
||||
row.from === from ||
|
||||
(row.from === from && row.to === toString) ||
|
||||
previousRows[index]?.id === row.id
|
||||
)
|
||||
if (existingIndex >= 0) {
|
||||
const [existing] = remainingRows.splice(existingIndex, 1)
|
||||
return {
|
||||
id: existing.id,
|
||||
from,
|
||||
to: toString,
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: createRowId(),
|
||||
from,
|
||||
to: toString,
|
||||
}
|
||||
})
|
||||
})
|
||||
setJsonError(null)
|
||||
return true
|
||||
} catch (_error) {
|
||||
setJsonError(t('Model mapping must be valid JSON format'))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Parse JSON to rows when value changes externally
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setJsonValue(props.value)
|
||||
parseJsonToRows(props.value)
|
||||
}, [props.value])
|
||||
|
||||
const convertRowsToJson = (updatedRows: MappingRow[]): string => {
|
||||
if (updatedRows.length === 0) {
|
||||
return ''
|
||||
}
|
||||
const obj: Record<string, string> = {}
|
||||
updatedRows.forEach((row) => {
|
||||
if (row.from.trim()) {
|
||||
obj[row.from.trim()] = row.to.trim()
|
||||
}
|
||||
})
|
||||
return JSON.stringify(obj, null, 2)
|
||||
}
|
||||
|
||||
const syncRows = (updatedRows: MappingRow[]) => {
|
||||
setRows(updatedRows)
|
||||
const duplicates = getDuplicateSources(updatedRows)
|
||||
if (duplicates.length > 0) {
|
||||
setJsonError(t('Duplicate source model mappings are not allowed'))
|
||||
setJsonValue(DUPLICATE_MAPPING_SENTINEL)
|
||||
props.onChange(DUPLICATE_MAPPING_SENTINEL)
|
||||
return
|
||||
}
|
||||
|
||||
const json = convertRowsToJson(updatedRows)
|
||||
setJsonError(null)
|
||||
setJsonValue(json)
|
||||
props.onChange(json)
|
||||
}
|
||||
|
||||
const handleAddRow = () => {
|
||||
const newRow: MappingRow = {
|
||||
id: createRowId(),
|
||||
from: '',
|
||||
to: '',
|
||||
}
|
||||
syncRows([...rows, newRow])
|
||||
}
|
||||
|
||||
const handleDeleteRow = (id: string) => {
|
||||
syncRows(rows.filter((row) => row.id !== id))
|
||||
}
|
||||
|
||||
const handleRowChange = (
|
||||
id: string,
|
||||
field: 'from' | 'to',
|
||||
newValue: string
|
||||
) => {
|
||||
const updatedRows = rows.map((row) =>
|
||||
row.id === id ? { ...row, [field]: newValue } : row
|
||||
)
|
||||
syncRows(updatedRows)
|
||||
}
|
||||
|
||||
const handleJsonChange = (newJson: string) => {
|
||||
setJsonValue(newJson)
|
||||
props.onChange(newJson)
|
||||
parseJsonToRows(newJson)
|
||||
}
|
||||
|
||||
const handleFillTemplate = () => {
|
||||
const template = JSON.stringify(
|
||||
{ 'gpt-3.5-turbo': 'gpt-3.5-turbo-0125' },
|
||||
null,
|
||||
2
|
||||
)
|
||||
setJsonValue(template)
|
||||
props.onChange(template)
|
||||
parseJsonToRows(template)
|
||||
}
|
||||
|
||||
const handleModeChange = (nextMode: string) => {
|
||||
if (nextMode !== 'visual' && nextMode !== 'json') return
|
||||
if (nextMode === 'json') {
|
||||
const duplicates = getDuplicateSources(rows)
|
||||
if (duplicates.length === 0) {
|
||||
const json = convertRowsToJson(rows)
|
||||
setJsonValue(json)
|
||||
props.onChange(json)
|
||||
}
|
||||
setMode('json')
|
||||
return
|
||||
}
|
||||
parseJsonToRows(jsonValue)
|
||||
setMode('visual')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
<Tabs value={mode} onValueChange={handleModeChange} className='space-y-2'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<TabsList>
|
||||
<TabsTrigger value='visual'>
|
||||
<Table className='h-4 w-4' aria-hidden='true' />
|
||||
{t('Visual')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value='json'>
|
||||
<Code className='h-4 w-4' aria-hidden='true' />
|
||||
{t('JSON')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<Button
|
||||
type='button'
|
||||
variant='link'
|
||||
size='sm'
|
||||
className='h-auto p-0'
|
||||
onClick={handleFillTemplate}
|
||||
disabled={props.disabled}
|
||||
>
|
||||
{t('Fill Template')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{jsonError && (
|
||||
<Alert variant='destructive'>
|
||||
<AlertDescription>{jsonError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{duplicateSources.length > 0 && (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{t('Duplicate source model(s): {{models}}', {
|
||||
models: duplicateSources.join(', '),
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TabsContent value='visual' className='space-y-2'>
|
||||
{rows.length > 0 ? (
|
||||
<div className='space-y-2'>
|
||||
<div className='grid grid-cols-[1fr_1fr_auto] gap-2 text-sm font-medium'>
|
||||
<div>{t('Original Model')}</div>
|
||||
<div>{t('Replacement Model')}</div>
|
||||
<div className='w-10'></div>
|
||||
</div>
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className='grid grid-cols-[1fr_1fr_auto] gap-2'
|
||||
>
|
||||
<Input
|
||||
value={row.from}
|
||||
onChange={(e) =>
|
||||
handleRowChange(row.id, 'from', e.target.value)
|
||||
}
|
||||
placeholder='gpt-3.5-turbo'
|
||||
disabled={props.disabled}
|
||||
list={sourceListId}
|
||||
/>
|
||||
<Input
|
||||
value={row.to}
|
||||
onChange={(e) =>
|
||||
handleRowChange(row.id, 'to', e.target.value)
|
||||
}
|
||||
placeholder='gpt-3.5-turbo-0125'
|
||||
disabled={props.disabled}
|
||||
list={targetListId}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() => handleDeleteRow(row.id)}
|
||||
disabled={props.disabled}
|
||||
className='h-10 w-10'
|
||||
aria-label={t('Delete mapping')}
|
||||
>
|
||||
<Trash2 className='h-4 w-4' aria-hidden='true' />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-muted-foreground flex h-24 items-center justify-center rounded-md border border-dashed text-sm'>
|
||||
{t(
|
||||
'No model mappings configured. Click "Add Mapping" to get started.'
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleAddRow}
|
||||
disabled={props.disabled}
|
||||
className='w-full'
|
||||
>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
{t('Add Mapping')}
|
||||
</Button>
|
||||
</TabsContent>
|
||||
<TabsContent value='json'>
|
||||
<JsonCodeEditor
|
||||
value={jsonValue}
|
||||
onChange={handleJsonChange}
|
||||
placeholder={t('{"original-model": "replacement-model"}')}
|
||||
disabled={props.disabled}
|
||||
className={jsonError ? 'border-destructive' : undefined}
|
||||
aria-invalid={Boolean(jsonError)}
|
||||
ariaLabel={t('Model Mapping')}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{props.sourceModelOptions && props.sourceModelOptions.length > 0 && (
|
||||
<datalist id={sourceListId}>
|
||||
{props.sourceModelOptions.map((model) => (
|
||||
<option key={model} value={model} />
|
||||
))}
|
||||
</datalist>
|
||||
)}
|
||||
{props.targetModelOptions && props.targetModelOptions.length > 0 && (
|
||||
<datalist id={targetListId}>
|
||||
{props.targetModelOptions.map((model) => (
|
||||
<option key={model} value={model} />
|
||||
))}
|
||||
</datalist>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Minus, Plus } from 'lucide-react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface NumericSpinnerInputProps {
|
||||
value: number | null | undefined
|
||||
onChange: (value: number) => void
|
||||
onCommit?: () => void
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
export function NumericSpinnerInput({
|
||||
value,
|
||||
onChange,
|
||||
onCommit,
|
||||
min = 0,
|
||||
max,
|
||||
step = 1,
|
||||
disabled = false,
|
||||
className,
|
||||
label,
|
||||
}: NumericSpinnerInputProps) {
|
||||
const [localValue, setLocalValue] = useState(String(value ?? 0))
|
||||
const [editing, setEditing] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setLocalValue(String(value ?? 0))
|
||||
}
|
||||
}, [value, editing])
|
||||
|
||||
const clamp = (v: number) => {
|
||||
let result = v
|
||||
if (min !== undefined) result = Math.max(min, result)
|
||||
if (max !== undefined) result = Math.min(max, result)
|
||||
return result
|
||||
}
|
||||
|
||||
const handleIncrement = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (disabled) return
|
||||
const next = clamp((Number(localValue) || 0) + step)
|
||||
setLocalValue(String(next))
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
const handleDecrement = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (disabled) return
|
||||
const next = clamp((Number(localValue) || 0) - step)
|
||||
setLocalValue(String(next))
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
const handleStartEdit = () => {
|
||||
if (disabled) return
|
||||
setEditing(true)
|
||||
requestAnimationFrame(() => inputRef.current?.select())
|
||||
}
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const raw = e.target.value
|
||||
if (raw === '' || raw === '-') {
|
||||
setLocalValue(raw)
|
||||
return
|
||||
}
|
||||
if (!/^-?\d+$/.test(raw)) return
|
||||
setLocalValue(raw)
|
||||
}
|
||||
|
||||
const commitValue = () => {
|
||||
setEditing(false)
|
||||
const num = Number(localValue)
|
||||
if (Number.isNaN(num) || localValue === '' || localValue === '-') {
|
||||
setLocalValue(String(value ?? 0))
|
||||
return
|
||||
}
|
||||
const clamped = clamp(num)
|
||||
setLocalValue(String(clamped))
|
||||
if (clamped !== (value ?? 0)) {
|
||||
onChange(clamped)
|
||||
}
|
||||
}
|
||||
|
||||
const handleControlBlur = (e: React.FocusEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
e.relatedTarget instanceof Node &&
|
||||
e.currentTarget.contains(e.relatedTarget)
|
||||
) {
|
||||
return
|
||||
}
|
||||
onCommit?.()
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
// Blurring routes Enter through the same focusout path as clicking
|
||||
// away (input onBlur -> commitValue, container onBlur -> onCommit),
|
||||
// so commit and onCommit each fire exactly once.
|
||||
inputRef.current?.blur()
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditing(false)
|
||||
setLocalValue(String(value ?? 0))
|
||||
}
|
||||
}
|
||||
|
||||
const atMin = min !== undefined && Number(localValue) <= min
|
||||
const atMax = max !== undefined && Number(localValue) >= max
|
||||
|
||||
return (
|
||||
<div className={cn('inline-flex items-center', className)}>
|
||||
{label && (
|
||||
<Label className='text-muted-foreground mr-1.5 text-xs'>{label}</Label>
|
||||
)}
|
||||
<div
|
||||
onBlur={handleControlBlur}
|
||||
className={cn(
|
||||
'group/spinner border-input inline-flex h-7 items-center gap-0 rounded-md border transition-colors',
|
||||
!disabled && 'hover:bg-muted/60',
|
||||
editing && 'bg-muted/60 ring-primary/30 ring-1'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
tabIndex={-1}
|
||||
aria-label='Decrement'
|
||||
onClick={handleDecrement}
|
||||
disabled={disabled || atMin}
|
||||
className={cn(
|
||||
'text-muted-foreground/0 group-hover/spinner:text-muted-foreground flex h-7 w-6 shrink-0 items-center justify-center rounded-l-md transition-colors',
|
||||
!disabled &&
|
||||
!atMin &&
|
||||
'group-hover/spinner:hover:text-foreground group-hover/spinner:hover:bg-muted',
|
||||
(disabled || atMin) && 'group-hover/spinner:opacity-30'
|
||||
)}
|
||||
>
|
||||
<Minus className='size-3' />
|
||||
</button>
|
||||
|
||||
{editing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type='text'
|
||||
value={localValue}
|
||||
onChange={handleInputChange}
|
||||
onBlur={commitValue}
|
||||
onKeyDown={handleKeyDown}
|
||||
className='h-7 w-10 bg-transparent text-center font-mono text-sm outline-none'
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleStartEdit}
|
||||
disabled={disabled}
|
||||
title={localValue}
|
||||
className={cn(
|
||||
'h-7 min-w-8 max-w-16 cursor-text truncate px-1 text-center font-mono text-sm tabular-nums',
|
||||
disabled && 'cursor-default opacity-50'
|
||||
)}
|
||||
>
|
||||
{localValue}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type='button'
|
||||
tabIndex={-1}
|
||||
aria-label='Increment'
|
||||
onClick={handleIncrement}
|
||||
disabled={disabled || atMax}
|
||||
className={cn(
|
||||
'text-muted-foreground/0 group-hover/spinner:text-muted-foreground flex h-7 w-6 shrink-0 items-center justify-center rounded-r-md transition-colors',
|
||||
!disabled &&
|
||||
!atMax &&
|
||||
'group-hover/spinner:hover:text-foreground group-hover/spinner:hover:bg-muted',
|
||||
(disabled || atMax) && 'group-hover/spinner:opacity-30'
|
||||
)}
|
||||
>
|
||||
<Plus className='size-3' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
// ============================================================================
|
||||
// Channel Types (from constant/channel.go)
|
||||
// All label/name values are i18n keys; use t(value) when displaying.
|
||||
// ============================================================================
|
||||
|
||||
export const CHANNEL_TYPE_NEW_API = 60
|
||||
|
||||
export const CHANNEL_TYPE_TASK_PLUGIN = 61
|
||||
|
||||
export const CHANNEL_TYPES = {
|
||||
0: 'Unknown',
|
||||
1: 'OpenAI',
|
||||
2: 'MjProxy',
|
||||
3: 'Azure',
|
||||
4: 'Ollama',
|
||||
5: 'MjProxyPlus',
|
||||
// 6: 'OpenAIMax',
|
||||
7: 'OhMyGPT',
|
||||
8: 'Custom',
|
||||
// 9: 'AILS',
|
||||
// 10: 'AI Proxy',
|
||||
// 11: 'PaLM',
|
||||
// 12: 'API2GPT',
|
||||
// 13: 'AIGC2D',
|
||||
14: 'Anthropic',
|
||||
15: 'Baidu',
|
||||
16: 'Zhipu',
|
||||
17: 'Ali',
|
||||
18: 'Xunfei',
|
||||
19: '360',
|
||||
20: 'OpenRouter',
|
||||
// 21: 'AI Proxy Library',
|
||||
22: 'FastGPT',
|
||||
23: 'Tencent',
|
||||
24: 'Gemini',
|
||||
25: 'Moonshot',
|
||||
26: 'Zhipu V4',
|
||||
27: 'Perplexity',
|
||||
31: 'LingYiWanWu',
|
||||
33: 'AWS',
|
||||
34: 'Cohere',
|
||||
35: 'MiniMax',
|
||||
36: 'SunoAPI',
|
||||
37: 'Dify',
|
||||
38: 'Jina',
|
||||
39: 'Cloudflare',
|
||||
40: 'SiliconFlow',
|
||||
41: 'Vertex AI',
|
||||
42: 'Mistral',
|
||||
43: 'DeepSeek',
|
||||
44: 'MokaAI',
|
||||
45: 'VolcEngine',
|
||||
46: 'Baidu V2',
|
||||
47: 'Xinference',
|
||||
48: 'xAI',
|
||||
49: 'Coze',
|
||||
50: 'Kling',
|
||||
51: 'Jimeng',
|
||||
52: 'Vidu',
|
||||
53: 'Submodel',
|
||||
54: 'DoubaoVideo',
|
||||
55: 'Sora',
|
||||
56: 'Replicate',
|
||||
57: 'ChatGPT Subscription (Codex)',
|
||||
58: 'Advanced Custom',
|
||||
59: 'Sub2API',
|
||||
60: 'New API',
|
||||
61: 'Task Plugin',
|
||||
} as const
|
||||
|
||||
const CHANNEL_TYPE_DISPLAY_ORDER: number[] = [
|
||||
1, 14, 33, 24, 43, 3, 41, 48, 60, 58, 61, 42, 34, 20, 4, 40, 27, 25, 17, 26,
|
||||
15, 46, 23, 18, 45, 31, 35, 49, 19, 47, 37, 38, 39, 11, 8, 57, 59, 22, 21,
|
||||
44, 2, 5, 36, 50, 51, 52, 53, 54, 55, 56,
|
||||
]
|
||||
|
||||
export const CHANNEL_TYPE_OPTIONS: { value: number; label: string }[] = (() => {
|
||||
const ordered: { value: number; label: string }[] = []
|
||||
const seen = new Set<number>()
|
||||
for (const id of CHANNEL_TYPE_DISPLAY_ORDER) {
|
||||
const label = CHANNEL_TYPES[id as keyof typeof CHANNEL_TYPES]
|
||||
if (label) {
|
||||
ordered.push({ value: id, label })
|
||||
seen.add(id)
|
||||
}
|
||||
}
|
||||
for (const [key, label] of Object.entries(CHANNEL_TYPES)) {
|
||||
const id = Number(key)
|
||||
if (id !== 0 && !seen.has(id)) {
|
||||
ordered.push({ value: id, label })
|
||||
}
|
||||
}
|
||||
return ordered
|
||||
})()
|
||||
|
||||
export function channelTypeOptionsForTaskPluginBind(
|
||||
canBindTaskPlugin: boolean
|
||||
): { value: number; label: string }[] {
|
||||
if (canBindTaskPlugin) {
|
||||
return CHANNEL_TYPE_OPTIONS
|
||||
}
|
||||
return CHANNEL_TYPE_OPTIONS.filter(
|
||||
(option) => option.value !== CHANNEL_TYPE_TASK_PLUGIN
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Channel Status (label values are i18n keys; use t(config.label) in components)
|
||||
// ============================================================================
|
||||
|
||||
export const CHANNEL_STATUS = {
|
||||
UNKNOWN: 0,
|
||||
ENABLED: 1,
|
||||
MANUAL_DISABLED: 2,
|
||||
AUTO_DISABLED: 3,
|
||||
} as const
|
||||
|
||||
export const CHANNEL_STATUS_LABELS = {
|
||||
[CHANNEL_STATUS.UNKNOWN]: 'Unknown',
|
||||
[CHANNEL_STATUS.ENABLED]: 'Enabled',
|
||||
[CHANNEL_STATUS.MANUAL_DISABLED]: 'Disabled',
|
||||
[CHANNEL_STATUS.AUTO_DISABLED]: 'Auto Disabled',
|
||||
} as const
|
||||
|
||||
export const CHANNEL_STATUS_OPTIONS = [
|
||||
{ value: 'all', label: 'All Status' },
|
||||
{ value: 'enabled', label: 'Enabled' },
|
||||
{ value: 'disabled', label: 'Disabled' },
|
||||
] as const
|
||||
|
||||
export const CHANNEL_STATUS_CONFIG = {
|
||||
[CHANNEL_STATUS.UNKNOWN]: {
|
||||
variant: 'neutral' as const,
|
||||
label: 'Unknown',
|
||||
},
|
||||
[CHANNEL_STATUS.ENABLED]: {
|
||||
variant: 'success' as const,
|
||||
label: 'Enabled',
|
||||
},
|
||||
[CHANNEL_STATUS.MANUAL_DISABLED]: {
|
||||
variant: 'danger' as const,
|
||||
label: 'Disabled',
|
||||
},
|
||||
[CHANNEL_STATUS.AUTO_DISABLED]: {
|
||||
variant: 'warning' as const,
|
||||
label: 'Auto Disabled',
|
||||
},
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Multi-Key Status
|
||||
// ============================================================================
|
||||
|
||||
export const MULTI_KEY_STATUS = {
|
||||
ENABLED: 1,
|
||||
MANUAL_DISABLED: 2,
|
||||
AUTO_DISABLED: 3,
|
||||
} as const
|
||||
|
||||
export const MULTI_KEY_STATUS_LABELS = {
|
||||
[MULTI_KEY_STATUS.ENABLED]: 'Enabled',
|
||||
[MULTI_KEY_STATUS.MANUAL_DISABLED]: 'Manual Disabled',
|
||||
[MULTI_KEY_STATUS.AUTO_DISABLED]: 'Auto Disabled',
|
||||
} as const
|
||||
|
||||
export const MULTI_KEY_STATUS_CONFIG = {
|
||||
[MULTI_KEY_STATUS.ENABLED]: {
|
||||
variant: 'success' as const,
|
||||
label: 'Enabled',
|
||||
},
|
||||
[MULTI_KEY_STATUS.MANUAL_DISABLED]: {
|
||||
variant: 'neutral' as const,
|
||||
label: 'Manual Disabled',
|
||||
},
|
||||
[MULTI_KEY_STATUS.AUTO_DISABLED]: {
|
||||
variant: 'danger' as const,
|
||||
label: 'Auto Disabled',
|
||||
},
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Multi-Key Modes
|
||||
// ============================================================================
|
||||
|
||||
export const MULTI_KEY_MODES = [
|
||||
{ value: 'random', label: 'Random' },
|
||||
{ value: 'polling', label: 'Polling' },
|
||||
] as const
|
||||
|
||||
export const ADD_MODE_OPTIONS = [
|
||||
{ value: 'single', label: 'Single Key' },
|
||||
{ value: 'batch', label: 'Batch Add (one key per line)' },
|
||||
{
|
||||
value: 'multi_to_single',
|
||||
label: 'Multi-Key Mode (multiple keys, one channel)',
|
||||
},
|
||||
] as const
|
||||
|
||||
// ============================================================================
|
||||
// Multi-Key Management
|
||||
// ============================================================================
|
||||
|
||||
export const MULTI_KEY_FILTER_OPTIONS = [
|
||||
{ value: 'all', label: 'All Status' },
|
||||
{ value: '1', label: 'Enabled' },
|
||||
{ value: '2', label: 'Manual Disabled' },
|
||||
{ value: '3', label: 'Auto Disabled' },
|
||||
] as const
|
||||
|
||||
export const MULTI_KEY_CONFIRM_MESSAGES = {
|
||||
DELETE:
|
||||
'Are you sure you want to delete this key? This action cannot be undone.',
|
||||
ENABLE: 'Enable this key?',
|
||||
DISABLE: 'Disable this key?',
|
||||
ENABLE_ALL: 'Are you sure you want to enable all keys?',
|
||||
DISABLE_ALL: 'Are you sure you want to disable all enabled keys?',
|
||||
DELETE_DISABLED:
|
||||
'Are you sure you want to delete all auto-disabled keys? This action cannot be undone.',
|
||||
} as const
|
||||
|
||||
// ============================================================================
|
||||
// Auto Ban Options
|
||||
// ============================================================================
|
||||
|
||||
export const AUTO_BAN_OPTIONS = [
|
||||
{ value: 1, label: 'Enabled' },
|
||||
{ value: 0, label: 'Disabled' },
|
||||
] as const
|
||||
|
||||
// ============================================================================
|
||||
// Error / Success Messages (i18n keys: use t(ERROR_MESSAGES.xxx) when displaying)
|
||||
// ============================================================================
|
||||
|
||||
export const ERROR_MESSAGES = {
|
||||
REQUIRED_NAME: 'Channel name is required',
|
||||
REQUIRED_TYPE: 'Channel type is required',
|
||||
REQUIRED_KEY: 'API key is required',
|
||||
REQUIRED_MODELS: 'Models are required',
|
||||
REQUIRED_GROUP: 'Group is required',
|
||||
INVALID_JSON: 'Invalid JSON format',
|
||||
INVALID_MODEL_MAPPING: 'Invalid model mapping format',
|
||||
INVALID_PROXY:
|
||||
'Proxy address must use HTTP, HTTPS, SOCKS5, or SOCKS5H and include a valid host',
|
||||
INVALID_HTTP_PROTOCOL: 'HTTP protocol must be Auto or HTTP/1.1',
|
||||
INVALID_HTTP2_CONNECTION_SHARDS:
|
||||
'HTTP/2 connection shards must be between 1 and 8',
|
||||
INVALID_HTTP1_WITH_SHARDS:
|
||||
'HTTP/2 connection shards must be 1 when HTTP/1.1 is selected',
|
||||
CREATE_FAILED: 'Failed to create channel',
|
||||
UPDATE_FAILED: 'Failed to update channel',
|
||||
DELETE_FAILED: 'Failed to delete channel',
|
||||
TEST_FAILED: 'Failed to test channel',
|
||||
BALANCE_QUERY_FAILED: 'Failed to query balance',
|
||||
FETCH_MODELS_FAILED: 'Failed to fetch models',
|
||||
} as const
|
||||
|
||||
export const SUCCESS_MESSAGES = {
|
||||
CREATED: 'Channel created successfully',
|
||||
UPDATED: 'Channel updated successfully',
|
||||
DELETED: 'Channel deleted successfully',
|
||||
ENABLED: 'Channel enabled successfully',
|
||||
DISABLED: 'Channel disabled successfully',
|
||||
TESTED: 'Channel test completed',
|
||||
BALANCE_QUERIED: 'Balance queried successfully',
|
||||
MODELS_FETCHED: 'Models fetched successfully',
|
||||
COPIED: 'Channel copied successfully',
|
||||
TAG_SET: 'Tag set successfully',
|
||||
BATCH_DELETED: 'Channels deleted successfully',
|
||||
} as const
|
||||
|
||||
// ============================================================================
|
||||
// Default Values
|
||||
// ============================================================================
|
||||
|
||||
export const DEFAULT_PAGE_SIZE = 20
|
||||
|
||||
export const DEFAULT_CHANNEL_VALUES = {
|
||||
name: '',
|
||||
type: 0,
|
||||
base_url: '',
|
||||
key: '',
|
||||
models: '',
|
||||
group: 'default',
|
||||
status: CHANNEL_STATUS.ENABLED,
|
||||
priority: 0,
|
||||
weight: 0,
|
||||
auto_ban: 1,
|
||||
remark: '',
|
||||
} as const
|
||||
|
||||
// ============================================================================
|
||||
// Table Configuration
|
||||
// ============================================================================
|
||||
|
||||
export const CHANNELS_TABLE_PAGE_SIZE_OPTIONS = [10, 20, 50, 100]
|
||||
|
||||
// ============================================================================
|
||||
// Sort Options (label values are i18n keys)
|
||||
// ============================================================================
|
||||
|
||||
export const SORT_OPTIONS = [
|
||||
{ value: 'priority', label: 'Priority (Default)' },
|
||||
{ value: 'id', label: 'ID' },
|
||||
{ value: 'name', label: 'Name' },
|
||||
{ value: 'balance', label: 'Balance' },
|
||||
{ value: 'response_time', label: 'Response Time' },
|
||||
] as const
|
||||
|
||||
// ============================================================================
|
||||
// Balance Display
|
||||
// ============================================================================
|
||||
|
||||
export const BALANCE_THRESHOLDS = {
|
||||
LOW: 1,
|
||||
MEDIUM: 10,
|
||||
HIGH: 100,
|
||||
} as const
|
||||
|
||||
// ============================================================================
|
||||
// Response Time Thresholds (in ms)
|
||||
// ============================================================================
|
||||
|
||||
export const RESPONSE_TIME_THRESHOLDS = {
|
||||
EXCELLENT: 500,
|
||||
GOOD: 1000,
|
||||
FAIR: 2000,
|
||||
POOR: 5000,
|
||||
} as const
|
||||
|
||||
export const RESPONSE_TIME_CONFIG = {
|
||||
EXCELLENT: { variant: 'success' as const, label: 'Excellent' },
|
||||
GOOD: { variant: 'success' as const, label: 'Good' },
|
||||
FAIR: { variant: 'warning' as const, label: 'Fair' },
|
||||
POOR: { variant: 'danger' as const, label: 'Poor' },
|
||||
UNKNOWN: { variant: 'neutral' as const, label: 'Not tested' },
|
||||
} as const
|
||||
|
||||
// ============================================================================
|
||||
// Field Hints and Placeholders (i18n keys; use t() when displaying)
|
||||
// ============================================================================
|
||||
|
||||
export const FIELD_PLACEHOLDERS = {
|
||||
NAME: 'e.g., OpenAI GPT-4 Production',
|
||||
BASE_URL: 'Leave empty to use default',
|
||||
KEY: 'API Key (one per line for batch mode)',
|
||||
MODELS: 'Comma-separated model names, e.g., gpt-4,gpt-3.5-turbo',
|
||||
GROUP: 'Please Select user groups that can access this channel.',
|
||||
MODEL_MAPPING: '{"request_model": "actual_model"}',
|
||||
TEST_MODEL: 'Model to use for testing',
|
||||
TAG: 'Optional tag for grouping channels',
|
||||
REMARK: 'Optional notes about this channel',
|
||||
PARAM_OVERRIDE: '{"temperature": 0.7}',
|
||||
HEADER_OVERRIDE: '{"X-Custom-Header": "value"}',
|
||||
STATUS_CODE_MAPPING: '{"400": "500"}',
|
||||
} as const
|
||||
|
||||
export const FIELD_DESCRIPTIONS = {
|
||||
NAME: 'Friendly name to identify this channel',
|
||||
TYPE: 'Provider type (OpenAI, Anthropic, etc.)',
|
||||
BASE_URL: 'Custom API base URL. Leave empty to use provider default.',
|
||||
KEY: 'API key from the provider',
|
||||
MODELS:
|
||||
'List of models supported by this channel. Use comma to separate multiple models.',
|
||||
GROUP: 'User groups that can access this channel. ',
|
||||
MODEL_MAPPING:
|
||||
'Map request model names to actual provider model names (JSON format)',
|
||||
PRIORITY: 'Higher priority channels are selected first',
|
||||
WEIGHT: 'Used for load balancing. Higher weight = more requests',
|
||||
TEST_MODEL: 'Model to use when testing channel connectivity',
|
||||
AUTO_BAN: 'Automatically disable channel on repeated failures',
|
||||
STATUS_CODE_MAPPING: 'Map response status codes (JSON format)',
|
||||
TAG: 'Group channels by tag for batch operations',
|
||||
REMARK: 'Internal notes (not shown to users)',
|
||||
SETTING: 'Channel-specific settings (JSON format)',
|
||||
PARAM_OVERRIDE: 'Override request parameters (JSON format)',
|
||||
HEADER_OVERRIDE: 'Override request headers (JSON format)',
|
||||
MULTI_KEY_MODE: 'How to select keys: random or sequential polling',
|
||||
BATCH_ADD: 'Create multiple channels from multiple keys',
|
||||
OPENAI_ORG: 'OpenAI Organization ID (optional)',
|
||||
} as const
|
||||
|
||||
// ============================================================================
|
||||
// Channel Type Specific Configurations
|
||||
// ============================================================================
|
||||
|
||||
export const MODEL_FETCHABLE_TYPES = new Set([
|
||||
1, 4, 14, 17, 20, 23, 24, 25, 26, 27, 31, 34, 35, 40, 42, 43, 47, 48, 57, 58,
|
||||
59, 60,
|
||||
])
|
||||
|
||||
export const FIELD_PASSTHROUGH_TYPES = new Set([
|
||||
1,
|
||||
14,
|
||||
57,
|
||||
58,
|
||||
59,
|
||||
CHANNEL_TYPE_NEW_API,
|
||||
])
|
||||
|
||||
export const OPENAI_FIELD_PASSTHROUGH_TYPES = new Set([
|
||||
1,
|
||||
57,
|
||||
58,
|
||||
59,
|
||||
CHANNEL_TYPE_NEW_API,
|
||||
])
|
||||
|
||||
export const CLAUDE_FIELD_PASSTHROUGH_TYPES = new Set([
|
||||
14,
|
||||
58,
|
||||
59,
|
||||
CHANNEL_TYPE_NEW_API,
|
||||
])
|
||||
|
||||
export const TYPE_TO_KEY_PROMPT: Record<number, string> = {
|
||||
15: 'Format: APIKey|SecretKey',
|
||||
18: 'Format: APPID|APISecret|APIKey',
|
||||
22: 'Format: APIKey-AppId, e.g., fastgpt-0sp2gtvfdgyi4k30jwlgwf1i-64f335d84283f05518e9e041',
|
||||
23: 'Format: TokenHub API Key, or legacy AppId|SecretId|SecretKey',
|
||||
33: 'Format: Ak|Sk|Region',
|
||||
50: 'Format: AccessKey|SecretKey (or just ApiKey if upstream is New API)',
|
||||
51: 'Format: Access Key ID|Secret Access Key',
|
||||
57: 'Paste Codex OAuth JSON credential (access_token / refresh_token / account_id)',
|
||||
59: 'Enter API key for this channel',
|
||||
60: 'Enter API key for this channel',
|
||||
}
|
||||
|
||||
export const CHANNEL_TYPE_WARNINGS: Record<number, string> = {
|
||||
3: 'For channels added after May 10, 2025, no need to remove "." from model names during deployment',
|
||||
8: 'If connecting to upstream One API or New API relay projects, use OpenAI type instead unless you know what you are doing',
|
||||
37: 'Dify channels only support chatflow and agent, and agent does not support images',
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
|
||||
import { SecureVerificationDialog } from '@/features/auth/secure-verification'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
import { useChannelKeyDisclosure } from '../use-channel-key-disclosure'
|
||||
|
||||
function Harness(props: { open: boolean; channelId: number }) {
|
||||
const disclosure = useChannelKeyDisclosure(props.open, props.channelId)
|
||||
return (
|
||||
<>
|
||||
<button type='button' onClick={disclosure.handleRevealKey}>
|
||||
Reveal
|
||||
</button>
|
||||
<output aria-label='Channel key'>
|
||||
{disclosure.channelKey ?? 'Hidden'}
|
||||
</output>
|
||||
<SecureVerificationDialog {...disclosure.verification.dialogProps} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function deferredResponse<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((finish) => {
|
||||
resolve = finish
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function channelVerification() {
|
||||
vi.spyOn(api, 'get').mockResolvedValue({
|
||||
data: {
|
||||
success: true,
|
||||
data: {
|
||||
scope: 'channel.key.read',
|
||||
methods: [{ method: '2fa', available: true }],
|
||||
oauth_providers: [],
|
||||
password_encryption_enabled: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
return {
|
||||
data: {
|
||||
success: true,
|
||||
data: {
|
||||
proof_token: 'channel-proof',
|
||||
method: '2fa',
|
||||
scope: 'channel.key.read',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 60,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it.each(['switch', 'close'] as const)(
|
||||
'discards a pending channel key response after %s',
|
||||
async (change) => {
|
||||
const proof = channelVerification()
|
||||
const keyReply = deferredResponse<{
|
||||
data: { success: boolean; data: { key: string } }
|
||||
}>()
|
||||
const post = vi.spyOn(api, 'post').mockImplementation((url) => {
|
||||
if (url === '/api/verify') return Promise.resolve(proof)
|
||||
if (url === '/api/channel/123/key') return keyReply.promise
|
||||
throw new Error(`Unexpected POST ${url}`)
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
const view = render(<Harness open channelId={123} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Reveal' }))
|
||||
await user.type(
|
||||
await screen.findByLabelText('Authenticator code or backup code'),
|
||||
'123456'
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Verify' }))
|
||||
await waitFor(() =>
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
'/api/channel/123/key',
|
||||
undefined,
|
||||
expect.anything()
|
||||
)
|
||||
)
|
||||
view.rerender(
|
||||
<Harness
|
||||
open={change !== 'close'}
|
||||
channelId={change === 'switch' ? 456 : 123}
|
||||
/>
|
||||
)
|
||||
await act(async () => {
|
||||
keyReply.resolve({
|
||||
data: { success: true, data: { key: 'CHANNEL_A_SECRET' } },
|
||||
})
|
||||
await keyReply.promise
|
||||
})
|
||||
expect(screen.getByLabelText('Channel key')).toHaveTextContent('Hidden')
|
||||
expect(screen.queryByText('CHANNEL_A_SECRET')).not.toBeInTheDocument()
|
||||
}
|
||||
)
|
||||
|
||||
it('cancels a pending verification when the selected channel changes', async () => {
|
||||
channelVerification()
|
||||
const reply = deferredResponse<{
|
||||
data: { success: boolean; data: Record<string, unknown> }
|
||||
}>()
|
||||
const post = vi.spyOn(api, 'post').mockReturnValue(reply.promise)
|
||||
const user = userEvent.setup()
|
||||
const view = render(<Harness open channelId={123} />)
|
||||
await user.click(screen.getByRole('button', { name: 'Reveal' }))
|
||||
await user.type(
|
||||
await screen.findByLabelText('Authenticator code or backup code'),
|
||||
'123456'
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Verify' }))
|
||||
view.rerender(<Harness open channelId={456} />)
|
||||
await act(async () => {
|
||||
reply.resolve({
|
||||
data: {
|
||||
success: true,
|
||||
data: {
|
||||
proof_token: 'late-proof',
|
||||
scope: 'channel.key.read',
|
||||
method: '2fa',
|
||||
expires_at: Math.floor(Date.now() / 1000) + 60,
|
||||
},
|
||||
},
|
||||
})
|
||||
await reply.promise
|
||||
})
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(post.mock.calls.map(([url]) => url)).toEqual(['/api/verify'])
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useSecureVerification } from '@/features/auth/secure-verification'
|
||||
import { AuthOperationError } from '@/lib/secure-verification'
|
||||
|
||||
import { getChannelKey } from '../api'
|
||||
|
||||
export function useChannelKeyDisclosure(
|
||||
open: boolean,
|
||||
channelId: number | null
|
||||
) {
|
||||
const { t } = useTranslation()
|
||||
const verification = useSecureVerification()
|
||||
const cancelVerification = verification.cancel
|
||||
const requestVerification = verification.requestVerification
|
||||
const [disclosedKey, setDisclosedKey] = useState<{
|
||||
channelId: number
|
||||
key: string
|
||||
} | null>(null)
|
||||
const [isChannelKeyLoading, setIsChannelKeyLoading] = useState(false)
|
||||
const operation = useRef<AbortController | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setDisclosedKey(null)
|
||||
setIsChannelKeyLoading(false)
|
||||
return () => {
|
||||
operation.current?.abort()
|
||||
operation.current = null
|
||||
cancelVerification()
|
||||
}
|
||||
}, [open, channelId, cancelVerification])
|
||||
|
||||
const handleRevealKey = useCallback(async () => {
|
||||
if (!channelId || !open || operation.current) return
|
||||
const current = new AbortController()
|
||||
operation.current = current
|
||||
try {
|
||||
const proof = await requestVerification({
|
||||
scope: 'channel.key.read',
|
||||
context: { channel_id: channelId },
|
||||
title: t('Verify to view channel key'),
|
||||
description: t(
|
||||
'Use Passkey or 2FA to confirm your identity before revealing this channel key.'
|
||||
),
|
||||
})
|
||||
if (!proof || operation.current !== current) return
|
||||
setIsChannelKeyLoading(true)
|
||||
const res = await getChannelKey(
|
||||
channelId,
|
||||
proof.proof_token,
|
||||
current.signal
|
||||
)
|
||||
if (operation.current !== current) return
|
||||
if (!res.success) {
|
||||
throw new Error(res.message || t('Failed to fetch channel key'))
|
||||
}
|
||||
setDisclosedKey({ channelId, key: res.data?.key ?? '' })
|
||||
toast.success(t('Channel key unlocked'))
|
||||
} catch (error) {
|
||||
if (operation.current === current && !current.signal.aborted) {
|
||||
toast.error(t(AuthOperationError.from(error).message))
|
||||
}
|
||||
} finally {
|
||||
if (operation.current === current) {
|
||||
operation.current = null
|
||||
setIsChannelKeyLoading(false)
|
||||
}
|
||||
}
|
||||
}, [channelId, open, requestVerification, t])
|
||||
|
||||
const channelKey =
|
||||
open && disclosedKey?.channelId === channelId ? disclosedKey.key : null
|
||||
return { channelKey, isChannelKeyLoading, handleRevealKey, verification }
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
ADMIN_PERMISSION_ACTIONS,
|
||||
ADMIN_PERMISSION_RESOURCES,
|
||||
hasPermission,
|
||||
} from '@/lib/admin-permissions'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
import { createChannel, updateChannel } from '../api'
|
||||
import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
|
||||
import {
|
||||
transformFormDataToCreatePayload,
|
||||
transformFormDataToUpdatePayload,
|
||||
type ChannelFormValues,
|
||||
} from '../lib'
|
||||
import type { Channel } from '../types'
|
||||
|
||||
type UseChannelMutateFormParams = {
|
||||
currentRow?: Channel | null
|
||||
isEditing: boolean
|
||||
isMultiKeyChannel: boolean
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
const SENSITIVE_UPDATE_FIELDS = [
|
||||
'type',
|
||||
'key',
|
||||
'base_url',
|
||||
'openai_organization',
|
||||
'param_override',
|
||||
'header_override',
|
||||
'setting',
|
||||
'settings',
|
||||
'other',
|
||||
] satisfies (keyof Channel)[]
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string | undefined {
|
||||
if (error instanceof Error && typeof error.message === 'string') {
|
||||
return error.message
|
||||
}
|
||||
|
||||
if (!isRecord(error)) return undefined
|
||||
|
||||
const response = error.response
|
||||
if (isRecord(response)) {
|
||||
const data = response.data
|
||||
if (isRecord(data)) {
|
||||
const message = data.message
|
||||
if (typeof message === 'string') return message
|
||||
}
|
||||
}
|
||||
|
||||
const message = error.message
|
||||
if (typeof message === 'string') return message
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function useChannelMutateForm(props: UseChannelMutateFormParams) {
|
||||
const { t } = useTranslation()
|
||||
const currentUser = useAuthStore((s) => s.auth.user)
|
||||
const canEditSensitive = hasPermission(
|
||||
currentUser,
|
||||
ADMIN_PERMISSION_RESOURCES.CHANNEL,
|
||||
ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
|
||||
)
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: ChannelFormValues): Promise<string> => {
|
||||
if (props.isEditing && props.currentRow) {
|
||||
const payload = transformFormDataToUpdatePayload(
|
||||
data,
|
||||
props.currentRow.id
|
||||
)
|
||||
if (!data.key?.trim()) {
|
||||
delete payload.key
|
||||
}
|
||||
if (!canEditSensitive) {
|
||||
for (const field of SENSITIVE_UPDATE_FIELDS) {
|
||||
delete payload[field]
|
||||
}
|
||||
}
|
||||
const payloadWithKeyMode =
|
||||
canEditSensitive &&
|
||||
props.isMultiKeyChannel &&
|
||||
data.key?.trim() &&
|
||||
data.key_mode
|
||||
? {
|
||||
...payload,
|
||||
key_mode: data.key_mode,
|
||||
}
|
||||
: payload
|
||||
|
||||
const response = await updateChannel(
|
||||
props.currentRow.id,
|
||||
payloadWithKeyMode
|
||||
)
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || t(ERROR_MESSAGES.UPDATE_FAILED))
|
||||
}
|
||||
return SUCCESS_MESSAGES.UPDATED
|
||||
}
|
||||
|
||||
const payload = transformFormDataToCreatePayload(data)
|
||||
const response = await createChannel(payload)
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || t(ERROR_MESSAGES.CREATE_FAILED))
|
||||
}
|
||||
return SUCCESS_MESSAGES.CREATED
|
||||
},
|
||||
onSuccess: (messageKey) => {
|
||||
toast.success(t(messageKey))
|
||||
props.onSuccess()
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast.error(getErrorMessage(error) || t(ERROR_MESSAGES.CREATE_FAILED))
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useRef, useState, useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { api, type ApiRequestConfig } from '@/lib/api'
|
||||
|
||||
import { normalizeModelList } from '../lib/upstream-update-utils'
|
||||
|
||||
const upstreamUpdateRequestConfig = {
|
||||
skipBusinessError: true,
|
||||
skipErrorHandler: true,
|
||||
} satisfies ApiRequestConfig
|
||||
|
||||
function getManualIgnoredModelCount(settings: unknown): number {
|
||||
let parsed: Record<string, unknown> | null = null
|
||||
if (settings && typeof settings === 'object')
|
||||
parsed = settings as Record<string, unknown>
|
||||
else if (typeof settings === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(settings)
|
||||
} catch {
|
||||
parsed = null
|
||||
}
|
||||
}
|
||||
if (!parsed) return 0
|
||||
return normalizeModelList(
|
||||
(parsed.upstream_model_update_ignored_models as unknown[]) || []
|
||||
).length
|
||||
}
|
||||
|
||||
export function useChannelUpstreamUpdates(refresh: () => Promise<void>) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [channel, setChannel] = useState<{
|
||||
id: number
|
||||
[key: string]: unknown
|
||||
} | null>(null)
|
||||
const [addModels, setAddModels] = useState<string[]>([])
|
||||
const [removeModels, setRemoveModels] = useState<string[]>([])
|
||||
const [preferredTab, setPreferredTab] = useState<'add' | 'remove'>('add')
|
||||
const [applyLoading, setApplyLoading] = useState(false)
|
||||
const [detectAllLoading, setDetectAllLoading] = useState(false)
|
||||
const [applyAllLoading, setApplyAllLoading] = useState(false)
|
||||
|
||||
const applyRef = useRef(false)
|
||||
const detectRef = useRef(false)
|
||||
const detectAllRef = useRef(false)
|
||||
const applyAllRef = useRef(false)
|
||||
|
||||
const openModal = useCallback(
|
||||
(
|
||||
record: { id: number; [key: string]: unknown } | null,
|
||||
pendingAdd: string[] = [],
|
||||
pendingRemove: string[] = [],
|
||||
tab: 'add' | 'remove' = 'add'
|
||||
) => {
|
||||
const normAdd = normalizeModelList(pendingAdd)
|
||||
const normRemove = normalizeModelList(pendingRemove)
|
||||
if (!record?.id || (normAdd.length === 0 && normRemove.length === 0)) {
|
||||
toast.info(t('No processable upstream model updates for this channel'))
|
||||
return
|
||||
}
|
||||
setChannel(record)
|
||||
setAddModels(normAdd)
|
||||
setRemoveModels(normRemove)
|
||||
setPreferredTab(tab)
|
||||
setShowModal(true)
|
||||
},
|
||||
[t]
|
||||
)
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setShowModal(false)
|
||||
setChannel(null)
|
||||
setAddModels([])
|
||||
setRemoveModels([])
|
||||
setPreferredTab('add')
|
||||
}, [])
|
||||
|
||||
const applyUpdates = useCallback(
|
||||
async ({
|
||||
addModels: selectedAdd = [],
|
||||
removeModels: selectedRemove = [],
|
||||
}: {
|
||||
addModels?: string[]
|
||||
removeModels?: string[]
|
||||
} = {}) => {
|
||||
if (applyRef.current) return
|
||||
if (!channel?.id) {
|
||||
closeModal()
|
||||
return
|
||||
}
|
||||
applyRef.current = true
|
||||
setApplyLoading(true)
|
||||
try {
|
||||
const normSelectedAdd = normalizeModelList(selectedAdd)
|
||||
const selectedAddSet = new Set(normSelectedAdd)
|
||||
const ignoreModels = addModels.filter((m) => !selectedAddSet.has(m))
|
||||
|
||||
const res = await api.post(
|
||||
'/api/channel/upstream_updates/apply',
|
||||
{
|
||||
id: channel.id,
|
||||
add_models: normSelectedAdd,
|
||||
ignore_models: ignoreModels,
|
||||
remove_models: normalizeModelList(selectedRemove),
|
||||
},
|
||||
upstreamUpdateRequestConfig
|
||||
)
|
||||
const { success, message, data } = res.data || {}
|
||||
if (!success) {
|
||||
toast.error(message || t('Operation failed'))
|
||||
return
|
||||
}
|
||||
|
||||
toast.success(
|
||||
t(
|
||||
'Upstream model updates applied: {{added}} added, {{removed}} removed, {{ignored}} ignored this time, {{totalIgnored}} total ignored models',
|
||||
{
|
||||
added: data?.added_models?.length || 0,
|
||||
removed: data?.removed_models?.length || 0,
|
||||
ignored: normalizeModelList(ignoreModels).length,
|
||||
totalIgnored: getManualIgnoredModelCount(data?.settings),
|
||||
}
|
||||
)
|
||||
)
|
||||
closeModal()
|
||||
await refresh()
|
||||
} catch (e: unknown) {
|
||||
const err = e as {
|
||||
response?: { data?: { message?: string } }
|
||||
message?: string
|
||||
}
|
||||
toast.error(
|
||||
err?.response?.data?.message || err?.message || t('Operation failed')
|
||||
)
|
||||
} finally {
|
||||
applyRef.current = false
|
||||
setApplyLoading(false)
|
||||
}
|
||||
},
|
||||
[channel, addModels, closeModal, refresh, t]
|
||||
)
|
||||
|
||||
const applyAllUpdates = useCallback(async () => {
|
||||
if (applyAllRef.current) return
|
||||
applyAllRef.current = true
|
||||
setApplyAllLoading(true)
|
||||
try {
|
||||
const res = await api.post(
|
||||
'/api/channel/upstream_updates/apply_all',
|
||||
{},
|
||||
upstreamUpdateRequestConfig
|
||||
)
|
||||
const { success, message, data } = res.data || {}
|
||||
if (!success) {
|
||||
toast.error(message || t('Batch processing failed'))
|
||||
return
|
||||
}
|
||||
|
||||
toast.success(
|
||||
t(
|
||||
'Batch upstream model updates applied: {{channels}} channels, {{added}} added, {{removed}} removed, {{fails}} failed',
|
||||
{
|
||||
channels: data?.processed_channels || 0,
|
||||
added: data?.added_models || 0,
|
||||
removed: data?.removed_models || 0,
|
||||
fails: (data?.failed_channel_ids || []).length,
|
||||
}
|
||||
)
|
||||
)
|
||||
await refresh()
|
||||
} catch (e: unknown) {
|
||||
const err = e as {
|
||||
response?: { data?: { message?: string } }
|
||||
message?: string
|
||||
}
|
||||
toast.error(
|
||||
err?.response?.data?.message ||
|
||||
err?.message ||
|
||||
t('Batch processing failed')
|
||||
)
|
||||
} finally {
|
||||
applyAllRef.current = false
|
||||
setApplyAllLoading(false)
|
||||
}
|
||||
}, [refresh, t])
|
||||
|
||||
const detectChannelUpdates = useCallback(
|
||||
async (ch: { id: number; [key: string]: unknown } | null) => {
|
||||
if (detectRef.current || !ch?.id) return
|
||||
detectRef.current = true
|
||||
try {
|
||||
const res = await api.post(
|
||||
'/api/channel/upstream_updates/detect',
|
||||
{ id: ch.id },
|
||||
upstreamUpdateRequestConfig
|
||||
)
|
||||
const { success, message, data } = res.data || {}
|
||||
if (!success) {
|
||||
toast.error(message || t('Detection failed'))
|
||||
return
|
||||
}
|
||||
|
||||
toast.success(
|
||||
t('Detection complete: {{add}} to add, {{remove}} to remove', {
|
||||
add: data?.add_models?.length || 0,
|
||||
remove: data?.remove_models?.length || 0,
|
||||
})
|
||||
)
|
||||
await refresh()
|
||||
} catch (e: unknown) {
|
||||
const err = e as {
|
||||
response?: { data?: { message?: string } }
|
||||
message?: string
|
||||
}
|
||||
toast.error(
|
||||
err?.response?.data?.message || err?.message || t('Detection failed')
|
||||
)
|
||||
} finally {
|
||||
detectRef.current = false
|
||||
}
|
||||
},
|
||||
[refresh, t]
|
||||
)
|
||||
|
||||
const detectAllUpdates = useCallback(async () => {
|
||||
if (detectAllRef.current) return
|
||||
detectAllRef.current = true
|
||||
setDetectAllLoading(true)
|
||||
try {
|
||||
const res = await api.post(
|
||||
'/api/channel/upstream_updates/detect_all',
|
||||
{},
|
||||
upstreamUpdateRequestConfig
|
||||
)
|
||||
const { success, message } = res.data || {}
|
||||
if (!success) {
|
||||
toast.error(message || t('Batch detection failed'))
|
||||
return
|
||||
}
|
||||
|
||||
toast.success(
|
||||
t(
|
||||
'Upstream model detection task started. Track progress in System Info, then refresh to review staged updates.'
|
||||
)
|
||||
)
|
||||
await refresh()
|
||||
} catch (e: unknown) {
|
||||
const err = e as {
|
||||
response?: { data?: { message?: string } }
|
||||
message?: string
|
||||
}
|
||||
toast.error(
|
||||
err?.response?.data?.message ||
|
||||
err?.message ||
|
||||
t('Batch detection failed')
|
||||
)
|
||||
} finally {
|
||||
detectAllRef.current = false
|
||||
setDetectAllLoading(false)
|
||||
}
|
||||
}, [refresh, t])
|
||||
|
||||
// Memoized so consumers (and the channels context value built from this) get
|
||||
// a stable reference unless an actual field changes. Callbacks above are all
|
||||
// useCallback-stable, so this only changes when relevant state changes.
|
||||
return useMemo(
|
||||
() => ({
|
||||
showModal,
|
||||
channel,
|
||||
addModels,
|
||||
removeModels,
|
||||
preferredTab,
|
||||
applyLoading,
|
||||
detectAllLoading,
|
||||
applyAllLoading,
|
||||
openModal,
|
||||
closeModal,
|
||||
applyUpdates,
|
||||
applyAllUpdates,
|
||||
detectChannelUpdates,
|
||||
detectAllUpdates,
|
||||
}),
|
||||
[
|
||||
showModal,
|
||||
channel,
|
||||
addModels,
|
||||
removeModels,
|
||||
preferredTab,
|
||||
applyLoading,
|
||||
detectAllLoading,
|
||||
applyAllLoading,
|
||||
openModal,
|
||||
closeModal,
|
||||
applyUpdates,
|
||||
applyAllUpdates,
|
||||
detectChannelUpdates,
|
||||
detectAllUpdates,
|
||||
]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Settings2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { SectionPageLayout } from '@/components/layout'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { ROLE } from '@/lib/roles'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
import { getChannelOps } from './api'
|
||||
import { ChannelsDialogs } from './components/channels-dialogs'
|
||||
import { ChannelsPrimaryButtons } from './components/channels-primary-buttons'
|
||||
import { ChannelsProvider } from './components/channels-provider'
|
||||
import { ChannelsTable } from './components/channels-table'
|
||||
|
||||
export function Channels() {
|
||||
const { t } = useTranslation()
|
||||
const isRoot = useAuthStore(
|
||||
(state) => state.auth.user?.role === ROLE.SUPER_ADMIN
|
||||
)
|
||||
const channelOpsQuery = useQuery({
|
||||
queryKey: ['channel-ops'],
|
||||
queryFn: getChannelOps,
|
||||
retry: false,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
const retryTimes = channelOpsQuery.data?.data?.retry_times
|
||||
const retryLabel =
|
||||
typeof retryTimes === 'number' ? `${t('Max Retries')}: ${retryTimes}` : null
|
||||
let retryBadge = null
|
||||
if (retryLabel) {
|
||||
retryBadge = isRoot ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='shrink-0 cursor-pointer'
|
||||
aria-label={t('Retry Settings')}
|
||||
render={
|
||||
<Link
|
||||
to='/system-settings/models/$section'
|
||||
params={{ section: 'routing-reliability' }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span>{retryLabel}</span>
|
||||
<Settings2 data-icon='inline-end' />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('Retry Settings')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Badge variant='outline' className='shrink-0'>
|
||||
{retryLabel}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChannelsProvider>
|
||||
<SectionPageLayout fixedContent>
|
||||
<SectionPageLayout.Title>
|
||||
<span className='flex min-w-0 items-center gap-2'>
|
||||
<span className='truncate'>{t('Channels')}</span>
|
||||
{retryBadge}
|
||||
</span>
|
||||
</SectionPageLayout.Title>
|
||||
<SectionPageLayout.Actions>
|
||||
<ChannelsPrimaryButtons />
|
||||
</SectionPageLayout.Actions>
|
||||
<SectionPageLayout.Content>
|
||||
<ChannelsTable />
|
||||
</SectionPageLayout.Content>
|
||||
</SectionPageLayout>
|
||||
|
||||
<ChannelsDialogs />
|
||||
</ChannelsProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import {
|
||||
CHANNEL_FIELD_UPDATE_DELAY_MS,
|
||||
createChannelFieldUpdateScheduler,
|
||||
} from '../channel-field-update'
|
||||
|
||||
function createFakeTimers() {
|
||||
const pending = new Map<number, () => void>()
|
||||
let nextId = 1
|
||||
|
||||
return {
|
||||
timers: {
|
||||
setTimeout: (callback: () => void, delay: number) => {
|
||||
expect(delay).toBe(CHANNEL_FIELD_UPDATE_DELAY_MS)
|
||||
const id = nextId++
|
||||
pending.set(id, callback)
|
||||
return id
|
||||
},
|
||||
clearTimeout: (id: number) => {
|
||||
pending.delete(id)
|
||||
},
|
||||
},
|
||||
fireAll() {
|
||||
const callbacks = [...pending.values()]
|
||||
pending.clear()
|
||||
for (const callback of callbacks) callback()
|
||||
},
|
||||
get pendingCount() {
|
||||
return pending.size
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('channel field update scheduler', () => {
|
||||
test('coalesces rapid schedules into one update with the latest value', () => {
|
||||
const fake = createFakeTimers()
|
||||
const updates: number[] = []
|
||||
const scheduler = createChannelFieldUpdateScheduler(
|
||||
(value) => updates.push(value),
|
||||
fake.timers
|
||||
)
|
||||
|
||||
scheduler.schedule(1)
|
||||
scheduler.schedule(2)
|
||||
scheduler.schedule(3)
|
||||
expect(updates).toEqual([])
|
||||
expect(fake.pendingCount).toBe(1)
|
||||
|
||||
fake.fireAll()
|
||||
expect(updates).toEqual([3])
|
||||
})
|
||||
|
||||
test('flush commits the pending value immediately and cancels the timer', () => {
|
||||
const fake = createFakeTimers()
|
||||
const updates: number[] = []
|
||||
const scheduler = createChannelFieldUpdateScheduler(
|
||||
(value) => updates.push(value),
|
||||
fake.timers
|
||||
)
|
||||
|
||||
scheduler.schedule(7)
|
||||
scheduler.flush()
|
||||
expect(updates).toEqual([7])
|
||||
expect(fake.pendingCount).toBe(0)
|
||||
|
||||
fake.fireAll()
|
||||
expect(updates).toEqual([7])
|
||||
})
|
||||
|
||||
test('flush without a pending value does nothing', () => {
|
||||
const fake = createFakeTimers()
|
||||
const updates: number[] = []
|
||||
const scheduler = createChannelFieldUpdateScheduler(
|
||||
(value) => updates.push(value),
|
||||
fake.timers
|
||||
)
|
||||
|
||||
scheduler.flush()
|
||||
scheduler.schedule(5)
|
||||
scheduler.flush()
|
||||
scheduler.flush()
|
||||
expect(updates).toEqual([5])
|
||||
})
|
||||
|
||||
test('preserves a pending value of 0', () => {
|
||||
const fake = createFakeTimers()
|
||||
const updates: number[] = []
|
||||
const scheduler = createChannelFieldUpdateScheduler(
|
||||
(value) => updates.push(value),
|
||||
fake.timers
|
||||
)
|
||||
|
||||
scheduler.schedule(0)
|
||||
scheduler.flush()
|
||||
expect(updates).toEqual([0])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import type { Channel } from '../../types'
|
||||
import { getChannelTableRowId, type TagRow } from '../channel-utils'
|
||||
|
||||
function channel(id: number): Channel {
|
||||
return { id } as Channel
|
||||
}
|
||||
|
||||
describe('channel table row identity', () => {
|
||||
test('keeps each channel identity when priority updates reorder the rows', () => {
|
||||
const first = channel(101)
|
||||
const updated = channel(202)
|
||||
const third = channel(303)
|
||||
|
||||
const beforeUpdate = [first, updated, third].map(getChannelTableRowId)
|
||||
const afterUpdate = [updated, first, third].map(getChannelTableRowId)
|
||||
|
||||
expect(beforeUpdate).toEqual(['channel:101', 'channel:202', 'channel:303'])
|
||||
expect(afterUpdate).toEqual(['channel:202', 'channel:101', 'channel:303'])
|
||||
})
|
||||
|
||||
test('uses separate namespaces for tag and channel rows', () => {
|
||||
const tagRow = {
|
||||
id: '202' as unknown as number,
|
||||
tag: '202',
|
||||
children: [channel(202)],
|
||||
} as TagRow
|
||||
|
||||
expect(getChannelTableRowId(tagRow)).toBe('tag:202')
|
||||
expect(getChannelTableRowId(channel(202))).toBe('channel:202')
|
||||
})
|
||||
})
|
||||
Loaded 100 of 2381 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user