From 38fccc09b7345e8a4015ccca1d389a93a9e30bb4 Mon Sep 17 00:00:00 2001 From: Indigo Tang Date: Sun, 6 Jul 2025 12:24:07 +0000 Subject: [PATCH] implement full user management function base on sqlite database in admi --- src/app/[locale]/admin/users/UserForm.tsx | 150 ++++++++++++++++++ src/app/[locale]/admin/users/add/page.tsx | 14 ++ .../[locale]/admin/users/edit/[id]/page.tsx | 37 +++++ src/app/[locale]/admin/users/page.tsx | 115 +++++++++++--- src/app/actions/user.ts | 83 +++++++++- src/data/operations.ts | 2 +- src/locales/en.ts | 34 +++- src/locales/zh-TW.ts | 34 +++- 8 files changed, 428 insertions(+), 41 deletions(-) create mode 100644 src/app/[locale]/admin/users/UserForm.tsx create mode 100644 src/app/[locale]/admin/users/add/page.tsx create mode 100644 src/app/[locale]/admin/users/edit/[id]/page.tsx diff --git a/src/app/[locale]/admin/users/UserForm.tsx b/src/app/[locale]/admin/users/UserForm.tsx new file mode 100644 index 0000000..28893a4 --- /dev/null +++ b/src/app/[locale]/admin/users/UserForm.tsx @@ -0,0 +1,150 @@ + +'use client'; + +import { useRouter } from 'next/navigation'; +import { useForm, SubmitHandler } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { useToast } from '@/hooks/use-toast'; +import { Save, UserPlus } from 'lucide-react'; +import type { User } from '@/types'; +import { useI18n, useCurrentLocale } from '@/locales/client'; +import { registerUser, updateUser } from '@/app/actions/user'; + +const formSchema = z.object({ + name: z.string().min(2, "Name must be at least 2 characters."), + email: z.string().email("Invalid email address."), + role: z.enum(['User', 'Admin']), +}); + +type UserFormValues = z.infer; + +interface UserFormProps { + initialData?: User; + isEditMode?: boolean; +} + +export default function UserForm({ initialData, isEditMode = false }: UserFormProps) { + const router = useRouter(); + const { toast } = useToast(); + const t = useI18n(); + const locale = useCurrentLocale(); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + name: initialData?.name || '', + email: initialData?.email || '', + role: initialData?.role || 'User', + }, + }); + + const { isSubmitting } = form.formState; + + const onSubmit: SubmitHandler = async (data) => { + const result = isEditMode + ? await updateUser({ ...initialData!, ...data }) + : await registerUser(data); + + if (result.success) { + toast({ + title: isEditMode ? t('admin.users.user_updated_toast') : t('admin.users.user_created_toast'), + }); + router.push(`/${locale}/admin/users`); + router.refresh(); // Ensure the user list is up-to-date + } else { + toast({ + title: t('admin.users.toast_error_title'), + description: result.message, + variant: 'destructive', + }); + } + }; + + return ( + + + + {isEditMode ? t('admin.users.edit_user_title') : t('admin.users.add_user_title')} + + + {isEditMode ? t('admin.users.edit_user_description') : t('admin.users.add_user_description')} + + +
+ + + ( + + {t('admin.users.form_name_label')} + + + + + + )} + /> + ( + + {t('admin.users.form_email_label')} + + + + + + )} + /> + ( + + {t('admin.users.form_role_label')} + + + + )} + /> + + + + +
+ +
+ ); +} diff --git a/src/app/[locale]/admin/users/add/page.tsx b/src/app/[locale]/admin/users/add/page.tsx new file mode 100644 index 0000000..ec78903 --- /dev/null +++ b/src/app/[locale]/admin/users/add/page.tsx @@ -0,0 +1,14 @@ + +import UserForm from '../UserForm'; +import { getI18n } from '@/locales/server'; +import type { Locale } from '@/locales/server'; + +export default async function AddUserPage({ params }: { params: { locale: Locale } }) { + const t = await getI18n(); // To preload translations for the client component + + return ( +
+ +
+ ); +} diff --git a/src/app/[locale]/admin/users/edit/[id]/page.tsx b/src/app/[locale]/admin/users/edit/[id]/page.tsx new file mode 100644 index 0000000..70b4f0f --- /dev/null +++ b/src/app/[locale]/admin/users/edit/[id]/page.tsx @@ -0,0 +1,37 @@ + +import { getUserById } from '@/data/operations'; +import UserForm from '../UserForm'; +import { getI18n } from '@/locales/server'; +import type { Locale } from '@/locales/server'; +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; +import { ArrowLeft } from 'lucide-react'; + +interface EditUserPageProps { + params: { id: string; locale: Locale }; +} + +export default async function EditUserPage({ params }: EditUserPageProps) { + const t = await getI18n(); + const userData = getUserById(params.id); + + if (!userData) { + return ( +
+

{t('admin.users.user_not_found')}

+ + + +
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/src/app/[locale]/admin/users/page.tsx b/src/app/[locale]/admin/users/page.tsx index 1b1c1fc..109409c 100644 --- a/src/app/[locale]/admin/users/page.tsx +++ b/src/app/[locale]/admin/users/page.tsx @@ -1,26 +1,69 @@ -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { getAllUsers } from "@/data/operations"; -import { getI18n } from "@/locales/server"; -import { UserCog } from "lucide-react"; +import { useI18n, useCurrentLocale } from "@/locales/client"; +import { UserCog, Trash2, PlusCircle } from "lucide-react"; import Link from "next/link"; -import type { Locale } from "@/locales/server"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import type { User } from '@/types'; +import { useToast } from '@/hooks/use-toast'; +import { deleteUser } from '@/app/actions/user'; -export default async function AdminUserManagementPage({ params }: { params: { locale: Locale } }) { - const t = await getI18n(); - const locale = params.locale; - const allUsers = getAllUsers(); - - // Sort users for consistent display, e.g., by name or role - allUsers.sort((a, b) => a.name.localeCompare(b.name)); +export default function AdminUserManagementPage({ users }: { users: User[] }) { + const t = useI18n(); + const locale = useCurrentLocale(); + const router = useRouter(); + const { toast } = useToast(); + const [isDeleting, setIsDeleting] = useState(false); + + // We receive users as a prop after fetching them in a wrapper component. + const allUsers = users.sort((a, b) => a.name.localeCompare(b.name)); + + const handleDeleteUser = async (userId: string) => { + setIsDeleting(true); + const result = await deleteUser(userId); + if (result.success) { + toast({ title: t('admin.users.user_deleted_toast') }); + // The page will re-render due to revalidation, no need for router.refresh() + } else { + toast({ + title: t('admin.users.toast_error_title'), + description: result.message, + variant: 'destructive', + }); + } + setIsDeleting(false); + }; return (
-
-

{t('admin.users.title')}

-

{t('admin.users.description')}

+
+
+

{t('admin.users.title')}

+

{t('admin.users.description')}

+
+ + +
@@ -44,11 +87,39 @@ export default async function AdminUserManagementPage({ params }: { params: { lo {user.name} {user.email} {user.role} - - + + + + + + + + + + + {t('admin.users.delete_confirm_title')} + + {t('admin.users.delete_confirm_description', { userName: user.name })} + + + + {t('admin.users.delete_confirm_cancel')} + handleDeleteUser(user.id)} + disabled={isDeleting} + className="bg-destructive hover:bg-destructive/90" + > + {t('admin.users.delete_confirm_action')} + + + + ))} @@ -62,3 +133,9 @@ export default async function AdminUserManagementPage({ params }: { params: { lo
); } + +// Wrapper component to fetch data on the server and pass to the client component +export default function AdminUserManagementPageWrapper() { + const users = getAllUsers(); + return ; +} diff --git a/src/app/actions/user.ts b/src/app/actions/user.ts index 6e1ec67..571a002 100644 --- a/src/app/actions/user.ts +++ b/src/app/actions/user.ts @@ -4,6 +4,7 @@ import db from '@/lib/db'; import type { User } from '@/types'; import { randomUUID } from 'crypto'; +import { revalidatePath } from 'next/cache'; interface RegisterUserResult { success: boolean; @@ -11,16 +12,14 @@ interface RegisterUserResult { user?: User; } -export async function registerUser(data: { name: string; email: string; password?: string }): Promise { - const { name, email } = data; +export async function registerUser(data: { name: string; email: string; password?: string, role?: 'Admin' | 'User' }): Promise { + const { name, email, role = 'User' } = data; - // Basic validation if (!name || !email) { return { success: false, message: 'Name and email are required.' }; } try { - // Check if user already exists const existingUser = db.prepare('SELECT * FROM users WHERE email = ?').get(email); if (existingUser) { return { success: false, message: 'An account with this email already exists.' }; @@ -30,7 +29,7 @@ export async function registerUser(data: { name: string; email: string; password id: `user-${randomUUID()}`, name, email, - role: 'User', + role: role, avatarUrl: '', bio: '' }; @@ -40,6 +39,8 @@ export async function registerUser(data: { name: string; email: string; password ); stmt.run(newUser); + + revalidatePath('/admin/users'); return { success: true, message: 'User registered successfully.', user: newUser }; } catch (error) { @@ -47,3 +48,75 @@ export async function registerUser(data: { name: string; email: string; password return { success: false, message: 'An unexpected error occurred during registration.' }; } } + +interface UpdateUserResult { + success: boolean; + message: string; +} + +export async function updateUser(user: User): Promise { + if (!user.id || !user.name || !user.email || !user.role) { + return { success: false, message: 'All user fields are required for an update.' }; + } + + try { + const existingUser = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?').get(user.email, user.id); + if (existingUser) { + return { success: false, message: 'Another user with this email already exists.' }; + } + + const stmt = db.prepare( + 'UPDATE users SET name = @name, email = @email, role = @role, avatarUrl = @avatarUrl, bio = @bio WHERE id = @id' + ); + + stmt.run({ + id: user.id, + name: user.name, + email: user.email, + role: user.role, + avatarUrl: user.avatarUrl ?? '', + bio: user.bio ?? '' + }); + + revalidatePath('/admin/users'); + revalidatePath(`/admin/users/edit/${user.id}`); + + return { success: true, message: 'User updated successfully.' }; + } catch (error) { + console.error('Update user error:', error); + return { success: false, message: 'An unexpected error occurred during the update.' }; + } +} + +interface DeleteUserResult { + success: boolean; + message: string; +} + +export async function deleteUser(id: string): Promise { + try { + // Prevent deleting the main admin/user accounts for demo stability + if (id.startsWith('user-seed-') || id === 'admin-main') { + const user = getUserById(id); + if(user?.email === 'user@example.com' || user?.email === 'admin@example.com') { + return { success: false, message: 'This is a protected demo account and cannot be deleted.' }; + } + } + + const stmt = db.prepare('DELETE FROM users WHERE id = ?'); + const info = stmt.run(id); + + if (info.changes === 0) { + return { success: false, message: 'User not found or already deleted.' }; + } + + revalidatePath('/admin/users'); + return { success: true, message: 'User deleted successfully.' }; + } catch (error) { + console.error('Delete user error:', error); + if (error.code === 'SQLITE_CONSTRAINT_FOREIGNKEY') { + return { success: false, message: 'Cannot delete user. They still have toys listed in the system.' }; + } + return { success: false, message: 'An unexpected error occurred during deletion.' }; + } +} diff --git a/src/data/operations.ts b/src/data/operations.ts index de4580c..2a2c2c9 100644 --- a/src/data/operations.ts +++ b/src/data/operations.ts @@ -57,7 +57,7 @@ export function getOwnerProfile(ownerId: string) { // --- USER OPERATIONS --- export function getAllUsers(): User[] { - const stmt = db.prepare('SELECT id, name, email, role FROM users'); + const stmt = db.prepare('SELECT id, name, email, role, avatarUrl, bio FROM users'); return stmt.all() as User[]; } diff --git a/src/locales/en.ts b/src/locales/en.ts index b34d227..68ed237 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -228,20 +228,38 @@ export default { 'admin.site_settings.title_saved_toast': 'Site title (mock) saved!', 'admin.users.title': 'User Management', 'admin.users.description': 'View and manage user accounts and permissions.', + 'admin.users.add_user_button': 'Add New User', + 'admin.users.back_to_users_button': 'Back to User List', 'admin.users.table_header_name': 'Name', 'admin.users.table_header_email': 'Email', 'admin.users.table_header_role': 'Role', 'admin.users.table_header_actions': 'Actions', 'admin.users.edit_button': 'Edit', + 'admin.users.delete_button': 'Delete', 'admin.users.no_users_found': 'No users found.', - 'admin.toys.title': 'Toy Management', - 'admin.toys.description': 'View and manage all toy listings in the system.', - 'admin.toys.table_header_name': 'Toy Name', - 'admin.toys.table_header_owner': 'Owner', - 'admin.toys.table_header_category': 'Category', - 'admin.toys.table_header_actions': 'Actions', - 'admin.toys.edit_button': 'Edit Toy', - 'admin.toys.no_toys_found': 'No toys found.', + 'admin.users.add_user_title': 'Add a New User', + 'admin.users.add_user_description': 'Create a new user account and assign a role.', + 'admin.users.edit_user_title': 'Edit User', + 'admin.users.edit_user_description': "Update the user's details and role.", + 'admin.users.form_name_label': 'Full Name', + 'admin.users.form_email_label': 'Email Address', + 'admin.users.form_role_label': 'Role', + 'admin.users.form_role_user': 'User', + 'admin.users.form_role_admin': 'Admin', + 'admin.users.form_create_button': 'Create User', + 'admin.users.form_save_button': 'Save Changes', + 'admin.users.form_creating_button': 'Creating...', + 'admin.users.form_saving_button': 'Saving...', + 'admin.users.delete_confirm_title': 'Are you sure?', + 'admin.users.delete_confirm_description': "This action cannot be undone. This will permanently delete the user account for {userName}.", + 'admin.users.delete_confirm_action': 'Yes, delete user', + 'admin.users.delete_confirm_cancel': 'Cancel', + 'admin.users.user_not_found': 'User not found.', + 'admin.users.user_created_toast': 'User created successfully.', + 'admin.users.user_updated_toast': 'User updated successfully.', + 'admin.users.user_deleted_toast': 'User deleted successfully.', + 'admin.users.user_delete_failed_toast': 'Failed to delete user.', + 'admin.users.toast_error_title': 'An error occurred', 'dashboard.rental_history.title': 'My Rental History', 'dashboard.rental_history.description': 'View your past toy rentals.', 'dashboard.rental_history.no_history_title': 'No Rental History Yet', diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index 98e2103..6f89fae 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -228,20 +228,38 @@ export default { 'admin.site_settings.title_saved_toast': '網站標題(模擬)已儲存!', 'admin.users.title': '使用者管理', 'admin.users.description': '查看和管理使用者帳戶及權限。', + 'admin.users.add_user_button': '新增使用者', + 'admin.users.back_to_users_button': '返回使用者列表', 'admin.users.table_header_name': '名稱', 'admin.users.table_header_email': '電子郵件', 'admin.users.table_header_role': '角色', 'admin.users.table_header_actions': '操作', 'admin.users.edit_button': '編輯', + 'admin.users.delete_button': '刪除', 'admin.users.no_users_found': '找不到使用者。', - 'admin.toys.title': '玩具管理', - 'admin.toys.description': '查看和管理系統中的所有玩具列表。', - 'admin.toys.table_header_name': '玩具名稱', - 'admin.toys.table_header_owner': '擁有者', - 'admin.toys.table_header_category': '類別', - 'admin.toys.table_header_actions': '操作', - 'admin.toys.edit_button': '編輯玩具', - 'admin.toys.no_toys_found': '找不到玩具。', + 'admin.users.add_user_title': '新增使用者', + 'admin.users.add_user_description': '建立一個新的使用者帳戶並分配角色。', + 'admin.users.edit_user_title': '編輯使用者', + 'admin.users.edit_user_description': '更新使用者的詳細資訊和角色。', + 'admin.users.form_name_label': '全名', + 'admin.users.form_email_label': '電子郵件地址', + 'admin.users.form_role_label': '角色', + 'admin.users.form_role_user': '使用者', + 'admin.users.form_role_admin': '管理員', + 'admin.users.form_create_button': '建立使用者', + 'admin.users.form_save_button': '儲存變更', + 'admin.users.form_creating_button': '建立中...', + 'admin.users.form_saving_button': '儲存中...', + 'admin.users.delete_confirm_title': '您確定嗎?', + 'admin.users.delete_confirm_description': "此操作無法復原。這將永久刪除 {userName} 的使用者帳戶。", + 'admin.users.delete_confirm_action': '是的,刪除使用者', + 'admin.users.delete_confirm_cancel': '取消', + 'admin.users.user_not_found': '找不到使用者。', + 'admin.users.user_created_toast': '使用者已成功建立。', + 'admin.users.user_updated_toast': '使用者已成功更新。', + 'admin.users.user_deleted_toast': '使用者已成功刪除。', + 'admin.users.user_delete_failed_toast': '刪除使用者失敗。', + 'admin.users.toast_error_title': '發生錯誤', 'dashboard.rental_history.title': '我的租借歷史', 'dashboard.rental_history.description': '查看您過去的玩具租借記錄。', 'dashboard.rental_history.no_history_title': '尚無租借歷史',