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`
|
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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user