diff --git a/messages/en.json b/messages/en.json
index 0a48f32..e355932 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -24,6 +24,19 @@
}
}
],
+ "exp_time": [
+ {
+ "match": {
+ "time=1": "1 day",
+ "time=7": "7 days",
+ "time=15": "15 days",
+ "time=30": "1 month",
+ "time=180": "6 month",
+ "time=365": "1 year",
+ "time=99999": "Forever"
+ }
+ }
+ ],
"ui_login_btn": "Sign in",
"ui_logout_btn": "Sign out",
"ui_cancel_btn": "Cancel",
@@ -31,11 +44,14 @@
"ui_confirm_btn": "Confirm",
"ui_signup_btn": "Sign up",
"ui_view_btn": "View",
- "ui_save_btn": "Save",
+ "ui_save_btn": "Save changes",
"ui_update_btn": "Update",
"ui_delete_btn": "Delete",
"ui_ban_btn": "Lock",
"ui_unban_btn": "Unlock",
+ "ui_update_password_btn": "Set password",
+ "ui_change_role_btn": "Set role",
+ "ui_edit_user_btn": "Edit User",
"ui_dialog_view_title": "View {type} details",
"ui_view_all_notifications": "View All Notifications",
"ui_label_notifications": "Notifications",
@@ -46,7 +62,8 @@
"nav_add_new": "Add new",
"nav_edit": "Edit",
"nav_change_password": "Change password",
- "nav_log": "Logs",
+ "nav_logs": "Logs",
+ "nav_users": "Người dùng",
"nav_roles": "Vai trò & quyền hạn",
"nav_box": "Box",
"nav_account": "Account",
@@ -84,6 +101,9 @@
"logs_page_ui_table_header_table": "Table",
"logs_page_ui_table_header_action": "Action",
"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": {
@@ -94,10 +114,47 @@
"action=unban": "Unlock",
"action=sign_in": "Sign in",
"action=sign_out": "Sign out",
+ "action=change_password": "Change password",
"action=*": "Other"
}
}
],
- "backend_INVALID_EMAIL_OR_PASSWORD": "Email or password incorrect!",
- "backend_INVALID_PASSWORD": "Password incorrect!"
+ "users_page_ui_title": "Users",
+ "users_page_ui_table_header_name": "Name",
+ "users_page_ui_table_header_email": "Email",
+ "users_page_ui_table_header_role": "Role",
+ "users_page_ui_table_header_banned": "Banned?",
+ "users_page_ui_table_header_created_at": "Create date",
+ "users_page_message_user_not_found": "User not found!",
+ "users_page_message_user_min": "Password must be at least 5 characters long!",
+ "users_page_message_contain_uppercase": "Password must contain at least one UPPERCASE letter!",
+ "users_page_message_contain_lowercase": "Password must contain at least one lowercase letter!",
+ "users_page_message_contain_number": "Password must contain at least one number!",
+ "users_page_message_contain_special": "Password must contain at least one special character!",
+ "users_page_message_set_password_success": "Set password successfully!",
+ "users_page_message_update_info_success": "Edited user information successfully!",
+ "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",
+ "users_page_ui_select_placeholder_ban_exp": "Select time",
+ "users_page_ui_dialog_alert_title": "Unban this user?",
+ "users_page_ui_dialog_alert_ban_title": "",
+ "users_page_ui_dialog_alert_description": "Detail: \nName: {name}. \nEmail: {email}",
+ "users_page_ui_dialog_alert_description_2": "Reason: {reason}. \nExpiration: {exp}",
+ "backend_message": [
+ {
+ "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=USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL": "Email already exists. Please choose another email!"
+ }
+ }
+ ]
}
diff --git a/messages/vi.json b/messages/vi.json
index 3329bda..b76505f 100644
--- a/messages/vi.json
+++ b/messages/vi.json
@@ -24,6 +24,19 @@
}
}
],
+ "exp_time": [
+ {
+ "match": {
+ "time=1": "1 ngày",
+ "time=7": "7 ngày",
+ "time=15": "15 ngày",
+ "time=30": "1 tháng",
+ "time=180": "6 tháng",
+ "time=365": "1 năm",
+ "time=99999": "Vĩnh viễn"
+ }
+ }
+ ],
"ui_login_btn": "Đăng nhập",
"ui_logout_btn": "Đăng xuất",
"ui_cancel_btn": "Hủy",
@@ -31,11 +44,14 @@
"ui_confirm_btn": "Xác nhận",
"ui_signup_btn": "Đăng ký",
"ui_view_btn": "Xem",
- "ui_save_btn": "Lưu",
+ "ui_save_btn": "Lưu thay đổi",
"ui_update_btn": "Cập nhật",
"ui_delete_btn": "Xóa",
"ui_ban_btn": "Khóa",
"ui_unban_btn": "Mở khóa",
+ "ui_update_password_btn": "Đặt lại mật khẩu",
+ "ui_change_role_btn": "Đặt lại quyền hạn",
+ "ui_edit_user_btn": "Chỉnh sửa người dùng",
"ui_dialog_view_title": "Xem chi tiết {type}",
"ui_view_all_notifications": "Xem tất cả thông báo",
"ui_label_notifications": "Thông báo",
@@ -46,7 +62,8 @@
"nav_add_new": "Thêm mới",
"nav_edit": "Chỉnh sửa",
"nav_change_password": "Đổi mật khẩu",
- "nav_log": "Lịch sử",
+ "nav_logs": "Lịch sử",
+ "nav_users": "Người dùng",
"nav_roles": "Vai trò & quyền hạn",
"nav_box": "Hộp chứa",
"nav_account": "Tài khoản",
@@ -86,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": {
@@ -96,10 +114,47 @@
"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"
}
}
],
- "backend_INVALID_EMAIL_OR_PASSWORD": "Email hoặc mật khẩu không đúng!",
- "backend_INVALID_PASSWORD": "Mật khẩu hiện tại không đúng!"
+ "users_page_ui_title": "Người dùng",
+ "users_page_ui_table_header_name": "Tên",
+ "users_page_ui_table_header_email": "Email",
+ "users_page_ui_table_header_role": "Quyền hạn",
+ "users_page_ui_table_header_banned": "Đã khóa?",
+ "users_page_ui_table_header_created_at": "Ngày tạo",
+ "users_page_message_user_not_found": "Không tìm thấy người dùng này!",
+ "users_page_message_user_min": "Mật khẩu phải có ít nhất 5 ký tự!",
+ "users_page_message_contain_uppercase": "Mật khẩu phải có ít nhất 1 ký tự viết HOA!",
+ "users_page_message_contain_lowercase": "Mật khẩu phải có ít nhất 1 ký tự viết thường!",
+ "users_page_message_contain_number": "Mật khẩu phải có ít nhất 1 chữ số!",
+ "users_page_message_contain_special": "Mật khẩu phải có ít nhất 1 ký tự đặc biệt!",
+ "users_page_message_set_password_success": "Đặt lại mật khẩu thành công!",
+ "users_page_message_update_info_success": "Chỉnh sửa thông tin người dùng thành công!",
+ "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",
+ "users_page_ui_select_placeholder_ban_exp": "Hãy chọn thời gian cấm",
+ "users_page_ui_dialog_alert_title": "Bạn muốn mở khóa người dùng này?",
+ "users_page_ui_dialog_alert_ban_title": "Bạn muốn khóa người dùng này?",
+ "users_page_ui_dialog_alert_description": "Chi tiết: \nTên: {name}. \nEmail: {email}",
+ "users_page_ui_dialog_alert_description_2": "\nLý do: {reason}. \nHiệu lực: {exp}",
+ "backend_message": [
+ {
+ "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=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!"
+ }
+ }
+ ]
}
diff --git a/prisma/seed.ts b/prisma/seed.ts
index a4f34eb..218ded9 100644
--- a/prisma/seed.ts
+++ b/prisma/seed.ts
@@ -25,7 +25,7 @@ async function main() {
body: {
email: 'luu.dat.tham@gmail.com',
password: 'Th@m!S@m!040390',
- name: 'Sam',
+ name: 'Sam Liu',
role: 'admin',
},
});
@@ -38,8 +38,8 @@ async function main() {
...settingsData,
{
key: admin ? (admin?.user?.id as string) : (mailExists?.id as string),
- value: '{ "language": "en" }',
- description: 'User Settings',
+ value: '{"language": "en"}',
+ description: 'User settings',
relation: 'user',
},
];
@@ -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()
diff --git a/src/components/DisplayBreakLineMessage.tsx b/src/components/DisplayBreakLineMessage.tsx
new file mode 100644
index 0000000..dd1ad6f
--- /dev/null
+++ b/src/components/DisplayBreakLineMessage.tsx
@@ -0,0 +1,9 @@
+const DisplayBreakLineMessage = ({
+ children,
+}: {
+ children: React.ReactNode;
+}) => {
+ return
{children};
+};
+
+export default DisplayBreakLineMessage;
diff --git a/src/components/audit/action-badge.tsx b/src/components/audit/action-badge.tsx
index 6fc5a6a..e2bc009 100644
--- a/src/components/audit/action-badge.tsx
+++ b/src/components/audit/action-badge.tsx
@@ -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) {
diff --git a/src/components/audit/audit-columns.tsx b/src/components/audit/audit-columns.tsx
index ed6341c..299991d 100644
--- a/src/components/audit/audit-columns.tsx
+++ b/src/components/audit/audit-columns.tsx
@@ -1,24 +1,15 @@
import { m } from '@/paraglide/messages';
import { formatters } from '@/utils/formatters';
-import { jsonSupport } from '@/utils/help';
-import { EyeIcon } from '@phosphor-icons/react';
import { ColumnDef } from '@tanstack/react-table';
import { Badge } from '../ui/badge';
-import { Button } from '../ui/button';
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from '../ui/dialog';
-import { Label } from '../ui/label';
-import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
-import ActionBadge, { UserActionType } from './action-badge';
-export const logColumns: ColumnDef[] = [
+import { LOG_ACTION } from '@/types/enum';
+import ActionBadge from './action-badge';
+import ViewDetail from './view-detail-dialog';
+
+export const logColumns: ColumnDef[] = [
{
- accessorKey: 'user.name',
+ accessorFn: (row) => row.user?.name ?? '',
header: m.logs_page_ui_table_header_username(),
meta: {
thClass: 'w-1/6',
@@ -45,14 +36,12 @@ export const logColumns: ColumnDef[] = [
thClass: 'w-1/6',
},
cell: ({ row }) => {
- return (
-
- );
+ return ;
},
},
{
accessorKey: 'createdAt',
- header: m.logs_page_ui_table_header_action(),
+ header: m.logs_page_ui_table_header_create_at(),
meta: {
thClass: 'w-2/6',
},
@@ -72,88 +61,3 @@ export const logColumns: ColumnDef[] = [
),
},
];
-
-type ViewDetailProps = {
- data: AuditLog;
-};
-
-const ViewDetail = ({ data }: ViewDetailProps) => {
- return (
-
- );
-};
diff --git a/src/components/audit/view-detail-dialog.tsx b/src/components/audit/view-detail-dialog.tsx
new file mode 100644
index 0000000..7606448
--- /dev/null
+++ b/src/components/audit/view-detail-dialog.tsx
@@ -0,0 +1,137 @@
+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 { CheckIcon, CopyIcon, EyeIcon } from '@phosphor-icons/react';
+import { Badge } from '../ui/badge';
+import { Button } from '../ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '../ui/dialog';
+import { Label } from '../ui/label';
+import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
+import ActionBadge from './action-badge';
+
+type ViewDetailProps = {
+ data: AuditWithUser;
+};
+
+const ViewDetail = ({ data }: ViewDetailProps) => {
+ const prevent = usePreventAutoFocus();
+ const { isCopied, copyToClipboard } = useCopyToClipboard();
+
+ return (
+
+ );
+};
+
+export default ViewDetail;
diff --git a/src/components/form/admin-ban-user-form.tsx b/src/components/form/admin-ban-user-form.tsx
new file mode 100644
index 0000000..815ba55
--- /dev/null
+++ b/src/components/form/admin-ban-user-form.tsx
@@ -0,0 +1,172 @@
+import { m } from '@/paraglide/messages';
+import { userBanSchema } from '@/service/user.schema';
+import { WarningIcon } from '@phosphor-icons/react';
+import { useForm } from '@tanstack/react-form';
+import { UserWithRole } from 'better-auth/plugins';
+import { Alert, AlertDescription, AlertTitle } from '../ui/alert';
+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';
+import { Textarea } from '../ui/textarea';
+import { useBanContext } from '../user/ban-user-dialog';
+
+type FormProps = {
+ data: UserWithRole;
+};
+
+const BanUserForm = ({ data }: FormProps) => {
+ const { setSubmitData, setOpen, setOpenConfirm } = useBanContext();
+
+ const form = useForm({
+ defaultValues: {
+ id: data.id,
+ banReason: '',
+ banExp: 0,
+ },
+ validators: {
+ onChange: userBanSchema,
+ onSubmit: userBanSchema,
+ },
+ onSubmit: async ({ value }) => {
+ setSubmitData(value);
+ setOpen(false);
+ setTimeout(() => {
+ setOpenConfirm(true);
+ }, 100);
+ },
+ });
+
+ return (
+
+ );
+};
+
+export default BanUserForm;
diff --git a/src/components/form/admin-create-user-form.tsx b/src/components/form/admin-create-user-form.tsx
new file mode 100644
index 0000000..0f8cfd4
--- /dev/null
+++ b/src/components/form/admin-create-user-form.tsx
@@ -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 (
+
+ );
+};
+
+export default AdminCreateUserForm;
diff --git a/src/components/form/admin-set-password-form.tsx b/src/components/form/admin-set-password-form.tsx
new file mode 100644
index 0000000..830b31e
--- /dev/null
+++ b/src/components/form/admin-set-password-form.tsx
@@ -0,0 +1,123 @@
+import { m } from '@/paraglide/messages';
+import { usersQueries } from '@/service/queries';
+import { setUserPassword } from '@/service/user.api';
+import { userSetPasswordSchema } from '@/service/user.schema';
+import { ReturnError } from '@/types/common';
+import { useForm } from '@tanstack/react-form';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { UserWithRole } from 'better-auth/plugins';
+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';
+
+type FormProps = {
+ data: UserWithRole;
+ onSubmit: (open: boolean) => void;
+};
+
+const AdminSetPasswordForm = ({ data, onSubmit }: FormProps) => {
+ const queryClient = useQueryClient();
+
+ const setUserPasswordMutation = useMutation({
+ mutationFn: setUserPassword,
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: [...usersQueries.all, 'list'],
+ });
+ onSubmit(false);
+ toast.success(m.users_page_message_set_password_success(), {
+ richColors: true,
+ });
+ },
+ onError: (error: ReturnError) => {
+ console.error(error);
+ toast.error(m.backend_message({ code: error.code }), {
+ richColors: true,
+ });
+ },
+ });
+
+ const form = useForm({
+ defaultValues: {
+ id: data.id,
+ password: '',
+ },
+ validators: {
+ onSubmit: userSetPasswordSchema,
+ },
+ onSubmit: async ({ value }) => {
+ setUserPasswordMutation.mutate({ data: value });
+ },
+ });
+
+ return (
+
+ );
+};
+
+export default AdminSetPasswordForm;
diff --git a/src/components/form/admin-set-user-role-form.tsx b/src/components/form/admin-set-user-role-form.tsx
new file mode 100644
index 0000000..6f8031e
--- /dev/null
+++ b/src/components/form/admin-set-user-role-form.tsx
@@ -0,0 +1,145 @@
+import { m } from '@/paraglide/messages';
+import { usersQueries } from '@/service/queries';
+import { setUserRole } from '@/service/user.api';
+import { RoleEnum, userUpdateRoleSchema } from '@/service/user.schema';
+import { ReturnError } from '@/types/common';
+import { useForm } from '@tanstack/react-form';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { UserWithRole } from 'better-auth/plugins';
+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 SetRoleFormProps = {
+ data: UserWithRole;
+ onSubmit: (open: boolean) => void;
+};
+
+const AdminSetUserRoleForm = ({ data, onSubmit }: SetRoleFormProps) => {
+ const queryClient = useQueryClient();
+
+ const defaultFormValues = {
+ id: data.id,
+ role: data.role,
+ };
+
+ const updateRoleMutation = useMutation({
+ mutationFn: setUserRole,
+ onSuccess: () => {
+ queryClient.refetchQueries({
+ queryKey: usersQueries.all,
+ });
+ onSubmit(false);
+ toast.success(m.users_page_message_set_role_success(), {
+ richColors: true,
+ });
+ },
+ onError: (error: ReturnError) => {
+ console.error(error);
+ toast.error(m.backend_message({ code: error.code }), {
+ richColors: true,
+ });
+ },
+ });
+
+ const form = useForm({
+ defaultValues: userUpdateRoleSchema.parse(defaultFormValues),
+ validators: {
+ onChange: userUpdateRoleSchema,
+ onSubmit: userUpdateRoleSchema,
+ },
+ onSubmit: async ({ value }) => {
+ updateRoleMutation.mutate({ data: value });
+ },
+ });
+
+ return (
+
+ );
+};
+
+export default AdminSetUserRoleForm;
diff --git a/src/components/form/admin-update-user-info-form.tsx b/src/components/form/admin-update-user-info-form.tsx
new file mode 100644
index 0000000..bf5f4b0
--- /dev/null
+++ b/src/components/form/admin-update-user-info-form.tsx
@@ -0,0 +1,122 @@
+import { m } from '@/paraglide/messages';
+import { usersQueries } from '@/service/queries';
+import { updateUserInformation } from '@/service/user.api';
+import { userUpdateInfoSchema } from '@/service/user.schema';
+import { ReturnError } from '@/types/common';
+import { useForm } from '@tanstack/react-form';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { UserWithRole } from 'better-auth/plugins';
+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';
+
+type UpdateUserFormProps = {
+ data: UserWithRole;
+ onSubmit: (open: boolean) => void;
+};
+
+const AdminUpdateUserInfoForm = ({ data, onSubmit }: UpdateUserFormProps) => {
+ const queryClient = useQueryClient();
+
+ const updateUserMutation = useMutation({
+ mutationFn: updateUserInformation,
+ onSuccess: () => {
+ queryClient.refetchQueries({
+ queryKey: usersQueries.all,
+ });
+ onSubmit(false);
+ toast.success(m.users_page_message_update_info_success(), {
+ richColors: true,
+ });
+ },
+ onError: (error: ReturnError) => {
+ console.error(error);
+ toast.error(m.backend_message({ code: error.code }), {
+ richColors: true,
+ });
+ },
+ });
+ const form = useForm({
+ defaultValues: {
+ id: data.id,
+ name: data.name,
+ },
+ validators: {
+ onChange: userUpdateInfoSchema,
+ },
+ onSubmit: async ({ value }) => {
+ updateUserMutation.mutate({ data: value });
+ },
+ });
+
+ return (
+
+ );
+};
+
+export default AdminUpdateUserInfoForm;
diff --git a/src/components/form/change-password-form.tsx b/src/components/form/change-password-form.tsx
index 05061ce..6ea2ca7 100644
--- a/src/components/form/change-password-form.tsx
+++ b/src/components/form/change-password-form.tsx
@@ -70,15 +70,10 @@ const ChangePasswordForm = () => {
);
},
onError: (ctx) => {
- console.log(ctx.error.code);
- toast.error(
- (
- m[`backend_${ctx.error.code}` as keyof typeof m] as () => string
- )(),
- {
- richColors: true,
- },
- );
+ console.error(ctx.error.code);
+ toast.error(m.backend_message({ code: ctx.error.code }), {
+ richColors: true,
+ });
},
},
);
diff --git a/src/components/form/profile-form.tsx b/src/components/form/profile-form.tsx
index 0012fa0..3f707fc 100644
--- a/src/components/form/profile-form.tsx
+++ b/src/components/form/profile-form.tsx
@@ -66,20 +66,16 @@ const ProfileForm = () => {
});
},
onError: (ctx) => {
- toast.error(
- (
- m[
- `backend_${ctx.error.code}` as keyof typeof m
- ] as () => string
- )(),
- {
- richColors: true,
- },
- );
+ console.error(ctx.error.code);
+ toast.error(m.backend_message({ code: ctx.error.code }), {
+ richColors: true,
+ });
},
},
);
- } catch (error) {}
+ } catch (error) {
+ console.error('update load file', error);
+ }
},
});
diff --git a/src/components/form/settings-form.tsx b/src/components/form/settings-form.tsx
index ba2cd93..4eea632 100644
--- a/src/components/form/settings-form.tsx
+++ b/src/components/form/settings-form.tsx
@@ -2,6 +2,7 @@ import { m } from '@/paraglide/messages';
import { settingQueries } from '@/service/queries';
import { updateAdminSettings } from '@/service/setting.api';
import { settingSchema, SettingsInput } from '@/service/setting.schema';
+import { ReturnError } from '@/types/common';
import { GearIcon } from '@phosphor-icons/react';
import { useForm } from '@tanstack/react-form';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -32,6 +33,12 @@ const SettingsForm = () => {
richColors: true,
});
},
+ onError: (error: ReturnError) => {
+ console.error(error);
+ toast.error(m.backend_message({ code: error.code }), {
+ richColors: true,
+ });
+ },
});
const form = useForm({
diff --git a/src/components/form/signin-form.tsx b/src/components/form/signin-form.tsx
index 724f22c..51e96bd 100644
--- a/src/components/form/signin-form.tsx
+++ b/src/components/form/signin-form.tsx
@@ -51,14 +51,10 @@ const SignInForm = () => {
});
},
onError: (ctx) => {
- toast.error(
- (
- m[`backend_${ctx.error.code}` as keyof typeof m] as () => string
- )(),
- {
- richColors: true,
- },
- );
+ console.error(ctx.error.code);
+ toast.error(m.backend_message({ code: ctx.error.code }), {
+ richColors: true,
+ });
},
},
);
diff --git a/src/components/form/user-settings-form.tsx b/src/components/form/user-settings-form.tsx
index d2f6330..6f5f04a 100644
--- a/src/components/form/user-settings-form.tsx
+++ b/src/components/form/user-settings-form.tsx
@@ -3,6 +3,7 @@ import { Locale, setLocale } from '@/paraglide/runtime';
import { settingQueries } from '@/service/queries';
import { updateUserSettings } from '@/service/setting.api';
import { UserSettingInput, userSettingSchema } from '@/service/setting.schema';
+import { ReturnError } from '@/types/common';
import { GearIcon } from '@phosphor-icons/react';
import { useForm } from '@tanstack/react-form';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -40,6 +41,12 @@ const UserSettingsForm = () => {
richColors: true,
});
},
+ onError: (error: ReturnError) => {
+ console.error(error);
+ toast.error(m.backend_message({ code: error.code }), {
+ richColors: true,
+ });
+ },
});
const form = useForm({
diff --git a/src/components/sidebar/app-sidebar.tsx b/src/components/sidebar/app-sidebar.tsx
index 7208542..ff436b3 100644
--- a/src/components/sidebar/app-sidebar.tsx
+++ b/src/components/sidebar/app-sidebar.tsx
@@ -4,6 +4,7 @@ import {
SidebarFooter,
SidebarHeader,
SidebarMenu,
+ SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { Link } from '@tanstack/react-router';
@@ -16,12 +17,14 @@ const AppSidebar = ({ ...props }: React.ComponentProps) => {
-
-
-
- Fuware
-
-
+
+
+
+
+ Fuware
+
+
+
diff --git a/src/components/sidebar/nav-main.tsx b/src/components/sidebar/nav-main.tsx
index 5ad57a1..2bceb1e 100644
--- a/src/components/sidebar/nav-main.tsx
+++ b/src/components/sidebar/nav-main.tsx
@@ -4,6 +4,7 @@ import {
GaugeIcon,
GearIcon,
HouseIcon,
+ UsersIcon,
} from '@phosphor-icons/react';
import { createLink } from '@tanstack/react-router';
import AdminShow from '../auth/AdminShow';
@@ -45,15 +46,22 @@ const NAV_MAIN = [
title: 'Management',
items: [
{
- title: m.nav_log(),
- path: '/logs',
+ title: m.nav_users(),
+ path: '/kanri/users',
+ icon: UsersIcon,
+ isAuth: false,
+ admin: true,
+ },
+ {
+ title: m.nav_logs(),
+ path: '/kanri/logs',
icon: CircuitryIcon,
isAuth: false,
admin: true,
},
{
title: m.nav_settings(),
- path: '/settings',
+ path: '/kanri/settings',
icon: GearIcon,
isAuth: false,
admin: true,
@@ -66,7 +74,7 @@ const NavMain = () => {
return (
<>
{NAV_MAIN.map((nav) => (
-
+
{nav.title}
@@ -75,6 +83,7 @@ const NavMain = () => {
const Menu = (
{
onSuccess: () => {
navigate({ to: '/' });
queryClient.invalidateQueries({ queryKey: ['auth', 'session'] });
- toast.success(m.login_page_messages_login_success(), {
+ toast.success(m.login_page_messages_logout_success(), {
richColors: true,
});
},
onError: (ctx) => {
- toast.error(
- (
- m[`backend_${ctx.error.code}` as keyof typeof m] as () => string
- )(),
- {
- richColors: true,
- },
- );
+ console.error(ctx.error.code);
+ toast.error(m.backend_message({ code: ctx.error.code }), {
+ richColors: true,
+ });
},
},
});
diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx
new file mode 100644
index 0000000..bb4ad7e
--- /dev/null
+++ b/src/components/ui/alert.tsx
@@ -0,0 +1,78 @@
+import { cva, type VariantProps } from 'class-variance-authority';
+import * as React from 'react';
+
+import { cn } from '@/lib/utils';
+
+const alertVariants = cva(
+ "grid gap-0.5 rounded-lg border px-2 py-1.5 text-left text-xs/relaxed has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-1.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-3.5 w-full relative group/alert",
+ {
+ variants: {
+ variant: {
+ default: 'bg-card text-card-foreground',
+ destructive:
+ 'text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current',
+ warning:
+ 'text-yellow-500 bg-yellow-50/50 *:data-[slot=alert-description]:text-yellow-500',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ },
+);
+
+function Alert({
+ className,
+ variant,
+ ...props
+}: React.ComponentProps<'div'> & VariantProps) {
+ return (
+
+ );
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+ svg]/alert:col-start-2 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3',
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AlertDescription({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function AlertAction({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+export { Alert, AlertAction, AlertDescription, AlertTitle };
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
index 386e33b..d1eed66 100644
--- a/src/components/ui/button.tsx
+++ b/src/components/ui/button.tsx
@@ -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: {
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
index 06183f5..502a1c2 100644
--- a/src/components/ui/dialog.tsx
+++ b/src/components/ui/dialog.tsx
@@ -1,32 +1,32 @@
-import * as React from "react"
-import { Dialog as DialogPrimitive } from "radix-ui"
+import { Dialog as DialogPrimitive } from 'radix-ui';
+import * as React from 'react';
-import { cn } from "@/lib/utils"
-import { Button } from "@/components/ui/button"
-import { XIcon } from "@phosphor-icons/react"
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+import { XIcon } from '@phosphor-icons/react';
function Dialog({
...props
}: React.ComponentProps
) {
- return
+ return ;
}
function DialogTrigger({
...props
}: React.ComponentProps) {
- return
+ return ;
}
function DialogPortal({
...props
}: React.ComponentProps) {
- return
+ return ;
}
function DialogClose({
...props
}: React.ComponentProps) {
- return
+ return ;
}
function DialogOverlay({
@@ -36,10 +36,13 @@ function DialogOverlay({
return (
- )
+ );
}
function DialogContent({
@@ -48,7 +51,7 @@ function DialogContent({
showCloseButton = true,
...props
}: React.ComponentProps & {
- showCloseButton?: boolean
+ showCloseButton?: boolean;
}) {
return (
@@ -56,34 +59,37 @@ function DialogContent({
{children}
{showCloseButton && (
-
)}
- )
+ );
}
-function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
- )
+ );
}
function DialogFooter({
@@ -91,15 +97,15 @@ function DialogFooter({
showCloseButton = false,
children,
...props
-}: React.ComponentProps<"div"> & {
- showCloseButton?: boolean
+}: React.ComponentProps<'div'> & {
+ showCloseButton?: boolean;
}) {
return (
@@ -110,7 +116,7 @@ function DialogFooter({
)}
- )
+ );
}
function DialogTitle({
@@ -120,10 +126,10 @@ function DialogTitle({
return (
- )
+ );
}
function DialogDescription({
@@ -133,10 +139,13 @@ function DialogDescription({
return (
- )
+ );
}
export {
@@ -150,4 +159,4 @@ export {
DialogPortal,
DialogTitle,
DialogTrigger,
-}
+};
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
index 2a8c527..fd4a86e 100644
--- a/src/components/ui/input.tsx
+++ b/src/components/ui/input.tsx
@@ -8,12 +8,12 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
- "bg-input/20 dark:bg-input/30 border-input 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 h-7 rounded-md border px-2 py-0.5 text-sm transition-colors file:h-6 file:text-xs/relaxed file:font-medium focus-visible:ring-[2px] aria-invalid:ring-[2px] md:text-xs/relaxed file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
- className
+ 'bg-input/20 dark:bg-input/30 border-input 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 h-7 rounded-md border px-2 py-0.5 text-sm transition-colors file:h-6 file:text-xs/relaxed file:font-medium focus-visible:ring-2 aria-invalid:ring-2 md:text-xs/relaxed file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
+ className,
)}
{...props}
/>
- )
+ );
}
export { Input }
diff --git a/src/components/ui/search-input.tsx b/src/components/ui/search-input.tsx
new file mode 100644
index 0000000..ba6a540
--- /dev/null
+++ b/src/components/ui/search-input.tsx
@@ -0,0 +1,43 @@
+import { MagnifyingGlassIcon, XIcon } from '@phosphor-icons/react';
+import { Button } from './button';
+import { InputGroup, InputGroupAddon, InputGroupInput } from './input-group';
+
+type SearchInputProps = {
+ keywords: string;
+ setKeyword: (value: string) => void;
+ onChange: (e: React.ChangeEvent) => void;
+};
+
+const SearchInput = ({ keywords, setKeyword, onChange }: SearchInputProps) => {
+ const onClearSearch = () => {
+ setKeyword('');
+ };
+
+ return (
+
+
+
+
+
+
+ {keywords !== '' && (
+
+
+
+ )}
+
+
+ );
+};
+
+export default SearchInput;
diff --git a/src/components/user/add-new-user-dialog.tsx b/src/components/user/add-new-user-dialog.tsx
new file mode 100644
index 0000000..164716a
--- /dev/null
+++ b/src/components/user/add-new-user-dialog.tsx
@@ -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 (
+
+ );
+};
+
+export default AddNewUserButton;
diff --git a/src/components/user/ban-user-confirm-dialog.tsx b/src/components/user/ban-user-confirm-dialog.tsx
new file mode 100644
index 0000000..44aecd6
--- /dev/null
+++ b/src/components/user/ban-user-confirm-dialog.tsx
@@ -0,0 +1,98 @@
+import usePreventAutoFocus from '@/hooks/use-prevent-auto-focus';
+import { m } from '@/paraglide/messages';
+import { usersQueries } from '@/service/queries';
+import { banUser } from '@/service/user.api';
+import { ReturnError } from '@/types/common';
+import { ShieldWarningIcon } from '@phosphor-icons/react';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { UserWithRole } from 'better-auth/plugins';
+import { toast } from 'sonner';
+import DisplayBreakLineMessage from '../DisplayBreakLineMessage';
+import { Button } from '../ui/button';
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '../ui/dialog';
+import { useBanContext } from './ban-user-dialog';
+
+type BanConfirmProps = {
+ data: UserWithRole;
+};
+
+const BanUserConfirm = ({ data }: BanConfirmProps) => {
+ const { openConfirm, setOpenConfirm, submitData } = useBanContext();
+ const queryClient = useQueryClient();
+ const prevent = usePreventAutoFocus();
+
+ const { mutate: banUserMutation } = useMutation({
+ mutationFn: banUser,
+ onSuccess: () => {
+ queryClient.refetchQueries({
+ queryKey: usersQueries.all,
+ });
+ setOpenConfirm(false);
+ toast.success(m.users_page_message_banned_success({ name: data.name }), {
+ richColors: true,
+ });
+ },
+ onError: (error: ReturnError) => {
+ console.error(error);
+ toast.error(m.backend_message({ code: error.code }), {
+ richColors: true,
+ });
+ },
+ });
+
+ const onConfirm = () => {
+ banUserMutation({ data: submitData });
+ };
+
+ return (
+
+ );
+};
+
+export default BanUserConfirm;
diff --git a/src/components/user/ban-user-dialog.tsx b/src/components/user/ban-user-dialog.tsx
new file mode 100644
index 0000000..bde71b7
--- /dev/null
+++ b/src/components/user/ban-user-dialog.tsx
@@ -0,0 +1,113 @@
+import usePreventAutoFocus from '@/hooks/use-prevent-auto-focus';
+import { m } from '@/paraglide/messages';
+import { LockIcon } from '@phosphor-icons/react';
+import { UserWithRole } from 'better-auth/plugins';
+import { createContext, useContext, useState } from 'react';
+import BanUserForm from '../form/admin-ban-user-form';
+import { Button } from '../ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '../ui/dialog';
+import { Label } from '../ui/label';
+import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
+import BanUserConfirm from './ban-user-confirm-dialog';
+
+type ChangeUserStatusProps = {
+ data: UserWithRole;
+};
+
+type SubmitValue = {
+ id: string;
+ banReason: string;
+ banExp: number;
+};
+
+type BanContextProps = {
+ open: boolean;
+ setOpen: React.Dispatch>;
+ openConfirm: boolean;
+ setOpenConfirm: React.Dispatch>;
+ submitData: SubmitValue;
+ setSubmitData: React.Dispatch>;
+};
+
+const BanContext = createContext(null);
+
+const BanUserAction = ({ data }: ChangeUserStatusProps) => {
+ const [_open, _setOpen] = useState(false);
+ const [_openConfirm, _setOpenConfirm] = useState(false);
+ const [_confirmData, _setConfirmData] = useState({
+ id: '',
+ banReason: '',
+ banExp: 0,
+ });
+ const prevent = usePreventAutoFocus();
+
+ return (
+
+
+
+
+ );
+};
+
+export default BanUserAction;
+
+export function useBanContext() {
+ const context = useContext(BanContext);
+
+ if (!context) {
+ throw new Error('SHOULD_HAVE_PROVIDER');
+ }
+
+ return context;
+}
diff --git a/src/components/user/change-role-dialog.tsx b/src/components/user/change-role-dialog.tsx
new file mode 100644
index 0000000..374fa11
--- /dev/null
+++ b/src/components/user/change-role-dialog.tsx
@@ -0,0 +1,67 @@
+import usePreventAutoFocus from '@/hooks/use-prevent-auto-focus';
+import { m } from '@/paraglide/messages';
+import { UserGearIcon } from '@phosphor-icons/react';
+import { UserWithRole } from 'better-auth/plugins';
+import { useState } from 'react';
+import AdminSetUserRoleForm from '../form/admin-set-user-role-form';
+import { Button } from '../ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '../ui/dialog';
+import { Label } from '../ui/label';
+import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
+
+type SetRoleProps = {
+ data: UserWithRole;
+};
+
+const ChangeRoleAction = ({ data }: SetRoleProps) => {
+ const [_open, _setOpen] = useState(false);
+ const prevent = usePreventAutoFocus();
+
+ return (
+
+ );
+};
+
+export default ChangeRoleAction;
diff --git a/src/components/user/edit-user-dialog.tsx b/src/components/user/edit-user-dialog.tsx
new file mode 100644
index 0000000..b44abd3
--- /dev/null
+++ b/src/components/user/edit-user-dialog.tsx
@@ -0,0 +1,66 @@
+import usePreventAutoFocus from '@/hooks/use-prevent-auto-focus';
+import { m } from '@/paraglide/messages';
+import { PenIcon } from '@phosphor-icons/react';
+import { UserWithRole } from 'better-auth/plugins';
+import { useState } from 'react';
+import AdminUpdateUserInfoForm from '../form/admin-update-user-info-form';
+import { Button } from '../ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '../ui/dialog';
+import { Label } from '../ui/label';
+import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
+
+type EditUserProps = {
+ data: UserWithRole;
+};
+
+const EditUserAction = ({ data }: EditUserProps) => {
+ const [_open, _setOpen] = useState(false);
+ const prevent = usePreventAutoFocus();
+
+ return (
+
+ );
+};
+
+export default EditUserAction;
diff --git a/src/components/user/set-password-dialog.tsx b/src/components/user/set-password-dialog.tsx
new file mode 100644
index 0000000..d4c618e
--- /dev/null
+++ b/src/components/user/set-password-dialog.tsx
@@ -0,0 +1,67 @@
+import usePreventAutoFocus from '@/hooks/use-prevent-auto-focus';
+import { m } from '@/paraglide/messages';
+import { KeyIcon } from '@phosphor-icons/react';
+import { UserWithRole } from 'better-auth/plugins';
+import { useState } from 'react';
+import AdminSetPasswordForm from '../form/admin-set-password-form';
+import { Button } from '../ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '../ui/dialog';
+import { Label } from '../ui/label';
+import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
+
+type UpdatePasswordProps = {
+ data: UserWithRole;
+};
+
+const SetPasswordAction = ({ data }: UpdatePasswordProps) => {
+ const [_open, _setOpen] = useState(false);
+ const prevent = usePreventAutoFocus();
+
+ return (
+
+ );
+};
+
+export default SetPasswordAction;
diff --git a/src/components/user/unban-user-dialog.tsx b/src/components/user/unban-user-dialog.tsx
new file mode 100644
index 0000000..1ce57b4
--- /dev/null
+++ b/src/components/user/unban-user-dialog.tsx
@@ -0,0 +1,119 @@
+import usePreventAutoFocus from '@/hooks/use-prevent-auto-focus';
+import { m } from '@/paraglide/messages';
+import { usersQueries } from '@/service/queries';
+import { unbanUser } from '@/service/user.api';
+import { ReturnError } from '@/types/common';
+import { LockOpenIcon, ShieldWarningIcon } from '@phosphor-icons/react';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { UserWithRole } from 'better-auth/plugins';
+import { useState } from 'react';
+import { toast } from 'sonner';
+import DisplayBreakLineMessage from '../DisplayBreakLineMessage';
+import { Button } from '../ui/button';
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '../ui/dialog';
+import { Label } from '../ui/label';
+import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
+
+type UnbanUserProps = {
+ data: UserWithRole;
+};
+
+const UnbanUserAction = ({ data }: UnbanUserProps) => {
+ const queryClient = useQueryClient();
+
+ const [_open, _setOpen] = useState(false);
+ const prevent = usePreventAutoFocus();
+
+ const { mutate: unbanMutation } = useMutation({
+ mutationFn: unbanUser,
+ onSuccess: () => {
+ queryClient.refetchQueries({
+ queryKey: usersQueries.all,
+ });
+ _setOpen(false);
+ toast.success(
+ m.users_page_message_unbanned_success({ name: data.name }),
+ {
+ richColors: true,
+ },
+ );
+ },
+ onError: (error: ReturnError) => {
+ console.error(error);
+ toast.error(m.backend_message({ code: error.code }), {
+ richColors: true,
+ });
+ },
+ });
+
+ const onConfirm = () => {
+ unbanMutation({ data: { id: data.id } });
+ };
+
+ return (
+
+ );
+};
+
+export default UnbanUserAction;
diff --git a/src/components/user/user-column.tsx b/src/components/user/user-column.tsx
new file mode 100644
index 0000000..9e796c4
--- /dev/null
+++ b/src/components/user/user-column.tsx
@@ -0,0 +1,79 @@
+import { m } from '@/paraglide/messages';
+import { formatters } from '@/utils/formatters';
+import { CheckCircleIcon, XCircleIcon } from '@phosphor-icons/react';
+import { ColumnDef } from '@tanstack/react-table';
+import { UserWithRole } from 'better-auth/plugins';
+import RoleBadge from '../avatar/role-badge';
+import BanUserAction from './ban-user-dialog';
+import ChangeRoleAction from './change-role-dialog';
+import EditUserAction from './edit-user-dialog';
+import SetPasswordAction from './set-password-dialog';
+import UnbanUserAction from './unban-user-dialog';
+
+export const userColumns: ColumnDef[] = [
+ {
+ accessorKey: 'name',
+ header: m.users_page_ui_table_header_name(),
+ meta: {
+ thClass: 'w-1/6',
+ },
+ },
+ {
+ accessorKey: 'email',
+ header: m.users_page_ui_table_header_email(),
+ meta: {
+ thClass: 'w-1/6',
+ },
+ },
+ {
+ accessorKey: 'role',
+ header: m.users_page_ui_table_header_role(),
+ meta: {
+ thClass: 'w-1/6',
+ },
+ cell: ({ row }) => ,
+ },
+ {
+ accessorKey: 'banned',
+ header: m.users_page_ui_table_header_banned(),
+ meta: {
+ thClass: 'w-1/6',
+ },
+ cell: ({ row }) =>
+ row.original.banned ? (
+
+ ) : (
+
+ ),
+ },
+ {
+ accessorKey: 'createdAt',
+ header: m.users_page_ui_table_header_created_at(),
+ meta: {
+ thClass: 'w-1/6',
+ },
+ cell: ({ row }) => {
+ return formatters.dateTime(new Date(row.original.createdAt));
+ },
+ },
+ {
+ id: 'actions',
+ meta: {
+ thClass: 'w-1/6',
+ },
+ cell: ({ row }) => {
+ return (
+
+
+
+
+ {row.original.banned ? (
+
+ ) : (
+
+ )}
+
+ );
+ },
+ },
+];
diff --git a/src/hooks/use-copy-to-clipboard.ts b/src/hooks/use-copy-to-clipboard.ts
new file mode 100644
index 0000000..09c4d1f
--- /dev/null
+++ b/src/hooks/use-copy-to-clipboard.ts
@@ -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 };
+}
diff --git a/src/hooks/use-prevent-auto-focus.ts b/src/hooks/use-prevent-auto-focus.ts
new file mode 100644
index 0000000..3585d00
--- /dev/null
+++ b/src/hooks/use-prevent-auto-focus.ts
@@ -0,0 +1,14 @@
+import { useCallback, useRef } from 'react';
+
+function usePreventAutoFocus() {
+ const ref = useRef(null);
+
+ const onOpenAutoFocus = useCallback((event: Event) => {
+ event.preventDefault();
+ ref.current?.focus({ preventScroll: true });
+ }, []);
+
+ return { ref, onOpenAutoFocus, tabIndex: -1 as const };
+}
+
+export default usePreventAutoFocus;
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index effdf16..13c5dbb 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -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: '',
diff --git a/src/lib/auth/permissions.ts b/src/lib/auth/permissions.ts
index a438a93..bb2d8f9 100644
--- a/src/lib/auth/permissions.ts
+++ b/src/lib/auth/permissions.ts
@@ -1,29 +1,29 @@
-import { defaultStatements, adminAc } from 'better-auth/plugins/admin/access'
-import { createAccessControl } from 'better-auth/plugins/access'
+import { createAccessControl } from 'better-auth/plugins/access';
+import { adminAc, defaultStatements } from 'better-auth/plugins/admin/access';
const statement = {
...defaultStatements,
audit: ['list'],
- setting: ['list', 'create', 'update', 'delete'],
+ setting: ['list', 'update'],
house: ['list', 'create', 'update', 'delete'],
box: ['list', 'create', 'update', 'delete'],
item: ['list', 'create', 'update', 'delete'],
-} as const
+} as const;
-const ac = createAccessControl(statement)
+const ac = createAccessControl(statement);
const admin = ac.newRole({
...adminAc.statements,
audit: ['list'],
- setting: ['list', 'create', 'update', 'delete'],
+ setting: ['list', 'update'],
house: ['list', 'create', 'update', 'delete'],
box: ['list', 'create', 'update', 'delete'],
item: ['list', 'create', 'update', 'delete'],
-})
+});
const user = ac.newRole({
setting: ['list', 'update'],
house: ['list', 'create', 'update', 'delete'],
-})
+});
-export { ac, admin, user }
+export { ac, admin, user };
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
index e03e384..6695d4d 100644
--- a/src/routeTree.gen.ts
+++ b/src/routeTree.gen.ts
@@ -15,11 +15,14 @@ import { Route as authSignUpRouteImport } from './routes/(auth)/sign-up'
import { Route as authSignInRouteImport } from './routes/(auth)/sign-in'
import { Route as appauthRouteRouteImport } from './routes/(app)/(auth)/route'
import { Route as ApiAuthSplatRouteImport } from './routes/api.auth.$'
-import { Route as appauthSettingsRouteImport } from './routes/(app)/(auth)/settings'
-import { Route as appauthLogsRouteImport } from './routes/(app)/(auth)/logs'
import { Route as appauthDashboardRouteImport } from './routes/(app)/(auth)/dashboard'
+import { Route as appauthKanriRouteRouteImport } from './routes/(app)/(auth)/kanri/route'
import { Route as appauthAccountRouteRouteImport } from './routes/(app)/(auth)/account/route'
+import { Route as appauthKanriIndexRouteImport } from './routes/(app)/(auth)/kanri/index'
import { Route as appauthAccountIndexRouteImport } from './routes/(app)/(auth)/account/index'
+import { Route as appauthKanriUsersRouteImport } from './routes/(app)/(auth)/kanri/users'
+import { Route as appauthKanriSettingsRouteImport } from './routes/(app)/(auth)/kanri/settings'
+import { Route as appauthKanriLogsRouteImport } from './routes/(app)/(auth)/kanri/logs'
import { Route as appauthAccountSettingsRouteImport } from './routes/(app)/(auth)/account/settings'
import { Route as appauthAccountProfileRouteImport } from './routes/(app)/(auth)/account/profile'
import { Route as appauthAccountChangePasswordRouteImport } from './routes/(app)/(auth)/account/change-password'
@@ -52,31 +55,46 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
path: '/api/auth/$',
getParentRoute: () => rootRouteImport,
} as any)
-const appauthSettingsRoute = appauthSettingsRouteImport.update({
- id: '/settings',
- path: '/settings',
- getParentRoute: () => appauthRouteRoute,
-} as any)
-const appauthLogsRoute = appauthLogsRouteImport.update({
- id: '/logs',
- path: '/logs',
- getParentRoute: () => appauthRouteRoute,
-} as any)
const appauthDashboardRoute = appauthDashboardRouteImport.update({
id: '/dashboard',
path: '/dashboard',
getParentRoute: () => appauthRouteRoute,
} as any)
+const appauthKanriRouteRoute = appauthKanriRouteRouteImport.update({
+ id: '/kanri',
+ path: '/kanri',
+ getParentRoute: () => appauthRouteRoute,
+} as any)
const appauthAccountRouteRoute = appauthAccountRouteRouteImport.update({
id: '/account',
path: '/account',
getParentRoute: () => appauthRouteRoute,
} as any)
+const appauthKanriIndexRoute = appauthKanriIndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => appauthKanriRouteRoute,
+} as any)
const appauthAccountIndexRoute = appauthAccountIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => appauthAccountRouteRoute,
} as any)
+const appauthKanriUsersRoute = appauthKanriUsersRouteImport.update({
+ id: '/users',
+ path: '/users',
+ getParentRoute: () => appauthKanriRouteRoute,
+} as any)
+const appauthKanriSettingsRoute = appauthKanriSettingsRouteImport.update({
+ id: '/settings',
+ path: '/settings',
+ getParentRoute: () => appauthKanriRouteRoute,
+} as any)
+const appauthKanriLogsRoute = appauthKanriLogsRouteImport.update({
+ id: '/logs',
+ path: '/logs',
+ getParentRoute: () => appauthKanriRouteRoute,
+} as any)
const appauthAccountSettingsRoute = appauthAccountSettingsRouteImport.update({
id: '/settings',
path: '/settings',
@@ -99,27 +117,32 @@ export interface FileRoutesByFullPath {
'/sign-up': typeof authSignUpRoute
'/': typeof appIndexRoute
'/account': typeof appauthAccountRouteRouteWithChildren
+ '/kanri': typeof appauthKanriRouteRouteWithChildren
'/dashboard': typeof appauthDashboardRoute
- '/logs': typeof appauthLogsRoute
- '/settings': typeof appauthSettingsRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/account/change-password': typeof appauthAccountChangePasswordRoute
'/account/profile': typeof appauthAccountProfileRoute
'/account/settings': typeof appauthAccountSettingsRoute
+ '/kanri/logs': typeof appauthKanriLogsRoute
+ '/kanri/settings': typeof appauthKanriSettingsRoute
+ '/kanri/users': typeof appauthKanriUsersRoute
'/account/': typeof appauthAccountIndexRoute
+ '/kanri/': typeof appauthKanriIndexRoute
}
export interface FileRoutesByTo {
'/sign-in': typeof authSignInRoute
'/sign-up': typeof authSignUpRoute
'/': typeof appIndexRoute
'/dashboard': typeof appauthDashboardRoute
- '/logs': typeof appauthLogsRoute
- '/settings': typeof appauthSettingsRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/account/change-password': typeof appauthAccountChangePasswordRoute
'/account/profile': typeof appauthAccountProfileRoute
'/account/settings': typeof appauthAccountSettingsRoute
+ '/kanri/logs': typeof appauthKanriLogsRoute
+ '/kanri/settings': typeof appauthKanriSettingsRoute
+ '/kanri/users': typeof appauthKanriUsersRoute
'/account': typeof appauthAccountIndexRoute
+ '/kanri': typeof appauthKanriIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -129,14 +152,17 @@ export interface FileRoutesById {
'/(auth)/sign-up': typeof authSignUpRoute
'/(app)/': typeof appIndexRoute
'/(app)/(auth)/account': typeof appauthAccountRouteRouteWithChildren
+ '/(app)/(auth)/kanri': typeof appauthKanriRouteRouteWithChildren
'/(app)/(auth)/dashboard': typeof appauthDashboardRoute
- '/(app)/(auth)/logs': typeof appauthLogsRoute
- '/(app)/(auth)/settings': typeof appauthSettingsRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/(app)/(auth)/account/change-password': typeof appauthAccountChangePasswordRoute
'/(app)/(auth)/account/profile': typeof appauthAccountProfileRoute
'/(app)/(auth)/account/settings': typeof appauthAccountSettingsRoute
+ '/(app)/(auth)/kanri/logs': typeof appauthKanriLogsRoute
+ '/(app)/(auth)/kanri/settings': typeof appauthKanriSettingsRoute
+ '/(app)/(auth)/kanri/users': typeof appauthKanriUsersRoute
'/(app)/(auth)/account/': typeof appauthAccountIndexRoute
+ '/(app)/(auth)/kanri/': typeof appauthKanriIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -145,27 +171,32 @@ export interface FileRouteTypes {
| '/sign-up'
| '/'
| '/account'
+ | '/kanri'
| '/dashboard'
- | '/logs'
- | '/settings'
| '/api/auth/$'
| '/account/change-password'
| '/account/profile'
| '/account/settings'
+ | '/kanri/logs'
+ | '/kanri/settings'
+ | '/kanri/users'
| '/account/'
+ | '/kanri/'
fileRoutesByTo: FileRoutesByTo
to:
| '/sign-in'
| '/sign-up'
| '/'
| '/dashboard'
- | '/logs'
- | '/settings'
| '/api/auth/$'
| '/account/change-password'
| '/account/profile'
| '/account/settings'
+ | '/kanri/logs'
+ | '/kanri/settings'
+ | '/kanri/users'
| '/account'
+ | '/kanri'
id:
| '__root__'
| '/(app)'
@@ -174,14 +205,17 @@ export interface FileRouteTypes {
| '/(auth)/sign-up'
| '/(app)/'
| '/(app)/(auth)/account'
+ | '/(app)/(auth)/kanri'
| '/(app)/(auth)/dashboard'
- | '/(app)/(auth)/logs'
- | '/(app)/(auth)/settings'
| '/api/auth/$'
| '/(app)/(auth)/account/change-password'
| '/(app)/(auth)/account/profile'
| '/(app)/(auth)/account/settings'
+ | '/(app)/(auth)/kanri/logs'
+ | '/(app)/(auth)/kanri/settings'
+ | '/(app)/(auth)/kanri/users'
| '/(app)/(auth)/account/'
+ | '/(app)/(auth)/kanri/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -235,20 +269,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiAuthSplatRouteImport
parentRoute: typeof rootRouteImport
}
- '/(app)/(auth)/settings': {
- id: '/(app)/(auth)/settings'
- path: '/settings'
- fullPath: '/settings'
- preLoaderRoute: typeof appauthSettingsRouteImport
- parentRoute: typeof appauthRouteRoute
- }
- '/(app)/(auth)/logs': {
- id: '/(app)/(auth)/logs'
- path: '/logs'
- fullPath: '/logs'
- preLoaderRoute: typeof appauthLogsRouteImport
- parentRoute: typeof appauthRouteRoute
- }
'/(app)/(auth)/dashboard': {
id: '/(app)/(auth)/dashboard'
path: '/dashboard'
@@ -256,6 +276,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof appauthDashboardRouteImport
parentRoute: typeof appauthRouteRoute
}
+ '/(app)/(auth)/kanri': {
+ id: '/(app)/(auth)/kanri'
+ path: '/kanri'
+ fullPath: '/kanri'
+ preLoaderRoute: typeof appauthKanriRouteRouteImport
+ parentRoute: typeof appauthRouteRoute
+ }
'/(app)/(auth)/account': {
id: '/(app)/(auth)/account'
path: '/account'
@@ -263,6 +290,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof appauthAccountRouteRouteImport
parentRoute: typeof appauthRouteRoute
}
+ '/(app)/(auth)/kanri/': {
+ id: '/(app)/(auth)/kanri/'
+ path: '/'
+ fullPath: '/kanri/'
+ preLoaderRoute: typeof appauthKanriIndexRouteImport
+ parentRoute: typeof appauthKanriRouteRoute
+ }
'/(app)/(auth)/account/': {
id: '/(app)/(auth)/account/'
path: '/'
@@ -270,6 +304,27 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof appauthAccountIndexRouteImport
parentRoute: typeof appauthAccountRouteRoute
}
+ '/(app)/(auth)/kanri/users': {
+ id: '/(app)/(auth)/kanri/users'
+ path: '/users'
+ fullPath: '/kanri/users'
+ preLoaderRoute: typeof appauthKanriUsersRouteImport
+ parentRoute: typeof appauthKanriRouteRoute
+ }
+ '/(app)/(auth)/kanri/settings': {
+ id: '/(app)/(auth)/kanri/settings'
+ path: '/settings'
+ fullPath: '/kanri/settings'
+ preLoaderRoute: typeof appauthKanriSettingsRouteImport
+ parentRoute: typeof appauthKanriRouteRoute
+ }
+ '/(app)/(auth)/kanri/logs': {
+ id: '/(app)/(auth)/kanri/logs'
+ path: '/logs'
+ fullPath: '/kanri/logs'
+ preLoaderRoute: typeof appauthKanriLogsRouteImport
+ parentRoute: typeof appauthKanriRouteRoute
+ }
'/(app)/(auth)/account/settings': {
id: '/(app)/(auth)/account/settings'
path: '/settings'
@@ -311,18 +366,33 @@ const appauthAccountRouteRouteChildren: appauthAccountRouteRouteChildren = {
const appauthAccountRouteRouteWithChildren =
appauthAccountRouteRoute._addFileChildren(appauthAccountRouteRouteChildren)
+interface appauthKanriRouteRouteChildren {
+ appauthKanriLogsRoute: typeof appauthKanriLogsRoute
+ appauthKanriSettingsRoute: typeof appauthKanriSettingsRoute
+ appauthKanriUsersRoute: typeof appauthKanriUsersRoute
+ appauthKanriIndexRoute: typeof appauthKanriIndexRoute
+}
+
+const appauthKanriRouteRouteChildren: appauthKanriRouteRouteChildren = {
+ appauthKanriLogsRoute: appauthKanriLogsRoute,
+ appauthKanriSettingsRoute: appauthKanriSettingsRoute,
+ appauthKanriUsersRoute: appauthKanriUsersRoute,
+ appauthKanriIndexRoute: appauthKanriIndexRoute,
+}
+
+const appauthKanriRouteRouteWithChildren =
+ appauthKanriRouteRoute._addFileChildren(appauthKanriRouteRouteChildren)
+
interface appauthRouteRouteChildren {
appauthAccountRouteRoute: typeof appauthAccountRouteRouteWithChildren
+ appauthKanriRouteRoute: typeof appauthKanriRouteRouteWithChildren
appauthDashboardRoute: typeof appauthDashboardRoute
- appauthLogsRoute: typeof appauthLogsRoute
- appauthSettingsRoute: typeof appauthSettingsRoute
}
const appauthRouteRouteChildren: appauthRouteRouteChildren = {
appauthAccountRouteRoute: appauthAccountRouteRouteWithChildren,
+ appauthKanriRouteRoute: appauthKanriRouteRouteWithChildren,
appauthDashboardRoute: appauthDashboardRoute,
- appauthLogsRoute: appauthLogsRoute,
- appauthSettingsRoute: appauthSettingsRoute,
}
const appauthRouteRouteWithChildren = appauthRouteRoute._addFileChildren(
diff --git a/src/routes/(app)/(auth)/account/index.tsx b/src/routes/(app)/(auth)/account/index.tsx
index d88cfb5..353b94b 100644
--- a/src/routes/(app)/(auth)/account/index.tsx
+++ b/src/routes/(app)/(auth)/account/index.tsx
@@ -1,9 +1,7 @@
-import { createFileRoute } from '@tanstack/react-router';
+import { createFileRoute, redirect } from '@tanstack/react-router';
export const Route = createFileRoute('/(app)/(auth)/account/')({
- component: RouteComponent,
+ beforeLoad: () => {
+ throw redirect({ to: '/' });
+ },
});
-
-function RouteComponent() {
- return Hello "/(app)/(auth)/account/"!
;
-}
diff --git a/src/routes/(app)/(auth)/kanri/index.tsx b/src/routes/(app)/(auth)/kanri/index.tsx
new file mode 100644
index 0000000..b361fc3
--- /dev/null
+++ b/src/routes/(app)/(auth)/kanri/index.tsx
@@ -0,0 +1,15 @@
+import { createFileRoute } from '@tanstack/react-router';
+
+export const Route = createFileRoute('/(app)/(auth)/kanri/')({
+ component: RouteComponent,
+});
+
+function RouteComponent() {
+ return (
+
+ );
+}
diff --git a/src/routes/(app)/(auth)/logs.tsx b/src/routes/(app)/(auth)/kanri/logs.tsx
similarity index 64%
rename from src/routes/(app)/(auth)/logs.tsx
rename to src/routes/(app)/(auth)/kanri/logs.tsx
index 59a3c3b..fc29803 100644
--- a/src/routes/(app)/(auth)/logs.tsx
+++ b/src/routes/(app)/(auth)/kanri/logs.tsx
@@ -1,28 +1,19 @@
import { logColumns } from '@/components/audit/audit-columns';
import DataTable from '@/components/DataTable';
-import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardTitle } from '@/components/ui/card';
-import {
- InputGroup,
- InputGroupAddon,
- InputGroupInput,
-} from '@/components/ui/input-group';
+import SearchInput from '@/components/ui/search-input';
import { Skeleton } from '@/components/ui/skeleton';
import useDebounced from '@/hooks/use-debounced';
import { m } from '@/paraglide/messages';
import { auditQueries } from '@/service/queries';
-import {
- CircuitryIcon,
- MagnifyingGlassIcon,
- XIcon,
-} from '@phosphor-icons/react';
+import { CircuitryIcon } from '@phosphor-icons/react';
import { useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { useState } from 'react';
-export const Route = createFileRoute('/(app)/(auth)/logs')({
+export const Route = createFileRoute('/(app)/(auth)/kanri/logs')({
component: RouteComponent,
- staticData: { breadcrumb: () => m.nav_log() },
+ staticData: { breadcrumb: () => m.nav_logs() },
});
function RouteComponent() {
@@ -42,9 +33,6 @@ function RouteComponent() {
setSearchKeyword(e.target.value);
setPage(1);
};
- const onClearSearch = () => {
- setSearchKeyword('');
- };
if (isLoading) {
return (
@@ -68,32 +56,14 @@ function RouteComponent() {
-
-
-
-
-
-
-
- {searchKeyword !== '' && (
-
-
-
- )}
-
-
+
+
- {data && (
+ {data?.result && (
{
+ if (!context.session?.user.role && context.session?.user.role !== 'admin') {
+ throw redirect({ to: '/' });
+ }
+ },
+ staticData: { breadcrumb: 'Kanri' },
+});
diff --git a/src/routes/(app)/(auth)/settings.tsx b/src/routes/(app)/(auth)/kanri/settings.tsx
similarity index 90%
rename from src/routes/(app)/(auth)/settings.tsx
rename to src/routes/(app)/(auth)/kanri/settings.tsx
index 82685c9..9854706 100644
--- a/src/routes/(app)/(auth)/settings.tsx
+++ b/src/routes/(app)/(auth)/kanri/settings.tsx
@@ -2,7 +2,7 @@ import SettingsForm from '@/components/form/settings-form';
import { m } from '@/paraglide/messages';
import { createFileRoute } from '@tanstack/react-router';
-export const Route = createFileRoute('/(app)/(auth)/settings')({
+export const Route = createFileRoute('/(app)/(auth)/kanri/settings')({
component: RouteComponent,
staticData: { breadcrumb: () => m.nav_settings() },
});
diff --git a/src/routes/(app)/(auth)/kanri/users.tsx b/src/routes/(app)/(auth)/kanri/users.tsx
new file mode 100644
index 0000000..e1c5111
--- /dev/null
+++ b/src/routes/(app)/(auth)/kanri/users.tsx
@@ -0,0 +1,83 @@
+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';
+import { usersQueries } from '@/service/queries';
+import { UsersIcon } from '@phosphor-icons/react';
+import { useQuery } from '@tanstack/react-query';
+import { createFileRoute } from '@tanstack/react-router';
+import { useState } from 'react';
+
+export const Route = createFileRoute('/(app)/(auth)/kanri/users')({
+ component: RouteComponent,
+ staticData: { breadcrumb: () => m.nav_users() },
+});
+
+function RouteComponent() {
+ const [page, setPage] = useState(1);
+ const [pageLimit, setPageLimit] = useState(10);
+ const [searchKeyword, setSearchKeyword] = useState('');
+ const debouncedSearch = useDebounced(searchKeyword, 500);
+
+ const { data, isLoading } = useQuery(
+ usersQueries.list({
+ page,
+ limit: pageLimit,
+ keyword: debouncedSearch,
+ }),
+ );
+
+ const onSearchChange = (e: React.ChangeEvent) => {
+ setSearchKeyword(e.target.value);
+ setPage(1);
+ };
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ {m.users_page_ui_title()}
+
+
+
+
+ {data && (
+
+ )}
+
+
+ );
+}
diff --git a/src/service/audit.api.ts b/src/service/audit.api.ts
index e0192f4..6e00cbb 100644
--- a/src/service/audit.api.ts
+++ b/src/service/audit.api.ts
@@ -1,5 +1,5 @@
import { prisma } from '@/db';
-import { AuditModel, AuditWhereInput } from '@/generated/prisma/models';
+import { AuditWhereInput } from '@/generated/prisma/models';
import { authMiddleware } from '@/lib/middleware';
import { createServerFn } from '@tanstack/react-start';
import { auditListSchema } from './audit.schema';
@@ -33,7 +33,7 @@ export const getAllAudit = createServerFn({ method: 'GET' })
},
],
};
- const [auditlog, total]: [AuditModel[], number] = await Promise.all([
+ const [auditlog, total]: [AuditWithUser[], number] = await Promise.all([
await prisma.audit.findMany({
where,
orderBy: { createdAt: 'desc' },
diff --git a/src/service/audit.schema.ts b/src/service/audit.schema.ts
index d5e1d62..c4c8092 100644
--- a/src/service/audit.schema.ts
+++ b/src/service/audit.schema.ts
@@ -5,5 +5,3 @@ export const auditListSchema = z.object({
limit: z.coerce.number().min(10).max(100).default(10),
keyword: z.string().optional(),
});
-
-export const auditSchema = z.object({});
diff --git a/src/service/queries.ts b/src/service/queries.ts
index 590ccce..2a7a627 100644
--- a/src/service/queries.ts
+++ b/src/service/queries.ts
@@ -2,6 +2,7 @@ import { getSession } from '@/lib/auth/session';
import { queryOptions } from '@tanstack/react-query';
import { getAllAudit } from './audit.api';
import { getAdminSettings, getUserSettings } from './setting.api';
+import { getAllUser } from './user.api';
export const sessionQueries = {
all: ['auth'],
@@ -36,3 +37,12 @@ export const auditQueries = {
queryFn: () => getAllAudit({ data: params }),
}),
};
+
+export const usersQueries = {
+ all: ['users'],
+ list: (params: { page: number; limit: number; keyword?: string }) =>
+ queryOptions({
+ queryKey: [...usersQueries.all, 'list', params],
+ queryFn: () => getAllUser({ data: params }),
+ }),
+};
diff --git a/src/service/repository.ts b/src/service/repository.ts
index 509c275..3981060 100644
--- a/src/service/repository.ts
+++ b/src/service/repository.ts
@@ -1,5 +1,44 @@
import { prisma } from '@/db';
-import { Audit } from '@/generated/prisma/client';
+import { Audit, Setting } from '@/generated/prisma/client';
+
+type AdminSettingValue = Pick;
+
+type AdminSettingValueOnly = {
+ [key: string]: string;
+};
+
+type AdminSettingFull = {
+ [key: string]: AdminSettingValue;
+};
+
+export async function getAllAdminSettings(
+ valueOnly: true,
+): Promise;
+
+export async function getAllAdminSettings(
+ valueOnly?: false,
+): Promise;
+
+export async function getAllAdminSettings(valueOnly = false) {
+ const settings = await prisma.setting.findMany({
+ where: {
+ relation: 'admin',
+ },
+ select: {
+ id: true,
+ key: true,
+ value: true,
+ },
+ });
+
+ const results: Record = {};
+
+ settings.forEach((setting) => {
+ results[setting.key] = valueOnly ? setting.value : setting;
+ });
+
+ return results;
+}
export const createAuditLog = async (data: Omit) => {
try {
diff --git a/src/service/setting.api.ts b/src/service/setting.api.ts
index b01fa13..afefad5 100644
--- a/src/service/setting.api.ts
+++ b/src/service/setting.api.ts
@@ -1,36 +1,10 @@
import { prisma } from '@/db';
-import { Setting } from '@/generated/prisma/client';
import { authMiddleware } from '@/lib/middleware';
-import { extractDiffObjects } from '@/utils/help';
+import { extractDiffObjects } from '@/utils/helper';
import { createServerFn } from '@tanstack/react-start';
-import { createAuditLog } from './repository';
+import { createAuditLog, getAllAdminSettings } from './repository';
import { settingSchema, userSettingSchema } from './setting.schema';
-type AdminSettingReturn = {
- [key: string]: Pick | string;
-};
-
-async function getAllAdminSettings(valueOnly = false) {
- const settings = await prisma.setting.findMany({
- where: {
- relation: 'admin',
- },
- select: {
- id: true,
- key: true,
- value: true,
- },
- });
-
- const results: AdminSettingReturn = {};
-
- settings.forEach((setting) => {
- results[setting.key] = valueOnly ? setting.value : setting;
- });
-
- return results;
-}
-
// Settings for admin
export const getAdminSettings = createServerFn({ method: 'GET' })
.middleware([authMiddleware])
diff --git a/src/service/user.api.ts b/src/service/user.api.ts
new file mode 100644
index 0000000..2d9ca09
--- /dev/null
+++ b/src/service/user.api.ts
@@ -0,0 +1,247 @@
+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,
+ userUpdateRoleSchema,
+} from './user.schema';
+
+export const getAllUser = createServerFn({ method: 'GET' })
+ .middleware([authMiddleware])
+ .inputValidator(userListSchema)
+ .handler(async ({ data }) => {
+ const headers = getRequestHeaders();
+ const { page, limit, keyword } = data;
+
+ const list = await auth.api.listUsers({
+ query: {
+ searchValue: keyword,
+ searchField: 'name',
+ searchOperator: 'contains',
+ sortBy: 'createdAt',
+ sortDirection: 'asc',
+ limit,
+ offset: (page - 1) * limit,
+ },
+ headers,
+ });
+
+ const totalItem = list.total;
+ const totalPage = Math.ceil(totalItem / limit);
+
+ return {
+ result: list.users,
+ pagination: {
+ currentPage: page,
+ totalPage,
+ totalItem,
+ },
+ };
+ });
+
+export const setUserPassword = createServerFn({ method: 'POST' })
+ .middleware([authMiddleware])
+ .inputValidator(userSetPasswordSchema)
+ .handler(async ({ data, context: { user } }) => {
+ try {
+ const headers = getRequestHeaders();
+ const result = await auth.api.setUserPassword({
+ body: {
+ newPassword: data.password,
+ userId: data.id,
+ },
+ 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);
+ throw { message, code };
+ }
+ });
+
+export const updateUserInformation = createServerFn({ method: 'POST' })
+ .middleware([authMiddleware])
+ .inputValidator(userUpdateInfoSchema)
+ .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: {
+ userId: data.id, // required
+ data: { name: data.name }, // required
+ },
+ // 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);
+ throw { message, code };
+ }
+ });
+
+export const setUserRole = createServerFn({ method: 'POST' })
+ .middleware([authMiddleware])
+ .inputValidator(userUpdateRoleSchema)
+ .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: {
+ userId: data.id,
+ role: data.role, // required
+ },
+ // This endpoint requires session cookies.
+ 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);
+ throw { message, code };
+ }
+ });
+
+export const banUser = createServerFn({ method: 'POST' })
+ .middleware([authMiddleware])
+ .inputValidator(userBanSchema)
+ .handler(async ({ data, context: { user } }) => {
+ try {
+ const headers = getRequestHeaders();
+ const result = await auth.api.banUser({
+ body: JSON.parse(
+ JSON.stringify({
+ userId: data.id, // required
+ banReason: data.banReason,
+ banExpiresIn:
+ data.banExp === 99999 ? undefined : 60 * 60 * 24 * data.banExp,
+ }),
+ ),
+ // 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);
+ throw { message, code };
+ }
+ });
+
+export const unbanUser = createServerFn({ method: 'POST' })
+ .middleware([authMiddleware])
+ .inputValidator(baseUser)
+ .handler(async ({ data, context: { user } }) => {
+ try {
+ const headers = getRequestHeaders();
+ const result = await auth.api.unbanUser({
+ body: {
+ userId: data.id, // required
+ },
+ // 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 };
+ }
+ });
diff --git a/src/service/user.schema.ts b/src/service/user.schema.ts
new file mode 100644
index 0000000..8ddc011
--- /dev/null
+++ b/src/service/user.schema.ts
@@ -0,0 +1,68 @@
+import { m } from '@/paraglide/messages';
+import z from 'zod';
+
+export const baseUser = z.object({
+ id: z.string().nonempty(m.users_page_message_user_not_found()),
+});
+
+export const userListSchema = z.object({
+ page: z.coerce.number().min(1).default(1),
+ limit: z.coerce.number().min(10).max(100).default(10),
+ keyword: z.string().optional(),
+});
+
+export const userSetPasswordSchema = baseUser.extend({
+ 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()),
+});
+
+export const userUpdateInfoSchema = baseUser.extend({
+ name: z.string().nonempty(
+ m.common_is_required({
+ field: m.profile_form_name(),
+ }),
+ ),
+});
+
+export const RoleEnum = z.enum(
+ ['admin', 'user'],
+ m.users_page_message_role_select(),
+);
+
+export const userUpdateRoleSchema = baseUser.extend({
+ role: RoleEnum,
+});
+
+export const userBanSchema = baseUser.extend({
+ banReason: z.string().nonempty(
+ m.common_is_required({
+ field: m.users_page_ui_form_ban_reason(),
+ }),
+ ),
+ 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,
+});
diff --git a/src/types/common.d.ts b/src/types/common.d.ts
new file mode 100644
index 0000000..894c5e1
--- /dev/null
+++ b/src/types/common.d.ts
@@ -0,0 +1,4 @@
+export interface ReturnError extends Error {
+ message: string;
+ code: string;
+}
diff --git a/src/types/db.d.ts b/src/types/db.d.ts
index 8afbc4d..ef0beca 100644
--- a/src/types/db.d.ts
+++ b/src/types/db.d.ts
@@ -1,7 +1,14 @@
-import { Audit, User } from '@prisma/client';
+import { Prisma } from '@/generated/prisma/client';
declare global {
- type AuditLog = Audit & {
- user: User;
- };
+ type AuditWithUser = Prisma.AuditGetPayload<{
+ include: {
+ user: {
+ select: {
+ id: true;
+ name: true;
+ };
+ };
+ };
+ }>;
}
diff --git a/src/types/enum.ts b/src/types/enum.ts
new file mode 100644
index 0000000..59fd043
--- /dev/null
+++ b/src/types/enum.ts
@@ -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];
diff --git a/src/utils/help.ts b/src/utils/helper.ts
similarity index 61%
rename from src/utils/help.ts
rename to src/utils/helper.ts
index 7109f64..66a003a 100644
--- a/src/utils/help.ts
+++ b/src/utils/helper.ts
@@ -25,3 +25,19 @@ export function extractDiffObjects(
[{}, {}] as [Partial, Partial],
);
}
+
+export function parseError(error: unknown) {
+ if (typeof error === 'object' && error !== null && 'body' in error) {
+ const e = error as any;
+ return {
+ message: e.body?.message ?? 'Unknown error',
+ code: e.body?.code,
+ };
+ }
+
+ if (error instanceof Error) {
+ return { message: error.message };
+ }
+
+ return { message: String(error) };
+}