Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -263,4 +263,63 @@ bulkActions: [
},
}
],
```

## Custom Component

If you want to style an action's button/icon without changing its behavior, attach a custom UI wrapper via `customComponent`.
The file points to your SFC in the custom folder (alias `@@/`), and `meta` lets you pass lightweight styling options (e.g., border color, radius).

```ts title="./resources/apartments.ts"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@NoOne7135 lets use mark as listed example as base (with link to original mark as listed action)

{
resourceId: 'aparts',
options: {
actions: [
{
name: 'Auto submit',
icon: 'flowbite:play-solid',
// UI wrapper for the built-in action button
//diff-add
customComponent: {
//diff-add
file: '@@/ActionBorder.vue', // SFC path in your custom folder
//diff-add
meta: { color: '#94a3b8', radius: 10 } // free-form styling params
//diff-add
},
showIn: { list: true, showButton: true, showThreeDotsMenu: true },
action: async ({ recordId, adminUser }) => {
return { ok: true, successMessage: 'Auto submitted' };
}
}
]
}
}
```

Use this minimal wrapper component to add a border/rounding around the default action UI while keeping the action logic intact.
Keep the `<slot />` (that's where AdminForth renders the default button) and emit `callAction` to trigger the handler when the wrapper is clicked.

```ts title="./custom/ActionBorder.vue"
<template>
<!-- Keep the slot: AdminForth renders the default action button/icon here -->
<!-- Emit `callAction` to trigger the action when the wrapper is clicked -->
<div :style="styleObj" @click="emit('callAction')">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@NoOne7135 for verbosity can we use emit('callAction', {}) ? so user will understand maybe better that he will pass payload

<slot />
</div>
</template>

<script setup lang="ts">
import { computed } from 'vue';

const props = defineProps<{ meta?: { color?: string; radius?: number; padding?: number } }>();
const emit = defineEmits<{ (e: 'callAction', payload?: unknown): void }>();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@NoOne7135 payload any here?


const styleObj = computed(() => ({
display: 'inline-block',
border: `1px solid ${props.meta?.color ?? '#e5e7eb'}`,
borderRadius: (props.meta?.radius ?? 8) + 'px',
padding: (props.meta?.padding ?? 2) + 'px',
}));
</script>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@NoOne7135 lets add one more example which demonstrates how to pass dynamic value from frontend to backend.

@click="emit('callAction', {asListed: true})"

@click="emit('callAction', {asListed: false})"

and how to access it on backend in handler - using extra

```
Original file line number Diff line number Diff line change
Expand Up @@ -205,4 +205,244 @@ 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 risk that user cookies might be stolen by some virous/doorway software after login.

To do it you first need to create custom component which will call `window.adminforthTwoFaModal.getCode(cb?)` frontend API exposed by this plugin. This is awaitable call wich shows 2FA popup and asks user to enter a code.

```ts title='/custom/RequireTwoFaGate.vue'
<template>
<div class="contents" @click.stop.prevent="onClick">
<slot /> <!-- render action defgault contend - button/icon -->
</div>
</template>

<script setup lang="ts">
const emit = defineEmits<{ (e: 'callAction', payload?: any): void }>();
const props = defineProps<{ disabled?: boolean; meta?: Record<string, any> }>();

async function onClick() {
if (props.disabled) return;

const code = await window.adminforthTwoFaModal.getCode(); // this will ask user to enter code
emit('callAction', { code }); // then we pass this code to action (from fronted to backend)
}
</script>
```

Now we need to read code entered on fronted on backend and verify that is is valid and not expired, on backend action handler:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now we need to use code which we got from user on fronted, inside of backend action handler and verify that is 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 }) => {
//diff-add
const code = extra?.code
//diff-add
if (!code) {
//diff-add
return { ok: false, error: 'No TOTP code provided' };
//diff-add
}
//diff-add
const t2fa = adminforth.getPluginByClassName<TwoFactorsAuthPlugin>('TwoFactorsAuthPlugin');
//diff-add
const result = await t2fa.verify(code, { adminUser });

//diff-add
if (!result?.ok) {
//diff-add
return { ok: false, error: result?.error ?? 'TOTP code is invalid' };
//diff-add
}
//diff-add
await adminforth
//diff-add
.getPluginByClassName<AuditLogPlugin>('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
<template>
<Button @click="callAdminAPI">Call critical API</Button>
</template>


<script setup lang="ts">
import { callApi } from '@/utils';
import adminforth from '@/adminforth';

async function callAdminAPI() {
const code = await window.adminforthTwoFaModal.getCode();

const res = await callApi({
path: '/myCriticalAction',
method: 'POST',
body: { param: 1 },
});
}
</script>
```

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 TOTP code. To do it, we need to make this change

```ts
<template>
<Button @click="callAdminAPI">Call critical API</Button>
</template>


<script setup lang="ts">
import { callApi } from '@/utils';
import adminforth from '@/adminforth';

async function callAdminAPI() {
const code = await window.adminforthTwoFaModal.getCode();

// diff-remove
const res = await callApi({
// diff-remove
path: '/myCriticalAction',
// diff-remove
method: 'POST',
// diff-remove
body: { param: 1 },
// diff-remove
});

// diff-add
const res = await callApi({
// diff-add
path: '/myCriticalAction',
// diff-add
method: 'POST',
// diff-add
body: { param: 1, code: String(code) },
// diff-add
});

// diff-add
if (!res?.ok) {
// diff-add
adminforth.alert({ message: res.error, variant: 'danger' });
// diff-add
}
}
</script>

```

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-remove
// ... your critical logic ...

// diff-remove
return res.json({ ok: true, successMessage: 'Action executed' });

// diff-add
const { adminUser } = req;
// diff-add
const { param, code } = req.body ?? {};
// diff-add
const token = String(code ?? '').replace(/\D/g, '');
// diff-add
if (token.length !== 6) {
// diff-add
return res.status(401).json({ ok: false, error: 'TOTP must be 6 digits' });
// diff-add
}
// diff-add
const t2fa = admin.getPluginByClassName<TwoFactorsAuthPlugin>('TwoFactorsAuthPlugin');
// diff-add
const verifyRes = await t2fa.verify(token, { adminUser });
// diff-add
if (!('ok' in verifyRes)) {
// diff-add
return res.status(400).json({ ok: false, error: verifyRes.error || 'Wrong or expired OTP code' });
// diff-add
}
// diff-add
await admin.getPluginByClassName<AuditLogPlugin>('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
});

// diff-add
// ... your critical logic ...

// diff-add
return res.json({ ok: true, successMessage: 'Action executed' });
}
)
);
```
13 changes: 13 additions & 0 deletions adminforth/modules/codeInjector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,19 @@ class CodeInjector implements ICodeInjector {
});
}
});
resource.options.actions.forEach((action) => {
const cc = action.customComponent;
if (!cc) return;

const file = (typeof cc === 'string') ? cc : cc.file;
if (!file) {
throw new Error('customComponent.file is missing for action: ' + JSON.stringify({ id: action.id, name: action.name }));
}
if (!customResourceComponents.includes(file)) {
console.log('Found injection', file);
Copy link

Copilot AI Oct 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Console.log statement should be removed or replaced with proper logging mechanism for production code.

Copilot uses AI. Check for mistakes.
customResourceComponents.push(file);
}
});

(Object.values(resource.options?.pageInjections || {})).forEach((injection) => {
Object.values(injection).forEach((filePathes: {file: string}[]) => {
Expand Down
6 changes: 5 additions & 1 deletion adminforth/modules/configValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,14 +375,18 @@ export default class ConfigValidator implements IConfigValidator {
if (!action.name) {
errors.push(`Resource "${res.resourceId}" has action without name`);
}

if (!action.action && !action.url) {
errors.push(`Resource "${res.resourceId}" action "${action.name}" must have action or url`);
}

if (action.action && action.url) {
errors.push(`Resource "${res.resourceId}" action "${action.name}" cannot have both action and url`);
}

if (action.customComponent) {
action.customComponent = this.validateComponent(action.customComponent as any, errors);
}

// Generate ID if not present
if (!action.id) {
Expand Down
4 changes: 2 additions & 2 deletions adminforth/modules/restApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1423,7 +1423,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
method: 'POST',
path: '/start_custom_action',
handler: async ({ body, adminUser, tr }) => {
const { resourceId, actionId, recordId } = body;
const { resourceId, actionId, recordId, extra } = body;
const resource = this.adminforth.config.resources.find((res) => res.resourceId == resourceId);
if (!resource) {
return { error: await tr(`Resource {resourceId} not found`, 'errors', { resourceId }) };
Expand Down Expand Up @@ -1454,7 +1454,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
redirectUrl: action.url
}
}
const response = await action.action({ recordId, adminUser, resource, tr, adminforth: this.adminforth });
const response = await action.action({ recordId, adminUser, resource, tr, adminforth: this.adminforth, extra });

return {
actionId,
Expand Down
15 changes: 15 additions & 0 deletions adminforth/spa/src/components/CallActionWrapper.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<template>
<div @click="onClick">
<slot />
</div>
</template>

<script setup lang="ts">
const props = defineProps<{ disabled?: boolean, extra?: any }>();
const emit = defineEmits<{ (e: 'callAction', extra?: any ): void }>();

function onClick() {
if (props.disabled) return;
emit('callAction', props.extra);
}
</script>
Loading