@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,83 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { useAuthClient, useHttpClient, useTranslation } from '@molecule/app-react';
4
+ import { getClassMap } from '@molecule/app-ui';
5
+ import { Flex, Input, Spinner } from '@molecule/app-ui-react';
6
+ /**
7
+ * Account section — edits the user's display name + email. Fetches
8
+ * `/users/me` on mount to refresh the current user record (and write it
9
+ * back to the auth cache so subsequent reloads see the latest data). On
10
+ * blur of either field, PATCHes `/api/users/:id` with the changed field;
11
+ * reverts + shows an inline error if the request fails.
12
+ *
13
+ * The user resource's update handler accepts `name`, `username`, and
14
+ * `email`; this surfaces `name` + `email` (the universally-present
15
+ * profile fields). Apps that use a public `username` handle can extend
16
+ * this with a username field the same way.
17
+ */
18
+ export function AccountSection() {
19
+ const cm = getClassMap();
20
+ const { t } = useTranslation();
21
+ const authClient = useAuthClient();
22
+ const http = useHttpClient();
23
+ const cachedUser = authClient.getState().user;
24
+ const [email, setEmail] = useState(cachedUser?.email || '');
25
+ const [name, setName] = useState(cachedUser?.name || '');
26
+ const [saving, setSaving] = useState(false);
27
+ const [error, setError] = useState('');
28
+ // Refresh the current user via the canonical `/users/me` endpoint on
29
+ // mount and push the result into the auth cache so reloads pick up
30
+ // fresh data. Failures are silent — the cached profile is the fallback.
31
+ useEffect(() => {
32
+ let cancelled = false;
33
+ http
34
+ .get('/api/users/me')
35
+ .then((res) => {
36
+ if (cancelled)
37
+ return;
38
+ const body = res.data ?? res;
39
+ const next = (body.props ?? body);
40
+ if (next && typeof next === 'object' && next.id) {
41
+ authClient.setUser(next);
42
+ setEmail((curr) => curr === (cachedUser?.email || '') ? next.email || curr : curr);
43
+ setName((curr) => curr === (cachedUser?.name || '') ? next.name || curr : curr);
44
+ }
45
+ })
46
+ .catch(() => {
47
+ /* refresh is best-effort */
48
+ });
49
+ return () => {
50
+ cancelled = true;
51
+ };
52
+ }, [http, authClient, cachedUser?.email, cachedUser?.name]);
53
+ const saveField = async (field, value) => {
54
+ const currentUser = authClient.getState().user;
55
+ if (value === (currentUser?.[field] || ''))
56
+ return;
57
+ if (field === 'email' && !value)
58
+ return;
59
+ setSaving(true);
60
+ setError('');
61
+ try {
62
+ await http.patch(`/api/users/${currentUser?.id}`, { [field]: value });
63
+ // Update the auth cache so a reload reflects the new value.
64
+ if (currentUser)
65
+ authClient.setUser({ ...currentUser, [field]: value });
66
+ }
67
+ catch (err) {
68
+ setError(err instanceof Error
69
+ ? err.message
70
+ : t('settings.failedToUpdateProfile', undefined, {
71
+ defaultValue: 'Failed to update profile',
72
+ }));
73
+ if (field === 'email')
74
+ setEmail(currentUser?.email || '');
75
+ else
76
+ setName(currentUser?.name || '');
77
+ }
78
+ finally {
79
+ setSaving(false);
80
+ }
81
+ };
82
+ return (_jsxs("section", { children: [_jsxs(Flex, { align: "center", gap: "sm", className: cm.sp('mb', 3), children: [_jsx("h3", { className: cm.sectionHeading, children: t('settings.account') }), saving && _jsx(Spinner, { size: "xs" })] }), _jsxs("div", { className: cm.stack(3), children: [_jsxs("div", { children: [_jsx("label", { htmlFor: "settings-name", className: cm.formLabelSmall, children: t('settings.name', undefined, { defaultValue: 'Name' }) }), _jsx(Input, { id: "settings-name", type: "text", value: name, onChange: (e) => setName(e.target.value), onBlur: () => saveField('name', name), autoComplete: "name", size: "sm" })] }), _jsxs("div", { children: [_jsx("label", { htmlFor: "settings-email", className: cm.formLabelSmall, children: t('settings.email') }), _jsx(Input, { id: "settings-email", type: "email", value: email, onChange: (e) => setEmail(e.target.value), onBlur: () => saveField('email', email), autoComplete: "email", size: "sm" }), error && (_jsx("p", { role: "alert", className: cm.cn(cm.textSize('xs'), cm.textError, cm.sp('mt', 1)), children: error }))] })] })] }));
83
+ }
@@ -0,0 +1,10 @@
1
+ import type { JSX } from 'react';
2
+ /**
3
+ * Appearance section — dark-mode toggle wired to the theme bond.
4
+ *
5
+ * Apps that expose other appearance knobs (font size, density, etc.)
6
+ * can either add more sub-components to this package or render their
7
+ * own section in line with `<AppearanceSection>` via children.
8
+ */
9
+ export declare function AppearanceSection(): JSX.Element;
10
+ //# sourceMappingURL=AppearanceSection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AppearanceSection.d.ts","sourceRoot":"","sources":["../src/AppearanceSection.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,OAAO,CAAA;AAMhC;;;;;;GAMG;AACH,wBAAgB,iBAAiB,IAAI,GAAG,CAAC,OAAO,CAwB/C"}
@@ -0,0 +1,17 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useTheme, useTranslation } from '@molecule/app-react';
3
+ import { getClassMap } from '@molecule/app-ui';
4
+ import { Flex, Switch } from '@molecule/app-ui-react';
5
+ /**
6
+ * Appearance section — dark-mode toggle wired to the theme bond.
7
+ *
8
+ * Apps that expose other appearance knobs (font size, density, etc.)
9
+ * can either add more sub-components to this package or render their
10
+ * own section in line with `<AppearanceSection>` via children.
11
+ */
12
+ export function AppearanceSection() {
13
+ const cm = getClassMap();
14
+ const { t } = useTranslation();
15
+ const { mode, toggleTheme } = useTheme();
16
+ return (_jsxs("section", { children: [_jsx("h3", { className: cm.cn(cm.sectionHeading, cm.sp('mb', 3)), children: t('settings.appearance', undefined, { defaultValue: 'Appearance' }) }), _jsxs(Flex, { align: "center", justify: "between", children: [_jsx("span", { className: cm.textSize('sm'), children: t('settings.darkMode', undefined, { defaultValue: 'Dark mode' }) }), _jsx(Switch, { id: "settings-theme-toggle", "aria-label": t('theme.toggle', undefined, { defaultValue: 'Toggle theme' }), checked: mode === 'dark', onChange: () => toggleTheme(), size: "sm" })] })] }));
17
+ }
@@ -0,0 +1,19 @@
1
+ import type { JSX } from 'react';
2
+ /**
3
+ * Authentication section — change password (modal) + two-factor (TOTP) setup.
4
+ *
5
+ * Two-factor uses the real enrollment flow against the user resource's
6
+ * `POST /users/:id/verify-two-factor` endpoint (`@molecule/api-two-factor`):
7
+ * - Enable → `{action:'setup'}` returns a QR code + secret to scan into an
8
+ * authenticator app → user enters the 6-digit code → `{action:'enable', token}`.
9
+ * - Disable → user enters a current code → `{action:'disable', token}`.
10
+ * The current status is read from `/users/me`. (This replaces the previous
11
+ * boolean toggle, which PATCHed `twoFactorEnabled` — a field the update
12
+ * handler deliberately ignores — so it never actually enrolled 2FA.)
13
+ *
14
+ * Auto-hides for OAuth-only users (`user.oauthServer` truthy) since
15
+ * password / 2FA wouldn't apply. If the app's API has no two-factor
16
+ * provider bonded, setup fails gracefully with an inline message.
17
+ */
18
+ export declare function AuthSection(): JSX.Element | null;
19
+ //# sourceMappingURL=AuthSection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AuthSection.d.ts","sourceRoot":"","sources":["../src/AuthSection.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,OAAO,CAAA;AAchC;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,WAAW,IAAI,GAAG,CAAC,OAAO,GAAG,IAAI,CA8QhD"}
@@ -0,0 +1,141 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { useAuth, useChangePassword, useHttpClient, useTranslation } from '@molecule/app-react';
4
+ import { getClassMap } from '@molecule/app-ui';
5
+ import { Alert, Button, Flex, Input, Modal } from '@molecule/app-ui-react';
6
+ /**
7
+ * Authentication section — change password (modal) + two-factor (TOTP) setup.
8
+ *
9
+ * Two-factor uses the real enrollment flow against the user resource's
10
+ * `POST /users/:id/verify-two-factor` endpoint (`@molecule/api-two-factor`):
11
+ * - Enable → `{action:'setup'}` returns a QR code + secret to scan into an
12
+ * authenticator app → user enters the 6-digit code → `{action:'enable', token}`.
13
+ * - Disable → user enters a current code → `{action:'disable', token}`.
14
+ * The current status is read from `/users/me`. (This replaces the previous
15
+ * boolean toggle, which PATCHed `twoFactorEnabled` — a field the update
16
+ * handler deliberately ignores — so it never actually enrolled 2FA.)
17
+ *
18
+ * Auto-hides for OAuth-only users (`user.oauthServer` truthy) since
19
+ * password / 2FA wouldn't apply. If the app's API has no two-factor
20
+ * provider bonded, setup fails gracefully with an inline message.
21
+ */
22
+ export function AuthSection() {
23
+ const cm = getClassMap();
24
+ const { t } = useTranslation();
25
+ const { user } = useAuth();
26
+ const http = useHttpClient();
27
+ const { status, error, changePassword, reset } = useChangePassword();
28
+ const [showModal, setShowModal] = useState(false);
29
+ const [currentPassword, setCurrentPassword] = useState('');
30
+ const [newPassword, setNewPassword] = useState('');
31
+ const userId = user?.id;
32
+ const [twoFactorEnabled, setTwoFactorEnabled] = useState(false);
33
+ const [tfModal, setTfModal] = useState(null);
34
+ const [tfSetup, setTfSetup] = useState(null);
35
+ const [tfToken, setTfToken] = useState('');
36
+ const [tfLoading, setTfLoading] = useState(false);
37
+ const [tfError, setTfError] = useState('');
38
+ // Read the persisted 2FA status from /users/me on mount.
39
+ useEffect(() => {
40
+ let cancelled = false;
41
+ http
42
+ .get('/api/users/me')
43
+ .then((res) => {
44
+ if (cancelled)
45
+ return;
46
+ const body = res.data ?? res;
47
+ const u = (body.props ?? body);
48
+ setTwoFactorEnabled(Boolean(u?.twoFactorEnabled));
49
+ })
50
+ .catch(() => {
51
+ /* status read is best-effort; defaults to disabled */
52
+ });
53
+ return () => {
54
+ cancelled = true;
55
+ };
56
+ }, [http]);
57
+ // OAuth users skip this section entirely.
58
+ if (user?.oauthServer)
59
+ return null;
60
+ const closeTf = () => {
61
+ setTfModal(null);
62
+ setTfSetup(null);
63
+ setTfToken('');
64
+ setTfError('');
65
+ };
66
+ const startEnable = async () => {
67
+ setTfError('');
68
+ setTfLoading(true);
69
+ setTfModal('enable');
70
+ try {
71
+ const res = await http.post(`/api/users/${userId}/verify-two-factor`, {
72
+ action: 'setup',
73
+ });
74
+ const data = (res.data ?? res);
75
+ setTfSetup(data);
76
+ }
77
+ catch (err) {
78
+ setTfError(err instanceof Error
79
+ ? err.message
80
+ : t('settings.twoFactorUnavailable', undefined, {
81
+ defaultValue: 'Two-factor authentication is not available for this app.',
82
+ }));
83
+ }
84
+ finally {
85
+ setTfLoading(false);
86
+ }
87
+ };
88
+ const submitTf = async (action) => {
89
+ setTfError('');
90
+ setTfLoading(true);
91
+ try {
92
+ await http.post(`/api/users/${userId}/verify-two-factor`, { action, token: tfToken });
93
+ setTwoFactorEnabled(action === 'enable');
94
+ closeTf();
95
+ }
96
+ catch (err) {
97
+ setTfError(err instanceof Error
98
+ ? err.message
99
+ : t('settings.twoFactorInvalidCode', undefined, { defaultValue: 'Invalid code' }));
100
+ }
101
+ finally {
102
+ setTfLoading(false);
103
+ }
104
+ };
105
+ const handleChangePassword = async (e) => {
106
+ e.preventDefault();
107
+ try {
108
+ await changePassword(currentPassword, newPassword);
109
+ setShowModal(false);
110
+ setCurrentPassword('');
111
+ setNewPassword('');
112
+ reset();
113
+ }
114
+ catch (_error) {
115
+ // Error tracked in hook state — useChangePassword surfaces it via `error`.
116
+ }
117
+ };
118
+ return (_jsxs(_Fragment, { children: [_jsxs("section", { children: [_jsx("h3", { className: cm.cn(cm.sectionHeading, cm.sp('mb', 3)), children: t('settings.authentication') }), _jsxs("div", { className: cm.stack(3), children: [_jsx(Button, { variant: "outline", size: "sm", onClick: () => setShowModal(true), children: t('settings.changePassword') }), _jsxs(Flex, { align: "center", justify: "between", children: [_jsxs("span", { className: cm.textSize('sm'), children: [t('settings.twoFactor'), twoFactorEnabled ? (_jsx("span", { className: cm.cn(cm.textSize('xs'), cm.textSubtle, cm.sp('ml', 2)), children: t('settings.twoFactorOn', undefined, { defaultValue: 'On' }) })) : null] }), twoFactorEnabled ? (_jsx(Button, { variant: "outline", size: "sm", onClick: () => {
119
+ setTfError('');
120
+ setTfToken('');
121
+ setTfModal('disable');
122
+ }, "data-mol-id": "two-factor-disable", children: t('settings.twoFactorDisable', undefined, { defaultValue: 'Disable' }) })) : (_jsx(Button, { variant: "outline", size: "sm", onClick: startEnable, "data-mol-id": "two-factor-enable", children: t('settings.twoFactorEnable', undefined, { defaultValue: 'Enable' }) }))] })] })] }), _jsxs(Modal, { open: showModal, onClose: () => {
123
+ setShowModal(false);
124
+ reset();
125
+ }, title: t('settings.changePasswordModal.title'), children: [error && (_jsx(Alert, { status: "error", className: cm.sp('mb', 4), children: error.message || t('settings.changePasswordModal.error') })), _jsxs("form", { onSubmit: handleChangePassword, className: cm.stack(4), children: [_jsxs("div", { children: [_jsx("label", { htmlFor: "current-pw", className: cm.formLabel, children: t('settings.changePasswordModal.currentPassword') }), _jsx(Input, { id: "current-pw", type: "password", value: currentPassword, onChange: (e) => setCurrentPassword(e.target.value), autoComplete: "current-password", required: true })] }), _jsxs("div", { children: [_jsx("label", { htmlFor: "new-pw", className: cm.formLabel, children: t('settings.changePasswordModal.newPassword') }), _jsx(Input, { id: "new-pw", type: "password", value: newPassword, onChange: (e) => setNewPassword(e.target.value), autoComplete: "new-password", required: true })] }), _jsx(Button, { type: "submit", fullWidth: true, loading: status === 'pending', children: status === 'pending'
126
+ ? t('settings.changePasswordModal.changing')
127
+ : t('settings.changePassword') })] })] }), _jsxs(Modal, { open: tfModal !== null, onClose: closeTf, title: tfModal === 'disable'
128
+ ? t('settings.twoFactorDisableTitle', undefined, {
129
+ defaultValue: 'Disable two-factor authentication',
130
+ })
131
+ : t('settings.twoFactorEnableTitle', undefined, {
132
+ defaultValue: 'Enable two-factor authentication',
133
+ }), children: [tfError && (_jsx(Alert, { status: "error", className: cm.sp('mb', 4), children: tfError })), tfModal === 'enable' && tfSetup?.QRImageUrl ? (_jsxs("div", { className: cm.cn(cm.stack(3), cm.sp('mb', 4)), children: [_jsx("p", { className: cm.cn(cm.textSize('sm'), cm.textMuted), children: t('settings.twoFactorScan', undefined, {
134
+ defaultValue: 'Scan this QR code with your authenticator app, then enter the 6-digit code.',
135
+ }) }), _jsx("img", { src: tfSetup.QRImageUrl, alt: t('settings.twoFactorQrAlt', undefined, { defaultValue: 'Two-factor QR code' }), className: cm.cn('mx-auto', cm.maxW('xs')) }), tfSetup.secret ? (_jsx("p", { className: cm.cn(cm.textSize('xs'), cm.textSubtle, 'text-center break-all'), children: tfSetup.secret })) : null] })) : null, (tfModal === 'disable' || tfSetup) && (_jsxs("form", { onSubmit: (e) => {
136
+ e.preventDefault();
137
+ void submitTf(tfModal === 'disable' ? 'disable' : 'enable');
138
+ }, className: cm.stack(4), children: [_jsxs("div", { children: [_jsx("label", { htmlFor: "tf-token", className: cm.formLabel, children: t('settings.twoFactorCode', undefined, { defaultValue: 'Authentication code' }) }), _jsx(Input, { id: "tf-token", type: "text", value: tfToken, onChange: (e) => setTfToken(e.target.value), autoComplete: "one-time-code", required: true })] }), _jsx(Button, { type: "submit", color: tfModal === 'disable' ? 'error' : undefined, fullWidth: true, loading: tfLoading, children: tfModal === 'disable'
139
+ ? t('settings.twoFactorDisable', undefined, { defaultValue: 'Disable' })
140
+ : t('settings.twoFactorVerify', undefined, { defaultValue: 'Verify & enable' }) })] }))] })] }));
141
+ }
@@ -0,0 +1,19 @@
1
+ import { type JSX } from 'react';
2
+ /**
3
+ * Billing section — current plan + Upgrade button.
4
+ *
5
+ * Fetches `/api/billing/status` on mount and uses the returned plan
6
+ * name as a more accurate label than the parent-supplied default.
7
+ * The `plan` prop remains a fallback (e.g. for offline rendering or
8
+ * apps that don't expose `/billing/status`).
9
+ *
10
+ * The Upgrade button navigates to `upgradeTo` (default `/settings`).
11
+ * Pass `upgradeTo="/billing"` or `upgradeTo="/pricing"` if your app
12
+ * routes elsewhere. Apps with a multi-tier checkout flow should use
13
+ * `<TiersUpgradeSection>` instead.
14
+ */
15
+ export declare function BillingSection({ plan, upgradeTo, }?: {
16
+ plan?: string;
17
+ upgradeTo?: string;
18
+ }): JSX.Element;
19
+ //# sourceMappingURL=BillingSection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"BillingSection.d.ts","sourceRoot":"","sources":["../src/BillingSection.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,GAAG,EAAuB,MAAM,OAAO,CAAA;AAOrD;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAAC,EAC7B,IAAa,EACb,SAAuB,GACxB,GAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,GAAG,CAAC,OAAO,CA2C1D"}
@@ -0,0 +1,49 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { useNavigate } from 'react-router';
4
+ import { useHttpClient, useTranslation } from '@molecule/app-react';
5
+ import { getClassMap } from '@molecule/app-ui';
6
+ import { Button, Flex } from '@molecule/app-ui-react';
7
+ /**
8
+ * Billing section — current plan + Upgrade button.
9
+ *
10
+ * Fetches `/api/billing/status` on mount and uses the returned plan
11
+ * name as a more accurate label than the parent-supplied default.
12
+ * The `plan` prop remains a fallback (e.g. for offline rendering or
13
+ * apps that don't expose `/billing/status`).
14
+ *
15
+ * The Upgrade button navigates to `upgradeTo` (default `/settings`).
16
+ * Pass `upgradeTo="/billing"` or `upgradeTo="/pricing"` if your app
17
+ * routes elsewhere. Apps with a multi-tier checkout flow should use
18
+ * `<TiersUpgradeSection>` instead.
19
+ */
20
+ export function BillingSection({ plan = 'Free', upgradeTo = '/settings', } = {}) {
21
+ const cm = getClassMap();
22
+ const { t } = useTranslation();
23
+ const navigate = useNavigate();
24
+ const http = useHttpClient();
25
+ const [livePlan, setLivePlan] = useState(null);
26
+ // Refresh the current plan from the bonded entitlements/billing
27
+ // status endpoint on mount. Fire-and-forget; the prop-supplied
28
+ // `plan` is the fallback if the fetch fails.
29
+ useEffect(() => {
30
+ let cancelled = false;
31
+ http
32
+ .get('/api/billing/status')
33
+ .then((res) => {
34
+ if (cancelled)
35
+ return;
36
+ const body = res.data ?? res;
37
+ const name = body?.name;
38
+ if (typeof name === 'string' && name)
39
+ setLivePlan(name);
40
+ })
41
+ .catch(() => {
42
+ /* fallback to the prop-supplied plan label */
43
+ });
44
+ return () => {
45
+ cancelled = true;
46
+ };
47
+ }, [http]);
48
+ return (_jsxs("section", { children: [_jsx("h3", { className: cm.cn(cm.sectionHeading, cm.sp('mb', 3)), children: t('settings.billing') }), _jsxs(Flex, { align: "center", justify: "between", children: [_jsxs("span", { className: cm.textSize('sm'), children: [t('settings.plan'), _jsx("strong", { children: livePlan ?? plan })] }), _jsx(Button, { variant: "outline", size: "sm", onClick: () => navigate(upgradeTo), children: t('settings.upgrade') })] })] }));
49
+ }
@@ -0,0 +1,30 @@
1
+ import { type JSX, type ReactNode } from 'react';
2
+ /** A user's registered device (subset rendered in the settings list). */
3
+ export interface Device {
4
+ id: string;
5
+ name: string;
6
+ platform: string;
7
+ lastSeen?: string;
8
+ /** `true` for the device making the current request (the API marks it). */
9
+ isCurrent?: boolean;
10
+ }
11
+ /**
12
+ * Devices section — lists the user's registered devices and lets them
13
+ * revoke (sign out) any device other than the one they're currently using.
14
+ *
15
+ * Revoking deletes the device row (`DELETE /api/devices/:id`). The API's
16
+ * authorization layer enforces server-side revocation: it rejects that
17
+ * device's session the next time it makes a request (within the
18
+ * device-exists cache TTL), so the removed device is actually signed out —
19
+ * not just hidden from the list. The current device is labelled
20
+ * "This device" and is not revocable here (use Sign out for the current
21
+ * session). Recreates molecule v1's device-revocation behaviour.
22
+ *
23
+ * Apps that want a different trailing element per row can pass a
24
+ * `renderRowIcon` callback (which then replaces the built-in revoke
25
+ * control); pass `() => null` to suppress it entirely.
26
+ */
27
+ export declare function DevicesSection({ renderRowIcon, }?: {
28
+ renderRowIcon?: (device: Device) => ReactNode;
29
+ }): JSX.Element;
30
+ //# sourceMappingURL=DevicesSection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"DevicesSection.d.ts","sourceRoot":"","sources":["../src/DevicesSection.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,GAAG,EAAE,KAAK,SAAS,EAAoC,MAAM,OAAO,CAAA;AAMlF,yEAAyE;AACzE,MAAM,WAAW,MAAM;IACrB,EAAE,EAAE,MAAM,CAAA;IACV,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,EAAE,MAAM,CAAA;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,2EAA2E;IAC3E,SAAS,CAAC,EAAE,OAAO,CAAA;CACpB;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,cAAc,CAAC,EAC7B,aAAa,GACd,GAAE;IACD,aAAa,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,SAAS,CAAA;CACzC,GAAG,GAAG,CAAC,OAAO,CA6HnB"}
@@ -0,0 +1,76 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useState } from 'react';
3
+ import { useHttpClient, useTranslation } from '@molecule/app-react';
4
+ import { getClassMap } from '@molecule/app-ui';
5
+ import { Button, Flex, Icon, Spinner } from '@molecule/app-ui-react';
6
+ /**
7
+ * Devices section — lists the user's registered devices and lets them
8
+ * revoke (sign out) any device other than the one they're currently using.
9
+ *
10
+ * Revoking deletes the device row (`DELETE /api/devices/:id`). The API's
11
+ * authorization layer enforces server-side revocation: it rejects that
12
+ * device's session the next time it makes a request (within the
13
+ * device-exists cache TTL), so the removed device is actually signed out —
14
+ * not just hidden from the list. The current device is labelled
15
+ * "This device" and is not revocable here (use Sign out for the current
16
+ * session). Recreates molecule v1's device-revocation behaviour.
17
+ *
18
+ * Apps that want a different trailing element per row can pass a
19
+ * `renderRowIcon` callback (which then replaces the built-in revoke
20
+ * control); pass `() => null` to suppress it entirely.
21
+ */
22
+ export function DevicesSection({ renderRowIcon, } = {}) {
23
+ const cm = getClassMap();
24
+ const { t } = useTranslation();
25
+ const http = useHttpClient();
26
+ const [devices, setDevices] = useState([]);
27
+ const [loading, setLoading] = useState(true);
28
+ const [confirmingId, setConfirmingId] = useState(null);
29
+ const [revokingId, setRevokingId] = useState(null);
30
+ const load = useCallback(async () => {
31
+ try {
32
+ // GET /api/devices responds with the ARRAY itself (see the device
33
+ // resource's query handler) — response.data IS the rows. This used to
34
+ // read `response.data.data`, which is always undefined for that shape,
35
+ // so the section rendered "No devices" for everyone.
36
+ const response = await http.get('/api/devices');
37
+ setDevices(Array.isArray(response.data) ? response.data : []);
38
+ }
39
+ catch (_error) {
40
+ // Devices endpoint is optional; the section renders empty when unavailable.
41
+ }
42
+ finally {
43
+ setLoading(false);
44
+ }
45
+ }, [http]);
46
+ useEffect(() => {
47
+ load();
48
+ }, [load]);
49
+ const revoke = async (id) => {
50
+ setRevokingId(id);
51
+ try {
52
+ await http.delete(`/api/devices/${id}`);
53
+ setConfirmingId(null);
54
+ await load();
55
+ }
56
+ catch (_error) {
57
+ // Best-effort — keep the row so the user can retry the revoke.
58
+ }
59
+ finally {
60
+ setRevokingId(null);
61
+ }
62
+ };
63
+ const trailing = (device) => {
64
+ if (renderRowIcon)
65
+ return renderRowIcon(device);
66
+ if (device.isCurrent)
67
+ return _jsx(Icon, { name: "chevron-right", size: 16, className: cm.textSubtle });
68
+ if (confirmingId === device.id) {
69
+ return (_jsxs(Flex, { align: "center", gap: "sm", children: [_jsx(Button, { variant: "ghost", size: "sm", disabled: revokingId === device.id, onClick: () => setConfirmingId(null), children: t('common.cancel', undefined, { defaultValue: 'Cancel' }) }), _jsx(Button, { color: "error", size: "sm", loading: revokingId === device.id, onClick: () => revoke(device.id), "data-mol-id": "device-revoke-confirm", children: t('settings.signOutDevice', undefined, { defaultValue: 'Sign out' }) })] }));
70
+ }
71
+ return (_jsx(Button, { variant: "ghost", size: "sm", onClick: () => setConfirmingId(device.id), "data-mol-id": "device-revoke", children: t('settings.signOutDevice', undefined, { defaultValue: 'Sign out' }) }));
72
+ };
73
+ return (_jsxs("section", { children: [_jsxs(Flex, { align: "center", gap: "sm", className: cm.sp('mb', 3), children: [_jsx("h3", { className: cm.sectionHeading, children: t('settings.devices', undefined, { defaultValue: 'Devices' }) }), loading && _jsx(Spinner, { size: "xs" })] }), devices.length > 0 ? (_jsx("ul", { className: cm.stack(2), children: devices.map((device) => (_jsxs("li", { children: [_jsxs(Flex, { align: "center", justify: "between", className: cm.cn(cm.textSize('sm'), cm.sp('py', 1)), children: [_jsxs("span", { children: [device.name || device.platform, device.isCurrent ? (_jsx("span", { className: cm.cn(cm.textSize('xs'), cm.textSubtle, cm.sp('ml', 2)), children: t('settings.thisDevice', undefined, { defaultValue: 'This device' }) })) : null] }), trailing(device)] }), confirmingId === device.id ? (_jsx("p", { className: cm.cn(cm.textSize('xs'), cm.textMuted, cm.sp('mt', 1)), children: t('settings.revokeDeviceWarning', undefined, {
74
+ defaultValue: "This device's access will be revoked shortly. Sign it out?",
75
+ }) })) : null] }, device.id))) })) : (!loading && (_jsx("p", { className: cm.cn(cm.textSize('sm'), cm.textMuted), children: t('settings.noDevices', undefined, { defaultValue: 'No devices' }) })))] }));
76
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Bottom actions section — Log out + Delete account.
3
+ *
4
+ * Owns the delete-account modal internally (the trigger lives in the
5
+ * same section so the modal lives here too). Reads `onClose` from
6
+ * the `<SettingsContainer>` context to dismiss the panel after either
7
+ * action and navigates to `/login`.
8
+ */
9
+ export declare function LogOutDeleteSection(): React.JSX.Element;
10
+ //# sourceMappingURL=LogOutDeleteSection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"LogOutDeleteSection.d.ts","sourceRoot":"","sources":["../src/LogOutDeleteSection.tsx"],"names":[],"mappings":"AAUA;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,IAAI,KAAK,CAAC,GAAG,CAAC,OAAO,CAoFvD"}
@@ -0,0 +1,50 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { useNavigate } from 'react-router';
4
+ import { useAuth, useHttpClient, useTranslation } from '@molecule/app-react';
5
+ import { getClassMap } from '@molecule/app-ui';
6
+ import { Alert, Button, Input, Modal } from '@molecule/app-ui-react';
7
+ import { useSettingsPanelContext } from './context.js';
8
+ /**
9
+ * Bottom actions section — Log out + Delete account.
10
+ *
11
+ * Owns the delete-account modal internally (the trigger lives in the
12
+ * same section so the modal lives here too). Reads `onClose` from
13
+ * the `<SettingsContainer>` context to dismiss the panel after either
14
+ * action and navigates to `/login`.
15
+ */
16
+ export function LogOutDeleteSection() {
17
+ const cm = getClassMap();
18
+ const { t } = useTranslation();
19
+ const navigate = useNavigate();
20
+ const { user, logout } = useAuth();
21
+ const http = useHttpClient();
22
+ const { onClose } = useSettingsPanelContext();
23
+ const [showModal, setShowModal] = useState(false);
24
+ const [password, setPassword] = useState('');
25
+ const [loading, setLoading] = useState(false);
26
+ const [error, setError] = useState('');
27
+ const handleLogout = async () => {
28
+ await logout();
29
+ onClose();
30
+ navigate('/login');
31
+ };
32
+ const handleDelete = async (e) => {
33
+ e.preventDefault();
34
+ setLoading(true);
35
+ setError('');
36
+ try {
37
+ await http.delete(`/api/users/${user?.id}`, { data: { password } });
38
+ await logout();
39
+ onClose();
40
+ navigate('/login');
41
+ }
42
+ catch (err) {
43
+ setError(err instanceof Error ? err.message : t('settings.failedToDeleteAccount'));
44
+ }
45
+ finally {
46
+ setLoading(false);
47
+ }
48
+ };
49
+ return (_jsxs(_Fragment, { children: [_jsxs("section", { className: cm.stack(3), children: [_jsx(Button, { color: "error", fullWidth: true, onClick: handleLogout, "data-mol-id": "user-menu-logout", children: t('settings.logOut', undefined, { defaultValue: 'Sign out' }) }), _jsx(Button, { variant: "ghost", fullWidth: true, onClick: () => setShowModal(true), "data-mol-id": "user-menu-delete-account", children: t('settings.deleteAccount', undefined, { defaultValue: 'Delete account' }) })] }), _jsxs(Modal, { open: showModal, onClose: () => setShowModal(false), title: t('settings.deleteAccountModal.title'), children: [_jsx("p", { className: cm.cn(cm.textSize('sm'), cm.textMuted, cm.sp('mb', 4)), children: t('settings.deleteAccountModal.warning') }), error && (_jsx(Alert, { status: "error", className: cm.sp('mb', 4), children: error })), _jsxs("form", { onSubmit: handleDelete, className: cm.stack(4), children: [_jsxs("div", { children: [_jsx("label", { htmlFor: "delete-pw", className: cm.formLabel, children: t('settings.deleteAccountModal.password') }), _jsx(Input, { id: "delete-pw", type: "password", value: password, onChange: (e) => setPassword(e.target.value), required: true })] }), _jsx(Button, { type: "submit", color: "error", fullWidth: true, loading: loading, children: loading ? t('settings.deleteAccountModal.deleting') : t('settings.deleteAccount') })] })] })] }));
50
+ }
@@ -0,0 +1,16 @@
1
+ import type { JSX } from 'react';
2
+ /**
3
+ * Push-notification toggle section — the full receive-side enable chain:
4
+ * browser permission → runtime VAPID public key
5
+ * (`GET /api/devices/push/public-key`) → `pushManager.subscribe` with
6
+ * `applicationServerKey` → PATCH the subscription onto the current device
7
+ * row (`/api/devices/:id { pushSubscription, hasPushSubscription }`), which
8
+ * is where the api-side push fan-outs look for it.
9
+ *
10
+ * Initial state reflects SERVER truth (the current device row's
11
+ * `hasPushSubscription`), and every failure surfaces as an honest inline
12
+ * message — a dev build without a service worker fails fast instead of
13
+ * hanging the switch.
14
+ */
15
+ export declare function NotificationsSection(): JSX.Element;
16
+ //# sourceMappingURL=NotificationsSection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"NotificationsSection.d.ts","sourceRoot":"","sources":["../src/NotificationsSection.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,OAAO,CAAA;AAchC;;;;;;;;;;;;GAYG;AACH,wBAAgB,oBAAoB,IAAI,GAAG,CAAC,OAAO,CAiGlD"}