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[]
|
||||
images Image[]
|
||||
urls Url[]
|
||||
Invite Invite[]
|
||||
}
|
||||
|
||||
enum ImageFormat {
|
||||
|
@ -75,3 +76,14 @@ model Stats {
|
|||
created_at DateTime @default(now())
|
||||
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 { useRouter } from 'next/router';
|
||||
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';
|
||||
|
||||
function MenuItemLink(props) {
|
||||
|
@ -225,29 +225,54 @@ export default function Layout({ children, user }) {
|
|||
</Link>
|
||||
))}
|
||||
{user.administrator && (
|
||||
<Link href='/dashboard/users' 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,
|
||||
<>
|
||||
<Link href='/dashboard/users' 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'>
|
||||
<UserIcon />
|
||||
</ThemeIcon>
|
||||
'&:hover': {
|
||||
backgroundColor: theme.other.hover,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Group>
|
||||
<ThemeIcon color='primary' variant='filled'>
|
||||
<UserIcon />
|
||||
</ThemeIcon>
|
||||
|
||||
<Text size='lg'>Users</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Link>
|
||||
<Text size='lg'>Users</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</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>
|
||||
|
|
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 CalendarIcon from './CalendarIcon';
|
||||
import HashIcon from './HashIcon';
|
||||
import TagIcon from './TagIcon';
|
||||
|
||||
export {
|
||||
ActivityIcon,
|
||||
|
@ -46,4 +47,5 @@ export {
|
|||
PlayIcon,
|
||||
CalendarIcon,
|
||||
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>
|
||||
</Card>
|
||||
)) : [1,2,3,4,5,6,7].map(x => (
|
||||
<div key={x}>
|
||||
<Skeleton width='100%' height={60} sx={{ borderRadius: 1 }}/>
|
||||
</div>
|
||||
)) : [1, 2, 3, 4].map(x => (
|
||||
<Skeleton key={x} width='100%' height={80} radius='sm' />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</>
|
||||
|
|
|
@ -145,8 +145,8 @@ export default function Users() {
|
|||
return (
|
||||
<>
|
||||
<CreateUserModal open={open} setOpen={setOpen} updateUsers={updateUsers} />
|
||||
<Group>
|
||||
<Title sx={{ marginBottom: 12 }}>Users</Title>
|
||||
<Group mb='md'>
|
||||
<Title>Users</Title>
|
||||
<ActionIcon variant='filled' color='primary' onClick={() => setOpen(true)}><PlusIcon /></ActionIcon>
|
||||
</Group>
|
||||
<SimpleGrid
|
||||
|
@ -170,10 +170,8 @@ export default function Users() {
|
|||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
)) : [1, 2, 3, 4].map(x => (
|
||||
<div key={x}>
|
||||
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }} />
|
||||
</div>
|
||||
)) : [1, 2, 3].map(x => (
|
||||
<Skeleton key={x} width='100%' height={100} radius='sm' />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</>
|
||||
|
|
|
@ -36,7 +36,7 @@ export type NextApiRes = NextApiResponse & {
|
|||
error: (message: string) => void;
|
||||
forbid: (message: string, extra?: any) => void;
|
||||
bad: (message: string) => void;
|
||||
json: (json: Record<string, any>) => void;
|
||||
json: (json: Record<string, any>, status?: number) => void;
|
||||
ratelimited: (remaining: number) => 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.json({
|
||||
error: message,
|
||||
});
|
||||
}, 500);
|
||||
};
|
||||
|
||||
res.forbid = (message: string, extra: any = {}) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.status(403);
|
||||
res.json({
|
||||
error: '403: ' + message,
|
||||
...extra,
|
||||
});
|
||||
}, 403);
|
||||
};
|
||||
|
||||
res.bad = (message: string) => {
|
||||
res.status(401);
|
||||
res.json({
|
||||
error: '403: ' + message,
|
||||
});
|
||||
error: '401: ' + message,
|
||||
}, 401);
|
||||
};
|
||||
|
||||
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));
|
||||
};
|
||||
|
||||
|
|
|
@ -4,6 +4,42 @@ import { createToken, hashPassword } from 'lib/util';
|
|||
import Logger from 'lib/logger';
|
||||
|
||||
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();
|
||||
if (!user) return res.forbid('not logged in');
|
||||
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';
|
||||
|
||||
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();
|
||||
if (!user) return res.forbid('not logged in');
|
||||
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