-
-
+
+
+
@@ -50,7 +52,7 @@
@@ -78,7 +80,7 @@
provider: focusedOauthProvider.name,
})
"
- @click="onOAuthProviderClick(focusedOauthProvider.id)"
+ @click="onOAuthProviderClick(focusedOauthProvider.id, $event)"
>
{{
@@ -92,7 +94,7 @@
type="colored"
color="brand"
class="!w-full !justify-center"
- @click="onPasskeySignIn"
+ @click="beginPasskeySignin"
>
{{ formatMessage(messages.continueWithPasskey) }}
@@ -106,23 +108,23 @@
class="sr-only"
type="text"
autocomplete="username"
- :value="emailModel"
+ :value="email"
tabindex="-1"
/>
-
@@ -146,7 +148,7 @@
:aria-label="
formatMessage(messages.continueWithProvider, { provider: provider.name })
"
- @click="onOAuthProviderClick(provider.id)"
+ @click="onOAuthProviderClick(provider.id, $event)"
>
{{ provider.name }}
@@ -164,8 +166,8 @@
}"
role="button"
tabindex="0"
- @click="onPasskeySignIn"
- @keydown.enter="onPasskeySignIn"
+ @click="beginPasskeySignin"
+ @keydown.enter="beginPasskeySignin"
>
{{ formatMessage(messages.continueWithPasskey) }}
@@ -184,7 +186,7 @@
@@ -215,7 +217,11 @@
{{ formatMessage(messages.forgotPasswordLabel) }}
+
+ {{ formatMessage(messages.createAccountLabel) }}
+
+import { MailIcon, UserKeyIcon } from '@modrinth/assets'
import {
- DiscordColorIcon,
- GitHubColorIcon,
- GitLabColorIcon,
- GoogleColorIcon,
- MailIcon,
- MicrosoftColorIcon,
- SteamColorIcon,
- UserKeyIcon,
-} from '@modrinth/assets'
-import {
- type AccountChoice,
AccountChoiceList,
Admonition,
Avatar,
@@ -263,146 +259,112 @@ import {
Input,
useVIntl,
} from '@modrinth/ui'
-import { useStorage } from '@vueuse/core'
import { computed, nextTick, ref, watch } from 'vue'
-import type { LocationQuery } from 'vue-router'
+import AuthCard from '@/components/ui/auth/AuthCard.vue'
import LauncherOpening from '@/components/ui/auth/LauncherOpening.vue'
+import OpeningAuthProvider from '@/components/ui/auth/OpeningAuthProvider.vue'
import PasswordSignInForm from '@/components/ui/auth/PasswordSignInForm.vue'
import TwoFactorAuthCodeInput from '@/components/ui/auth/TwoFactorAuthCodeInput.vue'
-import {
- LAST_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
- PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
- type StoredAccountAuthMethod,
-} from '@/composables/accounts.ts'
+import { useLastSignInAuthMethod } from '@/composables/accounts.ts'
import { getAuthUrl } from '@/composables/auth.ts'
+import { isAuthEmbedded, useAuthNavigation } from '@/composables/auth-modal.ts'
+import { useProviderOpening } from '@/composables/external-auth.ts'
import {
isLauncherProtocolV2,
LAUNCHER_REAUTH_ACCOUNT_STORAGE_KEY,
} from '@/composables/launcher-auth.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 AuthProvider = (typeof oauthProviders)[number]['id'] | 'passkey'
-
-interface AuthGlobals {
- captcha_enabled?: boolean
- [key: string]: unknown
-}
-
-interface LauncherReauthAccount {
- id: string
- username: string
- avatarUrl?: string | null
- authMethod?: StoredAccountAuthMethod | null
-}
-
-interface Props {
- subtleLauncherRedirectUri?: string
- launcherDeeplink?: string
- reauthAccount?: LauncherReauthAccount | null
- flow?: string
- redirectTarget?: string
- routeQuery?: LocationQuery
- globals?: AuthGlobals | null
- onPasswordSignIn?: () => void
- onTwoFactorSignIn?: (code: string) => void
- twoFactorPending?: boolean
- twoFactorError?: boolean
- onPasskeySignIn?: () => void
- onCancelReauthenticate?: () => void
- onSetCaptchaRef?: ((captchaRef: unknown) => void) | undefined
- accounts?: AccountChoice[]
-}
+import { type OAuthProviderId, oauthProviders } from '@/composables/oauth-providers.ts'
+import { useSignIn } from '@/composables/sign-in.ts'
const {
- subtleLauncherRedirectUri = '',
- launcherDeeplink = '',
- reauthAccount = null,
- flow = '',
- redirectTarget = '',
- routeQuery = {},
- globals = null,
- onPasswordSignIn = () => {},
- onTwoFactorSignIn = () => {},
- twoFactorPending = false,
- twoFactorError = false,
- onPasskeySignIn = () => {},
- onCancelReauthenticate = () => {},
- onSetCaptchaRef = undefined,
- accounts = [],
-} = defineProps()
+ launcherHandoff,
+ flow,
+ twoFactorCode,
+ twoFactorPending,
+ twoFactorError,
+ accounts,
+ reauthAccount,
+ redirectTarget,
+ routeQuery,
+ globals,
+ email,
+ password,
+ token,
+ beginPasswordSignIn,
+ begin2FASignIn,
+ beginPasskeySignin,
+ setCaptchaRef,
+ cancelReauth,
+ onSelectLauncherAccount,
+} = useSignIn()
const addingAccount = ref(false)
-const emit = defineEmits<{
- select: [account: AccountChoice]
-}>()
-
-const emailModel = defineModel('email', { default: '' })
-const passwordModel = defineModel('password', { default: '' })
-const tokenModel = defineModel('token', { default: '' })
-
function cancelAddAccount() {
addingAccount.value = false
- emailModel.value = ''
- passwordModel.value = ''
- tokenModel.value = ''
+ email.value = ''
+ password.value = ''
+ token.value = ''
}
-const twoFactorCodeModel = defineModel('twoFactorCode', { default: '' })
+
const twoFactorInput = ref>()
-watch(
- () => twoFactorPending,
- async (pending) => {
- if (!pending && twoFactorError) {
- await nextTick()
- twoFactorInput.value?.focus()
- }
- },
-)
+watch(twoFactorPending, async (pending) => {
+ if (!pending && twoFactorError.value) {
+ await nextTick()
+ twoFactorInput.value?.focus()
+ }
+})
-const lastSignInOAuthProvider = useStorage(
- LAST_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
- null,
- undefined,
- { initOnMounted: true },
-)
-const pendingSignInOAuthProvider = useStorage(
- PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
- null,
- undefined,
- { initOnMounted: true },
-)
+const lastSignInOAuthProvider = useLastSignInAuthMethod()
const focusedOauthProvider = computed(() =>
- oauthProviders.find((provider) => provider.id === reauthAccount?.authMethod),
+ oauthProviders.find((provider) => provider.id === reauthAccount.value?.authMethod),
)
const focusedReauthAccount = computed(() => {
- if (!reauthAccount?.authMethod) return null
+ const account = reauthAccount.value
+ if (!account?.authMethod) {
+ return null
+ }
if (
- reauthAccount.authMethod === 'password' ||
- reauthAccount.authMethod === 'passkey' ||
+ account.authMethod === 'password' ||
+ account.authMethod === 'passkey' ||
focusedOauthProvider.value
) {
- return reauthAccount
+ return account
}
return null
})
const requestsAppSession = computed(
- () => isLauncherProtocolV2({ query: routeQuery }) && !reauthAccount,
+ () => isLauncherProtocolV2({ query: routeQuery.value }) && !reauthAccount.value,
)
+const { openingProviderName, beginProviderHandoff, cancelProviderOpening } = useProviderOpening()
+const { go } = useAuthNavigation()
+const embedded = isAuthEmbedded()
+
+function openSignUp() {
+ void go({
+ path: '/auth/sign-up',
+ query: routeQuery.value,
+ })
+}
-const onOAuthProviderClick = (provider: AuthProvider) => {
- pendingSignInOAuthProvider.value = provider
- if (!reauthAccount || !import.meta.client) return
- window.sessionStorage.setItem(LAUNCHER_REAUTH_ACCOUNT_STORAGE_KEY, reauthAccount.id)
+const onOAuthProviderClick = (provider: OAuthProviderId, event: MouseEvent) => {
+ beginProviderHandoff(
+ provider,
+ event,
+ getAuthUrl(provider, redirectTarget, requestsAppSession.value),
+ {
+ skip: Boolean(routeQuery.value.launcher || requestsAppSession.value),
+ prepare() {
+ const account = reauthAccount.value
+ if (!account || !import.meta.client) {
+ return
+ }
+ window.sessionStorage.setItem(LAUNCHER_REAUTH_ACCOUNT_STORAGE_KEY, account.id)
+ },
+ },
+ )
}
const { formatMessage } = useVIntl()
diff --git a/apps/frontend/src/components/ui/auth/SignUp.vue b/apps/frontend/src/components/ui/auth/SignUp.vue
index aac22590a95..83392f4b69d 100644
--- a/apps/frontend/src/components/ui/auth/SignUp.vue
+++ b/apps/frontend/src/components/ui/auth/SignUp.vue
@@ -1,131 +1,140 @@
-
-
- {{ formatMessage(messages.signUpWithTitle) }}
-
-
-
-
- {{ formatMessage(messages.continueWithProvider, { provider: 'Google' }) }}
-
-
-
- {{ formatMessage(messages.continueWithProvider, { provider: 'Microsoft' }) }}
-
-
-
- {{ formatMessage(messages.continueWithProvider, { provider: 'Discord' }) }}
-
-
+
+
+
+
+ {{ formatMessage(messages.signUpWithTitle) }}
+
+
-
- {{ formatMessage(messages.continueWithProvider, { provider: 'GitHub' }) }}
+
+ {{ formatMessage(messages.continueWithProvider, { provider: 'Google' }) }}
-
- {{ formatMessage(messages.continueWithProvider, { provider: 'GitLab' }) }}
+
+ {{ formatMessage(messages.continueWithProvider, { provider: 'Microsoft' }) }}
-
- {{ formatMessage(messages.continueWithProvider, { provider: 'Steam' }) }}
+
+ {{ formatMessage(messages.continueWithProvider, { provider: 'Discord' }) }}
-
-
- {{
- showOtherOptions
- ? formatMessage(messages.showFewerOptions)
- : formatMessage(messages.showOtherOptions)
- }}
-
-
-
-
-
-
-
+ {{
+ showOtherOptions
+ ? formatMessage(messages.showFewerOptions)
+ : formatMessage(messages.showOtherOptions)
+ }}
+
+
+
+
+
+
+
+
diff --git a/apps/frontend/src/components/ui/auth/screens/ResetPasswordScreen.vue b/apps/frontend/src/components/ui/auth/screens/ResetPasswordScreen.vue
new file mode 100644
index 00000000000..a08a283b547
--- /dev/null
+++ b/apps/frontend/src/components/ui/auth/screens/ResetPasswordScreen.vue
@@ -0,0 +1,254 @@
+
+
+
+ {{ formatMessage(messages.longTitle) }}
+
+
+
+
+
+
+ {{ formatMessage(methodChoiceMessages.description) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(methodChoiceMessages.action) }}
+
+
+
+
+
+
+
+ {{ formatMessage(postChallengeMessages.description) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(postChallengeMessages.action) }}
+
+
+
+
+
+
diff --git a/apps/frontend/src/components/ui/auth/screens/SignInScreen.vue b/apps/frontend/src/components/ui/auth/screens/SignInScreen.vue
new file mode 100644
index 00000000000..bc06498c94a
--- /dev/null
+++ b/apps/frontend/src/components/ui/auth/screens/SignInScreen.vue
@@ -0,0 +1,13 @@
+
+
+
+
+
diff --git a/apps/frontend/src/components/ui/auth/screens/SignUpScreen.vue b/apps/frontend/src/components/ui/auth/screens/SignUpScreen.vue
new file mode 100644
index 00000000000..7936226b835
--- /dev/null
+++ b/apps/frontend/src/components/ui/auth/screens/SignUpScreen.vue
@@ -0,0 +1,265 @@
+
+
+
+
+
+
+
diff --git a/apps/frontend/src/components/ui/auth/screens/VerifyEmailScreen.vue b/apps/frontend/src/components/ui/auth/screens/VerifyEmailScreen.vue
new file mode 100644
index 00000000000..8204843be69
--- /dev/null
+++ b/apps/frontend/src/components/ui/auth/screens/VerifyEmailScreen.vue
@@ -0,0 +1,219 @@
+
+
+
+
+ {{ formatMessage(alreadyVerifiedMessages.title) }}
+
+
+
+ {{ formatMessage(alreadyVerifiedMessages.description) }}
+
+
+
+
+ {{ formatMessage(messages.accountSettings) }}
+
+
+ {{ formatMessage(messages.discoverMods) }}
+
+
+
+
+
+
+
+ {{ formatMessage(postVerificationMessages.title) }}
+
+
+
+ {{ formatMessage(postVerificationMessages.description) }}
+
+
+
+
+
+ {{ formatMessage(messages.accountSettings) }}
+
+
+ {{ formatMessage(messages.discoverMods) }}
+
+
+
+
+
+ {{ formatMessage(commonMessages.signInButton) }}
+
+
+
+
+
+
+ {{ formatMessage(failedVerificationMessages.title) }}
+
+
+
+ {{ formatMessage(failedVerificationMessages.loggedInDescription) }}
+
+
+ {{ formatMessage(failedVerificationMessages.description) }}
+
+
+
+ {{ formatMessage(failedVerificationMessages.action) }}
+
+
+
+ {{ formatMessage(commonMessages.signInButton) }}
+
+
+
+
+
+
diff --git a/apps/frontend/src/composables/accounts.ts b/apps/frontend/src/composables/accounts.ts
index 340ca6fdb8a..4fbedcb32e6 100644
--- a/apps/frontend/src/composables/accounts.ts
+++ b/apps/frontend/src/composables/accounts.ts
@@ -1,8 +1,10 @@
import type { AbstractModrinthClient, Labrinth } from '@modrinth/api-client'
+import { useStorage } from '@vueuse/core'
import { nextTick } from 'vue'
import { useAuthState } from '@/composables/auth.ts'
import { useAuthCookie } from '@/composables/auth-cookie.ts'
+import { promptAuth } from '@/composables/auth-modal.ts'
import type { CookieOptions } from '#app'
import { useTheme } from '~/composables/nuxt-accessors.ts'
import { getThemeType } from '~/plugins/theme/themes.ts'
@@ -122,12 +124,12 @@ const isAuthFailure = (error: unknown): boolean => {
return status === 401 || status === 403
}
-const readPendingAuthMethod = (): StoredAccountAuthMethod | undefined => {
- if (!import.meta.client) return undefined
+const parseStoredAuthMethod = (raw: string): StoredAccountAuthMethod | undefined => {
+ if (isStoredAccountAuthMethod(raw)) {
+ return raw
+ }
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 {
@@ -135,6 +137,76 @@ const readPendingAuthMethod = (): StoredAccountAuthMethod | undefined => {
}
}
+const readAuthMethodKey = (key: string): StoredAccountAuthMethod | undefined => {
+ if (!import.meta.client) {
+ return undefined
+ }
+
+ try {
+ const raw = window.localStorage.getItem(key)
+ return raw ? parseStoredAuthMethod(raw) : undefined
+ } catch {
+ return undefined
+ }
+}
+
+export function readPendingAuthMethod() {
+ return readAuthMethodKey(PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY)
+}
+
+export function useLastSignInAuthMethod() {
+ return useStorage(
+ LAST_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
+ null,
+ undefined,
+ {
+ initOnMounted: true,
+ serializer: {
+ read: (raw) => {
+ return parseStoredAuthMethod(raw) ?? null
+ },
+ write: (value) => {
+ return value ?? ''
+ },
+ },
+ },
+ )
+}
+
+export function setPendingAuthMethod(method: StoredAccountAuthMethod | null) {
+ if (!import.meta.client) {
+ return
+ }
+
+ try {
+ if (!method) {
+ window.localStorage.removeItem(PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY)
+ return
+ }
+ window.localStorage.setItem(PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY, method)
+ } catch {
+ return
+ }
+}
+
+export function promotePendingAuthMethod() {
+ if (!import.meta.client) {
+ return
+ }
+
+ const pending = readPendingAuthMethod()
+ if (!pending) {
+ return
+ }
+
+ try {
+ window.localStorage.setItem(LAST_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY, pending)
+ window.localStorage.removeItem(PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY)
+ } catch {
+ return
+ }
+}
+
const readLocal = (): StoredAccount[] => {
if (!import.meta.client) return []
@@ -228,10 +300,7 @@ export const rememberStoredAccount = (
token,
role: isRole(user.role) ? user.role : undefined,
email: user.email ?? existing?.email ?? null,
- authMethod:
- options && 'authMethod' in options
- ? options.authMethod
- : (existing?.authMethod ?? readPendingAuthMethod()),
+ authMethod: options?.authMethod ?? existing?.authMethod ?? readPendingAuthMethod(),
appearance: existing?.appearance,
}
@@ -364,7 +433,7 @@ async function prepareStoredAccount(
}
const goToReauthenticate = async (account: StoredAccount) => {
- await navigateTo({
+ await promptAuth({
path: '/auth/reauthenticate',
query: {
account: account.id,
@@ -407,12 +476,29 @@ export const switchToStoredAccount = async (
return 'ready'
}
-export const switchToSignedOut = async (client: AbstractModrinthClient) => {
- if (!import.meta.client) return
-
+const beginAccountSwitch = () => {
+ if (!import.meta.client) {
+ return false
+ }
const switching = useIsSwitchingAccount()
- if (switching.value) return
+ if (switching.value) {
+ return false
+ }
switching.value = true
+ return true
+}
+
+const dropActiveSession = async () => {
+ useAuthCookie().value = null
+ useAuthState().value = { user: null, token: '' }
+ await nextTick()
+ window.location.reload()
+}
+
+export const switchToSignedOut = async (client: AbstractModrinthClient) => {
+ if (!beginAccountSwitch()) {
+ return
+ }
const auth = useAuthState()
const authCookie = useAuthCookie()
@@ -432,8 +518,21 @@ export const switchToSignedOut = async (client: AbstractModrinthClient) => {
: account,
),
)
- authCookie.value = null
- auth.value = { user: null, token: '' }
- await nextTick()
- window.location.reload()
+ await dropActiveSession()
+}
+
+export const browseSignedOut = async () => {
+ if (!import.meta.client) {
+ return
+ }
+
+ const auth = useAuthState()
+ const authCookie = useAuthCookie()
+ if (!auth.value.user && !auth.value.token && !authCookie.value) {
+ return
+ }
+ if (!beginAccountSwitch()) {
+ return
+ }
+ await dropActiveSession()
}
diff --git a/apps/frontend/src/composables/auth-form.ts b/apps/frontend/src/composables/auth-form.ts
new file mode 100644
index 00000000000..3461006fb19
--- /dev/null
+++ b/apps/frontend/src/composables/auth-form.ts
@@ -0,0 +1,22 @@
+import type { LocationQueryValue } from 'vue-router'
+
+import { getQueryString } from '@/composables/launcher-auth.ts'
+
+export function authQueryString(
+ value: LocationQueryValue | LocationQueryValue[] | null | undefined,
+): string {
+ return getQueryString(value) ?? ''
+}
+
+export function getAuthErrorMessage(error: unknown): string {
+ if (error && typeof error === 'object' && 'data' in error) {
+ const description = (error as { data?: { description?: unknown } }).data?.description
+ if (typeof description === 'string') {
+ return description
+ }
+ }
+ if (error instanceof Error) {
+ return error.message
+ }
+ return String(error)
+}
diff --git a/apps/frontend/src/composables/auth-modal.ts b/apps/frontend/src/composables/auth-modal.ts
new file mode 100644
index 00000000000..5f4189dc3bc
--- /dev/null
+++ b/apps/frontend/src/composables/auth-modal.ts
@@ -0,0 +1,134 @@
+import type { ComputedRef, InjectionKey } from 'vue'
+import type { LocationQuery, RouteLocationNormalizedLoaded, RouteLocationRaw } from 'vue-router'
+
+export type AuthLocation = {
+ path: string
+ fullPath: string
+ hash: string
+ query: LocationQuery
+ name: string | null
+}
+
+export const EMBEDDABLE_AUTH_PATHS = [
+ '/auth/sign-in',
+ '/auth/sign-up',
+ '/auth/reauthenticate',
+ '/auth/verify-email',
+] as const
+
+export type EmbeddableAuthPath = (typeof EMBEDDABLE_AUTH_PATHS)[number]
+
+const PAGE_ONLY_QUERY_KEYS = ['code', 'state', 'flow', 'launcher'] as const
+
+export const authModalLocation = shallowRef(null)
+export const authModalReady = ref(false)
+
+export const AUTH_ROUTE_KEY: InjectionKey> = Symbol('auth-route')
+
+function queryHasValue(query: LocationQuery, key: string) {
+ const value = query[key]
+ if (Array.isArray(value)) {
+ return value.some((entry) => entry != null && entry !== '')
+ }
+ return value != null && value !== ''
+}
+
+export function isEmbeddableAuthLocation(to: { path: string; query: LocationQuery }) {
+ return (
+ (EMBEDDABLE_AUTH_PATHS as readonly string[]).includes(to.path) &&
+ !PAGE_ONLY_QUERY_KEYS.some((key) => queryHasValue(to.query, key))
+ )
+}
+
+export function isAuthEmbedded() {
+ return inject(AUTH_ROUTE_KEY, null) != null
+}
+
+export function openAuthModal(to: {
+ path: string
+ fullPath: string
+ hash?: string
+ query: LocationQuery
+ name?: unknown
+}) {
+ authModalLocation.value = {
+ path: to.path,
+ fullPath: to.fullPath,
+ hash: to.hash ?? '',
+ query: { ...to.query },
+ name: typeof to.name === 'string' ? to.name : null,
+ }
+}
+
+export function closeAuthModal() {
+ authModalLocation.value = null
+}
+
+export function useAuthRoute(): RouteLocationNormalizedLoaded {
+ const override = inject(AUTH_ROUTE_KEY, null)
+ const route = useRoute()
+ if (!override) {
+ return route
+ }
+
+ return new Proxy(route, {
+ get(target, prop, receiver) {
+ const source = override.value
+ if (source && Object.prototype.hasOwnProperty.call(source, prop)) {
+ return source[prop as keyof AuthLocation]
+ }
+ const value = Reflect.get(target, prop, receiver)
+ return typeof value === 'function' ? value.bind(target) : value
+ },
+ })
+}
+
+export function openEmbeddableAuth(to: RouteLocationRaw) {
+ const resolved = useRouter().resolve(to)
+ if (!authModalReady.value || !isEmbeddableAuthLocation(resolved)) {
+ return false
+ }
+ openAuthModal(resolved)
+ return true
+}
+
+export function promptAuth(to: RouteLocationRaw) {
+ const path = import.meta.client ? window.location.pathname : useRouter().resolve(to).path
+ if (!path.startsWith('/auth') && openEmbeddableAuth(to)) {
+ return Promise.resolve()
+ }
+ return navigateTo(to)
+}
+
+export function useAuthNavigation() {
+ const embedded = inject(AUTH_ROUTE_KEY, null)
+
+ return {
+ async go(to: RouteLocationRaw) {
+ if (embedded?.value && openEmbeddableAuth(to)) {
+ return
+ }
+ await navigateTo(to)
+ },
+ }
+}
+
+export function useAuthPageTitle(title: () => string) {
+ if (isAuthEmbedded()) {
+ return
+ }
+ useHead({
+ title: () => `${title()} - Modrinth`,
+ })
+}
+
+export async function finishAuth(destination?: string | null, replace = false) {
+ const current = import.meta.client
+ ? `${window.location.pathname}${window.location.search}${window.location.hash}`
+ : ''
+ closeAuthModal()
+ if (!destination || destination === current) {
+ return
+ }
+ await navigateTo(destination, replace ? { replace: true } : undefined)
+}
diff --git a/apps/frontend/src/composables/auth.ts b/apps/frontend/src/composables/auth.ts
index f36a1851b44..e40493261eb 100644
--- a/apps/frontend/src/composables/auth.ts
+++ b/apps/frontend/src/composables/auth.ts
@@ -1,9 +1,9 @@
import type { Labrinth } from '@modrinth/api-client'
-import { useStorage } from '@vueuse/core'
import type { RouteLocationNormalizedLoaded } from 'vue-router'
-import { rememberStoredAccount } from '@/composables/accounts.ts'
+import { promotePendingAuthMethod, rememberStoredAccount } from '@/composables/accounts.ts'
import { useAuthCookie } from '@/composables/auth-cookie.ts'
+import { useAuthRoute } from '@/composables/auth-modal.ts'
import {
getQueryString,
isLauncherProtocolV2,
@@ -209,7 +209,7 @@ export const ADD_ACCOUNT_QUERY_PARAM = 'add_account'
export const getAuthUrl = (provider: string, redirect?: string, includeAppSession = false) => {
const config = useRuntimeConfig()
- const route = useNativeRoute()
+ const route = useAuthRoute()
const launcher = getQueryString(route.query.launcher)
const addingAccount =
route.query[ADD_ACCOUNT_QUERY_PARAM] !== undefined || route.path === '/auth/reauthenticate'
@@ -239,19 +239,7 @@ export const getAuthUrl = (provider: string, redirect?: string, includeAppSessio
}
export const promotePendingSignInOAuthProvider = () => {
- if (!import.meta.client) return
- const pending = useStorage(
- 'auth-pending-sign-in-oauth-provider',
- null,
- undefined,
- { initOnMounted: true },
- )
- if (!pending.value) return
- const last = useStorage('auth-last-sign-in-oauth-provider', null, undefined, {
- initOnMounted: true,
- })
- last.value = pending.value
- pending.value = null
+ promotePendingAuthMethod()
}
export const removeAuthProvider = async (provider: string) => {
diff --git a/apps/frontend/src/composables/external-auth.ts b/apps/frontend/src/composables/external-auth.ts
new file mode 100644
index 00000000000..4cbd6b229a3
--- /dev/null
+++ b/apps/frontend/src/composables/external-auth.ts
@@ -0,0 +1,71 @@
+import { nextTick, ref } from 'vue'
+
+import { setPendingAuthMethod } from '@/composables/accounts.ts'
+import { isAuthEmbedded } from '@/composables/auth-modal.ts'
+import { type OAuthProviderId, oauthProviders } from '@/composables/oauth-providers.ts'
+import { isModifierKeyDown } from '@/helpers/events.ts'
+
+export const authProviderHandoff = ref(false)
+
+let handoffToken: object | null = null
+
+export function startAuthProviderHandoff(url: string) {
+ authProviderHandoff.value = true
+ const token = {}
+ handoffToken = token
+
+ void nextTick(() => {
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ if (handoffToken !== token) {
+ return
+ }
+ window.location.assign(url)
+ })
+ })
+ })
+}
+
+export function cancelAuthProviderHandoff() {
+ if (!authProviderHandoff.value) {
+ return
+ }
+ authProviderHandoff.value = false
+ handoffToken = null
+ window.stop()
+}
+
+export function useProviderOpening() {
+ const openingProviderName = ref(null)
+ const embedded = isAuthEmbedded()
+
+ function beginProviderHandoff(
+ provider: OAuthProviderId,
+ event: MouseEvent,
+ url: string,
+ options?: { skip?: boolean; prepare?: () => void },
+ ) {
+ setPendingAuthMethod(provider)
+ options?.prepare?.()
+ const name = oauthProviders.find((item) => item.id === provider)?.name
+ if (!name || !embedded || options?.skip || !isPlainPrimaryClick(event)) {
+ return
+ }
+
+ event.preventDefault()
+ openingProviderName.value = name
+ startAuthProviderHandoff(url)
+ }
+
+ function cancelProviderOpening() {
+ cancelAuthProviderHandoff()
+ openingProviderName.value = null
+ setPendingAuthMethod(null)
+ }
+
+ return { openingProviderName, beginProviderHandoff, cancelProviderOpening }
+}
+
+function isPlainPrimaryClick(event: MouseEvent) {
+ return event.button === 0 && !isModifierKeyDown(event)
+}
diff --git a/apps/frontend/src/composables/oauth-providers.ts b/apps/frontend/src/composables/oauth-providers.ts
new file mode 100644
index 00000000000..7d04aac1d55
--- /dev/null
+++ b/apps/frontend/src/composables/oauth-providers.ts
@@ -0,0 +1,19 @@
+import {
+ DiscordColorIcon,
+ GitHubColorIcon,
+ GitLabColorIcon,
+ GoogleColorIcon,
+ MicrosoftColorIcon,
+ SteamColorIcon,
+} from '@modrinth/assets'
+
+export 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
+
+export type OAuthProviderId = (typeof oauthProviders)[number]['id']
diff --git a/apps/frontend/src/composables/sign-in.ts b/apps/frontend/src/composables/sign-in.ts
new file mode 100644
index 00000000000..531fb436dfa
--- /dev/null
+++ b/apps/frontend/src/composables/sign-in.ts
@@ -0,0 +1,596 @@
+import {
+ commonMessages,
+ defineMessages,
+ injectModrinthClient,
+ injectNotificationManager,
+ useGlobals,
+ useVIntl,
+} from '@modrinth/ui'
+import { useQueryClient } from '@tanstack/vue-query'
+import type { ComputedRef, InjectionKey, Ref } from 'vue'
+import type { LocationQuery } from 'vue-router'
+
+import {
+ hydrateStoredAccounts,
+ isStoredAccountAuthMethod,
+ readPendingAuthMethod,
+ rememberStoredAccount,
+ setPendingAuthMethod,
+ type StoredAccount,
+ type StoredAccountAuthMethod,
+ updateStoredAccountAuthMethod,
+ useLastSignInAuthMethod,
+ useStoredAccounts,
+} from '@/composables/accounts.ts'
+import {
+ ADD_ACCOUNT_QUERY_PARAM,
+ promotePendingSignInOAuthProvider,
+ useAuthState,
+} from '@/composables/auth.ts'
+import { getAuthErrorMessage } from '@/composables/auth-form.ts'
+import { finishAuth, useAuthPageTitle, useAuthRoute } from '@/composables/auth-modal.ts'
+import {
+ createLauncherHandoff,
+ getQueryString,
+ hideLauncherSessionCode,
+ isLauncherProtocolV2,
+ LAUNCHER_APP_CODE_QUERY_PARAM,
+ LAUNCHER_REAUTH_ACCOUNT_STORAGE_KEY,
+ launcherAuthMessages,
+ type LauncherHandoff,
+} from '@/composables/launcher-auth.ts'
+import { getPasskeyCredential } from '@/helpers/passkey.ts'
+
+type LauncherCallback = Extract
+
+interface ReauthAccountPreview {
+ id: string
+ username: string
+ avatarUrl?: string | null
+ authMethod: StoredAccountAuthMethod | null
+}
+
+interface SignInContext {
+ showSignIn: ComputedRef
+ launcherHandoff: Ref
+ flow: Ref
+ twoFactorCode: Ref
+ twoFactorPending: Ref
+ twoFactorError: Ref
+ accounts: ComputedRef
+ reauthAccount: ComputedRef
+ redirectTarget: string
+ routeQuery: ComputedRef
+ globals: ReturnType['data']
+ email: Ref
+ password: Ref
+ token: Ref
+ beginPasswordSignIn: () => Promise
+ begin2FASignIn: (code: string) => Promise
+ beginPasskeySignin: () => Promise
+ setCaptchaRef: (captchaRef: unknown) => void
+ cancelReauth: () => void
+ onSelectLauncherAccount: (account: { id: string }) => void
+}
+
+const SIGN_IN_KEY: InjectionKey = Symbol('sign-in')
+
+export function provideSignIn() {
+ const client = injectModrinthClient()
+ const queryClient = useQueryClient()
+ const { addNotification } = injectNotificationManager()
+ const { formatMessage } = useVIntl()
+
+ const messages = defineMessages({
+ signInTitle: {
+ id: 'auth.sign-in.title',
+ defaultMessage: 'Sign In',
+ },
+ })
+
+ useAuthPageTitle(() => formatMessage(messages.signInTitle))
+
+ const route = useAuthRoute()
+ const auth = useAuthState()
+ const lastSignInOAuthProvider = useLastSignInAuthMethod()
+
+ const redirectTarget = getQueryString(route.query.redirect) ?? ''
+ const routeQuery = computed(() => route.query)
+ const launcherHandoff = ref(null)
+ const reauthAccount = ref(null)
+ const isProtocolV2 = isLauncherProtocolV2(route)
+
+ const isAddingAccount = route.query[ADD_ACCOUNT_QUERY_PARAM] !== undefined
+ const isLauncherSignIn = route.query.launcher !== undefined
+ const storedAccounts = useStoredAccounts()
+ const signInReady = ref(!isLauncherSignIn)
+ const email = ref('')
+ const password = ref('')
+ const token = ref('')
+
+ function isLauncherSignInMethod(value: unknown): value is StoredAccountAuthMethod {
+ return isStoredAccountAuthMethod(value) && value !== 'paypal'
+ }
+
+ function readLastSignInMethod(): StoredAccountAuthMethod | undefined {
+ return isLauncherSignInMethod(lastSignInOAuthProvider.value)
+ ? lastSignInOAuthProvider.value
+ : undefined
+ }
+
+ function inferLauncherAuthMethod(
+ account: StoredAccount,
+ accountCount: number,
+ ): StoredAccountAuthMethod | undefined {
+ if (isLauncherSignInMethod(account.authMethod)) {
+ return account.authMethod
+ }
+
+ const user = auth.value.user
+ const isCurrentUser = user?.id === account.id
+ const lastMethod = readLastSignInMethod()
+ if ((isCurrentUser || accountCount === 1) && lastMethod) {
+ return lastMethod
+ }
+ if (!isCurrentUser || !user) {
+ return undefined
+ }
+
+ const linked = (user.auth_providers ?? []).filter(isLauncherSignInMethod)
+ if (linked.length === 1) {
+ return linked[0]
+ }
+ if (linked.length === 0 && user.has_password) {
+ return 'password'
+ }
+ return undefined
+ }
+
+ 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.map((account) => ({
+ ...account,
+ authMethod: inferLauncherAuthMethod(account, accounts.length),
+ }))
+ })
+
+ const launcherAccountChoices = computed(() => {
+ if (!isLauncherSignIn) {
+ return []
+ }
+
+ const minimumAccounts = isProtocolV2 || isAddingAccount ? 1 : 2
+ return choosableAccounts.value.length >= minimumAccounts ? choosableAccounts.value : []
+ })
+
+ const accounts = computed(() => (reauthAccount.value ? [] : launcherAccountChoices.value))
+
+ const reauthAccountPreview = computed(() => {
+ const account = reauthAccount.value
+ if (!account) {
+ return null
+ }
+ return {
+ id: account.id,
+ username: account.username,
+ avatarUrl: account.avatarUrl,
+ authMethod: account.authMethod ?? null,
+ }
+ })
+
+ function sessionTokensFromRoute() {
+ const session = getQueryString(route.query.code) ?? ''
+ const appSession = getQueryString(route.query[LAUNCHER_APP_CODE_QUERY_PARAM]) ?? ''
+ return {
+ session: session.startsWith('mra_') ? session : null,
+ appSession: appSession.startsWith('mra_') ? appSession : null,
+ }
+ }
+
+ onMounted(async () => {
+ if (!isLauncherSignIn) {
+ return
+ }
+
+ hydrateStoredAccounts()
+
+ if (isProtocolV2) {
+ const { session, appSession } = sessionTokensFromRoute()
+ if (session) {
+ await completeProtocolV2SignIn(session, appSession, readPendingAuthMethod())
+ }
+ }
+
+ if (launcherHandoff.value) {
+ signInReady.value = true
+ return
+ }
+
+ if (
+ !isProtocolV2 &&
+ auth.value.user &&
+ !isAddingAccount &&
+ choosableAccounts.value.length === 1 &&
+ route.query.code === undefined
+ ) {
+ await showLauncherOpeningPage(auth.value.token)
+ if (launcherHandoff.value) {
+ signInReady.value = true
+ }
+ return
+ }
+
+ signInReady.value = true
+ })
+
+ async function showLauncherOpeningPage(sessionToken: string) {
+ try {
+ const handoff = await createLauncherHandoff(route, sessionToken)
+ if (handoff.type === 'external') {
+ promotePendingSignInOAuthProvider()
+ await navigateTo(handoff.url, {
+ external: true,
+ })
+ return
+ }
+
+ promotePendingSignInOAuthProvider()
+ launcherHandoff.value = handoff
+ if (isProtocolV2) {
+ hideLauncherSessionCode()
+ }
+ } catch (err) {
+ console.error(err)
+ addNotification({
+ title: formatMessage(commonMessages.errorNotificationTitle),
+ text: formatMessage(launcherAuthMessages.handoffFailed),
+ type: 'error',
+ })
+ }
+ }
+
+ function onSelectLauncherAccount(account: { id: string }) {
+ const stored = choosableAccounts.value.find((choice) => choice.id === account.id)
+ if (!stored) {
+ return
+ }
+
+ if (!isProtocolV2) {
+ void showLauncherOpeningPage(stored.token)
+ return
+ }
+
+ reauthAccount.value = stored
+ email.value = stored.username
+ password.value = ''
+ if (import.meta.client) {
+ window.sessionStorage.setItem(LAUNCHER_REAUTH_ACCOUNT_STORAGE_KEY, stored.id)
+ }
+
+ const original = storedAccounts.value.find((choice) => choice.id === stored.id)
+ if (stored.authMethod && stored.authMethod !== original?.authMethod) {
+ updateStoredAccountAuthMethod(stored, stored.authMethod)
+ }
+ }
+
+ function cancelReauth() {
+ reauthAccount.value = null
+ email.value = ''
+ password.value = ''
+ if (import.meta.client) {
+ window.sessionStorage.removeItem(LAUNCHER_REAUTH_ACCOUNT_STORAGE_KEY)
+ }
+ }
+
+ function rememberLauncherAuthMethod(authMethod?: StoredAccountAuthMethod) {
+ if (!authMethod || !import.meta.client) {
+ return
+ }
+
+ const accountId = window.sessionStorage.getItem(LAUNCHER_REAUTH_ACCOUNT_STORAGE_KEY)
+ const account =
+ (accountId ? choosableAccounts.value.find((stored) => stored.id === accountId) : undefined) ??
+ reauthAccount.value
+ if (!account) {
+ return
+ }
+
+ updateStoredAccountAuthMethod(account, authMethod)
+ window.sessionStorage.removeItem(LAUNCHER_REAUTH_ACCOUNT_STORAGE_KEY)
+ }
+
+ const captcha = ref<{ reset?: () => void } | null>(null)
+ const setCaptchaRef = (captchaRef: unknown) => {
+ captcha.value = (captchaRef as { reset?: () => void } | null) ?? null
+ }
+
+ const { data: globals } = useGlobals()
+
+ const flow = ref(getQueryString(route.query.flow) ?? '')
+
+ async function beginPasswordSignIn() {
+ setPendingAuthMethod(null)
+ lastSignInOAuthProvider.value = null
+ startLoading()
+ try {
+ const res = await client.labrinth.auth_v2.login({
+ username: email.value,
+ password: password.value,
+ challenge: token.value,
+ app_session: shouldRequestAppSession(),
+ })
+
+ if (res.flow) {
+ flow.value = res.flow
+ } else {
+ await finishSignIn(res.session, res.app_session, 'password')
+ }
+ } catch (err) {
+ addNotification({
+ title: formatMessage(commonMessages.errorNotificationTitle),
+ text: getAuthErrorMessage(err),
+ type: 'error',
+ })
+ captcha.value?.reset?.()
+ }
+ stopLoading()
+ }
+
+ const twoFactorCode = ref('')
+ const twoFactorPending = ref(false)
+ const twoFactorError = ref(false)
+
+ async function begin2FASignIn(code: string) {
+ if (twoFactorPending.value) {
+ return
+ }
+ twoFactorPending.value = true
+ twoFactorError.value = false
+ startLoading()
+ try {
+ const res = await client.labrinth.auth_v2.login2FA({
+ flow: flow.value,
+ code,
+ app_session: shouldRequestAppSession(),
+ })
+
+ await finishSignIn(res.session, res.app_session, 'password')
+ } catch {
+ twoFactorCode.value = ''
+ twoFactorError.value = true
+ } finally {
+ twoFactorPending.value = false
+ 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,
+ app_session: shouldRequestAppSession(),
+ })
+
+ setPendingAuthMethod('passkey')
+ await finishSignIn(result.session, result.app_session, 'passkey')
+ } catch (err) {
+ addNotification({
+ title: formatMessage(commonMessages.errorNotificationTitle),
+ text: getAuthErrorMessage(err),
+ type: 'error',
+ })
+ }
+ stopLoading()
+ }
+
+ function isKnownAccountReauth() {
+ if (reauthAccount.value) {
+ return true
+ }
+ if (!import.meta.client) {
+ return false
+ }
+ return window.sessionStorage.getItem(LAUNCHER_REAUTH_ACCOUNT_STORAGE_KEY) != null
+ }
+
+ const shouldRequestAppSession = () => isProtocolV2 && !isKnownAccountReauth()
+
+ async function refreshMountedQueries() {
+ await queryClient.resetQueries()
+ }
+
+ async function adoptWebsiteSession(sessionToken: string, authMethod?: StoredAccountAuthMethod) {
+ await useAuth(sessionToken)
+ await useUser()
+ await refreshMountedQueries()
+
+ const signedIn = await useAuth()
+ if (signedIn.value.user && signedIn.value.token) {
+ rememberStoredAccount(
+ signedIn.value.user,
+ signedIn.value.token,
+ authMethod ? { authMethod } : undefined,
+ )
+ }
+ }
+
+ async function completeProtocolV2SignIn(
+ sessionToken: string,
+ appSessionToken: string | null | undefined,
+ authMethod?: StoredAccountAuthMethod,
+ ) {
+ const knownAccount = isKnownAccountReauth()
+
+ if (!knownAccount) {
+ if (!appSessionToken) {
+ addNotification({
+ title: formatMessage(commonMessages.errorNotificationTitle),
+ text: formatMessage(launcherAuthMessages.handoffFailed),
+ type: 'error',
+ })
+ return
+ }
+
+ try {
+ await adoptWebsiteSession(sessionToken, authMethod)
+ } catch (err) {
+ addNotification({
+ title: formatMessage(commonMessages.errorNotificationTitle),
+ text: getAuthErrorMessage(err),
+ type: 'error',
+ })
+ return
+ }
+ } else {
+ rememberLauncherAuthMethod(authMethod)
+ }
+
+ await showLauncherOpeningPage(appSessionToken ?? sessionToken)
+ }
+
+ async function finishSignIn(
+ sessionToken?: string | null,
+ appSessionToken?: string | null,
+ authMethod?: StoredAccountAuthMethod,
+ ) {
+ if (route.query.launcher) {
+ if (isProtocolV2) {
+ if (sessionToken) {
+ await completeProtocolV2SignIn(sessionToken, appSessionToken, authMethod)
+ }
+ return
+ }
+
+ const sessionTokenForLauncher = sessionToken ?? auth.value.token
+ if (sessionTokenForLauncher) {
+ await showLauncherOpeningPage(sessionTokenForLauncher)
+ }
+
+ return
+ }
+
+ if (sessionToken) {
+ await useAuth(sessionToken)
+ await useUser()
+ await refreshMountedQueries()
+ }
+
+ const signedIn = await useAuth()
+ if (signedIn.value.user && signedIn.value.token) {
+ const nextAuthMethod = authMethod ?? readPendingAuthMethod()
+ rememberStoredAccount(
+ signedIn.value.user,
+ signedIn.value.token,
+ nextAuthMethod ? { authMethod: nextAuthMethod } : undefined,
+ )
+ }
+
+ promotePendingSignInOAuthProvider()
+
+ if (route.query.redirect) {
+ await finishAuth(decodeURIComponent(getQueryString(route.query.redirect) ?? ''), true)
+ } else if (signedIn.value.user) {
+ await finishAuth(`/user/${signedIn.value.user.username}`, true)
+ } else {
+ await finishAuth()
+ }
+ }
+
+ const showSignIn = computed(() => signInReady.value || launcherHandoff.value != null)
+
+ async function loadAuth() {
+ await useAuth()
+ }
+
+ async function redirectOAuthState() {
+ if (route.query.state === undefined) {
+ return
+ }
+ await navigateTo(
+ {
+ path: '/auth/create/oauth',
+ query: route.query,
+ },
+ {
+ replace: true,
+ },
+ )
+ }
+
+ async function finishSessionCode() {
+ if (!route.query.code || isProtocolV2) {
+ return
+ }
+ await finishSignIn()
+ }
+
+ const signIn = {
+ showSignIn,
+ launcherHandoff,
+ flow,
+ twoFactorCode,
+ twoFactorPending,
+ twoFactorError,
+ accounts,
+ reauthAccount: reauthAccountPreview,
+ redirectTarget,
+ routeQuery,
+ globals,
+ email,
+ password,
+ token,
+ beginPasswordSignIn,
+ begin2FASignIn,
+ beginPasskeySignin,
+ setCaptchaRef,
+ cancelReauth,
+ onSelectLauncherAccount,
+ }
+
+ provide(SIGN_IN_KEY, signIn)
+ return {
+ showSignIn,
+ loadAuth,
+ redirectOAuthState,
+ finishSessionCode,
+ }
+}
+
+export function useSignIn() {
+ const signIn = inject(SIGN_IN_KEY)
+ if (!signIn) {
+ throw new Error('useSignIn() must be used within the sign-in screen')
+ }
+ return signIn
+}
diff --git a/apps/frontend/src/layouts/default.vue b/apps/frontend/src/layouts/default.vue
index ed2956a3e49..61cb847a709 100644
--- a/apps/frontend/src/layouts/default.vue
+++ b/apps/frontend/src/layouts/default.vue
@@ -589,10 +589,10 @@
-
+
{{ formatMessage(commonMessages.signInButton) }}
-
+
{{ formatMessage(commonMessages.visitYourProfile) }}
-
{{ formatMessage(commonMessages.signInButton) }}
-
+
@@ -852,6 +852,7 @@ import {
TransferIcon,
UserIcon,
UserSearchIcon,
+ UserXIcon,
XIcon,
} from '@modrinth/assets'
import {
@@ -894,12 +895,14 @@ import OrganizationCreateModal from '~/components/ui/create/OrganizationCreateMo
import ProjectCreateModal from '~/components/ui/create/ProjectCreateModal.vue'
import ModrinthFooter from '~/components/ui/ModrinthFooter.vue'
import {
+ browseSignedOut,
forgetStoredAccount,
switchToSignedOut,
switchToStoredAccount,
useStoredAccounts,
} from '~/composables/accounts.ts'
import { getSignInRouteObj } from '~/composables/auth.ts'
+import { promptAuth } from '~/composables/auth-modal.ts'
import { logout } from '~/composables/user.js'
import {
errors as generatedStateErrors,
@@ -926,6 +929,11 @@ const config = useRuntimeConfig()
const route = useNativeRoute()
const router = useNativeRouter()
const signInRouteObj = computed(() => getSignInRouteObj(route))
+
+function openSignIn() {
+ void promptAuth(signInRouteObj.value)
+}
+
const storedAccounts = useStoredAccounts()
const canonicalUrl = computed(() => config.public.siteUrl + route.path.replace(/\/+$/, ''))
const client = injectModrinthClient()
@@ -1163,6 +1171,10 @@ const messages = defineMessages({
id: 'layout.nav.remove-account',
defaultMessage: 'Remove account',
},
+ useSignedOut: {
+ id: 'layout.nav.use-signed-out',
+ defaultMessage: 'Use signed out',
+ },
accountSwitchFailed: {
id: 'layout.nav.switch-account-failed',
defaultMessage: "Couldn't switch accounts. Please try again.",
@@ -1272,6 +1284,13 @@ const accountSwitcherOptions = computed(() => [
action: () => removeAccount(account),
},
})),
+ {
+ id: 'use-signed-out',
+ label: formatMessage(messages.useSignedOut),
+ icon: UserXIcon,
+ selected: !auth.value.user,
+ action: () => browseSignedOut(),
+ },
{
type: 'divider',
},
@@ -1279,8 +1298,9 @@ const accountSwitcherOptions = computed(() => [
id: 'add-account',
label: formatMessage(messages.addAccount),
icon: PlusIcon,
- type: 'link',
- to: signInRouteObj.value,
+ action: () => {
+ openSignIn()
+ },
},
])
diff --git a/apps/frontend/src/locales/en-US/index.json b/apps/frontend/src/locales/en-US/index.json
index abf95a03885..78b12f9397c 100644
--- a/apps/frontend/src/locales/en-US/index.json
+++ b/apps/frontend/src/locales/en-US/index.json
@@ -812,6 +812,12 @@
"auth.create-account.username.placeholder": {
"message": "Enter username"
},
+ "auth.oauth.opening.description": {
+ "message": "Complete your sign in on {provider}"
+ },
+ "auth.oauth.opening.title": {
+ "message": "Opening {provider}..."
+ },
"auth.reauthenticate.2fa.description": {
"message": "Enter the 6-digit code from your authenticator app, or one of your backup codes."
},
@@ -3002,6 +3008,9 @@
"layout.nav.upgrade-to-modrinth-plus": {
"message": "Upgrade to Modrinth+"
},
+ "layout.nav.use-signed-out": {
+ "message": "Use signed out"
+ },
"layout.publish.email-verification-required.description": {
"message": "You must verify your email before publishing on Modrinth."
},
diff --git a/apps/frontend/src/middleware/auth.ts b/apps/frontend/src/middleware/auth.ts
index 70e7ac7e5bb..bc48624f5f7 100644
--- a/apps/frontend/src/middleware/auth.ts
+++ b/apps/frontend/src/middleware/auth.ts
@@ -1,3 +1,5 @@
+import { openEmbeddableAuth } from '@/composables/auth-modal.ts'
+
const whitelistedParams = ['flow', 'error']
export default defineNuxtRouteMiddleware(async (to, from) => {
@@ -20,14 +22,17 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
}
}
- return await navigateTo(
- {
- path: '/auth/sign-in',
- query: {
- redirect: `${url.pathname}${url.search}`,
- ...extractedParams,
- },
+ const signInLocation = {
+ path: '/auth/sign-in',
+ query: {
+ redirect: `${url.pathname}${url.search}`,
+ ...extractedParams,
},
- { replace: true },
- )
+ }
+
+ if (import.meta.client && !useNuxtApp().isHydrating && openEmbeddableAuth(signInLocation)) {
+ return abortNavigation()
+ }
+
+ return await navigateTo(signInLocation, { replace: true })
})
diff --git a/apps/frontend/src/pages/[type]/[project].vue b/apps/frontend/src/pages/[type]/[project].vue
index 8a776d836e7..9b372f01d65 100644
--- a/apps/frontend/src/pages/[type]/[project].vue
+++ b/apps/frontend/src/pages/[type]/[project].vue
@@ -628,6 +628,7 @@ import ProjectCollectionSaveButton from '~/components/ui/ProjectCollectionSaveBu
import ProjectDownloadModal from '~/components/ui/ProjectDownloadModal/index.vue'
import ProjectMemberHeader from '~/components/ui/ProjectMemberHeader.vue'
import { getSignInRouteObj } from '~/composables/auth.ts'
+import { promptAuth } from '~/composables/auth-modal.ts'
import { useCreatorProjectStats } from '~/composables/creator-project-stats'
import { saveFeatureFlags } from '~/composables/featureFlags.ts'
import { useProjectLinkValidation } from '~/composables/link-network-validation'
@@ -2177,7 +2178,7 @@ function reportProjectFromHeader() {
if (auth.value.user) {
reportProject(project.value.id)
} else {
- navigateTo(getSignInRouteObj(route, getReportPath('project', project.value.id)))
+ void promptAuth(getSignInRouteObj(route, getReportPath('project', project.value.id)))
}
}
diff --git a/apps/frontend/src/pages/[type]/[project]/settings/versions.vue b/apps/frontend/src/pages/[type]/[project]/settings/versions.vue
index 93e573a71cd..3394e754e8f 100644
--- a/apps/frontend/src/pages/[type]/[project]/settings/versions.vue
+++ b/apps/frontend/src/pages/[type]/[project]/settings/versions.vue
@@ -120,7 +120,7 @@
tone: 'red',
hoverFilled: true,
action: () =>
- auth.user ? reportVersion(version.id) : navigateTo(getSignInRouteObj(route)),
+ auth.user ? reportVersion(version.id) : promptAuth(getSignInRouteObj(route)),
shown: !currentMember,
},
{ type: 'divider', shown: !!currentMember || flags.developerMode },
@@ -334,6 +334,7 @@ import EditVersionMenu from '~/components/ui/create-project-version/EditVersionM
import ProjectIssueCard from '~/components/ui/project-issue-card/index.vue'
import ValidationMessage from '~/components/ValidationMessage.vue'
import { getSignInRouteObj } from '~/composables/auth.ts'
+import { promptAuth } from '~/composables/auth-modal.ts'
import { useProjectNagMessages } from '~/composables/project-nag-validation'
import { reportVersion } from '~/utils/report-helpers.ts'
diff --git a/apps/frontend/src/pages/[type]/[project]/version/[version].vue b/apps/frontend/src/pages/[type]/[project]/version/[version].vue
index 6224f0e590a..edf4e40ffb9 100644
--- a/apps/frontend/src/pages/[type]/[project]/version/[version].vue
+++ b/apps/frontend/src/pages/[type]/[project]/version/[version].vue
@@ -278,7 +278,7 @@
label: formatMessage(commonMessages.reportButton),
tone: 'red',
action: () =>
- auth.user ? reportVersion(version!.id) : navigateTo(signInRouteObj),
+ auth.user ? reportVersion(version!.id) : promptAuth(signInRouteObj),
},
{ type: 'divider', shown: flags.developerMode },
{
@@ -573,6 +573,7 @@ import { onServerPrefetch } from 'vue'
import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue'
import ProjectDownloadModal from '~/components/ui/ProjectDownloadModal/index.vue'
import { getSignInRouteObj } from '~/composables/auth.ts'
+import { promptAuth } from '~/composables/auth-modal.ts'
import { projectQueryOptions, STALE_TIME } from '~/composables/queries/project'
import { versionQueryOptions } from '~/composables/queries/version'
import { useVersionDownload } from '~/composables/version-download'
diff --git a/apps/frontend/src/pages/[type]/[project]/versions.vue b/apps/frontend/src/pages/[type]/[project]/versions.vue
index c5796108f3e..7177cc97e79 100644
--- a/apps/frontend/src/pages/[type]/[project]/versions.vue
+++ b/apps/frontend/src/pages/[type]/[project]/versions.vue
@@ -163,7 +163,7 @@
tone: 'red',
hoverFilled: true,
action: () =>
- auth.user ? reportVersion(version.id) : navigateTo(getSignInRouteObj(route)),
+ auth.user ? reportVersion(version.id) : promptAuth(getSignInRouteObj(route)),
shown: !currentMember,
},
{ type: 'divider', shown: isStaff(auth.user) },
@@ -331,6 +331,7 @@ import { onMounted, useTemplateRef, watch } from 'vue'
import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue'
import ProjectC2paScanModal from '~/components/ui/moderation/ProjectC2paScanModal.vue'
import { getSignInRouteObj } from '~/composables/auth.ts'
+import { promptAuth } from '~/composables/auth-modal.ts'
import { useVersionDownload } from '~/composables/version-download.ts'
import { reportVersion } from '~/utils/report-helpers.ts'
diff --git a/apps/frontend/src/pages/auth.vue b/apps/frontend/src/pages/auth.vue
index a0a6ccd9c1c..6cc23dfc9f0 100644
--- a/apps/frontend/src/pages/auth.vue
+++ b/apps/frontend/src/pages/auth.vue
@@ -12,97 +12,3 @@ useSeoMeta({
-
-
diff --git a/apps/frontend/src/pages/auth/reauthenticate.vue b/apps/frontend/src/pages/auth/reauthenticate.vue
index 34e32a797d5..159c228b9cc 100644
--- a/apps/frontend/src/pages/auth/reauthenticate.vue
+++ b/apps/frontend/src/pages/auth/reauthenticate.vue
@@ -1,412 +1,7 @@