Skip to content

Commit 195ff77

Browse files
committed
feat: add custom Save button injection for create/edit pages and update documentation
1 parent 8d28950 commit 195ff77

File tree

10 files changed

+285
-22
lines changed

10 files changed

+285
-22
lines changed

adminforth/commands/createCustomComponent/main.js

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,9 @@ 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', value: 'saveButton' }, new Separator()]
193+
: []),
191194
{ name: '⬆️ Before Breadcrumbs', value: 'beforeBreadcrumbs' },
192195
{ name: '➡️ Before Action Buttons', value: 'beforeActionButtons' },
193196
{ name: '⬇️ After Breadcrumbs', value: 'afterBreadcrumbs' },
@@ -207,13 +210,15 @@ async function handleCrudPageInjectionCreation(config, resources) {
207210
},
208211
});
209212

210-
const isThin = await select({
211-
message: 'Will this component be thin enough to fit on the same page with list (so list will still shrink)?',
212-
choices: [
213-
{ name: 'Yes', value: true },
214-
{ name: 'No', value: false },
215-
],
216-
});
213+
const isThin = crudType === 'list'
214+
? await select({
215+
message: 'Will this component be thin enough to fit on the same page with list (so list will still shrink)?',
216+
choices: [
217+
{ name: 'Yes', value: true },
218+
{ name: 'No', value: false },
219+
],
220+
})
221+
: false;
217222
const formattedAdditionalName = additionalName
218223
? additionalName[0].toUpperCase() + additionalName.slice(1)
219224
: '';
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<template>
2+
<button
3+
class="px-4 py-2 border border-blue-500 text-blue-600 rounded hover:bg-blue-50 disabled:opacity-50"
4+
:disabled="props.disabled || props.saving || !props.isValid"
5+
@click="props.saveRecord()"
6+
>
7+
<span v-if="props.saving">Saving…</span>
8+
<span v-else>Save</span>
9+
</button>
10+
</template>
11+
12+
<script setup lang="ts">
13+
14+
const props = defineProps<{
15+
record: any
16+
resource: any
17+
adminUser: any
18+
meta: any
19+
saving: boolean
20+
validating: boolean
21+
isValid: boolean
22+
disabled: boolean
23+
saveRecord: () => Promise<void>
24+
}>();
25+
</script>
26+
27+
<style scoped>
28+
</style>

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

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,78 @@ beforeActionButtons: [
440440
441441
## List table custom
442442
443+
## Create/Edit custom Save button
444+
445+
You can replace the default Save button on the create and edit pages with your own Vue component.
446+
447+
Supported locations:
448+
- `pageInjections.create.saveButton`
449+
- `pageInjections.edit.saveButton`
450+
451+
Example configuration:
452+
453+
```ts title="/resources/apartments.ts"
454+
{
455+
resourceId: 'aparts',
456+
...
457+
options: {
458+
pageInjections: {
459+
create: {
460+
// String shorthand
461+
saveButton: '@@/SaveBordered.vue',
462+
},
463+
edit: {
464+
// Object form (lets you pass meta later, if needed)
465+
saveButton: { file: '@@/SaveBordered.vue' },
466+
}
467+
}
468+
}
469+
}
470+
```
471+
472+
Minimal example of a custom save button component:
473+
474+
```vue title="/custom/SaveBordered.vue"
475+
<template>
476+
<button
477+
class="px-4 py-2 border border-blue-500 text-blue-600 rounded hover:bg-blue-50 disabled:opacity-50"
478+
:disabled="props.disabled || props.saving || !props.isValid"
479+
@click="props.saveRecord()"
480+
>
481+
<span v-if="props.saving">{{$t('Saving…')}}</span>
482+
<span v-else>{{$t('Save')}}</span>
483+
</button>
484+
485+
</template>
486+
487+
<script setup lang="ts">
488+
const props = defineProps<{
489+
record: any
490+
resource: any
491+
adminUser: any
492+
meta: any
493+
saving: boolean
494+
validating: boolean
495+
isValid: boolean
496+
disabled: boolean
497+
saveRecord: () => Promise<void>
498+
}>();
499+
</script>
500+
```
501+
502+
Notes:
503+
- Your component fully replaces the default Save button in the page header.
504+
- The `saveRecord()` prop triggers the standard AdminForth save flow. Call it on click.
505+
- `saving`, `validating`, `isValid`, and `disabled` reflect the current form state.
506+
- If no `saveButton` is provided, the default button is shown.
507+
508+
Scaffolding via CLI: you can generate a ready-to-wire component and auto-update the resource config using the interactive command:
509+
510+
```bash
511+
adminforth component
512+
# Choose: CRUD page injections → (create|edit) → Save button
513+
```
514+
443515
## Global Injections
444516
445517
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: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,19 @@ 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+
356369
---
357370

358371
#### 🔐 Login Page Injections (`login`)

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

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ To do it, first, create frontend custom component which wraps and intercepts cli
227227
async function onClick() {
228228
if (props.disabled) return;
229229

230-
const verificationResult = await window.adminforthTwoFaModal.get2FaConfirmationResult(); // this will ask user to enter code
230+
const verificationResult = await window.adminforthTwoFaModal.get2FaConfirmationResult(); // this will ask user to enter code
231231
emit('callAction', { verificationResult }); // then we pass this verification result to action (from fronted to backend)
232232
}
233233
</script>
@@ -303,6 +303,74 @@ options: {
303303
}
304304
```
305305
306+
## Request 2FA for create/edit (secure save gating)
307+
308+
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.
309+
310+
Frontend (custom Save button example):
311+
312+
```vue
313+
<template>
314+
<button :disabled="disabled || saving || !isValid" @click="onClick">Save</button>
315+
<!-- The plugin injects TwoFAModal globally, exposing window.adminforthTwoFaModal -->
316+
</template>
317+
318+
<script setup lang="ts">
319+
const props = defineProps<{
320+
disabled: boolean;
321+
saving: boolean;
322+
isValid: boolean;
323+
// saveRecord accepts optional meta with confirmationResult
324+
saveRecord: (opts?: { confirmationResult?: any }) => Promise<void>;
325+
meta?: any;
326+
}>();
327+
328+
async function onClick() {
329+
if (props.disabled || props.saving || !props.isValid) return;
330+
const modal = (window as any)?.adminforthTwoFaModal;
331+
if (modal?.get2FaConfirmationResult) {
332+
const confirmationResult = await modal.get2FaConfirmationResult(undefined, props.meta?.twoFaTitle || 'Confirm to save changes');
333+
await props.saveRecord({ confirmationResult });
334+
} else {
335+
const code = window.prompt('Enter your 2FA code to proceed');
336+
if (!code) return;
337+
await props.saveRecord({ confirmationResult: { mode: 'totp', result: code } });
338+
}
339+
}
340+
</script>
341+
```
342+
343+
Backend (resource hook verification):
344+
345+
```ts
346+
// Inside resource config
347+
hooks: {
348+
edit: {
349+
beforeSave: async ({ adminUser, adminforth, extra }) => {
350+
const t2fa = adminforth.getPluginByClassName('TwoFactorsAuthPlugin');
351+
const confirmationResult = extra?.body?.meta?.confirmationResult;
352+
if (!confirmationResult) {
353+
return { ok: false, error: 'Two-factor authentication confirmation result is missing' };
354+
}
355+
const cookies = extra?.cookies;
356+
const verifyRes = await t2fa.verify(confirmationResult, {
357+
adminUser,
358+
userPk: adminUser.pk,
359+
cookies,
360+
});
361+
if (!('ok' in verifyRes) || verifyRes.ok !== true) {
362+
return { ok: false, error: verifyRes?.error || 'Two-factor authentication failed' };
363+
}
364+
return { ok: true };
365+
},
366+
},
367+
}
368+
```
369+
370+
This approach ensures 2FA cannot be bypassed by calling the API directly:
371+
- The client collects verification via the modal and forwards it under `meta.confirmationResult`.
372+
- The server validates it in `beforeSave` with access to `extra.cookies` and the `adminUser`.
373+
306374
## Request 2FA from custom components
307375
308376
Imagine you have some button which does some API call
@@ -360,7 +428,7 @@ import adminforth from '@/adminforth';
360428

361429
async function callAdminAPI() {
362430
// diff-add
363-
const verificationResult = await window.adminforthTwoFaModal.getCode();
431+
const verificationResult = await window.adminforthTwoFaModal.get2FaConfirmationResult();
364432

365433
const res = await callApi({
366434
path: '/myCriticalAction',

adminforth/modules/configValidator.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -860,22 +860,32 @@ export default class ConfigValidator implements IConfigValidator {
860860
});
861861

862862
// if pageInjection is a string, make array with one element. Also check file exists
863+
// Validate page-specific allowed injection keys
863864
const possiblePages = ['list', 'show', 'create', 'edit'];
865+
const allowedInjectionsByPage: Record<string, string[]> = {
866+
list: ['beforeBreadcrumbs', 'afterBreadcrumbs', 'bottom', 'threeDotsDropdownItems', 'customActionIcons', 'tableBodyStart'],
867+
show: ['beforeBreadcrumbs', 'afterBreadcrumbs', 'bottom', 'threeDotsDropdownItems'],
868+
edit: ['beforeBreadcrumbs', 'afterBreadcrumbs', 'bottom', 'threeDotsDropdownItems', 'saveButton'],
869+
create: ['beforeBreadcrumbs', 'afterBreadcrumbs', 'bottom', 'threeDotsDropdownItems', 'saveButton'],
870+
};
864871

865872
if (options.pageInjections) {
866873

867-
Object.entries(options.pageInjections).map(([key, value]) => {
868-
if (!possiblePages.includes(key)) {
869-
const similar = suggestIfTypo(possiblePages, key);
870-
errors.push(`Resource "${res.resourceId}" has invalid pageInjection key "${key}", allowed keys are ${possiblePages.join(', ')}. ${similar ? `Did you mean "${similar}"?` : ''}`);
874+
Object.entries(options.pageInjections).map(([pageKey, value]) => {
875+
if (!possiblePages.includes(pageKey)) {
876+
const similar = suggestIfTypo(possiblePages, pageKey);
877+
errors.push(`Resource "${res.resourceId}" has invalid pageInjection page "${pageKey}", allowed pages are ${possiblePages.join(', ')}. ${similar ? `Did you mean "${similar}"?` : ''}`);
878+
return;
871879
}
872880

873-
Object.entries(value).map(([injection, target]) => {
874-
if (ConfigValidator.PAGE_INJECTION_KEYS.includes(injection)) {
875-
options.pageInjections[key][injection] = this.validateAndListifyInjectionNew(options.pageInjections[key], injection, errors);
881+
const allowedForThisPage = allowedInjectionsByPage[pageKey];
882+
883+
Object.entries(value).map(([injection, _target]) => {
884+
if (allowedForThisPage.includes(injection)) {
885+
this.validateAndListifyInjection(options.pageInjections[pageKey], injection, errors);
876886
} else {
877-
const similar = suggestIfTypo(ConfigValidator.PAGE_INJECTION_KEYS, injection);
878-
errors.push(`Resource "${res.resourceId}" has invalid pageInjection key "${injection}", Supported keys are ${ConfigValidator.PAGE_INJECTION_KEYS.join(', ')} ${similar ? `Did you mean "${similar}"?` : ''}`);
887+
const similar = suggestIfTypo(allowedForThisPage, injection);
888+
errors.push(`Resource "${res.resourceId}" has invalid pageInjection key "${injection}" for page "${pageKey}", supported keys are ${allowedForThisPage.join(', ')}. ${similar ? `Did you mean "${similar}"?` : ''}`);
879889
}
880890
});
881891

adminforth/spa/src/components/ThreeDotsMenu.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ import adminforth from '@/adminforth';
8282
import { callAdminForthApi } from '@/utils';
8383
import { useRoute, useRouter } from 'vue-router';
8484
import CallActionWrapper from '@/components/CallActionWrapper.vue'
85+
import { ref, type ComponentPublicInstance } from 'vue';
86+
import type { AdminForthBulkActionCommon, AdminForthComponentDeclarationFull } from '@/types/Common';
87+
import type { AdminForthActionInput } from '@/types/Back';
8588
8689
8790
const route = useRoute();

adminforth/spa/src/views/CreateView.vue

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,25 @@
1818
{{ $t('Cancel') }}
1919
</button>
2020

21+
<!-- Custom Save Button injection -->
22+
<component
23+
v-if="createSaveButtonInjection"
24+
:is="getCustomComponent(createSaveButtonInjection)"
25+
:meta="createSaveButtonInjection.meta"
26+
:record="record"
27+
:resource="coreStore.resource"
28+
:adminUser="coreStore.adminUser"
29+
:saving="saving"
30+
:validating="validating"
31+
:isValid="isValid"
32+
:disabled="saving || (validating && !isValid)"
33+
:saveRecord="saveRecord"
34+
/>
35+
36+
<!-- Default Save Button fallback -->
2137
<button
22-
@click="saveRecord"
38+
v-else
39+
@click="() => saveRecord()"
2340
class="af-save-button flex items-center py-1 px-3 text-sm font-medium rounded-default text-lightCreateViewSaveButtonText focus:outline-none bg-lightCreateViewButtonBackground rounded border border-lightCreateViewButtonBorder hover:bg-lightCreateViewButtonBackgroundHover hover:text-lightCreateViewSaveButtonTextHover focus:z-10 focus:ring-4 focus:ring-lightCreateViewButtonFocusRing dark:focus:ring-darkCreateViewButtonFocusRing dark:bg-darkCreateViewButtonBackground dark:text-darkCreateViewSaveButtonText dark:border-darkCreateViewButtonBorder dark:hover:text-darkCreateViewSaveButtonTextHover dark:hover:bg-darkCreateViewButtonBackgroundHover disabled:opacity-50 gap-1"
2441
:disabled="saving || (validating && !isValid)"
2542
>
@@ -105,6 +122,13 @@ const coreStore = useCoreStore();
105122
106123
const { t } = useI18n();
107124
125+
const createSaveButtonInjection = computed<AdminForthComponentDeclarationFull | null>(() => {
126+
const raw: any = coreStore.resourceOptions?.pageInjections?.create?.saveButton as any;
127+
if (!raw) return null;
128+
const item = Array.isArray(raw) ? raw[0] : raw;
129+
return item as AdminForthComponentDeclarationFull;
130+
});
131+
108132
const initialValues = ref({});
109133
110134
const readonlyColumns = ref([]);
@@ -153,7 +177,7 @@ onMounted(async () => {
153177
initThreeDotsDropdown();
154178
});
155179
156-
async function saveRecord() {
180+
async function saveRecord(opts?: { confirmationResult?: any }) {
157181
if (!isValid.value) {
158182
validating.value = true;
159183
return;
@@ -167,6 +191,9 @@ async function saveRecord() {
167191
body: {
168192
resourceId: route.params.resourceId,
169193
record: record.value,
194+
meta: {
195+
...(opts?.confirmationResult ? { confirmationResult: opts.confirmationResult } : {}),
196+
},
170197
},
171198
});
172199
if (response?.error && response?.error !== 'Operation aborted by hook') {

0 commit comments

Comments
 (0)