Skip to content

Commit 03ffc19

Browse files
feat (Admin): Add sessions page (#1082)
1 parent b7b576b commit 03ffc19

File tree

7 files changed

+425
-3
lines changed

7 files changed

+425
-3
lines changed

ui/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
"@radix-ui/react-icons": "^1.3.0",
2020
"@raystack/apsara": "^0.51.0",
2121
"@raystack/frontier": "^0.72.4",
22-
"@raystack/proton": "^0.1.0-2dbafa5913a214851fdc4f1beefbe27c4a6de55a",
22+
"@raystack/proton": "^0.1.0-fba39927b8b974dc1cc1ae0f05f1390580ec6d58",
2323
"@stitches/react": "^1.2.8",
2424
"@tanstack/react-query": "^5.83.0",
2525
"@tanstack/react-table": "^8.9.3",

ui/src/pages/users/details/security/block-user.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,11 @@ export const BlockUserDialog = () => {
7373
<Dialog.Trigger asChild>
7474
<Button
7575
color={config.btnColor}
76-
size="small"
76+
size="normal"
77+
style={{
78+
alignSelf: 'flex-end',
79+
}}
80+
width={70}
7781
data-test-id="admin-ui-security-block-user"
7882
>
7983
{config.btnText}

ui/src/pages/users/details/security/security.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { Flex, Text } from "@raystack/apsara";
1+
import { Flex, Separator, Text } from "@raystack/apsara";
22
import PageTitle from "~/components/page-title";
33
import { useUser } from "../user-context";
44
import styles from "./security.module.css";
55
import { BlockUserDialog } from "./block-user";
6+
import { UserSessions } from "./sessions";
67

78
export const UserDetailsSecurityPage = () => {
89
const { user } = useUser();
@@ -12,7 +13,10 @@ export const UserDetailsSecurityPage = () => {
1213
return (
1314
<Flex justify="center" className={styles["container"]}>
1415
<PageTitle title={title} />
16+
1517
<Flex className={styles["content"]} direction="column" gap={9}>
18+
<UserSessions />
19+
<Separator />
1620
<Flex gap={5} justify="between">
1721
<Flex direction="column" gap={3}>
1822
<Text size={5}>Block user</Text>
@@ -21,6 +25,7 @@ export const UserDetailsSecurityPage = () => {
2125
unauthorized activities.
2226
</Text>
2327
</Flex>
28+
2429
<BlockUserDialog />
2530
</Flex>
2631
</Flex>
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import { useState } from "react";
2+
import { Flex, Text, Button, Skeleton, toast } from "@raystack/apsara/v1";
3+
import { useUser } from "../../user-context";
4+
import { RevokeSessionConfirm } from "./revoke-session-confirm";
5+
import { useQuery, useMutation, createConnectQueryKey, useTransport } from "@connectrpc/connect-query";
6+
import { useQueryClient } from "@tanstack/react-query";
7+
import { AdminServiceQueries, Session } from "@raystack/proton/frontier";
8+
import dayjs from "dayjs";
9+
import relativeTime from "dayjs/plugin/relativeTime";
10+
import { timestampToDate } from "~/utils/connect-timestamp";
11+
import styles from "./sessions.module.css";
12+
13+
dayjs.extend(relativeTime);
14+
15+
export const formatDeviceDisplay = (browser?: string, operatingSystem?: string): string => {
16+
const browserName = browser || "Unknown";
17+
const osName = operatingSystem || "Unknown";
18+
return browserName === "Unknown" && osName === "Unknown" ? "Unknown browser and OS" : `${browserName} on ${osName}`;
19+
};
20+
21+
export const UserSessions = () => {
22+
const { user } = useUser();
23+
const queryClient = useQueryClient();
24+
const transport = useTransport();
25+
const [isRevokeDialogOpen, setIsRevokeDialogOpen] = useState(false);
26+
const [selectedSession, setSelectedSession] = useState<{
27+
browser: string;
28+
operatingSystem: string;
29+
ipAddress: string;
30+
location: string;
31+
lastActive: string;
32+
sessionId: string;
33+
} | null>(null);
34+
35+
const {
36+
data: sessionsData,
37+
isLoading,
38+
error
39+
} = useQuery(
40+
AdminServiceQueries.listUserSessions,
41+
{ userId: user?.id || "" },
42+
{
43+
enabled: !!user?.id,
44+
}
45+
);
46+
47+
const {
48+
mutate: revokeUserSession,
49+
isPending: isRevokingSession,
50+
} = useMutation(AdminServiceQueries.revokeUserSession, {
51+
onSuccess: () => {
52+
queryClient.invalidateQueries({
53+
queryKey: createConnectQueryKey({
54+
schema: AdminServiceQueries.listUserSessions,
55+
transport,
56+
input: { userId: user?.id || "" },
57+
cardinality: "finite",
58+
}),
59+
});
60+
toast.success('Session revoked successfully');
61+
},
62+
onError: (error: any) => {
63+
toast.error('Failed to revoke session', {
64+
description: error.message || 'Something went wrong'
65+
});
66+
},
67+
});
68+
69+
const handleRevoke = (session: Session) => {
70+
setSelectedSession({
71+
browser: session.metadata?.browser || "Unknown",
72+
operatingSystem: session.metadata?.operatingSystem || "Unknown",
73+
ipAddress: session.metadata?.ipAddress || "Unknown",
74+
location: session.metadata?.location || "Unknown",
75+
lastActive: formatLastActive(session.updatedAt),
76+
sessionId: session.id || ""
77+
});
78+
setIsRevokeDialogOpen(true);
79+
};
80+
81+
const handleRevokeConfirm = () => {
82+
if (selectedSession?.sessionId) {
83+
revokeUserSession({ sessionId: selectedSession.sessionId });
84+
}
85+
};
86+
87+
88+
const formatLastActive = (updatedAt?: any) => {
89+
if (!updatedAt) return "Unknown";
90+
91+
const date = timestampToDate(updatedAt);
92+
if (!date) return "Unknown";
93+
94+
return dayjs(date).fromNow();
95+
};
96+
97+
const renderSessionsHeader = () => (
98+
<Flex direction="column" gap={3}>
99+
<Text size='large' weight='medium'>Sessions</Text>
100+
<Text size='regular' variant="secondary">
101+
Devices logged into this account.
102+
</Text>
103+
</Flex>
104+
);
105+
106+
if (isLoading) {
107+
return (
108+
<Flex direction="column" gap={9}>
109+
{renderSessionsHeader()}
110+
<Flex direction="column" className={styles.sessionsContainer}>
111+
<Skeleton
112+
height="32px"
113+
containerStyle={{ padding: '1rem 0' }}
114+
count={3}
115+
/>
116+
</Flex>
117+
</Flex>
118+
);
119+
}
120+
121+
if (error) {
122+
return (
123+
<Flex direction="column" gap={9}>
124+
{renderSessionsHeader()}
125+
<Flex justify="center" align="center" style={{ padding: "2rem" }}>
126+
<Text color="danger">Failed to load sessions</Text>
127+
</Flex>
128+
</Flex>
129+
);
130+
}
131+
132+
const sessions = sessionsData?.sessions || [];
133+
134+
return (
135+
<Flex direction="column" gap={9}>
136+
{renderSessionsHeader()}
137+
138+
<Flex direction="column" className={styles.sessionsContainer}>
139+
{sessions.length === 0 ? (
140+
<Flex justify="center" align="center" style={{ padding: "2rem" }}>
141+
<Text variant="secondary">No active sessions found</Text>
142+
</Flex>
143+
) : (
144+
sessions.map((session, index) => (
145+
<Flex key={session.id} justify="between" align="center" className={styles.sessionItem}>
146+
<Flex direction="column" gap={3}>
147+
<Text size="regular">
148+
{formatDeviceDisplay(session.metadata?.browser, session.metadata?.operatingSystem)}
149+
</Text>
150+
<Flex gap={2} align="center">
151+
<Text variant="tertiary" size="small">
152+
{session.metadata?.location || "Unknown location"}
153+
</Text>
154+
<Text variant="tertiary" size="small"></Text>
155+
<Text variant="tertiary" size="small">
156+
Last active {formatLastActive(session.updatedAt)}
157+
</Text>
158+
</Flex>
159+
</Flex>
160+
<Button
161+
variant="text"
162+
color="neutral"
163+
data-test-id={`frontier-ui-revoke-session-${index + 1}`}
164+
onClick={() => handleRevoke(session)}
165+
>
166+
Revoke
167+
</Button>
168+
</Flex>
169+
))
170+
)}
171+
</Flex>
172+
173+
<RevokeSessionConfirm
174+
isOpen={isRevokeDialogOpen}
175+
onOpenChange={setIsRevokeDialogOpen}
176+
sessionInfo={selectedSession || undefined}
177+
onRevokeConfirm={handleRevokeConfirm}
178+
isLoading={isRevokingSession}
179+
/>
180+
</Flex>
181+
);
182+
};
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { useState } from 'react';
2+
import {
3+
Button,
4+
Dialog,
5+
Flex,
6+
List
7+
} from '@raystack/apsara';
8+
import { RevokeSessionFinalConfirm } from './revoke-session-final-confirm';
9+
import { formatDeviceDisplay } from './index';
10+
import styles from './sessions.module.css';
11+
12+
interface RevokeSessionConfirmProps {
13+
isOpen: boolean;
14+
onOpenChange: (open: boolean) => void;
15+
sessionInfo?: {
16+
browser: string;
17+
operatingSystem: string;
18+
ipAddress: string;
19+
location: string;
20+
lastActive: string;
21+
};
22+
onRevokeConfirm: () => void;
23+
isLoading?: boolean;
24+
}
25+
26+
export const RevokeSessionConfirm = ({ isOpen, onOpenChange, sessionInfo, onRevokeConfirm, isLoading = false }: RevokeSessionConfirmProps) => {
27+
const [isFinalConfirmOpen, setIsFinalConfirmOpen] = useState(false);
28+
29+
const handleRevoke = () => {
30+
setIsFinalConfirmOpen(true);
31+
};
32+
33+
const handleFinalConfirm = () => {
34+
onRevokeConfirm();
35+
onOpenChange(false);
36+
};
37+
38+
return (
39+
<>
40+
<Dialog open={isOpen} onOpenChange={onOpenChange}>
41+
<Dialog.Content
42+
style={{ padding: 0, maxWidth: '400px', width: '100%' }}
43+
>
44+
<Dialog.Header className={styles.revokeSessionConfirmHeader}>
45+
<Dialog.Title>{sessionInfo ? formatDeviceDisplay(sessionInfo.browser, sessionInfo.operatingSystem) : "Unknown browser and OS"}</Dialog.Title>
46+
<Dialog.CloseButton data-test-id="frontier-ui-close-revoke-session-dialog" />
47+
</Dialog.Header>
48+
49+
<Dialog.Body className={styles.revokeSessionConfirmBody}>
50+
<List className={styles.listRoot}>
51+
<List.Item className={styles.listItem}>
52+
<List.Label minWidth="120px">Device</List.Label>
53+
<List.Value>{sessionInfo ? formatDeviceDisplay(sessionInfo.browser, sessionInfo.operatingSystem) : "Unknown"}</List.Value>
54+
</List.Item>
55+
<List.Item className={styles.listItem}>
56+
<List.Label minWidth="120px">IP Address</List.Label>
57+
<List.Value>{sessionInfo?.ipAddress || "Unknown"}</List.Value>
58+
</List.Item>
59+
<List.Item className={styles.listItem}>
60+
<List.Label minWidth="120px">Last Location</List.Label>
61+
<List.Value>{sessionInfo?.location || "Unknown"}</List.Value>
62+
</List.Item>
63+
<List.Item className={styles.listItem}>
64+
<List.Label minWidth="120px">Last Active</List.Label>
65+
<List.Value>{sessionInfo?.lastActive || "Unknown"}</List.Value>
66+
</List.Item>
67+
</List>
68+
</Dialog.Body>
69+
70+
<Dialog.Footer>
71+
<Flex justify="end" gap={5}>
72+
<Button
73+
variant="outline"
74+
color="neutral"
75+
onClick={() => onOpenChange(false)}
76+
disabled={isLoading}
77+
data-test-id="frontier-ui-cancel-revoke-session-dialog"
78+
>
79+
Cancel
80+
</Button>
81+
<Button
82+
variant="solid"
83+
color="danger"
84+
onClick={handleRevoke}
85+
loading={isLoading}
86+
loaderText="Revoking..."
87+
data-test-id="frontier-ui-confirm-revoke-session-dialog"
88+
>
89+
Revoke
90+
</Button>
91+
</Flex>
92+
</Dialog.Footer>
93+
</Dialog.Content>
94+
</Dialog>
95+
96+
<RevokeSessionFinalConfirm
97+
isOpen={isFinalConfirmOpen}
98+
onOpenChange={setIsFinalConfirmOpen}
99+
onConfirm={handleFinalConfirm}
100+
isLoading={isLoading}
101+
/>
102+
</>
103+
);
104+
};

0 commit comments

Comments
 (0)