Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 2 additions & 0 deletions web/apps/admin/src/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
OrganizationInvoicesView,
OrganizationTokensView,
OrganizationApisView,
OrganizationPatView,
useAdminPaths,
} from "@raystack/frontier/admin";

Expand Down Expand Up @@ -72,6 +73,7 @@ export default memo(function AppRoutes() {
<Route path="invoices" element={<OrganizationInvoicesView />} />
<Route path="tokens" element={<OrganizationTokensView />} />
<Route path="apis" element={<OrganizationApisView />} />
<Route path="pat" element={<OrganizationPatView />} />
</Route>
<Route path={paths.users} element={<UsersPage />}>
<Route path=":userId" element={<UsersPage />} />
Expand Down
1 change: 1 addition & 0 deletions web/sdk/admin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export { OrganizationProjectsView } from "./views/organizations/details/projects
export { OrganizationInvoicesView } from "./views/organizations/details/invoices";
export { OrganizationTokensView } from "./views/organizations/details/tokens";
export { OrganizationApisView } from "./views/organizations/details/apis";
export { OrganizationPatView } from "./views/organizations/details/pat";

// context exports
export {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ const NavLinks = ({
{ name: "Invoices", path: `${basePath}/invoices` },
{ name: "Tokens", path: `${basePath}/tokens` },
{ name: "API", path: `${basePath}/apis` },
{ name: "PAT", path: `${basePath}/pat` },
{ name: "Security", path: `${basePath}/security` },
];

Expand Down
133 changes: 133 additions & 0 deletions web/sdk/admin/views/organizations/details/pat/columns.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import {
Avatar,
Flex,
getAvatarColor,
Text,
type DataTableColumnDef,
} from "@raystack/apsara";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import type {
Project,
SearchOrganizationPATsResponse_OrganizationPAT,
} from "@raystack/proton/frontier";
import {
isNullTimestamp,
timestampToDayjs,
} from "~/admin/utils/connect-timestamp";
import { SCOPES } from "~/admin/utils/constants";
import { getInitials } from "~/utils";
import styles from "./pat.module.css";

dayjs.extend(relativeTime);

interface GetColumnsOptions {
projectsMap: Record<string, Project>;
}

const DATE_FORMAT = "DD MMM YYYY";

export function getColumns({
projectsMap,
}: GetColumnsOptions): DataTableColumnDef<
SearchOrganizationPATsResponse_OrganizationPAT,
unknown
>[] {
return [
{
accessorKey: "title",
header: "Title",
classNames: {
cell: styles["first-column"],
header: styles["first-column"],
},
cell: ({ getValue }) => {
const value = (getValue() as string) || "";
return <Text className={styles["truncate-text"]}>{value}</Text>;
},
enableSorting: true,
enableColumnFilter: true,
},
{
accessorKey: "scopes",
header: "Project",
classNames: { cell: styles["truncate-cell"] },
enableSorting: false,
cell: ({ row }) => {
const projectScope = row.original.scopes?.find(
(scope) => scope.resourceType === SCOPES.PROJECT,
);
const resourceIds = projectScope?.resourceIds ?? [];
if (resourceIds.length === 0) {
return <Text className={styles["truncate-text"]}>-</Text>;
}
const projectNamesText = resourceIds.map(
(id) => projectsMap[id]?.title || projectsMap[id]?.name || id,
).join(", ");
return <Text className={styles["truncate-text"]}>{projectNamesText}</Text>;
},
},
Comment thread
rohanchkrabrty marked this conversation as resolved.
{
accessorKey: "createdBy",
header: "Created By",
enableSorting: false,
enableColumnFilter: true,
enableHiding: true,
cell: ({ row }) => {
const createdBy = row.original.createdBy;
const userId = createdBy?.id || "";
const title = createdBy?.title || createdBy?.email || userId || "-";
const avatarColor = getAvatarColor(userId);
return (
<Flex gap={4} align="center">
<Avatar fallback={getInitials(title)} color={avatarColor} />
<Text className={styles["truncate-text"]}>{title}</Text>
</Flex>
);
},
},
{
accessorKey: "createdAt",
header: "Created On",
styles: { header: { width: "152px" } },
cell: ({ row }) => {
const date = timestampToDayjs(row.original.createdAt);
return date ? <Text>{date.format(DATE_FORMAT)}</Text> : <Text>-</Text>;
},
enableSorting: true,
enableColumnFilter: true,
filterType: "date",
enableHiding: true
},
{
accessorKey: "expiresAt",
header: "Expiry Date",
styles: { header: { width: "152px" } },
cell: ({ row }) => {
const expiresAt = row.original.expiresAt;
if (!expiresAt || isNullTimestamp(expiresAt)) return <Text>-</Text>;
const date = timestampToDayjs(expiresAt);
return date ? <Text>{date.format(DATE_FORMAT)}</Text> : <Text>-</Text>;
},
enableSorting: true,
enableColumnFilter: true,
filterType: "date",
enableHiding: true,
},
{
accessorKey: "usedAt",
header: "Last used",
styles: { header: { width: "152px" } },
enableSorting: false,
enableColumnFilter: true,
filterType: "date",
enableHiding: true,
cell: ({ row }) => {
const usedAt = row.original.usedAt;
if (!usedAt || isNullTimestamp(usedAt)) return <Text>-</Text>;
const date = timestampToDayjs(usedAt);
return date ? <Text>{date.fromNow()}</Text> : <Text>-</Text>;
},
},
];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
.details-dialog {
width: 100%;
max-width: 400px;
height: 500px;
display: flex;
flex-direction: column;
}

.dialog-body {
flex: 1;
overflow: hidden;
}

.tab-root {
gap: var(--rs-space-2);
height: 100%;
display: flex;
flex-direction: column;
}

.tab-content {
flex: 1;
min-height: 0;
overflow: auto;
}

.list-item {
padding: var(--rs-space-5) 0;
border-bottom: 0.5px solid var(--rs-color-border-base-primary);
}

.list-item:last-child {
border-bottom: none;
}

.list-item-text {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.skeleton {
flex: 1;
padding: var(--rs-space-5) 0;
}

.empty-state {
height: 200px;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { useMemo } from "react";
import {
Dialog,
Flex,
Skeleton,
Tabs,
Text,
} from "@raystack/apsara";
import { useQuery } from "@connectrpc/connect-query";
import {
FrontierServiceQueries,
type Project,
type Role,
type SearchOrganizationPATsResponse_OrganizationPAT,
} from "@raystack/proton/frontier";
import { useTerminology } from "~/admin/hooks/useTerminology";
import { SCOPES } from "~/admin/utils/constants";
import styles from "./pat-details-dialog.module.css";

interface PatDetailsDialogProps {
pat: SearchOrganizationPATsResponse_OrganizationPAT | null;
projectsMap: Record<string, Project>;
onClose: () => void;
}

export function PatDetailsDialog({
pat,
projectsMap,
onClose,
}: PatDetailsDialogProps) {
const t = useTerminology();
const open = pat !== null;

const projectScope = useMemo(
() => pat?.scopes?.find((s) => s.resourceType === SCOPES.PROJECT),
[pat],
);
const orgScope = useMemo(
() => pat?.scopes?.find((s) => s.resourceType === SCOPES.ORG),
[pat],
);

const scopeProjectIds = useMemo(() => projectScope?.resourceIds ?? [], [projectScope]);
const scopeProjects = useMemo(
() =>
scopeProjectIds
.map((id) => projectsMap[id])
.filter((p): p is Project => Boolean(p)),
[scopeProjectIds, projectsMap],
);

const { data: rolesData, isLoading: isRolesLoading } = useQuery(
FrontierServiceQueries.listRolesForPAT,
{ scopes: [SCOPES.ORG, SCOPES.PROJECT] },
{ enabled: open },
);
const rolesMap = useMemo(() => {
const map: Record<string, Role> = {};
(rolesData?.roles ?? []).forEach((role) => {
if (role.id) map[role.id] = role;
});
return map;
}, [rolesData]);

const orgRole = orgScope?.roleId ? rolesMap[orgScope.roleId] : undefined;
const projectRole = projectScope?.roleId
? rolesMap[projectScope.roleId]
: undefined;

const projectCount = scopeProjects.length;
const roleEntries = [
...(orgScope
? [
{
id: `org-${orgScope.roleId}`,
roleTitle: orgRole?.title || orgRole?.name || orgScope.roleId,
scopeLabel: t.organization({ case: "capital" }),
},
]
: []),
...(projectScope
? [
{
id: `project-${projectScope.roleId}`,
roleTitle:
projectRole?.title || projectRole?.name || projectScope.roleId,
scopeLabel: t.project({ plural: true, case: "capital" }),
},
]
: []),
];

const onOpenChange = (val: boolean) => {
if (!val) onClose();
};

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog.Content className={styles["details-dialog"]}>
<Dialog.Header>
<Dialog.Title>{pat?.title || ""}</Dialog.Title>
<Dialog.CloseButton data-test-id="frontier-sdk-pat-details-dialog-close-btn" />
</Dialog.Header>
<Dialog.Body className={styles["dialog-body"]}>
<Tabs defaultValue="projects" className={styles["tab-root"]}>
<Tabs.List>
<Tabs.Trigger value="projects">
{t.project({ plural: true, case: "capital" })} ({projectCount})
</Tabs.Trigger>
<Tabs.Trigger value="roles">
Roles ({roleEntries.length})
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="projects" className={styles["tab-content"]}>
{scopeProjects.length === 0 ? null : (
<Flex direction="column">
{scopeProjects.map((project) => (
<div key={project.id} className={styles["list-item"]}>
<Text
size="small"
weight="medium"
className={styles["list-item-text"]}
>
{project.title || project.name}
</Text>
</div>
))}
</Flex>
)}
</Tabs.Content>
<Tabs.Content value="roles" className={styles["tab-content"]}>
{isRolesLoading ? (
<Skeleton
containerClassName={styles["skeleton"]}
height={20}
count={4}
/>
) : roleEntries.length === 0 ? (
null
Comment thread
rohanchkrabrty marked this conversation as resolved.
) : (
<Flex direction="column">
{roleEntries.map((entry) => (
<Flex
key={entry.id}
direction="column"
gap={2}
className={styles["list-item"]}
>
<Text
size="small"
weight="medium"
className={styles["list-item-text"]}
>
{entry.roleTitle}
</Text>
<Text
size="micro"
variant="tertiary"
className={styles["list-item-text"]}
>
{entry.scopeLabel}
</Text>
</Flex>
))}
</Flex>
)}
</Tabs.Content>
</Tabs>
</Dialog.Body>
</Dialog.Content>
</Dialog>
);
}
Loading
Loading