feat: ability to link existing accounts to oauth

This commit is contained in:
diced 2022-10-29 20:02:54 -07:00
parent 0847802ce4
commit 561849ae5b
No known key found for this signature in database
GPG key ID: 370BD1BA142842D1
9 changed files with 171 additions and 19 deletions

View file

@ -22,12 +22,15 @@ import {
CheckIcon,
CrossIcon,
DeleteIcon,
DiscordIcon,
FlameshotIcon,
GitHubIcon,
RefreshIcon,
SettingsIcon,
ShareXIcon,
} from 'components/icons';
import DownloadIcon from 'components/icons/DownloadIcon';
import TrashIcon from 'components/icons/TrashIcon';
import Link from 'components/Link';
import MutedText from 'components/MutedText';
import { SmallTable } from 'components/SmallTable';
@ -51,7 +54,17 @@ function ExportDataTooltip({ children }) {
);
}
export default function Manage() {
export default function Manage({ oauth_registration, oauth_providers: raw_oauth_providers }) {
const oauth_providers = JSON.parse(raw_oauth_providers);
const icons = {
GitHub: GitHubIcon,
Discord: DiscordIcon,
};
for (const provider of oauth_providers) {
provider.Icon = icons[provider.name];
}
const [user, setUser] = useRecoilState(userSelector);
const modals = useModals();
@ -290,6 +303,26 @@ export default function Manage() {
}
};
const handleOauthUnlink = async () => {
const res = await useFetch('/api/auth/oauth', 'DELETE');
if (res.error) {
showNotification({
title: 'Error while unlinking from OAuth',
message: res.error,
color: 'red',
icon: <CrossIcon />,
});
} else {
setUser(res);
showNotification({
title: 'Unlinked from OAuth',
message: '',
color: 'green',
icon: <CheckIcon />,
});
}
};
const interval = useInterval(() => getExports(), 30000);
useEffect(() => {
getExports();
@ -334,7 +367,31 @@ export default function Manage() {
</Group>
</form>
<Box mb='md'>
{oauth_registration && (
<Box my='md'>
<Title>OAuth</Title>
<MutedText size='md'>Link your account with an OAuth provider.</MutedText>
<Group>
{oauth_providers
.filter((x) => x.name.toLowerCase() !== user.oauthProvider)
.map(({ link_url, name, Icon }, i) => (
<Link key={i} href={link_url} passHref legacyBehavior>
<Button size='lg' leftIcon={<Icon />} component='a' my='sm'>
Link account with {name}
</Button>
</Link>
))}
{user.oauth && user.oauthProvider && (
<Button onClick={handleOauthUnlink} size='lg' leftIcon={<TrashIcon />} my='sm' color='red'>
Unlink account with {user.oauthProvider[0].toUpperCase() + user.oauthProvider.slice(1)}
</Button>
)}
</Group>
</Box>
)}
<Box my='md'>
<Title>Avatar</Title>
<FileInput
placeholder='Click to upload a file'

View file

@ -13,11 +13,13 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => {
oauth_providers.push({
name: 'GitHub',
url: '/api/auth/oauth/github',
link_url: '/api/auth/oauth/github?state=link',
});
if (discEnabled)
oauth_providers.push({
name: 'Discord',
url: '/api/auth/oauth/discord',
link_url: '/api/auth/oauth/discord?state=link',
});
return {

View file

@ -1,6 +1,8 @@
export const github_auth = {
oauth_url: (clientId: string) =>
`https://github.com/login/oauth/authorize?client_id=${clientId}&scope=user`,
oauth_url: (clientId: string, state?: string) =>
`https://github.com/login/oauth/authorize?client_id=${clientId}&scope=read:user${
state ? `&state=${state}` : ''
}`,
oauth_user: async (access_token: string) => {
const res = await fetch('https://api.github.com/user', {
headers: {
@ -14,10 +16,10 @@ export const github_auth = {
};
export const discord_auth = {
oauth_url: (clientId: string, origin: string) =>
oauth_url: (clientId: string, origin: string, state?: string) =>
`https://discord.com/api/oauth2/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(
`${origin}/api/auth/oauth/discord`
)}&response_type=code&scope=identify`,
)}&response_type=code&scope=identify${state ? `&state=${state}` : ''}`,
oauth_user: async (access_token: string) => {
const res = await fetch('https://discord.com/api/users/@me', {
headers: {

View file

@ -36,7 +36,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
if (!valid) return res.forbid('Wrong password');
res.setCookie('user', user.id, {
sameSite: true,
sameSite: 'lax',
expires: new Date(Date.now() + 6.048e8 * 2),
path: '/',
});

View file

@ -13,12 +13,13 @@ async function handler(req: NextApiReq, res: NextApiRes) {
return res.bad('Discord OAuth is not configured');
}
const { code } = req.query as { code: string };
const { code, state } = req.query as { code: string; state?: string };
if (!code)
return res.redirect(
discord_auth.oauth_url(
config.oauth.discord_client_id,
`${config.core.https ? 'https' : 'http'}://${req.headers.host}`
`${config.core.https ? 'https' : 'http'}://${req.headers.host}`,
state
)
);
@ -55,7 +56,34 @@ async function handler(req: NextApiReq, res: NextApiRes) {
},
});
if (existing && existing.oauth && existing.oauthProvider === 'discord') {
if (state && state === 'link') {
const user = await req.user();
if (!user) return res.error('not logged in, unable to link account');
if (user.oauth && user.oauthProvider === 'discord')
return res.error('account already linked with discord');
await prisma.user.update({
where: {
id: user.id,
},
data: {
oauth: true,
oauthProvider: 'discord',
oauthAccessToken: json.access_token,
avatar: avatarBase64,
},
});
req.cleanCookie('user');
res.setCookie('user', user.id, {
sameSite: 'lax',
expires: new Date(Date.now() + 6.048e8 * 2),
path: '/',
});
Logger.get('user').info(`User ${user.username} (${user.id}) linked account via oauth(discord)`);
return res.redirect('/');
} else if (existing && existing.oauth && existing.oauthProvider === 'discord') {
await prisma.user.update({
where: {
id: existing.id,
@ -67,7 +95,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
req.cleanCookie('user');
res.setCookie('user', existing.id, {
sameSite: true,
sameSite: 'lax',
expires: new Date(Date.now() + 6.048e8 * 2),
path: '/',
});
@ -92,7 +120,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
req.cleanCookie('user');
res.setCookie('user', user.id, {
sameSite: true,
sameSite: 'lax',
expires: new Date(Date.now() + 6.048e8 * 2),
path: '/',
});

View file

@ -13,9 +13,9 @@ async function handler(req: NextApiReq, res: NextApiRes) {
return res.bad('GitHub OAuth is not configured');
}
const { code } = req.query as { code: string };
const { code, state } = req.query as { code: string; state: string };
if (!code) return res.redirect(github_auth.oauth_url(config.oauth.github_client_id));
if (!code) return res.redirect(github_auth.oauth_url(config.oauth.github_client_id, state));
const resp = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
@ -47,7 +47,33 @@ async function handler(req: NextApiReq, res: NextApiRes) {
},
});
if (existing && existing.oauth && existing.oauthProvider === 'github') {
if (state && state === 'link') {
const user = await req.user();
if (!user) return res.error('not logged in, unable to link account');
if (user.oauth && user.oauthProvider === 'github') return res.error('account already linked with github');
await prisma.user.update({
where: {
id: user.id,
},
data: {
oauth: true,
oauthProvider: 'github',
oauthAccessToken: json.access_token,
avatar: avatarBase64,
},
});
req.cleanCookie('user');
res.setCookie('user', user.id, {
sameSite: 'lax',
expires: new Date(Date.now() + 6.048e8 * 2),
path: '/',
});
Logger.get('user').info(`User ${user.username} (${user.id}) linked account via oauth(github)`);
return res.redirect('/');
} else if (existing && existing.oauth && existing.oauthProvider === 'github') {
await prisma.user.update({
where: {
id: existing.id,
@ -59,7 +85,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
req.cleanCookie('user');
res.setCookie('user', existing.id, {
sameSite: true,
sameSite: 'lax',
expires: new Date(Date.now() + 6.048e8 * 2),
path: '/',
});
@ -84,7 +110,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
req.cleanCookie('user');
res.setCookie('user', user.id, {
sameSite: true,
sameSite: 'lax',
expires: new Date(Date.now() + 6.048e8 * 2),
path: '/',
});

View file

@ -0,0 +1,35 @@
import prisma from 'lib/prisma';
import { NextApiReq, NextApiRes, withZipline } from 'lib/middleware/withZipline';
async function handler(req: NextApiReq, res: NextApiRes) {
const user = await req.user();
if (!user) return res.error('not logged in');
if (req.method === 'DELETE') {
if (!user.password)
return res.forbid("can't unlink account without a password, please set one then unlink.");
const nuser = await prisma.user.update({
where: {
id: user.id,
},
data: {
oauth: false,
oauthProvider: null,
oauthAccessToken: null,
},
});
delete nuser.password;
return res.json(nuser);
} else {
return res.json({
enabled: user.oauth,
provider: user.oauthProvider,
access_token: user.oauthAccessToken,
});
}
}
export default withZipline(handler);

View file

@ -41,7 +41,9 @@ async function handler(req: NextApiReq, res: NextApiRes) {
});
for (let i = 0; i !== files.length; ++i) {
await datasource.delete(files[i].file);
try {
await datasource.delete(files[i].file);
} catch (e) {}
}
const { count } = await prisma.image.deleteMany({

View file

@ -18,7 +18,7 @@ export default function ManagePage(props) {
<title>{title}</title>
</Head>
<Layout props={props}>
<Manage />
<Manage oauth_providers={props.oauth_providers} oauth_registration={props.oauth_registration} />
</Layout>
</>
);