refactor(auth): replace dashboard sessions with stateless tokens and session control (#6329)
* refactor(auth): replace dashboard sessions with stateless tokens * feat(auth): harden session issuance and distributed enforcement * fix(proxy): preserve trusted proxy compatibility defaults * refactor: address dashboard auth review feedback * refactor: remove classic frontend and flatten web app
This commit is contained in:
Vendored
+220
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Vendored
+139
@@ -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
|
||||
Vendored
+61
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
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>
|
||||
)
|
||||
}
|
||||
Vendored
+33
@@ -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
@@ -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
@@ -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 ?? {},
|
||||
}
|
||||
}
|
||||
Vendored
+152
@@ -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'
|
||||
Reference in New Issue
Block a user