@@ -11,6 +11,7 @@ export interface UserInputs { | |||
email?: string; | |||
addAuthIds?: number[]; | |||
removeAuthIds?: number[]; | |||
password?: string; | |||
} | |||
export interface PasswordInputs { | |||
@@ -50,4 +51,12 @@ export const changePassword = async (data: any) => { | |||
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" }, | |||
}); | |||
}; |
@@ -34,6 +34,15 @@ export interface UserDetail { | |||
auths: any[] | |||
} | |||
export type passwordRule = { | |||
min: number; | |||
max: number; | |||
number: boolean; | |||
upperEng: boolean; | |||
lowerEng: boolean; | |||
specialChar: boolean; | |||
} | |||
export const preloadUser = () => { | |||
fetchUser(); | |||
}; | |||
@@ -52,4 +61,10 @@ export interface UserDetail { | |||
return serverFetchJson<UserResult[]>(`${BASE_API_URL}/user/${id}`, { | |||
next: { tags: ["user"] }, | |||
}); | |||
}); | |||
export const fetchPwRules = cache(async () => { | |||
return serverFetchJson<passwordRule>(`${BASE_API_URL}/user/password-rule`, { | |||
next: { tags: ["pwRule"] }, | |||
}); | |||
}); |
@@ -26,17 +26,19 @@ import { | |||
} from "react-hook-form"; | |||
import { Check, Close, Error, RestartAlt } from "@mui/icons-material"; | |||
import { StaffResult } from "@/app/api/staff"; | |||
import { UserInputs, editUser, fetchUserDetails } from "@/app/api/user/actions"; | |||
import { UserInputs, adminChangePassword, editUser, fetchUserDetails } from "@/app/api/user/actions"; | |||
import UserDetail from "./UserDetail"; | |||
import { UserResult } from "@/app/api/user"; | |||
import { UserResult, passwordRule } from "@/app/api/user"; | |||
import { auth, fetchAuth } from "@/app/api/group/actions"; | |||
import AuthAllocation from "./AuthAllocation"; | |||
interface Props { | |||
// users: UserResult[] | |||
} | |||
rules: passwordRule | |||
} | |||
const EditUser: React.FC<Props> = async ({ }) => { | |||
const EditUser: React.FC<Props> = async ({ | |||
rules | |||
}) => { | |||
const { t } = useTranslation(); | |||
const formProps = useForm<UserInputs>(); | |||
const searchParams = useSearchParams(); | |||
@@ -53,11 +55,11 @@ const EditUser: React.FC<Props> = async ({ }) => { | |||
}, | |||
[] | |||
); | |||
console.log(rules); | |||
const errors = formProps.formState.errors; | |||
const fetchUserDetail = async () => { | |||
console.log(id); | |||
try { | |||
// fetch user info | |||
const userDetail = await fetchUserDetails(id); | |||
@@ -112,16 +114,45 @@ const EditUser: React.FC<Props> = async ({ }) => { | |||
const onSubmit = useCallback<SubmitHandler<UserInputs>>( | |||
async (data) => { | |||
try { | |||
let haveError = false | |||
let regex_pw = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/ | |||
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" }) | |||
} | |||
} | |||
console.log(data); | |||
const tempData = { | |||
const userData = { | |||
name: data.name, | |||
email: data.email, | |||
email: '', | |||
locked: false, | |||
addAuthIds: data.addAuthIds || [], | |||
removeAuthIds: data.removeAuthIds || [], | |||
} | |||
console.log(tempData); | |||
await editUser(id, tempData); | |||
const pwData = { | |||
id: id, | |||
password: "", | |||
newPassword: pw | |||
} | |||
console.log(userData); | |||
if (haveError) { | |||
return | |||
} | |||
await editUser(id, userData); | |||
if (data.password && data.password.length > 0) { | |||
await adminChangePassword(pwData); | |||
} | |||
router.replace("/settings/staff"); | |||
} catch (e) { | |||
console.log(e); | |||
@@ -5,7 +5,7 @@ import EditUserLoading from "./EditUserLoading"; | |||
import { useSearchParams } from "next/navigation"; | |||
import { fetchTeam, fetchTeamDetail } from "@/app/api/team"; | |||
import { fetchStaff } from "@/app/api/staff"; | |||
import { fetchUser, fetchUserDetail } from "@/app/api/user"; | |||
import { fetchPwRules, fetchUser, fetchUserDetail } from "@/app/api/user"; | |||
interface SubComponents { | |||
Loading: typeof EditUserLoading; | |||
@@ -17,10 +17,9 @@ interface Props { | |||
const EditUserWrapper: React.FC<Props> & SubComponents = async ({ | |||
// id | |||
}) => { | |||
// const users = await fetchUser() | |||
// const userDetail = await fetchUserDetail(id) | |||
const pwRule = await fetchPwRules() | |||
return <EditUser /> | |||
return <EditUser rules={pwRule} /> | |||
}; | |||
EditUserWrapper.Loading = EditUserLoading; | |||
@@ -47,12 +47,10 @@ const UserDetail: React.FC<Props> = ({ | |||
</Grid> | |||
<Grid item xs={6}> | |||
<TextField | |||
label={t("email")} | |||
label={t("password")} | |||
fullWidth | |||
{...register("email", { | |||
required: "email required!", | |||
})} | |||
error={Boolean(errors.email)} | |||
{...register("password")} | |||
error={Boolean(errors.password)} | |||
/> | |||
</Grid> | |||
</Grid> | |||
@@ -24,11 +24,18 @@ const GenerateMonthlyWorkHoursReport: React.FC<Props> = ({ staffs }) => { | |||
const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | |||
() => [ | |||
{ label: t("Staff"), | |||
paramName: "staff", | |||
type: "select", | |||
options: staffCombo, | |||
needAll: false}, | |||
{ | |||
label: t("Staff"), | |||
paramName: "staff", | |||
type: "select", | |||
options: staffCombo, | |||
needAll: false | |||
}, | |||
{ | |||
label: t("date"), | |||
paramName: "date", | |||
type: "monthYear", | |||
}, | |||
], | |||
[t], | |||
); | |||
@@ -38,9 +45,10 @@ return ( | |||
<SearchBox | |||
criteria={searchCriteria} | |||
onSearch={async (query: any) => { | |||
if (query.staff.length > 0 && query.staff.toLocaleLowerCase() !== "all") { | |||
console.log(query) | |||
if (query.staff.length > 0 && query.staff.toLocaleLowerCase() !== "all" && query.date.length < 0) { | |||
const index = staffCombo.findIndex(staff => staff === query.staff) | |||
const response = await fetchMonthlyWorkHoursReport({ id: staffs[index].id, yearMonth: "2023-03" }) | |||
const response = await fetchMonthlyWorkHoursReport({ id: staffs[index].id, yearMonth: query.date }) | |||
if (response) { | |||
downloadFile(new Uint8Array(response.blobValue), response.filename!!) | |||
} | |||
@@ -21,6 +21,7 @@ import { DatePicker } from "@mui/x-date-pickers/DatePicker"; | |||
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; | |||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | |||
import { Box } from "@mui/material"; | |||
import { DateCalendar } from "@mui/x-date-pickers"; | |||
interface BaseCriterion<T extends string> { | |||
label: string; | |||
@@ -43,10 +44,15 @@ interface DateRangeCriterion<T extends string> extends BaseCriterion<T> { | |||
type: "dateRange"; | |||
} | |||
interface MonthYearCriterion<T extends string> extends BaseCriterion<T> { | |||
type: "monthYear"; | |||
} | |||
export type Criterion<T extends string> = | |||
| TextCriterion<T> | |||
| SelectCriterion<T> | |||
| DateRangeCriterion<T>; | |||
| DateRangeCriterion<T> | |||
| MonthYearCriterion<T>; | |||
interface Props<T extends string> { | |||
criteria: Criterion<T>[]; | |||
@@ -66,19 +72,19 @@ function SearchBox<T extends string>({ | |||
(acc, c) => { | |||
return { ...acc, [c.paramName]: c.type === "select" ? "All" : "" }; | |||
}, | |||
{} as Record<T, string>, | |||
{} as Record<T, string> | |||
), | |||
[criteria], | |||
[criteria] | |||
); | |||
const [inputs, setInputs] = useState(defaultInputs); | |||
const makeInputChangeHandler = useCallback( | |||
(paramName: T): React.ChangeEventHandler<HTMLInputElement> => { | |||
return (e) => { | |||
setInputs((i) => ({ ...i, [paramName]: e.target.value })); | |||
}; | |||
}, | |||
[], | |||
[] | |||
); | |||
const makeSelectChangeHandler = useCallback((paramName: T) => { | |||
@@ -93,6 +99,13 @@ function SearchBox<T extends string>({ | |||
}; | |||
}, []); | |||
const makeMonthYearChangeHandler = useCallback((paramName: T) => { | |||
return (e: any) => { | |||
console.log(dayjs(e).format("YYYY-MM")) | |||
setInputs((i) => ({ ...i, [paramName]: dayjs(e).format("YYYY-MM") })); | |||
}; | |||
}, []); | |||
const makeDateToChangeHandler = useCallback((paramName: T) => { | |||
return (e: any) => { | |||
setInputs((i) => ({ | |||
@@ -135,7 +148,9 @@ function SearchBox<T extends string>({ | |||
onChange={makeSelectChangeHandler(c.paramName)} | |||
value={inputs[c.paramName]} | |||
> | |||
{!(c.needAll === false) && <MenuItem value={"All"}>{t("All")}</MenuItem>} | |||
{!(c.needAll === false) && ( | |||
<MenuItem value={"All"}>{t("All")}</MenuItem> | |||
)} | |||
{c.options.map((option, index) => ( | |||
<MenuItem key={`${option}-${index}`} value={option}> | |||
{t(option)} | |||
@@ -144,6 +159,26 @@ function SearchBox<T extends string>({ | |||
</Select> | |||
</FormControl> | |||
)} | |||
{c.type === "monthYear" && ( | |||
<LocalizationProvider | |||
dateAdapter={AdapterDayjs} | |||
// TODO: Should maybe use a custom adapterLocale here to support YYYY-MM-DD | |||
adapterLocale="zh-hk" | |||
> | |||
<Box display="flex"> | |||
<DateCalendar | |||
views={["month", "year"]} | |||
openTo="month" | |||
onChange={makeMonthYearChangeHandler(c.paramName)} | |||
value={ | |||
inputs[c.paramName] | |||
? dayjs(inputs[c.paramName]) | |||
: null | |||
} | |||
/> | |||
</Box> | |||
</LocalizationProvider> | |||
)} | |||
{c.type === "dateRange" && ( | |||
<LocalizationProvider | |||
dateAdapter={AdapterDayjs} | |||
@@ -155,7 +190,11 @@ function SearchBox<T extends string>({ | |||
<DatePicker | |||
label={c.label} | |||
onChange={makeDateChangeHandler(c.paramName)} | |||
value={inputs[c.paramName] ? dayjs(inputs[c.paramName]) : null} | |||
value={ | |||
inputs[c.paramName] | |||
? dayjs(inputs[c.paramName]) | |||
: null | |||
} | |||
/> | |||
</FormControl> | |||
<Box | |||
@@ -170,7 +209,11 @@ function SearchBox<T extends string>({ | |||
<DatePicker | |||
label={c.label2} | |||
onChange={makeDateToChangeHandler(c.paramName)} | |||
value={inputs[c.paramName.concat("To") as T] ? dayjs(inputs[c.paramName.concat("To") as T]) : null} | |||
value={ | |||
inputs[c.paramName.concat("To") as T] | |||
? dayjs(inputs[c.paramName.concat("To") as T]) | |||
: null | |||
} | |||
/> | |||
</FormControl> | |||
</Box> | |||
@@ -180,22 +223,22 @@ function SearchBox<T extends string>({ | |||
); | |||
})} | |||
</Grid> | |||
<CardActions sx={{ justifyContent: "flex-end" }}> | |||
<Button | |||
variant="text" | |||
startIcon={<RestartAlt />} | |||
onClick={handleReset} | |||
> | |||
{t("Reset")} | |||
</Button> | |||
<Button | |||
variant="outlined" | |||
startIcon={<Search />} | |||
onClick={handleSearch} | |||
> | |||
{t("Search")} | |||
</Button> | |||
</CardActions> | |||
<CardActions sx={{ justifyContent: "flex-end" }}> | |||
<Button | |||
variant="text" | |||
startIcon={<RestartAlt />} | |||
onClick={handleReset} | |||
> | |||
{t("Reset")} | |||
</Button> | |||
<Button | |||
variant="outlined" | |||
startIcon={<Search />} | |||
onClick={handleSearch} | |||
> | |||
{t("Search")} | |||
</Button> | |||
</CardActions> | |||
</CardContent> | |||
</Card> | |||
); | |||