feat: invitations to create accounts

This commit is contained in:
dicedtomato 2022-07-14 02:31:23 +00:00 committed by GitHub
parent eb30afcb83
commit 61c5df750a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 662 additions and 42 deletions

View 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;

View file

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Invite" ALTER COLUMN "expires_at" DROP NOT NULL,
ALTER COLUMN "expires_at" DROP DEFAULT;

View file

@ -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
}

View file

@ -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,
'&:hover': {
backgroundColor: theme.other.hover,
},
}}
>
<Group>
<ThemeIcon color='primary' variant='filled'>
<UserIcon />
</ThemeIcon>
<>
<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>
<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>

View 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>
);
}

View file

@ -0,0 +1,5 @@
import { Tag } from 'react-feather';
export default function TagIcon({ ...props }) {
return <Tag size={15} {...props} />;
}

View file

@ -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,
};

View 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>
</>
);
}

View file

@ -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>
</>

View file

@ -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>
</>

View file

@ -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));
};

View file

@ -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');

View 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);

View file

@ -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');

View 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
View 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 } };
};