implement full user management function base on sqlite database in admi

This commit is contained in:
Indigo Tang 2025-07-06 12:24:07 +00:00
parent e7d9db0cd6
commit 38fccc09b7
8 changed files with 428 additions and 41 deletions

View File

@ -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<typeof formSchema>;
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<UserFormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
name: initialData?.name || '',
email: initialData?.email || '',
role: initialData?.role || 'User',
},
});
const { isSubmitting } = form.formState;
const onSubmit: SubmitHandler<UserFormValues> = 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 (
<Card className="w-full max-w-2xl mx-auto shadow-xl">
<CardHeader>
<CardTitle className="text-2xl font-headline">
{isEditMode ? t('admin.users.edit_user_title') : t('admin.users.add_user_title')}
</CardTitle>
<CardDescription>
{isEditMode ? t('admin.users.edit_user_description') : t('admin.users.add_user_description')}
</CardDescription>
</CardHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<CardContent className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t('admin.users.form_name_label')}</FormLabel>
<FormControl>
<Input placeholder="John Doe" {...field} disabled={isSubmitting} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t('admin.users.form_email_label')}</FormLabel>
<FormControl>
<Input type="email" placeholder="user@example.com" {...field} disabled={isSubmitting} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>{t('admin.users.form_role_label')}</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={isSubmitting}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="User">{t('admin.users.form_role_user')}</SelectItem>
<SelectItem value="Admin">{t('admin.users.form_role_admin')}</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
<CardFooter>
<Button type="submit" disabled={isSubmitting}>
{isEditMode ? (
<>
<Save className="mr-2 h-4 w-4" />
{isSubmitting ? t('admin.users.form_saving_button') : t('admin.users.form_save_button')}
</>
) : (
<>
<UserPlus className="mr-2 h-4 w-4" />
{isSubmitting ? t('admin.users.form_creating_button') : t('admin.users.form_create_button')}
</>
)}
</Button>
</CardFooter>
</form>
</Form>
</Card>
);
}

View File

@ -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 (
<div className="space-y-8">
<UserForm isEditMode={false} />
</div>
);
}

View File

@ -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 (
<div className="text-center py-12">
<h1 className="text-2xl font-bold mb-4">{t('admin.users.user_not_found')}</h1>
<Link href={`/${params.locale}/admin/users`} passHref>
<Button variant="outline">
<ArrowLeft className="mr-2 h-4 w-4" />
{t('admin.users.back_to_users_button')}
</Button>
</Link>
</div>
);
}
return (
<div className="space-y-8">
<UserForm isEditMode={true} initialData={userData} />
</div>
);
}

View File

@ -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 { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { getAllUsers } from "@/data/operations"; import { getAllUsers } from "@/data/operations";
import { getI18n } from "@/locales/server"; import { useI18n, useCurrentLocale } from "@/locales/client";
import { UserCog } from "lucide-react"; import { UserCog, Trash2, PlusCircle } from "lucide-react";
import Link from "next/link"; 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 } }) { export default function AdminUserManagementPage({ users }: { users: User[] }) {
const t = await getI18n(); const t = useI18n();
const locale = params.locale; const locale = useCurrentLocale();
const allUsers = getAllUsers(); const router = useRouter();
const { toast } = useToast();
// Sort users for consistent display, e.g., by name or role const [isDeleting, setIsDeleting] = useState(false);
allUsers.sort((a, b) => a.name.localeCompare(b.name));
// 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 ( return (
<div className="space-y-8"> <div className="space-y-8">
<div> <div className="flex justify-between items-center">
<h1 className="text-3xl font-bold font-headline text-primary">{t('admin.users.title')}</h1> <div>
<p className="text-muted-foreground">{t('admin.users.description')}</p> <h1 className="text-3xl font-bold font-headline text-primary">{t('admin.users.title')}</h1>
<p className="text-muted-foreground">{t('admin.users.description')}</p>
</div>
<Link href={`/${locale}/admin/users/add`} passHref>
<Button>
<PlusCircle className="mr-2 h-5 w-5" />
{t('admin.users.add_user_button')}
</Button>
</Link>
</div> </div>
<Card className="shadow-md"> <Card className="shadow-md">
@ -44,11 +87,39 @@ export default async function AdminUserManagementPage({ params }: { params: { lo
<TableCell className="font-medium">{user.name}</TableCell> <TableCell className="font-medium">{user.name}</TableCell>
<TableCell>{user.email}</TableCell> <TableCell>{user.email}</TableCell>
<TableCell>{user.role}</TableCell> <TableCell>{user.role}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right space-x-2">
<Button variant="outline" size="sm" disabled> <Link href={`/${locale}/admin/users/edit/${user.id}`} passHref>
<UserCog className="mr-2 h-4 w-4" /> <Button variant="outline" size="sm">
{t('admin.users.edit_button')} <UserCog className="mr-2 h-4 w-4" />
</Button> {t('admin.users.edit_button')}
</Button>
</Link>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" disabled={isDeleting}>
<Trash2 className="mr-2 h-4 w-4" />
{t('admin.users.delete_button')}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('admin.users.delete_confirm_title')}</AlertDialogTitle>
<AlertDialogDescription>
{t('admin.users.delete_confirm_description', { userName: user.name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('admin.users.delete_confirm_cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDeleteUser(user.id)}
disabled={isDeleting}
className="bg-destructive hover:bg-destructive/90"
>
{t('admin.users.delete_confirm_action')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
@ -62,3 +133,9 @@ export default async function AdminUserManagementPage({ params }: { params: { lo
</div> </div>
); );
} }
// Wrapper component to fetch data on the server and pass to the client component
export default function AdminUserManagementPageWrapper() {
const users = getAllUsers();
return <AdminUserManagementPage users={users} />;
}

View File

@ -4,6 +4,7 @@
import db from '@/lib/db'; import db from '@/lib/db';
import type { User } from '@/types'; import type { User } from '@/types';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { revalidatePath } from 'next/cache';
interface RegisterUserResult { interface RegisterUserResult {
success: boolean; success: boolean;
@ -11,16 +12,14 @@ interface RegisterUserResult {
user?: User; user?: User;
} }
export async function registerUser(data: { name: string; email: string; password?: string }): Promise<RegisterUserResult> { export async function registerUser(data: { name: string; email: string; password?: string, role?: 'Admin' | 'User' }): Promise<RegisterUserResult> {
const { name, email } = data; const { name, email, role = 'User' } = data;
// Basic validation
if (!name || !email) { if (!name || !email) {
return { success: false, message: 'Name and email are required.' }; return { success: false, message: 'Name and email are required.' };
} }
try { try {
// Check if user already exists
const existingUser = db.prepare('SELECT * FROM users WHERE email = ?').get(email); const existingUser = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
if (existingUser) { if (existingUser) {
return { success: false, message: 'An account with this email already exists.' }; 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()}`, id: `user-${randomUUID()}`,
name, name,
email, email,
role: 'User', role: role,
avatarUrl: '', avatarUrl: '',
bio: '' bio: ''
}; };
@ -40,6 +39,8 @@ export async function registerUser(data: { name: string; email: string; password
); );
stmt.run(newUser); stmt.run(newUser);
revalidatePath('/admin/users');
return { success: true, message: 'User registered successfully.', user: newUser }; return { success: true, message: 'User registered successfully.', user: newUser };
} catch (error) { } 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.' }; return { success: false, message: 'An unexpected error occurred during registration.' };
} }
} }
interface UpdateUserResult {
success: boolean;
message: string;
}
export async function updateUser(user: User): Promise<UpdateUserResult> {
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<DeleteUserResult> {
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.' };
}
}

View File

@ -57,7 +57,7 @@ export function getOwnerProfile(ownerId: string) {
// --- USER OPERATIONS --- // --- USER OPERATIONS ---
export function getAllUsers(): User[] { 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[]; return stmt.all() as User[];
} }

View File

@ -228,20 +228,38 @@ export default {
'admin.site_settings.title_saved_toast': 'Site title (mock) saved!', 'admin.site_settings.title_saved_toast': 'Site title (mock) saved!',
'admin.users.title': 'User Management', 'admin.users.title': 'User Management',
'admin.users.description': 'View and manage user accounts and permissions.', '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_name': 'Name',
'admin.users.table_header_email': 'Email', 'admin.users.table_header_email': 'Email',
'admin.users.table_header_role': 'Role', 'admin.users.table_header_role': 'Role',
'admin.users.table_header_actions': 'Actions', 'admin.users.table_header_actions': 'Actions',
'admin.users.edit_button': 'Edit', 'admin.users.edit_button': 'Edit',
'admin.users.delete_button': 'Delete',
'admin.users.no_users_found': 'No users found.', 'admin.users.no_users_found': 'No users found.',
'admin.toys.title': 'Toy Management', 'admin.users.add_user_title': 'Add a New User',
'admin.toys.description': 'View and manage all toy listings in the system.', 'admin.users.add_user_description': 'Create a new user account and assign a role.',
'admin.toys.table_header_name': 'Toy Name', 'admin.users.edit_user_title': 'Edit User',
'admin.toys.table_header_owner': 'Owner', 'admin.users.edit_user_description': "Update the user's details and role.",
'admin.toys.table_header_category': 'Category', 'admin.users.form_name_label': 'Full Name',
'admin.toys.table_header_actions': 'Actions', 'admin.users.form_email_label': 'Email Address',
'admin.toys.edit_button': 'Edit Toy', 'admin.users.form_role_label': 'Role',
'admin.toys.no_toys_found': 'No toys found.', '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.title': 'My Rental History',
'dashboard.rental_history.description': 'View your past toy rentals.', 'dashboard.rental_history.description': 'View your past toy rentals.',
'dashboard.rental_history.no_history_title': 'No Rental History Yet', 'dashboard.rental_history.no_history_title': 'No Rental History Yet',

View File

@ -228,20 +228,38 @@ export default {
'admin.site_settings.title_saved_toast': '網站標題(模擬)已儲存!', 'admin.site_settings.title_saved_toast': '網站標題(模擬)已儲存!',
'admin.users.title': '使用者管理', 'admin.users.title': '使用者管理',
'admin.users.description': '查看和管理使用者帳戶及權限。', 'admin.users.description': '查看和管理使用者帳戶及權限。',
'admin.users.add_user_button': '新增使用者',
'admin.users.back_to_users_button': '返回使用者列表',
'admin.users.table_header_name': '名稱', 'admin.users.table_header_name': '名稱',
'admin.users.table_header_email': '電子郵件', 'admin.users.table_header_email': '電子郵件',
'admin.users.table_header_role': '角色', 'admin.users.table_header_role': '角色',
'admin.users.table_header_actions': '操作', 'admin.users.table_header_actions': '操作',
'admin.users.edit_button': '編輯', 'admin.users.edit_button': '編輯',
'admin.users.delete_button': '刪除',
'admin.users.no_users_found': '找不到使用者。', 'admin.users.no_users_found': '找不到使用者。',
'admin.toys.title': '玩具管理', 'admin.users.add_user_title': '新增使用者',
'admin.toys.description': '查看和管理系統中的所有玩具列表。', 'admin.users.add_user_description': '建立一個新的使用者帳戶並分配角色。',
'admin.toys.table_header_name': '玩具名稱', 'admin.users.edit_user_title': '編輯使用者',
'admin.toys.table_header_owner': '擁有者', 'admin.users.edit_user_description': '更新使用者的詳細資訊和角色。',
'admin.toys.table_header_category': '類別', 'admin.users.form_name_label': '全名',
'admin.toys.table_header_actions': '操作', 'admin.users.form_email_label': '電子郵件地址',
'admin.toys.edit_button': '編輯玩具', 'admin.users.form_role_label': '角色',
'admin.toys.no_toys_found': '找不到玩具。', '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.title': '我的租借歷史',
'dashboard.rental_history.description': '查看您過去的玩具租借記錄。', 'dashboard.rental_history.description': '查看您過去的玩具租借記錄。',
'dashboard.rental_history.no_history_title': '尚無租借歷史', 'dashboard.rental_history.no_history_title': '尚無租借歷史',