feat: user registration without oauth
This commit is contained in:
parent
4552643ff8
commit
74b1799d21
8 changed files with 223 additions and 240 deletions
|
@ -102,6 +102,7 @@ export interface ConfigDiscordEmbed {
|
|||
export interface ConfigFeatures {
|
||||
invites: boolean;
|
||||
oauth_registration: boolean;
|
||||
user_registration: boolean;
|
||||
}
|
||||
|
||||
export interface ConfigOAuth {
|
||||
|
|
|
@ -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 = {};
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
|
|
|
@ -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,16 +34,22 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
|||
},
|
||||
});
|
||||
|
||||
await prisma.invite.update({
|
||||
where: {
|
||||
code,
|
||||
},
|
||||
data: {
|
||||
used: true,
|
||||
},
|
||||
});
|
||||
if (code) {
|
||||
await prisma.invite.update({
|
||||
where: {
|
||||
code,
|
||||
},
|
||||
data: {
|
||||
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 });
|
||||
}
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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
|
||||
</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}
|
||||
</Button>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<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()}>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,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
Loading…
Reference in a new issue