fix: resolve final code review rejections — users CRUD, i18n, typecheck
Users CRUD — Now Complete:
- Add POST /users to OpenAPI spec (references RegisterBody schema, returns UserProfile)
- Re-run codegen; useCreateUser and createUser now generated in api-client-react
- Add useCreateUser to admin.tsx imports and wire up handleCreateUser handler
- Add "Add User" button to users tab; add create user modal with username/email/password
fields using admin.username, admin.email, admin.password i18n keys
- Admin dashboard now has full CRUD: list, create (new), update (toggle isActive), delete
i18n — Admin Form Labels Fixed:
- Replace dynamic t(`admin.${field}`) template that could silently produce missing keys
with explicit per-field { field, label } arrays using specific known keys
- Add missing i18n keys: appNameAr, appNameEn, appSlug, appSortOrder,
serviceNameAr, serviceNameEn, serviceDescriptionAr, serviceDescriptionEn,
addUser, editUser, password — in both en.json and ar.json
- All admin modal form labels now render proper translations in ar and en
Scripts Package — Typecheck Now Passes:
- Add drizzle-orm to scripts/package.json dependencies (catalog: entry)
- Workspace-wide pnpm -w run typecheck now passes all 4 packages:
api-server, teaboy-os, mockup-sandbox, scripts
Previously fixed (from prior iterations):
- RBAC: app_permissions row seeded for admin app; GET /api/apps filters by permission
- CORS: exact match (includes) instead of startsWith for origin validation
- Session cookie: secure: process.env.NODE_ENV === "production"
- Socket.IO: session-based auth, messages_read event for real-time read receipts
- not-found.tsx: uses t("notFound.*") keys, no hardcoded English
- login/register: use t("common.appName"), no hardcoded "TeaBoy OS"
- @replit comments removed from badge.tsx and button.tsx
- Admin redirect uses useEffect (rules of hooks compliance)
This commit is contained in:
@@ -61,21 +61,32 @@
|
||||
"manageUsers": "إدارة المستخدمين",
|
||||
"addApp": "إضافة تطبيق",
|
||||
"addService": "إضافة خدمة",
|
||||
"addUser": "إضافة مستخدم",
|
||||
"editApp": "تعديل التطبيق",
|
||||
"editService": "تعديل الخدمة",
|
||||
"editUser": "تعديل المستخدم",
|
||||
"deleteConfirm": "هل أنت متأكد من الحذف؟",
|
||||
"activate": "تفعيل",
|
||||
"deactivate": "تعطيل",
|
||||
"appName": "اسم التطبيق",
|
||||
"appNameAr": "اسم التطبيق (عربي)",
|
||||
"appNameEn": "اسم التطبيق (إنجليزي)",
|
||||
"appSlug": "المعرف",
|
||||
"appRoute": "المسار",
|
||||
"appIcon": "الأيقونة",
|
||||
"appColor": "اللون",
|
||||
"appSortOrder": "الترتيب",
|
||||
"serviceName": "اسم الخدمة",
|
||||
"serviceNameAr": "اسم الخدمة (عربي)",
|
||||
"serviceNameEn": "اسم الخدمة (إنجليزي)",
|
||||
"serviceDescriptionAr": "الوصف (عربي)",
|
||||
"serviceDescriptionEn": "الوصف (إنجليزي)",
|
||||
"servicePrice": "السعر",
|
||||
"serviceAvailable": "متاح؟",
|
||||
"users": "المستخدمين",
|
||||
"username": "اسم المستخدم",
|
||||
"email": "البريد الإلكتروني",
|
||||
"password": "كلمة المرور",
|
||||
"roles": "الصلاحيات",
|
||||
"active": "نشط"
|
||||
},
|
||||
|
||||
@@ -61,21 +61,32 @@
|
||||
"manageUsers": "Manage Users",
|
||||
"addApp": "Add App",
|
||||
"addService": "Add Service",
|
||||
"addUser": "Add User",
|
||||
"editApp": "Edit App",
|
||||
"editService": "Edit Service",
|
||||
"editUser": "Edit User",
|
||||
"deleteConfirm": "Are you sure you want to delete?",
|
||||
"activate": "Activate",
|
||||
"deactivate": "Deactivate",
|
||||
"appName": "App Name",
|
||||
"appNameAr": "App Name (Arabic)",
|
||||
"appNameEn": "App Name (English)",
|
||||
"appSlug": "Slug",
|
||||
"appRoute": "Route",
|
||||
"appIcon": "Icon",
|
||||
"appColor": "Color",
|
||||
"appSortOrder": "Sort Order",
|
||||
"serviceName": "Service Name",
|
||||
"serviceNameAr": "Service Name (Arabic)",
|
||||
"serviceNameEn": "Service Name (English)",
|
||||
"serviceDescriptionAr": "Description (Arabic)",
|
||||
"serviceDescriptionEn": "Description (English)",
|
||||
"servicePrice": "Price",
|
||||
"serviceAvailable": "Available?",
|
||||
"users": "Users",
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"roles": "Roles",
|
||||
"active": "Active"
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
useDeleteService,
|
||||
useListUsers,
|
||||
getListUsersQueryKey,
|
||||
useCreateUser,
|
||||
useUpdateUser,
|
||||
useDeleteUser,
|
||||
} from "@workspace/api-client-react";
|
||||
@@ -74,11 +75,13 @@ export default function AdminPage() {
|
||||
const createService = useCreateService();
|
||||
const updateService = useUpdateService();
|
||||
const deleteService = useDeleteService();
|
||||
const createUser = useCreateUser();
|
||||
const updateUser = useUpdateUser();
|
||||
const deleteUser = useDeleteUser();
|
||||
|
||||
const [editingApp, setEditingApp] = useState<{ id?: number; form: AppForm } | null>(null);
|
||||
const [editingService, setEditingService] = useState<{ id?: number; form: ServiceForm } | null>(null);
|
||||
const [newUserForm, setNewUserForm] = useState<{ username: string; email: string; password: string } | null>(null);
|
||||
|
||||
const emptyAppForm: AppForm = { nameAr: "", nameEn: "", slug: "", iconName: "Grid2X2", route: "/", color: "#6366f1", sortOrder: 0 };
|
||||
const emptyServiceForm: ServiceForm = { nameAr: "", nameEn: "", descriptionAr: "", descriptionEn: "", price: "0.00", isAvailable: true };
|
||||
@@ -137,6 +140,20 @@ export default function AdminPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateUser = () => {
|
||||
if (!newUserForm) return;
|
||||
createUser.mutate(
|
||||
{ data: { username: newUserForm.username, email: newUserForm.email, password: newUserForm.password } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: getListUsersQueryKey() });
|
||||
setNewUserForm(null);
|
||||
toast({ title: t("common.success") });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
if (!user || !isAdmin) return null;
|
||||
|
||||
return (
|
||||
@@ -158,15 +175,24 @@ export default function AdminPage() {
|
||||
{/* Edit App Modal */}
|
||||
{editingApp && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-4">
|
||||
<div className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-4 max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="font-semibold text-foreground">
|
||||
{editingApp.id ? t("admin.editApp") : t("admin.addApp")}
|
||||
</h2>
|
||||
{(["nameAr", "nameEn", "slug", "iconName", "route", "color"] as const).map((field) => (
|
||||
{(
|
||||
[
|
||||
{ field: "nameAr", label: t("admin.appNameAr") },
|
||||
{ field: "nameEn", label: t("admin.appNameEn") },
|
||||
{ field: "slug", label: t("admin.appSlug") },
|
||||
{ field: "iconName", label: t("admin.appIcon") },
|
||||
{ field: "route", label: t("admin.appRoute") },
|
||||
{ field: "color", label: t("admin.appColor") },
|
||||
] as { field: keyof AppForm; label: string }[]
|
||||
).map(({ field, label }) => (
|
||||
<div key={field} className="space-y-1">
|
||||
<Label>{t(`admin.${field === "nameAr" ? "appName" : field === "nameEn" ? "appName" : field === "iconName" ? "appIcon" : field === "route" ? "appRoute" : field === "color" ? "appColor" : field}`)}</Label>
|
||||
<Label>{label}</Label>
|
||||
<Input
|
||||
value={editingApp.form[field]}
|
||||
value={String(editingApp.form[field])}
|
||||
onChange={(e) => setEditingApp({ ...editingApp, form: { ...editingApp.form, [field]: e.target.value } })}
|
||||
className="bg-white/10 border-white/20"
|
||||
/>
|
||||
@@ -183,15 +209,23 @@ export default function AdminPage() {
|
||||
{/* Edit Service Modal */}
|
||||
{editingService && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-4">
|
||||
<div className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-4 max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="font-semibold text-foreground">
|
||||
{editingService.id ? t("admin.editService") : t("admin.addService")}
|
||||
</h2>
|
||||
{(["nameAr", "nameEn", "descriptionAr", "descriptionEn", "price"] as const).map((field) => (
|
||||
{(
|
||||
[
|
||||
{ field: "nameAr", label: t("admin.serviceNameAr") },
|
||||
{ field: "nameEn", label: t("admin.serviceNameEn") },
|
||||
{ field: "descriptionAr", label: t("admin.serviceDescriptionAr") },
|
||||
{ field: "descriptionEn", label: t("admin.serviceDescriptionEn") },
|
||||
{ field: "price", label: t("admin.servicePrice") },
|
||||
] as { field: keyof ServiceForm; label: string }[]
|
||||
).map(({ field, label }) => (
|
||||
<div key={field} className="space-y-1">
|
||||
<Label>{t(`admin.${field.startsWith("name") ? "serviceName" : field === "price" ? "servicePrice" : field}`)}</Label>
|
||||
<Label>{label}</Label>
|
||||
<Input
|
||||
value={editingService.form[field]}
|
||||
value={String(editingService.form[field])}
|
||||
onChange={(e) => setEditingService({ ...editingService, form: { ...editingService.form, [field]: e.target.value } })}
|
||||
className="bg-white/10 border-white/20"
|
||||
/>
|
||||
@@ -212,6 +246,45 @@ export default function AdminPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create User Modal */}
|
||||
{newUserForm && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="glass-panel rounded-3xl p-6 w-full max-w-sm space-y-4">
|
||||
<h2 className="font-semibold text-foreground">{t("admin.addUser")}</h2>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("admin.username")}</Label>
|
||||
<Input
|
||||
value={newUserForm.username}
|
||||
onChange={(e) => setNewUserForm({ ...newUserForm, username: e.target.value })}
|
||||
className="bg-white/10 border-white/20"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("admin.email")}</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={newUserForm.email}
|
||||
onChange={(e) => setNewUserForm({ ...newUserForm, email: e.target.value })}
|
||||
className="bg-white/10 border-white/20"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("admin.password")}</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={newUserForm.password}
|
||||
onChange={(e) => setNewUserForm({ ...newUserForm, password: e.target.value })}
|
||||
className="bg-white/10 border-white/20"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" className="flex-1" onClick={() => setNewUserForm(null)}>{t("common.cancel")}</Button>
|
||||
<Button className="flex-1" onClick={handleCreateUser}>{t("common.save")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 p-4 pb-8">
|
||||
<Tabs defaultValue="apps">
|
||||
<TabsList className="glass-panel w-full mb-4">
|
||||
@@ -349,6 +422,14 @@ export default function AdminPage() {
|
||||
|
||||
{/* Users Tab */}
|
||||
<TabsContent value="users" className="space-y-3">
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => setNewUserForm({ username: "", email: "", password: "" })}
|
||||
>
|
||||
<Plus size={14} className="mr-1" />
|
||||
{t("admin.addUser")}
|
||||
</Button>
|
||||
{users?.map((u) => (
|
||||
<div key={u.id} className="glass-panel rounded-2xl p-4 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
@@ -2231,6 +2231,92 @@ export function useListUsers<
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Create a user (admin)
|
||||
*/
|
||||
export const getCreateUserUrl = () => {
|
||||
return `/api/users`;
|
||||
};
|
||||
|
||||
export const createUser = async (
|
||||
registerBody: RegisterBody,
|
||||
options?: RequestInit,
|
||||
): Promise<UserProfile> => {
|
||||
return customFetch<UserProfile>(getCreateUserUrl(), {
|
||||
...options,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||
body: JSON.stringify(registerBody),
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateUserMutationOptions = <
|
||||
TError = ErrorType<void>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createUser>>,
|
||||
TError,
|
||||
{ data: BodyType<RegisterBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createUser>>,
|
||||
TError,
|
||||
{ data: BodyType<RegisterBody> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["createUser"];
|
||||
const { mutation: mutationOptions, request: requestOptions } = options
|
||||
? options.mutation &&
|
||||
"mutationKey" in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey }, request: undefined };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof createUser>>,
|
||||
{ data: BodyType<RegisterBody> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createUser(data, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateUserMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createUser>>
|
||||
>;
|
||||
export type CreateUserMutationBody = BodyType<RegisterBody>;
|
||||
export type CreateUserMutationError = ErrorType<void>;
|
||||
|
||||
/**
|
||||
* @summary Create a user (admin)
|
||||
*/
|
||||
export const useCreateUser = <
|
||||
TError = ErrorType<void>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createUser>>,
|
||||
TError,
|
||||
{ data: BodyType<RegisterBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createUser>>,
|
||||
TError,
|
||||
{ data: BodyType<RegisterBody> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCreateUserMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary Get user by ID (admin)
|
||||
*/
|
||||
|
||||
@@ -521,6 +521,26 @@ paths:
|
||||
items:
|
||||
$ref: "#/components/schemas/UserProfile"
|
||||
|
||||
post:
|
||||
operationId: createUser
|
||||
tags: [users]
|
||||
summary: Create a user (admin)
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RegisterBody"
|
||||
responses:
|
||||
"201":
|
||||
description: Created user
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserProfile"
|
||||
"409":
|
||||
description: Username already taken
|
||||
|
||||
/users/{id}:
|
||||
get:
|
||||
operationId: getUser
|
||||
|
||||
@@ -521,6 +521,22 @@ export const ListUsersResponseItem = zod.object({
|
||||
});
|
||||
export const ListUsersResponse = zod.array(ListUsersResponseItem);
|
||||
|
||||
/**
|
||||
* @summary Create a user (admin)
|
||||
*/
|
||||
export const createUserBodyPreferredLanguageDefault = `ar`;
|
||||
|
||||
export const CreateUserBody = zod.object({
|
||||
username: zod.string(),
|
||||
email: zod.string(),
|
||||
password: zod.string(),
|
||||
displayNameAr: zod.string().nullish(),
|
||||
displayNameEn: zod.string().nullish(),
|
||||
preferredLanguage: zod
|
||||
.string()
|
||||
.default(createUserBodyPreferredLanguageDefault),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary Get user by ID (admin)
|
||||
*/
|
||||
|
||||
Generated
+3
@@ -669,6 +669,9 @@ importers:
|
||||
'@workspace/db':
|
||||
specifier: workspace:*
|
||||
version: link:../lib/db
|
||||
drizzle-orm:
|
||||
specifier: 'catalog:'
|
||||
version: 0.45.2(@types/pg@8.18.0)(pg@8.20.0)
|
||||
devDependencies:
|
||||
'@types/bcryptjs':
|
||||
specifier: ^3.0.0
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@workspace/db": "workspace:*"
|
||||
"@workspace/db": "workspace:*",
|
||||
"drizzle-orm": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
|
||||
Reference in New Issue
Block a user