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