feat(auth,privacy,web): step-by-step login, privacy settings persistence, TOTP QR, and API docs
Some checks failed
CI / test (push) Failing after 22s

This commit is contained in:
2026-03-08 12:09:53 +03:00
parent 1546ae7381
commit 79baadb522
19 changed files with 2034 additions and 79 deletions

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import QRCode from "qrcode";
import { listBlockedUsers, updateMyProfile } from "../api/users";
import { disableTwoFactor, enableTwoFactor, listSessions, revokeAllSessions, revokeSession, setupTwoFactor } from "../api/auth";
import type { AuthSession, AuthUser } from "../chat/types";
@@ -15,6 +16,7 @@ interface Props {
export function SettingsPanel({ open, onClose }: Props) {
const me = useAuthStore((s) => s.me);
const logout = useAuthStore((s) => s.logout);
const [page, setPage] = useState<SettingsPage>("main");
const [prefs, setPrefs] = useState<AppPreferences>(() => getAppPreferences());
const [blockedCount, setBlockedCount] = useState(0);
@@ -25,7 +27,11 @@ export function SettingsPanel({ open, onClose }: Props) {
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 [privacyLastSeen, setPrivacyLastSeen] = useState<"everyone" | "contacts" | "nobody">("everyone");
const [privacyAvatar, setPrivacyAvatar] = useState<"everyone" | "contacts" | "nobody">("everyone");
const [privacyGroupInvites, setPrivacyGroupInvites] = useState<"everyone" | "contacts">("everyone");
const [profileDraft, setProfileDraft] = useState({
name: "",
username: "",
@@ -38,6 +44,9 @@ export function SettingsPanel({ open, onClose }: Props) {
return;
}
setAllowPrivateMessages(me.allow_private_messages);
setPrivacyLastSeen(me.privacy_last_seen || "everyone");
setPrivacyAvatar(me.privacy_avatar || "everyone");
setPrivacyGroupInvites(me.privacy_group_invites || "everyone");
setProfileDraft({
name: me.name || "",
username: me.username || "",
@@ -46,6 +55,29 @@ export function SettingsPanel({ open, onClose }: Props) {
});
}, [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;
@@ -268,9 +300,62 @@ export function SettingsPanel({ open, onClose }: Props) {
<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" />
<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")}
>
<option value="everyone">Everybody</option>
<option value="contacts">My contacts</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: allowPrivateMessages,
privacy_last_seen: privacyLastSeen,
privacy_avatar: privacyAvatar,
privacy_group_invites: privacyGroupInvites,
});
useAuthStore.setState({ me: updated as AuthUser });
} 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">
<CheckboxOption
@@ -316,6 +401,11 @@ export function SettingsPanel({ open, onClose }: Props) {
<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}
@@ -362,6 +452,7 @@ export function SettingsPanel({ open, onClose }: Props) {
setTwofaCode("");
setTwofaSecret(null);
setTwofaUrl(null);
setTwofaQrUrl(null);
} catch {
setTwofaError("Invalid 2FA code");
}
@@ -382,6 +473,8 @@ export function SettingsPanel({ open, onClose }: Props) {
onClick={async () => {
await revokeAllSessions();
setSessions([]);
logout();
onClose();
}}
type="button"
>