@peanut-admin/admin 0.1.0-alpha.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/admin-core/src/access/access.ts +27 -0
- package/admin-core/src/access/permission-policy.ts +37 -0
- package/admin-core/src/api/client.ts +200 -0
- package/admin-core/src/api/problem.ts +67 -0
- package/admin-core/src/api/refresh.ts +122 -0
- package/admin-core/src/auth/stores.ts +121 -0
- package/admin-core/src/auth/tenant-session.ts +32 -0
- package/admin-core/src/generated/api.d.ts +22106 -0
- package/admin-core/src/governance/audit.ts +56 -0
- package/admin-core/src/governance/catalog.ts +86 -0
- package/admin-core/src/governance/index.ts +26 -0
- package/admin-core/src/governance/menu.ts +63 -0
- package/admin-core/src/governance/roles.ts +144 -0
- package/admin-core/src/governance/types.ts +64 -0
- package/admin-core/src/index.ts +122 -0
- package/admin-core/src/lifecycle/tenant.ts +59 -0
- package/admin-core/src/module/contribution.ts +124 -0
- package/admin-core/src/module/plugin-contribution-policy.ts +51 -0
- package/admin-core/src/module/tenant-modules.ts +20 -0
- package/admin-core/src/runtime/config.ts +55 -0
- package/admin-core/src/runtime/errors.ts +81 -0
- package/admin-core/src/runtime/guard.ts +40 -0
- package/admin-core/src/runtime/navigation.ts +105 -0
- package/admin-core/src/runtime/overrides.ts +214 -0
- package/admin-core/src/targets/store.ts +153 -0
- package/admin-shell/src/config.ts +84 -0
- package/admin-shell/src/deployment-mode.ts +36 -0
- package/admin-shell/src/index.ts +44 -0
- package/admin-shell/src/layout.ts +332 -0
- package/admin-shell/src/overrides.ts +53 -0
- package/admin-shell/src/states.ts +93 -0
- package/admin-shell/src/tabs.ts +31 -0
- package/admin-shell/src/targets.ts +128 -0
- package/admin-shell/src/theme.ts +15 -0
- package/client-core/src/index.ts +415 -0
- package/client-nuxt/src/index.ts +48 -0
- package/client-uniapp/src/index.ts +50 -0
- package/file-media/src/FileAssetSelector.vue +117 -0
- package/file-media/src/FileMediaPage.vue +158 -0
- package/file-media/src/contracts.ts +220 -0
- package/file-media/src/index.ts +19 -0
- package/file-media/src/runtime.ts +210 -0
- package/import-export/src/ImportExportPage.vue +155 -0
- package/import-export/src/contracts.ts +96 -0
- package/import-export/src/index.ts +3 -0
- package/import-export/src/runtime.ts +128 -0
- package/integration-security/src/IntegrationSecurityPage.vue +402 -0
- package/integration-security/src/contracts.ts +171 -0
- package/integration-security/src/index.ts +3 -0
- package/integration-security/src/runtime.ts +180 -0
- package/notification-sms/src/NotificationInboxPage.vue +266 -0
- package/notification-sms/src/contracts.ts +195 -0
- package/notification-sms/src/index.ts +4 -0
- package/notification-sms/src/runtime.ts +143 -0
- package/ops-console/src/OpsConsolePage.vue +337 -0
- package/ops-console/src/contracts.ts +169 -0
- package/ops-console/src/index.ts +3 -0
- package/ops-console/src/runtime.ts +199 -0
- package/package.json +139 -0
- package/reference-codes/src/ReferenceCodesPage.vue +942 -0
- package/reference-codes/src/contracts.ts +484 -0
- package/reference-codes/src/index.ts +53 -0
- package/reference-codes/src/runtime.ts +855 -0
- package/settings/src/SettingsPage.vue +536 -0
- package/settings/src/contracts.ts +331 -0
- package/settings/src/index.ts +45 -0
- package/settings/src/runtime.ts +545 -0
- package/task-job/src/TaskJobPage.vue +120 -0
- package/task-job/src/contracts.ts +117 -0
- package/task-job/src/index.ts +2 -0
- package/task-job/src/runtime.ts +105 -0
- package/testing/src/index.ts +141 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
|
|
3
|
+
export interface TypedTarget {
|
|
4
|
+
target_resource_key: string
|
|
5
|
+
target_role: string
|
|
6
|
+
target_id: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface TypedTargetSet {
|
|
10
|
+
target_resource_key: string
|
|
11
|
+
target_role: string
|
|
12
|
+
target_ids: readonly string[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface TargetCandidate extends TypedTarget {
|
|
16
|
+
label: string
|
|
17
|
+
owner_label?: string
|
|
18
|
+
status?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface OperationTargetScope {
|
|
22
|
+
moduleKey: string
|
|
23
|
+
resourceKey: string
|
|
24
|
+
operation: string
|
|
25
|
+
targetResourceKey: string
|
|
26
|
+
targetRole: string
|
|
27
|
+
cardinality?: TargetCardinality
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type TargetCardinality = 'none' | 'one_required' | 'zero_or_one' | 'many_readable' | 'aggregate_read' | 'policy_publish' | 'bulk_write'
|
|
31
|
+
|
|
32
|
+
interface TargetEntry {
|
|
33
|
+
scope: OperationTargetScope
|
|
34
|
+
candidates: TargetCandidate[]
|
|
35
|
+
selectedIds: string[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface TargetStoreState {
|
|
39
|
+
entries: Record<string, TargetEntry>
|
|
40
|
+
generation: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const scopeKey = (scope: OperationTargetScope): string => JSON.stringify([
|
|
44
|
+
scope.moduleKey,
|
|
45
|
+
scope.resourceKey,
|
|
46
|
+
scope.operation,
|
|
47
|
+
scope.targetResourceKey,
|
|
48
|
+
scope.targetRole,
|
|
49
|
+
scope.cardinality ?? 'many_readable',
|
|
50
|
+
])
|
|
51
|
+
|
|
52
|
+
const normalizeCandidates = (
|
|
53
|
+
scope: OperationTargetScope,
|
|
54
|
+
candidates: readonly TargetCandidate[],
|
|
55
|
+
): TargetCandidate[] => {
|
|
56
|
+
const ids = new Set<string>()
|
|
57
|
+
return candidates.map(candidate => {
|
|
58
|
+
if (candidate.target_resource_key !== scope.targetResourceKey
|
|
59
|
+
|| candidate.target_role !== scope.targetRole
|
|
60
|
+
|| candidate.target_id === ''
|
|
61
|
+
|| ids.has(candidate.target_id)) {
|
|
62
|
+
throw new Error('TARGET_CANDIDATE_SCOPE_INVALID')
|
|
63
|
+
}
|
|
64
|
+
ids.add(candidate.target_id)
|
|
65
|
+
return { ...candidate }
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const useOperationTargets = defineStore('peanut-admin-operation-targets', {
|
|
70
|
+
state: (): TargetStoreState => ({ entries: {}, generation: 0 }),
|
|
71
|
+
actions: {
|
|
72
|
+
replace(scope: OperationTargetScope, candidates: readonly TargetCandidate[]): void {
|
|
73
|
+
const key = scopeKey(scope)
|
|
74
|
+
const normalized = normalizeCandidates(scope, candidates)
|
|
75
|
+
const available = new Set(normalized.map(candidate => candidate.target_id))
|
|
76
|
+
const previous = this.entries[key]?.selectedIds ?? []
|
|
77
|
+
const retained = previous.filter(id => available.has(id))
|
|
78
|
+
this.entries[key] = {
|
|
79
|
+
scope: { ...scope },
|
|
80
|
+
candidates: normalized,
|
|
81
|
+
selectedIds: normalized.length === 1 ? [normalized[0]!.target_id] : retained,
|
|
82
|
+
}
|
|
83
|
+
this.generation += 1
|
|
84
|
+
},
|
|
85
|
+
select(scope: OperationTargetScope, targets: readonly TypedTarget[]): void {
|
|
86
|
+
const key = scopeKey(scope)
|
|
87
|
+
const entry = this.entries[key]
|
|
88
|
+
if (entry === undefined) {
|
|
89
|
+
throw new Error('TARGET_SCOPE_NOT_LOADED')
|
|
90
|
+
}
|
|
91
|
+
const available = new Set(entry.candidates.map(candidate => candidate.target_id))
|
|
92
|
+
const selected = new Set<string>()
|
|
93
|
+
for (const target of targets) {
|
|
94
|
+
if (target.target_resource_key !== scope.targetResourceKey
|
|
95
|
+
|| target.target_role !== scope.targetRole
|
|
96
|
+
|| !available.has(target.target_id)) {
|
|
97
|
+
throw new Error('TARGET_SELECTION_INVALID')
|
|
98
|
+
}
|
|
99
|
+
selected.add(target.target_id)
|
|
100
|
+
}
|
|
101
|
+
const cardinality = scope.cardinality ?? 'many_readable'
|
|
102
|
+
if ((cardinality === 'none' && selected.size > 0)
|
|
103
|
+
|| (cardinality === 'one_required' && selected.size !== 1)
|
|
104
|
+
|| (cardinality === 'zero_or_one' && selected.size > 1)
|
|
105
|
+
|| cardinality === 'bulk_write') {
|
|
106
|
+
throw new Error('TARGET_SELECTION_CARDINALITY_INVALID')
|
|
107
|
+
}
|
|
108
|
+
entry.selectedIds = [...selected]
|
|
109
|
+
this.generation += 1
|
|
110
|
+
},
|
|
111
|
+
selected(scope: OperationTargetScope): TypedTarget[] {
|
|
112
|
+
const entry = this.entries[scopeKey(scope)]
|
|
113
|
+
return (entry?.selectedIds ?? []).map(targetId => ({
|
|
114
|
+
target_resource_key: scope.targetResourceKey,
|
|
115
|
+
target_role: scope.targetRole,
|
|
116
|
+
target_id: targetId,
|
|
117
|
+
}))
|
|
118
|
+
},
|
|
119
|
+
selectedSet(scope: OperationTargetScope): TypedTargetSet {
|
|
120
|
+
return {
|
|
121
|
+
target_resource_key: scope.targetResourceKey,
|
|
122
|
+
target_role: scope.targetRole,
|
|
123
|
+
target_ids: this.selected(scope).map(target => target.target_id),
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
selectionForRequest(scope: OperationTargetScope): TypedTargetSet {
|
|
127
|
+
const selection = this.selectedSet(scope)
|
|
128
|
+
const cardinality = scope.cardinality ?? 'many_readable'
|
|
129
|
+
if ((cardinality === 'one_required' && selection.target_ids.length !== 1)
|
|
130
|
+
|| (cardinality === 'zero_or_one' && selection.target_ids.length > 1)
|
|
131
|
+
|| (cardinality === 'none' && selection.target_ids.length !== 0)
|
|
132
|
+
|| cardinality === 'bulk_write') {
|
|
133
|
+
throw new Error('TARGET_SELECTION_CARDINALITY_INVALID')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return selection
|
|
137
|
+
},
|
|
138
|
+
clearScope(scope: OperationTargetScope): void {
|
|
139
|
+
delete this.entries[scopeKey(scope)]
|
|
140
|
+
this.generation += 1
|
|
141
|
+
},
|
|
142
|
+
clearModule(moduleKey: string): void {
|
|
143
|
+
this.entries = Object.fromEntries(
|
|
144
|
+
Object.entries(this.entries).filter(([, entry]) => entry.scope.moduleKey !== moduleKey),
|
|
145
|
+
)
|
|
146
|
+
this.generation += 1
|
|
147
|
+
},
|
|
148
|
+
clearAll(): void {
|
|
149
|
+
this.entries = {}
|
|
150
|
+
this.generation += 1
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export interface ShellHostConfigInput {
|
|
2
|
+
brand: {
|
|
3
|
+
name: string
|
|
4
|
+
mark: string
|
|
5
|
+
}
|
|
6
|
+
audiences: {
|
|
7
|
+
tenant: { label: string }
|
|
8
|
+
platform: { label: string }
|
|
9
|
+
}
|
|
10
|
+
commands: {
|
|
11
|
+
switchTenantLabel: string
|
|
12
|
+
logoutLabel: string
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ShellHostConfig {
|
|
17
|
+
readonly brand: Readonly<ShellHostConfigInput['brand']>
|
|
18
|
+
readonly audiences: Readonly<{
|
|
19
|
+
tenant: Readonly<ShellHostConfigInput['audiences']['tenant']>
|
|
20
|
+
platform: Readonly<ShellHostConfigInput['audiences']['platform']>
|
|
21
|
+
}>
|
|
22
|
+
readonly commands: Readonly<ShellHostConfigInput['commands']>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const assertObject = (value: unknown, path: string): Record<string, unknown> => {
|
|
26
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
27
|
+
throw new Error(`SHELL_CONFIG_INVALID:${path}`)
|
|
28
|
+
}
|
|
29
|
+
return value as Record<string, unknown>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const assertKnownFields = (value: Record<string, unknown>, allowed: readonly string[], path = ''): void => {
|
|
33
|
+
for (const key of Object.keys(value)) {
|
|
34
|
+
if (!allowed.includes(key)) {
|
|
35
|
+
throw new Error(`SHELL_CONFIG_UNKNOWN_FIELD:${path}${key}`)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const displayValue = (value: unknown, path: string, maximum: number): string => {
|
|
41
|
+
if (typeof value !== 'string') throw new Error(`SHELL_CONFIG_INVALID:${path}`)
|
|
42
|
+
const normalized = value.trim()
|
|
43
|
+
if (normalized.length === 0 || normalized.length > maximum) {
|
|
44
|
+
throw new Error(`SHELL_CONFIG_INVALID:${path}`)
|
|
45
|
+
}
|
|
46
|
+
return normalized
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const defineShellHostConfig = (input: ShellHostConfigInput): ShellHostConfig => {
|
|
50
|
+
const root = assertObject(input, 'root')
|
|
51
|
+
assertKnownFields(root, ['brand', 'audiences', 'commands'])
|
|
52
|
+
|
|
53
|
+
const brandInput = assertObject(root.brand, 'brand')
|
|
54
|
+
const audiencesInput = assertObject(root.audiences, 'audiences')
|
|
55
|
+
const tenantInput = assertObject(audiencesInput.tenant, 'audiences.tenant')
|
|
56
|
+
const platformInput = assertObject(audiencesInput.platform, 'audiences.platform')
|
|
57
|
+
const commandsInput = assertObject(root.commands, 'commands')
|
|
58
|
+
assertKnownFields(brandInput, ['name', 'mark'], 'brand.')
|
|
59
|
+
assertKnownFields(audiencesInput, ['tenant', 'platform'], 'audiences.')
|
|
60
|
+
assertKnownFields(tenantInput, ['label'], 'audiences.tenant.')
|
|
61
|
+
assertKnownFields(platformInput, ['label'], 'audiences.platform.')
|
|
62
|
+
assertKnownFields(commandsInput, ['switchTenantLabel', 'logoutLabel'], 'commands.')
|
|
63
|
+
|
|
64
|
+
const brand = Object.freeze({
|
|
65
|
+
name: displayValue(brandInput.name, 'brand.name', 120),
|
|
66
|
+
mark: displayValue(brandInput.mark, 'brand.mark', 12),
|
|
67
|
+
})
|
|
68
|
+
const tenant = Object.freeze({
|
|
69
|
+
label: displayValue(tenantInput.label, 'audiences.tenant.label', 80),
|
|
70
|
+
})
|
|
71
|
+
const platform = Object.freeze({
|
|
72
|
+
label: displayValue(platformInput.label, 'audiences.platform.label', 80),
|
|
73
|
+
})
|
|
74
|
+
const commands = Object.freeze({
|
|
75
|
+
switchTenantLabel: displayValue(commandsInput.switchTenantLabel, 'commands.switchTenantLabel', 80),
|
|
76
|
+
logoutLabel: displayValue(commandsInput.logoutLabel, 'commands.logoutLabel', 80),
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
return Object.freeze({
|
|
80
|
+
brand,
|
|
81
|
+
audiences: Object.freeze({ tenant, platform }),
|
|
82
|
+
commands,
|
|
83
|
+
})
|
|
84
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type DeploymentMode = 'standalone' | 'multi-tenant'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal router contract shared by host applications without coupling to a
|
|
5
|
+
* particular Vue Router major version.
|
|
6
|
+
*/
|
|
7
|
+
export interface DeploymentRoute {
|
|
8
|
+
meta?: {
|
|
9
|
+
controlPlane?: unknown
|
|
10
|
+
instanceTool?: boolean
|
|
11
|
+
[key: string]: unknown
|
|
12
|
+
}
|
|
13
|
+
children?: DeploymentRoute[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const deploymentMode = (value: unknown): DeploymentMode => (
|
|
17
|
+
value === 'multi-tenant' ? 'multi-tenant' : 'standalone'
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
export const allowsInstanceTools = (value: unknown): boolean => value === 'standalone'
|
|
21
|
+
|
|
22
|
+
export const routesForDeployment = <T extends DeploymentRoute>(
|
|
23
|
+
routes: T[],
|
|
24
|
+
mode: DeploymentMode,
|
|
25
|
+
instanceToolsAllowed = mode === 'standalone',
|
|
26
|
+
): T[] => routes.reduce<T[]>((visible, route) => {
|
|
27
|
+
if (mode !== 'multi-tenant' && route.meta?.controlPlane !== undefined) return visible
|
|
28
|
+
if (!instanceToolsAllowed && route.meta?.instanceTool === true) return visible
|
|
29
|
+
visible.push({
|
|
30
|
+
...route,
|
|
31
|
+
children: route.children
|
|
32
|
+
? routesForDeployment(route.children, mode, instanceToolsAllowed)
|
|
33
|
+
: route.children,
|
|
34
|
+
} as T)
|
|
35
|
+
return visible
|
|
36
|
+
}, [])
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export const ADMIN_SHELL_PACKAGE = '@peanut-admin/admin/shell' as const
|
|
2
|
+
export const ADMIN_SHELL_VERSION = '0.1.0' as const
|
|
3
|
+
|
|
4
|
+
export { defineShellHostConfig } from './config'
|
|
5
|
+
export type { ShellHostConfig, ShellHostConfigInput } from './config'
|
|
6
|
+
export {
|
|
7
|
+
AdminShell,
|
|
8
|
+
PageContent,
|
|
9
|
+
PageHeader,
|
|
10
|
+
PageToolbar,
|
|
11
|
+
PlatformShell,
|
|
12
|
+
ShellBreadcrumb,
|
|
13
|
+
ShellHeader,
|
|
14
|
+
ShellSidebar,
|
|
15
|
+
ShellTabs,
|
|
16
|
+
} from './layout'
|
|
17
|
+
export type { ShellBreadcrumbItem, ShellIdentity, ShellNavigationItem } from './layout'
|
|
18
|
+
export {
|
|
19
|
+
ConflictState,
|
|
20
|
+
EmptyState,
|
|
21
|
+
ForbiddenState,
|
|
22
|
+
ModuleUnavailableState,
|
|
23
|
+
NotFoundState,
|
|
24
|
+
RateLimitState,
|
|
25
|
+
ServiceUnavailableState,
|
|
26
|
+
SessionExpiredState,
|
|
27
|
+
} from './states'
|
|
28
|
+
export { TargetScopeSummary, TargetSelector } from './targets'
|
|
29
|
+
export type { TargetScopeMode } from './targets'
|
|
30
|
+
export { SHELL_THEME_TOKENS } from './theme'
|
|
31
|
+
export type { ShellSlotName, ShellThemeToken } from './theme'
|
|
32
|
+
export { allowsInstanceTools, deploymentMode, routesForDeployment } from './deployment-mode'
|
|
33
|
+
export type { DeploymentMode, DeploymentRoute } from './deployment-mode'
|
|
34
|
+
export { tabFromRoute } from './tabs'
|
|
35
|
+
export type { ShellTab, ShellTabRoute, ShellTabState } from './tabs'
|
|
36
|
+
export {
|
|
37
|
+
ADMIN_SHELL_OVERRIDE_SLOTS,
|
|
38
|
+
resolveWorkspaceShell,
|
|
39
|
+
WORKSPACE_SHELL_OVERRIDE_KEY,
|
|
40
|
+
} from './overrides'
|
|
41
|
+
export type {
|
|
42
|
+
AdminShellOverrideRegistry,
|
|
43
|
+
WorkspaceShellResolver,
|
|
44
|
+
} from './overrides'
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/* eslint-disable vue/one-component-per-file */
|
|
2
|
+
|
|
3
|
+
import { ElButton, ElDrawer } from 'element-plus'
|
|
4
|
+
import { defineComponent, h, onBeforeUnmount, onMounted } from 'vue'
|
|
5
|
+
import type { Component, PropType, VNodeChild } from 'vue'
|
|
6
|
+
|
|
7
|
+
import type { ShellHostConfig } from './config'
|
|
8
|
+
|
|
9
|
+
export interface ShellIdentity {
|
|
10
|
+
accountLabel: string
|
|
11
|
+
contextLabel: string
|
|
12
|
+
actorLabel: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ShellNavigationItem {
|
|
16
|
+
key: string
|
|
17
|
+
label: string
|
|
18
|
+
path: string | null
|
|
19
|
+
children: readonly ShellNavigationItem[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ShellBreadcrumbItem {
|
|
23
|
+
label: string
|
|
24
|
+
path: string | null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const shellProps = {
|
|
28
|
+
config: { type: Object as PropType<ShellHostConfig>, default: null },
|
|
29
|
+
identity: { type: Object as PropType<ShellIdentity>, default: null },
|
|
30
|
+
navigation: { type: Array as PropType<readonly ShellNavigationItem[]>, default: () => [] },
|
|
31
|
+
breadcrumbs: { type: Array as PropType<readonly ShellBreadcrumbItem[]>, default: () => [] },
|
|
32
|
+
activePath: { type: String, default: '' },
|
|
33
|
+
collapsed: { type: Boolean, default: false },
|
|
34
|
+
mobileOpen: { type: Boolean, default: false },
|
|
35
|
+
openNavigationLabel: { type: String, default: 'Open navigation' },
|
|
36
|
+
collapseNavigationLabel: { type: String, default: 'Collapse navigation' },
|
|
37
|
+
expandNavigationLabel: { type: String, default: 'Expand navigation' },
|
|
38
|
+
primaryNavigationLabel: { type: String, default: 'Primary navigation' },
|
|
39
|
+
mobileNavigationLabel: { type: String, default: 'Mobile navigation' },
|
|
40
|
+
breadcrumbLabel: { type: String, default: 'Breadcrumb' },
|
|
41
|
+
identityLabel: { type: String, default: 'Current identity' },
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const shellEmits = {
|
|
45
|
+
navigate: (path: string) => typeof path === 'string',
|
|
46
|
+
'update:collapsed': (collapsed: boolean) => typeof collapsed === 'boolean',
|
|
47
|
+
'update:mobileOpen': (open: boolean) => typeof open === 'boolean',
|
|
48
|
+
'switch-tenant': () => true,
|
|
49
|
+
logout: () => true,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const shellRouteOrigin = 'https://shell.invalid'
|
|
53
|
+
|
|
54
|
+
const trustedLocalPath = (path: string | null): path is string => {
|
|
55
|
+
if (path === null
|
|
56
|
+
|| !path.startsWith('/')
|
|
57
|
+
|| path.startsWith('//')
|
|
58
|
+
|| /[\\\u0000-\u001f\u007f]/.test(path)) return false
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
return new URL(path, shellRouteOrigin).origin === shellRouteOrigin
|
|
62
|
+
} catch {
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const ShellFrame = defineComponent({
|
|
68
|
+
name: 'ShellFrame',
|
|
69
|
+
inheritAttrs: false,
|
|
70
|
+
props: {
|
|
71
|
+
audience: {
|
|
72
|
+
type: String as () => 'tenant' | 'platform',
|
|
73
|
+
required: true,
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
setup(props, { attrs, slots }) {
|
|
77
|
+
return () => h('div', {
|
|
78
|
+
...attrs,
|
|
79
|
+
class: ['pa-shell', `pa-shell--${props.audience}`, attrs.class],
|
|
80
|
+
'data-audience': props.audience,
|
|
81
|
+
}, [
|
|
82
|
+
slots.header?.(),
|
|
83
|
+
h('div', { class: 'pa-shell__workspace' }, [
|
|
84
|
+
slots.sidebar?.(),
|
|
85
|
+
h('div', { class: 'pa-shell__main' }, [
|
|
86
|
+
slots.breadcrumb?.(),
|
|
87
|
+
slots.tabs?.(),
|
|
88
|
+
slots.default?.(),
|
|
89
|
+
]),
|
|
90
|
+
]),
|
|
91
|
+
])
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
const WorkspaceShell = defineComponent({
|
|
96
|
+
name: 'WorkspaceShell',
|
|
97
|
+
inheritAttrs: false,
|
|
98
|
+
props: {
|
|
99
|
+
...shellProps,
|
|
100
|
+
audience: {
|
|
101
|
+
type: String as PropType<'tenant' | 'platform'>,
|
|
102
|
+
required: true,
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
emits: shellEmits,
|
|
106
|
+
setup(props, { attrs, emit, slots }) {
|
|
107
|
+
const closeMobile = () => emit('update:mobileOpen', false)
|
|
108
|
+
const onKeydown = (event: KeyboardEvent) => {
|
|
109
|
+
if (event.key === 'Escape' && props.mobileOpen) closeMobile()
|
|
110
|
+
}
|
|
111
|
+
onMounted(() => document.addEventListener('keydown', onKeydown))
|
|
112
|
+
onBeforeUnmount(() => document.removeEventListener('keydown', onKeydown))
|
|
113
|
+
|
|
114
|
+
const renderNavigation = (items: readonly ShellNavigationItem[], mobile: boolean): VNodeChild[] => items.flatMap(item => {
|
|
115
|
+
const children = renderNavigation(item.children, mobile)
|
|
116
|
+
if (!trustedLocalPath(item.path)) {
|
|
117
|
+
return [h('p', { class: ['pa-shell-navigation__group', 'navigation-group'], key: item.key }, item.label), ...children]
|
|
118
|
+
}
|
|
119
|
+
return [h('a', {
|
|
120
|
+
key: item.key,
|
|
121
|
+
href: item.path,
|
|
122
|
+
class: ['pa-shell-navigation__link', 'navigation-link', { 'is-active': props.activePath === item.path }],
|
|
123
|
+
'aria-current': props.activePath === item.path ? 'page' : undefined,
|
|
124
|
+
'aria-label': item.label,
|
|
125
|
+
onClick: (event: MouseEvent) => {
|
|
126
|
+
event.preventDefault()
|
|
127
|
+
emit('navigate', item.path as string)
|
|
128
|
+
if (mobile) closeMobile()
|
|
129
|
+
},
|
|
130
|
+
}, [
|
|
131
|
+
h('span', { class: 'navigation-marker', 'aria-hidden': 'true' }),
|
|
132
|
+
h('span', item.label),
|
|
133
|
+
]), ...children]
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
return () => {
|
|
137
|
+
if (props.config === null) {
|
|
138
|
+
return h(ShellFrame, { ...attrs, audience: props.audience }, slots)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const audienceLabel = props.config.audiences[props.audience].label
|
|
142
|
+
const identity = props.identity
|
|
143
|
+
const commands = [
|
|
144
|
+
props.audience === 'tenant'
|
|
145
|
+
? h(ElButton as unknown as Component, {
|
|
146
|
+
text: true,
|
|
147
|
+
onClick: () => emit('switch-tenant'),
|
|
148
|
+
}, () => props.config?.commands.switchTenantLabel)
|
|
149
|
+
: null,
|
|
150
|
+
h(ElButton as unknown as Component, {
|
|
151
|
+
text: true,
|
|
152
|
+
onClick: () => emit('logout'),
|
|
153
|
+
}, () => props.config?.commands.logoutLabel),
|
|
154
|
+
]
|
|
155
|
+
const navigation = h('nav', {
|
|
156
|
+
class: ['pa-shell-navigation', 'workspace-navigation'],
|
|
157
|
+
'aria-label': props.primaryNavigationLabel,
|
|
158
|
+
}, renderNavigation(props.navigation, false))
|
|
159
|
+
const mobileNavigation = h('div', { class: 'pa-shell-mobile-content' }, [
|
|
160
|
+
identity === null ? null : h('div', { class: 'pa-shell-mobile-identity', 'aria-label': props.identityLabel }, [
|
|
161
|
+
h('strong', identity.actorLabel),
|
|
162
|
+
h('span', identity.contextLabel),
|
|
163
|
+
h('small', identity.accountLabel),
|
|
164
|
+
]),
|
|
165
|
+
h('nav', {
|
|
166
|
+
class: ['pa-shell-navigation', 'workspace-navigation'],
|
|
167
|
+
'aria-label': props.mobileNavigationLabel,
|
|
168
|
+
}, renderNavigation(props.navigation, true)),
|
|
169
|
+
h('div', { class: ['pa-shell-mobile-commands', 'mobile-navigation-commands'] }, commands),
|
|
170
|
+
])
|
|
171
|
+
|
|
172
|
+
return h(ShellFrame, { ...attrs, audience: props.audience }, {
|
|
173
|
+
header: () => h(ShellHeader, {}, () => [
|
|
174
|
+
h('button', {
|
|
175
|
+
type: 'button',
|
|
176
|
+
class: 'mobile-nav-trigger',
|
|
177
|
+
'aria-label': props.openNavigationLabel,
|
|
178
|
+
'aria-expanded': String(props.mobileOpen),
|
|
179
|
+
onClick: () => emit('update:mobileOpen', true),
|
|
180
|
+
}, [
|
|
181
|
+
h('span', { 'aria-hidden': 'true' }),
|
|
182
|
+
h('span', { 'aria-hidden': 'true' }),
|
|
183
|
+
h('span', { 'aria-hidden': 'true' }),
|
|
184
|
+
]),
|
|
185
|
+
h('div', { class: ['pa-shell-brand', 'shell-brand'] }, [
|
|
186
|
+
h('span', { class: ['pa-shell-brand__mark', 'brand-mark'], 'aria-hidden': 'true' }, props.config?.brand.mark),
|
|
187
|
+
h('span', {}, [h('strong', props.config?.brand.name), h('small', audienceLabel)]),
|
|
188
|
+
]),
|
|
189
|
+
identity === null ? null : h('div', {
|
|
190
|
+
class: ['pa-shell-identity', 'shell-context'],
|
|
191
|
+
'aria-label': props.identityLabel,
|
|
192
|
+
}, [
|
|
193
|
+
h('span', identity.contextLabel),
|
|
194
|
+
h('strong', identity.actorLabel),
|
|
195
|
+
h('small', identity.accountLabel),
|
|
196
|
+
]),
|
|
197
|
+
h('div', { class: ['pa-shell-commands', 'shell-commands'] }, commands),
|
|
198
|
+
]),
|
|
199
|
+
sidebar: () => h(ShellSidebar, { collapsed: props.collapsed }, () => [
|
|
200
|
+
navigation,
|
|
201
|
+
h('button', {
|
|
202
|
+
type: 'button',
|
|
203
|
+
class: ['pa-shell__collapse', 'sidebar-collapse'],
|
|
204
|
+
'aria-label': props.collapsed ? props.expandNavigationLabel : props.collapseNavigationLabel,
|
|
205
|
+
onClick: () => emit('update:collapsed', !props.collapsed),
|
|
206
|
+
}, props.collapsed ? props.expandNavigationLabel : props.collapseNavigationLabel),
|
|
207
|
+
]),
|
|
208
|
+
breadcrumb: () => h(ShellBreadcrumb, { label: props.breadcrumbLabel }, () => props.breadcrumbs.map((item, index) => (
|
|
209
|
+
trustedLocalPath(item.path)
|
|
210
|
+
? h('a', {
|
|
211
|
+
key: `${index}:${item.label}`,
|
|
212
|
+
href: item.path,
|
|
213
|
+
onClick: (event: MouseEvent) => {
|
|
214
|
+
event.preventDefault()
|
|
215
|
+
emit('navigate', item.path as string)
|
|
216
|
+
},
|
|
217
|
+
}, item.label)
|
|
218
|
+
: h('span', { key: `${index}:${item.label}`, 'aria-current': 'page' }, item.label)
|
|
219
|
+
))),
|
|
220
|
+
default: () => [
|
|
221
|
+
slots.default?.(),
|
|
222
|
+
h(ElDrawer as unknown as Component, {
|
|
223
|
+
modelValue: props.mobileOpen,
|
|
224
|
+
'onUpdate:modelValue': (open: boolean) => emit('update:mobileOpen', open),
|
|
225
|
+
title: props.config?.brand.name,
|
|
226
|
+
direction: 'ltr',
|
|
227
|
+
size: 'min(84vw, 320px)',
|
|
228
|
+
class: 'mobile-navigation-drawer',
|
|
229
|
+
closeOnPressEscape: true,
|
|
230
|
+
}, () => mobileNavigation),
|
|
231
|
+
],
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
const createAudienceShell = (name: string, audience: 'tenant' | 'platform') => defineComponent({
|
|
238
|
+
name,
|
|
239
|
+
inheritAttrs: false,
|
|
240
|
+
props: shellProps,
|
|
241
|
+
emits: shellEmits,
|
|
242
|
+
setup(props, { attrs, emit, slots }) {
|
|
243
|
+
return () => h(WorkspaceShell as Component, {
|
|
244
|
+
...attrs,
|
|
245
|
+
...props,
|
|
246
|
+
audience,
|
|
247
|
+
onNavigate: (path: string) => emit('navigate', path),
|
|
248
|
+
'onUpdate:collapsed': (collapsed: boolean) => emit('update:collapsed', collapsed),
|
|
249
|
+
'onUpdate:mobileOpen': (open: boolean) => emit('update:mobileOpen', open),
|
|
250
|
+
onSwitchTenant: () => emit('switch-tenant'),
|
|
251
|
+
onLogout: () => emit('logout'),
|
|
252
|
+
} as Record<string, unknown>, slots)
|
|
253
|
+
},
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
export const AdminShell = createAudienceShell('AdminShell', 'tenant')
|
|
257
|
+
|
|
258
|
+
export const PlatformShell = createAudienceShell('PlatformShell', 'platform')
|
|
259
|
+
|
|
260
|
+
export const ShellHeader = defineComponent({
|
|
261
|
+
name: 'ShellHeader',
|
|
262
|
+
setup(_, { slots }) {
|
|
263
|
+
return () => h('header', { class: 'pa-shell-header' }, slots.default?.())
|
|
264
|
+
},
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
export const ShellSidebar = defineComponent({
|
|
268
|
+
name: 'ShellSidebar',
|
|
269
|
+
props: {
|
|
270
|
+
label: { type: String, default: 'Primary navigation' },
|
|
271
|
+
collapsed: { type: Boolean, default: false },
|
|
272
|
+
},
|
|
273
|
+
setup(props, { slots }) {
|
|
274
|
+
return () => h('aside', {
|
|
275
|
+
class: ['pa-shell-sidebar', { 'is-collapsed': props.collapsed }],
|
|
276
|
+
'aria-label': props.label,
|
|
277
|
+
}, slots.default?.())
|
|
278
|
+
},
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
export const ShellBreadcrumb = defineComponent({
|
|
282
|
+
name: 'ShellBreadcrumb',
|
|
283
|
+
props: {
|
|
284
|
+
label: { type: String, default: 'Breadcrumb' },
|
|
285
|
+
},
|
|
286
|
+
setup(props, { slots }) {
|
|
287
|
+
return () => h('nav', { class: 'pa-shell-breadcrumb', 'aria-label': props.label }, slots.default?.())
|
|
288
|
+
},
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
export const ShellTabs = defineComponent({
|
|
292
|
+
name: 'ShellTabs',
|
|
293
|
+
props: {
|
|
294
|
+
label: { type: String, default: 'Open pages' },
|
|
295
|
+
},
|
|
296
|
+
setup(props, { slots }) {
|
|
297
|
+
return () => h('nav', { class: 'pa-shell-tabs', 'aria-label': props.label }, slots.default?.())
|
|
298
|
+
},
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
export const PageHeader = defineComponent({
|
|
302
|
+
name: 'PageHeader',
|
|
303
|
+
setup(_, { slots }) {
|
|
304
|
+
return () => h('header', { class: 'pa-page-header' }, [
|
|
305
|
+
h('div', { class: 'pa-page-header__title' }, slots.default?.()),
|
|
306
|
+
slots.actions === undefined
|
|
307
|
+
? null
|
|
308
|
+
: h('div', { class: 'pa-page-header__actions' }, slots.actions()),
|
|
309
|
+
])
|
|
310
|
+
},
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
export const PageToolbar = defineComponent({
|
|
314
|
+
name: 'PageToolbar',
|
|
315
|
+
props: {
|
|
316
|
+
label: { type: String, default: 'Page actions' },
|
|
317
|
+
},
|
|
318
|
+
setup(props, { slots }) {
|
|
319
|
+
return () => h('div', {
|
|
320
|
+
class: 'pa-page-toolbar',
|
|
321
|
+
role: 'toolbar',
|
|
322
|
+
'aria-label': props.label,
|
|
323
|
+
}, slots.default?.())
|
|
324
|
+
},
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
export const PageContent = defineComponent({
|
|
328
|
+
name: 'PageContent',
|
|
329
|
+
setup(_, { slots }) {
|
|
330
|
+
return () => h('section', { class: 'pa-page-content' }, slots.default?.())
|
|
331
|
+
},
|
|
332
|
+
})
|