@peanut-admin/admin 0.1.0-alpha.2
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/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/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 +105 -0
- package/admin-core/src/lifecycle/tenant.ts +59 -0
- package/admin-core/src/module/contribution.ts +124 -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/index.ts +40 -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/targets.ts +128 -0
- package/admin-shell/src/theme.ts +15 -0
- package/client-core/src/index.ts +325 -0
- package/client-nuxt/src/index.ts +40 -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 +108 -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,93 @@
|
|
|
1
|
+
import { ElButton } from 'element-plus'
|
|
2
|
+
import { defineComponent, h } from 'vue'
|
|
3
|
+
import type { PropType } from 'vue'
|
|
4
|
+
|
|
5
|
+
interface StateDefaults {
|
|
6
|
+
title: string
|
|
7
|
+
message: string
|
|
8
|
+
actionLabel?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const createStateComponent = (name: string, state: string, defaults: StateDefaults) => defineComponent({
|
|
12
|
+
name,
|
|
13
|
+
props: {
|
|
14
|
+
title: { type: String, default: defaults.title },
|
|
15
|
+
message: { type: String, default: defaults.message },
|
|
16
|
+
requestId: { type: String, default: null },
|
|
17
|
+
retryAfter: { type: String as PropType<string | null>, default: null },
|
|
18
|
+
actionLabel: { type: String, default: defaults.actionLabel ?? null },
|
|
19
|
+
onAction: { type: Function as PropType<() => void>, default: null },
|
|
20
|
+
},
|
|
21
|
+
emits: {
|
|
22
|
+
action: () => true,
|
|
23
|
+
},
|
|
24
|
+
setup(props, { emit, slots }) {
|
|
25
|
+
return () => h('section', {
|
|
26
|
+
class: ['pa-state', `pa-state--${name.replace(/State$/, '').toLowerCase()}`],
|
|
27
|
+
'data-state': state,
|
|
28
|
+
role: 'status',
|
|
29
|
+
'aria-live': 'polite',
|
|
30
|
+
}, [
|
|
31
|
+
h('h2', { class: 'pa-state__title' }, props.title),
|
|
32
|
+
h('p', { class: 'pa-state__message' }, props.message),
|
|
33
|
+
props.requestId === null
|
|
34
|
+
? null
|
|
35
|
+
: h('p', { class: 'pa-state__request-id' }, `Request ID: ${props.requestId}`),
|
|
36
|
+
props.retryAfter === null
|
|
37
|
+
? null
|
|
38
|
+
: h('p', { class: 'pa-state__retry-after' }, `Retry after: ${props.retryAfter}`),
|
|
39
|
+
slots.default?.(),
|
|
40
|
+
props.actionLabel === null
|
|
41
|
+
? null
|
|
42
|
+
: h(ElButton, {
|
|
43
|
+
onClick: () => {
|
|
44
|
+
emit('action')
|
|
45
|
+
},
|
|
46
|
+
}, () => props.actionLabel),
|
|
47
|
+
])
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
export const EmptyState = createStateComponent('EmptyState', 'empty', {
|
|
52
|
+
title: 'No data',
|
|
53
|
+
message: 'There is nothing to display.',
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
export const ForbiddenState = createStateComponent('ForbiddenState', 'forbidden', {
|
|
57
|
+
title: 'Access denied',
|
|
58
|
+
message: 'You do not have permission to view this page.',
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
export const NotFoundState = createStateComponent('NotFoundState', 'not-found', {
|
|
62
|
+
title: 'Not found',
|
|
63
|
+
message: 'The requested resource is unavailable.',
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
export const ModuleUnavailableState = createStateComponent('ModuleUnavailableState', 'module-unavailable', {
|
|
67
|
+
title: 'Module unavailable',
|
|
68
|
+
message: 'This module is currently unavailable.',
|
|
69
|
+
actionLabel: 'Retry',
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
export const ConflictState = createStateComponent('ConflictState', 'conflict', {
|
|
73
|
+
title: 'Content changed',
|
|
74
|
+
message: 'Reload the latest version before continuing.',
|
|
75
|
+
actionLabel: 'Reload',
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
export const RateLimitState = createStateComponent('RateLimitState', 'rate-limit', {
|
|
79
|
+
title: 'Too many requests',
|
|
80
|
+
message: 'Wait before trying again.',
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
export const ServiceUnavailableState = createStateComponent('ServiceUnavailableState', 'service-unavailable', {
|
|
84
|
+
title: 'Service unavailable',
|
|
85
|
+
message: 'The service is temporarily unavailable.',
|
|
86
|
+
actionLabel: 'Retry',
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
export const SessionExpiredState = createStateComponent('SessionExpiredState', 'session-expired', {
|
|
90
|
+
title: 'Session expired',
|
|
91
|
+
message: 'Sign in again to continue.',
|
|
92
|
+
actionLabel: 'Sign in',
|
|
93
|
+
})
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/* eslint-disable vue/one-component-per-file */
|
|
2
|
+
|
|
3
|
+
import type { TargetCandidate, TypedTarget } from '@peanut-admin/admin/core'
|
|
4
|
+
import { ElOption, ElPagination, ElSelect } from 'element-plus'
|
|
5
|
+
import { computed, defineComponent, h } from 'vue'
|
|
6
|
+
import type { Component, PropType } from 'vue'
|
|
7
|
+
|
|
8
|
+
const SelectComponent = ElSelect as unknown as Component
|
|
9
|
+
const OptionComponent = ElOption as unknown as Component
|
|
10
|
+
const PaginationComponent = ElPagination as unknown as Component
|
|
11
|
+
|
|
12
|
+
const targetKey = (target: TypedTarget): string => JSON.stringify([
|
|
13
|
+
target.target_resource_key,
|
|
14
|
+
target.target_role,
|
|
15
|
+
target.target_id,
|
|
16
|
+
])
|
|
17
|
+
|
|
18
|
+
export const TargetSelector = defineComponent({
|
|
19
|
+
name: 'TargetSelector',
|
|
20
|
+
props: {
|
|
21
|
+
modelValue: {
|
|
22
|
+
type: Array as PropType<readonly TypedTarget[]>,
|
|
23
|
+
default: () => [],
|
|
24
|
+
},
|
|
25
|
+
candidates: {
|
|
26
|
+
type: Array as PropType<readonly TargetCandidate[]>,
|
|
27
|
+
default: () => [],
|
|
28
|
+
},
|
|
29
|
+
multiple: { type: Boolean, default: false },
|
|
30
|
+
loading: { type: Boolean, default: false },
|
|
31
|
+
disabled: { type: Boolean, default: false },
|
|
32
|
+
placeholder: { type: String, default: 'Select target' },
|
|
33
|
+
page: { type: Number, default: 1 },
|
|
34
|
+
pageSize: { type: Number, default: 20 },
|
|
35
|
+
total: { type: Number, default: 0 },
|
|
36
|
+
},
|
|
37
|
+
emits: {
|
|
38
|
+
'update:modelValue': (targets: readonly TypedTarget[]) => Array.isArray(targets),
|
|
39
|
+
search: (query: string) => typeof query === 'string',
|
|
40
|
+
'page-change': (page: number) => Number.isInteger(page) && page >= 1,
|
|
41
|
+
},
|
|
42
|
+
setup(props, { emit }) {
|
|
43
|
+
const candidateMap = computed(() => new Map(
|
|
44
|
+
props.candidates.map(candidate => [targetKey(candidate), candidate]),
|
|
45
|
+
))
|
|
46
|
+
const selectedKeys = computed(() => props.modelValue.map(targetKey))
|
|
47
|
+
const updateSelection = (value: unknown): void => {
|
|
48
|
+
const values = (Array.isArray(value) ? value : [value]).filter(
|
|
49
|
+
(candidate): candidate is string => typeof candidate === 'string',
|
|
50
|
+
)
|
|
51
|
+
const targets = values.flatMap(key => {
|
|
52
|
+
const candidate = candidateMap.value.get(key)
|
|
53
|
+
return candidate === undefined
|
|
54
|
+
? []
|
|
55
|
+
: [{
|
|
56
|
+
target_resource_key: candidate.target_resource_key,
|
|
57
|
+
target_role: candidate.target_role,
|
|
58
|
+
target_id: candidate.target_id,
|
|
59
|
+
}]
|
|
60
|
+
})
|
|
61
|
+
emit('update:modelValue', props.multiple ? targets : targets.slice(0, 1))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return () => h('div', { class: 'pa-target-selector' }, [
|
|
65
|
+
h(SelectComponent, {
|
|
66
|
+
modelValue: props.multiple ? selectedKeys.value : (selectedKeys.value[0] ?? null),
|
|
67
|
+
'onUpdate:modelValue': updateSelection,
|
|
68
|
+
multiple: props.multiple,
|
|
69
|
+
filterable: true,
|
|
70
|
+
remote: true,
|
|
71
|
+
remoteMethod: (query: string) => emit('search', query),
|
|
72
|
+
loading: props.loading,
|
|
73
|
+
disabled: props.disabled,
|
|
74
|
+
placeholder: props.placeholder,
|
|
75
|
+
class: 'pa-target-selector__select',
|
|
76
|
+
}, () => props.candidates.map(candidate => h(OptionComponent, {
|
|
77
|
+
key: targetKey(candidate),
|
|
78
|
+
value: targetKey(candidate),
|
|
79
|
+
label: candidate.label,
|
|
80
|
+
}))),
|
|
81
|
+
props.total <= props.pageSize
|
|
82
|
+
? null
|
|
83
|
+
: h(PaginationComponent, {
|
|
84
|
+
class: 'pa-target-selector__pagination',
|
|
85
|
+
currentPage: props.page,
|
|
86
|
+
pageSize: props.pageSize,
|
|
87
|
+
total: props.total,
|
|
88
|
+
layout: 'prev, pager, next',
|
|
89
|
+
'onUpdate:currentPage': (page: number) => emit('page-change', page),
|
|
90
|
+
}),
|
|
91
|
+
])
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
export type TargetScopeMode = 'zero' | 'single' | 'multiple' | 'aggregate'
|
|
96
|
+
|
|
97
|
+
export const TargetScopeSummary = defineComponent({
|
|
98
|
+
name: 'TargetScopeSummary',
|
|
99
|
+
props: {
|
|
100
|
+
mode: {
|
|
101
|
+
type: String as PropType<TargetScopeMode>,
|
|
102
|
+
required: true,
|
|
103
|
+
},
|
|
104
|
+
availableCount: { type: Number, required: true },
|
|
105
|
+
selectedCount: { type: Number, default: 0 },
|
|
106
|
+
digest: { type: String, default: null },
|
|
107
|
+
},
|
|
108
|
+
setup(props) {
|
|
109
|
+
const message = computed(() => {
|
|
110
|
+
switch (props.mode) {
|
|
111
|
+
case 'zero':
|
|
112
|
+
return 'No available targets'
|
|
113
|
+
case 'single':
|
|
114
|
+
return '1 available target'
|
|
115
|
+
case 'aggregate':
|
|
116
|
+
return `Read-only aggregate across ${props.availableCount} targets`
|
|
117
|
+
default:
|
|
118
|
+
return `${props.selectedCount} of ${props.availableCount} targets selected`
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
return () => h('div', {
|
|
123
|
+
class: ['pa-target-scope-summary', `is-${props.mode}`],
|
|
124
|
+
role: 'status',
|
|
125
|
+
'aria-label': 'Target scope',
|
|
126
|
+
}, message.value)
|
|
127
|
+
},
|
|
128
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const SHELL_THEME_TOKENS = {
|
|
2
|
+
headerHeight: '--pa-shell-header-height',
|
|
3
|
+
sidebarWidth: '--pa-shell-sidebar-width',
|
|
4
|
+
sidebarCollapsedWidth: '--pa-shell-sidebar-collapsed-width',
|
|
5
|
+
contentMaxWidth: '--pa-shell-content-max-width',
|
|
6
|
+
surfaceColor: '--pa-shell-surface-color',
|
|
7
|
+
borderColor: '--pa-shell-border-color',
|
|
8
|
+
textColor: '--pa-shell-text-color',
|
|
9
|
+
mutedTextColor: '--pa-shell-muted-text-color',
|
|
10
|
+
focusColor: '--pa-shell-focus-color',
|
|
11
|
+
} as const
|
|
12
|
+
|
|
13
|
+
export type ShellThemeToken = typeof SHELL_THEME_TOKENS[keyof typeof SHELL_THEME_TOKENS]
|
|
14
|
+
|
|
15
|
+
export type ShellSlotName = 'header' | 'sidebar' | 'breadcrumb' | 'tabs' | 'default'
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
export type ClientRequestMethod =
|
|
2
|
+
| 'DELETE'
|
|
3
|
+
| 'GET'
|
|
4
|
+
| 'HEAD'
|
|
5
|
+
| 'OPTIONS'
|
|
6
|
+
| 'PATCH'
|
|
7
|
+
| 'POST'
|
|
8
|
+
| 'PUT'
|
|
9
|
+
|
|
10
|
+
export interface ClientRequest<TData = unknown> {
|
|
11
|
+
readonly path: string
|
|
12
|
+
readonly method?: ClientRequestMethod
|
|
13
|
+
readonly data?: TData
|
|
14
|
+
readonly headers?: HeadersInit
|
|
15
|
+
readonly auth?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ClientTransportRequest<TData = unknown> {
|
|
19
|
+
readonly path: string
|
|
20
|
+
readonly method: ClientRequestMethod
|
|
21
|
+
readonly data?: TData
|
|
22
|
+
readonly headers: Headers
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ClientTransport = (request: ClientTransportRequest) => Promise<unknown>
|
|
26
|
+
|
|
27
|
+
export interface ClientSession {
|
|
28
|
+
accessToken: () => string | null | undefined
|
|
29
|
+
clear: () => void | Promise<void>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ClientDecodeSuccess<TData = unknown> {
|
|
33
|
+
readonly kind: 'success'
|
|
34
|
+
readonly data: TData
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ClientDecodeUnauthorized {
|
|
38
|
+
readonly kind: 'unauthorized'
|
|
39
|
+
readonly code?: string
|
|
40
|
+
readonly message?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ClientDecodeBusiness {
|
|
44
|
+
readonly kind: 'business'
|
|
45
|
+
readonly code?: string
|
|
46
|
+
readonly message?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type ClientDecodeResult<TData = unknown> =
|
|
50
|
+
| ClientDecodeSuccess<TData>
|
|
51
|
+
| ClientDecodeUnauthorized
|
|
52
|
+
| ClientDecodeBusiness
|
|
53
|
+
|
|
54
|
+
export type ClientDecoder<TData = unknown> = (
|
|
55
|
+
response: unknown,
|
|
56
|
+
request: ClientTransportRequest,
|
|
57
|
+
) => ClientDecodeResult<TData> | Promise<ClientDecodeResult<TData>>
|
|
58
|
+
|
|
59
|
+
export type ClientRequestErrorKind =
|
|
60
|
+
| 'business'
|
|
61
|
+
| 'decoder'
|
|
62
|
+
| 'path'
|
|
63
|
+
| 'session'
|
|
64
|
+
| 'transport'
|
|
65
|
+
| 'unauthorized'
|
|
66
|
+
|
|
67
|
+
export class ClientRequestError extends Error {
|
|
68
|
+
readonly kind: ClientRequestErrorKind
|
|
69
|
+
readonly code: string
|
|
70
|
+
|
|
71
|
+
constructor(kind: ClientRequestErrorKind, code: string, message: string) {
|
|
72
|
+
super(message)
|
|
73
|
+
this.name = 'ClientRequestError'
|
|
74
|
+
this.kind = kind
|
|
75
|
+
this.code = code
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface ClientHooks {
|
|
80
|
+
readonly unauthorized?: (error: ClientRequestError) => void | Promise<void>
|
|
81
|
+
readonly businessError?: (error: ClientRequestError) => void | Promise<void>
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ClientOptions {
|
|
85
|
+
readonly transport: ClientTransport
|
|
86
|
+
readonly session: ClientSession
|
|
87
|
+
readonly decoder: ClientDecoder
|
|
88
|
+
readonly hooks?: ClientHooks
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface Client {
|
|
92
|
+
request: <TData = unknown>(request: ClientRequest) => Promise<TData>
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const pathControlCharacters = /[\u0000-\u001f\u007f]/
|
|
96
|
+
const controlCharacters = /[\u0000-\u001f\u007f]/g
|
|
97
|
+
const encodedUnsafePathSegment = /%(?:2e|2f|5c)/i
|
|
98
|
+
const absolutePath = /^[A-Za-z][A-Za-z0-9+.-]*:/
|
|
99
|
+
const safeCode = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/
|
|
100
|
+
const defaultUnauthorizedCode = 'CLIENT_UNAUTHORIZED'
|
|
101
|
+
const defaultBusinessCode = 'CLIENT_BUSINESS_ERROR'
|
|
102
|
+
const genericUnauthorizedMessage = 'Authentication is required.'
|
|
103
|
+
const genericBusinessMessage = 'The request was rejected.'
|
|
104
|
+
|
|
105
|
+
const invalidPath = (): ClientRequestError => (
|
|
106
|
+
new ClientRequestError('path', 'CLIENT_PATH_INVALID', 'The request path is invalid.')
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => (
|
|
110
|
+
typeof value === 'object' && value !== null
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
const assertClientPath = (path: string): void => {
|
|
114
|
+
if (
|
|
115
|
+
typeof path !== 'string'
|
|
116
|
+
|| path === ''
|
|
117
|
+
|| path.trim() !== path
|
|
118
|
+
|| path.startsWith('//')
|
|
119
|
+
|| absolutePath.test(path)
|
|
120
|
+
|| path.includes('\\')
|
|
121
|
+
|| pathControlCharacters.test(path)
|
|
122
|
+
|| encodedUnsafePathSegment.test(path)
|
|
123
|
+
) {
|
|
124
|
+
throw invalidPath()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const pathname = path.split(/[?#]/u, 1)[0] ?? ''
|
|
128
|
+
if (pathname === '' || pathname.split('/').some(segment => segment === '.' || segment === '..')) {
|
|
129
|
+
throw invalidPath()
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const validBaseUrl = (baseUrl: string): URL => {
|
|
134
|
+
let url: URL
|
|
135
|
+
try {
|
|
136
|
+
url = new URL(baseUrl)
|
|
137
|
+
} catch {
|
|
138
|
+
throw new ClientRequestError('path', 'CLIENT_BASE_URL_INVALID', 'The client base URL is invalid.')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (
|
|
142
|
+
!['http:', 'https:'].includes(url.protocol)
|
|
143
|
+
|| url.username !== ''
|
|
144
|
+
|| url.password !== ''
|
|
145
|
+
|| url.search !== ''
|
|
146
|
+
|| url.hash !== ''
|
|
147
|
+
) {
|
|
148
|
+
throw new ClientRequestError('path', 'CLIENT_BASE_URL_INVALID', 'The client base URL is invalid.')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return url
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export const resolveClientUrl = (baseUrl: string, path: string): string => {
|
|
155
|
+
assertClientPath(path)
|
|
156
|
+
const base = validBaseUrl(baseUrl)
|
|
157
|
+
const basePath = base.pathname.endsWith('/') ? base.pathname : `${base.pathname}/`
|
|
158
|
+
const root = new URL(base.origin)
|
|
159
|
+
root.pathname = basePath
|
|
160
|
+
return new URL(path, root).toString()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const safeMessage = (value: unknown, fallback: string): string => {
|
|
164
|
+
if (typeof value !== 'string' || value.trim() === '') return fallback
|
|
165
|
+
const normalized = value.replace(controlCharacters, ' ').trim()
|
|
166
|
+
return normalized === '' ? fallback : normalized.slice(0, 512)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const safeErrorCode = (value: unknown, fallback: string): string => (
|
|
170
|
+
typeof value === 'string' && safeCode.test(value) ? value : fallback
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
const decodeKind = (value: unknown): string | null => {
|
|
174
|
+
if (!isRecord(value)) return null
|
|
175
|
+
const candidate = value.kind
|
|
176
|
+
return typeof candidate === 'string' ? candidate : null
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const isDecodedResult = (value: unknown): value is ClientDecodeResult => {
|
|
180
|
+
const kind = decodeKind(value)
|
|
181
|
+
if (kind === 'success') return isRecord(value) && 'data' in value
|
|
182
|
+
if (kind === 'unauthorized' || kind === 'business') return isRecord(value)
|
|
183
|
+
return false
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const normalizedDecodedResult = (value: ClientDecodeResult): ClientDecodeResult => {
|
|
187
|
+
const kind = decodeKind(value)
|
|
188
|
+
if (kind === 'success') return { kind: 'success', data: (value as ClientDecodeSuccess).data }
|
|
189
|
+
if (kind === 'unauthorized') {
|
|
190
|
+
const result = value as ClientDecodeUnauthorized
|
|
191
|
+
return {
|
|
192
|
+
kind: 'unauthorized',
|
|
193
|
+
code: safeErrorCode(result.code, defaultUnauthorizedCode),
|
|
194
|
+
message: safeMessage(result.message, genericUnauthorizedMessage),
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const result = value as ClientDecodeBusiness
|
|
199
|
+
return {
|
|
200
|
+
kind: 'business',
|
|
201
|
+
code: safeErrorCode(result.code, defaultBusinessCode),
|
|
202
|
+
message: safeMessage(result.message, genericBusinessMessage),
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const requestHeaders = (headers: HeadersInit | undefined): Headers => {
|
|
207
|
+
const result = new Headers(headers)
|
|
208
|
+
// Headers is case-insensitive and delete removes all values for this name.
|
|
209
|
+
result.delete('Authorization')
|
|
210
|
+
return result
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const methodOf = (method: ClientRequestMethod | undefined): ClientRequestMethod => (
|
|
214
|
+
(method ?? 'GET').toUpperCase() as ClientRequestMethod
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
const requestError = (kind: ClientRequestErrorKind, code: string, message: string): ClientRequestError => (
|
|
218
|
+
new ClientRequestError(kind, safeErrorCode(code, `CLIENT_${kind.toUpperCase()}_ERROR`), safeMessage(message, 'The request could not be completed.'))
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
export const createClient = (options: ClientOptions): Client => {
|
|
222
|
+
let unauthorizedHandling: Promise<void> | null = null
|
|
223
|
+
|
|
224
|
+
const invokeUnauthorized = async (error: ClientRequestError): Promise<void> => {
|
|
225
|
+
if (unauthorizedHandling === null) {
|
|
226
|
+
unauthorizedHandling = (async () => {
|
|
227
|
+
try {
|
|
228
|
+
await options.session.clear()
|
|
229
|
+
} catch {
|
|
230
|
+
throw requestError('session', 'CLIENT_SESSION_CLEAR_ERROR', 'The client session could not be cleared.')
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
await options.hooks?.unauthorized?.(error)
|
|
234
|
+
} catch {
|
|
235
|
+
// Hook failures cannot turn an unauthorized response into success.
|
|
236
|
+
}
|
|
237
|
+
})().finally(() => {
|
|
238
|
+
unauthorizedHandling = null
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
await unauthorizedHandling
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const request = async <TData = unknown>(input: ClientRequest): Promise<TData> => {
|
|
246
|
+
try {
|
|
247
|
+
assertClientPath(input.path)
|
|
248
|
+
} catch (error) {
|
|
249
|
+
if (error instanceof ClientRequestError) throw error
|
|
250
|
+
throw invalidPath()
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const method = methodOf(input.method)
|
|
254
|
+
let headers: Headers
|
|
255
|
+
try {
|
|
256
|
+
headers = requestHeaders(input.headers)
|
|
257
|
+
} catch {
|
|
258
|
+
throw requestError('path', 'CLIENT_HEADERS_INVALID', 'The request headers are invalid.')
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (input.auth !== false) {
|
|
262
|
+
let token: string | null | undefined
|
|
263
|
+
try {
|
|
264
|
+
token = options.session.accessToken()
|
|
265
|
+
} catch {
|
|
266
|
+
throw requestError('session', 'CLIENT_SESSION_ERROR', 'The client session is unavailable.')
|
|
267
|
+
}
|
|
268
|
+
if (typeof token === 'string' && token !== '') headers.set('Authorization', `Bearer ${token}`)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const transportRequest: ClientTransportRequest = {
|
|
272
|
+
path: input.path,
|
|
273
|
+
method,
|
|
274
|
+
...(input.data !== undefined ? { data: input.data } : {}),
|
|
275
|
+
headers,
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
let response: unknown
|
|
279
|
+
try {
|
|
280
|
+
response = await options.transport(transportRequest)
|
|
281
|
+
} catch {
|
|
282
|
+
throw requestError('transport', 'CLIENT_TRANSPORT_ERROR', 'The request could not be completed.')
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let decoded: ClientDecodeResult
|
|
286
|
+
try {
|
|
287
|
+
decoded = await options.decoder(response, transportRequest)
|
|
288
|
+
if (!isDecodedResult(decoded)) throw new Error('invalid decoder result')
|
|
289
|
+
decoded = normalizedDecodedResult(decoded)
|
|
290
|
+
} catch {
|
|
291
|
+
throw requestError('decoder', 'CLIENT_DECODER_INVALID', 'The response could not be understood.')
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (decoded.kind === 'success') return decoded.data as TData
|
|
295
|
+
|
|
296
|
+
if (decoded.kind === 'unauthorized') {
|
|
297
|
+
const error = new ClientRequestError(
|
|
298
|
+
'unauthorized',
|
|
299
|
+
safeErrorCode(decoded.code, defaultUnauthorizedCode),
|
|
300
|
+
safeMessage(decoded.message, genericUnauthorizedMessage),
|
|
301
|
+
)
|
|
302
|
+
await invokeUnauthorized(error)
|
|
303
|
+
throw error
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const error = new ClientRequestError(
|
|
307
|
+
'business',
|
|
308
|
+
safeErrorCode(decoded.code, defaultBusinessCode),
|
|
309
|
+
safeMessage(decoded.message, genericBusinessMessage),
|
|
310
|
+
)
|
|
311
|
+
try {
|
|
312
|
+
await options.hooks?.businessError?.(error)
|
|
313
|
+
} catch {
|
|
314
|
+
// Hook failures remain attached to this stable business failure path.
|
|
315
|
+
}
|
|
316
|
+
throw error
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return { request }
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export type ClientResult<TData = unknown> = ClientDecodeResult<TData>
|
|
323
|
+
export type ClientRequestResult<TData = unknown> = Promise<TData>
|
|
324
|
+
export type ClientHook = (error: ClientRequestError) => void | Promise<void>
|
|
325
|
+
export type ClientRequestHeaders = HeadersInit
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { resolveClientUrl } from '@peanut-admin/admin/client'
|
|
2
|
+
import type { ClientTransport, ClientTransportRequest } from '@peanut-admin/admin/client'
|
|
3
|
+
|
|
4
|
+
export interface NuxtClientFetchOptions {
|
|
5
|
+
readonly method?: string
|
|
6
|
+
readonly query?: unknown
|
|
7
|
+
readonly body?: unknown
|
|
8
|
+
readonly headers?: HeadersInit
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type NuxtClientFetch = (
|
|
12
|
+
url: string,
|
|
13
|
+
options?: NuxtClientFetchOptions,
|
|
14
|
+
) => Promise<unknown>
|
|
15
|
+
|
|
16
|
+
export interface NuxtClientTransportOptions {
|
|
17
|
+
readonly baseUrl: string
|
|
18
|
+
readonly $fetch: NuxtClientFetch
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const isQueryMethod = (method: string): boolean => method === 'GET' || method === 'DELETE'
|
|
22
|
+
|
|
23
|
+
export const createNuxtClientTransport = (
|
|
24
|
+
options: NuxtClientTransportOptions,
|
|
25
|
+
): ClientTransport => {
|
|
26
|
+
return async (request: ClientTransportRequest): Promise<unknown> => {
|
|
27
|
+
const method = request.method.toUpperCase()
|
|
28
|
+
const url = resolveClientUrl(options.baseUrl, request.path)
|
|
29
|
+
const fetchOptions: NuxtClientFetchOptions = {
|
|
30
|
+
method,
|
|
31
|
+
headers: request.headers,
|
|
32
|
+
...(request.data !== undefined
|
|
33
|
+
? isQueryMethod(method)
|
|
34
|
+
? { query: request.data }
|
|
35
|
+
: { body: request.data }
|
|
36
|
+
: {}),
|
|
37
|
+
}
|
|
38
|
+
return options.$fetch(url, fetchOptions)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { resolveClientUrl } from '@peanut-admin/admin/client'
|
|
2
|
+
import type { ClientTransport, ClientTransportRequest } from '@peanut-admin/admin/client'
|
|
3
|
+
|
|
4
|
+
export interface UniAppClientResponse {
|
|
5
|
+
readonly data: unknown
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface UniAppClientRequestOptions {
|
|
9
|
+
readonly url: string
|
|
10
|
+
readonly method: string
|
|
11
|
+
readonly data?: unknown
|
|
12
|
+
readonly header: Record<string, string>
|
|
13
|
+
readonly success?: (response: UniAppClientResponse) => void
|
|
14
|
+
readonly fail?: (error: unknown) => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type UniAppClientRequest = (
|
|
18
|
+
options: UniAppClientRequestOptions,
|
|
19
|
+
) => void
|
|
20
|
+
|
|
21
|
+
export interface UniAppClientTransportOptions {
|
|
22
|
+
readonly baseUrl: string
|
|
23
|
+
readonly request: UniAppClientRequest
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const headersRecord = (headers: Headers): Record<string, string> => {
|
|
27
|
+
const result: Record<string, string> = {}
|
|
28
|
+
headers.forEach((value, key) => {
|
|
29
|
+
result[key] = value
|
|
30
|
+
})
|
|
31
|
+
return result
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const createUniAppClientTransport = (
|
|
35
|
+
options: UniAppClientTransportOptions,
|
|
36
|
+
): ClientTransport => async (request: ClientTransportRequest): Promise<unknown> => (
|
|
37
|
+
new Promise<unknown>((resolve, reject) => {
|
|
38
|
+
const method = request.method.toUpperCase()
|
|
39
|
+
const requestOptions: UniAppClientRequestOptions = {
|
|
40
|
+
url: resolveClientUrl(options.baseUrl, request.path),
|
|
41
|
+
method,
|
|
42
|
+
...(request.data !== undefined ? { data: request.data } : {}),
|
|
43
|
+
header: headersRecord(request.headers),
|
|
44
|
+
success: response => resolve(response.data),
|
|
45
|
+
fail: error => reject(error),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
options.request(requestOptions)
|
|
49
|
+
})
|
|
50
|
+
)
|