From dca0d319e9213f3c5b355bb4f701de17fe2aeb4d Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Mon, 3 Nov 2025 11:21:08 +0300 Subject: [PATCH] Redesign settings from scratch --- backend/routes/account.py | 84 +++++++ backend/routes/profile.py | 55 +---- frontend/src/core/api/securityApi.ts | 11 + .../push-notifications/push-notifications.ts | 15 ++ .../chat/css/settings-dialog.module.scss | 119 ++++----- .../chat/ui/left/settings/AccountPanel.tsx | 59 +++++ .../chat/ui/left/settings/DevicesPanel.tsx | 151 ++++++++++++ .../ui/left/settings/NotificationsPanel.tsx | 128 ++++++++++ .../chat/ui/left/settings/SecurityPanel.tsx | 25 ++ .../chat/ui/left/settings/SettingsDialog.tsx | 232 ++++++------------ 10 files changed, 606 insertions(+), 273 deletions(-) create mode 100644 frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx create mode 100644 frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx create mode 100644 frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx create mode 100644 frontend/src/pages/chat/ui/left/settings/SecurityPanel.tsx diff --git a/backend/routes/account.py b/backend/routes/account.py index d73ecf3..ff8cc26 100644 --- a/backend/routes/account.py +++ b/backend/routes/account.py @@ -1,6 +1,7 @@ from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, status, Request from sqlalchemy.orm import Session +from sqlalchemy import inspect, text import uuid from user_agents import parse as parse_ua 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 utils import create_token, get_password_hash, verify_password from validation import is_valid_password, is_valid_username, is_valid_display_name +import os router = APIRouter() @@ -349,4 +351,86 @@ def search_users(q: str, current_user: User = Depends(get_current_user), db: Ses return { "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" } \ No newline at end of file diff --git a/backend/routes/profile.py b/backend/routes/profile.py index 0fcd4ac..ccba31b 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -426,59 +426,8 @@ async def delete_user( if target_user.id == 1: raise HTTPException(status_code=400, detail="Cannot delete admin account") - # Mark user as deleted and clear sensitive data - target_user.deleted = True - 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 + from .account import _delete_user_data + await _delete_user_data(target_user, db) return { "status": "success", diff --git a/frontend/src/core/api/securityApi.ts b/frontend/src/core/api/securityApi.ts index 9244c83..a8488b8 100644 --- a/frontend/src/core/api/securityApi.ts +++ b/frontend/src/core/api/securityApi.ts @@ -22,4 +22,15 @@ export async function changePassword( if (!res.ok) throw new Error("Failed to change password"); } +export async function deleteAccount(token: string): Promise { + 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"); + } +} + diff --git a/frontend/src/core/push-notifications/push-notifications.ts b/frontend/src/core/push-notifications/push-notifications.ts index 435ee27..bef62c9 100644 --- a/frontend/src/core/push-notifications/push-notifications.ts +++ b/frontend/src/core/push-notifications/push-notifications.ts @@ -183,6 +183,21 @@ export async function subscribe(token: string): Promise { 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); } diff --git a/frontend/src/pages/chat/css/settings-dialog.module.scss b/frontend/src/pages/chat/css/settings-dialog.module.scss index 5822854..9663e57 100644 --- a/frontend/src/pages/chat/css/settings-dialog.module.scss +++ b/frontend/src/pages/chat/css/settings-dialog.module.scss @@ -2,7 +2,6 @@ @use "../../../css/material" as *; @use "sass:color"; -// Settings styles - override StyledDialog's backdrop and dialog .settingsDialog { width: calc(100vw - 60px) !important; height: calc(100vh - 60px) !important; @@ -15,97 +14,99 @@ display: flex; flex-direction: column; padding: 24px; + overflow-y: auto; - .header { + .settingsHeader { display: flex; - flex-direction: row; - gap: 10px; + align-items: center; + gap: 8px; margin-bottom: 16px; - flex-shrink: 0; - .title { - display: block; + .settingsTitle { + margin: 0; + font-size: 22px; + font-weight: 500; + color: $color-dark-on-surface; + flex: 1; } } - .settingsMenu { + .settingsLayout { display: flex; - flex-direction: row; - gap: 16px; flex: 1; - min-height: 0; + overflow: hidden; + gap: 1px; - :global(mdui-list) { - max-width: 280px; - padding-right: 16px; + .sidebar { + width: 240px; overflow-y: auto; - flex-shrink: 0; } - .screen { + .contentPanel { flex: 1; - overflow-y: auto; + overflow: hidden; + display: flex; + flex-direction: column; position: relative; - min-width: 0; - .settingsPanel { + .panelContent { + padding: 24px; display: flex; flex-direction: column; gap: 16px; - opacity: 0; - visibility: hidden; - transform: translateY(20px); - transition: opacity 0.3s ease, transform 0.3s ease, visibility 0.3s ease; + flex: 1; position: absolute; top: 0; left: 0; - width: 100%; + right: 0; + bottom: 0; + overflow-y: auto; - &.active { - opacity: 1; - visibility: visible; - transform: translateY(0); - position: relative; - } - - :global(h3) { - margin: 0 0 16px 0; + .panelTitle { + margin: 0; + font-size: 20px; + font-weight: 500; color: $color-dark-on-surface; + padding-bottom: 16px; + border-bottom: 1px solid $color-dark-outline-variant; } - :global(mdui-text-field), - :global(mdui-select), - :global(mdui-switch), - :global(mdui-button) { - margin-bottom: 8px; - } - - :global(mdui-switch) { + .loadingContainer { display: flex; + justify-content: center; align-items: center; - justify-content: space-between; - padding: 12px 0; - border-bottom: 1px solid $color-dark-outline; - - &:last-child { - border-bottom: none; - } + padding: 32px; } - :global(p) { - margin: 8px 0; - color: $color-dark-on-surface-variant; - } - - :global(mdui-linear-progress) { - margin: 16px 0; - } - - .productName { - display: inline; + .sectionActions { + display: flex; + justify-content: flex-end; + padding: 8px 0; } } } } } } + +.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; + } +} \ No newline at end of file diff --git a/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx b/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx new file mode 100644 index 0000000..16e39a9 --- /dev/null +++ b/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx @@ -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 ( + <> +

Account

+ + + + + + ); +} + diff --git a/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx b/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx new file mode 100644 index 0000000..ac9dbc7 --- /dev/null +++ b/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx @@ -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([]); + const [devicesLoading, setDevicesLoading] = useState(false); + const [revokingDevices, setRevokingDevices] = useImmer>(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 ( + <> +

Devices

+
+ +
+ + ); + } + + return ( + <> +

Devices

+ + {devices.map((device) => ( + handleRevokeDevice(device.session_id)} + disabled={revokingDevices.has(device.session_id)} + /> + ))} + + {devices.filter(d => !d.current).length > 0 && ( +
+ + Logout All Other Devices + +
+ )} + + ); +} + diff --git a/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx b/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx new file mode 100644 index 0000000..d5f0931 --- /dev/null +++ b/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx @@ -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(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 ( + <> +

Notifications

+ + + handlePushToggle(e.target.checked)} + slot="end-icon" + ref={switchRef} + /> + + + + ); +} + diff --git a/frontend/src/pages/chat/ui/left/settings/SecurityPanel.tsx b/frontend/src/pages/chat/ui/left/settings/SecurityPanel.tsx new file mode 100644 index 0000000..837fbb8 --- /dev/null +++ b/frontend/src/pages/chat/ui/left/settings/SecurityPanel.tsx @@ -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 ( + <> +

Security

+ + setCpOpen(true)} + className={styles.clickableItem} + headline="Change Password" + description="Change your account password" + icon="password" + /> + + + + ); +} + diff --git a/frontend/src/pages/chat/ui/left/settings/SettingsDialog.tsx b/frontend/src/pages/chat/ui/left/settings/SettingsDialog.tsx index 091a787..ee02723 100644 --- a/frontend/src/pages/chat/ui/left/settings/SettingsDialog.tsx +++ b/frontend/src/pages/chat/ui/left/settings/SettingsDialog.tsx @@ -1,182 +1,92 @@ -import { useState, useEffect } from "react"; -import { PRODUCT_NAME, API_BASE_URL } from "@/core/config"; +import { useState } from "react"; +import { motion, AnimatePresence } from "motion/react"; import type { DialogProps } from "@/core/types"; import { StyledDialog } from "@/core/components/StyledDialog"; -import { initialize, isSupported, startElectronReceiver, stopElectronReceiver, subscribe, unsubscribe } from "@/core/push-notifications/push-notifications"; -import { isElectron } from "@/core/electron/electron"; -import { useAppState } from "@/pages/chat/state"; -import { getAuthHeaders } from "@/core/api/authApi"; -import ChangePasswordDialog from "./ChangePasswordDialog"; -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 { NotificationsPanel } from "./NotificationsPanel"; +import { DevicesPanel } from "./DevicesPanel"; +import { SecurityPanel } from "./SecurityPanel"; +import { AccountPanel } from "./AccountPanel"; +import { MaterialList, MaterialListItem, MaterialIconButton } from "@/utils/material"; 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) { - const [activePanel, setActivePanel] = useState("notifications-settings"); - const [pushNotificationsEnabled, setPushNotificationsEnabled] = useState(false); - const [pushSupported, setPushSupported] = useState(false); - const user = useAppState(state => state.user); - const logout = useAppState(state => state.logout); - const [devices, updateDevices] = useImmer([]); - const [cpOpen, setCpOpen] = useState(false); - - useEffect(() => { - setPushSupported(isSupported()); - // For Electron, we assume notifications are enabled if supported - // For web browsers, we check if there's a subscription - setPushNotificationsEnabled(isSupported()); - }, []); - - useEffect(() => { - if (activePanel === "devices-settings" && user.authToken) { - listDevices(user.authToken) - .then(list => updateDevices(() => list)) - .catch(() => {}); + const sections: SettingsSection[] = [ + { + title: "Notifications", + icon: "notifications", + component: + }, + { + title: "Devices", + icon: "devices", + component: + }, + { + title: "Security", + icon: "lock", + component: + }, + { + title: "Account", + icon: "account_circle", + component: onOpenChange(false)} /> } - }, [activePanel, user.authToken, updateDevices]); - - const handlePanelChange = (panelId: string) => { - 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); - } - }; + ]; + + const [activeSection, setActiveSection] = useState(0); return ( <>
-
- onOpenChange(false)}> -
Настройки
+
+ onOpenChange(false)} /> +

Settings

-
- - handlePanelChange("notifications-settings")} - style={{ cursor: "pointer" }} - > - Уведомления - - handlePanelChange("security-settings")} - style={{ cursor: "pointer" }} - > - Безопасность - - handlePanelChange("devices-settings")} - style={{ cursor: "pointer" }} - > - Устройства - - handlePanelChange("about-settings")} - style={{ cursor: "pointer" }} - > - О приложении - - -
-
-

Уведомления

- {pushSupported && ( - handlePushNotificationToggle(e.target.checked)}> - Push уведомления - - )} -
-
-

Безопасность

- setCpOpen(true)}>Изменить пароль -
+
+
+ + {sections.map((section, index) => ( + setActiveSection(index)} + active={activeSection === index} + rounded + headline={section.title} + icon={section.icon} + /> + ))} + +
-
-

Устройства

-
- { if (!user.authToken) return; await logoutAllOtherDevices(user.authToken); const list = await listDevices(user.authToken); updateDevices(() => list); }}>Выйти на всех остальных устройствах - { if (!user.authToken) return; await fetch(`${API_BASE_URL}/logout`, { headers: getAuthHeaders(user.authToken) }); logout(); }}>Выйти на этом устройстве -
- - {devices.map((d) => ( - { - if (!user.authToken || d.current) return; - await revokeDevice(user.authToken, d.session_id); - const list = await listDevices(user.authToken); - updateDevices(() => list); - }} +
+ + {sections.map((section, index) => ( + activeSection === index && ( + -
{d.device_name || (d.browser_name || "Браузер")} на {d.os_name || "OS"} {d.current ? " (это устройство)" : ""}
-
Последняя активность: {d.last_seen || "—"}
- - ))} - -
- -
-

О приложении

-

100% open source. Репозиторий на GitHub.

-

{PRODUCT_NAME}

-
+ {section.component} + + ) + ))} +
- ); -} +} \ No newline at end of file