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"],