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