diff --git a/cypress/fixtures/userLogin.json b/cypress/fixtures/userLogin.json
index 954b55f..c3f7ea2 100644
--- a/cypress/fixtures/userLogin.json
+++ b/cypress/fixtures/userLogin.json
@@ -1,5 +1,7 @@
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX3NpZCI6IjFjNTc4MWQyLTY5MGItNDIwYy1iZDUzLTVkN2Y1NjMwMDVjOCIsInNjb3BlIjoiYWRtaW4iLCJmb3JjZV9jaGFuZ2UiOnRydWUsInBlcm1pc3Npb25zIjpbIlBST1ZJU0lPTl9VU0VSUyIsIlBST1ZJU0lPTl9TRVJWSUNFUyIsIlZJRVdfT05MWSJdLCJpYXQiOjE2NjY3OTgzMTEsImV4cCI6MTY2NjgwMTkxMX0.ZV3KnRit8WGpipfiiMAZ2AVLQ25csWje1-K6hdqxktE",
"user_sid": "78131ad5-f041-4d5d-821c-47b2d8c6d015",
- "force_change": false
+ "force_change": false,
+ "scope": "admin",
+ "permissions": ["VIEW_ONLY", "PROVISION_SERVICES", "PROVISION_USERS"]
}
diff --git a/cypress/support/component.ts b/cypress/support/component.tsx
similarity index 58%
rename from cypress/support/component.ts
rename to cypress/support/component.tsx
index fd08412..c61f7f0 100644
--- a/cypress/support/component.ts
+++ b/cypress/support/component.tsx
@@ -19,23 +19,47 @@ import "src/styles/index.scss";
// Import commands.js using ES2015 syntax:
import "./commands";
+import React from "react";
+
import { mount } from "cypress/react18";
+import { TestProvider } from "src/test";
+
+import type { TestProviderProps } from "src/test";
+import type { MountOptions, MountReturn } from "cypress/react";
+
// Augment the Cypress namespace to include type definitions for
// your custom command.
// Alternatively, can be defined in cypress/support/component.d.ts
-// with a at the top of your spec.
declare global {
// Disabling, but: https://typescript-eslint.io/rules/no-namespace/...
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
mount: typeof mount;
+ /**
+ * Mounts a React node
+ * @param component React Node to mount
+ * @param options Additional options to pass into mount
+ */
+ mountTestProvider(
+ component: React.ReactNode,
+ options?: MountOptions & { authProps?: TestProviderProps["authProps"] }
+ ): Cypress.Chainable;
}
}
}
Cypress.Commands.add("mount", mount);
+// This gives us access to dispatch inside of our test wrappers...
+Cypress.Commands.add("mountTestProvider", (component, options = {}) => {
+ const { authProps, ...mountOptions } = options;
+ const wrapper = (
+ {component}
+ );
+ return mount(wrapper, mountOptions);
+});
+
// Example use:
// cy.mount()
diff --git a/package.json b/package.json
index 0b98a5c..da05ca2 100644
--- a/package.json
+++ b/package.json
@@ -44,7 +44,6 @@
"dayjs": "^1.11.5",
"jambonz-ui": "^0.0.19",
"react": "^18.0.0",
- "react-blockies": "^1.4.1",
"react-dom": "^18.0.0",
"react-feather": "^2.0.10",
"react-router-dom": "^6.3.0"
@@ -54,7 +53,6 @@
"@types/express": "^4.17.13",
"@types/node": "^18.6.1",
"@types/react": "^18.0.0",
- "@types/react-blockies": "^1.4.1",
"@types/react-dom": "^18.0.0",
"@typescript-eslint/eslint-plugin": "^5.30.6",
"@typescript-eslint/parser": "^5.30.6",
diff --git a/src/api/types.ts b/src/api/types.ts
index 4fceff4..6f92f92 100644
--- a/src/api/types.ts
+++ b/src/api/types.ts
@@ -19,13 +19,6 @@ export type UserPermissions =
| "PROVISION_SERVICES"
| "PROVISION_USERS";
-// We'll want something like this for actual permissions implementation...
-// export enum UserPermissions {
-// VIEW_ONLY = 0,
-// PROVISION_SERVICES = 1,
-// PROVISION_USERS = 2,
-// }
-
/** Status codes */
export enum StatusCodes {
@@ -106,21 +99,24 @@ export interface PasswordSettings {
/** API responses/payloads */
export interface User {
- scope?: UserScopes;
+ scope: UserScopes;
user_sid: string;
name: string;
email: string;
is_active: boolean;
force_change: boolean;
- account_sid?: string | null;
- service_provider_sid?: string | null;
+ account_sid: string | null;
+ service_provider_sid: string | null;
initial_password?: string;
- permissions?: UserPermissions;
+ permissions?: UserPermissions[];
}
-export interface UserLogin extends User {
+export interface UserLogin {
token: string;
force_change: boolean;
+ scope: UserScopes;
+ user_sid: string;
+ permissions: UserPermissions[];
}
export interface UserLoginPayload {
@@ -140,6 +136,14 @@ export interface UserUpdatePayload {
account_sid: string | null;
}
+export interface UserJWT {
+ scope: UserScopes;
+ user_sid: string;
+ account_sid?: string | null;
+ service_provider_sid?: string | null;
+ permissions: UserPermissions[];
+}
+
export interface ServiceProvider {
name: string;
ms_teams_fqdn: null | string;
diff --git a/src/components/access-control.cy.tsx b/src/components/access-control.cy.tsx
index 5edc9af..8c6d3ef 100644
--- a/src/components/access-control.cy.tsx
+++ b/src/components/access-control.cy.tsx
@@ -1,7 +1,6 @@
import React from "react";
import { H1 } from "jambonz-ui";
-import { TestProvider } from "src/test";
import { AccessControl } from "./access-control";
import type { ACLProps } from "./access-control";
@@ -9,30 +8,28 @@ import type { ACLProps } from "./access-control";
/** Wrapper to pass different ACLs */
const AccessControlTestWrapper = (props: Partial) => {
return (
-
-
-
-
ACL: {props.acl}
-
-
-
+
+
+
ACL: {props.acl}
+
+
);
};
describe("", () => {
it("mounts", () => {
- cy.mount();
+ cy.mountTestProvider();
});
it("doesn't have teams fqdn ACL", () => {
- cy.mount();
+ cy.mountTestProvider();
/** Default ACL disables MS Teams FQDN */
cy.get(".acl-div").should("not.exist");
});
it("has admin ACL", () => {
- cy.mount();
+ cy.mountTestProvider();
/** Default ACL applies admin auth -- the singleton admin user */
cy.get(".acl-div").should("exist");
diff --git a/src/components/require-auth.cy.tsx b/src/components/require-auth.cy.tsx
index 0423af8..82d47a2 100644
--- a/src/components/require-auth.cy.tsx
+++ b/src/components/require-auth.cy.tsx
@@ -1,37 +1,34 @@
import React from "react";
import { H1 } from "jambonz-ui";
-import { TestProvider } from "src/test";
import { RequireAuth } from "./require-auth";
-import type { AuthStateContext } from "src/router/auth";
-
/** Wrapper to pass different auth contexts */
-const RequireAuthTestWrapper = (props: Partial) => {
+const RequireAuthTestWrapper = () => {
return (
-
-
-
-
Protected Route
-
-
-
+
+
+
Protected Route
+
+
);
};
describe("", () => {
it("mounts", () => {
- cy.mount();
+ cy.mountTestProvider();
});
it("is not authorized", () => {
- cy.mount();
+ cy.mountTestProvider();
cy.get(".auth-div").should("not.exist");
});
it("is authorized", () => {
- cy.mount();
+ cy.mountTestProvider(, {
+ authProps: { authorized: true },
+ });
cy.get(".auth-div").should("exist");
});
diff --git a/src/components/scoped-access.cy.tsx b/src/components/scoped-access.cy.tsx
new file mode 100644
index 0000000..ed69a8f
--- /dev/null
+++ b/src/components/scoped-access.cy.tsx
@@ -0,0 +1,66 @@
+import React from "react";
+import { H1 } from "jambonz-ui";
+
+import { ScopedAccess } from "./scoped-access";
+import { USER_SP, USER_ADMIN, USER_ACCOUNT } from "src/api/constants";
+
+import type { ScopedAccessProps } from "./scoped-access";
+import type { UserData } from "src/store/types";
+
+import { Scope } from "src/store/types";
+
+/** Wrapper to pass different user scopes as enum values */
+const ScopedAccessTestWrapper = (props: ScopedAccessProps) => {
+ return (
+
+ {props.children}
+
+ );
+};
+
+describe("", () => {
+ it("has sufficient scope - admin", () => {
+ const user = {
+ user_sid: "78131ad5-f041-4d5d-821c-47b2d8c6d015",
+ scope: USER_ADMIN,
+ access: Scope.admin,
+ } as UserData;
+
+ cy.mountTestProvider(
+
+ ScopedAccess: admin
+
+ );
+ cy.get(".scope-div").should("exist");
+ });
+
+ it("has insufficient scope - service_provider", () => {
+ const user = {
+ user_sid: "78131ad5-f041-4d5d-821c-47b2d8c6d015",
+ scope: USER_SP,
+ access: Scope.service_provider,
+ } as UserData;
+
+ cy.mountTestProvider(
+
+ ScopedAccess: service_provider
+
+ );
+ cy.get(".scope-div").should("not.exist");
+ });
+
+ it("has insufficient scope - account", () => {
+ const user = {
+ user_sid: "78131ad5-f041-4d5d-821c-47b2d8c6d015",
+ scope: USER_ACCOUNT,
+ access: Scope.account,
+ } as UserData;
+
+ cy.mountTestProvider(
+
+ ScopedAccess: account
+
+ );
+ cy.get(".scope-div").should("not.exist");
+ });
+});
diff --git a/src/components/scoped-access.tsx b/src/components/scoped-access.tsx
new file mode 100644
index 0000000..13406e1
--- /dev/null
+++ b/src/components/scoped-access.tsx
@@ -0,0 +1,17 @@
+import React from "react";
+
+import type { UserData, Scope } from "src/store/types";
+
+export type ScopedAccessProps = {
+ user?: UserData;
+ scope: Scope;
+ children: React.ReactNode;
+};
+
+export const ScopedAccess = ({ user, scope, children }: ScopedAccessProps) => {
+ if (user && user.access >= scope) {
+ return <>{children}>;
+ }
+
+ return null;
+};
diff --git a/src/containers/internal/navi/index.tsx b/src/containers/internal/navi/index.tsx
index 140ef4a..e16445c 100644
--- a/src/containers/internal/navi/index.tsx
+++ b/src/containers/internal/navi/index.tsx
@@ -1,9 +1,8 @@
import React, { useEffect, useState, useMemo } from "react";
-import Blockies from "react-blockies";
-import { classNames, getCssVar, M, Icon, Button } from "jambonz-ui";
+import { classNames, M, Icon, Button } from "jambonz-ui";
import { Link, useLocation } from "react-router-dom";
-import { Icons, ModalForm, AccessControl } from "src/components";
+import { Icons, ModalForm } from "src/components";
import { naviTop, naviByo } from "./items";
import {
useSelectState,
@@ -16,6 +15,8 @@ import { postServiceProviders } from "src/api";
import type { NaviItem } from "./items";
import "./styles.scss";
+import { ScopedAccess } from "src/components/scoped-access";
+import { Scope } from "src/store/types";
type CommonProps = {
handleMenu: () => void;
@@ -158,35 +159,17 @@ export const Navi = ({
-
+
+
+
- {/* Intentionally hiding this as we will need to work this out as we go... */}
- {/* ACL component will need to be updated for new user scope/permissions handling */}
- {false && user && (
-
- )}
{naviTop.map((item) => (
diff --git a/src/store/actions.ts b/src/store/actions.ts
index 9f83061..668efb7 100644
--- a/src/store/actions.ts
+++ b/src/store/actions.ts
@@ -2,8 +2,9 @@ import { getServiceProviders } from "src/api";
import { sortLocaleName } from "src/utils";
import { getToken, parseJwt } from "src/router/auth";
-import type { State, Action } from "./types";
-import type { ServiceProvider, User } from "src/api/types";
+import type { State, Action, UserData } from "./types";
+import { Scope } from "./types";
+import { ServiceProvider } from "src/api/types";
/** A generic action assumes action.type is ALWAYS our state key */
/** Since this is how we're designing our state interface we cool */
@@ -61,9 +62,12 @@ export const currentServiceProviderAction = (
return genericAction(state, action);
};
-export const userAsyncAction = async (): Promise => {
+export const userAsyncAction = async (): Promise => {
const token = getToken();
- return parseJwt(token);
+ const userData = parseJwt(token);
+
+ // Add `access` as enum for client-side usage
+ return { ...userData, access: Scope[userData.scope] };
};
export const serviceProvidersAsyncAction = async (): Promise<
diff --git a/src/store/index.tsx b/src/store/index.tsx
index f208975..8fb0722 100644
--- a/src/store/index.tsx
+++ b/src/store/index.tsx
@@ -21,7 +21,7 @@ import type {
ACL,
} from "./types";
-const initialState: State = {
+export const initialState: State = {
featureFlags: {
/** Placeholder since we may need feature-flags in the future... */
development: import.meta.env.DEV,
@@ -77,7 +77,7 @@ const middleware: MiddleWare = (dispatch) => {
};
};
-const StateContext = React.createContext(null!);
+export const StateContext = React.createContext(null!);
/** This will let us make a hook so dispatch is accessible anywhere */
let globalDispatch: GlobalDispatch;
diff --git a/src/store/types.ts b/src/store/types.ts
index f6768bb..14ddf24 100644
--- a/src/store/types.ts
+++ b/src/store/types.ts
@@ -1,6 +1,6 @@
import React from "react";
-import type { User, ServiceProvider } from "src/api/types";
+import type { UserJWT, ServiceProvider } from "src/api/types";
export type IMessage = string | JSX.Element;
@@ -18,9 +18,19 @@ export interface FeatureFlag {
development: boolean;
}
+export enum Scope {
+ "account" = 0,
+ "service_provider" = 1,
+ "admin" = 2,
+}
+
+export interface UserData extends UserJWT {
+ access: Scope;
+}
+
export interface State {
/** logged in user */
- user?: User;
+ user?: UserData;
/** global toast notifications */
toast?: Toast;
/** feature flags from vite ENV */
@@ -53,6 +63,6 @@ export interface MiddleWare {
export interface AppStateContext {
/** Global data store */
state: State;
- /** Globsl dispatch method */
+ /** Global dispatch method */
dispatch: GlobalDispatch;
}
diff --git a/src/styles/_grid.scss b/src/styles/_grid.scss
index 756702d..a488cc2 100644
--- a/src/styles/_grid.scss
+++ b/src/styles/_grid.scss
@@ -101,10 +101,16 @@
&--col4 {
.grid__row {
- grid-template-columns: [col] 37% [col] 35% [col] 20% [col] 3%;
+ grid-template-columns: [col] 30% [col] 30% [col] 30% [col] 10%;
grid-template-rows: [row] auto [row] auto [row] [row] auto;
display: grid;
justify-content: space-between;
+
+ > div:last-child {
+ text-align: right;
+ padding-right: 0;
+ font-size: 0;
+ }
}
}
}
diff --git a/src/test/index.tsx b/src/test/index.tsx
index a904478..10a8f2a 100644
--- a/src/test/index.tsx
+++ b/src/test/index.tsx
@@ -10,19 +10,21 @@ import type { UserLogin } from "src/api/types";
import userLogin from "../../cypress/fixtures/userLogin.json";
-type TestProviderProps = Partial & {
+export type TestProviderProps = {
children?: React.ReactNode;
+ authProps?: Partial;
};
-type LayoutProviderProps = TestProviderProps & {
+export type LayoutProviderProps = TestProviderProps & {
outlet: JSX.Element;
Layout: React.ElementType;
};
export const signinError = () => Promise.reject(MSG_SOMETHING_WRONG);
-export const signinSuccess = () => Promise.resolve(userLogin as UserLogin);
+export const signinSuccess = () =>
+ Promise.resolve(userLogin as unknown as UserLogin);
export const signout = () => undefined;
-export const authProps: AuthStateContext = {
+export const defaultAuthProps: AuthStateContext = {
token: "",
signin: signinSuccess,
signout,
@@ -32,13 +34,13 @@ export const authProps: AuthStateContext = {
/**
* Use this when you simply need to wrap with state and auth
*/
-export const TestProvider = ({ children, ...restProps }: TestProviderProps) => {
+export const TestProvider = ({ children, authProps }: TestProviderProps) => {
return (
@@ -57,14 +59,14 @@ export const TestProvider = ({ children, ...restProps }: TestProviderProps) => {
export const LayoutProvider = ({
Layout,
outlet,
- ...restProps
+ authProps,
}: LayoutProviderProps) => {
return (
diff --git a/tsconfig.json b/tsconfig.json
index f6d7a56..c74110c 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,6 +1,6 @@
{
"compilerOptions": {
- "types": ["@types/react", "@types/react-dom", "@types/react-blockies"],
+ "types": ["@types/react", "@types/react-dom"],
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],