This project converts the uploaded starter_postgres.sql (22 tables) to a Drizzle ORM
schema with JWT HTTP-Only Cookie + Refresh Token + RBAC Middleware on the server side authentication system.
✅ This project has been successfully run through testing (build, migrate, seed, login, refresh, RBAC 200/403/401 all tested via localhost)
| Layer | Version |
|---|---|
| Nuxt | 4.4.8 |
| Nuxt UI | 4.10.0 |
| Drizzle ORM | 0.45.2 (+ drizzle-kit 0.31.10) |
| PostgreSQL | 18 (via Docker) |
| TypeScript | 5.7+ |
| bcryptjs | 3.0.3 (via Password) |
| jsonwebtoken | 9.0.3 (sign/verify JWT) |
| zod | 4.4.3 (validate request body) |
app/
pages/
index.vue Home
login.vue Login page
admin/index.vue Dashboard (ต้อง login)
admin/users.vue ตัวอย่างหน้าที่ใช้ v-can + RBAC API
middleware/
auth.global.ts Client route middleware ป้องกัน /admin/**
composables/
useAuth.ts login/logout/fetchMe/can()/hasRole()
useApi.ts ofetch wrapper + silent refresh อัตโนมัติเมื่อเจอ 401
plugins/
permission.client.ts v-can="'permission_code'" directive
server/
database/
schema.ts Drizzle schema (22 ตาราง แปลงจาก starter_postgres.sql)
client.ts useDb() — postgres.js connection (singleton)
seed.ts Create basic permissions + Admin/Viewer roles + admin user.
middleware/
00.auth.ts Check the JWT from the cookie for every request at /api/** (except login/refresh/logout).
utils/
jwt.ts sign/verify access token, generate refresh token
password.ts hash/verify ด้วย bcryptjs
permission.ts loadUserPermissions(), requirePermission(), getAuthUser()
snowflake.ts Time-sortable bigint ID generator (replaces the original ID in the dump)
api/
auth/login.post.ts
auth/refresh.post.ts
auth/logout.post.ts
auth/me.get.ts
users/index.get.ts Examples of routes that require permission."app_user_list"
users/index.post.ts Examples of routes that require permission."app_user_add"
permissions/index.get.ts
drizzle/ The generated SQL migration (0000_*.sql, 22 tables)
docker-compose.yml Postgres 18
- Access Token = JWT (stateless, short-lived (15 minutes)).
permissions[]androles[]are stored in the payload to avoid querying the database for every request. — Not saved to the database. - Refresh Token = Random string (opaque, not a JWT). Long-lived (default 7 days).
Save to a table. 1.
access_token(columntoken) to allow revoke/rotate. — Matches the sample data in the dump where thetokencolumn is the UUID. - Both tokens are returned as HTTP-Only Cookie (
access_token,refresh_token) — there's no way for browser-side JavaScript to read the values. - When the Access Token expires → API responds with
401→ the client side (app/composables/useApi.ts) will silently call/api/auth/refreshand retry the original request once (Silent Refresh). - RBAC:
server/middleware/00.auth.tschecks the JWT and pastesevent.context.user = { permissions: [...] }, then each API route callsrequirePermission(event, 'app_user_add'). To check permissions by name:"<table name>_<action>"(list/view/add/edit/delete) — If there are no permissions, respond with403.
docker compose -f docker-compose-postgres.yml up-dWait 5-10 seconds for Postgres to prepare (the healthcheck is already included in compose).
pnpm installcp .env.example .envChange NUXT_JWT_ACCESS_SECRET and NUXT_JWT_REFRESH_SECRET to true random values (do not use the default values in production):
openssl rand -hex 64ค่า NUXT_DATABASE_URL ใน .env.example ตรงกับ docker-compose-postgres.yml อยู่แล้ว
(app_user / app_password / db ชื่อ nuxt4_rbac) ไม่ต้องแก้ถ้าใช้ docker-compose ได้เลย
The SQL migration has already been generated in drizzle/0000_*.sql (complete with 22 tables + FK + Index + Check Constraints according to starter_postgres.sql). You can apply it directly:
pnpm db:migrateOr, if you've modified schema.ts and need to generate a new migration:
pnpm db:generate #Create a new SQL file from schema.ts.
pnpm db:migrate # This command is responsible for executing the SQL file generated from the `generate` function and inserting it into the database.
pnpm db:push #This command is a speed-oriented shortcut; its function is to immediately push the schema.ts file into the actual database without creating an SQL file.pnpm db:seedYou will receive the Admin role (all privileges) and Viewer (list/view only) with the following user:
Email: admin@example.com
Username: admin
Password: Admin@12345
pnpm devOpen http://localhost:3000 → Click "Login" → Log in with the admin account above.
→ You will be taken to the /admin page which displays all roles/permissions for the current user.
# Log in and save cookies.
curl -c cookies.txt -X POST http://localhost:3000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"emailOrUsername":"admin@example.com","password":"Admin@12345"}'
# Calling a route that requires app_user_list permission.
curl -b cookies.txt http://localhost:3000/api/users
# try refresh token
curl -b cookies.txt -c cookies.txt -X POST http://localhost:3000/api/auth/refresh
# logout
curl -b cookies.txt -X POST http://localhost:3000/api/auth/logoutpnpm db:studio # Open Drizzle Studio and view the database information via the web.- Add a row to the
permissiontable (or modifyRESOURCES/ACTIONSinserver/database/seed.tsand run the seed again) following the convention"<table_name>_<action>", e.g.,files_directory_add. - In the new API route, call
requirePermission(event, 'files_directory_add')at the beginning of the function (see example inserver/api/users/index.post.ts). - On the UI side, use
v-can="'files_directory_add'"with the button/element you want to hide. (This hiding is just UX – the actual security lies in therequirePermissionon the server side.)
- All 22 tables from
starter_postgres.sqlhave been converted to a complete Drizzle schema (server/database/schema.ts), including Foreign Keys, Check Constraints (e.g.,service >= 0 AND service <= 1), and Indexes (e.g., index ontoken,revoked,lastest_activeof theaccess_tokentable). - **The
performance_dashboardview and the functioncreate_monthly_partitionshave not been converted because they are Postgres-specific objects that Drizzle ORM does not yet support direct generation — if you want to use them, add them as raw SQL migration separately (drizzle-kitsupports custom SQL migration vianpx drizzle-kit generate --custom). - The Primary key is a
bigintin Snowflake-style (created on the app side via Useserver/utils/snowflake.tsto match the original id style in the dump (e.g., 350885844724224000) instead of just usingbigserial`. - Do not import sample data (rows) from the original dump, such as audit_log,
file_manager which includes the actual data — intentionally omitted to prevent password/IP/
original files from being transferred to the new project. Use
pnpm db:seedto create a clean initial dataset instead.
- หน้า UI สำหรับจัดการ Role / Permission mapping (ตอนนี้มีแค่ API + seed)
- Rate limiting ที่
/api/auth/loginกัน brute-force - Redis cache สำหรับ permission ถ้าระบบใหญ่ขึ้นจนฝัง JWT payload ใหญ่เกินไป
- อัปโหลดไฟล์ (ตาราง
file_manager,files_directoryมี schema พร้อมแล้ว แต่ยังไม่มี API)