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.
- package/dist/account_portal/.kosh/bindings.js +1 -1
- package/dist/account_portal/.kosh/schema.d.ts +156 -1
- package/dist/accounting/.kosh/bindings.js +1 -1
- package/dist/accounting/.kosh/schema.d.ts +156 -1
- package/dist/assistant/index.js +1 -1
- package/dist/contacts/.kosh/bindings.js +1 -1
- package/dist/contacts/.kosh/schema.d.ts +156 -1
- package/dist/integrations/index.js +12 -0
- package/dist/integrations/web/OAuthAppsPage.d.ts +9 -0
- package/dist/integrations/web/OAuthAppsPage.js +79 -0
- package/dist/integrations/web/index.d.ts +1 -0
- package/dist/integrations/web/index.js +3 -0
- package/dist/integrations/web/messages.js +58 -0
- package/dist/l10n_bd/.kosh/bindings.js +1 -1
- package/dist/l10n_bd/.kosh/schema.d.ts +156 -1
- package/dist/l10n_in/.kosh/bindings.js +1 -1
- package/dist/l10n_in/.kosh/schema.d.ts +156 -4
- package/dist/l10n_in/module.d.ts +0 -62
- package/dist/l10n_in/module.js +2 -4
- package/dist/messaging/web/index.js +1 -1
- package/dist/notify/web/index.js +1 -1
- package/dist/payment/.kosh/bindings.js +1 -1
- package/dist/payment/.kosh/schema.d.ts +157 -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 +156 -1
- package/dist/product/.kosh/bindings.js +1 -1
- package/dist/product/.kosh/schema.d.ts +156 -1
- package/dist/project/.kosh/bindings.js +1 -1
- package/dist/project/.kosh/schema.d.ts +156 -1
- package/dist/project_portal/.kosh/bindings.js +1 -1
- package/dist/project_portal/.kosh/schema.d.ts +156 -1
- package/dist/purchase/.kosh/bindings.js +1 -1
- package/dist/purchase/.kosh/schema.d.ts +156 -1
- package/dist/purchase_account/.kosh/bindings.js +1 -1
- package/dist/purchase_account/.kosh/schema.d.ts +156 -1
- package/dist/purchase_portal/.kosh/bindings.js +1 -1
- package/dist/purchase_portal/.kosh/schema.d.ts +156 -1
- package/dist/sale_account/.kosh/bindings.js +1 -1
- package/dist/sale_account/.kosh/schema.d.ts +156 -1
- package/dist/sale_portal/.kosh/bindings.js +1 -1
- package/dist/sale_portal/.kosh/schema.d.ts +156 -1
- package/dist/sale_project/.kosh/bindings.js +1 -1
- package/dist/sale_project/.kosh/schema.d.ts +156 -1
- package/dist/sales/.kosh/bindings.js +1 -1
- package/dist/sales/.kosh/schema.d.ts +156 -1
- package/dist/server/auth/base.js +98 -0
- package/dist/server/auth/oauth.d.ts +21 -0
- package/dist/server/auth/oauth.js +579 -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 +56 -0
- package/dist/server/auth/service.js +185 -3
- package/dist/server/identity.d.ts +4 -0
- package/dist/server/images.d.ts +12 -0
- package/dist/server/images.js +139 -0
- package/dist/server/index.d.ts +2 -1
- package/dist/server/index.js +1 -0
- package/dist/server/server.d.ts +4 -0
- package/dist/server/server.js +20 -4
- package/dist/settings/company.d.ts +34 -0
- package/dist/settings/company.js +189 -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 +507 -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 +105 -0
- package/dist/settings/web/ConnectedAppsPage.d.ts +7 -0
- package/dist/settings/web/ConnectedAppsPage.js +33 -0
- package/dist/settings/web/OverviewPage.d.ts +9 -0
- package/dist/settings/web/OverviewPage.js +77 -0
- package/dist/settings/web/PeoplePage.d.ts +9 -0
- package/dist/settings/web/PeoplePage.js +154 -0
- package/dist/settings/web/ProfilePage.d.ts +14 -0
- package/dist/settings/web/ProfilePage.js +196 -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 +10 -0
- package/dist/settings/web/index.js +31 -1
- package/dist/settings/web/messages.js +594 -0
- package/dist/ui/api.d.ts +8 -0
- package/dist/ui/app.d.ts +5 -0
- package/dist/ui/app.js +143 -33
- package/dist/ui/components/AppIcon.js +11 -1
- package/dist/ui/components/Avatar.d.ts +21 -0
- package/dist/ui/components/Avatar.js +71 -0
- 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 +76 -0
- package/dist/ui/i18n/messages.js +152 -0
- package/dist/ui/index.d.ts +3 -1
- package/dist/ui/index.js +3 -1
- package/dist/ui/lib/accent.d.ts +13 -0
- package/dist/ui/lib/accent.js +36 -0
- package/dist/ui/registry.d.ts +7 -1
- package/dist/ui/registry.js +10 -1
- package/dist/ui/search/ViewTabs.d.ts +14 -0
- package/dist/ui/search/ViewTabs.js +42 -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/Consent.d.ts +5 -0
- package/dist/ui/shell/Consent.js +35 -0
- package/dist/ui/shell/Home.js +85 -16
- package/dist/ui/shell/Sidebar.d.ts +27 -0
- package/dist/ui/shell/Sidebar.js +77 -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 +57 -25
- 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,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings › Security (RFC 0019 §3.5), administrators only: the installation's policy — two-factor sign-in for
|
|
3
|
+
* administrators only (the default) or for everyone, and how long a sign-in lasts — with who it would stop until
|
|
4
|
+
* they set two-factor up; and who is signed in where, with sign-out of any session. The audit log has its own route
|
|
5
|
+
* (`/api/v1/admin/audit`, `shutro-erp/permissions`). Everything is audited.
|
|
6
|
+
*
|
|
7
|
+
* - `GET /api/v1/settings/security` the policy; the people without two-factor sign-in
|
|
8
|
+
* - `PATCH /api/v1/settings/security` `{ twoFactor?: 'administrators' | 'everyone', sessionDays?: 1–90 | null }`
|
|
9
|
+
* - `GET /api/v1/settings/sessions` every active session (the person, device, address, last use)
|
|
10
|
+
* - `DELETE /api/v1/settings/sessions/:id` signs that session out
|
|
11
|
+
*/
|
|
12
|
+
import { audit, HttpProblem } from '../server/index.js';
|
|
13
|
+
const ref = (env, name) => env.model(name);
|
|
14
|
+
const rows = async (set, keys) => {
|
|
15
|
+
const s = await set;
|
|
16
|
+
return s.length === 0 ? [] : (await s.load(keys)).read(['id', ...keys]);
|
|
17
|
+
};
|
|
18
|
+
const iso = (v) => v === null || v === undefined ? null : new Date(v instanceof Date ? v : String(v)).toISOString();
|
|
19
|
+
export function registerSecurityRoutes(context) {
|
|
20
|
+
const { app, registry } = context;
|
|
21
|
+
const auth = context.auth;
|
|
22
|
+
if (auth === undefined)
|
|
23
|
+
return;
|
|
24
|
+
/** Portal users: not the company's people, the policy is not theirs. */
|
|
25
|
+
const external = new Set(registry.loaded.modules.flatMap((m) => m.externalGroups ?? []));
|
|
26
|
+
const guarded = (request, fn) => request.env(async (env) => {
|
|
27
|
+
if (!env.administrator)
|
|
28
|
+
throw new HttpProblem(403, 'access', 'Administrators only', 'Security settings need the Administrator role.');
|
|
29
|
+
return env.asSuperuser({ reason: 'security settings' }, async (su) => {
|
|
30
|
+
const out = await fn(env, su);
|
|
31
|
+
await su.flush();
|
|
32
|
+
return out;
|
|
33
|
+
});
|
|
34
|
+
}, { write: true });
|
|
35
|
+
/** People who sign in (not service accounts, not portal users) without two-factor sign-in. */
|
|
36
|
+
const withoutTwoFactor = async (su) => {
|
|
37
|
+
const people = await rows(ref(su, 'res.users').search({ where: { isService: { ne: true } }, order: ['name asc'] }), ['name', 'login']);
|
|
38
|
+
const ids = people.map((p) => p['id']);
|
|
39
|
+
if (ids.length === 0)
|
|
40
|
+
return [];
|
|
41
|
+
const on = new Set((await rows(ref(su, 'auth.credential').search({ where: { user: { in: ids }, totpEnabled: true } }), ['user'])).map((c) => c['user']));
|
|
42
|
+
const groups = await rows(ref(su, 'auth.user_group').search({ where: { user: { in: ids } } }), [
|
|
43
|
+
'user',
|
|
44
|
+
'group',
|
|
45
|
+
]);
|
|
46
|
+
const isExternal = (id) => {
|
|
47
|
+
const own = groups.filter((g) => g['user'] === id).map((g) => String(g['group']));
|
|
48
|
+
return own.length > 0 && own.every((g) => external.has(g));
|
|
49
|
+
};
|
|
50
|
+
return people
|
|
51
|
+
.filter((p) => !on.has(p['id']) && !isExternal(p['id']))
|
|
52
|
+
.map((p) => ({ id: p['id'], name: String(p['name']), login: String(p['login']) }));
|
|
53
|
+
};
|
|
54
|
+
const read = async (su) => ({
|
|
55
|
+
...(await auth.policy()),
|
|
56
|
+
withoutTwoFactor: await withoutTwoFactor(su),
|
|
57
|
+
});
|
|
58
|
+
app.get('/api/v1/settings/security', async (request) => guarded(request, (_env, su) => read(su)));
|
|
59
|
+
app.patch('/api/v1/settings/security', async (request) => {
|
|
60
|
+
const body = (request.body ?? {});
|
|
61
|
+
const values = {};
|
|
62
|
+
if (body['twoFactor'] !== undefined) {
|
|
63
|
+
if (body['twoFactor'] !== 'administrators' && body['twoFactor'] !== 'everyone')
|
|
64
|
+
throw new HttpProblem(422, 'invalid-policy', 'Invalid policy', "Two-factor sign-in is required of 'administrators' or 'everyone'.");
|
|
65
|
+
values['twoFactor'] = body['twoFactor'];
|
|
66
|
+
}
|
|
67
|
+
if (body['sessionDays'] !== undefined) {
|
|
68
|
+
const days = body['sessionDays'];
|
|
69
|
+
if (days !== null &&
|
|
70
|
+
!(Number.isInteger(days) && days >= 1 && days <= 90))
|
|
71
|
+
throw new HttpProblem(422, 'invalid-policy', 'Invalid policy', 'A sign-in lasts 1 to 90 days, or empty for the server’s setting.');
|
|
72
|
+
values['sessionDays'] = days;
|
|
73
|
+
}
|
|
74
|
+
await guarded(request, async (env, su) => {
|
|
75
|
+
if (Object.keys(values).length > 0) {
|
|
76
|
+
const current = await ref(su, 'auth.policy').search({ where: {}, limit: 1 });
|
|
77
|
+
if (current.length === 0)
|
|
78
|
+
await ref(su, 'auth.policy').create([values]);
|
|
79
|
+
else
|
|
80
|
+
await current.write(values);
|
|
81
|
+
await audit(env, {
|
|
82
|
+
kind: 'security.policy',
|
|
83
|
+
summary: `Security policy changed: ${Object.entries(values)
|
|
84
|
+
.map(([k, v]) => `${k} = ${v ?? 'default'}`)
|
|
85
|
+
.join(', ')}.`,
|
|
86
|
+
details: values,
|
|
87
|
+
ip: request.ip,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
// After the commit: the policy applies to every request from now on (this node; others within the cache time).
|
|
92
|
+
auth.forgetPolicy();
|
|
93
|
+
return guarded(request, (_env, su) => read(su));
|
|
94
|
+
});
|
|
95
|
+
app.get('/api/v1/settings/sessions', async (request) => {
|
|
96
|
+
const current = await auth.session(request);
|
|
97
|
+
return guarded(request, async (_env, su) => {
|
|
98
|
+
const sessions = await rows(ref(su, 'auth.session').search({
|
|
99
|
+
where: { revoked: false, expiresAt: { gt: new Date() } },
|
|
100
|
+
order: ['lastSeenAt desc', 'id desc'],
|
|
101
|
+
limit: 500,
|
|
102
|
+
}), ['user', 'ip', 'userAgent', 'lastSeenAt', 'createdAt', 'secondFactor', 'twoFactorVerified']);
|
|
103
|
+
const users = [...new Set(sessions.map((s) => s['user']))];
|
|
104
|
+
const people = users.length === 0
|
|
105
|
+
? []
|
|
106
|
+
: await rows(ref(su, 'res.users').search({ where: { id: { in: users } } }), [
|
|
107
|
+
'name',
|
|
108
|
+
'login',
|
|
109
|
+
]);
|
|
110
|
+
const person = new Map(people.map((p) => [p['id'], p]));
|
|
111
|
+
return {
|
|
112
|
+
sessions: sessions
|
|
113
|
+
// Sign-ins still waiting for their second factor are not in yet.
|
|
114
|
+
.filter((s) => s['twoFactorVerified'] === true)
|
|
115
|
+
.map((s) => ({
|
|
116
|
+
id: s['id'],
|
|
117
|
+
user: s['user'],
|
|
118
|
+
name: String(person.get(s['user'])?.['name'] ?? ''),
|
|
119
|
+
login: String(person.get(s['user'])?.['login'] ?? ''),
|
|
120
|
+
ip: typeof s['ip'] === 'string' ? s['ip'] : null,
|
|
121
|
+
userAgent: typeof s['userAgent'] === 'string' ? s['userAgent'] : null,
|
|
122
|
+
lastSeenAt: iso(s['lastSeenAt']),
|
|
123
|
+
createdAt: iso(s['createdAt']),
|
|
124
|
+
secondFactor: s['secondFactor'] === true,
|
|
125
|
+
current: s['id'] === current?.id,
|
|
126
|
+
})),
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
app.delete('/api/v1/settings/sessions/:id', async (request) => {
|
|
131
|
+
const id = Number(request.params.id);
|
|
132
|
+
if (!Number.isSafeInteger(id) || id <= 0)
|
|
133
|
+
throw new HttpProblem(400, 'invalid-id', 'Invalid id', 'The id is a positive integer.');
|
|
134
|
+
const current = await auth.session(request);
|
|
135
|
+
if (current?.id === id)
|
|
136
|
+
throw new HttpProblem(409, 'current-session', 'This is your session', 'Sign out from the user menu to end your own session.');
|
|
137
|
+
const user = await guarded(request, async (env, su) => {
|
|
138
|
+
const set = await ref(su, 'auth.session').search({ where: { id, revoked: false } });
|
|
139
|
+
const [row] = await rows(set, ['user']);
|
|
140
|
+
if (row === undefined)
|
|
141
|
+
throw new HttpProblem(404, 'missing-record', 'Not found', `There is no session ${id}.`);
|
|
142
|
+
await set.write({ revoked: true });
|
|
143
|
+
const [who] = await rows(ref(su, 'res.users').search({ where: { id: row['user'] } }), [
|
|
144
|
+
'login',
|
|
145
|
+
]);
|
|
146
|
+
await audit(env, {
|
|
147
|
+
kind: 'security.session_revoked',
|
|
148
|
+
summary: `Signed out a session of ${String(who?.['login'] ?? row['user'])}.`,
|
|
149
|
+
targetModel: 'res.users',
|
|
150
|
+
targetId: row['user'],
|
|
151
|
+
ip: request.ip,
|
|
152
|
+
});
|
|
153
|
+
return row['user'];
|
|
154
|
+
});
|
|
155
|
+
auth.forget(user);
|
|
156
|
+
return { revoked: 1 };
|
|
157
|
+
});
|
|
158
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings › Audit log (RFC 0019 §3.5): every sign-in, permission change, export and sensitive action, newest first,
|
|
3
|
+
* filtered by kind, person, period and words, with more on demand and the filtered events exported as CSV (the export
|
|
4
|
+
* is itself logged). Administrators, and holders of the "View the audit log" permission.
|
|
5
|
+
*/
|
|
6
|
+
import { type PageProps } from '../../ui/index.js';
|
|
7
|
+
import { type ReactElement } from 'react';
|
|
8
|
+
export declare function AuditPage({ api }: PageProps): ReactElement;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Settings › Audit log (RFC 0019 §3.5): every sign-in, permission change, export and sensitive action, newest first,
|
|
4
|
+
* filtered by kind, person, period and words, with more on demand and the filtered events exported as CSV (the export
|
|
5
|
+
* is itself logged). Administrators, and holders of the "View the audit log" permission.
|
|
6
|
+
*/
|
|
7
|
+
import { Alert, ApiError, Badge, Button, Input, Label, Select, useKosh, } from '../../ui/index.js';
|
|
8
|
+
import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query';
|
|
9
|
+
import { Download } from 'lucide-react';
|
|
10
|
+
import { useId, useState } from 'react';
|
|
11
|
+
import { useTranslation } from 'react-i18next';
|
|
12
|
+
const PAGE = 50;
|
|
13
|
+
/** The families of events, by their kind's prefix. */
|
|
14
|
+
const FAMILIES = ['auth.', 'security.', 'people.', 'settings.', 'data.', 'admin.'];
|
|
15
|
+
const message = (error) => (error instanceof ApiError ? error.message : String(error));
|
|
16
|
+
const tone = (kind) => kind.endsWith('_failed') || kind.endsWith('_revoked')
|
|
17
|
+
? 'warning'
|
|
18
|
+
: kind.startsWith('security.')
|
|
19
|
+
? 'info'
|
|
20
|
+
: 'neutral';
|
|
21
|
+
function saveFile(blob, filename) {
|
|
22
|
+
const url = URL.createObjectURL(blob);
|
|
23
|
+
const a = document.createElement('a');
|
|
24
|
+
a.href = url;
|
|
25
|
+
a.download = filename;
|
|
26
|
+
a.click();
|
|
27
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
28
|
+
}
|
|
29
|
+
export function AuditPage({ api }) {
|
|
30
|
+
const { t } = useTranslation('settings');
|
|
31
|
+
const { format } = useKosh();
|
|
32
|
+
const id = useId();
|
|
33
|
+
const [family, setFamily] = useState('all');
|
|
34
|
+
const [actor, setActor] = useState('all');
|
|
35
|
+
const [from, setFrom] = useState('');
|
|
36
|
+
const [to, setTo] = useState('');
|
|
37
|
+
const [words, setWords] = useState('');
|
|
38
|
+
const people = useQuery({
|
|
39
|
+
queryKey: ['settings', 'people', 'all'],
|
|
40
|
+
queryFn: () => api.call('GET', '/api/v1/settings/people'),
|
|
41
|
+
});
|
|
42
|
+
const filters = new URLSearchParams();
|
|
43
|
+
if (family !== 'all')
|
|
44
|
+
filters.set('kind', family);
|
|
45
|
+
if (actor !== 'all')
|
|
46
|
+
filters.set('actor', actor);
|
|
47
|
+
if (from !== '')
|
|
48
|
+
filters.set('from', from);
|
|
49
|
+
if (to !== '')
|
|
50
|
+
filters.set('to', to);
|
|
51
|
+
if (words.trim() !== '')
|
|
52
|
+
filters.set('q', words.trim());
|
|
53
|
+
const query = filters.toString();
|
|
54
|
+
const events = useInfiniteQuery({
|
|
55
|
+
queryKey: ['settings', 'audit', query],
|
|
56
|
+
initialPageParam: 0,
|
|
57
|
+
queryFn: ({ pageParam }) => api.call('GET', `/api/v1/admin/audit?${query}${query === '' ? '' : '&'}limit=${PAGE}${pageParam === 0 ? '' : `&before=${pageParam}`}`),
|
|
58
|
+
getNextPageParam: (last) => last.events.length < PAGE ? undefined : last.events[last.events.length - 1]?.id,
|
|
59
|
+
});
|
|
60
|
+
const exported = useMutation({
|
|
61
|
+
mutationFn: async () => {
|
|
62
|
+
const { blob, filename } = await api.download(`/api/v1/admin/audit?${query}${query === '' ? '' : '&'}limit=1000&format=csv`, undefined, 'GET');
|
|
63
|
+
saveFile(blob, filename);
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
const list = events.data?.pages.flatMap((p) => p.events) ?? [];
|
|
67
|
+
return (_jsxs("div", { className: "mx-auto grid max-w-6xl gap-5", children: [_jsxs("header", { className: "flex flex-wrap items-start justify-between gap-3", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-xl font-semibold", children: t('audit.title') }), _jsx("p", { className: "text-sm text-muted-foreground", children: t('audit.intro') })] }), _jsxs(Button, { busy: exported.isPending, onClick: () => exported.mutate(), children: [_jsx(Download, { className: "size-4", "aria-hidden": true }), t('audit.export')] })] }), _jsxs("div", { className: "grid gap-3 rounded-lg border bg-surface p-4 sm:grid-cols-2 lg:grid-cols-5", children: [_jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { htmlFor: `${id}-kind`, children: t('audit.kind') }), _jsx(Select, { id: `${id}-kind`, label: t('audit.kind'), value: family, options: [
|
|
68
|
+
{ value: 'all', label: t('audit.all') },
|
|
69
|
+
...FAMILIES.map((f) => ({ value: f, label: t(`audit.families.${f.slice(0, -1)}`) })),
|
|
70
|
+
], onChange: setFamily })] }), _jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { htmlFor: `${id}-actor`, children: t('audit.person') }), _jsx(Select, { id: `${id}-actor`, label: t('audit.person'), value: actor, options: [
|
|
71
|
+
{ value: 'all', label: t('audit.everyone') },
|
|
72
|
+
...(people.data?.people ?? []).map((p) => ({ value: String(p.id), label: p.name })),
|
|
73
|
+
], onChange: setActor })] }), _jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { htmlFor: `${id}-from`, children: t('audit.from') }), _jsx(Input, { id: `${id}-from`, type: "date", value: from, onChange: (e) => setFrom(e.target.value) })] }), _jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { htmlFor: `${id}-to`, children: t('audit.to') }), _jsx(Input, { id: `${id}-to`, type: "date", value: to, onChange: (e) => setTo(e.target.value) })] }), _jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { htmlFor: `${id}-q`, children: t('audit.words') }), _jsx(Input, { id: `${id}-q`, type: "search", value: words, onChange: (e) => setWords(e.target.value) })] })] }), events.error === null ? null : _jsx(Alert, { tone: "danger", children: message(events.error) }), exported.error === null ? null : _jsx(Alert, { tone: "danger", children: message(exported.error) }), _jsx("div", { className: "overflow-x-auto rounded-lg border bg-surface", children: _jsxs("table", { className: "w-full text-sm", children: [_jsx("caption", { className: "sr-only", children: t('audit.title') }), _jsx("thead", { className: "border-b bg-muted/40 text-start text-xs text-muted-foreground", children: _jsxs("tr", { children: [_jsx("th", { scope: "col", className: "px-3 py-2 text-start font-medium", children: t('audit.when') }), _jsx("th", { scope: "col", className: "px-3 py-2 text-start font-medium", children: t('audit.person') }), _jsx("th", { scope: "col", className: "px-3 py-2 text-start font-medium", children: t('audit.what') }), _jsx("th", { scope: "col", className: "px-3 py-2 text-start font-medium", children: t('audit.ip') })] }) }), _jsxs("tbody", { className: "divide-y", children: [list.map((e) => (_jsxs("tr", { className: "align-top", children: [_jsx("td", { className: "whitespace-nowrap px-3 py-2 text-muted-foreground", children: format.datetime(e.createdAt) }), _jsx("td", { className: "whitespace-nowrap px-3 py-2", children: e.actorName ?? t('audit.system') }), _jsxs("td", { className: "px-3 py-2", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx(Badge, { tone: tone(e.kind), children: _jsx("span", { className: "font-mono text-xs", children: e.kind }) }), _jsx("span", { children: e.summary })] }), e.targetModel === null ? null : (_jsxs("p", { className: "mt-0.5 font-mono text-xs text-muted-foreground", children: [e.targetModel, e.targetId === null ? '' : ` #${e.targetId}`] }))] }), _jsx("td", { className: "whitespace-nowrap px-3 py-2 font-mono text-xs text-muted-foreground", children: e.ip ?? '' })] }, e.id))), events.data !== undefined && list.length === 0 ? (_jsx("tr", { children: _jsx("td", { colSpan: 4, className: "px-3 py-6 text-center text-muted-foreground", children: t('audit.none') }) })) : null] })] }) }), events.hasNextPage ? (_jsx("div", { className: "flex justify-center", children: _jsx(Button, { busy: events.isFetchingNextPage, onClick: () => void events.fetchNextPage(), children: t('audit.more') }) })) : null] }));
|
|
74
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings › Companies (RFC 0019 §3.4): every company of the installation with its currency and the people who work
|
|
3
|
+
* in it; switching to one; and, for administrators, a new company — its apps' defaults (journals, sequences, taxes…)
|
|
4
|
+
* are installed with it, and the administrators can switch to it at once.
|
|
5
|
+
*/
|
|
6
|
+
import { type PageProps } from '../../ui/index.js';
|
|
7
|
+
import { type ReactElement } from 'react';
|
|
8
|
+
export declare function CompaniesPage({ api }: PageProps): ReactElement;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Settings › Companies (RFC 0019 §3.4): every company of the installation with its currency and the people who work
|
|
4
|
+
* in it; switching to one; and, for administrators, a new company — its apps' defaults (journals, sequences, taxes…)
|
|
5
|
+
* are installed with it, and the administrators can switch to it at once.
|
|
6
|
+
*/
|
|
7
|
+
import { Alert, ApiError, Avatar, Badge, Button, Dialog, Input, imageUrl, Label, Select, useKosh, } from '../../ui/index.js';
|
|
8
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
9
|
+
import { Building2, Plus } 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
|
+
function NewCompanyDialog({ api, currencies, open, onOpenChange, }) {
|
|
14
|
+
const { t } = useTranslation('settings');
|
|
15
|
+
const id = useId();
|
|
16
|
+
const queryClient = useQueryClient();
|
|
17
|
+
const [name, setName] = useState('');
|
|
18
|
+
const [currency, setCurrency] = useState(null);
|
|
19
|
+
const create = useMutation({
|
|
20
|
+
mutationFn: () => api.call('POST', '/api/v1/settings/companies', {
|
|
21
|
+
name,
|
|
22
|
+
currency: Number(currency),
|
|
23
|
+
}),
|
|
24
|
+
onSuccess: () => {
|
|
25
|
+
void queryClient.invalidateQueries({ queryKey: ['settings', 'companies'] });
|
|
26
|
+
// The switcher lists the user's companies from their details.
|
|
27
|
+
void queryClient.invalidateQueries({ queryKey: ['me'] });
|
|
28
|
+
onOpenChange(false);
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!open) {
|
|
33
|
+
setName('');
|
|
34
|
+
setCurrency(null);
|
|
35
|
+
create.reset();
|
|
36
|
+
}
|
|
37
|
+
}, [open, create.reset]);
|
|
38
|
+
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, title: t('companies.newTitle'), description: t('companies.newHint'), footer: _jsxs(_Fragment, { children: [_jsx(Button, { onClick: () => onOpenChange(false), children: t('profile.cancel') }), _jsx(Button, { variant: "primary", busy: create.isPending, disabled: name.trim() === '' || currency === null, onClick: () => create.mutate(), children: t('companies.create') })] }), children: _jsxs("div", { className: "grid gap-4", children: [_jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { htmlFor: `${id}-name`, children: t('company.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}-currency`, children: t('companies.currency') }), _jsx(Select, { id: `${id}-currency`, label: t('companies.currency'), value: currency, placeholder: t('companies.chooseCurrency'), options: currencies.map((c) => ({ value: String(c.id), label: c.name })), onChange: setCurrency })] }), create.error === null ? null : _jsx(Alert, { tone: "danger", children: message(create.error) })] }) }));
|
|
39
|
+
}
|
|
40
|
+
export function CompaniesPage({ api }) {
|
|
41
|
+
const { t } = useTranslation('settings');
|
|
42
|
+
const { switchCompany } = useKosh();
|
|
43
|
+
const [creating, setCreating] = useState(false);
|
|
44
|
+
const list = useQuery({
|
|
45
|
+
queryKey: ['settings', 'companies'],
|
|
46
|
+
queryFn: () => api.call('GET', '/api/v1/settings/companies'),
|
|
47
|
+
});
|
|
48
|
+
if (list.error !== null)
|
|
49
|
+
return _jsx(Alert, { tone: "danger", children: message(list.error) });
|
|
50
|
+
const data = list.data;
|
|
51
|
+
return (_jsxs("div", { className: "mx-auto grid max-w-3xl gap-6", children: [_jsxs("header", { className: "flex flex-wrap items-start justify-between gap-3", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-xl font-semibold", children: t('companies.title') }), _jsx("p", { className: "text-sm text-muted-foreground", children: t('companies.intro') })] }), data?.canCreate === true ? (_jsxs(Button, { variant: "primary", onClick: () => setCreating(true), children: [_jsx(Plus, { className: "size-4", "aria-hidden": true }), t('companies.new')] })) : null] }), data === undefined ? (_jsx("p", { className: "text-sm text-muted-foreground", children: "\u2026" })) : (_jsx("ul", { className: "divide-y rounded-lg border bg-surface", children: data.companies.map((c) => (_jsxs("li", { className: "flex flex-wrap items-center gap-3 px-4 py-3", children: [c.logo === null ? (_jsx("span", { "aria-hidden": true, className: "grid size-9 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground", children: _jsx(Building2, { className: "size-4" }) })) : (_jsx(Avatar, { name: c.name, src: imageUrl('company', c.id, c.logo), shape: "square", className: "size-9 border" })), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("p", { className: "flex items-center gap-2 font-medium", children: [_jsx("span", { className: "truncate", children: c.name }), c.current ? _jsx(Badge, { tone: "info", children: t('companies.current') }) : null] }), _jsxs("p", { className: "text-sm text-muted-foreground", children: [c.currency, " \u00B7 ", t('companies.people', { count: c.people })] })] }), c.current || !c.member || switchCompany === undefined ? null : (_jsx(Button, { size: "sm", "aria-label": t('companies.switchTo', { name: c.name }), onClick: () => switchCompany(c.id), children: t('companies.switch') }))] }, c.id))) })), data === undefined ? null : (_jsx(NewCompanyDialog, { api: api, currencies: data.currencies, open: creating, onOpenChange: setCreating }))] }));
|
|
52
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings › Company (RFC 0018, RFC 0019 §3.4): the current company's name, number grouping and brand colour — the
|
|
3
|
+
* back-office's accent for everyone in the company — and its details: address, contacts and whatever the installed
|
|
4
|
+
* apps add (tax ids, fiscal year…), as the server offers them. The preview shows the colour as buttons, links and
|
|
5
|
+
* selections take it, in light and dark; the themes keep text readable whatever colour is chosen.
|
|
6
|
+
*/
|
|
7
|
+
import { type PageProps } from '../../ui/index.js';
|
|
8
|
+
import { type ReactElement } from 'react';
|
|
9
|
+
export declare function CompanyPage({ api }: PageProps): ReactElement;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Settings › Company (RFC 0018, RFC 0019 §3.4): the current company's name, number grouping and brand colour — the
|
|
4
|
+
* back-office's accent for everyone in the company — and its details: address, contacts and whatever the installed
|
|
5
|
+
* apps add (tax ids, fiscal year…), as the server offers them. The preview shows the colour as buttons, links and
|
|
6
|
+
* selections take it, in light and dark; the themes keep text readable whatever colour is chosen.
|
|
7
|
+
*/
|
|
8
|
+
import { Alert, ApiError, accentVariables, Badge, Button, Checkbox, ImagePicker, Input, imageUrl, Label, Select, Textarea, } from '../../ui/index.js';
|
|
9
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
10
|
+
import { Check } from 'lucide-react';
|
|
11
|
+
import { useId, useState } from 'react';
|
|
12
|
+
import { useTranslation } from 'react-i18next';
|
|
13
|
+
/** One detail, by its kind: text, a long text, a number, yes / no, or a choice. */
|
|
14
|
+
function DetailField({ detail, value, onChange, }) {
|
|
15
|
+
const id = `company-${detail.key}`;
|
|
16
|
+
const { t } = useTranslation('settings');
|
|
17
|
+
if (detail.kind === 'boolean')
|
|
18
|
+
return (_jsxs("div", { className: "flex items-center gap-2 sm:col-span-2", children: [_jsx(Checkbox, { id: id, checked: value === true, disabled: detail.readonly, onCheckedChange: (checked) => onChange(checked === true) }), _jsx(Label, { htmlFor: id, children: detail.label })] }));
|
|
19
|
+
return (_jsxs("div", { className: `grid gap-1.5 ${detail.kind === 'text' ? 'sm:col-span-2' : ''}`, children: [_jsx(Label, { htmlFor: id, children: detail.label }), detail.kind === 'selection' ? (_jsx(Select, { id: id, label: detail.label, value: typeof value === 'string' ? value : 'none', options: [{ value: 'none', label: t('company.notSet') }, ...(detail.options ?? [])], disabled: detail.readonly, onChange: (v) => onChange(v === 'none' ? null : v) })) : detail.kind === 'text' ? (_jsx(Textarea, { id: id, rows: 3, value: typeof value === 'string' ? value : '', readOnly: detail.readonly, onChange: (e) => onChange(e.target.value) })) : (_jsx(Input, { id: id, type: detail.kind === 'integer' ? 'number' : 'text', value: value === null ? '' : String(value), readOnly: detail.readonly, onChange: (e) => onChange(detail.kind === 'integer'
|
|
20
|
+
? e.target.value === ''
|
|
21
|
+
? null
|
|
22
|
+
: Number.parseInt(e.target.value, 10)
|
|
23
|
+
: e.target.value) }))] }));
|
|
24
|
+
}
|
|
25
|
+
/** Suggested colours: indigo (the default), and a few that read well as an accent in both themes. */
|
|
26
|
+
const SWATCHES = [
|
|
27
|
+
'#4f46e5',
|
|
28
|
+
'#2563eb',
|
|
29
|
+
'#0d9488',
|
|
30
|
+
'#16a34a',
|
|
31
|
+
'#ca8a04',
|
|
32
|
+
'#ea580c',
|
|
33
|
+
'#dc2626',
|
|
34
|
+
'#db2777',
|
|
35
|
+
'#7c3aed',
|
|
36
|
+
'#475569',
|
|
37
|
+
];
|
|
38
|
+
const HEX = /^#[0-9a-fA-F]{6}$/;
|
|
39
|
+
function Preview({ color, dark, }) {
|
|
40
|
+
const { t } = useTranslation('settings');
|
|
41
|
+
return (_jsxs("div", { className: `${dark ? 'dark' : 'light'} grid gap-3 rounded-lg border bg-background p-4 text-foreground`, style: accentVariables(color), children: [_jsx("p", { className: "text-xs font-medium text-muted-foreground", children: dark ? t('company.dark') : t('company.light') }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx(Button, { size: "sm", variant: "primary", tabIndex: -1, children: t('company.sampleAction') }), _jsx(Badge, { tone: "info", children: t('company.sampleBadge') }), _jsx("span", { className: "text-sm text-primary underline underline-offset-4", children: t('company.sampleLink') })] }), _jsx("div", { className: "rounded-md bg-accent px-3 py-2 text-sm text-accent-foreground", children: t('company.sampleSelected') })] }));
|
|
42
|
+
}
|
|
43
|
+
export function CompanyPage({ api }) {
|
|
44
|
+
const company = useQuery({
|
|
45
|
+
queryKey: ['settings', 'company'],
|
|
46
|
+
queryFn: () => api.call('GET', '/api/v1/settings/company'),
|
|
47
|
+
});
|
|
48
|
+
if (company.error !== null)
|
|
49
|
+
return (_jsx(Alert, { tone: "danger", children: company.error instanceof ApiError ? company.error.message : String(company.error) }));
|
|
50
|
+
// The form starts from the loaded company: nothing can be chosen before it is there and then overwritten.
|
|
51
|
+
return company.data === undefined ? (_jsx("p", { className: "text-sm text-muted-foreground", children: "\u2026" })) : (_jsx(CompanyForm, { api: api, company: company.data }));
|
|
52
|
+
}
|
|
53
|
+
function CompanyForm({ api, company }) {
|
|
54
|
+
const { t } = useTranslation('settings');
|
|
55
|
+
const id = useId();
|
|
56
|
+
const queryClient = useQueryClient();
|
|
57
|
+
const [name, setName] = useState(company.name);
|
|
58
|
+
const [grouping, setGrouping] = useState(company.numberGrouping);
|
|
59
|
+
const [color, setColor] = useState(company.accentColor);
|
|
60
|
+
const [typed, setTyped] = useState(company.accentColor ?? '');
|
|
61
|
+
const me = useQuery({
|
|
62
|
+
queryKey: ['me'],
|
|
63
|
+
queryFn: () => api.call('GET', '/api/v1/auth/me'),
|
|
64
|
+
});
|
|
65
|
+
const logo = useMutation({
|
|
66
|
+
mutationFn: (image) => image === null
|
|
67
|
+
? api.call('DELETE', `/api/v1/images/company/${company.id}`)
|
|
68
|
+
: api.call('PUT', `/api/v1/images/company/${company.id}`, { image }),
|
|
69
|
+
// The sidebar and the switcher show the logo from the user's details.
|
|
70
|
+
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['me'] }),
|
|
71
|
+
});
|
|
72
|
+
// Only the details changed are sent.
|
|
73
|
+
const [details, setDetails] = useState({});
|
|
74
|
+
const save = useMutation({
|
|
75
|
+
mutationFn: () => api.call('PATCH', '/api/v1/settings/company', {
|
|
76
|
+
name,
|
|
77
|
+
numberGrouping: grouping,
|
|
78
|
+
accentColor: color,
|
|
79
|
+
...(Object.keys(details).length === 0 ? {} : { details }),
|
|
80
|
+
}),
|
|
81
|
+
onSuccess: () => {
|
|
82
|
+
setDetails({});
|
|
83
|
+
void queryClient.invalidateQueries({ queryKey: ['settings', 'company'] });
|
|
84
|
+
// The shell takes the accent and the grouping from the user's details.
|
|
85
|
+
void queryClient.invalidateQueries({ queryKey: ['me'] });
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
const pick = (next) => {
|
|
89
|
+
setColor(next);
|
|
90
|
+
setTyped(next ?? '');
|
|
91
|
+
};
|
|
92
|
+
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('company.title') }), _jsx("p", { className: "text-sm text-muted-foreground", children: t('company.intro') })] }), _jsxs("form", { className: "grid gap-6", onSubmit: (e) => {
|
|
93
|
+
e.preventDefault();
|
|
94
|
+
save.mutate();
|
|
95
|
+
}, children: [_jsxs("section", { className: "grid gap-4 rounded-lg border bg-surface p-5", children: [_jsx("h2", { className: "font-semibold", children: t('company.identity') }), _jsx(ImagePicker, { name: name, src: imageUrl('company', company.id, me.data?.logos?.[String(company.id)]), fit: "logo", busy: logo.isPending, onChoose: (image) => logo.mutate(image), onRemove: () => logo.mutate(null) }), logo.error === null ? null : (_jsx(Alert, { tone: "danger", children: logo.error instanceof ApiError ? logo.error.message : String(logo.error) })), _jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { htmlFor: `${id}-name`, children: t('company.name') }), _jsx(Input, { id: `${id}-name`, value: name, required: true, onChange: (e) => setName(e.target.value) })] }), _jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { htmlFor: `${id}-grouping`, children: t('company.grouping') }), _jsx(Select, { id: `${id}-grouping`, label: t('company.grouping'), value: grouping, options: [
|
|
96
|
+
{ value: 'international', label: t('company.international') },
|
|
97
|
+
{ value: 'lakh', label: t('company.lakh') },
|
|
98
|
+
], onChange: (v) => setGrouping(v === 'lakh' ? 'lakh' : 'international') })] })] }), company.details.length === 0 ? null : (_jsxs("section", { className: "grid gap-4 rounded-lg border bg-surface p-5", children: [_jsxs("div", { children: [_jsx("h2", { className: "font-semibold", children: t('company.details') }), _jsx("p", { className: "text-sm text-muted-foreground", children: t('company.detailsHint', { currency: company.currency }) })] }), _jsx("div", { className: "grid gap-4 sm:grid-cols-2", children: company.details.map((d) => (_jsx(DetailField, { detail: d, value: d.key in details ? details[d.key] : d.value, onChange: (v) => setDetails((all) => ({ ...all, [d.key]: v })) }, d.key))) })] })), _jsxs("section", { className: "grid gap-4 rounded-lg border bg-surface p-5", children: [_jsxs("div", { children: [_jsx("h2", { className: "font-semibold", children: t('company.brand') }), _jsx("p", { className: "text-sm text-muted-foreground", children: t('company.brandHint') })] }), _jsxs("fieldset", { className: "grid gap-3", children: [_jsx("legend", { className: "sr-only", children: t('company.brand') }), _jsx("div", { className: "flex flex-wrap gap-2", children: SWATCHES.map((swatch) => (_jsx("button", { type: "button", "aria-label": swatch, "aria-pressed": color === swatch, onClick: () => pick(swatch), className: "grid size-8 place-items-center rounded-full border-2 border-surface ring-1 ring-border", style: { background: swatch }, children: color === swatch ? _jsx(Check, { className: "size-4 text-white", "aria-hidden": true }) : null }, swatch))) }), _jsxs("div", { className: "flex flex-wrap items-end gap-3", children: [_jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { htmlFor: `${id}-hex`, children: t('company.hex') }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("input", { type: "color", "aria-label": t('company.picker'), value: color ?? '#4f46e5', onChange: (e) => pick(e.target.value), className: "size-9 cursor-pointer rounded-md border bg-surface p-1" }), _jsx(Input, { id: `${id}-hex`, className: "w-32 font-mono", value: typed, placeholder: "#4f46e5", "aria-invalid": typed !== '' && !HEX.test(typed) ? true : undefined, onChange: (e) => {
|
|
99
|
+
setTyped(e.target.value);
|
|
100
|
+
if (HEX.test(e.target.value))
|
|
101
|
+
setColor(e.target.value.toLowerCase());
|
|
102
|
+
if (e.target.value === '')
|
|
103
|
+
setColor(null);
|
|
104
|
+
} })] })] }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => pick(null), children: t('company.default') })] })] }), _jsxs("div", { className: "grid gap-3 sm:grid-cols-2", children: [_jsx(Preview, { color: color, dark: false }), _jsx(Preview, { color: color, dark: true })] })] }), save.error === null ? null : (_jsx(Alert, { tone: "danger", children: save.error instanceof ApiError ? save.error.message : String(save.error) })), save.isSuccess ? _jsx(Alert, { tone: "info", children: t('company.saved') }) : null, _jsx("div", { className: "flex justify-end", children: _jsx(Button, { type: "submit", variant: "primary", busy: save.isPending, children: t('company.save') }) })] })] }));
|
|
105
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connected apps (RFC 0017 §5.2), from the account menu: the apps you allowed to act for you through OAuth, what each
|
|
3
|
+
* may do in words, when it last did, and *Remove access* — which ends every token the app holds for you at once.
|
|
4
|
+
*/
|
|
5
|
+
import { type PageProps } from '../../ui/index.js';
|
|
6
|
+
import type { ReactElement } from 'react';
|
|
7
|
+
export declare function ConnectedAppsPage({ api }: PageProps): ReactElement;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Connected apps (RFC 0017 §5.2), from the account menu: the apps you allowed to act for you through OAuth, what each
|
|
4
|
+
* may do in words, when it last did, and *Remove access* — which ends every token the app holds for you at once.
|
|
5
|
+
*/
|
|
6
|
+
import { Alert, ApiError, Button, useKosh } from '../../ui/index.js';
|
|
7
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
8
|
+
import { ExternalLink, Plug } from 'lucide-react';
|
|
9
|
+
import { useTranslation } from 'react-i18next';
|
|
10
|
+
export function ConnectedAppsPage({ api }) {
|
|
11
|
+
const { t } = useTranslation('settings');
|
|
12
|
+
const { format } = useKosh();
|
|
13
|
+
const queryClient = useQueryClient();
|
|
14
|
+
const grants = useQuery({
|
|
15
|
+
queryKey: ['oauth', 'grants'],
|
|
16
|
+
queryFn: () => api.call('GET', '/api/v1/auth/oauth/grants'),
|
|
17
|
+
});
|
|
18
|
+
const revoke = useMutation({
|
|
19
|
+
mutationFn: (id) => api.call('POST', `/api/v1/auth/oauth/grants/${id}/revoke`),
|
|
20
|
+
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['oauth', 'grants'] }),
|
|
21
|
+
});
|
|
22
|
+
const error = grants.error ?? revoke.error;
|
|
23
|
+
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('connected.title') }), _jsx("p", { className: "text-sm text-muted-foreground", children: t('connected.intro') })] }), error === null ? null : (_jsx(Alert, { tone: "danger", children: error instanceof ApiError ? error.message : String(error) })), grants.data === undefined ? (_jsx("p", { className: "text-sm text-muted-foreground", children: "\u2026" })) : grants.data.grants.length === 0 ? (_jsx("p", { className: "rounded-lg border bg-surface p-6 text-center text-sm text-muted-foreground", children: t('connected.none') })) : (_jsx("ul", { className: "grid gap-3", children: grants.data.grants.map((g) => (_jsxs("li", { className: "flex flex-wrap items-start gap-3 rounded-lg border bg-surface p-4", children: [_jsx("span", { "aria-hidden": true, className: "grid size-9 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground", children: _jsx(Plug, { className: "size-4" }) }), _jsxs("div", { className: "grid min-w-0 flex-1 gap-1", children: [_jsxs("p", { className: "flex items-center gap-2 font-medium", children: [g.app, g.homepage === null ? null : (_jsx("a", { href: g.homepage, target: "_blank", rel: "noreferrer noopener", "aria-label": t('connected.homepage', { app: g.app }), className: "text-muted-foreground hover:text-foreground", children: _jsx(ExternalLink, { className: "size-3.5", "aria-hidden": true }) }))] }), _jsx("ul", { className: "flex flex-wrap gap-x-3 text-sm text-muted-foreground", children: g.scopes.map((s) => (_jsx("li", { children: s.label }, s.scope))) }), _jsx("p", { className: "text-xs text-muted-foreground", children: [
|
|
24
|
+
g.createdAt === null
|
|
25
|
+
? null
|
|
26
|
+
: t('connected.since', { when: format.date(g.createdAt.slice(0, 10)) }),
|
|
27
|
+
g.lastUsedAt === null
|
|
28
|
+
? t('connected.neverUsed')
|
|
29
|
+
: t('connected.lastUsed', { when: format.datetime(g.lastUsedAt) }),
|
|
30
|
+
]
|
|
31
|
+
.filter((x) => x !== null)
|
|
32
|
+
.join(' · ') })] }), _jsx(Button, { size: "sm", variant: "danger", busy: revoke.isPending && revoke.variables === g.id, onClick: () => revoke.mutate(g.id), "aria-label": t('connected.revokeApp', { app: g.app }), children: t('connected.revoke') })] }, g.id))) }))] }));
|
|
33
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings' home (RFC 0019 §3.1): every section the user may open, in its group (Workspace, People, Security, Apps —
|
|
3
|
+
* the server's menus decide which), each with what it is for; and a search over their names, descriptions and
|
|
4
|
+
* keywords ("lakh" finds Company, "two-factor" your profile). Sections of other modules join their group with their
|
|
5
|
+
* menu; their description and keywords come from `settings:overview.<action>` messages when the module adds them.
|
|
6
|
+
*/
|
|
7
|
+
import { type PageProps } from '../../ui/index.js';
|
|
8
|
+
import { type ReactElement } from 'react';
|
|
9
|
+
export declare function OverviewPage({ api }: PageProps): ReactElement;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Settings' home (RFC 0019 §3.1): every section the user may open, in its group (Workspace, People, Security, Apps —
|
|
4
|
+
* the server's menus decide which), each with what it is for; and a search over their names, descriptions and
|
|
5
|
+
* keywords ("lakh" finds Company, "two-factor" your profile). Sections of other modules join their group with their
|
|
6
|
+
* menu; their description and keywords come from `settings:overview.<action>` messages when the module adds them.
|
|
7
|
+
*/
|
|
8
|
+
import { AppIcon } from '../../ui/index.js';
|
|
9
|
+
import { useQuery } from '@tanstack/react-query';
|
|
10
|
+
import { Link } from '@tanstack/react-router';
|
|
11
|
+
import { Search, UserRound } from 'lucide-react';
|
|
12
|
+
import { useId, useState } from 'react';
|
|
13
|
+
import { useTranslation } from 'react-i18next';
|
|
14
|
+
const normalise = (s) => s.toLocaleLowerCase().normalize('NFKD');
|
|
15
|
+
/** Each section's icon (AppIcon names); a module's own section shows the Settings gear. */
|
|
16
|
+
const SECTION_ICONS = {
|
|
17
|
+
'settings.action_company': 'company',
|
|
18
|
+
'settings.action_companies': 'companies',
|
|
19
|
+
'settings.action_people': 'users',
|
|
20
|
+
'settings.action_roles': 'shield',
|
|
21
|
+
'settings.action_security': 'security',
|
|
22
|
+
'settings.action_audit': 'audit',
|
|
23
|
+
'settings.action_apps': 'apps',
|
|
24
|
+
'assistant.action_settings': 'assistant',
|
|
25
|
+
};
|
|
26
|
+
export function OverviewPage({ api }) {
|
|
27
|
+
const { t } = useTranslation('settings');
|
|
28
|
+
const id = useId();
|
|
29
|
+
const [query, setQuery] = useState('');
|
|
30
|
+
const menus = useQuery({ queryKey: ['menus', 'settings'], queryFn: () => api.menus('settings') });
|
|
31
|
+
const about = (action) => ({
|
|
32
|
+
description: t(`overview.about.${action}.description`, { defaultValue: '' }),
|
|
33
|
+
keywords: t(`overview.about.${action}.keywords`, { defaultValue: '' }),
|
|
34
|
+
});
|
|
35
|
+
const entry = (n) => n.action === undefined || n.action === 'settings.action_overview'
|
|
36
|
+
? null
|
|
37
|
+
: { label: n.label, action: n.action, ...about(n.action) };
|
|
38
|
+
// The groups (a menu with children), and loose sections under "Other".
|
|
39
|
+
const roots = (menus.data ?? []).flatMap((root) => root.children);
|
|
40
|
+
const groups = [];
|
|
41
|
+
const loose = [];
|
|
42
|
+
for (const node of roots) {
|
|
43
|
+
if (node.children.length > 0)
|
|
44
|
+
groups.push({
|
|
45
|
+
id: node.id,
|
|
46
|
+
label: node.label,
|
|
47
|
+
entries: node.children.map(entry).filter((e) => e !== null),
|
|
48
|
+
});
|
|
49
|
+
else {
|
|
50
|
+
const e = entry(node);
|
|
51
|
+
if (e !== null)
|
|
52
|
+
loose.push(e);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (loose.length > 0)
|
|
56
|
+
groups.push({ id: 'other', label: t('overview.other'), entries: loose });
|
|
57
|
+
// Your profile is everyone's, outside the app's menus.
|
|
58
|
+
const profile = {
|
|
59
|
+
label: t('profile.title'),
|
|
60
|
+
action: '',
|
|
61
|
+
description: t('overview.profile'),
|
|
62
|
+
keywords: t('overview.profileKeywords'),
|
|
63
|
+
};
|
|
64
|
+
const q = normalise(query.trim());
|
|
65
|
+
const matches = (e) => q === '' ||
|
|
66
|
+
q
|
|
67
|
+
.split(/\s+/)
|
|
68
|
+
.every((word) => normalise(`${e.label} ${e.description} ${e.keywords}`).includes(word));
|
|
69
|
+
const shown = groups
|
|
70
|
+
.map((g) => ({ ...g, entries: g.entries.filter(matches) }))
|
|
71
|
+
.filter((g) => g.entries.length > 0);
|
|
72
|
+
const card = (e, to) => _jsx("li", { children: to }, e.action || 'profile');
|
|
73
|
+
const showProfile = matches(profile);
|
|
74
|
+
return (_jsxs("div", { className: "mx-auto grid max-w-4xl gap-6", children: [_jsxs("header", { className: "grid gap-3", children: [_jsx("h1", { className: "text-2xl font-semibold", children: t('overview.title') }), _jsxs("div", { className: "relative", 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('overview.search') }), _jsx("input", { id: `${id}-q`, type: "search",
|
|
75
|
+
// biome-ignore lint/a11y/noAutofocus: the page is a search first; it opens on purpose
|
|
76
|
+
autoFocus: true, value: query, placeholder: t('overview.search'), onChange: (e) => setQuery(e.target.value), className: "h-11 w-full rounded-lg border border-input bg-surface ps-10 pe-3 text-base shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" })] })] }), shown.length === 0 && !showProfile ? (_jsx("p", { className: "text-sm text-muted-foreground", children: t('overview.nothing', { query }) })) : null, shown.map((g) => (_jsxs("section", { "aria-labelledby": `${id}-${g.id}`, className: "grid gap-2", children: [_jsx("h2", { id: `${id}-${g.id}`, className: "text-xs font-medium tracking-wide text-muted-foreground uppercase", children: g.label }), _jsx("ul", { className: "grid gap-3 sm:grid-cols-2", children: g.entries.map((e) => card(e, _jsxs(Link, { to: "/$app/a/$action", params: { app: 'settings', action: e.action }, className: "flex h-full items-start gap-3 rounded-lg border bg-surface p-4 hover:border-primary/40 hover:bg-accent/40", children: [_jsx(AppIcon, { name: SECTION_ICONS[e.action] ?? 'settings', className: "size-9" }), _jsxs("span", { className: "grid gap-0.5", children: [_jsx("span", { className: "font-medium", children: e.label }), e.description === '' ? null : (_jsx("span", { className: "text-sm text-muted-foreground", children: e.description }))] })] }))) })] }, g.id))), showProfile ? (_jsxs("section", { "aria-labelledby": `${id}-you`, className: "grid gap-2", children: [_jsx("h2", { id: `${id}-you`, className: "text-xs font-medium tracking-wide text-muted-foreground uppercase", children: t('overview.you') }), _jsx("ul", { className: "grid gap-3 sm:grid-cols-2", children: card(profile, _jsxs(Link, { to: "/me/$page", params: { page: 'settings.Profile' }, className: "flex h-full items-start gap-3 rounded-lg border bg-surface p-4 hover:border-primary/40 hover:bg-accent/40", children: [_jsx("span", { "aria-hidden": true, className: "grid size-9 shrink-0 place-items-center rounded-lg bg-accent text-accent-foreground", children: _jsx(UserRound, { className: "size-4" }) }), _jsxs("span", { className: "grid gap-0.5", children: [_jsx("span", { className: "font-medium", children: profile.label }), _jsx("span", { className: "text-sm text-muted-foreground", children: profile.description })] })] })) })] })) : null] }));
|
|
77
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings › People (RFC 0019 §3.2): who works here — searched and filtered by status —, invitations (the link
|
|
3
|
+
* emailed when mail is set up, always shown to copy), a person's details and roles per app and company, a new
|
|
4
|
+
* invitation, a two-factor reset, deactivation and reactivation, and their recent activity. The server decides
|
|
5
|
+
* what a People manager may do (not administrators, not their own roles) and audits it all.
|
|
6
|
+
*/
|
|
7
|
+
import { type PageProps } from '../../ui/index.js';
|
|
8
|
+
import { type ReactElement } from 'react';
|
|
9
|
+
export declare function PeoplePage({ api }: PageProps): ReactElement;
|