From 28d30246553aa480f289880c978ece5ef278ddab Mon Sep 17 00:00:00 2001 From: Cannon Whitney Date: Tue, 2 Dec 2025 13:21:12 -0500 Subject: [PATCH 1/4] Reorganize schema to better manage user info --- schema/src/client.ts | 19 +++++++++++-------- schema/src/index.ts | 2 ++ schema/src/types.ts | 27 --------------------------- schema/src/user.ts | 37 +++++++++++++++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 35 deletions(-) create mode 100644 schema/src/user.ts diff --git a/schema/src/client.ts b/schema/src/client.ts index 701b82a..bd833f2 100644 --- a/schema/src/client.ts +++ b/schema/src/client.ts @@ -4,31 +4,34 @@ import { type CreateAuditResponse, type CreateItemResponse, type CreateLocationResponse, - type CreateUserResponse, - type DeleteUserResponse, type DeleteItemResponse, - type AuthenticateUserPayload, - type AuthenticateUserResponse, type InventoryLotDraft, type InventoryLotResource, type ItemDraft, type ItemResource, type ListItemsResponse, type ListLocationsResponse, - type ListUsersResponse, type LocationDraft, type LocationResource, type RecordStockTransactionResponse, type StockTransactionDraft, type StockTransactionResource, type UpdateItemResponse, - type UpdateUserResponse, type UpsertInventoryLotResponse, - type UserDraft, - type UserResource, type ApiErrorPayload, } from "./types"; +import { + type AuthenticateUserPayload, + type AuthenticateUserResponse, + type CreateUserResponse, + type DeleteUserResponse, + type ListUsersResponse, + type UpdateUserResponse, + type UserDraft, + type UserResource, +} from "./user"; + type FetchLike = typeof fetch; export interface ClientInit { diff --git a/schema/src/index.ts b/schema/src/index.ts index c68a033..0dbed02 100644 --- a/schema/src/index.ts +++ b/schema/src/index.ts @@ -5,4 +5,6 @@ export { type SchemaClient, } from "./client"; +export { toRole } from "./user"; export type * from "./types"; +export type * from "./user"; diff --git a/schema/src/types.ts b/schema/src/types.ts index 1447167..7a9410f 100644 --- a/schema/src/types.ts +++ b/schema/src/types.ts @@ -182,30 +182,3 @@ export interface AuditResource { } export type CreateAuditResponse = ApiResponse<{ audit: AuditResource }>; - -export interface UserDraft { - email: string; - name: string; - password: string; - role?: "admin" | "staff" | "volunteer"; -} - -export interface UserResource { - _id: ObjectIdString; - email: string; - name: string; - role: ["admin" | "staff" | "volunteer"]; - enabled: boolean; - createdAt: ISODateString; -} - -export interface AuthenticateUserPayload { - email: string; - password: string; -} - -export type AuthenticateUserResponse = ApiResponse<{ user: UserResource }>; -export type CreateUserResponse = ApiResponse<{ user: UserResource }>; -export type UpdateUserResponse = ApiResponse<{ user: UserResource }>; -export type DeleteUserResponse = ApiResponse<{ deleted: boolean }>; -export type ListUsersResponse = ApiResponse<{ users: UserResource[] }>; diff --git a/schema/src/user.ts b/schema/src/user.ts new file mode 100644 index 0000000..d9cc33d --- /dev/null +++ b/schema/src/user.ts @@ -0,0 +1,37 @@ +import type { ApiResponse, ISODateString, ObjectIdString } from "./types"; + +const allowedRoles = ["admin", "staff", "volunteer"] as const; +export type Role = (typeof allowedRoles)[number]; + +export function toRole(value: unknown): Role { + return typeof value === "string" && allowedRoles.includes(value as Role) + ? (value as Role) + : "volunteer"; +} + +export interface UserDraft { + email: string; + name: string; + password: string; + role?: Role; +} + +export interface UserResource { + _id: ObjectIdString; + email: string; + name: string; + role: Role; + enabled: boolean; + createdAt: ISODateString; +} + +export interface AuthenticateUserPayload { + email: string; + password: string; +} + +export type AuthenticateUserResponse = ApiResponse<{ user: UserResource }>; +export type CreateUserResponse = ApiResponse<{ user: UserResource }>; +export type UpdateUserResponse = ApiResponse<{ user: UserResource }>; +export type DeleteUserResponse = ApiResponse<{ deleted: boolean }>; +export type ListUsersResponse = ApiResponse<{ users: UserResource[] }>; \ No newline at end of file From 3a309a3dfc80dfc47c9fda0785c84e75b338e012 Mon Sep 17 00:00:00 2001 From: Cannon Whitney Date: Tue, 2 Dec 2025 13:32:58 -0500 Subject: [PATCH 2/4] Use new data scheme --- server/src/user.ts | 46 +++++++++++++++------------------------------- 1 file changed, 15 insertions(+), 31 deletions(-) diff --git a/server/src/user.ts b/server/src/user.ts index fcb1e2e..ab6dad8 100644 --- a/server/src/user.ts +++ b/server/src/user.ts @@ -9,10 +9,9 @@ import { sanitizeObjectId, } from "./validation.js"; import { handleDatabaseError, sendError, sendSuccess } from "./responses.js"; -import type { UserResource } from "@foodstoragemanager/schema"; +import { toRole, type Role, type UserResource } from "@foodstoragemanager/schema"; const COLLECTION = "users"; -const ALLOWED_ROLES = new Set(["admin", "staff", "volunteer"]); function formatNow(): string { return new Date().toISOString(); @@ -26,34 +25,26 @@ function toIsoString(value: unknown): string | undefined { } else return undefined; } -function sanitizeRoles(value: unknown): string | undefined { +const normalizeRole = (value: unknown): Role => { if (Array.isArray(value) && value.length > 0) { - const role = typeof value[0] === "string" ? value[0] : undefined; - const normalized = role?.trim().toLowerCase(); - return normalized && ALLOWED_ROLES.has(normalized) ? normalized : undefined; + return toRole(value[0]); } + return toRole(value); +}; - if (typeof value === "string") { - const normalized = value.trim().toLowerCase(); - return ALLOWED_ROLES.has(normalized) ? normalized : undefined; - } - - return undefined; -} - -function serializeUser(doc: Record): UserResource { - const role = sanitizeRoles(doc.roles ?? doc.role) ?? "volunteer"; +const serializeUser = (doc: Record): UserResource => { + const role = normalizeRole(doc.roles ?? doc.role); const createdAt: string = toIsoString(doc.createdAt) ?? formatNow(); return { _id: String(doc._id), email: String(doc.email), name: doc.name ? String(doc.name) : "", - role: [role as "admin" | "staff" | "volunteer"], + role, enabled: typeof doc.enabled === "boolean" ? doc.enabled : true, createdAt: createdAt, }; -} +}; async function getUsers(db: Connection): Promise { const collection = db.collection(COLLECTION); @@ -121,9 +112,10 @@ export const createUser: ApiHandler = async (req, res, db) => { const enabled = typeof draft.enabled === "boolean" ? draft.enabled : true; const now = new Date(); + const normalizedRole = normalizeRole(role); document = { email, - roles: role && ALLOWED_ROLES.has(role) ? [role] : ["volunteer"], + roles: [normalizedRole], enabled, createdAt: now, updatedAt: now, @@ -229,20 +221,12 @@ export const updateUser: ApiHandler = async (req, res, db) => { if (draft.role !== undefined) { // Convert single role to roles array for database - const role = + const roleValue = typeof draft.role === "string" ? draft.role.trim().toLowerCase() - : undefined; - - if (role && ALLOWED_ROLES.has(role)) { - update.roles = [role]; - } else if (role === null || role === undefined || role === "") { - update.roles = []; - } else { - throw new Error( - `Invalid role: ${role}. Allowed roles are: admin, staff, volunteer.` - ); - } + : draft.role; + const normalizedRole = normalizeRole(roleValue); + update.roles = [normalizedRole]; } if (draft.enabled !== undefined) { From 3f114b5fe67494012cafa09dd4ba4f9f4a6a7a46 Mon Sep 17 00:00:00 2001 From: Cannon Whitney Date: Tue, 2 Dec 2025 13:52:31 -0500 Subject: [PATCH 3/4] Ensure codebase uses new user scheme --- .../ChangeUserRoleModal.tsx | 13 ++-- client/src/features/UsersList/UserItem.tsx | 4 +- .../postman/backend.postman_collection.json | 2 +- server/src/user.ts | 68 ++++++++++++++----- 4 files changed, 59 insertions(+), 28 deletions(-) diff --git a/client/src/features/ChangeUserRoleModal/ChangeUserRoleModal.tsx b/client/src/features/ChangeUserRoleModal/ChangeUserRoleModal.tsx index 432c9b9..c92997b 100644 --- a/client/src/features/ChangeUserRoleModal/ChangeUserRoleModal.tsx +++ b/client/src/features/ChangeUserRoleModal/ChangeUserRoleModal.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import type { UserResource, UpdateUserResponse } from '@foodstoragemanager/schema'; +import type { UserResource, UpdateUserResponse, Role } from '@foodstoragemanager/schema'; import { getSchemaClient } from '@lib/schemaClient'; interface ChangeUserRoleModalProps { @@ -9,8 +9,8 @@ interface ChangeUserRoleModalProps { } export const ChangeUserRoleModal = ({ user, onRoleChanged, onCancel }: ChangeUserRoleModalProps) => { - const currentRole = user.role?.[0] ?? ''; - const [selectedRole, setSelectedRole] = useState(currentRole); + const currentRole: Role = user.role ?? 'volunteer'; + const [selectedRole, setSelectedRole] = useState(currentRole); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); @@ -28,7 +28,7 @@ export const ChangeUserRoleModal = ({ user, onRoleChanged, onCancel }: ChangeUse const client = getSchemaClient(); const payload: UpdateUserResponse = await client.updateUser(user._id, { - role: selectedRole === '' ? undefined : (selectedRole as 'admin' | 'staff' | 'volunteer'), + role: selectedRole, }); if ('error' in payload) { @@ -47,7 +47,7 @@ export const ChangeUserRoleModal = ({ user, onRoleChanged, onCancel }: ChangeUse const currentRoleDisplay = currentRole ? currentRole[0].toUpperCase() + currentRole.slice(1).toLowerCase() - : 'No Role'; + : 'Volunteer'; return (
@@ -75,12 +75,11 @@ export const ChangeUserRoleModal = ({ user, onRoleChanged, onCancel }: ChangeUse setSelectedRole(e.target.value as Role)} + onChange={(e) => setSelectedRole(toRole(e.target.value))} className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" required disabled={isSubmitting} diff --git a/client/src/features/CreateUserForm/CreateUserForm.tsx b/client/src/features/CreateUserForm/CreateUserForm.tsx index 1e1324a..abb89a7 100644 --- a/client/src/features/CreateUserForm/CreateUserForm.tsx +++ b/client/src/features/CreateUserForm/CreateUserForm.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; -import type { CreateUserResponse } from '@foodstoragemanager/schema'; +import type { CreateUserResponse, Role } from '@foodstoragemanager/schema'; +import { toRole } from '@foodstoragemanager/schema'; import { getSchemaClient } from '@lib/schemaClient'; type FormInputs = { @@ -7,6 +8,7 @@ type FormInputs = { email: string; password: string; confirmPassword: string; + role: Role; }; type CreateUserFormProps = { @@ -19,6 +21,7 @@ const initialForm: FormInputs = { email: '', password: '', confirmPassword: '', + role: 'volunteer', }; export const CreateUserForm = ({ onUserCreated, onCancel }: CreateUserFormProps) => { @@ -30,7 +33,7 @@ export const CreateUserForm = ({ onUserCreated, onCancel }: CreateUserFormProps) const handleInputChange = (field: keyof FormInputs, value: string) => { setFormData((prev) => ({ ...prev, - [field]: value, + [field]: field === 'role' ? (toRole(value) as Role) : value, })); }; @@ -58,6 +61,7 @@ export const CreateUserForm = ({ onUserCreated, onCancel }: CreateUserFormProps) name: formData.name.trim(), email: formData.email.trim(), password: formData.password, + role: formData.role, }); if ('error' in payload) { @@ -114,6 +118,7 @@ export const CreateUserForm = ({ onUserCreated, onCancel }: CreateUserFormProps) type="text" value={formData.name} onChange={(event) => handleInputChange('name', event.target.value)} + autoComplete="name" className="w-full rounded-md border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" required /> @@ -131,6 +136,7 @@ export const CreateUserForm = ({ onUserCreated, onCancel }: CreateUserFormProps) type="email" value={formData.email} onChange={(event) => handleInputChange('email', event.target.value)} + autoComplete="username" className="w-full rounded-md border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" required /> @@ -148,6 +154,7 @@ export const CreateUserForm = ({ onUserCreated, onCancel }: CreateUserFormProps) type="password" value={formData.password} onChange={(event) => handleInputChange('password', event.target.value)} + autoComplete="new-password" className="w-full rounded-md border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" required /> @@ -165,11 +172,32 @@ export const CreateUserForm = ({ onUserCreated, onCancel }: CreateUserFormProps) type="password" value={formData.confirmPassword} onChange={(event) => handleInputChange('confirmPassword', event.target.value)} + autoComplete="new-password" className="w-full rounded-md border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" required />
+
+ + +
+
{onCancel && (