-
Notifications
You must be signed in to change notification settings - Fork 21
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
Changes from 7 commits
e850dee
c83ee3b
e65f705
a5defaf
c95b1b6
b9a4d32
41eea76
28d3d04
afe97c6
8d28950
195ff77
e02ba0c
fa004e5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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" | ||
| { | ||
| 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')"> | ||
|
||
| <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 }>(); | ||
|
||
|
|
||
| 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> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
|---|---|---|
|
|
@@ -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: | ||
|
||
|
|
||
| ```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' }); | ||
| } | ||
| ) | ||
| ); | ||
| ``` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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); | ||
|
||
| customResourceComponents.push(file); | ||
| } | ||
| }); | ||
|
|
||
| (Object.values(resource.options?.pageInjections || {})).forEach((injection) => { | ||
| Object.values(injection).forEach((filePathes: {file: string}[]) => { | ||
|
|
||
| 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> |
There was a problem hiding this comment.
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)