Files
portal/app/resources/js/components/settings/security/ChangePasswordCard.vue
T
Дмитрий 9ff19f3cc3 @
feat(bot): всеобъемлющая инструкция (36 статей) + 31 узкая экскурсия, которая открывает окна

Живой урок владельца 12.07.2026: «дубли чистятся в 2-х местах, а бот выдумал третье»,
«стыдно такого бота показывать», «видео показываем полное, а я спрашиваю про одно —
надо резать», «надо не только кнопку показывать, а и что за ней».

База знаний: 17 обзорных статей → 36 узких по темам. Портал обойдён целиком
(5 агентов): все экраны, окна, подсказки «(?)», тексты ошибок, внутренние правила.
Новое, чего бот не знал: дубли в ДВУХ местах (Поле и Предложения) + окно «источник
уже есть»; правило 18:00; почему приходит меньше лимита; оплата по счёту и акты;
списания; безопасность и 2FA; отчёты; массовые действия; регистрация.

Экскурсии: 15 → 31, каждая на свою тему (3–7 шагов). Раннер GuidedTour научен
шагу `open` — сам открывает окно/вкладку и подсвечивает то, что ВНУТРИ
(окно дублей, форму подбора, диалог пополнения, панель тарифов, карточку сделки).
99 новых якорей data-tour расставлено по всему порталу (4 агента).

Поиск: веса в tsvector (заголовок A / синонимы B / текст C) — иначе на «чистка
дублей» первой всплывала статья «Списания» из-за мимоходного упоминания «поля».

Тесты: Pest бот 39/39, Vitest 1222/1222. Новые сторожа: у каждой статьи есть
экскурсия; экскурсия не короче 3 и не длиннее 7 шагов; каждая цель экскурсии
реально существует в разметке; экскурсии про действия обязаны открывать окно.
Живая проверка в браузере: экскурсия про дубли сама открыла окно объединения
и перешла на второе место чистки.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@
2026-07-12 12:56:22 +03:00

163 lines
6.3 KiB
Vue

<script setup lang="ts">
/**
* ChangePasswordCard — смена пароля (UI-аудит 21.06.2026, Security).
*
* Реальные данные: дата последней смены — GET /api/account/security;
* смена — POST /api/account/change-password (текущий + новый + подтверждение).
* Заменяет прежнюю статичную заглушку (захардкоженная дата + мёртвая кнопка).
*/
import { onMounted, ref } from 'vue';
import { changePassword, getAccountSecurity } from '../../../api/account';
import axios from 'axios';
const lastChangeAt = ref<string | null>(null);
const dialogOpen = ref(false);
const currentPassword = ref('');
const newPassword = ref('');
const confirmPassword = ref('');
const saving = ref(false);
const errorMessage = ref<string | null>(null);
const toastOpen = ref(false);
const showCurrent = ref(false);
const showNew = ref(false);
function formatDate(iso: string | null): string {
if (!iso) return 'не менялся';
const d = new Date(iso);
return d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
async function loadSecurity(): Promise<void> {
try {
const data = await getAccountSecurity();
lastChangeAt.value = data.last_password_change_at;
} catch {
lastChangeAt.value = null;
}
}
function openDialog(): void {
currentPassword.value = '';
newPassword.value = '';
confirmPassword.value = '';
errorMessage.value = null;
dialogOpen.value = true;
}
async function submit(): Promise<void> {
if (saving.value) return;
saving.value = true;
errorMessage.value = null;
try {
lastChangeAt.value = await changePassword({
current_password: currentPassword.value,
password: newPassword.value,
password_confirmation: confirmPassword.value,
});
dialogOpen.value = false;
toastOpen.value = true;
} catch (err) {
if (axios.isAxiosError(err) && err.response?.status === 422) {
const errors = (err.response.data as { errors?: Record<string, string[]> })?.errors ?? {};
errorMessage.value =
errors.current_password?.[0] ?? errors.password?.[0] ?? 'Проверьте введённые данные.';
} else {
errorMessage.value = 'Не удалось сменить пароль. Попробуйте позже.';
}
} finally {
saving.value = false;
}
}
onMounted(loadSecurity);
</script>
<template>
<v-card variant="outlined" class="pa-4 mb-4" data-tour="settings-password">
<h3 class="text-subtitle-2 mb-3">Пароль</h3>
<p class="text-body-2 text-medium-emphasis mb-3">
Последняя смена: {{ formatDate(lastChangeAt) }}
</p>
<v-btn
variant="outlined"
size="small"
prepend-icon="mdi-lock-reset"
data-testid="change-password-open"
data-tour="settings-password-btn"
@click="openDialog"
>
Сменить пароль
</v-btn>
<v-dialog v-model="dialogOpen" max-width="440">
<v-card class="pa-2" data-tour="password-dialog">
<v-card-title class="text-subtitle-1">Смена пароля</v-card-title>
<v-card-text>
<v-alert
v-if="errorMessage"
type="error"
variant="tonal"
density="compact"
class="mb-3"
data-testid="change-password-error"
>
{{ errorMessage }}
</v-alert>
<v-text-field
v-model="currentPassword"
:type="showCurrent ? 'text' : 'password'"
label="Текущий пароль"
variant="outlined"
density="comfortable"
autocomplete="current-password"
:append-inner-icon="showCurrent ? 'mdi-eye-off' : 'mdi-eye'"
data-testid="change-password-current"
class="mb-2"
@click:append-inner="showCurrent = !showCurrent"
/>
<v-text-field
v-model="newPassword"
:type="showNew ? 'text' : 'password'"
label="Новый пароль"
variant="outlined"
density="comfortable"
autocomplete="new-password"
persistent-hint
hint="Не короче 10 символов"
:append-inner-icon="showNew ? 'mdi-eye-off' : 'mdi-eye'"
data-testid="change-password-new"
class="mb-2"
@click:append-inner="showNew = !showNew"
/>
<v-text-field
v-model="confirmPassword"
:type="showNew ? 'text' : 'password'"
label="Повторите новый пароль"
variant="outlined"
density="comfortable"
autocomplete="new-password"
data-testid="change-password-confirm"
/>
</v-card-text>
<v-card-actions class="px-4 pb-3">
<v-spacer />
<v-btn variant="text" :disabled="saving" @click="dialogOpen = false">Отмена</v-btn>
<v-btn
color="primary"
variant="flat"
:loading="saving"
data-testid="change-password-submit"
@click="submit"
>
Сменить
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar v-model="toastOpen" :timeout="3000" location="bottom right" data-testid="change-password-toast">
Пароль изменён.
</v-snackbar>
</v-card>
</template>