diff --git a/backend/src/config.js b/backend/src/config.js index 630b05f..1548849 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -1,6 +1,4 @@ - - - +require('dotenv').config(); const os = require('os'); const config = { @@ -76,4 +74,4 @@ config.swaggerUrl = `${config.swaggerUI}${config.swaggerPort}`; config.uiUrl = `${config.hostUI}${config.portUI ? `:${config.portUI}` : ``}/#`; config.backUrl = `${config.hostUI}${config.portUI ? `:${config.portUI}` : ``}`; -module.exports = config; +module.exports = config; \ No newline at end of file diff --git a/backend/src/db/api/assets.js b/backend/src/db/api/assets.js index 82461d8..29a6e58 100644 --- a/backend/src/db/api/assets.js +++ b/backend/src/db/api/assets.js @@ -1,4 +1,3 @@ - const db = require('../models'); const FileDBApi = require('./file'); const crypto = require('crypto'); @@ -20,11 +19,6 @@ module.exports = class AssetsDBApi { const assets = await db.assets.create( { id: data.id || undefined, - - id: data.id - || - null - , title: data.title || @@ -56,6 +50,11 @@ module.exports = class AssetsDBApi { false , + + requiredGdpLevel: data.requiredGdpLevel + || + 1 + , importHash: data.importHash || null, createdById: currentUser.id, @@ -113,11 +112,6 @@ module.exports = class AssetsDBApi { // Prepare data - wrapping individual data transformations in a map() method const assetsData = data.map((item, index) => ({ id: item.id || undefined, - - id: item.id - || - null - , title: item.title || @@ -148,6 +142,11 @@ module.exports = class AssetsDBApi { || false + , + + requiredGdpLevel: item.requiredGdpLevel + || + 1 , importHash: item.importHash || null, @@ -221,7 +220,8 @@ module.exports = class AssetsDBApi { if (data.allow_download !== undefined) updatePayload.allow_download = data.allow_download; - + if (data.requiredGdpLevel !== undefined) updatePayload.requiredGdpLevel = data.requiredGdpLevel; + updatePayload.updatedById = currentUser.id; await assets.update(updatePayload, {transaction}); @@ -699,4 +699,3 @@ module.exports = class AssetsDBApi { }; - diff --git a/backend/src/db/api/users.js b/backend/src/db/api/users.js index eeff09c..506555d 100644 --- a/backend/src/db/api/users.js +++ b/backend/src/db/api/users.js @@ -1,4 +1,3 @@ - const db = require('../models'); const FileDBApi = require('./file'); const crypto = require('crypto'); @@ -84,6 +83,11 @@ module.exports = class UsersDBApi { || null , + + gdpLevel: data.data.gdpLevel + || + 1 + , importHash: data.data.importHash || null, createdById: currentUser.id, @@ -203,6 +207,11 @@ module.exports = class UsersDBApi { provider: item.provider || null + , + + gdpLevel: item.gdpLevel + || + 1 , importHash: item.importHash || null, @@ -298,7 +307,8 @@ module.exports = class UsersDBApi { if (data.provider !== undefined) updatePayload.provider = data.provider; - + if (data.gdpLevel !== undefined) updatePayload.gdpLevel = data.gdpLevel; + updatePayload.updatedById = currentUser.id; await users.update(updatePayload, {transaction}); @@ -935,5 +945,4 @@ module.exports = class UsersDBApi { -}; - +}; \ No newline at end of file diff --git a/backend/src/db/db.config.js b/backend/src/db/db.config.js index 5a2f718..d288b6f 100644 --- a/backend/src/db/db.config.js +++ b/backend/src/db/db.config.js @@ -1,4 +1,4 @@ - +require('dotenv').config(); module.exports = { production: { @@ -12,11 +12,12 @@ module.exports = { seederStorage: 'sequelize', }, development: { - username: 'postgres', + username: process.env.DB_USER || 'postgres', dialect: 'postgres', - password: '', - database: 'db_app_draft', + password: process.env.DB_PASS || '', + database: process.env.DB_NAME || 'db_app_draft', host: process.env.DB_HOST || 'localhost', + port: process.env.DB_PORT, logging: console.log, seederStorage: 'sequelize', }, diff --git a/backend/src/db/migrations/1770016660721.js b/backend/src/db/migrations/1770016660721.js index 36f4985..a4ae332 100644 --- a/backend/src/db/migrations/1770016660721.js +++ b/backend/src/db/migrations/1770016660721.js @@ -494,21 +494,6 @@ module.exports = { - await queryInterface.addColumn( - 'assets', - 'id', - { - type: Sequelize.DataTypes.INTEGER, - - - - }, - { transaction } - ); - - - - await queryInterface.addColumn( 'assets', 'title', @@ -634,21 +619,6 @@ module.exports = { - await queryInterface.addColumn( - 'product_categories', - 'id', - { - type: Sequelize.DataTypes.INTEGER, - - - - }, - { transaction } - ); - - - - await queryInterface.addColumn( 'product_categories', 'name', @@ -679,21 +649,6 @@ module.exports = { - await queryInterface.addColumn( - 'tags', - 'id', - { - type: Sequelize.DataTypes.INTEGER, - - - - }, - { transaction } - ); - - - - await queryInterface.addColumn( 'tags', 'name', @@ -724,21 +679,6 @@ module.exports = { - await queryInterface.addColumn( - 'download_logs', - 'id', - { - type: Sequelize.DataTypes.INTEGER, - - - - }, - { transaction } - ); - - - - await queryInterface.addColumn( 'download_logs', 'userId', diff --git a/backend/src/db/migrations/20260202120000-wellmax-setup.js b/backend/src/db/migrations/20260202120000-wellmax-setup.js new file mode 100644 index 0000000..8dadf76 --- /dev/null +++ b/backend/src/db/migrations/20260202120000-wellmax-setup.js @@ -0,0 +1,49 @@ +'use strict'; + +const { v4: uuid } = require('uuid'); + +module.exports = { + up: async (queryInterface, Sequelize) => { + const createdAt = new Date(); + const updatedAt = new Date(); + + // 1. Seed Product Categories + const categories = [ + { id: uuid(), name: 'High Bay', description: 'Industrial High Bay lighting', createdAt, updatedAt }, + { id: uuid(), name: 'Panel', description: 'LED Panels', createdAt, updatedAt }, + { id: uuid(), name: 'Tube', description: 'LED Tubes', createdAt, updatedAt }, + { id: uuid(), name: 'Downlight', description: 'LED Downlights', createdAt, updatedAt }, + { id: uuid(), name: 'Others', description: 'Other lighting solutions', createdAt, updatedAt }, + ]; + await queryInterface.bulkInsert('product_categories', categories); + + // 2. Grant READ_ASSETS, READ_PRODUCT_CATEGORIES, READ_TAGS to Public role + const [publicRole] = await queryInterface.sequelize.query( + "SELECT id FROM roles WHERE name = 'Public' LIMIT 1;" + ); + + if (publicRole && publicRole[0] && publicRole[0].length > 0) { + const publicRoleId = publicRole[0][0].id; + + const permissionsToGrant = ['READ_ASSETS', 'READ_PRODUCT_CATEGORIES', 'READ_TAGS']; + const [perms] = await queryInterface.sequelize.query( + `SELECT id FROM permissions WHERE name IN ('${permissionsToGrant.join("','")}')` + ); + + if (perms && perms.length > 0) { + const rolesPermissions = perms.map(p => ({ + createdAt, + updatedAt, + roles_permissionsId: publicRoleId, + permissionId: p.id + })); + + await queryInterface.bulkInsert('rolesPermissionsPermissions', rolesPermissions); + } + } + }, + + down: async (queryInterface, Sequelize) => { + // Revert logic if needed + } +}; diff --git a/backend/src/db/migrations/20260205100000-add-gdp-levels.js b/backend/src/db/migrations/20260205100000-add-gdp-levels.js new file mode 100644 index 0000000..7e63685 --- /dev/null +++ b/backend/src/db/migrations/20260205100000-add-gdp-levels.js @@ -0,0 +1,19 @@ +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addColumn('users', 'gdpLevel', { + type: Sequelize.INTEGER, + defaultValue: 1, // Blue + allowNull: false, + }); + await queryInterface.addColumn('assets', 'requiredGdpLevel', { + type: Sequelize.INTEGER, + defaultValue: 1, // Blue + allowNull: false, + }); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.removeColumn('users', 'gdpLevel'); + await queryInterface.removeColumn('assets', 'requiredGdpLevel'); + }, +}; diff --git a/backend/src/db/models/assets.js b/backend/src/db/models/assets.js index 46b1ae5..dd530cf 100644 --- a/backend/src/db/models/assets.js +++ b/backend/src/db/models/assets.js @@ -14,86 +14,42 @@ module.exports = function(sequelize, DataTypes) { primaryKey: true, }, -id: { - type: DataTypes.INTEGER, - - - - }, - title: { type: DataTypes.TEXT, - - - }, description: { type: DataTypes.TEXT, - - - }, asset_type: { type: DataTypes.ENUM, - - - values: [ - -"image", - - -"video", - - -"artwork" - + "image", + "video", + "artwork" ], - }, asset_category: { type: DataTypes.ENUM, - - - values: [ - -"ProductPhotos", - - -"ApplicationScenarios", - - -"Videos", - - -"Packaging&Artwork", - - -"Logos&BrandAssets" - + "ProductPhotos", + "ApplicationScenarios", + "Videos", + "Packaging&Artwork", + "Logos&BrandAssets" ], - }, uploaded_at: { type: DataTypes.DATE, - - - }, allow_download: { type: DataTypes.BOOLEAN, - allowNull: false, defaultValue: false, - - - }, importHash: { @@ -101,6 +57,12 @@ allow_download: { allowNull: true, unique: true, }, + + requiredGdpLevel: { + type: DataTypes.INTEGER, + defaultValue: 1, // Blue + allowNull: false, + }, }, { timestamps: true, @@ -147,16 +109,6 @@ allow_download: { through: 'assetsTagsTags', }); - -/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity - - - - - - - - db.assets.hasMany(db.download_logs, { as: 'download_logs_asset', foreignKey: { @@ -165,12 +117,6 @@ allow_download: { constraints: false, }); - - -//end loop - - - db.assets.belongsTo(db.users, { as: 'uploaded_by', foreignKey: { @@ -179,8 +125,6 @@ allow_download: { constraints: false, }); - - db.assets.hasMany(db.file, { as: 'thumbnail', foreignKey: 'belongsToId', @@ -201,7 +145,6 @@ allow_download: { }, }); - db.assets.belongsTo(db.users, { as: 'createdBy', }); @@ -211,9 +154,5 @@ allow_download: { }); }; - - return assets; -}; - - +}; \ No newline at end of file diff --git a/backend/src/db/models/download_logs.js b/backend/src/db/models/download_logs.js index 1307709..7146efe 100644 --- a/backend/src/db/models/download_logs.js +++ b/backend/src/db/models/download_logs.js @@ -14,13 +14,6 @@ module.exports = function(sequelize, DataTypes) { primaryKey: true, }, -id: { - type: DataTypes.INTEGER, - - - - }, - downloaded_at: { type: DataTypes.DATE, @@ -104,6 +97,4 @@ file_size_bytes: { return download_logs; -}; - - +}; \ No newline at end of file diff --git a/backend/src/db/models/product_categories.js b/backend/src/db/models/product_categories.js index 0cd1fc0..43dccef 100644 --- a/backend/src/db/models/product_categories.js +++ b/backend/src/db/models/product_categories.js @@ -14,13 +14,6 @@ module.exports = function(sequelize, DataTypes) { primaryKey: true, }, -id: { - type: DataTypes.INTEGER, - - - - }, - name: { type: DataTypes.TEXT, @@ -81,6 +74,4 @@ description: { return product_categories; -}; - - +}; \ No newline at end of file diff --git a/backend/src/db/models/tags.js b/backend/src/db/models/tags.js index 9d2febd..0cb70b0 100644 --- a/backend/src/db/models/tags.js +++ b/backend/src/db/models/tags.js @@ -14,13 +14,6 @@ module.exports = function(sequelize, DataTypes) { primaryKey: true, }, -id: { - type: DataTypes.INTEGER, - - - - }, - name: { type: DataTypes.TEXT, @@ -81,6 +74,4 @@ slug: { return tags; -}; - - +}; \ No newline at end of file diff --git a/backend/src/db/models/users.js b/backend/src/db/models/users.js index 934096f..124fafa 100644 --- a/backend/src/db/models/users.js +++ b/backend/src/db/models/users.js @@ -109,6 +109,12 @@ provider: { allowNull: true, unique: true, }, + + gdpLevel: { + type: DataTypes.INTEGER, + defaultValue: 1, // Blue + allowNull: false, + }, }, { timestamps: true, @@ -242,5 +248,4 @@ function trimStringFields(users) { : null; return users; -} - +} \ No newline at end of file diff --git a/backend/src/db/seeders/20200430130760-user-roles.js b/backend/src/db/seeders/20200430130760-user-roles.js index 48d6d09..870f73b 100644 --- a/backend/src/db/seeders/20200430130760-user-roles.js +++ b/backend/src/db/seeders/20200430130760-user-roles.js @@ -68,7 +68,7 @@ await queryInterface.bulkInsert("permissions", [{ id: getId(`READ_API_DOCS`), cr await queryInterface.bulkInsert("permissions", [{ id: getId(`CREATE_SEARCH`), createdAt, updatedAt, name: `CREATE_SEARCH`}]); -await queryInterface.sequelize.query(`create table "rolesPermissionsPermissions" +await queryInterface.sequelize.query(`create table if not exists "rolesPermissionsPermissions" ( "createdAt" timestamp with time zone not null, "updatedAt" timestamp with time zone not null, diff --git a/backend/src/db/seeders/20231127130745-sample-data.js b/backend/src/db/seeders/20231127130745-sample-data.js index 776e12b..ec46069 100644 --- a/backend/src/db/seeders/20231127130745-sample-data.js +++ b/backend/src/db/seeders/20231127130745-sample-data.js @@ -45,17 +45,7 @@ const DownloadLogs = db.download_logs; const AssetsData = [ { - - - - - "id": 1, - - - - - - + "title": "High Bay Warehouse - Front View", @@ -133,17 +123,7 @@ const AssetsData = [ }, { - - - - - "id": 2, - - - - - - + "title": "High Bay Installation Guide - Video", @@ -221,17 +201,7 @@ const AssetsData = [ }, { - - - - - "id": 3, - - - - - - + "title": "WELLMAX Primary Logo - EPS", @@ -309,17 +279,7 @@ const AssetsData = [ }, { - - - - - "id": 4, - - - - - - + "title": "Packaging Mockup - High Bay", @@ -397,17 +357,7 @@ const AssetsData = [ }, { - - - - - "id": 5, - - - - - - + "title": "Downlight Living Room Scenario", @@ -491,17 +441,7 @@ const AssetsData = [ const ProductCategoriesData = [ { - - - - - "id": 1, - - - - - - + "name": "High Bay", @@ -516,17 +456,7 @@ const ProductCategoriesData = [ }, { - - - - - "id": 2, - - - - - - + "name": "Panel", @@ -541,17 +471,7 @@ const ProductCategoriesData = [ }, { - - - - - "id": 3, - - - - - - + "name": "Tube", @@ -566,17 +486,7 @@ const ProductCategoriesData = [ }, { - - - - - "id": 4, - - - - - - + "name": "Downlight", @@ -591,17 +501,7 @@ const ProductCategoriesData = [ }, { - - - - - "id": 5, - - - - - - + "name": "Others", @@ -622,17 +522,7 @@ const ProductCategoriesData = [ const TagsData = [ { - - - - - "id": 1, - - - - - - + "name": "warehouse", @@ -647,17 +537,7 @@ const TagsData = [ }, { - - - - - "id": 2, - - - - - - + "name": "installation", @@ -672,17 +552,7 @@ const TagsData = [ }, { - - - - - "id": 3, - - - - - - + "name": "indoor", @@ -697,17 +567,7 @@ const TagsData = [ }, { - - - - - "id": 4, - - - - - - + "name": "logo", @@ -722,17 +582,7 @@ const TagsData = [ }, { - - - - - "id": 5, - - - - - - + "name": "packaging", @@ -753,17 +603,7 @@ const TagsData = [ const DownloadLogsData = [ { - - - - - "id": 1, - - - - - - + // type code here for "relation_one" field @@ -799,17 +639,7 @@ const DownloadLogsData = [ }, { - - - - - "id": 2, - - - - - - + // type code here for "relation_one" field @@ -845,17 +675,7 @@ const DownloadLogsData = [ }, { - - - - - "id": 3, - - - - - - + // type code here for "relation_one" field @@ -891,17 +711,7 @@ const DownloadLogsData = [ }, { - - - - - "id": 4, - - - - - - + // type code here for "relation_one" field @@ -937,17 +747,7 @@ const DownloadLogsData = [ }, { - - - - - "id": 5, - - - - - - + // type code here for "relation_one" field diff --git a/backend/src/index.js b/backend/src/index.js index efde57a..8da8093 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -92,6 +92,14 @@ app.use('/api/file', fileRoutes); app.use('/api/pexels', pexelsRoutes); app.enable('trust proxy'); +const optionalAuthenticate = (req, res, next) => { + passport.authenticate('jwt', { session: false }, (err, user, info) => { + if (err) return next(err); + if (user) req.currentUser = user; + next(); + })(req, res, next); +}; + app.use('/api/users', passport.authenticate('jwt', {session: false}), usersRoutes); @@ -99,11 +107,10 @@ app.use('/api/roles', passport.authenticate('jwt', {session: false}), rolesRoute app.use('/api/permissions', passport.authenticate('jwt', {session: false}), permissionsRoutes); -app.use('/api/assets', passport.authenticate('jwt', {session: false}), assetsRoutes); - -app.use('/api/product_categories', passport.authenticate('jwt', {session: false}), product_categoriesRoutes); - -app.use('/api/tags', passport.authenticate('jwt', {session: false}), tagsRoutes); +// Publicly accessible routes with optional authentication +app.use('/api/assets', optionalAuthenticate, assetsRoutes); +app.use('/api/product_categories', optionalAuthenticate, product_categoriesRoutes); +app.use('/api/tags', optionalAuthenticate, tagsRoutes); app.use('/api/download_logs', passport.authenticate('jwt', {session: false}), download_logsRoutes); diff --git a/backend/src/routes/assets.js b/backend/src/routes/assets.js index 9bc1407..6a241b3 100644 --- a/backend/src/routes/assets.js +++ b/backend/src/routes/assets.js @@ -1,4 +1,3 @@ - const express = require('express'); const AssetsService = require('../services/assets'); @@ -15,6 +14,13 @@ const { checkCrudPermissions, } = require('../middlewares/check-permissions'); +/** + * Custom download route for assets with role verification and logging + */ +router.get('/:id/download', wrapAsync(async (req, res) => { + await AssetsService.download(req.params.id, req.currentUser, req, res); +})); + router.use(checkCrudPermissions('assets')); @@ -434,4 +440,4 @@ router.get('/:id', wrapAsync(async (req, res) => { router.use('/', require('../helpers').commonErrorHandler); -module.exports = router; +module.exports = router; \ No newline at end of file diff --git a/backend/src/services/assets.js b/backend/src/services/assets.js index 552a4c5..f18fef9 100644 --- a/backend/src/services/assets.js +++ b/backend/src/services/assets.js @@ -1,15 +1,13 @@ const db = require('../db/models'); const AssetsDBApi = require('../db/api/assets'); +const Download_logsDBApi = require('../db/api/download_logs'); const processFile = require("../middlewares/upload"); const ValidationError = require('./notifications/errors/validation'); const csv = require('csv-parser'); const axios = require('axios'); const config = require('../config'); const stream = require('stream'); - - - - +const fileService = require('./file'); module.exports = class AssetsService { static async create(data, currentUser) { @@ -30,6 +28,62 @@ module.exports = class AssetsService { } }; + static async download(id, currentUser, req, res) { + const asset = await AssetsDBApi.findBy({ id }); + if (!asset) { + throw new ValidationError('assetsNotFound'); + } + + // Check if user has permission to download + const role = currentUser.app_role.name; + + if (role !== 'Administrator') { + if (role !== 'GDP_Partner') { + throw new ValidationError('Unauthorized to download original assets'); + } + + // Check GDP Level + const userLevel = currentUser.gdpLevel || 1; // Default to 1 (Blue) + const requiredLevel = asset.requiredGdpLevel || 1; // Default to 1 + + if (userLevel < requiredLevel) { + throw new ValidationError(`GDP Level insufficient. Required: ${requiredLevel}, Your Level: ${userLevel}`); + } + } + + // Log the download + const transaction = await db.sequelize.transaction(); + try { + await Download_logsDBApi.create({ + assetId: asset.id, + userId: currentUser.id, + ip_address: req.ip || req.connection.remoteAddress, + downloaded_at: new Date(), + }, { transaction, currentUser }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + console.error('Failed to log download:', error); + // We continue even if logging fails, but we logged the error + } + + // Get the original file + const originalFiles = asset.original_file || []; + if (originalFiles.length === 0) { + throw new ValidationError('Original file not found for this asset'); + } + + const file = originalFiles[0]; + req.query.privateUrl = file.privateUrl; + + if (process.env.NODE_ENV == "production" || process.env.NEXT_PUBLIC_BACK_API) { + return fileService.downloadGCloud(req, res); + } else { + return fileService.downloadLocal(req, res); + } + } + static async bulkImport(req, res, sendInvitationEmails = true, host) { const transaction = await db.sequelize.transaction(); @@ -51,7 +105,7 @@ module.exports = class AssetsService { .on('error', (error) => reject(error)); }) - await AssetsDBApi.bulkImport(results, { + await Download_logsDBApi.bulkImport(results, { transaction, ignoreDuplicates: true, validate: true, @@ -134,5 +188,3 @@ module.exports = class AssetsService { }; - - diff --git a/fix_migration.py b/fix_migration.py new file mode 100644 index 0000000..af3fc8a --- /dev/null +++ b/fix_migration.py @@ -0,0 +1,21 @@ +import re + +file_path = 'backend/src/db/migrations/1770016660721.js' + +with open(file_path, 'r') as f: + content = f.read() + +# Pattern to match the addColumn block for 'id' +# We use re.DOTALL so . matches newlines +# We look for 'id' as the second argument +pattern = r"\s*await queryInterface\.addColumn\(\s*'[^']+',\s*'id',\s*\{\s*type: Sequelize\.DataTypes\.INTEGER,.*?\},\s*\{ transaction \}\s*\);" + +matches = re.findall(pattern, content, re.DOTALL) +print(f"Found {len(matches)} matches.") +for m in matches: + print("Match snippet:", m.strip()[:100]) + +new_content = re.sub(pattern, "", content, flags=re.DOTALL) + +with open(file_path, 'w') as f: + f.write(new_content) diff --git a/fix_seeder.py b/fix_seeder.py new file mode 100644 index 0000000..b76370c --- /dev/null +++ b/fix_seeder.py @@ -0,0 +1,26 @@ +import re + +file_path = 'backend/src/db/seeders/20231127130745-sample-data.js' + +with open(file_path, 'r') as f: + content = f.read() + +# Pattern to remove "id": , lines +# We handle potential indentation and trailing commas +# regex: +# ^ start of line +# \s* whitespace +# "id": "id" key +# \s* whitespace +# \d+ integer +# \s* whitespace +# ,? optional comma +# \s* whitespace +# $ end of line +pattern = r'^\s*"id":\s*\d+\s*,?\s*$' + +# We need re.MULTILINE to match ^ and $ for each line +new_content = re.sub(pattern, '', content, flags=re.MULTILINE) + +with open(file_path, 'w') as f: + f.write(new_content) diff --git a/fix_seeder_table.py b/fix_seeder_table.py new file mode 100644 index 0000000..018d83d --- /dev/null +++ b/fix_seeder_table.py @@ -0,0 +1,15 @@ +import re + +file_path = 'backend/src/db/seeders/20200430130760-user-roles.js' + +with open(file_path, 'r') as f: + content = f.read() + +# Replace create table with create table if not exists +pattern = 'create table "rolesPermissionsPermissions"' +replacement = 'create table if not exists "rolesPermissionsPermissions"' + +new_content = content.replace(pattern, replacement) + +with open(file_path, 'w') as f: + f.write(new_content) diff --git a/frontend/src/components/Assets/GalleryGrid.tsx b/frontend/src/components/Assets/GalleryGrid.tsx new file mode 100644 index 0000000..c54ae1a --- /dev/null +++ b/frontend/src/components/Assets/GalleryGrid.tsx @@ -0,0 +1,263 @@ +import React, { useState } from 'react'; +import ImageField from '../ImageField'; +import { useAppSelector } from '../../stores/hooks'; +import dataFormatter from '../../helpers/dataFormatter'; +import { Pagination } from '../Pagination'; +import LoadingSpinner from "../LoadingSpinner"; +import { mdiDownload, mdiEye, mdiLock } from '@mdi/js'; +import BaseButton from '../BaseButton'; +import CardBoxModal from '../CardBoxModal'; +import axios from 'axios'; +import { saveAs } from 'file-saver'; +import BaseIcon from '../BaseIcon'; + +type Props = { + assets: any[]; + loading: boolean; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const gdpLevels = { + 1: 'Blue', + 2: 'Silver', + 3: 'Gold', + 4: 'Platinum' +}; + +const GalleryGrid = ({ + assets, + loading, + currentPage, + numPages, + onPageChange, +}: Props) => { + const corners = useAppSelector((state) => state.style.corners); + const currentUser = useAppSelector((state) => state.auth.currentUser); + const [selectedAsset, setSelectedAsset] = useState(null); + const [isPreviewActive, setIsPreviewActive] = useState(false); + const [isDownloading, setIsDownloading] = useState(null); + + const isPartner = currentUser?.app_role?.name === 'GDP_Partner' || currentUser?.app_role?.name === 'Administrator'; + + const canDownload = (asset: any) => { + if (!currentUser) return false; + const role = currentUser.app_role?.name; + if (role === 'Administrator') return true; + if (role !== 'GDP_Partner') return false; + + const userLevel = currentUser.gdpLevel || 1; + const requiredLevel = asset.requiredGdpLevel || 1; + return userLevel >= requiredLevel; + }; + + const handlePreview = (asset: any) => { + setSelectedAsset(asset); + setIsPreviewActive(true); + }; + + const handleDownload = async (e: React.MouseEvent, asset: any) => { + e.stopPropagation(); + if (isDownloading) return; + + setIsDownloading(asset.id); + try { + const response = await axios.get(`/assets/${asset.id}/download`, { + responseType: 'blob', + }); + + // Try to get filename from content-disposition + let fileName = asset.title || 'download'; + const contentDisposition = response.headers['content-disposition']; + if (contentDisposition) { + const fileNameMatch = contentDisposition.match(/filename="(.+)"/); + if (fileNameMatch && fileNameMatch.length === 2) { + fileName = fileNameMatch[1]; + } + } else { + // Fallback to asset title + original extension if possible + const originalFile = dataFormatter.filesFormatter(asset.original_file)[0]; + if (originalFile) { + const ext = originalFile.name.split('.').pop(); + if (ext && !fileName.endsWith(ext)) { + fileName = `${fileName}.${ext}`; + } + } + } + + saveAs(response.data, fileName); + } catch (error) { + console.error('Download failed:', error); + alert('Download failed. Please try again or contact support.'); + } finally { + setIsDownloading(null); + } + }; + + return ( +
+ {loading && } +
+ {!loading && assets.map((asset) => ( +
+ {/* Thumbnail Container */} +
+ + {asset.asset_type === 'video' && ( +
+
+
+
+
+ )} + + {/* Level Badge Overlay if locked */} + {isPartner && !canDownload(asset) && ( +
+ + {gdpLevels[asset.requiredGdpLevel || 1]}+ +
+ )} +
+ + {/* Content */} +
+
+ + {asset.asset_category || asset.asset_type} + +
+

{asset.title}

+

+ {asset.description || 'No description provided.'} +

+ +
+ handlePreview(asset)} + /> + {isPartner && ( +
+ {canDownload(asset) ? ( + handleDownload(e, asset)} + /> + ) : ( + + )} +
+ )} +
+
+
+ ))} +
+ + {!loading && assets.length === 0 && ( +
+

No assets found in this category.

+
+ )} + +
+ +
+ + {/* Preview Modal */} + setIsPreviewActive(false)} + onCancel={() => setIsPreviewActive(false)} + buttonLabel="Close" + buttonColor="info" + > +
+
+ {selectedAsset?.asset_type === 'video' ? ( + + ) : ( + + )} +
+
+

Description

+

{selectedAsset?.description || 'N/A'}

+
+
+
+ Category + {asset_category(selectedAsset)} +
+
+ Type + {selectedAsset?.asset_type || 'N/A'} +
+
+ Required Level + {gdpLevels[selectedAsset?.requiredGdpLevel || 1]} +
+
+ Product Categories + + {dataFormatter.product_categoriesManyListFormatter(selectedAsset?.product_categories).join(', ') || 'None'} + +
+
+ Tags + + {dataFormatter.tagsManyListFormatter(selectedAsset?.tags).join(', ') || 'None'} + +
+
+
+
+
+ ); +}; + +// Helper for safe access inside modal +const asset_category = (asset) => asset?.asset_category || 'N/A'; + +export default GalleryGrid; \ No newline at end of file diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..4ced3eb 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' @@ -129,4 +128,4 @@ export default function NavBarItem({ item }: Props) { } return
{NavBarItemComponentContents}
-} +} \ No newline at end of file diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..26c3572 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' @@ -126,4 +125,4 @@ export default function LayoutAuthenticated({
) -} +} \ No newline at end of file diff --git a/frontend/src/pages/assets/assets-edit.tsx b/frontend/src/pages/assets/assets-edit.tsx index bc53d4e..8cf6c03 100644 --- a/frontend/src/pages/assets/assets-edit.tsx +++ b/frontend/src/pages/assets/assets-edit.tsx @@ -361,6 +361,8 @@ const EditAssetsPage = () => { allow_download: false, + + requiredGdpLevel: 1, @@ -943,6 +945,15 @@ const EditAssetsPage = () => { component={SwitchField} > + + + + + + + + + @@ -982,4 +993,4 @@ EditAssetsPage.getLayout = function getLayout(page: ReactElement) { ) } -export default EditAssetsPage +export default EditAssetsPage \ No newline at end of file diff --git a/frontend/src/pages/assets/assets-new.tsx b/frontend/src/pages/assets/assets-new.tsx index f784cfd..7b744cf 100644 --- a/frontend/src/pages/assets/assets-new.tsx +++ b/frontend/src/pages/assets/assets-new.tsx @@ -216,6 +216,7 @@ const initialValues = { allow_download: false, + requiredGdpLevel: 1, @@ -690,6 +691,15 @@ const AssetsNew = () => { > + + + + + + + + + @@ -724,4 +734,4 @@ AssetsNew.getLayout = function getLayout(page: ReactElement) { ) } -export default AssetsNew +export default AssetsNew \ No newline at end of file diff --git a/frontend/src/pages/gallery.tsx b/frontend/src/pages/gallery.tsx new file mode 100644 index 0000000..1ca84ba --- /dev/null +++ b/frontend/src/pages/gallery.tsx @@ -0,0 +1,173 @@ + +import React, { useEffect, useState } from 'react'; +import type { ReactElement } from 'react'; +import Head from 'next/head'; +import { useAppDispatch, useAppSelector } from '../stores/hooks'; +import { fetch } from '../stores/assets/assetsSlice'; +import { fetch as fetchCategories } from '../stores/product_categories/product_categoriesSlice'; +import LayoutGuest from '../layouts/Guest'; +import SectionMain from '../components/SectionMain'; +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; +import { mdiLibraryOutline, mdiFilterOutline } from '@mdi/js'; +import { getPageTitle } from '../config'; +import GalleryGrid from '../components/Assets/GalleryGrid'; +import CardBox from '../components/CardBox'; +import FormField from '../components/FormField'; +import SelectField from '../components/SelectField'; +import BaseButton from '../components/BaseButton'; + +export default function GalleryPage() { + const dispatch = useAppDispatch(); + const { assets, loading, count } = useAppSelector((state) => state.assets); + const { product_categories } = useAppSelector((state) => state.product_categories); + const [currentPage, setCurrentPage] = useState(0); + const perPage = 12; + + const [filters, setFilters] = useState({ + asset_type: '', + asset_category: '', + product_category: '', + }); + + const assetTypes = [ + { id: 'image', name: 'Image' }, + { id: 'video', name: 'Video' }, + { id: 'artwork', name: 'Artwork' }, + ]; + + const assetCategories = [ + { id: 'ProductPhotos', name: 'Product Photos' }, + { id: 'ApplicationScenarios', name: 'Application Scenarios' }, + { id: 'Videos', name: 'Videos' }, + { id: 'Packaging&Artwork', name: 'Packaging & Artwork' }, + { id: 'Logos&BrandAssets', name: 'Logos & Brand Assets' }, + ]; + + const loadData = () => { + let query = `?page=${currentPage}&limit=${perPage}`; + if (filters.asset_type) query += `&asset_type=${filters.asset_type}`; + if (filters.asset_category) query += `&asset_category=${filters.asset_category}`; + if (filters.product_category) query += `&product_categories=${filters.product_category}`; + + dispatch(fetch({ limit: perPage, page: currentPage, query })); + }; + + useEffect(() => { + dispatch(fetchCategories({ limit: 100, page: 0 })); + }, [dispatch]); + + useEffect(() => { + loadData(); + }, [currentPage, filters]); + + const handleFilterChange = (name: string, value: string) => { + setFilters(prev => ({ ...prev, [name]: value })); + setCurrentPage(0); + }; + + const handleReset = () => { + setFilters({ asset_type: '', asset_category: '', product_category: '' }); + setCurrentPage(0); + }; + + return ( +
+ + {getPageTitle('Asset Library')} + + + {/* Header / Hero */} +
+ +
+
+

WELLMAX Digital Asset Library

+

Browse and download official brand materials.

+
+
+
+
+ + +
+ {/* Filters Sidebar */} +
+ +
+ + + Filters + +
+ +
+ + + + + + + + + + + + + +
+
+
+ + {/* Main Content */} +
+
+

+ {count} Assets Found +

+
+ + setCurrentPage(page)} + /> +
+
+
+
+ ); +} + +GalleryPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 9a58af9..0590b1c 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1,166 +1,121 @@ - -import React, { useEffect, useState } from 'react'; +import React from 'react'; import type { ReactElement } from 'react'; import Head from 'next/head'; import Link from 'next/link'; import BaseButton from '../components/BaseButton'; -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 { getPageTitle } from '../config'; import { useAppSelector } from '../stores/hooks'; -import CardBoxComponentTitle from "../components/CardBoxComponentTitle"; -import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'; +import SectionMain from '../components/SectionMain'; +import { mdiLibraryOutline, mdiShieldCheckOutline, mdiCloudDownloadOutline } from '@mdi/js'; +import IconRounded from '../components/IconRounded'; - -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('video'); - const [contentPosition, setContentPosition] = useState('left'); +export default function WELLMAXLanding() { const textColor = useAppSelector((state) => state.style.linkColor); + const { currentUser } = useAppSelector((state) => state.auth); - const title = 'App Draft' + const title = 'WELLMAX Digital Asset Library' - // Fetch Pexels image/video - useEffect(() => { - async function fetchData() { - const image = await getPexelsImage(); - const video = await getPexelsVideo(); - setIllustrationImage(image); - setIllustrationVideo(video); - } - fetchData(); - }, []); + return ( +
+ + {getPageTitle('Welcome to WELLMAX')} + - const imageBlock = (image) => ( -
-
- - Photo by {image?.photographer} on Pexels - + {/* Hero Section */} +
+

+ WELLMAX Asset Library +

+

+ The secure hub for all official brand assets, product photos, and marketing materials. + Exclusively for WELLMAX GDP Partners. +

+
+ + {!currentUser && ( + + )} +
+ + {/* Features Section */} + +
+
+ +

Centralized Library

+

+ Access all Product Photos, Application Scenarios, and Branding Assets in one place. +

+
+
+ +

Secure Access

+

+ Role-based access control ensuring that original high-res files are available only to authorized partners. +

+
+
+ +

Bulk Downloads

+

+ GDP Partners can easily download the assets they need for marketing and sales. +

+
+
+
+ + {/* Product Categories Preview (Conceptual) */} +
+
+

Our Core Categories

+
+ {['High Bay', 'Panel', 'Tube', 'Downlight'].map((cat) => ( +
+ {cat} +
+ ))} +
+
+
+ + {/* Footer */} +
+
+
+

WELLMAX

+

Industrial Digital Asset Management System

+
+
+ Terms + Privacy + {currentUser ? ( + Admin Panel + ) : ( + Login + )} +
+
+
+ © 2026 WELLMAX. All rights reserved. Powered by Flatlogic. +
+
); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- - -
) - } - }; - - return ( -
- - {getPageTitle('Starter Page')} - - - -
- {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

-
- - - - - -
-
-
-
-
-

© 2026 {title}. All rights reserved

- - Privacy Policy - -
- -
- ); } -Starter.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; - +WELLMAXLanding.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; \ No newline at end of file diff --git a/frontend/src/pages/users/users-edit.tsx b/frontend/src/pages/users/users-edit.tsx index b0e9244..cd75071 100644 --- a/frontend/src/pages/users/users-edit.tsx +++ b/frontend/src/pages/users/users-edit.tsx @@ -264,7 +264,9 @@ const EditUsersPage = () => { - password: '' + password: '', + + gdpLevel: 1, } const [initialValues, setInitialValues] = useState(initVals) @@ -589,6 +591,15 @@ const EditUsersPage = () => { > + + + + + + + + + @@ -692,4 +703,4 @@ EditUsersPage.getLayout = function getLayout(page: ReactElement) { ) } -export default EditUsersPage +export default EditUsersPage \ No newline at end of file diff --git a/frontend/src/pages/users/users-new.tsx b/frontend/src/pages/users/users-new.tsx index 510a85b..2056b3e 100644 --- a/frontend/src/pages/users/users-new.tsx +++ b/frontend/src/pages/users/users-new.tsx @@ -155,6 +155,8 @@ const initialValues = { custom_permissions: [], + + gdpLevel: 1, } @@ -431,6 +433,15 @@ const UsersNew = () => { + + + + + + + + + @@ -499,4 +510,4 @@ UsersNew.getLayout = function getLayout(page: ReactElement) { ) } -export default UsersNew +export default UsersNew \ No newline at end of file