mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6c671c668 | ||
|
|
dc569ecd89 | ||
|
|
7bfb418a11 | ||
|
|
b569354793 | ||
|
|
b667b3ae9e | ||
|
|
16de6182e4 | ||
|
|
364fed52dd | ||
|
|
0fe695721a | ||
|
|
129dfa66ca |
@@ -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' }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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