Skip to content

Commit 426d610

Browse files
committed
feat: implement save interceptors for create/edit operations with 2FA verification
1 parent 18d2854 commit 426d610

File tree

12 files changed

+230
-193
lines changed

12 files changed

+230
-193
lines changed

adminforth/commands/createCustomComponent/main.js

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,9 +188,6 @@ async function handleCrudPageInjectionCreation(config, resources) {
188188
const injectionPosition = await select({
189189
message: 'Where exactly do you want to inject the component?',
190190
choices: [
191-
...(crudType === 'create' || crudType === 'edit'
192-
? [{ name: '💾 Save button on create/edit page', value: 'saveButton' }, new Separator()]
193-
: []),
194191
{ name: '⬆️ Before Breadcrumbs', value: 'beforeBreadcrumbs' },
195192
{ name: '➡️ Before Action Buttons', value: 'beforeActionButtons' },
196193
{ name: '⬇️ After Breadcrumbs', value: 'afterBreadcrumbs' },

adminforth/documentation/docs/tutorial/03-Customization/08-pageInjections.md

Lines changed: 0 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -513,78 +513,6 @@ beforeActionButtons: [
513513
514514
## List table custom
515515
516-
## Create/Edit custom Save button
517-
518-
You can replace the default Save button on the create and edit pages with your own Vue component.
519-
520-
Supported locations:
521-
- `pageInjections.create.saveButton`
522-
- `pageInjections.edit.saveButton`
523-
524-
Example configuration:
525-
526-
```ts title="/resources/apartments.ts"
527-
{
528-
resourceId: 'aparts',
529-
...
530-
options: {
531-
pageInjections: {
532-
create: {
533-
// String shorthand
534-
saveButton: '@@/SaveBordered.vue',
535-
},
536-
edit: {
537-
// Object form (lets you pass meta later, if needed)
538-
saveButton: { file: '@@/SaveBordered.vue' },
539-
}
540-
}
541-
}
542-
}
543-
```
544-
545-
Minimal example of a custom save button component:
546-
547-
```vue title="/custom/SaveBordered.vue"
548-
<template>
549-
<button
550-
class="px-4 py-2 border border-blue-500 text-blue-600 rounded hover:bg-blue-50 disabled:opacity-50"
551-
:disabled="props.disabled || props.saving || !props.isValid"
552-
@click="props.saveRecord()"
553-
>
554-
<span v-if="props.saving">{{$t('Saving…')}}</span>
555-
<span v-else>{{$t('Save')}}</span>
556-
</button>
557-
558-
</template>
559-
560-
<script setup lang="ts">
561-
const props = defineProps<{
562-
record: any
563-
resource: any
564-
adminUser: any
565-
meta: any
566-
saving: boolean
567-
validating: boolean
568-
isValid: boolean
569-
disabled: boolean
570-
saveRecord: () => Promise<void>
571-
}>();
572-
</script>
573-
```
574-
575-
Notes:
576-
- Your component fully replaces the default Save button in the page header.
577-
- The `saveRecord()` prop triggers the standard AdminForth save flow. Call it on click.
578-
- `saving`, `validating`, `isValid`, and `disabled` reflect the current form state.
579-
- If no `saveButton` is provided, the default button is shown.
580-
581-
Scaffolding via CLI: you can generate a ready-to-wire component and auto-update the resource config using the interactive command:
582-
583-
```bash
584-
adminforth component
585-
# Choose: CRUD page injections → (create|edit) → Save button
586-
```
587-
588516
## Global Injections
589517
590518
You have opportunity to inject custom components to the global layout. For example, you can add a custom items into user menu

adminforth/documentation/docs/tutorial/06-CLICommands.md

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -353,21 +353,6 @@ Generated example:
353353
custom/OrderShowBottomExportButton.vue
354354
```
355355

356-
Special position for create/edit:
357-
- `saveButton` — replaces the default Save button at the top bar.
358-
359-
Example usage via interactive flow:
360-
```bash
361-
adminforth component
362-
# → CRUD Page Injections
363-
# → (create | edit)
364-
# → Save button
365-
```
366-
367-
Your generated component will receive props documented in Page Injections → Create/Edit custom Save button. At minimum, call `props.saveRecord()` on click and respect `props.saving`, `props.isValid`, and `props.disabled`.
368-
369-
---
370-
371356
#### 🔐 Login Page Injections (`login`)
372357

373358
Places a component before or after the login form.

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

Lines changed: 54 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -328,41 +328,44 @@ options: {
328328
329329
### Request 2FA for create/edit (secure save gating)
330330
331-
To protect create and edit operations, collect the result of the 2FA modal on the frontend and send it along with the save payload. The server must verify it before writing changes.
331+
To protect create and edit operations, use a Save Interceptor injected on the page to gate the save with 2FA, and forward the result to the backend for verification. This avoids wrapping the Save button and works with the default UI.
332332
333-
Frontend (custom Save button example):
333+
Frontend (Save Interceptor component injected via pageInjections):
334334
335-
```vue
336-
<template>
337-
<button :disabled="disabled || saving || !isValid" @click="onClick">Save</button>
338-
<!-- The plugin injects TwoFAModal globally, exposing window.adminforthTwoFaModal -->
339-
</template>
335+
```vue title='/custom/SaveInterceptor.vue'
336+
<script setup>
337+
import { useAdminforth } from '@/adminforth';
340338

341-
<script setup lang="ts">
342-
const props = defineProps<{
343-
disabled: boolean;
344-
saving: boolean;
345-
isValid: boolean;
346-
// saveRecord accepts optional meta with confirmationResult
347-
saveRecord: (opts?: { confirmationResult?: any }) => Promise<void>;
348-
meta?: any;
349-
}>();
350-
351-
async function onClick() {
352-
if (props.disabled || props.saving || !props.isValid) return;
339+
const { registerSaveInterceptor } = useAdminforth();
340+
341+
registerSaveInterceptor(async ({ action, values, resource }) => {
342+
// action is 'create' or 'edit'
353343
const modal = (window as any)?.adminforthTwoFaModal;
354344
if (modal?.get2FaConfirmationResult) {
355-
const confirmationResult = await modal.get2FaConfirmationResult(undefined, props.meta?.twoFaTitle || 'Confirm to save changes');
356-
await props.saveRecord({ confirmationResult });
357-
} else {
358-
const code = window.prompt('Enter your 2FA code to proceed');
359-
if (!code) return;
360-
await props.saveRecord({ confirmationResult: { mode: 'totp', result: code } });
345+
const confirmationResult = await modal.get2FaConfirmationResult(undefined, 'Confirm to save changes');
346+
if (!confirmationResult) return { ok: false, error: '2FA cancelled' };
347+
// Pass data to backend; the view will forward extra.confirmationResult to meta.confirmationResult
348+
return { ok: true, extra: { confirmationResult } };
361349
}
362-
}
350+
return { ok: false, error: '2FA code is required' };
351+
});
363352
</script>
353+
354+
<template></template>
364355
```
365356
357+
Resource injection (edit/create):
358+
359+
```ts
360+
options: {
361+
pageInjections: {
362+
edit: { bottom: [{ file: '@@/SaveInterceptor.vue' }] },
363+
create: { bottom: [{ file: '@@/SaveInterceptor.vue' }] },
364+
}
365+
}
366+
```
367+
> Note: You can use all injection, not only bottom
368+
366369
Backend (resource hook verification):
367370
368371
```ts
@@ -372,17 +375,37 @@ hooks: {
372375
beforeSave: async ({ adminUser, adminforth, extra }) => {
373376
const t2fa = adminforth.getPluginByClassName('TwoFactorsAuthPlugin');
374377
const confirmationResult = extra?.body?.meta?.confirmationResult;
378+
379+
if (!confirmationResult) {
380+
return { ok: false, error: 'Two-factor authentication confirmation result is missing' };
381+
}
382+
const verifyRes = await t2fa.verify(confirmationResult, {
383+
adminUser,
384+
userPk: adminUser.pk,
385+
cookies: extra?.cookies,
386+
});
387+
if (!verifyRes || 'error' in verifyRes) {
388+
return { ok: false, error: verifyRes?.error || '2FA verification failed' };
389+
}
390+
return { ok: true };
391+
},
392+
},
393+
create: {
394+
beforeSave: async ({ adminUser, adminforth, extra }) => {
395+
const t2fa = adminforth.getPluginByClassName('TwoFactorsAuthPlugin');
396+
const confirmationResult = extra?.body?.meta?.confirmationResult;
397+
375398
if (!confirmationResult) {
376399
return { ok: false, error: 'Two-factor authentication confirmation result is missing' };
377400
}
378-
const cookies = extra?.cookies;
401+
379402
const verifyRes = await t2fa.verify(confirmationResult, {
380403
adminUser,
381404
userPk: adminUser.pk,
382-
cookies,
405+
cookies: extra?.cookies,
383406
});
384-
if (!('ok' in verifyRes) || verifyRes.ok !== true) {
385-
return { ok: false, error: verifyRes?.error || 'Two-factor authentication failed' };
407+
if (!verifyRes || 'error' in verifyRes) {
408+
return { ok: false, error: verifyRes?.error || '2FA verification failed' };
386409
}
387410
return { ok: true };
388411
},
@@ -391,7 +414,7 @@ hooks: {
391414
```
392415
393416
This approach ensures 2FA cannot be bypassed by calling the API directly:
394-
- The client collects verification via the modal and forwards it under `meta.confirmationResult`.
417+
- The client collects verification via the Save Interceptor and forwards it under `meta.confirmationResult`.
395418
- The server validates it in `beforeSave` with access to `extra.cookies` and the `adminUser`.
396419
397420
### Request 2FA from custom components

adminforth/modules/configValidator.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -878,8 +878,8 @@ export default class ConfigValidator implements IConfigValidator {
878878
const allowedInjectionsByPage: Record<string, string[]> = {
879879
list: ['beforeBreadcrumbs', 'afterBreadcrumbs', 'beforeActionButtons', 'bottom', 'threeDotsDropdownItems', 'customActionIcons', 'tableBodyStart', 'tableRowReplace'],
880880
show: ['beforeBreadcrumbs', 'afterBreadcrumbs', 'bottom', 'threeDotsDropdownItems'],
881-
edit: ['beforeBreadcrumbs', 'afterBreadcrumbs', 'bottom', 'threeDotsDropdownItems', 'saveButton'],
882-
create: ['beforeBreadcrumbs', 'afterBreadcrumbs', 'bottom', 'threeDotsDropdownItems', 'saveButton'],
881+
edit: ['beforeBreadcrumbs', 'afterBreadcrumbs', 'bottom', 'threeDotsDropdownItems'],
882+
create: ['beforeBreadcrumbs', 'afterBreadcrumbs', 'bottom', 'threeDotsDropdownItems'],
883883
};
884884

885885
if (options.pageInjections) {

adminforth/spa/src/adminforth.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ class FrontendAPI implements FrontendAPIInterface {
1919
public modalStore:any
2020
public filtersStore:any
2121
public coreStore:any
22+
private saveInterceptors: Record<string, Array<(ctx: { action: 'create'|'edit'; values: any; resource: any; resourceId: string; }) => Promise<{ ok: boolean; error?: string | null; extra?: object; }>>> = {};
2223

2324
public list: {
2425
refresh(): Promise<{ error? : string }>;
@@ -84,6 +85,49 @@ class FrontendAPI implements FrontendAPIInterface {
8485
}
8586
}
8687

88+
registerSaveInterceptor(
89+
handler: (ctx: { action: 'create'|'edit'; values: any; resource: any; }) => Promise<{ ok: boolean; error?: string | null; extra?: object; }>,
90+
): void {
91+
const rid = router.currentRoute.value?.params?.resourceId as string;
92+
if (!rid) {
93+
return;
94+
}
95+
if (!this.saveInterceptors[rid]) {
96+
this.saveInterceptors[rid] = [];
97+
}
98+
this.saveInterceptors[rid].push(handler);
99+
}
100+
101+
async runSaveInterceptors(params: { action: 'create'|'edit'; values: any; resource: any; resourceId: string; }): Promise<{ ok: boolean; error?: string | null; extra?: any }>{
102+
const list = this.saveInterceptors[params.resourceId] || [];
103+
const aggregatedExtra: Record<string, any> = {};
104+
for (const fn of list) {
105+
try {
106+
const res = await fn(params);
107+
if (typeof res !== 'object' || typeof res.ok !== 'boolean') {
108+
return { ok: false, error: 'Invalid interceptor return value' };
109+
}
110+
if (!res.ok) {
111+
return { ok: false, error: res.error ?? 'Interceptor failed' };
112+
}
113+
if (res.extra) {
114+
Object.assign(aggregatedExtra, res.extra);
115+
}
116+
} catch (e: any) {
117+
return { ok: false, error: e?.message || String(e) };
118+
}
119+
}
120+
return { ok: true, extra: aggregatedExtra };
121+
}
122+
123+
clearSaveInterceptors(resourceId?: string): void {
124+
if (resourceId) {
125+
delete this.saveInterceptors[resourceId];
126+
} else {
127+
this.saveInterceptors = {};
128+
}
129+
}
130+
87131
confirm(params: ConfirmParams): Promise<boolean> {
88132
return new Promise((resolve, reject) => {
89133
this.modalStore.setModalContent({
@@ -180,5 +224,15 @@ export function initFrontedAPI() {
180224
api.filtersStore = useFiltersStore();
181225
}
182226

227+
export function useAdminforth() {
228+
const api = frontendAPI as FrontendAPI;
229+
return {
230+
registerSaveInterceptor: (handler: (ctx: { action: 'create'|'edit'; values: any; resource: any; resourceId: string; }) => Promise<{ ok: boolean; error?: string | null; }>, resourceId?: string) => api.registerSaveInterceptor(handler, resourceId),
231+
runSaveInterceptors: (params: { action: 'create'|'edit'; values: any; resource: any; resourceId: string; }) => api.runSaveInterceptors(params),
232+
clearSaveInterceptors: (resourceId?: string) => api.clearSaveInterceptors(resourceId),
233+
api,
234+
};
235+
}
236+
183237

184238
export default frontendAPI;

0 commit comments

Comments
 (0)