@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,93 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { useHttpClient, usePush, useTranslation } from '@molecule/app-react';
4
+ import { getClassMap } from '@molecule/app-ui';
5
+ import { Flex, Switch } from '@molecule/app-ui-react';
6
+ import { disablePushOnCurrentDevice, enablePushOnCurrentDevice, readCurrentDevicePushEnabled, } from './pushToggle.js';
7
+ /**
8
+ * Push-notification toggle section — the full receive-side enable chain:
9
+ * browser permission → runtime VAPID public key
10
+ * (`GET /api/devices/push/public-key`) → `pushManager.subscribe` with
11
+ * `applicationServerKey` → PATCH the subscription onto the current device
12
+ * row (`/api/devices/:id { pushSubscription, hasPushSubscription }`), which
13
+ * is where the api-side push fan-outs look for it.
14
+ *
15
+ * Initial state reflects SERVER truth (the current device row's
16
+ * `hasPushSubscription`), and every failure surfaces as an honest inline
17
+ * message — a dev build without a service worker fails fast instead of
18
+ * hanging the switch.
19
+ */
20
+ export function NotificationsSection() {
21
+ const cm = getClassMap();
22
+ const { t } = useTranslation();
23
+ const http = useHttpClient();
24
+ const { requestPermission, register, unregister } = usePush();
25
+ const [enabled, setEnabled] = useState(false);
26
+ const [busy, setBusy] = useState(false);
27
+ const [error, setError] = useState(null);
28
+ useEffect(() => {
29
+ let cancelled = false;
30
+ // Server truth for THIS device; resolves false (never throws) when the
31
+ // devices endpoint is unavailable.
32
+ readCurrentDevicePushEnabled(http).then((serverEnabled) => {
33
+ if (!cancelled && serverEnabled)
34
+ setEnabled(true);
35
+ });
36
+ return () => {
37
+ cancelled = true;
38
+ };
39
+ }, [http]);
40
+ const failureMessage = (result) => {
41
+ switch (result.reason) {
42
+ case 'permission-denied':
43
+ return t('settings.pushPermissionDenied', undefined, {
44
+ defaultValue: 'Notification permission was denied.',
45
+ });
46
+ case 'server-unconfigured':
47
+ return t('settings.pushServerUnavailable', undefined, {
48
+ defaultValue: 'Push notifications are not configured on this server.',
49
+ });
50
+ case 'register-failed':
51
+ // The push provider throws already-localized messages (not supported /
52
+ // no service worker in a dev build / push-service rejection).
53
+ return (result.message ||
54
+ t('settings.pushUpdateFailed', undefined, {
55
+ defaultValue: 'Could not update push notification settings.',
56
+ }));
57
+ case 'persist-failed':
58
+ return t('settings.pushUpdateFailed', undefined, {
59
+ defaultValue: 'Could not update push notification settings.',
60
+ });
61
+ }
62
+ };
63
+ const handleToggle = async (next) => {
64
+ if (busy)
65
+ return;
66
+ setBusy(true);
67
+ setError(null);
68
+ try {
69
+ if (next) {
70
+ const result = await enablePushOnCurrentDevice({ http, requestPermission, register });
71
+ if (result.ok) {
72
+ setEnabled(true);
73
+ }
74
+ else {
75
+ setError(failureMessage(result));
76
+ }
77
+ }
78
+ else {
79
+ const result = await disablePushOnCurrentDevice({ http, unregister });
80
+ // The switch goes off locally either way; a failed server clear is
81
+ // still reported so the user knows sends may continue.
82
+ setEnabled(false);
83
+ if (!result.ok) {
84
+ setError(failureMessage(result));
85
+ }
86
+ }
87
+ }
88
+ finally {
89
+ setBusy(false);
90
+ }
91
+ };
92
+ return (_jsxs("section", { children: [_jsx("h3", { className: cm.cn(cm.sectionHeading, cm.sp('mb', 3)), children: t('settings.notifications') }), _jsxs(Flex, { align: "center", justify: "between", children: [_jsx("span", { className: cm.textSize('sm'), children: t('settings.pushNotifications') }), _jsx(Switch, { checked: enabled, disabled: busy, onChange: (e) => handleToggle(e.target.checked), size: "sm", "data-mol-id": "settings-push-toggle" })] }), error ? (_jsx("p", { className: cm.cn(cm.textSize('xs'), cm.textError, cm.sp('mt', 1)), role: "alert", "data-mol-id": "settings-push-error", children: error })) : null] }));
93
+ }
@@ -0,0 +1,12 @@
1
+ import { type ReactElement, type ReactNode } from 'react';
2
+ /**
3
+ * Outer settings-panel layout: padded vertical stack that hosts the
4
+ * section sub-components. Publishes `onClose` to descendants via
5
+ * context so `<LogOutDeleteSection>` etc. can dismiss the panel
6
+ * after an action without explicit prop threading.
7
+ */
8
+ export declare function SettingsContainer({ onClose, children, }: {
9
+ onClose: () => void;
10
+ children: ReactNode;
11
+ }): ReactElement;
12
+ //# sourceMappingURL=SettingsContainer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SettingsContainer.d.ts","sourceRoot":"","sources":["../src/SettingsContainer.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,YAAY,EAAE,KAAK,SAAS,EAAW,MAAM,OAAO,CAAA;AAMlE;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,EAChC,OAAO,EACP,QAAQ,GACT,EAAE;IACD,OAAO,EAAE,MAAM,IAAI,CAAA;IACnB,QAAQ,EAAE,SAAS,CAAA;CACpB,GAAG,YAAY,CAQf"}
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useMemo } from 'react';
3
+ import { getClassMap } from '@molecule/app-ui';
4
+ import { SettingsPanelContext } from './context.js';
5
+ /**
6
+ * Outer settings-panel layout: padded vertical stack that hosts the
7
+ * section sub-components. Publishes `onClose` to descendants via
8
+ * context so `<LogOutDeleteSection>` etc. can dismiss the panel
9
+ * after an action without explicit prop threading.
10
+ */
11
+ export function SettingsContainer({ onClose, children, }) {
12
+ const cm = getClassMap();
13
+ const value = useMemo(() => ({ onClose }), [onClose]);
14
+ return (_jsx(SettingsPanelContext.Provider, { value: value, children: _jsx("div", { className: cm.cn(cm.sp('p', 6), cm.stack(6)), children: children }) }));
15
+ }
@@ -0,0 +1,9 @@
1
+ import type { JSX } from 'react';
2
+ /**
3
+ * "This device" detail strip — OS, browser, online/offline.
4
+ *
5
+ * Reads from `@molecule/app-device` (useDevice hook) + browser
6
+ * `navigator.onLine`. No side effects, no app-specific state.
7
+ */
8
+ export declare function ThisDeviceSection(): JSX.Element;
9
+ //# sourceMappingURL=ThisDeviceSection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ThisDeviceSection.d.ts","sourceRoot":"","sources":["../src/ThisDeviceSection.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,OAAO,CAAA;AAMhC;;;;;GAKG;AACH,wBAAgB,iBAAiB,IAAI,GAAG,CAAC,OAAO,CAuB/C"}
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useDevice, useTranslation } from '@molecule/app-react';
3
+ import { getClassMap } from '@molecule/app-ui';
4
+ import { Flex } from '@molecule/app-ui-react';
5
+ /**
6
+ * "This device" detail strip — OS, browser, online/offline.
7
+ *
8
+ * Reads from `@molecule/app-device` (useDevice hook) + browser
9
+ * `navigator.onLine`. No side effects, no app-specific state.
10
+ */
11
+ export function ThisDeviceSection() {
12
+ const cm = getClassMap();
13
+ const { t } = useTranslation();
14
+ const { deviceInfo } = useDevice();
15
+ return (_jsxs("section", { children: [_jsx("h3", { className: cm.cn(cm.sectionHeading, cm.sp('mb', 3)), children: t('settings.thisDevice') }), _jsxs("dl", { className: cm.cn(cm.stack(1), cm.textSize('sm')), children: [_jsxs(Flex, { justify: "between", children: [_jsx("dt", { className: cm.textMuted, children: t('settings.platform') }), _jsx("dd", { children: deviceInfo.os.name || t('settings.unknown') })] }), _jsxs(Flex, { justify: "between", children: [_jsx("dt", { className: cm.textMuted, children: t('settings.browser') }), _jsx("dd", { children: deviceInfo.browser.name || t('settings.unknown') })] }), _jsxs(Flex, { justify: "between", children: [_jsx("dt", { className: cm.textMuted, children: t('settings.network') }), _jsx("dd", { children: navigator.onLine ? t('settings.online') : t('settings.offline') })] })] })] }));
16
+ }
@@ -0,0 +1,15 @@
1
+ import { type JSX } from 'react';
2
+ /**
3
+ * Billing section with full multi-tier upgrade flow.
4
+ *
5
+ * Replaces the simpler `<BillingSection>` for apps backed by
6
+ * `@molecule/api-payments-stripe` + `@molecule/api-resource-payment`
7
+ * — loads the user's current plan from `/api/billing/status`,
8
+ * loads available tiers from `/api/billing/tiers`, and renders an
9
+ * Upgrade modal with a Subscribe button per tier price. Subscribe
10
+ * POSTs to `/api/billing/checkout` and redirects to the Stripe
11
+ * checkout URL; paid users see a Cancel button that POSTs to
12
+ * `/api/billing/cancel`.
13
+ */
14
+ export declare function TiersUpgradeSection(): JSX.Element;
15
+ //# sourceMappingURL=TiersUpgradeSection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"TiersUpgradeSection.d.ts","sourceRoot":"","sources":["../src/TiersUpgradeSection.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,GAAG,EAAuB,MAAM,OAAO,CAAA;AAkBrD;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,IAAI,GAAG,CAAC,OAAO,CA4LjD"}
@@ -0,0 +1,120 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { useHttpClient, useTranslation } from '@molecule/app-react';
4
+ import { getClassMap } from '@molecule/app-ui';
5
+ import { Alert, Button, Flex, Modal } from '@molecule/app-ui-react';
6
+ /**
7
+ * Billing section with full multi-tier upgrade flow.
8
+ *
9
+ * Replaces the simpler `<BillingSection>` for apps backed by
10
+ * `@molecule/api-payments-stripe` + `@molecule/api-resource-payment`
11
+ * — loads the user's current plan from `/api/billing/status`,
12
+ * loads available tiers from `/api/billing/tiers`, and renders an
13
+ * Upgrade modal with a Subscribe button per tier price. Subscribe
14
+ * POSTs to `/api/billing/checkout` and redirects to the Stripe
15
+ * checkout URL; paid users see a Cancel button that POSTs to
16
+ * `/api/billing/cancel`.
17
+ */
18
+ export function TiersUpgradeSection() {
19
+ const cm = getClassMap();
20
+ const { t } = useTranslation();
21
+ const http = useHttpClient();
22
+ const [plan, setPlan] = useState('Free');
23
+ const [planKey, setPlanKey] = useState('free');
24
+ const [tiers, setTiers] = useState([]);
25
+ const [showUpgrade, setShowUpgrade] = useState(false);
26
+ const [upgradeLoading, setUpgradeLoading] = useState(null);
27
+ const [upgradeError, setUpgradeError] = useState('');
28
+ useEffect(() => {
29
+ let cancelled = false;
30
+ Promise.all([
31
+ http.get('/api/billing/status').catch(() => null),
32
+ http
33
+ .get('/api/billing/tiers')
34
+ .catch(() => null),
35
+ ])
36
+ .then(([statusRes, tiersRes]) => {
37
+ if (cancelled)
38
+ return;
39
+ const status = statusRes?.data ?? null;
40
+ if (status?.name)
41
+ setPlan(status.name);
42
+ if (status?.planKey)
43
+ setPlanKey(status.planKey);
44
+ const list = tiersRes?.data?.data ?? [];
45
+ if (Array.isArray(list)) {
46
+ setTiers(list.map((tier) => ({
47
+ planKey: tier.key,
48
+ name: tier.name,
49
+ prices: (tier.prices || []).map((p) => ({
50
+ stripePriceId: p.stripePriceId ?? undefined,
51
+ interval: p.period,
52
+ priceLabel: p.price,
53
+ })),
54
+ })));
55
+ }
56
+ })
57
+ .catch(() => { });
58
+ return () => {
59
+ cancelled = true;
60
+ };
61
+ }, [http]);
62
+ const handleStartCheckout = async (stripePriceId) => {
63
+ setUpgradeError('');
64
+ setUpgradeLoading(stripePriceId);
65
+ try {
66
+ const res = await http.post('/api/billing/checkout', { priceId: stripePriceId });
67
+ const data = res.data;
68
+ if (data?.checkoutUrl) {
69
+ window.location.href = data.checkoutUrl;
70
+ return;
71
+ }
72
+ if (data?.updated) {
73
+ setShowUpgrade(false);
74
+ return;
75
+ }
76
+ setUpgradeError(t('settings.billing.checkoutFailed', undefined, {
77
+ defaultValue: 'Could not start checkout.',
78
+ }));
79
+ }
80
+ catch (err) {
81
+ setUpgradeError(err instanceof Error
82
+ ? err.message
83
+ : t('settings.billing.checkoutFailed', undefined, {
84
+ defaultValue: 'Could not start checkout.',
85
+ }));
86
+ }
87
+ finally {
88
+ setUpgradeLoading(null);
89
+ }
90
+ };
91
+ const handleCancelSubscription = async () => {
92
+ if (!window.confirm(t('settings.billing.cancelConfirm', undefined, {
93
+ defaultValue: 'Cancel your subscription?',
94
+ })))
95
+ return;
96
+ setUpgradeError('');
97
+ try {
98
+ await http.post('/api/billing/cancel', {});
99
+ setPlan('Free');
100
+ setPlanKey('free');
101
+ }
102
+ catch (err) {
103
+ setUpgradeError(err instanceof Error
104
+ ? err.message
105
+ : t('settings.billing.cancelFailed', undefined, {
106
+ defaultValue: 'Could not cancel.',
107
+ }));
108
+ }
109
+ };
110
+ return (_jsxs(_Fragment, { children: [_jsxs("section", { children: [_jsx("h3", { className: cm.cn(cm.sectionHeading, cm.sp('mb', 3)), children: t('settings.billing', undefined, { defaultValue: 'Billing' }) }), _jsxs(Flex, { align: "center", justify: "between", children: [_jsxs("span", { className: cm.textSize('sm'), children: [t('settings.plan', undefined, { defaultValue: 'Plan' }), _jsx("strong", { children: plan })] }), planKey === 'free' ? (_jsx(Button, { variant: "outline", size: "sm", onClick: () => setShowUpgrade(true), children: t('settings.upgrade', undefined, { defaultValue: 'Upgrade' }) })) : (_jsx(Button, { variant: "outline", size: "sm", onClick: handleCancelSubscription, children: t('settings.billing.cancel', undefined, { defaultValue: 'Cancel' }) }))] })] }), _jsxs(Modal, { open: showUpgrade, onClose: () => setShowUpgrade(false), title: t('settings.billing.upgradeTitle', undefined, {
111
+ defaultValue: 'Upgrade your plan',
112
+ }), children: [upgradeError ? (_jsx(Alert, { status: "error", className: cm.sp('mb', 4), children: upgradeError })) : null, tiers.length === 0 ? (_jsx("p", { className: cm.cn(cm.textSize('sm'), cm.textMuted), children: t('settings.billing.noTiers', undefined, {
113
+ defaultValue: 'No paid plans configured.',
114
+ }) })) : (_jsx("ul", { className: cm.stack(3), children: tiers
115
+ .filter((tier) => tier.planKey !== 'free')
116
+ .flatMap((tier) => tier.prices.filter((p) => p.stripePriceId).map((price) => ({ tier, price })))
117
+ .map(({ tier, price }) => (_jsx("li", { className: cm.sp('p', 4), children: _jsxs(Flex, { align: "center", justify: "between", children: [_jsxs("div", { children: [_jsxs("p", { className: cm.fontWeight('semibold'), children: [tier.name, " (", price.interval, ")"] }), _jsx("p", { className: cm.cn(cm.textSize('xs'), cm.textMuted), children: price.priceLabel })] }), _jsx(Button, { size: "sm", loading: upgradeLoading === price.stripePriceId, onClick: () => price.stripePriceId && handleStartCheckout(price.stripePriceId), children: t('settings.billing.subscribe', undefined, {
118
+ defaultValue: 'Subscribe',
119
+ }) })] }) }, `${tier.planKey}-${price.stripePriceId}`))) }))] })] }));
120
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Context published by `<SettingsContainer>` to its children.
3
+ *
4
+ * The container owns the dismiss-the-panel handler; sub-components
5
+ * that need to close after an action (logout, delete account, email
6
+ * error) read it from context rather than threading the prop down.
7
+ */
8
+ export interface SettingsPanelContextValue {
9
+ onClose: () => void;
10
+ }
11
+ /** React context object for the settings panel; consume via `useSettingsPanelContext`. */
12
+ export declare const SettingsPanelContext: import("react").Context<SettingsPanelContextValue | null>;
13
+ /**
14
+ * Reads the `onClose` handler exposed by the parent `<SettingsContainer>`.
15
+ * Throws if used outside the container so component misuse is loud.
16
+ */
17
+ export declare function useSettingsPanelContext(): SettingsPanelContextValue;
18
+ //# sourceMappingURL=context.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AAEA;;;;;;GAMG;AACH,MAAM,WAAW,yBAAyB;IACxC,OAAO,EAAE,MAAM,IAAI,CAAA;CACpB;AAID,0FAA0F;AAC1F,eAAO,MAAM,oBAAoB,2DAAM,CAAA;AAEvC;;;GAGG;AACH,wBAAgB,uBAAuB,IAAI,yBAAyB,CAQnE"}
@@ -0,0 +1,15 @@
1
+ import { createContext, useContext } from 'react';
2
+ const ctx = createContext(null);
3
+ /** React context object for the settings panel; consume via `useSettingsPanelContext`. */
4
+ export const SettingsPanelContext = ctx;
5
+ /**
6
+ * Reads the `onClose` handler exposed by the parent `<SettingsContainer>`.
7
+ * Throws if used outside the container so component misuse is loud.
8
+ */
9
+ export function useSettingsPanelContext() {
10
+ const value = useContext(ctx);
11
+ if (!value) {
12
+ throw new Error('Settings panel section components must be rendered inside <SettingsContainer>.');
13
+ }
14
+ return value;
15
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * `@molecule/app-settings-panel-react` — composable, batteries-included
3
+ * settings panel.
4
+ *
5
+ * `<SettingsContainer onClose={…}>` owns the layout and publishes `onClose`
6
+ * via context; each section component is independent and loads its own data
7
+ * through hooks:
8
+ * - `<AccountSection>` — name/email edit (`PATCH /api/users/:id`).
9
+ * - `<AppearanceSection>` — dark-mode toggle (`useTheme()`).
10
+ * - `<AuthSection>` — password change + TOTP two-factor
11
+ * (`POST /api/users/:id/verify-two-factor`).
12
+ * - `<NotificationsSection>` — web-push toggle (see the exported
13
+ * `enablePushOnCurrentDevice` / `disablePushOnCurrentDevice` helpers and
14
+ * their documented device-row contract).
15
+ * - `<BillingSection>` — read-only plan display (`GET /api/billing/status`),
16
+ * or `<TiersUpgradeSection>` — full Stripe upgrade/cancel flow
17
+ * (`/api/billing/tiers|checkout|cancel`). Use one or the other, not both.
18
+ * - `<DevicesSection>` / `<ThisDeviceSection>` — device list + current
19
+ * device (`GET/DELETE /api/devices`).
20
+ * - `<LogOutDeleteSection>` — sign out + delete account
21
+ * (`DELETE /api/users/:id`).
22
+ * Apps compose via JSX children — pick sections, order them, and interleave
23
+ * custom sections.
24
+ *
25
+ * @example
26
+ * ```tsx
27
+ * import {
28
+ * AccountSection,
29
+ * AppearanceSection,
30
+ * AuthSection,
31
+ * BillingSection,
32
+ * DevicesSection,
33
+ * LogOutDeleteSection,
34
+ * NotificationsSection,
35
+ * SettingsContainer,
36
+ * ThisDeviceSection,
37
+ * } from '@molecule/app-settings-panel-react'
38
+ *
39
+ * function SettingsPanel({ onClose }: { onClose: () => void }) {
40
+ * return (
41
+ * <SettingsContainer onClose={onClose}>
42
+ * <AccountSection />
43
+ * <AppearanceSection />
44
+ * <AuthSection />
45
+ * <NotificationsSection />
46
+ * <BillingSection />
47
+ * <DevicesSection />
48
+ * <ThisDeviceSection />
49
+ * <LogOutDeleteSection />
50
+ * </SettingsContainer>
51
+ * )
52
+ * }
53
+ * ```
54
+ *
55
+ * @remarks
56
+ * - Wiring prereqs: sections need the standard `@molecule/app-react`
57
+ * provider stack — `<I18nProvider>`, `<HttpProvider>` (authenticated
58
+ * client), `<AuthProvider>`, `<ThemeProvider>` (AppearanceSection), push +
59
+ * device providers (NotificationsSection/ThisDeviceSection) — plus a
60
+ * bonded ClassMap. `<BillingSection>` and `<LogOutDeleteSection>` also
61
+ * call react-router's `useNavigate()` and throw outside a `<Router>`.
62
+ * - Section components throw if rendered outside `<SettingsContainer>`
63
+ * (they read its context for `onClose`).
64
+ * - Server contract: the molecule API surface from
65
+ * `@molecule/api-resource-user`, `@molecule/api-resource-device`,
66
+ * `@molecule/api-two-factor`, and the billing endpoints
67
+ * (`/api/billing/*`) wired by the payments stack. Missing read endpoints
68
+ * degrade gracefully (sections render empty); the mutating actions do not.
69
+ * - Translations: `@molecule/app-locales-settings-panel` companion bond.
70
+ *
71
+ * @module
72
+ */
73
+ export * from './AccountSection.js';
74
+ export * from './AppearanceSection.js';
75
+ export * from './AuthSection.js';
76
+ export * from './BillingSection.js';
77
+ export * from './context.js';
78
+ export * from './DevicesSection.js';
79
+ export * from './LogOutDeleteSection.js';
80
+ export * from './NotificationsSection.js';
81
+ export * from './pushToggle.js';
82
+ export * from './SettingsContainer.js';
83
+ export * from './ThisDeviceSection.js';
84
+ export * from './TiersUpgradeSection.js';
85
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuEG;AAEH,cAAc,qBAAqB,CAAA;AACnC,cAAc,wBAAwB,CAAA;AACtC,cAAc,kBAAkB,CAAA;AAChC,cAAc,qBAAqB,CAAA;AACnC,cAAc,cAAc,CAAA;AAC5B,cAAc,qBAAqB,CAAA;AACnC,cAAc,0BAA0B,CAAA;AACxC,cAAc,2BAA2B,CAAA;AACzC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,wBAAwB,CAAA;AACtC,cAAc,wBAAwB,CAAA;AACtC,cAAc,0BAA0B,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * `@molecule/app-settings-panel-react` — composable, batteries-included
3
+ * settings panel.
4
+ *
5
+ * `<SettingsContainer onClose={…}>` owns the layout and publishes `onClose`
6
+ * via context; each section component is independent and loads its own data
7
+ * through hooks:
8
+ * - `<AccountSection>` — name/email edit (`PATCH /api/users/:id`).
9
+ * - `<AppearanceSection>` — dark-mode toggle (`useTheme()`).
10
+ * - `<AuthSection>` — password change + TOTP two-factor
11
+ * (`POST /api/users/:id/verify-two-factor`).
12
+ * - `<NotificationsSection>` — web-push toggle (see the exported
13
+ * `enablePushOnCurrentDevice` / `disablePushOnCurrentDevice` helpers and
14
+ * their documented device-row contract).
15
+ * - `<BillingSection>` — read-only plan display (`GET /api/billing/status`),
16
+ * or `<TiersUpgradeSection>` — full Stripe upgrade/cancel flow
17
+ * (`/api/billing/tiers|checkout|cancel`). Use one or the other, not both.
18
+ * - `<DevicesSection>` / `<ThisDeviceSection>` — device list + current
19
+ * device (`GET/DELETE /api/devices`).
20
+ * - `<LogOutDeleteSection>` — sign out + delete account
21
+ * (`DELETE /api/users/:id`).
22
+ * Apps compose via JSX children — pick sections, order them, and interleave
23
+ * custom sections.
24
+ *
25
+ * @example
26
+ * ```tsx
27
+ * import {
28
+ * AccountSection,
29
+ * AppearanceSection,
30
+ * AuthSection,
31
+ * BillingSection,
32
+ * DevicesSection,
33
+ * LogOutDeleteSection,
34
+ * NotificationsSection,
35
+ * SettingsContainer,
36
+ * ThisDeviceSection,
37
+ * } from '@molecule/app-settings-panel-react'
38
+ *
39
+ * function SettingsPanel({ onClose }: { onClose: () => void }) {
40
+ * return (
41
+ * <SettingsContainer onClose={onClose}>
42
+ * <AccountSection />
43
+ * <AppearanceSection />
44
+ * <AuthSection />
45
+ * <NotificationsSection />
46
+ * <BillingSection />
47
+ * <DevicesSection />
48
+ * <ThisDeviceSection />
49
+ * <LogOutDeleteSection />
50
+ * </SettingsContainer>
51
+ * )
52
+ * }
53
+ * ```
54
+ *
55
+ * @remarks
56
+ * - Wiring prereqs: sections need the standard `@molecule/app-react`
57
+ * provider stack — `<I18nProvider>`, `<HttpProvider>` (authenticated
58
+ * client), `<AuthProvider>`, `<ThemeProvider>` (AppearanceSection), push +
59
+ * device providers (NotificationsSection/ThisDeviceSection) — plus a
60
+ * bonded ClassMap. `<BillingSection>` and `<LogOutDeleteSection>` also
61
+ * call react-router's `useNavigate()` and throw outside a `<Router>`.
62
+ * - Section components throw if rendered outside `<SettingsContainer>`
63
+ * (they read its context for `onClose`).
64
+ * - Server contract: the molecule API surface from
65
+ * `@molecule/api-resource-user`, `@molecule/api-resource-device`,
66
+ * `@molecule/api-two-factor`, and the billing endpoints
67
+ * (`/api/billing/*`) wired by the payments stack. Missing read endpoints
68
+ * degrade gracefully (sections render empty); the mutating actions do not.
69
+ * - Translations: `@molecule/app-locales-settings-panel` companion bond.
70
+ *
71
+ * @module
72
+ */
73
+ export * from './AccountSection.js';
74
+ export * from './AppearanceSection.js';
75
+ export * from './AuthSection.js';
76
+ export * from './BillingSection.js';
77
+ export * from './context.js';
78
+ export * from './DevicesSection.js';
79
+ export * from './LogOutDeleteSection.js';
80
+ export * from './NotificationsSection.js';
81
+ export * from './pushToggle.js';
82
+ export * from './SettingsContainer.js';
83
+ export * from './ThisDeviceSection.js';
84
+ export * from './TiersUpgradeSection.js';
@@ -0,0 +1,111 @@
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
+ /** Minimal structural slice of `@molecule/app-http`'s HttpClient used here. */
22
+ export interface PushToggleHttp {
23
+ get<T = unknown>(url: string): Promise<{
24
+ data: T;
25
+ }>;
26
+ patch<T = unknown>(url: string, data?: unknown): Promise<{
27
+ data: T;
28
+ }>;
29
+ }
30
+ /** Device row slice returned by `GET /api/devices`. */
31
+ export interface PushToggleDevice {
32
+ id: string;
33
+ isCurrent?: boolean;
34
+ hasPushSubscription?: boolean;
35
+ }
36
+ /** Push token slice returned by `@molecule/app-push` `register()`. */
37
+ export interface PushToggleToken {
38
+ value: string;
39
+ platform: 'web' | 'ios' | 'android';
40
+ }
41
+ /** Why an enable/disable attempt failed (mapped to i18n by the component). */
42
+ export type PushToggleFailureReason = 'permission-denied' | 'server-unconfigured' | 'register-failed' | 'persist-failed';
43
+ /** Result of an enable/disable attempt. */
44
+ export type PushToggleResult = {
45
+ ok: true;
46
+ } | {
47
+ ok: false;
48
+ reason: PushToggleFailureReason;
49
+ message?: string;
50
+ };
51
+ /**
52
+ * Picks the caller's own device row: the api flags the session device
53
+ * `isCurrent`; fall back to the first row for sessions predating the flag.
54
+ *
55
+ * @param devices - Rows from `GET /api/devices`.
56
+ * @returns The current device row, or `undefined` when the user has none.
57
+ */
58
+ export declare const findCurrentDevice: (devices: PushToggleDevice[] | undefined) => PushToggleDevice | undefined;
59
+ /**
60
+ * Converts an `@molecule/app-push` token into the device resource's
61
+ * `pushSubscription` shape (web PushSubscription JSON, FCM registration for
62
+ * Android, APNs registration for iOS).
63
+ *
64
+ * @param token - The push token returned by `register()`.
65
+ * @returns The `pushSubscription` value to PATCH onto the device row.
66
+ */
67
+ export declare const subscriptionFromToken: (token: PushToggleToken) => unknown;
68
+ /**
69
+ * Runs the full enable chain: browser permission → runtime VAPID public key
70
+ * (`GET /api/devices/push/public-key`) → `register({ vapidPublicKey })` →
71
+ * persist the subscription on the current device row.
72
+ *
73
+ * Every failure is returned as a typed reason (never thrown) so the UI can
74
+ * show an honest, specific message instead of hanging or silently reverting.
75
+ *
76
+ * @param deps - The http client + push actions from `usePush()`.
77
+ * @param deps.http - Authenticated http client (`useHttpClient()`).
78
+ * @param deps.requestPermission - `usePush().requestPermission`.
79
+ * @param deps.register - `usePush().register`.
80
+ * @returns `{ ok: true }` or a typed failure.
81
+ */
82
+ export declare const enablePushOnCurrentDevice: (deps: {
83
+ http: PushToggleHttp;
84
+ requestPermission: () => Promise<string>;
85
+ register: (options?: {
86
+ vapidPublicKey?: string;
87
+ }) => Promise<PushToggleToken>;
88
+ }) => Promise<PushToggleResult>;
89
+ /**
90
+ * Disables push: unsubscribes the browser (best-effort — a dev build without
91
+ * a service worker has nothing to unsubscribe) and ALWAYS clears the server
92
+ * state so no further pushes target this device.
93
+ *
94
+ * @param deps - The http client + push action from `usePush()`.
95
+ * @param deps.http - Authenticated http client (`useHttpClient()`).
96
+ * @param deps.unregister - `usePush().unregister`.
97
+ * @returns `{ ok: true }` or a typed failure (server state not cleared).
98
+ */
99
+ export declare const disablePushOnCurrentDevice: (deps: {
100
+ http: PushToggleHttp;
101
+ unregister: () => Promise<void>;
102
+ }) => Promise<PushToggleResult>;
103
+ /**
104
+ * Reads whether push is currently enabled for THIS device (server truth:
105
+ * the current device row's `hasPushSubscription`).
106
+ *
107
+ * @param http - Authenticated http client (`useHttpClient()`).
108
+ * @returns `true` when the current device has a stored subscription.
109
+ */
110
+ export declare const readCurrentDevicePushEnabled: (http: PushToggleHttp) => Promise<boolean>;
111
+ //# sourceMappingURL=pushToggle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pushToggle.d.ts","sourceRoot":"","sources":["../src/pushToggle.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,+EAA+E;AAC/E,MAAM,WAAW,cAAc;IAC7B,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,CAAC,CAAA;KAAE,CAAC,CAAA;IACnD,KAAK,CAAC,CAAC,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,CAAC,CAAA;KAAE,CAAC,CAAA;CACtE;AAED,uDAAuD;AACvD,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAA;IACV,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,mBAAmB,CAAC,EAAE,OAAO,CAAA;CAC9B;AAED,sEAAsE;AACtE,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,KAAK,GAAG,KAAK,GAAG,SAAS,CAAA;CACpC;AAED,8EAA8E;AAC9E,MAAM,MAAM,uBAAuB,GACjC,mBAAmB,GAAG,qBAAqB,GAAG,iBAAiB,GAAG,gBAAgB,CAAA;AAEpF,2CAA2C;AAC3C,MAAM,MAAM,gBAAgB,GAC1B;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,uBAAuB,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,CAAA;AAEjF;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,GAC5B,SAAS,gBAAgB,EAAE,GAAG,SAAS,KACtC,gBAAgB,GAAG,SAGrB,CAAA;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,qBAAqB,GAAI,OAAO,eAAe,KAAG,OAS9D,CAAA;AAED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,yBAAyB,GAAU,MAAM;IACpD,IAAI,EAAE,cAAc,CAAA;IACpB,iBAAiB,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAA;IACxC,QAAQ,EAAE,CAAC,OAAO,CAAC,EAAE;QAAE,cAAc,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,eAAe,CAAC,CAAA;CAC9E,KAAG,OAAO,CAAC,gBAAgB,CAmD3B,CAAA;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,0BAA0B,GAAU,MAAM;IACrD,IAAI,EAAE,cAAc,CAAA;IACpB,UAAU,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAChC,KAAG,OAAO,CAAC,gBAAgB,CA0B3B,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,GAAU,MAAM,cAAc,KAAG,OAAO,CAAC,OAAO,CASxF,CAAA"}