feat: improve account security

Support TOTP 2fa, allow for changing password, add session management
and account removal.
This commit is contained in:
Zoe
2026-10-08 12:43:22 -05:00
parent ff33e9c573
commit 4bba529ba8
27 changed files with 4874 additions and 468 deletions
+106
View File
@@ -0,0 +1,106 @@
<script setup lang="ts">
const LENGTH = 6;
// the code typed so far, always a run of digits with no gaps
const code = defineModel<string>({ default: '' });
const props = defineProps<{
large?: boolean;
disabled?: boolean;
}>();
const emit = defineEmits<{
complete: [code: string];
}>();
const inputs = ref<HTMLInputElement[]>([]);
const digits = computed(() => Array.from({ length: LENGTH }, (_, i) => code.value[i] ?? ''));
const focusAt = (index: number) => {
inputs.value[Math.max(0, Math.min(LENGTH - 1, index))]?.focus();
};
const setCode = (value: string) => {
const next = value.slice(0, LENGTH);
code.value = next;
// code.value still reads the old value until the parent re-renders, so
// this has to check what was just written
if (next.length === LENGTH) {
emit('complete', next);
}
};
const handleInput = (index: number, event: Event) => {
const el = event.target as HTMLInputElement;
// more than one digit arrives at once when the browser autofills the code
const typed = el.value.replace(/\D/g, '');
if (typed !== '') {
const next = code.value.slice(0, index) + typed + code.value.slice(index + typed.length);
setCode(next);
}
// the box only ever shows its own digit, whatever was typed into it
nextTick(() => {
el.value = digits.value[index]!;
if (typed !== '') focusAt(index + typed.length);
});
};
const handleKeydown = (index: number, event: KeyboardEvent) => {
if (event.key === 'Backspace') {
event.preventDefault();
if (digits.value[index] !== '') {
code.value = code.value.slice(0, index) + code.value.slice(index + 1);
} else if (index > 0) {
code.value = code.value.slice(0, index - 1) + code.value.slice(index);
focusAt(index - 1);
}
} else if (event.key === 'ArrowLeft') {
event.preventDefault();
focusAt(index - 1);
} else if (event.key === 'ArrowRight') {
event.preventDefault();
focusAt(index + 1);
}
};
const handlePaste = (event: ClipboardEvent) => {
event.preventDefault();
const pasted = (event.clipboardData?.getData('text') ?? '').replace(/\D/g, '');
if (pasted === '') return;
setCode(pasted);
focusAt(pasted.length);
};
const handleFocus = (index: number, event: FocusEvent) => {
// boxes fill left to right, so never leave the cursor past the first empty one
if (index > code.value.length) {
focusAt(code.value.length);
return;
}
(event.target as HTMLInputElement).select();
};
defineExpose({
focus: () => focusAt(code.value.length),
});
</script>
<template>
<div class="flex gap-2">
<input v-for="(digit, index) in digits" :key="index" :ref="(el) => { inputs[index] = el as HTMLInputElement; }"
type="text" inputmode="numeric" pattern="[0-9]*" :aria-label="`Digit ${index + 1}`"
:autocomplete="index === 0 ? 'one-time-code' : 'off'" :value="digit" :disabled="props.disabled" :class="[
'flex-1 min-w-0 w-full p-0 text-center font-mono rounded-lg bg-[var(--bg-container)] text-[var(--text-primary)] border border-[var(--color-border)] outline-none transition-[border-color,box-shadow] duration-150 focus:border-[var(--color-accent)] focus:ring-3 focus:ring-[var(--color-accent)]/20 disabled:opacity-50',
props.large ? 'h-14 text-2xl' : 'h-13 text-[22px]',
]" @input="handleInput(index, $event)" @keydown="handleKeydown(index, $event)" @paste="handlePaste"
@focus="handleFocus(index, $event)" />
</div>
</template>