added Create User
This commit is contained in:
@@ -103,6 +103,7 @@
|
||||
"logs_page_ui_table_header_create_at": "Create date",
|
||||
"logs_page_ui_table_header_old_value": "Old value",
|
||||
"logs_page_ui_table_header_new_value": "New value",
|
||||
"logs_page_ui_table_header_record_id": "Record ID",
|
||||
"logs_page_ui_badge_action": [
|
||||
{
|
||||
"match": {
|
||||
@@ -113,6 +114,7 @@
|
||||
"action=unban": "Unlock",
|
||||
"action=sign_in": "Sign in",
|
||||
"action=sign_out": "Sign out",
|
||||
"action=change_password": "Change password",
|
||||
"action=*": "Other"
|
||||
}
|
||||
}
|
||||
@@ -134,7 +136,9 @@
|
||||
"users_page_message_set_role_success": "Modified user role successfully!",
|
||||
"users_page_message_banned_success": "Banned {name} successfully!",
|
||||
"users_page_message_unbanned_success": "Unbanned {name} successfully!",
|
||||
"users_page_message_created_user_success": "Created user successfully!",
|
||||
"users_page_message_select_min_one_day": "Please select expiration at least 1 day!",
|
||||
"users_page_message_role_select": "Must select one of them!",
|
||||
"users_page_ui_form_ban_reason": "Ban reason",
|
||||
"users_page_ui_form_ban_exp": "Ban expiration",
|
||||
"users_page_ui_select_placeholder_role": "Select role",
|
||||
@@ -148,7 +152,8 @@
|
||||
"match": {
|
||||
"code=INVALID_EMAIL_OR_PASSWORD": "Email or password incorrect!",
|
||||
"code=INVALID_PASSWORD": "Password incorrect!",
|
||||
"code=YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE": "You are not allowed to change users role"
|
||||
"code=YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE": "You are not allowed to change users role",
|
||||
"code=USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL": "Email already exists. Please choose another email!"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
"logs_page_ui_table_header_create_at": "Ngày tạo",
|
||||
"logs_page_ui_table_header_old_value": "Giá trị cũ",
|
||||
"logs_page_ui_table_header_new_value": "Giá trị mới",
|
||||
"logs_page_ui_table_header_record_id": "Id tương ứng",
|
||||
"logs_page_ui_badge_action": [
|
||||
{
|
||||
"match": {
|
||||
@@ -113,6 +114,7 @@
|
||||
"action=unban": "Mở khóa",
|
||||
"action=sign_in": "Đăng nhập",
|
||||
"action=sign_out": "Đăng xuất",
|
||||
"action=change_password": "Đổi mật khẩu",
|
||||
"action=*": "Khác"
|
||||
}
|
||||
}
|
||||
@@ -134,7 +136,9 @@
|
||||
"users_page_message_set_role_success": "Chỉnh sửa quyền hạn người dùng thành công!",
|
||||
"users_page_message_banned_success": "Người dùng {name} bị cấm thành công!",
|
||||
"users_page_message_unbanned_success": "Người dùng {name} bỏ cấm thành công!",
|
||||
"users_page_message_created_user_success": "Đăng ký người dùng thành công!",
|
||||
"users_page_message_select_min_one_day": "Chọn ít nhất là 1 ngày!",
|
||||
"users_page_message_role_select": "Mời bạn chọn quyền hạn!",
|
||||
"users_page_ui_form_ban_reason": "Lý do cấm",
|
||||
"users_page_ui_form_ban_exp": "Thời gian cấm",
|
||||
"users_page_ui_select_placeholder_role": "Hãy chọn quyền hạn",
|
||||
@@ -148,7 +152,8 @@
|
||||
"match": {
|
||||
"code=INVALID_EMAIL_OR_PASSWORD": "Email hoặc mật khẩu không đúng!",
|
||||
"code=INVALID_PASSWORD": "Mật khẩu hiện tại không đúng!",
|
||||
"code=YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE": "Bạn không đủ quyền để chỉnh sửa quyền hạn người dùng!"
|
||||
"code=YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE": "Bạn không đủ quyền để chỉnh sửa quyền hạn người dùng!",
|
||||
"code=USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL": "Email này đã có người sử dụng. Vui lòng chọn một email khác!"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -49,20 +49,6 @@ async function main() {
|
||||
skipDuplicates: true,
|
||||
});
|
||||
console.log('---------------Created settings-----------------');
|
||||
|
||||
// // Clear existing todos
|
||||
// await prisma.todo.deleteMany()
|
||||
|
||||
// // Create example todos
|
||||
// const todos = await prisma.todo.createMany({
|
||||
// data: [
|
||||
// { title: 'Buy groceries' },
|
||||
// { title: 'Read a book' },
|
||||
// { title: 'Workout' },
|
||||
// ],
|
||||
// })
|
||||
|
||||
// console.log(`✅ Created ${todos.count} todos`)
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { LOG_ACTION } from '@/types/enum';
|
||||
import { Badge } from '../ui/badge';
|
||||
|
||||
export type UserActionType = {
|
||||
@@ -9,9 +10,10 @@ export type UserActionType = {
|
||||
unban: string;
|
||||
sign_in: string;
|
||||
sign_out: string;
|
||||
change_password: string;
|
||||
};
|
||||
|
||||
const ActionBadge = ({ action }: { action: keyof UserActionType }) => {
|
||||
const ActionBadge = ({ action }: { action: LOG_ACTION }) => {
|
||||
const USER_ACTION = Object.freeze(
|
||||
new Proxy(
|
||||
{
|
||||
@@ -22,6 +24,7 @@ const ActionBadge = ({ action }: { action: keyof UserActionType }) => {
|
||||
sign_out: 'bg-yellow-400',
|
||||
ban: 'bg-rose-400',
|
||||
unban: 'bg-emerald-400',
|
||||
change_password: 'bg-red-600',
|
||||
} as UserActionType,
|
||||
{
|
||||
get: function (target: UserActionType, name: string | symbol) {
|
||||
|
||||
@@ -3,7 +3,8 @@ import { formatters } from '@/utils/formatters';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { Badge } from '../ui/badge';
|
||||
|
||||
import ActionBadge, { UserActionType } from './action-badge';
|
||||
import { LOG_ACTION } from '@/types/enum';
|
||||
import ActionBadge from './action-badge';
|
||||
import ViewDetail from './view-detail-dialog';
|
||||
|
||||
export const logColumns: ColumnDef<AuditWithUser>[] = [
|
||||
@@ -35,14 +36,12 @@ export const logColumns: ColumnDef<AuditWithUser>[] = [
|
||||
thClass: 'w-1/6',
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<ActionBadge action={row.original.action as keyof UserActionType} />
|
||||
);
|
||||
return <ActionBadge action={row.original.action as LOG_ACTION} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: m.logs_page_ui_table_header_action(),
|
||||
header: m.logs_page_ui_table_header_create_at(),
|
||||
meta: {
|
||||
thClass: 'w-2/6',
|
||||
},
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
|
||||
import usePreventAutoFocus from '@/hooks/use-prevent-auto-focus';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { LOG_ACTION } from '@/types/enum';
|
||||
import { formatters } from '@/utils/formatters';
|
||||
import { jsonSupport } from '@/utils/helper';
|
||||
import { EyeIcon } from '@phosphor-icons/react';
|
||||
import { CheckIcon, CopyIcon, EyeIcon } from '@phosphor-icons/react';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
@@ -15,7 +17,7 @@ import {
|
||||
} from '../ui/dialog';
|
||||
import { Label } from '../ui/label';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import ActionBadge, { UserActionType } from './action-badge';
|
||||
import ActionBadge from './action-badge';
|
||||
|
||||
type ViewDetailProps = {
|
||||
data: AuditWithUser;
|
||||
@@ -23,6 +25,7 @@ type ViewDetailProps = {
|
||||
|
||||
const ViewDetail = ({ data }: ViewDetailProps) => {
|
||||
const prevent = usePreventAutoFocus();
|
||||
const { isCopied, copyToClipboard } = useCopyToClipboard();
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
@@ -80,7 +83,7 @@ const ViewDetail = ({ data }: ViewDetailProps) => {
|
||||
<span className="font-bold">
|
||||
{m.logs_page_ui_table_header_action()}:
|
||||
</span>
|
||||
<ActionBadge action={data.action as keyof UserActionType} />
|
||||
<ActionBadge action={data.action as LOG_ACTION} />
|
||||
</div>
|
||||
{data.oldValue && (
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -92,13 +95,32 @@ const ViewDetail = ({ data }: ViewDetailProps) => {
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.newValue && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="font-bold">
|
||||
{m.logs_page_ui_table_header_new_value()}:
|
||||
</span>
|
||||
<pre className="whitespace-pre-wrap wrap-break-word">
|
||||
{data.newValue ? jsonSupport(data.newValue) : ''}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold">
|
||||
{m.logs_page_ui_table_header_new_value()}:
|
||||
{m.logs_page_ui_table_header_record_id()}:
|
||||
</span>
|
||||
<pre className="whitespace-pre-wrap wrap-break-word">
|
||||
{data.newValue ? jsonSupport(data.newValue) : ''}
|
||||
</pre>
|
||||
<div className="flex gap-1.5 items-center">
|
||||
{data.recordId}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-xs"
|
||||
className="rounded-full cursor-pointer"
|
||||
onClick={() => copyToClipboard(data.recordId)}
|
||||
>
|
||||
{isCopied ? <CheckIcon /> : <CopyIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold">
|
||||
|
||||
198
src/components/form/admin-create-user-form.tsx
Normal file
198
src/components/form/admin-create-user-form.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { usersQueries } from '@/service/queries';
|
||||
import { createUser } from '@/service/user.api';
|
||||
import { RoleEnum, userCreateSchema } from '@/service/user.schema';
|
||||
import { ReturnError } from '@/types/common';
|
||||
import { useForm } from '@tanstack/react-form';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '../ui/button';
|
||||
import { DialogClose, DialogFooter } from '../ui/dialog';
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from '../ui/field';
|
||||
import { Input } from '../ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../ui/select';
|
||||
|
||||
type FormProps = {
|
||||
onSubmit: (open: boolean) => void;
|
||||
};
|
||||
|
||||
const AdminCreateUserForm = ({ onSubmit }: FormProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutate: createUserMutation } = useMutation({
|
||||
mutationFn: createUser,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...usersQueries.all, 'list'],
|
||||
});
|
||||
onSubmit(false);
|
||||
toast.success(m.users_page_message_created_user_success(), {
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
onError: (error: ReturnError) => {
|
||||
console.error(error);
|
||||
toast.error(m.backend_message({ code: error.code }), {
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
name: '',
|
||||
role: '',
|
||||
},
|
||||
validators: {
|
||||
onChange: userCreateSchema,
|
||||
onSubmit: userCreateSchema,
|
||||
},
|
||||
onSubmit: ({ value }) => {
|
||||
createUserMutation({ data: userCreateSchema.parse(value) });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
id="admin-create-user-form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<FieldGroup>
|
||||
<form.Field
|
||||
name="email"
|
||||
children={(field) => {
|
||||
const isInvalid =
|
||||
field.state.meta.isTouched && !field.state.meta.isValid;
|
||||
return (
|
||||
<Field data-invalid={isInvalid} className="col-span-2">
|
||||
<FieldLabel htmlFor={field.name}>
|
||||
{m.login_page_form_email()}:
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
aria-invalid={isInvalid}
|
||||
type="email"
|
||||
/>
|
||||
{isInvalid && <FieldError errors={field.state.meta.errors} />}
|
||||
</Field>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<form.Field
|
||||
name="password"
|
||||
children={(field) => {
|
||||
const isInvalid =
|
||||
field.state.meta.isTouched && !field.state.meta.isValid;
|
||||
return (
|
||||
<Field data-invalid={isInvalid}>
|
||||
<FieldLabel htmlFor={field.name}>
|
||||
{m.login_page_form_password()}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
aria-invalid={isInvalid}
|
||||
type="password"
|
||||
/>
|
||||
{isInvalid && <FieldError errors={field.state.meta.errors} />}
|
||||
</Field>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<form.Field
|
||||
name="name"
|
||||
children={(field) => {
|
||||
const isInvalid =
|
||||
field.state.meta.isTouched && !field.state.meta.isValid;
|
||||
return (
|
||||
<Field data-invalid={isInvalid}>
|
||||
<FieldLabel htmlFor={field.name}>
|
||||
{m.profile_form_name()}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
aria-invalid={isInvalid}
|
||||
type="text"
|
||||
/>
|
||||
{isInvalid && <FieldError errors={field.state.meta.errors} />}
|
||||
</Field>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<form.Field
|
||||
name="role"
|
||||
children={(field) => {
|
||||
const isInvalid =
|
||||
field.state.meta.isTouched && !field.state.meta.isValid;
|
||||
return (
|
||||
<Field data-invalid={isInvalid}>
|
||||
<FieldLabel htmlFor={field.name}>
|
||||
{m.profile_form_role()}
|
||||
</FieldLabel>
|
||||
<Select
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onValueChange={(value) =>
|
||||
field.handleChange(RoleEnum.parse(value))
|
||||
}
|
||||
>
|
||||
<SelectTrigger aria-invalid={isInvalid}>
|
||||
<SelectValue
|
||||
placeholder={m.users_page_ui_select_placeholder_role()}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">
|
||||
{m.role_tags({ role: 'admin' })}
|
||||
</SelectItem>
|
||||
<SelectItem value="user">
|
||||
{m.role_tags({ role: 'user' })}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isInvalid && <FieldError errors={field.state.meta.errors} />}
|
||||
</Field>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Field>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" type="button">
|
||||
{m.ui_cancel_btn()}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" variant="destructive">
|
||||
{m.ui_signup_btn()}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminCreateUserForm;
|
||||
@@ -5,7 +5,7 @@ import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-xs/relaxed font-medium focus-visible:ring-[2px] aria-invalid:ring-[2px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 disabled:data-[active=true]:opacity-100 disabled:data-[active=true]:bg-teal-400 disabled:data-[active=true]:border-teal-400 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-xs/relaxed font-medium focus-visible:ring-[2px] aria-invalid:ring-[2px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 disabled:data-[active=true]:opacity-100 disabled:data-[active=true]:bg-primary disabled:data-[active=true]:border-primary disabled:data-[active=true]:text-white [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
48
src/components/user/add-new-user-dialog.tsx
Normal file
48
src/components/user/add-new-user-dialog.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import usePreventAutoFocus from '@/hooks/use-prevent-auto-focus';
|
||||
import { m } from '@/paraglide/messages';
|
||||
import { PlusIcon } from '@phosphor-icons/react';
|
||||
import { useState } from 'react';
|
||||
import AdminCreateUserForm from '../form/admin-create-user-form';
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '../ui/dialog';
|
||||
|
||||
const AddNewUserButton = () => {
|
||||
const [_open, _setOpen] = useState(false);
|
||||
const prevent = usePreventAutoFocus();
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger>
|
||||
<Button type="button" variant="default">
|
||||
<PlusIcon />
|
||||
{m.nav_add_new()}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className="max-w-80 xl:max-w-xl"
|
||||
{...prevent}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-3 text-lg font-bold text-red-600">
|
||||
<PlusIcon size={16} />
|
||||
{m.nav_add_new()}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{m.nav_add_new()}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<AdminCreateUserForm />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddNewUserButton;
|
||||
33
src/hooks/use-copy-to-clipboard.ts
Normal file
33
src/hooks/use-copy-to-clipboard.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export function useCopyToClipboard({
|
||||
timeout = 2000,
|
||||
onCopy,
|
||||
}: {
|
||||
timeout?: number;
|
||||
onCopy?: () => void;
|
||||
} = {}) {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
const copyToClipboard = (value: string) => {
|
||||
if (typeof window === 'undefined' || !navigator.clipboard.writeText) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!value) return;
|
||||
|
||||
navigator.clipboard.writeText(value).then(() => {
|
||||
setIsCopied(true);
|
||||
|
||||
if (onCopy) {
|
||||
onCopy();
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setIsCopied(false);
|
||||
}, timeout);
|
||||
}, console.error);
|
||||
};
|
||||
|
||||
return { isCopied, copyToClipboard };
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '@/lib/auth/organization-permissions';
|
||||
import { ac, admin, user } from '@/lib/auth/permissions';
|
||||
import { createAuditLog } from '@/service/repository';
|
||||
import { DB_TABLE, LOG_ACTION } from '@/types/enum';
|
||||
import { betterAuth } from 'better-auth';
|
||||
import { prismaAdapter } from 'better-auth/adapters/prisma';
|
||||
import { admin as adminPlugin, organization } from 'better-auth/plugins';
|
||||
@@ -66,6 +67,14 @@ export const auth = betterAuth({
|
||||
color: '#000000',
|
||||
},
|
||||
});
|
||||
await prisma.setting.create({
|
||||
data: {
|
||||
key: user.id,
|
||||
value: '{"language": "en"}',
|
||||
description: '',
|
||||
relation: 'user',
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
update: {
|
||||
@@ -79,8 +88,8 @@ export const auth = betterAuth({
|
||||
),
|
||||
);
|
||||
await createAuditLog({
|
||||
action: 'update',
|
||||
tableName: 'user',
|
||||
action: LOG_ACTION.UPDATE,
|
||||
tableName: DB_TABLE.USER,
|
||||
recordId: ctx?.context.session?.user.id,
|
||||
oldValue: JSON.stringify(oldUser),
|
||||
newValue: JSON.stringify(newUser),
|
||||
@@ -95,8 +104,8 @@ export const auth = betterAuth({
|
||||
after: async (account, context) => {
|
||||
if (context?.path === '/change-password') {
|
||||
await createAuditLog({
|
||||
action: 'change_password',
|
||||
tableName: 'account',
|
||||
action: LOG_ACTION.CHANGE_PASSWORD,
|
||||
tableName: DB_TABLE.ACCOUNT,
|
||||
recordId: account.id,
|
||||
oldValue: 'Change Password',
|
||||
newValue: 'Change Password',
|
||||
@@ -111,8 +120,8 @@ export const auth = betterAuth({
|
||||
after: async (session, context) => {
|
||||
if (context?.path.includes('/sign-in')) {
|
||||
await createAuditLog({
|
||||
action: 'sign_in',
|
||||
tableName: 'session',
|
||||
action: LOG_ACTION.SIGN_IN,
|
||||
tableName: DB_TABLE.SESSION,
|
||||
recordId: session.id,
|
||||
oldValue: '',
|
||||
newValue: JSON.stringify(session),
|
||||
@@ -125,8 +134,8 @@ export const auth = betterAuth({
|
||||
after: async (session, context) => {
|
||||
if (context?.path === '/sign-out') {
|
||||
await createAuditLog({
|
||||
action: 'sign_out',
|
||||
tableName: 'session',
|
||||
action: LOG_ACTION.SIGN_OUT,
|
||||
tableName: DB_TABLE.SESSION,
|
||||
recordId: session.id,
|
||||
oldValue: JSON.stringify(session),
|
||||
newValue: '',
|
||||
|
||||
@@ -2,6 +2,7 @@ import DataTable from '@/components/DataTable';
|
||||
import { Card, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import SearchInput from '@/components/ui/search-input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import AddNewUserButton from '@/components/user/add-new-user-dialog';
|
||||
import { userColumns } from '@/components/user/user-column';
|
||||
import useDebounced from '@/hooks/use-debounced';
|
||||
import { m } from '@/paraglide/messages';
|
||||
@@ -57,12 +58,13 @@ function RouteComponent() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center justify-between">
|
||||
<SearchInput
|
||||
keywords={searchKeyword}
|
||||
setKeyword={setSearchKeyword}
|
||||
onChange={onSearchChange}
|
||||
/>
|
||||
<AddNewUserButton />
|
||||
</div>
|
||||
{data && (
|
||||
<DataTable
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { prisma } from '@/db';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { authMiddleware } from '@/lib/middleware';
|
||||
import { DB_TABLE, LOG_ACTION } from '@/types/enum';
|
||||
import { parseError } from '@/utils/helper';
|
||||
import { createServerFn } from '@tanstack/react-start';
|
||||
import { getRequestHeaders } from '@tanstack/react-start/server';
|
||||
import { createAuditLog } from './repository';
|
||||
import {
|
||||
baseUser,
|
||||
userBanSchema,
|
||||
userCreateSchema,
|
||||
userListSchema,
|
||||
userSetPasswordSchema,
|
||||
userUpdateInfoSchema,
|
||||
@@ -48,7 +52,7 @@ export const getAllUser = createServerFn({ method: 'GET' })
|
||||
export const setUserPassword = createServerFn({ method: 'POST' })
|
||||
.middleware([authMiddleware])
|
||||
.inputValidator(userSetPasswordSchema)
|
||||
.handler(async ({ data }) => {
|
||||
.handler(async ({ data, context: { user } }) => {
|
||||
try {
|
||||
const headers = getRequestHeaders();
|
||||
const result = await auth.api.setUserPassword({
|
||||
@@ -58,6 +62,16 @@ export const setUserPassword = createServerFn({ method: 'POST' })
|
||||
},
|
||||
headers,
|
||||
});
|
||||
|
||||
await createAuditLog({
|
||||
action: LOG_ACTION.CHANGE_PASSWORD,
|
||||
tableName: DB_TABLE.ACCOUNT,
|
||||
recordId: data.id,
|
||||
oldValue: 'Admin Set User Password',
|
||||
newValue: 'Admin Set User Password',
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const { message, code } = parseError(error);
|
||||
@@ -68,8 +82,15 @@ export const setUserPassword = createServerFn({ method: 'POST' })
|
||||
export const updateUserInformation = createServerFn({ method: 'POST' })
|
||||
.middleware([authMiddleware])
|
||||
.inputValidator(userUpdateInfoSchema)
|
||||
.handler(async ({ data }) => {
|
||||
.handler(async ({ data, context: { user } }) => {
|
||||
try {
|
||||
const currentUser = await prisma.user.findUnique({
|
||||
where: { id: data.id },
|
||||
select: { name: true },
|
||||
});
|
||||
|
||||
if (!currentUser) throw Error('User not found');
|
||||
|
||||
const headers = getRequestHeaders();
|
||||
const result = await auth.api.adminUpdateUser({
|
||||
body: {
|
||||
@@ -79,6 +100,16 @@ export const updateUserInformation = createServerFn({ method: 'POST' })
|
||||
// This endpoint requires session cookies.
|
||||
headers,
|
||||
});
|
||||
|
||||
await createAuditLog({
|
||||
action: LOG_ACTION.UPDATE,
|
||||
tableName: DB_TABLE.USER,
|
||||
recordId: data.id,
|
||||
oldValue: JSON.stringify({ name: currentUser.name }),
|
||||
newValue: JSON.stringify({ name: data.name }),
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const { message, code } = parseError(error);
|
||||
@@ -89,8 +120,15 @@ export const updateUserInformation = createServerFn({ method: 'POST' })
|
||||
export const setUserRole = createServerFn({ method: 'POST' })
|
||||
.middleware([authMiddleware])
|
||||
.inputValidator(userUpdateRoleSchema)
|
||||
.handler(async ({ data }) => {
|
||||
.handler(async ({ data, context: { user } }) => {
|
||||
try {
|
||||
const currentUser = await prisma.user.findUnique({
|
||||
where: { id: data.id },
|
||||
select: { role: true },
|
||||
});
|
||||
|
||||
if (!currentUser) throw Error('User not found');
|
||||
|
||||
const headers = getRequestHeaders();
|
||||
const result = await auth.api.setRole({
|
||||
body: {
|
||||
@@ -101,6 +139,15 @@ export const setUserRole = createServerFn({ method: 'POST' })
|
||||
headers,
|
||||
});
|
||||
|
||||
await createAuditLog({
|
||||
action: LOG_ACTION.UPDATE,
|
||||
tableName: DB_TABLE.USER,
|
||||
recordId: data.id,
|
||||
oldValue: JSON.stringify({ role: currentUser.role }),
|
||||
newValue: JSON.stringify({ role: data.role }),
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const { message, code } = parseError(error);
|
||||
@@ -111,7 +158,7 @@ export const setUserRole = createServerFn({ method: 'POST' })
|
||||
export const banUser = createServerFn({ method: 'POST' })
|
||||
.middleware([authMiddleware])
|
||||
.inputValidator(userBanSchema)
|
||||
.handler(async ({ data }) => {
|
||||
.handler(async ({ data, context: { user } }) => {
|
||||
try {
|
||||
const headers = getRequestHeaders();
|
||||
const result = await auth.api.banUser({
|
||||
@@ -126,6 +173,16 @@ export const banUser = createServerFn({ method: 'POST' })
|
||||
// This endpoint requires session cookies.
|
||||
headers,
|
||||
});
|
||||
|
||||
await createAuditLog({
|
||||
action: LOG_ACTION.BAN,
|
||||
tableName: DB_TABLE.USER,
|
||||
recordId: data.id,
|
||||
oldValue: null,
|
||||
newValue: `Lý do: ${data.banReason} - ${data.banExp} days`,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const { message, code } = parseError(error);
|
||||
@@ -136,7 +193,7 @@ export const banUser = createServerFn({ method: 'POST' })
|
||||
export const unbanUser = createServerFn({ method: 'POST' })
|
||||
.middleware([authMiddleware])
|
||||
.inputValidator(baseUser)
|
||||
.handler(async ({ data }) => {
|
||||
.handler(async ({ data, context: { user } }) => {
|
||||
try {
|
||||
const headers = getRequestHeaders();
|
||||
const result = await auth.api.unbanUser({
|
||||
@@ -146,9 +203,45 @@ export const unbanUser = createServerFn({ method: 'POST' })
|
||||
// This endpoint requires session cookies.
|
||||
headers,
|
||||
});
|
||||
|
||||
await createAuditLog({
|
||||
action: LOG_ACTION.UNBAN,
|
||||
tableName: DB_TABLE.USER,
|
||||
recordId: data.id,
|
||||
oldValue: null,
|
||||
newValue: null,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const { message, code } = parseError(error);
|
||||
throw { message, code };
|
||||
}
|
||||
});
|
||||
|
||||
export const createUser = createServerFn({ method: 'POST' })
|
||||
.middleware([authMiddleware])
|
||||
.inputValidator(userCreateSchema)
|
||||
.handler(async ({ data, context: { user } }) => {
|
||||
try {
|
||||
const result = await auth.api.createUser({
|
||||
body: data,
|
||||
});
|
||||
|
||||
await createAuditLog({
|
||||
action: LOG_ACTION.CREATE,
|
||||
tableName: DB_TABLE.USER,
|
||||
recordId: result.user.id,
|
||||
oldValue: null,
|
||||
newValue: JSON.stringify(result.user),
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
const { message, code } = parseError(error);
|
||||
throw { message, code };
|
||||
}
|
||||
});
|
||||
|
||||
@@ -29,7 +29,10 @@ export const userUpdateInfoSchema = baseUser.extend({
|
||||
),
|
||||
});
|
||||
|
||||
export const RoleEnum = z.enum(['admin', 'user']);
|
||||
export const RoleEnum = z.enum(
|
||||
['admin', 'user'],
|
||||
m.users_page_message_role_select(),
|
||||
);
|
||||
|
||||
export const userUpdateRoleSchema = baseUser.extend({
|
||||
role: RoleEnum,
|
||||
@@ -43,3 +46,23 @@ export const userBanSchema = baseUser.extend({
|
||||
),
|
||||
banExp: z.number().int().min(1, m.users_page_message_select_min_one_day()),
|
||||
});
|
||||
|
||||
export const userCreateSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.nonempty(m.common_is_required({ field: m.login_page_form_email() }))
|
||||
.email(m.login_page_messages_email_invalid()),
|
||||
password: z
|
||||
.string()
|
||||
.min(5, m.users_page_message_user_min())
|
||||
.regex(/[A-Z]/, m.users_page_message_contain_uppercase())
|
||||
.regex(/[a-z]/, m.users_page_message_contain_lowercase())
|
||||
.regex(/[0-9]/, m.users_page_message_contain_number())
|
||||
.regex(/[^a-zA-Z0-9]/, m.users_page_message_contain_special()),
|
||||
name: z.string().nonempty(
|
||||
m.common_is_required({
|
||||
field: m.profile_form_name(),
|
||||
}),
|
||||
),
|
||||
role: RoleEnum,
|
||||
});
|
||||
|
||||
28
src/types/enum.ts
Normal file
28
src/types/enum.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export const LOG_ACTION = {
|
||||
CHANGE_PASSWORD: 'change_password',
|
||||
CREATE: 'create',
|
||||
UPDATE: 'update',
|
||||
DELETE: 'delete',
|
||||
SIGN_IN: 'sign_in',
|
||||
SIGN_OUT: 'sign_out',
|
||||
BAN: 'ban',
|
||||
UNBAN: 'unban',
|
||||
} as const;
|
||||
|
||||
export type LOG_ACTION = (typeof LOG_ACTION)[keyof typeof LOG_ACTION];
|
||||
|
||||
export const DB_TABLE = {
|
||||
ACCOUNT: 'account',
|
||||
AUDIT: 'audit',
|
||||
INVITATION: 'invitation',
|
||||
MEMBER: 'member',
|
||||
ORGANIZATION: 'organization',
|
||||
SESSION: 'session',
|
||||
SETTING: 'setting',
|
||||
USER: 'user',
|
||||
VERIFICATION: 'verification',
|
||||
BOX: 'box',
|
||||
ITEM: 'item',
|
||||
} as const;
|
||||
|
||||
export type DB_TABLE = (typeof DB_TABLE)[keyof typeof DB_TABLE];
|
||||
Reference in New Issue
Block a user