2026-05-09 04:52:52 +03:00
|
|
|
|
<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';
|
|
|
|
|
|
|
2026-05-09 19:36:02 +03:00
|
|
|
|
// Vue 3.5 defineModel() — Sprint 2 Phase B / O-stack-04.
|
|
|
|
|
|
const dialogOpen = defineModel<boolean>({ required: true });
|
|
|
|
|
|
|
2026-05-09 04:52:52 +03:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 19:36:02 +03:00
|
|
|
|
watch(dialogOpen, (open) => {
|
|
|
|
|
|
if (open) resetState();
|
|
|
|
|
|
});
|
2026-05-09 04:52:52 +03:00
|
|
|
|
|
|
|
|
|
|
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>
|