From 7cfcf3dbee58fc06d35492bca60a65561915f00d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sat, 13 Jun 2026 01:07:05 +0300 Subject: [PATCH 1/8] feat: add sessions endpoint client and hooks --- src/hooks/api/use-sessions.ts | 39 +++++++++++++++++++++++++++++++ src/lib/api/endpoints/sessions.ts | 21 +++++++++++++++++ src/lib/types/session.ts | 36 ++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 src/hooks/api/use-sessions.ts create mode 100644 src/lib/api/endpoints/sessions.ts create mode 100644 src/lib/types/session.ts diff --git a/src/hooks/api/use-sessions.ts b/src/hooks/api/use-sessions.ts new file mode 100644 index 0000000..5fae06d --- /dev/null +++ b/src/hooks/api/use-sessions.ts @@ -0,0 +1,39 @@ +import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { sessionsApi } from "@/lib/api/endpoints/sessions"; +import type { SessionListParams } from "@/lib/types/session"; + +export const sessionKeys = { + all: ["sessions"] as const, + list: (params?: SessionListParams) => ["sessions", "list", params ?? {}] as const, +}; + +export const useSessions = (params?: SessionListParams) => + useQuery({ + queryKey: sessionKeys.list(params), + queryFn: () => sessionsApi.list(params), + placeholderData: keepPreviousData, + }); + +// Success toasts come from the backend `message` via the axios response +// interceptor — no manual toast in either mutation. + +export const useRevokeSession = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => sessionsApi.revoke(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: sessionKeys.all }); + }, + }); +}; + +export const useRevokeOtherSessions = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => sessionsApi.revokeOthers(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: sessionKeys.all }); + }, + }); +}; diff --git a/src/lib/api/endpoints/sessions.ts b/src/lib/api/endpoints/sessions.ts new file mode 100644 index 0000000..f02db26 --- /dev/null +++ b/src/lib/api/endpoints/sessions.ts @@ -0,0 +1,21 @@ +import api from "@/lib/api/api"; +import { pruneParams } from "@/lib/api/endpoints/admin"; +import type { MessageResponse } from "@/lib/types/auth"; +import type { + SessionListParams, + SessionListResponse, + SessionsRevokedResponse, +} from "@/lib/types/session"; + +export const sessionsApi = { + list: (params?: SessionListParams): Promise => + api.get("/users/me/sessions", { + params: pruneParams(params), + }), + + revoke: (id: string): Promise => + api.delete(`/users/me/sessions/${id}`), + + revokeOthers: (): Promise => + api.delete("/users/me/sessions"), +}; diff --git a/src/lib/types/session.ts b/src/lib/types/session.ts new file mode 100644 index 0000000..2cf62bf --- /dev/null +++ b/src/lib/types/session.ts @@ -0,0 +1,36 @@ +// --- Core shapes ------------------------------------------------------------ + +// One active login session as returned by the backend. `browser`/`os` are +// parsed server-side from the User-Agent; null means "unknown device". +export interface SessionItem { + id: string; + browser: string | null; + os: string | null; + ip_address: string | null; + created_at: string; + last_used_at: string; + // True only on /users/me/sessions for the session making the request; + // always false in the admin view. + is_current: boolean; +} + +export interface SessionListResponse { + data: SessionItem[]; + total: number; + skip: number; + limit: number; +} + +// --- Query params ----------------------------------------------------------- + +export interface SessionListParams { + skip?: number; + limit?: number; +} + +// --- Response wrappers ------------------------------------------------------ + +export interface SessionsRevokedResponse { + revoked: number; + message: string; +} From 35441d9f4bf7670e953eb26f35e241568175a5db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sat, 13 Jun 2026 01:17:35 +0300 Subject: [PATCH 2/8] feat: add sessions management section --- src/components/profile/SecurityTab.tsx | 3 + src/components/profile/SessionsSection.tsx | 259 +++++++++++++++++++++ src/i18n/locales/en/profile.json | 25 ++ src/i18n/locales/tr/profile.json | 25 ++ src/lib/types/session.ts | 2 +- 5 files changed, 313 insertions(+), 1 deletion(-) create mode 100644 src/components/profile/SessionsSection.tsx diff --git a/src/components/profile/SecurityTab.tsx b/src/components/profile/SecurityTab.tsx index 01cc842..8750822 100644 --- a/src/components/profile/SecurityTab.tsx +++ b/src/components/profile/SecurityTab.tsx @@ -5,6 +5,7 @@ import { Lock, Shield, CheckCircle2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import { DangerZone } from "@/components/profile/DangerZone"; +import { SessionsSection } from "@/components/profile/SessionsSection"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; @@ -173,6 +174,8 @@ export const SecurityTab = ({ showDangerZone = true }: SecurityTabProps) => { + + {showDangerZone ? : null} ); diff --git a/src/components/profile/SessionsSection.tsx b/src/components/profile/SessionsSection.tsx new file mode 100644 index 0000000..694cf7d --- /dev/null +++ b/src/components/profile/SessionsSection.tsx @@ -0,0 +1,259 @@ +"use client"; + +import { useState, type ReactNode } from "react"; + +import { Modal } from "antd"; +import { LogOut, MonitorSmartphone, Smartphone } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useRevokeOtherSessions, useRevokeSession, useSessions } from "@/hooks/api/use-sessions"; +import { formatDateTime } from "@/lib/format-date"; +import type { SessionItem } from "@/lib/types/session"; +import { cn } from "@/lib/utils"; + +const PAGE_SIZE = 10; + +const MOBILE_OS = new Set(["iOS", "iPadOS", "Android"]); + +const DeviceIcon = ({ os }: { os: string | null }) => { + const Icon = os && MOBILE_OS.has(os) ? Smartphone : MonitorSmartphone; + return ( + + + + ); +}; + +interface ConfirmRevokeModalProps { + open: boolean; + busy: boolean; + title: string; + description: string; + confirmLabel: string; + cancelLabel: string; + onCancel: () => void; + onConfirm: () => void; +} + +const ConfirmRevokeModal = ({ + open, + busy, + title, + description, + confirmLabel, + cancelLabel, + onCancel, + onConfirm, +}: ConfirmRevokeModalProps) => ( + { + if (!busy) onCancel(); + }} + title={title} + footer={null} + destroyOnHidden + centered + > +

{description}

+
+ + +
+
+); + +interface SessionRowProps { + session: SessionItem; + disabled: boolean; + onRevoke: (session: SessionItem) => void; +} + +const SessionRow = ({ session, disabled, onRevoke }: SessionRowProps) => { + const { t } = useTranslation("profile"); + + const deviceLabel = + session.browser && session.os + ? t("sessions.deviceLabel", { browser: session.browser, os: session.os }) + : (session.browser ?? session.os ?? t("sessions.unknownDevice")); + + return ( +
  • + +
    +

    + {deviceLabel} + {session.is_current ? ( + + {t("sessions.currentBadge")} + + ) : null} +

    +

    + {t("sessions.lastActive", { date: formatDateTime(session.last_used_at) })} +

    +
    + {!session.is_current ? ( + + ) : null} +
  • + ); +}; + +export const SessionsSection = () => { + const { t } = useTranslation("profile"); + // "Load more" grows the page instead of paginating — a person's device + // list is short, and keeping every row visible avoids losing context. + const [limit, setLimit] = useState(PAGE_SIZE); + const { data, isLoading } = useSessions({ limit }); + const { mutate: revokeSession, isPending: isRevoking } = useRevokeSession(); + const { mutate: revokeOthers, isPending: isRevokingOthers } = useRevokeOtherSessions(); + const [confirmTarget, setConfirmTarget] = useState(null); + const [confirmOthersOpen, setConfirmOthersOpen] = useState(false); + + const sessions = data?.data ?? []; + const total = data?.total ?? 0; + const hasOthers = sessions.some((s) => !s.is_current); + const busy = isRevoking || isRevokingOthers; + + const handleRevoke = () => { + if (!confirmTarget) return; + revokeSession(confirmTarget.id, { + onSettled: () => { + setConfirmTarget(null); + }, + }); + }; + + const handleRevokeOthers = () => { + revokeOthers(undefined, { + onSettled: () => { + setConfirmOthersOpen(false); + }, + }); + }; + + let content: ReactNode; + if (isLoading) { + content = ( +
    + {[0, 1].map((row) => ( + + ))} +
    + ); + } else if (sessions.length === 0) { + content = ( +

    {t("sessions.empty")}

    + ); + } else { + content = ( +
      + {sessions.map((session) => ( + + ))} +
    + ); + } + + return ( + + + + + + + {t("sessions.title")} + + {t("sessions.description")} + + + {content} + + {!isLoading && total > sessions.length ? ( +
    + +
    + ) : null} + + {hasOthers ? ( +
    + +
    + ) : null} +
    + + { + setConfirmTarget(null); + }} + onConfirm={handleRevoke} + /> + + { + setConfirmOthersOpen(false); + }} + onConfirm={handleRevokeOthers} + /> +
    + ); +}; diff --git a/src/i18n/locales/en/profile.json b/src/i18n/locales/en/profile.json index a16abe0..3aed225 100644 --- a/src/i18n/locales/en/profile.json +++ b/src/i18n/locales/en/profile.json @@ -31,6 +31,31 @@ "submit": "Update Password", "submitting": "Updating..." }, + "sessions": { + "title": "Active Sessions", + "description": "View and manage the devices signed in to your account", + "currentBadge": "This device", + "unknownDevice": "Unknown device", + "deviceLabel": "{{browser}} — {{os}}", + "signedIn": "Signed in: {{date}}", + "lastActive": "Last active: {{date}}", + "revoke": "Revoke", + "revokeOthers": "Sign out all other devices", + "loadMore": "Show more", + "empty": "No active sessions found", + "confirmRevoke": { + "title": "Revoke session", + "description": "This device will be signed out of your account immediately. Continue?", + "confirm": "Revoke Session", + "cancel": "Cancel" + }, + "confirmRevokeOthers": { + "title": "Sign out other devices", + "description": "Every session except this device will be terminated immediately. Continue?", + "confirm": "Sign Out All", + "cancel": "Cancel" + } + }, "avatar": { "title": "Profile Photo", "description": "Upload a photo to personalize your account" diff --git a/src/i18n/locales/tr/profile.json b/src/i18n/locales/tr/profile.json index e86ad73..b74305f 100644 --- a/src/i18n/locales/tr/profile.json +++ b/src/i18n/locales/tr/profile.json @@ -31,6 +31,31 @@ "submit": "Şifreyi Güncelle", "submitting": "Güncelleniyor..." }, + "sessions": { + "title": "Aktif Oturumlar", + "description": "Hesabına giriş yapılmış cihazları görüntüle ve yönet", + "currentBadge": "Bu cihaz", + "unknownDevice": "Bilinmeyen cihaz", + "deviceLabel": "{{browser}} — {{os}}", + "signedIn": "Giriş: {{date}}", + "lastActive": "Son aktivite: {{date}}", + "revoke": "Sonlandır", + "revokeOthers": "Diğer tüm cihazlardan çıkış yap", + "loadMore": "Daha fazla göster", + "empty": "Aktif oturum bulunamadı", + "confirmRevoke": { + "title": "Oturumu sonlandır", + "description": "Bu cihaz hesabından anında çıkış yapacak. Devam etmek istiyor musun?", + "confirm": "Oturumu Sonlandır", + "cancel": "Vazgeç" + }, + "confirmRevokeOthers": { + "title": "Diğer cihazlardan çıkış yap", + "description": "Bu cihaz dışındaki tüm oturumlar anında sonlandırılacak. Devam etmek istiyor musun?", + "confirm": "Hepsini Sonlandır", + "cancel": "Vazgeç" + } + }, "avatar": { "title": "Profil Fotoğrafı", "description": "Hesabını kişiselleştirmek için bir fotoğraf yükle" diff --git a/src/lib/types/session.ts b/src/lib/types/session.ts index 2cf62bf..01cbd29 100644 --- a/src/lib/types/session.ts +++ b/src/lib/types/session.ts @@ -2,11 +2,11 @@ // One active login session as returned by the backend. `browser`/`os` are // parsed server-side from the User-Agent; null means "unknown device". +// The session's IP address intentionally never leaves the backend. export interface SessionItem { id: string; browser: string | null; os: string | null; - ip_address: string | null; created_at: string; last_used_at: string; // True only on /users/me/sessions for the session making the request; From db2f21bd757c81e7b1ed78a40010b670b7938423 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sat, 13 Jun 2026 01:26:43 +0300 Subject: [PATCH 3/8] feat: user sessions card with revoke-all --- .../users/[id]/UserDetailContent.tsx | 5 + src/components/admin/UserSessionsCard.tsx | 157 ++++++++++++++++++ src/hooks/api/use-admin.ts | 31 ++++ src/hooks/use-permissions.ts | 2 + src/i18n/locales/en/admin.json | 16 ++ src/i18n/locales/tr/admin.json | 16 ++ src/lib/api/endpoints/admin.ts | 13 ++ src/lib/types/permissions.ts | 1 + 8 files changed, 241 insertions(+) create mode 100644 src/components/admin/UserSessionsCard.tsx diff --git a/src/app/[locale]/admin/(protected)/users/[id]/UserDetailContent.tsx b/src/app/[locale]/admin/(protected)/users/[id]/UserDetailContent.tsx index c974f18..23f8270 100644 --- a/src/app/[locale]/admin/(protected)/users/[id]/UserDetailContent.tsx +++ b/src/app/[locale]/admin/(protected)/users/[id]/UserDetailContent.tsx @@ -16,12 +16,14 @@ import { UserAvatarCard } from "@/components/admin/UserAvatarCard"; import { UserDangerZone } from "@/components/admin/UserDangerZone"; import { UserEditForm } from "@/components/admin/UserEditForm"; import { UserOverviewCard } from "@/components/admin/UserOverviewCard"; +import { UserSessionsCard } from "@/components/admin/UserSessionsCard"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { useAdminUser, useAdminUserActivities, useUpdateAdminUser } from "@/hooks/api/use-admin"; import { useUserActions, type UserActionKind } from "@/hooks/api/use-user-actions"; import { useCanDeleteUsers, + useCanManageUserSessions, useCanResetUserPassword, useCanSuspendUsers, useCanWriteUsers, @@ -46,6 +48,7 @@ export function UserDetailContent({ userId }: { userId: string }) { const { run, isLoading: isActionLoading } = useUserActions(); const canWrite = useCanWriteUsers(); + const canManageSessions = useCanManageUserSessions(); const dangerCaps = { canResetPassword: useCanResetUserPassword(), canSuspend: useCanSuspendUsers(), @@ -146,6 +149,8 @@ export function UserDetailContent({ userId }: { userId: string }) { + {canManageSessions ? : null} + { + const Icon = os && MOBILE_OS.has(os) ? Smartphone : MonitorSmartphone; + return ( + + + + ); +}; + +const SessionRow = ({ session }: { session: SessionItem }) => { + const { t } = useTranslation("admin"); + + const deviceLabel = + session.browser && session.os + ? t("userDetail.sessions.deviceLabel", { browser: session.browser, os: session.os }) + : (session.browser ?? session.os ?? t("userDetail.sessions.unknownDevice")); + + return ( +
  • + +
    +

    {deviceLabel}

    +

    + {t("userDetail.sessions.lastActive", { date: formatDateTime(session.last_used_at) })} +

    +
    +
  • + ); +}; + +export function UserSessionsCard({ userId }: { userId: string }) { + const { t } = useTranslation("admin"); + const [limit, setLimit] = useState(PAGE_SIZE); + const { data, isLoading } = useAdminUserSessions(userId, { limit }); + const { mutate: revokeAll, isPending } = useRevokeAdminUserSessions(); + const [confirmOpen, setConfirmOpen] = useState(false); + + const sessions = data?.data ?? []; + const total = data?.total ?? 0; + + const handleRevokeAll = () => { + revokeAll(userId, { + onSettled: () => { + setConfirmOpen(false); + }, + }); + }; + + let content: ReactNode; + if (isLoading) { + content = ; + } else if (sessions.length === 0) { + content = ( +

    + {t("userDetail.sessions.empty")} +

    + ); + } else { + content = ( +
      + {sessions.map((session) => ( + + ))} +
    + ); + } + + return ( + + +
    + {t("userDetail.sessions.title")} + {t("userDetail.sessions.description")} +
    + {sessions.length > 0 ? ( + + ) : null} +
    + + {content} + + {!isLoading && total > sessions.length ? ( +
    + +
    + ) : null} +
    + + { + if (!isPending) setConfirmOpen(false); + }} + title={t("userDetail.sessions.confirm.title")} + footer={null} + destroyOnHidden + centered + > +

    + {t("userDetail.sessions.confirm.description")} +

    +
    + + +
    +
    +
    + ); +} diff --git a/src/hooks/api/use-admin.ts b/src/hooks/api/use-admin.ts index 31ba203..f1c82d3 100644 --- a/src/hooks/api/use-admin.ts +++ b/src/hooks/api/use-admin.ts @@ -12,6 +12,7 @@ import type { RootTransferConfirmPayload, RootTransferRequestPayload, } from "@/lib/types/admin"; +import type { SessionListParams } from "@/lib/types/session"; import { useAuthStore } from "@/stores/auth.store"; // Each resource type owns a distinct top-level segment so prefix-invalidation @@ -24,6 +25,9 @@ export const adminKeys = { user: (id: string) => ["admin", "user", id] as const, userActivities: (userId: string, opts?: { skip?: number; limit?: number }) => ["admin", "userActivities", userId, opts ?? {}] as const, + userSessionsPrefix: (userId: string) => ["admin", "userSessions", userId] as const, + userSessions: (userId: string, params?: SessionListParams) => + ["admin", "userSessions", userId, params ?? {}] as const, activitiesListPrefix: ["admin", "activitiesList"] as const, activitiesList: (params?: AdminActivityListParams) => ["admin", "activitiesList", params ?? {}] as const, @@ -141,6 +145,33 @@ export const useAdminUserActivities = ( placeholderData: keepPreviousData, }); +export const useAdminUserSessions = ( + userId: string | undefined, + params?: SessionListParams, + enabled = true, +) => + useQuery({ + queryKey: userId + ? adminKeys.userSessions(userId, params) + : ["admin", "userSessions", "invalid"], + queryFn: () => { + if (!userId) throw new Error("User ID is required"); + return adminApi.listUserSessions(userId, params); + }, + enabled: Boolean(userId) && enabled, + placeholderData: keepPreviousData, + }); + +export const useRevokeAdminUserSessions = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (userId: string) => adminApi.revokeUserSessions(userId), + onSuccess: (_data, userId) => { + queryClient.invalidateQueries({ queryKey: adminKeys.userSessionsPrefix(userId) }); + }, + }); +}; + // --- Admin / RBAC management (superadmin only) ----------------------------- export const useAdmins = (params?: AdminListParams, enabled = true) => diff --git a/src/hooks/use-permissions.ts b/src/hooks/use-permissions.ts index 3a57734..ab91527 100644 --- a/src/hooks/use-permissions.ts +++ b/src/hooks/use-permissions.ts @@ -49,6 +49,8 @@ export const useCanDeleteUsers = (): boolean => usePermissions().has(Permission. export const useCanSuspendUsers = (): boolean => usePermissions().has(Permission.UsersSuspend); export const useCanResetUserPassword = (): boolean => usePermissions().has(Permission.UsersPasswordReset); +export const useCanManageUserSessions = (): boolean => + usePermissions().has(Permission.UsersSessions); export const useCanReadFiles = (): boolean => usePermissions().has(Permission.FilesRead); export const useCanDeleteFiles = (): boolean => usePermissions().has(Permission.FilesDelete); diff --git a/src/i18n/locales/en/admin.json b/src/i18n/locales/en/admin.json index 4fc5e53..39a5696 100644 --- a/src/i18n/locales/en/admin.json +++ b/src/i18n/locales/en/admin.json @@ -228,6 +228,22 @@ "activityEmpty": "No recent activity.", "dangerTitle": "Danger zone", "dangerDescription": "These actions are audited and can't be undone by the target user.", + "sessions": { + "title": "Active sessions", + "description": "Devices currently signed in to this account.", + "deviceLabel": "{{browser}} — {{os}}", + "unknownDevice": "Unknown device", + "lastActive": "Last active: {{date}}", + "revokeAll": "Terminate all sessions", + "loadMore": "Show more", + "empty": "No active sessions.", + "confirm": { + "title": "Terminate all sessions?", + "description": "The user will be signed out of every device immediately and will need to log in again. This action is audited.", + "confirm": "Terminate All", + "cancel": "Cancel" + } + }, "fields": { "firstName": "First name", "lastName": "Last name", diff --git a/src/i18n/locales/tr/admin.json b/src/i18n/locales/tr/admin.json index c95675d..bea5a86 100644 --- a/src/i18n/locales/tr/admin.json +++ b/src/i18n/locales/tr/admin.json @@ -228,6 +228,22 @@ "activityEmpty": "Son aktivite yok.", "dangerTitle": "Tehlikeli işlemler", "dangerDescription": "Bu işlemler denetlenir ve kullanıcı tarafından geri alınamaz.", + "sessions": { + "title": "Aktif oturumlar", + "description": "Bu kullanıcının giriş yapılmış cihazları.", + "deviceLabel": "{{browser}} — {{os}}", + "unknownDevice": "Bilinmeyen cihaz", + "lastActive": "Son aktivite: {{date}}", + "revokeAll": "Tüm oturumları sonlandır", + "loadMore": "Daha fazla göster", + "empty": "Aktif oturum yok.", + "confirm": { + "title": "Tüm oturumlar sonlandırılsın mı?", + "description": "Kullanıcı tüm cihazlarından anında çıkış yapacak ve yeniden giriş yapması gerekecek. Bu işlem denetlenir.", + "confirm": "Hepsini Sonlandır", + "cancel": "Vazgeç" + } + }, "fields": { "firstName": "Ad", "lastName": "Soyad", diff --git a/src/lib/api/endpoints/admin.ts b/src/lib/api/endpoints/admin.ts index 83430b9..6804e2c 100644 --- a/src/lib/api/endpoints/admin.ts +++ b/src/lib/api/endpoints/admin.ts @@ -18,6 +18,11 @@ import type { RootTransferRequestPayload, } from "@/lib/types/admin"; import type { MessageResponse } from "@/lib/types/auth"; +import type { + SessionListParams, + SessionListResponse, + SessionsRevokedResponse, +} from "@/lib/types/session"; // Drop undefined / empty-string keys so axios doesn't serialize them onto the // URL. Generic over the caller's param type so we don't have to widen admin @@ -69,6 +74,14 @@ export const adminApi = { { params: pruneParams(params) }, ), + listUserSessions: (userId: string, params?: SessionListParams): Promise => + api.get(`/admin/users/${userId}/sessions`, { + params: pruneParams(params), + }), + + revokeUserSessions: (userId: string): Promise => + api.delete(`/admin/users/${userId}/sessions`), + // Filters + pagination ride the POST body (the audit log query grew enough // filters that a body is cleaner than a long query string). listActivities: (params?: AdminActivityListParams): Promise => diff --git a/src/lib/types/permissions.ts b/src/lib/types/permissions.ts index 705b26d..49c7a67 100644 --- a/src/lib/types/permissions.ts +++ b/src/lib/types/permissions.ts @@ -8,6 +8,7 @@ export enum Permission { UsersDelete = "users:delete", UsersSuspend = "users:suspend", UsersPasswordReset = "users:password_reset", + UsersSessions = "users:sessions", FilesRead = "files:read", FilesDelete = "files:delete", SupportRead = "support:read", From 280e42f326f34566f9f3fe09b7092b6ee06179c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sat, 13 Jun 2026 01:57:10 +0300 Subject: [PATCH 4/8] feat: live logout on sessions_revoked --- src/app/[locale]/(protected)/layout.tsx | 8 +- .../users/[id]/UserDetailContent.tsx | 22 +-- src/components/admin/UserActivityCard.tsx | 53 ++++++++ src/components/common/AccountEventsBridge.tsx | 16 +++ .../__tests__/SessionsSection.test.tsx | 109 +++++++++++++++ .../__tests__/use-account-events.test.tsx | 125 ++++++++++++++++++ src/hooks/use-account-events.ts | 27 +++- src/i18n/locales/en/admin.json | 2 + src/i18n/locales/en/errors.json | 3 + src/i18n/locales/en/success.json | 5 + src/i18n/locales/tr/admin.json | 2 + src/i18n/locales/tr/errors.json | 3 + src/i18n/locales/tr/success.json | 5 + src/schemas/account.ts | 2 +- 14 files changed, 354 insertions(+), 28 deletions(-) create mode 100644 src/components/admin/UserActivityCard.tsx create mode 100644 src/components/common/AccountEventsBridge.tsx create mode 100644 src/components/profile/__tests__/SessionsSection.test.tsx create mode 100644 src/hooks/__tests__/use-account-events.test.tsx diff --git a/src/app/[locale]/(protected)/layout.tsx b/src/app/[locale]/(protected)/layout.tsx index 1fb5314..dc728cc 100644 --- a/src/app/[locale]/(protected)/layout.tsx +++ b/src/app/[locale]/(protected)/layout.tsx @@ -1,6 +1,7 @@ import { cookies } from "next/headers"; import { redirect } from "next/navigation"; +import { AccountEventsBridge } from "@/components/common/AccountEventsBridge"; import { ROUTES, getLocalizedPath } from "@/lib/config/routes"; interface ProtectedLayoutProps { @@ -24,5 +25,10 @@ export default async function ProtectedLayout({ children, params }: ProtectedLay redirect(getLocalizedPath(ROUTES.login, locale)); } - return <>{children}; + return ( + <> + + {children} + + ); } diff --git a/src/app/[locale]/admin/(protected)/users/[id]/UserDetailContent.tsx b/src/app/[locale]/admin/(protected)/users/[id]/UserDetailContent.tsx index 23f8270..f88d033 100644 --- a/src/app/[locale]/admin/(protected)/users/[id]/UserDetailContent.tsx +++ b/src/app/[locale]/admin/(protected)/users/[id]/UserDetailContent.tsx @@ -8,10 +8,10 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useTranslation } from "react-i18next"; -import { ActivityTable } from "@/components/admin/ActivityTable"; import { PermissionNote } from "@/components/admin/PermissionNote"; import { StatusBadge, UserStatusBadge } from "@/components/admin/StatusBadge"; import { UserActionDialogs } from "@/components/admin/UserActionDialogs"; +import { UserActivityCard } from "@/components/admin/UserActivityCard"; import { UserAvatarCard } from "@/components/admin/UserAvatarCard"; import { UserDangerZone } from "@/components/admin/UserDangerZone"; import { UserEditForm } from "@/components/admin/UserEditForm"; @@ -19,7 +19,7 @@ import { UserOverviewCard } from "@/components/admin/UserOverviewCard"; import { UserSessionsCard } from "@/components/admin/UserSessionsCard"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { useAdminUser, useAdminUserActivities, useUpdateAdminUser } from "@/hooks/api/use-admin"; +import { useAdminUser, useUpdateAdminUser } from "@/hooks/api/use-admin"; import { useUserActions, type UserActionKind } from "@/hooks/api/use-user-actions"; import { useCanDeleteUsers, @@ -41,9 +41,6 @@ export function UserDetailContent({ userId }: { userId: string }) { const currentUserId = useAuthStore((state) => state.user?.id ?? null); const { data: user, isLoading } = useAdminUser(userId); - const { data: activities, isLoading: activitiesLoading } = useAdminUserActivities(userId, { - limit: 20, - }); const update = useUpdateAdminUser(); const { run, isLoading: isActionLoading } = useUserActions(); @@ -159,20 +156,7 @@ export function UserDetailContent({ userId }: { userId: string }) { onAction={setAction} /> - - - {t("userDetail.activityTitle")} - {t("userDetail.activityDescription")} - - - - - + + + {t("userDetail.activityTitle")} + {t("userDetail.activityDescription")} + + + + {!isLoading && total > rows.length ? ( +
    + +
    + ) : null} +
    + + ); +} diff --git a/src/components/common/AccountEventsBridge.tsx b/src/components/common/AccountEventsBridge.tsx new file mode 100644 index 0000000..d03fd55 --- /dev/null +++ b/src/components/common/AccountEventsBridge.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { useAccountEvents } from "@/hooks/use-account-events"; + +/** + * Mounts the per-user account event socket inside server layouts. + * + * The protected layout is a server component and cannot call hooks; rendering + * this empty client component there keeps the socket alive on every protected + * page so live account events (permission changes, remote session revocation) + * reach the user without a refresh. + */ +export function AccountEventsBridge() { + useAccountEvents(); + return null; +} diff --git a/src/components/profile/__tests__/SessionsSection.test.tsx b/src/components/profile/__tests__/SessionsSection.test.tsx new file mode 100644 index 0000000..9e45da9 --- /dev/null +++ b/src/components/profile/__tests__/SessionsSection.test.tsx @@ -0,0 +1,109 @@ +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { describe, expect, it } from "vitest"; + +import { SessionsSection } from "@/components/profile/SessionsSection"; +import type { SessionItem, SessionListResponse } from "@/lib/types/session"; +import { server } from "@/test/msw/server"; +import { renderWithProviders } from "@/test/test-utils"; + +const currentSession: SessionItem = { + id: "s-current", + browser: "Chrome", + os: "Windows", + created_at: "2026-06-01T10:00:00Z", + last_used_at: "2026-06-13T08:00:00Z", + is_current: true, +}; + +const otherSession: SessionItem = { + id: "s-other", + browser: "Safari", + os: "iOS", + created_at: "2026-06-02T10:00:00Z", + last_used_at: "2026-06-12T08:00:00Z", + is_current: false, +}; + +const listResponse = (data: SessionItem[]): SessionListResponse => ({ + data, + total: data.length, + skip: 0, + limit: 10, +}); + +const mockList = (data: SessionItem[]) => { + server.use(http.get("*/api/v1/users/me/sessions", () => HttpResponse.json(listResponse(data)))); +}; + +describe("SessionsSection", () => { + it("renders the device list and flags the current session", async () => { + mockList([currentSession, otherSession]); + + renderWithProviders(); + + // Both devices show up with their parsed labels. + await waitFor(() => { + expect(screen.getAllByText(/sessions\.deviceLabel/)).toHaveLength(2); + }); + // Only the current session carries the badge; only the other one a revoke button. + expect(screen.getAllByText(/sessions\.currentBadge/)).toHaveLength(1); + expect(screen.getAllByRole("button", { name: /sessions\.revoke$/ })).toHaveLength(1); + // With another device present, the bulk sign-out action is offered. + expect(screen.getByRole("button", { name: /sessions\.revokeOthers/ })).toBeInTheDocument(); + }); + + it("revokes a single session after the confirm dialog", async () => { + const user = userEvent.setup(); + mockList([currentSession, otherSession]); + let revokedId: string | null = null; + server.use( + http.delete("*/api/v1/users/me/sessions/:id", ({ params }) => { + revokedId = params.id as string; + return HttpResponse.json({ success: true, message: "ok" }); + }), + ); + + renderWithProviders(); + + await user.click(await screen.findByRole("button", { name: /sessions\.revoke$/ })); + // Confirm inside the dialog. + await user.click(screen.getByRole("button", { name: /confirmRevoke\.confirm/ })); + + await waitFor(() => { + expect(revokedId).toBe("s-other"); + }); + }); + + it("revokes all other sessions after the confirm dialog", async () => { + const user = userEvent.setup(); + mockList([currentSession, otherSession]); + let called = false; + server.use( + http.delete("*/api/v1/users/me/sessions", () => { + called = true; + return HttpResponse.json({ revoked: 1, message: "ok" }); + }), + ); + + renderWithProviders(); + + await user.click(await screen.findByRole("button", { name: /sessions\.revokeOthers/ })); + await user.click(screen.getByRole("button", { name: /confirmRevokeOthers\.confirm/ })); + + await waitFor(() => { + expect(called).toBe(true); + }); + }); + + it("shows the empty state without bulk actions for a single current session", async () => { + mockList([currentSession]); + + renderWithProviders(); + + await screen.findByText(/sessions\.deviceLabel/); + expect(screen.queryByRole("button", { name: /sessions\.revokeOthers/ })).toBeNull(); + expect(screen.queryByRole("button", { name: /sessions\.revoke$/ })).toBeNull(); + }); +}); diff --git a/src/hooks/__tests__/use-account-events.test.tsx b/src/hooks/__tests__/use-account-events.test.tsx new file mode 100644 index 0000000..3fdac20 --- /dev/null +++ b/src/hooks/__tests__/use-account-events.test.tsx @@ -0,0 +1,125 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { useAccountEvents } from "@/hooks/use-account-events"; +import type { AccountEvent } from "@/schemas/account"; +import { createTestQueryClient, createWrapper } from "@/test/test-utils"; + +// Mock the socket factory so events can be fired by hand, the auth store so +// the gate can be flipped, and getMe so session re-validation is observable. +const { sockets, authState, getMe } = vi.hoisted(() => ({ + sockets: [] as { + onEvent: (event: AccountEvent) => void; + close: ReturnType; + }[], + authState: { + isAuthenticated: true, + setUser: vi.fn(), + }, + getMe: vi.fn(), +})); + +vi.mock("@/lib/websocket/account-socket", () => ({ + createAccountSocket: (onEvent: (event: AccountEvent) => void) => { + const socket = { onEvent, close: vi.fn() }; + sockets.push(socket); + return socket; + }, +})); + +vi.mock("@/stores/auth.store", () => ({ + useAuthStore: (selector: (s: typeof authState) => unknown) => selector(authState), +})); + +vi.mock("@/lib/api/endpoints/auth", () => ({ + authService: { getMe: () => getMe() as Promise }, +})); + +afterEach(() => { + sockets.length = 0; + authState.isAuthenticated = true; + vi.clearAllMocks(); +}); + +describe("useAccountEvents", () => { + it("refetches /users/me and stores the result on permissions_updated", async () => { + const me = { id: "u-1", permissions: ["users:read"] }; + getMe.mockResolvedValue(me); + + renderHook( + () => { + useAccountEvents(); + }, + { wrapper: createWrapper() }, + ); + + expect(sockets).toHaveLength(1); + sockets[0].onEvent({ type: "permissions_updated" }); + + await waitFor(() => { + expect(authState.setUser).toHaveBeenCalledWith(me); + }); + }); + + it("invalidates the sessions cache on sessions_revoked when this device survives", async () => { + getMe.mockResolvedValue({ id: "u-1" }); + const client = createTestQueryClient(); + const invalidate = vi.spyOn(client, "invalidateQueries"); + + renderHook( + () => { + useAccountEvents(); + }, + { wrapper: createWrapper(client) }, + ); + + sockets[0].onEvent({ type: "sessions_revoked" }); + + await waitFor(() => { + expect(invalidate).toHaveBeenCalledWith({ queryKey: ["sessions"] }); + }); + }); + + it("swallows the refetch failure when this device's session was revoked", async () => { + // The 401 path (logout + redirect) is owned by the api layer; the hook + // must simply not crash or touch the store. + getMe.mockRejectedValue(new Error("401")); + const client = createTestQueryClient(); + const invalidate = vi.spyOn(client, "invalidateQueries"); + + renderHook( + () => { + useAccountEvents(); + }, + { wrapper: createWrapper(client) }, + ); + + sockets[0].onEvent({ type: "sessions_revoked" }); + + await waitFor(() => { + expect(getMe).toHaveBeenCalledTimes(1); + }); + expect(authState.setUser).not.toHaveBeenCalled(); + expect(invalidate).not.toHaveBeenCalled(); + }); + + it("closes the socket on unmount and skips guests", () => { + const { unmount } = renderHook( + () => { + useAccountEvents(); + }, + { wrapper: createWrapper() }, + ); + unmount(); + expect(sockets[0].close).toHaveBeenCalledTimes(1); + + authState.isAuthenticated = false; + renderHook( + () => { + useAccountEvents(); + }, + { wrapper: createWrapper() }, + ); + expect(sockets).toHaveLength(1); // no second socket for the guest + }); +}); diff --git a/src/hooks/use-account-events.ts b/src/hooks/use-account-events.ts index e63e3f4..4cf9a69 100644 --- a/src/hooks/use-account-events.ts +++ b/src/hooks/use-account-events.ts @@ -2,6 +2,9 @@ import { useEffect } from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +import { sessionKeys } from "@/hooks/api/use-sessions"; import { authService } from "@/lib/api/endpoints/auth"; import { createAccountSocket } from "@/lib/websocket/account-socket"; import { useAuthStore } from "@/stores/auth.store"; @@ -9,26 +12,36 @@ import { useAuthStore } from "@/stores/auth.store"; /** * Hold a per-user account notification socket while mounted. * - * When a superadmin changes the current admin's grants, the backend pushes a - * `permissions_updated` event; we refetch `/users/me` so the new permission set - * takes effect immediately (nav + gates) without a re-login. + * `permissions_updated`: a superadmin changed the current admin's grants — we + * refetch `/users/me` so the new permission set takes effect immediately + * (nav + gates) without a re-login. + * + * `sessions_revoked`: one or more of the user's sessions were terminated + * (remote logout, admin kill, password change). Refetching `/users/me` + * re-validates THIS device: if its session was the one revoked, the request + * 401s and the api layer's logout flow drops the tab to the login screen at + * once. If this device survived, the sessions list cache is refreshed so the + * UI reflects the kill live. */ export const useAccountEvents = (): void => { const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const setUser = useAuthStore((state) => state.setUser); + const queryClient = useQueryClient(); useEffect(() => { if (!isAuthenticated) return; const socket = createAccountSocket((event) => { - if (event.type !== "permissions_updated") return; void (async () => { try { const me = await authService.getMe(); setUser(me); + if (event.type === "sessions_revoked") { + await queryClient.invalidateQueries({ queryKey: sessionKeys.all }); + } } catch { - // A failed refetch (e.g. the session was lost) is handled by the - // auth layer; nothing to do here. + // A failed refetch (e.g. this device's session was revoked) is + // handled by the auth layer's 401 flow; nothing to do here. } })(); }); @@ -36,5 +49,5 @@ export const useAccountEvents = (): void => { return () => { socket.close(); }; - }, [isAuthenticated, setUser]); + }, [isAuthenticated, setUser, queryClient]); }; diff --git a/src/i18n/locales/en/admin.json b/src/i18n/locales/en/admin.json index 39a5696..8ea164b 100644 --- a/src/i18n/locales/en/admin.json +++ b/src/i18n/locales/en/admin.json @@ -105,6 +105,7 @@ "delete": "Delete", "suspend": "Suspend", "password_reset": "Reset password", + "sessions": "Manage sessions", "update": "Assign" } }, @@ -226,6 +227,7 @@ "activityTitle": "Recent activity", "activityDescription": "Audit events recorded for this user.", "activityEmpty": "No recent activity.", + "activityLoadMore": "Show more", "dangerTitle": "Danger zone", "dangerDescription": "These actions are audited and can't be undone by the target user.", "sessions": { diff --git a/src/i18n/locales/en/errors.json b/src/i18n/locales/en/errors.json index 35f49ec..d68c6a1 100644 --- a/src/i18n/locales/en/errors.json +++ b/src/i18n/locales/en/errors.json @@ -55,6 +55,9 @@ "not_found": "Notification not found.", "access_denied": "You don't have access to this notification." }, + "session": { + "not_found": "Session not found." + }, "support": { "ticket_not_found": "Ticket not found.", "ticket_access_denied": "You don't have access to this ticket.", diff --git a/src/i18n/locales/en/success.json b/src/i18n/locales/en/success.json index 781aa84..54ac032 100644 --- a/src/i18n/locales/en/success.json +++ b/src/i18n/locales/en/success.json @@ -37,6 +37,11 @@ "read": "Notification marked as read.", "all_read": "All notifications marked as read." }, + "session": { + "revoked": "Session revoked.", + "others_revoked": "Signed out of all other devices.", + "admin_revoked": "All of the user's sessions have been terminated." + }, "support": { "ticket_created": "Your support ticket has been created.", "ticket_closed": "Ticket closed.", diff --git a/src/i18n/locales/tr/admin.json b/src/i18n/locales/tr/admin.json index bea5a86..ed79a41 100644 --- a/src/i18n/locales/tr/admin.json +++ b/src/i18n/locales/tr/admin.json @@ -105,6 +105,7 @@ "delete": "Sil", "suspend": "Askıya al", "password_reset": "Şifre sıfırla", + "sessions": "Oturumları yönet", "update": "Ata" } }, @@ -226,6 +227,7 @@ "activityTitle": "Son aktiviteler", "activityDescription": "Bu kullanıcıya ait denetim olayları.", "activityEmpty": "Son aktivite yok.", + "activityLoadMore": "Daha fazla göster", "dangerTitle": "Tehlikeli işlemler", "dangerDescription": "Bu işlemler denetlenir ve kullanıcı tarafından geri alınamaz.", "sessions": { diff --git a/src/i18n/locales/tr/errors.json b/src/i18n/locales/tr/errors.json index 28538d4..f5469f8 100644 --- a/src/i18n/locales/tr/errors.json +++ b/src/i18n/locales/tr/errors.json @@ -55,6 +55,9 @@ "not_found": "Bildirim bulunamadı.", "access_denied": "Bu bildirime erişiminiz yok." }, + "session": { + "not_found": "Oturum bulunamadı." + }, "support": { "ticket_not_found": "Talep bulunamadı.", "ticket_access_denied": "Bu talebe erişiminiz yok.", diff --git a/src/i18n/locales/tr/success.json b/src/i18n/locales/tr/success.json index 59263f0..8b5b801 100644 --- a/src/i18n/locales/tr/success.json +++ b/src/i18n/locales/tr/success.json @@ -37,6 +37,11 @@ "read": "Bildirim okundu olarak işaretlendi.", "all_read": "Tüm bildirimler okundu olarak işaretlendi." }, + "session": { + "revoked": "Oturum sonlandırıldı.", + "others_revoked": "Diğer tüm cihazlardan çıkış yapıldı.", + "admin_revoked": "Kullanıcının tüm oturumları sonlandırıldı." + }, "support": { "ticket_created": "Destek talebiniz oluşturuldu.", "ticket_closed": "Talep kapatıldı.", diff --git a/src/schemas/account.ts b/src/schemas/account.ts index 9060baa..1719b1b 100644 --- a/src/schemas/account.ts +++ b/src/schemas/account.ts @@ -4,7 +4,7 @@ import { z } from "zod"; // `AccountEvent`. Validated on the socket so malformed frames are dropped rather // than acted on. export const accountEventSchema = z.object({ - type: z.enum(["permissions_updated"]), + type: z.enum(["permissions_updated", "sessions_revoked"]), }); export type AccountEvent = z.infer; From cb012f23980181a27472012a4b5d9dbdc103074c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sat, 13 Jun 2026 07:25:52 +0300 Subject: [PATCH 5/8] test: mock sessions endpoint in base fixture --- tests-e2e/base-test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests-e2e/base-test.ts b/tests-e2e/base-test.ts index 1dcd8f2..8ff32b7 100644 --- a/tests-e2e/base-test.ts +++ b/tests-e2e/base-test.ts @@ -50,6 +50,28 @@ export const test = base.extend({ }, ); + // The sessions card mounts on the admin user-detail page and the profile + // security tab, firing GET .../sessions on load. Like the notification + // bell above, an unmocked call hits the catch-all 401 and trips the + // logout cascade, bouncing the page to /login. Answer GETs with an empty + // session list by default; specs that assert on sessions (or test revoke, + // which is a DELETE) register their own routes, which take precedence. + // Non-GET methods fall through so those spec handlers — or the 401 — apply. + await page.route( + (url) => url.pathname.includes("/api/v1/") && url.pathname.endsWith("/sessions"), + async (route) => { + if (route.request().method() !== "GET") { + await route.fallback(); + return; + } + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: [], total: 0, skip: 0, limit: 50 }), + }); + }, + ); + // page.route() does not cover WebSockets, so realtime sockets (account // events, support feed) would otherwise reach the real backend through the // dev proxy. Intercept API WebSockets and answer them locally — the handler From e094b159b9a9ecd4ba7091771433b6081748d006 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sat, 13 Jun 2026 07:31:40 +0300 Subject: [PATCH 6/8] docs: document active sessions feature --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index 54db348..65b4da0 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ The API proxy is pre-configured in `next.config.ts` to forward requests from you - **RBAC / Permission-aware Admin Panel:** A sidebar-shell admin area (`/admin`) with `user` / `admin` / `superadmin` roles, isolated from the user-facing app. A central `usePermissions()` hook (plus per-permission helpers like `useCanDeleteUsers`) drives **nav, route, and action gating** from the `permissions` on `/users/me`; superadmins pass everything by role and get a searchable **permission-matrix** to **create/delete admin accounts** and assign grants — while the **root superadmin** additionally promotes/demotes superadmins and hands over root via **email OTP**. Permission changes apply **instantly** over a WebSocket — no re-login. See [Admin Panel & RBAC](#-admin-panel--rbac). - **Support / Ticketing UI + Realtime:** User and admin ticketing screens (open, reply with attachments, status/priority, assignment) with live thread & queue updates over a self-healing WebSocket. See [Support UI](#-support-ticketing-ui). - **Notification Center:** A header bell (unread badge + dropdown panel) on both the user header and the admin topbar, plus a full paginated inbox (`/notifications`, `/admin/notifications`) with an **All / Unread** filter and mark-(all-)read. Updates **live** over a self-healing WebSocket — a pushed event invalidates the React Query caches so the badge and lists refresh without a reload. Notification copy is rendered from a `type` + `data` payload via i18n (en/tr), and the inbox is reachable on mobile from the drawer / admin sidebar. See [Notification Center](#-notification-center). +- **Active Sessions / Device Management:** A **Security** tab on the profile lists the devices signed in to the account (browser/OS + last active, current device flagged) and revokes any one — or **all other devices** in one click. Admins get the same list plus a **terminate-all** action on the user detail page, gated by the `users:sessions` permission. Revocation is **live**: an `account` WebSocket `sessions_revoked` event re-validates the device, dropping a kicked tab straight to login without a reload. See [Active Sessions](#-active-sessions). --- @@ -345,6 +346,17 @@ A persistent, in-app notification surface wired to the backend's `notifications` --- +## 🔒 Active Sessions + +A device-management surface over the backend's `user_session` feed, so a user sees where they're signed in and can sign out remotely. + +- **Profile security tab** — `SessionsSection` (under the profile **Security** tab) lists active sessions via `useSessions()`, each showing a parsed browser/OS label and last-active time, with the calling device flagged **This device**. A per-row **Revoke** (with confirm dialog) signs out one device; **Sign out all other devices** clears the rest while keeping the current one. The stored IP stays server-side — it's never sent to the client. +- **Admin user detail** — `UserSessionsCard` renders the same list plus a **Terminate all sessions** action on `/admin/users/[id]`, shown only when the admin holds the `users:sessions` permission (`useCanManageUserSessions()`). +- **Live logout** — `useAccountEvents()` (mounted app-wide via `AccountEventsBridge`) holds the self-healing `account` socket; a `sessions_revoked` frame re-validates the device by refetching `/users/me`. If this device was the one revoked the request 401s and the axios layer drops it to login at once; otherwise the sessions list cache is refreshed so the UI reflects the change live. +- **Localized** — all copy lives under the `profile` / `admin` i18n namespaces (en/tr); session toasts resolve from the backend `success.session.*` / `error.session.*` keys. + +--- + ## 🤝 Contributing Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. @@ -378,6 +390,7 @@ Distributed under the MIT License. See the `LICENSE` file at the root of the wor - [ ] Sign in as that permission-limited admin and confirm the nav, routes, and actions gate to their grants - [ ] Try the support flow: open a ticket at `/support`, then reply/assign it from `/admin/support` - [ ] Open the notification bell (header / admin topbar) or the full inbox at `/notifications` and watch the badge update live as the backend emits events +- [ ] Open the profile **Security** tab to view active sessions; sign in from a second browser and revoke it (or terminate it from `/admin/users/[id]` with the `users:sessions` grant) and watch that tab drop to login live - [ ] Check the E2E tests in `tests-e2e/` (incl. `admin/` RBAC + `support/`) to understand the testing patterns - [ ] Update the i18n translation files in `src/i18n/locales/` as needed From 3fce39f5daa3183f8efb8a523ed446b231065443 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sat, 13 Jun 2026 12:25:23 +0300 Subject: [PATCH 7/8] docs: forbid nested subagents in pipeline --- .claude/commands/review.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.claude/commands/review.md b/.claude/commands/review.md index 57056fe..3d0c12d 100644 --- a/.claude/commands/review.md +++ b/.claude/commands/review.md @@ -17,6 +17,8 @@ You are orchestrating a multi-agent code review pipeline for a pull request in t Execute the eight steps below in order. Each step that says "spawn an agent" must use the Agent tool. Agent prompts are written in English (more reliable for model instructions); the final user-facing comment is in **Turkish** (the project's working language). +> **No nested agents.** Only the orchestrator (this top-level loop) spawns agents — exactly at Steps 1, 2, 3, 4, and 5. Every spawned agent is a **leaf**: it completes its task entirely within its own context and MUST NOT call the `Agent`/`Task` tool or spawn any sub-agent of its own. If work needs to be split, only the orchestrator splits it. + --- ### Step 1 — Eligibility check (Sonnet) @@ -82,7 +84,7 @@ Pass this object to the five parallel reviewers in Step 4. ### Step 4 — Five parallel Sonnet reviewers -> **Read-only mandate for every spawned agent (Steps 1–5).** Each agent's ONLY output is the JSON described in its prompt. An agent MUST NOT post, edit, or delete anything on the PR: never run `gh pr comment`, `gh issue comment`, `gh pr review`, or any `gh api` call with `-X POST`/`-X PATCH`/`-X PUT`/`-X DELETE`. Only the orchestrator posts, and only once, in Step 8. Any agent that writes to the PR is a pipeline violation and causes duplicate comments. +> **Read-only mandate for every spawned agent (Steps 1–5).** Each agent's ONLY output is the JSON described in its prompt. An agent MUST NOT post, edit, or delete anything on the PR: never run `gh pr comment`, `gh issue comment`, `gh pr review`, or any `gh api` call with `-X POST`/`-X PATCH`/`-X PUT`/`-X DELETE`. Only the orchestrator posts, and only once, in Step 8. Any agent that writes to the PR is a pipeline violation and causes duplicate comments. **Each agent is also a leaf node: it does its own work in its own context and MUST NOT call the `Agent`/`Task` tool or spawn any sub-agent.** A spawned agent that spawns further agents is a pipeline violation. Spawn the following five agents **in parallel** in a single message, all with `subagent_type: "general-purpose"` and `model: "sonnet"`. Each must return strict JSON of the shape: @@ -341,6 +343,7 @@ Use `--body-file` (not `--body`) to preserve markdown formatting and avoid shell - **Never edit files.** This command only reviews. - **Only the orchestrator posts, and only in Step 8.** Spawned agents (Steps 1–5) are read-only: they return JSON and never call `gh pr comment`, `gh issue comment`, `gh pr review`, or a writing `gh api` (`-X POST/PATCH/PUT/DELETE`). If a sub-agent reports it already posted a comment, do not post again — verify the PR state and reconcile to exactly one correct comment. +- **No nested agents.** Spawned agents are leaf nodes — they complete their task in their own context and never call the `Agent`/`Task` tool or spawn sub-agents. Only the orchestrator spawns, exactly at Steps 1, 2, 3, 4, and 5. - **Never post more than one comment per run.** All findings are batched into a single comment. - **Never post without `REVIEW.md`** — abort instead. The whole pipeline relies on it for false-positive control. - **Always use the full head SHA** in permalinks, captured at Step 1, even if the PR receives new commits during the run. The review applies to the SHA you actually read. From 69864680ad3a8229c9ff392860d97aa998c7abac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Kemal=20=C3=87alak?= Date: Sat, 13 Jun 2026 12:46:26 +0300 Subject: [PATCH 8/8] refactor: named props + shared confirm dialog --- src/components/admin/UserActivityCard.tsx | 6 +- src/components/admin/UserSessionsCard.tsx | 55 ++++++++-------- src/components/profile/SessionsSection.tsx | 73 ++++++---------------- 3 files changed, 49 insertions(+), 85 deletions(-) diff --git a/src/components/admin/UserActivityCard.tsx b/src/components/admin/UserActivityCard.tsx index 3558d69..87aa4f1 100644 --- a/src/components/admin/UserActivityCard.tsx +++ b/src/components/admin/UserActivityCard.tsx @@ -11,7 +11,11 @@ import { useAdminUserActivities } from "@/hooks/api/use-admin"; const PAGE_SIZE = 20; -export function UserActivityCard({ userId }: { userId: string }) { +interface UserActivityCardProps { + userId: string; +} + +export function UserActivityCard({ userId }: UserActivityCardProps) { const { t } = useTranslation("admin"); // "Show more" grows the window instead of paginating, mirroring the // sessions card — context stays on screen while older entries append. diff --git a/src/components/admin/UserSessionsCard.tsx b/src/components/admin/UserSessionsCard.tsx index 8c512d2..19e97c7 100644 --- a/src/components/admin/UserSessionsCard.tsx +++ b/src/components/admin/UserSessionsCard.tsx @@ -2,10 +2,10 @@ import { useState, type ReactNode } from "react"; -import { Modal } from "antd"; import { LogOut, MonitorSmartphone, Smartphone } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { ConfirmDialog } from "@/components/admin/ConfirmDialog"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; @@ -17,7 +17,11 @@ const PAGE_SIZE = 10; const MOBILE_OS = new Set(["iOS", "iPadOS", "Android"]); -const DeviceIcon = ({ os }: { os: string | null }) => { +interface DeviceIconProps { + os: string | null; +} + +const DeviceIcon = ({ os }: DeviceIconProps) => { const Icon = os && MOBILE_OS.has(os) ? Smartphone : MonitorSmartphone; return ( @@ -26,7 +30,11 @@ const DeviceIcon = ({ os }: { os: string | null }) => { ); }; -const SessionRow = ({ session }: { session: SessionItem }) => { +interface SessionRowProps { + session: SessionItem; +} + +const SessionRow = ({ session }: SessionRowProps) => { const { t } = useTranslation("admin"); const deviceLabel = @@ -47,7 +55,11 @@ const SessionRow = ({ session }: { session: SessionItem }) => { ); }; -export function UserSessionsCard({ userId }: { userId: string }) { +interface UserSessionsCardProps { + userId: string; +} + +export function UserSessionsCard({ userId }: UserSessionsCardProps) { const { t } = useTranslation("admin"); const [limit, setLimit] = useState(PAGE_SIZE); const { data, isLoading } = useAdminUserSessions(userId, { limit }); @@ -124,34 +136,19 @@ export function UserSessionsCard({ userId }: { userId: string }) { ) : null} - { - if (!isPending) setConfirmOpen(false); + onOpenChange={(open) => { + if (!open) setConfirmOpen(false); }} title={t("userDetail.sessions.confirm.title")} - footer={null} - destroyOnHidden - centered - > -

    - {t("userDetail.sessions.confirm.description")} -

    -
    - - -
    -
    + description={t("userDetail.sessions.confirm.description")} + confirmLabel={t("userDetail.sessions.confirm.confirm")} + cancelLabel={t("userDetail.sessions.confirm.cancel")} + onConfirm={handleRevokeAll} + isLoading={isPending} + destructive + /> ); } diff --git a/src/components/profile/SessionsSection.tsx b/src/components/profile/SessionsSection.tsx index 694cf7d..c519d9e 100644 --- a/src/components/profile/SessionsSection.tsx +++ b/src/components/profile/SessionsSection.tsx @@ -2,10 +2,10 @@ import { useState, type ReactNode } from "react"; -import { Modal } from "antd"; import { LogOut, MonitorSmartphone, Smartphone } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { ConfirmDialog } from "@/components/admin/ConfirmDialog"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; @@ -18,7 +18,11 @@ const PAGE_SIZE = 10; const MOBILE_OS = new Set(["iOS", "iPadOS", "Android"]); -const DeviceIcon = ({ os }: { os: string | null }) => { +interface DeviceIconProps { + os: string | null; +} + +const DeviceIcon = ({ os }: DeviceIconProps) => { const Icon = os && MOBILE_OS.has(os) ? Smartphone : MonitorSmartphone; return ( @@ -27,49 +31,6 @@ const DeviceIcon = ({ os }: { os: string | null }) => { ); }; -interface ConfirmRevokeModalProps { - open: boolean; - busy: boolean; - title: string; - description: string; - confirmLabel: string; - cancelLabel: string; - onCancel: () => void; - onConfirm: () => void; -} - -const ConfirmRevokeModal = ({ - open, - busy, - title, - description, - confirmLabel, - cancelLabel, - onCancel, - onConfirm, -}: ConfirmRevokeModalProps) => ( - { - if (!busy) onCancel(); - }} - title={title} - footer={null} - destroyOnHidden - centered - > -

    {description}

    -
    - - -
    -
    -); - interface SessionRowProps { session: SessionItem; disabled: boolean; @@ -229,30 +190,32 @@ export const SessionsSection = () => { ) : null} - { + if (!open) setConfirmTarget(null); + }} title={t("sessions.confirmRevoke.title")} description={t("sessions.confirmRevoke.description")} confirmLabel={t("sessions.confirmRevoke.confirm")} cancelLabel={t("sessions.confirmRevoke.cancel")} - onCancel={() => { - setConfirmTarget(null); - }} onConfirm={handleRevoke} + isLoading={isRevoking} + destructive /> - { + if (!open) setConfirmOthersOpen(false); + }} title={t("sessions.confirmRevokeOthers.title")} description={t("sessions.confirmRevokeOthers.description")} confirmLabel={t("sessions.confirmRevokeOthers.confirm")} cancelLabel={t("sessions.confirmRevokeOthers.cancel")} - onCancel={() => { - setConfirmOthersOpen(false); - }} onConfirm={handleRevokeOthers} + isLoading={isRevokingOthers} + destructive /> );