menu refactor + web account switcher (#7307)

* add account switcher, migrate context menus to overflow menu system, update version filter controls, only pad instance icons

* app account switcher, improve some context menus, theme stuff

* prepr

* handle reauthentication

* fix a couple sign in issues, admin page cleanup, fix org status badges, fix official account badge, open in local keybind, publish plus icons

* rename generic menus to ButtonMenu (& types)

* fix hydration issue w/ dropdowns + middleware error
This commit is contained in:
Prospector
2026-08-27 00:35:34 +00:00
committed by GitHub
parent 3439a43880
commit b68a199814
226 changed files with 6585 additions and 4078 deletions
@@ -70,9 +70,9 @@ import {
import { ButtonLink, TeleportOverflowMenu } from '@modrinth/ui'
import {
Avatar,
type ButtonMenuOption,
commonMessages,
defineMessages,
type OverflowMenuOption,
PageHeader,
PageHeaderActions,
PageHeaderBadgeItem,
@@ -133,7 +133,7 @@ const emit = defineEmits<{
const { formatMessage } = useVIntl()
const formatNumber = useFormatNumber()
const moreActions = computed<OverflowMenuOption[]>(() => [
const moreActions = computed<ButtonMenuOption[]>(() => [
{
id: 'manage-projects',
label: formatMessage(messages.manageProjects),
@@ -104,12 +104,12 @@ import { DownloadIcon, SpinnerIcon } from '@modrinth/assets'
import {
Avatar,
Button,
type ButtonMenuOption,
type CdnDownloadReason,
defineMessages,
injectModrinthClient,
injectNotificationManager,
NewModal,
type OverflowMenuOption,
ServersPromo,
SplitButton,
truncatedTooltip,
@@ -350,7 +350,7 @@ const showDependencyDownloadActions = computed(
hasRecommendedDownloadFiles.value),
)
const downloadWithRecommendedOptions = computed<OverflowMenuOption[]>(() => [
const downloadWithRecommendedOptions = computed<ButtonMenuOption[]>(() => [
{
id: 'download-with-recommended',
label: formatMessage(messages.downloadWithRecommended),
@@ -47,7 +47,12 @@
class="flex flex-col items-center gap-4 rounded-3xl border border-solid border-surface-5 bg-surface-3 p-6"
>
<div class="flex w-full items-center gap-3 rounded-xl bg-surface-2 p-3">
<Avatar :src="instance.icon" :alt="instance.name" size="48px" />
<Avatar
:src="instance.icon"
:alt="instance.name"
size="48px"
pad-transparent-corners
/>
<div class="flex min-w-0 flex-col gap-1">
<span class="truncate font-semibold text-contrast">{{ instance.name }}</span>
<span class="flex items-center gap-1.5 text-sm font-medium leading-4 text-primary">
+173 -113
View File
@@ -10,10 +10,20 @@
If the app doesnt open, use the button below to finish signing in.
</p>
</div>
<Button type="colored" color="brand" class="" @click="sendLauncherCallback">
{{ formatMessage(messages.returnToLauncherButton) }}
<RightArrowIcon />
</Button>
<div class="flex flex-col gap-2">
<Button
type="colored"
color="brand"
class="!w-full !justify-center"
@click="sendLauncherCallback"
>
{{ formatMessage(messages.returnToLauncherButton) }}
<RightArrowIcon />
</Button>
<ButtonLink to="/" class="!w-full !justify-center">
{{ formatMessage(messages.goToWebsiteButton) }}
</ButtonLink>
</div>
</div>
</div>
<div
@@ -46,111 +56,133 @@
</template>
<template v-else>
<div class="flex flex-col gap-5">
<div class="text-center text-2xl font-semibold text-contrast">
{{ formatMessage(messages.signInWithLabel) }}
</div>
<section class="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
<ButtonLink
v-for="provider in oauthProviders"
:key="provider.id"
class="relative w-full !justify-center overflow-visible"
:class="{
'!border !border-[var(--color-green)]': lastSignInProvider === provider.id,
}"
:href="getAuthUrl(provider.id, redirectTarget)"
:aria-label="formatMessage(messages.continueWithProvider, { provider: provider.name })"
@click="onOAuthProviderClick(provider.id)"
>
<component :is="provider.icon" />
<span>{{ provider.name }}</span>
<span
v-if="lastSignInProvider === provider.id"
class="oauth-provider-last-sign-in-badge"
>
{{ formatMessage(messages.lastSignInLabel) }}
</span>
</ButtonLink>
<Button
class="relative !w-full !justify-center overflow-visible sm:col-span-2"
:class="{ '!border !border-[var(--color-green)]': lastSignInProvider === 'passkey' }"
role="button"
tabindex="0"
@click="onPasskeySignIn"
@keydown.enter="onPasskeySignIn"
>
<UserKeyIcon />
<span class="ml-1">{{ formatMessage(messages.continueWithPasskey) }}</span>
<span v-if="lastSignInProvider === 'passkey'" class="oauth-provider-last-sign-in-badge">
{{ formatMessage(messages.lastSignInLabel) }}
</span>
</Button>
</section>
<div class="h-px w-full bg-surface-5"></div>
<section class="mx-auto flex w-full flex-col gap-2.5">
<label for="email" hidden>{{ formatMessage(commonMessages.emailUsernameLabel) }}</label>
<Input
id="email"
v-model="emailModel"
:icon="MailIcon"
type="text"
inputmode="email"
autocomplete="username"
:placeholder="formatMessage(commonMessages.emailUsernameLabel)"
wrapper-class="w-full"
/>
<label for="password" hidden>{{ formatMessage(commonMessages.passwordLabel) }}</label>
<Input
id="password"
v-model="passwordModel"
:icon="KeyIcon"
type="password"
autocomplete="current-password"
:placeholder="formatMessage(commonMessages.passwordLabel)"
wrapper-class="w-full"
/>
<HCaptcha
v-if="globals?.captcha_enabled && emailModel && passwordModel"
:ref="onSetCaptchaRef"
v-model="tokenModel"
/>
<Button
type="colored"
color="brand"
class="!w-full"
:disabled="globals?.captcha_enabled ? !tokenModel : false"
@click="onPasswordSignIn()"
>
{{ formatMessage(messages.continueWithEmail) }} <RightArrowIcon />
</Button>
<div class="flex flex-wrap items-center justify-center gap-2.5 !text-base">
<NuxtLink
class="text-link"
:to="{
path: '/auth/reset-password',
query: routeQuery,
}"
>
{{ formatMessage(messages.forgotPasswordLabel) }}
</NuxtLink>
<div class="h-1.5 w-1.5 rounded-full bg-surface-5" />
<NuxtLink
class="inline text-link"
:to="{
path: '/auth/sign-up',
query: routeQuery,
}"
>
{{ formatMessage(messages.createAccountLabel) }}
</NuxtLink>
<template v-if="accounts.length && !addingAccount">
<div class="flex w-full flex-col gap-4">
<div class="text-center text-2xl font-semibold text-contrast">
{{ formatMessage(messages.chooseAccountLabel) }}
</div>
<AccountChoiceList
:accounts="accounts"
:add-account-label="formatMessage(messages.addAccountLabel)"
@select="emit('select', $event)"
@add="addingAccount = true"
/>
</div>
</section>
</template>
<template v-else>
<div class="text-center text-2xl font-semibold text-contrast">
{{ formatMessage(messages.signInWithLabel) }}
</div>
<section class="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
<ButtonLink
v-for="provider in oauthProviders"
:key="provider.id"
class="relative w-full !justify-center overflow-visible"
:class="{
'!border !border-[var(--color-green)]': lastSignInOAuthProvider === provider.id,
}"
:href="getAuthUrl(provider.id, redirectTarget)"
:aria-label="
formatMessage(messages.continueWithProvider, { provider: provider.name })
"
@click="onOAuthProviderClick(provider.id)"
>
<component :is="provider.icon" />
<span>{{ provider.name }}</span>
<span
v-if="lastSignInOAuthProvider === provider.id"
class="oauth-provider-last-sign-in-badge"
>
{{ formatMessage(messages.lastSignInLabel) }}
</span>
</ButtonLink>
<Button
class="relative !w-full !justify-center overflow-visible sm:col-span-2"
:class="{
'!border !border-[var(--color-green)]': lastSignInOAuthProvider === 'passkey',
}"
role="button"
tabindex="0"
@click="onPasskeySignIn"
@keydown.enter="onPasskeySignIn"
>
<UserKeyIcon />
<span class="ml-1">{{ formatMessage(messages.continueWithPasskey) }}</span>
<span
v-if="lastSignInOAuthProvider === 'passkey'"
class="oauth-provider-last-sign-in-badge"
>
{{ formatMessage(messages.lastSignInLabel) }}
</span>
</Button>
</section>
<div class="h-px w-full bg-surface-5"></div>
<section class="mx-auto flex w-full flex-col gap-2.5">
<label for="email" hidden>{{ formatMessage(commonMessages.emailUsernameLabel) }}</label>
<Input
id="email"
v-model="emailModel"
:icon="MailIcon"
type="text"
inputmode="email"
autocomplete="username"
:placeholder="formatMessage(commonMessages.emailUsernameLabel)"
wrapper-class="w-full"
/>
<label for="password" hidden>{{ formatMessage(commonMessages.passwordLabel) }}</label>
<Input
id="password"
v-model="passwordModel"
:icon="KeyIcon"
type="password"
autocomplete="current-password"
:placeholder="formatMessage(commonMessages.passwordLabel)"
wrapper-class="w-full"
/>
<HCaptcha
v-if="globals?.captcha_enabled && emailModel && passwordModel"
:ref="onSetCaptchaRef"
v-model="tokenModel"
/>
<Button
type="colored"
color="brand"
class="!w-full"
:disabled="globals?.captcha_enabled ? !tokenModel : false"
@click="onPasswordSignIn()"
>
{{ formatMessage(messages.continueWithEmail) }} <RightArrowIcon />
</Button>
<div class="flex flex-wrap items-center justify-center gap-2.5 !text-base">
<NuxtLink
class="text-link"
:to="{
path: '/auth/reset-password',
query: routeQuery,
}"
>
{{ formatMessage(messages.forgotPasswordLabel) }}
</NuxtLink>
<div class="h-1.5 w-1.5 rounded-full bg-surface-5" />
<NuxtLink
class="inline text-link"
:to="{
path: '/auth/sign-up',
query: routeQuery,
}"
>
{{ formatMessage(messages.createAccountLabel) }}
</NuxtLink>
</div>
</section>
</template>
</div>
</template>
</div>
@@ -169,17 +201,26 @@ import {
SteamColorIcon,
UserKeyIcon,
} from '@modrinth/assets'
import { Button, ButtonLink, commonMessages, defineMessages, Input, useVIntl } from '@modrinth/ui'
import {
type AccountChoice,
AccountChoiceList,
Button,
ButtonLink,
commonMessages,
defineMessages,
Input,
useVIntl,
} from '@modrinth/ui'
import { useStorage } from '@vueuse/core'
import { computed } from 'vue'
import { ref } from 'vue'
import type { LocationQuery } from 'vue-router'
import HCaptcha from '@/components/ui/auth/HCaptcha.vue'
import {
getAuthUrl,
LAST_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
} from '@/composables/auth.ts'
} from '@/composables/accounts.ts'
import { getAuthUrl } from '@/composables/auth.ts'
const oauthProviders = [
{ id: 'discord', name: 'Discord', icon: DiscordColorIcon },
@@ -207,6 +248,7 @@ interface Props {
onTwoFactorSignIn?: () => void
onPasskeySignIn?: () => void
onSetCaptchaRef?: ((captchaRef: unknown) => void) | undefined
accounts?: AccountChoice[]
}
const {
@@ -219,8 +261,15 @@ const {
onTwoFactorSignIn = () => {},
onPasskeySignIn = () => {},
onSetCaptchaRef = undefined,
accounts = [],
} = defineProps<Props>()
const addingAccount = ref(false)
const emit = defineEmits<{
select: [account: AccountChoice]
}>()
const emailModel = defineModel<string>('email', { default: '' })
const passwordModel = defineModel<string>('password', { default: '' })
const tokenModel = defineModel<string>('token', { default: '' })
@@ -238,7 +287,6 @@ const pendingSignInOAuthProvider = useStorage<AuthProvider | null>(
undefined,
{ initOnMounted: true },
)
const lastSignInProvider = computed(() => lastSignInOAuthProvider.value)
const onOAuthProviderClick = (provider: AuthProvider) => {
pendingSignInOAuthProvider.value = provider
}
@@ -266,6 +314,14 @@ const messages = defineMessages({
id: 'auth.sign-in.sign-in-with',
defaultMessage: 'Sign into Modrinth',
},
chooseAccountLabel: {
id: 'auth.sign-in.choose-account',
defaultMessage: 'Choose an account to use in Modrinth App',
},
addAccountLabel: {
id: 'auth.sign-in.add-account',
defaultMessage: 'Add account',
},
twoFactorCodeInputPlaceholder: {
id: 'auth.sign-in.2fa.placeholder',
defaultMessage: 'Enter code...',
@@ -308,6 +364,10 @@ const messages = defineMessages({
id: 'auth.sign-in.launcher.complete.return-button',
defaultMessage: 'Open Modrinth App',
},
goToWebsiteButton: {
id: 'auth.sign-in.launcher.complete.go-to-website',
defaultMessage: 'Go to Modrinth.com',
},
})
</script>
@@ -152,7 +152,8 @@ import {
import { useStorage } from '@vueuse/core'
import type { LocationQuery } from 'vue-router'
import { getAuthUrl, PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY } from '@/composables/auth.ts'
import { PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY } from '@/composables/accounts.ts'
import { getAuthUrl } from '@/composables/auth.ts'
type AuthProvider = 'discord' | 'google' | 'github' | 'gitlab' | 'steam' | 'microsoft'
@@ -61,6 +61,7 @@
:src="reportItemAvatarUrl"
:circle="report.item_type === 'user'"
size="4rem"
:pad-transparent-corners="report.item_type === 'shared-instance'"
:class="[
'flex-shrink-0 border border-surface-5 bg-surface-4 !shadow-none',
report.item_type !== 'user' && 'rounded-2xl',
@@ -25,6 +25,7 @@ import { type TechReviewContext, techReviewQuickReplies } from '@modrinth/modera
import { Button, ButtonLink, IconButton, TeleportOverflowMenu } from '@modrinth/ui'
import {
Avatar,
type ButtonMenuOption,
Collapsible,
CollapsibleRegion,
commonMessages,
@@ -32,7 +33,6 @@ import {
injectModrinthClient,
injectNotificationManager,
NavTabs,
type OverflowMenuOption,
Toggle,
useFormatBytes,
useFormatDateTime,
@@ -116,7 +116,7 @@ const isProjectApproved = computed(() => {
})
const isLoadingStatusAction = ref(false)
const projectStatusActions = computed<OverflowMenuOption[]>(() => [
const projectStatusActions = computed<ButtonMenuOption[]>(() => [
{
id: 'approve',
label: 'Approve',
@@ -363,7 +363,13 @@ function formattedLoader(version: SharedInstanceReportVersion) {
:key="instance.id"
class="flex min-w-0 items-center gap-2 rounded-xl bg-surface-2 p-3"
>
<Avatar :src="instance.icon_url" :tint-by="instance.id" size="2.5rem" no-shadow />
<Avatar
:src="instance.icon_url"
:tint-by="instance.id"
size="2.5rem"
no-shadow
pad-transparent-corners
/>
<div class="flex min-w-0 flex-1 flex-col">
<div class="flex min-w-0 items-center gap-1.5">
<span class="truncate font-semibold text-contrast">{{ instance.name }}</span>
@@ -70,6 +70,7 @@
size="xs"
no-shadow
:raised="raised"
pad-transparent-corners
/>
<div v-else class="backed-svg" :class="{ raised: raised }">
<BoxesIcon />
@@ -95,10 +95,10 @@ import { ChevronDownIcon, MessageIcon, ReplyIcon, SendIcon } from '@modrinth/ass
import type { QuickReply } from '@modrinth/moderation'
import { Button, TeleportOverflowMenu } from '@modrinth/ui'
import {
type ButtonMenuOption,
CopyCode,
injectNotificationManager,
MarkdownEditor,
type OverflowMenuOption,
} from '@modrinth/ui'
import type { Thread, User } from '@modrinth/utils'
import dayjs from 'dayjs'
@@ -110,7 +110,7 @@ import ThreadMessage from './ThreadMessage.vue'
const { addNotification } = injectNotificationManager()
const visibleQuickReplies = computed<OverflowMenuOption[]>(() => {
const visibleQuickReplies = computed<ButtonMenuOption[]>(() => {
const replies = props.quickReplies
const context = props.quickReplyContext
@@ -127,7 +127,7 @@ const visibleQuickReplies = computed<OverflowMenuOption[]>(() => {
id: reply.label,
label: reply.label,
action: () => handleQuickReply(reply, context),
}) as OverflowMenuOption,
}) as ButtonMenuOption,
)
})