shutro-erp 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/dist/account_portal/.kosh/bindings.js +1 -1
  2. package/dist/account_portal/.kosh/schema.d.ts +156 -1
  3. package/dist/accounting/.kosh/bindings.js +1 -1
  4. package/dist/accounting/.kosh/schema.d.ts +156 -1
  5. package/dist/assistant/index.js +1 -1
  6. package/dist/contacts/.kosh/bindings.js +1 -1
  7. package/dist/contacts/.kosh/schema.d.ts +156 -1
  8. package/dist/integrations/index.js +12 -0
  9. package/dist/integrations/web/OAuthAppsPage.d.ts +9 -0
  10. package/dist/integrations/web/OAuthAppsPage.js +79 -0
  11. package/dist/integrations/web/index.d.ts +1 -0
  12. package/dist/integrations/web/index.js +3 -0
  13. package/dist/integrations/web/messages.js +58 -0
  14. package/dist/l10n_bd/.kosh/bindings.js +1 -1
  15. package/dist/l10n_bd/.kosh/schema.d.ts +156 -1
  16. package/dist/l10n_in/.kosh/bindings.js +1 -1
  17. package/dist/l10n_in/.kosh/schema.d.ts +156 -4
  18. package/dist/l10n_in/module.d.ts +0 -62
  19. package/dist/l10n_in/module.js +2 -4
  20. package/dist/messaging/web/index.js +1 -1
  21. package/dist/notify/web/index.js +1 -1
  22. package/dist/payment/.kosh/bindings.js +1 -1
  23. package/dist/payment/.kosh/schema.d.ts +157 -2
  24. package/dist/payment/bkash.d.ts +28 -0
  25. package/dist/payment/bkash.js +169 -0
  26. package/dist/payment/index.d.ts +4 -0
  27. package/dist/payment/index.js +80 -24
  28. package/dist/payment/module.d.ts +7 -4
  29. package/dist/payment/module.js +3 -0
  30. package/dist/payment/money.d.ts +7 -0
  31. package/dist/payment/money.js +37 -0
  32. package/dist/payment/providers.d.ts +13 -2
  33. package/dist/payment/razorpay.d.ts +12 -0
  34. package/dist/payment/razorpay.js +149 -0
  35. package/dist/payment/sslcommerz.js +1 -1
  36. package/dist/payment/stripe.d.ts +15 -0
  37. package/dist/payment/stripe.js +182 -0
  38. package/dist/permissions/admin.d.ts +3 -0
  39. package/dist/permissions/admin.js +108 -10
  40. package/dist/permissions/index.d.ts +1 -0
  41. package/dist/permissions/index.js +3 -0
  42. package/dist/permissions/roles.d.ts +2 -0
  43. package/dist/permissions/roles.js +236 -0
  44. package/dist/portal/.kosh/bindings.js +1 -1
  45. package/dist/portal/.kosh/schema.d.ts +156 -1
  46. package/dist/product/.kosh/bindings.js +1 -1
  47. package/dist/product/.kosh/schema.d.ts +156 -1
  48. package/dist/project/.kosh/bindings.js +1 -1
  49. package/dist/project/.kosh/schema.d.ts +156 -1
  50. package/dist/project_portal/.kosh/bindings.js +1 -1
  51. package/dist/project_portal/.kosh/schema.d.ts +156 -1
  52. package/dist/purchase/.kosh/bindings.js +1 -1
  53. package/dist/purchase/.kosh/schema.d.ts +156 -1
  54. package/dist/purchase_account/.kosh/bindings.js +1 -1
  55. package/dist/purchase_account/.kosh/schema.d.ts +156 -1
  56. package/dist/purchase_portal/.kosh/bindings.js +1 -1
  57. package/dist/purchase_portal/.kosh/schema.d.ts +156 -1
  58. package/dist/sale_account/.kosh/bindings.js +1 -1
  59. package/dist/sale_account/.kosh/schema.d.ts +156 -1
  60. package/dist/sale_portal/.kosh/bindings.js +1 -1
  61. package/dist/sale_portal/.kosh/schema.d.ts +156 -1
  62. package/dist/sale_project/.kosh/bindings.js +1 -1
  63. package/dist/sale_project/.kosh/schema.d.ts +156 -1
  64. package/dist/sales/.kosh/bindings.js +1 -1
  65. package/dist/sales/.kosh/schema.d.ts +156 -1
  66. package/dist/server/auth/base.js +98 -0
  67. package/dist/server/auth/oauth.d.ts +21 -0
  68. package/dist/server/auth/oauth.js +579 -0
  69. package/dist/server/auth/routes.d.ts +3 -0
  70. package/dist/server/auth/routes.js +29 -0
  71. package/dist/server/auth/service.d.ts +56 -0
  72. package/dist/server/auth/service.js +185 -3
  73. package/dist/server/identity.d.ts +4 -0
  74. package/dist/server/images.d.ts +12 -0
  75. package/dist/server/images.js +139 -0
  76. package/dist/server/index.d.ts +2 -1
  77. package/dist/server/index.js +1 -0
  78. package/dist/server/server.d.ts +4 -0
  79. package/dist/server/server.js +20 -4
  80. package/dist/settings/company.d.ts +34 -0
  81. package/dist/settings/company.js +189 -0
  82. package/dist/settings/index.d.ts +10 -10
  83. package/dist/settings/index.js +159 -30
  84. package/dist/settings/people.d.ts +16 -0
  85. package/dist/settings/people.js +507 -0
  86. package/dist/settings/security.d.ts +13 -0
  87. package/dist/settings/security.js +158 -0
  88. package/dist/settings/web/AuditPage.d.ts +8 -0
  89. package/dist/settings/web/AuditPage.js +74 -0
  90. package/dist/settings/web/CompaniesPage.d.ts +8 -0
  91. package/dist/settings/web/CompaniesPage.js +52 -0
  92. package/dist/settings/web/CompanyPage.d.ts +9 -0
  93. package/dist/settings/web/CompanyPage.js +105 -0
  94. package/dist/settings/web/ConnectedAppsPage.d.ts +7 -0
  95. package/dist/settings/web/ConnectedAppsPage.js +33 -0
  96. package/dist/settings/web/OverviewPage.d.ts +9 -0
  97. package/dist/settings/web/OverviewPage.js +77 -0
  98. package/dist/settings/web/PeoplePage.d.ts +9 -0
  99. package/dist/settings/web/PeoplePage.js +154 -0
  100. package/dist/settings/web/ProfilePage.d.ts +14 -0
  101. package/dist/settings/web/ProfilePage.js +196 -0
  102. package/dist/settings/web/RolesEditor.d.ts +35 -0
  103. package/dist/settings/web/RolesEditor.js +49 -0
  104. package/dist/settings/web/RolesPage.d.ts +9 -0
  105. package/dist/settings/web/RolesPage.js +296 -0
  106. package/dist/settings/web/SecurityPage.d.ts +8 -0
  107. package/dist/settings/web/SecurityPage.js +81 -0
  108. package/dist/settings/web/index.d.ts +10 -0
  109. package/dist/settings/web/index.js +31 -1
  110. package/dist/settings/web/messages.js +594 -0
  111. package/dist/ui/api.d.ts +8 -0
  112. package/dist/ui/app.d.ts +5 -0
  113. package/dist/ui/app.js +143 -33
  114. package/dist/ui/components/AppIcon.js +11 -1
  115. package/dist/ui/components/Avatar.d.ts +21 -0
  116. package/dist/ui/components/Avatar.js +71 -0
  117. package/dist/ui/components/primitives.d.ts +9 -1
  118. package/dist/ui/components/primitives.js +12 -4
  119. package/dist/ui/context.d.ts +10 -1
  120. package/dist/ui/context.js +60 -2
  121. package/dist/ui/i18n/messages.d.ts +76 -0
  122. package/dist/ui/i18n/messages.js +152 -0
  123. package/dist/ui/index.d.ts +3 -1
  124. package/dist/ui/index.js +3 -1
  125. package/dist/ui/lib/accent.d.ts +13 -0
  126. package/dist/ui/lib/accent.js +36 -0
  127. package/dist/ui/registry.d.ts +7 -1
  128. package/dist/ui/registry.js +10 -1
  129. package/dist/ui/search/ViewTabs.d.ts +14 -0
  130. package/dist/ui/search/ViewTabs.js +42 -0
  131. package/dist/ui/shell/Activate.d.ts +6 -0
  132. package/dist/ui/shell/Activate.js +39 -0
  133. package/dist/ui/shell/CommandPalette.d.ts +7 -1
  134. package/dist/ui/shell/CommandPalette.js +16 -2
  135. package/dist/ui/shell/Consent.d.ts +5 -0
  136. package/dist/ui/shell/Consent.js +35 -0
  137. package/dist/ui/shell/Home.js +85 -16
  138. package/dist/ui/shell/Sidebar.d.ts +27 -0
  139. package/dist/ui/shell/Sidebar.js +77 -0
  140. package/dist/ui/shell/TwoFactorSetup.d.ts +5 -0
  141. package/dist/ui/shell/TwoFactorSetup.js +32 -0
  142. package/dist/ui/shell/shortcuts.d.ts +3 -0
  143. package/dist/ui/shell/shortcuts.js +13 -4
  144. package/dist/ui/styles.css +70 -28
  145. package/dist/ui/views/DateField.d.ts +10 -0
  146. package/dist/ui/views/DateField.js +129 -0
  147. package/dist/ui/views/FormView.js +161 -21
  148. package/dist/ui/views/ListView.d.ts +2 -0
  149. package/dist/ui/views/ListView.js +57 -25
  150. package/dist/ui/views/StatusChip.d.ts +10 -0
  151. package/dist/ui/views/StatusChip.js +22 -0
  152. package/dist/ui/views/widgets.d.ts +3 -0
  153. package/dist/ui/views/widgets.js +26 -8
  154. package/dist/views/types.d.ts +7 -0
  155. package/dist/views/validate.js +4 -0
  156. package/package.json +2 -2
@@ -0,0 +1,42 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Saved views as tabs (RFC 0018 §4.3): above the search, *All* and the saved searches of this model and view type —
4
+ * the user's own and the shared ones —, the one matching the current search marked. A tab applies its search; more
5
+ * than a handful go into a *More* menu. Saving and the full list stay in the search bar's favorites menu; with
6
+ * nothing saved, there are no tabs.
7
+ */
8
+ import { encodeSearch } from '../../search/index.js';
9
+ import { useQuery } from '@tanstack/react-query';
10
+ import { ChevronDown, Users } from 'lucide-react';
11
+ import { useTranslation } from 'react-i18next';
12
+ import { Button, Menu, MenuContent, MenuItem, MenuTrigger } from '../components/primitives.js';
13
+ import { useKosh } from '../context.js';
14
+ import { cn } from '../lib/cn.js';
15
+ /** Tabs shown before the rest go into *More*. */
16
+ const VISIBLE = 6;
17
+ /** The search without its order: a tab matches whatever the columns are sorted by. */
18
+ const key = (state) => encodeSearch({ ...state, order: [] });
19
+ export function ViewTabs({ model, viewType, state, onChange, }) {
20
+ const { api } = useKosh();
21
+ const { t } = useTranslation();
22
+ // The search bar's favorites query: one request, one cache.
23
+ const favorites = useQuery({
24
+ queryKey: ['saved-views', model],
25
+ queryFn: () => api.list('kosh.saved_view', {
26
+ filter: { targetModel: model },
27
+ fields: ['name', 'state', 'shared', 'viewType'],
28
+ }),
29
+ });
30
+ const views = (favorites.data?.items ?? []).filter((v) => v['viewType'] === viewType || v['viewType'] === null);
31
+ if (views.length === 0)
32
+ return null;
33
+ const current = key(state);
34
+ const stateOf = (v) => v['state'] ?? {};
35
+ const active = views.find((v) => key(stateOf(v)) === current);
36
+ const tab = (label, selected, apply, shared = false) => (_jsxs(Button, { size: "sm", variant: "ghost", "aria-current": selected ? 'true' : undefined, onClick: apply, className: cn('h-8 rounded-none border-b-2 border-transparent px-3 text-muted-foreground hover:text-foreground', selected && 'border-primary font-medium text-foreground'), children: [label, shared ? _jsx(Users, { className: "size-3.5 opacity-60", "aria-label": t('search.shared') }) : null] }));
37
+ const shown = views.slice(0, VISIBLE);
38
+ const more = views.slice(VISIBLE);
39
+ return (_jsxs("nav", { "aria-label": t('search.savedViews'), className: "-mb-1 flex items-end gap-1 border-b", children: [tab(t('search.allRecords'), current === '' && active === undefined, () => onChange({})), shown.map((v) => (_jsx("span", { className: "contents", children: tab(String(v['name']), v === active, () => onChange(stateOf(v)), v['shared'] === true) }, v.id))), more.length === 0 ? null : (_jsxs(Menu, { children: [_jsx(MenuTrigger, { asChild: true, children: _jsxs(Button, { size: "sm", variant: "ghost", className: cn('h-8 rounded-none border-b-2 border-transparent px-3 text-muted-foreground', active !== undefined && more.includes(active) && 'border-primary text-foreground'), children: [active !== undefined && more.includes(active)
40
+ ? String(active['name'])
41
+ : t('search.moreViews', { count: more.length }), _jsx(ChevronDown, { className: "size-3.5", "aria-hidden": true })] }) }), _jsx(MenuContent, { align: "start", children: more.map((v) => (_jsx(MenuItem, { onSelect: () => onChange(stateOf(v)), children: String(v['name']) }, v.id))) })] }))] }));
42
+ }
@@ -0,0 +1,6 @@
1
+ import { type ReactElement } from 'react';
2
+ import { type KoshApi } from '../api.js';
3
+ export declare function Activate({ api, onSignedIn, }: {
4
+ readonly api: KoshApi;
5
+ readonly onSignedIn: () => void;
6
+ }): ReactElement;
@@ -0,0 +1,39 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The invitation page (RFC 0019 §3.2): an invited person chooses their password (at least 12 characters) and is
4
+ * signed in. A used, replaced or expired link says so.
5
+ */
6
+ import { useMutation, useQueryClient } from '@tanstack/react-query';
7
+ import { useId, useState } from 'react';
8
+ import { useTranslation } from 'react-i18next';
9
+ import { ApiError } from '../api.js';
10
+ import { Alert, Button, Card, Input, Label } from '../components/primitives.js';
11
+ export function Activate({ api, onSignedIn, }) {
12
+ const { t } = useTranslation();
13
+ const queryClient = useQueryClient();
14
+ const id = useId();
15
+ const token = new URLSearchParams(window.location.search).get('token') ?? '';
16
+ const [password, setPassword] = useState('');
17
+ const [repeat, setRepeat] = useState('');
18
+ const activate = useMutation({
19
+ mutationFn: async () => {
20
+ const { login } = await api.call('POST', '/api/v1/auth/activate', {
21
+ token,
22
+ password,
23
+ });
24
+ await api.login(login, password);
25
+ },
26
+ onSuccess: async () => {
27
+ await queryClient.invalidateQueries();
28
+ onSignedIn();
29
+ },
30
+ });
31
+ const mismatch = repeat !== '' && repeat !== password;
32
+ return (_jsx("main", { className: "grid min-h-screen place-items-center bg-background p-4", children: _jsxs(Card, { className: "w-full max-w-sm p-6", children: [_jsx("h1", { className: "mb-1 text-xl font-semibold", children: t('auth.activateTitle') }), _jsx("p", { className: "mb-5 text-sm text-muted-foreground", children: t('auth.activateIntro') }), token === '' ? (_jsx(Alert, { children: t('auth.activateNoToken') })) : (_jsxs("form", { className: "grid gap-4", onSubmit: (e) => {
33
+ e.preventDefault();
34
+ if (!mismatch && password.length >= 12)
35
+ activate.mutate();
36
+ }, children: [activate.error === null ? null : (_jsx(Alert, { children: activate.error instanceof ApiError
37
+ ? activate.error.message
38
+ : String(activate.error) })), _jsxs("div", { className: "grid gap-1", children: [_jsx(Label, { htmlFor: `${id}-password`, children: t('auth.newPassword') }), _jsx(Input, { id: `${id}-password`, type: "password", autoComplete: "new-password", minLength: 12, required: true, value: password, onChange: (e) => setPassword(e.target.value) }), _jsx("p", { className: "text-xs text-muted-foreground", children: t('auth.passwordRule') })] }), _jsxs("div", { className: "grid gap-1", children: [_jsx(Label, { htmlFor: `${id}-repeat`, children: t('auth.repeatPassword') }), _jsx(Input, { id: `${id}-repeat`, type: "password", autoComplete: "new-password", required: true, value: repeat, "aria-invalid": mismatch ? true : undefined, onChange: (e) => setRepeat(e.target.value) }), mismatch ? _jsx("p", { className: "text-xs text-danger", children: t('auth.mismatch') }) : null] }), _jsx(Button, { type: "submit", variant: "primary", busy: activate.isPending, disabled: password.length < 12 || mismatch, children: t('auth.activate') })] }))] }) }));
39
+ }
@@ -3,9 +3,15 @@ import type { KoshApi } from '../api.js';
3
3
  /** Opens the palette from anywhere in the shell (the home page's search box). */
4
4
  export declare const PaletteContext: Context<() => void>;
5
5
  export declare function usePalette(): () => void;
6
- export declare function CommandPalette({ open, onOpenChange, api, onShortcuts, }: {
6
+ export declare function CommandPalette({ open, onOpenChange, api, onShortcuts, companies, onSwitchCompany, }: {
7
7
  readonly open: boolean;
8
8
  readonly onOpenChange: (open: boolean) => void;
9
9
  readonly api: KoshApi;
10
10
  readonly onShortcuts: () => void;
11
+ /** The user's other companies, offered as "Switch to …" (RFC 0019 §3.4). */
12
+ readonly companies?: readonly {
13
+ readonly id: number;
14
+ readonly name: string;
15
+ }[];
16
+ readonly onSwitchCompany?: (id: number) => void;
11
17
  }): ReactElement;
@@ -7,7 +7,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
7
7
  */
8
8
  import { useQuery } from '@tanstack/react-query';
9
9
  import { useNavigate } from '@tanstack/react-router';
10
- import { ArrowRight, FileText, History, Home, Keyboard, Monitor, Moon, Plus, Search, Sun, } from 'lucide-react';
10
+ import { ArrowRight, Building2, FileText, History, Home, Keyboard, Monitor, Moon, Plus, Search, Sun, UserRound, } from 'lucide-react';
11
11
  import { Dialog as RDialog } from 'radix-ui';
12
12
  import { createContext, useContext, useEffect, useId, useMemo, useRef, useState, } from 'react';
13
13
  import { useTranslation } from 'react-i18next';
@@ -57,7 +57,7 @@ function score(text, query) {
57
57
  return 2;
58
58
  return t.includes(q) ? 1 : 0;
59
59
  }
60
- export function CommandPalette({ open, onOpenChange, api, onShortcuts, }) {
60
+ export function CommandPalette({ open, onOpenChange, api, onShortcuts, companies = [], onSwitchCompany, }) {
61
61
  const { t } = useTranslation();
62
62
  const { uid, setTheme } = useKosh();
63
63
  const navigate = useNavigate();
@@ -199,6 +199,18 @@ export function CommandPalette({ open, onOpenChange, api, onShortcuts, }) {
199
199
  icon: _jsx(Monitor, { className: "size-4", "aria-hidden": true }),
200
200
  run: () => setTheme('system'),
201
201
  },
202
+ ...companies.map((c) => ({
203
+ id: `cmd:company:${c.id}`,
204
+ label: t('palette.switchCompany', { name: c.name }),
205
+ icon: _jsx(Building2, { className: "size-4", "aria-hidden": true }),
206
+ run: () => onSwitchCompany?.(c.id),
207
+ })),
208
+ {
209
+ id: 'cmd:profile',
210
+ label: t('palette.profile'),
211
+ icon: _jsx(UserRound, { className: "size-4", "aria-hidden": true }),
212
+ run: () => void navigate({ to: '/me/$page', params: { page: 'settings.Profile' } }),
213
+ },
202
214
  {
203
215
  id: 'cmd:shortcuts',
204
216
  label: t('palette.shortcuts'),
@@ -230,6 +242,8 @@ export function CommandPalette({ open, onOpenChange, api, onShortcuts, }) {
230
242
  setTheme,
231
243
  t,
232
244
  onShortcuts,
245
+ companies,
246
+ onSwitchCompany,
233
247
  ]);
234
248
  useEffect(() => {
235
249
  if (active >= items.length)
@@ -0,0 +1,5 @@
1
+ import type { ReactElement } from 'react';
2
+ import { type KoshApi } from '../api.js';
3
+ export declare function Consent({ api }: {
4
+ readonly api: KoshApi;
5
+ }): ReactElement;
@@ -0,0 +1,35 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The OAuth consent page (RFC 0017 §5.2), at `/oauth/authorize`: an app asks to act for the signed-in user. It names
4
+ * the app, where it sends the user back, and what it may do in words; *Allow* or *Cancel* — either returns to the
5
+ * app. The server checks the request (registered redirect, allowed scopes, PKCE) before anything is shown; signed out,
6
+ * the person signs in and comes back here.
7
+ */
8
+ import { useMutation, useQuery } from '@tanstack/react-query';
9
+ import { ExternalLink, ShieldCheck } from 'lucide-react';
10
+ import { useTranslation } from 'react-i18next';
11
+ import { ApiError } from '../api.js';
12
+ import { Alert, Button, Card, Spinner } from '../components/primitives.js';
13
+ export function Consent({ api }) {
14
+ const { t } = useTranslation();
15
+ const search = window.location.search;
16
+ const params = Object.fromEntries(new URLSearchParams(search));
17
+ const me = useQuery({ queryKey: ['me'], queryFn: () => api.me(), retry: false });
18
+ const screen = useQuery({
19
+ queryKey: ['oauth', 'consent', search],
20
+ queryFn: () => api.call('GET', `/api/v1/oauth/authorize${search}`),
21
+ enabled: me.isSuccess,
22
+ retry: false,
23
+ });
24
+ const decide = useMutation({
25
+ mutationFn: (decision) => api.call('POST', '/api/v1/oauth/authorize', { ...params, decision }),
26
+ onSuccess: ({ redirect }) => window.location.assign(redirect),
27
+ });
28
+ if (me.error instanceof ApiError && me.error.status === 401) {
29
+ // Signed out: sign in, then back here with the same request.
30
+ window.location.assign(`/login?next=${encodeURIComponent(`/oauth/authorize${search}`)}`);
31
+ return _jsx(Spinner, { label: t('app.loading') });
32
+ }
33
+ const error = me.error ?? screen.error ?? decide.error;
34
+ return (_jsx("main", { className: "grid min-h-screen place-items-center bg-background p-4", children: _jsx(Card, { className: "grid w-full max-w-md gap-5 p-6", children: error !== null ? (_jsx(Alert, { children: error instanceof ApiError ? error.message : String(error) })) : screen.data === undefined || me.data === undefined ? (_jsx(Spinner, { label: t('app.loading') })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "grid gap-1", children: [_jsx("span", { "aria-hidden": true, className: "mb-2 grid size-10 place-items-center rounded-lg bg-accent text-accent-foreground", children: _jsx(ShieldCheck, { className: "size-5" }) }), _jsx("h1", { className: "text-xl font-semibold", children: t('oauth.title', { app: screen.data.app.name }) }), _jsx("p", { className: "text-sm text-muted-foreground", children: t('oauth.as', { name: me.data.name, login: me.data.login }) })] }), _jsxs("div", { className: "grid gap-2", children: [_jsx("h2", { className: "text-sm font-medium", children: t('oauth.may') }), _jsx("ul", { className: "grid gap-1.5 rounded-md border p-3 text-sm", children: screen.data.scopes.map((s) => (_jsxs("li", { className: "flex items-center gap-2", children: [_jsx(ShieldCheck, { className: "size-4 text-success", "aria-hidden": true }), s.label] }, s.scope))) }), _jsx("p", { className: "text-xs text-muted-foreground", children: t('oauth.limits') })] }), _jsxs("p", { className: "text-sm text-muted-foreground", children: [t('oauth.back', { host: screen.data.redirectHost }), screen.data.app.homepage === null ? null : (_jsxs(_Fragment, { children: [' ', _jsxs("a", { className: "inline-flex items-center gap-1 text-primary underline", href: screen.data.app.homepage, target: "_blank", rel: "noreferrer noopener", children: [t('oauth.homepage'), _jsx(ExternalLink, { className: "size-3", "aria-hidden": true })] })] }))] }), screen.data.alreadyAllowed ? _jsx(Alert, { tone: "info", children: t('oauth.already') }) : null, _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(Button, { busy: decide.isPending && decide.variables === 'deny', onClick: () => decide.mutate('deny'), children: t('oauth.cancel') }), _jsx(Button, { variant: "primary", busy: decide.isPending && decide.variables === 'allow', onClick: () => decide.mutate('allow'), children: t('oauth.allow') })] })] })) }) }));
35
+ }
@@ -1,12 +1,15 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
- * The home page (RFC 0010 §3.8): a greeting, the palette's search box, what needs the user's attention (approvals
4
- * they may decide, activities due), recently opened records, and the apps. Everything on it comes from routes the
5
- * server already filters per user; apps that are not installed (Permissions, Messaging) simply contribute nothing.
3
+ * Today, the home page (RFC 0010 §3.8, RFC 0018 §4.4): a greeting with the date, the palette's search box, what needs
4
+ * the user — each approval waiting for them by record, the activities due, unread mentions — as a list with its
5
+ * action, recently opened records, the saved views they keep (lists, pivots, charts: one click back to them), and the
6
+ * apps. Everything comes from routes the server already filters per user; apps that are not installed (Permissions,
7
+ * Messaging, Notifications) simply contribute nothing.
6
8
  */
9
+ import { encodeSearch } from '../../search/index.js';
7
10
  import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
8
11
  import { Link, useNavigate } from '@tanstack/react-router';
9
- import { CalendarClock, CircleCheckBig, History, Search } from 'lucide-react';
12
+ import { AtSign, BarChart3, CalendarClock, CircleCheckBig, History, List, Search, ShieldCheck, Star, Table2, } from 'lucide-react';
10
13
  import { useTranslation } from 'react-i18next';
11
14
  import { AppIcon } from '../components/AppIcon.js';
12
15
  import { Badge, Button, Card, Skeleton } from '../components/primitives.js';
@@ -22,6 +25,15 @@ const TINTS = [
22
25
  'bg-primary/10 text-primary',
23
26
  'bg-danger/10 text-danger',
24
27
  ];
28
+ const VIEW_ICONS = {
29
+ pivot: Table2,
30
+ graph: BarChart3,
31
+ chart: BarChart3,
32
+ };
33
+ /** One row of "Needs your attention": an icon, what it is, and its actions. */
34
+ function Item({ icon, title, detail, children, }) {
35
+ return (_jsxs("li", { className: "flex items-center gap-3 px-4 py-3", children: [_jsx("span", { "aria-hidden": true, className: "grid size-8 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground", children: icon }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("p", { className: "truncate text-sm font-medium", children: title }), detail === undefined ? null : (_jsx("p", { className: "truncate text-xs text-muted-foreground", children: detail }))] }), _jsx("div", { className: "flex shrink-0 items-center gap-1", children: children })] }));
36
+ }
25
37
  export function Home() {
26
38
  const { api, uid, format } = useKosh();
27
39
  const { t } = useTranslation();
@@ -34,10 +46,25 @@ export function Home() {
34
46
  // Each is a module's route: absent (404) when the module is not installed, so an absent list is empty.
35
47
  const approvals = useQuery({
36
48
  queryKey: ['home', 'approvals'],
37
- queryFn: () => api
38
- .call('GET', '/api/v1/approvals/inbox')
39
- .then((r) => r.requests)
40
- .catch(() => []),
49
+ queryFn: async () => {
50
+ const requests = await api
51
+ .call('GET', '/api/v1/approvals/inbox')
52
+ .then((r) => r.requests)
53
+ .catch(() => []);
54
+ // The records' names, one request per model.
55
+ const names = new Map();
56
+ for (const model of new Set(requests.map((r) => r.targetModel)))
57
+ for (const n of await api
58
+ .names(model, {
59
+ ids: requests.filter((r) => r.targetModel === model).map((r) => r.targetId),
60
+ })
61
+ .catch(() => []))
62
+ names.set(`${model}:${n.id}`, n.displayName);
63
+ return requests.map((r) => ({
64
+ ...r,
65
+ record: names.get(`${r.targetModel}:${r.targetId}`) ?? `#${r.targetId}`,
66
+ }));
67
+ },
41
68
  });
42
69
  const activities = useQuery({
43
70
  queryKey: ['home', 'activities'],
@@ -46,13 +73,38 @@ export function Home() {
46
73
  .then((r) => r.activities)
47
74
  .catch(() => []),
48
75
  });
76
+ const mentions = useQuery({
77
+ queryKey: ['home', 'mentions'],
78
+ queryFn: () => api
79
+ .call('GET', '/api/v1/notifications?unread=true&limit=50')
80
+ .then((r) => r.items.filter((n) => n.kind === 'mention'))
81
+ .catch(() => []),
82
+ });
83
+ const saved = useQuery({
84
+ queryKey: ['home', 'saved-views'],
85
+ queryFn: () => api
86
+ .list('kosh.saved_view', {
87
+ fields: ['name', 'targetModel', 'viewType', 'state'],
88
+ limit: 12,
89
+ })
90
+ .then((r) => r.items)
91
+ .catch(() => []),
92
+ });
49
93
  const done = useMutation({
50
94
  mutationFn: (id) => api.call('POST', `/api/v1/activities/${id}/done`, {}),
51
95
  onSuccess: () => queryClient.invalidateQueries({ queryKey: ['home', 'activities'] }),
52
96
  });
53
- const hour = new Date().getHours();
97
+ const read = useMutation({
98
+ mutationFn: (id) => api.call('POST', `/api/v1/notifications/${id}/read`, {}),
99
+ onSuccess: () => {
100
+ void queryClient.invalidateQueries({ queryKey: ['home', 'mentions'] });
101
+ void queryClient.invalidateQueries({ queryKey: ['notifications'] });
102
+ },
103
+ });
104
+ const now = new Date();
105
+ const hour = now.getHours();
54
106
  const greeting = hour < 12 ? 'morning' : hour < 18 ? 'afternoon' : 'evening';
55
- const today = new Date().toISOString().slice(0, 10);
107
+ const today = now.toISOString().slice(0, 10);
56
108
  const due = (activities.data ?? []).filter((a) => a.deadline <= today);
57
109
  const open = (model, id) => {
58
110
  const route = routes.data?.[model];
@@ -63,13 +115,30 @@ export function Home() {
63
115
  params: { app: route.app, action: route.action, id: String(id) },
64
116
  });
65
117
  };
118
+ const openSaved = (view) => {
119
+ const route = routes.data?.[view.targetModel];
120
+ if (route?.app === undefined)
121
+ return;
122
+ const q = encodeSearch((view.state ?? {}));
123
+ void navigate({
124
+ to: '/$app/a/$action',
125
+ params: { app: route.app, action: route.action },
126
+ search: { view: view.viewType, ...(q === '' ? {} : { q }) },
127
+ });
128
+ };
66
129
  const categories = [...new Set((apps.data ?? []).map((a) => a.category ?? ''))].sort((a, b) => a.localeCompare(b));
67
130
  const tint = (a) => TINTS[categories.indexOf(a.category ?? '') % TINTS.length];
68
131
  const recent = recentRecords(uid);
69
- const waiting = (approvals.data?.length ?? 0) + due.length;
70
- return (_jsxs("div", { className: "mx-auto grid max-w-5xl gap-8 py-4", children: [_jsxs("header", { className: "grid gap-4", children: [_jsx("h1", { className: "text-2xl font-semibold", children: me.data === undefined ? (_jsx(Skeleton, { className: "h-8 w-64" })) : (t(`home.${greeting}`, { name: me.data.name.split(' ')[0] })) }), _jsxs("button", { type: "button", onClick: openPalette, className: "flex h-12 w-full items-center gap-3 rounded-xl border bg-surface px-4 text-start text-muted-foreground shadow-sm hover:bg-muted", children: [_jsx(Search, { className: "size-5", "aria-hidden": true }), _jsx("span", { className: "flex-1", children: t('home.search') }), _jsx("kbd", { className: "rounded border px-1.5 text-xs", children: "Ctrl K" })] })] }), _jsxs("section", { "aria-labelledby": "home-attention", className: "grid gap-3", children: [_jsx("h2", { id: "home-attention", className: "text-sm font-semibold text-muted-foreground", children: t('home.attention') }), approvals.isPending || activities.isPending ? (_jsx(Skeleton, { className: "h-16" })) : waiting === 0 ? (_jsxs(Card, { className: "flex items-center gap-3 p-4 text-sm text-muted-foreground", children: [_jsx(CircleCheckBig, { className: "size-5 text-success", "aria-hidden": true }), t('home.nothing')] })) : (_jsxs("ul", { className: "grid gap-2 sm:grid-cols-2", children: [(approvals.data?.length ?? 0) === 0 ? null : (_jsx("li", { children: _jsxs(Card, { className: "flex items-center justify-between gap-3 p-4", children: [_jsxs("div", { className: "grid gap-1", children: [_jsx("span", { className: "font-medium", children: t('home.approvals', { count: approvals.data?.length ?? 0 }) }), _jsx("span", { className: "text-xs text-muted-foreground", children: [...new Set((approvals.data ?? []).map((a) => a.levelName))].join(' · ') })] }), approvals.data?.[0] === undefined ? null : (_jsx(Button, { size: "sm", onClick: () => {
71
- const first = approvals.data?.[0];
72
- if (first !== undefined)
73
- open(first.targetModel, first.targetId);
74
- }, children: t('home.open') }))] }) })), due.length === 0 ? null : (_jsx("li", { children: _jsxs(Card, { className: "grid gap-2 p-4", children: [_jsx("span", { className: "font-medium", children: t('home.activities', { count: due.length }) }), _jsx("ul", { className: "grid gap-1 text-sm", children: due.slice(0, 5).map((a) => (_jsxs("li", { className: "flex items-center justify-between gap-2", children: [_jsxs("button", { type: "button", className: "flex min-w-0 items-center gap-2 text-start hover:underline", onClick: () => open(a.model, a.resId), children: [_jsx(CalendarClock, { className: "size-4 shrink-0 text-muted-foreground", "aria-hidden": true }), _jsxs("span", { className: "truncate", children: [a.summary ?? a.type.name, " \u00B7 ", a.record] }), a.deadline < today ? (_jsx(Badge, { tone: "danger", children: t('home.overdue') })) : null] }), _jsx(Button, { size: "sm", variant: "ghost", busy: done.isPending && done.variables === a.id, onClick: () => done.mutate(a.id), children: t('home.done') })] }, a.id))) })] }) }))] }))] }), recent.length === 0 ? null : (_jsxs("section", { "aria-labelledby": "home-recent", className: "grid gap-3", children: [_jsx("h2", { id: "home-recent", className: "text-sm font-semibold text-muted-foreground", children: t('home.recent') }), _jsx("ul", { className: "flex flex-wrap gap-2", children: recent.slice(0, 8).map((r) => (_jsx("li", { children: _jsxs(Link, { to: "/$app/a/$action/$id", params: { app: r.app, action: r.action, id: String(r.id) }, className: "flex items-center gap-2 rounded-full border bg-surface px-3 py-1.5 text-sm hover:bg-muted", children: [_jsx(History, { className: "size-3.5 text-muted-foreground", "aria-hidden": true }), _jsx("span", { className: "max-w-56 truncate", children: r.title }), _jsx("span", { className: "text-xs text-muted-foreground", children: format.date(new Date(r.at).toISOString().slice(0, 10)) })] }) }, `${r.model}:${r.id}`))) })] })), _jsxs("section", { "aria-labelledby": "apps-title", className: "grid gap-3", children: [_jsx("h2", { id: "apps-title", className: "text-sm font-semibold text-muted-foreground", children: t('home.apps') }), apps.isPending ? (_jsx("div", { className: "grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-3", children: [0, 1, 2, 3].map((i) => (_jsx(Skeleton, { className: "h-28" }, i))) })) : (apps.data ?? []).length === 0 ? (_jsx("p", { className: "text-muted-foreground", children: t('shell.noApps') })) : (_jsx("ul", { className: "grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-3", children: (apps.data ?? []).map((a) => (_jsx("li", { children: _jsx(Link, { to: "/$app", params: { app: a.code }, className: "block h-full", children: _jsxs(Card, { className: "flex h-full flex-col gap-3 p-4 hover:bg-muted", children: [_jsx(AppIcon, { name: a.icon ?? a.code, className: cn('size-11', tint(a)) }), _jsxs("div", { className: "grid gap-0.5", children: [_jsx("span", { className: "font-medium", children: a.label }), a.summary === undefined ? null : (_jsx("span", { className: "line-clamp-2 text-xs text-muted-foreground", children: a.summary }))] })] }) }) }, a.code))) }))] })] }));
132
+ const waiting = (approvals.data?.length ?? 0) + due.length + (mentions.data?.length ?? 0);
133
+ const views = (saved.data ?? []).filter((v) => routes.data?.[v.targetModel]?.app !== undefined);
134
+ return (_jsxs("div", { className: "mx-auto grid max-w-5xl gap-8 py-4", children: [_jsxs("header", { className: "grid gap-4", children: [_jsxs("div", { children: [_jsx("p", { className: "text-sm font-medium text-muted-foreground", children: t('home.today', { date: format.date(today) }) }), _jsx("h1", { className: "text-2xl font-semibold", children: me.data === undefined ? (_jsx(Skeleton, { className: "h-8 w-64" })) : (t(`home.${greeting}`, { name: me.data.name.split(' ')[0] })) })] }), _jsxs("button", { type: "button", onClick: openPalette, className: "flex h-12 w-full items-center gap-3 rounded-xl border bg-surface px-4 text-start text-muted-foreground shadow-sm hover:bg-muted", children: [_jsx(Search, { className: "size-5", "aria-hidden": true }), _jsx("span", { className: "flex-1", children: t('home.search') }), _jsx("kbd", { className: "rounded border px-1.5 text-xs", children: "Ctrl K" })] })] }), _jsxs("section", { "aria-labelledby": "home-attention", className: "grid gap-3", children: [_jsxs("h2", { id: "home-attention", className: "flex items-center gap-2 text-sm font-semibold text-muted-foreground", children: [t('home.attention'), waiting === 0 ? null : _jsx(Badge, { tone: "info", children: waiting })] }), approvals.isPending || activities.isPending || mentions.isPending ? (_jsx(Skeleton, { className: "h-16" })) : waiting === 0 ? (_jsxs(Card, { className: "flex items-center gap-3 p-4 text-sm text-muted-foreground", children: [_jsx(CircleCheckBig, { className: "size-5 text-success", "aria-hidden": true }), t('home.nothing')] })) : (_jsx(Card, { className: "p-0", children: _jsxs("ul", { className: "divide-y", children: [(approvals.data ?? []).map((a) => (_jsx(Item, { icon: _jsx(ShieldCheck, { className: "size-4" }), title: t('home.approve', { record: a.record }), detail: a.levelName, children: _jsx(Button, { size: "sm", onClick: () => open(a.targetModel, a.targetId), children: t('home.review') }) }, `approval-${a.id}`))), due.map((a) => (_jsxs(Item, { icon: _jsx(CalendarClock, { className: "size-4" }), title: _jsxs("span", { className: "flex items-center gap-2", children: [_jsx("span", { className: "truncate", children: a.summary ?? a.type.name }), a.deadline < today ? (_jsx(Badge, { tone: "danger", children: t('home.overdue') })) : (_jsx(Badge, { tone: "warning", children: t('home.dueToday') }))] }), detail: `${a.type.name} · ${a.record}`, children: [_jsx(Button, { size: "sm", variant: "ghost", onClick: () => open(a.model, a.resId), children: t('home.open') }), _jsx(Button, { size: "sm", busy: done.isPending && done.variables === a.id, onClick: () => done.mutate(a.id), children: t('home.done') })] }, `activity-${a.id}`))), (mentions.data ?? []).map((m) => (_jsx(Item, { icon: _jsx(AtSign, { className: "size-4" }), title: t('home.mentioned', {
135
+ actor: m.params.actor ?? '',
136
+ record: m.params.record ?? '',
137
+ }), detail: m.params.preview ?? format.datetime(m.createdAt), children: _jsx(Button, { size: "sm", onClick: () => {
138
+ read.mutate(m.id);
139
+ open(m.model, m.resId);
140
+ }, children: t('home.open') }) }, `mention-${m.id}`)))] }) }))] }), recent.length === 0 ? null : (_jsxs("section", { "aria-labelledby": "home-recent", className: "grid gap-3", children: [_jsx("h2", { id: "home-recent", className: "text-sm font-semibold text-muted-foreground", children: t('home.recent') }), _jsx("ul", { className: "flex flex-wrap gap-2", children: recent.slice(0, 8).map((r) => (_jsx("li", { children: _jsxs(Link, { to: "/$app/a/$action/$id", params: { app: r.app, action: r.action, id: String(r.id) }, className: "flex items-center gap-2 rounded-full border bg-surface px-3 py-1.5 text-sm hover:bg-muted", children: [_jsx(History, { className: "size-3.5 text-muted-foreground", "aria-hidden": true }), _jsx("span", { className: "max-w-56 truncate", children: r.title }), _jsx("span", { className: "text-xs text-muted-foreground", children: format.date(new Date(r.at).toISOString().slice(0, 10)) })] }) }, `${r.model}:${r.id}`))) })] })), views.length === 0 ? null : (_jsxs("section", { "aria-labelledby": "home-saved", className: "grid gap-3", children: [_jsx("h2", { id: "home-saved", className: "text-sm font-semibold text-muted-foreground", children: t('home.saved') }), _jsx("ul", { className: "grid grid-cols-[repeat(auto-fill,minmax(14rem,1fr))] gap-2", children: views.map((v) => {
141
+ const Icon = VIEW_ICONS[v.viewType] ?? List;
142
+ return (_jsx("li", { children: _jsxs("button", { type: "button", onClick: () => openSaved(v), className: "flex w-full items-center gap-2 rounded-lg border bg-surface px-3 py-2 text-start text-sm hover:bg-muted", children: [_jsx(Icon, { className: "size-4 shrink-0 text-muted-foreground", "aria-hidden": true }), _jsx("span", { className: "min-w-0 flex-1 truncate", children: v.name }), _jsx(Star, { className: "size-3.5 shrink-0 text-muted-foreground", "aria-hidden": true })] }) }, v.id));
143
+ }) })] })), _jsxs("section", { "aria-labelledby": "apps-title", className: "grid gap-3", children: [_jsx("h2", { id: "apps-title", className: "text-sm font-semibold text-muted-foreground", children: t('home.apps') }), apps.isPending ? (_jsx("div", { className: "grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-3", children: [0, 1, 2, 3].map((i) => (_jsx(Skeleton, { className: "h-16" }, i))) })) : (apps.data ?? []).length === 0 ? (_jsx("p", { className: "text-muted-foreground", children: t('shell.noApps') })) : (_jsx("ul", { className: "grid grid-cols-[repeat(auto-fill,minmax(13rem,1fr))] gap-2", children: (apps.data ?? []).map((a) => (_jsx("li", { children: _jsx(Link, { to: "/$app", params: { app: a.code }, className: "block h-full", children: _jsxs(Card, { className: "flex h-full items-center gap-3 p-3 hover:bg-muted", children: [_jsx(AppIcon, { name: a.icon ?? a.code, className: cn('size-9', tint(a)) }), _jsxs("div", { className: "grid min-w-0 gap-0.5", children: [_jsx("span", { className: "truncate font-medium", children: a.label }), a.summary === undefined ? null : (_jsx("span", { className: "truncate text-xs text-muted-foreground", children: a.summary }))] })] }) }) }, a.code))) }))] })] }));
75
144
  }
@@ -0,0 +1,27 @@
1
+ import { type ReactElement, type ReactNode } from 'react';
2
+ import type { AppEntry, KoshApi } from '../api.js';
3
+ /** Whether the sidebar is collapsed to its rail, remembered per browser. */
4
+ export declare function useSidebarCollapsed(): [boolean, (collapsed: boolean) => void];
5
+ export declare function Sidebar({ api, apps, current, company, collapsed, onCollapse, onSearch, top, bottom, onNavigate, }: {
6
+ readonly api: KoshApi;
7
+ readonly apps: readonly AppEntry[];
8
+ /** The open app (from the URL). */
9
+ readonly current: string | undefined;
10
+ readonly company: number;
11
+ readonly collapsed: boolean;
12
+ /** Absent in the drawer (it closes instead). */
13
+ readonly onCollapse?: ((collapsed: boolean) => void) | undefined;
14
+ readonly onSearch: () => void;
15
+ /** The company block. */
16
+ readonly top: ReactNode;
17
+ /** The user menu. */
18
+ readonly bottom: ReactNode;
19
+ /** After following a link (the drawer closes). */
20
+ readonly onNavigate?: (() => void) | undefined;
21
+ }): ReactElement;
22
+ /** The sidebar as a drawer on small screens. */
23
+ export declare function SidebarDrawer({ open, onOpenChange, children, }: {
24
+ readonly open: boolean;
25
+ readonly onOpenChange: (open: boolean) => void;
26
+ readonly children: ReactNode;
27
+ }): ReactElement;
@@ -0,0 +1,77 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The Workspace sidebar (RFC 0018 §4.1), shown inside an app: the company at the top, search, Home, then the open
4
+ * app — its name switches to another app — with only its own sections, and the user at the bottom. The home page has
5
+ * no sidebar. It collapses to an icon rail (`Ctrl+B`, remembered per browser) and becomes a drawer on small screens.
6
+ * The server decides which apps and menus the user sees; the sidebar shows them.
7
+ */
8
+ import { useQuery } from '@tanstack/react-query';
9
+ import { Link, useNavigate } from '@tanstack/react-router';
10
+ import { ChevronsUpDown, House, PanelLeftClose, PanelLeftOpen, Search, X } from 'lucide-react';
11
+ import { Dialog } from 'radix-ui';
12
+ import { useState } from 'react';
13
+ import { useTranslation } from 'react-i18next';
14
+ import { AppIcon } from '../components/AppIcon.js';
15
+ import { Button, Menu, MenuCheckboxItem, MenuContent, MenuLabel, MenuTrigger, } from '../components/primitives.js';
16
+ import { cn } from '../lib/cn.js';
17
+ const SIDEBAR_KEY = 'kosh.sidebar';
18
+ /** Whether the sidebar is collapsed to its rail, remembered per browser. */
19
+ export function useSidebarCollapsed() {
20
+ const [collapsed, setCollapsed] = useState(() => {
21
+ try {
22
+ return localStorage.getItem(SIDEBAR_KEY) === 'collapsed';
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ });
28
+ const set = (next) => {
29
+ setCollapsed(next);
30
+ try {
31
+ if (next)
32
+ localStorage.setItem(SIDEBAR_KEY, 'collapsed');
33
+ else
34
+ localStorage.removeItem(SIDEBAR_KEY);
35
+ }
36
+ catch {
37
+ // storage unavailable: the choice lasts for this page
38
+ }
39
+ };
40
+ return [collapsed, set];
41
+ }
42
+ /** An app's entries as the sidebar lists them: its top level, with groups (Reporting, Configuration) kept. */
43
+ function entriesOf(menus) {
44
+ return menus.flatMap((root) => (root.action === undefined ? [...root.children] : [root]));
45
+ }
46
+ function SectionLink({ app, node, onNavigate, }) {
47
+ if (node.action === undefined)
48
+ return null;
49
+ return (_jsx(Link, { to: "/$app/a/$action", params: { app, action: node.action }, onClick: onNavigate, className: "block truncate rounded-md px-2 py-1.5 text-sm text-muted-foreground hover:bg-muted hover:text-foreground", activeProps: {
50
+ 'aria-current': 'page',
51
+ className: 'bg-accent font-medium text-accent-foreground hover:bg-accent',
52
+ }, children: node.label }));
53
+ }
54
+ function AppSections({ api, app, company, onNavigate, }) {
55
+ const menus = useQuery({
56
+ queryKey: ['menus', app, company],
57
+ queryFn: () => api.menus(app),
58
+ });
59
+ return (_jsx("ul", { className: "grid gap-0.5 pt-1 pb-2", children: entriesOf(menus.data ?? []).map((node) => node.children.length > 0 ? (_jsxs("li", { className: "pt-1", children: [_jsx("p", { className: "px-2 pb-0.5 text-xs font-medium tracking-wide text-muted-foreground uppercase", children: node.label }), _jsx("ul", { className: "grid gap-0.5", children: node.children.map((child) => (_jsx("li", { children: _jsx(SectionLink, { app: app, node: child, onNavigate: onNavigate }) }, child.id))) })] }, node.id)) : (_jsx("li", { children: _jsx(SectionLink, { app: app, node: node, onNavigate: onNavigate }) }, node.id))) }));
60
+ }
61
+ export function Sidebar({ api, apps, current, company, collapsed, onCollapse, onSearch, top, bottom, onNavigate, }) {
62
+ const { t } = useTranslation();
63
+ const navigate = useNavigate();
64
+ const app = apps.find((a) => a.code === current);
65
+ const item = 'flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-sm hover:bg-muted focus-visible:outline-2';
66
+ return (_jsxs("div", { className: "flex h-full flex-col gap-1 p-2", children: [_jsx("div", { className: cn('pb-1', collapsed && 'flex justify-center'), children: top }), _jsxs("button", { type: "button", className: cn(item, 'text-muted-foreground', collapsed && 'justify-center'), onClick: onSearch, "aria-keyshortcuts": "Control+K Meta+K", "aria-label": collapsed ? t('palette.button') : undefined, title: collapsed ? t('palette.button') : undefined, children: [_jsx(Search, { className: "size-4 shrink-0", "aria-hidden": true }), collapsed ? null : (_jsxs(_Fragment, { children: [_jsx("span", { className: "flex-1 text-start", children: t('palette.button') }), _jsx("kbd", { className: "rounded border bg-surface px-1 text-[0.65rem]", children: "Ctrl K" })] }))] }), _jsxs(Link, { to: "/", onClick: onNavigate, className: cn(item, collapsed && 'justify-center'), activeOptions: { exact: true }, activeProps: { 'aria-current': 'page', className: 'bg-muted font-medium' }, "aria-label": collapsed ? t('shell.home') : undefined, title: collapsed ? t('shell.home') : undefined, children: [_jsx(House, { className: "size-4 shrink-0", "aria-hidden": true }), collapsed ? null : t('shell.home')] }), _jsx("nav", { "aria-label": t('shell.menu'), className: "-mx-2 mt-2 flex-1 overflow-y-auto border-t px-2 pt-2", children: app === undefined ? null : (_jsxs(_Fragment, { children: [_jsxs(Menu, { children: [_jsx(MenuTrigger, { asChild: true, children: _jsxs("button", { type: "button", "aria-label": t('shell.appNamed', { app: app.label }), title: collapsed ? app.label : undefined, className: cn(item, 'font-semibold', collapsed && 'justify-center'), children: [_jsx(AppIcon, { name: app.icon ?? app.code, className: "size-6 rounded-md [&_svg]:size-3.5" }), collapsed ? null : (_jsxs(_Fragment, { children: [_jsx("span", { className: "flex-1 truncate text-start", children: app.label }), _jsx(ChevronsUpDown, { className: "size-3.5 text-muted-foreground", "aria-hidden": true })] }))] }) }), _jsxs(MenuContent, { align: "start", className: "w-60", children: [_jsx(MenuLabel, { children: t('shell.apps') }), apps.map((a) => (_jsxs(MenuCheckboxItem, { checked: a.code === current, onCheckedChange: () => {
67
+ if (a.code === current)
68
+ return;
69
+ void navigate({ to: '/$app', params: { app: a.code } });
70
+ onNavigate?.();
71
+ }, children: [_jsx(AppIcon, { name: a.icon ?? a.code, className: "size-5 rounded [&_svg]:size-3" }), a.label] }, a.code)))] })] }), collapsed ? null : (_jsx(AppSections, { api: api, app: app.code, company: company, onNavigate: onNavigate }))] })) }), _jsxs("div", { className: cn('flex items-center gap-1 border-t pt-2', collapsed && 'flex-col'), children: [_jsx("div", { className: "min-w-0 flex-1", children: bottom }), onCollapse === undefined ? null : (_jsx(Button, { variant: "ghost", size: "icon", "aria-label": collapsed ? t('shell.expandSidebar') : t('shell.collapseSidebar'), title: `${collapsed ? t('shell.expandSidebar') : t('shell.collapseSidebar')} (Ctrl B)`, "aria-keyshortcuts": "Control+B Meta+B", onClick: () => onCollapse(!collapsed), children: collapsed ? (_jsx(PanelLeftOpen, { className: "rtl:rotate-180", "aria-hidden": true })) : (_jsx(PanelLeftClose, { className: "rtl:rotate-180", "aria-hidden": true })) }))] })] }));
72
+ }
73
+ /** The sidebar as a drawer on small screens. */
74
+ export function SidebarDrawer({ open, onOpenChange, children, }) {
75
+ const { t } = useTranslation();
76
+ return (_jsx(Dialog.Root, { open: open, onOpenChange: onOpenChange, children: _jsxs(Dialog.Portal, { children: [_jsx(Dialog.Overlay, { className: "fixed inset-0 z-40 bg-black/40" }), _jsxs(Dialog.Content, { className: "fixed inset-y-0 start-0 z-50 w-72 max-w-[85vw] border-e bg-surface shadow-xl outline-none", children: [_jsx(Dialog.Title, { className: "sr-only", children: t('shell.menu') }), _jsx(Dialog.Description, { className: "sr-only", children: t('shell.menuHint') }), _jsx(Dialog.Close, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon", className: "absolute end-2 top-2 z-10", "aria-label": t('shell.closeMenu'), children: _jsx(X, { "aria-hidden": true }) }) }), children] })] }) }));
77
+ }
@@ -0,0 +1,5 @@
1
+ import { type ReactElement } from 'react';
2
+ import { type KoshApi } from '../api.js';
3
+ export declare function TwoFactorSetup({ api }: {
4
+ readonly api: KoshApi;
5
+ }): ReactElement;
@@ -0,0 +1,32 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Two-factor sign-in required of everyone (RFC 0019 §3.5): until the person sets it up, the server allows nothing
4
+ * else, so the shell shows this page instead — the key for the authenticator app, a code to turn it on, the recovery
5
+ * codes once — then opens the back-office.
6
+ */
7
+ import { useMutation, useQueryClient } from '@tanstack/react-query';
8
+ import { useId, useState } from 'react';
9
+ import { useTranslation } from 'react-i18next';
10
+ import { ApiError } from '../api.js';
11
+ import { Alert, Button, Card, Input, Label } from '../components/primitives.js';
12
+ export function TwoFactorSetup({ api }) {
13
+ const { t } = useTranslation();
14
+ const id = useId();
15
+ const queryClient = useQueryClient();
16
+ const [code, setCode] = useState('');
17
+ const [codes, setCodes] = useState(null);
18
+ const start = useMutation({
19
+ mutationFn: () => api.call('POST', '/api/v1/auth/2fa/setup'),
20
+ });
21
+ const enable = useMutation({
22
+ mutationFn: () => api.call('POST', '/api/v1/auth/2fa/enable', { code }),
23
+ onSuccess: (data) => setCodes(data.recoveryCodes),
24
+ });
25
+ const error = start.error ?? enable.error;
26
+ const setup = start.data;
27
+ return (_jsx("main", { className: "grid min-h-screen place-items-center bg-background p-4", children: _jsxs(Card, { className: "grid w-full max-w-md gap-4 p-6", children: [_jsxs("div", { children: [_jsx("h1", { className: "mb-1 text-xl font-semibold", children: t('auth.setupTitle') }), _jsx("p", { className: "text-sm text-muted-foreground", children: t('auth.setupIntro') })] }), error === null ? null : (_jsx(Alert, { children: error instanceof ApiError ? error.message : String(error) })), codes !== null ? (_jsxs(_Fragment, { children: [_jsxs(Alert, { tone: "info", children: [_jsx("p", { className: "font-medium", children: t('auth.setupCodes') }), _jsx("ul", { className: "mt-2 grid grid-cols-2 gap-1 font-mono text-sm", children: codes.map((c) => (_jsx("li", { children: c }, c))) })] }), _jsx(Button, { variant: "primary", onClick: () => void queryClient.invalidateQueries(), children: t('auth.setupContinue') })] })) : setup === undefined ? (_jsxs("div", { className: "flex flex-wrap gap-2", children: [_jsx(Button, { variant: "primary", busy: start.isPending, onClick: () => start.mutate(), children: t('auth.setupStart') }), _jsx(Button, { variant: "ghost", onClick: () => void api.logout().then(() => window.location.assign('/login')), children: t('auth.signOut') })] })) : (_jsxs("form", { className: "grid gap-4", onSubmit: (e) => {
28
+ e.preventDefault();
29
+ if (code.length >= 6)
30
+ enable.mutate();
31
+ }, children: [_jsxs("ol", { className: "grid list-decimal gap-2 ps-5 text-sm", children: [_jsxs("li", { children: [t('auth.setupStep1'), ' ', _jsx("a", { className: "text-primary underline", href: setup.otpauthUri, children: t('auth.setupOpenApp') })] }), _jsxs("li", { children: [t('auth.setupStep2'), _jsx("code", { "data-testid": "two-factor-key", className: "ms-2 select-all break-all rounded bg-muted px-2 py-1 font-mono text-xs", children: setup.secret })] }), _jsx("li", { children: t('auth.setupStep3') })] }), _jsxs("div", { className: "grid gap-1.5", children: [_jsx(Label, { htmlFor: `${id}-code`, children: t('auth.code') }), _jsx(Input, { id: `${id}-code`, inputMode: "numeric", autoComplete: "one-time-code", className: "w-40 font-mono", value: code, onChange: (e) => setCode(e.target.value.trim()) })] }), _jsx(Button, { type: "submit", variant: "primary", busy: enable.isPending, disabled: code.length < 6, children: t('auth.setupTurnOn') })] }))] }) }));
32
+ }
@@ -5,6 +5,7 @@
5
5
  * | Keys | Does | Marked with |
6
6
  * | --------------- | ----------------------------------- | ----------------------- |
7
7
  * | Ctrl/⌘ + K | command palette | (the shell) |
8
+ * | Ctrl/⌘ + B | collapse / expand the sidebar | (the shell) |
8
9
  * | ? | this list | (the shell) |
9
10
  * | / | focus the search box | `data-kosh-search` |
10
11
  * | N | new record | `data-kosh-new` |
@@ -21,6 +22,8 @@ import { type ReactElement } from 'react';
21
22
  export declare function useShortcuts(handlers: {
22
23
  readonly palette: () => void;
23
24
  readonly help: () => void;
25
+ /** Collapses or expands the sidebar (RFC 0018). */
26
+ readonly sidebar?: () => void;
24
27
  }): void;
25
28
  /** The cheat sheet (`?`). */
26
29
  export declare function ShortcutsHelp({ open, onOpenChange, }: {
@@ -6,6 +6,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
6
6
  * | Keys | Does | Marked with |
7
7
  * | --------------- | ----------------------------------- | ----------------------- |
8
8
  * | Ctrl/⌘ + K | command palette | (the shell) |
9
+ * | Ctrl/⌘ + B | collapse / expand the sidebar | (the shell) |
9
10
  * | ? | this list | (the shell) |
10
11
  * | / | focus the search box | `data-kosh-search` |
11
12
  * | N | new record | `data-kosh-new` |
@@ -48,6 +49,11 @@ export function useShortcuts(handlers) {
48
49
  handlers.palette();
49
50
  return;
50
51
  }
52
+ if (mod && key === 'b' && handlers.sidebar !== undefined && !typing(e.target)) {
53
+ e.preventDefault();
54
+ handlers.sidebar();
55
+ return;
56
+ }
51
57
  if (mod && key === 's') {
52
58
  // On a form, Ctrl+S saves (or does nothing when there is nothing to save): never the browser's dialog.
53
59
  if (click('[data-kosh-save]') || document.querySelector('[data-kosh-form]') !== null)
@@ -68,8 +74,10 @@ export function useShortcuts(handlers) {
68
74
  }
69
75
  if (mod || e.altKey || typing(e.target))
70
76
  return;
71
- if (document.querySelector('[role="dialog"]') !== null)
72
- return; // dialogs own their keys
77
+ // Dialogs, popovers, menus and lists own their keys (Escape closes them, not the page). Checked while the key
78
+ // is captured, before they handle it and close.
79
+ if (document.querySelector('[role="dialog"], [role="menu"], [role="listbox"], [data-radix-popper-content-wrapper]') !== null)
80
+ return;
73
81
  if (e.key === '?') {
74
82
  e.preventDefault();
75
83
  handlers.help();
@@ -90,8 +98,8 @@ export function useShortcuts(handlers) {
90
98
  e.preventDefault();
91
99
  }
92
100
  };
93
- window.addEventListener('keydown', onKey);
94
- return () => window.removeEventListener('keydown', onKey);
101
+ window.addEventListener('keydown', onKey, true);
102
+ return () => window.removeEventListener('keydown', onKey, true);
95
103
  }, [handlers]);
96
104
  }
97
105
  const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform);
@@ -101,6 +109,7 @@ export function ShortcutsHelp({ open, onOpenChange, }) {
101
109
  const mod = isMac ? '⌘' : 'Ctrl';
102
110
  const rows = [
103
111
  ['palette', [mod, 'K']],
112
+ ['sidebar', [mod, 'B']],
104
113
  ['search', ['/']],
105
114
  ['new', ['N']],
106
115
  ['views', ['Alt', '1…9']],