((props, ref) => {
+ const outerProps = useContext(OuterElementContext);
+ return ;
+});
+export const VirtualizedList = forwardRef((props: any, ref: any) => {
+ const itemCount = props.children.length;
+ const gridRef = useResetCache(itemCount);
+ const outerProps = { ...props };
+ delete outerProps.children;
+ return (
+
+
+ props.rowheight}
+ overscanCount={5}
+ itemData={{ ...props.children }}
+ >
+ {Row}
+
+
+
+ );
+});
diff --git a/src/components/accounts/ui/dialogs/index.js b/src/components/accounts/ui/dialogs/index.js
index 4fbf5bd8d..41df76df5 100644
--- a/src/components/accounts/ui/dialogs/index.js
+++ b/src/components/accounts/ui/dialogs/index.js
@@ -17,6 +17,9 @@ export const SuccessMsg = ({
title,
timer: 2500,
confirmButtonColor: light.primary.main,
+ customClass: {
+ container: 'swal-zindex-override',
+ },
}).then(() => action());
};
@@ -36,6 +39,9 @@ export const ErrorMsg = ({
confirmButtonColor: light.zesty.zestyRose,
timer,
timerProgressBar,
+ customClass: {
+ container: 'swal-zindex-override',
+ },
});
};
@@ -85,6 +91,9 @@ export const DeleteMsg = ({
confirmButtonText: 'Yes',
confirmButtonColor: '#3085d6',
cancelButtonColor: '#d33',
+ customClass: {
+ container: 'swal-zindex-override',
+ },
}).then((result) => {
if (result.isConfirmed) {
action();
diff --git a/src/components/accounts/ui/header/index.js b/src/components/accounts/ui/header/index.js
index a9edf6533..9ff68ffb6 100644
--- a/src/components/accounts/ui/header/index.js
+++ b/src/components/accounts/ui/header/index.js
@@ -1,39 +1,43 @@
import React from 'react';
-import { Grid, Stack, Typography } from '@mui/material';
+import { Grid, Stack, Typography, ThemeProvider } from '@mui/material';
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
+import { theme } from '@zesty-io/material';
const Index = ({ title, description, info, children }) => {
return (
-
-
-
-
-
- {title}
-
-
+
+
+
+
+
+
+ {title}
+
+
+
+
+
+ {description}
+
+
-
-
- {description}
-
+
+ {children}
-
- {children}
-
-
-
+
+
);
};
export const AccountsHeader = React.memo(Index);
diff --git a/src/components/globals/NoPermission.jsx b/src/components/globals/NoPermission.jsx
new file mode 100644
index 000000000..a64fb533c
--- /dev/null
+++ b/src/components/globals/NoPermission.jsx
@@ -0,0 +1,80 @@
+import { useMemo } from 'react';
+import {
+ Stack,
+ Box,
+ Typography,
+ Avatar,
+ List,
+ ListItem,
+ ListItemText,
+ ListItemAvatar,
+} from '@mui/material';
+
+import { hashMD5 } from 'utils/Md5Hash';
+import shield from '../../../public/assets/images/shield.svg';
+
+export const NoPermission = ({ users }) => {
+ const ownersAndAdmins = useMemo(() => {
+ if (!users && !users?.length) return [];
+
+ const owners = users
+ .filter((user) => user.role?.name?.toLowerCase() === 'owner')
+ .sort((a, b) => a.firstName.localeCompare(b.firstName));
+ const admins = users
+ .filter((user) => user.role?.name?.toLowerCase() === 'admin')
+ .sort((a, b) => a.firstName.localeCompare(b.firstName));
+
+ return [...owners, ...admins];
+ }, [users]);
+
+ return (
+
+
+
+ You need permission to view and edit Roles & Permissions
+
+
+ Contact the instance owner or administrators listed below to upgrade
+ your role to Admin or Owner for this capability.
+
+
+ {ownersAndAdmins?.map((user) => (
+
+
+
+
+
+
+ ))}
+
+
+
+
+ );
+};
diff --git a/src/mui.d.ts b/src/mui.d.ts
new file mode 100644
index 000000000..9af9d5043
--- /dev/null
+++ b/src/mui.d.ts
@@ -0,0 +1,27 @@
+import { Color } from '@mui/material';
+
+declare module '@mui/material/Typography' {
+ export interface TypographyPropsVariantOverrides {
+ body3: true;
+ }
+}
+
+declare module '@mui/material/styles' {
+ export interface Palette {
+ red: Color;
+ deepPurple: Color;
+ deepOrange: Color;
+ pink: Color;
+ blue: Color;
+ green: Color;
+ purple: Color;
+ yellow: Color;
+ }
+}
+
+declare module '@mui/material/IconButton' {
+ interface IconButtonPropsSizeOverrides {
+ xsmall: true;
+ xxsmall: true;
+ }
+}
diff --git a/src/pages/instances/[zuid]/roles.tsx b/src/pages/instances/[zuid]/roles.tsx
new file mode 100644
index 000000000..729c25f6c
--- /dev/null
+++ b/src/pages/instances/[zuid]/roles.tsx
@@ -0,0 +1,59 @@
+import { useState, useEffect, useMemo } from 'react';
+import { useRouter } from 'next/router';
+
+import { useZestyStore } from 'store';
+import { useRoles } from 'store/roles';
+import { useInstance } from 'store/instance';
+import InstanceContainer from 'components/accounts/instances/InstanceContainer';
+import { Roles } from 'views/accounts';
+import { ErrorMsg } from 'components/accounts';
+
+export { default as getServerSideProps } from 'lib/accounts/protectedRouteGetServerSideProps';
+
+export default function RolesPage() {
+ const router = useRouter();
+ const { userInfo, loading } = useZestyStore((state) => state);
+ const { usersWithRoles, getRoles, getUsersWithRoles } = useRoles(
+ (state) => state,
+ );
+ const { getInstanceModels, getInstanceContentItems, getLanguages } =
+ useInstance((state) => state);
+ const [isInitializingData, setIsInitializingData] = useState(true);
+
+ const { zuid } = router.query;
+
+ const hasPermission = useMemo(() => {
+ if (!userInfo?.ZUID || !usersWithRoles?.length) return false;
+
+ return ['admin', 'owner'].includes(
+ usersWithRoles
+ ?.find((user) => user.ZUID === userInfo?.ZUID)
+ ?.role?.name?.toLowerCase(),
+ );
+ }, [userInfo, usersWithRoles]);
+
+ useEffect(() => {
+ if (router.isReady) {
+ const instanceZUID = String(zuid);
+
+ Promise.all([
+ getUsersWithRoles(instanceZUID),
+ getRoles(instanceZUID),
+ getInstanceModels(),
+ getInstanceContentItems(),
+ getLanguages('all'),
+ ])
+ .catch(() => ErrorMsg({ title: 'Failed to fetch page data' }))
+ .finally(() => setIsInitializingData(false));
+ }
+ }, [router.isReady]);
+
+ return (
+
+
+
+ );
+}
diff --git a/src/store/instance.ts b/src/store/instance.ts
new file mode 100644
index 000000000..606a6294f
--- /dev/null
+++ b/src/store/instance.ts
@@ -0,0 +1,58 @@
+import { create } from 'zustand';
+import { ContentItem, ContentModel, Language } from './types';
+import { getZestyAPI } from 'store';
+
+const ZestyAPI = getZestyAPI();
+
+type InstanceState = {
+ instanceModels: ContentModel[];
+ instanceContentItems: ContentItem[];
+ languages: Language[];
+};
+type InstanceAction = {
+ getInstanceModels: () => Promise;
+ getInstanceContentItems: () => Promise;
+ getLanguages: (type: 'all' | 'active') => Promise;
+};
+
+export const useInstance = create((set) => ({
+ instanceModels: [],
+ getInstanceModels: async () => {
+ const response = await ZestyAPI.getModels();
+
+ if (response.error) {
+ console.error('getInstanceModels error: ', response.error);
+ throw new Error(response.error);
+ } else {
+ set({
+ instanceModels: response.data,
+ });
+ }
+ },
+
+ instanceContentItems: [],
+ getInstanceContentItems: async () => {
+ const response = await ZestyAPI.searchItems();
+
+ if (response.error) {
+ console.error('getInstanceContentItems error: ', response.error);
+ throw new Error(response.error);
+ } else {
+ set({
+ instanceContentItems: response.data,
+ });
+ }
+ },
+
+ languages: [],
+ getLanguages: async (type) => {
+ const response = await ZestyAPI.getLocales(type);
+
+ if (response.error) {
+ console.error('getLanguages error: ', response.error);
+ throw new Error(response.error);
+ } else {
+ set({ languages: response.data });
+ }
+ },
+}));
diff --git a/src/store/roles.ts b/src/store/roles.ts
new file mode 100644
index 000000000..25c31e5a6
--- /dev/null
+++ b/src/store/roles.ts
@@ -0,0 +1,244 @@
+import { create } from 'zustand';
+
+import { UserRole, Role, GranularRole, RoleWithSort } from './types';
+import { getZestyAPI } from 'store';
+import { RoleDetails } from 'components/accounts/roles/CreateCustomRoleDialog';
+import { NewGranularRole } from 'components/accounts/roles/EditCustomRoleDialog/tabs/Permissions';
+
+const BASE_ROLE_SORT_ORDER = [
+ '31-71cfc74-0wn3r',
+ '31-71cfc74-4dm13',
+ '31-71cfc74-4cc4dm13',
+ '31-71cfc74-d3v3l0p3r',
+ '31-71cfc74-d3vc0n',
+ '31-71cfc74-s30',
+ '31-71cfc74-p0bl1shr',
+ '31-71cfc74-c0ntr1b0t0r',
+] as const;
+
+const ZestyAPI = getZestyAPI();
+
+type RolesState = {
+ usersWithRoles: UserRole[];
+ baseRoles: RoleWithSort[];
+ customRoles: Role[];
+};
+type RolesAction = {
+ getUsersWithRoles: (instanceZUID: string) => Promise;
+ updateUserRole: (
+ data: { userZUID: string; oldRoleZUID: string; newRoleZUID: string }[],
+ ) => Promise;
+ getRoles: (instanceZUID: string) => Promise;
+ createRole: (data: RoleDetails & { instanceZUID: string }) => Promise;
+ updateRole: ({
+ roleZUID,
+ name,
+ description,
+ systemRoleZUID,
+ }: {
+ roleZUID: string;
+ name: string;
+ description: string;
+ systemRoleZUID: string;
+ }) => Promise;
+ deleteRole: (data: {
+ roleZUIDToDelete: string;
+ roleZUIDToTransferUsers: string;
+ }) => Promise;
+ createGranularRole: ({
+ roleZUID,
+ data,
+ }: {
+ roleZUID: string;
+ data: NewGranularRole & { name: string };
+ }) => Promise;
+ updateGranularRole: ({
+ roleZUID,
+ granularRoles,
+ }: {
+ roleZUID: string;
+ granularRoles: Partial[];
+ }) => Promise;
+ deleteGranularRole: ({
+ roleZUID,
+ resourceZUIDs,
+ }: {
+ roleZUID: string;
+ resourceZUIDs: string[];
+ }) => Promise;
+};
+
+export const useRoles = create((set) => ({
+ usersWithRoles: [],
+ getUsersWithRoles: async (instanceZUID) => {
+ const response = await ZestyAPI.getInstanceUsersWithRoles(instanceZUID);
+
+ if (response.error) {
+ console.error('getUsersWithRoles error: ', response.error);
+ throw new Error(response.error);
+ } else {
+ set({ usersWithRoles: response.data });
+ return response.data;
+ }
+ },
+ updateUserRole: async (data) => {
+ if (!data?.length) return;
+
+ Promise.all([
+ data?.forEach(({ userZUID, oldRoleZUID, newRoleZUID }) =>
+ ZestyAPI.updateUserRole(userZUID, oldRoleZUID, newRoleZUID),
+ ),
+ ])
+ .then((response) => response)
+ .catch((error) => {
+ console.error('updateUserRole error: ', error);
+ throw new Error(error);
+ });
+ },
+
+ baseRoles: [],
+ customRoles: [],
+ getRoles: async (instanceZUID) => {
+ const response = await ZestyAPI.getInstanceRoles(instanceZUID);
+
+ if (response.error) {
+ console.error('getRoles error: ', response.error);
+ throw new Error(response.error);
+ } else {
+ const _baseRoles: RoleWithSort[] = [];
+ const _customRoles: Role[] = [];
+
+ // Separate base roles from custom roles
+ response.data?.forEach((role: Role) => {
+ if (role.static) {
+ _baseRoles.push({
+ ...role,
+ sort: BASE_ROLE_SORT_ORDER.findIndex(
+ (systemRoleZUID) => systemRoleZUID === role.systemRoleZUID,
+ ),
+ });
+ } else {
+ _customRoles.push(role);
+ }
+ });
+
+ set({
+ baseRoles: _baseRoles.sort((a, b) => a.sort - b.sort),
+ customRoles: _customRoles,
+ });
+ }
+ },
+ createRole: async ({ name, description, systemRoleZUID, instanceZUID }) => {
+ if (!name && !systemRoleZUID) return;
+
+ const res = await ZestyAPI.createRole(
+ name,
+ instanceZUID,
+ systemRoleZUID,
+ description,
+ );
+
+ if (res.error) {
+ console.error('Failed to create role: ', res.error);
+ throw new Error(res.error);
+ } else {
+ return res.data;
+ }
+ },
+ updateRole: async ({ roleZUID, name, description, systemRoleZUID }) => {
+ if (!roleZUID || !name) return;
+
+ const res = await ZestyAPI.updateRole(roleZUID, {
+ name,
+ description,
+ systemRoleZUID,
+ });
+
+ if (res.error) {
+ console.error('Failed to update role: ', res.error);
+ throw new Error(res.error);
+ } else {
+ return res.data;
+ }
+ },
+ deleteRole: async ({ roleZUIDToDelete, roleZUIDToTransferUsers }) => {
+ if (!roleZUIDToDelete || !roleZUIDToTransferUsers) return;
+
+ // Transfer the existing users to a new role
+ const transferResponse = await ZestyAPI.bulkReassignUsersRole({
+ oldRoleZUID: roleZUIDToDelete,
+ newRoleZUID: roleZUIDToTransferUsers,
+ });
+
+ if (transferResponse.error) {
+ console.error('Failed to reassign users role: ', transferResponse.error);
+ throw new Error(transferResponse.error);
+ } else {
+ // Once users have been reassigned, delete the role
+ const deleteRoleResponse = await ZestyAPI.deleteRole(roleZUIDToDelete);
+
+ if (deleteRoleResponse.error) {
+ console.error(
+ `Failed to delete role ${roleZUIDToDelete}: `,
+ transferResponse.error,
+ );
+ throw new Error(transferResponse.error);
+ } else {
+ return deleteRoleResponse.data;
+ }
+ }
+ },
+
+ createGranularRole: async ({ roleZUID, data }) => {
+ if (!roleZUID || !data || !Object.keys(data)?.length) return;
+
+ const res = await ZestyAPI.createGranularRole(
+ roleZUID,
+ data.resourceZUID,
+ data.create,
+ data.read,
+ data.update,
+ data.delete,
+ data.publish,
+ );
+
+ if (res.error) {
+ console.error('Failed to update role: ', res.error);
+ throw new Error(res.error);
+ } else {
+ return res.data;
+ }
+ },
+ updateGranularRole: async ({ roleZUID, granularRoles }) => {
+ if (!roleZUID || !granularRoles) return;
+
+ const res = await ZestyAPI.batchUpdateGranularRoles(
+ roleZUID,
+ granularRoles,
+ );
+
+ if (res.error) {
+ console.error('Failed to update granular role: ', res.error);
+ throw new Error(res.error);
+ } else {
+ return res.data;
+ }
+ },
+ deleteGranularRole: async ({ roleZUID, resourceZUIDs }) => {
+ if (!roleZUID || !resourceZUIDs || !resourceZUIDs?.length) return;
+
+ Promise.all([
+ resourceZUIDs.forEach((zuid) =>
+ ZestyAPI.deleteGranularRole(roleZUID, zuid),
+ ),
+ ])
+ .then((res) => {
+ console.log('delete response', res);
+ return res;
+ })
+ .catch((err) => {
+ console.error('Failed to delete granular role: ', err);
+ throw new Error(err);
+ });
+ },
+}));
diff --git a/src/store/types.ts b/src/store/types.ts
new file mode 100644
index 000000000..b0e45f4a6
--- /dev/null
+++ b/src/store/types.ts
@@ -0,0 +1,134 @@
+export type UserRole = {
+ ID: number;
+ ZUID: string;
+ authSource: string | null;
+ authyEnabled?: boolean;
+ authyPhoneCountryCode: string | null;
+ authyPhoneNumber: string | null;
+ authyUserID: string | null;
+ createdAt: string;
+ email: string;
+ firstName: string;
+ lastLogin: string;
+ lastName: string;
+ prefs: string | null;
+ role: Role;
+ signupInfo: string | null;
+ staff: boolean;
+ unverifiedEmails: string | null;
+ updatedAt: string;
+ verifiedEmails: string | null;
+ websiteCreator: boolean;
+};
+
+export type Role = {
+ ZUID: string;
+ createdAt: string;
+ createdByUserZUID: string;
+ entityZUID: string;
+ expiry: string | null;
+ granularRoleZUID: string | null;
+ granularRoles: GranularRole[] | null;
+ name: string;
+ static: boolean;
+ systemRole: SystemRole;
+ systemRoleZUID: string;
+ updatedAt: string;
+ description?: string;
+};
+
+export type RoleWithSort = Role & { sort?: number };
+
+export type SystemRole = {
+ ZUID: string;
+ create: boolean;
+ createdAt: string;
+ delete: boolean;
+ grant: boolean;
+ name: string;
+ publish: boolean;
+ read: boolean;
+ super: boolean;
+ update: boolean;
+ updatedAt: string;
+};
+
+export type GranularRole = SystemRole & { resourceZUID: string };
+
+export type ContentModel = {
+ ZUID: string;
+ masterZUID: string;
+ parentZUID: string;
+ description: string;
+ label: string;
+ metaTitle?: any;
+ metaDescription?: any;
+ metaKeywords?: any;
+ type: ModelType;
+ name: string;
+ sort: number;
+ listed: boolean;
+ createdByUserZUID: string;
+ updatedByUserZUID: string;
+ createdAt: string;
+ updatedAt: string;
+ module?: number;
+ plugin?: number;
+};
+
+export type ModelType = 'pageset' | 'templateset' | 'dataset';
+
+export type ContentItem = {
+ web: Web;
+ meta: Meta;
+ siblings: [{ [key: number]: { value: string; id: number } }] | [];
+ data: Data;
+ publishAt?: any;
+};
+
+export type Web = {
+ version: number;
+ versionZUID: string;
+ metaDescription: string;
+ metaTitle: string;
+ metaLinkText: string;
+ metaKeywords?: any;
+ parentZUID?: any;
+ pathPart: string;
+ path: string;
+ sitemapPriority: number;
+ canonicalTagMode: number;
+ canonicalQueryParamWhitelist?: any;
+ canonicalTagCustomValue?: any;
+ createdByUserZUID: string;
+ createdAt: string;
+ updatedAt: string;
+};
+
+export type Meta = {
+ ZUID: string;
+ zid: number;
+ masterZUID: string;
+ contentModelZUID: string;
+ sort: number;
+ listed: boolean;
+ version: number;
+ langID: number;
+ createdAt: string;
+ updatedAt: string;
+ createdByUserZUID: string;
+};
+
+export type Data = {
+ [key: string]: number | string | null | undefined;
+};
+
+export type Language = {
+ ID: number;
+ code: string;
+ name: string;
+ default: boolean;
+ active: boolean;
+ createdAt: string;
+ updatedAt: string;
+};
diff --git a/src/views/accounts/instances/Roles.tsx b/src/views/accounts/instances/Roles.tsx
new file mode 100644
index 000000000..e55fa3609
--- /dev/null
+++ b/src/views/accounts/instances/Roles.tsx
@@ -0,0 +1,181 @@
+import { useMemo, useState, useRef, useDeferredValue } from 'react';
+import {
+ Button,
+ TextField,
+ Stack,
+ InputAdornment,
+ ThemeProvider,
+ CircularProgress,
+} from '@mui/material';
+import { Search, AddRounded } from '@mui/icons-material';
+import { theme } from '@zesty-io/material';
+
+import { useRoles } from 'store/roles';
+import { AccountsHeader } from 'components/accounts';
+import { NoPermission } from 'components/globals/NoPermission';
+import { BaseRoles } from 'components/accounts/roles/BaseRoles';
+import { NoCustomRoles } from 'components/accounts/roles/NoCustomRoles';
+import { CustomRoles } from 'components/accounts/roles/CustomRoles';
+import { CreateCustomRoleDialog } from 'components/accounts/roles/CreateCustomRoleDialog';
+import { NoSearchResults } from 'components/accounts/ui/NoSearchResults';
+
+type RolesProps = {
+ isLoading: boolean;
+ hasPermission: boolean;
+};
+export const Roles = ({ isLoading, hasPermission }: RolesProps) => {
+ const { usersWithRoles, customRoles, baseRoles } = useRoles((state) => state);
+ const customRolesRef = useRef(null);
+ const searchFieldRef = useRef(null);
+ const [isCreateCustomRoleDialogOpen, setIsCreateCustomRoleDialogOpen] =
+ useState(false);
+ const [filterKeyword, setFilterKeyword] = useState('');
+ const deferredFilterKeyword = useDeferredValue(filterKeyword);
+
+ const filteredRoles = useMemo(() => {
+ const keyword = deferredFilterKeyword?.toLowerCase();
+
+ if (!keyword) {
+ return {
+ baseRoles,
+ customRoles,
+ };
+ }
+
+ return {
+ baseRoles: baseRoles?.filter((role) =>
+ role.name.toLowerCase().includes(keyword),
+ ),
+ customRoles: customRoles?.filter((role) =>
+ role.name.toLowerCase().includes(keyword),
+ ),
+ };
+ }, [baseRoles, customRoles, deferredFilterKeyword]);
+
+ if (isLoading) {
+ return (
+
+
+ {/* @ts-expect-error untyped component */}
+
+
+
+
+
+
+ );
+ }
+
+ if (!hasPermission) {
+ return (
+
+
+ {/* @ts-expect-error untyped component */}
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ setFilterKeyword(evt.target.value)}
+ ref={searchFieldRef}
+ InputProps={{
+ startAdornment: (
+
+
+
+ ),
+ }}
+ />
+ }
+ onClick={() => setIsCreateCustomRoleDialogOpen(true)}
+ >
+ Create Custom Role
+
+
+
+
+ {!filteredRoles?.customRoles?.length &&
+ !filteredRoles?.baseRoles?.length &&
+ deferredFilterKeyword ? (
+ {
+ setFilterKeyword('');
+ searchFieldRef.current?.querySelector('input')?.focus();
+ }}
+ />
+ ) : (
+ <>
+ {filteredRoles?.customRoles?.length ||
+ (!filteredRoles?.customRoles?.length &&
+ !!deferredFilterKeyword) ? (
+
+ ) : (
+
+ setIsCreateCustomRoleDialogOpen(true)
+ }
+ />
+ )}
+
+ >
+ )}
+
+
+ {isCreateCustomRoleDialogOpen && (
+ setIsCreateCustomRoleDialogOpen(false)}
+ onRoleCreated={(ZUID) =>
+ customRolesRef.current?.updateZUIDToEdit?.(ZUID)
+ }
+ />
+ )}
+
+ );
+};
diff --git a/src/views/accounts/instances/index.js b/src/views/accounts/instances/index.js
index 06f20b328..00845c59b 100644
--- a/src/views/accounts/instances/index.js
+++ b/src/views/accounts/instances/index.js
@@ -5,3 +5,4 @@ export * from './Apis';
export * from './Webhooks';
export * from './Overview';
export * from './Usage';
+export * from './Roles';
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 000000000..01c2da4b5
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": false,
+ "noEmit": true,
+ "incremental": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "baseUrl": "src"
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "src"],
+ "exclude": ["node_modules"]
+}