add user moderation page
This commit is contained in:
parent
361ba07585
commit
8b2e5096f4
@ -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<ActionResult<{ data: AdminUser[] }>> => {
|
||||
await requireUser({ permission: UserPermissions.USERMOD });
|
||||
|
||||
const res = await addCardToUser(user, code);
|
||||
|
||||
if (res.error)
|
||||
return res;
|
||||
|
||||
return { data: await getUsers() };
|
||||
};
|
||||
|
@ -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 ??= [];
|
||||
|
85
src/actions/user.ts
Normal file
85
src/actions/user.ts
Normal file
@ -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<ActionResult> => {
|
||||
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();
|
||||
};
|
11
src/app/(with-header)/admin/users/page.tsx
Normal file
11
src/app/(with-header)/admin/users/page.tsx
Normal file
@ -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 (<AdminUserList users={users} />);
|
||||
}
|
185
src/components/admin-user-list.tsx
Normal file
185
src/components/admin-user-list.tsx
Normal file
@ -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<AdminUser | null>(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 }
|
||||
<div className="flex overflow-hidden rounded-lg">
|
||||
<Input label="Access Code" inputMode="numeric" size="sm" type="text" maxLength={24} radius="none"
|
||||
classNames={{ input: `[font-feature-settings:"fwid"] text-xs sm:text-sm` }}
|
||||
value={val.match(/.{1,4}/g)?.join('-') ?? ''}
|
||||
onValueChange={v => setVal(v.replace(/\D/g, ''))} />
|
||||
<Tooltip content="Generate Random Code">
|
||||
<Button isIconOnly color="primary" size="lg" radius="none" onPress={() => setVal(generateAccessCode())}>
|
||||
<ArrowPathIcon className="h-7" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>
|
||||
}, v => onConfirm(v.replace(/\D/g, '')));
|
||||
}
|
||||
|
||||
return (<main className="max-w-5xl mx-auto w-full">
|
||||
<header className="p-4 font-semibold text-2xl flex items-center">
|
||||
Users
|
||||
|
||||
<Tooltip content="Create new user">
|
||||
<Button isIconOnly className="ml-auto"
|
||||
onPress={() => promptAccessCode('Enter an access code to create this user', code => {
|
||||
createUserWithAccessCode(code)
|
||||
.then(res => {
|
||||
if (res.error)
|
||||
return setError(res.message!);
|
||||
setUsers(res.data);
|
||||
})
|
||||
})}>
|
||||
<PlusIcon className="h-6" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</header>
|
||||
|
||||
<Divider className="mb-2" />
|
||||
|
||||
<PermissionEditModal user={editingUser} onClose={() => 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 => <Accordion key={userEntry.id} className="my-1 border-b sm:border-b-0 border-divider sm:bg-content1 sm:rounded-lg sm:px-4 overflow-hidden">
|
||||
<AccordionItem key="1" indicator={({ isOpen }) => <Tooltip content="Show cards">
|
||||
<div className="flex items-center">
|
||||
<CreditCardIcon className="h-6 w-6 mr-1" />
|
||||
<ChevronDownIcon className={`h-4 transition ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
</Tooltip>}
|
||||
disableIndicatorAnimation
|
||||
title={
|
||||
<header className="w-full flex items-center flex-wrap gap-y-1">
|
||||
|
||||
{!hasPermission(userEntry.permissions, UserPermissions.SYSADMIN) && userEntry.id !== user?.id && <Tooltip content="Delete user">
|
||||
<div className="mr-1.5 p-1.5 rounded-lg transition bg-danger hover:brightness-90" onClick={() => {
|
||||
confirm(<span>
|
||||
Do you want to delete this user? This will remove all user data including scores. <br />
|
||||
<span className="font-bold">THIS ACTION CANNOT BE UNDONE.</span>
|
||||
</span>, () => {
|
||||
confirm(<span>Are you <span className="font-bold">REALLY</span> sure?</span>, () => {
|
||||
deleteUser(userEntry.id)
|
||||
.then(res => {
|
||||
if (res.error) return setError(res.message);
|
||||
setUsers(u => u.filter(u => u.id !== userEntry.id));
|
||||
});
|
||||
});
|
||||
});
|
||||
}}>
|
||||
<TrashIcon className="w-5" />
|
||||
</div>
|
||||
</Tooltip>}
|
||||
|
||||
{hasPermission(user?.permissions, UserPermissions.OWNER) && <Tooltip content="Edit permissions">
|
||||
<div className="mr-1.5 p-1.5 rounded-lg transition bg-default hover:brightness-90" onClick={() => setEditingUser(userEntry)}>
|
||||
<PencilSquareIcon className="w-5" />
|
||||
</div>
|
||||
</Tooltip>}
|
||||
|
||||
<Spacer className="w-px" />
|
||||
|
||||
{userEntry.username ? <>
|
||||
<Link href={`/user/${userEntry.uuid}`} className="text-white font-semibold transition hover:text-secondary">
|
||||
{userEntry.username}
|
||||
</Link>
|
||||
<span className="text-medium"> ({userEntry.email})</span>
|
||||
</> :
|
||||
<span className="italic text-gray-500">
|
||||
Unregistered User
|
||||
</span>
|
||||
}
|
||||
|
||||
{[...PERMISSION_ICONS].filter(([permission]) => userEntry.permissions! & (1 << permission))
|
||||
.map(([permission, Icon]) => <Tooltip key={permission} content={USER_PERMISSION_NAMES.get(permission)!.title}>
|
||||
<div className="ml-2"><Icon className="h-6 w-6" /></div>
|
||||
</Tooltip>)}
|
||||
|
||||
<Spacer className="flex-grow" />
|
||||
|
||||
{userEntry.created_date && <time className="text-xs mr-4" dateTime={userEntry.created_date.toISOString()}>
|
||||
<span className="font-semibold text-sm">Created </span>
|
||||
{userEntry.created_date.toLocaleTimeString(undefined, FORMAT)}
|
||||
</time>}
|
||||
{userEntry.last_login_date && <time className="text-xs mr-4" dateTime={userEntry.last_login_date.toISOString()}>
|
||||
<span className="font-semibold text-sm">Last Login: </span>
|
||||
{userEntry.last_login_date.toLocaleTimeString(undefined, FORMAT)}
|
||||
</time>}
|
||||
</header>
|
||||
}>
|
||||
<section className="flex sm:p-4">
|
||||
<div className="flex-grow flex flex-wrap items-center justify-center gap-2">
|
||||
{userEntry.cards.map(c => <AimeCard key={c.access_code}
|
||||
card={{
|
||||
...c,
|
||||
created_date: new Date(c.created_date!),
|
||||
last_login_date: c.last_login_date ? new Date(c.last_login_date!) : null,
|
||||
id: c.id!,
|
||||
user: c.user!
|
||||
}} />)}
|
||||
</div>
|
||||
<Tooltip content="Add new card to this user">
|
||||
<Button isIconOnly onPress={() => promptAccessCode('Enter an access code to add',
|
||||
code => adminAddCardToUser(userEntry.id, code).then(res => {
|
||||
if (res.error)
|
||||
return setError(res.message);
|
||||
setUsers(res.data);
|
||||
}))}>
|
||||
<PlusIcon className="h-6" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</section>
|
||||
</AccordionItem>
|
||||
</Accordion>)}
|
||||
</main>);
|
||||
};
|
@ -74,7 +74,8 @@ export const AimeCard = ({ className, card }: AimeCardProps) => {
|
||||
</div>
|
||||
|
||||
<div className="text-sm sm:text-medium">
|
||||
Last Used {card.last_login_date?.toLocaleTimeString(undefined, formatOptions)}
|
||||
{card.last_login_date ? `Last Used ${card.last_login_date.toLocaleTimeString(undefined, formatOptions)}` :
|
||||
'Never Used'}
|
||||
</div>
|
||||
|
||||
{(locked || banned) && <div className="absolute flex items-center justify-center w-full left-0 top-[60%] backdrop-blur h-12 sm:h-16 bg-gray-600/50 font-bold sm:text-2xl">
|
||||
|
@ -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<ConfirmCallback>(() => {});
|
||||
|
||||
export const ConfirmProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<ReactNode | null>(null);
|
||||
const confirmCallback = useRef<() => void>();
|
||||
const cancelCallback = useRef<() => void>();
|
||||
|
||||
@ -37,13 +37,15 @@ export const ConfirmProvider = ({ children }: { children: ReactNode }) => {
|
||||
<ModalBody>{message}</ModalBody>
|
||||
<ModalFooter className="gap-2">
|
||||
<Button onPress={() => {
|
||||
cancelCallback.current?.();
|
||||
if (cancelCallback.current)
|
||||
setTimeout(cancelCallback.current, 15);
|
||||
onClose();
|
||||
}} >
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onPress={() => {
|
||||
confirmCallback.current?.();
|
||||
if (confirmCallback.current)
|
||||
setTimeout(confirmCallback.current, 15);
|
||||
onClose();
|
||||
}} color="danger">
|
||||
Confirm
|
||||
|
@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { Button, Checkbox, Modal, ModalContent, ModalHeader, Tooltip } from '@nextui-org/react';
|
||||
import { useHashNavigation } from '@/helpers/use-hash-navigation';
|
||||
import { ARCADE_PERMISSION_NAMES, ArcadePermissions, USER_PERMISSION_NAMES, UserPermissions } from '@/types/permissions';
|
||||
@ -7,11 +5,12 @@ import Link from 'next/link';
|
||||
import { ModalBody, ModalFooter } from '@nextui-org/modal';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { QuestionMarkCircleIcon } from '@heroicons/react/24/outline';
|
||||
import { useConfirmModal } from './confirm-modal';
|
||||
|
||||
export type PermissionEditModalUser = {
|
||||
permissions: number,
|
||||
uuid?: string,
|
||||
id: number,
|
||||
permissions: number | null,
|
||||
uuid?: string | null,
|
||||
id: number | null,
|
||||
username?: string | null
|
||||
};
|
||||
|
||||
@ -20,19 +19,21 @@ type PermissionEditModalProps = {
|
||||
onClose: () => void,
|
||||
permissions: (typeof USER_PERMISSION_NAMES) | (typeof ARCADE_PERMISSION_NAMES),
|
||||
displayUpTo?: UserPermissions | ArcadePermissions,
|
||||
onEdit: (id: number, permissions: number) => void
|
||||
onEdit: (id: number, permissions: number) => void,
|
||||
disallowDemoteOwners?: boolean
|
||||
};
|
||||
|
||||
export const PermissionEditModal = ({ user, onClose, permissions, displayUpTo, onEdit }: PermissionEditModalProps) => {
|
||||
export const PermissionEditModal = ({ user, onClose, permissions, displayUpTo, onEdit, disallowDemoteOwners }: PermissionEditModalProps) => {
|
||||
const onModalClose = useHashNavigation({
|
||||
onClose,
|
||||
isOpen: user !== null,
|
||||
hash: '#permissions'
|
||||
});
|
||||
const [editingPermissions, setEditingPermissions] = useState(0);
|
||||
const confirm = useConfirmModal();
|
||||
|
||||
useEffect(() => {
|
||||
if (user) setEditingPermissions(user.permissions);
|
||||
if (user) setEditingPermissions(user.permissions!);
|
||||
}, [user?.permissions])
|
||||
|
||||
return (<Modal onClose={onModalClose} isOpen={user !== null}>
|
||||
@ -47,11 +48,12 @@ export const PermissionEditModal = ({ user, onClose, permissions, displayUpTo, o
|
||||
{[...permissions].filter(([p]) => p <= (displayUpTo ?? Infinity))
|
||||
.map(([permission, { description, title }]) => <div key={permission} className="flex gap-2 items-center">
|
||||
<Checkbox size="lg" isSelected={!!(editingPermissions & (1 << permission))}
|
||||
isDisabled={permission === UserPermissions.OWNER && disallowDemoteOwners && !!(user?.permissions! & (1 << UserPermissions.OWNER))}
|
||||
onValueChange={selected => setEditingPermissions(p =>
|
||||
selected ? (p | (1 << permission)) : (p & ~(1 << permission)))}>
|
||||
{title}
|
||||
</Checkbox>
|
||||
<Tooltip content={description}>
|
||||
<Tooltip content={permission === UserPermissions.OWNER && disallowDemoteOwners ? `${description} (owners cannot be removed from the owner role)` : description}>
|
||||
<QuestionMarkCircleIcon className="h-6" />
|
||||
</Tooltip>
|
||||
</div>)}
|
||||
@ -61,8 +63,16 @@ export const PermissionEditModal = ({ user, onClose, permissions, displayUpTo, o
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="primary" onPress={() => {
|
||||
onEdit(user?.id!, editingPermissions);
|
||||
const id = user?.id!;
|
||||
const permissions = editingPermissions;
|
||||
onClose();
|
||||
if (disallowDemoteOwners && !(user?.permissions! & (1 << UserPermissions.OWNER)) && (editingPermissions & (1 << UserPermissions.OWNER))) {
|
||||
setTimeout(() => {
|
||||
confirm('Once a user is promoted to owner, they cannot be removed from the owner role.', () => onEdit(id, permissions))
|
||||
}, 15);
|
||||
} else {
|
||||
onEdit(id, permissions);
|
||||
}
|
||||
}}>
|
||||
Save
|
||||
</Button>
|
||||
|
@ -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<Pick<InputProps, 'type' | 'name' | 'label' | 'placeholder'>>;
|
||||
type PromptOptions = { title: string, size?: ModalProps['size'] } &
|
||||
(({ message: string, content?: never } & Partial<Pick<InputProps, 'type' | 'name' | 'label' | 'placeholder'>>) |
|
||||
{ content: (value: string, setValue: (v: string) => void) => ReactNode, message?: never });
|
||||
type PromptCallback = (options: PromptOptions, onConfirm: (val: string) => void, onCancel?: () => void) => void;
|
||||
const PromptContext = createContext<PromptCallback>(() => {});
|
||||
|
||||
@ -12,9 +13,10 @@ export const PromptProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [options, setOptions] = useState<PromptOptions | null>(null);
|
||||
const confirmCallback = useRef<(val: string) => void>();
|
||||
const cancelCallback = useRef<() => void>();
|
||||
const inputRef = useRef<HTMLInputElement | null>(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 (<>
|
||||
<Modal isOpen={options !== null} onClose={onModalClose}>
|
||||
<Modal isOpen={options !== null} onClose={onModalClose} size={size}>
|
||||
<ModalContent>
|
||||
{onClose => <>
|
||||
<ModalHeader className="text-danger">{ title }</ModalHeader>
|
||||
<ModalHeader>{ title }</ModalHeader>
|
||||
<ModalBody>
|
||||
{ message }
|
||||
<Input type="text" size="sm" {...inputProps} ref={inputRef} />
|
||||
{content ? content(value, setValue) : <>{ message }
|
||||
<Input type="text" size="sm" {...inputProps} value={value} onValueChange={setValue} />
|
||||
</>}
|
||||
</ModalBody>
|
||||
<ModalFooter className="gap-2">
|
||||
<Button onPress={() => {
|
||||
@ -53,7 +56,7 @@ export const PromptProvider = ({ children }: { children: ReactNode }) => {
|
||||
</Button>
|
||||
<Button onPress={() => {
|
||||
if (confirmCallback.current)
|
||||
setTimeout(confirmCallback.current, 5, inputRef.current?.value ?? '');
|
||||
setTimeout(confirmCallback.current, 15, value ?? '');
|
||||
onClose();
|
||||
}} color="primary">
|
||||
Confirm
|
||||
|
27
src/data/card.ts
Normal file
27
src/data/card.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { db } from '@/db';
|
||||
import { ActionResult } from '@/types/action-result';
|
||||
|
||||
export const addCardToUser = async (user: number, code: string): Promise<ActionResult<{ id: number }>> => {
|
||||
if (!/^\d{20}$/.test(code))
|
||||
return { error: true, message: 'Invalid access code format' };
|
||||
|
||||
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' };
|
||||
|
||||
const insertResult = await db.insertInto('aime_card')
|
||||
.values({
|
||||
access_code: code,
|
||||
user,
|
||||
created_date: new Date(),
|
||||
is_locked: 0,
|
||||
is_banned: 0
|
||||
})
|
||||
.executeTakeFirst();
|
||||
|
||||
return { id: Number(insertResult.insertId) };
|
||||
};
|
@ -3,6 +3,8 @@ import { hasPermission } from '@/helpers/permissions';
|
||||
import { UserPermissions } from '@/types/permissions';
|
||||
import { sql } from 'kysely';
|
||||
import { db } from '@/db';
|
||||
import { jsonObjectArray } from '@/types/json-object-array';
|
||||
import { parseJsonResult } from '@/helpers/parse-json-result';
|
||||
|
||||
type WithUsersVisibleToOptions = {
|
||||
// ignore targets's visibility settings and always show if they share a team with the user
|
||||
@ -69,3 +71,22 @@ export const withUsersVisibleTo = (viewingUser: UserPayload | null | undefined,
|
||||
export const userIsVisible = (userKey: string) => {
|
||||
return sql<boolean>`(EXISTS (SELECT id FROM visible WHERE id = ${sql.raw(userKey)}))`;
|
||||
}
|
||||
|
||||
export const getUsers = async () => {
|
||||
const res = await db.selectFrom('aime_user as u')
|
||||
.leftJoin('actaeon_user_ext as ext', 'u.id', 'ext.userId')
|
||||
.leftJoin('aime_card as c', 'c.user', 'u.id')
|
||||
.groupBy('u.id')
|
||||
.select(eb => [
|
||||
'u.id', 'u.username', 'u.email', 'u.permissions', 'u.created_date', 'u.last_login_date',
|
||||
'ext.uuid', 'ext.visibility', 'ext.team',
|
||||
jsonObjectArray(eb, [
|
||||
'c.id', 'c.access_code', 'c.created_date', 'c.last_login_date', 'c.is_locked', 'c.is_banned', 'c.user'
|
||||
]).as('cards')
|
||||
] as const)
|
||||
.execute();
|
||||
|
||||
return parseJsonResult(res, ['cards']);
|
||||
};
|
||||
|
||||
export type AdminUser = Awaited<ReturnType<typeof getUsers>>[number];
|
||||
|
5
src/helpers/access-code.ts
Normal file
5
src/helpers/access-code.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { choice, choices } from './random';
|
||||
|
||||
export const generateAccessCode = () => {
|
||||
return choice('012456789') + choices('0123456789', 19).join('');
|
||||
};
|
18
src/helpers/parse-json-result.ts
Normal file
18
src/helpers/parse-json-result.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { ParseableToJSON, ParsedToJSON } from '@/types/json-parseable';
|
||||
import { Entries } from 'type-fest';
|
||||
|
||||
type ParseableKeys<T> = {
|
||||
[K in keyof T]: T[K] extends ParseableToJSON<any> ? K : never
|
||||
}[keyof T];
|
||||
|
||||
type ParseJSONResultOptions = {
|
||||
<T extends object, K extends ParseableKeys<T>>(data: T, keys: K[]): ParsedToJSON<T, K>,
|
||||
<T extends object, K extends ParseableKeys<T>>(data: T[], keys: K[]): ParsedToJSON<T, K>[]
|
||||
};
|
||||
|
||||
export const parseJsonResult = (<T extends object, K extends ParseableKeys<T>>(data: T | T[], keys: K[]) => {
|
||||
if (Array.isArray(data))
|
||||
return data.map(d => parseJsonResult(d, keys));
|
||||
return Object.fromEntries((Object.entries(data) as Entries<T>)
|
||||
.map(([key, val]) => [key, keys.includes(key as any) ? JSON.parse(val as any) : val]));
|
||||
}) as ParseJSONResultOptions;
|
@ -8,6 +8,9 @@ export const choice = <T>(arr: ArrayLike<T>): T => {
|
||||
return arr[randomInt(0, arr.length - 1)];
|
||||
}
|
||||
|
||||
export const choices = <T>(arr: ArrayLike<T>, k: number): T[] => [...new Array(k)]
|
||||
.map(() => choice(arr));
|
||||
|
||||
export const randomString = (length: number) => {
|
||||
return [...Array(length)].map(() => {
|
||||
let l = '';
|
||||
|
@ -32,6 +32,6 @@ export const useHashNavigation = (options: UseHashNavigationOptions) => {
|
||||
|
||||
return () => {
|
||||
router.back();
|
||||
options.onClose();
|
||||
setTimeout(() => options.onClose(), 15);
|
||||
};
|
||||
};
|
||||
|
2
src/types/action-result.ts
Normal file
2
src/types/action-result.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export type ActionResult<T = {}> = { error: true, message: string } |
|
||||
({ error?: false | null, message?: string; } & T);
|
@ -1,33 +1,53 @@
|
||||
import { AliasedExpression, AliasNode, ColumnNode, Expression, IdentifierNode, ReferenceNode, sql } from 'kysely';
|
||||
import { AliasNode, ColumnNode, IdentifierNode, ReferenceNode, sql, RawBuilder, Selection, Simplify, ExpressionBuilder, SelectExpression, OperationNode } from 'kysely';
|
||||
import { DBJSONPrimitive, ParseableToJSON } from '@/types/json-parseable';
|
||||
|
||||
export const jsonObjectArray = (...refs: (Expression<any> | AliasedExpression<any, string>)[]) => {
|
||||
const args: string[] = [];
|
||||
|
||||
for (const ref of refs) {
|
||||
let node = ref.toOperationNode();
|
||||
let name: string | null = null;
|
||||
const parseNodeToArgs = (node: OperationNode) => {
|
||||
let name: string | null = null;
|
||||
|
||||
if (AliasNode.is(node)) {
|
||||
if (!IdentifierNode.is(node.alias))
|
||||
throw TypeError(`unexpected alias type ${node.alias}`)
|
||||
name = node.alias.name;
|
||||
node = node.node;
|
||||
}
|
||||
|
||||
if (!ReferenceNode.is(node))
|
||||
throw TypeError(`unexpected node type ${node.kind}`);
|
||||
|
||||
if (!ColumnNode.is(node.column))
|
||||
throw TypeError('cannot use select all with json');
|
||||
|
||||
name ??= node.column.column.name;
|
||||
args.push(`'${name}'`);
|
||||
|
||||
let identifier: string = '`' + node.column.column.name + '`';
|
||||
if (node.table)
|
||||
identifier = '`' + node.table.table.identifier.name + '`.' +identifier;
|
||||
args.push(identifier);
|
||||
if (AliasNode.is(node)) {
|
||||
if (!IdentifierNode.is(node.alias))
|
||||
throw TypeError(`unexpected alias type ${node.alias}`)
|
||||
name = node.alias.name;
|
||||
node = node.node;
|
||||
}
|
||||
|
||||
return sql<string>`JSON_ARRAYAGG(JSON_OBJECT(${sql.raw(args.join(','))}))`;
|
||||
};
|
||||
if (!ReferenceNode.is(node))
|
||||
throw TypeError(`unexpected node type ${node.kind}`);
|
||||
|
||||
if (!ColumnNode.is(node.column))
|
||||
throw TypeError('cannot use select all with json');
|
||||
|
||||
name ??= node.column.column.name;
|
||||
let identifier: string = '`' + node.column.column.name + '`';
|
||||
if (node.table)
|
||||
identifier = '`' + node.table.table.identifier.name + '`.' +identifier;
|
||||
|
||||
return [`'${name}'`, identifier];
|
||||
}
|
||||
|
||||
export const jsonObjectArray = <DB, TB extends keyof DB, SE extends SelectExpression<DB, TB>>(eb: ExpressionBuilder<DB, TB>, selections: ReadonlyArray<SE>):
|
||||
RawBuilder<ParseableToJSON<Simplify<DBJSONPrimitive<Selection<DB, TB, SE>>>[]>> => {
|
||||
const args: string[] = [];
|
||||
|
||||
for (const selection of selections) {
|
||||
let node: OperationNode;
|
||||
|
||||
if (typeof selection === 'string') {
|
||||
if (selection.includes(' as ')) {
|
||||
const [col, as] = selection.split(' as ').map(s => s.trim());
|
||||
node = eb.ref(col as any).as(as).toOperationNode();
|
||||
} else {
|
||||
node = eb.ref(selection as any).toOperationNode();
|
||||
}
|
||||
} else if (typeof selection === 'function') {
|
||||
node = selection(eb).toOperationNode();
|
||||
} else {
|
||||
node = selection.toOperationNode();
|
||||
}
|
||||
|
||||
args.push(...parseNodeToArgs(node));
|
||||
}
|
||||
|
||||
return sql`JSON_ARRAYAGG(JSON_OBJECT(${sql.raw(args.join(','))}))`;
|
||||
}
|
||||
|
27
src/types/json-parseable.ts
Normal file
27
src/types/json-parseable.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { JsonPrimitive } from 'type-fest';
|
||||
|
||||
declare const parseableToJson: unique symbol;
|
||||
|
||||
// opaque type helper for setting the return type of json.parse
|
||||
export type ParseableToJSON<T> = string & { [parseableToJson]: T };
|
||||
|
||||
export type ParsedToJSON<T, ParseKeys extends keyof T = keyof T> = { [K in keyof T]: K extends ParseKeys ? T[K] extends ParseableToJSON<infer R> ? R : T[K] : T[K] };
|
||||
|
||||
// helper type to convert data types to json returned from db selects
|
||||
export type DBJSONPrimitive<T> = T extends Date ? string : // dates get converted to strings
|
||||
T extends ParseableToJSON<infer R> ? R : // unwrap nested ParseableToJSON
|
||||
T extends JsonPrimitive ? T : // types directly representable as json
|
||||
T extends object ? { [K in keyof T]: DBJSONPrimitive<T[K]> } : // object, convert entries to json
|
||||
T extends any[] ? DBJSONPrimitive<T[number]> : // array, convert entries to json
|
||||
never; // cannot serialize other types
|
||||
|
||||
declare global {
|
||||
interface JSON {
|
||||
parse<T = any>(text: (T extends string ? T : never) | string, reviver?: (this: any, key: string, value: any) => any):
|
||||
T extends ParseableToJSON<infer R> ?
|
||||
R : // jsonparseable helper
|
||||
string extends T ?
|
||||
any : // plain string passed in, could be any type
|
||||
T; // type assertion through manual type argument
|
||||
}
|
||||
}
|
@ -14,6 +14,9 @@ export const USER_PERMISSION_NAMES = new Map([
|
||||
[UserPermissions.OWNER, { title: 'Owner', description: 'Can do anything' }]
|
||||
]);
|
||||
|
||||
export const USER_PERMISSION_MASK = (1 << UserPermissions.USER) | (1 << UserPermissions.USERMOD) |
|
||||
(1 << UserPermissions.ACMOD) | (1 << UserPermissions.SYSADMIN) | (1 << UserPermissions.OWNER);
|
||||
|
||||
export const enum ArcadePermissions {
|
||||
VIEW = 0, // view info and cabs
|
||||
BOOKKEEP = 1, // view bookkeeping info
|
||||
@ -29,3 +32,6 @@ export const ARCADE_PERMISSION_NAMES = new Map([
|
||||
[ArcadePermissions.REGISTRAR, { title: 'Registrar', description: 'Can add and edit cabs' }],
|
||||
[ArcadePermissions.OWNER, { title: 'Arcade Owner', description: 'Can do anything' }]
|
||||
]);
|
||||
|
||||
export const ARCADE_PERMISSION_MASK = (1 << ArcadePermissions.VIEW) | (1 << ArcadePermissions.BOOKKEEP) |
|
||||
(1 << ArcadePermissions.EDITOR) | (1 << ArcadePermissions.REGISTRAR) | (1 << ArcadePermissions.OWNER);
|
||||
|
Loading…
Reference in New Issue
Block a user