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`;
+ }
+}