first commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
DATABASE_URL="postgresql://postgres:password@localhost:5432/4tech_app?schema=public"
|
||||||
|
AUTH_SECRET="57afa027bf1f1213acb32e26b261dfb250f4b871f35c3fbb9cfadf3aa28b924f"
|
||||||
|
NEXT_PUBLIC_APP_NAME="4TECH"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": ["next/core-web-vitals"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.env
|
||||||
|
.next
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
*.log
|
||||||
|
.next-dev.*.log
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# 4TECH Login + User Management
|
||||||
|
|
||||||
|
Modern internal web app built with Next.js App Router, Tailwind CSS, Prisma, PostgreSQL, JWT httpOnly cookies, bcrypt password hashing, and role-based access control.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
cp .env.example .env
|
||||||
|
npx prisma migrate dev
|
||||||
|
npx prisma db seed
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:3000`.
|
||||||
|
|
||||||
|
## Development Login
|
||||||
|
|
||||||
|
- Email: `admin@4tech.co.th`
|
||||||
|
- Password: `Admin@123456`
|
||||||
|
- Role: `SUPER_ADMIN`
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- `/login` glassmorphism login screen with remember-me and password visibility toggle
|
||||||
|
- `/home` protected dashboard with user summary cards
|
||||||
|
- `/profile` profile details, edit form, and password change
|
||||||
|
- `/users` admin-only user management with search, role/status filters, create/edit modal, soft disable, pagination, and mobile card layout
|
||||||
|
- Server-side RBAC for `SUPER_ADMIN`, `ADMIN`, and `USER`
|
||||||
|
- Prisma schema and seed script ready for PostgreSQL
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL="postgresql://postgres:password@localhost:5432/4tech_app?schema=public"
|
||||||
|
AUTH_SECRET="replace-with-a-long-random-secret"
|
||||||
|
NEXT_PUBLIC_APP_NAME="4TECH"
|
||||||
|
```
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
# Codex Task: Build 4TECH Login + User Management Web App
|
||||||
|
|
||||||
|
## 1) Project Goal
|
||||||
|
สร้าง Web App สำหรับบริษัท 4TECH โดยใช้ **Next.js + Tailwind CSS + PostgreSQL** มีระบบ Login และจัดการผู้ใช้งาน พร้อม UI แนว **Glassmorphism + Responsive Design** และใช้โทนสีให้เข้ากับโลโก้บริษัท
|
||||||
|
|
||||||
|
Logo color reference:
|
||||||
|
- Primary Orange: `#DC2F02`
|
||||||
|
- Black: `#000000`
|
||||||
|
- White: `#FFFFFF`
|
||||||
|
- Dark Background: `#090909` / `#111111`
|
||||||
|
- Glass Border: `rgba(255,255,255,0.15)`
|
||||||
|
- Glass Background: `rgba(255,255,255,0.08)`
|
||||||
|
|
||||||
|
## 2) Tech Stack
|
||||||
|
- Next.js latest stable version, App Router
|
||||||
|
- TypeScript
|
||||||
|
- Tailwind CSS
|
||||||
|
- PostgreSQL
|
||||||
|
- Prisma ORM
|
||||||
|
- NextAuth.js or custom JWT auth; choose the cleaner implementation for production
|
||||||
|
- bcrypt for password hashing
|
||||||
|
- Zod for validation
|
||||||
|
- React Hook Form for forms
|
||||||
|
- Lucide React for icons
|
||||||
|
|
||||||
|
## 3) Required Pages
|
||||||
|
|
||||||
|
### 3.1 Login Page `/login`
|
||||||
|
Requirements:
|
||||||
|
- Full-screen responsive login page
|
||||||
|
- Glassmorphism login card
|
||||||
|
- 4TECH logo area at top
|
||||||
|
- Email and password fields
|
||||||
|
- Show/hide password button
|
||||||
|
- Remember me checkbox
|
||||||
|
- Login button with orange gradient
|
||||||
|
- Error message area
|
||||||
|
- Redirect authenticated users to `/home`
|
||||||
|
- On successful login, redirect to `/home`
|
||||||
|
|
||||||
|
UI style:
|
||||||
|
- Dark gradient background
|
||||||
|
- Orange glow accents using `#DC2F02`
|
||||||
|
- Card with `backdrop-blur-xl`, transparent white background, soft border, shadow
|
||||||
|
|
||||||
|
### 3.2 Home Page `/home`
|
||||||
|
Requirements:
|
||||||
|
- Protected route
|
||||||
|
- Dashboard landing page after login
|
||||||
|
- Welcome message with current user name
|
||||||
|
- Summary cards:
|
||||||
|
- Total Users
|
||||||
|
- Active Users
|
||||||
|
- Admin Users
|
||||||
|
- Last Login
|
||||||
|
- Responsive card grid
|
||||||
|
- Sidebar or top navigation
|
||||||
|
|
||||||
|
### 3.3 User Profile Page `/profile`
|
||||||
|
Requirements:
|
||||||
|
- Protected route
|
||||||
|
- Show current user profile:
|
||||||
|
- Avatar placeholder
|
||||||
|
- Full name
|
||||||
|
- Email
|
||||||
|
- Role
|
||||||
|
- Status
|
||||||
|
- Created date
|
||||||
|
- Last login date
|
||||||
|
- Edit profile form:
|
||||||
|
- Full name
|
||||||
|
- Phone number
|
||||||
|
- Department
|
||||||
|
- Position
|
||||||
|
- Change password section:
|
||||||
|
- Current password
|
||||||
|
- New password
|
||||||
|
- Confirm password
|
||||||
|
|
||||||
|
### 3.4 Users Management Page `/users`
|
||||||
|
Requirements:
|
||||||
|
- Protected route
|
||||||
|
- Admin-only page
|
||||||
|
- Users table with:
|
||||||
|
- Name
|
||||||
|
- Email
|
||||||
|
- Role
|
||||||
|
- Status
|
||||||
|
- Department
|
||||||
|
- Created at
|
||||||
|
- Actions
|
||||||
|
- Search users by name/email
|
||||||
|
- Filter by role and status
|
||||||
|
- Create user modal
|
||||||
|
- Edit user modal
|
||||||
|
- Delete/disable user confirmation
|
||||||
|
- Pagination
|
||||||
|
- Responsive mobile layout; table should become stacked cards on small screens
|
||||||
|
|
||||||
|
## 4) User Roles
|
||||||
|
Implement role-based access control.
|
||||||
|
|
||||||
|
Roles:
|
||||||
|
- `SUPER_ADMIN`
|
||||||
|
- `ADMIN`
|
||||||
|
- `USER`
|
||||||
|
|
||||||
|
Permissions:
|
||||||
|
- `SUPER_ADMIN`: full access, can manage all users and roles
|
||||||
|
- `ADMIN`: can manage users but cannot edit/delete SUPER_ADMIN
|
||||||
|
- `USER`: can access Home and Profile only
|
||||||
|
|
||||||
|
## 5) Database Schema
|
||||||
|
Use Prisma with PostgreSQL.
|
||||||
|
|
||||||
|
Create models similar to:
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
passwordHash String
|
||||||
|
fullName String
|
||||||
|
phone String?
|
||||||
|
department String?
|
||||||
|
position String?
|
||||||
|
role Role @default(USER)
|
||||||
|
status Status @default(ACTIVE)
|
||||||
|
lastLoginAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Role {
|
||||||
|
SUPER_ADMIN
|
||||||
|
ADMIN
|
||||||
|
USER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Status {
|
||||||
|
ACTIVE
|
||||||
|
INACTIVE
|
||||||
|
SUSPENDED
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6) API Routes / Server Actions
|
||||||
|
Create secure backend actions or API routes for:
|
||||||
|
|
||||||
|
Authentication:
|
||||||
|
- `POST /api/auth/login`
|
||||||
|
- `POST /api/auth/logout`
|
||||||
|
- `GET /api/auth/me`
|
||||||
|
|
||||||
|
Users:
|
||||||
|
- `GET /api/users`
|
||||||
|
- `POST /api/users`
|
||||||
|
- `GET /api/users/:id`
|
||||||
|
- `PATCH /api/users/:id`
|
||||||
|
- `DELETE /api/users/:id` or soft disable user
|
||||||
|
|
||||||
|
Profile:
|
||||||
|
- `GET /api/profile`
|
||||||
|
- `PATCH /api/profile`
|
||||||
|
- `PATCH /api/profile/password`
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- Validate all inputs with Zod
|
||||||
|
- Never return password hash
|
||||||
|
- Hash password with bcrypt before saving
|
||||||
|
|
||||||
|
## 7) UI/UX Requirements
|
||||||
|
Overall design:
|
||||||
|
- Glassmorphism
|
||||||
|
- Corporate modern look, similar enterprise app style
|
||||||
|
- Dark mode first
|
||||||
|
- Responsive desktop/tablet/mobile
|
||||||
|
- Smooth hover states and transitions
|
||||||
|
- Orange accent color from logo
|
||||||
|
- Rounded corners: `rounded-2xl`
|
||||||
|
- Use consistent spacing and typography
|
||||||
|
|
||||||
|
Tailwind design tokens:
|
||||||
|
```ts
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
orange: '#DC2F02',
|
||||||
|
orangeDark: '#A82000',
|
||||||
|
black: '#000000',
|
||||||
|
dark: '#090909',
|
||||||
|
panel: 'rgba(255,255,255,0.08)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reusable components:
|
||||||
|
- `GlassCard`
|
||||||
|
- `Button`
|
||||||
|
- `Input`
|
||||||
|
- `Select`
|
||||||
|
- `Modal`
|
||||||
|
- `ConfirmDialog`
|
||||||
|
- `DataTable`
|
||||||
|
- `Sidebar`
|
||||||
|
- `Topbar`
|
||||||
|
- `Badge`
|
||||||
|
- `Avatar`
|
||||||
|
|
||||||
|
## 8) Suggested Folder Structure
|
||||||
|
```txt
|
||||||
|
src/
|
||||||
|
app/
|
||||||
|
login/
|
||||||
|
page.tsx
|
||||||
|
home/
|
||||||
|
page.tsx
|
||||||
|
profile/
|
||||||
|
page.tsx
|
||||||
|
users/
|
||||||
|
page.tsx
|
||||||
|
api/
|
||||||
|
auth/
|
||||||
|
users/
|
||||||
|
profile/
|
||||||
|
layout.tsx
|
||||||
|
globals.css
|
||||||
|
components/
|
||||||
|
ui/
|
||||||
|
layout/
|
||||||
|
users/
|
||||||
|
profile/
|
||||||
|
lib/
|
||||||
|
auth.ts
|
||||||
|
prisma.ts
|
||||||
|
password.ts
|
||||||
|
validations.ts
|
||||||
|
permissions.ts
|
||||||
|
middleware.ts
|
||||||
|
prisma/
|
||||||
|
schema.prisma
|
||||||
|
seed.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9) Security Requirements
|
||||||
|
- Protect all private routes with middleware
|
||||||
|
- Redirect unauthenticated users to `/login`
|
||||||
|
- Prevent logged-in users from accessing `/login`
|
||||||
|
- Use httpOnly cookie if using JWT custom auth
|
||||||
|
- Store password only as bcrypt hash
|
||||||
|
- Validate role permissions on server side, not only client side
|
||||||
|
- Add basic rate limiting for login if possible
|
||||||
|
- Use environment variables for database URL and secrets
|
||||||
|
|
||||||
|
Required `.env.example`:
|
||||||
|
```env
|
||||||
|
DATABASE_URL="postgresql://postgres:password@localhost:5432/4tech_app?schema=public"
|
||||||
|
AUTH_SECRET="57afa027bf1f1213acb32e26b261dfb250f4b871f35c3fbb9cfadf3aa28b924f"
|
||||||
|
NEXT_PUBLIC_APP_NAME="4TECH"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10) Seed Data
|
||||||
|
Create seed script with initial SUPER_ADMIN:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Email: admin@4tech.co.th
|
||||||
|
Password: Admin@123456
|
||||||
|
Role: SUPER_ADMIN
|
||||||
|
Status: ACTIVE
|
||||||
|
```
|
||||||
|
|
||||||
|
Show this credential only in README/development note, not in UI.
|
||||||
|
|
||||||
|
## 11) Acceptance Criteria
|
||||||
|
Codex should finish with:
|
||||||
|
- Complete working Next.js project
|
||||||
|
- Login works with PostgreSQL user data
|
||||||
|
- Protected Home/Profile/Users pages
|
||||||
|
- Admin-only Users Management
|
||||||
|
- Responsive glassmorphism UI
|
||||||
|
- Prisma migration and seed ready
|
||||||
|
- Clear README with setup commands
|
||||||
|
|
||||||
|
## 12) Setup Commands Expected in README
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
cp .env.example .env
|
||||||
|
npx prisma migrate dev
|
||||||
|
npx prisma db seed
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 13) Important Design Direction
|
||||||
|
Use the uploaded 4TECH logo as brand inspiration. The final UI should feel like a modern internal enterprise system: dark, premium, clean, glass-like, with orange highlights from the company logo. Avoid colorful gradients that do not match the logo. Keep the design professional and suitable for HR/admin/business systems.
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
/// <reference path="./.next/types/routes.d.ts" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+7124
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "4tech-user-management",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"prisma:seed": "tsx prisma/seed.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^3.9.1",
|
||||||
|
"@prisma/client": "^5.22.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"jose": "^5.9.6",
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"next": "^15.0.3",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-hook-form": "^7.53.2",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/node": "^22.9.0",
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"eslint": "^8.57.1",
|
||||||
|
"eslint-config-next": "^15.0.3",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"prisma": "^5.22.0",
|
||||||
|
"tailwindcss": "^3.4.15",
|
||||||
|
"tsx": "^4.19.2",
|
||||||
|
"typescript": "^5.6.3"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "tsx prisma/seed.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Role" AS ENUM ('SUPER_ADMIN', 'ADMIN', 'USER');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Status" AS ENUM ('ACTIVE', 'INACTIVE', 'SUSPENDED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"passwordHash" TEXT NOT NULL,
|
||||||
|
"fullName" TEXT NOT NULL,
|
||||||
|
"phone" TEXT,
|
||||||
|
"department" TEXT,
|
||||||
|
"position" TEXT,
|
||||||
|
"role" "Role" NOT NULL DEFAULT 'USER',
|
||||||
|
"status" "Status" NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
"lastLoginAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
passwordHash String
|
||||||
|
fullName String
|
||||||
|
phone String?
|
||||||
|
department String?
|
||||||
|
position String?
|
||||||
|
role Role @default(USER)
|
||||||
|
status Status @default(ACTIVE)
|
||||||
|
lastLoginAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Role {
|
||||||
|
SUPER_ADMIN
|
||||||
|
ADMIN
|
||||||
|
USER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Status {
|
||||||
|
ACTIVE
|
||||||
|
INACTIVE
|
||||||
|
SUSPENDED
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { PrismaClient, Role, Status } from "@prisma/client";
|
||||||
|
import { hashPassword } from "../src/lib/password";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await prisma.user.upsert({
|
||||||
|
where: { email: "admin@4tech.co.th" },
|
||||||
|
update: {
|
||||||
|
fullName: "4TECH Super Admin",
|
||||||
|
role: Role.SUPER_ADMIN,
|
||||||
|
status: Status.ACTIVE
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
email: "admin@4tech.co.th",
|
||||||
|
passwordHash: await hashPassword("Admin@123456"),
|
||||||
|
fullName: "4TECH Super Admin",
|
||||||
|
department: "IT",
|
||||||
|
position: "System Administrator",
|
||||||
|
role: Role.SUPER_ADMIN,
|
||||||
|
status: Status.ACTIVE
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
})
|
||||||
|
.catch(async (error) => {
|
||||||
|
console.error(error);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Status } from "@prisma/client";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { handleApiError, jsonError, userSelect } from "@/lib/api";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { checkRateLimit } from "@/lib/rate-limit";
|
||||||
|
import { setAuthCookie, signSession } from "@/lib/auth";
|
||||||
|
import { loginSchema } from "@/lib/validations";
|
||||||
|
import { verifyPassword } from "@/lib/password";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const forwardedFor = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
|
||||||
|
const clientKey = forwardedFor || request.headers.get("x-real-ip") || "local";
|
||||||
|
const limit = checkRateLimit(`login:${clientKey}`);
|
||||||
|
if (!limit.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Too many login attempts. Please try again soon." },
|
||||||
|
{ status: 429, headers: { "Retry-After": String(limit.retryAfter) } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = loginSchema.parse(await request.json());
|
||||||
|
const user = await prisma.user.findUnique({ where: { email: body.email } });
|
||||||
|
|
||||||
|
if (!user || user.status !== Status.ACTIVE) {
|
||||||
|
return jsonError("Invalid email or password", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await verifyPassword(body.password, user.passwordHash);
|
||||||
|
if (!isValid) {
|
||||||
|
return jsonError("Invalid email or password", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUser = await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { lastLoginAt: new Date() },
|
||||||
|
select: userSelect
|
||||||
|
});
|
||||||
|
const token = await signSession(
|
||||||
|
{
|
||||||
|
sub: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
name: user.fullName
|
||||||
|
},
|
||||||
|
body.remember
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = NextResponse.json({ user: updatedUser });
|
||||||
|
setAuthCookie(response, token, body.remember);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { clearAuthCookie } from "@/lib/auth";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const response = NextResponse.json({ ok: true });
|
||||||
|
clearAuthCookie(response);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
import { jsonError } from "@/lib/api";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
return jsonError("Unauthorized", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ user });
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { handleApiError, jsonError } from "@/lib/api";
|
||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import { hashPassword, verifyPassword } from "@/lib/password";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { passwordChangeSchema } from "@/lib/validations";
|
||||||
|
|
||||||
|
export async function PATCH(request: Request) {
|
||||||
|
try {
|
||||||
|
const sessionUser = await requireUser();
|
||||||
|
const body = passwordChangeSchema.parse(await request.json());
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: sessionUser.id } });
|
||||||
|
if (!user) {
|
||||||
|
return jsonError("Unauthorized", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await verifyPassword(body.currentPassword, user.passwordHash);
|
||||||
|
if (!isValid) {
|
||||||
|
return jsonError("Current password is incorrect", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { passwordHash: await hashPassword(body.newPassword) }
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { handleApiError, userSelect } from "@/lib/api";
|
||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { profileSchema } from "@/lib/validations";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const user = await requireUser();
|
||||||
|
return NextResponse.json({ user });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: Request) {
|
||||||
|
try {
|
||||||
|
const user = await requireUser();
|
||||||
|
const body = profileSchema.parse(await request.json());
|
||||||
|
const updatedUser = await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: body,
|
||||||
|
select: userSelect
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ user: updatedUser });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { handleApiError, jsonError, userSelect } from "@/lib/api";
|
||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import { canAssignRole, canManageUsers, canMutateUser } from "@/lib/permissions";
|
||||||
|
import { hashPassword } from "@/lib/password";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { userUpdateSchema } from "@/lib/validations";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
export async function GET(_request: Request, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const actor = await requireUser();
|
||||||
|
if (!canManageUsers(actor)) {
|
||||||
|
return jsonError("Forbidden", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: userSelect
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
return jsonError("User not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ user });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: Request, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const actor = await requireUser();
|
||||||
|
if (!canManageUsers(actor)) {
|
||||||
|
return jsonError("Forbidden", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = await prisma.user.findUnique({ where: { id } });
|
||||||
|
if (!target) {
|
||||||
|
return jsonError("User not found", 404);
|
||||||
|
}
|
||||||
|
if (!canMutateUser(actor, target)) {
|
||||||
|
return jsonError("Forbidden", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = userUpdateSchema.parse(await request.json());
|
||||||
|
if (body.role && !canAssignRole(actor, body.role)) {
|
||||||
|
return jsonError("You cannot assign this role", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUser = await prisma.user.update({
|
||||||
|
where: { id: target.id },
|
||||||
|
data: {
|
||||||
|
fullName: body.fullName,
|
||||||
|
phone: body.phone,
|
||||||
|
department: body.department,
|
||||||
|
position: body.position,
|
||||||
|
role: body.role,
|
||||||
|
status: body.status,
|
||||||
|
passwordHash: body.password ? await hashPassword(body.password) : undefined
|
||||||
|
},
|
||||||
|
select: userSelect
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ user: updatedUser });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_request: Request, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const actor = await requireUser();
|
||||||
|
if (!canManageUsers(actor)) {
|
||||||
|
return jsonError("Forbidden", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = await prisma.user.findUnique({ where: { id } });
|
||||||
|
if (!target) {
|
||||||
|
return jsonError("User not found", 404);
|
||||||
|
}
|
||||||
|
if (!canMutateUser(actor, target)) {
|
||||||
|
return jsonError("Forbidden", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUser = await prisma.user.update({
|
||||||
|
where: { id: target.id },
|
||||||
|
data: { status: "INACTIVE" },
|
||||||
|
select: userSelect
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ user: updatedUser });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { Role, type Prisma } from "@prisma/client";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { handleApiError, jsonError, userSelect } from "@/lib/api";
|
||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import { canAssignRole, canManageUsers } from "@/lib/permissions";
|
||||||
|
import { hashPassword } from "@/lib/password";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { userCreateSchema, usersQuerySchema } from "@/lib/validations";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const user = await requireUser();
|
||||||
|
if (!canManageUsers(user)) {
|
||||||
|
return jsonError("Forbidden", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const query = usersQuerySchema.parse(Object.fromEntries(url.searchParams));
|
||||||
|
const where: Prisma.UserWhereInput = {
|
||||||
|
role: query.role,
|
||||||
|
status: query.status,
|
||||||
|
OR: query.search
|
||||||
|
? [
|
||||||
|
{ fullName: { contains: query.search, mode: "insensitive" } },
|
||||||
|
{ email: { contains: query.search, mode: "insensitive" } }
|
||||||
|
]
|
||||||
|
: undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
const [users, total] = await prisma.$transaction([
|
||||||
|
prisma.user.findMany({
|
||||||
|
where,
|
||||||
|
select: userSelect,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
skip: (query.page - 1) * query.pageSize,
|
||||||
|
take: query.pageSize
|
||||||
|
}),
|
||||||
|
prisma.user.count({ where })
|
||||||
|
]);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
users,
|
||||||
|
meta: {
|
||||||
|
page: query.page,
|
||||||
|
pageSize: query.pageSize,
|
||||||
|
total,
|
||||||
|
pages: Math.max(1, Math.ceil(total / query.pageSize))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const actor = await requireUser();
|
||||||
|
if (!canManageUsers(actor)) {
|
||||||
|
return jsonError("Forbidden", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = userCreateSchema.parse(await request.json());
|
||||||
|
if (!canAssignRole(actor, body.role)) {
|
||||||
|
return jsonError("You cannot assign this role", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { password, ...userData } = body;
|
||||||
|
const createdUser = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
...userData,
|
||||||
|
role: actor.role === Role.ADMIN && body.role === Role.SUPER_ADMIN ? Role.USER : body.role,
|
||||||
|
passwordHash: await hashPassword(password)
|
||||||
|
},
|
||||||
|
select: userSelect
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ user: createdUser }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: #090909;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: rgba(220, 47, 2, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-bg {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(220, 47, 2, 0.34), transparent 34rem),
|
||||||
|
radial-gradient(circle at bottom right, rgba(220, 47, 2, 0.18), transparent 28rem),
|
||||||
|
linear-gradient(135deg, #090909 0%, #111111 54%, #000000 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
box-shadow: 0 22px 70px rgba(0, 0, 0, 0.38);
|
||||||
|
backdrop-filter: blur(22px);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { Activity, ShieldCheck, Users, Clock3 } from "lucide-react";
|
||||||
|
import { AppShell } from "@/components/layout/app-shell";
|
||||||
|
import { GlassCard } from "@/components/ui/glass-card";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
|
||||||
|
function formatDate(date?: Date | null) {
|
||||||
|
if (!date) return "Never";
|
||||||
|
return new Intl.DateTimeFormat("en", { dateStyle: "medium", timeStyle: "short" }).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function HomePage() {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
const [totalUsers, activeUsers, adminUsers] = await Promise.all([
|
||||||
|
prisma.user.count(),
|
||||||
|
prisma.user.count({ where: { status: "ACTIVE" } }),
|
||||||
|
prisma.user.count({ where: { role: { in: ["ADMIN", "SUPER_ADMIN"] } } })
|
||||||
|
]);
|
||||||
|
|
||||||
|
const cards = [
|
||||||
|
{ label: "Total Users", value: totalUsers, icon: Users },
|
||||||
|
{ label: "Active Users", value: activeUsers, icon: Activity },
|
||||||
|
{ label: "Admin Users", value: adminUsers, icon: ShieldCheck },
|
||||||
|
{ label: "Last Login", value: formatDate(user?.lastLoginAt), icon: Clock3 }
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<header className="pt-2">
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-brand-orange">Dashboard</p>
|
||||||
|
<h1 className="mt-2 text-3xl font-black sm:text-4xl">Welcome, {user?.fullName}</h1>
|
||||||
|
<p className="mt-2 text-white/55">4TECH user access and account overview.</p>
|
||||||
|
</header>
|
||||||
|
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{cards.map((card) => (
|
||||||
|
<GlassCard key={card.label} className="p-5">
|
||||||
|
<div className="mb-5 flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-orange/15 text-brand-orange">
|
||||||
|
<card.icon className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-white/55">{card.label}</p>
|
||||||
|
<p className="mt-2 break-words text-2xl font-black">{card.value}</p>
|
||||||
|
</GlassCard>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
<GlassCard className="p-6">
|
||||||
|
<h2 className="text-xl font-bold">System Status</h2>
|
||||||
|
<div className="mt-5 grid gap-3 md:grid-cols-3">
|
||||||
|
{["Authentication", "Profile Service", "User Management"].map((item) => (
|
||||||
|
<div key={item} className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||||
|
<p className="text-sm text-white/55">{item}</p>
|
||||||
|
<p className="mt-2 font-semibold text-emerald-200">Operational</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "4TECH",
|
||||||
|
description: "4TECH internal user management"
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { GlassCard } from "@/components/ui/glass-card";
|
||||||
|
import { LoginForm } from "@/components/login/login-form";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
|
||||||
|
export default async function LoginPage() {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (user) {
|
||||||
|
redirect("/home");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="brand-bg flex min-h-screen items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<GlassCard className="p-7 sm:p-8">
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-brand-orange text-2xl font-black shadow-glow">
|
||||||
|
4T
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-black tracking-wide">4TECH</h1>
|
||||||
|
<p className="mt-2 text-sm text-white/55">Secure internal access</p>
|
||||||
|
</div>
|
||||||
|
<LoginForm />
|
||||||
|
</GlassCard>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { AppShell } from "@/components/layout/app-shell";
|
||||||
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { GlassCard } from "@/components/ui/glass-card";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
import { PasswordForm } from "@/components/profile/password-form";
|
||||||
|
import { ProfileForm } from "@/components/profile/profile-form";
|
||||||
|
|
||||||
|
function formatDate(date?: Date | null) {
|
||||||
|
if (!date) return "Never";
|
||||||
|
return new Intl.DateTimeFormat("en", { dateStyle: "medium", timeStyle: "short" }).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ProfilePage() {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<header className="pt-2">
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-brand-orange">Profile</p>
|
||||||
|
<h1 className="mt-2 text-3xl font-black sm:text-4xl">Account Settings</h1>
|
||||||
|
</header>
|
||||||
|
<GlassCard className="p-6">
|
||||||
|
<div className="flex flex-col gap-5 sm:flex-row sm:items-center">
|
||||||
|
<Avatar name={user.fullName} className="h-20 w-20 text-2xl" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h2 className="break-words text-2xl font-bold">{user.fullName}</h2>
|
||||||
|
<p className="break-words text-white/55">{user.email}</p>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<Badge tone="orange">{user.role}</Badge>
|
||||||
|
<Badge tone={user.status === "ACTIVE" ? "green" : "red"}>{user.status}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 grid gap-3 md:grid-cols-3">
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||||
|
<p className="text-xs text-white/45">Created</p>
|
||||||
|
<p className="mt-1 text-sm">{formatDate(user.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||||
|
<p className="text-xs text-white/45">Last login</p>
|
||||||
|
<p className="mt-1 text-sm">{formatDate(user.lastLoginAt)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||||
|
<p className="text-xs text-white/45">Department</p>
|
||||||
|
<p className="mt-1 text-sm">{user.department ?? "-"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
<GlassCard className="p-6">
|
||||||
|
<h2 className="mb-5 text-xl font-bold">Edit Profile</h2>
|
||||||
|
<ProfileForm user={user} />
|
||||||
|
</GlassCard>
|
||||||
|
<GlassCard className="p-6">
|
||||||
|
<h2 className="mb-5 text-xl font-bold">Change Password</h2>
|
||||||
|
<PasswordForm />
|
||||||
|
</GlassCard>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { AppShell } from "@/components/layout/app-shell";
|
||||||
|
import { UsersManager } from "@/components/users/users-manager";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
import { canManageUsers } from "@/lib/permissions";
|
||||||
|
|
||||||
|
export default async function UsersPage() {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
if (!canManageUsers(user)) redirect("/home");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<header className="pt-2">
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-brand-orange">Administration</p>
|
||||||
|
<h1 className="mt-2 text-3xl font-black sm:text-4xl">Users Management</h1>
|
||||||
|
<p className="mt-2 text-white/55">Manage roles, access status, departments, and employee profiles.</p>
|
||||||
|
</header>
|
||||||
|
<UsersManager currentUserRole={user.role} />
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { LogOut, UserCog, UserRound, LayoutDashboard } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
import { canManageUsers } from "@/lib/permissions";
|
||||||
|
import { LogoutButton } from "./logout-button";
|
||||||
|
|
||||||
|
export async function AppShell({ children }: { children: React.ReactNode }) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const nav = [
|
||||||
|
{ href: "/home", label: "Home", icon: LayoutDashboard },
|
||||||
|
{ href: "/profile", label: "Profile", icon: UserRound },
|
||||||
|
...(canManageUsers(user) ? [{ href: "/users", label: "Users", icon: UserCog }] : [])
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="brand-bg min-h-screen">
|
||||||
|
<div className="mx-auto flex min-h-screen w-full max-w-7xl flex-col gap-6 p-4 md:flex-row md:p-6">
|
||||||
|
<aside className="glass flex h-fit flex-col rounded-2xl p-4 md:sticky md:top-6 md:w-64">
|
||||||
|
<Link href="/home" className="mb-6 flex items-center gap-3">
|
||||||
|
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-brand-orange font-black">4T</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-black tracking-wide">4TECH</p>
|
||||||
|
<p className="text-xs text-white/50">Internal System</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<nav className="grid gap-2">
|
||||||
|
{nav.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className="flex items-center gap-3 rounded-2xl px-3 py-3 text-sm font-semibold text-white/75 transition hover:bg-white/10 hover:text-white"
|
||||||
|
>
|
||||||
|
<item.icon className="h-5 w-5 text-brand-orange" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="mt-6 border-t border-white/10 pt-4">
|
||||||
|
<p className="truncate text-sm font-semibold">{user.fullName}</p>
|
||||||
|
<p className="truncate text-xs text-white/45">{user.email}</p>
|
||||||
|
<LogoutButton>
|
||||||
|
<Button variant="secondary" className="mt-4 w-full">
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
Sign out
|
||||||
|
</Button>
|
||||||
|
</LogoutButton>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<section className="min-w-0 flex-1">{children}</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
export function LogoutButton({ children }: { children: React.ReactElement }) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await fetch("/api/auth/logout", { method: "POST" });
|
||||||
|
router.push("/login");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span onClick={logout} role="presentation">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Eye, EyeOff, Lock, Mail } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { loginSchema } from "@/lib/validations";
|
||||||
|
|
||||||
|
type LoginValues = z.infer<typeof loginSchema>;
|
||||||
|
|
||||||
|
export function LoginForm() {
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors, isSubmitting }
|
||||||
|
} = useForm<LoginValues>({
|
||||||
|
resolver: zodResolver(loginSchema),
|
||||||
|
defaultValues: { email: "", password: "", remember: false }
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit(values: LoginValues) {
|
||||||
|
setMessage("");
|
||||||
|
const response = await fetch("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(values)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
setMessage(data?.error ?? "Unable to sign in");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = "/home";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="pointer-events-none absolute left-4 top-10 h-5 w-5 text-white/35" />
|
||||||
|
<Input label="Email" type="email" placeholder="admin@4tech.co.th" className="pl-12" error={errors.email?.message} {...register("email")} />
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="pointer-events-none absolute left-4 top-10 h-5 w-5 text-white/35" />
|
||||||
|
<Input
|
||||||
|
label="Password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
placeholder="Enter password"
|
||||||
|
className="pl-12 pr-12"
|
||||||
|
error={errors.password?.message}
|
||||||
|
{...register("password")}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-3 top-9 rounded-full p-2 text-white/60 transition hover:bg-white/10 hover:text-white"
|
||||||
|
onClick={() => setShowPassword((value) => !value)}
|
||||||
|
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-3 text-sm text-white/70">
|
||||||
|
<input type="checkbox" className="h-4 w-4 accent-brand-orange" {...register("remember")} />
|
||||||
|
Remember me
|
||||||
|
</label>
|
||||||
|
{message ? <div className="rounded-2xl border border-red-400/30 bg-red-500/10 px-4 py-3 text-sm text-red-100">{message}</div> : null}
|
||||||
|
<Button className="w-full" loading={isSubmitting}>
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
export function PasswordForm() {
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
async function submit(formData: FormData) {
|
||||||
|
setSaving(true);
|
||||||
|
setMessage("");
|
||||||
|
const response = await fetch("/api/profile/password", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(Object.fromEntries(formData))
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
setSaving(false);
|
||||||
|
setMessage(response.ok ? "Password changed" : data?.error ?? "Unable to change password");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={submit} className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<Input name="currentPassword" label="Current password" type="password" required />
|
||||||
|
<Input name="newPassword" label="New password" type="password" required />
|
||||||
|
<Input name="confirmPassword" label="Confirm password" type="password" required />
|
||||||
|
<div className="sm:col-span-3">
|
||||||
|
{message ? <p className="mb-3 text-sm text-white/65">{message}</p> : null}
|
||||||
|
<Button loading={saving}>Change password</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { User } from "@prisma/client";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
type ProfileUser = Pick<User, "fullName" | "phone" | "department" | "position">;
|
||||||
|
|
||||||
|
export function ProfileForm({ user }: { user: ProfileUser }) {
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
async function submit(formData: FormData) {
|
||||||
|
setSaving(true);
|
||||||
|
setMessage("");
|
||||||
|
const response = await fetch("/api/profile", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(Object.fromEntries(formData))
|
||||||
|
});
|
||||||
|
setSaving(false);
|
||||||
|
setMessage(response.ok ? "Profile updated" : "Unable to update profile");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={submit} className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Input name="fullName" label="Full name" defaultValue={user.fullName} required />
|
||||||
|
<Input name="phone" label="Phone" defaultValue={user.phone ?? ""} />
|
||||||
|
<Input name="department" label="Department" defaultValue={user.department ?? ""} />
|
||||||
|
<Input name="position" label="Position" defaultValue={user.position ?? ""} />
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
{message ? <p className="mb-3 text-sm text-white/65">{message}</p> : null}
|
||||||
|
<Button loading={saving}>Save profile</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export function Avatar({ name, className = "" }: { name: string; className?: string }) {
|
||||||
|
const initials = name
|
||||||
|
.split(" ")
|
||||||
|
.map((part) => part[0])
|
||||||
|
.join("")
|
||||||
|
.slice(0, 2)
|
||||||
|
.toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-center rounded-2xl bg-gradient-to-br from-brand-orange to-black font-bold text-white shadow-glow ${className}`}
|
||||||
|
aria-label={name}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export function Badge({ children, tone = "neutral" }: { children: React.ReactNode; tone?: "green" | "orange" | "red" | "neutral" }) {
|
||||||
|
const tones = {
|
||||||
|
green: "border-emerald-400/30 bg-emerald-400/10 text-emerald-200",
|
||||||
|
orange: "border-brand-orange/40 bg-brand-orange/15 text-orange-100",
|
||||||
|
red: "border-red-400/30 bg-red-400/10 text-red-200",
|
||||||
|
neutral: "border-white/15 bg-white/10 text-white/75"
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-semibold ${tones[tone]}`}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import type { ButtonHTMLAttributes } from "react";
|
||||||
|
|
||||||
|
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
|
variant?: "primary" | "secondary" | "ghost" | "danger";
|
||||||
|
loading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
primary:
|
||||||
|
"bg-gradient-to-r from-brand-orange to-brand-orangeDark text-white shadow-glow hover:brightness-110",
|
||||||
|
secondary:
|
||||||
|
"border border-white/15 bg-white/10 text-white hover:bg-white/15",
|
||||||
|
ghost: "text-white/80 hover:bg-white/10 hover:text-white",
|
||||||
|
danger: "border border-red-400/30 bg-red-500/15 text-red-100 hover:bg-red-500/25"
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Button({ className = "", children, variant = "primary", loading, disabled, ...props }: ButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`inline-flex min-h-11 items-center justify-center gap-2 rounded-2xl px-4 py-2 text-sm font-semibold transition disabled:cursor-not-allowed disabled:opacity-60 ${variants[variant]} ${className}`}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
import { Button } from "./button";
|
||||||
|
import { Modal } from "./modal";
|
||||||
|
|
||||||
|
export function ConfirmDialog({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
confirmLabel = "Confirm",
|
||||||
|
onCancel,
|
||||||
|
onConfirm
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Modal title={title} open={open} onClose={onCancel}>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-red-500/15 text-red-200">
|
||||||
|
<AlertTriangle className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-6 text-white/65">{description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex justify-end gap-3">
|
||||||
|
<Button type="button" variant="secondary" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="danger" onClick={onConfirm}>
|
||||||
|
{confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export function DataTable({
|
||||||
|
headers,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
headers: string[];
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="hidden overflow-hidden rounded-2xl border border-white/15 bg-white/[0.06] md:block">
|
||||||
|
<table className="w-full border-collapse text-left text-sm">
|
||||||
|
<thead className="bg-white/10 text-xs uppercase tracking-wide text-white/50">
|
||||||
|
<tr>
|
||||||
|
{headers.map((head) => (
|
||||||
|
<th key={head} className="px-4 py-3 font-semibold">
|
||||||
|
{head}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>{children}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type { HTMLAttributes } from "react";
|
||||||
|
|
||||||
|
export function GlassCard({ className = "", ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={`glass rounded-2xl ${className}`} {...props} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { InputHTMLAttributes } from "react";
|
||||||
|
|
||||||
|
type InputProps = InputHTMLAttributes<HTMLInputElement> & {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Input({ label, error, className = "", ...props }: InputProps) {
|
||||||
|
return (
|
||||||
|
<label className="block space-y-2 text-sm text-white/80">
|
||||||
|
{label ? <span>{label}</span> : null}
|
||||||
|
<input
|
||||||
|
className={`h-12 w-full rounded-2xl border border-white/15 bg-black/30 px-4 text-white outline-none transition placeholder:text-white/35 focus:border-brand-orange focus:ring-2 focus:ring-brand-orange/25 ${className}`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{error ? <span className="text-xs text-red-300">{error}</span> : null}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { Button } from "./button";
|
||||||
|
|
||||||
|
export function Modal({
|
||||||
|
title,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
if (!open) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
|
||||||
|
<div className="glass max-h-[92vh] w-full max-w-2xl overflow-y-auto rounded-2xl p-6">
|
||||||
|
<div className="mb-5 flex items-center justify-between gap-4">
|
||||||
|
<h2 className="text-xl font-semibold">{title}</h2>
|
||||||
|
<Button type="button" variant="ghost" className="h-10 w-10 rounded-full p-0" onClick={onClose} aria-label="Close">
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { SelectHTMLAttributes } from "react";
|
||||||
|
|
||||||
|
type SelectProps = SelectHTMLAttributes<HTMLSelectElement> & {
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Select({ label, className = "", children, ...props }: SelectProps) {
|
||||||
|
return (
|
||||||
|
<label className="block space-y-2 text-sm text-white/80">
|
||||||
|
{label ? <span>{label}</span> : null}
|
||||||
|
<select
|
||||||
|
className={`h-12 w-full rounded-2xl border border-white/15 bg-black/40 px-4 text-white outline-none transition focus:border-brand-orange focus:ring-2 focus:ring-brand-orange/25 ${className}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Role, Status, type User } from "@prisma/client";
|
||||||
|
import { Edit, Plus, Search, ShieldAlert, Trash2 } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
|
import { DataTable } from "@/components/ui/data-table";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Modal } from "@/components/ui/modal";
|
||||||
|
import { Select } from "@/components/ui/select";
|
||||||
|
|
||||||
|
type PublicUser = Omit<User, "passwordHash">;
|
||||||
|
type Meta = { page: number; pageSize: number; total: number; pages: number };
|
||||||
|
type UserForm = {
|
||||||
|
fullName: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
phone: string;
|
||||||
|
department: string;
|
||||||
|
position: string;
|
||||||
|
role: Role;
|
||||||
|
status: Status;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyUser: UserForm = {
|
||||||
|
fullName: "",
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
phone: "",
|
||||||
|
department: "",
|
||||||
|
position: "",
|
||||||
|
role: Role.USER,
|
||||||
|
status: Status.ACTIVE
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(date: string | Date) {
|
||||||
|
return new Intl.DateTimeFormat("en", { dateStyle: "medium" }).format(new Date(date));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UsersManager({ currentUserRole }: { currentUserRole: Role }) {
|
||||||
|
const [users, setUsers] = useState<PublicUser[]>([]);
|
||||||
|
const [meta, setMeta] = useState<Meta>({ page: 1, pageSize: 8, total: 0, pages: 1 });
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [role, setRole] = useState("");
|
||||||
|
const [status, setStatus] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [editing, setEditing] = useState<PublicUser | null>(null);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [form, setForm] = useState<UserForm>(emptyUser);
|
||||||
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
|
const [disableTarget, setDisableTarget] = useState<PublicUser | null>(null);
|
||||||
|
|
||||||
|
const canUseSuperAdmin = currentUserRole === Role.SUPER_ADMIN;
|
||||||
|
const page = meta.page;
|
||||||
|
|
||||||
|
const query = useMemo(() => {
|
||||||
|
const params = new URLSearchParams({ page: String(page), pageSize: "8" });
|
||||||
|
if (search) params.set("search", search);
|
||||||
|
if (role) params.set("role", role);
|
||||||
|
if (status) params.set("status", status);
|
||||||
|
return params.toString();
|
||||||
|
}, [page, role, search, status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
async function loadUsers() {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch(`/api/users?${query}`, { signal: controller.signal });
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setUsers(data.users);
|
||||||
|
setMeta(data.meta);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadUsers().catch(() => setLoading(false));
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [query, reloadKey]);
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setForm(emptyUser);
|
||||||
|
setCreating(true);
|
||||||
|
setEditing(null);
|
||||||
|
setMessage("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(user: PublicUser) {
|
||||||
|
setEditing(user);
|
||||||
|
setCreating(false);
|
||||||
|
setMessage("");
|
||||||
|
setForm({
|
||||||
|
fullName: user.fullName,
|
||||||
|
email: user.email,
|
||||||
|
password: "",
|
||||||
|
phone: user.phone ?? "",
|
||||||
|
department: user.department ?? "",
|
||||||
|
position: user.position ?? "",
|
||||||
|
role: user.role,
|
||||||
|
status: user.status
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUser(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setMessage("");
|
||||||
|
const payload = editing
|
||||||
|
? {
|
||||||
|
fullName: form.fullName,
|
||||||
|
phone: form.phone,
|
||||||
|
department: form.department,
|
||||||
|
position: form.position,
|
||||||
|
role: form.role,
|
||||||
|
status: form.status,
|
||||||
|
password: form.password
|
||||||
|
}
|
||||||
|
: form;
|
||||||
|
const response = await fetch(editing ? `/api/users/${editing.id}` : "/api/users", {
|
||||||
|
method: editing ? "PATCH" : "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) {
|
||||||
|
setMessage(data?.error ?? "Unable to save user");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreating(false);
|
||||||
|
setEditing(null);
|
||||||
|
setReloadKey((value) => value + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disableUser() {
|
||||||
|
if (!disableTarget) return;
|
||||||
|
const response = await fetch(`/api/users/${disableTarget.id}`, { method: "DELETE" });
|
||||||
|
if (response.ok) {
|
||||||
|
setDisableTarget(null);
|
||||||
|
setReloadKey((value) => value + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalOpen = creating || Boolean(editing);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="glass rounded-2xl p-4">
|
||||||
|
<div className="grid gap-3 lg:grid-cols-[1fr_180px_180px_auto]">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-4 top-3.5 h-5 w-5 text-white/35" />
|
||||||
|
<Input placeholder="Search name or email" className="pl-12" value={search} onChange={(event) => setSearch(event.target.value)} />
|
||||||
|
</div>
|
||||||
|
<Select value={role} onChange={(event) => setRole(event.target.value)}>
|
||||||
|
<option value="">All roles</option>
|
||||||
|
{Object.values(Role).map((item) => (
|
||||||
|
<option key={item} value={item}>
|
||||||
|
{item}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Select value={status} onChange={(event) => setStatus(event.target.value)}>
|
||||||
|
<option value="">All status</option>
|
||||||
|
{Object.values(Status).map((item) => (
|
||||||
|
<option key={item} value={item}>
|
||||||
|
{item}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Button onClick={openCreate}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable headers={["Name", "Email", "Role", "Status", "Department", "Created", "Actions"]}>
|
||||||
|
{users.map((user) => (
|
||||||
|
<tr key={user.id} className="border-t border-white/10">
|
||||||
|
<td className="px-4 py-4 font-semibold">{user.fullName}</td>
|
||||||
|
<td className="px-4 py-4 text-white/65">{user.email}</td>
|
||||||
|
<td className="px-4 py-4"><Badge tone="orange">{user.role}</Badge></td>
|
||||||
|
<td className="px-4 py-4"><Badge tone={user.status === Status.ACTIVE ? "green" : "red"}>{user.status}</Badge></td>
|
||||||
|
<td className="px-4 py-4 text-white/65">{user.department ?? "-"}</td>
|
||||||
|
<td className="px-4 py-4 text-white/65">{formatDate(user.createdAt)}</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="secondary" className="h-10 w-10 p-0" onClick={() => openEdit(user)} aria-label="Edit user">
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" className="h-10 w-10 p-0" onClick={() => setDisableTarget(user)} aria-label="Disable user">
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:hidden">
|
||||||
|
{users.map((user) => (
|
||||||
|
<div key={user.id} className="glass rounded-2xl p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="break-words font-semibold">{user.fullName}</p>
|
||||||
|
<p className="break-words text-sm text-white/55">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
<Badge tone={user.status === Status.ACTIVE ? "green" : "red"}>{user.status}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-2 text-sm text-white/65">
|
||||||
|
<p>Role: {user.role}</p>
|
||||||
|
<p>Department: {user.department ?? "-"}</p>
|
||||||
|
<p>Created: {formatDate(user.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex gap-2">
|
||||||
|
<Button variant="secondary" onClick={() => openEdit(user)}>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" onClick={() => setDisableTarget(user)}>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Disable
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? <p className="text-sm text-white/55">Loading users...</p> : null}
|
||||||
|
{!loading && users.length === 0 ? (
|
||||||
|
<div className="glass rounded-2xl p-8 text-center text-white/60">
|
||||||
|
<ShieldAlert className="mx-auto mb-3 h-8 w-8 text-brand-orange" />
|
||||||
|
No users found
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-white/55">
|
||||||
|
<span>
|
||||||
|
Page {meta.page} of {meta.pages} · {meta.total} users
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="secondary" disabled={meta.page <= 1} onClick={() => setMeta((value) => ({ ...value, page: value.page - 1 }))}>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" disabled={meta.page >= meta.pages} onClick={() => setMeta((value) => ({ ...value, page: value.page + 1 }))}>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal title={editing ? "Edit User" : "Create User"} open={modalOpen} onClose={() => { setCreating(false); setEditing(null); }}>
|
||||||
|
<form onSubmit={saveUser} className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Input label="Full name" value={form.fullName} onChange={(event) => setForm({ ...form, fullName: event.target.value })} required />
|
||||||
|
<Input label="Email" type="email" value={form.email} onChange={(event) => setForm({ ...form, email: event.target.value })} required disabled={Boolean(editing)} />
|
||||||
|
<Input
|
||||||
|
label={editing ? "New password" : "Password"}
|
||||||
|
type="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(event) => setForm({ ...form, password: event.target.value })}
|
||||||
|
required={!editing}
|
||||||
|
/>
|
||||||
|
<Input label="Phone" value={form.phone} onChange={(event) => setForm({ ...form, phone: event.target.value })} />
|
||||||
|
<Input label="Department" value={form.department} onChange={(event) => setForm({ ...form, department: event.target.value })} />
|
||||||
|
<Input label="Position" value={form.position} onChange={(event) => setForm({ ...form, position: event.target.value })} />
|
||||||
|
<Select label="Role" value={form.role} onChange={(event) => setForm({ ...form, role: event.target.value as Role })}>
|
||||||
|
{Object.values(Role)
|
||||||
|
.filter((item) => canUseSuperAdmin || item !== Role.SUPER_ADMIN)
|
||||||
|
.map((item) => (
|
||||||
|
<option key={item} value={item}>
|
||||||
|
{item}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Select label="Status" value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value as Status })}>
|
||||||
|
{Object.values(Status).map((item) => (
|
||||||
|
<option key={item} value={item}>
|
||||||
|
{item}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
{message ? <p className="rounded-2xl border border-red-400/30 bg-red-500/10 px-4 py-3 text-sm text-red-100 sm:col-span-2">{message}</p> : null}
|
||||||
|
<div className="flex justify-end gap-3 sm:col-span-2">
|
||||||
|
<Button type="button" variant="secondary" onClick={() => { setCreating(false); setEditing(null); }}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">Save user</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(disableTarget)}
|
||||||
|
title="Disable User"
|
||||||
|
description={`Disable ${disableTarget?.fullName ?? "this user"} and mark the account as inactive?`}
|
||||||
|
confirmLabel="Disable"
|
||||||
|
onCancel={() => setDisableTarget(null)}
|
||||||
|
onConfirm={disableUser}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { ZodError } from "zod";
|
||||||
|
|
||||||
|
export function jsonError(message: string, status = 400) {
|
||||||
|
return NextResponse.json({ error: message }, { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleApiError(error: unknown) {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
return jsonError(error.issues[0]?.message ?? "Invalid request", 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Response) {
|
||||||
|
return jsonError(error.statusText || "Unauthorized", error.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(error);
|
||||||
|
return jsonError("Something went wrong", 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const userSelect = {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
fullName: true,
|
||||||
|
phone: true,
|
||||||
|
department: true,
|
||||||
|
position: true,
|
||||||
|
role: true,
|
||||||
|
status: true,
|
||||||
|
lastLoginAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true
|
||||||
|
};
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import { prisma } from "./prisma";
|
||||||
|
import { AUTH_COOKIE, signSession, verifySession } from "./session";
|
||||||
|
|
||||||
|
export { AUTH_COOKIE, signSession, verifySession };
|
||||||
|
|
||||||
|
export async function getCurrentUser() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get(AUTH_COOKIE)?.value;
|
||||||
|
const session = await verifySession(token);
|
||||||
|
if (!session?.sub) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.user.findUnique({
|
||||||
|
where: { id: session.sub },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
fullName: true,
|
||||||
|
phone: true,
|
||||||
|
department: true,
|
||||||
|
position: true,
|
||||||
|
role: true,
|
||||||
|
status: true,
|
||||||
|
lastLoginAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireUser() {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
throw new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAuthCookie(response: NextResponse, token: string, remember = false) {
|
||||||
|
response.cookies.set(AUTH_COOKIE, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
maxAge: remember ? 60 * 60 * 24 * 30 : 60 * 60 * 8
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAuthCookie(response: NextResponse) {
|
||||||
|
response.cookies.set(AUTH_COOKIE, "", {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
maxAge: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sessionFromRequest(request: NextRequest) {
|
||||||
|
return verifySession(request.cookies.get(AUTH_COOKIE)?.value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
|
export function hashPassword(password: string) {
|
||||||
|
return bcrypt.hash(password, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyPassword(password: string, passwordHash: string) {
|
||||||
|
return bcrypt.compare(password, passwordHash);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Role, type User } from "@prisma/client";
|
||||||
|
|
||||||
|
type SessionUser = Pick<User, "id" | "role">;
|
||||||
|
type TargetUser = Pick<User, "id" | "role">;
|
||||||
|
|
||||||
|
export function canManageUsers(user: SessionUser | null) {
|
||||||
|
return user?.role === Role.SUPER_ADMIN || user?.role === Role.ADMIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canMutateUser(actor: SessionUser, target: TargetUser) {
|
||||||
|
if (actor.role === Role.SUPER_ADMIN) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actor.role === Role.ADMIN) {
|
||||||
|
return target.role !== Role.SUPER_ADMIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
return actor.id === target.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canAssignRole(actor: SessionUser, role: Role) {
|
||||||
|
if (actor.role === Role.SUPER_ADMIN) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return actor.role === Role.ADMIN && role !== Role.SUPER_ADMIN;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
|
||||||
|
|
||||||
|
export const prisma =
|
||||||
|
globalForPrisma.prisma ??
|
||||||
|
new PrismaClient({
|
||||||
|
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
globalForPrisma.prisma = prisma;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
type Bucket = {
|
||||||
|
count: number;
|
||||||
|
resetAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buckets = new Map<string, Bucket>();
|
||||||
|
|
||||||
|
export function checkRateLimit(key: string, limit = 5, windowMs = 60_000) {
|
||||||
|
const now = Date.now();
|
||||||
|
const bucket = buckets.get(key);
|
||||||
|
|
||||||
|
if (!bucket || bucket.resetAt <= now) {
|
||||||
|
buckets.set(key, { count: 1, resetAt: now + windowMs });
|
||||||
|
return { allowed: true, retryAfter: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
bucket.count += 1;
|
||||||
|
if (bucket.count <= limit) {
|
||||||
|
return { allowed: true, retryAfter: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
retryAfter: Math.ceil((bucket.resetAt - now) / 1000)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { jwtVerify, SignJWT } from "jose";
|
||||||
|
|
||||||
|
export const AUTH_COOKIE = "ft_auth";
|
||||||
|
|
||||||
|
export type AuthPayload = {
|
||||||
|
sub: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function secretKey() {
|
||||||
|
const secret = process.env.AUTH_SECRET;
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error("AUTH_SECRET is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TextEncoder().encode(secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signSession(payload: AuthPayload, remember = false) {
|
||||||
|
return new SignJWT(payload)
|
||||||
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
|
.setSubject(payload.sub)
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime(remember ? "30d" : "8h")
|
||||||
|
.sign(secretKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifySession(token?: string) {
|
||||||
|
if (!token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(token, secretKey());
|
||||||
|
return payload as AuthPayload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Role, Status } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const loginSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(8),
|
||||||
|
remember: z.boolean().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const profileSchema = z.object({
|
||||||
|
fullName: z.string().min(2).max(120),
|
||||||
|
phone: z.string().max(30).optional().nullable(),
|
||||||
|
department: z.string().max(80).optional().nullable(),
|
||||||
|
position: z.string().max(80).optional().nullable()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const passwordChangeSchema = z
|
||||||
|
.object({
|
||||||
|
currentPassword: z.string().min(8),
|
||||||
|
newPassword: z.string().min(8),
|
||||||
|
confirmPassword: z.string().min(8)
|
||||||
|
})
|
||||||
|
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||||
|
message: "Passwords do not match",
|
||||||
|
path: ["confirmPassword"]
|
||||||
|
});
|
||||||
|
|
||||||
|
export const userCreateSchema = profileSchema.extend({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(8),
|
||||||
|
role: z.nativeEnum(Role),
|
||||||
|
status: z.nativeEnum(Status)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const userUpdateSchema = profileSchema.extend({
|
||||||
|
role: z.nativeEnum(Role).optional(),
|
||||||
|
status: z.nativeEnum(Status).optional(),
|
||||||
|
password: z.string().min(8).optional().or(z.literal(""))
|
||||||
|
});
|
||||||
|
|
||||||
|
export const usersQuerySchema = z.object({
|
||||||
|
search: z.string().optional().default(""),
|
||||||
|
role: z.nativeEnum(Role).optional(),
|
||||||
|
status: z.nativeEnum(Status).optional(),
|
||||||
|
page: z.coerce.number().int().min(1).optional().default(1),
|
||||||
|
pageSize: z.coerce.number().int().min(1).max(50).optional().default(8)
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import { AUTH_COOKIE, verifySession } from "@/lib/session";
|
||||||
|
|
||||||
|
const privateRoutes = ["/home", "/profile", "/users"];
|
||||||
|
const adminRoutes = ["/users"];
|
||||||
|
|
||||||
|
export async function middleware(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
const session = await verifySession(request.cookies.get(AUTH_COOKIE)?.value);
|
||||||
|
|
||||||
|
if (pathname === "/" && !request.cookies.get(AUTH_COOKIE)) {
|
||||||
|
return NextResponse.redirect(new URL("/login", request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === "/login" && session) {
|
||||||
|
return NextResponse.redirect(new URL("/home", request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (privateRoutes.some((route) => pathname.startsWith(route)) && !session) {
|
||||||
|
return NextResponse.redirect(new URL("/login", request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adminRoutes.some((route) => pathname.startsWith(route))) {
|
||||||
|
const allowed = session?.role === "SUPER_ADMIN" || session?.role === "ADMIN";
|
||||||
|
if (!allowed) {
|
||||||
|
return NextResponse.redirect(new URL("/home", request.url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/", "/login", "/home/:path*", "/profile/:path*", "/users/:path*"]
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: ["./src/**/*.{ts,tsx}"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
orange: "#DC2F02",
|
||||||
|
orangeDark: "#A82000",
|
||||||
|
black: "#000000",
|
||||||
|
dark: "#090909",
|
||||||
|
panel: "rgba(255,255,255,0.08)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
glow: "0 0 40px rgba(220,47,2,0.28)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: []
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user