diff --git a/controller/admin/I18nPreferencesController.js b/controller/admin/I18nPreferencesController.js new file mode 100644 index 0000000..72ca719 --- /dev/null +++ b/controller/admin/I18nPreferencesController.js @@ -0,0 +1,106 @@ +const I18nPreferencesRepository = require('../../repositories/settings/I18nPreferencesRepository'); +const { logger } = require('../../middleware/logger'); + +const repo = new I18nPreferencesRepository(); + +class I18nPreferencesController { + static _normalizeStringArray(values) { + if (!Array.isArray(values)) return []; + const normalized = values + .map((v) => (v == null ? '' : String(v).trim())) + .filter(Boolean); + return [...new Set(normalized)]; + } + + static _normalizeCategories(categories) { + if (!Array.isArray(categories)) return []; + + return categories + .map((item, idx) => { + const id = String(item?.id ?? '').trim() || `category_${idx + 1}`; + const label = String(item?.label ?? id).trim() || id; + const namespaces = I18nPreferencesController._normalizeStringArray(item?.namespaces); + const isCustom = Boolean(item?.isCustom); + return { id, label, namespaces, isCustom }; + }); + } + + static _buildResponse(preferences) { + return { + ok: true, + preferences, + categories: preferences.categories, + globalKeys: preferences.globalKeys, + }; + } + + static async get(req, res) { + try { + const preferences = await repo.get(); + return res.status(200).json(I18nPreferencesController._buildResponse(preferences)); + } catch (error) { + logger.error('i18nPreferences:get:failed', { error: error?.message }); + return res.status(500).json({ ok: false, message: 'Failed to load i18n preferences' }); + } + } + + static async post(req, res) { + try { + const categories = I18nPreferencesController._normalizeCategories(req.body?.categories); + const globalKeys = I18nPreferencesController._normalizeStringArray(req.body?.globalKeys); + + const preferences = await repo.upsert({ + categories, + globalKeys, + updatedByUserId: req.user?.userId ?? req.user?.id ?? null, + }); + + return res.status(200).json(I18nPreferencesController._buildResponse(preferences)); + } catch (error) { + logger.error('i18nPreferences:post:failed', { error: error?.message }); + return res.status(500).json({ ok: false, message: 'Failed to save i18n preferences' }); + } + } + + static async put(req, res) { + try { + const categories = I18nPreferencesController._normalizeCategories(req.body?.categories); + const globalKeys = I18nPreferencesController._normalizeStringArray(req.body?.globalKeys); + + const preferences = await repo.upsert({ + categories, + globalKeys, + updatedByUserId: req.user?.userId ?? req.user?.id ?? null, + }); + + return res.status(200).json(I18nPreferencesController._buildResponse(preferences)); + } catch (error) { + logger.error('i18nPreferences:put:failed', { error: error?.message }); + return res.status(500).json({ ok: false, message: 'Failed to update i18n preferences' }); + } + } + + static async delete(req, res) { + try { + const hasBodyReplacement = req.body && ( + Object.prototype.hasOwnProperty.call(req.body, 'categories') || + Object.prototype.hasOwnProperty.call(req.body, 'globalKeys') + ); + + const preferences = hasBodyReplacement + ? await repo.upsert({ + categories: I18nPreferencesController._normalizeCategories(req.body?.categories), + globalKeys: I18nPreferencesController._normalizeStringArray(req.body?.globalKeys), + updatedByUserId: req.user?.userId ?? req.user?.id ?? null, + }) + : await repo.clear(req.user?.userId ?? req.user?.id ?? null); + + return res.status(200).json(I18nPreferencesController._buildResponse(preferences)); + } catch (error) { + logger.error('i18nPreferences:delete:failed', { error: error?.message }); + return res.status(500).json({ ok: false, message: 'Failed to delete i18n preferences' }); + } + } +} + +module.exports = I18nPreferencesController; diff --git a/controller/login/LoginController.js b/controller/login/LoginController.js index f65c84d..e923dbb 100644 --- a/controller/login/LoginController.js +++ b/controller/login/LoginController.js @@ -261,6 +261,56 @@ class LoginController { return res.status(500).json({ success: false, message: 'Internal server error' }); } } + + static async validate(req, res) { + try { + const refreshToken = req.cookies?.refreshToken; + if (!refreshToken) { + return res.status(401).json({ ok: false, message: 'No refresh token provided' }); + } + + const result = await LoginService.validateByRefreshToken(refreshToken); + const user = result?.user || {}; + + const role = String(user.role || '').toLowerCase(); + const userRoles = Array.isArray(user.roles) + ? user.roles.map((r) => String(r).toLowerCase()) + : []; + const mergedRoles = [...new Set([role, ...userRoles].filter(Boolean))]; + + const isAdmin = Boolean( + user.isAdmin || + role === 'admin' || + role === 'super_admin' || + role === 'superadmin' || + mergedRoles.includes('admin') || + mergedRoles.includes('super_admin') || + mergedRoles.includes('superadmin') + ); + + return res.status(200).json({ + ok: true, + user: { + id: String(user.id ?? ''), + role: user.role || null, + isAdmin, + roles: mergedRoles, + }, + isAdmin, + role: user.role || null, + roles: mergedRoles, + }); + } catch (error) { + if (error.status) { + return res.status(error.status).json({ ok: false, message: error.message }); + } + logger.error('authValidate:error', { + message: error?.message, + stack: error?.stack, + }); + return res.status(500).json({ ok: false, message: 'Internal server error' }); + } + } } module.exports = LoginController; \ No newline at end of file diff --git a/database/createDb.js b/database/createDb.js index d662f78..f98ee49 100644 --- a/database/createDb.js +++ b/database/createDb.js @@ -829,6 +829,38 @@ const createDatabase = async () => { await addColumnIfMissing(connection, 'company_settings', 'qr_code_60_base64', 'LONGTEXT NULL'); await addColumnIfMissing(connection, 'company_settings', 'qr_code_120_base64', 'LONGTEXT NULL'); + // --- I18n Preferences (single-row, admin language-management settings) --- + await connection.query(` + CREATE TABLE IF NOT EXISTS i18n_preferences ( + id TINYINT PRIMARY KEY DEFAULT 1, + categories_json JSON NULL, + global_keys_json JSON NULL, + updated_by_user_id INT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT chk_i18n_preferences_singleton CHECK (id = 1), + FOREIGN KEY (updated_by_user_id) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE + ); + `); + await connection.query(` + INSERT IGNORE INTO i18n_preferences (id, categories_json, global_keys_json, updated_by_user_id) + VALUES (1, JSON_ARRAY(), JSON_ARRAY(), NULL); + `); + console.log('✅ i18n preferences table created/verified'); + + // Backward-compatible for older schemas + await addColumnIfMissing(connection, 'i18n_preferences', 'categories_json', 'JSON NULL'); + await addColumnIfMissing(connection, 'i18n_preferences', 'global_keys_json', 'JSON NULL'); + await addColumnIfMissing(connection, 'i18n_preferences', 'updated_by_user_id', 'INT NULL'); + + await addForeignKeyIfMissing( + connection, + 'i18n_preferences', + 'i18n_preferences_ibfk_1', + `ALTER TABLE \`i18n_preferences\` + ADD CONSTRAINT \`i18n_preferences_ibfk_1\` FOREIGN KEY (\`updated_by_user_id\`) REFERENCES \`users\`(\`id\`) ON DELETE SET NULL ON UPDATE CASCADE` + ); + // --- Dashboard Platforms (admin managed dashboard cards) --- await connection.query(` CREATE TABLE IF NOT EXISTS dashboard_plattforms ( diff --git a/repositories/settings/I18nPreferencesRepository.js b/repositories/settings/I18nPreferencesRepository.js new file mode 100644 index 0000000..0b3343b --- /dev/null +++ b/repositories/settings/I18nPreferencesRepository.js @@ -0,0 +1,67 @@ +const db = require('../../database/database'); + +class I18nPreferencesRepository { + _safeJsonArray(value) { + if (Array.isArray(value)) return value; + if (value == null) return []; + + try { + const parsed = typeof value === 'string' ? JSON.parse(value) : value; + return Array.isArray(parsed) ? parsed : []; + } catch (_) { + return []; + } + } + + _normalizeRow(row) { + const categories = this._safeJsonArray(row?.categories_json); + const globalKeys = this._safeJsonArray(row?.global_keys_json); + return { + categories, + globalKeys, + }; + } + + async get() { + const [rows] = await db.query('SELECT * FROM i18n_preferences WHERE id = 1 LIMIT 1'); + if (!rows.length) { + return { categories: [], globalKeys: [] }; + } + return this._normalizeRow(rows[0]); + } + + async upsert({ categories, globalKeys, updatedByUserId } = {}) { + const current = await this.get(); + + const nextCategories = categories !== undefined ? categories : current.categories; + const nextGlobalKeys = globalKeys !== undefined ? globalKeys : current.globalKeys; + + await db.query( + `INSERT INTO i18n_preferences (id, categories_json, global_keys_json, updated_by_user_id) + VALUES (1, ?, ?, ?) + ON DUPLICATE KEY UPDATE + categories_json = VALUES(categories_json), + global_keys_json = VALUES(global_keys_json), + updated_by_user_id = VALUES(updated_by_user_id)`, + [JSON.stringify(nextCategories || []), JSON.stringify(nextGlobalKeys || []), updatedByUserId || null] + ); + + return this.get(); + } + + async clear(updatedByUserId) { + await db.query( + `INSERT INTO i18n_preferences (id, categories_json, global_keys_json, updated_by_user_id) + VALUES (1, ?, ?, ?) + ON DUPLICATE KEY UPDATE + categories_json = VALUES(categories_json), + global_keys_json = VALUES(global_keys_json), + updated_by_user_id = VALUES(updated_by_user_id)`, + [JSON.stringify([]), JSON.stringify([]), updatedByUserId || null] + ); + + return this.get(); + } +} + +module.exports = I18nPreferencesRepository; diff --git a/routes/deleteRoutes.js b/routes/deleteRoutes.js index 79ff077..f49a657 100644 --- a/routes/deleteRoutes.js +++ b/routes/deleteRoutes.js @@ -10,6 +10,10 @@ const CoffeeController = require('../controller/admin/CoffeeController'); const AffiliateController = require('../controller/affiliate/AffiliateController'); const NewsController = require('../controller/news/NewsController'); const PoolController = require('../controller/pool/PoolController'); +const I18nPreferencesController = require('../controller/admin/I18nPreferencesController'); +const { getRouterPathFromApiEnv } = require('../utils/apiPath'); + +const I18N_PREFERENCES_ROUTE_PATH = getRouterPathFromApiEnv('BACKEND_I18N_PREFERENCES_PATH', '/api/admin/i18n/preferences'); // Helper middlewares for company-stamp function forceCompanyForAdmin(req, res, next) { @@ -37,5 +41,6 @@ router.delete('/admin/news/:id', authMiddleware, adminOnly, NewsController.delet // Admin: remove pool members router.delete('/admin/pools/:id/members', authMiddleware, adminOnly, PoolController.removeMembers); +router.delete(I18N_PREFERENCES_ROUTE_PATH, authMiddleware, adminOnly, I18nPreferencesController.delete); module.exports = router; diff --git a/routes/getRoutes.js b/routes/getRoutes.js index 9f32e0a..5937e9f 100644 --- a/routes/getRoutes.js +++ b/routes/getRoutes.js @@ -30,6 +30,12 @@ const DevManagementController = require('../controller/dev/DevManagementControll const CompanySettingsController = require('../controller/admin/CompanySettingsController'); const DashboardPlatformsController = require('../controller/admin/DashboardPlatformsController'); const ShippingFeesController = require('../controller/admin/ShippingFeesController'); +const LoginController = require('../controller/login/LoginController'); +const I18nPreferencesController = require('../controller/admin/I18nPreferencesController'); +const { getRouterPathFromApiEnv } = require('../utils/apiPath'); + +const AUTH_VALIDATE_ROUTE_PATH = getRouterPathFromApiEnv('BACKEND_AUTH_VALIDATE_PATH', '/api/auth/validate'); +const I18N_PREFERENCES_ROUTE_PATH = getRouterPathFromApiEnv('BACKEND_I18N_PREFERENCES_PATH', '/api/admin/i18n/preferences'); // small helpers copied from original files @@ -44,6 +50,7 @@ function forceCompanyForAdmin(req, res, next) { // === GET routes moved from other files === // auth.js GETs +router.get(AUTH_VALIDATE_ROUTE_PATH, LoginController.validate); router.get('/me', authMiddleware, UserController.getMe); router.get('/user/status', authMiddleware, UserStatusController.getStatus); router.get('/user/status-progress', authMiddleware, UserStatusController.getStatusProgress); @@ -53,6 +60,7 @@ router.get('/users/:id/permissions', authMiddleware, PermissionController.getUse router.get('/admin/users/:id/full', authMiddleware, adminOnly, AdminUserController.getFullUserAccountDetails); router.get('/admin/users/:id/detailed', authMiddleware, adminOnly, AdminUserController.getDetailedUserInfo); router.get('/admin/company-settings', authMiddleware, adminOnly, CompanySettingsController.get); +router.get(I18N_PREFERENCES_ROUTE_PATH, authMiddleware, adminOnly, I18nPreferencesController.get); router.get('/users/:id/documents', authMiddleware, UserController.getUserDocumentsAndContracts); router.get('/verify-password-reset', (req, res) => { /* Note: was moved from PasswordResetController.verifyPasswordResetToken */ res.status(204).end(); }); // keep placeholder if controller already registered via other verb diff --git a/routes/postRoutes.js b/routes/postRoutes.js index bba38c8..12dcfea 100644 --- a/routes/postRoutes.js +++ b/routes/postRoutes.js @@ -32,6 +32,10 @@ const NewsController = require('../controller/news/NewsController'); const InvoiceController = require('../controller/invoice/InvoiceController'); // NEW const DevManagementController = require('../controller/dev/DevManagementController'); const DashboardPlatformsController = require('../controller/admin/DashboardPlatformsController'); +const I18nPreferencesController = require('../controller/admin/I18nPreferencesController'); +const { getRouterPathFromApiEnv } = require('../utils/apiPath'); + +const I18N_PREFERENCES_ROUTE_PATH = getRouterPathFromApiEnv('BACKEND_I18N_PREFERENCES_PATH', '/api/admin/i18n/preferences'); const multer = require('multer'); const upload = multer({ storage: multer.memoryStorage() }); @@ -82,6 +86,7 @@ router.post('/profile/company/complete', authMiddleware, CompanyProfileControlle // Admin POSTs (moved from routes/admin.js) router.post('/admin/verify-user/:id', authMiddleware, adminOnly, AdminUserController.verifyUser); +router.post(I18N_PREFERENCES_ROUTE_PATH, authMiddleware, adminOnly, I18nPreferencesController.post); router.post('/admin/send-password-reset/:userId', authMiddleware, adminOnly, async (req, res) => { const userId = req.params.userId; // require here to avoid circular/top-level ordering issues diff --git a/routes/putRoutes.js b/routes/putRoutes.js index de17da9..62158f7 100644 --- a/routes/putRoutes.js +++ b/routes/putRoutes.js @@ -9,6 +9,10 @@ const CoffeeController = require('../controller/admin/CoffeeController'); const CompanySettingsController = require('../controller/admin/CompanySettingsController'); const DashboardPlatformsController = require('../controller/admin/DashboardPlatformsController'); const ShippingFeesController = require('../controller/admin/ShippingFeesController'); +const I18nPreferencesController = require('../controller/admin/I18nPreferencesController'); +const { getRouterPathFromApiEnv } = require('../utils/apiPath'); + +const I18N_PREFERENCES_ROUTE_PATH = getRouterPathFromApiEnv('BACKEND_I18N_PREFERENCES_PATH', '/api/admin/i18n/preferences'); const multer = require('multer'); const upload = multer({ storage: multer.memoryStorage() }); @@ -28,5 +32,6 @@ router.put('/admin/dashboard-platforms/:id', authMiddleware, adminOnly, Dashboar // Admin: update shipping fee for a piece count (60/120) router.put('/admin/shipping-fees/:pieceCount', authMiddleware, adminOnly, ShippingFeesController.updatePrice); +router.put(I18N_PREFERENCES_ROUTE_PATH, authMiddleware, adminOnly, I18nPreferencesController.put); module.exports = router; diff --git a/services/login/LoginService.js b/services/login/LoginService.js index e37c5dd..3e8c66a 100644 --- a/services/login/LoginService.js +++ b/services/login/LoginService.js @@ -221,6 +221,63 @@ class LoginService { throw error; } } + + static async validateByRefreshToken(refreshToken) { + logger.info('LoginService.validateByRefreshToken:start'); + const unitOfWork = new UnitOfWork(); + await unitOfWork.start(); + unitOfWork.registerRepository('login', new LoginRepository(unitOfWork)); + unitOfWork.registerRepository('user', new UserRepository(unitOfWork)); + unitOfWork.registerRepository('status', new UserStatusRepository(unitOfWork)); + + try { + const loginRepo = unitOfWork.getRepository('login'); + const tokenRecord = await loginRepo.findRefreshToken(refreshToken); + if (!tokenRecord) { + const error = new Error('Invalid or expired refresh token'); + error.status = 401; + throw error; + } + + if (new Date(tokenRecord.expires_at) < new Date()) { + const error = new Error('Refresh token expired'); + error.status = 401; + throw error; + } + + const userRepo = unitOfWork.getRepository('user'); + const user = await userRepo.findUserByEmailOrId(tokenRecord.user_id); + if (!user) { + const error = new Error('User not found'); + error.status = 401; + throw error; + } + + const statusRepo = unitOfWork.getRepository('status'); + const userStatus = await statusRepo.getStatusByUserId(user.id); + if (userStatus && userStatus.status === 'suspended') { + const error = new Error('Account suspended'); + error.status = 403; + throw error; + } + + const role = await loginRepo.getUserRole(user.id); + const permissions = await loginRepo.getUserPermissions(user.id); + + await unitOfWork.commit(); + + return { + user: { + ...user.getPublicData(), + role, + permissions, + }, + }; + } catch (error) { + await unitOfWork.rollback(error); + throw error; + } + } } // Helper for finding user by id or email diff --git a/utils/apiPath.js b/utils/apiPath.js new file mode 100644 index 0000000..3c68681 --- /dev/null +++ b/utils/apiPath.js @@ -0,0 +1,22 @@ +function toRouterPath(configuredPath, defaultApiPath) { + const fallback = String(defaultApiPath || '/').trim() || '/'; + const raw = String(configuredPath || fallback).trim() || fallback; + + let path = raw.startsWith('/') ? raw : `/${raw}`; + + // Routers are mounted under /api in server.js, so strip an optional /api prefix. + if (path === '/api') path = '/'; + if (path.startsWith('/api/')) path = path.slice(4); + + if (path.length > 1) path = path.replace(/\/+$/, ''); + return path || '/'; +} + +function getRouterPathFromApiEnv(envKey, defaultApiPath) { + return toRouterPath(process.env[envKey], defaultApiPath); +} + +module.exports = { + toRouterPath, + getRouterPathFromApiEnv, +};