mirror of
https://github.com/modrinth/code.git
synced 2026-08-26 09:34:50 +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
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<div
|
||||
class="shadow-card mx-auto flex w-full max-w-[27rem] flex-col gap-6 rounded-2xl border border-solid border-button-bg bg-surface-3 !p-6"
|
||||
>
|
||||
<h1
|
||||
class="mx-auto my-0 flex w-full justify-center text-center text-2xl font-semibold text-contrast"
|
||||
>
|
||||
{{ formatMessage(messages.title) }}
|
||||
</h1>
|
||||
|
||||
<section v-if="requiresDob" class="flex flex-col gap-2.5">
|
||||
<label class="text-md font-semibold text-contrast" for="create-account-dob">
|
||||
{{ formatMessage(messages.dateOfBirthLabel) }}
|
||||
</label>
|
||||
<DatePicker
|
||||
id="create-account-dob"
|
||||
v-model="dateOfBirthModel"
|
||||
wrapper-class="w-full"
|
||||
min-date="1900-01-01"
|
||||
:max-date="maxInputDate"
|
||||
:default-view-date="defaultDobViewDate"
|
||||
preserve-day
|
||||
:placeholder="formatMessage(messages.dateOfBirthPlaceholder)"
|
||||
/>
|
||||
<Admonition :type="'info'">
|
||||
<template #header>
|
||||
<div class="-mb-3 -mt-1 flex flex-col gap-0 text-sm font-normal leading-normal">
|
||||
<div>
|
||||
{{ formatMessage(messages.infoPanelText) }}
|
||||
</div>
|
||||
<a
|
||||
class="w-fit text-link underline"
|
||||
:href="SOURCE_CODE_URL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ formatMessage(messages.relevantSourceCodeText) }}
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</Admonition>
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-2.5">
|
||||
<label class="text-md font-semibold text-contrast" for="create-account-username">
|
||||
{{ formatMessage(messages.usernameOptionalLabel) }}
|
||||
</label>
|
||||
<StyledInput
|
||||
id="create-account-username"
|
||||
v-model="usernameModel"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.usernamePlaceholder)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section 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 v-if="globals?.captcha_enabled" :ref="onSetCaptchaRef" v-model="tokenModel" />
|
||||
</section>
|
||||
|
||||
<div
|
||||
class="flex gap-2.5 rounded-2xl border border-solid border-surface-5 bg-surface-3 transition-all hover:brightness-110"
|
||||
>
|
||||
<Checkbox
|
||||
v-model="subscribeModel"
|
||||
class="p-3 text-left leading-snug text-primary transition-all"
|
||||
:label="formatMessage(messages.subscribeLabel)"
|
||||
:description="formatMessage(messages.subscribeLabel)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
class="!w-full font-bold"
|
||||
:disabled="globals?.captcha_enabled ? !tokenModel : false"
|
||||
@click="onCompleteSignUpClick"
|
||||
>
|
||||
{{ formatMessage(messages.completeSignUpButton) }}
|
||||
<RightArrowIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RightArrowIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import HCaptcha from '@/components/ui/auth/HCaptcha.vue'
|
||||
|
||||
interface AuthGlobals {
|
||||
captcha_enabled?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface Props {
|
||||
globals?: AuthGlobals | null
|
||||
requiresDob?: boolean
|
||||
onCompleteSignUp?: () => void
|
||||
onSetCaptchaRef?: ((captchaRef: unknown) => void) | undefined
|
||||
}
|
||||
|
||||
const {
|
||||
globals = null,
|
||||
requiresDob = true,
|
||||
onCompleteSignUp = () => {},
|
||||
onSetCaptchaRef = undefined,
|
||||
} = defineProps<Props>()
|
||||
|
||||
const SOURCE_CODE_URL =
|
||||
'https://github.com/modrinth/code/blob/main/apps/frontend/src/components/ui/auth/CreateAccount.vue'
|
||||
|
||||
const dateOfBirthModel = defineModel<string | null>('dateOfBirth', { default: '' })
|
||||
const usernameModel = defineModel<string>('username', { default: '' })
|
||||
const tokenModel = defineModel<string>('token', { default: '' })
|
||||
const subscribeModel = defineModel<boolean>('subscribe', { default: false })
|
||||
|
||||
const maxInputDate = computed(() => `${new Date().getFullYear()}-12-31`)
|
||||
|
||||
const defaultDobViewDate = computed(() => `${new Date().getFullYear()}-01-01`)
|
||||
|
||||
const maxBirthDate = computed(() => {
|
||||
const date = new Date()
|
||||
date.setFullYear(date.getFullYear() - 13)
|
||||
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
|
||||
return `${year}-${month}-${day}`
|
||||
})
|
||||
|
||||
const getBirthYear = (dateOfBirth: string | null): number | null => {
|
||||
if (!dateOfBirth) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [yearPart = ''] = dateOfBirth.split('-')
|
||||
const year = Number(yearPart)
|
||||
return Number.isInteger(year) ? year : null
|
||||
}
|
||||
|
||||
const isDateOfBirthMissing = computed(() => requiresDob && !dateOfBirthModel.value)
|
||||
|
||||
const isDateOfBirthYearZero = computed(() => {
|
||||
if (!requiresDob || !dateOfBirthModel.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
return getBirthYear(dateOfBirthModel.value) === 0
|
||||
})
|
||||
|
||||
const isUnder13 = computed(
|
||||
() => requiresDob && !!dateOfBirthModel.value && dateOfBirthModel.value > maxBirthDate.value,
|
||||
)
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
function onCompleteSignUpClick() {
|
||||
if (isDateOfBirthMissing.value) {
|
||||
addNotification({
|
||||
title: formatMessage(messages.dateOfBirthRequiredTitle),
|
||||
text: formatMessage(messages.dateOfBirthRequiredText),
|
||||
type: 'warning',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (isDateOfBirthYearZero.value) {
|
||||
addNotification({
|
||||
title: formatMessage(messages.dateOfBirthInvalidTitle),
|
||||
text: formatMessage(messages.dateOfBirthInvalidText),
|
||||
type: 'error',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (isUnder13.value) {
|
||||
addNotification({
|
||||
title: formatMessage(messages.ageRequirementWarningTitle),
|
||||
text: formatMessage(messages.under13HelperText),
|
||||
type: 'error',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
onCompleteSignUp()
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'auth.create-account.title',
|
||||
defaultMessage: 'Create an Account',
|
||||
},
|
||||
dateOfBirthLabel: {
|
||||
id: 'auth.create-account.date-of-birth.label',
|
||||
defaultMessage: 'Date of birth',
|
||||
},
|
||||
dateOfBirthPlaceholder: {
|
||||
id: 'auth.create-account.date-of-birth.placeholder',
|
||||
defaultMessage: 'Select your date of birth',
|
||||
},
|
||||
dateOfBirthRequiredTitle: {
|
||||
id: 'auth.create-account.date-of-birth.required.title',
|
||||
defaultMessage: 'Date of birth required',
|
||||
},
|
||||
dateOfBirthRequiredText: {
|
||||
id: 'auth.create-account.date-of-birth.required.text',
|
||||
defaultMessage: 'Please enter your date of birth before continuing.',
|
||||
},
|
||||
dateOfBirthInvalidTitle: {
|
||||
id: 'auth.create-account.date-of-birth.invalid.title',
|
||||
defaultMessage: 'Invalid date of birth',
|
||||
},
|
||||
dateOfBirthInvalidText: {
|
||||
id: 'auth.create-account.date-of-birth.invalid.text',
|
||||
defaultMessage: 'Please enter a valid date of birth. Year cannot be 0000.',
|
||||
},
|
||||
under13HelperText: {
|
||||
id: 'auth.create-account.date-of-birth.under13-helper',
|
||||
defaultMessage: 'You do not meet the age requirement to create an account at Modrinth.',
|
||||
},
|
||||
ageRequirementWarningTitle: {
|
||||
id: 'auth.create-account.age-requirement.warning-title',
|
||||
defaultMessage: 'Age requirement',
|
||||
},
|
||||
infoPanelText: {
|
||||
id: 'auth.create-account.info-panel.text',
|
||||
defaultMessage:
|
||||
'We do not store your date of birth. We only use it during sign-up to validate COPPA compliance.',
|
||||
},
|
||||
relevantSourceCodeText: {
|
||||
id: 'auth.create-account.info-panel.source-code-link',
|
||||
defaultMessage: 'Relevant source code',
|
||||
},
|
||||
usernameOptionalLabel: {
|
||||
id: 'auth.create-account.username.label',
|
||||
defaultMessage: 'Username',
|
||||
},
|
||||
usernamePlaceholder: {
|
||||
id: 'auth.create-account.username.placeholder',
|
||||
defaultMessage: 'Enter username',
|
||||
},
|
||||
securityCheckLabel: {
|
||||
id: 'auth.create-account.security-check.label',
|
||||
defaultMessage: 'Security check',
|
||||
},
|
||||
subscribeLabel: {
|
||||
id: 'auth.create-account.subscribe.label',
|
||||
defaultMessage: 'Keep me updated on the cool things Modrinth is working on via email',
|
||||
},
|
||||
completeSignUpButton: {
|
||||
id: 'auth.create-account.complete-sign-up',
|
||||
defaultMessage: 'Complete sign up',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
+21
-4
@@ -47,17 +47,34 @@ defineExpose({
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
<style>
|
||||
.h-captcha {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-md);
|
||||
border: 2px solid var(--color-button-bg);
|
||||
height: 78px;
|
||||
height: 100px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
iframe {
|
||||
margin: -1px;
|
||||
.h-captcha iframe {
|
||||
transform: scale(1.33);
|
||||
transform-origin: center;
|
||||
margin: -1px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 400px) {
|
||||
.h-captcha {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.h-captcha iframe {
|
||||
transform: scale(1.03);
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,236 @@
|
||||
<template>
|
||||
<div
|
||||
class="universal-card flex w-full max-w-[27rem] flex-col gap-6 border border-solid border-surface-5 !p-6"
|
||||
>
|
||||
<div class="mx-auto text-center text-2xl font-semibold text-contrast">
|
||||
{{ formatMessage(messages.signUpWithTitle) }}
|
||||
</div>
|
||||
<section class="flex flex-col gap-2.5">
|
||||
<ButtonStyled>
|
||||
<a
|
||||
class="!shadow-none"
|
||||
:href="getAuthUrl('google', redirectTarget)"
|
||||
@click="onOAuthProviderClick('google')"
|
||||
>
|
||||
<GoogleColorIcon />
|
||||
<span>{{ formatMessage(messages.continueWithProvider, { provider: 'Google' }) }}</span>
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<a
|
||||
class="!shadow-none"
|
||||
:href="getAuthUrl('microsoft', redirectTarget)"
|
||||
@click="onOAuthProviderClick('microsoft')"
|
||||
>
|
||||
<MicrosoftColorIcon />
|
||||
<span>{{ formatMessage(messages.continueWithProvider, { provider: 'Microsoft' }) }}</span>
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<a
|
||||
class="!shadow-none"
|
||||
:href="getAuthUrl('discord', redirectTarget)"
|
||||
@click="onOAuthProviderClick('discord')"
|
||||
>
|
||||
<DiscordColorIcon />
|
||||
<span>{{ formatMessage(messages.continueWithProvider, { provider: 'Discord' }) }}</span>
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<template v-if="showOtherOptions">
|
||||
<ButtonStyled>
|
||||
<a
|
||||
class="!shadow-none"
|
||||
:href="getAuthUrl('github', redirectTarget)"
|
||||
@click="onOAuthProviderClick('github')"
|
||||
>
|
||||
<GitHubColorIcon />
|
||||
<span>{{ formatMessage(messages.continueWithProvider, { provider: 'GitHub' }) }}</span>
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<a
|
||||
class="!shadow-none"
|
||||
:href="getAuthUrl('gitlab', redirectTarget)"
|
||||
@click="onOAuthProviderClick('gitlab')"
|
||||
>
|
||||
<GitLabColorIcon />
|
||||
<span>{{ formatMessage(messages.continueWithProvider, { provider: 'GitLab' }) }}</span>
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<a
|
||||
class="!shadow-none"
|
||||
:href="getAuthUrl('steam', redirectTarget)"
|
||||
@click="onOAuthProviderClick('steam')"
|
||||
>
|
||||
<SteamColorIcon />
|
||||
<span>{{ formatMessage(messages.continueWithProvider, { provider: 'Steam' }) }}</span>
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<button
|
||||
class="mx-auto -mb-3 bg-transparent pt-1 text-center text-base font-semibold text-secondary transition-all hover:text-primary"
|
||||
@click="onToggleOtherOptions()"
|
||||
>
|
||||
{{
|
||||
showOtherOptions
|
||||
? formatMessage(messages.showFewerOptions)
|
||||
: formatMessage(messages.showOtherOptions)
|
||||
}}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div class="h-px w-full bg-surface-5"></div>
|
||||
|
||||
<section class="flex flex-col gap-2.5">
|
||||
<label for="email" hidden>{{ formatMessage(commonMessages.emailLabel) }}</label>
|
||||
<StyledInput
|
||||
id="email"
|
||||
v-model="emailModel"
|
||||
:icon="MailIcon"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
:placeholder="formatMessage(commonMessages.emailLabel)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
|
||||
<label for="password" hidden>{{ formatMessage(commonMessages.passwordLabel) }}</label>
|
||||
<StyledInput
|
||||
id="password"
|
||||
v-model="passwordModel"
|
||||
:icon="KeyIcon"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
:placeholder="formatMessage(commonMessages.passwordLabel)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
class="!w-full"
|
||||
:disabled="!emailModel || !passwordModel"
|
||||
@click="onContinueWithEmail()"
|
||||
>
|
||||
{{ formatMessage(messages.continueWithEmail) }} <RightArrowIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<p v-if="!routeQuery.launcher" class="m-0 text-center">
|
||||
<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>
|
||||
|
||||
<div class="mx-auto flex flex-wrap items-center justify-start gap-2 text-center">
|
||||
{{ formatMessage(messages.alreadyHaveAccountLabel) }}
|
||||
<NuxtLink
|
||||
class="mr-1 text-link"
|
||||
:to="{
|
||||
path: '/auth/sign-in',
|
||||
query: routeQuery,
|
||||
}"
|
||||
>
|
||||
{{ formatMessage(commonMessages.signInButton) }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DiscordColorIcon,
|
||||
GitHubColorIcon,
|
||||
GitLabColorIcon,
|
||||
GoogleColorIcon,
|
||||
KeyIcon,
|
||||
MailIcon,
|
||||
MicrosoftColorIcon,
|
||||
RightArrowIcon,
|
||||
SteamColorIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
IntlFormatted,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
|
||||
import { getAuthUrl, PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY } from '@/composables/auth.ts'
|
||||
|
||||
type AuthProvider = 'discord' | 'google' | 'github' | 'gitlab' | 'steam' | 'microsoft'
|
||||
|
||||
interface Props {
|
||||
redirectTarget?: string
|
||||
showOtherOptions?: boolean
|
||||
routeQuery?: LocationQuery
|
||||
onToggleOtherOptions?: () => void
|
||||
onContinueWithEmail?: () => void
|
||||
}
|
||||
|
||||
const {
|
||||
redirectTarget = '',
|
||||
showOtherOptions = false,
|
||||
routeQuery = {},
|
||||
onToggleOtherOptions = () => {},
|
||||
onContinueWithEmail = () => {},
|
||||
} = defineProps<Props>()
|
||||
|
||||
const emailModel = defineModel<string>('email', { default: '' })
|
||||
const passwordModel = defineModel<string>('password', { default: '' })
|
||||
|
||||
const pendingSignInOAuthProvider = useStorage<AuthProvider | null>(
|
||||
PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
|
||||
null,
|
||||
)
|
||||
const onOAuthProviderClick = (provider: AuthProvider) => {
|
||||
pendingSignInOAuthProvider.value = provider
|
||||
}
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
signUpWithTitle: {
|
||||
id: 'auth.sign-up.title.sign-up-with',
|
||||
defaultMessage: 'Create an Account',
|
||||
},
|
||||
continueWithProvider: {
|
||||
id: 'auth.continue-with-provider',
|
||||
defaultMessage: 'Continue with {provider}',
|
||||
},
|
||||
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>.",
|
||||
},
|
||||
alreadyHaveAccountLabel: {
|
||||
id: 'auth.sign-up.sign-in-option.title',
|
||||
defaultMessage: 'Already have an account?',
|
||||
},
|
||||
continueWithEmail: {
|
||||
id: 'auth.sign-up.continue-with-email',
|
||||
defaultMessage: 'Continue with Email',
|
||||
},
|
||||
showFewerOptions: {
|
||||
id: 'auth.sign-up.show-fewer-options',
|
||||
defaultMessage: 'Show fewer options',
|
||||
},
|
||||
showOtherOptions: {
|
||||
id: 'auth.sign-up.show-other-options',
|
||||
defaultMessage: 'Show other options',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -166,9 +166,7 @@ defineExpose({
|
||||
show,
|
||||
})
|
||||
|
||||
const auth = (await useAuth()) as Ref<{
|
||||
user: { id: string; username: string; avatar_url: string } | null
|
||||
}>
|
||||
const auth = await useAuth()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
|
||||
+1
-2
@@ -120,7 +120,7 @@ import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import RevenueInputField from '@/components/ui/dashboard/RevenueInputField.vue'
|
||||
import WithdrawFeeBreakdown from '@/components/ui/dashboard/WithdrawFeeBreakdown.vue'
|
||||
import { getAuthUrl, removeAuthProvider, useAuth } from '@/composables/auth.js'
|
||||
import { getAuthUrl, removeAuthProvider, useAuth } from '@/composables/auth.ts'
|
||||
import { useWithdrawContext } from '@/providers/creator-withdraw.ts'
|
||||
|
||||
const { withdrawData, maxWithdrawAmount, availableMethods, calculateFees, saveStateToStorage } =
|
||||
@@ -193,7 +193,6 @@ async function saveVenmoHandle() {
|
||||
},
|
||||
})
|
||||
|
||||
// @ts-expect-error auth.js is not typed
|
||||
await useAuth(auth.value.token)
|
||||
|
||||
initialVenmoHandle.value = venmoHandle.value.trim()
|
||||
|
||||
+1
-1
@@ -358,8 +358,8 @@ import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import RevenueInputField from '@/components/ui/dashboard/RevenueInputField.vue'
|
||||
import WithdrawFeeBreakdown from '@/components/ui/dashboard/WithdrawFeeBreakdown.vue'
|
||||
import { useAuth } from '@/composables/auth.js'
|
||||
import { useWithdrawContext } from '@/providers/creator-withdraw.ts'
|
||||
import { useAuth } from '~/composables/auth.ts'
|
||||
|
||||
const debug = useDebugLogger('TremendousDetailsStage')
|
||||
const { withdrawData, maxWithdrawAmount, availableMethods, paymentOptions, calculateFees } =
|
||||
|
||||
@@ -190,7 +190,6 @@ import {
|
||||
LinkIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { type ExtendedReport, reportQuickReplies } from '@modrinth/moderation'
|
||||
import { type OverflowMenuOption, useFormatDateTime } from '@modrinth/ui'
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
@@ -198,6 +197,8 @@ import {
|
||||
getProjectTypeIcon,
|
||||
injectNotificationManager,
|
||||
OverflowMenu,
|
||||
type OverflowMenuOption,
|
||||
useFormatDateTime,
|
||||
useRelativeTime,
|
||||
} from '@modrinth/ui'
|
||||
import { formatProjectType } from '@modrinth/utils'
|
||||
|
||||
@@ -31,13 +31,13 @@ import {
|
||||
getProjectTypeIcon,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
NavTabs,
|
||||
OverflowMenu,
|
||||
type OverflowMenuOption,
|
||||
useFormatBytes,
|
||||
useFormatDateTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { NavTabs } from '@modrinth/ui'
|
||||
import {
|
||||
capitalizeString,
|
||||
formatProjectType,
|
||||
|
||||
+1
-1
@@ -87,7 +87,7 @@ const currentProjectId = computed(() => projectV3.value?.id)
|
||||
const { selectedProjectId, selectedVersionId } = injectServerCompatibilityContext()
|
||||
const { labrinth } = injectModrinthClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const auth = (await useAuth()) as { user?: { id: string } }
|
||||
const auth = await useAuth()
|
||||
|
||||
interface VersionInfo {
|
||||
id: string
|
||||
|
||||
Reference in New Issue
Block a user