shutro-erp 0.3.2 → 0.3.4

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 (156) hide show
  1. package/dist/account_portal/.kosh/bindings.js +1 -1
  2. package/dist/account_portal/.kosh/schema.d.ts +156 -1
  3. package/dist/accounting/.kosh/bindings.js +1 -1
  4. package/dist/accounting/.kosh/schema.d.ts +156 -1
  5. package/dist/assistant/index.js +1 -1
  6. package/dist/contacts/.kosh/bindings.js +1 -1
  7. package/dist/contacts/.kosh/schema.d.ts +156 -1
  8. package/dist/integrations/index.js +12 -0
  9. package/dist/integrations/web/OAuthAppsPage.d.ts +9 -0
  10. package/dist/integrations/web/OAuthAppsPage.js +79 -0
  11. package/dist/integrations/web/index.d.ts +1 -0
  12. package/dist/integrations/web/index.js +3 -0
  13. package/dist/integrations/web/messages.js +58 -0
  14. package/dist/l10n_bd/.kosh/bindings.js +1 -1
  15. package/dist/l10n_bd/.kosh/schema.d.ts +156 -1
  16. package/dist/l10n_in/.kosh/bindings.js +1 -1
  17. package/dist/l10n_in/.kosh/schema.d.ts +156 -4
  18. package/dist/l10n_in/module.d.ts +0 -62
  19. package/dist/l10n_in/module.js +2 -4
  20. package/dist/messaging/web/index.js +1 -1
  21. package/dist/notify/web/index.js +1 -1
  22. package/dist/payment/.kosh/bindings.js +1 -1
  23. package/dist/payment/.kosh/schema.d.ts +157 -2
  24. package/dist/payment/bkash.d.ts +28 -0
  25. package/dist/payment/bkash.js +169 -0
  26. package/dist/payment/index.d.ts +4 -0
  27. package/dist/payment/index.js +80 -24
  28. package/dist/payment/module.d.ts +7 -4
  29. package/dist/payment/module.js +3 -0
  30. package/dist/payment/money.d.ts +7 -0
  31. package/dist/payment/money.js +37 -0
  32. package/dist/payment/providers.d.ts +13 -2
  33. package/dist/payment/razorpay.d.ts +12 -0
  34. package/dist/payment/razorpay.js +149 -0
  35. package/dist/payment/sslcommerz.js +1 -1
  36. package/dist/payment/stripe.d.ts +15 -0
  37. package/dist/payment/stripe.js +182 -0
  38. package/dist/permissions/admin.d.ts +3 -0
  39. package/dist/permissions/admin.js +108 -10
  40. package/dist/permissions/index.d.ts +1 -0
  41. package/dist/permissions/index.js +3 -0
  42. package/dist/permissions/roles.d.ts +2 -0
  43. package/dist/permissions/roles.js +236 -0
  44. package/dist/portal/.kosh/bindings.js +1 -1
  45. package/dist/portal/.kosh/schema.d.ts +156 -1
  46. package/dist/product/.kosh/bindings.js +1 -1
  47. package/dist/product/.kosh/schema.d.ts +156 -1
  48. package/dist/project/.kosh/bindings.js +1 -1
  49. package/dist/project/.kosh/schema.d.ts +156 -1
  50. package/dist/project_portal/.kosh/bindings.js +1 -1
  51. package/dist/project_portal/.kosh/schema.d.ts +156 -1
  52. package/dist/purchase/.kosh/bindings.js +1 -1
  53. package/dist/purchase/.kosh/schema.d.ts +156 -1
  54. package/dist/purchase_account/.kosh/bindings.js +1 -1
  55. package/dist/purchase_account/.kosh/schema.d.ts +156 -1
  56. package/dist/purchase_portal/.kosh/bindings.js +1 -1
  57. package/dist/purchase_portal/.kosh/schema.d.ts +156 -1
  58. package/dist/sale_account/.kosh/bindings.js +1 -1
  59. package/dist/sale_account/.kosh/schema.d.ts +156 -1
  60. package/dist/sale_portal/.kosh/bindings.js +1 -1
  61. package/dist/sale_portal/.kosh/schema.d.ts +156 -1
  62. package/dist/sale_project/.kosh/bindings.js +1 -1
  63. package/dist/sale_project/.kosh/schema.d.ts +156 -1
  64. package/dist/sales/.kosh/bindings.js +1 -1
  65. package/dist/sales/.kosh/schema.d.ts +156 -1
  66. package/dist/server/auth/base.js +98 -0
  67. package/dist/server/auth/oauth.d.ts +21 -0
  68. package/dist/server/auth/oauth.js +579 -0
  69. package/dist/server/auth/routes.d.ts +3 -0
  70. package/dist/server/auth/routes.js +29 -0
  71. package/dist/server/auth/service.d.ts +56 -0
  72. package/dist/server/auth/service.js +185 -3
  73. package/dist/server/identity.d.ts +4 -0
  74. package/dist/server/images.d.ts +12 -0
  75. package/dist/server/images.js +139 -0
  76. package/dist/server/index.d.ts +2 -1
  77. package/dist/server/index.js +1 -0
  78. package/dist/server/server.d.ts +4 -0
  79. package/dist/server/server.js +20 -4
  80. package/dist/settings/company.d.ts +34 -0
  81. package/dist/settings/company.js +189 -0
  82. package/dist/settings/index.d.ts +10 -10
  83. package/dist/settings/index.js +159 -30
  84. package/dist/settings/people.d.ts +16 -0
  85. package/dist/settings/people.js +507 -0
  86. package/dist/settings/security.d.ts +13 -0
  87. package/dist/settings/security.js +158 -0
  88. package/dist/settings/web/AuditPage.d.ts +8 -0
  89. package/dist/settings/web/AuditPage.js +74 -0
  90. package/dist/settings/web/CompaniesPage.d.ts +8 -0
  91. package/dist/settings/web/CompaniesPage.js +52 -0
  92. package/dist/settings/web/CompanyPage.d.ts +9 -0
  93. package/dist/settings/web/CompanyPage.js +105 -0
  94. package/dist/settings/web/ConnectedAppsPage.d.ts +7 -0
  95. package/dist/settings/web/ConnectedAppsPage.js +33 -0
  96. package/dist/settings/web/OverviewPage.d.ts +9 -0
  97. package/dist/settings/web/OverviewPage.js +77 -0
  98. package/dist/settings/web/PeoplePage.d.ts +9 -0
  99. package/dist/settings/web/PeoplePage.js +154 -0
  100. package/dist/settings/web/ProfilePage.d.ts +14 -0
  101. package/dist/settings/web/ProfilePage.js +196 -0
  102. package/dist/settings/web/RolesEditor.d.ts +35 -0
  103. package/dist/settings/web/RolesEditor.js +49 -0
  104. package/dist/settings/web/RolesPage.d.ts +9 -0
  105. package/dist/settings/web/RolesPage.js +296 -0
  106. package/dist/settings/web/SecurityPage.d.ts +8 -0
  107. package/dist/settings/web/SecurityPage.js +81 -0
  108. package/dist/settings/web/index.d.ts +10 -0
  109. package/dist/settings/web/index.js +31 -1
  110. package/dist/settings/web/messages.js +594 -0
  111. package/dist/ui/api.d.ts +8 -0
  112. package/dist/ui/app.d.ts +5 -0
  113. package/dist/ui/app.js +143 -33
  114. package/dist/ui/components/AppIcon.js +11 -1
  115. package/dist/ui/components/Avatar.d.ts +21 -0
  116. package/dist/ui/components/Avatar.js +71 -0
  117. package/dist/ui/components/primitives.d.ts +9 -1
  118. package/dist/ui/components/primitives.js +12 -4
  119. package/dist/ui/context.d.ts +10 -1
  120. package/dist/ui/context.js +60 -2
  121. package/dist/ui/i18n/messages.d.ts +76 -0
  122. package/dist/ui/i18n/messages.js +152 -0
  123. package/dist/ui/index.d.ts +3 -1
  124. package/dist/ui/index.js +3 -1
  125. package/dist/ui/lib/accent.d.ts +13 -0
  126. package/dist/ui/lib/accent.js +36 -0
  127. package/dist/ui/registry.d.ts +7 -1
  128. package/dist/ui/registry.js +10 -1
  129. package/dist/ui/search/ViewTabs.d.ts +14 -0
  130. package/dist/ui/search/ViewTabs.js +42 -0
  131. package/dist/ui/shell/Activate.d.ts +6 -0
  132. package/dist/ui/shell/Activate.js +39 -0
  133. package/dist/ui/shell/CommandPalette.d.ts +7 -1
  134. package/dist/ui/shell/CommandPalette.js +16 -2
  135. package/dist/ui/shell/Consent.d.ts +5 -0
  136. package/dist/ui/shell/Consent.js +35 -0
  137. package/dist/ui/shell/Home.js +85 -16
  138. package/dist/ui/shell/Sidebar.d.ts +27 -0
  139. package/dist/ui/shell/Sidebar.js +77 -0
  140. package/dist/ui/shell/TwoFactorSetup.d.ts +5 -0
  141. package/dist/ui/shell/TwoFactorSetup.js +32 -0
  142. package/dist/ui/shell/shortcuts.d.ts +3 -0
  143. package/dist/ui/shell/shortcuts.js +13 -4
  144. package/dist/ui/styles.css +70 -28
  145. package/dist/ui/views/DateField.d.ts +10 -0
  146. package/dist/ui/views/DateField.js +129 -0
  147. package/dist/ui/views/FormView.js +161 -21
  148. package/dist/ui/views/ListView.d.ts +2 -0
  149. package/dist/ui/views/ListView.js +57 -25
  150. package/dist/ui/views/StatusChip.d.ts +10 -0
  151. package/dist/ui/views/StatusChip.js +22 -0
  152. package/dist/ui/views/widgets.d.ts +3 -0
  153. package/dist/ui/views/widgets.js +26 -8
  154. package/dist/views/types.d.ts +7 -0
  155. package/dist/views/validate.js +4 -0
  156. package/package.json +2 -2
@@ -0,0 +1,154 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Settings › People (RFC 0019 §3.2): who works here — searched and filtered by status —, invitations (the link
4
+ * emailed when mail is set up, always shown to copy), a person's details and roles per app and company, a new
5
+ * invitation, a two-factor reset, deactivation and reactivation, and their recent activity. The server decides
6
+ * what a People manager may do (not administrators, not their own roles) and audits it all.
7
+ */
8
+ import { Alert, ApiError, Avatar, Badge, Button, Dialog, EmptyState, Input, imageUrl, Label, useKosh, } from '../../ui/index.js';
9
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
10
+ import { Check, Copy, Search, ShieldCheck, UserPlus } from 'lucide-react';
11
+ import { useEffect, useId, useState } from 'react';
12
+ import { useTranslation } from 'react-i18next';
13
+ import { RolesEditor } from './RolesEditor.js';
14
+ const P = '/api/v1/settings/people';
15
+ const message = (error) => (error instanceof ApiError ? error.message : String(error));
16
+ function StateBadge({ person }) {
17
+ const { t } = useTranslation('settings');
18
+ return person.state === 'deactivated' ? (_jsx(Badge, { tone: "neutral", children: t('people.deactivated') })) : person.state === 'invited' ? (_jsx(Badge, { tone: "warning", children: t('people.invited') })) : (_jsx(Badge, { tone: "success", children: t('people.active') }));
19
+ }
20
+ /** The link to pass on, with a copy button. */
21
+ function InvitationLink({ url, emailed, to, }) {
22
+ const { t } = useTranslation('settings');
23
+ const [copied, setCopied] = useState(false);
24
+ return (_jsxs(Alert, { tone: "info", children: [_jsx("p", { children: emailed ? t('people.emailed', { to }) : t('people.notEmailed') }), _jsxs("div", { className: "mt-2 flex items-center gap-2", children: [_jsx("code", { className: "min-w-0 flex-1 truncate rounded bg-surface px-2 py-1 font-mono text-xs", "data-testid": "invitation-link", children: url }), _jsxs(Button, { size: "sm", variant: "secondary", onClick: () => {
25
+ void navigator.clipboard?.writeText(url).then(() => setCopied(true));
26
+ }, children: [copied ? _jsx(Check, { "aria-hidden": true }) : _jsx(Copy, { "aria-hidden": true }), copied ? t('people.copied') : t('people.copy')] })] })] }));
27
+ }
28
+ function InviteDialog({ api, catalogue, open, onOpenChange, }) {
29
+ const { t } = useTranslation('settings');
30
+ const id = useId();
31
+ const queryClient = useQueryClient();
32
+ const [name, setName] = useState('');
33
+ const [email, setEmail] = useState('');
34
+ const [assignments, setAssignments] = useState([]);
35
+ const invite = useMutation({
36
+ mutationFn: () => api.call('POST', P, {
37
+ name,
38
+ email,
39
+ assignments,
40
+ }),
41
+ onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['settings', 'people'] }),
42
+ });
43
+ useEffect(() => {
44
+ if (!open) {
45
+ setName('');
46
+ setEmail('');
47
+ setAssignments([]);
48
+ invite.reset();
49
+ }
50
+ }, [open, invite.reset]);
51
+ const done = invite.data;
52
+ return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, title: t('people.inviteTitle'), description: t('people.inviteHint'), wide: true, footer: done === undefined ? (_jsxs(_Fragment, { children: [_jsx(Button, { onClick: () => onOpenChange(false), children: t('profile.cancel') }), _jsx(Button, { variant: "primary", busy: invite.isPending, disabled: name.trim() === '' || !email.includes('@'), onClick: () => invite.mutate(), children: t('people.send') })] })) : (_jsx(Button, { variant: "primary", onClick: () => onOpenChange(false), children: t('people.done') })), children: done !== undefined ? (_jsx(InvitationLink, { url: done.activationUrl, emailed: done.emailed, to: done.login })) : (_jsxs("div", { className: "grid gap-4", children: [_jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [_jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { htmlFor: `${id}-name`, children: t('profile.name') }), _jsx(Input, { id: `${id}-name`, value: name, onChange: (e) => setName(e.target.value) })] }), _jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { htmlFor: `${id}-email`, children: t('profile.email') }), _jsx(Input, { id: `${id}-email`, type: "email", value: email, onChange: (e) => setEmail(e.target.value) })] })] }), _jsxs("div", { className: "grid gap-2", children: [_jsx("h3", { className: "text-sm font-semibold", children: t('people.roles') }), _jsx(RolesEditor, { catalogue: catalogue, value: assignments, onChange: setAssignments })] }), invite.error === null ? null : _jsx(Alert, { tone: "danger", children: message(invite.error) })] })) }));
53
+ }
54
+ /** What a person can do, computed by the server as them (administrators only). */
55
+ function Effective({ api, personId }) {
56
+ const { t } = useTranslation('settings');
57
+ const [open, setOpen] = useState(false);
58
+ const effective = useQuery({
59
+ queryKey: ['settings', 'people', personId, 'effective'],
60
+ queryFn: () => api.call('GET', `/api/v1/admin/users/${personId}/effective`),
61
+ enabled: open,
62
+ });
63
+ const models = Object.entries(effective.data?.models ?? {})
64
+ .filter(([, m]) => m.perms.includes('read'))
65
+ .sort((a, b) => a[1].label.localeCompare(b[1].label));
66
+ return (_jsxs("details", { className: "rounded-md border", onToggle: (e) => setOpen(e.target.open), children: [_jsx("summary", { className: "cursor-pointer px-3 py-2 text-sm font-semibold", children: t('people.canDo') }), _jsx("div", { className: "grid gap-3 border-t p-3 text-sm", children: effective.data === undefined ? (_jsx("p", { className: "text-muted-foreground", children: "\u2026" })) : effective.data.administrator ? (_jsx("p", { children: t('people.canDoAll') })) : (_jsxs(_Fragment, { children: [models.length === 0 ? (_jsx("p", { className: "text-muted-foreground", children: t('people.canDoNothing') })) : (_jsx("ul", { className: "grid gap-1", children: models.map(([name, m]) => (_jsxs("li", { className: "flex flex-wrap gap-x-2", children: [_jsx("span", { className: "font-medium", children: m.label }), _jsx("span", { className: "text-muted-foreground", children: m.perms.map((p) => t(`roles.perm.${p}`)).join(', ') }), m.hiddenFields.length === 0 ? null : (_jsx("span", { className: "text-xs text-muted-foreground", children: t('people.hiddenFields', { count: m.hiddenFields.length }) }))] }, name))) })), effective.data.permissions.length === 0 ? null : (_jsxs("p", { children: [_jsxs("span", { className: "font-medium", children: [t('roles.permissions'), ": "] }), effective.data.permissions
67
+ .map((p) => effective.data?.permissionLabels[p] ?? p)
68
+ .join(', ')] }))] })) })] }));
69
+ }
70
+ function PersonDialog({ api, catalogue, personId, onClose, }) {
71
+ const { t } = useTranslation('settings');
72
+ const { format, uid } = useKosh();
73
+ const id = useId();
74
+ const queryClient = useQueryClient();
75
+ const person = useQuery({
76
+ queryKey: ['settings', 'people', personId],
77
+ queryFn: () => api.call('GET', `${P}/${personId}`),
78
+ });
79
+ const [name, setName] = useState('');
80
+ const [email, setEmail] = useState('');
81
+ const [assignments, setAssignments] = useState([]);
82
+ const [link, setLink] = useState(null);
83
+ const [confirm, setConfirm] = useState(null);
84
+ useEffect(() => {
85
+ if (person.data === undefined)
86
+ return;
87
+ setName(person.data.name);
88
+ setEmail(person.data.email ?? '');
89
+ setAssignments(person.data.assignments.map(({ group, company }) => ({ group, company })));
90
+ }, [person.data]);
91
+ const refresh = () => void queryClient.invalidateQueries({ queryKey: ['settings', 'people'] });
92
+ const saveDetails = useMutation({
93
+ mutationFn: () => api.call('PATCH', `${P}/${personId}`, { name, email }),
94
+ onSuccess: refresh,
95
+ });
96
+ const saveRoles = useMutation({
97
+ mutationFn: () => api.call('PUT', `${P}/${personId}/roles`, { assignments }),
98
+ onSuccess: refresh,
99
+ });
100
+ const setActive = useMutation({
101
+ mutationFn: (active) => api.call('PATCH', `${P}/${personId}`, { active }),
102
+ onSuccess: () => {
103
+ setConfirm(null);
104
+ refresh();
105
+ },
106
+ });
107
+ const resend = useMutation({
108
+ mutationFn: () => api.call('POST', `${P}/${personId}/invitation`),
109
+ onSuccess: (data) => {
110
+ setLink({ url: data.activationUrl, emailed: data.emailed });
111
+ refresh();
112
+ },
113
+ });
114
+ const reset = useMutation({
115
+ mutationFn: () => api.call('POST', `${P}/${personId}/reset-2fa`),
116
+ onSuccess: () => {
117
+ setConfirm(null);
118
+ refresh();
119
+ },
120
+ });
121
+ const p = person.data;
122
+ const error = saveDetails.error ?? saveRoles.error ?? setActive.error ?? resend.error ?? reset.error;
123
+ const self = personId === uid;
124
+ return (_jsx(Dialog, { open: true, onOpenChange: (open) => !open && onClose(), title: p?.name ?? '…', wide: true, children: p === undefined ? null : (_jsxs("div", { className: "grid gap-5", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2 text-sm", children: [_jsx(Avatar, { name: p.name, src: imageUrl('user', p.id, p.avatar), className: "size-8" }), _jsx("span", { className: "text-muted-foreground", children: p.login }), _jsx(StateBadge, { person: p }), p.administrator ? _jsx(Badge, { tone: "info", children: t('people.administrator') }) : null, p.twoFactor ? (_jsxs(Badge, { tone: "success", children: [_jsx(ShieldCheck, { className: "size-3", "aria-hidden": true }), t('people.twoFactor')] })) : null] }), _jsxs("section", { className: "grid gap-3", children: [_jsx("h3", { className: "text-sm font-semibold", children: t('people.details') }), _jsxs("div", { className: "grid gap-3 sm:grid-cols-[1fr_1fr_auto] sm:items-end", children: [_jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { htmlFor: `${id}-name`, children: t('profile.name') }), _jsx(Input, { id: `${id}-name`, value: name, onChange: (e) => setName(e.target.value) })] }), _jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { htmlFor: `${id}-email`, children: t('profile.email') }), _jsx(Input, { id: `${id}-email`, type: "email", value: email, onChange: (e) => setEmail(e.target.value) })] }), _jsx(Button, { variant: "secondary", busy: saveDetails.isPending, onClick: () => saveDetails.mutate(), children: t('profile.save') })] })] }), _jsxs("section", { className: "grid gap-3", children: [_jsx("h3", { className: "text-sm font-semibold", children: t('people.roles') }), self && !catalogue.manager.administrator ? (_jsx("p", { className: "text-sm text-muted-foreground", children: t('people.notYourOwn') })) : (_jsxs(_Fragment, { children: [_jsx(RolesEditor, { catalogue: catalogue, value: assignments, onChange: setAssignments }), _jsx("div", { children: _jsx(Button, { variant: "primary", busy: saveRoles.isPending, onClick: () => saveRoles.mutate(), children: t('people.saveRoles') }) })] }))] }), _jsxs("section", { className: "grid gap-3", children: [_jsx("h3", { className: "text-sm font-semibold", children: t('people.access') }), _jsxs("div", { className: "flex flex-wrap gap-2", children: [p.state === 'invited' || p.state === 'active' ? (_jsx(Button, { variant: "secondary", busy: resend.isPending, onClick: () => resend.mutate(), children: p.state === 'invited' ? t('people.resend') : t('people.newLink') })) : null, p.twoFactor ? (_jsx(Button, { variant: "secondary", onClick: () => setConfirm('reset'), children: t('people.reset2fa') })) : null, self ? null : p.active ? (_jsx(Button, { variant: "danger", onClick: () => setConfirm('deactivate'), children: t('people.deactivate') })) : (_jsx(Button, { variant: "secondary", busy: setActive.isPending, onClick: () => setActive.mutate(true), children: t('people.reactivate') }))] }), link === null ? null : (_jsx(InvitationLink, { url: link.url, emailed: link.emailed, to: p.login })), confirm === null ? null : (_jsx(Alert, { tone: "warning", action: _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { size: "sm", onClick: () => setConfirm(null), children: t('profile.cancel') }), _jsx(Button, { size: "sm", variant: "danger", busy: setActive.isPending || reset.isPending, onClick: () => confirm === 'reset' ? reset.mutate() : setActive.mutate(false), children: confirm === 'reset' ? t('people.reset2fa') : t('people.deactivate') })] }), children: confirm === 'reset' ? t('people.resetConfirm') : t('people.deactivateConfirm') }))] }), error === null ? null : _jsx(Alert, { tone: "danger", children: message(error) }), catalogue.manager.administrator ? _jsx(Effective, { api: api, personId: personId }) : null, _jsxs("section", { className: "grid gap-2", children: [_jsx("h3", { className: "text-sm font-semibold", children: t('people.activity') }), p.activity.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: t('people.noActivity') })) : (_jsx("ol", { className: "grid gap-1.5 text-sm", children: p.activity.map((a, i) => (_jsxs("li", { className: "flex gap-3", children: [_jsx("span", { className: "w-40 shrink-0 text-xs text-muted-foreground", children: a.at === null ? '' : format.datetime(a.at) }), _jsx("span", { children: a.summary })] }, i))) }))] })] })) }));
125
+ }
126
+ export function PeoplePage({ api }) {
127
+ const { t } = useTranslation('settings');
128
+ const { format } = useKosh();
129
+ const id = useId();
130
+ const [q, setQ] = useState('');
131
+ const [status, setStatus] = useState('all');
132
+ const [inviting, setInviting] = useState(false);
133
+ const [open, setOpen] = useState(null);
134
+ const catalogue = useQuery({
135
+ queryKey: ['settings', 'people', 'catalogue'],
136
+ queryFn: () => api.call('GET', `${P}/catalogue`),
137
+ });
138
+ const people = useQuery({
139
+ queryKey: ['settings', 'people', 'list', q, status],
140
+ queryFn: () => api.call('GET', `${P}?status=${status}&q=${encodeURIComponent(q.trim())}`),
141
+ });
142
+ if (catalogue.error !== null)
143
+ return _jsx(Alert, { tone: "danger", children: message(catalogue.error) });
144
+ const list = people.data?.people ?? [];
145
+ const statuses = ['all', 'active', 'invited', 'deactivated'];
146
+ 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('people.title') }), _jsx("p", { className: "text-sm text-muted-foreground", children: t('people.intro') })] }), _jsxs(Button, { variant: "primary", onClick: () => setInviting(true), disabled: catalogue.data === undefined, children: [_jsx(UserPlus, { "aria-hidden": true }), t('people.invite')] })] }), _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('people.search') }), _jsx(Input, { id: `${id}-q`, type: "search", className: "ps-9", placeholder: t('people.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('people.status') }), statuses.map((s) => (_jsx("button", { type: "button", "aria-pressed": status === s, onClick: () => setStatus(s), className: "rounded px-3 py-1 text-sm aria-pressed:bg-accent aria-pressed:font-medium aria-pressed:text-accent-foreground", children: t(`people.filter.${s}`) }, s)))] })] }), people.isSuccess && list.length === 0 ? (_jsx(EmptyState, { title: t('people.empty') })) : (_jsx("ul", { className: "divide-y rounded-lg border bg-surface", "aria-label": t('people.title'), children: list.map((p) => (_jsx("li", { children: _jsxs("button", { type: "button", onClick: () => setOpen(p.id), className: "flex w-full flex-wrap items-center gap-3 px-4 py-3 text-start hover:bg-muted/50", children: [_jsx(Avatar, { name: p.name, src: imageUrl('user', p.id, p.avatar), className: "size-8" }), _jsxs("span", { className: "grid min-w-48 flex-1", children: [_jsx("span", { className: "font-medium", children: p.name }), _jsx("span", { className: "text-sm text-muted-foreground", children: p.login })] }), _jsxs("span", { className: "flex min-w-48 flex-1 flex-wrap gap-1", children: [p.administrator ? _jsx(Badge, { tone: "info", children: t('people.administrator') }) : null, p.assignments
147
+ .filter((a) => !a.group.startsWith('base.'))
148
+ .slice(0, 3)
149
+ .map((a) => (_jsx(Badge, { tone: "neutral", children: a.label }, `${a.group}-${a.company}`))), p.assignments.length > 3 ? (_jsxs("span", { className: "text-xs text-muted-foreground", children: ["+", p.assignments.length - 3] })) : null] }), _jsxs("span", { className: "flex items-center gap-2", children: [p.twoFactor ? (_jsx(ShieldCheck, { className: "size-4 text-success", "aria-label": t('people.twoFactor') })) : null, _jsx(StateBadge, { person: p })] }), _jsx("span", { className: "w-40 text-end text-xs text-muted-foreground", children: p.state === 'invited' && p.invitationExpiresAt !== null
150
+ ? t('people.expires', { when: format.date(p.invitationExpiresAt) })
151
+ : p.lastSeenAt === null
152
+ ? t('people.never')
153
+ : t('profile.lastActive', { when: format.datetime(p.lastSeenAt) }) })] }) }, p.id))) })), catalogue.data === undefined ? null : (_jsx(InviteDialog, { api: api, catalogue: catalogue.data, open: inviting, onOpenChange: setInviting })), open === null || catalogue.data === undefined ? null : (_jsx(PersonDialog, { api: api, catalogue: catalogue.data, personId: open, onClose: () => setOpen(null) }))] }));
154
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Your profile (RFC 0019 §3.6), from the user menu: your details, language and region (with your default company),
3
+ * your password, two-factor sign-in (set up with an authenticator app, recovery codes, turn off) and the devices
4
+ * signed in as you (sign one out, or all the others). The server checks and audits every change.
5
+ */
6
+ import { type PageProps } from '../../ui/index.js';
7
+ import { type ReactElement } from 'react';
8
+ /** "Chrome on Windows" from a user agent; enough to recognise a device. */
9
+ export declare function deviceOf(userAgent: string | null): {
10
+ browser: string;
11
+ os: string;
12
+ phone: boolean;
13
+ };
14
+ export declare function ProfilePage({ api }: PageProps): ReactElement;
@@ -0,0 +1,196 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Your profile (RFC 0019 §3.6), from the user menu: your details, language and region (with your default company),
4
+ * your password, two-factor sign-in (set up with an authenticator app, recovery codes, turn off) and the devices
5
+ * signed in as you (sign one out, or all the others). The server checks and audits every change.
6
+ */
7
+ import { Alert, ApiError, Badge, Button, ImagePicker, Input, imageUrl, LANGUAGES, Label, Select, useKosh, useLanguage, } from '../../ui/index.js';
8
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
9
+ import { Laptop, Monitor, Smartphone } from 'lucide-react';
10
+ import { useEffect, useId, useState } from 'react';
11
+ import { useTranslation } from 'react-i18next';
12
+ const message = (error) => (error instanceof ApiError ? error.message : String(error));
13
+ /** "Chrome on Windows" from a user agent; enough to recognise a device. */
14
+ export function deviceOf(userAgent) {
15
+ const ua = userAgent ?? '';
16
+ const browser = /Edg\//.test(ua)
17
+ ? 'Edge'
18
+ : /OPR\//.test(ua)
19
+ ? 'Opera'
20
+ : /Firefox\//.test(ua)
21
+ ? 'Firefox'
22
+ : /Chrome\//.test(ua)
23
+ ? 'Chrome'
24
+ : /Safari\//.test(ua)
25
+ ? 'Safari'
26
+ : (ua.split(/[\s/]/)[0] ?? '');
27
+ const os = /Windows/.test(ua)
28
+ ? 'Windows'
29
+ : /Android/.test(ua)
30
+ ? 'Android'
31
+ : /iPhone|iPad/.test(ua)
32
+ ? 'iOS'
33
+ : /Mac OS X/.test(ua)
34
+ ? 'macOS'
35
+ : /Linux/.test(ua)
36
+ ? 'Linux'
37
+ : '';
38
+ return { browser, os, phone: /Mobile|Android|iPhone/.test(ua) };
39
+ }
40
+ function Section({ title, hint, children, }) {
41
+ const id = useId();
42
+ 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 }), hint === undefined ? null : _jsx("p", { className: "text-sm text-muted-foreground", children: hint })] }), children] }));
43
+ }
44
+ function Field({ label, id, children, }) {
45
+ return (_jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { htmlFor: id, children: label }), children] }));
46
+ }
47
+ function TwoFactor({ api, me }) {
48
+ const { t } = useTranslation('settings');
49
+ const id = useId();
50
+ const queryClient = useQueryClient();
51
+ const [setup, setSetup] = useState(null);
52
+ const [code, setCode] = useState('');
53
+ const [password, setPassword] = useState('');
54
+ const [codes, setCodes] = useState(null);
55
+ const refresh = () => void queryClient.invalidateQueries({ queryKey: ['me'] });
56
+ const start = useMutation({
57
+ mutationFn: () => api.call('POST', '/api/v1/auth/2fa/setup'),
58
+ onSuccess: (data) => {
59
+ setSetup(data);
60
+ setCode('');
61
+ },
62
+ });
63
+ const enable = useMutation({
64
+ mutationFn: () => api.call('POST', '/api/v1/auth/2fa/enable', { code }),
65
+ onSuccess: (data) => {
66
+ setCodes(data.recoveryCodes);
67
+ setSetup(null);
68
+ refresh();
69
+ },
70
+ });
71
+ const disable = useMutation({
72
+ mutationFn: () => api.call('POST', '/api/v1/auth/2fa/disable', { password, code }),
73
+ onSuccess: () => {
74
+ setPassword('');
75
+ setCode('');
76
+ refresh();
77
+ },
78
+ });
79
+ const error = start.error ?? enable.error ?? disable.error;
80
+ return (_jsxs(Section, { title: t('profile.twoFactor'), hint: t('profile.twoFactorHint'), children: [_jsxs("div", { className: "flex items-center gap-2 text-sm", children: [me.twoFactorEnabled ? (_jsx(Badge, { tone: "success", children: t('profile.twoFactorOn') })) : (_jsx(Badge, { tone: "warning", children: t('profile.twoFactorOff') })), me.administrator === true && !me.twoFactorEnabled ? (_jsx("span", { className: "text-warning-text", children: t('profile.adminNeeds') })) : null] }), codes === null ? null : (_jsxs(Alert, { tone: "info", children: [_jsx("p", { className: "font-medium", children: t('profile.recoveryCodes') }), _jsx("ul", { className: "mt-2 grid grid-cols-2 gap-1 font-mono text-sm sm:grid-cols-4", children: codes.map((c) => (_jsx("li", { children: c }, c))) })] })), !me.twoFactorEnabled && setup === null ? (_jsx("div", { children: _jsx(Button, { variant: "primary", busy: start.isPending, onClick: () => start.mutate(), children: t('profile.setUp') }) })) : null, setup === null ? null : (_jsxs("form", { className: "grid gap-3", onSubmit: (e) => {
81
+ e.preventDefault();
82
+ enable.mutate();
83
+ }, children: [_jsxs("ol", { className: "grid list-decimal gap-2 ps-5 text-sm", children: [_jsxs("li", { children: [t('profile.step1'), ' ', _jsx("a", { className: "text-primary underline", href: setup.otpauthUri, children: t('profile.openApp') })] }), _jsxs("li", { children: [t('profile.step2'), _jsx("code", { className: "ms-2 select-all break-all rounded bg-muted px-2 py-1 font-mono text-xs", children: setup.secret })] }), _jsx("li", { children: t('profile.step3') })] }), _jsx(Field, { label: t('profile.code'), id: `${id}-code`, children: _jsx(Input, { id: `${id}-code`, inputMode: "numeric", autoComplete: "one-time-code", className: "w-40 font-mono", value: code, onChange: (e) => setCode(e.target.value.trim()) }) }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "submit", variant: "primary", busy: enable.isPending, disabled: code.length < 6, children: t('profile.turnOn') }), _jsx(Button, { type: "button", variant: "ghost", onClick: () => setSetup(null), children: t('profile.cancel') })] })] })), me.twoFactorEnabled ? (_jsxs("details", { children: [_jsx("summary", { className: "cursor-pointer text-sm", children: t('profile.turnOff') }), _jsxs("form", { className: "mt-3 grid gap-3 sm:grid-cols-[1fr_1fr_auto] sm:items-end", onSubmit: (e) => {
84
+ e.preventDefault();
85
+ disable.mutate();
86
+ }, children: [_jsx(Field, { label: t('profile.password'), id: `${id}-off-password`, children: _jsx(Input, { id: `${id}-off-password`, type: "password", autoComplete: "current-password", value: password, onChange: (e) => setPassword(e.target.value) }) }), _jsx(Field, { label: t('profile.code'), id: `${id}-off-code`, children: _jsx(Input, { id: `${id}-off-code`, inputMode: "numeric", autoComplete: "one-time-code", value: code, onChange: (e) => setCode(e.target.value.trim()) }) }), _jsx(Button, { type: "submit", variant: "danger", busy: disable.isPending, disabled: password === '' || code === '', children: t('profile.turnOff') })] })] })) : null, error === null ? null : _jsx(Alert, { tone: "danger", children: message(error) })] }));
87
+ }
88
+ function Sessions({ api }) {
89
+ const { t } = useTranslation('settings');
90
+ const { format } = useKosh();
91
+ const queryClient = useQueryClient();
92
+ const sessions = useQuery({
93
+ queryKey: ['profile', 'sessions'],
94
+ queryFn: () => api.call('GET', '/api/v1/auth/sessions'),
95
+ });
96
+ const refresh = () => void queryClient.invalidateQueries({ queryKey: ['profile', 'sessions'] });
97
+ const one = useMutation({
98
+ mutationFn: (sid) => api.call('DELETE', `/api/v1/auth/sessions/${sid}`),
99
+ onSuccess: refresh,
100
+ });
101
+ const others = useMutation({
102
+ mutationFn: () => api.call('POST', '/api/v1/auth/sessions/sign-out-others'),
103
+ onSuccess: refresh,
104
+ });
105
+ const list = sessions.data?.sessions ?? [];
106
+ return (_jsxs(Section, { title: t('profile.sessions'), hint: t('profile.sessionsHint'), children: [_jsx("ul", { className: "divide-y rounded-md border", children: list.map((s) => {
107
+ const device = deviceOf(s.userAgent);
108
+ const Icon = device.phone ? Smartphone : device.os === '' ? Monitor : Laptop;
109
+ const name = [device.browser, device.os].filter((x) => x !== '').join(' · ') ||
110
+ t('profile.unknownDevice');
111
+ 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: [name, ' ', s.current ? _jsx(Badge, { tone: "success", children: t('profile.thisDevice') }) : null] }), _jsx("p", { className: "text-xs text-muted-foreground", children: [s.ip, t('profile.lastActive', { when: format.datetime(s.lastSeenAt) })]
112
+ .filter((x) => x !== null)
113
+ .join(' · ') })] }), s.current ? null : (_jsx(Button, { size: "sm", variant: "secondary", busy: one.isPending && one.variables === s.id, onClick: () => one.mutate(s.id), "aria-label": t('profile.signOutDevice', { device: name }), children: t('profile.signOut') }))] }, s.id));
114
+ }) }), list.filter((s) => !s.current).length === 0 ? null : (_jsx("div", { children: _jsx(Button, { variant: "secondary", busy: others.isPending, onClick: () => others.mutate(), children: t('profile.signOutOthers') }) })), (one.error ?? others.error) === null ? null : (_jsx(Alert, { tone: "danger", children: message(one.error ?? others.error) }))] }));
115
+ }
116
+ export function ProfilePage({ api }) {
117
+ const { t } = useTranslation('settings');
118
+ const id = useId();
119
+ const queryClient = useQueryClient();
120
+ const { setLang } = useLanguage();
121
+ const profile = useQuery({
122
+ queryKey: ['profile'],
123
+ queryFn: () => api.call('GET', '/api/v1/auth/profile'),
124
+ });
125
+ const me = useQuery({ queryKey: ['me'], queryFn: () => api.call('GET', '/api/v1/auth/me') });
126
+ const companies = useQuery({
127
+ queryKey: ['names', 'res.company', me.data?.companies],
128
+ queryFn: () => api.names('res.company', { ids: me.data?.companies ?? [] }),
129
+ enabled: (me.data?.companies.length ?? 0) > 1,
130
+ });
131
+ const [draft, setDraft] = useState(null);
132
+ useEffect(() => {
133
+ if (profile.data !== undefined)
134
+ setDraft(profile.data);
135
+ }, [profile.data]);
136
+ const save = useMutation({
137
+ mutationFn: (values) => api.call('PATCH', '/api/v1/auth/profile', values),
138
+ onSuccess: (saved) => {
139
+ queryClient.setQueryData(['profile'], saved);
140
+ void queryClient.invalidateQueries({ queryKey: ['me'] });
141
+ // The app follows the language chosen here.
142
+ setLang(saved.lang);
143
+ },
144
+ });
145
+ const photo = useMutation({
146
+ mutationFn: (image) => image === null
147
+ ? api.call('DELETE', '/api/v1/auth/profile/avatar')
148
+ : api.call('PUT', '/api/v1/auth/profile/avatar', { image }),
149
+ // The shell shows the photo from the user's details.
150
+ onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['me'] }),
151
+ });
152
+ const [current, setCurrent] = useState('');
153
+ const [next, setNext] = useState('');
154
+ const [repeat, setRepeat] = useState('');
155
+ const password = useMutation({
156
+ mutationFn: () => api.call('POST', '/api/v1/auth/password', { current, next }),
157
+ onSuccess: () => {
158
+ setCurrent('');
159
+ setNext('');
160
+ setRepeat('');
161
+ },
162
+ });
163
+ const zones = (() => {
164
+ let list;
165
+ try {
166
+ list = Intl.supportedValuesOf('timeZone');
167
+ }
168
+ catch {
169
+ list = ['Asia/Dhaka', 'Asia/Kolkata'];
170
+ }
171
+ // UTC (every new user's default) and the saved zone are always offered.
172
+ return [...new Set(['UTC', ...(draft === null ? [] : [draft.tz]), ...list])];
173
+ })();
174
+ if (profile.error !== null)
175
+ return _jsx(Alert, { tone: "danger", children: message(profile.error) });
176
+ if (draft === null || me.data === undefined)
177
+ return _jsx("p", { className: "text-sm text-muted-foreground", children: "\u2026" });
178
+ const set = (patch) => setDraft({ ...draft, ...patch });
179
+ return (_jsxs("div", { className: "mx-auto grid max-w-3xl gap-6", children: [_jsxs("header", { className: "grid gap-4", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-xl font-semibold", children: t('profile.title') }), _jsx("p", { className: "text-sm text-muted-foreground", children: t('profile.signedInAs', { login: draft.login }) })] }), _jsxs("section", { "aria-label": t('profile.photo'), className: "rounded-lg border bg-surface p-5", children: [_jsx(ImagePicker, { name: draft.name, src: imageUrl('user', me.data.id, me.data.avatar), fit: "photo", busy: photo.isPending, onChoose: (image) => photo.mutate(image), onRemove: () => photo.mutate(null) }), photo.error === null ? null : (_jsx("div", { className: "mt-3", children: _jsx(Alert, { tone: "danger", children: message(photo.error) }) }))] })] }), _jsxs("form", { className: "grid gap-6", onSubmit: (e) => {
180
+ e.preventDefault();
181
+ save.mutate({
182
+ name: draft.name,
183
+ email: draft.email ?? '',
184
+ phone: draft.phone ?? '',
185
+ lang: draft.lang,
186
+ tz: draft.tz,
187
+ ...(draft.company === null ? {} : { company: draft.company }),
188
+ });
189
+ }, children: [_jsx(Section, { title: t('profile.details'), children: _jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [_jsx(Field, { label: t('profile.name'), id: `${id}-name`, children: _jsx(Input, { id: `${id}-name`, autoComplete: "name", required: true, value: draft.name, onChange: (e) => set({ name: e.target.value }) }) }), _jsx(Field, { label: t('profile.email'), id: `${id}-email`, children: _jsx(Input, { id: `${id}-email`, type: "email", autoComplete: "email", value: draft.email ?? '', onChange: (e) => set({ email: e.target.value }) }) }), _jsx(Field, { label: t('profile.phone'), id: `${id}-phone`, children: _jsx(Input, { id: `${id}-phone`, type: "tel", autoComplete: "tel", value: draft.phone ?? '', onChange: (e) => set({ phone: e.target.value }) }) })] }) }), _jsx(Section, { title: t('profile.region'), hint: t('profile.regionHint'), children: _jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [_jsx(Field, { label: t('profile.language'), id: `${id}-lang`, children: _jsx(Select, { id: `${id}-lang`, label: t('profile.language'), value: draft.lang, options: LANGUAGES.map((l) => ({ value: l.code, label: l.name })), onChange: (v) => set({ lang: v }) }) }), _jsx(Field, { label: t('profile.timeZone'), id: `${id}-tz`, children: _jsx(Select, { id: `${id}-tz`, label: t('profile.timeZone'), value: draft.tz, options: zones.map((z) => ({ value: z, label: z.replace(/_/g, ' ') })), onChange: (v) => set({ tz: v }) }) }), (me.data.companies.length ?? 0) > 1 ? (_jsx(Field, { label: t('profile.defaultCompany'), id: `${id}-company`, children: _jsx(Select, { id: `${id}-company`, label: t('profile.defaultCompany'), value: draft.company === null ? null : String(draft.company), options: (companies.data ?? []).map((c) => ({
190
+ value: String(c.id),
191
+ label: c.displayName,
192
+ })), onChange: (v) => set({ company: Number(v) }) }) })) : null] }) }), save.error === null ? null : _jsx(Alert, { tone: "danger", children: message(save.error) }), save.isSuccess ? _jsx(Alert, { tone: "info", children: t('profile.saved') }) : null, _jsx("div", { className: "flex justify-end", children: _jsx(Button, { type: "submit", variant: "primary", busy: save.isPending, children: t('profile.save') }) })] }), _jsxs(Section, { title: t('profile.passwordTitle'), hint: t('profile.passwordHint'), children: [_jsxs("form", { className: "grid gap-4 sm:grid-cols-3 sm:items-end", onSubmit: (e) => {
193
+ e.preventDefault();
194
+ password.mutate();
195
+ }, children: [_jsx(Field, { label: t('profile.currentPassword'), id: `${id}-current`, children: _jsx(Input, { id: `${id}-current`, type: "password", autoComplete: "current-password", value: current, onChange: (e) => setCurrent(e.target.value) }) }), _jsx(Field, { label: t('profile.newPassword'), id: `${id}-next`, children: _jsx(Input, { id: `${id}-next`, type: "password", autoComplete: "new-password", minLength: 12, value: next, onChange: (e) => setNext(e.target.value) }) }), _jsx(Field, { label: t('profile.repeatPassword'), id: `${id}-repeat`, children: _jsx(Input, { id: `${id}-repeat`, type: "password", autoComplete: "new-password", value: repeat, "aria-invalid": repeat !== '' && repeat !== next ? true : undefined, onChange: (e) => setRepeat(e.target.value) }) }), _jsx("div", { className: "sm:col-span-3", children: _jsx(Button, { type: "submit", variant: "secondary", busy: password.isPending, disabled: current === '' || next.length < 12 || next !== repeat, children: t('profile.changePassword') }) })] }), password.error === null ? null : _jsx(Alert, { tone: "danger", children: message(password.error) }), password.isSuccess ? _jsx(Alert, { tone: "info", children: t('profile.passwordChanged') }) : null] }), _jsx(TwoFactor, { api: api, me: me.data }), _jsx(Sessions, { api: api })] }));
196
+ }
@@ -0,0 +1,35 @@
1
+ import { type ReactElement } from 'react';
2
+ export interface Assignment {
3
+ readonly group: string;
4
+ readonly company: number | null;
5
+ }
6
+ export interface Catalogue {
7
+ readonly apps: readonly {
8
+ readonly module: string;
9
+ readonly label: string;
10
+ /** A real app (in the sidebar), or a technical module (storage, notifications…). */
11
+ readonly app: boolean;
12
+ readonly levels: readonly {
13
+ readonly key: string;
14
+ readonly label: string;
15
+ readonly groups: readonly string[];
16
+ }[];
17
+ }[];
18
+ readonly roles: readonly {
19
+ readonly code: string;
20
+ readonly label: string;
21
+ readonly administrator: boolean;
22
+ }[];
23
+ readonly companies: readonly {
24
+ readonly id: number;
25
+ readonly name: string;
26
+ }[];
27
+ readonly manager: {
28
+ readonly administrator: boolean;
29
+ };
30
+ }
31
+ export declare function RolesEditor({ catalogue, value, onChange, }: {
32
+ readonly catalogue: Catalogue;
33
+ readonly value: readonly Assignment[];
34
+ readonly onChange: (next: Assignment[]) => void;
35
+ }): ReactElement;
@@ -0,0 +1,49 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * A person's roles as people think of them (RFC 0019 §3.2): per app, one access level (*No access · User · Manager*),
4
+ * per company when there are several; other roles (runtime roles, Administrator) as checkboxes. It edits the
5
+ * assignments `{ group, company }` the server stores; the server checks every one.
6
+ */
7
+ import { Checkbox, Label, Select } from '../../ui/index.js';
8
+ import { useId, useState } from 'react';
9
+ import { useTranslation } from 'react-i18next';
10
+ /** "No access" (the select takes no empty value). */
11
+ const NONE = 'none';
12
+ export function RolesEditor({ catalogue, value, onChange, }) {
13
+ const { t } = useTranslation('settings');
14
+ const id = useId();
15
+ const several = catalogue.companies.length > 1;
16
+ const [company, setCompany] = useState(null);
17
+ const here = value.filter((a) => a.company === company);
18
+ const has = (group) => here.some((a) => a.group === group);
19
+ const levelGroups = new Set(catalogue.apps.flatMap((a) => a.levels.flatMap((l) => l.groups)));
20
+ /** The app's level: the last declared whose groups the person all holds (levels go up). */
21
+ const levelOf = (app) => [...app.levels].reverse().find((l) => l.groups.every(has))?.key ?? NONE;
22
+ const setLevel = (app, key) => {
23
+ const appGroups = new Set(app.levels.flatMap((l) => l.groups));
24
+ const kept = value.filter((a) => a.company !== company || !appGroups.has(a.group));
25
+ const level = app.levels.find((l) => l.key === key);
26
+ onChange([...kept, ...(level?.groups ?? []).map((group) => ({ group, company }))]);
27
+ };
28
+ const toggle = (group, on) => onChange(on
29
+ ? [...value, { group, company }]
30
+ : value.filter((a) => !(a.company === company && a.group === group)));
31
+ const others = catalogue.roles.filter((r) => !levelGroups.has(r.code) && r.code !== 'base.user' && !r.code.startsWith('base.'));
32
+ const admins = catalogue.roles.filter((r) => r.administrator);
33
+ return (_jsxs("div", { className: "grid gap-4", children: [several ? (_jsxs("div", { className: "grid gap-1.5 sm:max-w-xs", children: [_jsx(Label, { htmlFor: `${id}-company`, children: t('people.rolesIn') }), _jsx(Select, { id: `${id}-company`, label: t('people.rolesIn'), value: company === null ? 'all' : String(company), options: [
34
+ { value: 'all', label: t('people.everyCompany') },
35
+ ...catalogue.companies.map((c) => ({ value: String(c.id), label: c.name })),
36
+ ], onChange: (v) => setCompany(v === 'all' ? null : Number(v)) })] })) : null, [
37
+ { key: 'apps', list: catalogue.apps.filter((a) => a.app) },
38
+ { key: 'technical', list: catalogue.apps.filter((a) => !a.app) },
39
+ ].map(({ key, list }) => list.length === 0 ? null : key === 'apps' ? (_jsx("ul", { className: "divide-y rounded-md border", children: list.map((app) => (_jsxs("li", { className: "flex flex-wrap items-center justify-between gap-3 px-3 py-2", children: [_jsx(Label, { htmlFor: `${id}-${app.module}`, className: "font-medium", children: app.label }), _jsx(Select, { id: `${id}-${app.module}`, label: app.label, className: "w-48", value: levelOf(app), options: [
40
+ { value: NONE, label: t('people.noAccess') },
41
+ ...app.levels.map((l) => ({ value: l.key, label: l.label })),
42
+ ], onChange: (v) => setLevel(app, v) })] }, app.module))) }, key)) : (_jsxs("details", { className: "rounded-md border", children: [_jsx("summary", { className: "cursor-pointer px-3 py-2 text-sm", children: t('people.technical') }), _jsx("ul", { className: "divide-y border-t", children: list.map((app) => (_jsxs("li", { className: "flex flex-wrap items-center justify-between gap-3 px-3 py-2", children: [_jsx(Label, { htmlFor: `${id}-${app.module}`, className: "font-medium", children: app.label }), _jsx(Select, { id: `${id}-${app.module}`, label: app.label, className: "w-48", value: levelOf(app), options: [
43
+ { value: NONE, label: t('people.noAccess') },
44
+ ...app.levels.map((l) => ({ value: l.key, label: l.label })),
45
+ ], onChange: (v) => setLevel(app, v) })] }, app.module))) })] }, key))), [...admins, ...others.filter((r) => !r.administrator)].length === 0 ? null : (_jsxs("fieldset", { className: "grid gap-2", children: [_jsx("legend", { className: "mb-1 text-sm font-medium", children: t('people.otherRoles') }), [...admins, ...others.filter((r) => !r.administrator)].map((r) => {
46
+ const locked = r.administrator && !catalogue.manager.administrator;
47
+ return (_jsxs("div", { className: "flex items-center gap-2 text-sm", children: [_jsx(Checkbox, { id: `${id}-role-${r.code}`, checked: has(r.code), disabled: locked, onCheckedChange: (c) => toggle(r.code, c === true) }), _jsx(Label, { htmlFor: `${id}-role-${r.code}`, children: r.label }), r.administrator ? (_jsx("span", { className: "text-xs text-muted-foreground", children: t('people.administratorHint') })) : null] }, r.code));
48
+ })] }))] }));
49
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Settings › Roles & permissions (RFC 0019 §3.3): every role with what it opens and who holds it; a role's page —
3
+ * simple first (per app, one access level), *Advanced* below (rights per model with code rights locked, feature
4
+ * permissions, record rules, field security) — saved only after a preview of who gains and who loses what. New
5
+ * roles from scratch or copied. Administrators only: the server refuses everyone else.
6
+ */
7
+ import { type PageProps } from '../../ui/index.js';
8
+ import { type ReactElement } from 'react';
9
+ export declare function RolesPage({ api }: PageProps): ReactElement;