shutro-erp 0.3.2 → 0.3.3

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 (130) hide show
  1. package/dist/account_portal/.kosh/bindings.js +1 -1
  2. package/dist/account_portal/.kosh/schema.d.ts +29 -1
  3. package/dist/accounting/.kosh/bindings.js +1 -1
  4. package/dist/accounting/.kosh/schema.d.ts +29 -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 +29 -1
  8. package/dist/l10n_bd/.kosh/bindings.js +1 -1
  9. package/dist/l10n_bd/.kosh/schema.d.ts +29 -1
  10. package/dist/l10n_in/.kosh/bindings.js +1 -1
  11. package/dist/l10n_in/.kosh/schema.d.ts +29 -4
  12. package/dist/l10n_in/module.d.ts +0 -62
  13. package/dist/l10n_in/module.js +2 -4
  14. package/dist/payment/.kosh/bindings.js +1 -1
  15. package/dist/payment/.kosh/schema.d.ts +30 -2
  16. package/dist/payment/bkash.d.ts +28 -0
  17. package/dist/payment/bkash.js +169 -0
  18. package/dist/payment/index.d.ts +4 -0
  19. package/dist/payment/index.js +80 -24
  20. package/dist/payment/module.d.ts +7 -4
  21. package/dist/payment/module.js +3 -0
  22. package/dist/payment/money.d.ts +7 -0
  23. package/dist/payment/money.js +37 -0
  24. package/dist/payment/providers.d.ts +13 -2
  25. package/dist/payment/razorpay.d.ts +12 -0
  26. package/dist/payment/razorpay.js +149 -0
  27. package/dist/payment/sslcommerz.js +1 -1
  28. package/dist/payment/stripe.d.ts +15 -0
  29. package/dist/payment/stripe.js +182 -0
  30. package/dist/permissions/admin.d.ts +3 -0
  31. package/dist/permissions/admin.js +108 -10
  32. package/dist/permissions/index.d.ts +1 -0
  33. package/dist/permissions/index.js +3 -0
  34. package/dist/permissions/roles.d.ts +2 -0
  35. package/dist/permissions/roles.js +236 -0
  36. package/dist/portal/.kosh/bindings.js +1 -1
  37. package/dist/portal/.kosh/schema.d.ts +29 -1
  38. package/dist/product/.kosh/bindings.js +1 -1
  39. package/dist/product/.kosh/schema.d.ts +29 -1
  40. package/dist/project/.kosh/bindings.js +1 -1
  41. package/dist/project/.kosh/schema.d.ts +29 -1
  42. package/dist/project_portal/.kosh/bindings.js +1 -1
  43. package/dist/project_portal/.kosh/schema.d.ts +29 -1
  44. package/dist/purchase/.kosh/bindings.js +1 -1
  45. package/dist/purchase/.kosh/schema.d.ts +29 -1
  46. package/dist/purchase_account/.kosh/bindings.js +1 -1
  47. package/dist/purchase_account/.kosh/schema.d.ts +29 -1
  48. package/dist/purchase_portal/.kosh/bindings.js +1 -1
  49. package/dist/purchase_portal/.kosh/schema.d.ts +29 -1
  50. package/dist/sale_account/.kosh/bindings.js +1 -1
  51. package/dist/sale_account/.kosh/schema.d.ts +29 -1
  52. package/dist/sale_portal/.kosh/bindings.js +1 -1
  53. package/dist/sale_portal/.kosh/schema.d.ts +29 -1
  54. package/dist/sale_project/.kosh/bindings.js +1 -1
  55. package/dist/sale_project/.kosh/schema.d.ts +29 -1
  56. package/dist/sales/.kosh/bindings.js +1 -1
  57. package/dist/sales/.kosh/schema.d.ts +29 -1
  58. package/dist/server/auth/base.js +22 -0
  59. package/dist/server/auth/routes.d.ts +3 -0
  60. package/dist/server/auth/routes.js +29 -0
  61. package/dist/server/auth/service.d.ts +52 -0
  62. package/dist/server/auth/service.js +168 -3
  63. package/dist/server/identity.d.ts +2 -0
  64. package/dist/server/index.d.ts +1 -1
  65. package/dist/server/server.js +11 -3
  66. package/dist/settings/company.d.ts +34 -0
  67. package/dist/settings/company.js +187 -0
  68. package/dist/settings/index.d.ts +10 -10
  69. package/dist/settings/index.js +159 -30
  70. package/dist/settings/people.d.ts +16 -0
  71. package/dist/settings/people.js +505 -0
  72. package/dist/settings/security.d.ts +13 -0
  73. package/dist/settings/security.js +158 -0
  74. package/dist/settings/web/AuditPage.d.ts +8 -0
  75. package/dist/settings/web/AuditPage.js +74 -0
  76. package/dist/settings/web/CompaniesPage.d.ts +8 -0
  77. package/dist/settings/web/CompaniesPage.js +52 -0
  78. package/dist/settings/web/CompanyPage.d.ts +9 -0
  79. package/dist/settings/web/CompanyPage.js +94 -0
  80. package/dist/settings/web/OverviewPage.d.ts +9 -0
  81. package/dist/settings/web/OverviewPage.js +66 -0
  82. package/dist/settings/web/PeoplePage.d.ts +9 -0
  83. package/dist/settings/web/PeoplePage.js +163 -0
  84. package/dist/settings/web/ProfilePage.d.ts +14 -0
  85. package/dist/settings/web/ProfilePage.js +194 -0
  86. package/dist/settings/web/RolesEditor.d.ts +35 -0
  87. package/dist/settings/web/RolesEditor.js +49 -0
  88. package/dist/settings/web/RolesPage.d.ts +9 -0
  89. package/dist/settings/web/RolesPage.js +296 -0
  90. package/dist/settings/web/SecurityPage.d.ts +8 -0
  91. package/dist/settings/web/SecurityPage.js +81 -0
  92. package/dist/settings/web/index.d.ts +9 -0
  93. package/dist/settings/web/index.js +26 -1
  94. package/dist/settings/web/messages.js +566 -0
  95. package/dist/ui/api.d.ts +4 -0
  96. package/dist/ui/app.d.ts +5 -0
  97. package/dist/ui/app.js +108 -31
  98. package/dist/ui/components/primitives.d.ts +9 -1
  99. package/dist/ui/components/primitives.js +12 -4
  100. package/dist/ui/context.d.ts +10 -1
  101. package/dist/ui/context.js +60 -2
  102. package/dist/ui/i18n/messages.d.ts +46 -0
  103. package/dist/ui/i18n/messages.js +92 -0
  104. package/dist/ui/index.d.ts +2 -1
  105. package/dist/ui/index.js +2 -1
  106. package/dist/ui/lib/accent.d.ts +13 -0
  107. package/dist/ui/lib/accent.js +36 -0
  108. package/dist/ui/shell/Activate.d.ts +6 -0
  109. package/dist/ui/shell/Activate.js +39 -0
  110. package/dist/ui/shell/CommandPalette.d.ts +7 -1
  111. package/dist/ui/shell/CommandPalette.js +16 -2
  112. package/dist/ui/shell/Sidebar.d.ts +27 -0
  113. package/dist/ui/shell/Sidebar.js +84 -0
  114. package/dist/ui/shell/TwoFactorSetup.d.ts +5 -0
  115. package/dist/ui/shell/TwoFactorSetup.js +32 -0
  116. package/dist/ui/shell/shortcuts.d.ts +3 -0
  117. package/dist/ui/shell/shortcuts.js +13 -4
  118. package/dist/ui/styles.css +70 -28
  119. package/dist/ui/views/DateField.d.ts +10 -0
  120. package/dist/ui/views/DateField.js +129 -0
  121. package/dist/ui/views/FormView.js +161 -21
  122. package/dist/ui/views/ListView.d.ts +2 -0
  123. package/dist/ui/views/ListView.js +8 -3
  124. package/dist/ui/views/StatusChip.d.ts +10 -0
  125. package/dist/ui/views/StatusChip.js +22 -0
  126. package/dist/ui/views/widgets.d.ts +3 -0
  127. package/dist/ui/views/widgets.js +26 -8
  128. package/dist/views/types.d.ts +7 -0
  129. package/dist/views/validate.js +4 -0
  130. package/package.json +2 -2
@@ -12,6 +12,14 @@ export const en = {
12
12
  unknownPage: 'The page "{{page}}" is not part of this app. Register it with registry.registerPage.',
13
13
  },
14
14
  auth: {
15
+ activateTitle: 'Choose your password',
16
+ activateIntro: 'You were invited to Shutro ERP. Choose a password to start.',
17
+ activateNoToken: 'This link is incomplete: open the link from your invitation.',
18
+ newPassword: 'Password',
19
+ repeatPassword: 'Repeat the password',
20
+ passwordRule: 'At least 12 characters.',
21
+ mismatch: 'The passwords are not the same.',
22
+ activate: 'Save and sign in',
15
23
  signIn: 'Sign in',
16
24
  login: 'Login',
17
25
  password: 'Password',
@@ -21,11 +29,32 @@ export const en = {
21
29
  signOut: 'Sign out',
22
30
  invalid: 'The login or password is wrong.',
23
31
  adminNeeds2fa: 'Administrator rights apply after you verify a second factor.',
32
+ setupTitle: 'Set up two-factor sign-in',
33
+ setupIntro: 'This company requires a second step when you sign in: a code from an authenticator app on your phone. Set it up to continue.',
34
+ setupStart: 'Set it up',
35
+ setupStep1: 'Add this account to your authenticator app.',
36
+ setupOpenApp: 'Open in the app',
37
+ setupStep2: 'Or type this key:',
38
+ setupStep3: 'Enter the 6-digit code it shows.',
39
+ setupTurnOn: 'Turn on and continue',
40
+ setupCodes: 'Keep these recovery codes somewhere safe: each signs you in once if you lose your phone.',
41
+ setupContinue: 'Continue',
24
42
  },
25
43
  shell: {
26
44
  apps: 'Apps',
27
45
  menu: 'Main menu',
28
46
  theme: 'Theme',
47
+ density: 'Density',
48
+ home: 'Home',
49
+ appNamed: '{{app}} app',
50
+ switchCompany: 'Company: {{name}} (switch)',
51
+ sidebar: 'Sidebar',
52
+ collapseSidebar: 'Collapse the sidebar',
53
+ expandSidebar: 'Expand the sidebar',
54
+ openMenu: 'Open the menu',
55
+ closeMenu: 'Close the menu',
56
+ menuHint: 'Apps and their sections',
57
+ location: 'You are here',
29
58
  light: 'Light',
30
59
  dark: 'Dark',
31
60
  system: 'System',
@@ -62,6 +91,20 @@ export const en = {
62
91
  exportFailed: 'The export failed: {{reason}}',
63
92
  columns: 'Columns',
64
93
  density: 'Density',
94
+ statusOf: '{{field}}: {{state}}',
95
+ nextState: 'Next',
96
+ recordActions: 'Record actions',
97
+ summary: 'Summary',
98
+ details: 'Details',
99
+ recordPanel: 'About this record',
100
+ showPanel: 'Show the panel',
101
+ hidePanel: 'Hide the panel',
102
+ previousMonth: 'Previous month',
103
+ nextMonth: 'Next month',
104
+ pickDate: 'Pick a date',
105
+ datePlaceholder: 'Date',
106
+ today: 'Today',
107
+ clear: 'Clear',
65
108
  proposed: 'Proposed',
66
109
  design: { addField: 'Add field', addColumn: 'Add column' },
67
110
  compact: 'Compact',
@@ -191,6 +234,8 @@ export const en = {
191
234
  },
192
235
  toast: { region: 'Notifications', close: 'Close' },
193
236
  palette: {
237
+ switchCompany: 'Switch to {{name}}',
238
+ profile: 'Your profile',
194
239
  button: 'Search or jump to…',
195
240
  title: 'Command palette',
196
241
  description: 'Search records, menus and commands. Use the arrow keys and Enter.',
@@ -209,6 +254,7 @@ export const en = {
209
254
  shortcuts: {
210
255
  title: 'Keyboard shortcuts',
211
256
  palette: 'Search or jump anywhere',
257
+ sidebar: 'Collapse or expand the sidebar',
212
258
  search: 'Focus the search box',
213
259
  new: 'New record',
214
260
  views: 'Switch view',
@@ -247,6 +293,14 @@ export const bn = {
247
293
  unknownPage: '"{{page}}" পৃষ্ঠাটি এই অ্যাপে নেই। registry.registerPage দিয়ে নিবন্ধন করুন।',
248
294
  },
249
295
  auth: {
296
+ activateTitle: 'আপনার পাসওয়ার্ড বেছে নিন',
297
+ activateIntro: 'আপনাকে Shutro ERP-এ আমন্ত্রণ জানানো হয়েছে। শুরু করতে একটি পাসওয়ার্ড বেছে নিন।',
298
+ activateNoToken: 'লিংকটি অসম্পূর্ণ: আমন্ত্রণের লিংকটি খুলুন।',
299
+ newPassword: 'পাসওয়ার্ড',
300
+ repeatPassword: 'পাসওয়ার্ড আবার লিখুন',
301
+ passwordRule: 'কমপক্ষে ১২ অক্ষর।',
302
+ mismatch: 'পাসওয়ার্ড দুটি মেলেনি।',
303
+ activate: 'সংরক্ষণ করে সাইন ইন করুন',
250
304
  signIn: 'সাইন ইন',
251
305
  login: 'লগইন',
252
306
  password: 'পাসওয়ার্ড',
@@ -256,11 +310,32 @@ export const bn = {
256
310
  signOut: 'সাইন আউট',
257
311
  invalid: 'লগইন বা পাসওয়ার্ড ভুল।',
258
312
  adminNeeds2fa: 'দ্বিতীয় ধাপ যাচাইয়ের পর অ্যাডমিনিস্ট্রেটরের অধিকার কার্যকর হবে।',
313
+ setupTitle: 'দুই-ধাপের সাইন-ইন চালু করুন',
314
+ setupIntro: 'এই প্রতিষ্ঠানে সাইন-ইনের সময় একটি দ্বিতীয় ধাপ লাগে: ফোনের অথেনটিকেটর অ্যাপের একটি কোড। চালিয়ে যেতে এটি চালু করুন।',
315
+ setupStart: 'চালু করুন',
316
+ setupStep1: 'অ্যাকাউন্টটি আপনার অথেনটিকেটর অ্যাপে যোগ করুন।',
317
+ setupOpenApp: 'অ্যাপে খুলুন',
318
+ setupStep2: 'অথবা এই কী লিখুন:',
319
+ setupStep3: 'অ্যাপে দেখানো ৬-সংখ্যার কোড দিন।',
320
+ setupTurnOn: 'চালু করে এগিয়ে যান',
321
+ setupCodes: 'এই রিকভারি কোডগুলো নিরাপদে রাখুন: ফোন হারালে প্রতিটি একবার সাইন-ইন করায়।',
322
+ setupContinue: 'এগিয়ে যান',
259
323
  },
260
324
  shell: {
261
325
  apps: 'অ্যাপস',
262
326
  menu: 'প্রধান মেনু',
263
327
  theme: 'থিম',
328
+ density: 'ঘনত্ব',
329
+ home: 'হোম',
330
+ appNamed: '{{app}} অ্যাপ',
331
+ switchCompany: 'প্রতিষ্ঠান: {{name}} (বদলান)',
332
+ sidebar: 'সাইডবার',
333
+ collapseSidebar: 'সাইডবার ছোট করুন',
334
+ expandSidebar: 'সাইডবার বড় করুন',
335
+ openMenu: 'মেনু খুলুন',
336
+ closeMenu: 'মেনু বন্ধ করুন',
337
+ menuHint: 'অ্যাপ ও তাদের অংশ',
338
+ location: 'আপনি এখানে আছেন',
264
339
  light: 'লাইট',
265
340
  dark: 'ডার্ক',
266
341
  system: 'সিস্টেম',
@@ -297,6 +372,20 @@ export const bn = {
297
372
  exportFailed: 'এক্সপোর্ট ব্যর্থ: {{reason}}',
298
373
  columns: 'কলাম',
299
374
  density: 'ঘনত্ব',
375
+ statusOf: '{{field}}: {{state}}',
376
+ nextState: 'পরবর্তী',
377
+ recordActions: 'রেকর্ডের কাজ',
378
+ summary: 'সারাংশ',
379
+ details: 'বিস্তারিত',
380
+ recordPanel: 'এই রেকর্ড সম্পর্কে',
381
+ showPanel: 'প্যানেল দেখান',
382
+ hidePanel: 'প্যানেল লুকান',
383
+ previousMonth: 'আগের মাস',
384
+ nextMonth: 'পরের মাস',
385
+ pickDate: 'তারিখ বাছুন',
386
+ datePlaceholder: 'তারিখ',
387
+ today: 'আজ',
388
+ clear: 'মুছে ফেলুন',
300
389
  proposed: 'প্রস্তাবিত',
301
390
  design: { addField: 'ফিল্ড যোগ করুন', addColumn: 'কলাম যোগ করুন' },
302
391
  compact: 'সংক্ষিপ্ত',
@@ -423,6 +512,8 @@ export const bn = {
423
512
  },
424
513
  toast: { region: 'বিজ্ঞপ্তি', close: 'বন্ধ করুন' },
425
514
  palette: {
515
+ switchCompany: '{{name}}-এ যান',
516
+ profile: 'আপনার প্রোফাইল',
426
517
  button: 'খুঁজুন বা যান…',
427
518
  title: 'কমান্ড প্যালেট',
428
519
  description: 'রেকর্ড, মেনু ও কমান্ড খুঁজুন। তীর কী ও এন্টার ব্যবহার করুন।',
@@ -441,6 +532,7 @@ export const bn = {
441
532
  shortcuts: {
442
533
  title: 'কীবোর্ড শর্টকাট',
443
534
  palette: 'যেকোনো জায়গায় খুঁজুন বা যান',
535
+ sidebar: 'সাইডবার ছোট বা বড় করুন',
444
536
  search: 'সার্চ বক্সে যান',
445
537
  new: 'নতুন রেকর্ড',
446
538
  views: 'ভিউ বদলান',
@@ -8,13 +8,14 @@
8
8
  */
9
9
  import { Registry } from './registry.js';
10
10
  export { ApiError, type ApiOptions, type AppEntry, type CurrentUser, createKoshApi, type KoshApi, type ListQuery, type MenuNode, NetworkError, type Problem, type Row, type ServedView, type Versioned, type ViewField, } from './api.js';
11
- export { KoshApp, type KoshAppProps } from './app.js';
11
+ export { KoshApp, type KoshAppProps, useLanguage } from './app.js';
12
12
  export { AppIcon } from './components/AppIcon.js';
13
13
  export * from './components/primitives.js';
14
14
  export { type KoshContextValue, KoshProvider, type ThemeChoice, useKosh, useTheme, } from './context.js';
15
15
  export { type DesignContextValue, DesignProvider, type DesignSpot, useDesign, } from './design.js';
16
16
  export { addMessages, createI18n, directionOf, NAMESPACE } from './i18n/index.js';
17
17
  export { LANGUAGES } from './i18n/messages.js';
18
+ export { accentVariables, hexToOklch } from './lib/accent.js';
18
19
  export { cn } from './lib/cn.js';
19
20
  export { bcp47, createFormatter, type Formatter } from './lib/format.js';
20
21
  export { type LiveRecord, useLiveModel, useLiveRecord } from './live.js';
package/dist/ui/index.js CHANGED
@@ -12,13 +12,14 @@ import { KanbanView } from './views/KanbanView.js';
12
12
  import { ListView } from './views/ListView.js';
13
13
  import { registerDefaultWidgets } from './views/widgets.js';
14
14
  export { ApiError, createKoshApi, NetworkError, } from './api.js';
15
- export { KoshApp } from './app.js';
15
+ export { KoshApp, useLanguage } from './app.js';
16
16
  export { AppIcon } from './components/AppIcon.js';
17
17
  export * from './components/primitives.js';
18
18
  export { KoshProvider, useKosh, useTheme, } from './context.js';
19
19
  export { DesignProvider, useDesign, } from './design.js';
20
20
  export { addMessages, createI18n, directionOf, NAMESPACE } from './i18n/index.js';
21
21
  export { LANGUAGES } from './i18n/messages.js';
22
+ export { accentVariables, hexToOklch } from './lib/accent.js';
22
23
  export { cn } from './lib/cn.js';
23
24
  export { bcp47, createFormatter } from './lib/format.js';
24
25
  export { useLiveModel, useLiveRecord } from './live.js';
@@ -0,0 +1,13 @@
1
+ /**
2
+ * A company's brand colour as the back-office's accent (RFC 0018 §4.5). Only its hue and chroma are used: the
3
+ * themes fix the accent's lightness (light: dark enough for white text; dark: light enough for dark text), so any
4
+ * brand colour keeps text on buttons and links readable (WCAG AA) in both.
5
+ */
6
+ /** OKLCH of an sRGB hex colour (`#4f46e5` → `{ l, c, h }`), or null when it is not one. */
7
+ export declare function hexToOklch(hex: string): {
8
+ l: number;
9
+ c: number;
10
+ h: number;
11
+ } | null;
12
+ /** The CSS variables a brand colour sets (`--accent-h`, `--accent-c`), or none for the default accent. */
13
+ export declare function accentVariables(hex: string | null | undefined): Record<string, string>;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * A company's brand colour as the back-office's accent (RFC 0018 §4.5). Only its hue and chroma are used: the
3
+ * themes fix the accent's lightness (light: dark enough for white text; dark: light enough for dark text), so any
4
+ * brand colour keeps text on buttons and links readable (WCAG AA) in both.
5
+ */
6
+ /** OKLCH of an sRGB hex colour (`#4f46e5` → `{ l, c, h }`), or null when it is not one. */
7
+ export function hexToOklch(hex) {
8
+ const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
9
+ if (m === null)
10
+ return null;
11
+ const linear = (v) => {
12
+ const x = Number.parseInt(v, 16) / 255;
13
+ return x <= 0.04045 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
14
+ };
15
+ const [r, g, b] = [linear(m[1]), linear(m[2]), linear(m[3])];
16
+ const l_ = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
17
+ const m_ = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
18
+ const s_ = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
19
+ const L = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_;
20
+ const A = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_;
21
+ const B = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_;
22
+ const c = Math.sqrt(A * A + B * B);
23
+ const h = (Math.atan2(B, A) * 180) / Math.PI;
24
+ return { l: L, c, h: h < 0 ? h + 360 : h };
25
+ }
26
+ /** The CSS variables a brand colour sets (`--accent-h`, `--accent-c`), or none for the default accent. */
27
+ export function accentVariables(hex) {
28
+ const color = hex === null || hex === undefined ? null : hexToOklch(hex);
29
+ if (color === null)
30
+ return {};
31
+ // Chroma is capped (very saturated colours glare as large surfaces); greys stay grey.
32
+ return {
33
+ '--accent-h': color.h.toFixed(1),
34
+ '--accent-c': Math.min(color.c, 0.2).toFixed(3),
35
+ };
36
+ }
@@ -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,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,84 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * The Workspace sidebar (RFC 0018 §4.1): the company at the top, search, Home, then the user's apps — each a button
4
+ * that opens its sections (the current app opens itself; one at a time) — and the user at the bottom. It collapses
5
+ * to an icon rail (`Ctrl+B`, remembered per browser) and becomes a drawer on small screens. The server decides which
6
+ * 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 { ChevronDown, House, PanelLeftClose, PanelLeftOpen, Search, X } from 'lucide-react';
11
+ import { Dialog } from 'radix-ui';
12
+ import { useEffect, useState } from 'react';
13
+ import { useTranslation } from 'react-i18next';
14
+ import { AppIcon } from '../components/AppIcon.js';
15
+ import { Button } 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: "ms-4 grid gap-0.5 border-s ps-2 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 [open, setOpen] = useState(current);
65
+ useEffect(() => setOpen(current), [current]);
66
+ 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';
67
+ 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: _jsx("ul", { className: "grid gap-0.5", children: apps.map((a) => {
68
+ const expanded = !collapsed && open === a.code;
69
+ return (_jsxs("li", { children: [_jsxs("button", { type: "button", "aria-expanded": collapsed ? undefined : expanded, "aria-label": t('shell.appNamed', { app: a.label }), title: collapsed ? a.label : undefined, className: cn(item, current === a.code && 'font-medium', collapsed && 'justify-center'), onClick: () => {
70
+ if (collapsed || a.code !== current) {
71
+ setOpen(a.code);
72
+ void navigate({ to: '/$app', params: { app: a.code } });
73
+ onNavigate?.();
74
+ }
75
+ else
76
+ setOpen(expanded ? undefined : a.code);
77
+ }, children: [_jsx(AppIcon, { name: a.icon ?? a.code, className: "size-6 rounded-md [&_svg]:size-3.5" }), collapsed ? null : (_jsxs(_Fragment, { children: [_jsx("span", { className: "flex-1 truncate text-start", children: a.label }), _jsx(ChevronDown, { className: cn('size-3.5 text-muted-foreground transition-transform', !expanded && '-rotate-90 rtl:rotate-90'), "aria-hidden": true })] }))] }), expanded ? (_jsx(AppSections, { api: api, app: a.code, company: company, onNavigate: onNavigate })) : null] }, a.code));
78
+ }) }) }), _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 })) }))] })] }));
79
+ }
80
+ /** The sidebar as a drawer on small screens. */
81
+ export function SidebarDrawer({ open, onOpenChange, children, }) {
82
+ const { t } = useTranslation();
83
+ 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] })] }) }));
84
+ }
@@ -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']],