Compare commits

..

No commits in common. "2418553172f61b1cc6f6cfd7fa5d2157b5efa767" and "8569b506d2412bae5f85c94784de4d5d7e3dbdd0" have entirely different histories.

53 changed files with 13 additions and 1476 deletions

View File

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

2
.gitignore vendored
View File

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

View File

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

View File

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

View File

@ -1,18 +0,0 @@
"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

@ -1,18 +0,0 @@
"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

@ -1 +0,0 @@
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

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

View File

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

View File

@ -1,22 +0,0 @@
"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

@ -1,22 +0,0 @@
"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

@ -1,22 +0,0 @@
"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

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

View File

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

View File

@ -1,12 +0,0 @@
"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

@ -1,12 +0,0 @@
"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

@ -1,12 +0,0 @@
"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
;

View File

View File

@ -1,6 +0,0 @@
## 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

View File

@ -1,259 +0,0 @@
# 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

@ -1,78 +0,0 @@
"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

@ -1,57 +0,0 @@
"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

@ -1,144 +0,0 @@
"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

@ -1,29 +0,0 @@
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

@ -1,17 +0,0 @@
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

@ -1,189 +0,0 @@
"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

@ -1,45 +0,0 @@
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

@ -1,29 +0,0 @@
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

@ -1,44 +0,0 @@
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

@ -1,41 +0,0 @@
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

@ -1,15 +0,0 @@
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

@ -1,54 +0,0 @@
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

@ -1,49 +0,0 @@
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,13 +11,6 @@ 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

@ -1,44 +0,0 @@
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,10 +14,6 @@ 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("");
@ -28,37 +24,15 @@ export default function OnboardingWizard() {
const canProceed = () => {
switch (step) {
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;
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;
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("");
@ -126,42 +100,6 @@ 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">
@ -194,7 +132,7 @@ export default function OnboardingWizard() {
</div>
)}
{step === 2 && (
{step === 1 && (
<div className="space-y-4">
<p className="text-sm text-stone-500">
Овие се опционални. Можете да внесете точни датуми, приближни години, или да ги оставите празни.
@ -230,18 +168,18 @@ export default function OnboardingWizard() {
</div>
)}
{step === 3 && (
{step === 2 && (
<ImageUploader
images={images}
onImagesChange={(newImages) => setImages(newImages as typeof images)}
/>
)}
{step === 4 && (
{step === 3 && (
<SubdomainPicker value={subdomain} onChange={setSubdomain} />
)}
{step === 5 && (
{step === 4 && (
<TemplatePicker
value={templateId}
onChange={setTemplateId}

View File

@ -1,81 +0,0 @@
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,14 +1,9 @@
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;
@ -30,22 +25,6 @@ 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();
}
@ -62,4 +41,4 @@ export default clerkMiddleware(async (auth, req: NextRequest) => {
export const config = {
matcher: ["/(api|trpc)(.*)", "/__clerk/:path*", "/((?!_next|api/static|.*\\..*).*)"],
};
};