Files
portal/app/resources/js/components/settings/security/ChangePasswordCard.vue
T

162 lines
6.2 KiB
Vue
Raw Normal View History

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