diff --git a/backend/api/account/serializers.py b/backend/api/account/serializers.py index 186b87ce4..9515969f6 100644 --- a/backend/api/account/serializers.py +++ b/backend/api/account/serializers.py @@ -24,13 +24,15 @@ class ActiveSessionSerializer(serializers.Serializer): client_version = serializers.CharField( required=False, allow_null=True, default=None ) + created_at = serializers.DateTimeField(required=True) last_used = serializers.DateTimeField(required=True) is_current = serializers.BooleanField(required=True) class ActiveSessionListSerializer(serializers.Serializer): - sessions = serializers.ListField( - child=ActiveSessionSerializer(), required=True, allow_empty=False + current_session = ActiveSessionSerializer(required=True) + other_sessions = serializers.ListField( + child=ActiveSessionSerializer(), required=True, allow_empty=True ) diff --git a/backend/api/account/views.py b/backend/api/account/views.py index 5a18c7e15..6af3fdbba 100644 --- a/backend/api/account/views.py +++ b/backend/api/account/views.py @@ -29,6 +29,7 @@ from api.settings import ( ACCOUNT_COOKIE_NAME, AUTHED_PWD_RESET_EXP_SECONDS, + GENERIC_ERR_RESPONSE, LONG_SESS_EXP_SECONDS, SEND_EMAILS, SESS_EXP_SECONDS, @@ -107,11 +108,13 @@ def get_active_sessions(request): user_account=user, ).order_by("-last_used") - active_sessions = [] + current_session = None + other_sessions = [] for session in sessions: session_data = { "public_id": session.public_id, + "created_at": session.created_at, "last_used": session.last_used, "is_current": session.session_token == request.COOKIES.get(ACCOUNT_COOKIE_NAME), @@ -123,9 +126,26 @@ def get_active_sessions(request): session_data["os_version"] = device.os_version() or None session_data["client_name"] = device.client_name() or None session_data["client_version"] = device.client_version() or None - active_sessions.append(session_data) - return Response({"sessions": active_sessions}, status=200) + if session_data["is_current"]: + if current_session is not None: + logger.error( + f"Multiple sessions matching session token for user: {user.id}." + ) + current_session = session_data + else: + other_sessions.append(session_data) + + if current_session is None: + logger.error( + f"No session matching session token for user, despite being authenticated: {user.id}." + ) + return GENERIC_ERR_RESPONSE + + return Response( + {"current_session": current_session, "other_sessions": other_sessions}, + status=200, + ) @api_endpoint("POST") diff --git a/frontend/src/app/settings/(submenus)/security/page.tsx b/frontend/src/app/settings/(submenus)/security/page.tsx index 8a0ad22e2..a434192fe 100644 --- a/frontend/src/app/settings/(submenus)/security/page.tsx +++ b/frontend/src/app/settings/(submenus)/security/page.tsx @@ -1,8 +1,11 @@ -"use client"; - import ChangePasswordDialog from "@/features/account/setting-dialogs/change-password/main-dialog"; +import SessionManager from "@/features/account/settings/security/components/session-manager"; +import { ROUTES } from "@/lib/utils/api/endpoints"; +import { serverGet } from "@/lib/utils/api/server-fetch"; + +export default async function Page() { + const activeSessions = await serverGet(ROUTES.account.getActiveSessions); -export default function Page() { return (
@@ -17,6 +20,8 @@ export default function Page() {
+ + ); } diff --git a/frontend/src/features/account/settings/security/components/session-manager.tsx b/frontend/src/features/account/settings/security/components/session-manager.tsx new file mode 100644 index 000000000..49c6905e4 --- /dev/null +++ b/frontend/src/features/account/settings/security/components/session-manager.tsx @@ -0,0 +1,302 @@ +"use client"; + +import { + startTransition, + useEffect, + useOptimistic, + useRef, + useState, +} from "react"; + +import * as Collapsible from "@radix-ui/react-collapsible"; +import { toZonedTime } from "date-fns-tz"; +import { + ChevronDownIcon, + CircleQuestionMark, + Laptop2Icon, + SmartphoneIcon, + TabletIcon, +} from "lucide-react"; + +import { pruneSessions } from "@/features/account/settings/security/prune-sessions"; +import { removeSession } from "@/features/account/settings/security/remove-session"; +import ActionButton from "@/features/button/components/action"; +import { ConfirmationDialog, useToast } from "@/features/system-feedback"; +import { MESSAGES } from "@/lib/messages"; +import { ActiveSessionList, type ActiveSession } from "@/lib/utils/api/types"; +import { cn } from "@/lib/utils/classname"; +import { formatTimeAgo } from "@/lib/utils/date-time-format"; + +type SessionAction = { type: "remove"; publicId: string } | { type: "prune" }; + +export default function SessionManager({ + sessions, +}: { + sessions: ActiveSessionList; +}) { + const [now, setNow] = useState(Date.now()); + // This updates the "last used" timestamps every minute + useEffect(() => { + const interval = setInterval(() => { + setNow(Date.now()); + }, 60000); // Update every minute + return () => clearInterval(interval); + }, []); + + const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + const [optimisticSessions, setOptimisticSessions] = useOptimistic( + sessions, + (state, action: SessionAction) => { + switch (action.type) { + case "remove": + return { + current_session: state.current_session, + other_sessions: state.other_sessions.filter( + (s) => s.public_id !== action.publicId, + ), + }; + case "prune": + return { + current_session: state.current_session, + other_sessions: [], + }; + default: + return state; + } + }, + ); + + const [pruneConfirmationOpen, setPruneConfirmationOpen] = useState(false); + const [removeConfirmationOpen, setRemoveConfirmationOpen] = useState(false); + const sessionToRemove = useRef(null); + const { addToast } = useToast(); + + const handlePruneSessions = async () => { + // Immediate UI update + startTransition(() => { + setOptimisticSessions({ type: "prune" }); + }); + + // Server Action + const result = await pruneSessions(); + + if (!result.success) { + addToast("error", result.error || MESSAGES.ERROR_GENERIC); + } else { + addToast("success", MESSAGES.SUCCESS_SESSION_PRUNE); + } + }; + + const handleRemoveSession = async (publicId: string) => { + // Immediate UI update + startTransition(() => { + setOptimisticSessions({ type: "remove", publicId }); + }); + + // Server Action + const result = await removeSession(publicId); + + if (!result.success) { + addToast("error", result.error || MESSAGES.ERROR_GENERIC); + } else { + addToast("success", MESSAGES.SUCCESS_SESSION_REMOVE); + } + }; + + const onRemoveSession = (publicId: string) => { + sessionToRemove.current = publicId; + setRemoveConfirmationOpen(true); + }; + + return ( +
+
+

Active Sessions

+

+ These devices have access to your account. If there are any you don + {"'"}t recognize, remove them and change your password. +

+
+ + +
+ {optimisticSessions.other_sessions.length > 0 ? ( + <> +
+ {optimisticSessions.other_sessions.map((session) => ( + { + onRemoveSession(session.public_id); + }} + /> + ))} +
+ + { + setPruneConfirmationOpen(true); + }} + /> + + ) : ( +

+ No other active sessions. +

+ )} + + { + if (!sessionToRemove.current) return false; + handleRemoveSession(sessionToRemove.current); + return true; + }} + /> + { + handlePruneSessions(); + return true; + }} + /> +
+ ); +} + +function Session({ + session, + now, + userTz, + onRemove, +}: { + session: ActiveSession; + now: number; + userTz: string; + onRemove?: () => void; +}) { + const [isOpen, setIsOpen] = useState(false); + + const lastUsedLocal = toZonedTime(new Date(session.last_used + "Z"), userTz); + const lastUsedSecondsAgo = (now - lastUsedLocal.getTime()) / 1000; + + const createdAtLocal = toZonedTime( + new Date(session.created_at + "Z"), + userTz, + ); + + return ( +
+ + +
+
+ {session.device_type === "desktop" ? ( + + ) : session.device_type === "smartphone" ? ( + + ) : session.device_type === "tablet" ? ( + + ) : ( + + )} +
+ +
+
+ {!session.os_name && !session.client_name + ? "Unknown" + : (session.os_name || "Unknown Device") + + " • " + + (session.client_name || "Unknown Browser")} +
+
+ {session.is_current + ? "This Session" + : `Last used ${formatTimeAgo(lastUsedSecondsAgo)}`} +
+
+
+
+
+ +
+
+
+ +
+
+ {session.os_name && session.os_version && ( +

+ {session.os_name} {session.os_version} +

+ )} + {session.client_name && session.client_version && ( +

+ {session.client_name} {session.client_version} +

+ )} +

+ Logged in on{" "} + {createdAtLocal.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "numeric", + })} +

+
+ {onRemove && ( + + )} +
+
+
+
+ ); +} diff --git a/frontend/src/features/account/settings/security/prune-sessions.ts b/frontend/src/features/account/settings/security/prune-sessions.ts new file mode 100644 index 000000000..e5c7eaee3 --- /dev/null +++ b/frontend/src/features/account/settings/security/prune-sessions.ts @@ -0,0 +1,20 @@ +"use server"; + +import { revalidatePath } from "next/cache"; + +import { ROUTES } from "@/lib/utils/api/endpoints"; +import { ApiErrorResponse } from "@/lib/utils/api/fetch-wrapper"; +import { serverPost } from "@/lib/utils/api/server-fetch"; + +export async function pruneSessions() { + try { + await serverPost(ROUTES.account.pruneSessions, undefined, { + cache: "no-store", + }); + revalidatePath("/settings/security"); + return { success: true }; + } catch (e) { + const error = e as ApiErrorResponse; + return { success: false, error: error.formattedMessage }; + } +} diff --git a/frontend/src/features/account/settings/security/remove-session.ts b/frontend/src/features/account/settings/security/remove-session.ts new file mode 100644 index 000000000..dcd89cb8b --- /dev/null +++ b/frontend/src/features/account/settings/security/remove-session.ts @@ -0,0 +1,22 @@ +"use server"; + +import { revalidatePath } from "next/cache"; + +import { ROUTES } from "@/lib/utils/api/endpoints"; +import { ApiErrorResponse } from "@/lib/utils/api/fetch-wrapper"; +import { serverPost } from "@/lib/utils/api/server-fetch"; + +export async function removeSession(sessionId: string) { + try { + await serverPost( + ROUTES.account.terminateSession, + { session_id: sessionId }, + { cache: "no-store" }, + ); + revalidatePath("/settings/security"); + return { success: true }; + } catch (e) { + const error = e as ApiErrorResponse; + return { success: false, error: error.formattedMessage }; + } +} diff --git a/frontend/src/lib/messages.ts b/frontend/src/lib/messages.ts index e298bfb1d..4fe72ebee 100644 --- a/frontend/src/lib/messages.ts +++ b/frontend/src/lib/messages.ts @@ -49,6 +49,8 @@ export const MESSAGES = { SUCCESS_DEFAULT_NAME_SAVED: "Nickname saved successfully.", SUCCESS_DEFAULT_NAME_REMOVED: "Nickname removed successfully.", SUCCESS_EVENT_DELETE: "Event deleted successfully.", + SUCCESS_SESSION_REMOVE: "Session removed successfully.", + SUCCESS_SESSION_PRUNE: "All other sessions removed successfully.", SUCCESS_ACCOUNT_DELETE: "Account deleted successfully. Have a nice day!", // copy link messages diff --git a/frontend/src/lib/utils/api/types.ts b/frontend/src/lib/utils/api/types.ts index e87613620..77e82e1a9 100644 --- a/frontend/src/lib/utils/api/types.ts +++ b/frontend/src/lib/utils/api/types.ts @@ -136,17 +136,35 @@ export type DisplayName = { export type ActiveSession = { public_id: string; - device_type: string | null; + device_type: + | "desktop" + | "smartphone" + | "tablet" + | "feature phone" + | "console" + | "tv" + | "car browser" + | "smart display" + | "camera" + | "portable media player" + | "phablet" + | "smart speaker" + | "wearable" + | "peripheral" + | "" + | null; os_name: string | null; os_version: string | null; client_name: string | null; client_version: string | null; + created_at: string; last_used: string; is_current: boolean; } export type ActiveSessionList = { - sessions: ActiveSession[]; + current_session: ActiveSession; + other_sessions: ActiveSession[]; } export type SessionId = { diff --git a/frontend/src/lib/utils/date-time-format.ts b/frontend/src/lib/utils/date-time-format.ts index d8e61ad68..ae9265619 100644 --- a/frontend/src/lib/utils/date-time-format.ts +++ b/frontend/src/lib/utils/date-time-format.ts @@ -72,19 +72,18 @@ export function timeslotToISOString( } } - /** * Checks if two timezones are equivalent even if they represent different locations. - * + * * For example, "America/New_York" and "America/Detroit" are equal because they are both * in Eastern Time. - * + * * IMPORTANT: This function also checks if the timezones have the same DST rules by * comparing offsets in January and July. - * + * * For example, "America/New_York" and "America/Caracas" are NOT equal because Caracas * does not observe DST, despite both having the same offset during part of the year. - * + * * @param tz1 The first timezone to compare * @param tz2 The second timezone to compare * @returns `true` if the timezones are equivalent, `false` otherwise @@ -207,3 +206,26 @@ export function convert12To24(time12: string): string { const date = parse(time12, "hh:mm a", new Date()); return format(date, "HH:mm"); } + +/* TIME AGO UTILS */ + +/** + * Formats a time difference in seconds into a simplified readable string. + * @param secondsAgo The number of seconds ago. + * @param largestUnit The largest time unit to display. + * @returns A string representing the time difference. + */ +export function formatTimeAgo(secondsAgo: number): string { + if (secondsAgo < 60) { + return "just now"; + } else if (secondsAgo < 60 * 60) { + const minutes = Math.floor(secondsAgo / 60); + return `${minutes} minute${minutes !== 1 ? "s" : ""} ago`; + } else if (secondsAgo < 60 * 60 * 24) { + const hours = Math.floor(secondsAgo / (60 * 60)); + return `${hours} hour${hours !== 1 ? "s" : ""} ago`; + } else { + const days = Math.floor(secondsAgo / (60 * 60 * 24)); + return `${days} day${days !== 1 ? "s" : ""} ago`; + } +}