From 96755ac5ba44af11c03a19efa0651f22c3512335 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 13 Aug 2026 16:33:20 -0400 Subject: [PATCH 01/21] Move security submenu to client page --- .../(submenus)/security/page-client.tsx | 22 ++++++++++++++++++ .../app/settings/(submenus)/security/page.tsx | 23 +++---------------- 2 files changed, 25 insertions(+), 20 deletions(-) create mode 100644 frontend/src/app/settings/(submenus)/security/page-client.tsx diff --git a/frontend/src/app/settings/(submenus)/security/page-client.tsx b/frontend/src/app/settings/(submenus)/security/page-client.tsx new file mode 100644 index 000000000..a4ad05fdd --- /dev/null +++ b/frontend/src/app/settings/(submenus)/security/page-client.tsx @@ -0,0 +1,22 @@ +"use client"; + +import ChangePasswordDialog from "@/features/account/setting-dialogs/change-password/main-dialog"; + +export default function ClientPage() { + return ( +
+
+
+

Password

+

+ Update your password to keep your account secure. +

+
+ +
+ +
+
+
+ ); +} diff --git a/frontend/src/app/settings/(submenus)/security/page.tsx b/frontend/src/app/settings/(submenus)/security/page.tsx index 8a0ad22e2..16c11e8cf 100644 --- a/frontend/src/app/settings/(submenus)/security/page.tsx +++ b/frontend/src/app/settings/(submenus)/security/page.tsx @@ -1,22 +1,5 @@ -"use client"; +import ClientPage from "@/app/settings/(submenus)/security/page-client"; -import ChangePasswordDialog from "@/features/account/setting-dialogs/change-password/main-dialog"; - -export default function Page() { - return ( -
-
-
-

Password

-

- Update your password to keep your account secure. -

-
- -
- -
-
-
- ); +export default async function Page() { + return ; } From 0045c36e7f56ac3e8902c53c28113552522e04db Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 13 Aug 2026 17:18:17 -0400 Subject: [PATCH 02/21] Add created_at to session data --- backend/api/account/serializers.py | 1 + backend/api/account/views.py | 1 + frontend/src/lib/utils/api/types.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/backend/api/account/serializers.py b/backend/api/account/serializers.py index 186b87ce4..7eccff07c 100644 --- a/backend/api/account/serializers.py +++ b/backend/api/account/serializers.py @@ -24,6 +24,7 @@ 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) diff --git a/backend/api/account/views.py b/backend/api/account/views.py index 5a18c7e15..b24fdf6d6 100644 --- a/backend/api/account/views.py +++ b/backend/api/account/views.py @@ -112,6 +112,7 @@ def get_active_sessions(request): 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), diff --git a/frontend/src/lib/utils/api/types.ts b/frontend/src/lib/utils/api/types.ts index e87613620..24229277f 100644 --- a/frontend/src/lib/utils/api/types.ts +++ b/frontend/src/lib/utils/api/types.ts @@ -141,6 +141,7 @@ export type ActiveSession = { os_version: string | null; client_name: string | null; client_version: string | null; + created_at: string; last_used: string; is_current: boolean; } From 2a8341523213450cea16c94a3212d7dafeb85355 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 13 Aug 2026 18:09:31 -0400 Subject: [PATCH 03/21] Create formatTimeAgo function --- frontend/src/lib/utils/date-time-format.ts | 32 ++++++++++++++++++---- 1 file changed, 27 insertions(+), 5 deletions(-) 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`; + } +} From 6bf3c2d1ea8c9106da1fa2e025426bfa2e721a52 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 13 Aug 2026 18:09:58 -0400 Subject: [PATCH 04/21] Add basic session data to security submenu --- .../(submenus)/security/page-client.tsx | 71 ++++++++++++++++++- .../app/settings/(submenus)/security/page.tsx | 6 +- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/settings/(submenus)/security/page-client.tsx b/frontend/src/app/settings/(submenus)/security/page-client.tsx index a4ad05fdd..c5a7bbd3e 100644 --- a/frontend/src/app/settings/(submenus)/security/page-client.tsx +++ b/frontend/src/app/settings/(submenus)/security/page-client.tsx @@ -1,8 +1,20 @@ "use client"; +import { toZonedTime } from "date-fns-tz"; +import { CircleQuestionMark, Laptop2Icon, SmartphoneIcon } from "lucide-react"; + import ChangePasswordDialog from "@/features/account/setting-dialogs/change-password/main-dialog"; +import { ActiveSession } from "@/lib/utils/api/types"; +import { cn } from "@/lib/utils/classname"; +import { formatTimeAgo } from "@/lib/utils/date-time-format"; + +export default function ClientPage({ + sessions, +}: { + sessions: ActiveSession[]; +}) { + const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; -export default function ClientPage() { return (
@@ -17,6 +29,63 @@ export default function ClientPage() {
+ +
+
+

Active Devices

+

+ If there are any devices you don{"'"}t recognize, remove it and + change your password. +

+
+ + {sessions.map((session) => { + const lastUsedLocal = toZonedTime( + new Date(session.last_used + "Z"), + userTimeZone, + ); + const lastUsedSecondsAgo = + (Date.now() - lastUsedLocal.getTime()) / 1000; + + return ( +
+
+
+ {session.device_type === "desktop" ? ( + + ) : session.device_type === "smartphone" ? ( + + ) : ( + + )} +
+
+
+ {!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)}`} +
+
+
+
+ ); + })} +
); } diff --git a/frontend/src/app/settings/(submenus)/security/page.tsx b/frontend/src/app/settings/(submenus)/security/page.tsx index 16c11e8cf..e572e15a3 100644 --- a/frontend/src/app/settings/(submenus)/security/page.tsx +++ b/frontend/src/app/settings/(submenus)/security/page.tsx @@ -1,5 +1,9 @@ import ClientPage from "@/app/settings/(submenus)/security/page-client"; +import { ROUTES } from "@/lib/utils/api/endpoints"; +import { serverGet } from "@/lib/utils/api/server-fetch"; export default async function Page() { - return ; + const activeSessions = await serverGet(ROUTES.account.getActiveSessions); + + return ; } From 83ffb9098c3c2af11f7b64a8076d38f72da94fa5 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 13 Aug 2026 18:18:08 -0400 Subject: [PATCH 05/21] Move security submenu back to server page --- .../(submenus)/security/page-client.tsx | 91 ------------------- .../app/settings/(submenus)/security/page.tsx | 22 ++++- .../security/components/session-manager.tsx | 75 +++++++++++++++ 3 files changed, 95 insertions(+), 93 deletions(-) delete mode 100644 frontend/src/app/settings/(submenus)/security/page-client.tsx create mode 100644 frontend/src/features/account/settings/security/components/session-manager.tsx diff --git a/frontend/src/app/settings/(submenus)/security/page-client.tsx b/frontend/src/app/settings/(submenus)/security/page-client.tsx deleted file mode 100644 index c5a7bbd3e..000000000 --- a/frontend/src/app/settings/(submenus)/security/page-client.tsx +++ /dev/null @@ -1,91 +0,0 @@ -"use client"; - -import { toZonedTime } from "date-fns-tz"; -import { CircleQuestionMark, Laptop2Icon, SmartphoneIcon } from "lucide-react"; - -import ChangePasswordDialog from "@/features/account/setting-dialogs/change-password/main-dialog"; -import { ActiveSession } from "@/lib/utils/api/types"; -import { cn } from "@/lib/utils/classname"; -import { formatTimeAgo } from "@/lib/utils/date-time-format"; - -export default function ClientPage({ - sessions, -}: { - sessions: ActiveSession[]; -}) { - const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; - - return ( -
-
-
-

Password

-

- Update your password to keep your account secure. -

-
- -
- -
-
- -
-
-

Active Devices

-

- If there are any devices you don{"'"}t recognize, remove it and - change your password. -

-
- - {sessions.map((session) => { - const lastUsedLocal = toZonedTime( - new Date(session.last_used + "Z"), - userTimeZone, - ); - const lastUsedSecondsAgo = - (Date.now() - lastUsedLocal.getTime()) / 1000; - - return ( -
-
-
- {session.device_type === "desktop" ? ( - - ) : session.device_type === "smartphone" ? ( - - ) : ( - - )} -
-
-
- {!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)}`} -
-
-
-
- ); - })} -
-
- ); -} diff --git a/frontend/src/app/settings/(submenus)/security/page.tsx b/frontend/src/app/settings/(submenus)/security/page.tsx index e572e15a3..97b539a90 100644 --- a/frontend/src/app/settings/(submenus)/security/page.tsx +++ b/frontend/src/app/settings/(submenus)/security/page.tsx @@ -1,9 +1,27 @@ -import ClientPage from "@/app/settings/(submenus)/security/page-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); - return ; + return ( +
+
+
+

Password

+

+ Update your password to keep your account secure. +

+
+ +
+ +
+
+ + +
+ ); } 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..056b27d64 --- /dev/null +++ b/frontend/src/features/account/settings/security/components/session-manager.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { toZonedTime } from "date-fns-tz"; +import { CircleQuestionMark, Laptop2Icon, SmartphoneIcon } from "lucide-react"; + +import { type ActiveSession } from "@/lib/utils/api/types"; +import { cn } from "@/lib/utils/classname"; +import { formatTimeAgo } from "@/lib/utils/date-time-format"; + +export default function SessionManager({ + sessions, +}: { + sessions: ActiveSession[]; +}) { + const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + return ( +
+
+

Active Devices

+

+ If there are any devices you don{"'"}t recognize, remove it and change + your password. +

+
+ + {sessions.map((session) => { + const lastUsedLocal = toZonedTime( + new Date(session.last_used + "Z"), + userTimeZone, + ); + const lastUsedSecondsAgo = + (Date.now() - lastUsedLocal.getTime()) / 1000; + + return ( +
+
+
+ {session.device_type === "desktop" ? ( + + ) : session.device_type === "smartphone" ? ( + + ) : ( + + )} +
+
+
+ {!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)}`} +
+
+
+
+ ); + })} +
+ ); +} From be64815f90300fac2ae3855acc68de9d186def31 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 13 Aug 2026 18:18:49 -0400 Subject: [PATCH 06/21] Update subtitle --- .../account/settings/security/components/session-manager.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/features/account/settings/security/components/session-manager.tsx b/frontend/src/features/account/settings/security/components/session-manager.tsx index 056b27d64..cd0234232 100644 --- a/frontend/src/features/account/settings/security/components/session-manager.tsx +++ b/frontend/src/features/account/settings/security/components/session-manager.tsx @@ -19,8 +19,8 @@ export default function SessionManager({

Active Devices

- If there are any devices you don{"'"}t recognize, remove it and change - your password. + If there are any devices you don{"'"}t recognize, remove them and + change your password.

From e292563c8a216a81ead015f2ae8fb7a2d697288b Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 13 Aug 2026 18:34:49 -0400 Subject: [PATCH 07/21] Update active sessions endpoint return type --- backend/api/account/serializers.py | 5 +++-- backend/api/account/views.py | 25 ++++++++++++++++++++++--- frontend/src/lib/utils/api/types.ts | 3 ++- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/backend/api/account/serializers.py b/backend/api/account/serializers.py index 7eccff07c..9515969f6 100644 --- a/backend/api/account/serializers.py +++ b/backend/api/account/serializers.py @@ -30,8 +30,9 @@ class ActiveSessionSerializer(serializers.Serializer): 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 b24fdf6d6..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,7 +108,8 @@ 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 = { @@ -124,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/lib/utils/api/types.ts b/frontend/src/lib/utils/api/types.ts index 24229277f..ad5b8e825 100644 --- a/frontend/src/lib/utils/api/types.ts +++ b/frontend/src/lib/utils/api/types.ts @@ -147,7 +147,8 @@ export type ActiveSession = { } export type ActiveSessionList = { - sessions: ActiveSession[]; + current_session: ActiveSession; + other_sessions: ActiveSession[]; } export type SessionId = { From ce2c5a99f3d053c5ebd0da197f5ad3ea39e534ba Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 13 Aug 2026 18:37:59 -0400 Subject: [PATCH 08/21] Update session data and display --- .../app/settings/(submenus)/security/page.tsx | 2 +- .../security/components/session-manager.tsx | 108 ++++++++++-------- 2 files changed, 63 insertions(+), 47 deletions(-) diff --git a/frontend/src/app/settings/(submenus)/security/page.tsx b/frontend/src/app/settings/(submenus)/security/page.tsx index 97b539a90..a434192fe 100644 --- a/frontend/src/app/settings/(submenus)/security/page.tsx +++ b/frontend/src/app/settings/(submenus)/security/page.tsx @@ -21,7 +21,7 @@ export default async 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 index cd0234232..ab6b38aff 100644 --- a/frontend/src/features/account/settings/security/components/session-manager.tsx +++ b/frontend/src/features/account/settings/security/components/session-manager.tsx @@ -3,14 +3,14 @@ import { toZonedTime } from "date-fns-tz"; import { CircleQuestionMark, Laptop2Icon, SmartphoneIcon } from "lucide-react"; -import { type ActiveSession } from "@/lib/utils/api/types"; +import { ActiveSessionList, type ActiveSession } from "@/lib/utils/api/types"; import { cn } from "@/lib/utils/classname"; import { formatTimeAgo } from "@/lib/utils/date-time-format"; export default function SessionManager({ sessions, }: { - sessions: ActiveSession[]; + sessions: ActiveSessionList; }) { const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; @@ -24,52 +24,68 @@ export default function SessionManager({

- {sessions.map((session) => { - const lastUsedLocal = toZonedTime( - new Date(session.last_used + "Z"), - userTimeZone, - ); - const lastUsedSecondsAgo = - (Date.now() - lastUsedLocal.getTime()) / 1000; +
+ - return ( -
-
-
- {session.device_type === "desktop" ? ( - - ) : session.device_type === "smartphone" ? ( - - ) : ( - - )} -
-
-
- {!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)}`} -
-
-
+
+ +
+ {sessions.other_sessions.map((session) => ( + + ))} +
+
+
+ ); +} + +function Session({ + session, + userTz, +}: { + session: ActiveSession; + userTz: string; +}) { + const lastUsedLocal = toZonedTime(new Date(session.last_used + "Z"), userTz); + const lastUsedSecondsAgo = (Date.now() - lastUsedLocal.getTime()) / 1000; + + return ( +
+
+
+ {session.device_type === "desktop" ? ( + + ) : session.device_type === "smartphone" ? ( + + ) : ( + + )} +
+ +
+
+ {!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)}`}
- ); - })} +
+
); } From b067a8c030ef797f25aadb47c6cf9c01c249359b Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 13 Aug 2026 18:38:48 -0400 Subject: [PATCH 09/21] Lighten accent on current session --- .../account/settings/security/components/session-manager.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/features/account/settings/security/components/session-manager.tsx b/frontend/src/features/account/settings/security/components/session-manager.tsx index ab6b38aff..7d5ca1f20 100644 --- a/frontend/src/features/account/settings/security/components/session-manager.tsx +++ b/frontend/src/features/account/settings/security/components/session-manager.tsx @@ -59,7 +59,7 @@ function Session({
{session.device_type === "desktop" ? ( From b1a69061f034ee55bbcc9f0bf069b589a145f29e Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 13 Aug 2026 21:19:14 -0400 Subject: [PATCH 10/21] Add device type value typing --- frontend/src/lib/utils/api/types.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/utils/api/types.ts b/frontend/src/lib/utils/api/types.ts index ad5b8e825..c36f3ecc7 100644 --- a/frontend/src/lib/utils/api/types.ts +++ b/frontend/src/lib/utils/api/types.ts @@ -136,7 +136,23 @@ 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; From 2e1b6751fd587d328c81c84b596c751426d0b0d4 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 13 Aug 2026 21:19:50 -0400 Subject: [PATCH 11/21] Add collapsible session information --- .../security/components/session-manager.tsx | 125 +++++++++++++----- 1 file changed, 95 insertions(+), 30 deletions(-) diff --git a/frontend/src/features/account/settings/security/components/session-manager.tsx b/frontend/src/features/account/settings/security/components/session-manager.tsx index 7d5ca1f20..ec020de0c 100644 --- a/frontend/src/features/account/settings/security/components/session-manager.tsx +++ b/frontend/src/features/account/settings/security/components/session-manager.tsx @@ -1,11 +1,20 @@ "use client"; +import { useState } from "react"; + +import * as Collapsible from "@radix-ui/react-collapsible"; import { toZonedTime } from "date-fns-tz"; -import { CircleQuestionMark, Laptop2Icon, SmartphoneIcon } from "lucide-react"; +import { + ChevronDownIcon, + CircleQuestionMark, + Laptop2Icon, + SmartphoneIcon, +} from "lucide-react"; import { ActiveSessionList, type ActiveSession } from "@/lib/utils/api/types"; import { cn } from "@/lib/utils/classname"; import { formatTimeAgo } from "@/lib/utils/date-time-format"; +import EmptyButton from "@/features/button/components/empty"; export default function SessionManager({ sessions, @@ -50,42 +59,98 @@ function Session({ session: ActiveSession; userTz: string; }) { + const [isOpen, setIsOpen] = useState(false); + const lastUsedLocal = toZonedTime(new Date(session.last_used + "Z"), userTz); const lastUsedSecondsAgo = (Date.now() - lastUsedLocal.getTime()) / 1000; return ( -
-
-
- {session.device_type === "desktop" ? ( - - ) : session.device_type === "smartphone" ? ( - - ) : ( - - )} -
+
+ + +
+
+ {session.device_type === "desktop" ? ( + + ) : session.device_type === "smartphone" ? ( + + ) : ( + + )} +
-
-
- {!session.os_name && !session.client_name - ? "Unknown" - : (session.os_name || "Unknown Device") + - " • " + - (session.client_name || "Unknown Browser")} +
+
+ {!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.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{" "} + {toZonedTime( + new Date(session.created_at + "Z"), + userTz, + ).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "numeric", + })} +

+
+ {!session.is_current && ( + + )} +
+
+
); } From 28cb62fec8e8a06fdcf3db8f0949b62c7d2f14ff Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 13 Aug 2026 21:33:23 -0400 Subject: [PATCH 12/21] Add tablet device icon --- .../account/settings/security/components/session-manager.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/features/account/settings/security/components/session-manager.tsx b/frontend/src/features/account/settings/security/components/session-manager.tsx index ec020de0c..fd2331a03 100644 --- a/frontend/src/features/account/settings/security/components/session-manager.tsx +++ b/frontend/src/features/account/settings/security/components/session-manager.tsx @@ -7,8 +7,10 @@ import { toZonedTime } from "date-fns-tz"; import { ChevronDownIcon, CircleQuestionMark, + EllipsisIcon, Laptop2Icon, SmartphoneIcon, + TabletIcon, } from "lucide-react"; import { ActiveSessionList, type ActiveSession } from "@/lib/utils/api/types"; @@ -79,6 +81,8 @@ function Session({ ) : session.device_type === "smartphone" ? ( + ) : session.device_type === "tablet" ? ( + ) : ( )} From 845652caeccbfb2d2a0a05704795ac400012229f Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 13 Aug 2026 21:37:22 -0400 Subject: [PATCH 13/21] Clean up layout and imports --- .../security/components/session-manager.tsx | 27 ++++++++----------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/frontend/src/features/account/settings/security/components/session-manager.tsx b/frontend/src/features/account/settings/security/components/session-manager.tsx index fd2331a03..7b89f89cb 100644 --- a/frontend/src/features/account/settings/security/components/session-manager.tsx +++ b/frontend/src/features/account/settings/security/components/session-manager.tsx @@ -7,16 +7,15 @@ import { toZonedTime } from "date-fns-tz"; import { ChevronDownIcon, CircleQuestionMark, - EllipsisIcon, Laptop2Icon, SmartphoneIcon, TabletIcon, } from "lucide-react"; +import EmptyButton from "@/features/button/components/empty"; import { ActiveSessionList, type ActiveSession } from "@/lib/utils/api/types"; import { cn } from "@/lib/utils/classname"; import { formatTimeAgo } from "@/lib/utils/date-time-format"; -import EmptyButton from "@/features/button/components/empty"; export default function SessionManager({ sessions, @@ -35,20 +34,16 @@ export default function SessionManager({

-
- - -
- -
- {sessions.other_sessions.map((session) => ( - - ))} -
+ +
+
+ {sessions.other_sessions.map((session) => ( + + ))}
); From 574bfe591518aad10156a28f613b72c3b09cb3f3 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 13 Aug 2026 21:39:10 -0400 Subject: [PATCH 14/21] Add cursor pointer to collapsible trigger --- .../settings/security/components/session-manager.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/features/account/settings/security/components/session-manager.tsx b/frontend/src/features/account/settings/security/components/session-manager.tsx index 7b89f89cb..822317547 100644 --- a/frontend/src/features/account/settings/security/components/session-manager.tsx +++ b/frontend/src/features/account/settings/security/components/session-manager.tsx @@ -64,7 +64,11 @@ function Session({ return (
- +
Date: Thu, 13 Aug 2026 21:41:42 -0400 Subject: [PATCH 15/21] Move created at conversion logic --- .../settings/security/components/session-manager.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend/src/features/account/settings/security/components/session-manager.tsx b/frontend/src/features/account/settings/security/components/session-manager.tsx index 822317547..4415f3dee 100644 --- a/frontend/src/features/account/settings/security/components/session-manager.tsx +++ b/frontend/src/features/account/settings/security/components/session-manager.tsx @@ -61,6 +61,11 @@ function Session({ const lastUsedLocal = toZonedTime(new Date(session.last_used + "Z"), userTz); const lastUsedSecondsAgo = (Date.now() - lastUsedLocal.getTime()) / 1000; + const createdAtLocal = toZonedTime( + new Date(session.created_at + "Z"), + userTz, + ); + return (
@@ -133,10 +138,7 @@ function Session({ )}

Logged in on{" "} - {toZonedTime( - new Date(session.created_at + "Z"), - userTz, - ).toLocaleString(undefined, { + {createdAtLocal.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", From 0b5db4ac0fd020b21162fb355bcfad099dc0d99c Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 13 Aug 2026 22:27:00 -0400 Subject: [PATCH 16/21] Add session removal --- .../security/components/session-manager.tsx | 85 ++++++++++++++++--- .../settings/security/remove-session.tsx | 22 +++++ frontend/src/lib/messages.ts | 1 + 3 files changed, 97 insertions(+), 11 deletions(-) create mode 100644 frontend/src/features/account/settings/security/remove-session.tsx diff --git a/frontend/src/features/account/settings/security/components/session-manager.tsx b/frontend/src/features/account/settings/security/components/session-manager.tsx index 4415f3dee..b7649c3f0 100644 --- a/frontend/src/features/account/settings/security/components/session-manager.tsx +++ b/frontend/src/features/account/settings/security/components/session-manager.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { startTransition, useOptimistic, useRef, useState } from "react"; import * as Collapsible from "@radix-ui/react-collapsible"; import { toZonedTime } from "date-fns-tz"; @@ -12,7 +12,10 @@ import { TabletIcon, } from "lucide-react"; -import EmptyButton from "@/features/button/components/empty"; +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"; @@ -24,27 +27,84 @@ export default function SessionManager({ }) { const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const [optimisticSessions, removeOptimisticSession] = useOptimistic( + sessions, + (state, sessionToRemove: string) => { + return { + current_session: state.current_session, + other_sessions: state.other_sessions.filter( + (s) => s.public_id !== sessionToRemove, + ), + }; + }, + ); + + const [confirmationOpen, setConfirmationOpen] = useState(false); + const sessionToRemove = useRef(null); + const { addToast } = useToast(); + + const handleRemoveSession = async (publicId: string) => { + // Immediate UI update + startTransition(() => { + removeOptimisticSession(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; + setConfirmationOpen(true); + }; + return (

-

Active Devices

+

Active Sessions

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

- +
- {sessions.other_sessions.map((session) => ( + {optimisticSessions.other_sessions.map((session) => ( { + onRemoveSession(session.public_id); + }} /> ))}
+ + { + if (!sessionToRemove.current) return false; + handleRemoveSession(sessionToRemove.current); + return true; + }} + />
); } @@ -52,9 +112,11 @@ export default function SessionManager({ function Session({ session, userTz, + onRemove, }: { session: ActiveSession; userTz: string; + onRemove?: () => void; }) { const [isOpen, setIsOpen] = useState(false); @@ -146,11 +208,12 @@ function Session({ })}

- {!session.is_current && ( - )}
diff --git a/frontend/src/features/account/settings/security/remove-session.tsx b/frontend/src/features/account/settings/security/remove-session.tsx new file mode 100644 index 000000000..dcd89cb8b --- /dev/null +++ b/frontend/src/features/account/settings/security/remove-session.tsx @@ -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..b706d17a7 100644 --- a/frontend/src/lib/messages.ts +++ b/frontend/src/lib/messages.ts @@ -49,6 +49,7 @@ 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_ACCOUNT_DELETE: "Account deleted successfully. Have a nice day!", // copy link messages From 73e7ee6fbe7a13eea56e6676460e3e5cde019209 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 13 Aug 2026 22:36:15 -0400 Subject: [PATCH 17/21] Add updating last used timestamps --- .../security/components/session-manager.tsx | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/frontend/src/features/account/settings/security/components/session-manager.tsx b/frontend/src/features/account/settings/security/components/session-manager.tsx index b7649c3f0..2c43796ef 100644 --- a/frontend/src/features/account/settings/security/components/session-manager.tsx +++ b/frontend/src/features/account/settings/security/components/session-manager.tsx @@ -1,6 +1,12 @@ "use client"; -import { startTransition, useOptimistic, useRef, useState } from "react"; +import { + startTransition, + useEffect, + useOptimistic, + useRef, + useState, +} from "react"; import * as Collapsible from "@radix-ui/react-collapsible"; import { toZonedTime } from "date-fns-tz"; @@ -25,6 +31,15 @@ export default function SessionManager({ }: { 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, removeOptimisticSession] = useOptimistic( @@ -76,6 +91,7 @@ export default function SessionManager({
@@ -84,6 +100,7 @@ export default function SessionManager({ { onRemoveSession(session.public_id); @@ -111,17 +128,19 @@ export default function SessionManager({ 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 = (Date.now() - lastUsedLocal.getTime()) / 1000; + const lastUsedSecondsAgo = (now - lastUsedLocal.getTime()) / 1000; const createdAtLocal = toZonedTime( new Date(session.created_at + "Z"), From 0812706dfb830b9fd745277d0b33f1bf45332995 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 13 Aug 2026 22:41:06 -0400 Subject: [PATCH 18/21] Change remove session to TypeScript file --- .../settings/security/{remove-session.tsx => remove-session.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename frontend/src/features/account/settings/security/{remove-session.tsx => remove-session.ts} (100%) diff --git a/frontend/src/features/account/settings/security/remove-session.tsx b/frontend/src/features/account/settings/security/remove-session.ts similarity index 100% rename from frontend/src/features/account/settings/security/remove-session.tsx rename to frontend/src/features/account/settings/security/remove-session.ts From fbd752040a7694650ca2649ef6861e060a05f151 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 13 Aug 2026 22:49:09 -0400 Subject: [PATCH 19/21] Add session pruning --- .../security/components/session-manager.tsx | 77 +++++++++++++++---- .../settings/security/prune-sessions.ts | 20 +++++ frontend/src/lib/messages.ts | 1 + 3 files changed, 85 insertions(+), 13 deletions(-) create mode 100644 frontend/src/features/account/settings/security/prune-sessions.ts diff --git a/frontend/src/features/account/settings/security/components/session-manager.tsx b/frontend/src/features/account/settings/security/components/session-manager.tsx index 2c43796ef..19a44548e 100644 --- a/frontend/src/features/account/settings/security/components/session-manager.tsx +++ b/frontend/src/features/account/settings/security/components/session-manager.tsx @@ -25,6 +25,9 @@ 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"; +import { pruneSessions } from "@/features/account/settings/security/prune-sessions"; + +type SessionAction = { type: "remove"; publicId: string } | { type: "prune" }; export default function SessionManager({ sessions, @@ -42,26 +45,53 @@ export default function SessionManager({ const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; - const [optimisticSessions, removeOptimisticSession] = useOptimistic( + const [optimisticSessions, setOptimisticSessions] = useOptimistic( sessions, - (state, sessionToRemove: string) => { - return { - current_session: state.current_session, - other_sessions: state.other_sessions.filter( - (s) => s.public_id !== sessionToRemove, - ), - }; + (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 [confirmationOpen, setConfirmationOpen] = useState(false); + 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(() => { - removeOptimisticSession(publicId); + setOptimisticSessions({ type: "remove", publicId }); }); // Server Action @@ -76,7 +106,7 @@ export default function SessionManager({ const onRemoveSession = (publicId: string) => { sessionToRemove.current = publicId; - setConfirmationOpen(true); + setRemoveConfirmationOpen(true); }; return ( @@ -109,19 +139,40 @@ export default function SessionManager({ ))}
+ { + setPruneConfirmationOpen(true); + }} + /> + { if (!sessionToRemove.current) return false; handleRemoveSession(sessionToRemove.current); return true; }} /> + { + handlePruneSessions(); + return true; + }} + />
); } 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/lib/messages.ts b/frontend/src/lib/messages.ts index b706d17a7..4fe72ebee 100644 --- a/frontend/src/lib/messages.ts +++ b/frontend/src/lib/messages.ts @@ -50,6 +50,7 @@ export const MESSAGES = { 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 From 0155de9162be4b0a3af8311ad36be35066508448 Mon Sep 17 00:00:00 2001 From: jzgom067 Date: Thu, 13 Aug 2026 22:53:01 -0400 Subject: [PATCH 20/21] Add empty other sessions layout --- .../security/components/session-manager.tsx | 50 +++++++++++-------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/frontend/src/features/account/settings/security/components/session-manager.tsx b/frontend/src/features/account/settings/security/components/session-manager.tsx index 19a44548e..49c6905e4 100644 --- a/frontend/src/features/account/settings/security/components/session-manager.tsx +++ b/frontend/src/features/account/settings/security/components/session-manager.tsx @@ -18,6 +18,7 @@ import { 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"; @@ -25,7 +26,6 @@ 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"; -import { pruneSessions } from "@/features/account/settings/security/prune-sessions"; type SessionAction = { type: "remove"; publicId: string } | { type: "prune" }; @@ -125,28 +125,36 @@ export default function SessionManager({ userTz={userTimeZone} />
-
- {optimisticSessions.other_sessions.map((session) => ( - { - onRemoveSession(session.public_id); + {optimisticSessions.other_sessions.length > 0 ? ( + <> +
+ {optimisticSessions.other_sessions.map((session) => ( + { + onRemoveSession(session.public_id); + }} + /> + ))} +
+ + { + setPruneConfirmationOpen(true); }} /> - ))} -
- - { - setPruneConfirmationOpen(true); - }} - /> + + ) : ( +

+ No other active sessions. +

+ )} Date: Fri, 14 Aug 2026 00:15:39 -0400 Subject: [PATCH 21/21] Remove accidental whitespace --- frontend/src/lib/utils/api/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/utils/api/types.ts b/frontend/src/lib/utils/api/types.ts index c36f3ecc7..77e82e1a9 100644 --- a/frontend/src/lib/utils/api/types.ts +++ b/frontend/src/lib/utils/api/types.ts @@ -144,7 +144,7 @@ export type ActiveSession = { | "console" | "tv" | "car browser" - | "smart display " + | "smart display" | "camera" | "portable media player" | "phablet"