mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 12:05:53 +00:00
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:
@@ -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">
|
||||
|
||||
@@ -10,10 +10,20 @@
|
||||
If the app doesn’t 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,
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "We’re 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": "管理聯盟連結"
|
||||
},
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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) ?? '')),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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],
|
||||
() => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user