- store refresh session metadata in redis (ip/user-agent/created_at) - add auth APIs: list sessions, revoke one, revoke all - add web privacy UI for active sessions
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { http } from "./http";
|
||||
import type { AuthUser, TokenPair } from "../chat/types";
|
||||
import type { AuthSession, AuthUser, TokenPair } from "../chat/types";
|
||||
|
||||
export async function registerRequest(email: string, name: string, username: string, password: string): Promise<void> {
|
||||
await http.post("/auth/register", { email, name, username, password });
|
||||
@@ -19,3 +19,16 @@ export async function meRequest(): Promise<AuthUser> {
|
||||
const { data } = await http.get<AuthUser>("/auth/me");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function listSessions(): Promise<AuthSession[]> {
|
||||
const { data } = await http.get<AuthSession[]>("/auth/sessions");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function revokeSession(jti: string): Promise<void> {
|
||||
await http.delete(`/auth/sessions/${jti}`);
|
||||
}
|
||||
|
||||
export async function revokeAllSessions(): Promise<void> {
|
||||
await http.delete("/auth/sessions");
|
||||
}
|
||||
|
||||
@@ -88,6 +88,13 @@ export interface TokenPair {
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
export interface AuthSession {
|
||||
jti: string;
|
||||
created_at: string;
|
||||
ip_address?: string | null;
|
||||
user_agent?: string | null;
|
||||
}
|
||||
|
||||
export interface UserSearchItem {
|
||||
id: number;
|
||||
name: string;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { listBlockedUsers, updateMyProfile } from "../api/users";
|
||||
import type { AuthUser } from "../chat/types";
|
||||
import { listSessions, revokeAllSessions, revokeSession } from "../api/auth";
|
||||
import type { AuthSession, AuthUser } from "../chat/types";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
import { AppPreferences, getAppPreferences, updateAppPreferences } from "../utils/preferences";
|
||||
|
||||
@@ -19,6 +20,8 @@ export function SettingsPanel({ open, onClose }: Props) {
|
||||
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 [profileDraft, setProfileDraft] = useState({
|
||||
name: "",
|
||||
username: "",
|
||||
@@ -68,6 +71,33 @@ export function SettingsPanel({ open, onClose }: Props) {
|
||||
};
|
||||
}, [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";
|
||||
@@ -255,6 +285,42 @@ export function SettingsPanel({ open, onClose }: Props) {
|
||||
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">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>
|
||||
@@ -312,4 +378,3 @@ function CheckboxOption({
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user