Compare commits

...
4 changed files with 306 additions and 29 deletions
+40 -29
View File
@@ -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>
+1
View File
@@ -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 />
`,
}),
}