Compare commits

...
Author SHA1 Message Date
tdgao c6c671c668 feat: update account settings 2fa set up to use 2fa code component 2026-06-30 11:13:55 -06:00
tdgao dc569ecd89 feat: improve 2fa UX 2026-06-30 10:58:29 -06:00
tdgao 7bfb418a11 Merge branch 'main' into truman/reusable-2fa-code-component 2026-06-30 10:33:07 -06:00
ThatGravyBoatandGitHub b569354793 fix: packs not being validated correctly (#6533) 2026-06-30 14:02:58 +00:00
Michael H. b667b3ae9e fix: remove sccache s3 2026-06-30 11:41:08 +02:00
coolbotandGitHub 16de6182e4 Add nag for custom license info. (#6564)
* add nag for custom license info

* preppr
2026-06-30 02:25:13 +00:00
Calum H.andGitHub 364fed52dd feat: changelog (#6563) 2026-06-30 01:57:11 +02:00
Calum H.andGitHub 0fe695721a fix: try fix app freezing on instance page load (#6562)
* fix: app freezing fix

* fix: fmt
2026-06-30 01:55:55 +02:00
tdgao 129dfa66ca feat: implement 2fa code input component 2026-06-15 22:27:40 -07:00
19 changed files with 412 additions and 107 deletions
-7
View File
@@ -76,13 +76,6 @@ jobs:
env:
SCCACHE_DIR: ${{ needs.skip-if-clean.outputs.internal == 'true' && '/mnt/sccache' || '' }}
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '10G' || '' }}
SCCACHE_MULTILEVEL_CHAIN: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'disk,s3' || '' }}
SCCACHE_S3_KEY_PREFIX: ${{ needs.skip-if-clean.outputs.internal == 'true' && format('{0}/', github.repository) || '' }}
SCCACHE_BUCKET: ${{ secrets.SCCACHE_BUCKET }}
SCCACHE_REGION: ${{ secrets.SCCACHE_REGION }}
SCCACHE_ENDPOINT: ${{ secrets.SCCACHE_ENDPOINT }}
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
RUSTC_WRAPPER: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'sccache' || '' }}
needs: [skip-if-clean]
if: ${{ needs.skip-if-clean.outputs.skip != 'true' }}
-7
View File
@@ -78,13 +78,6 @@ jobs:
GIT_HASH: ${{ github.sha }}
SCCACHE_DIR: ${{ needs.skip-if-clean.outputs.internal == 'true' && '/mnt/sccache' || '' }}
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '10G' || '' }}
SCCACHE_MULTILEVEL_CHAIN: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'disk,s3' || '' }}
SCCACHE_S3_KEY_PREFIX: ${{ needs.skip-if-clean.outputs.internal == 'true' && format('{0}/', github.repository) || '' }}
SCCACHE_BUCKET: ${{ secrets.SCCACHE_BUCKET }}
SCCACHE_REGION: ${{ secrets.SCCACHE_REGION }}
SCCACHE_ENDPOINT: ${{ secrets.SCCACHE_ENDPOINT }}
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
RUSTC_WRAPPER: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'sccache' || '' }}
steps:
- name: Check out code
-7
View File
@@ -81,13 +81,6 @@ jobs:
# blacksmith runner)
SCCACHE_DIR: ${{ needs.skip-if-clean.outputs.internal == 'true' && '/mnt/sccache' || '' }}
SCCACHE_CACHE_SIZE: ${{ needs.skip-if-clean.outputs.internal == 'true' && '10G' || '' }}
SCCACHE_MULTILEVEL_CHAIN: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'disk,s3' || '' }}
SCCACHE_S3_KEY_PREFIX: ${{ needs.skip-if-clean.outputs.internal == 'true' && format('{0}/', github.repository) || '' }}
SCCACHE_BUCKET: ${{ secrets.SCCACHE_BUCKET }}
SCCACHE_REGION: ${{ secrets.SCCACHE_REGION }}
SCCACHE_ENDPOINT: ${{ secrets.SCCACHE_ENDPOINT }}
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_S3_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_S3_SECRET_ACCESS_KEY }}
RUSTC_WRAPPER: ${{ needs.skip-if-clean.outputs.internal == 'true' && 'sccache' || '' }}
steps:
@@ -1443,7 +1443,7 @@ onMounted(() => {
props.instance &&
event.instance_id === props.instance.id &&
event.event === 'synced' &&
props.instance.install_stage !== 'pack_installing' &&
props.instance.install_stage === 'installed' &&
!isBulkOperating.value
) {
await initProjects()
+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 {
+3 -4
View File
@@ -1,5 +1,6 @@
use crate::validate::{
SupportedGameVersions, ValidationError, ValidationResult, filter_out_packs,
SupportedGameVersions, ValidationError, ValidationResult,
validate_pack_formats,
};
use std::io::Cursor;
use zip::ZipArchive;
@@ -29,8 +30,6 @@ impl super::Validator for FabricValidator {
));
}
filter_out_packs(archive)?;
Ok(ValidationResult::Pass)
Ok(validate_pack_formats(archive))
}
}
+4 -7
View File
@@ -1,5 +1,6 @@
use crate::validate::{
SupportedGameVersions, ValidationError, ValidationResult, filter_out_packs,
SupportedGameVersions, ValidationError, ValidationResult,
validate_pack_formats,
};
use chrono::DateTime;
use std::io::Cursor;
@@ -36,9 +37,7 @@ impl super::Validator for ForgeValidator {
));
}
filter_out_packs(archive)?;
Ok(ValidationResult::Pass)
Ok(validate_pack_formats(archive))
}
}
@@ -74,8 +73,6 @@ impl super::Validator for LegacyForgeValidator {
));
};
filter_out_packs(archive)?;
Ok(ValidationResult::Pass)
Ok(validate_pack_formats(archive))
}
}
+3 -4
View File
@@ -1,5 +1,6 @@
use crate::validate::{
SupportedGameVersions, ValidationError, ValidationResult, filter_out_packs,
SupportedGameVersions, ValidationError, ValidationResult,
validate_pack_formats,
};
use std::io::Cursor;
use zip::ZipArchive;
@@ -29,8 +30,6 @@ impl super::Validator for LiteLoaderValidator {
));
}
filter_out_packs(archive)?;
Ok(ValidationResult::Pass)
Ok(validate_pack_formats(archive))
}
}
+8 -7
View File
@@ -326,9 +326,10 @@ fn game_version_supported(
}
}
pub fn filter_out_packs(
archive: &mut ZipArchive<Cursor<bytes::Bytes>>,
) -> Result<ValidationResult, ValidationError> {
#[must_use]
pub fn validate_pack_formats(
archive: &mut ZipArchive<Cursor<Bytes>>,
) -> ValidationResult {
if (archive.by_name("modlist.html").is_ok()
&& archive.by_name("manifest.json").is_ok())
|| archive
@@ -338,10 +339,10 @@ pub fn filter_out_packs(
.file_names()
.any(|x| x.starts_with("override/mods/") && x.ends_with(".jar"))
{
return Ok(ValidationResult::Warning(
"Invalid modpack file. You must upload a valid .MRPACK file.",
));
return ValidationResult::Warning(
"Invalid modpack file. Modpacks must be uploaded in the .mrpack format, not as a ZIP file.",
);
}
Ok(ValidationResult::Pass)
ValidationResult::Pass
}
+3 -4
View File
@@ -1,5 +1,6 @@
use crate::validate::{
SupportedGameVersions, ValidationError, ValidationResult, filter_out_packs,
SupportedGameVersions, ValidationError, ValidationResult,
validate_pack_formats,
};
use std::io::Cursor;
use zip::ZipArchive;
@@ -33,8 +34,6 @@ impl super::Validator for NeoForgeValidator {
));
}
filter_out_packs(archive)?;
Ok(ValidationResult::Pass)
Ok(validate_pack_formats(archive))
}
}
+3 -4
View File
@@ -1,5 +1,6 @@
use crate::validate::{
SupportedGameVersions, ValidationError, ValidationResult, filter_out_packs,
SupportedGameVersions, ValidationError, ValidationResult,
validate_pack_formats,
};
use chrono::DateTime;
use std::io::Cursor;
@@ -34,8 +35,6 @@ impl super::Validator for QuiltValidator {
));
}
filter_out_packs(archive)?;
Ok(ValidationResult::Pass)
Ok(validate_pack_formats(archive))
}
}
+3 -4
View File
@@ -1,5 +1,6 @@
use crate::validate::{
SupportedGameVersions, ValidationError, ValidationResult, filter_out_packs,
SupportedGameVersions, ValidationError, ValidationResult,
validate_pack_formats,
};
use std::io::Cursor;
use zip::ZipArchive;
@@ -29,8 +30,6 @@ impl super::Validator for RiftValidator {
));
}
filter_out_packs(archive)?;
Ok(ValidationResult::Pass)
Ok(validate_pack_formats(archive))
}
}
@@ -209,29 +209,18 @@ pub(crate) async fn list_content(
.await?;
let imported_modpack_scope = is_imported_modpack_scope(&link);
let linked_modpack_source_kind = linked_modpack_source_kind(&link);
let mut failed_modpack_identifier_lookup = false;
let modpack_ids = if imported_modpack_scope {
None
} else {
match linked_modpack_ids(&link) {
Some((_, version_id)) => match get_modpack_identifiers(
&version_id,
&resolved.content_set,
&state.pool,
&state.api_semaphore,
)
.await
{
Ok(ids) => Some(ids),
Err(err) => {
tracing::warn!(
"Failed to fetch modpack identifiers: {}",
err
);
failed_modpack_identifier_lookup = true;
None
}
},
Some((_, version_id)) => {
get_cached_modpack_identifiers(
&version_id,
&state.pool,
&state.api_semaphore,
)
.await?
}
None => None,
}
};
@@ -243,10 +232,9 @@ pub(crate) async fn list_content(
}
} else if let Some(ids) = modpack_ids.as_ref() {
ContentFilter::ExcludeModpack(ids)
} else if failed_modpack_identifier_lookup {
} else if let Some(source_kind) = linked_modpack_source_kind {
ContentFilter::ExcludeSourceKind {
source_kind: linked_modpack_source_kind
.unwrap_or(ContentSourceKind::ModrinthModpack),
source_kind,
exclude_untracked: true,
}
} else {
@@ -1182,6 +1170,28 @@ impl ModpackIdentifiers {
}
}
async fn get_cached_modpack_identifiers(
version_id: &str,
pool: &SqlitePool,
fetch_semaphore: &FetchSemaphore,
) -> crate::Result<Option<ModpackIdentifiers>> {
let Some(cached) =
CachedEntry::get_modpack_files(version_id, pool, fetch_semaphore)
.await?
else {
return Ok(None);
};
if cached.project_ids.is_empty() {
return Ok(None);
}
Ok(Some(ModpackIdentifiers {
hashes: cached.file_hashes.into_iter().collect(),
project_ids: cached.project_ids.into_iter().collect(),
}))
}
async fn get_modpack_identifiers(
version_id: &str,
content_set: &ContentSet,
+7
View File
@@ -10,6 +10,13 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
{
date: `2026-06-29T23:56:37+00:00`,
product: 'app',
version: '0.15.4',
body: `## Fixed
- Fixes another issue causing the app to freeze up when going into an instance page.`,
},
{
date: `2026-06-29T22:52:04+00:00`,
product: 'app',
+33
View File
@@ -202,6 +202,39 @@ export const coreNags: Nag[] = [
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-settings-license',
},
},
{
id: 'add-custom-license-details',
title: defineMessage({
id: 'nags.add-license-details.title',
defaultMessage: 'Add license details',
}),
description: (context: NagContext) => {
const { formatMessage } = useVIntl()
return formatMessage(
defineMessage({
id: 'nags.add-license-details.description',
defaultMessage: 'Add a valid URL and name or SPDX identifier for your custom license.',
}),
{
type: formatProjectTypeSentence(formatMessage, context.project.project_type),
},
)
},
status: 'required',
shouldShow: (context: NagContext) =>
context.project.license.id === 'LicenseRef-' ||
((!context.project.license.url || context.project.license.url === '') &&
!context.projectV3?.minecraft_server &&
context.project.license.id !== 'LicenseRef-Unknown'),
link: {
path: 'settings/license',
title: defineMessage({
id: 'nags.settings.license.title',
defaultMessage: 'Visit license settings',
}),
shouldShow: (context: NagContext) => context.currentRoute !== 'type-project-settings-license',
},
},
{
id: 'review-permissions',
title: defineMessage({
@@ -17,6 +17,12 @@
"nags.add-java-address.title": {
"defaultMessage": "Add a Java address"
},
"nags.add-license-details.description": {
"defaultMessage": "Add a valid URL and name or SPDX identifier for your custom license."
},
"nags.add-license-details.title": {
"defaultMessage": "Add license details"
},
"nags.add-links-server.description": {
"defaultMessage": "Add any relevant links targeted outside of Modrinth, such as a website, store, or a Discord invite."
},
@@ -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 />
`,
}),
}