Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
d96a702
feat: session page admin ui
paanSinghCoder Jul 25, 2025
73d1597
Merge branch 'main' into feat/admin-session-page
paanSinghCoder Aug 18, 2025
e5ade72
Merge branch 'main' into feat/admin-session-page
paanSinghCoder Aug 21, 2025
1fa7f5c
Merge branch 'main' into feat/admin-session-page
paanSinghCoder Aug 21, 2025
0031694
Merge branch 'main' into feat/admin-session-page
paanSinghCoder Sep 3, 2025
b65db49
Merge branch 'main' into feat/admin-session-page
paanSinghCoder Sep 23, 2025
e5dc494
Update ui/src/pages/users/details/security/sessions/revoke-session-co…
paanSinghCoder Sep 23, 2025
73cb756
Update ui/src/pages/users/details/security/sessions/revoke-session-co…
paanSinghCoder Sep 23, 2025
c087892
Update ui/src/pages/users/details/security/sessions/revoke-session-co…
paanSinghCoder Sep 23, 2025
7f62a5e
feat: add listUserSession API
paanSinghCoder Sep 24, 2025
f448407
chore: update proton npm package
paanSinghCoder Sep 24, 2025
88d62fe
Merge branch 'main' into feat/admin-session-page
paanSinghCoder Sep 24, 2025
50bf685
feat: add listUserSession and revokeUserSession APIs
paanSinghCoder Sep 25, 2025
7e926f4
Merge branch 'main' into feat/admin-session-page
paanSinghCoder Sep 25, 2025
206969c
refactor: remove comment and take out common component
paanSinghCoder Sep 25, 2025
97109a5
fix: remove device and add browser
paanSinghCoder Sep 25, 2025
328c2b8
Merge branch 'main' into feat/admin-session-page
paanSinghCoder Sep 26, 2025
954120d
Merge branch 'main' into feat/admin-session-page
paanSinghCoder Sep 29, 2025
12f1e2e
chore: replace spinner with skeleton
paanSinghCoder Sep 29, 2025
069f825
Merge branch 'main' into feat/admin-session-page
paanSinghCoder Oct 3, 2025
244012d
style: fix Block button css
paanSinghCoder Oct 3, 2025
bd13466
chore: error Unknown states
paanSinghCoder Oct 3, 2025
9e7630f
fix: loading state
paanSinghCoder Oct 3, 2025
9a600aa
style: increase skeleton height
paanSinghCoder Oct 3, 2025
5384baf
Merge branch 'main' into feat/admin-session-page
paanSinghCoder Oct 6, 2025
808ea9c
fix: duplicate imports
paanSinghCoder Oct 6, 2025
1855d97
chore: import session interface
paanSinghCoder Oct 6, 2025
497181f
fix: use inbuilt helper function
paanSinghCoder Oct 6, 2025
905e40d
Merge branch 'main' into feat/admin-session-page
paanSinghCoder Oct 6, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"@radix-ui/react-icons": "^1.3.0",
"@raystack/apsara": "^0.51.0",
"@raystack/frontier": "^0.72.4",
"@raystack/proton": "^0.1.0-2dbafa5913a214851fdc4f1beefbe27c4a6de55a",
"@raystack/proton": "^0.1.0-fba39927b8b974dc1cc1ae0f05f1390580ec6d58",
"@stitches/react": "^1.2.8",
"@tanstack/react-query": "^5.83.0",
"@tanstack/react-table": "^8.9.3",
Expand Down
6 changes: 5 additions & 1 deletion ui/src/pages/users/details/security/block-user.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,11 @@ export const BlockUserDialog = () => {
<Dialog.Trigger asChild>
<Button
color={config.btnColor}
size="small"
size="normal"
style={{
alignSelf: 'flex-end',
}}
width={70}
data-test-id="admin-ui-security-block-user"
>
{config.btnText}
Expand Down
7 changes: 6 additions & 1 deletion ui/src/pages/users/details/security/security.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Flex, Text } from "@raystack/apsara";
import { Flex, Separator, Text } from "@raystack/apsara";
import PageTitle from "~/components/page-title";
import { useUser } from "../user-context";
import styles from "./security.module.css";
import { BlockUserDialog } from "./block-user";
import { UserSessions } from "./sessions";

export const UserDetailsSecurityPage = () => {
const { user } = useUser();
Expand All @@ -12,7 +13,10 @@ export const UserDetailsSecurityPage = () => {
return (
<Flex justify="center" className={styles["container"]}>
<PageTitle title={title} />

<Flex className={styles["content"]} direction="column" gap={9}>
<UserSessions />
<Separator />
<Flex gap={5} justify="between">
<Flex direction="column" gap={3}>
<Text size={5}>Block user</Text>
Expand All @@ -21,6 +25,7 @@ export const UserDetailsSecurityPage = () => {
unauthorized activities.
</Text>
</Flex>

<BlockUserDialog />
</Flex>
</Flex>
Expand Down
182 changes: 182 additions & 0 deletions ui/src/pages/users/details/security/sessions/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { useState } from "react";
import { Flex, Text, Button, Skeleton, toast } from "@raystack/apsara/v1";
import { useUser } from "../../user-context";
import { RevokeSessionConfirm } from "./revoke-session-confirm";
import { useQuery, useMutation, createConnectQueryKey, useTransport } from "@connectrpc/connect-query";
import { useQueryClient } from "@tanstack/react-query";
import { AdminServiceQueries, Session } from "@raystack/proton/frontier";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { timestampToDate } from "~/utils/connect-timestamp";
import styles from "./sessions.module.css";

dayjs.extend(relativeTime);

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 UserSessions = () => {
const { user } = useUser();
const queryClient = useQueryClient();
const transport = useTransport();
const [isRevokeDialogOpen, setIsRevokeDialogOpen] = useState(false);
const [selectedSession, setSelectedSession] = useState<{
browser: string;
operatingSystem: string;
ipAddress: string;
location: string;
lastActive: string;
sessionId: string;
} | null>(null);

const {
data: sessionsData,
isLoading,
error
} = useQuery(
AdminServiceQueries.listUserSessions,
{ userId: user?.id || "" },
{
enabled: !!user?.id,
}
);

const {
mutate: revokeUserSession,
isPending: isRevokingSession,
} = useMutation(AdminServiceQueries.revokeUserSession, {
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: createConnectQueryKey({
schema: AdminServiceQueries.listUserSessions,
transport,
input: { userId: user?.id || "" },
cardinality: "finite",
}),
});
toast.success('Session revoked successfully');
},
onError: (error: any) => {
toast.error('Failed to revoke session', {
description: error.message || 'Something went wrong'
});
},
});

const handleRevoke = (session: Session) => {
setSelectedSession({
browser: session.metadata?.browser || "Unknown",
operatingSystem: session.metadata?.operatingSystem || "Unknown",
ipAddress: session.metadata?.ipAddress || "Unknown",
location: session.metadata?.location || "Unknown",
lastActive: formatLastActive(session.updatedAt),
sessionId: session.id || ""
});
setIsRevokeDialogOpen(true);
};

const handleRevokeConfirm = () => {
if (selectedSession?.sessionId) {
revokeUserSession({ sessionId: selectedSession.sessionId });
}
};


const formatLastActive = (updatedAt?: any) => {
if (!updatedAt) return "Unknown";

const date = timestampToDate(updatedAt);
if (!date) return "Unknown";

return dayjs(date).fromNow();
};

const renderSessionsHeader = () => (
<Flex direction="column" gap={3}>
<Text size='large' weight='medium'>Sessions</Text>
<Text size='regular' variant="secondary">
Devices logged into this account.
</Text>
</Flex>
);

if (isLoading) {
return (
<Flex direction="column" gap={9}>
{renderSessionsHeader()}
<Flex direction="column" className={styles.sessionsContainer}>
<Skeleton
height="32px"
containerStyle={{ padding: '1rem 0' }}
count={3}
/>
</Flex>
</Flex>
);
}

if (error) {
return (
<Flex direction="column" gap={9}>
{renderSessionsHeader()}
<Flex justify="center" align="center" style={{ padding: "2rem" }}>
<Text color="danger">Failed to load sessions</Text>
</Flex>
</Flex>
);
}

const sessions = sessionsData?.sessions || [];

return (
<Flex direction="column" gap={9}>
{renderSessionsHeader()}

<Flex direction="column" className={styles.sessionsContainer}>
{sessions.length === 0 ? (
<Flex justify="center" align="center" style={{ padding: "2rem" }}>
<Text variant="secondary">No active sessions found</Text>
</Flex>
) : (
sessions.map((session, index) => (
<Flex key={session.id} justify="between" align="center" className={styles.sessionItem}>
<Flex direction="column" gap={3}>
<Text size="regular">
{formatDeviceDisplay(session.metadata?.browser, session.metadata?.operatingSystem)}
</Text>
<Flex gap={2} align="center">
<Text variant="tertiary" size="small">
{session.metadata?.location || "Unknown location"}
</Text>
<Text variant="tertiary" size="small">•</Text>
<Text variant="tertiary" size="small">
Last active {formatLastActive(session.updatedAt)}
</Text>
</Flex>
</Flex>
<Button
variant="text"
color="neutral"
data-test-id={`frontier-ui-revoke-session-${index + 1}`}
onClick={() => handleRevoke(session)}
>
Revoke
</Button>
</Flex>
))
)}
</Flex>

<RevokeSessionConfirm
isOpen={isRevokeDialogOpen}
onOpenChange={setIsRevokeDialogOpen}
sessionInfo={selectedSession || undefined}
onRevokeConfirm={handleRevokeConfirm}
isLoading={isRevokingSession}
/>
</Flex>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { useState } from 'react';
import {
Button,
Dialog,
Flex,
List
} from '@raystack/apsara';
import { RevokeSessionFinalConfirm } from './revoke-session-final-confirm';
import { formatDeviceDisplay } from './index';
import styles from './sessions.module.css';

interface RevokeSessionConfirmProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
sessionInfo?: {
browser: string;
operatingSystem: string;
ipAddress: string;
location: string;
lastActive: string;
};
onRevokeConfirm: () => void;
isLoading?: boolean;
}

export const RevokeSessionConfirm = ({ isOpen, onOpenChange, sessionInfo, onRevokeConfirm, isLoading = false }: RevokeSessionConfirmProps) => {
const [isFinalConfirmOpen, setIsFinalConfirmOpen] = useState(false);

const handleRevoke = () => {
setIsFinalConfirmOpen(true);
};

const handleFinalConfirm = () => {
onRevokeConfirm();
onOpenChange(false);
};

return (
<>
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<Dialog.Content
style={{ padding: 0, maxWidth: '400px', width: '100%' }}
>
<Dialog.Header className={styles.revokeSessionConfirmHeader}>
<Dialog.Title>{sessionInfo ? formatDeviceDisplay(sessionInfo.browser, sessionInfo.operatingSystem) : "Unknown browser and OS"}</Dialog.Title>
<Dialog.CloseButton data-test-id="frontier-ui-close-revoke-session-dialog" />
</Dialog.Header>

<Dialog.Body className={styles.revokeSessionConfirmBody}>
<List className={styles.listRoot}>
<List.Item className={styles.listItem}>
<List.Label minWidth="120px">Device</List.Label>
<List.Value>{sessionInfo ? formatDeviceDisplay(sessionInfo.browser, sessionInfo.operatingSystem) : "Unknown"}</List.Value>
</List.Item>
<List.Item className={styles.listItem}>
<List.Label minWidth="120px">IP Address</List.Label>
<List.Value>{sessionInfo?.ipAddress || "Unknown"}</List.Value>
</List.Item>
<List.Item className={styles.listItem}>
<List.Label minWidth="120px">Last Location</List.Label>
<List.Value>{sessionInfo?.location || "Unknown"}</List.Value>
</List.Item>
<List.Item className={styles.listItem}>
<List.Label minWidth="120px">Last Active</List.Label>
<List.Value>{sessionInfo?.lastActive || "Unknown"}</List.Value>
</List.Item>
</List>
</Dialog.Body>

<Dialog.Footer>
<Flex justify="end" gap={5}>
<Button
variant="outline"
color="neutral"
onClick={() => onOpenChange(false)}
disabled={isLoading}
data-test-id="frontier-ui-cancel-revoke-session-dialog"
>
Cancel
</Button>
<Button
variant="solid"
color="danger"
onClick={handleRevoke}
loading={isLoading}
loaderText="Revoking..."
data-test-id="frontier-ui-confirm-revoke-session-dialog"
>
Revoke
</Button>
</Flex>
</Dialog.Footer>
</Dialog.Content>
</Dialog>

<RevokeSessionFinalConfirm
isOpen={isFinalConfirmOpen}
onOpenChange={setIsFinalConfirmOpen}
onConfirm={handleFinalConfirm}
isLoading={isLoading}
/>
</>
);
};
Loading
Loading