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

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

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

* fix(proxy): preserve trusted proxy compatibility defaults

* refactor: address dashboard auth review feedback

* refactor: remove classic frontend and flatten web app
This commit is contained in:
Calcium-Ion
2026-07-20 16:48:43 +08:00
committed by GitHub
parent 5a6c53d496
commit 31d70fca39
1605 changed files with 17511 additions and 147913 deletions
+220
View File
@@ -0,0 +1,220 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { PermissionCatalog } from '@/lib/admin-permissions'
import { api } from '@/lib/api'
import type {
User,
GetUsersParams,
GetUsersResponse,
SearchUsersParams,
UserFormData,
ManageUserAction,
ManageUserQuotaPayload,
ApiResponse,
} from './types'
// ============================================================================
// User Management APIs
// ============================================================================
/**
* Get paginated users list
*/
export async function getUsers(
params: GetUsersParams = {}
): Promise<GetUsersResponse> {
const { p = 1, page_size = 10, sort_by, sort_order } = params
const res = await api.get('/api/user/', {
params: {
p,
page_size,
sort_by,
sort_order,
},
})
return res.data
}
/**
* Search users by keyword or group
*/
export async function searchUsers(
params: SearchUsersParams
): Promise<GetUsersResponse> {
const {
keyword = '',
group = '',
role = '',
status = '',
p = 1,
page_size = 10,
sort_by,
sort_order,
} = params
const queryParams = new URLSearchParams()
queryParams.set('keyword', keyword)
queryParams.set('group', group)
if (role) queryParams.set('role', role)
if (status) queryParams.set('status', status)
queryParams.set('p', String(p))
queryParams.set('page_size', String(page_size))
if (sort_by) queryParams.set('sort_by', sort_by)
if (sort_order) queryParams.set('sort_order', sort_order)
const res = await api.get(`/api/user/search?${queryParams.toString()}`)
return res.data
}
/**
* Get single user by ID
*/
export async function getUser(id: number): Promise<ApiResponse<User>> {
const res = await api.get(`/api/user/${id}`)
return res.data
}
/**
* Create a new user
*/
export async function createUser(
data: UserFormData
): Promise<ApiResponse<User>> {
const res = await api.post('/api/user/', data)
return res.data
}
/**
* Update an existing user
*/
export async function updateUser(
data: UserFormData & { id: number }
): Promise<ApiResponse<Partial<User>>> {
const res = await api.put('/api/user/', data)
return res.data
}
/**
* Delete a single user (hard delete)
*/
export async function deleteUser(id: number): Promise<ApiResponse> {
const res = await api.delete(`/api/user/${id}/`)
return res.data
}
/**
* Manage user (promote, demote, enable, disable, delete)
*/
export async function manageUser(
id: number,
action: ManageUserAction
): Promise<ApiResponse<Partial<User>>> {
const res = await api.post('/api/user/manage', { id, action })
return res.data
}
/**
* Adjust user quota atomically (add/subtract/override)
*/
export async function adjustUserQuota(
payload: ManageUserQuotaPayload
): Promise<ApiResponse<Partial<User>>> {
const res = await api.post('/api/user/manage', payload)
return res.data
}
/**
* Reset user's Passkey registration
*/
export async function resetUserPasskey(id: number): Promise<ApiResponse> {
const res = await api.delete(`/api/user/${id}/reset_passkey`)
return res.data
}
/**
* Reset user's Two-Factor Authentication setup
*/
export async function resetUserTwoFA(id: number): Promise<ApiResponse> {
const res = await api.delete(`/api/user/${id}/2fa`)
return res.data
}
/**
* Get all available groups
*/
export async function getGroups(): Promise<ApiResponse<string[]>> {
const res = await api.get('/api/group/')
return res.data
}
/**
* Get the permission catalog (resources, actions, and role baselines).
* Source of truth lives in the backend authz package.
*/
export async function getPermissionCatalog(): Promise<PermissionCatalog> {
const res = await api.get('/api/authz/catalog')
return {
resources: res.data?.data?.resources ?? [],
roles: res.data?.data?.roles ?? [],
}
}
// ============================================================================
// Admin Binding Management APIs
// ============================================================================
export interface OAuthBinding {
provider_id: string
provider_name: string
user_id?: number
external_id?: string
}
/**
* Get user's custom OAuth bindings (admin)
*/
export async function getUserOAuthBindings(
userId: number
): Promise<ApiResponse<OAuthBinding[]>> {
const res = await api.get(`/api/user/${userId}/oauth/bindings`)
return res.data
}
/**
* Clear a user's built-in binding (admin)
*/
export async function adminClearUserBinding(
userId: number,
bindingType: string
): Promise<ApiResponse> {
const res = await api.delete(`/api/user/${userId}/bindings/${bindingType}`)
return res.data
}
/**
* Unbind custom OAuth for a user (admin)
*/
export async function adminUnbindCustomOAuth(
userId: number,
providerId: string
): Promise<ApiResponse> {
const res = await api.delete(
`/api/user/${userId}/oauth/bindings/${providerId}`
)
return res.data
}
@@ -0,0 +1,35 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { type Table } from '@tanstack/react-table'
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
import { type User } from '../types'
interface DataTableBulkActionsProps {
table: Table<User>
}
export function DataTableBulkActions({ table }: DataTableBulkActionsProps) {
return (
<BulkActionsToolbar table={table} entityName='user'>
<></>
</BulkActionsToolbar>
)
}
@@ -0,0 +1,306 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { Row } from '@tanstack/react-table'
import {
Pencil,
Trash2,
Power,
PowerOff,
ArrowUp,
ArrowDown,
KeyRound,
ShieldAlert,
Link2,
CreditCard,
} from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { DataTableRowActionMenu } from '@/components/data-table/core/row-action-menu'
import { Button } from '@/components/ui/button'
import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
} from '@/components/ui/dropdown-menu'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { UserSubscriptionsDialog } from '@/features/subscriptions/components/dialogs/user-subscriptions-dialog'
import { manageUser, resetUserPasskey, resetUserTwoFA } from '../api'
import {
USER_STATUS,
USER_ROLE,
ERROR_MESSAGES,
isUserDeleted,
} from '../constants'
import { getUserActionMessage } from '../lib'
import type { User, ManageUserAction } from '../types'
import { UserBindingDialog } from './dialogs/user-binding-dialog'
import { useUsers } from './users-provider'
interface DataTableRowActionsProps {
row: Row<User>
}
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const { t } = useTranslation()
const user = row.original
const { setOpen, setCurrentRow, triggerRefresh } = useUsers()
const [resetPasskeyOpen, setResetPasskeyOpen] = useState(false)
const [resetTwoFAOpen, setResetTwoFAOpen] = useState(false)
const [bindingDialogOpen, setBindingDialogOpen] = useState(false)
const [subscriptionsDialogOpen, setSubscriptionsDialogOpen] = useState(false)
const handleEdit = () => {
setCurrentRow(user)
setOpen('update')
}
const handleDelete = () => {
setCurrentRow(user)
setOpen('delete')
}
const handleManage = async (action: Exclude<ManageUserAction, 'delete'>) => {
try {
const result = await manageUser(user.id, action)
if (result.success) {
toast.success(t(getUserActionMessage(action)))
triggerRefresh()
} else {
toast.error(
result.message || t('Failed to {{action}} user', { action })
)
}
} catch {
toast.error(t(ERROR_MESSAGES.UNEXPECTED))
}
}
const handleResetPasskey = async () => {
try {
const result = await resetUserPasskey(user.id)
if (result.success) {
toast.success(t('Passkey reset successfully'))
triggerRefresh()
} else {
toast.error(result.message || t('Failed to reset Passkey'))
}
} catch {
toast.error(t(ERROR_MESSAGES.UNEXPECTED))
} finally {
setResetPasskeyOpen(false)
}
}
const handleResetTwoFA = async () => {
try {
const result = await resetUserTwoFA(user.id)
if (result.success) {
toast.success(t('Two-factor authentication reset'))
triggerRefresh()
} else {
toast.error(result.message || t('Failed to reset 2FA'))
}
} catch {
toast.error(t(ERROR_MESSAGES.UNEXPECTED))
} finally {
setResetTwoFAOpen(false)
}
}
const isDisabled = user.status === USER_STATUS.DISABLED
const isAdmin = user.role >= USER_ROLE.ADMIN
const isRoot = user.role === USER_ROLE.ROOT
if (isUserDeleted(user)) {
return null
}
return (
<div className='-ml-1.5 flex items-center gap-1'>
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
onClick={handleEdit}
aria-label={t('Edit')}
/>
}
>
<Pencil />
</TooltipTrigger>
<TooltipContent>{t('Edit')}</TooltipContent>
</Tooltip>
<DataTableRowActionMenu
ariaLabel={t('Open menu')}
contentClassName='w-48'
>
{isDisabled ? (
<DropdownMenuItem onClick={() => handleManage('enable')}>
{t('Enable')}
<DropdownMenuShortcut>
<Power size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() => handleManage('disable')}
disabled={isRoot}
>
{t('Disable')}
<DropdownMenuShortcut>
<PowerOff size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
)}
{isAdmin && !isRoot && (
<DropdownMenuItem onClick={() => handleManage('demote')}>
{t('Demote')}
<DropdownMenuShortcut>
<ArrowDown size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
)}
{!isAdmin && (
<DropdownMenuItem onClick={() => handleManage('promote')}>
{t('Promote')}
<DropdownMenuShortcut>
<ArrowUp size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
)}
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault()
setBindingDialogOpen(true)
}}
>
{t('Manage Bindings')}
<DropdownMenuShortcut>
<Link2 size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault()
setSubscriptionsDialogOpen(true)
}}
>
{t('Manage Subscriptions')}
<DropdownMenuShortcut>
<CreditCard size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault()
setResetPasskeyOpen(true)
}}
disabled={isRoot}
>
{t('Reset Passkey')}
<DropdownMenuShortcut>
<KeyRound size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault()
setResetTwoFAOpen(true)
}}
disabled={isRoot}
>
{t('Reset 2FA')}
<DropdownMenuShortcut>
<ShieldAlert size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleDelete}
className='text-destructive focus:text-destructive'
disabled={isRoot}
>
{t('Delete')}
<DropdownMenuShortcut>
<Trash2 size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DataTableRowActionMenu>
<ConfirmDialog
open={resetPasskeyOpen}
onOpenChange={setResetPasskeyOpen}
title={t('Reset Passkey')}
desc={t(
'Reset Passkey for {{username}}? The user will need to register a new Passkey before using passwordless login.',
{ username: user.username }
)}
confirmText={t('Reset Passkey')}
handleConfirm={handleResetPasskey}
/>
<ConfirmDialog
open={resetTwoFAOpen}
onOpenChange={setResetTwoFAOpen}
title={t('Reset Two-Factor Authentication')}
desc={t(
'Reset 2FA for {{username}}? The user must set up 2FA again to continue using it.',
{ username: user.username }
)}
confirmText={t('Reset 2FA')}
handleConfirm={handleResetTwoFA}
/>
<UserBindingDialog
open={bindingDialogOpen}
onOpenChange={setBindingDialogOpen}
userId={user.id}
onUnbindSuccess={triggerRefresh}
/>
<UserSubscriptionsDialog
open={subscriptionsDialogOpen}
onOpenChange={setSubscriptionsDialogOpen}
user={{ id: user.id, username: user.username }}
onSuccess={triggerRefresh}
/>
</div>
)
}
@@ -0,0 +1,454 @@
/*
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 {
Mail,
Globe,
MessageCircle,
Send,
Link2,
Unlink,
Loader2,
Eye,
EyeOff,
} from 'lucide-react'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { SiGithub, SiDiscord } from 'react-icons/si'
import { toast } from 'sonner'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { api } from '@/lib/api'
import {
getUser,
getUserOAuthBindings,
adminClearUserBinding,
adminUnbindCustomOAuth,
type OAuthBinding,
} from '../../api'
import type { User } from '../../types'
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
userId: number | null
onUnbindSuccess?: () => void
}
interface BindingItem {
key: string
label: string
icon: React.ReactNode
value: string
type: 'builtin' | 'custom'
providerId?: string
isBound: boolean
isEnabled: boolean
}
interface StatusInfo {
github_oauth?: boolean
discord_oauth?: boolean
oidc_enabled?: boolean
wechat_login?: boolean
telegram_oauth?: boolean
linuxdo_oauth?: boolean
custom_oauth_providers?: Array<{
id: string
name: string
icon?: string
}>
}
const BUILTIN_BINDINGS: ReadonlyArray<{
key: string
field: string
label: string
icon: React.ReactNode
statusKey: keyof StatusInfo | null
}> = [
{
key: 'email',
field: 'email',
label: 'Email',
icon: <Mail className='h-4 w-4' />,
statusKey: null,
},
{
key: 'github_id',
field: 'github_id',
label: 'GitHub',
icon: <SiGithub className='h-4 w-4' />,
statusKey: 'github_oauth',
},
{
key: 'discord_id',
field: 'discord_id',
label: 'Discord',
icon: <SiDiscord className='h-4 w-4' />,
statusKey: 'discord_oauth',
},
{
key: 'wechat_id',
field: 'wechat_id',
label: 'WeChat',
icon: <MessageCircle className='h-4 w-4' />,
statusKey: 'wechat_login',
},
{
key: 'oidc_id',
field: 'oidc_id',
label: 'OIDC',
icon: <Globe className='h-4 w-4' />,
statusKey: 'oidc_enabled',
},
{
key: 'telegram_id',
field: 'telegram_id',
label: 'Telegram',
icon: <Send className='h-4 w-4' />,
statusKey: 'telegram_oauth',
},
{
key: 'linux_do_id',
field: 'linux_do_id',
label: 'LinuxDO',
icon: <Globe className='h-4 w-4' />,
statusKey: 'linuxdo_oauth',
},
]
function CustomProviderIcon(props: { iconUrl?: string }) {
if (!props.iconUrl) return <Link2 className='h-4 w-4' />
return (
<img
src={props.iconUrl}
alt=''
className='h-4 w-4 rounded-sm object-contain'
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
)
}
export function UserBindingDialog(props: Props) {
const { t } = useTranslation()
const [user, setUser] = useState<User | null>(null)
const [oauthBindings, setOauthBindings] = useState<OAuthBinding[]>([])
const [statusInfo, setStatusInfo] = useState<StatusInfo>({})
const [loading, setLoading] = useState(false)
const [showBoundOnly, setShowBoundOnly] = useState(true)
const [unbindTarget, setUnbindTarget] = useState<BindingItem | null>(null)
const [unbinding, setUnbinding] = useState(false)
const fetchData = useCallback(async () => {
if (!props.userId) return
setLoading(true)
try {
const [userRes, oauthRes, statusRes] = await Promise.all([
getUser(props.userId),
getUserOAuthBindings(props.userId).catch(() => ({
success: false,
data: [],
})),
api
.get('/api/status')
.then((r) => r.data)
.catch(() => ({
success: false,
data: {},
})),
])
if (userRes.success && userRes.data) {
setUser(userRes.data)
}
if (oauthRes.success && oauthRes.data) {
setOauthBindings(oauthRes.data as OAuthBinding[])
}
if (statusRes.success && statusRes.data) {
setStatusInfo(statusRes.data as StatusInfo)
}
} catch {
toast.error(t('Failed to load'))
} finally {
setLoading(false)
}
}, [props.userId, t])
useEffect(() => {
if (props.open && props.userId) {
setShowBoundOnly(true)
fetchData()
} else {
setUser(null)
setOauthBindings([])
setStatusInfo({})
}
}, [props.open, props.userId, fetchData])
const allBindings = useMemo<BindingItem[]>(() => {
const items: BindingItem[] = []
for (const field of BUILTIN_BINDINGS) {
const value = user
? String((user as Record<string, unknown>)[field.field] || '')
: ''
const isBound = !!value
const isEnabled =
field.statusKey == null ? true : Boolean(statusInfo[field.statusKey])
items.push({
key: field.key,
label: field.label,
icon: field.icon,
value: isBound ? value : '',
type: 'builtin',
isBound,
isEnabled,
})
}
const oauthBindingMap = new Map(
oauthBindings.map((b) => [String(b.provider_id), b])
)
const customProviders = statusInfo.custom_oauth_providers || []
const seenProviderIds = new Set<string>()
for (const provider of customProviders) {
seenProviderIds.add(String(provider.id))
const binding = oauthBindingMap.get(String(provider.id))
items.push({
key: `oauth_${provider.id}`,
label: provider.name || provider.id,
icon: <CustomProviderIcon iconUrl={provider.icon} />,
value: binding?.external_id || '',
type: 'custom',
providerId: String(provider.id),
isBound: !!binding,
isEnabled: true,
})
}
for (const binding of oauthBindings) {
if (!seenProviderIds.has(String(binding.provider_id))) {
items.push({
key: `oauth_${binding.provider_id}`,
label: binding.provider_name || binding.provider_id,
icon: <Link2 className='h-4 w-4' />,
value: binding.external_id || '-',
type: 'custom',
providerId: String(binding.provider_id),
isBound: true,
isEnabled: false,
})
}
}
return items
}, [user, oauthBindings, statusInfo])
const displayedBindings = showBoundOnly
? allBindings.filter((b) => b.isBound)
: allBindings
const boundCount = allBindings.filter((b) => b.isBound).length
const handleUnbind = async () => {
if (!unbindTarget || !props.userId) return
setUnbinding(true)
try {
let res
if (unbindTarget.type === 'builtin') {
res = await adminClearUserBinding(props.userId, unbindTarget.key)
} else if (unbindTarget.providerId) {
res = await adminUnbindCustomOAuth(
props.userId,
unbindTarget.providerId
)
}
if (res?.success) {
toast.success(
t('Unbound {{provider}}', { provider: unbindTarget.label })
)
await fetchData()
props.onUnbindSuccess?.()
} else {
toast.error(res?.message || t('Unbind failed'))
}
} catch {
toast.error(t('Unbind failed'))
} finally {
setUnbinding(false)
setUnbindTarget(null)
}
}
return (
<>
<Dialog
open={props.open}
onOpenChange={props.onOpenChange}
title={
<>
<Link2 className='h-5 w-5' />
{t('Account Binding Management')}
</>
}
description={t('Manage account bindings for this user')}
contentClassName='sm:max-w-lg'
titleClassName='flex items-center gap-2'
descriptionClassName='sr-only'
contentHeight='auto'
bodyClassName='space-y-4'
>
{loading ? (
<div className='flex items-center justify-center py-8'>
<Loader2 className='text-muted-foreground h-6 w-6 animate-spin' />
</div>
) : (
<div className='space-y-3'>
<div className='flex items-center justify-between'>
{user && (
<p className='text-muted-foreground text-sm'>
{user.username} (ID: {user.id})
</p>
)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='sm'
className='h-7 gap-1.5 px-2 text-xs'
onClick={() => setShowBoundOnly((v) => !v)}
/>
}
>
{showBoundOnly ? (
<Eye className='h-3.5 w-3.5' />
) : (
<EyeOff className='h-3.5 w-3.5' />
)}
{showBoundOnly ? t('Show All') : t('Bound Only')}
</TooltipTrigger>
<TooltipContent>
{showBoundOnly
? t('Show all providers including unbound')
: t('Show only bound providers')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<Separator />
<ScrollArea className='max-h-[50vh]'>
{displayedBindings.length === 0 ? (
<p className='text-muted-foreground py-4 text-center text-sm'>
{showBoundOnly
? t('This user has no bindings')
: t('No providers available')}
</p>
) : (
<div className='grid grid-cols-1 gap-2 pr-3 lg:grid-cols-2'>
{displayedBindings.map((binding) => (
<div
key={binding.key}
className={`flex items-center justify-between rounded-md border px-3 py-2.5 ${
!binding.isBound ? 'opacity-50' : ''
}`}
>
<div className='flex min-w-0 items-center gap-2.5'>
<div className='text-muted-foreground shrink-0'>
{binding.icon}
</div>
<div className='min-w-0'>
<div className='flex items-center gap-1.5'>
<span className='text-sm font-medium'>
{binding.label}
</span>
{!binding.isEnabled && (
<StatusBadge
variant='neutral'
label={t('Disabled')}
copyable={false}
size='sm'
/>
)}
</div>
<p className='text-muted-foreground max-w-[140px] truncate text-xs'>
{binding.isBound ? binding.value : t('Not bound')}
</p>
</div>
</div>
{binding.isBound && (
<Button
variant='ghost'
size='sm'
className='text-destructive hover:text-destructive h-7 w-7 shrink-0 p-0'
onClick={() => setUnbindTarget(binding)}
>
<Unlink className='h-3.5 w-3.5' />
</Button>
)}
</div>
))}
</div>
)}
</ScrollArea>
<p className='text-muted-foreground text-xs'>
{t('Bound')}: {boundCount} / {allBindings.length}
</p>
</div>
)}
</Dialog>
<ConfirmDialog
open={!!unbindTarget}
onOpenChange={(open) => !open && setUnbindTarget(null)}
title={t('Confirm Unbind')}
desc={t(
'Are you sure you want to unbind {{provider}} for this user? The user will no longer be able to log in via this method.',
{
provider: unbindTarget?.label || '',
}
)}
confirmText={t('Confirm Unbind')}
destructive
handleConfirm={handleUnbind}
isLoading={unbinding}
/>
</>
)
}
+98
View File
@@ -0,0 +1,98 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
import { StatusBadge } from '@/components/status-badge'
import { Progress } from '@/components/ui/progress'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { formatQuota } from '@/lib/format'
import { cn } from '@/lib/utils'
type UserQuotaCellProps = {
used: number
remaining: number
}
function getQuotaProgressColor(percentage: number): string {
if (percentage <= 10) return '[&_[data-slot=progress-indicator]]:bg-rose-500'
if (percentage <= 30) return '[&_[data-slot=progress-indicator]]:bg-amber-500'
return '[&_[data-slot=progress-indicator]]:bg-emerald-500'
}
export function UserQuotaCell(props: UserQuotaCellProps) {
const { t } = useTranslation()
const total = props.used + props.remaining
const percentage = total > 0 ? (props.remaining / total) * 100 : 0
const formattedRemaining = formatQuota(props.remaining)
const formattedTotal = formatQuota(total)
if (total === 0) {
return (
<StatusBadge
label={t('No Quota')}
variant='neutral'
copyable={false}
className='-ml-1.5'
/>
)
}
return (
<Tooltip>
<TooltipTrigger
render={
<div className='w-full min-w-0 cursor-help space-y-1.5 overflow-hidden' />
}
>
<div className='grid min-w-0 grid-cols-2 gap-x-4 text-xs'>
<span className='min-w-0 truncate font-medium tabular-nums'>
{formattedRemaining}
</span>
<span className='text-muted-foreground min-w-0 truncate text-right tabular-nums'>
{formattedTotal}
</span>
</div>
<Progress
value={percentage}
className={cn('h-1.5', getQuotaProgressColor(percentage))}
/>
</TooltipTrigger>
<TooltipContent>
<div className='space-y-1 text-xs'>
<div>
{t('Used:')} {formatQuota(props.used)}
</div>
<div>
{t('Remaining:')} {formattedRemaining}
</div>
<div>
{t('Total:')} {formattedTotal}
</div>
<div>
{t('Percentage:')} {percentage.toFixed(1)}%
</div>
</div>
</TooltipContent>
</Tooltip>
)
}
+181
View File
@@ -0,0 +1,181 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency'
import { formatQuota, parseQuotaFromDollars } from '@/lib/format'
import { cn } from '@/lib/utils'
import { adjustUserQuota } from '../api'
import type { QuotaAdjustMode } from '../types'
interface UserQuotaDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
userId: number
currentQuota: number
onSuccess: () => void
}
export function UserQuotaDialog(props: UserQuotaDialogProps) {
const { t } = useTranslation()
const [mode, setMode] = useState<QuotaAdjustMode>('add')
const [amount, setAmount] = useState('')
const [loading, setLoading] = useState(false)
const { meta: currencyMeta } = getCurrencyDisplay()
const currencyLabel = getCurrencyLabel()
const tokensOnly = currencyMeta.kind === 'tokens'
const amountValue = parseFloat(amount) || 0
const quotaValue = parseQuotaFromDollars(Math.abs(amountValue))
const getPreviewText = () => {
const current = props.currentQuota
const val = quotaValue
switch (mode) {
case 'add':
return `${t('Current quota')}: ${formatQuota(current)} +${formatQuota(val)} = ${formatQuota(current + val)}`
case 'subtract':
return `${t('Current quota')}: ${formatQuota(current)} -${formatQuota(val)} = ${formatQuota(current - val)}`
case 'override': {
const overrideQuota = parseQuotaFromDollars(amountValue)
return `${t('Current quota')}: ${formatQuota(current)}${formatQuota(overrideQuota)}`
}
default:
return ''
}
}
const handleConfirm = async () => {
if (!amount && mode !== 'override') return
if (quotaValue <= 0 && mode !== 'override') return
setLoading(true)
try {
const value =
mode === 'override' ? parseQuotaFromDollars(amountValue) : quotaValue
const result = await adjustUserQuota({
id: props.userId,
action: 'add_quota',
mode,
value: mode === 'override' ? value : Math.abs(value),
})
if (result.success) {
toast.success(t('Quota adjusted successfully'))
setAmount('')
setMode('add')
props.onOpenChange(false)
props.onSuccess()
} else {
toast.error(result.message || t('Failed to adjust quota'))
}
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : t('Failed to adjust quota'))
} finally {
setLoading(false)
}
}
const handleCancel = () => {
setAmount('')
setMode('add')
props.onOpenChange(false)
}
const placeholder = tokensOnly
? t('Enter amount in tokens')
: t('Enter amount in {{currency}}', { currency: currencyLabel })
return (
<Dialog
open={props.open}
onOpenChange={props.onOpenChange}
title={t('Adjust Quota')}
description={t('Select an operation mode and enter the amount')}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={handleCancel}>
{t('Cancel')}
</Button>
<Button onClick={handleConfirm} disabled={loading}>
{loading ? t('Processing...') : t('Confirm')}
</Button>
</>
}
>
<div className='space-y-4'>
<div className='text-muted-foreground text-sm'>{getPreviewText()}</div>
<div className='space-y-2'>
<Label>{t('Mode')}</Label>
<div className='flex gap-1'>
{(['add', 'subtract', 'override'] as const).map((m) => (
<Button
key={m}
type='button'
variant='outline'
size='sm'
className={cn(
mode === m &&
'bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground'
)}
onClick={() => {
setMode(m)
setAmount('')
}}
>
{m === 'add'
? t('Add')
: m === 'subtract'
? t('Subtract')
: t('Override')}
</Button>
))}
</div>
</div>
<div className='space-y-2'>
<Label>
{t('Amount')} ({currencyLabel})
</Label>
<Input
type='number'
step={tokensOnly ? 1 : 0.000001}
min={mode === 'override' ? undefined : 0}
placeholder={placeholder}
value={amount}
onChange={(e) => setAmount(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleConfirm()
}}
/>
</div>
</div>
</Dialog>
)
}
+330
View File
@@ -0,0 +1,330 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { ColumnDef } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next'
import { BadgeCell } from '@/components/data-table'
import { GroupBadge } from '@/components/group-badge'
import { LongText } from '@/components/long-text'
import { StatusBadge } from '@/components/status-badge'
import { TableId } from '@/components/table-id'
import { Checkbox } from '@/components/ui/checkbox'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { formatQuota, formatTimestamp } from '@/lib/format'
import {
USER_STATUS,
USER_STATUSES,
USER_ROLES,
isUserDeleted,
} from '../constants'
import type { User } from '../types'
import { DataTableRowActions } from './data-table-row-actions'
import { UserQuotaCell } from './user-quota-cell'
export function useUsersColumns(): ColumnDef<User>[] {
const { t } = useTranslation()
return [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label='Select all'
className='translate-y-[2px]'
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label='Select row'
className='translate-y-[2px]'
/>
),
enableSorting: false,
enableHiding: false,
size: 40,
},
{
accessorKey: 'id',
header: t('ID'),
cell: ({ row }) => {
return (
<TableId
value={row.getValue('id') as number}
className='w-[60px] text-sm'
/>
)
},
size: 80,
meta: { mobileOrder: 10 },
},
{
accessorKey: 'username',
header: t('Username'),
cell: ({ row }) => {
const username = row.getValue('username') as string
const displayName = row.original.display_name
const remark = row.original.remark
return (
<div className='flex min-w-[160px] flex-col gap-1'>
<div className='flex items-center gap-2'>
<LongText className='max-w-[140px] font-medium'>
{username}
</LongText>
{remark && (
<Tooltip>
<TooltipTrigger
render={<StatusBadge variant='success' copyable={false} />}
>
<LongText className='max-w-[80px]'>{remark}</LongText>
</TooltipTrigger>
<TooltipContent>
<p className='text-xs'>{remark}</p>
</TooltipContent>
</Tooltip>
)}
</div>
{displayName && displayName !== username && (
<LongText className='text-muted-foreground max-w-[180px] text-xs'>
{displayName}
</LongText>
)}
</div>
)
},
enableHiding: false,
size: 220,
meta: { mobileTitle: true },
},
{
accessorKey: 'status',
header: t('Status'),
cell: ({ row }) => {
const user = row.original
const requestCount = user.request_count
const statusConfig = isUserDeleted(user)
? USER_STATUSES[USER_STATUS.DELETED]
: USER_STATUSES[user.status as keyof typeof USER_STATUSES]
if (!statusConfig) {
return null
}
return (
<Tooltip>
<TooltipTrigger render={<div className='-ml-1.5 cursor-help' />}>
<StatusBadge
label={t(statusConfig.labelKey)}
variant={statusConfig.variant}
copyable={false}
/>
</TooltipTrigger>
<TooltipContent>
<p className='text-xs'>
{t('Requests:')} {requestCount.toLocaleString()}
</p>
</TooltipContent>
</Tooltip>
)
},
filterFn: (row, id, value) => {
return value.includes(String(row.getValue(id)))
},
enableSorting: false,
size: 120,
meta: { mobileBadge: true },
},
{
id: 'quota',
accessorKey: 'quota',
header: t('Quota'),
cell: ({ row }) => {
const user = row.original
return <UserQuotaCell used={user.used_quota} remaining={user.quota} />
},
size: 300,
minSize: 260,
meta: { mobileOrder: 40 },
},
{
accessorKey: 'group',
header: t('Group'),
cell: ({ row }) => {
const group = row.getValue('group') as string
return (
<BadgeCell>
<GroupBadge group={group} />
</BadgeCell>
)
},
filterFn: (row, id, value) => {
const group = String(row.getValue(id) || t('User Group')).toLowerCase()
const searchValue = String(value).toLowerCase()
return group.includes(searchValue)
},
size: 140,
meta: { mobileOrder: 30 },
},
{
accessorKey: 'role',
header: t('Role'),
cell: ({ row }) => {
const roleValue = row.getValue('role') as number
const roleConfig = USER_ROLES[roleValue as keyof typeof USER_ROLES]
if (!roleConfig) {
return null
}
return (
<div className='flex items-center gap-x-2'>
{roleConfig.icon && (
<roleConfig.icon size={16} className='text-muted-foreground' />
)}
<span className='text-sm'>{t(roleConfig.labelKey)}</span>
</div>
)
},
filterFn: (row, id, value) => {
return value.includes(String(row.getValue(id)))
},
enableSorting: false,
size: 120,
meta: { mobileOrder: 20 },
},
{
id: 'invite_info',
header: t('Invite Info'),
cell: ({ row }) => {
const user = row.original
const affCount = user.aff_count || 0
const affHistoryQuota = user.aff_history_quota || 0
const inviterId = user.inviter_id || 0
return (
<div className='flex max-w-full min-w-0 flex-wrap items-center gap-1 overflow-hidden'>
<Tooltip>
<TooltipTrigger
render={
<StatusBadge
label={`${t('Invited')}: ${affCount}`}
variant='neutral'
copyable={false}
className='cursor-help'
/>
}
/>
<TooltipContent>
<p className='text-xs'>{t('Number of users invited')}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<StatusBadge
label={`${t('Revenue')}: ${formatQuota(affHistoryQuota)}`}
variant='neutral'
copyable={false}
className='cursor-help'
/>
}
/>
<TooltipContent>
<p className='text-xs'>{t('Total invitation revenue')}</p>
</TooltipContent>
</Tooltip>
{inviterId > 0 && (
<Tooltip>
<TooltipTrigger
render={
<StatusBadge
label={`${t('Inviter')}: ${inviterId}`}
variant='neutral'
copyable={false}
className='cursor-help'
/>
}
/>
<TooltipContent>
<p className='text-xs'>
{t('Invited by user ID')} {inviterId}
</p>
</TooltipContent>
</Tooltip>
)}
{inviterId === 0 && (
<StatusBadge
label={t('No Inviter')}
variant='neutral'
copyable={false}
/>
)}
</div>
)
},
size: 240,
enableSorting: false,
meta: { mobileHidden: true },
},
{
accessorKey: 'created_at',
header: t('Created At'),
cell: ({ row }) => {
const ts = row.getValue('created_at') as number | undefined
return (
<span className='text-muted-foreground text-sm'>
{ts ? formatTimestamp(ts) : '-'}
</span>
)
},
size: 180,
meta: { mobileHidden: true },
},
{
accessorKey: 'last_login_at',
header: t('Last Login'),
cell: ({ row }) => {
const ts = row.getValue('last_login_at') as number | undefined
return (
<span className='text-muted-foreground text-sm'>
{ts ? formatTimestamp(ts) : '-'}
</span>
)
},
size: 180,
meta: { mobileHidden: true },
},
{
id: 'actions',
header: () => t('Actions'),
cell: ({ row }) => <DataTableRowActions row={row} />,
meta: { pinned: 'right' as const },
},
]
}
@@ -0,0 +1,73 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { deleteUser } from '../api'
import { ERROR_MESSAGES } from '../constants'
import { getUserActionMessage } from '../lib'
import { useUsers } from './users-provider'
export function UsersDeleteDialog() {
const { t } = useTranslation()
const { open, setOpen, currentRow, triggerRefresh } = useUsers()
const [isDeleting, setIsDeleting] = useState(false)
const handleDelete = async () => {
if (!currentRow) return
setIsDeleting(true)
try {
const result = await deleteUser(currentRow.id)
if (result.success) {
toast.success(t(getUserActionMessage('delete')))
setOpen(null)
triggerRefresh()
} else {
toast.error(result.message || t(ERROR_MESSAGES.DELETE_FAILED))
}
} catch {
toast.error(t(ERROR_MESSAGES.UNEXPECTED))
} finally {
setIsDeleting(false)
}
}
return (
<ConfirmDialog
open={open === 'delete'}
onOpenChange={(open) => !open && setOpen(null)}
title={t('Are you sure?')}
desc={
<>
{t('This will permanently delete user')}{' '}
<span className='font-semibold'>{currentRow?.username}</span>
{t('. This action cannot be undone.')}
</>
}
confirmText={isDeleting ? t('Deleting...') : t('Delete')}
destructive
isLoading={isDeleting}
handleConfirm={handleDelete}
/>
)
}
@@ -0,0 +1,599 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { zodResolver } from '@hookform/resolvers/zod'
import { useQuery } from '@tanstack/react-query'
import { Pencil } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
SideDrawerSection,
sideDrawerContentClassName,
sideDrawerFooterClassName,
sideDrawerFormClassName,
sideDrawerHeaderClassName,
} from '@/components/drawer-layout'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { Textarea } from '@/components/ui/textarea'
import {
ADMIN_PERMISSION_ACTIONS,
ADMIN_PERMISSION_RESOURCES,
EMPTY_PERMISSION_CATALOG,
hasPermission,
normalizeAdminPermissions,
} from '@/lib/admin-permissions'
import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency'
import { formatQuota, parseQuotaFromDollars } from '@/lib/format'
import { ROLE } from '@/lib/roles'
import { useAuthStore } from '@/stores/auth-store'
import {
createUser,
updateUser,
getUser,
getGroups,
getPermissionCatalog,
} from '../api'
import { BINDING_FIELDS, ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
import {
userFormSchema,
type UserFormValues,
USER_FORM_DEFAULT_VALUES,
transformFormDataToPayload,
transformUserToFormDefaults,
} from '../lib'
import { type User } from '../types'
import { UserQuotaDialog } from './user-quota-dialog'
import { useUsers } from './users-provider'
type UsersMutateDrawerProps = {
open: boolean
onOpenChange: (open: boolean) => void
currentRow?: User
}
export function UsersMutateDrawer({
open,
onOpenChange,
currentRow,
}: UsersMutateDrawerProps) {
const { t } = useTranslation()
const isUpdate = !!currentRow
const { triggerRefresh } = useUsers()
const currentUser = useAuthStore((s) => s.auth.user)
const [isSubmitting, setIsSubmitting] = useState(false)
const [quotaDialogOpen, setQuotaDialogOpen] = useState(false)
// Fetch groups
const { data: groupsData } = useQuery({
queryKey: ['groups'],
queryFn: getGroups,
staleTime: 5 * 60 * 1000,
})
const groups = groupsData?.data || []
// Permission catalog is owned by the backend; fetched once and reused.
const { data: permissionCatalog = EMPTY_PERMISSION_CATALOG } = useQuery({
queryKey: ['admin-permission-catalog'],
queryFn: getPermissionCatalog,
staleTime: 5 * 60 * 1000,
})
const form = useForm<UserFormValues>({
resolver: zodResolver(userFormSchema),
defaultValues: USER_FORM_DEFAULT_VALUES,
})
// Load existing data when updating
useEffect(() => {
if (open && isUpdate && currentRow) {
// For update, fetch fresh data
getUser(currentRow.id).then((result) => {
if (result.success && result.data) {
form.reset(transformUserToFormDefaults(result.data))
}
})
} else if (open && !isUpdate) {
// For create, reset to defaults
form.reset(USER_FORM_DEFAULT_VALUES)
}
}, [open, isUpdate, currentRow, form])
const { meta: currencyMeta } = getCurrencyDisplay()
const currencyLabel = getCurrencyLabel()
const tokensOnly = currencyMeta.kind === 'tokens'
const currentQuotaRaw = form.watch('quota_dollars') || 0
const selectedRole = form.watch('role')
const canEditAdminPermissions = currentUser?.role === ROLE.SUPER_ADMIN
const targetIsAdmin = (selectedRole ?? currentRow?.role ?? 0) >= ROLE.ADMIN
const onSubmit = async (data: UserFormValues) => {
if (!isUpdate) {
const passwordLength = data.password?.length || 0
if (passwordLength < 8 || passwordLength > 20) {
form.setError('password', {
type: 'manual',
message: t('Password must be between 8 and 20 characters'),
})
return
}
}
setIsSubmitting(true)
try {
const payload = transformFormDataToPayload(
data,
currentRow?.id,
permissionCatalog
)
const result = isUpdate
? await updateUser(payload as typeof payload & { id: number })
: await createUser(payload)
if (result.success) {
toast.success(
isUpdate
? t(SUCCESS_MESSAGES.USER_UPDATED)
: t(SUCCESS_MESSAGES.USER_CREATED)
)
onOpenChange(false)
triggerRefresh()
} else {
toast.error(
result.message ||
(isUpdate
? t(ERROR_MESSAGES.UPDATE_FAILED)
: t(ERROR_MESSAGES.CREATE_FAILED))
)
}
} catch (_error) {
toast.error(t(ERROR_MESSAGES.UNEXPECTED))
} finally {
setIsSubmitting(false)
}
}
const refreshUserData = async () => {
if (!currentRow) return
const result = await getUser(currentRow.id)
if (result.success && result.data) {
form.reset(transformUserToFormDefaults(result.data))
}
triggerRefresh()
}
return (
<>
<Sheet
open={open}
onOpenChange={(v) => {
onOpenChange(v)
if (!v) {
form.reset()
}
}}
>
<SheetContent
className={sideDrawerContentClassName('sm:max-w-[600px]')}
>
<SheetHeader className={sideDrawerHeaderClassName()}>
<SheetTitle>
{isUpdate ? t('Update') : t('Create')} {t('User')}
</SheetTitle>
<SheetDescription>
{isUpdate
? t('Update the user by providing necessary info.')
: t('Add a new user by providing necessary info.')}
</SheetDescription>
</SheetHeader>
<Form {...form}>
<form
id='user-form'
onSubmit={form.handleSubmit(onSubmit)}
className={sideDrawerFormClassName()}
>
{/* Basic Information */}
<SideDrawerSection>
<h3 className='text-sm font-medium'>
{t('Basic Information')}
</h3>
<FormField
control={form.control}
name='username'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Username')}</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t('Enter username')}
disabled={isUpdate}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{!isUpdate && (
<FormField
control={form.control}
name='role'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Role')}</FormLabel>
<Select
items={[
{ value: '1', label: t('Common User') },
{ value: '10', label: t('Admin') },
]}
onValueChange={(value) =>
value !== null && field.onChange(parseInt(value))
}
value={String(field.value)}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('Select a role')} />
</SelectTrigger>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectItem value='1'>
{t('Common User')}
</SelectItem>
<SelectItem value='10'>{t('Admin')}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FormDescription>
{t("Set the user's role (cannot be Root)")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name='display_name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Display Name')}</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t('Enter display name')}
/>
</FormControl>
<FormDescription>
{t('Leave empty to use username')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Password')}</FormLabel>
<FormControl>
<Input
{...field}
type='password'
placeholder={
isUpdate
? t('Leave empty to keep unchanged')
: t('Enter password (8-20 characters)')
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SideDrawerSection>
{/* Group & Quota Settings (Update only) */}
{isUpdate && (
<SideDrawerSection>
<h3 className='text-sm font-medium'>{t('Group & Quota')}</h3>
<FormField
control={form.control}
name='group'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Group')}</FormLabel>
<Select
items={[
...groups.map((group) => ({
value: group,
label: group,
})),
]}
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('Select a group')} />
</SelectTrigger>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{groups.map((group) => (
<SelectItem key={group} value={group}>
{group}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='quota_dollars'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('Remaining Quota ({{currency}})', {
currency: currencyLabel,
})}
</FormLabel>
<div className='flex gap-2'>
<FormControl>
<Input
value={
tokensOnly
? String(field.value || 0)
: (field.value || 0).toFixed(6)
}
readOnly
className='flex-1'
/>
</FormControl>
<Button
type='button'
variant='outline'
onClick={() => setQuotaDialogOpen(true)}
>
<Pencil className='mr-1 h-4 w-4' />
{t('Adjust Quota')}
</Button>
</div>
<FormDescription>
{formatQuota(parseQuotaFromDollars(field.value || 0))}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='remark'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Remark')}</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder={t(
'Admin notes (only visible to admins)'
)}
rows={3}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SideDrawerSection>
)}
{canEditAdminPermissions &&
targetIsAdmin &&
permissionCatalog.resources.length > 0 && (
<SideDrawerSection>
<h3 className='text-sm font-medium'>
{t('Admin Permissions')}
</h3>
<p className='text-muted-foreground text-xs'>
{t(
'Default administrator permissions can be overridden for this user.'
)}
</p>
<FormField
control={form.control}
name='admin_permissions'
render={({ field }) => {
const selected = normalizeAdminPermissions(
field.value,
permissionCatalog
)
return (
<FormItem>
<div className='space-y-3'>
{permissionCatalog.resources.map((resource) => (
<div
key={resource.resource}
className='space-y-2 rounded-md border p-3'
>
<div className='text-sm font-medium'>
{t(resource.label_key)}
</div>
<div className='space-y-2'>
{resource.actions.map((option) => (
<label
key={option.action}
className='flex items-start gap-3'
>
<Checkbox
checked={
selected[resource.resource]?.[
option.action
] === true
}
onCheckedChange={(checked) => {
field.onChange({
...selected,
[resource.resource]: {
...selected[resource.resource],
[option.action]:
checked === true,
},
})
}}
/>
<span className='flex flex-col gap-1'>
<span className='text-sm font-medium'>
{t(option.label_key)}
</span>
<span className='text-muted-foreground text-xs'>
{t(option.description_key)}
</span>
</span>
</label>
))}
</div>
</div>
))}
</div>
<FormMessage />
</FormItem>
)
}}
/>
{currentUser && (
<p className='text-muted-foreground text-xs'>
{hasPermission(
currentUser,
ADMIN_PERMISSION_RESOURCES.CHANNEL,
ADMIN_PERMISSION_ACTIONS.SENSITIVE_WRITE
)
? t(
'Your account can edit sensitive channel settings.'
)
: t(
'Your account cannot edit sensitive channel settings.'
)}
</p>
)}
</SideDrawerSection>
)}
{/* Binding Information (Read-only) */}
{isUpdate && (
<SideDrawerSection>
<h3 className='text-sm font-medium'>
{t('Binding Information')}
</h3>
<p className='text-muted-foreground text-xs'>
{t(
'Third-party account bindings (read-only, managed by user in profile settings)'
)}
</p>
<div className='flex flex-col gap-3'>
{BINDING_FIELDS.map(({ key, label }) => (
<div key={key}>
<Label className='text-muted-foreground text-xs'>
{t(label)}
</Label>
<Input
value={
(currentRow?.[key as keyof User] as string) || '-'
}
disabled
className='mt-1'
/>
</div>
))}
</div>
</SideDrawerSection>
)}
</form>
</Form>
<SheetFooter className={sideDrawerFooterClassName()}>
<SheetClose render={<Button variant='outline' />}>
{t('Close')}
</SheetClose>
<Button form='user-form' type='submit' disabled={isSubmitting}>
{isSubmitting ? t('Saving...') : t('Save changes')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
{/* Adjust Quota Dialog */}
{currentRow && (
<UserQuotaDialog
open={quotaDialogOpen}
onOpenChange={setQuotaDialogOpen}
userId={currentRow.id}
currentQuota={parseQuotaFromDollars(currentQuotaRaw || 0)}
onSuccess={refreshUserData}
/>
)}
</>
)
}
@@ -0,0 +1,43 @@
/*
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 { Plus } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { useUsers } from './users-provider'
export function UsersPrimaryButtons() {
const { t } = useTranslation()
const { setOpen, setCurrentRow } = useUsers()
const handleCreate = () => {
setCurrentRow(null)
setOpen('create')
}
return (
<div className='flex gap-2'>
<Button size='sm' onClick={handleCreate}>
<Plus className='h-4 w-4' />
{t('Add User')}
</Button>
</div>
)
}
+68
View File
@@ -0,0 +1,68 @@
/*
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 React, { useState } from 'react'
import useDialogState from '@/hooks/use-dialog'
import { type User, type UsersDialogType } from '../types'
type UsersContextType = {
open: UsersDialogType | null
setOpen: (str: UsersDialogType | null) => void
currentRow: User | null
setCurrentRow: React.Dispatch<React.SetStateAction<User | null>>
refreshTrigger: number
triggerRefresh: () => void
}
const UsersContext = React.createContext<UsersContextType | null>(null)
export function UsersProvider({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useDialogState<UsersDialogType>(null)
const [currentRow, setCurrentRow] = useState<User | null>(null)
const [refreshTrigger, setRefreshTrigger] = useState(0)
const triggerRefresh = () => setRefreshTrigger((prev) => prev + 1)
return (
<UsersContext
value={{
open,
setOpen,
currentRow,
setCurrentRow,
refreshTrigger,
triggerRefresh,
}}
>
{children}
</UsersContext>
)
}
// eslint-disable-next-line react-refresh/only-export-components
export const useUsers = () => {
const usersContext = React.useContext(UsersContext)
if (!usersContext) {
throw new Error('useUsers has to be used within <UsersContext>')
}
return usersContext
}
+244
View File
@@ -0,0 +1,244 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router'
import type { OnChangeFn, SortingState } from '@tanstack/react-table'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
DISABLED_ROW_DESKTOP,
DISABLED_ROW_MOBILE,
DataTablePage,
useDataTable,
} from '@/components/data-table'
import { useMediaQuery } from '@/hooks'
import { useTableUrlState } from '@/hooks/use-table-url-state'
import { getUsers, searchUsers } from '../api'
import {
USER_STATUS,
getUserStatusOptions,
getUserRoleOptions,
isUserDeleted,
} from '../constants'
import type { User, UserSortBy } from '../types'
import { DataTableBulkActions } from './data-table-bulk-actions'
import { useUsersColumns } from './users-columns'
import { useUsers } from './users-provider'
const route = getRouteApi('/_authenticated/users/')
const USER_SORTABLE_COLUMNS = new Set<UserSortBy>([
'id',
'username',
'quota',
'group',
'created_at',
'last_login_at',
])
function isDisabledUserRow(user: User) {
return isUserDeleted(user) || user.status === USER_STATUS.DISABLED
}
export function UsersTable() {
const { t } = useTranslation()
const columns = useUsersColumns()
const { refreshTrigger } = useUsers()
const isMobile = useMediaQuery('(max-width: 640px)')
const [sorting, setSorting] = useState<SortingState>([])
const {
globalFilter,
onGlobalFilterChange,
columnFilters,
onColumnFiltersChange,
pagination,
onPaginationChange,
ensurePageInRange,
} = useTableUrlState({
search: route.useSearch(),
navigate: route.useNavigate(),
pagination: { defaultPage: 1, defaultPageSize: isMobile ? 10 : 20 },
globalFilter: { enabled: true, key: 'filter' },
columnFilters: [
{ columnId: 'status', searchKey: 'status', type: 'array' },
{ columnId: 'role', searchKey: 'role', type: 'array' },
{ columnId: 'group', searchKey: 'group', type: 'string' },
],
})
const statusFilter =
(columnFilters.find((filter) => filter.id === 'status')?.value as
| string[]
| undefined) ?? []
const roleFilter =
(columnFilters.find((filter) => filter.id === 'role')?.value as
| string[]
| undefined) ?? []
const groupFilter =
(columnFilters.find((filter) => filter.id === 'group')?.value as string) ??
''
const sortParams = useMemo(() => {
const activeSort = sorting[0]
if (
!activeSort ||
!USER_SORTABLE_COLUMNS.has(activeSort.id as UserSortBy)
) {
return {}
}
return {
sort_by: activeSort.id as UserSortBy,
sort_order: activeSort.desc ? 'desc' : 'asc',
} as const
}, [sorting])
const handleSortingChange: OnChangeFn<SortingState> = (updater) => {
setSorting(updater)
if (pagination.pageIndex > 0) {
onPaginationChange({ ...pagination, pageIndex: 0 })
}
}
// Fetch data with React Query
const { data, isLoading, isFetching } = useQuery({
queryKey: [
'users',
pagination.pageIndex + 1,
pagination.pageSize,
globalFilter,
statusFilter,
roleFilter,
groupFilter,
sortParams,
refreshTrigger,
],
queryFn: async () => {
const hasFilter = globalFilter?.trim()
const hasColumnFilter =
statusFilter.length > 0 || roleFilter.length > 0 || Boolean(groupFilter)
const params = {
p: pagination.pageIndex + 1,
page_size: pagination.pageSize,
...sortParams,
}
const result =
hasFilter || hasColumnFilter
? await searchUsers({
...params,
keyword: globalFilter,
status: statusFilter[0] ?? '',
role: roleFilter[0] ?? '',
group: groupFilter,
})
: await getUsers(params)
if (!result.success) {
toast.error(
result.message || `Failed to ${hasFilter ? 'search' : 'load'} users`
)
return { items: [], total: 0 }
}
return {
items: result.data?.items || [],
total: result.data?.total || 0,
}
},
placeholderData: (previousData) => previousData,
})
const users = data?.items || []
const { table } = useDataTable({
data: users,
columns,
enableRowSelection: true,
columnFilters,
globalFilter,
pagination,
sorting,
globalFilterFn: (row, _columnId, filterValue) => {
const searchValue = String(filterValue).toLowerCase()
const fields = [
row.getValue('username'),
row.original.display_name,
row.original.email,
]
return fields.some((field) =>
String(field || '')
.toLowerCase()
.includes(searchValue)
)
},
onPaginationChange,
onGlobalFilterChange,
onColumnFiltersChange,
onSortingChange: handleSortingChange,
manualPagination: true,
manualFiltering: true,
manualSorting: true,
totalCount: data?.total || 0,
ensurePageInRange,
})
return (
<DataTablePage
table={table}
columns={columns}
isLoading={isLoading}
isFetching={isFetching}
emptyTitle={t('No Users Found')}
emptyDescription={t(
'No users available. Try adjusting your search or filters.'
)}
skeletonKeyPrefix='users-skeleton'
applyHeaderSize
toolbarProps={{
searchPlaceholder: t('Filter by username, name or email...'),
filters: [
{
columnId: 'status',
title: t('Status'),
options: getUserStatusOptions(t),
singleSelect: true,
},
{
columnId: 'role',
title: t('Role'),
options: getUserRoleOptions(t),
singleSelect: true,
},
],
}}
getRowClassName={(row, { isMobile }) =>
isDisabledUserRow(row.original)
? isMobile
? DISABLED_ROW_MOBILE
: DISABLED_ROW_DESKTOP
: undefined
}
bulkActions={<DataTableBulkActions table={table} />}
/>
)
}
+139
View File
@@ -0,0 +1,139 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Shield, User, Users } from 'lucide-react'
import type { User as UserType } from './types'
// ============================================================================
// User Utilities
// ============================================================================
export const isUserDeleted = (user: UserType): boolean => {
return user.DeletedAt != null
}
// ============================================================================
// User Status Configuration
// ============================================================================
export const USER_STATUS = {
ENABLED: 1,
DISABLED: 2,
DELETED: -1,
} as const
export const USER_STATUSES = {
[USER_STATUS.ENABLED]: {
labelKey: 'Enabled',
variant: 'success' as const,
value: USER_STATUS.ENABLED,
},
[USER_STATUS.DISABLED]: {
labelKey: 'Disabled',
variant: 'neutral' as const,
value: USER_STATUS.DISABLED,
},
[USER_STATUS.DELETED]: {
labelKey: 'Deleted',
variant: 'danger' as const,
value: USER_STATUS.DELETED,
},
} as const
export const getUserStatusOptions = (t: (key: string) => string) => [
{ label: t('Enabled'), value: String(USER_STATUS.ENABLED) },
{ label: t('Disabled'), value: String(USER_STATUS.DISABLED) },
{ label: t('Deleted'), value: String(USER_STATUS.DELETED) },
]
// ============================================================================
// User Role Configuration
// ============================================================================
export const USER_ROLE = {
USER: 1,
ADMIN: 10,
ROOT: 100,
} as const
export const USER_ROLES = {
[USER_ROLE.USER]: {
labelKey: 'User',
value: USER_ROLE.USER,
icon: User,
},
[USER_ROLE.ADMIN]: {
labelKey: 'Admin',
value: USER_ROLE.ADMIN,
icon: Users,
},
[USER_ROLE.ROOT]: {
labelKey: 'Root',
value: USER_ROLE.ROOT,
icon: Shield,
},
} as const
export const getUserRoleOptions = (t: (key: string) => string) => [
{ label: t('User'), value: String(USER_ROLE.USER), icon: User },
{ label: t('Admin'), value: String(USER_ROLE.ADMIN), icon: Users },
{ label: t('Root'), value: String(USER_ROLE.ROOT), icon: Shield },
]
// ============================================================================
// Default Values
// ============================================================================
export const DEFAULT_GROUP = 'default' as const
// ============================================================================
// Third-party Binding Fields
// ============================================================================
export const BINDING_FIELDS = [
{ key: 'github_id', label: 'GitHub ID' },
{ key: 'discord_id', label: 'Discord ID' },
{ key: 'oidc_id', label: 'OIDC ID' },
{ key: 'wechat_id', label: 'WeChat ID' },
{ key: 'email', label: 'Email' },
{ key: 'telegram_id', label: 'Telegram ID' },
] as const
// ============================================================================
// Error Messages (i18n keys: use t(ERROR_MESSAGES.xxx) when displaying)
// ============================================================================
export const ERROR_MESSAGES = {
UNEXPECTED: 'An unexpected error occurred',
NO_USER: 'No user selected',
LOAD_FAILED: 'Failed to load users',
SEARCH_FAILED: 'Failed to search users',
CREATE_FAILED: 'Failed to create user',
UPDATE_FAILED: 'Failed to update user',
DELETE_FAILED: 'Failed to delete user',
} as const
// ============================================================================
// Success Messages (i18n keys: use t(SUCCESS_MESSAGES.xxx) when displaying)
// ============================================================================
export const SUCCESS_MESSAGES = {
USER_CREATED: 'User created successfully',
USER_UPDATED: 'User updated successfully',
} as const
+61
View File
@@ -0,0 +1,61 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
import { SectionPageLayout } from '@/components/layout'
import { UsersDeleteDialog } from './components/users-delete-dialog'
import { UsersMutateDrawer } from './components/users-mutate-drawer'
import { UsersPrimaryButtons } from './components/users-primary-buttons'
import { UsersProvider, useUsers } from './components/users-provider'
import { UsersTable } from './components/users-table'
function UsersContent() {
const { t } = useTranslation()
const { open, setOpen, currentRow } = useUsers()
return (
<>
<SectionPageLayout fixedContent>
<SectionPageLayout.Title>{t('Users')}</SectionPageLayout.Title>
<SectionPageLayout.Actions>
<UsersPrimaryButtons />
</SectionPageLayout.Actions>
<SectionPageLayout.Content>
<UsersTable />
</SectionPageLayout.Content>
</SectionPageLayout>
<UsersMutateDrawer
open={open === 'create' || open === 'update'}
onOpenChange={(isOpen) => !isOpen && setOpen(null)}
currentRow={open === 'update' ? currentRow || undefined : undefined}
/>
<UsersDeleteDialog />
</>
)
}
export function Users() {
return (
<UsersProvider>
<UsersContent />
</UsersProvider>
)
}
+33
View File
@@ -0,0 +1,33 @@
/*
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
*/
// ============================================================================
// User Actions
// ============================================================================
export { getUserActionMessage } from './user-actions'
// ============================================================================
// Form Utilities
// ============================================================================
export {
userFormSchema,
type UserFormValues,
USER_FORM_DEFAULT_VALUES,
transformFormDataToPayload,
transformUserToFormDefaults,
} from './user-form'
+39
View File
@@ -0,0 +1,39 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { type ManageUserAction } from '../types'
// ============================================================================
// User Action Messages
// ============================================================================
const ACTION_MESSAGES: Record<ManageUserAction, string> = {
enable: 'User enabled successfully',
disable: 'User disabled successfully',
promote: 'User promoted to admin successfully',
demote: 'User demoted to regular user successfully',
delete: 'User deleted successfully',
add_quota: 'Quota adjusted successfully',
}
/**
* Get success message for user management action
*/
export function getUserActionMessage(action: ManageUserAction): string {
return ACTION_MESSAGES[action]
}
+126
View File
@@ -0,0 +1,126 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { z } from 'zod'
import {
type PermissionCatalog,
type AdminPermissionMatrix,
normalizeAdminPermissions,
} from '@/lib/admin-permissions'
import { quotaUnitsToDollars } from '@/lib/format'
import { ROLE } from '@/lib/roles'
import { DEFAULT_GROUP } from '../constants'
import { type UserFormData, type User } from '../types'
// ============================================================================
// Form Schema
// ============================================================================
export const userFormSchema = z.object({
username: z.string().min(1, 'Username is required'),
display_name: z.string().optional(),
password: z.string().optional(),
role: z.number().optional(),
quota_dollars: z.number().min(0).optional(),
group: z.string().optional(),
remark: z.string().optional(),
admin_permissions: z
.record(z.string(), z.record(z.string(), z.boolean()))
.optional(),
})
export type UserFormValues = z.infer<typeof userFormSchema>
// ============================================================================
// Form Defaults
// ============================================================================
export const USER_FORM_DEFAULT_VALUES: UserFormValues = {
username: '',
display_name: '',
password: '',
role: 1, // Default to common user
quota_dollars: 0,
group: DEFAULT_GROUP,
remark: '',
// Filled against the backend catalog at render time; see UsersMutateDrawer.
admin_permissions: {},
}
// ============================================================================
// Form Data Transformation
// ============================================================================
/**
* Transform form data to API payload
*/
export function transformFormDataToPayload(
data: UserFormValues,
userId?: number,
catalog?: PermissionCatalog
): UserFormData & { id?: number } {
const payload: UserFormData & { id?: number } = {
username: data.username,
display_name: data.display_name || data.username,
password: data.password || undefined,
}
const role = userId === undefined ? data.role || 1 : (data.role ?? 0)
// Only send the permission matrix when the target is an admin and the catalog
// is available; without the catalog we cannot build a full matrix, so we omit
// the field (the backend then leaves existing permissions untouched).
if (role >= ROLE.ADMIN && catalog) {
payload.admin_permissions = normalizeAdminPermissions(
data.admin_permissions as AdminPermissionMatrix | undefined,
catalog
)
}
// For create: only send required fields
if (userId === undefined) {
payload.role = role
} else {
// For update: quota is adjusted atomically via /api/user/manage, not sent here
payload.group = data.group
payload.remark = data.remark || undefined
payload.id = userId
}
return payload
}
/**
* Transform user data to form defaults. The admin permission matrix is passed
* through as-is (the backend already returns a full matrix); it is filled against
* the catalog at render time in UsersMutateDrawer.
*/
export function transformUserToFormDefaults(user: User): UserFormValues {
return {
username: user.username,
display_name: user.display_name,
password: '',
role: user.role,
quota_dollars: quotaUnitsToDollars(user.quota),
group: user.group || DEFAULT_GROUP,
remark: user.remark || '',
admin_permissions: user.admin_permissions ?? {},
}
}
+152
View File
@@ -0,0 +1,152 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { z } from 'zod'
import type { AdminPermissionMatrix } from '@/lib/admin-permissions'
// ============================================================================
// User Schema & Types
// ============================================================================
/** User status: 1 = enabled, 2 = disabled, 3+ = other states */
export const userStatusSchema = z.number()
export type UserStatus = z.infer<typeof userStatusSchema>
/** User role: 1 = common user, 10 = admin, 100 = root */
export const userRoleSchema = z.number()
export type UserRole = z.infer<typeof userRoleSchema>
export const userSchema = z.object({
id: z.number(),
username: z.string(),
display_name: z.string(),
password: z.string().optional(),
github_id: z.string().optional(),
oidc_id: z.string().optional(),
wechat_id: z.string().optional(),
telegram_id: z.string().optional(),
email: z.string().optional(),
quota: z.number(),
used_quota: z.number(),
request_count: z.number(),
group: z.string(),
aff_code: z.string().optional(),
aff_count: z.number().optional(),
aff_quota: z.number().optional(),
aff_history_quota: z.number().optional(),
inviter_id: z.number().optional(),
linux_do_id: z.string().optional(),
status: userStatusSchema,
role: userRoleSchema,
created_at: z.number().optional(),
updated_at: z.number().optional(),
last_login_at: z.number().optional(),
DeletedAt: z.any().nullable().optional(),
remark: z.string().optional(),
admin_permissions: z
.record(z.string(), z.record(z.string(), z.boolean()))
.optional(),
})
export type User = z.infer<typeof userSchema>
export const userListSchema = z.array(userSchema)
// ============================================================================
// API Request/Response Types
// ============================================================================
/** Generic API response */
export interface ApiResponse<T = unknown> {
success: boolean
message?: string
data?: T
}
export type UserSortBy =
| 'id'
| 'username'
| 'quota'
| 'group'
| 'created_at'
| 'last_login_at'
export type UserSortOrder = 'asc' | 'desc'
export interface GetUsersParams {
p?: number
page_size?: number
sort_by?: UserSortBy
sort_order?: UserSortOrder
}
export interface GetUsersResponse {
success: boolean
message?: string
data?: {
items: User[]
total: number
page: number
page_size: number
}
}
export interface SearchUsersParams {
keyword?: string
group?: string
role?: string
status?: string
p?: number
page_size?: number
sort_by?: UserSortBy
sort_order?: UserSortOrder
}
export interface UserFormData {
username: string
display_name: string
password?: string
role?: number // Only used when creating user
quota?: number // Only used when updating user
group?: string // Only used when updating user
remark?: string // Only used when updating user
admin_permissions?: AdminPermissionMatrix
}
export type ManageUserAction =
| 'promote'
| 'demote'
| 'enable'
| 'disable'
| 'delete'
| 'add_quota'
export type QuotaAdjustMode = 'add' | 'subtract' | 'override'
export interface ManageUserQuotaPayload {
id: number
action: 'add_quota'
mode: QuotaAdjustMode
value: number
}
// ============================================================================
// Dialog Types
// ============================================================================
export type UsersDialogType = 'create' | 'update' | 'delete'