first commit

This commit is contained in:
rachit1977
2026-06-14 10:03:34 +07:00
commit b55a575ac3
53 changed files with 9257 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
DATABASE_URL="postgresql://postgres:password@localhost:5432/4tech_app?schema=public"
AUTH_SECRET="57afa027bf1f1213acb32e26b261dfb250f4b871f35c3fbb9cfadf3aa28b924f"
NEXT_PUBLIC_APP_NAME="4TECH"
+3
View File
@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals"]
}
+7
View File
@@ -0,0 +1,7 @@
.env
.next
node_modules
dist
coverage
*.log
.next-dev.*.log
+38
View File
@@ -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"
```
+293
View File
@@ -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.
+6
View File
@@ -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.
+4
View File
@@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;
+7124
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -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"
}
}
+8
View File
@@ -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");
+35
View File
@@ -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
}
+34
View File
@@ -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);
});
+55
View File
@@ -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);
}
}
+8
View File
@@ -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;
}
+12
View File
@@ -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 });
}
+31
View File
@@ -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);
}
}
+30
View File
@@ -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);
}
}
+100
View File
@@ -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);
}
}
+81
View File
@@ -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);
}
}
+46
View File
@@ -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);
}
+60
View File
@@ -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>
);
}
+15
View File
@@ -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>
);
}
+28
View File
@@ -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>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function Page() {
redirect("/login");
}
+64
View File
@@ -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>
);
}
+24
View File
@@ -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>
);
}
+59
View File
@@ -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>
);
}
+19
View File
@@ -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>
);
}
+78
View File
@@ -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>
);
}
+35
View File
@@ -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>
);
}
+38
View File
@@ -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>
);
}
+17
View File
@@ -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>
);
}
+14
View File
@@ -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>
);
}
+29
View File
@@ -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>
);
}
+40
View File
@@ -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>
);
}
+24
View File
@@ -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>
);
}
+5
View File
@@ -0,0 +1,5 @@
import type { HTMLAttributes } from "react";
export function GlassCard({ className = "", ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={`glass rounded-2xl ${className}`} {...props} />;
}
+19
View File
@@ -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>
);
}
+34
View File
@@ -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>
);
}
+19
View File
@@ -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>
);
}
+302
View File
@@ -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>
);
}
+33
View File
@@ -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
};
+65
View File
@@ -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);
}
+9
View File
@@ -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);
}
+28
View File
@@ -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;
}
+13
View File
@@ -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;
}
+26
View File
@@ -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)
};
}
+41
View File
@@ -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;
}
}
+47
View File
@@ -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)
});
+35
View File
@@ -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*"]
};
+24
View File
@@ -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;
+23
View File
@@ -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"]
}