feat: basic discord connection support

This commit is contained in:
niko 2025-05-27 04:20:02 -04:00
parent 6abdcc718e
commit 0f5da1dba1
No known key found for this signature in database
GPG Key ID: C3B29CF2BA3406AD
7 changed files with 256 additions and 12 deletions

View File

@ -26,11 +26,28 @@ export default defineNuxtConfig({
runtimeConfig: {
apiBase: 'https://api.pretendo.cc',
discord: {
botToken: '',
clientID: '',
clientSecret: ''
},
public: {
hCaptchaSitekey: ''
}
},
hooks: {
'pages:extend'(pages) {
// add a route
pages.push({
name: 'connect',
path: '/account/connect/:connection',
file: '~/pages/account/index.vue'
});
}
},
css: ['~/assets/css/main.css'],
fonts: {

26
package-lock.json generated
View File

@ -16,6 +16,8 @@
"@nuxt/fonts": "^0.11.1",
"@nuxt/icon": "^1.11.0",
"@nuxtjs/i18n": "^9.5.3",
"discord-api-types": "^0.38.8",
"discord-oauth2": "^2.12.1",
"eslint": "^9.24.0",
"feed": "^4.2.2",
"nuxt": "^3.16.2",
@ -24,6 +26,7 @@
},
"devDependencies": {
"@pretendonetwork/eslint-config": "^0.0.8",
"@types/node": "^22.15.21",
"eslint-plugin-vue": "^10.0.0",
"sass-embedded": "^1.86.3"
}
@ -3913,6 +3916,16 @@
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="
},
"node_modules/@types/node": {
"version": "22.15.21",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.21.tgz",
"integrity": "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/normalize-package-data": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz",
@ -6449,6 +6462,12 @@
"scripts/actions/documentation"
]
},
"node_modules/discord-oauth2": {
"version": "2.12.1",
"resolved": "https://registry.npmjs.org/discord-oauth2/-/discord-oauth2-2.12.1.tgz",
"integrity": "sha512-/Um39bRxVjcGHUu1YaTLangZvZveXjsX4BNsa1Iyd6OQG0jL972IBQGKD0mYqQswxC3bT+hqWSouabfI2RdaZA==",
"license": "MIT"
},
"node_modules/doctrine": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
@ -15046,6 +15065,13 @@
"node": ">=18.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/unenv": {
"version": "2.0.0-rc.15",
"resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.15.tgz",

View File

@ -20,14 +20,20 @@
"@nuxt/fonts": "^0.11.1",
"@nuxt/icon": "^1.11.0",
"@nuxtjs/i18n": "^9.5.3",
"discord-api-types": "^0.38.8",
"discord-oauth2": "^2.12.1",
"eslint": "^9.24.0",
"feed": "^4.2.2",
"nuxt": "^3.16.2",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"browser": {
"crypto": false
},
"devDependencies": {
"@pretendonetwork/eslint-config": "^0.0.8",
"@types/node": "^22.15.21",
"eslint-plugin-vue": "^10.0.0",
"sass-embedded": "^1.86.3"
}

View File

@ -24,9 +24,49 @@ interface InfoCCResponse {
};
}
interface DiscordInfoResponse {
username?: string;
authLink?: string;
}
const route = useRoute();
const router = useRouter();
const account = await useFetch<InfoCCResponse>('/api/account/info'); // TODO: easy way to share this between navbar and account page? is that a good idea?
const showingEditingDisabledModal = ref(false);
if (route.params.connection) { // my nightmares are made of this logic
if (import.meta.server) {
switch (route.params.connection) { // future proofing for more connections?
case 'discord': {
if (route.query.code) {
await useFetch('/api/account/connections/discord/add', {
method: 'POST',
body: {
code: route.query.code
}
});
}
break;
}
default:
break;
}
}
router.replace('/account');
}
const discordInfo = await useFetch<DiscordInfoResponse>('/api/account/connections/discord/info');
async function handleUnlinkDiscord() {
try {
await $fetch('/api/account/connections/discord/remove', { method: 'DELETE' });
discordInfo.refresh();
} catch {
console.log('uhoh');
}
}
</script>
<template>
@ -333,17 +373,23 @@ const showingEditingDisabledModal = ref(false);
<h2 class="header">
{{ $t("account.settings.settingCards.discord") }}
</h2>
<!--{{#if discordUser}}
<p>{{ locale.account.settings.settingCards.connectedToDiscord }} {{ discordUser.username }}#{{ discordUser.discriminator }}</p>
<a href="/account/remove/discord">
<button
id="remove-discord-connection"
class="button secondary"
>{{ locale.account.settings.settingCards.removeDiscord }}</button>
</a>
{{else}}
<p>{{ locale.account.settings.settingCards.noDiscordLinked }} <a href="{{ discordAuthURL }}">{{ locale.account.settings.settingCards.linkDiscord }}</a></p>
{{/if}}-->
<template
v-if="discordInfo.data.value?.username"
>
<p>{{ $t("account.settings.settingCards.connectedToDiscord") }} {{ discordInfo.data.value.username }}</p>
<button
id="remove-discord-connection"
class="button secondary"
@click="handleUnlinkDiscord"
>
{{ $t("account.settings.settingCards.removeDiscord") }}
</button>
</template>
<template
v-else
>
<p>{{ $t("account.settings.settingCards.noDiscordLinked") }} <a :href="discordInfo.data.value?.authLink">{{ $t("account.settings.settingCards.linkDiscord") }}</a></p>
</template>
</div>
<div class="setting-card">
@ -481,7 +527,7 @@ const showingEditingDisabledModal = ref(false);
left: 0;
width: 100%;
height: 100%;
background: no-repeat center/40% url("/assets/images/edit.svg"), rgba(55, 60, 101, 0.7);
background: no-repeat center/40% /*url("/assets/images/edit.svg"),*/ rgba(55, 60, 101, 0.7);
opacity: 0;
transition: opacity 150ms;
}

View File

@ -0,0 +1,49 @@
import DiscordOauth2 from 'discord-oauth2';
import { FetchError } from 'ofetch';
export default defineEventHandler(async (event) => {
const body = await readBody(event);
const cookies = parseCookies(event);
const discordOauth = new DiscordOauth2({
clientId: useRuntimeConfig(event).discord.clientID,
clientSecret: useRuntimeConfig(event).discord.clientSecret,
redirectUri: `http://localhost:3000/account/connect/discord`,
version: 'v10'
});
try {
// Attempt to get OAuth2 tokens
const tokens = await discordOauth.tokenRequest({
code: body.code,
scope: 'identify guilds',
grantType: 'authorization_code'
});
const discordUser = await discordOauth.getUser(tokens.access_token);
await $fetch(`/v1/connections/add/discord`, {
method: 'POST',
baseURL: useRuntimeConfig(event).apiBase,
headers: { Authorization: `${cookies?.token_type} ${cookies?.access_token}` },
body: {
data: {
id: discordUser.id
}
}
});
return { success: true }; // TODO: figure out why $fetch doesn't send cookies but useFetch does so this dumb fix can be removed
} catch (error: unknown) {
console.log(error);
if (error instanceof FetchError) {
setResponseStatus(event, error.status, error.data.error);
} else {
setResponseStatus(event, 500);
}
event.node.res.end();
return;
}
});

View File

@ -0,0 +1,76 @@
import * as crypto from 'crypto';
import { REST as DiscordRest } from '@discordjs/rest';
import { Routes } from 'discord-api-types/v10';
import { FetchError } from 'ofetch';
import DiscordOauth2 from 'discord-oauth2';
import type { APIUser } from 'discord-api-types/v10';
interface InfoCCResponse { // TODO: de-dupe
username: string;
access_level: number;
mii: {
name: string;
image_url: string;
};
connections: {
stripe?: {
tier_name?: string;
tier_level?: string;
};
discord?: {
id: string;
};
};
birthdate: string;
gender: string;
country: string;
timezone: {
name: string;
};
server_access_level: string;
email: {
address: string;
};
}
export default defineEventHandler(async (event) => {
const discordRest = new DiscordRest({ version: '10' }).setToken(useRuntimeConfig(event).discord.botToken);
const discordOauth = new DiscordOauth2({
clientId: useRuntimeConfig(event).discord.clientID,
clientSecret: useRuntimeConfig(event).discord.clientSecret,
redirectUri: `http://localhost:3000/account/connect/discord`,
version: 'v10'
});
try {
const cookies = parseCookies(event);
const accountInfo = await $fetch<InfoCCResponse>(`/v1/user`, {
method: 'GET',
baseURL: useRuntimeConfig(event).apiBase,
headers: { Authorization: `${cookies?.token_type} ${cookies?.access_token}` }
});
if (!accountInfo.connections.discord?.id) {
return {
authLink: discordOauth.generateAuthUrl({
scope: ['identify', 'guilds'],
state: crypto.randomBytes(16).toString('hex')
})
};
}
return await discordRest.get(Routes.user(accountInfo.connections.discord?.id)) as APIUser;
} catch (error: unknown) {
console.log(error);
if (error instanceof FetchError) {
setResponseStatus(event, error.status, error.data.error);
} else {
setResponseStatus(event, 500);
}
event.node.res.end();
return;
}
});

View File

@ -0,0 +1,24 @@
import { FetchError } from 'ofetch';
export default defineEventHandler(async (event) => {
const cookies = parseCookies(event);
try {
await $fetch(`/v1/connections/remove/discord`, {
method: 'DELETE',
baseURL: useRuntimeConfig(event).apiBase,
headers: { Authorization: `${cookies?.token_type} ${cookies?.access_token}` }
});
} catch (error: unknown) {
console.log(error);
if (error instanceof FetchError) {
setResponseStatus(event, error.status, error.data.error);
} else {
setResponseStatus(event, 500);
}
event.node.res.end();
return;
}
});