@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.
Files changed (73) hide show
  1. package/LICENSE +202 -0
  2. package/admin-core/src/access/access.ts +27 -0
  3. package/admin-core/src/access/permission-policy.ts +37 -0
  4. package/admin-core/src/api/client.ts +200 -0
  5. package/admin-core/src/api/problem.ts +67 -0
  6. package/admin-core/src/api/refresh.ts +122 -0
  7. package/admin-core/src/auth/stores.ts +121 -0
  8. package/admin-core/src/auth/tenant-session.ts +32 -0
  9. package/admin-core/src/generated/api.d.ts +22106 -0
  10. package/admin-core/src/governance/audit.ts +56 -0
  11. package/admin-core/src/governance/catalog.ts +86 -0
  12. package/admin-core/src/governance/index.ts +26 -0
  13. package/admin-core/src/governance/menu.ts +63 -0
  14. package/admin-core/src/governance/roles.ts +144 -0
  15. package/admin-core/src/governance/types.ts +64 -0
  16. package/admin-core/src/index.ts +122 -0
  17. package/admin-core/src/lifecycle/tenant.ts +59 -0
  18. package/admin-core/src/module/contribution.ts +124 -0
  19. package/admin-core/src/module/plugin-contribution-policy.ts +51 -0
  20. package/admin-core/src/module/tenant-modules.ts +20 -0
  21. package/admin-core/src/runtime/config.ts +55 -0
  22. package/admin-core/src/runtime/errors.ts +81 -0
  23. package/admin-core/src/runtime/guard.ts +40 -0
  24. package/admin-core/src/runtime/navigation.ts +105 -0
  25. package/admin-core/src/runtime/overrides.ts +214 -0
  26. package/admin-core/src/targets/store.ts +153 -0
  27. package/admin-shell/src/config.ts +84 -0
  28. package/admin-shell/src/deployment-mode.ts +36 -0
  29. package/admin-shell/src/index.ts +44 -0
  30. package/admin-shell/src/layout.ts +332 -0
  31. package/admin-shell/src/overrides.ts +53 -0
  32. package/admin-shell/src/states.ts +93 -0
  33. package/admin-shell/src/tabs.ts +31 -0
  34. package/admin-shell/src/targets.ts +128 -0
  35. package/admin-shell/src/theme.ts +15 -0
  36. package/client-core/src/index.ts +415 -0
  37. package/client-nuxt/src/index.ts +48 -0
  38. package/client-uniapp/src/index.ts +50 -0
  39. package/file-media/src/FileAssetSelector.vue +117 -0
  40. package/file-media/src/FileMediaPage.vue +158 -0
  41. package/file-media/src/contracts.ts +220 -0
  42. package/file-media/src/index.ts +19 -0
  43. package/file-media/src/runtime.ts +210 -0
  44. package/import-export/src/ImportExportPage.vue +155 -0
  45. package/import-export/src/contracts.ts +96 -0
  46. package/import-export/src/index.ts +3 -0
  47. package/import-export/src/runtime.ts +128 -0
  48. package/integration-security/src/IntegrationSecurityPage.vue +402 -0
  49. package/integration-security/src/contracts.ts +171 -0
  50. package/integration-security/src/index.ts +3 -0
  51. package/integration-security/src/runtime.ts +180 -0
  52. package/notification-sms/src/NotificationInboxPage.vue +266 -0
  53. package/notification-sms/src/contracts.ts +195 -0
  54. package/notification-sms/src/index.ts +4 -0
  55. package/notification-sms/src/runtime.ts +143 -0
  56. package/ops-console/src/OpsConsolePage.vue +337 -0
  57. package/ops-console/src/contracts.ts +169 -0
  58. package/ops-console/src/index.ts +3 -0
  59. package/ops-console/src/runtime.ts +199 -0
  60. package/package.json +139 -0
  61. package/reference-codes/src/ReferenceCodesPage.vue +942 -0
  62. package/reference-codes/src/contracts.ts +484 -0
  63. package/reference-codes/src/index.ts +53 -0
  64. package/reference-codes/src/runtime.ts +855 -0
  65. package/settings/src/SettingsPage.vue +536 -0
  66. package/settings/src/contracts.ts +331 -0
  67. package/settings/src/index.ts +45 -0
  68. package/settings/src/runtime.ts +545 -0
  69. package/task-job/src/TaskJobPage.vue +120 -0
  70. package/task-job/src/contracts.ts +117 -0
  71. package/task-job/src/index.ts +2 -0
  72. package/task-job/src/runtime.ts +105 -0
  73. package/testing/src/index.ts +141 -0
@@ -0,0 +1,180 @@
1
+ import { defineAdminModule } from '@peanut-admin/admin/core'
2
+ import { inject, reactive } from 'vue'
3
+ import type { AdminModuleContribution } from '@peanut-admin/admin/core'
4
+ import type { InjectionKey } from 'vue'
5
+ import { parseAttempt, parseDelivery, parseItem, parseList, parseMachine, parsePage, parseProvisionedMachine, parseProvisionedWebhook, parseSession, parseWebhook } from './contracts'
6
+ import type { IntegrationSecurityTransport, MachineIdentity, Page, ProvisionedMachineIdentity, ProvisionedWebhookEndpoint, SessionDevice, TransportResult, WebhookAttemptRecord, WebhookDeliveryRecord, WebhookEndpoint } from './contracts'
7
+
8
+ export const INTEGRATION_SECURITY_MODULE_KEY = 'peanut.integration-security' as const
9
+ export const INTEGRATION_SECURITY_ROUTE_PATH = '/app/integration-security' as const
10
+ export const INTEGRATION_SECURITY_ROUTE_PERMISSION = 'peanut.integration-security.access' as const
11
+ export const INTEGRATION_SECURITY_READ_PERMISSIONS = [
12
+ 'peanut.integration-security.machine.read', 'peanut.integration-security.webhook.read',
13
+ 'peanut.integration-security.delivery.read', 'peanut.integration-security.session.read',
14
+ ] as const
15
+
16
+ export type RuntimeErrorCode =
17
+ | 'INTEGRATION_PERMISSION_DENIED' | 'INTEGRATION_INPUT_INVALID' | 'INTEGRATION_REVISION_CONFLICT'
18
+ | 'INTEGRATION_REQUEST_FAILED' | 'INTEGRATION_NETWORK_FAILED' | 'INTEGRATION_RESPONSE_INVALID' | 'INTEGRATION_MUTATION_FAILED'
19
+ | 'MACHINE_IDENTITY_NOT_FOUND' | 'MACHINE_TOKEN_INVALID' | 'MACHINE_TOKEN_EXPIRED' | 'MACHINE_SCOPE_DENIED'
20
+ | 'WEBHOOK_ENDPOINT_NOT_FOUND' | 'WEBHOOK_DESTINATION_DENIED' | 'WEBHOOK_SECRET_INVALID' | 'SESSION_DEVICE_NOT_FOUND'
21
+ export interface RuntimeError { readonly code: RuntimeErrorCode; readonly message: string; readonly requestId: string | null; readonly status: number | null }
22
+ export interface SurfaceState<T> { items: T[]; loading: boolean; error: RuntimeError | null }
23
+ export interface IntegrationSecurityState {
24
+ machines: SurfaceState<MachineIdentity>; webhooks: SurfaceState<WebhookEndpoint>
25
+ deliveries: SurfaceState<WebhookDeliveryRecord> & { page: number; pageSize: number; total: number }
26
+ attempts: SurfaceState<WebhookAttemptRecord> & { deliveryKey: string | null }
27
+ sessions: SurfaceState<SessionDevice>; mutating: boolean
28
+ disclosure: { kind: 'machine-token' | 'webhook-secret'; value: string } | null
29
+ }
30
+ export interface IntegrationSecurityRuntime {
31
+ readonly state: IntegrationSecurityState
32
+ load: () => Promise<void>; loadMachines: () => Promise<void>; loadWebhooks: () => Promise<void>; loadDeliveries: (page?: number) => Promise<void>; loadAttempts: (deliveryKey: string) => Promise<void>; loadSessions: () => Promise<void>
33
+ createMachine: (input: { name: string; scopes: string[]; expires_at: string | null }) => Promise<void>
34
+ rotateMachine: (identity: MachineIdentity) => Promise<void>; revokeMachine: (identity: MachineIdentity) => Promise<void>
35
+ createWebhook: (input: { name: string; url: string; events: string[] }) => Promise<void>
36
+ rotateWebhook: (endpoint: WebhookEndpoint) => Promise<void>; disableWebhook: (endpoint: WebhookEndpoint) => Promise<void>
37
+ revokeSession: (session: SessionDevice) => Promise<void>; clearDisclosure: () => void; dispose: () => void
38
+ readonly can: IntegrationSecurityPermissions
39
+ }
40
+ export interface IntegrationSecurityPermissions {
41
+ readonly canReadMachines: () => boolean; readonly canManageMachines: () => boolean
42
+ readonly canReadWebhooks: () => boolean; readonly canManageWebhooks: () => boolean
43
+ readonly canReadDeliveries: () => boolean; readonly canReadSessions: () => boolean; readonly canRevokeSession: () => boolean
44
+ }
45
+
46
+ const requestIdPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$/
47
+ const messages: Readonly<Record<RuntimeErrorCode, string>> = {
48
+ INTEGRATION_PERMISSION_DENIED: 'You do not have permission to perform this action.',
49
+ INTEGRATION_INPUT_INVALID: 'The submitted integration settings are invalid.',
50
+ INTEGRATION_REQUEST_FAILED: 'The integration security request failed.',
51
+ INTEGRATION_NETWORK_FAILED: 'The integration security service could not be reached.',
52
+ INTEGRATION_RESPONSE_INVALID: 'The integration security service returned an invalid response.',
53
+ INTEGRATION_MUTATION_FAILED: 'The requested integration security change could not be completed.',
54
+ MACHINE_IDENTITY_NOT_FOUND: 'The machine identity is unavailable.',
55
+ MACHINE_TOKEN_INVALID: 'The machine credential is invalid.',
56
+ MACHINE_TOKEN_EXPIRED: 'The machine credential has expired.',
57
+ MACHINE_SCOPE_DENIED: 'One or more machine scopes cannot be granted.',
58
+ INTEGRATION_REVISION_CONFLICT: 'This record changed. Refresh and try again.',
59
+ WEBHOOK_ENDPOINT_NOT_FOUND: 'The webhook endpoint is unavailable.',
60
+ WEBHOOK_DESTINATION_DENIED: 'The webhook destination is not allowed.',
61
+ WEBHOOK_SECRET_INVALID: 'The webhook credential is invalid.',
62
+ SESSION_DEVICE_NOT_FOUND: 'The session is unavailable.',
63
+ }
64
+ const runtimeErrorCodes = new Set<RuntimeErrorCode>(Object.keys(messages) as RuntimeErrorCode[])
65
+ const failure = (result: TransportResult): RuntimeError => {
66
+ const body = typeof result.body === 'object' && result.body !== null && !Array.isArray(result.body) ? result.body as Record<string, unknown> : {}
67
+ const code: RuntimeErrorCode = typeof body.code === 'string' && runtimeErrorCodes.has(body.code as RuntimeErrorCode) ? body.code as RuntimeErrorCode : 'INTEGRATION_REQUEST_FAILED'
68
+ const candidate = body.request_id ?? result.headers.get('X-Request-Id')
69
+ return { code, message: messages[code], requestId: typeof candidate === 'string' && requestIdPattern.test(candidate) ? candidate : null, status: result.status }
70
+ }
71
+ const localFailure = (code: RuntimeErrorCode): RuntimeError => ({ code, message: messages[code], requestId: null, status: null })
72
+
73
+ type SurfaceKey = 'machines' | 'webhooks' | 'deliveries' | 'attempts' | 'sessions'
74
+ type RequestKey = SurfaceKey | 'machine-mutation' | 'webhook-mutation' | 'session-mutation'
75
+
76
+ export const createIntegrationSecurityRuntime = (options: { readonly transport: IntegrationSecurityTransport; readonly permissions: IntegrationSecurityPermissions }): IntegrationSecurityRuntime => {
77
+ const state = reactive<IntegrationSecurityState>({
78
+ machines: { items: [], loading: false, error: null }, webhooks: { items: [], loading: false, error: null },
79
+ deliveries: { items: [], loading: false, error: null, page: 1, pageSize: 20, total: 0 },
80
+ attempts: { items: [], loading: false, error: null, deliveryKey: null },
81
+ sessions: { items: [], loading: false, error: null }, mutating: false, disclosure: null,
82
+ })
83
+ const controllers = new Map<RequestKey, AbortController>()
84
+ const epochs: Record<SurfaceKey, number> = { machines: 0, webhooks: 0, deliveries: 0, attempts: 0, sessions: 0 }
85
+ let generation = 0
86
+ const cancel = (key: RequestKey): void => { controllers.get(key)?.abort(); controllers.delete(key) }
87
+ const run = async <T>(key: RequestKey, operation: (signal: AbortSignal) => Promise<T>): Promise<T> => {
88
+ cancel(key)
89
+ const controller = new AbortController(); controllers.set(key, controller)
90
+ try { return await operation(controller.signal) } finally { if (controllers.get(key) === controller) controllers.delete(key) }
91
+ }
92
+ const loadSurface = async <T>(key: SurfaceKey, surface: SurfaceState<T>, allowed: () => boolean, request: (signal: AbortSignal) => Promise<TransportResult>, parser: (body: unknown) => T[]): Promise<void> => {
93
+ const currentGeneration = generation; const currentEpoch = ++epochs[key]
94
+ cancel(key); surface.loading = true; surface.error = null
95
+ if (!allowed()) { surface.items = []; surface.loading = false; surface.error = localFailure('INTEGRATION_PERMISSION_DENIED'); return }
96
+ let result: TransportResult
97
+ try {
98
+ result = await run(key, request)
99
+ } catch {
100
+ if (currentGeneration === generation && currentEpoch === epochs[key]) { surface.error = localFailure('INTEGRATION_NETWORK_FAILED'); surface.loading = false }
101
+ return
102
+ }
103
+ if (currentGeneration !== generation || currentEpoch !== epochs[key]) return
104
+ if (result.status !== 200) { surface.error = failure(result); surface.loading = false; return }
105
+ try { surface.items = parser(result.body) } catch { surface.error = localFailure('INTEGRATION_RESPONSE_INVALID') }
106
+ finally { if (currentGeneration === generation && currentEpoch === epochs[key]) surface.loading = false }
107
+ }
108
+ const loadMachines = () => loadSurface('machines', state.machines, options.permissions.canReadMachines, options.transport.machines, body => parseList(body, parseMachine))
109
+ const loadWebhooks = () => loadSurface('webhooks', state.webhooks, options.permissions.canReadWebhooks, options.transport.webhooks, body => parseList(body, parseWebhook))
110
+ const loadSessions = () => loadSurface('sessions', state.sessions, options.permissions.canReadSessions, options.transport.sessions, body => parseList(body, parseSession))
111
+ const loadDeliveries = async (page = state.deliveries.page): Promise<void> => {
112
+ const key: SurfaceKey = 'deliveries'; const currentGeneration = generation; const currentEpoch = ++epochs[key]
113
+ const surface = state.deliveries; cancel(key); surface.loading = true; surface.error = null
114
+ if (!options.permissions.canReadDeliveries()) { surface.items = []; surface.total = 0; surface.loading = false; surface.error = localFailure('INTEGRATION_PERMISSION_DENIED'); return }
115
+ let result: TransportResult
116
+ try {
117
+ result = await run(key, signal => options.transport.deliveries(page, surface.pageSize, signal))
118
+ } catch {
119
+ if (currentGeneration === generation && currentEpoch === epochs[key]) { surface.error = localFailure('INTEGRATION_NETWORK_FAILED'); surface.loading = false }
120
+ return
121
+ }
122
+ if (currentGeneration !== generation || currentEpoch !== epochs[key]) return
123
+ if (result.status !== 200) { surface.error = failure(result); surface.loading = false; return }
124
+ try {
125
+ const parsed: Page<WebhookDeliveryRecord> = parsePage(result.body, parseDelivery)
126
+ surface.items = parsed.items; surface.page = parsed.page; surface.pageSize = parsed.pageSize; surface.total = parsed.total
127
+ } catch { surface.error = localFailure('INTEGRATION_RESPONSE_INVALID') }
128
+ finally { if (currentGeneration === generation && currentEpoch === epochs[key]) surface.loading = false }
129
+ }
130
+ const loadAttempts = async (deliveryKey: string): Promise<void> => {
131
+ const surface = state.attempts; surface.deliveryKey = deliveryKey
132
+ await loadSurface('attempts', surface, options.permissions.canReadDeliveries, signal => options.transport.deliveryAttempts(deliveryKey, 1, 100, signal), body => parsePage(body, parseAttempt).items)
133
+ }
134
+ const mutate = async <T>(key: RequestKey, allowed: () => boolean, request: (signal: AbortSignal) => Promise<TransportResult>, parser: (body: unknown) => T, after: (value: T) => Promise<void>, surface: SurfaceState<unknown>): Promise<void> => {
135
+ if (!allowed()) { surface.error = localFailure('INTEGRATION_PERMISSION_DENIED'); return }
136
+ if (state.mutating) return
137
+ const current = generation; state.mutating = true; surface.error = null; state.disclosure = null
138
+ let result: TransportResult
139
+ try {
140
+ result = await run(key, request)
141
+ } catch {
142
+ if (current === generation) { surface.error = localFailure('INTEGRATION_NETWORK_FAILED'); state.mutating = false }
143
+ return
144
+ }
145
+ if (current !== generation) return
146
+ if (result.status < 200 || result.status >= 300) { surface.error = failure(result); state.mutating = false; return }
147
+ let value: T
148
+ try { value = parser(result.body) } catch { surface.error = localFailure('INTEGRATION_RESPONSE_INVALID'); state.mutating = false; return }
149
+ try { await after(value) } catch { if (current === generation) surface.error = localFailure('INTEGRATION_MUTATION_FAILED') }
150
+ finally { if (current === generation) state.mutating = false }
151
+ }
152
+ return {
153
+ state, can: options.permissions, load: async () => { await Promise.allSettled([loadMachines(), loadWebhooks(), loadDeliveries(), loadSessions()]) },
154
+ loadMachines, loadWebhooks, loadDeliveries, loadAttempts, loadSessions,
155
+ createMachine: input => mutate('machine-mutation', options.permissions.canManageMachines, signal => options.transport.createMachine(input, signal), body => parseItem(body, value => { try { return parseProvisionedMachine(value) } catch { return parseMachine(value) } }), async (value: ProvisionedMachineIdentity | MachineIdentity) => { if ('token' in value) state.disclosure = { kind: 'machine-token', value: value.token }; await loadMachines() }, state.machines),
156
+ rotateMachine: identity => mutate('machine-mutation', options.permissions.canManageMachines, signal => options.transport.rotateMachine(identity.identityKey, identity.revision, signal), body => parseItem(body, value => { try { return parseProvisionedMachine(value) } catch { return parseMachine(value) } }), async (value: ProvisionedMachineIdentity | MachineIdentity) => { if ('token' in value) state.disclosure = { kind: 'machine-token', value: value.token }; await loadMachines() }, state.machines),
157
+ revokeMachine: identity => mutate('machine-mutation', options.permissions.canManageMachines, signal => options.transport.revokeMachine(identity.identityKey, identity.revision, signal), body => parseItem(body, parseMachine), async () => loadMachines(), state.machines),
158
+ createWebhook: input => mutate('webhook-mutation', options.permissions.canManageWebhooks, signal => options.transport.createWebhook(input, signal), body => parseItem(body, value => { try { return parseProvisionedWebhook(value) } catch { return parseWebhook(value) } }), async (value: ProvisionedWebhookEndpoint | WebhookEndpoint) => { if ('signingSecret' in value) state.disclosure = { kind: 'webhook-secret', value: value.signingSecret }; await loadWebhooks() }, state.webhooks),
159
+ rotateWebhook: endpoint => mutate('webhook-mutation', options.permissions.canManageWebhooks, signal => options.transport.rotateWebhook(endpoint.endpointKey, endpoint.revision, signal), body => parseItem(body, value => { try { return parseProvisionedWebhook(value) } catch { return parseWebhook(value) } }), async (value: ProvisionedWebhookEndpoint | WebhookEndpoint) => { if ('signingSecret' in value) state.disclosure = { kind: 'webhook-secret', value: value.signingSecret }; await loadWebhooks() }, state.webhooks),
160
+ disableWebhook: endpoint => mutate('webhook-mutation', options.permissions.canManageWebhooks, signal => options.transport.disableWebhook(endpoint.endpointKey, endpoint.revision, signal), body => parseItem(body, parseWebhook), async () => loadWebhooks(), state.webhooks),
161
+ revokeSession: session => mutate('session-mutation', options.permissions.canRevokeSession, signal => options.transport.revokeSession(session.sessionKey, signal), body => parseItem(body, parseSession), async () => loadSessions(), state.sessions),
162
+ clearDisclosure: () => { state.disclosure = null },
163
+ dispose() {
164
+ generation += 1; for (const key of Object.keys(epochs) as SurfaceKey[]) epochs[key] += 1
165
+ for (const controller of controllers.values()) controller.abort(); controllers.clear()
166
+ state.machines.items = []; state.webhooks.items = []; state.deliveries.items = []; state.attempts.items = []; state.sessions.items = []
167
+ state.machines.loading = state.webhooks.loading = state.deliveries.loading = state.attempts.loading = state.sessions.loading = false
168
+ state.machines.error = state.webhooks.error = state.deliveries.error = state.attempts.error = state.sessions.error = null
169
+ state.attempts.deliveryKey = null
170
+ state.deliveries.total = 0; state.deliveries.page = 1; state.mutating = false; state.disclosure = null
171
+ },
172
+ }
173
+ }
174
+ export const integrationSecurityRuntimeKey: InjectionKey<IntegrationSecurityRuntime> = Symbol('peanut.integration-security.runtime')
175
+ export const useIntegrationSecurityRuntime = (): IntegrationSecurityRuntime => { const runtime = inject(integrationSecurityRuntimeKey); if (runtime === undefined) throw new Error('INTEGRATION_SECURITY_RUNTIME_MISSING'); return runtime }
176
+ export const createIntegrationSecurityModuleContribution = (runtime: IntegrationSecurityRuntime): AdminModuleContribution => defineAdminModule({
177
+ key: INTEGRATION_SECURITY_MODULE_KEY,
178
+ routes: [{ name: 'peanut.integration-security.index', path: INTEGRATION_SECURITY_ROUTE_PATH, component: async () => ({ default: (await import('./IntegrationSecurityPage.vue')).default }), access: { moduleKey: INTEGRATION_SECURITY_MODULE_KEY, permissionKeys: [INTEGRATION_SECURITY_ROUTE_PERMISSION] } }],
179
+ disposeOnTenantChange: true, stores: [{ key: 'peanut.integration-security.runtime', dispose: runtime.dispose }],
180
+ })
@@ -0,0 +1,266 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ EmptyState,
4
+ ForbiddenState,
5
+ ModuleUnavailableState,
6
+ PageContent,
7
+ PageHeader,
8
+ PageToolbar,
9
+ SessionExpiredState,
10
+ } from '@peanut-admin/admin/shell'
11
+ import { ElButton, ElCheckbox, ElTag } from 'element-plus'
12
+ import { onMounted } from 'vue'
13
+
14
+ import type { NotificationFilter, NotificationMessage } from './contracts'
15
+ import { useNotificationRuntime } from './runtime'
16
+
17
+ const runtime = useNotificationRuntime()
18
+ const state = runtime.state
19
+ const filters: readonly { label: string; value: NotificationFilter }[] = [
20
+ { label: 'All', value: 'all' },
21
+ { label: 'Unread', value: 'unread' },
22
+ { label: 'Read', value: 'read' },
23
+ { label: 'Archived', value: 'archived' },
24
+ ]
25
+
26
+ const run = async (operation: () => Promise<void>): Promise<void> => {
27
+ try { await operation() } catch { return }
28
+ }
29
+ const statusType = (message: NotificationMessage): 'primary' | 'success' | 'info' => (
30
+ message.status === 'unread' ? 'primary' : message.status === 'read' ? 'success' : 'info'
31
+ )
32
+
33
+ onMounted(() => run(runtime.load))
34
+ </script>
35
+
36
+ <template>
37
+ <PageContent class="notification-inbox-page">
38
+ <PageHeader>
39
+ Notifications
40
+ <template #actions>
41
+ <ElButton
42
+ aria-label="Reload notifications"
43
+ :disabled="state.mutating"
44
+ :loading="state.loading"
45
+ @click="run(runtime.load)"
46
+ >
47
+ Reload
48
+ </ElButton>
49
+ </template>
50
+ </PageHeader>
51
+
52
+ <PageToolbar
53
+ v-if="!state.error"
54
+ label="Notification controls"
55
+ >
56
+ <div class="notification-toolbar">
57
+ <div
58
+ class="notification-filters"
59
+ role="group"
60
+ aria-label="Inbox status"
61
+ >
62
+ <ElButton
63
+ v-for="filter in filters"
64
+ :key="filter.value"
65
+ :aria-pressed="state.status === filter.value"
66
+ :disabled="state.loading || state.mutating"
67
+ :type="state.status === filter.value ? 'primary' : 'default'"
68
+ @click="run(() => runtime.setStatus(filter.value))"
69
+ >
70
+ {{ filter.label }}
71
+ </ElButton>
72
+ </div>
73
+ <div class="notification-actions">
74
+ <span>{{ state.selected.size }} selected</span>
75
+ <ElButton
76
+ :disabled="state.selected.size === 0 || state.mutating"
77
+ @click="run(() => runtime.bulk('read'))"
78
+ >
79
+ Mark read
80
+ </ElButton>
81
+ <ElButton
82
+ :disabled="state.selected.size === 0 || state.mutating"
83
+ @click="run(() => runtime.bulk('archive'))"
84
+ >
85
+ Archive
86
+ </ElButton>
87
+ </div>
88
+ </div>
89
+ </PageToolbar>
90
+
91
+ <SessionExpiredState
92
+ v-if="state.error?.status === 401"
93
+ :message="state.error.message"
94
+ v-bind="state.error.requestId === null ? {} : { requestId: state.error.requestId }"
95
+ />
96
+ <ForbiddenState
97
+ v-else-if="state.error?.status === 403"
98
+ :message="state.error.message"
99
+ v-bind="state.error.requestId === null ? {} : { requestId: state.error.requestId }"
100
+ />
101
+ <ModuleUnavailableState
102
+ v-else-if="state.error?.status === 503"
103
+ :message="state.error.message"
104
+ v-bind="state.error.requestId === null ? {} : { requestId: state.error.requestId }"
105
+ @action="run(runtime.load)"
106
+ />
107
+ <section
108
+ v-else-if="state.error"
109
+ class="notification-state"
110
+ role="alert"
111
+ >
112
+ <h2>Unable to load notifications</h2>
113
+ <p>{{ state.error.message }}</p>
114
+ <ElButton @click="run(runtime.load)">
115
+ Retry
116
+ </ElButton>
117
+ </section>
118
+ <div
119
+ v-else-if="state.loading"
120
+ class="notification-state"
121
+ role="status"
122
+ aria-live="polite"
123
+ >
124
+ Loading notifications...
125
+ </div>
126
+ <EmptyState
127
+ v-else-if="state.items.length === 0"
128
+ title="No notifications"
129
+ message="No messages match the selected inbox status."
130
+ />
131
+ <section
132
+ v-else
133
+ class="notification-list"
134
+ aria-label="Notification inbox"
135
+ >
136
+ <article
137
+ v-for="message in state.items"
138
+ :key="message.messageKey"
139
+ class="notification-item"
140
+ :class="{ 'notification-item--unread': message.status === 'unread' }"
141
+ >
142
+ <ElCheckbox
143
+ :aria-label="`Select ${message.subject}`"
144
+ :disabled="state.mutating"
145
+ :model-value="state.selected.has(message.messageKey)"
146
+ @update:model-value="runtime.toggle(message.messageKey)"
147
+ />
148
+ <div class="notification-item__content">
149
+ <div class="notification-item__heading">
150
+ <h2>{{ message.subject }}</h2>
151
+ <ElTag :type="statusType(message)">
152
+ {{ message.status }}
153
+ </ElTag>
154
+ </div>
155
+ <p>{{ message.body }}</p>
156
+ <ul
157
+ v-if="message.attachments.length > 0"
158
+ class="notification-attachments"
159
+ aria-label="Attachments"
160
+ >
161
+ <li
162
+ v-for="attachment in message.attachments"
163
+ :key="attachment.fileKey"
164
+ >
165
+ {{ attachment.originalName }} - {{ attachment.mediaType }}
166
+ </li>
167
+ </ul>
168
+ <div class="notification-item__footer">
169
+ <time :datetime="message.createdAt">{{ message.createdAt }}</time>
170
+ <ElButton
171
+ v-if="message.status === 'unread'"
172
+ text
173
+ :disabled="state.mutating"
174
+ @click="run(() => runtime.markRead(message))"
175
+ >
176
+ Mark read
177
+ </ElButton>
178
+ </div>
179
+ </div>
180
+ </article>
181
+ </section>
182
+ </PageContent>
183
+ </template>
184
+
185
+ <style scoped>
186
+ .notification-toolbar,
187
+ .notification-filters,
188
+ .notification-actions,
189
+ .notification-item__heading,
190
+ .notification-item__footer {
191
+ display: flex;
192
+ align-items: center;
193
+ gap: 8px;
194
+ }
195
+
196
+ .notification-toolbar,
197
+ .notification-item__heading,
198
+ .notification-item__footer {
199
+ justify-content: space-between;
200
+ }
201
+
202
+ .notification-toolbar {
203
+ flex-wrap: wrap;
204
+ width: 100%;
205
+ }
206
+
207
+ .notification-list {
208
+ border-top: 1px solid var(--el-border-color);
209
+ }
210
+
211
+ .notification-item {
212
+ display: grid;
213
+ grid-template-columns: 32px minmax(0, 1fr);
214
+ gap: 12px;
215
+ padding: 16px 0;
216
+ border-bottom: 1px solid var(--el-border-color);
217
+ }
218
+
219
+ .notification-item--unread {
220
+ border-left: 3px solid var(--el-color-primary);
221
+ padding-left: 12px;
222
+ }
223
+
224
+ .notification-item__content,
225
+ .notification-item__heading h2 {
226
+ min-width: 0;
227
+ }
228
+
229
+ .notification-item__heading h2 {
230
+ margin: 0;
231
+ overflow-wrap: anywhere;
232
+ font-size: 16px;
233
+ letter-spacing: 0;
234
+ }
235
+
236
+ .notification-item__content > p {
237
+ white-space: pre-wrap;
238
+ overflow-wrap: anywhere;
239
+ }
240
+
241
+ .notification-attachments {
242
+ margin: 8px 0;
243
+ padding-left: 20px;
244
+ }
245
+
246
+ .notification-item__footer {
247
+ color: var(--el-text-color-secondary);
248
+ font-size: 13px;
249
+ }
250
+
251
+ .notification-state {
252
+ padding: 24px 0;
253
+ }
254
+
255
+ @media (max-width: 720px) {
256
+ .notification-toolbar,
257
+ .notification-actions {
258
+ align-items: flex-start;
259
+ flex-direction: column;
260
+ }
261
+
262
+ .notification-filters {
263
+ flex-wrap: wrap;
264
+ }
265
+ }
266
+ </style>
@@ -0,0 +1,195 @@
1
+ export type NotificationStatus = 'unread' | 'read' | 'archived'
2
+ export type NotificationFilter = NotificationStatus | 'all'
3
+ export type NotificationBulkAction = 'read' | 'archive'
4
+
5
+ export interface NotificationAttachment {
6
+ readonly fileKey: string
7
+ readonly originalName: string
8
+ readonly mediaType: string
9
+ readonly sizeBytes: number
10
+ readonly sha256: string
11
+ }
12
+
13
+ export interface NotificationMessage {
14
+ readonly messageKey: string
15
+ readonly templateKey: string
16
+ readonly templateRevision: number
17
+ readonly subject: string
18
+ readonly body: string
19
+ readonly status: NotificationStatus
20
+ readonly revision: number
21
+ readonly createdAt: string
22
+ readonly readAt: string | null
23
+ readonly archivedAt: string | null
24
+ readonly attachments: readonly NotificationAttachment[]
25
+ }
26
+
27
+ export interface NotificationList {
28
+ readonly items: readonly NotificationMessage[]
29
+ readonly page: number
30
+ readonly pageSize: number
31
+ readonly total: number
32
+ }
33
+
34
+ export interface NotificationTransportResult {
35
+ readonly body: unknown
36
+ readonly headers: Headers
37
+ readonly status: number
38
+ }
39
+
40
+ export interface NotificationTransport {
41
+ list: (status: NotificationFilter, page: number, pageSize: number, signal: AbortSignal) => Promise<NotificationTransportResult>
42
+ markRead: (messageKey: string, revision: number, signal: AbortSignal) => Promise<NotificationTransportResult>
43
+ bulk: (messageKeys: readonly string[], action: NotificationBulkAction, signal: AbortSignal) => Promise<NotificationTransportResult>
44
+ }
45
+
46
+ const record = (value: unknown): Record<string, unknown> => {
47
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('NOTIFICATION_RESPONSE_INVALID')
48
+ return value as Record<string, unknown>
49
+ }
50
+
51
+ const exactKeys = (value: Record<string, unknown>, keys: readonly string[]): void => {
52
+ const actual = Object.keys(value).sort()
53
+ const expected = [...keys].sort()
54
+ if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
55
+ throw new Error('NOTIFICATION_RESPONSE_INVALID')
56
+ }
57
+ }
58
+
59
+ const instant = (value: unknown): value is string => typeof value === 'string'
60
+ && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)
61
+ && Number.isFinite(Date.parse(value))
62
+
63
+ const fileKeyPattern = /^file_[0-9a-f]{32}$/
64
+ const messageKeyPattern = /^notice_[0-9a-f]{32}$/
65
+ const qualifiedKeyPattern = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/
66
+
67
+ const parseAttachment = (value: unknown): NotificationAttachment => {
68
+ const item = record(value)
69
+ exactKeys(item, ['file_key', 'original_name', 'media_type', 'size_bytes', 'sha256'])
70
+ if (typeof item.file_key !== 'string' || !fileKeyPattern.test(item.file_key)
71
+ || typeof item.original_name !== 'string' || item.original_name === '' || [...item.original_name].length > 255
72
+ || typeof item.media_type !== 'string' || !/^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/.test(item.media_type)
73
+ || typeof item.size_bytes !== 'number' || !Number.isSafeInteger(item.size_bytes) || item.size_bytes < 0
74
+ || typeof item.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(item.sha256)
75
+ ) throw new Error('NOTIFICATION_RESPONSE_INVALID')
76
+ return {
77
+ fileKey: item.file_key,
78
+ originalName: item.original_name,
79
+ mediaType: item.media_type,
80
+ sizeBytes: item.size_bytes,
81
+ sha256: item.sha256,
82
+ }
83
+ }
84
+
85
+ export const parseNotification = (value: unknown): NotificationMessage => {
86
+ const item = record(value)
87
+ exactKeys(item, [
88
+ 'message_key', 'template_key', 'template_revision', 'subject', 'body', 'status', 'revision',
89
+ 'created_at', 'read_at', 'archived_at', 'attachments',
90
+ ])
91
+ if (typeof item.message_key !== 'string' || !messageKeyPattern.test(item.message_key)
92
+ || typeof item.template_key !== 'string' || item.template_key.length > 64 || !qualifiedKeyPattern.test(item.template_key)
93
+ || typeof item.template_revision !== 'number' || !Number.isSafeInteger(item.template_revision) || item.template_revision < 1
94
+ || typeof item.subject !== 'string' || item.subject === '' || [...item.subject].length > 255
95
+ || typeof item.body !== 'string' || item.body === '' || [...item.body].length > 10000
96
+ || !['unread', 'read', 'archived'].includes(String(item.status))
97
+ || typeof item.revision !== 'number' || !Number.isSafeInteger(item.revision) || item.revision < 1
98
+ || !instant(item.created_at)
99
+ || (item.read_at !== null && !instant(item.read_at))
100
+ || (item.archived_at !== null && !instant(item.archived_at))
101
+ || ((item.status === 'unread') !== (item.read_at === null))
102
+ || ((item.status === 'archived') !== (item.archived_at !== null))
103
+ || !Array.isArray(item.attachments) || item.attachments.length > 10
104
+ ) throw new Error('NOTIFICATION_RESPONSE_INVALID')
105
+ return {
106
+ messageKey: item.message_key,
107
+ templateKey: item.template_key,
108
+ templateRevision: item.template_revision,
109
+ subject: item.subject,
110
+ body: item.body,
111
+ status: item.status as NotificationStatus,
112
+ revision: item.revision,
113
+ createdAt: item.created_at,
114
+ readAt: item.read_at as string | null,
115
+ archivedAt: item.archived_at as string | null,
116
+ attachments: item.attachments.map(parseAttachment),
117
+ }
118
+ }
119
+
120
+ export const parseNotificationResponse = (value: unknown): NotificationMessage => {
121
+ const body = record(value)
122
+ exactKeys(body, ['data', 'meta'])
123
+ const meta = record(body.meta)
124
+ exactKeys(meta, ['request_id'])
125
+ if (typeof meta.request_id !== 'string' || meta.request_id === '') throw new Error('NOTIFICATION_RESPONSE_INVALID')
126
+ return parseNotification(body.data)
127
+ }
128
+
129
+ export const parseNotificationList = (value: unknown): NotificationList => {
130
+ const body = record(value)
131
+ exactKeys(body, ['data', 'meta'])
132
+ const data = record(body.data)
133
+ const meta = record(body.meta)
134
+ exactKeys(data, ['items'])
135
+ exactKeys(meta, ['request_id', 'page', 'page_size', 'total'])
136
+ if (!Array.isArray(data.items) || typeof meta.request_id !== 'string' || meta.request_id === '') {
137
+ throw new Error('NOTIFICATION_RESPONSE_INVALID')
138
+ }
139
+ for (const key of ['page', 'page_size', 'total'] as const) {
140
+ if (typeof meta[key] !== 'number' || !Number.isSafeInteger(meta[key]) || meta[key] < (key === 'total' ? 0 : 1)) {
141
+ throw new Error('NOTIFICATION_RESPONSE_INVALID')
142
+ }
143
+ }
144
+ return {
145
+ items: data.items.map(parseNotification),
146
+ page: meta.page as number,
147
+ pageSize: meta.page_size as number,
148
+ total: meta.total as number,
149
+ }
150
+ }
151
+
152
+ export const parseBulkResult = (value: unknown): number => {
153
+ const body = record(value)
154
+ exactKeys(body, ['data', 'meta'])
155
+ const data = record(body.data)
156
+ const meta = record(body.meta)
157
+ exactKeys(data, ['changed'])
158
+ exactKeys(meta, ['request_id'])
159
+ if (typeof data.changed !== 'number' || !Number.isSafeInteger(data.changed) || data.changed < 0
160
+ || typeof meta.request_id !== 'string' || meta.request_id === ''
161
+ ) throw new Error('NOTIFICATION_RESPONSE_INVALID')
162
+ return data.changed
163
+ }
164
+
165
+ export const createNotificationFetchTransport = (options: {
166
+ readonly baseUrl: string
167
+ readonly fetch?: (request: Request) => Promise<Response>
168
+ }): NotificationTransport => {
169
+ const fetcher = options.fetch ?? fetch
170
+ const request = async (path: string, init: RequestInit): Promise<NotificationTransportResult> => {
171
+ const headers = new Headers(init.headers)
172
+ if (!headers.has('Accept')) headers.set('Accept', 'application/json')
173
+ if (init.body !== undefined && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json')
174
+ const response = await fetcher(new Request(new URL(path, options.baseUrl), {
175
+ credentials: 'include', ...init, headers,
176
+ }))
177
+ return { body: response.status === 204 ? null : await response.json(), headers: response.headers, status: response.status }
178
+ }
179
+ return {
180
+ list(status, page, pageSize, signal) {
181
+ const query = new URLSearchParams({ status, page: String(page), page_size: String(pageSize) })
182
+ return request(`/api/v1/notifications?${query}`, { method: 'GET', signal })
183
+ },
184
+ markRead(messageKey, revision, signal) {
185
+ return request(`/api/v1/notifications/${encodeURIComponent(messageKey)}/read`, {
186
+ method: 'POST', headers: { 'If-Match': `"rev-${revision}"` }, body: '{}', signal,
187
+ })
188
+ },
189
+ bulk(messageKeys, action, signal) {
190
+ return request('/api/v1/notifications/bulk', {
191
+ method: 'POST', body: JSON.stringify({ message_keys: messageKeys, action }), signal,
192
+ })
193
+ },
194
+ }
195
+ }
@@ -0,0 +1,4 @@
1
+ export const NOTIFICATION_SMS_PACKAGE = '@peanut-admin/admin/notification-sms' as const
2
+ export const NOTIFICATION_SMS_VERSION = '0.1.0' as const
3
+ export * from './contracts'
4
+ export * from './runtime'