add user moderation page

This commit is contained in:
sk1982 2024-03-27 20:31:34 -04:00
parent 361ba07585
commit 8b2e5096f4
19 changed files with 534 additions and 91 deletions

View File

@ -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() };
};

View File

@ -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
View 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();
};

View 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} />);
}

View 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">&nbsp;({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>);
};

View File

@ -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">

View File

@ -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

View File

@ -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>

View File

@ -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
View 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) };
};

View File

@ -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];

View File

@ -0,0 +1,5 @@
import { choice, choices } from './random';
export const generateAccessCode = () => {
return choice('012456789') + choices('0123456789', 19).join('');
};

View 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;

View File

@ -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 = '';

View File

@ -32,6 +32,6 @@ export const useHashNavigation = (options: UseHashNavigationOptions) => {
return () => {
router.back();
options.onClose();
setTimeout(() => options.onClose(), 15);
};
};

View File

@ -0,0 +1,2 @@
export type ActionResult<T = {}> = { error: true, message: string } |
({ error?: false | null, message?: string; } & T);

View File

@ -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(','))}))`;
}

View 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
}
}

View File

@ -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);