♻️ refactor(web): consolidate design-system primitives and responsive data views

This commit is contained in:
t0ng7u
2026-07-11 05:13:16 +08:00
parent 262ab93123
commit 0918bdb49a
451 changed files with 7295 additions and 7693 deletions
@@ -17,21 +17,15 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useQuery } from '@tanstack/react-query'
import {
CalendarDays,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
Sparkles,
} from 'lucide-react'
import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp } from 'lucide-react'
import { useEffect, useState, useMemo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/design-system/button'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { Turnstile } from '@/components/turnstile'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import {
@@ -162,7 +156,7 @@ export function CheckinCalendarCard({
}
toast.error(res.message || t('Check-in failed'))
}
} catch (_error) {
} catch {
toast.error(t('Check-in failed'))
} finally {
setCheckinLoading(false)
@@ -218,6 +212,13 @@ export function CheckinCalendarCard({
const weekDays = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
let checkinButtonLabel = t('Check in now')
if (checkinLoading) {
checkinButtonLabel = t('Loading...')
} else if (checkedToday) {
checkinButtonLabel = t('Checked in')
}
if (!checkinEnabled) {
return null
}
@@ -225,16 +226,13 @@ export function CheckinCalendarCard({
if (isLoading) {
return (
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
<div className='p-6'>
<div className='p-4 sm:p-5'>
<div className='flex items-start justify-between gap-4'>
<div className='flex items-center gap-3'>
<Skeleton className='h-10 w-10 rounded-xl' />
<div className='space-y-2'>
<Skeleton className='h-5 w-32' />
<Skeleton className='h-3 w-56' />
</div>
<div className='space-y-2'>
<Skeleton className='h-5 w-32' />
<Skeleton className='h-4 w-56' />
</div>
<Skeleton className='h-9 w-28 rounded-md' />
<Skeleton className='h-8 w-28 rounded-md' />
</div>
</div>
</Card>
@@ -275,56 +273,44 @@ export function CheckinCalendarCard({
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
{/* Header */}
<div className='border-b p-4 sm:p-6'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4'>
<div className='border-b p-4 sm:p-5'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4'>
<button
type='button'
className='flex min-w-0 flex-1 items-start gap-3 rounded-lg text-left whitespace-normal outline-none'
className='flex min-w-0 flex-1 flex-col gap-0.5 rounded-lg text-left whitespace-normal outline-none'
onClick={() => setCollapsed((v) => !v)}
aria-expanded={!collapsed}
>
<div className='bg-primary/10 text-primary flex h-10 w-10 shrink-0 items-center justify-center rounded-xl sm:h-11 sm:w-11'>
<CalendarDays
className='h-4 w-4 sm:h-5 sm:w-5'
strokeWidth={2}
/>
</div>
<div className='min-w-0 flex-1'>
<div className='flex flex-wrap items-center gap-1.5 sm:gap-2'>
<h3 className='text-base font-semibold tracking-tight sm:text-lg'>
{t('Daily Check-in')}
</h3>
{checkedToday && (
<div className='bg-success/10 text-success inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium sm:gap-1.5 sm:px-2.5'>
<Sparkles className='h-2.5 w-2.5 sm:h-3 sm:w-3' />
{t('Checked in')}
</div>
<span className='flex flex-wrap items-center gap-2'>
<span className='text-base font-semibold tracking-tight'>
{t('Daily Check-in')}
</span>
{checkedToday && (
<StatusBadge variant='success'>{t('Checked in')}</StatusBadge>
)}
<span
className='text-muted-foreground inline-flex items-center'
aria-hidden='true'
>
{collapsed ? (
<ChevronDown className='size-4' />
) : (
<ChevronUp className='size-4' />
)}
<span className='text-muted-foreground inline-flex items-center'>
{collapsed ? (
<ChevronDown className='h-4 w-4' />
) : (
<ChevronUp className='h-4 w-4' />
)}
</span>
</div>
<p className='text-muted-foreground mt-1 line-clamp-2 text-xs sm:text-sm'>
{checkedToday && todayAward !== undefined
? `${t('Today')} +${formatQuotaWithCurrency(todayAward)}`
: t('Check in daily to receive random quota rewards')}
</p>
</div>
</span>
</span>
<span className='text-muted-foreground line-clamp-2 text-sm'>
{checkedToday && todayAward !== undefined
? `${t('Today')} +${formatQuotaWithCurrency(todayAward)}`
: t('Check in daily to receive random quota rewards')}
</span>
</button>
<Button
onClick={() => doCheckin()}
disabled={checkinLoading || checkedToday}
size='sm'
className='w-full shrink-0 sm:w-auto'
>
{checkinLoading
? t('Loading...')
: checkedToday
? t('Checked in')
: t('Check in now')}
{checkinButtonLabel}
</Button>
</div>
</div>
@@ -332,25 +318,25 @@ export function CheckinCalendarCard({
{!collapsed ? (
<>
{/* Stats */}
<div className='grid grid-cols-3 gap-px border-b'>
<div className='bg-card p-3 text-center sm:p-5'>
<div className='text-xl font-semibold tracking-tight tabular-nums sm:text-2xl'>
<div className='divide-border/60 grid grid-cols-3 divide-x border-b'>
<div className='min-w-0 px-3 py-3 text-center sm:py-4'>
<div className='truncate text-lg font-semibold tracking-tight tabular-nums sm:text-xl'>
{checkinData?.stats?.total_checkins || 0}
</div>
<div className='text-muted-foreground mt-0.5 text-xs font-medium sm:mt-1 sm:text-xs'>
<div className='text-muted-foreground mt-0.5 truncate text-xs'>
{t('Total check-ins')}
</div>
</div>
<div className='bg-card p-3 text-center sm:p-5'>
<div className='text-xl font-semibold tracking-tight tabular-nums sm:text-2xl'>
<div className='min-w-0 px-3 py-3 text-center sm:py-4'>
<div className='truncate text-lg font-semibold tracking-tight tabular-nums sm:text-xl'>
{formatQuotaWithCurrency(monthlyQuota, { digitsLarge: 0 })}
</div>
<div className='text-muted-foreground mt-0.5 text-xs font-medium sm:mt-1 sm:text-xs'>
<div className='text-muted-foreground mt-0.5 truncate text-xs'>
{t('This month')}
</div>
</div>
<div className='bg-card p-3 text-center sm:p-5'>
<div className='text-xl font-semibold tracking-tight tabular-nums sm:text-2xl'>
<div className='min-w-0 px-3 py-3 text-center sm:py-4'>
<div className='truncate text-lg font-semibold tracking-tight tabular-nums sm:text-xl'>
{formatQuotaWithCurrency(
checkinData?.stats?.total_quota || 0,
{
@@ -358,25 +344,24 @@ export function CheckinCalendarCard({
}
)}
</div>
<div className='text-muted-foreground mt-0.5 text-xs font-medium sm:mt-1 sm:text-xs'>
<div className='text-muted-foreground mt-0.5 truncate text-xs'>
{t('Total earned')}
</div>
</div>
</div>
{/* Calendar */}
<div className='p-4 sm:p-6'>
<div className='p-4 sm:p-5'>
<div className='space-y-3 sm:space-y-4'>
{/* Month navigation */}
<div className='flex items-center justify-between'>
<h4 className='text-xs font-semibold sm:text-sm'>
<h4 className='text-sm font-medium tabular-nums'>
{dayjs(currentMonth).format('YYYY-MM')}
</h4>
<div className='flex items-center gap-0.5 sm:gap-1'>
<Button
variant='ghost'
size='icon'
className='h-7 w-7 sm:h-8 sm:w-8'
onClick={handlePrevMonth}
>
<ChevronLeft className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
@@ -384,7 +369,6 @@ export function CheckinCalendarCard({
<Button
variant='ghost'
size='icon'
className='h-7 w-7 sm:h-8 sm:w-8'
onClick={handleNextMonth}
>
<ChevronRight className='h-3.5 w-3.5 sm:h-4 sm:w-4' />
@@ -405,7 +389,7 @@ export function CheckinCalendarCard({
))}
{/* Calendar days */}
{calendarDays.map((dayObj, idx) => {
{calendarDays.map((dayObj) => {
const dateStr = `${dayObj.date.getFullYear()}-${String(
dayObj.date.getMonth() + 1
).padStart(2, '0')}-${String(
@@ -418,7 +402,7 @@ export function CheckinCalendarCard({
const dayButton = (
<Button
key={idx}
key={dateStr}
variant={isToday ? 'default' : 'ghost'}
disabled={!dayObj.isCurrentMonth}
className={cn(
@@ -437,8 +421,8 @@ export function CheckinCalendarCard({
if (isCheckedIn && dayObj.isCurrentMonth) {
return (
<Tooltip key={idx}>
<TooltipTrigger render={dayButton}></TooltipTrigger>
<Tooltip key={dateStr}>
<TooltipTrigger render={dayButton} />
<TooltipContent>
<div className='text-xs'>
<div className='font-medium'>
@@ -458,21 +442,9 @@ export function CheckinCalendarCard({
</div>
{/* Footer hint */}
<div className='text-muted-foreground border-t pt-3 text-center text-xs sm:pt-4 sm:text-xs'>
<div className='text-muted-foreground border-t pt-3 text-center text-xs sm:pt-4'>
{t('You can only check in once per day')}
</div>
<div className='bg-muted/30 text-muted-foreground rounded-lg border p-3 text-xs'>
<ul className='list-disc space-y-1 pl-5'>
<li>
{t('Check in daily to receive random quota rewards')}
</li>
<li>
{t('Rewards will be added directly to your balance')}
</li>
<li>{t('Do not repeat check-in; only once per day')}</li>
</ul>
</div>
</div>
</div>
</>
@@ -24,9 +24,11 @@ import { toast } from 'sonner'
import { IconDiscord } from '@/assets/brand-icons'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Button } from '@/components/design-system/button'
import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { TitledCard } from '@/components/ui/titled-card'
import { OAUTH_BIND_STORAGE_KEY } from '@/features/auth/constants'
import { useDialogs } from '@/hooks/use-dialog'
import { useStatus } from '@/hooks/use-status'
@@ -41,30 +43,28 @@ import {
getSelfOAuthBindings,
unbindCustomOAuth,
type CustomOAuthBinding,
} from '../../api'
import type { UserProfile, BindingItem } from '../../types'
import { EmailBindDialog } from '../dialogs/email-bind-dialog'
import { TelegramBindDialog } from '../dialogs/telegram-bind-dialog'
import { WeChatBindDialog } from '../dialogs/wechat-bind-dialog'
} from '../api'
import type { UserProfile, BindingItem } from '../types'
import { EmailBindDialog } from './dialogs/email-bind-dialog'
import { TelegramBindDialog } from './dialogs/telegram-bind-dialog'
import { WeChatBindDialog } from './dialogs/wechat-bind-dialog'
// ============================================================================
// Account Bindings Tab Component
// ============================================================================
interface AccountBindingsTabProps {
interface ConnectedAccountsCardProps {
profile: UserProfile | null
loading: boolean
onUpdate: () => void
}
type DialogKey = 'email' | 'wechat' | 'telegram'
export function AccountBindingsTab({
export function ConnectedAccountsCard({
profile,
loading,
onUpdate,
}: AccountBindingsTabProps) {
}: ConnectedAccountsCardProps) {
const { t } = useTranslation()
const dialogs = useDialogs<DialogKey>()
const { status, loading } = useStatus()
const { status, loading: statusLoading } = useStatus()
const [customBindings, setCustomBindings] = useState<CustomOAuthBinding[]>([])
const [unbindTarget, setUnbindTarget] = useState<CustomOAuthBinding | null>(
null
@@ -261,119 +261,122 @@ export function AccountBindingsTab({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [profile, status, t])
if (!profile || loading) return null
if (loading || statusLoading) {
return (
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
<CardHeader className='border-b p-4 !pb-4 sm:p-5 sm:!pb-5'>
<Skeleton className='h-6 w-40' />
<Skeleton className='mt-2 h-4 w-64' />
</CardHeader>
<CardContent className='grid grid-cols-1 gap-2.5 p-4 sm:grid-cols-2 sm:gap-3 sm:p-5'>
<Skeleton className='h-14 w-full rounded-lg' />
<Skeleton className='h-14 w-full rounded-lg' />
<Skeleton className='h-14 w-full rounded-lg' />
<Skeleton className='h-14 w-full rounded-lg' />
</CardContent>
</Card>
)
}
if (!profile) return null
return (
<>
<div className='grid grid-cols-1 gap-2.5 sm:grid-cols-2 sm:gap-3'>
{bindings.map((binding) => (
<div
key={binding.id}
className='flex items-center justify-between gap-2.5 rounded-lg border p-2.5 sm:gap-3 sm:p-3'
>
<div className='flex min-w-0 items-center gap-2.5 sm:gap-3'>
<div className='bg-muted shrink-0 rounded-md p-1.5 sm:p-2'>
<binding.icon className='h-4 w-4' />
</div>
<div className='min-w-0'>
<div className='flex items-center gap-1.5'>
<p className='text-sm font-medium'>{binding.label}</p>
{binding.isBound && (
<StatusBadge
label={t('Bound')}
variant='success'
copyable={false}
/>
)}
</div>
<p className='text-muted-foreground truncate text-xs'>
{binding.value || t('Not bound')}
</p>
</div>
</div>
<Button
variant='outline'
size='sm'
className='h-7 shrink-0 px-2.5 text-xs'
onClick={binding.onBind}
disabled={binding.isBound && binding.id !== 'email'}
>
{binding.isBound
? binding.id === 'email'
? t('Change')
: t('Bound')
: t('Bind')}
</Button>
</div>
))}
</div>
{/* Custom OAuth Bindings */}
{customProviders && customProviders.length > 0 && (
<>
<Separator className='my-4' />
<p className='text-muted-foreground mb-3 text-sm font-medium'>
{t('Custom OAuth')}
</p>
<div className='grid grid-cols-1 gap-2.5 sm:grid-cols-2 sm:gap-3'>
{customProviders.map((provider) => {
const binding = customBindings.find(
(b) => b.provider_id === provider.id
)
const isBound = !!binding
return (
<div
key={provider.id}
className='flex items-center justify-between gap-2.5 rounded-lg border p-2.5 sm:gap-3 sm:p-3'
>
<div className='flex min-w-0 items-center gap-2.5 sm:gap-3'>
<div className='bg-muted shrink-0 rounded-md p-1.5 sm:p-2'>
<Link2 className='h-4 w-4' />
</div>
<div className='min-w-0'>
<div className='flex items-center gap-1.5'>
<p className='text-sm font-medium'>{provider.name}</p>
{isBound && (
<StatusBadge
label={t('Bound')}
variant='success'
copyable={false}
/>
)}
</div>
<p className='text-muted-foreground truncate text-xs'>
{isBound
? binding?.external_id || t('Bound')
: t('Not bound')}
</p>
<TitledCard
title={t('Connected Accounts')}
description={t('Link third-party accounts for sign-in and alerts')}
disableHoverEffect
>
<div className='grid grid-cols-1 gap-2.5 sm:grid-cols-2 sm:gap-3'>
{bindings.map((binding) => {
let actionLabel = t('Bind')
if (binding.isBound) {
actionLabel = binding.id === 'email' ? t('Change') : t('Bound')
}
return (
<div
key={binding.id}
className='flex items-center justify-between gap-3 rounded-lg border p-3'
>
<div className='flex min-w-0 items-center gap-3'>
<binding.icon className='text-muted-foreground size-4 shrink-0' />
<div className='min-w-0'>
<div className='flex items-center gap-1.5'>
<p className='text-sm font-medium'>{binding.label}</p>
{binding.isBound && (
<StatusBadge variant='success'>
{t('Bound')}
</StatusBadge>
)}
</div>
<p className='text-muted-foreground truncate text-xs'>
{binding.value || t('Not bound')}
</p>
</div>
{isBound ? (
<Button
variant='ghost'
size='sm'
className='text-destructive h-7 shrink-0 px-2.5 text-xs'
onClick={() => setUnbindTarget(binding)}
>
<Unlink className='mr-1 h-3 w-3' />
{t('Unbind')}
</Button>
) : (
<Button
variant='outline'
size='sm'
className='h-7 shrink-0 px-2.5 text-xs'
onClick={() => handleBindCustomOAuth(provider)}
>
{t('Bind')}
</Button>
)}
</div>
)
})}
</div>
</>
)}
<Button
variant='outline'
className='shrink-0'
onClick={binding.onBind}
disabled={binding.isBound && binding.id !== 'email'}
>
{actionLabel}
</Button>
</div>
)
})}
{customProviders?.map((provider) => {
const binding = customBindings.find(
(b) => b.provider_id === provider.id
)
const isBound = !!binding
return (
<div
key={provider.id}
className='flex items-center justify-between gap-3 rounded-lg border p-3'
>
<div className='flex min-w-0 items-center gap-3'>
<Link2 className='text-muted-foreground size-4 shrink-0' />
<div className='min-w-0'>
<div className='flex items-center gap-1.5'>
<p className='text-sm font-medium'>{provider.name}</p>
{isBound && (
<StatusBadge variant='success'>
{t('Bound')}
</StatusBadge>
)}
</div>
<p className='text-muted-foreground truncate text-xs'>
{isBound
? binding?.external_id || t('Bound')
: t('Not bound')}
</p>
</div>
</div>
{isBound ? (
<Button
variant='ghost'
className='text-destructive shrink-0'
onClick={() => setUnbindTarget(binding)}
>
<Unlink className='mr-1 size-3' />
{t('Unbind')}
</Button>
) : (
<Button
variant='outline'
className='shrink-0'
onClick={() => handleBindCustomOAuth(provider)}
>
{t('Bind')}
</Button>
)}
</div>
)
})}
</div>
</TitledCard>
{/* Custom OAuth Unbind Confirmation */}
<ConfirmDialog
@@ -392,7 +395,6 @@ export function AccountBindingsTab({
isLoading={unbinding}
/>
{/* Email Bind Dialog */}
<EmailBindDialog
open={dialogs.isOpen('email')}
onOpenChange={(open) =>
@@ -402,7 +404,6 @@ export function AccountBindingsTab({
onSuccess={onUpdate}
/>
{/* WeChat Bind Dialog */}
<WeChatBindDialog
open={dialogs.isOpen('wechat')}
onOpenChange={(open) =>
@@ -411,7 +412,6 @@ export function AccountBindingsTab({
onSuccess={onUpdate}
/>
{/* Telegram Bind Dialog */}
{status?.telegram_bot_name && (
<TelegramBindDialog
open={dialogs.isOpen('telegram')}
@@ -21,9 +21,9 @@ import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { CopyButton } from '@/components/copy-button'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useAccessToken } from '../../hooks'
@@ -102,7 +102,6 @@ export function AccessTokenDialog({
<CopyButton
value={token}
variant='outline'
className='size-9'
iconClassName='size-4'
tooltip={t('Copy token')}
aria-label={t('Copy token')}
@@ -21,9 +21,9 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/design-system/button'
import { Dialog } from '@/components/dialog'
import { PasswordInput } from '@/components/password-input'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { updateUserProfile } from '../../api'
@@ -102,7 +102,7 @@ export function ChangePasswordDialog({
} else {
toast.error(response.message || t('Failed to change password'))
}
} catch (_error) {
} catch {
toast.error(t('Failed to change password'))
} finally {
setLoading(false)
@@ -22,10 +22,10 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import { Dialog } from '@/components/dialog'
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 { api } from '@/lib/api'
import { useAuthStore } from '@/stores/auth-store'
@@ -79,7 +79,7 @@ export function DeleteAccountDialog({
} else {
toast.error(response.message || t('Failed to delete account'))
}
} catch (_error) {
} catch {
toast.error(t('Failed to delete account'))
} finally {
setLoading(false)
@@ -21,9 +21,9 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useCountdown } from '@/hooks/use-countdown'
@@ -76,7 +76,7 @@ export function EmailBindDialog({
} else {
toast.error(response.message || t('Failed to send verification code'))
}
} catch (_error) {
} catch {
toast.error(t('Failed to send verification code'))
} finally {
setSendingCode(false)
@@ -104,7 +104,7 @@ export function EmailBindDialog({
} else {
toast.error(response.message || t('Failed to bind email'))
}
} catch (_error) {
} catch {
toast.error(t('Failed to bind email'))
} finally {
setLoading(false)
@@ -123,6 +123,11 @@ export function EmailBindDialog({
}
}
let sendCodeLabel = sendingCode ? t('Sending...') : t('Send')
if (isActive) {
sendCodeLabel = `${secondsLeft}s`
}
return (
<Dialog
open={open}
@@ -189,11 +194,7 @@ export function EmailBindDialog({
onClick={handleSendCode}
disabled={sendingCode || isActive || !email}
>
{isActive
? `${secondsLeft}s`
: sendingCode
? t('Sending...')
: t('Send')}
{sendCodeLabel}
</Button>
</div>
</div>
@@ -22,10 +22,10 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { CopyButton } from '@/components/copy-button'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import { Dialog } from '@/components/dialog'
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 { regenerate2FABackupCodes } from '@/lib/api'
@@ -65,7 +65,7 @@ export function TwoFABackupDialog({
} else {
toast.error(response.message || t('Failed to regenerate backup codes'))
}
} catch (_error) {
} catch {
toast.error(t('Failed to regenerate backup codes'))
} finally {
setLoading(false)
@@ -107,25 +107,23 @@ export function TwoFABackupDialog({
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
{backupCodes.length === 0 ? (
<>
<Button
variant='outline'
onClick={() => handleOpenChange(false)}
disabled={loading}
>
{t('Cancel')}
</Button>
<Button onClick={handleRegenerate} disabled={loading || !code}>
{loading && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{loading ? t('Generating...') : t('Generate New Codes')}
</Button>
</>
) : (
<Button onClick={handleDone}>{t('Done')}</Button>
)}
</>
backupCodes.length === 0 ? (
<>
<Button
variant='outline'
onClick={() => handleOpenChange(false)}
disabled={loading}
>
{t('Cancel')}
</Button>
<Button onClick={handleRegenerate} disabled={loading || !code}>
{loading && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{loading ? t('Generating...') : t('Generate New Codes')}
</Button>
</>
) : (
<Button onClick={handleDone}>{t('Done')}</Button>
)
}
>
<div className='space-y-4 py-4'>
@@ -163,12 +161,12 @@ export function TwoFABackupDialog({
<div className='rounded-lg border p-4'>
<div className='grid grid-cols-2 gap-2'>
{backupCodes.map((code, index) => (
{backupCodes.map((backupCode) => (
<div
key={index}
key={backupCode}
className='bg-muted rounded-md p-2 text-center font-mono text-sm'
>
{code}
{backupCode}
</div>
))}
</div>
@@ -21,11 +21,11 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import { Dialog } from '@/components/dialog'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { disable2FA } from '@/lib/api'
@@ -74,7 +74,7 @@ export function TwoFADisableDialog({
} else {
toast.error(response.message || t('Failed to disable 2FA'))
}
} catch (_error) {
} catch {
toast.error(t('Failed to disable 2FA'))
} finally {
setLoading(false)
@@ -23,10 +23,10 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { CopyButton } from '@/components/copy-button'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import { Dialog } from '@/components/dialog'
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 { setup2FA, enable2FA } from '@/lib/api'
@@ -102,7 +102,7 @@ export function TwoFASetupDialog({
} else {
toast.error(response.message || t('Failed to enable 2FA'))
}
} catch (_error) {
} catch {
toast.error(t('Failed to enable 2FA'))
} finally {
setLoading(false)
@@ -177,20 +177,22 @@ export function TwoFASetupDialog({
}
>
<div className='space-y-4 py-4'>
{initializing ? (
{initializing && (
<div className='flex flex-col items-center justify-center gap-3 py-8'>
<div className='border-primary h-8 w-8 animate-spin rounded-full border-4 border-t-transparent' />
<div className='text-muted-foreground text-sm'>
{t('Setting up 2FA...')}
</div>
</div>
) : !setupData ? (
)}
{!initializing && !setupData && (
<div className='flex justify-center py-8'>
<div className='text-muted-foreground'>
{t('Failed to load setup data')}
</div>
</div>
) : (
)}
{!initializing && setupData && (
<>
{/* Step 0: QR Code */}
{step === 0 && (
@@ -236,12 +238,12 @@ export function TwoFASetupDialog({
</Alert>
<div className='rounded-lg border p-4'>
<div className='grid grid-cols-2 gap-2'>
{setupData.backup_codes.map((code, index) => (
{setupData.backup_codes.map((backupCode) => (
<div
key={index}
key={backupCode}
className='bg-muted rounded-md p-2 text-center font-mono text-sm'
>
{code}
{backupCode}
</div>
))}
</div>
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Languages, Loader2 } from 'lucide-react'
import { Loader2 } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -28,8 +28,8 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { TitledCard } from '@/components/ui/titled-card'
} from '@/components/design-system/select'
import { Card, CardContent } from '@/components/ui/card'
import {
INTERFACE_LANGUAGE_OPTIONS,
normalizeInterfaceLanguage,
@@ -93,7 +93,7 @@ export function LanguagePreferencesCard(props: LanguagePreferencesCardProps) {
props.onProfileUpdate()
toast.success(t('Language preference saved'))
} catch (_error) {
} catch {
setCurrentLanguage(previousLanguage)
await i18n.changeLanguage(previousLanguage)
toast.error(t('Failed to update settings'))
@@ -103,34 +103,27 @@ export function LanguagePreferencesCard(props: LanguagePreferencesCardProps) {
}
return (
<TitledCard
title={t('Language Preferences')}
description={t('Set the language used across the interface')}
icon={<Languages className='h-4 w-4' />}
disableHoverEffect
>
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4'>
<div className='space-y-1'>
<div className='text-sm font-medium'>{t('Interface Language')}</div>
<Card data-card-hover='false' className='py-0'>
<CardContent className='flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:p-5'>
<div className='min-w-0 space-y-0.5'>
<p className='text-sm font-medium'>{t('Interface Language')}</p>
<p className='text-muted-foreground line-clamp-2 text-xs sm:text-sm'>
{t(
'Language preferences sync across your signed-in devices and affect API error messages.'
)}
</p>
</div>
<div className='flex items-center gap-2 sm:min-w-48'>
<div className='flex shrink-0 items-center gap-2'>
<Select
items={[
...INTERFACE_LANGUAGE_OPTIONS.map((language) => ({
value: language.code,
label: language.label,
})),
]}
items={INTERFACE_LANGUAGE_OPTIONS.map((language) => ({
value: language.code,
label: language.label,
}))}
value={currentLanguage}
onValueChange={handleLanguageChange}
disabled={saving}
>
<SelectTrigger className='w-full sm:w-48'>
<SelectTrigger className='w-full sm:w-44'>
<SelectValue placeholder={t('Select language')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
@@ -147,7 +140,7 @@ export function LanguagePreferencesCard(props: LanguagePreferencesCardProps) {
<Loader2 className='text-muted-foreground size-4 animate-spin' />
)}
</div>
</div>
</TitledCard>
</CardContent>
</Card>
)
}
@@ -21,21 +21,27 @@ import { useState, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import {
ToggleGroup,
ToggleGroupItem,
} from '@/components/design-system/toggle-group'
import { PasswordInput } from '@/components/password-input'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { TitledCard } from '@/components/ui/titled-card'
import { ROLE } from '@/lib/roles'
import { updateUserSettings } from '../../api'
import { updateUserSettings } from '../api'
import {
DEFAULT_QUOTA_WARNING_THRESHOLD,
NOTIFICATION_METHODS,
} from '../../constants'
import { parseUserSettings } from '../../lib'
import type { UserProfile, UserSettings, NotifyType } from '../../types'
} from '../constants'
import { parseUserSettings } from '../lib'
import type { UserProfile, UserSettings, NotifyType } from '../types'
const NOTIFICATION_ICONS: Record<NotifyType, typeof Mail> = {
email: Mail,
@@ -55,16 +61,17 @@ function normalizeNotifyType(value: unknown): NotifyType {
: 'email'
}
// ============================================================================
// Settings Tab Component
// ============================================================================
interface NotificationTabProps {
interface NotificationSettingsCardProps {
profile: UserProfile | null
loading: boolean
onUpdate: () => void
}
export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
export function NotificationSettingsCard({
profile,
loading: pageLoading,
onUpdate,
}: NotificationSettingsCardProps) {
const { t } = useTranslation()
const isAdmin = (profile?.role ?? 0) >= ROLE.ADMIN
const [loading, setLoading] = useState(false)
@@ -125,7 +132,7 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
} else {
toast.error(response.message || t('Failed to update settings'))
}
} catch (_error) {
} catch {
toast.error(t('Failed to update settings'))
} finally {
setLoading(false)
@@ -134,8 +141,29 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
const notifyType = normalizeNotifyType(settings.notify_type)
if (pageLoading) {
return (
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
<CardHeader className='border-b p-4 !pb-4 sm:p-5 sm:!pb-5'>
<Skeleton className='h-6 w-40' />
<Skeleton className='mt-2 h-4 w-64' />
</CardHeader>
<CardContent className='space-y-4 p-4 sm:p-5'>
<Skeleton className='h-14 w-full' />
<Skeleton className='h-9 w-full' />
<Skeleton className='h-9 w-full' />
</CardContent>
</Card>
)
}
return (
<div className='space-y-4 sm:space-y-6'>
<TitledCard
title={t('Notification Settings')}
description={t('Choose how you receive alerts and account notices')}
disableHoverEffect
contentClassName='space-y-5 sm:space-y-6'
>
{/* Notification Type */}
<div className='space-y-2.5'>
<Label>{t('Notification Method')}</Label>
@@ -143,14 +171,15 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
value={[notifyType]}
onValueChange={(value) => {
const nextValue = value.find((item) => item !== notifyType)
if (nextValue)
if (nextValue) {
updateField('notify_type', normalizeNotifyType(nextValue))
}
}}
aria-label={t('Notification Method')}
variant='outline'
size='lg'
spacing={2}
className='grid w-full grid-cols-2 gap-2 sm:grid-cols-4 sm:gap-3'
className='grid w-full grid-cols-2 gap-2 sm:grid-cols-4'
>
{NOTIFICATION_METHODS.map((method) => {
const Icon = NOTIFICATION_ICONS[method.value]
@@ -158,10 +187,10 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
<ToggleGroupItem
key={method.value}
value={method.value}
className='h-auto min-h-14 w-full flex-col gap-1.5 px-3 py-3 sm:min-h-16'
className='h-auto min-h-14 w-full flex-col gap-1.5 px-3 py-2.5 sm:h-auto'
>
<Icon className='h-4 w-4 sm:h-5 sm:w-5' />
<span className='max-w-full truncate text-xs font-medium sm:text-sm'>
<Icon className='size-4' />
<span className='max-w-full truncate text-xs font-medium'>
{t(method.label)}
</span>
</ToggleGroupItem>
@@ -176,7 +205,6 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
<Input
id='threshold'
type='number'
className='h-9'
value={settings.quota_warning_threshold}
onChange={(e) =>
updateField('quota_warning_threshold', Number(e.target.value))
@@ -195,7 +223,6 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
<Input
id='notifyEmail'
type='email'
className='h-9'
value={settings.notification_email}
onChange={(e) => updateField('notification_email', e.target.value)}
placeholder={t('Leave empty to use account email')}
@@ -211,7 +238,6 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
<Input
id='webhookUrl'
type='url'
className='h-9'
value={settings.webhook_url}
onChange={(e) => updateField('webhook_url', e.target.value)}
placeholder={t('https://example.com/webhook')}
@@ -236,7 +262,6 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
<Input
id='barkUrl'
type='url'
className='h-9'
value={settings.bark_url}
onChange={(e) => updateField('bark_url', e.target.value)}
placeholder={t('https://api.day.app/yourkey/{{title}}/{{content}}')}
@@ -255,7 +280,6 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
<Input
id='gotifyUrl'
type='url'
className='h-9'
value={settings.gotify_url}
onChange={(e) => updateField('gotify_url', e.target.value)}
placeholder={t('https://gotify.example.com')}
@@ -281,7 +305,6 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
<Input
id='gotifyPriority'
type='number'
className='h-9'
min='0'
max='10'
value={settings.gotify_priority}
@@ -296,8 +319,8 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
)}
</p>
</div>
<div className='bg-muted/50 rounded-lg border p-3 sm:p-4'>
<h5 className='mb-1.5 text-sm font-medium sm:mb-2'>
<div className='bg-muted/30 rounded-lg border p-3 sm:p-4'>
<h5 className='mb-1.5 text-sm font-medium'>
{t('Setup Instructions')}
</h5>
<ol className='text-muted-foreground space-y-1 text-xs'>
@@ -320,86 +343,87 @@ export function NotificationTab({ profile, onUpdate }: NotificationTabProps) {
</>
)}
{/* Divider */}
<div className='border-t' />
{/* Preferences Section */}
<div className='space-y-3'>
<div className='space-y-3 border-t pt-5 sm:pt-6'>
<div>
<h4 className='text-sm font-medium'>{t('Preferences')}</h4>
<p className='text-muted-foreground mt-1 text-xs'>
<p className='text-muted-foreground mt-0.5 text-xs'>
{t('Configure your account behavior preferences')}
</p>
</div>
{/* Receive Upstream Model Update Notifications (admin only) */}
{isAdmin && (
<div className='flex items-start justify-between gap-3 rounded-lg border p-3 sm:items-center sm:p-4'>
<div className='divide-border/60 divide-y rounded-lg border'>
{/* Receive Upstream Model Update Notifications (admin only) */}
{isAdmin && (
<div className='flex items-start justify-between gap-3 p-3 sm:items-center sm:p-4'>
<div className='space-y-0.5'>
<Label htmlFor='upstreamModelUpdateNotify'>
{t('Receive Upstream Model Update Notifications')}
</Label>
<p className='text-muted-foreground line-clamp-3 text-xs sm:line-clamp-none'>
{t(
'Only available for admins. When enabled, you will receive a summary notification via your selected method when the scheduled model check detects upstream model changes or check failures.'
)}
</p>
</div>
<Switch
id='upstreamModelUpdateNotify'
className='shrink-0'
checked={settings.upstream_model_update_notify_enabled}
onCheckedChange={(checked) =>
updateField('upstream_model_update_notify_enabled', checked)
}
/>
</div>
)}
{/* Accept Unset Model Price */}
<div className='flex items-start justify-between gap-3 p-3 sm:items-center sm:p-4'>
<div className='space-y-0.5'>
<Label htmlFor='upstreamModelUpdateNotify'>
{t('Receive Upstream Model Update Notifications')}
<Label htmlFor='acceptUnsetPrice'>
{t('Accept Unpriced Models')}
</Label>
<p className='text-muted-foreground line-clamp-3 text-xs sm:line-clamp-none sm:text-sm'>
{t(
'Only available for admins. When enabled, you will receive a summary notification via your selected method when the scheduled model check detects upstream model changes or check failures.'
)}
<p className='text-muted-foreground text-xs'>
{t('Allow using models without price configuration')}
</p>
</div>
<Switch
id='upstreamModelUpdateNotify'
id='acceptUnsetPrice'
className='shrink-0'
checked={settings.upstream_model_update_notify_enabled}
checked={settings.accept_unset_model_ratio_model}
onCheckedChange={(checked) =>
updateField('upstream_model_update_notify_enabled', checked)
updateField('accept_unset_model_ratio_model', checked)
}
/>
</div>
)}
{/* Accept Unset Model Price */}
<div className='flex items-start justify-between gap-3 rounded-lg border p-3 sm:items-center sm:p-4'>
<div className='space-y-0.5'>
<Label htmlFor='acceptUnsetPrice'>
{t('Accept Unpriced Models')}
</Label>
<p className='text-muted-foreground text-xs sm:text-sm'>
{t('Allow using models without price configuration')}
</p>
{/* Record IP Log */}
<div className='flex items-start justify-between gap-3 p-3 sm:items-center sm:p-4'>
<div className='space-y-0.5'>
<Label htmlFor='recordIp'>{t('Record IP Address')}</Label>
<p className='text-muted-foreground text-xs'>
{t('Log IP address for usage and error logs')}
</p>
</div>
<Switch
id='recordIp'
className='shrink-0'
checked={settings.record_ip_log}
onCheckedChange={(checked) =>
updateField('record_ip_log', checked)
}
/>
</div>
<Switch
id='acceptUnsetPrice'
className='shrink-0'
checked={settings.accept_unset_model_ratio_model}
onCheckedChange={(checked) =>
updateField('accept_unset_model_ratio_model', checked)
}
/>
</div>
{/* Record IP Log */}
<div className='flex items-start justify-between gap-3 rounded-lg border p-3 sm:items-center sm:p-4'>
<div className='space-y-0.5'>
<Label htmlFor='recordIp'>{t('Record IP Address')}</Label>
<p className='text-muted-foreground text-xs sm:text-sm'>
{t('Log IP address for usage and error logs')}
</p>
</div>
<Switch
id='recordIp'
className='shrink-0'
checked={settings.record_ip_log}
onCheckedChange={(checked) => updateField('record_ip_log', checked)}
/>
</div>
</div>
{/* Save Button */}
<div className='flex justify-end'>
<Button onClick={handleSave} disabled={loading}>
{loading && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{loading && <Loader2 className='mr-2 size-4 animate-spin' />}
{loading ? t('Saving...') : t('Save Settings')}
</Button>
</div>
</div>
</TitledCard>
)
}
@@ -16,12 +16,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { AlertTriangle, KeyRound, Loader2, ShieldAlert } from 'lucide-react'
import { Loader2 } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { StatusBadge } from '@/components/status-badge'
import {
AlertDialog,
AlertDialogAction,
@@ -32,15 +31,9 @@ import {
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
} from '@/components/design-system/alert-dialog'
import { Button } from '@/components/design-system/button'
import { StatusBadge } from '@/components/status-badge'
import { Skeleton } from '@/components/ui/skeleton'
import { usePasskeyManagement } from '@/features/auth/passkey'
import {
@@ -51,11 +44,11 @@ import {
} from '@/features/auth/secure-verification'
import dayjs from '@/lib/dayjs'
interface PasskeyCardProps {
interface PasskeyRowProps {
loading: boolean
}
export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
export function PasskeyRow({ loading: pageLoading }: PasskeyRowProps) {
const { t } = useTranslation()
const [confirmOpen, setConfirmOpen] = useState(false)
const [restrictedMethod, setRestrictedMethod] =
@@ -189,15 +182,10 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
if (pageLoading || loading) {
return (
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
<CardHeader className='p-3 sm:p-5'>
<Skeleton className='h-6 w-48' />
<Skeleton className='mt-2 h-4 w-64' />
</CardHeader>
<CardContent className='p-3 sm:p-5'>
<Skeleton className='h-20 w-full' />
</CardContent>
</Card>
<div className='space-y-2 py-4 first:pt-0 last:pb-0'>
<Skeleton className='h-5 w-48' />
<Skeleton className='h-4 w-64' />
</div>
)
}
@@ -228,129 +216,86 @@ export function PasskeyCard({ loading: pageLoading }: PasskeyCardProps) {
return (
<>
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
<CardHeader className='p-3 sm:p-5'>
<CardTitle className='text-lg tracking-tight sm:text-xl'>
{t('Passkey Login')}
</CardTitle>
<CardDescription className='text-xs sm:text-sm'>
{t('Use Passkey to sign in without entering your password.')}
</CardDescription>
</CardHeader>
<CardContent className='p-3 sm:p-5'>
<div className='space-y-6'>
<div className='flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between xl:flex-col 2xl:flex-row'>
<div className='flex items-start gap-4'>
<div className='bg-muted rounded-md p-2'>
<KeyRound className='h-5 w-5' />
</div>
<div className='space-y-1'>
<div className='flex flex-wrap items-center gap-2'>
<p className='font-medium'>{t('Passkey Authentication')}</p>
<StatusBadge
label={enabled ? t('Enabled') : t('Disabled')}
variant={enabled ? 'success' : 'neutral'}
showDot
copyable={false}
/>
{backupStatus && (
<StatusBadge
label={backupStatus.label}
variant={backupStatus.variant}
showDot
copyable={false}
/>
)}
</div>
<p className='text-muted-foreground text-sm'>
{t('Last used:')} {formattedLastUsed}
</p>
</div>
</div>
{!enabled && (
<Button
className='w-full sm:w-auto xl:w-full 2xl:w-auto'
onClick={handleRegister}
disabled={!supported || registering}
>
{registering && (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
)}
{t('Enable Passkey')}
</Button>
)}
</div>
{enabled && (
<div className='flex flex-col gap-3 border-t pt-6 sm:flex-row xl:flex-col 2xl:flex-row'>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogTrigger
render={
<Button
variant='destructive'
className='flex-1'
disabled={removing}
/>
}
>
{removing ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<AlertTriangle className='mr-2 h-4 w-4' />
)}
{t('Remove Passkey')}
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t('Remove Passkey?')}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
'Removing Passkey will require you to sign in with your password next time. You can re-register anytime.'
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={removing}>
{t('Cancel')}
</AlertDialogCancel>
<AlertDialogAction
variant='destructive'
disabled={removing}
onClick={(event) => {
event.preventDefault()
handleRemove()
}}
>
{t('Remove')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
{showUnsupportedNotice && (
<div className='bg-muted/60 text-muted-foreground flex items-start gap-3 rounded-md p-4 text-sm'>
<ShieldAlert className='text-warning mt-0.5 h-4 w-4 flex-shrink-0' />
<div>
<p className='text-foreground font-medium'>
{t('Passkey not supported on this device')}
</p>
<p>
{t(
'Use a compatible browser or device with biometric authentication or a security key to register a Passkey.'
)}
</p>
</div>
</div>
<div className='flex flex-col gap-3 py-4 first:pt-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between'>
<div className='min-w-0 space-y-0.5'>
<div className='flex flex-wrap items-center gap-2'>
<p className='text-sm font-medium'>{t('Passkey')}</p>
<StatusBadge variant={enabled ? 'success' : 'neutral'}>
{enabled ? t('Enabled') : t('Disabled')}
</StatusBadge>
{backupStatus && (
<StatusBadge variant={backupStatus.variant}>
{backupStatus.label}
</StatusBadge>
)}
</div>
</CardContent>
</Card>
<p className='text-muted-foreground text-xs sm:text-sm'>
{enabled
? `${t('Last used:')} ${formattedLastUsed}`
: t('Use Passkey to sign in without entering your password.')}
</p>
{showUnsupportedNotice && (
<p className='text-muted-foreground text-xs'>
{t(
'Use a compatible browser or device with biometric authentication or a security key to register a Passkey.'
)}
</p>
)}
</div>
<div className='flex shrink-0 flex-wrap items-center gap-2'>
{enabled ? (
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogTrigger
render={
<Button
variant='outline'
className='text-destructive hover:text-destructive'
disabled={removing}
/>
}
>
{removing && <Loader2 className='mr-2 size-4 animate-spin' />}
{t('Remove Passkey')}
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('Remove Passkey?')}</AlertDialogTitle>
<AlertDialogDescription>
{t(
'Removing Passkey will require you to sign in with your password next time. You can re-register anytime.'
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={removing}>
{t('Cancel')}
</AlertDialogCancel>
<AlertDialogAction
variant='destructive'
disabled={removing}
onClick={(event) => {
event.preventDefault()
handleRemove()
}}
>
{t('Remove')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : (
<Button
variant='outline'
onClick={handleRegister}
disabled={!supported || registering}
>
{registering && <Loader2 className='mr-2 size-4 animate-spin' />}
{t('Enable Passkey')}
</Button>
)}
</div>
</div>
<SecureVerificationDialog
open={verificationOpen}
@@ -16,10 +16,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Activity, BarChart3, WalletCards } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { StatusBadge } from '@/components/status-badge'
import { CopyableStatusBadge, StatusBadge } from '@/components/status-badge'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Card, CardContent } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
@@ -30,10 +29,6 @@ import { getRoleLabel } from '@/lib/roles'
import { getDisplayName } from '../lib'
import type { UserProfile } from '../types'
// ============================================================================
// Profile Header Component
// ============================================================================
interface ProfileHeaderProps {
profile: UserProfile | null
loading: boolean
@@ -46,28 +41,20 @@ export function ProfileHeader({ profile, loading }: ProfileHeaderProps) {
return (
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
<CardContent className='p-4 sm:p-5'>
<div className='flex flex-col items-center gap-4 text-center sm:flex-row sm:text-left'>
<Skeleton className='h-16 w-16 rounded-2xl' />
<div className='space-y-3'>
<div className='flex flex-col items-center gap-2 sm:flex-row sm:justify-start'>
<Skeleton className='h-8 w-48' />
<Skeleton className='h-5 w-16' />
</div>
<div className='flex flex-col items-center gap-1 sm:flex-row sm:justify-start sm:gap-4'>
<Skeleton className='h-4 w-24' />
<Skeleton className='h-4 w-40' />
<Skeleton className='h-4 w-20' />
</div>
<div className='flex items-center gap-3 sm:gap-4'>
<Skeleton className='size-12 rounded-xl sm:size-14' />
<div className='space-y-2'>
<Skeleton className='h-6 w-48' />
<Skeleton className='h-4 w-64' />
</div>
</div>
</CardContent>
<div className='border-t'>
<div className='divide-border/60 grid grid-cols-1 divide-y sm:grid-cols-3 sm:divide-x sm:divide-y-0'>
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className='px-4 py-3.5 sm:px-5 sm:py-4'>
<Skeleton className='h-3.5 w-20' />
<div className='divide-border/60 grid grid-cols-3 divide-x'>
{['balance', 'usage', 'requests'].map((key) => (
<div key={key} className='px-4 py-3 sm:px-5 sm:py-4'>
<Skeleton className='h-4 w-24' />
<Skeleton className='mt-2 h-7 w-28' />
<Skeleton className='mt-1.5 h-3.5 w-24' />
</div>
))}
</div>
@@ -87,64 +74,53 @@ export function ProfileHeader({ profile, loading }: ProfileHeaderProps) {
{
label: t('Current Balance'),
value: formatQuota(profile.quota),
description: t('Remaining quota'),
icon: WalletCards,
},
{
label: t('Total Usage'),
value: formatQuota(profile.used_quota),
description: t('Total consumed quota'),
icon: BarChart3,
},
{
label: t('API Requests'),
value: formatCompactNumber(profile.request_count),
description: t('Total requests made'),
icon: Activity,
},
]
return (
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
<CardContent className='p-3 sm:p-5'>
<div className='flex items-center gap-3 text-left sm:gap-4'>
<Avatar className='ring-background h-12 w-12 rounded-xl text-sm ring-2 sm:h-16 sm:w-16 sm:rounded-2xl sm:text-lg sm:ring-4'>
<CardContent className='p-4 sm:p-5'>
<div className='flex items-center gap-3 sm:gap-4'>
<Avatar className='size-12 rounded-xl text-base sm:size-14 sm:text-lg'>
<AvatarFallback
className='rounded-xl font-semibold text-white sm:rounded-2xl'
className='rounded-xl font-semibold text-white'
style={avatarFallbackStyle}
>
{avatarFallback}
</AvatarFallback>
</Avatar>
<div className='min-w-0 flex-1 space-y-1.5 sm:space-y-3'>
<div className='flex min-w-0 items-center gap-2'>
<h1 className='truncate text-xl font-semibold tracking-tight sm:text-2xl'>
<div className='min-w-0 flex-1 space-y-1'>
<div className='flex min-w-0 flex-wrap items-center gap-x-2.5 gap-y-1'>
<h1 className='truncate text-xl font-semibold tracking-tight'>
{displayName}
</h1>
<StatusBadge
label={roleLabel}
variant='neutral'
copyable={false}
/>
<StatusBadge
label={`${t('User ID')} ${profile.id}`}
variant='info'
copyText={String(profile.id)}
/>
<StatusBadge className='shrink-0'>{roleLabel}</StatusBadge>
</div>
<div className='text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs sm:gap-x-4 sm:text-sm'>
<div className='text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm'>
<span className='truncate'>@{profile.username}</span>
<span aria-hidden='true'>·</span>
<CopyableStatusBadge value={String(profile.id)}>
ID {profile.id}
</CopyableStatusBadge>
{profile.email && (
<>
<span></span>
<span aria-hidden='true'>·</span>
<span className='truncate'>{profile.email}</span>
</>
)}
{profile.group && (
<>
<span></span>
<span aria-hidden='true'>·</span>
<span className='truncate'>{profile.group}</span>
</>
)}
@@ -155,20 +131,13 @@ export function ProfileHeader({ profile, loading }: ProfileHeaderProps) {
<div className='border-t'>
<div className='divide-border/60 grid grid-cols-3 divide-x'>
{stats.map((item) => (
<div key={item.label} className='min-w-0 px-3 py-3 sm:px-5 sm:py-4'>
<div className='flex items-center gap-2'>
<item.icon className='text-muted-foreground/60 size-3.5 shrink-0' />
<div className='text-muted-foreground truncate text-xs font-medium tracking-wider uppercase'>
{item.label}
</div>
<div key={item.label} className='min-w-0 px-4 py-3 sm:px-5 sm:py-4'>
<div className='text-muted-foreground truncate text-sm'>
{item.label}
</div>
<div className='text-foreground mt-1.5 truncate text-lg font-bold tracking-tight tabular-nums sm:mt-2 sm:text-2xl'>
<div className='text-foreground mt-1 truncate text-lg font-semibold tracking-tight tabular-nums sm:text-2xl'>
{item.value}
</div>
<div className='text-muted-foreground/60 mt-1 hidden text-xs md:block'>
{item.description}
</div>
</div>
))}
</div>
@@ -16,9 +16,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Shield, Key, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/design-system/button'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { TitledCard } from '@/components/ui/titled-card'
@@ -28,10 +28,8 @@ import type { UserProfile } from '../types'
import { AccessTokenDialog } from './dialogs/access-token-dialog'
import { ChangePasswordDialog } from './dialogs/change-password-dialog'
import { DeleteAccountDialog } from './dialogs/delete-account-dialog'
// ============================================================================
// Profile Security Card Component
// ============================================================================
import { PasskeyRow } from './passkey-row'
import { TwoFARow } from './two-fa-row'
interface ProfileSecurityCardProps {
profile: UserProfile | null
@@ -50,13 +48,16 @@ export function ProfileSecurityCard({
if (loading) {
return (
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
<CardHeader className='border-b p-3 !pb-3 sm:p-5 sm:!pb-5'>
<CardHeader className='border-b p-4 !pb-4 sm:p-5 sm:!pb-5'>
<Skeleton className='h-6 w-32' />
<Skeleton className='mt-2 h-4 w-48' />
<Skeleton className='mt-2 h-4 w-56' />
</CardHeader>
<CardContent className='space-y-3 p-3 sm:p-5'>
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className='h-16 w-full' />
<CardContent className='divide-border/60 divide-y p-4 sm:p-5'>
{['password', 'token', 'twofa', 'passkey'].map((key) => (
<div key={key} className='space-y-2 py-4 first:pt-0 last:pb-0'>
<Skeleton className='h-5 w-48' />
<Skeleton className='h-4 w-64' />
</div>
))}
</CardContent>
</Card>
@@ -65,69 +66,73 @@ export function ProfileSecurityCard({
if (!profile) return null
const securityActions = [
{
icon: Shield,
title: t('Change Password'),
description: t('Update your password to keep your account secure'),
action: () => dialogs.open('password'),
variant: 'default' as const,
},
{
icon: Key,
title: t('Access Token'),
description: t('Generate and manage your API access token'),
action: () => dialogs.open('token'),
variant: 'default' as const,
},
{
icon: Trash2,
title: t('Delete Account'),
description: t('Permanently delete your account and all data'),
action: () => dialogs.open('delete'),
variant: 'destructive' as const,
},
]
return (
<>
<TitledCard
title={t('Security')}
description={t('Manage your security settings and account access')}
icon={<Shield className='h-4 w-4' />}
disableHoverEffect
>
<div className='grid grid-cols-1 gap-2.5 sm:gap-3 md:grid-cols-3'>
{securityActions.map((item) => (
<button
key={item.title}
type='button'
onClick={item.action}
className={`flex items-center gap-3 rounded-lg border p-3 text-left md:flex-col md:gap-2 md:p-4 md:text-center ${
item.variant === 'destructive' ? 'border-destructive/30' : ''
}`}
<div className='divide-border/60 divide-y'>
<div className='flex flex-col gap-3 py-4 first:pt-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between'>
<div className='min-w-0 space-y-0.5'>
<p className='text-sm font-medium'>{t('Password')}</p>
<p className='text-muted-foreground text-xs sm:text-sm'>
{t('Update your password to keep your account secure')}
</p>
</div>
<Button
variant='outline'
className='shrink-0 self-start sm:self-auto'
onClick={() => dialogs.open('password')}
>
<div
className={`rounded-md p-2 ${
item.variant === 'destructive'
? 'bg-destructive/10 text-destructive'
: 'bg-muted'
}`}
>
<item.icon className='h-5 w-5' />
</div>
<div className='min-w-0 md:contents'>
<p className='text-sm font-medium'>{item.title}</p>
<p className='text-muted-foreground line-clamp-1 text-xs md:line-clamp-none'>
{item.description}
</p>
</div>
</button>
))}
{t('Change Password')}
</Button>
</div>
<div className='flex flex-col gap-3 py-4 first:pt-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between'>
<div className='min-w-0 space-y-0.5'>
<p className='text-sm font-medium'>{t('Access Token')}</p>
<p className='text-muted-foreground text-xs sm:text-sm'>
{t('Generate and manage your API access token')}
</p>
</div>
<Button
variant='outline'
className='shrink-0 self-start sm:self-auto'
onClick={() => dialogs.open('token')}
>
{t('Manage')}
</Button>
</div>
<TwoFARow loading={loading} />
<PasskeyRow loading={loading} />
</div>
</TitledCard>
{/* Dialogs */}
<Card
data-card-hover='false'
className='ring-destructive/30 gap-0 overflow-hidden py-0'
>
<CardContent className='flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:p-5'>
<div className='min-w-0 space-y-0.5'>
<p className='text-sm font-medium'>{t('Delete Account')}</p>
<p className='text-muted-foreground text-xs sm:text-sm'>
{t('Permanently delete your account and all data')}
</p>
</div>
<Button
variant='destructive'
className='shrink-0 self-start sm:self-auto'
onClick={() => dialogs.open('delete')}
>
{t('Delete Account')}
</Button>
</CardContent>
</Card>
<ChangePasswordDialog
open={dialogs.isOpen('password')}
onOpenChange={(open) =>
@@ -1,106 +0,0 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU 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 { Link2, Settings } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { TitledCard } from '@/components/ui/titled-card'
import type { UserProfile } from '../types'
import { AccountBindingsTab } from './tabs/account-bindings-tab'
import { NotificationTab } from './tabs/notification-tab'
// ============================================================================
// Profile Settings Card Component
// ============================================================================
interface ProfileSettingsCardProps {
profile: UserProfile | null
loading: boolean
onProfileUpdate: () => void
}
export function ProfileSettingsCard({
profile,
loading,
onProfileUpdate,
}: ProfileSettingsCardProps) {
const { t } = useTranslation()
const [activeTab, setActiveTab] = useState('bindings')
if (loading) {
return (
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
<CardHeader className='border-b p-3 !pb-3 sm:p-5 sm:!pb-5'>
<Skeleton className='h-6 w-32' />
<Skeleton className='mt-2 h-4 w-48' />
</CardHeader>
<CardContent className='space-y-4 p-3 sm:p-5'>
<Skeleton className='h-10 w-full' />
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className='h-20 w-full' />
))}
</CardContent>
</Card>
)
}
return (
<TitledCard
title={t('Settings')}
description={t('Configure your account preferences and integrations')}
icon={<Settings className='h-4 w-4' />}
disableHoverEffect
>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className='grid w-full grid-cols-2 items-stretch gap-1 rounded-xl p-1 group-data-horizontal/tabs:h-10'>
<TabsTrigger
value='bindings'
className='h-full gap-2 rounded-lg px-3 py-0 leading-none'
>
<Link2 className='h-4 w-4' />
<span className='hidden sm:inline'>{t('Account Bindings')}</span>
<span className='sm:hidden'>{t('Bindings')}</span>
</TabsTrigger>
<TabsTrigger
value='settings'
className='h-full gap-2 rounded-lg px-3 py-0 leading-none'
>
<Settings className='h-4 w-4' />
<span className='hidden sm:inline'>
{t('Settings & Preferences')}
</span>
<span className='sm:hidden'>{t('Settings')}</span>
</TabsTrigger>
</TabsList>
<TabsContent value='bindings' className='mt-4 sm:mt-6'>
<AccountBindingsTab profile={profile} onUpdate={onProfileUpdate} />
</TabsContent>
<TabsContent value='settings' className='mt-4 sm:mt-6'>
<NotificationTab profile={profile} onUpdate={onProfileUpdate} />
</TabsContent>
</Tabs>
</TitledCard>
)
}
@@ -16,21 +16,15 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { LayoutDashboard } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/design-system/button'
import { Switch } from '@/components/ui/switch'
import { TitledCard } from '@/components/ui/titled-card'
import { api } from '@/lib/api'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/stores/auth-store'
type SidebarModuleConfig = {
@@ -201,81 +195,65 @@ export function SidebarModulesCard() {
}
return (
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
<CardHeader className='border-b p-3 !pb-3 sm:p-5 sm:!pb-5'>
<div className='flex items-center gap-3'>
<div className='bg-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-lg sm:h-9 sm:w-9'>
<LayoutDashboard className='h-4 w-4' />
</div>
<div className='min-w-0'>
<CardTitle className='text-lg tracking-tight sm:text-xl'>
{t('Sidebar Personal Settings')}
</CardTitle>
<CardDescription className='text-xs sm:text-sm'>
{t('Customize sidebar display content')}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className='space-y-4 p-3 sm:space-y-5 sm:p-5'>
{sectionDefs.map((section) => {
const sectionEnabled = config[section.key]?.enabled !== false
return (
<div
key={section.key}
className='bg-background/60 rounded-xl border p-3'
>
<div className='flex items-start justify-between gap-3'>
<div className='min-w-0'>
<p className='text-sm font-medium'>{section.title}</p>
<p className='text-muted-foreground text-xs'>
{section.description}
</p>
</div>
<Switch
checked={sectionEnabled}
onCheckedChange={(v) => toggleSection(section.key, v)}
/>
</div>
<div className='mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-1'>
{section.modules.map((mod) => (
<div
key={mod.key}
className={`flex min-h-16 items-center justify-between rounded-lg border p-3 ${
sectionEnabled ? '' : 'opacity-50'
}`}
>
<div className='mr-2 min-w-0'>
<p className='truncate text-sm font-medium'>
{mod.title}
</p>
<p className='text-muted-foreground truncate text-xs'>
{mod.description}
</p>
</div>
<Switch
checked={config[section.key]?.[mod.key] !== false}
onCheckedChange={(v) =>
toggleModule(section.key, mod.key, v)
}
disabled={!sectionEnabled}
/>
</div>
))}
<TitledCard
title={t('Sidebar Personal Settings')}
description={t('Customize sidebar display content')}
disableHoverEffect
contentClassName='space-y-5 sm:space-y-6'
>
{sectionDefs.map((section) => {
const sectionEnabled = config[section.key]?.enabled !== false
return (
<div key={section.key} className='space-y-2.5'>
<div className='flex items-center justify-between gap-3'>
<div className='min-w-0'>
<p className='text-sm font-medium'>{section.title}</p>
<p className='text-muted-foreground text-xs'>
{section.description}
</p>
</div>
<Switch
checked={sectionEnabled}
onCheckedChange={(v) => toggleSection(section.key, v)}
/>
</div>
)
})}
<div className='divide-border/60 divide-y rounded-lg border'>
{section.modules.map((mod) => (
<div
key={mod.key}
className={cn(
'flex items-center justify-between gap-3 px-3 py-2.5',
!sectionEnabled && 'opacity-50'
)}
>
<div className='min-w-0'>
<p className='truncate text-sm'>{mod.title}</p>
<p className='text-muted-foreground truncate text-xs'>
{mod.description}
</p>
</div>
<Switch
checked={config[section.key]?.[mod.key] !== false}
onCheckedChange={(v) =>
toggleModule(section.key, mod.key, v)
}
disabled={!sectionEnabled}
/>
</div>
))}
</div>
</div>
)
})}
<div className='flex flex-col-reverse gap-2 border-t pt-4 sm:flex-row sm:justify-end'>
<Button variant='outline' onClick={handleReset}>
{t('Reset to Default')}
</Button>
<Button onClick={handleSave} disabled={loading}>
{loading ? t('Saving...') : t('Save Changes')}
</Button>
</div>
</CardContent>
</Card>
<div className='flex flex-col-reverse gap-2 border-t pt-4 sm:flex-row sm:justify-end'>
<Button variant='outline' onClick={handleReset}>
{t('Reset to Default')}
</Button>
<Button onClick={handleSave} disabled={loading}>
{loading ? t('Saving...') : t('Save Changes')}
</Button>
</div>
</TitledCard>
)
}
@@ -1,186 +0,0 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU 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 { Shield, AlertTriangle, RefreshCw } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { useDialogs } from '@/hooks/use-dialog'
import { useTwoFA } from '../hooks'
import { TwoFABackupDialog } from './dialogs/two-fa-backup-dialog'
import { TwoFADisableDialog } from './dialogs/two-fa-disable-dialog'
import { TwoFASetupDialog } from './dialogs/two-fa-setup-dialog'
// ============================================================================
// Two-Factor Authentication Card Component
// ============================================================================
interface TwoFACardProps {
loading: boolean
}
type DialogKey = 'setup' | 'disable' | 'backup'
export function TwoFACard({ loading: pageLoading }: TwoFACardProps) {
const { t } = useTranslation()
const { status, loading, refetch } = useTwoFA(!pageLoading)
const dialogs = useDialogs<DialogKey>()
if (pageLoading || loading) {
return (
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
<CardHeader className='p-3 sm:p-5'>
<Skeleton className='h-6 w-48' />
<Skeleton className='mt-2 h-4 w-64' />
</CardHeader>
<CardContent className='p-3 sm:p-5'>
<Skeleton className='h-20 w-full' />
</CardContent>
</Card>
)
}
return (
<>
<Card data-card-hover='false' className='gap-0 overflow-hidden py-0'>
<CardHeader className='p-3 sm:p-5'>
<CardTitle className='text-lg tracking-tight sm:text-xl'>
{t('Two-Factor Authentication')}
</CardTitle>
<CardDescription className='text-xs sm:text-sm'>
{t('Add an extra layer of security to your account')}
</CardDescription>
</CardHeader>
<CardContent className='p-3 sm:p-5'>
<div className='space-y-6'>
{/* Status Section */}
<div className='flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between xl:flex-col 2xl:flex-row'>
<div className='flex items-start gap-4'>
<div className='bg-muted rounded-md p-2'>
<Shield className='h-5 w-5' />
</div>
<div className='space-y-1'>
<div className='flex items-center gap-2'>
<p className='font-medium'>{t('Two-Step Verification')}</p>
{status.enabled ? (
<StatusBadge
label={t('Enabled')}
variant='success'
showDot
copyable={false}
/>
) : (
<StatusBadge
label={t('Disabled')}
variant='neutral'
showDot
copyable={false}
/>
)}
{status.locked && (
<StatusBadge
label={t('Locked')}
variant='danger'
showDot
copyable={false}
/>
)}
</div>
<p className='text-muted-foreground text-sm'>
{status.enabled
? t('Backup codes remaining: {{count}}', {
count: status.backup_codes_remaining,
})
: t('Add an extra layer of security to your account')}
</p>
</div>
</div>
{!status.enabled && (
<Button
className='w-full sm:w-auto xl:w-full 2xl:w-auto'
onClick={() => dialogs.open('setup')}
>
{t('Enable')}
</Button>
)}
</div>
{/* Actions Section - Only show when enabled */}
{status.enabled && (
<div className='flex flex-col gap-3 border-t pt-6 sm:flex-row xl:flex-col 2xl:flex-row'>
<Button
variant='outline'
className='flex-1'
onClick={() => dialogs.open('backup')}
>
<RefreshCw className='mr-2 h-4 w-4' />
{t('Regenerate Backup Codes')}
</Button>
<Button
variant='destructive'
className='flex-1'
onClick={() => dialogs.open('disable')}
>
<AlertTriangle className='mr-2 h-4 w-4' />
{t('Disable 2FA')}
</Button>
</div>
)}
</div>
</CardContent>
</Card>
{/* Dialogs */}
<TwoFASetupDialog
open={dialogs.isOpen('setup')}
onOpenChange={(open) =>
open ? dialogs.open('setup') : dialogs.close('setup')
}
onSuccess={refetch}
/>
<TwoFADisableDialog
open={dialogs.isOpen('disable')}
onOpenChange={(open) =>
open ? dialogs.open('disable') : dialogs.close('disable')
}
onSuccess={refetch}
/>
<TwoFABackupDialog
open={dialogs.isOpen('backup')}
onOpenChange={(open) =>
open ? dialogs.open('backup') : dialogs.close('backup')
}
onSuccess={refetch}
/>
</>
)
}
@@ -0,0 +1,122 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/design-system/button'
import { StatusBadge } from '@/components/status-badge'
import { Skeleton } from '@/components/ui/skeleton'
import { useDialogs } from '@/hooks/use-dialog'
import { useTwoFA } from '../hooks'
import { TwoFABackupDialog } from './dialogs/two-fa-backup-dialog'
import { TwoFADisableDialog } from './dialogs/two-fa-disable-dialog'
import { TwoFASetupDialog } from './dialogs/two-fa-setup-dialog'
interface TwoFARowProps {
loading: boolean
}
type DialogKey = 'setup' | 'disable' | 'backup'
export function TwoFARow({ loading: pageLoading }: TwoFARowProps) {
const { t } = useTranslation()
const { status, loading, refetch } = useTwoFA(!pageLoading)
const dialogs = useDialogs<DialogKey>()
if (pageLoading || loading) {
return (
<div className='space-y-2 py-4 first:pt-0 last:pb-0'>
<Skeleton className='h-5 w-56' />
<Skeleton className='h-4 w-72' />
</div>
)
}
return (
<>
<div className='flex flex-col gap-3 py-4 first:pt-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between'>
<div className='min-w-0 space-y-0.5'>
<div className='flex flex-wrap items-center gap-2'>
<p className='text-sm font-medium'>
{t('Two-Factor Authentication')}
</p>
<StatusBadge variant={status.enabled ? 'success' : 'neutral'}>
{status.enabled ? t('Enabled') : t('Disabled')}
</StatusBadge>
{status.locked && (
<StatusBadge variant='destructive'>{t('Locked')}</StatusBadge>
)}
</div>
<p className='text-muted-foreground text-xs sm:text-sm'>
{status.enabled
? t('Backup codes remaining: {{count}}', {
count: status.backup_codes_remaining,
})
: t('Add an extra layer of security to your account')}
</p>
</div>
<div className='flex shrink-0 flex-wrap items-center gap-2'>
{status.enabled ? (
<>
<Button variant='outline' onClick={() => dialogs.open('backup')}>
{t('Regenerate Backup Codes')}
</Button>
<Button
variant='outline'
className='text-destructive hover:text-destructive'
onClick={() => dialogs.open('disable')}
>
{t('Disable 2FA')}
</Button>
</>
) : (
<Button variant='outline' onClick={() => dialogs.open('setup')}>
{t('Enable')}
</Button>
)}
</div>
</div>
<TwoFASetupDialog
open={dialogs.isOpen('setup')}
onOpenChange={(open) =>
open ? dialogs.open('setup') : dialogs.close('setup')
}
onSuccess={refetch}
/>
<TwoFADisableDialog
open={dialogs.isOpen('disable')}
onOpenChange={(open) =>
open ? dialogs.open('disable') : dialogs.close('disable')
}
onSuccess={refetch}
/>
<TwoFABackupDialog
open={dialogs.isOpen('backup')}
onOpenChange={(open) =>
open ? dialogs.open('backup') : dialogs.close('backup')
}
onSuccess={refetch}
/>
</>
)
}
+43 -45
View File
@@ -16,25 +16,23 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Main } from '@/components/layout'
import {
CardStaggerContainer,
CardStaggerItem,
} from '@/components/page-transition'
import { useTranslation } from 'react-i18next'
import { SectionPageLayout } from '@/components/layout'
import { useStatus } from '@/hooks/use-status'
import { useAuthStore } from '@/stores/auth-store'
import { CheckinCalendarCard } from './components/checkin-calendar-card'
import { ConnectedAccountsCard } from './components/connected-accounts-card'
import { LanguagePreferencesCard } from './components/language-preferences-card'
import { PasskeyCard } from './components/passkey-card'
import { NotificationSettingsCard } from './components/notification-settings-card'
import { ProfileHeader } from './components/profile-header'
import { ProfileSecurityCard } from './components/profile-security-card'
import { ProfileSettingsCard } from './components/profile-settings-card'
import { SidebarModulesCard } from './components/sidebar-modules-card'
import { TwoFACard } from './components/two-fa-card'
import { useProfile } from './hooks'
export function Profile() {
const { t } = useTranslation()
const { profile, loading, refreshProfile } = useProfile()
const { status } = useStatus()
const permissions = useAuthStore((s) => s.auth.user?.permissions)
@@ -47,44 +45,44 @@ export function Profile() {
const canConfigureSidebar = permissions?.sidebar_settings !== false
return (
<Main>
<div className='min-h-0 flex-1 overflow-auto px-3 py-3 sm:px-4 sm:py-6'>
<CardStaggerContainer className='mx-auto flex w-full max-w-7xl flex-col gap-4 sm:gap-6'>
<CardStaggerItem>
<ProfileHeader profile={profile} loading={loading} />
</CardStaggerItem>
<SectionPageLayout>
<SectionPageLayout.Title>{t('Profile')}</SectionPageLayout.Title>
<SectionPageLayout.Content>
<div className='mx-auto flex w-full max-w-7xl flex-col gap-4 sm:gap-5'>
<ProfileHeader profile={profile} loading={loading} />
<CardStaggerItem>
<div className='grid gap-4 sm:gap-5 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.46fr)] xl:items-start'>
<div className='space-y-4 sm:space-y-6'>
<ProfileSettingsCard
profile={profile}
loading={loading}
onProfileUpdate={refreshProfile}
/>
<LanguagePreferencesCard
profile={profile}
onProfileUpdate={refreshProfile}
/>
<ProfileSecurityCard profile={profile} loading={loading} />
</div>
<div className='space-y-4 sm:space-y-6 xl:sticky xl:top-6'>
{checkinEnabled && (
<CheckinCalendarCard
checkinEnabled={checkinEnabled}
turnstileEnabled={turnstileEnabled}
turnstileSiteKey={turnstileSiteKey}
/>
)}
{canConfigureSidebar && <SidebarModulesCard />}
<PasskeyCard loading={loading} />
<TwoFACard loading={loading} />
</div>
<div className='grid gap-4 sm:gap-5 xl:grid-cols-3 xl:items-start'>
<div className='flex flex-col gap-4 sm:gap-5 xl:col-span-2'>
<ConnectedAccountsCard
profile={profile}
loading={loading}
onUpdate={refreshProfile}
/>
<ProfileSecurityCard profile={profile} loading={loading} />
<NotificationSettingsCard
profile={profile}
loading={loading}
onUpdate={refreshProfile}
/>
</div>
</CardStaggerItem>
</CardStaggerContainer>
</div>
</Main>
<div className='flex flex-col gap-4 sm:gap-5'>
{checkinEnabled && (
<CheckinCalendarCard
checkinEnabled={checkinEnabled}
turnstileEnabled={turnstileEnabled}
turnstileSiteKey={turnstileSiteKey}
/>
)}
<LanguagePreferencesCard
profile={profile}
onProfileUpdate={refreshProfile}
/>
{canConfigureSidebar && <SidebarModulesCard />}
</div>
</div>
</div>
</SectionPageLayout.Content>
</SectionPageLayout>
)
}