shutro-erp 0.3.3 → 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 +128 -1
- package/dist/accounting/.kosh/bindings.js +1 -1
- package/dist/accounting/.kosh/schema.d.ts +128 -1
- package/dist/contacts/.kosh/bindings.js +1 -1
- package/dist/contacts/.kosh/schema.d.ts +128 -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 +128 -1
- package/dist/l10n_in/.kosh/bindings.js +1 -1
- package/dist/l10n_in/.kosh/schema.d.ts +128 -1
- 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 +128 -1
- package/dist/portal/.kosh/bindings.js +1 -1
- package/dist/portal/.kosh/schema.d.ts +128 -1
- package/dist/product/.kosh/bindings.js +1 -1
- package/dist/product/.kosh/schema.d.ts +128 -1
- package/dist/project/.kosh/bindings.js +1 -1
- package/dist/project/.kosh/schema.d.ts +128 -1
- package/dist/project_portal/.kosh/bindings.js +1 -1
- package/dist/project_portal/.kosh/schema.d.ts +128 -1
- package/dist/purchase/.kosh/bindings.js +1 -1
- package/dist/purchase/.kosh/schema.d.ts +128 -1
- package/dist/purchase_account/.kosh/bindings.js +1 -1
- package/dist/purchase_account/.kosh/schema.d.ts +128 -1
- package/dist/purchase_portal/.kosh/bindings.js +1 -1
- package/dist/purchase_portal/.kosh/schema.d.ts +128 -1
- package/dist/sale_account/.kosh/bindings.js +1 -1
- package/dist/sale_account/.kosh/schema.d.ts +128 -1
- package/dist/sale_portal/.kosh/bindings.js +1 -1
- package/dist/sale_portal/.kosh/schema.d.ts +128 -1
- package/dist/sale_project/.kosh/bindings.js +1 -1
- package/dist/sale_project/.kosh/schema.d.ts +128 -1
- package/dist/sales/.kosh/bindings.js +1 -1
- package/dist/sales/.kosh/schema.d.ts +128 -1
- package/dist/server/auth/base.js +76 -0
- package/dist/server/auth/oauth.d.ts +21 -0
- package/dist/server/auth/oauth.js +579 -0
- package/dist/server/auth/service.d.ts +4 -0
- package/dist/server/auth/service.js +17 -0
- package/dist/server/identity.d.ts +2 -0
- package/dist/server/images.d.ts +12 -0
- package/dist/server/images.js +139 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +1 -0
- package/dist/server/server.d.ts +4 -0
- package/dist/server/server.js +9 -1
- package/dist/settings/company.js +3 -1
- package/dist/settings/people.js +3 -1
- package/dist/settings/web/CompaniesPage.js +2 -2
- package/dist/settings/web/CompanyPage.js +13 -2
- package/dist/settings/web/ConnectedAppsPage.d.ts +7 -0
- package/dist/settings/web/ConnectedAppsPage.js +33 -0
- package/dist/settings/web/OverviewPage.js +12 -1
- package/dist/settings/web/PeoplePage.js +3 -12
- package/dist/settings/web/ProfilePage.js +9 -7
- package/dist/settings/web/index.d.ts +1 -0
- package/dist/settings/web/index.js +5 -0
- package/dist/settings/web/messages.js +28 -0
- package/dist/ui/api.d.ts +4 -0
- package/dist/ui/app.js +49 -16
- 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/i18n/messages.d.ts +30 -0
- package/dist/ui/i18n/messages.js +60 -0
- package/dist/ui/index.d.ts +1 -0
- package/dist/ui/index.js +1 -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/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.js +15 -22
- package/dist/ui/views/ListView.js +49 -22
- package/package.json +1 -1
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile photos and company logos: small images (resized to 256 px in the browser) kept in the database, so they
|
|
3
|
+
* work with or without file storage. PNG, JPEG or WebP only — checked by their bytes, never trusted from the name or
|
|
4
|
+
* the type given; at most 300 KB. Served to signed-in users with a version in the URL, so browsers cache them.
|
|
5
|
+
*
|
|
6
|
+
* - `GET /api/v1/images/:kind/:id` `kind`: `user` or `company`; the image, or 404
|
|
7
|
+
* - `PUT /api/v1/auth/profile/avatar` `{ image: 'data:image/…;base64,…' }`: your photo; `DELETE` removes it
|
|
8
|
+
* - `PUT /api/v1/images/company/:id` a company's logo (Administrator or Settings / Company, one of your
|
|
9
|
+
* companies); `DELETE` removes it
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import { audit } from './audit.js';
|
|
13
|
+
import { HttpProblem } from './errors.js';
|
|
14
|
+
const MAX_BYTES = 300 * 1024;
|
|
15
|
+
const KINDS = { user: 'res.users', company: 'res.company' };
|
|
16
|
+
const images = (env) => env.model('kosh.image');
|
|
17
|
+
/** The image's type from its first bytes: PNG, JPEG or WebP; anything else (SVG included) is refused. */
|
|
18
|
+
function sniff(bytes) {
|
|
19
|
+
if (bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])))
|
|
20
|
+
return 'image/png';
|
|
21
|
+
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff)
|
|
22
|
+
return 'image/jpeg';
|
|
23
|
+
if (bytes.subarray(0, 4).toString('latin1') === 'RIFF' &&
|
|
24
|
+
bytes.subarray(8, 12).toString('latin1') === 'WEBP')
|
|
25
|
+
return 'image/webp';
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
/** The bytes of a `data:` URL, checked. @throws {HttpProblem} 422 */
|
|
29
|
+
function decode(input) {
|
|
30
|
+
const bad = (detail) => new HttpProblem(422, 'invalid-image', 'Invalid image', detail);
|
|
31
|
+
const match = typeof input === 'string'
|
|
32
|
+
? /^data:image\/[a-z+.-]+;base64,([A-Za-z0-9+/=]+)$/.exec(input)
|
|
33
|
+
: null;
|
|
34
|
+
if (match === null)
|
|
35
|
+
throw bad('Send the image as a data URL (PNG, JPEG or WebP).');
|
|
36
|
+
const bytes = Buffer.from(match[1], 'base64');
|
|
37
|
+
if (bytes.length === 0 || bytes.length > MAX_BYTES)
|
|
38
|
+
throw bad('The image must be under 300 KB.');
|
|
39
|
+
const mime = sniff(bytes);
|
|
40
|
+
if (mime === null)
|
|
41
|
+
throw bad('Only PNG, JPEG or WebP images.');
|
|
42
|
+
return { bytes, mime };
|
|
43
|
+
}
|
|
44
|
+
/** The image versions of `ids` of a kind (a short digest), for URLs that change when the image does. */
|
|
45
|
+
export async function imageVersions(env, kind, ids) {
|
|
46
|
+
if (ids.length === 0)
|
|
47
|
+
return {};
|
|
48
|
+
const found = await images(env).search({
|
|
49
|
+
where: { ownerModel: KINDS[kind], ownerId: { in: [...ids] } },
|
|
50
|
+
});
|
|
51
|
+
const rows = found.length === 0 ? [] : (await found.load(['ownerId', 'hash'])).read(['ownerId', 'hash']);
|
|
52
|
+
return Object.fromEntries(rows.map((r) => [String(r['ownerId']), String(r['hash'])]));
|
|
53
|
+
}
|
|
54
|
+
async function store(env, kind, id, input) {
|
|
55
|
+
const existing = await images(env).search({ where: { ownerModel: KINDS[kind], ownerId: id } });
|
|
56
|
+
if (input === null) {
|
|
57
|
+
if (existing.length > 0)
|
|
58
|
+
await existing.unlink();
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const { bytes, mime } = decode(input);
|
|
62
|
+
const hash = createHash('sha256').update(bytes).digest('hex').slice(0, 16);
|
|
63
|
+
const values = { mime, data: bytes.toString('base64'), hash };
|
|
64
|
+
if (existing.length > 0)
|
|
65
|
+
await existing.write(values);
|
|
66
|
+
else
|
|
67
|
+
await images(env).create([{ ownerModel: KINDS[kind], ownerId: id, ...values }]);
|
|
68
|
+
return hash;
|
|
69
|
+
}
|
|
70
|
+
export function registerImageRoutes(app, auth) {
|
|
71
|
+
app.get('/api/v1/images/:kind/:id', async (request, reply) => {
|
|
72
|
+
const { kind, id } = request.params;
|
|
73
|
+
const owner = Number(id);
|
|
74
|
+
if (!(kind === 'user' || kind === 'company') || !Number.isSafeInteger(owner) || owner <= 0)
|
|
75
|
+
throw new HttpProblem(404, 'not-found', 'Not found', 'No such image.');
|
|
76
|
+
const row = await request.env((env) => env.asSuperuser({ reason: 'image' }, async (su) => {
|
|
77
|
+
const found = await images(su).search({
|
|
78
|
+
where: { ownerModel: KINDS[kind], ownerId: owner },
|
|
79
|
+
});
|
|
80
|
+
return found.length === 0
|
|
81
|
+
? undefined
|
|
82
|
+
: (await found.load(['mime', 'data', 'hash'])).read(['mime', 'data', 'hash'])[0];
|
|
83
|
+
}), { write: true });
|
|
84
|
+
if (row === undefined)
|
|
85
|
+
throw new HttpProblem(404, 'not-found', 'Not found', 'No image.');
|
|
86
|
+
const etag = `"${String(row['hash'])}"`;
|
|
87
|
+
reply
|
|
88
|
+
.header('content-type', String(row['mime']))
|
|
89
|
+
.header('etag', etag)
|
|
90
|
+
.header('x-content-type-options', 'nosniff')
|
|
91
|
+
// Versioned URLs (`?v=<hash>`) never change; plain ones are checked again after a minute.
|
|
92
|
+
.header('cache-control', request.query.v === undefined
|
|
93
|
+
? 'private, max-age=60'
|
|
94
|
+
: 'private, max-age=31536000, immutable');
|
|
95
|
+
if (request.headers['if-none-match'] === etag)
|
|
96
|
+
return reply.status(304).send();
|
|
97
|
+
return reply.send(Buffer.from(String(row['data']), 'base64'));
|
|
98
|
+
});
|
|
99
|
+
const own = async (request, image) => {
|
|
100
|
+
const session = await auth.requireSession(request);
|
|
101
|
+
const hash = await request.env((env) => env.asSuperuser({ reason: 'profile photo' }, async (su) => {
|
|
102
|
+
const out = await store(su, 'user', session.user, image);
|
|
103
|
+
await audit(env, {
|
|
104
|
+
kind: 'auth.avatar',
|
|
105
|
+
summary: image === null ? 'Profile photo removed.' : 'Profile photo changed.',
|
|
106
|
+
targetModel: 'res.users',
|
|
107
|
+
targetId: session.user,
|
|
108
|
+
});
|
|
109
|
+
await su.flush();
|
|
110
|
+
return out;
|
|
111
|
+
}), { write: true });
|
|
112
|
+
auth.forget(session.user);
|
|
113
|
+
return { avatar: hash };
|
|
114
|
+
};
|
|
115
|
+
app.put('/api/v1/auth/profile/avatar', async (request) => own(request, request.body?.['image'] ?? ''));
|
|
116
|
+
app.delete('/api/v1/auth/profile/avatar', async (request) => own(request, null));
|
|
117
|
+
const logo = async (request, image) => {
|
|
118
|
+
const company = Number(request.params.id);
|
|
119
|
+
return request.env(async (env) => {
|
|
120
|
+
if (!env.administrator && !env.groups.has('settings.company'))
|
|
121
|
+
throw new HttpProblem(403, 'access', 'Company settings', 'Changing the logo needs the Administrator role or the Settings / Company access.');
|
|
122
|
+
if (!env.administrator && !env.companyIds.includes(company))
|
|
123
|
+
throw new HttpProblem(403, 'access', 'Company settings', 'Not one of your companies.');
|
|
124
|
+
return env.asSuperuser({ reason: 'company logo' }, async (su) => {
|
|
125
|
+
const hash = await store(su, 'company', company, image);
|
|
126
|
+
await audit(env, {
|
|
127
|
+
kind: 'settings.logo',
|
|
128
|
+
summary: image === null ? 'Company logo removed.' : 'Company logo changed.',
|
|
129
|
+
targetModel: 'res.company',
|
|
130
|
+
targetId: company,
|
|
131
|
+
});
|
|
132
|
+
await su.flush();
|
|
133
|
+
return { logo: hash };
|
|
134
|
+
});
|
|
135
|
+
}, { write: true });
|
|
136
|
+
};
|
|
137
|
+
app.put('/api/v1/images/company/:id', async (request) => logo(request, request.body?.['image'] ?? ''));
|
|
138
|
+
app.delete('/api/v1/images/company/:id', async (request) => logo(request, null));
|
|
139
|
+
}
|
package/dist/server/index.d.ts
CHANGED
|
@@ -24,6 +24,7 @@ export { applyEnvironmentNames } from './env-names.js';
|
|
|
24
24
|
export { HttpProblem, type Problem, problemOf, ServerConfigError } from './errors.js';
|
|
25
25
|
export { type AppHost, type AppHostOptions, createAppHost, startAppHost } from './host.js';
|
|
26
26
|
export { type Authenticator, denyAll, isSuperuser, languageOf, type RequestIdentity, type RequestRunOptions, runOptionsOf, } from './identity.js';
|
|
27
|
+
export { imageVersions, registerImageRoutes } from './images.js';
|
|
27
28
|
export { migrate, orm, pg } from './orm.js';
|
|
28
29
|
export { buildKoshRegistry, KOSH_VERSION, type KoshRegistry } from './registry.js';
|
|
29
30
|
export { type AppsContext, createServer, type KoshServer, type ModuleContext, type ServerOptions, startServer, } from './server.js';
|
package/dist/server/index.js
CHANGED
|
@@ -24,6 +24,7 @@ export { applyEnvironmentNames } from './env-names.js';
|
|
|
24
24
|
export { HttpProblem, problemOf, ServerConfigError } from './errors.js';
|
|
25
25
|
export { createAppHost, startAppHost } from './host.js';
|
|
26
26
|
export { denyAll, isSuperuser, languageOf, runOptionsOf, } from './identity.js';
|
|
27
|
+
export { imageVersions, registerImageRoutes } from './images.js';
|
|
27
28
|
export { migrate, orm, pg } from './orm.js';
|
|
28
29
|
export { buildKoshRegistry, KOSH_VERSION } from './registry.js';
|
|
29
30
|
export { createServer, startServer, } from './server.js';
|
package/dist/server/server.d.ts
CHANGED
|
@@ -47,6 +47,10 @@ export interface ServerOptions {
|
|
|
47
47
|
/** Sign-in attempts per 15 minutes per IP and login (default 10; 0 turns the limit off). */
|
|
48
48
|
readonly loginLimit?: number;
|
|
49
49
|
};
|
|
50
|
+
/** OAuth 2.0 provider (RFC 0017 §5.2): the back-office's address, where the consent page is (`KOSH_BASE_URL`). */
|
|
51
|
+
readonly oauth?: {
|
|
52
|
+
readonly baseUrl?: string;
|
|
53
|
+
};
|
|
50
54
|
/**
|
|
51
55
|
* Shutro's identity loader checks every request's user (active, companies, groups) against `base`'s data. Default:
|
|
52
56
|
* on with the built-in authentication; custom authenticators opt in.
|
package/dist/server/server.js
CHANGED
|
@@ -13,12 +13,14 @@ import { openApiDocument } from './api/openapi.js';
|
|
|
13
13
|
import { registerRestApi } from './api/rest.js';
|
|
14
14
|
import { ViewRegistry } from './api/views.js';
|
|
15
15
|
import { registerApiKeyRoutes } from './auth/api-keys.js';
|
|
16
|
+
import { registerOAuthRoutes } from './auth/oauth.js';
|
|
16
17
|
import { registerAuthRoutes } from './auth/routes.js';
|
|
17
18
|
import { enforceScopes } from './auth/scopes.js';
|
|
18
19
|
import { AuthService } from './auth/service.js';
|
|
19
20
|
import { applyEnvironmentNames } from './env-names.js';
|
|
20
21
|
import { HttpProblem, problemOf, ServerConfigError } from './errors.js';
|
|
21
22
|
import { denyAll, runOptionsOf } from './identity.js';
|
|
23
|
+
import { registerImageRoutes } from './images.js';
|
|
22
24
|
import { orm, pg } from './orm.js';
|
|
23
25
|
import { buildKoshRegistry, KOSH_VERSION } from './registry.js';
|
|
24
26
|
import { endSpan, tracer } from './telemetry.js';
|
|
@@ -169,7 +171,9 @@ export async function createServer(options) {
|
|
|
169
171
|
? `ip:${request.ip}`
|
|
170
172
|
: request.identity.apiKey !== undefined
|
|
171
173
|
? `key:${request.identity.apiKey}`
|
|
172
|
-
:
|
|
174
|
+
: request.identity.oauthToken !== undefined
|
|
175
|
+
? `oauth:${request.identity.oauthToken}`
|
|
176
|
+
: `user:${request.identity.user}`,
|
|
173
177
|
allowList: (request) => request.routeOptions.config.public === true,
|
|
174
178
|
errorResponseBuilder: (_request, context) => new HttpProblem(429, 'rate-limited', 'Too many requests', `Limit of ${context.max} requests per ${context.after} reached.`, `Retry after ${context.after}.`),
|
|
175
179
|
});
|
|
@@ -209,6 +213,10 @@ export async function createServer(options) {
|
|
|
209
213
|
if (auth !== undefined) {
|
|
210
214
|
registerAuthRoutes(app, auth, { loginLimit: options.auth?.loginLimit ?? 10 });
|
|
211
215
|
registerApiKeyRoutes(app, auth, registry);
|
|
216
|
+
registerImageRoutes(app, auth);
|
|
217
|
+
registerOAuthRoutes(app, auth, () => shutro, registry, {
|
|
218
|
+
baseUrl: options.oauth?.baseUrl ?? process.env['KOSH_BASE_URL'] ?? 'http://localhost:5173',
|
|
219
|
+
});
|
|
212
220
|
}
|
|
213
221
|
// The generated REST API and its OpenAPI document (RFC 0002 §6.3–6.4).
|
|
214
222
|
for (const statement of idempotencyTableStatements())
|
package/dist/settings/company.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* - `GET /api/v1/settings/companies` every company with its currency and people, the currencies
|
|
11
11
|
* - `POST /api/v1/settings/companies` `{ name, currency }`: a new company (administrators)
|
|
12
12
|
*/
|
|
13
|
-
import { audit, HttpProblem, installDefaults, publicApiModels, } from '../server/index.js';
|
|
13
|
+
import { audit, HttpProblem, imageVersions, installDefaults, publicApiModels, } from '../server/index.js';
|
|
14
14
|
const model = (env, name) => env.model(name);
|
|
15
15
|
const find = async (env, name, where, keys) => {
|
|
16
16
|
const set = await model(env, name).search({ where, order: ['id asc'] });
|
|
@@ -118,6 +118,7 @@ export function registerCompanyRoutes(context) {
|
|
|
118
118
|
app.get('/api/v1/settings/companies', async (request) => guarded(request, async (su, current, env) => {
|
|
119
119
|
const all = await find(su, 'res.company', {}, ['name', 'currency']);
|
|
120
120
|
const currencies = await find(su, 'res.currency', {}, ['name']);
|
|
121
|
+
const logos = await imageVersions(su, 'company', all.map((c) => c['id']));
|
|
121
122
|
const currencyName = new Map(currencies.map((c) => [c['id'], String(c['name'])]));
|
|
122
123
|
return {
|
|
123
124
|
canCreate: env.administrator,
|
|
@@ -129,6 +130,7 @@ export function registerCompanyRoutes(context) {
|
|
|
129
130
|
where: { isService: { ne: true }, companies: { any: { id: c['id'] } } },
|
|
130
131
|
})).length,
|
|
131
132
|
current: c['id'] === current,
|
|
133
|
+
logo: logos[String(c['id'])] ?? null,
|
|
132
134
|
// The person can switch to it: it is one of their companies.
|
|
133
135
|
member: (await model(su, 'res.users').search({
|
|
134
136
|
where: { id: env.uid, companies: { any: { id: c['id'] } } },
|
package/dist/settings/people.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { createHash, randomBytes } from 'node:crypto';
|
|
19
19
|
import { layout } from '../mail/index.js';
|
|
20
|
-
import { audit, HttpProblem, isAdministratorUser } from '../server/index.js';
|
|
20
|
+
import { audit, HttpProblem, imageVersions, isAdministratorUser, } from '../server/index.js';
|
|
21
21
|
/** How long an invitation works. */
|
|
22
22
|
export const INVITATION_DAYS = 7;
|
|
23
23
|
const ref = (env, name) => env.model(name);
|
|
@@ -164,6 +164,7 @@ export function registerPeopleRoutes(context, options) {
|
|
|
164
164
|
const invitations = await rows(ref(su, 'settings.invitation').search({ where: { user: { in: ids }, usedAt: null } }), ['user', 'expiresAt']);
|
|
165
165
|
const known = await knownGroups();
|
|
166
166
|
const admin = adminGroups(su);
|
|
167
|
+
const photos = await imageVersions(su, 'user', ids);
|
|
167
168
|
// "Purchase · Manager": a group named by the app level that gives it, else by its own label.
|
|
168
169
|
const levelLabel = new Map();
|
|
169
170
|
for (const m of modules)
|
|
@@ -184,6 +185,7 @@ export function registerPeopleRoutes(context, options) {
|
|
|
184
185
|
name: String(u['name'] ?? ''),
|
|
185
186
|
login: String(u['login'] ?? ''),
|
|
186
187
|
email: typeof u['email'] === 'string' ? u['email'] : null,
|
|
188
|
+
avatar: photos[String(id)] ?? null,
|
|
187
189
|
active: u['active'] !== false,
|
|
188
190
|
state: u['active'] === false
|
|
189
191
|
? 'deactivated'
|
|
@@ -4,7 +4,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
4
4
|
* in it; switching to one; and, for administrators, a new company — its apps' defaults (journals, sequences, taxes…)
|
|
5
5
|
* are installed with it, and the administrators can switch to it at once.
|
|
6
6
|
*/
|
|
7
|
-
import { Alert, ApiError, Badge, Button, Dialog, Input, Label, Select, useKosh, } from '../../ui/index.js';
|
|
7
|
+
import { Alert, ApiError, Avatar, Badge, Button, Dialog, Input, imageUrl, Label, Select, useKosh, } from '../../ui/index.js';
|
|
8
8
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
9
9
|
import { Building2, Plus } from 'lucide-react';
|
|
10
10
|
import { useEffect, useId, useState } from 'react';
|
|
@@ -48,5 +48,5 @@ export function CompaniesPage({ api }) {
|
|
|
48
48
|
if (list.error !== null)
|
|
49
49
|
return _jsx(Alert, { tone: "danger", children: message(list.error) });
|
|
50
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: [_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" }) }), _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 }))] }));
|
|
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
52
|
}
|
|
@@ -5,7 +5,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
5
5
|
* apps add (tax ids, fiscal year…), as the server offers them. The preview shows the colour as buttons, links and
|
|
6
6
|
* selections take it, in light and dark; the themes keep text readable whatever colour is chosen.
|
|
7
7
|
*/
|
|
8
|
-
import { Alert, ApiError, accentVariables, Badge, Button, Checkbox, Input, Label, Select, Textarea, } from '../../ui/index.js';
|
|
8
|
+
import { Alert, ApiError, accentVariables, Badge, Button, Checkbox, ImagePicker, Input, imageUrl, Label, Select, Textarea, } from '../../ui/index.js';
|
|
9
9
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
10
10
|
import { Check } from 'lucide-react';
|
|
11
11
|
import { useId, useState } from 'react';
|
|
@@ -58,6 +58,17 @@ function CompanyForm({ api, company }) {
|
|
|
58
58
|
const [grouping, setGrouping] = useState(company.numberGrouping);
|
|
59
59
|
const [color, setColor] = useState(company.accentColor);
|
|
60
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
|
+
});
|
|
61
72
|
// Only the details changed are sent.
|
|
62
73
|
const [details, setDetails] = useState({});
|
|
63
74
|
const save = useMutation({
|
|
@@ -81,7 +92,7 @@ function CompanyForm({ api, company }) {
|
|
|
81
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) => {
|
|
82
93
|
e.preventDefault();
|
|
83
94
|
save.mutate();
|
|
84
|
-
}, children: [_jsxs("section", { className: "grid gap-4 rounded-lg border bg-surface p-5", children: [_jsx("h2", { className: "font-semibold", children: t('company.identity') }), _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: [
|
|
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: [
|
|
85
96
|
{ value: 'international', label: t('company.international') },
|
|
86
97
|
{ value: 'lakh', label: t('company.lakh') },
|
|
87
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) => {
|
|
@@ -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
|
+
}
|
|
@@ -12,6 +12,17 @@ import { Search, UserRound } from 'lucide-react';
|
|
|
12
12
|
import { useId, useState } from 'react';
|
|
13
13
|
import { useTranslation } from 'react-i18next';
|
|
14
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
|
+
};
|
|
15
26
|
export function OverviewPage({ api }) {
|
|
16
27
|
const { t } = useTranslation('settings');
|
|
17
28
|
const id = useId();
|
|
@@ -62,5 +73,5 @@ export function OverviewPage({ api }) {
|
|
|
62
73
|
const showProfile = matches(profile);
|
|
63
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",
|
|
64
75
|
// biome-ignore lint/a11y/noAutofocus: the page is a search first; it opens on purpose
|
|
65
|
-
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:
|
|
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] }));
|
|
66
77
|
}
|
|
@@ -5,7 +5,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
5
5
|
* invitation, a two-factor reset, deactivation and reactivation, and their recent activity. The server decides
|
|
6
6
|
* what a People manager may do (not administrators, not their own roles) and audits it all.
|
|
7
7
|
*/
|
|
8
|
-
import { Alert, ApiError, Badge, Button, Dialog, EmptyState, Input, Label, useKosh, } from '../../ui/index.js';
|
|
8
|
+
import { Alert, ApiError, Avatar, Badge, Button, Dialog, EmptyState, Input, imageUrl, Label, useKosh, } from '../../ui/index.js';
|
|
9
9
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
10
10
|
import { Check, Copy, Search, ShieldCheck, UserPlus } from 'lucide-react';
|
|
11
11
|
import { useEffect, useId, useState } from 'react';
|
|
@@ -13,15 +13,6 @@ import { useTranslation } from 'react-i18next';
|
|
|
13
13
|
import { RolesEditor } from './RolesEditor.js';
|
|
14
14
|
const P = '/api/v1/settings/people';
|
|
15
15
|
const message = (error) => (error instanceof ApiError ? error.message : String(error));
|
|
16
|
-
const initials = (name) => name
|
|
17
|
-
.split(/\s+/)
|
|
18
|
-
.map((w) => w.slice(0, 1))
|
|
19
|
-
.join('')
|
|
20
|
-
.slice(0, 2)
|
|
21
|
-
.toUpperCase();
|
|
22
|
-
function Avatar({ name }) {
|
|
23
|
-
return (_jsx("span", { "aria-hidden": true, className: "grid size-8 shrink-0 place-items-center rounded-full bg-accent text-xs font-semibold text-accent-foreground", children: initials(name) }));
|
|
24
|
-
}
|
|
25
16
|
function StateBadge({ person }) {
|
|
26
17
|
const { t } = useTranslation('settings');
|
|
27
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') }));
|
|
@@ -130,7 +121,7 @@ function PersonDialog({ api, catalogue, personId, onClose, }) {
|
|
|
130
121
|
const p = person.data;
|
|
131
122
|
const error = saveDetails.error ?? saveRoles.error ?? setActive.error ?? resend.error ?? reset.error;
|
|
132
123
|
const self = personId === uid;
|
|
133
|
-
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 }), _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))) }))] })] })) }));
|
|
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))) }))] })] })) }));
|
|
134
125
|
}
|
|
135
126
|
export function PeoplePage({ api }) {
|
|
136
127
|
const { t } = useTranslation('settings');
|
|
@@ -152,7 +143,7 @@ export function PeoplePage({ api }) {
|
|
|
152
143
|
return _jsx(Alert, { tone: "danger", children: message(catalogue.error) });
|
|
153
144
|
const list = people.data?.people ?? [];
|
|
154
145
|
const statuses = ['all', 'active', 'invited', 'deactivated'];
|
|
155
|
-
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 }), _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
|
|
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
|
|
156
147
|
.filter((a) => !a.group.startsWith('base.'))
|
|
157
148
|
.slice(0, 3)
|
|
158
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
|
|
@@ -4,7 +4,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
4
4
|
* your password, two-factor sign-in (set up with an authenticator app, recovery codes, turn off) and the devices
|
|
5
5
|
* signed in as you (sign one out, or all the others). The server checks and audits every change.
|
|
6
6
|
*/
|
|
7
|
-
import { Alert, ApiError, Badge, Button, Input, LANGUAGES, Label, Select, useKosh, useLanguage, } from '../../ui/index.js';
|
|
7
|
+
import { Alert, ApiError, Badge, Button, ImagePicker, Input, imageUrl, LANGUAGES, Label, Select, useKosh, useLanguage, } from '../../ui/index.js';
|
|
8
8
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
9
9
|
import { Laptop, Monitor, Smartphone } from 'lucide-react';
|
|
10
10
|
import { useEffect, useId, useState } from 'react';
|
|
@@ -142,6 +142,13 @@ export function ProfilePage({ api }) {
|
|
|
142
142
|
setLang(saved.lang);
|
|
143
143
|
},
|
|
144
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
|
+
});
|
|
145
152
|
const [current, setCurrent] = useState('');
|
|
146
153
|
const [next, setNext] = useState('');
|
|
147
154
|
const [repeat, setRepeat] = useState('');
|
|
@@ -169,12 +176,7 @@ export function ProfilePage({ api }) {
|
|
|
169
176
|
if (draft === null || me.data === undefined)
|
|
170
177
|
return _jsx("p", { className: "text-sm text-muted-foreground", children: "\u2026" });
|
|
171
178
|
const set = (patch) => setDraft({ ...draft, ...patch });
|
|
172
|
-
return (_jsxs("div", { className: "mx-auto grid max-w-3xl gap-6", children: [_jsxs("header", { className: "
|
|
173
|
-
.split(/\s+/)
|
|
174
|
-
.map((w) => w.slice(0, 1))
|
|
175
|
-
.join('')
|
|
176
|
-
.slice(0, 2)
|
|
177
|
-
.toUpperCase() }), _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("form", { className: "grid gap-6", onSubmit: (e) => {
|
|
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) => {
|
|
178
180
|
e.preventDefault();
|
|
179
181
|
save.mutate({
|
|
180
182
|
name: draft.name,
|
|
@@ -11,6 +11,7 @@ export { AppsPage } from './AppsPage.js';
|
|
|
11
11
|
export { AuditPage } from './AuditPage.js';
|
|
12
12
|
export { CompaniesPage } from './CompaniesPage.js';
|
|
13
13
|
export { CompanyPage } from './CompanyPage.js';
|
|
14
|
+
export { ConnectedAppsPage } from './ConnectedAppsPage.js';
|
|
14
15
|
export { OverviewPage } from './OverviewPage.js';
|
|
15
16
|
export { PeoplePage } from './PeoplePage.js';
|
|
16
17
|
export { deviceOf, ProfilePage } from './ProfilePage.js';
|
|
@@ -9,6 +9,7 @@ import { AppsPage } from './AppsPage.js';
|
|
|
9
9
|
import { AuditPage } from './AuditPage.js';
|
|
10
10
|
import { CompaniesPage } from './CompaniesPage.js';
|
|
11
11
|
import { CompanyPage } from './CompanyPage.js';
|
|
12
|
+
import { ConnectedAppsPage } from './ConnectedAppsPage.js';
|
|
12
13
|
import { messages } from './messages.js';
|
|
13
14
|
import { OverviewPage } from './OverviewPage.js';
|
|
14
15
|
import { PeoplePage } from './PeoplePage.js';
|
|
@@ -20,6 +21,7 @@ export { AppsPage } from './AppsPage.js';
|
|
|
20
21
|
export { AuditPage } from './AuditPage.js';
|
|
21
22
|
export { CompaniesPage } from './CompaniesPage.js';
|
|
22
23
|
export { CompanyPage } from './CompanyPage.js';
|
|
24
|
+
export { ConnectedAppsPage } from './ConnectedAppsPage.js';
|
|
23
25
|
export { OverviewPage } from './OverviewPage.js';
|
|
24
26
|
export { PeoplePage } from './PeoplePage.js';
|
|
25
27
|
export { deviceOf, ProfilePage } from './ProfilePage.js';
|
|
@@ -39,6 +41,9 @@ export function registerSettingsWeb(registry, i18n) {
|
|
|
39
41
|
// Every signed-in user's own profile (RFC 0019) and keys (RFC 0017 §5.1): in the user menu, not in Settings.
|
|
40
42
|
registry.registerUserPage('settings.Profile', ProfilePage, { label: 'settings:profile.title' });
|
|
41
43
|
registry.registerUserPage('settings.ApiKeys', ApiKeysPage, { label: 'settings:apiKeys.title' });
|
|
44
|
+
registry.registerUserPage('settings.ConnectedApps', ConnectedAppsPage, {
|
|
45
|
+
label: 'settings:connected.title',
|
|
46
|
+
});
|
|
42
47
|
if (i18n !== undefined)
|
|
43
48
|
addMessages(i18n, 'settings', messages);
|
|
44
49
|
return registry;
|