Files
Messenger/web/src/components/SettingsPanel.tsx
2026-03-08 22:51:39 +03:00

872 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import QRCode from "qrcode";
import { listBlockedUsers, updateMyProfile } from "../api/users";
import { requestUploadUrl, uploadToPresignedUrl } from "../api/chats";
import {
disableTwoFactor,
enableTwoFactor,
getTwoFactorRecoveryStatus,
listSessions,
regenerateTwoFactorRecoveryCodes,
revokeAllSessions,
revokeSession,
setupTwoFactor
} from "../api/auth";
import { getNotifications, type NotificationItem } from "../api/notifications";
import type { AuthSession, AuthUser } from "../chat/types";
import { useAuthStore } from "../store/authStore";
import { useUiStore } from "../store/uiStore";
import { AppPreferences, getAppPreferences, updateAppPreferences } from "../utils/preferences";
import { AvatarCropModal } from "./AvatarCropModal";
type SettingsPage = "main" | "general" | "notifications" | "privacy";
interface Props {
open: boolean;
onClose: () => void;
}
export function SettingsPanel({ open, onClose }: Props) {
const me = useAuthStore((s) => s.me);
const logout = useAuthStore((s) => s.logout);
const showToast = useUiStore((s) => s.showToast);
const [page, setPage] = useState<SettingsPage>("main");
const [prefs, setPrefs] = useState<AppPreferences>(() => getAppPreferences());
const [blockedCount, setBlockedCount] = useState(0);
const [savingPrivacy, setSavingPrivacy] = useState(false);
const [privacyPrivateMessages, setPrivacyPrivateMessages] = useState<"everyone" | "contacts" | "nobody">("everyone");
const [sessions, setSessions] = useState<AuthSession[]>([]);
const [sessionsLoading, setSessionsLoading] = useState(false);
const [sessionsError, setSessionsError] = useState<string | null>(null);
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);
const [twofaQrUrl, setTwofaQrUrl] = useState<string | null>(null);
const [twofaError, setTwofaError] = useState<string | null>(null);
const [recoveryRemaining, setRecoveryRemaining] = useState<number>(0);
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([]);
const [privacyLastSeen, setPrivacyLastSeen] = useState<"everyone" | "contacts" | "nobody">("everyone");
const [privacyAvatar, setPrivacyAvatar] = useState<"everyone" | "contacts" | "nobody">("everyone");
const [privacyGroupInvites, setPrivacyGroupInvites] = useState<"everyone" | "contacts" | "nobody">("everyone");
const [notificationItems, setNotificationItems] = useState<NotificationItem[]>([]);
const [notificationItemsLoading, setNotificationItemsLoading] = useState(false);
const [profileDraft, setProfileDraft] = useState({
name: "",
username: "",
bio: "",
avatarUrl: "",
});
const [profileAvatarUploading, setProfileAvatarUploading] = useState(false);
const [avatarCropFile, setAvatarCropFile] = useState<File | null>(null);
useEffect(() => {
if (!me) {
return;
}
setPrivacyPrivateMessages(me.privacy_private_messages || (me.allow_private_messages ? "everyone" : "nobody"));
setPrivacyLastSeen(me.privacy_last_seen || "everyone");
setPrivacyAvatar(me.privacy_avatar || "everyone");
setPrivacyGroupInvites(me.privacy_group_invites || "everyone");
setProfileDraft({
name: me.name || "",
username: me.username || "",
bio: me.bio || "",
avatarUrl: me.avatar_url || "",
});
}, [me]);
useEffect(() => {
if (!twofaUrl) {
setTwofaQrUrl(null);
return;
}
let cancelled = false;
void (async () => {
try {
const url = await QRCode.toDataURL(twofaUrl, { margin: 1, width: 192 });
if (!cancelled) {
setTwofaQrUrl(url);
}
} catch {
if (!cancelled) {
setTwofaQrUrl(null);
}
}
})();
return () => {
cancelled = true;
};
}, [twofaUrl]);
useEffect(() => {
if (!open) {
return;
}
setPrefs(getAppPreferences());
}, [open]);
useEffect(() => {
if (!open || page !== "privacy") {
return;
}
let cancelled = false;
void (async () => {
try {
const blocked = await listBlockedUsers();
if (!cancelled) {
setBlockedCount(blocked.length);
}
} catch {
if (!cancelled) {
setBlockedCount(0);
}
}
})();
return () => {
cancelled = true;
};
}, [open, page]);
useEffect(() => {
if (!open || page !== "privacy" || !me?.twofa_enabled) {
setRecoveryRemaining(0);
return;
}
let cancelled = false;
void (async () => {
try {
const status = await getTwoFactorRecoveryStatus();
if (!cancelled) {
setRecoveryRemaining(status.remaining_codes);
}
} catch {
if (!cancelled) {
setRecoveryRemaining(0);
}
}
})();
return () => {
cancelled = true;
};
}, [open, page, me?.twofa_enabled]);
useEffect(() => {
if (!open || page !== "notifications") {
return;
}
let cancelled = false;
setNotificationItemsLoading(true);
void (async () => {
try {
const rows = await getNotifications(30);
if (!cancelled) {
setNotificationItems(rows);
}
} catch {
if (!cancelled) {
setNotificationItems([]);
}
} finally {
if (!cancelled) {
setNotificationItemsLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [open, page]);
useEffect(() => {
if (!open || page !== "privacy") {
return;
}
let cancelled = false;
setSessionsLoading(true);
setSessionsError(null);
void (async () => {
try {
const rows = await listSessions();
if (!cancelled) {
setSessions(rows);
}
} catch {
if (!cancelled) {
setSessions([]);
setSessionsError("Failed to load sessions");
}
} finally {
if (!cancelled) {
setSessionsLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [open, page]);
const title = useMemo(() => {
if (page === "general") return "General";
if (page === "notifications") return "Notifications";
if (page === "privacy") return "Privacy and Security";
return "Settings";
}, [page]);
if (!open || !me) {
return null;
}
function updatePrefs(patch: Partial<AppPreferences>) {
setPrefs(updateAppPreferences(patch));
}
const notificationStatus = "Notification" in window ? Notification.permission : "denied";
async function uploadAvatar(file: File) {
setProfileAvatarUploading(true);
try {
const upload = await requestUploadUrl(file);
await uploadToPresignedUrl(upload.upload_url, upload.required_headers, file);
setProfileDraft((prev) => ({ ...prev, avatarUrl: upload.file_url }));
showToast("Avatar uploaded");
} catch {
showToast("Avatar upload failed");
} finally {
setProfileAvatarUploading(false);
}
}
async function copyRecoveryCodes(codes: string[]) {
if (!codes.length) {
showToast("No recovery codes to copy");
return;
}
try {
await navigator.clipboard.writeText(codes.join("\n"));
showToast("Recovery codes copied");
} catch {
showToast("Failed to copy recovery codes");
}
}
function downloadRecoveryCodes(codes: string[]) {
if (!codes.length) {
showToast("No recovery codes to download");
return;
}
const content = `BenyaMessenger 2FA Recovery Codes\n\n${codes.join("\n")}\n`;
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "benya-recovery-codes.txt";
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
showToast("Recovery codes downloaded");
}
return createPortal(
<div className="fixed inset-0 z-[150] bg-slate-950/60" onClick={onClose}>
<aside
className="absolute left-0 top-0 flex h-full w-full max-w-md flex-col border-r border-slate-700/70 bg-slate-900 shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-slate-700/60 px-4 py-3">
<div className="flex items-center gap-2">
{page !== "main" ? (
<button className="rounded bg-slate-700 px-2 py-1 text-xs" onClick={() => setPage("main")} type="button">
Back
</button>
) : null}
<p className="text-lg font-semibold">{title}</p>
</div>
<button className="rounded bg-slate-700 px-2 py-1 text-xs" onClick={onClose} type="button">
Close
</button>
</div>
<div className="tg-scrollbar min-h-0 flex-1 overflow-y-auto">
{page === "main" ? (
<>
<section className="border-b border-slate-700/60 px-4 py-4">
<p className="mb-2 text-xs uppercase tracking-wide text-slate-400">Profile</p>
<div className="space-y-2">
<div className="flex items-center gap-3 rounded border border-slate-700/70 bg-slate-800/40 p-2">
{profileDraft.avatarUrl.trim() ? (
<img alt="avatar preview" className="h-12 w-12 rounded-full object-cover" src={profileDraft.avatarUrl.trim()} />
) : (
<div className="flex h-12 w-12 flex-col items-center justify-center rounded-full bg-slate-700 text-[10px] leading-[1.05] text-slate-300">
<span>No</span>
<span>avatar</span>
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<label className="cursor-pointer rounded bg-sky-500 px-3 py-1.5 text-xs font-semibold text-slate-950">
{profileAvatarUploading ? "Uploading..." : "Upload avatar"}
<input
accept="image/*"
className="hidden"
disabled={profileAvatarUploading}
onChange={(e) => {
const file = e.target.files?.[0];
e.currentTarget.value = "";
if (!file) {
return;
}
setAvatarCropFile(file);
}}
type="file"
/>
</label>
{profileDraft.avatarUrl.trim() ? (
<button
className="rounded bg-slate-700 px-3 py-1.5 text-xs"
onClick={() => setProfileDraft((prev) => ({ ...prev, avatarUrl: "" }))}
type="button"
>
Remove
</button>
) : null}
</div>
</div>
<input
className="w-full rounded bg-slate-800 px-3 py-2 text-sm"
placeholder="Name"
value={profileDraft.name}
onChange={(e) => setProfileDraft((prev) => ({ ...prev, name: e.target.value }))}
/>
<input
className="w-full rounded bg-slate-800 px-3 py-2 text-sm"
placeholder="Username"
value={profileDraft.username}
onChange={(e) => setProfileDraft((prev) => ({ ...prev, username: e.target.value.replace("@", "") }))}
/>
<input
className="w-full rounded bg-slate-800 px-3 py-2 text-sm"
placeholder="Bio"
value={profileDraft.bio}
onChange={(e) => setProfileDraft((prev) => ({ ...prev, bio: e.target.value }))}
/>
<input
className="w-full rounded bg-slate-800 px-3 py-2 text-sm"
placeholder="Avatar URL (optional)"
value={profileDraft.avatarUrl}
onChange={(e) => setProfileDraft((prev) => ({ ...prev, avatarUrl: e.target.value }))}
/>
<button
className="w-full rounded bg-sky-500 px-3 py-2 text-sm font-semibold text-slate-950 disabled:opacity-60"
disabled={profileAvatarUploading}
onClick={async () => {
const updated = await updateMyProfile({
name: profileDraft.name.trim() || undefined,
username: profileDraft.username.trim() || undefined,
bio: profileDraft.bio.trim() || null,
avatar_url: profileDraft.avatarUrl.trim() || null,
});
useAuthStore.setState({ me: updated as AuthUser });
}}
type="button"
>
Save profile
</button>
</div>
</section>
<section className="border-b border-slate-700/60 py-1">
<SettingsRow label="General Settings" value={`${prefs.messageFontSize}px, ${prefs.theme}`} onClick={() => setPage("general")} />
<SettingsRow
label="Notifications"
value={prefs.webNotifications ? "Enabled" : "Disabled"}
onClick={() => setPage("notifications")}
/>
<SettingsRow
label="Privacy and Security"
value={
privacyPrivateMessages === "everyone"
? "Everybody can message"
: privacyPrivateMessages === "contacts"
? "Only contacts can message"
: "Messages disabled"
}
onClick={() => setPage("privacy")}
/>
</section>
</>
) : null}
{page === "general" ? (
<div className="space-y-4 px-4 py-3">
<section className="rounded border border-slate-700/70 bg-slate-800/50 p-3">
<p className="mb-2 text-xs uppercase tracking-wide text-slate-400">Settings</p>
<p className="mb-2 text-sm">Message Font Size</p>
<div className="flex items-center gap-3">
<input
className="w-full"
min={12}
max={24}
step={1}
type="range"
value={prefs.messageFontSize}
onChange={(e) => updatePrefs({ messageFontSize: Number(e.target.value) })}
/>
<span className="text-sm">{prefs.messageFontSize}</span>
</div>
</section>
<section className="rounded border border-slate-700/70 bg-slate-800/50 p-3">
<p className="mb-2 text-xs uppercase tracking-wide text-slate-400">Theme</p>
<RadioOption checked={prefs.theme === "light"} label="Light" onChange={() => updatePrefs({ theme: "light" })} />
<RadioOption checked={prefs.theme === "dark"} label="Dark" onChange={() => updatePrefs({ theme: "dark" })} />
<RadioOption checked={prefs.theme === "system"} label="System" onChange={() => updatePrefs({ theme: "system" })} />
</section>
<section className="rounded border border-slate-700/70 bg-slate-800/50 p-3">
<p className="mb-2 text-xs uppercase tracking-wide text-slate-400">Keyboard</p>
<RadioOption checked={prefs.sendMode === "enter"} label="Send with Enter (Shift+Enter new line)" onChange={() => updatePrefs({ sendMode: "enter" })} />
<RadioOption checked={prefs.sendMode === "ctrl_enter"} label="Send with Ctrl+Enter (Enter new line)" onChange={() => updatePrefs({ sendMode: "ctrl_enter" })} />
</section>
</div>
) : null}
{page === "notifications" ? (
<div className="space-y-4 px-4 py-3">
<section className="rounded border border-slate-700/70 bg-slate-800/50 p-3">
<p className="mb-2 text-xs uppercase tracking-wide text-slate-400">Web Notifications</p>
<CheckboxOption
checked={prefs.webNotifications}
label={`Web Notifications (${notificationStatus})`}
onChange={async (checked) => {
updatePrefs({ webNotifications: checked });
if (checked && "Notification" in window && Notification.permission === "default") {
await Notification.requestPermission();
}
}}
/>
<CheckboxOption checked={prefs.notificationSound} label="Notification Sound" onChange={(checked) => updatePrefs({ notificationSound: checked })} />
<CheckboxOption checked={prefs.messagePreview} label="Message Preview" onChange={(checked) => updatePrefs({ messagePreview: checked })} />
</section>
<section className="rounded border border-slate-700/70 bg-slate-800/50 p-3">
<p className="mb-2 text-xs uppercase tracking-wide text-slate-400">Chats</p>
<CheckboxOption checked={prefs.privateNotifications} label="Notifications for private chats" onChange={(checked) => updatePrefs({ privateNotifications: checked })} />
<CheckboxOption checked={prefs.groupNotifications} label="Notifications for groups" onChange={(checked) => updatePrefs({ groupNotifications: checked })} />
<CheckboxOption checked={prefs.channelNotifications} label="Notifications for channels" onChange={(checked) => updatePrefs({ channelNotifications: checked })} />
</section>
<section className="rounded border border-slate-700/70 bg-slate-800/50 p-3">
<div className="mb-2 flex items-center justify-between">
<p className="text-xs uppercase tracking-wide text-slate-400">Recent Notifications</p>
<button
className="rounded bg-slate-700 px-2 py-1 text-[11px]"
onClick={async () => {
setNotificationItemsLoading(true);
try {
setNotificationItems(await getNotifications(30));
} finally {
setNotificationItemsLoading(false);
}
}}
type="button"
>
Refresh
</button>
</div>
{notificationItemsLoading ? <p className="text-xs text-slate-400">Loading...</p> : null}
{!notificationItemsLoading && notificationItems.length === 0 ? <p className="text-xs text-slate-400">No notifications yet</p> : null}
<div className="space-y-2">
{notificationItems.slice(0, 20).map((item) => (
<div className="rounded bg-slate-900/50 px-2 py-2" key={item.id}>
<p className="text-xs font-semibold text-slate-200">{item.event_type}</p>
<p className="mt-0.5 line-clamp-2 text-[11px] text-slate-400">{renderNotificationPayload(item.payload)}</p>
<p className="mt-0.5 text-[11px] text-slate-500">{new Date(item.created_at).toLocaleString()}</p>
</div>
))}
</div>
</section>
</div>
) : null}
{page === "privacy" ? (
<div className="space-y-4 px-4 py-3">
<section className="rounded border border-slate-700/70 bg-slate-800/50 p-3">
<p className="mb-2 text-xs uppercase tracking-wide text-slate-400">Privacy</p>
<SettingsTextRow label="Blocked Users" value={String(blockedCount)} />
<div className="mb-2 rounded bg-slate-900/50 px-2 py-2">
<p className="mb-1 text-xs text-slate-300">Who can send me private messages?</p>
<select
className="w-full rounded bg-slate-800 px-2 py-1 text-xs"
value={privacyPrivateMessages}
onChange={(e) => setPrivacyPrivateMessages(e.target.value as "everyone" | "contacts" | "nobody")}
>
<option value="everyone">Everybody</option>
<option value="contacts">My contacts</option>
<option value="nobody">Nobody</option>
</select>
</div>
<div className="mb-2 rounded bg-slate-900/50 px-2 py-2">
<p className="mb-1 text-xs text-slate-300">Who can see my profile photo?</p>
<select
className="w-full rounded bg-slate-800 px-2 py-1 text-xs"
value={privacyAvatar}
onChange={(e) => setPrivacyAvatar(e.target.value as "everyone" | "contacts" | "nobody")}
>
<option value="everyone">Everybody</option>
<option value="contacts">My contacts</option>
<option value="nobody">Nobody</option>
</select>
</div>
<div className="mb-2 rounded bg-slate-900/50 px-2 py-2">
<p className="mb-1 text-xs text-slate-300">Who can see my last seen?</p>
<select
className="w-full rounded bg-slate-800 px-2 py-1 text-xs"
value={privacyLastSeen}
onChange={(e) => setPrivacyLastSeen(e.target.value as "everyone" | "contacts" | "nobody")}
>
<option value="everyone">Everybody</option>
<option value="contacts">My contacts</option>
<option value="nobody">Nobody</option>
</select>
</div>
<div className="mb-2 rounded bg-slate-900/50 px-2 py-2">
<p className="mb-1 text-xs text-slate-300">Who can add me to groups?</p>
<select
className="w-full rounded bg-slate-800 px-2 py-1 text-xs"
value={privacyGroupInvites}
onChange={(e) => setPrivacyGroupInvites(e.target.value as "everyone" | "contacts" | "nobody")}
>
<option value="everyone">Everybody</option>
<option value="contacts">My contacts</option>
<option value="nobody">Nobody</option>
</select>
</div>
<button
className="w-full rounded bg-sky-500 px-3 py-2 text-sm font-semibold text-slate-950 disabled:opacity-60"
onClick={async () => {
setSavingPrivacy(true);
try {
const updated = await updateMyProfile({
allow_private_messages: privacyPrivateMessages !== "nobody",
privacy_private_messages: privacyPrivateMessages,
privacy_last_seen: privacyLastSeen,
privacy_avatar: privacyAvatar,
privacy_group_invites: privacyGroupInvites,
});
useAuthStore.setState({ me: updated as AuthUser });
showToast("Privacy settings saved");
} catch {
showToast("Failed to save privacy settings");
} finally {
setSavingPrivacy(false);
}
}}
disabled={savingPrivacy}
type="button"
>
{savingPrivacy ? "Saving..." : "Save privacy settings"}
</button>
</section>
<section className="rounded border border-slate-700/70 bg-slate-800/50 p-3">
<div className="mb-2 flex items-center justify-between">
<p className="text-xs uppercase tracking-wide text-slate-400">Two-Factor Authentication</p>
<span className="text-[11px] text-slate-400">{me.twofa_enabled ? "Enabled" : "Disabled"}</span>
</div>
{!me.twofa_enabled ? (
<>
<button
className="mb-2 rounded bg-slate-700 px-2 py-1 text-xs"
onClick={async () => {
setTwofaError(null);
try {
const setup = await setupTwoFactor();
setTwofaSecret(setup.secret);
setTwofaUrl(setup.otpauth_url);
} catch {
setTwofaError("Failed to setup 2FA");
}
}}
type="button"
>
Generate secret
</button>
{twofaSecret ? (
<div className="mb-2 rounded bg-slate-900/50 px-2 py-2">
<p className="text-[11px] text-slate-400">Secret</p>
<p className="break-all text-xs text-slate-200">{twofaSecret}</p>
{twofaQrUrl ? (
<div className="mt-2 rounded bg-white p-2">
<img alt="TOTP QR" className="mx-auto h-44 w-44" src={twofaQrUrl} />
</div>
) : null}
{twofaUrl ? <p className="mt-1 break-all text-[11px] text-slate-500">{twofaUrl}</p> : null}
</div>
) : null}
<div className="flex gap-2">
<input
className="w-full rounded bg-slate-800 px-2 py-1.5 text-xs"
placeholder="Enter app code"
value={twofaCode}
onChange={(e) => setTwofaCode(e.target.value.replace(/\D/g, "").slice(0, 8))}
/>
<button
className="rounded bg-sky-500 px-2 py-1 text-xs font-semibold text-slate-950"
onClick={async () => {
setTwofaError(null);
try {
await enableTwoFactor(twofaCode);
await useAuthStore.getState().loadMe();
setTwofaCode("");
} catch {
setTwofaError("Invalid 2FA code");
}
}}
type="button"
>
Enable
</button>
</div>
</>
) : (
<div className="flex gap-2">
<input
className="w-full rounded bg-slate-800 px-2 py-1.5 text-xs"
placeholder="Code to disable"
value={twofaCode}
onChange={(e) => setTwofaCode(e.target.value.replace(/\D/g, "").slice(0, 8))}
/>
<button
className="rounded bg-red-500 px-2 py-1 text-xs font-semibold text-white"
onClick={async () => {
setTwofaError(null);
try {
await disableTwoFactor(twofaCode);
await useAuthStore.getState().loadMe();
setTwofaCode("");
setTwofaSecret(null);
setTwofaUrl(null);
setTwofaQrUrl(null);
setRecoveryCodes([]);
setRecoveryRemaining(0);
} catch {
setTwofaError("Invalid 2FA code");
}
}}
type="button"
>
Disable
</button>
</div>
)}
{twofaError ? <p className="mt-2 text-xs text-red-400">{twofaError}</p> : null}
{me.twofa_enabled ? (
<div className="mt-3 rounded bg-slate-900/50 px-2 py-2">
{recoveryRemaining === 0 ? (
<p className="mb-2 rounded border border-amber-500/40 bg-amber-500/10 px-2 py-1.5 text-[11px] text-amber-200">
You have no recovery codes. Generate and save them to avoid lockout.
</p>
) : null}
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-xs text-slate-300">Recovery codes remaining: {recoveryRemaining}</p>
<button
className="rounded bg-slate-700 px-2 py-1 text-[11px]"
onClick={async () => {
setTwofaError(null);
try {
const result = await regenerateTwoFactorRecoveryCodes(twofaCode);
setRecoveryCodes(result.codes);
setRecoveryRemaining(result.codes.length);
setTwofaCode("");
showToast("New recovery codes generated");
} catch {
setTwofaError("Invalid 2FA code for recovery codes");
}
}}
type="button"
>
Generate recovery codes
</button>
</div>
{recoveryCodes.length > 0 ? (
<div className="rounded border border-amber-500/40 bg-amber-500/10 p-2">
<p className="mb-1 text-[11px] text-amber-200">Save these codes now. They are shown only once.</p>
<div className="mb-2 flex items-center gap-2">
<button
className="rounded bg-slate-700 px-2 py-1 text-[11px]"
onClick={() => {
void copyRecoveryCodes(recoveryCodes);
}}
type="button"
>
Copy
</button>
<button
className="rounded bg-slate-700 px-2 py-1 text-[11px]"
onClick={() => downloadRecoveryCodes(recoveryCodes)}
type="button"
>
Download .txt
</button>
</div>
<div className="grid grid-cols-2 gap-1">
{recoveryCodes.map((item) => (
<code className="rounded bg-slate-900 px-1.5 py-1 text-[11px]" key={item}>
{item}
</code>
))}
</div>
</div>
) : null}
</div>
) : null}
</section>
<section className="rounded border border-slate-700/70 bg-slate-800/50 p-3">
<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 disabled:opacity-60"
onClick={async () => {
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"
>
{revokeAllLoading ? "Revoking..." : "Revoke all"}
</button>
</div>
{sessionsLoading ? <p className="text-xs text-slate-400">Loading sessions...</p> : null}
{!sessionsLoading && sessionsError ? <p className="text-xs text-red-400">{sessionsError}</p> : null}
{!sessionsLoading && sessions.length === 0 ? <p className="text-xs text-slate-400">No active sessions</p> : null}
<div className="space-y-2">
{sessions.map((session) => (
<div className="rounded bg-slate-900/50 px-2 py-2" key={session.jti}>
<p className="truncate text-xs text-slate-300">
{session.user_agent || "Unknown device"}
{session.current ? " (current)" : ""}
</p>
<p className="truncate text-[11px] text-slate-500">Token: {session.token_type || "refresh"}</p>
<p className="truncate text-[11px] text-slate-400">{session.ip_address || "Unknown IP"}</p>
<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] disabled:opacity-60"
onClick={async () => {
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"
>
{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>
)}
</div>
))}
</div>
</section>
</div>
) : null}
</div>
</aside>
<AvatarCropModal
file={avatarCropFile}
onApply={(processedFile) => {
setAvatarCropFile(null);
void uploadAvatar(processedFile);
}}
onCancel={() => setAvatarCropFile(null)}
open={Boolean(avatarCropFile)}
/>
</div>,
document.body
);
}
function renderNotificationPayload(payload: string): string {
try {
const parsed = JSON.parse(payload) as { text?: string; title?: string; body?: string; chat_id?: number };
return parsed.text || parsed.body || parsed.title || (parsed.chat_id ? `Chat #${parsed.chat_id}` : payload);
} catch {
return payload;
}
}
function SettingsRow({ label, value, onClick }: { label: string; value: string; onClick: () => void }) {
return (
<button className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-slate-800/60" onClick={onClick} type="button">
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{label}</p>
<p className="truncate text-xs text-slate-400">{value}</p>
</div>
<span className="text-slate-400"></span>
</button>
);
}
function SettingsTextRow({ label, value }: { label: string; value: string }) {
return (
<div className="mb-2 flex items-center justify-between gap-2 rounded bg-slate-900/50 px-2 py-2">
<p className="text-sm">{label}</p>
<p className="text-xs text-slate-400">{value}</p>
</div>
);
}
function RadioOption({ checked, label, onChange }: { checked: boolean; label: string; onChange: () => void }) {
return (
<label className="mb-2 flex items-center gap-2 text-sm">
<input checked={checked} onChange={onChange} type="radio" />
<span>{label}</span>
</label>
);
}
function CheckboxOption({
checked,
label,
onChange,
disabled,
}: {
checked: boolean;
label: string;
onChange: (checked: boolean) => void;
disabled?: boolean;
}) {
return (
<label className="mb-2 flex items-center gap-2 text-sm">
<input checked={checked} disabled={disabled} onChange={(e) => onChange(e.target.checked)} type="checkbox" />
<span>{label}</span>
</label>
);
}