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

This commit is contained in:
tdgao
2026-04-17 16:54:44 -06:00
parent a5ccfab01f
commit 2d8c66186c
5 changed files with 146 additions and 73 deletions
@@ -15,25 +15,29 @@
<input
id="create-account-dob"
v-model="dateOfBirthModel"
class="scheme-dark w-full border-0 bg-surface-4 text-lg text-contrast outline-none [color-scheme:dark]"
class="scheme-dark w-full border-0 bg-surface-4 text-lg text-primary outline-none [color-scheme:dark]"
type="date"
:max="maxBirthDate"
/>
<div>You must be over 13 years old to use Modrinth.</div>
<Admonition type="info">
<div class="flex flex-col gap-1.5 leading-normal">
<div>
{{ formatMessage(messages.infoPanelText) }}
<div>
{{ formatMessage(messages.over13HelperText) }}
</div>
<Admonition :type="'info'">
<template #header>
<div class="-mb-2 flex flex-col gap-1.5 font-normal leading-normal">
<div>
{{ formatMessage(messages.infoPanelText) }}
</div>
<a
class="w-fit text-link underline"
:href="sourceCodeUrl"
target="_blank"
rel="noopener noreferrer"
>
{{ formatMessage(messages.relevantSourceCodeText) }}
</a>
</div>
<a
class="w-fit text-link"
:href="sourceCodeUrl"
target="_blank"
rel="noopener noreferrer"
>
{{ formatMessage(messages.relevantSourceCodeText) }}
</a>
</div>
</template>
</Admonition>
</section>
@@ -71,7 +75,7 @@
<button
class="!w-full font-bold"
:disabled="globals?.captcha_enabled ? !tokenModel : false"
@click="onCompleteSignUp()"
@click="onCompleteSignUpClick"
>
{{ formatMessage(messages.completeSignUpButton) }}
</button>
@@ -85,6 +89,7 @@ import {
ButtonStyled,
Checkbox,
defineMessages,
injectNotificationManager,
StyledInput,
useVIntl,
} from '@modrinth/ui'
@@ -120,7 +125,7 @@ const props = defineProps({
sourceCodeUrl: {
type: String,
default:
'https://github.com/modrinth/labrinth/blob/main/apps/labrinth/src/routes/internal/flows.rs',
'https://github.com/modrinth/code/blob/main/apps/frontend/src/components/ui/auth/CreateAccount.vue',
},
onCompleteSignUp: {
type: Function,
@@ -165,8 +170,40 @@ const maxBirthDate = computed(() => {
return date.toISOString().slice(0, 10)
})
const isDateOfBirthMissing = computed(() => props.requiresDob && dateOfBirthModel.value === '')
const isUnder13 = computed(
() =>
props.requiresDob &&
dateOfBirthModel.value !== '' &&
dateOfBirthModel.value > maxBirthDate.value,
)
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
function onCompleteSignUpClick() {
if (isDateOfBirthMissing.value) {
addNotification({
title: formatMessage(messages.dateOfBirthRequiredTitle),
text: formatMessage(messages.dateOfBirthRequiredText),
type: 'warning',
})
return
}
if (isUnder13.value) {
addNotification({
title: formatMessage(messages.ageRequirementWarningTitle),
text: formatMessage(messages.under13HelperText),
type: 'error',
})
return
}
props.onCompleteSignUp()
}
const messages = defineMessages({
title: {
id: 'auth.create-account.title',
@@ -176,10 +213,26 @@ const messages = defineMessages({
id: 'auth.create-account.date-of-birth.label',
defaultMessage: 'Date of birth',
},
dateOfBirthRequiredTitle: {
id: 'auth.create-account.date-of-birth.required.title',
defaultMessage: 'Date of birth required',
},
dateOfBirthRequiredText: {
id: 'auth.create-account.date-of-birth.required.text',
defaultMessage: 'Please enter your date of birth before continuing.',
},
over13HelperText: {
id: 'auth.create-account.date-of-birth.over13-helper',
defaultMessage: 'You must be over 13 years old to use Modrinth.',
},
under13HelperText: {
id: 'auth.create-account.date-of-birth.under13-helper',
defaultMessage: 'You cannot create an account at Modrinth unless you are 13 years old.',
},
ageRequirementWarningTitle: {
id: 'auth.create-account.age-requirement.warning-title',
defaultMessage: 'Age requirement',
},
infoPanelText: {
id: 'auth.create-account.info-panel.text',
defaultMessage:
@@ -79,13 +79,6 @@
wrapper-class="w-full"
/>
<Checkbox
v-model="subscribeModel"
class="subscribe-btn"
:label="formatMessage(messages.subscribeLabel)"
:description="formatMessage(messages.subscribeLabel)"
/>
<p v-if="!routeQuery.launcher">
<IntlFormatted :message-id="messages.legalDisclaimer">
<template #terms-link="{ children }">
@@ -101,17 +94,11 @@
</IntlFormatted>
</p>
<HCaptcha
v-if="globals?.captcha_enabled && emailModel && passwordModel"
:ref="onSetCaptchaRef"
v-model="tokenModel"
/>
<ButtonStyled color="brand">
<button
class="!w-full"
:disabled="globals?.captcha_enabled ? !tokenModel : false"
@click="onCreateAccount()"
:disabled="!emailModel || !passwordModel"
@click="onContinueWithEmail()"
>
{{ formatMessage(messages.continueWithEmail) }} <RightArrowIcon />
</button>
@@ -147,7 +134,6 @@ import {
} from '@modrinth/assets'
import {
ButtonStyled,
Checkbox,
commonMessages,
defineMessages,
IntlFormatted,
@@ -156,7 +142,6 @@ import {
} from '@modrinth/ui'
import { computed } from 'vue'
import HCaptcha from '@/components/ui/auth/HCaptcha.vue'
import { getAuthUrl } from '@/composables/auth.ts'
const props = defineProps({
@@ -172,10 +157,6 @@ const props = defineProps({
type: Object,
default: () => ({}),
},
globals: {
type: Object,
default: null,
},
email: {
type: String,
default: '',
@@ -184,29 +165,17 @@ const props = defineProps({
type: String,
default: '',
},
token: {
type: String,
default: '',
},
subscribe: {
type: Boolean,
default: false,
},
onToggleOtherOptions: {
type: Function,
default: () => {},
},
onCreateAccount: {
onContinueWithEmail: {
type: Function,
default: () => {},
},
onSetCaptchaRef: {
type: Function,
default: undefined,
},
})
const emit = defineEmits(['update:email', 'update:password', 'update:token', 'update:subscribe'])
const emit = defineEmits(['update:email', 'update:password'])
const emailModel = computed({
get: () => props.email,
@@ -218,16 +187,6 @@ const passwordModel = computed({
set: (value) => emit('update:password', value),
})
const tokenModel = computed({
get: () => props.token,
set: (value) => emit('update:token', value),
})
const subscribeModel = computed({
get: () => props.subscribe,
set: (value) => emit('update:subscribe', value),
})
const { formatMessage } = useVIntl()
const messages = defineMessages({
@@ -239,10 +198,6 @@ const messages = defineMessages({
id: 'auth.continue-with-provider',
defaultMessage: 'Continue with {provider}',
},
subscribeLabel: {
id: 'auth.sign-up.subscribe.label',
defaultMessage: 'Subscribe to updates about Modrinth',
},
legalDisclaimer: {
id: 'auth.sign-up.legal-dislaimer',
defaultMessage:
+50 -7
View File
@@ -1,15 +1,22 @@
<template>
<SignUpView
v-if="!isCreateAccountStep"
v-model:email="email"
v-model:password="password"
v-model:token="token"
v-model:subscribe="subscribe"
:redirect-target="redirectTarget"
:show-other-options="showOtherOptions"
:route-query="route.query"
:globals="globals"
:on-toggle-other-options="toggleOtherOptions"
:on-create-account="createAccount"
: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>
@@ -24,6 +31,7 @@ import {
} from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import CreateAccountView from '@/components/ui/auth/CreateAccount.vue'
import SignUpView from '@/components/ui/auth/SignUp.vue'
const client = injectModrinthClient()
@@ -35,6 +43,14 @@ const messages = defineMessages({
id: 'auth.sign-up.title',
defaultMessage: 'Sign Up',
},
ageRequirementWarningTitle: {
id: 'auth.sign-up.age-requirement.warning-title',
defaultMessage: 'Age requirement',
},
under13HelperText: {
id: 'auth.create-account.date-of-birth.under13-helper',
defaultMessage: 'You cannot create an account at Modrinth unless you are 13 years old.',
},
})
useHead({
@@ -46,6 +62,7 @@ const route = useNativeRoute()
const redirectTarget = route.query.redirect
const showOtherOptions = ref(false)
const isCreateAccountStep = ref(false)
if (auth.value.user) {
await navigateTo('/dashboard')
@@ -73,12 +90,38 @@ const { data: globals } = useQuery({
const email = ref('')
const password = ref('')
const dateOfBirth = ref('')
const username = ref('')
const token = ref('')
const subscribe = ref(false)
async function continueWithEmail() {
startLoading()
try {
const generatedUsername = generateUsernameFromEmail(email.value)
await client.labrinth.auth_v2.validateCreateAccount({
username: generatedUsername,
password: password.value,
email: email.value,
})
token.value = ''
username.value = generatedUsername
isCreateAccountStep.value = true
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading()
}
function generateUsernameFromEmail(emailAddress) {
const [localPart = '', domainPart = ''] = emailAddress.trim().toLowerCase().split('@')
const sanitized = `${localPart}_${domainPart}`
const [localPart = ''] = emailAddress.trim().toLowerCase().split('@')
const sanitized = localPart
.replace(/[^a-zA-Z0-9_-]/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '')
@@ -90,7 +133,7 @@ async function createAccount() {
startLoading()
try {
const res = await client.labrinth.auth_v2.createAccount({
username: generateUsernameFromEmail(email.value),
username: username.value.trim() || generateUsernameFromEmail(email.value),
password: password.value,
email: email.value,
challenge: token.value,
@@ -57,6 +57,22 @@ export class LabrinthAuthV2Module extends AbstractModule {
})
}
/**
* Validate email/password inputs for account creation without creating an account.
*
* @param data - Prospective account credentials
*/
public async validateCreateAccount(
data: Labrinth.Auth.v2.ValidateCreateAccountRequest,
): Promise<void> {
return this.client.request(`/auth/create/validate`, {
api: 'labrinth',
version: 2,
method: 'POST',
body: data,
})
}
/**
* Create a new account from an OAuth callback flow state
*
@@ -278,6 +278,12 @@ export namespace Labrinth {
session: string
}
export type ValidateCreateAccountRequest = {
username: string
password: string
email: string
}
export type CreateOAuthAccountRequest = {
username: string
state: string