Files
Messenger/web/src/components/SettingsPanel.tsx
benya 27d3340a37
Some checks failed
CI / test (push) Failing after 21s
feat(auth): add TOTP 2FA setup and login verification
- add user twofa fields and migration

- add 2FA setup/enable/disable endpoints

- enforce OTP on login when 2FA enabled

- add web login OTP field and settings UI
2026-03-08 11:43:51 +03:00

470 lines
20 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 { listBlockedUsers, updateMyProfile } from "../api/users";
import { disableTwoFactor, enableTwoFactor, listSessions, revokeAllSessions, revokeSession, setupTwoFactor } from "../api/auth";
import type { AuthSession, AuthUser } from "../chat/types";
import { useAuthStore } from "../store/authStore";
import { AppPreferences, getAppPreferences, updateAppPreferences } from "../utils/preferences";
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 [page, setPage] = useState<SettingsPage>("main");
const [prefs, setPrefs] = useState<AppPreferences>(() => getAppPreferences());
const [blockedCount, setBlockedCount] = useState(0);
const [savingPrivacy, setSavingPrivacy] = useState(false);
const [allowPrivateMessages, setAllowPrivateMessages] = useState(true);
const [sessions, setSessions] = useState<AuthSession[]>([]);
const [sessionsLoading, setSessionsLoading] = useState(false);
const [twofaCode, setTwofaCode] = useState("");
const [twofaSecret, setTwofaSecret] = useState<string | null>(null);
const [twofaUrl, setTwofaUrl] = useState<string | null>(null);
const [twofaError, setTwofaError] = useState<string | null>(null);
const [profileDraft, setProfileDraft] = useState({
name: "",
username: "",
bio: "",
avatarUrl: "",
});
useEffect(() => {
if (!me) {
return;
}
setAllowPrivateMessages(me.allow_private_messages);
setProfileDraft({
name: me.name || "",
username: me.username || "",
bio: me.bio || "",
avatarUrl: me.avatar_url || "",
});
}, [me]);
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") {
return;
}
let cancelled = false;
setSessionsLoading(true);
void (async () => {
try {
const rows = await listSessions();
if (!cancelled) {
setSessions(rows);
}
} catch {
if (!cancelled) {
setSessions([]);
}
} 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";
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/95 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">
<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"
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"
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={allowPrivateMessages ? "Everybody 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.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>
</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)} />
<SettingsTextRow label="Who can see my profile photo?" value="Everybody" />
<SettingsTextRow label="Who can see my last seen?" value="Everybody" />
<SettingsTextRow label="Who can add me to groups?" value="Everybody" />
</section>
<section className="rounded border border-slate-700/70 bg-slate-800/50 p-3">
<CheckboxOption
checked={allowPrivateMessages}
label="Who can send me messages? (Everybody)"
onChange={async (checked) => {
setAllowPrivateMessages(checked);
setSavingPrivacy(true);
try {
const updated = await updateMyProfile({ allow_private_messages: checked });
useAuthStore.setState({ me: updated as AuthUser });
} finally {
setSavingPrivacy(false);
}
}}
disabled={savingPrivacy}
/>
</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>
{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);
} catch {
setTwofaError("Invalid 2FA code");
}
}}
type="button"
>
Disable
</button>
</div>
)}
{twofaError ? <p className="mt-2 text-xs text-red-400">{twofaError}</p> : 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"
onClick={async () => {
await revokeAllSessions();
setSessions([]);
}}
type="button"
>
Revoke all
</button>
</div>
{sessionsLoading ? <p className="text-xs text-slate-400">Loading sessions...</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"}</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>
<button
className="mt-1 rounded bg-slate-700 px-2 py-1 text-[11px]"
onClick={async () => {
await revokeSession(session.jti);
setSessions((prev) => prev.filter((item) => item.jti !== session.jti));
}}
type="button"
>
Revoke
</button>
</div>
))}
</div>
</section>
</div>
) : null}
</div>
</aside>
</div>,
document.body
);
}
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>
);
}