mirror of
https://github.com/diced/zipline.git
synced 2026-06-12 19:01:18 -07:00
feat: view users & their files
This commit is contained in:
@@ -7,6 +7,7 @@ export default function GridTableSwitcher({ type }: { type: keyof SettingsStore[
|
||||
|
||||
return (
|
||||
<SegmentedControl
|
||||
sx={{ marginLeft: 'auto' }}
|
||||
size='xs'
|
||||
data={[
|
||||
{
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { relativeTime } from '@/lib/relativeTime';
|
||||
import { Tooltip } from '@mantine/core';
|
||||
|
||||
export default function RelativeDate({
|
||||
date,
|
||||
from,
|
||||
}: {
|
||||
date: Date | string | number;
|
||||
from?: Date | string | number;
|
||||
}) {
|
||||
const d = new Date(date);
|
||||
const f = from ? new Date(from) : new Date();
|
||||
|
||||
return (
|
||||
<Tooltip label={d.toLocaleString()}>
|
||||
<span>{relativeTime(d, f)}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ type ApiPaginationOptions = {
|
||||
favorite?: boolean;
|
||||
sort?: keyof Prisma.FileOrderByWithRelationInput;
|
||||
order?: 'asc' | 'desc';
|
||||
id?: string;
|
||||
};
|
||||
|
||||
const fetcher = async (
|
||||
@@ -26,6 +27,7 @@ const fetcher = async (
|
||||
if (options.perpage) searchParams.append('perpage', options.perpage.toString());
|
||||
if (options.sort) searchParams.append('sortBy', options.sort);
|
||||
if (options.order) searchParams.append('order', options.order);
|
||||
if (options.id) searchParams.append('id', options.id);
|
||||
|
||||
const res = await fetch(`/api/user/files${searchParams.toString() ? `?${searchParams.toString()}` : ''}`);
|
||||
|
||||
@@ -43,29 +45,15 @@ export function useApiPagination(
|
||||
page: 1,
|
||||
}
|
||||
) {
|
||||
const { data, error, isLoading, mutate } = useSWR<Extract<Response['/api/user/files'], File[]>>(
|
||||
const { data, error, isLoading, mutate } = useSWR<Response['/api/user/files']>(
|
||||
{ key: `/api/user/files`, options },
|
||||
fetcher
|
||||
);
|
||||
const {
|
||||
data: pagesCount,
|
||||
error: pagesCountError,
|
||||
isLoading: pagesCountLoading,
|
||||
mutate: pagesCountMutate,
|
||||
} = useSWR<Extract<Response['/api/user/files'], { count: number }>>(`/api/user/files?pagecount=true`);
|
||||
|
||||
return {
|
||||
pages: {
|
||||
data,
|
||||
error,
|
||||
isLoading,
|
||||
mutate,
|
||||
},
|
||||
pagesCount: {
|
||||
data: pagesCount,
|
||||
error: pagesCountError,
|
||||
isLoading: pagesCountLoading,
|
||||
mutate: pagesCountMutate,
|
||||
},
|
||||
data,
|
||||
error,
|
||||
isLoading,
|
||||
mutate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useConfig } from '@/components/ConfigProvider';
|
||||
import DashboardFile from '@/components/file/DashboardFile';
|
||||
import { SafeConfig } from '@/lib/config/safe';
|
||||
import {
|
||||
Accordion,
|
||||
Button,
|
||||
@@ -14,10 +14,9 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
import { useApiPagination } from '../useApiPagination';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useConfig } from '@/components/ConfigProvider';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useApiPagination } from '../useApiPagination';
|
||||
|
||||
export default function FavoriteFiles() {
|
||||
const router = useRouter();
|
||||
@@ -26,7 +25,7 @@ export default function FavoriteFiles() {
|
||||
const [page, setPage] = useState<number>(
|
||||
router.query.favoritePage ? parseInt(router.query.favoritePage as string) : 1
|
||||
);
|
||||
const { pages, pagesCount } = useApiPagination({
|
||||
const { data, isLoading } = useApiPagination({
|
||||
page,
|
||||
favorite: true,
|
||||
filter: 'dashboard',
|
||||
@@ -45,7 +44,7 @@ export default function FavoriteFiles() {
|
||||
);
|
||||
}, [page]);
|
||||
|
||||
if (!pages.isLoading && pages.data?.length === 0) return null;
|
||||
if (!isLoading && data?.page.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -56,7 +55,7 @@ export default function FavoriteFiles() {
|
||||
<Accordion.Panel>
|
||||
<SimpleGrid
|
||||
my='sm'
|
||||
cols={pages.data?.length ?? 0 > 0 ? 3 : 1}
|
||||
cols={data?.page.length ?? 0 > 0 ? 3 : 1}
|
||||
spacing='md'
|
||||
breakpoints={[
|
||||
{ maxWidth: 'sm', cols: 1 },
|
||||
@@ -64,12 +63,12 @@ export default function FavoriteFiles() {
|
||||
]}
|
||||
pos='relative'
|
||||
>
|
||||
{pages.isLoading ? (
|
||||
{isLoading ? (
|
||||
<Paper withBorder h={200}>
|
||||
<LoadingOverlay visible />
|
||||
</Paper>
|
||||
) : pages.data?.length ?? 0 > 0 ? (
|
||||
pages.data?.map((file) => (
|
||||
) : data?.page.length ?? 0 > 0 ? (
|
||||
data?.page.map((file) => (
|
||||
<DashboardFile
|
||||
disableMediaPreview={config?.website.disableMediaPreview ?? false}
|
||||
key={file.id}
|
||||
@@ -101,7 +100,7 @@ export default function FavoriteFiles() {
|
||||
</SimpleGrid>
|
||||
|
||||
<Center>
|
||||
<Pagination my='sm' value={page} onChange={setPage} total={pagesCount.data?.count ?? 1} />
|
||||
<Pagination my='sm' value={page} onChange={setPage} total={data?.pages ?? 1} />
|
||||
</Center>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Control>
|
||||
|
||||
@@ -1,41 +1,39 @@
|
||||
import FileModal from '@/components/file/DashboardFile/FileModal';
|
||||
import { copyFile, deleteFile, viewFile } from '@/components/file/actions';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { SafeConfig } from '@/lib/config/safe';
|
||||
import type { File } from '@/lib/db/models/file';
|
||||
import { fileSelect, type File } from '@/lib/db/models/file';
|
||||
import { ActionIcon, Box, Group, Tooltip } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { IconCopy, IconExternalLink, IconFile, IconTrashFilled } from '@tabler/icons-react';
|
||||
import bytes from 'bytes';
|
||||
import { DataTable } from 'mantine-datatable';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { useApiPagination } from '../useApiPagination';
|
||||
import FileModal from '@/components/file/DashboardFile/FileModal';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { IconCopy, IconExternalLink, IconFile, IconTrashFilled } from '@tabler/icons-react';
|
||||
import { copyFile, deleteFile, viewFile } from '@/components/file/actions';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useConfig } from '@/components/ConfigProvider';
|
||||
import RelativeDate from '@/components/RelativeDate';
|
||||
|
||||
const PER_PAGE_OPTIONS = [10, 20, 50];
|
||||
|
||||
export default function FileTable() {
|
||||
export default function FileTable({ id }: { id?: string }) {
|
||||
const router = useRouter();
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const { data: stats, isLoading: statsLoading } = useSWR<Response['/api/user/stats']>('/api/user/stats');
|
||||
|
||||
const [page, setPage] = useState<number>(router.query.page ? parseInt(router.query.page as string) : 1);
|
||||
const [perpage, setPerpage] = useState<number>(20);
|
||||
const [sort, setSort] = useState<keyof Prisma.FileOrderByWithRelationInput>('createdAt');
|
||||
const [order, setOrder] = useState<'asc' | 'desc'>('desc');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
|
||||
const { pages } = useApiPagination({
|
||||
const { data, isLoading } = useApiPagination({
|
||||
page,
|
||||
perpage,
|
||||
filter: 'all',
|
||||
sort,
|
||||
order,
|
||||
id,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -68,7 +66,7 @@ export default function FileTable() {
|
||||
borderRadius='sm'
|
||||
withBorder
|
||||
minHeight={200}
|
||||
records={pages.data}
|
||||
records={data?.page ?? []}
|
||||
columns={[
|
||||
{ accessor: 'name', sortable: true },
|
||||
{ accessor: 'type', sortable: true },
|
||||
@@ -76,7 +74,12 @@ export default function FileTable() {
|
||||
{
|
||||
accessor: 'createdAt',
|
||||
sortable: true,
|
||||
render: (file) => new Date(file.createdAt).toLocaleString(),
|
||||
render: (file) => <RelativeDate date={file.createdAt} />,
|
||||
},
|
||||
{
|
||||
accessor: 'favorite',
|
||||
sortable: true,
|
||||
render: (file) => (file.favorite ? 'Yes' : 'No')
|
||||
},
|
||||
{
|
||||
accessor: 'actions',
|
||||
@@ -132,8 +135,8 @@ export default function FileTable() {
|
||||
),
|
||||
},
|
||||
]}
|
||||
fetching={pages.isLoading || statsLoading}
|
||||
totalRecords={stats?.filesUploaded ?? 0}
|
||||
fetching={isLoading}
|
||||
totalRecords={data?.total ?? 0}
|
||||
recordsPerPage={perpage}
|
||||
onRecordsPerPageChange={setPerpage}
|
||||
recordsPerPageOptions={PER_PAGE_OPTIONS}
|
||||
|
||||
@@ -18,13 +18,14 @@ import { useEffect, useState } from 'react';
|
||||
import { useApiPagination } from '../useApiPagination';
|
||||
import { useConfig } from '@/components/ConfigProvider';
|
||||
|
||||
export default function Files() {
|
||||
export default function Files({ id }: { id?: string }) {
|
||||
const router = useRouter();
|
||||
const config = useConfig();
|
||||
|
||||
const [page, setPage] = useState<number>(router.query.page ? parseInt(router.query.page as string) : 1);
|
||||
const { pages, pagesCount } = useApiPagination({
|
||||
const { data, isLoading } = useApiPagination({
|
||||
page,
|
||||
id,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -44,7 +45,7 @@ export default function Files() {
|
||||
<>
|
||||
<SimpleGrid
|
||||
my='sm'
|
||||
cols={pages.data?.length ?? 0 > 0 ? 3 : 1}
|
||||
cols={data?.page?.length ?? 0 > 0 ? 3 : 1}
|
||||
spacing='md'
|
||||
breakpoints={[
|
||||
{ maxWidth: 'sm', cols: 1 },
|
||||
@@ -52,12 +53,12 @@ export default function Files() {
|
||||
]}
|
||||
pos='relative'
|
||||
>
|
||||
{pages.isLoading ? (
|
||||
{isLoading ? (
|
||||
<Paper withBorder h={200}>
|
||||
<LoadingOverlay visible />
|
||||
</Paper>
|
||||
) : pages.data?.length ?? 0 > 0 ? (
|
||||
pages.data?.map((file) => (
|
||||
) : data?.page?.length ?? 0 > 0 ? (
|
||||
data?.page.map((file) => (
|
||||
<DashboardFile
|
||||
disableMediaPreview={config.website.disableMediaPreview}
|
||||
key={file.id}
|
||||
@@ -89,7 +90,7 @@ export default function Files() {
|
||||
</SimpleGrid>
|
||||
|
||||
<Center>
|
||||
<Pagination my='sm' value={page} onChange={setPage} total={pagesCount.data?.count ?? 1} />
|
||||
<Pagination my='sm' value={page} onChange={setPage} total={data?.pages ?? 1} />
|
||||
</Center>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { User } from '@/lib/db/models/user';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { readToDataURL } from '@/lib/readToDataURL';
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
FileInput,
|
||||
Modal,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconPhotoMinus, IconUserCancel, IconUserEdit, IconUserPlus } from '@tabler/icons-react';
|
||||
import { mutate } from 'swr';
|
||||
|
||||
export default function EditUserModal({
|
||||
user,
|
||||
opened,
|
||||
onClose,
|
||||
}: {
|
||||
user: User;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const form = useForm<{
|
||||
username: string;
|
||||
password: string;
|
||||
administrator: boolean;
|
||||
avatar: File | null;
|
||||
}>({
|
||||
initialValues: {
|
||||
username: user.username,
|
||||
password: '',
|
||||
administrator: user.administrator,
|
||||
avatar: null,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (values: typeof form.values) => {
|
||||
let avatar64: string | null = null;
|
||||
if (values.avatar) {
|
||||
if (!values.avatar.type.startsWith('image/')) return form.setFieldError('avatar', 'Invalid file type');
|
||||
|
||||
try {
|
||||
const res = await readToDataURL(values.avatar);
|
||||
avatar64 = res;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
||||
return form.setFieldError('avatar', 'Failed to read avatar file');
|
||||
}
|
||||
}
|
||||
|
||||
const { data, error } = await fetchApi<Response['/api/users/[id]']>(`/api/users/${user.id}`, 'PATCH', {
|
||||
...(values.username !== user.username && { username: values.username }),
|
||||
...(values.password && { password: values.password }),
|
||||
...(values.administrator !== user.administrator && { administrator: values.administrator }),
|
||||
...(avatar64 && { avatar: avatar64 }),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
notifications.show({
|
||||
title: 'Failed to edit user',
|
||||
message: error.message,
|
||||
color: 'red',
|
||||
icon: <IconUserCancel size='1rem' />,
|
||||
});
|
||||
} else {
|
||||
notifications.show({
|
||||
title: 'User edited',
|
||||
message: `User ${data?.username} has been edited`,
|
||||
color: 'blue',
|
||||
icon: <IconUserEdit size='1rem' />,
|
||||
});
|
||||
|
||||
form.reset();
|
||||
onClose();
|
||||
mutate('/api/users?noincl=true');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal centered title={<Title>Edit {user.username}</Title>} onClose={onClose} opened={opened}>
|
||||
<Text size='sm' color='dimmed'>
|
||||
Any fields that are blank will be omitted, and will not be updated.
|
||||
</Text>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Stack spacing='sm'>
|
||||
<TextInput label='Username' placeholder='Enter a username...' {...form.getInputProps('username')} />
|
||||
<PasswordInput
|
||||
label='Password'
|
||||
placeholder='Enter a password...'
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
<FileInput
|
||||
label='Avatar'
|
||||
placeholder='Select an avatar...'
|
||||
rightSection={
|
||||
<Tooltip label='Clear avatar'>
|
||||
<ActionIcon
|
||||
variant='transparent'
|
||||
color='gray'
|
||||
disabled={!form.values.avatar}
|
||||
onClick={() => form.setFieldValue('avatar', null)}
|
||||
>
|
||||
<IconPhotoMinus size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
}
|
||||
{...form.getInputProps('avatar')}
|
||||
/>
|
||||
<Switch
|
||||
label='Administrator'
|
||||
color='red'
|
||||
{...form.getInputProps('administrator', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
variant='outline'
|
||||
color='blue'
|
||||
radius='sm'
|
||||
leftIcon={<IconUserEdit size='1rem' />}
|
||||
>
|
||||
Update user
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { User } from '@/lib/db/models/user';
|
||||
import { ActionIcon, Avatar, Card, Group, Menu, Stack, Text } from '@mantine/core';
|
||||
import { useUserStore } from '@/lib/store/user';
|
||||
import { IconDots, IconTrashFilled, IconUserEdit } from '@tabler/icons-react';
|
||||
import EditUserModal from './EditUserModal';
|
||||
import { useState } from 'react';
|
||||
import { deleteUser } from './actions';
|
||||
import RelativeDate from '@/components/RelativeDate';
|
||||
|
||||
export default function UserCard({ user }: { user: User }) {
|
||||
const currentUser = useUserStore((state) => state.user);
|
||||
|
||||
const [opened, setOpen] = useState(false);
|
||||
|
||||
if (currentUser?.id === user.id) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditUserModal user={user} opened={opened} onClose={() => setOpen(false)} />
|
||||
|
||||
<Card withBorder shadow='sm' radius='sm'>
|
||||
<Card.Section withBorder inheritPadding py='xs'>
|
||||
<Group position='apart'>
|
||||
<Group>
|
||||
<Avatar
|
||||
color={user.administrator ? 'red' : 'gray'}
|
||||
size='md'
|
||||
radius='md'
|
||||
src={user.avatar ?? null}
|
||||
>
|
||||
{user.username[0].toUpperCase()}
|
||||
</Avatar>
|
||||
|
||||
<Stack spacing={1}>
|
||||
<Text weight={400}>{user.username}</Text>
|
||||
<Text size='xs' color='dimmed'>
|
||||
{user.id}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Menu withinPortal position='bottom-end' shadow='sm'>
|
||||
<Menu.Target>
|
||||
<ActionIcon>
|
||||
<IconDots size='1rem' />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
disabled={user.administrator}
|
||||
icon={<IconUserEdit size='1rem' />}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Edit
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
disabled={user.administrator}
|
||||
icon={<IconTrashFilled size='1rem' />}
|
||||
color='red'
|
||||
onClick={() => deleteUser(user)}
|
||||
>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</Card.Section>
|
||||
|
||||
<Card.Section inheritPadding py='xs'>
|
||||
<Stack spacing={1}>
|
||||
<Text size='xs' color='dimmed'>
|
||||
<b>Administrator:</b> {user.administrator ? 'Yes' : 'No'}
|
||||
</Text>
|
||||
<Text size='xs' color='dimmed'>
|
||||
<b>Created:</b> <RelativeDate date={user.createdAt} />
|
||||
</Text>
|
||||
<Text size='xs' color='dimmed'>
|
||||
<b>Updated:</b> <RelativeDate date={user.updatedAt} />
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import GridTableSwitcher from '@/components/GridTableSwitcher';
|
||||
import { User } from '@/lib/db/models/user';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import { ActionIcon, Group, Title, Tooltip } from '@mantine/core';
|
||||
import FileTable from '../files/views/FileTable';
|
||||
import Files from '../files/views/Files';
|
||||
import Link from 'next/link';
|
||||
import { IconArrowBackUp } from '@tabler/icons-react';
|
||||
|
||||
export default function ViewFiles({ user }: { user: User }) {
|
||||
if (!user) return null;
|
||||
|
||||
const view = useSettingsStore((state) => state.view.files);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group>
|
||||
<Title>{user.username}'s files</Title>
|
||||
<Tooltip label='Back to users'>
|
||||
<ActionIcon variant='outline' color='gray' component={Link} href='/dashboard/admin/users'>
|
||||
<IconArrowBackUp size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<GridTableSwitcher type='files' />
|
||||
</Group>
|
||||
|
||||
{view === 'grid' ? <Files id={user.id} /> : <FileTable id={user.id} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { User } from '@/lib/db/models/user';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { Title } from '@mantine/core';
|
||||
import { modals } from '@mantine/modals';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconUserCancel, IconUserMinus } from '@tabler/icons-react';
|
||||
import { mutate } from 'swr';
|
||||
|
||||
export async function deleteUser(user: User) {
|
||||
modals.openConfirmModal({
|
||||
centered: true,
|
||||
title: <Title>Delete {user.username}?</Title>,
|
||||
children: `Are you sure you want to delete ${user.username}? This action cannot be undone.`,
|
||||
labels: {
|
||||
cancel: 'Cancel',
|
||||
confirm: 'Delete',
|
||||
},
|
||||
onConfirm: () =>
|
||||
modals.openConfirmModal({
|
||||
centered: true,
|
||||
title: <Title>Delete {user.username}'s data?</Title>,
|
||||
children: `Would you like to delete ${user.username}'s files and urls? This action cannot be undone.`,
|
||||
labels: {
|
||||
cancel: 'No, keep everything & only delete user',
|
||||
confirm: 'Yes, delete everything',
|
||||
},
|
||||
confirmProps: { color: 'red' },
|
||||
onConfirm: () => handleDeleteUser(user, true),
|
||||
onCancel: () => handleDeleteUser(user, false),
|
||||
}),
|
||||
onCancel: modals.closeAll,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDeleteUser(user: User, deleteFiles: boolean = false) {
|
||||
const { data, error } = await fetchApi<Response['/api/users/[id]']>(`/api/users/${user.id}`, 'DELETE', {
|
||||
delete: deleteFiles,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
notifications.show({
|
||||
title: 'Failed to delete user',
|
||||
message: error.message,
|
||||
color: 'red',
|
||||
icon: <IconUserCancel size='1rem' />,
|
||||
});
|
||||
} else {
|
||||
notifications.show({
|
||||
title: 'User deleted',
|
||||
message: `User ${data?.username} has been deleted`,
|
||||
color: 'blue',
|
||||
icon: <IconUserMinus size='1rem' />,
|
||||
});
|
||||
}
|
||||
|
||||
mutate('/api/users?noincl=true');
|
||||
modals.closeAll();
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import GridTableSwitcher from '@/components/GridTableSwitcher';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { User } from '@/lib/db/models/user';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
FileInput,
|
||||
Group,
|
||||
Modal,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Switch,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { hasLength, useForm } from '@mantine/form';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconPhotoMinus, IconUserCancel, IconUserPlus } from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
import { mutate } from 'swr';
|
||||
import UserGridView from './views/UserGridView';
|
||||
import UserTableView from './views/UserTableView';
|
||||
import { readToDataURL } from '@/lib/readToDataURL';
|
||||
|
||||
export default function DashboardUsers() {
|
||||
const view = useSettingsStore((state) => state.view.users);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const form = useForm<{
|
||||
username: string;
|
||||
password: string;
|
||||
administrator: boolean;
|
||||
avatar: File | null;
|
||||
}>({
|
||||
initialValues: {
|
||||
username: '',
|
||||
password: '',
|
||||
administrator: false,
|
||||
avatar: null,
|
||||
},
|
||||
validate: {
|
||||
username: hasLength({ min: 1 }, 'Username is required'),
|
||||
password: hasLength({ min: 1 }, 'Password is required'),
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (values: typeof form.values) => {
|
||||
let avatar64: string | null = null;
|
||||
if (values.avatar) {
|
||||
if (!values.avatar.type.startsWith('image/')) return form.setFieldError('avatar', 'Invalid file type');
|
||||
|
||||
try {
|
||||
const res = await readToDataURL(values.avatar);
|
||||
avatar64 = res;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
||||
return form.setFieldError('avatar', 'Failed to read avatar file');
|
||||
}
|
||||
}
|
||||
|
||||
const { data, error } = await fetchApi<Extract<Response['/api/users'], User>>('/api/users', 'POST', {
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
administrator: values.administrator ?? false,
|
||||
...(avatar64 ? { avatar: avatar64 } : {}),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
notifications.show({
|
||||
title: 'Failed to create user',
|
||||
message: error.message,
|
||||
color: 'red',
|
||||
icon: <IconUserCancel size='1rem' />,
|
||||
});
|
||||
} else {
|
||||
notifications.show({
|
||||
title: 'User created',
|
||||
message: `User ${data?.username} has been created`,
|
||||
color: 'blue',
|
||||
icon: <IconUserPlus size='1rem' />,
|
||||
});
|
||||
|
||||
form.reset();
|
||||
setOpen(false);
|
||||
mutate('/api/users?noincl=true');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal centered opened={open} onClose={() => setOpen(false)} title={<Title>Create a new user</Title>}>
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Stack spacing='sm'>
|
||||
<TextInput
|
||||
label='Username'
|
||||
placeholder='Enter a username...'
|
||||
{...form.getInputProps('username')}
|
||||
/>
|
||||
<PasswordInput
|
||||
label='Password'
|
||||
placeholder='Enter a password...'
|
||||
{...form.getInputProps('password')}
|
||||
/>
|
||||
<FileInput
|
||||
label='Avatar'
|
||||
placeholder='Select an avatar...'
|
||||
rightSection={
|
||||
<Tooltip label='Clear avatar'>
|
||||
<ActionIcon
|
||||
variant='transparent'
|
||||
color='gray'
|
||||
disabled={!form.values.avatar}
|
||||
onClick={() => form.setFieldValue('avatar', null)}
|
||||
>
|
||||
<IconPhotoMinus size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
}
|
||||
{...form.getInputProps('avatar')}
|
||||
/>
|
||||
<Switch
|
||||
label='Administrator'
|
||||
color='red'
|
||||
{...form.getInputProps('administrator', { type: 'checkbox' })}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
variant='outline'
|
||||
color='blue'
|
||||
radius='sm'
|
||||
leftIcon={<IconUserPlus size='1rem' />}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Group>
|
||||
<Title>Users</Title>
|
||||
|
||||
<Tooltip label='Create a new user'>
|
||||
<ActionIcon variant='outline' color='gray' onClick={() => setOpen(true)}>
|
||||
<IconUserPlus size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<GridTableSwitcher type='users' />
|
||||
</Group>
|
||||
|
||||
{view === 'grid' ? <UserGridView /> : <UserTableView />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { User } from '@/lib/db/models/user';
|
||||
import { Center, Group, LoadingOverlay, Paper, SimpleGrid, Stack, Text, Title } from '@mantine/core';
|
||||
import { IconFilesOff } from '@tabler/icons-react';
|
||||
import useSWR from 'swr';
|
||||
import UserCard from '../UserCard';
|
||||
|
||||
export default function UserGridView() {
|
||||
const { data: users, isLoading } =
|
||||
useSWR<Extract<Response['/api/users'], User[]>>(`/api/users?noincl=true`);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading ? (
|
||||
<Paper withBorder h={200}>
|
||||
<LoadingOverlay visible />
|
||||
</Paper>
|
||||
) : users?.length ?? 0 !== 0 ? (
|
||||
<SimpleGrid
|
||||
my='sm'
|
||||
spacing='md'
|
||||
cols={4}
|
||||
breakpoints={[
|
||||
{ maxWidth: 'sm', cols: 1 },
|
||||
{ maxWidth: 'md', cols: 2 },
|
||||
]}
|
||||
pos='relative'
|
||||
>
|
||||
{users?.map((user) => (
|
||||
<UserCard key={user.id} user={user} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Paper withBorder p='sm' my='sm'>
|
||||
<Center>
|
||||
<Stack>
|
||||
<Group>
|
||||
<IconFilesOff size='2rem' />
|
||||
<Title order={2}>No users found</Title>
|
||||
</Group>
|
||||
<Text size='sm' color='dimmed'>
|
||||
Create a user to see them here
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Paper>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { User } from '@/lib/db/models/user';
|
||||
import { ActionIcon, Avatar, Box, Group, Tooltip } from '@mantine/core';
|
||||
import { IconEdit, IconFiles } from '@tabler/icons-react';
|
||||
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
|
||||
import { useEffect, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import EditUserModal from '../EditUserModal';
|
||||
import Link from 'next/link';
|
||||
import RelativeDate from '@/components/RelativeDate';
|
||||
|
||||
export default function UserTableView() {
|
||||
const { data, isLoading } = useSWR<Extract<Response['/api/users'], User[]>>(`/api/users?noincl=true`);
|
||||
|
||||
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
|
||||
columnAccessor: 'createdAt',
|
||||
direction: 'desc',
|
||||
});
|
||||
const [sorted, setSorted] = useState<User[]>(data ?? []);
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const sorted = data.sort((a, b) => {
|
||||
const cl = sortStatus.columnAccessor as keyof User;
|
||||
|
||||
return sortStatus.direction === 'asc' ? (a[cl]! > b[cl]! ? 1 : -1) : a[cl]! < b[cl]! ? 1 : -1;
|
||||
});
|
||||
|
||||
setSorted(sorted);
|
||||
}
|
||||
}, [sortStatus]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedUser && (
|
||||
<EditUserModal opened={!!selectedUser} onClose={() => setSelectedUser(null)} user={selectedUser} />
|
||||
)}
|
||||
|
||||
<Box my='sm'>
|
||||
<DataTable
|
||||
borderRadius='sm'
|
||||
withBorder
|
||||
minHeight={200}
|
||||
records={sorted ?? []}
|
||||
columns={[
|
||||
{
|
||||
accessor: 'avatar',
|
||||
render: (user) => <Avatar src={user.avatar}>{user.username[0].toUpperCase()}</Avatar>,
|
||||
},
|
||||
{ accessor: 'username', sortable: true },
|
||||
{
|
||||
accessor: 'createdAt',
|
||||
title: 'Created',
|
||||
sortable: true,
|
||||
render: (user) => <RelativeDate date={user.createdAt} />,
|
||||
},
|
||||
{
|
||||
accessor: 'updatedAt',
|
||||
title: 'Last updated',
|
||||
sortable: true,
|
||||
render: (user) => <RelativeDate date={user.updatedAt} />,
|
||||
},
|
||||
{
|
||||
accessor: 'actions',
|
||||
render: (user) => (
|
||||
<Group spacing='sm'>
|
||||
<Tooltip label='Edit user'>
|
||||
<ActionIcon
|
||||
variant='outline'
|
||||
color='gray'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedUser(user);
|
||||
}}
|
||||
>
|
||||
<IconEdit size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="View user's files">
|
||||
<ActionIcon
|
||||
variant='outline'
|
||||
color='gray'
|
||||
component={Link}
|
||||
href={`/dashboard/admin/users/${user.id}/files`}
|
||||
>
|
||||
<IconFiles size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
]}
|
||||
fetching={isLoading}
|
||||
sortStatus={sortStatus}
|
||||
onSortStatusChange={(s) => setSortStatus(s)}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+17
-13
@@ -1,26 +1,30 @@
|
||||
import type { ApiLoginResponse } from '@/pages/api/auth/login';
|
||||
import type { ApiLogoutResponse } from '@/pages/api/auth/logout';
|
||||
|
||||
import type { ApiUserResponse } from '@/pages/api/user';
|
||||
import type { ApiUserRecentResponse } from '@/pages/api/user/recent';
|
||||
import type { ApiUserTokenResponse } from '@/pages/api/user/token';
|
||||
import type { ApiUserFilesIdResponse } from '@/pages/api/user/files/[id]';
|
||||
import type { ApiUserFilesResponse } from '@/pages/api/user/files';
|
||||
import type { ApiUserStatsResponse } from '@/pages/api/user/stats';
|
||||
|
||||
import type { ApiHealthcheckResponse } from '@/pages/api/healthcheck';
|
||||
import type { ApiSetupResponse } from '@/pages/api/setup';
|
||||
import type { ApiUploadResponse } from '@/pages/api/upload';
|
||||
import { ApiLoginResponse } from '@/pages/api/auth/login';
|
||||
import { ApiLogoutResponse } from '@/pages/api/auth/logout';
|
||||
import { ApiHealthcheckResponse } from '@/pages/api/healthcheck';
|
||||
import { ApiSetupResponse } from '@/pages/api/setup';
|
||||
import { ApiUploadResponse } from '@/pages/api/upload';
|
||||
import { ApiUserResponse } from '@/pages/api/user';
|
||||
import { ApiUserFilesResponse } from '@/pages/api/user/files';
|
||||
import { ApiUserFilesIdResponse } from '@/pages/api/user/files/[id]';
|
||||
import { ApiUserFilesIdPasswordResponse } from '@/pages/api/user/files/[id]/password';
|
||||
import { ApiUserRecentResponse } from '@/pages/api/user/recent';
|
||||
import { ApiUserStatsResponse } from '@/pages/api/user/stats';
|
||||
import { ApiUserTokenResponse } from '@/pages/api/user/token';
|
||||
import { ApiUsersResponse } from '@/pages/api/users';
|
||||
import { ApiUsersIdResponse } from '@/pages/api/users/[id]';
|
||||
|
||||
export type Response = {
|
||||
'/api/auth/login': ApiLoginResponse;
|
||||
'/api/auth/logout': ApiLogoutResponse;
|
||||
'/api/user/files/[id]/password': ApiUserFilesIdPasswordResponse;
|
||||
'/api/user/files/[id]': ApiUserFilesIdResponse;
|
||||
'/api/user/files': ApiUserFilesResponse;
|
||||
'/api/user': ApiUserResponse;
|
||||
'/api/user/stats': ApiUserStatsResponse;
|
||||
'/api/user/recent': ApiUserRecentResponse;
|
||||
'/api/user/token': ApiUserTokenResponse;
|
||||
'/api/users': ApiUsersResponse;
|
||||
'/api/users/[id]': ApiUsersIdResponse;
|
||||
'/api/healthcheck': ApiHealthcheckResponse;
|
||||
'/api/setup': ApiSetupResponse;
|
||||
'/api/upload': ApiUploadResponse;
|
||||
|
||||
@@ -6,12 +6,23 @@ export function withSafeConfig<T = {}>(
|
||||
): GetServerSideProps<
|
||||
T & {
|
||||
config: SafeConfig;
|
||||
notFound?: boolean;
|
||||
}
|
||||
> {
|
||||
return async (ctx) => {
|
||||
const config = safeConfig();
|
||||
const data = await fn(ctx);
|
||||
|
||||
return { props: { ...data, config } };
|
||||
if ((data as any) && (data as any).notFound)
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
|
||||
return {
|
||||
props: {
|
||||
config,
|
||||
...data,
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export async function readToDataURL(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import dayjs from "dayjs";
|
||||
import dayJsrelativeTime from "dayjs/plugin/relativeTime";
|
||||
dayjs.extend(dayJsrelativeTime);
|
||||
|
||||
export function relativeTime(to: Date, from: Date = new Date()) {
|
||||
if (!to) return null;
|
||||
|
||||
if (to.getTime() < from.getTime()) {
|
||||
return dayjs(to).from(from);
|
||||
} else {
|
||||
return dayjs(from).to(to);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,9 @@
|
||||
import { verifyPassword } from '@/lib/crypto';
|
||||
import { datasource } from '@/lib/datasource';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { File, fileSelect } from '@/lib/db/models/file';
|
||||
import { log } from '@/lib/logger';
|
||||
import { combine } from '@/lib/middleware/combine';
|
||||
import { method } from '@/lib/middleware/method';
|
||||
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
|
||||
import { NextApiReq, NextApiRes } from '@/lib/response';
|
||||
import bytes from 'bytes';
|
||||
|
||||
export type ApiUserFilesIdPasswordResponse = {
|
||||
success: boolean;
|
||||
@@ -25,6 +21,7 @@ export async function handler(req: NextApiReq<Body>, res: NextApiRes<ApiUserFile
|
||||
OR: [{ id: req.query.id }, { name: req.query.id }],
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
password: true,
|
||||
},
|
||||
});
|
||||
@@ -34,6 +31,8 @@ export async function handler(req: NextApiReq<Body>, res: NextApiRes<ApiUserFile
|
||||
const verified = await verifyPassword(req.body.password, file.password);
|
||||
if (!verified) return res.forbidden('Incorrect password');
|
||||
|
||||
logger.info(`${file.name} was accessed with the correct password`, { ua: req.headers['user-agent'] });
|
||||
|
||||
return res.ok({ success: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -7,23 +7,20 @@ import { NextApiReq, NextApiRes } from '@/lib/response';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
export type ApiUserFilesResponse =
|
||||
| File[]
|
||||
| {
|
||||
count: number;
|
||||
}
|
||||
| {
|
||||
totalCount: number;
|
||||
};
|
||||
export type ApiUserFilesResponse = {
|
||||
page: File[];
|
||||
total: number;
|
||||
pages: number;
|
||||
};
|
||||
|
||||
type Query = {
|
||||
page?: string;
|
||||
perpage?: string;
|
||||
pagecount?: string;
|
||||
filter?: 'dashboard' | 'none' | 'all';
|
||||
favorite?: 'true' | 'false';
|
||||
sortBy: keyof Prisma.FileOrderByWithRelationInput;
|
||||
order: 'asc' | 'desc';
|
||||
id?: string;
|
||||
};
|
||||
|
||||
const validateSortBy = z
|
||||
@@ -44,18 +41,23 @@ const validateSortBy = z
|
||||
const validateOrder = z.enum(['asc', 'desc']).default('desc');
|
||||
|
||||
export async function handler(req: NextApiReq<any, Query>, res: NextApiRes<ApiUserFilesResponse>) {
|
||||
if (req.query.id && !req.user.administrator) return res.forbidden("You cannot view another user's files");
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: req.query.id ?? req.user.id,
|
||||
},
|
||||
});
|
||||
if (!user) return res.notFound('User not found');
|
||||
|
||||
const perpage = Number(req.query.perpage || '9');
|
||||
if (isNaN(Number(perpage))) return res.badRequest('Perpage must be a number');
|
||||
|
||||
if (req.query.pagecount) {
|
||||
const count = await prisma.file.count({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
return res.ok({ count: Math.ceil(count / perpage) });
|
||||
}
|
||||
const count = await prisma.file.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
const { page, filter, favorite } = req.query;
|
||||
if (!page) return res.badRequest('Page is required');
|
||||
@@ -70,7 +72,7 @@ export async function handler(req: NextApiReq<any, Query>, res: NextApiRes<ApiUs
|
||||
const files = cleanFiles(
|
||||
await prisma.file.findMany({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
userId: user.id,
|
||||
...(filter === 'dashboard' && {
|
||||
OR: [
|
||||
{
|
||||
@@ -104,7 +106,11 @@ export async function handler(req: NextApiReq<any, Query>, res: NextApiRes<ApiUs
|
||||
})
|
||||
);
|
||||
|
||||
return res.ok(files);
|
||||
return res.ok({
|
||||
page: files,
|
||||
total: count,
|
||||
pages: Math.ceil(count / perpage),
|
||||
});
|
||||
}
|
||||
|
||||
export default combine([method(['GET']), ziplineAuth()], handler);
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { hashPassword } from '@/lib/crypto';
|
||||
import { datasource } from '@/lib/datasource';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { User, userSelect } from '@/lib/db/models/user';
|
||||
import { log } from '@/lib/logger';
|
||||
import { combine } from '@/lib/middleware/combine';
|
||||
import { method } from '@/lib/middleware/method';
|
||||
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
|
||||
import { NextApiReq, NextApiRes } from '@/lib/response';
|
||||
|
||||
export type ApiUsersIdResponse = User;
|
||||
|
||||
type Body = {
|
||||
username?: string;
|
||||
password?: string;
|
||||
avatar?: string;
|
||||
administrator?: boolean;
|
||||
|
||||
delete?: boolean;
|
||||
};
|
||||
|
||||
type Query = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const logger = log('api').c('users').c('[id]');
|
||||
|
||||
export async function handler(req: NextApiReq<Body, Query>, res: NextApiRes<ApiUsersIdResponse>) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: req.query.id,
|
||||
},
|
||||
select: userSelect,
|
||||
});
|
||||
if (!user) return res.notFound('User not found');
|
||||
|
||||
if (req.method === 'PATCH') {
|
||||
const { username, password, avatar, administrator } = req.body;
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
...(username && { username }),
|
||||
...(password && { password: await hashPassword(password) }),
|
||||
...(administrator !== undefined && { administrator }),
|
||||
...(avatar && { avatar }),
|
||||
},
|
||||
select: userSelect,
|
||||
});
|
||||
|
||||
logger.info(`${req.user.username} updated another user`, {
|
||||
username: updatedUser.username,
|
||||
administrator: updatedUser.administrator,
|
||||
});
|
||||
|
||||
return res.ok(updatedUser);
|
||||
} else if (req.method === 'DELETE') {
|
||||
if (user.id === req.user.id) return res.forbidden('You cannot delete yourself');
|
||||
|
||||
if (req.body.delete) {
|
||||
const files = await prisma.file.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
const [{ count: filesDeleted }, { count: urlsDeleted }] = await prisma.$transaction([
|
||||
prisma.file.deleteMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
}),
|
||||
prisma.url.deleteMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
logger.debug(`preparing to delete ${files.length} files from datasource`, {
|
||||
username: user.username,
|
||||
});
|
||||
|
||||
for (let i = 0; i !== files.length; ++i) {
|
||||
await datasource.delete(files[i].name);
|
||||
}
|
||||
|
||||
logger.info(`${req.user.username} deleted another user's files & urls`, {
|
||||
username: user.username,
|
||||
deletedFiles: filesDeleted,
|
||||
deletedUrls: urlsDeleted,
|
||||
});
|
||||
}
|
||||
|
||||
const deletedUser = await prisma.user.delete({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
select: userSelect,
|
||||
});
|
||||
|
||||
logger.info(`${req.user.username} deleted another user`, {
|
||||
username: deletedUser.username,
|
||||
administrator: deletedUser.administrator,
|
||||
});
|
||||
|
||||
return res.ok(deletedUser);
|
||||
}
|
||||
|
||||
return res.ok(user);
|
||||
}
|
||||
|
||||
export default combine(
|
||||
[method(['GET', 'PATCH', 'DELETE']), ziplineAuth({ administratorOnly: true })],
|
||||
handler
|
||||
);
|
||||
@@ -9,7 +9,11 @@ import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
|
||||
import { NextApiReq, NextApiRes } from '@/lib/response';
|
||||
import { readFile } from 'fs/promises';
|
||||
|
||||
export type ApiUsersIdResponse = User[] | User;
|
||||
export type ApiUsersResponse = User[] | User;
|
||||
|
||||
type Query = {
|
||||
noincl?: 'true' | 'false';
|
||||
};
|
||||
|
||||
type Body = {
|
||||
username?: string;
|
||||
@@ -18,7 +22,9 @@ type Body = {
|
||||
administrator?: boolean;
|
||||
};
|
||||
|
||||
export async function handler(req: NextApiReq<Body>, res: NextApiRes<ApiUsersIdResponse>) {
|
||||
const logger = log('api').c('users');
|
||||
|
||||
export async function handler(req: NextApiReq<Body, Query>, res: NextApiRes<ApiUsersResponse>) {
|
||||
if (req.method === 'POST') {
|
||||
const { username, password, avatar, administrator } = req.body;
|
||||
|
||||
@@ -27,10 +33,14 @@ export async function handler(req: NextApiReq<Body>, res: NextApiRes<ApiUsersIdR
|
||||
|
||||
let avatar64 = null;
|
||||
|
||||
if (config.website.defaultAvatar) {
|
||||
avatar64 = (await readFile(config.website.defaultAvatar)).toString('base64');
|
||||
} else if (avatar) {
|
||||
avatar64 = avatar;
|
||||
try {
|
||||
if (config.website.defaultAvatar) {
|
||||
avatar64 = (await readFile(config.website.defaultAvatar)).toString('base64');
|
||||
} else if (avatar) {
|
||||
avatar64 = avatar;
|
||||
}
|
||||
} catch {
|
||||
logger.debug('failed to read default avatar', { path: config.website.defaultAvatar });
|
||||
}
|
||||
|
||||
const user = await prisma.user.create({
|
||||
@@ -44,11 +54,22 @@ export async function handler(req: NextApiReq<Body>, res: NextApiRes<ApiUsersIdR
|
||||
select: userSelect,
|
||||
});
|
||||
|
||||
logger.info(`${req.user.username} created a new user`, {
|
||||
username: user.username,
|
||||
administrator: user.administrator,
|
||||
});
|
||||
|
||||
return res.ok(user);
|
||||
}
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
select: userSelect,
|
||||
select: {
|
||||
...userSelect,
|
||||
avatar: true,
|
||||
},
|
||||
where: {
|
||||
...(req.query.noincl === 'true' && { id: { not: req.user.id } }),
|
||||
},
|
||||
});
|
||||
|
||||
return res.ok(users);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import Layout from '@/components/Layout';
|
||||
import ViewFiles from '@/components/pages/users/ViewUserFiles';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { User, userSelect } from '@/lib/db/models/user';
|
||||
import useLogin from '@/lib/hooks/useLogin';
|
||||
import { withSafeConfig } from '@/lib/middleware/next/withSafeConfig';
|
||||
import { LoadingOverlay } from '@mantine/core';
|
||||
import { InferGetServerSidePropsType } from 'next';
|
||||
|
||||
export default function DashboardIndex({
|
||||
user,
|
||||
config,
|
||||
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
||||
const { loading } = useLogin();
|
||||
if (loading) return <LoadingOverlay visible />;
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<Layout config={config}>
|
||||
<ViewFiles user={user} />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export const getServerSideProps = withSafeConfig(async (ctx) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: ctx.query.id as string,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
},
|
||||
});
|
||||
// if (!user) return { notFound: true };
|
||||
// if (!user.administrator) return { notFound: true };
|
||||
|
||||
return {
|
||||
user: user as User,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import Layout from '@/components/Layout';
|
||||
import DashboardUsers from '@/components/pages/users';
|
||||
import useLogin from '@/lib/hooks/useLogin';
|
||||
import { withSafeConfig } from '@/lib/middleware/next/withSafeConfig';
|
||||
import { LoadingOverlay } from '@mantine/core';
|
||||
import { InferGetServerSidePropsType } from 'next';
|
||||
|
||||
export default function DashboardIndex({ config }: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
||||
const { loading } = useLogin();
|
||||
if (loading) return <LoadingOverlay visible />;
|
||||
|
||||
return (
|
||||
<Layout config={config}>
|
||||
<DashboardUsers />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export const getServerSideProps = withSafeConfig();
|
||||
Reference in New Issue
Block a user