feat: invitations to create accounts
This commit is contained in:
parent
eb30afcb83
commit
61c5df750a
16 changed files with 662 additions and 42 deletions
17
prisma/migrations/20220713164531_invites/migration.sql
Normal file
17
prisma/migrations/20220713164531_invites/migration.sql
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Invite" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"code" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"expires_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"used" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdById" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Invite_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Invite_code_key" ON "Invite"("code");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Invite" ADD CONSTRAINT "Invite_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
@ -0,0 +1,3 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Invite" ALTER COLUMN "expires_at" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "expires_at" DROP DEFAULT;
|
|
@ -21,6 +21,7 @@ model User {
|
||||||
domains String[]
|
domains String[]
|
||||||
images Image[]
|
images Image[]
|
||||||
urls Url[]
|
urls Url[]
|
||||||
|
Invite Invite[]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ImageFormat {
|
enum ImageFormat {
|
||||||
|
@ -75,3 +76,14 @@ model Stats {
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
data Json
|
data Json
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Invite {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
code String @unique
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
expires_at DateTime?
|
||||||
|
used Boolean @default(false)
|
||||||
|
|
||||||
|
createdBy User @relation(fields: [createdById], references: [id])
|
||||||
|
createdById Int
|
||||||
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { useStoreDispatch } from 'lib/redux/store';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ActivityIcon, CheckIcon, CopyIcon, CrossIcon, DeleteIcon, FileIcon, HomeIcon, LinkIcon, LogoutIcon, PencilIcon, SettingsIcon, TypeIcon, UploadIcon, UserIcon } from './icons';
|
import { ActivityIcon, CheckIcon, CopyIcon, CrossIcon, DeleteIcon, FileIcon, HomeIcon, LinkIcon, LogoutIcon, PencilIcon, SettingsIcon, TagIcon, TypeIcon, UploadIcon, UserIcon } from './icons';
|
||||||
import { friendlyThemeName, themes } from './Theming';
|
import { friendlyThemeName, themes } from './Theming';
|
||||||
|
|
||||||
function MenuItemLink(props) {
|
function MenuItemLink(props) {
|
||||||
|
@ -225,6 +225,7 @@ export default function Layout({ children, user }) {
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
{user.administrator && (
|
{user.administrator && (
|
||||||
|
<>
|
||||||
<Link href='/dashboard/users' passHref>
|
<Link href='/dashboard/users' passHref>
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
sx={{
|
sx={{
|
||||||
|
@ -248,6 +249,30 @@ export default function Layout({ children, user }) {
|
||||||
</Group>
|
</Group>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href='/dashboard/invites' passHref>
|
||||||
|
<UnstyledButton
|
||||||
|
sx={{
|
||||||
|
display: 'block',
|
||||||
|
width: '100%',
|
||||||
|
padding: theme.spacing.xs,
|
||||||
|
borderRadius: theme.radius.sm,
|
||||||
|
color: theme.colorScheme === 'dark' ? theme.colors.dark[0] : theme.black,
|
||||||
|
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: theme.other.hover,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group>
|
||||||
|
<ThemeIcon color='primary' variant='filled'>
|
||||||
|
<TagIcon />
|
||||||
|
</ThemeIcon>
|
||||||
|
|
||||||
|
<Text size='lg'>Invites</Text>
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Navbar.Section>
|
</Navbar.Section>
|
||||||
</Navbar>
|
</Navbar>
|
||||||
|
|
75
src/components/PasswordStrength.tsx
Normal file
75
src/components/PasswordStrength.tsx
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
// https://mantine.dev/core/password-input/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { PasswordInput, Progress, Text, Popover, Box } from '@mantine/core';
|
||||||
|
import { CheckIcon, CrossIcon } from './icons';
|
||||||
|
|
||||||
|
function PasswordRequirement({ meets, label }: { meets: boolean; label: string }) {
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
color={meets ? 'teal' : 'red'}
|
||||||
|
sx={{ display: 'flex', alignItems: 'center' }}
|
||||||
|
mt='sm'
|
||||||
|
size='sm'
|
||||||
|
>
|
||||||
|
{meets ? <CheckIcon /> : <CrossIcon />} <Box ml='md'>{label}</Box>
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requirements = [
|
||||||
|
{ re: /[0-9]/, label: 'Includes number' },
|
||||||
|
{ re: /[a-z]/, label: 'Includes lowercase letter' },
|
||||||
|
{ re: /[A-Z]/, label: 'Includes uppercase letter' },
|
||||||
|
{ re: /[$&+,:;=?@#|'<>.^*()%!-]/, label: 'Includes special symbol' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function getStrength(password: string) {
|
||||||
|
let multiplier = password.length > 7 ? 0 : 1;
|
||||||
|
|
||||||
|
requirements.forEach((requirement) => {
|
||||||
|
if (!requirement.re.test(password)) {
|
||||||
|
multiplier += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Math.max(100 - (100 / (requirements.length + 1)) * multiplier, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PasswordStrength({ value, setValue, setStrength, ...props }) {
|
||||||
|
const [popoverOpened, setPopoverOpened] = useState(false);
|
||||||
|
const checks = requirements.map((requirement, index) => (
|
||||||
|
<PasswordRequirement key={index} label={requirement.label} meets={requirement.re.test(value)} />
|
||||||
|
));
|
||||||
|
|
||||||
|
const strength = getStrength(value);
|
||||||
|
setStrength(strength);
|
||||||
|
const color = strength === 100 ? 'teal' : strength > 50 ? 'yellow' : 'red';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
opened={popoverOpened}
|
||||||
|
position='bottom'
|
||||||
|
placement='start'
|
||||||
|
withArrow
|
||||||
|
trapFocus={false}
|
||||||
|
transition='pop-top-left'
|
||||||
|
onFocusCapture={() => setPopoverOpened(true)}
|
||||||
|
onBlurCapture={() => setPopoverOpened(false)}
|
||||||
|
styles={{ root: { width: '100%' } }}
|
||||||
|
target={
|
||||||
|
<PasswordInput
|
||||||
|
label='Password'
|
||||||
|
description='Strong password should include letters in lower and uppercase, at least 1 number, at least 1 special symbol'
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => setValue(event.currentTarget.value)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Progress color={color} value={strength} size={7} mb='md' />
|
||||||
|
<PasswordRequirement label='Includes at least 8 characters' meets={value.length > 7} />
|
||||||
|
{checks}
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
5
src/components/icons/TagIcon.tsx
Normal file
5
src/components/icons/TagIcon.tsx
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import { Tag } from 'react-feather';
|
||||||
|
|
||||||
|
export default function TagIcon({ ...props }) {
|
||||||
|
return <Tag size={15} {...props} />;
|
||||||
|
}
|
|
@ -21,6 +21,7 @@ import VideoIcon from './VideoIcon';
|
||||||
import PlayIcon from './PlayIcon';
|
import PlayIcon from './PlayIcon';
|
||||||
import CalendarIcon from './CalendarIcon';
|
import CalendarIcon from './CalendarIcon';
|
||||||
import HashIcon from './HashIcon';
|
import HashIcon from './HashIcon';
|
||||||
|
import TagIcon from './TagIcon';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
ActivityIcon,
|
ActivityIcon,
|
||||||
|
@ -46,4 +47,5 @@ export {
|
||||||
PlayIcon,
|
PlayIcon,
|
||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
HashIcon,
|
HashIcon,
|
||||||
|
TagIcon,
|
||||||
};
|
};
|
204
src/components/pages/Invites.tsx
Normal file
204
src/components/pages/Invites.tsx
Normal file
|
@ -0,0 +1,204 @@
|
||||||
|
import { ActionIcon, Avatar, Button, Card, Group, Modal, Select, SimpleGrid, Skeleton, Stack, Switch, TextInput, Title } from '@mantine/core';
|
||||||
|
import { useClipboard, useForm } from '@mantine/hooks';
|
||||||
|
import { useModals } from '@mantine/modals';
|
||||||
|
import { useNotifications } from '@mantine/notifications';
|
||||||
|
import { CopyIcon, CrossIcon, DeleteIcon, PlusIcon, TagIcon } from 'components/icons';
|
||||||
|
import MutedText from 'components/MutedText';
|
||||||
|
import useFetch from 'hooks/useFetch';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const expires = [
|
||||||
|
'30m',
|
||||||
|
'1h',
|
||||||
|
'6h',
|
||||||
|
'12h',
|
||||||
|
'1d',
|
||||||
|
'3d',
|
||||||
|
'5d',
|
||||||
|
'7d',
|
||||||
|
'never',
|
||||||
|
];
|
||||||
|
|
||||||
|
function CreateInviteModal({ open, setOpen, updateInvites }) {
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
expires: '30m',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const notif = useNotifications();
|
||||||
|
|
||||||
|
const onSubmit = async values => {
|
||||||
|
if (!expires.includes(values.expires)) return form.setFieldError('expires', 'Invalid expiration');
|
||||||
|
const expires_at = values.expires === 'never' ? null : new Date({
|
||||||
|
'30m': Date.now() + 30 * 60 * 1000,
|
||||||
|
'1h': Date.now() + 60 * 60 * 1000,
|
||||||
|
'6h': Date.now() + 6 * 60 * 60 * 1000,
|
||||||
|
'12h': Date.now() + 12 * 60 * 60 * 1000,
|
||||||
|
'1d': Date.now() + 24 * 60 * 60 * 1000,
|
||||||
|
'5d': Date.now() + 5 * 24 * 60 * 60 * 1000,
|
||||||
|
'7d': Date.now() + 7 * 24 * 60 * 60 * 1000,
|
||||||
|
}[values.expires]);
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
|
||||||
|
const res = await useFetch('/api/auth/invite', 'POST', {
|
||||||
|
expires_at,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.error) {
|
||||||
|
notif.showNotification({
|
||||||
|
title: 'Failed to create invite',
|
||||||
|
message: res.error,
|
||||||
|
icon: <CrossIcon />,
|
||||||
|
color: 'red',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
notif.showNotification({
|
||||||
|
title: 'Created invite',
|
||||||
|
message: '',
|
||||||
|
icon: <TagIcon />,
|
||||||
|
color: 'green',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateInvites();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
title={<Title>Create Invite</Title>}
|
||||||
|
overlayBlur={3}
|
||||||
|
centered={true}
|
||||||
|
|
||||||
|
>
|
||||||
|
<form onSubmit={form.onSubmit(v => onSubmit(v))}>
|
||||||
|
<Select
|
||||||
|
label='Expires'
|
||||||
|
id='expires'
|
||||||
|
{...form.getInputProps('expires')}
|
||||||
|
data={[
|
||||||
|
{ value: '30m', label: '30 minutes' },
|
||||||
|
{ value: '1h', label: '1 hour' },
|
||||||
|
{ value: '6h', label: '6 hours' },
|
||||||
|
{ value: '12h', label: '12 hours' },
|
||||||
|
{ value: '1d', label: '1 day' },
|
||||||
|
{ value: '3d', label: '3 days' },
|
||||||
|
{ value: '5d', label: '5 days' },
|
||||||
|
{ value: '7d', label: '7 days' },
|
||||||
|
{ value: 'never', label: 'Never' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group position='right' mt={22}>
|
||||||
|
<Button onClick={() => setOpen(false)}>Cancel</Button>
|
||||||
|
<Button type='submit'>Create</Button>
|
||||||
|
</Group>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Users() {
|
||||||
|
const router = useRouter();
|
||||||
|
const notif = useNotifications();
|
||||||
|
const modals = useModals();
|
||||||
|
const clipboard = useClipboard();
|
||||||
|
|
||||||
|
const [invites, setInvites] = useState([]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const openDeleteModal = invite => modals.openConfirmModal({
|
||||||
|
title: `Delete ${invite.code}?`,
|
||||||
|
centered: true,
|
||||||
|
overlayBlur: 3,
|
||||||
|
labels: { confirm: 'Yes', cancel: 'No' },
|
||||||
|
onConfirm: async () => {
|
||||||
|
const res = await useFetch(`/api/auth/invite?code=${invite.code}`, 'DELETE');
|
||||||
|
if (res.error) {
|
||||||
|
notif.showNotification({
|
||||||
|
title: 'Failed to delete invite ${invite.code}',
|
||||||
|
message: res.error,
|
||||||
|
icon: <CrossIcon />,
|
||||||
|
color: 'red',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
notif.showNotification({
|
||||||
|
title: `Deleted invite ${invite.code}`,
|
||||||
|
message: '',
|
||||||
|
icon: <DeleteIcon />,
|
||||||
|
color: 'green',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateInvites();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCopy = async invite => {
|
||||||
|
clipboard.copy(`${window.location.protocol}//${window.location.host}/invite/${invite.code}`);
|
||||||
|
notif.showNotification({
|
||||||
|
title: 'Copied to clipboard',
|
||||||
|
message: '',
|
||||||
|
icon: <CopyIcon />,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateInvites = async () => {
|
||||||
|
const us = await useFetch('/api/auth/invite');
|
||||||
|
if (!us.error) {
|
||||||
|
setInvites(us);
|
||||||
|
} else {
|
||||||
|
router.push('/dashboard');
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log(invites);
|
||||||
|
updateInvites();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CreateInviteModal open={open} setOpen={setOpen} updateInvites={updateInvites} />
|
||||||
|
<Group mb='md'>
|
||||||
|
<Title>Invites</Title>
|
||||||
|
<ActionIcon variant='filled' color='primary' onClick={() => setOpen(true)}><PlusIcon /></ActionIcon>
|
||||||
|
</Group>
|
||||||
|
<SimpleGrid
|
||||||
|
cols={3}
|
||||||
|
spacing='lg'
|
||||||
|
breakpoints={[
|
||||||
|
{ maxWidth: 'sm', cols: 1, spacing: 'sm' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{invites.length ? invites.map(invite => (
|
||||||
|
<Card key={invite.id} sx={{ maxWidth: '100%' }}>
|
||||||
|
<Group position='apart'>
|
||||||
|
<Group position='left'>
|
||||||
|
<Avatar size='lg' color={invite.used ? 'dark' : 'primary'}>{invite.id}</Avatar>
|
||||||
|
<Stack spacing={0}>
|
||||||
|
<Title>{invite.code}{invite.used && <> (Used)</>}</Title>
|
||||||
|
<MutedText size='sm'>Created: {new Date(invite.created_at).toLocaleString()}</MutedText>
|
||||||
|
<MutedText size='sm'>Expires: {invite.expires_at ? new Date(invite.expires_at).toLocaleString() : 'Never'}</MutedText>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
<Group position='right'>
|
||||||
|
<ActionIcon aria-label='copy' onClick={() => handleCopy(invite)}>
|
||||||
|
<CopyIcon />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon aria-label='delete' onClick={() => openDeleteModal(invite)}>
|
||||||
|
<DeleteIcon />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
)) : [1, 2, 3].map(x => (
|
||||||
|
<Skeleton key={x} width='100%' height={100} radius='sm' />
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -155,10 +155,8 @@ export default function Urls() {
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
</Card>
|
</Card>
|
||||||
)) : [1,2,3,4,5,6,7].map(x => (
|
)) : [1, 2, 3, 4].map(x => (
|
||||||
<div key={x}>
|
<Skeleton key={x} width='100%' height={80} radius='sm' />
|
||||||
<Skeleton width='100%' height={60} sx={{ borderRadius: 1 }}/>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -145,8 +145,8 @@ export default function Users() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CreateUserModal open={open} setOpen={setOpen} updateUsers={updateUsers} />
|
<CreateUserModal open={open} setOpen={setOpen} updateUsers={updateUsers} />
|
||||||
<Group>
|
<Group mb='md'>
|
||||||
<Title sx={{ marginBottom: 12 }}>Users</Title>
|
<Title>Users</Title>
|
||||||
<ActionIcon variant='filled' color='primary' onClick={() => setOpen(true)}><PlusIcon /></ActionIcon>
|
<ActionIcon variant='filled' color='primary' onClick={() => setOpen(true)}><PlusIcon /></ActionIcon>
|
||||||
</Group>
|
</Group>
|
||||||
<SimpleGrid
|
<SimpleGrid
|
||||||
|
@ -170,10 +170,8 @@ export default function Users() {
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
</Card>
|
</Card>
|
||||||
)) : [1, 2, 3, 4].map(x => (
|
)) : [1, 2, 3].map(x => (
|
||||||
<div key={x}>
|
<Skeleton key={x} width='100%' height={100} radius='sm' />
|
||||||
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }} />
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -36,7 +36,7 @@ export type NextApiRes = NextApiResponse & {
|
||||||
error: (message: string) => void;
|
error: (message: string) => void;
|
||||||
forbid: (message: string, extra?: any) => void;
|
forbid: (message: string, extra?: any) => void;
|
||||||
bad: (message: string) => void;
|
bad: (message: string) => void;
|
||||||
json: (json: Record<string, any>) => void;
|
json: (json: Record<string, any>, status?: number) => void;
|
||||||
ratelimited: (remaining: number) => void;
|
ratelimited: (remaining: number) => void;
|
||||||
setCookie: (name: string, value: unknown, options: CookieSerializeOptions) => void;
|
setCookie: (name: string, value: unknown, options: CookieSerializeOptions) => void;
|
||||||
}
|
}
|
||||||
|
@ -50,23 +50,20 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
|
||||||
res.error = (message: string) => {
|
res.error = (message: string) => {
|
||||||
res.json({
|
res.json({
|
||||||
error: message,
|
error: message,
|
||||||
});
|
}, 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
res.forbid = (message: string, extra: any = {}) => {
|
res.forbid = (message: string, extra: any = {}) => {
|
||||||
res.setHeader('Content-Type', 'application/json');
|
|
||||||
res.status(403);
|
|
||||||
res.json({
|
res.json({
|
||||||
error: '403: ' + message,
|
error: '403: ' + message,
|
||||||
...extra,
|
...extra,
|
||||||
});
|
}, 403);
|
||||||
};
|
};
|
||||||
|
|
||||||
res.bad = (message: string) => {
|
res.bad = (message: string) => {
|
||||||
res.status(401);
|
|
||||||
res.json({
|
res.json({
|
||||||
error: '403: ' + message,
|
error: '401: ' + message,
|
||||||
});
|
}, 401);
|
||||||
};
|
};
|
||||||
|
|
||||||
res.ratelimited = (remaining: number) => {
|
res.ratelimited = (remaining: number) => {
|
||||||
|
@ -77,7 +74,9 @@ export const withZipline = (handler: (req: NextApiRequest, res: NextApiResponse)
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
res.json = (json: any) => {
|
res.json = (json: any, status: number = 200) => {
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.status(status);
|
||||||
res.end(JSON.stringify(json));
|
res.end(JSON.stringify(json));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,42 @@ import { createToken, hashPassword } from 'lib/util';
|
||||||
import Logger from 'lib/logger';
|
import Logger from 'lib/logger';
|
||||||
|
|
||||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||||
|
if (req.method === 'POST' && req.body && req.body.code) {
|
||||||
|
const { code, username, password } = req.body as { code: string; username: string, password: string };
|
||||||
|
const invite = await prisma.invite.findUnique({
|
||||||
|
where: { code },
|
||||||
|
});
|
||||||
|
if (!invite) return res.bad('invalid invite code');
|
||||||
|
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: { username },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user) return res.bad('username already exists');
|
||||||
|
const hashed = await hashPassword(password);
|
||||||
|
const newUser = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
password: hashed,
|
||||||
|
username,
|
||||||
|
token: createToken(),
|
||||||
|
administrator: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.invite.update({
|
||||||
|
where: {
|
||||||
|
code,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
used: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Logger.get('user').info(`Created user ${newUser.username} (${newUser.id}) from invite code ${code}`);
|
||||||
|
|
||||||
|
return res.json({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
const user = await req.user();
|
const user = await req.user();
|
||||||
if (!user) return res.forbid('not logged in');
|
if (!user) return res.forbid('not logged in');
|
||||||
if (!user.administrator) return res.forbid('you arent an administrator');
|
if (!user.administrator) return res.forbid('you arent an administrator');
|
||||||
|
|
56
src/pages/api/auth/invite.ts
Normal file
56
src/pages/api/auth/invite.ts
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
import prisma from 'lib/prisma';
|
||||||
|
import { NextApiReq, NextApiRes, withZipline } from 'lib/middleware/withZipline';
|
||||||
|
import { randomChars } from 'lib/util';
|
||||||
|
import Logger from 'lib/logger';
|
||||||
|
|
||||||
|
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||||
|
const user = await req.user();
|
||||||
|
if (!user) return res.forbid('not logged in');
|
||||||
|
if (!user.administrator) return res.forbid('you arent an administrator');
|
||||||
|
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
const { expires_at } = req.body as { expires_at: string };
|
||||||
|
|
||||||
|
const expiry = expires_at ? new Date(expires_at) : null;
|
||||||
|
if (expiry) {
|
||||||
|
if (!expiry.getTime()) return res.bad('invalid date');
|
||||||
|
if (expiry.getTime() < Date.now()) return res.bad('date is in the past');
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = randomChars(6);
|
||||||
|
|
||||||
|
const invite = await prisma.invite.create({
|
||||||
|
data: {
|
||||||
|
code,
|
||||||
|
createdById: user.id,
|
||||||
|
expires_at: expiry,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Logger.get('invite').info(`${user.username} (${user.id}) created invite ${invite.code}`);
|
||||||
|
|
||||||
|
return res.json(invite);
|
||||||
|
} else if (req.method === 'GET') {
|
||||||
|
const invites = await prisma.invite.findMany({
|
||||||
|
orderBy: {
|
||||||
|
created_at: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json(invites);
|
||||||
|
} else if (req.method === 'DELETE') {
|
||||||
|
const { code } = req.query as { code: string };
|
||||||
|
|
||||||
|
const invite = await prisma.invite.delete({
|
||||||
|
where: {
|
||||||
|
code,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Logger.get('invite').info(`${user.username} (${user.id}) deleted invite ${invite.code}`);
|
||||||
|
|
||||||
|
return res.json(invite);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withZipline(handler);
|
|
@ -4,6 +4,21 @@ import Logger from 'lib/logger';
|
||||||
import datasource from 'lib/datasource';
|
import datasource from 'lib/datasource';
|
||||||
|
|
||||||
async function handler(req: NextApiReq, res: NextApiRes) {
|
async function handler(req: NextApiReq, res: NextApiRes) {
|
||||||
|
if (req.method === 'POST' && req.body && req.body.code) {
|
||||||
|
const { code, username } = req.body as { code: string; username: string };
|
||||||
|
const invite = await prisma.invite.findUnique({
|
||||||
|
where: { code },
|
||||||
|
});
|
||||||
|
if (!invite) return res.bad('invalid invite code');
|
||||||
|
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: { username },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user) return res.bad('username already exists');
|
||||||
|
return res.json({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
const user = await req.user();
|
const user = await req.user();
|
||||||
if (!user) return res.forbid('not logged in');
|
if (!user) return res.forbid('not logged in');
|
||||||
if (!user.administrator) return res.forbid('you aren\'t an administrator');
|
if (!user.administrator) return res.forbid('you aren\'t an administrator');
|
||||||
|
|
21
src/pages/dashboard/invites.tsx
Normal file
21
src/pages/dashboard/invites.tsx
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import React from 'react';
|
||||||
|
import useLogin from 'hooks/useLogin';
|
||||||
|
import Layout from 'components/Layout';
|
||||||
|
import Invites from 'components/pages/Invites';
|
||||||
|
import { LoadingOverlay } from '@mantine/core';
|
||||||
|
|
||||||
|
export default function InvitesPage() {
|
||||||
|
const { user, loading } = useLogin();
|
||||||
|
|
||||||
|
if (loading) return <LoadingOverlay visible={loading} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
user={user}
|
||||||
|
>
|
||||||
|
<Invites />
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
InvitesPage.title = 'Zipline - Invites';
|
154
src/pages/invite/[code].tsx
Normal file
154
src/pages/invite/[code].tsx
Normal file
|
@ -0,0 +1,154 @@
|
||||||
|
import { GetServerSideProps } from 'next';
|
||||||
|
import prisma from 'lib/prisma';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button, Card, Center, Group, PasswordInput, Stepper, TextInput } from '@mantine/core';
|
||||||
|
import useFetch from 'hooks/useFetch';
|
||||||
|
import PasswordStrength from 'components/PasswordStrength';
|
||||||
|
import { useNotifications } from '@mantine/notifications';
|
||||||
|
import { CrossIcon, UserIcon } from 'components/icons';
|
||||||
|
import { useStoreDispatch } from 'lib/redux/store';
|
||||||
|
import { updateUser } from 'lib/redux/reducers/user';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
|
||||||
|
export default function Invite({ code }) {
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [usernameError, setUsernameError] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [verifyPassword, setVerifyPassword] = useState('');
|
||||||
|
const [verifyPasswordError, setVerifyPasswordError] = useState('');
|
||||||
|
const [strength, setStrength] = useState(0);
|
||||||
|
|
||||||
|
const notif = useNotifications();
|
||||||
|
const dispatch = useStoreDispatch();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const nextStep = () => setActive((current) => (current < 3 ? current + 1 : current));
|
||||||
|
const prevStep = () => setActive((current) => (current > 0 ? current - 1 : current));
|
||||||
|
|
||||||
|
const checkUsername = async () => {
|
||||||
|
setUsername(username.trim());
|
||||||
|
|
||||||
|
setUsernameError('');
|
||||||
|
|
||||||
|
const res = await useFetch('/api/users', 'POST', { code, username });
|
||||||
|
if (res.error) {
|
||||||
|
setUsernameError('A user with that username already exists');
|
||||||
|
} else {
|
||||||
|
setUsernameError('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkPassword = () => {
|
||||||
|
setVerifyPasswordError('');
|
||||||
|
setPassword(password.trim());
|
||||||
|
setVerifyPassword(verifyPassword.trim());
|
||||||
|
|
||||||
|
if (password.trim() !== verifyPassword.trim()) {
|
||||||
|
setVerifyPasswordError('Passwords do not match');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createUser = async () => {
|
||||||
|
const res = await useFetch('/api/auth/create', 'POST', { code, username, password });
|
||||||
|
if (res.error) {
|
||||||
|
notif.showNotification({
|
||||||
|
title: 'Error while creating user',
|
||||||
|
message: res.error,
|
||||||
|
color: 'red',
|
||||||
|
icon: <CrossIcon />,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
notif.showNotification({
|
||||||
|
title: 'User created',
|
||||||
|
message: 'You will be logged in shortly...',
|
||||||
|
color: 'green',
|
||||||
|
icon: <UserIcon />,
|
||||||
|
});
|
||||||
|
|
||||||
|
dispatch(updateUser(null));
|
||||||
|
await useFetch('/api/auth/logout');
|
||||||
|
await useFetch('/api/auth/login', 'POST', {
|
||||||
|
username, password,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push('/dashboard');
|
||||||
|
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Center sx={{ height: '100vh' }}>
|
||||||
|
<Card>
|
||||||
|
<Stepper active={active} onStepClick={setActive} breakpoint='sm'>
|
||||||
|
<Stepper.Step label='Welcome' description='Choose a username' allowStepSelect={active > 0}>
|
||||||
|
<TextInput
|
||||||
|
label='Username'
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
error={usernameError}
|
||||||
|
onBlur={() => checkUsername()}
|
||||||
|
/>
|
||||||
|
<Group position='center' mt='xl'>
|
||||||
|
{/* <Button variant='default' onClick={prevStep}>Back</Button> */}
|
||||||
|
<Button disabled={usernameError !== '' || username == ''} onClick={nextStep}>Continue</Button>
|
||||||
|
</Group>
|
||||||
|
</Stepper.Step>
|
||||||
|
<Stepper.Step label='Choose a password' allowStepSelect={active > 1 && usernameError === ''}>
|
||||||
|
<PasswordStrength value={password} setValue={setPassword} setStrength={setStrength} />
|
||||||
|
<Group position='center' mt='xl'>
|
||||||
|
<Button variant='default' onClick={prevStep}>Back</Button>
|
||||||
|
<Button disabled={strength !== 100} onClick={nextStep}>Continue</Button>
|
||||||
|
</Group>
|
||||||
|
</Stepper.Step>
|
||||||
|
<Stepper.Step label='Verify your password' allowStepSelect={active > 2}>
|
||||||
|
<PasswordInput
|
||||||
|
label='Verify password'
|
||||||
|
value={verifyPassword}
|
||||||
|
onChange={(e) => setVerifyPassword(e.target.value)}
|
||||||
|
error={verifyPasswordError}
|
||||||
|
onBlur={() => checkPassword()}
|
||||||
|
/>
|
||||||
|
<Group position='center' mt='xl'>
|
||||||
|
<Button variant='default' onClick={prevStep}>Back</Button>
|
||||||
|
<Button disabled={verifyPasswordError !== '' || verifyPassword == ''} onClick={nextStep}>Continue</Button>
|
||||||
|
</Group>
|
||||||
|
</Stepper.Step>
|
||||||
|
<Stepper.Completed>
|
||||||
|
<Group position='center' mt='xl'>
|
||||||
|
<Button variant='default' onClick={() => setActive(0)}>Go back</Button>
|
||||||
|
<Button onClick={() => createUser()}>Finish setup</Button>
|
||||||
|
</Group>
|
||||||
|
</Stepper.Completed>
|
||||||
|
</Stepper>
|
||||||
|
</Card>
|
||||||
|
</Center>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getServerSideProps: GetServerSideProps = async context => {
|
||||||
|
const { code } = context.query as { code: string };
|
||||||
|
|
||||||
|
const invite = await prisma.invite.findUnique({
|
||||||
|
where: {
|
||||||
|
code,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!invite) return { notFound: true };
|
||||||
|
if (invite.used) return { notFound: true };
|
||||||
|
|
||||||
|
if (invite.expires_at && invite.expires_at < new Date()) {
|
||||||
|
await prisma.invite.delete({
|
||||||
|
where: {
|
||||||
|
code,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { notFound: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
return { props: { code: invite.code } };
|
||||||
|
};
|
Loading…
Reference in a new issue