create-nightwing 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +5 -0
- package/README.md +51 -0
- package/bin/index.js +71 -0
- package/package.json +37 -0
- package/templates/employee-rbac/client/index.html +1 -0
- package/templates/employee-rbac/client/package.json +22 -0
- package/templates/employee-rbac/client/postcss.config.js +1 -0
- package/templates/employee-rbac/client/src/api/http.js +8 -0
- package/templates/employee-rbac/client/src/components/ui.jsx +7 -0
- package/templates/employee-rbac/client/src/index.css +5 -0
- package/templates/employee-rbac/client/src/layouts/AppLayout.jsx +18 -0
- package/templates/employee-rbac/client/src/main.jsx +5 -0
- package/templates/employee-rbac/client/src/pages/Accounts.jsx +14 -0
- package/templates/employee-rbac/client/src/pages/AdminDashboard.jsx +11 -0
- package/templates/employee-rbac/client/src/pages/Employees.jsx +14 -0
- package/templates/employee-rbac/client/src/pages/Login.jsx +9 -0
- package/templates/employee-rbac/client/src/pages/Templates.jsx +10 -0
- package/templates/employee-rbac/client/src/pages/UserDashboard.jsx +10 -0
- package/templates/employee-rbac/client/src/router/index.jsx +24 -0
- package/templates/employee-rbac/client/tailwind.config.js +1 -0
- package/templates/employee-rbac/docs/API.md +58 -0
- package/templates/employee-rbac/docs/SQL.md +55 -0
- package/templates/employee-rbac/docs/TAILWIND.md +34 -0
- package/templates/employee-rbac/instructions.md +39 -0
- package/templates/employee-rbac/schema.sql +45 -0
- package/templates/employee-rbac/server/package.json +19 -0
- package/templates/employee-rbac/server/src/app.js +22 -0
- package/templates/employee-rbac/server/src/config/db.js +13 -0
- package/templates/employee-rbac/server/src/controllers/authController.js +48 -0
- package/templates/employee-rbac/server/src/controllers/employeeController.js +28 -0
- package/templates/employee-rbac/server/src/controllers/userController.js +52 -0
- package/templates/employee-rbac/server/src/middleware/auth.js +12 -0
- package/templates/employee-rbac/server/src/routes/authRoutes.js +9 -0
- package/templates/employee-rbac/server/src/routes/employeeRoutes.js +10 -0
- package/templates/employee-rbac/server/src/routes/userRoutes.js +12 -0
- package/templates/employee-rbac/server/src/utils/security.js +3 -0
package/LICENSE
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
|
|
2
|
+
# create-nightwing
|
|
3
|
+
|
|
4
|
+
A clean enterprise starter generator for student exams, internal tools, and real admin systems.
|
|
5
|
+
|
|
6
|
+
It generates one focused fullstack template:
|
|
7
|
+
|
|
8
|
+
- Admin login
|
|
9
|
+
- Employee management
|
|
10
|
+
- Employee-owned user accounts
|
|
11
|
+
- User login
|
|
12
|
+
- Session authentication
|
|
13
|
+
- Password hashing
|
|
14
|
+
- Protected routes
|
|
15
|
+
- Airbnb-inspired UI system
|
|
16
|
+
- Forms, tables, modal CRUD, drawer, stats cards, filters, badges, empty states
|
|
17
|
+
- MySQL schema and beginner-friendly docs
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx create-nightwing my-app
|
|
23
|
+
cd my-app
|
|
24
|
+
mysql -u root -p < schema.sql
|
|
25
|
+
cd server && npm install && npm run dev
|
|
26
|
+
cd ../client && npm install && npm run dev
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Default admin
|
|
30
|
+
|
|
31
|
+
```txt
|
|
32
|
+
username: admin
|
|
33
|
+
password: admin123
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Generated structure
|
|
37
|
+
|
|
38
|
+
```txt
|
|
39
|
+
my-app/
|
|
40
|
+
server/ Express + MySQL + sessions
|
|
41
|
+
client/ React + Vite + Tailwind
|
|
42
|
+
docs/ SQL, Tailwind, API, testing docs
|
|
43
|
+
schema.sql Database setup and seed
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Publish this package
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npm login
|
|
50
|
+
npm publish
|
|
51
|
+
```
|
package/bin/index.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "fs-extra";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { input, confirm } from "@inquirer/prompts";
|
|
5
|
+
import kleur from "kleur";
|
|
6
|
+
import { spawnSync } from "child_process";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
+
const __dirname = path.dirname(__filename);
|
|
11
|
+
|
|
12
|
+
const slugify = (value) => value.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-_]/g, "");
|
|
13
|
+
const dbSafe = (value) => value.trim().replace(/-/g, "_").replace(/[^a-zA-Z0-9_]/g, "_");
|
|
14
|
+
const validPort = (value) => { const n=Number(value); return Number.isInteger(n) && n>0 && n<=65535; };
|
|
15
|
+
|
|
16
|
+
const flags = new Set(process.argv.slice(2).filter(a => a.startsWith("--")));
|
|
17
|
+
const yes = flags.has("--yes") || flags.has("-y");
|
|
18
|
+
function argValue(name, fallback){ const raw=process.argv.find(a=>a.startsWith(`--${name}=`)); return raw ? raw.split("=").slice(1).join("=") : fallback; }
|
|
19
|
+
|
|
20
|
+
let projectName = process.argv.slice(2).find(a => !a.startsWith("--"));
|
|
21
|
+
if (!projectName) {
|
|
22
|
+
projectName = await input({ message: "Project name:", validate: v => slugify(v).length ? true : "Project name is required.", transformer: slugify });
|
|
23
|
+
}
|
|
24
|
+
projectName = slugify(projectName);
|
|
25
|
+
if (!projectName) { console.log(kleur.red("Project name is required.")); process.exit(1); }
|
|
26
|
+
|
|
27
|
+
const dbName = dbSafe(yes ? argValue("db", `${projectName.replace(/-/g,"_")}_db`) : await input({ message: "Database name:", default: `${projectName.replace(/-/g,"_")}_db`, validate: v => dbSafe(v).length ? true : "Database name is required." }));
|
|
28
|
+
const serverPort = yes ? argValue("server-port", "5000") : await input({ message: "Backend port:", default: "5000", validate: v => validPort(v) ? true : "Enter a valid port." });
|
|
29
|
+
const clientPort = yes ? argValue("client-port", "5173") : await input({ message: "Frontend port:", default: "5173", validate: v => validPort(v) ? true : "Enter a valid port." });
|
|
30
|
+
const installDeps = yes ? false : await confirm({ message: "Install dependencies now?", default: false });
|
|
31
|
+
|
|
32
|
+
const templateDir = path.join(__dirname, "..", "templates", "employee-rbac");
|
|
33
|
+
const targetDir = path.join(process.cwd(), projectName);
|
|
34
|
+
if (!fs.existsSync(templateDir)) { console.log(kleur.red("Template folder not found.")); process.exit(1); }
|
|
35
|
+
if (fs.existsSync(targetDir)) { console.log(kleur.red("Folder already exists. Choose another name.")); process.exit(1); }
|
|
36
|
+
|
|
37
|
+
await fs.copy(templateDir, targetDir);
|
|
38
|
+
const replacements = { __PROJECT_NAME__: projectName, __DB_NAME__: dbName, __SERVER_PORT__: String(serverPort), __CLIENT_PORT__: String(clientPort) };
|
|
39
|
+
async function replaceInFile(file){ let content=await fs.readFile(file,"utf8"); for(const [k,v] of Object.entries(replacements)) content=content.replaceAll(k,v); await fs.writeFile(file,content); }
|
|
40
|
+
async function walk(dir){ for(const item of await fs.readdir(dir)){ const full=path.join(dir,item); const stat=await fs.stat(full); if(stat.isDirectory()) await walk(full); else if(/\.(js|jsx|json|css|html|md|sql|env)$/.test(full)) await replaceInFile(full); } }
|
|
41
|
+
await walk(targetDir);
|
|
42
|
+
|
|
43
|
+
await fs.writeFile(path.join(targetDir,"server",".env"), `PORT=${serverPort}
|
|
44
|
+
DB_HOST=localhost
|
|
45
|
+
DB_USER=root
|
|
46
|
+
DB_PASSWORD=
|
|
47
|
+
DB_NAME=${dbName}
|
|
48
|
+
CLIENT_URL=http://localhost:${clientPort}
|
|
49
|
+
SESSION_SECRET=change_this_${projectName}_secret
|
|
50
|
+
NODE_ENV=development
|
|
51
|
+
`);
|
|
52
|
+
await fs.writeFile(path.join(targetDir,"client",".env"), `VITE_API_URL=http://localhost:${serverPort}
|
|
53
|
+
`);
|
|
54
|
+
|
|
55
|
+
if(installDeps){
|
|
56
|
+
console.log(kleur.cyan("Installing server dependencies..."));
|
|
57
|
+
spawnSync("npm",["install"],{cwd:path.join(targetDir,"server"),stdio:"inherit",shell:true});
|
|
58
|
+
console.log(kleur.cyan("Installing client dependencies..."));
|
|
59
|
+
spawnSync("npm",["install"],{cwd:path.join(targetDir,"client"),stdio:"inherit",shell:true});
|
|
60
|
+
}
|
|
61
|
+
console.log(kleur.green(`
|
|
62
|
+
Created ${projectName} successfully.`));
|
|
63
|
+
console.log(`
|
|
64
|
+
Next steps:
|
|
65
|
+
cd ${projectName}
|
|
66
|
+
mysql -u root -p < schema.sql
|
|
67
|
+
cd server && npm install && npm run dev
|
|
68
|
+
cd ../client && npm install && npm run dev
|
|
69
|
+
|
|
70
|
+
Default admin: admin / admin123
|
|
71
|
+
`);
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-nightwing",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Enterprise-ready React + Express + MySQL employee RBAC starter generator.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"create-nightwing": "./bin/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"templates",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"keywords": [
|
|
16
|
+
"cli",
|
|
17
|
+
"react",
|
|
18
|
+
"express",
|
|
19
|
+
"mysql",
|
|
20
|
+
"session",
|
|
21
|
+
"rbac",
|
|
22
|
+
"employee",
|
|
23
|
+
"dashboard",
|
|
24
|
+
"tailwind",
|
|
25
|
+
"starter"
|
|
26
|
+
],
|
|
27
|
+
"author": "House of Logic Ltd",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@inquirer/prompts": "^7.3.3",
|
|
34
|
+
"fs-extra": "^11.3.0",
|
|
35
|
+
"kleur": "^4.1.5"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<!doctype html><html><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0"/><title>__PROJECT_NAME__</title></head><body><div id="root"></div><script type="module" src="/src/main.jsx"></script></body></html>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__PROJECT_NAME__-client",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "vite --host 0.0.0.0 --port __CLIENT_PORT__",
|
|
7
|
+
"build": "vite build",
|
|
8
|
+
"preview": "vite preview"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@vitejs/plugin-react": "latest",
|
|
12
|
+
"vite": "latest",
|
|
13
|
+
"react": "latest",
|
|
14
|
+
"react-dom": "latest",
|
|
15
|
+
"lucide-react": "^0.468.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"tailwindcss": "3.4.17",
|
|
19
|
+
"postcss": "8.4.49",
|
|
20
|
+
"autoprefixer": "10.4.20"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default { plugins: { tailwindcss: {}, autoprefixer: {} } };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
|
|
2
|
+
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:__SERVER_PORT__";
|
|
3
|
+
export async function request(path,{method="GET",body}={}){
|
|
4
|
+
const res=await fetch(`${API_URL}/api${path}`,{method,credentials:"include",headers:{"Content-Type":"application/json"},body:body?JSON.stringify(body):undefined});
|
|
5
|
+
const data=await res.json().catch(()=>({}));
|
|
6
|
+
if(!res.ok) throw new Error(data.message || "Request failed");
|
|
7
|
+
return data;
|
|
8
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
|
|
2
|
+
export function Card({children,className=""}){ return <div className={`rounded-[2rem] border border-slate-200 bg-white p-5 shadow-sm ${className}`}>{children}</div>; }
|
|
3
|
+
export function Button({children,className="",variant="primary",...props}){ const styles=variant==="primary"?"bg-[#FF385C] text-white hover:bg-[#E31C5F]":"border border-slate-200 bg-white text-slate-900 hover:bg-slate-50"; return <button className={`rounded-full px-5 py-3 text-sm font-black transition disabled:opacity-50 ${styles} ${className}`} {...props}>{children}</button>; }
|
|
4
|
+
export function Input(props){ return <input className="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-[#FF385C]" {...props}/>; }
|
|
5
|
+
export function Select(props){ return <select className="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-[#FF385C]" {...props}/>; }
|
|
6
|
+
export function Badge({children,tone="slate"}){ const map={green:"bg-emerald-50 text-emerald-700",red:"bg-rose-50 text-rose-700",amber:"bg-amber-50 text-amber-700",slate:"bg-slate-100 text-slate-700"}; return <span className={`rounded-full px-3 py-1 text-xs font-black ${map[tone]||map.slate}`}>{children}</span>; }
|
|
7
|
+
export function EmptyState({title,note}){ return <div className="rounded-[2rem] border border-dashed border-slate-200 bg-slate-50 p-8 text-center"><h3 className="font-black text-slate-900">{title}</h3><p className="mt-2 text-sm text-slate-500">{note}</p></div>; }
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
|
|
2
|
+
import { LogOut, Menu, Users, LayoutDashboard, FileText, UserRoundCog } from "lucide-react";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { request } from "../api/http";
|
|
5
|
+
const adminNav=[['/admin','Dashboard',LayoutDashboard],['/employees','Employees',Users],['/accounts','Accounts',UserRoundCog],['/templates','Templates',FileText]];
|
|
6
|
+
const userNav=[['/user','My Dashboard',LayoutDashboard]];
|
|
7
|
+
export default function AppLayout({user,children}){
|
|
8
|
+
const [open,setOpen]=useState(false); const nav=user?.role==='admin'?adminNav:userNav;
|
|
9
|
+
async function logout(){ await request('/auth/logout',{method:'POST'}); location.href='/'; }
|
|
10
|
+
return <div className="min-h-screen bg-[#F7F7F7]">
|
|
11
|
+
<aside className={`fixed inset-y-0 left-0 z-40 w-72 border-r border-slate-200 bg-white p-5 transition md:translate-x-0 ${open?'translate-x-0':'-translate-x-full'}`}>
|
|
12
|
+
<div className="rounded-[2rem] bg-slate-950 p-5 text-white"><p className="text-xs font-black uppercase tracking-[0.2em] text-white/50">Nightwing</p><h1 className="mt-2 text-2xl font-black">__PROJECT_NAME__</h1></div>
|
|
13
|
+
<nav className="mt-6 grid gap-2">{nav.map(([href,label,Icon])=><a key={href} href={href} className="flex items-center gap-3 rounded-2xl px-4 py-3 text-sm font-bold text-slate-600 hover:bg-slate-100 hover:text-slate-950"><Icon size={18}/>{label}</a>)}</nav>
|
|
14
|
+
<button onClick={logout} className="absolute bottom-5 left-5 right-5 flex items-center justify-center gap-2 rounded-full border border-slate-200 px-4 py-3 text-sm font-black"><LogOut size={18}/> Logout</button>
|
|
15
|
+
</aside>
|
|
16
|
+
<main className="md:pl-72"><header className="sticky top-0 z-30 border-b border-slate-200 bg-white/80 px-4 py-3 backdrop-blur md:px-8"><div className="flex items-center justify-between"><button onClick={()=>setOpen(!open)} className="rounded-full border p-3 md:hidden"><Menu size={18}/></button><div><p className="text-xs font-black uppercase tracking-[0.2em] text-slate-400">Logged in as</p><p className="font-black">{user?.username} <span className="text-slate-400">/{user?.role}</span></p></div></div></header><section className="p-4 md:p-8">{children}</section></main>
|
|
17
|
+
</div>;
|
|
18
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { request } from "../api/http";
|
|
4
|
+
import { Button, Card, Input, Select, Badge } from "../components/ui";
|
|
5
|
+
export default function Accounts(){
|
|
6
|
+
const [employees,setEmployees]=useState([]),[users,setUsers]=useState([]),[form,setForm]=useState({employeeId:'',username:'',password:'admin123',status:'Active'}),[msg,setMsg]=useState(''),[reset,setReset]=useState({id:'',password:''});
|
|
7
|
+
const load=()=>{request('/employees').then(d=>setEmployees(d.employees));request('/users').then(d=>setUsers(d.users));}; useEffect(()=>{load()},[]);
|
|
8
|
+
async function create(e){e.preventDefault();setMsg('');try{await request('/users',{method:'POST',body:form});setForm({employeeId:'',username:'',password:'admin123',status:'Active'});setMsg('Account created');load();}catch(err){setMsg(err.message)}}
|
|
9
|
+
async function update(u,status){await request(`/users/${u.id}`,{method:'PUT',body:{username:u.username,status}});load();}
|
|
10
|
+
async function del(id){if(confirm('Delete this account?')){await request(`/users/${id}`,{method:'DELETE'});load();}}
|
|
11
|
+
async function resetPass(e){e.preventDefault();await request(`/users/${reset.id}/password`,{method:'PATCH',body:{password:reset.password}});setReset({id:'',password:''});setMsg('Password reset');}
|
|
12
|
+
const free=employees.filter(e=>!users.some(u=>u.employee_id===e.id));
|
|
13
|
+
return <div className="grid gap-6 xl:grid-cols-[420px_1fr]"><div className="grid gap-6"><Card><h1 className="text-2xl font-black">Create Account</h1><p className="mt-2 text-sm text-slate-500">Only employees without an account appear here.</p><form onSubmit={create} className="mt-5 grid gap-3"><Select value={form.employeeId} onChange={e=>setForm({...form,employeeId:e.target.value})}><option value="">Choose employee</option>{free.map(e=><option key={e.id} value={e.id}>{e.full_name}</option>)}</Select><Input placeholder="Username" value={form.username} onChange={e=>setForm({...form,username:e.target.value})}/><Input placeholder="Temporary password" value={form.password} onChange={e=>setForm({...form,password:e.target.value})}/><Select value={form.status} onChange={e=>setForm({...form,status:e.target.value})}><option>Active</option><option>Suspended</option></Select>{msg&&<p className="text-sm font-bold text-slate-500">{msg}</p>}<Button>Create login account</Button></form></Card>{reset.id&&<Card><h2 className="text-xl font-black">Reset Password</h2><form onSubmit={resetPass} className="mt-4 grid gap-3"><Input placeholder="New password" value={reset.password} onChange={e=>setReset({...reset,password:e.target.value})}/><Button>Save password</Button></form></Card>}</div><Card><h2 className="text-2xl font-black">User Accounts</h2><div className="mt-5 overflow-x-auto"><table className="w-full text-left text-sm"><thead><tr className="border-b text-slate-400"><th className="py-3">Account</th><th>Employee</th><th>Status</th><th>Actions</th></tr></thead><tbody>{users.map(u=><tr className="border-b" key={u.id}><td className="py-4 font-black">{u.username}<p className="font-normal text-slate-500">{u.email}</p></td><td>{u.employeeName}<p className="text-slate-500">{u.position}</p></td><td><Badge tone={u.status==='Active'?'green':'red'}>{u.status}</Badge></td><td className="space-x-2"><button className="font-black text-[#FF385C]" onClick={()=>update(u,u.status==='Active'?'Suspended':'Active')}>{u.status==='Active'?'Suspend':'Activate'}</button><button className="font-black" onClick={()=>setReset({id:u.id,password:'newpass123'})}>Reset</button><button className="font-black text-rose-700" onClick={()=>del(u.id)}>Delete</button></td></tr>)}</tbody></table></div></Card></div>
|
|
14
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { Users, UserRoundCog, ShieldCheck, Sparkles } from "lucide-react";
|
|
4
|
+
import { request } from "../api/http";
|
|
5
|
+
import { Card, Badge } from "../components/ui";
|
|
6
|
+
export default function AdminDashboard(){
|
|
7
|
+
const [employees,setEmployees]=useState([]),[users,setUsers]=useState([]);
|
|
8
|
+
useEffect(()=>{request('/employees').then(d=>setEmployees(d.employees));request('/users').then(d=>setUsers(d.users));},[]);
|
|
9
|
+
const stats=[['Employees',employees.length,Users],['Accounts',users.length,UserRoundCog],['Active Employees',employees.filter(e=>e.status==='Active').length,ShieldCheck],['Template Blocks',12,Sparkles]];
|
|
10
|
+
return <div className="grid gap-6"><section className="rounded-[2.5rem] bg-slate-950 p-8 text-white"><p className="text-xs font-black uppercase tracking-[0.2em] text-white/50">Admin Command Center</p><h1 className="mt-3 text-4xl font-black md:text-6xl">Manage people, access, and workflows.</h1></section><div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">{stats.map(([t,v,Icon])=><Card key={t}><Icon className="text-[#FF385C]"/><p className="mt-5 text-sm font-bold text-slate-500">{t}</p><p className="text-4xl font-black">{v}</p></Card>)}</div><Card><div className="flex items-center justify-between"><h2 className="text-xl font-black">Recent Employees</h2><a className="text-sm font-black text-[#FF385C]" href="/employees">Manage</a></div><div className="mt-4 overflow-x-auto"><table className="w-full text-left text-sm"><tbody>{employees.slice(0,5).map(e=><tr className="border-t" key={e.id}><td className="py-4 font-black">{e.full_name}</td><td>{e.position}</td><td><Badge tone={e.status==='Active'?'green':e.status==='On Leave'?'amber':'red'}>{e.status}</Badge></td></tr>)}</tbody></table></div></Card></div>
|
|
11
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { request } from "../api/http";
|
|
4
|
+
import { Button, Card, Input, Select, Badge } from "../components/ui";
|
|
5
|
+
const empty={fullName:'',email:'',phone:'',department:'',position:'',status:'Active',hiredAt:new Date().toISOString().slice(0,10)};
|
|
6
|
+
export default function Employees(){
|
|
7
|
+
const [items,setItems]=useState([]),[form,setForm]=useState(empty),[editing,setEditing]=useState(null),[q,setQ]=useState(''),[msg,setMsg]=useState('');
|
|
8
|
+
const load=()=>request('/employees').then(d=>setItems(d.employees)); useEffect(()=>{load()},[]);
|
|
9
|
+
async function save(e){e.preventDefault(); setMsg(''); const body=form; try{ if(editing) await request(`/employees/${editing}`,{method:'PUT',body}); else await request('/employees',{method:'POST',body}); setForm(empty); setEditing(null); setMsg('Saved successfully'); load(); }catch(err){setMsg(err.message)} }
|
|
10
|
+
function edit(x){setEditing(x.id);setForm({fullName:x.full_name,email:x.email,phone:x.phone,department:x.department,position:x.position,status:x.status,hiredAt:String(x.hired_at).slice(0,10)})}
|
|
11
|
+
async function del(id){ if(confirm('Delete employee and linked account?')){await request(`/employees/${id}`,{method:'DELETE'});load();}}
|
|
12
|
+
const filtered=items.filter(x=>(x.full_name+x.email+x.position).toLowerCase().includes(q.toLowerCase()));
|
|
13
|
+
return <div className="grid gap-6 xl:grid-cols-[420px_1fr]"><Card><h1 className="text-2xl font-black">{editing?'Edit':'Create'} Employee</h1><form onSubmit={save} className="mt-5 grid gap-3"><Input placeholder="Full name" value={form.fullName} onChange={e=>setForm({...form,fullName:e.target.value})}/><Input placeholder="Email" value={form.email} onChange={e=>setForm({...form,email:e.target.value})}/><Input placeholder="Phone" value={form.phone} onChange={e=>setForm({...form,phone:e.target.value})}/><Input placeholder="Department" value={form.department} onChange={e=>setForm({...form,department:e.target.value})}/><Input placeholder="Position" value={form.position} onChange={e=>setForm({...form,position:e.target.value})}/><Select value={form.status} onChange={e=>setForm({...form,status:e.target.value})}><option>Active</option><option>On Leave</option><option>Inactive</option></Select><Input type="date" value={form.hiredAt} onChange={e=>setForm({...form,hiredAt:e.target.value})}/>{msg && <p className="text-sm font-bold text-slate-500">{msg}</p>}<Button>{editing?'Update':'Create'} employee</Button></form></Card><Card><div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"><h2 className="text-2xl font-black">Employees</h2><Input placeholder="Search people..." value={q} onChange={e=>setQ(e.target.value)} /></div><div className="mt-5 overflow-x-auto"><table className="w-full text-left text-sm"><thead><tr className="border-b text-slate-400"><th className="py-3">Name</th><th>Department</th><th>Position</th><th>Status</th><th>Actions</th></tr></thead><tbody>{filtered.map(x=><tr className="border-b" key={x.id}><td className="py-4 font-black">{x.full_name}<p className="font-normal text-slate-500">{x.email}</p></td><td>{x.department}</td><td>{x.position}</td><td><Badge tone={x.status==='Active'?'green':x.status==='On Leave'?'amber':'red'}>{x.status}</Badge></td><td className="space-x-2"><button onClick={()=>edit(x)} className="font-black text-[#FF385C]">Edit</button><button onClick={()=>del(x.id)} className="font-black text-rose-700">Delete</button></td></tr>)}</tbody></table></div></Card></div>
|
|
14
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { request } from "../api/http";
|
|
4
|
+
import { Button, Card, Input } from "../components/ui";
|
|
5
|
+
export default function Login(){
|
|
6
|
+
const [portal,setPortal]=useState('admin'); const [form,setForm]=useState({username:'admin',password:'admin123'}); const [error,setError]=useState('');
|
|
7
|
+
async function submit(e){e.preventDefault(); setError(''); try{await request(`/auth/${portal}/login`,{method:'POST',body:form}); location.href=portal==='admin'?'/admin':'/user';}catch(err){setError(err.message)}}
|
|
8
|
+
return <main className="min-h-screen bg-[#F7F7F7] p-4 md:p-8"><div className="mx-auto grid min-h-[calc(100vh-4rem)] max-w-6xl items-center gap-8 md:grid-cols-2"><section><div className="inline-flex rounded-full bg-white p-1 shadow-sm"><button onClick={()=>{setPortal('admin');setForm({username:'admin',password:'admin123'})}} className={`rounded-full px-5 py-3 text-sm font-black ${portal==='admin'?'bg-[#FF385C] text-white':'text-slate-500'}`}>Admin</button><button onClick={()=>{setPortal('user');setForm({username:'aline',password:'admin123'})}} className={`rounded-full px-5 py-3 text-sm font-black ${portal==='user'?'bg-[#FF385C] text-white':'text-slate-500'}`}>User</button></div><h1 className="mt-8 text-5xl font-black tracking-tight text-slate-950 md:text-7xl">A cleaner way to start serious systems.</h1><p className="mt-5 max-w-xl text-lg leading-8 text-slate-500">Employee records, account ownership, session auth, and polished admin UX in one focused template.</p></section><Card className="p-8"><p className="text-xs font-black uppercase tracking-[0.2em] text-[#FF385C]">{portal} portal</p><h2 className="mt-3 text-3xl font-black">Welcome back</h2><form onSubmit={submit} className="mt-6 grid gap-4"><Input placeholder="username" value={form.username} onChange={e=>setForm({...form,username:e.target.value})}/><Input type="password" placeholder="password" value={form.password} onChange={e=>setForm({...form,password:e.target.value})}/>{error && <p className="rounded-2xl bg-rose-50 p-3 text-sm font-bold text-rose-700">{error}</p>}<Button>Sign in</Button></form><p className="mt-4 text-sm text-slate-500">Default password: <b>admin123</b></p></Card></div></main>
|
|
9
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Search, Download, Plus, SlidersHorizontal, X } from "lucide-react";
|
|
4
|
+
import { Button, Card, Input, Select, Badge, EmptyState } from "../components/ui";
|
|
5
|
+
const rows=[{id:1,name:'Aline Uwase',team:'Operations',status:'Active',score:96},{id:2,name:'Eric Nshimiyimana',team:'Engineering',status:'Active',score:88},{id:3,name:'Diane Mutoni',team:'Finance',status:'Paused',score:73}];
|
|
6
|
+
export default function Templates(){
|
|
7
|
+
const [modal,setModal]=useState(false),[drawer,setDrawer]=useState(false),[q,setQ]=useState('');
|
|
8
|
+
const filtered=rows.filter(r=>r.name.toLowerCase().includes(q.toLowerCase())||r.team.toLowerCase().includes(q.toLowerCase()));
|
|
9
|
+
return <div className="grid gap-6"><section className="rounded-[2.5rem] bg-white p-8 shadow-sm"><p className="text-xs font-black uppercase tracking-[0.2em] text-[#FF385C]">Component Templates</p><h1 className="mt-3 text-4xl font-black md:text-6xl">Copy-ready building blocks.</h1><p className="mt-4 max-w-2xl text-slate-500">Forms, tables, modals, drawers, filters, badges, empty states, and action bars designed for exam speed and production polish.</p></section><div className="grid gap-6 xl:grid-cols-3"><Card><h2 className="text-xl font-black">Stacked Form</h2><div className="mt-4 grid gap-3"><Input placeholder="Full name"/><Input placeholder="Email address"/><Select><option>Active</option><option>Paused</option></Select><Button><Plus size={16}/> Create record</Button></div></Card><Card><h2 className="text-xl font-black">Filter Bar</h2><div className="mt-4 flex flex-col gap-3"><div className="relative"><Search className="absolute left-4 top-3.5 text-slate-400" size={18}/><input value={q} onChange={e=>setQ(e.target.value)} placeholder="Search..." className="w-full rounded-full border border-slate-200 py-3 pl-11 pr-4 text-sm outline-none focus:border-[#FF385C]"/></div><div className="flex gap-2"><Button variant="secondary" onClick={()=>setDrawer(true)}><SlidersHorizontal size={16}/> Filters</Button><Button variant="secondary"><Download size={16}/> Export</Button></div></div></Card><Card><h2 className="text-xl font-black">Action States</h2><div className="mt-4 flex flex-wrap gap-2"><Badge tone="green">Active</Badge><Badge tone="amber">Pending</Badge><Badge tone="red">Blocked</Badge><Badge>Draft</Badge></div><button onClick={()=>setModal(true)} className="mt-5 rounded-full bg-slate-950 px-5 py-3 text-sm font-black text-white">Open modal</button></Card></div><Card><div className="flex items-center justify-between"><h2 className="text-2xl font-black">Data Table Template</h2><Button onClick={()=>setModal(true)}>New item</Button></div><div className="mt-5 overflow-x-auto"><table className="w-full text-left text-sm"><thead><tr className="border-b text-slate-400"><th className="py-3">Name</th><th>Team</th><th>Status</th><th>Score</th><th>Actions</th></tr></thead><tbody>{filtered.map(r=><tr className="border-b" key={r.id}><td className="py-4 font-black">{r.name}</td><td>{r.team}</td><td><Badge tone={r.status==='Active'?'green':'amber'}>{r.status}</Badge></td><td>{r.score}%</td><td><button className="font-black text-[#FF385C]">Edit</button></td></tr>)}</tbody></table>{!filtered.length&&<div className="mt-5"><EmptyState title="No records found" note="Change the search query or create a new record."/></div>}</div></Card>{modal&&<div className="fixed inset-0 z-50 grid place-items-center bg-slate-950/40 p-4"><Card className="w-full max-w-lg"><div className="flex items-center justify-between"><h2 className="text-2xl font-black">Modal Form</h2><button onClick={()=>setModal(false)}><X/></button></div><div className="mt-5 grid gap-3"><Input placeholder="Record title"/><Input placeholder="Owner"/><Select><option>High priority</option><option>Normal</option></Select><Button onClick={()=>setModal(false)}>Save example</Button></div></Card></div>}{drawer&&<div className="fixed inset-0 z-50 bg-slate-950/30"><aside className="ml-auto h-full w-full max-w-md bg-white p-6 shadow-xl"><div className="flex items-center justify-between"><h2 className="text-2xl font-black">Drawer Filters</h2><button onClick={()=>setDrawer(false)}><X/></button></div><div className="mt-6 grid gap-3"><Select><option>All teams</option><option>Engineering</option></Select><Select><option>Any status</option><option>Active</option></Select><Button onClick={()=>setDrawer(false)}>Apply filters</Button></div></aside></div>}</div>
|
|
10
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { request } from "../api/http";
|
|
4
|
+
import { Button, Card, Input, Badge } from "../components/ui";
|
|
5
|
+
export default function UserDashboard(){
|
|
6
|
+
const [profile,setProfile]=useState(null),[form,setForm]=useState({currentPassword:'',newPassword:''}),[msg,setMsg]=useState('');
|
|
7
|
+
useEffect(()=>{request('/users/me/profile').then(d=>setProfile(d.profile))},[]);
|
|
8
|
+
async function change(e){e.preventDefault();setMsg('');try{await request('/users/me/password',{method:'PATCH',body:form});setForm({currentPassword:'',newPassword:''});setMsg('Password changed successfully');}catch(err){setMsg(err.message)}}
|
|
9
|
+
return <div className="grid gap-6"><section className="rounded-[2.5rem] bg-white p-8 shadow-sm"><p className="text-xs font-black uppercase tracking-[0.2em] text-[#FF385C]">Employee Portal</p><h1 className="mt-3 text-4xl font-black">Welcome, {profile?.full_name || 'Employee'}.</h1><p className="mt-3 text-slate-500">View your account identity and manage your password.</p></section><div className="grid gap-6 lg:grid-cols-2"><Card><h2 className="text-2xl font-black">My Info</h2>{profile&&<div className="mt-5 grid gap-3 text-sm"><p><b>Name:</b> {profile.full_name}</p><p><b>Email:</b> {profile.email}</p><p><b>Phone:</b> {profile.phone}</p><p><b>Department:</b> {profile.department}</p><p><b>Position:</b> {profile.position}</p><p><b>Status:</b> <Badge tone="green">{profile.status}</Badge></p></div>}</Card><Card><h2 className="text-2xl font-black">Change Password</h2><form onSubmit={change} className="mt-5 grid gap-3"><Input type="password" placeholder="Current password" value={form.currentPassword} onChange={e=>setForm({...form,currentPassword:e.target.value})}/><Input type="password" placeholder="New password" value={form.newPassword} onChange={e=>setForm({...form,newPassword:e.target.value})}/>{msg&&<p className="text-sm font-bold text-slate-500">{msg}</p>}<Button>Update password</Button></form></Card></div></div>
|
|
10
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import Login from "../pages/Login";
|
|
4
|
+
import AdminDashboard from "../pages/AdminDashboard";
|
|
5
|
+
import Employees from "../pages/Employees";
|
|
6
|
+
import Accounts from "../pages/Accounts";
|
|
7
|
+
import Templates from "../pages/Templates";
|
|
8
|
+
import UserDashboard from "../pages/UserDashboard";
|
|
9
|
+
import AppLayout from "../layouts/AppLayout";
|
|
10
|
+
import { request } from "../api/http";
|
|
11
|
+
const path=()=>window.location.pathname;
|
|
12
|
+
export default function AppRouter(){
|
|
13
|
+
const [user,setUser]=useState(null); const [loading,setLoading]=useState(true);
|
|
14
|
+
useEffect(()=>{request('/auth/me').then(d=>setUser(d.user)).catch(()=>setUser(null)).finally(()=>setLoading(false));},[]);
|
|
15
|
+
if(loading) return <div className="grid min-h-screen place-items-center font-black">Loading Nightwing...</div>;
|
|
16
|
+
if(!user) return <Login/>;
|
|
17
|
+
const p=path();
|
|
18
|
+
let Page=user.role==='admin'?AdminDashboard:UserDashboard;
|
|
19
|
+
if(user.role==='admin' && p==='/employees') Page=Employees;
|
|
20
|
+
if(user.role==='admin' && p==='/accounts') Page=Accounts;
|
|
21
|
+
if(user.role==='admin' && p==='/templates') Page=Templates;
|
|
22
|
+
if(user.role==='user') Page=UserDashboard;
|
|
23
|
+
return <AppLayout user={user}><Page user={user}/></AppLayout>;
|
|
24
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default { content:["./index.html","./src/**/*.{js,jsx}"], theme:{ extend:{ fontFamily:{ sans:["Inter","ui-sans-serif","system-ui"] }, colors:{ nightwing:"#FF385C" } } }, plugins:[] };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
|
|
2
|
+
# API Guide
|
|
3
|
+
|
|
4
|
+
Base URL: `http://localhost:__SERVER_PORT__/api`
|
|
5
|
+
|
|
6
|
+
## Auth
|
|
7
|
+
|
|
8
|
+
Admin login:
|
|
9
|
+
|
|
10
|
+
```http
|
|
11
|
+
POST /auth/admin/login
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```json
|
|
15
|
+
{ "username": "admin", "password": "admin123" }
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
User login:
|
|
19
|
+
|
|
20
|
+
```http
|
|
21
|
+
POST /auth/user/login
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{ "username": "aline", "password": "admin123" }
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Employees
|
|
29
|
+
|
|
30
|
+
All employee routes require admin session.
|
|
31
|
+
|
|
32
|
+
```http
|
|
33
|
+
GET /employees
|
|
34
|
+
POST /employees
|
|
35
|
+
PUT /employees/:id
|
|
36
|
+
DELETE /employees/:id
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Accounts
|
|
40
|
+
|
|
41
|
+
All account-management routes require admin session.
|
|
42
|
+
|
|
43
|
+
```http
|
|
44
|
+
GET /users
|
|
45
|
+
POST /users
|
|
46
|
+
PUT /users/:id
|
|
47
|
+
PATCH /users/:id/password
|
|
48
|
+
DELETE /users/:id
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## User self-service
|
|
52
|
+
|
|
53
|
+
Requires user session.
|
|
54
|
+
|
|
55
|
+
```http
|
|
56
|
+
GET /users/me/profile
|
|
57
|
+
PATCH /users/me/password
|
|
58
|
+
```
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
|
|
2
|
+
# SQL Cheatcode Book
|
|
3
|
+
|
|
4
|
+
This template uses three tables: `admins`, `employees`, and `users`.
|
|
5
|
+
|
|
6
|
+
## Why this structure exists
|
|
7
|
+
|
|
8
|
+
Admins operate the system. Employees are real people in the organization. Users are login accounts owned by employees.
|
|
9
|
+
|
|
10
|
+
```sql
|
|
11
|
+
users.employee_id -> employees.id
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
That means an employee can exist without a login account, but a user account cannot exist without an employee.
|
|
15
|
+
|
|
16
|
+
## Setup
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
mysql -u root -p < schema.sql
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Default logins
|
|
23
|
+
|
|
24
|
+
Admin and seeded users use:
|
|
25
|
+
|
|
26
|
+
```txt
|
|
27
|
+
password: admin123
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Useful exam queries
|
|
31
|
+
|
|
32
|
+
List accounts with employee names:
|
|
33
|
+
|
|
34
|
+
```sql
|
|
35
|
+
SELECT u.id, u.username, e.full_name, e.position
|
|
36
|
+
FROM users u
|
|
37
|
+
JOIN employees e ON e.id = u.employee_id;
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Find employees without accounts:
|
|
41
|
+
|
|
42
|
+
```sql
|
|
43
|
+
SELECT e.*
|
|
44
|
+
FROM employees e
|
|
45
|
+
LEFT JOIN users u ON u.employee_id = e.id
|
|
46
|
+
WHERE u.id IS NULL;
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Count active employees:
|
|
50
|
+
|
|
51
|
+
```sql
|
|
52
|
+
SELECT COUNT(*) AS total_active
|
|
53
|
+
FROM employees
|
|
54
|
+
WHERE status = 'Active';
|
|
55
|
+
```
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
|
|
2
|
+
# Tailwind Cheatcode Book
|
|
3
|
+
|
|
4
|
+
This UI uses an Airbnb-inspired system: white surfaces, soft borders, rounded cards, warm red accent, strong spacing, and readable typography.
|
|
5
|
+
|
|
6
|
+
## Layout recipe
|
|
7
|
+
|
|
8
|
+
```jsx
|
|
9
|
+
<div className="rounded-[2rem] border border-slate-200 bg-white p-6 shadow-sm">
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Use this for cards, panels, and forms.
|
|
13
|
+
|
|
14
|
+
## Button recipe
|
|
15
|
+
|
|
16
|
+
```jsx
|
|
17
|
+
<button className="rounded-full bg-[#FF385C] px-5 py-3 text-sm font-black text-white shadow-sm hover:bg-[#E31C5F]">
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Inputs
|
|
21
|
+
|
|
22
|
+
```jsx
|
|
23
|
+
<input className="w-full rounded-2xl border border-slate-200 px-4 py-3 text-sm outline-none focus:border-[#FF385C]" />
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Responsive grids
|
|
27
|
+
|
|
28
|
+
```jsx
|
|
29
|
+
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Rule
|
|
33
|
+
|
|
34
|
+
Keep color minimal. Use the accent only for actions and focus states.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
|
|
2
|
+
# __PROJECT_NAME__ Instructions
|
|
3
|
+
|
|
4
|
+
## Start
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
mysql -u root -p < schema.sql
|
|
8
|
+
cd server
|
|
9
|
+
npm install
|
|
10
|
+
npm run dev
|
|
11
|
+
cd ../client
|
|
12
|
+
npm install
|
|
13
|
+
npm run dev
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Admin Login
|
|
17
|
+
|
|
18
|
+
```txt
|
|
19
|
+
username: admin
|
|
20
|
+
password: admin123
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## User Login
|
|
24
|
+
|
|
25
|
+
```txt
|
|
26
|
+
username: aline
|
|
27
|
+
password: admin123
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## What this project gives you
|
|
31
|
+
|
|
32
|
+
- Admin dashboard
|
|
33
|
+
- Employee CRUD
|
|
34
|
+
- Employee-owned user account CRUD
|
|
35
|
+
- User dashboard
|
|
36
|
+
- Change password
|
|
37
|
+
- Component templates page
|
|
38
|
+
- Session auth
|
|
39
|
+
- MySQL schema
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
|
|
2
|
+
DROP DATABASE IF EXISTS __DB_NAME__;
|
|
3
|
+
CREATE DATABASE __DB_NAME__;
|
|
4
|
+
USE __DB_NAME__;
|
|
5
|
+
|
|
6
|
+
CREATE TABLE admins (
|
|
7
|
+
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
8
|
+
username VARCHAR(100) NOT NULL UNIQUE,
|
|
9
|
+
password VARCHAR(255) NOT NULL,
|
|
10
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
CREATE TABLE employees (
|
|
14
|
+
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
15
|
+
full_name VARCHAR(150) NOT NULL,
|
|
16
|
+
email VARCHAR(150) NOT NULL UNIQUE,
|
|
17
|
+
phone VARCHAR(40) NOT NULL,
|
|
18
|
+
department VARCHAR(100) NOT NULL,
|
|
19
|
+
position VARCHAR(100) NOT NULL,
|
|
20
|
+
status ENUM('Active','On Leave','Inactive') NOT NULL DEFAULT 'Active',
|
|
21
|
+
hired_at DATE NOT NULL,
|
|
22
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
CREATE TABLE users (
|
|
26
|
+
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
27
|
+
employee_id INT NOT NULL UNIQUE,
|
|
28
|
+
username VARCHAR(100) NOT NULL UNIQUE,
|
|
29
|
+
password VARCHAR(255) NOT NULL,
|
|
30
|
+
status ENUM('Active','Suspended') NOT NULL DEFAULT 'Active',
|
|
31
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
32
|
+
FOREIGN KEY (employee_id) REFERENCES employees(id) ON DELETE CASCADE
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
INSERT INTO admins(username,password) VALUES
|
|
36
|
+
('admin','$2a$10$t7KDoPGfkg/lQUbCaU0I2.OwJdz7VJHt1XmVECDwSD2S02ULkq5FK');
|
|
37
|
+
|
|
38
|
+
INSERT INTO employees(full_name,email,phone,department,position,status,hired_at) VALUES
|
|
39
|
+
('Aline Uwase','aline@nightwing.dev','0788000001','Operations','Operations Lead','Active','2025-01-10'),
|
|
40
|
+
('Eric Nshimiyimana','eric@nightwing.dev','0788000002','Engineering','Frontend Developer','Active','2025-02-12'),
|
|
41
|
+
('Diane Mutoni','diane@nightwing.dev','0788000003','Finance','Accountant','On Leave','2024-11-03');
|
|
42
|
+
|
|
43
|
+
INSERT INTO users(employee_id,username,password,status) VALUES
|
|
44
|
+
(1,'aline','$2a$10$t7KDoPGfkg/lQUbCaU0I2.OwJdz7VJHt1XmVECDwSD2S02ULkq5FK','Active'),
|
|
45
|
+
(2,'eric','$2a$10$t7KDoPGfkg/lQUbCaU0I2.OwJdz7VJHt1XmVECDwSD2S02ULkq5FK','Active');
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__PROJECT_NAME__-server",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "node --watch src/app.js",
|
|
7
|
+
"start": "node src/app.js",
|
|
8
|
+
"check": "node --check src/app.js"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"bcryptjs": "^2.4.3",
|
|
12
|
+
"cors": "^2.8.5",
|
|
13
|
+
"dotenv": "^16.4.7",
|
|
14
|
+
"express": "^4.21.2",
|
|
15
|
+
"express-session": "^1.18.1",
|
|
16
|
+
"mysql2": "^3.12.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {}
|
|
19
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
|
|
2
|
+
import express from "express";
|
|
3
|
+
import cors from "cors";
|
|
4
|
+
import session from "express-session";
|
|
5
|
+
import dotenv from "dotenv";
|
|
6
|
+
import authRoutes from "./routes/authRoutes.js";
|
|
7
|
+
import employeeRoutes from "./routes/employeeRoutes.js";
|
|
8
|
+
import userRoutes from "./routes/userRoutes.js";
|
|
9
|
+
|
|
10
|
+
dotenv.config();
|
|
11
|
+
const app=express();
|
|
12
|
+
const PORT=process.env.PORT || __SERVER_PORT__;
|
|
13
|
+
app.use(cors({origin:process.env.CLIENT_URL || "http://localhost:__CLIENT_PORT__",credentials:true}));
|
|
14
|
+
app.use(express.json());
|
|
15
|
+
app.use(session({name:"nightwing.sid",secret:process.env.SESSION_SECRET || "nightwing_dev_secret",resave:false,saveUninitialized:false,cookie:{httpOnly:true,secure:process.env.NODE_ENV==="production",sameSite:process.env.NODE_ENV==="production"?"none":"lax",maxAge:1000*60*60*24}}));
|
|
16
|
+
app.get("/api/health",(req,res)=>res.json({ok:true,app:"__PROJECT_NAME__"}));
|
|
17
|
+
app.use("/api/auth",authRoutes);
|
|
18
|
+
app.use("/api/employees",employeeRoutes);
|
|
19
|
+
app.use("/api/users",userRoutes);
|
|
20
|
+
app.use((req,res)=>res.status(404).json({message:"Route not found"}));
|
|
21
|
+
app.use((err,req,res,next)=>{ console.error(err); res.status(500).json({message:"Server error"}); });
|
|
22
|
+
app.listen(PORT,()=>console.log(`Nightwing server running on http://localhost:${PORT}`));
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
|
|
2
|
+
import mysql from "mysql2/promise";
|
|
3
|
+
import dotenv from "dotenv";
|
|
4
|
+
dotenv.config();
|
|
5
|
+
export const db = mysql.createPool({
|
|
6
|
+
host: process.env.DB_HOST || "localhost",
|
|
7
|
+
user: process.env.DB_USER || "root",
|
|
8
|
+
password: process.env.DB_PASSWORD || "",
|
|
9
|
+
database: process.env.DB_NAME || "__DB_NAME__",
|
|
10
|
+
waitForConnections: true,
|
|
11
|
+
connectionLimit: 10,
|
|
12
|
+
queueLimit: 0
|
|
13
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
|
|
2
|
+
import { db } from "../config/db.js";
|
|
3
|
+
import { comparePassword, hashPassword } from "../utils/security.js";
|
|
4
|
+
|
|
5
|
+
function clean(user){
|
|
6
|
+
return { id:user.id, username:user.username, role:user.role, employeeId:user.employee_id || null, employeeName:user.employeeName || null, status:user.status || null };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function adminLogin(req,res){
|
|
10
|
+
try{
|
|
11
|
+
const {username,password}=req.body;
|
|
12
|
+
const [rows]=await db.query("SELECT * FROM admins WHERE username=? LIMIT 1",[username]);
|
|
13
|
+
const admin=rows[0];
|
|
14
|
+
if(!admin || !(await comparePassword(password,admin.password))) return res.status(401).json({message:"Invalid admin credentials"});
|
|
15
|
+
req.session.user=clean({...admin,role:"admin",status:"Active"});
|
|
16
|
+
res.json({message:"Logged in",user:req.session.user});
|
|
17
|
+
}catch(error){ console.error(error); res.status(500).json({message:"Admin login failed"}); }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function userLogin(req,res){
|
|
21
|
+
try{
|
|
22
|
+
const {username,password}=req.body;
|
|
23
|
+
const [rows]=await db.query(`SELECT u.*, e.full_name AS employeeName FROM users u JOIN employees e ON e.id=u.employee_id WHERE u.username=? LIMIT 1`,[username]);
|
|
24
|
+
const user=rows[0];
|
|
25
|
+
if(!user || user.status!=="Active" || !(await comparePassword(password,user.password))) return res.status(401).json({message:"Invalid user credentials"});
|
|
26
|
+
req.session.user=clean({...user,role:"user"});
|
|
27
|
+
res.json({message:"Logged in",user:req.session.user});
|
|
28
|
+
}catch(error){ console.error(error); res.status(500).json({message:"User login failed"}); }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function registerFirstAdmin(req,res){
|
|
32
|
+
try{
|
|
33
|
+
const [countRows]=await db.query("SELECT COUNT(*) AS total FROM admins");
|
|
34
|
+
if(countRows[0].total>0) return res.status(403).json({message:"Admin registration is locked after setup"});
|
|
35
|
+
const {username,password}=req.body;
|
|
36
|
+
if(!username || !password) return res.status(400).json({message:"Username and password are required"});
|
|
37
|
+
const hashed=await hashPassword(password);
|
|
38
|
+
const [result]=await db.query("INSERT INTO admins(username,password) VALUES(?,?)",[username,hashed]);
|
|
39
|
+
req.session.user={id:result.insertId,username,role:"admin",status:"Active"};
|
|
40
|
+
res.status(201).json({message:"First admin created",user:req.session.user});
|
|
41
|
+
}catch(error){ console.error(error); res.status(500).json({message:"Registration failed"}); }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function me(req,res){
|
|
45
|
+
if(!req.session.user) return res.status(401).json({message:"Not authenticated"});
|
|
46
|
+
res.json({user:req.session.user});
|
|
47
|
+
}
|
|
48
|
+
export function logout(req,res){ req.session.destroy(()=>res.clearCookie("nightwing.sid").json({message:"Logged out"})); }
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
|
|
2
|
+
import { db } from "../config/db.js";
|
|
3
|
+
|
|
4
|
+
export async function getEmployees(req,res){
|
|
5
|
+
const [rows]=await db.query("SELECT * FROM employees ORDER BY id DESC");
|
|
6
|
+
res.json({employees:rows});
|
|
7
|
+
}
|
|
8
|
+
export async function createEmployee(req,res){
|
|
9
|
+
try{
|
|
10
|
+
const {fullName,email,phone,department,position,status="Active",hiredAt}=req.body;
|
|
11
|
+
if(!fullName || !email || !phone || !department || !position || !hiredAt) return res.status(400).json({message:"All employee fields are required"});
|
|
12
|
+
const [result]=await db.query("INSERT INTO employees(full_name,email,phone,department,position,status,hired_at) VALUES(?,?,?,?,?,?,?)",[fullName,email,phone,department,position,status,hiredAt]);
|
|
13
|
+
res.status(201).json({message:"Employee created",id:result.insertId});
|
|
14
|
+
}catch(error){ console.error(error); res.status(500).json({message:error.code==="ER_DUP_ENTRY"?"Employee email already exists":"Failed to create employee"}); }
|
|
15
|
+
}
|
|
16
|
+
export async function updateEmployee(req,res){
|
|
17
|
+
try{
|
|
18
|
+
const {fullName,email,phone,department,position,status,hiredAt}=req.body;
|
|
19
|
+
const [result]=await db.query("UPDATE employees SET full_name=?,email=?,phone=?,department=?,position=?,status=?,hired_at=? WHERE id=?",[fullName,email,phone,department,position,status,hiredAt,req.params.id]);
|
|
20
|
+
if(!result.affectedRows) return res.status(404).json({message:"Employee not found"});
|
|
21
|
+
res.json({message:"Employee updated"});
|
|
22
|
+
}catch(error){ console.error(error); res.status(500).json({message:"Failed to update employee"}); }
|
|
23
|
+
}
|
|
24
|
+
export async function deleteEmployee(req,res){
|
|
25
|
+
const [result]=await db.query("DELETE FROM employees WHERE id=?",[req.params.id]);
|
|
26
|
+
if(!result.affectedRows) return res.status(404).json({message:"Employee not found"});
|
|
27
|
+
res.json({message:"Employee deleted"});
|
|
28
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
|
|
2
|
+
import { db } from "../config/db.js";
|
|
3
|
+
import { comparePassword, hashPassword } from "../utils/security.js";
|
|
4
|
+
|
|
5
|
+
export async function getUsers(req,res){
|
|
6
|
+
const [rows]=await db.query(`SELECT u.id,u.username,u.status,u.created_at,u.employee_id,e.full_name AS employeeName,e.email,e.position FROM users u JOIN employees e ON e.id=u.employee_id ORDER BY u.id DESC`);
|
|
7
|
+
res.json({users:rows});
|
|
8
|
+
}
|
|
9
|
+
export async function createUser(req,res){
|
|
10
|
+
try{
|
|
11
|
+
const {employeeId,username,password,status="Active"}=req.body;
|
|
12
|
+
if(!employeeId || !username || !password) return res.status(400).json({message:"Employee, username and password are required"});
|
|
13
|
+
const hashed=await hashPassword(password);
|
|
14
|
+
const [result]=await db.query("INSERT INTO users(employee_id,username,password,status) VALUES(?,?,?,?)",[employeeId,username,hashed,status]);
|
|
15
|
+
res.status(201).json({message:"User account created",id:result.insertId});
|
|
16
|
+
}catch(error){ console.error(error); res.status(500).json({message:error.code==="ER_DUP_ENTRY"?"Employee already has an account or username exists":"Failed to create account"}); }
|
|
17
|
+
}
|
|
18
|
+
export async function updateUser(req,res){
|
|
19
|
+
try{
|
|
20
|
+
const {username,status}=req.body;
|
|
21
|
+
const [result]=await db.query("UPDATE users SET username=?, status=? WHERE id=?",[username,status,req.params.id]);
|
|
22
|
+
if(!result.affectedRows) return res.status(404).json({message:"User not found"});
|
|
23
|
+
res.json({message:"User updated"});
|
|
24
|
+
}catch(error){ console.error(error); res.status(500).json({message:"Failed to update user"}); }
|
|
25
|
+
}
|
|
26
|
+
export async function resetPassword(req,res){
|
|
27
|
+
const {password}=req.body;
|
|
28
|
+
if(!password || password.length<6) return res.status(400).json({message:"Password must be at least 6 characters"});
|
|
29
|
+
const hashed=await hashPassword(password);
|
|
30
|
+
const [result]=await db.query("UPDATE users SET password=? WHERE id=?",[hashed,req.params.id]);
|
|
31
|
+
if(!result.affectedRows) return res.status(404).json({message:"User not found"});
|
|
32
|
+
res.json({message:"Password reset"});
|
|
33
|
+
}
|
|
34
|
+
export async function deleteUser(req,res){
|
|
35
|
+
const [result]=await db.query("DELETE FROM users WHERE id=?",[req.params.id]);
|
|
36
|
+
if(!result.affectedRows) return res.status(404).json({message:"User not found"});
|
|
37
|
+
res.json({message:"User deleted"});
|
|
38
|
+
}
|
|
39
|
+
export async function myProfile(req,res){
|
|
40
|
+
const [rows]=await db.query(`SELECT u.id,u.username,u.status,u.created_at,e.full_name,e.email,e.phone,e.department,e.position,e.hired_at FROM users u JOIN employees e ON e.id=u.employee_id WHERE u.id=? LIMIT 1`,[req.session.user.id]);
|
|
41
|
+
res.json({profile:rows[0]});
|
|
42
|
+
}
|
|
43
|
+
export async function changeMyPassword(req,res){
|
|
44
|
+
const {currentPassword,newPassword}=req.body;
|
|
45
|
+
if(!currentPassword || !newPassword) return res.status(400).json({message:"Both passwords are required"});
|
|
46
|
+
if(newPassword.length<6) return res.status(400).json({message:"New password must be at least 6 characters"});
|
|
47
|
+
const [rows]=await db.query("SELECT password FROM users WHERE id=? LIMIT 1",[req.session.user.id]);
|
|
48
|
+
const ok=rows[0] && await comparePassword(currentPassword,rows[0].password);
|
|
49
|
+
if(!ok) return res.status(401).json({message:"Current password is incorrect"});
|
|
50
|
+
await db.query("UPDATE users SET password=? WHERE id=?",[await hashPassword(newPassword),req.session.user.id]);
|
|
51
|
+
res.json({message:"Password changed"});
|
|
52
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
|
|
2
|
+
export function requireAuth(req,res,next){
|
|
3
|
+
if(!req.session.user) return res.status(401).json({message:"Not authenticated"});
|
|
4
|
+
next();
|
|
5
|
+
}
|
|
6
|
+
export function requireRole(role){
|
|
7
|
+
return (req,res,next)=>{
|
|
8
|
+
if(!req.session.user) return res.status(401).json({message:"Not authenticated"});
|
|
9
|
+
if(req.session.user.role!==role) return res.status(403).json({message:"Forbidden"});
|
|
10
|
+
next();
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { adminLogin,userLogin,registerFirstAdmin,me,logout } from "../controllers/authController.js";
|
|
3
|
+
const router=Router();
|
|
4
|
+
router.post("/setup/register",registerFirstAdmin);
|
|
5
|
+
router.post("/admin/login",adminLogin);
|
|
6
|
+
router.post("/user/login",userLogin);
|
|
7
|
+
router.get("/me",me);
|
|
8
|
+
router.post("/logout",logout);
|
|
9
|
+
export default router;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { createEmployee,deleteEmployee,getEmployees,updateEmployee } from "../controllers/employeeController.js";
|
|
3
|
+
import { requireRole } from "../middleware/auth.js";
|
|
4
|
+
const router=Router();
|
|
5
|
+
router.use(requireRole("admin"));
|
|
6
|
+
router.get("/",getEmployees);
|
|
7
|
+
router.post("/",createEmployee);
|
|
8
|
+
router.put("/:id",updateEmployee);
|
|
9
|
+
router.delete("/:id",deleteEmployee);
|
|
10
|
+
export default router;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { changeMyPassword,createUser,deleteUser,getUsers,myProfile,resetPassword,updateUser } from "../controllers/userController.js";
|
|
3
|
+
import { requireRole } from "../middleware/auth.js";
|
|
4
|
+
const router=Router();
|
|
5
|
+
router.get("/",requireRole("admin"),getUsers);
|
|
6
|
+
router.post("/",requireRole("admin"),createUser);
|
|
7
|
+
router.put("/:id",requireRole("admin"),updateUser);
|
|
8
|
+
router.patch("/:id/password",requireRole("admin"),resetPassword);
|
|
9
|
+
router.delete("/:id",requireRole("admin"),deleteUser);
|
|
10
|
+
router.get("/me/profile",requireRole("user"),myProfile);
|
|
11
|
+
router.patch("/me/password",requireRole("user"),changeMyPassword);
|
|
12
|
+
export default router;
|