From 7bd6c03c3e9c5be152551060a8a110ba0001bfe1 Mon Sep 17 00:00:00 2001 From: nl_0 Date: Mon, 27 May 2024 09:47:42 +0200 Subject: [PATCH 1/3] fancier username display --- catalog/app/containers/Admin/Users/Users.tsx | 24 +++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/catalog/app/containers/Admin/Users/Users.tsx b/catalog/app/containers/Admin/Users/Users.tsx index 94581e2cfe6..3f74ae256af 100644 --- a/catalog/app/containers/Admin/Users/Users.tsx +++ b/catalog/app/containers/Admin/Users/Users.tsx @@ -510,16 +510,26 @@ const useUsernameStyles = M.makeStyles((t) => ({ }, })) -interface UsernameProps extends React.HTMLProps { - admin?: boolean +interface UsernameProps { + name: string + admin: boolean + self: boolean } -function Username({ className, admin = false, children, ...props }: UsernameProps) { +function Username({ name, admin, self }: UsernameProps) { const classes = useUsernameStyles() return ( - + {admin && security} - {children} + + {self ? ( + + {name}* + + ) : ( + name + )} + ) } @@ -753,7 +763,9 @@ const columns: Table.Column[] = [ id: 'username', label: 'Username', getValue: (u) => u.name, - getDisplay: (_name: string, u) => {u.name}, + getDisplay: (_name: string, u, { isSelf }: ColumnDisplayProps) => ( + + ), props: { component: 'th', scope: 'row' }, }, { From a99a67eb64fb84e951131f0456f1e2a504f6bb61 Mon Sep 17 00:00:00 2001 From: nl_0 Date: Wed, 29 May 2024 16:06:26 +0200 Subject: [PATCH 2/3] update gql schema, regen --- catalog/app/model/graphql/schema.generated.ts | 50 +++++-------------- catalog/app/model/graphql/types.generated.ts | 14 ++---- shared/graphql/schema.graphql | 10 ++-- 3 files changed, 23 insertions(+), 51 deletions(-) diff --git a/catalog/app/model/graphql/schema.generated.ts b/catalog/app/model/graphql/schema.generated.ts index 3853548b941..8f3adb33896 100644 --- a/catalog/app/model/graphql/schema.generated.ts +++ b/catalog/app/model/graphql/schema.generated.ts @@ -1474,51 +1474,27 @@ export default { }, args: [ { - name: 'roleName', - type: { - kind: 'SCALAR', - name: 'String', - ofType: null, - }, - }, - ], - }, - { - name: 'setRoles', - type: { - kind: 'NON_NULL', - ofType: { - kind: 'UNION', - name: 'UserResult', - ofType: null, - }, - }, - args: [ - { - name: 'roleNames', + name: 'role', type: { kind: 'NON_NULL', ofType: { - kind: 'LIST', - ofType: { - kind: 'NON_NULL', - ofType: { - kind: 'SCALAR', - name: 'String', - ofType: null, - }, - }, + kind: 'SCALAR', + name: 'String', + ofType: null, }, }, }, { - name: 'activeRoleName', + name: 'extraRoles', type: { - kind: 'NON_NULL', + kind: 'LIST', ofType: { - kind: 'SCALAR', - name: 'String', - ofType: null, + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'String', + ofType: null, + }, }, }, }, @@ -5249,7 +5225,7 @@ export default { args: [], }, { - name: 'roles', + name: 'extraRoles', type: { kind: 'NON_NULL', ofType: { diff --git a/catalog/app/model/graphql/types.generated.ts b/catalog/app/model/graphql/types.generated.ts index ab6e4d49dc6..ebd1100ef17 100644 --- a/catalog/app/model/graphql/types.generated.ts +++ b/catalog/app/model/graphql/types.generated.ts @@ -333,7 +333,6 @@ export interface MutateUserAdminMutations { readonly delete: OperationResult readonly setEmail: UserResult readonly setRole: UserResult - readonly setRoles: UserResult readonly setAdmin: UserResult readonly setActive: UserResult readonly resetPassword: OperationResult @@ -344,12 +343,8 @@ export interface MutateUserAdminMutationssetEmailArgs { } export interface MutateUserAdminMutationssetRoleArgs { - roleName: Maybe -} - -export interface MutateUserAdminMutationssetRolesArgs { - roleNames: ReadonlyArray - activeRoleName: Scalars['String'] + role: Scalars['String'] + extraRoles: Maybe> } export interface MutateUserAdminMutationssetAdminArgs { @@ -1139,7 +1134,7 @@ export interface User { readonly isSsoOnly: Scalars['Boolean'] readonly isService: Scalars['Boolean'] readonly role: Maybe - readonly roles: ReadonlyArray + readonly extraRoles: ReadonlyArray } export interface UserAdminMutations { @@ -1169,7 +1164,8 @@ export interface UserAdminQueriesgetArgs { export interface UserInput { readonly name: Scalars['String'] readonly email: Scalars['String'] - readonly roleName: Maybe + readonly role: Scalars['String'] + readonly extraRoles: Maybe> } export type UserResult = User | InvalidInput | OperationError diff --git a/shared/graphql/schema.graphql b/shared/graphql/schema.graphql index fcae6dd2ffd..c3a282b3ebe 100644 --- a/shared/graphql/schema.graphql +++ b/shared/graphql/schema.graphql @@ -200,8 +200,8 @@ type User { isAdmin: Boolean! isSsoOnly: Boolean! isService: Boolean! - role: Role # XXX: activeRole? - roles: [Role!]! # XXX: better name + role: Role + extraRoles: [Role!]! } type AccessCountForDate { @@ -838,7 +838,8 @@ union BrowsingSessionDisposeResult = Ok | OperationError input UserInput { name: String! email: String! - roleName: String + role: String! + extraRoles: [String!] } union UserResult = User | InvalidInput | OperationError @@ -846,8 +847,7 @@ union UserResult = User | InvalidInput | OperationError type MutateUserAdminMutations { delete: OperationResult! setEmail(email: String!): UserResult! - setRole(roleName: String): UserResult! - setRoles(roleNames: [String!]!, activeRoleName: String!): UserResult! + setRole(role: String!, extraRoles: [String!]): UserResult! setAdmin(admin: Boolean!): UserResult! setActive(active: Boolean!): UserResult! resetPassword: OperationResult! From 8b8c39cbd16ad97dd2131a995a229475e7330cfc Mon Sep 17 00:00:00 2001 From: nl_0 Date: Wed, 29 May 2024 16:08:59 +0200 Subject: [PATCH 3/3] adjust api, draft ui --- catalog/app/containers/Admin/Users/Users.tsx | 282 ++++++++++++------ .../Users/gql/UserSelection.generated.ts | 4 +- .../Admin/Users/gql/UserSelection.graphql | 2 +- ....generated.ts => UserSetRole.generated.ts} | 48 +-- ...erSetRoles.graphql => UserSetRole.graphql} | 4 +- 5 files changed, 224 insertions(+), 116 deletions(-) rename catalog/app/containers/Admin/Users/gql/{UserSetRoles.generated.ts => UserSetRole.generated.ts} (88%) rename catalog/app/containers/Admin/Users/gql/{UserSetRoles.graphql => UserSetRole.graphql} (53%) diff --git a/catalog/app/containers/Admin/Users/Users.tsx b/catalog/app/containers/Admin/Users/Users.tsx index 3f74ae256af..db755197f3a 100644 --- a/catalog/app/containers/Admin/Users/Users.tsx +++ b/catalog/app/containers/Admin/Users/Users.tsx @@ -1,5 +1,6 @@ import cx from 'classnames' import * as FF from 'final-form' +import invariant from 'invariant' import * as R from 'ramda' import * as React from 'react' import * as redux from 'react-redux' @@ -23,7 +24,7 @@ import USERS_QUERY from './gql/Users.generated' import USER_CREATE_MUTATION from './gql/UserCreate.generated' import USER_DELETE_MUTATION from './gql/UserDelete.generated' import USER_SET_EMAIL_MUTATION from './gql/UserSetEmail.generated' -import USER_SET_ROLES_MUTATION from './gql/UserSetRoles.generated' +import USER_SET_ROLE_MUTATION from './gql/UserSetRole.generated' import USER_SET_ACTIVE_MUTATION from './gql/UserSetActive.generated' import USER_SET_ADMIN_MUTATION from './gql/UserSetAdmin.generated' @@ -49,6 +50,154 @@ function Mono({ className, children }: MonoProps) { return {children} } +const useRoleSelectStyles = M.makeStyles((t) => ({ + root: {}, + chips: { + display: 'flex', + flexWrap: 'wrap', + marginTop: t.spacing(2.5), + }, + chip: { + marginRight: t.spacing(0.5), + marginTop: t.spacing(0.5), + }, + addIcon: { + transform: 'rotate(45deg)', + }, +})) + +interface RoleSelectValue { + extra: readonly Role[] + active: Role | null | undefined +} + +const ROLE_SELECT_VALUE_EMPTY: RoleSelectValue = { extra: [], active: undefined } + +interface RoleSelectProps extends RF.FieldRenderProps { + roles: readonly Role[] + label?: React.ReactNode + // value?: RoleSelectValue + // input: { + // onChange: (value: RoleSelectValue) => void + // } +} + +// TODO: +// - [ ] validation +function RoleSelect({ + roles, + input: { value, onChange /*...input*/ }, + label, // ...props +}: RoleSelectProps) { + const classes = useRoleSelectStyles() + const { active, extra } = value ?? ROLE_SELECT_VALUE_EMPTY + + const selected = React.useMemo( + () => extra.concat(active ?? []).sort(R.ascend((r) => r.name)), + [extra, active], + ) + + const available = React.useMemo( + () => + roles + .filter((r) => !selected.find((r2) => r2.id === r.id)) + .sort(R.ascend((r) => r.name)), + [roles, selected], + ) + + const [anchorEl, setAnchorEl] = React.useState(null) + + const openAddMenu = React.useCallback( + (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget) + }, + [setAnchorEl], + ) + + const closeAddMenu = React.useCallback(() => { + setAnchorEl(null) + }, []) + + const add = (r: Role) => + onChange({ + extra: extra.concat(active ? r : []).sort(R.ascend((r2) => r2.name)), + active: active ?? r, + }) + + const remove = (r: Role) => + onChange({ + extra: extra.filter((r2) => r2.id !== r.id), + active: r.id === active?.id ? undefined : active, + }) + + const activate = (r: Role) => + onChange({ + extra: extra + .concat(active ?? []) + .filter((r2) => r2.id !== r.id) + .sort(R.ascend((r2) => r2.name)), + active: r, + }) + + return ( + + {!!label && {label}} +
+ {selected.map((r) => + active?.id === r.id ? ( + remove(r)} + /> + ) : ( + remove(r)} + clickable + onClick={() => activate(r)} + /> + ), + )} + {available.length > 0 && ( + + )} +
+ + {available.map((r) => ( + { + closeAddMenu() + add(r) + }} + > + {r.name} + + ))} + + Assign a role please +
+ ) +} + const useInviteStyles = M.makeStyles({ infoIcon: { fontSize: '1.25em', @@ -59,21 +208,29 @@ const useInviteStyles = M.makeStyles({ interface InviteProps { close: () => void roles: readonly Role[] - defaultRoleName: string | undefined + defaultRole: Role | null } -function Invite({ close, roles, defaultRoleName }: InviteProps) { +function Invite({ close, roles, defaultRole }: InviteProps) { const classes = useInviteStyles() const create = GQL.useMutation(USER_CREATE_MUTATION) const { push } = Notifications.use() + interface FormValues { + username: string + email: string + roles: RoleSelectValue + } + const onSubmit = React.useCallback( - async ({ username, email, roleName }) => { + async (values: FormValues) => { // XXX: use formspec to convert/validate form values into gql input? + invariant(values.roles.active, 'No active role') const input = { - name: username, - email, - roleName, + name: values.username, + email: values.email, + role: values.roles.active.name, + extraRoles: values.roles.extra.map((r) => r.name), } try { const data = await create({ input }) @@ -123,11 +280,11 @@ function Invite({ close, roles, defaultRoleName }: InviteProps) { [create, push, close], ) + const active = defaultRole || roles[0] + const extra = roles.filter((r) => r.id !== active.id) + return ( - + onSubmit={onSubmit} initialValues={{ roles: { active, extra } }}> {({ handleSubmit, submitting, @@ -180,19 +337,8 @@ function Invite({ close, roles, defaultRoleName }: InviteProps) { }} autoComplete="off" /> - - {roles.map((r) => ( - - {r.name} - - ))} + name="roles"> + {(props) => } {(!!error || !!submitError) && ( { - // console.log('submit', values) + async (values: FormValues) => { // XXX: use formspec to convert/validate form values into gql input? - const input = { + invariant(values.roles.active, 'No active role') + const vars = { name: user.name, - roleNames: values.roles.map((r: Role) => r.name), - activeRoleName: values.roles[0].name, + role: values.roles.active.name, + extraRoles: values.roles.extra.map((r) => r.name), } try { - const data = await setRoles(input) - const r = data.admin.user.mutate?.setRoles + const data = await setRole(vars) + const r = data.admin.user.mutate?.setRole switch (r?.__typename) { case undefined: throw new Error('User not found') // should not happend @@ -577,23 +727,21 @@ function EditRoles({ close, roles, user }: EditRolesProps) { } } catch (e) { // eslint-disable-next-line no-console - console.error('Error setting roles for user', input) + console.error('Error setting roles for user', vars) // eslint-disable-next-line no-console console.dir(e) Sentry.captureException(e) return { [FF.FORM_ERROR]: 'unexpected' } } }, - [push, close, setRoles, user.name], + [push, close, setRole, user.name], ) - const handleToggle = (selected: Role[], role: Role) => - selected.find((r) => r.id === role.id) - ? selected.filter((r) => r.id !== role.id) - : [...selected, role] - return ( - + + onSubmit={onSubmit} + initialValues={{ roles: { active: user.role, extra: user.extraRoles } }} + > {({ handleSubmit, submitting, @@ -608,48 +756,8 @@ function EditRoles({ close, roles, user }: EditRolesProps) { Configure roles for {user.name}
- - name="roles" - // validate={validators.required as FF.FieldValidator} - // errors={{ - // required: 'Enter a username', - // taken: 'Username already taken', - // invalid: ( - // <> - // Enter a valid username{' '} - // - // info - // - // - // ), - // }} - // autoComplete="off" - > - {({ input: { onChange, value } }) => ( - - {roles.map((role) => ( - onChange(handleToggle(value, role))} - > - - r.id === role.id)} - tabIndex={-1} - disableRipple - /> - - - - ))} - - )} + name="roles"> + {(props) => } {(!!error || !!submitError) && (
+ {/*TODO: reset?*/} Cancel @@ -784,7 +893,7 @@ const columns: Table.Column[] = [ } > {v ?? emptyRole} - {u.roles.length > 1 && +{u.roles.length - 1}} + {u.extraRoles.length > 0 && +{u.extraRoles.length}} ), }, @@ -882,8 +991,7 @@ function useSetActive() { export default function Users() { const data = GQL.useQueryS(USERS_QUERY) const rows = data.admin.user.list - const roles = data.roles - const defaultRoleName = data.defaultRole?.name + const { roles, defaultRole } = data const openDialog = Dialogs.use() @@ -907,10 +1015,10 @@ export default function Users() { icon: add, fn: React.useCallback(() => { openDialog( - ({ close }) => , + ({ close }) => , DIALOG_PROPS, ) - }, [roles, defaultRoleName, openDialog]), + }, [roles, defaultRole, openDialog]), }, ] diff --git a/catalog/app/containers/Admin/Users/gql/UserSelection.generated.ts b/catalog/app/containers/Admin/Users/gql/UserSelection.generated.ts index 2993119988d..2c045575818 100644 --- a/catalog/app/containers/Admin/Users/gql/UserSelection.generated.ts +++ b/catalog/app/containers/Admin/Users/gql/UserSelection.generated.ts @@ -20,7 +20,7 @@ export type UserSelectionFragment = { readonly __typename: 'User' } & Pick< >) | ({ readonly __typename: 'ManagedRole' } & Pick) > - readonly roles: ReadonlyArray< + readonly extraRoles: ReadonlyArray< | ({ readonly __typename: 'UnmanagedRole' } & Pick< Types.UnmanagedRole, 'id' | 'name' @@ -88,7 +88,7 @@ export const UserSelectionFragmentDoc = { }, { kind: 'Field', - name: { kind: 'Name', value: 'roles' }, + name: { kind: 'Name', value: 'extraRoles' }, selectionSet: { kind: 'SelectionSet', selections: [ diff --git a/catalog/app/containers/Admin/Users/gql/UserSelection.graphql b/catalog/app/containers/Admin/Users/gql/UserSelection.graphql index 17fddeaddee..d9151dc96d5 100644 --- a/catalog/app/containers/Admin/Users/gql/UserSelection.graphql +++ b/catalog/app/containers/Admin/Users/gql/UserSelection.graphql @@ -19,7 +19,7 @@ fragment UserSelection on User { name } } - roles { + extraRoles { __typename ... on ManagedRole { id diff --git a/catalog/app/containers/Admin/Users/gql/UserSetRoles.generated.ts b/catalog/app/containers/Admin/Users/gql/UserSetRole.generated.ts similarity index 88% rename from catalog/app/containers/Admin/Users/gql/UserSetRoles.generated.ts rename to catalog/app/containers/Admin/Users/gql/UserSetRole.generated.ts index 6a4b139d83a..65b3d88258d 100644 --- a/catalog/app/containers/Admin/Users/gql/UserSetRoles.generated.ts +++ b/catalog/app/containers/Admin/Users/gql/UserSetRole.generated.ts @@ -9,20 +9,20 @@ import { UserResultSelectionFragmentDoc, } from './UserResultSelection.generated' -export type containers_Admin_Users_gql_UserSetRolesMutationVariables = Types.Exact<{ +export type containers_Admin_Users_gql_UserSetRoleMutationVariables = Types.Exact<{ name: Types.Scalars['String'] - roleNames: ReadonlyArray - activeRoleName: Types.Scalars['String'] + role: Types.Scalars['String'] + extraRoles: ReadonlyArray }> -export type containers_Admin_Users_gql_UserSetRolesMutation = { +export type containers_Admin_Users_gql_UserSetRoleMutation = { readonly __typename: 'Mutation' } & { readonly admin: { readonly __typename: 'AdminMutations' } & { readonly user: { readonly __typename: 'UserAdminMutations' } & { readonly mutate: Types.Maybe< { readonly __typename: 'MutateUserAdminMutations' } & { - readonly setRoles: + readonly setRole: | ({ readonly __typename: 'User' } & UserResultSelection_User_Fragment) | ({ readonly __typename: 'InvalidInput' @@ -36,13 +36,13 @@ export type containers_Admin_Users_gql_UserSetRolesMutation = { } } -export const containers_Admin_Users_gql_UserSetRolesDocument = { +export const containers_Admin_Users_gql_UserSetRoleDocument = { kind: 'Document', definitions: [ { kind: 'OperationDefinition', operation: 'mutation', - name: { kind: 'Name', value: 'containers_Admin_Users_gql_UserSetRoles' }, + name: { kind: 'Name', value: 'containers_Admin_Users_gql_UserSetRole' }, variableDefinitions: [ { kind: 'VariableDefinition', @@ -54,7 +54,15 @@ export const containers_Admin_Users_gql_UserSetRolesDocument = { }, { kind: 'VariableDefinition', - variable: { kind: 'Variable', name: { kind: 'Name', value: 'roleNames' } }, + variable: { kind: 'Variable', name: { kind: 'Name', value: 'role' } }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, + }, + }, + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'extraRoles' } }, type: { kind: 'NonNullType', type: { @@ -66,14 +74,6 @@ export const containers_Admin_Users_gql_UserSetRolesDocument = { }, }, }, - { - kind: 'VariableDefinition', - variable: { kind: 'Variable', name: { kind: 'Name', value: 'activeRoleName' } }, - type: { - kind: 'NonNullType', - type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, - }, - }, ], selectionSet: { kind: 'SelectionSet', @@ -108,22 +108,22 @@ export const containers_Admin_Users_gql_UserSetRolesDocument = { selections: [ { kind: 'Field', - name: { kind: 'Name', value: 'setRoles' }, + name: { kind: 'Name', value: 'setRole' }, arguments: [ { kind: 'Argument', - name: { kind: 'Name', value: 'roleNames' }, + name: { kind: 'Name', value: 'role' }, value: { kind: 'Variable', - name: { kind: 'Name', value: 'roleNames' }, + name: { kind: 'Name', value: 'role' }, }, }, { kind: 'Argument', - name: { kind: 'Name', value: 'activeRoleName' }, + name: { kind: 'Name', value: 'extraRoles' }, value: { kind: 'Variable', - name: { kind: 'Name', value: 'activeRoleName' }, + name: { kind: 'Name', value: 'extraRoles' }, }, }, ], @@ -152,8 +152,8 @@ export const containers_Admin_Users_gql_UserSetRolesDocument = { ...UserResultSelectionFragmentDoc.definitions, ], } as unknown as DocumentNode< - containers_Admin_Users_gql_UserSetRolesMutation, - containers_Admin_Users_gql_UserSetRolesMutationVariables + containers_Admin_Users_gql_UserSetRoleMutation, + containers_Admin_Users_gql_UserSetRoleMutationVariables > -export { containers_Admin_Users_gql_UserSetRolesDocument as default } +export { containers_Admin_Users_gql_UserSetRoleDocument as default } diff --git a/catalog/app/containers/Admin/Users/gql/UserSetRoles.graphql b/catalog/app/containers/Admin/Users/gql/UserSetRole.graphql similarity index 53% rename from catalog/app/containers/Admin/Users/gql/UserSetRoles.graphql rename to catalog/app/containers/Admin/Users/gql/UserSetRole.graphql index 5e96e4efda7..3bb2eb35f60 100644 --- a/catalog/app/containers/Admin/Users/gql/UserSetRoles.graphql +++ b/catalog/app/containers/Admin/Users/gql/UserSetRole.graphql @@ -1,10 +1,10 @@ # import UserResultSelection from "./UserResultSelection.graphql" -mutation ($name: String!, $roleNames: [String!]!, $activeRoleName: String!) { +mutation ($name: String!, $role: String!, $extraRoles: [String!]!) { admin { user { mutate(name: $name) { - setRoles(roleNames: $roleNames, activeRoleName: $activeRoleName) { + setRole(role: $role, extraRoles: $extraRoles) { ...UserResultSelection } }