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
@@ -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 { 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<DeviceInfo[]>([]);
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: <NotificationsPanel />
},
{
title: "Devices",
icon: "devices",
component: <DevicesPanel />
},
{
title: "Security",
icon: "lock",
component: <SecurityPanel />
},
{
title: "Account",
icon: "account_circle",
component: <AccountPanel onClose={() => 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<number>(0);
return (
<>
<StyledDialog open={isOpen} onOpenChange={onOpenChange} className={styles.settingsDialog}>
<div className={styles.settingsDialogInner}>
<div className={styles.header}>
<MaterialIconButton icon="close" onClick={() => onOpenChange(false)}></MaterialIconButton>
<div className={styles.title}>Настройки</div>
<div className={styles.settingsHeader}>
<MaterialIconButton icon="close" onClick={() => onOpenChange(false)} />
<h2 className={styles.settingsTitle}>Settings</h2>
</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 : ""}`}>
<h3>Безопасность</h3>
<MaterialButton variant="tonal" onClick={() => setCpOpen(true)}>Изменить пароль</MaterialButton>
</div>
<div className={styles.settingsLayout}>
<div className={styles.sidebar}>
<MaterialList>
{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 : ""}`}>
<h3>Устройства</h3>
<div style={{ display: "flex", gap: 12, marginBottom: 12 }}>
<MaterialButton variant="tonal" onClick={async () => { if (!user.authToken) return; await logoutAllOtherDevices(user.authToken); const list = await listDevices(user.authToken); updateDevices(() => list); }}>Выйти на всех остальных устройствах</MaterialButton>
<MaterialButton variant="outlined" onClick={async () => { if (!user.authToken) return; await fetch(`${API_BASE_URL}/logout`, { headers: getAuthHeaders(user.authToken) }); logout(); }}>Выйти на этом устройстве</MaterialButton>
</div>
<MaterialList>
{devices.map((d) => (
<MaterialListItem
key={d.session_id}
icon={d.current ? "devices_other--filled" : "devices--filled"}
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 className={styles.contentPanel}>
<AnimatePresence mode="wait">
{sections.map((section, index) => (
activeSection === index && (
<motion.div
key={index}
className={styles.panelContent}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
>
<div slot="headline">{d.device_name || (d.browser_name || "Браузер")} на {d.os_name || "OS"} {d.current ? " (это устройство)" : ""}</div>
<div slot="description">Последняя активность: {d.last_seen || "—"}</div>
</MaterialListItem>
))}
</MaterialList>
</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>
{section.component}
</motion.div>
)
))}
</AnimatePresence>
</div>
</div>
</div>
</StyledDialog>
<ChangePasswordDialog isOpen={cpOpen} onOpenChange={setCpOpen} />
</>
);
}
}