@open-mercato/channel-fcm 0.6.8-develop.6985.1.fb93574faa
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/.turbo/turbo-build.log +2 -0
- package/AGENTS.md +31 -0
- package/build.mjs +7 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +7 -0
- package/dist/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-001.spec.js +23 -0
- package/dist/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-001.spec.js.map +7 -0
- package/dist/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-002.spec.js +43 -0
- package/dist/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-002.spec.js.map +7 -0
- package/dist/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-005.meta.js +7 -0
- package/dist/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-005.meta.js.map +7 -0
- package/dist/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-005.spec.js +64 -0
- package/dist/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-005.spec.js.map +7 -0
- package/dist/modules/channel_fcm/acl.js +10 -0
- package/dist/modules/channel_fcm/acl.js.map +7 -0
- package/dist/modules/channel_fcm/di.js +25 -0
- package/dist/modules/channel_fcm/di.js.map +7 -0
- package/dist/modules/channel_fcm/index.js +9 -0
- package/dist/modules/channel_fcm/index.js.map +7 -0
- package/dist/modules/channel_fcm/integration.js +54 -0
- package/dist/modules/channel_fcm/integration.js.map +7 -0
- package/dist/modules/channel_fcm/lib/adapter.js +160 -0
- package/dist/modules/channel_fcm/lib/adapter.js.map +7 -0
- package/dist/modules/channel_fcm/lib/credentials.js +56 -0
- package/dist/modules/channel_fcm/lib/credentials.js.map +7 -0
- package/dist/modules/channel_fcm/lib/fake-provider.js +32 -0
- package/dist/modules/channel_fcm/lib/fake-provider.js.map +7 -0
- package/dist/modules/channel_fcm/lib/health.js +10 -0
- package/dist/modules/channel_fcm/lib/health.js.map +7 -0
- package/dist/modules/channel_fcm/setup.js +25 -0
- package/dist/modules/channel_fcm/setup.js.map +7 -0
- package/dist/modules/channel_fcm/widgets/injection/connect/widget.client.js +176 -0
- package/dist/modules/channel_fcm/widgets/injection/connect/widget.client.js.map +7 -0
- package/dist/modules/channel_fcm/widgets/injection/connect/widget.js +17 -0
- package/dist/modules/channel_fcm/widgets/injection/connect/widget.js.map +7 -0
- package/dist/modules/channel_fcm/widgets/injection-table.js +15 -0
- package/dist/modules/channel_fcm/widgets/injection-table.js.map +7 -0
- package/jest.config.cjs +34 -0
- package/package.json +96 -0
- package/src/index.ts +1 -0
- package/src/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-001.spec.ts +39 -0
- package/src/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-002.spec.ts +64 -0
- package/src/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-005.meta.ts +3 -0
- package/src/modules/channel_fcm/__integration__/TC-CHANNEL-PUSH-005.spec.ts +88 -0
- package/src/modules/channel_fcm/acl.ts +6 -0
- package/src/modules/channel_fcm/di.ts +27 -0
- package/src/modules/channel_fcm/index.ts +6 -0
- package/src/modules/channel_fcm/integration.ts +55 -0
- package/src/modules/channel_fcm/lib/__tests__/adapter-app-cache.test.ts +84 -0
- package/src/modules/channel_fcm/lib/__tests__/adapter.test.ts +176 -0
- package/src/modules/channel_fcm/lib/__tests__/credentials.test.ts +36 -0
- package/src/modules/channel_fcm/lib/__tests__/fake-provider.test.ts +96 -0
- package/src/modules/channel_fcm/lib/__tests__/message-golden.test.ts +98 -0
- package/src/modules/channel_fcm/lib/adapter.ts +238 -0
- package/src/modules/channel_fcm/lib/credentials.ts +79 -0
- package/src/modules/channel_fcm/lib/fake-provider.ts +46 -0
- package/src/modules/channel_fcm/lib/health.ts +13 -0
- package/src/modules/channel_fcm/setup.ts +34 -0
- package/src/modules/channel_fcm/widgets/injection/connect/widget.client.tsx +208 -0
- package/src/modules/channel_fcm/widgets/injection/connect/widget.ts +16 -0
- package/src/modules/channel_fcm/widgets/injection-table.ts +13 -0
- package/tsconfig.json +9 -0
- package/watch.mjs +7 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import type { InjectionWidgetComponentProps } from '@open-mercato/shared/modules/widgets/injection'
|
|
5
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
|
+
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
7
|
+
import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
|
|
8
|
+
import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
|
|
9
|
+
import {
|
|
10
|
+
resolvePushConnectErrorMessage,
|
|
11
|
+
resolvePushConnectFieldErrors,
|
|
12
|
+
} from '@open-mercato/core/modules/communication_channels/lib/push-connect-error'
|
|
13
|
+
import { Button } from '@open-mercato/ui/primitives/button'
|
|
14
|
+
import {
|
|
15
|
+
Dialog,
|
|
16
|
+
DialogContent,
|
|
17
|
+
DialogFooter,
|
|
18
|
+
DialogHeader,
|
|
19
|
+
DialogTitle,
|
|
20
|
+
} from '@open-mercato/ui/primitives/dialog'
|
|
21
|
+
import { Input } from '@open-mercato/ui/primitives/input'
|
|
22
|
+
import { Label } from '@open-mercato/ui/primitives/label'
|
|
23
|
+
import { Textarea } from '@open-mercato/ui/primitives/textarea'
|
|
24
|
+
|
|
25
|
+
type WidgetContext = Record<string, unknown> & {
|
|
26
|
+
reload?: () => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type ConnectResponse = {
|
|
30
|
+
channelId?: string
|
|
31
|
+
error?: string
|
|
32
|
+
code?: string
|
|
33
|
+
fieldErrors?: Record<string, string>
|
|
34
|
+
fieldErrorCodes?: Record<string, string>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type FormState = {
|
|
38
|
+
displayName: string
|
|
39
|
+
serviceAccountJson: string
|
|
40
|
+
appName: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const INITIAL_FORM: FormState = {
|
|
44
|
+
displayName: 'Firebase Cloud Messaging',
|
|
45
|
+
serviceAccountJson: '',
|
|
46
|
+
appName: '',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export default function ConnectFcmWidget({
|
|
50
|
+
context,
|
|
51
|
+
}: InjectionWidgetComponentProps<Record<string, unknown>, Record<string, unknown>>) {
|
|
52
|
+
const t = useT()
|
|
53
|
+
const widgetContext = context as WidgetContext | undefined
|
|
54
|
+
const [open, setOpen] = React.useState(false)
|
|
55
|
+
const [pending, setPending] = React.useState(false)
|
|
56
|
+
const [form, setForm] = React.useState<FormState>(INITIAL_FORM)
|
|
57
|
+
const [fieldErrors, setFieldErrors] = React.useState<Record<string, string>>({})
|
|
58
|
+
const { runMutation, retryLastMutation } = useGuardedMutation({
|
|
59
|
+
contextId: 'channel-fcm-connect',
|
|
60
|
+
blockedMessage: t('communication_channels.push.connect.blocked', 'Connection blocked by validation'),
|
|
61
|
+
})
|
|
62
|
+
const mutationContext = React.useMemo(
|
|
63
|
+
() => ({ providerKey: 'fcm', retryLastMutation }),
|
|
64
|
+
[retryLastMutation],
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
const update = React.useCallback(
|
|
68
|
+
<K extends keyof FormState>(key: K, value: FormState[K]) => {
|
|
69
|
+
setForm((current) => ({ ...current, [key]: value }))
|
|
70
|
+
setFieldErrors((current) => {
|
|
71
|
+
if (!current[key]) return current
|
|
72
|
+
const next = { ...current }
|
|
73
|
+
delete next[key]
|
|
74
|
+
return next
|
|
75
|
+
})
|
|
76
|
+
},
|
|
77
|
+
[],
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
const submit = React.useCallback(async () => {
|
|
81
|
+
if (pending) return
|
|
82
|
+
setPending(true)
|
|
83
|
+
setFieldErrors({})
|
|
84
|
+
const displayName = form.displayName.trim() || 'Firebase Cloud Messaging'
|
|
85
|
+
try {
|
|
86
|
+
const response = await runMutation({
|
|
87
|
+
context: mutationContext,
|
|
88
|
+
mutationPayload: { providerKey: 'fcm', displayName },
|
|
89
|
+
operation: () =>
|
|
90
|
+
apiCall<ConnectResponse>('/api/communication_channels/channels/connect/tenant-credentials', {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
headers: { 'content-type': 'application/json' },
|
|
93
|
+
body: JSON.stringify({
|
|
94
|
+
providerKey: 'fcm',
|
|
95
|
+
displayName,
|
|
96
|
+
credentials: {
|
|
97
|
+
serviceAccountJson: form.serviceAccountJson.trim(),
|
|
98
|
+
...(form.appName.trim() ? { appName: form.appName.trim() } : {}),
|
|
99
|
+
},
|
|
100
|
+
}),
|
|
101
|
+
}),
|
|
102
|
+
})
|
|
103
|
+
const body = response.result as ConnectResponse | undefined
|
|
104
|
+
if (!response.ok) {
|
|
105
|
+
setFieldErrors(resolvePushConnectFieldErrors(t, body))
|
|
106
|
+
flash(resolvePushConnectErrorMessage(t, body), 'error')
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
flash(t('communication_channels.push.connect.connected', 'Push provider connected.'), 'success')
|
|
110
|
+
setOpen(false)
|
|
111
|
+
setForm(INITIAL_FORM)
|
|
112
|
+
widgetContext?.reload?.()
|
|
113
|
+
} finally {
|
|
114
|
+
setPending(false)
|
|
115
|
+
}
|
|
116
|
+
}, [form, mutationContext, pending, runMutation, t, widgetContext])
|
|
117
|
+
|
|
118
|
+
const onDialogKeyDown = React.useCallback(
|
|
119
|
+
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
120
|
+
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
|
121
|
+
event.preventDefault()
|
|
122
|
+
void submit()
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
[submit],
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<>
|
|
130
|
+
<Button type="button" variant="outline" onClick={() => setOpen(true)}>
|
|
131
|
+
{t('communication_channels.push.connect.button.fcm', 'Connect FCM')}
|
|
132
|
+
</Button>
|
|
133
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
134
|
+
<DialogContent onKeyDown={onDialogKeyDown}>
|
|
135
|
+
<DialogHeader>
|
|
136
|
+
<DialogTitle>
|
|
137
|
+
{t('communication_channels.push.connect.title.fcm', 'Connect Firebase Cloud Messaging')}
|
|
138
|
+
</DialogTitle>
|
|
139
|
+
</DialogHeader>
|
|
140
|
+
|
|
141
|
+
<div className="grid gap-4 py-2">
|
|
142
|
+
<p className="text-sm text-muted-foreground">
|
|
143
|
+
{t(
|
|
144
|
+
'communication_channels.push.connect.description',
|
|
145
|
+
"This is a shared, tenant-wide channel — every user's devices in this workspace are served by it.",
|
|
146
|
+
)}
|
|
147
|
+
</p>
|
|
148
|
+
<Field
|
|
149
|
+
label={t('communication_channels.push.connect.displayName', 'Display name')}
|
|
150
|
+
error={fieldErrors.displayName}
|
|
151
|
+
>
|
|
152
|
+
<Input
|
|
153
|
+
value={form.displayName}
|
|
154
|
+
onChange={(event) => update('displayName', event.target.value)}
|
|
155
|
+
aria-invalid={Boolean(fieldErrors.displayName)}
|
|
156
|
+
/>
|
|
157
|
+
</Field>
|
|
158
|
+
<Field
|
|
159
|
+
label={t('communication_channels.push.connect.fields.fcm.serviceAccountJson', 'Service Account JSON')}
|
|
160
|
+
error={fieldErrors.serviceAccountJson}
|
|
161
|
+
>
|
|
162
|
+
<Textarea
|
|
163
|
+
rows={8}
|
|
164
|
+
value={form.serviceAccountJson}
|
|
165
|
+
onChange={(event) => update('serviceAccountJson', event.target.value)}
|
|
166
|
+
aria-invalid={Boolean(fieldErrors.serviceAccountJson)}
|
|
167
|
+
className="font-mono text-xs"
|
|
168
|
+
/>
|
|
169
|
+
</Field>
|
|
170
|
+
<Field
|
|
171
|
+
label={t('communication_channels.push.connect.fields.fcm.appName', 'App name (optional)')}
|
|
172
|
+
error={fieldErrors.appName}
|
|
173
|
+
>
|
|
174
|
+
<Input
|
|
175
|
+
value={form.appName}
|
|
176
|
+
onChange={(event) => update('appName', event.target.value)}
|
|
177
|
+
aria-invalid={Boolean(fieldErrors.appName)}
|
|
178
|
+
/>
|
|
179
|
+
</Field>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<DialogFooter>
|
|
183
|
+
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={pending}>
|
|
184
|
+
{t('communication_channels.push.connect.cancel', 'Cancel')}
|
|
185
|
+
</Button>
|
|
186
|
+
<Button type="button" onClick={() => void submit()} disabled={pending}>
|
|
187
|
+
{pending
|
|
188
|
+
? t('communication_channels.push.connect.connecting', 'Connecting…')
|
|
189
|
+
: t('communication_channels.push.connect.save', 'Connect')}
|
|
190
|
+
</Button>
|
|
191
|
+
</DialogFooter>
|
|
192
|
+
</DialogContent>
|
|
193
|
+
</Dialog>
|
|
194
|
+
</>
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function Field(props: { label: string; error?: string; children: React.ReactNode }) {
|
|
199
|
+
return (
|
|
200
|
+
<label className="grid gap-1.5">
|
|
201
|
+
<Label asChild>
|
|
202
|
+
<span>{props.label}</span>
|
|
203
|
+
</Label>
|
|
204
|
+
{props.children}
|
|
205
|
+
{props.error ? <span className="text-xs text-destructive">{props.error}</span> : null}
|
|
206
|
+
</label>
|
|
207
|
+
)
|
|
208
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { InjectionWidgetModule } from '@open-mercato/shared/modules/widgets/injection'
|
|
2
|
+
import ConnectFcmWidget from './widget.client'
|
|
3
|
+
|
|
4
|
+
const widget: InjectionWidgetModule<Record<string, unknown>, Record<string, unknown>> = {
|
|
5
|
+
metadata: {
|
|
6
|
+
id: 'channel_fcm.injection.connect',
|
|
7
|
+
title: 'Connect FCM',
|
|
8
|
+
description: 'Connects a tenant-wide Firebase Cloud Messaging push channel.',
|
|
9
|
+
features: ['communication_channels.connect_tenant_channel'],
|
|
10
|
+
priority: 100,
|
|
11
|
+
enabled: true,
|
|
12
|
+
},
|
|
13
|
+
Widget: ConnectFcmWidget,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default widget
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ModuleInjectionTable } from '@open-mercato/shared/modules/widgets/injection'
|
|
2
|
+
|
|
3
|
+
export const injectionTable: ModuleInjectionTable = {
|
|
4
|
+
// Tenant-wide connect entry on the shared channels admin DataTable toolbar.
|
|
5
|
+
'data-table:communication_channels.channels:toolbar': [
|
|
6
|
+
{
|
|
7
|
+
widgetId: 'channel_fcm.injection.connect',
|
|
8
|
+
priority: 100,
|
|
9
|
+
},
|
|
10
|
+
],
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default injectionTable
|
package/tsconfig.json
ADDED