Files
portal/app/resources/js/components/admin/ImpersonationDialog.vue
T
Дмитрий 61afa72591 phase2(impersonation-ui): UI dialog для Ю-1 в AdminTenantsView (frontend)
Закрывает TODO из v1.44 — frontend для Impersonation backend (`1963694`).

api/admin.ts:
- impersonationInit/Verify/End — типизированные axios-helpers для трёх
  endpoint из v1.53. Все три — ensureCsrfCookie + apiClient.post.
  На prod автоматически перейдут под middleware('auth:saas-admin').

components/admin/ImpersonationDialog.vue — 4-step state-machine:
- step 1 «reason»: v-textarea ≥30 chars + counter + hint «Ещё N символов».
- step 2 «verify»: info-alert email клиента + 6-digit input
  (autocomplete=one-time-code) + dev-banner с _dev_plain_code.
- step 3 «active»: success-alert + кнопка «Завершить сессию».
- step 4 «done»: финальный success.
- persistent dialog (нельзя закрыть кликом за пределами — audit trail).
- watch(modelValue) сбрасывает state при каждом открытии.

AdminTenantsView:
- 8-я колонка actions (width=56) с v-tooltip + icon-btn mdi-account-switch.
- :disabled на suspended (по ТЗ §22.7 — только активные tenant'ы).
- @click.stop, data-testid=impersonate-btn-{id}.
- ADMIN_USER_ID=1 заглушка (на prod удалится — backend возьмёт из auth).

Vitest +11 (всего 190/190 за 13.23 сек):
- ImpersonationDialog.spec.ts (7): hide когда modelValue=false; step-1 mount;
  reason<30 показывает counter; init→step2 (email+dev-banner); verify→step3
  (end-btn); 5-digit code не вызывает API; end→step4; Cancel emit.
- AdminTenantsView.spec.ts (+4): impersonate-btn в каждой строке; suspended
  disabled; click открывает диалог с правильным tenant; props.requestedBy=1.

Vitest quirk: v-dialog/v-tooltip требуют layout-injection — stub'ы
VDialog как passthrough <div v-if="modelValue"><slot/></div>, VTooltip как
<div><slot name="activator" :props="{}"/></div>. ImpersonationDialog
stub'ится в AdminTenantsView spec. api/admin + helpers extractValidationErrors/
extractErrorMessage мокаются через vi.mock — axios.isAxiosError(plain Error)
в jsdom возвращает false (паттерн из auth-store.spec.ts).

Production TODO: SaaS-admin auth (Yandex 360 SSO, Б-1) → middleware,
two-person approval (CTO-15/Ю-9), MailService → _dev_plain_code исчезает,
live cookie-swap session, страница «Активные impersonation-сессии».

Регресс: lint+type-check+format+build OK (924 ms; AdminTenantsView lazy-chunk
20.68 KB включает inline ImpersonationDialog); Vitest 190/190 за 13.23 сек;
Pest 120/120 за 15.69 сек (нетронут). Реестр v1.53→v1.54, CLAUDE.md v1.44→v1.45.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 04:52:52 +03:00

302 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';
const props = defineProps<{
modelValue: boolean;
tenant: AdminTenant | null;
/**
* ID admin-пользователя, инициирующего impersonation. На MVP передаётся
* родителем (заглушка — id из mock-данных). Production: backend будет
* брать из auth()->user()->id, frontend перестанет это передавать.
*/
requestedBy: number;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
}>();
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 dialogOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
});
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(
() => props.modelValue,
(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>