Skip to content
Open
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
32 changes: 32 additions & 0 deletions web/apps/admin/src/pages/users/UsersPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { UsersView } from "@raystack/frontier/admin";
import { useCallback } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { clients } from "~/connect/clients";
import { exportCsvFromStream } from "~/utils/helper";

const adminClient = clients.admin({ useBinary: true });

export function UsersPage() {
const { userId } = useParams();
const navigate = useNavigate();

const onExportUsers = useCallback(async () => {
await exportCsvFromStream(adminClient.exportUsers, {}, "users.csv");
}, []);

const onNavigateToUser = useCallback(
(id: string) => {
navigate(`/users/${id}/security`);
},
[navigate],
);

return (
<UsersView
selectedUserId={userId}
onCloseDetail={() => navigate("/users")}
onExportUsers={onExportUsers}
onNavigateToUser={onNavigateToUser}
/>
);
}
11 changes: 4 additions & 7 deletions web/apps/admin/src/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,7 @@ import { OrganizationInvoicesPage } from "./pages/organizations/details/invoices
import { OrganizationTokensPage } from "./pages/organizations/details/tokens";
import { OrganizationApisPage } from "./pages/organizations/details/apis";

import { UsersList } from "./pages/users/list";
import { UserDetails } from "./pages/users/details";
import { UserDetailsSecurityPage } from "./pages/users/details/security";
import { UsersPage } from "./pages/users/UsersPage";

import { InvoicesPage } from "./pages/invoices/InvoicesPage";
import { AuditLogsPage } from "./pages/audit-logs/AuditLogsPage";
Expand Down Expand Up @@ -71,10 +69,9 @@ export default memo(function AppRoutes() {
<Route path="tokens" element={<OrganizationTokensPage />} />
<Route path="apis" element={<OrganizationApisPage />} />
</Route>
<Route path="users" element={<UsersList />} />
<Route path="users/:userId" element={<UserDetails />}>
<Route index element={<Navigate to="security" />} />
<Route path="security" element={<UserDetailsSecurityPage />} />
<Route path="users" element={<UsersPage />}>
<Route path=":userId" element={<UsersPage />} />
<Route path=":userId/security" element={<UsersPage />} />
</Route>

<Route path="audit-logs" element={<AuditLogsPage />} />
Expand Down
24 changes: 24 additions & 0 deletions web/lib/admin/assets/icons/UsersIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { SVGProps } from "react";

export function UsersIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g id="users">
<path
id="Vector"
d="M5.6 6.4C6.23652 6.4 6.84697 6.14714 7.29706 5.69705C7.74715 5.24697 8 4.63652 8 4C8 3.36348 7.74715 2.75303 7.29706 2.30294C6.84697 1.85285 6.23652 1.6 5.6 1.6C4.96348 1.6 4.35303 1.85285 3.90295 2.30294C3.45286 2.75303 3.2 3.36348 3.2 4C3.2 4.63652 3.45286 5.24697 3.90295 5.69705C4.35303 6.14714 4.96348 6.4 5.6 6.4ZM11.6 7.2C12.1304 7.2 12.6391 6.98928 13.0142 6.61421C13.3893 6.23914 13.6 5.73043 13.6 5.2C13.6 4.66957 13.3893 4.16086 13.0142 3.78578C12.6391 3.41071 12.1304 3.2 11.6 3.2C11.0696 3.2 10.5609 3.41071 10.1858 3.78578C9.81072 4.16086 9.6 4.66957 9.6 5.2C9.6 5.73043 9.81072 6.23914 10.1858 6.61421C10.5609 6.98928 11.0696 7.2 11.6 7.2ZM1.292 13.1424C1.13515 13.0446 1.00917 12.9045 0.928616 12.7382C0.848065 12.5718 0.816233 12.3861 0.836803 12.2024C0.984287 11.0432 1.5494 9.97765 2.42625 9.2053C3.3031 8.43294 4.4315 8.00684 5.6 8.00684C6.7685 8.00684 7.8969 8.43294 8.77375 9.2053C9.6506 9.97765 10.2157 11.0432 10.3632 12.2024C10.4096 12.576 10.2256 12.9384 9.9072 13.1416C8.6218 13.9653 7.12669 14.4021 5.6 14.4C4.07313 14.4025 2.57772 13.966 1.292 13.1424ZM11.6 12.8H11.5152C11.5712 12.5624 11.5856 12.3112 11.5536 12.0536C11.4169 10.9538 10.977 9.91377 10.2832 9.0496C10.7297 8.87391 11.2068 8.7892 11.6865 8.80046C12.1662 8.81172 12.6388 8.91872 13.0765 9.11517C13.5143 9.31162 13.9084 9.59356 14.2357 9.94443C14.563 10.2953 14.8168 10.708 14.9824 11.1584C15.0333 11.3022 15.0323 11.4592 14.9797 11.6023C14.9272 11.7455 14.8262 11.8657 14.6944 11.9424C13.7606 12.5054 12.6904 12.802 11.6 12.8Z"
fill="currentColor"
/>
</g>
</svg>
);
}

export default UsersIcon;
213 changes: 213 additions & 0 deletions web/lib/admin/components/AssignRole.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import {
Button,
Checkbox,
Dialog,
Flex,
Label,
Text,
toast,
} from "@raystack/apsara";
import { useCallback } from "react";
import type {
SearchOrganizationUsersResponse_OrganizationUser,
Role,
Policy,
} from "@raystack/proton/frontier";
import {
FrontierService,
FrontierServiceQueries,
ListPoliciesRequestSchema,
DeletePolicyRequestSchema,
CreatePolicyRequestSchema,
} from "@raystack/proton/frontier";
import { create } from "@bufbuild/protobuf";
import { useMutation, useTransport } from "@connectrpc/connect-query";
import { createClient } from "@connectrpc/connect";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

interface AssignRoleProps {
organizationId: string;
roles: Role[];
user?: SearchOrganizationUsersResponse_OrganizationUser;
onRoleUpdate: () => void;
onClose: () => void;
}

const formSchema = z.object({
roleIds: z.instanceof(Set<string>).refine((set) => set.size > 0, {
message: "At least one role must be selected",
}),
});

type FormData = z.infer<typeof formSchema>;

export const AssignRole = ({
roles = [],
user,
organizationId,
onRoleUpdate,
onClose,
}: AssignRoleProps) => {
const transport = useTransport();

const {
handleSubmit,
watch,
setValue,
formState: { isSubmitting, errors },
} = useForm<FormData>({
defaultValues: {
roleIds: new Set(user?.roleIds || []),
},
resolver: zodResolver(formSchema),
});

const { mutateAsync: deletePolicy } = useMutation(
FrontierServiceQueries.deletePolicy,
);

const { mutateAsync: createPolicy } = useMutation(
FrontierServiceQueries.createPolicy,
);

const roleIds = watch("roleIds");

function onCheckedChange(value: boolean | string, roleId?: string) {
if (!roleId) return;
const currentRoles = new Set(roleIds);

if (value) {
currentRoles.add(roleId);
} else {
currentRoles.delete(roleId);
}

setValue("roleIds", currentRoles);
}

const checkRole = useCallback(
(roleId?: string) => {
if (!roleId) return false;
return roleIds?.has(roleId) || false;
},
[roleIds],
);

const onSubmit = async (data: FormData) => {
try {
const client = createClient(FrontierService, transport);
const policiesResp = await client.listPolicies(
create(ListPoliciesRequestSchema, {
orgId: organizationId,
userId: user?.id,
}),
);
const policies = policiesResp.policies || [];

const removedRolesPolicies = policies.filter(
(policy: Policy) => !(policy.roleId && data.roleIds.has(policy.roleId)),
);
await Promise.all(
removedRolesPolicies.map((policy: Policy) =>
deletePolicy(
create(DeletePolicyRequestSchema, { id: policy.id || "" }),
),
),
);

const resource = `app/organization:${organizationId}`;
const principal = `app/user:${user?.id}`;

const assignedRolesArr = Array.from(data.roleIds);
await Promise.all(
assignedRolesArr.map((roleId) =>
createPolicy(
create(CreatePolicyRequestSchema, {
body: {
roleId,
resource,
principal,
},
}),
),
),
);

if (onRoleUpdate) {
onRoleUpdate();
}

toast.success("Role assigned successfully");
} catch (error) {
toast.error("Failed to assign role");
console.error(error);
}
};

return (
<Dialog open onOpenChange={onClose}>
<Dialog.Content width={400}>
<Dialog.Header>
<Dialog.Title>Assign Role</Dialog.Title>
<Dialog.CloseButton data-test-id="assign-role-close-button" />
</Dialog.Header>
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<Dialog.Body>
<Flex direction="column" gap={7}>
<Text variant="secondary">
Taking this action may result in changes in the role which might
lead to changes in access of the user.
</Text>
<div role="group" aria-labelledby="roles-group">
<Flex direction="column" gap={4}>
{roles.map((role) => {
const htmlId = `role-${role.id}`;
const checked = checkRole(role.id);
return (
<Flex gap={3} key={role.id}>
<Checkbox
id={htmlId}
data-test-id={`role-checkbox-${role.id}`}
checked={checked}
onCheckedChange={(value) =>
onCheckedChange(value, role.id)
}
/>
<Label htmlFor={htmlId}>{role.title}</Label>
</Flex>
);
})}
{errors.roleIds && (
<Text variant="danger">{errors.roleIds.message}</Text>
)}
</Flex>
</div>
</Flex>
</Dialog.Body>
<Dialog.Footer>
<Dialog.Close asChild>
<Button
type="button"
variant="outline"
color="neutral"
data-test-id="assign-role-cancel-button"
>
Cancel
</Button>
</Dialog.Close>
<Button
type="submit"
data-test-id="assign-role-update-button"
loading={isSubmitting}
loaderText="Updating..."
>
Update
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog>
);
};
1 change: 1 addition & 0 deletions web/lib/admin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export { default as AdminsView } from "./views/admins";
export { default as PlansView } from "./views/plans";
export { default as WebhooksView } from "./views/webhooks/webhooks";
export { default as PreferencesView } from "./views/preferences/PreferencesView";
export { default as UsersView } from "./views/users/UsersView";

// utils exports
export {
Expand Down
6 changes: 6 additions & 0 deletions web/lib/admin/utils/connect-timestamp.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { timestampDate, type Timestamp } from "@bufbuild/protobuf/wkt";
import dayjs, { type Dayjs } from "dayjs";

export function timestampToDate(timestamp?: Timestamp): Date | null {
if (!timestamp) return null;
return timestampDate(timestamp);
}

export function timestampToDayjs(timestamp?: Timestamp): Dayjs | null {
const date = timestampToDate(timestamp);
return date ? dayjs(date) : null;
}

/**
* Checks if a ConnectRPC Timestamp is the null time (0001-01-01T00:00:00Z)
*/
Expand Down
13 changes: 13 additions & 0 deletions web/lib/admin/utils/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export const SCOPES = {
ORG: "app/organization",
PROJECT: "app/project",
GROUP: "app/group",
} as const;

export const DEFAULT_ROLES = {
ORG_MANAGER: "app_organization_manager",
ORG_OWNER: "app_organization_owner",
ORG_BILLING_MANAGER: "app_billing_manager",
ORG_VIEWER: "app_organization_viewer",
PROJECT_VIEWER: "app_project_viewer",
} as const;
27 changes: 27 additions & 0 deletions web/lib/admin/views/users/UsersView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { UsersList } from "./list/list";
import { UserDetailsByUserId } from "./details/user-details";

export type UsersViewProps = {
selectedUserId?: string;
onCloseDetail?: () => void;
onExportUsers?: () => Promise<void>;
onNavigateToUser?: (userId: string) => void;
};

export default function UsersView({
selectedUserId,
onCloseDetail,
onExportUsers,
onNavigateToUser,
}: UsersViewProps = {}) {
if (selectedUserId) {
return <UserDetailsByUserId userId={selectedUserId} />;
}

return (
<UsersList
onExportUsers={onExportUsers}
onNavigateToUser={onNavigateToUser}
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import {
} from "@raystack/proton/frontier";
import { create } from "@bufbuild/protobuf";
import { useQuery } from "@connectrpc/connect-query";
import { SCOPES } from "~/utils/constants";
import { AssignRole } from "~/components/assign-role";
import { SCOPES } from "../../../../utils/constants";
import { AssignRole } from "../../../../components/AssignRole";
import { useUser } from "../user-context";
import { SuspendUser } from "./suspend-user";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
} from "@raystack/apsara";
import { NavLink } from "react-router-dom";
import { SidebarIcon } from "@raystack/apsara/icons";
import UserIcon from "~/assets/icons/users.svg?react";
import UserIcon from "../../../../assets/icons/UsersIcon";
import styles from "./navbar.module.css";
import { getUserName } from "../../util";
import { useUser } from "../user-context";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { CalendarIcon } from "@radix-ui/react-icons";
import styles from "./side-panel.module.css";
import { UserState, USER_STATES } from "../../util";
import { useUser } from "../user-context";
import { timestampToDayjs } from "~/utils/connect-timestamp";
import { timestampToDayjs } from "../../../../utils/connect-timestamp";

export const SidePanelDetails = () => {
const { user } = useUser();
Expand Down
Loading
Loading