61afa72591
Закрывает 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>
302 lines
12 KiB
Vue
302 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';
|
||
|
||
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>
|