From c58ef67dc73a0e394e1e3a5290281f6fd2fa8176 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Tue, 30 Jun 2026 18:26:57 +0200 Subject: [PATCH 1/3] feat(admin): generic per-entity Clone/Copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slugs, client_ids and audiences are immutable by design, so the way to "rename" an entity is to clone it under a new identity and re-wire the references. Add a right-click "Clone" affordance across the six admin entities: App, OAuth Client, Scope, API, Role and Group. One pattern for all: a `useClone()` composable holds a module-level stash (the fragment-routed modals only carry an `id` slot, so the prefill rides out-of-band) plus a tiny per-entity descriptor — identity fields blanked, secrets/server-issued ids dropped, everything else copied 1:1. A List stages the prefill from the full source DTO and opens the Create modal; the modal consumes it on mount and maps it through its existing fromDto. Per-entity shaping: - App: blank Slug, null catalog-entry ids (they belong to the source's streams — the clone mints fresh ids), drop the Origin override (the subdomain is globally unique). Branding/registration/grant overrides clone 1:1 via the settings tab. - OAuth Client: blank ClientId, drop the hashed secret (create mints a fresh one) + DCR audit fields + SA linkage. - Scope / API / Role / Group: blank the immutable identity (Name / aud); drop API secrets; Group's last script error is not carried over. Frontend-only — reuses the existing create endpoints, no backend change. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/frontend-vue/public/i18n/de.json | 1 + src/frontend-vue/src/composables/useClone.ts | 162 ++++++++++++++++++ .../src/views/admin/apps/AppDetails.vue | 22 ++- .../src/views/admin/apps/AppList.vue | 20 +++ .../src/views/admin/group/GroupDetails.vue | 26 ++- .../src/views/admin/group/GroupList.vue | 14 ++ .../src/views/admin/oauth/ApiDetails.vue | 10 +- .../src/views/admin/oauth/ApiList.vue | 19 ++ .../src/views/admin/oauth/ClientDetails.vue | 10 +- .../src/views/admin/oauth/ClientList.vue | 19 ++ .../src/views/admin/oauth/ScopeDetails.vue | 9 +- .../src/views/admin/oauth/ScopeList.vue | 18 ++ .../src/views/admin/role/RoleDetails.vue | 46 +++-- .../src/views/admin/role/RoleList.vue | 14 ++ 14 files changed, 367 insertions(+), 23 deletions(-) create mode 100644 src/frontend-vue/src/composables/useClone.ts diff --git a/src/frontend-vue/public/i18n/de.json b/src/frontend-vue/public/i18n/de.json index 217336f6..3042a381 100644 --- a/src/frontend-vue/public/i18n/de.json +++ b/src/frontend-vue/public/i18n/de.json @@ -11,6 +11,7 @@ "apply": "Übernehmen", "delete": "Löschen", "open": "Öffnen", + "clone": "Klonen", "close": "Schließen", "cancel": "Abbrechen", "back": "Zurück", diff --git a/src/frontend-vue/src/composables/useClone.ts b/src/frontend-vue/src/composables/useClone.ts new file mode 100644 index 00000000..e7e3adc2 --- /dev/null +++ b/src/frontend-vue/src/composables/useClone.ts @@ -0,0 +1,162 @@ +/** + * Generic "Clone / Copy" affordance for admin entities. + * + * Slugs, client_ids and audiences are immutable by design — so the way to + * "rename" an entity is to clone it under a new identity and re-point the + * references. This composable is the plumbing for that: a List stages a + * prefill (the source entity with its immutable identity blanked and its + * server-issued secrets dropped) and navigates to the entity's Create modal; + * the Create modal consumes the prefill on mount and maps it into its form. + * + * The fragment-routed modals only carry an `id` slot in the URL (`create`), + * so the prefill rides out-of-band through this module-level stash instead of + * the route. `consume()` is single-use (it clears on read) and keyed by entity + * so a stale stash can never bleed into a different entity's Create modal. + */ +import { ref } from 'vue' + +interface CloneStash { + entity: string + prefill: Record +} + +// Module-level singleton: one pending clone at a time. Staging overwrites; +// consuming clears. A normal (non-clone) Create simply finds nothing. +const pending = ref(null) + +export function useClone() { + /** Stash a prefill for the given entity, to be picked up by its Create modal. */ + function stage(entity: string, prefill: Record): void { + pending.value = { entity, prefill } + } + + /** + * Pop the staged prefill if it belongs to `entity`, else return null. Reads + * are single-use — the stash is cleared so a re-opened blank Create starts + * empty. + */ + function consume>(entity: string): T | null { + const p = pending.value + if (!p || p.entity !== entity) return null + pending.value = null + return p.prefill as T + } + + /** Drop any pending stash (e.g. on an aborted flow). */ + function clear(): void { + pending.value = null + } + + return { stage, consume, clear } +} + +/** + * Per-entity clone rule. Everything not named here is copied 1:1 from the + * source DTO; `blank` fields are reset to '' (the immutable identity the admin + * must re-enter), `drop` fields are removed entirely (secrets, server-issued + * ids), and `reshape` runs last for entity-specific shaping. + */ +export interface CloneDescriptor { + /** Identity fields reset to '' — the Create form re-validates them. */ + blank?: string[] + /** Fields removed entirely (secrets, ids belonging to the source's streams). */ + drop?: string[] + /** Final entity-specific reshape. */ + reshape?: (clone: Record) => Record +} + +/** + * Build a clone prefill from a full source DTO. The result is a detached deep + * copy shaped exactly like the source DTO (so the Create modal's existing + * `fromDto` can consume it), with identity blanked and secrets dropped. + */ +export function buildClonePrefill( + source: T, + descriptor: CloneDescriptor, +): Record { + const clone = structuredClone(source) as Record + for (const key of descriptor.blank ?? []) clone[key] = '' + for (const key of descriptor.drop ?? []) delete clone[key] + return descriptor.reshape ? descriptor.reshape(clone) : clone +} + +// ── Per-entity descriptors ────────────────────────────────────────────────── +// The `entity` string is an internal match key shared between a List's stage() +// and its Create modal's consume() — it need only be stable, not user-facing. + +export const APP_CLONE = { + entity: 'app', + descriptor: { + blank: ['Slug'], + drop: ['Id', 'IsSystem'], + reshape: (c) => { + // Catalog entries carry server-issued ids that belong to the SOURCE + // app's event streams — null them so the clone mints fresh ids under + // its own streams (role-grants / RS-subsets on the source are untouched). + if (Array.isArray(c.Permissions)) { + c.Permissions = (c.Permissions as Record[]).map((p) => ({ + ...p, + Id: null, + })) + } + // Origin (subdomain) is globally unique — the clone can't claim the + // source's. Drop just that override; the rest of the ADR-0011 settings + // clone 1:1. + if (c.Settings && typeof c.Settings === 'object') { + c.Settings = { ...(c.Settings as Record), Origin: null } + } + return c + }, + } satisfies CloneDescriptor, +} as const + +export const CLIENT_CLONE = { + entity: 'oauth-client', + descriptor: { + blank: ['ClientId'], + // Secret is hashed at rest (unclonable; create mints a fresh one). DCR + // audit fields + SA-linkage belong to the original registration only. + drop: [ + 'Id', + 'ClientSecret', + 'IsDynamicallyRegistered', + 'DcrRegisteredAt', + 'DcrRegisteredFromIp', + 'DcrLastUsedAt', + 'LinkedServiceAccountId', + ], + } satisfies CloneDescriptor, +} as const + +export const SCOPE_CLONE = { + entity: 'oauth-scope', + descriptor: { + blank: ['Name'], + drop: ['Id', 'IsStandard'], + } satisfies CloneDescriptor, +} as const + +export const API_CLONE = { + entity: 'oauth-api', + descriptor: { + blank: ['Name'], + // Secrets are server-issued; a clone starts with none. + drop: ['Id', 'Secrets'], + } satisfies CloneDescriptor, +} as const + +export const ROLE_CLONE = { + entity: 'role', + descriptor: { + blank: ['Name'], + drop: ['Id'], + } satisfies CloneDescriptor, +} as const + +export const GROUP_CLONE = { + entity: 'group', + descriptor: { + blank: ['Name'], + drop: ['Id'], + } satisfies CloneDescriptor, +} as const diff --git a/src/frontend-vue/src/views/admin/apps/AppDetails.vue b/src/frontend-vue/src/views/admin/apps/AppDetails.vue index e4c1bfad..d665c830 100644 --- a/src/frontend-vue/src/views/admin/apps/AppDetails.vue +++ b/src/frontend-vue/src/views/admin/apps/AppDetails.vue @@ -6,9 +6,11 @@ import { useI18n } from '@cocoar/vue-localization' import ModalLayout from '@/components/ModalLayout.vue' import AppSettingsSections from './AppSettingsSections.vue' import { useApplicationsStore } from '@/stores/applications.store' +import { useClone, APP_CLONE } from '@/composables/useClone' import type { ApplicationDto, ApplicationPermissionInputDto, + ApplicationSettingsDto, } from '@/models/application' const { t } = useI18n() @@ -21,6 +23,7 @@ const props = defineProps<{ const id = computed(() => props.id) const store = useApplicationsStore() +const { consume } = useClone() const isCreate = computed(() => id.value === 'create') const loading = ref(false) const error = ref(null) @@ -72,6 +75,9 @@ interface FormState { const form = ref({ Slug: '', DisplayName: '', Description: '' }) const dto = ref(null) +// In clone-create mode the source's ADR-0011 settings (minus its globally-unique +// Origin) ride here so AppSettingsSections populates from them; null otherwise. +const clonePrefillSettings = ref(null) function fromDto(d: ApplicationDto): { form: FormState; catalog: CatalogRow[] } { return { @@ -256,8 +262,18 @@ const footerButton = computed(() => ({ onMounted(async () => { if (isCreate.value) { - // Start with one empty row so a new App has somewhere to type into. - addRow() + // Clone: prefill identity + catalog (ids already nulled) + settings (Origin + // dropped) from the staged source; fall back to one empty row otherwise. + const clone = consume(APP_CLONE.entity) + if (clone) { + const parsed = fromDto(clone) + form.value = parsed.form + catalog.value = parsed.catalog + clonePrefillSettings.value = clone.Settings ?? null + } else { + // Start with one empty row so a new App has somewhere to type into. + addRow() + } return } loading.value = true @@ -415,7 +431,7 @@ async function save() {
- +

{{ error }}

diff --git a/src/frontend-vue/src/views/admin/apps/AppList.vue b/src/frontend-vue/src/views/admin/apps/AppList.vue index 7148a778..8bb5150b 100644 --- a/src/frontend-vue/src/views/admin/apps/AppList.vue +++ b/src/frontend-vue/src/views/admin/apps/AppList.vue @@ -13,6 +13,7 @@ import { useFragmentNavigation, useRoutedModals } from '@cocoar/vue-fragment-par import { useApplicationsStore } from '@/stores/applications.store' import { useUI } from '@/composables/useUI' import { useGridLocale } from '@/composables/useGridLocale' +import { useClone, buildClonePrefill, APP_CLONE } from '@/composables/useClone' import type { ApplicationDto } from '@/models/application' import GridEmptyState from '@/components/GridEmptyState.vue' @@ -20,6 +21,7 @@ const { t, language } = useI18n() const { searchPlaceholder, applyListGridDefaults } = useGridLocale() useRoutedModals() const { navigateToModal } = useFragmentNavigation() +const { stage } = useClone() const store = useApplicationsStore() const ui = useUI() @@ -84,6 +86,22 @@ const builder = applyListGridDefaults(CoarGridBuilder.create(), : t('common.no', {}, 'Nein')), ]) +// Clone: load the full source App (the list endpoint omits Settings + only the +// detail DTO carries the complete catalog), build a prefill with a blank slug + +// fresh catalog ids, then open the Create modal pre-filled. +async function cloneSelected() { + const id = selectedIds.value[0] + if (!id) return + try { + const source = await store.loadOne(id) + if (!source) return + stage(APP_CLONE.entity, buildClonePrefill(source, APP_CLONE.descriptor)) + navigateToModal('create') + } catch (e: any) { + alert(e?.message ?? String(e)) + } +} + async function deleteSelected() { const id = selectedIds.value[0] if (!id) return @@ -122,6 +140,8 @@ onMounted(() => store.initialize()) @clicked="selectedIds[0] && navigateToModal(selectedIds[0])" /> + diff --git a/src/frontend-vue/src/views/admin/group/GroupDetails.vue b/src/frontend-vue/src/views/admin/group/GroupDetails.vue index f4f0575d..c7ecf647 100644 --- a/src/frontend-vue/src/views/admin/group/GroupDetails.vue +++ b/src/frontend-vue/src/views/admin/group/GroupDetails.vue @@ -5,6 +5,7 @@ import { useRoleStore } from '@/stores/role.store' import { useUserStore } from '@/stores/user.store' import { usePrincipalStore } from '@/stores/principal.store' import { useApplicationsStore } from '@/stores/applications.store' +import { useClone, GROUP_CLONE } from '@/composables/useClone' import { CoarTextInput, CoarFormField, CoarCheckbox, CoarSelect, CoarMultiSelect, CoarTabGroup, CoarTab, CoarPopover, CoarCodeBlock, CoarIcon, CoarListbox, CoarDualListbox } from '@cocoar/vue-ui' import type { CoarListboxOption } from '@cocoar/vue-ui' import { useI18n } from '@cocoar/vue-localization' @@ -12,7 +13,7 @@ import ModalLayout from '@/components/ModalLayout.vue' import { CoarScriptEditor } from '@cocoar/vue-script-editor' import { membershipExamples, membershipPreamble } from './membershipScriptTypes' import { useScriptTypes } from './useScriptTypes' -import type { MembershipMode, EmailMode } from '@/models/group' +import type { GroupDto, MembershipMode, EmailMode } from '@/models/group' const { sharedTypeDefinitions } = useScriptTypes() const scriptExtraLibs = computed(() => [ @@ -31,6 +32,7 @@ const roleStore = useRoleStore() const userStore = useUserStore() const principalStore = usePrincipalStore() const applicationsStore = useApplicationsStore() +const { consume } = useClone() const isCreate = computed(() => props.id === 'create') const initialLoad = ref(false) const saving = ref(false) @@ -286,7 +288,27 @@ onMounted(async () => { applicationsStore.initialize(), ]) - if (!isCreate.value) { + if (isCreate.value) { + // Clone: prefill from the staged source with the Name blanked. Members, + // roles, script and BoundTo clone 1:1; the source's last script error is + // not carried over (the clone hasn't run yet). + const clone = consume(GROUP_CLONE.entity) + if (clone) { + form.value = { + Name: clone.Name ?? '', + Description: clone.Description || '', + MemberIds: [...(clone.MemberIds ?? [])], + RoleIds: [...(clone.RoleIds ?? [])], + MembershipMode: clone.MembershipMode || 'Manual', + MembershipScript: clone.MembershipScript || '', + MembershipLastError: null, + ExternallyDrivable: clone.ExternallyDrivable ?? false, + Email: clone.Email || '', + EmailMode: clone.EmailMode || 'Shared', + BoundTo: [...(clone.BoundTo ?? [])], + } + } + } else { await groupStore.initialize() const group = groupStore.groups.find(g => g.Id === props.id) if (group) { diff --git a/src/frontend-vue/src/views/admin/group/GroupList.vue b/src/frontend-vue/src/views/admin/group/GroupList.vue index a50c016d..7fd1ac0d 100644 --- a/src/frontend-vue/src/views/admin/group/GroupList.vue +++ b/src/frontend-vue/src/views/admin/group/GroupList.vue @@ -15,6 +15,7 @@ import { useAppContextStore } from '@/stores/appContext.store' import { useApplicationsStore } from '@/stores/applications.store' import { useUI } from '@/composables/useUI' import { useGridLocale } from '@/composables/useGridLocale' +import { useClone, buildClonePrefill, GROUP_CLONE } from '@/composables/useClone' import type { GroupDto } from '@/models/group' import GridEmptyState from '@/components/GridEmptyState.vue' @@ -22,6 +23,7 @@ const { t, language } = useI18n() const { searchPlaceholder, applyListGridDefaults } = useGridLocale() useRoutedModals() const { navigateToModal } = useFragmentNavigation() +const { stage } = useClone() const groupStore = useGroupStore() const appCtx = useAppContextStore() const appsStore = useApplicationsStore() @@ -94,6 +96,17 @@ async function deleteSelected() { } } +// Clone: groups load in full on the list, so prefill straight from the store — +// blank the Name; members, roles, script, BoundTo clone 1:1. +function cloneSelected() { + const id = selectedIds.value[0] + if (!id) return + const source = groupStore.groups.find((g) => g.Id === id) + if (!source) return + stage(GROUP_CLONE.entity, buildClonePrefill(source, GROUP_CLONE.descriptor)) + navigateToModal('create') +} + onMounted(() => groupStore.initialize()) @@ -117,6 +130,7 @@ onMounted(() => groupStore.initialize()) + diff --git a/src/frontend-vue/src/views/admin/oauth/ApiDetails.vue b/src/frontend-vue/src/views/admin/oauth/ApiDetails.vue index 29708e10..d485f61c 100644 --- a/src/frontend-vue/src/views/admin/oauth/ApiDetails.vue +++ b/src/frontend-vue/src/views/admin/oauth/ApiDetails.vue @@ -10,6 +10,7 @@ import ApiFormSections, { } from './ApiFormSections.vue' import { useOAuthApiStore } from '@/stores/oauthApi.store' import { useApplicationsStore } from '@/stores/applications.store' +import { useClone, API_CLONE } from '@/composables/useClone' import type { OAuthApiDto } from '@/models/oauth' const { t } = useI18n() @@ -21,6 +22,7 @@ const props = defineProps<{ const store = useOAuthApiStore() const applicationsStore = useApplicationsStore() +const { consume } = useClone() const isCreate = computed(() => props.id === 'create') // Empty value = "unassigned" — RS exists but the IdP can't resolve a @@ -105,7 +107,13 @@ const editFooterButton = computed(() => ({ onMounted(async () => { applicationsStore.initialize() - if (isCreate.value) return + if (isCreate.value) { + // Clone: prefill the wizard with the Name (the immutable aud) blanked. The + // linked App + catalog subset clone 1:1; secrets reset to none. + const clone = consume(API_CLONE.entity) + if (clone) form.value = fromDto(clone) + return + } loading.value = true try { const loaded = await store.loadOne(props.id) diff --git a/src/frontend-vue/src/views/admin/oauth/ApiList.vue b/src/frontend-vue/src/views/admin/oauth/ApiList.vue index 8c6630a2..3ce7a83a 100644 --- a/src/frontend-vue/src/views/admin/oauth/ApiList.vue +++ b/src/frontend-vue/src/views/admin/oauth/ApiList.vue @@ -14,6 +14,7 @@ import { useOAuthApiStore } from '@/stores/oauthApi.store' import { useAppContextStore } from '@/stores/appContext.store' import { useUI } from '@/composables/useUI' import { useGridLocale } from '@/composables/useGridLocale' +import { useClone, buildClonePrefill, API_CLONE } from '@/composables/useClone' import type { OAuthApiDto } from '@/models/oauth' import GridEmptyState from '@/components/GridEmptyState.vue' @@ -21,6 +22,7 @@ const { t, language } = useI18n() const { searchPlaceholder, applyListGridDefaults } = useGridLocale() useRoutedModals() const { navigateToModal } = useFragmentNavigation() +const { stage } = useClone() const store = useOAuthApiStore() const appCtx = useAppContextStore() @@ -79,6 +81,21 @@ async function deleteSelected() { try { await store.remove(id) } catch (e: any) { alert(e?.message ?? String(e)) } } +// Clone: load the full API, blank the immutable Name (the aud), open Create +// pre-filled. The linked App + its catalog subset clone 1:1; secrets reset. +async function cloneSelected() { + const id = selectedIds.value[0] + if (!id) return + try { + const source = await store.loadOne(id) + if (!source) return + stage(API_CLONE.entity, buildClonePrefill(source, API_CLONE.descriptor)) + navigateToModal('create') + } catch (e: any) { + alert(e?.message ?? String(e)) + } +} + onMounted(() => store.initialize()) @@ -106,6 +123,8 @@ onMounted(() => store.initialize()) @clicked="selectedIds[0] && navigateToModal(selectedIds[0])" /> + diff --git a/src/frontend-vue/src/views/admin/oauth/ClientDetails.vue b/src/frontend-vue/src/views/admin/oauth/ClientDetails.vue index 7c7701ec..77eb4564 100644 --- a/src/frontend-vue/src/views/admin/oauth/ClientDetails.vue +++ b/src/frontend-vue/src/views/admin/oauth/ClientDetails.vue @@ -18,6 +18,7 @@ import { useOAuthClientStore } from '@/stores/oauthClient.store' import { useOAuthScopeStore } from '@/stores/oauthScope.store' import { useApplicationsStore } from '@/stores/applications.store' import { useRealmSettingsStore } from '@/stores/realmSettings.store' +import { useClone, CLIENT_CLONE } from '@/composables/useClone' import type { OAuthClientDto, CreateOAuthClientDto, UpdateOAuthClientDto, AccessTokenType } from '@/models/oauth' const { t } = useI18n() @@ -31,6 +32,7 @@ const store = useOAuthClientStore() const scopeStore = useOAuthScopeStore() const applicationsStore = useApplicationsStore() const realmSettingsStore = useRealmSettingsStore() +const { consume } = useClone() const isCreate = computed(() => props.id === 'create' && !justCreated.value) // Genuinely-existing client opened from the list (drives the regenerate-secret // affordance) — distinct from the transient just-created state where props.id @@ -332,7 +334,13 @@ onMounted(async () => { // Realm settings gate whether the native passwordless grants appear in the // grant-type picker. Cheap singleton GET; skip if already in the store. if (!realmSettingsStore.loaded) realmSettingsStore.load().catch(() => {}) - if (isCreate.value) return + if (isCreate.value) { + // Clone: prefill the whole form (ClientId blank, secret dropped → a fresh + // one is minted on create). + const clone = consume(CLIENT_CLONE.entity) + if (clone) form.value = fromDto(clone) + return + } loading.value = true try { const dto = await store.loadOne(props.id) diff --git a/src/frontend-vue/src/views/admin/oauth/ClientList.vue b/src/frontend-vue/src/views/admin/oauth/ClientList.vue index 74d5386b..aaf49f0b 100644 --- a/src/frontend-vue/src/views/admin/oauth/ClientList.vue +++ b/src/frontend-vue/src/views/admin/oauth/ClientList.vue @@ -16,6 +16,7 @@ import { useAppContextStore } from '@/stores/appContext.store' import { useServiceAccountStore } from '@/stores/serviceAccount.store' import { useUI } from '@/composables/useUI' import { useGridLocale } from '@/composables/useGridLocale' +import { useClone, buildClonePrefill, CLIENT_CLONE } from '@/composables/useClone' import { useRouter } from 'vue-router' import type { OAuthClientDto } from '@/models/oauth' import GridEmptyState from '@/components/GridEmptyState.vue' @@ -24,6 +25,7 @@ const { t, language } = useI18n() const { searchPlaceholder, applyListGridDefaults } = useGridLocale() useRoutedModals() const { navigateToModal } = useFragmentNavigation() +const { stage } = useClone() const store = useOAuthClientStore() const appCtx = useAppContextStore() const saStore = useServiceAccountStore() @@ -115,6 +117,21 @@ async function deleteSelected() { } } +// Clone: load the full client, build a prefill with a blank client_id and the +// secret dropped (create mints a fresh one), then open the Create modal. +async function cloneSelected() { + const id = selectedIds.value[0] + if (!id) return + try { + const source = await store.loadOne(id) + if (!source) return + stage(CLIENT_CLONE.entity, buildClonePrefill(source, CLIENT_CLONE.descriptor)) + navigateToModal('create') + } catch (e: any) { + alert(e?.message ?? String(e)) + } +} + // Pre-load the SA list once so the M2M column can resolve names without // per-row HTTP fetches. SignalR keeps the store fresh from there. onMounted(async () => { @@ -172,6 +189,8 @@ function openClient(client: OAuthClientDto) { })()" /> + diff --git a/src/frontend-vue/src/views/admin/oauth/ScopeDetails.vue b/src/frontend-vue/src/views/admin/oauth/ScopeDetails.vue index dba2e1f7..6ae68414 100644 --- a/src/frontend-vue/src/views/admin/oauth/ScopeDetails.vue +++ b/src/frontend-vue/src/views/admin/oauth/ScopeDetails.vue @@ -6,6 +6,7 @@ import ModalLayout from '@/components/ModalLayout.vue' import EditableStringList from '@/components/EditableStringList.vue' import { useOAuthScopeStore } from '@/stores/oauthScope.store' import { useApplicationsStore } from '@/stores/applications.store' +import { useClone, SCOPE_CLONE } from '@/composables/useClone' import type { OAuthScopeDto } from '@/models/oauth' const { t } = useI18n() @@ -17,6 +18,7 @@ const props = defineProps<{ const store = useOAuthScopeStore() const applicationsStore = useApplicationsStore() +const { consume } = useClone() const isCreate = computed(() => props.id === 'create') // Empty value = "global" (cross-app, e.g. standard OIDC scopes). @@ -96,7 +98,12 @@ const footerButton = computed(() => ({ onMounted(async () => { applicationsStore.initialize() - if (isCreate.value) return + if (isCreate.value) { + // Clone: prefill the form with the Name (immutable) blanked. + const clone = consume(SCOPE_CLONE.entity) + if (clone) form.value = fromDto(clone) + return + } loading.value = true try { const dto = await store.loadOne(props.id) diff --git a/src/frontend-vue/src/views/admin/oauth/ScopeList.vue b/src/frontend-vue/src/views/admin/oauth/ScopeList.vue index 0771725f..b4bb2e76 100644 --- a/src/frontend-vue/src/views/admin/oauth/ScopeList.vue +++ b/src/frontend-vue/src/views/admin/oauth/ScopeList.vue @@ -14,6 +14,7 @@ import { useOAuthScopeStore } from '@/stores/oauthScope.store' import { useAppContextStore } from '@/stores/appContext.store' import { useUI } from '@/composables/useUI' import { useGridLocale } from '@/composables/useGridLocale' +import { useClone, buildClonePrefill, SCOPE_CLONE } from '@/composables/useClone' import type { OAuthScopeDto } from '@/models/oauth' import GridEmptyState from '@/components/GridEmptyState.vue' @@ -21,6 +22,7 @@ const { t, language } = useI18n() const { searchPlaceholder, applyListGridDefaults } = useGridLocale() useRoutedModals() const { navigateToModal } = useFragmentNavigation() +const { stage } = useClone() const store = useOAuthScopeStore() const appCtx = useAppContextStore() @@ -89,6 +91,20 @@ async function deleteSelected() { try { await store.remove(id) } catch (e: any) { alert(e?.message ?? String(e)) } } +// Clone: load the full scope, blank the immutable Name, open Create pre-filled. +async function cloneSelected() { + const id = selectedIds.value[0] + if (!id) return + try { + const source = await store.loadOne(id) + if (!source) return + stage(SCOPE_CLONE.entity, buildClonePrefill(source, SCOPE_CLONE.descriptor)) + navigateToModal('create') + } catch (e: any) { + alert(e?.message ?? String(e)) + } +} + onMounted(() => store.initialize()) @@ -116,6 +132,8 @@ onMounted(() => store.initialize()) @clicked="selectedIds[0] && navigateToModal(selectedIds[0])" /> + diff --git a/src/frontend-vue/src/views/admin/role/RoleDetails.vue b/src/frontend-vue/src/views/admin/role/RoleDetails.vue index 8f031e27..1c726310 100644 --- a/src/frontend-vue/src/views/admin/role/RoleDetails.vue +++ b/src/frontend-vue/src/views/admin/role/RoleDetails.vue @@ -2,9 +2,11 @@ import { ref, computed, onMounted } from 'vue' import { useRoleStore } from '@/stores/role.store' import { useApplicationsStore } from '@/stores/applications.store' +import { useClone, ROLE_CLONE } from '@/composables/useClone' import { CoarTextInput, CoarFormField, CoarCheckbox, CoarSelect, CoarNote, CoarTabGroup, CoarTab } from '@cocoar/vue-ui' import { useI18n } from '@cocoar/vue-localization' import ModalLayout from '@/components/ModalLayout.vue' +import type { RoleDto } from '@/models/role' const { t } = useI18n() @@ -15,6 +17,7 @@ const props = defineProps<{ const roleStore = useRoleStore() const applicationsStore = useApplicationsStore() +const { consume } = useClone() const isCreate = computed(() => props.id === 'create') const loading = ref(false) const activeTab = ref<'general' | 'permissions'>('general') @@ -72,23 +75,36 @@ const footerButton = computed(() => ({ onMounted(async () => { applicationsStore.initialize() - if (!isCreate.value) { - loading.value = true - try { - await roleStore.initialize() - const role = roleStore.roles.find(r => r.Id === props.id) - if (role) { - form.value = { - Name: role.Name, - Description: role.Description || '', - AppId: role.AppId ?? '', - IsRealmAdmin: role.IsRealmAdmin, - PermissionIds: new Set(role.PermissionIds ?? []), - } + if (isCreate.value) { + // Clone: prefill from the staged source with the Name blanked. The App-link + // + its catalog subset clone 1:1. + const clone = consume(ROLE_CLONE.entity) + if (clone) { + form.value = { + Name: clone.Name ?? '', + Description: clone.Description || '', + AppId: clone.AppId ?? '', + IsRealmAdmin: clone.IsRealmAdmin, + PermissionIds: new Set(clone.PermissionIds ?? []), } - } finally { - loading.value = false } + return + } + loading.value = true + try { + await roleStore.initialize() + const role = roleStore.roles.find(r => r.Id === props.id) + if (role) { + form.value = { + Name: role.Name, + Description: role.Description || '', + AppId: role.AppId ?? '', + IsRealmAdmin: role.IsRealmAdmin, + PermissionIds: new Set(role.PermissionIds ?? []), + } + } + } finally { + loading.value = false } }) diff --git a/src/frontend-vue/src/views/admin/role/RoleList.vue b/src/frontend-vue/src/views/admin/role/RoleList.vue index f0615501..6e1bde94 100644 --- a/src/frontend-vue/src/views/admin/role/RoleList.vue +++ b/src/frontend-vue/src/views/admin/role/RoleList.vue @@ -14,6 +14,7 @@ import { useRoleStore } from '@/stores/role.store' import { useAppContextStore } from '@/stores/appContext.store' import { useUI } from '@/composables/useUI' import { useGridLocale } from '@/composables/useGridLocale' +import { useClone, buildClonePrefill, ROLE_CLONE } from '@/composables/useClone' import type { RoleDto } from '@/models/role' import GridEmptyState from '@/components/GridEmptyState.vue' @@ -21,6 +22,7 @@ const { t, language } = useI18n() const { searchPlaceholder, applyListGridDefaults } = useGridLocale() useRoutedModals() const { navigateToModal } = useFragmentNavigation() +const { stage } = useClone() const roleStore = useRoleStore() const appCtx = useAppContextStore() @@ -87,6 +89,17 @@ async function deleteSelected() { } } +// Clone: roles load in full on the list, so prefill straight from the store — +// blank the Name; the App-link + catalog subset clone 1:1. +function cloneSelected() { + const id = selectedIds.value[0] + if (!id) return + const source = roleStore.roles.find((r) => r.Id === id) + if (!source) return + stage(ROLE_CLONE.entity, buildClonePrefill(source, ROLE_CLONE.descriptor)) + navigateToModal('create') +} + onMounted(() => roleStore.initialize()) @@ -111,6 +124,7 @@ onMounted(() => roleStore.initialize()) + From 05363504465c01312d17ea3af86e4f757d081864 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Tue, 30 Jun 2026 19:17:48 +0200 Subject: [PATCH 2/3] fix(admin): clone Role/Group via JSON deep-clone, not structuredClone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Role/Group clone handlers pass the Pinia store entity (a Vue reactive Proxy) straight into buildClonePrefill. structuredClone rejects a Proxy with DataCloneError, so right-click → Klonen threw on those two lists (App/Scope/API/Client went through a fresh loadOne object and happened to work). Switch buildClonePrefill to a JSON round-trip: the DTOs are pure JSON (no Dates/Sets/functions), so it is a faithful detached deep copy and serialises straight through the reactive proxy. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/frontend-vue/src/composables/useClone.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/frontend-vue/src/composables/useClone.ts b/src/frontend-vue/src/composables/useClone.ts index e7e3adc2..8af50f32 100644 --- a/src/frontend-vue/src/composables/useClone.ts +++ b/src/frontend-vue/src/composables/useClone.ts @@ -74,7 +74,12 @@ export function buildClonePrefill( source: T, descriptor: CloneDescriptor, ): Record { - const clone = structuredClone(source) as Record + // JSON round-trip rather than structuredClone: the source is often a Pinia + // store entity (a Vue reactive Proxy), which structuredClone rejects with a + // DataCloneError. The DTOs are pure JSON (no Dates / Sets / functions), so + // this is a faithful detached deep copy and serialises straight through the + // reactive proxy. + const clone = JSON.parse(JSON.stringify(source)) as Record for (const key of descriptor.blank ?? []) clone[key] = '' for (const key of descriptor.drop ?? []) delete clone[key] return descriptor.reshape ? descriptor.reshape(clone) : clone From dfd256ea47f43f7182f0f0a04daf5d87f94bf2f4 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Tue, 30 Jun 2026 19:27:37 +0200 Subject: [PATCH 3/3] docs(admin): document entity Clone + correct stale App-settings refs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a concise "Cloning" section to each admin entity page (Applications, OAuth Clients/Scopes/APIs, Roles, Groups): how to invoke (list → right-click → Clone), which immutable identity is blanked, and which secrets/server-issued fields are dropped — framing clone as the way to "rename" an entity whose identity can't be changed. While in applications.md + the admin-API reference, correct the App **settings** references that PR #108 made stale: the separate `settings/:id` modal and `GET/PATCH /api/app/{id}/settings` endpoint are gone — per-App ADR-0011 settings now ride inline on the App resource (`GET /api/app/{id}` + `POST`/`PUT /api/app`, one tenant transaction, replace semantics). A reader following the old docs would have hit a 404. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/admin/applications.md | 29 ++++++++++++++++++++++++----- docs/admin/groups.md | 4 ++++ docs/admin/oauth-apis.md | 8 ++++++++ docs/admin/oauth-clients.md | 4 ++++ docs/admin/oauth-scopes.md | 4 ++++ docs/admin/roles.md | 4 ++++ docs/reference/admin-api.md | 33 +++++++++++++++++++++------------ 7 files changed, 69 insertions(+), 17 deletions(-) diff --git a/docs/admin/applications.md b/docs/admin/applications.md index bfd03f25..d5e7d8fa 100644 --- a/docs/admin/applications.md +++ b/docs/admin/applications.md @@ -86,6 +86,23 @@ you also need to: provision a resource server - create at least one role + group that connects users to the app +## Cloning an app + +The slug is immutable, so the way to stand up a near-identical app — or to +effectively **rename** one — is to clone it. In the list, right-click a row → +**Clone**. The Create modal opens pre-filled from the source: + +- **Slug** is left blank — give the copy its own (the source's can't be reused). +- **Display name, description and the whole permission catalog** are copied. The + catalog entries are copied as *new* entries (fresh ids), so the source app's + role grants and resource-server subsets are left untouched. +- **Settings** are copied too — branding, registration, native-grant / DCR / CIMD + overrides — **except the Origin subdomain**, which is globally unique and would + collide. Set a new subdomain on the copy if it needs one. + +To rename: clone the app, give the copy the new slug, re-point the dependent +clients / scopes / APIs / roles / groups, then delete the original. + ## Provisioning the resource server Under [OAuth → APIs](./oauth-apis), create an OAuth API named after @@ -109,9 +126,9 @@ Catalog entries can be edited any time, but: ## Application settings Beyond the permission catalog, an Application can override a slice of the -realm's configuration and carry its own login experience. Open -**Settings** from the app's row in the list (the routed `settings/:id` -modal; disabled for the system apps). Everything here is **optional and +realm's configuration and carry its own login experience. Open the app +from the list and switch to the **Settings** tab (disabled for the system +apps). Everything here is **optional and sparse** — an App overrides only what you switch on; anything left off **inherits the realm** value, field by field. Clearing an override re-inherits the realm. @@ -200,8 +217,10 @@ pages (operational / GDPR), and the DCR garbage-collection interval (the GC job iterates per realm) are **not** per-app overridable — set them in [Realm settings](./realm-settings). -The same overrides are reachable over the API at -`GET`/`PATCH /api/app/{id}/settings` (`app:read` / `app:write`) — see the +An App is one resource, so these overrides ride **inline** on the app itself — +`GET /api/app/{id}` returns them, and `POST`/`PUT /api/app` write them in the +same call that creates or updates the app (`app:read` / `app:write`), in one +tenant transaction. There is no separate settings endpoint. See the [Admin API reference](../reference/admin-api#application-settings). ## Relationships to other areas diff --git a/docs/admin/groups.md b/docs/admin/groups.md index c305346a..337691ca 100644 --- a/docs/admin/groups.md +++ b/docs/admin/groups.md @@ -90,6 +90,10 @@ Tab **Roles**: pick the roles the group should carry. A group can hold roles fro Tab **Effective Members** shows the fully expanded list — direct members plus everyone reached through nested groups, with a "via" hint pointing at the first nested-group hop. Useful for sanity checks before granting a powerful role. +## Cloning a group + +To make a near-identical group, right-click it in the list → **Clone**. The Create modal opens pre-filled: members, assigned roles, the membership type (manual or the auto-membership script), email mode and the `BoundTo` app list are all copied; only the **Name** is blank. A cloned auto-group starts without the source's last-evaluation error — it re-evaluates on first save. + ## Deleting a group List → right-click → **Delete**. diff --git a/docs/admin/oauth-apis.md b/docs/admin/oauth-apis.md index f4d8637c..d9499457 100644 --- a/docs/admin/oauth-apis.md +++ b/docs/admin/oauth-apis.md @@ -128,6 +128,14 @@ Changing the linked **Application** is allowed but be careful — the RS's scope-resolution and the per-Audience `resource_access` shape immediately switch to the new app context. +## Cloning an API + +The **Name** (the audience) is immutable, so to make a near-identical +resource server, clone it. List → right-click → **Clone**. The Create +wizard opens pre-filled — display name, description, scopes, user claims, +the linked Application and its catalog subset are copied; only **Name** is +blank. API secrets are not copied; the copy starts with none. + ## Deleting List → right-click → **Delete**. Soft-deleted; the OAuth API is no diff --git a/docs/admin/oauth-clients.md b/docs/admin/oauth-clients.md index 1f3c7418..ffb3e54b 100644 --- a/docs/admin/oauth-clients.md +++ b/docs/admin/oauth-clients.md @@ -130,6 +130,10 @@ Open a client by double-click. Most fields can be edited live; **Client ID** is The **Regenerate Secret** button at the bottom rotates the client secret. Old secret stops working immediately, new one is shown once — copy it now. +## Cloning a client + +**Client ID** is immutable, so to stand up a near-identical client — or to effectively rename one — clone it. List → right-click → **Clone**. The Create modal opens pre-filled: scopes, grants, redirect URIs, app links, token lifetimes and the rest are copied; only **Client ID** is blank (enter a new one). The **client secret is not copied** — a fresh one is generated on create and shown once, exactly as for a brand-new client. DCR registration metadata and any Service-Account link are dropped, so the copy is a plain admin-created client. + ## Deleting List → right-click → **Delete**. Soft-deleted entries can still be queried for audit purposes but are excluded from the OAuth flow. diff --git a/docs/admin/oauth-scopes.md b/docs/admin/oauth-scopes.md index cccb887b..c568e2f3 100644 --- a/docs/admin/oauth-scopes.md +++ b/docs/admin/oauth-scopes.md @@ -75,6 +75,10 @@ Hiding scopes from discovery is defense-in-depth. An attacker can still try arbi In the [OAuth client](./oauth-clients) → tab **Scopes** → add the new scope to "Allowed scopes". Only then may the client include it in its authorisation request. +## Cloning a scope + +Scope **Name** is immutable, so to make a variant of an existing scope, clone it. List → right-click → **Clone**. The Create modal opens pre-filled — display name, description, resources, user claims, the app binding and all the flags are copied; only **Name** is blank. A standard OIDC scope can be cloned too — the copy is an ordinary editable scope. + ## Deleting a scope List → right-click → **Delete** (soft delete). diff --git a/docs/admin/roles.md b/docs/admin/roles.md index fc5c470d..81894b1a 100644 --- a/docs/admin/roles.md +++ b/docs/admin/roles.md @@ -102,6 +102,10 @@ modgud:authorization-group:read Fully-qualified strings (containing `:`) pass through the resolver unchanged. The seeded System Admin / User Manager / Viewer roles are built exactly this way. +## Cloning a role + +To make a variant of a role — say a tighter copy of an existing one — right-click it in the list → **Clone**. The Create modal opens pre-filled: the linked Application, the selected permission subset and the realm-admin flag are copied; only the **Name** is blank. Give the copy a new name, adjust the permission selection, and create. + ## Cross-app roles (special case) A role can also include fully-qualified permissions from **other** apps in its permissions list — for example a "Cross-App Auditor" with `modgud:auth-log:read` AND `acme-tasks:audit:read`. This works because fully-qualified permissions pass through without further filtering. diff --git a/docs/reference/admin-api.md b/docs/reference/admin-api.md index b867d280..442a92f6 100644 --- a/docs/reference/admin-api.md +++ b/docs/reference/admin-api.md @@ -209,8 +209,8 @@ behind the `realm:admin` bypass. The SPA bootstrap reads public realm/branding metadata anonymously. Realm-wide admin-owned config lives under `/api/realm-settings`; the -**per-Application** override of that config lives under -`/api/app/{id}/settings` (below). +**per-Application** override of that config rides inline on the App +resource (see [Application settings](#application-settings) below). | Method | Path | Permission | |---|---|---| @@ -224,19 +224,28 @@ the identity inputs the realm or App requires. See ## Application settings -Per-Application override of the realm defaults (ADR-0011). The -`ApplicationSettings` document is keyed by the App's id and tenant-scoped. -**Sparse** in both directions: `GET` returns only what the App overrides -(a `null` section = inherits the realm); `PATCH` replaces the provided -sections (a `null`/omitted section = no change, and within a provided -section a `null` field = inherit the realm value). Setting -`Origin.Subdomain` also writes the global host→App routing map (and clearing -it removes the route). See [Applications](/admin/applications#application-settings). +Per-Application override of the realm defaults (ADR-0011). An App is **one +resource**, so this override is not a separate endpoint — it rides **inline** +on the App as a `Settings` field, read and written in the same call as the +rest of the App (one tenant transaction). + +- **Read** — `GET /api/app/{id}` returns `Settings` next to the catalog. It is + **sparse**: only the sections the App overrides are present; a `null`/absent + section inherits the realm. (The list endpoint `GET /api/app` omits it.) +- **Write** — `POST /api/app` and `PUT /api/app/{id}` carry `Settings` as the + **complete desired override state** (a replace, not a merge): a present + section sets that override, a `null` section clears it (→ inherit the realm), + and within a section a `null` field inherits that field. Setting + `Origin.Subdomain` also writes the global host→App routing map (clearing it + removes the route). + +See [Applications](/admin/applications#application-settings). | Method | Path | Permission | |---|---|---| -| `GET` | `/api/app/{id}/settings` | `app:read` | -| `PATCH` | `/api/app/{id}/settings` | `app:write` | +| `GET` | `/api/app/{id}` | `app:read` | +| `POST` | `/api/app` | `app:write` | +| `PUT` | `/api/app/{id}` | `app:write` | The body sections are `Origin` (subdomain), `Branding`, `EmailBranding`, `SelfRegistration` (incl. the `Posture` = `Off` / `JitOnOtp` /