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:
Vendored
+120
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
Vendored
+212
@@ -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
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useSystemConfig } from '@/hooks/use-system-config'
|
||||
|
||||
type AuthLayoutProps = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function AuthLayout({ children }: AuthLayoutProps) {
|
||||
const { t } = useTranslation()
|
||||
const { systemName, logo, loading } = useSystemConfig()
|
||||
|
||||
return (
|
||||
<div className='relative grid h-svh max-w-none'>
|
||||
<Link
|
||||
to='/'
|
||||
className='absolute top-4 left-4 z-10 flex items-center gap-2 transition-opacity hover:opacity-80 sm:top-8 sm:left-8'
|
||||
>
|
||||
<div className='relative h-8 w-8'>
|
||||
{loading ? (
|
||||
<Skeleton className='absolute inset-0 rounded-full' />
|
||||
) : (
|
||||
<img
|
||||
src={logo}
|
||||
alt={t('Logo')}
|
||||
className='h-8 w-8 rounded-full object-cover'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{loading ? (
|
||||
<Skeleton className='h-6 w-24' />
|
||||
) : (
|
||||
<h1 className='text-xl font-medium'>{systemName}</h1>
|
||||
)}
|
||||
</Link>
|
||||
<div className='container flex items-center pt-16 sm:pt-0'>
|
||||
<div className='mx-auto flex w-full flex-col justify-center space-y-2 px-4 py-8 sm:w-[480px] sm:p-8'>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { SystemStatus } from '../types'
|
||||
|
||||
interface LegalConsentProps {
|
||||
status: SystemStatus | null
|
||||
checked: boolean
|
||||
onCheckedChange: (nextValue: boolean) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function LegalConsent({
|
||||
status,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
className,
|
||||
}: LegalConsentProps) {
|
||||
const { t } = useTranslation()
|
||||
const hasUserAgreement = Boolean(status?.user_agreement_enabled)
|
||||
const hasPrivacyPolicy = Boolean(status?.privacy_policy_enabled)
|
||||
|
||||
if (!hasUserAgreement && !hasPrivacyPolicy) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleChange = (value: boolean) => {
|
||||
onCheckedChange(value === true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-border/60 bg-muted/40 flex items-start gap-3 rounded-md border p-3',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id='legal-consent'
|
||||
checked={checked}
|
||||
onCheckedChange={handleChange}
|
||||
className='mt-0.5'
|
||||
/>
|
||||
<Label
|
||||
htmlFor='legal-consent'
|
||||
className='text-muted-foreground items-start gap-1 text-left text-xs leading-5 font-normal'
|
||||
>
|
||||
<span>
|
||||
{t('I have read and agree to the')}{' '}
|
||||
{hasUserAgreement && (
|
||||
<a
|
||||
href='/user-agreement'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-primary hover:underline'
|
||||
>
|
||||
{t('User Agreement')}
|
||||
</a>
|
||||
)}
|
||||
{hasUserAgreement && hasPrivacyPolicy && ' and the '}
|
||||
{hasPrivacyPolicy && (
|
||||
<a
|
||||
href='/privacy-policy'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-primary hover:underline'
|
||||
>
|
||||
{t('Privacy Policy')}
|
||||
</a>
|
||||
)}
|
||||
.
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Vendored
+80
@@ -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
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { AuthLayout } from '../auth-layout'
|
||||
import { ForgotPasswordForm } from './components/forgot-password-form'
|
||||
|
||||
export function ForgotPassword() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<AuthLayout>
|
||||
<div className='w-full space-y-8'>
|
||||
<div className='space-y-3'>
|
||||
<h2 className='text-center text-2xl font-semibold tracking-tight sm:text-left'>
|
||||
{t('Forgot password')}
|
||||
</h2>
|
||||
<p className='text-muted-foreground text-left text-sm sm:text-base'>
|
||||
{t(
|
||||
'Enter your registered email and we will send you a link to reset your password.'
|
||||
)}
|
||||
</p>
|
||||
<p className='text-muted-foreground text-left text-sm sm:text-base'>
|
||||
{t("Don't have an account?")}{' '}
|
||||
<Link
|
||||
to='/sign-up'
|
||||
className='hover:text-primary font-medium underline underline-offset-4'
|
||||
>
|
||||
{t('Sign up')}
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ForgotPasswordForm className='space-y-0' />
|
||||
</div>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
Vendored
+118
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
Vendored
+103
@@ -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
|
||||
)
|
||||
}
|
||||
Vendored
+61
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
/**
|
||||
* Utilities for managing authentication-related browser storage
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// LocalStorage Keys
|
||||
// ============================================================================
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
AFFILIATE: 'aff',
|
||||
STATUS: 'status',
|
||||
} as const
|
||||
|
||||
// ============================================================================
|
||||
// Affiliate Code Storage
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get affiliate code from localStorage
|
||||
*/
|
||||
export function getAffiliateCode(): string {
|
||||
if (typeof window === 'undefined') return ''
|
||||
try {
|
||||
return window.localStorage.getItem(STORAGE_KEYS.AFFILIATE) ?? ''
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to get affiliate code:', error)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save affiliate code to localStorage
|
||||
*/
|
||||
export function saveAffiliateCode(code: string): void {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEYS.AFFILIATE, code)
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to save affiliate code:', error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
Vendored
+53
@@ -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>
|
||||
)
|
||||
}
|
||||
Vendored
+111
@@ -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
@@ -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
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
export interface ApiResponse<T = unknown> {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: T
|
||||
}
|
||||
|
||||
export interface PasskeyStatus {
|
||||
enabled: boolean
|
||||
last_used_at?: string | null
|
||||
backup_eligible?: boolean
|
||||
backup_state?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface PasskeyOptionsPayload {
|
||||
options?: unknown
|
||||
flow_token?: string
|
||||
expires_at?: number
|
||||
publicKey?: unknown
|
||||
response?: unknown
|
||||
Response?: unknown
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { CheckIcon, CopyIcon } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useCountdown } from '@/hooks/use-countdown'
|
||||
import { api } from '@/lib/api'
|
||||
import { copyToClipboard } from '@/lib/copy-to-clipboard'
|
||||
|
||||
import { AuthLayout } from '../auth-layout'
|
||||
|
||||
export type ResetPasswordSearchParams = {
|
||||
email?: string
|
||||
token?: string
|
||||
}
|
||||
|
||||
type ResetPasswordConfirmProps = ResetPasswordSearchParams
|
||||
|
||||
export function ResetPasswordConfirm({
|
||||
email,
|
||||
token,
|
||||
}: ResetPasswordConfirmProps) {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const {
|
||||
secondsLeft,
|
||||
isActive,
|
||||
start: startCountdown,
|
||||
} = useCountdown({ initialSeconds: 30 })
|
||||
|
||||
const isValidResetLink = Boolean(email && token)
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!isValidResetLink || !email || !token) {
|
||||
toast.error(t('Invalid reset link, please request a new password reset'))
|
||||
return
|
||||
}
|
||||
|
||||
startCountdown()
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.post('/api/user/reset', { email, token }, {
|
||||
skipBusinessError: true,
|
||||
} as Record<string, unknown>)
|
||||
|
||||
if (res?.data?.success) {
|
||||
const password = res.data.data
|
||||
setNewPassword(password)
|
||||
const copySuccess = await copyToClipboard(password)
|
||||
if (copySuccess) {
|
||||
toast.success(
|
||||
t('Password reset and copied to clipboard: {{password}}', {
|
||||
password,
|
||||
})
|
||||
)
|
||||
} else {
|
||||
toast.success(t('Password reset: {{password}}', { password }))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Errors handled by global interceptor
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
if (!newPassword) return
|
||||
|
||||
const copySuccess = await copyToClipboard(newPassword)
|
||||
if (copySuccess) {
|
||||
setCopied(true)
|
||||
toast.success(
|
||||
t('Password copied to clipboard: {{password}}', {
|
||||
password: newPassword,
|
||||
})
|
||||
)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<div className='w-full space-y-8'>
|
||||
<div className='space-y-2'>
|
||||
<h2 className='text-center text-2xl font-semibold tracking-tight sm:text-left'>
|
||||
{t('Reset password')}
|
||||
</h2>
|
||||
<p className='text-muted-foreground text-left text-sm sm:text-base'>
|
||||
{newPassword
|
||||
? t('auth.resetPasswordConfirm.success')
|
||||
: t('auth.resetPasswordConfirm.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='space-y-4'>
|
||||
{!isValidResetLink && (
|
||||
<Alert variant='destructive'>
|
||||
<AlertDescription>
|
||||
{t('Invalid reset link, please request a new password reset.')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='email'>{t('Email')}</Label>
|
||||
<Input
|
||||
id='email'
|
||||
type='email'
|
||||
value={email || ''}
|
||||
disabled
|
||||
placeholder={t('Waiting for email...')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{newPassword && (
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='password'>{t('New password')}</Label>
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
id='password'
|
||||
value={newPassword}
|
||||
disabled
|
||||
className='font-mono'
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
size='icon'
|
||||
variant='outline'
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className='h-4 w-4' />
|
||||
) : (
|
||||
<CopyIcon className='h-4 w-4' />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
{t('Password has been copied to clipboard')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className='w-full'
|
||||
onClick={
|
||||
newPassword
|
||||
? () => navigate({ to: '/sign-in', replace: true })
|
||||
: handleSubmit
|
||||
}
|
||||
disabled={
|
||||
newPassword ? false : loading || isActive || !isValidResetLink
|
||||
}
|
||||
>
|
||||
{newPassword
|
||||
? t('auth.resetPasswordConfirm.backToLogin')
|
||||
: isActive
|
||||
? t('auth.resetPasswordConfirm.retry', {
|
||||
seconds: secondsLeft,
|
||||
})
|
||||
: t('auth.resetPasswordConfirm.confirm')}
|
||||
</Button>
|
||||
|
||||
{!newPassword && (
|
||||
<Button
|
||||
variant='link'
|
||||
className='w-full'
|
||||
onClick={() => navigate({ to: '/sign-in', replace: true })}
|
||||
>
|
||||
{t('Back to login')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
+191
@@ -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
|
||||
}
|
||||
}
|
||||
+203
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
export * from './api'
|
||||
export * from './types'
|
||||
export * from './hooks/use-secure-verification'
|
||||
export * from './components/secure-verification-dialog'
|
||||
@@ -0,0 +1,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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
Vendored
+212
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user