Browse Source

feat: Restrict admin pages

pull/35/head
Dale 6 days ago
parent
commit
cacd9b8e8e
Signed by: Deiru GPG Key ID: AA250C0277B927E1
  1. 48
      graphql.schema.json
  2. 2
      src/client/AdminPanel/Roles/const.ts
  3. 19
      src/client/AdminPanel/Users/AdminUserForm.tsx
  4. 10
      src/client/AdminPanel/Users/ResetPasswordField.tsx
  5. 33
      src/client/AdminPanel/Users/UserModal.tsx
  6. 58
      src/client/AdminPanel/Users/index.tsx
  7. 2
      src/client/components/form/FormSelect.tsx
  8. 2
      src/client/components/roles/RolePermissions/RolePermissions.tsx
  9. 8
      src/client/components/ui/CopyField.tsx
  10. 69
      src/client/components/ui/Select.tsx
  11. 26
      src/client/hooks/auth.tsx
  12. 82
      src/pages/admin/[[...path]].tsx
  13. 1
      src/server/db/generate-roles.ts
  14. 4
      src/server/graphql/directives/auth.tsx
  15. 2
      src/server/graphql/errors/auth.ts
  16. 2
      src/server/graphql/errors/general.ts
  17. 8
      src/server/graphql/resolvers/users/index.ts
  18. 3
      src/server/graphql/schema/Roles.graphql
  19. 29
      src/shared/acl/utils.ts
  20. 35
      src/shared/graphql.ts
  21. 6
      src/shared/hooks.ts

48
graphql.schema.json

@ -63,6 +63,22 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "sessions",
"description": null,
"args": [],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "AccessUnit",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "streamKeys",
"description": null,
@ -187,6 +203,22 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "sessions",
"description": null,
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "AccessUnit",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "streamKeys",
"description": null,
@ -203,6 +235,22 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "system",
"description": null,
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "AccessUnit",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "userSettings",
"description": null,

2
src/client/AdminPanel/Roles/const.ts

@ -13,6 +13,8 @@ export const ADMIN_ROLE_FRAGMENT = gql`
users
activities
userSettings
system
sessions
}
actions {
user {

19
src/client/AdminPanel/Users/AdminUserForm.tsx

@ -1,11 +1,13 @@
import { Heading, Flex, Button, useToast } from "@chakra-ui/react";
import { FormRolesSelect } from "miracle-tv-client/components/form/selects/FormRoleSelect";
import { useCurrentUser } from "miracle-tv-client/hooks/auth";
import {
AccessUnit,
AdminFullUserFragment,
UpdateFullUserInput,
} from "miracle-tv-shared/graphql";
import { useUpdateFullUserMutation } from "miracle-tv-shared/hooks";
import { useCallback } from "react";
import { useCallback, useMemo } from "react";
import { Form } from "react-final-form";
type Props = {
@ -14,6 +16,7 @@ type Props = {
export const AdminUserEditForm = ({ user }: Props) => {
const toast = useToast();
const { checkRights } = useCurrentUser();
const formData: Partial<UpdateFullUserInput> = {
roles: user.roles?.map((role) => role.id),
};
@ -23,8 +26,11 @@ export const AdminUserEditForm = ({ user }: Props) => {
onCompleted() {
toast({ status: "success", title: "Updated user" });
},
onError() {
toast({ status: "error", title: "There was an error updated user" });
onError(data) {
toast({
status: "error",
title: `There was an error updating user: ${data.message}`,
});
},
refetchQueries: ["FullUserAdmin", "FullUserAdminCount"],
});
@ -38,6 +44,10 @@ export const AdminUserEditForm = ({ user }: Props) => {
[updateFullUserMutation, user]
);
const canEditUser = useMemo(() => {
return checkRights(AccessUnit.Write, "users");
}, [checkRights]);
return (
<>
<Heading size="md" mb={2}>
@ -47,6 +57,7 @@ export const AdminUserEditForm = ({ user }: Props) => {
{({ handleSubmit, pristine }) => (
<form onSubmit={handleSubmit}>
<FormRolesSelect
isDisabled={!canEditUser}
label="Roles"
name="roles"
mb={2}
@ -56,7 +67,7 @@ export const AdminUserEditForm = ({ user }: Props) => {
<Button
type="submit"
isLoading={isUserUpdating}
isDisabled={pristine || isUserUpdating}
isDisabled={pristine || isUserUpdating || !canEditUser}
>
Update
</Button>

10
src/client/AdminPanel/Users/ResetPasswordField.tsx

@ -13,6 +13,7 @@ import { useCallback, useEffect, useState } from "react";
type Props = {
userId: string;
isDisabled?: boolean;
} & FlexProps;
gql`
@ -24,7 +25,11 @@ gql`
}
`;
export const ResetPasswordField = ({ userId, ...props }: Props) => {
export const ResetPasswordField = ({
userId,
isDisabled = false,
...props
}: Props) => {
const toast = useToast();
const [password, setPassword] = useState<string>("");
@ -66,6 +71,7 @@ export const ResetPasswordField = ({ userId, ...props }: Props) => {
<CopyField
value={password}
w="100%"
isDisabled={isDisabled}
placeholder="New password will appear here."
/>
<IconButton
@ -74,7 +80,7 @@ export const ResetPasswordField = ({ userId, ...props }: Props) => {
icon={<RepeatIcon />}
onClick={onPasswordReset}
isLoading={isPasswordResetting}
isDisabled={isPasswordResetting}
isDisabled={isPasswordResetting || isDisabled}
>
Reset password
</IconButton>

33
src/client/AdminPanel/Users/UserModal.tsx

@ -1,6 +1,7 @@
import { gql } from "@apollo/client";
import { UseDisclosureReturn } from "@chakra-ui/hooks";
import {
AccessUnit,
AdminFullUserFragment,
PasswordResetMethod,
} from "miracle-tv-shared/graphql";
@ -23,7 +24,7 @@ import {
useToast,
IconButton,
} from "@chakra-ui/react";
import React, { useCallback } from "react";
import React, { useCallback, useMemo } from "react";
import { getMediaURL } from "miracle-tv-shared/media";
import { Avatar } from "miracle-tv-client/components/ui/Avatar";
import { useUpdateFullUserMutation } from "miracle-tv-shared/hooks";
@ -31,6 +32,7 @@ import { ResetPasswordField } from "./ResetPasswordField";
import { AdminUserEditForm } from "./AdminUserForm";
import { useMediaQuery } from "miracle-tv-client/utils/css";
import { MediaQuery } from "miracle-tv-client/utils/const";
import { useCurrentUser } from "miracle-tv-client/hooks/auth";
type Props = {
user: AdminFullUserFragment | null;
@ -53,14 +55,15 @@ type UpdateFields =
| "roles";
export const UserModal = ({ user, onClose }: Props) => {
const { checkRights, checkActions } = useCurrentUser();
const isMobile = useMediaQuery(MediaQuery.mobile);
const toast = useToast();
const [updateFullUserMutation] = useUpdateFullUserMutation({
onCompleted() {
toast({ status: "success", title: "Updated user" });
},
onError() {
toast({ status: "error", title: "There was an error updated user" });
onError(data: any) {
toast({ status: "error", title: "There was an error updating user" });
},
refetchQueries: ["FullUserAdmin", "FullUserAdminCount"],
});
@ -74,6 +77,20 @@ export const UserModal = ({ user, onClose }: Props) => {
[updateFullUserMutation, user]
);
const canEditUser = useMemo(() => {
return checkRights(AccessUnit.Write, "users");
}, [checkRights]);
const actionRights = useMemo(
() => ({
user: {
silence: checkActions("user", "silence"),
ban: checkActions("user", "ban"),
},
}),
[checkActions]
);
return (
<Modal
isOpen={!!user}
@ -203,6 +220,7 @@ export const UserModal = ({ user, onClose }: Props) => {
<SimpleGrid columns={2} spacing={2}>
<Button
onClick={() => onUpdate("suspended", !user?.suspended)}
isDisabled={!actionRights.user.ban}
colorScheme={!user?.suspended ? "red" : undefined}
>
{!user?.suspended ? "Suspend" : "Unsuspend"}
@ -211,17 +229,20 @@ export const UserModal = ({ user, onClose }: Props) => {
onClick={() =>
onUpdate("loginDisabled", !user?.loginDisabled)
}
isDisabled={!canEditUser}
colorScheme={!user?.loginDisabled ? "red" : undefined}
>
{!user?.loginDisabled ? "Disable Login" : "Enable Login"}
</Button>
<Button
onClick={() => onUpdate("deleted", !user?.deleted)}
isDisabled={!canEditUser}
colorScheme={!user?.deleted ? "red" : undefined}
>
{!user?.deleted ? "Delete" : "Restore"}
</Button>
<Button
isDisabled={!actionRights.user.silence}
onClick={() => onUpdate("silenced", !user?.silenced)}
colorScheme={!user?.silenced ? "red" : undefined}
>
@ -237,7 +258,11 @@ export const UserModal = ({ user, onClose }: Props) => {
<Heading size="md" mb={2} mt={4}>
Reset user's password
</Heading>
<ResetPasswordField userId={user.id} w="100%" />
<ResetPasswordField
userId={user.id}
w="100%"
isDisabled={!canEditUser}
/>
</>
)}
</Box>

58
src/client/AdminPanel/Users/index.tsx

@ -26,8 +26,10 @@ import { FormInput } from "miracle-tv-client/components/form/FormInput";
import { FormRolesSelect } from "miracle-tv-client/components/form/selects/FormRoleSelect";
import { Filter } from "miracle-tv-client/components/ui/Filter";
import { Loading } from "miracle-tv-client/components/ui/Loading";
import { useCurrentUser } from "miracle-tv-client/hooks/auth";
import { Pagination, usePagination } from "miracle-tv-client/hooks/pagination";
import {
AccessUnit,
AdminFullUserFragment,
FullUsersFilter,
} from "miracle-tv-shared/graphql";
@ -110,6 +112,7 @@ const defaultFilter: FullUsersFilter = {};
export const AdminUserList = () => {
const toast = useToast();
const { checkRights, checkActions, currentUser } = useCurrentUser();
const [filter, setFilter] = useState<FullUsersFilter>(defaultFilter);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
@ -310,6 +313,20 @@ export const AdminUserList = () => {
return fullUsers;
}, [fullUsers]);
const canEditUser = useMemo(() => {
return checkRights(AccessUnit.Write, "users");
}, [checkRights]);
const actionRights = useMemo(
() => ({
user: {
silence: checkActions("user", "silence"),
ban: checkActions("user", "ban"),
},
}),
[checkActions]
);
return (
<>
<Heading mb={2}>Users</Heading>
@ -356,17 +373,42 @@ export const AdminUserList = () => {
Bulk actions
</MenuButton>
<MenuList>
<MenuItem onClick={onBulkDelete}>Delete</MenuItem>
<MenuItem onClick={onBulkRestore}>Restore</MenuItem>
<MenuItem isDisabled={!canEditUser} onClick={onBulkDelete}>
Delete
</MenuItem>
<MenuItem isDisabled={!canEditUser} onClick={onBulkRestore}>
Restore
</MenuItem>
<MenuDivider />
<MenuItem onClick={onBulkSuspend}>Suspend</MenuItem>
<MenuItem onClick={onBulkUnsuspend}>Unsuspend</MenuItem>
<MenuItem isDisabled={!actionRights.user.ban} onClick={onBulkSuspend}>
Suspend
</MenuItem>
<MenuItem
isDisabled={!actionRights.user.ban}
onClick={onBulkUnsuspend}
>
Unsuspend
</MenuItem>
<MenuDivider />
<MenuItem onClick={onBulkLoginDisable}>Disable Login</MenuItem>
<MenuItem onClick={onBulkLoginEnable}>Enable Login</MenuItem>
<MenuItem isDisabled={!canEditUser} onClick={onBulkLoginDisable}>
Disable Login
</MenuItem>
<MenuItem isDisabled={!canEditUser} onClick={onBulkLoginEnable}>
Enable Login
</MenuItem>
<MenuDivider />
<MenuItem onClick={onBulkSilence}>Silence</MenuItem>
<MenuItem onClick={onBulkUnsilence}>Unsilence</MenuItem>
<MenuItem
isDisabled={!actionRights.user.silence}
onClick={onBulkSilence}
>
Silence
</MenuItem>
<MenuItem
isDisabled={!actionRights.user.silence}
onClick={onBulkUnsilence}
>
Unsilence
</MenuItem>
</MenuList>
</Menu>
<UserModal user={editableUser} onClose={onUserModalClose} />

2
src/client/components/form/FormSelect.tsx

@ -28,6 +28,7 @@ export const FormSelect = ({
options,
onSearch,
isLoading,
isDisabled,
...formGroupProps
}: FormSelectProps) => {
const { input } = useField(name);
@ -45,6 +46,7 @@ export const FormSelect = ({
placeholder={inputPlaceholder}
onSearch={onSearch}
isLoading={isLoading}
isDisabled={isDisabled}
{...inputProps}
/>
</FormGroup>

2
src/client/components/roles/RolePermissions/RolePermissions.tsx

@ -25,6 +25,7 @@ const accessRightsOrder: AccessKey[] = [
"streamKeys",
"activities",
"userSettings",
"sessions",
"system",
];
@ -36,6 +37,7 @@ const accessRightsLabels: Record<AccessKey, string> = {
activities: "Activities",
streamKeys: "Stream keys",
userSettings: "User Settings",
sessions: "Sessions",
};
export const RolePermissions = () => {

8
src/client/components/ui/CopyField.tsx

@ -13,12 +13,14 @@ import React, { useCallback, useState } from "react";
type Props = {
value: string;
isDisabled?: boolean;
hideValue?: boolean;
} & FlexProps;
export const CopyField = ({
value,
hideValue = false,
isDisabled = false,
placeholder,
...props
}: Props) => {
@ -45,15 +47,20 @@ export const CopyField = ({
<Input
value={value}
readOnly
isDisabled={isDisabled}
type={passwordType}
borderRightRadius={isMobile ? undefined : 0}
borderRight={isMobile ? undefined : 0}
placeholder={placeholder}
mb={isMobile ? 1 : undefined}
_hover={{
cursor: isDisabled ? "not-allowed" : undefined,
}}
/>
{hideValue && (
<IconButton
aria-label="Show / Hide password"
isDisabled={isDisabled}
icon={<VisibleIcon />}
onClick={() => setIsVisible(!isVisible)}
mb={isMobile ? 1 : undefined}
@ -61,6 +68,7 @@ export const CopyField = ({
)}
<IconButton
aria-label="Copy"
isDisabled={isDisabled}
icon={<CopyIcon />}
borderLeftWidth="1px"
borderLeftStyle={isMobile ? undefined : "solid"}

69
src/client/components/ui/Select.tsx

@ -27,6 +27,7 @@ export type SelectProps = {
options: SelectOption[];
onChange: (v: any | any[]) => void;
onSearch?: (query: string) => void;
isDisabled?: boolean;
isLoading?: boolean;
placeholder?: string;
};
@ -38,6 +39,7 @@ export const Select = ({
onChange,
multi = false,
placeholder = "Select value...",
isDisabled = false,
isLoading = false,
onSearch,
}: SelectProps) => {
@ -64,15 +66,19 @@ export const Select = ({
);
const onSelectClick = useCallback(() => {
setShowOptions(true);
setShowInput(true);
setTimeout(() => {
inputRef.current?.focus();
}, 10);
if (!isDisabled) {
setShowOptions(true);
setShowInput(true);
setTimeout(() => {
inputRef.current?.focus();
}, 10);
}
}, [setShowOptions, setShowInput, multi, inputRef]);
const onInputFocus = useCallback(() => {
setShowOptions(true);
if (!isDisabled) {
setShowOptions(true);
}
}, [setShowOptions]);
const onInputBlur = useCallback(() => {
@ -132,7 +138,14 @@ export const Select = ({
return (
<>
<Flex id="selectContainer" flexDirection="column" position="relative">
<Flex
id="selectContainer"
flexDirection="column"
position="relative"
_hover={{
cursor: isDisabled ? "not-allowed" : undefined,
}}
>
{showOptions && (
<Box
position="fixed"
@ -175,15 +188,17 @@ export const Select = ({
justifyContent="space-between"
>
{optionsMap[selectValue[0]]?.label}
<CloseIcon
onClick={(e: any) => {
e.stopPropagation();
onOptionRemove(selectValue[0]);
}}
height="100%"
fontSize="0.8rem"
cursor="pointer"
/>
{!isDisabled && (
<CloseIcon
onClick={(e: any) => {
e.stopPropagation();
onOptionRemove(selectValue[0]);
}}
height="100%"
fontSize="0.8rem"
cursor="pointer"
/>
)}
</Badge>
)}
{multi &&
@ -200,16 +215,18 @@ export const Select = ({
px={2}
>
{optionsMap[v as keyof typeof optionsMap]?.label}{" "}
<CloseIcon
h="0.5rem"
ml={1}
onClick={(e) => {
e.stopPropagation();
onOptionRemove(v);
}}
cursor="pointer"
zIndex={2}
/>
{!isDisabled && (
<CloseIcon
h="0.5rem"
ml={1}
onClick={(e) => {
e.stopPropagation();
onOptionRemove(v);
}}
cursor="pointer"
zIndex={2}
/>
)}
</Badge>
))}
<Input

26
src/client/hooks/auth.tsx

@ -4,11 +4,13 @@ import {
useCurrentUserSettingsQuery,
} from "miracle-tv-shared/hooks";
import {
AccessUnit,
CurrentUserFullQuery,
CurrentUserSettingsQuery,
} from "miracle-tv-shared/graphql";
import { useRouter } from "next/dist/client/router";
import { Url } from "url";
import { useCallback } from "react";
import { checkActions, checkRight } from "miracle-tv-shared/acl/utils";
type CurrentUserInfo = CurrentUserFullQuery["self"];
type CurrentUserSettings = CurrentUserSettingsQuery["userSettings"];
@ -16,6 +18,8 @@ type CurrentUserSettings = CurrentUserSettingsQuery["userSettings"];
type CurrentUserHookReturn = {
isUserLoading: boolean;
isUserCalled: boolean;
checkRights: (unit: AccessUnit, subject: string) => boolean;
checkActions: (subject: string, action: string) => boolean;
currentUser: CurrentUserInfo;
logout: () => void;
refetchUser: () => void;
@ -37,9 +41,25 @@ export const useCurrentUser = (): CurrentUserHookReturn => {
} = useCurrentUserFullQuery({});
const { reload } = useRouter();
const checkRightsFn = useCallback(
(unit: AccessUnit, subject: string) => {
return checkRight(self?.roles ?? [], unit, subject);
},
[self?.roles]
);
const checkActionsFn = useCallback(
(subject: string, action: string) => {
return checkActions(self?.roles ?? [], subject, action);
},
[self?.roles]
);
return {
currentUser: self || null,
isUserLoading: isUserLoading,
checkRights: checkRightsFn,
checkActions: checkActionsFn,
isUserCalled,
refetchUser,
logout: signOut(reload),
@ -95,8 +115,12 @@ export const CurrentUserFullFragment = gql`
rights {
channels
streamKeys
roles
users
activities
userSettings
system
sessions
}
actions {
user {

82
src/pages/admin/[[...path]].tsx

@ -12,6 +12,9 @@ import { AdminRolesPage } from "miracle-tv-client/AdminPanel/Roles";
import Head from "next/head";
import { AdminChannelsPage } from "miracle-tv-client/AdminPanel/Channels";
import { AdminActivitiesPage } from "miracle-tv-client/AdminPanel/Activities";
import { identity } from "ramda";
import { useCurrentUser } from "miracle-tv-client/hooks/auth";
import { AccessUnit } from "miracle-tv-shared/graphql";
const components: NavComponentMap = {
"/admin": { component: <AdminDashboard />, exact: true },
@ -21,42 +24,53 @@ const components: NavComponentMap = {
"/admin/activities": { component: <AdminActivitiesPage /> },
};
const nav: NavConfig = [
{
id: "admin",
urls: [
{ id: "dashboard", name: "Admin Dashboard", url: "/admin", exact: true },
{ id: "users", name: "Users", url: "/admin/users" },
{
id: "channels",
name: "Channels",
url: "/admin/channels",
},
{
id: "roles",
name: "Roles",
url: "/admin/roles",
},
{
id: "streamkeys",
name: "Stream Keys",
url: "/admin/stream-keys",
},
{
id: "sessions",
name: "Sessions",
url: "/admin/sessions",
},
const AdminPage = () => {
const { checkRights } = useCurrentUser();
const nav: NavConfig = useMemo(() => {
return [
{
id: "activities",
name: "Activities",
url: "/admin/activities",
id: "admin",
urls: [
checkRights(AccessUnit.Read, "system") && {
id: "dashboard",
name: "Admin Dashboard",
url: "/admin",
exact: true,
},
checkRights(AccessUnit.Read, "users") && {
id: "users",
name: "Users",
url: "/admin/users",
},
checkRights(AccessUnit.Read, "channels") && {
id: "channels",
name: "Channels",
url: "/admin/channels",
},
checkRights(AccessUnit.Read, "roles") && {
id: "roles",
name: "Roles",
url: "/admin/roles",
},
checkRights(AccessUnit.Read, "streamKeys") && {
id: "streamkeys",
name: "Stream Keys",
url: "/admin/stream-keys",
},
checkRights(AccessUnit.Read, "sessions") && {
id: "sessions",
name: "Sessions",
url: "/admin/sessions",
},
checkRights(AccessUnit.Read, "activities") && {
id: "activities",
name: "Activities",
url: "/admin/activities",
},
].filter(identity),
},
],
},
];
const AdminPage = () => {
];
}, [checkRights]);
return (
<AuthRedirect>
<Head>

1
src/server/db/generate-roles.ts

@ -17,6 +17,7 @@ const defaultAdminRole: Role = {
users: [AccessUnit.Write, AccessUnit.Read],
activities: [AccessUnit.Write, AccessUnit.Read],
userSettings: [AccessUnit.Write, AccessUnit.Read],
sessions: [AccessUnit.Write, AccessUnit.Read],
system: [AccessUnit.Write, AccessUnit.Read],
},
actions: {

4
src/server/graphql/directives/auth.tsx

@ -56,7 +56,9 @@ const roleGuard =
if (hasRoles && hasRights) {
return await resolve(source, args, context, info);
} else {
throw new AuthorizationError();
throw new AuthorizationError(
`Insufficient rights for: ${info.fieldName}`
);
}
};
return fieldConfig;

2
src/server/graphql/errors/auth.ts

@ -42,7 +42,7 @@ export class AuthenticationError extends ApolloError {
export class AuthorizationError extends ApolloError {
constructor(msg?: string) {
const prompt = "Unauthorized";
const message = msg ? `${prompt}: ${msg}` : "${prompt}:";
const message = msg ? `${prompt}: ${msg}` : `${prompt}:`;
super(message, "E_AUTHORIZATION");
Object.defineProperty(this, "name", { value: "E_AUTHORIZATION" });

2
src/server/graphql/errors/general.ts

@ -14,7 +14,7 @@ export class ServerError extends ApolloError {
export class NotFoundError extends ApolloError {
constructor(msg?: string) {
const prompt = "Not found";
const message = msg ? `${prompt}: ${msg}` : "${prompt}:";
const message = msg ? `${prompt}: ${msg}` : `${prompt}:`;
super(message, "E_SERVER");
Object.defineProperty(this, "name", { value: "ServerError" });

8
src/server/graphql/resolvers/users/index.ts

@ -11,6 +11,7 @@ import {
import { ResolverContext } from "miracle-tv-server/types/resolver";
import { fileResolver } from "miracle-tv-server/graphql/resolvers/file";
import { validate as uuidValidate } from "uuid";
import { getCompleteRights } from "miracle-tv-shared/acl/utils";
export const usersQueryResolver: QueryResolvers<ResolverContext>["users"] = (
_,
@ -102,7 +103,12 @@ export const userResolver: UserResolvers<ResolverContext> = {
const rolesList = await roles.getAll(
(user.roles as unknown as string[]) || []
);
return rolesList as Role[];
const allRoles = await roles.list();
const completeRoles = user.roles.map((role) =>
getCompleteRights(allRoles, role as unknown as string)
);
console.log(completeRoles);
return completeRoles as Role[];
},
avatar: fileResolver("avatar"),
header: fileResolver("header"),

3
src/server/graphql/schema/Roles.graphql

@ -24,6 +24,7 @@ type AccessRights {
activities: [AccessUnit]
userSettings: [AccessUnit]
system: [AccessUnit]
sessions: [AccessUnit]
}
type AccessTargets {
@ -87,6 +88,8 @@ input AccessRightsInput {
users: [AccessUnit]
activities: [AccessUnit]
userSettings: [AccessUnit]
system: [AccessUnit]
sessions: [AccessUnit]
}
input CreateRoleInput {

29
src/shared/acl/utils.ts

@ -4,7 +4,7 @@ import {
Role,
UserActions,
} from "miracle-tv-shared/graphql";
import { pathOr } from "ramda";
import { equals, intersection, is, pathOr } from "ramda";
import { any, flatten, identity, lensPath, view } from "ramda";
type RowMap = Record<string, Role>;
@ -22,9 +22,9 @@ const fetchAccess = (
roles
);
const parent: keyof RowMap = pathOr(null, currentParentPath, roles);
if (access === [AccessUnit.Inherit] && !parent) {
if (equals(access, [AccessUnit.Inherit]) && !parent) {
return [AccessUnit.Deny];
} else if (access !== [AccessUnit.Inherit]) {
} else if (!equals(access, [AccessUnit.Inherit])) {
return access;
}
return fetchAccess(roles, parent, target);
@ -64,6 +64,7 @@ export const getCompleteRights = (roles: Role[], target: Role["id"]): Role => {
channels: fetchAccess(rolesById, target, "channels"),
activities: fetchAccess(rolesById, target, "activities"),
userSettings: fetchAccess(rolesById, target, "userSettings"),
sessions: fetchAccess(rolesById, target, "sessions"),
system: fetchAccess(rolesById, target, "system"),
},
actions: {
@ -81,18 +82,38 @@ export const getCompleteRights = (roles: Role[], target: Role["id"]): Role => {
export const checkRight = (
roles: Role[],
unit: AccessUnit,
unit: AccessUnit | AccessUnit[],
subject: string
) => {
const channelEditRightsLens = lensPath(["access", "rights", subject]);
return any(
(right: AccessUnit[]) => {
if (is(Array, unit)) {
return intersection(unit, right).length > 0;
}
return right.includes(unit);
},
roles.map((e) => view(channelEditRightsLens, e) ?? [AccessUnit.Deny])
);
};
export const checkActions = (
roles: Role[],
subject: string,
action: string
) => {
const channelEditRightsLens = lensPath([
"access",
"actions",
subject,
action,
]);
return any(
identity,
roles.map((e) => view(channelEditRightsLens, e) ?? false)
);
};
const adminWritePermissions: Array<keyof AccessRights> = [
"channels",
"streamKeys",

35
src/shared/graphql.ts

@ -34,6 +34,7 @@ export type AccessRights = {
activities?: Maybe<Array<Maybe<AccessUnit>>>;
channels?: Maybe<Array<Maybe<AccessUnit>>>;
roles?: Maybe<Array<Maybe<AccessUnit>>>;
sessions?: Maybe<Array<Maybe<AccessUnit>>>;
streamKeys?: Maybe<Array<Maybe<AccessUnit>>>;
system?: Maybe<Array<Maybe<AccessUnit>>>;
userSettings?: Maybe<Array<Maybe<AccessUnit>>>;
@ -44,7 +45,9 @@ export type AccessRightsInput = {
activities?: InputMaybe<Array<InputMaybe<AccessUnit>>>;
channels?: InputMaybe<Array<InputMaybe<AccessUnit>>>;
roles?: InputMaybe<Array<InputMaybe<AccessUnit>>>;
sessions?: InputMaybe<Array<InputMaybe<AccessUnit>>>;
streamKeys?: InputMaybe<Array<InputMaybe<AccessUnit>>>;
system?: InputMaybe<Array<InputMaybe<AccessUnit>>>;
userSettings?: InputMaybe<Array<InputMaybe<AccessUnit>>>;
users?: InputMaybe<Array<InputMaybe<AccessUnit>>>;
};
@ -1115,6 +1118,11 @@ export type AccessRightsResolvers<
ParentType,
ContextType
>;
sessions?: Resolver<
Maybe<Array<Maybe<ResolversTypes["AccessUnit"]>>>,
ParentType,
ContextType
>;
streamKeys?: Resolver<
Maybe<Array<Maybe<ResolversTypes["AccessUnit"]>>>,
ParentType,
@ -2323,6 +2331,8 @@ export type AdminRolesQuery = {
| Array<AccessUnit | null | undefined>
| null
| undefined;
system?: Array<AccessUnit | null | undefined> | null | undefined;
sessions?: Array<AccessUnit | null | undefined> | null | undefined;
};
actions: {
__typename?: "Actions";
@ -2383,6 +2393,8 @@ export type AdminRolePageQuery = {
| Array<AccessUnit | null | undefined>
| null
| undefined;
system?: Array<AccessUnit | null | undefined> | null | undefined;
sessions?: Array<AccessUnit | null | undefined> | null | undefined;
};
actions: {
__typename?: "Actions";
@ -2423,6 +2435,8 @@ export type AdminUpdateRoleMutation = {
users?: Array<AccessUnit | null | undefined> | null | undefined;
activities?: Array<AccessUnit | null | undefined> | null | undefined;
userSettings?: Array<AccessUnit | null | undefined> | null | undefined;
system?: Array<AccessUnit | null | undefined> | null | undefined;
sessions?: Array<AccessUnit | null | undefined> | null | undefined;
};
actions: {
__typename?: "Actions";
@ -2455,6 +2469,8 @@ export type AdminRoleFragment = {
users?: Array<AccessUnit | null | undefined> | null | undefined;
activities?: Array<AccessUnit | null | undefined> | null | undefined;
userSettings?: Array<AccessUnit | null | undefined> | null | undefined;
system?: Array<AccessUnit | null | undefined> | null | undefined;
sessions?: Array<AccessUnit | null | undefined> | null | undefined;
};
actions: {
__typename?: "Actions";
@ -4909,6 +4925,8 @@ export type AdminCreateRoleMutation = {
users?: Array<AccessUnit | null | undefined> | null | undefined;
activities?: Array<AccessUnit | null | undefined> | null | undefined;
userSettings?: Array<AccessUnit | null | undefined> | null | undefined;
system?: Array<AccessUnit | null | undefined> | null | undefined;
sessions?: Array<AccessUnit | null | undefined> | null | undefined;
};
actions: {
__typename?: "Actions";
@ -5069,11 +5087,18 @@ export type CurrentUserFragment = {
| Array<AccessUnit | null | undefined>
| null
| undefined;
roles?: Array<AccessUnit | null | undefined> | null | undefined;
users?: Array<AccessUnit | null | undefined> | null | undefined;
activities?:
| Array<AccessUnit | null | undefined>
| null
| undefined;
userSettings?:
| Array<AccessUnit | null | undefined>
| null
| undefined;
system?: Array<AccessUnit | null | undefined> | null | undefined;
sessions?: Array<AccessUnit | null | undefined> | null | undefined;
};
actions: {
__typename?: "Actions";
@ -5183,11 +5208,21 @@ export type CurrentUserFullQuery = {
| Array<AccessUnit | null | undefined>
| null
| undefined;
roles?: Array<AccessUnit | null | undefined> | null | undefined;
users?: Array<AccessUnit | null | undefined> | null | undefined;
activities?:
| Array<AccessUnit | null | undefined>
| null
| undefined;
userSettings?:
| Array<AccessUnit | null | undefined>
| null
| undefined;
system?: Array<AccessUnit | null | undefined> | null | undefined;
sessions?:
| Array<AccessUnit | null | undefined>
| null
| undefined;
};
actions: {
__typename?: "Actions";

6
src/shared/hooks.ts

@ -31,6 +31,8 @@ export const AdminRoleFragmentDoc = gql`
users
activities
userSettings
system
sessions
}
actions {
user {
@ -321,8 +323,12 @@ export const CurrentUserFragmentDoc = gql`
rights {
channels
streamKeys
roles
users
activities
userSettings
system
sessions
}
actions {
user {

Loading…
Cancel
Save