Skip to content

Commit c83ee3b

Browse files
committed
feat: add CallActionWrapper component and integrate with action menus
1 parent e850dee commit c83ee3b

File tree

7 files changed

+157
-47
lines changed

7 files changed

+157
-47
lines changed

adminforth/documentation/docs/tutorial/07-Plugins/02-TwoFactorsAuth.md

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,4 +205,54 @@ plugins: [
205205
}),
206206
],
207207
...
208-
```
208+
```
209+
210+
## Trigger 2FA from Actions via a Custom Component
211+
212+
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?)`.
213+
214+
### Minimal Wrapper Component
215+
216+
```ts title='/custom/RequireTwoFaGate.vue'
217+
<template>
218+
<div class="contents" @click.stop.prevent="onClick"><slot /></div>
219+
</template>
220+
<script setup lang="ts">
221+
import { callAdminForthApi } from '@/utils';
222+
const emit = defineEmits<{ (e: 'callAction'): void }>();
223+
const props = defineProps<{ disabled?: boolean; meta?: { verifyPath?: string; [k: string]: any } }>();
224+
225+
async function verify2fa(code: string) {
226+
const path = props.meta?.verifyPath ?? '/plugin/twofa/verify';
227+
const resp = await callAdminForthApi({ method: 'POST', path, body: { code } });
228+
return !!resp?.ok;
229+
}
230+
231+
async function onClick() {
232+
if (props.disabled) return;
233+
if (!window.adminforthTwoFaModal?.getCode) { emit('callAction'); return; }
234+
await window.adminforthTwoFaModal.getCode(verify2fa);
235+
emit('callAction');
236+
}
237+
</script>
238+
```
239+
240+
### Attach to an Action
241+
242+
```ts title='/adminuser.ts'
243+
options: {
244+
actions: [
245+
{
246+
name: 'Auto submit',
247+
icon: 'flowbite:play-solid',
248+
allowed: () => true,
249+
action: async ({ recordId, adminUser }) => ({ ok: true, successMessage: 'Auto submitted' }),
250+
showIn: { showButton: true, showThreeDotsMenu: true, list: true },
251+
//diff-add
252+
customComponent: '@@/RequireTwoFaGate.vue',
253+
// or with runtime config:
254+
// customComponent: { name: '@@/RequireTwoFaGate.vue', meta: { verifyPath: '/plugin/twofa/verify' } },
255+
},
256+
],
257+
}
258+
```

adminforth/modules/configValidator.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -375,14 +375,18 @@ export default class ConfigValidator implements IConfigValidator {
375375
if (!action.name) {
376376
errors.push(`Resource "${res.resourceId}" has action without name`);
377377
}
378-
378+
379379
if (!action.action && !action.url) {
380380
errors.push(`Resource "${res.resourceId}" action "${action.name}" must have action or url`);
381381
}
382382

383383
if (action.action && action.url) {
384384
errors.push(`Resource "${res.resourceId}" action "${action.name}" cannot have both action and url`);
385385
}
386+
387+
if (action.customComponent) {
388+
action.customComponent = this.validateComponent(action.customComponent as any, errors);
389+
}
386390

387391
// Generate ID if not present
388392
if (!action.id) {
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<template>
2+
<div @click="onClick">
3+
<slot />
4+
</div>
5+
</template>
6+
7+
<script setup lang="ts">
8+
const props = defineProps<{ disabled?: boolean }>();
9+
const emit = defineEmits<{ (e: 'callAction', extra?: any ): void }>();
10+
11+
function onClick() {
12+
if (props.disabled) return;
13+
const extra = { someData: 'example' };
14+
emit('callAction', extra);
15+
}
16+
</script>

adminforth/spa/src/components/ResourceListTable.vue

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -176,13 +176,36 @@
176176
</template>
177177

178178
<template v-if="resource.options?.actions">
179-
<Tooltip v-for="action in resource.options.actions.filter(a => a.showIn?.list)" :key="action.id">
180-
<button
181-
@click="startCustomAction(action.id, row)"
179+
<Tooltip
180+
v-for="action in resource.options.actions.filter(a => a.showIn?.list)"
181+
:key="action.id"
182+
>
183+
<CallActionWrapper
184+
:disabled="rowActionLoadingStates?.[action.id]"
185+
@callAction="startCustomAction(action.id, row)"
182186
>
183-
<component v-if="action.icon" :is="getIcon(action.icon)" class="w-5 h-5 mr-2 text-lightPrimary dark:text-darkPrimary"></component>
184-
</button>
185-
<template v-slot:tooltip>
187+
<component
188+
:is="action.customComponent ? getCustomComponent(action.customComponent) : 'span'"
189+
:meta="action.customComponent?.meta"
190+
:row="row"
191+
:resource="resource"
192+
:adminUser="adminUser"
193+
>
194+
<button
195+
type="button"
196+
:disabled="rowActionLoadingStates?.[action.id]"
197+
@click.stop.prevent="startCustomAction(action.id, row)"
198+
>
199+
<component
200+
v-if="action.icon"
201+
:is="getIcon(action.icon)"
202+
class="w-5 h-5 mr-2 text-lightPrimary dark:text-darkPrimary"
203+
/>
204+
</button>
205+
</component>
206+
</CallActionWrapper>
207+
208+
<template #tooltip>
186209
{{ action.name }}
187210
</template>
188211
</Tooltip>

adminforth/spa/src/components/ResourceListTableVirtual.vue

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -185,14 +185,37 @@
185185
/>
186186
</template>
187187

188-
<template v-if="resource.options?.actions">
189-
<Tooltip v-for="action in resource.options.actions.filter(a => a.showIn?.list)" :key="action.id">
190-
<button
191-
@click="startCustomAction(action.id, row)"
188+
<template v-if="resource.options?.actions">
189+
<Tooltip
190+
v-for="action in resource.options.actions.filter(a => a.showIn?.list)"
191+
:key="action.id"
192+
>
193+
<CallActionWrapper
194+
:disabled="rowActionLoadingStates?.[action.id]"
195+
@callAction="startCustomAction(action.id, row)"
192196
>
193-
<component v-if="action.icon" :is="getIcon(action.icon)" class="w-5 h-5 mr-2 text-lightPrimary dark:text-darkPrimary"></component>
194-
</button>
195-
<template v-slot:tooltip>
197+
<component
198+
:is="action.customComponent ? getCustomComponent(action.customComponent) : 'span'"
199+
:meta="action.customComponent?.meta"
200+
:row="row"
201+
:resource="resource"
202+
:adminUser="adminUser"
203+
>
204+
<button
205+
type="button"
206+
:disabled="rowActionLoadingStates?.[action.id]"
207+
@click.stop.prevent="startCustomAction(action.id, row)"
208+
>
209+
<component
210+
v-if="action.icon"
211+
:is="getIcon(action.icon)"
212+
class="w-5 h-5 mr-2 text-lightPrimary dark:text-darkPrimary"
213+
/>
214+
</button>
215+
</component>
216+
</CallActionWrapper>
217+
218+
<template #tooltip>
196219
{{ action.name }}
197220
</template>
198221
</Tooltip>

adminforth/spa/src/components/ThreeDotsMenu.vue

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,22 @@
2424
</a>
2525
</li>
2626
<li v-for="action in customActions" :key="action.id">
27-
<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">
28-
<div class="flex items-center gap-2">
29-
<component
30-
v-if="action.icon"
31-
:is="getIcon(action.icon)"
32-
class="w-4 h-4 text-lightPrimary dark:text-darkPrimary"
33-
/>
34-
{{ action.name }}
35-
</div>
36-
</a>
27+
<component
28+
:is="(action.customComponent && getCustomComponent(action.customComponent)) || CallActionWrapper"
29+
:meta="action.customComponent?.meta"
30+
@callAction="handleActionClick(action)"
31+
>
32+
<a href="#" @click.prevent class="block px-4 py-2 hover:text-lightThreeDotsMenuBodyTextHover hover:bg-lightThreeDotsMenuBodyBackgroundHover dark:hover:bg-darkThreeDotsMenuBodyBackgroundHover dark:hover:text-darkThreeDotsMenuBodyTextHover">
33+
<div class="flex items-center gap-2">
34+
<component
35+
v-if="action.icon"
36+
:is="getIcon(action.icon)"
37+
class="w-4 h-4 text-lightPrimary dark:text-darkPrimary"
38+
/>
39+
{{ action.name }}
40+
</div>
41+
</a>
42+
</component>
3743
</li>
3844
<li v-for="action in bulkActions?.filter(a => a.showInThreeDotsDropdown)" :key="action.id">
3945
<a href="#" @click.prevent="startBulkAction(action.id)"
@@ -65,6 +71,8 @@ import { useCoreStore } from '@/stores/core';
6571
import adminforth from '@/adminforth';
6672
import { callAdminForthApi } from '@/utils';
6773
import { useRoute, useRouter } from 'vue-router';
74+
import CallActionWrapper from '@/components/CallActionWrapper.vue'
75+
6876
6977
const route = useRoute();
7078
const coreStore = useCoreStore();

adminforth/spa/src/views/ShowView.vue

Lines changed: 8 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,16 @@
1212
<BreadcrumbsWithButtons>
1313
<template v-if="coreStore.resource?.options?.actions">
1414

15-
<template v-for="action in coreStore.resource.options.actions.filter(a => a.showIn?.showButton)" >
15+
<template v-for="action in coreStore.resource.options.actions.filter(a => a.showIn?.showButton)" :key="action.id">
1616
<component
17-
v-if="action.customComponent"
18-
:is="getCustomComponent(action.customComponent)"
19-
:meta="action.customComponent.meta"
20-
@callAction="startCustomAction(action.id)"
21-
:disabled="actionLoadingStates[action.id]"
22-
>
17+
:is="getCustomComponent(action.customComponent) || CallActionWrapper"
18+
:meta="action.customComponent?.meta"
19+
@callAction="startCustomAction(action.id)"
20+
:disabled="actionLoadingStates[action.id]"
21+
>
2322
<button
2423
:key="action.id"
24+
:disabled="actionLoadingStates[action.id]"
2525
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"
2626
>
2727
<component
@@ -32,21 +32,6 @@
3232
{{ action.name }}
3333
</button>
3434
</component>
35-
36-
<button
37-
v-else
38-
:key="action.id"
39-
@click="startCustomAction(action.id)"
40-
:disabled="actionLoadingStates[action.id]"
41-
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"
42-
>
43-
<component
44-
v-if="action.icon"
45-
:is="getIcon(action.icon)"
46-
class="w-4 h-4 me-2 text-lightPrimary dark:text-darkPrimary"
47-
/>
48-
{{ action.name }}
49-
</button>
5035
</template>
5136
</template>
5237
<RouterLink v-if="coreStore.resource?.options?.allowedActions?.create"
@@ -167,6 +152,7 @@ import adminforth from "@/adminforth";
167152
import { useI18n } from 'vue-i18n';
168153
import { getIcon } from '@/utils';
169154
import { type AdminForthComponentDeclarationFull } from '@/types/Common.js';
155+
import CallActionWrapper from '@/components/CallActionWrapper.vue'
170156
171157
const route = useRoute();
172158
const router = useRouter();

0 commit comments

Comments
 (0)