feat: update auth flow (#5790)

* Backend routes for choosing username in OAuth flow

* fix up oauth flow routes

* improve URL-related OAuth code

* Use user-provided callback addr instead of SELF_ADDR

* Revert "Use user-provided callback addr instead of SELF_ADDR"

This reverts commit 7ea0635d86.

* fix flow

* fix: backend response for create oauth account

* feat: new auth flow (#5840)

* update auth with new designs

* refactor: auth.js to auth.ts

* refactor: componentize auth pages

* fix: auth pages height

* feat: initial implementation of new sign-in oauth

* fix create account flow

* fix checkbox

* remove hard coded username

* implement create user validation endpoint and add more specific error responses

* feat: implement under 13 DOB guard and email/password validation route

* fix: TOCTOU issue

* refactor: pnpm prepr

* fix: make sure staging uses staging

* fix: hcaptcha styles

* fix: copy

* remove: auth/welcome page as its no longer used

* refactor: bring root page card styles into individual components and use tailwind

* fix: account settings modals to use new modal and fix lots of bad styles

* refactor: pnpm prepr

* feat: implement last signed in indicator

* fix: append number when generated name from email is taken

* refactor: pnpm prepr

* fix: last sign in badge color

* fix: qa issues

* refactor: pnpm prepr

* fix: hover effect on native date picker

* chore: temp staging undo

* Revert "chore: temp staging undo"

This reverts commit cad6bd4f92.

* feat: handle app create account

* fix: last signed in style

* fix: add initOnMounted for SSR race

* refactor: use typescript

* refactor: pnpm prepr

* refactor: use typescript for reset-password

* refactor: convert verify-email to use typescript

* refactor: convert authorize.vue to use typescript

* fix: authorize.vue error states

* feat: small style updates

* feat: implement date picker component

* feat: improve UX and styles for range select

* refactor: pnpm prepr

* fix: range select border styles

* feat: implement date picker component in create account

* feat: implement preserve date for date picker

* update rust toolchain

* increase recursion limit

* fix: date picker can be null

* fix: calculate age based on user's timezone

* fix: number input icons color

* fix: date picker icons

* feat: improve styles

* fix: add width on date

* fix: hover color bad on number input

* fix lints

* feat: add default date open view

* fmt

* fix: account.vue

* fix: remove default date to open 13 years ago

* fix: edit copy on info banner

* fix: cannot hover over project card tooltip items (#6071)

fix: cannot hover over project cards

* feat: improve add dependency flow (#6075)

* fix: shadow on nav

* feat: improve add dependency flow

* feat: update suggested dependency style

* feat: update dependency rows to use version number and update styles

* feat: implement combobox select searched text on focus

* feat: add Tabs.vue

* feat: update nav tabs to use tabs

* feat: improve project search dropdown

* fix: dependency search not clearing inbound query

* fix: combobox no options open state bug

* feat: improve dependency project and version search

* fix: open modrinth project links in the app (#6072)

* pin tanstack versions + set pnpm min age to 7 days

* squash commits

* fix: 2 factor auth enter code screen styles

* update copy

* update copy

* improve reset password

* feat: update sign in screen

* fix: unused import

* Merge branch 'main' into boris/dev-908-backend-changes

* Revert "Merge branch 'main' into boris/dev-908-backend-changes"

This reverts commit b9b03796e3.

* fix: add stroke

* feat: add passkey support (#6375)

* feat: add passkey backend

* feat: passkey frontend

* invalidate sessions on compromised passkey

* chore: run sqlx prepare

* fix: make passkey button use both collumns to prevent empty space

* fix: correctly verify max passkeys in finish route

* fix: use structs for response

* fix: add rp name default

* style: use web::Json

* fmt

* feat: improve manage passkeys UI

* fix copy

* pnpm prepr

---------

Co-authored-by: tdgao <mr.trumgao@gmail.com>
Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
Co-authored-by: Michael H. <michael@iptables.sh>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
Co-authored-by: Calum H. <calum@modrinth.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
Co-authored-by: DeDiamondPro <67508414+DeDiamondPro@users.noreply.github.com>
This commit is contained in:
aecsocket
2026-06-25 20:36:19 +00:00
committed by GitHub
co-authored by tdgao Truman Gao Michael H. Calum H. Calum H. Prospector DeDiamondPro
parent 6fc741f7c0
commit ef4044534f
125 changed files with 5170 additions and 2828 deletions
+127 -178
View File
@@ -1,29 +1,33 @@
<template>
<div>
<div v-if="error" class="oauth-items">
<div>
<h1>{{ formatMessage(commonMessages.errorLabel) }}</h1>
</div>
<p>
<span>{{ error.data?.error }}: </span>
{{ error.data?.description }}
<div
class="universal-card flex w-full max-w-[27rem] flex-col gap-6 border border-solid border-surface-5 !p-6"
>
<div v-if="authorizationError" class="flex flex-col gap-2.5">
<h1 class="m-0 mx-auto text-xl font-semibold text-contrast">
{{ formatMessage(messages.errorTitle) }}
</h1>
<p class="m-0">
<span>{{ authorizationError.error }}: </span>
{{ authorizationError.description }}
</p>
</div>
<div v-else-if="app && createdBy && authorizationData" class="oauth-items">
<div class="connected-items">
<div class="profile-pics">
<div v-else-if="app && createdBy && authorizationData" class="flex flex-col gap-6">
<div class="mt-4 flex items-center justify-center">
<div class="flex w-full flex-row items-center justify-evenly">
<Avatar size="md" :src="app.icon_url" />
<!-- <img class="profile-pic" :src="app.icon_url" alt="User profile picture" /> -->
<div class="connection-indicator"></div>
<Avatar size="md" circle :src="auth.user.avatar_url" />
<div class="flex select-none items-center justify-center text-[2rem] text-primary"></div>
<Avatar size="md" circle :src="auth.user?.avatar_url" />
<!-- <img class="profile-pic" :src="auth.user.avatar_url" alt="User profile picture" /> -->
</div>
</div>
<div class="title">
<h1>{{ formatMessage(messages.title, { appName: app.name }) }}</h1>
<div class="mx-auto">
<h1 class="mb-0 ml-0 mr-0 mt-0 text-xl text-contrast">
{{ formatMessage(messages.title, { appName: app.name }) }}
</h1>
</div>
<div class="auth-info">
<div class="scope-heading">
<div class="flex flex-col gap-3">
<div class="mb-3">
<IntlFormatted
:message-id="messages.appInfo"
:values="{
@@ -43,11 +47,11 @@
</template>
</IntlFormatted>
</div>
<div class="scope-items">
<div class="flex flex-col gap-3">
<div v-for="scopeItem in scopeDefinitions" :key="scopeItem">
<div class="scope-item">
<div class="scope-icon">
<CheckIcon />
<div class="flex flex-row items-center gap-2">
<div class="grid h-5 min-h-5 w-5 min-w-5 place-content-center rounded-full bg-green">
<CheckIcon class="text-sm text-black" />
</div>
{{ scopeItem }}
</div>
@@ -68,11 +72,11 @@
</button>
</ButtonStyled>
</div>
<div class="redirection-notice">
<p class="redirect-instructions">
<div class="flex flex-col gap-2 text-center">
<p class="m-0 text-sm">
<IntlFormatted :message-id="messages.redirectUrl" :values="{ url: redirectUri }">
<template #redirect-url="{ children }">
<span class="redirect-url">
<span class="font-bold">
<component :is="() => normalizeChildren(children)" />
</span>
</template>
@@ -83,7 +87,7 @@
</div>
</template>
<script setup>
<script setup lang="ts">
import { CheckIcon, XIcon } from '@modrinth/assets'
import {
Avatar,
@@ -98,10 +102,35 @@ import {
} from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { computed } from 'vue'
import type { LocationQueryValue } from 'vue-router'
import { useAuth } from '@/composables/auth.js'
import { useScopes } from '@/composables/auth/scopes.ts'
interface ApiErrorShape {
data?: {
description?: string
error?: string
}
}
const getQueryString = (
value: LocationQueryValue | LocationQueryValue[] | null | undefined,
): string => {
const firstValue = Array.isArray(value) ? value[0] : value
return typeof firstValue === 'string' ? firstValue : ''
}
const getErrorMessage = (error: unknown): string => {
const apiError = error as ApiErrorShape
if (typeof apiError?.data?.description === 'string') {
return apiError.data.description
}
if (error instanceof Error) {
return error.message
}
return String(error)
}
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -124,6 +153,10 @@ const messages = defineMessages({
id: 'auth.authorize.error.no-redirect-url',
defaultMessage: 'No redirect location found in response',
},
missingParametersError: {
id: 'auth.authorize.error.missing-parameters',
defaultMessage: 'Missing required OAuth query parameters.',
},
redirectUrl: {
id: 'auth.authorize.redirect-url',
defaultMessage: 'You will be redirected to <redirect-url>{url}</redirect-url>',
@@ -132,33 +165,35 @@ const messages = defineMessages({
id: 'auth.authorize.authorize-app-name',
defaultMessage: 'Authorize {appName}',
},
errorTitle: {
id: 'auth.authorize.errro-title',
defaultMessage: 'An Error Occured',
},
})
const router = useNativeRoute()
const auth = await useAuth()
const { scopesToDefinitions } = useScopes()
const clientId = router.query?.client_id || false
const redirectUri = router.query?.redirect_uri || false
const scope = router.query?.scope || false
const state = router.query?.state || false
const clientId = computed(() => getQueryString(router.query.client_id))
const redirectUri = computed(() => getQueryString(router.query.redirect_uri))
const scope = computed(() => getQueryString(router.query.scope))
const state = computed(() => getQueryString(router.query.state))
const hasRequiredParams = computed(() => !!clientId.value && !!redirectUri.value && !!scope.value)
const getFlowIdAuthorization = async () => {
const params = {
client_id: clientId,
redirect_uri: redirectUri,
scope,
}
if (state) {
params.state = state
}
const authorization = await client.labrinth.oauth_internal.authorize(params)
const authorization = await client.labrinth.oauth_internal.authorize({
client_id: clientId.value,
redirect_uri: redirectUri.value,
scope: scope.value,
...(state.value ? { state: state.value } : {}),
})
if (typeof authorization === 'string') {
await navigateTo(authorization, {
external: true,
})
return null
}
return authorization
@@ -170,27 +205,60 @@ const {
error,
suspense: authSusp,
} = useQuery({
queryKey: computed(() => ['authorization', clientId, redirectUri, scope, state]),
queryKey: computed(() => [
'authorization',
clientId.value,
redirectUri.value,
scope.value,
state.value,
]),
queryFn: getFlowIdAuthorization,
enabled: computed(() => !!clientId && !!redirectUri && !!scope),
enabled: hasRequiredParams,
})
const authorizationError = computed(() => {
if (!hasRequiredParams.value) {
return {
error: 'invalid_request',
description: formatMessage(messages.missingParametersError),
}
}
const apiError = (error.value as ApiErrorShape | null)?.data
if (apiError) {
return apiError
}
if (error.value) {
return {
error: 'server_error',
description: getErrorMessage(error.value),
}
}
return null
})
const { data: app, suspense: appSusp } = useQuery({
queryKey: computed(() => ['oauth/app', clientId]),
queryFn: () => client.labrinth.oauth_internal.getApp(clientId),
enabled: computed(() => !!clientId),
queryKey: computed(() => ['oauth/app', clientId.value]),
queryFn: () => client.labrinth.oauth_internal.getApp(clientId.value),
enabled: computed(() => !!clientId.value),
})
const { data: createdBy, suspense: userSusp } = useQuery({
queryKey: computed(() => ['user', app.value?.created_by]),
queryFn: () => client.labrinth.users_v2.get(app.value.created_by),
queryFn: () => {
if (!app.value?.created_by) {
throw new Error('Missing OAuth app creator')
}
return client.labrinth.users_v2.get(app.value.created_by)
},
enabled: computed(() => !!app.value?.created_by),
})
onServerPrefetch(async () => {
await authSusp()
await appSusp()
await userSusp()
await Promise.allSettled([authSusp(), appSusp(), userSusp()])
})
const scopeDefinitions = computed(() =>
@@ -199,6 +267,10 @@ const scopeDefinitions = computed(() =>
const onAuthorize = async () => {
try {
if (!authorizationData.value?.flow_id) {
throw new Error(formatMessage(messages.noRedirectUrlError))
}
const res = await client.labrinth.oauth_internal.accept({
flow: authorizationData.value.flow_id,
})
@@ -214,7 +286,7 @@ const onAuthorize = async () => {
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
text: getErrorMessage(err),
type: 'error',
})
}
@@ -222,6 +294,10 @@ const onAuthorize = async () => {
const onReject = async () => {
try {
if (!authorizationData.value?.flow_id) {
throw new Error(formatMessage(messages.noRedirectUrlError))
}
const res = await client.labrinth.oauth_internal.reject({
flow: authorizationData.value.flow_id,
})
@@ -237,7 +313,7 @@ const onReject = async () => {
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
text: getErrorMessage(err),
type: 'error',
})
}
@@ -247,130 +323,3 @@ definePageMeta({
middleware: 'auth',
})
</script>
<style scoped lang="scss">
.oauth-items {
display: flex;
flex-direction: column;
gap: var(--gap-xl);
}
.scope-items {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
}
.scope-item {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-sm);
}
.scope-icon {
display: flex;
color: var(--color-raised-bg);
background-color: var(--color-green);
aspect-ratio: 1;
border-radius: 50%;
padding: var(--gap-xs);
}
.title {
margin-inline: auto;
h1 {
margin-bottom: 0 !important;
}
}
.redirection-notice {
display: flex;
flex-direction: column;
gap: var(--gap-xs);
text-align: center;
.redirect-instructions {
font-size: var(--font-size-sm);
}
.redirect-url {
font-weight: bold;
}
}
.wide-button {
width: 100% !important;
}
.button-row {
display: flex;
flex-direction: row;
gap: var(--gap-xs);
justify-content: center;
}
.auth-info {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
}
.scope-heading {
margin-bottom: var(--gap-sm);
}
.profile-pics {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-evenly;
.connection-indicator {
// Make sure the text sits in the middle and is centered.
// Make the text large, and make sure it's not selectable.
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
user-select: none;
color: var(--color-primary);
}
}
.profile-pic {
width: 6rem;
height: 6rem;
border-radius: 50%;
margin: 0 1rem;
}
.dotted-border-line {
width: 75%;
border: 0.1rem dashed var(--color-divider);
}
.connected-items {
// Display dotted-border-line under profile-pics and centered behind them
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
z-index: 1;
margin-top: 1rem;
// Display profile-pics on top of dotted-border-line
.profile-pics {
position: relative;
z-index: 2;
}
// Display dotted-border-line behind profile-pics
.dotted-border-line {
position: absolute;
z-index: 1;
}
}
</style>
@@ -0,0 +1,196 @@
<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 lang="ts">
import {
commonMessages,
defineMessages,
injectModrinthClient,
injectNotificationManager,
useVIntl,
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import type { LocationQueryValue } from 'vue-router'
import CreateAccountView from '@/components/ui/auth/CreateAccount.vue'
import { getLauncherRedirectUrl, promotePendingSignInOAuthProvider } from '@/composables/auth.ts'
interface AuthGlobalsResponse {
captcha_enabled?: boolean
[key: string]: unknown
}
interface ApiErrorShape {
data?: {
description?: string
}
}
const getQueryString = (
value: LocationQueryValue | LocationQueryValue[] | null | undefined,
): string => {
const firstValue = Array.isArray(value) ? value[0] : value
return typeof firstValue === 'string' ? firstValue : ''
}
const getErrorMessage = (error: unknown): string => {
const apiError = error as ApiErrorShape
if (typeof apiError?.data?.description === 'string') {
return apiError.data.description
}
if (error instanceof Error) {
return error.message
}
return String(error)
}
const client = injectModrinthClient()
const queryClient = useQueryClient()
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const route = useNativeRoute()
const 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 : ''
})
const dateOfBirth = ref('')
const username = ref(defaultUsername.value)
const token = ref('')
const subscribe = ref(false)
const subtleLauncherRedirectUri = ref<string>()
const captcha = ref<{ reset?: () => void } | null>(null)
const setCaptchaRef = (captchaRef: unknown) => {
captcha.value = (captchaRef as { reset?: () => void } | null) ?? null
}
const { data: globals } = useQuery<AuthGlobalsResponse>({
queryKey: ['auth-globals'],
queryFn: async () => {
try {
return await client.labrinth.globals_internal.get()
} catch (err) {
console.error('Error fetching globals:', err)
return { captcha_enabled: true, tax_compliance_thresholds: {} }
}
},
})
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: getErrorMessage(err),
type: 'error',
})
captcha.value?.reset?.()
}
stopLoading()
}
async function finishSignIn(sessionToken?: string | null) {
if (route.query.launcher) {
let token = sessionToken
if (!token) {
token = auth.value.token
}
promotePendingSignInOAuthProvider()
const redirectUrl = `${getLauncherRedirectUrl(route)}/?code=${token}`
if (redirectUrl.startsWith('https://launcher-files.modrinth.com/')) {
await navigateTo(redirectUrl, {
external: true,
})
} else {
subtleLauncherRedirectUri.value = redirectUrl
}
return
}
if (sessionToken) {
await useAuth(sessionToken)
await useUser()
queryClient.clear()
promotePendingSignInOAuthProvider()
}
if (route.query.redirect) {
const redirect = decodeURIComponent(getQueryString(route.query.redirect))
await navigateTo(redirect, {
replace: true,
})
} else {
await navigateTo('/dashboard')
}
}
</script>
+98 -35
View File
@@ -1,14 +1,24 @@
<template>
<div>
<h1>{{ formatMessage(messages.longTitle) }}</h1>
<section class="auth-form">
<template v-if="step === 'choose_method'">
<p>
{{ formatMessage(methodChoiceMessages.description) }}
</p>
<div
class="universal-card flex w-full max-w-[27rem] flex-col gap-6 border border-solid border-surface-5 !p-6"
>
<h1 class="m-0 mx-auto text-xl font-semibold text-contrast">
{{ formatMessage(messages.longTitle) }}
</h1>
<template v-if="step === 'choose_method'">
<Admonition :type="'info'">
<template #header>
<div class="-mb-2 flex flex-col gap-1.5 font-normal leading-normal">
<div>
{{ formatMessage(methodChoiceMessages.description) }}
</div>
</div>
</template>
</Admonition>
<label for="email" hidden>
{{ formatMessage(commonMessages.emailUsernameLabel) }}
<div class="flex flex-col gap-2.5">
<label class="text-md font-semibold text-contrast" for="email">
{{ formatMessage(commonMessages.emailLabel) }}
</label>
<StyledInput
id="email"
@@ -19,22 +29,37 @@
:placeholder="formatMessage(commonMessages.emailLabel)"
wrapper-class="w-full"
/>
</div>
<HCaptcha v-if="globals?.captcha_enabled" ref="captcha" v-model="token" />
<div v-if="globals?.captcha_enabled" class="flex flex-col gap-2.5">
<label class="text-md font-semibold text-contrast">{{
formatMessage(messages.securityCheckLabel)
}}</label>
<HCaptcha ref="captcha" v-model="token" />
</div>
<ButtonStyled color="brand">
<button
class="mx-auto"
:disabled="globals?.captcha_enabled ? !token : false"
@click="recovery"
>
<SendIcon /> {{ formatMessage(methodChoiceMessages.action) }}
</button>
</ButtonStyled>
</template>
<template v-else-if="step === 'passed_challenge'">
<p>{{ formatMessage(postChallengeMessages.description) }}</p>
<ButtonStyled color="brand">
<button
class="!w-full"
:disabled="(globals?.captcha_enabled ? !token : false) || !email"
@click="recovery"
>
<SendIcon /> {{ formatMessage(methodChoiceMessages.action) }}
</button>
</ButtonStyled>
</template>
<template v-else-if="step === 'passed_challenge'">
<Admonition :type="'info'">
<template #header>
<div class="-mb-2 flex flex-col gap-1.5 font-normal leading-normal">
<div>
{{ formatMessage(postChallengeMessages.description) }}
</div>
</div>
</template>
</Admonition>
<div class="flex flex-col gap-2.5">
<label for="password" hidden>{{ formatMessage(commonMessages.passwordLabel) }}</label>
<StyledInput
id="password"
@@ -60,17 +85,18 @@
/>
<ButtonStyled color="brand">
<button class="auth-form__input continue-btn" @click="changePassword">
<button class="!w-full" @click="changePassword">
{{ formatMessage(postChallengeMessages.action) }}
</button>
</ButtonStyled>
</template>
</section>
</div>
</template>
</div>
</template>
<script setup>
<script setup lang="ts">
import { KeyIcon, MailIcon, SendIcon } from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
commonMessages,
defineMessages,
@@ -80,8 +106,41 @@ import {
useVIntl,
} from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import type { LocationQueryValue } from 'vue-router'
import HCaptcha from '@/components/ui/HCaptcha.vue'
import HCaptcha from '@/components/ui/auth/HCaptcha.vue'
interface AuthGlobalsResponse {
captcha_enabled?: boolean
[key: string]: unknown
}
interface ApiErrorShape {
data?: {
description?: string
error?: string
}
}
type ResetPasswordStep = 'choose_method' | 'passed_challenge'
const getQueryString = (
value: LocationQueryValue | LocationQueryValue[] | null | undefined,
): string => {
const firstValue = Array.isArray(value) ? value[0] : value
return typeof firstValue === 'string' ? firstValue : ''
}
const getErrorMessage = (error: unknown): string => {
const apiError = error as ApiErrorShape
if (typeof apiError?.data?.description === 'string') {
return apiError.data.description
}
if (error instanceof Error) {
return error.message
}
return String(error)
}
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
@@ -149,6 +208,10 @@ const messages = defineMessages({
id: 'auth.reset-password.title.long',
defaultMessage: 'Reset your password',
},
securityCheckLabel: {
id: 'auth.create-account.security-check.label',
defaultMessage: 'Security check',
},
})
useHead({
@@ -162,15 +225,15 @@ if (auth.value.user) {
const route = useNativeRoute()
const step = ref('choose_method')
const step = ref<ResetPasswordStep>('choose_method')
if (route.query.flow) {
step.value = 'passed_challenge'
}
const captcha = ref()
const captcha = ref<{ reset?: () => void } | null>(null)
const { data: globals } = useQuery({
const { data: globals } = useQuery<AuthGlobalsResponse>({
queryKey: ['auth-globals'],
queryFn: async () => {
try {
@@ -201,10 +264,10 @@ async function recovery() {
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
text: getErrorMessage(err),
type: 'error',
})
captcha.value?.reset()
captcha.value?.reset?.()
}
stopLoading()
}
@@ -217,7 +280,7 @@ async function changePassword() {
try {
await client.labrinth.auth_v2.changePassword({
new_password: newPassword.value,
flow: route.query.flow,
flow: getQueryString(route.query.flow),
})
addNotification({
@@ -229,10 +292,10 @@ async function changePassword() {
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
text: getErrorMessage(err),
type: 'error',
})
captcha.value?.reset()
captcha.value?.reset?.()
}
stopLoading()
}
+131 -197
View File
@@ -1,171 +1,72 @@
<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>
<div v-else>
<template v-if="flow && !subtleLauncherRedirectUri">
<label for="two-factor-code">
<span class="label__title">{{ formatMessage(messages.twoFactorCodeLabel) }}</span>
<span class="label__description">
{{ formatMessage(messages.twoFactorCodeLabelDescription) }}
</span>
</label>
<StyledInput
id="two-factor-code"
v-model="twoFactorCode"
:maxlength="11"
inputmode="numeric"
:placeholder="formatMessage(messages.twoFactorCodeInputPlaceholder)"
autocomplete="one-time-code"
@keyup.enter="begin2FASignIn"
/>
<ButtonStyled color="brand">
<button class="continue-btn" @click="begin2FASignIn">
{{ formatMessage(commonMessages.signInButton) }} <RightArrowIcon />
</button>
</ButtonStyled>
</template>
<template v-else>
<h1>{{ formatMessage(messages.signInWithLabel) }}</h1>
<section class="third-party">
<ButtonStyled>
<a :href="getAuthUrl('discord', redirectTarget)">
<DiscordColorIcon />
<span>Discord</span>
</a>
</ButtonStyled>
<ButtonStyled>
<a :href="getAuthUrl('github', redirectTarget)">
<GitHubColorIcon />
<span>GitHub</span>
</a>
</ButtonStyled>
<ButtonStyled>
<a :href="getAuthUrl('microsoft', redirectTarget)">
<MicrosoftColorIcon />
<span>Microsoft</span>
</a>
</ButtonStyled>
<ButtonStyled>
<a :href="getAuthUrl('google', redirectTarget)">
<GoogleColorIcon />
<span>Google</span>
</a>
</ButtonStyled>
<ButtonStyled>
<a :href="getAuthUrl('steam', redirectTarget)">
<SteamColorIcon />
<span>Steam</span>
</a>
</ButtonStyled>
<ButtonStyled>
<a :href="getAuthUrl('gitlab', redirectTarget)">
<GitLabColorIcon />
<span>GitLab</span>
</a>
</ButtonStyled>
</section>
<h1>{{ formatMessage(messages.usePasswordLabel) }}</h1>
<section class="auth-form">
<label for="email" hidden>{{ formatMessage(commonMessages.emailUsernameLabel) }}</label>
<StyledInput
id="email"
v-model="email"
:icon="MailIcon"
type="text"
inputmode="email"
autocomplete="username"
:placeholder="formatMessage(commonMessages.emailUsernameLabel)"
wrapper-class="w-full"
/>
<label for="password" hidden>{{ formatMessage(commonMessages.passwordLabel) }}</label>
<StyledInput
id="password"
v-model="password"
:icon="KeyIcon"
type="password"
autocomplete="current-password"
:placeholder="formatMessage(commonMessages.passwordLabel)"
wrapper-class="w-full"
/>
<HCaptcha v-if="globals?.captcha_enabled" ref="captcha" v-model="token" />
<ButtonStyled color="brand">
<button
class="continue-btn centered-btn"
:disabled="globals?.captcha_enabled ? !token : false"
@click="beginPasswordSignIn()"
>
{{ formatMessage(commonMessages.signInButton) }} <RightArrowIcon />
</button>
</ButtonStyled>
<div class="auth-form__additional-options">
<IntlFormatted :message-id="messages.additionalOptionsLabel">
<template #forgot-password-link="{ children }">
<NuxtLink
class="text-link"
:to="{
path: '/auth/reset-password',
query: route.query,
}"
>
<component :is="() => children" />
</NuxtLink>
</template>
<template #create-account-link="{ children }">
<NuxtLink
class="text-link"
:to="{
path: '/auth/sign-up',
query: route.query,
}"
>
<component :is="() => children" />
</NuxtLink>
</template>
</IntlFormatted>
</div>
</section>
</template>
</div>
<SignInView
v-model:email="email"
v-model:password="password"
v-model:token="token"
v-model:two-factor-code="twoFactorCode"
:subtle-launcher-redirect-uri="subtleLauncherRedirectUri"
:flow="flow"
:redirect-target="redirectTarget"
:route-query="route.query"
:globals="globals"
:on-password-sign-in="beginPasswordSignIn"
:on-two-factor-sign-in="begin2FASignIn"
:on-passkey-sign-in="beginPasskeySignin"
:on-set-captcha-ref="setCaptchaRef"
/>
</template>
<script setup>
<script setup lang="ts">
import {
DiscordColorIcon,
GitHubColorIcon,
GitLabColorIcon,
GoogleColorIcon,
KeyIcon,
MailIcon,
MicrosoftColorIcon,
RightArrowIcon,
SteamColorIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
defineMessages,
injectModrinthClient,
injectNotificationManager,
IntlFormatted,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { useStorage } from '@vueuse/core'
import type { LocationQueryValue } from 'vue-router'
import HCaptcha from '@/components/ui/HCaptcha.vue'
import { getAuthUrl, getLauncherRedirectUrl } from '@/composables/auth.js'
import SignInView from '@/components/ui/auth/SignIn.vue'
import {
getLauncherRedirectUrl,
LAST_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
promotePendingSignInOAuthProvider,
} from '@/composables/auth.ts'
import { getPasskeyCredential } from '@/helpers/passkey.ts'
type AuthProvider = 'discord' | 'google' | 'github' | 'gitlab' | 'steam' | 'microsoft' | 'passkey'
interface AuthGlobalsResponse {
captcha_enabled?: boolean
[key: string]: unknown
}
interface ApiErrorShape {
data?: {
description?: string
}
}
const getQueryString = (
value: LocationQueryValue | LocationQueryValue[] | null | undefined,
): string => {
const firstValue = Array.isArray(value) ? value[0] : value
return typeof firstValue === 'string' ? firstValue : ''
}
const getErrorMessage = (error: unknown): string => {
const apiError = error as ApiErrorShape
if (typeof apiError?.data?.description === 'string') {
return apiError.data.description
}
if (error instanceof Error) {
return error.message
}
return String(error)
}
const client = injectModrinthClient()
const queryClient = useQueryClient()
@@ -173,35 +74,10 @@ const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
additionalOptionsLabel: {
id: 'auth.sign-in.additional-options',
defaultMessage:
'<forgot-password-link>Forgot password?</forgot-password-link> • <create-account-link>Create an account</create-account-link>',
},
signInWithLabel: {
id: 'auth.sign-in.sign-in-with',
defaultMessage: 'Sign in with',
},
signInTitle: {
id: 'auth.sign-in.title',
defaultMessage: 'Sign In',
},
twoFactorCodeInputPlaceholder: {
id: 'auth.sign-in.2fa.placeholder',
defaultMessage: 'Enter code...',
},
twoFactorCodeLabel: {
id: 'auth.sign-in.2fa.label',
defaultMessage: 'Enter two-factor code',
},
twoFactorCodeLabelDescription: {
id: 'auth.sign-in.2fa.description',
defaultMessage: 'Please enter a two-factor code to proceed.',
},
usePasswordLabel: {
id: 'auth.sign-in.use-password',
defaultMessage: 'Or use a password',
},
})
useHead({
@@ -212,11 +88,35 @@ useHead({
const auth = await useAuth()
const route = useNativeRoute()
const pendingSignInOAuthProvider = useStorage<AuthProvider | null>(
PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
null,
undefined,
{ initOnMounted: true },
)
const lastSignInOAuthProvider = useStorage<AuthProvider | null>(
LAST_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
null,
undefined,
{ initOnMounted: true },
)
const redirectTarget = route.query.redirect || ''
const subtleLauncherRedirectUri = ref()
if (route.query.state !== undefined) {
await navigateTo(
{
path: '/auth/create/oauth',
query: route.query,
},
{
replace: true,
},
)
}
if (route.query.code && !route.fullPath.includes('new_account=true')) {
const redirectTarget = getQueryString(route.query.redirect)
const subtleLauncherRedirectUri = ref<string>()
if (route.query.code) {
await finishSignIn()
}
@@ -224,9 +124,12 @@ if (auth.value.user) {
await finishSignIn()
}
const captcha = ref()
const captcha = ref<{ reset?: () => void } | null>(null)
const setCaptchaRef = (captchaRef: unknown) => {
captcha.value = (captchaRef as { reset?: () => void } | null) ?? null
}
const { data: globals } = useQuery({
const { data: globals } = useQuery<AuthGlobalsResponse>({
queryKey: ['auth-globals'],
queryFn: async () => {
try {
@@ -242,9 +145,11 @@ const email = ref('')
const password = ref('')
const token = ref('')
const flow = ref(route.query.flow)
const flow = ref(getQueryString(route.query.flow))
async function beginPasswordSignIn() {
pendingSignInOAuthProvider.value = null
lastSignInOAuthProvider.value = null
startLoading()
try {
const res = await client.labrinth.auth_v2.login({
@@ -261,41 +166,68 @@ async function beginPasswordSignIn() {
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
text: getErrorMessage(err),
type: 'error',
})
captcha.value?.reset()
captcha.value?.reset?.()
}
stopLoading()
}
const twoFactorCode = ref(null)
const twoFactorCode = ref('')
async function begin2FASignIn() {
startLoading()
try {
const res = await client.labrinth.auth_v2.login2FA({
flow: flow.value,
code: twoFactorCode.value ? twoFactorCode.value.toString() : twoFactorCode.value,
code: twoFactorCode.value,
})
await finishSignIn(res.session)
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
text: getErrorMessage(err),
type: 'error',
})
captcha.value?.reset()
captcha.value?.reset?.()
}
stopLoading()
}
async function finishSignIn(token) {
async function beginPasskeySignin() {
startLoading()
try {
const start = await client.labrinth.auth_v2.authenticatePasskeyStart()
const credential = await getPasskeyCredential(start.options.publicKey)
const result = await client.labrinth.auth_v2.authenticatePasskeyFinish({
flow: start.flow,
credential,
})
pendingSignInOAuthProvider.value = 'passkey'
await finishSignIn(result.session)
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: getErrorMessage(err),
type: 'error',
})
}
stopLoading()
}
async function finishSignIn(sessionToken?: string | null) {
if (route.query.launcher) {
let token = sessionToken
if (!token) {
token = auth.value.token
}
promotePendingSignInOAuthProvider()
const redirectUrl = `${getLauncherRedirectUrl(route)}/?code=${token}`
if (redirectUrl.startsWith('https://launcher-files.modrinth.com/')) {
@@ -316,14 +248,16 @@ async function finishSignIn(token) {
return
}
if (token) {
await useAuth(token)
if (sessionToken) {
await useAuth(sessionToken)
await useUser()
queryClient.clear()
}
promotePendingSignInOAuthProvider()
if (route.query.redirect) {
const redirect = decodeURIComponent(route.query.redirect)
const redirect = decodeURIComponent(getQueryString(route.query.redirect))
await navigateTo(redirect, {
replace: true,
})
+203 -200
View File
@@ -1,171 +1,81 @@
<template>
<div>
<h1>{{ formatMessage(messages.signUpWithTitle) }}</h1>
<section class="third-party">
<ButtonStyled>
<a class="discord-btn" :href="getAuthUrl('discord', redirectTarget)">
<DiscordColorIcon />
<span>Discord</span>
</a>
</ButtonStyled>
<ButtonStyled>
<a :href="getAuthUrl('github', redirectTarget)">
<GitHubColorIcon />
<span>GitHub</span>
</a>
</ButtonStyled>
<ButtonStyled>
<a :href="getAuthUrl('microsoft', redirectTarget)">
<MicrosoftColorIcon />
<span>Microsoft</span>
</a>
</ButtonStyled>
<ButtonStyled>
<a :href="getAuthUrl('google', redirectTarget)">
<GoogleColorIcon />
<span>Google</span>
</a>
</ButtonStyled>
<ButtonStyled>
<a :href="getAuthUrl('steam', redirectTarget)">
<SteamColorIcon />
<span>Steam</span>
</a>
</ButtonStyled>
<ButtonStyled>
<a :href="getAuthUrl('gitlab', redirectTarget)">
<GitLabColorIcon />
<span>GitLab</span>
</a>
</ButtonStyled>
</section>
<h1>{{ formatMessage(messages.createAccountTitle) }}</h1>
<section class="auth-form">
<label for="email" hidden>{{ formatMessage(commonMessages.emailLabel) }}</label>
<StyledInput
id="email"
v-model="email"
:icon="MailIcon"
type="email"
autocomplete="username"
:placeholder="formatMessage(commonMessages.emailLabel)"
wrapper-class="w-full"
/>
<label for="username" hidden>{{ formatMessage(commonMessages.usernameLabel) }}</label>
<StyledInput
id="username"
v-model="username"
:icon="UserIcon"
type="text"
autocomplete="username"
:placeholder="formatMessage(commonMessages.usernameLabel)"
wrapper-class="w-full"
/>
<label for="password" hidden>{{ formatMessage(commonMessages.passwordLabel) }}</label>
<StyledInput
id="password"
v-model="password"
:icon="KeyIcon"
type="password"
autocomplete="new-password"
:placeholder="formatMessage(commonMessages.passwordLabel)"
wrapper-class="w-full"
/>
<label for="confirm-password" hidden>{{ formatMessage(commonMessages.passwordLabel) }}</label>
<StyledInput
id="confirm-password"
v-model="confirmPassword"
:icon="KeyIcon"
type="password"
autocomplete="new-password"
:placeholder="formatMessage(commonMessages.confirmPasswordLabel)"
wrapper-class="w-full"
/>
<Checkbox
v-model="subscribe"
class="subscribe-btn"
:label="formatMessage(messages.subscribeLabel)"
:description="formatMessage(messages.subscribeLabel)"
/>
<p v-if="!route.query.launcher">
<IntlFormatted :message-id="messages.legalDisclaimer">
<template #terms-link="{ children }">
<NuxtLink to="/legal/terms" class="text-link">
<component :is="() => children" />
</NuxtLink>
</template>
<template #privacy-policy-link="{ children }">
<NuxtLink to="/legal/privacy" class="text-link">
<component :is="() => children" />
</NuxtLink>
</template>
</IntlFormatted>
</p>
<HCaptcha v-if="globals?.captcha_enabled" ref="captcha" v-model="token" />
<ButtonStyled color="brand">
<button
class="continue-btn centered-btn"
:disabled="globals?.captcha_enabled ? !token : false"
@click="createAccount"
>
{{ formatMessage(messages.createAccountButton) }} <RightArrowIcon />
</button>
</ButtonStyled>
<div class="auth-form__additional-options">
{{ formatMessage(messages.alreadyHaveAccountLabel) }}
<NuxtLink
class="text-link"
:to="{
path: '/auth/sign-in',
query: route.query,
}"
>
{{ formatMessage(commonMessages.signInButton) }}
</NuxtLink>
</div>
</section>
</div>
<SignUpView
v-if="!isCreateAccountStep"
v-model:email="email"
v-model:password="password"
:redirect-target="redirectTarget"
:show-other-options="showOtherOptions"
:route-query="route.query"
:on-toggle-other-options="toggleOtherOptions"
:on-continue-with-email="continueWithEmail"
/>
<CreateAccountView
v-else
v-model:date-of-birth="dateOfBirth"
v-model:username="username"
v-model:token="token"
v-model:subscribe="subscribe"
:globals="globals"
:on-complete-sign-up="createAccount"
:on-set-captcha-ref="setCaptchaRef"
/>
</template>
<script setup>
<script setup lang="ts">
import {
DiscordColorIcon,
GitHubColorIcon,
GitLabColorIcon,
GoogleColorIcon,
KeyIcon,
MailIcon,
MicrosoftColorIcon,
RightArrowIcon,
SteamColorIcon,
UserIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
Checkbox,
commonMessages,
defineMessages,
injectModrinthClient,
injectNotificationManager,
IntlFormatted,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { useStorage } from '@vueuse/core'
import type { LocationQueryValue } from 'vue-router'
import HCaptcha from '@/components/ui/HCaptcha.vue'
import { getAuthUrl } from '@/composables/auth.js'
import CreateAccountView from '@/components/ui/auth/CreateAccount.vue'
import SignUpView from '@/components/ui/auth/SignUp.vue'
import {
LAST_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
promotePendingSignInOAuthProvider,
} from '@/composables/auth.ts'
interface AuthGlobalsResponse {
captcha_enabled?: boolean
[key: string]: unknown
}
interface ApiErrorShape {
data?: {
description?: string
error?: string
}
v1Error?: {
error?: string
}
responseData?: {
error?: string
}
}
const getQueryString = (
value: LocationQueryValue | LocationQueryValue[] | null | undefined,
): string => {
const firstValue = Array.isArray(value) ? value[0] : value
return typeof firstValue === 'string' ? firstValue : ''
}
const getErrorMessage = (error: unknown): string => {
const apiError = error as ApiErrorShape
if (typeof apiError?.data?.description === 'string') {
return apiError.data.description
}
if (error instanceof Error) {
return error.message
}
return String(error)
}
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
@@ -176,30 +86,13 @@ const messages = defineMessages({
id: 'auth.sign-up.title',
defaultMessage: 'Sign Up',
},
signUpWithTitle: {
id: 'auth.sign-up.title.sign-up-with',
defaultMessage: 'Sign up with',
ageRequirementWarningTitle: {
id: 'auth.sign-up.age-requirement.warning-title',
defaultMessage: 'Age requirement',
},
createAccountTitle: {
id: 'auth.sign-up.title.create-account',
defaultMessage: 'Or create an account yourself',
},
subscribeLabel: {
id: 'auth.sign-up.subscribe.label',
defaultMessage: 'Subscribe to updates about Modrinth',
},
legalDisclaimer: {
id: 'auth.sign-up.legal-dislaimer',
defaultMessage:
"By creating an account, you agree to Modrinth's <terms-link>Terms</terms-link> and <privacy-policy-link>Privacy Policy</privacy-policy-link>.",
},
createAccountButton: {
id: 'auth.sign-up.action.create-account',
defaultMessage: 'Create account',
},
alreadyHaveAccountLabel: {
id: 'auth.sign-up.sign-in-option.title',
defaultMessage: 'Already have an account?',
under13HelperText: {
id: 'auth.create-account.date-of-birth.under13-helper',
defaultMessage: 'You cannot create an account at Modrinth unless you are over 13 years old.',
},
})
@@ -209,16 +102,36 @@ useHead({
const auth = await useAuth()
const route = useNativeRoute()
const pendingSignInOAuthProvider = useStorage(
PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
null,
undefined,
{ initOnMounted: true },
)
const lastSignInOAuthProvider = useStorage(
LAST_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
null,
undefined,
{ initOnMounted: true },
)
const redirectTarget = route.query.redirect
const redirectTarget = getQueryString(route.query.redirect)
const showOtherOptions = ref(false)
const isCreateAccountStep = ref(false)
if (auth.value.user) {
await navigateTo('/dashboard')
}
const captcha = ref()
const captcha = ref<{ reset?: () => void } | null>(null)
const setCaptchaRef = (captchaRef: unknown) => {
captcha.value = (captchaRef as { reset?: () => void } | null) ?? null
}
const toggleOtherOptions = () => {
showOtherOptions.value = !showOtherOptions.value
}
const { data: globals } = useQuery({
const { data: globals } = useQuery<AuthGlobalsResponse>({
queryKey: ['auth-globals'],
queryFn: async () => {
try {
@@ -231,29 +144,117 @@ const { data: globals } = useQuery({
})
const email = ref('')
const username = ref('')
const password = ref('')
const confirmPassword = ref('')
const dateOfBirth = ref('')
const username = ref('')
const token = ref('')
const subscribe = ref(false)
const USERNAME_MAX_LENGTH = 39
const MAX_USERNAME_SUFFIX_RETRIES = 10
const MAX_RANDOM_USERNAME_ATTEMPTS = 10
async function continueWithEmail() {
pendingSignInOAuthProvider.value = null
lastSignInOAuthProvider.value = null
startLoading()
try {
const generatedUsername = await findAvailableGeneratedUsername()
token.value = ''
username.value = generatedUsername
isCreateAccountStep.value = true
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: getErrorMessage(err),
type: 'error',
})
}
stopLoading()
}
function generateUsernameFromEmail(emailAddress: string): string {
const [localPart = ''] = emailAddress.trim().toLowerCase().split('@')
const sanitized = localPart
.replace(/[^a-zA-Z0-9_-]/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '')
return (sanitized || 'user').slice(0, USERNAME_MAX_LENGTH)
}
async function findAvailableGeneratedUsername(): Promise<string> {
const baseUsername = generateUsernameFromEmail(email.value)
for (let suffixAttempt = 0; suffixAttempt <= MAX_USERNAME_SUFFIX_RETRIES; suffixAttempt++) {
const candidateUsername = appendUsernameSuffix(baseUsername, suffixAttempt)
try {
await validateCreateAccountCandidate(candidateUsername)
return candidateUsername
} catch (err) {
if (!isUsernameTakenValidationError(err)) {
throw err
}
}
}
return await findAvailableRandomUsername()
}
function appendUsernameSuffix(baseUsername: string, suffixAttempt: number): string {
if (suffixAttempt === 0) {
return baseUsername
}
const suffix = String(suffixAttempt)
const maxBaseLength = Math.max(1, USERNAME_MAX_LENGTH - suffix.length)
return `${baseUsername.slice(0, maxBaseLength)}${suffix}`
}
function generateRandomUsername(): string {
const randomSuffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`
const base = `user_${randomSuffix}`
return base.slice(0, USERNAME_MAX_LENGTH)
}
async function findAvailableRandomUsername(): Promise<string> {
for (let attempt = 0; attempt < MAX_RANDOM_USERNAME_ATTEMPTS; attempt++) {
const candidateUsername = generateRandomUsername()
try {
await validateCreateAccountCandidate(candidateUsername)
return candidateUsername
} catch (err) {
if (!isUsernameTakenValidationError(err)) {
throw err
}
}
}
throw new Error('Unable to find an available username. Please choose one manually.')
}
async function validateCreateAccountCandidate(candidateUsername: string): Promise<void> {
await client.labrinth.auth_v2.validateCreateAccount({
username: candidateUsername,
password: password.value,
email: email.value,
})
}
function isUsernameTakenValidationError(error: unknown): boolean {
const apiError = error as ApiErrorShape
const errorCode =
apiError?.data?.error ?? apiError?.v1Error?.error ?? apiError?.responseData?.error
return errorCode === 'username_taken'
}
async function createAccount() {
startLoading()
try {
if (confirmPassword.value !== password.value) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: formatMessage({
id: 'auth.sign-up.notification.password-mismatch.text',
defaultMessage: 'Passwords do not match!',
}),
type: 'error',
})
captcha.value?.reset()
}
const res = await client.labrinth.auth_v2.createAccount({
username: username.value,
username: username.value.trim() || generateUsernameFromEmail(email.value),
password: password.value,
email: email.value,
challenge: token.value,
@@ -263,23 +264,25 @@ async function createAccount() {
await useAuth(res.session)
await useUser()
promotePendingSignInOAuthProvider()
if (route.query.launcher) {
await navigateTo({ path: '/auth/sign-in', query: route.query })
return
}
if (route.query.redirect) {
await navigateTo(route.query.redirect)
await navigateTo(getQueryString(route.query.redirect))
} else {
await navigateTo('/dashboard')
}
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
text: getErrorMessage(err),
type: 'error',
})
captcha.value?.reset()
captcha.value?.reset?.()
}
stopLoading()
}
+118 -52
View File
@@ -1,78 +1,126 @@
<template>
<div>
<div
class="universal-card flex w-full max-w-[27rem] flex-col gap-6 border border-solid border-surface-5 !p-6"
>
<template v-if="auth.user && auth.user.email_verified && !success">
<h1>{{ formatMessage(alreadyVerifiedMessages.title) }}</h1>
<h1 class="m-0 mx-auto text-xl font-semibold text-contrast">
{{ formatMessage(alreadyVerifiedMessages.title) }}
</h1>
<section class="auth-form">
<p>{{ formatMessage(alreadyVerifiedMessages.description) }}</p>
<Admonition type="success">
{{ formatMessage(alreadyVerifiedMessages.description) }}
</Admonition>
<div class="grid grid-cols-2 gap-2">
<ButtonStyled>
<NuxtLink to="/settings/account">
<NuxtLink class="shadow-none" to="/settings/account">
<SettingsIcon /> {{ formatMessage(messages.accountSettings) }}
</NuxtLink>
</ButtonStyled>
</section>
<ButtonStyled color="brand">
<NuxtLink to="/discover/mods">
{{ formatMessage(messages.discoverMods) }}
<RightArrowIcon />
</NuxtLink>
</ButtonStyled>
</div>
</template>
<template v-else-if="success">
<h1>{{ formatMessage(postVerificationMessages.title) }}</h1>
<h1 class="m-0 mx-auto text-xl font-semibold text-contrast">
{{ formatMessage(postVerificationMessages.title) }}
</h1>
<section class="auth-form">
<p>{{ formatMessage(postVerificationMessages.description) }}</p>
<Admonition type="success">
{{ formatMessage(postVerificationMessages.description) }}
</Admonition>
<ButtonStyled v-if="auth.user">
<NuxtLink to="/settings/account">
<SettingsIcon /> {{ formatMessage(messages.accountSettings) }}
</NuxtLink>
</ButtonStyled>
<ButtonStyled v-else>
<NuxtLink to="/auth/sign-in">
{{ formatMessage(commonMessages.signInButton) }}
<RightArrowIcon />
</NuxtLink>
</ButtonStyled>
</section>
<template v-if="auth.user">
<div class="grid grid-cols-2 gap-2">
<ButtonStyled>
<NuxtLink to="/settings/account">
<SettingsIcon /> {{ formatMessage(messages.accountSettings) }}
</NuxtLink>
</ButtonStyled>
<ButtonStyled color="brand">
<NuxtLink to="/discover/mods">
{{ formatMessage(messages.discoverMods) }}
<RightArrowIcon />
</NuxtLink>
</ButtonStyled>
</div>
</template>
<ButtonStyled v-else color="brand">
<NuxtLink to="/auth/sign-in" class="!w-full">
{{ formatMessage(commonMessages.signInButton) }}
<RightArrowIcon />
</NuxtLink>
</ButtonStyled>
</template>
<template v-else>
<h1>{{ formatMessage(failedVerificationMessages.title) }}</h1>
<h1 class="m-0 mx-auto text-xl font-semibold text-contrast">
{{ formatMessage(failedVerificationMessages.title) }}
</h1>
<section class="auth-form">
<p>
<template v-if="auth.user">
{{ formatMessage(failedVerificationMessages.loggedInDescription) }}
</template>
<template v-else>
{{ formatMessage(failedVerificationMessages.description) }}
</template>
</p>
<Admonition v-if="auth.user" type="warning">
{{ formatMessage(failedVerificationMessages.loggedInDescription) }}
</Admonition>
<Admonition v-else type="warning">
{{ formatMessage(failedVerificationMessages.description) }}
</Admonition>
<ButtonStyled v-if="auth.user" color="brand">
<button @click="handleResendEmailVerification">
{{ formatMessage(failedVerificationMessages.action) }}
<RightArrowIcon />
</button>
</ButtonStyled>
<ButtonStyled v-else color="brand">
<NuxtLink to="/auth/sign-in">
{{ formatMessage(commonMessages.signInButton) }}
<RightArrowIcon />
</NuxtLink>
</ButtonStyled>
</section>
<ButtonStyled v-if="auth.user" color="brand">
<button class="!w-full" @click="handleResendEmailVerification">
{{ formatMessage(failedVerificationMessages.action) }}
<RightArrowIcon />
</button>
</ButtonStyled>
<ButtonStyled v-else color="brand">
<NuxtLink to="/auth/sign-in" class="!w-full">
{{ formatMessage(commonMessages.signInButton) }}
<RightArrowIcon />
</NuxtLink>
</ButtonStyled>
</template>
</div>
</template>
<script setup>
<script setup lang="ts">
import { RightArrowIcon, SettingsIcon } from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
commonMessages,
defineMessages,
injectNotificationManager,
useVIntl,
} from '@modrinth/ui'
import type { LocationQueryValue } from 'vue-router'
interface ApiErrorShape {
data?: {
description?: string
error?: string
}
}
const getQueryString = (
value: LocationQueryValue | LocationQueryValue[] | null | undefined,
): string => {
const firstValue = Array.isArray(value) ? value[0] : value
return typeof firstValue === 'string' ? firstValue : ''
}
const getErrorMessage = (error: unknown): string => {
const apiError = error as ApiErrorShape
if (typeof apiError?.data?.description === 'string') {
return apiError.data.description
}
if (error instanceof Error) {
return error.message
}
return String(error)
}
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -86,6 +134,22 @@ const messages = defineMessages({
id: 'auth.verify-email.action.account-settings',
defaultMessage: 'Account settings',
},
discoverMods: {
id: 'auth.verify-email.action.discover-mods',
defaultMessage: 'Discover mods',
},
emailSentNotificationTitle: {
id: 'auth.verify-email.notification.email-sent.title',
defaultMessage: 'Email sent',
},
emailSentNotificationDescription: {
id: 'auth.verify-email.notification.email-sent.description',
defaultMessage: 'An email with a link to verify your account has been sent to {email}.',
},
errorOccurredTitle: {
id: 'auth.verify-email.notification.error-occurred.title',
defaultMessage: 'An error occurred',
},
})
const alreadyVerifiedMessages = defineMessages({
@@ -142,13 +206,13 @@ const route = useNativeRoute()
if (route.query.flow) {
try {
const emailVerified = useState('emailVerified', () => null)
const emailVerified = useState<boolean | null>('emailVerified', () => null)
if (emailVerified.value === null) {
await useBaseFetch('auth/email/verify', {
method: 'POST',
body: {
flow: route.query.flow,
flow: getQueryString(route.query.flow),
},
})
emailVerified.value = true
@@ -171,14 +235,16 @@ async function handleResendEmailVerification() {
try {
await resendVerifyEmail()
addNotification({
title: 'Email sent',
text: `An email with a link to verify your account has been sent to ${auth.value.user.email}.`,
title: formatMessage(messages.emailSentNotificationTitle),
text: formatMessage(messages.emailSentNotificationDescription, {
email: auth.value.user?.email ?? '',
}),
type: 'success',
})
} catch (err) {
addNotification({
title: 'An error occurred',
text: err.data.description,
title: formatMessage(messages.errorOccurredTitle),
text: getErrorMessage(err),
type: 'error',
})
}
-205
View File
@@ -1,205 +0,0 @@
<template>
<div class="welcome-box has-bot">
<img :src="WavingRinthbot" alt="Waving Modrinth Bot" class="welcome-box__waving-bot" />
<div class="welcome-box__top-glow" />
<div class="welcome-box__body">
<h1 class="welcome-box__title">
{{ formatMessage(messages.welcomeLongTitle) }}
</h1>
<p class="welcome-box__subtitle">
<IntlFormatted :message-id="messages.welcomeDescription">
<template #bold="{ children }">
<strong>
<component :is="() => normalizeChildren(children)" />
</strong>
</template>
</IntlFormatted>
</p>
<Checkbox
v-model="subscribe"
class="subscribe-btn"
:label="formatMessage(messages.subscribeCheckbox)"
:description="formatMessage(messages.subscribeCheckbox)"
/>
<ButtonStyled color="brand">
<button class="centered-btn" @click="continueSignUp">
{{ formatMessage(commonMessages.continueButton) }}
<RightArrowIcon />
</button>
</ButtonStyled>
<p class="tos-text">
<IntlFormatted :message-id="messages.tosLabel">
<template #terms-link="{ children }">
<NuxtLink to="/legal/terms" class="text-link">
<component :is="() => children" />
</NuxtLink>
</template>
<template #privacy-policy-link="{ children }">
<NuxtLink to="/legal/privacy" class="text-link">
<component :is="() => children" />
</NuxtLink>
</template>
</IntlFormatted>
</p>
</div>
</div>
</template>
<script setup>
import { RightArrowIcon, WavingRinthbot } from '@modrinth/assets'
import {
ButtonStyled,
Checkbox,
commonMessages,
defineMessages,
IntlFormatted,
normalizeChildren,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
const queryClient = useQueryClient()
const route = useRoute()
const { formatMessage } = useVIntl()
const messages = defineMessages({
subscribeCheckbox: {
id: 'auth.welcome.checkbox.subscribe',
defaultMessage: 'Subscribe to updates about Modrinth',
},
tosLabel: {
id: 'auth.welcome.label.tos',
defaultMessage:
"By creating an account, you have agreed to Modrinth's <terms-link>Terms</terms-link> and <privacy-policy-link>Privacy Policy</privacy-policy-link>.",
},
welcomeDescription: {
id: 'auth.welcome.description',
defaultMessage:
'Youre now part of the awesome community of creators & explorers already building, downloading, and staying up-to-date with amazing mods.',
},
welcomeLongTitle: {
id: 'auth.welcome.long-title',
defaultMessage: 'Welcome to Modrinth!',
},
welcomeTitle: {
id: 'auth.welcome.title',
defaultMessage: 'Welcome',
},
})
useHead({
title: () => `${formatMessage(messages.welcomeTitle)} - Modrinth`,
})
const subscribe = ref(true)
onMounted(async () => {
await useAuth(route.query.authToken)
await useUser()
queryClient.clear()
})
async function continueSignUp() {
if (subscribe.value) {
try {
await useBaseFetch('auth/email/subscribe', {
method: 'POST',
})
} catch {
// Ignored
}
}
if (route.query.redirect) {
await navigateTo(route.query.redirect)
} else {
await navigateTo('/dashboard')
}
}
</script>
<style lang="scss" scoped>
.welcome-box {
background-color: var(--color-raised-bg);
border-radius: var(--size-rounded-lg);
padding: 1.75rem 2rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
box-shadow: var(--shadow-card);
position: relative;
&.has-bot {
margin-block: 120px;
}
p {
margin: 0;
}
a {
color: var(--color-brand);
font-weight: var(--weight-bold);
&:hover,
&:focus {
filter: brightness(1.125);
text-decoration: underline;
}
}
&__waving-bot {
--bot-height: 112px;
position: absolute;
top: calc(-1 * var(--bot-height));
right: 5rem;
height: var(--bot-height);
width: auto;
@media (max-width: 768px) {
--bot-height: 70px;
right: 2rem;
}
}
&__top-glow {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 1px;
opacity: 0.4;
background: linear-gradient(
to right,
transparent 2rem,
var(--color-green) calc(100% - 13rem),
var(--color-green) calc(100% - 5rem),
transparent calc(100% - 2rem)
);
}
&__body {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
&__title {
font-size: var(--text-32);
font-weight: var(--weight-extrabold);
margin: 0;
}
&__subtitle {
font-size: var(--text-18);
}
.tos-text {
font-size: var(--text-14);
line-height: 1.5;
}
}
</style>