shutro-erp 0.3.2 → 0.3.3
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/dist/account_portal/.kosh/bindings.js +1 -1
- package/dist/account_portal/.kosh/schema.d.ts +29 -1
- package/dist/accounting/.kosh/bindings.js +1 -1
- package/dist/accounting/.kosh/schema.d.ts +29 -1
- package/dist/assistant/index.js +1 -1
- package/dist/contacts/.kosh/bindings.js +1 -1
- package/dist/contacts/.kosh/schema.d.ts +29 -1
- package/dist/l10n_bd/.kosh/bindings.js +1 -1
- package/dist/l10n_bd/.kosh/schema.d.ts +29 -1
- package/dist/l10n_in/.kosh/bindings.js +1 -1
- package/dist/l10n_in/.kosh/schema.d.ts +29 -4
- package/dist/l10n_in/module.d.ts +0 -62
- package/dist/l10n_in/module.js +2 -4
- package/dist/payment/.kosh/bindings.js +1 -1
- package/dist/payment/.kosh/schema.d.ts +30 -2
- package/dist/payment/bkash.d.ts +28 -0
- package/dist/payment/bkash.js +169 -0
- package/dist/payment/index.d.ts +4 -0
- package/dist/payment/index.js +80 -24
- package/dist/payment/module.d.ts +7 -4
- package/dist/payment/module.js +3 -0
- package/dist/payment/money.d.ts +7 -0
- package/dist/payment/money.js +37 -0
- package/dist/payment/providers.d.ts +13 -2
- package/dist/payment/razorpay.d.ts +12 -0
- package/dist/payment/razorpay.js +149 -0
- package/dist/payment/sslcommerz.js +1 -1
- package/dist/payment/stripe.d.ts +15 -0
- package/dist/payment/stripe.js +182 -0
- package/dist/permissions/admin.d.ts +3 -0
- package/dist/permissions/admin.js +108 -10
- package/dist/permissions/index.d.ts +1 -0
- package/dist/permissions/index.js +3 -0
- package/dist/permissions/roles.d.ts +2 -0
- package/dist/permissions/roles.js +236 -0
- package/dist/portal/.kosh/bindings.js +1 -1
- package/dist/portal/.kosh/schema.d.ts +29 -1
- package/dist/product/.kosh/bindings.js +1 -1
- package/dist/product/.kosh/schema.d.ts +29 -1
- package/dist/project/.kosh/bindings.js +1 -1
- package/dist/project/.kosh/schema.d.ts +29 -1
- package/dist/project_portal/.kosh/bindings.js +1 -1
- package/dist/project_portal/.kosh/schema.d.ts +29 -1
- package/dist/purchase/.kosh/bindings.js +1 -1
- package/dist/purchase/.kosh/schema.d.ts +29 -1
- package/dist/purchase_account/.kosh/bindings.js +1 -1
- package/dist/purchase_account/.kosh/schema.d.ts +29 -1
- package/dist/purchase_portal/.kosh/bindings.js +1 -1
- package/dist/purchase_portal/.kosh/schema.d.ts +29 -1
- package/dist/sale_account/.kosh/bindings.js +1 -1
- package/dist/sale_account/.kosh/schema.d.ts +29 -1
- package/dist/sale_portal/.kosh/bindings.js +1 -1
- package/dist/sale_portal/.kosh/schema.d.ts +29 -1
- package/dist/sale_project/.kosh/bindings.js +1 -1
- package/dist/sale_project/.kosh/schema.d.ts +29 -1
- package/dist/sales/.kosh/bindings.js +1 -1
- package/dist/sales/.kosh/schema.d.ts +29 -1
- package/dist/server/auth/base.js +22 -0
- package/dist/server/auth/routes.d.ts +3 -0
- package/dist/server/auth/routes.js +29 -0
- package/dist/server/auth/service.d.ts +52 -0
- package/dist/server/auth/service.js +168 -3
- package/dist/server/identity.d.ts +2 -0
- package/dist/server/index.d.ts +1 -1
- package/dist/server/server.js +11 -3
- package/dist/settings/company.d.ts +34 -0
- package/dist/settings/company.js +187 -0
- package/dist/settings/index.d.ts +10 -10
- package/dist/settings/index.js +159 -30
- package/dist/settings/people.d.ts +16 -0
- package/dist/settings/people.js +505 -0
- package/dist/settings/security.d.ts +13 -0
- package/dist/settings/security.js +158 -0
- package/dist/settings/web/AuditPage.d.ts +8 -0
- package/dist/settings/web/AuditPage.js +74 -0
- package/dist/settings/web/CompaniesPage.d.ts +8 -0
- package/dist/settings/web/CompaniesPage.js +52 -0
- package/dist/settings/web/CompanyPage.d.ts +9 -0
- package/dist/settings/web/CompanyPage.js +94 -0
- package/dist/settings/web/OverviewPage.d.ts +9 -0
- package/dist/settings/web/OverviewPage.js +66 -0
- package/dist/settings/web/PeoplePage.d.ts +9 -0
- package/dist/settings/web/PeoplePage.js +163 -0
- package/dist/settings/web/ProfilePage.d.ts +14 -0
- package/dist/settings/web/ProfilePage.js +194 -0
- package/dist/settings/web/RolesEditor.d.ts +35 -0
- package/dist/settings/web/RolesEditor.js +49 -0
- package/dist/settings/web/RolesPage.d.ts +9 -0
- package/dist/settings/web/RolesPage.js +296 -0
- package/dist/settings/web/SecurityPage.d.ts +8 -0
- package/dist/settings/web/SecurityPage.js +81 -0
- package/dist/settings/web/index.d.ts +9 -0
- package/dist/settings/web/index.js +26 -1
- package/dist/settings/web/messages.js +566 -0
- package/dist/ui/api.d.ts +4 -0
- package/dist/ui/app.d.ts +5 -0
- package/dist/ui/app.js +108 -31
- package/dist/ui/components/primitives.d.ts +9 -1
- package/dist/ui/components/primitives.js +12 -4
- package/dist/ui/context.d.ts +10 -1
- package/dist/ui/context.js +60 -2
- package/dist/ui/i18n/messages.d.ts +46 -0
- package/dist/ui/i18n/messages.js +92 -0
- package/dist/ui/index.d.ts +2 -1
- package/dist/ui/index.js +2 -1
- package/dist/ui/lib/accent.d.ts +13 -0
- package/dist/ui/lib/accent.js +36 -0
- package/dist/ui/shell/Activate.d.ts +6 -0
- package/dist/ui/shell/Activate.js +39 -0
- package/dist/ui/shell/CommandPalette.d.ts +7 -1
- package/dist/ui/shell/CommandPalette.js +16 -2
- package/dist/ui/shell/Sidebar.d.ts +27 -0
- package/dist/ui/shell/Sidebar.js +84 -0
- package/dist/ui/shell/TwoFactorSetup.d.ts +5 -0
- package/dist/ui/shell/TwoFactorSetup.js +32 -0
- package/dist/ui/shell/shortcuts.d.ts +3 -0
- package/dist/ui/shell/shortcuts.js +13 -4
- package/dist/ui/styles.css +70 -28
- package/dist/ui/views/DateField.d.ts +10 -0
- package/dist/ui/views/DateField.js +129 -0
- package/dist/ui/views/FormView.js +161 -21
- package/dist/ui/views/ListView.d.ts +2 -0
- package/dist/ui/views/ListView.js +8 -3
- package/dist/ui/views/StatusChip.d.ts +10 -0
- package/dist/ui/views/StatusChip.js +22 -0
- package/dist/ui/views/widgets.d.ts +3 -0
- package/dist/ui/views/widgets.js +26 -8
- package/dist/views/types.d.ts +7 -0
- package/dist/views/validate.js +4 -0
- package/package.json +2 -2
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Settings › Roles & permissions (RFC 0019 §3.3): every role with what it opens and who holds it; a role's page —
|
|
4
|
+
* simple first (per app, one access level), *Advanced* below (rights per model with code rights locked, feature
|
|
5
|
+
* permissions, record rules, field security) — saved only after a preview of who gains and who loses what. New
|
|
6
|
+
* roles from scratch or copied. Administrators only: the server refuses everyone else.
|
|
7
|
+
*/
|
|
8
|
+
import { Alert, ApiError, Badge, Button, Checkbox, Dialog, Input, Label, Select, Textarea, } from '../../ui/index.js';
|
|
9
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
10
|
+
import { Lock, Plus, Search, Trash2 } from 'lucide-react';
|
|
11
|
+
import { useEffect, useId, useMemo, useState } from 'react';
|
|
12
|
+
import { useTranslation } from 'react-i18next';
|
|
13
|
+
const PERMS = ['read', 'create', 'write', 'unlink', 'export', 'import'];
|
|
14
|
+
const RULE_PERMS = ['read', 'write', 'create', 'unlink'];
|
|
15
|
+
const A = '/api/v1/admin';
|
|
16
|
+
const message = (error) => (error instanceof ApiError ? error.message : String(error));
|
|
17
|
+
/** Per app module: its levels, in declared order (lowest first). */
|
|
18
|
+
function useLevels(api) {
|
|
19
|
+
const levels = useQuery({
|
|
20
|
+
queryKey: ['admin', 'access-levels'],
|
|
21
|
+
queryFn: () => api.call('GET', `${A}/access-levels`),
|
|
22
|
+
});
|
|
23
|
+
const apps = useQuery({
|
|
24
|
+
queryKey: ['settings', 'people', 'catalogue'],
|
|
25
|
+
queryFn: () => api.call('GET', '/api/v1/settings/people/catalogue'),
|
|
26
|
+
});
|
|
27
|
+
return useMemo(() => {
|
|
28
|
+
const byModule = new Map();
|
|
29
|
+
for (const l of levels.data?.accessLevels ?? []) {
|
|
30
|
+
const a = apps.data?.apps.find((x) => x.module === l.module);
|
|
31
|
+
const entry = byModule.get(l.module) ?? {
|
|
32
|
+
label: a?.label ?? l.module,
|
|
33
|
+
app: a?.app ?? false,
|
|
34
|
+
levels: [],
|
|
35
|
+
};
|
|
36
|
+
entry.levels.push(l);
|
|
37
|
+
byModule.set(l.module, entry);
|
|
38
|
+
}
|
|
39
|
+
/** Whether a level belongs to an app (the list shows those; technical levels are in every role). */
|
|
40
|
+
const isApp = (code) => {
|
|
41
|
+
const l = levels.data?.accessLevels.find((x) => x.code === code);
|
|
42
|
+
return l !== undefined && byModule.get(l.module)?.app === true;
|
|
43
|
+
};
|
|
44
|
+
const label = (code) => {
|
|
45
|
+
const l = levels.data?.accessLevels.find((x) => x.code === code);
|
|
46
|
+
return l === undefined ? code : `${byModule.get(l.module)?.label ?? l.module} · ${l.label}`;
|
|
47
|
+
};
|
|
48
|
+
return { byModule, label, isApp, ready: levels.data !== undefined };
|
|
49
|
+
}, [levels.data, apps.data]);
|
|
50
|
+
}
|
|
51
|
+
function PreviewPanel({ preview }) {
|
|
52
|
+
const { t } = useTranslation('settings');
|
|
53
|
+
const nothing = preview.people.length === 0 && preview.models.length === 0;
|
|
54
|
+
return (_jsxs("section", { "aria-label": t('roles.preview'), className: "grid gap-3 rounded-md border bg-muted/40 p-4 text-sm", children: [_jsx("h3", { className: "font-semibold", children: t('roles.preview') }), nothing ? _jsx("p", { children: t('roles.noEffect') }) : null, preview.people.length === 0 ? null : (_jsx("ul", { className: "grid gap-1.5", children: preview.people.map((p) => (_jsxs("li", { children: [_jsx("span", { className: "font-medium", children: p.name }), p.gains.length === 0 ? null : (_jsxs("span", { className: "text-success", children: [' ', t('roles.gains', { what: p.gains.join(', ') })] })), p.losses.length === 0 ? null : (_jsxs("span", { className: "text-danger", children: [' ', t('roles.loses', { what: p.losses.join(', ') })] }))] }, p.id))) })), preview.models.length === 0 ? null : (_jsx("ul", { className: "grid gap-1 text-muted-foreground", children: preview.models.map((m) => (_jsxs("li", { children: [m.label, ": ", m.added.map((p) => `+${t(`roles.perm.${p}`)}`).join(' '), ' ', m.removed.map((p) => `−${t(`roles.perm.${p}`)}`).join(' ')] }, m.model))) })), _jsx("p", { className: "text-xs text-muted-foreground", children: preview.holders === 0
|
|
55
|
+
? t('roles.nobodyYet')
|
|
56
|
+
: t('roles.holders', { count: preview.holders }) })] }));
|
|
57
|
+
}
|
|
58
|
+
function RoleDialog({ api, code, onClose, }) {
|
|
59
|
+
const { t } = useTranslation('settings');
|
|
60
|
+
const id = useId();
|
|
61
|
+
const queryClient = useQueryClient();
|
|
62
|
+
const levels = useLevels(api);
|
|
63
|
+
const role = useQuery({
|
|
64
|
+
queryKey: ['admin', 'role', code],
|
|
65
|
+
queryFn: () => api.call('GET', `${A}/roles/${encodeURIComponent(code)}`),
|
|
66
|
+
});
|
|
67
|
+
const [label, setLabel] = useState('');
|
|
68
|
+
const [chosen, setChosen] = useState({});
|
|
69
|
+
const [own, setOwn] = useState({});
|
|
70
|
+
const [granted, setGranted] = useState(new Set());
|
|
71
|
+
const [preview, setPreview] = useState(null);
|
|
72
|
+
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
73
|
+
const r = role.data;
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (r === undefined)
|
|
76
|
+
return;
|
|
77
|
+
setLabel(r.label);
|
|
78
|
+
// The highest level per app the role gives.
|
|
79
|
+
const next = {};
|
|
80
|
+
for (const [module, entry] of levels.byModule)
|
|
81
|
+
next[module] =
|
|
82
|
+
[...entry.levels].reverse().find((l) => r.accessLevels.includes(l.code))?.code ?? 'none';
|
|
83
|
+
setChosen(next);
|
|
84
|
+
setOwn(Object.fromEntries(r.models.map((m) => [m.name, [...m.own]])));
|
|
85
|
+
setGranted(new Set(r.permissions.filter((p) => p.own).map((p) => p.code)));
|
|
86
|
+
setPreview(null);
|
|
87
|
+
}, [r, levels.byModule]);
|
|
88
|
+
const proposal = () => {
|
|
89
|
+
if (r === undefined)
|
|
90
|
+
return {};
|
|
91
|
+
const changed = Object.fromEntries(r.models
|
|
92
|
+
.filter((m) => [...(own[m.name] ?? [])].sort().join() !== [...m.own].sort().join())
|
|
93
|
+
.map((m) => [m.name, own[m.name] ?? []]));
|
|
94
|
+
const allOwn = Object.fromEntries(r.models
|
|
95
|
+
.filter((m) => (own[m.name] ?? []).length > 0)
|
|
96
|
+
.map((m) => [m.name, own[m.name] ?? []]));
|
|
97
|
+
const accessLevels = Object.values(chosen).filter((c) => c !== 'none');
|
|
98
|
+
const levelGroups = new Set([...levels.byModule.values()].flatMap((e) => e.levels.flatMap((l) => l.groups)));
|
|
99
|
+
return {
|
|
100
|
+
changed,
|
|
101
|
+
body: {
|
|
102
|
+
...(r.builtIn
|
|
103
|
+
? {}
|
|
104
|
+
: { accessLevels, implies: r.implies.filter((g) => !levelGroups.has(g)) }),
|
|
105
|
+
access: allOwn,
|
|
106
|
+
permissions: [...granted],
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
const preview$ = useMutation({
|
|
111
|
+
mutationFn: () => api.call('POST', `${A}/roles/${encodeURIComponent(code)}/preview`, proposal().body),
|
|
112
|
+
onSuccess: setPreview,
|
|
113
|
+
});
|
|
114
|
+
const save = useMutation({
|
|
115
|
+
mutationFn: async () => {
|
|
116
|
+
if (r === undefined)
|
|
117
|
+
return;
|
|
118
|
+
const { changed, body } = proposal();
|
|
119
|
+
if (!r.builtIn)
|
|
120
|
+
await api.call('PUT', `${A}/roles/${encodeURIComponent(code)}`, {
|
|
121
|
+
label,
|
|
122
|
+
accessLevels: body.accessLevels,
|
|
123
|
+
implies: body.implies,
|
|
124
|
+
});
|
|
125
|
+
for (const [model, perms] of Object.entries(changed ?? {}))
|
|
126
|
+
await api.call('PUT', `${A}/roles/${encodeURIComponent(code)}/access/${encodeURIComponent(model)}`, {
|
|
127
|
+
perms,
|
|
128
|
+
});
|
|
129
|
+
const before = new Set(r.permissions.filter((p) => p.own).map((p) => p.code));
|
|
130
|
+
for (const p of granted)
|
|
131
|
+
if (!before.has(p))
|
|
132
|
+
await api.call('PUT', `${A}/roles/${encodeURIComponent(code)}/permissions/${encodeURIComponent(p)}`);
|
|
133
|
+
for (const p of before)
|
|
134
|
+
if (!granted.has(p))
|
|
135
|
+
await api.call('DELETE', `${A}/roles/${encodeURIComponent(code)}/permissions/${encodeURIComponent(p)}`);
|
|
136
|
+
},
|
|
137
|
+
onSuccess: () => {
|
|
138
|
+
setPreview(null);
|
|
139
|
+
void queryClient.invalidateQueries({ queryKey: ['admin'] });
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
const remove = useMutation({
|
|
143
|
+
mutationFn: () => api.call('DELETE', `${A}/roles/${encodeURIComponent(code)}`),
|
|
144
|
+
onSuccess: () => {
|
|
145
|
+
void queryClient.invalidateQueries({ queryKey: ['admin'] });
|
|
146
|
+
onClose();
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
// A new record rule and a field restriction.
|
|
150
|
+
const [ruleModel, setRuleModel] = useState('');
|
|
151
|
+
const [rulePerms, setRulePerms] = useState(new Set(['read']));
|
|
152
|
+
const [ruleDomain, setRuleDomain] = useState('{ "createdBy": { "$env": "uid" } }');
|
|
153
|
+
const addRule = useMutation({
|
|
154
|
+
mutationFn: () => {
|
|
155
|
+
const domain = JSON.parse(ruleDomain);
|
|
156
|
+
const name = `${code.replace('.', '_')}_${ruleModel.replace(/\./g, '_')}_${Date.now().toString(36)}`;
|
|
157
|
+
return api.call('PUT', `${A}/roles/${encodeURIComponent(code)}/rules/${name}`, {
|
|
158
|
+
model: ruleModel,
|
|
159
|
+
perms: [...rulePerms],
|
|
160
|
+
domain,
|
|
161
|
+
});
|
|
162
|
+
},
|
|
163
|
+
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['admin', 'role', code] }),
|
|
164
|
+
});
|
|
165
|
+
const deleteRule = useMutation({
|
|
166
|
+
mutationFn: (name) => api.call('DELETE', `${A}/rules/${encodeURIComponent(name)}`),
|
|
167
|
+
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['admin', 'role', code] }),
|
|
168
|
+
});
|
|
169
|
+
const error = preview$.error ?? save.error ?? remove.error ?? addRule.error ?? deleteRule.error;
|
|
170
|
+
const groupedModels = useMemo(() => {
|
|
171
|
+
const out = new Map();
|
|
172
|
+
for (const m of r?.models ?? []) {
|
|
173
|
+
const key = m.app ?? t('roles.technical');
|
|
174
|
+
out.set(key, [...(out.get(key) ?? []), m]);
|
|
175
|
+
}
|
|
176
|
+
return [...out];
|
|
177
|
+
}, [r, t]);
|
|
178
|
+
return (_jsx(Dialog, { open: true, onOpenChange: (open) => !open && onClose(), title: r?.label ?? code, wide: true, children: r === undefined ? null : (_jsxs("div", { className: "grid gap-5", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2 text-sm", children: [_jsx("code", { className: "rounded bg-muted px-1.5 py-0.5 text-xs", children: r.code }), r.builtIn ? (_jsx(Badge, { tone: "neutral", children: t('roles.builtIn') })) : (_jsx(Badge, { tone: "info", children: t('roles.custom') })), r.administrator ? _jsx(Badge, { tone: "warning", children: t('people.administrator') }) : null, _jsx("span", { className: "text-muted-foreground", children: r.holders.length === 0
|
|
179
|
+
? t('roles.nobody')
|
|
180
|
+
: t('roles.heldBy', { names: r.holders.map((h) => h.name).join(', ') }) })] }), r.builtIn ? (_jsx("p", { className: "text-sm text-muted-foreground", children: t('roles.builtInHint') })) : (_jsxs("div", { className: "grid gap-1.5 sm:max-w-sm", children: [_jsx(Label, { htmlFor: `${id}-label`, children: t('roles.name') }), _jsx(Input, { id: `${id}-label`, value: label, onChange: (e) => setLabel(e.target.value) })] })), _jsxs("section", { className: "grid gap-2", children: [_jsx("h3", { className: "text-sm font-semibold", children: t('roles.apps') }), _jsx("ul", { className: "divide-y rounded-md border", children: [...levels.byModule]
|
|
181
|
+
.filter(([, e]) => e.app)
|
|
182
|
+
.map(([module, entry]) => (_jsxs("li", { className: "flex flex-wrap items-center justify-between gap-3 px-3 py-2", children: [_jsx(Label, { htmlFor: `${id}-${module}`, className: "font-medium", children: entry.label }), r.builtIn ? (_jsx("span", { className: "text-sm text-muted-foreground", children: entry.levels.find((l) => l.code === chosen[module])?.label ??
|
|
183
|
+
t('people.noAccess') })) : (_jsx(Select, { id: `${id}-${module}`, label: entry.label, className: "w-48", value: chosen[module] ?? 'none', options: [
|
|
184
|
+
{ value: 'none', label: t('people.noAccess') },
|
|
185
|
+
...entry.levels.map((l) => ({ value: l.code, label: l.label })),
|
|
186
|
+
], onChange: (v) => {
|
|
187
|
+
setChosen({ ...chosen, [module]: v });
|
|
188
|
+
setPreview(null);
|
|
189
|
+
} }))] }, module))) })] }), _jsxs("details", { className: "rounded-md border", children: [_jsx("summary", { className: "cursor-pointer px-3 py-2 text-sm font-semibold", children: t('roles.advanced') }), _jsxs("div", { className: "grid gap-5 border-t p-3", children: [_jsxs("section", { className: "grid gap-2", children: [_jsx("h4", { className: "text-sm font-semibold", children: t('roles.models') }), _jsxs("p", { className: "text-xs text-muted-foreground", children: [_jsx(Lock, { className: "inline size-3", "aria-hidden": true }), " ", t('roles.lockedHint')] }), _jsx("div", { className: "max-h-96 overflow-auto rounded-md border", children: _jsxs("table", { className: "w-full text-sm", children: [_jsx("caption", { className: "sr-only", children: t('roles.models') }), _jsx("thead", { className: "sticky top-0 bg-surface", children: _jsxs("tr", { className: "border-b text-xs text-muted-foreground", children: [_jsx("th", { scope: "col", className: "px-2 py-1.5 text-start font-medium", children: t('roles.model') }), PERMS.map((p) => (_jsx("th", { scope: "col", className: "px-2 py-1.5 font-medium", children: t(`roles.perm.${p}`) }, p)))] }) }), _jsx("tbody", { children: groupedModels.map(([app, models]) => [
|
|
190
|
+
_jsx("tr", { className: "bg-muted/50", children: _jsx("th", { colSpan: PERMS.length + 1, scope: "colgroup", className: "px-2 py-1 text-start text-xs font-medium", children: app }) }, `h-${app}`),
|
|
191
|
+
...models.map((m) => (_jsxs("tr", { className: "border-b last:border-0", children: [_jsx("th", { scope: "row", className: "px-2 py-1 text-start font-normal", children: m.label }), PERMS.map((p) => {
|
|
192
|
+
const locked = m.locked.includes(p);
|
|
193
|
+
const mine = (own[m.name] ?? []).includes(p);
|
|
194
|
+
return (_jsx("td", { className: "px-2 py-1 text-center", children: _jsx(Checkbox, { "aria-label": `${m.label}: ${t(`roles.perm.${p}`)}`, checked: locked || mine, disabled: locked, onCheckedChange: (c) => {
|
|
195
|
+
const set = new Set(own[m.name] ?? []);
|
|
196
|
+
if (c === true)
|
|
197
|
+
set.add(p);
|
|
198
|
+
else
|
|
199
|
+
set.delete(p);
|
|
200
|
+
setOwn({ ...own, [m.name]: [...set] });
|
|
201
|
+
setPreview(null);
|
|
202
|
+
} }) }, p));
|
|
203
|
+
})] }, m.name))),
|
|
204
|
+
]) })] }) })] }), _jsxs("section", { className: "grid gap-2", children: [_jsx("h4", { className: "text-sm font-semibold", children: t('roles.permissions') }), _jsx("div", { className: "grid gap-1.5 sm:grid-cols-2", children: r.permissions.map((p) => (_jsxs("div", { className: "flex items-center gap-2 text-sm", children: [_jsx(Checkbox, { id: `${id}-p-${p.code}`, checked: p.locked || granted.has(p.code), disabled: p.locked, onCheckedChange: (c) => {
|
|
205
|
+
const next = new Set(granted);
|
|
206
|
+
if (c === true)
|
|
207
|
+
next.add(p.code);
|
|
208
|
+
else
|
|
209
|
+
next.delete(p.code);
|
|
210
|
+
setGranted(next);
|
|
211
|
+
setPreview(null);
|
|
212
|
+
} }), _jsx(Label, { htmlFor: `${id}-p-${p.code}`, children: p.label })] }, p.code))) })] }), _jsxs("section", { className: "grid gap-2", children: [_jsx("h4", { className: "text-sm font-semibold", children: t('roles.rules') }), _jsx("p", { className: "text-xs text-muted-foreground", children: t('roles.rulesHint') }), r.rules.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: t('roles.noRules') })) : (_jsx("ul", { className: "divide-y rounded-md border text-sm", children: r.rules.map((rule) => (_jsxs("li", { className: "flex flex-wrap items-center gap-2 px-3 py-2", children: [_jsx("span", { className: "font-medium", children: rule.model }), _jsx("span", { className: "text-muted-foreground", children: rule.perms.join(', ') }), _jsx("code", { className: "min-w-0 flex-1 truncate text-xs", children: JSON.stringify(rule.domain) }), _jsx(Button, { size: "icon", variant: "ghost", "aria-label": t('roles.deleteRule', { model: rule.model }), onClick: () => deleteRule.mutate(rule.name), children: _jsx(Trash2, { "aria-hidden": true }) })] }, rule.name))) })), _jsxs("div", { className: "grid gap-2 rounded-md border p-3 sm:grid-cols-2", children: [_jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { htmlFor: `${id}-rule-model`, children: t('roles.model') }), _jsx(Select, { id: `${id}-rule-model`, label: t('roles.model'), value: ruleModel === '' ? null : ruleModel, options: r.models.map((m) => ({ value: m.name, label: m.label })), onChange: setRuleModel })] }), _jsxs("fieldset", { className: "grid gap-1.5", children: [_jsx("legend", { className: "text-sm font-medium", children: t('roles.ruleApplies') }), _jsx("div", { className: "flex flex-wrap gap-3", children: RULE_PERMS.map((p) => (_jsxs("div", { className: "flex items-center gap-1.5 text-sm", children: [_jsx(Checkbox, { id: `${id}-rp-${p}`, checked: rulePerms.has(p), onCheckedChange: (c) => {
|
|
213
|
+
const next = new Set(rulePerms);
|
|
214
|
+
if (c === true)
|
|
215
|
+
next.add(p);
|
|
216
|
+
else
|
|
217
|
+
next.delete(p);
|
|
218
|
+
setRulePerms(next);
|
|
219
|
+
} }), _jsx(Label, { htmlFor: `${id}-rp-${p}`, children: t(`roles.perm.${p}`) })] }, p))) })] }), _jsxs("div", { className: "grid gap-1.5 sm:col-span-2", children: [_jsx(Label, { htmlFor: `${id}-rule-domain`, children: t('roles.ruleCondition') }), _jsx(Textarea, { id: `${id}-rule-domain`, className: "font-mono text-xs", value: ruleDomain, onChange: (e) => setRuleDomain(e.target.value) })] }), _jsx("div", { className: "sm:col-span-2", children: _jsxs(Button, { variant: "secondary", busy: addRule.isPending, disabled: ruleModel === '' || rulePerms.size === 0, onClick: () => addRule.mutate(), children: [_jsx(Plus, { "aria-hidden": true }), t('roles.addRule')] }) })] })] }), r.fields.length === 0 ? null : (_jsxs("section", { className: "grid gap-2", children: [_jsx("h4", { className: "text-sm font-semibold", children: t('roles.fields') }), _jsx("ul", { className: "grid gap-1 text-sm", children: r.fields.map((f) => (_jsxs("li", { children: [f.model, ".", f.field, ":", ' ', f.write.includes(code) ? t('roles.mayEdit') : t('roles.mayRead')] }, `${f.model}.${f.field}`))) })] }))] })] }), preview === null ? null : _jsx(PreviewPanel, { preview: preview }), error === null ? null : _jsx(Alert, { tone: "danger", children: message(error) }), save.isSuccess && preview === null ? (_jsx(Alert, { tone: "info", children: t('roles.saved') })) : null, _jsxs("div", { className: "flex flex-wrap justify-between gap-2", children: [r.builtIn ? (_jsx("span", {})) : confirmDelete ? (_jsxs("div", { className: "flex items-center gap-2 text-sm", children: [t('roles.deleteConfirm', { count: r.holders.length }), _jsx(Button, { size: "sm", onClick: () => setConfirmDelete(false), children: t('profile.cancel') }), _jsx(Button, { size: "sm", variant: "danger", busy: remove.isPending, onClick: () => remove.mutate(), children: t('roles.delete') })] })) : (_jsxs(Button, { variant: "ghost", onClick: () => setConfirmDelete(true), children: [_jsx(Trash2, { "aria-hidden": true }), t('roles.delete')] })), preview === null ? (_jsx(Button, { variant: "primary", busy: preview$.isPending, onClick: () => preview$.mutate(), children: t('roles.review') })) : (_jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { onClick: () => setPreview(null), children: t('roles.keepEditing') }), _jsx(Button, { variant: "primary", busy: save.isPending, onClick: () => save.mutate(), children: t('roles.confirmSave') })] }))] })] })) }));
|
|
220
|
+
}
|
|
221
|
+
function NewRoleDialog({ api, roles, open, onOpenChange, onCreated, }) {
|
|
222
|
+
const { t } = useTranslation('settings');
|
|
223
|
+
const id = useId();
|
|
224
|
+
const [label, setLabel] = useState('');
|
|
225
|
+
const [from, setFrom] = useState(null);
|
|
226
|
+
const code = `company.${label
|
|
227
|
+
.toLowerCase()
|
|
228
|
+
.normalize('NFKD')
|
|
229
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
230
|
+
.replace(/^_+|_+$/g, '')}`;
|
|
231
|
+
const create = useMutation({
|
|
232
|
+
mutationFn: async () => {
|
|
233
|
+
const source = from === null
|
|
234
|
+
? null
|
|
235
|
+
: await api.call('GET', `${A}/roles/${encodeURIComponent(from)}`);
|
|
236
|
+
await api.call('PUT', `${A}/roles/${encodeURIComponent(code)}`, {
|
|
237
|
+
label: label.trim(),
|
|
238
|
+
implies: source === null ? ['base.user'] : [...source.implies],
|
|
239
|
+
});
|
|
240
|
+
// A copy takes the source's own grants too.
|
|
241
|
+
for (const m of source?.models ?? [])
|
|
242
|
+
if (m.own.length > 0)
|
|
243
|
+
await api.call('PUT', `${A}/roles/${encodeURIComponent(code)}/access/${encodeURIComponent(m.name)}`, {
|
|
244
|
+
perms: m.own,
|
|
245
|
+
});
|
|
246
|
+
for (const p of source?.permissions ?? [])
|
|
247
|
+
if (p.own)
|
|
248
|
+
await api.call('PUT', `${A}/roles/${encodeURIComponent(code)}/permissions/${encodeURIComponent(p.code)}`);
|
|
249
|
+
return code;
|
|
250
|
+
},
|
|
251
|
+
onSuccess: (made) => {
|
|
252
|
+
setLabel('');
|
|
253
|
+
setFrom(null);
|
|
254
|
+
onCreated(made);
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, title: t('roles.newTitle'), description: t('roles.newHint'), footer: _jsxs(_Fragment, { children: [_jsx(Button, { onClick: () => onOpenChange(false), children: t('profile.cancel') }), _jsx(Button, { variant: "primary", busy: create.isPending, disabled: code === 'company.', onClick: () => create.mutate(), children: t('roles.create') })] }), children: _jsxs("div", { className: "grid gap-4", children: [_jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { htmlFor: `${id}-label`, children: t('roles.name') }), _jsx(Input, { id: `${id}-label`, value: label, onChange: (e) => setLabel(e.target.value) }), code === 'company.' ? null : (_jsx("code", { className: "text-xs text-muted-foreground", children: code }))] }), _jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { htmlFor: `${id}-from`, children: t('roles.copyFrom') }), _jsx(Select, { id: `${id}-from`, label: t('roles.copyFrom'), value: from ?? 'none', options: [
|
|
258
|
+
{ value: 'none', label: t('roles.fromScratch') },
|
|
259
|
+
...roles
|
|
260
|
+
.filter((r) => !r.administrator)
|
|
261
|
+
.map((r) => ({ value: r.code, label: r.label })),
|
|
262
|
+
], onChange: (v) => setFrom(v === 'none' ? null : v) })] }), create.error === null ? null : _jsx(Alert, { tone: "danger", children: message(create.error) })] }) }));
|
|
263
|
+
}
|
|
264
|
+
export function RolesPage({ api }) {
|
|
265
|
+
const { t } = useTranslation('settings');
|
|
266
|
+
const id = useId();
|
|
267
|
+
const levels = useLevels(api);
|
|
268
|
+
const [q, setQ] = useState('');
|
|
269
|
+
const [kind, setKind] = useState('all');
|
|
270
|
+
const [open, setOpen] = useState(null);
|
|
271
|
+
const [creating, setCreating] = useState(false);
|
|
272
|
+
const roles = useQuery({
|
|
273
|
+
queryKey: ['admin', 'roles'],
|
|
274
|
+
queryFn: () => api.call('GET', `${A}/roles`),
|
|
275
|
+
});
|
|
276
|
+
if (roles.error !== null)
|
|
277
|
+
return (_jsx(Alert, { tone: "danger", children: roles.error instanceof ApiError && roles.error.status === 403
|
|
278
|
+
? t('roles.adminsOnly')
|
|
279
|
+
: message(roles.error) }));
|
|
280
|
+
const query = q.trim().toLowerCase();
|
|
281
|
+
const list = (roles.data?.roles ?? [])
|
|
282
|
+
.filter((r) => !r.code.startsWith('portal.'))
|
|
283
|
+
.filter((r) => (kind === 'custom' ? !r.builtIn : kind === 'builtIn' ? r.builtIn : true))
|
|
284
|
+
.filter((r) => query === '' || r.label.toLowerCase().includes(query) || r.code.includes(query))
|
|
285
|
+
.sort((a, b) => Number(a.builtIn) - Number(b.builtIn) || a.label.localeCompare(b.label));
|
|
286
|
+
return (_jsxs("div", { className: "mx-auto grid max-w-5xl gap-5", children: [_jsxs("header", { className: "flex flex-wrap items-center justify-between gap-3", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-xl font-semibold", children: t('roles.title') }), _jsx("p", { className: "text-sm text-muted-foreground", children: t('roles.intro') })] }), _jsxs(Button, { variant: "primary", onClick: () => setCreating(true), children: [_jsx(Plus, { "aria-hidden": true }), t('roles.new')] })] }), _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [_jsxs("div", { className: "relative min-w-60 flex-1", children: [_jsx(Search, { className: "pointer-events-none absolute start-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground", "aria-hidden": true }), _jsx("label", { htmlFor: `${id}-q`, className: "sr-only", children: t('roles.search') }), _jsx(Input, { id: `${id}-q`, type: "search", className: "ps-9", placeholder: t('roles.search'), value: q, onChange: (e) => setQ(e.target.value) })] }), _jsxs("fieldset", { className: "flex rounded-md border p-0.5", children: [_jsx("legend", { className: "sr-only", children: t('roles.kind') }), ['all', 'custom', 'builtIn'].map((k) => (_jsx("button", { type: "button", "aria-pressed": kind === k, onClick: () => setKind(k), className: "rounded px-3 py-1 text-sm aria-pressed:bg-accent aria-pressed:font-medium aria-pressed:text-accent-foreground", children: t(`roles.filter.${k}`) }, k)))] })] }), _jsx("ul", { className: "divide-y rounded-lg border bg-surface", "aria-label": t('roles.title'), children: list.map((r) => (_jsx("li", { children: _jsxs("button", { type: "button", onClick: () => setOpen(r.code), className: "flex w-full flex-wrap items-center gap-3 px-4 py-3 text-start hover:bg-muted/50", children: [_jsxs("span", { className: "grid min-w-52 flex-1", children: [_jsx("span", { className: "font-medium", children: r.label }), _jsx("code", { className: "text-xs text-muted-foreground", children: r.code })] }), _jsxs("span", { className: "flex min-w-52 flex-[2] flex-wrap gap-1", children: [r.administrator ? _jsx(Badge, { tone: "warning", children: t('people.administrator') }) : null, levels.ready
|
|
287
|
+
? r.accessLevels
|
|
288
|
+
.filter(levels.isApp)
|
|
289
|
+
.slice(0, 4)
|
|
290
|
+
.map((l) => (_jsx(Badge, { tone: "neutral", children: levels.label(l) }, l)))
|
|
291
|
+
: null] }), _jsxs("span", { className: "flex items-center gap-2 text-sm text-muted-foreground", children: [r.builtIn ? (_jsx(Badge, { tone: "neutral", children: t('roles.builtIn') })) : (_jsx(Badge, { tone: "info", children: t('roles.custom') })), t('roles.holders', { count: r.holders })] })] }) }, r.code))) }), _jsx(NewRoleDialog, { api: api, roles: roles.data?.roles ?? [], open: creating, onOpenChange: setCreating, onCreated: (code) => {
|
|
292
|
+
setCreating(false);
|
|
293
|
+
void roles.refetch();
|
|
294
|
+
setOpen(code);
|
|
295
|
+
} }), open === null ? null : _jsx(RoleDialog, { api: api, code: open, onClose: () => setOpen(null) })] }));
|
|
296
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings › Security (RFC 0019 §3.5), administrators: the policy — who must sign in with a second factor (the people
|
|
3
|
+
* it would stop until they set it up are named before saving) and how long a sign-in lasts — and every signed-in
|
|
4
|
+
* device across people, each signed out with one click. The server enforces the policy and audits every change.
|
|
5
|
+
*/
|
|
6
|
+
import { type PageProps } from '../../ui/index.js';
|
|
7
|
+
import { type ReactElement } from 'react';
|
|
8
|
+
export declare function SecurityPage({ api }: PageProps): ReactElement;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Settings › Security (RFC 0019 §3.5), administrators: the policy — who must sign in with a second factor (the people
|
|
4
|
+
* it would stop until they set it up are named before saving) and how long a sign-in lasts — and every signed-in
|
|
5
|
+
* device across people, each signed out with one click. The server enforces the policy and audits every change.
|
|
6
|
+
*/
|
|
7
|
+
import { Alert, ApiError, Badge, Button, Input, Label, useKosh } from '../../ui/index.js';
|
|
8
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
9
|
+
import { Laptop, Monitor, Smartphone } from 'lucide-react';
|
|
10
|
+
import { useId, useState } from 'react';
|
|
11
|
+
import { useTranslation } from 'react-i18next';
|
|
12
|
+
import { deviceOf } from './ProfilePage.js';
|
|
13
|
+
const message = (error) => (error instanceof ApiError ? error.message : String(error));
|
|
14
|
+
function Section({ title, hint, children, }) {
|
|
15
|
+
const id = useId();
|
|
16
|
+
return (_jsxs("section", { "aria-labelledby": id, className: "grid gap-4 rounded-lg border bg-surface p-5", children: [_jsxs("div", { children: [_jsx("h2", { id: id, className: "font-semibold", children: title }), _jsx("p", { className: "text-sm text-muted-foreground", children: hint })] }), children] }));
|
|
17
|
+
}
|
|
18
|
+
function PolicyForm({ api, policy }) {
|
|
19
|
+
const { t } = useTranslation('settings');
|
|
20
|
+
const id = useId();
|
|
21
|
+
const queryClient = useQueryClient();
|
|
22
|
+
const [twoFactor, setTwoFactor] = useState(policy.twoFactor);
|
|
23
|
+
const [days, setDays] = useState(policy.sessionDays === null ? '' : String(policy.sessionDays));
|
|
24
|
+
const save = useMutation({
|
|
25
|
+
mutationFn: () => api.call('PATCH', '/api/v1/settings/security', {
|
|
26
|
+
twoFactor,
|
|
27
|
+
sessionDays: days === '' ? null : Number(days),
|
|
28
|
+
}),
|
|
29
|
+
// The form already shows what was saved; the server's answer names who is still without it.
|
|
30
|
+
onSuccess: (saved) => queryClient.setQueryData(['settings', 'security'], saved),
|
|
31
|
+
});
|
|
32
|
+
const stopped = twoFactor === 'everyone' ? policy.withoutTwoFactor : [];
|
|
33
|
+
return (_jsxs("form", { className: "grid gap-4", onSubmit: (e) => {
|
|
34
|
+
e.preventDefault();
|
|
35
|
+
save.mutate();
|
|
36
|
+
}, children: [_jsxs("fieldset", { className: "grid gap-2", children: [_jsx("legend", { className: "mb-1 text-sm font-medium", children: t('security.twoFactorFor') }), ['administrators', 'everyone'].map((value) => (_jsxs("label", { className: "flex items-start gap-2 text-sm", children: [_jsx("input", { type: "radio", name: `${id}-two-factor`, className: "mt-1 accent-[var(--primary)]", checked: twoFactor === value, onChange: () => setTwoFactor(value) }), _jsxs("span", { children: [_jsx("span", { className: "font-medium", children: t(`security.${value}`) }), _jsx("span", { className: "block text-muted-foreground", children: t(`security.${value}Hint`) })] })] }, value)))] }), stopped.length === 0 ? null : (_jsx(Alert, { tone: "warning", children: t('security.wouldStop', {
|
|
37
|
+
count: stopped.length,
|
|
38
|
+
names: stopped
|
|
39
|
+
.slice(0, 5)
|
|
40
|
+
.map((p) => p.name)
|
|
41
|
+
.join(', '),
|
|
42
|
+
}) })), _jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { htmlFor: `${id}-days`, children: t('security.sessionDays') }), _jsx(Input, { id: `${id}-days`, type: "number", min: 1, max: 90, className: "w-32", placeholder: t('security.serverDefault'), value: days, onChange: (e) => setDays(e.target.value) }), _jsx("p", { className: "text-xs text-muted-foreground", children: t('security.sessionDaysHint') })] }), save.error === null ? null : _jsx(Alert, { tone: "danger", children: message(save.error) }), save.isSuccess ? _jsx(Alert, { tone: "info", children: t('security.saved') }) : null, _jsx("div", { className: "flex justify-end", children: _jsx(Button, { type: "submit", variant: "primary", busy: save.isPending, children: t('security.save') }) })] }));
|
|
43
|
+
}
|
|
44
|
+
function Sessions({ api }) {
|
|
45
|
+
const { t } = useTranslation('settings');
|
|
46
|
+
const { format } = useKosh();
|
|
47
|
+
const queryClient = useQueryClient();
|
|
48
|
+
const [filter, setFilter] = useState('');
|
|
49
|
+
const sessions = useQuery({
|
|
50
|
+
queryKey: ['settings', 'sessions'],
|
|
51
|
+
queryFn: () => api.call('GET', '/api/v1/settings/sessions'),
|
|
52
|
+
});
|
|
53
|
+
const revoke = useMutation({
|
|
54
|
+
mutationFn: (sid) => api.call('DELETE', `/api/v1/settings/sessions/${sid}`),
|
|
55
|
+
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['settings', 'sessions'] }),
|
|
56
|
+
});
|
|
57
|
+
const words = filter.trim().toLowerCase();
|
|
58
|
+
const list = (sessions.data?.sessions ?? []).filter((s) => words === '' || `${s.name} ${s.login} ${s.ip ?? ''}`.toLowerCase().includes(words));
|
|
59
|
+
return (_jsxs("div", { className: "grid gap-3", children: [_jsx(Input, { "aria-label": t('security.searchSessions'), placeholder: t('security.searchSessions'), className: "max-w-xs", value: filter, onChange: (e) => setFilter(e.target.value) }), sessions.error === null ? null : _jsx(Alert, { tone: "danger", children: message(sessions.error) }), revoke.error === null ? null : _jsx(Alert, { tone: "danger", children: message(revoke.error) }), _jsxs("ul", { className: "divide-y rounded-md border", children: [list.map((s) => {
|
|
60
|
+
const device = deviceOf(s.userAgent);
|
|
61
|
+
const Icon = device.phone ? Smartphone : device.os === '' ? Monitor : Laptop;
|
|
62
|
+
const name = [device.browser, device.os].filter((x) => x !== '').join(' · ') ||
|
|
63
|
+
t('profile.unknownDevice');
|
|
64
|
+
return (_jsxs("li", { className: "flex flex-wrap items-center gap-3 px-3 py-2.5", children: [_jsx(Icon, { className: "size-5 text-muted-foreground", "aria-hidden": true }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("p", { className: "text-sm font-medium", children: [s.name, " ", _jsxs("span", { className: "font-normal text-muted-foreground", children: ["\u00B7 ", name] }), ' ', s.current ? _jsx(Badge, { tone: "success", children: t('profile.thisDevice') }) : null] }), _jsx("p", { className: "text-xs text-muted-foreground", children: [
|
|
65
|
+
s.ip,
|
|
66
|
+
s.lastSeenAt === null
|
|
67
|
+
? null
|
|
68
|
+
: t('profile.lastActive', { when: format.datetime(s.lastSeenAt) }),
|
|
69
|
+
]
|
|
70
|
+
.filter((x) => x !== null)
|
|
71
|
+
.join(' · ') })] }), s.current ? null : (_jsx(Button, { size: "sm", variant: "secondary", busy: revoke.isPending && revoke.variables === s.id, onClick: () => revoke.mutate(s.id), "aria-label": t('security.signOutOf', { name: s.name, device: name }), children: t('profile.signOut') }))] }, s.id));
|
|
72
|
+
}), sessions.data !== undefined && list.length === 0 ? (_jsx("li", { className: "px-3 py-2.5 text-sm text-muted-foreground", children: t('security.noSessions') })) : null] })] }));
|
|
73
|
+
}
|
|
74
|
+
export function SecurityPage({ api }) {
|
|
75
|
+
const { t } = useTranslation('settings');
|
|
76
|
+
const policy = useQuery({
|
|
77
|
+
queryKey: ['settings', 'security'],
|
|
78
|
+
queryFn: () => api.call('GET', '/api/v1/settings/security'),
|
|
79
|
+
});
|
|
80
|
+
return (_jsxs("div", { className: "mx-auto grid max-w-3xl gap-6", children: [_jsxs("header", { children: [_jsx("h1", { className: "text-xl font-semibold", children: t('security.title') }), _jsx("p", { className: "text-sm text-muted-foreground", children: t('security.intro') })] }), policy.error !== null ? (_jsx(Alert, { tone: "danger", children: message(policy.error) })) : (_jsxs(_Fragment, { children: [_jsx(Section, { title: t('security.policy'), hint: t('security.policyHint'), children: policy.data === undefined ? (_jsx("p", { className: "text-sm text-muted-foreground", children: "\u2026" })) : (_jsx(PolicyForm, { api: api, policy: policy.data })) }), _jsx(Section, { title: t('security.sessions'), hint: t('security.sessionsHint'), children: _jsx(Sessions, { api: api }) })] }))] }));
|
|
81
|
+
}
|
|
@@ -8,6 +8,15 @@ import type { i18n } from 'i18next';
|
|
|
8
8
|
import { messages } from './messages.js';
|
|
9
9
|
export { ApiKeysPage } from './ApiKeysPage.js';
|
|
10
10
|
export { AppsPage } from './AppsPage.js';
|
|
11
|
+
export { AuditPage } from './AuditPage.js';
|
|
12
|
+
export { CompaniesPage } from './CompaniesPage.js';
|
|
13
|
+
export { CompanyPage } from './CompanyPage.js';
|
|
14
|
+
export { OverviewPage } from './OverviewPage.js';
|
|
15
|
+
export { PeoplePage } from './PeoplePage.js';
|
|
16
|
+
export { deviceOf, ProfilePage } from './ProfilePage.js';
|
|
17
|
+
export { RolesEditor } from './RolesEditor.js';
|
|
18
|
+
export { RolesPage } from './RolesPage.js';
|
|
19
|
+
export { SecurityPage } from './SecurityPage.js';
|
|
11
20
|
/** Registers the Apps page (and its messages in `i18n`, when given). */
|
|
12
21
|
export declare function registerSettingsWeb(registry: Registry, i18n?: i18n): Registry;
|
|
13
22
|
export { messages as settingsMessages };
|
|
@@ -6,13 +6,38 @@
|
|
|
6
6
|
import { addMessages } from '../../ui/index.js';
|
|
7
7
|
import { ApiKeysPage } from './ApiKeysPage.js';
|
|
8
8
|
import { AppsPage } from './AppsPage.js';
|
|
9
|
+
import { AuditPage } from './AuditPage.js';
|
|
10
|
+
import { CompaniesPage } from './CompaniesPage.js';
|
|
11
|
+
import { CompanyPage } from './CompanyPage.js';
|
|
9
12
|
import { messages } from './messages.js';
|
|
13
|
+
import { OverviewPage } from './OverviewPage.js';
|
|
14
|
+
import { PeoplePage } from './PeoplePage.js';
|
|
15
|
+
import { ProfilePage } from './ProfilePage.js';
|
|
16
|
+
import { RolesPage } from './RolesPage.js';
|
|
17
|
+
import { SecurityPage } from './SecurityPage.js';
|
|
10
18
|
export { ApiKeysPage } from './ApiKeysPage.js';
|
|
11
19
|
export { AppsPage } from './AppsPage.js';
|
|
20
|
+
export { AuditPage } from './AuditPage.js';
|
|
21
|
+
export { CompaniesPage } from './CompaniesPage.js';
|
|
22
|
+
export { CompanyPage } from './CompanyPage.js';
|
|
23
|
+
export { OverviewPage } from './OverviewPage.js';
|
|
24
|
+
export { PeoplePage } from './PeoplePage.js';
|
|
25
|
+
export { deviceOf, ProfilePage } from './ProfilePage.js';
|
|
26
|
+
export { RolesEditor } from './RolesEditor.js';
|
|
27
|
+
export { RolesPage } from './RolesPage.js';
|
|
28
|
+
export { SecurityPage } from './SecurityPage.js';
|
|
12
29
|
/** Registers the Apps page (and its messages in `i18n`, when given). */
|
|
13
30
|
export function registerSettingsWeb(registry, i18n) {
|
|
31
|
+
registry.registerPage('settings.Overview', OverviewPage);
|
|
14
32
|
registry.registerPage('settings.Apps', AppsPage);
|
|
15
|
-
|
|
33
|
+
registry.registerPage('settings.People', PeoplePage);
|
|
34
|
+
registry.registerPage('settings.Roles', RolesPage);
|
|
35
|
+
registry.registerPage('settings.Company', CompanyPage);
|
|
36
|
+
registry.registerPage('settings.Companies', CompaniesPage);
|
|
37
|
+
registry.registerPage('settings.Security', SecurityPage);
|
|
38
|
+
registry.registerPage('settings.Audit', AuditPage);
|
|
39
|
+
// Every signed-in user's own profile (RFC 0019) and keys (RFC 0017 §5.1): in the user menu, not in Settings.
|
|
40
|
+
registry.registerUserPage('settings.Profile', ProfilePage, { label: 'settings:profile.title' });
|
|
16
41
|
registry.registerUserPage('settings.ApiKeys', ApiKeysPage, { label: 'settings:apiKeys.title' });
|
|
17
42
|
if (i18n !== undefined)
|
|
18
43
|
addMessages(i18n, 'settings', messages);
|