added Create User

This commit is contained in:
2026-01-23 09:24:05 +07:00
parent a8745327d6
commit 51c26f3704
15 changed files with 500 additions and 46 deletions

View File

@@ -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!"
}
}
]

View File

@@ -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!"
}
}
]

View File

@@ -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()

View File

@@ -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) {

View File

@@ -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',
},

View File

@@ -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">

View 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;

View File

@@ -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: {

View 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;

View 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 };
}

View File

@@ -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: '',

View File

@@ -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

View File

@@ -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 };
}
});

View File

@@ -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
View 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];