@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,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
|
+
}
|