From 3d1babf4b3188be0d0f9ed580a57559d734ff99f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Fri, 27 Mar 2026 23:17:50 +0300 Subject: [PATCH 001/163] refactor(FormBase): simplify context usage and improve slot bindings - Refactored the FormBase component to streamline context usage by replacing `ctx` with direct references to properties and methods. - Updated slot bindings to enhance readability and maintainability, ensuring consistent access to component data. - Improved the overall structure of the template for better clarity and performance. --- vue/src/js/components/others/FormBase.vue | 66 ++++++++++++----------- 1 file changed, 35 insertions(+), 31 deletions(-) diff --git a/vue/src/js/components/others/FormBase.vue b/vue/src/js/components/others/FormBase.vue index e4252d245..448c3d0a1 100644 --- a/vue/src/js/components/others/FormBase.vue +++ b/vue/src/js/components/others/FormBase.vue @@ -5,44 +5,45 @@ --> + @@ -318,6 +326,7 @@ + @@ -402,17 +418,158 @@ + + + + + + + + mdi-arrow-left + + + + + +
+ + {{ vp.icon }} + +
+ + + + +
+ + + + +
+ + + +
+ + + + + {{ field.label }} + + {{ field.value || '—' }} + + + +
+
+
+ + + + + + + + + + + + + + +
+ +
+ + + + + + + + {{ field.label }} + + {{ field.value || '—' }} + + + +
+ + + + + Approve + + +
+
diff --git a/vue/src/js/hooks/useForm.js b/vue/src/js/hooks/useForm.js index e4309443e..86f733352 100644 --- a/vue/src/js/hooks/useForm.js +++ b/vue/src/js/hooks/useForm.js @@ -175,6 +175,22 @@ export const makeFormProps = propsFactory({ type: Array, default: null, }, + revisions: { + type: Array, + default: () => [], + }, + restoreUrl: { + type: String, + default: null, + }, + previewUrl: { + type: String, + default: null, + }, + previewComponent: { + type: [Object, Function], + default: null, + }, }) export default function useForm(props, context) { @@ -275,11 +291,15 @@ export default function useForm(props, context) { }) || find(formEventSchema.value, checkSubmittable) ? true : false }) + const currentRevisions = ref(props.revisions || []) + const restoringRevisionId = ref(null) + const hasAdditionalSection = computed(() => context.slots.right || context.slots['right.top'] || context.slots['right.bottom'] || context.slots['right.middle'] || ['right-top', 'right-middle', 'right-bottom'].includes(props.actionsPosition) + || (currentRevisions.value && currentRevisions.value.length > 0) ) const states = reactive({ @@ -356,6 +376,10 @@ export default function useForm(props, context) { store.commit(ALERT.SET_ALERT, { message: response.data.message, variant: response.data.variant }) } + if (Object.prototype.hasOwnProperty.call(response.data, 'revisions')) { + currentRevisions.value = response.data.revisions + } + if(props.clearOnSaved) { states.model = getModel(rawSchema.value) resetValidation() @@ -713,23 +737,140 @@ export default function useForm(props, context) { }, { deep: true }) + watch(() => props.revisions, (newVal) => { + if (newVal) { + currentRevisions.value = newVal + } + }) + + const restoreDialogActive = ref(false) + const restorePreviewData = ref(null) + const pendingRestoreRevisionId = ref(null) + + const getRestoreUrl = () => { + if (props.restoreUrl) return props.restoreUrl + if (props.actionUrl) { + const segments = props.actionUrl.replace(/\/+$/, '').split('/') + const id = segments.pop() + return segments.join('/') + '/restore-revision/' + id + } + return null + } + + const normalizeRevisionPayload = (payload) => { + if (!payload) return payload + + const locales = (store.state.language.all || []).map(l => l.value) + if (!locales.length) return payload + + const result = {} + const translatedFields = {} + + for (const key in payload) { + if (locales.includes(key) && payload[key] && typeof payload[key] === 'object' && !Array.isArray(payload[key])) { + const locale = key + for (const field in payload[locale]) { + if (!translatedFields[field]) translatedFields[field] = {} + translatedFields[field][locale] = payload[locale][field] + } + } else { + result[key] = payload[key] + } + } + + return { ...result, ...translatedFields } + } + + const restoreRevision = (revisionId) => { + const url = getRestoreUrl() + if (!url) return + + // Open dialog immediately and remember which revision is being previewed + restorePreviewData.value = null + pendingRestoreRevisionId.value = revisionId + restoreDialogActive.value = true + restoringRevisionId.value = revisionId + + api.get(`${url}?revisionId=${revisionId}&preview=1`, + (response) => { + restoringRevisionId.value = null + + if (response.data.form_fields) { + restorePreviewData.value = normalizeRevisionPayload(response.data.form_fields) + } + }, + () => { + restoringRevisionId.value = null + store.commit(ALERT.SET_ALERT, { message: 'Failed to load revision preview.', variant: 'error' }) + } + ) + } + + const confirmRestore = () => { + const url = getRestoreUrl() + if (!url || !pendingRestoreRevisionId.value) return + + const revisionId = pendingRestoreRevisionId.value + restoreDialogActive.value = false + restoringRevisionId.value = revisionId + formLoading.value = true + + api.get(`${url}?revisionId=${revisionId}`, + (response) => { + formLoading.value = false + restoringRevisionId.value = null + restorePreviewData.value = null + pendingRestoreRevisionId.value = null + + if (response.data.variant) { + store.commit(ALERT.SET_ALERT, { message: response.data.message, variant: response.data.variant }) + } + + if (response.data.revisions) { + currentRevisions.value = response.data.revisions + } + + if (response.data.form_fields) { + model.value = getModel(rawSchema.value, response.data.form_fields, store.state) + inputSchema.value = validations.invokeRuleGenerator( + getSchema(rawSchema.value, { ...model.value, ...response.data.form_fields }, props.isEditing) + ) + context.emit('update:modelValue', response.data.form_fields) + } else if (shouldUseInertia.value) { + router.reload({ only: ['formAttributes'] }) + } else { + window.location.reload(true) + } + }, + () => { + formLoading.value = false + restoringRevisionId.value = null + store.commit(ALERT.SET_ALERT, { message: 'Failed to restore revision.', variant: 'error' }) + } + ) + } + + const cancelRestore = () => { + restoreDialogActive.value = false + restorePreviewData.value = null + pendingRestoreRevisionId.value = null + } + initialize() - // Add resetSchemaErrors to the returned methods return { ...toRefs(states), ...toRefs(methods), ...inputHandlers, ...validations, ...locale, - // ...itemActions, - // handleInput, - // createModel, - // createSchema, - // submit, - // resetValidation, - // initialize, - // resetSchemaError, - // setSchemaErrors, + currentRevisions, + restoringRevisionId, + restoreRevision, + restoreDialogActive, + restorePreviewData, + pendingRestoreRevisionId, + confirmRestore, + cancelRestore, } } From 59291172d363ec2043685965e05cb207dd2ac0ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Mon, 30 Mar 2026 17:39:45 +0300 Subject: [PATCH 015/163] fix(SvgIcon): enhance SvgIcon component with improved styling and structure - Refactored the SvgIcon component to eliminate the wrapping div, directly using a span for better semantics. - Introduced a computed property for dynamic inline styles based on height and width props. - Updated styles to ensure SVG icons are displayed correctly and responsively. - Scoped styles added for better encapsulation and to prevent style leakage. --- vue/src/js/components/SvgIcon.vue | 45 ++++++++++++++++++------------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/vue/src/js/components/SvgIcon.vue b/vue/src/js/components/SvgIcon.vue index 3be540bea..c80942846 100644 --- a/vue/src/js/components/SvgIcon.vue +++ b/vue/src/js/components/SvgIcon.vue @@ -1,7 +1,10 @@ - From 244231174843a400587812b0990033e25fd72aa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Mon, 30 Mar 2026 17:40:15 +0300 Subject: [PATCH 016/163] refactor(User): replace HasRoles trait with Rolable and streamline role management - Replaced the HasRoles trait with a new Rolable trait to encapsulate role-related functionality. - Removed deprecated role-related methods and attributes from the User entity for cleaner code. - Introduced global scope and role metadata handling within the Rolable trait. - Updated avatar retrieval logic for improved clarity and performance. --- src/Entities/Traits/Core/Rolable.php | 110 +++++++++++++++++++++++++++ src/Entities/User.php | 84 +------------------- 2 files changed, 114 insertions(+), 80 deletions(-) create mode 100644 src/Entities/Traits/Core/Rolable.php diff --git a/src/Entities/Traits/Core/Rolable.php b/src/Entities/Traits/Core/Rolable.php new file mode 100644 index 000000000..f60e1fc40 --- /dev/null +++ b/src/Entities/Traits/Core/Rolable.php @@ -0,0 +1,110 @@ +with('rolesMetaRelation'); + }); + } + + public function initializeRolable() + { + $this->append(['roles_meta', 'is_superadmin', 'is_client']); + } + + /** + * Minimal roles relation (id, name, title) for roles_meta. + * Does not affect the original roles relationship. + */ + public function rolesMetaRelation(): BelongsToMany + { + $rolesTable = config('permission.table_names.roles'); + $relation = $this->morphToMany( + config('permission.models.role'), + 'model', + config('permission.table_names.model_has_roles'), + config('permission.column_names.model_morph_key'), + PermissionRegistrar::$pivotRole + )->select("{$rolesTable}.id", "{$rolesTable}.name", "{$rolesTable}.title"); + + if (! PermissionRegistrar::$teams) { + return $relation; + } + + return $relation->wherePivot(PermissionRegistrar::$teamsKey, getPermissionsTeamId()) + ->where(function ($q) use ($rolesTable) { + $teamField = "{$rolesTable}." . PermissionRegistrar::$teamsKey; + $q->whereNull($teamField)->orWhere($teamField, getPermissionsTeamId()); + }); + } + + protected function rolesMeta(): Attribute + { + return new Attribute( + get: fn () => $this->rolesMetaRelation + ); + } + + public function isSuperadmin(): Attribute + { + return new Attribute( + get: fn () => collect($this->roles_meta) + ->contains(fn ($role) => $role['name'] === 'superadmin'), + ); + } + + public function isAdmin(): Attribute + { + return new Attribute( + get: fn () => collect($this->roles_meta) + ->contains(fn ($role) => $role['name'] === 'admin'), + ); + } + + /** + * @deprecated Use $this->is_client instead + */ + public function isClient(): bool + { + return $this->is_client; + } + + public function getIsClientAttribute() + { + return collect($this->roles_meta) + ->contains(fn ($role) => Str::startsWith($role['name'], 'client')); + } + + public function existRole(string|Model $role): bool + { + $roleName = $role instanceof Model ? $role->name : $role; + + return collect($this->roles_meta) + ->contains(fn ($role) => $role['name'] === $roleName); + } + + public function existRoles(array $roles): bool + { + return collect($roles) + ->every(fn ($role) => $this->existRole($role)); + } + + public function existAnyRole(array $roles): bool + { + return collect($roles) + ->some(fn ($role) => $this->existRole($role)); + } +} diff --git a/src/Entities/User.php b/src/Entities/User.php index c85d81399..74d891d99 100755 --- a/src/Entities/User.php +++ b/src/Entities/User.php @@ -7,20 +7,17 @@ use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\HasFactory; -use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Session; -use Illuminate\Support\Str; use Laravel\Sanctum\HasApiTokens; -use Spatie\Permission\PermissionRegistrar; -use Spatie\Permission\Traits\HasRoles; use Unusualify\Modularity\Database\Factories\UserFactory; use Unusualify\Modularity\Entities\Traits\Auth\CanRegister; use Unusualify\Modularity\Entities\Traits\Auth\HasOauth; use Unusualify\Modularity\Entities\Traits\Core\HasCompany; use Unusualify\Modularity\Entities\Traits\Core\ModelHelpers; +use Unusualify\Modularity\Entities\Traits\Core\Rolable; use Unusualify\Modularity\Entities\Traits\HasFileponds; use Unusualify\Modularity\Entities\Traits\IsTranslatable; use Unusualify\Modularity\Notifications\GeneratePasswordNotification; @@ -30,7 +27,7 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma { use HasApiTokens, HasFactory, - HasRoles, + Rolable, IsTranslatable, ModelHelpers, Notifiable, @@ -80,12 +77,6 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma 'ui_preferences' => 'array', ]; - protected $appends = [ - 'roles_meta', - 'is_client', - 'is_superadmin', - ]; - protected static function boot() { parent::boot(); @@ -102,10 +93,6 @@ protected static function boot() $model->saveQuietly(); } }); - - static::addGlobalScope('roles_meta', function ($query) { - $query->with('rolesMetaRelation'); - }); } protected static function newFactory(): Factory @@ -113,39 +100,6 @@ protected static function newFactory(): Factory return UserFactory::new(); } - /** - * Minimal roles relation (id, name, title) for roles_meta. - * Does not affect the original roles relationship. - */ - public function rolesMetaRelation(): BelongsToMany - { - $rolesTable = config('permission.table_names.roles'); - $relation = $this->morphToMany( - config('permission.models.role'), - 'model', - config('permission.table_names.model_has_roles'), - config('permission.column_names.model_morph_key'), - PermissionRegistrar::$pivotRole - )->select("{$rolesTable}.id", "{$rolesTable}.name", "{$rolesTable}.title"); - - if (! PermissionRegistrar::$teams) { - return $relation; - } - - return $relation->wherePivot(PermissionRegistrar::$teamsKey, getPermissionsTeamId()) - ->where(function ($q) use ($rolesTable) { - $teamField = "{$rolesTable}." . PermissionRegistrar::$teamsKey; - $q->whereNull($teamField)->orWhere($teamField, getPermissionsTeamId()); - }); - } - - protected function rolesMeta(): Attribute - { - return new Attribute( - get: fn () => $this->rolesMetaRelation - ); - } - public function setImpersonating($id) { Session::put('impersonate', $id); @@ -161,41 +115,11 @@ public function isImpersonating() return Session::has('impersonate'); } - public function isSuperadmin(): Attribute - { - return new Attribute( - get: fn () => collect($this->roles_meta) - ->contains(fn ($role) => $role['name'] === 'superadmin'), - ); - } - - public function isAdmin(): Attribute - { - return new Attribute( - get: fn () => collect($this->roles_meta) - ->contains(fn ($role) => $role['name'] === 'admin'), - ); - } - - /** - * @deprecated Use $this->is_client instead - */ - public function isClient(): bool - { - return $this->is_client; - } - - public function getIsClientAttribute() - { - return collect($this->roles_meta) - ->contains(fn ($role) => Str::startsWith($role['name'], 'client')); - } - protected function avatar(): Attribute { return new Attribute( - get: fn ($value) => $this->fileponds() - ->where('role', 'avatar') + get: fn ($value) => $this->fileponds + ->filter(fn ($filepond) => $filepond->role === 'avatar') ->first()?->mediableFormat()['source'] ?? '/vendor/modularity/jpg/anonymous.jpg', ); } From ae2e4012c5389b2d93adc708f40771b836da9a38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Mon, 30 Mar 2026 17:41:01 +0300 Subject: [PATCH 017/163] fix(MakeInputHydrateCommand): correct argument description for input creation - Updated the argument description in MakeInputHydrateCommand from 'The name of theme to be created.' to 'The name of input to be created.' for improved clarity. --- src/Console/Make/MakeInputHydrateCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Console/Make/MakeInputHydrateCommand.php b/src/Console/Make/MakeInputHydrateCommand.php index 15153cbf3..a8d13dce8 100644 --- a/src/Console/Make/MakeInputHydrateCommand.php +++ b/src/Console/Make/MakeInputHydrateCommand.php @@ -83,7 +83,7 @@ public function handle(): int protected function getArguments() { return [ - ['name', InputArgument::REQUIRED, 'The name of theme to be created.'], + ['name', InputArgument::REQUIRED, 'The name of input to be created.'], ]; } From 4a43a5964b62f0b1f373d200ad39e9af80934557 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Tue, 31 Mar 2026 18:09:25 +0300 Subject: [PATCH 018/163] feat(Register): use configurable default role for user registration - Added 'default_register_role' to configuration for dynamic role assignment during user registration. - Updated RegisterController and CreateVerifiedEmailAccount trait to assign the role based on the new configuration instead of hardcoding 'client-manager'. --- config/config.php | 1 + src/Http/Controllers/Auth/RegisterController.php | 2 +- .../Controllers/Traits/Utilities/CreateVerifiedEmailAccount.php | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/config/config.php b/config/config.php index be75a8cb2..79a6d93ed 100755 --- a/config/config.php +++ b/config/config.php @@ -22,6 +22,7 @@ 'admin_route_name_prefix' => env('ADMIN_ROUTE_NAME_PREFIX', 'admin'), 'app_theme' => env('VUE_APP_THEME', 'unusualify'), 'available_user_locales' => explode(',', env('MODULARITY_AVAILABLE_USER_LOCALES', 'en')), + 'default_register_role' => env('MODULARITY_DEFAULT_REGISTER_ROLE', 'client-manager'), 'version' => '1.0.0', 'auth_login_redirect_path' => '/', diff --git a/src/Http/Controllers/Auth/RegisterController.php b/src/Http/Controllers/Auth/RegisterController.php index 581f2cfb8..e75767eea 100755 --- a/src/Http/Controllers/Auth/RegisterController.php +++ b/src/Http/Controllers/Auth/RegisterController.php @@ -86,7 +86,7 @@ protected function register(Request $request) 'language' => $request['language'] ?? app()->getLocale(), ]); - $user->assignRole('client-manager'); + $user->assignRole(modularityConfig('default_register_role')); event(new ModularityUserRegistered($user, $request)); diff --git a/src/Http/Controllers/Traits/Utilities/CreateVerifiedEmailAccount.php b/src/Http/Controllers/Traits/Utilities/CreateVerifiedEmailAccount.php index e51727d0c..5144150a9 100644 --- a/src/Http/Controllers/Traits/Utilities/CreateVerifiedEmailAccount.php +++ b/src/Http/Controllers/Traits/Utilities/CreateVerifiedEmailAccount.php @@ -167,7 +167,7 @@ public function setUserRegister(array $credentials) $user->setRememberToken(Str::random(60)); - $user->assignRole('client-manager'); + $user->assignRole(modularityConfig('default_register_role')); return $user; } From 8598744d5940cd000f15c2037befa39a31707bbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Tue, 31 Mar 2026 18:09:56 +0300 Subject: [PATCH 019/163] refactor(auth): streamline authentication handling with centralized redirect logic - Replaced direct store commits for opening login modals with a new utility function for full-page redirects on 401 responses. - Introduced `authRedirect.js` to manage login URL resolution and redirection based on server responses. - Updated Inertia interceptors to utilize the new redirect logic for improved clarity and maintainability. --- vue/src/js/setup/inertia-interceptors.js | 8 ++-- vue/src/js/setup/init.js | 18 +++++--- vue/src/js/utils/authRedirect.js | 58 ++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 11 deletions(-) create mode 100644 vue/src/js/utils/authRedirect.js diff --git a/vue/src/js/setup/inertia-interceptors.js b/vue/src/js/setup/inertia-interceptors.js index 040394cad..70ab7ff6d 100644 --- a/vue/src/js/setup/inertia-interceptors.js +++ b/vue/src/js/setup/inertia-interceptors.js @@ -1,7 +1,7 @@ import { router } from '@inertiajs/vue3' import store from '@/store' import { CONFIG } from '@/store/mutations' -import { USER } from '@/store/mutations' +import { redirectToLoginFullPage } from '@/utils/authRedirect' /** * Setup Inertia request interceptors @@ -53,9 +53,9 @@ export function setupInertiaInterceptors() { function handleInertiaSuccessResponse(visit) { // console.log('Inertia success response:', visit) - // Handle authentication redirects + // 401 JSON (e.g. misconfigured guard) — full-page login; normal Inertia auth uses 409 from Inertia::location() if (visit.response?.status === 401) { - store.commit(USER.OPEN_LOGIN_MODAL) + redirectToLoginFullPage() } // Handle CSRF token mismatch @@ -79,7 +79,7 @@ function handleInertiaErrorResponse(errorDetail) { // Handle authentication errors if (errorDetail.response?.status === 401) { - // store.commit(USER.OPEN_LOGIN_MODAL) + redirectToLoginFullPage() } // Handle validation errors diff --git a/vue/src/js/setup/init.js b/vue/src/js/setup/init.js index 08f00e47e..8d5f77f18 100755 --- a/vue/src/js/setup/init.js +++ b/vue/src/js/setup/init.js @@ -13,10 +13,11 @@ import 'moment/dist/locale/nl' import { useI18n } from 'vue-i18n' import store from '@/store' // Adjust the import based on your store structure -import { CONFIG, USER } from '@/store/mutations' +import { CONFIG } from '@/store/mutations' import { addParametersToUrl, replaceState } from '@/utils/pushState' import { handleSuccessResponse, handleErrorResponse } from '@/utils/response' +import { redirectFromUnauthorizedPayload, redirectToLoginFullPage } from '@/utils/authRedirect' /** * We'll load the axios HTTP library which allows us to easily issue requests @@ -225,13 +226,15 @@ export default function init(){ // Do something with response data store.commit(CONFIG.DECREASE_AXIOS_REQUEST) - // Check for 401 Unauthenticated error - if (response.status === 401 && response.data.message === 'Unauthenticated.') { - store.commit(USER.OPEN_LOGIN_MODAL) + // 401: server sends JSON { message, login_url?, redirect? }; client redirects when asked. + if (response.status === 401) { + if (!redirectFromUnauthorizedPayload(response.data)) { + redirectToLoginFullPage() + } } if (response.status === 419 || response.data.message === 'CSRF token mismatch.') { - // store.commit(USER.OPEN_LOGIN_MODAL) + // Optionally: reload or redirect to login } handleSuccessResponse(response) @@ -243,9 +246,10 @@ export default function init(){ store.commit(CONFIG.DECREASE_AXIOS_REQUEST) // Any status codes that falls outside the range of 2xx cause this function to trigger - // Check for 401 Unauthenticated error if (error.response?.status === 401) { - store.commit(USER.OPEN_LOGIN_MODAL) + if (!redirectFromUnauthorizedPayload(error.response?.data)) { + redirectToLoginFullPage() + } } // Do something with response error return Promise.reject(error); diff --git a/vue/src/js/utils/authRedirect.js b/vue/src/js/utils/authRedirect.js new file mode 100644 index 000000000..10f65df5a --- /dev/null +++ b/vue/src/js/utils/authRedirect.js @@ -0,0 +1,58 @@ +import store from '@/store' + +/** + * Full-page login URL (Ziggy, blade STORE, Vuex). + */ +export function resolveLoginUrl () { + if (typeof window !== 'undefined' && typeof window.route === 'function') { + const candidates = ['admin.login.form', 'login.form', 'admin.login', 'login'] + for (const routeName of candidates) { + try { + const u = window.route(routeName) + if (u) { + return u + } + } catch { + /* try next */ + } + } + } + + const ns = import.meta.env.VUE_APP_NAME + if (typeof window !== 'undefined' && ns && window[ns]?.STORE?.user?.loginRoute) { + return window[ns].STORE.user.loginRoute + } + + if (typeof window !== 'undefined' && window.MODULARITY?.STORE?.user?.loginRoute) { + return window.MODULARITY.STORE.user.loginRoute + } + + const fromStore = store.state.user?.loginRoute + if (fromStore) { + return fromStore + } + + return '' +} + +/** + * 401 JSON from AuthenticateMiddleware (`login_url`, `redirect`). + * + * @returns {boolean} true if navigation was triggered + */ +export function redirectFromUnauthorizedPayload (data) { + if (data?.redirect === true && data?.login_url) { + window.location.assign(data.login_url) + + return true + } + + return false +} + +export function redirectToLoginFullPage () { + const url = resolveLoginUrl() + if (url) { + window.location.assign(url) + } +} From ff7e410c7de76d6338ae8b390f5afe7a654ed4a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Tue, 31 Mar 2026 20:56:15 +0300 Subject: [PATCH 020/163] fix(auth): enhance authentication handling for Inertia and AJAX requests - Introduced HandlesUnauthenticatedInertiaAndAjax trait to manage unauthenticated responses for Inertia and AJAX requests. - Updated AuthenticateMiddleware to utilize the new trait, providing a fallback login URL and improved session handling for intended URLs. - Removed hardcoded login route from HandleInertiaRequests to streamline login URL management. --- .../Middleware/AuthenticateMiddleware.php | 60 +++++++------ .../HandlesUnauthenticatedInertiaAndAjax.php | 89 +++++++++++++++++++ 2 files changed, 121 insertions(+), 28 deletions(-) create mode 100644 src/Http/Middleware/Concerns/HandlesUnauthenticatedInertiaAndAjax.php diff --git a/src/Http/Middleware/AuthenticateMiddleware.php b/src/Http/Middleware/AuthenticateMiddleware.php index 96293ae46..77a785cad 100755 --- a/src/Http/Middleware/AuthenticateMiddleware.php +++ b/src/Http/Middleware/AuthenticateMiddleware.php @@ -3,53 +3,57 @@ namespace Unusualify\Modularity\Http\Middleware; use Illuminate\Auth\Middleware\Authenticate as Middleware; -use Illuminate\Http\Request; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Route; use Unusualify\Modularity\Facades\Modularity; +use Unusualify\Modularity\Http\Middleware\Concerns\HandlesUnauthenticatedInertiaAndAjax; class AuthenticateMiddleware extends Middleware { + use HandlesUnauthenticatedInertiaAndAjax; + + protected function fallbackLoginUrlForUnauthenticated(): string + { + if (Route::has('admin.login')) { + return route('admin.login'); + } + + return route(Route::hasAdmin('login.form')); + } + /** * Get the path the user should be redirected to when they are not authenticated. * - * @param Request $request * @return string|null */ protected function redirectTo($request) { - if (! $request->expectsJson()) { - $modularityAdminRouteNamePrefix = Modularity::getAdminRouteNamePrefix(); - // Define auth routes that should not be stored as intended URL - $excludedRoutes = Arr::map([ - 'login.form', 'login', 'logout', - 'register.form', 'register', 'register.success', - 'password.reset.link', 'password.reset.email', - 'password.reset.success', 'password.reset', - 'password.reset.update', - 'impersonate.stop', 'impersonate', - ], function ($route) use ($modularityAdminRouteNamePrefix) { - return $modularityAdminRouteNamePrefix ? $modularityAdminRouteNamePrefix . '.' . $route : $route; - }); - - // Only store the previous URL if it's not an auth route - if (! in_array($request->route()->getName(), $excludedRoutes)) { - session()->put('url.intended', url()->previous()); - } - } - if ($request->expectsJson()) { $referer = $request->headers->get('referer'); session()->put('url.intended', $referer); - return response()->json([ - 'message' => 'Unauthenticated.', - 'mode' => 'experimental', - // 'redirector' => $referer, - ], 401); + return null; + } + + $modularityAdminRouteNamePrefix = Modularity::getAdminRouteNamePrefix(); + // Define auth routes that should not be stored as intended URL + $excludedRoutes = Arr::map([ + 'login.form', 'login', 'logout', + 'register.form', 'register', 'register.success', + 'password.reset.link', 'password.reset.email', + 'password.reset.success', 'password.reset', + 'password.reset.update', + 'impersonate.stop', 'impersonate', + ], function ($route) use ($modularityAdminRouteNamePrefix) { + return $modularityAdminRouteNamePrefix ? $modularityAdminRouteNamePrefix . '.' . $route : $route; + }); + + $routeName = $request->route()?->getName(); + // Only store the previous URL if it's not an auth route + if ($routeName === null || ! in_array($routeName, $excludedRoutes)) { + session()->put('url.intended', url()->previous()); } return route(Route::hasAdmin('login.form')); - // return $request->expectsJson() ? null : route('login.create'); } } diff --git a/src/Http/Middleware/Concerns/HandlesUnauthenticatedInertiaAndAjax.php b/src/Http/Middleware/Concerns/HandlesUnauthenticatedInertiaAndAjax.php new file mode 100644 index 000000000..5c6bdf888 --- /dev/null +++ b/src/Http/Middleware/Concerns/HandlesUnauthenticatedInertiaAndAjax.php @@ -0,0 +1,89 @@ +redirectTo() ?? $this->fallbackLoginUrlForUnauthenticated(); + + if ($request->header('X-Inertia')) { + return response('', 409)->withHeaders([ + 'X-Inertia-Location' => $loginUrl, + ]); + } + + if ($request->ajax()) { + return response()->json([ + 'message' => $e->getMessage(), + 'login_url' => $loginUrl ?: null, + 'redirect' => (bool) $loginUrl, + ], 401); + } + + throw $e; + } + + // Pipeline bazen AuthenticationException'ı render edip RedirectResponse(302) olarak döndürür. + // Bu durumu Inertia/AJAX istekleri için de dönüştür. + if ($response instanceof RedirectResponse && $this->isLoginRedirectResponse($response)) { + if ($request->header('X-Inertia')) { + return response('', 409)->withHeaders([ + 'X-Inertia-Location' => $response->getTargetUrl(), + ]); + } + + if ($request->ajax()) { + return response()->json([ + 'message' => __('Unauthenticated.'), + 'login_url' => $response->getTargetUrl(), + 'redirect' => true, + ], 401); + } + } + + return $response; + } + + /** + * Redirect response'un login sayfasına yönlendirip yönlendirmediğini kontrol eder. + * /login, /admin/login, /system/login gibi "login" ile biten path'leri yakalar. + */ + protected function isLoginRedirectResponse(RedirectResponse $response): bool + { + if (! in_array($response->getStatusCode(), [301, 302, 303], true)) { + return false; + } + + $path = trim(parse_url($response->getTargetUrl(), PHP_URL_PATH) ?? '', '/'); + + return str_ends_with($path, 'login'); + } + + protected function fallbackLoginUrlForUnauthenticated(): string + { + return route('login'); + } +} From bb88148aced5b072b8cfb4dbb55f71ecfb7dea5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Tue, 31 Mar 2026 20:58:09 +0300 Subject: [PATCH 021/163] fix(auth): enhance google oauth button flex - Updated the OAuth Google button to use a dynamic route for improved flexibility. --- src/Http/Controllers/Traits/Utilities/AuthFormBuilder.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Http/Controllers/Traits/Utilities/AuthFormBuilder.php b/src/Http/Controllers/Traits/Utilities/AuthFormBuilder.php index a17e0296e..b8e867ecd 100644 --- a/src/Http/Controllers/Traits/Utilities/AuthFormBuilder.php +++ b/src/Http/Controllers/Traits/Utilities/AuthFormBuilder.php @@ -82,6 +82,7 @@ protected function oauthGoogleButtonSlot(string $type = 'sign-in'): array 'class' => 'mt-5 mb-2 custom-auth-button', 'color' => 'grey-lighten-1', 'density' => 'default', + 'block' => true, ], 'slots' => [ 'prepend' => [ From f759aa521f1c71e9a6443d53146978b7d8acf811 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Wed, 1 Apr 2026 05:41:53 +0300 Subject: [PATCH 022/163] feat(StepUpChallenge): add StepUpChallenge component for OTP verification - Introduced a new StepUpChallenge component to handle OTP verification. - Implemented a form for users to input their verification code and a button to resend the code. - Added logic for handling form submission and resending verification codes with appropriate notifications. --- vue/src/js/components/StepUpChallenge.vue | 105 ++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 vue/src/js/components/StepUpChallenge.vue diff --git a/vue/src/js/components/StepUpChallenge.vue b/vue/src/js/components/StepUpChallenge.vue new file mode 100644 index 000000000..823b06d03 --- /dev/null +++ b/vue/src/js/components/StepUpChallenge.vue @@ -0,0 +1,105 @@ + + + From cde57c0c37e5214911ce12ae5d39cf10c23676b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Wed, 1 Apr 2026 05:42:21 +0300 Subject: [PATCH 023/163] fix(api): update validateStatus to include 428 status for POST and PUT requests - Enhanced the validateStatus function in the API form module to account for 428 status, improving error handling for precondition requirements in server responses. --- vue/src/js/store/api/form.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vue/src/js/store/api/form.js b/vue/src/js/store/api/form.js index 70302cf98..ba151600c 100755 --- a/vue/src/js/store/api/form.js +++ b/vue/src/js/store/api/form.js @@ -41,7 +41,7 @@ export default { }, post (endpoint, data, callback, errorCallback) { axios.post(endpoint, withCsrfToken(data), { - validateStatus: status => (status >= 200 && status < 300) || status === 422 || status === 403 || status === 419 + validateStatus: status => (status >= 200 && status < 300) || status === 422 || status === 403 || status === 419 || status === 428 }).then(function (resp) { if (callback && typeof callback === 'function') callback(resp) }).catch(function (err) { @@ -57,7 +57,7 @@ export default { }, put (endpoint, data, callback, errorCallback) { axios.put(endpoint, withCsrfToken(data), { - validateStatus: status => (status >= 200 && status < 300) || status === 422 || status === 403 || status === 419 + validateStatus: status => (status >= 200 && status < 300) || status === 422 || status === 403 || status === 419 || status === 428 }).then(function (resp) { if (callback && typeof callback === 'function') callback(resp) }).catch(function (err) { From 6009b0198e8a35cdcd9492438ae62441c0f641f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Wed, 1 Apr 2026 05:42:35 +0300 Subject: [PATCH 024/163] feat(useInputFetch): enhance input fetching with configurable keys for pagination and data - Added configurable props for last page, current page, and data keys to the useInputFetch hook. - Utilized lodash's get function to safely access nested properties in the response data, improving robustness and flexibility. --- vue/src/js/hooks/useInputFetch.js | 34 +++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/vue/src/js/hooks/useInputFetch.js b/vue/src/js/hooks/useInputFetch.js index 1cd805e16..d94443014 100644 --- a/vue/src/js/hooks/useInputFetch.js +++ b/vue/src/js/hooks/useInputFetch.js @@ -2,6 +2,8 @@ import { ref, computed } from 'vue' import { propsFactory } from 'vuetify/lib/util/index.mjs' // Types +import { get } from 'lodash-es' + import { makeSelectProps } from '@/hooks/utils/useSelect.js' import { usePagination, makePaginationProps } from '@/hooks/utils/usePagination.js' @@ -9,6 +11,18 @@ import { usePagination, makePaginationProps } from '@/hooks/utils/usePagination. export const makeInputFetchProps = propsFactory({ ...makeSelectProps(), ...makePaginationProps(), + lastPageKey: { + type: String, + default: 'resource.last_page' + }, + currentPageKey: { + type: String, + default: 'resource.current_page' + }, + dataKey: { + type: String, + default: 'resource.data' + } }) export default function useInputFetch(props, context) { @@ -45,16 +59,16 @@ export default function useInputFetch(props, context) { if(response.status == 200){ if(activeLastPage.value < 0) - activeLastPage.value = response.data.resource.last_page + activeLastPage.value = get(response.data, props.lastPageKey, 1) if(search.value == ''){ - appendElements(response.data.resource.data ?? []); + appendElements(get(response.data, props.dataKey) ?? []); }else{ - setElements(response.data.resource.data ?? []) + setElements(get(response.data, props.dataKey) ?? []) } // page.value++; - setActivePage(response.data.resource.current_page) + setActivePage(get(response.data, props.currentPageKey, 1)) if(!!context.input.value){ let searchContinue = false; @@ -103,6 +117,18 @@ export default function useInputFetch(props, context) { key: 'sourceLoading', value: false }, + { + key: 'items', + value: elements.value + }, + { + key: 'lastPage', + value: activeLastPage.value + }, + { + key: 'page', + value: activePage.value + } ]) } From 11a785605ad38f720fc827bc72ee271450f04036 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Wed, 1 Apr 2026 05:42:47 +0300 Subject: [PATCH 025/163] feat(useForm): implement step-up verification handling in form submission - Added logic to handle 428 status responses during form submission, triggering a modal for step-up verification when required. - Refactored the saveForm function to include a submitRequest helper for improved readability and reusability. - Enhanced error handling to manage server responses more effectively, ensuring a smoother user experience. --- vue/src/js/hooks/useForm.js | 64 +++++++++++++++++++++++++++++++------ 1 file changed, 54 insertions(+), 10 deletions(-) diff --git a/vue/src/js/hooks/useForm.js b/vue/src/js/hooks/useForm.js index 7c3241c6d..e21e25d7f 100644 --- a/vue/src/js/hooks/useForm.js +++ b/vue/src/js/hooks/useForm.js @@ -239,7 +239,6 @@ export default function useForm(props, context) { const inputSchema = ref(validations.invokeRuleGenerator(getSchema(rawSchema.value, { ...model.value, ...formItem.value }, props.isEditing))) // const inputSchema = ref(getSchema(rawSchema.value, { ...model.value, ...formItem.value }, props.isEditing)) - console.log('inputSchema', inputSchema.value) const formEventSchema = ref(getFormEventSchema(rawSchema.value, { ...model.value, ...formItem.value }, props.isEditing)) const extraValids = ref(props.actions.length ? props.actions.map(() => true) : []) @@ -344,17 +343,31 @@ export default function useForm(props, context) { } const saveForm = (callback = null, errorCallback = null) => { + const submitRequest = (method, endpoint, data) => { + api[method](endpoint, data, + (response) => { + if (response.status === 428 && response.data?.step_up_required) { + formLoading.value = false + + window.$modalService.open('ue-step-up-challenge', { + props: { + stepUp: response.data.step_up ?? {} + }, + emits: { + verified: () => { + formLoading.value = true + submitRequest(method, endpoint, data) + } + }, + modalProps: { + noActions: true, + // title: response.data?.step_up?.title ?? 'Verification required' + } + }) - if (props.actionUrl) { - formErrors.value = {} - formLoading.value = true - - const formData = getSubmitFormData(rawSchema.value, states.model, store._state.data) - - const method = Object.prototype.hasOwnProperty.call(formData, 'id') ? 'put' : 'post' + return + } - api[method](props.actionUrl, formData, - (response) => { formLoading.value = false if (Object.prototype.hasOwnProperty.call(response.data, 'errors')) { serverValid.value = false @@ -393,6 +406,27 @@ export default function useForm(props, context) { }, (response) => { formLoading.value = false + + if (response?.status === 428 && response?.data?.step_up_required) { + window.$modalService.open('ue-step-up-challenge', { + props: { + stepUp: response.data.step_up ?? {}, + noActions: true, + }, + emits: { + verified: () => { + formLoading.value = true + submitRequest(method, endpoint, data) + } + }, + modalProps: { + title: response.data?.step_up?.title ?? 'Verification required' + } + }) + + return + } + if (Object.prototype.hasOwnProperty.call(response.data, 'exception')) { store.commit(ALERT.SET_ALERT, { message: 'Your submission could not be processed.', variant: 'error' }) } else { @@ -403,6 +437,16 @@ export default function useForm(props, context) { } ) } + + if (props.actionUrl) { + formErrors.value = {} + formLoading.value = true + + const formData = getSubmitFormData(rawSchema.value, states.model, store._state.data) + const method = Object.prototype.hasOwnProperty.call(formData, 'id') ? 'put' : 'post' + + submitRequest(method, props.actionUrl, formData) + } } const sendSync = (e) => { From 4455fc47ea4853b53179a1ceededd48b6232da91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Thu, 2 Apr 2026 01:08:55 +0300 Subject: [PATCH 026/163] feat(auth): implement MFA setup and authentication handling - Introduced multi-factor authentication (MFA) setup enforcement during login, ensuring users meet security requirements based on their roles. - Added traits for handling MFA authentication and enforcing MFA setup, streamlining the login process. - Updated the LoginController to manage MFA login flows, including handling OTP challenges and user session management. - Implemented middleware for session security and MFA requirements, enhancing overall application security. - Created a notification system for sending MFA codes via email, improving user experience during the login process. --- src/Http/Controllers/Auth/LoginController.php | 92 ++--- .../Utilities/EnforcesMfaSetupOnLogin.php | 45 +++ .../Utilities/HandlesMfaAuthentication.php | 342 +++++++++++++++++ src/Http/Middleware/RequireMfaMiddleware.php | 48 +++ .../Middleware/SessionSecurityMiddleware.php | 42 +++ .../LoginMfaCodeNotification.php | 36 ++ src/Providers/ModularityProvider.php | 1 + src/Providers/SecurityServiceProvider.php | 36 ++ src/Services/Security/SecurityService.php | 353 ++++++++++++++++++ 9 files changed, 952 insertions(+), 43 deletions(-) create mode 100644 src/Http/Controllers/Traits/Utilities/EnforcesMfaSetupOnLogin.php create mode 100644 src/Http/Controllers/Traits/Utilities/HandlesMfaAuthentication.php create mode 100644 src/Http/Middleware/RequireMfaMiddleware.php create mode 100644 src/Http/Middleware/SessionSecurityMiddleware.php create mode 100644 src/Notifications/LoginMfaCodeNotification.php create mode 100644 src/Providers/SecurityServiceProvider.php create mode 100644 src/Services/Security/SecurityService.php diff --git a/src/Http/Controllers/Auth/LoginController.php b/src/Http/Controllers/Auth/LoginController.php index 69425aec5..b518a0fd9 100755 --- a/src/Http/Controllers/Auth/LoginController.php +++ b/src/Http/Controllers/Auth/LoginController.php @@ -14,18 +14,18 @@ use Illuminate\Validation\ValidationException; use Illuminate\View\Factory as ViewFactory; use Illuminate\View\View; -use PragmaRX\Google2FA\Exceptions\IncompatibleWithGoogleAuthenticatorException; -use PragmaRX\Google2FA\Exceptions\InvalidCharactersException; -use PragmaRX\Google2FA\Exceptions\SecretKeyTooShortException; -use PragmaRX\Google2FA\Google2FA; -use Unusualify\Modularity\Entities\User; use Unusualify\Modularity\Facades\Modularity; +use Unusualify\Modularity\Http\Controllers\Traits\Utilities\EnforcesMfaSetupOnLogin; +use Unusualify\Modularity\Http\Controllers\Traits\Utilities\HandlesMfaAuthentication; use Unusualify\Modularity\Http\Controllers\Traits\Utilities\HandlesOAuth; use Unusualify\Modularity\Services\MessageStage; class LoginController extends Controller { - use AuthenticatesUsers, HandlesOAuth; + use AuthenticatesUsers { + login as protected defaultPasswordLogin; + } + use EnforcesMfaSetupOnLogin, HandlesMfaAuthentication, HandlesOAuth; /** * @var AuthManager @@ -67,7 +67,20 @@ protected function guard() public function showForm() { - return $this->viewFactory->make(modularityBaseKey() . '::auth.login', $this->buildAuthViewData('login')); + $pageKey = $this->shouldUseMfaLoginFlow() + ? $this->mfaLoginPageKey() + : 'login'; + + return $this->viewFactory->make(modularityBaseKey() . '::auth.login', $this->buildAuthViewData($pageKey)); + } + + public function login(Request $request) + { + if ($this->shouldUseMfaLoginFlow()) { + return $this->handleMfaLoginRequest($request); + } + + return $this->defaultPasswordLogin($request); } /** @@ -75,7 +88,14 @@ public function showForm() */ public function showLogin2FaForm() { - return $this->viewFactory->make(modularityBaseKey() . '::auth.2fa'); + if (! $this->isMfaEnabled()) { + return $this->viewFactory->make(modularityBaseKey() . '::auth.login', $this->buildAuthViewData('login')); + } + + return $this->viewFactory->make( + modularityBaseKey() . '::auth.login', + $this->buildAuthViewData($this->mfaChallengePageKey()) + ); } /** @@ -103,18 +123,12 @@ protected function authenticated(Request $request, $user) protected function afterAuthentication(Request $request, $user) { - // dd('here',$user->google_2fa_secret && $user->google_2fa_enabled); - - if ($user->google_2fa_secret && $user->google_2fa_enabled) { - $this->guard()->logout(); - - $request->session()->put('2fa:user:id', $user->id); + if ($mfaResponse = $this->enforceMfaSetupOnLogin($request, $user)) { + return $mfaResponse; + } - return $request->wantsJson() - ? new JsonResponse([ - 'redirector' => $this->redirector->to(route(Route::hasAdmin('admin.login-2fa.form')))->getTargetUrl(), - ]) - : $this->redirector->to(route(Route::hasAdmin('admin.login-2fa.form'))); + if ($mfaChallenge = $this->startMfaChallenge($request, $user)) { + return $mfaChallenge; } $previousRouteName = previous_route_name(); @@ -140,35 +154,23 @@ protected function afterAuthentication(Request $request, $user) } - /** - * @return RedirectResponse - * - * @throws IncompatibleWithGoogleAuthenticatorException - * @throws InvalidCharactersException - * @throws SecretKeyTooShortException - */ public function login2Fa(Request $request) { - $userId = $request->session()->get('2fa:user:id'); - - $user = User::findOrFail($userId); - - $valid = (new Google2FA)->verifyKey( - $user->google_2fa_secret, - $request->input('verify-code') - ); + if (! $this->isMfaEnabled()) { + return $this->redirector->to(route(Route::hasAdmin('login.form'))); + } - if ($valid) { - $this->authManager->guard(Modularity::getAuthGuardName())->loginUsingId($userId); + $user = $this->resolveMfaUserFromSession($request); - $request->session()->pull('2fa:user:id'); + if (! $user) { + return $this->mfaFailureResponse($request, 'Your MFA session has expired. Please login again.'); + } - return $this->redirector->intended($this->redirectTo); + if (! $this->validateMfaOtp($user, $request)) { + return $this->mfaFailureResponse($request, 'Your one time password is invalid.'); } - return $this->redirector->to(route(Route::hasAdmin('admin.login-2fa.form')))->withErrors([ - 'error' => 'Your one time password is invalid.', - ]); + return $this->completeMfaLogin($request, $user); } public function redirectTo() @@ -184,11 +186,15 @@ public function redirectTo() protected function sendFailedLoginResponse(Request $request) { if ($request->wantsJson()) { - return new JsonResponse([ + $errors = [ $this->username() => [trans('auth.failed')], + ]; + + return new JsonResponse([ + 'errors' => $errors, 'message' => __('auth.failed'), 'variant' => MessageStage::WARNING, - ], 200); + ], 422); } throw ValidationException::withMessages([ diff --git a/src/Http/Controllers/Traits/Utilities/EnforcesMfaSetupOnLogin.php b/src/Http/Controllers/Traits/Utilities/EnforcesMfaSetupOnLogin.php new file mode 100644 index 000000000..13fe580d5 --- /dev/null +++ b/src/Http/Controllers/Traits/Utilities/EnforcesMfaSetupOnLogin.php @@ -0,0 +1,45 @@ +bound(SecurityService::class) + ? app()->make(SecurityService::class) + : null; + + if ( + $securityService === null + || ! modularityConfig('security.enabled', false) + || ! modularityConfig('security.mfa.enabled', false) + || ! modularityConfig('security.mfa.strict', true) + || ! $securityService->userRequiresMfa($user) + || $securityService->userHasEnabledMfa($user) + ) { + return null; + } + + $this->guard()->logout(); + + $message = __('MFA setup is required for your role.'); + + return $request->wantsJson() + ? new JsonResponse([ + 'message' => $message, + 'variant' => MessageStage::WARNING, + ], 403) + : redirect()->to(route(Route::hasAdmin('login.form')))->withErrors([ + 'mfa' => $message, + ]); + } +} diff --git a/src/Http/Controllers/Traits/Utilities/HandlesMfaAuthentication.php b/src/Http/Controllers/Traits/Utilities/HandlesMfaAuthentication.php new file mode 100644 index 000000000..6c21ed61b --- /dev/null +++ b/src/Http/Controllers/Traits/Utilities/HandlesMfaAuthentication.php @@ -0,0 +1,342 @@ +isMfaEnabled() + && (bool) modularityConfig('security.mfa.remove_password_login', true); + } + + protected function isMfaEnabled(): bool + { + return (bool) modularityConfig('security.mfa.enabled', false); + } + + protected function mfaProvider(): string + { + return (string) modularityConfig('security.mfa.provider', 'email_otp'); + } + + protected function mfaSessionKey(): string + { + return (string) modularityConfig('security.mfa.session_key', '2fa:user:id'); + } + + protected function mfaFlowSessionKey(): string + { + return (string) modularityConfig('security.mfa.flow_session_key', '2fa:flow:key'); + } + + protected function mfaOtpField(): string + { + return (string) modularityConfig('security.mfa.otp_field', 'verify-code'); + } + + protected function mfaChallengePageKey(): string + { + return (string) modularityConfig('security.mfa.challenge_page', 'login_2fa'); + } + + protected function mfaLoginPageKey(): string + { + return (string) modularityConfig('security.mfa.login_page', 'login_mfa'); + } + + protected function mfaChallengeFormRoute(): string + { + return (string) modularityConfig('security.mfa.challenge_form_route', Route::hasAdmin('login-2fa.form')); + } + + protected function mfaRegistrationSuccessRoute(): string + { + return (string) modularityConfig('security.mfa.registration_success_route', Route::hasAdmin('register.verification.success')); + } + + protected function mfaCodeLength(): int + { + return (int) modularityConfig('security.mfa.email_otp.length', 6); + } + + protected function mfaCodeExpiryMinutes(): int + { + return (int) modularityConfig('security.mfa.email_otp.expire_minutes', 10); + } + + protected function mfaCodeMaxAttempts(): int + { + return (int) modularityConfig('security.mfa.email_otp.max_attempts', 5); + } + + protected function mfaCachePrefix(): string + { + return (string) modularityConfig('security.mfa.email_otp.cache_prefix', 'mfa:email-otp'); + } + + protected function mfaAllowsRegistrationFromLogin(): bool + { + return (bool) modularityConfig('security.mfa.register_first_time', true); + } + + protected function usesEmailOtpMfaProvider(): bool + { + return $this->mfaProvider() === 'email_otp'; + } + + protected function userHasMfaEnabled($user): bool + { + return ! empty($user?->google_2fa_secret) && (bool) ($user?->google_2fa_enabled ?? false); + } + + protected function resolveChallengeRouteName(): string + { + $challengeRoute = $this->mfaChallengeFormRoute(); + + if (! Route::has($challengeRoute)) { + return Route::hasAdmin('login.form'); + } + + return $challengeRoute; + } + + protected function resolveRegistrationSuccessRouteName(): string + { + $routeName = $this->mfaRegistrationSuccessRoute(); + + if (! Route::has($routeName)) { + return Route::hasAdmin('register.verification.success'); + } + + return $routeName; + } + + protected function generateMfaCode(): string + { + $length = max(4, min(10, $this->mfaCodeLength())); + $max = (10 ** $length) - 1; + + return str_pad((string) random_int(0, $max), $length, '0', STR_PAD_LEFT); + } + + protected function createEmailOtpChallenge(Request $request, User $user): string + { + $flowKey = $this->mfaCachePrefix() . ':' . (string) Str::uuid(); + $code = $this->generateMfaCode(); + $expiresAt = now()->addMinutes($this->mfaCodeExpiryMinutes()); + + Cache::put($flowKey, [ + 'user_id' => $user->id, + 'email' => $user->email, + 'code_hash' => Hash::make($code), + 'attempts' => 0, + 'expires_at' => $expiresAt->toDateTimeString(), + ], $expiresAt); + + $request->session()->put($this->mfaFlowSessionKey(), $flowKey); + $request->session()->put($this->mfaSessionKey(), $user->id); + + $user->notify(new LoginMfaCodeNotification( + code: $code, + expiresAt: $expiresAt, + )); + + return $flowKey; + } + + protected function registrationFromMfaLoginResponse(Request $request, string $email): JsonResponse|RedirectResponse + { + if (! $this->mfaAllowsRegistrationFromLogin()) { + return $this->mfaLoginFailedResponse($request, __('auth.failed')); + } + + $response = Register::broker('register_verified_users')->sendVerificationLink( + ['email' => $email], + function ($notifiable, $token) { + $notifiable->sendRegisterNotification($token); + } + ); + + if ($response !== RegisterBroker::VERIFICATION_LINK_SENT) { + return $this->mfaLoginFailedResponse($request, __($response)); + } + + $redirectRoute = $this->resolveRegistrationSuccessRouteName(); + + if ($request->wantsJson()) { + return new JsonResponse([ + 'message' => __('authentication.pre-register-description'), + 'variant' => MessageStage::SUCCESS, + 'redirector' => route($redirectRoute), + ], 200); + } + + return redirect()->route($redirectRoute)->with('status', __('authentication.pre-register-description')); + } + + protected function handleMfaLoginRequest(Request $request): JsonResponse|RedirectResponse + { + $request->validate(['email' => 'required|email']); + + $user = User::where('email', $request->string('email')->toString())->first(); + + if (! $user) { + return $this->registrationFromMfaLoginResponse($request, $request->string('email')->toString()); + } + + return $this->startMfaChallenge($request, $user) + ?? $this->mfaLoginFailedResponse($request, __('auth.failed')); + } + + protected function mfaLoginFailedResponse(Request $request, string $message): JsonResponse|RedirectResponse + { + if ($request->wantsJson()) { + return new JsonResponse([ + 'errors' => [ + 'email' => [$message], + ], + 'message' => $message, + 'variant' => MessageStage::WARNING, + ], 422); + } + + return back() + ->withInput($request->only('email')) + ->withErrors(['email' => $message]); + } + + protected function startMfaChallenge(Request $request, $user): JsonResponse|RedirectResponse|null + { + if (! $this->isMfaEnabled()) { + return null; + } + + if ($this->usesEmailOtpMfaProvider()) { + $this->createEmailOtpChallenge($request, $user); + } elseif ($this->userHasMfaEnabled($user)) { + $this->guard()->logout(); + $request->session()->put($this->mfaSessionKey(), $user->id); + } else { + return null; + } + + $challengeRoute = $this->resolveChallengeRouteName(); + + $redirectUrl = $this->redirector->to(route($challengeRoute))->getTargetUrl(); + + return $request->wantsJson() + ? new JsonResponse(['redirector' => $redirectUrl], 200) + : $this->redirector->to($redirectUrl); + } + + protected function resolveMfaUserFromSession(Request $request): ?User + { + $userId = $request->session()->get($this->mfaSessionKey()); + + if (! $userId) { + return null; + } + + return User::find($userId); + } + + protected function clearMfaSession(Request $request): void + { + $flowKey = (string) $request->session()->get($this->mfaFlowSessionKey(), ''); + if ($flowKey !== '') { + Cache::forget($flowKey); + } + + $request->session()->forget($this->mfaSessionKey()); + $request->session()->forget($this->mfaFlowSessionKey()); + } + + protected function validateMfaOtp(User $user, Request $request): bool + { + if ($this->usesEmailOtpMfaProvider()) { + $flowKey = (string) $request->session()->get($this->mfaFlowSessionKey(), ''); + $challenge = $flowKey !== '' ? Cache::get($flowKey) : null; + + if (! is_array($challenge)) { + return false; + } + + if ((int) ($challenge['user_id'] ?? 0) !== (int) $user->id) { + return false; + } + + if ((int) ($challenge['attempts'] ?? 0) >= $this->mfaCodeMaxAttempts()) { + Cache::forget($flowKey); + + return false; + } + + $otp = (string) $request->input($this->mfaOtpField(), ''); + $valid = Hash::check($otp, (string) ($challenge['code_hash'] ?? '')); + + if (! $valid) { + $challenge['attempts'] = (int) ($challenge['attempts'] ?? 0) + 1; + $expiresAt = now()->addMinutes($this->mfaCodeExpiryMinutes()); + Cache::put($flowKey, $challenge, $expiresAt); + } + + return $valid; + } + + $otp = (string) $request->input($this->mfaOtpField(), ''); + + return (new Google2FA)->verifyKey((string) $user->google_2fa_secret, $otp); + } + + protected function mfaFailureResponse(Request $request, string $message): JsonResponse|RedirectResponse + { + $challengeRoute = $this->resolveChallengeRouteName(); + + if ($request->wantsJson()) { + return new JsonResponse([ + 'message' => $message, + 'variant' => MessageStage::WARNING, + ], 422); + } + + return $this->redirector->to(route($challengeRoute))->withErrors([ + 'error' => $message, + ]); + } + + protected function completeMfaLogin(Request $request, User $user): JsonResponse|RedirectResponse + { + $this->authManager->guard(Modularity::getAuthGuardName())->loginUsingId($user->id); + $this->clearMfaSession($request); + + $redirectUrl = redirect()->intended($this->redirectTo)->getTargetUrl(); + + if ($request->wantsJson()) { + return new JsonResponse([ + 'message' => __('authentication.login-success-message'), + 'variant' => MessageStage::SUCCESS, + 'redirector' => $redirectUrl, + ], 200); + } + + return $this->redirector->intended($this->redirectTo); + } +} diff --git a/src/Http/Middleware/RequireMfaMiddleware.php b/src/Http/Middleware/RequireMfaMiddleware.php new file mode 100644 index 000000000..70230bc2d --- /dev/null +++ b/src/Http/Middleware/RequireMfaMiddleware.php @@ -0,0 +1,48 @@ +user(); + + if (! $this->securityService->userRequiresMfa($user)) { + return $next($request); + } + + if ($this->securityService->userHasEnabledMfa($user)) { + return $next($request); + } + + if ($request->expectsJson()) { + return response()->json([ + 'message' => 'MFA is required for this role.', + ], 403); + } + + if (Route::has('admin.login.form')) { + auth()->logout(); + + return redirect()->route('admin.login.form')->withErrors([ + 'mfa' => 'MFA setup is required before continuing.', + ]); + } + + abort(403, 'MFA is required for this role.'); + } +} diff --git a/src/Http/Middleware/SessionSecurityMiddleware.php b/src/Http/Middleware/SessionSecurityMiddleware.php new file mode 100644 index 000000000..8d645013b --- /dev/null +++ b/src/Http/Middleware/SessionSecurityMiddleware.php @@ -0,0 +1,42 @@ +session()->get('security_last_seen_at', time()); + + if ((time() - $lastSeenAt) > ($idleTimeoutMinutes * 60)) { + Auth::logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + if ($request->expectsJson()) { + return response()->json(['message' => 'Session timed out. Please login again.'], 401); + } + + return redirect()->route('admin.login.form')->withErrors([ + 'session' => 'Session timed out. Please login again.', + ]); + } + + $request->session()->put('security_last_seen_at', time()); + + return $next($request); + } +} diff --git a/src/Notifications/LoginMfaCodeNotification.php b/src/Notifications/LoginMfaCodeNotification.php new file mode 100644 index 000000000..a2ee49ddb --- /dev/null +++ b/src/Notifications/LoginMfaCodeNotification.php @@ -0,0 +1,36 @@ +subject(Lang::get('Login Verification Code')) + ->line(Lang::get('Use this code to complete your login: :code', ['code' => $this->code])) + ->line(Lang::get('This code will expire at :time', ['time' => $this->expiresAt->format('H:i')])) + ->line(Lang::get('If you did not attempt to login, you can ignore this email.')); + } +} + diff --git a/src/Providers/ModularityProvider.php b/src/Providers/ModularityProvider.php index 0634cb392..9c45f8321 100755 --- a/src/Providers/ModularityProvider.php +++ b/src/Providers/ModularityProvider.php @@ -15,6 +15,7 @@ class ModularityProvider extends ServiceProvider // Unusual Providers BaseServiceProvider::class, ModuleServiceProvider::class, + SecurityServiceProvider::class, RouteServiceProvider::class, AuthServiceProvider::class, CoverageServiceProvider::class, diff --git a/src/Providers/SecurityServiceProvider.php b/src/Providers/SecurityServiceProvider.php new file mode 100644 index 000000000..b2d9c3d9a --- /dev/null +++ b/src/Providers/SecurityServiceProvider.php @@ -0,0 +1,36 @@ +app->singleton(SecurityService::class, fn () => new SecurityService); + + if (! modularityConfig('security.enabled', false)) { + return; + } + + ModularityRoutes::addDefaultMiddlewares([ + 'modularity.security.session', + 'modularity.security.require_mfa', + 'modularity.security.step_up', + ]); + } + + public function boot(): void + { + Route::aliasMiddleware('modularity.security.session', SessionSecurityMiddleware::class); + Route::aliasMiddleware('modularity.security.require_mfa', RequireMfaMiddleware::class); + Route::aliasMiddleware('modularity.security.step_up', StepUpMiddleware::class); + } +} diff --git a/src/Services/Security/SecurityService.php b/src/Services/Security/SecurityService.php new file mode 100644 index 000000000..d98fa8aaf --- /dev/null +++ b/src/Services/Security/SecurityService.php @@ -0,0 +1,353 @@ + $this->buildCapabilitiesMap() + ); + } + + public function requiredStepUpCapabilities(): array + { + return Cache::remember( + self::CACHE_KEY_REQUIRED_STEP_UP_CAPABILITIES, + self::CACHE_TTL_SECONDS, + fn () => $this->buildRequiredStepUpCapabilities() + ); + } + + public function routeMatchesStepUpCapability(string $capability, ?string $routeName): bool + { + return in_array($capability, $this->stepUpCapabilitiesForRoute($routeName), true); + } + + public function stepUpCapabilitiesForRoute(?string $routeName): array + { + $routeName = is_string($routeName) ? trim($routeName) : ''; + + if ($routeName === '') { + return []; + } + + $map = Cache::remember( + self::CACHE_KEY_ROUTE_STEP_UP_CAPABILITIES, + self::CACHE_TTL_SECONDS, + fn () => $this->buildRouteStepUpCapabilitiesMap() + ); + + return $map[$routeName] ?? []; + } + + public function matchedUserStepUpCapability(?Authenticatable $user, ?string $routeName, ?string $hintCapability = null): ?string + { + if (! $user) { + return null; + } + + $routeCapabilities = $this->stepUpCapabilitiesForRoute($routeName); + + if ($routeCapabilities === []) { + return null; + } + + if (is_string($hintCapability) && $hintCapability !== '' && ! in_array($hintCapability, $routeCapabilities, true)) { + return null; + } + + $userCapabilities = $this->userCapabilities($user); + + if ($userCapabilities === []) { + return null; + } + + if (is_string($hintCapability) && $hintCapability !== '' && in_array($hintCapability, $userCapabilities, true)) { + return $hintCapability; + } + + foreach ($routeCapabilities as $capability) { + if (in_array($capability, $userCapabilities, true)) { + return $capability; + } + } + + return null; + } + + public function userCapabilities(?Authenticatable $user): array + { + if (! $user) { + return []; + } + + if (method_exists($user, 'getAttribute')) { + $capabilities = $user->getAttribute('capabilities'); + + if (is_array($capabilities)) { + return array_values(array_unique(array_filter($capabilities, fn ($capability) => is_string($capability) && $capability !== ''))); + } + } + + $caps = []; + + foreach ($this->getCapabilities() as $role => $roleCaps) { + if ($this->userHasRole($user, $role)) { + $caps = array_merge($caps, (array) $roleCaps); + } + } + + return array_values(array_unique($caps)); + } + + public function userHasCapability(?Authenticatable $user, string $capability): bool + { + if ($user && is_callable([$user, 'hasCapability'])) { + return (bool) $user->hasCapability($capability); + } + + return in_array($capability, $this->userCapabilities($user), true); + } + + public function userRequiresMfa(?Authenticatable $user): bool + { + if (! $this->config('mfa.enabled', false) || ! $user) { + return false; + } + + $requiredRoles = (array) $this->config('mfa.required_roles', []); + + if (method_exists($user, 'existAnyRole') && $user->existAnyRole($requiredRoles)) { + return true; + } + + foreach ($requiredRoles as $requiredRole) { + if ($this->userHasRole($user, (string) $requiredRole)) { + return true; + } + } + + return false; + } + + public function userHasEnabledMfa(?Authenticatable $user): bool + { + if (! $user) { + return false; + } + + $provider = (string) $this->config('mfa.provider', 'email_otp'); + + // Email OTP does not require per-user setup columns. + if ($provider === 'email_otp') { + return true; + } + + // Unknown providers should not lock users out by strict setup checks. + if ($provider !== 'google_totp') { + return true; + } + + return (bool) ($user->google_2fa_enabled ?? false) + && ! empty($user->google_2fa_secret ?? null); + } + + public function canWriteField(?Authenticatable $user, string $field): bool + { + $permission = $this->config("critical_field_permissions.{$field}", null); + + if (! $permission) { + return true; + } + + if (! $user) { + return false; + } + + if (is_callable([$user, 'can'])) { + return (bool) $user->can($permission); + } + + return false; + } + + public function canPromote(?Authenticatable $user): bool + { + if (! $user) { + return false; + } + + $allowedRoles = (array) modularityConfig('cms_promotion.approval.roles', []); + $allowedEmails = (array) modularityConfig('cms_promotion.approval.emails', []); + + foreach ($allowedRoles as $role) { + if ($this->userHasRole($user, (string) $role)) { + return true; + } + } + + return in_array((string) ($user->email ?? ''), $allowedEmails, true); + } + + public function flushPersistentCache(bool $warmup = true): void + { + Cache::forget(self::CACHE_KEY_CAPABILITIES_MAP); + Cache::forget(self::CACHE_KEY_REQUIRED_STEP_UP_CAPABILITIES); + Cache::forget(self::CACHE_KEY_ROUTE_STEP_UP_CAPABILITIES); + + if ($warmup) { + $this->warmupPersistentCache(); + } + } + + public function warmupPersistentCache(): void + { + $this->getCapabilities(); + $this->requiredStepUpCapabilities(); + Cache::remember( + self::CACHE_KEY_ROUTE_STEP_UP_CAPABILITIES, + self::CACHE_TTL_SECONDS, + fn () => $this->buildRouteStepUpCapabilitiesMap() + ); + } + + private function buildCapabilitiesMap(): array + { + $query = $this->capabilityQuery(); + + if (! $query) { + return []; + } + + $map = []; + + foreach ($query->where('published', true)->with('roles:id,name')->get(['id', 'name']) as $capability) { + $name = (string) ($capability->name ?? ''); + if ($name === '') { + continue; + } + + foreach ($capability->roles ?? [] as $role) { + $role = (string) ($role->name ?? ''); + if ($role === '') { + continue; + } + + $map[$role] ??= []; + $map[$role][] = $name; + } + } + + return collect($map) + ->map(fn ($caps) => array_values(array_unique((array) $caps))) + ->toArray(); + } + + private function buildRequiredStepUpCapabilities(): array + { + $query = $this->capabilityQuery(); + + if (! $query) { + return []; + } + + return $query + ->where('published', true) + ->where('requires_step_up', true) + ->pluck('name') + ->filter(fn ($name) => is_string($name) && $name !== '') + ->values() + ->all(); + } + + private function buildRouteStepUpCapabilitiesMap(): array + { + $query = $this->capabilityQuery(); + + if (! $query) { + return []; + } + + $map = []; + + $query + ->where('published', true) + ->where('requires_step_up', true) + ->with(['routes' => fn ($q) => $q->where('is_active', true)]) + ->get(['id', 'name']) + ->each(function ($capability) use (&$map) { + $capabilityName = (string) ($capability->name ?? ''); + + if ($capabilityName === '') { + return; + } + + foreach ($capability->routes ?? [] as $route) { + $routeName = (string) ($route->route_name ?? ''); + + if ($routeName === '') { + continue; + } + + $map[$routeName] ??= []; + $map[$routeName][] = $capabilityName; + } + }); + + return collect($map) + ->map(fn ($capabilities) => array_values(array_unique(array_filter($capabilities, fn ($capability) => is_string($capability) && $capability !== '')))) + ->toArray(); + } + + private function userHasRole(Authenticatable $user, string $role): bool + { + if (is_callable([$user, 'hasRole'])) { + return (bool) $user->hasRole($role); + } + + return false; + } + + private function capabilityQuery(): ?Builder + { + $class = '\Modules\SystemUser\Entities\Capability'; + + if (! class_exists($class)) { + return null; + } + + $model = app()->make($class); + + if (! method_exists($model, 'getTable') || ! method_exists($model, 'newQuery')) { + return null; + } + + if (! Schema::hasTable($model->getTable())) { + return null; + } + + return $model->newQuery(); + } +} From 574e16364aed51bdc7c157d8d53dba3d05390b29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Thu, 2 Apr 2026 01:09:16 +0300 Subject: [PATCH 027/163] feat(ModularityRoutes): add dynamic middleware management - Introduced methods to add dynamic default and panel middlewares, allowing for flexible middleware configuration. - Enhanced existing middleware retrieval methods to include dynamic middlewares, ensuring unique values are returned. - Refactored middleware-related methods for improved readability and maintainability. --- src/Support/ModularityRoutes.php | 90 ++++++++++++++++++++++++-------- 1 file changed, 69 insertions(+), 21 deletions(-) diff --git a/src/Support/ModularityRoutes.php b/src/Support/ModularityRoutes.php index 2c1518435..6ea4ab86d 100755 --- a/src/Support/ModularityRoutes.php +++ b/src/Support/ModularityRoutes.php @@ -26,11 +26,55 @@ class ModularityRoutes { + private static array $dynamicDefaultMiddlewares = []; + + private static array $dynamicPanelMiddlewares = []; + private array $defaultMiddlewares = [ 'modularity.log', 'modularity.core', ]; + public function addDefaultMiddleware(string $middleware): void + { + $middleware = trim($middleware); + if ($middleware === '') { + return; + } + + self::$dynamicDefaultMiddlewares[] = $middleware; + self::$dynamicDefaultMiddlewares = array_values(array_unique(self::$dynamicDefaultMiddlewares)); + } + + public function addPanelMiddleware(string $middleware): void + { + $middleware = trim($middleware); + if ($middleware === '') { + return; + } + + self::$dynamicPanelMiddlewares[] = $middleware; + self::$dynamicPanelMiddlewares = array_values(array_unique(self::$dynamicPanelMiddlewares)); + } + + public function addDefaultMiddlewares(array $middlewares): void + { + foreach ($middlewares as $middleware) { + if (is_string($middleware)) { + $this->addDefaultMiddleware($middleware); + } + } + } + + public function addPanelMiddlewares(array $middlewares): void + { + foreach ($middlewares as $middleware) { + if (is_string($middleware)) { + $this->addPanelMiddleware($middleware); + } + } + } + public function configureRoutePatterns(): void { if (($patterns = modularityConfig('route_patterns')) != null) { @@ -56,48 +100,52 @@ public function groupOptions(): array public function webMiddlewares(): array { - return [ - ...['web'], - ...$this->defaultMiddlewares, - ]; + return array_values(array_unique([ + 'web', + ...$this->defaultMiddlewares(), + ])); } public function webPanelMiddlewares(): array { - return [ - ...['web.auth'], - ...$this->defaultMiddlewares, - ...['modularity.panel'], - ]; + return array_values(array_unique([ + 'web.auth', + ...$this->defaultMiddlewares(), + ...$this->defaultPanelMiddlewares(), + ])); } public function apiMiddlewares(): array { - return [ - ...['api'], - ...$this->defaultMiddlewares, - ]; + return array_values(array_unique([ + 'api', + ...$this->defaultMiddlewares(), + ])); } public function apiPanelMiddlewares(): array { - return [ - ...['api.auth'], - ...$this->defaultMiddlewares, - ...['modularity.panel'], - ]; + return array_values(array_unique([ + 'api.auth', + ...$this->defaultMiddlewares(), + ...$this->defaultPanelMiddlewares(), + ])); } public function defaultMiddlewares(): array { - return $this->defaultMiddlewares; + return array_values(array_unique([ + ...$this->defaultMiddlewares, + ...self::$dynamicDefaultMiddlewares, + ])); } public function defaultPanelMiddlewares(): array { - return [ + return array_values(array_unique([ 'modularity.panel', - ]; + ...self::$dynamicPanelMiddlewares, + ])); } public function generateRouteMiddlewares(): void From 33005484985794bb65a5c6e69944c56d2fc5730e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Thu, 2 Apr 2026 01:11:22 +0300 Subject: [PATCH 028/163] feat(security): implement capabilities and step-up verification system - Introduced a new capabilities management system, allowing for role-based access control through capabilities. - Added step-up verification for enhanced security during sensitive actions, requiring users to verify their identity. - Created new entities, controllers, and requests for managing capabilities and capability routes. - Implemented middleware for handling step-up verification challenges, improving session security. - Updated authentication routes and forms to support multi-factor authentication (MFA) and step-up processes. - Enhanced user experience with dynamic forms and notifications for verification processes. --- config/defers/auth_pages.php | 32 ++ config/defers/form_drafts.php | 42 ++ config/merges/security.php | 77 ++++ config/merges/tables.php | 4 + lang/en/modules.php | 6 + modules/SystemUser/Config/config.php | 214 +++++++++++ ..._create_system_user_capabilities_table.php | 78 ++++ modules/SystemUser/Entities/Capability.php | 51 +++ .../SystemUser/Entities/CapabilityRoute.php | 37 ++ modules/SystemUser/Entities/Role.php | 14 +- .../Entities/Traits/FlushesSecurityCache.php | 22 ++ .../CapabilityRouteDiscoveryController.php | 77 ++++ .../Http/Controllers/CapabilityController.php | 29 ++ .../Controllers/CapabilityRouteController.php | 29 ++ .../Http/Requests/CapabilityRequest.php | 39 ++ .../Http/Requests/CapabilityRouteRequest.php | 53 +++ .../Repositories/CapabilityRepository.php | 15 + .../CapabilityRouteRepository.php | 15 + modules/SystemUser/Routes/api.php | 3 +- modules/SystemUser/Routes/web.php | 6 +- resources/views/auth/step-up-replay.blade.php | 53 +++ routes/auth.php | 16 +- src/Entities/Traits/Core/HasCapabilities.php | 80 ++++ src/Entities/User.php | 2 + .../Controllers/Auth/StepUpController.php | 49 +++ .../Traits/Utilities/AuthFormBuilder.php | 23 ++ src/Http/Middleware/StepUpMiddleware.php | 45 +++ src/Notifications/StepUpCodeNotification.php | 36 ++ src/Services/Security/StepUpService.php | 359 ++++++++++++++++++ 29 files changed, 1501 insertions(+), 5 deletions(-) create mode 100644 config/merges/security.php create mode 100644 modules/SystemUser/Database/Migrations/2026_03_30_000001_create_system_user_capabilities_table.php create mode 100644 modules/SystemUser/Entities/Capability.php create mode 100644 modules/SystemUser/Entities/CapabilityRoute.php create mode 100644 modules/SystemUser/Entities/Traits/FlushesSecurityCache.php create mode 100644 modules/SystemUser/Http/Controllers/API/CapabilityRouteDiscoveryController.php create mode 100644 modules/SystemUser/Http/Controllers/CapabilityController.php create mode 100644 modules/SystemUser/Http/Controllers/CapabilityRouteController.php create mode 100644 modules/SystemUser/Http/Requests/CapabilityRequest.php create mode 100644 modules/SystemUser/Http/Requests/CapabilityRouteRequest.php create mode 100644 modules/SystemUser/Repositories/CapabilityRepository.php create mode 100644 modules/SystemUser/Repositories/CapabilityRouteRepository.php create mode 100644 resources/views/auth/step-up-replay.blade.php create mode 100644 src/Entities/Traits/Core/HasCapabilities.php create mode 100644 src/Http/Controllers/Auth/StepUpController.php create mode 100644 src/Http/Middleware/StepUpMiddleware.php create mode 100644 src/Notifications/StepUpCodeNotification.php create mode 100644 src/Services/Security/StepUpService.php diff --git a/config/defers/auth_pages.php b/config/defers/auth_pages.php index 043adef5f..97665f201 100644 --- a/config/defers/auth_pages.php +++ b/config/defers/auth_pages.php @@ -47,6 +47,38 @@ 'formSlotsPreset' => 'login_options', 'slotsPreset' => 'login_bottom', ], + 'login_mfa' => [ + 'pageTitle' => 'authentication.login', + 'layoutPreset' => 'minimal', + 'formDraft' => 'login_email_form', + 'actionRoute' => 'admin.login', + 'formTitle' => 'authentication.login-title', + 'buttonText' => 'authentication.sign-in', + 'formSlotsPreset' => 'login_mfa_options', + 'slotsPreset' => 'login_mfa_bottom', + ], + 'login_2fa' => [ + 'pageTitle' => 'authentication.verify-login', + 'layoutPreset' => 'minimal', + 'formDraft' => 'login_2fa_form', + 'actionRoute' => 'admin.login-2fa', + 'formTitle' => 'authentication.verify-login', + 'buttonText' => 'authentication.login', + 'formSlotsPreset' => 'login_2fa_options', + 'formOverrides' => ['noValidation' => true], + 'slotsPreset' => null, + ], + 'step_up' => [ + 'pageTitle' => 'authentication.verify-login', + 'layoutPreset' => 'minimal', + 'formDraft' => 'step_up_form', + 'actionRoute' => 'admin.step-up.verify', + 'formTitle' => 'authentication.verify-login', + 'buttonText' => 'authentication.login', + 'formSlotsPreset' => 'step_up_options', + 'formOverrides' => ['noValidation' => true, 'async' => false], + 'slotsPreset' => null, + ], 'register' => [ 'pageTitle' => 'authentication.register', 'layoutPreset' => 'banner', diff --git a/config/defers/form_drafts.php b/config/defers/form_drafts.php index ebefca717..319dbdab5 100755 --- a/config/defers/form_drafts.php +++ b/config/defers/form_drafts.php @@ -310,6 +310,48 @@ 'hideDetails' => 'auto', ], ], + 'login_email_form' => [ + 'email' => [ + 'type' => 'text', + 'name' => 'email', + 'label' => 'E-mail', + 'hint' => 'enter @example.com', + 'default' => '', + 'col' => [ + 'lg' => 12, + ], + 'rules' => [ + ['email', '', 'E-mail must be valid'], + ], + 'validateOn' => 'lazy blur', + ], + ], + 'login_2fa_form' => [ + 'verify_code' => [ + 'type' => 'otp-input', + 'name' => 'verify-code', + 'label' => 'One Time Password', + 'default' => '', + 'col' => [ + 'lg' => 12, + ], + 'length' => env('MODULARITY_SECURITY_MFA_EMAIL_OTP_LENGTH', 6), + 'rules' => 'required', + ], + ], + 'step_up_form' => [ + 'verify_code' => [ + 'type' => 'otp-input', + 'name' => 'verify-code', + 'label' => 'Verification Code', + 'default' => '', + 'col' => [ + 'lg' => 12, + ], + 'length' => 6, + 'rules' => 'required', + ], + ], 'forgot_password_form' => [ 'email' => [ 'type' => 'text', diff --git a/config/merges/security.php b/config/merges/security.php new file mode 100644 index 000000000..19e6994f9 --- /dev/null +++ b/config/merges/security.php @@ -0,0 +1,77 @@ + env('MODULARITY_SECURITY_ENABLED', false), + + // Managed dynamically from SystemUser/Capability route. + 'capabilities' => [], + + 'mfa' => [ + 'enabled' => env('MODULARITY_SECURITY_MFA_ENABLED', false), + 'required_roles' => array_filter(array_map('trim', explode(',', env('MODULARITY_SECURITY_MFA_REQUIRED_ROLES', 'admin,marketing-manager,marketing_manager')))), + 'strict' => env('MODULARITY_SECURITY_MFA_STRICT', false), + 'provider' => env('MODULARITY_SECURITY_MFA_PROVIDER', 'email_otp'), // email_otp | google_totp + 'remove_password_login' => (bool) env('MODULARITY_SECURITY_MFA_REMOVE_PASSWORD', true), + 'register_first_time' => (bool) env('MODULARITY_SECURITY_MFA_REGISTER_FIRST_TIME', true), + 'registration_success_route' => env('MODULARITY_SECURITY_MFA_REGISTRATION_SUCCESS_ROUTE', 'admin.register.verification.success'), + 'session_key' => env('MODULARITY_SECURITY_MFA_SESSION_KEY', '2fa:user:id'), + 'flow_session_key' => env('MODULARITY_SECURITY_MFA_FLOW_SESSION_KEY', '2fa:flow:key'), + 'otp_field' => env('MODULARITY_SECURITY_MFA_OTP_FIELD', 'verify-code'), + 'login_page' => env('MODULARITY_SECURITY_MFA_LOGIN_PAGE', 'login_mfa'), + 'challenge_page' => env('MODULARITY_SECURITY_MFA_CHALLENGE_PAGE', 'login_2fa'), + 'challenge_form_route' => env('MODULARITY_SECURITY_MFA_CHALLENGE_FORM_ROUTE', 'admin.login-2fa.form'), + 'throttle' => env('MODULARITY_SECURITY_MFA_THROTTLE', '6,1'), + 'email_otp' => [ + 'length' => (int) env('MODULARITY_SECURITY_MFA_EMAIL_OTP_LENGTH', 6), + 'expire_minutes' => (int) env('MODULARITY_SECURITY_MFA_EMAIL_OTP_EXPIRE_MINUTES', 10), + 'max_attempts' => (int) env('MODULARITY_SECURITY_MFA_EMAIL_OTP_MAX_ATTEMPTS', 5), + 'cache_prefix' => env('MODULARITY_SECURITY_MFA_EMAIL_OTP_CACHE_PREFIX', 'mfa:email-otp'), + ], + ], + + 'throttle' => [ + 'login' => env('MODULARITY_SECURITY_THROTTLE_LOGIN', '8,1'), + 'login_2fa' => env('MODULARITY_SECURITY_THROTTLE_LOGIN_2FA', '6,1'), + 'critical_action' => env('MODULARITY_SECURITY_THROTTLE_CRITICAL', '30,1'), + ], + + 'session' => [ + 'idle_timeout_minutes' => (int) env('MODULARITY_SECURITY_IDLE_TIMEOUT_MINUTES', 60), + 'step_up_ttl_minutes' => (int) env('MODULARITY_SECURITY_STEP_UP_TTL_MINUTES', 15), + ], + + 'critical_field_permissions' => [ + 'robots_index' => 'page_edit', + 'robots_follow' => 'page_edit', + 'canonical_url' => 'page_edit', + 'head_scripts' => 'site_setting_edit', + 'body_scripts' => 'site_setting_edit', + 'redirect_from' => 'redirect_edit', + 'redirect_to' => 'redirect_edit', + 'status_code' => 'redirect_edit', + ], + + 'step_up' => [ + 'enabled' => env('MODULARITY_SECURITY_STEP_UP_ENABLED', false), + // Managed dynamically from SystemUser/Capability route (requires_step_up=true). + 'required_capabilities' => [], + 'provider' => 'email_otp', + 'otp_field' => 'verify-code', + 'page' => 'step_up', + 'challenge_form_route' => 'admin.step-up.form', + 'verify_route' => 'admin.step-up.verify', + 'resend_route' => 'admin.step-up.resend', + 'user_session_key' => 'step-up:user:id', + 'flow_session_key' => 'step-up:flow:key', + 'capability_session_key' => 'step-up:capability:key', + 'pending_request_session_key' => 'step-up:pending:request', + 'return_url_session_key' => 'step-up:return:url', + 'email_otp' => [ + 'length' => 6, + 'expire_minutes' => 10, + 'max_attempts' => 5, + 'cache_prefix' => 'step-up:email-otp', + ], + ], +]; diff --git a/config/merges/tables.php b/config/merges/tables.php index 6e730932e..a2ed83f63 100644 --- a/config/merges/tables.php +++ b/config/merges/tables.php @@ -27,6 +27,10 @@ 'process_histories' => 'um_process_histories', 'assignments' => 'um_assignments', 'user_oauths' => 'um_user_oauths', + 'capabilities' => 'um_capabilities', + 'capability_routes' => 'um_capability_routes', + 'role_capability' => 'um_role_capability', + 'capability_capability_route' => 'um_capability_capability_route', 'email_verification_tokens' => 'um_email_verification_tokens', 'countries' => 'um_countries', 'country_translations' => 'um_country_translations', diff --git a/lang/en/modules.php b/lang/en/modules.php index 72ce54b65..caeefb494 100755 --- a/lang/en/modules.php +++ b/lang/en/modules.php @@ -59,6 +59,12 @@ 'user' => [ 'name' => 'User | Users | {n} Users', ], + 'capability' => [ + 'name' => 'Capability | Capabilities | {n} Capabilities', + ], + 'capability_route' => [ + 'name' => 'Capability Route | Capability Routes | {n} Capability Routes', + ], ], 'system_utility' => [ 'country' => [ diff --git a/modules/SystemUser/Config/config.php b/modules/SystemUser/Config/config.php index 9af844b2f..2deb318ef 100755 --- a/modules/SystemUser/Config/config.php +++ b/modules/SystemUser/Config/config.php @@ -1380,6 +1380,220 @@ 'destroy' => [], ], ], + 'capability' => [ + 'name' => 'Capability', + 'headline' => 'Capabilities', + 'url' => 'capabilities', + 'route_name' => 'capability', + 'icon' => 'mdi-shield-lock-outline', + 'index_with' => [ + 'roles', + 'routes', + ], + 'table_options' => [ + 'createOnModal' => true, + 'editOnModal' => true, + 'isRowEditing' => true, + 'rowActionsType' => 'inline', + ], + 'headers' => [ + [ + 'title' => 'Title', + 'key' => 'title', + 'sortable' => true, + 'searchable' => true, + ], + [ + 'title' => 'Name', + 'key' => 'name', + 'sortable' => true, + 'searchable' => true, + ], + [ + 'title' => 'Roles', + 'key' => 'roles', + 'itemTitle' => 'title', + ], + [ + 'title' => 'Routes', + 'key' => 'routes', + 'itemTitle' => 'route_name', + ], + [ + 'title' => 'Strict Route Binding', + 'key' => 'strict_route_binding', + 'formatter' => [ + 'switch', + ], + ], + [ + 'title' => 'Step-Up', + 'key' => 'requires_step_up', + 'formatter' => [ + 'switch', + ], + ], + [ + 'title' => 'Actions', + 'key' => 'actions', + 'sortable' => false, + ], + ], + 'inputs' => [ + [ + 'type' => 'text', + 'name' => 'name', + 'label' => 'Capability Key', + 'placeholder' => 'promotion.execute', + 'rules' => 'required|min:3', + 'col' => [ + 'cols' => 12, + 'sm' => 8, + 'md' => 6, + ], + ], + [ + 'type' => 'text', + 'name' => 'title', + 'label' => 'Title', + 'placeholder' => 'Promotion Execute', + 'col' => [ + 'cols' => 12, + 'sm' => 8, + 'md' => 6, + ], + ], + [ + 'type' => 'select', + 'name' => 'roles', + 'multiple' => true, + 'itemValue' => 'id', + 'itemTitle' => 'title', + 'label' => 'Allowed Roles', + 'connector' => 'SystemUser:Role|repository:list:column=title', + 'col' => [ + 'cols' => 12, + ], + ], + [ + 'type' => 'select-scroll', + 'componentType' => 'v-autocomplete', + 'name' => 'routes', + 'label' => 'Bound Routes', + 'multiple' => true, + 'chips' => true, + 'itemValue' => 'id', + 'itemTitle' => 'route_name', + 'itemsPerPage' => 100, + 'page' => 1, + 'endpoint' => 'admin.system.system_user.capability_route.index', + 'searchKeys' => ['route_name'], + 'col' => [ + 'cols' => 12, + ], + ], + [ + 'type' => 'switch', + 'name' => 'strict_route_binding', + 'label' => 'Strict Route Binding', + 'default' => false, + 'hint' => 'When enabled, step-up runs only for bound route names.', + 'col' => [ + 'cols' => 12, + 'sm' => 6, + 'md' => 4, + 'lg' => 3, + ], + ], + [ + 'type' => 'switch', + 'name' => 'requires_step_up', + 'label' => 'Require Step-Up', + 'default' => false, + 'col' => [ + 'cols' => 12, + 'sm' => 6, + 'md' => 4, + 'lg' => 3, + ], + ], + [ + 'type' => 'switch', + 'name' => 'published', + 'label' => 'Active', + 'default' => true, + 'col' => [ + 'cols' => 12, + 'sm' => 6, + 'md' => 4, + 'lg' => 3, + ], + ], + ], + ], + 'capability_route' => [ + 'name' => 'CapabilityRoute', + 'headline' => 'Capability Routes', + 'url' => 'capability-routes', + 'route_name' => 'capability_route', + // 'belongs' => ['capability'], + 'icon' => 'mdi-security-network', + 'table_options' => [ + 'createOnModal' => true, + 'editOnModal' => true, + 'isRowEditing' => true, + 'rowActionsType' => 'inline', + ], + 'headers' => [ + [ + 'title' => 'Route Name', + 'key' => 'route_name', + 'searchable' => true, + ], + [ + 'title' => 'Active', + 'key' => 'is_active', + 'formatter' => [ + 'switch', + ], + ], + [ + 'title' => 'Actions', + 'key' => 'actions', + 'sortable' => false, + ], + ], + 'inputs' => [ + [ + 'type' => 'select-scroll', + 'componentType' => 'v-autocomplete', + 'name' => 'route_name', + 'label' => 'Route Name', + 'itemValue' => 'name', + 'itemTitle' => 'name_with_uri', + 'placeholder' => 'admin.system.cms.promotion.execute', + 'itemsPerPage' => 100, + 'page' => 1, + 'endpoint' => 'admin.system.system_user.capabilities.discover_routes', + 'searchKeys' => ['name', 'uri'], + 'col' => [ + 'cols' => 12, + ], + 'rules' => 'required|min:2', + ], + [ + 'type' => 'switch', + 'name' => 'is_active', + 'label' => 'Active', + 'default' => true, + 'col' => [ + 'cols' => 12, + 'sm' => 6, + 'md' => 4, + ], + ], + ], + ], 'company' => [ 'name' => 'Company', 'headline' => 'Companies', diff --git a/modules/SystemUser/Database/Migrations/2026_03_30_000001_create_system_user_capabilities_table.php b/modules/SystemUser/Database/Migrations/2026_03_30_000001_create_system_user_capabilities_table.php new file mode 100644 index 000000000..8bda75006 --- /dev/null +++ b/modules/SystemUser/Database/Migrations/2026_03_30_000001_create_system_user_capabilities_table.php @@ -0,0 +1,78 @@ +id(); + $table->string('name')->unique(); + $table->string('title')->nullable(); + $table->boolean('strict_route_binding')->default(false); + $table->boolean('requires_step_up')->default(false); + $table->boolean('published')->default(true); + $table->timestamps(); + $table->softDeletes(); + }); + } elseif (! Schema::hasColumn($capabilitiesTable, 'strict_route_binding')) { + Schema::table($capabilitiesTable, function (Blueprint $table) { + $table->boolean('strict_route_binding')->default(false)->after('title'); + }); + } + + if (! Schema::hasTable($pivotTable)) { + Schema::create($pivotTable, function (Blueprint $table) use ($capabilitiesTable, $rolesTable) { + $table->id(); + $table->unsignedBigInteger('role_id'); + $table->unsignedBigInteger('capability_id'); + $table->timestamps(); + + $table->unique(['role_id', 'capability_id'], 'um_role_capability_unique'); + $table->foreign('role_id')->references('id')->on($rolesTable)->cascadeOnDelete(); + $table->foreign('capability_id')->references('id')->on($capabilitiesTable)->cascadeOnDelete(); + }); + } + + if (! Schema::hasTable($capabilityRoutesTable)) { + Schema::create($capabilityRoutesTable, function (Blueprint $table) { + $table->id(); + $table->string('route_name')->unique(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->softDeletes(); + }); + } + + if (! Schema::hasTable($capabilityRoutePivotTable)) { + Schema::create($capabilityRoutePivotTable, function (Blueprint $table) use ($capabilitiesTable, $capabilityRoutesTable) { + $table->id(); + $table->unsignedBigInteger('capability_id'); + $table->unsignedBigInteger('capability_route_id'); + $table->timestamps(); + + $table->unique(['capability_id', 'capability_route_id'], 'um_capability_capability_route_unique'); + $table->foreign('capability_id')->references('id')->on($capabilitiesTable)->cascadeOnDelete(); + $table->foreign('capability_route_id')->references('id')->on($capabilityRoutesTable)->cascadeOnDelete(); + }); + } + } + + public function down(): void + { + Schema::dropIfExists(modularityConfig('tables.capability_capability_route', 'um_capability_capability_route')); + Schema::dropIfExists(modularityConfig('tables.capability_routes', 'um_capability_routes')); + Schema::dropIfExists(modularityConfig('tables.role_capability', 'um_role_capability')); + Schema::dropIfExists(modularityConfig('tables.capabilities', 'um_capabilities')); + } +}; diff --git a/modules/SystemUser/Entities/Capability.php b/modules/SystemUser/Entities/Capability.php new file mode 100644 index 000000000..4083cc22e --- /dev/null +++ b/modules/SystemUser/Entities/Capability.php @@ -0,0 +1,51 @@ + 'boolean', + // 'requires_step_up' => 'boolean', + // 'published' => 'boolean', + // ]; + + public function getTable() + { + return modularityConfig('tables.capabilities', parent::getTable()); + } + + public function roles(): BelongsToMany + { + return $this->belongsToMany( + Role::class, + modularityConfig('tables.role_capability', 'um_role_capability'), + 'capability_id', + 'role_id' + ); + } + + public function routes(): BelongsToMany + { + return $this->belongsToMany( + CapabilityRoute::class, + modularityConfig('tables.capability_capability_route', 'um_capability_capability_route'), + 'capability_id', + 'capability_route_id' + ); + } +} diff --git a/modules/SystemUser/Entities/CapabilityRoute.php b/modules/SystemUser/Entities/CapabilityRoute.php new file mode 100644 index 000000000..25f0dd2ff --- /dev/null +++ b/modules/SystemUser/Entities/CapabilityRoute.php @@ -0,0 +1,37 @@ + 'boolean', + // ]; + + public function getTable() + { + return modularityConfig('tables.capability_routes', parent::getTable()); + } + + public function capabilities(): BelongsToMany + { + return $this->belongsToMany( + Capability::class, + modularityConfig('tables.capability_capability_route', 'um_capability_capability_route'), + 'capability_route_id', + 'capability_id' + ); + } +} diff --git a/modules/SystemUser/Entities/Role.php b/modules/SystemUser/Entities/Role.php index 765dd376b..2f1bb146e 100644 --- a/modules/SystemUser/Entities/Role.php +++ b/modules/SystemUser/Entities/Role.php @@ -2,15 +2,27 @@ namespace Modules\SystemUser\Entities; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Modules\SystemUser\Entities\Traits\FlushesSecurityCache; use Spatie\Permission\Models\Role as SpatieRole; use Unusualify\Modularity\Entities\Traits\Core\ModelHelpers; class Role extends SpatieRole { - use ModelHelpers; + use ModelHelpers, FlushesSecurityCache; public function scopeClient($query) { return $query->where('name', 'like', '%client%'); } + + public function capabilities(): BelongsToMany + { + return $this->belongsToMany( + Capability::class, + modularityConfig('tables.role_capability', 'um_role_capability'), + 'role_id', + 'capability_id' + ); + } } diff --git a/modules/SystemUser/Entities/Traits/FlushesSecurityCache.php b/modules/SystemUser/Entities/Traits/FlushesSecurityCache.php new file mode 100644 index 000000000..21fe6cd54 --- /dev/null +++ b/modules/SystemUser/Entities/Traits/FlushesSecurityCache.php @@ -0,0 +1,22 @@ +flushPersistentCache(); + }; + + static::saved($flush); + static::deleted($flush); + + if (method_exists(static::class, 'restored')) { + static::restored($flush); + } + } +} diff --git a/modules/SystemUser/Http/Controllers/API/CapabilityRouteDiscoveryController.php b/modules/SystemUser/Http/Controllers/API/CapabilityRouteDiscoveryController.php new file mode 100644 index 000000000..ef2d0dae0 --- /dev/null +++ b/modules/SystemUser/Http/Controllers/API/CapabilityRouteDiscoveryController.php @@ -0,0 +1,77 @@ +query('search') ?? $request->query('q', '')); + $onlyNamed = (bool) $request->boolean('only_named', true); + $perPage = max(1, min((int) $request->query('itemsPerPage', $request->query('limit', 50)), 300)); + $page = max(1, (int) $request->query('page', 1)); + + $routes = collect(Route::getRoutes())->map(function ($route) { + $methods = array_values(array_filter( + $route->methods(), + fn ($method) => ! in_array($method, ['HEAD', 'OPTIONS'], true) + )); + + return [ + 'name' => $route->getName(), + 'route_name' => $route->getName(), + 'uri' => $route->uri(), + 'method' => $methods[0] ?? null, + 'methods' => $methods, + 'name_with_uri' => $route->uri() . ' - ' . $route->getName(), + ]; + }); + + if ($onlyNamed) { + $routes = $routes->filter(fn ($item) => is_string($item['name']) && $item['name'] !== ''); + } + + if ($search !== '') { + $routes = $routes->filter(function ($item) use ($search) { + return str_contains((string) ($item['name'] ?? ''), $search) + || str_contains((string) ($item['uri'] ?? ''), $search); + }); + } + + $items = $routes + ->sortBy(fn ($item) => (string) ($item['name'] ?? $item['uri'])) + ->values(); + + $paginated = $this->paginate($items, $page, $perPage); + + return response()->json([ + 'resource' => [ + 'data' => $paginated['data'], + 'current_page' => $paginated['current_page'], + 'last_page' => $paginated['last_page'], + 'per_page' => $perPage, + 'total' => $paginated['total'], + ], + ]); + } + + private function paginate(Collection $items, int $page, int $perPage): array + { + $total = $items->count(); + $lastPage = max((int) ceil($total / $perPage), 1); + $page = min($page, $lastPage); + + return [ + 'data' => $items->forPage($page, $perPage)->values()->all(), + 'current_page' => $page, + 'last_page' => $lastPage, + 'total' => $total, + ]; + } +} diff --git a/modules/SystemUser/Http/Controllers/CapabilityController.php b/modules/SystemUser/Http/Controllers/CapabilityController.php new file mode 100644 index 000000000..a77b239cf --- /dev/null +++ b/modules/SystemUser/Http/Controllers/CapabilityController.php @@ -0,0 +1,29 @@ + ['sometimes', 'required', 'array'], + 'routes' => ['sometimes', 'array'], + 'strict_route_binding' => ['nullable', 'boolean'], + 'requires_step_up' => ['nullable', 'boolean'], + ]; + } + + public function rulesForCreate() + { + $tableName = $this->model->getTable(); + + return [ + 'name' => "required|string|min:3|unique:{$tableName},name", + 'routes' => ['sometimes', 'array'], + ]; + } + + public function rulesForUpdate() + { + $tableName = $this->model->getTable(); + + return [ + 'name' => "sometimes|required|string|min:3|unique:{$tableName},name,{$this->id}", + 'roles' => ['sometimes', 'required', 'array'], + 'routes' => ['sometimes', 'array'], + ]; + } +} diff --git a/modules/SystemUser/Http/Requests/CapabilityRouteRequest.php b/modules/SystemUser/Http/Requests/CapabilityRouteRequest.php new file mode 100644 index 000000000..4036cbeb4 --- /dev/null +++ b/modules/SystemUser/Http/Requests/CapabilityRouteRequest.php @@ -0,0 +1,53 @@ +model->getTable(); + + return [ + 'route_name' => [ + "required", + "string", + "min:2", + "unique:{$tableName},route_name", + function (string $attribute, mixed $value, Closure $fail) { + if (! is_string($value) || ! Route::has($value)) { + $fail(__('The selected route name must be a named Laravel route.')); + } + }, + ], + ]; + } + + public function rulesForUpdate() + { + $tableName = $this->model->getTable(); + + return [ + 'route_name' => [ + "required", + "string", + "min:2", + "unique:{$tableName},route_name,{$this->id}", + function (string $attribute, mixed $value, Closure $fail) { + if (! is_string($value) || ! Route::has($value)) { + $fail(__('The selected route name must be a named Laravel route.')); + } + }, + ], + ]; + } +} diff --git a/modules/SystemUser/Repositories/CapabilityRepository.php b/modules/SystemUser/Repositories/CapabilityRepository.php new file mode 100644 index 000000000..b0082be33 --- /dev/null +++ b/modules/SystemUser/Repositories/CapabilityRepository.php @@ -0,0 +1,15 @@ +model = $model; + } +} + diff --git a/modules/SystemUser/Repositories/CapabilityRouteRepository.php b/modules/SystemUser/Repositories/CapabilityRouteRepository.php new file mode 100644 index 000000000..496c57ed3 --- /dev/null +++ b/modules/SystemUser/Repositories/CapabilityRouteRepository.php @@ -0,0 +1,15 @@ +model = $model; + } +} + diff --git a/modules/SystemUser/Routes/api.php b/modules/SystemUser/Routes/api.php index 236ca07b5..22b41cd54 100755 --- a/modules/SystemUser/Routes/api.php +++ b/modules/SystemUser/Routes/api.php @@ -14,4 +14,5 @@ | */ -Route::middleware(['api.auth', ...ModularityRoutes::defaultMiddlewares()])->group(function () {}); +Route::middleware(['api.auth', ...ModularityRoutes::defaultMiddlewares()])->group(function () { +}); diff --git a/modules/SystemUser/Routes/web.php b/modules/SystemUser/Routes/web.php index 3e4b4bd90..afdec204e 100755 --- a/modules/SystemUser/Routes/web.php +++ b/modules/SystemUser/Routes/web.php @@ -1,6 +1,7 @@ group(function () { - Route::middleware((ModularityRoutes::defaultPanelMiddlewares()))->group(function () {}); + Route::middleware((ModularityRoutes::defaultPanelMiddlewares()))->group(function () { + Route::get('capabilities/discover-routes', [CapabilityRouteDiscoveryController::class, 'index']) + ->name('capabilities.discover_routes'); + }); }); diff --git a/resources/views/auth/step-up-replay.blade.php b/resources/views/auth/step-up-replay.blade.php new file mode 100644 index 000000000..e1c96d533 --- /dev/null +++ b/resources/views/auth/step-up-replay.blade.php @@ -0,0 +1,53 @@ +@extends("{$MODULARITY_VIEW_NAMESPACE}::layouts.base") + +@section('body') + @php + $pendingRequest = $pendingRequest ?? []; + $payload = $pendingRequest['payload'] ?? []; + $method = strtoupper($pendingRequest['method'] ?? 'POST'); + $targetUrl = $pendingRequest['url'] ?? url()->previous(); + + $renderFields = function ($items, $prefix = null) use (&$renderFields) { + $html = ''; + + foreach ($items as $key => $value) { + $name = $prefix ? "{$prefix}[{$key}]" : $key; + + if (is_array($value)) { + $html .= $renderFields($value, $name); + continue; + } + + $html .= ''; + } + + return $html; + }; + @endphp + +
+
+

{{ __('Continuing your action') }}

+

{{ __('Your verification succeeded. We are continuing your previous request.') }}

+
+ @csrf + @if(! in_array($method, ['GET', 'POST'], true)) + + @endif + {!! $renderFields($payload) !!} + +
+
+
+@endsection + +@push('footer_js') + +@endpush diff --git a/routes/auth.php b/routes/auth.php index 10a862e7a..1c4a92a1e 100755 --- a/routes/auth.php +++ b/routes/auth.php @@ -14,16 +14,28 @@ */ if (modularityConfig('enabled.users-management')) { + $securityEnabled = (bool) modularityConfig('security.enabled', false); + $authMfaEnabled = (bool) modularityConfig('security.mfa.enabled', false); + $loginMiddlewares = $securityEnabled + ? ['throttle:' . modularityConfig('security.throttle.login', '8,1')] + : []; + $login2faMiddlewares = $authMfaEnabled + ? ['throttle:' . modularityConfig('security.mfa.throttle', modularityConfig('security.throttle.login_2fa', '6,1'))] + : []; + Route::get('register', 'RegisterController@showForm')->name('register.form'); Route::post('register', 'RegisterController@register')->name('register'); Route::get('login', 'LoginController@showForm')->name('login.form'); - Route::post('login', 'LoginController@login')->name('login'); + Route::post('login', 'LoginController@login')->middleware($loginMiddlewares)->name('login'); Route::post('logout', 'LoginController@logout')->name('logout'); Route::get('login/2fa', 'LoginController@showLogin2FaForm')->name('login-2fa.form'); - Route::post('login/2fa', 'LoginController@login2Fa')->name('login-2fa'); + Route::post('login/2fa', 'LoginController@login2Fa')->middleware($login2faMiddlewares)->name('login-2fa'); + Route::get('step-up', 'StepUpController@showForm')->name('step-up.form'); + Route::match(['get', 'post'], 'step-up/resend', 'StepUpController@resend')->middleware($login2faMiddlewares)->name('step-up.resend'); + Route::post('step-up/verify', 'StepUpController@verify')->middleware($login2faMiddlewares)->name('step-up.verify'); Route::get('login/oauth', 'LoginController@showPasswordForm')->name('login.oauth.showPasswordForm'); Route::post('login/oauth', 'LoginController@linkProvider')->name('login.oauth.linkProvider'); diff --git a/src/Entities/Traits/Core/HasCapabilities.php b/src/Entities/Traits/Core/HasCapabilities.php new file mode 100644 index 000000000..16d865e0e --- /dev/null +++ b/src/Entities/Traits/Core/HasCapabilities.php @@ -0,0 +1,80 @@ +getModel(); + $usersTable = $model->getTable(); + $capabilitiesTable = modularityConfig('tables.capabilities', 'um_capabilities'); + $roleCapabilityTable = modularityConfig('tables.role_capability', 'um_role_capability'); + $modelHasRolesTable = config('permission.table_names.model_has_roles', 'sp_model_has_roles'); + $modelMorphKey = config('permission.column_names.model_morph_key', 'model_id'); + + if (! class_exists(\Modules\SystemUser\Entities\Capability::class) + || ! Schema::hasTable($capabilitiesTable) + || ! Schema::hasTable($roleCapabilityTable) + || ! Schema::hasTable($modelHasRolesTable)) { + return; + } + + $capabilitiesSubQuery = DB::table("{$capabilitiesTable} as capabilities") + ->selectRaw( + "COALESCE(CONCAT('[', GROUP_CONCAT(DISTINCT JSON_QUOTE(capabilities.name) ORDER BY capabilities.name SEPARATOR ','), ']'), '[]')" + ) + ->join("{$roleCapabilityTable} as role_capability", 'role_capability.capability_id', '=', 'capabilities.id') + ->join("{$modelHasRolesTable} as model_has_roles", 'model_has_roles.role_id', '=', 'role_capability.role_id') + ->whereColumn("model_has_roles.{$modelMorphKey}", "{$usersTable}.id") + ->where('model_has_roles.model_type', $model::class) + ->where('capabilities.published', true); + + if ($builder->getQuery()->columns === null) { + $builder->select("{$usersTable}.*"); + } + + $builder->addSelect(['capabilities_payload' => $capabilitiesSubQuery]); + }); + } + + public function initializeHasCapabilities(): void + { + if (! modularityConfig('security.step_up.enabled', false)) { + return; + } + + $this->append(['capabilities']); + } + + protected function capabilities(): Attribute + { + return Attribute::make( + get: function ($value, array $attributes) { + $payload = $attributes['capabilities_payload'] ?? '[]'; + $decoded = json_decode((string) $payload, true); + + if (! is_array($decoded)) { + return []; + } + + return array_values(array_unique(array_filter($decoded, fn ($capability) => is_string($capability) && $capability !== ''))); + } + ); + } + + public function hasCapability(string $capability): bool + { + return in_array($capability, $this->capabilities ?? [], true); + } +} diff --git a/src/Entities/User.php b/src/Entities/User.php index 74d891d99..634c26fe0 100755 --- a/src/Entities/User.php +++ b/src/Entities/User.php @@ -16,6 +16,7 @@ use Unusualify\Modularity\Entities\Traits\Auth\CanRegister; use Unusualify\Modularity\Entities\Traits\Auth\HasOauth; use Unusualify\Modularity\Entities\Traits\Core\HasCompany; +use Unusualify\Modularity\Entities\Traits\Core\HasCapabilities; use Unusualify\Modularity\Entities\Traits\Core\ModelHelpers; use Unusualify\Modularity\Entities\Traits\Core\Rolable; use Unusualify\Modularity\Entities\Traits\HasFileponds; @@ -28,6 +29,7 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma use HasApiTokens, HasFactory, Rolable, + HasCapabilities, IsTranslatable, ModelHelpers, Notifiable, diff --git a/src/Http/Controllers/Auth/StepUpController.php b/src/Http/Controllers/Auth/StepUpController.php new file mode 100644 index 000000000..334ac0a52 --- /dev/null +++ b/src/Http/Controllers/Auth/StepUpController.php @@ -0,0 +1,49 @@ +stepUpService->hasActiveChallenge(request())) { + return redirect()->route(Route::hasAdmin('dashboard')); + } + + return $this->viewFactory->make( + modularityBaseKey() . '::auth.login', + $this->buildAuthViewData($this->stepUpService->pageKey(), [ + 'formAttributes' => [ + 'subtitle' => __('We sent a verification code to your email.'), + ], + ]) + ); + } + + public function verify(Request $request) + { + return $this->stepUpService->verify($request); + } + + public function resend(Request $request) + { + return $this->stepUpService->resend($request); + } +} diff --git a/src/Http/Controllers/Traits/Utilities/AuthFormBuilder.php b/src/Http/Controllers/Traits/Utilities/AuthFormBuilder.php index b8e867ecd..26d361fa8 100644 --- a/src/Http/Controllers/Traits/Utilities/AuthFormBuilder.php +++ b/src/Http/Controllers/Traits/Utilities/AuthFormBuilder.php @@ -325,6 +325,24 @@ protected function resolveFormSlotsPreset(?string $preset): array route('admin.password.reset.link') ), ], + 'login_mfa_options' => [ + 'options' => $this->authFormOptionSlot( + __('authentication.create-an-account'), + route(Route::hasAdmin('register.email_form')) + ), + ], + 'login_2fa_options' => [ + 'options' => $this->authFormOptionSlot( + __('authentication.back-to-login'), + route(Route::hasAdmin('login.form')) + ), + ], + 'step_up_options' => [ + 'options' => $this->authFormOptionSlot( + __('Resend verification code'), + route(Route::hasAdmin('step-up.resend')) + ), + ], 'have_account' => $this->haveAccountOptionSlot(), 'restart' => $this->restartOptionSlot(), 'resend' => $this->resendOptionSlot(), @@ -391,6 +409,11 @@ protected function resolveSlotsPreset(?string $preset): array $this->createAccountButtonSlot(), ]), ], + 'login_mfa_bottom' => [ + 'bottom' => $this->authBottomSlots([ + $this->oauthGoogleButtonSlot('sign-in'), + ]), + ], 'register_bottom' => [ 'bottom' => $this->authBottomSlots([ $this->oauthGoogleButtonSlot('sign-up'), diff --git a/src/Http/Middleware/StepUpMiddleware.php b/src/Http/Middleware/StepUpMiddleware.php new file mode 100644 index 000000000..dbd3d8332 --- /dev/null +++ b/src/Http/Middleware/StepUpMiddleware.php @@ -0,0 +1,45 @@ +user(); + $currentRouteName = $request->route()?->getName(); + + if (! $user || ! is_string($currentRouteName) || $currentRouteName === '') { + return $next($request); + } + + $matchedCapability = $this->securityService->matchedUserStepUpCapability($user, $currentRouteName, $capability); + + if (! $matchedCapability) { + return $next($request); + } + + $verifiedAt = (int) $request->session()->get('security_step_up_verified_at', 0); + $ttlMinutes = (int) modularityConfig('security.session.step_up_ttl_minutes', 15); + + if ($verifiedAt > 0 && (time() - $verifiedAt) <= ($ttlMinutes * 60)) { + return $next($request); + } + + return $this->stepUpService->interrupt($request, $matchedCapability); + } +} diff --git a/src/Notifications/StepUpCodeNotification.php b/src/Notifications/StepUpCodeNotification.php new file mode 100644 index 000000000..07e0718fa --- /dev/null +++ b/src/Notifications/StepUpCodeNotification.php @@ -0,0 +1,36 @@ +subject(Lang::get('Security Verification Code')) + ->line(Lang::get('Use this code to confirm your sensitive action: :code', ['code' => $this->code])) + ->line(Lang::get('This code will expire at :time', ['time' => $this->expiresAt->format('H:i')])) + ->line(Lang::get('If you did not trigger this action, you can ignore this email.')); + } +} diff --git a/src/Services/Security/StepUpService.php b/src/Services/Security/StepUpService.php new file mode 100644 index 000000000..15194c028 --- /dev/null +++ b/src/Services/Security/StepUpService.php @@ -0,0 +1,359 @@ +config('enabled', false); + } + + public function otpField(): string + { + return (string) $this->config('otp_field', modularityConfig('security.mfa.otp_field', 'verify-code')); + } + + public function pageKey(): string + { + return (string) $this->config('page', 'step_up'); + } + + public function challengeRouteName(): string + { + $route = (string) $this->config('challenge_form_route', 'admin.step-up.form'); + + return Route::has($route) ? $route : Route::hasAdmin('dashboard'); + } + + public function verifyRouteName(): string + { + $route = (string) $this->config('verify_route', 'admin.step-up.verify'); + + return Route::has($route) ? $route : Route::hasAdmin('dashboard'); + } + + public function resendRouteName(): string + { + $route = (string) $this->config('resend_route', 'admin.step-up.resend'); + + return Route::has($route) ? $route : $this->challengeRouteName(); + } + + public function challengePayload(?string $capability = null): array + { + return [ + 'title' => __('Verification required'), + 'description' => __('We sent a verification code to your email to confirm this sensitive action.'), + 'verifyUrl' => route($this->verifyRouteName()), + 'resendUrl' => route($this->resendRouteName()), + 'otpField' => $this->otpField(), + 'otpLength' => $this->codeLength(), + 'buttonText' => __('Verify'), + 'resendText' => __('Resend code'), + 'capability' => $capability, + ]; + } + + public function interrupt(Request $request, string $capability): JsonResponse|RedirectResponse + { + $user = $request->user(); + + abort_unless($user instanceof Authenticatable, 403); + + $resolvedUser = User::find($user->getAuthIdentifier()); + abort_unless($resolvedUser instanceof User, 403); + + + $this->storePendingRequest($request, $capability); + $this->createChallenge($request, $resolvedUser, $capability); + + if ($request->expectsJson()) { + return response()->json([ + 'message' => __('Step-up verification required.'), + 'variant' => MessageStage::WARNING, + 'step_up_required' => true, + 'step_up' => $this->challengePayload($capability), + ], 428); + } + + return redirect()->to(route($this->challengeRouteName())); + } + + public function resend(Request $request): JsonResponse|RedirectResponse + { + $user = $this->resolveUserFromSession($request); + + if (! $user) { + return $this->failureResponse($request, __('Your verification session has expired. Please try again.')); + } + + $capability = (string) $request->session()->get($this->capabilitySessionKey(), ''); + $this->createChallenge($request, $user, $capability); + + if ($request->expectsJson()) { + return response()->json([ + 'message' => __('A new verification code has been sent.'), + 'variant' => MessageStage::SUCCESS, + ], 200); + } + + return redirect()->to(route($this->challengeRouteName())) + ->with('status', __('A new verification code has been sent.')); + } + + public function verify(Request $request): JsonResponse|RedirectResponse + { + $user = $this->resolveUserFromSession($request); + + if (! $user) { + return $this->failureResponse($request, __('Your verification session has expired. Please try again.')); + } + + if (! $this->validateOtp($request, $user)) { + return $this->failureResponse($request, __('Your verification code is invalid.')); + } + + $request->session()->put('security_step_up_verified_at', time()); + + if ($request->expectsJson()) { + $this->clearChallengeState($request, keepPendingRequest: true); + + return response()->json([ + 'message' => __('Verification completed. You can continue your action.'), + 'variant' => MessageStage::SUCCESS, + 'step_up_verified' => true, + ], 200); + } + + $pending = $this->pullPendingRequest($request); + $this->clearChallengeState($request, keepPendingRequest: false); + + if (! $pending) { + $returnUrl = (string) $request->session()->pull($this->returnUrlSessionKey(), route(Route::hasAdmin('dashboard'))); + + return redirect()->to($returnUrl); + } + + if (($pending['method'] ?? 'GET') === 'GET') { + return redirect()->to((string) ($pending['full_url'] ?? $pending['url'] ?? route(Route::hasAdmin('dashboard')))); + } + + return response()->view(modularityBaseKey() . '::auth.step-up-replay', [ + 'pendingRequest' => $pending, + 'pageTitle' => __('Continuing your action') . ' | ' . \Unusualify\Modularity\Facades\Modularity::pageTitle(), + 'otpField' => $this->otpField(), + ]); + } + + public function resolveUserFromSession(Request $request): ?User + { + $userId = $request->session()->get($this->userSessionKey()); + + if (! $userId) { + return null; + } + + return User::find($userId); + } + + public function hasActiveChallenge(Request $request): bool + { + return (bool) $request->session()->has($this->userSessionKey()); + } + + private function provider(): string + { + return (string) $this->config('provider', modularityConfig('security.mfa.provider', 'email_otp')); + } + + private function usesEmailOtp(): bool + { + return $this->provider() === 'email_otp'; + } + + private function userSessionKey(): string + { + return (string) $this->config('user_session_key', 'step-up:user:id'); + } + + private function flowSessionKey(): string + { + return (string) $this->config('flow_session_key', 'step-up:flow:key'); + } + + private function capabilitySessionKey(): string + { + return (string) $this->config('capability_session_key', 'step-up:capability:key'); + } + + private function pendingRequestSessionKey(): string + { + return (string) $this->config('pending_request_session_key', 'step-up:pending:request'); + } + + private function returnUrlSessionKey(): string + { + return (string) $this->config('return_url_session_key', 'step-up:return:url'); + } + + private function codeLength(): int + { + return (int) $this->config('email_otp.length', 6); + } + + private function codeExpiryMinutes(): int + { + return (int) $this->config('email_otp.expire_minutes', 10); + } + + private function codeMaxAttempts(): int + { + return (int) $this->config('email_otp.max_attempts', 5); + } + + private function cachePrefix(): string + { + return (string) $this->config('email_otp.cache_prefix', 'step-up:email-otp'); + } + + private function generateCode(): string + { + $length = max(4, min(10, $this->codeLength())); + $max = (10 ** $length) - 1; + + return str_pad((string) random_int(0, $max), $length, '0', STR_PAD_LEFT); + } + + private function createChallenge(Request $request, User $user, ?string $capability = null): void + { + if ($this->usesEmailOtp()) { + $flowKey = $this->cachePrefix() . ':' . (string) Str::uuid(); + $code = $this->generateCode(); + $expiresAt = now()->addMinutes($this->codeExpiryMinutes()); + + Cache::put($flowKey, [ + 'user_id' => $user->id, + 'email' => $user->email, + 'code_hash' => Hash::make($code), + 'attempts' => 0, + 'expires_at' => $expiresAt->toDateTimeString(), + 'capability' => $capability, + ], $expiresAt); + + $request->session()->put($this->flowSessionKey(), $flowKey); + + $user->notify(new StepUpCodeNotification( + code: $code, + expiresAt: $expiresAt, + capability: $capability, + )); + } + + $request->session()->put($this->userSessionKey(), $user->id); + $request->session()->put($this->capabilitySessionKey(), $capability); + $request->session()->put($this->returnUrlSessionKey(), url()->previous()); + } + + private function storePendingRequest(Request $request, ?string $capability = null): void + { + $request->session()->put($this->pendingRequestSessionKey(), [ + 'url' => $request->url(), + 'full_url' => $request->fullUrl(), + 'method' => strtoupper($request->method()), + 'payload' => collect($request->request->all()) + ->except(['_token', '_method']) + ->toArray(), + 'query' => $request->query(), + 'capability' => $capability, + ]); + } + + private function pullPendingRequest(Request $request): ?array + { + $pending = $request->session()->pull($this->pendingRequestSessionKey()); + + return is_array($pending) ? $pending : null; + } + + private function clearChallengeState(Request $request, bool $keepPendingRequest = false): void + { + $flowKey = (string) $request->session()->get($this->flowSessionKey(), ''); + if ($flowKey !== '') { + Cache::forget($flowKey); + } + + $request->session()->forget($this->userSessionKey()); + $request->session()->forget($this->flowSessionKey()); + $request->session()->forget($this->capabilitySessionKey()); + + if (! $keepPendingRequest) { + $request->session()->forget($this->pendingRequestSessionKey()); + } + } + + private function validateOtp(Request $request, User $user): bool + { + if ($this->usesEmailOtp()) { + $flowKey = (string) $request->session()->get($this->flowSessionKey(), ''); + $challenge = $flowKey !== '' ? Cache::get($flowKey) : null; + + if (! is_array($challenge) || (int) ($challenge['user_id'] ?? 0) !== (int) $user->id) { + return false; + } + + if ((int) ($challenge['attempts'] ?? 0) >= $this->codeMaxAttempts()) { + Cache::forget($flowKey); + + return false; + } + + $otp = (string) $request->input($this->otpField(), ''); + $valid = Hash::check($otp, (string) ($challenge['code_hash'] ?? '')); + + if (! $valid) { + $challenge['attempts'] = (int) ($challenge['attempts'] ?? 0) + 1; + Cache::put($flowKey, $challenge, now()->addMinutes($this->codeExpiryMinutes())); + } + + return $valid; + } + + $otp = (string) $request->input($this->otpField(), ''); + + return (new Google2FA)->verifyKey((string) $user->google_2fa_secret, $otp); + } + + private function failureResponse(Request $request, string $message): JsonResponse|RedirectResponse + { + if ($request->expectsJson()) { + return response()->json([ + 'message' => $message, + 'variant' => MessageStage::WARNING, + ], 422); + } + + return redirect()->to(route($this->challengeRouteName())) + ->withErrors(['error' => $message]); + } +} From 4b825469f997e146270f550d62241f9437949741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Thu, 2 Apr 2026 01:11:58 +0300 Subject: [PATCH 029/163] test(security): add unit tests for MFA and field permission checks - Implemented tests for MFA requirements based on user roles and configurations. - Added tests to verify behavior of the email OTP provider regarding Google 2FA columns. - Included tests for field permission checks to ensure proper access control based on user capabilities. - Refactored existing test methods for consistency and clarity. --- .../Controllers/Auth/LoginControllerTest.php | 10 +++- tests/Services/CoverageServiceTest.php | 2 +- .../Services/Security/SecurityServiceTest.php | 52 +++++++++++++++++++ tests/Traits/MiscTraitsTest.php | 2 +- 4 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 tests/Services/Security/SecurityServiceTest.php diff --git a/tests/Http/Controllers/Auth/LoginControllerTest.php b/tests/Http/Controllers/Auth/LoginControllerTest.php index 76d44fb66..cb37a0f21 100644 --- a/tests/Http/Controllers/Auth/LoginControllerTest.php +++ b/tests/Http/Controllers/Auth/LoginControllerTest.php @@ -102,8 +102,10 @@ public function it_returns_json_on_failed_login_when_requesting_json(): void $response = $method->invoke($this->controller, $request); $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertEquals(422, $response->getStatusCode()); $data = json_decode($response->getContent(), true); - $this->assertArrayHasKey('email', $data); + $this->assertArrayHasKey('errors', $data); + $this->assertArrayHasKey('email', $data['errors']); $this->assertArrayHasKey('message', $data); $this->assertArrayHasKey('variant', $data); } @@ -155,6 +157,9 @@ public function it_returns_json_response_when_authenticated_without_2fa(): void /** @test */ public function it_redirects_to_2fa_form_when_user_has_2fa_enabled(): void { + config()->set('modularity.security.mfa.enabled', true); + config()->set('modularity.security.mfa.provider', 'google_totp'); + $user = (object) [ 'id' => 1, 'google_2fa_secret' => 'secret', @@ -179,6 +184,9 @@ public function it_redirects_to_2fa_form_when_user_has_2fa_enabled(): void /** @test */ public function it_returns_json_with_redirector_when_authenticated_with_2fa_and_requesting_json(): void { + config()->set('modularity.security.mfa.enabled', true); + config()->set('modularity.security.mfa.provider', 'google_totp'); + $user = (object) [ 'id' => 1, 'google_2fa_secret' => 'secret', diff --git a/tests/Services/CoverageServiceTest.php b/tests/Services/CoverageServiceTest.php index f337424dc..5a3e4425f 100644 --- a/tests/Services/CoverageServiceTest.php +++ b/tests/Services/CoverageServiceTest.php @@ -125,7 +125,7 @@ public function git_parses_branch_references_correctly() // Test that different branch formats are handled $mock = new class($this->cloverDir, $this->cloverName) extends CoverageService { - public function test_get_git_changed_files(string $baseBranch): array + public function testGetGitChangedFiles(string $baseBranch): array { // Call the private method through reflection $method = new \ReflectionMethod(parent::class, 'getGitChangedFiles'); diff --git a/tests/Services/Security/SecurityServiceTest.php b/tests/Services/Security/SecurityServiceTest.php new file mode 100644 index 000000000..215fd18de --- /dev/null +++ b/tests/Services/Security/SecurityServiceTest.php @@ -0,0 +1,52 @@ +set('modularity.security.mfa.enabled', true); + config()->set('modularity.security.mfa.provider', 'google_totp'); + config()->set('modularity.security.mfa.required_roles', ['admin']); + + $user = \Mockery::mock(Authenticatable::class); + $user->shouldReceive('hasRole')->with('admin')->andReturn(true); + $user->google_2fa_enabled = false; + $user->google_2fa_secret = null; + + $service = new SecurityService; + + $this->assertTrue($service->userRequiresMfa($user)); + $this->assertFalse($service->userHasEnabledMfa($user)); + } + + public function test_email_otp_provider_does_not_require_google_2fa_columns(): void + { + config()->set('modularity.security.mfa.enabled', true); + config()->set('modularity.security.mfa.provider', 'email_otp'); + + $user = \Mockery::mock(Authenticatable::class); + + $service = new SecurityService; + + $this->assertTrue($service->userHasEnabledMfa($user)); + } + + public function test_field_permission_checks_are_applied(): void + { + config()->set('modularity.security.critical_field_permissions.canonical_url', 'cms-seo-override_edit'); + + $user = \Mockery::mock(Authenticatable::class); + $user->shouldReceive('can')->with('cms-seo-override_edit')->andReturn(true); + + $service = new SecurityService; + + $this->assertTrue($service->canWriteField($user, 'canonical_url')); + $this->assertTrue($service->canWriteField($user, 'non_critical_field')); + } +} diff --git a/tests/Traits/MiscTraitsTest.php b/tests/Traits/MiscTraitsTest.php index febd70e5e..6ffa3481a 100644 --- a/tests/Traits/MiscTraitsTest.php +++ b/tests/Traits/MiscTraitsTest.php @@ -112,7 +112,7 @@ public function it_can_use_traitify_trait() { use Traitify; - public function test_method_traitify() + public function testMethodTraitify() { return 'success'; } From 91011b91baacfdf5c7e486be26c837431c0699dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Thu, 2 Apr 2026 01:12:08 +0300 Subject: [PATCH 030/163] chore(dependencies): add pragmarx/google2fa package for multi-factor authentication support --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index 7e975f6a3..81602c6ce 100755 --- a/composer.json +++ b/composer.json @@ -56,6 +56,7 @@ "oobook/priceable": "^1.0", "oobook/snapshot": "^2.0", "orangehill/iseed": "^3.0", + "pragmarx/google2fa": "^8.0", "spatie/laravel-activitylog": "^3.0|^4.0", "spatie/laravel-permission": "^5.0", "spatie/once": "^2.0|^3.0", From 795fb3be91b4fea972cad1cc59b264f765450986 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Thu, 2 Apr 2026 01:12:19 +0300 Subject: [PATCH 031/163] feat(database): add Google 2FA fields to users table for multi-factor authentication support --- .../default/2022_01_23_085810_create_modularity_users_table.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/database/migrations/default/2022_01_23_085810_create_modularity_users_table.php b/database/migrations/default/2022_01_23_085810_create_modularity_users_table.php index c619c5695..c38829e41 100644 --- a/database/migrations/default/2022_01_23_085810_create_modularity_users_table.php +++ b/database/migrations/default/2022_01_23_085810_create_modularity_users_table.php @@ -24,6 +24,8 @@ public function up(): void $table->boolean('published')->default(true); $table->string('email')->unique(); $table->timestamp('email_verified_at')->nullable(); + $table->boolean('google_2fa_enabled')->default(false); + $table->string('google_2fa_secret')->nullable(); $table->string('language')->default('en'); $table->string('timezone')->default('Europe/London'); $table->string('phone', 20)->nullable(); From 4a2e9453909fb7ff259db623af1f44e899f22bca Mon Sep 17 00:00:00 2001 From: celikerde Date: Thu, 2 Apr 2026 19:17:22 +0300 Subject: [PATCH 032/163] refactor(HasRevisions, RevisionsTrait): update visibility and add new methods for revision management - Changed `getRevisionModel` method from protected to public for broader access. - Introduced `getFormFieldsRevisionsTrait` method to manipulate form fields based on the object and schema. - Added `getRevisions` method to retrieve revisions for a specific model instance. --- src/Entities/Traits/HasRevisions.php | 2 +- src/Repositories/Traits/RevisionsTrait.php | 26 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/Entities/Traits/HasRevisions.php b/src/Entities/Traits/HasRevisions.php index 1258561cf..3132ac30c 100755 --- a/src/Entities/Traits/HasRevisions.php +++ b/src/Entities/Traits/HasRevisions.php @@ -82,7 +82,7 @@ public function deleteSpecificRevisions(int $maxRevisions): void } - protected function getRevisionModel() + public function getRevisionModel() { if (property_exists($this, 'revisionModel') && is_string($this->revisionModel) && @class_exists($this->revisionModel)) { return $this->revisionModel; diff --git a/src/Repositories/Traits/RevisionsTrait.php b/src/Repositories/Traits/RevisionsTrait.php index 5dd88d530..491297e16 100644 --- a/src/Repositories/Traits/RevisionsTrait.php +++ b/src/Repositories/Traits/RevisionsTrait.php @@ -16,6 +16,22 @@ public function afterSaveRevisionsTrait($object, $fields): void $this->createRevisionIfNeeded($object, $fields); } + /** + * @param Model $object + * @param array $fields + * @param array $schema + * @return array + */ + public function getFormFieldsRevisionsTrait($object, $fields, $schema = []) + { + // set, cast, unset or manipulate the fields by using object, fields and schema + if (isset($schema['revisionable_id'])) { + $fields['revisionable_id'] = $object?->id; + } + + return $fields; + } + public function createRevisionIfNeeded($object, array $fields): array { if ($this->skipRevisionCreation) { @@ -113,4 +129,14 @@ protected function hydrateObject($object, array $fields) return $this->hydrate($object, $fields); } + + public function getRevisions(int $id) + { + $revisionModel = $this->model->getRevisionModel(); + $revisions = $revisionModel::where($this->model->getForeignKey(), $id) + ->orderBy('created_at', 'desc') + ->get(); + + return $revisions; + } } From fb48459c06f234c626b87f4c41586a462794cb87 Mon Sep 17 00:00:00 2001 From: celikerde Date: Thu, 2 Apr 2026 19:18:02 +0300 Subject: [PATCH 033/163] feat(ManagePreview): add ManagePreview trait for handling preview and revision functionalities - Implemented methods for showing views, listing revisions, and restoring revisions. - Integrated logic for handling active languages and preview views based on module names. - Enhanced response structure for restoring revisions with success messages and form fields. --- src/Http/Controllers/Traits/ManagePreview.php | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/Http/Controllers/Traits/ManagePreview.php diff --git a/src/Http/Controllers/Traits/ManagePreview.php b/src/Http/Controllers/Traits/ManagePreview.php new file mode 100644 index 000000000..1c6d6b9f3 --- /dev/null +++ b/src/Http/Controllers/Traits/ManagePreview.php @@ -0,0 +1,89 @@ +request->has('revisionId')); + if ($this->request->has('revisionId')) { + $item = $this->repository->previewForRevision($id, $this->request->get('revisionId')); + } else { + $formRequest = $this->validateFormRequest(); + $item = $this->repository->preview($id, $formRequest->all()); + } + + if ($this->request->has('activeLanguage')) { + //App::setLocale($this->request->get('activeLanguage')); + } + + // dd($this->previewView); + + $previewView = $this->previewView ?? (Config::get('twill.frontend.views_path', 'site') . '.' . Str::singular( + $this->moduleName + )); + + // dd($previewView); + + return View::exists($previewView) ? View::make( + $previewView, + array_replace([ + 'item' => $item, + ], $this->previewData($item)) + ) : View::make('twill::errors.preview', [ + 'moduleName' => Str::singular($this->moduleName), + ]); + } + + public function listRevisions($id) + { + $revisions = $this->repository->getRevisions($id); + return $revisions; + } + + public function restoreRevision($id) + { + // dd('restoreRevision'); + if (! $this->routeHasTrait('revisions')) { + return $this->respondWithError(__('Revisions are not enabled for this route.')); + } + + $params = $this->request->route()->parameters(); + $id = last($params); + $revisionId = (int) $this->request->get('revisionId'); + // dd($revisionId); + + if ($revisionId < 1) { + return $this->respondWithError(__('Revision id is required.')); + } + + if ($this->request->get('preview')) { + // dd("preview is called for revision id: $revisionId"); + $rawPayload = $this->repository->getRevisionPayload((int) $id, $revisionId); + + return Response::json([ + 'form_fields' => $rawPayload, + ]); + } + + + $item = $this->repository->restoreRevision((int) $id, $revisionId); + // dd($item); + + return Response::json([ + 'message' => __('Revision restored successfully.'), + 'variant' => MessageStage::SUCCESS, + 'revisions' => $item->revisionsArray(), + 'form_fields' => $this->repository->getFormFields($item, $this->getPreviousRouteSchema()), + ]); + } + +} From 96972dd6172f863b356f6d2472d758a8836428ac Mon Sep 17 00:00:00 2001 From: celikerde Date: Thu, 2 Apr 2026 19:18:34 +0300 Subject: [PATCH 034/163] refactor(BaseController): integrate ManagePreview trait and remove obsolete restoreRevision method - Added ManagePreview trait to enhance preview functionalities. - Removed the restoreRevision method to streamline the controller's responsibilities. --- src/Http/Controllers/BaseController.php | 36 ++----------------------- 1 file changed, 2 insertions(+), 34 deletions(-) diff --git a/src/Http/Controllers/BaseController.php b/src/Http/Controllers/BaseController.php index 935cd3bff..28fda76a0 100755 --- a/src/Http/Controllers/BaseController.php +++ b/src/Http/Controllers/BaseController.php @@ -15,6 +15,7 @@ use Illuminate\Support\Str; use Unusualify\Modularity\Http\Controllers\Traits\ManageIndexAjax; use Unusualify\Modularity\Http\Controllers\Traits\ManageInertia; +use Unusualify\Modularity\Http\Controllers\Traits\ManagePreview; use Unusualify\Modularity\Http\Controllers\Traits\ManagePrevious; use Unusualify\Modularity\Http\Controllers\Traits\ManageSingleton; use Unusualify\Modularity\Http\Controllers\Traits\ManageTranslations; @@ -23,7 +24,7 @@ abstract class BaseController extends PanelController { - use ManageIndexAjax, ManagePrevious, ManageUtilities, ManageSingleton, ManageInertia, ManageTranslations; + use ManageIndexAjax, ManagePrevious, ManageUtilities, ManageSingleton, ManageInertia, ManageTranslations, ManagePreview; /** * @var string @@ -384,39 +385,6 @@ public function update($id, $submoduleId = null) } } - public function restoreRevision($id) - { - if (! $this->routeHasTrait('revisions')) { - return $this->respondWithError(__('Revisions are not enabled for this route.')); - } - - $params = $this->request->route()->parameters(); - $id = last($params); - $revisionId = (int) $this->request->get('revisionId'); - - if ($revisionId < 1) { - return $this->respondWithError(__('Revision id is required.')); - } - - if ($this->request->get('preview')) { - // dd("preview"); - $rawPayload = $this->repository->getRevisionPayload((int) $id, $revisionId); - - return Response::json([ - 'form_fields' => $rawPayload, - ]); - } - - $item = $this->repository->restoreRevision((int) $id, $revisionId); - - return Response::json([ - 'message' => __('Revision restored successfully.'), - 'variant' => MessageStage::SUCCESS, - 'revisions' => $item->revisionsArray(), - 'form_fields' => $this->repository->getFormFields($item, $this->getPreviousRouteSchema()), - ]); - } - /** * @param int $id * @param int|null $submoduleId From 0f318639b0c8acc570a9708933a0a210fb741750 Mon Sep 17 00:00:00 2001 From: celikerde Date: Thu, 2 Apr 2026 19:18:49 +0300 Subject: [PATCH 035/163] feat(RevisionHydrate): implement RevisionHydrate class for input handling - Created the RevisionHydrate class to manage input hydration for revision-related functionalities. - Defined default requirements and implemented the hydrate method to manipulate input schema structure. - Added endpoints for restoring revisions, showing views, and listing revisions based on the route name. --- src/Hydrates/Inputs/RevisionHydrate.php | 57 +++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/Hydrates/Inputs/RevisionHydrate.php diff --git a/src/Hydrates/Inputs/RevisionHydrate.php b/src/Hydrates/Inputs/RevisionHydrate.php new file mode 100644 index 000000000..6189401b1 --- /dev/null +++ b/src/Hydrates/Inputs/RevisionHydrate.php @@ -0,0 +1,57 @@ + 'revision_id', + 'noSubmit' => true, + 'col' => ['cols' => 12], + 'default' => null, + ]; + + /** + * Manipulate Input Schema Structure + * + * @return void + */ + public function hydrate() + { + $input = $this->input; + + $input['type'] = 'input-revision'; + $input['name'] = 'revisionable_id'; + + + $snakeRouteName = snakeCase($this->routeName); + + $input['restoreEndpoint'] = $this->module->getRouteActionUrl( + $this->routeName, + 'restoreRevision', + [$snakeRouteName => ':id'] + ); + + $input['showViewEndpoint'] = $this->module->getRouteActionUrl( + $this->routeName, + 'showView', + [$snakeRouteName => ':id'] + ); + + $input['listRevisionsEndpoint'] = $this->module->getRouteActionUrl( + $this->routeName, + 'listRevisions', + [$snakeRouteName => ':id'] + ); + + dd($input); + + return $input; + } +} From 17be84b24b02462ff7636d7a13b944fa0d052b60 Mon Sep 17 00:00:00 2001 From: celikerde Date: Thu, 2 Apr 2026 19:19:19 +0300 Subject: [PATCH 036/163] feat(Module, RouteServiceProvider): add new routes for revision management - Introduced 'restoreRevision', 'showView', and 'listRevisions' to the Module class. - Updated RouteServiceProvider to handle new routes for 'showView' and 'listRevisions', enhancing revision management capabilities. --- src/Module.php | 3 +++ src/Providers/RouteServiceProvider.php | 12 ++++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/Module.php b/src/Module.php index be4895d4d..2bf605369 100755 --- a/src/Module.php +++ b/src/Module.php @@ -54,6 +54,9 @@ class Module extends NwidartModule 'tagsUpdate', 'assignments', 'createAssignment', + 'restoreRevision', + 'showView', + 'listRevisions', ]; /** diff --git a/src/Providers/RouteServiceProvider.php b/src/Providers/RouteServiceProvider.php index 355b2d1b3..eae4bb3b1 100755 --- a/src/Providers/RouteServiceProvider.php +++ b/src/Providers/RouteServiceProvider.php @@ -361,6 +361,8 @@ protected function bootMacros() // 'feature', // 'preview', // 'bulkFeature', + 'showView', + 'listRevisions', 'restoreRevision', 'restore', @@ -400,8 +402,9 @@ protected function bootMacros() 'uses' => "{$controllerClass}@{$customRoute}", ]; - if ($customRoute === 'assignments') { - Route::get("{$url}/{{$snakeCase}}/assignments", $mapping); + if (in_array($customRoute, ['assignments', 'listRevisions'])) { + // dd($customRoute, $routeSlug, $mapping, $url, $snakeCase); + Route::get("{$url}/{{$snakeCase}}/{$customRouteKebab}", $mapping, ); } if ($customRoute === 'createAssignment') { @@ -429,11 +432,12 @@ protected function bootMacros() Route::put($routeSlug, $mapping); } - if (in_array($customRoute, ['duplicate'])) { + if (in_array($customRoute, ['duplicate', 'preview', 'showView','restoreRevision'])) { Route::put($routeSlug . "/{{$snakeCase}}", $mapping); } - if (in_array($customRoute, ['preview'])) { + if (in_array($customRoute, ['preview', 'showView', 'restoreRevision'])) { + // dd($customRoute, $routeSlug, $routeSlug . "/{{$snakeCase}}", $mapping); Route::put($routeSlug . "/{{$snakeCase}}", $mapping); } From 7c4c0d3eca1a138b0d5871c1164dd43b35e3f602 Mon Sep 17 00:00:00 2001 From: celikerde Date: Thu, 2 Apr 2026 19:19:46 +0300 Subject: [PATCH 037/163] feat(Revision): create Revision component for displaying individual revisions - Added a new Revision component to encapsulate the display logic for each revision, including author initials, date formatting, and a restore button. - Updated RevisionsList component to utilize the new Revision component, improving code organization and readability. --- vue/src/js/components/form/Revision.vue | 81 ++++++++++++++++++++ vue/src/js/components/form/RevisionsList.vue | 61 ++------------- 2 files changed, 88 insertions(+), 54 deletions(-) create mode 100644 vue/src/js/components/form/Revision.vue diff --git a/vue/src/js/components/form/Revision.vue b/vue/src/js/components/form/Revision.vue new file mode 100644 index 000000000..53a1eeef7 --- /dev/null +++ b/vue/src/js/components/form/Revision.vue @@ -0,0 +1,81 @@ + + + diff --git a/vue/src/js/components/form/RevisionsList.vue b/vue/src/js/components/form/RevisionsList.vue index 8ff3e104b..c0cab2d2e 100644 --- a/vue/src/js/components/form/RevisionsList.vue +++ b/vue/src/js/components/form/RevisionsList.vue @@ -36,47 +36,14 @@
- - - - - {{ revision.author }} - - - - - - - {{ formatDate(revision.datetime) }} - - - - + :revision="revision" + :all-revisions="revisions" + :show-restore="windowStart !== 0 || index !== 0" + @restore="selectRevision" + />
@@ -113,6 +80,7 @@ diff --git a/vue/src/js/components/others/FormBase.vue b/vue/src/js/components/others/FormBase.vue index 448c3d0a1..cec2dea69 100644 --- a/vue/src/js/components/others/FormBase.vue +++ b/vue/src/js/components/others/FormBase.vue @@ -73,7 +73,7 @@ diff --git a/vue/src/js/components/inputs/registry.js b/vue/src/js/components/inputs/registry.js index f749f2a27..d5adc7e73 100644 --- a/vue/src/js/components/inputs/registry.js +++ b/vue/src/js/components/inputs/registry.js @@ -55,6 +55,7 @@ const hydrateTypeMap = { 'input-radio-group': 'VInputRadioGroup', 'input-repeater': 'VInputRepeater', 'input-select-scroll': 'VInputSelectScroll', + 'input-slug': 'VInputSlug', 'input-spread': 'VInputSpread', 'input-tag': 'VInputTag', 'input-tagger': 'VInputTagger' diff --git a/vue/src/js/utils/getFormData.js b/vue/src/js/utils/getFormData.js index 6c7dbf5d4..8da780109 100755 --- a/vue/src/js/utils/getFormData.js +++ b/vue/src/js/utils/getFormData.js @@ -33,7 +33,9 @@ const numberable = 'number-input' // const isMediableTypes = 'input-file|input-image' // const isMediableFields = 'files|medias' -export const getSchema = (inputs, model = null, isEditing = false) => { +export const getSchema = (inputs, model = null, isEditing = false, editingEntityId = null) => { + const entityId = editingEntityId ?? (__isset(model) && __isset(model.id) ? model.id : null) + let _inputs = _.omitBy(inputs, (value, key) => { return Object.prototype.hasOwnProperty.call(value, 'slotable') || isFormEventInput(value, model) @@ -99,7 +101,14 @@ export const getSchema = (inputs, model = null, isEditing = false) => { } if (__isset(input) && __isset(input.schema) && ['wrap', 'group', 'repeater', 'input-repeater'].includes(input.type)) { - input.schema = getSchema(input.schema, input.type === 'wrap' ? model : model[key], isEditing); + input.schema = getSchema(input.schema, input.type === 'wrap' ? model : model[key], isEditing, entityId); + } + + if (isEditing && entityId != null && entityId !== '') { + const slugTypes = ['input-slug', 'slug'] + if (slugTypes.includes(input.type)) { + input.excludeId = entityId + } } input.creatable = isCreatable From c2ac417060c98952e0c098ad379a92c70447b4a3 Mon Sep 17 00:00:00 2001 From: celikerde Date: Thu, 9 Apr 2026 18:58:47 +0300 Subject: [PATCH 051/163] fix(TranslationsTrait): add afterSaveTranslationsTrait method for timestamp management - Introduced afterSaveTranslationsTrait method to re-enable timestamps and touch the parent model only when meaningful changes occur in translation rows, ignoring auto-timestamp columns. This enhances the handling of translation updates in the model. --- src/Repositories/Traits/TranslationsTrait.php | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/Repositories/Traits/TranslationsTrait.php b/src/Repositories/Traits/TranslationsTrait.php index 0edf79866..36595a1bb 100755 --- a/src/Repositories/Traits/TranslationsTrait.php +++ b/src/Repositories/Traits/TranslationsTrait.php @@ -78,7 +78,7 @@ public function prepareFieldsBeforeSaveTranslationsTrait($object, $fields) $activeField = $shouldPublishFirstLanguage || (isset($submittedLanguage) ? $submittedLanguage['published'] : false); $fields[$locale] = [ - 'active' => $activeField, + 'active' => (int) $activeField, ] + $attributes->mapWithKeys(function ($attribute) use (&$fields, $locale, $localesCount, $index, $translationsFields) { $attributeValue = $fields[$attribute] ?? $translationsFields[$attribute] ?? null; @@ -205,6 +205,36 @@ public function orderTranslationsTrait($query, &$orders) } } + /** + * After save, re-enable timestamps and touch the parent model + * only when a translation row really changed (ignoring auto-timestamp + * columns that the translation table may carry). + * + * @param \Illuminate\Database\Eloquent\Model $object + * @param array $fields + * @return void + */ + public function afterSaveTranslationsTrait($object, $fields) + { + if (! $this->model->isTranslatable()) { + return; + } + + if ($object->relationLoaded('translations')) { + $timestampKeys = ['updated_at', 'created_at', 'deleted_at']; + + foreach ($object->translations as $translation) { + $changedKeys = array_keys($translation->getChanges()); + $meaningfulChanges = array_diff($changedKeys, $timestampKeys); + + if (! empty($meaningfulChanges)) { + $this->letEloquentModelBeTouched(true); + break; + } + } + } + } + /** * @return array */ From 6e680afcec908c3edf73095a18031df32fd7cda4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20B=C3=BCk=C3=A7=C3=BCo=C4=9Flu?= Date: Mon, 13 Apr 2026 17:22:12 +0300 Subject: [PATCH 052/163] feat(Revision Management): implement revision approval and rejection workflow - Added support for approving and rejecting revisions, including new endpoints and permissions. - Introduced `approveRevision` and `rejectRevision` methods in the relevant controllers and traits. - Enhanced the `Revision` model to include status tracking and approval metadata. - Updated language files for user-facing messages related to revision actions. - Created a console command to sync permissions for revision-related routes. - Refactored existing repository and trait methods to accommodate the new revision workflow. - Improved UI components to handle revision approval and rejection actions seamlessly. --- lang/en/messages.php | 45 ++ lang/tr/messages.php | 33 ++ .../Sync/SyncRevisionPermissionsCommand.php | 112 ++++ src/Entities/Enums/Permission.php | 3 + src/Entities/Enums/RevisionStatus.php | 15 + src/Entities/Filepond.php | 6 + src/Entities/Revision.php | 40 +- src/Entities/Traits/HasRevisions.php | 151 ++++- src/Helpers/migrations_helpers.php | 9 + src/Helpers/module.php | 2 +- src/Http/Controllers/Traits/ManagePreview.php | 87 ++- src/Hydrates/Inputs/RevisionHydrate.php | 38 +- src/Module.php | 2 + src/Providers/RouteServiceProvider.php | 6 +- src/Repositories/Logic/MethodTransformers.php | 21 + src/Repositories/Logic/Relationships.php | 6 + src/Repositories/Repository.php | 4 +- .../InteractsWithAttachmentPayloads.php | 343 ++++++++++++ src/Repositories/Traits/FilepondsTrait.php | 189 ++++++- src/Repositories/Traits/FilesTrait.php | 279 +++++++--- src/Repositories/Traits/ImagesTrait.php | 224 ++++++-- src/Repositories/Traits/PaymentTrait.php | 6 + src/Repositories/Traits/PricesTrait.php | 6 + src/Repositories/Traits/RepeatersTrait.php | 98 ++++ src/Repositories/Traits/RevisionsTrait.php | 526 +++++++++++++++++- src/Repositories/Traits/SlugsTrait.php | 60 +- src/Repositories/Traits/SpreadableTrait.php | 4 + src/Repositories/Traits/TagsTrait.php | 6 + src/Repositories/Traits/TranslationsTrait.php | 31 +- vue/src/js/components/form/Revision.vue | 81 --- vue/src/js/components/form/RevisionsList.vue | 240 -------- .../others/RevisionConfirmDialog.vue | 65 +++ vue/src/js/components/others/RevisionItem.vue | 177 ++++++ .../others/RevisionPreviewDialog.vue | 266 +++++++++ .../others/RevisionPreviewMainPanels.vue | 235 ++++++++ .../others/RevisionPreviewSidebar.vue | 170 ++++++ vue/src/js/hooks/revision/index.js | 3 + vue/src/js/hooks/revision/useRevisionDiff.js | 162 ++++++ .../revision/useRevisionVisualCompare.js | 111 ++++ vue/src/js/hooks/useJsonDiff.js | 76 +++ 40 files changed, 3435 insertions(+), 503 deletions(-) create mode 100644 src/Console/Sync/SyncRevisionPermissionsCommand.php create mode 100644 src/Entities/Enums/RevisionStatus.php create mode 100644 src/Repositories/Traits/Concerns/InteractsWithAttachmentPayloads.php delete mode 100644 vue/src/js/components/form/Revision.vue delete mode 100644 vue/src/js/components/form/RevisionsList.vue create mode 100644 vue/src/js/components/others/RevisionConfirmDialog.vue create mode 100644 vue/src/js/components/others/RevisionItem.vue create mode 100644 vue/src/js/components/others/RevisionPreviewDialog.vue create mode 100644 vue/src/js/components/others/RevisionPreviewMainPanels.vue create mode 100644 vue/src/js/components/others/RevisionPreviewSidebar.vue create mode 100644 vue/src/js/hooks/revision/index.js create mode 100644 vue/src/js/hooks/revision/useRevisionDiff.js create mode 100644 vue/src/js/hooks/revision/useRevisionVisualCompare.js create mode 100644 vue/src/js/hooks/useJsonDiff.js diff --git a/lang/en/messages.php b/lang/en/messages.php index d2aefc975..065add1c3 100755 --- a/lang/en/messages.php +++ b/lang/en/messages.php @@ -10,6 +10,51 @@ 'invalid-company' => 'Company fields must be filled!', 'password-saved' => 'Password saved successfully!', 'profile-update-success' => 'Your profile was successfully updated!', + + 'revision' => [ + 'source-date-tooltip' => 'This is the date of the revision that was restored from (snapshot before this version).', + 'restore-disabled-already-restored' => 'This version was already created by restoring an older revision; it cannot be restored again.', + 'preview-tab' => 'Preview', + 'diff-tab' => 'Diff', + 'diff-compare-with' => 'Compare with (older)', + 'diff-no-older' => 'There is no older revision to compare this snapshot against.', + 'diff-load-error' => 'Could not load revision data for diff.', + 'diff-from-to' => 'Changes from older snapshot to the one you opened', + 'compare-tab' => 'Compare', + 'compare-older' => 'Older (left)', + 'compare-newer' => 'Newer (right)', + 'compare-load-error' => 'Could not load one or both HTML previews for compare.', + 'compare-hint' => 'Two live previews side by side — same baseline as text diff.', + 'preview-sidebar-title' => 'Versions', + 'preview-sidebar-current' => 'You are viewing this version', + 'pending-locks-record' => 'This record has a revision pending approval. Save and restore are disabled until it is resolved.', + 'restore-blocked-pending' => 'Cannot restore a revision while another revision is pending approval.', + 'restore-blocked-rejected' => 'A rejected revision cannot be restored.', + 'restore-forbidden' => 'You are not allowed to restore revisions for this module.', + 'approve-not-applicable' => 'Revision approval workflow is not enabled for this model.', + 'approve-not-pending' => 'Only a pending revision can be approved.', + 'approve-not-current-pending' => 'The selected revision is not the current pending revision.', + 'approve-not-latest' => 'Only the latest revision can be pending or approved; refresh and try again.', + 'pending-only-one' => 'A pending revision already exists (only the latest revision may be pending).', + 'approved-success' => 'The pending revision was approved and applied.', + 'approve-action' => 'Approve', + 'approve-failed' => 'Could not approve the revision.', + 'reject-not-applicable' => 'Revision rejection is not enabled for this model.', + 'reject-not-latest' => 'Only the latest revision can be rejected.', + 'reject-not-pending' => 'Only a pending revision can be rejected.', + 'rejected-success' => 'The pending revision was rejected. Live content was not changed.', + 'reject-failed' => 'Could not reject the revision.', + 'reject-action' => 'Reject', + 'approve-confirm-title' => 'Approve this revision?', + 'approve-confirm-body' => 'The pending changes will be applied to the live record. This action cannot be undone from here.', + 'restore-confirm-title' => 'Restore this revision?', + 'restore-confirm-body' => 'The record will be updated to match this snapshot. A new revision entry may be created depending on your workflow.', + 'reject-confirm-title' => 'Reject this revision?', + 'reject-confirm-body' => 'The pending proposal will be discarded. Published content will stay as it is.', + 'status-pending' => 'Pending approval', + 'status-rejected' => 'Rejected', + ], + 'notifications' => [ 'mark-read-success' => 'Notifications marked as read.', ], diff --git a/lang/tr/messages.php b/lang/tr/messages.php index 110b88da9..5236ec735 100755 --- a/lang/tr/messages.php +++ b/lang/tr/messages.php @@ -1,6 +1,39 @@ [ + 'source-date-tooltip' => 'Bu tarih, geri yüklemenin yapıldığı önceki revizyonun tarihidir (bu sürümden önceki anlık görüntü).', + 'restore-blocked-pending' => 'Başka bir revizyon onay beklerken eski bir sürüme geri dönülemez.', + 'restore-blocked-rejected' => 'Reddedilmiş bir revizyondan geri yükleme yapılamaz.', + 'restore-disabled-already-restored' => 'Bu sürüm zaten eski bir revizyondan geri yükleme ile oluşturuldu; tekrar geri yüklenemez.', + 'preview-tab' => 'Önizleme', + 'diff-tab' => 'Fark', + 'diff-compare-with' => 'Karşılaştır (daha eski)', + 'diff-no-older' => 'Bu anlık görüntüyü karşılaştırmak için daha eski bir revizyon yok.', + 'diff-load-error' => 'Fark için revizyon verileri yüklenemedi.', + 'diff-from-to' => 'Eski anlık görüntüden açtığınız sürüme yapılan değişiklikler', + 'compare-tab' => 'Karşılaştır', + 'compare-older' => 'Eski (sol)', + 'compare-newer' => 'Yeni (sağ)', + 'compare-load-error' => 'Karşılaştırma için HTML önizlemeleri yüklenemedi.', + 'compare-hint' => 'İki canlı önizleme yan yana — metin farkı ile aynı baz.', + 'preview-sidebar-title' => 'Sürümler', + 'preview-sidebar-current' => 'Şu an bu sürümü görüntülüyorsunuz', + 'status-pending' => 'Onay bekliyor', + 'status-rejected' => 'Reddedildi', + 'approve-confirm-title' => 'Bu revizyonu onaylamak istiyor musunuz?', + 'approve-confirm-body' => 'Bekleyen değişiklikler canlı kayda uygulanacaktır. Bu işlem buradan geri alınamaz.', + 'restore-confirm-title' => 'Bu revizyona geri dönmek istiyor musunuz?', + 'restore-confirm-body' => 'Kayıt bu anlık görüntüye göre güncellenecektir. İş akışınıza bağlı olarak yeni bir revizyon satırı oluşabilir.', + 'reject-not-applicable' => 'Bu model için revizyon reddi etkin değil.', + 'reject-not-latest' => 'Yalnızca en son revizyon reddedilebilir.', + 'reject-not-pending' => 'Yalnızca onay bekleyen bir revizyon reddedilebilir.', + 'rejected-success' => 'Bekleyen revizyon reddedildi. Yayınlanan içerik değiştirilmedi.', + 'reject-failed' => 'Revizyon reddedilemedi.', + 'reject-action' => 'Reddet', + 'reject-confirm-title' => 'Bu revizyonu reddetmek istiyor musunuz?', + 'reject-confirm-body' => 'Bekleyen öneri iptal edilecektir. Yayında olan içerik aynı kalacaktır.', + ], 'assignment' => [ 'task-to-assignee-by-assigner' => 'İlgili kullanıcı: {assigneeName} — Oluşturan: {assignerName}', ], diff --git a/src/Console/Sync/SyncRevisionPermissionsCommand.php b/src/Console/Sync/SyncRevisionPermissionsCommand.php new file mode 100644 index 000000000..2527ed4bb --- /dev/null +++ b/src/Console/Sync/SyncRevisionPermissionsCommand.php @@ -0,0 +1,112 @@ +warn('No models using HasRevisions were found.'); + + return 0; + } + + // $guard = config('auth.defaults.guard', 'web'); + $guard = Modularity::getAuthGuardName(); + + $suffixes = [ + PermissionEnum::REVISION_APPROVE->value, + PermissionEnum::REVISION_REJECT->value, + PermissionEnum::REVISION_RESTORE->value, + ]; + + $created = []; + + foreach ($models as $modelClass) { + $prefixes = $this->resolveRoutePrefixesForModel($modelClass); + + foreach ($prefixes as $prefix) { + foreach ($suffixes as $suffix) { + $name = "{$prefix}_{$suffix}"; + + if ($this->option('dry-run')) { + $this->line("[dry-run] {$name}"); + + continue; + } + + $permission = Permission::firstOrCreate( + ['name' => $name, 'guard_name' => $guard], + [] + ); + + if ($permission->wasRecentlyCreated) { + $created[] = $name; + } + } + } + } + + if ($this->option('dry-run')) { + return 0; + } + + foreach ($created as $name) { + $this->info("Created permission: {$name}"); + } + + $this->info('Revision permissions synced.'); + + return 0; + } + + /** + * Uses {@see HasRevisions::revisionPermissionPrefix()} when overridden on the model. + * + * @return list + */ + protected function resolveRoutePrefixesForModel(string $modelClass): array + { + $method = new ReflectionMethod($modelClass, 'revisionPermissionPrefix'); + + if ($method->getDeclaringClass()->getName() === HasRevisions::class) { + $this->warn("Skipping {$modelClass}: override protected function revisionPermissionPrefix(): ?string (kebab-case route name)."); + + return []; + } + + $method->setAccessible(true); + $instance = new $modelClass; + $prefix = $method->invoke($instance); + + if (! is_string($prefix) || $prefix === '') { + $this->warn("Skipping {$modelClass}: revisionPermissionPrefix() returned empty."); + + return []; + } + + return [$prefix]; + } +} diff --git a/src/Entities/Enums/Permission.php b/src/Entities/Enums/Permission.php index f93a97c62..6748c7f13 100755 --- a/src/Entities/Enums/Permission.php +++ b/src/Entities/Enums/Permission.php @@ -19,6 +19,9 @@ enum Permission: string case BULKDELETE = 'bulkDelete'; case BULKFORCEDELETE = 'bulkForceDelete'; case BULKRESTORE = 'bulkRestore'; + case REVISION_APPROVE = 'revisionApprove'; + case REVISION_REJECT = 'revisionReject'; + case REVISION_RESTORE = 'revisionRestore'; case ACTIVITY = 'activity'; case SHOW = 'show'; diff --git a/src/Entities/Enums/RevisionStatus.php b/src/Entities/Enums/RevisionStatus.php new file mode 100644 index 000000000..7fa1ddcc3 --- /dev/null +++ b/src/Entities/Enums/RevisionStatus.php @@ -0,0 +1,15 @@ + 'datetime', ]; public function __construct(array $attributes = []) @@ -33,6 +42,14 @@ public function user() return $this->belongsTo(User::class); } + /** + * Parent revision this row was branched from (e.g. after a restore, points at the snapshot that was applied). + */ + public function source(): BelongsTo + { + return $this->belongsTo(static::class, 'source_id'); + } + public function getByUserAttribute() { return isset($this->user) ? $this->user->name : 'System'; @@ -46,4 +63,25 @@ public function isDraft(): bool return Str::startsWith($cmsSaveType, 'draft-revision'); } + + public function isPending(): bool + { + $status = $this->status ?? RevisionStatus::Approved->value; + + return $status === RevisionStatus::Pending->value; + } + + public function isApproved(): bool + { + $status = $this->status ?? RevisionStatus::Approved->value; + + return $status === RevisionStatus::Approved->value; + } + + public function isRejected(): bool + { + $status = $this->status ?? RevisionStatus::Approved->value; + + return $status === RevisionStatus::Rejected->value; + } } diff --git a/src/Entities/Traits/HasRevisions.php b/src/Entities/Traits/HasRevisions.php index 3132ac30c..01fc6b9a7 100755 --- a/src/Entities/Traits/HasRevisions.php +++ b/src/Entities/Traits/HasRevisions.php @@ -2,12 +2,44 @@ namespace Unusualify\Modularity\Entities\Traits; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\Schema; use RuntimeException; +use Unusualify\Modularity\Entities\Enums\RevisionStatus; use Unusualify\Modularity\Facades\Modularity; +use Unusualify\Modularity\Module; +use Unusualify\Modularity\Entities\Enums\Permission; trait HasRevisions { + + /** + * Override and return true together with {@see revisionPermissionPrefix()} to enable approval workflow. + * This property is used to check if the revision workflow is enabled for the model. + * Do not use a model property named revisionWorkflowEnabled — Eloquent resolves it as this method (relationship). + */ + protected function revisionWorkflowEnabled(): bool + { + return $this->isRevisionWorkflowEnabled ?? false; + } + + /** + * Kebab-case route segment for permissions, e.g. "page" → "page_revision_approve". + * Override in the composed model; do not redeclare as a property. + */ + protected function revisionPermissionPrefix(): ?string + { + if(method_exists($this, 'getModule') && ($module = $this->getModule()) instanceof Module) { + $routeName = $this->getRouteName(); + + return snakeCase($routeName); + } + + return null; + } + /** * Defines the one-to-many relationship for revisions. * @@ -15,7 +47,116 @@ trait HasRevisions */ public function revisions() { - return $this->hasMany($this->getRevisionModel())->orderBy('created_at', 'desc'); + return $this->hasMany($this->getRevisionModel()) + ->orderBy('created_at', 'desc') + ->with(['user', 'source']); + } + + /** + * Latest revision row by id (the only row that may be {@see RevisionStatus::Pending} when workflow is on). + */ + public function latestRevision(): HasOne + { + return $this->hasOne($this->getRevisionModel())->latestOfMany('id'); + } + + public function usesRevisionWorkflow(): bool + { + return $this->revisionWorkflowEnabled() === true + && is_string($this->revisionPermissionPrefix()) + && $this->revisionPermissionPrefix() !== ''; + } + + /** + * Id of the current pending revision when the newest revision row has status pending; otherwise null. + */ + public function getPendingRevisionId(): ?int + { + $revisionModel = $this->getRevisionModel(); + $instance = new $revisionModel; + + if (! Schema::hasColumn($instance->getTable(), 'status')) { + return null; + } + + $latest = $this->revisions()->orderByDesc('id')->first(); + + if (! $latest || ($latest->status ?? RevisionStatus::Approved->value) !== RevisionStatus::Pending->value) { + return null; + } + + return (int) $latest->id; + } + + /** + * True when the newest revision row is pending. That state locks update and restore. + */ + public function isRevisionWorkflowLocked(): bool + { + if (! $this->usesRevisionWorkflow()) { + return false; + } + + return $this->latestRevisionIsPending(); + } + + /** + * @deprecated Use {@see isRevisionWorkflowLocked()} for workflow models. + */ + public function hasPendingRevision(): bool + { + return $this->isRevisionWorkflowLocked(); + } + + protected function latestRevisionIsPending(): bool + { + $revisionModel = $this->getRevisionModel(); + $instance = new $revisionModel; + + if (! Schema::hasColumn($instance->getTable(), 'status')) { + return false; + } + + $latest = $this->revisions()->orderByDesc('id')->first(); + + if (! $latest) { + return false; + } + + return ($latest->status ?? RevisionStatus::Approved->value) === RevisionStatus::Pending->value; + } + + public function userCanApproveRevisions(): bool + { + if (! $this->usesRevisionWorkflow()) { + return true; + } + + $user = Auth::guard(Modularity::getAuthGuardName())->user(); + + return $user && Gate::forUser($user)->allows(Permission::generatePermissionName('REVISION_APPROVE', $this->revisionPermissionPrefix())); + } + + public function userCanRejectRevisions(): bool + { + if (! $this->usesRevisionWorkflow()) { + return true; + } + + $user = Auth::guard(Modularity::getAuthGuardName())->user(); + + return $user && Gate::forUser($user)->allows(Permission::generatePermissionName('REVISION_REJECT', $this->revisionPermissionPrefix())); + } + + public function userCanRestoreRevisions(): bool + { + if (! $this->usesRevisionWorkflow()) { + return true; + } + + $user = Auth::guard(Modularity::getAuthGuardName())->user(); + + return $user && Gate::forUser($user)->allows(Permission::generatePermissionName('REVISION_RESTORE', $this->revisionPermissionPrefix())); } /** @@ -53,8 +194,8 @@ public function revisionsArray() return $revisions ->map(function ($revision, $index) use ($total, $versionMap) { - $sourceLabel = $revision->source_revision_id && isset($versionMap[$revision->source_revision_id]) - ? 'V' . $versionMap[$revision->source_revision_id] + $sourceLabel = $revision->source_id && isset($versionMap[$revision->source_id]) + ? 'V' . $versionMap[$revision->source_id] : null; return [ @@ -63,6 +204,9 @@ public function revisionsArray() 'datetime' => $revision->created_at->toIso8601String(), 'label' => 'V' . ($total - $index), 'source_label' => $sourceLabel, + 'is_restored' => (bool) $revision->source_id, + 'source_datetime' => $revision->source?->created_at?->toIso8601String(), + 'status' => $revision->status ?? 'approved', ]; }) ->toArray(); @@ -81,7 +225,6 @@ public function deleteSpecificRevisions(int $maxRevisions): void $this->revisions()->get()->slice($maxRevisions)->each->delete(); } - public function getRevisionModel() { if (property_exists($this, 'revisionModel') && is_string($this->revisionModel) && @class_exists($this->revisionModel)) { diff --git a/src/Helpers/migrations_helpers.php b/src/Helpers/migrations_helpers.php index 94015ddaa..e961df2c0 100755 --- a/src/Helpers/migrations_helpers.php +++ b/src/Helpers/migrations_helpers.php @@ -234,6 +234,9 @@ function createDefaultMorphPivotTableFields($table, $modelName = null, $tableNam if (! function_exists('createDefaultRevisionsTableFields')) { /** + * Standard revision table: payload, optional lineage (source_id), and workflow columns (status, approved_at, approved_by). + * Pending state is represented by the latest row’s status only — no column on the subject model. + * * @param Blueprint $table * @param string $tableNameSingular * @param string|null $tableNamePlural @@ -248,10 +251,16 @@ function createDefaultRevisionsTableFields($table, $tableNameSingular, $tableNam $table->{modularityIncrementsMethod()}('id'); $table->{modularityIntegerMethod()}("{$tableNameSingular}_id")->unsigned(); $table->{modularityIntegerMethod()}('user_id')->unsigned()->nullable(); + $table->unsignedBigInteger('source_id')->nullable(); + + $table->string('status', 32)->default('approved'); + $table->timestamp('approved_at')->nullable(); + $table->{modularityIntegerMethod()}('approved_by')->unsigned()->nullable(); $table->timestamps(); $table->json('payload'); $table->foreign("{$tableNameSingular}_id")->references('id')->on("{$tableNamePlural}")->onDelete('cascade'); $table->foreign('user_id')->references('id')->on(modularityConfig('tables.users', 'um_users'))->onDelete('set null'); + $table->foreign('approved_by')->references('id')->on(modularityConfig('tables.users', 'um_users'))->onDelete('set null'); } } diff --git a/src/Helpers/module.php b/src/Helpers/module.php index 9f6f2d08e..69ff1a30f 100755 --- a/src/Helpers/module.php +++ b/src/Helpers/module.php @@ -195,7 +195,7 @@ function moduleRoute($moduleName, $prefix, $action = '', $parameters = [], $abso $routeName .= "{$snakeName}"; } // dd($snakeName, $parameters); - if (preg_match('/edit|show|update|destroy|duplicate|restoreRevision|preview/', $action) && ! array_key_exists($snakeName, $parameters) && ! $singleton) { + if (preg_match('/edit|show|update|destroy|duplicate|restoreRevision|approveRevision|rejectRevision|preview/', $action) && ! array_key_exists($snakeName, $parameters) && ! $singleton) { $parameters[$snakeName] = ':id'; // dd( // $routeName, diff --git a/src/Http/Controllers/Traits/ManagePreview.php b/src/Http/Controllers/Traits/ManagePreview.php index 1c6d6b9f3..832489145 100644 --- a/src/Http/Controllers/Traits/ManagePreview.php +++ b/src/Http/Controllers/Traits/ManagePreview.php @@ -2,6 +2,7 @@ namespace Unusualify\Modularity\Http\Controllers\Traits; +use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Response; use Illuminate\Support\Facades\View; @@ -10,6 +11,25 @@ trait ManagePreview { + protected function addMiddlewarePermissionsManagePreview() + { + if($this->module && $this->routeHasTrait('revisions')) { + $permissions = [ + 'REVISION_RESTORE' => [ 'only' => ['restoreRevision']], + 'REVISION_APPROVE' => [ 'only' => ['approveRevision']], + 'REVISION_REJECT' => [ 'only' => ['rejectRevision']], + ]; + + foreach ($permissions as $permission => $options) { + $this->setMiddlewarePermission($permission, $options); + } + } + } + public function previewData($item) + { + return []; + } + public function showView($id) { // dd($id); @@ -22,17 +42,13 @@ public function showView($id) } if ($this->request->has('activeLanguage')) { - //App::setLocale($this->request->get('activeLanguage')); + App::setLocale($this->request->get('activeLanguage')); } - // dd($this->previewView); - - $previewView = $this->previewView ?? (Config::get('twill.frontend.views_path', 'site') . '.' . Str::singular( + $previewView = $this->previewView ?? (Config::get('modularity.frontend.views_path', 'site') . '.' . Str::singular( $this->moduleName )); - // dd($previewView); - return View::exists($previewView) ? View::make( $previewView, array_replace([ @@ -45,13 +61,17 @@ public function showView($id) public function listRevisions($id) { - $revisions = $this->repository->getRevisions($id); - return $revisions; + if(! $this->routeHasTrait('revisions')) { + return $this->respondWithError(__('Revisions are not enabled for this route.')); + } + + $object = $this->repository->getModel()->newQuery()->findOrFail($id); + + return $object->revisionsArray(); } public function restoreRevision($id) { - // dd('restoreRevision'); if (! $this->routeHasTrait('revisions')) { return $this->respondWithError(__('Revisions are not enabled for this route.')); } @@ -74,7 +94,6 @@ public function restoreRevision($id) ]); } - $item = $this->repository->restoreRevision((int) $id, $revisionId); // dd($item); @@ -86,4 +105,52 @@ public function restoreRevision($id) ]); } + public function approveRevision($id) + { + if (! $this->routeHasTrait('revisions')) { + return $this->respondWithError(__('Revisions are not enabled for this route.')); + } + + $params = $this->request->route()->parameters(); + $id = last($params); + $revisionId = (int) $this->request->get('revisionId'); + + if ($revisionId < 1) { + return $this->respondWithError(__('Revision id is required.')); + } + + $item = $this->repository->approveRevision((int) $id, $revisionId); + + return Response::json([ + 'message' => __('messages.revision.approved-success'), + 'variant' => MessageStage::SUCCESS, + 'revisions' => $item->revisionsArray(), + 'form_fields' => $this->repository->getFormFields($item, $this->getPreviousRouteSchema()), + ]); + } + + public function rejectRevision($id) + { + if (! $this->routeHasTrait('revisions')) { + return $this->respondWithError(__('Revisions are not enabled for this route.')); + } + + $params = $this->request->route()->parameters(); + $id = last($params); + $revisionId = (int) $this->request->get('revisionId'); + + if ($revisionId < 1) { + return $this->respondWithError(__('Revision id is required.')); + } + + $item = $this->repository->rejectRevision((int) $id, $revisionId); + + return Response::json([ + 'message' => __('messages.revision.rejected-success'), + 'variant' => MessageStage::SUCCESS, + 'revisions' => $item->revisionsArray(), + 'form_fields' => $this->repository->getFormFields($item, $this->getPreviousRouteSchema()), + ]); + } + } diff --git a/src/Hydrates/Inputs/RevisionHydrate.php b/src/Hydrates/Inputs/RevisionHydrate.php index 6189401b1..4030ed13b 100644 --- a/src/Hydrates/Inputs/RevisionHydrate.php +++ b/src/Hydrates/Inputs/RevisionHydrate.php @@ -2,6 +2,8 @@ namespace Unusualify\Modularity\Hydrates\Inputs; +use Unusualify\Modularity\Entities\Enums\Permission; + class RevisionHydrate extends InputHydrate { /** @@ -11,10 +13,12 @@ class RevisionHydrate extends InputHydrate * @var array */ public $requirements = [ - 'name' => 'revision_id', + 'name' => 'revisionable_id', 'noSubmit' => true, 'col' => ['cols' => 12], 'default' => null, + /** Max height of the scrollable revision list (CSS length, e.g. 320px). */ + 'maxHeight' => '320px', ]; /** @@ -27,8 +31,6 @@ public function hydrate() $input = $this->input; $input['type'] = 'input-revision'; - $input['name'] = 'revisionable_id'; - $snakeRouteName = snakeCase($this->routeName); @@ -38,19 +40,43 @@ public function hydrate() [$snakeRouteName => ':id'] ); - $input['showViewEndpoint'] = $this->module->getRouteActionUrl( + $input['approveEndpoint'] = $this->module->getRouteActionUrl( + $this->routeName, + 'approveRevision', + [$snakeRouteName => ':id'] + ); + + $input['rejectEndpoint'] = $this->module->getRouteActionUrl( + $this->routeName, + 'rejectRevision', + [$snakeRouteName => ':id'] + ); + + $input['showEndpoint'] = $this->module->getRouteActionUrl( $this->routeName, 'showView', [$snakeRouteName => ':id'] ); - $input['listRevisionsEndpoint'] = $this->module->getRouteActionUrl( + $input['fetchEndpoint'] = $this->module->getRouteActionUrl( $this->routeName, 'listRevisions', [$snakeRouteName => ':id'] ); - dd($input); + $canApprove = false; + $canReject = false; + $canRestore = false; + + if($this->module && $this->module->getRepository($this->routeName)->hasBehavior('revisions')) { + $canApprove = $this->module->getModel($this->routeName)->usesRevisionWorkflow() && $this->module->allowedPermission(Permission::REVISION_APPROVE->value, $this->routeName); + $canReject = $this->module->getModel($this->routeName)->usesRevisionWorkflow() && $this->module->allowedPermission(Permission::REVISION_REJECT->value, $this->routeName); + $canRestore = $this->module->allowedPermission(Permission::REVISION_RESTORE->value, $this->routeName); + } + + $input['canApprove'] = $canApprove; + $input['canReject'] = $canReject; + $input['canRestore'] = $canRestore; return $input; } diff --git a/src/Module.php b/src/Module.php index 88c576809..e1d749849 100755 --- a/src/Module.php +++ b/src/Module.php @@ -58,6 +58,8 @@ class Module extends NwidartModule 'assignments', 'createAssignment', 'restoreRevision', + 'approveRevision', + 'rejectRevision', 'showView', 'listRevisions', ]; diff --git a/src/Providers/RouteServiceProvider.php b/src/Providers/RouteServiceProvider.php index eae4bb3b1..78f3a810c 100755 --- a/src/Providers/RouteServiceProvider.php +++ b/src/Providers/RouteServiceProvider.php @@ -364,6 +364,8 @@ protected function bootMacros() 'showView', 'listRevisions', 'restoreRevision', + 'approveRevision', + 'rejectRevision', 'restore', 'bulkRestore', @@ -432,11 +434,11 @@ protected function bootMacros() Route::put($routeSlug, $mapping); } - if (in_array($customRoute, ['duplicate', 'preview', 'showView','restoreRevision'])) { + if (in_array($customRoute, ['duplicate', 'preview', 'showView','restoreRevision', 'approveRevision', 'rejectRevision'])) { Route::put($routeSlug . "/{{$snakeCase}}", $mapping); } - if (in_array($customRoute, ['preview', 'showView', 'restoreRevision'])) { + if (in_array($customRoute, ['preview', 'showView', 'restoreRevision', 'approveRevision', 'rejectRevision'])) { // dd($customRoute, $routeSlug, $routeSlug . "/{{$snakeCase}}", $mapping); Route::put($routeSlug . "/{{$snakeCase}}", $mapping); } diff --git a/src/Repositories/Logic/MethodTransformers.php b/src/Repositories/Logic/MethodTransformers.php index 34595ef61..cc27758b6 100644 --- a/src/Repositories/Logic/MethodTransformers.php +++ b/src/Repositories/Logic/MethodTransformers.php @@ -213,10 +213,31 @@ public function beforeSave($object, $fields) public function afterSave($object, $fields) { foreach ($this->traitsMethods(__FUNCTION__) as $method) { + if ($this->shouldBypassAfterSaveHook($method)) { + continue; + } + $this->$method($object, $fields); } } + /** + * When RevisionsTrait::bypassAfterSaves sets passAfterSave* (after opt-in via each trait’s + * pendingBypassRevision*), that hook is skipped. Naming: afterSaveFooTrait → passAfterSaveFooTrait. + * + * @see traitsMethods() + */ + protected function shouldBypassAfterSaveHook(string $method): bool + { + if (! str_starts_with($method, 'afterSave')) { + return false; + } + + $passProperty = preg_replace('/^after/', 'passAfter', $method); + + return property_exists($this, $passProperty) && $this->{$passProperty} === true; + } + /** * @param Model $object * @return void diff --git a/src/Repositories/Logic/Relationships.php b/src/Repositories/Logic/Relationships.php index 79fdcbe6f..cd5662ef4 100755 --- a/src/Repositories/Logic/Relationships.php +++ b/src/Repositories/Logic/Relationships.php @@ -17,6 +17,12 @@ trait Relationships use CheckSnapshot, ResolveConnector; + /** + * When true, {@see \Unusualify\Modularity\Repositories\Traits\RevisionsTrait::bypassAfterSaves} may set + * `passAfterSaveRelationships` during pending-only revision saves so {@see afterSaveRelationships} is skipped. + */ + protected bool $pendingBypassRevisionRelationships = true; + public $exceptRelations = []; /** diff --git a/src/Repositories/Repository.php b/src/Repositories/Repository.php index b6e1a7df9..7cb9227b9 100755 --- a/src/Repositories/Repository.php +++ b/src/Repositories/Repository.php @@ -15,11 +15,13 @@ use Unusualify\Modularity\Contracts\ModuleableInterface; use Unusualify\Modularity\Models\Model; use Unusualify\Modularity\Repositories\Contracts\Repository as RepositoryContract; +use Unusualify\Modularity\Repositories\Traits\Concerns\InteractsWithAttachmentPayloads; use Unusualify\Modularity\Traits\ManageNames; abstract class Repository implements CacheableInterface, ModuleableInterface, RepositoryContract, UserAwareCacheInterface { - use ManageNames, + use InteractsWithAttachmentPayloads, + ManageNames, Logic\InspectTraits, Logic\RelationshipHelpers, Logic\MethodTransformers, diff --git a/src/Repositories/Traits/Concerns/InteractsWithAttachmentPayloads.php b/src/Repositories/Traits/Concerns/InteractsWithAttachmentPayloads.php new file mode 100644 index 000000000..e9c7d67c7 --- /dev/null +++ b/src/Repositories/Traits/Concerns/InteractsWithAttachmentPayloads.php @@ -0,0 +1,343 @@ + + */ + protected function resolveAttachmentRoles(string $traitFqcn, string $chunkInputTypeRegex, array $fields, callable $inferRoleFromKeyValue): array + { + $fromTrait = $this->getColumns($traitFqcn); + + $fromChunk = collect($this->chunkInputs(all: true)) + ->filter(fn ($input) => isset($input['type']) && preg_match($chunkInputTypeRegex, $input['type'])) + ->pluck('name') + ->all(); + + $fromFields = []; + foreach ($fields as $key => $value) { + if ($this->reservedAttachmentFieldKey($key)) { + continue; + } + + if (in_array($key, getLocales(), true) && is_array($value)) { + foreach ($value as $subKey => $subVal) { + if ($subKey === 'active' || $this->reservedAttachmentFieldKey((string) $subKey)) { + continue; + } + if ($inferRoleFromKeyValue((string) $subKey, $subVal)) { + $fromFields[] = (string) $subKey; + } + } + + continue; + } + + if ($inferRoleFromKeyValue($key, $value)) { + $fromFields[] = $key; + } + } + + return collect($fromTrait) + ->merge($fromChunk) + ->merge($fromFields) + ->unique() + ->values() + ->all(); + } + + protected function reservedAttachmentFieldKey(string $key): bool + { + return in_array($key, [ + 'translations', + 'translationLanguages', + '_token', + '_method', + 'revisionId', + 'activeLanguage', + 'preview', + ], true); + } + + /** + * Role appears in the incoming payload (top-level or under a locale bucket). + */ + protected function attachmentRoleIsPresentInFields(array $fields, string $role): bool + { + if (array_key_exists($role, $fields)) { + return true; + } + + foreach (getLocales() as $locale) { + if (array_key_exists($role, $fields[$locale] ?? [])) { + return true; + } + } + + return false; + } + + /** + * @return array|list|null + */ + protected function getAttachmentPayloadForRole(array $fields, string $role): mixed + { + if (array_key_exists($role, $fields)) { + return $fields[$role]; + } + + $nested = []; + foreach (getLocales() as $locale) { + if (array_key_exists($role, $fields[$locale] ?? [])) { + $nested[$locale] = $fields[$locale][$role]; + } + } + + return $nested === [] ? null : $nested; + } + + /** + * Payload uses locale keys (translated image/file field). + */ + protected function isLocaleKeyedAttachmentPayload(mixed $payload): bool + { + if (! is_array($payload)) { + return false; + } + + foreach (array_keys($payload) as $key) { + if (in_array($key, getLocales(), true)) { + return true; + } + } + + return false; + } + + protected function isAttachmentRoleTranslatedInSchema(string $role): bool + { + $chunked = $this->chunkInputs(all: true); + + return (bool) ($chunked[$role]['translated'] ?? false); + } + + /** + * Translated vs locale-keyed payload when schema is missing (e.g. revision JSON only). + * + * @param array $fields + */ + protected function isAttachmentRoleTranslatedForFields(array $fields, string $role): bool + { + if ($this->isAttachmentRoleTranslatedInSchema($role)) { + return true; + } + + $payload = $this->getAttachmentPayloadForRole($fields, $role); + if (! is_array($payload)) { + return false; + } + + return $this->isLocaleKeyedAttachmentPayload($payload); + } + + /** + * File / image field payload: either locale => rows or a list of rows with id. + */ + protected function valueLooksLikeMorphAttachmentPayload(mixed $value): bool + { + if (! is_array($value)) { + return false; + } + + if ($this->isLocaleKeyedAttachmentPayload($value)) { + return true; + } + + foreach ($value as $item) { + if (is_array($item) && isset($item['id'])) { + return true; + } + } + + return false; + } + + /** + * Media library row (image) — distinguish from file-library rows which also use numeric id. + */ + protected function arrayLooksLikeMediaLibraryItem(array $item): bool + { + if (isset($item['thumbnail']) || isset($item['medium'])) { + return true; + } + + $meta = $item['metadatas'] ?? null; + if (is_array($meta)) { + $def = $meta['default'] ?? null; + if (is_array($def) && (array_key_exists('altText', $def) || array_key_exists('video', $def))) { + return true; + } + } + + return false; + } + + /** + * Image / media-library payload for inferring roles from raw request data. + */ + protected function valueLooksLikeImageRolePayload(mixed $value): bool + { + if (! is_array($value)) { + return false; + } + + if ($this->isLocaleKeyedAttachmentPayload($value)) { + foreach (getLocales() as $loc) { + if (! array_key_exists($loc, $value)) { + continue; + } + $slice = $value[$loc]; + if ($slice === null || ! is_array($slice)) { + continue; + } + foreach ($slice as $row) { + if (is_array($row) && $this->arrayLooksLikeMediaLibraryItem($row)) { + return true; + } + } + } + + return false; + } + + foreach ($value as $item) { + if (is_array($item) && $this->arrayLooksLikeMediaLibraryItem($item)) { + return true; + } + } + + return false; + } + + /** + * File-library payload (excludes media-library / image rows). + */ + protected function valueLooksLikeFileRolePayload(mixed $value): bool + { + if (! is_array($value)) { + return false; + } + + if ($this->isLocaleKeyedAttachmentPayload($value)) { + foreach (getLocales() as $loc) { + if (! array_key_exists($loc, $value)) { + continue; + } + $slice = $value[$loc]; + if ($slice === null || $slice === []) { + return true; + } + if (! is_array($slice)) { + continue; + } + foreach ($slice as $row) { + if (is_array($row) && isset($row['id']) && ! $this->arrayLooksLikeMediaLibraryItem($row)) { + return true; + } + } + } + + return false; + } + + foreach ($value as $item) { + if (! is_array($item) || ! isset($item['id'])) { + continue; + } + if ($this->arrayLooksLikeMediaLibraryItem($item)) { + continue; + } + + return true; + } + + return false; + } + + /** + * FilePond field payload: list of rows with a `uuid` (temp folder or persisted row id path). + */ + protected function valueLooksLikeFilepondRolePayload(mixed $value): bool + { + if (! is_array($value)) { + return false; + } + + if ($this->isLocaleKeyedAttachmentPayload($value)) { + foreach (getLocales() as $loc) { + if (! array_key_exists($loc, $value)) { + continue; + } + $slice = $value[$loc]; + if ($slice === null || $slice === [] || ! is_array($slice)) { + continue; + } + foreach ($slice as $row) { + if (is_array($row) && isset($row['uuid'])) { + return true; + } + } + } + + return false; + } + + foreach ($value as $item) { + if (is_array($item) && isset($item['uuid'])) { + return true; + } + } + + return false; + } + + /** + * Chunked input is a media-library image field (not file-library). + */ + protected function isImageLibraryInputRole(string $role): bool + { + $input = $this->chunkInputs(all: true)[$role] ?? null; + if (! is_array($input) || ! isset($input['type'])) { + return false; + } + + return preg_match('/\bimage\b/i', (string) $input['type']) === 1; + } + + /** + * Exclude from {@see \Unusualify\Modularity\Repositories\Traits\FilesTrait} so media IDs are not written as {@code file_id}. + * + * @param array $fields + */ + protected function shouldExcludeRoleFromFileTrait(string $role, array $fields): bool + { + if ($this->isImageLibraryInputRole($role)) { + return true; + } + + $payload = $this->getAttachmentPayloadForRole($fields, $role); + if (! is_array($payload)) { + return false; + } + + return $this->valueLooksLikeImageRolePayload($payload); + } +} diff --git a/src/Repositories/Traits/FilepondsTrait.php b/src/Repositories/Traits/FilepondsTrait.php index fd2a23ee9..52919c348 100644 --- a/src/Repositories/Traits/FilepondsTrait.php +++ b/src/Repositories/Traits/FilepondsTrait.php @@ -3,10 +3,22 @@ namespace Unusualify\Modularity\Repositories\Traits; use Illuminate\Support\Arr; -use Unusualify\Modularity\Facades\Filepond; +use Illuminate\Support\Collection; +use Unusualify\Modularity\Entities\Filepond as FilepondEntity; +use Unusualify\Modularity\Entities\Model; +use Unusualify\Modularity\Entities\TemporaryFilepond; +use Unusualify\Modularity\Facades\Filepond as FilepondFacade; trait FilepondsTrait { + /** + * When true, {@see RevisionsTrait::bypassAfterSaves} may set `passAfterSaveFilepondsTrait` during + * pending-only revision saves so {@see afterSaveFilepondsTrait} is skipped (live Filepond persistence deferred). + * + * Set to false on the repository to run {@see afterSaveFilepondsTrait} even while queuing a pending revision. + */ + protected bool $pendingBypassRevisionFilepondsTrait = true; + public function setColumnsFilepondsTrait($columns, $inputs) { $traitName = get_class_short_name(__TRAIT__); @@ -24,6 +36,173 @@ public function setColumnsFilepondsTrait($columns, $inputs) return $columns; } + /** + * Preview: merge persisted `fileponds` with payload (revision or form) so FilePond fields reflect pending data. + * Rows whose UUID still exists only in {@see TemporaryFilepond} (pending approval / bypassed afterSave) are + * surfaced as unsaved {@see FilepondEntity} models with `isTemporaryRevisionPreview` set. + * + * @param Model $object + * @param array $fields + * @return Model + */ + public function hydrateFilepondsTrait($object, $fields) + { + if ($this->shouldIgnoreFieldBeforeSave('fileponds')) { + return $object; + } + if (! $object->has('fileponds')) { + return $object; + } + + $object->setRelation('fileponds', $this->getPreviewFileponds($object, $fields)); + + return $object; + } + + /** + * @param Model $object + * @param array $fields + */ + private function getPreviewFileponds($object, array $fields): Collection + { + $object->loadMissing('fileponds'); + + $original = $object->fileponds; + $out = Collection::make(); + $replacedRoles = []; + + + foreach ($this->getColumns(__TRAIT__) as $column) { + if (! $this->dataHasFilepondPayloadKey($fields, $column)) { + continue; + } + + $files = data_get($fields, $column); + + if (preg_match('/\.\*\./', $column)) { + foreach ($files as $index => $nestedFiles) { + $nestedRole = preg_replace('/\.\*\./', ".$index.", $column); + $replacedRoles[$nestedRole] = true; + + if (Arr::isAssoc($nestedFiles)) { + foreach ($nestedFiles as $locale => $nestedFilesByLocale) { + if (empty($nestedFilesByLocale)) { + continue; + } + $rows = is_array($nestedFilesByLocale) ? $nestedFilesByLocale : []; + $out = $out->merge($this->mapFilepondRowsToPreviewModels($object, $rows, $nestedRole, (string) $locale, $original)); + } + } else { + if (empty($nestedFiles)) { + continue; + } + $rows = is_array($nestedFiles) ? $nestedFiles : []; + $locale = (string) config('app.locale', 'en'); + $out = $out->merge($this->mapFilepondRowsToPreviewModels($object, $rows, $nestedRole, $locale, $original)); + } + } + } else { + $role = $column; + $replacedRoles[$role] = true; + + if (Arr::isAssoc($files)) { + foreach ($files as $locale => $filesByLocale) { + if (empty($filesByLocale)) { + continue; + } + $rows = is_array($filesByLocale) ? $filesByLocale : []; + $out = $out->merge($this->mapFilepondRowsToPreviewModels($object, $rows, $role, (string) $locale, $original)); + } + } else { + $rows = is_array($files) ? $files : []; + $locale = (string) config('app.locale', 'en'); + $out = $out->merge($this->mapFilepondRowsToPreviewModels($object, $rows, $role, $locale, $original)); + } + } + } + + foreach ($original as $filepond) { + if (isset($replacedRoles[$filepond->role])) { + continue; + } + $out->push($filepond); + } + + return $out->values(); + } + + /** + * @param array $rows + */ + private function mapFilepondRowsToPreviewModels(Model $object, array $rows, string $role, string $locale, Collection $original): Collection + { + $acc = Collection::make(); + + foreach ($rows as $item) { + if (! is_array($item) || empty($item['uuid'])) { + continue; + } + + $uuid = (string) $item['uuid']; + + $existing = $original->first( + fn (FilepondEntity $f) => $f->role === $role + && (string) $f->locale === $locale + && $f->uuid === $uuid + ); + + if ($existing) { + $existing->isTemporaryRevisionPreview = false; + $acc->push($existing); + + continue; + } + + $fileName = (string) ($item['file_name'] ?? ($item['file']['name'] ?? '')); + + $preview = new FilepondEntity([ + 'uuid' => $uuid, + 'file_name' => $fileName, + 'role' => $role, + 'locale' => $locale, + 'filepondable_id' => $object->getKey(), + 'filepondable_type' => $object->getMorphClass(), + ]); + $preview->exists = false; + $preview->isTemporaryRevisionPreview = $this->filepondUuidIsTemporaryForPreview($uuid, $object, $original); + $acc->push($preview); + } + + return $acc; + } + + /** + * True when the upload is still only in {@see TemporaryFilepond} (typical when revision workflow deferred afterSave). + */ + private function filepondUuidIsTemporaryForPreview(string $uuid, Model $object, Collection $originalFileponds): bool + { + $persistedOnSubject = $originalFileponds->contains( + fn (FilepondEntity $f) => $f->uuid === $uuid + && (int) $f->filepondable_id === (int) $object->getKey() + ); + + if ($persistedOnSubject) { + return false; + } + + return TemporaryFilepond::where('folder_name', $uuid)->exists(); + } + + /** + * Same presence rules as {@see afterSaveFilepondsTrait}: allow empty list (cleared field); skip only when absent. + * + * @param array $fields + */ + private function dataHasFilepondPayloadKey(array $fields, string $column): bool + { + return data_get($fields, $column) !== null; + } + public function afterSaveFilepondsTrait($object, $fields) { $columns = $this->getColumns(__TRAIT__); @@ -44,14 +223,14 @@ public function afterSaveFilepondsTrait($object, $fields) if (empty($nestedFilesByLocale)) { continue; } - Filepond::saveFile($object, $nestedFilesByLocale, $nestedRole, $locale); + FilepondFacade::saveFile($object, $nestedFilesByLocale, $nestedRole, $locale); $this->mustTouchEloquentModel(); } } else { if (empty($nestedFiles)) { continue; } - Filepond::saveFile($object, $nestedFiles, $nestedRole); + FilepondFacade::saveFile($object, $nestedFiles, $nestedRole); $this->mustTouchEloquentModel(); } } @@ -62,11 +241,11 @@ public function afterSaveFilepondsTrait($object, $fields) if (empty($filesByLocale)) { continue; } - Filepond::saveFile($object, $filesByLocale, $role, $locale); + FilepondFacade::saveFile($object, $filesByLocale, $role, $locale); $this->mustTouchEloquentModel(); } } else { - Filepond::saveFile($object, $files, $role); + FilepondFacade::saveFile($object, $files, $role); $this->mustTouchEloquentModel(); } } diff --git a/src/Repositories/Traits/FilesTrait.php b/src/Repositories/Traits/FilesTrait.php index ffb6d7a7b..e1c7c4ded 100755 --- a/src/Repositories/Traits/FilesTrait.php +++ b/src/Repositories/Traits/FilesTrait.php @@ -9,6 +9,12 @@ trait FilesTrait { + /** + * When true, {@see RevisionsTrait::bypassAfterSaves} may set `passAfterSaveFilesTrait` during pending-only + * revision saves so {@see afterSaveFilesTrait} is skipped. + */ + protected bool $pendingBypassRevisionFilesTrait = true; + public function setColumnsFilesTrait($columns, $inputs) { $traitName = get_class_short_name(__TRAIT__); @@ -35,17 +41,7 @@ public function hydrateFilesTrait($object, $fields) return $object; } - $filesCollection = Collection::make(); - $filesFromFields = $this->getFiles($object, $fields); - - $filesFromFields->each(function ($file) use ($object, $filesCollection) { - $newFile = File::withTrashed()->find($file['file_id']); - $pivot = $newFile->newPivot($object, Arr::except($file, ['id']), 'fileables', true); - $newFile->setRelation('pivot', $pivot); - $filesCollection->push($newFile); - }); - - $object->setRelation('files', $filesCollection); + $object->setRelation('files', $this->getPreviewFiles($object, $fields)); return $object; } @@ -61,16 +57,72 @@ public function afterSaveFilesTrait($object, $fields) return; } - $this->getFiles($object, $fields)->each(function ($file) use ($object) { - if (isset($file['id']) && $file['id']) { - $result = $object->files()->updateExistingPivot($file['id'], Arr::except($file, ['id', 'file_id'])); - if ($result) { - $this->mustTouchEloquentModel(); + $object->loadMissing('files'); + + foreach ($this->resolveFileTraitRoles($fields) as $role) { + if (! $this->attachmentRoleIsPresentInFields($fields, $role)) { + continue; + } + + $payload = $this->getAttachmentPayloadForRole($fields, $role); + if ($payload === null) { + continue; + } + + if ($this->isAttachmentRoleTranslatedForFields($fields, $role)) { + $rolePayload = is_array($payload) ? $payload : []; + + foreach (getLocales() as $locale) { + if (! array_key_exists($locale, $rolePayload)) { + continue; + } + + $slice = $rolePayload[$locale]; + $this->detachFilesForRoleLocale($object, $role, $locale); + + if ($slice === null) { + continue; + } + + $rows = is_array($slice) ? $slice : []; + $this->attachFileSpecsFromRows($object, $rows, $role, $locale); } } else { - $object->files()->attach($file['file_id'], Arr::except($file, ['file_id'])); - $this->mustTouchEloquentModel(); + $locale = (string) config('app.locale', 'en'); + $this->detachFilesForRoleLocale($object, $role, $locale); + $rows = is_array($payload) ? $payload : []; + $this->attachFileSpecsFromRows($object, $rows, $role, $locale); } + } + } + + /** + * Remove all file pivots for this role + locale so the next attach matches {@code fields} exactly. + */ + private function detachFilesForRoleLocale($object, string $role, string $locale): void + { + $relatedKey = $object->files()->getRelated()->getQualifiedKeyName(); + $ids = $object->files() + ->wherePivot('role', $role) + ->wherePivot('locale', $locale) + ->pluck($relatedKey); + + if ($ids->isEmpty()) { + return; + } + + $object->files()->detach($ids->all()); + $this->mustTouchEloquentModel(); + } + + /** + * @param array $rows + */ + private function attachFileSpecsFromRows($object, array $rows, string $role, string $locale): void + { + $this->collectPivotSpecsForFileRows($object, $rows, $role, $locale)->each(function ($file) use ($object) { + $object->files()->attach($file['file_id'], Arr::except($file, ['file_id', 'id'])); + $this->mustTouchEloquentModel(); }); } @@ -84,17 +136,6 @@ public function getFormFieldsFilesTrait($object, $fields, $schema) $fileInputs = $this->getColumns(__TRAIT__); if (! empty($fileInputs) && $object->has('files')) { $schema = $schema ?? $this->inputs(); - // foreach ($object->files->groupBy('pivot.role') as $role => $filesByRole) { - // foreach ($filesByRole->groupBy('pivot.locale') as $locale => $filesByLocale) { - // // $fields['files'][$locale][$role] = $filesByLocale->map(function ($file) { - // // return $file->mediableFormat(); - // // }); - // $fields[$role][$locale] = $filesByLocale->map(function ($file) { - // return $file->mediableFormat(); - // }); - // } d - // } - $systemLocales = getLocales(); $default_locale = config('app.locale'); $fallback_locale = config('app.fallback_locale'); $filesByRole = $object->files->groupBy('pivot.role'); @@ -124,10 +165,6 @@ public function getFormFieldsFilesTrait($object, $fields, $schema) })) : Collection::make([]), ]; } - - // foreach ($systemLocales as $locale) { - // $fields[$role][$locale] = []; - // } } } } @@ -136,66 +173,138 @@ public function getFormFieldsFilesTrait($object, $fields, $schema) } /** - * @param array $fields - * @return Collection + * Preview: merge DB file pivots with payload. + * + * @param array $fields */ - private function getFiles($object, $fields) + private function getPreviewFiles($object, array $fields): Collection { - $files = Collection::make(); - $systemLocales = getLocales(); - $fileRoles = $this->getColumns(__TRAIT__); - $fileablesTable = modularityConfig('tables.fileables', 'um_fileables'); + $object->loadMissing('files'); - foreach ($fileRoles as $role) { - if (isset($fields[$role]) && count(array_keys($fields[$role])) > 0) { - $default_locale = array_keys($fields[$role])[0]; - foreach ($systemLocales as $locale) { - if (isset($fields[$role][$locale])) { - Collection::make($fields[$role][$locale])->each(function ($file) use ($object, $fileablesTable, &$files, $role, $locale) { - $fileableId = $object->files() - ->select($fileablesTable . '.id as pivot_id') - ->where('file_id', $file['id']) - ->where('role', $role) - ->where('locale', $locale)->value('pivot_id') ?? null; - - $files->push([ - ...($fileableId ? ['id' => $fileableId] : []), - 'file_id' => $file['id'], - 'role' => $role, - 'locale' => $locale, - ]); - }); - } else { - Collection::make($fields[$role])->each(function ($file) use ($object, $fileablesTable, &$files, $role, $locale) { - $fileableId = $object->files() - ->select($fileablesTable . '.id as pivot_id') - ->where('file_id', $file['id']) - ->where('role', $role) - ->where('locale', $locale)->value('pivot_id') ?? null; - - $files->push([ - ...($fileableId ? ['id' => $fileableId] : []), - 'file_id' => $file['id'], - 'role' => $role, - 'locale' => $locale, - ]); - }); + $roles = $this->resolveFileTraitRoles($fields); + $original = $object->files; + + if (! collect($roles)->contains(fn ($role) => $this->attachmentRoleIsPresentInFields($fields, $role))) { + return $original; + } + + $out = Collection::make(); + + foreach ($roles as $role) { + if (! $this->attachmentRoleIsPresentInFields($fields, $role)) { + $out = $out->merge($original->where('pivot.role', $role)); + + continue; + } + + $payload = $this->getAttachmentPayloadForRole($fields, $role); + + if ($this->isAttachmentRoleTranslatedForFields($fields, $role)) { + $rolePayload = is_array($payload) ? $payload : []; + + foreach (getLocales() as $locale) { + if (! array_key_exists($locale, $rolePayload)) { + $out = $out->merge($original->filter( + fn ($f) => $f->pivot->role === $role && $f->pivot->locale === $locale + )); + + continue; + } + + $slice = $rolePayload[$locale]; + if ($slice === null) { + continue; } + + $rows = is_array($slice) ? $slice : []; + $out = $out->merge($this->pivotSpecsToFileModels( + $object, + $this->collectPivotSpecsForFileRows($object, $rows, $role, $locale) + )); } - // foreach($fields[$role] as $locale => $filesForRole){ - // Collection::make($filesForRole)->each(function ($file) use (&$files, $role, $locale) { - // $files->push([ - // 'id' => $file['id'], - // 'role' => $role, - // 'locale' => $locale, - // ]); - // }); - // } } else { - // dd($role); + $rows = is_array($payload) ? $payload : []; + $locale = (string) config('app.locale', 'en'); + $out = $out->merge($this->pivotSpecsToFileModels( + $object, + $this->collectPivotSpecsForFileRows($object, $rows, $role, $locale) + )); } } - return $files; + $out = $out->merge($original->filter( + fn ($f) => ! in_array($f->pivot->role, $roles, true) + )); + + return $out->values(); + } + + /** + * Roles for file pivots only — never image / media-library fields (e.g. {@code photos}). + * + * @param array $fields + * @return list + */ + private function resolveFileTraitRoles(array $fields): array + { + $resolved = $this->resolveAttachmentRoles( + __TRAIT__, + '/\bfile\b/', + $fields, + fn ($k, $v) => $this->valueLooksLikeFileRolePayload($v) + ); + + return array_values(array_filter( + $resolved, + fn (string $role) => ! $this->shouldExcludeRoleFromFileTrait($role, $fields) + )); + } + + /** + * @param array $rows + */ + private function collectPivotSpecsForFileRows($object, array $rows, string $role, string $locale): Collection + { + $specs = Collection::make(); + $fileablesTable = modularityConfig('tables.fileables', 'um_fileables'); + + Collection::make($rows)->each(function ($file) use ($object, $fileablesTable, $specs, $role, $locale) { + if (! is_array($file) || ! isset($file['id'])) { + return; + } + + $fileableId = $object->files() + ->select($fileablesTable . '.id as pivot_id') + ->where('file_id', $file['id']) + ->where('role', $role) + ->where('locale', $locale)->value('pivot_id') ?? null; + + $specs->push([ + ...($fileableId ? ['id' => $fileableId] : []), + 'file_id' => $file['id'], + 'role' => $role, + 'locale' => $locale, + ]); + }); + + return $specs; + } + + private function pivotSpecsToFileModels($object, Collection $specs): Collection + { + $filesCollection = Collection::make(); + + $specs->each(function ($file) use ($object, $filesCollection) { + $newFile = File::withTrashed()->find($file['file_id']); + if (! $newFile) { + return; + } + + $pivot = $newFile->newPivot($object, Arr::except($file, ['id']), 'fileables', true); + $newFile->setRelation('pivot', $pivot); + $filesCollection->push($newFile); + }); + + return $filesCollection; } } diff --git a/src/Repositories/Traits/ImagesTrait.php b/src/Repositories/Traits/ImagesTrait.php index f3eb91911..3a3188abd 100755 --- a/src/Repositories/Traits/ImagesTrait.php +++ b/src/Repositories/Traits/ImagesTrait.php @@ -9,9 +9,14 @@ trait ImagesTrait { + /** + * When true, {@see RevisionsTrait::bypassAfterSaves} may set `passAfterSaveImagesTrait` during pending-only + * revision saves so {@see afterSaveImagesTrait} is skipped. + */ + protected bool $pendingBypassRevisionImagesTrait = true; + public function setColumnsImagesTrait($columns, $inputs) { - $traitName = get_class_short_name(__TRAIT__); $columns[$traitName] = collect($inputs)->reduce(function ($acc, $curr) { @@ -32,25 +37,109 @@ public function setColumnsImagesTrait($columns, $inputs) */ public function hydrateImagesTrait($object, $fields) { - // dd('hydrateImagesTrait', $object, $fields, $this->getMedias($fields)); if ($this->shouldIgnoreFieldBeforeSave('medias')) { return $object; } + $object->setRelation('medias', $this->getPreviewMedias($object, $fields)); + + return $object; + } + + /** + * Preview: merge DB medias with payload; omitted roles / locales keep persisted rows. + * + * @param array $fields + */ + private function getPreviewMedias($object, array $fields): Collection + { + $object->loadMissing('medias'); + + $roles = $this->resolveAttachmentRoles(__TRAIT__, '/image/', $fields, fn ($k, $v) => $this->valueLooksLikeImageRolePayload($v)); + $original = $object->medias; + + if (! collect($roles)->contains(fn ($role) => $this->attachmentRoleIsPresentInFields($fields, $role))) { + return $original; + } + + $out = Collection::make(); + + foreach ($roles as $role) { + if (! $this->attachmentRoleIsPresentInFields($fields, $role)) { + $out = $out->merge($original->where('pivot.role', $role)); + + continue; + } + + $payload = $this->getAttachmentPayloadForRole($fields, $role); + + if ($this->isAttachmentRoleTranslatedForFields($fields, $role)) { + $rolePayload = is_array($payload) ? $payload : []; + + foreach (getLocales() as $locale) { + if (! array_key_exists($locale, $rolePayload)) { + $out = $out->merge($original->filter( + fn ($m) => $m->pivot->role === $role && $m->pivot->locale === $locale + )); + + continue; + } + + $slice = $rolePayload[$locale]; + if ($slice === null) { + continue; + } + + $rows = is_array($slice) ? $slice : []; + $acc = Collection::make(); + $specs = $this->pushImage($object, $acc, $rows, $role, $locale); + $out = $out->merge($this->pivotSpecsToMediaModels($object, $specs)); + } + } else { + $rows = is_array($payload) ? $payload : []; + $locale = (string) config('app.locale', 'en'); + $acc = Collection::make(); + $specs = $this->pushImage($object, $acc, $rows, $role, $locale); + $out = $out->merge($this->pivotSpecsToMediaModels($object, $specs)); + } + } + + $out = $out->merge($original->filter( + fn ($m) => ! in_array($m->pivot->role, $roles, true) + )); + + return $out->values(); + } + + /** + * @return Collection + */ + private function pivotSpecsToMediaModels($object, Collection $specs): Collection + { $mediasCollection = Collection::make(); - $mediasFromFields = $this->getMedias($object, $fields); + $specs->each(function ($spec) use ($object, $mediasCollection) { + if (! is_array($spec)) { + return; + } + + $mediaId = $spec['media_id'] ?? null; + $mediaId = is_array($mediaId) ? Arr::first($mediaId) : $mediaId; + if ($mediaId === null) { + return; + } + + $newMedia = Media::withTrashed()->find($mediaId); + if (! $newMedia) { + return; + } - $mediasFromFields->each(function ($media) use ($object, $mediasCollection) { - $newMedia = Media::withTrashed()->find(is_array($media['media_id']) ? Arr::first($media['media_id']) : $media['media_id']); - $pivot = $newMedia->newPivot($object, Arr::except($media, ['id']), modularityConfig('tables.mediables', 'umod_mediables'), true); + $pivot = $newMedia->newPivot($object, Arr::except($spec, ['id']), modularityConfig('tables.mediables', 'umod_mediables'), true); $newMedia->setRelation('pivot', $pivot); $mediasCollection->push($newMedia); }); - $object->setRelation('medias', $mediasCollection); - - return $object; + return $mediasCollection; } /** @@ -64,16 +153,84 @@ public function afterSaveImagesTrait($object, $fields) return; } - $this->getMedias($object, $fields)->each(function ($media) use ($object) { - if (isset($media['id']) && $media['id']) { - $result = $object->medias()->updateExistingPivot($media['id'], Arr::except($media, ['id', 'media_id'])); - if ($result) { - $this->mustTouchEloquentModel(); + $object->loadMissing('medias'); + + $roles = $this->resolveAttachmentRoles(__TRAIT__, '/image/', $fields, fn ($k, $v) => $this->valueLooksLikeImageRolePayload($v)); + + foreach ($roles as $role) { + if (! $this->attachmentRoleIsPresentInFields($fields, $role)) { + continue; + } + + $payload = $this->getAttachmentPayloadForRole($fields, $role); + if ($payload === null) { + continue; + } + + if ($this->isAttachmentRoleTranslatedForFields($fields, $role)) { + $rolePayload = is_array($payload) ? $payload : []; + + foreach (getLocales() as $locale) { + if (! array_key_exists($locale, $rolePayload)) { + continue; + } + + $slice = $rolePayload[$locale]; + $this->detachMediasForRoleLocale($object, $role, $locale); + + if ($slice === null) { + continue; + } + + $rows = is_array($slice) ? $slice : []; + $this->attachImageSpecsFromRows($object, $rows, $role, $locale); } } else { - $object->medias()->attach($media['media_id'], Arr::except($media, ['media_id'])); - $this->mustTouchEloquentModel(); + $locale = (string) config('app.locale', 'en'); + $this->detachMediasForRoleLocale($object, $role, $locale); + $rows = is_array($payload) ? $payload : []; + $this->attachImageSpecsFromRows($object, $rows, $role, $locale); + } + } + } + + /** + * Remove all media pivots for this role + locale so the next attach matches {@code fields} exactly. + */ + private function detachMediasForRoleLocale($object, string $role, string $locale): void + { + $relatedKey = $object->medias()->getRelated()->getQualifiedKeyName(); + $relation = $object->medias()->wherePivot('role', $role); + + if (modularityConfig('media_library.translated_form_fields', false)) { + $relation->wherePivot('locale', $locale); + } + + $ids = $relation->pluck($relatedKey); + + if ($ids->isEmpty()) { + return; + } + + $object->medias()->detach($ids->all()); + $this->mustTouchEloquentModel(); + } + + /** + * @param array $rows + */ + private function attachImageSpecsFromRows($object, array $rows, string $role, string $locale): void + { + $acc = Collection::make(); + $specs = $this->pushImage($object, $acc, $rows, $role, $locale); + + $specs->each(function ($media) use ($object) { + if (! is_array($media) || ! isset($media['media_id'])) { + return; } + + $object->medias()->attach($media['media_id'], Arr::except($media, ['media_id', 'id'])); + $this->mustTouchEloquentModel(); }); } @@ -84,7 +241,6 @@ public function afterSaveImagesTrait($object, $fields) */ public function getFormFieldsImagesTrait($object, $fields, $schema) { - // $t = []; $imageInputs = $this->getColumns(__TRAIT__); if (! empty($imageInputs) && $object->has('medias')) { $schema = $schema ?? $this->inputs(); @@ -124,38 +280,14 @@ public function getFormFieldsImagesTrait($object, $fields, $schema) return $fields; } - /** - * @param array $fields - * @return Collection - */ - private function getMedias($object, $fields) - { - $images = Collection::make(); - - $systemLocales = getLocales(); - - $imageRoles = $this->getColumns(__TRAIT__); - - foreach ($imageRoles as $role) { - if (isset($fields[$role])) { - foreach ($systemLocales as $locale) { - if (isset($fields[$role][$locale])) { - $images = $this->pushImage($object, $images, $fields[$role][$locale], $role, $locale); - } else { - $images = $this->pushImage($object, $images, $fields[$role], $role, $locale); - - } - } - } - } - - return $images; - } - public function pushImage($object, $images, $imagesData, $role, $locale, $index = null) { $mediablesTable = modularityConfig('tables.mediables', 'um_mediables'); Collection::make($imagesData)->each(function ($image) use ($object, $mediablesTable, &$images, $role, $locale, $index) { + if (! is_array($image) || ! isset($image['id'])) { + return; + } + $replacePattern = '/([A-Za-z-_]+)(\.)(\*)(\.)([A-Za-z-_\.]+)/'; $role = preg_replace($replacePattern, '${1}${2}' . $index . '${4}${5}', $role); $mediableId = $object->medias() @@ -168,7 +300,7 @@ public function pushImage($object, $images, $imagesData, $role, $locale, $index ...($mediableId ? ['id' => $mediableId] : []), 'media_id' => $image['id'], 'role' => $role, - 'metadatas' => json_encode($image['metadatas']), + 'metadatas' => json_encode($image['metadatas'] ?? []), 'crop' => 'default', 'locale' => $locale, ]); diff --git a/src/Repositories/Traits/PaymentTrait.php b/src/Repositories/Traits/PaymentTrait.php index 0d5a3a400..c56f9e0f8 100644 --- a/src/Repositories/Traits/PaymentTrait.php +++ b/src/Repositories/Traits/PaymentTrait.php @@ -18,6 +18,12 @@ trait PaymentTrait { use PricesTrait; + /** + * When true, {@see RevisionsTrait::bypassAfterSaves} may set `passAfterSavePaymentTrait` during pending-only + * revision saves so {@see afterSavePaymentTrait} is skipped. + */ + protected bool $pendingBypassRevisionPaymentTrait = true; + /** * paymentTraitRelationName * diff --git a/src/Repositories/Traits/PricesTrait.php b/src/Repositories/Traits/PricesTrait.php index 927b9fed3..208a1f93b 100755 --- a/src/Repositories/Traits/PricesTrait.php +++ b/src/Repositories/Traits/PricesTrait.php @@ -11,6 +11,12 @@ trait PricesTrait { + /** + * When true, {@see RevisionsTrait::bypassAfterSaves} may set `passAfterSavePricesTrait` during pending-only + * revision saves so {@see afterSavePricesTrait} is skipped. + */ + protected bool $pendingBypassRevisionPricesTrait = true; + protected $formatableColumns = [ 'id', 'raw_amount', diff --git a/src/Repositories/Traits/RepeatersTrait.php b/src/Repositories/Traits/RepeatersTrait.php index 84ab269e3..1d1f98406 100644 --- a/src/Repositories/Traits/RepeatersTrait.php +++ b/src/Repositories/Traits/RepeatersTrait.php @@ -5,6 +5,7 @@ use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Unusualify\Modularity\Entities\Model; +use Unusualify\Modularity\Entities\Repeater; /** * This trait is used for repeaters that may or may not have files or images in them. @@ -42,6 +43,103 @@ public function setColumnsRepeatersTrait($columns, $inputs) return $columns; } + /** + * Preview / {@see \Unusualify\Modularity\Repositories\Traits\RevisionsTrait::previewForRevision}: populate `repeaters` + * from the revision payload so presenters and forms see the same shape as after a normal load. + * + * @param Model $object + * @param array $fields + * @return Model + */ + public function hydrateRepeatersTrait($object, $fields) + { + if ($this->shouldIgnoreFieldBeforeSave('repeaters')) { + return $object; + } + + if (! classHasTrait($object, 'Unusualify\Modularity\Entities\Traits\HasRepeaters') || ! method_exists($object, 'repeaters')) { + return $object; + } + + $object->setRelation('repeaters', $this->buildPreviewRepeatersRelation($object, $fields)); + + return $object; + } + + /** + * Mirrors {@see afterSaveRepeatersTrait} field resolution so one {@see Repeater} row exists per role/locale + * with `content` taken from the payload (unsaved models for preview). + * + * @param array $fields + */ + private function buildPreviewRepeatersRelation(Model $object, array $fields): Collection + { + $out = Collection::make(); + $schema = $this->getRawInputs(); + $systemLocales = getLocales(); + $fallbackLocale = app()->getFallbackLocale(); + + foreach ($this->getRepeaterInputs($schema) as $input) { + $name = $input['name']; + $isTranslated = $input['translated'] ?? false; + + if (! isset($fields[$name]) || ! is_array($fields[$name])) { + continue; + } + + $intersectLocales = array_intersect(array_keys($fields[$name]), $systemLocales); + $localized = count($intersectLocales) > 1; + $existLocale = $localized ? ($intersectLocales[0] ?? null) : null; + + if ($isTranslated) { + foreach ($systemLocales as $systemLocale) { + $content = $fields[$name]; + if ($localized) { + $content = isset($fields[$name][$systemLocale]) + ? $fields[$name][$systemLocale] + : ($existLocale ? ($fields[$name][$existLocale] ?? []) : []); + } + if (! is_array($content)) { + $content = []; + } + + $out->push($this->makePreviewRepeaterRow($object, $name, $systemLocale, $content)); + } + } else { + $payload = $fields[$name]; + if ($localized) { + $payload = isset($fields[$name][$fallbackLocale]) + ? $fields[$name][$fallbackLocale] + : ($existLocale ? ($fields[$name][$existLocale] ?? []) : []); + } + if (! is_array($payload)) { + $payload = []; + } + + $out->push($this->makePreviewRepeaterRow($object, $name, $fallbackLocale, $payload)); + } + } + + return $out; + } + + /** + * @param array $content + */ + private function makePreviewRepeaterRow(Model $object, string $role, string $locale, array $content): Repeater + { + $repeater = new Repeater([ + 'role' => $role, + 'locale' => $locale, + 'content' => $content, + 'repeatable_id' => $object->getKey(), + 'repeatable_type' => $object->getMorphClass(), + ]); + $repeater->exists = false; + + return $repeater; + } + /** * @param Model $object * @param array $fields diff --git a/src/Repositories/Traits/RevisionsTrait.php b/src/Repositories/Traits/RevisionsTrait.php index 491297e16..2260359f8 100644 --- a/src/Repositories/Traits/RevisionsTrait.php +++ b/src/Repositories/Traits/RevisionsTrait.php @@ -4,22 +4,130 @@ use Illuminate\Support\Arr; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; +use Spatie\Activitylog\Facades\LogBatch; +use Unusualify\Modularity\Entities\Enums\RevisionStatus; +use Unusualify\Modularity\Facades\ValidationException; use Unusualify\Modularity\Facades\Modularity; trait RevisionsTrait { protected bool $skipRevisionCreation = false; + protected ?int $pendingSourceRevisionId = null; + protected bool $workflowBypassPendingGuard = false; + + /** + * Runtime flags for MethodTransformers::afterSave: when true, that hook is skipped. + * Declared here; set by bypassAfterSaves() when a composing trait opts in via pendingBypassRevision* (see each trait). + * + * @see resetPassAfterSaves() + */ + protected bool $passAfterSaveSlugsTrait = false; + + protected bool $passAfterSaveFilesTrait = false; + + protected bool $passAfterSaveImagesTrait = false; + + protected bool $passAfterSaveFilepondsTrait = false; + + protected bool $passAfterSaveRepeatersTrait = false; + + protected bool $passAfterSavePricesTrait = false; + + protected bool $passAfterSaveTagsTrait = false; + + protected bool $passAfterSaveRelationships = false; + + protected bool $passAfterSavePaymentTrait = false; + + /** + * Overrides {@see \Unusualify\Modularity\Repositories\Repository::update} so pending-only workflow + * can persist a revision row and skip subject fill/save without changing Repository. + * + * @param mixed $id + * @param array $fields + */ + public function update($id, $fields, $schema = null, $options = []) + { + $this->setSchema($schema); + + $this->setColumns($schema ?? $this->chunkInputs(all: true)); + + return DB::transaction(function () use ($id, $fields, $options) { + LogBatch::startBatch(); + + if (classHasTrait($this->model, 'Unusualify\Modularity\Entities\Traits\IsSingular')) { + $object = $this->model->single(); + } else { + $object = $this->model->findOrFail($id); + } + + $this->beforeSave($object, $fields); + + $fields = $this->prepareFieldsBeforeSave($object, $fields); + + if ( + $this->shouldQueuePendingRevisionOnly($object, $fields) + && $this->processPendingRevisionSubmission($object, $fields) + ) { + LogBatch::endBatch(); + + $object = $this->touchEloquentModel($object->fresh()); + + $this->dispatchEvent($object, 'update'); + + return true; + } + + $object->fill(Arr::except($fields, $this->getReservedFields())); + + if (method_exists($object, 'preventDependentWarming')) { + $object = $object->preventDependentWarming(isset($options['preventDependentWarming']) && $options['preventDependentWarming']); + } + + $object->save(); + + $this->afterSave($object, $fields); + + LogBatch::endBatch(); + + $object = $this->touchEloquentModel($object); + + $this->dispatchEvent($object, 'update'); + + return $object->wasChanged(); + }, 3); + } + + public function beforeSaveRevisionsTrait($object, $fields): void + { + if (! method_exists($object, 'usesRevisionWorkflow') || ! $object->usesRevisionWorkflow()) { + return; + } + + if ($this->workflowBypassPendingGuard) { + return; + } + + if (method_exists($object, 'isRevisionWorkflowLocked') && $object->isRevisionWorkflowLocked()) { + $message = __('messages.revision.pending-locks-record'); + + throw ValidationException::withMessages([ + 'revision' => [$message], + ])->variant('warning'); + } + } + public function afterSaveRevisionsTrait($object, $fields): void { $this->createRevisionIfNeeded($object, $fields); } - /** - * @param Model $object - * @param array $fields - * @param array $schema + /** + * @param \Unusualify\Modularity\Models\Model $object * @return array */ public function getFormFieldsRevisionsTrait($object, $fields, $schema = []) @@ -38,21 +146,28 @@ public function createRevisionIfNeeded($object, array $fields): array return $fields; } - $lastRevision = $object->revisions()->latest('id')->first(); - $lastRevisionPayload = json_decode($lastRevision->payload ?? '{}', true) ?: []; + $lastRevisionPayload = $this->getLastApprovedRevisionPayload($object); $fullPayload = array_replace_recursive($lastRevisionPayload, $fields); - if ($fullPayload !== $lastRevisionPayload) { - $userId = Auth::guard(Modularity::getAuthGuardName())->id() ?? Auth::id(); + if ($this->revisionPayloadsAreEquivalent($fullPayload, $lastRevisionPayload)) { + return $fields; + } - $object->revisions()->create([ - 'payload' => json_encode($fullPayload), - 'user_id' => $userId, - 'source_revision_id' => $this->pendingSourceRevisionId, - ]); + $userId = Auth::guard(Modularity::getAuthGuardName())->id() ?? Auth::id(); + + $revisionAttributes = [ + 'payload' => json_encode($fullPayload), + 'user_id' => $userId, + 'source_id' => $this->pendingSourceRevisionId, + ]; + + if ($this->revisionTableHasStatusColumn($object)) { + $this->applyApprovedRevisionAttributes($revisionAttributes, $userId); } + $object->revisions()->create($revisionAttributes); + if (isset($object->limitRevisions) && (int) $object->limitRevisions > 0) { $object->deleteSpecificRevisions((int) $object->limitRevisions); } @@ -67,8 +182,11 @@ public function preview(int $id, array $fields) return $this->hydrateObject($object, $fields); } - public function previewForRevision(int $id, int $revisionId) + public function previewForRevision(int $id, int $revisionId, $schema = []) { + $this->setSchema($schema); + $this->setColumns($schema ?? $this->chunkInputs(all: true)); + $object = $this->model->findOrFail($id); $revision = $object->revisions()->where('id', $revisionId)->firstOrFail(); $fields = json_decode($revision->payload, true) ?: []; @@ -80,8 +198,37 @@ public function restoreRevision(int $id, int $revisionId) { $object = $this->model->findOrFail($id); $revision = $object->revisions()->where('id', $revisionId)->firstOrFail(); + + if ($this->revisionTableHasStatusColumn($object) && ($revision->status ?? RevisionStatus::Approved->value) === RevisionStatus::Rejected->value) { + throw ValidationException::withMessages([ + 'revision' => [__('messages.revision.restore-blocked-rejected')], + ]); + } + + if (method_exists($object, 'usesRevisionWorkflow') && $object->usesRevisionWorkflow()) { + if (method_exists($object, 'isRevisionWorkflowLocked') && $object->isRevisionWorkflowLocked()) { + throw ValidationException::withMessages([ + 'revision' => [__('messages.revision.restore-blocked-pending')], + ]); + } + + if (! $object->userCanRestoreRevisions()) { + throw ValidationException::withMessages([ + 'revision' => [__('messages.revision.restore-forbidden')], + ]); + } + } + + if ($revision->source_id !== null) { + abort(422, __('messages.revision.restore-disabled-already-restored')); + } + $fields = json_decode($revision->payload, true) ?: []; + if ($this->shouldRestoreAsPendingOnly($object)) { + return $this->restoreRevisionAsPendingOnly($object, $fields, $revisionId); + } + // Skip auto-revision creation during update so we can force-create one below, // ensuring a restore is always recorded even when content is identical to the latest revision. $this->skipRevisionCreation = true; @@ -89,11 +236,217 @@ public function restoreRevision(int $id, int $revisionId) $this->skipRevisionCreation = false; $userId = Auth::guard(Modularity::getAuthGuardName())->id() ?? Auth::id(); - $object->revisions()->create([ + $restoreAttributes = [ 'payload' => json_encode($fields), 'user_id' => $userId, - 'source_revision_id' => $revisionId, - ]); + 'source_id' => $revisionId, + ]; + if ($this->revisionTableHasStatusColumn($object)) { + $this->applyApprovedRevisionAttributes($restoreAttributes, $userId); + } + + $object->revisions()->create($restoreAttributes); + + return $this->model->findOrFail($id); + } + + /** + * Workflow on + user lacks {@code *_revision_approve}: restore only queues a pending snapshot (subject row unchanged), like a normal edit. + * + * @param \Unusualify\Modularity\Models\Model $object + */ + protected function shouldRestoreAsPendingOnly($object): bool + { + if (! $this->revisionTableHasStatusColumn($object)) { + return false; + } + + if (! method_exists($object, 'usesRevisionWorkflow') || ! $object->usesRevisionWorkflow()) { + return false; + } + + return ! $object->userCanApproveRevisions(); + } + + /** + * Record a proposed restore as the latest pending revision without persisting payload to the subject. + * + * @param \Unusualify\Modularity\Models\Model $object + */ + protected function restoreRevisionAsPendingOnly($object, array $fields, int $sourceRevisionId): mixed + { + if (method_exists($object, 'isRevisionWorkflowLocked') && $object->isRevisionWorkflowLocked()) { + throw ValidationException::withMessages([ + 'revision' => [__('messages.revision.pending-only-one')], + ]); + } + + $this->skipRevisionCreation = true; + + try { + $userId = Auth::guard(Modularity::getAuthGuardName())->id() ?? Auth::id(); + + $revisionAttributes = [ + 'payload' => json_encode($fields), + 'user_id' => $userId, + 'source_id' => $sourceRevisionId, + ]; + + if ($this->revisionTableHasStatusColumn($object)) { + $revisionAttributes['status'] = RevisionStatus::Pending->value; + $revisionAttributes['approved_at'] = null; + $revisionAttributes['approved_by'] = null; + } + + $object->revisions()->create($revisionAttributes); + + $this->bypassAfterSaves(); + try { + $this->afterSave($object, $fields); + } finally { + $this->resetPassAfterSaves(); + } + } finally { + $this->skipRevisionCreation = false; + } + + return $this->model->findOrFail($object->id); + } + + /** + * Sets passAfterSave* flags when a composing trait opts in via traitProperties('pendingBypassRevision'). + * Only traits that declare pendingBypassRevision{TraitBasename} participate; when that flag is true, + * passAfterSave{SameSuffix} is set so the corresponding afterSave hook is skipped during pending-only saves. + * + * File / Filepond: when bypassed, Filepond::saveFile does not run; the revision JSON must still store upload + * response metadata (ids, paths). On approve, a normal afterSave finalizes. Mitigate temp expiry via longer TTL, + * staging disk, or a dedicated “promote temp file to library without attaching to live row” step. + */ + protected function bypassAfterSaves(): void + { + foreach ($this->traitProperties('pendingBypassRevision') as $pendingKey) { + if (! $this->{$pendingKey}) { + continue; + } + + $suffix = (string) preg_replace('/^pendingBypassRevision/', '', $pendingKey); + + if ($suffix === 'RevisionsTrait') { + continue; + } + + $passKey = 'passAfterSave' . $suffix; + + if (property_exists($this, $passKey)) { + $this->{$passKey} = true; + } + } + } + + protected function resetPassAfterSaves(): void + { + foreach ($this->traitProperties('passAfterSave') as $passKey) { + $this->{$passKey} = false; + } + } + + /** + * @param array $attributes + */ + protected function applyApprovedRevisionAttributes(array &$attributes, $userId): void + { + $attributes['status'] = RevisionStatus::Approved->value; + $attributes['approved_at'] = now(); + $attributes['approved_by'] = $userId; + } + + /** + * Apply a pending revision payload to the subject and mark the revision approved. + * + * @return \Illuminate\Database\Eloquent\Model + */ + public function approveRevision(int $id, int $revisionId) + { + $object = $this->model->findOrFail($id); + $revision = $object->revisions()->where('id', $revisionId)->firstOrFail(); + + if (! method_exists($object, 'usesRevisionWorkflow') || ! $object->usesRevisionWorkflow()) { + abort(422, __('messages.revision.approve-not-applicable')); + } + + $latest = $object->revisions()->orderByDesc('id')->first(); + + if (! $latest || (int) $latest->id !== (int) $revision->id) { + throw ValidationException::withMessages([ + 'revision' => [__('messages.revision.approve-not-latest')], + ]); + } + + if ($this->revisionTableHasStatusColumn($object) && ! $revision->isPending()) { + throw ValidationException::withMessages([ + 'revision' => [__('messages.revision.approve-not-pending')], + ]); + } + + $fields = json_decode($revision->payload, true) ?: []; + + $this->workflowBypassPendingGuard = true; + $this->skipRevisionCreation = true; + + try { + $this->update($id, $fields); + + if ($this->revisionTableHasStatusColumn($object)) { + $revision->refresh(); + $revision->update([ + 'status' => RevisionStatus::Approved->value, + 'approved_at' => now(), + 'approved_by' => Auth::guard(Modularity::getAuthGuardName())->id() ?? Auth::id(), + ]); + } + } finally { + $this->workflowBypassPendingGuard = false; + $this->skipRevisionCreation = false; + } + + return $this->model->findOrFail($id); + } + + /** + * Mark the latest pending revision as rejected. Does not update the subject row. + * + * @return \Illuminate\Database\Eloquent\Model + */ + public function rejectRevision(int $id, int $revisionId) + { + $object = $this->model->findOrFail($id); + $revision = $object->revisions()->where('id', $revisionId)->firstOrFail(); + + if (! method_exists($object, 'usesRevisionWorkflow') || ! $object->usesRevisionWorkflow()) { + abort(422, __('messages.revision.reject-not-applicable')); + } + + $latest = $object->revisions()->orderByDesc('id')->first(); + + if (! $latest || (int) $latest->id !== (int) $revision->id) { + throw ValidationException::withMessages([ + 'revision' => [__('messages.revision.reject-not-latest')], + ]); + } + + if ($this->revisionTableHasStatusColumn($object) && ! $revision->isPending()) { + throw ValidationException::withMessages([ + 'revision' => [__('messages.revision.reject-not-pending')], + ]); + } + + if ($this->revisionTableHasStatusColumn($object)) { + $revision->update([ + 'status' => RevisionStatus::Rejected->value, + 'approved_at' => null, + 'approved_by' => null, + ]); + } return $this->model->findOrFail($id); } @@ -139,4 +492,143 @@ public function getRevisions(int $id) return $revisions; } + + protected function shouldQueuePendingRevisionOnly($object, array $fields): bool + { + if (! method_exists($object, 'usesRevisionWorkflow') || ! $object->usesRevisionWorkflow()) { + return false; + } + + if ($this->workflowBypassPendingGuard) { + return false; + } + + if (! $object->userCanApproveRevisions()) { + return true; + } + + return false; + } + + /** + * @param \Unusualify\Modularity\Models\Model $object + * @return bool false when merged payload matches last approved (nothing new to queue) + */ + protected function processPendingRevisionSubmission($object, array $fields): bool + { + if (method_exists($object, 'isRevisionWorkflowLocked') && $object->isRevisionWorkflowLocked()) { + throw ValidationException::withMessages([ + 'revision' => [__('messages.revision.pending-only-one')], + ]); + } + + $lastPayload = $this->getLastApprovedRevisionPayload($object); + $fullPayload = array_replace_recursive($lastPayload, $fields); + + if ($this->revisionPayloadsAreEquivalent($fullPayload, $lastPayload)) { + return false; + } + + $this->skipRevisionCreation = true; + + try { + $userId = Auth::guard(Modularity::getAuthGuardName())->id() ?? Auth::id(); + + $revisionAttributes = [ + 'payload' => json_encode($fullPayload), + 'user_id' => $userId, + 'source_id' => $this->pendingSourceRevisionId, + ]; + + if ($this->revisionTableHasStatusColumn($object)) { + $revisionAttributes['status'] = RevisionStatus::Pending->value; + $revisionAttributes['approved_at'] = null; + $revisionAttributes['approved_by'] = null; + } + + $object->revisions()->create($revisionAttributes); + + $this->bypassAfterSaves(); + try { + $this->afterSave($object, $fields); + } finally { + $this->resetPassAfterSaves(); + } + + return true; + } finally { + $this->skipRevisionCreation = false; + } + } + + /** + * Compare revision payloads without regard to associative key order (PHP's array === is order-sensitive). + * + * @param array $a + * @param array $b + */ + protected function revisionPayloadsAreEquivalent(array $a, array $b): bool + { + $na = $this->normalizeRevisionPayloadForComparison($a); + $nb = $this->normalizeRevisionPayloadForComparison($b); + + return json_encode($na, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + === json_encode($nb, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + /** + * @param array $value + * @return array|mixed + */ + protected function normalizeRevisionPayloadForComparison(mixed $value): mixed + { + if (! is_array($value)) { + return $value; + } + + if (array_is_list($value)) { + return array_map(fn ($item) => $this->normalizeRevisionPayloadForComparison($item), $value); + } + + ksort($value); + + foreach ($value as $k => $v) { + $value[$k] = $this->normalizeRevisionPayloadForComparison($v); + } + + return $value; + } + + /** + * @param \Unusualify\Modularity\Models\Model $object + */ + protected function revisionTableHasStatusColumn($object): bool + { + $modelClass = $object->getRevisionModel(); + $instance = new $modelClass; + + return Schema::hasColumn($instance->getTable(), 'status'); + } + + /** + * Payload merged from the latest approved (or legacy unmarked) revision. + * + * @param \Unusualify\Modularity\Models\Model $object + * @return array + */ + public function getLastApprovedRevisionPayload($object): array + { + $query = $object->revisions()->orderByDesc('id'); + + if ($this->revisionTableHasStatusColumn($object)) { + $query->where(function ($q) { + $q->where('status', RevisionStatus::Approved->value) + ->orWhereNull('status'); + }); + } + + $revision = $query->first(); + + return json_decode($revision->payload ?? '{}', true) ?: []; + } } diff --git a/src/Repositories/Traits/SlugsTrait.php b/src/Repositories/Traits/SlugsTrait.php index d32f2a0c5..a67010587 100755 --- a/src/Repositories/Traits/SlugsTrait.php +++ b/src/Repositories/Traits/SlugsTrait.php @@ -6,6 +6,46 @@ trait SlugsTrait { + /** + * When true, {@see RevisionsTrait::bypassAfterSaves} may set `passAfterSaveSlugsTrait` during pending-only + * revision saves so {@see afterSaveSlugsTrait} is skipped. + */ + protected bool $pendingBypassRevisionSlugsTrait = true; + + /** + * Map per-locale slug attribute strings into {@see afterSaveSlugsTrait}'s `slugs` key. + * Runs after {@see TranslationsTrait::prepareFieldsBeforeSaveTranslationsTrait} when repository uses both traits. + * + * @param Model $object + * @param array $fields + * @return array + */ + public function prepareFieldsBeforeSaveSlugsTrait($object, $fields) + { + if (! property_exists($this->model, 'slugAttributes')) { + return $fields; + } + + $slugAttributes = $object->getSlugAttributes(); + + if ($slugAttributes === []) { + return $fields; + } + + foreach (getLocales() as $locale) { + foreach ($slugAttributes as $attr) { + $slugPayload = $fields[$locale][$attr] ?? null; + if ($slugPayload !== null && $slugPayload !== '') { + $fields['slugs'][$locale] = $slugPayload; + + break; + } + } + } + + return $fields; + } + /** * @param Model $object * @param array $fields @@ -60,10 +100,26 @@ public function getFormFieldsSlugsTrait($object, $fields) { unset($fields['slugs']); - if ($object->slugs != null) { + if (! property_exists($this->model, 'slugAttributes')) { + return $fields; + } + + $slugAttributes = $object->getSlugAttributes(); + + if ($slugAttributes === []) { + return $fields; + } + + $object->loadMissing('slugs'); + + if ($object->slugs === null) { + return $fields; + } + + foreach ($slugAttributes as $attr) { foreach ($object->slugs as $slug) { if ($slug->active || $object->slugs->where('locale', $slug->locale)->where('active', true)->count() === 0) { - $fields['translations']['slug'][$slug->locale] = $slug->slug; + $fields['translations'][$attr][$slug->locale] = $slug->slug; } } } diff --git a/src/Repositories/Traits/SpreadableTrait.php b/src/Repositories/Traits/SpreadableTrait.php index 9d7fc73ed..dbc6518f4 100644 --- a/src/Repositories/Traits/SpreadableTrait.php +++ b/src/Repositories/Traits/SpreadableTrait.php @@ -24,6 +24,10 @@ protected function setColumnsSpreadableTrait($columns, $inputs) protected function beforeSaveSpreadableTrait($object, $fields) { + if (method_exists($this, 'shouldQueuePendingRevisionOnly') && $this->shouldQueuePendingRevisionOnly($object, $fields)) { + return; + } + // Get the spreadable model instance $spreadableModel = $object->spreadable()->first(); diff --git a/src/Repositories/Traits/TagsTrait.php b/src/Repositories/Traits/TagsTrait.php index f7cc78faf..67a023a1e 100755 --- a/src/Repositories/Traits/TagsTrait.php +++ b/src/Repositories/Traits/TagsTrait.php @@ -8,6 +8,12 @@ trait TagsTrait { + /** + * When true, {@see RevisionsTrait::bypassAfterSaves} may set `passAfterSaveTagsTrait` during pending-only + * revision saves so {@see afterSaveTagsTrait} is skipped. + */ + protected bool $pendingBypassRevisionTagsTrait = true; + public function setColumnsTagsTrait($columns, $inputs) { $traitName = get_class_short_name(__TRAIT__); diff --git a/src/Repositories/Traits/TranslationsTrait.php b/src/Repositories/Traits/TranslationsTrait.php index 0edf79866..c7c016905 100755 --- a/src/Repositories/Traits/TranslationsTrait.php +++ b/src/Repositories/Traits/TranslationsTrait.php @@ -90,7 +90,7 @@ public function prepareFieldsBeforeSaveTranslationsTrait($object, $fields) return [ // $attribute => ($attributeValue[$locale] ?? null), - $attribute => ($attributeValue[$locale] ?? $attributeValue ?? null), + $attribute => (isset($attributeValue[$locale]) ? $attributeValue[$locale] : null), ]; })->toArray(); } @@ -110,13 +110,36 @@ public function prepareFieldsBeforeSaveTranslationsTrait($object, $fields) */ public function getFormFieldsTranslationsTrait($object, $fields) { - unset($fields['translations']); $translatedAttributes = $object->getTranslatedAttributes(); + $slugAttributes = (method_exists($object, 'getSlugAttributes')) + ? $object->getSlugAttributes() + : []; + + // SlugsTrait may run before this trait and fill translations.{slugAttr}[locale]. Clearing translations + // below would drop that data; when slugs exist we also skip the translation row for slug attrs. + $preservedSlugTranslations = []; + if ($slugAttributes !== [] && isset($fields['translations'])) { + foreach ($slugAttributes as $attr) { + if (isset($fields['translations'][$attr])) { + $preservedSlugTranslations[$attr] = $fields['translations'][$attr]; + } + } + } + + unset($fields['translations']); + if ($object->translations != null && $translatedAttributes != null) { foreach ($object->translations as $translation) { foreach ($translatedAttributes as $attribute) { unset($fields[$attribute]); + if ($slugAttributes !== [] && in_array($attribute, $slugAttributes, true)) { + $object->loadMissing('slugs'); + if ($object->slugs !== null && $object->slugs->isNotEmpty()) { + continue; + } + } + if (array_key_exists($attribute, $this->fieldsGroups) && is_array($translation->{$attribute})) { // foreach ($this->fieldsGroups[$attribute] as $field_name) { // if (isset($translation->{$attribute}[$field_name])) { @@ -135,6 +158,10 @@ public function getFormFieldsTranslationsTrait($object, $fields) } } + foreach ($preservedSlugTranslations as $attr => $perLocale) { + $fields['translations'][$attr] = $perLocale; + } + return $fields; } diff --git a/vue/src/js/components/form/Revision.vue b/vue/src/js/components/form/Revision.vue deleted file mode 100644 index 53a1eeef7..000000000 --- a/vue/src/js/components/form/Revision.vue +++ /dev/null @@ -1,81 +0,0 @@ - - - diff --git a/vue/src/js/components/form/RevisionsList.vue b/vue/src/js/components/form/RevisionsList.vue deleted file mode 100644 index c0cab2d2e..000000000 --- a/vue/src/js/components/form/RevisionsList.vue +++ /dev/null @@ -1,240 +0,0 @@ - - - - - diff --git a/vue/src/js/components/others/RevisionConfirmDialog.vue b/vue/src/js/components/others/RevisionConfirmDialog.vue new file mode 100644 index 000000000..141bd50ae --- /dev/null +++ b/vue/src/js/components/others/RevisionConfirmDialog.vue @@ -0,0 +1,65 @@ + + + diff --git a/vue/src/js/components/others/RevisionItem.vue b/vue/src/js/components/others/RevisionItem.vue new file mode 100644 index 000000000..bd3ce6239 --- /dev/null +++ b/vue/src/js/components/others/RevisionItem.vue @@ -0,0 +1,177 @@ + + + + + diff --git a/vue/src/js/components/others/RevisionPreviewDialog.vue b/vue/src/js/components/others/RevisionPreviewDialog.vue new file mode 100644 index 000000000..6c445e4ff --- /dev/null +++ b/vue/src/js/components/others/RevisionPreviewDialog.vue @@ -0,0 +1,266 @@ + + + + + diff --git a/vue/src/js/components/others/RevisionPreviewMainPanels.vue b/vue/src/js/components/others/RevisionPreviewMainPanels.vue new file mode 100644 index 000000000..15b9324df --- /dev/null +++ b/vue/src/js/components/others/RevisionPreviewMainPanels.vue @@ -0,0 +1,235 @@ + + +