feat: use discord_id from discord sso for role grant (#6326)

* feat: properly use labrinth's linked discord accts sso for discord bot

* Update email for fixture user in SQL insert

Signed-off-by: Calum H. <calum@modrinth.com>

* fix: rev changes

* fix: copy on email

* fix: lint

* fix: rev

* fix: lint

---------

Signed-off-by: Calum H. <calum@modrinth.com>
This commit is contained in:
Calum H.
2026-06-09 18:33:07 +01:00
committed by GitHub
parent bc5a761312
commit 543d25e2d6
20 changed files with 726 additions and 59 deletions
@@ -1466,6 +1466,24 @@
"dashboard.creator-withdraw-modal.withdraw-limit-used": {
"message": "You've used up your <b>{withdrawLimit}</b> withdrawal limit. You must complete a tax form to withdraw more."
},
"dashboard.discord-roles.banner.body": {
"message": "You're eligible for {roles}. Link your Discord account through Modrinth and we'll sync them automatically."
},
"dashboard.discord-roles.banner.cta": {
"message": "Link Discord"
},
"dashboard.discord-roles.banner.title": {
"message": "Claim your Discord roles"
},
"dashboard.discord-roles.role.big-creator": {
"message": "1M+ Downloads"
},
"dashboard.discord-roles.role.creator": {
"message": "Creator"
},
"dashboard.discord-roles.role.pride": {
"message": "Pride 2026"
},
"dashboard.head-title": {
"message": "Dashboard"
},
+125 -3
View File
@@ -48,28 +48,69 @@
/>
</div>
<div class="normal-page__content mt-4 lg:!mt-0">
<Admonition
v-if="showDiscordRoleBanner"
class="mb-3"
type="info"
:header="formatMessage(messages.discordRoleBannerTitle)"
show-actions-underneath
dismissible
@dismiss="dismissDiscordRoleBanner"
>
<div class="text-primary">
{{
formatMessage(messages.discordRoleBannerBody, {
roles: eligibleDiscordRolesLabel,
})
}}
</div>
<template #actions>
<ButtonStyled color="blue">
<NuxtLink to="/discord/link" class="w-fit !px-4">
<ExternalIcon />
{{ formatMessage(messages.discordRoleBannerCta) }}
</NuxtLink>
</ButtonStyled>
</template>
</Admonition>
<NuxtPage :route="route" />
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
AffiliateIcon,
BellIcon as NotificationsIcon,
ChartIcon,
CurrencyIcon,
DashboardIcon,
ExternalIcon,
LibraryIcon,
ListIcon,
OrganizationIcon,
ReportIcon,
} from '@modrinth/assets'
import { commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
import { type User, UserBadge } from '@modrinth/utils'
import {
Admonition,
ButtonStyled,
commonMessages,
defineMessages,
injectModrinthClient,
useVIntl,
} from '@modrinth/ui'
import { UserBadge } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import { useLocalStorage } from '@vueuse/core'
import NavStack from '~/components/ui/NavStack.vue'
const auth = (await useAuth()) as Ref<{ user: User | null }>
const auth = (await useAuth()) as Ref<{ user: Labrinth.Users.v3.User | null }>
const client = injectModrinthClient()
const dismissedDiscordRoleBannerUsers = useLocalStorage<string[]>(
'dashboard-discord-role-banner-dismissed-users',
[],
)
const isAffiliate = computed(() => {
return auth.value.user && auth.value.user.badges & UserBadge.AFFILIATE
@@ -114,6 +155,31 @@ const messages = defineMessages({
id: 'dashboard.sidebar.label.revenue',
defaultMessage: 'Revenue',
},
discordRoleBannerTitle: {
id: 'dashboard.discord-roles.banner.title',
defaultMessage: 'Claim your Discord roles',
},
discordRoleBannerBody: {
id: 'dashboard.discord-roles.banner.body',
defaultMessage:
"You're eligible for {roles}. Link your Discord account through Modrinth and we'll sync them automatically.",
},
discordRoleBannerCta: {
id: 'dashboard.discord-roles.banner.cta',
defaultMessage: 'Link Discord',
},
discordRolePride: {
id: 'dashboard.discord-roles.role.pride',
defaultMessage: 'Pride 2026',
},
discordRoleCreator: {
id: 'dashboard.discord-roles.role.creator',
defaultMessage: 'Creator',
},
discordRoleBigCreator: {
id: 'dashboard.discord-roles.role.big-creator',
defaultMessage: '1M+ Downloads',
},
})
definePageMeta({
@@ -125,4 +191,60 @@ useSeoMeta({
})
const route = useNativeRoute()
const { data: projects } = useQuery({
queryKey: computed(() => ['dashboard-discord-role-eligibility', auth.value.user?.id, 'projects']),
queryFn: () => {
const userId = auth.value.user?.id
if (!userId) return []
return client.labrinth.users_v2.getProjects(userId)
},
enabled: computed(() => !!auth.value.user?.id),
})
const totalProjectDownloads = computed(() =>
(projects.value ?? []).reduce((total, project) => total + (project.downloads ?? 0), 0),
)
const eligibleDiscordRoles = computed(() => {
const roles = []
if (auth.value.user?.campaigns?.pride_26?.has_badge === true) {
roles.push(formatMessage(messages.discordRolePride))
}
if (totalProjectDownloads.value >= 20_000) {
roles.push(formatMessage(messages.discordRoleCreator))
}
if (totalProjectDownloads.value >= 1_000_000) {
roles.push(formatMessage(messages.discordRoleBigCreator))
}
return roles
})
const roleListFormatter = new Intl.ListFormat(undefined, {
style: 'long',
type: 'conjunction',
})
const eligibleDiscordRolesLabel = computed(() =>
roleListFormatter.format(eligibleDiscordRoles.value),
)
const hasDismissedDiscordRoleBanner = computed(() =>
dismissedDiscordRoleBannerUsers.value.includes(auth.value.user?.id ?? ''),
)
const showDiscordRoleBanner = computed(
() => eligibleDiscordRoles.value.length > 0 && !hasDismissedDiscordRoleBanner.value,
)
function dismissDiscordRoleBanner() {
const userId = auth.value.user?.id
if (!userId || dismissedDiscordRoleBannerUsers.value.includes(userId)) return
dismissedDiscordRoleBannerUsers.value = [...dismissedDiscordRoleBannerUsers.value, userId]
}
</script>
+62
View File
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { injectModrinthClient } from '@modrinth/ui'
import { getAuthUrl } from '~/composables/auth.js'
definePageMeta({
layout: 'empty',
middleware: 'auth',
})
const route = useRoute()
const auth = await useAuth()
const client = injectModrinthClient()
const error = ref<unknown>(null)
const isLinkedCallback = computed(() => route.query.callback === 'linked')
onMounted(async () => {
if (isLinkedCallback.value) return
try {
if (!auth.value.user?.auth_providers?.includes('discord')) {
window.location.href = `${getAuthUrl('discord', '/discord/link')}&token=${auth.value.token}`
return
}
const res = await client.labrinth.auth_internal.createDiscordCommunityLink()
window.location.href = res.url
} catch (err) {
error.value = err
}
})
</script>
<template>
<section class="discord-link-container universal-card">
<h1>{{ isLinkedCallback ? 'Modrinth account linked' : 'Linking Discord' }}</h1>
<p v-if="isLinkedCallback">Your Modrinth account has been linked to the Discord server.</p>
<p v-else-if="!error">Connecting your Modrinth account to the Discord server...</p>
<p v-else>Discord linking failed. Please try again later.</p>
</section>
</template>
<style scoped>
.discord-link-container {
width: 26rem;
max-width: calc(100% - 2rem);
margin: 1rem auto;
display: flex;
flex-direction: column;
gap: 2rem;
}
.discord-link-container h1 {
font-size: var(--font-size-xl);
margin: 0 0 -1rem 0;
color: var(--color-contrast);
}
.discord-link-container p {
margin: 0;
}
</style>
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { Button, Heading, Section, Text } from '@vue-email/components'
import StyledEmail from '../shared/StyledEmail.vue'
</script>
<template>
<StyledEmail
title="You're invited to the Creator Club"
:manual-links="[{ link: '{discord.link_url}', label: 'Link Discord' }]"
>
<Heading as="h1" class="mb-2 text-2xl font-bold">You're invited to the Creator Club</Heading>
<Text class="text-base">Hey <span class="no-auto-link">{user.name}</span>!</Text>
<Text class="text-base"> Your projects just passed 20,000 total downloads, nice! </Text>
<Text class="text-base">
We want to invite you to Modrinth's Creator Club, a space in our discord where you can chat
with other creators, share feedback with us, and stay plugged in.
</Text>
<Text class="text-base">
To join just link your Discord account through Modrinth and we'll grant access automatically!
</Text>
<Section class="mb-4 mt-4">
<Button
href="{discord.link_url}"
target="_blank"
class="text-accentContrast inline-block rounded-[12px] bg-brand pb-3 pl-4 pr-4 pt-3 text-[14px] font-bold"
>
Join the Creator Club
</Button>
</Section>
</StyledEmail>
</template>
@@ -36,6 +36,9 @@ export default {
'server-invited': () => import('./server/ServerInvited.vue'),
'server-invited-no-account': () => import('./server/ServerInvitedNoAccount.vue'),
// Discord
'discord-role-creator-club': () => import('./discord/DiscordRoleCreatorClub.vue'),
// Organizations
'organization-invited': () => import('./organization/OrganizationInvited.vue'),
} as Record<string, () => Promise<{ default: Component }>>