Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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,54 @@ plugins: [
}),
],
...
```
```

## Trigger 2FA from Actions via a Custom Component

Enable a one‑time 2FA prompt before running any AdminForth action by attaching a tiny Vue wrapper via `customComponent`. The plugin exposes a global modal: `window.adminforthTwoFaModal.getCode(cb?)`.

### Minimal Wrapper Component

```ts title='/custom/RequireTwoFaGate.vue'
<template>
<div class="contents" @click.stop.prevent="onClick"><slot /></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 verify2fa(code: string) {
const path = props.meta?.verifyPath ?? '/plugin/twofa/verify';
const resp = await callAdminForthApi({ method: 'POST', path, body: { code } });
return !!resp?.ok;
}

async function onClick() {
if (props.disabled) return;
if (!window.adminforthTwoFaModal?.getCode) { emit('callAction'); return; }
await window.adminforthTwoFaModal.getCode(verify2fa);
emit('callAction');
}
</script>
```

### Attach to an Action

```ts title='/adminuser.ts'
options: {
actions: [
{
name: 'Auto submit',
icon: 'flowbite:play-solid',
allowed: () => true,
action: async ({ recordId, adminUser }) => ({ ok: true, successMessage: 'Auto submitted' }),
showIn: { showButton: true, showThreeDotsMenu: true, list: true },
//diff-add
customComponent: '@@/RequireTwoFaGate.vue',
// or with runtime config:
// customComponent: { name: '@@/RequireTwoFaGate.vue', meta: { verifyPath: '/plugin/twofa/verify' } },
},
],
}
```
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
16 changes: 16 additions & 0 deletions adminforth/spa/src/components/CallActionWrapper.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<template>
<div @click="onClick">
<slot />
</div>
</template>

<script setup lang="ts">
const props = defineProps<{ disabled?: boolean }>();
const emit = defineEmits<{ (e: 'callAction', extra?: any ): void }>();
function onClick() {
if (props.disabled) return;
const extra = { someData: 'example' };
emit('callAction', extra);
Copy link

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.

The hardcoded 'extra' object with example data appears to be placeholder code that should be removed or made configurable.

Suggested change
emit('callAction', extra);
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);

Copilot uses AI. Check for mistakes.
}
</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

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

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">
Copy link

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.

Adding optional chaining (?.) to bulkActions suggests it might be undefined, but this could cause silent failures. Consider checking if bulkActions exists before filtering or provide a default empty array.

Suggested change
<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">

Copilot uses AI. Check for mistakes.
<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>
42 changes: 28 additions & 14 deletions dev-demo/resources/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export default {
dataSource: "maindb",
table: "users",
resourceId: "users",
label: "Users",
label: "Users1",
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.

Corrected label from 'Users1' to 'Users'.

Suggested change
label: "Users1",
label: "Users",

Copilot uses AI. Check for mistakes.

recordLabel: (r: any) => `👤 ${r.email}`,
plugins: [
Expand Down Expand Up @@ -153,19 +153,33 @@ export default {
}),
],
options: {
allowedActions: {
create: async ({
adminUser,
meta,
}: {
adminUser: AdminUser;
meta: any;
}) => {
// console.log('create', adminUser, meta);
return true;
},
delete: true,
},
actions: [
{
name: 'Auto submit', // Display name of the action
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.

[nitpick] Corrected 'submit' to 'Submit' for consistency with action name capitalization conventions.

Suggested change
name: 'Auto submit', // Display name of the action
name: 'Auto Submit', // Display name of the action

Copilot uses AI. Check for mistakes.
icon: 'flowbite:play-solid', // Icon to display (using Flowbite icons)

// Control who can see/use this action
allowed: ({ adminUser, standardAllowedActions }) => {
return true; // Allow everyone
},

// Handler function when action is triggered
action: async ({ recordId, adminUser }) => {
console.log("auto submit", recordId, adminUser);
return {
ok: true,
successMessage: "Auto submitted"
};
},

// Configure where the action appears
showIn: {
showButton: true, // Show as a button
// showThreeDotsMenu: true, // Show in the three dots menu
list: true, // Show in the list view
}
}
],
},
columns: [
{
Expand Down