849bc73290
BillingView 416→114 (+ BalanceCard 155 + TransactionsTable 113 + InvoicesTable 90 + billingFormatters 51 composable: formatPlain/formatCost/statusChipColor/ statusLabel/formatLabel/formatIcon/txAmountClass). SecurityTab 354→39 (+ ChangePasswordCard 17 + TwoFactorCard 218 + RecoveryCodesCard 104 + SessionsTable 66; auth-store читается напрямую в каждом sub-component). RemindersView 345→183 (+ RemindersFilters 51 + RemindersList 173; ReminderDialog уже отдельный с прошлой фазы — служит как ReminderForm). State (`activeTab`, `editingReminder`, `deletingReminderId` в RemindersView) остаётся в parent ради единого reload-flow + confirm-dialog'ов. Auth-store читается напрямую в TwoFactorCard/RecoveryCodesCard через useAuthStore() — без prop-drilling. Reminders-store читается напрямую в RemindersFilters/ RemindersList. Все sub-components <250 строк (acceptance threshold). 3 view-shells: 114/39/183. Регрессия: ESLint 0 + vue-tsc 0 + Vitest 416/416 + build OK 968 ms. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
105 lines
3.9 KiB
Vue
105 lines
3.9 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* RecoveryCodesCard — перегенерация 8 recovery-codes (доступна только при включённой 2FA).
|
||
* Sprint 4 Phase B/2 — split SecurityTab (audit O-refactor-04 хвост).
|
||
*
|
||
* Flow:
|
||
* - Кнопка «Перегенерировать резервные коды» → modal с password →
|
||
* POST /api/2fa/regenerate-recovery-codes → показ 8 новых кодов один раз.
|
||
*
|
||
* Видимость кнопки управляется через computed на auth.user?.totp_enabled —
|
||
* чтобы не показывать действие, недоступное без 2FA.
|
||
*/
|
||
import * as authApi from '../../../api/auth';
|
||
import { useAuthStore } from '../../../stores/auth';
|
||
import { computed, ref } from 'vue';
|
||
|
||
const auth = useAuthStore();
|
||
const has2fa = computed(() => auth.user?.totp_enabled ?? false);
|
||
|
||
const regenOpen = ref(false);
|
||
const regenPassword = ref('');
|
||
const regenError = ref('');
|
||
const regenCodes = ref<string[]>([]);
|
||
|
||
async function confirmRegen(): Promise<void> {
|
||
regenError.value = '';
|
||
try {
|
||
const r = await authApi.twoFactorRegenerateRecoveryCodes(regenPassword.value);
|
||
regenCodes.value = r.recovery_codes;
|
||
regenPassword.value = '';
|
||
} catch {
|
||
regenError.value = 'Неверный пароль.';
|
||
}
|
||
}
|
||
|
||
function closeRegen(): void {
|
||
regenOpen.value = false;
|
||
regenCodes.value = [];
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div v-if="has2fa">
|
||
<v-btn
|
||
variant="outlined"
|
||
size="small"
|
||
prepend-icon="mdi-key"
|
||
class="mb-4"
|
||
data-testid="regen-codes-btn"
|
||
@click="regenOpen = true"
|
||
>
|
||
Перегенерировать резервные коды
|
||
</v-btn>
|
||
|
||
<v-dialog v-model="regenOpen" :max-width="480" data-testid="regen-dialog">
|
||
<v-card>
|
||
<v-card-title>Перегенерация резервных кодов</v-card-title>
|
||
<v-card-text>
|
||
<template v-if="!regenCodes.length">
|
||
<p class="mb-3">Старые коды будут аннулированы. Введите пароль для подтверждения.</p>
|
||
<v-text-field
|
||
v-model="regenPassword"
|
||
label="Пароль"
|
||
type="password"
|
||
autocomplete="current-password"
|
||
:error-messages="regenError ? [regenError] : []"
|
||
density="comfortable"
|
||
/>
|
||
</template>
|
||
<template v-else>
|
||
<v-alert type="warning" variant="tonal" class="mb-3">
|
||
Сохраните новые 8 кодов — старые больше не действуют.
|
||
</v-alert>
|
||
<div class="codes-grid">
|
||
<div v-for="(c, i) in regenCodes" :key="i" class="code-item font-mono">{{ c }}</div>
|
||
</div>
|
||
</template>
|
||
</v-card-text>
|
||
<v-card-actions>
|
||
<v-spacer />
|
||
<v-btn v-if="!regenCodes.length" color="primary" @click="confirmRegen">Перегенерировать</v-btn>
|
||
<v-btn v-else color="primary" @click="closeRegen">Готово</v-btn>
|
||
<v-btn v-if="!regenCodes.length" variant="text" @click="regenOpen = false">Отмена</v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.codes-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 1fr);
|
||
gap: 8px;
|
||
}
|
||
.code-item {
|
||
background: #f6f3ec;
|
||
padding: 8px 12px;
|
||
border-radius: 6px;
|
||
text-align: center;
|
||
font-size: 14px;
|
||
letter-spacing: 0.06em;
|
||
}
|
||
</style>
|