Skip to content

Feat/bulk action component #323

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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 @@ -205,4 +205,127 @@ 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">
import { callAdminForthApi } from '@/utils';
const emit = defineEmits<{ (e: 'callAction'): void }>();
const props = defineProps<{ disabled?: boolean; meta?: { verifyPath?: string; [k: 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:

```ts title='/adminuser.ts'
options: {
actions: [
{
name: 'Auto submit',
icon: 'flowbite:play-solid',
allowed: () => true,
action: async ({ recordId, adminUser, payload, adminforth }) => {
const { code } = payload;
const totpIsValid = adminforth.getPluginByClassName<>('T2FAPlug').verify(code);
if (!totpIsValid) {
return { ok: false, error: 'TOTP code is invalid' }
}
// we will also register fact of ussage of this critical action using audit log Plugin
getPluginBYClassName<auditlog>.logCustomAction()...
.... 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
<Button @click="callApi">Call critical api</Button>


<script>

async function callAPI() {
const res = await callAdminForthAPI('/myCriticalAction', { param: 1 })
}
</scrip>
```

On backend you have simple express api

```

app.post(
adminforth.authorize(
() => {
req.body.param
... some custom action
}
))
```

You might want to protect this call with a TOTP code. To do it, we need to make this change

```ts
<Button @click="callApi">Call critical api</Button>


<script>

function callAPI() {
// diff-remove
const res = callAdminForthAPI('/myCriticalAction', { param: 1 })
//diff-add
const code = await window.adminforthTwoFaModal.getCode(async (code) => {
//diff-add
const res = await callAdminForthAPI('/myCriticalAction', { param: 1, code })
//diff-add
return !res.totpError
}); // this will ask user to enter code
}
</scrip>
```

And oin API call we need to verify it:


```

app.post(
adminforth.authorize(
() => {
//diff-add
getPBCNM
.. log some critical action
... some custom action
}
))
```
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
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>
35 changes: 29 additions & 6 deletions adminforth/spa/src/components/ResourceListTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -176,13 +176,36 @@
</template>

<template v-if="resource.options?.actions">
<Tooltip v-for="action in resource.options.actions.filter(a => a.showIn?.list)" :key="action.id">
<button
@click="startCustomAction(action.id, row)"
<Tooltip
v-for="action in resource.options.actions.filter(a => a.showIn?.list)"
:key="action.id"
>
<CallActionWrapper
:disabled="rowActionLoadingStates?.[action.id]"
@callAction="startCustomAction(action.id, row)"
>
<component v-if="action.icon" :is="getIcon(action.icon)" class="w-5 h-5 mr-2 text-lightPrimary dark:text-darkPrimary"></component>
</button>
<template v-slot:tooltip>
<component
:is="action.customComponent ? getCustomComponent(action.customComponent) : 'span'"
:meta="action.customComponent?.meta"
:row="row"
:resource="resource"
:adminUser="adminUser"
>
<button
type="button"
:disabled="rowActionLoadingStates?.[action.id]"
@click.stop.prevent="startCustomAction(action.id, row)"
Copy link
Preview

Copilot AI Aug 20, 2025

Choose a reason for hiding this comment

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

[nitpick] The click handler uses both .stop and .prevent modifiers which might be overly restrictive. Consider if both are necessary or if this could interfere with expected event behavior.

Suggested change
@click.stop.prevent="startCustomAction(action.id, row)"
@click.stop="startCustomAction(action.id, row)"

Copilot uses AI. Check for mistakes.

>
<component
v-if="action.icon"
:is="getIcon(action.icon)"
class="w-5 h-5 mr-2 text-lightPrimary dark:text-darkPrimary"
/>
</button>
</component>
</CallActionWrapper>

<template #tooltip>
{{ action.name }}
</template>
</Tooltip>
Expand Down
37 changes: 30 additions & 7 deletions adminforth/spa/src/components/ResourceListTableVirtual.vue
Original file line number Diff line number Diff line change
Expand Up @@ -185,14 +185,37 @@
/>
</template>

<template v-if="resource.options?.actions">
<Tooltip v-for="action in resource.options.actions.filter(a => a.showIn?.list)" :key="action.id">
<button
@click="startCustomAction(action.id, row)"
<template v-if="resource.options?.actions">
<Tooltip
v-for="action in resource.options.actions.filter(a => a.showIn?.list)"
:key="action.id"
>
<CallActionWrapper
:disabled="rowActionLoadingStates?.[action.id]"
@callAction="startCustomAction(action.id, row)"
>
<component v-if="action.icon" :is="getIcon(action.icon)" class="w-5 h-5 mr-2 text-lightPrimary dark:text-darkPrimary"></component>
</button>
<template v-slot:tooltip>
<component
:is="action.customComponent ? getCustomComponent(action.customComponent) : 'span'"
:meta="action.customComponent?.meta"
:row="row"
:resource="resource"
:adminUser="adminUser"
>
<button
type="button"
:disabled="rowActionLoadingStates?.[action.id]"
@click.stop.prevent="startCustomAction(action.id, row)"
Copy link
Preview

Copilot AI Aug 20, 2025

Choose a reason for hiding this comment

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

[nitpick] The click handler uses both .stop and .prevent modifiers which might be overly restrictive. Consider if both are necessary or if this could interfere with expected event behavior.

Suggested change
@click.stop.prevent="startCustomAction(action.id, row)"
@click.prevent="startCustomAction(action.id, row)"

Copilot uses AI. Check for mistakes.

>
<component
v-if="action.icon"
:is="getIcon(action.icon)"
class="w-5 h-5 mr-2 text-lightPrimary dark:text-darkPrimary"
/>
</button>
</component>
</CallActionWrapper>

<template #tooltip>
{{ action.name }}
</template>
</Tooltip>
Expand Down
30 changes: 19 additions & 11 deletions adminforth/spa/src/components/ThreeDotsMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,24 @@
</a>
</li>
<li v-for="action in customActions" :key="action.id">
<a href="#" @click.prevent="handleActionClick(action)" class="block px-4 py-2 hover:text-lightThreeDotsMenuBodyTextHover hover:bg-lightThreeDotsMenuBodyBackgroundHover dark:hover:bg-darkThreeDotsMenuBodyBackgroundHover dark:hover:text-darkThreeDotsMenuBodyTextHover">
<div class="flex items-center gap-2">
<component
v-if="action.icon"
:is="getIcon(action.icon)"
class="w-4 h-4 text-lightPrimary dark:text-darkPrimary"
/>
{{ action.name }}
</div>
</a>
<component
:is="(action.customComponent && getCustomComponent(action.customComponent)) || CallActionWrapper"
:meta="action.customComponent?.meta"
@callAction="handleActionClick(action)"
>
<a href="#" @click.prevent class="block px-4 py-2 hover:text-lightThreeDotsMenuBodyTextHover hover:bg-lightThreeDotsMenuBodyBackgroundHover dark:hover:bg-darkThreeDotsMenuBodyBackgroundHover dark:hover:text-darkThreeDotsMenuBodyTextHover">
<div class="flex items-center gap-2">
<component
v-if="action.icon"
:is="getIcon(action.icon)"
class="w-4 h-4 text-lightPrimary dark:text-darkPrimary"
/>
{{ action.name }}
</div>
</a>
</component>
</li>
<li v-for="action in bulkActions.filter(a => a.showInThreeDotsDropdown)" :key="action.id">
<li v-for="action in (bulkActions ?? []).filter(a => a.showInThreeDotsDropdown)" :key="action.id">
<a href="#" @click.prevent="startBulkAction(action.id)"
class="block px-4 py-2 hover:text-lightThreeDotsMenuBodyTextHover hover:bg-lightThreeDotsMenuBodyBackgroundHover dark:hover:bg-darkThreeDotsMenuBodyBackgroundHover dark:hover:text-darkThreeDotsMenuBodyTextHover"
:class="{
Expand Down Expand Up @@ -65,6 +71,8 @@ import { useCoreStore } from '@/stores/core';
import adminforth from '@/adminforth';
import { callAdminForthApi } from '@/utils';
import { useRoute, useRouter } from 'vue-router';
import CallActionWrapper from '@/components/CallActionWrapper.vue'


const route = useRoute();
const coreStore = useCoreStore();
Expand Down
39 changes: 24 additions & 15 deletions adminforth/spa/src/views/ShowView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,29 @@
:adminUser="coreStore.adminUser"
/>
<BreadcrumbsWithButtons>
<template v-if="coreStore.resource?.options?.bulkActions">
<button
v-for="action in coreStore.resource.options.bulkActions.filter(a => a.showIn?.showButton)"
:key="action.id"
@click="startCustomAction(action.id)"
:disabled="actionLoadingStates[action.id]"
class="flex items-center py-1 px-3 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-default border border-gray-300 hover:bg-gray-100 hover:text-lightPrimary focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
>
<component
v-if="action.icon"
:is="getIcon(action.icon)"
class="w-4 h-4 me-2 text-lightPrimary dark:text-darkPrimary"
/>
{{ action.name }}
</button>
<template v-if="coreStore.resource?.options?.actions">

<template v-for="action in coreStore.resource.options.actions.filter(a => a.showIn?.showButton)" :key="action.id">
<component
:is="getCustomComponent(action.customComponent) || CallActionWrapper"
:meta="action.customComponent?.meta"
@callAction="startCustomAction(action.id)"
:disabled="actionLoadingStates[action.id]"
>
<button
:key="action.id"
:disabled="actionLoadingStates[action.id]"
class="flex items-center py-1 px-3 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-default border border-gray-300 hover:bg-gray-100 hover:text-lightPrimary focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
>
<component
v-if="action.icon"
:is="getIcon(action.icon)"
class="w-4 h-4 me-2 text-lightPrimary dark:text-darkPrimary"
/>
{{ action.name }}
</button>
</component>
</template>
</template>
<RouterLink v-if="coreStore.resource?.options?.allowedActions?.create"
:to="{ name: 'resource-create', params: { resourceId: $route.params.resourceId } }"
Expand Down Expand Up @@ -144,6 +152,7 @@ import adminforth from "@/adminforth";
import { useI18n } from 'vue-i18n';
import { getIcon } from '@/utils';
import { type AdminForthComponentDeclarationFull } from '@/types/Common.js';
import CallActionWrapper from '@/components/CallActionWrapper.vue'

const route = useRoute();
const router = useRouter();
Expand Down
1 change: 1 addition & 0 deletions adminforth/types/Back.ts
Original file line number Diff line number Diff line change
Expand Up @@ -819,6 +819,7 @@ export interface AdminForthActionInput {
}>;
icon?: string;
id?: string;
customComponent?: AdminForthComponentDeclaration;
}

export interface AdminForthResourceInput extends Omit<NonNullable<AdminForthResourceInputCommon>, 'columns' | 'hooks' | 'options'> {
Expand Down
1 change: 1 addition & 0 deletions dev-demo/custom/ShadowLoginButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<span> TEST CUSTOM COMPONENT </span>
Loading