mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
Redesign settings from scratch
This commit is contained in:
@@ -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()
|
||||||
|
|
||||||
@@ -350,3 +352,85 @@ 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"
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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} />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user