Skip to content
Merged

Dev #40

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
10c6040
Merge remote-tracking branch 'origin/dev' into feature/Admin_Control
Accommodus Nov 6, 2025
8a09908
Merge remote-tracking branch 'origin/dev' into feature/Admin_Control
Accommodus Nov 6, 2025
8085486
Merge remote-tracking branch 'origin/dev' into feature/Admin_Control
Accommodus Nov 6, 2025
fddb923
Merge remote-tracking branch 'origin/dev' into feature/Admin_Control
Accommodus Nov 6, 2025
e0aa35a
Merge remote-tracking branch 'origin/dev' into feature/Admin_Control
Accommodus Nov 6, 2025
4a65d3d
Merge remote-tracking branch 'origin/dev' into feature/Admin_Control
Accommodus Nov 6, 2025
1d6eff3
Merge remote-tracking branch 'origin/dev' into feature/Admin_Control
Accommodus Nov 6, 2025
777cb85
Merge remote-tracking branch 'origin/dev' into feature/Admin_Control
Accommodus Nov 20, 2025
a0b4f09
Merge remote-tracking branch 'origin/dev' into feature/Admin_Control
Accommodus Nov 20, 2025
3303e86
Merge remote-tracking branch 'origin/dev' into feature/Admin_Control
Accommodus Nov 20, 2025
e755eb4
Merge remote-tracking branch 'origin/dev' into feature/Admin_Control
Accommodus Dec 1, 2025
9ec9e73
Merge remote-tracking branch 'origin/dev' into feature/Admin_Control
Accommodus Dec 1, 2025
9dfc6ca
Merge remote-tracking branch 'origin/dev' into feature/Admin_Control
Accommodus Dec 1, 2025
0cfd942
Merge remote-tracking branch 'origin/dev' into feature/Admin_Control
Accommodus Dec 1, 2025
dec6399
Merge remote-tracking branch 'origin/dev' into feature/Admin_Control
Accommodus Dec 1, 2025
f22a9dd
Merge remote-tracking branch 'origin/dev' into feature/Admin_Control
Accommodus Dec 1, 2025
222a34d
Merge remote-tracking branch 'origin/dev' into feature/Admin_Control
Accommodus Dec 1, 2025
b5e33c2
Merge remote-tracking branch 'origin/dev' into feature/Admin_Control
Accommodus Dec 1, 2025
3cd7a16
Merge remote-tracking branch 'origin/dev' into feature/Admin_Control
Accommodus Dec 1, 2025
8666141
Merge remote-tracking branch 'origin/dev' into feature/Admin_Control
Accommodus Dec 1, 2025
766ad09
Merge remote-tracking branch 'origin/dev' into feature/Admin_Control
Accommodus Dec 1, 2025
d1bf0b6
Merge remote-tracking branch 'origin/dev' into feature/Admin_Control
Accommodus Dec 1, 2025
75bf0b8
Merge remote-tracking branch 'origin/dev' into feature/Admin_Control
Accommodus Dec 2, 2025
953fdec
Merge remote-tracking branch 'origin/dev' into feature/Admin_Control
Accommodus Dec 2, 2025
671aadf
Merge remote-tracking branch 'origin/dev' into feature/Admin_Control
Accommodus Dec 2, 2025
53ef8c4
Merge remote-tracking branch 'origin/dev' into feature/Admin_Control
Accommodus Dec 2, 2025
28d3024
Reorganize schema to better manage user info
Accommodus Dec 2, 2025
3a309a3
Use new data scheme
Accommodus Dec 2, 2025
3f114b5
Ensure codebase uses new user scheme
Accommodus Dec 2, 2025
6ee9a58
Add admin control of users
Accommodus Dec 3, 2025
b85f6db
Merge pull request #39 from Accommodus/feature/Admin_Control
Accommodus Dec 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Inventory from './pages/Inventory.tsx';
import Users from './pages/Users.tsx';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router';
import { RequireAuth } from '@features/auth/RequireAuth';
import { RequireAdmin } from '@features/auth/RequireAdmin';

const App = () => {
return (
Expand All @@ -24,7 +25,14 @@ const App = () => {
element={<Navigate to="inventory" replace />}
/>
<Route path="inventory" element={<Inventory />} />
<Route path="users" element={<Users />} />
<Route
path="users"
element={
<RequireAdmin>
<Users />
</RequireAdmin>
}
/>
</Route>
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
Expand Down
15 changes: 6 additions & 9 deletions client/src/features/ChangeUserRoleModal/ChangeUserRoleModal.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState } from 'react';
import type { UserResource, UpdateUserResponse } from '@foodstoragemanager/schema';
import { toRole, type UserResource, type UpdateUserResponse, type Role } from '@foodstoragemanager/schema';
import { getSchemaClient } from '@lib/schemaClient';

interface ChangeUserRoleModalProps {
Expand All @@ -9,8 +9,8 @@ interface ChangeUserRoleModalProps {
}

export const ChangeUserRoleModal = ({ user, onRoleChanged, onCancel }: ChangeUserRoleModalProps) => {
const currentRole = user.role?.[0] ?? '';
const [selectedRole, setSelectedRole] = useState<string>(currentRole);
const currentRole: Role = toRole(user.role);
const [selectedRole, setSelectedRole] = useState<Role>(currentRole);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);

Expand All @@ -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) {
Expand All @@ -45,9 +45,7 @@ export const ChangeUserRoleModal = ({ user, onRoleChanged, onCancel }: ChangeUse
}
};

const currentRoleDisplay = currentRole
? currentRole[0].toUpperCase() + currentRole.slice(1).toLowerCase()
: 'No Role';
const currentRoleDisplay = `${currentRole[0].toUpperCase()}${currentRole.slice(1).toLowerCase()}`;

return (
<div className="p-6 bg-white rounded-lg shadow-md max-w-md w-full">
Expand Down Expand Up @@ -75,12 +73,11 @@ export const ChangeUserRoleModal = ({ user, onRoleChanged, onCancel }: ChangeUse
<select
id="role-select"
value={selectedRole}
onChange={(e) => setSelectedRole(e.target.value)}
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}
>
<option value="">No Role</option>
<option value="admin">Admin</option>
<option value="staff">Staff</option>
<option value="volunteer">Volunteer</option>
Expand Down
32 changes: 30 additions & 2 deletions client/src/features/CreateUserForm/CreateUserForm.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
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 = {
name: string;
email: string;
password: string;
confirmPassword: string;
role: Role;
};

type CreateUserFormProps = {
Expand All @@ -19,6 +21,7 @@
email: '',
password: '',
confirmPassword: '',
role: 'volunteer',
};

export const CreateUserForm = ({ onUserCreated, onCancel }: CreateUserFormProps) => {
Expand All @@ -30,7 +33,7 @@
const handleInputChange = (field: keyof FormInputs, value: string) => {
setFormData((prev) => ({
...prev,
[field]: value,
[field]: field === 'role' ? (toRole(value) as Role) : value,

Check warning on line 36 in client/src/features/CreateUserForm/CreateUserForm.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This assertion is unnecessary since it does not change the type of the expression.

See more on https://sonarcloud.io/project/issues?id=Accommodus_FoodStorageManager&issues=AZrhr9a9L86Qldq5QYCQ&open=AZrhr9a9L86Qldq5QYCQ&pullRequest=40
}));
};

Expand Down Expand Up @@ -58,6 +61,7 @@
name: formData.name.trim(),
email: formData.email.trim(),
password: formData.password,
role: formData.role,
});

if ('error' in payload) {
Expand Down Expand Up @@ -114,6 +118,7 @@
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
/>
Expand All @@ -131,6 +136,7 @@
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
/>
Expand All @@ -148,6 +154,7 @@
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
/>
Expand All @@ -165,11 +172,32 @@
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
/>
</div>

<div>
<label
htmlFor="role"
className="mb-1 block text-sm font-medium text-gray-700"
>
Role *
</label>
<select
id="role"
value={formData.role}
onChange={(event) => handleInputChange('role', event.target.value)}
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
required
>
<option value="admin">Admin</option>
<option value="staff">Staff</option>
<option value="volunteer">Volunteer</option>
</select>
</div>

<div className="flex flex-wrap gap-3 pt-2">
{onCancel && (
<button
Expand Down
8 changes: 3 additions & 5 deletions client/src/features/UsersList/UserItem.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useMemo } from 'react';
import type { UserResource } from '@foodstoragemanager/schema';
import { toRole, type UserResource } from '@foodstoragemanager/schema';
import { BiSolidUser, BiSolidPencil, BiSolidTrash } from 'react-icons/bi';

type UserItemProps = {
Expand All @@ -10,10 +10,8 @@ type UserItemProps = {

export const UserItem = ({ user, onEditUser, onDeleteUser }: UserItemProps) => {
const role = useMemo(() => {
const currentRole = user.role?.[0];
if (!currentRole) return '';

return currentRole[0].toUpperCase() + currentRole.slice(1).toLowerCase();
const normalized = toRole(user.role);
return `${normalized[0].toUpperCase()}${normalized.slice(1).toLowerCase()}`;
}, [user.role]);

const handleEditClick = () => {
Expand Down
24 changes: 20 additions & 4 deletions client/src/features/auth/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import {
import type {
AuthenticateUserResponse,
UserResource,
Role,
} from '@foodstoragemanager/schema';
import { toRole } from '@foodstoragemanager/schema';
import { getSchemaClient } from '@lib/schemaClient';

type AuthResult = { ok: true } | { ok: false; error: string };
Expand All @@ -26,10 +28,22 @@ const STORAGE_KEY = 'fsm:auth:user';

const AuthContext = createContext<AuthContextValue | undefined>(undefined);

const normalizeUserRole = (user: UserResource | null): UserResource | null => {
if (!user) return null;

const rawRole: unknown = Array.isArray((user as unknown as { role?: unknown }).role)
? (user as unknown as { role?: unknown[] }).role?.[0]
: (user as unknown as { role?: unknown }).role;

const role: Role = toRole(rawRole);
return { ...user, role };
};

const readStoredUser = (): UserResource | null => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as UserResource) : null;
const parsed = raw ? (JSON.parse(raw) as UserResource) : null;
return normalizeUserRole(parsed);
} catch (error) {
console.warn("Failed to read stored user session", error);
return null;
Expand All @@ -38,12 +52,13 @@ const readStoredUser = (): UserResource | null => {

const writeStoredUser = (user: UserResource | null) => {
try {
const normalized = normalizeUserRole(user);
if (!user) {
localStorage.removeItem(STORAGE_KEY);
return;
}

localStorage.setItem(STORAGE_KEY, JSON.stringify(user));
localStorage.setItem(STORAGE_KEY, JSON.stringify(normalized));
} catch (error) {
console.warn("Failed to persist user session", error);
}
Expand Down Expand Up @@ -71,8 +86,9 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
return { ok: false, error: payload.error.message };
}

setUser(payload.user);
writeStoredUser(payload.user);
const normalizedUser = normalizeUserRole(payload.user);
setUser(normalizedUser);
writeStoredUser(normalizedUser);
return { ok: true };
} catch (error) {
const message =
Expand Down
26 changes: 26 additions & 0 deletions client/src/features/auth/RequireAdmin.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { ReactNode } from 'react';
import { Navigate, useLocation } from 'react-router';
import { useAuth } from './AuthContext';

export const RequireAdmin = ({ children }: { children: ReactNode }) => {
const { user, loading } = useAuth();
const location = useLocation();

if (loading) {
return (
<div className="flex min-h-screen items-center justify-center text-neutral-700">
Checking your permissions...
</div>
);
}

if (!user) {
return <Navigate to="/login" replace state={{ from: location }} />;
}

if (user.role !== 'admin') {
return <Navigate to="/app/inventory" replace state={{ from: location, reason: 'forbidden' }} />;
}

return <>{children}</>;
};
2 changes: 1 addition & 1 deletion client/src/features/ui/NavBar/NavBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const NavBar = () => {
<div className="flex h-16 w-full items-center bg-neutral-900 text-neutral-50">
<nav className="flex flex-1 items-center justify-around">
<NavLink to={'/app/inventory'}>Inventory</NavLink>
<NavLink to={'/app/users'}>Users</NavLink>
{user?.role === 'admin' && <NavLink to={'/app/users'}>Users</NavLink>}
</nav>
<div className="flex items-center gap-3 pr-6">
{user && (
Expand Down
19 changes: 11 additions & 8 deletions schema/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions schema/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@ export {
type SchemaClient,
} from "./client";

export { toRole } from "./user";
export type * from "./types";
export type * from "./user";
27 changes: 0 additions & 27 deletions schema/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] }>;
Loading