feat: ability to link existing accounts to oauth
This commit is contained in:
parent
0847802ce4
commit
561849ae5b
9 changed files with 171 additions and 19 deletions
|
@ -22,12 +22,15 @@ import {
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
CrossIcon,
|
CrossIcon,
|
||||||
DeleteIcon,
|
DeleteIcon,
|
||||||
|
DiscordIcon,
|
||||||
FlameshotIcon,
|
FlameshotIcon,
|
||||||
|
GitHubIcon,
|
||||||
RefreshIcon,
|
RefreshIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
ShareXIcon,
|
ShareXIcon,
|
||||||
} from 'components/icons';
|
} from 'components/icons';
|
||||||
import DownloadIcon from 'components/icons/DownloadIcon';
|
import DownloadIcon from 'components/icons/DownloadIcon';
|
||||||
|
import TrashIcon from 'components/icons/TrashIcon';
|
||||||
import Link from 'components/Link';
|
import Link from 'components/Link';
|
||||||
import MutedText from 'components/MutedText';
|
import MutedText from 'components/MutedText';
|
||||||
import { SmallTable } from 'components/SmallTable';
|
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 [user, setUser] = useRecoilState(userSelector);
|
||||||
const modals = useModals();
|
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);
|
const interval = useInterval(() => getExports(), 30000);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getExports();
|
getExports();
|
||||||
|
@ -334,7 +367,31 @@ export default function Manage() {
|
||||||
</Group>
|
</Group>
|
||||||
</form>
|
</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>
|
<Title>Avatar</Title>
|
||||||
<FileInput
|
<FileInput
|
||||||
placeholder='Click to upload a file'
|
placeholder='Click to upload a file'
|
||||||
|
|
|
@ -13,11 +13,13 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
||||||
oauth_providers.push({
|
oauth_providers.push({
|
||||||
name: 'GitHub',
|
name: 'GitHub',
|
||||||
url: '/api/auth/oauth/github',
|
url: '/api/auth/oauth/github',
|
||||||
|
link_url: '/api/auth/oauth/github?state=link',
|
||||||
});
|
});
|
||||||
if (discEnabled)
|
if (discEnabled)
|
||||||
oauth_providers.push({
|
oauth_providers.push({
|
||||||
name: 'Discord',
|
name: 'Discord',
|
||||||
url: '/api/auth/oauth/discord',
|
url: '/api/auth/oauth/discord',
|
||||||
|
link_url: '/api/auth/oauth/discord?state=link',
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
export const github_auth = {
|
export const github_auth = {
|
||||||
oauth_url: (clientId: string) =>
|
oauth_url: (clientId: string, state?: string) =>
|
||||||
`https://github.com/login/oauth/authorize?client_id=${clientId}&scope=user`,
|
`https://github.com/login/oauth/authorize?client_id=${clientId}&scope=read:user${
|
||||||
|
state ? `&state=${state}` : ''
|
||||||
|
}`,
|
||||||
oauth_user: async (access_token: string) => {
|
oauth_user: async (access_token: string) => {
|
||||||
const res = await fetch('https://api.github.com/user', {
|
const res = await fetch('https://api.github.com/user', {
|
||||||
headers: {
|
headers: {
|
||||||
|
@ -14,10 +16,10 @@ export const github_auth = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const discord_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(
|
`https://discord.com/api/oauth2/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(
|
||||||
`${origin}/api/auth/oauth/discord`
|
`${origin}/api/auth/oauth/discord`
|
||||||
)}&response_type=code&scope=identify`,
|
)}&response_type=code&scope=identify${state ? `&state=${state}` : ''}`,
|
||||||
oauth_user: async (access_token: string) => {
|
oauth_user: async (access_token: string) => {
|
||||||
const res = await fetch('https://discord.com/api/users/@me', {
|
const res = await fetch('https://discord.com/api/users/@me', {
|
||||||
headers: {
|
headers: {
|
||||||
|
|
|
@ -36,7 +36,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||||
if (!valid) return res.forbid('Wrong password');
|
if (!valid) return res.forbid('Wrong password');
|
||||||
|
|
||||||
res.setCookie('user', user.id, {
|
res.setCookie('user', user.id, {
|
||||||
sameSite: true,
|
sameSite: 'lax',
|
||||||
expires: new Date(Date.now() + 6.048e8 * 2),
|
expires: new Date(Date.now() + 6.048e8 * 2),
|
||||||
path: '/',
|
path: '/',
|
||||||
});
|
});
|
||||||
|
|
|
@ -13,12 +13,13 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||||
return res.bad('Discord OAuth is not configured');
|
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)
|
if (!code)
|
||||||
return res.redirect(
|
return res.redirect(
|
||||||
discord_auth.oauth_url(
|
discord_auth.oauth_url(
|
||||||
config.oauth.discord_client_id,
|
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({
|
await prisma.user.update({
|
||||||
where: {
|
where: {
|
||||||
id: existing.id,
|
id: existing.id,
|
||||||
|
@ -67,7 +95,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||||
|
|
||||||
req.cleanCookie('user');
|
req.cleanCookie('user');
|
||||||
res.setCookie('user', existing.id, {
|
res.setCookie('user', existing.id, {
|
||||||
sameSite: true,
|
sameSite: 'lax',
|
||||||
expires: new Date(Date.now() + 6.048e8 * 2),
|
expires: new Date(Date.now() + 6.048e8 * 2),
|
||||||
path: '/',
|
path: '/',
|
||||||
});
|
});
|
||||||
|
@ -92,7 +120,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||||
|
|
||||||
req.cleanCookie('user');
|
req.cleanCookie('user');
|
||||||
res.setCookie('user', user.id, {
|
res.setCookie('user', user.id, {
|
||||||
sameSite: true,
|
sameSite: 'lax',
|
||||||
expires: new Date(Date.now() + 6.048e8 * 2),
|
expires: new Date(Date.now() + 6.048e8 * 2),
|
||||||
path: '/',
|
path: '/',
|
||||||
});
|
});
|
||||||
|
|
|
@ -13,9 +13,9 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||||
return res.bad('GitHub OAuth is not configured');
|
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', {
|
const resp = await fetch('https://github.com/login/oauth/access_token', {
|
||||||
method: 'POST',
|
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({
|
await prisma.user.update({
|
||||||
where: {
|
where: {
|
||||||
id: existing.id,
|
id: existing.id,
|
||||||
|
@ -59,7 +85,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||||
|
|
||||||
req.cleanCookie('user');
|
req.cleanCookie('user');
|
||||||
res.setCookie('user', existing.id, {
|
res.setCookie('user', existing.id, {
|
||||||
sameSite: true,
|
sameSite: 'lax',
|
||||||
expires: new Date(Date.now() + 6.048e8 * 2),
|
expires: new Date(Date.now() + 6.048e8 * 2),
|
||||||
path: '/',
|
path: '/',
|
||||||
});
|
});
|
||||||
|
@ -84,7 +110,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||||
|
|
||||||
req.cleanCookie('user');
|
req.cleanCookie('user');
|
||||||
res.setCookie('user', user.id, {
|
res.setCookie('user', user.id, {
|
||||||
sameSite: true,
|
sameSite: 'lax',
|
||||||
expires: new Date(Date.now() + 6.048e8 * 2),
|
expires: new Date(Date.now() + 6.048e8 * 2),
|
||||||
path: '/',
|
path: '/',
|
||||||
});
|
});
|
||||||
|
|
35
src/pages/api/auth/oauth/index.ts
Normal file
35
src/pages/api/auth/oauth/index.ts
Normal 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);
|
|
@ -41,7 +41,9 @@ async function handler(req: NextApiReq, res: NextApiRes) {
|
||||||
});
|
});
|
||||||
|
|
||||||
for (let i = 0; i !== files.length; ++i) {
|
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({
|
const { count } = await prisma.image.deleteMany({
|
||||||
|
|
|
@ -18,7 +18,7 @@ export default function ManagePage(props) {
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<Layout props={props}>
|
<Layout props={props}>
|
||||||
<Manage />
|
<Manage oauth_providers={props.oauth_providers} oauth_registration={props.oauth_registration} />
|
||||||
</Layout>
|
</Layout>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
Loading…
Add table
Reference in a new issue