@molecule/app-settings-panel-react 1.0.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.
Files changed (42) hide show
  1. package/LICENSE +115 -0
  2. package/README.md +487 -0
  3. package/dist/AccountSection.d.ts +15 -0
  4. package/dist/AccountSection.d.ts.map +1 -0
  5. package/dist/AccountSection.js +83 -0
  6. package/dist/AppearanceSection.d.ts +10 -0
  7. package/dist/AppearanceSection.d.ts.map +1 -0
  8. package/dist/AppearanceSection.js +17 -0
  9. package/dist/AuthSection.d.ts +19 -0
  10. package/dist/AuthSection.d.ts.map +1 -0
  11. package/dist/AuthSection.js +141 -0
  12. package/dist/BillingSection.d.ts +19 -0
  13. package/dist/BillingSection.d.ts.map +1 -0
  14. package/dist/BillingSection.js +49 -0
  15. package/dist/DevicesSection.d.ts +30 -0
  16. package/dist/DevicesSection.d.ts.map +1 -0
  17. package/dist/DevicesSection.js +76 -0
  18. package/dist/LogOutDeleteSection.d.ts +10 -0
  19. package/dist/LogOutDeleteSection.d.ts.map +1 -0
  20. package/dist/LogOutDeleteSection.js +50 -0
  21. package/dist/NotificationsSection.d.ts +16 -0
  22. package/dist/NotificationsSection.d.ts.map +1 -0
  23. package/dist/NotificationsSection.js +93 -0
  24. package/dist/SettingsContainer.d.ts +12 -0
  25. package/dist/SettingsContainer.d.ts.map +1 -0
  26. package/dist/SettingsContainer.js +15 -0
  27. package/dist/ThisDeviceSection.d.ts +9 -0
  28. package/dist/ThisDeviceSection.d.ts.map +1 -0
  29. package/dist/ThisDeviceSection.js +16 -0
  30. package/dist/TiersUpgradeSection.d.ts +15 -0
  31. package/dist/TiersUpgradeSection.d.ts.map +1 -0
  32. package/dist/TiersUpgradeSection.js +120 -0
  33. package/dist/context.d.ts +18 -0
  34. package/dist/context.d.ts.map +1 -0
  35. package/dist/context.js +15 -0
  36. package/dist/index.d.ts +85 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.js +84 -0
  39. package/dist/pushToggle.d.ts +111 -0
  40. package/dist/pushToggle.d.ts.map +1 -0
  41. package/dist/pushToggle.js +170 -0
  42. package/package.json +50 -0
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Framework-free logic behind the push-notifications toggle
3
+ * ({@link NotificationsSection}). Extracted so the full enable/disable chain —
4
+ * permission → runtime VAPID key fetch → subscribe → persist on the device
5
+ * row — is unit-testable without a browser.
6
+ *
7
+ * The server contract (shared by every molecule api):
8
+ *
9
+ * - `GET /api/devices/push/public-key` → `{ publicKey }` — the VAPID public
10
+ * key the browser must pass as `applicationServerKey` (Chromium rejects
11
+ * keyless subscriptions). 404/503 when push is not configured server-side.
12
+ * - `GET /api/devices` → the caller's device rows; the session's own device
13
+ * is flagged `isCurrent: true`.
14
+ * - `PATCH /api/devices/:id { pushSubscription, hasPushSubscription }` —
15
+ * persists the browser subscription where the api-side push fan-outs read
16
+ * it. Without this PATCH the server never knows the subscription exists
17
+ * and no push can ever reach the user.
18
+ *
19
+ * @module
20
+ */
21
+ /**
22
+ * Picks the caller's own device row: the api flags the session device
23
+ * `isCurrent`; fall back to the first row for sessions predating the flag.
24
+ *
25
+ * @param devices - Rows from `GET /api/devices`.
26
+ * @returns The current device row, or `undefined` when the user has none.
27
+ */
28
+ export const findCurrentDevice = (devices) => {
29
+ if (!Array.isArray(devices) || devices.length === 0)
30
+ return undefined;
31
+ return devices.find((device) => device.isCurrent) ?? devices[0];
32
+ };
33
+ /**
34
+ * Converts an `@molecule/app-push` token into the device resource's
35
+ * `pushSubscription` shape (web PushSubscription JSON, FCM registration for
36
+ * Android, APNs registration for iOS).
37
+ *
38
+ * @param token - The push token returned by `register()`.
39
+ * @returns The `pushSubscription` value to PATCH onto the device row.
40
+ */
41
+ export const subscriptionFromToken = (token) => {
42
+ if (token.platform === 'android') {
43
+ return { registrationId: token.value, registrationType: 'FCM' };
44
+ }
45
+ if (token.platform === 'ios') {
46
+ return { registrationId: token.value };
47
+ }
48
+ // Web: register() serializes `subscription.toJSON()` — endpoint + keys.
49
+ return JSON.parse(token.value);
50
+ };
51
+ /**
52
+ * Runs the full enable chain: browser permission → runtime VAPID public key
53
+ * (`GET /api/devices/push/public-key`) → `register({ vapidPublicKey })` →
54
+ * persist the subscription on the current device row.
55
+ *
56
+ * Every failure is returned as a typed reason (never thrown) so the UI can
57
+ * show an honest, specific message instead of hanging or silently reverting.
58
+ *
59
+ * @param deps - The http client + push actions from `usePush()`.
60
+ * @param deps.http - Authenticated http client (`useHttpClient()`).
61
+ * @param deps.requestPermission - `usePush().requestPermission`.
62
+ * @param deps.register - `usePush().register`.
63
+ * @returns `{ ok: true }` or a typed failure.
64
+ */
65
+ export const enablePushOnCurrentDevice = async (deps) => {
66
+ const permission = await deps.requestPermission();
67
+ if (permission !== 'granted') {
68
+ return { ok: false, reason: 'permission-denied' };
69
+ }
70
+ let publicKey;
71
+ try {
72
+ const response = await deps.http.get('/api/devices/push/public-key');
73
+ publicKey = response.data?.publicKey;
74
+ }
75
+ catch (_error) {
76
+ // 404/503 from the bond-gated route — push is not configured server-side.
77
+ // Reported to the user as 'server-unconfigured'; nothing else to log here.
78
+ publicKey = undefined;
79
+ }
80
+ if (!publicKey) {
81
+ return { ok: false, reason: 'server-unconfigured' };
82
+ }
83
+ let token;
84
+ try {
85
+ token = await deps.register({ vapidPublicKey: publicKey });
86
+ }
87
+ catch (error) {
88
+ // The provider throws already-localized messages (not supported / no
89
+ // service worker in dev builds / push-service rejection) — surface them.
90
+ return {
91
+ ok: false,
92
+ reason: 'register-failed',
93
+ message: error instanceof Error ? error.message : String(error),
94
+ };
95
+ }
96
+ try {
97
+ const devices = await deps.http.get('/api/devices');
98
+ const device = findCurrentDevice(devices.data);
99
+ if (!device) {
100
+ return { ok: false, reason: 'persist-failed' };
101
+ }
102
+ await deps.http.patch(`/api/devices/${device.id}`, {
103
+ pushSubscription: subscriptionFromToken(token),
104
+ hasPushSubscription: true,
105
+ });
106
+ }
107
+ catch (error) {
108
+ return {
109
+ ok: false,
110
+ reason: 'persist-failed',
111
+ message: error instanceof Error ? error.message : String(error),
112
+ };
113
+ }
114
+ return { ok: true };
115
+ };
116
+ /**
117
+ * Disables push: unsubscribes the browser (best-effort — a dev build without
118
+ * a service worker has nothing to unsubscribe) and ALWAYS clears the server
119
+ * state so no further pushes target this device.
120
+ *
121
+ * @param deps - The http client + push action from `usePush()`.
122
+ * @param deps.http - Authenticated http client (`useHttpClient()`).
123
+ * @param deps.unregister - `usePush().unregister`.
124
+ * @returns `{ ok: true }` or a typed failure (server state not cleared).
125
+ */
126
+ export const disablePushOnCurrentDevice = async (deps) => {
127
+ try {
128
+ await deps.unregister();
129
+ }
130
+ catch (_error) {
131
+ // Best-effort: browser-side unsubscribe can fail (no service worker in a
132
+ // dev build); the server-side clear below is what stops future sends.
133
+ }
134
+ try {
135
+ const devices = await deps.http.get('/api/devices');
136
+ const device = findCurrentDevice(devices.data);
137
+ if (device) {
138
+ await deps.http.patch(`/api/devices/${device.id}`, {
139
+ pushSubscription: null,
140
+ hasPushSubscription: false,
141
+ });
142
+ }
143
+ }
144
+ catch (error) {
145
+ return {
146
+ ok: false,
147
+ reason: 'persist-failed',
148
+ message: error instanceof Error ? error.message : String(error),
149
+ };
150
+ }
151
+ return { ok: true };
152
+ };
153
+ /**
154
+ * Reads whether push is currently enabled for THIS device (server truth:
155
+ * the current device row's `hasPushSubscription`).
156
+ *
157
+ * @param http - Authenticated http client (`useHttpClient()`).
158
+ * @returns `true` when the current device has a stored subscription.
159
+ */
160
+ export const readCurrentDevicePushEnabled = async (http) => {
161
+ try {
162
+ const devices = await http.get('/api/devices');
163
+ return findCurrentDevice(devices.data)?.hasPushSubscription === true;
164
+ }
165
+ catch (_error) {
166
+ // Devices endpoint unavailable (e.g. unauthenticated preview) — render
167
+ // the toggle off rather than failing the whole settings panel.
168
+ return false;
169
+ }
170
+ };
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@molecule/app-settings-panel-react",
3
+ "version": "1.0.0",
4
+ "description": "Composable settings panel — a SettingsContainer + family of section sub-components (Account, Auth, Notifications, Billing, Devices, ThisDevice, LogOutDelete). Apps compose via JSX children, picking which sections to include and what order.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "scripts": {
9
+ "build": "tsc",
10
+ "test": "vitest run",
11
+ "test:watch": "vitest"
12
+ },
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "README.md"
22
+ ],
23
+ "keywords": [
24
+ "molecule",
25
+ "settings",
26
+ "panel",
27
+ "react"
28
+ ],
29
+ "license": "Apache-2.0",
30
+ "devDependencies": {
31
+ "@types/node": "26.1.2",
32
+ "typescript": "6.0.3",
33
+ "vitest": "4.1.10",
34
+ "@molecule/app-auth": "1.0.0",
35
+ "@molecule/app-react": "1.0.0",
36
+ "@molecule/app-ui": "1.0.0",
37
+ "@molecule/app-ui-react": "1.0.0",
38
+ "@types/react": "19.2.17",
39
+ "react": "19.2.8",
40
+ "react-router": "8.3.0"
41
+ },
42
+ "peerDependencies": {
43
+ "@molecule/app-auth": "^1.0.0",
44
+ "@molecule/app-react": "^1.0.0",
45
+ "@molecule/app-ui": "^1.0.0",
46
+ "@molecule/app-ui-react": "^1.0.0",
47
+ "react": "^18.0.0 || ^19.0.0",
48
+ "react-router": "^7.0.0 || ^8.0.0"
49
+ }
50
+ }