Files
portal/app/resources/js/components/admin/ImpersonationDialog.vue
T
Дмитрий 2d7d7d1188 feat(frontend): Sprint 2 Phase B — Vue 3.5 defineModel + Vuetify 3.12 typed slots + lazy-imports + ESLint check
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>
2026-05-09 19:36:02 +03:00

292 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>