@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.
- package/LICENSE +115 -0
- package/README.md +487 -0
- package/dist/AccountSection.d.ts +15 -0
- package/dist/AccountSection.d.ts.map +1 -0
- package/dist/AccountSection.js +83 -0
- package/dist/AppearanceSection.d.ts +10 -0
- package/dist/AppearanceSection.d.ts.map +1 -0
- package/dist/AppearanceSection.js +17 -0
- package/dist/AuthSection.d.ts +19 -0
- package/dist/AuthSection.d.ts.map +1 -0
- package/dist/AuthSection.js +141 -0
- package/dist/BillingSection.d.ts +19 -0
- package/dist/BillingSection.d.ts.map +1 -0
- package/dist/BillingSection.js +49 -0
- package/dist/DevicesSection.d.ts +30 -0
- package/dist/DevicesSection.d.ts.map +1 -0
- package/dist/DevicesSection.js +76 -0
- package/dist/LogOutDeleteSection.d.ts +10 -0
- package/dist/LogOutDeleteSection.d.ts.map +1 -0
- package/dist/LogOutDeleteSection.js +50 -0
- package/dist/NotificationsSection.d.ts +16 -0
- package/dist/NotificationsSection.d.ts.map +1 -0
- package/dist/NotificationsSection.js +93 -0
- package/dist/SettingsContainer.d.ts +12 -0
- package/dist/SettingsContainer.d.ts.map +1 -0
- package/dist/SettingsContainer.js +15 -0
- package/dist/ThisDeviceSection.d.ts +9 -0
- package/dist/ThisDeviceSection.d.ts.map +1 -0
- package/dist/ThisDeviceSection.js +16 -0
- package/dist/TiersUpgradeSection.d.ts +15 -0
- package/dist/TiersUpgradeSection.d.ts.map +1 -0
- package/dist/TiersUpgradeSection.js +120 -0
- package/dist/context.d.ts +18 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +15 -0
- package/dist/index.d.ts +85 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +84 -0
- package/dist/pushToggle.d.ts +111 -0
- package/dist/pushToggle.d.ts.map +1 -0
- package/dist/pushToggle.js +170 -0
- 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"}
|