Files
portal/app/resources/js/components/settings/security/ChangePasswordCard.vue
T
Дмитрий ec6434ee9a
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
feat(security): реальная смена пароля + недавние входы вместо mock-заглушек
UI-аудит раунд 2, вкладка «Безопасность» — убраны фейк-данные и мёртвые кнопки.

Backend (schema-free, без смены SESSION_DRIVER):
- AccountController + ChangePasswordRequest: POST /api/account/change-password —
  проверка текущего пароля (Hash::check против password_hash), новый >=10 симв +
  confirmed, лог password_changed/password_change_failed в auth_log (hash-chain).
- GET /api/account/security: last_password_change_at (max по password-событиям
  auth_log) + recent_logins (реальные login_success: устройство/IP/время).
- Роуты под auth:sanctum + throttle:auth-password.
- Pest: 6 тестов. Регрессия Account+Auth — 23/23 GREEN. phpstan-baseline обновлён
  (Pest-$this false-positives нового теста, как у прочих тестов).

Frontend:
- api/account.ts.
- ChangePasswordCard: реальная дата + диалог (текущий/новый/подтверждение,
  show/hide, обработка 422 неверного текущего пароля).
- SessionsTable -> «Недавние входы»: реальный список из API, убраны 3
  захардкоженных фейк-сессии + мёртвая кнопка «Завершить».

NB: индивидуальный отзыв cookie-сессий требует database-драйвера сессий
(инфра-решение владельца) — отдельный follow-up. Сейчас входы — честный read-only.

Верификация: type-check, build, Playwright (диалог: неверный->ошибка,
смена->дата 21.06, восстановление password123; недавние входы — реальные).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 15:03:43 +03:00

162 lines
6.2 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">
<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"
@click="openDialog"
>
Сменить пароль
</v-btn>
<v-dialog v-model="dialogOpen" max-width="440">
<v-card class="pa-2">
<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>