Implement Discord linking
Some checks failed
/ build (push) Failing after 4m43s

This commit is contained in:
Jorge Vargas 2025-03-21 11:35:03 -06:00
parent 0ec019f959
commit 4cafc41b88
11 changed files with 192 additions and 77 deletions

View file

@ -21,7 +21,11 @@ export default defineConfig({
optional: true, optional: true,
default: 'http://localhost:4321' default: 'http://localhost:4321'
}), }),
WEBHOOK_URL: envField.string({ context: 'server', access: 'secret' }) WEBHOOK_URL: envField.string({ context: 'server', access: 'secret' }),
DISCORD_OAUTH_ID: envField.string({ context: 'server', access: 'public' }),
DISCORD_OAUTH_SECRET: envField.string({ context: 'server', access: 'secret' }),
DISCORD_GUILD_ID: envField.string({ context: 'server', access: 'public' }),
DISCORD_DONATOR_ID: envField.string({ context: 'server', access: 'public' })
}, },
validateSecrets: true validateSecrets: true
}, },
@ -53,6 +57,7 @@ export default defineConfig({
'/game/list': { status: 307, destination: '/maintenance' }, '/game/list': { status: 307, destination: '/maintenance' },
'/platform/list': { status: 307, destination: '/maintenance' }, '/platform/list': { status: 307, destination: '/maintenance' },
'/platform/[id]': { status: 307, destination: '/maintenance' }, '/platform/[id]': { status: 307, destination: '/maintenance' },
'/profile': { status: 307, destination: '/maintenance' },
'/profile/[username]': { status: 307, destination: '/maintenance' }, '/profile/[username]': { status: 307, destination: '/maintenance' },
'/series/[slug]': { status: 307, destination: '/maintenance' }, '/series/[slug]': { status: 307, destination: '/maintenance' },
'/series/list': { status: 307, destination: '/maintenance' }, '/series/list': { status: 307, destination: '/maintenance' },

View file

@ -1,69 +1,77 @@
{ {
"$schema": "https://inlang.com/schema/inlang-message-format", "$schema": "https://inlang.com/schema/inlang-message-format",
"register": "Register", "register": "Register",
"login": "Login", "login": "Login",
"logout": "Logout", "logout": "Logout",
"username": "Username", "username": "Username",
"password": "Password", "password": "Password",
"email": "Email", "email": "Email",
"recoverPassword": "Recover Password", "recoverPassword": "Recover Password",
"home": "Home", "home": "Home",
"lastaddednav": "Last Added", "lastaddednav": "Last Added",
"albumlist": "Album List", "albumlist": "Album List",
"games": "Games", "games": "Games",
"albums": "Albums", "albums": "Albums",
"series": "Series", "series": "Series",
"publishers": "Publishers", "publishers": "Publishers",
"platforms": "Platforms", "platforms": "Platforms",
"gamelist": "Game List", "gamelist": "Game List",
"animation": "Animation", "animation": "Animation",
"animationlist": "Animation List", "animationlist": "Animation List",
"studios": "Studios", "studios": "Studios",
"requests": "Requests", "requests": "Requests",
"submitalbum": "Submit Album", "submitalbum": "Submit Album",
"adminGrounds": "Admin Grounds", "adminGrounds": "Admin Grounds",
"manageAlbums": "Manage Albums", "manageAlbums": "Manage Albums",
"manageUsers": "Manage Users", "manageUsers": "Manage Users",
"manageRequests": "Manage Requests", "manageRequests": "Manage Requests",
"manageSubmissions": "Manage Submissions", "manageSubmissions": "Manage Submissions",
"profilePic": "Profile picture", "profilePic": "Profile picture",
"emailSuccess": "An email with further instructions has been sent to the address linked to the account. Check your spam folder.", "emailSuccess": "An email with further instructions has been sent to the address linked to the account. Check your spam folder.",
"close": "Close", "close": "Close",
"newPassword": "New password", "newPassword": "New password",
"newPasswordRetype": "Re-type new password", "newPasswordRetype": "Re-type new password",
"savePassword": "Save Password", "savePassword": "Save Password",
"passwordResetSuccesful": "Password reset succesfully", "passwordResetSuccesful": "Password reset succesfully",
"displayName": "Display name", "displayName": "Display name",
"lastAddedSidebar": "Last Added", "lastAddedSidebar": "Last Added",
"getLucky": "Get Lucky", "getLucky": "Get Lucky",
"randomPull": "Random Pull", "randomPull": "Random Pull",
"highlightAlbum": "Highlight Soundtrack", "highlightAlbum": "Highlight Soundtrack",
"ostCount": "Soundtrack Count", "ostCount": "Soundtrack Count",
"recentReleases": "Recent Releases", "recentReleases": "Recent Releases",
"moreGameReleases": "More Game Releases", "moreGameReleases": "More Game Releases",
"moreAnimReleases": "more Animation releases", "moreAnimReleases": "more Animation releases",
"moreLastAdded": "more Last Added", "moreLastAdded": "more Last Added",
"lastAdded": "Last Added", "lastAdded": "Last Added",
"releaseDate": "Release Date", "releaseDate": "Release Date",
"artists": "Artists", "artists": "Artists",
"classification": "Classification", "classification": "Classification",
"AnimationOsts": "Animation Soundtracks", "AnimationOsts": "Animation Soundtracks",
"GameOsts": "Game Soundtracks", "GameOsts": "Game Soundtracks",
"publishedBy": "Published by", "publishedBy": "Published by",
"animations": "Animations", "animations": "Animations",
"avgRating": "Average Rating", "avgRating": "Average Rating",
"tracklist": "Tracklist", "tracklist": "Tracklist",
"donationCall": "Consider Donating to remove ads", "donationCall": "Consider Donating to remove ads",
"donationSteps": "After donating, if the donation e-mail is the same as the one used in the notation, it should be\r\n available in a few hours. If not, contact us on", "donationSteps": "After donating, if the donation e-mail is the same as the one used in the notation, it should be\r\n available in a few hours. If not, contact us on",
"brokenLinkContact": "Broken Link? Contact us at Join our Discord!", "brokenLinkContact": "Broken Link? Contact us at Join our Discord!",
"mediafirePermission": "MediaFire permission denied?", "mediafirePermission": "MediaFire permission denied?",
"mediafirePermissionGuide": "Check this guide", "mediafirePermissionGuide": "Check this guide",
"disc": "Disc", "disc": "Disc",
"checkVGMDB": "Check album at", "checkVGMDB": "Check album at",
"buyOriginal": "Buy The Original Soundtrack to support the artists", "buyOriginal": "Buy The Original Soundtrack to support the artists",
"download": "Download", "download": "Download",
"flyInc": "Fly.inc", "flyInc": "Fly.inc",
"ouoIO": "ouo.io", "ouoIO": "ouo.io",
"direct": "Direct", "direct": "Direct",
"relatedAlbums": "Related Albums" "relatedAlbums": "Related Albums",
} "loginDiscord": "Login using Discord",
"profile": "Profile",
"linkDiscord": "Link Discord account",
"loggedInPage": "You need to be logged in to access this page",
"alreadyDonator": "You are already a donator!",
"discordNeeded": "You need to link your Discord account to access this page",
"discordRoleNeeded": "You need the Donator Discord role to access this page",
"addedDonator": "Added donator benefits to your account!"
}

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `verification` MODIFY `value` TEXT NOT NULL;

View file

@ -442,7 +442,7 @@ model session {
model verification { model verification {
id String @id id String @id
identifier String identifier String
value String value String @db.Text
expiresAt DateTime expiresAt DateTime
createdAt DateTime createdAt DateTime
updatedAt DateTime updatedAt DateTime

View file

@ -1,6 +1,7 @@
import { betterAuth } from 'better-auth' import { betterAuth } from 'better-auth'
import { prismaAdapter } from 'better-auth/adapters/prisma' import { prismaAdapter } from 'better-auth/adapters/prisma'
import { username, bearer } from 'better-auth/plugins' import { username, bearer } from 'better-auth/plugins'
import { DISCORD_OAUTH_ID, DISCORD_OAUTH_SECRET } from 'astro:env/server'
import prismaClient from './utils/prisma-client' import prismaClient from './utils/prisma-client'
import { sendEmail } from './utils/email' import { sendEmail } from './utils/email'
@ -11,6 +12,19 @@ export const auth = betterAuth({
database: prismaAdapter(prismaClient, { provider: 'mysql' }), database: prismaAdapter(prismaClient, { provider: 'mysql' }),
user: { modelName: 'users' }, user: { modelName: 'users' },
plugins: [username(), bearer()], plugins: [username(), bearer()],
account: {
accountLinking: {
enabled: true,
allowDifferentEmails: true
}
},
socialProviders: {
discord: {
clientId: DISCORD_OAUTH_ID,
clientSecret: DISCORD_OAUTH_SECRET,
scope: ['identify', 'email', 'guilds.members.read']
}
},
emailVerification: { emailVerification: {
sendOnSignUp: true, sendOnSignUp: true,
autoSignInAfterVerification: true, autoSignInAfterVerification: true,

View file

@ -79,13 +79,20 @@ function LoginForm(props: { setForm: SetState<FormOptions>; setModalOpen: SetSta
/> />
</div> </div>
</div> </div>
<div className='flex'> <div className='flex justify-center gap-x-2'>
<Button loading={loading} disabled={loading} className='mx-auto px-6'> <Button loading={loading} disabled={loading} className='px-6' type='submit'>
{m.login()} {m.login()}
</Button> </Button>
<Button
onClick={(ev) => {
ev.preventDefault()
signIn.social({ provider: 'discord', callbackURL: window.location.href })
}}
>
{m.loginDiscord()}
</Button>
</div> </div>
</form> </form>
<div className='mx-auto'> <div className='mx-auto'>
<Button <Button
onClick={() => { onClick={() => {

View file

@ -1,15 +1,37 @@
--- ---
import * as m from 'paraglide/messages'
import prismaClient from 'utils/prisma-client'
import clsx from 'clsx'
import RegisterBtn from './RegisterButton' import RegisterBtn from './RegisterButton'
import LoginBtn from './LoginButton' import LoginBtn from './LoginButton'
import LogoutBtn from './LogoutButton' import LogoutBtn from './LogoutButton'
import Button from 'components/Button'
const session = Astro.locals.session const { permissions, session, user } = Astro.locals
const isDonator = permissions.includes('SKIP_ADS')
const discordAcc = user
? await prismaClient.account.findFirst({
where: { providerId: 'discord', userId: user.id }
})
: null
--- ---
<div class='px-2 flex gap-x-2 justify-end absolute w-full md:ms-auto md:block md:w-auto md:static'> <div class='px-2 flex gap-x-2 justify-end absolute w-full md:ms-auto md:block md:w-auto md:static'>
{ {
session ? ( session ? (
<LogoutBtn client:only='react' /> <>
{!discordAcc ? (
<Button id='link-discord' className='rounded-t-none'>
{m.linkDiscord()}
</Button>
) : null}
<a href='/profile'>
<Button className={clsx(['rounded-t-none', { '!bg-amber-400': isDonator }])}>{m.profile()}</Button>
</a>
<LogoutBtn client:only='react' />
</>
) : ( ) : (
<> <>
<LoginBtn client:only='react' /> <LoginBtn client:only='react' />
@ -18,3 +40,12 @@ const session = Astro.locals.session
) )
} }
</div> </div>
<script>
import { linkSocial } from 'utils/auth-client'
const discordBtn = document.getElementById('link-discord')
discordBtn?.addEventListener('click', () => {
linkSocial({ provider: 'discord', callbackURL: window.location.href })
})
</script>

1
src/env.d.ts vendored
View file

@ -6,5 +6,6 @@ declare namespace App {
session: import('better-auth').Session | null session: import('better-auth').Session | null
permissions: string[] permissions: string[]
pages: string[] pages: string[]
roles: string[]
} }
} }

View file

@ -14,7 +14,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
context.locals.session = isAuthed.session context.locals.session = isAuthed.session
const user = await prismaClient.users.findUnique({ const user = await prismaClient.users.findUnique({
select: { roles: { select: { roles: { select: { permissions: true } } } } }, select: { roles: { select: { roleName: true, roles: { select: { permissions: true } } } } },
where: { id: isAuthed.user.id } where: { id: isAuthed.user.id }
}) })
const permissions = (user?.roles.map((r) => r.roles.permissions).flat() as string[]) ?? [] const permissions = (user?.roles.map((r) => r.roles.permissions).flat() as string[]) ?? []
@ -22,6 +22,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
context.locals.permissions = permissions context.locals.permissions = permissions
context.locals.pages = pages context.locals.pages = pages
context.locals.roles = user?.roles.map((r) => r.roleName) ?? []
} else { } else {
context.locals.user = null context.locals.user = null
context.locals.session = null context.locals.session = null

View file

@ -0,0 +1,46 @@
---
import * as m from 'paraglide/messages'
import { DISCORD_DONATOR_ID } from 'astro:env/server'
import prismaClient from 'utils/prisma-client'
const { session, user, roles } = Astro.locals
async function getMessage() {
if (!user || !session) {
return m.loggedInPage()
}
if (roles.includes('Donator')) {
return m.alreadyDonator()
}
const discordAcc = await prismaClient.account.findFirst({
where: { providerId: 'discord', userId: session.userId }
})
if (!discordAcc) {
return m.discordNeeded()
}
const memberInfoRes = await fetch(`https://discord.com/api/users/@me/guilds/535484312124915714/member`, {
headers: {
Authorization: `Bearer ${discordAcc.accessToken}`,
'Content-Type': 'application/x-www-form-urlencoded'
}
})
const memberInfo: { roles: string[] } = await memberInfoRes.json()
const isDiscordDonator = memberInfo.roles.includes(DISCORD_DONATOR_ID)
if (!isDiscordDonator) {
return m.discordRoleNeeded()
}
await prismaClient.user_Role.create({ data: { userUsername: user.id, roleName: 'Donator' } })
return m.addedDonator()
}
const message = await getMessage()
---
<div>{message}</div>

View file

@ -4,4 +4,4 @@ import { usernameClient } from 'better-auth/client/plugins'
export const authClient = createAuthClient({ export const authClient = createAuthClient({
plugins: [usernameClient()] plugins: [usernameClient()]
}) })
export const { useSession, signIn, signUp, signOut, forgetPassword, resetPassword } = authClient export const { useSession, signIn, signUp, signOut, forgetPassword, resetPassword, linkSocial } = authClient