Skip to content

Commit

Permalink
Merge pull request #1 from paritytech/feat/tag-roles
Browse files Browse the repository at this point in the history
Roles v3
  • Loading branch information
ba1uev authored Feb 13, 2024
2 parents 273cf1e + ff49b86 commit 99973ae
Show file tree
Hide file tree
Showing 83 changed files with 1,443 additions and 766 deletions.
8 changes: 0 additions & 8 deletions config-demo/company.json
Original file line number Diff line number Diff line change
Expand Up @@ -203,13 +203,5 @@
}
]
}
],
"departments": [
"Engineering",
"Operations",
"Finance",
"Legal",
"Security",
"Marketing"
]
}
5 changes: 0 additions & 5 deletions config-demo/modules.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,6 @@
"label": "Birthday",
"required": false
},
"department": {
"label": "Department",
"placeholder": "Select a department",
"required": true
},
"team": {
"label": "Team",
"placeholder": "Team name",
Expand Down
5 changes: 0 additions & 5 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,6 @@ e.g
"label": "Birthday",
"required": false
},
"department": {
"label": "Department",
"placeholder": "Select a department",
"required": true,
},
"team": {
"label": "Team",
"placeholder": "Team name",
Expand Down
17 changes: 7 additions & 10 deletions scripts/client.build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,6 @@ function getBuildConfig(): BuildOptions {
])
)
),
'process.env.DIVISIONS': JSON.stringify(
appConfig.config.company.divisions
),
'process.env.DEPARTMENTS': JSON.stringify(
appConfig.config.company.departments
),
'process.env.LAYOUT': JSON.stringify(appConfig.config.application.layout),
'process.env.APP_NAME': JSON.stringify(appConfig.config.application.name),
'process.env.COMPANY_NAME': JSON.stringify(appConfig.config.company.name),
Expand All @@ -71,10 +65,13 @@ function getBuildConfig(): BuildOptions {
'process.env.AUTH_MESSAGE_TO_SIGN': JSON.stringify(
config.authMessageToSign
),
'process.env.ROLES': JSON.stringify(
appConfig.config.permissions.roles.map((x) => ({
...fp.pick(['id', 'name', 'accessByDefault'])(x),
lowPriority: x.id === appConfig.lowPriorityRole,
'process.env.ROLE_GROUPS': JSON.stringify(
appConfig.config.permissions.roleGroups.map((x) => ({
...x,
roles: x.roles.map((r) => ({
...fp.pick(['id', 'name', 'accessByDefault'])(r),
lowPriority: r.id === appConfig.lowPriorityRole,
})),
}))
),
},
Expand Down
83 changes: 46 additions & 37 deletions src/client/components/AdminHome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,28 @@ import * as React from 'react'
import { useStore } from '@nanostores/react'
import * as stores from '#client/stores'
import config from '#client/config'
import { prop, propEq } from '#shared/utils/fp'
import {
ADMIN_ACCESS_PERMISSION_RE,
ADMIN_ACCESS_PERMISSION_POSTFIX,
} from '#client/constants'
import { Button, ComponentWrapper } from '#client/components/ui'
import Permissions from '#shared/permissions'
import { DefaultPermissionPostfix } from '#shared/types'
import { PermissionsSet } from '#shared/utils'

type ModuleWithAdminComponents = {
id: string
name: string
paths: string[]
routes: string[]
}
const modulesWithAdminComponents: ModuleWithAdminComponents[] =
config.modules.reduce((acc, m) => {
if (!m.router.admin) return acc
const adminRoutePaths = Object.keys(m.router.admin)
.map((x) => m.router.admin![x])
.map(prop('path'))
if (adminRoutePaths.length) {
const routes = Object.keys(m.router.admin)
if (routes.length) {
const moduleInfo: ModuleWithAdminComponents = {
id: m.id,
name: m.name,
paths: adminRoutePaths,
routes,
}
return [...acc, moduleInfo]
}
Expand All @@ -33,7 +33,7 @@ const modulesWithAdminComponents: ModuleWithAdminComponents[] =
type Props = { children: React.ReactNode }

const doesUserHaveAdminPermission = (granted: PermissionsSet) => {
return granted.some((x) => x.endsWith(`.${DefaultPermissionPostfix.Admin}`))
return granted.some((x) => ADMIN_ACCESS_PERMISSION_RE.test(x))
}

export const AdminHome: React.FC<Props> = (props) => {
Expand All @@ -50,42 +50,51 @@ export const AdminHome: React.FC<Props> = (props) => {
const _AdminHome: React.FC<Props> = ({ children }) => {
const permissions = useStore(stores.permissions)
const page = useStore(stores.router)
const moduleVisibilityFilter = React.useCallback(
(m: ModuleWithAdminComponents): boolean => {
const officeId = useStore(stores.officeId)

const filteredModules = React.useMemo(() => {
return modulesWithAdminComponents.filter((m) => {
const modulePermissions: string[] = Object.values(
Permissions[m.id as keyof typeof Permissions] || {}
)
const adminPermission = `${m.id}.${DefaultPermissionPostfix.Admin}`
const adminPermission = `${m.id}.${ADMIN_ACCESS_PERMISSION_POSTFIX}`
const adminPermissionPerOffice = `${adminPermission}:${officeId}`
return (
permissions.has(adminPermission) &&
modulePermissions.includes(adminPermission)
modulePermissions.includes(adminPermission) &&
(permissions.has(adminPermissionPerOffice) ||
permissions.has(adminPermission))
)
},
[permissions]
)
})
}, [permissions, officeId])

return (
<ComponentWrapper wide>
<div className="-mx-8 -mt-4 sm:mt-0 px-2 sm:px-8 mb-6 pb-2 sm:pb-4 border-b border-gray-200">
{modulesWithAdminComponents.filter(moduleVisibilityFilter).map((x) => {
return (
<Button
key={x.id}
kind={
page?.route && x.paths.includes(page.route)
? 'primary'
: 'secondary'
}
href={`/admin/${x.id}`}
className="mb-2 sm:mb-4 mr-2 sm:mr-4 rounded-[24px] relative focus:ring-0"
>
{x.name}
{/* {!!counter && <CounterBadge count={counter} />} */}
</Button>
)
})}
</div>
{children}
{filteredModules.length ? (
<>
<div className="-mx-8 -mt-4 sm:mt-0 px-2 sm:px-8 mb-6 pb-2 sm:pb-4 border-b border-gray-200">
{filteredModules.map((x) => {
return (
<Button
key={x.id}
kind={
page && x.routes.includes(page.route)
? 'primary'
: 'secondary'
}
href={`/admin/${x.id}`}
className="mb-2 sm:mb-4 mr-2 rounded-[24px] relative focus:ring-0"
>
{x.name}
{/* {!!counter && <CounterBadge count={counter} />} */}
</Button>
)
})}
</div>
{children}
</>
) : (
<div>Please select an office that you can work with.</div>
)}
</ComponentWrapper>
)
}
Expand Down
40 changes: 34 additions & 6 deletions src/client/components/EntityAccessSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { ENTITY_VISIBILITY_LABEL } from '#client/components/EntityVisibilityTag'
import { CheckboxGroup, RadioGroup } from '#client/components/ui'
import {
Button,
CheckboxGroup,
LabelWrapper,
Link,
RadioGroup,
} from '#client/components/ui'
import config from '#client/config'
import { EntityVisibility } from '#shared/types'
import { cn } from '#client/utils'
import { USER_ROLES } from '#client/constants'
import { prop } from '#shared/utils/fp'
import React from 'react'

Expand Down Expand Up @@ -44,8 +51,11 @@ export const EntityAccessSelector: React.FC<Props> = ({
visibilityTypes,
...props
}) => {
const [showAllRoles, setShowAllRoles] = React.useState(
USER_ROLES.length <= 10
)
const roleIds = React.useMemo(
() => config.roles.filter(prop('accessByDefault')).map(prop('id')),
() => USER_ROLES.filter(prop('accessByDefault')).map(prop('id')),
[]
)

Expand Down Expand Up @@ -122,6 +132,22 @@ export const EntityAccessSelector: React.FC<Props> = ({
]
)

const filteredRoles = React.useMemo<
Array<{ value: string; label: string }>
>(() => {
const availableRoles = USER_ROLES.map(prop('id'))
const unsupportedRoles = (props.value.allowedRoles || [])
.filter((x) => !availableRoles.includes(x))
.map((x) => ({ value: x, label: `${x} (UNSUPPORTED)` }))
const filteredRoles = USER_ROLES.filter(
(x) => showAllRoles || x.accessByDefault
).map((x) => ({
value: x.id,
label: x.name,
}))
return unsupportedRoles.concat(filteredRoles)
}, [showAllRoles, props.value.allowedRoles])

return (
<div
className={cn(
Expand All @@ -144,12 +170,14 @@ export const EntityAccessSelector: React.FC<Props> = ({
name="roles"
label="Allowed user roles"
value={allowedRolesValue}
options={config.roles.map((x) => ({
value: x.id,
label: x.name,
}))}
options={filteredRoles}
onChange={onChange('allowedRoles')}
/>
{!showAllRoles && (
<LabelWrapper label="">
<Link onClick={() => setShowAllRoles(true)}>Show all roles</Link>
</LabelWrapper>
)}
</div>
)}
{showOfficesList && (
Expand Down
20 changes: 16 additions & 4 deletions src/client/components/PermissionsValidator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,27 @@ import React from 'react'

type Props = {
required: string[]
officeId?: string
onReject?: () => void
onRejectRender?: React.ReactElement
onRejectGoHome?: boolean
children: React.ReactNode
}

export const PermissionsValidator: React.FC<Props> = ({
required = [],
officeId,
children,
onReject,
onRejectGoHome = false,
onRejectRender = null,
}) => {
const permissions = useStore(stores.permissions)
const [isValid, setIsValid] = React.useState(permissions.hasAll(required))
const [isValid, setIsValid] = React.useState(
permissions.hasAll(required, officeId)
)
React.useEffect(() => {
if (!permissions.hasAll(required)) {
if (!permissions.hasAll(required, officeId)) {
if (onRejectGoHome) {
setTimeout(() => stores.goTo('home'), 0)
} else if (onReject) {
Expand All @@ -28,6 +34,12 @@ export const PermissionsValidator: React.FC<Props> = ({
} else {
setIsValid(true)
}
}, [required])
return isValid ? <>{children}</> : null
}, [required, officeId])
if (isValid) {
return <>{children}</>
}
if (onRejectRender) {
return onRejectRender
}
return null
}
1 change: 1 addition & 0 deletions src/client/components/ui/Filters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export const Filters = <T,>(props: React.PropsWithChildren<Props<T>>) => {
}
return (
<Tag
size="small"
key={String(x.id || '~none~')}
className="inline-block cursor-pointer hover:opacity-70 mr-1 mb-1"
color={isSelected ? 'blue' : 'gray'}
Expand Down
2 changes: 1 addition & 1 deletion src/client/components/ui/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ type Props = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> & {
const labeledTypes = ['checkbox', 'radio']
type LabelProps = {
name?: string | undefined
label: string | undefined
label: string | React.ReactNode | undefined
required?: boolean | undefined
type?: string | undefined
extraLabel?: string | null
Expand Down
30 changes: 17 additions & 13 deletions src/client/components/ui/UserLabel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,8 @@ import { USER_ROLE_BY_ID } from '#client/constants'

type Props = {
user: User | UserCompact
hideRole?: boolean
}
export const UserLabel: React.FC<Props> = ({ user, hideRole = false }) => {
export const UserLabel: React.FC<Props> = ({ user }) => {
return user ? (
<span className="inline-flex items-center w-max">
<Avatar
Expand All @@ -23,30 +22,35 @@ export const UserLabel: React.FC<Props> = ({ user, hideRole = false }) => {
href={`/profile/${user.id}`}
target="_blank"
className={cn(!user.isInitialised && 'opacity-50')}
title={!user.isInitialised ? 'The user has not been onboarded yet' : undefined}
title={
!user.isInitialised
? 'The user has not been onboarded yet'
: undefined
}
kind="secondary"
>
{user.fullName}
</Link>
{!hideRole && (
<UserRoleLabel
role={user.role}
className="ml-2"
/>
)}
</span>
) : null
}

type UserRoleLabelType = {
role: User['role']
role: string
className?: string
}
export const UserRoleLabel: React.FC<UserRoleLabelType> = ({ role, ...props }) => {
export const UserRoleLabel: React.FC<UserRoleLabelType> = ({
role,
...props
}) => {
const roleRecord = USER_ROLE_BY_ID[role]
// TODO: use roleRecord.color ?
return (
<Tag className={cn(props.className)} color="gray" size="small">
<Tag
className={cn(props.className)}
size="small"
color={roleRecord ? 'gray' : 'red'}
title={roleRecord ? '' : 'Unsupported role'}
>
{roleRecord?.name || role}
</Tag>
)
Expand Down
Loading

0 comments on commit 99973ae

Please sign in to comment.