From 2130d23ff45c554320ffa60826ae9aa9cbb9054f Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Wed, 6 May 2026 11:16:57 +0000 Subject: [PATCH] hery --- ...506103000-add-user-to-page-access-rules.js | 14 + backend/src/db/models/page_access_rules.js | 13 +- backend/src/db/models/users.js | 17 +- backend/src/index.js | 5 +- backend/src/routes/portalAccess.js | 142 +++ backend/src/services/portalAccess.js | 741 +++++++++++++++ frontend/src/components/NavBarItem.tsx | 3 +- frontend/src/layouts/Authenticated.tsx | 3 +- frontend/src/menuAside.ts | 8 + frontend/src/pages/index.tsx | 470 +++++++--- frontend/src/pages/pdf-access-center.tsx | 884 ++++++++++++++++++ frontend/src/pages/pdf-viewer.tsx | 573 ++++++++++++ 12 files changed, 2713 insertions(+), 160 deletions(-) create mode 100644 backend/src/db/migrations/20260506103000-add-user-to-page-access-rules.js create mode 100644 backend/src/routes/portalAccess.js create mode 100644 backend/src/services/portalAccess.js create mode 100644 frontend/src/pages/pdf-access-center.tsx create mode 100644 frontend/src/pages/pdf-viewer.tsx 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) => ( -
-
- - Photo by {image?.photographer} on Pexels - -
-
- ); + if (error instanceof Error) { + return error.message; + } - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- -
- - Video by {video.user.name} on Pexels - -
-
) - } - }; + 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} -
- - - -
-

This is a React.js/Node.js app generated by the Flatlogic Web App Generator

-

For guides and documentation please check - your local README.md and the Flatlogic documentation

-
- - - +
+
+
+
+
+
+

+ Secure PDF Page Access +

+

+ Viewer aman untuk 1 halaman PDF per user. +

+
+
+ + Masuk Admin + +
+
- - +
+
+
+ + 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. +

+
+
+ +
+ + + setForm((currentForm) => ({ + ...currentForm, + name: event.target.value, + })) + } + placeholder='Contoh: Budi Santoso' + autoComplete='name' + /> + + + + + setForm((currentForm) => ({ + ...currentForm, + uniqueNumber: event.target.value, + })) + } + placeholder='Masukkan nomor unik' + autoComplete='current-password' + /> + + + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + + {successMessage ? ( +
+ {successMessage} +
+ ) : null} + +
+ +
+
+ +
+

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) => ( +
+
+
+ +
+ 0{index + 1} +
+

{item.title}

+

{item.description}

+
+ ))} +
+
+
+ +
+
+

© 2026 Secure PDF Page Access. Halaman publik untuk viewer dan admin.

+
+ + Login Admin + + + Privacy Policy + +
+
+
+
+ ); } -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. +

+
+ +
+ + + setViewerForm((currentForm) => ({ + ...currentForm, + name: event.target.value, + })) + } + placeholder='Contoh: Budi Santoso' + disabled={viewerForm.id ? !canUpdateViewer : !canCreateViewer} + /> + + + + + setViewerForm((currentForm) => ({ + ...currentForm, + uniqueNumber: event.target.value, + })) + } + placeholder='Contoh: INV-2026-001' + disabled={viewerForm.id ? !canUpdateViewer : !canCreateViewer} + /> + + +
+ + +
+
+
+
+ + +
+
+

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. +

+
+ +
+ + + + + + + + + + + setAssignmentForm((currentForm) => ({ + ...currentForm, + pageNumber: event.target.value, + })) + } + placeholder='Contoh: 12' + disabled={!canAssignPage} + /> + + +
+ + +
+
+
+
+
+ +
+ +
+
+
+

Daftar viewer

+

Status master data & assignment

+
+ + Lihat semua user bawaan → + +
+ +
+
+ + + + + + + + + + + {viewers.length === 0 ? ( + + + + ) : ( + viewers.map((viewer) => ( + + + + + + + )) + )} + +
ViewerNomor unikAssignmentAksi
+ Belum ada viewer. Tambahkan master data viewer pertama Anda. +
+

{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. +

+
+ +
+ +