♻️ refactor(web): consolidate design-system primitives and responsive data views
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
|
||||
+125
-125
@@ -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')}
|
||||
+2
-3
@@ -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')}
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
+3
-3
@@ -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>
|
||||
|
||||
+23
-25
@@ -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>
|
||||
|
||||
+3
-3
@@ -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)
|
||||
|
||||
+11
-9
@@ -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
-23
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+105
-81
@@ -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>
|
||||
)
|
||||
}
|
||||
+88
-143
@@ -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}
|
||||
+29
-60
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user