diff --git a/sdks/js/packages/core/react/components/organization/profile.tsx b/sdks/js/packages/core/react/components/organization/profile.tsx index f9e0ec72d..7342e8a4b 100644 --- a/sdks/js/packages/core/react/components/organization/profile.tsx +++ b/sdks/js/packages/core/react/components/organization/profile.tsx @@ -30,7 +30,8 @@ export const OrganizationProfile = ({ showAPIKeys = false, showPreferences = false, hideToast = false, - customScreens = [] + customScreens = [], + onLogout = () => {} }: OrganizationProfileProps) => { const memoryHistory = createMemoryHistory({ initialEntries: [defaultRoute] @@ -50,7 +51,8 @@ export const OrganizationProfile = ({ showAPIKeys, hideToast, showPreferences, - customRoutes + customRoutes, + onLogout } }); return ; diff --git a/sdks/js/packages/core/react/components/organization/routes.tsx b/sdks/js/packages/core/react/components/organization/routes.tsx index e32e1d4fc..dd51ae3fe 100644 --- a/sdks/js/packages/core/react/components/organization/routes.tsx +++ b/sdks/js/packages/core/react/components/organization/routes.tsx @@ -47,6 +47,7 @@ import ServiceUserPage from './api-keys/service-user'; import { DeleteServiceAccount } from './api-keys/delete'; import { DeleteServiceAccountKey } from './api-keys/service-user/delete'; import ManageServiceUserProjects from './api-keys/service-user/projects'; +import { SessionsPage, RevokeSessionConfirm } from './sessions'; export interface CustomScreen { name: string; path: string; @@ -63,6 +64,7 @@ export interface OrganizationProfileProps { showPreferences?: boolean; hideToast?: boolean; customScreens?: CustomScreen[]; + onLogout?: () => void; } export interface CustomRoutes { @@ -78,7 +80,7 @@ type RouterContext = Pick< | 'showAPIKeys' | 'hideToast' | 'showPreferences' -> & { customRoutes: CustomRoutes }; +> & { customRoutes: CustomRoutes; onLogout?: () => void }; export function getCustomRoutes(customScreens: CustomScreen[] = []) { return ( @@ -349,6 +351,21 @@ const deleteServiceAccountKeyRoute = createRoute({ component: DeleteServiceAccountKey }); +const sessionsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/sessions', + component: SessionsPage +}); + +const revokeSessionRoute = createRoute({ + getParentRoute: () => sessionsRoute, + path: '/revoke', + component: RevokeSessionConfirm, + validateSearch: (search: Record) => ({ + sessionId: search.sessionId as string | undefined, + }), +}); + interface getRootTreeOptions { customScreens?: CustomScreen[]; } @@ -357,6 +374,7 @@ export function getRootTree({ customScreens = [] }: getRootTreeOptions) { return rootRoute.addChildren([ indexRoute.addChildren([deleteOrgRoute]), securityRoute, + sessionsRoute.addChildren([revokeSessionRoute]), membersRoute.addChildren([inviteMemberRoute, removeMemberRoute]), teamsRoute.addChildren([addTeamRoute]), domainsRoute.addChildren([ diff --git a/sdks/js/packages/core/react/components/organization/sessions/index.tsx b/sdks/js/packages/core/react/components/organization/sessions/index.tsx new file mode 100644 index 000000000..b0191b81c --- /dev/null +++ b/sdks/js/packages/core/react/components/organization/sessions/index.tsx @@ -0,0 +1,3 @@ +export { SessionsPage } from './sessions-page'; +export { RevokeSessionConfirm } from './revoke-session-confirm'; +export { RevokeSessionFinalConfirm } from './revoke-session-final-confirm'; \ No newline at end of file diff --git a/sdks/js/packages/core/react/components/organization/sessions/revoke-session-confirm.tsx b/sdks/js/packages/core/react/components/organization/sessions/revoke-session-confirm.tsx new file mode 100644 index 000000000..0760545ba --- /dev/null +++ b/sdks/js/packages/core/react/components/organization/sessions/revoke-session-confirm.tsx @@ -0,0 +1,152 @@ +import { useState, useMemo } from 'react'; +import { useNavigate, useSearch, useRouteContext } from '@tanstack/react-router'; +import { + Button, + Text, + Dialog, + Flex, + List, + Skeleton +} from '@raystack/apsara/v1'; +import { useSessions } from '../../../hooks/useSessions'; +import { useMutation } from '@connectrpc/connect-query'; +import { FrontierServiceQueries } from '@raystack/proton/frontier'; +import { RevokeSessionFinalConfirm } from './revoke-session-final-confirm'; +import styles from './sessions.module.css'; + +export const RevokeSessionConfirm = () => { + const navigate = useNavigate({ from: '/sessions/revoke' }); + const search = useSearch({ from: '/sessions/revoke' }) as { sessionId?: string }; + const { sessions, revokeSession, isRevokingSession } = useSessions(); + const { onLogout } = useRouteContext({ from: '__root__' }) as { onLogout?: () => void }; + const [isFinalConfirmOpen, setIsFinalConfirmOpen] = useState(false); + + const { mutate: logout } = useMutation(FrontierServiceQueries.authLogout, { + onSuccess: () => { + if (onLogout) { + onLogout(); + } + }, + onError: (error) => { + console.error('Failed to logout:', error); + // Fallback to regular session revocation + if (search.sessionId) { + revokeSession(search.sessionId); + navigate({ to: '/sessions' }); + } + }, + }); + + // Find the session data based on sessionId from URL params + const sessionData = useMemo(() => { + if (!search.sessionId || sessions.length === 0) { + return null; + } + + const session = sessions.find(s => s.id === search.sessionId); + if (!session) { + console.error('Not found'); + return null; + } + + return session; + }, [search.sessionId, sessions]); + + const handleRevokeClick = () => { + setIsFinalConfirmOpen(true); + }; + + const handleFinalConfirm = () => { + if (!search.sessionId) return; + + if (sessionData?.isCurrent) { + logout({}); + return; + } + + revokeSession(search.sessionId); + navigate({ to: '/sessions' }); + }; + + + return ( + <> + {sessionData ? ( + navigate({ to: '/sessions' })}> + + + + + {sessionData.browser} on {sessionData.operatingSystem} + + + + + + + + + Device + {sessionData.browser} on {sessionData.operatingSystem} + + + IP Address + {sessionData.ipAddress} + + + Last Location + {sessionData.location} + + + Last Active + {sessionData.lastActive} + + + + + + + + + + + + + ) : ( + navigate({ to: '/sessions' })}> + + + + + )} + + + + ); +}; diff --git a/sdks/js/packages/core/react/components/organization/sessions/revoke-session-final-confirm.tsx b/sdks/js/packages/core/react/components/organization/sessions/revoke-session-final-confirm.tsx new file mode 100644 index 000000000..35fc4a43d --- /dev/null +++ b/sdks/js/packages/core/react/components/organization/sessions/revoke-session-final-confirm.tsx @@ -0,0 +1,88 @@ +import { + Button, + toast, + Dialog, + Flex, + Text +} from '@raystack/apsara/v1'; +import styles from './sessions.module.css'; + +const getErrorMessage = (error: unknown): string => { + if (error && typeof error === 'object' && 'status' in error && error.status === 500) { + return 'Something went wrong'; + } + return error instanceof Error ? error.message : 'Something went wrong'; +}; + +interface RevokeSessionFinalConfirmProps { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + onConfirm: () => void; + isLoading?: boolean; + isCurrentSession?: boolean; +} + +export const RevokeSessionFinalConfirm = ({ + isOpen, + onOpenChange, + onConfirm, + isLoading = false, + isCurrentSession = false +}: RevokeSessionFinalConfirmProps) => { + const handleConfirm = async () => { + try { + onConfirm(); + onOpenChange(false); + } catch (error: any) { + toast.error('Failed to revoke session', { + description: getErrorMessage(error) + }); + } + }; + + return ( + + + + Revoke + + + + + + + Are you sure you want to revoke this session? This action cannot be undone. + + + + + + + + + + + + + ); +}; diff --git a/sdks/js/packages/core/react/components/organization/sessions/sessions-page.tsx b/sdks/js/packages/core/react/components/organization/sessions/sessions-page.tsx new file mode 100644 index 000000000..4fe94002a --- /dev/null +++ b/sdks/js/packages/core/react/components/organization/sessions/sessions-page.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { + Text, + Flex, + Headline, + Button, + Skeleton, +} from '@raystack/apsara/v1'; +import { Outlet, useNavigate } from '@tanstack/react-router'; +import { useSessions } from '../../../hooks/useSessions'; +import styles from './sessions.module.css'; + +export const SessionsPage = () => { + const navigate = useNavigate({ from: '/sessions' }); + const { sessions, isLoading, error } = useSessions(); + + + const handleRevoke = (sessionId: string) => { + navigate({ to: '/sessions/revoke', search: { sessionId } }); + }; + + const renderSessionsHeader = () => ( + + Sessions + + Devices logged into this account. + + + ); + + if (isLoading) { + return ( + + + + {renderSessionsHeader()} + + + + + + + ); + } + + if (error) { + return ( + + + + {renderSessionsHeader()} + + + + {error} + + + + + ); + } + + return ( + + + + {renderSessionsHeader()} + + + + {sessions.length === 0 ? ( + + + No active sessions found. + + + ) : ( + sessions.map((session) => ( + + + + {session.browser} on {session.operatingSystem} + + + {session.location} + + {session.isCurrent ? ( + Current session + ) : ( + Last active {session.lastActive} + )} + + + + + )) + )} + + + + + ); +}; \ No newline at end of file diff --git a/sdks/js/packages/core/react/components/organization/sessions/sessions.module.css b/sdks/js/packages/core/react/components/organization/sessions/sessions.module.css new file mode 100644 index 000000000..6899a8d27 --- /dev/null +++ b/sdks/js/packages/core/react/components/organization/sessions/sessions.module.css @@ -0,0 +1,49 @@ +.header { + margin-bottom: var(--rs-space-9); +} + +.container { + padding: var(--rs-space-9) var(--rs-space-11); + max-height: 80vh; + overflow-y: auto; +} + +.sessionsList { + max-height: 75vh; + overflow-y: auto; +} + +.sessionItem { + padding: var(--rs-space-7) 0 var(--rs-space-7) 0; + border-bottom: 0.9px solid var(--rs-color-border-base-primary); +} + +/* Revoke Session Confirm START */ +.revokeSessionConfirmHeader { + border-bottom: none; +} + +.revokeSessionConfirmBody { + padding: 0 var(--rs-space-7) var(--rs-space-5) var(--rs-space-7); + border-bottom: 0.9px solid var(--rs-color-border-base-primary); +} + +.revokeSessionFinalConfirmBody { + padding: 0 var(--rs-space-7) var(--rs-space-5) var(--rs-space-7); + border-bottom: 0.9px solid var(--rs-color-border-base-primary); +} + +.listItem { + border-bottom: 0.9px solid var(--rs-color-border-base-primary); + padding-top: var(--rs-space-5); + padding-bottom: var(--rs-space-5); +} + +.listItem:last-child { + border-bottom: none; +} + +.listRoot { + gap: 0; +} +/* Revoke Session Confirm END */ \ No newline at end of file diff --git a/sdks/js/packages/core/react/components/organization/sidebar/helpers.ts b/sdks/js/packages/core/react/components/organization/sidebar/helpers.ts index d169ffd84..11fafdd5b 100644 --- a/sdks/js/packages/core/react/components/organization/sidebar/helpers.ts +++ b/sdks/js/packages/core/react/components/organization/sidebar/helpers.ts @@ -99,7 +99,12 @@ export const getUserNavItems = (options: getUserNavItemsOptions = {}) => { name: 'Preferences', to: '/preferences', show: options?.showPreferences - } + }, + { + name: 'Sessions', + to: '/sessions', + show: true + }, ]; const customRoutes = getCustomRoutes(options?.customRoutes); return [...routes, ...customRoutes].filter( diff --git a/sdks/js/packages/core/react/components/window/index.tsx b/sdks/js/packages/core/react/components/window/index.tsx index 65a5aa4fa..5c206464d 100644 --- a/sdks/js/packages/core/react/components/window/index.tsx +++ b/sdks/js/packages/core/react/components/window/index.tsx @@ -24,6 +24,7 @@ export const Window = ({ const [zoom, setZoom] = useState(false); const [isCloseActive, setCloseActive] = useState(false); const [isZoomActive, setZoomActive] = useState(false); + return ( { + useQuery(FrontierServiceQueries.pingUserSession, {}, { + // Ping immediately and then every 10 minutes + refetchInterval: 10 * 60 * 1000, + refetchIntervalInBackground: true, + staleTime: Infinity, + gcTime: Infinity, + retry: false, + }); +}; \ No newline at end of file diff --git a/sdks/js/packages/core/react/hooks/useSessions.ts b/sdks/js/packages/core/react/hooks/useSessions.ts new file mode 100644 index 000000000..2e57ce626 --- /dev/null +++ b/sdks/js/packages/core/react/hooks/useSessions.ts @@ -0,0 +1,101 @@ +import { useMemo } from 'react'; +import { useQuery, useMutation, createConnectQueryKey, useTransport } from '@connectrpc/connect-query'; +import { useQueryClient } from '@tanstack/react-query'; +import { FrontierServiceQueries } from '@raystack/proton/frontier'; +import { toast } from '@raystack/apsara'; +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import { timestampToDayjs } from '../../utils/timestamp'; + +dayjs.extend(relativeTime); + +// Utility function to format error messages based on status code +const getErrorMessage = (error: unknown): string => { + if (error instanceof Error && 'status' in error && error.status === 500) { + return 'Something went wrong'; + } + if (error instanceof Error) { + return error.message; + } + return 'Something went wrong'; +}; + +export const formatDeviceDisplay = (browser?: string, operatingSystem?: string): string => { + const browserName = browser || "Unknown"; + const osName = operatingSystem || "Unknown"; + return browserName === "Unknown" && osName === "Unknown" ? "Unknown browser and OS" : `${browserName} on ${osName}`; +}; + +export const useSessions = () => { + const queryClient = useQueryClient(); + const transport = useTransport(); + + const { + data: sessionsData, + isLoading, + error + } = useQuery( + FrontierServiceQueries.listSessions, + {} + ); + + const formatLastActive = (updatedAt?: any) => { + const d = timestampToDayjs(updatedAt); + return d ? d.fromNow() : "Unknown"; + }; + + const sessions = useMemo(() => + (sessionsData?.sessions || []) + .map((session: any) => ({ + id: session.id || '', + browser: session.metadata?.browser || 'Unknown', + operatingSystem: session.metadata?.operatingSystem || 'Unknown', + ipAddress: session.metadata?.ipAddress || 'Unknown', + location: session.metadata?.location || 'Unknown', + lastActive: formatLastActive(session.updatedAt), + isCurrent: session.isCurrentSession || false, + })) + .sort((a, b) => { + // Current session first, then by last active (most recent first) + if (a.isCurrent && !b.isCurrent) return -1; + if (!a.isCurrent && b.isCurrent) return 1; + return 0; // Keep original order for non-current sessions + }), [sessionsData?.sessions] + ); + + const { + mutate: revokeSession, + isPending: isRevokingSession, + } = useMutation(FrontierServiceQueries.revokeSession, { + onSuccess: () => { + // Invalidate and refetch the sessions list + queryClient.invalidateQueries({ + queryKey: createConnectQueryKey({ + schema: FrontierServiceQueries.listSessions, + transport, + input: {}, + cardinality: "finite", + }), + }); + toast.success('Session revoked successfully'); + }, + onError: (error: any) => { + toast.error('Failed to revoke session', { + description: getErrorMessage(error) + }); + }, + }); + + const handleRevokeSession = (sessionId: string) => { + revokeSession({ sessionId }); + }; + + return { + sessions, + isLoading, + error: error?.message || null, + refetch: () => {}, + revokeSession: handleRevokeSession, + isRevokingSession, + }; +}; diff --git a/sdks/js/packages/sdk-demo/src/pages/Organization.tsx b/sdks/js/packages/sdk-demo/src/pages/Organization.tsx index e7f8667cd..1436ef167 100644 --- a/sdks/js/packages/sdk-demo/src/pages/Organization.tsx +++ b/sdks/js/packages/sdk-demo/src/pages/Organization.tsx @@ -12,6 +12,9 @@ export default function Organization() { showTokens={true} showPreferences={true} showAPIKeys={true} + onLogout={() => { + window.location.href = '/login'; + }} /> ) : null; diff --git a/ui/src/pages/users/details/security/sessions/index.tsx b/ui/src/pages/users/details/security/sessions/index.tsx index 2365e84db..87e1670b2 100644 --- a/ui/src/pages/users/details/security/sessions/index.tsx +++ b/ui/src/pages/users/details/security/sessions/index.tsx @@ -12,6 +12,13 @@ import styles from "./sessions.module.css"; dayjs.extend(relativeTime); +const getErrorMessage = (error: any): string => { + if (error?.status === 500) { + return 'Something went wrong'; + } + return error?.message || 'Something went wrong'; +}; + export const formatDeviceDisplay = (browser?: string, operatingSystem?: string): string => { const browserName = browser || "Unknown"; const osName = operatingSystem || "Unknown"; @@ -61,7 +68,7 @@ export const UserSessions = () => { }, onError: (error: any) => { toast.error('Failed to revoke session', { - description: error.message || 'Something went wrong' + description: getErrorMessage(error) }); }, }); @@ -152,9 +159,13 @@ export const UserSessions = () => { {session.metadata?.location || "Unknown location"} - - Last active {formatLastActive(session.updatedAt)} - + {session.isCurrentSession ? ( + Current session + ) : ( + + Last active {formatLastActive(session.updatedAt)} + + )}