Compare commits

...

10 Commits

Author SHA1 Message Date
2418553172 superAdmin and admin implemented 2026-08-01 23:42:42 +02:00
7046c3375f fix: add credentials: include to admin fetch calls for cookie consistency 2026-07-29 20:06:28 +02:00
faa8216716 fix: set cookie path to / so admin API routes receive the session
Cookie path was /admin, but admin API routes live under /api/admin/.
Browser only sends cookies to paths matching the cookie's path, so
all API calls were unauthenticated. Changed path to / for both
cookie creation (login) and deletion (logout).
2026-07-29 19:56:16 +02:00
117f9fadfc fix: exempt /api/admin/login from admin session check in middleware
The login API was blocked by the admin session middleware, preventing
the creation of the session cookie. Now the API endpoint is exempted
alongside the login page.
2026-07-29 19:46:08 +02:00
09acf20b9b fix(admin): restructure routes into groups to prevent redirect loop
- Move login page into (auth) route group — no layout wrapper
- Move dashboard/users/codes + layout into (panel) route group —
  session check and sidebar only apply to these
- URL paths remain unchanged (/admin/login, /admin/dashboard, etc.)
2026-07-29 19:28:44 +02:00
eafefcb1da docs: add implementation plan and gitignore for old build artifacts 2026-07-29 19:08:14 +02:00
0d00b1ec9c fix(build): use Web Crypto API for Edge Runtime compatibility + final build verification
- Rewrite admin-session.ts to use Web Crypto API (crypto.subtle) instead
  of Node.js crypto module, ensuring compatibility with Edge Runtime
  in middleware
- Add ADMIN_SESSION_SECRET to .env.example
- Build passes with zero warnings
2026-07-29 19:07:43 +02:00
94ef7901e2 feat(code-gating): add code validation and enforce code usage for memorial creation
- Add /api/validate-code endpoint: validates code, marks it as used by
  the current Clerk user, prevents reuse
- Add 'Код' step to onboarding wizard (step 0): user must enter and
  validate a code before proceeding to fill memorial details
- Protect /api/publish: reject with 403 if user has not consumed a valid
  code
- Code input auto-capitalizes on the onboarding page
2026-07-29 18:58:18 +02:00
9ca66fc753 feat(admin): add admin panel with dashboard, users, and codes management
- Add admin layout with sidebar navigation and session guard
- Create AdminSidebar client component with role-based nav links
- Add dashboard page showing stats (admin count, code counts)
- Add users management page (SuperAdmin only): list, create, delete,
  and reset passwords for admin users
- Add codes management page: list all codes, generate new codes,
  delete unused codes
- Add API routes for admin user CRUD (GET, POST, DELETE, PUT)
- Add API routes for code management (GET, POST, DELETE)
- All UI in Macedonian
2026-07-29 18:56:49 +02:00
14d0b533af feat(auth): add admin authentication with HMAC session cookies
- Create admin-session lib with sign/verify helpers using HMAC-SHA256
- Add admin login API that checks hardcoded super/admin credentials
  and DB-stored admin users with bcrypt password comparison
- Add admin logout API to clear session cookie
- Add change-password API for admin self-service password changes
- Create admin login page with Macedonian UI
- Update middleware to protect /admin/* and /api/admin/* routes
  with admin session check, bypassing Clerk auth
2026-07-29 18:55:46 +02:00
53 changed files with 1476 additions and 13 deletions

View File

@ -19,4 +19,7 @@ S3_BUCKET_NAME=monuments-images
# App
NEXT_PUBLIC_APP_URL=https://testbed.mk
NEXT_PUBLIC_APP_DOMAIN=testbed.mk
NEXT_PUBLIC_APP_DOMAIN=testbed.mk
# Admin
ADMIN_SESSION_SECRET=your-random-64-char-secret-here-change-it-in-production

2
.gitignore vendored
View File

@ -36,4 +36,4 @@ yarn-error.log*
next-env.d.ts
# docker
certbot/
certbot/.next_old/

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1 @@
{"c":["middleware","edge-runtime-webpack"],"r":[],"m":[]}

View File

@ -0,0 +1 @@
{"c":["middleware","edge-runtime-webpack"],"r":[],"m":["crypto"]}

View File

@ -0,0 +1,18 @@
"use strict";
/*
* ATTENTION: An "eval-source-map" devtool has been used.
* This devtool is neither made for production nor for readable output files.
* It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
* or disable the default devtool with "devtool: false".
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
*/
self["webpackHotUpdate_N_E"]("edge-runtime-webpack",{},
/******/ function(__webpack_require__) { // webpackRuntimeModules
/******/ /* webpack/runtime/getFullHash */
/******/ (() => {
/******/ __webpack_require__.h = () => ("4f7aad26b0af8eaa")
/******/ })();
/******/
/******/ }
);

View File

@ -0,0 +1,18 @@
"use strict";
/*
* ATTENTION: An "eval-source-map" devtool has been used.
* This devtool is neither made for production nor for readable output files.
* It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
* or disable the default devtool with "devtool: false".
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
*/
self["webpackHotUpdate_N_E"]("edge-runtime-webpack",{},
/******/ function(__webpack_require__) { // webpackRuntimeModules
/******/ /* webpack/runtime/getFullHash */
/******/ (() => {
/******/ __webpack_require__.h = () => ("57c1a1c3af9932d9")
/******/ })();
/******/
/******/ }
);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
self.__BUILD_MANIFEST = (function(a){return {__rewrites:{afterFiles:[],beforeFiles:[],fallback:[]},__routerFilterStatic:a,__routerFilterDynamic:a,sortedPages:["\u002F_app"]}}(void 0));self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()

View File

@ -0,0 +1 @@
self.__SSG_MANIFEST=new Set;self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()

View File

@ -0,0 +1 @@
{"c":["app/layout","webpack"],"r":[],"m":[]}

View File

@ -0,0 +1,22 @@
"use strict";
/*
* ATTENTION: An "eval-source-map" devtool has been used.
* This devtool is neither made for production nor for readable output files.
* It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
* or disable the default devtool with "devtool: false".
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
*/
self["webpackHotUpdate_N_E"]("app/layout",{
/***/ "(app-pages-browser)/./src/app/globals.css":
/*!*****************************!*\
!*** ./src/app/globals.css ***!
\*****************************/
/***/ ((module, __webpack_exports__, __webpack_require__) => {
eval(__webpack_require__.ts("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => (__WEBPACK_DEFAULT_EXPORT__)\n/* harmony export */ });\n/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (\"b5a80c100df0\");\nif (true) { module.hot.accept() }\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiKGFwcC1wYWdlcy1icm93c2VyKS8uL3NyYy9hcHAvZ2xvYmFscy5jc3MiLCJtYXBwaW5ncyI6Ijs7OztBQUFBLGlFQUFlLGNBQWM7QUFDN0IsSUFBSSxJQUFVLElBQUksaUJBQWlCIiwic291cmNlcyI6WyIvYXBwL3NyYy9hcHAvZ2xvYmFscy5jc3MiXSwic291cmNlc0NvbnRlbnQiOlsiZXhwb3J0IGRlZmF1bHQgXCJiNWE4MGMxMDBkZjBcIlxuaWYgKG1vZHVsZS5ob3QpIHsgbW9kdWxlLmhvdC5hY2NlcHQoKSB9XG4iXSwibmFtZXMiOltdLCJpZ25vcmVMaXN0IjpbXSwic291cmNlUm9vdCI6IiJ9\n//# sourceURL=webpack-internal:///(app-pages-browser)/./src/app/globals.css\n"));
/***/ })
});

View File

@ -0,0 +1,22 @@
"use strict";
/*
* ATTENTION: An "eval-source-map" devtool has been used.
* This devtool is neither made for production nor for readable output files.
* It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
* or disable the default devtool with "devtool: false".
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
*/
self["webpackHotUpdate_N_E"]("app/layout",{
/***/ "(app-pages-browser)/./src/app/globals.css":
/*!*****************************!*\
!*** ./src/app/globals.css ***!
\*****************************/
/***/ ((module, __webpack_exports__, __webpack_require__) => {
eval(__webpack_require__.ts("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => (__WEBPACK_DEFAULT_EXPORT__)\n/* harmony export */ });\n/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (\"da0b7f42d544\");\nif (true) { module.hot.accept() }\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiKGFwcC1wYWdlcy1icm93c2VyKS8uL3NyYy9hcHAvZ2xvYmFscy5jc3MiLCJtYXBwaW5ncyI6Ijs7OztBQUFBLGlFQUFlLGNBQWM7QUFDN0IsSUFBSSxJQUFVLElBQUksaUJBQWlCIiwic291cmNlcyI6WyIvYXBwL3NyYy9hcHAvZ2xvYmFscy5jc3MiXSwic291cmNlc0NvbnRlbnQiOlsiZXhwb3J0IGRlZmF1bHQgXCJkYTBiN2Y0MmQ1NDRcIlxuaWYgKG1vZHVsZS5ob3QpIHsgbW9kdWxlLmhvdC5hY2NlcHQoKSB9XG4iXSwibmFtZXMiOltdLCJpZ25vcmVMaXN0IjpbXSwic291cmNlUm9vdCI6IiJ9\n//# sourceURL=webpack-internal:///(app-pages-browser)/./src/app/globals.css\n"));
/***/ })
});

View File

@ -0,0 +1,22 @@
"use strict";
/*
* ATTENTION: An "eval-source-map" devtool has been used.
* This devtool is neither made for production nor for readable output files.
* It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
* or disable the default devtool with "devtool: false".
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
*/
self["webpackHotUpdate_N_E"]("app/layout",{
/***/ "(app-pages-browser)/./src/app/globals.css":
/*!*****************************!*\
!*** ./src/app/globals.css ***!
\*****************************/
/***/ ((module, __webpack_exports__, __webpack_require__) => {
eval(__webpack_require__.ts("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => (__WEBPACK_DEFAULT_EXPORT__)\n/* harmony export */ });\n/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (\"cc1073572c60\");\nif (true) { module.hot.accept() }\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiKGFwcC1wYWdlcy1icm93c2VyKS8uL3NyYy9hcHAvZ2xvYmFscy5jc3MiLCJtYXBwaW5ncyI6Ijs7OztBQUFBLGlFQUFlLGNBQWM7QUFDN0IsSUFBSSxJQUFVLElBQUksaUJBQWlCIiwic291cmNlcyI6WyIvYXBwL3NyYy9hcHAvZ2xvYmFscy5jc3MiXSwic291cmNlc0NvbnRlbnQiOlsiZXhwb3J0IGRlZmF1bHQgXCJjYzEwNzM1NzJjNjBcIlxuaWYgKG1vZHVsZS5ob3QpIHsgbW9kdWxlLmhvdC5hY2NlcHQoKSB9XG4iXSwibmFtZXMiOltdLCJpZ25vcmVMaXN0IjpbXSwic291cmNlUm9vdCI6IiJ9\n//# sourceURL=webpack-internal:///(app-pages-browser)/./src/app/globals.css\n"));
/***/ })
});

View File

@ -0,0 +1 @@
{"c":["app/layout","webpack"],"r":[],"m":[]}

View File

@ -0,0 +1 @@
{"c":["app/layout","webpack"],"r":[],"m":[]}

View File

@ -0,0 +1,12 @@
"use strict";
self["webpackHotUpdate_N_E"]("webpack",{},
/******/ function(__webpack_require__) { // webpackRuntimeModules
/******/ /* webpack/runtime/getFullHash */
/******/ (() => {
/******/ __webpack_require__.h = () => ("132a22e239466e3b")
/******/ })();
/******/
/******/ }
)
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJpZ25vcmVMaXN0IjpbMF0sIm1hcHBpbmdzIjoiQUFBQSIsInNvdXJjZXMiOlsid2VicGFjay1pbnRlcm5hbDovL25leHRqcy93ZWJwYWNrLmpzIl0sInNvdXJjZXNDb250ZW50IjpbIi8vIFRoaXMgc291cmNlIHdhcyBnZW5lcmF0ZWQgYnkgTmV4dC5qcyBiYXNlZCBvZmYgb2YgdGhlIGdlbmVyYXRlZCBXZWJwYWNrIHJ1bnRpbWUuXG4vLyBUaGUgbWFwcGluZ3MgYXJlIGluY29ycmVjdC5cbi8vIFRvIGdldCB0aGUgY29ycmVjdCBsaW5lL2NvbHVtbiBtYXBwaW5ncywgdHVybiBvZmYgc291cmNlbWFwcyBpbiB5b3VyIGRlYnVnZ2VyLlxuXG5zZWxmW1wid2VicGFja0hvdFVwZGF0ZV9OX0VcIl0oXCJ3ZWJwYWNrXCIse30sXG4vKioqKioqLyBmdW5jdGlvbihfX3dlYnBhY2tfcmVxdWlyZV9fKSB7IC8vIHdlYnBhY2tSdW50aW1lTW9kdWxlc1xuLyoqKioqKi8gLyogd2VicGFjay9ydW50aW1lL2dldEZ1bGxIYXNoICovXG4vKioqKioqLyAoKCkgPT4ge1xuLyoqKioqKi8gXHRfX3dlYnBhY2tfcmVxdWlyZV9fLmggPSAoKSA9PiAoXCIxMzJhMjJlMjM5NDY2ZTNiXCIpXG4vKioqKioqLyB9KSgpO1xuLyoqKioqKi8gXG4vKioqKioqLyB9XG4pIl19
;

View File

@ -0,0 +1,12 @@
"use strict";
self["webpackHotUpdate_N_E"]("webpack",{},
/******/ function(__webpack_require__) { // webpackRuntimeModules
/******/ /* webpack/runtime/getFullHash */
/******/ (() => {
/******/ __webpack_require__.h = () => ("7595051f8d05b6c5")
/******/ })();
/******/
/******/ }
)
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJpZ25vcmVMaXN0IjpbMF0sIm1hcHBpbmdzIjoiQUFBQSIsInNvdXJjZXMiOlsid2VicGFjay1pbnRlcm5hbDovL25leHRqcy93ZWJwYWNrLmpzIl0sInNvdXJjZXNDb250ZW50IjpbIi8vIFRoaXMgc291cmNlIHdhcyBnZW5lcmF0ZWQgYnkgTmV4dC5qcyBiYXNlZCBvZmYgb2YgdGhlIGdlbmVyYXRlZCBXZWJwYWNrIHJ1bnRpbWUuXG4vLyBUaGUgbWFwcGluZ3MgYXJlIGluY29ycmVjdC5cbi8vIFRvIGdldCB0aGUgY29ycmVjdCBsaW5lL2NvbHVtbiBtYXBwaW5ncywgdHVybiBvZmYgc291cmNlbWFwcyBpbiB5b3VyIGRlYnVnZ2VyLlxuXG5zZWxmW1wid2VicGFja0hvdFVwZGF0ZV9OX0VcIl0oXCJ3ZWJwYWNrXCIse30sXG4vKioqKioqLyBmdW5jdGlvbihfX3dlYnBhY2tfcmVxdWlyZV9fKSB7IC8vIHdlYnBhY2tSdW50aW1lTW9kdWxlc1xuLyoqKioqKi8gLyogd2VicGFjay9ydW50aW1lL2dldEZ1bGxIYXNoICovXG4vKioqKioqLyAoKCkgPT4ge1xuLyoqKioqKi8gXHRfX3dlYnBhY2tfcmVxdWlyZV9fLmggPSAoKSA9PiAoXCI3NTk1MDUxZjhkMDViNmM1XCIpXG4vKioqKioqLyB9KSgpO1xuLyoqKioqKi8gXG4vKioqKioqLyB9XG4pIl19
;

View File

@ -0,0 +1,12 @@
"use strict";
self["webpackHotUpdate_N_E"]("webpack",{},
/******/ function(__webpack_require__) { // webpackRuntimeModules
/******/ /* webpack/runtime/getFullHash */
/******/ (() => {
/******/ __webpack_require__.h = () => ("b78eb0568bf98f77")
/******/ })();
/******/
/******/ }
)
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJpZ25vcmVMaXN0IjpbMF0sIm1hcHBpbmdzIjoiQUFBQSIsInNvdXJjZXMiOlsid2VicGFjay1pbnRlcm5hbDovL25leHRqcy93ZWJwYWNrLmpzIl0sInNvdXJjZXNDb250ZW50IjpbIi8vIFRoaXMgc291cmNlIHdhcyBnZW5lcmF0ZWQgYnkgTmV4dC5qcyBiYXNlZCBvZmYgb2YgdGhlIGdlbmVyYXRlZCBXZWJwYWNrIHJ1bnRpbWUuXG4vLyBUaGUgbWFwcGluZ3MgYXJlIGluY29ycmVjdC5cbi8vIFRvIGdldCB0aGUgY29ycmVjdCBsaW5lL2NvbHVtbiBtYXBwaW5ncywgdHVybiBvZmYgc291cmNlbWFwcyBpbiB5b3VyIGRlYnVnZ2VyLlxuXG5zZWxmW1wid2VicGFja0hvdFVwZGF0ZV9OX0VcIl0oXCJ3ZWJwYWNrXCIse30sXG4vKioqKioqLyBmdW5jdGlvbihfX3dlYnBhY2tfcmVxdWlyZV9fKSB7IC8vIHdlYnBhY2tSdW50aW1lTW9kdWxlc1xuLyoqKioqKi8gLyogd2VicGFjay9ydW50aW1lL2dldEZ1bGxIYXNoICovXG4vKioqKioqLyAoKCkgPT4ge1xuLyoqKioqKi8gXHRfX3dlYnBhY2tfcmVxdWlyZV9fLmggPSAoKSA9PiAoXCJiNzhlYjA1NjhiZjk4Zjc3XCIpXG4vKioqKioqLyB9KSgpO1xuLyoqKioqKi8gXG4vKioqKioqLyB9XG4pIl19
;

6
docs/admin.md Normal file
View File

@ -0,0 +1,6 @@
## SuperAdmin and admin flow
we should have SuperAdmin acc. SuperAdmin can create admins, admins generate code which user use
after first login so he can create memories, without code user cant create memories.
SuperAdmin will log in with hardcoded username: super and password:admin

259
docs/adminImplem.md Normal file
View File

@ -0,0 +1,259 @@
# SuperAdmin & Admin + Code Access System — Implementation Plan
## Overview
Implement SuperAdmin/admin role management and code-gated memorial creation as outlined in `admin.md`.
- SuperAdmin: hardcoded username `super`, password `admin`
- SuperAdmin creates admin accounts
- Admins generate access codes
- Users must enter a valid code during onboarding to create memories
---
## Phase 1 — Database & Dependencies
### New Prisma Models (`prisma/schema.prisma`)
```prisma
enum Role {
SUPER_ADMIN
ADMIN
}
model AdminUser {
id String @id @default(cuid())
username String @unique
passwordHash String
role Role @default(ADMIN)
createdAt DateTime @default(now())
createdCodes Code[]
}
model Code {
id String @id @default(cuid())
code String @unique
createdById String
createdBy AdminUser @relation(fields: [createdById], references: [id])
usedByUserId String? // User.clerkId
usedAt DateTime?
createdAt DateTime @default(now())
expiresAt DateTime?
@@index([code])
@@index([usedByUserId])
}
```
### New Dependencies
- `bcryptjs` + `@types/bcryptjs` — hash admin passwords
### Seed SuperAdmin
Seed script or migration that creates the SuperAdmin `AdminUser` record with hashed password. However, the login itself checks hardcoded `super`/`admin` first (and also queries DB by role for token auth), so the seed is optional — used mainly for listing in the admin panel.
### Migration
`npx prisma migrate dev --name add_admin_and_code`
---
## Phase 2 — Admin Auth
Admin auth is separate from Clerk. Uses a signed HMAC cookie.
### `src/lib/admin-session.ts` (new)
Helpers:
- `createAdminSession(username: string, role: Role): string` — sign a cookie value with HMAC-SHA256 using `ADMIN_SESSION_SECRET` env var
- `verifyAdminSession(token: string): { username: string; role: Role } | null` — verify and decode
- `getAdminSession(): { username: string; role: Role } | null` — read from `request.cookies` or `cookies()`
- Cookie name: `admin_session`
Payload: `{ username, role, iat }` serialized + HMAC signature.
### `src/app/api/admin/login/route.ts` (new)
- POST: accept `{ username, password }`
- If `username === "super"` and `password === "admin"` → set session with role `SUPER_ADMIN`
- Else query `AdminUser` where `username === username`, compare with `bcrypt.compare`
- Return `{ success: true }` and set `admin_session` cookie (httpOnly, secure, sameSite=lax, path=/admin)
- On failure: `401`
### `src/app/api/admin/logout/route.ts` (new)
- POST: clear `admin_session` cookie
### `src/app/api/admin/change-password/route.ts` (new)
- POST: accept `{ currentPassword, newPassword }`
- Verify admin session, then verify current password against DB
- Hash new password, update `AdminUser` record
### `src/app/admin/login/page.tsx` (new)
- Macedonian UI: username/password form
- On submit → `POST /api/admin/login`
- On success → redirect to `/admin/dashboard`
- Show error on failure
### `src/middleware.ts` (modify)
- Add `/admin(.*)` and `/api/admin(.*)` to the Clerk exclude list
- Before Clerk middleware runs: if path starts with `/admin`, check admin session cookie
- No/invalid cookie → redirect to `/admin/login`
- Valid → allow
- If path starts with `/api/admin`, check admin session cookie
- No/invalid cookie → return `401`
---
## Phase 3 — Admin Panel
All UI in Macedonian. Layout with sidebar navigation.
### `src/app/admin/layout.tsx` (new)
- Checks admin session (server component)
- Redirects to `/admin/login` if not authenticated
- Provides sidebar with links: Dashboard, Users (SuperAdmin only), Codes
- Logout button
### `src/app/admin/dashboard/page.tsx` (new)
- Stats cards:
- Total AdminUsers count
- Total Codes generated
- Codes used vs unused
- Simple overview
### `src/app/admin/users/page.tsx` (new)
- Accessible only to `SUPER_ADMIN`
- Table of admin users (username, role, created at)
- Button to create new admin (modal/page with username + password fields)
- Button to delete admin (with confirmation)
- Inline password reset option
### `src/app/admin/users/create/page.tsx` or modal (new)
- Form: username, password (with confirmation)
- POST to `/api/admin/users/`
### `src/app/admin/codes/page.tsx` (new)
- "Generate Code" button → POST `/api/admin/codes` → returns new code string, displays it
- Table of all codes: code value, who created it, status (used/unused), used by, used at
- Admin can see their own codes. SuperAdmin sees all.
- Delete code button
### `src/app/api/admin/users/route.ts` (new)
- GET: list all `AdminUser` (SuperAdmin only)
- POST: create `AdminUser` with hashed password (SuperAdmin only)
### `src/app/api/admin/users/[id]/route.ts` (new)
- DELETE: remove `AdminUser` (SuperAdmin only)
- PUT: reset password (SuperAdmin only)
### `src/app/api/admin/codes/route.ts` (new)
- GET: list codes (Admins see their own, SuperAdmin sees all)
- POST: generate a new code (`crypto.randomBytes(6).toString('hex').toUpperCase()` → 12 chars), store with `createdById` from session
### `src/app/api/admin/codes/[id]/route.ts` (new)
- DELETE: remove unused code
---
## Phase 4 — Code Gating
### `src/app/api/validate-code/route.ts` (new)
- Requires Clerk auth (`auth()` from `@clerk/nextjs/server`)
- POST: `{ code: string }`
- Look up `Code` where `code === code` and `usedByUserId === null`
- If not found → `{ valid: false, error: "Невалиден или веќе искористен код" }`
- If found → update `usedByUserId = userId`, `usedAt = now()`
- Return `{ valid: true }`
### `src/app/onboarding/page.tsx` (modify)
- Add `"Код"` as step 0 (before "Податоци")
- STEPS becomes: `["Код", "Податоци", "Датуми", "Фотографии", "Поддомен", "Шаблон"]`
- Step 0: single input for code + "Потврди" button
- Calls `POST /api/validate-code`
- On success → shows green checkmark, enables "Продолжи"
- On failure → shows error, blocks progression
- Once validated, user can proceed to step 1
- `canProceed()` for step 0 returns true only if code validated successfully
### `src/app/api/publish/route.ts` (modify)
- After Clerk auth check, query: `prisma.code.findFirst({ where: { usedByUserId: userId } })`
- If no code found → return `403` with `"Потребен е валиден код за креирање спомен страница"`
---
## Phase 5 — Environment & Build
### New Env Variable
```
ADMIN_SESSION_SECRET=<random-64-char-string>
```
Add to `.env.example` and `docker-compose` files.
### Build & Verify
```bash
npm install bcryptjs
npm install -D @types/bcryptjs
npx prisma migrate dev --name add_admin_and_code
npm run build
```
Verify:
- [ ] Admin login at `/admin/login` with `super`/`admin`
- [ ] Create admin user from admin panel
- [ ] Login as created admin
- [ ] Generate code from admin panel
- [ ] Sign up as new user via Clerk
- [ ] Enter code in onboarding step 0
- [ ] Proceed through onboarding and publish
- [ ] Verify publish fails without valid code
---
## File Change Summary
### New Files
| Path | Purpose |
|------|---------|
| `src/lib/admin-session.ts` | HMAC cookie helpers |
| `src/app/admin/login/page.tsx` | Admin login form |
| `src/app/admin/layout.tsx` | Admin layout with sidebar |
| `src/app/admin/dashboard/page.tsx` | Dashboard stats |
| `src/app/admin/users/page.tsx` | Admin user management |
| `src/app/admin/codes/page.tsx` | Code generation & listing |
| `src/app/api/admin/login/route.ts` | Admin login API |
| `src/app/api/admin/logout/route.ts` | Admin logout API |
| `src/app/api/admin/change-password/route.ts` | Self-service password change |
| `src/app/api/admin/users/route.ts` | List/create admin users |
| `src/app/api/admin/users/[id]/route.ts` | Delete/reset-password admin user |
| `src/app/api/admin/codes/route.ts` | List/create codes |
| `src/app/api/admin/codes/[id]/route.ts` | Delete code |
| `src/app/api/validate-code/route.ts` | Public code validation |
### Modified Files
| Path | Change |
|------|--------|
| `prisma/schema.prisma` | Add `AdminUser` and `Code` models |
| `src/middleware.ts` | Exclude `/admin/*` from Clerk, add admin session check |
| `src/app/onboarding/page.tsx` | Add code step (step 0) |
| `src/app/api/publish/route.ts` | Add code usage check before publish |

View File

View File

@ -0,0 +1,78 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function AdminLoginPage() {
const router = useRouter();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError("");
try {
const res = await fetch("/api/admin/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Најавата не успеа");
}
router.push("/admin/dashboard");
} catch (err) {
setError(err instanceof Error ? err.message : "Најавата не успеа");
} finally {
setLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-stone-100">
<div className="w-full max-w-sm rounded-lg bg-white p-8 shadow-sm">
<h1 className="mb-6 text-center text-xl font-semibold text-stone-900">Администрација</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-stone-700">
Корисничко име
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="mt-1 block w-full rounded-lg border border-stone-200 px-3 py-2.5 text-stone-900 placeholder:text-stone-400 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
autoComplete="username"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-stone-700">
Лозинка
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded-lg border border-stone-200 px-3 py-2.5 text-stone-900 placeholder:text-stone-400 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
autoComplete="current-password"
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={loading || !username || !password}
className="w-full rounded-lg bg-primary px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-primary-light disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? "Најавување..." : "Најави се"}
</button>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,57 @@
"use client";
import Link from "next/link";
import { useRouter, usePathname } from "next/navigation";
interface Props {
username: string;
role: "SUPER_ADMIN" | "ADMIN";
}
export default function AdminSidebar({ username, role }: Props) {
const router = useRouter();
const pathname = usePathname();
const handleLogout = async () => {
await fetch("/api/admin/logout", { method: "POST", credentials: "include" });
router.push("/admin/login");
};
const links = [
{ href: "/admin/dashboard", label: "Контролна табла" },
...(role === "SUPER_ADMIN" ? [{ href: "/admin/users", label: "Администратори" }] : []),
{ href: "/admin/codes", label: "Кодови" },
];
return (
<aside className="flex w-64 flex-col bg-primary text-white">
<div className="border-b border-white/10 px-6 py-5">
<h2 className="text-lg font-semibold">СпоменQR</h2>
<p className="mt-1 text-xs text-white/60">
{username} {role === "SUPER_ADMIN" ? "(SuperAdmin)" : "(Admin)"}
</p>
</div>
<nav className="flex-1 space-y-1 px-3 py-4">
{links.map((link) => (
<Link
key={link.href}
href={link.href}
className={`block rounded-md px-3 py-2 text-sm transition-colors ${
pathname === link.href ? "bg-white/15 text-white" : "text-white/70 hover:bg-white/10 hover:text-white"
}`}
>
{link.label}
</Link>
))}
</nav>
<div className="border-t border-white/10 px-3 py-4">
<button
onClick={handleLogout}
className="block w-full rounded-md px-3 py-2 text-left text-sm text-white/70 transition-colors hover:bg-white/10 hover:text-white"
>
Одјави се
</button>
</div>
</aside>
);
}

View File

@ -0,0 +1,144 @@
"use client";
import { useEffect, useState } from "react";
interface Code {
id: string;
code: string;
usedByUserId: string | null;
usedAt: string | null;
createdAt: string;
createdBy: { username: string };
}
export default function AdminCodesPage() {
const [codes, setCodes] = useState<Code[]>([]);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
const [newCode, setNewCode] = useState("");
const [error, setError] = useState("");
const fetchCodes = async () => {
setLoading(true);
try {
const res = await fetch("/api/admin/codes", { credentials: "include" });
if (res.ok) {
setCodes(await res.json());
}
} catch {
// ignore
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchCodes();
}, []);
const handleGenerate = async () => {
setGenerating(true);
setError("");
setNewCode("");
try {
const res = await fetch("/api/admin/codes", { method: "POST", credentials: "include" });
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Грешка при генерирање");
}
setNewCode(data.code);
fetchCodes();
} catch (err) {
setError(err instanceof Error ? err.message : "Грешка при генерирање");
} finally {
setGenerating(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm("Дали сте сигурни?")) return;
try {
await fetch(`/api/admin/codes/${id}`, { method: "DELETE", credentials: "include" });
fetchCodes();
} catch {
// ignore
}
};
return (
<div>
<div className="mb-8 flex items-center justify-between">
<h1 className="text-2xl font-semibold text-stone-900">Кодови</h1>
<button
onClick={handleGenerate}
disabled={generating}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-light disabled:cursor-not-allowed disabled:opacity-50"
>
{generating ? "Генерирање..." : "Генерирај код"}
</button>
</div>
{newCode && (
<div className="mb-8 rounded-lg border border-green-200 bg-green-50 p-4">
<p className="text-sm font-medium text-green-800">Нов код:</p>
<p className="mt-1 text-2xl font-bold tracking-widest text-green-900">{newCode}</p>
<p className="mt-1 text-xs text-green-600">Копирајте го кодот. Ќе биде прикажан само еднаш.</p>
</div>
)}
{error && (
<div className="mb-8 rounded-lg bg-red-50 p-4 text-sm text-red-700">{error}</div>
)}
<div className="rounded-lg bg-white shadow-sm">
{loading ? (
<p className="p-6 text-sm text-stone-500">Вчитување...</p>
) : codes.length === 0 ? (
<p className="p-6 text-sm text-stone-500">Нема генерирани кодови</p>
) : (
<table className="w-full text-left text-sm">
<thead className="border-b border-stone-200">
<tr>
<th className="px-6 py-3 font-medium text-stone-500">Код</th>
<th className="px-6 py-3 font-medium text-stone-500">Креиран од</th>
<th className="px-6 py-3 font-medium text-stone-500">Креиран</th>
<th className="px-6 py-3 font-medium text-stone-500">Статус</th>
<th className="px-6 py-3 font-medium text-stone-500">Акции</th>
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
{codes.map((item) => (
<tr key={item.id} className="hover:bg-stone-50">
<td className="px-6 py-4 font-mono text-stone-900">{item.code}</td>
<td className="px-6 py-4 text-stone-600">{item.createdBy.username}</td>
<td className="px-6 py-4 text-stone-600">{new Date(item.createdAt).toLocaleDateString("mk-MK")}</td>
<td className="px-6 py-4">
{item.usedByUserId ? (
<span className="inline-flex rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
Искористен
</span>
) : (
<span className="inline-flex rounded-full bg-stone-100 px-2.5 py-0.5 text-xs font-medium text-stone-600">
Неискористен
</span>
)}
</td>
<td className="px-6 py-4">
{!item.usedByUserId && (
<button
onClick={() => handleDelete(item.id)}
className="text-sm text-red-600 hover:underline"
>
Избриши
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,29 @@
import { prisma } from "@/lib/prisma";
export default async function AdminDashboardPage() {
const [adminCount, codeCount, usedCodeCount] = await Promise.all([
prisma.adminUser.count(),
prisma.code.count(),
prisma.code.count({ where: { usedByUserId: { not: null } } }),
]);
return (
<div>
<h1 className="mb-8 text-2xl font-semibold text-stone-900">Контролна табла</h1>
<div className="grid gap-6 sm:grid-cols-3">
<div className="rounded-lg bg-white p-6 shadow-sm">
<p className="text-sm text-stone-500">Администратори</p>
<p className="mt-1 text-3xl font-semibold text-stone-900">{adminCount}</p>
</div>
<div className="rounded-lg bg-white p-6 shadow-sm">
<p className="text-sm text-stone-500">Генерирани кодови</p>
<p className="mt-1 text-3xl font-semibold text-stone-900">{codeCount}</p>
</div>
<div className="rounded-lg bg-white p-6 shadow-sm">
<p className="text-sm text-stone-500">Искористени кодови</p>
<p className="mt-1 text-3xl font-semibold text-stone-900">{usedCodeCount}</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,17 @@
import { getAdminSession } from "@/lib/admin-session";
import { redirect } from "next/navigation";
import AdminSidebar from "./AdminSidebar";
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const session = await getAdminSession();
if (!session) {
redirect("/admin/login");
}
return (
<div className="flex min-h-screen bg-stone-100">
<AdminSidebar username={session.username} role={session.role} />
<main className="flex-1 p-8">{children}</main>
</div>
);
}

View File

@ -0,0 +1,189 @@
"use client";
import { useEffect, useState } from "react";
interface AdminUser {
id: string;
username: string;
role: string;
createdAt: string;
}
export default function AdminUsersPage() {
const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [newUsername, setNewUsername] = useState("");
const [newPassword, setNewPassword] = useState("");
const [error, setError] = useState("");
const fetchUsers = async () => {
setLoading(true);
try {
const res = await fetch("/api/admin/users", { credentials: "include" });
if (res.ok) {
setUsers(await res.json());
}
} catch {
// ignore
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchUsers();
}, []);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
try {
const res = await fetch("/api/admin/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ username: newUsername, password: newPassword }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Грешка при креирање");
}
setShowCreate(false);
setNewUsername("");
setNewPassword("");
fetchUsers();
} catch (err) {
setError(err instanceof Error ? err.message : "Грешка при креирање");
}
};
const handleDelete = async (id: string) => {
if (!confirm("Дали сте сигурни дека сакате да го избришете овој администратор?")) return;
try {
const res = await fetch(`/api/admin/users/${id}`, { method: "DELETE", credentials: "include" });
if (res.ok) {
fetchUsers();
}
} catch {
// ignore
}
};
const handleResetPassword = async (id: string) => {
const newPw = prompt("Внесете нова лозинка:");
if (!newPw || newPw.length < 6) {
alert("Лозинката мора да има најмалку 6 карактери");
return;
}
try {
const res = await fetch(`/api/admin/users/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ password: newPw }),
});
if (res.ok) {
alert("Лозинката е променета");
} else {
const data = await res.json();
alert(data.error || "Грешка");
}
} catch {
alert("Грешка");
}
};
return (
<div>
<div className="mb-8 flex items-center justify-between">
<h1 className="text-2xl font-semibold text-stone-900">Администратори</h1>
<button
onClick={() => setShowCreate(!showCreate)}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-light"
>
{showCreate ? "Откажи" : "Креирај администратор"}
</button>
</div>
{showCreate && (
<form onSubmit={handleCreate} className="mb-8 rounded-lg bg-white p-6 shadow-sm">
<div className="mb-4 grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-stone-700">Корисничко име</label>
<input
type="text"
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
className="mt-1 block w-full rounded-lg border border-stone-200 px-3 py-2.5 text-stone-900 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-700">Лозинка</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="mt-1 block w-full rounded-lg border border-stone-200 px-3 py-2.5 text-stone-900 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
required
minLength={6}
/>
</div>
</div>
{error && <p className="mb-4 text-sm text-red-600">{error}</p>}
<button
type="submit"
className="rounded-lg bg-primary px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-light"
>
Креирај
</button>
</form>
)}
<div className="rounded-lg bg-white shadow-sm">
{loading ? (
<p className="p-6 text-sm text-stone-500">Вчитување...</p>
) : users.length === 0 ? (
<p className="p-6 text-sm text-stone-500">Нема администратори</p>
) : (
<table className="w-full text-left text-sm">
<thead className="border-b border-stone-200">
<tr>
<th className="px-6 py-3 font-medium text-stone-500">Корисничко име</th>
<th className="px-6 py-3 font-medium text-stone-500">Улога</th>
<th className="px-6 py-3 font-medium text-stone-500">Креиран</th>
<th className="px-6 py-3 font-medium text-stone-500">Акции</th>
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
{users.map((user) => (
<tr key={user.id} className="hover:bg-stone-50">
<td className="px-6 py-4 text-stone-900">{user.username}</td>
<td className="px-6 py-4 text-stone-600">{user.role === "SUPER_ADMIN" ? "SuperAdmin" : "Admin"}</td>
<td className="px-6 py-4 text-stone-600">{new Date(user.createdAt).toLocaleDateString("mk-MK")}</td>
<td className="space-x-2 px-6 py-4">
<button
onClick={() => handleResetPassword(user.id)}
className="text-sm text-primary hover:underline"
>
Промени лозинка
</button>
{user.role !== "SUPER_ADMIN" && (
<button
onClick={() => handleDelete(user.id)}
className="text-sm text-red-600 hover:underline"
>
Избриши
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import { hash, compare } from "bcryptjs";
import { prisma } from "@/lib/prisma";
import { getAdminSession } from "@/lib/admin-session";
export async function POST(req: NextRequest) {
try {
const session = await getAdminSession();
if (!session) {
return NextResponse.json({ error: "Неавторизирано" }, { status: 401 });
}
if (session.username === "super") {
return NextResponse.json({ error: "SuperAdmin не може да ја промени лозинката преку овој метод" }, { status: 400 });
}
const { currentPassword, newPassword } = await req.json();
if (!currentPassword || !newPassword) {
return NextResponse.json({ error: "Тековната и новата лозинка се задолжителни" }, { status: 400 });
}
if (newPassword.length < 6) {
return NextResponse.json({ error: "Новата лозинка мора да има најмалку 6 карактери" }, { status: 400 });
}
const admin = await prisma.adminUser.findUnique({ where: { username: session.username } });
if (!admin) {
return NextResponse.json({ error: "Администраторот не е пронајден" }, { status: 404 });
}
const valid = await compare(currentPassword, admin.passwordHash);
if (!valid) {
return NextResponse.json({ error: "Тековната лозинка е неточна" }, { status: 401 });
}
const passwordHash = await hash(newPassword, 12);
await prisma.adminUser.update({
where: { username: session.username },
data: { passwordHash },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Change password error:", error);
return NextResponse.json({ error: "Внатрешна грешка на серверот" }, { status: 500 });
}
}

View File

@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { getAdminSession } from "@/lib/admin-session";
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getAdminSession();
if (!session) {
return NextResponse.json({ error: "Неавторизирано" }, { status: 401 });
}
const { id } = await params;
const code = await prisma.code.findUnique({ where: { id } });
if (!code) {
return NextResponse.json({ error: "Кодот не е пронајден" }, { status: 404 });
}
if (code.usedByUserId) {
return NextResponse.json({ error: "Не можете да избришете искористен код" }, { status: 400 });
}
if (session.role !== "SUPER_ADMIN") {
const admin = await prisma.adminUser.findUnique({ where: { username: session.username } });
if (!admin || code.createdById !== admin.id) {
return NextResponse.json({ error: "Немате дозвола" }, { status: 403 });
}
}
await prisma.code.delete({ where: { id } });
return NextResponse.json({ success: true });
}

View File

@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import { randomBytes } from "crypto";
import { prisma } from "@/lib/prisma";
import { getAdminSession } from "@/lib/admin-session";
export async function GET() {
const session = await getAdminSession();
if (!session) {
return NextResponse.json({ error: "Неавторизирано" }, { status: 401 });
}
const where = session.role === "SUPER_ADMIN" ? {} : { createdBy: { username: session.username } };
const codes = await prisma.code.findMany({
where,
orderBy: { createdAt: "desc" },
include: { createdBy: { select: { username: true } } },
});
return NextResponse.json(codes);
}
export async function POST() {
const session = await getAdminSession();
if (!session) {
return NextResponse.json({ error: "Неавторизирано" }, { status: 401 });
}
const admin = await prisma.adminUser.findUnique({ where: { username: session.username } });
if (!admin) {
return NextResponse.json({ error: "Администраторот не е пронајден" }, { status: 404 });
}
const code = randomBytes(6).toString("hex").toUpperCase();
const created = await prisma.code.create({
data: {
code,
createdById: admin.id,
},
});
return NextResponse.json(created, { status: 201 });
}

View File

@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from "next/server";
import { compare } from "bcryptjs";
import { prisma } from "@/lib/prisma";
import { createAdminSession, cookieOptions } from "@/lib/admin-session";
const SUPER_USERNAME = "super";
const SUPER_PASSWORD = "admin";
export async function POST(req: NextRequest) {
try {
const { username, password } = await req.json();
if (!username || !password) {
return NextResponse.json({ error: "Корисничко име и лозинка се задолжителни" }, { status: 400 });
}
if (username === SUPER_USERNAME && password === SUPER_PASSWORD) {
const session = await createAdminSession({ username: SUPER_USERNAME, role: "SUPER_ADMIN" });
const res = NextResponse.json({ success: true });
res.cookies.set(cookieOptions(session));
return res;
}
const admin = await prisma.adminUser.findUnique({ where: { username } });
if (!admin) {
return NextResponse.json({ error: "Невалидно корисничко име или лозинка" }, { status: 401 });
}
const valid = await compare(password, admin.passwordHash);
if (!valid) {
return NextResponse.json({ error: "Невалидно корисничко име или лозинка" }, { status: 401 });
}
const session = await createAdminSession({ username: admin.username, role: admin.role });
const res = NextResponse.json({ success: true });
res.cookies.set(cookieOptions(session));
return res;
} catch (error) {
console.error("Admin login error:", error);
return NextResponse.json({ error: "Внатрешна грешка на серверот" }, { status: 500 });
}
}

View File

@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
export async function POST() {
const res = NextResponse.json({ success: true });
res.cookies.set({
name: "admin_session",
value: "",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 0,
});
return res;
}

View File

@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from "next/server";
import { hash } from "bcryptjs";
import { prisma } from "@/lib/prisma";
import { getAdminSession } from "@/lib/admin-session";
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getAdminSession();
if (!session || session.role !== "SUPER_ADMIN") {
return NextResponse.json({ error: "Немате дозвола" }, { status: 403 });
}
const { id } = await params;
const user = await prisma.adminUser.findUnique({ where: { id } });
if (!user) {
return NextResponse.json({ error: "Администраторот не е пронајден" }, { status: 404 });
}
if (user.role === "SUPER_ADMIN") {
return NextResponse.json({ error: "Не можете да избришете SuperAdmin" }, { status: 400 });
}
await prisma.adminUser.delete({ where: { id } });
return NextResponse.json({ success: true });
}
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await getAdminSession();
if (!session || session.role !== "SUPER_ADMIN") {
return NextResponse.json({ error: "Немате дозвола" }, { status: 403 });
}
try {
const { id } = await params;
const { password } = await req.json();
if (!password || password.length < 6) {
return NextResponse.json({ error: "Лозинката мора да има најмалку 6 карактери" }, { status: 400 });
}
const user = await prisma.adminUser.findUnique({ where: { id } });
if (!user) {
return NextResponse.json({ error: "Администраторот не е пронајден" }, { status: 404 });
}
const passwordHash = await hash(password, 12);
await prisma.adminUser.update({
where: { id },
data: { passwordHash },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Update admin error:", error);
return NextResponse.json({ error: "Внатрешна грешка на серверот" }, { status: 500 });
}
}

View File

@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server";
import { hash } from "bcryptjs";
import { prisma } from "@/lib/prisma";
import { getAdminSession } from "@/lib/admin-session";
export async function GET() {
const session = await getAdminSession();
if (!session || session.role !== "SUPER_ADMIN") {
return NextResponse.json({ error: "Немате дозвола" }, { status: 403 });
}
const users = await prisma.adminUser.findMany({
orderBy: { createdAt: "desc" },
});
return NextResponse.json(users);
}
export async function POST(req: NextRequest) {
const session = await getAdminSession();
if (!session || session.role !== "SUPER_ADMIN") {
return NextResponse.json({ error: "Немате дозвола" }, { status: 403 });
}
try {
const { username, password } = await req.json();
if (!username || !password) {
return NextResponse.json({ error: "Корисничко име и лозинка се задолжителни" }, { status: 400 });
}
if (password.length < 6) {
return NextResponse.json({ error: "Лозинката мора да има најмалку 6 карактери" }, { status: 400 });
}
const existing = await prisma.adminUser.findUnique({ where: { username } });
if (existing) {
return NextResponse.json({ error: "Корисничкото име веќе постои" }, { status: 409 });
}
const passwordHash = await hash(password, 12);
const user = await prisma.adminUser.create({
data: { username, passwordHash, role: "ADMIN" },
});
return NextResponse.json(user, { status: 201 });
} catch (error) {
console.error("Create admin error:", error);
return NextResponse.json({ error: "Внатрешна грешка на серверот" }, { status: 500 });
}
}

View File

@ -11,6 +11,13 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Неавторизирано" }, { status: 401 });
}
const hasCode = await prisma.code.findFirst({
where: { usedByUserId: userId },
});
if (!hasCode) {
return NextResponse.json({ error: "Потребен е валиден код за креирање спомен страница" }, { status: 403 });
}
try {
const body = await req.json();
const { title, description, bornDate, passedDate, subdomain, templateId, images } = body;

View File

@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { prisma } from "@/lib/prisma";
export async function POST(req: NextRequest) {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Неавторизирано" }, { status: 401 });
}
try {
const { code } = await req.json();
if (!code?.trim()) {
return NextResponse.json({ error: "Кодот е задолжителен" }, { status: 400 });
}
const normalizedCode = code.trim().toUpperCase();
const existing = await prisma.code.findUnique({ where: { code: normalizedCode } });
if (!existing) {
return NextResponse.json({ valid: false, error: "Невалиден код" });
}
if (existing.usedByUserId) {
return NextResponse.json({ valid: false, error: "Кодот е веќе искористен" });
}
const alreadyUsed = await prisma.code.findFirst({
where: { usedByUserId: userId },
});
if (alreadyUsed) {
return NextResponse.json({ valid: false, error: "Веќе имате искористено код" });
}
await prisma.code.update({
where: { id: existing.id },
data: { usedByUserId: userId, usedAt: new Date() },
});
return NextResponse.json({ valid: true });
} catch (error) {
console.error("Validate code error:", error);
return NextResponse.json({ error: "Внатрешна грешка на серверот" }, { status: 500 });
}
}

View File

@ -6,7 +6,7 @@ import ImageUploader from "@/components/ImageUploader";
import SubdomainPicker from "@/components/SubdomainPicker";
import TemplatePicker from "@/components/TemplatePicker";
const STEPS = ["Податоци", "Датуми", "Фотографии", "Поддомен", "Шаблон"] as const;
const STEPS = ["Код", "Податоци", "Датуми", "Фотографии", "Поддомен", "Шаблон"] as const;
export default function OnboardingWizard() {
const router = useRouter();
@ -14,6 +14,10 @@ export default function OnboardingWizard() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [code, setCode] = useState("");
const [codeValidated, setCodeValidated] = useState(false);
const [codeValidating, setCodeValidating] = useState(false);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [bornDate, setBornDate] = useState("");
@ -24,15 +28,37 @@ export default function OnboardingWizard() {
const canProceed = () => {
switch (step) {
case 0: return title.trim().length > 0;
case 1: return true;
case 2: return images.length > 0;
case 3: return subdomain.length >= 3;
case 4: return templateId >= 1 && templateId <= 3;
case 0: return codeValidated;
case 1: return title.trim().length > 0;
case 2: return true;
case 3: return images.length > 0;
case 4: return subdomain.length >= 3;
case 5: return templateId >= 1 && templateId <= 3;
default: return false;
}
};
const handleValidateCode = async () => {
setCodeValidating(true);
setError("");
try {
const res = await fetch("/api/validate-code", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code }),
});
const data = await res.json();
if (!data.valid) {
throw new Error(data.error || "Невалиден код");
}
setCodeValidated(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Грешка при валидација");
} finally {
setCodeValidating(false);
}
};
const handlePublish = async () => {
setLoading(true);
setError("");
@ -100,6 +126,42 @@ export default function OnboardingWizard() {
)}
{step === 0 && (
<div className="space-y-4">
<p className="text-sm text-stone-500">
Внесете го кодот што го добивте за да креирате спомен страница.
</p>
<div className="flex gap-3">
<input
id="code"
type="text"
value={code}
onChange={(e) => {
setCode(e.target.value.toUpperCase());
setCodeValidated(false);
}}
placeholder="Внесете код"
className="mt-1 block flex-1 rounded-lg border border-stone-200 px-3 py-2.5 font-mono text-lg uppercase tracking-widest text-stone-900 placeholder:text-stone-400 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
maxLength={20}
disabled={codeValidated}
/>
{!codeValidated ? (
<button
onClick={handleValidateCode}
disabled={code.trim().length === 0 || codeValidating}
className="mt-1 rounded-lg bg-primary px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-primary-light disabled:cursor-not-allowed disabled:opacity-50"
>
{codeValidating ? "Проверка..." : "Потврди"}
</button>
) : (
<div className="mt-1 flex items-center rounded-lg bg-green-50 px-4 py-2.5 text-sm font-medium text-green-700">
Потврден
</div>
)}
</div>
</div>
)}
{step === 1 && (
<div className="space-y-4">
<div>
<label htmlFor="title" className="block text-sm font-medium text-stone-700">
@ -132,7 +194,7 @@ export default function OnboardingWizard() {
</div>
)}
{step === 1 && (
{step === 2 && (
<div className="space-y-4">
<p className="text-sm text-stone-500">
Овие се опционални. Можете да внесете точни датуми, приближни години, или да ги оставите празни.
@ -168,18 +230,18 @@ export default function OnboardingWizard() {
</div>
)}
{step === 2 && (
{step === 3 && (
<ImageUploader
images={images}
onImagesChange={(newImages) => setImages(newImages as typeof images)}
/>
)}
{step === 3 && (
{step === 4 && (
<SubdomainPicker value={subdomain} onChange={setSubdomain} />
)}
{step === 4 && (
{step === 5 && (
<TemplatePicker
value={templateId}
onChange={setTemplateId}

81
src/lib/admin-session.ts Normal file
View File

@ -0,0 +1,81 @@
import { cookies } from "next/headers";
const COOKIE_NAME = "admin_session";
const SEP = ".";
function getSecret(): string {
const secret = process.env.ADMIN_SESSION_SECRET;
if (!secret) throw new Error("ADMIN_SESSION_SECRET env var is not set");
return secret;
}
async function sign(payload: string): Promise<string> {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(getSecret()),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
return btoa(String.fromCharCode(...new Uint8Array(sig)));
}
export interface AdminSession {
username: string;
role: "SUPER_ADMIN" | "ADMIN";
}
export async function createAdminSession(data: AdminSession): Promise<string> {
const payload = btoa(JSON.stringify(data));
return payload + SEP + (await sign(payload));
}
export async function verifyAdminSession(token: string): Promise<AdminSession | null> {
const sepIdx = token.lastIndexOf(SEP);
if (sepIdx === -1) return null;
const payload = token.slice(0, sepIdx);
const sig = token.slice(sepIdx + 1);
const expectedSig = await sign(payload);
if (sig.length !== expectedSig.length) return null;
try {
if (!constantTimeEqual(sig, expectedSig)) return null;
} catch {
return null;
}
try {
return JSON.parse(atob(payload));
} catch {
return null;
}
}
function constantTimeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
}
export const COOKIE_NAME_ADMIN = COOKIE_NAME;
export function cookieOptions(value: string) {
return {
name: COOKIE_NAME,
value,
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax" as const,
path: "/",
};
}
export async function getAdminSession(): Promise<AdminSession | null> {
const store = await cookies();
const token = store.get(COOKIE_NAME)?.value;
if (!token) return null;
return verifyAdminSession(token);
}

View File

@ -1,9 +1,14 @@
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { verifyAdminSession, COOKIE_NAME_ADMIN } from "@/lib/admin-session";
const isProtectedRoute = createRouteMatcher(["/dashboard(.*)", "/onboarding(.*)", "/api/publish(.*)", "/api/upload(.*)", "/api/user(.*)"]);
function isAdminRoute(pathname: string): boolean {
return pathname.startsWith("/admin") || pathname.startsWith("/api/admin");
}
function getSubdomain(req: NextRequest): string | null {
const host = req.headers.get("host");
if (!host) return null;
@ -25,6 +30,22 @@ function getSubdomain(req: NextRequest): string | null {
}
export default clerkMiddleware(async (auth, req: NextRequest) => {
if (isAdminRoute(req.nextUrl.pathname)) {
if (req.nextUrl.pathname === "/admin/login" || req.nextUrl.pathname === "/api/admin/login") {
return NextResponse.next();
}
const token = req.cookies.get(COOKIE_NAME_ADMIN)?.value;
if (!token || !(await verifyAdminSession(token))) {
if (req.nextUrl.pathname.startsWith("/api/")) {
return NextResponse.json({ error: "Неавторизирано" }, { status: 401 });
}
return NextResponse.redirect(new URL("/admin/login", req.url));
}
return NextResponse.next();
}
if (isProtectedRoute(req)) {
await auth.protect();
}
@ -41,4 +62,4 @@ export default clerkMiddleware(async (auth, req: NextRequest) => {
export const config = {
matcher: ["/(api|trpc)(.*)", "/__clerk/:path*", "/((?!_next|api/static|.*\\..*).*)"],
};
};