feat: publish v1.0.0-rc.36-custom.1

This commit is contained in:
Codex committed 2026-09-11 05:07:48 +08:00
commit 3a7e8f3b54
2381 files changed
+551494

No files matched your search

+31
View File
@@ -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
}
+185
View File
@@ -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>
)
}
+23
View File
@@ -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
}
+181
View File
@@ -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)
})
})
+282
View File
@@ -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
}
+63
View File
@@ -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>
)
}
+78
View File
@@ -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,
}
}
+118
View File
@@ -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'
}
+186
View File
@@ -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)
})
}
+103
View File
@@ -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)
}
+61
View File
@@ -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
}
+80
View File
@@ -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} />
}
+53
View File
@@ -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>
)
}
+132
View File
@@ -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,
}
}
+21
View File
@@ -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'
+40
View File
@@ -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>
)
}
+65
View File
@@ -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>
)
}
+61
View File
@@ -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>
)
}
+216
View File
@@ -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
}
+669
View File
@@ -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>
)
}
@@ -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>
)
}
+450
View File
@@ -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,
]
)
}
+107
View File
@@ -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