@reflagged/shell 1.3.2 → 1.4.0
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/README.md +38 -0
- package/package.json +4 -2
- package/src/components/NotificationBell.tsx +85 -0
- package/src/lib/notifications/notifier.ts +120 -0
package/README.md
CHANGED
|
@@ -94,6 +94,44 @@ export { default, config } from '@reflagged/shell/middleware'
|
|
|
94
94
|
Other API re-exports: `@reflagged/shell/api/oidc-callback`,
|
|
95
95
|
`@reflagged/shell/api/oidc-signout`, `@reflagged/shell/api/shell-info`.
|
|
96
96
|
|
|
97
|
+
### Notifications (`createNotifier`, `NotificationBell`)
|
|
98
|
+
|
|
99
|
+
Since 1.4.0 a module can post notifications to the platform inbox. The base
|
|
100
|
+
stores one inbox entry per recipient, renders and brands the mail, and
|
|
101
|
+
delivers it through the tenant's sender. Modules send data, never HTML.
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
// server only — uses OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET
|
|
105
|
+
import { createNotifier } from '@reflagged/shell/notifications/notifier'
|
|
106
|
+
|
|
107
|
+
const notifier = createNotifier()
|
|
108
|
+
await notifier.notify({
|
|
109
|
+
idempotencyKey: `absence-${absence.id}-approved`, // same key → no duplicate
|
|
110
|
+
topic: 'kollega.absence.approved', // <module>.<object>.<event>
|
|
111
|
+
category: 'activity', // or 'transactional' (not opt-out-able)
|
|
112
|
+
title: 'Urlaub genehmigt',
|
|
113
|
+
body: '12.–16. Oktober',
|
|
114
|
+
actionUrl: `https://kollega.acme.rfl.gd/abwesenheiten/${absence.id}`, // https, own or tenant domain
|
|
115
|
+
actionLabel: 'Antrag öffnen',
|
|
116
|
+
recipients: [{ email: requester.email }], // or { userId } / { role: 'org:admins' | 'org:members' }
|
|
117
|
+
})
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Recipients must belong to the booking's organization (403 otherwise);
|
|
121
|
+
addresses without a platform account are skipped. The token comes from the
|
|
122
|
+
`client_credentials` grant with scope `notifications:write`, which every
|
|
123
|
+
module client of the platform may request. Errors throw `NotifyError` with
|
|
124
|
+
the HTTP `status` and the base's error `code`.
|
|
125
|
+
|
|
126
|
+
The bell shows the unread count (from `/api/shell-info`) and links to the
|
|
127
|
+
central inbox:
|
|
128
|
+
|
|
129
|
+
```tsx
|
|
130
|
+
import { NotificationBell } from '@reflagged/shell/components/NotificationBell'
|
|
131
|
+
|
|
132
|
+
<NotificationBell />
|
|
133
|
+
```
|
|
134
|
+
|
|
97
135
|
## Local development (live-edit against a consumer)
|
|
98
136
|
|
|
99
137
|
A published package is frozen in `node_modules`. To iterate on the shell and a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reflagged/shell",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Shared app shell for Reflagged services — OIDC auth, SSO, app switcher",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -27,7 +27,9 @@
|
|
|
27
27
|
"./api/oidc-signout": "./src/lib/api/oidc-signout.ts",
|
|
28
28
|
"./api/shell-info": "./src/lib/api/shell-info.ts",
|
|
29
29
|
"./package.json": "./package.json",
|
|
30
|
-
"./components/ServiceIcon": "./src/components/ServiceIcon.tsx"
|
|
30
|
+
"./components/ServiceIcon": "./src/components/ServiceIcon.tsx",
|
|
31
|
+
"./notifications/notifier": "./src/lib/notifications/notifier.ts",
|
|
32
|
+
"./components/NotificationBell": "./src/components/NotificationBell.tsx"
|
|
31
33
|
},
|
|
32
34
|
"scripts": {
|
|
33
35
|
"typecheck": "tsc --noEmit",
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
type ShellInfo = { unreadNotifications?: number; baseUrl?: string; org?: { tenantSlug?: string | null } | null }
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Bell for module headers: unread count from rflgd-base (via the module's
|
|
9
|
+
* /api/shell-info proxy) and a link to the central inbox. Renders nothing
|
|
10
|
+
* until the shell info is known, so a module without a session stays clean.
|
|
11
|
+
*/
|
|
12
|
+
export function NotificationBell({
|
|
13
|
+
inboxUrl,
|
|
14
|
+
className,
|
|
15
|
+
}: {
|
|
16
|
+
/** Override the inbox link; defaults to `<baseUrl>/benachrichtigungen`. */
|
|
17
|
+
inboxUrl?: string
|
|
18
|
+
className?: string
|
|
19
|
+
}) {
|
|
20
|
+
const [info, setInfo] = useState<ShellInfo | null>(null)
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
let cancelled = false
|
|
24
|
+
fetch('/api/shell-info', { credentials: 'include' })
|
|
25
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
26
|
+
.then((data: ShellInfo | null) => {
|
|
27
|
+
if (!cancelled) setInfo(data)
|
|
28
|
+
})
|
|
29
|
+
.catch(() => undefined)
|
|
30
|
+
return () => {
|
|
31
|
+
cancelled = true
|
|
32
|
+
}
|
|
33
|
+
}, [])
|
|
34
|
+
|
|
35
|
+
if (!info) return null
|
|
36
|
+
const unread = info.unreadNotifications ?? 0
|
|
37
|
+
const href = inboxUrl ?? `${(info.baseUrl ?? 'https://app.rfl.gd').replace(/\/$/, '')}/benachrichtigungen`
|
|
38
|
+
const label = unread > 0 ? `Benachrichtigungen, ${unread} ungelesen` : 'Benachrichtigungen'
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<a
|
|
42
|
+
href={href}
|
|
43
|
+
aria-label={label}
|
|
44
|
+
title={label}
|
|
45
|
+
data-testid="rflgd-notification-bell"
|
|
46
|
+
className={className}
|
|
47
|
+
style={{
|
|
48
|
+
position: 'relative',
|
|
49
|
+
display: 'inline-grid',
|
|
50
|
+
placeItems: 'center',
|
|
51
|
+
width: 40,
|
|
52
|
+
height: 40,
|
|
53
|
+
borderRadius: 10,
|
|
54
|
+
color: 'inherit',
|
|
55
|
+
}}
|
|
56
|
+
>
|
|
57
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
58
|
+
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
|
59
|
+
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
|
60
|
+
</svg>
|
|
61
|
+
{unread > 0 ? (
|
|
62
|
+
<span
|
|
63
|
+
data-testid="rflgd-notification-badge"
|
|
64
|
+
style={{
|
|
65
|
+
position: 'absolute',
|
|
66
|
+
top: 4,
|
|
67
|
+
right: 4,
|
|
68
|
+
minWidth: 16,
|
|
69
|
+
height: 16,
|
|
70
|
+
padding: '0 4px',
|
|
71
|
+
borderRadius: 999,
|
|
72
|
+
background: '#7D551A',
|
|
73
|
+
color: '#fff',
|
|
74
|
+
fontSize: 9,
|
|
75
|
+
lineHeight: '16px',
|
|
76
|
+
textAlign: 'center',
|
|
77
|
+
fontVariantNumeric: 'tabular-nums',
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
{unread > 99 ? '99+' : unread}
|
|
81
|
+
</span>
|
|
82
|
+
) : null}
|
|
83
|
+
</a>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side helper for modules: post notifications to the rflgd-base inbox.
|
|
3
|
+
*
|
|
4
|
+
* const notifier = createNotifier() // reads OIDC_ISSUER / OIDC_CLIENT_ID / OIDC_CLIENT_SECRET
|
|
5
|
+
* await notifier.notify({
|
|
6
|
+
* idempotencyKey: `absence-${id}-approved`,
|
|
7
|
+
* topic: 'kollega.absence.approved',
|
|
8
|
+
* category: 'activity',
|
|
9
|
+
* title: 'Urlaub genehmigt',
|
|
10
|
+
* actionUrl: `https://kollega.acme.rfl.gd/abwesenheiten/${id}`,
|
|
11
|
+
* recipients: [{ email: requester.email }],
|
|
12
|
+
* })
|
|
13
|
+
*
|
|
14
|
+
* The token is fetched with the client_credentials grant (scope
|
|
15
|
+
* `notifications:write`) and cached until shortly before it expires.
|
|
16
|
+
* Modules send data, never HTML; the base renders and brands every mail.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type NotificationRecipient =
|
|
20
|
+
| { userId: number }
|
|
21
|
+
| { email: string }
|
|
22
|
+
| { role: 'org:admins' | 'org:members' }
|
|
23
|
+
|
|
24
|
+
export type NotifyInput = {
|
|
25
|
+
idempotencyKey: string
|
|
26
|
+
/** `<module>.<object>.<event>`, lower case. */
|
|
27
|
+
topic: string
|
|
28
|
+
category: 'transactional' | 'activity'
|
|
29
|
+
title: string
|
|
30
|
+
body?: string
|
|
31
|
+
/** https link on the module's or tenant's domain. */
|
|
32
|
+
actionUrl?: string
|
|
33
|
+
actionLabel?: string
|
|
34
|
+
data?: Record<string, unknown>
|
|
35
|
+
recipients: NotificationRecipient[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type NotifyResult = { ids: number[]; recipients: number; skipped: number }
|
|
39
|
+
|
|
40
|
+
export class NotifyError extends Error {
|
|
41
|
+
constructor(
|
|
42
|
+
message: string,
|
|
43
|
+
readonly status: number,
|
|
44
|
+
readonly code: string,
|
|
45
|
+
) {
|
|
46
|
+
super(message)
|
|
47
|
+
this.name = 'NotifyError'
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type NotifierOptions = {
|
|
52
|
+
issuer?: string
|
|
53
|
+
clientId?: string
|
|
54
|
+
clientSecret?: string
|
|
55
|
+
fetch?: typeof fetch
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const SCOPE = 'notifications:write'
|
|
59
|
+
const EXPIRY_SKEW_MS = 60_000
|
|
60
|
+
|
|
61
|
+
export function createNotifier(options: NotifierOptions = {}) {
|
|
62
|
+
const issuer = (options.issuer ?? process.env.OIDC_ISSUER ?? '').replace(/\/$/, '')
|
|
63
|
+
const clientId = options.clientId ?? process.env.OIDC_CLIENT_ID ?? ''
|
|
64
|
+
const clientSecret = options.clientSecret ?? process.env.OIDC_CLIENT_SECRET ?? ''
|
|
65
|
+
const doFetch = options.fetch ?? fetch
|
|
66
|
+
if (!issuer || !clientId || !clientSecret) {
|
|
67
|
+
throw new Error('createNotifier: OIDC_ISSUER, OIDC_CLIENT_ID and OIDC_CLIENT_SECRET are required')
|
|
68
|
+
}
|
|
69
|
+
const baseUrl = issuer.replace(/\/oidc$/, '')
|
|
70
|
+
let cached: { token: string; expiresAt: number } | null = null
|
|
71
|
+
|
|
72
|
+
async function token(): Promise<string> {
|
|
73
|
+
if (cached && cached.expiresAt - EXPIRY_SKEW_MS > Date.now()) return cached.token
|
|
74
|
+
const res = await doFetch(`${issuer}/token`, {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: {
|
|
77
|
+
authorization: `Basic ${btoa(`${encodeURIComponent(clientId)}:${encodeURIComponent(clientSecret)}`)}`,
|
|
78
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
79
|
+
},
|
|
80
|
+
body: new URLSearchParams({ grant_type: 'client_credentials', scope: SCOPE }),
|
|
81
|
+
})
|
|
82
|
+
const body = (await res.json().catch(() => ({}))) as {
|
|
83
|
+
access_token?: string
|
|
84
|
+
expires_in?: number
|
|
85
|
+
error?: string
|
|
86
|
+
}
|
|
87
|
+
if (!res.ok || !body.access_token) {
|
|
88
|
+
throw new NotifyError(`token request failed: ${body.error ?? res.status}`, res.status, body.error ?? 'token')
|
|
89
|
+
}
|
|
90
|
+
cached = { token: body.access_token, expiresAt: Date.now() + (body.expires_in ?? 600) * 1000 }
|
|
91
|
+
return cached.token
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function send(input: NotifyInput, accessToken: string): Promise<Response> {
|
|
95
|
+
return doFetch(`${baseUrl}/api/notifications`, {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: { authorization: `Bearer ${accessToken}`, 'content-type': 'application/json' },
|
|
98
|
+
body: JSON.stringify(input),
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
async notify(input: NotifyInput): Promise<NotifyResult> {
|
|
104
|
+
let res = await send(input, await token())
|
|
105
|
+
if (res.status === 401) {
|
|
106
|
+
cached = null
|
|
107
|
+
res = await send(input, await token())
|
|
108
|
+
}
|
|
109
|
+
const body = (await res.json().catch(() => ({}))) as {
|
|
110
|
+
success?: boolean
|
|
111
|
+
data?: NotifyResult
|
|
112
|
+
error?: { code?: string; message?: string }
|
|
113
|
+
}
|
|
114
|
+
if (!res.ok || !body.success || !body.data) {
|
|
115
|
+
throw new NotifyError(body.error?.message ?? `notify failed (${res.status})`, res.status, body.error?.code ?? 'failed')
|
|
116
|
+
}
|
|
117
|
+
return body.data
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
}
|