feat: user registration without oauth

This commit is contained in:
diced 2022-10-24 11:28:06 -07:00
parent 4552643ff8
commit 74b1799d21
No known key found for this signature in database
GPG key ID: 370BD1BA142842D1
8 changed files with 223 additions and 240 deletions

View file

@ -102,6 +102,7 @@ export interface ConfigDiscordEmbed {
export interface ConfigFeatures {
invites: boolean;
oauth_registration: boolean;
user_registration: boolean;
}
export interface ConfigOAuth {

View file

@ -121,6 +121,7 @@ export default function readConfig() {
map('FEATURES_INVITES', 'boolean', 'features.invites'),
map('FEATURES_OAUTH_REGISTRATION', 'boolean', 'features.oauth_registration'),
map('FEATURES_USER_REGISTRATION', 'boolean', 'features.user_registration'),
];
const config = {};

View file

@ -163,10 +163,11 @@ const validator = s.object({
.nullish.default(null),
features: s
.object({
invites: s.boolean.default(true),
invites: s.boolean.default(false),
oauth_registration: s.boolean.default(false),
user_registration: s.boolean.default(false),
})
.default({ invites: true, oauth_registration: false }),
.default({ invites: false, oauth_registration: false, user_registration: false }),
});
export default function validate(config): Config {

View file

@ -27,6 +27,7 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => {
external_links: JSON.stringify(config.website.external_links),
disable_media_preview: config.website.disable_media_preview,
invites: config.features.invites,
user_registration: config.features.user_registration,
oauth_registration: config.features.oauth_registration,
oauth_providers: JSON.stringify(oauth_providers),
},

View file

@ -5,18 +5,19 @@ import Logger from 'lib/logger';
import config from 'lib/config';
async function handler(req: NextApiReq, res: NextApiRes) {
if (req.method === 'POST' && req.body && req.body.code) {
if (!config.features.invites) return res.forbid('invites are disabled');
if (req.method === 'POST' && req.body) {
if (!config.features.invites && req.body.code) return res.forbid('invites are disabled');
if (!config.features.user_registration) return res.forbid('user registration is disabled');
const { code, username, password } = req.body as {
code: string;
code?: string;
username: string;
password: string;
};
const invite = await prisma.invite.findUnique({
where: { code },
where: { code: code ?? '' },
});
if (!invite) return res.bad('invalid invite code');
if (!invite && code) return res.bad('invalid invite code');
const user = await prisma.user.findFirst({
where: { username },
@ -33,6 +34,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
},
});
if (code) {
await prisma.invite.update({
where: {
code,
@ -41,8 +43,13 @@ async function handler(req: NextApiReq, res: NextApiRes) {
used: true,
},
});
}
Logger.get('user').info(`Created user ${newUser.username} (${newUser.id}) from invite code ${code}`);
Logger.get('user').info(
`Created user ${newUser.username} (${newUser.id}) ${
code ? `from invite code ${code}` : 'via registration'
}`
);
return res.json({ success: true });
}

View file

@ -8,7 +8,7 @@ import Head from 'next/head';
import { GitHubIcon, DiscordIcon } from 'components/icons';
export { getServerSideProps } from 'middleware/getServerSideProps';
export default function Login({ title, oauth_registration, oauth_providers: unparsed }) {
export default function Login({ title, user_registration, oauth_registration, oauth_providers: unparsed }) {
const router = useRouter();
const oauth_providers = JSON.parse(unparsed);
@ -75,6 +75,16 @@ export default function Login({ title, oauth_registration, oauth_providers: unpa
Login
</Button>
</form>
{user_registration && (
<>
<Divider label='or' labelPosition='center' my={8} />
<Link href='/auth/register' passHref>
<Button size='lg' fullWidth component='a'>
Register
</Button>
</Link>
</>
)}
{oauth_registration && (
<>
<Divider label='or' labelPosition='center' my={8} />

View file

@ -1,58 +1,203 @@
import { Button, Center } from '@mantine/core';
import Link from 'next/link';
import { GetServerSideProps } from 'next';
import prisma from 'lib/prisma';
import { useState } from 'react';
import { Box, Button, Card, Center, Group, PasswordInput, Stepper, TextInput } from '@mantine/core';
import useFetch from 'hooks/useFetch';
import PasswordStrength from 'components/PasswordStrength';
import { showNotification } from '@mantine/notifications';
import { CrossIcon, UserIcon } from 'components/icons';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import GitHubIcon from 'components/icons/GitHubIcon';
import DiscordIcon from 'components/icons/DiscordIcon';
import Head from 'next/head';
export { getServerSideProps } from 'middleware/getServerSideProps';
import config from 'lib/config';
import { useSetRecoilState } from 'recoil';
import { userSelector } from 'lib/recoil/user';
import { randomChars } from 'lib/util';
export default function Login({ title, oauth_registration, oauth_providers: unparsed }) {
const oauth_providers = JSON.parse(unparsed);
export default function Register({ code, title, user_registration }) {
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 icons = {
GitHub: GitHubIcon,
Discord: DiscordIcon,
const setUser = useSetRecoilState(userSelector);
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('');
}
};
for (const provider of oauth_providers) {
provider.Icon = icons[provider.name];
}
const checkPassword = () => {
setVerifyPasswordError('');
setPassword(password.trim());
setVerifyPassword(verifyPassword.trim());
const router = useRouter();
if (!oauth_registration) {
router.push('/auth/login');
return null;
if (password.trim() !== verifyPassword.trim()) {
setVerifyPasswordError('Passwords do not match');
}
};
useEffect(() => {
(async () => {
const a = await fetch('/api/user');
if (a.ok) await router.push('/dashboard');
})();
}, []);
const createUser = async () => {
const res = await useFetch('/api/auth/create', 'POST', {
code: user_registration ? null : code,
username,
password,
});
if (res.error) {
showNotification({
title: 'Error while creating user',
message: res.error,
color: 'red',
icon: <CrossIcon />,
});
} else {
showNotification({
title: 'User created',
message: 'You will be logged in shortly...',
color: 'green',
icon: <UserIcon />,
});
setUser(null);
await useFetch('/api/auth/logout');
await useFetch('/api/auth/login', 'POST', {
username,
password,
});
router.push('/dashboard');
}
};
return (
<>
<Head>
<title>{title} - Login</title>
<title>
{title} - Invite ({code})
</title>
</Head>
<Center sx={{ height: '100vh' }}>
<div>
<Link href='/auth/login' passHref>
<Button size='lg' fullWidth variant='outline' component='a'>
Go Back to Login
<Box
sx={(t) => ({
backgroundColor: t.colors.dark[6],
borderRadius: t.radius.sm,
})}
p='md'
>
<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 disabled={usernameError !== '' || username == ''} onClick={nextStep}>
Continue
</Button>
</Link>
{oauth_providers.map(({ url, name, Icon }, i) => (
<Link key={i} href={url} passHref>
<Button size='lg' fullWidth mt={12} leftIcon={<Icon />} component='a'>
Sign in with {name}
</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>
</Link>
))}
</div>
<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()}>Register</Button>
</Group>
</Stepper.Completed>
</Stepper>
</Box>
</Center>
</>
);
}
export const getServerSideProps: GetServerSideProps = async (context) => {
const { code } = context.query as { code: string };
if (!config.features.invites && code)
return {
notFound: true,
};
if (!config.features.user_registration) return { notFound: true };
if (code) {
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()) return { notFound: true };
return {
props: {
title: config.website.title,
code: invite.code,
},
};
} else {
const code = randomChars(4);
await prisma.invite.create({
data: {
code,
createdById: 1,
},
});
return {
props: {
title: config.website.title,
code,
user_registration: true,
},
};
}
};

View file

@ -1,183 +0,0 @@
import { GetServerSideProps } from 'next';
import prisma from 'lib/prisma';
import { useState } from 'react';
import { Box, Button, Card, Center, Group, PasswordInput, Stepper, TextInput } from '@mantine/core';
import useFetch from 'hooks/useFetch';
import PasswordStrength from 'components/PasswordStrength';
import { showNotification } from '@mantine/notifications';
import { CrossIcon, UserIcon } from 'components/icons';
import { useRouter } from 'next/router';
import Head from 'next/head';
import config from 'lib/config';
import { useSetRecoilState } from 'recoil';
import { userSelector } from 'lib/recoil/user';
export default function Invite({ code, title }) {
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 setUser = useSetRecoilState(userSelector);
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) {
showNotification({
title: 'Error while creating user',
message: res.error,
color: 'red',
icon: <CrossIcon />,
});
} else {
showNotification({
title: 'User created',
message: 'You will be logged in shortly...',
color: 'green',
icon: <UserIcon />,
});
setUser(null);
await useFetch('/api/auth/logout');
await useFetch('/api/auth/login', 'POST', {
username,
password,
});
router.push('/dashboard');
}
};
return (
<>
<Head>
<title>
{title} - Invite ({code})
</title>
</Head>
<Center sx={{ height: '100vh' }}>
<Box
sx={(t) => ({
backgroundColor: t.colors.dark[6],
borderRadius: t.radius.sm,
})}
p='md'
>
<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 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>
</Box>
</Center>
</>
);
}
export const getServerSideProps: GetServerSideProps = async (context) => {
if (!config.features.invites)
return {
notFound: true,
};
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()) return { notFound: true };
return {
props: {
title: config.website.title,
code: invite.code,
},
};
};