2d7d7d1188
Sprint 2 Phase B (modernization). Закрытие audit O-stack-04/05/07 + O-perf-06:
- O-stack-04: Vue 3.5 defineModel() в 3 диалогах (NewDealDialog,
ImpersonationDialog, ReminderDialog) — boilerplate −5 строк/файл.
+ useTemplateRef() в TwoFactorView (input v-for refs).
- O-stack-05: Vuetify 3.12 типизированные слоты VDataTable
(DealsView + AdminTenantsView) — inline-аннотации `{ item }: { item: T }`
на 6+7 scoped-slot bindings; vue-tsc проверяет доступ к полям статически.
- O-stack-07: ESLint flat-config verified — header-comment добавлен
в eslint.config.js. Legacy .eslintrc.json не используется.
- O-perf-06: defineAsyncComponent() для тяжёлых диалогов в 3 местах:
DealsView (DealDetailDrawer + NewDealDialog), DealDetailDrawer
(ReminderDialog), RemindersView (ReminderDialog). KanbanView оставлен
sync — async-загрузка приводила к EnvironmentTeardownError в jsdom
(KanbanView.spec.ts), see in-file comment. Сборка показывает chunk'и
ImpersonationDialog (7.61 kB), DealDetailDrawer (11.12 kB), NewDealDialog
и ReminderDialog как отдельные lazy-bundles.
vue-tsc: 0 errors. ESLint: 0. Vitest: 416/416 PASS. Build: success.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
292 lines
12 KiB
Vue
292 lines
12 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* Диалог impersonation flow в AdminTenantsView (ТЗ §22.7 / Ю-1).
|
||
*
|
||
* 3-step state-machine:
|
||
* 1. 'reason' — textarea для основания (≥30 chars) → POST /api/admin/impersonation/init.
|
||
* 2. 'verify' — показ email клиента + ввод 6-значного кода → /api/admin/impersonation/verify.
|
||
* На dev показывается _dev_plain_code (на prod исчезнет после MailService).
|
||
* 3. 'active' — chip «Сессия активна», кнопка «Завершить» → /api/admin/impersonation/end.
|
||
*
|
||
* NB: на MVP saas-admin auth не реализован, requested_by передаётся параметром
|
||
* (стандартный admin user id = 1; на prod заменится request()->user()->id).
|
||
*
|
||
* Two-person approval (CTO-15/Ю-9) для тенантов с processing_restricted/
|
||
* chargeback_unrecovered_rub > 0 — TODO после saas-admin auth.
|
||
*/
|
||
import * as adminApi from '../../api/admin';
|
||
import { extractErrorMessage, extractValidationErrors } from '../../api/client';
|
||
import type { AdminTenant } from '../../composables/mockTenants';
|
||
import { computed, ref, watch } from 'vue';
|
||
|
||
// Vue 3.5 defineModel() — Sprint 2 Phase B / O-stack-04.
|
||
const dialogOpen = defineModel<boolean>({ required: true });
|
||
|
||
const props = defineProps<{
|
||
tenant: AdminTenant | null;
|
||
/**
|
||
* ID admin-пользователя, инициирующего impersonation. На MVP передаётся
|
||
* родителем (заглушка — id из mock-данных). Production: backend будет
|
||
* брать из auth()->user()->id, frontend перестанет это передавать.
|
||
*/
|
||
requestedBy: number;
|
||
}>();
|
||
|
||
type Step = 'reason' | 'verify' | 'active' | 'done';
|
||
|
||
const step = ref<Step>('reason');
|
||
const busy = ref(false);
|
||
const errorMessage = ref<string | null>(null);
|
||
const reasonError = ref<string | null>(null);
|
||
const codeError = ref<string | null>(null);
|
||
|
||
const reason = ref('');
|
||
const code = ref('');
|
||
|
||
const tokenId = ref<number | null>(null);
|
||
const sentToEmail = ref<string | null>(null);
|
||
const expiresAt = ref<string | null>(null);
|
||
const devPlainCode = ref<string | null>(null);
|
||
const usedAtIso = ref<string | null>(null);
|
||
|
||
const reasonLength = computed(() => reason.value.trim().length);
|
||
const reasonRemaining = computed(() => Math.max(0, 30 - reasonLength.value));
|
||
const reasonValid = computed(() => reasonLength.value >= 30);
|
||
|
||
function resetState() {
|
||
step.value = 'reason';
|
||
busy.value = false;
|
||
errorMessage.value = null;
|
||
reasonError.value = null;
|
||
codeError.value = null;
|
||
reason.value = '';
|
||
code.value = '';
|
||
tokenId.value = null;
|
||
sentToEmail.value = null;
|
||
expiresAt.value = null;
|
||
devPlainCode.value = null;
|
||
usedAtIso.value = null;
|
||
}
|
||
|
||
watch(dialogOpen, (open) => {
|
||
if (open) resetState();
|
||
});
|
||
|
||
async function submitInit() {
|
||
if (!props.tenant) return;
|
||
if (!reasonValid.value) {
|
||
reasonError.value = 'Минимум 30 символов.';
|
||
return;
|
||
}
|
||
busy.value = true;
|
||
errorMessage.value = null;
|
||
reasonError.value = null;
|
||
try {
|
||
const r = await adminApi.impersonationInit({
|
||
tenant_id: props.tenant.id,
|
||
requested_by: props.requestedBy,
|
||
reason: reason.value.trim(),
|
||
});
|
||
tokenId.value = r.token_id;
|
||
sentToEmail.value = r.sent_to_email;
|
||
expiresAt.value = r.expires_at;
|
||
devPlainCode.value = r._dev_plain_code ?? null;
|
||
step.value = 'verify';
|
||
} catch (err) {
|
||
const validation = extractValidationErrors(err);
|
||
if (validation?.reason?.[0]) {
|
||
reasonError.value = validation.reason[0];
|
||
} else {
|
||
errorMessage.value = extractErrorMessage(err, 'Не удалось инициировать impersonation.');
|
||
}
|
||
} finally {
|
||
busy.value = false;
|
||
}
|
||
}
|
||
|
||
async function submitVerify() {
|
||
if (tokenId.value === null) return;
|
||
if (!/^\d{6}$/.test(code.value)) {
|
||
codeError.value = 'Введите 6 цифр.';
|
||
return;
|
||
}
|
||
busy.value = true;
|
||
errorMessage.value = null;
|
||
codeError.value = null;
|
||
try {
|
||
const r = await adminApi.impersonationVerify({
|
||
token_id: tokenId.value,
|
||
code: code.value,
|
||
});
|
||
usedAtIso.value = r.used_at;
|
||
step.value = 'active';
|
||
} catch (err) {
|
||
codeError.value = extractErrorMessage(err, 'Неверный код.');
|
||
} finally {
|
||
busy.value = false;
|
||
}
|
||
}
|
||
|
||
async function submitEnd() {
|
||
if (tokenId.value === null) return;
|
||
busy.value = true;
|
||
errorMessage.value = null;
|
||
try {
|
||
await adminApi.impersonationEnd(tokenId.value);
|
||
step.value = 'done';
|
||
} catch (err) {
|
||
errorMessage.value = extractErrorMessage(err, 'Не удалось завершить сессию.');
|
||
} finally {
|
||
busy.value = false;
|
||
}
|
||
}
|
||
|
||
function close() {
|
||
dialogOpen.value = false;
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<v-dialog v-model="dialogOpen" :max-width="520" persistent data-testid="impersonation-dialog">
|
||
<v-card v-if="tenant">
|
||
<v-card-title class="d-flex align-center">
|
||
<v-icon class="me-2" color="warning">mdi-account-switch</v-icon>
|
||
Войти как клиент
|
||
</v-card-title>
|
||
|
||
<v-card-subtitle class="pb-2">
|
||
<strong>{{ tenant.name }}</strong>
|
||
<span class="text-medium-emphasis"> · {{ tenant.code }} · ИНН {{ tenant.inn }}</span>
|
||
</v-card-subtitle>
|
||
|
||
<v-card-text>
|
||
<v-alert
|
||
v-if="errorMessage"
|
||
type="error"
|
||
variant="tonal"
|
||
density="compact"
|
||
class="mb-3"
|
||
data-testid="error-alert"
|
||
>
|
||
{{ errorMessage }}
|
||
</v-alert>
|
||
|
||
<!-- STEP 1: REASON -->
|
||
<template v-if="step === 'reason'">
|
||
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
|
||
Действие протоколируется в <code>saas_admin_audit_log</code> и уведомление уходит клиенту по
|
||
email. Все ваши действия в этой сессии прозрачны для клиента.
|
||
</v-alert>
|
||
<p class="text-body-2 mb-2">
|
||
Опишите основание (тикет поддержки, инцидент, согласованный запрос клиента). Минимум
|
||
<strong>30 символов</strong> по политике §22.7.
|
||
</p>
|
||
<v-textarea
|
||
v-model="reason"
|
||
variant="outlined"
|
||
rows="3"
|
||
autofocus
|
||
:counter="200"
|
||
:error-messages="reasonError ? [reasonError] : []"
|
||
:hint="reasonValid ? 'Готово к отправке.' : `Ещё ${reasonRemaining} символов`"
|
||
persistent-hint
|
||
placeholder="Тикет SUP-12453: клиент сообщил, что в карточке сделки #4471 не сохраняется комментарий…"
|
||
data-testid="reason-input"
|
||
/>
|
||
</template>
|
||
|
||
<!-- STEP 2: VERIFY -->
|
||
<template v-else-if="step === 'verify'">
|
||
<v-alert type="info" variant="tonal" density="compact" class="mb-3">
|
||
Код отправлен на email клиента: <strong>{{ sentToEmail }}</strong>
|
||
</v-alert>
|
||
<p class="text-body-2 mb-2">
|
||
Получите 6-значный код у клиента и введите его. Код действует
|
||
<strong>15 минут</strong>. После 5 неверных попыток токен будет аннулирован.
|
||
</p>
|
||
<v-text-field
|
||
v-model="code"
|
||
label="6-значный код"
|
||
inputmode="numeric"
|
||
maxlength="6"
|
||
autofocus
|
||
autocomplete="one-time-code"
|
||
:error-messages="codeError ? [codeError] : []"
|
||
density="comfortable"
|
||
data-testid="code-input"
|
||
/>
|
||
<v-alert
|
||
v-if="devPlainCode"
|
||
type="success"
|
||
variant="tonal"
|
||
density="compact"
|
||
class="mt-3"
|
||
data-testid="dev-code-banner"
|
||
>
|
||
<strong>DEV-only:</strong> код = <code class="font-mono">{{ devPlainCode }}</code> (на prod
|
||
исчезнет — будет приходить только на email клиента).
|
||
</v-alert>
|
||
</template>
|
||
|
||
<!-- STEP 3: ACTIVE -->
|
||
<template v-else-if="step === 'active'">
|
||
<v-alert type="success" variant="tonal" density="compact" class="mb-3">
|
||
Impersonation активен. На production здесь начнётся реальная сессия в кабинете клиента.
|
||
</v-alert>
|
||
<p class="text-body-2">
|
||
Сессия привязана к токену <code class="font-mono">#{{ tokenId }}</code> и автоматически истечёт
|
||
через 1 час. Завершите её сразу после выполнения задачи — это требование §22.7.
|
||
</p>
|
||
<p class="text-caption text-medium-emphasis mt-2">
|
||
Активирована в {{ new Date(usedAtIso ?? '').toLocaleString('ru-RU') }}.
|
||
</p>
|
||
</template>
|
||
|
||
<!-- STEP 4: DONE -->
|
||
<template v-else-if="step === 'done'">
|
||
<v-alert type="success" variant="tonal" density="compact">
|
||
Сессия impersonation завершена. Запись доступна в журнале аудита.
|
||
</v-alert>
|
||
</template>
|
||
</v-card-text>
|
||
|
||
<v-card-actions>
|
||
<v-spacer />
|
||
<template v-if="step === 'reason'">
|
||
<v-btn variant="text" :disabled="busy" data-testid="cancel-btn" @click="close">Отмена</v-btn>
|
||
<v-btn
|
||
color="primary"
|
||
:loading="busy"
|
||
:disabled="!reasonValid"
|
||
data-testid="submit-init-btn"
|
||
@click="submitInit"
|
||
>
|
||
Запросить код
|
||
</v-btn>
|
||
</template>
|
||
<template v-else-if="step === 'verify'">
|
||
<v-btn variant="text" :disabled="busy" data-testid="cancel-btn" @click="close">Отмена</v-btn>
|
||
<v-btn color="primary" :loading="busy" data-testid="submit-verify-btn" @click="submitVerify">
|
||
Подтвердить
|
||
</v-btn>
|
||
</template>
|
||
<template v-else-if="step === 'active'">
|
||
<v-btn color="error" :loading="busy" data-testid="submit-end-btn" @click="submitEnd">
|
||
Завершить сессию
|
||
</v-btn>
|
||
</template>
|
||
<template v-else>
|
||
<v-btn color="primary" data-testid="close-btn" @click="close">Закрыть</v-btn>
|
||
</template>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-dialog>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.font-mono {
|
||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||
font-feature-settings: 'tnum';
|
||
}
|
||
</style>
|