ScopeAccess component implementation (#154)

* component based on enumScope

* apply review comments

* add store props

* Revert "add store props"

This reverts commit 0e0978c5f3.

* Tests for ScopedAccess (#156)

* Tests for ScopedAccess

* Create cypress mountTestProvider

Co-authored-by: eglehelms <e.helms@cognigy.com>
Co-authored-by: Brandon Lee Kitajchuk <bk@kitajchuk.com>
This commit is contained in:
EgleH
2022-11-29 16:19:27 +01:00
committed by GitHub
parent 5af7471886
commit 667e4a8883
15 changed files with 201 additions and 91 deletions

View File

@@ -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"]
}

View File

@@ -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 <reference path="./component" /> 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<MountReturn>;
}
}
}
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 = (
<TestProvider authProps={authProps}>{component}</TestProvider>
);
return mount(wrapper, mountOptions);
});
// Example use:
// cy.mount(<MyComponent />)

View File

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

View File

@@ -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;

View File

@@ -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<ACLProps>) => {
return (
<TestProvider>
<AccessControl acl={props.acl!}>
<div className="acl-div">
<H1>ACL: {props.acl}</H1>
</div>
</AccessControl>
</TestProvider>
<AccessControl acl={props.acl!}>
<div className="acl-div">
<H1>ACL: {props.acl}</H1>
</div>
</AccessControl>
);
};
describe("<AccessControl>", () => {
it("mounts", () => {
cy.mount(<AccessControlTestWrapper acl="hasAdminAuth" />);
cy.mountTestProvider(<AccessControlTestWrapper acl="hasAdminAuth" />);
});
it("doesn't have teams fqdn ACL", () => {
cy.mount(<AccessControlTestWrapper acl="hasMSTeamsFqdn" />);
cy.mountTestProvider(<AccessControlTestWrapper acl="hasMSTeamsFqdn" />);
/** Default ACL disables MS Teams FQDN */
cy.get(".acl-div").should("not.exist");
});
it("has admin ACL", () => {
cy.mount(<AccessControlTestWrapper acl="hasAdminAuth" />);
cy.mountTestProvider(<AccessControlTestWrapper acl="hasAdminAuth" />);
/** Default ACL applies admin auth -- the singleton admin user */
cy.get(".acl-div").should("exist");

View File

@@ -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<AuthStateContext>) => {
const RequireAuthTestWrapper = () => {
return (
<TestProvider {...(props as AuthStateContext)}>
<RequireAuth>
<div className="auth-div">
<H1>Protected Route</H1>
</div>
</RequireAuth>
</TestProvider>
<RequireAuth>
<div className="auth-div">
<H1>Protected Route</H1>
</div>
</RequireAuth>
);
};
describe("<RequireAuth>", () => {
it("mounts", () => {
cy.mount(<RequireAuthTestWrapper />);
cy.mountTestProvider(<RequireAuthTestWrapper />);
});
it("is not authorized", () => {
cy.mount(<RequireAuthTestWrapper />);
cy.mountTestProvider(<RequireAuthTestWrapper />);
cy.get(".auth-div").should("not.exist");
});
it("is authorized", () => {
cy.mount(<RequireAuthTestWrapper authorized />);
cy.mountTestProvider(<RequireAuthTestWrapper />, {
authProps: { authorized: true },
});
cy.get(".auth-div").should("exist");
});

View File

@@ -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 (
<ScopedAccess {...props}>
<div className="scope-div">{props.children}</div>
</ScopedAccess>
);
};
describe("<ScopedAccess>", () => {
it("has sufficient scope - admin", () => {
const user = {
user_sid: "78131ad5-f041-4d5d-821c-47b2d8c6d015",
scope: USER_ADMIN,
access: Scope.admin,
} as UserData;
cy.mountTestProvider(
<ScopedAccessTestWrapper scope={Scope.admin} user={user}>
<H1>ScopedAccess: admin</H1>
</ScopedAccessTestWrapper>
);
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(
<ScopedAccessTestWrapper scope={Scope.admin} user={user}>
<H1>ScopedAccess: service_provider</H1>
</ScopedAccessTestWrapper>
);
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(
<ScopedAccessTestWrapper scope={Scope.admin} user={user}>
<H1>ScopedAccess: account</H1>
</ScopedAccessTestWrapper>
);
cy.get(".scope-div").should("not.exist");
});
});

View File

@@ -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;
};

View File

@@ -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 = ({
</span>
</div>
</div>
<button
type="button"
onClick={() => setModal(true)}
title="Add service provider"
className="btnty"
>
<Icons.PlusCircle />
</button>
<ScopedAccess scope={Scope.admin} user={user}>
<button
type="button"
onClick={() => setModal(true)}
title="Add service provider"
className="btnty"
>
<Icons.PlusCircle />
</button>
</ScopedAccess>
</div>
{/* 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 && (
<div className="navi__user">
<Blockies
seed={user!.user_sid}
size={6}
scale={6}
color={getCssVar("--jambonz")}
bgColor={getCssVar("--pink")}
spotColor={getCssVar("--teal")}
className="avatar"
/>
<AccessControl acl="hasAdminAuth">
<button type="button" className="btnty adduser" title="Add user">
<Icons.PlusCircle />
</button>
</AccessControl>
</div>
)}
<div className="navi__routes">
<ul>
{naviTop.map((item) => (

View File

@@ -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<User> => {
export const userAsyncAction = async (): Promise<UserData> => {
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<

View File

@@ -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<AppStateContext>(null!);
export const StateContext = React.createContext<AppStateContext>(null!);
/** This will let us make a hook so dispatch is accessible anywhere */
let globalDispatch: GlobalDispatch;

View File

@@ -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;
}

View File

@@ -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;
}
}
}
}

View File

@@ -10,19 +10,21 @@ import type { UserLogin } from "src/api/types";
import userLogin from "../../cypress/fixtures/userLogin.json";
type TestProviderProps = Partial<AuthStateContext> & {
export type TestProviderProps = {
children?: React.ReactNode;
authProps?: Partial<AuthStateContext>;
};
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 (
<StateProvider>
<AuthContext.Provider
value={{
...defaultAuthProps,
...authProps,
...restProps,
}}
>
<BrowserRouter>
@@ -57,14 +59,14 @@ export const TestProvider = ({ children, ...restProps }: TestProviderProps) => {
export const LayoutProvider = ({
Layout,
outlet,
...restProps
authProps,
}: LayoutProviderProps) => {
return (
<StateProvider>
<AuthContext.Provider
value={{
...defaultAuthProps,
...authProps,
...restProps,
}}
>
<BrowserRouter>

View File

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