feat(settings): harden privacy and sessions actions UX
Some checks are pending
CI / test (push) Has started running

This commit is contained in:
2026-03-08 20:33:16 +03:00
parent 4122882b7e
commit 586d3acc16
2 changed files with 39 additions and 11 deletions

View File

@@ -38,7 +38,7 @@ Legend:
29. Archive - `DONE` 29. Archive - `DONE`
30. Blacklist - `DONE` 30. Blacklist - `DONE`
31. Privacy - `PARTIAL` (avatar/last-seen/group-invites + PM policy `everyone|contacts|nobody`; group-invite `nobody` is available in API and web settings; integration tests cover PM policy matrix (`everyone/contacts/nobody`), group-invite policy matrix (`everyone/contacts/nobody`), and private chat counterpart visibility for `nobody/contacts`, remaining UX/matrix hardening) 31. Privacy - `PARTIAL` (avatar/last-seen/group-invites + PM policy `everyone|contacts|nobody`; group-invite `nobody` is available in API and web settings; integration tests cover PM policy matrix (`everyone/contacts/nobody`), group-invite policy matrix (`everyone/contacts/nobody`), and private chat counterpart visibility for `nobody/contacts`, remaining UX/matrix hardening)
32. Security - `PARTIAL` (sessions + revoke + 2FA base + access-session visibility; integration tests cover single-session revoke and revoke-all invalidation/force-disconnect; 2FA setup now blocked after enable to prevent secret re-issuance; one-time recovery codes added; UX polish ongoing) 32. Security - `PARTIAL` (sessions + revoke + 2FA base + access-session visibility; integration tests cover single-session revoke and revoke-all invalidation/force-disconnect; 2FA setup now blocked after enable to prevent secret re-issuance; one-time recovery codes added; web settings now has safer revoke UX with confirmation/loading/error feedback)
33. Realtime Events - `DONE` (connect/disconnect/send/receive/typing/read/delivered/online/offline + chat/message updates + chat_deleted) 33. Realtime Events - `DONE` (connect/disconnect/send/receive/typing/read/delivered/online/offline + chat/message updates + chat_deleted)
34. Sync - `PARTIAL` (cross-device via backend state + realtime; reconciliation improved for loaded chats/messages, chat-info panel hot-refreshes on `chat_updated`, delete/leave updates realtime subscriptions, full-chat delete emits `chat_deleted`) 34. Sync - `PARTIAL` (cross-device via backend state + realtime; reconciliation improved for loaded chats/messages, chat-info panel hot-refreshes on `chat_updated`, delete/leave updates realtime subscriptions, full-chat delete emits `chat_deleted`)
35. Additional - `PARTIAL` (drafts/link preview partial/autoload media basic) 35. Additional - `PARTIAL` (drafts/link preview partial/autoload media basic)

View File

@@ -38,6 +38,8 @@ export function SettingsPanel({ open, onClose }: Props) {
const [privacyPrivateMessages, setPrivacyPrivateMessages] = useState<"everyone" | "contacts" | "nobody">("everyone"); const [privacyPrivateMessages, setPrivacyPrivateMessages] = useState<"everyone" | "contacts" | "nobody">("everyone");
const [sessions, setSessions] = useState<AuthSession[]>([]); const [sessions, setSessions] = useState<AuthSession[]>([]);
const [sessionsLoading, setSessionsLoading] = useState(false); const [sessionsLoading, setSessionsLoading] = useState(false);
const [revokeAllLoading, setRevokeAllLoading] = useState(false);
const [revokingSessionIds, setRevokingSessionIds] = useState<string[]>([]);
const [twofaCode, setTwofaCode] = useState(""); const [twofaCode, setTwofaCode] = useState("");
const [twofaSecret, setTwofaSecret] = useState<string | null>(null); const [twofaSecret, setTwofaSecret] = useState<string | null>(null);
const [twofaUrl, setTwofaUrl] = useState<string | null>(null); const [twofaUrl, setTwofaUrl] = useState<string | null>(null);
@@ -521,6 +523,9 @@ export function SettingsPanel({ open, onClose }: Props) {
privacy_group_invites: privacyGroupInvites, privacy_group_invites: privacyGroupInvites,
}); });
useAuthStore.setState({ me: updated as AuthUser }); useAuthStore.setState({ me: updated as AuthUser });
showToast("Privacy settings saved");
} catch {
showToast("Failed to save privacy settings");
} finally { } finally {
setSavingPrivacy(false); setSavingPrivacy(false);
} }
@@ -665,16 +670,30 @@ export function SettingsPanel({ open, onClose }: Props) {
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<p className="text-xs uppercase tracking-wide text-slate-400">Active Sessions</p> <p className="text-xs uppercase tracking-wide text-slate-400">Active Sessions</p>
<button <button
className="rounded bg-slate-700 px-2 py-1 text-xs" className="rounded bg-slate-700 px-2 py-1 text-xs disabled:opacity-60"
onClick={async () => { onClick={async () => {
await revokeAllSessions(); if (revokeAllLoading) {
setSessions([]); return;
logout(); }
onClose(); if (!window.confirm("Revoke all sessions and log out on this device?")) {
return;
}
setRevokeAllLoading(true);
try {
await revokeAllSessions();
setSessions([]);
logout();
onClose();
} catch {
showToast("Failed to revoke sessions");
} finally {
setRevokeAllLoading(false);
}
}} }}
disabled={revokeAllLoading}
type="button" type="button"
> >
Revoke all {revokeAllLoading ? "Revoking..." : "Revoke all"}
</button> </button>
</div> </div>
{sessionsLoading ? <p className="text-xs text-slate-400">Loading sessions...</p> : null} {sessionsLoading ? <p className="text-xs text-slate-400">Loading sessions...</p> : null}
@@ -691,14 +710,23 @@ export function SettingsPanel({ open, onClose }: Props) {
<p className="text-[11px] text-slate-500">{new Date(session.created_at).toLocaleString()}</p> <p className="text-[11px] text-slate-500">{new Date(session.created_at).toLocaleString()}</p>
{session.token_type === "refresh" ? ( {session.token_type === "refresh" ? (
<button <button
className="mt-1 rounded bg-slate-700 px-2 py-1 text-[11px]" className="mt-1 rounded bg-slate-700 px-2 py-1 text-[11px] disabled:opacity-60"
onClick={async () => { onClick={async () => {
await revokeSession(session.jti); const token = session.jti;
setSessions((prev) => prev.filter((item) => item.jti !== session.jti)); setRevokingSessionIds((prev) => (prev.includes(token) ? prev : [...prev, token]));
try {
await revokeSession(session.jti);
setSessions((prev) => prev.filter((item) => item.jti !== session.jti));
} catch {
showToast("Failed to revoke session");
} finally {
setRevokingSessionIds((prev) => prev.filter((item) => item !== token));
}
}} }}
disabled={revokingSessionIds.includes(session.jti)}
type="button" type="button"
> >
Revoke {revokingSessionIds.includes(session.jti) ? "Revoking..." : "Revoke"}
</button> </button>
) : ( ) : (
<p className="mt-1 text-[11px] text-slate-500">Use Revoke all to invalidate active access token.</p> <p className="mt-1 text-[11px] text-slate-500">Use Revoke all to invalidate active access token.</p>