mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6c671c668 | ||
|
|
dc569ecd89 | ||
|
|
7bfb418a11 | ||
|
|
129dfa66ca |
@@ -183,24 +183,24 @@
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<template v-if="auth.user.has_totp && twoFactorStep === 0">
|
||||
<label for="two-factor-code">
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<span class="text-md font-semibold text-contrast">{{
|
||||
formatMessage(messages.twoFactorEnterCodeLabel)
|
||||
}}</span>
|
||||
<StyledInput
|
||||
id="two-factor-code"
|
||||
v-model="twoFactorCode"
|
||||
:maxlength="11"
|
||||
:placeholder="formatMessage(messages.twoFactorCodePlaceholder)"
|
||||
@keyup.enter="removeTwoFactor()"
|
||||
/>
|
||||
<span class="label__description">{{
|
||||
formatMessage(messages.twoFactorEnterCodeDescription)
|
||||
}}</span>
|
||||
</label>
|
||||
<StyledInput
|
||||
id="two-factor-code"
|
||||
v-model="twoFactorCode"
|
||||
:maxlength="11"
|
||||
:placeholder="formatMessage(messages.twoFactorCodePlaceholder)"
|
||||
@keyup.enter="removeTwoFactor()"
|
||||
/>
|
||||
<p v-if="twoFactorIncorrect" class="known-errors m-0">
|
||||
{{ formatMessage(messages.twoFactorIncorrectError) }}
|
||||
</p>
|
||||
<p v-if="twoFactorIncorrect" class="known-errors m-0">
|
||||
{{ formatMessage(messages.twoFactorIncorrectError) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2.5">
|
||||
<ButtonStyled>
|
||||
<button @click="$refs.manageTwoFactorModal.hide()">
|
||||
@@ -222,12 +222,13 @@
|
||||
<p class="m-0">
|
||||
<IntlFormatted :message-id="messages.twoFactorSetupScan">
|
||||
<template #authy-link="{ children }">
|
||||
<a href="https://authy.com/" target="_blank" rel="noreferrer">
|
||||
<a class="underline" href="https://authy.com/" target="_blank" rel="noreferrer">
|
||||
<component :is="() => children" />
|
||||
</a>
|
||||
</template>
|
||||
<template #microsoft-authenticator-link="{ children }">
|
||||
<a
|
||||
class="underline"
|
||||
href="https://www.microsoft.com/en-us/security/mobile-authenticator-app"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
@@ -252,25 +253,22 @@
|
||||
</p>
|
||||
</template>
|
||||
<template v-if="twoFactorStep === 1">
|
||||
<label for="verify-code">
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<span class="text-md font-semibold text-contrast">{{
|
||||
formatMessage(messages.twoFactorVerifyCodeLabel)
|
||||
}}</span>
|
||||
<TwoFactorAuthCodeInput
|
||||
ref="twoFactorCodeInput"
|
||||
v-model="twoFactorCode"
|
||||
@keyup.enter="verifyTwoFactorCode()"
|
||||
/>
|
||||
<span class="label__description">{{
|
||||
formatMessage(messages.twoFactorVerifyCodeDescription)
|
||||
}}</span>
|
||||
</label>
|
||||
<StyledInput
|
||||
id="verify-code"
|
||||
v-model="twoFactorCode"
|
||||
:maxlength="6"
|
||||
autocomplete="one-time-code"
|
||||
:placeholder="formatMessage(messages.twoFactorCodePlaceholder)"
|
||||
@keyup.enter="verifyTwoFactorCode()"
|
||||
/>
|
||||
<p v-if="twoFactorIncorrect" class="known-errors m-0">
|
||||
{{ formatMessage(messages.twoFactorIncorrectError) }}
|
||||
</p>
|
||||
<p v-if="twoFactorIncorrect" class="known-errors m-0">
|
||||
{{ formatMessage(messages.twoFactorIncorrectError) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="twoFactorStep === 2">
|
||||
<p class="m-0">{{ formatMessage(messages.twoFactorBackupCodesIntro) }}</p>
|
||||
@@ -478,6 +476,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, watch } from 'vue'
|
||||
import {
|
||||
CheckIcon,
|
||||
DownloadIcon,
|
||||
@@ -503,6 +502,7 @@ import {
|
||||
NewModal,
|
||||
StyledInput,
|
||||
Table,
|
||||
TwoFactorAuthCodeInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import KeyIcon from 'assets/icons/auth/key.svg'
|
||||
@@ -878,9 +878,10 @@ const manageTwoFactorModal = ref()
|
||||
const twoFactorSecret = ref(null)
|
||||
const twoFactorFlow = ref(null)
|
||||
const twoFactorStep = ref(0)
|
||||
const twoFactorCodeInput = ref()
|
||||
async function showTwoFactorModal() {
|
||||
twoFactorStep.value = 0
|
||||
twoFactorCode.value = null
|
||||
twoFactorCode.value = ''
|
||||
twoFactorIncorrect.value = false
|
||||
if (auth.value.user.has_totp) {
|
||||
manageTwoFactorModal.value.show()
|
||||
@@ -890,7 +891,6 @@ async function showTwoFactorModal() {
|
||||
twoFactorSecret.value = null
|
||||
twoFactorFlow.value = null
|
||||
backupCodes.value = []
|
||||
manageTwoFactorModal.value.show()
|
||||
|
||||
startLoading()
|
||||
try {
|
||||
@@ -908,11 +908,22 @@ async function showTwoFactorModal() {
|
||||
})
|
||||
}
|
||||
stopLoading()
|
||||
manageTwoFactorModal.value.show()
|
||||
}
|
||||
|
||||
const twoFactorIncorrect = ref(false)
|
||||
const twoFactorCode = ref(null)
|
||||
const twoFactorCode = ref('')
|
||||
const backupCodes = ref([])
|
||||
|
||||
watch(twoFactorStep, async (step) => {
|
||||
if (step !== 1) {
|
||||
return
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
twoFactorCodeInput.value?.focus()
|
||||
})
|
||||
|
||||
async function verifyTwoFactorCode() {
|
||||
startLoading()
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-fit flex-wrap gap-1.5"
|
||||
:class="[wrapperClass, { 'opacity-50': disabled }]"
|
||||
role="group"
|
||||
aria-label="Two-factor authentication code"
|
||||
@pointerdown="handlePointerDown"
|
||||
>
|
||||
<input
|
||||
v-for="index in codeLength"
|
||||
:key="index"
|
||||
:ref="(element) => setCodeInput(element, index - 1)"
|
||||
:value="digits[index - 1]"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxlength="1"
|
||||
:autocomplete="index === 1 ? autocomplete : undefined"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:aria-label="`Code digit ${index}`"
|
||||
class="h-12 w-11 appearance-none rounded-xl border-none bg-surface-4 p-1 text-center text-base font-medium text-primary focus:text-primary focus:ring-4 focus:ring-brand-shadow disabled:cursor-not-allowed"
|
||||
:class="[inputClass, 'outline-none']"
|
||||
@focus="handleFocus($event)"
|
||||
@input="handleInput($event, index - 1)"
|
||||
@keydown="handleKeydown($event, index - 1)"
|
||||
@paste.prevent="handlePaste"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch, type ComponentPublicInstance } from 'vue'
|
||||
|
||||
const model = defineModel<string>({ default: '' })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
autocomplete?: string
|
||||
disabled?: boolean
|
||||
readonly?: boolean
|
||||
inputClass?: string
|
||||
wrapperClass?: string
|
||||
}>(),
|
||||
{
|
||||
autocomplete: 'one-time-code',
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
},
|
||||
)
|
||||
|
||||
const codeInputs = ref<HTMLInputElement[]>([])
|
||||
const digits = ref<string[]>([])
|
||||
const codeLength = 6
|
||||
|
||||
watch(
|
||||
() => model.value,
|
||||
(value) => {
|
||||
const sanitizedValue = sanitizeCode(value)
|
||||
if (sanitizedValue !== digits.value.join('') || digits.value.length !== codeLength) {
|
||||
digits.value = Array.from({ length: codeLength }, (_, index) => sanitizedValue[index] ?? '')
|
||||
}
|
||||
|
||||
if (value !== sanitizedValue) {
|
||||
model.value = sanitizedValue
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function sanitizeCode(value: string) {
|
||||
return value.replace(/\D/g, '').slice(0, codeLength)
|
||||
}
|
||||
|
||||
function updateModel() {
|
||||
model.value = digits.value.join('')
|
||||
}
|
||||
|
||||
function setCodeInput(element: Element | ComponentPublicInstance | null, index: number) {
|
||||
if (element instanceof HTMLInputElement) {
|
||||
codeInputs.value[index] = element
|
||||
}
|
||||
}
|
||||
|
||||
function focusInput(index: number) {
|
||||
const input = codeInputs.value[index]
|
||||
input?.focus()
|
||||
selectInputValue(input)
|
||||
}
|
||||
|
||||
function focusFirstUnfilledCodeInput() {
|
||||
const firstUnfilledIndex = digits.value.findIndex((digit) => !digit)
|
||||
const inputIndex = firstUnfilledIndex === -1 ? digits.value.length - 1 : firstUnfilledIndex
|
||||
focusInput(inputIndex)
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (props.disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.target instanceof HTMLInputElement && event.target.value) {
|
||||
event.preventDefault()
|
||||
event.target.focus()
|
||||
selectInputValue(event.target)
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
focusFirstUnfilledCodeInput()
|
||||
}
|
||||
|
||||
function handleFocus(event: FocusEvent) {
|
||||
if (props.disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
selectInputValue(event.target as HTMLInputElement)
|
||||
}
|
||||
|
||||
function handleInput(event: Event, index: number) {
|
||||
if (disabledOrReadonly()) {
|
||||
return
|
||||
}
|
||||
|
||||
const input = event.target as HTMLInputElement
|
||||
const inputDigits = input.value.replace(/\D/g, '')
|
||||
|
||||
if (!inputDigits) {
|
||||
digits.value[index] = ''
|
||||
input.value = ''
|
||||
updateModel()
|
||||
return
|
||||
}
|
||||
|
||||
if (inputDigits.length === 1) {
|
||||
const digit = inputDigits.slice(-1)
|
||||
digits.value[index] = digit
|
||||
input.value = digit
|
||||
updateModel()
|
||||
|
||||
if (index < codeLength - 1) {
|
||||
focusInput(index + 1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const pastedDigits = inputDigits.slice(0, codeLength - index)
|
||||
for (const [offset, digit] of Array.from(pastedDigits).entries()) {
|
||||
digits.value[index + offset] = digit
|
||||
}
|
||||
updateModel()
|
||||
input.value = digits.value[index] ?? ''
|
||||
void nextTick(focusFirstUnfilledCodeInput)
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent, index: number) {
|
||||
if (disabledOrReadonly()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Backspace' && !digits.value[index] && index > 0) {
|
||||
focusInput(index - 1)
|
||||
} else if (event.key === 'ArrowLeft' && index > 0) {
|
||||
event.preventDefault()
|
||||
focusInput(index - 1)
|
||||
} else if (event.key === 'ArrowRight' && index < codeLength - 1) {
|
||||
event.preventDefault()
|
||||
focusInput(index + 1)
|
||||
}
|
||||
}
|
||||
|
||||
function handlePaste(event: ClipboardEvent) {
|
||||
if (disabledOrReadonly()) {
|
||||
return
|
||||
}
|
||||
|
||||
const clipboardText = event.clipboardData?.getData('text') ?? ''
|
||||
const pastedCode = sanitizeCode(clipboardText)
|
||||
if (!pastedCode) {
|
||||
return
|
||||
}
|
||||
|
||||
digits.value = Array.from({ length: codeLength }, (_, index) => pastedCode[index] ?? '')
|
||||
updateModel()
|
||||
void nextTick(focusFirstUnfilledCodeInput)
|
||||
}
|
||||
|
||||
function disabledOrReadonly() {
|
||||
return props.disabled || props.readonly
|
||||
}
|
||||
|
||||
function selectInputValue(input?: HTMLInputElement) {
|
||||
if (!input) {
|
||||
return
|
||||
}
|
||||
|
||||
if (input.value) {
|
||||
input.select()
|
||||
} else {
|
||||
input.setSelectionRange(input.value.length, input.value.length)
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
digits.value = Array.from({ length: codeLength }, () => '')
|
||||
updateModel()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
clear,
|
||||
focus: focusFirstUnfilledCodeInput,
|
||||
})
|
||||
</script>
|
||||
@@ -99,4 +99,5 @@ export type {
|
||||
export { default as TimeFramePicker } from './TimeFramePicker.vue'
|
||||
export { default as Timeline } from './Timeline.vue'
|
||||
export { default as Toggle } from './Toggle.vue'
|
||||
export { default as TwoFactorAuthCodeInput } from './TwoFactorAuthCodeInput.vue'
|
||||
export { default as UnsavedChangesPopup } from './UnsavedChangesPopup.vue'
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import TwoFactorAuthCodeInput from '../../components/base/TwoFactorAuthCodeInput.vue'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/TwoFactorAuthCodeInput',
|
||||
component: TwoFactorAuthCodeInput,
|
||||
} satisfies Meta<typeof TwoFactorAuthCodeInput>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => ({
|
||||
components: { TwoFactorAuthCodeInput },
|
||||
setup() {
|
||||
const code = ref('')
|
||||
return { code }
|
||||
},
|
||||
template: `
|
||||
<TwoFactorAuthCodeInput v-model="code" />
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const Filled: Story = {
|
||||
render: () => ({
|
||||
components: { TwoFactorAuthCodeInput },
|
||||
setup() {
|
||||
const code = ref('123456')
|
||||
return { code }
|
||||
},
|
||||
template: `
|
||||
<TwoFactorAuthCodeInput v-model="code" />
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: () => ({
|
||||
components: { TwoFactorAuthCodeInput },
|
||||
setup() {
|
||||
const code = ref('123456')
|
||||
return { code }
|
||||
},
|
||||
template: `
|
||||
<TwoFactorAuthCodeInput v-model="code" disabled />
|
||||
`,
|
||||
}),
|
||||
}
|
||||
Reference in New Issue
Block a user