refactor(auth): replace dashboard sessions with stateless tokens and session control (#6329)

* refactor(auth): replace dashboard sessions with stateless tokens

* feat(auth): harden session issuance and distributed enforcement

* fix(proxy): preserve trusted proxy compatibility defaults

* refactor: address dashboard auth review feedback

* refactor: remove classic frontend and flatten web app
This commit is contained in:
Calcium-Ion
2026-07-20 16:48:43 +08:00
committed by GitHub
parent 5a6c53d496
commit 31d70fca39
1605 changed files with 17511 additions and 147913 deletions
+120
View File
@@ -0,0 +1,120 @@
/*
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 assert from 'node:assert/strict'
import { describe, test } from 'node:test'
import type { RefreshOutcome } from '@/lib/api'
import type { AuthBundle } from '@/stores/auth-store'
import { executeLogout } from './api'
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' }
},
})
assert.deepEqual(result, { success: false, message: 'not revoked' })
assert.equal(refreshCount, 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 }
},
})
assert.deepEqual(result, { success: true, message: '' })
assert.deepEqual(requestedSIDs, ['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' }),
})
assert.deepEqual(result, { 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 assert.rejects(
executeLogout({
getExpectedSID: () => 'session-a',
request: async () => {
throw originalError
},
refresh: async () => transient,
}),
(error) => error === originalError
)
})
})
+212
View File
@@ -0,0 +1,212 @@
/*
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 { useAuthStore } from '@/stores/auth-store'
import { getAffiliateCode } from './lib/storage'
import type { TelegramAuthorization } from './lib/telegram-login'
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) {
const turnstile = payload.turnstile ?? ''
const res = await api.post<LoginResponse>(
`/api/user/login?turnstile=${turnstile}`,
{
username: payload.username,
password: payload.password,
},
{ skipAuthRefresh: true }
)
return res.data
}
// 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 createOAuthFlow(
provider: string,
intent: 'login' | 'bind'
): Promise<string> {
const aff = intent === 'login' ? getAffiliateCode() : ''
const res = await api.post(
'/api/oauth/state',
{ provider, intent, aff: aff || undefined },
{ skipAuthRefresh: intent === 'login' }
)
if (res.data?.success) {
if (typeof res.data.data === 'string') return res.data.data
if (typeof res.data.data?.flow_token === 'string') {
return res.data.data.flow_token
}
}
throw new Error(res.data?.message || 'Failed to initialize OAuth')
}
// 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
}
// Bind email to OAuth account
export async function bindEmail(
email: string,
code: string
): Promise<ApiResponse> {
const res = await api.post('/api/oauth/email/bind', {
email,
code,
})
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>
)
}
+97
View File
@@ -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,125 @@
/*
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'
}
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)
const isBindMode = mode === 'bind'
const headline = isBindMode
? t('Binding your {{provider}} account', { provider: providerLabel })
: t('Signing you in with {{provider}}', { provider: providerLabel })
const description = isBindMode
? t('Hang tight while we securely link this account to your profile.')
: t('Hang tight while we finish connecting your account.')
const secondaryNote = isBindMode
? t(
'You can close this tab once the binding completes or a success message appears in the original window.'
)
: t(
"You'll be redirected automatically. You can return to the previous page if nothing happens after a few seconds."
)
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>
)
}
+191
View File
@@ -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 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'
import { TelegramLoginDialog } from './telegram-login-dialog'
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,
isTelegramDialogOpen,
isTelegramPending,
handleTelegramAuthorization,
setIsTelegramDialogOpen,
} = 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) {
providerButtons.push({
key: 'oidc',
label: t('Continue with OIDC'),
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>
<TelegramLoginDialog
open={isTelegramDialogOpen}
botName={status?.telegram_bot_name ?? ''}
pending={isTelegramPending}
onOpenChange={setIsTelegramDialogOpen}
onAuthorization={handleTelegramAuthorization}
/>
</>
)
}
@@ -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>
)
}
+94
View File
@@ -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>
)
}
+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 { z } from 'zod'
// ============================================================================
// 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: z
.string()
.min(1, 'Please enter your password')
.min(8, 'Password must be between 8 and 20 characters')
.max(20, 'Password must be at most 20 characters long'),
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_BIND_CALLBACK_MESSAGE = 'oauth:binding:callback'
export const OAUTH_BIND_RESULT_MESSAGE = 'oauth:binding: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>
)
}
+55
View File
@@ -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>
)
}
+82
View File
@@ -0,0 +1,82 @@
/*
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 {
getSavedLanguage,
sanitizeAuthRedirect,
} from '@/features/auth/lib/auth-redirect'
import { applyAuthBundle } from '@/lib/api'
import type { AuthBundle } from '@/stores/auth-store'
/**
* Hook for handling authentication redirects and user data management
*/
export function useAuthRedirect() {
const navigate = useNavigate()
/**
* Handle successful login
* @param userData - Optional user data from login response
* @param redirectTo - Redirect path after login
*/
const handleLoginSuccess = async (
bundle: AuthBundle,
redirectTo?: string
) => {
applyAuthBundle(bundle)
const savedLang = getSavedLanguage(bundle.user)
if (savedLang && savedLang !== i18n.language) {
await i18n.changeLanguage(savedLang)
}
const targetPath =
sanitizeAuthRedirect(redirectTo, window.location.origin) ?? '/dashboard'
navigate({ href: targetPath, replace: true })
}
/**
* Redirect to 2FA page
*/
const redirectTo2FA = () => {
navigate({ to: '/otp', replace: true })
}
/**
* Redirect to login page
*/
const redirectToLogin = () => {
navigate({ to: '/sign-in', replace: true })
}
/**
* Redirect to register page
*/
const redirectToRegister = () => {
navigate({ to: '/sign-up', replace: true })
}
return {
handleLoginSuccess,
redirectTo2FA,
redirectToLogin,
redirectToRegister,
}
}
+84
View File
@@ -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,
}
}
+249
View File
@@ -0,0 +1,249 @@
/*
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, isAuthBundle } from '@/lib/api'
import { createOAuthFlow, logout, telegramLogin } from '../api'
import {
buildGitHubOAuthUrl,
buildDiscordOAuthUrl,
buildOIDCOAuthUrl,
buildLinuxDOOAuthUrl,
} from '../lib/oauth'
import { pickTelegramAuthorization } from '../lib/telegram-login'
import type { SystemStatus, CustomOAuthProviderInfo } from '../types'
import { useAuthRedirect } from './use-auth-redirect'
/**
* Hook for managing OAuth login
*/
export function useOAuthLogin(
status: SystemStatus | null,
redirectTo?: string
) {
const { t } = useTranslation()
const { handleLoginSuccess } = useAuthRedirect()
const [isLoading, setIsLoading] = useState(false)
const [isTelegramDialogOpen, setIsTelegramDialogOpen] = useState(false)
const [isTelegramPending, setIsTelegramPending] = 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')
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')
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')
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')
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_bot_name?.trim()) {
toast.error(t('Login failed'))
return
}
setIsLoading(true)
try {
await resetSession()
setIsTelegramDialogOpen(true)
} catch {
toast.error(
t('Failed to start {{provider}} login', { provider: 'Telegram' })
)
} finally {
setIsLoading(false)
}
}
const handleTelegramAuthorization = async (value: unknown) => {
const authorization = pickTelegramAuthorization(value)
if (!authorization) {
toast.error(t('Login failed'))
return
}
setIsTelegramPending(true)
try {
const response = await telegramLogin(authorization)
if (!response.success || !isAuthBundle(response.data)) {
toast.error(t('Login failed'))
return
}
setIsTelegramDialogOpen(false)
await handleLoginSuccess(response.data, redirectTo)
toast.success(t('Welcome back!'))
} catch {
toast.error(t('Login failed'))
} finally {
setIsTelegramPending(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')
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,
isTelegramDialogOpen,
isTelegramPending,
handleGitHubLogin,
handleDiscordLogin,
handleOIDCLogin,
handleLinuxDOLogin,
handleTelegramLogin,
handleTelegramAuthorization,
setIsTelegramDialogOpen,
handleCustomOAuthLogin,
}
}
+57
View File
@@ -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'
+98
View File
@@ -0,0 +1,98 @@
/*
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 assert from 'node:assert/strict'
import { describe, test } from 'node:test'
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', () => {
assert.equal(
sanitizeAuthRedirect('/console?tab=usage#recent', origin),
'/console?tab=usage#recent'
)
assert.equal(
sanitizeAuthRedirect(
'https://dashboard.example.com/dashboard?tab=quota#daily',
origin
),
'/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) {
assert.equal(sanitizeAuthRedirect(target, origin), null)
}
})
test('rejects invalid or non-HTTP application origins', () => {
assert.equal(sanitizeAuthRedirect('/dashboard', 'not-an-origin'), null)
assert.equal(sanitizeAuthRedirect('/dashboard', 'file:///tmp/app'), null)
})
})
describe('saved authentication language', () => {
const user: AuthUser = { id: 1, username: 'user', role: 1 }
test('prefers the explicit user language', () => {
assert.equal(
getSavedLanguage({
...user,
language: 'ja',
setting: { language: 'fr' },
}),
'ja'
)
})
test('reads object and JSON string settings', () => {
assert.equal(
getSavedLanguage({ ...user, setting: { language: 'fr' } }),
'fr'
)
assert.equal(
getSavedLanguage({ ...user, setting: '{"language":"ru"}' }),
'ru'
)
})
test('ignores malformed and non-string setting languages', () => {
assert.equal(getSavedLanguage({ ...user, setting: '{' }), undefined)
assert.equal(
getSavedLanguage({ ...user, setting: { language: 123 } }),
undefined
)
})
})
+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 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}`
}
+190
View File
@@ -0,0 +1,190 @@
/*
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 assert from 'node:assert/strict'
import { describe, test } from 'node:test'
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', () => {
assert.deepEqual(
parseTelegramBindCallback({
telegram_bind: 'success',
flow_token: 'flow-success',
}),
{
kind: 'result',
flowToken: 'flow-success',
success: true,
}
)
assert.deepEqual(
parseTelegramBindCallback({
telegram_bind: 'error',
flow_token: 'flow-error',
error_code: 'TELEGRAM_BIND_ALREADY_BOUND',
}),
{
kind: 'result',
flowToken: 'flow-error',
success: false,
code: 'TELEGRAM_BIND_ALREADY_BOUND',
}
)
})
test('rejects Telegram callbacks without a flow token and ignores descriptions', () => {
assert.deepEqual(parseTelegramBindCallback({ telegram_bind: 'error' }), {
kind: 'invalid',
})
assert.deepEqual(
parseTelegramBindCallback({
telegram_bind: 'error',
flow_token: 'flow-error',
error_code: 'UNKNOWN_CODE',
error_description: 'untrusted message',
} as Parameters<typeof parseTelegramBindCallback>[0]),
{
kind: 'result',
flowToken: 'flow-error',
success: false,
code: 'UNKNOWN_CODE',
}
)
assert.equal(parseTelegramBindCallback({}), 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',
})
assert.equal(
postTelegramBindResult(callback, opener, 'https://dashboard.example.com'),
true
)
assert.deepEqual(messages, [
{
message: {
type: 'telegram:binding:result',
flow_token: 'flow-error',
success: false,
code: 'UNKNOWN_CODE',
},
targetOrigin: 'https://dashboard.example.com',
},
])
assert.equal(
postTelegramBindResult(
{ kind: 'invalid' },
opener,
'https://example.com'
),
false
)
assert.equal(
postTelegramBindResult(
callback,
{ ...opener, closed: true },
'https://example.com'
),
false
)
assert.equal(messages.length, 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
)
assert.equal(timer.delay, 30_000)
cancel()
timer.fire()
assert.equal(timedOut, false)
assert.deepEqual(timer.cancelled, [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
)
assert.equal(timer.delay, 500)
timer.fire()
assert.equal(closedCount, 0)
popup.closed = true
timer.fire()
timer.fire()
assert.equal(closedCount, 1)
assert.deepEqual(timer.cancelled, [timer.handle])
})
})
+134
View File
@@ -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)
}
}
+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
)
}
+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)
}
}
+67
View File
@@ -0,0 +1,67 @@
/*
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 assert from 'node:assert/strict'
import { describe, test } from 'node:test'
import { pickTelegramAuthorization } from './telegram-login'
describe('Telegram login authorization', () => {
test('keeps only fields signed by the Telegram login contract', () => {
assert.deepEqual(
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',
}),
{
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', () => {
assert.equal(pickTelegramAuthorization(null), null)
assert.equal(
pickTelegramAuthorization({ auth_date: 1, hash: 'hash' }),
null
)
assert.equal(
pickTelegramAuthorization({ id: 1, auth_date: 1, hash: '' }),
null
)
assert.equal(
pickTelegramAuthorization({ id: {}, auth_date: 1, hash: 'hash' }),
null
)
})
})
+71
View File
@@ -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)
}
+239
View File
@@ -0,0 +1,239 @@
/*
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 { useState } from 'react'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import type { z } from 'zod'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
InputOTPSeparator,
} from '@/components/ui/input-otp'
import { login2fa } from '@/features/auth/api'
import {
otpFormSchema,
OTP_LENGTH,
BACKUP_CODE_LENGTH,
} from '@/features/auth/constants'
import { useAuthRedirect } from '@/features/auth/hooks/use-auth-redirect'
import {
isValidOTP,
isValidBackupCode,
formatBackupCode,
cleanBackupCode,
} from '@/features/auth/lib/validation'
import { getServerErrorMessageKey } from '@/lib/server-error-message'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/stores/auth-store'
type OtpFormProps = React.HTMLAttributes<HTMLFormElement>
export function OtpForm({ className, ...props }: OtpFormProps) {
const { t } = useTranslation()
const [isLoading, setIsLoading] = useState(false)
const [useBackupCode, setUseBackupCode] = useState(false)
const pending2FAFlowToken = useAuthStore(
(state) => state.auth.pending2FAFlowToken
)
const { handleLoginSuccess, redirectToLogin } = useAuthRedirect()
const form = useForm<z.infer<typeof otpFormSchema>>({
resolver: zodResolver(otpFormSchema),
defaultValues: { otp: '' },
})
const otp = form.watch('otp')
async function onSubmit(data: z.infer<typeof otpFormSchema>) {
// Validate based on mode
if (useBackupCode) {
if (!isValidBackupCode(data.otp)) {
toast.error(t('Backup code must be in format XXXX-XXXX'))
return
}
} else {
if (!isValidOTP(data.otp)) {
toast.error(t('Verification code must be 6 digits'))
return
}
}
setIsLoading(true)
try {
// Remove all hyphens from backup code before sending to backend
const code = useBackupCode ? cleanBackupCode(data.otp) : data.otp
if (!pending2FAFlowToken) {
toast.error(t('Login flow expired. Please sign in again.'))
redirectToLogin()
return
}
const res = await login2fa({
code,
flow_token: pending2FAFlowToken,
})
if (!res.success) {
if (getServerErrorMessageKey(res)) return
toast.error(res.message || t('Invalid code'))
return
}
if (!res.data) {
throw new Error(t('Login failed'))
}
await handleLoginSuccess(res.data)
toast.success(t('Signed in'))
} catch (error) {
// eslint-disable-next-line no-console
console.error('2FA verification error:', error)
if (getServerErrorMessageKey(error)) return
const errorMessage =
error instanceof Error ? error.message : t('Verification failed')
toast.error(errorMessage)
} finally {
setIsLoading(false)
}
}
function handleToggleMode() {
setUseBackupCode(!useBackupCode)
form.setValue('otp', '')
}
function handleBackToLogin() {
redirectToLogin()
}
const isFormValid = useBackupCode
? otp.length >= BACKUP_CODE_LENGTH
: otp.length >= OTP_LENGTH
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className={cn('grid gap-4', className)}
{...props}
>
<FormField
control={form.control}
name='otp'
render={({ field }) => (
<FormItem>
<FormLabel>
{useBackupCode ? t('Backup Code') : t('Verification Code')}
</FormLabel>
<FormControl>
{useBackupCode ? (
<Input
placeholder={t('Enter backup code (e.g., CAWD-OQDV)')}
{...field}
maxLength={BACKUP_CODE_LENGTH}
autoComplete='off'
className='font-mono uppercase'
onChange={(e) => {
const formatted = formatBackupCode(e.target.value)
field.onChange(formatted)
}}
/>
) : (
<InputOTP
maxLength={OTP_LENGTH}
{...field}
containerClassName='justify-between sm:[&>[data-slot="input-otp-group"]>div]:w-12'
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
)}
</FormControl>
<FormDescription className='text-muted-foreground text-xs'>
{useBackupCode
? t('Each backup code can only be used once.')
: t('Verification code updates every 30 seconds.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button
type='submit'
className='mt-2 w-full'
disabled={!isFormValid || isLoading}
>
{isLoading ? <Loader2 className='h-4 w-4 animate-spin' /> : null}
{t('Verify and Sign In')}
</Button>
<div className='flex items-center justify-center gap-2 text-sm'>
<Button
type='button'
variant='link'
size='sm'
className='text-primary h-auto p-0'
onClick={handleToggleMode}
>
{useBackupCode ? t('Use authenticator code') : t('Use backup code')}
</Button>
<span className='text-muted-foreground'>·</span>
<Button
type='button'
variant='link'
size='sm'
className='text-primary h-auto p-0'
onClick={handleBackToLogin}
>
{t('Back to login')}
</Button>
</div>
</form>
</Form>
)
}
+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('Two-factor Authentication')}
</h2>
<p className='text-muted-foreground text-left text-sm sm:text-base'>
{t('Please enter the authentication code.')}
</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>
)
}
+111
View File
@@ -0,0 +1,111 @@
/*
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 {
SecurityProof,
SecurityProofScope,
} 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 async function beginPasskeyRegistration(
proofToken?: string
): Promise<ApiResponse<PasskeyOptionsPayload>> {
const res = await api.post<ApiResponse<PasskeyOptionsPayload>>(
'/api/user/passkey/register/begin',
undefined,
{ headers: proofHeaders(proofToken) }
)
return res.data
}
export async function finishPasskeyRegistration(
flowToken: string,
payload: Record<string, unknown>,
proofToken?: string
): Promise<ApiResponse> {
const res = await api.post<ApiResponse>(
'/api/user/passkey/register/finish',
{
flow_token: flowToken,
credential: payload,
},
{ headers: proofHeaders(proofToken), acceptAuthRotation: true }
)
return res.data
}
export async function deletePasskey(proofToken?: string): Promise<ApiResponse> {
const res = await api.delete<ApiResponse>('/api/user/passkey', {
headers: proofHeaders(proofToken),
acceptAuthRotation: true,
})
return res.data
}
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 async function beginPasskeyVerification(
scope: SecurityProofScope
): Promise<ApiResponse<PasskeyOptionsPayload>> {
const res = await api.post<ApiResponse<PasskeyOptionsPayload>>(
'/api/user/passkey/verify/begin',
{ scope }
)
return res.data
}
export async function finishPasskeyVerification(
flowToken: string,
payload: Record<string, unknown>
): Promise<ApiResponse<SecurityProof>> {
const res = await api.post<ApiResponse<SecurityProof>>(
'/api/user/passkey/verify/finish',
{ flow_token: flowToken, credential: payload }
)
return res.data
}
@@ -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 i18next from 'i18next'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import {
buildRegistrationResult,
createCredential,
isPasskeySupported as detectPasskeySupport,
prepareCredentialCreationOptions,
} from '@/lib/passkey'
import {
beginPasskeyRegistration,
deletePasskey,
finishPasskeyRegistration,
getPasskeyStatus,
} from '../api'
import type { PasskeyStatus } from '../types'
interface UsePasskeyManagementOptions {
onStatusChange?: (status: PasskeyStatus | null) => void
}
export function usePasskeyManagement(
options: UsePasskeyManagementOptions = {}
) {
const { onStatusChange } = options
const [status, setStatus] = useState<PasskeyStatus | null>(null)
const [loading, setLoading] = useState(true)
const [registering, setRegistering] = useState(false)
const [removing, setRemoving] = useState(false)
const [supported, setSupported] = useState(false)
const fetchStatus = useCallback(async () => {
try {
setLoading(true)
const res = await getPasskeyStatus()
if (res.success) {
setStatus(res.data ?? null)
onStatusChange?.(res.data ?? null)
} else {
setStatus(null)
toast.error(res.message || i18next.t('Failed to load Passkey status'))
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('[Passkey] Failed to fetch status', error)
toast.error(i18next.t('Failed to load Passkey status'))
setStatus(null)
} finally {
setLoading(false)
}
}, [onStatusChange])
useEffect(() => {
fetchStatus()
}, [fetchStatus])
useEffect(() => {
detectPasskeySupport()
.then(setSupported)
.catch(() => setSupported(false))
}, [])
const register = useCallback(
async (proofToken?: string) => {
if (!supported) {
toast.error(i18next.t('This device does not support Passkey'))
return false
}
if (!navigator?.credentials) {
toast.error(i18next.t('Passkey is not supported in this environment'))
return false
}
setRegistering(true)
try {
const beginResponse = await beginPasskeyRegistration(proofToken)
if (!beginResponse.success) {
toast.error(
beginResponse.message ||
i18next.t('Failed to start Passkey registration')
)
return false
}
const publicKey = prepareCredentialCreationOptions(
beginResponse.data?.options ?? beginResponse.data
)
const flowToken = beginResponse.data?.flow_token
if (!flowToken) {
toast.error(i18next.t('Registration flow expired. Please try again.'))
return false
}
const credential = (await createCredential(
publicKey
)) as PublicKeyCredential | null
if (!credential) {
toast.error(i18next.t('Passkey registration was cancelled'))
return false
}
const attestation = buildRegistrationResult(credential)
if (!attestation) {
toast.error(i18next.t('Invalid Passkey registration response'))
return false
}
const finishResponse = await finishPasskeyRegistration(
flowToken,
attestation,
proofToken
)
if (!finishResponse.success) {
toast.error(
finishResponse.message || i18next.t('Failed to register Passkey')
)
return false
}
toast.success(i18next.t('Passkey registered successfully'))
await fetchStatus()
return true
} catch (error: unknown) {
if (error instanceof DOMException && error.name === 'NotAllowedError') {
toast.info(i18next.t('Passkey registration was cancelled'))
return false
}
// eslint-disable-next-line no-console
console.error('[Passkey] Registration error', error)
toast.error(
error instanceof Error
? error.message
: i18next.t('Failed to register Passkey')
)
return false
} finally {
setRegistering(false)
}
},
[supported, fetchStatus]
)
const remove = useCallback(
async (proofToken?: string) => {
setRemoving(true)
try {
const res = await deletePasskey(proofToken)
if (!res.success) {
toast.error(res.message || i18next.t('Failed to remove Passkey'))
return false
}
toast.success(i18next.t('Passkey removed successfully'))
await fetchStatus()
return true
} catch (error) {
// eslint-disable-next-line no-console
console.error('[Passkey] Removal error', error)
toast.error(i18next.t('Failed to remove Passkey'))
return false
} finally {
setRemoving(false)
}
},
[fetchStatus]
)
const enabled = useMemo(() => Boolean(status?.enabled), [status])
const lastUsed = useMemo(() => status?.last_used_at ?? null, [status])
return {
status,
loading,
registering,
removing,
supported,
enabled,
lastUsed,
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
}
+204
View File
@@ -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>
)
}
+191
View File
@@ -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 i18next from 'i18next'
import type { ApiResponse } from '@/features/auth/types'
import { api, get2FAStatus } from '@/lib/api'
import {
buildAssertionResult,
prepareCredentialRequestOptions,
isPasskeySupported as detectPasskeySupport,
} from '@/lib/passkey'
import {
beginPasskeyVerification,
finishPasskeyVerification,
getPasskeyStatus,
} from '../passkey'
import type {
SecurityProof,
SecurityProofScope,
VerificationMethod,
VerificationMethods,
} from './types'
/**
* Fetch available verification methods for the current user.
*/
export async function checkVerificationMethods(): Promise<VerificationMethods> {
try {
const [twoFAResponse, passkeyResponse, passkeySupported] =
await Promise.all([
get2FAStatus(),
getPasskeyStatus(),
detectPasskeySupport(),
])
const has2FA =
Boolean(twoFAResponse?.success) && Boolean(twoFAResponse?.data?.enabled)
const hasPasskey =
Boolean(passkeyResponse?.success) &&
Boolean(passkeyResponse?.data?.enabled)
return {
has2FA,
hasPasskey,
passkeySupported,
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('[Secure Verification] Failed to check methods', error)
return {
has2FA: false,
hasPasskey: false,
passkeySupported: false,
}
}
}
/**
* Execute a verification flow based on the method type.
*/
export async function verify(
method: VerificationMethod,
scope: SecurityProofScope,
code?: string
): Promise<SecurityProof> {
switch (method) {
case '2fa':
return verifyTwoFA(scope, code)
case 'passkey':
return verifyPasskey(scope)
default:
throw new Error(
i18next.t('Unsupported verification method: {{method}}', { method })
)
}
}
/**
* Perform 2FA verification flow.
*/
async function verifyTwoFA(
scope: SecurityProofScope,
code?: string | null
): Promise<SecurityProof> {
const trimmed = code?.trim()
if (!trimmed) {
throw new Error(
i18next.t('Please enter the verification code or backup code')
)
}
const res = await api.post<ApiResponse<SecurityProof>>('/api/verify', {
method: '2fa',
code: trimmed,
scope,
})
if (!res.data?.success) {
throw new Error(res.data?.message || i18next.t('Verification failed'))
}
if (!res.data.data?.proof_token) {
throw new Error(i18next.t('Verification proof was not returned'))
}
return res.data.data
}
/**
* Perform Passkey verification flow.
*/
async function verifyPasskey(
scope: SecurityProofScope
): Promise<SecurityProof> {
if (typeof navigator === 'undefined' || !navigator.credentials) {
throw new Error(
i18next.t('Passkey verification is not supported in this environment')
)
}
try {
const beginResponse = await beginPasskeyVerification(scope)
if (!beginResponse.success) {
throw new Error(
beginResponse.message || i18next.t('Failed to start verification')
)
}
const publicKey = prepareCredentialRequestOptions(
beginResponse.data?.options ?? beginResponse.data
)
const flowToken = beginResponse.data?.flow_token
if (!flowToken) {
throw new Error(i18next.t('Verification flow expired'))
}
const credential = (await navigator.credentials.get({
publicKey,
})) as PublicKeyCredential | null
if (!credential) {
throw new Error(i18next.t('Passkey verification was cancelled'))
}
const assertion = buildAssertionResult(credential)
if (!assertion) {
throw new Error(i18next.t('Unable to build Passkey assertion'))
}
const finishResponse = await finishPasskeyVerification(flowToken, assertion)
if (!finishResponse.success) {
throw new Error(
finishResponse.message || i18next.t('Passkey verification failed')
)
}
if (!finishResponse.data?.proof_token) {
throw new Error(i18next.t('Verification proof was not returned'))
}
return finishResponse.data
} catch (error: unknown) {
if (error instanceof DOMException && error.name === 'NotAllowedError') {
throw new Error(
i18next.t('Passkey verification was cancelled or timed out'),
{ cause: error }
)
}
if (error instanceof DOMException && error.name === 'InvalidStateError') {
throw new Error(
i18next.t('Passkey verification is not available in the current state'),
{ cause: error }
)
}
throw error
}
}
@@ -0,0 +1,203 @@
/*
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 { ShieldCheck, KeyRound, Loader2 } from 'lucide-react'
import { useMemo } 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import type {
SecureVerificationState,
VerificationMethod,
VerificationMethods,
} from '../types'
interface SecureVerificationDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
methods: VerificationMethods
state: SecureVerificationState
onVerify: (method: VerificationMethod, code?: string) => void | Promise<void>
onCancel: () => void
onCodeChange: (code: string) => void
onMethodChange: (method: VerificationMethod) => void
}
export function SecureVerificationDialog({
open,
onOpenChange,
methods,
state,
onVerify,
onCancel,
onCodeChange,
onMethodChange,
}: SecureVerificationDialogProps) {
const { t } = useTranslation()
const availableTabs: VerificationMethod[] = useMemo(() => {
const tabs: VerificationMethod[] = []
if (methods.has2FA) tabs.push('2fa')
if (methods.hasPasskey && methods.passkeySupported) tabs.push('passkey')
return tabs
}, [methods])
const activeMethod =
state.method ?? (availableTabs.length > 0 ? availableTabs[0] : null)
const title =
state.title ??
(availableTabs.length
? 'Additional verification required'
: 'Verification unavailable')
const description =
state.description ??
(availableTabs.length
? 'Confirm your identity before accessing this sensitive action.'
: 'Enable Two-factor Authentication or Passkey in your profile settings to continue.')
const handleVerify = () => {
if (!activeMethod) return
const payload = activeMethod === '2fa' ? state.code : undefined
onVerify(activeMethod, payload)
}
const verifyDisabled =
state.loading ||
(activeMethod === '2fa' && (!state.code.trim() || state.code.length < 6))
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title={
<>
<ShieldCheck className='text-primary h-5 w-5' />
{title}
</>
}
description={description}
contentClassName='top-[8vh] max-w-[calc(100%-1.5rem)] translate-y-0 overflow-hidden border-none shadow-xl sm:top-1/2 sm:max-w-md sm:translate-y-[-50%] sm:rounded-xl'
headerClassName='border-b pb-4 text-left'
titleClassName='flex items-center gap-2 text-lg font-semibold'
descriptionClassName='text-left'
contentHeight='auto'
bodyClassName='px-1 py-1'
showCloseButton={!state.loading}
footerClassName='bg-muted/30 border-t px-6 py-4 sm:flex-row sm:justify-end'
footer={
<>
<Button
type='button'
variant='outline'
disabled={state.loading}
onClick={onCancel}
>
{t('Cancel')}
</Button>
<Button
type='button'
onClick={handleVerify}
disabled={availableTabs.length === 0 || verifyDisabled}
>
{state.loading && <Loader2 className='h-4 w-4 animate-spin' />}
{t('Verify')}
</Button>
</>
}
>
{availableTabs.length === 0 ? (
<div className='grid place-items-center gap-4 text-center'>
<div className='bg-muted flex h-16 w-16 items-center justify-center rounded-2xl'>
<ShieldCheck className='text-muted-foreground h-8 w-8' />
</div>
<p className='text-muted-foreground text-sm'>
{t(
'Enable Two-factor Authentication or Passkey in your profile to unlock sensitive operations.'
)}
</p>
</div>
) : (
<Tabs
value={activeMethod ?? availableTabs[0]}
onValueChange={(value) => onMethodChange(value as VerificationMethod)}
className='gap-4'
>
<TabsList>
{methods.has2FA && (
<TabsTrigger value='2fa'>{t('Authenticator code')}</TabsTrigger>
)}
{methods.hasPasskey && methods.passkeySupported && (
<TabsTrigger value='passkey'>{t('Passkey')}</TabsTrigger>
)}
</TabsList>
<TabsContent value='2fa' className='space-y-3'>
<p className='text-muted-foreground text-sm'>
{t(
'Enter the 6-digit Time-based One-Time Password or 8-character backup code from your authenticator app.'
)}
</p>
<Input
inputMode='numeric'
maxLength={8}
value={state.code}
onChange={(event) => onCodeChange(event.target.value)}
placeholder={t('Enter verification code')}
disabled={state.loading}
autoFocus={activeMethod === '2fa'}
onKeyDown={(event) => {
if (event.key === 'Enter' && !verifyDisabled) {
event.preventDefault()
handleVerify()
}
}}
/>
</TabsContent>
<TabsContent value='passkey' className='space-y-4'>
<div className='bg-muted/50 flex items-center justify-center rounded-lg p-4'>
<div className='text-muted-foreground flex items-center gap-3'>
<KeyRound className='text-primary h-6 w-6' />
<div className='text-left text-sm'>
<p className='text-foreground font-medium'>
{t('Use your Passkey')}
</p>
<p>
{t(
'We will prompt your device to confirm using biometrics or your hardware key.'
)}
</p>
</div>
</div>
</div>
{!methods.passkeySupported && (
<p className='text-destructive text-sm'>
{t('This device does not support Passkey verification.')}
</p>
)}
</TabsContent>
</Tabs>
)}
</Dialog>
)
}
@@ -0,0 +1,256 @@
/*
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 { useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import {
extractVerificationInfo,
isVerificationRequiredError,
} from '@/lib/secure-verification'
import { checkVerificationMethods, verify } from '../api'
import type {
SecureVerificationState,
StartVerificationOptions,
UseSecureVerificationOptions,
VerificationMethod,
VerificationMethods,
} from '../types'
type ApiCall = ((proofToken?: string) => Promise<unknown>) | null
interface InternalState extends SecureVerificationState {
apiCall: ApiCall
}
const defaultMethods: VerificationMethods = {
has2FA: false,
hasPasskey: false,
passkeySupported: false,
}
const initialState: InternalState = {
method: null,
loading: false,
code: '',
title: undefined,
description: undefined,
apiCall: null,
}
export function useSecureVerification(
options: UseSecureVerificationOptions = {}
) {
const { onSuccess, onError, successMessage, autoReset = true } = options
const [methods, setMethods] = useState<VerificationMethods>(defaultMethods)
const [state, setState] = useState<InternalState>(initialState)
const [open, setOpen] = useState(false)
const fetchVerificationMethods = useCallback(async () => {
const result = await checkVerificationMethods()
setMethods(result)
return result
}, [])
useEffect(() => {
fetchVerificationMethods()
}, [fetchVerificationMethods])
const reset = useCallback(() => {
setState(initialState)
setOpen(false)
}, [])
const startVerification = useCallback(
async (
apiCall: (proofToken?: string) => Promise<unknown>,
config: StartVerificationOptions
) => {
const { preferredMethod, scope, title, description } = config
const availableMethods = await fetchVerificationMethods()
if (!availableMethods.has2FA && !availableMethods.hasPasskey) {
toast.error(
i18next.t(
'Please enable Two-factor Authentication or Passkey before proceeding'
)
)
onError?.(
new Error(
'No verification methods available. Enable 2FA or Passkey to continue.'
)
)
return false
}
let defaultMethod: VerificationMethod | null = preferredMethod ?? null
if (
(defaultMethod === 'passkey' &&
(!availableMethods.hasPasskey ||
!availableMethods.passkeySupported)) ||
(defaultMethod === '2fa' && !availableMethods.has2FA)
) {
defaultMethod = null
}
if (!defaultMethod) {
if (availableMethods.hasPasskey && availableMethods.passkeySupported) {
defaultMethod = 'passkey'
} else if (availableMethods.has2FA) {
defaultMethod = '2fa'
}
}
setState((prev) => ({
...prev,
apiCall,
method: defaultMethod,
scope,
title,
description,
}))
setOpen(true)
return true
},
[fetchVerificationMethods, onError]
)
const executeVerification = useCallback(
async (method?: VerificationMethod, code?: string) => {
if (!state.apiCall) {
toast.error(i18next.t('Verification is not configured properly'))
return
}
const actualMethod = method ?? state.method
if (!actualMethod) {
toast.error(i18next.t('Select a verification method first'))
return
}
setState((prev) => ({ ...prev, loading: true }))
try {
if (!state.scope) {
throw new Error(i18next.t('Verification scope is missing'))
}
const proof = await verify(
actualMethod,
state.scope,
code ?? state.code
)
const result = await state.apiCall(proof.proof_token)
if (successMessage) {
toast.success(successMessage)
}
onSuccess?.(result, actualMethod)
if (autoReset) {
reset()
}
return result
} catch (error) {
const message =
error instanceof Error
? error.message
: i18next.t('Verification failed')
toast.error(message)
onError?.(error)
throw error
} finally {
setState((prev) => ({ ...prev, loading: false }))
}
},
[state, successMessage, onSuccess, onError, autoReset, reset]
)
const setCode = useCallback((code: string) => {
setState((prev) => ({ ...prev, code }))
}, [])
const switchMethod = useCallback((method: VerificationMethod) => {
setState((prev) => ({ ...prev, method, code: '' }))
}, [])
const cancel = useCallback(() => {
reset()
}, [reset])
const withVerification = useCallback(
async (
apiCall: (proofToken?: string) => Promise<unknown>,
config: StartVerificationOptions
) => {
try {
return await apiCall()
} catch (error) {
if (isVerificationRequiredError(error)) {
const info = extractVerificationInfo(error)
toast.info(info.message)
await startVerification(apiCall, config)
return null
}
throw error
}
},
[startVerification]
)
const canUseMethod = useCallback(
(method: VerificationMethod) => {
if (method === '2fa') return methods.has2FA
if (method === 'passkey') {
return methods.hasPasskey && methods.passkeySupported
}
return false
},
[methods]
)
const recommendedMethod = useMemo<VerificationMethod | null>(() => {
if (methods.hasPasskey && methods.passkeySupported) return 'passkey'
if (methods.has2FA) return '2fa'
return null
}, [methods])
return {
open,
setOpen,
methods,
state,
startVerification,
executeVerification,
cancel,
reset,
setCode,
switchMethod,
withVerification,
fetchVerificationMethods,
canUseMethod,
recommendedMethod,
hasAnyMethod: methods.has2FA || methods.hasPasskey,
isLoading: state.loading,
currentMethod: state.method,
code: state.code,
}
}
+22
View File
@@ -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'
+60
View File
@@ -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
*/
export type VerificationMethod = '2fa' | 'passkey'
export type SecurityProofScope =
| 'channel.key.read'
| 'passkey.register'
| 'passkey.delete'
export interface SecurityProof {
proof_token: string
expires_at: number
method: VerificationMethod
scope: SecurityProofScope
}
export interface VerificationMethods {
has2FA: boolean
hasPasskey: boolean
passkeySupported: boolean
}
export interface SecureVerificationState {
method: VerificationMethod | null
scope?: SecurityProofScope
loading: boolean
code: string
title?: string
description?: string
}
export interface UseSecureVerificationOptions {
onSuccess?: (result: unknown, method: VerificationMethod) => void
onError?: (error: unknown) => void
successMessage?: string
autoReset?: boolean
}
export interface StartVerificationOptions {
scope: SecurityProofScope
preferredMethod?: VerificationMethod
title?: string
description?: string
}
@@ -0,0 +1,496 @@
/*
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 { isAuthBundle } from '@/lib/api'
import {
buildAssertionResult,
prepareCredentialRequestOptions,
isPasskeySupported as detectPasskeySupport,
} from '@/lib/passkey'
import { getServerErrorMessageKey } from '@/lib/server-error-message'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/stores/auth-store'
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 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 {
isTurnstileEnabled,
turnstileSiteKey,
turnstileToken,
setTurnstileToken,
validateTurnstile,
} = useTurnstile()
const { handleLoginSuccess, redirectTo2FA } = useAuthRedirect()
const setPending2FAFlowToken = useAuthStore(
(state) => state.auth.setPending2FAFlowToken
)
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
setIsLoading(true)
try {
const res = await login({
username: data.username,
password: data.password,
turnstile: turnstileToken,
})
if (res.success) {
if (res.data && 'require_2fa' in res.data && res.data.require_2fa) {
if (!res.data.flow_token) {
throw new Error(t('Login flow expired. Please sign in again.'))
}
setPending2FAFlowToken(res.data.flow_token)
redirectTo2FA()
return
}
if (!isAuthBundle(res.data)) {
throw new Error(t('Login failed'))
}
await handleLoginSuccess(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 && isAuthBundle(res.data)) {
await handleLoginSuccess(res.data, redirectTo)
toast.success(t('Signed in via WeChat'))
handleWeChatDialogChange(false)
} 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 (!isAuthBundle(finish.data)) {
throw new Error(t('Missing user data from Passkey login response'))
}
await handleLoginSuccess(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
siteKey={turnstileSiteKey}
onVerify={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 { isAuthBundle } from '@/lib/api'
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, handleLoginSuccess } = 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 && isAuthBundle(res.data)) {
await handleLoginSuccess(res.data)
toast.success(t('Signed in via WeChat'))
handleWeChatDialogChange(false)
} 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-20 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>
)
}
+212
View File
@@ -0,0 +1,212 @@
/*
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'
// ============================================================================
// API Payloads
// ============================================================================
export interface LoginPayload {
username: string
password: string
turnstile?: string
}
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 {
email: string
code: string
}
// ============================================================================
// API Responses
// ============================================================================
export interface LoginResponse {
success: boolean
message: string
data?:
| AuthBundle
| {
require_2fa?: boolean
flow_token?: string
expires_at?: number
}
}
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
linuxdo_oauth?: boolean
linuxdo_client_id?: string
telegram_oauth?: 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_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
linuxdo_oauth?: boolean
linuxdo_client_id?: string
telegram_oauth?: 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_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
}