diff --git a/src/app/(dashboard)/users/invite/page.tsx b/src/app/(dashboard)/users/invite/page.tsx index b2460577..048cb028 100644 --- a/src/app/(dashboard)/users/invite/page.tsx +++ b/src/app/(dashboard)/users/invite/page.tsx @@ -1,3 +1,3 @@ -export default function Page() { - return null -} \ No newline at end of file +import {UserInvitePage} from "@edition/user/pages/UserInvitePage"; + +export default UserInvitePage diff --git a/src/packages/ce/src/user/components/UserEditDialogComponent.tsx b/src/packages/ce/src/user/components/UserEditDialogComponent.tsx new file mode 100644 index 00000000..b0c0b06e --- /dev/null +++ b/src/packages/ce/src/user/components/UserEditDialogComponent.tsx @@ -0,0 +1,296 @@ +"use client" + +import React from "react"; +import { + Button, + ButtonGroup, + Card, Col, + Dialog, + DialogClose, + DialogContent, + DialogOverlay, + DialogPortal, + EmailInput, + emailValidation, + Flex, + PasswordInput, + passwordValidation, Row, + Spacing, + SwitchInput, + Text, + TextAreaInput, + TextInput, + useForm, + useService, + useStore +} from "@code0-tech/pictor"; +import CardSection from "@code0-tech/pictor/dist/components/card/CardSection"; +import {Tab, TabContent, TabList, TabTrigger} from "@code0-tech/pictor/dist/components/tab/Tab"; +import {User, UsersUpdateInput} from "@code0-tech/sagittarius-graphql-types"; +import {UserService} from "@edition/user/services/User.service"; +import { + addIslandErrorNotification, + addIslandSuccessNotification +} from "@code0-tech/pictor/dist/components/island/Island.hook"; +import {IconAt, IconLock, IconMail, IconUser, IconX} from "@tabler/icons-react"; +import {Layout} from "@code0-tech/pictor/dist/components/layout/Layout"; + +export interface UserEditDialogComponentProps { + userId?: User['id'] + open?: boolean + onOpenChange?: (open: boolean) => void +} + +export const UserEditDialogComponent: React.FC = (props) => { + + const {userId, open, onOpenChange} = props + + const userService = useService(UserService) + const userStore = useStore(UserService) + const [, startTransition] = React.useTransition() + + const user = React.useMemo( + () => userService.getById(userId), + [userStore, userId] + ) + + const initialValues = React.useMemo(() => ({ + firstname: user?.firstname ?? null, + lastname: user?.lastname ?? null, + email: user?.email ?? null, + username: user?.username ?? null, + readme: user?.readme ?? null, + admin: user?.admin ?? false, + password: null, + repeatPassword: null, + }), [user]) + + const [inputs, validate] = useForm<{ + firstname: string | null, + lastname: string | null, + email: string | null, + username: string | null, + readme: string | null, + admin: boolean | null, + password: string | null, + repeatPassword: string | null, + }>({ + useInitialValidation: false, + initialValues: initialValues, + validate: { + email: (value) => { + if (!value) return "Email is required" + if (!emailValidation(value)) return "Please provide a valid email" + return null + }, + username: (value) => { + if (!value) return "Username is required" + return null + }, + password: (value) => { + if (!value) return null + return passwordValidation(value) + }, + repeatPassword: (value, values) => { + if (!values?.password) return null + if (passwordValidation(value) != null) return passwordValidation(value) + if (value != values?.password) return "Passwords do not match" + return null + } + }, + onSubmit: (values) => { + if (!userId) return + if (!values.email || !values.username) return + + const payload: UsersUpdateInput = {userId: userId} + if (values.email !== (user?.email ?? null)) payload.email = values.email + if (values.username !== (user?.username ?? null)) payload.username = values.username + if (values.firstname !== (user?.firstname ?? null)) payload.firstname = values.firstname + if (values.lastname !== (user?.lastname ?? null)) payload.lastname = values.lastname + if (values.readme !== (user?.readme ?? null)) payload.readme = values.readme + if (values.admin !== (user?.admin ?? false)) payload.admin = values.admin + if (values.password) { + payload.password = values.password + payload.passwordRepeat = values.repeatPassword + } + + // nothing but the userId changed, so there is nothing to update + if (Object.keys(payload).length <= 1) { + onOpenChange?.(false) + return + } + + startTransition(async () => { + await userService.usersUpdate(payload).then(payload => { + if (payload?.user && (payload?.errors?.length ?? 0) <= 0) { + onOpenChange?.(false) + addIslandSuccessNotification({ + message: "Updated user", + }) + } + }) + }) + } + }) + + return onOpenChange?.(open)}> + + + + + + + Settings of @{user?.username ?? ""} + + + + Edit the general settings, permissions and security for user + @{user?.username ?? ""} + + + + + + + + + + + + + + }> + + + + General + + + + + + + + + + + + } leftType={"icon"} + {...inputs.getInputProps("firstname")}/> + + + } leftType={"icon"} + {...inputs.getInputProps("lastname")}/> + + + + + + } leftType={"icon"} + {...inputs.getInputProps("email")}/> + + } leftType={"icon"} + {...inputs.getInputProps("username")}/> + + + + + + Permissions + + + + + + + + + + + + + + Admin + + Grant this user administrator privileges. + + + + + + + + + + Security + + + + + + + + + + } leftType={"icon"} + onChange={() => validate("password")} + {...inputs.getInputProps("password")}/> + + } leftType={"icon"} + onChange={() => validate("repeatPassword")} + {...inputs.getInputProps("repeatPassword")}/> + + + + + + + +} diff --git a/src/packages/ce/src/user/pages/UserInvitePage.tsx b/src/packages/ce/src/user/pages/UserInvitePage.tsx new file mode 100644 index 00000000..87a042d1 --- /dev/null +++ b/src/packages/ce/src/user/pages/UserInvitePage.tsx @@ -0,0 +1,168 @@ +"use client" + +import React from "react"; +import { + Button, + Col, + EmailInput, + emailValidation, + Flex, + PasswordInput, + passwordValidation, + Spacing, + SwitchInput, + Text, + TextInput, + useForm, + useService, + useStore +} from "@code0-tech/pictor"; +import Link from "next/link"; +import {useRouter, notFound} from "next/navigation"; +import {UserService} from "@edition/user/services/User.service"; +import {useUserSession} from "@edition/user/hooks/User.session.hook"; +import {addIslandSuccessNotification, addIslandErrorNotification} from "@code0-tech/pictor/dist/components/island/Island.hook"; +import {IconAt, IconLock, IconMail, IconUser} from "@tabler/icons-react"; + +export const UserInvitePage: React.FC = () => { + + const currentSession = useUserSession() + const userService = useService(UserService) + const userStore = useStore(UserService) + const currentUser = React.useMemo(() => userService.getById(currentSession?.user?.id), [userStore, currentSession]) + const router = useRouter() + const [, startTransition] = React.useTransition() + + const [inputs, validate] = useForm<{ + email: string | null, + username: string | null, + firstname: string | null, + lastname: string | null, + password: string | null, + repeatPassword: string | null, + admin: boolean | null, + }>({ + useInitialValidation: false, + initialValues: { + email: null, + username: null, + firstname: null, + lastname: null, + password: null, + repeatPassword: null, + admin: false, + }, + validate: { + email: (value) => { + if (!value) return "Email is required" + if (!emailValidation(value)) return "Please provide a valid email" + return null + }, + username: (value) => { + if (!value) return "Username is required" + return null + }, + password: passwordValidation, + repeatPassword: (value, values) => { + if (passwordValidation(value) != null) return passwordValidation(value) + if (value != values?.password) return "Passwords do not match" + return null + } + }, + onSubmit: (values) => { + if (!values.email || !values.username || !values.password || !values.repeatPassword) return + startTransition(async () => { + await userService.usersCreate({ + email: values.email!!, + username: values.username!!, + firstname: values.firstname, + lastname: values.lastname, + password: values.password!!, + passwordRepeat: values.repeatPassword!!, + admin: values.admin, + }).then(payload => { + if (payload?.user) { + router.push("/users") + addIslandSuccessNotification({ + message: "Invited user", + }) + } + }) + }) + } + }) + + if (currentUser && !currentUser.admin) { + notFound() + } + + return
+ + + + Invite a new user + + + + Create a new user with access to your instance. They can log in with the credentials you provide. + + + + {/*@ts-ignore*/} + } leftType={"icon"} + {...inputs.getInputProps("firstname")}/> + {/*@ts-ignore*/} + } leftType={"icon"} + {...inputs.getInputProps("lastname")}/> + + + {/*@ts-ignore*/} + } leftType={"icon"} + {...inputs.getInputProps("email")}/> + + {/*@ts-ignore*/} + } leftType={"icon"} + {...inputs.getInputProps("username")}/> + + } leftType={"icon"} + onChange={() => validate("password")} + {...inputs.getInputProps("password")}/> + + } leftType={"icon"} + onChange={() => validate("repeatPassword")} + {...inputs.getInputProps("repeatPassword")}/> + + + + Admin + Grant this user administrator privileges. + + + + + + + + + + + + +
+} diff --git a/src/packages/ce/src/user/pages/UsersPage.tsx b/src/packages/ce/src/user/pages/UsersPage.tsx index 98c3d80e..1787d966 100644 --- a/src/packages/ce/src/user/pages/UsersPage.tsx +++ b/src/packages/ce/src/user/pages/UsersPage.tsx @@ -16,12 +16,15 @@ import { useStore } from "@code0-tech/pictor"; import {UserService} from "@edition/user/services/User.service"; +import Link from "next/link"; import {notFound} from "next/navigation"; import {UserDataTableComponent} from "@edition/user/components/UserDataTableComponent"; import {DataTableFilterProps, DataTableSortProps} from "@code0-tech/pictor/dist/components/data-table/DataTable"; import {UserDataTableFilterInputComponent} from "@edition/user/components/UserDataTableFilterInputComponent"; +import {UserEditDialogComponent} from "@edition/user/components/UserEditDialogComponent"; import {IconMinus, IconSortAscending, IconSortDescending} from "@tabler/icons-react"; import {useUserSession} from "@edition/user/hooks/User.session.hook"; +import {User} from "@code0-tech/sagittarius-graphql-types"; export const UsersPage: React.FC = () => { @@ -32,6 +35,7 @@ export const UsersPage: React.FC = () => { const [filter, setFilter] = React.useState({}) const [sort, setSort] = React.useState({}) + const [editUserId, setEditUserId] = React.useState(undefined) if (currentUser && !currentUser.admin) { notFound() @@ -54,7 +58,9 @@ export const UsersPage: React.FC = () => { - + + + @@ -124,6 +130,12 @@ export const UsersPage: React.FC = () => { setFilter(filter)}/> - + setEditUserId(item?.id)}/> + { + if (!open) setEditUserId(undefined) + }}/> } \ No newline at end of file diff --git a/src/packages/ce/src/user/services/User.service.ts b/src/packages/ce/src/user/services/User.service.ts index 58e817f1..f5bb9b39 100644 --- a/src/packages/ce/src/user/services/User.service.ts +++ b/src/packages/ce/src/user/services/User.service.ts @@ -3,6 +3,8 @@ import { Mutation, Query, User, + UsersCreateInput, + UsersCreatePayload, UsersEmailVerificationInput, UsersEmailVerificationPayload, UsersIdentityLinkInput, @@ -28,9 +30,13 @@ import { UsersPasswordResetRequestInput, UsersPasswordResetRequestPayload, UsersRegisterInput, - UsersRegisterPayload + UsersRegisterPayload, + UsersUpdateInput, + UsersUpdatePayload } from "@code0-tech/sagittarius-graphql-types"; import {GraphqlClient} from "@core/util/graphql-client"; +import createMutation from "./mutations/User.create.mutation.graphql"; +import updateMutation from "./mutations/User.update.mutation.graphql"; import loginMutation from "./mutations/User.login.mutation.graphql"; import logoutMutation from "./mutations/User.logout.mutation.graphql"; import registerMutation from "./mutations/User.register.mutation.graphql"; @@ -119,6 +125,40 @@ export class UserService extends ReactiveArrayService { return user !== undefined; } + async usersCreate(payload: UsersCreateInput): Promise { + const result = await this.client.mutate({ + mutation: createMutation, + variables: { + ...payload + } + }) + + if (result.data && result.data.usersCreate && result.data.usersCreate.user && !this.hasById(result.data.usersCreate.user.id)) { + this.add(new View(result.data.usersCreate.user)) + } + + return result.data?.usersCreate ?? undefined + } + + async usersUpdate(payload: UsersUpdateInput): Promise { + const result = await this.client.mutate({ + mutation: updateMutation, + variables: { + ...payload + } + }) + + if (result.data && result.data.usersUpdate && result.data.usersUpdate.user) { + //TODO: dont use the result. Instead merge result and already existing data together + const updatedUser = result.data.usersUpdate.user + const index = super.values().findIndex(user => user.id === updatedUser.id) + if (index >= 0) this.set(index, new View(updatedUser)) + else if (!this.hasById(updatedUser.id)) this.add(new View(updatedUser)) + } + + return result.data?.usersUpdate ?? undefined + } + async usersEmailVerification(payload: UsersEmailVerificationInput): Promise { const result = await this.client.mutate({ mutation: emailVerificationMutation, diff --git a/src/packages/ce/src/user/services/mutations/User.create.mutation.graphql b/src/packages/ce/src/user/services/mutations/User.create.mutation.graphql new file mode 100644 index 00000000..9430d027 --- /dev/null +++ b/src/packages/ce/src/user/services/mutations/User.create.mutation.graphql @@ -0,0 +1,24 @@ +#import "@edition/user/services/fragments/User.basic.fragment.graphql" +mutation create($email: String!, $password: String!, $passwordRepeat: String!, $username: String!, $firstname: String, $lastname: String, $admin: Boolean) { + usersCreate(input: { + email: $email + password: $password + passwordRepeat: $passwordRepeat + username: $username + firstname: $firstname + lastname: $lastname + admin: $admin + }) { + errors { + ...on Error { + errorCode, + details { + __typename + } + } + } + user { + ...UserBasic + } + } +} diff --git a/src/packages/ce/src/user/services/mutations/User.update.mutation.graphql b/src/packages/ce/src/user/services/mutations/User.update.mutation.graphql new file mode 100644 index 00000000..8bc7c994 --- /dev/null +++ b/src/packages/ce/src/user/services/mutations/User.update.mutation.graphql @@ -0,0 +1,26 @@ +#import "@edition/user/services/fragments/User.basic.fragment.graphql" +mutation update($userId: UserID!, $email: String, $username: String, $firstname: String, $lastname: String, $readme: String, $admin: Boolean, $password: String, $passwordRepeat: String) { + usersUpdate(input: { + userId: $userId + email: $email + username: $username + firstname: $firstname + lastname: $lastname + readme: $readme + admin: $admin + password: $password + passwordRepeat: $passwordRepeat + }) { + errors { + ...on Error { + errorCode, + details { + __typename + } + } + } + user { + ...UserBasic + } + } +}