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,416 @@
<template>
<div class="contents">
<ConfirmModal
ref="removePasskeyModal"
:title="formatMessage(messages.removePasskeyConfirmTitle)"
:description="
formatMessage(messages.removePasskeyConfirmDescription, { name: passkeyToRemove?.name })
"
:proceed-label="formatMessage(commonMessages.removeButton)"
@proceed="removePasskey()"
/>
<NewModal
ref="managePasskeyModal"
width="600px"
:header="formatMessage(messages.managePasskeyTitle)"
>
<div class="flex flex-col gap-4">
<div v-if="passkeysLoading" class="flex items-center gap-2 text-secondary">
<SpinnerIcon class="animate-spin" />
{{ formatMessage(messages.managePasskeyModalLoading) }}
</div>
<template v-else>
<p v-if="passkeys.length === 0" class="m-0 text-secondary">
{{ formatMessage(messages.managePasskeyModalNoPasskeys) }}
</p>
<div
v-for="passkey in passkeys"
:key="passkey.id"
class="universal-card recessed flex items-center justify-between"
>
<div>
<div>
<strong>{{ passkey.name }}</strong>
</div>
<div>
<span v-tooltip="formatDateTime(passkey.created_at)">
{{
formatMessage(messages.managePasskeyModalAdded, {
ago: formatRelativeTime(passkey.created_at),
})
}}
</span>
<span v-if="passkey.last_used" v-tooltip="formatDateTime(passkey.last_used)">
{{
formatMessage(messages.managePasskeyModalLastUsed, {
ago: formatRelativeTime(passkey.last_used),
})
}}
</span>
<span v-else>
{{ formatMessage(messages.managePasskeyModalNeverUsed) }}
</span>
</div>
</div>
<div class="flex gap-2">
<ButtonStyled circular>
<button
v-tooltip="formatMessage(commonMessages.renameButton)"
@click="
() => {
passkeyToRename = { ...passkey }
renamePasskeyModal?.show()
}
"
>
<EditIcon />
</button>
</ButtonStyled>
<ButtonStyled circular>
<button
v-tooltip="formatMessage(commonMessages.removeButton)"
@click="
() => {
passkeyToRemove = passkey
removePasskeyModal?.show()
}
"
>
<TrashIcon />
</button>
</ButtonStyled>
</div>
</div>
</template>
<div class="input-group self-end">
<ButtonStyled>
<button @click="registerPasskey()">
<PlusIcon />
{{ formatMessage(messages.managePasskeyAddPasskey) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="managePasskeyModal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.closeButton) }}
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<NewModal
ref="addPasskeyModal"
width="500px"
:header="formatMessage(messages.managePasskeyAddPasskey)"
>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<div class="text-md font-semibold text-contrast">
{{ formatMessage(messages.passkeyNameLabel) }}
</div>
<StyledInput
id="passkey-name"
v-model="pendingPasskeyName"
:maxlength="255"
type="text"
:placeholder="formatMessage(messages.passkeyNamePlaceholder)"
@keyup.enter="pendingPasskeyName && finishRegisterPasskey()"
/>
<div class="label__description">
{{ formatMessage(messages.passkeyNameDescription) }}
</div>
</div>
<div class="flex justify-end gap-2.5">
<ButtonStyled>
<button @click="addPasskeyModal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!pendingPasskeyName" @click="finishRegisterPasskey()">
<PlusIcon />
{{ formatMessage(messages.managePasskeyAddPasskey) }}
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<NewModal
ref="renamePasskeyModal"
width="500px"
:header="formatMessage(messages.renamePasskeyModalHeader)"
>
<div v-if="passkeyToRename" class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<div class="text-md font-semibold text-contrast">
{{ formatMessage(messages.passkeyNameLabel) }}
</div>
<StyledInput
id="passkey-rename"
v-model="passkeyToRenameName"
:maxlength="255"
type="text"
:placeholder="formatMessage(messages.passkeyNamePlaceholder)"
@keyup.enter="passkeyToRenameName && renamePasskey()"
/>
<div class="label__description">
{{ formatMessage(messages.passkeyNameDescription) }}
</div>
</div>
<div class="flex justify-end gap-2.5">
<ButtonStyled>
<button @click="renamePasskeyModal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!passkeyToRenameName" @click="renamePasskey()">
<SaveIcon />
{{ formatMessage(commonMessages.saveButton) }}
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<div class="adjacent-input mb-0">
<label for="manage-passkeys">
<span class="label__title">{{ formatMessage(messages.managePasskeyTitle) }}</span>
<span class="label__description">{{
formatMessage(messages.managePasskeyDescription)
}}</span>
</label>
<div>
<ButtonStyled>
<button id="manage-passkeys" @click="showPasskeyModal">
<UserKeyIcon /> {{ formatMessage(messages.managePasskeyTitle) }}
</button>
</ButtonStyled>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
EditIcon,
PlusIcon,
SaveIcon,
SpinnerIcon,
TrashIcon,
UserKeyIcon,
XIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
ConfirmModal,
defineMessages,
injectModrinthClient,
injectNotificationManager,
NewModal,
StyledInput,
useFormatDateTime,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import { computed, ref, useTemplateRef } from 'vue'
import { createPasskeyCredential } from '~/helpers/passkey.ts'
const { labrinth } = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
})
function notifyError(err: unknown) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err instanceof Error ? err.message : String(err),
type: 'error',
})
}
const messages = defineMessages({
managePasskeyTitle: {
id: 'settings.account.security.passkey.title',
defaultMessage: 'Manage passkeys',
},
managePasskeyDescription: {
id: 'settings.account.security.passkey.description',
defaultMessage: 'Manage your registered passkeys, or add a new one.',
},
managePasskeyAddPasskey: {
id: 'settings.account.security.passkey.add',
defaultMessage: 'Add passkey',
},
managePasskeyModalLoading: {
id: 'settings.account.security.passkey.modal.loading',
defaultMessage: 'Loading passkeys…',
},
managePasskeyModalNoPasskeys: {
id: 'settings.account.security.passkey.modal.no-passkeys',
defaultMessage: 'You do not have any passkeys registered.',
},
managePasskeyModalAdded: {
id: 'settings.account.security.passkey.modal.added',
defaultMessage: 'Added {ago}',
},
managePasskeyModalLastUsed: {
id: 'settings.account.security.passkey.modal.last-used',
defaultMessage: 'Last used {ago}',
},
managePasskeyModalNeverUsed: {
id: 'settings.account.security.passkey.modal.never-used',
defaultMessage: 'Never used',
},
passkeyNameLabel: {
id: 'settings.account.security.passkey.add-modal.name.label',
defaultMessage: 'Name',
},
passkeyNameDescription: {
id: 'settings.account.security.passkey.add-modal.name.description',
defaultMessage:
'Make sure to pick something memorable, so you can identify this passkey later.',
},
passkeyNamePlaceholder: {
id: 'settings.account.security.passkey.add-modal.name.placeholder',
defaultMessage: 'My passkey',
},
renamePasskeyModalHeader: {
id: 'settings.account.security.passkey.rename-modal.header',
defaultMessage: 'Rename passkey',
},
removePasskeyConfirmTitle: {
id: 'settings.account.security.passkey.remove.title',
defaultMessage: 'Are you sure you want to remove this passkey?',
},
removePasskeyConfirmDescription: {
id: 'settings.account.security.passkey.remove.description',
defaultMessage:
'This will permanently remove the passkey "{name}". You will no longer be able to sign in with it.',
},
})
const removePasskeyModal = useTemplateRef<InstanceType<typeof ConfirmModal>>('removePasskeyModal')
const managePasskeyModal = useTemplateRef<InstanceType<typeof NewModal>>('managePasskeyModal')
const addPasskeyModal = useTemplateRef<InstanceType<typeof NewModal>>('addPasskeyModal')
const renamePasskeyModal = useTemplateRef<InstanceType<typeof NewModal>>('renamePasskeyModal')
const pendingPasskey = ref<Labrinth.Auth.v2.PasskeyRegisterFinishRequest | null>(null)
const passkeyToRemove = ref<Labrinth.Auth.v2.Passkey | null>(null)
const passkeyToRename = ref<Labrinth.Auth.v2.Passkey | null>(null)
const pendingPasskeyName = computed({
get: () => pendingPasskey.value?.name ?? '',
set: (name) => {
if (pendingPasskey.value) {
pendingPasskey.value.name = name
}
},
})
const passkeyToRenameName = computed({
get: () => passkeyToRename.value?.name ?? '',
set: (name) => {
if (passkeyToRename.value) {
passkeyToRename.value.name = name
}
},
})
const passkeys = ref<Labrinth.Auth.v2.Passkey[]>([])
const passkeysLoading = ref(false)
async function fetchPasskeys() {
passkeysLoading.value = true
try {
passkeys.value = await labrinth.auth_v2.listPasskeys()
} catch (err) {
notifyError(err)
}
passkeysLoading.value = false
}
async function showPasskeyModal() {
managePasskeyModal.value?.show()
await fetchPasskeys()
}
async function registerPasskey() {
startLoading()
try {
const res = await labrinth.auth_v2.registerPasskeyStart()
const credential = await createPasskeyCredential(res.options.publicKey)
pendingPasskey.value = {
flow: res.flow,
credential: credential,
name: '',
}
addPasskeyModal.value?.show()
} catch (err) {
notifyError(err)
}
stopLoading()
}
async function finishRegisterPasskey() {
if (!pendingPasskey.value) return
startLoading()
try {
const passkey = await labrinth.auth_v2.registerPasskeyFinish(pendingPasskey.value)
passkeys.value.unshift(passkey)
pendingPasskey.value = null
addPasskeyModal.value?.hide()
} catch (err) {
notifyError(err)
}
stopLoading()
}
async function renamePasskey() {
if (!passkeyToRename.value) return
const { id, name } = passkeyToRename.value
startLoading()
try {
await labrinth.auth_v2.renamePasskey(id, { name })
const existing = passkeys.value.find((passkey) => passkey.id === id)
if (existing) {
existing.name = name
}
passkeyToRename.value = null
renamePasskeyModal.value?.hide()
} catch (err) {
notifyError(err)
}
stopLoading()
}
async function removePasskey() {
if (!passkeyToRemove.value) return
const { id } = passkeyToRemove.value
startLoading()
try {
await labrinth.auth_v2.deletePasskey(id)
passkeys.value = passkeys.value.filter((passkey) => passkey.id !== id)
passkeyToRemove.value = null
} catch (err) {
notifyError(err)
}
stopLoading()
}
</script>