feat: browser microsoft sign-in, reachable offline and ely.by login
This commit is contained in:
1 parent
61a9b53af6
commit
9f8f11f424
8 files changed
+1003
-139
No files matched your search
@@ -0,0 +1,235 @@
|
||||
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
|
||||
Date: Mon, 14 Sep 2026 10:18:37 +0200
|
||||
Subject: [PATCH] Remove advertising and Modrinth+ upsells
|
||||
|
||||
The sidebar ad slot, the ad cookie consent prompt and both "Upgrade to
|
||||
Modrinth+" prompts are gone:
|
||||
|
||||
* `showAd` and `adConsentAvailable` are pinned to false, which takes the
|
||||
ad slot and the consent prompt out of the layout.
|
||||
* The upgrade link above the ad slot and the entry in the account menu
|
||||
are removed outright rather than left behind a false condition.
|
||||
* The sidebar keeps upstream's `has-plus` class unconditionally, which
|
||||
is what stops space being reserved at the bottom for an ad.
|
||||
* With no upsell left to decide about, the request that asked Modrinth
|
||||
whether this account has Modrinth+ goes too.
|
||||
|
||||
The helpers in `helpers/ads.js` are additionally stubbed out, which
|
||||
stops the Tauri `ads` plugin from ever being asked to spawn the ad
|
||||
webview, no matter which call site reaches for it.
|
||||
|
||||
Nothing else in the app is gated behind Modrinth+: it decided whether
|
||||
ads and these prompts were shown, and nothing more.
|
||||
---
|
||||
apps/app-frontend/src/App.vue | 55 ++++-------------------
|
||||
apps/app-frontend/src/helpers/ads.js | 65 +++++++++-------------------
|
||||
2 files changed, 29 insertions(+), 91 deletions(-)
|
||||
|
||||
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
|
||||
index a2ae0e6..6e7b27d 100644
|
||||
--- a/apps/app-frontend/src/App.vue
|
||||
+++ b/apps/app-frontend/src/App.vue
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
VerboseLoggingFeature,
|
||||
} from '@modrinth/api-client'
|
||||
import {
|
||||
- ArrowBigUpDashIcon,
|
||||
ArrowLeftRightIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
@@ -94,7 +93,6 @@ import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
|
||||
import NavButton from '@/components/ui/NavButton.vue'
|
||||
import OnboardingChecklist from '@/components/ui/onboarding-checklist/index.vue'
|
||||
import PrideFundraiserBanner from '@/components/ui/PrideFundraiserBanner.vue'
|
||||
-import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
|
||||
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
|
||||
import SharedInstanceInviteHandler from '@/components/ui/shared-instances/shared-instance-invite-handler/index.vue'
|
||||
import SplashScreen from '@/components/ui/SplashScreen.vue'
|
||||
@@ -159,7 +157,6 @@ import {
|
||||
syncedServersQueryOptions,
|
||||
} from '@/helpers/synced-options'
|
||||
import { syncedPackQueryOptions } from '@/helpers/synced-packs'
|
||||
-import { hasActivePride26Midas, hasMidasBadge } from '@/helpers/user-campaigns.ts'
|
||||
import { get_user_preferences } from '@/helpers/user-preferences.ts'
|
||||
import { parse_modrinth_user_link } from '@/helpers/users'
|
||||
import {
|
||||
@@ -344,12 +341,6 @@ const tauriApiClient = new TauriModrinthClient({
|
||||
],
|
||||
})
|
||||
provideModrinthClient(tauriApiClient)
|
||||
-const { data: authenticatedModrinthUser } = useQuery({
|
||||
- queryKey: computed(() => ['authenticated-user', 'campaigns', credentials.value?.user?.id]),
|
||||
- queryFn: () => tauriApiClient.labrinth.users_v3.getAuthenticated(),
|
||||
- enabled: () => !!credentials.value?.session,
|
||||
- retry: false,
|
||||
-})
|
||||
useQuery({
|
||||
queryKey: computed(() => instanceKeys.sharedEligibility(credentials.value?.user?.id)),
|
||||
queryFn: can_current_user_use_shared_instances,
|
||||
@@ -360,16 +351,13 @@ useQuery({
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
})
|
||||
-const hasPlus = computed(
|
||||
- () =>
|
||||
- !!credentials.value?.user &&
|
||||
- (hasMidasBadge(credentials.value.user) ||
|
||||
- hasActivePride26Midas(authenticatedModrinthUser.value?.campaigns?.pride_26)),
|
||||
-)
|
||||
-const showAd = computed(
|
||||
- () => sidebarVisible.value && !hasPlus.value && credentials.value !== undefined,
|
||||
-)
|
||||
-const adConsentAvailable = computed(() => credentials.value !== undefined && !hasPlus.value)
|
||||
+// Modrinth Enhanced ships without advertising, so the sidebar ad slot and the
|
||||
+// ad cookie consent flow that only exists to serve it are both switched off.
|
||||
+// The sidebar keeps upstream's `has-plus` class unconditionally for the same
|
||||
+// reason: that class is what stops space being reserved at the bottom for an
|
||||
+// ad and the gradient being drawn above it.
|
||||
+const showAd = computed(() => false)
|
||||
+const adConsentAvailable = computed(() => false)
|
||||
providePageContext({
|
||||
hierarchicalSidebarAvailable: ref(true),
|
||||
showAds: showAd,
|
||||
@@ -694,10 +682,6 @@ const messages = defineMessages({
|
||||
id: 'app.restarting',
|
||||
defaultMessage: 'Restarting...',
|
||||
},
|
||||
- upgradeToModrinthPlus: {
|
||||
- id: 'app.nav.upgrade-to-modrinth-plus',
|
||||
- defaultMessage: 'Upgrade to Modrinth+',
|
||||
- },
|
||||
news: {
|
||||
id: 'app.news.title',
|
||||
defaultMessage: 'News',
|
||||
@@ -1529,16 +1513,6 @@ const modrinthAccountMenuOptions = computed(() => [
|
||||
icon: UserIcon,
|
||||
action: () => router.push(`/user/${encodeURIComponent(credentials.value.user.username)}`),
|
||||
},
|
||||
- {
|
||||
- id: 'plus',
|
||||
- label: formatMessage(messages.upgradeToModrinthPlus),
|
||||
- icon: ArrowBigUpDashIcon,
|
||||
- type: 'link',
|
||||
- href: 'https://modrinth.plus?app',
|
||||
- target: '_blank',
|
||||
- tone: 'purple',
|
||||
- shown: !hasPlus.value,
|
||||
- },
|
||||
{
|
||||
id: 'add-friend',
|
||||
label: formatMessage(messages.addFriend),
|
||||
@@ -2484,13 +2458,11 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
</RouterView>
|
||||
</div>
|
||||
<div
|
||||
- class="app-sidebar mt-px shrink-0 flex flex-col border-0 border-l-[1px] border-[--brand-gradient-border] border-solid"
|
||||
- :class="{ 'has-plus': hasPlus }"
|
||||
+ class="app-sidebar mt-px shrink-0 flex flex-col border-0 border-l-[1px] border-[--brand-gradient-border] border-solid has-plus"
|
||||
>
|
||||
<div
|
||||
v-overlay-scrollbars="sidebarOverlayScrollbarsOptions"
|
||||
class="app-sidebar-scrollable flex-grow shrink relative"
|
||||
- :class="{ 'pb-12': !hasPlus }"
|
||||
data-overlayscrollbars-initialize
|
||||
>
|
||||
<OnboardingChecklist
|
||||
@@ -2552,17 +2524,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
- <template v-if="showAd">
|
||||
- <a
|
||||
- href="https://modrinth.plus?app"
|
||||
- class="absolute bottom-[250px] w-full flex justify-center items-center gap-1 px-4 py-3 text-purple font-medium hover:underline z-10"
|
||||
- target="_blank"
|
||||
- >
|
||||
- <ArrowBigUpDashIcon class="text-2xl" />
|
||||
- {{ formatMessage(messages.upgradeToModrinthPlus) }}
|
||||
- </a>
|
||||
- <PromotionWrapper />
|
||||
- </template>
|
||||
</div>
|
||||
</div>
|
||||
<I18nDebugPanel />
|
||||
diff --git a/apps/app-frontend/src/helpers/ads.js b/apps/app-frontend/src/helpers/ads.js
|
||||
index 8c85970..96005e6 100644
|
||||
--- a/apps/app-frontend/src/helpers/ads.js
|
||||
+++ b/apps/app-frontend/src/helpers/ads.js
|
||||
@@ -1,55 +1,32 @@
|
||||
-import { invoke } from '@tauri-apps/api/core'
|
||||
+/**
|
||||
+ * Modrinth Enhanced does not show advertising.
|
||||
+ *
|
||||
+ * Upstream these helpers forward to the Tauri `ads` plugin, which spawns a
|
||||
+ * second webview that loads the ad network and tracks clicks on it. Every
|
||||
+ * helper below is a no-op instead, so the plugin is never asked to create
|
||||
+ * that webview and the consent flow that exists purely for ad cookies never
|
||||
+ * has anything to consent to.
|
||||
+ *
|
||||
+ * The functions are kept — rather than removed along with their callers — so
|
||||
+ * that upstream call sites keep working unchanged.
|
||||
+ */
|
||||
|
||||
-export async function init_ads_window(overrideShown = false) {
|
||||
- return await invoke('plugin:ads|init_ads_window', {
|
||||
- overrideShown,
|
||||
- dpr: window.devicePixelRatio,
|
||||
- })
|
||||
-}
|
||||
-
|
||||
-let adsWindowHoldUpdate = Promise.resolve()
|
||||
-
|
||||
-async function update_ads_window_hold(acquire) {
|
||||
- adsWindowHoldUpdate = adsWindowHoldUpdate
|
||||
- .catch(() => {})
|
||||
- .then(() =>
|
||||
- invoke('plugin:ads|update_ads_window_hold', {
|
||||
- acquire,
|
||||
- dpr: window.devicePixelRatio,
|
||||
- }),
|
||||
- )
|
||||
+export async function init_ads_window() {}
|
||||
|
||||
- return await adsWindowHoldUpdate
|
||||
-}
|
||||
-
|
||||
-export async function take_ads_window_hold() {
|
||||
- return await update_ads_window_hold(true)
|
||||
-}
|
||||
+export async function take_ads_window_hold() {}
|
||||
|
||||
-export async function release_ads_window_hold() {
|
||||
- return await update_ads_window_hold(false)
|
||||
-}
|
||||
+export async function release_ads_window_hold() {}
|
||||
|
||||
-export async function hide_ads_window(reset) {
|
||||
- return await invoke('plugin:ads|hide_ads_window', { reset })
|
||||
-}
|
||||
+export async function hide_ads_window() {}
|
||||
|
||||
export async function should_show_ads_consent_popup() {
|
||||
- return await invoke('plugin:ads|should_show_ads_consent_popup')
|
||||
+ return false
|
||||
}
|
||||
|
||||
-export async function perform_ads_consent_action(action) {
|
||||
- return await invoke('plugin:ads|perform_ads_consent_action', { action })
|
||||
-}
|
||||
+export async function perform_ads_consent_action() {}
|
||||
|
||||
-export async function open_ads_consent_preferences() {
|
||||
- return await invoke('plugin:ads|open_ads_consent_preferences')
|
||||
-}
|
||||
+export async function open_ads_consent_preferences() {}
|
||||
|
||||
-export async function record_ads_click() {
|
||||
- return await invoke('plugin:ads|record_ads_click')
|
||||
-}
|
||||
+export async function record_ads_click() {}
|
||||
|
||||
-export async function open_ads_link(path, origin) {
|
||||
- return await invoke('plugin:ads|open_link', { path, origin })
|
||||
-}
|
||||
+export async function open_ads_link() {}
|
||||
@@ -1,117 +0,0 @@
|
||||
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
|
||||
Date: Mon, 14 Sep 2026 10:18:37 +0200
|
||||
Subject: [PATCH] Remove advertising
|
||||
|
||||
The sidebar ad slot, the "Upgrade to Modrinth+" nag above it and the ad
|
||||
cookie consent prompt are all driven by two computed flags in App.vue,
|
||||
so pinning both to false takes the whole surface out of the layout.
|
||||
|
||||
The helpers in `helpers/ads.js` are additionally stubbed out, which
|
||||
stops the Tauri `ads` plugin from ever being asked to spawn the ad
|
||||
webview, no matter which call site reaches for it.
|
||||
---
|
||||
apps/app-frontend/src/App.vue | 8 ++--
|
||||
apps/app-frontend/src/helpers/ads.js | 65 +++++++++-------------------
|
||||
2 files changed, 25 insertions(+), 48 deletions(-)
|
||||
|
||||
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
|
||||
index a2ae0e6..33c29ce 100644
|
||||
--- a/apps/app-frontend/src/App.vue
|
||||
+++ b/apps/app-frontend/src/App.vue
|
||||
@@ -366,10 +366,10 @@ const hasPlus = computed(
|
||||
(hasMidasBadge(credentials.value.user) ||
|
||||
hasActivePride26Midas(authenticatedModrinthUser.value?.campaigns?.pride_26)),
|
||||
)
|
||||
-const showAd = computed(
|
||||
- () => sidebarVisible.value && !hasPlus.value && credentials.value !== undefined,
|
||||
-)
|
||||
-const adConsentAvailable = computed(() => credentials.value !== undefined && !hasPlus.value)
|
||||
+// Modrinth Enhanced ships without advertising, so the sidebar ad slot and the
|
||||
+// ad cookie consent flow that only exists to serve it are both switched off.
|
||||
+const showAd = computed(() => false)
|
||||
+const adConsentAvailable = computed(() => false)
|
||||
providePageContext({
|
||||
hierarchicalSidebarAvailable: ref(true),
|
||||
showAds: showAd,
|
||||
diff --git a/apps/app-frontend/src/helpers/ads.js b/apps/app-frontend/src/helpers/ads.js
|
||||
index 8c85970..96005e6 100644
|
||||
--- a/apps/app-frontend/src/helpers/ads.js
|
||||
+++ b/apps/app-frontend/src/helpers/ads.js
|
||||
@@ -1,55 +1,32 @@
|
||||
-import { invoke } from '@tauri-apps/api/core'
|
||||
+/**
|
||||
+ * Modrinth Enhanced does not show advertising.
|
||||
+ *
|
||||
+ * Upstream these helpers forward to the Tauri `ads` plugin, which spawns a
|
||||
+ * second webview that loads the ad network and tracks clicks on it. Every
|
||||
+ * helper below is a no-op instead, so the plugin is never asked to create
|
||||
+ * that webview and the consent flow that exists purely for ad cookies never
|
||||
+ * has anything to consent to.
|
||||
+ *
|
||||
+ * The functions are kept — rather than removed along with their callers — so
|
||||
+ * that upstream call sites keep working unchanged.
|
||||
+ */
|
||||
|
||||
-export async function init_ads_window(overrideShown = false) {
|
||||
- return await invoke('plugin:ads|init_ads_window', {
|
||||
- overrideShown,
|
||||
- dpr: window.devicePixelRatio,
|
||||
- })
|
||||
-}
|
||||
-
|
||||
-let adsWindowHoldUpdate = Promise.resolve()
|
||||
-
|
||||
-async function update_ads_window_hold(acquire) {
|
||||
- adsWindowHoldUpdate = adsWindowHoldUpdate
|
||||
- .catch(() => {})
|
||||
- .then(() =>
|
||||
- invoke('plugin:ads|update_ads_window_hold', {
|
||||
- acquire,
|
||||
- dpr: window.devicePixelRatio,
|
||||
- }),
|
||||
- )
|
||||
+export async function init_ads_window() {}
|
||||
|
||||
- return await adsWindowHoldUpdate
|
||||
-}
|
||||
-
|
||||
-export async function take_ads_window_hold() {
|
||||
- return await update_ads_window_hold(true)
|
||||
-}
|
||||
+export async function take_ads_window_hold() {}
|
||||
|
||||
-export async function release_ads_window_hold() {
|
||||
- return await update_ads_window_hold(false)
|
||||
-}
|
||||
+export async function release_ads_window_hold() {}
|
||||
|
||||
-export async function hide_ads_window(reset) {
|
||||
- return await invoke('plugin:ads|hide_ads_window', { reset })
|
||||
-}
|
||||
+export async function hide_ads_window() {}
|
||||
|
||||
export async function should_show_ads_consent_popup() {
|
||||
- return await invoke('plugin:ads|should_show_ads_consent_popup')
|
||||
+ return false
|
||||
}
|
||||
|
||||
-export async function perform_ads_consent_action(action) {
|
||||
- return await invoke('plugin:ads|perform_ads_consent_action', { action })
|
||||
-}
|
||||
+export async function perform_ads_consent_action() {}
|
||||
|
||||
-export async function open_ads_consent_preferences() {
|
||||
- return await invoke('plugin:ads|open_ads_consent_preferences')
|
||||
-}
|
||||
+export async function open_ads_consent_preferences() {}
|
||||
|
||||
-export async function record_ads_click() {
|
||||
- return await invoke('plugin:ads|record_ads_click')
|
||||
-}
|
||||
+export async function record_ads_click() {}
|
||||
|
||||
-export async function open_ads_link(path, origin) {
|
||||
- return await invoke('plugin:ads|open_link', { path, origin })
|
||||
-}
|
||||
+export async function open_ads_link() {}
|
||||
@@ -21,17 +21,25 @@ data when they are opened elsewhere.
|
||||
|
||||
Usernames are validated the way Mojang validates them: 3 to 16
|
||||
characters of letters, numbers and underscores.
|
||||
|
||||
Two places had to be opened up for any of this to be reachable. The
|
||||
account card was hidden until a Microsoft account had been added, which
|
||||
left offline sign-in unreachable for exactly the people who want it, and
|
||||
the "Minecraft required" modal - what you actually hit on pressing Play
|
||||
with no account - offered Microsoft and nothing else.
|
||||
---
|
||||
Cargo.lock | 1 +
|
||||
Cargo.toml | 1 +
|
||||
.../src/components/ui/AccountsCard.vue | 25 ++++
|
||||
apps/app-frontend/src/App.vue | 10 +-
|
||||
.../src/components/ui/AccountsCard.vue | 30 ++++
|
||||
.../src/components/ui/OfflineAccountModal.vue | 136 ++++++++++++++++++
|
||||
.../MinecraftRequiredModal.vue | 26 +++-
|
||||
apps/app-frontend/src/helpers/auth.js | 13 ++
|
||||
apps/app/src/api/auth.rs | 7 +
|
||||
packages/app-lib/Cargo.toml | 1 +
|
||||
packages/app-lib/src/api/minecraft_auth.rs | 39 +++++
|
||||
packages/app-lib/src/state/minecraft_auth.rs | 68 +++++++++
|
||||
9 files changed, 291 insertions(+)
|
||||
11 files changed, 327 insertions(+), 5 deletions(-)
|
||||
create mode 100644 apps/app-frontend/src/components/ui/OfflineAccountModal.vue
|
||||
|
||||
diff --git a/Cargo.lock b/Cargo.lock
|
||||
@@ -58,8 +66,29 @@ index a4a779c..a85f576 100644
|
||||
modrinth-content-management = { path = "packages/modrinth-content-management" }
|
||||
modrinth-log = { path = "packages/modrinth-log" }
|
||||
modrinth-util = { path = "packages/modrinth-util" }
|
||||
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
|
||||
index 6e7b27d..532f104 100644
|
||||
--- a/apps/app-frontend/src/App.vue
|
||||
+++ b/apps/app-frontend/src/App.vue
|
||||
@@ -2472,10 +2472,12 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
/>
|
||||
<div id="sidebar-teleport-target" class="sidebar-teleport-content"></div>
|
||||
<div class="sidebar-default-content" :class="{ 'sidebar-enabled': sidebarVisible }">
|
||||
- <div
|
||||
- v-show="hasLoggedIntoMinecraft"
|
||||
- class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
|
||||
- >
|
||||
+ <!--
|
||||
+ Upstream hides this until a Microsoft account has been added, which
|
||||
+ would leave the offline sign-in below it unreachable for exactly the
|
||||
+ people who want it.
|
||||
+ -->
|
||||
+ <div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
|
||||
<h3 class="text-base text-primary font-medium m-0">
|
||||
{{ formatMessage(messages.playingAs) }}
|
||||
</h3>
|
||||
diff --git a/apps/app-frontend/src/components/ui/AccountsCard.vue b/apps/app-frontend/src/components/ui/AccountsCard.vue
|
||||
index 70cc46e..e695a6d 100644
|
||||
index 70cc46e..ebc92c1 100644
|
||||
--- a/apps/app-frontend/src/components/ui/AccountsCard.vue
|
||||
+++ b/apps/app-frontend/src/components/ui/AccountsCard.vue
|
||||
@@ -9,6 +9,10 @@
|
||||
@@ -115,7 +144,21 @@ index 70cc46e..e695a6d 100644
|
||||
const loginDisabled = ref(false)
|
||||
const defaultUser = ref<string | undefined>()
|
||||
const equippedSkin = ref<Skin | null>(null)
|
||||
@@ -247,6 +262,12 @@ async function login() {
|
||||
@@ -187,8 +202,13 @@ function setLoginDisabled(value: boolean) {
|
||||
loginDisabled.value = value
|
||||
}
|
||||
|
||||
+function showOfflineAccountModal(event?: MouseEvent) {
|
||||
+ offlineAccountModal.value?.show(event)
|
||||
+}
|
||||
+
|
||||
defineExpose({
|
||||
refreshValues,
|
||||
+ showOfflineAccountModal,
|
||||
setEquippedSkin,
|
||||
setLoginDisabled,
|
||||
login,
|
||||
@@ -247,6 +267,12 @@ async function login() {
|
||||
loginDisabled.value = false
|
||||
}
|
||||
|
||||
@@ -128,7 +171,7 @@ index 70cc46e..e695a6d 100644
|
||||
async function logout(id: string) {
|
||||
await remove_user(id).catch(handleError)
|
||||
await refreshValues()
|
||||
@@ -273,6 +294,10 @@ const messages = defineMessages({
|
||||
@@ -273,6 +299,10 @@ const messages = defineMessages({
|
||||
id: 'minecraft-account.add-account',
|
||||
defaultMessage: 'Add account',
|
||||
},
|
||||
@@ -281,6 +324,64 @@ index 0000000..8300e28
|
||||
+ },
|
||||
+})
|
||||
+</script>
|
||||
diff --git a/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue b/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
|
||||
index 73293f5..bbdc517 100644
|
||||
--- a/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
|
||||
+++ b/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
|
||||
@@ -43,6 +43,16 @@
|
||||
{{ formatMessage(messages.signIn) }}
|
||||
</Button>
|
||||
</div>
|
||||
+ <div class="flex flex-col gap-2 border-0 border-t border-solid border-surface-4 pt-5">
|
||||
+ <p class="m-0 text-sm leading-tight text-secondary">
|
||||
+ {{ formatMessage(messages.offlineHint) }}
|
||||
+ </p>
|
||||
+ <Button @click="addOfflineAccount">
|
||||
+ <UserIcon />
|
||||
+ {{ formatMessage(messages.addOfflineAccount) }}
|
||||
+ </Button>
|
||||
+ </div>
|
||||
+
|
||||
<p class="m-0 text-center text-sm text-secondary">
|
||||
{{ formatMessage(messages.dontHaveAccount) }}
|
||||
<a
|
||||
@@ -57,7 +67,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
-import { MessagesSquareIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
+import { MessagesSquareIcon, SpinnerIcon, UserIcon } from '@modrinth/assets'
|
||||
import { Button, ButtonLink, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
|
||||
import { inject, type Ref, ref } from 'vue'
|
||||
|
||||
@@ -100,6 +110,15 @@ const messages = defineMessages({
|
||||
id: 'minecraft-required.get-minecraft',
|
||||
defaultMessage: 'Get Minecraft',
|
||||
},
|
||||
+ offlineHint: {
|
||||
+ id: 'minecraft-required.offline-hint',
|
||||
+ defaultMessage:
|
||||
+ 'Or play singleplayer and offline-mode servers without an account of any kind.',
|
||||
+ },
|
||||
+ addOfflineAccount: {
|
||||
+ id: 'minecraft-required.add-offline-account',
|
||||
+ defaultMessage: 'Add offline account',
|
||||
+ },
|
||||
})
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
@@ -127,6 +146,11 @@ async function signIn() {
|
||||
}
|
||||
}
|
||||
|
||||
+function addOfflineAccount(event: MouseEvent) {
|
||||
+ modal.value?.hide()
|
||||
+ accountsCard.value?.showOfflineAccountModal(event)
|
||||
+}
|
||||
+
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
diff --git a/apps/app-frontend/src/helpers/auth.js b/apps/app-frontend/src/helpers/auth.js
|
||||
index 94bd13e..cb7319a 100644
|
||||
--- a/apps/app-frontend/src/helpers/auth.js
|
||||
|
||||
@@ -32,18 +32,18 @@ bar button - so nothing there promises anything.
|
||||
4 files changed, 92 insertions(+), 9 deletions(-)
|
||||
|
||||
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
|
||||
index 33c29ce..3be5fe7 100644
|
||||
index 532f104..54e3912 100644
|
||||
--- a/apps/app-frontend/src/App.vue
|
||||
+++ b/apps/app-frontend/src/App.vue
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@modrinth/api-client'
|
||||
import {
|
||||
ArrowBigUpDashIcon,
|
||||
ArrowLeftRightIcon,
|
||||
+ ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
CompassIcon,
|
||||
@@ -257,12 +258,31 @@ const credentials = ref()
|
||||
@@ -254,12 +255,31 @@ const credentials = ref()
|
||||
const storedModrinthAccounts = ref([])
|
||||
let credentialsRefreshId = 0
|
||||
const sidebarToggled = ref(true)
|
||||
@@ -78,8 +78,8 @@ index 33c29ce..3be5fe7 100644
|
||||
const forceSidebar = computed(
|
||||
() =>
|
||||
route.path.startsWith('/browse') ||
|
||||
@@ -370,6 +390,21 @@ const hasPlus = computed(
|
||||
// ad cookie consent flow that only exists to serve it are both switched off.
|
||||
@@ -358,6 +378,21 @@ useQuery({
|
||||
// ad and the gradient being drawn above it.
|
||||
const showAd = computed(() => false)
|
||||
const adConsentAvailable = computed(() => false)
|
||||
+
|
||||
@@ -100,7 +100,7 @@ index 33c29ce..3be5fe7 100644
|
||||
providePageContext({
|
||||
hierarchicalSidebarAvailable: ref(true),
|
||||
showAds: showAd,
|
||||
@@ -600,6 +635,8 @@ const messages = defineMessages({
|
||||
@@ -588,6 +623,8 @@ const messages = defineMessages({
|
||||
goBack: { id: 'app.navigation.go-back', defaultMessage: 'Go back' },
|
||||
goForward: { id: 'app.navigation.go-forward', defaultMessage: 'Go forward' },
|
||||
nextImage: { id: 'app.navigation.next-image', defaultMessage: 'Next image' },
|
||||
@@ -109,7 +109,7 @@ index 33c29ce..3be5fe7 100644
|
||||
updateDownloadMissingVersion: {
|
||||
id: 'app.update.download-error.missing-version',
|
||||
defaultMessage: 'Failed to download update: no version available',
|
||||
@@ -2278,6 +2315,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
@@ -2252,6 +2289,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
<ImageIcon />
|
||||
</NavButton>
|
||||
<NavButton
|
||||
@@ -117,7 +117,7 @@ index 33c29ce..3be5fe7 100644
|
||||
v-tooltip.right="formatMessage(messages.modrinthHosting)"
|
||||
to="/hosting/manage"
|
||||
:is-primary="(r) => r.path === '/hosting/manage' || r.path === '/hosting/manage/'"
|
||||
@@ -2404,12 +2442,15 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
@@ -2378,12 +2416,15 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
</div>
|
||||
<section data-tauri-drag-region class="flex shrink-0 ml-auto items-center">
|
||||
<IconButton
|
||||
@@ -136,7 +136,7 @@ index 33c29ce..3be5fe7 100644
|
||||
>
|
||||
<RightArrowIcon />
|
||||
</IconButton>
|
||||
@@ -2528,10 +2569,18 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
@@ -2502,10 +2543,18 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
|
||||
/>
|
||||
<div v-if="news && news.length > 0" class="p-4 flex flex-col items-center">
|
||||
|
||||
@@ -27,8 +27,9 @@ Signing in on Ely.by's own page would be better than a password form,
|
||||
and needs an OAuth application registered with Ely.by; there is none for
|
||||
Modrinth Enhanced yet.
|
||||
---
|
||||
.../src/components/ui/AccountsCard.vue | 25 +-
|
||||
.../src/components/ui/AccountsCard.vue | 30 ++-
|
||||
.../src/components/ui/ElyAccountModal.vue | 166 ++++++++++++++
|
||||
.../MinecraftRequiredModal.vue | 29 ++-
|
||||
apps/app-frontend/src/helpers/auth.js | 15 ++
|
||||
apps/app/src/api/auth.rs | 10 +
|
||||
packages/app-lib/src/api/minecraft_auth.rs | 34 +++
|
||||
@@ -36,12 +37,12 @@ Modrinth Enhanced yet.
|
||||
packages/app-lib/src/state/minecraft_auth.rs | 213 +++++++++++++++++-
|
||||
packages/app-lib/src/util/authlib_injector.rs | 77 +++++++
|
||||
packages/app-lib/src/util/mod.rs | 1 +
|
||||
9 files changed, 551 insertions(+), 6 deletions(-)
|
||||
10 files changed, 578 insertions(+), 13 deletions(-)
|
||||
create mode 100644 apps/app-frontend/src/components/ui/ElyAccountModal.vue
|
||||
create mode 100644 packages/app-lib/src/util/authlib_injector.rs
|
||||
|
||||
diff --git a/apps/app-frontend/src/components/ui/AccountsCard.vue b/apps/app-frontend/src/components/ui/AccountsCard.vue
|
||||
index e695a6d..3f694db 100644
|
||||
index ebc92c1..35c21fa 100644
|
||||
--- a/apps/app-frontend/src/components/ui/AccountsCard.vue
|
||||
+++ b/apps/app-frontend/src/components/ui/AccountsCard.vue
|
||||
@@ -9,6 +9,10 @@
|
||||
@@ -100,7 +101,22 @@ index e695a6d..3f694db 100644
|
||||
const loginDisabled = ref(false)
|
||||
const defaultUser = ref<string | undefined>()
|
||||
const equippedSkin = ref<Skin | null>(null)
|
||||
@@ -262,8 +277,8 @@ async function login() {
|
||||
@@ -206,9 +221,14 @@ function showOfflineAccountModal(event?: MouseEvent) {
|
||||
offlineAccountModal.value?.show(event)
|
||||
}
|
||||
|
||||
+function showElyAccountModal(event?: MouseEvent) {
|
||||
+ elyAccountModal.value?.show(event)
|
||||
+}
|
||||
+
|
||||
defineExpose({
|
||||
refreshValues,
|
||||
showOfflineAccountModal,
|
||||
+ showElyAccountModal,
|
||||
setEquippedSkin,
|
||||
setLoginDisabled,
|
||||
login,
|
||||
@@ -267,8 +287,8 @@ async function login() {
|
||||
loginDisabled.value = false
|
||||
}
|
||||
|
||||
@@ -111,7 +127,7 @@ index e695a6d..3f694db 100644
|
||||
await refreshValues()
|
||||
emit('change')
|
||||
}
|
||||
@@ -298,6 +313,10 @@ const messages = defineMessages({
|
||||
@@ -303,6 +323,10 @@ const messages = defineMessages({
|
||||
id: 'minecraft-account.add-offline-account',
|
||||
defaultMessage: 'Add offline account',
|
||||
},
|
||||
@@ -294,6 +310,70 @@ index 0000000..d909ec3
|
||||
+ },
|
||||
+})
|
||||
+</script>
|
||||
diff --git a/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue b/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
|
||||
index bbdc517..ec939d7 100644
|
||||
--- a/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
|
||||
+++ b/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
|
||||
@@ -47,10 +47,16 @@
|
||||
<p class="m-0 text-sm leading-tight text-secondary">
|
||||
{{ formatMessage(messages.offlineHint) }}
|
||||
</p>
|
||||
- <Button @click="addOfflineAccount">
|
||||
- <UserIcon />
|
||||
- {{ formatMessage(messages.addOfflineAccount) }}
|
||||
- </Button>
|
||||
+ <div class="grid grid-cols-2 gap-2">
|
||||
+ <Button @click="addElyAccount">
|
||||
+ <KeyIcon />
|
||||
+ {{ formatMessage(messages.addElyAccount) }}
|
||||
+ </Button>
|
||||
+ <Button @click="addOfflineAccount">
|
||||
+ <UserIcon />
|
||||
+ {{ formatMessage(messages.addOfflineAccount) }}
|
||||
+ </Button>
|
||||
+ </div>
|
||||
</div>
|
||||
|
||||
<p class="m-0 text-center text-sm text-secondary">
|
||||
@@ -67,7 +73,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
-import { MessagesSquareIcon, SpinnerIcon, UserIcon } from '@modrinth/assets'
|
||||
+import { KeyIcon, MessagesSquareIcon, SpinnerIcon, UserIcon } from '@modrinth/assets'
|
||||
import { Button, ButtonLink, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
|
||||
import { inject, type Ref, ref } from 'vue'
|
||||
|
||||
@@ -113,11 +119,15 @@ const messages = defineMessages({
|
||||
offlineHint: {
|
||||
id: 'minecraft-required.offline-hint',
|
||||
defaultMessage:
|
||||
- 'Or play singleplayer and offline-mode servers without an account of any kind.',
|
||||
+ 'Or sign in with Ely.by, or play singleplayer and offline-mode servers without an account of any kind.',
|
||||
+ },
|
||||
+ addElyAccount: {
|
||||
+ id: 'minecraft-required.add-ely-account',
|
||||
+ defaultMessage: 'Ely.by account',
|
||||
},
|
||||
addOfflineAccount: {
|
||||
id: 'minecraft-required.add-offline-account',
|
||||
- defaultMessage: 'Add offline account',
|
||||
+ defaultMessage: 'Offline account',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -151,6 +161,11 @@ function addOfflineAccount(event: MouseEvent) {
|
||||
accountsCard.value?.showOfflineAccountModal(event)
|
||||
}
|
||||
|
||||
+function addElyAccount(event: MouseEvent) {
|
||||
+ modal.value?.hide()
|
||||
+ accountsCard.value?.showElyAccountModal(event)
|
||||
+}
|
||||
+
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
diff --git a/apps/app-frontend/src/helpers/auth.js b/apps/app-frontend/src/helpers/auth.js
|
||||
index cb7319a..57580ff 100644
|
||||
--- a/apps/app-frontend/src/helpers/auth.js
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
||||
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
|
||||
Date: Mon, 14 Sep 2026 14:43:17 +0200
|
||||
Subject: [PATCH] Sign in to Microsoft in the player's own browser
|
||||
|
||||
The launcher opened Microsoft's sign-in page in a webview of its own,
|
||||
which is the one place a player cannot use the tools they use everywhere
|
||||
else: no password manager, no autofill, no passkeys, and no way to tell
|
||||
by looking that the page is really Microsoft's.
|
||||
|
||||
The browser gets all of it. What it cannot do is hand the code back:
|
||||
this client id is Minecraft's own, its only registered redirect is a
|
||||
fixed page on login.live.com, and no loopback address is registered for
|
||||
it, so there is nothing for the launcher to listen on. The browser
|
||||
therefore lands on that page with the code in the address, and the
|
||||
player copies the address over - which is what the new modal asks for,
|
||||
and what `login_browser_finish` reads the code out of.
|
||||
|
||||
Every entry point goes through it, since they all end up at
|
||||
`AccountsCard.login()`. The webview is still one click away in that
|
||||
modal for anyone the browser does not work out for.
|
||||
---
|
||||
.../src/components/ui/AccountsCard.vue | 17 +-
|
||||
.../src/components/ui/MicrosoftLoginModal.vue | 213 ++++++++++++++++++
|
||||
.../MinecraftRequiredModal.vue | 29 +--
|
||||
apps/app-frontend/src/helpers/auth.js | 25 ++
|
||||
apps/app/src/api/auth.rs | 77 +++++++
|
||||
packages/app-lib/src/api/mod.rs | 12 +-
|
||||
6 files changed, 331 insertions(+), 42 deletions(-)
|
||||
create mode 100644 apps/app-frontend/src/components/ui/MicrosoftLoginModal.vue
|
||||
|
||||
diff --git a/apps/app-frontend/src/components/ui/AccountsCard.vue b/apps/app-frontend/src/components/ui/AccountsCard.vue
|
||||
index 35c21fa..c81603c 100644
|
||||
--- a/apps/app-frontend/src/components/ui/AccountsCard.vue
|
||||
+++ b/apps/app-frontend/src/components/ui/AccountsCard.vue
|
||||
@@ -105,6 +105,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
+ <MicrosoftLoginModal ref="microsoftLoginModal" @created="accountAdded" />
|
||||
<OfflineAccountModal ref="offlineAccountModal" @created="accountAdded" />
|
||||
<ElyAccountModal ref="elyAccountModal" @created="accountAdded" />
|
||||
</template>
|
||||
@@ -133,13 +134,12 @@ import type { Ref } from 'vue'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
|
||||
import ElyAccountModal from '@/components/ui/ElyAccountModal.vue'
|
||||
+import MicrosoftLoginModal from '@/components/ui/MicrosoftLoginModal.vue'
|
||||
import OfflineAccountModal from '@/components/ui/OfflineAccountModal.vue'
|
||||
import { useAppEvent } from '@/composables/use-app-event'
|
||||
-import { handleSevereError } from '@/composables/use-error.js'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import {
|
||||
get_default_user,
|
||||
- login as login_flow,
|
||||
remove_user,
|
||||
set_default_user,
|
||||
users,
|
||||
@@ -165,6 +165,7 @@ type MinecraftCredential = {
|
||||
const accounts: Ref<MinecraftCredential[]> = ref([])
|
||||
const offlineAccountModal = ref<InstanceType<typeof OfflineAccountModal>>()
|
||||
const elyAccountModal = ref<InstanceType<typeof ElyAccountModal>>()
|
||||
+const microsoftLoginModal = ref<InstanceType<typeof MicrosoftLoginModal>>()
|
||||
const loginDisabled = ref(false)
|
||||
const defaultUser = ref<string | undefined>()
|
||||
const equippedSkin = ref<Skin | null>(null)
|
||||
@@ -275,16 +276,8 @@ async function setAccount(account: MinecraftCredential) {
|
||||
emit('change')
|
||||
}
|
||||
|
||||
-async function login() {
|
||||
- loginDisabled.value = true
|
||||
- const loggedIn = await login_flow().catch(handleSevereError)
|
||||
-
|
||||
- if (loggedIn) {
|
||||
- await setAccount(loggedIn)
|
||||
- }
|
||||
-
|
||||
- trackEvent('AccountLogIn')
|
||||
- loginDisabled.value = false
|
||||
+function login(event?: MouseEvent) {
|
||||
+ microsoftLoginModal.value?.show(event)
|
||||
}
|
||||
|
||||
async function accountAdded() {
|
||||
diff --git a/apps/app-frontend/src/components/ui/MicrosoftLoginModal.vue b/apps/app-frontend/src/components/ui/MicrosoftLoginModal.vue
|
||||
new file mode 100644
|
||||
index 0000000..283b89c
|
||||
--- /dev/null
|
||||
+++ b/apps/app-frontend/src/components/ui/MicrosoftLoginModal.vue
|
||||
@@ -0,0 +1,213 @@
|
||||
+<template>
|
||||
+ <NewModal ref="modal" :header="formatMessage(messages.header)" max-width="520px" width="100%">
|
||||
+ <div class="flex flex-col gap-4">
|
||||
+ <p class="m-0 leading-tight text-secondary">
|
||||
+ {{ formatMessage(messages.description) }}
|
||||
+ </p>
|
||||
+
|
||||
+ <ol class="m-0 flex list-decimal flex-col gap-1 pl-5 leading-tight text-secondary">
|
||||
+ <li>{{ formatMessage(messages.stepSignIn) }}</li>
|
||||
+ <li>{{ formatMessage(messages.stepCopy) }}</li>
|
||||
+ </ol>
|
||||
+
|
||||
+ <form class="flex flex-col gap-2" @submit.prevent="submit">
|
||||
+ <label class="font-semibold text-contrast" for="microsoft-login-redirect">
|
||||
+ {{ formatMessage(messages.addressLabel) }}
|
||||
+ </label>
|
||||
+ <Input
|
||||
+ id="microsoft-login-redirect"
|
||||
+ v-model="redirect"
|
||||
+ :icon="LinkIcon"
|
||||
+ placeholder="https://login.live.com/oauth20_desktop.srf?code=..."
|
||||
+ :error="!!error"
|
||||
+ autocapitalize="none"
|
||||
+ autocorrect="off"
|
||||
+ :spellcheck="false"
|
||||
+ class="w-full"
|
||||
+ />
|
||||
+ <p v-if="error" class="m-0 text-sm leading-tight text-red">{{ error }}</p>
|
||||
+ </form>
|
||||
+
|
||||
+ <button
|
||||
+ class="button-base m-0 cursor-pointer border-0 bg-transparent p-0 text-left text-sm text-secondary underline"
|
||||
+ type="button"
|
||||
+ :disabled="submitting"
|
||||
+ @click="useBuiltInWindow"
|
||||
+ >
|
||||
+ {{ formatMessage(messages.useBuiltInWindow) }}
|
||||
+ </button>
|
||||
+ </div>
|
||||
+
|
||||
+ <template #actions>
|
||||
+ <div class="flex justify-end gap-2">
|
||||
+ <Button native-type="button" @click="modal?.hide()">
|
||||
+ <XIcon aria-hidden="true" />
|
||||
+ {{ formatMessage(commonMessages.cancelButton) }}
|
||||
+ </Button>
|
||||
+ <Button
|
||||
+ native-type="button"
|
||||
+ :disabled="submitting || opening"
|
||||
+ @click="openBrowser"
|
||||
+ >
|
||||
+ <SpinnerIcon v-if="opening" aria-hidden="true" class="animate-spin" />
|
||||
+ <ExternalIcon v-else aria-hidden="true" />
|
||||
+ {{ formatMessage(messages.openAgain) }}
|
||||
+ </Button>
|
||||
+ <Button
|
||||
+ type="colored"
|
||||
+ color="brand"
|
||||
+ native-type="button"
|
||||
+ :disabled="submitting || !redirect.trim()"
|
||||
+ @click="submit"
|
||||
+ >
|
||||
+ <SpinnerIcon v-if="submitting" aria-hidden="true" class="animate-spin" />
|
||||
+ <LogInIcon v-else aria-hidden="true" />
|
||||
+ {{ formatMessage(messages.finish) }}
|
||||
+ </Button>
|
||||
+ </div>
|
||||
+ </template>
|
||||
+ </NewModal>
|
||||
+</template>
|
||||
+
|
||||
+<script setup lang="ts">
|
||||
+import { ExternalIcon, LinkIcon, LogInIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
+import {
|
||||
+ Button,
|
||||
+ commonMessages,
|
||||
+ defineMessages,
|
||||
+ Input,
|
||||
+ NewModal,
|
||||
+ useVIntl,
|
||||
+} from '@modrinth/ui'
|
||||
+import { ref } from 'vue'
|
||||
+
|
||||
+import { handleSevereError } from '@/composables/use-error.js'
|
||||
+import { login as builtInLogin, login_browser_begin, login_browser_finish } from '@/helpers/auth'
|
||||
+
|
||||
+const { formatMessage } = useVIntl()
|
||||
+
|
||||
+const emit = defineEmits<{
|
||||
+ created: [account: unknown]
|
||||
+}>()
|
||||
+
|
||||
+const modal = ref<InstanceType<typeof NewModal>>()
|
||||
+const redirect = ref('')
|
||||
+const error = ref('')
|
||||
+const opening = ref(false)
|
||||
+const submitting = ref(false)
|
||||
+let flow: unknown = null
|
||||
+
|
||||
+async function show(event?: MouseEvent) {
|
||||
+ redirect.value = ''
|
||||
+ error.value = ''
|
||||
+ submitting.value = false
|
||||
+ modal.value?.show(event)
|
||||
+ await openBrowser()
|
||||
+}
|
||||
+
|
||||
+async function openBrowser() {
|
||||
+ if (opening.value) return
|
||||
+
|
||||
+ opening.value = true
|
||||
+ error.value = ''
|
||||
+
|
||||
+ try {
|
||||
+ flow = await login_browser_begin()
|
||||
+ } catch (e) {
|
||||
+ flow = null
|
||||
+ error.value = messageOf(e, messages.openError)
|
||||
+ } finally {
|
||||
+ opening.value = false
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+async function submit() {
|
||||
+ if (submitting.value) return
|
||||
+
|
||||
+ const value = redirect.value.trim()
|
||||
+ if (!value) return
|
||||
+
|
||||
+ if (!flow) {
|
||||
+ error.value = formatMessage(messages.noFlow)
|
||||
+ return
|
||||
+ }
|
||||
+
|
||||
+ submitting.value = true
|
||||
+ error.value = ''
|
||||
+
|
||||
+ try {
|
||||
+ const account = await login_browser_finish(value, flow)
|
||||
+ redirect.value = ''
|
||||
+ modal.value?.hide()
|
||||
+ emit('created', account)
|
||||
+ } catch (e) {
|
||||
+ error.value = messageOf(e, messages.genericError)
|
||||
+ } finally {
|
||||
+ submitting.value = false
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+// The window the launcher opens itself, for when the browser will not do.
|
||||
+async function useBuiltInWindow() {
|
||||
+ modal.value?.hide()
|
||||
+
|
||||
+ const account = await builtInLogin().catch(handleSevereError)
|
||||
+ if (account) emit('created', account)
|
||||
+}
|
||||
+
|
||||
+function messageOf(e: unknown, fallback: { id: string; defaultMessage: string }) {
|
||||
+ if (typeof e === 'string') return e
|
||||
+ return (e as Error)?.message ?? formatMessage(fallback)
|
||||
+}
|
||||
+
|
||||
+defineExpose({ show })
|
||||
+
|
||||
+const messages = defineMessages({
|
||||
+ header: {
|
||||
+ id: 'app.microsoft-login.header',
|
||||
+ defaultMessage: 'Sign in to Microsoft',
|
||||
+ },
|
||||
+ description: {
|
||||
+ id: 'app.microsoft-login.description',
|
||||
+ defaultMessage:
|
||||
+ 'Your browser has opened, so your password manager and passkeys work as they do everywhere else. Microsoft cannot hand the result back to the launcher on its own, so it has to be copied over.',
|
||||
+ },
|
||||
+ stepSignIn: {
|
||||
+ id: 'app.microsoft-login.step-sign-in',
|
||||
+ defaultMessage: 'Sign in to Microsoft in the browser.',
|
||||
+ },
|
||||
+ stepCopy: {
|
||||
+ id: 'app.microsoft-login.step-copy',
|
||||
+ defaultMessage:
|
||||
+ 'You will land on a blank page. Copy its whole address from the address bar and paste it below.',
|
||||
+ },
|
||||
+ addressLabel: {
|
||||
+ id: 'app.microsoft-login.address-label',
|
||||
+ defaultMessage: 'Address the browser finished at',
|
||||
+ },
|
||||
+ openAgain: {
|
||||
+ id: 'app.microsoft-login.open-again',
|
||||
+ defaultMessage: 'Open browser again',
|
||||
+ },
|
||||
+ finish: {
|
||||
+ id: 'app.microsoft-login.finish',
|
||||
+ defaultMessage: 'Sign in',
|
||||
+ },
|
||||
+ useBuiltInWindow: {
|
||||
+ id: 'app.microsoft-login.use-built-in-window',
|
||||
+ defaultMessage: 'Trouble with the browser? Sign in in a window here instead.',
|
||||
+ },
|
||||
+ openError: {
|
||||
+ id: 'app.microsoft-login.open-error',
|
||||
+ defaultMessage: 'Could not open a browser to sign in with.',
|
||||
+ },
|
||||
+ noFlow: {
|
||||
+ id: 'app.microsoft-login.no-flow',
|
||||
+ defaultMessage: 'Open the browser again before signing in.',
|
||||
+ },
|
||||
+ genericError: {
|
||||
+ id: 'app.microsoft-login.generic-error',
|
||||
+ defaultMessage: 'Could not sign in to Microsoft.',
|
||||
+ },
|
||||
+})
|
||||
+</script>
|
||||
diff --git a/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue b/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
|
||||
index ec939d7..b1e5358 100644
|
||||
--- a/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
|
||||
+++ b/apps/app-frontend/src/components/ui/minecraft-required-modal/MinecraftRequiredModal.vue
|
||||
@@ -25,10 +25,8 @@
|
||||
<MessagesSquareIcon />
|
||||
{{ formatMessage(messages.getSupport) }}
|
||||
</ButtonLink>
|
||||
- <Button type="colored" color="brand" :disabled="loadingSignIn" @click="signIn">
|
||||
- <SpinnerIcon v-if="loadingSignIn" class="animate-spin" />
|
||||
+ <Button type="colored" color="brand" @click="signIn">
|
||||
<svg
|
||||
- v-else
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -73,15 +71,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
-import { KeyIcon, MessagesSquareIcon, SpinnerIcon, UserIcon } from '@modrinth/assets'
|
||||
+import { KeyIcon, MessagesSquareIcon, UserIcon } from '@modrinth/assets'
|
||||
import { Button, ButtonLink, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
|
||||
import { inject, type Ref, ref } from 'vue'
|
||||
|
||||
import steveImage from '@/assets/steve-look-up-left.webp'
|
||||
import type AccountsCard from '@/components/ui/AccountsCard.vue'
|
||||
-import { handleSevereError } from '@/composables/use-error.js'
|
||||
-import { trackEvent } from '@/helpers/analytics'
|
||||
-import { login as loginFlow, set_default_user } from '@/helpers/auth.js'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const accountsCard = inject('accountsCard') as Ref<InstanceType<typeof AccountsCard> | null>
|
||||
@@ -132,28 +127,14 @@ const messages = defineMessages({
|
||||
})
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
-const loadingSignIn = ref(false)
|
||||
|
||||
function show() {
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
-async function signIn() {
|
||||
- loadingSignIn.value = true
|
||||
-
|
||||
- try {
|
||||
- const loggedIn = await loginFlow()
|
||||
- if (!loggedIn) return
|
||||
-
|
||||
- await set_default_user(loggedIn.profile.id)
|
||||
- await accountsCard.value?.refreshValues()
|
||||
- await trackEvent('AccountLogIn', { source: 'MinecraftRequiredModal' })
|
||||
- modal.value?.hide()
|
||||
- } catch (error) {
|
||||
- handleSevereError(error)
|
||||
- } finally {
|
||||
- loadingSignIn.value = false
|
||||
- }
|
||||
+function signIn(event: MouseEvent) {
|
||||
+ modal.value?.hide()
|
||||
+ accountsCard.value?.login(event)
|
||||
}
|
||||
|
||||
function addOfflineAccount(event: MouseEvent) {
|
||||
diff --git a/apps/app-frontend/src/helpers/auth.js b/apps/app-frontend/src/helpers/auth.js
|
||||
index 57580ff..c25af95 100644
|
||||
--- a/apps/app-frontend/src/helpers/auth.js
|
||||
+++ b/apps/app-frontend/src/helpers/auth.js
|
||||
@@ -33,6 +33,31 @@ export async function login() {
|
||||
return await invoke('plugin:auth|login')
|
||||
}
|
||||
|
||||
+/**
|
||||
+ * Starts a Microsoft sign-in in the default browser.
|
||||
+ *
|
||||
+ * Opens the browser and returns the flow to hand back to
|
||||
+ * {@link login_browser_finish} together with the address the browser ends up
|
||||
+ * at. Unlike the window the launcher opens itself, the browser has the
|
||||
+ * player's password manager, autofill and passkeys.
|
||||
+ *
|
||||
+ * @returns {Promise<object>} the login flow to finish with
|
||||
+ */
|
||||
+export async function login_browser_begin() {
|
||||
+ return await invoke('plugin:auth|login_browser_begin')
|
||||
+}
|
||||
+
|
||||
+/**
|
||||
+ * Finishes a browser sign-in.
|
||||
+ *
|
||||
+ * @param {string} redirect the address the browser finished at, or the code in it
|
||||
+ * @param {object} flow the flow returned by {@link login_browser_begin}
|
||||
+ * @returns {Promise<Credential>}
|
||||
+ */
|
||||
+export async function login_browser_finish(redirect, flow) {
|
||||
+ return await invoke('plugin:auth|login_browser_finish', { redirect, flow })
|
||||
+}
|
||||
+
|
||||
/**
|
||||
* Adds an offline account with the given username and makes it the active one.
|
||||
*
|
||||
diff --git a/apps/app/src/api/auth.rs b/apps/app/src/api/auth.rs
|
||||
index dea07b2..5c3648c 100644
|
||||
--- a/apps/app/src/api/auth.rs
|
||||
+++ b/apps/app/src/api/auth.rs
|
||||
@@ -2,6 +2,7 @@ use crate::api::Result;
|
||||
use chrono::{Duration, Utc};
|
||||
use tauri::plugin::TauriPlugin;
|
||||
use tauri::{Manager, Runtime, UserAttentionType};
|
||||
+use tauri_plugin_opener::OpenerExt;
|
||||
use theseus::prelude::*;
|
||||
|
||||
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
@@ -9,6 +10,8 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
check_reachable,
|
||||
login,
|
||||
+ login_browser_begin,
|
||||
+ login_browser_finish,
|
||||
login_offline,
|
||||
login_ely,
|
||||
remove_user,
|
||||
@@ -88,6 +91,80 @@ pub async fn login<R: Runtime>(
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
+/// Starts a Microsoft sign-in in the player's own browser.
|
||||
+///
|
||||
+/// The window the launcher opens for this has no password manager, no
|
||||
+/// autofill and no passkeys, which makes the launcher the one place a player
|
||||
+/// cannot sign in the way they sign in everywhere else. Their own browser has
|
||||
+/// all of it.
|
||||
+///
|
||||
+/// What it cannot do is hand the code back. This client id's redirect is a
|
||||
+/// fixed page on login.live.com - no loopback address is registered for it, so
|
||||
+/// there is nothing for the launcher to listen on - and the browser lands
|
||||
+/// there with the code in the address. The player copies that address over,
|
||||
+/// which is what [`login_browser_finish`] takes.
|
||||
+#[tauri::command]
|
||||
+pub async fn login_browser_begin<R: Runtime>(
|
||||
+ app: tauri::AppHandle<R>,
|
||||
+) -> Result<MinecraftLoginFlow> {
|
||||
+ let flow = minecraft_auth::begin_login().await?;
|
||||
+
|
||||
+ app.opener()
|
||||
+ .open_url(&flow.auth_request_uri, None::<String>)
|
||||
+ .map_err(|error| {
|
||||
+ theseus::ErrorKind::OtherError(format!(
|
||||
+ "Could not open a browser to sign in with: {error}"
|
||||
+ ))
|
||||
+ .as_error()
|
||||
+ })?;
|
||||
+
|
||||
+ Ok(flow)
|
||||
+}
|
||||
+
|
||||
+/// Finishes a browser sign-in with the address the browser ended up at.
|
||||
+///
|
||||
+/// Takes the whole address, since that is what a player can select and copy,
|
||||
+/// and a bare code as well, since that is what someone who knows what they are
|
||||
+/// looking at will paste.
|
||||
+#[tauri::command]
|
||||
+pub async fn login_browser_finish(
|
||||
+ redirect: String,
|
||||
+ flow: MinecraftLoginFlow,
|
||||
+) -> Result<Credentials> {
|
||||
+ let redirect = redirect.trim();
|
||||
+
|
||||
+ let code = url::Url::parse(redirect)
|
||||
+ .ok()
|
||||
+ .and_then(|url| {
|
||||
+ url.query_pairs()
|
||||
+ .find(|(key, _)| key == "code")
|
||||
+ .map(|(_, code)| code.into_owned())
|
||||
+ })
|
||||
+ .or_else(|| {
|
||||
+ // Not a URL, so treat it as the code itself - but only if it looks
|
||||
+ // like one, rather than sending whatever was in the clipboard.
|
||||
+ (!redirect.is_empty()
|
||||
+ && redirect.len() < 2048
|
||||
+ && redirect.bytes().all(|byte| {
|
||||
+ byte.is_ascii_alphanumeric()
|
||||
+ || byte == b'.'
|
||||
+ || byte == b'-'
|
||||
+ || byte == b'_'
|
||||
+ }))
|
||||
+ .then(|| redirect.to_owned())
|
||||
+ })
|
||||
+ .ok_or_else(|| {
|
||||
+ theseus::ErrorKind::InputError(
|
||||
+ "That does not look like the address the browser finished at. \
|
||||
+ Copy the whole address out of the address bar."
|
||||
+ .to_string(),
|
||||
+ )
|
||||
+ .as_error()
|
||||
+ })?;
|
||||
+
|
||||
+ Ok(minecraft_auth::finish_login(&code, flow).await?)
|
||||
+}
|
||||
+
|
||||
/// Adds an offline account with the given username and makes it active.
|
||||
#[tauri::command]
|
||||
pub async fn login_offline(username: String) -> Result<Credentials> {
|
||||
diff --git a/packages/app-lib/src/api/mod.rs b/packages/app-lib/src/api/mod.rs
|
||||
index 320112f..743d5e7 100644
|
||||
--- a/packages/app-lib/src/api/mod.rs
|
||||
+++ b/packages/app-lib/src/api/mod.rs
|
||||
@@ -28,12 +28,12 @@ pub mod data {
|
||||
InstanceInstallCandidate, InstanceInstallTarget,
|
||||
InstanceLaunchOverridesPatch, InstanceLink, InstanceMetadata,
|
||||
InstanceSyncedOption, InstanceSyncedOptions, InstanceTabVisibility,
|
||||
- JavaVersion, LinkedModpackInfo, MemorySettings, ModLoader,
|
||||
- ModrinthCredentials, OnboardingChecklist, Organization, OwnerType,
|
||||
- ProcessMetadata, Project, ProjectType, ProjectV3, SearchResult,
|
||||
- SearchResults, SearchResultsV3, Settings, SharedInstanceAttachment,
|
||||
- SharedInstanceRole, TeamMember, Theme, User, UserFriend, Version,
|
||||
- WindowSize,
|
||||
+ JavaVersion, LinkedModpackInfo, MemorySettings, MinecraftLoginFlow,
|
||||
+ ModLoader, ModrinthCredentials, OnboardingChecklist, Organization,
|
||||
+ OwnerType, ProcessMetadata, Project, ProjectType, ProjectV3,
|
||||
+ SearchResult, SearchResults, SearchResultsV3, Settings,
|
||||
+ SharedInstanceAttachment, SharedInstanceRole, TeamMember, Theme, User,
|
||||
+ UserFriend, Version, WindowSize,
|
||||
};
|
||||
pub use ariadne::users::UserStatus;
|
||||
pub use modrinth_content_management::{
|
||||
Reference in new issue
Block a user