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.
Files changed (85) hide show
  1. package/dist/account_portal/.kosh/bindings.js +1 -1
  2. package/dist/account_portal/.kosh/schema.d.ts +128 -1
  3. package/dist/accounting/.kosh/bindings.js +1 -1
  4. package/dist/accounting/.kosh/schema.d.ts +128 -1
  5. package/dist/contacts/.kosh/bindings.js +1 -1
  6. package/dist/contacts/.kosh/schema.d.ts +128 -1
  7. package/dist/integrations/index.js +12 -0
  8. package/dist/integrations/web/OAuthAppsPage.d.ts +9 -0
  9. package/dist/integrations/web/OAuthAppsPage.js +79 -0
  10. package/dist/integrations/web/index.d.ts +1 -0
  11. package/dist/integrations/web/index.js +3 -0
  12. package/dist/integrations/web/messages.js +58 -0
  13. package/dist/l10n_bd/.kosh/bindings.js +1 -1
  14. package/dist/l10n_bd/.kosh/schema.d.ts +128 -1
  15. package/dist/l10n_in/.kosh/bindings.js +1 -1
  16. package/dist/l10n_in/.kosh/schema.d.ts +128 -1
  17. package/dist/messaging/web/index.js +1 -1
  18. package/dist/notify/web/index.js +1 -1
  19. package/dist/payment/.kosh/bindings.js +1 -1
  20. package/dist/payment/.kosh/schema.d.ts +128 -1
  21. package/dist/portal/.kosh/bindings.js +1 -1
  22. package/dist/portal/.kosh/schema.d.ts +128 -1
  23. package/dist/product/.kosh/bindings.js +1 -1
  24. package/dist/product/.kosh/schema.d.ts +128 -1
  25. package/dist/project/.kosh/bindings.js +1 -1
  26. package/dist/project/.kosh/schema.d.ts +128 -1
  27. package/dist/project_portal/.kosh/bindings.js +1 -1
  28. package/dist/project_portal/.kosh/schema.d.ts +128 -1
  29. package/dist/purchase/.kosh/bindings.js +1 -1
  30. package/dist/purchase/.kosh/schema.d.ts +128 -1
  31. package/dist/purchase_account/.kosh/bindings.js +1 -1
  32. package/dist/purchase_account/.kosh/schema.d.ts +128 -1
  33. package/dist/purchase_portal/.kosh/bindings.js +1 -1
  34. package/dist/purchase_portal/.kosh/schema.d.ts +128 -1
  35. package/dist/sale_account/.kosh/bindings.js +1 -1
  36. package/dist/sale_account/.kosh/schema.d.ts +128 -1
  37. package/dist/sale_portal/.kosh/bindings.js +1 -1
  38. package/dist/sale_portal/.kosh/schema.d.ts +128 -1
  39. package/dist/sale_project/.kosh/bindings.js +1 -1
  40. package/dist/sale_project/.kosh/schema.d.ts +128 -1
  41. package/dist/sales/.kosh/bindings.js +1 -1
  42. package/dist/sales/.kosh/schema.d.ts +128 -1
  43. package/dist/server/auth/base.js +76 -0
  44. package/dist/server/auth/oauth.d.ts +21 -0
  45. package/dist/server/auth/oauth.js +579 -0
  46. package/dist/server/auth/service.d.ts +4 -0
  47. package/dist/server/auth/service.js +17 -0
  48. package/dist/server/identity.d.ts +2 -0
  49. package/dist/server/images.d.ts +12 -0
  50. package/dist/server/images.js +139 -0
  51. package/dist/server/index.d.ts +1 -0
  52. package/dist/server/index.js +1 -0
  53. package/dist/server/server.d.ts +4 -0
  54. package/dist/server/server.js +9 -1
  55. package/dist/settings/company.js +3 -1
  56. package/dist/settings/people.js +3 -1
  57. package/dist/settings/web/CompaniesPage.js +2 -2
  58. package/dist/settings/web/CompanyPage.js +13 -2
  59. package/dist/settings/web/ConnectedAppsPage.d.ts +7 -0
  60. package/dist/settings/web/ConnectedAppsPage.js +33 -0
  61. package/dist/settings/web/OverviewPage.js +12 -1
  62. package/dist/settings/web/PeoplePage.js +3 -12
  63. package/dist/settings/web/ProfilePage.js +9 -7
  64. package/dist/settings/web/index.d.ts +1 -0
  65. package/dist/settings/web/index.js +5 -0
  66. package/dist/settings/web/messages.js +28 -0
  67. package/dist/ui/api.d.ts +4 -0
  68. package/dist/ui/app.js +49 -16
  69. package/dist/ui/components/AppIcon.js +11 -1
  70. package/dist/ui/components/Avatar.d.ts +21 -0
  71. package/dist/ui/components/Avatar.js +71 -0
  72. package/dist/ui/i18n/messages.d.ts +30 -0
  73. package/dist/ui/i18n/messages.js +60 -0
  74. package/dist/ui/index.d.ts +1 -0
  75. package/dist/ui/index.js +1 -0
  76. package/dist/ui/registry.d.ts +7 -1
  77. package/dist/ui/registry.js +10 -1
  78. package/dist/ui/search/ViewTabs.d.ts +14 -0
  79. package/dist/ui/search/ViewTabs.js +42 -0
  80. package/dist/ui/shell/Consent.d.ts +5 -0
  81. package/dist/ui/shell/Consent.js +35 -0
  82. package/dist/ui/shell/Home.js +85 -16
  83. package/dist/ui/shell/Sidebar.js +15 -22
  84. package/dist/ui/views/ListView.js +49 -22
  85. 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
+ }
@@ -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';
@@ -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';
@@ -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.
@@ -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
- : `user:${request.identity.user}`,
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())
@@ -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'] } } },
@@ -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: "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] }));
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: "flex items-center gap-4", children: [_jsx("span", { "aria-hidden": true, className: "grid size-14 place-items-center rounded-full bg-primary text-lg font-semibold text-primary-foreground", children: draft.name
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;