Redesign settings from scratch

This commit is contained in:
2025-11-03 11:21:08 +03:00
Unverified
parent 1c38bc39ec
commit dca0d319e9
10 changed files with 606 additions and 273 deletions
+84
View File
@@ -1,6 +1,7 @@
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, Request from fastapi import APIRouter, Depends, HTTPException, status, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import inspect, text
import uuid import uuid
from user_agents import parse as parse_ua from user_agents import parse as parse_ua
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
@@ -11,6 +12,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession 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
import os
router = APIRouter() router = APIRouter()
@@ -349,4 +351,86 @@ def search_users(q: str, current_user: User = Depends(get_current_user), db: Ses
return { return {
"users": [convert_user(u) for u in users] "users": [convert_user(u) for u in users]
}
async def _delete_user_data(user: User, db: Session):
"""
Helper function to delete user data - marks user as deleted, clears sensitive data,
deletes profile picture, removes non-whitelist user data, and sends WebSocket message.
"""
user_id = user.id
# Mark user as deleted and clear sensitive data
user.deleted = True
user.display_name = f"Deleted User #{user_id}"
user.bio = None
user.password_hash = ""
user.username = f"deleted_{user_id}"
user.profile_picture = None
user.last_seen = None # Clear last seen timestamp
user.created_at = None # Clear member since timestamp
# Delete profile picture file if exists
if user.profile_picture and user.profile_picture.startswith("/api/profile-picture/"):
try:
filename = user.profile_picture.split("/")[-1]
filepath = os.path.join("data/uploads/pfp", filename)
if os.path.exists(filepath):
os.remove(filepath)
except Exception as e:
# Log error but don't fail the request
pass
# Dynamic deletion of all non-whitelist data
WHITELIST_TABLES = {"message", "dm_envelope", "reaction", "dm_reaction", "message_file", "dm_file"}
try:
inspector = inspect(db.bind)
all_tables = inspector.get_table_names()
for table_name in all_tables:
if table_name in WHITELIST_TABLES or table_name == "user":
continue
# Check if table has user_id column
columns = inspector.get_columns(table_name)
has_user_id = any(col['name'] == 'user_id' for col in columns)
if has_user_id:
# Delete all records for this user
db.execute(text(f"DELETE FROM {table_name} WHERE user_id = :uid"), {"uid": user_id})
db.commit()
except Exception as e:
# Log error and rollback
db.rollback()
raise HTTPException(status_code=500, detail="Failed to delete user data")
# Send WebSocket deletion message
try:
from .messaging import messagingManager
await messagingManager.send_deletion_to_user(user_id)
except Exception as e:
# Log error but don't fail the request
pass
@router.post("/delete")
async def delete_account(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
Delete the current user's own account - preserves messages/DMs/reactions/files
"""
# Prevent admin/owner account self-deletion
if current_user.username == OWNER_USERNAME or current_user.id == 1:
raise HTTPException(status_code=400, detail="Cannot delete admin/owner account")
await _delete_user_data(current_user, db)
return {
"status": "success",
"message": "Account deleted successfully"
} }
+2 -53
View File
@@ -426,59 +426,8 @@ async def delete_user(
if target_user.id == 1: if target_user.id == 1:
raise HTTPException(status_code=400, detail="Cannot delete admin account") raise HTTPException(status_code=400, detail="Cannot delete admin account")
# Mark user as deleted and clear sensitive data from .account import _delete_user_data
target_user.deleted = True await _delete_user_data(target_user, db)
target_user.display_name = f"Deleted User #{user_id}"
target_user.bio = None
target_user.password_hash = ""
target_user.username = f"deleted_{user_id}"
target_user.profile_picture = None
target_user.last_seen = None # Clear last seen timestamp
target_user.created_at = None # Clear member since timestamp
# Delete profile picture file if exists
if target_user.profile_picture and target_user.profile_picture.startswith("/api/profile-picture/"):
try:
import os
filename = target_user.profile_picture.split("/")[-1]
filepath = os.path.join("data/uploads/pfp", filename)
if os.path.exists(filepath):
os.remove(filepath)
except Exception as e:
# Log error but don't fail the request
pass
# Dynamic deletion of all non-whitelist data
WHITELIST_TABLES = {"message", "dm_envelope", "reaction", "dm_reaction", "message_file", "dm_file"}
try:
inspector = inspect(db.bind)
all_tables = inspector.get_table_names()
for table_name in all_tables:
if table_name in WHITELIST_TABLES or table_name == "user":
continue
# Check if table has user_id column
columns = inspector.get_columns(table_name)
has_user_id = any(col['name'] == 'user_id' for col in columns)
if has_user_id:
# Delete all records for this user
db.execute(text(f"DELETE FROM {table_name} WHERE user_id = :uid"), {"uid": user_id})
db.commit()
except Exception as e:
# Log error and rollback
db.rollback()
raise HTTPException(status_code=500, detail="Failed to delete user data")
# Send WebSocket deletion message
try:
await messagingManager.send_deletion_to_user(user_id)
except Exception as e:
# Log error but don't fail the request
pass
return { return {
"status": "success", "status": "success",
+11
View File
@@ -22,4 +22,15 @@ export async function changePassword(
if (!res.ok) throw new Error("Failed to change password"); if (!res.ok) throw new Error("Failed to change password");
} }
export async function deleteAccount(token: string): Promise<void> {
const res = await fetch(`${API_BASE_URL}/account/delete`, {
method: "POST",
headers: getAuthHeaders(token)
});
if (!res.ok) {
const error = await res.json().catch(() => ({ detail: "Failed to delete account" }));
throw new Error(error.detail || "Failed to delete account");
}
}
@@ -183,6 +183,21 @@ export async function subscribe(token: string): Promise<boolean> {
return true; return true;
} }
// If subscription doesn't exist, try to get it from the push manager or create a new one
if (!subscription && registration) {
try {
// Try to get existing subscription first
subscription = await registration.pushManager.getSubscription();
// If no existing subscription, create a new one
if (!subscription) {
subscription = await subscribeToWebPush();
}
} catch (error) {
console.error("Failed to get or create subscription:", error);
subscription = await subscribeToWebPush();
}
}
return await sendSubscriptionToServer(token); return await sendSubscriptionToServer(token);
} }
@@ -2,7 +2,6 @@
@use "../../../css/material" as *; @use "../../../css/material" as *;
@use "sass:color"; @use "sass:color";
// Settings styles - override StyledDialog's backdrop and dialog
.settingsDialog { .settingsDialog {
width: calc(100vw - 60px) !important; width: calc(100vw - 60px) !important;
height: calc(100vh - 60px) !important; height: calc(100vh - 60px) !important;
@@ -15,97 +14,99 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 24px; padding: 24px;
overflow-y: auto;
.header { .settingsHeader {
display: flex; display: flex;
flex-direction: row; align-items: center;
gap: 10px; gap: 8px;
margin-bottom: 16px; margin-bottom: 16px;
flex-shrink: 0;
.title { .settingsTitle {
display: block; margin: 0;
font-size: 22px;
font-weight: 500;
color: $color-dark-on-surface;
flex: 1;
} }
} }
.settingsMenu { .settingsLayout {
display: flex; display: flex;
flex-direction: row;
gap: 16px;
flex: 1; flex: 1;
min-height: 0; overflow: hidden;
gap: 1px;
:global(mdui-list) { .sidebar {
max-width: 280px; width: 240px;
padding-right: 16px;
overflow-y: auto; overflow-y: auto;
flex-shrink: 0;
} }
.screen { .contentPanel {
flex: 1; flex: 1;
overflow-y: auto; overflow: hidden;
display: flex;
flex-direction: column;
position: relative; position: relative;
min-width: 0;
.settingsPanel { .panelContent {
padding: 24px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
opacity: 0; flex: 1;
visibility: hidden;
transform: translateY(20px);
transition: opacity 0.3s ease, transform 0.3s ease, visibility 0.3s ease;
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
width: 100%; right: 0;
bottom: 0;
overflow-y: auto;
&.active { .panelTitle {
opacity: 1; margin: 0;
visibility: visible; font-size: 20px;
transform: translateY(0); font-weight: 500;
position: relative;
}
:global(h3) {
margin: 0 0 16px 0;
color: $color-dark-on-surface; color: $color-dark-on-surface;
padding-bottom: 16px;
border-bottom: 1px solid $color-dark-outline-variant;
} }
:global(mdui-text-field), .loadingContainer {
:global(mdui-select),
:global(mdui-switch),
:global(mdui-button) {
margin-bottom: 8px;
}
:global(mdui-switch) {
display: flex; display: flex;
justify-content: center;
align-items: center; align-items: center;
justify-content: space-between; padding: 32px;
padding: 12px 0;
border-bottom: 1px solid $color-dark-outline;
&:last-child {
border-bottom: none;
}
} }
:global(p) { .sectionActions {
margin: 8px 0; display: flex;
color: $color-dark-on-surface-variant; justify-content: flex-end;
} padding: 8px 0;
:global(mdui-linear-progress) {
margin: 16px 0;
}
.productName {
display: inline;
} }
} }
} }
} }
} }
} }
.clickableItem {
cursor: pointer;
border-radius: 16px;
overflow: hidden;
}
.dangerItem {
color: $color-dark-error;
&::part(icon) {
color: $color-dark-error;
}
&::part(headline) {
color: $color-dark-error;
}
&::part(description) {
color: $color-dark-error;
}
}
@@ -0,0 +1,59 @@
import { MaterialList, MaterialListItem } from "@/utils/material";
import { useAppState } from "@/pages/chat/state";
import { deleteAccount } from "@/core/api/securityApi";
import { confirm } from "mdui/functions/confirm";
import styles from "@/pages/chat/css/settings-dialog.module.scss";
interface AccountPanelProps {
onClose: () => void;
}
export function AccountPanel({ onClose }: AccountPanelProps) {
const { user, logout } = useAppState();
const authToken = user?.authToken;
async function handleDeleteAccount() {
if (!authToken) return;
try {
await confirm({
headline: "Delete Account?",
description: "This will permanently delete your account and all your data. This action cannot be undone.",
confirmText: "Delete",
cancelText: "Cancel"
});
await deleteAccount(authToken);
logout();
onClose();
} catch (error) {
if (error !== "cancelled") {
console.error("Failed to delete account:", error);
alert(error instanceof Error ? error.message : "Failed to delete account");
}
}
}
return (
<>
<h3 className={styles.panelTitle}>Account</h3>
<MaterialList>
<MaterialListItem
onClick={logout}
className={styles.clickableItem}
headline="Logout"
description="Sign out of your account"
icon="logout"
/>
<MaterialListItem
onClick={handleDeleteAccount}
className={`${styles.clickableItem} ${styles.dangerItem}`}
headline="Delete Account"
description="Permanently delete your account"
icon="delete_forever"
/>
</MaterialList>
</>
);
}
@@ -0,0 +1,151 @@
import { useState, useEffect } from "react";
import { useImmer } from "use-immer";
import { MaterialList, MaterialListItem, MaterialButton, MaterialCircularProgress } from "@/utils/material";
import { useAppState } from "@/pages/chat/state";
import { listDevices, revokeDevice, logoutAllOtherDevices, type DeviceInfo } from "@/core/api/devicesApi";
import { confirm } from "mdui/functions/confirm";
import styles from "@/pages/chat/css/settings-dialog.module.scss";
export function DevicesPanel() {
const { user } = useAppState();
const authToken = user?.authToken ?? null;
const [devices, updateDevices] = useImmer<DeviceInfo[]>([]);
const [devicesLoading, setDevicesLoading] = useState(false);
const [revokingDevices, setRevokingDevices] = useImmer<Set<string>>(new Set());
useEffect(() => {
if (authToken) {
loadDevices();
}
}, [authToken]);
async function loadDevices() {
if (!authToken) return;
setDevicesLoading(true);
try {
const deviceList = await listDevices(authToken);
updateDevices(deviceList);
} catch (error) {
console.error("Failed to load devices:", error);
} finally {
setDevicesLoading(false);
}
}
async function handleRevokeDevice(sessionId: string) {
if (!authToken) return;
try {
await confirm({
headline: "Revoke Device?",
description: "This will log out this device. You will need to log in again on this device.",
confirmText: "Revoke",
cancelText: "Cancel"
});
setRevokingDevices(draft => {
draft.add(sessionId);
});
await revokeDevice(authToken, sessionId);
await loadDevices();
} catch (error) {
if (error !== "cancelled") {
console.error("Failed to revoke device:", error);
}
} finally {
setRevokingDevices(draft => {
draft.delete(sessionId);
});
}
}
async function handleLogoutAll() {
if (!authToken) return;
try {
await confirm({
headline: "Logout All Other Devices?",
description: "This will log you out on all other devices. You will remain logged in on this device.",
confirmText: "Logout All",
cancelText: "Cancel"
});
await logoutAllOtherDevices(authToken);
await loadDevices();
} catch (error) {
if (error !== "cancelled") {
console.error("Failed to logout all devices:", error);
}
}
}
function formatDeviceInfo(device: DeviceInfo): string {
const parts: string[] = [];
if (device.device_name) parts.push(device.device_name);
if (device.os_name) parts.push(device.os_name);
if (device.browser_name) parts.push(device.browser_name);
return parts.length > 0 ? parts.join(" • ") : device.device_type || "Unknown device";
}
function formatLastSeen(dateStr: string | undefined): string {
if (!dateStr) return "Never";
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return "Just now";
if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? "s" : ""} ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? "s" : ""} ago`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? "s" : ""} ago`;
return date.toLocaleDateString();
}
if (devicesLoading) {
return (
<>
<h3 className={styles.panelTitle}>Devices</h3>
<div className={styles.loadingContainer}>
<MaterialCircularProgress />
</div>
</>
);
}
return (
<>
<h3 className={styles.panelTitle}>Devices</h3>
<MaterialList>
{devices.map((device) => (
<MaterialListItem
key={device.session_id}
className={styles.clickableItem}
headline={formatDeviceInfo(device)}
description={device.current ? "Current" : "Last seen: " + formatLastSeen(device.last_seen)}
icon={device.current ? "smartphone" : "phone_android"}
onClick={() => handleRevokeDevice(device.session_id)}
disabled={revokingDevices.has(device.session_id)}
/>
))}
</MaterialList>
{devices.filter(d => !d.current).length > 0 && (
<div className={styles.sectionActions}>
<MaterialButton
onClick={handleLogoutAll}
variant="tonal"
>
Logout All Other Devices
</MaterialButton>
</div>
)}
</>
);
}
@@ -0,0 +1,128 @@
import { useState, useRef } from "react";
import { MaterialList, MaterialListItem, MaterialSwitch, type MDUISwitch } from "@/utils/material";
import { useAppState } from "@/pages/chat/state";
import { initialize, subscribe, unsubscribe, isSupported } from "@/core/push-notifications/push-notifications";
import { isElectron } from "@/core/electron/electron";
import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "@/core/api/authApi";
import styles from "@/pages/chat/css/settings-dialog.module.scss";
export function NotificationsPanel() {
const { user } = useAppState();
const authToken = user?.authToken ?? null;
const [pushEnabled, setPushEnabled] = useState(false);
const [loading, setLoading] = useState(false);
const [checking, setChecking] = useState(true);
const switchRef = useRef<MDUISwitch>(null);
async function checkPushStatus() {
if (!isSupported()) {
setPushEnabled(false);
setChecking(false);
return;
}
setChecking(true);
try {
let permission: string;
if (isElectron) {
permission = await window.electronInterface.notifications.requestPermission();
} else {
permission = Notification.permission;
}
console.log("checkPushStatus", permission);
setPushEnabled(permission === "granted");
} catch (error) {
console.error("Failed to check push status:", error);
setPushEnabled(false);
} finally {
setChecking(false);
}
}
async function handlePushToggle(enabled: boolean) {
console.log("handlePushToggle", enabled);
if (!authToken || !isSupported() || loading) return;
// Optimistic update
const previousState = pushEnabled;
setPushEnabled(enabled);
setLoading(true);
try {
if (enabled) {
// Initialize push notifications (creates service worker and requests permission)
const initResult = await initialize();
if (!initResult) {
throw new Error("Failed to initialize push notifications");
}
// Subscribe to push notifications (sends subscription to server)
// The subscribe() function will handle creating/getting the subscription if needed
const subscribeResult = await subscribe(authToken);
if (!subscribeResult) {
throw new Error("Failed to subscribe to push notifications");
}
// Verify the state after subscription - check permission to ensure it's actually granted
await checkPushStatus();
} else {
// Unsubscribe locally first
const unsubscribed = await unsubscribe();
if (!unsubscribed) {
throw new Error("Failed to unsubscribe locally");
}
// Then unsubscribe from server
const response = await fetch(`${API_BASE_URL}/push/unsubscribe`, {
method: "DELETE",
headers: getAuthHeaders(authToken)
});
if (!response.ok) {
throw new Error("Failed to unsubscribe from push notifications");
}
// After unsubscribing, permission is still granted but we're not subscribed
// So we keep the state as disabled (false)
setPushEnabled(false);
}
} catch (error) {
console.error("Failed to toggle push notifications:", error);
// Revert optimistic update
setPushEnabled(previousState);
// Re-check actual status to sync with reality
await checkPushStatus();
} finally {
setLoading(false);
}
}
function handleListItemClick(e: React.MouseEvent) {
if (checking || loading || !isSupported() || e.target === switchRef.current) return;
handlePushToggle(!pushEnabled);
}
return (
<>
<h3 className={styles.panelTitle}>Notifications</h3>
<MaterialList>
<MaterialListItem
className={styles.clickableItem}
headline="Push Notifications"
description="Receive notifications for new messages"
icon="notifications"
onClick={handleListItemClick}>
<MaterialSwitch
checked={pushEnabled}
disabled={!isSupported() || loading || checking}
onChange={(e) => handlePushToggle(e.target.checked)}
slot="end-icon"
ref={switchRef}
/>
</MaterialListItem>
</MaterialList>
</>
);
}
@@ -0,0 +1,25 @@
import { useState } from "react";
import { MaterialList, MaterialListItem } from "@/utils/material";
import ChangePasswordDialog from "./ChangePasswordDialog";
import styles from "@/pages/chat/css/settings-dialog.module.scss";
export function SecurityPanel() {
const [cpOpen, setCpOpen] = useState(false);
return (
<>
<h3 className={styles.panelTitle}>Security</h3>
<MaterialList>
<MaterialListItem
onClick={() => setCpOpen(true)}
className={styles.clickableItem}
headline="Change Password"
description="Change your account password"
icon="password"
/>
</MaterialList>
<ChangePasswordDialog isOpen={cpOpen} onOpenChange={setCpOpen} />
</>
);
}
@@ -1,182 +1,92 @@
import { useState, useEffect } from "react"; import { useState } from "react";
import { PRODUCT_NAME, API_BASE_URL } from "@/core/config"; import { motion, AnimatePresence } from "motion/react";
import type { DialogProps } from "@/core/types"; import type { DialogProps } from "@/core/types";
import { StyledDialog } from "@/core/components/StyledDialog"; import { StyledDialog } from "@/core/components/StyledDialog";
import { initialize, isSupported, startElectronReceiver, stopElectronReceiver, subscribe, unsubscribe } from "@/core/push-notifications/push-notifications"; import { NotificationsPanel } from "./NotificationsPanel";
import { isElectron } from "@/core/electron/electron"; import { DevicesPanel } from "./DevicesPanel";
import { useAppState } from "@/pages/chat/state"; import { SecurityPanel } from "./SecurityPanel";
import { getAuthHeaders } from "@/core/api/authApi"; import { AccountPanel } from "./AccountPanel";
import ChangePasswordDialog from "./ChangePasswordDialog"; import { MaterialList, MaterialListItem, MaterialIconButton } from "@/utils/material";
import { listDevices, revokeDevice, logoutAllOtherDevices, type DeviceInfo } from "@/core/api/devicesApi";
import { useImmer } from "use-immer";
import { MaterialButton, MaterialIconButton, MaterialList, MaterialListItem, MaterialSwitch } from "@/utils/material";
import styles from "@/pages/chat/css/settings-dialog.module.scss"; import styles from "@/pages/chat/css/settings-dialog.module.scss";
interface SettingsSection {
title: string;
icon: string;
component: React.ReactNode;
}
export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) { export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
const [activePanel, setActivePanel] = useState("notifications-settings"); const sections: SettingsSection[] = [
const [pushNotificationsEnabled, setPushNotificationsEnabled] = useState(false); {
const [pushSupported, setPushSupported] = useState(false); title: "Notifications",
const user = useAppState(state => state.user); icon: "notifications",
const logout = useAppState(state => state.logout); component: <NotificationsPanel />
const [devices, updateDevices] = useImmer<DeviceInfo[]>([]); },
const [cpOpen, setCpOpen] = useState(false); {
title: "Devices",
useEffect(() => { icon: "devices",
setPushSupported(isSupported()); component: <DevicesPanel />
// For Electron, we assume notifications are enabled if supported },
// For web browsers, we check if there's a subscription {
setPushNotificationsEnabled(isSupported()); title: "Security",
}, []); icon: "lock",
component: <SecurityPanel />
useEffect(() => { },
if (activePanel === "devices-settings" && user.authToken) { {
listDevices(user.authToken) title: "Account",
.then(list => updateDevices(() => list)) icon: "account_circle",
.catch(() => {}); component: <AccountPanel onClose={() => onOpenChange(false)} />
} }
}, [activePanel, user.authToken, updateDevices]); ];
const handlePanelChange = (panelId: string) => { const [activeSection, setActiveSection] = useState<number>(0);
setActivePanel(panelId);
};
const handlePushNotificationToggle = async (enabled: boolean) => {
if (!user.authToken) return;
try {
if (enabled) {
const initialized = await initialize();
if (initialized) {
await subscribe(user.authToken);
// For Electron, start the notification receiver
if (isElectron) {
await startElectronReceiver();
}
setPushNotificationsEnabled(true);
}
} else {
await unsubscribe();
// For Electron, stop the notification receiver
if (isElectron) {
stopElectronReceiver();
}
// Call API to unsubscribe on server (for web browsers)
await fetch(`${API_BASE_URL}/push/unsubscribe`, {
method: "DELETE",
headers: getAuthHeaders(user.authToken)
});
setPushNotificationsEnabled(false);
}
} catch (error) {
console.error("Failed to toggle notifications:", error);
}
};
return ( return (
<> <>
<StyledDialog open={isOpen} onOpenChange={onOpenChange} className={styles.settingsDialog}> <StyledDialog open={isOpen} onOpenChange={onOpenChange} className={styles.settingsDialog}>
<div className={styles.settingsDialogInner}> <div className={styles.settingsDialogInner}>
<div className={styles.header}> <div className={styles.settingsHeader}>
<MaterialIconButton icon="close" onClick={() => onOpenChange(false)}></MaterialIconButton> <MaterialIconButton icon="close" onClick={() => onOpenChange(false)} />
<div className={styles.title}>Настройки</div> <h2 className={styles.settingsTitle}>Settings</h2>
</div> </div>
<div className={styles.settingsMenu}>
<MaterialList>
<MaterialListItem
icon="notifications--filled"
rounded
active={activePanel === "notifications-settings"}
onClick={() => handlePanelChange("notifications-settings")}
style={{ cursor: "pointer" }}
>
Уведомления
</MaterialListItem>
<MaterialListItem
icon="security--filled"
rounded
active={activePanel === "security-settings"}
onClick={() => handlePanelChange("security-settings")}
style={{ cursor: "pointer" }}
>
Безопасность
</MaterialListItem>
<MaterialListItem
icon="devices--filled"
rounded
active={activePanel === "devices-settings"}
onClick={() => handlePanelChange("devices-settings")}
style={{ cursor: "pointer" }}
>
Устройства
</MaterialListItem>
<MaterialListItem
icon="info--filled"
rounded
active={activePanel === "about-settings"}
onClick={() => handlePanelChange("about-settings")}
style={{ cursor: "pointer" }}
>
О приложении
</MaterialListItem>
</MaterialList>
<div className={styles.screen}>
<div className={`${styles.settingsPanel} ${activePanel === "notifications-settings" ? styles.active : ""}`}>
<h3>Уведомления</h3>
{pushSupported && (
<MaterialSwitch
checked={pushNotificationsEnabled}
onInput={(e) => handlePushNotificationToggle(e.target.checked)}>
Push уведомления
</MaterialSwitch>
)}
</div>
<div className={`${styles.settingsPanel} ${activePanel === "security-settings" ? styles.active : ""}`}> <div className={styles.settingsLayout}>
<h3>Безопасность</h3> <div className={styles.sidebar}>
<MaterialButton variant="tonal" onClick={() => setCpOpen(true)}>Изменить пароль</MaterialButton> <MaterialList>
</div> {sections.map((section, index) => (
<MaterialListItem
key={index}
onClick={() => setActiveSection(index)}
active={activeSection === index}
rounded
headline={section.title}
icon={section.icon}
/>
))}
</MaterialList>
</div>
<div className={`${styles.settingsPanel} ${activePanel === "devices-settings" ? styles.active : ""}`}> <div className={styles.contentPanel}>
<h3>Устройства</h3> <AnimatePresence mode="wait">
<div style={{ display: "flex", gap: 12, marginBottom: 12 }}> {sections.map((section, index) => (
<MaterialButton variant="tonal" onClick={async () => { if (!user.authToken) return; await logoutAllOtherDevices(user.authToken); const list = await listDevices(user.authToken); updateDevices(() => list); }}>Выйти на всех остальных устройствах</MaterialButton> activeSection === index && (
<MaterialButton variant="outlined" onClick={async () => { if (!user.authToken) return; await fetch(`${API_BASE_URL}/logout`, { headers: getAuthHeaders(user.authToken) }); logout(); }}>Выйти на этом устройстве</MaterialButton> <motion.div
</div> key={index}
<MaterialList> className={styles.panelContent}
{devices.map((d) => ( initial={{ opacity: 0, y: 20 }}
<MaterialListItem animate={{ opacity: 1, y: 0 }}
key={d.session_id} exit={{ opacity: 0, y: -20 }}
icon={d.current ? "devices_other--filled" : "devices--filled"} transition={{ duration: 0.2, ease: "easeInOut" }}
rounded
end-icon={!d.current ? "logout--filled" : undefined}
onClick={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.device_name || (d.browser_name || "Браузер")} на {d.os_name || "OS"} {d.current ? " (это устройство)" : ""}</div> {section.component}
<div slot="description">Последняя активность: {d.last_seen || "—"}</div> </motion.div>
</MaterialListItem> )
))} ))}
</MaterialList> </AnimatePresence>
</div>
<div className={`${styles.settingsPanel} ${activePanel === "about-settings" ? styles.active : ""}`}>
<h3>О приложении</h3>
<p>100% open source. Репозиторий на <a href="https://github.com/Toolbox-io/FromChat" target="_blank" rel="noreferrer">GitHub</a>.</p>
<p><span className={styles.productName}>{PRODUCT_NAME}</span></p>
</div>
</div> </div>
</div> </div>
</div> </div>
</StyledDialog> </StyledDialog>
<ChangePasswordDialog isOpen={cpOpen} onOpenChange={setCpOpen} />
</> </>
); );
} }