From 4f8a5c87b7a35f82330800f59ef80c43c82aa215 Mon Sep 17 00:00:00 2001 From: "MSI\\2Fi" Date: Wed, 11 Jun 2025 12:13:40 +0800 Subject: [PATCH] create user,. edit user edit auths --- src/app/(main)/settings/user/create/page.tsx | 29 +++ src/app/(main)/settings/user/edit/page.tsx | 24 ++ src/app/(main)/settings/user/page.tsx | 44 +++- src/app/(main)/user/page.tsx | 3 - src/app/api/group/actions.ts | 55 ++++ src/app/api/group/index.ts | 34 +++ src/app/api/user/actions.ts | 72 ++++++ src/app/api/user/index.ts | 58 +++++ src/app/utils/fetchUtil.ts | 4 + src/components/CreateUser/AuthAllocation.tsx | 225 ++++++++++++++++ src/components/CreateUser/CreateUser.tsx | 243 +++++++++++++++++ .../CreateUser/CreateUserLoading.tsx | 40 +++ .../CreateUser/CreateUserWrapper.tsx | 25 ++ src/components/CreateUser/UserDetail.tsx | 104 ++++++++ src/components/CreateUser/index.ts | 1 + src/components/EditUser/AuthAllocation.tsx | 225 ++++++++++++++++ src/components/EditUser/EditUser.tsx | 244 ++++++++++++++++++ src/components/EditUser/EditUserLoading.tsx | 40 +++ src/components/EditUser/EditUserWrapper.tsx | 32 +++ src/components/EditUser/UserDetail.tsx | 104 ++++++++ src/components/EditUser/index.ts | 1 + src/components/Logo/Logo.tsx | 6 +- src/components/UserSearch/UserSearch.tsx | 15 +- src/i18n/zh/user.json | 18 ++ 24 files changed, 1621 insertions(+), 25 deletions(-) create mode 100644 src/app/(main)/settings/user/create/page.tsx create mode 100644 src/app/(main)/settings/user/edit/page.tsx create mode 100644 src/app/api/group/actions.ts create mode 100644 src/app/api/group/index.ts create mode 100644 src/app/api/user/actions.ts create mode 100644 src/app/api/user/index.ts create mode 100644 src/components/CreateUser/AuthAllocation.tsx create mode 100644 src/components/CreateUser/CreateUser.tsx create mode 100644 src/components/CreateUser/CreateUserLoading.tsx create mode 100644 src/components/CreateUser/CreateUserWrapper.tsx create mode 100644 src/components/CreateUser/UserDetail.tsx create mode 100644 src/components/CreateUser/index.ts create mode 100644 src/components/EditUser/AuthAllocation.tsx create mode 100644 src/components/EditUser/EditUser.tsx create mode 100644 src/components/EditUser/EditUserLoading.tsx create mode 100644 src/components/EditUser/EditUserWrapper.tsx create mode 100644 src/components/EditUser/UserDetail.tsx create mode 100644 src/components/EditUser/index.ts create mode 100644 src/i18n/zh/user.json diff --git a/src/app/(main)/settings/user/create/page.tsx b/src/app/(main)/settings/user/create/page.tsx new file mode 100644 index 0000000..a48bdec --- /dev/null +++ b/src/app/(main)/settings/user/create/page.tsx @@ -0,0 +1,29 @@ +// 'use client'; +import { I18nProvider, getServerI18n } from "@/i18n"; +import React, { Suspense, useCallback, useState } from "react"; +import { Typography } from "@mui/material"; +import CreateUser from "@/components/CreateUser"; + +interface CreateCustomInputs { + projectCode: string; + projectName: string; +} + +// const Title = ["title1", "title2"]; + +const CreateStaffPage: React.FC = async () => { + const { t } = await getServerI18n("user"); + + return ( + <> + {t("Create User")} + + }> + + + + + ); +}; + +export default CreateStaffPage; diff --git a/src/app/(main)/settings/user/edit/page.tsx b/src/app/(main)/settings/user/edit/page.tsx new file mode 100644 index 0000000..b45dc04 --- /dev/null +++ b/src/app/(main)/settings/user/edit/page.tsx @@ -0,0 +1,24 @@ +import { Edit } from "@mui/icons-material"; +import { Metadata } from "next"; +import { I18nProvider, getServerI18n } from "@/i18n"; +import EditUser from "@/components/EditUser"; +import { Typography } from "@mui/material"; +import { Suspense } from "react"; +import { preloadUser } from "@/app/api/user"; +import { searchParamsProps } from "@/app/utils/fetchUtil"; + +const User: React.FC = async ({ searchParams }) => { + const { t } = await getServerI18n("user"); + preloadUser(); + return ( + <> + {t("Edit User")} + + }> + + + + + ); +}; +export default User; diff --git a/src/app/(main)/settings/user/page.tsx b/src/app/(main)/settings/user/page.tsx index a7a9ef1..81424a1 100644 --- a/src/app/(main)/settings/user/page.tsx +++ b/src/app/(main)/settings/user/page.tsx @@ -1,19 +1,45 @@ import { Metadata } from "next"; -import { I18nProvider } from "@/i18n"; +import { getServerI18n, I18nProvider } from "@/i18n"; import Typography from "@mui/material/Typography"; +import { Suspense } from "react"; +import { Stack } from "@mui/material"; +import { Button } from "@mui/material"; +import Link from "next/link"; +import UserSearch from "@/components/UserSearch"; +import Add from "@mui/icons-material/Add"; export const metadata: Metadata = { - title: "Project Status by Client", + title: "User Management", }; -const User: React.FC = () => { +const User: React.FC = async() => { + const { t } = await getServerI18n("user"); return ( - - - User - - <> - + <> + + + {t("User")} + + + + + }> + + + + ); }; export default User; diff --git a/src/app/(main)/user/page.tsx b/src/app/(main)/user/page.tsx index 788fd9b..272d5a1 100644 --- a/src/app/(main)/user/page.tsx +++ b/src/app/(main)/user/page.tsx @@ -1,5 +1,3 @@ -import { preloadTaskTemplates } from "@/app/api/tasks"; -import TaskTemplateSearch from "@/components/TaskTemplateSearch"; import { getServerI18n } from "@/i18n"; import Add from "@mui/icons-material/Add"; import Button from "@mui/material/Button"; @@ -15,7 +13,6 @@ export const metadata: Metadata = { const TaskTemplates: React.FC = async () => { const { t } = await getServerI18n("user"); - preloadTaskTemplates(); return ( <> diff --git a/src/app/api/group/actions.ts b/src/app/api/group/actions.ts new file mode 100644 index 0000000..eadfe7d --- /dev/null +++ b/src/app/api/group/actions.ts @@ -0,0 +1,55 @@ +"use server"; + +import { serverFetchJson, serverFetchWithNoContent } from "@/app/utils/fetchUtil"; +import { BASE_API_URL } from "@/config/api"; +import { revalidateTag } from "next/cache"; +import { cache } from "react"; + + +export interface CreateGroupInputs { + id?: number; + name: string; + description: string; + addUserIds?: number[]; + removeUserIds?: number[]; + addAuthIds?: number[]; + removeAuthIds?: number[]; + } + +export interface auth { + id: number; + module?: any | null; + authority: string; + name: string; + description: string | null; + v: number; + } + +export interface record { + records: auth[]; + } + +export const fetchAuth = cache(async (target: string, id?: number ) => { + return serverFetchJson(`${BASE_API_URL}/group/auth/${target}/${id ?? 0}`, { + next: { tags: ["auth"] }, + }); +}); + +export const saveGroup = async (data: CreateGroupInputs) => { + const newGroup = serverFetchJson(`${BASE_API_URL}/group/save`, { + method: "POST", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }); + revalidateTag("group") + return newGroup + }; + +export const deleteGroup = async (id: number) => { + const newGroup = serverFetchWithNoContent(`${BASE_API_URL}/group/${id}`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + }); + revalidateTag("group") + return newGroup +}; \ No newline at end of file diff --git a/src/app/api/group/index.ts b/src/app/api/group/index.ts new file mode 100644 index 0000000..082b0f3 --- /dev/null +++ b/src/app/api/group/index.ts @@ -0,0 +1,34 @@ +import { serverFetchJson } from "@/app/utils/fetchUtil"; +import { BASE_API_URL } from "@/config/api"; +import { cache } from "react"; +import "server-only"; + +export interface Records { + records: UserGroupResult[] +} + +export interface UserGroupResult { + id: number; + action: () => void; + name: string; + description: string; +} + +export type IndivUserGroup = { + authIds: number[]; + data: any; + userIds: number[]; +} + +export const fetchGroup = cache(async () => { + return serverFetchJson(`${BASE_API_URL}/group`, { + next: { tags: ["group"] }, + }); + }); + + +export const fetchIndivGroup = cache(async (id: number) => { + return serverFetchJson(`${BASE_API_URL}/group/${id}`, { + next: { tags: ["group"] }, + }); + }); diff --git a/src/app/api/user/actions.ts b/src/app/api/user/actions.ts new file mode 100644 index 0000000..759bef5 --- /dev/null +++ b/src/app/api/user/actions.ts @@ -0,0 +1,72 @@ +"use server"; + +import { serverFetchJson, serverFetchWithNoContent } from "@/app/utils/fetchUtil"; +import { BASE_API_URL } from "@/config/api"; +import { revalidateTag } from "next/cache"; +import { UserDetail, UserResult } from "."; +import { cache } from "react"; + +export interface UserInputs { + username: string; + // name: string; + addAuthIds?: number[]; + removeAuthIds?: number[]; + password?: string; +} + +export interface PasswordInputs { + password: string; + newPassword: string; + newPasswordCheck: string; +} + +export const fetchUserDetails = cache(async (id: number) => { + return serverFetchJson(`${BASE_API_URL}/user/${id}`, { + next: { tags: ["user"] }, + }); + }); + +export const editUser = async (id: number, data: UserInputs) => { + const newUser = serverFetchWithNoContent(`${BASE_API_URL}/user/${id}`, { + method: "PUT", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }); + revalidateTag("user") + return newUser +}; + +export const createUser = async (data: UserInputs) => { + const newUser = serverFetchWithNoContent(`${BASE_API_URL}/user/save`, { + method: "POST", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }); + revalidateTag("user") + return newUser +}; + +export const deleteUser = async (id: number) => { + const newUser = serverFetchWithNoContent(`${BASE_API_URL}/user/${id}`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + }); + revalidateTag("user") + return newUser + }; + +export const changePassword = async (data: any) => { + return serverFetchWithNoContent(`${BASE_API_URL}/user/change-password`, { + method: "PATCH", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }); + }; + +export const adminChangePassword = async (data: any) => { + return serverFetchWithNoContent(`${BASE_API_URL}/user/admin-change-password`, { + method: "PATCH", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }); + }; \ No newline at end of file diff --git a/src/app/api/user/index.ts b/src/app/api/user/index.ts new file mode 100644 index 0000000..79fc62b --- /dev/null +++ b/src/app/api/user/index.ts @@ -0,0 +1,58 @@ +import { serverFetchJson } from "@/app/utils/fetchUtil"; +import { BASE_API_URL } from "@/config/api"; +import { cache } from "react"; +import "server-only"; + +export interface UserResult { + action: any; + id: number; + username: string; + // name: string; + } + +// export interface DetailedUser extends UserResult { +// username: string; +// password: string +// } + +export interface UserDetail { + data: UserResult; + authIds: number[]; + groupIds: number[]; + auths: any[] + } + + export type passwordRule = { + min: number; + max: number; + number: boolean; + upperEng: boolean; + lowerEng: boolean; + specialChar: boolean; + } + + export const preloadUser = () => { + fetchUser(); + }; + + export const preloadUserDetail = (id: number) => { + fetchUserDetail(id); + }; + + export const fetchUser = cache(async () => { + return serverFetchJson(`${BASE_API_URL}/user`, { + next: { tags: ["user"] }, + }); + }); + + export const fetchUserDetail = cache(async (id: number) => { + return serverFetchJson(`${BASE_API_URL}/user/${id}`, { + next: { tags: ["user"] }, + }); + }); + + export const fetchPwRules = cache(async () => { + return serverFetchJson(`${BASE_API_URL}/user/password-rule`, { + next: { tags: ["pwRule"] }, + }); + }); \ No newline at end of file diff --git a/src/app/utils/fetchUtil.ts b/src/app/utils/fetchUtil.ts index 92a2127..053957a 100644 --- a/src/app/utils/fetchUtil.ts +++ b/src/app/utils/fetchUtil.ts @@ -7,6 +7,10 @@ export type SearchParams = { searchParams: { [key: string]: string | string[] | undefined }; } +export interface searchParamsProps { + searchParams: { [key: string]: string | string[] | undefined }; +} + export class ServerFetchError extends Error { public readonly response: Response | undefined; constructor(message?: string, response?: Response) { diff --git a/src/components/CreateUser/AuthAllocation.tsx b/src/components/CreateUser/AuthAllocation.tsx new file mode 100644 index 0000000..fe6c5a9 --- /dev/null +++ b/src/components/CreateUser/AuthAllocation.tsx @@ -0,0 +1,225 @@ +"use client"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { + Add, + Clear, + PersonAdd, + PersonRemove, + Remove, + Search, +} from "@mui/icons-material"; +import { useTranslation } from "react-i18next"; +import { + FieldErrors, + FormProvider, + SubmitErrorHandler, + SubmitHandler, + useForm, + useFormContext, +} from "react-hook-form"; +import { + Box, + Card, + CardContent, + Grid, + IconButton, + InputAdornment, + Stack, + Tab, + Tabs, + TabsProps, + TextField, + Typography, +} from "@mui/material"; +import { differenceBy } from "lodash"; +import { UserInputs } from "@/app/api/user/actions"; +import { auth } from "@/app/api/group/actions"; +import SearchResults, { Column } from "../SearchResults"; + +export interface Props { + auths: auth[]; +} + +const AuthAllocation: React.FC = ({ auths }) => { + const { t } = useTranslation(); + const searchParams = useSearchParams(); + const id = parseInt(searchParams.get("id") || "0"); + const { + setValue, + getValues, + formState: { defaultValues }, + reset, + resetField, + } = useFormContext(); + const initialAuths = auths.map((u) => ({ ...u })).sort((a, b) => a.id - b.id); + const [filteredAuths, setFilteredAuths] = useState(initialAuths); + const [selectedAuths, setSelectedAuths] = useState( + () => { + return filteredAuths.filter( + (s) => getValues("addAuthIds")?.includes(s.id) + ); + } + ); + const [removeAuthIds, setRemoveAuthIds] = useState([]); + + // Adding / Removing Auth + const addAuth = useCallback((auth: auth) => { + setSelectedAuths((a) => [...a, auth]); + }, []); + const removeAuth = useCallback((auth: auth) => { + setSelectedAuths((a) => a.filter((a) => a.id !== auth.id)); + setRemoveAuthIds((prevIds) => [...prevIds, auth.id]); + }, []); + + const clearAuth = useCallback(() => { + if (defaultValues !== undefined) { + resetField("addAuthIds"); + setSelectedAuths( + initialAuths.filter( + (auth) => defaultValues.addAuthIds?.includes(auth.id) + ) + ); + } + }, [defaultValues]); + + // Sync with form + useEffect(() => { + setValue( + "addAuthIds", + selectedAuths.map((a) => a.id) + ); + setValue("removeAuthIds", removeAuthIds); + }, [selectedAuths, removeAuthIds, setValue]); + + const AuthPoolColumns = useMemo[]>( + () => [ + { + label: t("Add"), + name: "id", + onClick: addAuth, + buttonIcon: , + }, + { label: t("authority"), name: "authority" }, + { label: t("description"), name: "name" }, + ], + [addAuth, t] + ); + + const allocatedAuthColumns = useMemo[]>( + () => [ + { + label: t("Remove"), + name: "id", + onClick: removeAuth, + buttonIcon: , + }, + { label: t("authority"), name: "authority" }, + { label: t("description"), name: "name" }, + ], + [removeAuth, selectedAuths, t] + ); + + const [query, setQuery] = React.useState(""); + const onQueryInputChange = React.useCallback< + React.ChangeEventHandler + >((e) => { + setQuery(e.target.value); + }, []); + const clearQueryInput = React.useCallback(() => { + setQuery(""); + }, []); + + React.useEffect(() => { + setFilteredAuths( + initialAuths.filter((a) => + ( + a.authority.toLowerCase().includes(query.toLowerCase()) || + a.name?.toLowerCase().includes(query.toLowerCase()) + ) + ) + ); + }, [auths, query]); + + const resetAuth = React.useCallback(() => { + clearQueryInput(); + clearAuth(); + }, [clearQueryInput, clearAuth]); + + const formProps = useForm({}); + + // Tab related + const [tabIndex, setTabIndex] = React.useState(0); + const handleTabChange = React.useCallback>( + (_e, newValue) => { + setTabIndex(newValue); + }, + [] + ); + + return ( + <> + + + + + + {t("Authority")} + + + + + + + + + + ), + }} + /> + + + + + + + + {tabIndex === 0 && ( + + )} + {tabIndex === 1 && ( + + )} + + + + + + + ); +}; +export default AuthAllocation; diff --git a/src/components/CreateUser/CreateUser.tsx b/src/components/CreateUser/CreateUser.tsx new file mode 100644 index 0000000..8ec54dd --- /dev/null +++ b/src/components/CreateUser/CreateUser.tsx @@ -0,0 +1,243 @@ +"use client"; +import { useRouter, useSearchParams } from "next/navigation"; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useState, +} from "react"; +// import { TeamResult } from "@/app/api/team"; +import { useTranslation } from "react-i18next"; +import { + Button, + Card, + CardContent, + Grid, + Stack, + Tab, + Tabs, + TabsProps, + TextField, + Typography, +} from "@mui/material"; +import { + FieldErrors, + FormProvider, + SubmitErrorHandler, + SubmitHandler, + useForm, + useFormContext, +} from "react-hook-form"; +import { Check, Close, Error, RestartAlt } from "@mui/icons-material"; +import { + UserInputs, + adminChangePassword, + fetchUserDetails, + createUser, +} from "@/app/api/user/actions"; +import UserDetail from "./UserDetail"; +import { UserResult, passwordRule } from "@/app/api/user"; +import { auth } from "@/app/api/group/actions"; +import AuthAllocation from "./AuthAllocation"; + +interface Props { + rules: passwordRule; + auths: auth[]; +} + +const CreateUser: React.FC = async ({ rules, auths }) => { + console.log(auths) + const { t } = useTranslation("user"); + const formProps = useForm(); + const searchParams = useSearchParams(); + const id = parseInt(searchParams.get("id") || "0"); + const [tabIndex, setTabIndex] = useState(0); + const router = useRouter(); + const [serverError, setServerError] = useState(""); + const addAuthIds = + auths && auths.length > 0 + ? auths + .filter((item) => item.v === 1) + .map((item) => item.id) + .sort((a, b) => a - b) + : []; + + const handleTabChange = useCallback>( + (_e, newValue) => { + setTabIndex(newValue); + }, + [] + ); + + const errors = formProps.formState.errors; + + const resetForm = React.useCallback(() => { + console.log("triggerred"); + console.log(addAuthIds); + try { + formProps.reset({ + username: "", + addAuthIds: addAuthIds, + removeAuthIds: [], + password: "", + }); + console.log(formProps.formState.defaultValues); + } catch (error) { + console.log(error); + setServerError(t("An error has occurred. Please try again later.")); + } + }, [auths]); + + useEffect(() => { + resetForm(); + }, []); + + const hasErrorsInTab = ( + tabIndex: number, + errors: FieldErrors + ) => { + switch (tabIndex) { + case 0: + return Object.keys(errors).length > 0; + default: + false; + } + }; + + const handleCancel = () => { + router.back(); + }; + + const onSubmit = useCallback>( + async (data) => { + try { + let haveError = false; + let regex_pw = + /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*])[A-Za-z\d!@#$%^&*]{8,20}$/; + let pw = ""; + if (data.password && data.password.length > 0) { + pw = data.password; + if (pw.length < rules.min) { + haveError = true; + formProps.setError("password", { + message: t("The password requires 8-20 characters."), + type: "required", + }); + } + if (pw.length > rules.max) { + haveError = true; + formProps.setError("password", { + message: t("The password requires 8-20 characters."), + type: "required", + }); + } + if (!regex_pw.test(pw)) { + haveError = true; + formProps.setError("password", { + message: + "A combination of uppercase letters, lowercase letters, numbers, and symbols is required.", + type: "required", + }); + } + } + const userData = { + username: data.username, + // name: data.name, + locked: false, + addAuthIds: data.addAuthIds || [], + removeAuthIds: data.removeAuthIds || [], + password: pw, + }; + const pwData = { + id: id, + password: pw, + newPassword: "", + }; + if (haveError) { + return; + } + console.log("passed"); + console.log(userData) + await createUser(userData); + // if (data.password && data.password.length > 0) { + // await adminChangePassword(pwData); + // } + router.replace("/settings/user"); + } catch (e) { + console.log(e); + setServerError(t("An error has occurred. Please try again later.")); + } + }, + [router] + ); + const onSubmitError = useCallback>( + (errors) => { + console.log(errors); + }, + [] + ); + + return ( + <> + {serverError && ( + + {serverError} + + )} + + + + + + ) : undefined + } + iconPosition="end" + /> + + + + {tabIndex == 0 && } + {tabIndex === 1 && } + + + + + + + + + ); +}; +export default CreateUser; diff --git a/src/components/CreateUser/CreateUserLoading.tsx b/src/components/CreateUser/CreateUserLoading.tsx new file mode 100644 index 0000000..6ff6088 --- /dev/null +++ b/src/components/CreateUser/CreateUserLoading.tsx @@ -0,0 +1,40 @@ +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Skeleton from "@mui/material/Skeleton"; +import Stack from "@mui/material/Stack"; +import React from "react"; + +// Can make this nicer +export const CreateUserLoading: React.FC = () => { + return ( + <> + + + + + + + + + + + CreateUser + + + + + + + + + + + ); +}; + +export default CreateUserLoading; diff --git a/src/components/CreateUser/CreateUserWrapper.tsx b/src/components/CreateUser/CreateUserWrapper.tsx new file mode 100644 index 0000000..76d341d --- /dev/null +++ b/src/components/CreateUser/CreateUserWrapper.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import CreateUser from "./CreateUser"; +import CreateUserLoading from "./CreateUserLoading"; +import { searchParamsProps } from "@/app/utils/fetchUtil"; +import { fetchPwRules, fetchUser, fetchUserDetail } from "@/app/api/user"; +import { fetchUserDetails } from "@/app/api/user/actions"; +import { fetchAuth } from "@/app/api/group/actions"; + +interface SubComponents { + Loading: typeof CreateUserLoading; +} + +const CreateUserWrapper: React.FC & SubComponents = async ({ +}) => { + const [pwRule, auths] = await Promise.all([ + fetchPwRules(), + fetchAuth("user"), + ]); + + return ; +}; + +CreateUserWrapper.Loading = CreateUserLoading; + +export default CreateUserWrapper; diff --git a/src/components/CreateUser/UserDetail.tsx b/src/components/CreateUser/UserDetail.tsx new file mode 100644 index 0000000..df1581a --- /dev/null +++ b/src/components/CreateUser/UserDetail.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { UserResult } from "@/app/api/user"; +import { UserInputs } from "@/app/api/user/actions"; +import { + Card, + CardContent, + Grid, + Stack, + TextField, + Typography, + makeStyles, +} from "@mui/material"; +import { useFormContext } from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +const UserDetail: React.FC = () => { + + const { t } = useTranslation(); + const { + register, + formState: { errors }, + control, + } = useFormContext(); + + return ( + + + + {t("User Detail")} + + + + + + + + // - 8-20 characters + //
+ // - Uppercase letters + //
+ // - Lowercase letters + //
+ // - Numbers + //
+ // - Symbols + // ) + // ) + // } + helperText={ + Boolean(errors.password) && + (errors.password?.message + ? t(errors.password.message) + : t("Please input correct password")) + } + error={Boolean(errors.password)} + /> +
+ {/* + + */} +
+
+
+ ); +}; + +export default UserDetail; + + +{/* <> + - 8-20 characters +
+ - Uppercase letters +
+ - Lowercase letters +
+ - Numbers +
+ - Symbols + */} \ No newline at end of file diff --git a/src/components/CreateUser/index.ts b/src/components/CreateUser/index.ts new file mode 100644 index 0000000..502645d --- /dev/null +++ b/src/components/CreateUser/index.ts @@ -0,0 +1 @@ +export { default } from "./CreateUserWrapper"; diff --git a/src/components/EditUser/AuthAllocation.tsx b/src/components/EditUser/AuthAllocation.tsx new file mode 100644 index 0000000..fe6c5a9 --- /dev/null +++ b/src/components/EditUser/AuthAllocation.tsx @@ -0,0 +1,225 @@ +"use client"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { + Add, + Clear, + PersonAdd, + PersonRemove, + Remove, + Search, +} from "@mui/icons-material"; +import { useTranslation } from "react-i18next"; +import { + FieldErrors, + FormProvider, + SubmitErrorHandler, + SubmitHandler, + useForm, + useFormContext, +} from "react-hook-form"; +import { + Box, + Card, + CardContent, + Grid, + IconButton, + InputAdornment, + Stack, + Tab, + Tabs, + TabsProps, + TextField, + Typography, +} from "@mui/material"; +import { differenceBy } from "lodash"; +import { UserInputs } from "@/app/api/user/actions"; +import { auth } from "@/app/api/group/actions"; +import SearchResults, { Column } from "../SearchResults"; + +export interface Props { + auths: auth[]; +} + +const AuthAllocation: React.FC = ({ auths }) => { + const { t } = useTranslation(); + const searchParams = useSearchParams(); + const id = parseInt(searchParams.get("id") || "0"); + const { + setValue, + getValues, + formState: { defaultValues }, + reset, + resetField, + } = useFormContext(); + const initialAuths = auths.map((u) => ({ ...u })).sort((a, b) => a.id - b.id); + const [filteredAuths, setFilteredAuths] = useState(initialAuths); + const [selectedAuths, setSelectedAuths] = useState( + () => { + return filteredAuths.filter( + (s) => getValues("addAuthIds")?.includes(s.id) + ); + } + ); + const [removeAuthIds, setRemoveAuthIds] = useState([]); + + // Adding / Removing Auth + const addAuth = useCallback((auth: auth) => { + setSelectedAuths((a) => [...a, auth]); + }, []); + const removeAuth = useCallback((auth: auth) => { + setSelectedAuths((a) => a.filter((a) => a.id !== auth.id)); + setRemoveAuthIds((prevIds) => [...prevIds, auth.id]); + }, []); + + const clearAuth = useCallback(() => { + if (defaultValues !== undefined) { + resetField("addAuthIds"); + setSelectedAuths( + initialAuths.filter( + (auth) => defaultValues.addAuthIds?.includes(auth.id) + ) + ); + } + }, [defaultValues]); + + // Sync with form + useEffect(() => { + setValue( + "addAuthIds", + selectedAuths.map((a) => a.id) + ); + setValue("removeAuthIds", removeAuthIds); + }, [selectedAuths, removeAuthIds, setValue]); + + const AuthPoolColumns = useMemo[]>( + () => [ + { + label: t("Add"), + name: "id", + onClick: addAuth, + buttonIcon: , + }, + { label: t("authority"), name: "authority" }, + { label: t("description"), name: "name" }, + ], + [addAuth, t] + ); + + const allocatedAuthColumns = useMemo[]>( + () => [ + { + label: t("Remove"), + name: "id", + onClick: removeAuth, + buttonIcon: , + }, + { label: t("authority"), name: "authority" }, + { label: t("description"), name: "name" }, + ], + [removeAuth, selectedAuths, t] + ); + + const [query, setQuery] = React.useState(""); + const onQueryInputChange = React.useCallback< + React.ChangeEventHandler + >((e) => { + setQuery(e.target.value); + }, []); + const clearQueryInput = React.useCallback(() => { + setQuery(""); + }, []); + + React.useEffect(() => { + setFilteredAuths( + initialAuths.filter((a) => + ( + a.authority.toLowerCase().includes(query.toLowerCase()) || + a.name?.toLowerCase().includes(query.toLowerCase()) + ) + ) + ); + }, [auths, query]); + + const resetAuth = React.useCallback(() => { + clearQueryInput(); + clearAuth(); + }, [clearQueryInput, clearAuth]); + + const formProps = useForm({}); + + // Tab related + const [tabIndex, setTabIndex] = React.useState(0); + const handleTabChange = React.useCallback>( + (_e, newValue) => { + setTabIndex(newValue); + }, + [] + ); + + return ( + <> + + + + + + {t("Authority")} + + + + + + + + + + ), + }} + /> + + + + + + + + {tabIndex === 0 && ( + + )} + {tabIndex === 1 && ( + + )} + + + + + + + ); +}; +export default AuthAllocation; diff --git a/src/components/EditUser/EditUser.tsx b/src/components/EditUser/EditUser.tsx new file mode 100644 index 0000000..27f6609 --- /dev/null +++ b/src/components/EditUser/EditUser.tsx @@ -0,0 +1,244 @@ +"use client"; +import { useRouter, useSearchParams } from "next/navigation"; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useState, +} from "react"; +// import { TeamResult } from "@/app/api/team"; +import { useTranslation } from "react-i18next"; +import { + Button, + Card, + CardContent, + Grid, + Stack, + Tab, + Tabs, + TabsProps, + TextField, + Typography, +} from "@mui/material"; +import { + FieldErrors, + FormProvider, + SubmitErrorHandler, + SubmitHandler, + useForm, + useFormContext, +} from "react-hook-form"; +import { Check, Close, Error, RestartAlt } from "@mui/icons-material"; +import { + UserInputs, + adminChangePassword, + editUser, + fetchUserDetails, +} from "@/app/api/user/actions"; +import UserDetail from "./UserDetail"; +import { UserResult, passwordRule } from "@/app/api/user"; +import { auth } from "@/app/api/group/actions"; +import AuthAllocation from "./AuthAllocation"; + +interface Props { + user: UserResult; + rules: passwordRule; + auths: auth[]; +} + +const EditUser: React.FC = async ({ user, rules, auths }) => { + console.log(user) + const { t } = useTranslation("user"); + const formProps = useForm(); + const searchParams = useSearchParams(); + const id = parseInt(searchParams.get("id") || "0"); + const [tabIndex, setTabIndex] = useState(0); + const router = useRouter(); + const [serverError, setServerError] = useState(""); + const addAuthIds = + auths && auths.length > 0 + ? auths + .filter((item) => item.v === 1) + .map((item) => item.id) + .sort((a, b) => a - b) + : []; + + const handleTabChange = useCallback>( + (_e, newValue) => { + setTabIndex(newValue); + }, + [] + ); + + const errors = formProps.formState.errors; + + const resetForm = React.useCallback(() => { + console.log("triggerred"); + console.log(addAuthIds); + try { + formProps.reset({ + username: user.username, + // name: user.name, + // email: user.email, + addAuthIds: addAuthIds, + removeAuthIds: [], + password: "", + }); + console.log(formProps.formState.defaultValues); + } catch (error) { + console.log(error); + setServerError(t("An error has occurred. Please try again later.")); + } + }, [auths, user]); + + useEffect(() => { + resetForm(); + }, []); + + const hasErrorsInTab = ( + tabIndex: number, + errors: FieldErrors + ) => { + switch (tabIndex) { + case 0: + return Object.keys(errors).length > 0; + default: + false; + } + }; + + const handleCancel = () => { + router.back(); + }; + + const onSubmit = useCallback>( + async (data) => { + try { + let haveError = false; + let regex_pw = + /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*])[A-Za-z\d!@#$%^&*]{8,20}$/; + let pw = ""; + if (data.password && data.password.length > 0) { + pw = data.password; + if (pw.length < rules.min) { + haveError = true; + formProps.setError("password", { + message: t("The password requires 8-20 characters."), + type: "required", + }); + } + if (pw.length > rules.max) { + haveError = true; + formProps.setError("password", { + message: t("The password requires 8-20 characters."), + type: "required", + }); + } + if (!regex_pw.test(pw)) { + haveError = true; + formProps.setError("password", { + message: + "A combination of uppercase letters, lowercase letters, numbers, and symbols is required.", + type: "required", + }); + } + } + const userData = { + username: data.username, + // name: user.name, + locked: false, + addAuthIds: data.addAuthIds || [], + removeAuthIds: data.removeAuthIds || [], + }; + const pwData = { + id: id, + password: "", + newPassword: pw, + }; + if (haveError) { + return; + } + console.log("passed"); + await editUser(id, userData); + if (data.password && data.password.length > 0) { + await adminChangePassword(pwData); + } + router.replace("/settings/user"); + } catch (e) { + console.log(e); + setServerError(t("An error has occurred. Please try again later.")); + } + }, + [router] + ); + const onSubmitError = useCallback>( + (errors) => { + console.log(errors); + }, + [] + ); + + return ( + <> + {serverError && ( + + {serverError} + + )} + + + + + + ) : undefined + } + iconPosition="end" + /> + + + + {tabIndex == 0 && } + {tabIndex === 1 && } + + + + + + + + + ); +}; +export default EditUser; diff --git a/src/components/EditUser/EditUserLoading.tsx b/src/components/EditUser/EditUserLoading.tsx new file mode 100644 index 0000000..971c9e4 --- /dev/null +++ b/src/components/EditUser/EditUserLoading.tsx @@ -0,0 +1,40 @@ +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import Skeleton from "@mui/material/Skeleton"; +import Stack from "@mui/material/Stack"; +import React from "react"; + +// Can make this nicer +export const EditUserLoading: React.FC = () => { + return ( + <> + + + + + + + + + + + EditUser + + + + + + + + + + + ); +}; + +export default EditUserLoading; diff --git a/src/components/EditUser/EditUserWrapper.tsx b/src/components/EditUser/EditUserWrapper.tsx new file mode 100644 index 0000000..dc0743b --- /dev/null +++ b/src/components/EditUser/EditUserWrapper.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import EditUser from "./EditUser"; +import EditUserLoading from "./EditUserLoading"; +// import { fetchTeam, fetchTeamLeads } from "@/app/api/Team"; +import { useSearchParams } from "next/navigation"; +import { fetchPwRules, fetchUser, fetchUserDetail } from "@/app/api/user"; +import { searchParamsProps } from "@/app/utils/fetchUtil"; +import { fetchUserDetails } from "@/app/api/user/actions"; +import { fetchAuth } from "@/app/api/group/actions"; + +interface SubComponents { + Loading: typeof EditUserLoading; +} + +const EditUserWrapper: React.FC & SubComponents = async ({ + searchParams, +}) => { + const id = parseInt(searchParams.id as string); + const [pwRule, user, auths] = await Promise.all([ + fetchPwRules(), + fetchUserDetails(id), + fetchAuth("user", id), + ]); + console.log(user.data) + console.log(auths.records) + + return ; +}; + +EditUserWrapper.Loading = EditUserLoading; + +export default EditUserWrapper; diff --git a/src/components/EditUser/UserDetail.tsx b/src/components/EditUser/UserDetail.tsx new file mode 100644 index 0000000..df1581a --- /dev/null +++ b/src/components/EditUser/UserDetail.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { UserResult } from "@/app/api/user"; +import { UserInputs } from "@/app/api/user/actions"; +import { + Card, + CardContent, + Grid, + Stack, + TextField, + Typography, + makeStyles, +} from "@mui/material"; +import { useFormContext } from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +const UserDetail: React.FC = () => { + + const { t } = useTranslation(); + const { + register, + formState: { errors }, + control, + } = useFormContext(); + + return ( + + + + {t("User Detail")} + + + + + + + + // - 8-20 characters + //
+ // - Uppercase letters + //
+ // - Lowercase letters + //
+ // - Numbers + //
+ // - Symbols + // ) + // ) + // } + helperText={ + Boolean(errors.password) && + (errors.password?.message + ? t(errors.password.message) + : t("Please input correct password")) + } + error={Boolean(errors.password)} + /> +
+ {/* + + */} +
+
+
+ ); +}; + +export default UserDetail; + + +{/* <> + - 8-20 characters +
+ - Uppercase letters +
+ - Lowercase letters +
+ - Numbers +
+ - Symbols + */} \ No newline at end of file diff --git a/src/components/EditUser/index.ts b/src/components/EditUser/index.ts new file mode 100644 index 0000000..c12dc8e --- /dev/null +++ b/src/components/EditUser/index.ts @@ -0,0 +1 @@ +export { default } from "./EditUserWrapper"; diff --git a/src/components/Logo/Logo.tsx b/src/components/Logo/Logo.tsx index 59b48d9..28b00fd 100644 --- a/src/components/Logo/Logo.tsx +++ b/src/components/Logo/Logo.tsx @@ -21,10 +21,8 @@ const Logo: React.FC = ({ width, height }) => { fill="#000" // style="stroke:#000;stroke-width:0.25mm;fill:#000" > - + ); diff --git a/src/components/UserSearch/UserSearch.tsx b/src/components/UserSearch/UserSearch.tsx index b7ac669..60e1976 100644 --- a/src/components/UserSearch/UserSearch.tsx +++ b/src/components/UserSearch/UserSearch.tsx @@ -10,10 +10,12 @@ import { useRouter } from "next/navigation"; import { deleteDialog, successDialog } from "../Swal/CustomAlerts"; import { UserResult } from "@/app/api/user"; import { deleteUser } from "@/app/api/user/actions"; +import UserSearchLoading from "./UserSearchLoading"; interface Props { users: UserResult[]; } + type SearchQuery = Partial>; type SearchParamNames = keyof SearchQuery; @@ -25,8 +27,8 @@ const UserSearch: React.FC = ({ users }) => { const searchCriteria: Criterion[] = useMemo( () => [ { - label: t("User Name"), - paramName: "title", + label: t("Username"), + paramName: "username", type: "text", }, ], @@ -56,12 +58,7 @@ const UserSearch: React.FC = ({ users }) => { onClick: onUserClick, buttonIcon: , }, - { name: "name", label: t("UserName") }, - { name: "fullName", label: t("FullName") }, - { name: "title", label: t("Title") }, - { name: "department", label: t("Department") }, - { name: "email", label: t("Email") }, - { name: "phone1", label: t("Phone") }, + { name: "username", label: t("Username") }, { name: "action", label: t("Delete"), @@ -88,7 +85,7 @@ const UserSearch: React.FC = ({ users }) => { // ) }} /> - items={filteredUser} columns={columns} /> + items={filteredUser} columns={columns} pagingController={{ pageNum: 1, pageSize: 10, totalCount: 100 }}/> ); }; diff --git a/src/i18n/zh/user.json b/src/i18n/zh/user.json new file mode 100644 index 0000000..2548c90 --- /dev/null +++ b/src/i18n/zh/user.json @@ -0,0 +1,18 @@ +{ + "Create User": "新增用戶", + "User Detail": "用戶詳細資料", + "User Authority": "用戶權限", + "Authority Pool": "權限池", + "Allocated Authority": "已分配權限", + "username": "用戶名稱", + "password": "密碼", + "Confirm Password": "確認密碼", + "Reset": "重置", + "Cancel": "取消", + "Confirm": "確認", + "name": "姓名", + "User ID": "用戶ID", + "User Name": "用戶名稱", + "User Group": "用戶群組", + "Authority": "權限" +} \ No newline at end of file