feat: initial implementation of new sign-in oauth

This commit is contained in:
tdgao
2026-04-13 15:05:30 -06:00
parent c0fd7bebbd
commit c1696e0d9a
6 changed files with 205 additions and 29 deletions
@@ -2,7 +2,7 @@
<div class="create-account-card"> <div class="create-account-card">
<h1 class="create-account-title">{{ formatMessage(messages.title) }}</h1> <h1 class="create-account-title">{{ formatMessage(messages.title) }}</h1>
<section class="create-account-section"> <section v-if="requiresDob" class="create-account-section">
<label class="create-account-label" for="create-account-dob"> <label class="create-account-label" for="create-account-dob">
{{ formatMessage(messages.dateOfBirthLabel) }} {{ formatMessage(messages.dateOfBirthLabel) }}
</label> </label>
@@ -98,6 +98,10 @@ const props = defineProps({
type: Object, type: Object,
default: null, default: null,
}, },
requiresDob: {
type: Boolean,
default: true,
},
sourceCodeUrl: { sourceCodeUrl: {
type: String, type: String,
default: 'https://github.com/modrinth/labrinth/blob/main/apps/labrinth/src/routes/internal/flows.rs', default: 'https://github.com/modrinth/labrinth/blob/main/apps/labrinth/src/routes/internal/flows.rs',
+1 -11
View File
@@ -58,20 +58,10 @@ export const initAuth = async (oldToken: string | null | undefined = null) => {
authCookie.value = oldToken authCookie.value = oldToken
} }
if (authCode && !route.fullPath.includes('new_account=true')) { if (authCode) {
authCookie.value = authCode authCookie.value = authCode
} }
if (route.fullPath.includes('new_account=true') && route.path !== '/auth/welcome') {
const redirect = route.path.startsWith('/auth/') ? null : route.fullPath
await navigateTo(
`/auth/welcome?authToken=${authCode ?? ''}${
redirect ? `&redirect=${encodeURIComponent(redirect)}` : ''
}`,
)
}
if (authCookie.value) { if (authCookie.value) {
auth.token = authCookie.value auth.token = authCookie.value
@@ -0,0 +1,156 @@
<template>
<div v-if="subtleLauncherRedirectUri">
<iframe
:src="subtleLauncherRedirectUri"
class="fixed left-0 top-0 z-[9999] m-0 h-full w-full border-0 p-0"
></iframe>
</div>
<CreateAccountView
v-else
v-model:date-of-birth="dateOfBirth"
v-model:username="username"
v-model:token="token"
v-model:subscribe="subscribe"
:globals="globals"
:requires-dob="requiresDob"
:on-complete-sign-up="completeOAuthSignUp"
:on-set-captcha-ref="setCaptchaRef"
/>
</template>
<script setup>
import { commonMessages, defineMessages, injectModrinthClient, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import CreateAccountView from '@/components/ui/auth/CreateAccount.vue'
import { getLauncherRedirectUrl } from '@/composables/auth.ts'
const client = injectModrinthClient()
const queryClient = useQueryClient()
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const route = useNativeRoute()
const auth = await useAuth()
const messages = defineMessages({
createAccountTitle: {
id: 'auth.create-account.page-title',
defaultMessage: 'Create Account',
},
})
useHead({
title() {
return `${formatMessage(messages.createAccountTitle)} - Modrinth`
},
})
const requiresDob = computed(() => {
const raw = route.query.requires_dob
const value = Array.isArray(raw) ? raw[0] : raw
if (!value) {
return false
}
return value === 'true' || value === '1'
})
const oauthFlowState = computed(() => {
const state = route.query.state
const value = Array.isArray(state) ? state[0] : state
return typeof value === 'string' ? value : ''
})
const defaultUsername = computed(() => {
const queryUsername = route.query.username
const value = Array.isArray(queryUsername) ? queryUsername[0] : queryUsername
return typeof value === 'string' && value.length > 0 ? value : 'user'
})
const dateOfBirth = ref('')
const username = ref(defaultUsername.value)
const token = ref('')
const subscribe = ref(false)
const subtleLauncherRedirectUri = ref()
const captcha = ref()
const setCaptchaRef = (captchaRef) => {
captcha.value = captchaRef
}
const { data: globals } = useQuery({
queryKey: ['auth-globals'],
queryFn: async () => {
try {
return await client.labrinth.globals_internal.get()
} catch (err) {
console.error('Error fetching globals:', err)
return { captcha_enabled: true, tax_compliance_thresholds: {} }
}
},
})
async function completeOAuthSignUp() {
startLoading()
try {
if (!oauthFlowState.value) {
throw new Error('Missing OAuth flow state')
}
const res = await client.labrinth.auth_v2.createOAuthAccount({
username: username.value.trim() || defaultUsername.value,
state: oauthFlowState.value,
challenge: token.value,
sign_up_newsletter: subscribe.value,
})
await finishSignIn(res.session)
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
type: 'error',
})
captcha.value?.reset()
}
stopLoading()
}
async function finishSignIn(sessionToken) {
if (route.query.launcher) {
let token = sessionToken
if (!token) {
token = auth.value.token
}
const redirectUrl = `${getLauncherRedirectUrl(route)}/?code=${token}`
if (redirectUrl.startsWith('https://launcher-files.modrinth.com/')) {
await navigateTo(redirectUrl, {
external: true,
})
} else {
subtleLauncherRedirectUri.value = redirectUrl
}
return
}
if (sessionToken) {
await useAuth(sessionToken)
await useUser()
queryClient.clear()
}
if (route.query.redirect) {
const redirect = decodeURIComponent(route.query.redirect)
await navigateTo(redirect, {
replace: true,
})
} else {
await navigateTo('/dashboard')
}
}
</script>
+15 -17
View File
@@ -1,20 +1,18 @@
<template> <template>
<div> <SignInView
<SignInView v-model:email="email"
v-model:email="email" v-model:password="password"
v-model:password="password" v-model:token="token"
v-model:token="token" v-model:two-factor-code="twoFactorCode"
v-model:two-factor-code="twoFactorCode" :subtle-launcher-redirect-uri="subtleLauncherRedirectUri"
:subtle-launcher-redirect-uri="subtleLauncherRedirectUri" :flow="flow"
:flow="flow" :redirect-target="redirectTarget"
:redirect-target="redirectTarget" :route-query="route.query"
:route-query="route.query" :globals="globals"
:globals="globals" :on-password-sign-in="beginPasswordSignIn"
:on-password-sign-in="beginPasswordSignIn" :on-two-factor-sign-in="begin2FASignIn"
:on-two-factor-sign-in="begin2FASignIn" :on-set-captcha-ref="setCaptchaRef"
:on-set-captcha-ref="setCaptchaRef" />
/>
</div>
</template> </template>
<script setup> <script setup>
@@ -54,7 +52,7 @@ const route = useNativeRoute()
const redirectTarget = route.query.redirect || '' const redirectTarget = route.query.redirect || ''
const subtleLauncherRedirectUri = ref() const subtleLauncherRedirectUri = ref()
if (route.query.code && !route.fullPath.includes('new_account=true')) { if (route.query.code) {
await finishSignIn() await finishSignIn()
} }
@@ -57,6 +57,23 @@ export class LabrinthAuthV2Module extends AbstractModule {
}) })
} }
/**
* Create a new account from an OAuth callback flow state
*
* @param data - OAuth account creation data
* @returns Promise resolving to a session response
*/
public async createOAuthAccount(
data: Labrinth.Auth.v2.CreateOAuthAccountRequest,
): Promise<Labrinth.Auth.v2.CreateOAuthAccountResponse> {
return this.client.request<Labrinth.Auth.v2.CreateOAuthAccountResponse>(`/auth/create/oauth`, {
api: 'labrinth',
version: 2,
method: 'POST',
body: data,
})
}
/** /**
* Begin a password reset flow by sending a recovery email * Begin a password reset flow by sending a recovery email
* *
@@ -278,6 +278,17 @@ export namespace Labrinth {
session: string session: string
} }
export type CreateOAuthAccountRequest = {
username: string
state: string
challenge: string
sign_up_newsletter: boolean
}
export type CreateOAuthAccountResponse = {
session: string
}
export type ResetPasswordRequest = { export type ResetPasswordRequest = {
username: string username: string
challenge: string challenge: string