diff --git a/projects/onelga-local-services/README.md b/projects/onelga-local-services/README.md new file mode 100644 index 0000000..7b5cd37 --- /dev/null +++ b/projects/onelga-local-services/README.md @@ -0,0 +1,148 @@ +# Onelga Local Services + +Onelga Local Services is a full-stack platform that demonstrates a municipal services portal with authentication, admin tooling, and citizen-facing news. + +## Project Structure + +``` +projects/onelga-local-services/ +├── backend/ # Node.js + Express + Prisma API +├── frontend/ # React + TypeScript dashboard and citizen portal +└── docker-compose.yml +``` + +## Getting Started + +### Prerequisites + +- Node.js 18+ +- pnpm, npm, or yarn +- (Optional) Docker Desktop if you prefer running Postgres locally via Docker Compose + +### Backend Setup + +1. Install dependencies: + ```bash + cd projects/onelga-local-services/backend + npm install + ``` +2. Copy environment variables: + ```bash + cp .env.example .env + ``` + Update the values if needed. The default configuration uses SQLite. +3. Generate the Prisma client and apply migrations: + ```bash + npx prisma migrate dev --name init + ``` +4. Seed demo data (admin, staff, and citizen accounts plus sample content): + ```bash + npm run prisma:seed + ``` +5. Start the development server: + ```bash + npm run dev + ``` + The API will be available at `http://localhost:4000`. + +### Frontend Setup + +1. Install dependencies: + ```bash + cd projects/onelga-local-services/frontend + npm install + ``` +2. Copy environment variables: + ```bash + cp .env.example .env + ``` + Adjust `VITE_API_URL` if your backend runs on a different URL. +3. Start the development server: + ```bash + npm run dev + ``` + Open the app at `http://localhost:5173`. + +### Demo Credentials + +Use the seeded accounts to explore the platform locally: + +| Role | Email | Password | +| ------ | -------------------- | ---------- | +| Admin | `admin@onelga.local` | `Passw0rd!` | +| Staff | `staff@onelga.local` | `Passw0rd!` | +| Citizen| `citizen@onelga.local` | `Passw0rd!` | + +### Smoke Test Script + +After starting the backend, run a quick health check: + +```bash +cd projects/onelga-local-services/backend +npx ts-node scripts/smoke-test.ts +``` + +The script signs in with the admin account, fetches dashboard stats, and retrieves the news feed. + +### Unlocking Accounts + +If you lock an account due to repeated failed logins, reset it with: + +```bash +cd projects/onelga-local-services/backend +npm run unlock:user -- user@example.com +``` + +### Docker Compose (Optional) + +A `docker-compose.yml` file is provided to run Postgres locally. Update `DATABASE_URL` in your backend `.env` before starting. + +```bash +cd projects/onelga-local-services +docker-compose up -d +``` + +Run `npx prisma migrate deploy` against the Postgres instance and reseed data. + +## Available Scripts + +### Backend + +- `npm run dev` — Start the Express server with hot reload. +- `npm run build` — Compile TypeScript output. +- `npm run prisma:seed` — Seed baseline data. +- `npm run seed:test` — Seed extended demo data. +- `npm run unlock:user -- ` — Clear failed login attempts for a user. +- `npm run typecheck` / `npm run lint` — Static analysis. + +### Frontend + +- `npm run dev` — Start the Vite dev server. +- `npm run build` — Build production assets. +- `npm run typecheck` / `npm run lint` — Static analysis. + +## API Overview + +Key backend endpoints are namespaced under `/api`: + +- `POST /api/auth/login` — Authenticate a user. +- `GET /api/auth/validate` — Validate a JWT. +- `GET /api/admin/stats` — Admin dashboard metrics. +- `GET /api/admin/applications` — List service applications. +- `POST /api/admin/applications/:id/decide` — Approve or reject an application. +- `GET /api/news` — Public news feed. +- `GET /api/news/admin/articles` — Admin news management (requires admin or staff role). + +Refer to the source code for the full set of routes and controllers. + +## Testing Checklist + +1. Run `npm run typecheck` in both `backend` and `frontend`. +2. Start the backend and frontend dev servers. +3. Sign in with the admin account. +4. Navigate to `/admin`, `/admin/news`, and `/admin/requests` to confirm data loads. +5. Execute the smoke test script. + +## License + +MIT diff --git a/projects/onelga-local-services/backend/.env.example b/projects/onelga-local-services/backend/.env.example new file mode 100644 index 0000000..0c84954 --- /dev/null +++ b/projects/onelga-local-services/backend/.env.example @@ -0,0 +1,9 @@ +PORT=4000 +DATABASE_URL="file:./dev.db" +JWT_SECRET="your-jwt-secret" +JWT_EXPIRES_IN="1d" +FRONTEND_URL="http://localhost:5173" +SMTP_HOST="smtp.example.com" +SMTP_PORT=587 +SMTP_USER="username" +SMTP_PASS="password" diff --git a/projects/onelga-local-services/backend/.eslintrc.json b/projects/onelga-local-services/backend/.eslintrc.json new file mode 100644 index 0000000..12be062 --- /dev/null +++ b/projects/onelga-local-services/backend/.eslintrc.json @@ -0,0 +1,26 @@ +{ + "env": { + "es2021": true, + "node": true + }, + "extends": [ + "eslint:recommended", + "plugin:import/recommended", + "plugin:promise/recommended", + "plugin:jsdoc/recommended", + "prettier" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "project": ["./tsconfig.json"], + "tsconfigRootDir": __dirname, + "sourceType": "module" + }, + "plugins": ["@typescript-eslint", "prettier"], + "rules": { + "prettier/prettier": "error", + "import/no-unresolved": "off", + "jsdoc/require-jsdoc": "off" + }, + "ignorePatterns": ["dist", "node_modules"] +} diff --git a/projects/onelga-local-services/backend/.gitignore b/projects/onelga-local-services/backend/.gitignore new file mode 100644 index 0000000..b098fa4 --- /dev/null +++ b/projects/onelga-local-services/backend/.gitignore @@ -0,0 +1,4 @@ +/node_modules +/dist +/.env +/prisma/dev.db* diff --git a/projects/onelga-local-services/backend/package.json b/projects/onelga-local-services/backend/package.json new file mode 100644 index 0000000..7674925 --- /dev/null +++ b/projects/onelga-local-services/backend/package.json @@ -0,0 +1,48 @@ +{ + "name": "onelga-local-services-backend", + "version": "1.0.0", + "description": "Backend API for the Onelga Local Services platform", + "main": "dist/server.js", + "scripts": { + "dev": "ts-node-dev --respawn --transpile-only src/server.ts", + "build": "tsc", + "start": "node dist/server.js", + "lint": "eslint 'src/**/*.{ts,tsx}'", + "typecheck": "tsc --noEmit", + "prisma:migrate": "prisma migrate dev", + "prisma:generate": "prisma generate", + "prisma:seed": "ts-node prisma/seed.ts", + "seed:test": "ts-node scripts/seed-test.ts", + "seed:staff": "ts-node scripts/seed-test-staff.ts", + "unlock:user": "ts-node scripts/unlock-user.ts" + }, + "dependencies": { + "@prisma/client": "^5.15.0", + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "jsonwebtoken": "^9.0.2", + "morgan": "^1.10.0" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.5", + "@types/morgan": "^1.9.7", + "@types/node": "^20.14.9", + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-import": "^2.29.1", + "eslint-plugin-jsdoc": "^48.2.6", + "eslint-plugin-prettier": "^5.1.3", + "eslint-plugin-promise": "^6.1.1", + "prisma": "^5.15.0", + "ts-node": "^10.9.2", + "ts-node-dev": "^2.0.0", + "typescript": "^5.4.5", + "@typescript-eslint/eslint-plugin": "^7.13.0", + "@typescript-eslint/parser": "^7.13.0" + } +} diff --git a/projects/onelga-local-services/backend/prisma/schema.prisma b/projects/onelga-local-services/backend/prisma/schema.prisma new file mode 100644 index 0000000..91431df --- /dev/null +++ b/projects/onelga-local-services/backend/prisma/schema.prisma @@ -0,0 +1,94 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +enum Role { + CITIZEN + STAFF + ADMIN +} + +enum ApplicationStatus { + PENDING + UNDER_REVIEW + APPROVED + REJECTED + PENDING_DOCUMENTS + COMPLETED +} + +model User { + id String @id @default(cuid()) + email String @unique + firstName String + lastName String + passwordHash String + role Role @default(CITIZEN) + failedLoginAttempts Int @default(0) + lockoutUntil DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + newsArticles NewsArticle[] + applications Application[] + auditLogs AuditLog[] +} + +model Application { + id String @id @default(cuid()) + user User @relation(fields: [userId], references: [id]) + userId String + type String + status ApplicationStatus @default(PENDING) + data Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + documents Document[] + assignments ServiceAssignment[] +} + +model Document { + id String @id @default(cuid()) + application Application @relation(fields: [applicationId], references: [id]) + applicationId String + filename String + url String + status String + uploadedAt DateTime @default(now()) +} + +model NewsArticle { + id String @id @default(cuid()) + title String + slug String @unique + content String + published Boolean @default(false) + publishedAt DateTime? + author User? @relation(fields: [authorId], references: [id]) + authorId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model ServiceAssignment { + id String @id @default(cuid()) + application Application @relation(fields: [applicationId], references: [id]) + applicationId String + staff User @relation(fields: [staffId], references: [id]) + staffId String + notes String? + createdAt DateTime @default(now()) +} + +model AuditLog { + id String @id @default(cuid()) + actor User? @relation(fields: [actorId], references: [id]) + actorId String? + action String + details Json? + createdAt DateTime @default(now()) +} diff --git a/projects/onelga-local-services/backend/prisma/seed.ts b/projects/onelga-local-services/backend/prisma/seed.ts new file mode 100644 index 0000000..dd6760b --- /dev/null +++ b/projects/onelga-local-services/backend/prisma/seed.ts @@ -0,0 +1,81 @@ +import "dotenv/config"; +import { PrismaClient, Role, ApplicationStatus } from "@prisma/client"; +import bcrypt from "bcryptjs"; + +const prisma = new PrismaClient(); + +const users = [ + { + email: "admin@onelga.local", + role: Role.ADMIN, + firstName: "Admin", + lastName: "User", + }, + { + email: "staff@onelga.local", + role: Role.STAFF, + firstName: "Staff", + lastName: "Member", + }, + { + email: "citizen@onelga.local", + role: Role.CITIZEN, + firstName: "Citizen", + lastName: "Resident", + }, +]; + +async function main() { + const passwordHash = await bcrypt.hash("Passw0rd!", 10); + for (const user of users) { + await prisma.user.upsert({ + where: { email: user.email }, + update: {}, + create: { + ...user, + passwordHash, + }, + }); + } + + const admin = await prisma.user.findUnique({ where: { email: "admin@onelga.local" } }); + const citizen = await prisma.user.findUnique({ where: { email: "citizen@onelga.local" } }); + if (!admin || !citizen) { + throw new Error("Seed users not found"); + } + + await prisma.newsArticle.upsert({ + where: { slug: "welcome-to-onelga" }, + update: {}, + create: { + title: "Welcome to Onelga Services", + slug: "welcome-to-onelga", + content: "Discover services, news, and updates for Onelga citizens.", + published: true, + publishedAt: new Date(), + authorId: admin.id, + }, + }); + + await prisma.application.upsert({ + where: { id: "demo-application" }, + update: {}, + create: { + id: "demo-application", + type: "building-permit", + status: ApplicationStatus.PENDING, + data: { projectName: "Community Center" }, + userId: citizen.id, + }, + }); +} + +main() + .then(async () => { + await prisma.$disconnect(); + }) + .catch(async (error) => { + console.error(error); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/projects/onelga-local-services/backend/scripts/seed-test-staff.ts b/projects/onelga-local-services/backend/scripts/seed-test-staff.ts new file mode 100644 index 0000000..7e26f52 --- /dev/null +++ b/projects/onelga-local-services/backend/scripts/seed-test-staff.ts @@ -0,0 +1,33 @@ +import "dotenv/config"; +import { PrismaClient, Role } from "@prisma/client"; +import bcrypt from "bcryptjs"; + +const prisma = new PrismaClient(); + +async function seedStaff() { + const passwordHash = await bcrypt.hash("Passw0rd!", 10); + + await prisma.user.upsert({ + where: { email: "staff@onelga.local" }, + update: {}, + create: { + email: "staff@onelga.local", + firstName: "Staff", + lastName: "Member", + role: Role.STAFF, + passwordHash, + }, + }); + + console.log("Seeded staff account"); +} + +seedStaff() + .then(async () => { + await prisma.$disconnect(); + }) + .catch(async (error) => { + console.error(error); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/projects/onelga-local-services/backend/scripts/seed-test.ts b/projects/onelga-local-services/backend/scripts/seed-test.ts new file mode 100644 index 0000000..0c5f22e --- /dev/null +++ b/projects/onelga-local-services/backend/scripts/seed-test.ts @@ -0,0 +1,97 @@ +import "dotenv/config"; +import { PrismaClient, Role, ApplicationStatus } from "@prisma/client"; +import bcrypt from "bcryptjs"; + +const prisma = new PrismaClient(); + +async function seed() { + const passwordHash = await bcrypt.hash("Passw0rd!", 10); + + const admin = await prisma.user.upsert({ + where: { email: "admin@onelga.local" }, + update: {}, + create: { + email: "admin@onelga.local", + firstName: "Admin", + lastName: "User", + role: Role.ADMIN, + passwordHash, + }, + }); + + const staff = await prisma.user.upsert({ + where: { email: "staff@onelga.local" }, + update: {}, + create: { + email: "staff@onelga.local", + firstName: "Staff", + lastName: "Member", + role: Role.STAFF, + passwordHash, + }, + }); + + const citizen = await prisma.user.upsert({ + where: { email: "citizen@onelga.local" }, + update: {}, + create: { + email: "citizen@onelga.local", + firstName: "Citizen", + lastName: "Resident", + role: Role.CITIZEN, + passwordHash, + }, + }); + + await prisma.newsArticle.createMany({ + data: [ + { + title: "City Approves Park Renovation", + slug: "park-renovation", + content: "The city council approved the renovation budget for Central Park.", + published: true, + publishedAt: new Date(), + authorId: admin.id, + }, + { + title: "Seasonal Road Maintenance Schedule", + slug: "road-maintenance", + content: "Road maintenance is scheduled for the coming weeks across the city.", + published: true, + publishedAt: new Date(), + authorId: staff.id, + }, + ], + skipDuplicates: true, + }); + + await prisma.application.createMany({ + data: [ + { + userId: citizen.id, + type: "business-license", + status: ApplicationStatus.UNDER_REVIEW, + data: { businessName: "Citizen Bakery" }, + }, + { + userId: citizen.id, + type: "waste-management", + status: ApplicationStatus.PENDING, + data: { requestedBins: 2 }, + }, + ], + skipDuplicates: true, + }); + + console.log("Seeded test data"); +} + +seed() + .then(async () => { + await prisma.$disconnect(); + }) + .catch(async (error) => { + console.error(error); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/projects/onelga-local-services/backend/scripts/smoke-test.ts b/projects/onelga-local-services/backend/scripts/smoke-test.ts new file mode 100644 index 0000000..fb38778 --- /dev/null +++ b/projects/onelga-local-services/backend/scripts/smoke-test.ts @@ -0,0 +1,53 @@ +const BASE_URL = process.env.BASE_URL ?? "http://localhost:4000"; +const ADMIN_EMAIL = process.env.ADMIN_EMAIL ?? "admin@onelga.local"; +const PASSWORD = process.env.ADMIN_PASSWORD ?? "Passw0rd!"; + +type LoginResponse = { + token: string; +}; + +type AdminStats = { + totalUsers: number; + totalApplications: number; + pendingApplications: number; + publishedArticles: number; +}; + +async function main() { + const loginResponse = await fetch(`${BASE_URL}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: ADMIN_EMAIL, password: PASSWORD }), + }); + + if (!loginResponse.ok) { + throw new Error(`Login failed with status ${loginResponse.status}`); + } + + const loginData = (await loginResponse.json()) as LoginResponse; + console.log("Login successful"); + + const statsResponse = await fetch(`${BASE_URL}/api/admin/stats`, { + headers: { Authorization: `Bearer ${loginData.token}` }, + }); + + if (!statsResponse.ok) { + throw new Error(`Fetching stats failed with status ${statsResponse.status}`); + } + + const stats = (await statsResponse.json()) as AdminStats; + console.log("Admin stats:", stats); + + const newsResponse = await fetch(`${BASE_URL}/api/news`); + if (!newsResponse.ok) { + throw new Error(`Fetching news failed with status ${newsResponse.status}`); + } + + const news = (await newsResponse.json()) as unknown[]; + console.log(`Fetched ${news.length} news articles`); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/projects/onelga-local-services/backend/scripts/unlock-user.ts b/projects/onelga-local-services/backend/scripts/unlock-user.ts new file mode 100644 index 0000000..3fe430d --- /dev/null +++ b/projects/onelga-local-services/backend/scripts/unlock-user.ts @@ -0,0 +1,30 @@ +import "dotenv/config"; +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +async function unlock(email: string) { + const user = await prisma.user.update({ + where: { email: email.toLowerCase() }, + data: { failedLoginAttempts: 0, lockoutUntil: null }, + }); + + console.log(`Unlocked user ${user.email}`); +} + +const email = process.argv[2]; + +if (!email) { + console.error("Usage: ts-node scripts/unlock-user.ts "); + process.exit(1); +} + +unlock(email) + .then(async () => { + await prisma.$disconnect(); + }) + .catch(async (error) => { + console.error(error); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/projects/onelga-local-services/backend/src/app.ts b/projects/onelga-local-services/backend/src/app.ts new file mode 100644 index 0000000..910fae5 --- /dev/null +++ b/projects/onelga-local-services/backend/src/app.ts @@ -0,0 +1,24 @@ +import cors from "cors"; +import express from "express"; +import morgan from "morgan"; +import env from "./config/env"; +import routes from "./routes"; + +const app = express(); + +app.use(cors({ origin: env.FRONTEND_URL, credentials: true })); +app.use(express.json()); +app.use(morgan("dev")); + +app.get("/api/health", (_req, res) => { + res.json({ status: "ok" }); +}); + +app.use("/api", routes); + +app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + console.error(err); + res.status(500).json({ message: "Internal server error" }); +}); + +export default app; diff --git a/projects/onelga-local-services/backend/src/config/env.ts b/projects/onelga-local-services/backend/src/config/env.ts new file mode 100644 index 0000000..f77ba20 --- /dev/null +++ b/projects/onelga-local-services/backend/src/config/env.ts @@ -0,0 +1,32 @@ +import dotenv from "dotenv"; + +dotenv.config(); + +const requiredEnvVars = ["PORT", "DATABASE_URL", "JWT_SECRET", "JWT_EXPIRES_IN", "FRONTEND_URL"] as const; + +type RequiredEnv = (typeof requiredEnvVars)[number]; + +type EnvConfig = Record & { + smtpHost?: string; + smtpPort?: number; + smtpUser?: string; + smtpPass?: string; +}; + +const env: EnvConfig = requiredEnvVars.reduce((acc, key) => { + const value = process.env[key]; + if (!value) { + throw new Error(`Missing required environment variable: ${key}`); + } + acc[key] = value; + return acc; +}, {} as EnvConfig); + +if (process.env.SMTP_HOST) { + env.smtpHost = process.env.SMTP_HOST; + env.smtpPort = process.env.SMTP_PORT ? Number(process.env.SMTP_PORT) : undefined; + env.smtpUser = process.env.SMTP_USER; + env.smtpPass = process.env.SMTP_PASS; +} + +export default env; diff --git a/projects/onelga-local-services/backend/src/controllers/adminController.ts b/projects/onelga-local-services/backend/src/controllers/adminController.ts new file mode 100644 index 0000000..b5cc629 --- /dev/null +++ b/projects/onelga-local-services/backend/src/controllers/adminController.ts @@ -0,0 +1,31 @@ +import { Request, Response } from "express"; +import { ApplicationStatus } from "@prisma/client"; +import { listUsers } from "../services/userService"; +import { getDashboardStats, listApplications, updateApplicationStatus } from "../services/applicationService"; + +export const getStats = async (_req: Request, res: Response) => { + const stats = await getDashboardStats(); + return res.json(stats); +}; + +export const getUsers = async (_req: Request, res: Response) => { + const users = await listUsers(); + return res.json(users); +}; + +export const getApplications = async (req: Request, res: Response) => { + const status = req.query.status as ApplicationStatus | undefined; + const applications = await listApplications(status ? { status } : undefined); + return res.json(applications); +}; + +export const decideApplication = async (req: Request, res: Response) => { + const { id } = req.params; + const { status } = req.body as { status: ApplicationStatus }; + if (!status) { + return res.status(400).json({ message: "Status is required" }); + } + + const updated = await updateApplicationStatus(id, status); + return res.json(updated); +}; diff --git a/projects/onelga-local-services/backend/src/controllers/authController.ts b/projects/onelga-local-services/backend/src/controllers/authController.ts new file mode 100644 index 0000000..382829d --- /dev/null +++ b/projects/onelga-local-services/backend/src/controllers/authController.ts @@ -0,0 +1,90 @@ +import { Request, Response } from "express"; +import { Role } from "@prisma/client"; +import { + findUserByEmail, + createUser, + verifyPassword, + recordFailedAttempt, + resetFailedAttempts, + getUserById, +} from "../services/userService"; +import { signToken } from "../utils/token"; + +const normalizeEmail = (email: string) => email.trim().toLowerCase(); + +const sanitizeUser = (user: { id: string; email: string; role: Role; firstName: string; lastName: string }) => ({ + id: user.id, + email: user.email, + role: user.role, + firstName: user.firstName, + lastName: user.lastName, +}); + +export const register = async (req: Request, res: Response) => { + const { email, password, firstName, lastName } = req.body; + if (!email || !password) { + return res.status(400).json({ message: "Email and password are required" }); + } + + if (!firstName || !lastName) { + return res.status(400).json({ message: "First and last name are required" }); + } + + const existing = await findUserByEmail(normalizeEmail(email)); + if (existing) { + return res.status(409).json({ message: "Email already in use" }); + } + + const user = await createUser({ email, password, firstName, lastName }); + const token = signToken({ sub: user.id, role: user.role }); + return res.status(201).json({ + user: sanitizeUser(user), + token, + }); +}; + +export const login = async (req: Request, res: Response) => { + const { email, password } = req.body; + if (!email || !password) { + return res.status(400).json({ message: "Email and password are required" }); + } + + const user = await findUserByEmail(normalizeEmail(email)); + if (!user) { + return res.status(401).json({ message: "Invalid credentials" }); + } + + if (user.lockoutUntil && user.lockoutUntil > new Date()) { + return res.status(423).json({ message: "Account locked. Try again later." }); + } + + const passwordMatches = await verifyPassword(password, user.passwordHash); + if (!passwordMatches) { + await recordFailedAttempt(user.id); + return res.status(401).json({ message: "Invalid credentials" }); + } + + if (user.failedLoginAttempts > 0 || user.lockoutUntil) { + await resetFailedAttempts(user.id); + } + + const token = signToken({ sub: user.id, role: user.role }); + return res.json({ + token, + user: sanitizeUser(user), + }); +}; + +export const validateToken = async (req: Request, res: Response) => { + const { user } = req as Request & { user?: { id: string; role: Role } }; + if (!user) { + return res.status(401).json({ message: "Invalid token" }); + } + + const freshUser = await getUserById(user.id); + if (!freshUser) { + return res.status(401).json({ message: "Invalid token" }); + } + + return res.json({ user: sanitizeUser(freshUser) }); +}; diff --git a/projects/onelga-local-services/backend/src/controllers/newsController.ts b/projects/onelga-local-services/backend/src/controllers/newsController.ts new file mode 100644 index 0000000..9558292 --- /dev/null +++ b/projects/onelga-local-services/backend/src/controllers/newsController.ts @@ -0,0 +1,50 @@ +import { Request, Response } from "express"; +import { createArticle, deleteArticle, getAdminStats, getArticleBySlug, listAdminArticles, listPublishedArticles, updateArticle } from "../services/newsService"; + +export const getPublicNews = async (_req: Request, res: Response) => { + const articles = await listPublishedArticles(); + return res.json(articles); +}; + +export const getArticle = async (req: Request, res: Response) => { + const article = await getArticleBySlug(req.params.slug); + if (!article || !article.published) { + return res.status(404).json({ message: "Article not found" }); + } + + return res.json(article); +}; + +export const getAdminArticles = async (_req: Request, res: Response) => { + const articles = await listAdminArticles(); + return res.json(articles); +}; + +export const createAdminArticle = async (req: Request, res: Response) => { + const { title, slug, content, published } = req.body; + const user = (req as Request & { user?: { id: string } }).user; + if (!title || !slug) { + return res.status(400).json({ message: "Title and slug are required" }); + } + + const article = await createArticle({ title, slug, content, published, authorId: user?.id }); + return res.status(201).json(article); +}; + +export const updateAdminArticle = async (req: Request, res: Response) => { + const { id } = req.params; + const { title, slug, content, published } = req.body; + const article = await updateArticle(id, { title, slug, content, published }); + return res.json(article); +}; + +export const removeAdminArticle = async (req: Request, res: Response) => { + const { id } = req.params; + await deleteArticle(id); + return res.status(204).send(); +}; + +export const getNewsStats = async (_req: Request, res: Response) => { + const stats = await getAdminStats(); + return res.json(stats); +}; diff --git a/projects/onelga-local-services/backend/src/middleware/auth.ts b/projects/onelga-local-services/backend/src/middleware/auth.ts new file mode 100644 index 0000000..e21938d --- /dev/null +++ b/projects/onelga-local-services/backend/src/middleware/auth.ts @@ -0,0 +1,42 @@ +import { Role } from "@prisma/client"; +import { NextFunction, Request, Response } from "express"; +import { verifyToken } from "../utils/token"; +import { getUserById } from "../services/userService"; + +export interface AuthenticatedRequest extends Request { + user?: { + id: string; + role: Role; + }; +} + +export const authenticate = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith("Bearer ")) { + return res.status(401).json({ message: "Authentication token missing" }); + } + + try { + const token = authHeader.substring(7); + const decoded = verifyToken(token); + const user = await getUserById(decoded.sub); + if (!user) { + return res.status(401).json({ message: "Invalid authentication token" }); + } + + req.user = { id: user.id, role: user.role }; + return next(); + } catch (error) { + return res.status(401).json({ message: "Invalid authentication token" }); + } +}; + +export const authorize = (roles: Role[]) => { + return (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + if (!req.user || !roles.includes(req.user.role)) { + return res.status(403).json({ message: "You do not have permission to perform this action" }); + } + + return next(); + }; +}; diff --git a/projects/onelga-local-services/backend/src/routes/adminRoutes.ts b/projects/onelga-local-services/backend/src/routes/adminRoutes.ts new file mode 100644 index 0000000..76b568c --- /dev/null +++ b/projects/onelga-local-services/backend/src/routes/adminRoutes.ts @@ -0,0 +1,16 @@ +import { Role } from "@prisma/client"; +import { Router } from "express"; +import { authenticate, authorize } from "../middleware/auth"; +import { decideApplication, getApplications, getStats, getUsers } from "../controllers/adminController"; + +const router = Router(); + +router.use(authenticate, authorize([Role.ADMIN, Role.STAFF])); + +router.get("/dashboard/stats", getStats); +router.get("/stats", getStats); +router.get("/users", authorize([Role.ADMIN]), getUsers); +router.get("/applications", getApplications); +router.post("/applications/:id/decide", decideApplication); + +export default router; diff --git a/projects/onelga-local-services/backend/src/routes/authRoutes.ts b/projects/onelga-local-services/backend/src/routes/authRoutes.ts new file mode 100644 index 0000000..f054813 --- /dev/null +++ b/projects/onelga-local-services/backend/src/routes/authRoutes.ts @@ -0,0 +1,11 @@ +import { Router } from "express"; +import { authenticate } from "../middleware/auth"; +import { login, register, validateToken } from "../controllers/authController"; + +const router = Router(); + +router.post("/login", login); +router.post("/register", register); +router.get("/validate", authenticate, validateToken); + +export default router; diff --git a/projects/onelga-local-services/backend/src/routes/index.ts b/projects/onelga-local-services/backend/src/routes/index.ts new file mode 100644 index 0000000..95ab800 --- /dev/null +++ b/projects/onelga-local-services/backend/src/routes/index.ts @@ -0,0 +1,12 @@ +import { Router } from "express"; +import authRoutes from "./authRoutes"; +import adminRoutes from "./adminRoutes"; +import newsRoutes from "./newsRoutes"; + +const router = Router(); + +router.use("/auth", authRoutes); +router.use("/admin", adminRoutes); +router.use("/news", newsRoutes); + +export default router; diff --git a/projects/onelga-local-services/backend/src/routes/newsRoutes.ts b/projects/onelga-local-services/backend/src/routes/newsRoutes.ts new file mode 100644 index 0000000..057fd54 --- /dev/null +++ b/projects/onelga-local-services/backend/src/routes/newsRoutes.ts @@ -0,0 +1,26 @@ +import { Role } from "@prisma/client"; +import { Router } from "express"; +import { authenticate, authorize } from "../middleware/auth"; +import { + createAdminArticle, + getAdminArticles, + getArticle, + getNewsStats, + getPublicNews, + removeAdminArticle, + updateAdminArticle, +} from "../controllers/newsController"; + +const router = Router(); + +router.get("/", getPublicNews); + +router.get("/admin/articles", authenticate, authorize([Role.ADMIN, Role.STAFF]), getAdminArticles); +router.post("/admin/articles", authenticate, authorize([Role.ADMIN, Role.STAFF]), createAdminArticle); +router.put("/admin/articles/:id", authenticate, authorize([Role.ADMIN, Role.STAFF]), updateAdminArticle); +router.delete("/admin/articles/:id", authenticate, authorize([Role.ADMIN, Role.STAFF]), removeAdminArticle); +router.get("/admin/stats", authenticate, authorize([Role.ADMIN, Role.STAFF]), getNewsStats); + +router.get("/:slug", getArticle); + +export default router; diff --git a/projects/onelga-local-services/backend/src/server.ts b/projects/onelga-local-services/backend/src/server.ts new file mode 100644 index 0000000..99339e3 --- /dev/null +++ b/projects/onelga-local-services/backend/src/server.ts @@ -0,0 +1,19 @@ +import env from "./config/env"; +import app from "./app"; +import prisma from "./utils/prisma"; + +const port = Number(env.PORT); + +async function start() { + try { + await prisma.$connect(); + app.listen(port, () => { + console.log(`Server listening on port ${port}`); + }); + } catch (error) { + console.error("Failed to start server", error); + process.exit(1); + } +} + +void start(); diff --git a/projects/onelga-local-services/backend/src/services/applicationService.ts b/projects/onelga-local-services/backend/src/services/applicationService.ts new file mode 100644 index 0000000..b66b629 --- /dev/null +++ b/projects/onelga-local-services/backend/src/services/applicationService.ts @@ -0,0 +1,43 @@ +import { ApplicationStatus, Prisma } from "@prisma/client"; +import prisma from "../utils/prisma"; + +export const listApplications = async (filters?: { status?: ApplicationStatus }) => { + return prisma.application.findMany({ + where: filters, + include: { + user: { + select: { id: true, email: true, firstName: true, lastName: true, role: true }, + }, + documents: true, + assignments: true, + }, + orderBy: { createdAt: "desc" }, + }); +}; + +export const updateApplicationStatus = (id: string, status: ApplicationStatus) => { + return prisma.application.update({ + where: { id }, + data: { status }, + }); +}; + +export const createApplication = (data: Prisma.ApplicationCreateInput) => { + return prisma.application.create({ data }); +}; + +export const getDashboardStats = async () => { + const [totalUsers, totalApplications, pendingApplications, publishedArticles] = await Promise.all([ + prisma.user.count(), + prisma.application.count(), + prisma.application.count({ where: { status: ApplicationStatus.PENDING } }), + prisma.newsArticle.count({ where: { published: true } }), + ]); + + return { + totalUsers, + totalApplications, + pendingApplications, + publishedArticles, + }; +}; diff --git a/projects/onelga-local-services/backend/src/services/newsService.ts b/projects/onelga-local-services/backend/src/services/newsService.ts new file mode 100644 index 0000000..5536d61 --- /dev/null +++ b/projects/onelga-local-services/backend/src/services/newsService.ts @@ -0,0 +1,60 @@ +import prisma from "../utils/prisma"; + +export const listPublishedArticles = () => { + return prisma.newsArticle.findMany({ + where: { published: true }, + orderBy: { publishedAt: "desc" }, + }); +}; + +export const getArticleBySlug = (slug: string) => { + return prisma.newsArticle.findUnique({ where: { slug } }); +}; + +export const listAdminArticles = () => { + return prisma.newsArticle.findMany({ orderBy: { createdAt: "desc" } }); +}; + +export const createArticle = (data: { + title: string; + slug: string; + content: string; + published?: boolean; + authorId?: string; +}) => { + return prisma.newsArticle.create({ + data: { + ...data, + publishedAt: data.published ? new Date() : null, + }, + }); +}; + +export const updateArticle = (id: string, data: { + title?: string; + slug?: string; + content?: string; + published?: boolean; +}) => { + return prisma.newsArticle.update({ + where: { id }, + data: { + ...data, + publishedAt: typeof data.published === "boolean" ? (data.published ? new Date() : null) : undefined, + }, + }); +}; + +export const deleteArticle = (id: string) => { + return prisma.newsArticle.delete({ where: { id } }); +}; + +export const getAdminStats = async () => { + const [totalArticles, publishedArticles, drafts] = await Promise.all([ + prisma.newsArticle.count(), + prisma.newsArticle.count({ where: { published: true } }), + prisma.newsArticle.count({ where: { published: false } }), + ]); + + return { totalArticles, publishedArticles, drafts }; +}; diff --git a/projects/onelga-local-services/backend/src/services/userService.ts b/projects/onelga-local-services/backend/src/services/userService.ts new file mode 100644 index 0000000..05a9a4f --- /dev/null +++ b/projects/onelga-local-services/backend/src/services/userService.ts @@ -0,0 +1,77 @@ +import { Role } from "@prisma/client"; +import bcrypt from "bcryptjs"; +import prisma from "../utils/prisma"; + +const MAX_FAILED_ATTEMPTS = 5; +const LOCKOUT_DURATION_MINUTES = 15; + +export const findUserByEmail = (email: string) => { + return prisma.user.findUnique({ where: { email: email.toLowerCase() } }); +}; + +export const getUserById = (id: string) => { + return prisma.user.findUnique({ where: { id } }); +}; + +export const createUser = async (params: { + email: string; + password: string; + firstName: string; + lastName: string; + role?: Role; +}) => { + const hash = await bcrypt.hash(params.password, 10); + return prisma.user.create({ + data: { + email: params.email.toLowerCase(), + passwordHash: hash, + firstName: params.firstName, + lastName: params.lastName, + role: params.role ?? Role.CITIZEN, + }, + }); +}; + +export const verifyPassword = (password: string, hash: string) => bcrypt.compare(password, hash); + +export const recordFailedAttempt = async (userId: string) => { + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + return null; + } + + const failedAttempts = user.failedLoginAttempts + 1; + const updateData: { failedLoginAttempts: number; lockoutUntil?: Date } = { + failedLoginAttempts: failedAttempts, + }; + + if (failedAttempts >= MAX_FAILED_ATTEMPTS) { + updateData.lockoutUntil = new Date(Date.now() + LOCKOUT_DURATION_MINUTES * 60 * 1000); + } + + return prisma.user.update({ + where: { id: userId }, + data: updateData, + }); +}; + +export const resetFailedAttempts = (userId: string) => { + return prisma.user.update({ + where: { id: userId }, + data: { failedLoginAttempts: 0, lockoutUntil: null }, + }); +}; + +export const listUsers = () => { + return prisma.user.findMany({ + orderBy: { createdAt: "desc" }, + select: { + id: true, + email: true, + firstName: true, + lastName: true, + role: true, + createdAt: true, + }, + }); +}; diff --git a/projects/onelga-local-services/backend/src/utils/prisma.ts b/projects/onelga-local-services/backend/src/utils/prisma.ts new file mode 100644 index 0000000..b5bf6ce --- /dev/null +++ b/projects/onelga-local-services/backend/src/utils/prisma.ts @@ -0,0 +1,5 @@ +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +export default prisma; diff --git a/projects/onelga-local-services/backend/src/utils/token.ts b/projects/onelga-local-services/backend/src/utils/token.ts new file mode 100644 index 0000000..1c3f646 --- /dev/null +++ b/projects/onelga-local-services/backend/src/utils/token.ts @@ -0,0 +1,15 @@ +import jwt from "jsonwebtoken"; +import env from "../config/env"; + +interface TokenPayload { + sub: string; + role: string; +} + +export const signToken = (payload: TokenPayload) => { + return jwt.sign(payload, env.JWT_SECRET, { expiresIn: env.JWT_EXPIRES_IN }); +}; + +export const verifyToken = (token: string) => { + return jwt.verify(token, env.JWT_SECRET) as TokenPayload & { iat: number; exp: number }; +}; diff --git a/projects/onelga-local-services/backend/tsconfig.json b/projects/onelga-local-services/backend/tsconfig.json new file mode 100644 index 0000000..9fdbe53 --- /dev/null +++ b/projects/onelga-local-services/backend/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "rootDir": "src", + "outDir": "dist", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "types": [ + "node" + ], + "moduleResolution": "node", + "lib": [ + "ES2020", + "DOM" + ] + }, + "include": [ + "src" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/projects/onelga-local-services/docker-compose.yml b/projects/onelga-local-services/docker-compose.yml new file mode 100644 index 0000000..cab42bd --- /dev/null +++ b/projects/onelga-local-services/docker-compose.yml @@ -0,0 +1,17 @@ +version: "3.9" + +services: + db: + image: postgres:16 + restart: unless-stopped + environment: + POSTGRES_USER: onelga + POSTGRES_PASSWORD: onelga + POSTGRES_DB: onelga + ports: + - "5432:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + +volumes: + postgres-data: diff --git a/projects/onelga-local-services/frontend/.env.example b/projects/onelga-local-services/frontend/.env.example new file mode 100644 index 0000000..e82d0a0 --- /dev/null +++ b/projects/onelga-local-services/frontend/.env.example @@ -0,0 +1 @@ +VITE_API_URL="http://localhost:4000" diff --git a/projects/onelga-local-services/frontend/.eslintrc.json b/projects/onelga-local-services/frontend/.eslintrc.json new file mode 100644 index 0000000..f788a87 --- /dev/null +++ b/projects/onelga-local-services/frontend/.eslintrc.json @@ -0,0 +1,22 @@ +{ + "env": { + "browser": true, + "es2021": true + }, + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:react-hooks/recommended", + "plugin:react-refresh/recommended", + "prettier" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module" + }, + "plugins": ["@typescript-eslint"], + "rules": { + "react-refresh/only-export-components": "off" + } +} diff --git a/projects/onelga-local-services/frontend/.gitignore b/projects/onelga-local-services/frontend/.gitignore new file mode 100644 index 0000000..e5b1704 --- /dev/null +++ b/projects/onelga-local-services/frontend/.gitignore @@ -0,0 +1,3 @@ +/node_modules +/dist +/.env diff --git a/projects/onelga-local-services/frontend/index.html b/projects/onelga-local-services/frontend/index.html new file mode 100644 index 0000000..b2bc1f0 --- /dev/null +++ b/projects/onelga-local-services/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Onelga Local Services + + +
+ + + diff --git a/projects/onelga-local-services/frontend/package.json b/projects/onelga-local-services/frontend/package.json new file mode 100644 index 0000000..f742e5c --- /dev/null +++ b/projects/onelga-local-services/frontend/package.json @@ -0,0 +1,38 @@ +{ + "name": "onelga-local-services-frontend", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "lint": "eslint 'src/**/*.{ts,tsx}'", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@emotion/react": "^11.11.4", + "@emotion/styled": "^11.11.5", + "@mui/icons-material": "^5.15.21", + "@mui/material": "^5.15.21", + "@reduxjs/toolkit": "^2.2.5", + "@tanstack/react-query": "^5.40.0", + "axios": "^1.7.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-redux": "^9.1.2", + "react-router-dom": "^6.23.1" + }, + "devDependencies": { + "@types/node": "^20.14.9", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@typescript-eslint/eslint-plugin": "^7.13.0", + "@typescript-eslint/parser": "^7.13.0", + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-react-hooks": "^4.6.2", + "eslint-plugin-react-refresh": "^0.4.7", + "typescript": "^5.4.5", + "vite": "^5.2.11" + } +} diff --git a/projects/onelga-local-services/frontend/src/App.tsx b/projects/onelga-local-services/frontend/src/App.tsx new file mode 100644 index 0000000..e41d4f3 --- /dev/null +++ b/projects/onelga-local-services/frontend/src/App.tsx @@ -0,0 +1,37 @@ +import { Navigate, Route, Routes } from "react-router-dom"; +import AppLayout from "./components/AppLayout"; +import ProtectedRoute from "./components/ProtectedRoute"; +import LoginPage from "./pages/LoginPage"; +import NewsPage from "./pages/NewsPage"; +import AdminDashboardPage from "./pages/dashboards/AdminDashboardPage"; +import NewsManagementPage from "./pages/dashboards/NewsManagementPage"; +import ServiceRequestsPage from "./pages/dashboards/ServiceRequestsPage"; +import StaffDashboardPage from "./pages/dashboards/StaffDashboardPage"; +import HomePage from "./pages/HomePage"; +import { Role } from "./types"; + +const App = () => { + return ( + + }> + } /> + } /> + } /> + + }> + } /> + } /> + } /> + + + }> + } /> + + + } /> + + + ); +}; + +export default App; diff --git a/projects/onelga-local-services/frontend/src/components/AppLayout.tsx b/projects/onelga-local-services/frontend/src/components/AppLayout.tsx new file mode 100644 index 0000000..f1e35c2 --- /dev/null +++ b/projects/onelga-local-services/frontend/src/components/AppLayout.tsx @@ -0,0 +1,49 @@ +import { AppBar, Box, Button, Container, Toolbar, Typography } from "@mui/material"; +import { Link as RouterLink, Outlet, useNavigate } from "react-router-dom"; +import { useAppDispatch, useAppSelector } from "../store/hooks"; +import { clearCredentials } from "../store/slices/authSlice"; + +const AppLayout = () => { + const { user } = useAppSelector((state) => state.auth); + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + + const handleLogout = () => { + dispatch(clearCredentials()); + navigate("/login"); + }; + + return ( + + + + + Onelga Local Services + + {user ? ( + <> + + + + + ) : ( + + )} + + + + + + + ); +}; + +export default AppLayout; diff --git a/projects/onelga-local-services/frontend/src/components/ProtectedRoute.tsx b/projects/onelga-local-services/frontend/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..0eb65c9 --- /dev/null +++ b/projects/onelga-local-services/frontend/src/components/ProtectedRoute.tsx @@ -0,0 +1,23 @@ +import { Role } from "../types"; +import { Navigate, Outlet } from "react-router-dom"; +import { useAppSelector } from "../store/hooks"; + +interface ProtectedRouteProps { + roles?: Role[]; +} + +const ProtectedRoute = ({ roles }: ProtectedRouteProps) => { + const { token, user } = useAppSelector((state) => state.auth); + + if (!token || !user) { + return ; + } + + if (roles && !roles.includes(user.role)) { + return ; + } + + return ; +}; + +export default ProtectedRoute; diff --git a/projects/onelga-local-services/frontend/src/main.tsx b/projects/onelga-local-services/frontend/src/main.tsx new file mode 100644 index 0000000..16e4a93 --- /dev/null +++ b/projects/onelga-local-services/frontend/src/main.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { CssBaseline, ThemeProvider, createTheme } from "@mui/material"; +import { Provider } from "react-redux"; +import { BrowserRouter } from "react-router-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import App from "./App"; +import { store } from "./store"; + +const theme = createTheme({ + palette: { + mode: "light", + primary: { + main: "#0063b2", + }, + secondary: { + main: "#f57c00", + }, + }, +}); + +const queryClient = new QueryClient(); + +ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + + + + + + + + + + + , +); diff --git a/projects/onelga-local-services/frontend/src/pages/HomePage.tsx b/projects/onelga-local-services/frontend/src/pages/HomePage.tsx new file mode 100644 index 0000000..e758ed9 --- /dev/null +++ b/projects/onelga-local-services/frontend/src/pages/HomePage.tsx @@ -0,0 +1,29 @@ +import { Box, Button, Grid, Typography } from "@mui/material"; +import { Link as RouterLink } from "react-router-dom"; + +const HomePage = () => { + return ( + + + Welcome to Onelga Local Services + + + Access government services, manage applications, and stay informed with the latest community news. + + + + + + + + + + + ); +}; + +export default HomePage; diff --git a/projects/onelga-local-services/frontend/src/pages/LoginPage.tsx b/projects/onelga-local-services/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..a9ea572 --- /dev/null +++ b/projects/onelga-local-services/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,53 @@ +import { useState } from "react"; +import { Alert, Box, Button, Paper, Stack, TextField, Typography } from "@mui/material"; +import { useNavigate } from "react-router-dom"; +import { login as loginRequest } from "../services/api"; +import { useAppDispatch } from "../store/hooks"; +import { setCredentials } from "../store/slices/authSlice"; + +const LoginPage = () => { + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const [email, setEmail] = useState("admin@onelga.local"); + const [password, setPassword] = useState("Passw0rd!"); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setLoading(true); + setError(null); + + try { + const response = await loginRequest(email, password); + dispatch(setCredentials(response)); + navigate("/admin"); + } catch (err) { + setError("Login failed. Please check your credentials."); + } finally { + setLoading(false); + } + }; + + return ( + + + + Sign in + + + + setEmail(event.target.value)} /> + setPassword(event.target.value)} /> + {error ? {error} : null} + + + + + + ); +}; + +export default LoginPage; diff --git a/projects/onelga-local-services/frontend/src/pages/NewsPage.tsx b/projects/onelga-local-services/frontend/src/pages/NewsPage.tsx new file mode 100644 index 0000000..f4fe1ea --- /dev/null +++ b/projects/onelga-local-services/frontend/src/pages/NewsPage.tsx @@ -0,0 +1,40 @@ +import { useQuery } from "@tanstack/react-query"; +import { Alert, Card, CardContent, CircularProgress, Grid, Typography } from "@mui/material"; +import { fetchPublicNews } from "../services/api"; + +const NewsPage = () => { + const { data, isLoading, error } = useQuery({ queryKey: ["news"], queryFn: fetchPublicNews }); + + if (isLoading) { + return ; + } + + if (error) { + return Failed to load news.; + } + + if (!data?.length) { + return No articles available.; + } + + return ( + + {data.map((article) => ( + + + + + {article.title} + + + {article.content.slice(0, 140)}... + + + + + ))} + + ); +}; + +export default NewsPage; diff --git a/projects/onelga-local-services/frontend/src/pages/dashboards/AdminDashboardPage.tsx b/projects/onelga-local-services/frontend/src/pages/dashboards/AdminDashboardPage.tsx new file mode 100644 index 0000000..40f2070 --- /dev/null +++ b/projects/onelga-local-services/frontend/src/pages/dashboards/AdminDashboardPage.tsx @@ -0,0 +1,48 @@ +import { useQuery } from "@tanstack/react-query"; +import { Alert, Card, CardContent, CircularProgress, Grid, Typography } from "@mui/material"; +import { fetchAdminStats } from "../../services/api"; +import { useAppSelector } from "../../store/hooks"; + +const AdminDashboardPage = () => { + const { token } = useAppSelector((state) => state.auth); + + const { data, isLoading, error } = useQuery({ + queryKey: ["admin-stats"], + queryFn: () => fetchAdminStats(token ?? ""), + enabled: Boolean(token), + }); + + if (isLoading) { + return ; + } + + if (error || !data) { + return Unable to load dashboard statistics.; + } + + const cards = [ + { label: "Total Users", value: data.totalUsers }, + { label: "Total Applications", value: data.totalApplications }, + { label: "Pending Applications", value: data.pendingApplications }, + { label: "Published Articles", value: data.publishedArticles }, + ]; + + return ( + + {cards.map((card) => ( + + + + {card.label} + + {card.value} + + + + + ))} + + ); +}; + +export default AdminDashboardPage; diff --git a/projects/onelga-local-services/frontend/src/pages/dashboards/NewsManagementPage.tsx b/projects/onelga-local-services/frontend/src/pages/dashboards/NewsManagementPage.tsx new file mode 100644 index 0000000..c495dbf --- /dev/null +++ b/projects/onelga-local-services/frontend/src/pages/dashboards/NewsManagementPage.tsx @@ -0,0 +1,42 @@ +import { useQuery } from "@tanstack/react-query"; +import { Alert, CircularProgress, List, ListItem, ListItemText, Typography } from "@mui/material"; +import { fetchAdminArticles } from "../../services/api"; +import { useAppSelector } from "../../store/hooks"; + +const NewsManagementPage = () => { + const { token } = useAppSelector((state) => state.auth); + + const { data, isLoading, error } = useQuery({ + queryKey: ["admin-articles"], + queryFn: () => fetchAdminArticles(token ?? ""), + enabled: Boolean(token), + }); + + if (isLoading) { + return ; + } + + if (error) { + return Unable to load articles.; + } + + if (!data?.length) { + return No articles found.; + } + + return ( + + {data.map((article) => ( + + + + ))} + + ); +}; + +export default NewsManagementPage; diff --git a/projects/onelga-local-services/frontend/src/pages/dashboards/ServiceRequestsPage.tsx b/projects/onelga-local-services/frontend/src/pages/dashboards/ServiceRequestsPage.tsx new file mode 100644 index 0000000..cee4ebc --- /dev/null +++ b/projects/onelga-local-services/frontend/src/pages/dashboards/ServiceRequestsPage.tsx @@ -0,0 +1,52 @@ +import { useQuery } from "@tanstack/react-query"; +import { Alert, CircularProgress, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Typography } from "@mui/material"; +import { fetchApplications } from "../../services/api"; +import { useAppSelector } from "../../store/hooks"; +import type { ApplicationSummary } from "../../types"; + +const ServiceRequestsPage = () => { + const { token } = useAppSelector((state) => state.auth); + + const { data, isLoading, error } = useQuery({ + queryKey: ["admin-applications"], + queryFn: () => fetchApplications(token ?? ""), + enabled: Boolean(token), + }); + + if (isLoading) { + return ; + } + + if (error) { + return Unable to load service requests.; + } + + if (!data?.length) { + return No service requests found.; + } + + return ( + + + + + Type + Status + Created + + + + {data.map((application: ApplicationSummary) => ( + + {application.type} + {application.status} + {new Date(application.createdAt).toLocaleDateString()} + + ))} + +
+
+ ); +}; + +export default ServiceRequestsPage; diff --git a/projects/onelga-local-services/frontend/src/pages/dashboards/StaffDashboardPage.tsx b/projects/onelga-local-services/frontend/src/pages/dashboards/StaffDashboardPage.tsx new file mode 100644 index 0000000..dd7e063 --- /dev/null +++ b/projects/onelga-local-services/frontend/src/pages/dashboards/StaffDashboardPage.tsx @@ -0,0 +1,23 @@ +import { Alert, Box, Typography } from "@mui/material"; +import { useAppSelector } from "../../store/hooks"; + +const StaffDashboardPage = () => { + const { user } = useAppSelector((state) => state.auth); + + if (!user) { + return You must be signed in.; + } + + return ( + + + Welcome back, {user.firstName} + + + Use the admin dashboard to review and manage service assignments. + + + ); +}; + +export default StaffDashboardPage; diff --git a/projects/onelga-local-services/frontend/src/services/api.ts b/projects/onelga-local-services/frontend/src/services/api.ts new file mode 100644 index 0000000..dd99af5 --- /dev/null +++ b/projects/onelga-local-services/frontend/src/services/api.ts @@ -0,0 +1,52 @@ +import axios from "axios"; +import type { ApplicationSummary, NewsArticle, User } from "../types"; + +const api = axios.create({ + baseURL: "/api", +}); + +export interface LoginResponse { + token: string; + user: User; +} + +export const login = async (email: string, password: string) => { + const response = await api.post("/auth/login", { email, password }); + return response.data; +}; + +export const fetchAdminStats = async (token: string) => { + const response = await api.get("/admin/stats", { + headers: { Authorization: `Bearer ${token}` }, + }); + return response.data as { + totalUsers: number; + totalApplications: number; + pendingApplications: number; + publishedArticles: number; + }; +}; + +export const fetchApplications = async (token: string) => { + const response = await api.get>("/admin/applications", { + headers: { Authorization: `Bearer ${token}` }, + }); + return response.data.map((application) => ({ + id: application.id, + type: application.type, + status: application.status, + createdAt: application.createdAt, + })); +}; + +export const fetchAdminArticles = async (token: string) => { + const response = await api.get("/news/admin/articles", { + headers: { Authorization: `Bearer ${token}` }, + }); + return response.data; +}; + +export const fetchPublicNews = async () => { + const response = await api.get("/news"); + return response.data; +}; diff --git a/projects/onelga-local-services/frontend/src/store/hooks.ts b/projects/onelga-local-services/frontend/src/store/hooks.ts new file mode 100644 index 0000000..878ac35 --- /dev/null +++ b/projects/onelga-local-services/frontend/src/store/hooks.ts @@ -0,0 +1,5 @@ +import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; +import type { AppDispatch, RootState } from "./index"; + +export const useAppDispatch = () => useDispatch(); +export const useAppSelector: TypedUseSelectorHook = useSelector; diff --git a/projects/onelga-local-services/frontend/src/store/index.ts b/projects/onelga-local-services/frontend/src/store/index.ts new file mode 100644 index 0000000..592be3d --- /dev/null +++ b/projects/onelga-local-services/frontend/src/store/index.ts @@ -0,0 +1,11 @@ +import { configureStore } from "@reduxjs/toolkit"; +import authReducer from "./slices/authSlice"; + +export const store = configureStore({ + reducer: { + auth: authReducer, + }, +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; diff --git a/projects/onelga-local-services/frontend/src/store/slices/authSlice.ts b/projects/onelga-local-services/frontend/src/store/slices/authSlice.ts new file mode 100644 index 0000000..af07fc3 --- /dev/null +++ b/projects/onelga-local-services/frontend/src/store/slices/authSlice.ts @@ -0,0 +1,30 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import type { User } from "../../types"; + +export interface AuthState { + token: string | null; + user: User | null; +} + +const initialState: AuthState = { + token: null, + user: null, +}; + +const authSlice = createSlice({ + name: "auth", + initialState, + reducers: { + setCredentials: (state, action: PayloadAction<{ token: string; user: User }>) => { + state.token = action.payload.token; + state.user = action.payload.user; + }, + clearCredentials: (state) => { + state.token = null; + state.user = null; + }, + }, +}); + +export const { setCredentials, clearCredentials } = authSlice.actions; +export default authSlice.reducer; diff --git a/projects/onelga-local-services/frontend/src/types/index.ts b/projects/onelga-local-services/frontend/src/types/index.ts new file mode 100644 index 0000000..12f1740 --- /dev/null +++ b/projects/onelga-local-services/frontend/src/types/index.ts @@ -0,0 +1,25 @@ +export type Role = "ADMIN" | "STAFF" | "CITIZEN"; + +export interface User { + id: string; + email: string; + firstName: string; + lastName: string; + role: Role; +} + +export interface ApplicationSummary { + id: string; + type: string; + status: string; + createdAt: string; +} + +export interface NewsArticle { + id: string; + title: string; + slug: string; + content: string; + published: boolean; + publishedAt?: string; +} diff --git a/projects/onelga-local-services/frontend/tsconfig.json b/projects/onelga-local-services/frontend/tsconfig.json new file mode 100644 index 0000000..2f27c13 --- /dev/null +++ b/projects/onelga-local-services/frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ES2020"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/projects/onelga-local-services/frontend/tsconfig.node.json b/projects/onelga-local-services/frontend/tsconfig.node.json new file mode 100644 index 0000000..9d31e2a --- /dev/null +++ b/projects/onelga-local-services/frontend/tsconfig.node.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "Node", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/projects/onelga-local-services/frontend/vite.config.ts b/projects/onelga-local-services/frontend/vite.config.ts new file mode 100644 index 0000000..3f32e57 --- /dev/null +++ b/projects/onelga-local-services/frontend/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + "/api": { + target: process.env.VITE_API_URL ?? "http://localhost:4000", + changeOrigin: true, + }, + }, + }, +});