From 8b2e5096f4cd6a66356a19060c27e74c1175d39b Mon Sep 17 00:00:00 2001 From: sk1982 Date: Wed, 27 Mar 2024 20:31:34 -0400 Subject: [PATCH] add user moderation page --- src/actions/card.ts | 14 ++ src/actions/chuni/userbox.ts | 77 ++++----- src/actions/user.ts | 85 ++++++++++ src/app/(with-header)/admin/users/page.tsx | 11 ++ src/components/admin-user-list.tsx | 185 +++++++++++++++++++++ src/components/aime-card.tsx | 3 +- src/components/confirm-modal.tsx | 10 +- src/components/permission-edit-modal.tsx | 30 ++-- src/components/prompt-modal.tsx | 23 +-- src/data/card.ts | 27 +++ src/data/user.ts | 21 +++ src/helpers/access-code.ts | 5 + src/helpers/parse-json-result.ts | 18 ++ src/helpers/random.ts | 3 + src/helpers/use-hash-navigation.ts | 2 +- src/types/action-result.ts | 2 + src/types/json-object-array.ts | 76 +++++---- src/types/json-parseable.ts | 27 +++ src/types/permissions.ts | 6 + 19 files changed, 534 insertions(+), 91 deletions(-) create mode 100644 src/actions/user.ts create mode 100644 src/app/(with-header)/admin/users/page.tsx create mode 100644 src/components/admin-user-list.tsx create mode 100644 src/data/card.ts create mode 100644 src/helpers/access-code.ts create mode 100644 src/helpers/parse-json-result.ts create mode 100644 src/types/action-result.ts create mode 100644 src/types/json-parseable.ts diff --git a/src/actions/card.ts b/src/actions/card.ts index c0b6891..cb3d9f1 100644 --- a/src/actions/card.ts +++ b/src/actions/card.ts @@ -4,6 +4,9 @@ import { requireUser } from '@/actions/auth'; import { db } from '@/db'; import { UserPermissions } from '@/types/permissions'; import { requirePermission } from '@/helpers/permissions'; +import { addCardToUser } from '@/data/card'; +import { AdminUser, getUsers } from '@/data/user'; +import { ActionResult } from '@/types/action-result'; export const getCards = async () => { const user = await requireUser(); @@ -39,3 +42,14 @@ export const lockUnlockCard = async (opts: { cardId: number, userId: number, isL ])) .executeTakeFirst(); }; + +export const adminAddCardToUser = async (user: number, code: string): Promise> => { + await requireUser({ permission: UserPermissions.USERMOD }); + + const res = await addCardToUser(user, code); + + if (res.error) + return res; + + return { data: await getUsers() }; +}; diff --git a/src/actions/chuni/userbox.ts b/src/actions/chuni/userbox.ts index eed474f..2fcc15b 100644 --- a/src/actions/chuni/userbox.ts +++ b/src/actions/chuni/userbox.ts @@ -8,6 +8,7 @@ import { jsonObjectArray } from '@/types/json-object-array'; import { AvatarCategory } from '@/helpers/chuni/avatar'; import { DB } from '@/types/db'; import { ChuniUserData } from '@/actions/chuni/profile'; +import { parseJsonResult } from '@/helpers/parse-json-result'; const ALLOW_EQUIP_UNEARNED = ['true', '1', 'yes'].includes(process.env.CHUNI_ALLOW_EQUIP_UNEARNED?.toLowerCase() ?? ''); @@ -39,67 +40,69 @@ export const getUserboxItems = async (user: UserPayload, profile: ChuniUserData) const res = await db .with('map_icons', eb => joinItem(eb.selectFrom('actaeon_chuni_static_map_icon as map_icon'), 'map_icon.id', user.id, ItemKind.MAP_ICON, profile?.mapIconId) - .select(eb => jsonObjectArray( - eb.ref('map_icon.id'), - eb.ref('map_icon.name'), - eb.ref('map_icon.sortName'), - eb.ref('map_icon.imagePath') - ).as('mapIcon')) + .select(eb => jsonObjectArray(eb, [ + 'map_icon.id', + 'map_icon.name', + 'map_icon.sortName', + 'map_icon.imagePath' + ]).as('mapIcon')) ) .with('name_plates', eb => joinItem(eb.selectFrom('actaeon_chuni_static_name_plate as name_plate'), 'name_plate.id', user.id, ItemKind.NAME_PLATE, profile?.nameplateId) - .select(eb => jsonObjectArray( - eb.ref('name_plate.id'), - eb.ref('name_plate.name'), - eb.ref('name_plate.sortName'), - eb.ref('name_plate.imagePath') - ).as('namePlate'))) + .select(eb => jsonObjectArray(eb, [ + 'name_plate.id', + 'name_plate.name', + 'name_plate.sortName', + 'name_plate.imagePath' + ]).as('namePlate'))) .with('system_voices', eb => joinItem(eb.selectFrom('actaeon_chuni_static_system_voice as system_voice'), 'system_voice.id', user.id, ItemKind.SYSTEM_VOICE, profile?.voiceId) - .select(eb => jsonObjectArray( - eb.ref('system_voice.id'), - eb.ref('system_voice.name'), - eb.ref('system_voice.sortName'), - eb.ref('system_voice.imagePath'), - eb.ref('system_voice.cuePath'), - ).as('systemVoice'))) + .select(eb => jsonObjectArray(eb, [ + 'system_voice.id', + 'system_voice.name', + 'system_voice.sortName', + 'system_voice.imagePath', + 'system_voice.cuePath', + ]).as('systemVoice'))) .with('trophies', eb => joinItem(eb.selectFrom('actaeon_chuni_static_trophies as trophy'), 'trophy.id', user.id, ItemKind.TROPHY, profile?.nameplateId) - .select(eb => jsonObjectArray( - eb.ref('trophy.id'), - eb.ref('trophy.name'), - eb.ref('trophy.rareType'), - eb.ref('trophy.explainText') - ).as('trophy'))) + .select(eb => jsonObjectArray(eb, [ + 'trophy.id', + 'trophy.name', + 'trophy.rareType', + 'trophy.explainText' + ]).as('trophy'))) .with('avatars', eb => joinItem(eb.selectFrom('chuni_static_avatar as avatar'), 'avatar.avatarAccessoryId', user.id, ItemKind.AVATAR_ACCESSORY, profile?.avatarBack, profile?.avatarFace, profile?.avatarItem, profile?.avatarWear, profile?.avatarFront, profile?.avatarSkin, profile?.avatarHead) .where(({ selectFrom, eb }) => eb('avatar.version', '=', selectFrom('chuni_static_avatar') .select(({ fn }) => fn.max('version').as('latest')))) .groupBy('avatar.category') - .select(eb => ['avatar.category', jsonObjectArray( - eb.ref('avatar.avatarAccessoryId').as('id'), - eb.ref('avatar.name'), - eb.ref('avatar.iconPath'), - eb.ref('avatar.texturePath') - ).as('avatar')])) + .select(eb => ['avatar.category', jsonObjectArray(eb, [ + 'avatar.avatarAccessoryId as id', + 'avatar.name', + 'avatar.iconPath', + 'avatar.texturePath' + ]).as('avatar')] as const)) .selectFrom(['map_icons', 'name_plates', 'system_voices', 'trophies', 'avatars']) .select(eb => ['map_icons.mapIcon', 'name_plates.namePlate', 'system_voices.systemVoice', 'trophies.trophy', - jsonObjectArray(eb.ref('avatars.category'), eb.ref('avatars.avatar')).as('avatar')]) + jsonObjectArray(eb, [ + 'avatars.category', 'avatars.avatar' + ]).as('avatar') + ] as const) .executeTakeFirstOrThrow(); - const data = Object.fromEntries(Object.entries(res) - .map(([key, val]) => [key, JSON.parse(val)])); + const data = parseJsonResult(res, ['mapIcon', 'namePlate', 'systemVoice', 'trophy', 'avatar']); const { avatar, ...output } = data; const itemTypes: { [key: number]: any[] } = {}; Object.entries(AvatarCategory).forEach(([category, number]) => { const key = `avatar${category[0]}${category.slice(1).toLowerCase()}`; - output[key] = itemTypes[number] = []; + output[key as keyof typeof output] = itemTypes[number] = []; }); - (avatar as { category: number, avatar: UserboxItems['avatarBack'] }[]) - ?.forEach(({ category, avatar }) => itemTypes[category].push(...avatar)); + avatar + ?.forEach(({ category, avatar }) => itemTypes[category!].push(...avatar)); output.mapIcon ??= []; output.namePlate ??= []; diff --git a/src/actions/user.ts b/src/actions/user.ts new file mode 100644 index 0000000..1ec30d5 --- /dev/null +++ b/src/actions/user.ts @@ -0,0 +1,85 @@ +'use server'; + +import { db } from '@/db'; +import { requireUser } from '@/actions/auth'; +import { USER_PERMISSION_MASK, UserPermissions } from '@/types/permissions'; +import { getUsers } from '@/data/user'; +import { hasPermission } from '@/helpers/permissions'; +import { ActionResult } from '@/types/action-result'; + +export const createUserWithAccessCode = async (code: string) => { + await requireUser({ permission: UserPermissions.USERMOD }); + + if (!/^\d{20}$/.test(code)) + return { error: true, message: 'Invalid access code format', data: [] }; + + const existingUser = await db.selectFrom('aime_card') + .where('access_code', '=', code) + .select('access_code') + .executeTakeFirst(); + + if (existingUser) + return { error: true, message: 'That access code is already in use', data: [] }; + + await db.transaction().execute(async trx => { + const now = new Date(); + const insertResult = await trx.insertInto('aime_user') + .values({ + created_date: now, + permissions: 1 + }) + .executeTakeFirstOrThrow(); + + await trx.insertInto('aime_card') + .values({ + user: Number(insertResult.insertId), + access_code: code, + created_date: now, + is_banned: 0, + is_locked: 0 + }) + .executeTakeFirst(); + }); + + return { error: false, message: '', data: await getUsers() }; +}; + +export const deleteUser = async (user: number): Promise => { + const adminUser = await requireUser({ permission: UserPermissions.USERMOD }); + + if (adminUser.id === user) + return { error: true, message: 'You cannot delete yourself' }; + + const permissions = await db.selectFrom('aime_user') + .where('id', '=', user) + .select('permissions') + .executeTakeFirst(); + + if (!permissions) + return { error: true, message: 'User not found' }; + + if (hasPermission(permissions.permissions, UserPermissions.SYSADMIN)) + return { error: true, message: 'You cannot delete that user' }; + + await db.deleteFrom('aime_user') + .where('id', '=', user) + .executeTakeFirst(); + + return {}; +}; + +export const setUserPermissions = async (user: number, permissions: number) => { + await requireUser({ permission: UserPermissions.OWNER }); + + permissions &= USER_PERMISSION_MASK; + + await db.updateTable('aime_user') + .where('id', '=', user) + .set(({ eb, parens }) => ({ + permissions: eb( + parens(eb('permissions', '&', 1 << UserPermissions.OWNER)), // if already owner, keep owner status + '|', + permissions) + })) + .executeTakeFirst(); +}; diff --git a/src/app/(with-header)/admin/users/page.tsx b/src/app/(with-header)/admin/users/page.tsx new file mode 100644 index 0000000..e0e5675 --- /dev/null +++ b/src/app/(with-header)/admin/users/page.tsx @@ -0,0 +1,11 @@ +import { requireUser } from '@/actions/auth'; +import { UserPermissions } from '@/types/permissions'; +import { AdminUserList } from '@/components/admin-user-list'; +import { getUsers } from '@/data/user'; + +export default async function AdminUsersPage() { + await requireUser({ permission: UserPermissions.USERMOD }); + const users = await getUsers(); + + return (); +} diff --git a/src/components/admin-user-list.tsx b/src/components/admin-user-list.tsx new file mode 100644 index 0000000..9124458 --- /dev/null +++ b/src/components/admin-user-list.tsx @@ -0,0 +1,185 @@ +'use client'; + +import { createUserWithAccessCode, deleteUser, setUserPermissions } from '@/actions/user'; +import { PermissionEditModal } from './permission-edit-modal'; +import { useState } from 'react'; +import { USER_PERMISSION_NAMES, UserPermissions } from '@/types/permissions'; +import { Button, Divider, Tooltip, Input, Accordion, AccordionItem, Link, Spacer } from '@nextui-org/react'; +import { ChevronDownIcon, CreditCardIcon, PencilSquareIcon, PlusIcon } from '@heroicons/react/24/outline'; +import { usePromptModal } from './prompt-modal'; +import { ArrowPathIcon } from '@heroicons/react/24/outline'; +import { generateAccessCode } from '@/helpers/access-code'; +import { useUser } from '@/helpers/use-user'; +import { TbBrandAppleArcade, TbCrown, TbFileSettings, TbUserShield } from 'react-icons/tb'; +import { hasPermission } from '@/helpers/permissions'; +import { AimeCard } from './aime-card'; +import { useErrorModal } from './error-modal'; +import { AdminUser } from '@/data/user'; +import { adminAddCardToUser } from '@/actions/card'; +import { TrashIcon } from '@heroicons/react/24/outline'; +import { useConfirmModal } from './confirm-modal'; + +const PERMISSION_ICONS = new Map([ + [UserPermissions.USERMOD, TbUserShield], + [UserPermissions.ACMOD, TbBrandAppleArcade], + [UserPermissions.SYSADMIN, TbFileSettings], + [UserPermissions.OWNER, TbCrown] +]); + +const FORMAT = { + month: 'numeric', + day: 'numeric', + year: '2-digit', + hour: 'numeric', + minute: '2-digit' +} as const; + +export const AdminUserList = ({ users: initialUsers }: { users: AdminUser[]; }) => { + const [editingUser, setEditingUser] = useState(null); + const [users, setUsers] = useState(initialUsers); + const user = useUser(); + const prompt = usePromptModal(); + const setError = useErrorModal(); + const confirm = useConfirmModal(); + + const promptAccessCode = (message: string, onConfirm: (val: string) => void) => { + prompt({ + size: '2xl', + title: 'Enter Access Code', content: (val, setVal) => <> + { message } +
+ setVal(v.replace(/\D/g, ''))} /> + + + +
+ + }, v => onConfirm(v.replace(/\D/g, ''))); + } + + return (
+
+ Users + + + + +
+ + + + setEditingUser(null)} + permissions={USER_PERMISSION_NAMES} disallowDemoteOwners + displayUpTo={hasPermission(user?.permissions, UserPermissions.OWNER) ? UserPermissions.OWNER : UserPermissions.ACMOD} + onEdit={(id, permissions) => { + setUserPermissions(id, permissions); + setUsers(u => u.map(u => u.id === id ? { ...u, permissions } : u)); + }} /> + + {users.map(userEntry => + +
+ + +
+
} + disableIndicatorAnimation + title={ +
+ + {!hasPermission(userEntry.permissions, UserPermissions.SYSADMIN) && userEntry.id !== user?.id && +
{ + confirm( + Do you want to delete this user? This will remove all user data including scores.
+ THIS ACTION CANNOT BE UNDONE. +
, () => { + confirm(Are you REALLY sure?, () => { + deleteUser(userEntry.id) + .then(res => { + if (res.error) return setError(res.message); + setUsers(u => u.filter(u => u.id !== userEntry.id)); + }); + }); + }); + }}> + +
+
} + + {hasPermission(user?.permissions, UserPermissions.OWNER) && +
setEditingUser(userEntry)}> + +
+
} + + + + {userEntry.username ? <> + + {userEntry.username} + +  ({userEntry.email}) + : + + Unregistered User + + } + + {[...PERMISSION_ICONS].filter(([permission]) => userEntry.permissions! & (1 << permission)) + .map(([permission, Icon]) => +
+
)} + + + + {userEntry.created_date && } + {userEntry.last_login_date && } +
+ }> +
+
+ {userEntry.cards.map(c => )} +
+ + + +
+
+
)} +
); +}; \ No newline at end of file diff --git a/src/components/aime-card.tsx b/src/components/aime-card.tsx index b8f9398..4060d3b 100644 --- a/src/components/aime-card.tsx +++ b/src/components/aime-card.tsx @@ -74,7 +74,8 @@ export const AimeCard = ({ className, card }: AimeCardProps) => {
- Last Used {card.last_login_date?.toLocaleTimeString(undefined, formatOptions)} + {card.last_login_date ? `Last Used ${card.last_login_date.toLocaleTimeString(undefined, formatOptions)}` : + 'Never Used'}
{(locked || banned) &&
diff --git a/src/components/confirm-modal.tsx b/src/components/confirm-modal.tsx index 3c55d9f..45c1438 100644 --- a/src/components/confirm-modal.tsx +++ b/src/components/confirm-modal.tsx @@ -3,11 +3,11 @@ import { Button, Modal, ModalContent, ModalHeader } from '@nextui-org/react'; import { ModalBody, ModalFooter } from '@nextui-org/modal'; import { useHashNavigation } from '@/helpers/use-hash-navigation'; -type ConfirmCallback = (message: string, onConfirm: () => void, onCancel?: () => void) => void; +type ConfirmCallback = (message: ReactNode, onConfirm: () => void, onCancel?: () => void) => void; const ConfirmContext = createContext(() => {}); export const ConfirmProvider = ({ children }: { children: ReactNode }) => { - const [message, setMessage] = useState(null); + const [message, setMessage] = useState(null); const confirmCallback = useRef<() => void>(); const cancelCallback = useRef<() => void>(); @@ -37,13 +37,15 @@ export const ConfirmProvider = ({ children }: { children: ReactNode }) => { {message} diff --git a/src/components/prompt-modal.tsx b/src/components/prompt-modal.tsx index 173f183..8a301cb 100644 --- a/src/components/prompt-modal.tsx +++ b/src/components/prompt-modal.tsx @@ -1,10 +1,11 @@ import { createContext, ReactNode, useCallback, useContext, useRef, useState } from 'react'; import { Button, Input, InputProps, Modal, ModalContent, ModalHeader } from '@nextui-org/react'; -import { ModalBody, ModalFooter } from '@nextui-org/modal'; +import { ModalBody, ModalFooter, ModalProps } from '@nextui-org/modal'; import { useHashNavigation } from '@/helpers/use-hash-navigation'; -type PromptOptions = { title: string, message: string } & - Partial>; +type PromptOptions = { title: string, size?: ModalProps['size'] } & + (({ message: string, content?: never } & Partial>) | + { content: (value: string, setValue: (v: string) => void) => ReactNode, message?: never }); type PromptCallback = (options: PromptOptions, onConfirm: (val: string) => void, onCancel?: () => void) => void; const PromptContext = createContext(() => {}); @@ -12,9 +13,10 @@ export const PromptProvider = ({ children }: { children: ReactNode }) => { const [options, setOptions] = useState(null); const confirmCallback = useRef<(val: string) => void>(); const cancelCallback = useRef<() => void>(); - const inputRef = useRef(null); + const [value, setValue] = useState(''); const setPrompt: PromptCallback = useCallback((options, onConfirm, onCancel) => { + setValue(''); setOptions(options); confirmCallback.current = onConfirm; cancelCallback.current = onCancel; @@ -32,16 +34,17 @@ export const PromptProvider = ({ children }: { children: ReactNode }) => { hash: '#prompt' }); - const { title, message, ...inputProps } = options ?? {}; + const { title, message, size, content, ...inputProps } = options ?? {}; return (<> - + {onClose => <> - { title } + { title } - { message } - + {content ? content(value, setValue) : <>{ message } + + }