feat(settings): harden privacy and sessions actions UX
Some checks are pending
CI / test (push) Has started running
Some checks are pending
CI / test (push) Has started running
This commit is contained in:
@@ -38,7 +38,7 @@ Legend:
|
||||
29. Archive - `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)
|
||||
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)
|
||||
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)
|
||||
|
||||
@@ -38,6 +38,8 @@ export function SettingsPanel({ open, onClose }: Props) {
|
||||
const [privacyPrivateMessages, setPrivacyPrivateMessages] = useState<"everyone" | "contacts" | "nobody">("everyone");
|
||||
const [sessions, setSessions] = useState<AuthSession[]>([]);
|
||||
const [sessionsLoading, setSessionsLoading] = useState(false);
|
||||
const [revokeAllLoading, setRevokeAllLoading] = useState(false);
|
||||
const [revokingSessionIds, setRevokingSessionIds] = useState<string[]>([]);
|
||||
const [twofaCode, setTwofaCode] = useState("");
|
||||
const [twofaSecret, setTwofaSecret] = 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,
|
||||
});
|
||||
useAuthStore.setState({ me: updated as AuthUser });
|
||||
showToast("Privacy settings saved");
|
||||
} catch {
|
||||
showToast("Failed to save privacy settings");
|
||||
} finally {
|
||||
setSavingPrivacy(false);
|
||||
}
|
||||
@@ -665,16 +670,30 @@ export function SettingsPanel({ open, onClose }: Props) {
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-400">Active Sessions</p>
|
||||
<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 () => {
|
||||
await revokeAllSessions();
|
||||
setSessions([]);
|
||||
logout();
|
||||
onClose();
|
||||
if (revokeAllLoading) {
|
||||
return;
|
||||
}
|
||||
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"
|
||||
>
|
||||
Revoke all
|
||||
{revokeAllLoading ? "Revoking..." : "Revoke all"}
|
||||
</button>
|
||||
</div>
|
||||
{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>
|
||||
{session.token_type === "refresh" ? (
|
||||
<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 () => {
|
||||
await revokeSession(session.jti);
|
||||
setSessions((prev) => prev.filter((item) => item.jti !== session.jti));
|
||||
const token = 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"
|
||||
>
|
||||
Revoke
|
||||
{revokingSessionIds.includes(session.jti) ? "Revoking..." : "Revoke"}
|
||||
</button>
|
||||
) : (
|
||||
<p className="mt-1 text-[11px] text-slate-500">Use Revoke all to invalidate active access token.</p>
|
||||
|
||||
Reference in New Issue
Block a user