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
+35 -8
View File
@@ -5,18 +5,31 @@
<LoadingBar />
</ClientOnly>
<NotificationPanel />
<AccountSwitchOverlay :show="isSwitchingAccount" />
<AdsConsentNotification />
<I18nDebugPanel />
<NuxtPage />
<div id="teleports"></div>
</NuxtLayout>
</template>
<script setup lang="ts">
import { I18nDebugPanel, injectI18n, LoadingBar, NotificationPanel } from '@modrinth/ui'
import {
AccountSwitchOverlay,
I18nDebugPanel,
injectI18n,
LoadingBar,
NotificationPanel,
} from '@modrinth/ui'
import AdsConsentNotification from '~/components/ui/AdsConsentNotification.vue'
import { isDarkTheme } from '~/plugins/theme/index.ts'
import { setupProviders } from '~/providers/setup.ts'
import {
hydrateStoredAccounts,
rememberStoredAccount,
rememberStoredAccountAppearance,
useIsSwitchingAccount,
} from './composables/accounts'
import { useAuth } from './composables/auth'
const auth = await useAuth()
@@ -24,6 +37,20 @@ const userPreferences = setupProviders(auth)
const cosmetics = useCosmetics()
const theme = useTheme()
const { locale, setLocale } = injectI18n()
const isSwitchingAccount = useIsSwitchingAccount()
// initAuth doesn't run again after SSR, so stash the current account for switching
watch(
auth,
({ user, token }) => {
if (user && token) {
rememberStoredAccount(user, token)
}
},
{ immediate: true },
)
onMounted(hydrateStoredAccounts)
watch(
userPreferences.preferences,
@@ -31,12 +58,12 @@ watch(
if (!preferences) return
if (theme.syncAcrossDevices) {
if (isDarkTheme(preferences.appearance.theme)) {
theme.preferences.dark = preferences.appearance.theme
} else {
theme.preferences.light = preferences.appearance.theme
}
theme.preferred = preferences.appearance.auto ? 'system' : preferences.appearance.theme
theme.applyAccountAppearance(preferences.appearance)
}
const userId = auth.value.user?.id
if (userId) {
rememberStoredAccountAppearance(userId, preferences.appearance)
}
if (locale.value !== preferences.localization.locale) {
@@ -192,8 +192,8 @@
import { DownloadIcon, DropdownIcon, SearchIcon, UserIcon } from '@modrinth/assets'
import { TeleportOverflowMenu } from '@modrinth/ui'
import {
type ButtonMenuOption,
Input,
type OverflowMenuOption,
Pagination,
Table,
useFormatNumber,
@@ -363,7 +363,7 @@ const displayedIncludeDateColumn = computed(() =>
const groupByLabel = computed(() =>
getAnalyticsTableGroupByLabel(selectedGroupBy.value, formatMessage),
)
const csvExportOptions = computed<OverflowMenuOption[]>(() => {
const csvExportOptions = computed<ButtonMenuOption[]>(() => {
if (showGraphDatasetSelection.value) {
return [
{
@@ -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,
)
})
+384
View File
@@ -0,0 +1,384 @@
import type { Labrinth } from '@modrinth/api-client'
import { nextTick } from 'vue'
import { useAuthCookie } from '@/composables/auth-cookie.ts'
import type { CookieOptions } from '#app'
import { useTheme } from '~/composables/nuxt-accessors.ts'
import { getThemeType } from '~/plugins/theme/themes.ts'
export const LAST_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY = 'auth-last-sign-in-oauth-provider'
export const PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY = 'auth-pending-sign-in-oauth-provider'
export const STORED_ACCOUNT_AUTH_METHODS = [
'password',
'passkey',
'github',
'discord',
'microsoft',
'gitlab',
'google',
'steam',
'paypal',
] as const
export type StoredAccountAuthMethod = (typeof STORED_ACCOUNT_AUTH_METHODS)[number]
export type StoredAccount = {
id: string
username: string
avatarUrl: string | null
token: string
role?: Labrinth.Users.v2.Role
email?: string | null
authMethod?: StoredAccountAuthMethod
appearance?: Labrinth.Users.v3.AppearancePreferences
}
type AccountPreview = Omit<StoredAccount, 'token' | 'appearance'>
const STORAGE_KEY = 'auth-stored-accounts'
const COOKIE_OPTIONS = {
maxAge: 60 * 60 * 24 * 365 * 10,
sameSite: 'lax',
httpOnly: false,
path: '/',
} satisfies CookieOptions<AccountPreview[]>
const ROLES: Labrinth.Users.v2.Role[] = ['developer', 'moderator', 'admin']
const isRole = (value: unknown): value is Labrinth.Users.v2.Role =>
ROLES.includes(value as Labrinth.Users.v2.Role)
export const isStoredAccountAuthMethod = (value: unknown): value is StoredAccountAuthMethod =>
typeof value === 'string' && (STORED_ACCOUNT_AUTH_METHODS as readonly string[]).includes(value)
const isAppearance = (value: unknown): value is Labrinth.Users.v3.AppearancePreferences => {
if (!value || typeof value !== 'object') return false
const appearance = value as Labrinth.Users.v3.AppearancePreferences
return (
typeof appearance.auto === 'boolean' &&
typeof appearance.theme === 'string' &&
getThemeType(appearance.theme) !== 'unknown'
)
}
const parsePreview = (value: unknown): AccountPreview | null => {
if (!value || typeof value !== 'object') return null
const candidate = value as Partial<AccountPreview>
if (typeof candidate.id !== 'string' || typeof candidate.username !== 'string') return null
return {
id: candidate.id,
username: candidate.username,
avatarUrl: candidate.avatarUrl ?? null,
role: isRole(candidate.role) ? candidate.role : undefined,
email: typeof candidate.email === 'string' ? candidate.email : undefined,
authMethod: isStoredAccountAuthMethod(candidate.authMethod) ? candidate.authMethod : undefined,
}
}
const parseAccount = (value: unknown): StoredAccount | null => {
const preview = parsePreview(value)
if (!preview || typeof (value as StoredAccount).token !== 'string') return null
const candidate = value as Partial<StoredAccount>
return {
...preview,
token: candidate.token ?? '',
appearance: isAppearance(candidate.appearance) ? candidate.appearance : undefined,
}
}
const parsePreviews = (value: unknown): AccountPreview[] =>
Array.isArray(value) ? value.map(parsePreview).filter((account) => account !== null) : []
const toPreview = ({
id,
username,
avatarUrl,
role,
email,
authMethod,
}: StoredAccount): AccountPreview => ({
id,
username,
avatarUrl,
role,
email,
authMethod,
})
const getErrorStatus = (error: unknown): number | undefined => {
if (!error || typeof error !== 'object') return undefined
const typedError = error as { statusCode?: unknown; status?: unknown }
const status = typedError.statusCode ?? typedError.status
return typeof status === 'number' ? status : undefined
}
const isAuthFailure = (error: unknown): boolean => {
const status = getErrorStatus(error)
return status === 401 || status === 403
}
const readPendingAuthMethod = (): StoredAccountAuthMethod | undefined => {
if (!import.meta.client) return undefined
try {
const raw = window.localStorage.getItem(PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY)
if (!raw) return undefined
const parsed: unknown = JSON.parse(raw)
return isStoredAccountAuthMethod(parsed) ? parsed : undefined
} catch {
return undefined
}
}
const readLocal = (): StoredAccount[] => {
if (!import.meta.client) return []
try {
const parsed: unknown = JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? '')
if (!Array.isArray(parsed)) return []
return parsed.map(parseAccount).filter((account) => account !== null)
} catch {
return []
}
}
const writeLocal = (accounts: StoredAccount[]) => {
if (!import.meta.client) return
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(accounts))
} catch {
// storage blocked or full — switching just won't be offered
}
}
const accountsCookie = () => {
const config = useRuntimeConfig()
return useCookie<AccountPreview[]>(STORAGE_KEY, {
...COOKIE_OPTIONS,
secure: config.public.cookieSecure,
default: () => [],
decode: (value) => {
try {
return parsePreviews(JSON.parse(value))
} catch {
return []
}
},
encode: (value) => JSON.stringify(parsePreviews(value)),
})
}
export const useStoredAccounts = () =>
useState<StoredAccount[]>('stored-accounts', () =>
parsePreviews(accountsCookie().value).map((preview) => ({ ...preview, token: '' })),
)
const setAccounts = (accounts: StoredAccount[]) => {
useStoredAccounts().value = accounts
writeLocal(accounts)
accountsCookie().value = accounts.map(toPreview)
}
export const hydrateStoredAccounts = () => {
if (!import.meta.client) return
const local = readLocal()
if (local.length > 0) {
setAccounts(local)
return
}
accountsCookie().value = useStoredAccounts().value.map(toPreview)
}
export const rememberStoredAccount = (
user: Pick<Labrinth.Users.v2.User, 'id' | 'username' | 'avatar_url' | 'role' | 'email'>,
token: string,
options?: { authMethod?: StoredAccountAuthMethod },
) => {
if (!import.meta.client) return
const local = readLocal()
const accounts = local.length > 0 ? local : [...useStoredAccounts().value]
const existingIndex = accounts.findIndex((stored) => stored.id === user.id)
const existing = existingIndex === -1 ? undefined : accounts[existingIndex]
const account: StoredAccount = {
id: user.id,
username: user.username,
avatarUrl: user.avatar_url ?? null,
token,
role: isRole(user.role) ? user.role : undefined,
email: user.email ?? existing?.email ?? null,
authMethod:
options && 'authMethod' in options
? options.authMethod
: (existing?.authMethod ?? readPendingAuthMethod()),
appearance: existing?.appearance,
}
if (existingIndex === -1) {
setAccounts([...accounts, account])
return
}
const next = [...accounts]
next[existingIndex] = account
setAccounts(next)
}
export const rememberStoredAccountAppearance = (
id: string,
appearance: Labrinth.Users.v3.AppearancePreferences,
) => {
if (!import.meta.client) return
const accounts = readLocal()
const existing = accounts.find((stored) => stored.id === id)
if (
!existing ||
(existing.appearance?.theme === appearance.theme &&
existing.appearance?.auto === appearance.auto)
) {
return
}
setAccounts(accounts.map((stored) => (stored.id === id ? { ...stored, appearance } : stored)))
}
export const forgetStoredAccount = (id: string) => {
if (!import.meta.client) return
setAccounts(readLocal().filter((stored) => stored.id !== id))
}
export const forgetStoredAccountByToken = (token: string) => {
if (!import.meta.client) return
setAccounts(readLocal().filter((stored) => stored.token !== token))
}
export const useIsSwitchingAccount = () => useState<boolean>('switching-account', () => false)
const getSwitchRedirectPath = (username: string) => {
if (!import.meta.client) return `/user/${username}`
const fullPath = `${window.location.pathname}${window.location.search}${window.location.hash}`
if (fullPath === '/auth' || fullPath.startsWith('/auth/')) return `/user/${username}`
return fullPath
}
async function fetchUserWithToken(
token: string,
): Promise<Labrinth.Users.v2.User | 'invalid' | 'error'> {
try {
return (await useBaseFetch(
'user',
{
apiVersion: 3,
headers: {
Authorization: token,
},
},
true,
)) as Labrinth.Users.v2.User
} catch (error) {
return isAuthFailure(error) ? 'invalid' : 'error'
}
}
async function refreshStoredToken(token: string): Promise<string | 'invalid' | 'error'> {
try {
const session = (await useBaseFetch(
'session/refresh',
{
method: 'POST',
headers: {
Authorization: token,
},
},
true,
)) as { session?: unknown }
const nextToken = typeof session.session === 'string' ? session.session : ''
return nextToken || 'invalid'
} catch (error) {
return isAuthFailure(error) ? 'invalid' : 'error'
}
}
async function prepareStoredAccount(
account: StoredAccount,
): Promise<'ready' | 'invalid' | 'error'> {
if (!account.token) return 'invalid'
const user = await fetchUserWithToken(account.token)
if (user !== 'invalid' && user !== 'error') {
rememberStoredAccount(user, account.token, { authMethod: account.authMethod })
return 'ready'
}
if (user === 'error') return 'error'
const refreshed = await refreshStoredToken(account.token)
if (refreshed === 'error' || refreshed === 'invalid') return refreshed
const refreshedUser = await fetchUserWithToken(refreshed)
if (refreshedUser === 'error' || refreshedUser === 'invalid') return refreshedUser
rememberStoredAccount(refreshedUser, refreshed, { authMethod: account.authMethod })
return 'ready'
}
const goToReauthenticate = async (account: StoredAccount) => {
await navigateTo({
path: '/auth/reauthenticate',
query: {
account: account.id,
redirect: getSwitchRedirectPath(account.username),
},
})
}
export const switchToStoredAccount = async (
account: StoredAccount,
): Promise<'ready' | 'invalid' | 'error'> => {
if (!import.meta.client) return 'error'
hydrateStoredAccounts()
const stored = readLocal().find((item) => item.id === account.id) ?? account
if (!stored.token) {
await goToReauthenticate(stored)
return 'invalid'
}
useIsSwitchingAccount().value = true
const status = await prepareStoredAccount(stored)
if (status !== 'ready') {
useIsSwitchingAccount().value = false
if (status === 'invalid') await goToReauthenticate(stored)
return status
}
const latest = readLocal().find((item) => item.id === stored.id) ?? stored
const theme = useTheme()
if (latest.appearance) {
theme.applyAccountAppearance(latest.appearance)
await nextTick()
}
useAuthCookie().value = latest.token
await nextTick()
window.location.reload()
return 'ready'
}
export const switchToSignedOut = async () => {
if (!import.meta.client) return
useIsSwitchingAccount().value = true
useAuthCookie().value = null
await nextTick()
window.location.reload()
}
@@ -0,0 +1,19 @@
import type { CookieOptions } from '#app'
const AUTH_COOKIE_NAME = 'auth-token'
const AUTH_COOKIE_OPTIONS = {
maxAge: 60 * 60 * 24 * 365 * 10,
sameSite: 'lax',
httpOnly: false,
path: '/',
} satisfies CookieOptions<string | null>
export const useAuthCookie = () => {
const config = useRuntimeConfig()
return useCookie<string | null>(AUTH_COOKIE_NAME, {
...AUTH_COOKIE_OPTIONS,
secure: config.public.cookieSecure,
})
}
+59 -46
View File
@@ -2,7 +2,8 @@ import type { Labrinth } from '@modrinth/api-client'
import { useStorage } from '@vueuse/core'
import type { LocationQueryValue, RouteLocationNormalizedLoaded } from 'vue-router'
import type { CookieOptions } from '#app'
import { rememberStoredAccount } from '@/composables/accounts.ts'
import { useAuthCookie } from '@/composables/auth-cookie.ts'
type AuthState = {
user: Labrinth.Users.v2.User | null
@@ -12,16 +13,7 @@ type AuthState = {
type QueryValue = LocationQueryValue | LocationQueryValue[] | undefined
type FullPathRoute = Pick<RouteLocationNormalizedLoaded, 'fullPath'>
type LauncherRoute = Pick<RouteLocationNormalizedLoaded, 'query'>
export const LAST_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY = 'auth-last-sign-in-oauth-provider'
export const PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY = 'auth-pending-sign-in-oauth-provider'
const AUTH_COOKIE_OPTIONS = {
maxAge: 60 * 60 * 24 * 365 * 10,
sameSite: 'lax',
httpOnly: false,
path: '/',
} satisfies CookieOptions<string | null>
type AuthInitRoute = Pick<RouteLocationNormalizedLoaded, 'fullPath' | 'path' | 'query'>
const normalizeAuthToken = (value: unknown) => {
if (typeof value === 'string') {
@@ -60,20 +52,26 @@ const getQueryString = (value: QueryValue) => {
return value ?? null
}
export const useAuth = async (oldToken: string | null | undefined = null) => {
export const useAuth = async (
oldToken: string | null | undefined = null,
route?: AuthInitRoute,
) => {
const auth = useState<AuthState>('auth', () => ({
user: null,
token: '',
}))
if (!auth.value.user || oldToken) {
auth.value = await initAuth(oldToken)
auth.value = await initAuth(oldToken, route)
}
return auth
}
export const initAuth = async (oldToken: string | null | undefined = null) => {
export const initAuth = async (
oldToken: string | null | undefined = null,
route?: AuthInitRoute,
) => {
const auth: AuthState = {
user: null,
token: '',
@@ -83,12 +81,8 @@ export const initAuth = async (oldToken: string | null | undefined = null) => {
return auth
}
const route = useRoute()
const config = useRuntimeConfig()
const authCookie = useCookie<string | null>('auth-token', {
...AUTH_COOKIE_OPTIONS,
secure: config.public.cookieSecure,
})
const resolvedRoute = route ?? useRoute()
const authCookie = useAuthCookie()
if (oldToken) {
const normalized = normalizeAuthToken(oldToken)
@@ -97,13 +91,16 @@ export const initAuth = async (oldToken: string | null | undefined = null) => {
}
}
const oauthCode = normalizeAuthToken(route.query.code)
if (oauthCode && !route.fullPath.includes('new_account=true')) {
const oauthCode = normalizeAuthToken(resolvedRoute.query.code)
if (oauthCode && !resolvedRoute.fullPath.includes('new_account=true')) {
authCookie.value = oauthCode
}
if (route.fullPath.includes('new_account=true') && route.path !== '/auth/welcome') {
const redirect = route.path.startsWith('/auth/') ? null : route.fullPath
if (
resolvedRoute.fullPath.includes('new_account=true') &&
resolvedRoute.path !== '/auth/welcome'
) {
const redirect = resolvedRoute.path.startsWith('/auth/') ? null : resolvedRoute.fullPath
await navigateTo(
`/auth/welcome?authToken=${oauthCode}${
@@ -183,14 +180,16 @@ export const initAuth = async (oldToken: string | null | undefined = null) => {
}
}
if (auth.user && auth.token) {
rememberStoredAccount(auth.user, auth.token)
}
return auth
}
export const getSignInRedirectPath = (route: FullPathRoute) => {
const fullPath = route.fullPath
if (fullPath === '/auth' || fullPath.startsWith('/auth/')) {
return '/dashboard'
}
if (fullPath === '/auth' || fullPath.startsWith('/auth/')) return undefined
return fullPath
}
@@ -201,44 +200,58 @@ export const getSignInRouteObj = (route: FullPathRoute, redirectOverride?: strin
},
})
export const getAuthUrl = (provider: string, redirect = '/dashboard') => {
export const ADD_ACCOUNT_QUERY_PARAM = 'add_account'
export const getAddAccountRouteObj = (route: FullPathRoute) => ({
path: '/auth/sign-in',
query: {
redirect: getSignInRedirectPath(route),
[ADD_ACCOUNT_QUERY_PARAM]: 'true',
},
})
export const getAuthUrl = (provider: string, redirect?: string) => {
const config = useRuntimeConfig()
const route = useNativeRoute()
const launcher = getQueryString(route.query.launcher)
const addingAccount =
route.query[ADD_ACCOUNT_QUERY_PARAM] !== undefined || route.path === '/auth/reauthenticate'
const fullURL = launcher
? (() => {
const callbackUrl = new URL('/auth/sign-in', config.public.siteUrl)
callbackUrl.searchParams.set('launcher', launcher)
const callbackUrl = new URL('/auth/sign-in', config.public.siteUrl)
if (launcher) {
callbackUrl.searchParams.set('launcher', launcher)
const ipver = getQueryString(route.query.ipver)
const port = getQueryString(route.query.port)
const ipver = getQueryString(route.query.ipver)
const port = getQueryString(route.query.port)
if (ipver) {
callbackUrl.searchParams.set('ipver', ipver)
}
if (ipver) {
callbackUrl.searchParams.set('ipver', ipver)
}
if (port) {
callbackUrl.searchParams.set('port', port)
}
if (port) {
callbackUrl.searchParams.set('port', port)
}
} else if (redirect) {
callbackUrl.searchParams.set('redirect', redirect)
}
return callbackUrl.toString()
})()
: `${config.public.siteUrl}/auth/sign-in?redirect=${encodeURIComponent(redirect)}`
if (addingAccount) {
callbackUrl.searchParams.set(ADD_ACCOUNT_QUERY_PARAM, 'true')
}
return `${config.public.apiBaseUrl}auth/init?provider=${provider}&url=${encodeURIComponent(fullURL)}`
return `${config.public.apiBaseUrl}auth/init?provider=${provider}&url=${encodeURIComponent(callbackUrl.toString())}`
}
export const promotePendingSignInOAuthProvider = () => {
if (!import.meta.client) return
const pending = useStorage<string | null>(
PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
'auth-pending-sign-in-oauth-provider',
null,
undefined,
{ initOnMounted: true },
)
if (!pending.value) return
const last = useStorage<string | null>(LAST_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY, null, undefined, {
const last = useStorage<string | null>('auth-last-sign-in-oauth-provider', null, undefined, {
initOnMounted: true,
})
last.value = pending.value
+8 -1
View File
@@ -1,3 +1,5 @@
import { forgetStoredAccount } from '@/composables/accounts.ts'
import { useAuthCookie } from '@/composables/auth-cookie.ts'
import { useAppQueryClient } from '@/composables/query-client'
export const useUser = async (force = false) => {
@@ -150,6 +152,7 @@ export const resendVerifyEmail = async () => {
export const logout = async () => {
startLoading()
const auth = await useAuth()
const userId = auth.value.user?.id
try {
await useBaseFetch(`session/${auth.value.token}`, {
method: 'DELETE',
@@ -158,8 +161,12 @@ export const logout = async () => {
/* empty */
}
if (userId) {
forgetStoredAccount(userId)
}
await useAuth('none')
useCookie('auth-token').value = null
useAuthCookie().value = null
useAppQueryClient().clear()
stopLoading()
}
+63 -4
View File
@@ -2,6 +2,7 @@
<NuxtLayout>
<LoadingBar />
<NotificationPanel />
<AccountSwitchOverlay :show="isSwitchingAccount" />
<div class="main">
<div v-if="is404" class="error-graphic">
<Logo404 />
@@ -32,15 +33,29 @@
{{ formatMessage(unauthorizedMessages.signedInAsLabel) }}
</p>
<div
class="flex items-center gap-2 rounded-2xl border border-solid border-surface-5 bg-surface-4 p-4"
class="flex items-center gap-2 rounded-2xl border border-solid border-surface-4 bg-surface-2 p-4"
>
<Avatar :src="auth.user.avatar_url" size="32px" circle />
<span class="font-medium text-contrast">{{ auth.user.username }}</span>
<UserRoleIcon :role="auth.user.role" />
<Button type="quiet" color="red" native-type="button" class="ml-auto" @click="logout">
<Button
type="quiet"
color="red"
native-type="button"
class="ml-auto"
@click="signOut"
>
<LogOutIcon />
{{ formatMessage(commonMessages.signOutButton) }}
</Button>
</div>
<template v-if="otherAccounts.length">
<p class="m-0">
{{ formatMessage(unauthorizedMessages.switchAccountLabel) }}
</p>
<AccountChoiceList :accounts="otherAccounts" @select="onSelectStoredAccount" />
</template>
</template>
<template v-else>
<ButtonLink type="colored" color="brand" class="button-like w-fit" :to="signInRoute">
@@ -85,30 +100,42 @@
</template>
<script setup>
import { AnnoyedRinthbot, LogInIcon, SadRinthbot } from '@modrinth/assets'
import { AnnoyedRinthbot, LogInIcon, LogOutIcon, SadRinthbot } from '@modrinth/assets'
import {
AccountChoiceList,
AccountSwitchOverlay,
Avatar,
Button,
ButtonLink,
commonMessages,
defineMessage,
defineMessages,
injectNotificationManager,
IntlFormatted,
LoadingBar,
normalizeChildren,
NotificationPanel,
UserRoleIcon,
useVIntl,
} from '@modrinth/ui'
import Logo404 from '~/assets/images/404.svg'
import {
hydrateStoredAccounts,
switchToSignedOut,
switchToStoredAccount,
useIsSwitchingAccount,
useStoredAccounts,
} from '~/composables/accounts.ts'
import { getSignInRouteObj } from '~/composables/auth.js'
import { logout } from '~/composables/user.js'
import { setupProviders } from '~/providers/setup.ts'
const auth = await useAuth()
setupProviders(auth)
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const isSwitchingAccount = useIsSwitchingAccount()
const props = defineProps({
error: {
@@ -130,8 +157,40 @@ const unauthorizedMessages = defineMessages({
id: 'error.generic.401.signed-in-as',
defaultMessage: "You're currently signed in as:",
},
switchAccountLabel: {
id: 'error.generic.401.switch-account',
defaultMessage: 'Or switch to another account:',
},
accountSwitchFailed: {
id: 'error.generic.401.switch-account-failed',
defaultMessage: "Couldn't switch accounts. Please try again.",
},
})
const storedAccounts = useStoredAccounts()
const otherAccounts = computed(() =>
storedAccounts.value.filter((account) => account.id !== auth.value.user?.id),
)
async function signOut() {
await switchToSignedOut()
}
async function onSelectStoredAccount(account) {
const result = await switchToStoredAccount(account)
if (result === 'error') {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: formatMessage(unauthorizedMessages.accountSwitchFailed),
type: 'error',
})
}
}
// error page replaces the app root, so hydrate here too
onMounted(hydrateStoredAccounts)
const signInRoute = computed(() => getSignInRouteObj(route))
const errorMessages = computed(
+233 -153
View File
@@ -1,35 +1,4 @@
<template>
<div class="pointer-events-none fixed inset-0 z-[-1]">
<div id="fixed-background-teleport" class="relative"></div>
</div>
<div class="pointer-events-none absolute inset-0 z-[-1]">
<div id="absolute-background-teleport" class="relative"></div>
</div>
<div
class="pride-backdrop pointer-events-none absolute inset-0 z-[-1]"
:class="{ shown: showPrideBackdrop }"
></div>
<div class="pointer-events-none absolute inset-0 z-50">
<div
class="over-the-top-random-animation"
:style="{ '--_r-count': rCount }"
:class="{ threshold: rCount > 20, 'rings-expand': rCount >= 40 }"
>
<div>
<div
class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40"
></div>
<div
class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60"
></div>
<div
class="animation-ring-1 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight text-9xl font-extrabold text-contrast"
>
?
</div>
</div>
</div>
</div>
<div
ref="main_page"
class="layout"
@@ -38,6 +7,37 @@
'modrinth-parent__no-modal-blurs': !cosmetics.advancedRendering,
}"
>
<div class="pointer-events-none fixed inset-0 z-[-1]">
<div id="fixed-background-teleport" class="relative"></div>
</div>
<div class="pointer-events-none absolute inset-0 z-[-1]">
<div id="absolute-background-teleport" class="relative"></div>
</div>
<div
class="pride-backdrop pointer-events-none absolute inset-0 z-[-1]"
:class="{ shown: showPrideBackdrop }"
></div>
<div class="pointer-events-none absolute inset-0 z-50">
<div
class="over-the-top-random-animation"
:style="{ '--_r-count': rCount }"
:class="{ threshold: rCount > 20, 'rings-expand': rCount >= 40 }"
>
<div>
<div
class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40"
></div>
<div
class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60"
></div>
<div
class="animation-ring-1 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight text-9xl font-extrabold text-contrast"
>
?
</div>
</div>
</div>
</div>
<RussiaBanner v-if="flags.showAllBanners || isRussia" />
<TaxIdMismatchBanner v-if="flags.showAllBanners || showTinMismatchBanner" />
<TaxComplianceBanner v-if="flags.showAllBanners || showTaxComplianceBanner" />
@@ -373,6 +373,7 @@
{
id: 'review-projects',
label: formatMessage(messages.reviewProjects),
icon: ScaleIcon,
type: 'link',
to: '/moderation',
tone: 'orange',
@@ -380,6 +381,7 @@
{
id: 'tech-review',
label: formatMessage(messages.techReview),
icon: ShieldAlertIcon,
type: 'link',
to: '/moderation/technical-review',
tone: 'orange',
@@ -387,6 +389,7 @@
{
id: 'review-reports',
label: formatMessage(messages.reports),
icon: ReportIcon,
type: 'link',
to: '/moderation/reports',
tone: 'orange',
@@ -394,116 +397,109 @@
{
id: 'external-projects',
label: formatMessage(messages.externalProjects),
icon: GlobeIcon,
type: 'link',
to: '/moderation/external-projects',
tone: 'orange',
},
{
type: 'divider',
id: 'global-traces',
label: 'Global traces',
icon: HashIcon,
type: 'link',
to: '/moderation/global-traces',
tone: 'orange',
},
{ type: 'divider' },
{
id: 'file-lookup',
label: formatMessage(messages.fileLookup),
label: 'File lookup',
icon: FileSearchCornerIcon,
type: 'link',
to: '/admin/file_lookup',
},
{
type: 'divider',
shown: isAdmin(auth.user),
},
{
id: 'user-lookup',
label: formatMessage(messages.lookupByEmail),
label: 'User lookup',
icon: UserSearchIcon,
type: 'link',
to: '/admin/user_email',
tone: 'brand',
shown: isAdmin(auth.user),
},
{
id: 'affiliates',
label: formatMessage(messages.manageAffiliates),
type: 'link',
to: '/admin/affiliates',
tone: 'brand',
type: 'divider',
shown: isAdmin(auth.user),
},
{
id: 'servers-lookup',
label: 'Server lookup',
icon: ServerSearchIcon,
type: 'link',
to: '/admin/servers/lookup',
tone: 'brand',
shown: isAdmin(auth.user),
},
{
id: 'servers-notices',
label: formatMessage(messages.manageServerNotices),
label: 'Server notices',
icon: IssuesIcon,
type: 'link',
to: '/admin/servers/notices',
tone: 'brand',
shown: isAdmin(auth.user),
},
{
id: 'servers-transfers',
label: 'Server transfers',
icon: TransferIcon,
type: 'link',
to: '/admin/servers/transfers',
tone: 'brand',
shown: isAdmin(auth.user),
},
{
id: 'servers-nodes',
label: 'Credit server nodes',
icon: ServerIcon,
action: (event) => $refs.modal_batch_credit.show(event),
tone: 'brand',
shown: isAdmin(auth.user),
},
{
type: 'divider',
shown: isAdmin(auth.user),
},
{
id: 'affiliates',
label: 'Affiliate links',
icon: AffiliateIcon,
type: 'link',
to: '/admin/affiliates',
shown: isAdmin(auth.user),
},
{
id: 'analytics-events',
label: formatMessage(messages.analyticsEvents),
label: 'Analytics events',
icon: ChartIcon,
type: 'link',
to: '/admin/analytics/events',
tone: 'brand',
shown: isAdmin(auth.user),
},
{ type: 'divider' },
{
id: 'email-templates',
label: 'Email templates',
icon: MailIcon,
type: 'link',
to: '/admin/emails',
},
{
id: 'document-templates',
label: 'Document templates',
icon: BookOpenIcon,
type: 'link',
to: '/admin/docs',
},
]"
>
<ModrinthIcon aria-hidden="true" />
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
<template #review-projects>
<ScaleIcon aria-hidden="true" /> {{ formatMessage(messages.reviewProjects) }}
</template>
<template #tech-review>
<ShieldAlertIcon aria-hidden="true" /> {{ formatMessage(messages.techReview) }}
</template>
<template #review-reports>
<ReportIcon aria-hidden="true" /> {{ formatMessage(messages.reports) }}
</template>
<template #external-projects>
<GlobeIcon aria-hidden="true" /> {{ formatMessage(messages.externalProjects) }}
</template>
<template #user-lookup>
<UserSearchIcon aria-hidden="true" /> {{ formatMessage(messages.lookupByEmail) }}
</template>
<template #file-lookup>
<FileIcon aria-hidden="true" /> {{ formatMessage(messages.fileLookup) }}
</template>
<template #servers-lookup> <ServerIcon aria-hidden="true" /> Server lookup </template>
<template #servers-notices>
<IssuesIcon aria-hidden="true" /> {{ formatMessage(messages.manageServerNotices) }}
</template>
<template #servers-transfers>
<TransferIcon aria-hidden="true" /> Server transfers
</template>
<template #affiliates>
<AffiliateIcon aria-hidden="true" /> {{ formatMessage(messages.manageAffiliates) }}
</template>
<template #servers-nodes>
<ServerIcon aria-hidden="true" /> Credit server nodes
</template>
<template #analytics-events>
<ChartIcon aria-hidden="true" /> {{ formatMessage(messages.analyticsEvents) }}
</template>
</TeleportOverflowMenu>
<TeleportOverflowMenu
v-if="auth.user"
@@ -515,17 +511,20 @@
{
id: 'new-project',
label: formatMessage(messages.newProject),
icon: BoxPlusIcon,
action: (event) => requireVerifiedEmail(() => $refs.modal_creation.show(event)),
},
{
id: 'new-server-project',
label: formatMessage(messages.newServerProject),
icon: ServerPlusIcon,
action: (event) =>
requireVerifiedEmail(() => $refs.modal_creation.show(event, { type: 'server' })),
},
{
id: 'new-collection',
label: formatMessage(messages.newCollection),
icon: CollectionPlusIcon,
action: (event) =>
requireVerifiedEmail(() => $refs.modal_collection_creation.show(event)),
},
@@ -533,6 +532,7 @@
{
id: 'new-organization',
label: formatMessage(messages.newOrganization),
icon: OrganizationPlusIcon,
action: (event) =>
requireVerifiedEmail(() => $refs.modal_organization_creation.show(event)),
},
@@ -540,19 +540,6 @@
>
<PlusIcon aria-hidden="true" />
{{ formatMessage(messages.publish) }}
<template #new-project>
<BoxIcon aria-hidden="true" /> {{ formatMessage(messages.newProject) }}
</template>
<template #new-server-project>
<ServerIcon aria-hidden="true" /> {{ formatMessage(messages.newServerProject) }}
</template>
<!-- <template #import-project> <BoxImportIcon /> Import project </template>-->
<template #new-collection>
<CollectionIcon aria-hidden="true" /> {{ formatMessage(messages.newCollection) }}
</template>
<template #new-organization>
<OrganizationIcon aria-hidden="true" /> {{ formatMessage(messages.newOrganization) }}
</template>
</TeleportOverflowMenu>
<TeleportOverflowMenu
v-if="auth.user"
@@ -566,57 +553,40 @@
>
<Avatar :src="auth.user.avatar_url" aria-hidden="true" circle />
<DropdownIcon class="h-5 w-5 text-secondary" />
<template #profile>
<UserIcon aria-hidden="true" /> {{ formatMessage(messages.profile) }}
</template>
<template #notifications>
<BellIcon aria-hidden="true" /> {{ formatMessage(commonMessages.notificationsLabel) }}
</template>
<template #reports>
<ReportIcon aria-hidden="true" /> {{ formatMessage(messages.activeReports) }}
</template>
<template #saved>
<LibraryIcon aria-hidden="true" /> {{ formatMessage(commonMessages.collectionsLabel) }}
</template>
<template #servers>
<ServerStackIcon aria-hidden="true" /> {{ formatMessage(messages.myServers) }}
</template>
<template #plus>
<ArrowBigUpDashIcon aria-hidden="true" />
{{ formatMessage(messages.upgradeToModrinthPlus) }}
</template>
<template #settings>
<SettingsIcon aria-hidden="true" /> {{ formatMessage(commonMessages.settingsLabel) }}
</template>
<template #flags>
<ToggleRightIcon aria-hidden="true" />
{{ formatMessage(commonSettingsMessages.featureFlags) }}
</template>
<template #projects>
<BoxIcon aria-hidden="true" /> {{ formatMessage(messages.projects) }}
</template>
<template #organizations>
<OrganizationIcon aria-hidden="true" /> {{ formatMessage(messages.organizations) }}
</template>
<template #affiliate-links>
<AffiliateIcon aria-hidden="true" />
{{ formatMessage(commonMessages.affiliateLinksButton) }}
</template>
<template #revenue>
<CurrencyIcon aria-hidden="true" /> {{ formatMessage(messages.revenue) }}
</template>
<template #analytics>
<ChartIcon aria-hidden="true" /> {{ formatMessage(commonMessages.analyticsButton) }}
</template>
<template #moderation>
<ScaleIcon aria-hidden="true" /> {{ formatMessage(commonMessages.moderationLabel) }}
</template>
<template #sign-out>
<LogOutIcon aria-hidden="true" /> {{ formatMessage(commonMessages.signOutButton) }}
<template
v-for="account in accountSwitcherAccounts"
:key="account.id"
#[account.optionId]
>
<Avatar :src="account.avatarUrl" size="1.25rem" aria-hidden="true" circle />
{{ account.username }}
<UserRoleIcon :role="account.role" />
</template>
</TeleportOverflowMenu>
<template v-else>
<ButtonLink type="colored" color="brand" :to="signInRouteObj">
<TeleportOverflowMenu
v-if="accountSwitcherAccounts.length > 0"
type="colored"
color="brand"
:icon-only="false"
:label="formatMessage(commonMessages.signInButton)"
class="btn-dropdown-animation !gap-1 !pr-1"
:options="accountSwitcherOptions"
>
<LogInIcon aria-hidden="true" />
{{ formatMessage(commonMessages.signInButton) }}
<DropdownIcon class="h-5 w-5" />
<template
v-for="account in accountSwitcherAccounts"
:key="account.id"
#[account.optionId]
>
<Avatar :src="account.avatarUrl" size="1.25rem" aria-hidden="true" circle />
{{ account.username }}
<UserRoleIcon :role="account.role" />
</template>
</TeleportOverflowMenu>
<ButtonLink v-else type="colored" color="brand" :to="signInRouteObj">
<LogInIcon aria-hidden="true" />
{{ formatMessage(commonMessages.signInButton) }}
</ButtonLink>
@@ -835,27 +805,33 @@
import {
AffiliateIcon,
ArrowBigUpDashIcon,
ArrowLeftRightIcon,
BellIcon,
BookOpenIcon,
BoxIcon,
BoxPlusIcon,
BracesIcon,
ChartIcon,
CollectionIcon,
CollectionPlusIcon,
CompassIcon,
CurrencyIcon,
DownloadIcon,
DropdownIcon,
FileIcon,
FileSearchCornerIcon,
GlassesIcon,
GlobeIcon,
HamburgerIcon,
HashIcon,
HomeIcon,
IssuesIcon,
LibraryIcon,
LogInIcon,
LogOutIcon,
MailIcon,
ModrinthIcon,
MoonIcon,
OrganizationIcon,
OrganizationPlusIcon,
PackageOpenIcon,
PaintbrushIcon,
PlugIcon,
@@ -864,6 +840,8 @@ import {
ScaleIcon,
SearchIcon,
ServerIcon,
ServerPlusIcon,
ServerSearchIcon,
ServerStackIcon,
SettingsIcon,
ShieldAlertIcon,
@@ -890,6 +868,7 @@ import {
providePageContext,
TeleportOverflowMenu,
useHostingIntercom,
UserRoleIcon,
useVIntl,
} from '@modrinth/ui'
import { isAdmin, isStaff, UserBadge } from '@modrinth/utils'
@@ -911,7 +890,14 @@ import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.
import OrganizationCreateModal from '~/components/ui/create/OrganizationCreateModal.vue'
import ProjectCreateModal from '~/components/ui/create/ProjectCreateModal.vue'
import ModrinthFooter from '~/components/ui/ModrinthFooter.vue'
import { getSignInRouteObj } from '~/composables/auth.ts'
import {
forgetStoredAccount,
switchToSignedOut,
switchToStoredAccount,
useStoredAccounts,
} from '~/composables/accounts.ts'
import { getAddAccountRouteObj, getSignInRouteObj } from '~/composables/auth.ts'
import { logout } from '~/composables/user.js'
import { errors as generatedStateErrors, taxComplianceThresholds } from '~/generated/state.json'
import { provideCurrentProjectId } from '~/providers/current-project.ts'
import { getProjectTypeMessage } from '~/utils/i18n-project-type.ts'
@@ -933,6 +919,8 @@ const config = useRuntimeConfig()
const route = useNativeRoute()
const router = useNativeRouter()
const signInRouteObj = computed(() => getSignInRouteObj(route))
const addAccountRouteObj = computed(() => getAddAccountRouteObj(route))
const storedAccounts = useStoredAccounts()
const link = config.public.siteUrl + route.path.replace(/\/+$/, '')
const client = injectModrinthClient()
const pageContext = injectPageContext()
@@ -1101,9 +1089,9 @@ const messages = defineMessages({
id: 'layout.action.external-projects',
defaultMessage: 'External projects',
},
lookupByEmail: {
id: 'layout.action.lookup-by-email',
defaultMessage: 'Lookup by email',
userLookup: {
id: 'layout.action.user-lookup',
defaultMessage: 'User lookup',
},
fileLookup: {
id: 'layout.action.file-lookup',
@@ -1169,6 +1157,22 @@ const messages = defineMessages({
id: 'layout.nav.my-servers',
defaultMessage: 'My servers',
},
switchAccount: {
id: 'layout.nav.switch-account',
defaultMessage: 'Switch account',
},
addAccount: {
id: 'layout.nav.add-account',
defaultMessage: 'Add account',
},
removeAccount: {
id: 'layout.nav.remove-account',
defaultMessage: 'Remove account',
},
accountSwitchFailed: {
id: 'layout.nav.switch-account-failed',
defaultMessage: "Couldn't switch accounts. Please try again.",
},
openMenu: {
id: 'layout.mobile.open-menu',
defaultMessage: 'Open menu',
@@ -1252,6 +1256,61 @@ const navRoutes = computed(() => [
},
])
const accountSwitcherAccounts = computed(() =>
storedAccounts.value.map((account) => ({
...account,
optionId: `account-${account.id}`,
current: account.id === auth.value.user?.id,
})),
)
const accountSwitcherOptions = computed(() => [
...accountSwitcherAccounts.value.map((account) => ({
id: account.optionId,
label: account.username,
selected: account.current,
action: () => switchAccount(account),
trailingAction: {
label: formatMessage(messages.removeAccount),
icon: XIcon,
color: 'red',
action: () => removeAccount(account),
},
})),
{
type: 'divider',
},
{
id: 'add-account',
label: formatMessage(messages.addAccount),
icon: PlusIcon,
type: 'link',
to: addAccountRouteObj.value,
},
])
async function switchAccount(account) {
if (account.current) return
const result = await switchToStoredAccount(account)
if (result === 'error') {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: formatMessage(messages.accountSwitchFailed),
type: 'error',
})
}
}
async function removeAccount(account) {
if (account.current) {
await logout()
return
}
forgetStoredAccount(account.id)
}
const userMenuOptions = computed(() => {
const user = auth.value.user
if (!user) return []
@@ -1260,12 +1319,14 @@ const userMenuOptions = computed(() => {
{
id: 'profile',
label: formatMessage(messages.profile),
icon: UserIcon,
type: 'link',
to: `/user/${user.username}`,
},
{
id: 'plus',
label: formatMessage(messages.upgradeToModrinthPlus),
icon: ArrowBigUpDashIcon,
type: 'link',
to: '/plus',
tone: 'purple',
@@ -1274,12 +1335,14 @@ const userMenuOptions = computed(() => {
{
id: 'servers',
label: formatMessage(messages.myServers),
icon: ServerStackIcon,
type: 'link',
to: '/hosting/manage',
},
{
id: 'flags',
label: formatMessage(commonSettingsMessages.featureFlags),
icon: ToggleRightIcon,
type: 'link',
to: '/settings/flags',
shown: flags.value.developerMode,
@@ -1287,6 +1350,7 @@ const userMenuOptions = computed(() => {
{
id: 'settings',
label: formatMessage(commonMessages.settingsLabel),
icon: SettingsIcon,
type: 'link',
to: '/settings',
},
@@ -1301,18 +1365,21 @@ const userMenuOptions = computed(() => {
{
id: 'notifications',
label: formatMessage(commonMessages.notificationsLabel),
icon: BellIcon,
type: 'link',
to: '/dashboard/notifications',
},
{
id: 'reports',
label: formatMessage(messages.activeReports),
icon: ReportIcon,
type: 'link',
to: '/dashboard/reports',
},
{
id: 'saved',
label: formatMessage(commonMessages.collectionsLabel),
icon: LibraryIcon,
type: 'link',
to: '/dashboard/collections',
},
@@ -1322,24 +1389,28 @@ const userMenuOptions = computed(() => {
{
id: 'projects',
label: formatMessage(messages.projects),
icon: BoxIcon,
type: 'link',
to: '/dashboard/projects',
},
{
id: 'organizations',
label: formatMessage(messages.organizations),
icon: OrganizationIcon,
type: 'link',
to: '/dashboard/organizations',
},
{
id: 'analytics',
label: formatMessage(commonMessages.analyticsButton),
icon: ChartIcon,
type: 'link',
to: '/dashboard/analytics',
},
{
id: 'affiliate-links',
label: formatMessage(commonMessages.affiliateLinksButton),
icon: AffiliateIcon,
type: 'link',
to: '/dashboard/affiliate-links',
shown: Boolean(user.badges & UserBadge.AFFILIATE),
@@ -1347,6 +1418,7 @@ const userMenuOptions = computed(() => {
{
id: 'revenue',
label: formatMessage(messages.revenue),
icon: CurrencyIcon,
type: 'link',
to: '/dashboard/revenue',
},
@@ -1357,9 +1429,17 @@ const userMenuOptions = computed(() => {
{
type: 'divider',
},
{
id: 'switch-account',
label: formatMessage(messages.switchAccount),
icon: ArrowLeftRightIcon,
type: 'submenu',
options: accountSwitcherOptions.value,
},
{
id: 'sign-out',
label: formatMessage(commonMessages.signOutButton),
icon: LogOutIcon,
tone: 'red',
hoverFilled: true,
action: () => logoutUser(),
@@ -1448,7 +1528,7 @@ watch(
)
async function logoutUser() {
await logout()
await switchToSignedOut()
}
function runAnalytics() {
@@ -5,9 +5,6 @@
"action.continue": {
"message": "متابعة"
},
"admin.billing.error.not-found": {
"message": "المستخدم غير موجود"
},
"ads-consent.accept": {
"message": "قبول الكل"
},
@@ -2648,9 +2645,6 @@
"layout.action.file-lookup": {
"message": "البحث عن ملف"
},
"layout.action.lookup-by-email": {
"message": "البحث باستخدام البريد الإلكتروني"
},
"layout.action.manage-affiliates": {
"message": "إدارة روابط العمولة"
},
@@ -5,9 +5,6 @@
"action.continue": {
"message": "Pokračovat"
},
"admin.billing.error.not-found": {
"message": "Uživatel nebyl nalezen"
},
"ads-consent.accept": {
"message": "Přijmout vše"
},
@@ -5,9 +5,6 @@
"action.continue": {
"message": "Forsæt"
},
"admin.billing.error.not-found": {
"message": "Bruger ikke fundet"
},
"ads-consent.accept": {
"message": "Tillad alle"
},
@@ -5,9 +5,6 @@
"action.continue": {
"message": "Weiter"
},
"admin.billing.error.not-found": {
"message": "Benutzer nicht gefunden"
},
"ads-consent.accept": {
"message": "Alle akzeptieren"
},
@@ -2651,9 +2648,6 @@
"layout.action.file-lookup": {
"message": "Datei ansehen"
},
"layout.action.lookup-by-email": {
"message": "Über E-Mail ansehen"
},
"layout.action.manage-affiliates": {
"message": "Partnerlinks verwalten"
},
@@ -5,9 +5,6 @@
"action.continue": {
"message": "Weiter"
},
"admin.billing.error.not-found": {
"message": "Benutzer nicht gefunden"
},
"ads-consent.accept": {
"message": "Alle akzeptieren"
},
@@ -2651,9 +2648,6 @@
"layout.action.file-lookup": {
"message": "Dateisuche"
},
"layout.action.lookup-by-email": {
"message": "Mit E-Mail suchen"
},
"layout.action.manage-affiliates": {
"message": "Partnerlinks verwalten"
},
+57 -6
View File
@@ -5,9 +5,6 @@
"action.continue": {
"message": "Continue"
},
"admin.billing.error.not-found": {
"message": "User not found"
},
"ads-consent.accept": {
"message": "Accept all"
},
@@ -821,6 +818,33 @@
"auth.create-account.username.placeholder": {
"message": "Enter username"
},
"auth.reauthenticate.2fa.description": {
"message": "Enter the 6-digit code from your authenticator app, or one of your backup codes."
},
"auth.reauthenticate.2fa.label": {
"message": "Two-factor authentication"
},
"auth.reauthenticate.2fa.placeholder": {
"message": "Enter code..."
},
"auth.reauthenticate.continue-with-passkey": {
"message": "Continue with passkey"
},
"auth.reauthenticate.last-signed-in-with": {
"message": "It was last signed into with {method, select, password {a password} passkey {a passkey} github {GitHub} discord {Discord} google {Google} microsoft {Microsoft} gitlab {GitLab} steam {Steam} paypal {PayPal} other {{method}} }."
},
"auth.reauthenticate.page-title": {
"message": "Reauthenticate"
},
"auth.reauthenticate.sign-in-another-way": {
"message": "Sign in another way"
},
"auth.reauthenticate.sign-in-with-provider": {
"message": "Sign in with {provider}"
},
"auth.reauthenticate.title": {
"message": "{name} needs to be reauthenticated."
},
"auth.reset-password.method-choice.action": {
"message": "Send recovery email"
},
@@ -863,6 +887,12 @@
"auth.sign-in.2fa.placeholder": {
"message": "Enter code..."
},
"auth.sign-in.add-account": {
"message": "Add account"
},
"auth.sign-in.choose-account": {
"message": "Choose an account to use in Modrinth App"
},
"auth.sign-in.continue-with-email": {
"message": "Continue with Email"
},
@@ -881,6 +911,9 @@
"auth.sign-in.launcher.complete.description": {
"message": "Were returning you to the Modrinth App. If nothing happens, use the button below."
},
"auth.sign-in.launcher.complete.go-to-website": {
"message": "Go to Modrinth.com"
},
"auth.sign-in.launcher.complete.return-button": {
"message": "Open Modrinth App"
},
@@ -2204,6 +2237,12 @@
"error.generic.401.signed-in-as": {
"message": "You're currently signed in as:"
},
"error.generic.401.switch-account": {
"message": "Or switch to another account:"
},
"error.generic.401.switch-account-failed": {
"message": "Couldn't switch accounts. Please try again."
},
"error.generic.401.title": {
"message": "You don't have access to this page"
},
@@ -2660,9 +2699,6 @@
"layout.action.file-lookup": {
"message": "File lookup"
},
"layout.action.lookup-by-email": {
"message": "Lookup by email"
},
"layout.action.manage-affiliates": {
"message": "Manage affiliate links"
},
@@ -2693,6 +2729,9 @@
"layout.action.tech-review": {
"message": "Tech review"
},
"layout.action.user-lookup": {
"message": "User lookup"
},
"layout.avatar.alt": {
"message": "Your avatar"
},
@@ -2888,6 +2927,9 @@
"layout.nav.active-reports": {
"message": "Active reports"
},
"layout.nav.add-account": {
"message": "Add account"
},
"layout.nav.discover": {
"message": "Discover"
},
@@ -2918,6 +2960,9 @@
"layout.nav.projects": {
"message": "Projects"
},
"layout.nav.remove-account": {
"message": "Remove account"
},
"layout.nav.revenue": {
"message": "Revenue"
},
@@ -2927,6 +2972,12 @@
"layout.nav.search": {
"message": "Search"
},
"layout.nav.switch-account": {
"message": "Switch account"
},
"layout.nav.switch-account-failed": {
"message": "Couldn't switch accounts. Please try again."
},
"layout.nav.upgrade-to-modrinth-plus": {
"message": "Upgrade to Modrinth+"
},
@@ -5,9 +5,6 @@
"action.continue": {
"message": "Continuar"
},
"admin.billing.error.not-found": {
"message": "Usuario no encontrado"
},
"ads-consent.accept": {
"message": "Aceptar todo"
},
@@ -2651,9 +2648,6 @@
"layout.action.file-lookup": {
"message": "Buscar archivo"
},
"layout.action.lookup-by-email": {
"message": "Buscar por correo electrónico"
},
"layout.action.manage-affiliates": {
"message": "Gestionar enlaces de afiliado"
},
@@ -5,9 +5,6 @@
"action.continue": {
"message": "Continuar"
},
"admin.billing.error.not-found": {
"message": "Usuario no encontrado"
},
"ads-consent.accept": {
"message": "Aceptar todos"
},
@@ -2642,9 +2639,6 @@
"layout.action.file-lookup": {
"message": "Búsqueda de archivos"
},
"layout.action.lookup-by-email": {
"message": "Búsqueda por correo electrónico"
},
"layout.action.manage-affiliates": {
"message": "Manejar enlaces de afiliados"
},
@@ -5,9 +5,6 @@
"action.continue": {
"message": "Jatka"
},
"admin.billing.error.not-found": {
"message": "Käyttäjää ei löydetty"
},
"analytics.action.add": {
"message": "Lisää"
},
@@ -5,9 +5,6 @@
"action.continue": {
"message": "Tumuloy"
},
"admin.billing.error.not-found": {
"message": "Hindi mahanap na tagagamit"
},
"analytics.action.add": {
"message": "Magdagdag"
},
@@ -1625,9 +1622,6 @@
"layout.action.file-lookup": {
"message": "File lookup"
},
"layout.action.lookup-by-email": {
"message": "Lookup by email"
},
"layout.action.manage-affiliates": {
"message": "Pamahalaan ang mga affiliate link"
},
@@ -5,9 +5,6 @@
"action.continue": {
"message": "Continuer"
},
"admin.billing.error.not-found": {
"message": "Utilisateur non trouvé"
},
"ads-consent.accept": {
"message": "Tout accepter"
},
@@ -2651,9 +2648,6 @@
"layout.action.file-lookup": {
"message": "Recherche de fichier"
},
"layout.action.lookup-by-email": {
"message": "Recherche par adresse e-mail"
},
"layout.action.manage-affiliates": {
"message": "Gérer les liens affiliés"
},
@@ -5,9 +5,6 @@
"action.continue": {
"message": "המשך"
},
"admin.billing.error.not-found": {
"message": "משתמש לא קיים"
},
"ads-consent.accept": {
"message": "אשר הכול"
},
@@ -1448,9 +1445,6 @@
"layout.action.file-lookup": {
"message": "חיפוש קבצים"
},
"layout.action.lookup-by-email": {
"message": "חיפוש על ידי אימייל"
},
"layout.action.manage-affiliates": {
"message": "נהל את קישורי השותפים"
},
@@ -5,9 +5,6 @@
"action.continue": {
"message": "Tovább"
},
"admin.billing.error.not-found": {
"message": "Felhasználó nem található"
},
"ads-consent.accept": {
"message": "Összes elfogadása"
},
@@ -2504,9 +2501,6 @@
"layout.action.file-lookup": {
"message": "Fájl keresés"
},
"layout.action.lookup-by-email": {
"message": "Keresés e-mail cím alapján"
},
"layout.action.manage-affiliates": {
"message": "Partnerlinkek kezelése"
},
@@ -5,9 +5,6 @@
"action.continue": {
"message": "Lanjutkan"
},
"admin.billing.error.not-found": {
"message": "Pengguna tidak ditemukan"
},
"analytics.action.add": {
"message": "Tambah"
},
@@ -1640,9 +1637,6 @@
"layout.action.file-lookup": {
"message": "Pencarian berkas"
},
"layout.action.lookup-by-email": {
"message": "Cari dengan sur-el"
},
"layout.action.manage-affiliates": {
"message": "Kelola tautan afiliasi"
},
@@ -5,9 +5,6 @@
"action.continue": {
"message": "Continua"
},
"admin.billing.error.not-found": {
"message": "Utente non trovato"
},
"ads-consent.accept": {
"message": "Accetta tutto"
},
@@ -2642,9 +2639,6 @@
"layout.action.file-lookup": {
"message": "Trova file"
},
"layout.action.lookup-by-email": {
"message": "Trova tramite email"
},
"layout.action.manage-affiliates": {
"message": "Gestisci link affiliati"
},
@@ -5,9 +5,6 @@
"action.continue": {
"message": "続ける"
},
"admin.billing.error.not-found": {
"message": "ユーザーが見つかりませんでした"
},
"ads-consent.accept": {
"message": "すべて受け入れる"
},
@@ -2537,9 +2534,6 @@
"layout.action.file-lookup": {
"message": "ファイル検索"
},
"layout.action.lookup-by-email": {
"message": "メールで探す"
},
"layout.action.manage-affiliates": {
"message": "アフィリエイトリンクを管理"
},
@@ -5,9 +5,6 @@
"action.continue": {
"message": "계속"
},
"admin.billing.error.not-found": {
"message": "사용자를 찾을 수 없습니다"
},
"ads-consent.accept": {
"message": "모두 수용"
},
@@ -2651,9 +2648,6 @@
"layout.action.file-lookup": {
"message": "파일 찾기"
},
"layout.action.lookup-by-email": {
"message": "이메일로 찾기"
},
"layout.action.manage-affiliates": {
"message": "제휴 링크 관리"
},
@@ -5,9 +5,6 @@
"action.continue": {
"message": "Teruskan"
},
"admin.billing.error.not-found": {
"message": "Pengguna tidak dijumpai"
},
"analytics.action.add": {
"message": "Tambah"
},
@@ -2156,9 +2153,6 @@
"layout.action.file-lookup": {
"message": "Carian fail"
},
"layout.action.lookup-by-email": {
"message": "Cari melalui e-mel"
},
"layout.action.manage-affiliates": {
"message": "Urus pautan afiliasi"
},
@@ -5,9 +5,6 @@
"action.continue": {
"message": "Doorgaan"
},
"admin.billing.error.not-found": {
"message": "Gebruiker niet gevonden"
},
"ads-consent.accept": {
"message": "Alles accepteren"
},
@@ -2648,9 +2645,6 @@
"layout.action.file-lookup": {
"message": "Bestand opzoeken"
},
"layout.action.lookup-by-email": {
"message": "Zoek op via email"
},
"layout.action.manage-affiliates": {
"message": "Beheer partner links"
},
@@ -5,9 +5,6 @@
"action.continue": {
"message": "Fortsett"
},
"admin.billing.error.not-found": {
"message": "Bruker ikke funnet"
},
"analytics.action.add": {
"message": "Legg til"
},
@@ -2078,9 +2075,6 @@
"layout.action.file-lookup": {
"message": "Filoppslag"
},
"layout.action.lookup-by-email": {
"message": "Bla gjennom med email"
},
"layout.action.manage-affiliates": {
"message": "Hold styr på tilknytte lenker"
},
@@ -5,9 +5,6 @@
"action.continue": {
"message": "Kontynuuj"
},
"admin.billing.error.not-found": {
"message": "Nie znaleziono użytkownika"
},
"ads-consent.accept": {
"message": "Zaakceptuj wszystko"
},
@@ -2645,9 +2642,6 @@
"layout.action.file-lookup": {
"message": "Wyszukiwarka plików"
},
"layout.action.lookup-by-email": {
"message": "Szukaj po e-mailu"
},
"layout.action.manage-affiliates": {
"message": "Zarządzaj linkami afiliacyjnymi"
},
@@ -5,9 +5,6 @@
"action.continue": {
"message": "Continuar"
},
"admin.billing.error.not-found": {
"message": "Usuário não encontrado"
},
"ads-consent.accept": {
"message": "Aceitar todos"
},
@@ -2651,9 +2648,6 @@
"layout.action.file-lookup": {
"message": "Procurar arquivo"
},
"layout.action.lookup-by-email": {
"message": "Procurar por e-mail"
},
"layout.action.manage-affiliates": {
"message": "Gerenciar links de afiliado"
},
@@ -5,9 +5,6 @@
"action.continue": {
"message": "Continuar"
},
"admin.billing.error.not-found": {
"message": "Utilizador não encontrado"
},
"ads-consent.accept": {
"message": "Aceitar todos"
},
@@ -2324,9 +2321,6 @@
"layout.action.file-lookup": {
"message": "Pesquisa de ficheiros"
},
"layout.action.lookup-by-email": {
"message": "Pesquisa por e-mail"
},
"layout.action.manage-affiliates": {
"message": "Gerir links de afiliado"
},
@@ -5,9 +5,6 @@
"action.continue": {
"message": "Continuă"
},
"admin.billing.error.not-found": {
"message": "Utilizatorul nu a fost găsit"
},
"app-marketing.download.description": {
"message": "Aplicația noastră de desktop este disponibilă pe toate platformele, alege versiunea ta favorită."
},
@@ -941,9 +938,6 @@
"layout.action.file-lookup": {
"message": "Căutare fișier"
},
"layout.action.lookup-by-email": {
"message": "Căutare după email"
},
"layout.action.manage-server-notices": {
"message": "Administrează notificările serverului"
},
@@ -5,9 +5,6 @@
"action.continue": {
"message": "Продолжить"
},
"admin.billing.error.not-found": {
"message": "Пользователь не найден"
},
"ads-consent.accept": {
"message": "Принять всё"
},
@@ -2645,9 +2642,6 @@
"layout.action.file-lookup": {
"message": "Поиск файла"
},
"layout.action.lookup-by-email": {
"message": "Поиск по почте"
},
"layout.action.manage-affiliates": {
"message": "Партнёрские ссылки"
},
@@ -5,9 +5,6 @@
"action.continue": {
"message": "Nastavi"
},
"admin.billing.error.not-found": {
"message": "Korisnik nije pronađen"
},
"ads-consent.accept": {
"message": "Prihvati sve"
},
@@ -5,9 +5,6 @@
"action.continue": {
"message": "Fortsätt"
},
"admin.billing.error.not-found": {
"message": "Användaren hittades inte"
},
"ads-consent.accept": {
"message": "Godkänn alla"
},
@@ -2462,9 +2459,6 @@
"layout.action.file-lookup": {
"message": "Sök efter fil"
},
"layout.action.lookup-by-email": {
"message": "Sök efter e-post"
},
"layout.action.manage-affiliates": {
"message": "Hantera affiliatelänk"
},
@@ -5,9 +5,6 @@
"action.continue": {
"message": "ดำเนินการต่อ"
},
"admin.billing.error.not-found": {
"message": "ไม่พบผู้ใช้นี้"
},
"analytics.action.add": {
"message": "เพิ่ม"
},
@@ -5,9 +5,6 @@
"action.continue": {
"message": "Devam et"
},
"admin.billing.error.not-found": {
"message": "Kullanıcı bulunamadı"
},
"ads-consent.accept": {
"message": "Tümünü kabul et"
},
@@ -2651,9 +2648,6 @@
"layout.action.file-lookup": {
"message": "Dosya bulma"
},
"layout.action.lookup-by-email": {
"message": "E-posta ile arama"
},
"layout.action.manage-affiliates": {
"message": "Ortaklık linklerini yönet"
},
@@ -5,9 +5,6 @@
"action.continue": {
"message": "Продовжити"
},
"admin.billing.error.not-found": {
"message": "Користувача не знайдено"
},
"ads-consent.accept": {
"message": "Прийняти всі"
},
@@ -2648,9 +2645,6 @@
"layout.action.file-lookup": {
"message": "Пошук файлів"
},
"layout.action.lookup-by-email": {
"message": "Пошук електронною поштою"
},
"layout.action.manage-affiliates": {
"message": "Керувати партнерськими посиланнями"
},
@@ -5,9 +5,6 @@
"action.continue": {
"message": "Tiếp tục"
},
"admin.billing.error.not-found": {
"message": "Không tìm thấy người dùng"
},
"ads-consent.accept": {
"message": "Chấp nhận tất cả"
},
@@ -2321,9 +2318,6 @@
"layout.action.file-lookup": {
"message": "Tra cứu tệp"
},
"layout.action.lookup-by-email": {
"message": "Tra cứu theo email"
},
"layout.action.manage-affiliates": {
"message": "Quản lý link affiliate"
},
@@ -5,9 +5,6 @@
"action.continue": {
"message": "继续"
},
"admin.billing.error.not-found": {
"message": "用户不存在"
},
"ads-consent.accept": {
"message": "全部接受"
},
@@ -2651,9 +2648,6 @@
"layout.action.file-lookup": {
"message": "文件查询"
},
"layout.action.lookup-by-email": {
"message": "通过电子邮箱查找"
},
"layout.action.manage-affiliates": {
"message": "管理推广链接"
},
@@ -5,9 +5,6 @@
"action.continue": {
"message": "繼續"
},
"admin.billing.error.not-found": {
"message": "找不到使用者"
},
"ads-consent.accept": {
"message": "全部接受"
},
@@ -2651,9 +2648,6 @@
"layout.action.file-lookup": {
"message": "檔案查詢"
},
"layout.action.lookup-by-email": {
"message": "依電子郵件查詢"
},
"layout.action.manage-affiliates": {
"message": "管理聯盟連結"
},
+2 -2
View File
@@ -1,8 +1,8 @@
const whitelistedParams = ['flow', 'error']
export default defineNuxtRouteMiddleware(async (_to, from) => {
export default defineNuxtRouteMiddleware(async (to, from) => {
const config = useRuntimeConfig()
const auth = await useAuth()
const auth = await useAuth(null, to)
if (auth.value.user) return
@@ -1,3 +1,5 @@
import type { RouteLocationNormalized } from 'vue-router'
import { useGeneratedState } from '~/composables/generated'
import { projectQueryOptions, warmProjectCheckCaches } from '~/composables/queries/project'
import { useAppQueryClient } from '~/composables/query-client'
@@ -29,7 +31,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
}
const queryClient = useAppQueryClient()
const client = await getProjectMiddlewareClient()
const client = await getProjectMiddlewareClient(to)
const tags = useGeneratedState()
if (import.meta.client) startLoading()
@@ -85,13 +87,13 @@ export default defineNuxtRouteMiddleware(async (to) => {
}
})
async function getProjectMiddlewareClient() {
async function getProjectMiddlewareClient(route: RouteLocationNormalized) {
if (import.meta.server) {
const authToken = useCookie('auth-token')
return useServerModrinthClient({ authToken: authToken.value || undefined })
}
const auth = await useAuth()
const auth = await useAuth(null, route)
const config = useRuntimeConfig()
return createModrinthClient(auth, {
+2 -2
View File
@@ -1,7 +1,7 @@
import { isStaff } from '@modrinth/utils'
export default defineNuxtRouteMiddleware(async () => {
const auth = await useAuth()
export default defineNuxtRouteMiddleware(async (to) => {
const auth = await useAuth(null, to)
if (!auth.value.user || !isStaff(auth.value.user)) {
throw createError({
@@ -92,6 +92,7 @@
import { DownloadIcon, SpinnerIcon } from '@modrinth/assets'
import {
ButtonLink,
getEnvironmentFilterValue,
injectModrinthClient,
injectProjectPageContext,
Pagination,
@@ -141,6 +142,7 @@ const filteredVersions = computed(() => {
const selectedGameVersions = getArrayOrString(route.query.g) ?? []
const selectedLoaders = getArrayOrString(route.query.l) ?? []
const selectedVersionTypes = getArrayOrString(route.query.c) ?? []
const selectedEnvironments = getArrayOrString(route.query.e) ?? []
return versions.value.filter(
(projectVersion) =>
@@ -151,7 +153,9 @@ const filteredVersions = computed(() => {
(selectedLoaders.length === 0 ||
selectedLoaders.some((loader) => getVersionLoaders(projectVersion).includes(loader))) &&
(selectedVersionTypes.length === 0 ||
selectedVersionTypes.includes(projectVersion.version_type)),
selectedVersionTypes.includes(projectVersion.version_type)) &&
(selectedEnvironments.length === 0 ||
selectedEnvironments.includes(getEnvironmentFilterValue(projectVersion.environment) ?? '')),
)
})
+100 -3
View File
@@ -1,4 +1,104 @@
<template>
<div>
<NormalPage sidebar="left">
<template #header>
<h1 class="m-0 text-3xl font-semibold">Admin</h1>
</template>
<template #sidebar>
<NavStack
aria-label="Admin navigation"
:items="[
{ type: 'heading', label: 'Lookup' },
{
link: '/admin/file_lookup',
label: 'File lookup',
icon: FileSearchCornerIcon,
},
{
link: '/admin/user_email',
label: 'User lookup',
icon: UserSearchIcon,
shown: admin,
},
{
type: 'heading',
label: 'Hosting',
shown: admin,
},
{
link: '/admin/servers/lookup',
label: 'Server lookup',
icon: ServerSearchIcon,
shown: admin,
},
{
link: '/admin/servers/notices',
label: 'Server notices',
icon: IssuesIcon,
shown: admin,
},
{
link: '/admin/servers/transfers',
label: 'Server transfers',
icon: TransferIcon,
shown: admin,
},
{
type: 'heading',
label: 'Management',
shown: admin,
},
{
link: '/admin/affiliates',
label: 'Affiliate links',
icon: AffiliateIcon,
shown: admin,
},
{
link: '/admin/analytics/events',
label: 'Analytics events',
icon: ChartIcon,
shown: admin,
},
{ type: 'heading', label: 'Templates' },
{
link: '/admin/emails',
label: 'Email templates',
icon: MailIcon,
},
{
link: '/admin/docs',
label: 'Document templates',
icon: BookOpenIcon,
},
]"
/>
</template>
<NuxtPage :route="route" />
</NormalPage>
</div>
</template>
<script setup lang="ts">
import {
AffiliateIcon,
BookOpenIcon,
ChartIcon,
FileSearchCornerIcon,
IssuesIcon,
MailIcon,
ServerSearchIcon,
TransferIcon,
UserSearchIcon,
} from '@modrinth/assets'
import { NormalPage } from '@modrinth/ui'
import { isAdmin } from '@modrinth/utils'
import NavStack from '~/components/ui/NavStack.vue'
const auth = await useAuth()
const route = useNativeRoute()
const admin = computed(() => isAdmin(auth.value.user))
definePageMeta({
middleware: ['auth', 'staff'],
})
@@ -7,6 +107,3 @@ useSeoMeta({
robots: 'noindex',
})
</script>
<template>
<NuxtPage />
</template>
+3 -14
View File
@@ -13,11 +13,9 @@
:proceed-label="`Revoke`"
@proceed="confirmRevokeAffiliateCode"
/>
<div class="page">
<div
class="mb-6 flex items-center gap-6 border-0 border-b-[1px] border-solid border-divider pb-6"
>
<h1 class="m-0 grow text-2xl font-extrabold">Manage affiliate links</h1>
<div>
<div class="mb-4 flex items-center justify-between gap-4">
<h2 class="m-0 text-2xl font-semibold">Manage affiliate links</h2>
<div class="flex items-center gap-2">
<Input
v-model="filterQuery"
@@ -260,12 +258,3 @@ async function confirmRevokeAffiliateCode() {
}
}
</script>
<style lang="scss" scoped>
.page {
padding: 1rem;
margin-left: auto;
margin-right: auto;
max-width: 78.5rem;
}
</style>
@@ -125,108 +125,105 @@
</template>
</NewModal>
<div class="normal-page no-sidebar">
<div class="normal-page__content flex flex-col gap-4">
<div class="mt-8 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<h1 class="m-0 text-2xl font-extrabold text-contrast">Analytics Events</h1>
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between gap-4">
<h2 class="m-0 text-2xl font-semibold">Analytics events</h2>
<div class="flex flex-wrap items-center gap-2">
<Input
v-model="searchQuery"
:icon="SearchIcon"
type="search"
placeholder="Search..."
clearable
wrapper-class="w-full sm:w-72"
/>
<Button type="colored" color="brand" :disabled="isSaving" @click="openCreateModal">
<PlusIcon aria-hidden="true" />
New event
</Button>
</div>
</div>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input
v-model="searchQuery"
:icon="SearchIcon"
type="search"
placeholder="Search..."
clearable
wrapper-class="w-full sm:w-72"
/>
<Button type="colored" color="brand" :disabled="isSaving" @click="openCreateModal">
<PlusIcon aria-hidden="true" />
New event
<Table
v-model:sort-column="sortColumn"
v-model:sort-direction="sortDirection"
:columns="columns"
:data="sortedEvents"
row-key="id"
>
<template #cell-title="{ row }">
<span class="line-clamp-2 font-medium text-primary">{{ row.title }}</span>
</template>
<template #cell-announcement="{ row }">
<a
v-if="row.announcement_url"
:href="row.announcement_url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 font-medium text-primary hover:text-contrast"
>
Open link
<ExternalIcon class="size-4" aria-hidden="true" />
</a>
<span v-else class="text-xs font-medium text-primary"></span>
</template>
<template #cell-date="{ row }">
<div
v-if="isEventDateRange(row)"
class="flex flex-col gap-0.5 text-sm font-medium leading-5 text-primary"
>
<span>{{ formatEventDateRangeStart(row) }} -</span>
<span>{{ formatEventDateRangeEnd(row) }}</span>
</div>
<span v-else class="font-medium text-primary">{{ formatEventDateRange(row) }}</span>
</template>
<template #cell-metrics="{ row }">
<div class="flex flex-wrap gap-1">
<span
v-for="metric in getMetricKindOptions(row.for_metric_kind)"
:key="metric.value"
class="inline-flex items-center rounded-full border border-solid border-surface-5 px-2 py-0.5 text-xs font-medium text-secondary"
>
{{ metric.label }}
</span>
</div>
</template>
<template #cell-actions="{ row }">
<div class="flex justify-end gap-2">
<IconButton
type="outlined"
:label="`Delete ${row.title}`"
:disabled="isDeletingEvent(row.id)"
class="!text-red !shadow-[inset_0_0_0_1px_var(--color-red)] [&>svg]:!text-red"
@click="openDeleteEventModal(row)"
>
<TrashIcon aria-hidden="true" />
</IconButton>
<Button
type="outlined"
:disabled="isSaving || isDeletingEvent(row.id)"
@click="openEditModal(row)"
>
Edit
<EditIcon aria-hidden="true" />
</Button>
</div>
</div>
</template>
<Table
v-model:sort-column="sortColumn"
v-model:sort-direction="sortDirection"
:columns="columns"
:data="sortedEvents"
row-key="id"
>
<template #cell-title="{ row }">
<span class="line-clamp-2 font-medium text-primary">{{ row.title }}</span>
</template>
<template #cell-announcement="{ row }">
<a
v-if="row.announcement_url"
:href="row.announcement_url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 font-medium text-primary hover:text-contrast"
>
Open link
<ExternalIcon class="size-4" aria-hidden="true" />
</a>
<span v-else class="text-xs font-medium text-primary"></span>
</template>
<template #cell-date="{ row }">
<div
v-if="isEventDateRange(row)"
class="flex flex-col gap-0.5 text-sm font-medium leading-5 text-primary"
>
<span>{{ formatEventDateRangeStart(row) }} -</span>
<span>{{ formatEventDateRangeEnd(row) }}</span>
<template #empty-state>
<div class="flex h-64 items-center justify-center text-secondary">
<div v-if="isFetchingEvents" class="flex items-center gap-2">
<SpinnerIcon class="size-5 animate-spin" aria-hidden="true" />
Loading
</div>
<span v-else class="font-medium text-primary">{{ formatEventDateRange(row) }}</span>
</template>
<template #cell-metrics="{ row }">
<div class="flex flex-wrap gap-1">
<span
v-for="metric in getMetricKindOptions(row.for_metric_kind)"
:key="metric.value"
class="inline-flex items-center rounded-full border border-solid border-surface-5 px-2 py-0.5 text-xs font-medium text-secondary"
>
{{ metric.label }}
</span>
</div>
</template>
<template #cell-actions="{ row }">
<div class="flex justify-end gap-2">
<IconButton
type="outlined"
:label="`Delete ${row.title}`"
:disabled="isDeletingEvent(row.id)"
class="!text-red !shadow-[inset_0_0_0_1px_var(--color-red)] [&>svg]:!text-red"
@click="openDeleteEventModal(row)"
>
<TrashIcon aria-hidden="true" />
</IconButton>
<Button
type="outlined"
:disabled="isSaving || isDeletingEvent(row.id)"
@click="openEditModal(row)"
>
Edit
<EditIcon aria-hidden="true" />
</Button>
</div>
</template>
<template #empty-state>
<div class="flex h-64 items-center justify-center text-secondary">
<div v-if="isFetchingEvents" class="flex items-center gap-2">
<SpinnerIcon class="size-5 animate-spin" aria-hidden="true" />
Loading
</div>
<template v-else>No results.</template>
</div>
</template>
</Table>
</div>
<template v-else>No results.</template>
</div>
</template>
</Table>
</div>
</template>
@@ -241,10 +238,12 @@ import {
SpinnerIcon,
TrashIcon,
} from '@modrinth/assets'
import { Button, ButtonLink, IconButton } from '@modrinth/ui'
import {
Button,
ButtonLink,
ConfirmModal,
DatePicker,
IconButton,
injectModrinthClient,
injectNotificationManager,
Input,
@@ -120,13 +120,11 @@
</div>
</div>
</NewModal>
<div class="page">
<div
class="mb-4 flex items-center justify-between border-0 border-b border-solid border-divider pb-4"
>
<div>
<div class="mb-4 flex items-center justify-between gap-4">
<div class="flex items-center gap-2">
<Avatar :src="user?.avatar_url" :alt="user?.username" size="32px" circle />
<h1 class="m-0 text-2xl font-extrabold">{{ user?.username }}'s subscriptions</h1>
<h2 class="m-0 text-2xl font-semibold">{{ user?.username }}'s subscriptions</h2>
</div>
<div class="flex items-center gap-2">
<ButtonLink :to="`/user/${user?.id}`">
@@ -210,7 +208,6 @@ import {
Button,
ButtonLink,
CopyCode,
defineMessages,
DropdownSelect,
injectModrinthClient,
injectNotificationManager,
@@ -219,7 +216,6 @@ import {
Toggle,
useFormatDateTime,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import { capitalizeString } from '@modrinth/utils'
import { DEFAULT_CREDIT_EMAIL_MESSAGE } from '@modrinth/utils/utils.ts'
@@ -236,18 +232,8 @@ const formatDateTime = useFormatDateTime({
dateStyle: 'long',
})
const vintl = useVIntl()
const { formatMessage } = vintl
const formatRelativeTime = useRelativeTime()
const messages = defineMessages({
userNotFoundError: {
id: 'admin.billing.error.not-found',
defaultMessage: 'User not found',
},
})
const userId = useRouteId('user')
const {
@@ -266,7 +252,7 @@ watch(userError, (error) => {
showError({
fatal: true,
statusCode: error.statusCode ?? error.status ?? 404,
message: formatMessage(messages.userNotFoundError),
message: 'User not found',
})
}
})
@@ -410,11 +396,3 @@ async function modifyCharge() {
modifying.value = false
}
</script>
<style scoped>
.page {
padding: 1rem;
margin-left: auto;
margin-right: auto;
max-width: 56rem;
}
</style>
+191 -68
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { CopyIcon, LibraryIcon, PlayIcon, SearchIcon } from '@modrinth/assets'
import { Button, Card, Input } from '@modrinth/ui'
import { Button, IconButton, Input, NewModal } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import docs from '~/templates/docs'
@@ -14,7 +14,7 @@ const filtered = computed(() =>
function openAll() {
let offset = 0
for (const id of filtered.value) {
openPreview(id, offset)
openPopupPreview(id, offset)
offset++
}
}
@@ -23,101 +23,224 @@ function copy(id: string) {
navigator.clipboard?.writeText(`/_internal/templates/doc/${id}`).catch(() => {})
}
function openPreview(id: string, offset = 0) {
function uncachedPreviewUrl(id: string) {
return `/_internal/templates/doc/${id}?preview=${Date.now()}`
}
const previewModal = ref<{ hide: () => void; show: () => void } | null>(null)
const previewTemplate = ref<string | null>(null)
const previewLoading = ref(false)
const previewError = ref<string | null>(null)
const previewHtml = ref('')
const previewVariables = ref<string[]>([])
const variableValues = ref<Record<string, string>>({})
function extractVariables(html: string): string[] {
const tokens = new Set<string>()
const regex = /\{\s*([a-zA-Z0-9_.-]+)\s*\}/g
let match = regex.exec(html)
while (match !== null) {
tokens.add(match[1])
match = regex.exec(html)
}
return [...tokens]
}
const renderedPreview = computed(() => {
let html = previewHtml.value
for (const [key, value] of Object.entries(variableValues.value)) {
if (!value) {
continue
}
const pattern = new RegExp(`\\{\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\}`, 'g')
html = html.replace(pattern, value)
}
return html
})
async function openPreview(id: string, event?: MouseEvent) {
if (event?.shiftKey) {
openPopupPreview(id)
return
}
previewTemplate.value = id
previewLoading.value = true
previewError.value = null
previewHtml.value = ''
previewVariables.value = []
variableValues.value = {}
try {
const response = await fetch(uncachedPreviewUrl(id), { cache: 'no-store' })
previewHtml.value = await response.text()
if (!response.ok) {
throw new Error(`Failed to load template ${id}`)
}
const variables = extractVariables(previewHtml.value)
previewVariables.value = variables
variableValues.value = Object.fromEntries(variables.map((value) => [value, '']))
previewModal.value?.show()
} catch (error) {
previewError.value = 'Failed to load document preview.'
console.error(error)
previewModal.value?.show()
} finally {
previewLoading.value = false
}
}
function closePreview() {
previewModal.value?.hide()
}
function openPopupPreview(id: string, offset = 0) {
const width = 800
const height = 1000
const left = window.screenX + (window.outerWidth - width) / 2 + ((offset * 28) % 320)
const top = window.screenY + (window.outerHeight - height) / 2 + ((offset * 28) % 320)
window.open(
`/_internal/templates/doc/${id}`,
uncachedPreviewUrl(id),
`doc-${id}`,
`popup=yes,width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes,menubar=no,toolbar=no,location=no,status=no`,
)
}
const counts = computed(() => ({
total: allTemplates.length,
shown: filtered.value.length,
}))
onMounted(() => {
document.getElementById('doc-search')?.focus()
})
</script>
<template>
<div class="normal-page no-sidebar">
<h1 class="mb-4 text-3xl font-extrabold text-heading">Document templates</h1>
<div class="normal-page__content">
<Card class="mb-6 flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-3">
<Input
id="doc-search"
v-model="query"
type="search"
:icon="SearchIcon"
placeholder="Search templates..."
wrapper-class="w-72"
/>
<Button type="colored" color="brand" :disabled="filtered.length === 0" @click="openAll">
<LibraryIcon class="h-4 w-4" aria-hidden="true" />
Open all ({{ counts.shown }})
</Button>
<span class="text-sm text-secondary">
Showing <span class="font-medium text-contrast">{{ counts.shown }}</span> of
<span class="font-medium text-contrast">{{ counts.total }}</span>
</span>
</div>
<div>
<div class="mb-4 flex items-center justify-between gap-4">
<h2 class="m-0 text-2xl font-semibold">Document templates</h2>
<div class="flex items-center gap-2">
<Input
id="doc-search"
v-model="query"
:icon="SearchIcon"
type="text"
autocomplete="off"
placeholder="Search templates..."
clearable
/>
<Button type="colored" color="brand" :disabled="filtered.length === 0" @click="openAll">
<LibraryIcon class="h-4 w-4" aria-hidden="true" />
Open all ({{ filtered.length }})
</Button>
</div>
</div>
<NewModal
ref="previewModal"
header="Preview document"
width="min(92vw, 1000px)"
:max-content-height="'88vh'"
scrollable
>
<div class="flex flex-col gap-4">
<p class="label__title text-base">Template: {{ previewTemplate }}</p>
<div
v-if="filtered.length === 0"
class="rounded-lg border border-dashed border-divider px-6 py-10 text-center text-sm text-secondary"
v-if="previewError"
class="border-danger bg-danger/10 text-danger my-2 rounded border px-3 py-2 text-sm"
>
No templates match your search.
{{ previewError }}
</div>
<ul v-else class="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<li
v-for="id in filtered"
:key="id"
class="hover:border-green/70 group flex flex-col justify-between rounded-lg border border-divider bg-button-bg p-4 shadow-sm transition hover:shadow"
>
<div class="mb-3">
<div class="font-mono text-sm font-semibold tracking-tight text-contrast">
{{ id }}
</div>
<div class="mt-1 truncate text-xs text-secondary">
/_internal/templates/doc/{{ id }}
</div>
<div v-if="previewLoading" class="my-4 text-sm text-secondary">Loading preview...</div>
<div v-else>
<div v-if="previewVariables.length" class="mt-2 grid gap-3 md:grid-cols-2">
<label
v-for="variable in previewVariables"
:key="variable"
:for="`preview-${variable}`"
class="flex flex-col"
>
<span class="label__title">{{ variable }}</span>
<Input
:id="`preview-${variable}`"
v-model="variableValues[variable]"
type="text"
:placeholder="`Enter ${variable}`"
/>
</label>
</div>
<p v-else class="mt-2 text-xs text-secondary">
No template variables were detected; preview shown using default values.
</p>
<div class="mt-4">
<div class="label__title mb-2">Rendered template</div>
<iframe
v-if="!previewError"
:srcdoc="renderedPreview"
class="h-[60vh] w-full rounded border border-divider bg-white"
sandbox="allow-same-origin"
/>
<div
v-else
class="rounded border border-divider bg-white px-4 py-3 text-sm text-secondary"
>
Could not render template preview.
</div>
</div>
<div class="mt-auto flex gap-2">
<Button
type="colored"
color="brand"
class="w-full flex-1 justify-center"
@click="openPreview(id)"
>
<PlayIcon class="h-4 w-4" aria-hidden="true" />
Preview
</Button>
<div class="input-group mt-4">
<Button type="quiet" @click="closePreview">Close</Button>
</div>
</div>
</div>
</NewModal>
<div>
<div
v-if="filtered.length === 0"
class="border-0 border-b border-solid border-surface-4 pb-4"
>
No templates match your search.
</div>
<Button class="justify-center" title="Copy preview URL" @click="copy(id)">
<CopyIcon class="h-4 w-4" aria-hidden="true" />
</Button>
<ul v-else class="m-0 mt-4 grid gap-3 p-0 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<li
v-for="id in filtered"
:key="id"
class="group flex flex-col justify-between rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4 shadow-sm transition hover:shadow"
>
<div class="mb-3">
<div class="font-mono text-sm font-semibold tracking-tight text-contrast">
{{ id }}
</div>
</li>
</ul>
</Card>
<div class="mt-1 truncate text-xs text-secondary">
/_internal/templates/doc/{{ id }}
</div>
</div>
<p class="mt-2 text-xs text-secondary">
<div class="mt-auto flex gap-2">
<Button type="colored" color="brand" @click="openPreview(id, $event)">
<PlayIcon aria-hidden="true" />
Preview
</Button>
<IconButton type="outlined" label="Copy" title="Copy preview URL" @click="copy(id)">
<CopyIcon aria-hidden="true" />
</IconButton>
</div>
</li>
</ul>
<p class="mt-4">
All templates come from
<code class="rounded bg-code-bg px-1 py-0.5 text-[11px] text-code-text"
<code class="rounded bg-code-bg px-1 py-0.5 text-sm text-code-text"
>src/templates/docs/index.ts</code
>. Popouts render via
<code class="rounded bg-code-bg px-1 py-0.5 text-[11px] text-code-text"
<code class="rounded bg-code-bg px-1 py-0.5 text-sm text-code-text"
>/_internal/templates/doc/[template]</code
>.
</p>
+21 -30
View File
@@ -113,19 +113,31 @@ function openPopupPreview(id: string, offset = 0) {
)
}
const counts = computed(() => ({
total: allTemplates.length,
shown: filtered.value.length,
}))
onMounted(() => {
document.getElementById('email-search')?.focus()
})
</script>
<template>
<div class="normal-page no-sidebar">
<h1 class="mb-4 text-3xl font-extrabold text-heading">Email templates</h1>
<div>
<div class="mb-4 flex items-center justify-between gap-4">
<h2 class="m-0 text-2xl font-semibold">Email templates</h2>
<div class="flex items-center gap-2">
<Input
id="email-search"
v-model="query"
:icon="SearchIcon"
type="text"
autocomplete="off"
placeholder="Search templates..."
clearable
/>
<Button type="colored" color="brand" :disabled="filtered.length === 0" @click="openAll">
<LibraryIcon class="h-4 w-4" aria-hidden="true" />
Open all ({{ filtered.length }})
</Button>
</div>
</div>
<NewModal
ref="previewModal"
header="Preview email"
@@ -187,31 +199,10 @@ onMounted(() => {
</div>
</div>
</NewModal>
<div class="normal-page__content">
<div class="flex flex-wrap items-center gap-3">
<Input
id="email-search"
v-model="query"
type="search"
:icon="SearchIcon"
placeholder="Search templates..."
wrapper-class="w-72"
/>
<Button type="colored" color="brand" :disabled="filtered.length === 0" @click="openAll">
<LibraryIcon class="h-4 w-4" aria-hidden="true" />
Open all ({{ counts.shown }})
</Button>
<span class="text-sm text-secondary">
Showing <span class="font-medium text-contrast">{{ counts.shown }}</span> of
<span class="font-medium text-contrast">{{ counts.total }}</span>
</span>
</div>
<div>
<div
v-if="filtered.length === 0"
class="mt-4 border-0 border-b border-solid border-surface-4 pb-4"
class="border-0 border-b border-solid border-surface-4 pb-4"
>
No templates match your search.
</div>
+65 -69
View File
@@ -1,80 +1,76 @@
<template>
<div class="normal-page no-sidebar">
<h1>File lookup</h1>
<div class="normal-page__content">
<div class="card flex flex-col gap-3">
<div
class="border-highlight-gray hover:bg-button-hover relative flex h-32 cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed bg-button-bg p-8 transition-colors"
@click="triggerFileInput"
@drop.prevent="handleDrop"
@dragover.prevent
@dragenter.prevent
>
<p
class="mx-auto mb-0 flex items-center gap-2 text-center text-lg font-bold text-primary"
>
<UploadIcon /> Select file to lookup
</p>
<p class="mx-auto mt-0 text-center text-sm text-secondary">
Drag and drop or click here to browse
</p>
<input ref="fileInput" type="file" class="hidden" @change="handleFileSelect" />
<div>
<h2 class="m-0 mb-4 text-2xl font-semibold">File lookup</h2>
<div class="card flex flex-col gap-3">
<div
class="border-highlight-gray hover:bg-button-hover relative flex h-32 cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed bg-button-bg p-8 transition-colors"
@click="triggerFileInput"
@drop.prevent="handleDrop"
@dragover.prevent
@dragenter.prevent
>
<p class="mx-auto mb-0 flex items-center gap-2 text-center text-lg font-bold text-primary">
<UploadIcon /> Select file to lookup
</p>
<p class="mx-auto mt-0 text-center text-sm text-secondary">
Drag and drop or click here to browse
</p>
<input ref="fileInput" type="file" class="hidden" @change="handleFileSelect" />
</div>
<template v-if="selectedFile">
<div class="flex items-center gap-2 text-sm text-secondary">
<FileIcon class="h-4 w-4" />
<span>{{ selectedFile.name }} ({{ formatBytes(selectedFile.size) }})</span>
</div>
<template v-if="selectedFile">
<div class="flex items-center gap-2 text-sm text-secondary">
<FileIcon class="h-4 w-4" />
<span>{{ selectedFile.name }} ({{ formatBytes(selectedFile.size) }})</span>
</div>
<div v-if="loadingHash" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="h-4 w-4 animate-spin" />
Calculating hashes...
</div>
<div v-if="loadingLookup" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="h-4 w-4 animate-spin" />
Looking up file on Modrinth...
</div>
<div v-if="loadingHash" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="h-4 w-4 animate-spin" />
Calculating hashes...
<template v-if="fileHashes">
<h3 class="mb-0 text-lg font-extrabold text-contrast">File hashes:</h3>
<div class="flex flex-col gap-2">
<span class="text-xs text-secondary">SHA512:</span>
<CopyCode :text="fileHashes.sha512" />
<span class="mt-1 text-xs text-secondary">SHA256:</span>
<CopyCode :text="fileHashes.sha256" />
<span class="mt-1 text-xs text-secondary">SHA1:</span>
<CopyCode :text="fileHashes.sha1" />
</div>
<div v-if="loadingLookup" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="h-4 w-4 animate-spin" />
Looking up file on Modrinth...
</div>
<template v-if="fileHashes">
<h3 class="mb-0 text-lg font-extrabold text-contrast">File hashes:</h3>
<div class="flex flex-col gap-2">
<span class="text-xs text-secondary">SHA512:</span>
<CopyCode :text="fileHashes.sha512" />
<span class="mt-1 text-xs text-secondary">SHA256:</span>
<CopyCode :text="fileHashes.sha256" />
<span class="mt-1 text-xs text-secondary">SHA1:</span>
<CopyCode :text="fileHashes.sha1" />
</div>
</template>
</template>
</template>
<template v-if="lookupResult">
<h3 class="mb-0 text-lg font-extrabold text-contrast">Modrinth project:</h3>
<nuxt-link
class="flex w-fit items-center gap-2 text-lg font-semibold text-contrast hover:underline"
target="_blank"
:to="`/project/${lookupResult.projectId}`"
>
<Avatar :src="lookupResult.iconUrl" alt="" size="48px" />
{{ lookupResult.name }}
</nuxt-link>
<CopyCode :text="lookupResult.projectId" />
<h3 class="mb-0 text-lg font-extrabold text-contrast">Modrinth version:</h3>
<nuxt-link
class="text-blue hover:underline"
:to="`/project/${lookupResult.projectId}/version/${lookupResult.versionId}`"
target="_blank"
>
Version {{ lookupResult.versionNumber }}
</nuxt-link>
<CopyCode :text="lookupResult.versionId" />
</template>
<template v-if="lookupResult">
<h3 class="mb-0 text-lg font-extrabold text-contrast">Modrinth project:</h3>
<nuxt-link
class="flex w-fit items-center gap-2 text-lg font-semibold text-contrast hover:underline"
target="_blank"
:to="`/project/${lookupResult.projectId}`"
>
<Avatar :src="lookupResult.iconUrl" alt="" size="48px" />
{{ lookupResult.name }}
</nuxt-link>
<CopyCode :text="lookupResult.projectId" />
<h3 class="mb-0 text-lg font-extrabold text-contrast">Modrinth version:</h3>
<nuxt-link
class="text-blue hover:underline"
:to="`/project/${lookupResult.projectId}/version/${lookupResult.versionId}`"
target="_blank"
>
Version {{ lookupResult.versionNumber }}
</nuxt-link>
<CopyCode :text="lookupResult.versionId" />
</template>
<Admonition v-if="lookupError" type="critical" header="Lookup failed">
{{ lookupError }}
</Admonition>
</div>
<Admonition v-if="lookupError" type="critical" header="Lookup failed">
{{ lookupError }}
</Admonition>
</div>
</div>
</template>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
definePageMeta({
redirect: '/admin/file_lookup',
})
</script>
<template>
<div />
</template>
@@ -1,42 +1,40 @@
<template>
<div class="normal-page no-sidebar">
<h1>Server lookup</h1>
<div class="normal-page__content">
<form class="card flex flex-col gap-3" @submit.prevent="lookupServer">
<div class="flex flex-col gap-2">
<label for="server-subdomain">
<span class="text-lg font-semibold text-contrast">
Server subdomain
<span class="text-brand-red">*</span>
</span>
</label>
<Input
id="server-subdomain"
v-model="subdomainInput"
:icon="ServerIcon"
:error="submitted && !!validationError"
placeholder="example or example.modrinth.gg"
autocomplete="off"
autocapitalize="none"
:spellcheck="false"
/>
<span v-if="submitted && validationError" class="text-sm font-medium text-red">
{{ validationError }}
<div>
<h2 class="m-0 mb-4 text-2xl font-semibold">Server lookup</h2>
<form class="card flex flex-col gap-3" @submit.prevent="lookupServer">
<div class="flex flex-col gap-2">
<label for="server-subdomain">
<span class="text-lg font-semibold text-contrast">
Server subdomain
<span class="text-brand-red">*</span>
</span>
</div>
</label>
<Input
id="server-subdomain"
v-model="subdomainInput"
:icon="ServerIcon"
:error="submitted && !!validationError"
placeholder="example or example.modrinth.gg"
autocomplete="off"
autocapitalize="none"
:spellcheck="false"
/>
<span v-if="submitted && validationError" class="text-sm font-medium text-red">
{{ validationError }}
</span>
</div>
<div>
<Button type="colored" color="brand" native-type="submit" :loading="isLoading">
<SearchIcon aria-hidden="true" />
Look up server
</Button>
</div>
<div>
<Button type="colored" color="brand" native-type="submit" :loading="isLoading">
<SearchIcon aria-hidden="true" />
Look up server
</Button>
</div>
<Admonition v-if="lookupError" type="critical" header="Lookup failed">
{{ lookupError }}
</Admonition>
</form>
</div>
<Admonition v-if="lookupError" type="critical" header="Lookup failed">
{{ lookupError }}
</Admonition>
</form>
</div>
</template>
@@ -116,11 +116,9 @@
</div>
</NewModal>
<AssignNoticeModal ref="assignNoticeModal" @close="refreshNotices" />
<div class="page">
<div
class="mb-6 flex items-end justify-between border-0 border-b border-solid border-divider pb-4"
>
<h1 class="m-0 text-2xl">Server notices</h1>
<div>
<div class="mb-4 flex items-center justify-between gap-4">
<h2 class="m-0 text-2xl font-semibold">Server notices</h2>
<Button type="colored" color="brand" @click="openNewNoticeModal">
<PlusIcon />
{{ formatMessage(messages.createNotice) }}
@@ -498,11 +496,3 @@ const messages = defineMessages({
},
})
</script>
<style lang="scss" scoped>
.page {
padding: 1rem;
margin-left: auto;
margin-right: auto;
max-width: 78.5rem;
}
</style>
@@ -8,11 +8,9 @@
proceed-label="Cancel transfer"
@proceed="confirmCancel"
/>
<div class="mx-auto max-w-[78.5rem] p-4">
<div
class="mb-6 flex items-end justify-between border-0 border-b border-solid border-divider pb-4"
>
<h1 class="m-0 text-2xl">Server transfers</h1>
<div>
<div class="mb-4 flex items-center justify-between gap-4">
<h2 class="m-0 text-2xl font-semibold">Server transfers</h2>
<Button type="colored" color="brand" @click="openTransferModal">
<PlusIcon />
New transfer
+24 -26
View File
@@ -1,30 +1,28 @@
<template>
<div class="normal-page no-sidebar">
<h1>User account request</h1>
<div class="normal-page__content">
<div class="card flex flex-col gap-3">
<div class="flex flex-col gap-2">
<label for="name">
<span class="text-lg font-semibold text-contrast">
User email
<span class="text-brand-red">*</span>
</span>
</label>
<Input
id="name"
v-model="userEmail"
type="email"
:maxlength="64"
:placeholder="`Enter user email...`"
autocomplete="off"
/>
</div>
<div class="flex gap-2">
<Button type="colored" color="brand" @click="getUserFromEmail">
<MailIcon aria-hidden="true" />
Get user account
</Button>
</div>
<div>
<h2 class="m-0 mb-4 text-2xl font-semibold">User lookup</h2>
<div class="card flex flex-col gap-3">
<div class="flex flex-col gap-2">
<label for="name">
<span class="text-lg font-semibold text-contrast">
User email
<span class="text-brand-red">*</span>
</span>
</label>
<Input
id="name"
v-model="userEmail"
type="email"
:maxlength="64"
:placeholder="`Enter user email...`"
autocomplete="off"
/>
</div>
<div class="flex gap-2">
<Button type="colored" color="brand" @click="getUserFromEmail">
<MailIcon aria-hidden="true" />
Get user account
</Button>
</div>
</div>
</div>
@@ -190,8 +190,8 @@ async function finishSignIn(sessionToken?: string | null) {
await navigateTo(redirect, {
replace: true,
})
} else {
await navigateTo('/dashboard')
} else if (auth.value.user) {
await navigateTo(`/user/${auth.value.user.username}`)
}
}
</script>
@@ -0,0 +1,409 @@
<template>
<div
class="universal-card mx-auto flex w-full max-w-[27rem] flex-col gap-6 border border-solid border-surface-5 !p-6"
>
<template v-if="flow">
<div class="flex flex-col items-end gap-4">
<div class="flex flex-col gap-1.5">
<label for="two-factor-code">
<span class="label__title">{{ formatMessage(messages.twoFactorCodeLabel) }}</span>
<span class="label__description">
{{ formatMessage(messages.twoFactorCodeDescription) }}
</span>
</label>
<Input
id="two-factor-code"
v-model="twoFactorCode"
:maxlength="11"
inputmode="numeric"
:placeholder="formatMessage(messages.twoFactorCodePlaceholder)"
autocomplete="one-time-code"
@keyup.enter="begin2FASignIn"
/>
</div>
<Button type="colored" color="brand" @click="begin2FASignIn">
{{ formatMessage(commonMessages.signInButton) }}
<RightArrowIcon />
</Button>
</div>
</template>
<template v-else-if="account">
<div class="flex flex-col items-center gap-3 text-center">
<Avatar :src="account.avatarUrl" size="64px" circle />
<div class="flex flex-col gap-2">
<h1 class="m-0 text-2xl font-semibold text-contrast">
{{ formatMessage(messages.title, { name: account.username }) }}
</h1>
<p v-if="account.authMethod" class="m-0 text-primary">
{{ formatMessage(messages.lastSignedInWith, { method: account.authMethod }) }}
</p>
</div>
</div>
<template v-if="oauthProvider">
<div class="flex flex-col gap-2.5">
<ButtonLink
type="colored"
color="brand"
class="!w-full !justify-center"
:href="getAuthUrl(oauthProvider.id, redirectTarget)"
:aria-label="
formatMessage(messages.signInWithProvider, { provider: oauthProvider.name })
"
@click="pendingSignInOAuthProvider = oauthProvider.id"
>
<component :is="oauthProvider.icon" />
{{ formatMessage(messages.signInWithProvider, { provider: oauthProvider.name }) }}
</ButtonLink>
<ButtonLink class="!w-full !justify-center" :to="signInAnotherWayRoute">
{{ formatMessage(messages.signInAnotherWay) }}
</ButtonLink>
</div>
</template>
<template v-else-if="account.authMethod === 'passkey'">
<div class="flex flex-col gap-2.5">
<Button
type="colored"
color="brand"
class="!w-full !justify-center"
@click="beginPasskeySignIn"
>
<UserKeyIcon />
{{ formatMessage(messages.continueWithPasskey) }}
</Button>
<ButtonLink class="!w-full !justify-center" :to="signInAnotherWayRoute">
{{ formatMessage(messages.signInAnotherWay) }}
</ButtonLink>
</div>
</template>
<template v-else-if="account.authMethod === 'password'">
<section class="flex w-full flex-col gap-2.5">
<label for="reauth-username" hidden>
{{ formatMessage(commonMessages.emailUsernameLabel) }}
</label>
<Input
id="reauth-username"
:model-value="loginIdentifier"
:icon="MailIcon"
type="text"
autocomplete="username"
disabled
wrapper-class="w-full"
/>
<label for="reauth-password" hidden>
{{ formatMessage(commonMessages.passwordLabel) }}
</label>
<Input
id="reauth-password"
v-model="password"
:icon="KeyIcon"
type="password"
autocomplete="current-password"
:placeholder="formatMessage(commonMessages.passwordLabel)"
wrapper-class="w-full"
@keyup.enter="beginPasswordSignIn"
/>
<HCaptcha
v-if="globals?.captcha_enabled && password"
:ref="setCaptchaRef"
v-model="captchaToken"
/>
<Button
type="colored"
color="brand"
class="!w-full"
:disabled="globals?.captcha_enabled ? !captchaToken : false"
@click="beginPasswordSignIn"
>
{{ formatMessage(commonMessages.signInButton) }}
<RightArrowIcon />
</Button>
<ButtonLink class="!w-full !justify-center" :to="signInAnotherWayRoute">
{{ formatMessage(messages.signInAnotherWay) }}
</ButtonLink>
</section>
</template>
<ButtonLink v-else class="!w-full !justify-center" :to="signInAnotherWayRoute">
{{ formatMessage(messages.signInAnotherWay) }}
</ButtonLink>
</template>
</div>
</template>
<script setup lang="ts">
import {
DiscordColorIcon,
GitHubColorIcon,
GitLabColorIcon,
GoogleColorIcon,
KeyIcon,
MailIcon,
MicrosoftColorIcon,
RightArrowIcon,
SteamColorIcon,
UserKeyIcon,
} from '@modrinth/assets'
import {
Avatar,
Button,
ButtonLink,
commonMessages,
defineMessages,
injectModrinthClient,
injectNotificationManager,
Input,
useVIntl,
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { useStorage } from '@vueuse/core'
import type { LocationQueryValue } from 'vue-router'
import HCaptcha from '@/components/ui/auth/HCaptcha.vue'
import {
hydrateStoredAccounts,
PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
rememberStoredAccount,
type StoredAccountAuthMethod,
useStoredAccounts,
} from '@/composables/accounts.ts'
import {
ADD_ACCOUNT_QUERY_PARAM,
getAuthUrl,
promotePendingSignInOAuthProvider,
} from '@/composables/auth.ts'
import { getPasskeyCredential } from '@/helpers/passkey.ts'
const oauthProviders = [
{ id: 'discord', name: 'Discord', icon: DiscordColorIcon },
{ id: 'github', name: 'GitHub', icon: GitHubColorIcon },
{ id: 'microsoft', name: 'Microsoft', icon: MicrosoftColorIcon },
{ id: 'google', name: 'Google', icon: GoogleColorIcon },
{ id: 'steam', name: 'Steam', icon: SteamColorIcon },
{ id: 'gitlab', name: 'GitLab', icon: GitLabColorIcon },
] as const
type OAuthProviderId = (typeof oauthProviders)[number]['id']
interface AuthGlobalsResponse {
captcha_enabled?: boolean
[key: string]: unknown
}
interface ApiErrorShape {
data?: {
description?: string
}
}
const getQueryString = (
value: LocationQueryValue | LocationQueryValue[] | null | undefined,
): string => {
const firstValue = Array.isArray(value) ? value[0] : value
return typeof firstValue === 'string' ? firstValue : ''
}
const getErrorMessage = (error: unknown): string => {
const apiError = error as ApiErrorShape
if (typeof apiError?.data?.description === 'string') {
return apiError.data.description
}
if (error instanceof Error) {
return error.message
}
return String(error)
}
const client = injectModrinthClient()
const queryClient = useQueryClient()
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const route = useNativeRoute()
const storedAccounts = useStoredAccounts()
const messages = defineMessages({
title: {
id: 'auth.reauthenticate.title',
defaultMessage: '{name} needs to be reauthenticated.',
},
lastSignedInWith: {
id: 'auth.reauthenticate.last-signed-in-with',
defaultMessage:
'It was last signed into with {method, select, password {a password} passkey {a passkey} github {GitHub} discord {Discord} google {Google} microsoft {Microsoft} gitlab {GitLab} steam {Steam} paypal {PayPal} other {{method}} }.',
},
signInWithProvider: {
id: 'auth.reauthenticate.sign-in-with-provider',
defaultMessage: 'Sign in with {provider}',
},
signInAnotherWay: {
id: 'auth.reauthenticate.sign-in-another-way',
defaultMessage: 'Sign in another way',
},
continueWithPasskey: {
id: 'auth.reauthenticate.continue-with-passkey',
defaultMessage: 'Continue with passkey',
},
twoFactorCodePlaceholder: {
id: 'auth.reauthenticate.2fa.placeholder',
defaultMessage: 'Enter code...',
},
twoFactorCodeLabel: {
id: 'auth.reauthenticate.2fa.label',
defaultMessage: 'Two-factor authentication',
},
twoFactorCodeDescription: {
id: 'auth.reauthenticate.2fa.description',
defaultMessage:
'Enter the 6-digit code from your authenticator app, or one of your backup codes.',
},
pageTitle: {
id: 'auth.reauthenticate.page-title',
defaultMessage: 'Reauthenticate',
},
})
useHead({
title() {
return `${formatMessage(messages.pageTitle)} - Modrinth`
},
})
const accountId = computed(() => getQueryString(route.query.account))
const account = computed(() => storedAccounts.value.find((stored) => stored.id === accountId.value))
const redirectTarget = computed(
() =>
getQueryString(route.query.redirect) ||
(account.value ? `/user/${account.value.username}` : ''),
)
const loginIdentifier = computed(() => account.value?.username || '')
const oauthProvider = computed(() =>
oauthProviders.find((provider) => provider.id === account.value?.authMethod),
)
const signInAnotherWayRoute = computed(() => ({
path: '/auth/sign-in',
query: {
redirect: redirectTarget.value,
[ADD_ACCOUNT_QUERY_PARAM]: 'true',
},
}))
const pendingSignInOAuthProvider = useStorage<OAuthProviderId | StoredAccountAuthMethod | null>(
PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
null,
undefined,
{ initOnMounted: true },
)
const password = ref('')
const captchaToken = ref('')
const twoFactorCode = ref('')
const flow = ref('')
const captcha = ref<{ reset?: () => void } | null>(null)
const setCaptchaRef = (captchaRef: unknown) => {
captcha.value = (captchaRef as { reset?: () => void } | null) ?? null
}
const { data: globals } = useQuery<AuthGlobalsResponse>({
queryKey: ['auth-globals'],
queryFn: async () => {
try {
return await client.labrinth.globals_internal.get()
} catch (err) {
console.error('Error fetching globals:', err)
return { captcha_enabled: true, tax_compliance_thresholds: {} }
}
},
})
onMounted(() => {
hydrateStoredAccounts()
if (!accountId.value || !account.value) {
void navigateTo({
path: '/auth/sign-in',
query: { redirect: redirectTarget.value },
})
}
})
async function completeSignIn(sessionToken: string, authMethod: StoredAccountAuthMethod) {
await useAuth(sessionToken)
await useUser()
queryClient.clear()
const signedIn = await useAuth()
if (signedIn.value.user && signedIn.value.token) {
rememberStoredAccount(signedIn.value.user, signedIn.value.token, { authMethod })
}
promotePendingSignInOAuthProvider()
await navigateTo(redirectTarget.value, { replace: true })
}
function notifyError(error: unknown) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: getErrorMessage(error),
type: 'error',
})
captcha.value?.reset?.()
}
async function beginPasswordSignIn() {
pendingSignInOAuthProvider.value = null
startLoading()
try {
const res = await client.labrinth.auth_v2.login({
username: loginIdentifier.value,
password: password.value,
challenge: captchaToken.value,
})
if (res.flow) {
flow.value = res.flow
} else if (res.session) {
await completeSignIn(res.session, 'password')
}
} catch (err) {
notifyError(err)
}
stopLoading()
}
async function begin2FASignIn() {
startLoading()
try {
const res = await client.labrinth.auth_v2.login2FA({
flow: flow.value,
code: twoFactorCode.value,
})
await completeSignIn(res.session, 'password')
} catch (err) {
notifyError(err)
}
stopLoading()
}
async function beginPasskeySignIn() {
startLoading()
try {
const start = await client.labrinth.auth_v2.authenticatePasskeyStart()
const credential = await getPasskeyCredential(start.options.publicKey)
const result = await client.labrinth.auth_v2.authenticatePasskeyFinish({
flow: start.flow,
credential,
})
pendingSignInOAuthProvider.value = 'passkey'
await completeSignIn(result.session, 'passkey')
} catch (err) {
notifyError(err)
}
stopLoading()
}
</script>
@@ -218,7 +218,7 @@ useHead({
const auth = await useAuth()
if (auth.value.user) {
await navigateTo('/dashboard')
await navigateTo(`/user/${auth.value.user.username}`)
}
const route = useNativeRoute()
+133 -30
View File
@@ -1,5 +1,6 @@
<template>
<SignInView
v-if="signInReady || subtleLauncherRedirectUri"
v-model:email="email"
v-model:password="password"
v-model:token="token"
@@ -9,10 +10,12 @@
:redirect-target="redirectTarget"
:route-query="route.query"
:globals="globals"
:accounts="launcherAccountChoices"
:on-password-sign-in="beginPasswordSignIn"
:on-two-factor-sign-in="begin2FASignIn"
:on-passkey-sign-in="beginPasskeySignin"
:on-set-captcha-ref="setCaptchaRef"
@select="onSelectLauncherAccount"
/>
</template>
@@ -30,9 +33,18 @@ import type { LocationQueryValue } from 'vue-router'
import SignInView from '@/components/ui/auth/SignIn.vue'
import {
getLauncherRedirectUrl,
hydrateStoredAccounts,
isStoredAccountAuthMethod,
LAST_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
rememberStoredAccount,
type StoredAccount,
type StoredAccountAuthMethod,
useStoredAccounts,
} from '@/composables/accounts.ts'
import {
ADD_ACCOUNT_QUERY_PARAM,
getLauncherRedirectUrl,
promotePendingSignInOAuthProvider,
} from '@/composables/auth.ts'
import { getPasskeyCredential } from '@/helpers/passkey.ts'
@@ -120,10 +132,106 @@ if (route.query.code) {
await finishSignIn()
}
if (auth.value.user) {
const isAddingAccount = route.query[ADD_ACCOUNT_QUERY_PARAM] !== undefined
const isLauncherSignIn = route.query.launcher !== undefined
const storedAccounts = useStoredAccounts()
const signInReady = ref(!isLauncherSignIn)
const choosableAccounts = computed((): StoredAccount[] => {
const user = auth.value.user
const token = auth.value.token
const accounts = storedAccounts.value.map((stored) => {
if (user && token && stored.id === user.id) {
return {
...stored,
username: user.username,
avatarUrl: user.avatar_url ?? stored.avatarUrl,
token,
role: user.role,
}
}
return stored
})
if (user && token && !accounts.some((account) => account.id === user.id)) {
accounts.push({
id: user.id,
username: user.username,
avatarUrl: user.avatar_url ?? null,
token,
role: user.role,
})
}
return accounts
})
const launcherAccountChoices = computed(() => {
if (!isLauncherSignIn) return []
const minimumAccounts = isAddingAccount ? 1 : 2
return choosableAccounts.value.length >= minimumAccounts ? choosableAccounts.value : []
})
if (auth.value.user && !isAddingAccount && !isLauncherSignIn) {
await finishSignIn()
}
onMounted(async () => {
if (!isLauncherSignIn) {
return
}
hydrateStoredAccounts()
if (subtleLauncherRedirectUri.value) {
signInReady.value = true
return
}
if (
auth.value.user &&
!isAddingAccount &&
choosableAccounts.value.length === 1 &&
route.query.code === undefined
) {
await showLauncherOpeningPage(auth.value.token)
if (subtleLauncherRedirectUri.value) {
signInReady.value = true
}
return
}
signInReady.value = true
})
function getLauncherCallbackUrl(sessionToken: string) {
return `${getLauncherRedirectUrl(route)}/?code=${sessionToken}`
}
async function showLauncherOpeningPage(sessionToken: string) {
promotePendingSignInOAuthProvider()
const redirectUrl = getLauncherCallbackUrl(sessionToken)
if (redirectUrl.startsWith('https://launcher-files.modrinth.com/')) {
await navigateTo(redirectUrl, {
external: true,
})
return
}
subtleLauncherRedirectUri.value = redirectUrl
}
function onSelectLauncherAccount(account: { id: string }) {
const stored = choosableAccounts.value.find((choice) => choice.id === account.id)
if (stored) {
void showLauncherOpeningPage(stored.token)
}
}
const captcha = ref<{ reset?: () => void } | null>(null)
const setCaptchaRef = (captchaRef: unknown) => {
captcha.value = (captchaRef as { reset?: () => void } | null) ?? null
@@ -161,7 +269,7 @@ async function beginPasswordSignIn() {
if (res.flow) {
flow.value = res.flow
} else {
await finishSignIn(res.session)
await finishSignIn(res.session, 'password')
}
} catch (err) {
addNotification({
@@ -183,7 +291,7 @@ async function begin2FASignIn() {
code: twoFactorCode.value,
})
await finishSignIn(res.session)
await finishSignIn(res.session, 'password')
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
@@ -208,7 +316,7 @@ async function beginPasskeySignin() {
})
pendingSignInOAuthProvider.value = 'passkey'
await finishSignIn(result.session)
await finishSignIn(result.session, 'passkey')
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
@@ -219,30 +327,11 @@ async function beginPasskeySignin() {
stopLoading()
}
async function finishSignIn(sessionToken?: string | null) {
async function finishSignIn(sessionToken?: string | null, authMethod?: StoredAccountAuthMethod) {
if (route.query.launcher) {
let token = sessionToken
if (!token) {
token = auth.value.token
}
promotePendingSignInOAuthProvider()
const redirectUrl = `${getLauncherRedirectUrl(route)}/?code=${token}`
if (redirectUrl.startsWith('https://launcher-files.modrinth.com/')) {
await navigateTo(redirectUrl, {
external: true,
})
} else {
// When redirecting to localhost, the auth token is very visible in the URL to the user.
// While we could make it harder to find with a POST request, such is security by obscurity:
// the user and other applications would still be able to sniff the token in the request body.
// So, to make the UX a little better by not changing the displayed URL, while keeping the
// token hidden from very casual observation and keeping the protocol as close to OAuth's
// standard flows as possible, let's execute the redirect within an iframe that visually
// covers the entire page.
subtleLauncherRedirectUri.value = redirectUrl
const token = sessionToken ?? auth.value.token
if (token) {
await showLauncherOpeningPage(token)
}
return
@@ -254,6 +343,20 @@ async function finishSignIn(sessionToken?: string | null) {
queryClient.clear()
}
const signedIn = await useAuth()
if (signedIn.value.user && signedIn.value.token) {
const nextAuthMethod =
authMethod ??
(isStoredAccountAuthMethod(pendingSignInOAuthProvider.value)
? pendingSignInOAuthProvider.value
: undefined)
rememberStoredAccount(
signedIn.value.user,
signedIn.value.token,
nextAuthMethod ? { authMethod: nextAuthMethod } : undefined,
)
}
promotePendingSignInOAuthProvider()
if (route.query.redirect) {
@@ -261,8 +364,8 @@ async function finishSignIn(sessionToken?: string | null) {
await navigateTo(redirect, {
replace: true,
})
} else {
await navigateTo('/dashboard')
} else if (signedIn.value.user) {
await navigateTo(`/user/${signedIn.value.user.username}`)
}
}
</script>
+4 -4
View File
@@ -38,8 +38,8 @@ import SignUpView from '@/components/ui/auth/SignUp.vue'
import {
LAST_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
promotePendingSignInOAuthProvider,
} from '@/composables/auth.ts'
} from '@/composables/accounts.ts'
import { promotePendingSignInOAuthProvider } from '@/composables/auth.ts'
interface AuthGlobalsResponse {
captcha_enabled?: boolean
@@ -120,7 +120,7 @@ const showOtherOptions = ref(false)
const isCreateAccountStep = ref(false)
if (auth.value.user) {
await navigateTo('/dashboard')
await navigateTo(`/user/${auth.value.user.username}`)
}
const captcha = ref<{ reset?: () => void } | null>(null)
@@ -275,7 +275,7 @@ async function createAccount(accountConsent: boolean) {
if (route.query.redirect) {
await navigateTo(getQueryString(route.query.redirect))
} else {
await navigateTo('/dashboard')
await navigateTo(`/user/${username.value}`)
}
} catch (err) {
addNotification({
+2 -2
View File
@@ -1,9 +1,9 @@
<template>
<div
class="relativemb-6 flex min-h-screen w-full max-w-[1280px] flex-col px-6"
class="relative mb-6 mt-4 flex min-h-screen w-full max-w-[1280px] flex-col px-6"
:class="`m${marginTarget}-auto`"
>
<h1>Moderation</h1>
<h1 class="mb-4 mt-0 text-3xl font-semibold">Moderation</h1>
<NavTabs :links="moderationLinks" class="mb-4 hidden sm:flex" />
<div class="mb-4 sm:hidden">
<Chips
@@ -126,7 +126,7 @@
<ProjectList
v-if="projects && projects.length > 0"
:projects="displayedProjects"
:show-status="showProjectStatus"
:show-status="canSeeProjectStatus"
/>
<div v-else-if="true" class="error">
<UpToDate class="icon" />
@@ -215,7 +215,6 @@ const messages = defineMessages({
const { formatCompactNumber } = useCompactNumber()
const auth: { user: any } & any = await useAuth()
const user = await useUser()
const cosmetics = useCosmetics()
const route = useNativeRoute()
const router = useRouter()
@@ -321,10 +320,6 @@ const displayedProjects = computed(() =>
.sort(projectUserSorting),
)
const showProjectStatus = computed(
() => !!(auth.user && (auth.user.id === user.id || tags.staffRoles.includes(auth.user.role))),
)
const sumDownloads = computed(() => {
let sum = 0
@@ -358,6 +353,22 @@ provideOrganizationContext(organizationContext)
const canAccessSettings = computed(() => !!currentMember.value?.accepted)
const authUserId = computed(() => auth.value?.user?.id as string | undefined)
const viewerProjectsQuery = useQuery({
queryKey: computed(() => ['user', authUserId.value, 'projects']),
queryFn: () => client.labrinth.users_v3.getProjects(authUserId.value!),
enabled: computed(() => !!authUserId.value && !!organization.value && !currentMember.value),
staleTime: 30_000,
})
const viewerMemberProjectIds = computed(
() => new Set((viewerProjectsQuery.data.value ?? []).map((project) => project.id)),
)
function canSeeProjectStatus(project: ProjectV3) {
if (currentMember.value) return true
return viewerMemberProjectIds.value.has(project.id)
}
watch(
[routeHasSettings, acceptedMembers, currentMember],
() => {
+4 -1
View File
@@ -493,7 +493,9 @@ import SteamIcon from 'assets/icons/auth/sso-steam.svg'
import QrcodeVue from 'qrcode.vue'
import PasskeySettings from '~/components/ui/auth/PasskeySettings.vue'
import { forgetStoredAccount } from '~/composables/accounts.ts'
import { getAuthUrl, removeAuthProvider } from '~/composables/auth.ts'
import { useAuthCookie } from '~/composables/auth-cookie.ts'
definePageMeta({
middleware: 'auth',
@@ -987,7 +989,8 @@ async function deleteAccount() {
})
}
useCookie('auth-token').value = null
forgetStoredAccount(auth.value.user.id)
useAuthCookie().value = null
window.location.href = '/'
stopLoading()
@@ -287,6 +287,9 @@ provideAppearanceSettings({
current: computed(() => current.value.theme),
options: themeOptions,
system: systemTheme,
preferredDark: computed(() =>
isDarkTheme(current.value.theme) ? current.value.theme : theme.preferences.dark,
),
set: setTheme,
syncAcrossDevices: {
value: computed(() => current.value.syncAcrossDevices),
+7 -1
View File
@@ -68,7 +68,13 @@
<span
class="flex min-w-0 max-w-full items-center gap-1.5 whitespace-nowrap rounded-xl font-semibold text-contrast"
>
<Avatar :src="instanceIcon" :alt="invite.instance_name" size="32px" no-shadow />
<Avatar
:src="instanceIcon"
:alt="invite.instance_name"
size="32px"
no-shadow
pad-transparent-corners
/>
<span class="min-w-0 truncate whitespace-nowrap">{{ invite.instance_name }}</span>
</span>
</div>
@@ -19,6 +19,7 @@ export default defineNuxtPlugin(() => {
keybinds.value.handle(event, {
scope: 'global',
officialUrl: `${getOfficialOrigin(String(config.public.apiBaseUrl))}${window.location.pathname}${window.location.search}`,
localhostUrl: `http://localhost:3000${window.location.pathname}${window.location.search}`,
notifyCopied,
})
})
+35 -2
View File
@@ -1,4 +1,6 @@
import { ref } from 'vue'
import type { Labrinth } from '@modrinth/api-client'
import { prepareThemeColorTransition } from '@modrinth/ui'
import { ref, watch, watchEffect } from 'vue'
import { useNativeTheme } from './native-theme.ts'
import { usePreferredThemes } from './preferred-theme.ts'
@@ -46,6 +48,21 @@ export default defineNuxtPlugin({
$settings.preferred === 'system' ? getPreferredNativeTheme() : $settings.preferred
}
function applyAccountAppearance(appearance: Labrinth.Users.v3.AppearancePreferences) {
if (isDarkTheme(appearance.theme)) {
$preferredThemes.dark = appearance.theme
} else {
$preferredThemes.light = appearance.theme
}
$settings.preferred = appearance.auto ? 'system' : appearance.theme
const systemThemeIsUnknown =
import.meta.server && $settings.preferred === 'system' && $nativeTheme.value === 'unknown'
if (!systemThemeIsUnknown) syncTheme()
}
if (
import.meta.server &&
$settings.preferred === 'system' &&
@@ -57,12 +74,27 @@ export default defineNuxtPlugin({
if (import.meta.client) {
const $clientReady = ref(false)
let themeColorTransitionsEnabled = false
nuxtApp.hook('app:suspense:resolve', () => {
$clientReady.value = true
})
watchEffect(() => $clientReady.value && syncTheme())
watchEffect(() => {
if (!$clientReady.value) return
syncTheme()
themeColorTransitionsEnabled = true
})
watch(
$active,
(theme, previousTheme) => {
if (themeColorTransitionsEnabled && previousTheme && theme !== previousTheme) {
prepareThemeColorTransition()
}
},
{ flush: 'sync' },
)
}
function cycle() {
@@ -91,6 +123,7 @@ export default defineNuxtPlugin({
*/
native: $nativeTheme,
cycle,
applyAccountAppearance,
}),
},
}