Files
portal/app/resources/js/components/admin/ImpersonationDialog.vue
T

292 lines
12 KiB
Vue
Raw Normal View History

<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>