mirror of
https://github.com/jambonz/jambonz-webapp.git
synced 2025-12-18 21:27:43 +00:00
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:
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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 />)
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
66
src/components/scoped-access.cy.tsx
Normal file
66
src/components/scoped-access.cy.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
17
src/components/scoped-access.tsx
Normal file
17
src/components/scoped-access.tsx
Normal 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;
|
||||
};
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user