diff --git a/backend/src/db/migrations/20260506103000-add-user-to-page-access-rules.js b/backend/src/db/migrations/20260506103000-add-user-to-page-access-rules.js
new file mode 100644
index 0000000..9297274
--- /dev/null
+++ b/backend/src/db/migrations/20260506103000-add-user-to-page-access-rules.js
@@ -0,0 +1,14 @@
+'use strict';
+
+module.exports = {
+ async up(queryInterface, Sequelize) {
+ await queryInterface.addColumn('page_access_rules', 'userId', {
+ type: Sequelize.DataTypes.UUID,
+ allowNull: true,
+ });
+ },
+
+ async down(queryInterface) {
+ await queryInterface.removeColumn('page_access_rules', 'userId');
+ },
+};
diff --git a/backend/src/db/models/page_access_rules.js b/backend/src/db/models/page_access_rules.js
index da9e64a..6b2f946 100644
--- a/backend/src/db/models/page_access_rules.js
+++ b/backend/src/db/models/page_access_rules.js
@@ -1,9 +1,3 @@
-const config = require('../../config');
-const providers = config.providers;
-const crypto = require('crypto');
-const bcrypt = require('bcrypt');
-const moment = require('moment');
-
module.exports = function(sequelize, DataTypes) {
const page_access_rules = sequelize.define(
'page_access_rules',
@@ -98,6 +92,13 @@ effective_until: {
constraints: false,
});
+ db.page_access_rules.belongsTo(db.users, {
+ as: 'user',
+ foreignKey: {
+ name: 'userId',
+ },
+ constraints: false,
+ });
diff --git a/backend/src/db/models/users.js b/backend/src/db/models/users.js
index 5c0fc51..5f05919 100644
--- a/backend/src/db/models/users.js
+++ b/backend/src/db/models/users.js
@@ -2,7 +2,6 @@ const config = require('../../config');
const providers = config.providers;
const crypto = require('crypto');
const bcrypt = require('bcrypt');
-const moment = require('moment');
module.exports = function(sequelize, DataTypes) {
const users = sequelize.define(
@@ -154,6 +153,14 @@ provider: {
constraints: false,
});
+ db.users.hasMany(db.page_access_rules, {
+ as: 'page_access_rules_user',
+ foreignKey: {
+ name: 'userId',
+ },
+ constraints: false,
+ });
+
//end loop
@@ -191,8 +198,8 @@ provider: {
};
- users.beforeCreate((users, options) => {
- users = trimStringFields(users);
+ users.beforeCreate((users) => {
+ trimStringFields(users);
if (users.provider !== providers.LOCAL && Object.values(providers).indexOf(users.provider) > -1) {
users.emailVerified = true;
@@ -212,8 +219,8 @@ provider: {
}
});
- users.beforeUpdate((users, options) => {
- users = trimStringFields(users);
+ users.beforeUpdate((users) => {
+ trimStringFields(users);
});
diff --git a/backend/src/index.js b/backend/src/index.js
index a481a3d..6f903ca 100644
--- a/backend/src/index.js
+++ b/backend/src/index.js
@@ -6,7 +6,6 @@ const passport = require('passport');
const path = require('path');
const fs = require('fs');
const bodyParser = require('body-parser');
-const db = require('./db/models');
const config = require('./config');
const swaggerUI = require('swagger-ui-express');
const swaggerJsDoc = require('swagger-jsdoc');
@@ -18,8 +17,7 @@ const sqlRoutes = require('./routes/sql');
const pexelsRoutes = require('./routes/pexels');
const openaiRoutes = require('./routes/openai');
-
-
+const portalAccessRoutes = require('./routes/portalAccess');
const usersRoutes = require('./routes/users');
@@ -88,6 +86,7 @@ app.use(bodyParser.json());
app.use('/api/auth', authRoutes);
app.use('/api/file', fileRoutes);
app.use('/api/pexels', pexelsRoutes);
+app.use('/api/portal-access', portalAccessRoutes);
app.enable('trust proxy');
diff --git a/backend/src/routes/portalAccess.js b/backend/src/routes/portalAccess.js
new file mode 100644
index 0000000..09adb9f
--- /dev/null
+++ b/backend/src/routes/portalAccess.js
@@ -0,0 +1,142 @@
+const express = require('express');
+const passport = require('passport');
+
+const PortalAccessService = require('../services/portalAccess');
+const wrapAsync = require('../helpers').wrapAsync;
+const commonErrorHandler = require('../helpers').commonErrorHandler;
+const { checkPermissions } = require('../middlewares/check-permissions');
+
+const router = express.Router();
+
+function getViewerSessionToken(req) {
+ const sessionHeader = req.headers['x-viewer-session'];
+
+ if (Array.isArray(sessionHeader)) {
+ return sessionHeader[0];
+ }
+
+ return sessionHeader;
+}
+
+router.post('/login', wrapAsync(async (req, res) => {
+ const payload = await PortalAccessService.login(
+ req.body?.name,
+ req.body?.uniqueNumber,
+ );
+
+ res.status(200).send(payload);
+}));
+
+router.get('/session', wrapAsync(async (req, res) => {
+ const payload = await PortalAccessService.getViewerSession(
+ getViewerSessionToken(req),
+ );
+
+ res.set('Cache-Control', 'no-store');
+ res.status(200).send(payload);
+}));
+
+router.get('/document', wrapAsync(async (req, res) => {
+ const requestBaseUrl = `${req.protocol}://${req.get('host')}`;
+ const payload = await PortalAccessService.getViewerDocumentBuffer(
+ getViewerSessionToken(req),
+ requestBaseUrl,
+ );
+ const safeFilename = (payload.filename || 'document.pdf').replace(/"/g, '');
+
+ res.set('Cache-Control', 'no-store');
+ res.set('Content-Type', 'application/pdf');
+ res.set('Content-Disposition', `inline; filename="${safeFilename}"`);
+ res.status(200).send(payload.buffer);
+}));
+
+router.get('/feedback', wrapAsync(async (req, res) => {
+ const payload = await PortalAccessService.listViewerFeedback(
+ getViewerSessionToken(req),
+ );
+
+ res.set('Cache-Control', 'no-store');
+ res.status(200).send(payload);
+}));
+
+router.post('/feedback', wrapAsync(async (req, res) => {
+ const payload = await PortalAccessService.createViewerFeedback(
+ getViewerSessionToken(req),
+ req.body?.message,
+ );
+
+ res.status(200).send(payload);
+}));
+
+router.use(passport.authenticate('jwt', { session: false }));
+
+router.get(
+ '/viewers',
+ checkPermissions('CREATE_USERS'),
+ wrapAsync(async (req, res) => {
+ const payload = await PortalAccessService.listManagedViewers();
+ res.status(200).send(payload);
+ }),
+);
+
+router.post(
+ '/viewers',
+ checkPermissions('CREATE_USERS'),
+ wrapAsync(async (req, res) => {
+ const payload = await PortalAccessService.createManagedViewer(
+ req.body,
+ req.currentUser,
+ );
+ res.status(200).send(payload);
+ }),
+);
+
+router.put(
+ '/viewers/:id',
+ checkPermissions('UPDATE_USERS'),
+ wrapAsync(async (req, res) => {
+ const payload = await PortalAccessService.updateManagedViewer(
+ req.params.id,
+ req.body,
+ req.currentUser,
+ );
+ res.status(200).send(payload);
+ }),
+);
+
+router.delete(
+ '/viewers/:id',
+ checkPermissions('DELETE_USERS'),
+ wrapAsync(async (req, res) => {
+ await PortalAccessService.removeManagedViewer(req.params.id, req.currentUser);
+ res.status(200).send(true);
+ }),
+);
+
+router.post(
+ '/assignments',
+ checkPermissions('CREATE_PAGE_ACCESS_RULES'),
+ wrapAsync(async (req, res) => {
+ const payload = await PortalAccessService.upsertAssignment(
+ req.body,
+ req.currentUser,
+ );
+ res.status(200).send(payload);
+ }),
+);
+
+router.delete(
+ '/assignments/:viewerId',
+ checkPermissions('UPDATE_PAGE_ACCESS_RULES'),
+ wrapAsync(async (req, res) => {
+ await PortalAccessService.clearAssignment(
+ req.params.viewerId,
+ req.currentUser,
+ );
+ res.status(200).send(true);
+ }),
+);
+
+router.use('/', commonErrorHandler);
+
+module.exports = router;
diff --git a/backend/src/services/portalAccess.js b/backend/src/services/portalAccess.js
new file mode 100644
index 0000000..157dbd1
--- /dev/null
+++ b/backend/src/services/portalAccess.js
@@ -0,0 +1,741 @@
+const axios = require('axios');
+const bcrypt = require('bcrypt');
+const jwt = require('jsonwebtoken');
+const { Op, Sequelize } = require('sequelize');
+
+const db = require('../db/models');
+const config = require('../config');
+
+const VIEWER_PROVIDER = 'viewer_access';
+const VIEWER_TOKEN_EXPIRY = '4h';
+
+function buildHttpError(message, code = 400) {
+ const error = new Error(message);
+ error.code = code;
+ return error;
+}
+
+function normalizeValue(value) {
+ return String(value || '').trim();
+}
+
+function normalizeName(value) {
+ return normalizeValue(value).replace(/\s+/g, ' ');
+}
+
+function buildViewerEmail(uniqueNumber) {
+ const normalizedUniqueNumber = normalizeValue(uniqueNumber);
+ const encodedUniqueNumber = Buffer.from(normalizedUniqueNumber)
+ .toString('hex')
+ .slice(0, 40) || Date.now().toString(16);
+
+ return `viewer-${encodedUniqueNumber}@secure-pdf.local`;
+}
+
+function serializePdfDocument(pdfDocument) {
+ if (!pdfDocument) {
+ return null;
+ }
+
+ const rawPdfDocument = pdfDocument.get ? pdfDocument.get({ plain: true }) : pdfDocument;
+ const pdfSourceFile = Array.isArray(rawPdfDocument.pdf_file)
+ ? rawPdfDocument.pdf_file[0]
+ : null;
+
+ return {
+ id: rawPdfDocument.id,
+ title: rawPdfDocument.title,
+ fileName: rawPdfDocument.file_name,
+ totalPages: rawPdfDocument.total_pages,
+ isActive: rawPdfDocument.is_active,
+ publishedAt: rawPdfDocument.published_at,
+ hasSourceFile: Boolean(pdfSourceFile),
+ sourceFileName: pdfSourceFile?.name || rawPdfDocument.file_name || null,
+ };
+}
+
+function serializeAssignment(assignment) {
+ if (!assignment) {
+ return null;
+ }
+
+ const rawAssignment = assignment.get ? assignment.get({ plain: true }) : assignment;
+
+ return {
+ id: rawAssignment.id,
+ ruleName: rawAssignment.rule_name,
+ pageNumber: rawAssignment.page_number,
+ isEnabled: rawAssignment.is_enabled,
+ effectiveFrom: rawAssignment.effective_from,
+ effectiveUntil: rawAssignment.effective_until,
+ pdfDocument: serializePdfDocument(rawAssignment.pdf_document),
+ };
+}
+
+function serializeViewer(viewer, assignment = null) {
+ if (!viewer) {
+ return null;
+ }
+
+ const rawViewer = viewer.get ? viewer.get({ plain: true }) : viewer;
+
+ return {
+ id: rawViewer.id,
+ name: rawViewer.firstName,
+ uniqueNumber: rawViewer.phoneNumber,
+ disabled: rawViewer.disabled,
+ createdAt: rawViewer.createdAt,
+ updatedAt: rawViewer.updatedAt,
+ assignment,
+ };
+}
+
+async function getViewerRole(transaction) {
+ const viewerRole = await db.roles.findOne({
+ where: { name: config.roles.user || 'Document Viewer' },
+ transaction,
+ });
+
+ if (!viewerRole) {
+ throw buildHttpError('Role Document Viewer tidak ditemukan.', 500);
+ }
+
+ return viewerRole;
+}
+
+function buildViewerToken(userId) {
+ return jwt.sign(
+ {
+ viewerSession: {
+ userId,
+ provider: VIEWER_PROVIDER,
+ },
+ },
+ config.secret_key,
+ { expiresIn: VIEWER_TOKEN_EXPIRY },
+ );
+}
+
+function verifyViewerToken(token) {
+ if (!token) {
+ throw buildHttpError('Sesi viewer tidak ditemukan. Silakan login ulang.', 401);
+ }
+
+ try {
+ const payload = jwt.verify(token, config.secret_key);
+
+ if (
+ !payload?.viewerSession?.userId ||
+ payload.viewerSession.provider !== VIEWER_PROVIDER
+ ) {
+ throw buildHttpError('Sesi viewer tidak valid.', 401);
+ }
+
+ return payload.viewerSession;
+ } catch (error) {
+ if (error.code) {
+ throw error;
+ }
+
+ throw buildHttpError('Sesi viewer tidak valid atau sudah kedaluwarsa.', 401);
+ }
+}
+
+async function findManagedViewerById(id, transaction) {
+ const viewer = await db.users.findOne({
+ where: {
+ id,
+ provider: VIEWER_PROVIDER,
+ },
+ transaction,
+ });
+
+ if (!viewer) {
+ throw buildHttpError('Data viewer tidak ditemukan.', 404);
+ }
+
+ return viewer;
+}
+
+async function findActiveAssignmentForViewer(userId) {
+ const now = new Date();
+
+ const assignment = await db.page_access_rules.findOne({
+ where: {
+ userId,
+ is_enabled: true,
+ [Op.and]: [
+ {
+ [Op.or]: [
+ { effective_from: null },
+ { effective_from: { [Op.lte]: now } },
+ ],
+ },
+ {
+ [Op.or]: [
+ { effective_until: null },
+ { effective_until: { [Op.gte]: now } },
+ ],
+ },
+ ],
+ },
+ include: [
+ {
+ model: db.pdf_documents,
+ as: 'pdf_document',
+ required: true,
+ where: {
+ is_active: true,
+ },
+ include: [
+ {
+ model: db.file,
+ as: 'pdf_file',
+ required: false,
+ },
+ ],
+ },
+ ],
+ order: [
+ ['updatedAt', 'desc'],
+ ['createdAt', 'desc'],
+ ],
+ });
+
+ if (!assignment) {
+ throw buildHttpError(
+ 'Belum ada halaman PDF aktif yang ditugaskan untuk akun ini.',
+ 404,
+ );
+ }
+
+ const rawAssignment = assignment.get({ plain: true });
+
+ if (!rawAssignment.pdf_document?.pdf_file?.length) {
+ throw buildHttpError('File PDF sumber belum tersedia untuk penugasan ini.', 404);
+ }
+
+ return assignment;
+}
+
+async function getViewerSessionFromToken(token) {
+ const { userId } = verifyViewerToken(token);
+ const viewer = await findManagedViewerById(userId);
+
+ if (viewer.disabled) {
+ throw buildHttpError('Akun viewer sedang dinonaktifkan.', 401);
+ }
+
+ const assignment = await findActiveAssignmentForViewer(viewer.id);
+
+ return {
+ viewer,
+ assignment,
+ };
+}
+
+function getDownloadBaseUrl(requestBaseUrl) {
+ if (process.env.NODE_ENV === 'dev_stage') {
+ return 'http://127.0.0.1:3000';
+ }
+
+ return requestBaseUrl;
+}
+
+async function listViewerAssignments(viewerId, transaction) {
+ return db.page_access_rules.findAll({
+ where: { userId: viewerId },
+ include: [
+ {
+ model: db.pdf_documents,
+ as: 'pdf_document',
+ required: false,
+ include: [
+ {
+ model: db.file,
+ as: 'pdf_file',
+ required: false,
+ },
+ ],
+ },
+ ],
+ order: [
+ ['updatedAt', 'desc'],
+ ['createdAt', 'desc'],
+ ],
+ transaction,
+ });
+}
+
+module.exports = class PortalAccessService {
+ static async login(name, uniqueNumber) {
+ const normalizedName = normalizeName(name);
+ const normalizedUniqueNumber = normalizeValue(uniqueNumber);
+
+ if (!normalizedName || !normalizedUniqueNumber) {
+ throw buildHttpError('Nama dan nomor unik wajib diisi.');
+ }
+
+ const viewer = await db.users.findOne({
+ where: {
+ provider: VIEWER_PROVIDER,
+ disabled: false,
+ phoneNumber: normalizedUniqueNumber,
+ [Op.and]: [
+ Sequelize.where(
+ Sequelize.fn('LOWER', Sequelize.col('firstName')),
+ normalizedName.toLowerCase(),
+ ),
+ ],
+ },
+ });
+
+ if (!viewer) {
+ throw buildHttpError('Nama dan nomor unik tidak cocok.');
+ }
+
+ if (viewer.password) {
+ const isPasswordValid = await bcrypt.compare(
+ normalizedUniqueNumber,
+ viewer.password,
+ );
+
+ if (!isPasswordValid) {
+ throw buildHttpError('Nama dan nomor unik tidak cocok.');
+ }
+ }
+
+ const assignment = await findActiveAssignmentForViewer(viewer.id);
+
+ return {
+ token: buildViewerToken(viewer.id),
+ viewer: serializeViewer(viewer),
+ assignment: serializeAssignment(assignment),
+ };
+ }
+
+ static async getViewerSession(token) {
+ const { viewer, assignment } = await getViewerSessionFromToken(token);
+
+ return {
+ viewer: {
+ id: viewer.id,
+ name: viewer.firstName,
+ },
+ assignment: serializeAssignment(assignment),
+ };
+ }
+
+ static async getViewerDocumentBuffer(token, requestBaseUrl) {
+ const { assignment } = await getViewerSessionFromToken(token);
+ const rawAssignment = assignment.get({ plain: true });
+ const pdfSourceFile = rawAssignment.pdf_document?.pdf_file?.[0];
+
+ if (!pdfSourceFile) {
+ throw buildHttpError('File PDF tidak ditemukan.', 404);
+ }
+
+ const relativeDownloadPath = pdfSourceFile.publicUrl ||
+ `/api/file/download?privateUrl=${encodeURIComponent(pdfSourceFile.privateUrl)}`;
+ const downloadUrl = new URL(
+ relativeDownloadPath,
+ getDownloadBaseUrl(requestBaseUrl),
+ ).toString();
+
+ let response;
+
+ try {
+ response = await axios.get(downloadUrl, {
+ responseType: 'arraybuffer',
+ });
+ } catch (error) {
+ console.error('Failed to fetch secured PDF source:', error?.response?.data || error.message || error);
+ throw buildHttpError('PDF tidak dapat dibuka saat ini.', 500);
+ }
+
+ return {
+ buffer: response.data,
+ filename: pdfSourceFile.name || rawAssignment.pdf_document?.file_name || 'document.pdf',
+ };
+ }
+
+ static async listViewerFeedback(token) {
+ const { viewer } = await getViewerSessionFromToken(token);
+
+ const feedbackRows = await db.user_feedback.findAll({
+ where: { userId: viewer.id },
+ include: [
+ {
+ model: db.page_access_rules,
+ as: 'page_access_rule',
+ required: false,
+ },
+ ],
+ order: [
+ ['submitted_at', 'desc'],
+ ['createdAt', 'desc'],
+ ],
+ limit: 10,
+ });
+
+ return feedbackRows.map((feedbackItem) => {
+ const rawFeedback = feedbackItem.get({ plain: true });
+
+ return {
+ id: rawFeedback.id,
+ message: rawFeedback.message,
+ status: rawFeedback.status,
+ submittedAt: rawFeedback.submitted_at || rawFeedback.createdAt,
+ reviewedAt: rawFeedback.reviewed_at || null,
+ adminNote: rawFeedback.is_visible_to_user ? rawFeedback.admin_note : null,
+ pageRuleName: rawFeedback.page_access_rule?.rule_name || null,
+ };
+ });
+ }
+
+ static async createViewerFeedback(token, message) {
+ const normalizedMessage = normalizeValue(message);
+
+ if (normalizedMessage.length < 3) {
+ throw buildHttpError('Masukan minimal terdiri dari 3 karakter.');
+ }
+
+ const { viewer, assignment } = await getViewerSessionFromToken(token);
+
+ const feedback = await db.user_feedback.create({
+ status: 'new',
+ message: normalizedMessage,
+ submitted_at: new Date(),
+ is_visible_to_user: true,
+ userId: viewer.id,
+ page_access_ruleId: assignment.id,
+ createdById: viewer.id,
+ updatedById: viewer.id,
+ });
+
+ return {
+ id: feedback.id,
+ message: feedback.message,
+ status: feedback.status,
+ submittedAt: feedback.submitted_at,
+ };
+ }
+
+ static async listManagedViewers() {
+ const viewers = await db.users.findAll({
+ where: {
+ provider: VIEWER_PROVIDER,
+ },
+ attributes: ['id', 'firstName', 'phoneNumber', 'disabled', 'createdAt', 'updatedAt'],
+ include: [
+ {
+ model: db.page_access_rules,
+ as: 'page_access_rules_user',
+ required: false,
+ include: [
+ {
+ model: db.pdf_documents,
+ as: 'pdf_document',
+ required: false,
+ include: [
+ {
+ model: db.file,
+ as: 'pdf_file',
+ required: false,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ order: [['createdAt', 'desc']],
+ });
+
+ return viewers.map((viewer) => {
+ const viewerAssignments = Array.isArray(viewer.page_access_rules_user)
+ ? viewer.page_access_rules_user
+ : [];
+ const sortedAssignments = [...viewerAssignments].sort((leftAssignment, rightAssignment) => {
+ const rightTimestamp = new Date(rightAssignment.updatedAt || rightAssignment.createdAt).getTime();
+ const leftTimestamp = new Date(leftAssignment.updatedAt || leftAssignment.createdAt).getTime();
+ return rightTimestamp - leftTimestamp;
+ });
+ const activeAssignment =
+ sortedAssignments.find((assignment) => assignment.is_enabled) ||
+ sortedAssignments[0] ||
+ null;
+
+ return serializeViewer(
+ viewer,
+ activeAssignment ? serializeAssignment(activeAssignment) : null,
+ );
+ });
+ }
+
+ static async createManagedViewer(data, currentUser) {
+ const name = normalizeName(data?.name);
+ const uniqueNumber = normalizeValue(data?.uniqueNumber);
+
+ if (!name || !uniqueNumber) {
+ throw buildHttpError('Nama dan nomor unik wajib diisi.');
+ }
+
+ const transaction = await db.sequelize.transaction();
+
+ try {
+ const existingViewer = await db.users.findOne({
+ where: {
+ provider: VIEWER_PROVIDER,
+ phoneNumber: uniqueNumber,
+ },
+ transaction,
+ });
+
+ if (existingViewer) {
+ throw buildHttpError('Nomor unik sudah dipakai oleh viewer lain.');
+ }
+
+ const passwordHash = await bcrypt.hash(uniqueNumber, config.bcrypt.saltRounds);
+ const viewerRole = await getViewerRole(transaction);
+ const viewer = await db.users.create(
+ {
+ firstName: name,
+ phoneNumber: uniqueNumber,
+ email: buildViewerEmail(uniqueNumber),
+ password: passwordHash,
+ disabled: false,
+ emailVerified: true,
+ provider: VIEWER_PROVIDER,
+ createdById: currentUser?.id || null,
+ updatedById: currentUser?.id || null,
+ },
+ { transaction },
+ );
+
+ await viewer.setApp_role(viewerRole, { transaction });
+ await transaction.commit();
+
+ return serializeViewer(viewer);
+ } catch (error) {
+ await transaction.rollback();
+ throw error;
+ }
+ }
+
+ static async updateManagedViewer(id, data, currentUser) {
+ const name = normalizeName(data?.name);
+ const uniqueNumber = normalizeValue(data?.uniqueNumber);
+
+ if (!name || !uniqueNumber) {
+ throw buildHttpError('Nama dan nomor unik wajib diisi.');
+ }
+
+ const transaction = await db.sequelize.transaction();
+
+ try {
+ const viewer = await findManagedViewerById(id, transaction);
+ const duplicateViewer = await db.users.findOne({
+ where: {
+ provider: VIEWER_PROVIDER,
+ phoneNumber: uniqueNumber,
+ id: {
+ [Op.ne]: id,
+ },
+ },
+ transaction,
+ });
+
+ if (duplicateViewer) {
+ throw buildHttpError('Nomor unik sudah dipakai oleh viewer lain.');
+ }
+
+ const passwordHash = await bcrypt.hash(uniqueNumber, config.bcrypt.saltRounds);
+ await viewer.update(
+ {
+ firstName: name,
+ phoneNumber: uniqueNumber,
+ email: buildViewerEmail(uniqueNumber),
+ password: passwordHash,
+ updatedById: currentUser?.id || null,
+ },
+ { transaction },
+ );
+
+ await transaction.commit();
+
+ return serializeViewer(viewer);
+ } catch (error) {
+ await transaction.rollback();
+ throw error;
+ }
+ }
+
+ static async removeManagedViewer(id, currentUser) {
+ const transaction = await db.sequelize.transaction();
+
+ try {
+ const viewer = await findManagedViewerById(id, transaction);
+
+ await db.page_access_rules.update(
+ {
+ userId: null,
+ is_enabled: false,
+ effective_until: new Date(),
+ updatedById: currentUser?.id || null,
+ },
+ {
+ where: { userId: id },
+ transaction,
+ },
+ );
+
+ await viewer.destroy({ transaction });
+ await transaction.commit();
+ } catch (error) {
+ await transaction.rollback();
+ throw error;
+ }
+ }
+
+ static async upsertAssignment(data, currentUser) {
+ const viewerId = normalizeValue(data?.viewerId);
+ const pdfDocumentId = normalizeValue(data?.pdfDocumentId);
+ const pageNumber = Number(data?.pageNumber);
+
+ if (!viewerId || !pdfDocumentId || !Number.isInteger(pageNumber) || pageNumber < 1) {
+ throw buildHttpError('Viewer, dokumen PDF, dan nomor halaman wajib valid.');
+ }
+
+ const transaction = await db.sequelize.transaction();
+
+ try {
+ const viewer = await findManagedViewerById(viewerId, transaction);
+ const pdfDocument = await db.pdf_documents.findByPk(pdfDocumentId, {
+ transaction,
+ include: [
+ {
+ model: db.file,
+ as: 'pdf_file',
+ required: false,
+ },
+ ],
+ });
+
+ if (!pdfDocument) {
+ throw buildHttpError('Dokumen PDF tidak ditemukan.', 404);
+ }
+
+ if (!pdfDocument.is_active) {
+ throw buildHttpError('Aktifkan dokumen PDF sebelum melakukan assignment.');
+ }
+
+ if (!pdfDocument.pdf_file?.length) {
+ throw buildHttpError('Dokumen PDF belum memiliki file sumber.');
+ }
+
+ if (pdfDocument.total_pages && pageNumber > pdfDocument.total_pages) {
+ throw buildHttpError(`Nomor halaman melebihi total halaman PDF (${pdfDocument.total_pages}).`);
+ }
+
+ let assignment = await db.page_access_rules.findOne({
+ where: { userId: viewerId },
+ order: [
+ ['updatedAt', 'desc'],
+ ['createdAt', 'desc'],
+ ],
+ transaction,
+ });
+
+ const assignmentPayload = {
+ rule_name: `${viewer.firstName} · ${pdfDocument.title || 'PDF'} · halaman ${pageNumber}`,
+ page_number: pageNumber,
+ is_enabled: true,
+ effective_from: assignment?.effective_from || new Date(),
+ effective_until: null,
+ pdf_documentId: pdfDocumentId,
+ userId: viewerId,
+ updatedById: currentUser?.id || null,
+ };
+
+ if (assignment) {
+ await assignment.update(assignmentPayload, { transaction });
+ } else {
+ assignment = await db.page_access_rules.create(
+ {
+ ...assignmentPayload,
+ createdById: currentUser?.id || null,
+ },
+ { transaction },
+ );
+ }
+
+ await db.page_access_rules.update(
+ {
+ is_enabled: false,
+ effective_until: new Date(),
+ updatedById: currentUser?.id || null,
+ },
+ {
+ where: {
+ userId: viewerId,
+ id: {
+ [Op.ne]: assignment.id,
+ },
+ },
+ transaction,
+ },
+ );
+
+ await transaction.commit();
+
+ const [refreshedAssignment] = await listViewerAssignments(viewerId);
+ return serializeAssignment(refreshedAssignment || assignment);
+ } catch (error) {
+ await transaction.rollback();
+ throw error;
+ }
+ }
+
+ static async clearAssignment(viewerId, currentUser) {
+ const normalizedViewerId = normalizeValue(viewerId);
+
+ if (!normalizedViewerId) {
+ throw buildHttpError('Viewer tidak ditemukan.', 404);
+ }
+
+ const transaction = await db.sequelize.transaction();
+
+ try {
+ const assignment = await db.page_access_rules.findOne({
+ where: { userId: normalizedViewerId },
+ order: [
+ ['updatedAt', 'desc'],
+ ['createdAt', 'desc'],
+ ],
+ transaction,
+ });
+
+ if (!assignment) {
+ throw buildHttpError('Viewer ini belum memiliki assignment halaman.', 404);
+ }
+
+ await db.page_access_rules.update(
+ {
+ userId: null,
+ is_enabled: false,
+ effective_until: new Date(),
+ updatedById: currentUser?.id || null,
+ },
+ {
+ where: { userId: normalizedViewerId },
+ transaction,
+ },
+ );
+
+ await transaction.commit();
+ } catch (error) {
+ await transaction.rollback();
+ throw error;
+ }
+ }
+};
diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx
index 72935e6..995a452 100644
--- a/frontend/src/components/NavBarItem.tsx
+++ b/frontend/src/components/NavBarItem.tsx
@@ -1,6 +1,5 @@
-import React, {useEffect, useRef} from 'react'
+import React, {useEffect, useRef, useState} from 'react'
import Link from 'next/link'
-import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon'
diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx
index 1b9907d..73d8391 100644
--- a/frontend/src/layouts/Authenticated.tsx
+++ b/frontend/src/layouts/Authenticated.tsx
@@ -1,5 +1,4 @@
-import React, { ReactNode, useEffect } from 'react'
-import { useState } from 'react'
+import React, { ReactNode, useEffect, useState } from 'react'
import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside'
diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts
index 89f5ced..5d7f89f 100644
--- a/frontend/src/menuAside.ts
+++ b/frontend/src/menuAside.ts
@@ -7,6 +7,14 @@ const menuAside: MenuAsideItem[] = [
icon: icon.mdiViewDashboardOutline,
label: 'Dashboard',
},
+ {
+ href: '/pdf-access-center',
+ label: 'Portal akses PDF',
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ icon: 'mdiShieldLockOutline' in icon ? icon['mdiShieldLockOutline' as keyof typeof icon] : icon.mdiTable,
+ permissions: 'CREATE_USERS',
+ },
{
href: '/users/users-list',
diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx
index 7515f22..45e905f 100644
--- a/frontend/src/pages/index.tsx
+++ b/frontend/src/pages/index.tsx
@@ -1,166 +1,352 @@
-
-import React, { useEffect, useState } from 'react';
-import type { ReactElement } from 'react';
+import {
+ mdiAccountKeyOutline,
+ mdiArrowRight,
+ mdiCheckCircleOutline,
+ mdiCommentTextOutline,
+ mdiFilePdfBox,
+ mdiLogin,
+ mdiShieldLockOutline,
+} from '@mdi/js';
+import axios from 'axios';
import Head from 'next/head';
import Link from 'next/link';
+import React, { useState } from 'react';
+import type { ReactElement } from 'react';
+
import BaseButton from '../components/BaseButton';
+import BaseIcon from '../components/BaseIcon';
import CardBox from '../components/CardBox';
-import SectionFullScreen from '../components/SectionFullScreen';
-import LayoutGuest from '../layouts/Guest';
-import BaseDivider from '../components/BaseDivider';
-import BaseButtons from '../components/BaseButtons';
+import FormField from '../components/FormField';
import { getPageTitle } from '../config';
-import { useAppSelector } from '../stores/hooks';
-import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
-import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
+import LayoutGuest from '../layouts/Guest';
+import { useRouter } from 'next/router';
+const VIEWER_SESSION_STORAGE_KEY = 'viewerAccessToken';
-export default function Starter() {
- const [illustrationImage, setIllustrationImage] = useState({
- src: undefined,
- photographer: undefined,
- photographer_url: undefined,
- })
- const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
- const [contentType, setContentType] = useState('image');
- const [contentPosition, setContentPosition] = useState('right');
- const textColor = useAppSelector((state) => state.style.linkColor);
+const featureCards = [
+ {
+ icon: mdiShieldLockOutline,
+ title: 'Akses terkunci per user',
+ description: 'Setiap user login dengan nama dan nomor unik yang sudah ditentukan admin.',
+ },
+ {
+ icon: mdiFilePdfBox,
+ title: 'Hanya 1 halaman',
+ description: 'Viewer diarahkan langsung ke halaman PDF yang sudah di-assign dan tidak perlu mencari manual.',
+ },
+ {
+ icon: mdiCommentTextOutline,
+ title: 'Masukan cepat',
+ description: 'User dapat mengirim catatan perubahan data ke admin dari layar yang sama.',
+ },
+];
- const title = 'Secure PDF Page Access'
+const adminWorkflow = [
+ {
+ icon: mdiFilePdfBox,
+ title: 'Upload PDF sumber',
+ description: 'Admin mengunggah file PDF dan menandai dokumen yang aktif untuk dibuka user.',
+ },
+ {
+ icon: mdiAccountKeyOutline,
+ title: 'Kelola nama + nomor unik',
+ description: 'Master data viewer dibuat sederhana: cukup nama penerima dan nomor unik sebagai password.',
+ },
+ {
+ icon: mdiCheckCircleOutline,
+ title: 'Assign halaman & review feedback',
+ description: 'Tentukan satu halaman per user, lalu pantau masukan perubahan data dari dashboard admin.',
+ },
+];
- // Fetch Pexels image/video
- useEffect(() => {
- async function fetchData() {
- const image = await getPexelsImage();
- const video = await getPexelsVideo();
- setIllustrationImage(image);
- setIllustrationVideo(video);
- }
- fetchData();
- }, []);
+function getErrorMessage(error: unknown, fallback: string) {
+ if (axios.isAxiosError(error)) {
+ return String(error.response?.data || error.message || fallback);
+ }
- const imageBlock = (image) => (
-
- );
+ if (error instanceof Error) {
+ return error.message;
+ }
- const videoBlock = (video) => {
- if (video?.video_files?.length > 0) {
- return (
-
-
-
- Your browser does not support the video tag.
-
-
-
)
- }
- };
+ return fallback;
+}
+
+export default function LandingPage() {
+ const router = useRouter();
+ const [form, setForm] = useState({
+ name: '',
+ uniqueNumber: '',
+ });
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [errorMessage, setErrorMessage] = useState('');
+ const [successMessage, setSuccessMessage] = useState('');
+
+ const handleViewerLogin = async (event: React.FormEvent) => {
+ event.preventDefault();
+ setErrorMessage('');
+ setSuccessMessage('');
+
+ if (!form.name.trim() || !form.uniqueNumber.trim()) {
+ setErrorMessage('Nama dan nomor unik wajib diisi.');
+ return;
+ }
+
+ try {
+ setIsSubmitting(true);
+ const response = await axios.post('/portal-access/login', {
+ name: form.name,
+ uniqueNumber: form.uniqueNumber,
+ });
+
+ const token = response.data?.token;
+ const assignedPage = response.data?.assignment?.pageNumber;
+
+ if (!token) {
+ throw new Error('Sesi viewer tidak berhasil dibuat.');
+ }
+
+ sessionStorage.setItem(VIEWER_SESSION_STORAGE_KEY, token);
+ setSuccessMessage(
+ `Login berhasil. Membuka halaman ${assignedPage || ''} sekarang...`,
+ );
+ await router.push('/pdf-viewer');
+ } catch (error) {
+ setErrorMessage(
+ getErrorMessage(
+ error,
+ 'Login gagal. Pastikan nama dan nomor unik sudah sesuai.',
+ ),
+ );
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
return (
-
+ <>
-
{getPageTitle('Starter Page')}
+
{getPageTitle('Portal Akses PDF')}
-
-
- {contentType === 'image' && contentPosition !== 'background'
- ? imageBlock(illustrationImage)
- : null}
- {contentType === 'video' && contentPosition !== 'background'
- ? videoBlock(illustrationVideo)
- : null}
-
-
-
-
-
-
-
-
+
+
+
+
+
-
-
+
+
+
+
+ PDF dikunci sesuai assignment admin
+
+
+
+
+ Login, buka 1 halaman PDF yang ditugaskan, lalu kirim masukan perubahan data.
+
+
+ Halaman publik ini dibuat untuk alur yang sangat sederhana:
+ user memasukkan nama {' '}
+ dan nomor unik ,
+ lalu sistem langsung menampilkan halaman PDF yang sudah ditentukan admin.
+
+
+
+
+ {featureCards.map((item) => (
+
+
+
+
+
{item.title}
+
{item.description}
+
+ ))}
+
+
+
+
+
+ Satu user hanya melihat satu halaman yang di-assign.
+
+
+
+ Feedback user langsung tercatat untuk admin.
+
+
+
+
+
+
+
+
+
+
+ Viewer Login
+
+
Masuk untuk membuka halaman Anda
+
+ Nomor unik berfungsi sebagai password. Setelah terverifikasi,
+ Anda langsung diarahkan ke halaman PDF yang sudah ditentukan.
+
+
+
+
+
+
+
+
Untuk admin
+
+ Upload PDF sumber, kelola nama + nomor unik, lalu tentukan halaman
+ yang boleh dibuka user dari dashboard admin.
+
+
+
+
+
+
+
+
+
-
-
-
-
© 2026 {title} . All rights reserved
-
- Privacy Policy
-
-
-
+
+
+
+
+
+ Alur awal yang paling penting
+
+
+ Dari upload PDF sampai feedback user, semuanya mengalir dalam satu workflow sederhana.
+
+
+
+ Buka admin interface
+
+
+
+
+
+ {adminWorkflow.map((item, index) => (
+
+
+
{item.title}
+
{item.description}
+
+ ))}
+
+
+
+
+
+
+ >
);
}
-Starter.getLayout = function getLayout(page: ReactElement) {
+LandingPage.getLayout = function getLayout(page: ReactElement) {
return {page} ;
};
-
diff --git a/frontend/src/pages/pdf-access-center.tsx b/frontend/src/pages/pdf-access-center.tsx
new file mode 100644
index 0000000..f255a1d
--- /dev/null
+++ b/frontend/src/pages/pdf-access-center.tsx
@@ -0,0 +1,884 @@
+import {
+ mdiCommentTextOutline,
+ mdiDeleteOutline,
+ mdiFilePdfBox,
+ mdiOpenInNew,
+ mdiPencilOutline,
+ mdiPlus,
+ mdiRefresh,
+ mdiShieldLockOutline,
+} from '@mdi/js';
+import axios from 'axios';
+import Head from 'next/head';
+import Link from 'next/link';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import type { ReactElement } from 'react';
+
+import BaseButton from '../components/BaseButton';
+import BaseDivider from '../components/BaseDivider';
+import BaseIcon from '../components/BaseIcon';
+import CardBox from '../components/CardBox';
+import FormField from '../components/FormField';
+import LoadingSpinner from '../components/LoadingSpinner';
+import SectionMain from '../components/SectionMain';
+import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
+import { getPageTitle } from '../config';
+import { hasPermission } from '../helpers/userPermissions';
+import LayoutAuthenticated from '../layouts/Authenticated';
+import { useAppSelector } from '../stores/hooks';
+
+type ViewerRecord = {
+ id: string;
+ name: string;
+ uniqueNumber: string;
+ disabled?: boolean;
+ assignment?: {
+ id: string;
+ pageNumber: number;
+ isEnabled?: boolean;
+ pdfDocument?: {
+ id: string;
+ title: string;
+ totalPages?: number | null;
+ isActive?: boolean;
+ } | null;
+ } | null;
+};
+
+type PdfDocumentRecord = {
+ id: string;
+ title: string;
+ total_pages?: number | null;
+ is_active?: boolean;
+ published_at?: string | null;
+};
+
+type FeedbackPreview = {
+ id: string;
+ status: string;
+ message: string;
+ submitted_at?: string | null;
+ user?: {
+ firstName?: string | null;
+ } | null;
+ page_access_rule?: {
+ rule_name?: string | null;
+ } | null;
+};
+
+type ViewerFormState = {
+ id: string;
+ name: string;
+ uniqueNumber: string;
+};
+
+type AssignmentFormState = {
+ viewerId: string;
+ pdfDocumentId: string;
+ pageNumber: string;
+};
+
+const initialViewerForm: ViewerFormState = {
+ id: '',
+ name: '',
+ uniqueNumber: '',
+};
+
+const initialAssignmentForm: AssignmentFormState = {
+ viewerId: '',
+ pdfDocumentId: '',
+ pageNumber: '',
+};
+
+function getErrorMessage(error: unknown, fallback: string) {
+ if (axios.isAxiosError(error)) {
+ return String(error.response?.data || error.message || fallback);
+ }
+
+ if (error instanceof Error) {
+ return error.message;
+ }
+
+ return fallback;
+}
+
+function formatDate(value?: string | null) {
+ if (!value) {
+ return '-';
+ }
+
+ return new Intl.DateTimeFormat('id-ID', {
+ dateStyle: 'medium',
+ }).format(new Date(value));
+}
+
+function getFeedbackStatusClasses(status: string) {
+ switch (status) {
+ case 'resolved':
+ return 'bg-emerald-100 text-emerald-700 border-emerald-200';
+ case 'reviewed':
+ return 'bg-sky-100 text-sky-700 border-sky-200';
+ case 'rejected':
+ return 'bg-red-100 text-red-700 border-red-200';
+ default:
+ return 'bg-amber-100 text-amber-700 border-amber-200';
+ }
+}
+
+export default function PdfAccessCenterPage() {
+ const { currentUser } = useAppSelector((state) => state.auth);
+ const [viewers, setViewers] = useState([]);
+ const [pdfDocuments, setPdfDocuments] = useState([]);
+ const [feedbackPreview, setFeedbackPreview] = useState([]);
+ const [viewerForm, setViewerForm] = useState(initialViewerForm);
+ const [assignmentForm, setAssignmentForm] = useState(initialAssignmentForm);
+ const [loading, setLoading] = useState(true);
+ const [reloading, setReloading] = useState(false);
+ const [savingViewer, setSavingViewer] = useState(false);
+ const [savingAssignment, setSavingAssignment] = useState(false);
+ const [notice, setNotice] = useState({
+ type: '',
+ text: '',
+ });
+
+ const canCreateViewer = hasPermission(currentUser, 'CREATE_USERS');
+ const canUpdateViewer = hasPermission(currentUser, 'UPDATE_USERS');
+ const canDeleteViewer = hasPermission(currentUser, 'DELETE_USERS');
+ const canAssignPage = hasPermission(currentUser, ['CREATE_PAGE_ACCESS_RULES', 'UPDATE_PAGE_ACCESS_RULES']);
+
+ const selectedPdfDocument = useMemo(
+ () => pdfDocuments.find((item) => item.id === assignmentForm.pdfDocumentId) || null,
+ [assignmentForm.pdfDocumentId, pdfDocuments],
+ );
+
+ const latestActivePdfCount = useMemo(
+ () => pdfDocuments.filter((item) => item.is_active).length,
+ [pdfDocuments],
+ );
+
+ const viewersWithAssignmentCount = useMemo(
+ () => viewers.filter((item) => item.assignment?.pdfDocument).length,
+ [viewers],
+ );
+
+ const loadPortalData = useCallback(async (isRefresh = false) => {
+ if (isRefresh) {
+ setReloading(true);
+ } else {
+ setLoading(true);
+ }
+
+ try {
+ const [viewersResponse, pdfDocumentsResponse, feedbackResponse] = await Promise.all([
+ axios.get('/portal-access/viewers'),
+ axios.get('/pdf_documents?limit=100&page=0'),
+ axios.get('/user_feedback?limit=5&page=0'),
+ ]);
+
+ setViewers(viewersResponse.data || []);
+ setPdfDocuments(pdfDocumentsResponse.data?.rows || []);
+ setFeedbackPreview(feedbackResponse.data?.rows || []);
+ } catch (error) {
+ setNotice({
+ type: 'error',
+ text: getErrorMessage(
+ error,
+ 'Dashboard akses PDF belum bisa dimuat. Silakan coba lagi.',
+ ),
+ });
+ } finally {
+ setLoading(false);
+ setReloading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ loadPortalData();
+ }, [loadPortalData]);
+
+ const resetViewerForm = () => {
+ setViewerForm(initialViewerForm);
+ };
+
+ const resetAssignmentForm = () => {
+ setAssignmentForm(initialAssignmentForm);
+ };
+
+ const handleViewerSubmit = async (event: React.FormEvent) => {
+ event.preventDefault();
+ setNotice({ type: '', text: '' });
+
+ if (!viewerForm.name.trim() || !viewerForm.uniqueNumber.trim()) {
+ setNotice({
+ type: 'error',
+ text: 'Nama dan nomor unik wajib diisi.',
+ });
+ return;
+ }
+
+ try {
+ setSavingViewer(true);
+
+ if (viewerForm.id) {
+ await axios.put(`/portal-access/viewers/${viewerForm.id}`, {
+ name: viewerForm.name,
+ uniqueNumber: viewerForm.uniqueNumber,
+ });
+ setNotice({
+ type: 'success',
+ text: 'Data viewer berhasil diperbarui.',
+ });
+ } else {
+ await axios.post('/portal-access/viewers', {
+ name: viewerForm.name,
+ uniqueNumber: viewerForm.uniqueNumber,
+ });
+ setNotice({
+ type: 'success',
+ text: 'Viewer baru berhasil ditambahkan.',
+ });
+ }
+
+ resetViewerForm();
+ await loadPortalData(true);
+ } catch (error) {
+ setNotice({
+ type: 'error',
+ text: getErrorMessage(
+ error,
+ 'Data viewer belum berhasil disimpan.',
+ ),
+ });
+ } finally {
+ setSavingViewer(false);
+ }
+ };
+
+ const handleAssignmentSubmit = async (event: React.FormEvent) => {
+ event.preventDefault();
+ setNotice({ type: '', text: '' });
+
+ if (!assignmentForm.viewerId || !assignmentForm.pdfDocumentId || !assignmentForm.pageNumber) {
+ setNotice({
+ type: 'error',
+ text: 'Pilih viewer, pilih PDF, dan isi nomor halaman.',
+ });
+ return;
+ }
+
+ try {
+ setSavingAssignment(true);
+ await axios.post('/portal-access/assignments', {
+ viewerId: assignmentForm.viewerId,
+ pdfDocumentId: assignmentForm.pdfDocumentId,
+ pageNumber: Number(assignmentForm.pageNumber),
+ });
+ setNotice({
+ type: 'success',
+ text: 'Assignment halaman berhasil disimpan.',
+ });
+ resetAssignmentForm();
+ await loadPortalData(true);
+ } catch (error) {
+ setNotice({
+ type: 'error',
+ text: getErrorMessage(
+ error,
+ 'Assignment halaman belum berhasil disimpan.',
+ ),
+ });
+ } finally {
+ setSavingAssignment(false);
+ }
+ };
+
+ const handleEditViewer = (viewer: ViewerRecord) => {
+ setViewerForm({
+ id: viewer.id,
+ name: viewer.name,
+ uniqueNumber: viewer.uniqueNumber,
+ });
+ setNotice({ type: '', text: '' });
+ };
+
+ const handlePrepareAssignment = (viewer: ViewerRecord) => {
+ setAssignmentForm({
+ viewerId: viewer.id,
+ pdfDocumentId: viewer.assignment?.pdfDocument?.id || '',
+ pageNumber: viewer.assignment?.pageNumber ? String(viewer.assignment.pageNumber) : '',
+ });
+ setNotice({ type: '', text: '' });
+ };
+
+ const handleDeleteViewer = async (viewer: ViewerRecord) => {
+ if (!window.confirm(`Hapus viewer ${viewer.name}?`)) {
+ return;
+ }
+
+ try {
+ setNotice({ type: '', text: '' });
+ await axios.delete(`/portal-access/viewers/${viewer.id}`);
+ setNotice({
+ type: 'success',
+ text: 'Viewer berhasil dihapus.',
+ });
+
+ if (viewerForm.id === viewer.id) {
+ resetViewerForm();
+ }
+
+ if (assignmentForm.viewerId === viewer.id) {
+ resetAssignmentForm();
+ }
+
+ await loadPortalData(true);
+ } catch (error) {
+ setNotice({
+ type: 'error',
+ text: getErrorMessage(error, 'Viewer belum berhasil dihapus.'),
+ });
+ }
+ };
+
+ const handleClearAssignment = async (viewer: ViewerRecord) => {
+ if (!window.confirm(`Lepaskan assignment halaman untuk ${viewer.name}?`)) {
+ return;
+ }
+
+ try {
+ await axios.delete(`/portal-access/assignments/${viewer.id}`);
+ setNotice({
+ type: 'success',
+ text: 'Assignment halaman berhasil dilepas.',
+ });
+
+ if (assignmentForm.viewerId === viewer.id) {
+ resetAssignmentForm();
+ }
+
+ await loadPortalData(true);
+ } catch (error) {
+ setNotice({
+ type: 'error',
+ text: getErrorMessage(error, 'Assignment belum berhasil dilepas.'),
+ });
+ }
+ };
+
+ const noticeClassName =
+ notice.type === 'success'
+ ? 'border-emerald-200 bg-emerald-50 text-emerald-700'
+ : 'border-red-200 bg-red-50 text-red-700';
+
+ return (
+ <>
+
+ {getPageTitle('Portal Akses PDF')}
+
+
+
+
+
+
+
+ {loading ? : null}
+
+ {!loading ? (
+
+
+
+
+
+
+
+ Initial MVP slice
+
+
+ Kelola viewer, assign 1 halaman PDF, dan pantau feedback dari satu tempat.
+
+
+ Halaman ini melengkapi CRUD bawaan dengan workflow domain yang lebih natural:
+ admin cukup upload PDF sumber, mengisi master data viewer, lalu menentukan
+ halaman yang boleh dibuka oleh masing-masing user.
+
+
+
loadPortalData(true)}
+ disabled={reloading}
+ />
+
+
+
+
+
Viewer aktif
+
{viewers.length}
+
+
+
PDF aktif
+
{latestActivePdfCount}
+
+
+
Viewer ter-assign
+
{viewersWithAssignmentCount}
+
+
+
+
+
+
+
+
+
Quick links
+
Langkah admin yang paling sering dipakai
+
+
+
+
+
+
+
+
+ Upload & kelola PDF sumber
+
+ Aktifkan dokumen yang akan dijadikan sumber halaman viewer.
+
+
+
+
+
+
+
+
+
+ Buka inbox feedback user
+
+ Review perubahan data yang dikirim dari halaman viewer.
+
+
+
+
+
+
+
+
+
+ Cek halaman publik viewer
+
+ Gunakan link ini untuk mencoba login viewer dari sisi user.
+
+
+
+
+
+
+
+
+ {notice.text ? (
+
+ {notice.text}
+
+ ) : null}
+
+
+
+
+
+
Master data viewer
+
Nama + nomor unik
+
+ Nomor unik disimpan sebagai password viewer. Begitu user login,
+ sistem langsung mengarahkan ke halaman yang sudah di-assign.
+
+
+
+
+
+
+
+
+
+
+
Assignment halaman
+
Atur 1 halaman per viewer
+
+ Pilih viewer, pilih PDF yang aktif, lalu isi nomor halaman yang akan tampil
+ di viewer publik. Satu viewer hanya membutuhkan satu assignment aktif.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Daftar viewer
+
Status master data & assignment
+
+
+ Lihat semua user bawaan →
+
+
+
+
+
+
+
+
+ Viewer
+ Nomor unik
+ Assignment
+ Aksi
+
+
+
+ {viewers.length === 0 ? (
+
+
+ Belum ada viewer. Tambahkan master data viewer pertama Anda.
+
+
+ ) : (
+ viewers.map((viewer) => (
+
+
+ {viewer.name}
+ ID: {viewer.id.slice(0, 8)}...
+
+ {viewer.uniqueNumber}
+
+ {viewer.assignment?.pdfDocument ? (
+
+
+ aktif
+
+
+
{viewer.assignment.pdfDocument.title}
+
+ Halaman {viewer.assignment.pageNumber}
+ {viewer.assignment.pdfDocument.totalPages
+ ? ` dari ${viewer.assignment.pdfDocument.totalPages}`
+ : ''}
+
+
+
+ ) : (
+
+ belum ada assignment
+
+ )}
+
+
+
+ handleEditViewer(viewer)}
+ disabled={!canUpdateViewer}
+ />
+ handlePrepareAssignment(viewer)}
+ disabled={!canAssignPage}
+ />
+ handleClearAssignment(viewer)}
+ disabled={!canAssignPage || !viewer.assignment?.pdfDocument}
+ />
+ handleDeleteViewer(viewer)}
+ disabled={!canDeleteViewer}
+ />
+
+
+
+ ))
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
PDF terbaru
+
Dokumen sumber
+
+ {pdfDocuments.length === 0 ? (
+
+ Belum ada PDF yang diunggah. Upload PDF terlebih dahulu sebelum membuat assignment halaman.
+
+ ) : (
+
+ {pdfDocuments.slice(0, 4).map((pdfDocument) => (
+
+
+
+
{pdfDocument.title}
+
+ {pdfDocument.total_pages
+ ? `${pdfDocument.total_pages} halaman`
+ : 'Total halaman belum diisi'}
+
+
+
+ {pdfDocument.is_active ? 'aktif' : 'draft'}
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
Feedback terbaru
+
Masukan user masuk ke sini
+
+
+ {feedbackPreview.length === 0 ? (
+
+ Belum ada feedback dari viewer. Setelah user login dan mengirim masukan, ringkasannya tampil di sini.
+
+ ) : (
+
+ {feedbackPreview.map((item) => (
+
+
+
+ {item.status}
+
+ {formatDate(item.submitted_at)}
+
+
{item.message}
+
+
+ {item.user?.firstName || 'Viewer'}
+ {item.page_access_rule?.rule_name
+ ? ` · ${item.page_access_rule.rule_name}`
+ : ''}
+
+
+ ))}
+
+ )}
+
+
+
+
+
+ ) : null}
+
+ >
+ );
+}
+
+PdfAccessCenterPage.getLayout = function getLayout(page: ReactElement) {
+ return (
+
+ {page}
+
+ );
+};
diff --git a/frontend/src/pages/pdf-viewer.tsx b/frontend/src/pages/pdf-viewer.tsx
new file mode 100644
index 0000000..0e558b1
--- /dev/null
+++ b/frontend/src/pages/pdf-viewer.tsx
@@ -0,0 +1,573 @@
+import {
+ mdiArrowLeft,
+ mdiCheckCircleOutline,
+ mdiCommentTextOutline,
+ mdiFilePdfBox,
+ mdiShieldLockOutline,
+} from '@mdi/js';
+import axios from 'axios';
+import Head from 'next/head';
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import type { ReactElement } from 'react';
+
+import BaseButton from '../components/BaseButton';
+import BaseIcon from '../components/BaseIcon';
+import CardBox from '../components/CardBox';
+import FormField from '../components/FormField';
+import LoadingSpinner from '../components/LoadingSpinner';
+import { getPageTitle } from '../config';
+import LayoutGuest from '../layouts/Guest';
+import { useRouter } from 'next/router';
+
+const VIEWER_SESSION_STORAGE_KEY = 'viewerAccessToken';
+const PDFJS_SCRIPT_URL = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.16.105/pdf.min.js';
+const PDFJS_WORKER_URL = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.16.105/pdf.worker.min.js';
+
+declare global {
+ interface Window {
+ pdfjsLib?: any;
+ }
+}
+
+type ViewerSession = {
+ viewer: {
+ id: string;
+ name: string;
+ };
+ assignment: {
+ id: string;
+ ruleName: string;
+ pageNumber: number;
+ effectiveFrom?: string | null;
+ pdfDocument?: {
+ id: string;
+ title: string;
+ totalPages?: number | null;
+ sourceFileName?: string | null;
+ } | null;
+ };
+};
+
+type ViewerFeedbackItem = {
+ id: string;
+ message: string;
+ status: string;
+ submittedAt?: string | null;
+ reviewedAt?: string | null;
+ adminNote?: string | null;
+ pageRuleName?: string | null;
+};
+
+function getErrorMessage(error: unknown, fallback: string) {
+ if (axios.isAxiosError(error)) {
+ return String(error.response?.data || error.message || fallback);
+ }
+
+ if (error instanceof Error) {
+ return error.message;
+ }
+
+ return fallback;
+}
+
+function formatDate(value?: string | null) {
+ if (!value) {
+ return '-';
+ }
+
+ return new Intl.DateTimeFormat('id-ID', {
+ dateStyle: 'medium',
+ timeStyle: 'short',
+ }).format(new Date(value));
+}
+
+function getStatusStyles(status: string) {
+ switch (status) {
+ case 'resolved':
+ return 'bg-emerald-100 text-emerald-700 border-emerald-200';
+ case 'reviewed':
+ return 'bg-sky-100 text-sky-700 border-sky-200';
+ case 'rejected':
+ return 'bg-red-100 text-red-700 border-red-200';
+ default:
+ return 'bg-amber-100 text-amber-700 border-amber-200';
+ }
+}
+
+function loadPdfJsLibrary() {
+ return new Promise((resolve, reject) => {
+ if (typeof window === 'undefined') {
+ resolve(null);
+ return;
+ }
+
+ if (window.pdfjsLib) {
+ resolve(window.pdfjsLib);
+ return;
+ }
+
+ const existingScript = document.getElementById('pdfjs-cdn-script') as HTMLScriptElement | null;
+
+ if (existingScript) {
+ existingScript.addEventListener('load', () => resolve(window.pdfjsLib));
+ existingScript.addEventListener('error', () => reject(new Error('PDF.js gagal dimuat.')));
+ return;
+ }
+
+ const script = document.createElement('script');
+ script.id = 'pdfjs-cdn-script';
+ script.src = PDFJS_SCRIPT_URL;
+ script.async = true;
+ script.onload = () => resolve(window.pdfjsLib);
+ script.onerror = () => reject(new Error('PDF.js gagal dimuat.'));
+ document.body.appendChild(script);
+ });
+}
+
+export default function PdfViewerPage() {
+ const router = useRouter();
+ const canvasRef = useRef(null);
+ const [viewerToken, setViewerToken] = useState('');
+ const [session, setSession] = useState(null);
+ const [feedbackItems, setFeedbackItems] = useState([]);
+ const [feedbackMessage, setFeedbackMessage] = useState('');
+ const [isPageLoading, setIsPageLoading] = useState(true);
+ const [isPdfLoading, setIsPdfLoading] = useState(false);
+ const [isSubmittingFeedback, setIsSubmittingFeedback] = useState(false);
+ const [pageError, setPageError] = useState('');
+ const [pdfError, setPdfError] = useState('');
+ const [feedbackError, setFeedbackError] = useState('');
+ const [feedbackSuccess, setFeedbackSuccess] = useState('');
+
+ const assignmentTitle = useMemo(() => {
+ if (!session?.assignment?.pdfDocument?.title) {
+ return 'Halaman PDF';
+ }
+
+ return session.assignment.pdfDocument.title;
+ }, [session]);
+
+ const loadFeedbackHistory = useCallback(async (token: string) => {
+ const response = await axios.get('/portal-access/feedback', {
+ headers: {
+ 'x-viewer-session': token,
+ },
+ });
+
+ setFeedbackItems(response.data || []);
+ }, []);
+
+ const renderAssignedPage = useCallback(async (token: string, pageNumber: number) => {
+ if (!pageNumber) {
+ return;
+ }
+
+ setPdfError('');
+ setIsPdfLoading(true);
+
+ try {
+ const pdfjsLib = await loadPdfJsLibrary();
+
+ if (!pdfjsLib) {
+ throw new Error('Viewer PDF tidak tersedia di browser ini.');
+ }
+
+ pdfjsLib.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_URL;
+
+ const response = await axios.get('/portal-access/document', {
+ headers: {
+ 'x-viewer-session': token,
+ },
+ responseType: 'arraybuffer',
+ });
+
+ const pdfDocument = await pdfjsLib.getDocument({ data: response.data }).promise;
+ const pdfPage = await pdfDocument.getPage(pageNumber);
+ const canvas = canvasRef.current;
+
+ if (!canvas) {
+ return;
+ }
+
+ const context = canvas.getContext('2d');
+
+ if (!context) {
+ throw new Error('Canvas viewer tidak tersedia.');
+ }
+
+ const parentWidth = canvas.parentElement?.clientWidth || 900;
+ const initialViewport = pdfPage.getViewport({ scale: 1 });
+ const scale = Math.min(Math.max(parentWidth / initialViewport.width, 1), 2.2);
+ const viewport = pdfPage.getViewport({ scale });
+
+ canvas.width = viewport.width;
+ canvas.height = viewport.height;
+ canvas.style.width = '100%';
+ canvas.style.height = 'auto';
+
+ await pdfPage.render({
+ canvasContext: context,
+ viewport,
+ }).promise;
+ } catch (error) {
+ console.error('PDF render failed:', error);
+ setPdfError(
+ getErrorMessage(
+ error,
+ 'Halaman PDF belum bisa dirender. Silakan coba lagi atau hubungi admin.',
+ ),
+ );
+ } finally {
+ setIsPdfLoading(false);
+ }
+ }, []);
+
+ const loadViewerSession = useCallback(async (token: string) => {
+ setIsPageLoading(true);
+ setPageError('');
+
+ try {
+ const response = await axios.get('/portal-access/session', {
+ headers: {
+ 'x-viewer-session': token,
+ },
+ });
+
+ setSession(response.data);
+ await loadFeedbackHistory(token);
+ await renderAssignedPage(token, response.data?.assignment?.pageNumber);
+ } catch (error) {
+ const message = getErrorMessage(
+ error,
+ 'Sesi viewer tidak ditemukan. Silakan login ulang.',
+ );
+ setPageError(message);
+ sessionStorage.removeItem(VIEWER_SESSION_STORAGE_KEY);
+ } finally {
+ setIsPageLoading(false);
+ }
+ }, [loadFeedbackHistory, renderAssignedPage]);
+
+ useEffect(() => {
+ if (!router.isReady) {
+ return;
+ }
+
+ const savedViewerToken = sessionStorage.getItem(VIEWER_SESSION_STORAGE_KEY);
+
+ if (!savedViewerToken) {
+ router.replace('/');
+ return;
+ }
+
+ setViewerToken(savedViewerToken);
+ loadViewerSession(savedViewerToken);
+ }, [loadViewerSession, router]);
+
+ const handleExitViewer = async () => {
+ sessionStorage.removeItem(VIEWER_SESSION_STORAGE_KEY);
+ await router.push('/');
+ };
+
+ const handleSubmitFeedback = async (event: React.FormEvent) => {
+ event.preventDefault();
+ setFeedbackError('');
+ setFeedbackSuccess('');
+
+ if (!feedbackMessage.trim()) {
+ setFeedbackError('Masukan tidak boleh kosong.');
+ return;
+ }
+
+ try {
+ setIsSubmittingFeedback(true);
+ await axios.post(
+ '/portal-access/feedback',
+ {
+ message: feedbackMessage,
+ },
+ {
+ headers: {
+ 'x-viewer-session': viewerToken,
+ },
+ },
+ );
+ setFeedbackMessage('');
+ setFeedbackSuccess('Masukan berhasil dikirim ke admin.');
+ await loadFeedbackHistory(viewerToken);
+ } catch (error) {
+ setFeedbackError(
+ getErrorMessage(
+ error,
+ 'Masukan belum berhasil dikirim. Coba lagi sebentar.',
+ ),
+ );
+ } finally {
+ setIsSubmittingFeedback(false);
+ }
+ };
+
+ return (
+ <>
+
+ {getPageTitle('Viewer PDF')}
+
+
+
+
+
+
+
+ Viewer Aman
+
+
Akses halaman PDF yang ditugaskan
+
+
+
+
+
+
+ {isPageLoading ?
: null}
+
+ {!isPageLoading && pageError ? (
+
+
+
+
+
Akses viewer tidak tersedia
+
+
{pageError}
+
+
+
+
+
+ ) : null}
+
+ {!isPageLoading && !pageError && session ? (
+
+
+
+
+
+
+
+ Satu halaman terkunci
+
+
+
+ Halo, {session.viewer.name}
+
+
+ Sistem sudah memverifikasi akun Anda. Di bawah ini hanya ditampilkan
+ halaman PDF yang di-assign admin untuk Anda.
+
+
+
+
+
Halaman aktif
+
+ {session.assignment.pageNumber}
+
+
+
+
+
+
+
+
+
+
{assignmentTitle}
+
+ {session.assignment.pdfDocument?.totalPages
+ ? `${session.assignment.pdfDocument.totalPages} halaman total`
+ : 'Total halaman belum diisi admin'}
+
+
+
+
+
+
+
Aturan akses
+
+ {session.assignment.ruleName || 'Assignment aktif'}
+
+
+
+
+
+
+
Catatan perubahan
+
+ Gunakan formulir di bawah jika ada data pada halaman ini yang perlu dikoreksi.
+
+
+
+
+
+
+
+
+
Ringkasan akses
+
Informasi halaman yang Anda buka
+
+
+
+
Dokumen PDF
+
{assignmentTitle}
+
+
+
Nomor halaman
+
Halaman {session.assignment.pageNumber}
+
+
+
Mulai berlaku
+
{formatDate(session.assignment.effectiveFrom)}
+
+
+
+
+
+
+
+
+
+
+
+
Halaman PDF
+
Preview halaman yang diizinkan
+
+
+ Halaman {session.assignment.pageNumber}
+
+
+
+ {isPdfLoading ?
: null}
+
+ {pdfError ? (
+
+ {pdfError}
+
+ ) : null}
+
+
+
+ event.preventDefault()}
+ className='mx-auto block rounded-2xl shadow-[0_12px_40px_rgba(15,23,42,0.12)]'
+ />
+
+
+
+
+
+
+
+
+
+
Masukan perubahan data
+
Kirim feedback ke admin
+
+ Jelaskan data yang perlu diperbarui, misalnya ejaan nama, nomor,
+ atau isi lain pada halaman PDF ini.
+
+
+
+
+
+
+
+
+
+
+
Riwayat masukan
+
Masukan terakhir Anda
+
+
+ {feedbackItems.length === 0 ? (
+
+ Belum ada masukan yang dikirim. Jika ada perubahan data pada halaman ini,
+ kirimkan lewat formulir di atas.
+
+ ) : (
+
+ {feedbackItems.map((item) => (
+
+
+
+ {item.status}
+
+
+ {formatDate(item.submittedAt)}
+
+
+
{item.message}
+ {item.adminNote ? (
+
+
Catatan admin
+
{item.adminNote}
+
+ ) : null}
+
+ ))}
+
+ )}
+
+
+
+
+
+ ) : null}
+
+
+ >
+ );
+}
+
+PdfViewerPage.getLayout = function getLayout(page: ReactElement) {
+ return {page} ;
+};