diff --git a/adapters/install-adapters.sh b/adapters/install-adapters.sh index 9fe020066..8fd135ddc 100644 --- a/adapters/install-adapters.sh +++ b/adapters/install-adapters.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash ADAPTERS="adminforth-completion-adapter-open-ai-chat-gpt adminforth-email-adapter-aws-ses \ adminforth-email-adapter-mailgun adminforth-google-oauth-adapter adminforth-github-oauth-adapter \ -adminforth-facebook-oauth-adapter adminforth-keycloak-oauth-adapter adminforth-microsoft-oauth-adapter \ -adminforth-twitch-oauth-adapter adminforth-image-generation-adapter-openai adminforth-storage-adapter-amazon-s3 \ +adminforth-facebook-oauth-adapter adminforth-keycloak-oauth-adapter adminforth-microsoft-oauth-adapter \ +adminforth-twitch-oauth-adapter adminforth-image-generation-adapter-openai adminforth-storage-adapter-amazon-s3 \ adminforth-storage-adapter-local adminforth-image-vision-adapter-openai adminforth-key-value-adapter-ram \ adminforth-login-captcha-adapter-cloudflare adminforth-login-captcha-adapter-recaptcha" diff --git a/adminforth/commands/createCustomComponent/configLoader.js b/adminforth/commands/createCustomComponent/configLoader.js index 6fb8593e4..d7029d783 100644 --- a/adminforth/commands/createCustomComponent/configLoader.js +++ b/adminforth/commands/createCustomComponent/configLoader.js @@ -2,21 +2,20 @@ import fs from 'fs/promises'; import path from 'path'; import chalk from 'chalk'; import jiti from 'jiti'; -import dotenv from "dotenv"; +import dotenv, { config } from "dotenv"; dotenv.config({ path: '.env.local', override: true }); dotenv.config({ path: '.env', override: true }); -export async function loadAdminForthConfig() { +export async function getAdminInstance() { const configFileName = 'index.ts'; const configPath = path.resolve(process.cwd(), configFileName); - try { await fs.access(configPath); } catch (error) { console.error(chalk.red(`\nError: Configuration file not found at ${configPath}`)); console.error(chalk.yellow(`Please ensure you are running this command from your project's root directory and the '${configFileName}' file exists.`)); - process.exit(1); + return null; } try { @@ -29,8 +28,19 @@ export async function loadAdminForthConfig() { const configModule = _require(configPath); const adminInstance = configModule.admin || configModule.default?.admin; + return { adminInstance, configPath, configFileName }; + } catch (error) { + console.error(chalk.red(`\nError loading or parsing configuration file: ${configPath}`)); + console.error(error); + return null; + } +} +export async function loadAdminForthConfig() { + + const { adminInstance, configPath, configFileName } = await getAdminInstance(); + try { if (!adminInstance) { throw new Error(`Could not find 'admin' export in ${configFileName}. Please ensure your config file exports the AdminForth instance like: 'export const admin = new AdminForth({...});'`); } @@ -53,7 +63,7 @@ export async function loadAdminForthConfig() { return config; } catch (error) { - console.error(chalk.red(`\nError loading or parsing configuration file: ${configPath}`)); + console.error(chalk.red(`\nError loading or parsing configuration file: ${configPath}, error: ${error}`)); console.error(error); process.exit(1); } diff --git a/adminforth/commands/createPlugin/templates/package.json.hbs b/adminforth/commands/createPlugin/templates/package.json.hbs index d46c1e5af..42b3f687f 100644 --- a/adminforth/commands/createPlugin/templates/package.json.hbs +++ b/adminforth/commands/createPlugin/templates/package.json.hbs @@ -5,7 +5,7 @@ "types": "dist/index.d.ts", "type": "module", "scripts": { - "build": "tsc && rsync -av --exclude 'node_modules' custom dist/ && npm version patch" + "build": "tsc && rsync -av --exclude 'node_modules' custom dist/" }, "keywords": [], "author": "", diff --git a/adminforth/commands/generateModels.js b/adminforth/commands/generateModels.js index f6ad2637a..c64377889 100755 --- a/adminforth/commands/generateModels.js +++ b/adminforth/commands/generateModels.js @@ -2,6 +2,8 @@ import fs from "fs"; import path from "path"; import { toPascalCase, mapToTypeScriptType, getInstance } from "./utils.js"; import dotenv from "dotenv"; +import { callTsProxy } from "./callTsProxy.js"; +import { getAdminInstance } from "../commands/createCustomComponent/configLoader.js"; const envFileArg = process.argv.find((arg) => arg.startsWith("--env-file=")); const envFilePath = envFileArg ? envFileArg.split("=")[1] : ".env"; @@ -23,39 +25,45 @@ async function generateModels() { } let modelContent = "// Generated model file\n\n"; - const files = fs.readdirSync(currentDirectory); let instanceFound = false; - for (const file of files) { - if (file.endsWith(".js") || file.endsWith(".ts")) { - const instance = await getInstance(file, currentDirectory); - if (instance) { - await instance.discoverDatabases(); - instanceFound = true; - instance.config.resources.forEach((resource) => { - if (resource.columns) { - modelContent += `export type ${toPascalCase( - resource.resourceId - )} = {\n`; - resource.columns.forEach((column) => { - if (column.name && column.type) { - modelContent += ` ${column.name}: ${mapToTypeScriptType( - column.type - )};\n`; + const { adminInstance, configPath, configFileName } = await getAdminInstance(); + if (adminInstance) { + await adminInstance.discoverDatabases(); + instanceFound = true; + for (const resource of adminInstance.config.resources) { + if (resource.columns) { + const typeName = toPascalCase(resource.resourceId); + const tsCode = ` + export async function exec() { + const columns = ${JSON.stringify(resource.columns)}; + const typeName = "${typeName}"; + function mapToTypeScriptType(type) { + const map = { "integer": "number", "varchar": "string", "boolean": "boolean", "date": "string", "datetime": "string", "decimal": "number", "float": "number", "json": "Record", "text": "string", "string": "string", "time": "string" }; + return map[type] || "any"; + } + + let typeStr = \`export type \${typeName} = {\\n\`; + for (const col of columns) { + if (col.name && col.type) { + typeStr += \` \${col.name}: \${mapToTypeScriptType(col.type)};\\n\`; } - }); - modelContent += `}\n\n`; + } + typeStr += "}\\n\\n"; + return typeStr; } - }); + `; + + const result = await callTsProxy(tsCode); + modelContent += result; } - } + }; } if (!instanceFound) { console.error("Error: No valid instance found to generate models."); return; } - fs.writeFileSync(modelFilePath, modelContent, "utf-8"); console.log(`Generated TypeScript model file: ${modelFilePath}`); return true; diff --git a/adminforth/dataConnectors/mongo.ts b/adminforth/dataConnectors/mongo.ts index 52007df83..6e1186172 100644 --- a/adminforth/dataConnectors/mongo.ts +++ b/adminforth/dataConnectors/mongo.ts @@ -189,6 +189,9 @@ class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataS } else if (field.type == AdminForthDataTypes.BOOLEAN) { return value === null ? null : !!value; } else if (field.type == AdminForthDataTypes.DECIMAL) { + if (value === null || value === undefined) { + return null; + } return value?.toString(); } @@ -206,6 +209,9 @@ class MongoConnector extends AdminForthBaseConnector implements IAdminForthDataS } else if (field.type == AdminForthDataTypes.BOOLEAN) { return value === null ? null : (value ? true : false); } else if (field.type == AdminForthDataTypes.DECIMAL) { + if (value === null || value === undefined) { + return null; + } return Decimal128.fromString(value?.toString()); } return value; diff --git a/adminforth/documentation/docs/tutorial/03-Customization/03-virtualColumns.md b/adminforth/documentation/docs/tutorial/03-Customization/03-virtualColumns.md index e6fa5adb6..199036cc9 100644 --- a/adminforth/documentation/docs/tutorial/03-Customization/03-virtualColumns.md +++ b/adminforth/documentation/docs/tutorial/03-Customization/03-virtualColumns.md @@ -142,10 +142,10 @@ This way, when admin selects, for example, "Luxury" option for "Apartment Type" ### Custom SQL queries with `insecureRawSQL` -Rarely the sec of Filters supported by AdminForth is not enough for your needs. +Rarely the set of Filters supported by AdminForth is not enough for your needs. In this case you can use `insecureRawSQL` to write your own part of where clause. -However the vital concern that the SQL passed to DB as is, so if you substitute any user inputs it will not be escaped and can lead to SQL injection. To miticate the issue we recommend using `sqlstring` package which will escape the inputs for you. +However the vital concern that the SQL passed to DB as is, so if you substitute any user inputs it will not be escaped and can lead to SQL injection. To mitigate the issue we recommend using `sqlstring` package which will escape the inputs for you. ```bash npm i sqlstring @@ -163,7 +163,7 @@ import sqlstring from 'sqlstring'; if (filter.field === 'some_json_b_field') { return { // check if some_json_b_field->'$.some_field' is equal to filter.value - insecureRawSQL: `some_json_b_field->'$.some_field' = ${sqlstring.escape(filter.value)}`, + insecureRawSQL: `some_json_b_field->>'$.some_field' = ${sqlstring.escape(filter.value)}`, } } diff --git a/adminforth/documentation/docs/tutorial/03-Customization/11-dataApi.md b/adminforth/documentation/docs/tutorial/03-Customization/11-dataApi.md index d7a370f60..3ed6c92f2 100644 --- a/adminforth/documentation/docs/tutorial/03-Customization/11-dataApi.md +++ b/adminforth/documentation/docs/tutorial/03-Customization/11-dataApi.md @@ -123,6 +123,39 @@ const users = await admin.resource('adminuser').list( ); ``` +## Using a raw SQL in queries. + +Rarely you might want to add ciondition for some exotic SQL but still want to keep the rest of API. +Technically it happened that AdminForth allows you to do this also + +```js +const minUgcAge = 18; +const usersWithNoUgcAccess = await admin.resource('adminuser').list( + [ + Filters.NEQ('role', 'Admin'), + { + insecureRawSQL: `(user_meta->>'age') < ${sqlstring.escape(minUgcAge)}` + } + + ], 15, 0, Sorts.DESC('createdAt') +); + +This will produce next SQL query: +``` + +``` +SELECT * +FROM "adminuser" +WHERE "role" != 'Admin' + AND (user_meta->>'age') < 18 +ORDER BY "createdAt" DESC +LIMIT 15 OFFSET 0; + +``` + + +Finds users with age less then 18 from meta field which should be a JSONB field in Postgress. + ## Create a new item in database Signature: diff --git a/adminforth/documentation/docs/tutorial/07-Plugins/02-TwoFactorsAuth.md b/adminforth/documentation/docs/tutorial/07-Plugins/02-TwoFactorsAuth.md index ae4454f53..cff90e228 100644 --- a/adminforth/documentation/docs/tutorial/07-Plugins/02-TwoFactorsAuth.md +++ b/adminforth/documentation/docs/tutorial/07-Plugins/02-TwoFactorsAuth.md @@ -207,6 +207,238 @@ plugins: [ ... ``` +## Request 2FA on custom Actions + +You might want to to allow to call some custom critical/money related actions with additional 2FA approval. This eliminates risks caused by user cookies theft by some virous/doorway software after login. + +To do it, first, create frontend custom component which wraps and intercepts click event to menu item, and in click handler do a call to `window.adminforthTwoFaModal.getCode(cb?)` frontend API exposed by this plugin. This is awaitable call wich shows 2FA popup and asks user to authenticate with 2nd factor (if passkey is enabled it will be suggested first, with ability to fallback to TOTP) + +```ts title='/custom/RequireTwoFaGate.vue' + + + +``` + +Now we need to use verification result which we got from user on frontend, inside of backend action handler and verify that it is valid (and not expired): + +```ts title='/adminuser.ts' +options: { + actions: [ + { + name: 'Auto submit', + icon: 'flowbite:play-solid', + allowed: () => true, + action: async ({ recordId, adminUser, adminforth, extra, cookies }) => { + //diff-add + const verificationResult = extra?.verificationResult + //diff-add + if (!verificationResult) { + //diff-add + return { ok: false, error: 'No verification result provided' }; + //diff-add + } + //diff-add + const t2fa = adminforth.getPluginByClassName('TwoFactorsAuthPlugin'); + //diff-add + const result = await t2fa.verify(verificationResult, { + //diff-add + adminUser: adminUser, + //diff-add + userPk: adminUser.pk, + //diff-add + cookies: cookies + //diff-add + }); + + //diff-add + if (!result?.ok) { + //diff-add + return { ok: false, error: result?.error ?? 'Provided 2fa verification data is invalid' }; + //diff-add + } + //diff-add + await adminforth + //diff-add + .getPluginByClassName('AuditLogPlugin') + //diff-add + .logCustomAction({ + //diff-add + resourceId: 'aparts', + //diff-add + recordId: null, + //diff-add + actionId: 'visitedDashboard', + //diff-add + oldData: null, + //diff-add + data: { dashboard: 'main' }, + //diff-add + user: adminUser, + //diff-add + }); + + //your critical action logic + + return { ok: true, successMessage: 'Auto submitted' }; + }, + showIn: { showButton: true, showThreeDotsMenu: true, list: true }, + //diff-add + customComponent: '@@/RequireTwoFaGate.vue', + }, + ], +} +``` + +## Request 2FA from custom components + +Imagine you have some button which does some API call + +```ts + + + + +``` + +On backend you have simple express api + +```ts +app.post(`${ADMIN_BASE_URL}/myCriticalAction`, + admin.express.authorize( + async (req: any, res: any) => { + + // ... your critical logic ... + + return res.json({ ok: true, successMessage: 'Action executed' }); + } + ) +); +``` + +You might want to protect this call with a second factor also. To do it, we need to make this change + +```ts + + + + + +``` + +And oin API call we need to verify it: + + +```ts +app.post(`${ADMIN_BASE_URL}/myCriticalAction`, + admin.express.authorize( + async (req: any, res: any) => { + + // diff-add + const { adminUser } = req; + // diff-add + const { param, verificationResult } = req.body ?? {}; + // diff-add + const t2fa = admin.getPluginByClassName('TwoFactorsAuthPlugin'); + // diff-add + const verifyRes = await t2fa.verify(verificationResult, { + // diff-add + adminUser: adminUser, + // diff-add + userPk: adminUser.pk, + // diff-add + cookies: cookies + // diff-add + }); + // diff-add + if (!('ok' in verifyRes)) { + // diff-add + return res.status(400).json({ ok: false, error: verifyRes.error || 'Verification failed' }); + // diff-add + } + // diff-add + await admin.getPluginByClassName('AuditLogPlugin').logCustomAction({ + // diff-add + resourceId: 'aparts', + // diff-add + recordId: null, + // diff-add + actionId: 'myCriticalAction', + // diff-add + oldData: null, + // diff-add + data: { param }, + // diff-add + user: adminUser, + // diff-add + }); + + // ... your critical logic ... + + return res.json({ ok: true, successMessage: 'Action executed' }); + } + ) +); +``` + + ## Custom label prefix in authenticator app By default label prefix in Authenticator app is formed from Adminforth [brandName setting](/docs/tutorial/Customization/branding/) which is best behaviour for most admin apps (always remember to configure brandName correctly e.g. "RoyalFinTech Admin") @@ -231,15 +463,19 @@ First, you need to create a passkeys table in your schema.prisma file: ```ts title='./schema.prisma' //diff-add model passkeys { - //diff-add - credential_id String @id - //diff-add + //diff-add + id String @id + //diff-add + credential_id String + //diff-add user_id String - //diff-add + //diff-add meta String - //diff-add + //diff-add @@index([user_id]) - //diff-add + //diff-add + @@index([credential_id]) + //diff-add } ``` @@ -253,7 +489,8 @@ npm run makemigration -- --name add-passkeys ; npm run migrate:local Next, you need to create a new resource for passkeys: ```ts title='./resources/passkeys.ts' - import { AdminForthDataTypes, AdminForthResourceInput } from "../../adminforth"; + import { AdminForthDataTypes, AdminForthResourceInput } from "adminforth"; + import { randomUUID } from "crypto"; export default { dataSource: 'maindb', @@ -261,10 +498,16 @@ Next, you need to create a new resource for passkeys: resourceId: 'passkeys', label: 'Passkeys', columns: [ + { + name: 'id', + label: 'ID', + primaryKey: true, + showIn: { all: false}, + fillOnCreate: () => randomUUID(), + }, { name: 'credential_id', label: 'Credential ID', - primaryKey: true, }, { name: 'user_id', @@ -303,7 +546,7 @@ Now, update the settings of the Two-Factor Authentication plugin: plugins: [ new TwoFactorsAuthPlugin ({ twoFaSecretFieldName: 'secret2fa', - timeStepWindow: 1 + timeStepWindow: 1, //diff-add passkeys: { //diff-add @@ -317,7 +560,7 @@ Now, update the settings of the Two-Factor Authentication plugin: //diff-add settings: { // diff-add - expectedOrigin: "http://localhost:3000", // important, set it to your backoffice origin (starts from scheme, no slash at the end) + expectedOrigin: "http://localhost:3500", // important, set it to your backoffice origin (starts from scheme, no slash at the end) //diff-add // relying party config //diff-add @@ -352,7 +595,7 @@ Now, update the settings of the Two-Factor Authentication plugin: // diff-add // Can be "platform", "cross-platform" or "both" // diff-add - authenticatorAttachment: "platform", + authenticatorAttachment: "both", //diff-add requireResidentKey: true, //diff-add diff --git a/adminforth/index.ts b/adminforth/index.ts index c1c575e29..28b4dd4d2 100644 --- a/adminforth/index.ts +++ b/adminforth/index.ts @@ -157,6 +157,10 @@ class AdminForth implements IAdminForth { this.activatePlugins(); process.env.HEAVY_DEBUG && console.log('🔧 Plugins activated'); + process.env.HEAVY_DEBUG && console.log('🔧 Validating after plugin activation...'); + this.configValidator.validateAfterPluginsActivation(); + process.env.HEAVY_DEBUG && console.log('🔧 Config validated'); + process.env.HEAVY_DEBUG && console.log('🔧 Creating ExpressServer...'); this.express = new ExpressServer(this); process.env.HEAVY_DEBUG && console.log('🔧 ExpressServer created'); diff --git a/adminforth/modules/codeInjector.ts b/adminforth/modules/codeInjector.ts index ca38cab80..9b5c0b2e8 100644 --- a/adminforth/modules/codeInjector.ts +++ b/adminforth/modules/codeInjector.ts @@ -248,7 +248,12 @@ class CodeInjector implements ICodeInjector { } })); } - + async migrateLegacyCustomLayout(oldMeta) { + if (oldMeta.customLayout === true) { + oldMeta.sidebarAndHeader = "none"; + } + return oldMeta; + } async prepareSources() { // collects all files and folders into SPA_TMP_DIR @@ -315,14 +320,15 @@ class CodeInjector implements ICodeInjector { }; const registerCustomPages = (config) => { if (config.customization.customPages) { - config.customization.customPages.forEach((page) => { + config.customization.customPages.forEach(async (page) => { + const newMeta = await this.migrateLegacyCustomLayout(page?.component?.meta || {}); routes += `{ path: '${page.path}', name: '${page.path}', component: () => import('${page?.component?.file || page.component}'), meta: ${ JSON.stringify({ - ...(page?.component?.meta || {}), + ...newMeta, title: page.meta?.title || page.path.replace('/', '') }) } diff --git a/adminforth/modules/configValidator.ts b/adminforth/modules/configValidator.ts index 9f4e79f66..9a498750a 100644 --- a/adminforth/modules/configValidator.ts +++ b/adminforth/modules/configValidator.ts @@ -34,6 +34,10 @@ export default class ConfigValidator implements IConfigValidator { customComponentsDir: string | undefined; + private static readonly LOGIN_INJECTION_KEYS = ['underInputs', 'underLoginButton', 'panelHeader']; + private static readonly GLOBAL_INJECTION_KEYS = ['userMenu', 'header', 'sidebar', 'sidebarTop', 'everyPageBottom']; + private static readonly PAGE_INJECTION_KEYS = ['beforeBreadcrumbs', 'beforeActionButtons', 'afterBreadcrumbs', 'bottom', 'threeDotsDropdownItems', 'customActionIcons']; + constructor(private adminforth: IAdminForth, private inputConfig: AdminForthInputConfig) { this.adminforth = adminforth; this.inputConfig = inputConfig; @@ -114,21 +118,20 @@ export default class ConfigValidator implements IConfigValidator { const loginPageInjections: AdminForthConfigCustomization['loginPageInjections'] = { underInputs: [], + underLoginButton: [], panelHeader: [], }; if (this.inputConfig.customization?.loginPageInjections) { - const ALLOWED_LOGIN_INJECTIONS = ['underInputs', 'panelHeader'] Object.keys(this.inputConfig.customization.loginPageInjections).forEach((injection) => { - if (ALLOWED_LOGIN_INJECTIONS.includes(injection)) { + if (ConfigValidator.LOGIN_INJECTION_KEYS.includes(injection)) { loginPageInjections[injection] = this.validateAndListifyInjectionNew(this.inputConfig.customization.loginPageInjections, injection, errors); } else { - const similar = suggestIfTypo(ALLOWED_LOGIN_INJECTIONS, injection); - errors.push(`Login page injection key "${injection}" is not allowed. Allowed keys are ${ALLOWED_LOGIN_INJECTIONS.join(', ')}. ${similar ? `Did you mean "${similar}"?` : ''}`); + const similar = suggestIfTypo(ConfigValidator.LOGIN_INJECTION_KEYS, injection); + errors.push(`Login page injection key "${injection}" is not allowed. Allowed keys are ${ConfigValidator.LOGIN_INJECTION_KEYS.join(', ')}. ${similar ? `Did you mean "${similar}"?` : ''}`); } }); } - const globalInjections: AdminForthConfigCustomization['globalInjections'] = { userMenu: [], header: [], @@ -138,13 +141,12 @@ export default class ConfigValidator implements IConfigValidator { }; if (this.inputConfig.customization?.globalInjections) { - const ALLOWED_GLOBAL_INJECTIONS = ['userMenu', 'header', 'sidebar', 'sidebarTop', 'everyPageBottom']; Object.keys(this.inputConfig.customization.globalInjections).forEach((injection) => { - if (ALLOWED_GLOBAL_INJECTIONS.includes(injection)) { + if (ConfigValidator.GLOBAL_INJECTION_KEYS.includes(injection)) { globalInjections[injection] = this.validateAndListifyInjectionNew(this.inputConfig.customization.globalInjections, injection, errors); } else { - const similar = suggestIfTypo(ALLOWED_GLOBAL_INJECTIONS, injection); - errors.push(`Global injection key "${injection}" is not allowed. Allowed keys are ${ALLOWED_GLOBAL_INJECTIONS.join(', ')}. ${similar ? `Did you mean "${similar}"?` : ''}`); + const similar = suggestIfTypo(ConfigValidator.GLOBAL_INJECTION_KEYS, injection); + errors.push(`Global injection key "${injection}" is not allowed. Allowed keys are ${ConfigValidator.GLOBAL_INJECTION_KEYS.join(', ')}. ${similar ? `Did you mean "${similar}"?` : ''}`); } }); } @@ -806,7 +808,6 @@ export default class ConfigValidator implements IConfigValidator { }); // if pageInjection is a string, make array with one element. Also check file exists - const possibleInjections = ['beforeBreadcrumbs', 'beforeActionButtons', 'afterBreadcrumbs', 'bottom', 'threeDotsDropdownItems', 'customActionIcons']; const possiblePages = ['list', 'show', 'create', 'edit']; if (options.pageInjections) { @@ -818,11 +819,11 @@ export default class ConfigValidator implements IConfigValidator { } Object.entries(value).map(([injection, target]) => { - if (possibleInjections.includes(injection)) { - this.validateAndListifyInjection(options.pageInjections[key], injection, errors); + if (ConfigValidator.PAGE_INJECTION_KEYS.includes(injection)) { + options.pageInjections[key][injection] = this.validateAndListifyInjectionNew(options.pageInjections[key], injection, errors); } else { - const similar = suggestIfTypo(possibleInjections, injection); - errors.push(`Resource "${res.resourceId}" has invalid pageInjection key "${injection}", Supported keys are ${possibleInjections.join(', ')} ${similar ? `Did you mean "${similar}"?` : ''}`); + const similar = suggestIfTypo(ConfigValidator.PAGE_INJECTION_KEYS, injection); + errors.push(`Resource "${res.resourceId}" has invalid pageInjection key "${injection}", Supported keys are ${ConfigValidator.PAGE_INJECTION_KEYS.join(', ')} ${similar ? `Did you mean "${similar}"?` : ''}`); } }); @@ -896,6 +897,65 @@ export default class ConfigValidator implements IConfigValidator { } + validateAfterPluginsActivation() { + // Sort all page injections throughout the config by afOrder + this.sortAllPageInjections(); + } + + private sortAllPageInjections(): void { + const config = this.adminforth.config; + + // Sort login page injections + if (config.customization?.loginPageInjections) { + const loginInjections = config.customization.loginPageInjections; + ConfigValidator.LOGIN_INJECTION_KEYS.forEach(key => { + if (loginInjections[key]) { + this.sortInjectionArray(loginInjections[key]); + } + }); + } + + // Sort global injections + if (config.customization?.globalInjections) { + const globalInjections = config.customization.globalInjections; + ConfigValidator.GLOBAL_INJECTION_KEYS.forEach(key => { + if (globalInjections[key]) { + this.sortInjectionArray(globalInjections[key]); + } + }); + } + + // Sort resource page injections + if (config.resources) { + config.resources.forEach(resource => { + if (resource.options?.pageInjections) { + const pageInjections = resource.options.pageInjections; + + // For each page type (list, show, create, edit) + Object.keys(pageInjections).forEach(pageType => { + const pageTypeInjections = pageInjections[pageType]; + if (pageTypeInjections) { + // For each injection point within the page + ConfigValidator.PAGE_INJECTION_KEYS.forEach(injectionKey => { + if (pageTypeInjections[injectionKey]) { + this.sortInjectionArray(pageTypeInjections[injectionKey]); + } + }); + } + }); + } + }); + } + } + + private sortInjectionArray(injections: any): void { + if (Array.isArray(injections)) { + injections.sort((a: AdminForthComponentDeclarationFull, b: AdminForthComponentDeclarationFull) => + (b.meta?.afOrder ?? 0) - (a.meta?.afOrder ?? 0) + ); + } + } + validateConfig() { const errors = []; const warnings = []; diff --git a/adminforth/modules/restApi.ts b/adminforth/modules/restApi.ts index f84b64b29..77b7a6432 100644 --- a/adminforth/modules/restApi.ts +++ b/adminforth/modules/restApi.ts @@ -286,7 +286,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { server.endpoint({ method: 'GET', path: '/get_base_config', - handler: async ({input, adminUser, cookies, tr}): Promise=> { + handler: async ({input, adminUser, cookies, tr, response}): Promise=> { let username = '' let userFullName = '' @@ -295,6 +295,11 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { throw new Error('No config.auth defined'); } + response.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); + response.setHeader('Pragma', 'no-cache'); + response.setHeader('Expires', '0'); + response.setHeader('Surrogate-Control', 'no-store'); + const dbUser = adminUser.dbUser; username = dbUser[this.adminforth.config.auth.usernameField]; userFullName = dbUser[this.adminforth.config.auth.userFullNameField]; diff --git a/adminforth/modules/styles.ts b/adminforth/modules/styles.ts index 62e81b59e..df3ba2bc1 100644 --- a/adminforth/modules/styles.ts +++ b/adminforth/modules/styles.ts @@ -346,6 +346,18 @@ export const styles = () => ({ lightCardTitle: "#374151", lightCardDescription: "#6B7280", + lightUserMenuSettingsButtonBackground: "#FFFFFF", + lightUserMenuSettingsButtonBackgroundHover: "#FFFFFF", + lightUserMenuSettingsButtonBackgroundExpanded: "#E6E6E6", + lightUserMenuSettingsButtonText: "#000000", + lightUserMenuSettingsButtonTextHover: "#000000", + lightUserMenuSettingsButtonDropdownItemBackground: "#E6E6E6", + lightUserMenuSettingsButtonDropdownItemBackgroundHover: "#FFFFFF", + lightUserMenuSettingsButtonDropdownItemText: "alias:lightBreadcrumbsHomepageText", + lightUserMenuSettingsButtonDropdownItemTextHover: "alias:lightBreadcrumbsHomepageTextHover", + + + // colors for dark theme darkHtml: "#111827", @@ -689,6 +701,16 @@ export const styles = () => ({ darkCardTitle: "#FFFFFF", // card title darkCardDescription: "#9CA3AF", // card description + darkUserMenuSettingsButtonBackground: "alias:darkPrimary", + darkUserMenuSettingsButtonBackgroundHover: "alias:darkSidebarItemHover", + darkUserMenuSettingsButtonBackgroundExpanded: "alias:darkUserMenuSettingsButtonBackgroundHover", + darkUserMenuSettingsButtonText: "#FFFFFF", + darkUserMenuSettingsButtonTextHover: "#FFFFFF", + darkUserMenuSettingsButtonDropdownItemBackground: "alias:darkUserMenuSettingsButtonBackgroundHover", + darkUserMenuSettingsButtonDropdownItemBackgroundHover: "#alias:darkUserMenuSettingsButtonBackground", + darkUserMenuSettingsButtonDropdownItemText: "#FFFFFF", + darkUserMenuSettingsButtonDropdownItemTextHover: "#FFFFFF", + }, boxShadow: { customLight: "0 4px 8px rgba(0, 0, 0, 0.1)", // Lighter shadow diff --git a/adminforth/servers/express.ts b/adminforth/servers/express.ts index ac1e70cbb..b8e38b8d1 100644 --- a/adminforth/servers/express.ts +++ b/adminforth/servers/express.ts @@ -269,6 +269,19 @@ class ExpressServer implements IExpressHttpServer { const fullPath = `${this.adminforth.config.baseUrl}/adminapi/v1${path}`; const expressHandler = async (req, res) => { + // Enforce JSON-only for mutation HTTP methods + // AdminForth API endpoints accept only application/json for POST, PUT, PATCH, DELETE + // If you need other content types, use a custom server endpoint. + const method = (req.method || '').toUpperCase(); + if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) { + const contentTypeHeader = (req.headers?.['content-type'] || '').toString(); + const isJson = contentTypeHeader.toLowerCase().startsWith('application/json'); + if (!isJson) { + const passed = contentTypeHeader || 'undefined'; + res.status(415).send(`AdminForth API endpoints support only requests with Content/Type: application/json, when you passed: ${passed}. Please use custom server endpoint if you really need this content type`); + return; + } + } let body = req.body || {}; if (typeof body === 'string') { try { diff --git a/adminforth/spa/src/App.vue b/adminforth/spa/src/App.vue index cd5f543be..67a6eebde 100644 --- a/adminforth/spa/src/App.vue +++ b/adminforth/spa/src/App.vue @@ -83,7 +83,7 @@
+
- + + + \ No newline at end of file diff --git a/adminforth/spa/src/components/ResourceListTableVirtual.vue b/adminforth/spa/src/components/ResourceListTableVirtual.vue index 8933f5cfa..53003d208 100644 --- a/adminforth/spa/src/components/ResourceListTableVirtual.vue +++ b/adminforth/spa/src/components/ResourceListTableVirtual.vue @@ -14,7 +14,7 @@
- +
@@ -74,7 +74,7 @@ :column-widths="columnWidths" /> - +
-