This commit is contained in:
lendry
2026-06-25 07:23:34 +03:00
parent f2366a69a0
commit 71b270fcb3
19 changed files with 1410 additions and 112 deletions

View File

@@ -24,7 +24,8 @@ import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { OtpInput } from '@/components/ui/otp-input';
import { ActiveDevice, apiFetch, SignInEvent, TotpSetupResponse, TotpStatusResponse } from '@/lib/api';
import { ActiveDevice, apiFetch, getApiErrorMessage, SignInEvent, TotpSetupResponse, TotpStatusResponse } from '@/lib/api';
import { cn } from '@/lib/utils';
import { QRCodeSVG } from 'qrcode.react';
import { useRequireAuth } from '@/hooks/use-require-auth';
@@ -56,8 +57,13 @@ const dialogConfig: Record<Exclude<DialogMode, null | 'totpSetup' | 'totpDisable
backupPhone: { title: 'Резервный телефон', placeholder: '+79991234567', type: 'tel' }
};
const passwordChangeHint =
'Чтобы сменить или удалить пароль, подтвердите личность одним из доступных способов ниже.';
type PasswordVerifyMethod = 'current' | 'sms' | 'email' | 'totp';
export default function SecurityPage() {
const { user, token } = useAuth();
const { user, token, refreshProfile } = useAuth();
const { isReady } = useRequireAuth();
const { showToast } = useToast();
const [devices, setDevices] = useState<ActiveDevice[]>([]);
@@ -70,6 +76,17 @@ export default function SecurityPage() {
const [totpEnabled, setTotpEnabled] = useState(false);
const [totpSetup, setTotpSetup] = useState<TotpSetupResponse | null>(null);
const [totpCode, setTotpCode] = useState('');
const [passwordVerifyMethod, setPasswordVerifyMethod] = useState<PasswordVerifyMethod>('current');
const [currentPassword, setCurrentPassword] = useState('');
const [passwordOtpCode, setPasswordOtpCode] = useState('');
const [passwordOtpSent, setPasswordOtpSent] = useState(false);
const [passwordOtpMasked, setPasswordOtpMasked] = useState('');
const [passwordTotpCode, setPasswordTotpCode] = useState('');
const [newPassword, setNewPassword] = useState('');
const [passwordOtpSending, setPasswordOtpSending] = useState(false);
const hasSmsContact = Boolean(user?.phone || user?.backupPhone);
const hasEmailContact = Boolean(user?.email || user?.backupEmail);
const loadSecurity = useCallback(async () => {
if (!user || !token) return;
@@ -178,6 +195,108 @@ export default function SecurityPage() {
}
}
function buildPasswordVerification() {
if (passwordVerifyMethod === 'current') {
return { currentPassword: currentPassword.trim() };
}
if (passwordVerifyMethod === 'sms' || passwordVerifyMethod === 'email') {
return { otpCode: passwordOtpCode.trim(), otpChannel: passwordVerifyMethod };
}
return { totpCode: passwordTotpCode.trim() };
}
function isPasswordVerificationReady() {
if (passwordVerifyMethod === 'current') return currentPassword.trim().length > 0;
if (passwordVerifyMethod === 'sms' || passwordVerifyMethod === 'email') {
return passwordOtpSent && passwordOtpCode.trim().length === 6;
}
return passwordTotpCode.trim().length === 6;
}
async function sendPasswordVerificationOtp() {
if (!user || !token || (passwordVerifyMethod !== 'sms' && passwordVerifyMethod !== 'email')) return;
setPasswordOtpSending(true);
try {
const response = await apiFetch<{ maskedTarget?: string }>(
`/profile/users/${user.id}/password/otp`,
{ method: 'POST', body: JSON.stringify({ channel: passwordVerifyMethod }) },
token
);
setPasswordOtpSent(true);
setPasswordOtpMasked(response.maskedTarget ?? '');
setPasswordOtpCode('');
showToast(`Код отправлен${response.maskedTarget ? ` на ${response.maskedTarget}` : ''}`);
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось отправить код') ?? 'Ошибка');
} finally {
setPasswordOtpSending(false);
}
}
async function submitPasswordChange() {
if (!user || !token || !newPassword.trim()) {
showToast('Укажите новый пароль');
return;
}
if (!isPasswordVerificationReady()) {
showToast('Подтвердите личность перед сменой пароля');
return;
}
setIsDialogSaving(true);
try {
await apiFetch(
`/profile/users/${user.id}/password`,
{
method: 'PATCH',
body: JSON.stringify({ newPassword: newPassword.trim(), ...buildPasswordVerification() })
},
token
);
showToast('Пароль изменён');
resetPasswordDialog();
setDialogMode(null);
await refreshProfile();
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось сменить пароль') ?? 'Ошибка');
} finally {
setIsDialogSaving(false);
}
}
async function submitPasswordRemove() {
if (!user || !token) return;
if (!isPasswordVerificationReady()) {
showToast('Подтвердите личность перед удалением пароля');
return;
}
setIsDialogSaving(true);
try {
await apiFetch(
`/profile/users/${user.id}/password`,
{ method: 'DELETE', body: JSON.stringify(buildPasswordVerification()) },
token
);
showToast('Пароль удалён');
resetPasswordDialog();
setDialogMode(null);
await refreshProfile();
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось удалить пароль') ?? 'Ошибка');
} finally {
setIsDialogSaving(false);
}
}
function resetPasswordDialog() {
setPasswordVerifyMethod('current');
setCurrentPassword('');
setPasswordOtpCode('');
setPasswordOtpSent(false);
setPasswordOtpMasked('');
setPasswordTotpCode('');
setNewPassword('');
}
async function submitDialog() {
if (!user || !token || !dialogMode) return;
setIsDialogSaving(true);
@@ -188,6 +307,7 @@ export default function SecurityPage() {
} else if (dialogMode === 'password') {
await apiFetch(`/profile/users/${user.id}/password`, { method: 'POST', body: JSON.stringify({ password: dialogValue }) }, token);
showToast('Пароль установлен');
await refreshProfile();
} else if (dialogMode === 'backupEmail') {
await apiFetch(`/profile/users/${user.id}/contacts`, { method: 'PATCH', body: JSON.stringify({ backupEmail: dialogValue.trim() }) }, token);
showToast('Резервная почта добавлена');
@@ -206,6 +326,9 @@ export default function SecurityPage() {
function openDialog(mode: Exclude<DialogMode, null>) {
setDialogValue('');
if (mode === 'password') {
resetPasswordDialog();
}
setDialogMode(mode);
}
@@ -280,7 +403,11 @@ export default function SecurityPage() {
<KeyRound className="h-5 w-5" />
<div className="flex-1">
<p className="font-medium">Пароль</p>
<p className="text-sm text-[#667085]">{user?.email || user?.phone ? 'Добавить пароль как способ входа' : 'Установить пароль'}</p>
<p className="text-sm text-[#667085]">
{user?.hasPassword
? 'Пароль установлен. Для смены или удаления потребуется подтверждение.'
: 'Добавить пароль как способ входа'}
</p>
</div>
<ChevronRight className="h-5 w-5 text-[#a8adbc]" />
</button>
@@ -335,6 +462,7 @@ export default function SecurityPage() {
setTotpSetup(null);
}
setTotpCode('');
resetPasswordDialog();
}
}}>
<DialogContent>
@@ -369,21 +497,133 @@ export default function SecurityPage() {
{dialogMode && dialogMode !== 'totpSetup' && dialogMode !== 'totpDisable' ? (
<>
<DialogHeader>
<DialogTitle>{dialogConfig[dialogMode].title}</DialogTitle>
<DialogTitle>
{dialogMode === 'password' && user?.hasPassword ? 'Пароль установлен' : dialogConfig[dialogMode].title}
</DialogTitle>
</DialogHeader>
<div className="flex items-center gap-3 rounded-2xl bg-[#f4f5f8] px-4">
<ShieldCheck className="h-5 w-5 text-[#667085]" />
<Input
className="border-0 bg-transparent focus:ring-0"
type={dialogConfig[dialogMode].type}
placeholder={dialogConfig[dialogMode].placeholder}
value={dialogValue}
onChange={(event) => setDialogValue(event.target.value)}
/>
</div>
<Button className="mt-5 w-full" onClick={submitDialog} disabled={isDialogSaving || !dialogValue.trim()}>
{isDialogSaving ? 'Сохраняем...' : 'Сохранить'}
</Button>
{dialogMode === 'password' && user?.hasPassword ? (
<div className="space-y-4">
<p className="text-sm leading-relaxed text-[#667085]">{passwordChangeHint}</p>
<div className="flex flex-wrap gap-2">
{[
{ id: 'current' as const, label: 'Текущий пароль' },
...(hasSmsContact ? [{ id: 'sms' as const, label: 'SMS' }] : []),
...(hasEmailContact ? [{ id: 'email' as const, label: 'Почта' }] : []),
...(totpEnabled ? [{ id: 'totp' as const, label: 'Аутентификатор' }] : [])
].map((method) => (
<button
key={method.id}
type="button"
className={cn(
'rounded-full px-3 py-1.5 text-sm transition',
passwordVerifyMethod === method.id
? 'bg-[#111827] text-white'
: 'bg-[#f4f5f8] text-[#667085] hover:bg-[#eceef4]'
)}
onClick={() => {
setPasswordVerifyMethod(method.id);
setPasswordOtpSent(false);
setPasswordOtpCode('');
setPasswordTotpCode('');
setCurrentPassword('');
}}
>
{method.label}
</button>
))}
</div>
{passwordVerifyMethod === 'current' ? (
<div className="rounded-2xl bg-[#f4f5f8] px-4">
<Input
className="border-0 bg-transparent focus-visible:ring-0"
type="password"
placeholder="Текущий пароль"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
/>
</div>
) : null}
{passwordVerifyMethod === 'sms' || passwordVerifyMethod === 'email' ? (
<div className="space-y-3">
<Button
type="button"
variant="secondary"
className="w-full rounded-xl"
disabled={passwordOtpSending}
onClick={() => void sendPasswordVerificationOtp()}
>
{passwordOtpSending ? 'Отправляем код...' : `Отправить код ${passwordVerifyMethod === 'sms' ? 'в SMS' : 'на почту'}`}
</Button>
{passwordOtpSent ? (
<p className="text-center text-sm text-[#667085]">
Код отправлен{passwordOtpMasked ? ` на ${passwordOtpMasked}` : ''}
</p>
) : null}
<OtpInput
variant="light"
value={passwordOtpCode}
onChange={setPasswordOtpCode}
disabled={!passwordOtpSent || isDialogSaving}
/>
</div>
) : null}
{passwordVerifyMethod === 'totp' ? (
<div className="space-y-2">
<p className="text-sm text-[#667085]">Введите код из приложения-аутентификатора</p>
<OtpInput variant="light" value={passwordTotpCode} onChange={setPasswordTotpCode} disabled={isDialogSaving} />
</div>
) : null}
<div className="rounded-2xl bg-[#f4f5f8] px-4">
<Input
className="border-0 bg-transparent focus-visible:ring-0"
type="password"
placeholder="Новый пароль (мин. 8 символов)"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2 pt-1">
<Button
className="w-full rounded-xl"
disabled={isDialogSaving || newPassword.trim().length < 8 || !isPasswordVerificationReady()}
onClick={() => void submitPasswordChange()}
>
{isDialogSaving ? 'Сохраняем...' : 'Сохранить новый пароль'}
</Button>
<Button
type="button"
variant="ghost"
className="w-full rounded-xl text-red-600 hover:bg-red-50 hover:text-red-700"
disabled={isDialogSaving || !isPasswordVerificationReady()}
onClick={() => void submitPasswordRemove()}
>
Удалить пароль
</Button>
</div>
</div>
) : (
<>
<div className="flex items-center gap-3 rounded-2xl bg-[#f4f5f8] px-4">
<ShieldCheck className="h-5 w-5 text-[#667085]" />
<Input
className="border-0 bg-transparent focus:ring-0"
type={dialogConfig[dialogMode].type}
placeholder={dialogConfig[dialogMode].placeholder}
value={dialogValue}
onChange={(event) => setDialogValue(event.target.value)}
/>
</div>
<Button className="mt-5 w-full" onClick={submitDialog} disabled={isDialogSaving || !dialogValue.trim()}>
{isDialogSaving ? 'Сохраняем...' : 'Сохранить'}
</Button>
</>
)}
</>
) : null}
</DialogContent>

View File

@@ -36,6 +36,14 @@ function shouldShowMessagingField(setting: SystemSetting, settings: SystemSettin
const provider = getSettingValue(settings, 'SMS_PROVIDER');
if (setting.key === 'SMS_API_KEY') return provider === 'smsru';
if (setting.key === 'SMS_LOGIN' || setting.key === 'SMS_PASSWORD') return provider === 'smsc';
if (
setting.key === 'SMS_GATEWAY_URL' ||
setting.key === 'SMS_GATEWAY_USERNAME' ||
setting.key === 'SMS_GATEWAY_PASSWORD' ||
setting.key === 'SMS_GATEWAY_MESSAGE_TEMPLATE'
) {
return provider === 'huawei_modem';
}
const emailProvider = getSettingValue(settings, 'EMAIL_PROVIDER');
if (setting.key.startsWith('EMAIL_SMTP_') || setting.key.startsWith('EMAIL_FROM_')) {
@@ -180,7 +188,7 @@ export function MessagingSettingsSection({
<MessagingBlock
title="SMS"
description="Отправка OTP-кодов по SMS через sms.ru или smsc.ru."
description="Отправка OTP-кодов по SMS через sms.ru, smsc.ru или локальный модем Huawei."
groupKey="messaging-sms"
blockSettings={smsSettings}
allSettings={settings}
@@ -222,6 +230,7 @@ export function MessagingSettingsSection({
</p>
<div className="mt-4">
<PhoneInput
variant="light"
country={testPhoneCountry}
value={testPhone}
onCountryChange={setTestPhoneCountry}

View File

@@ -51,6 +51,13 @@ export function SettingsFieldRow({ setting, onChange }: SettingsFieldRowProps) {
</option>
))}
</select>
) : type === 'textarea' ? (
<textarea
value={setting.value}
rows={3}
className="min-h-[88px] w-full min-w-[280px] resize-y rounded-xl border border-[#e4e7ec] bg-white px-3 py-2 text-sm outline-none focus:border-[#98a2b3] md:w-[360px]"
onChange={(event) => onChange(event.target.value)}
/>
) : (
<>
<Input

View File

@@ -75,6 +75,7 @@ export interface PublicUser {
canManageSettings?: boolean;
canViewUsers?: boolean;
canViewUserDocuments?: boolean;
hasPassword?: boolean;
}
export interface AdminUser {

View File

@@ -1,4 +1,4 @@
export type SystemSettingFieldType = 'text' | 'number' | 'boolean' | 'select';
export type SystemSettingFieldType = 'text' | 'number' | 'boolean' | 'select' | 'textarea';
export interface SystemSettingMeta {
key: string;
@@ -78,10 +78,43 @@ export const SYSTEM_SETTING_CATALOG: SystemSettingMeta[] = [
options: [
{ value: 'smsru', label: 'sms.ru' },
{ value: 'smsc', label: 'smsc.ru' },
{ value: 'huawei_modem', label: 'Huawei E5573 (локальный шлюз)' },
{ value: 'console', label: 'Только лог (отладка)' }
],
showWhen: { key: 'SMS_ENABLED', equals: 'true' }
},
{
key: 'SMS_GATEWAY_URL',
label: 'URL локального SMS-шлюза',
group: 'messaging',
type: 'text',
hint: 'IP или URL модема Huawei HiLink, например http://192.168.8.1',
showWhen: { key: 'SMS_ENABLED', equals: 'true' }
},
{
key: 'SMS_GATEWAY_USERNAME',
label: 'Логин модема Huawei',
group: 'messaging',
type: 'text',
hint: 'Обычно admin',
showWhen: { key: 'SMS_ENABLED', equals: 'true' }
},
{
key: 'SMS_GATEWAY_PASSWORD',
label: 'Пароль модема Huawei',
group: 'messaging',
type: 'text',
hint: 'Пароль веб-интерфейса модема (требуется для отправки SMS)',
showWhen: { key: 'SMS_ENABLED', equals: 'true' }
},
{
key: 'SMS_GATEWAY_MESSAGE_TEMPLATE',
label: 'Шаблон текста SMS (Huawei)',
group: 'messaging',
type: 'textarea',
hint: 'Теги: {{appname}} — название проекта, {{code}} — OTP-код, {{expiry}} — срок действия в минутах',
showWhen: { key: 'SMS_ENABLED', equals: 'true' }
},
{ key: 'SMS_API_KEY', label: 'API-ключ sms.ru', group: 'messaging', type: 'text', hint: 'api_id из личного кабинета sms.ru', showWhen: { key: 'SMS_ENABLED', equals: 'true' } },
{ key: 'SMS_LOGIN', label: 'Логин smsc.ru', group: 'messaging', type: 'text', showWhen: { key: 'SMS_ENABLED', equals: 'true' } },
{ key: 'SMS_PASSWORD', label: 'Пароль smsc.ru', group: 'messaging', type: 'text', showWhen: { key: 'SMS_ENABLED', equals: 'true' } },