mirror of
https://github.com/modrinth/code.git
synced 2026-08-27 01:54:47 +00:00
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 commit7ea0635d86. * 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 commitcad6bd4f92. * 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 commitb9b03796e3. * 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:
co-authored by
tdgao
Truman Gao
Michael H.
Calum H.
Calum H.
Prospector
DeDiamondPro
parent
6fc741f7c0
commit
ef4044534f
@@ -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>
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
'You’re 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>
|
||||
Reference in New Issue
Block a user