diff --git a/backend/src/opsce/audit/audit.module.ts b/backend/src/opsce/audit/audit.module.ts new file mode 100644 index 00000000..b65d33a1 --- /dev/null +++ b/backend/src/opsce/audit/audit.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuditLog } from './entities/audit-log.entity'; +import { AuditService } from './audit.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([AuditLog])], + providers: [AuditService], + exports: [AuditService, TypeOrmModule], +}) +export class AuditModule {} diff --git a/backend/src/opsce/audit/audit.service.ts b/backend/src/opsce/audit/audit.service.ts new file mode 100644 index 00000000..afd7f12f --- /dev/null +++ b/backend/src/opsce/audit/audit.service.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AuditLog } from './entities/audit-log.entity'; + +export interface LogDto { + userId?: string; + action: string; + resourceType: string; + resourceId: string; + oldValue?: Record; + newValue?: Record; + ipAddress?: string; + userAgent?: string; +} + +@Injectable() +export class AuditService { + constructor( + @InjectRepository(AuditLog) + private readonly repo: Repository, + ) {} + + async log(dto: LogDto): Promise { + const entry = this.repo.create(dto); + return this.repo.save(entry); + } + + async findByResource(resourceType: string, resourceId: string): Promise { + return this.repo.find({ + where: { resourceType, resourceId }, + order: { createdAt: 'DESC' }, + }); + } + + async findByUser(userId: string): Promise { + return this.repo.find({ + where: { userId }, + order: { createdAt: 'DESC' }, + }); + } +} diff --git a/backend/src/opsce/audit/entities/audit-log.entity.ts b/backend/src/opsce/audit/entities/audit-log.entity.ts new file mode 100644 index 00000000..d9eb9d8c --- /dev/null +++ b/backend/src/opsce/audit/entities/audit-log.entity.ts @@ -0,0 +1,44 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +@Entity('audit_logs') +export class AuditLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ nullable: true }) + userId?: string; + + @Column() + action: string; + + @Index() + @Column() + resourceType: string; + + @Index() + @Column() + resourceId: string; + + @Column({ type: 'jsonb', nullable: true }) + oldValue?: Record; + + @Column({ type: 'jsonb', nullable: true }) + newValue?: Record; + + @Column({ nullable: true }) + ipAddress?: string; + + @Column({ nullable: true }) + userAgent?: string; + + @Index() + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/opsce/opsce.module.ts b/backend/src/opsce/opsce.module.ts index 9dfd81f8..18eb1d3c 100644 --- a/backend/src/opsce/opsce.module.ts +++ b/backend/src/opsce/opsce.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; +import { AuditModule } from './audit/audit.module'; import { UsersModule } from './users/users.module'; import { LocationsModule } from './locations/locations.module'; @Module({ - imports: [UsersModule, LocationsModule], - exports: [UsersModule, LocationsModule], + imports: [UsersModule, LocationsModule, AuditModule], + exports: [UsersModule, LocationsModule, AuditModule], }) export class OpsceModule {} diff --git a/frontend/opsce/components/ErrorBoundary/ErrorBoundary.tsx b/frontend/opsce/components/ErrorBoundary/ErrorBoundary.tsx new file mode 100644 index 00000000..6db2ab84 --- /dev/null +++ b/frontend/opsce/components/ErrorBoundary/ErrorBoundary.tsx @@ -0,0 +1,74 @@ +'use client'; + +import React from 'react'; + +interface State { + hasError: boolean; + error?: Error; +} + +export class ErrorBoundary extends React.Component< + { children: React.ReactNode }, + State +> { + constructor(props: { children: React.ReactNode }) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error('[ErrorBoundary]', error, errorInfo); + } + + render() { + if (this.state.hasError) { + return ( +
+
+
+ + + +
+

+ Something went wrong +

+

+ An unexpected error occurred. Please try reloading the page. +

+
+ + + Go to Dashboard + +
+
+
+ ); + } + + return this.props.children; + } +} diff --git a/frontend/opsce/components/ErrorBoundary/index.ts b/frontend/opsce/components/ErrorBoundary/index.ts new file mode 100644 index 00000000..8d337a3d --- /dev/null +++ b/frontend/opsce/components/ErrorBoundary/index.ts @@ -0,0 +1 @@ +export { ErrorBoundary } from './ErrorBoundary'; diff --git a/frontend/opsce/features/locations/LocationsPage.tsx b/frontend/opsce/features/locations/LocationsPage.tsx new file mode 100644 index 00000000..ab5fe1c8 --- /dev/null +++ b/frontend/opsce/features/locations/LocationsPage.tsx @@ -0,0 +1,394 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + ChevronRight, + ChevronDown, + Building2, + Layers, + DoorOpen, + MapPin, + Plus, + Edit2, + Trash2, +} from 'lucide-react'; + +interface Location { + id: string; + name: string; + type: string; + address?: string; + parentLocationId?: string; + assetCount?: number; + children?: Location[]; +} + +const TYPE_ICONS: Record = { + building: Building2, + floor: Layers, + room: DoorOpen, + zone: MapPin, +}; + +const TYPE_COLORS: Record = { + building: 'bg-blue-100 text-blue-700', + floor: 'bg-purple-100 text-purple-700', + room: 'bg-green-100 text-green-700', + zone: 'bg-amber-100 text-amber-700', +}; + +function buildTree(flat: Location[]): Location[] { + const map: Record = {}; + flat.forEach((l) => { + map[l.id] = { ...l, children: [] }; + }); + const roots: Location[] = []; + flat.forEach((l) => { + if (l.parentLocationId && map[l.parentLocationId]) { + map[l.parentLocationId].children!.push(map[l.id]); + } else { + roots.push(map[l.id]); + } + }); + return roots; +} + +function fetchLocations(): Promise { + const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:6003/api'; + const token = typeof window !== 'undefined' ? localStorage.getItem('accessToken') : ''; + return fetch(`${API}/locations`, { + headers: { Authorization: `Bearer ${token}` }, + }).then((r) => r.json()); +} + +async function deleteLocation(id: string) { + const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:6003/api'; + const token = typeof window !== 'undefined' ? localStorage.getItem('accessToken') : ''; + const res = await fetch(`${API}/locations/${id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) { + const e = await res.json(); + throw new Error(e.message ?? 'Delete failed'); + } +} + +interface TreeNodeProps { + node: Location; + depth?: number; + onSelect: (loc: Location) => void; + onEdit: (loc: Location) => void; + onDelete: (loc: Location) => void; +} + +function TreeNode({ node, depth = 0, onSelect, onEdit, onDelete }: TreeNodeProps) { + const [expanded, setExpanded] = useState(true); + const hasChildren = !!node.children?.length; + const Icon = TYPE_ICONS[node.type] ?? MapPin; + + return ( +
+
+ + + + +
+ + +
+
+ + {expanded && + hasChildren && + node.children!.map((child) => ( + + ))} +
+ ); +} + +interface AddModalProps { + onClose: () => void; + onSave: (data: { + name: string; + type: string; + parentLocationId?: string; + address?: string; + }) => void; + locations: Location[]; +} + +function AddModal({ onClose, onSave, locations }: AddModalProps) { + const [form, setForm] = useState({ + name: '', + type: 'building', + parentLocationId: '', + address: '', + }); + + return ( +
+
+
+

Add Location

+
+ setForm((f) => ({ ...f, name: e.target.value }))} + /> + + + setForm((f) => ({ ...f, address: e.target.value }))} + /> +
+
+ + +
+
+
+ ); +} + +export function LocationsPage() { + const qc = useQueryClient(); + const [selectedLocation, setSelectedLocation] = useState(null); + const [showAdd, setShowAdd] = useState(false); + const [deleteError, setDeleteError] = useState(''); + + const { data: locations = [], isLoading } = useQuery({ + queryKey: ['locations'], + queryFn: fetchLocations, + }); + + const tree = buildTree(locations); + + const deleteMutation = useMutation({ + mutationFn: deleteLocation, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['locations'] }); + setDeleteError(''); + }, + onError: (e: Error) => setDeleteError(e.message), + }); + + const handleAdd = async (data: { + name: string; + type: string; + parentLocationId?: string; + address?: string; + }) => { + const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:6003/api'; + const token = + typeof window !== 'undefined' ? localStorage.getItem('accessToken') : ''; + await fetch(`${API}/locations`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify(data), + }); + qc.invalidateQueries({ queryKey: ['locations'] }); + }; + + return ( +
+ {/* Tree panel */} +
+
+

Locations

+ +
+ +
+ {isLoading ? ( + Array.from({ length: 4 }).map((_, i) => ( +
+
+
+ )) + ) : tree.length === 0 ? ( +

+ No locations yet. Add your first location. +

+ ) : ( + tree.map((node) => ( + {}} + onDelete={(loc) => deleteMutation.mutate(loc.id)} + /> + )) + )} + {deleteError && ( +

{deleteError}

+ )} +
+
+ + {/* Side panel */} + {selectedLocation && ( +
+
+

+ {selectedLocation.name} +

+ +
+ +
+
+ Type + + {selectedLocation.type} + +
+ {selectedLocation.address && ( +
+ Address + {selectedLocation.address} +
+ )} +
+ Assets + {selectedLocation.assetCount ?? 0} +
+
+ +
+

+ Assets at this location +

+

Asset list loads from API

+
+
+ )} + + {showAdd && ( + setShowAdd(false)} + onSave={handleAdd} + locations={locations} + /> + )} +
+ ); +} diff --git a/frontend/opsce/features/settings/SettingsPage.tsx b/frontend/opsce/features/settings/SettingsPage.tsx new file mode 100644 index 00000000..62648d19 --- /dev/null +++ b/frontend/opsce/features/settings/SettingsPage.tsx @@ -0,0 +1,285 @@ +'use client'; + +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { User, Lock, CheckCircle } from 'lucide-react'; +import { useAuthStore } from '@/store/auth.store'; + +const profileSchema = z.object({ + fullName: z.string().min(1, 'Full name is required'), + email: z.string().email('Valid email required'), +}); +type ProfileForm = z.infer; + +const passwordSchema = z + .object({ + currentPassword: z.string().min(1, 'Current password is required'), + newPassword: z.string().min(8, 'Password must be at least 8 characters'), + confirmNewPassword: z.string(), + }) + .refine((d) => d.newPassword === d.confirmNewPassword, { + message: "Passwords don't match", + path: ['confirmNewPassword'], + }); +type PasswordForm = z.infer; + +type Tab = 'profile' | 'security'; + +async function patchProfile(data: Partial) { + const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:6003/api'; + const token = typeof window !== 'undefined' ? localStorage.getItem('accessToken') : ''; + const res = await fetch(`${API}/users/me`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error('Update failed'); +} + +async function changePassword(data: PasswordForm) { + const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:6003/api'; + const token = typeof window !== 'undefined' ? localStorage.getItem('accessToken') : ''; + const res = await fetch(`${API}/auth/change-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ + currentPassword: data.currentPassword, + newPassword: data.newPassword, + }), + }); + if (!res.ok) throw new Error('Password change failed'); +} + +export function SettingsPage() { + const user = useAuthStore((s) => s.user); + const [tab, setTab] = useState('profile'); + const [profileSaved, setProfileSaved] = useState(false); + const [passwordSaved, setPasswordSaved] = useState(false); + const [profileError, setProfileError] = useState(''); + const [passwordError, setPasswordError] = useState(''); + + const profileForm = useForm({ + resolver: zodResolver(profileSchema), + defaultValues: { + fullName: user + ? `${(user as { firstName?: string }).firstName ?? ''} ${(user as { lastName?: string }).lastName ?? ''}`.trim() + : '', + email: user?.email ?? '', + }, + }); + + const passwordForm = useForm({ + resolver: zodResolver(passwordSchema), + }); + + const onProfileSubmit = async (data: ProfileForm) => { + setProfileError(''); + try { + await patchProfile(data); + setProfileSaved(true); + setTimeout(() => setProfileSaved(false), 2000); + } catch { + setProfileError('Failed to save. Try again.'); + } + }; + + const onPasswordSubmit = async (data: PasswordForm) => { + setPasswordError(''); + try { + await changePassword(data); + passwordForm.reset(); + setPasswordSaved(true); + setTimeout(() => setPasswordSaved(false), 2000); + } catch { + setPasswordError('Failed to change password. Check your current password.'); + } + }; + + const inputClass = + 'w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-900'; + const errorClass = 'text-xs text-red-500 mt-1'; + + return ( +
+
+

Settings

+

+ Manage your profile and account preferences +

+
+ + {/* Tabs */} +
+ {(['profile', 'security'] as Tab[]).map((t) => ( + + ))} +
+ + {tab === 'profile' && ( +
+
+
+ +
+
+

Profile

+

Update your account details

+
+
+ +
+
+ + + {profileForm.formState.errors.fullName && ( +

{profileForm.formState.errors.fullName.message}

+ )} +
+ +
+ + + {profileForm.formState.errors.email && ( +

{profileForm.formState.errors.email.message}

+ )} +
+ +
+ + {profileSaved && ( + + + Profile updated successfully + + )} + {profileError && ( + {profileError} + )} +
+
+
+ )} + + {tab === 'security' && ( +
+
+
+ +
+
+

Change Password

+

+ Choose a strong password (min. 8 characters) +

+
+
+ +
+
+ + + {passwordForm.formState.errors.currentPassword && ( +

+ {passwordForm.formState.errors.currentPassword.message} +

+ )} +
+ +
+ + + {passwordForm.formState.errors.newPassword && ( +

+ {passwordForm.formState.errors.newPassword.message} +

+ )} +
+ +
+ + + {passwordForm.formState.errors.confirmNewPassword && ( +

+ {passwordForm.formState.errors.confirmNewPassword.message} +

+ )} +
+ +
+ + {passwordSaved && ( + + + Password updated + + )} + {passwordError && ( + {passwordError} + )} +
+
+
+ )} +
+ ); +}