Skip to content

Commit 455cd80

Browse files
committed
Merge branch 'feature/AdminForth/1093/we-need-to-have-validation-ste' of https://github.com/devforth/adminforth into next
2 parents 157cd5f + c46ac02 commit 455cd80

File tree

13 files changed

+200
-221
lines changed

13 files changed

+200
-221
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/commands/createCustomComponent/templates/customCrud/saveButton.vue.hbs

Lines changed: 0 additions & 28 deletions
This file was deleted.

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

Lines changed: 0 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -533,78 +533,6 @@ beforeActionButtons: [
533533
534534
## List table custom
535535
536-
## Create/Edit custom Save button
537-
538-
You can replace the default Save button on the create and edit pages with your own Vue component.
539-
540-
Supported locations:
541-
- `pageInjections.create.saveButton`
542-
- `pageInjections.edit.saveButton`
543-
544-
Example configuration:
545-
546-
```ts title="/resources/apartments.ts"
547-
{
548-
resourceId: 'aparts',
549-
...
550-
options: {
551-
pageInjections: {
552-
create: {
553-
// String shorthand
554-
saveButton: '@@/SaveBordered.vue',
555-
},
556-
edit: {
557-
// Object form (lets you pass meta later, if needed)
558-
saveButton: { file: '@@/SaveBordered.vue' },
559-
}
560-
}
561-
}
562-
}
563-
```
564-
565-
Minimal example of a custom save button component:
566-
567-
```vue title="/custom/SaveBordered.vue"
568-
<template>
569-
<button
570-
class="px-4 py-2 border border-blue-500 text-blue-600 rounded hover:bg-blue-50 disabled:opacity-50"
571-
:disabled="props.disabled || props.saving || !props.isValid"
572-
@click="props.saveRecord()"
573-
>
574-
<span v-if="props.saving">{{$t('Saving…')}}</span>
575-
<span v-else>{{$t('Save')}}</span>
576-
</button>
577-
578-
</template>
579-
580-
<script setup lang="ts">
581-
const props = defineProps<{
582-
record: any
583-
resource: any
584-
adminUser: any
585-
meta: any
586-
saving: boolean
587-
validating: boolean
588-
isValid: boolean
589-
disabled: boolean
590-
saveRecord: () => Promise<void>
591-
}>();
592-
</script>
593-
```
594-
595-
Notes:
596-
- Your component fully replaces the default Save button in the page header.
597-
- The `saveRecord()` prop triggers the standard AdminForth save flow. Call it on click.
598-
- `saving`, `validating`, `isValid`, and `disabled` reflect the current form state.
599-
- If no `saveButton` is provided, the default button is shown.
600-
601-
Scaffolding via CLI: you can generate a ready-to-wire component and auto-update the resource config using the interactive command:
602-
603-
```bash
604-
adminforth component
605-
# Choose: CRUD page injections → (create|edit) → Save button
606-
```
607-
608536
## Global Injections
609537
610538
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: 67 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -330,41 +330,49 @@ options: {
330330
331331
### Request 2FA for create/edit (secure save gating)
332332
333-
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.
333+
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.
334334
335-
Frontend (custom Save button example):
335+
Frontend (Save Interceptor component injected via pageInjections):
336336
337-
```ts
338-
<template>
339-
<button :disabled="disabled || saving || !isValid" @click="onClick">Save</button>
340-
<!-- The plugin injects TwoFAModal globally, exposing window.adminforthTwoFaModal -->
341-
</template>
337+
```vue title='/custom/SaveInterceptor.vue'
338+
<script setup>
339+
import { useAdminforth } from '@/adminforth';
342340

343-
<script setup lang="ts">
344-
const props = defineProps<{
345-
disabled: boolean;
346-
saving: boolean;
347-
isValid: boolean;
348-
// saveRecord accepts optional meta with confirmationResult
349-
saveRecord: (opts?: { confirmationResult?: any }) => Promise<void>;
350-
meta?: any;
351-
}>();
352-
353-
async function onClick() {
354-
if (props.disabled || props.saving || !props.isValid) return;
341+
const { registerSaveInterceptor } = useAdminforth();
342+
343+
registerSaveInterceptor(async ({ action, values, resource }) => {
344+
// action is 'create' or 'edit'
355345
const modal = (window as any)?.adminforthTwoFaModal;
356346
if (modal?.get2FaConfirmationResult) {
357-
const confirmationResult = await modal.get2FaConfirmationResult(undefined, props.meta?.twoFaTitle || 'Confirm to save changes');
358-
await props.saveRecord({ confirmationResult });
359-
} else {
360-
const code = window.prompt('Enter your 2FA code to proceed');
361-
if (!code) return;
362-
await props.saveRecord({ confirmationResult: { mode: 'totp', result: code } });
347+
const confirmationResult = await modal.get2FaConfirmationResult('Confirm to save changes');
348+
if (!confirmationResult) {
349+
return { ok: false, error: 'Two-factor authentication cancelled' };
350+
}
351+
// Pass data to backend; the view will forward extra.confirmationResult to meta.confirmationResult
352+
return { ok: true, extra: { confirmationResult } };
363353
}
364-
}
354+
else {
355+
throw new Error('No Two-Factor Authentication modal found, please ensure you have latest version of @adminforth/two-factors-auth installed and instantiated on resource');
356+
}
357+
return { ok: false, error: 'Two-factor authentication code is required' };
358+
});
365359
</script>
360+
361+
<template></template>
366362
```
367363
364+
Resource injection (edit/create):
365+
366+
```ts
367+
options: {
368+
pageInjections: {
369+
edit: { bottom: [{ file: '@@/SaveInterceptor.vue' }] },
370+
create: { bottom: [{ file: '@@/SaveInterceptor.vue' }] },
371+
}
372+
}
373+
```
374+
Note: You can use any injection which executes JS on a page where Save bottom is rendered, not only bottom
375+
368376
Backend (resource hook verification):
369377
370378
```ts
@@ -373,20 +381,47 @@ hooks: {
373381
edit: {
374382
beforeSave: async ({ adminUser, adminforth, response, extra }) => {
375383
const t2fa = adminforth.getPluginByClassName('TwoFactorsAuthPlugin');
384+
if (!t2fa) {
385+
return { ok: false, error: 'TwoFactorsAuthPlugin is not configured' };
386+
}
387+
376388
const confirmationResult = extra?.body?.meta?.confirmationResult;
377389
if (!confirmationResult) {
378390
return { ok: false, error: 'Two-factor authentication confirmation result is missing' };
379391
}
380-
const cookies = extra?.cookies;
392+
381393
const verifyRes = await t2fa.verify(confirmationResult, {
382394
adminUser,
383395
userPk: adminUser.pk,
384-
cookies,
385-
response,
396+
cookies: extra?.cookies,
397+
response,
386398
extra
387399
});
388-
if (!('ok' in verifyRes) || verifyRes.ok !== true) {
389-
return { ok: false, error: verifyRes?.error || 'Two-factor authentication failed' };
400+
if (!verifyRes || 'error' in verifyRes) {
401+
return { ok: false, error: verifyRes?.error || 'Two-factor verification failed' };
402+
}
403+
return { ok: true };
404+
},
405+
},
406+
create: {
407+
beforeSave: async ({ adminUser, adminforth, extra }) => {
408+
const t2fa = adminforth.getPluginByClassName('TwoFactorsAuthPlugin');
409+
if (!t2fa) {
410+
return { ok: false, error: 'TwoFactorsAuthPlugin is not configured' };
411+
}
412+
413+
const confirmationResult = extra?.body?.meta?.confirmationResult;
414+
if (!confirmationResult) {
415+
return { ok: false, error: 'Two-factor authentication confirmation result is missing' };
416+
}
417+
418+
const verifyRes = await t2fa.verify(confirmationResult, {
419+
adminUser,
420+
userPk: adminUser.pk,
421+
cookies: extra?.cookies,
422+
});
423+
if (!verifyRes || 'error' in verifyRes) {
424+
return { ok: false, error: verifyRes?.error || 'Two-factor verification failed' };
390425
}
391426
return { ok: true };
392427
},
@@ -395,7 +430,7 @@ hooks: {
395430
```
396431
397432
This approach ensures 2FA cannot be bypassed by calling the API directly:
398-
- The client collects verification via the modal and forwards it under `meta.confirmationResult`.
433+
- The client collects verification via the Save Interceptor and forwards it under `meta.confirmationResult`.
399434
- The server validates it in `beforeSave` with access to `extra.cookies` and the `adminUser`.
400435
401436
### Request 2FA from custom components

adminforth/modules/configValidator.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -880,8 +880,8 @@ export default class ConfigValidator implements IConfigValidator {
880880
const allowedInjectionsByPage: Record<string, string[]> = {
881881
list: ['beforeBreadcrumbs', 'afterBreadcrumbs', 'beforeActionButtons', 'bottom', 'threeDotsDropdownItems', 'customActionIcons', 'customActionIconsThreeDotsMenuItems', 'tableBodyStart', 'tableRowReplace'],
882882
show: ['beforeBreadcrumbs', 'afterBreadcrumbs', 'bottom', 'threeDotsDropdownItems'],
883-
edit: ['beforeBreadcrumbs', 'afterBreadcrumbs', 'bottom', 'threeDotsDropdownItems', 'saveButton'],
884-
create: ['beforeBreadcrumbs', 'afterBreadcrumbs', 'bottom', 'threeDotsDropdownItems', 'saveButton'],
883+
edit: ['beforeBreadcrumbs', 'afterBreadcrumbs', 'bottom', 'threeDotsDropdownItems'],
884+
create: ['beforeBreadcrumbs', 'afterBreadcrumbs', 'bottom', 'threeDotsDropdownItems'],
885885
};
886886

887887
if (options.pageInjections) {

adminforth/spa/src/adminforth.ts

Lines changed: 52 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?: object; }> {
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,13 @@ 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; }) => Promise<{ ok: boolean; error?: string | null; extra?: object; }>) => api.registerSaveInterceptor(handler),
231+
api,
232+
};
233+
}
234+
183235

184236
export default frontendAPI;

0 commit comments

Comments
 (0)