feat: view users & their files

This commit is contained in:
diced
2023-07-09 21:33:07 -07:00
parent 30a861de07
commit 48d04fffb7
23 changed files with 978 additions and 98 deletions
+1
View File
@@ -7,6 +7,7 @@ export default function GridTableSwitcher({ type }: { type: keyof SettingsStore[
return (
<SegmentedControl
sx={{ marginLeft: 'auto' }}
size='xs'
data={[
{
+19
View File
@@ -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>
+19 -16
View File
@@ -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}
+8 -7
View File
@@ -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>
);
}
+86
View File
@@ -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}&apos;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} />}
</>
);
}
+59
View File
@@ -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();
}
+159
View File
@@ -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
View File
@@ -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;
+12 -1
View File
@@ -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,
},
};
};
}
+8
View File
@@ -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);
});
}
+13
View 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);
}
}
+3 -4
View File
@@ -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 });
}
+26 -20
View File
@@ -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);
+121
View File
@@ -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
);
+28 -7
View File
@@ -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,
};
});
+19
View File
@@ -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();