Implement hashed password transfer, change password, redesign the settings UI

This commit is contained in:
2025-10-30 22:36:23 +03:00
Unverified
parent 1d4a46dff2
commit 5f49b45eed
13 changed files with 381 additions and 74 deletions
+3 -2
View File
@@ -5,7 +5,7 @@ import subprocess
import sys import sys
import os import os
from constants import DATABASE_URL from constants import DATABASE_URL
from routes import account, messaging, profile, push, webrtc from routes import account, messaging, profile, push, webrtc, devices
import logging import logging
from models import User from models import User
from constants import OWNER_USERNAME from constants import OWNER_USERNAME
@@ -88,4 +88,5 @@ app.include_router(account.router)
app.include_router(messaging.router) app.include_router(messaging.router)
app.include_router(profile.router) app.include_router(profile.router)
app.include_router(push.router, prefix="/push") app.include_router(push.router, prefix="/push")
app.include_router(webrtc.router, prefix="/webrtc") app.include_router(webrtc.router, prefix="/webrtc")
app.include_router(devices.router, prefix="/devices")
+26
View File
@@ -36,6 +36,32 @@ def get_current_user(
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
# Validate device session from JWT
session_id = payload.get("session_id")
if not session_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid session",
headers={"WWW-Authenticate": "Bearer"},
)
device_session = (
db.query(DeviceSession)
.filter(DeviceSession.user_id == user.id, DeviceSession.session_id == session_id)
.first()
)
if not device_session or device_session.revoked:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session revoked or not found",
headers={"WWW-Authenticate": "Bearer"},
)
# Touch last_seen on valid session
device_session.last_seen = datetime.now()
db.commit()
# Check if user is suspended # Check if user is suspended
if user.suspended: if user.suspended:
raise HTTPException( raise HTTPException(
+36
View File
@@ -146,6 +146,36 @@ class DMReaction(Base):
__table_args__ = (UniqueConstraint('dm_envelope_id', 'user_id', 'emoji', name='unique_dm_reaction'),) __table_args__ = (UniqueConstraint('dm_envelope_id', 'user_id', 'emoji', name='unique_dm_reaction'),)
# Tracks authenticated device sessions per user
class DeviceSession(Base):
__tablename__ = "device_session"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
# Raw User-Agent for reference/debugging
raw_user_agent = Column(Text, nullable=True)
# Parsed fields
device_type = Column(String(32), nullable=True) # desktop/mobile/tablet/bot/unknown
os_name = Column(String(64), nullable=True)
os_version = Column(String(64), nullable=True)
browser_name = Column(String(64), nullable=True)
browser_version = Column(String(64), nullable=True)
brand = Column(String(64), nullable=True)
model = Column(String(64), nullable=True)
# Session identity embedded into JWTs
session_id = Column(String(64), unique=True, nullable=False, index=True)
# Lifecycle
created_at = Column(DateTime, default=datetime.now)
last_seen = Column(DateTime, default=datetime.now)
revoked = Column(Boolean, default=False)
# Relationship back to user (optional lazy to avoid heavy loads)
user = relationship("User", lazy="select")
# Pydantic модели # Pydantic модели
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
username: str username: str
@@ -159,6 +189,12 @@ class RegisterRequest(BaseModel):
confirm_password: str confirm_password: str
class ChangePasswordRequest(BaseModel):
currentPasswordDerived: str
newPasswordDerived: str
logoutAllExceptCurrent: bool = False
class SendMessageRequest(BaseModel): class SendMessageRequest(BaseModel):
content: str content: str
reply_to_id: int | None = None reply_to_id: int | None = None
+1
View File
@@ -10,3 +10,4 @@ pywebpush>=1.14.0
cryptography>=41.0.0 cryptography>=41.0.0
alembic>=1.13.2 alembic>=1.13.2
better-profanity>=0.7.0 better-profanity>=0.7.0
user-agents>=2.2.0
+84 -6
View File
@@ -1,10 +1,13 @@
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
import uuid
from user_agents import parse as parse_ua
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from constants import OWNER_USERNAME from constants import OWNER_USERNAME
from dependencies import get_current_user, get_db from dependencies import get_current_user, get_db
from models import LoginRequest, RegisterRequest, User, CryptoPublicKey, CryptoBackup from models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession
from utils import create_token, get_password_hash, verify_password from utils import create_token, get_password_hash, verify_password
from validation import is_valid_password, is_valid_username, is_valid_display_name from validation import is_valid_password, is_valid_username, is_valid_display_name
@@ -37,7 +40,7 @@ def check_auth(current_user: User = Depends(get_current_user)):
@router.post("/login") @router.post("/login")
def login(request: LoginRequest, db: Session = Depends(get_db)): def login(request: LoginRequest, db: Session = Depends(get_db), http: Request = None):
user = db.query(User).filter(User.username == request.username.strip()).first() user = db.query(User).filter(User.username == request.username.strip()).first()
if not user or not verify_password(request.password.strip(), user.password_hash): if not user or not verify_password(request.password.strip(), user.password_hash):
@@ -46,11 +49,33 @@ def login(request: LoginRequest, db: Session = Depends(get_db)):
detail="Неверное имя пользователя или пароль" detail="Неверное имя пользователя или пароль"
) )
# Create device session and embed into JWT
raw_ua = http.headers.get("user-agent") if http else None
ua = parse_ua(raw_ua or "")
session_id = uuid.uuid4().hex
device = DeviceSession(
user_id=user.id,
raw_user_agent=raw_ua,
device_type=("mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop"),
os_name=(ua.os.family or None),
os_version=(ua.os.version_string or None),
browser_name=(ua.browser.family or None),
browser_version=(ua.browser.version_string or None),
brand=(ua.device.brand or None),
model=(ua.device.model or None),
session_id=session_id,
created_at=datetime.now(),
last_seen=datetime.now(),
revoked=False,
)
db.add(device)
user.online = True user.online = True
user.last_seen = datetime.now() user.last_seen = datetime.now()
db.commit() db.commit()
token = create_token(user.id, user.username) token = create_token(user.id, user.username, session_id)
return { return {
"status": "success", "status": "success",
@@ -61,7 +86,7 @@ def login(request: LoginRequest, db: Session = Depends(get_db)):
@router.post("/register") @router.post("/register")
def register(request: RegisterRequest, db: Session = Depends(get_db)): def register(request: RegisterRequest, db: Session = Depends(get_db), http: Request = None):
username = request.username.strip() username = request.username.strip()
display_name = request.display_name.strip() display_name = request.display_name.strip()
password = request.password.strip() password = request.password.strip()
@@ -134,7 +159,29 @@ def register(request: RegisterRequest, db: Session = Depends(get_db)):
db.commit() db.commit()
db.refresh(new_user) db.refresh(new_user)
token = create_token(new_user.id, new_user.username) # Create initial device session
raw_ua = http.headers.get("user-agent") if http else None
ua = parse_ua(raw_ua or "")
session_id = uuid.uuid4().hex
device = DeviceSession(
user_id=new_user.id,
raw_user_agent=raw_ua,
device_type=("mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop"),
os_name=(ua.os.family or None),
os_version=(ua.os.version_string or None),
browser_name=(ua.browser.family or None),
browser_version=(ua.browser.version_string or None),
brand=(ua.device.brand or None),
model=(ua.device.model or None),
session_id=session_id,
created_at=datetime.now(),
last_seen=datetime.now(),
revoked=False,
)
db.add(device)
db.commit()
token = create_token(new_user.id, new_user.username, session_id)
return { return {
"status": "success", "status": "success",
@@ -227,6 +274,37 @@ def logout(
} }
@router.post("/change-password")
def change_password(
request: ChangePasswordRequest,
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
# Verify current derived password against stored hash
if not verify_password(request.currentPasswordDerived.strip(), current_user.password_hash):
raise HTTPException(status_code=401, detail="Текущий пароль неверный")
# Update password hash to hash of new derived password
current_user.password_hash = get_password_hash(request.newPasswordDerived.strip())
db.commit()
# Optionally revoke all other sessions, keeping the current one
if request.logoutAllExceptCurrent:
from utils import verify_token as _verify_token
payload = _verify_token(credentials.credentials)
if not payload:
raise HTTPException(status_code=401, detail="Invalid token")
current_session_id = payload.get("session_id")
db.query(DeviceSession).filter(
DeviceSession.user_id == current_user.id,
DeviceSession.session_id != current_session_id,
).update({DeviceSession.revoked: True})
db.commit()
return {"status": "success"}
@router.get("/users") @router.get("/users")
def list_users(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): def list_users(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
users = db.query(User).order_by(User.username.asc()).all() users = db.query(User).order_by(User.username.asc()).all()
+88
View File
@@ -0,0 +1,88 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from dependencies import get_current_user, get_db
from models import User, DeviceSession
from utils import verify_token
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
router = APIRouter()
security = HTTPBearer()
def _get_current_session_id(credentials: HTTPAuthorizationCredentials) -> str:
token = credentials.credentials
payload = verify_token(token)
if not payload or "session_id" not in payload:
raise HTTPException(status_code=401, detail="Invalid session")
return payload["session_id"]
@router.get("")
def list_devices(
credentials: HTTPAuthorizationCredentials = Depends(security),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
current_session_id = _get_current_session_id(credentials)
sessions = (
db.query(DeviceSession)
.filter(DeviceSession.user_id == current_user.id)
.order_by(DeviceSession.last_seen.desc())
.all()
)
return {
"devices": [
{
"session_id": s.session_id,
"device_type": s.device_type,
"os_name": s.os_name,
"os_version": s.os_version,
"browser_name": s.browser_name,
"browser_version": s.browser_version,
"brand": s.brand,
"model": s.model,
"created_at": s.created_at.isoformat() if s.created_at else None,
"last_seen": s.last_seen.isoformat() if s.last_seen else None,
"revoked": s.revoked,
"current": s.session_id == current_session_id,
}
for s in sessions
]
}
@router.delete("/{session_id}")
def revoke_device(
session_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
s = (
db.query(DeviceSession)
.filter(DeviceSession.user_id == current_user.id, DeviceSession.session_id == session_id)
.first()
)
if not s:
raise HTTPException(status_code=404, detail="Device session not found")
s.revoked = True
db.commit()
return {"status": "success"}
@router.post("/logout-all")
def logout_all_except_current(
credentials: HTTPAuthorizationCredentials = Depends(security),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
current_session_id = _get_current_session_id(credentials)
db.query(DeviceSession).filter(
DeviceSession.user_id == current_user.id,
DeviceSession.session_id != current_session_id,
).update({DeviceSession.revoked: True})
db.commit()
return {"status": "success"}
+2 -1
View File
@@ -6,11 +6,12 @@ import bcrypt
from constants import * from constants import *
# JWT Helper Functions # JWT Helper Functions
def create_token(user_id: int, username: str) -> str: def create_token(user_id: int, username: str, session_id: str) -> str:
expire = datetime.now() + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS) expire = datetime.now() + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
payload = { payload = {
"user_id": user_id, "user_id": user_id,
"username": username, "username": username,
"session_id": session_id,
"exp": expire "exp": expire
} }
return jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM) return jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
+14
View File
@@ -3,6 +3,7 @@ import { generateX25519KeyPair } from "@/utils/crypto/asymmetric";
import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@/utils/crypto/backup"; import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@/utils/crypto/backup";
import { b64, ub64 } from "@/utils/utils"; import { b64, ub64 } from "@/utils/utils";
import { API_BASE_URL } from "@/core/config"; import { API_BASE_URL } from "@/core/config";
import { importPassword, hkdfExtractAndExpand } from "@/utils/crypto/kdf";
/** /**
* Generates authentication headers for API requests * Generates authentication headers for API requests
@@ -143,4 +144,17 @@ export function restoreKeys() {
export function getAuthToken(): string | null { export function getAuthToken(): string | null {
return localStorage.getItem("authToken"); return localStorage.getItem("authToken");
}
/**
* Derive a client-side authentication secret so the raw password never leaves the client.
* Uses PBKDF2 (via WebCrypto) + HKDF to produce a stable 32-byte key, then base64.
*/
export async function deriveAuthSecret(username: string, password: string): Promise<string> {
const key = await importPassword(password);
// Use per-user salt derived from username; in future we can fetch a server-provided salt
const salt = new TextEncoder().encode(`fromchat.user:${username}`);
// Derive 32 bytes using HKDF; PBKDF2 already used within importPassword
const derived = await hkdfExtractAndExpand(new TextEncoder().encode(password), salt, new TextEncoder().encode("auth-secret"), 32);
return b64(derived);
} }
+36
View File
@@ -0,0 +1,36 @@
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "@/core/api/authApi";
export interface DeviceInfo {
session_id: string;
device_type?: string;
os_name?: string;
os_version?: string;
browser_name?: string;
browser_version?: string;
brand?: string;
model?: string;
created_at?: string;
last_seen?: string;
revoked?: boolean;
current?: boolean;
}
export async function listDevices(token: string): Promise<DeviceInfo[]> {
const res = await fetch(`${API_BASE_URL}/devices`, { headers: getAuthHeaders(token) });
if (!res.ok) throw new Error("Failed to fetch devices");
const data = await res.json();
return data.devices as DeviceInfo[];
}
export async function revokeDevice(token: string, sessionId: string): Promise<void> {
const res = await fetch(`${API_BASE_URL}/devices/${sessionId}`, { method: "DELETE", headers: getAuthHeaders(token) });
if (!res.ok) throw new Error("Failed to revoke device");
}
export async function logoutAllOtherDevices(token: string): Promise<void> {
const res = await fetch(`${API_BASE_URL}/devices/logout-all`, { method: "POST", headers: getAuthHeaders(token) });
if (!res.ok) throw new Error("Failed to logout all devices");
}
+25
View File
@@ -0,0 +1,25 @@
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders, deriveAuthSecret } from "@/core/api/authApi";
export async function changePassword(
token: string,
username: string,
currentPassword: string,
newPassword: string,
logoutAllExceptCurrent: boolean
): Promise<void> {
const currentDerived = await deriveAuthSecret(username, currentPassword);
const newDerived = await deriveAuthSecret(username, newPassword);
const res = await fetch(`${API_BASE_URL}/change-password`, {
method: "POST",
headers: getAuthHeaders(token),
body: JSON.stringify({
currentPasswordDerived: currentDerived,
newPasswordDerived: newDerived,
logoutAllExceptCurrent
})
});
if (!res.ok) throw new Error("Failed to change password");
}
+3 -2
View File
@@ -2,7 +2,7 @@ import { useImmer } from "use-immer";
import { AlertsContainer, type Alert, type AlertType } from "./Auth"; import { AlertsContainer, type Alert, type AlertType } from "./Auth";
import { AuthContainer, AuthHeader } from "./Auth"; import { AuthContainer, AuthHeader } from "./Auth";
import type { ErrorResponse, LoginRequest, LoginResponse } from "@/core/types"; import type { ErrorResponse, LoginRequest, LoginResponse } from "@/core/types";
import { ensureKeysOnLogin } from "@/core/api/authApi"; import { ensureKeysOnLogin, deriveAuthSecret } from "@/core/api/authApi";
import { API_BASE_URL } from "@/core/config"; import { API_BASE_URL } from "@/core/config";
import { useRef } from "react"; import { useRef } from "react";
import type { TextField } from "mdui/components/text-field"; import type { TextField } from "mdui/components/text-field";
@@ -47,9 +47,10 @@ export default function LoginPage() {
} }
try { try {
const derived = await deriveAuthSecret(username, password);
const request: LoginRequest = { const request: LoginRequest = {
username: username, username: username,
password: password password: derived
} }
const response = await fetch(`${API_BASE_URL}/login`, { const response = await fetch(`${API_BASE_URL}/login`, {
+4 -3
View File
@@ -7,7 +7,7 @@ import type { ErrorResponse, RegisterRequest, LoginResponse } from "@/core/types
import { API_BASE_URL } from "@/core/config"; import { API_BASE_URL } from "@/core/config";
import { useAppState } from "@/pages/chat/state"; import { useAppState } from "@/pages/chat/state";
import { MaterialTextField } from "@/core/components/MaterialTextField"; import { MaterialTextField } from "@/core/components/MaterialTextField";
import { ensureKeysOnLogin } from "@/core/api/authApi"; import { ensureKeysOnLogin, deriveAuthSecret } from "@/core/api/authApi";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import "./auth.scss"; import "./auth.scss";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen"; import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
@@ -74,11 +74,12 @@ export default function RegisterPage() {
} }
try { try {
const derived = await deriveAuthSecret(username, password);
const request: RegisterRequest = { const request: RegisterRequest = {
display_name: displayName, display_name: displayName,
username: username, username: username,
password: password, password: derived,
confirm_password: confirmPassword confirm_password: derived
} }
const response = await fetch(`${API_BASE_URL}/register`, { const response = await fetch(`${API_BASE_URL}/register`, {
@@ -7,12 +7,21 @@ import { isElectron } from "@/core/electron/electron";
import { useAppState } from "@/pages/chat/state"; import { useAppState } from "@/pages/chat/state";
import type { Switch } from "mdui/components/switch"; import type { Switch } from "mdui/components/switch";
import { getAuthHeaders } from "@/core/api/authApi"; import { getAuthHeaders } from "@/core/api/authApi";
import { changePassword } from "@/core/api/securityApi";
import { listDevices, revokeDevice, logoutAllOtherDevices, type DeviceInfo } from "@/core/api/devicesApi";
import { useImmer } from "use-immer";
export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) { export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
const [activePanel, setActivePanel] = useState("notifications-settings"); const [activePanel, setActivePanel] = useState("notifications-settings");
const [pushNotificationsEnabled, setPushNotificationsEnabled] = useState(false); const [pushNotificationsEnabled, setPushNotificationsEnabled] = useState(false);
const [pushSupported, setPushSupported] = useState(false); const [pushSupported, setPushSupported] = useState(false);
const user = useAppState(state => state.user); const user = useAppState(state => state.user);
const logout = useAppState(state => state.logout);
const [devices, updateDevices] = useImmer<DeviceInfo[]>([]);
const [cpCurrent, setCpCurrent] = useState("");
const [cpNext, setCpNext] = useState("");
const [cpConfirm, setCpConfirm] = useState("");
const [cpLogoutAll, setCpLogoutAll] = useState(true);
useEffect(() => { useEffect(() => {
setPushSupported(isSupported()); setPushSupported(isSupported());
@@ -21,6 +30,14 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
setPushNotificationsEnabled(isSupported()); setPushNotificationsEnabled(isSupported());
}, []); }, []);
useEffect(() => {
if (activePanel === "devices-settings" && user.authToken) {
listDevices(user.authToken)
.then(list => updateDevices(() => list))
.catch(() => {});
}
}, [activePanel, user.authToken, updateDevices]);
const handlePanelChange = (panelId: string) => { const handlePanelChange = (panelId: string) => {
setActivePanel(panelId); setActivePanel(panelId);
}; };
@@ -79,15 +96,6 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
> >
Уведомления Уведомления
</mdui-list-item> </mdui-list-item>
<mdui-list-item
icon="palette--filled"
rounded
active={activePanel === "appearance-settings"}
onClick={() => handlePanelChange("appearance-settings")}
style={{ cursor: "pointer" }}
>
Внешний вид
</mdui-list-item>
<mdui-list-item <mdui-list-item
icon="security--filled" icon="security--filled"
rounded rounded
@@ -98,31 +106,13 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
Безопасность Безопасность
</mdui-list-item> </mdui-list-item>
<mdui-list-item <mdui-list-item
icon="language--filled" icon="devices--filled"
rounded rounded
active={activePanel === "language-settings"} active={activePanel === "devices-settings"}
onClick={() => handlePanelChange("language-settings")} onClick={() => handlePanelChange("devices-settings")}
style={{ cursor: "pointer" }} style={{ cursor: "pointer" }}
> >
Язык Устройства
</mdui-list-item>
<mdui-list-item
icon="storage--filled"
rounded
active={activePanel === "storage-settings"}
onClick={() => handlePanelChange("storage-settings")}
style={{ cursor: "pointer" }}
>
Хранилище
</mdui-list-item>
<mdui-list-item
icon="help--filled"
rounded
active={activePanel === "help-settings"}
onClick={() => handlePanelChange("help-settings")}
style={{ cursor: "pointer" }}
>
Помощь
</mdui-list-item> </mdui-list-item>
<mdui-list-item <mdui-list-item
icon="info--filled" icon="info--filled"
@@ -145,31 +135,33 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
Push уведомления Push уведомления
</mdui-switch> </mdui-switch>
)} )}
<mdui-switch checked>Новые сообщения</mdui-switch>
<mdui-switch checked>Звуковые уведомления</mdui-switch>
<mdui-switch>Уведомления о статусе</mdui-switch>
<mdui-switch checked>Email уведомления</mdui-switch>
</div> </div>
<div id="appearance-settings" className={`settings-panel ${activePanel === "appearance-settings" ? "active" : ""}`}>
<h3>Внешний вид</h3>
<mdui-select label="Тема" variant="outlined">
<mdui-menu-item value="dark">Тёмная</mdui-menu-item>
<mdui-menu-item value="light">Светлая</mdui-menu-item>
<mdui-menu-item value="auto">Авто</mdui-menu-item>
</mdui-select>
<mdui-select label="Размер шрифта" variant="outlined">
<mdui-menu-item value="small">Маленький</mdui-menu-item>
<mdui-menu-item value="medium">Средний</mdui-menu-item>
<mdui-menu-item value="large">Большой</mdui-menu-item>
</mdui-select>
</div>
<div id="security-settings" className={`settings-panel ${activePanel === "security-settings" ? "active" : ""}`}> <div id="security-settings" className={`settings-panel ${activePanel === "security-settings" ? "active" : ""}`}>
<h3>Безопасность</h3> <h3>Безопасность</h3>
<mdui-button variant="outlined">Изменить пароль</mdui-button> <form onSubmit={async (e) => {
<mdui-button variant="outlined">Двухфакторная аутентификация</mdui-button> e.preventDefault();
<mdui-switch>Автоматический выход</mdui-switch> if (!user.authToken || !user.username) return;
if (!cpCurrent || !cpNext || cpNext !== cpConfirm) return;
try {
await changePassword(user.authToken, user.username, cpCurrent, cpNext, cpLogoutAll);
setCpCurrent("");
setCpNext("");
setCpConfirm("");
} catch (err) {
console.error(err);
}
}}>
<mdui-text-field label="Текущий пароль" type="password" value={cpCurrent} onInput={(e: any) => setCpCurrent(e.target.value)} variant="outlined" toggle-password></mdui-text-field>
<mdui-text-field label="Новый пароль" type="password" value={cpNext} onInput={(e: any) => setCpNext(e.target.value)} variant="outlined" toggle-password></mdui-text-field>
<mdui-text-field label="Подтвердите пароль" type="password" value={cpConfirm} onInput={(e: any) => setCpConfirm(e.target.value)} variant="outlined" toggle-password></mdui-text-field>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<mdui-switch checked={cpLogoutAll} onInput={(e: any) => setCpLogoutAll(e.target.checked)}>Выйти на всех устройствах (кроме текущего)</mdui-switch>
<div style={{ flexGrow: 1 }}></div>
<mdui-button type="submit" variant="tonal">Сохранить</mdui-button>
</div>
</form>
</div> </div>
<div id="language-settings" className={`settings-panel ${activePanel === "language-settings" ? "active" : ""}`}> <div id="language-settings" className={`settings-panel ${activePanel === "language-settings" ? "active" : ""}`}>
@@ -188,19 +180,26 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
<mdui-button variant="outlined">Очистить кэш</mdui-button> <mdui-button variant="outlined">Очистить кэш</mdui-button>
</div> </div>
<div id="help-settings" className={`settings-panel ${activePanel === "help-settings" ? "active" : ""}`}> <div id="devices-settings" className={`settings-panel ${activePanel === "devices-settings" ? "active" : ""}`}>
<h3>Помощь</h3> <h3>Устройства</h3>
<mdui-button variant="outlined">Руководство пользователя</mdui-button> <div style={{ display: "flex", gap: 12, marginBottom: 12 }}>
<mdui-button variant="outlined">Связаться с поддержкой</mdui-button> <mdui-button variant="tonal" onClick={async () => { if (!user.authToken) return; await logoutAllOtherDevices(user.authToken); const list = await listDevices(user.authToken); updateDevices(() => list); }}>Выйти на всех остальных устройствах</mdui-button>
<mdui-button variant="outlined">FAQ</mdui-button> <mdui-button variant="outlined" onClick={async () => { if (!user.authToken) return; await fetch(`${API_BASE_URL}/logout`, { headers: getAuthHeaders(user.authToken) }); logout(); }}>Выйти на этом устройстве</mdui-button>
</div>
<mdui-list>
{devices.map((d) => (
<mdui-list-item key={d.session_id} icon={d.current ? "devices_other--filled" : "devices--filled"} rounded end-icon={!d.current ? "logout--filled" : undefined} onEndIconClick={async () => { if (!user.authToken || d.current) return; await revokeDevice(user.authToken, d.session_id); const list = await listDevices(user.authToken); updateDevices(() => list); }}>
<div slot="headline">{d.browser_name || "Браузер"} на {d.os_name || "OS"} {d.current ? " (это устройство)" : ""}</div>
<div slot="description">Последняя активность: {d.last_seen || "—"}</div>
</mdui-list-item>
))}
</mdui-list>
</div> </div>
<div id="about-settings" className={`settings-panel ${activePanel === "about-settings" ? "active" : ""}`}> <div id="about-settings" className={`settings-panel ${activePanel === "about-settings" ? "active" : ""}`}>
<h3>О приложении</h3> <h3>О приложении</h3>
<p>Версия: 1.0.0</p> <p>100% open source. Репозиторий на <a href="https://github.com/Toolbox-io/FromChat" target="_blank" rel="noreferrer">GitHub</a>.</p>
<p>© 2025 <span className="product-name">{PRODUCT_NAME}</span>. Все права защищены.</p> <p><span className="product-name">{PRODUCT_NAME}</span></p>
<mdui-button variant="outlined">Политика конфиденциальности</mdui-button>
<mdui-button variant="outlined">Условия использования</mdui-button>
</div> </div>
</div> </div>
</div> </div>