mirror of
https://github.com/diced/zipline.git
synced 2025-12-12 15:50:11 -08:00
609 lines
17 KiB
TypeScript
609 lines
17 KiB
TypeScript
import {
|
|
Anchor,
|
|
Box,
|
|
Button,
|
|
Card,
|
|
ColorInput,
|
|
FileInput,
|
|
Group,
|
|
Image,
|
|
PasswordInput,
|
|
SimpleGrid,
|
|
Space,
|
|
Text,
|
|
TextInput,
|
|
Title,
|
|
Tooltip,
|
|
} from '@mantine/core';
|
|
import { useForm } from '@mantine/form';
|
|
import { randomId, useInterval, useMediaQuery } from '@mantine/hooks';
|
|
import { useModals } from '@mantine/modals';
|
|
import { showNotification, updateNotification } from '@mantine/notifications';
|
|
import {
|
|
CheckIcon,
|
|
CrossIcon,
|
|
DeleteIcon,
|
|
DiscordIcon,
|
|
FlameshotIcon,
|
|
GitHubIcon,
|
|
GoogleIcon,
|
|
RefreshIcon,
|
|
SettingsIcon,
|
|
ShareXIcon,
|
|
} from 'components/icons';
|
|
import DownloadIcon from 'components/icons/DownloadIcon';
|
|
import TrashIcon from 'components/icons/TrashIcon';
|
|
import Link from 'components/Link';
|
|
import MutedText from 'components/MutedText';
|
|
import { SmallTable } from 'components/SmallTable';
|
|
import useFetch from 'hooks/useFetch';
|
|
import { userSelector } from 'lib/recoil/user';
|
|
import { bytesToHuman } from 'lib/utils/bytes';
|
|
import { capitalize } from 'lib/utils/client';
|
|
import { useEffect, useState } from 'react';
|
|
import { useRecoilState } from 'recoil';
|
|
import ClearStorage from './ClearStorage';
|
|
import Flameshot from './Flameshot';
|
|
import ShareX from './ShareX';
|
|
import { TotpModal } from './TotpModal';
|
|
|
|
function ExportDataTooltip({ children }) {
|
|
return (
|
|
<Tooltip
|
|
position='top'
|
|
color=''
|
|
label='After clicking, if you have a lot of files the export can take a while to complete. A list of previous exports will be below to download.'
|
|
>
|
|
{children}
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
export default function Manage({ oauth_registration, oauth_providers: raw_oauth_providers, totp_enabled }) {
|
|
const oauth_providers = JSON.parse(raw_oauth_providers);
|
|
const icons = {
|
|
Discord: DiscordIcon,
|
|
GitHub: GitHubIcon,
|
|
Google: GoogleIcon,
|
|
};
|
|
|
|
for (const provider of oauth_providers) {
|
|
provider.Icon = icons[provider.name];
|
|
}
|
|
|
|
const [user, setUser] = useRecoilState(userSelector);
|
|
const modals = useModals();
|
|
|
|
const [totpOpen, setTotpOpen] = useState(false);
|
|
const [shareXOpen, setShareXOpen] = useState(false);
|
|
const [flameshotOpen, setFlameshotOpen] = useState(false);
|
|
const [clrStorOpen, setClrStorOpen] = useState(false);
|
|
const [exports, setExports] = useState([]);
|
|
const [file, setFile] = useState<File>(null);
|
|
const [fileDataURL, setFileDataURL] = useState(user.avatar ?? null);
|
|
const [totpEnabled, setTotpEnabled] = useState(!!user.totpSecret);
|
|
const [checked, setCheck] = useState(false);
|
|
|
|
const getDataURL = (f: File): Promise<string> => {
|
|
return new Promise((res, rej) => {
|
|
const reader = new FileReader();
|
|
|
|
reader.addEventListener('load', () => {
|
|
res(reader.result as string);
|
|
});
|
|
|
|
reader.addEventListener('error', () => {
|
|
rej(reader.error);
|
|
});
|
|
|
|
reader.readAsDataURL(f);
|
|
});
|
|
};
|
|
|
|
const handleAvatarChange = async (file: File) => {
|
|
setFile(file);
|
|
|
|
setFileDataURL(await getDataURL(file));
|
|
};
|
|
|
|
const saveAvatar = async () => {
|
|
const dataURL = await getDataURL(file);
|
|
|
|
showNotification({
|
|
id: 'update-user',
|
|
title: 'Updating user...',
|
|
message: '',
|
|
loading: true,
|
|
autoClose: false,
|
|
});
|
|
|
|
const newUser = await useFetch('/api/user', 'PATCH', {
|
|
avatar: dataURL,
|
|
});
|
|
|
|
if (newUser.error) {
|
|
updateNotification({
|
|
id: 'update-user',
|
|
title: "Couldn't save user",
|
|
message: newUser.error,
|
|
color: 'red',
|
|
icon: <CrossIcon />,
|
|
});
|
|
} else {
|
|
setUser(newUser);
|
|
updateNotification({
|
|
id: 'update-user',
|
|
title: 'Saved User',
|
|
message: '',
|
|
});
|
|
}
|
|
};
|
|
|
|
const form = useForm({
|
|
initialValues: {
|
|
username: user.username,
|
|
password: '',
|
|
embedTitle: user.embed?.title ?? null,
|
|
embedColor: user.embed?.color ?? '',
|
|
embedSiteName: user.embed?.siteName ?? null,
|
|
embedDescription: user.embed?.description ?? null,
|
|
domains: user.domains.join(','),
|
|
},
|
|
});
|
|
|
|
const onSubmit = async (values) => {
|
|
const cleanUsername = values.username.trim();
|
|
const cleanPassword = values.password.trim();
|
|
const cleanEmbed = {
|
|
title: values.embedTitle ? values.embedTitle.trim() : null,
|
|
color: values.embedColor !== '' ? values.embedColor.trim() : null,
|
|
siteName: values.embedSiteName ? values.embedSiteName.trim() : null,
|
|
description: values.embedDescription ? values.embedDescription.trim() : null,
|
|
};
|
|
|
|
if (cleanUsername === '') return form.setFieldError('username', "Username can't be nothing");
|
|
|
|
showNotification({
|
|
id: 'update-user',
|
|
title: 'Updating user...',
|
|
message: '',
|
|
loading: true,
|
|
autoClose: false,
|
|
});
|
|
|
|
const data = {
|
|
username: cleanUsername,
|
|
password: cleanPassword === '' ? null : cleanPassword,
|
|
domains: values.domains
|
|
.split(/\s?,\s?/)
|
|
.map((x) => x.trim())
|
|
.filter((x) => x !== ''),
|
|
embed: cleanEmbed,
|
|
};
|
|
|
|
const newUser = await useFetch('/api/user', 'PATCH', data);
|
|
|
|
if (newUser.error) {
|
|
if (newUser.invalidDomains) {
|
|
updateNotification({
|
|
id: 'update-user',
|
|
message: (
|
|
<>
|
|
<Text mt='xs'>The following domains are invalid:</Text>
|
|
{newUser.invalidDomains.map((err) => (
|
|
<>
|
|
<Text color='gray' key={randomId()}>
|
|
{err.domain}: {err.reason}
|
|
</Text>
|
|
<Space h='md' />
|
|
</>
|
|
))}
|
|
</>
|
|
),
|
|
color: 'red',
|
|
icon: <CrossIcon />,
|
|
});
|
|
}
|
|
updateNotification({
|
|
id: 'update-user',
|
|
title: "Couldn't save user",
|
|
message: newUser.error,
|
|
color: 'red',
|
|
icon: <CrossIcon />,
|
|
});
|
|
} else {
|
|
setUser(newUser);
|
|
updateNotification({
|
|
id: 'update-user',
|
|
title: 'Saved User',
|
|
message: '',
|
|
});
|
|
}
|
|
};
|
|
|
|
const exportData = async () => {
|
|
const res = await useFetch('/api/user/export', 'POST');
|
|
if (res.url) {
|
|
showNotification({
|
|
title: 'Export started...',
|
|
loading: true,
|
|
message:
|
|
'If you have a lot of files, the export may take a while. The list of exports will be updated every 30s.',
|
|
});
|
|
} else {
|
|
showNotification({
|
|
title: 'Error exporting data',
|
|
message: res.error,
|
|
color: 'red',
|
|
icon: <CrossIcon />,
|
|
});
|
|
}
|
|
};
|
|
|
|
const getExports = async () => {
|
|
const res = await useFetch('/api/user/export');
|
|
|
|
setExports(
|
|
res.exports
|
|
?.map((s) => ({
|
|
date: new Date(Number(s.name.split('_')[3].slice(0, -4))),
|
|
size: s.size,
|
|
full: s.name,
|
|
}))
|
|
.sort((a, b) => a.date.getTime() - b.date.getTime())
|
|
);
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
const res = await useFetch('/api/user/files', 'DELETE', {
|
|
all: true,
|
|
});
|
|
|
|
if (!res.count) {
|
|
showNotification({
|
|
title: "Couldn't delete files",
|
|
message: res.error,
|
|
color: 'red',
|
|
icon: <CrossIcon />,
|
|
});
|
|
} else {
|
|
showNotification({
|
|
title: 'Deleted files',
|
|
message: `${res.count} files deleted`,
|
|
color: 'green',
|
|
icon: <DeleteIcon />,
|
|
});
|
|
}
|
|
};
|
|
|
|
const openDeleteModal = () =>
|
|
modals.openConfirmModal({
|
|
title: 'Are you sure you want to delete all of your files?',
|
|
closeOnConfirm: false,
|
|
labels: { confirm: 'Yes', cancel: 'No' },
|
|
onConfirm: () => {
|
|
modals.openConfirmModal({
|
|
title: 'Are you really sure?',
|
|
labels: { confirm: 'Yes', cancel: 'No' },
|
|
onConfirm: () => {
|
|
handleDelete();
|
|
modals.closeAll();
|
|
},
|
|
onCancel: () => {
|
|
modals.closeAll();
|
|
},
|
|
});
|
|
},
|
|
});
|
|
|
|
const forceUpdateStats = async () => {
|
|
const res = await useFetch('/api/stats', 'POST');
|
|
if (res.error) {
|
|
showNotification({
|
|
title: 'Error updating stats',
|
|
message: res.error,
|
|
color: 'red',
|
|
icon: <CrossIcon />,
|
|
});
|
|
} else {
|
|
showNotification({
|
|
title: 'Updated stats',
|
|
message: '',
|
|
color: 'green',
|
|
icon: <CheckIcon />,
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleOauthUnlink = async (provider) => {
|
|
const res = await useFetch('/api/auth/oauth', 'DELETE', {
|
|
provider,
|
|
});
|
|
if (res.error) {
|
|
showNotification({
|
|
title: 'Error while unlinking from OAuth',
|
|
message: res.error,
|
|
color: 'red',
|
|
icon: <CrossIcon />,
|
|
});
|
|
} else {
|
|
setUser(res);
|
|
showNotification({
|
|
title: `Unlinked from ${provider[0] + provider.slice(1).toLowerCase()}`,
|
|
message: '',
|
|
color: 'green',
|
|
icon: <CheckIcon />,
|
|
});
|
|
}
|
|
};
|
|
|
|
const interval = useInterval(() => getExports(), 30000);
|
|
useEffect(() => {
|
|
getExports();
|
|
interval.start();
|
|
}, []);
|
|
|
|
return (
|
|
<>
|
|
<Title>Manage User</Title>
|
|
<MutedText size='md'>
|
|
Want to use variables in embed text? Visit{' '}
|
|
<Link href='https://zipline.diced.tech/docs/guides/variables'>the docs</Link> for variables
|
|
</MutedText>
|
|
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
|
|
<TextInput id='username' label='Username' my='sm' {...form.getInputProps('username')} />
|
|
<PasswordInput
|
|
id='password'
|
|
label='Password'
|
|
description='Leave blank to keep your old password'
|
|
my='sm'
|
|
{...form.getInputProps('password')}
|
|
/>
|
|
|
|
<SimpleGrid
|
|
cols={4}
|
|
breakpoints={[
|
|
{ maxWidth: 768, cols: 1 },
|
|
{ minWidth: 769, maxWidth: 1024, cols: 2 },
|
|
{ minWidth: 1281, cols: 4 },
|
|
]}
|
|
>
|
|
<TextInput id='embedTitle' label='Embed Title' my='sm' {...form.getInputProps('embedTitle')} />
|
|
<ColorInput id='embedColor' label='Embed Color' my='sm' {...form.getInputProps('embedColor')} />
|
|
<TextInput
|
|
id='embedSiteName'
|
|
label='Embed Site Name'
|
|
my='sm'
|
|
{...form.getInputProps('embedSiteName')}
|
|
/>
|
|
<TextInput
|
|
id='embedDescription'
|
|
label='Embed Description'
|
|
my='sm'
|
|
{...form.getInputProps('embedDescription')}
|
|
/>
|
|
</SimpleGrid>
|
|
|
|
<TextInput
|
|
id='domains'
|
|
label='Domains'
|
|
description='A list of domains separated by commas. These domains will be used to randomly output a domain when uploading. This is optional.'
|
|
placeholder='https://example.com, https://example2.com'
|
|
my='sm'
|
|
{...form.getInputProps('domains')}
|
|
/>
|
|
|
|
<Group position='right' mt='md'>
|
|
<Button
|
|
type='submit'
|
|
size='lg'
|
|
my='sm'
|
|
sx={{
|
|
'@media screen and (max-width: 768px)': {
|
|
width: '100%',
|
|
},
|
|
}}
|
|
>
|
|
Save
|
|
</Button>
|
|
</Group>
|
|
</form>
|
|
|
|
{totp_enabled && (
|
|
<Box my='md'>
|
|
<Title>Two Factor Authentication</Title>
|
|
<MutedText size='md'>
|
|
{totpEnabled
|
|
? 'You have two factor authentication enabled.'
|
|
: 'You do not have two factor authentication enabled.'}
|
|
</MutedText>
|
|
|
|
<Button
|
|
size='lg'
|
|
my='sm'
|
|
onClick={() => setTotpOpen(true)}
|
|
sx={{
|
|
'@media screen and (max-width: 768px)': {
|
|
width: '100%',
|
|
},
|
|
}}
|
|
>
|
|
{totpEnabled ? 'Disable' : 'Enable'} Two Factor Authentication
|
|
</Button>
|
|
|
|
<TotpModal
|
|
opened={totpOpen}
|
|
onClose={() => setTotpOpen(false)}
|
|
deleteTotp={totpEnabled}
|
|
setTotpEnabled={setTotpEnabled}
|
|
/>
|
|
</Box>
|
|
)}
|
|
|
|
{oauth_registration && (
|
|
<Box my='md'>
|
|
<Title>OAuth</Title>
|
|
<MutedText size='md'>Link your account with an OAuth provider.</MutedText>
|
|
|
|
<Group>
|
|
{oauth_providers
|
|
.filter(
|
|
(x) =>
|
|
!user.oauth?.map(({ provider }) => provider.toLowerCase()).includes(x.name.toLowerCase())
|
|
)
|
|
.map(({ link_url, name, Icon }, i) => (
|
|
<Link key={i} href={link_url} passHref legacyBehavior>
|
|
<Button size='lg' leftIcon={<Icon colorScheme='manage' />} component='a' my='sm'>
|
|
Link account with {name}
|
|
</Button>
|
|
</Link>
|
|
))}
|
|
|
|
{user?.oauth?.map(({ provider }, i) => (
|
|
<Button
|
|
key={i}
|
|
onClick={() => handleOauthUnlink(provider)}
|
|
size='lg'
|
|
leftIcon={<TrashIcon />}
|
|
my='sm'
|
|
color='red'
|
|
>
|
|
Unlink account with {capitalize(provider)}
|
|
</Button>
|
|
))}
|
|
</Group>
|
|
</Box>
|
|
)}
|
|
|
|
<Box my='md'>
|
|
<Title>Avatar</Title>
|
|
<FileInput
|
|
placeholder='Click to upload a file'
|
|
id='file'
|
|
description='Add a custom avatar or leave blank for none'
|
|
accept='image/png,image/jpeg,image/gif'
|
|
value={file}
|
|
onChange={handleAvatarChange}
|
|
/>
|
|
<Card mt='md'>
|
|
<Text>Preview:</Text>
|
|
<Button
|
|
leftIcon={fileDataURL ? <Image src={fileDataURL} height={32} radius='md' /> : <SettingsIcon />}
|
|
size='xl'
|
|
p='sm'
|
|
variant='subtle'
|
|
color='gray'
|
|
compact
|
|
>
|
|
{user.username}
|
|
</Button>
|
|
</Card>
|
|
|
|
<Group position='right' my='md' grow={useMediaQuery('(max-width: 768px)')}>
|
|
<Button
|
|
onClick={() => {
|
|
setFile(null);
|
|
setFileDataURL(null);
|
|
}}
|
|
color='red'
|
|
>
|
|
Reset
|
|
</Button>
|
|
<Button onClick={saveAvatar}>Save Avatar</Button>
|
|
</Group>
|
|
</Box>
|
|
|
|
<Box my='md'>
|
|
<Title>Manage Data</Title>
|
|
<MutedText size='md'>Delete, or export your data into a zip file.</MutedText>
|
|
</Box>
|
|
|
|
<Group my='md' grow={useMediaQuery('(max-width: 768px)')}>
|
|
<Button onClick={openDeleteModal} rightIcon={<DeleteIcon />} color='red'>
|
|
Delete All Data
|
|
</Button>
|
|
<ExportDataTooltip>
|
|
<Button onClick={exportData} rightIcon={<DownloadIcon />}>
|
|
Export Data
|
|
</Button>
|
|
</ExportDataTooltip>
|
|
<Button onClick={getExports} rightIcon={<RefreshIcon />}>
|
|
Refresh
|
|
</Button>
|
|
</Group>
|
|
<Card mt='md'>
|
|
{exports && exports.length ? (
|
|
<SmallTable
|
|
columns={[
|
|
{ id: 'name', name: 'Name' },
|
|
{ id: 'date', name: 'Date' },
|
|
{ id: 'size', name: 'Size' },
|
|
]}
|
|
rows={
|
|
exports
|
|
? exports.map((x, i) => ({
|
|
name: (
|
|
<Anchor target='_blank' href={'/api/user/export?name=' + x.full}>
|
|
Export {i + 1}
|
|
</Anchor>
|
|
),
|
|
date: x.date.toLocaleString(),
|
|
size: bytesToHuman(x.size),
|
|
}))
|
|
: []
|
|
}
|
|
/>
|
|
) : (
|
|
<Text>No exports yet</Text>
|
|
)}
|
|
</Card>
|
|
|
|
{user.administrator && (
|
|
<Box mt='md'>
|
|
<Title>Server</Title>
|
|
<Group my='md' grow={useMediaQuery('(max-width: 768px)')}>
|
|
<Button size='md' onClick={forceUpdateStats} color='red' rightIcon={<RefreshIcon />}>
|
|
Force Update Stats
|
|
</Button>
|
|
<Button size='md' onClick={() => setClrStorOpen(true)} color='red' rightIcon={<TrashIcon />}>
|
|
Delete all uploads
|
|
</Button>
|
|
</Group>
|
|
</Box>
|
|
)}
|
|
|
|
<Title my='md'>Uploaders</Title>
|
|
<Group>
|
|
<Button
|
|
size='xl'
|
|
onClick={() => setShareXOpen(true)}
|
|
rightIcon={<ShareXIcon />}
|
|
sx={{
|
|
'@media screen and (max-width: 768px)': {
|
|
width: '100%',
|
|
},
|
|
}}
|
|
>
|
|
Generate ShareX Config
|
|
</Button>
|
|
<Button
|
|
size='xl'
|
|
onClick={() => setFlameshotOpen(true)}
|
|
rightIcon={<FlameshotIcon />}
|
|
sx={{
|
|
'@media screen and (max-width: 768px)': {
|
|
width: '100%',
|
|
},
|
|
}}
|
|
>
|
|
Generate Flameshot Script
|
|
</Button>
|
|
</Group>
|
|
|
|
<ShareX user={user} open={shareXOpen} setOpen={setShareXOpen} />
|
|
<Flameshot user={user} open={flameshotOpen} setOpen={setFlameshotOpen} />
|
|
<ClearStorage open={clrStorOpen} setOpen={setClrStorOpen} check={checked} setCheck={setCheck} />
|
|
</>
|
|
);
|
|
}
|