Remove onelga-local-services project from main repo
This commit is contained in:
parent
ff259c1903
commit
9254469ef4
5
projects/README.md
Normal file
5
projects/README.md
Normal file
@ -0,0 +1,5 @@
|
||||
# Projects
|
||||
|
||||
The `onelga-local-services` project has been moved to a dedicated repository at `../onelga-local-services`.
|
||||
|
||||
Please clone or copy that repository to continue working on the application outside of the main `awesome-copilot` repo.
|
||||
@ -1,148 +0,0 @@
|
||||
# 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 -- <email>` — 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
|
||||
@ -1,9 +0,0 @@
|
||||
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"
|
||||
@ -1,26 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
/node_modules
|
||||
/dist
|
||||
/.env
|
||||
/prisma/dev.db*
|
||||
@ -1,48 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@ -1,94 +0,0 @@
|
||||
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())
|
||||
}
|
||||
@ -1,81 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@ -1,33 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@ -1,97 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@ -1,53 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@ -1,30 +0,0 @@
|
||||
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 <email>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
unlock(email)
|
||||
.then(async () => {
|
||||
await prisma.$disconnect();
|
||||
})
|
||||
.catch(async (error) => {
|
||||
console.error(error);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
@ -1,24 +0,0 @@
|
||||
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;
|
||||
@ -1,32 +0,0 @@
|
||||
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<RequiredEnv, string> & {
|
||||
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;
|
||||
@ -1,31 +0,0 @@
|
||||
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);
|
||||
};
|
||||
@ -1,90 +0,0 @@
|
||||
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) });
|
||||
};
|
||||
@ -1,50 +0,0 @@
|
||||
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);
|
||||
};
|
||||
@ -1,42 +0,0 @@
|
||||
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();
|
||||
};
|
||||
};
|
||||
@ -1,16 +0,0 @@
|
||||
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;
|
||||
@ -1,11 +0,0 @@
|
||||
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;
|
||||
@ -1,12 +0,0 @@
|
||||
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;
|
||||
@ -1,26 +0,0 @@
|
||||
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;
|
||||
@ -1,19 +0,0 @@
|
||||
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();
|
||||
@ -1,43 +0,0 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@ -1,60 +0,0 @@
|
||||
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 };
|
||||
};
|
||||
@ -1,77 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,5 +0,0 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export default prisma;
|
||||
@ -1,15 +0,0 @@
|
||||
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 };
|
||||
};
|
||||
@ -1,28 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
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:
|
||||
@ -1 +0,0 @@
|
||||
VITE_API_URL="http://localhost:4000"
|
||||
@ -1,22 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
/node_modules
|
||||
/dist
|
||||
/.env
|
||||
@ -1,12 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Onelga Local Services</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,38 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@ -1,37 +0,0 @@
|
||||
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 (
|
||||
<Routes>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="news" element={<NewsPage />} />
|
||||
<Route path="login" element={<LoginPage />} />
|
||||
|
||||
<Route element={<ProtectedRoute roles={[Role.ADMIN, Role.STAFF]} />}>
|
||||
<Route path="admin" element={<AdminDashboardPage />} />
|
||||
<Route path="admin/news" element={<NewsManagementPage />} />
|
||||
<Route path="admin/requests" element={<ServiceRequestsPage />} />
|
||||
</Route>
|
||||
|
||||
<Route element={<ProtectedRoute roles={[Role.STAFF]} />}>
|
||||
<Route path="staff" element={<StaffDashboardPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@ -1,49 +0,0 @@
|
||||
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 (
|
||||
<Box sx={{ minHeight: "100vh", bgcolor: "background.default" }}>
|
||||
<AppBar position="static">
|
||||
<Toolbar>
|
||||
<Typography variant="h6" sx={{ flexGrow: 1 }} component={RouterLink} to="/" color="inherit" style={{ textDecoration: "none" }}>
|
||||
Onelga Local Services
|
||||
</Typography>
|
||||
{user ? (
|
||||
<>
|
||||
<Button color="inherit" component={RouterLink} to="/admin">
|
||||
Dashboard
|
||||
</Button>
|
||||
<Button color="inherit" component={RouterLink} to="/news">
|
||||
News
|
||||
</Button>
|
||||
<Button color="inherit" onClick={handleLogout}>
|
||||
Logout
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button color="inherit" component={RouterLink} to="/login">
|
||||
Login
|
||||
</Button>
|
||||
)}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Container sx={{ py: 4 }}>
|
||||
<Outlet />
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppLayout;
|
||||
@ -1,23 +0,0 @@
|
||||
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 <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (roles && !roles.includes(user.role)) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
@ -1,37 +0,0 @@
|
||||
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(
|
||||
<React.StrictMode>
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@ -1,29 +0,0 @@
|
||||
import { Box, Button, Grid, Typography } from "@mui/material";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
|
||||
const HomePage = () => {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Welcome to Onelga Local Services
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" paragraph>
|
||||
Access government services, manage applications, and stay informed with the latest community news.
|
||||
</Typography>
|
||||
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||||
<Grid item>
|
||||
<Button variant="contained" component={RouterLink} to="/login">
|
||||
Login
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Button variant="outlined" component={RouterLink} to="/news">
|
||||
View News
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
@ -1,53 +0,0 @@
|
||||
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<string | null>(null);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
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 (
|
||||
<Stack alignItems="center" justifyContent="center">
|
||||
<Paper elevation={3} sx={{ p: 4, width: "100%", maxWidth: 400 }}>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Sign in
|
||||
</Typography>
|
||||
<Box component="form" onSubmit={handleSubmit} noValidate>
|
||||
<Stack spacing={2}>
|
||||
<TextField label="Email" type="email" fullWidth required value={email} onChange={(event) => setEmail(event.target.value)} />
|
||||
<TextField label="Password" type="password" fullWidth required value={password} onChange={(event) => setPassword(event.target.value)} />
|
||||
{error ? <Alert severity="error">{error}</Alert> : null}
|
||||
<Button type="submit" variant="contained" disabled={loading}>
|
||||
{loading ? "Signing in..." : "Sign in"}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
@ -1,40 +0,0 @@
|
||||
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 <CircularProgress />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Alert severity="error">Failed to load news.</Alert>;
|
||||
}
|
||||
|
||||
if (!data?.length) {
|
||||
return <Typography>No articles available.</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid container spacing={2}>
|
||||
{data.map((article) => (
|
||||
<Grid item xs={12} md={6} key={article.id}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{article.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{article.content.slice(0, 140)}...
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewsPage;
|
||||
@ -1,48 +0,0 @@
|
||||
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 <CircularProgress />;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return <Alert severity="error">Unable to load dashboard statistics.</Alert>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<Grid container spacing={2}>
|
||||
{cards.map((card) => (
|
||||
<Grid item xs={12} md={3} key={card.label}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6">{card.label}</Typography>
|
||||
<Typography variant="h4" color="primary">
|
||||
{card.value}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminDashboardPage;
|
||||
@ -1,42 +0,0 @@
|
||||
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 <CircularProgress />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Alert severity="error">Unable to load articles.</Alert>;
|
||||
}
|
||||
|
||||
if (!data?.length) {
|
||||
return <Typography>No articles found.</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<List>
|
||||
{data.map((article) => (
|
||||
<ListItem key={article.id} divider>
|
||||
<ListItemText
|
||||
primary={article.title}
|
||||
secondary={article.published ? "Published" : "Draft"}
|
||||
primaryTypographyProps={{ fontWeight: 600 }}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewsManagementPage;
|
||||
@ -1,52 +0,0 @@
|
||||
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 <CircularProgress />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Alert severity="error">Unable to load service requests.</Alert>;
|
||||
}
|
||||
|
||||
if (!data?.length) {
|
||||
return <Typography>No service requests found.</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Type</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Created</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{data.map((application: ApplicationSummary) => (
|
||||
<TableRow key={application.id}>
|
||||
<TableCell>{application.type}</TableCell>
|
||||
<TableCell>{application.status}</TableCell>
|
||||
<TableCell>{new Date(application.createdAt).toLocaleDateString()}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServiceRequestsPage;
|
||||
@ -1,23 +0,0 @@
|
||||
import { Alert, Box, Typography } from "@mui/material";
|
||||
import { useAppSelector } from "../../store/hooks";
|
||||
|
||||
const StaffDashboardPage = () => {
|
||||
const { user } = useAppSelector((state) => state.auth);
|
||||
|
||||
if (!user) {
|
||||
return <Alert severity="error">You must be signed in.</Alert>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Welcome back, {user.firstName}
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
Use the admin dashboard to review and manage service assignments.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default StaffDashboardPage;
|
||||
@ -1,52 +0,0 @@
|
||||
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<LoginResponse>("/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<Array<ApplicationSummary & { createdAt: string }>>("/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<NewsArticle[]>("/news/admin/articles", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const fetchPublicNews = async () => {
|
||||
const response = await api.get<NewsArticle[]>("/news");
|
||||
return response.data;
|
||||
};
|
||||
@ -1,5 +0,0 @@
|
||||
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
|
||||
import type { AppDispatch, RootState } from "./index";
|
||||
|
||||
export const useAppDispatch = () => useDispatch<AppDispatch>();
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||
@ -1,11 +0,0 @@
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import authReducer from "./slices/authSlice";
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
auth: authReducer,
|
||||
},
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
@ -1,30 +0,0 @@
|
||||
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;
|
||||
@ -1,25 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@ -1,21 +0,0 @@
|
||||
{
|
||||
"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" }]
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user