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
@@ -0,0 +1,319 @@
<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
class="universal-card mx-auto flex w-full max-w-[27rem] flex-col gap-6 border border-solid border-surface-5 !p-6"
>
<template v-if="flow && !subtleLauncherRedirectUri">
<div class="flex flex-col items-end gap-4">
<div class="flex flex-col gap-1.5">
<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="twoFactorCodeModel"
:maxlength="11"
inputmode="numeric"
:placeholder="formatMessage(messages.twoFactorCodeInputPlaceholder)"
autocomplete="one-time-code"
@keyup.enter="onTwoFactorSignIn()"
/>
</div>
<ButtonStyled color="brand">
<button @click="onTwoFactorSignIn()">
{{ formatMessage(commonMessages.signInButton) }} <RightArrowIcon />
</button>
</ButtonStyled>
</div>
</template>
<template v-else>
<div class="flex flex-col gap-5">
<div class="text-center text-2xl font-semibold text-contrast">
{{ formatMessage(messages.signInWithLabel) }}
</div>
<section class="grid grid-cols-1 gap-2.5 sm:grid-cols-2">
<ButtonStyled v-for="provider in oauthProviders" :key="provider.id">
<a
class="relative w-full !justify-center overflow-visible !shadow-none"
:class="{
'!border !border-[var(--color-green)]': lastSignInProvider === provider.id,
}"
:href="getAuthUrl(provider.id, redirectTarget)"
:aria-label="
formatMessage(messages.continueWithProvider, { provider: provider.name })
"
@click="onOAuthProviderClick(provider.id)"
>
<component :is="provider.icon" />
<span>{{ provider.name }}</span>
<span
v-if="lastSignInProvider === provider.id"
class="oauth-provider-last-sign-in-badge"
>
{{ formatMessage(messages.lastSignInLabel) }}
</span>
</a>
</ButtonStyled>
<ButtonStyled>
<a
class="relative w-full !justify-center overflow-visible !shadow-none sm:col-span-2"
:class="{ '!border !border-[var(--color-green)]': lastSignInProvider === 'passkey' }"
role="button"
tabindex="0"
@click="onPasskeySignIn"
@keydown.enter="onPasskeySignIn"
>
<UserKeyIcon />
<span class="ml-1">{{ formatMessage(messages.continueWithPasskey) }}</span>
<span
v-if="lastSignInProvider === 'passkey'"
class="oauth-provider-last-sign-in-badge"
>
{{ formatMessage(messages.lastSignInLabel) }}
</span>
</a>
</ButtonStyled>
</section>
<div class="h-px w-full bg-surface-5"></div>
<section class="mx-auto flex w-full flex-col gap-2.5">
<label for="email" hidden>{{ formatMessage(commonMessages.emailUsernameLabel) }}</label>
<StyledInput
id="email"
v-model="emailModel"
: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="passwordModel"
:icon="KeyIcon"
type="password"
autocomplete="current-password"
:placeholder="formatMessage(commonMessages.passwordLabel)"
wrapper-class="w-full"
/>
<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="onPasswordSignIn()"
>
{{ formatMessage(messages.continueWithEmail) }} <RightArrowIcon />
</button>
</ButtonStyled>
<div class="flex flex-wrap items-center justify-center gap-2.5 !text-base">
<NuxtLink
class="text-link"
:to="{
path: '/auth/reset-password',
query: routeQuery,
}"
>
{{ formatMessage(messages.forgotPasswordLabel) }}
</NuxtLink>
<div class="h-1.5 w-1.5 rounded-full bg-surface-5" />
<NuxtLink
class="inline text-link"
:to="{
path: '/auth/sign-up',
query: routeQuery,
}"
>
{{ formatMessage(messages.createAccountLabel) }}
</NuxtLink>
</div>
</section>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import {
DiscordColorIcon,
GitHubColorIcon,
GitLabColorIcon,
GoogleColorIcon,
KeyIcon,
MailIcon,
MicrosoftColorIcon,
RightArrowIcon,
SteamColorIcon,
UserKeyIcon,
} from '@modrinth/assets'
import { ButtonStyled, commonMessages, defineMessages, StyledInput, useVIntl } from '@modrinth/ui'
import { useStorage } from '@vueuse/core'
import { computed } from 'vue'
import type { LocationQuery } from 'vue-router'
import HCaptcha from '@/components/ui/auth/HCaptcha.vue'
import {
getAuthUrl,
LAST_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
} from '@/composables/auth.ts'
const oauthProviders = [
{ id: 'discord', name: 'Discord', icon: DiscordColorIcon },
{ id: 'github', name: 'GitHub', icon: GitHubColorIcon },
{ id: 'microsoft', name: 'Microsoft', icon: MicrosoftColorIcon },
{ id: 'google', name: 'Google', icon: GoogleColorIcon },
{ id: 'steam', name: 'Steam', icon: SteamColorIcon },
{ id: 'gitlab', name: 'GitLab', icon: GitLabColorIcon },
] as const
type AuthProvider = (typeof oauthProviders)[number]['id'] | 'passkey'
interface AuthGlobals {
captcha_enabled?: boolean
[key: string]: unknown
}
interface Props {
subtleLauncherRedirectUri?: string
flow?: string
redirectTarget?: string
routeQuery?: LocationQuery
globals?: AuthGlobals | null
onPasswordSignIn?: () => void
onTwoFactorSignIn?: () => void
onPasskeySignIn?: () => void
onSetCaptchaRef?: ((captchaRef: unknown) => void) | undefined
}
const {
subtleLauncherRedirectUri = '',
flow = '',
redirectTarget = '',
routeQuery = {},
globals = null,
onPasswordSignIn = () => {},
onTwoFactorSignIn = () => {},
onPasskeySignIn = () => {},
onSetCaptchaRef = undefined,
} = defineProps<Props>()
const emailModel = defineModel<string>('email', { default: '' })
const passwordModel = defineModel<string>('password', { default: '' })
const tokenModel = defineModel<string>('token', { default: '' })
const twoFactorCodeModel = defineModel<string>('twoFactorCode', { default: '' })
const lastSignInOAuthProvider = useStorage<AuthProvider | null>(
LAST_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
null,
undefined,
{ initOnMounted: true },
)
const pendingSignInOAuthProvider = useStorage<AuthProvider | null>(
PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
null,
undefined,
{ initOnMounted: true },
)
const lastSignInProvider = computed(() => lastSignInOAuthProvider.value)
const onOAuthProviderClick = (provider: AuthProvider) => {
pendingSignInOAuthProvider.value = provider
}
const { formatMessage } = useVIntl()
const messages = defineMessages({
forgotPasswordLabel: {
id: 'auth.sign-in.forgot-password',
defaultMessage: 'Forgot password',
},
noAccountLabel: {
id: 'auth.sign-in.no-account',
defaultMessage: "Don't have an account?",
},
createAccountLabel: {
id: 'auth.sign-in.create-account',
defaultMessage: 'Sign up',
},
signInWithLabel: {
id: 'auth.sign-in.sign-in-with',
defaultMessage: 'Sign into Modrinth',
},
twoFactorCodeInputPlaceholder: {
id: 'auth.sign-in.2fa.placeholder',
defaultMessage: 'Enter code...',
},
twoFactorCodeLabel: {
id: 'auth.sign-in.2fa.label',
defaultMessage: 'Two-factor authentication',
},
twoFactorCodeLabelDescription: {
id: 'auth.sign-in.2fa.description',
defaultMessage:
'Enter the 6-digit code from your authenticator app, or one of your backup codes.',
},
continueWithProvider: {
id: 'auth.continue-with-provider',
defaultMessage: 'Continue with {provider}',
},
continueWithEmail: {
id: 'auth.sign-in.continue-with-email',
defaultMessage: 'Continue with Email',
},
lastSignInLabel: {
id: 'auth.sign-in.last-sign-in',
defaultMessage: 'Last used',
},
continueWithPasskey: {
id: 'auth.sign-in.continue-with-passkey',
defaultMessage: 'Continue with passkey',
},
})
</script>
<style scoped lang="scss">
.oauth-provider-last-sign-in-badge {
position: absolute;
top: -0.75rem;
right: 0.25rem;
z-index: 1;
border-radius: 9999px;
background-color: var(--surface-3);
color: var(--color-green);
border: 1px solid var(--color-green);
padding: 0.125rem 0.375rem;
font-size: 0.75rem;
font-weight: 600;
line-height: 1;
pointer-events: none;
}
.oauth-provider-last-sign-in-badge::before {
content: '';
inset: 0;
border-radius: inherit;
background-color: var(--color-green-highlight);
position: absolute;
z-index: 0;
}
</style>