@aderis-erp/cli 1.0.2 → 1.0.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 (85) hide show
  1. package/dist/commands/{processing-logs.d.ts → access-groups.d.ts} +1 -1
  2. package/dist/commands/{access-groups 2.js → access-groups.js} +30 -18
  3. package/dist/commands/account-transactions.d.ts +41 -0
  4. package/dist/commands/account-transactions.js +1032 -0
  5. package/dist/commands/accounts.d.ts +40 -0
  6. package/dist/commands/accounts.js +1024 -0
  7. package/dist/commands/agent-runs.d.ts +39 -0
  8. package/dist/commands/agent-runs.js +1013 -0
  9. package/dist/commands/{pubsub.d.ts → async-endpoints-internal.d.ts} +1 -1
  10. package/dist/commands/{pubsub.js → async-endpoints-internal.js} +37 -25
  11. package/dist/commands/audit-log.d.ts +39 -0
  12. package/dist/commands/{processing-logs.js → audit-log.js} +42 -28
  13. package/dist/commands/auth/login.d.ts +14 -0
  14. package/dist/commands/auth/login.js +116 -0
  15. package/dist/commands/auth/logout.d.ts +10 -0
  16. package/dist/commands/auth/logout.js +58 -0
  17. package/dist/commands/auth/status.d.ts +12 -0
  18. package/dist/commands/auth/status.js +101 -0
  19. package/dist/commands/auth/tenant.d.ts +19 -0
  20. package/dist/commands/auth/tenant.js +184 -0
  21. package/dist/commands/auth.d.ts +37 -0
  22. package/dist/commands/auth.js +871 -0
  23. package/dist/commands/bank-accounts.d.ts +41 -0
  24. package/dist/commands/bank-accounts.js +1032 -0
  25. package/dist/commands/bank-integrations.js +30 -18
  26. package/dist/commands/business-partners---contacts.js +30 -18
  27. package/dist/commands/business-partners.js +30 -18
  28. package/dist/commands/config/add.d.ts +13 -0
  29. package/dist/commands/config/add.js +120 -0
  30. package/dist/commands/config/current.d.ts +9 -0
  31. package/dist/commands/config/current.js +43 -0
  32. package/dist/commands/config/delete.d.ts +12 -0
  33. package/dist/commands/config/delete.js +67 -0
  34. package/dist/commands/config/list.d.ts +9 -0
  35. package/dist/commands/config/list.js +50 -0
  36. package/dist/commands/config/use.d.ts +9 -0
  37. package/dist/commands/config/use.js +40 -0
  38. package/dist/commands/credit-card-accounts.js +30 -18
  39. package/dist/commands/documents.js +30 -18
  40. package/dist/commands/dunning-configs.js +30 -18
  41. package/dist/commands/dunning-reminders.js +30 -18
  42. package/dist/commands/dunning-runs.js +30 -18
  43. package/dist/commands/employees.js +30 -18
  44. package/dist/commands/event-processor-mappings.js +30 -18
  45. package/dist/commands/files-internal.js +30 -18
  46. package/dist/commands/health.js +30 -18
  47. package/dist/commands/inbox.js +33 -19
  48. package/dist/commands/integrations.js +30 -18
  49. package/dist/commands/item-categories.js +30 -18
  50. package/dist/commands/items.js +30 -18
  51. package/dist/commands/journal-lines.js +30 -18
  52. package/dist/commands/journals.js +30 -18
  53. package/dist/commands/ledgers.js +30 -18
  54. package/dist/commands/office-locations.js +30 -18
  55. package/dist/commands/payment-method-ledger-mappings.js +30 -18
  56. package/dist/commands/payment-methods.js +30 -18
  57. package/dist/commands/payment-runs.js +30 -18
  58. package/dist/commands/payment-terms.js +30 -18
  59. package/dist/commands/price-lists.js +30 -18
  60. package/dist/commands/processor-mappings.js +30 -18
  61. package/dist/commands/processors.js +31 -19
  62. package/dist/commands/reconciliation.js +30 -18
  63. package/dist/commands/reports.js +30 -18
  64. package/dist/commands/salary-types.js +1018 -0
  65. package/dist/commands/sales-invoices.js +30 -18
  66. package/dist/commands/sales-orders.js +32 -19
  67. package/dist/commands/switch.d.ts +1 -0
  68. package/dist/commands/switch.js +58 -3
  69. package/dist/commands/tags.js +30 -18
  70. package/dist/commands/tax-codes.js +30 -18
  71. package/dist/commands/tax-matrix.js +30 -18
  72. package/dist/commands/tenants.js +30 -18
  73. package/dist/commands/text-templates.js +30 -18
  74. package/dist/commands/tools.js +30 -18
  75. package/dist/commands/vendor-invoices.js +33 -19
  76. package/dist/commands/webhooks.js +30 -18
  77. package/dist/commands/workflows.js +30 -18
  78. package/dist/index 2.js +1 -0
  79. package/dist/index.d 2.ts +1 -0
  80. package/dist/lib/auth.d.ts +16 -1
  81. package/dist/lib/auth.js +153 -45
  82. package/dist/lib/config.d.ts +7 -0
  83. package/dist/lib/config.js +6 -0
  84. package/oclif.manifest.json +2092 -669
  85. package/package.json +2 -2
@@ -6,7 +6,7 @@ const HAS_IMPORT = true;
6
6
  const HAS_EXPORT = true;
7
7
  const HAS_CAMT053 = false;
8
8
  const NESTED_IMPORTS = ["line-items", "notes", "files", "payments"];
9
- const SUBCOMMANDS = [...["list", "create", "get", "update", "delete", "submit", "approve", "decline", "cancel", "confirm-cancellation", "validate", "create-line-items", "update-line-items", "delete-line-items", "list-notes", "create-notes", "get-notes", "update-notes", "delete-notes", "list-files", "create-files", "get-files", "update-files", "delete-files", "files-download", "list-payments", "create-payments", "get-payments", "update-payments", "delete-payments", "payments-approve", "payments-cancel"], ...(HAS_IMPORT ? ['import'] : []), ...(HAS_EXPORT ? ['export'] : []), ...NESTED_IMPORTS.map(n => `import-${n}`)];
9
+ const SUBCOMMANDS = [...["list", "create", "get", "update", "delete", "submit", "approve", "decline", "book", "cancel", "confirm-cancellation", "change-vendor", "validate", "create-line-items", "update-line-items", "delete-line-items", "list-notes", "create-notes", "get-notes", "update-notes", "delete-notes", "list-files", "create-files", "get-files", "update-files", "delete-files", "files-download", "list-payments", "create-payments", "get-payments", "update-payments", "delete-payments", "payments-approve", "payments-cancel"], ...(HAS_IMPORT ? ['import'] : []), ...(HAS_EXPORT ? ['export'] : []), ...NESTED_IMPORTS.map(n => `import-${n}`)];
10
10
  const ENDPOINTS = {
11
11
  'list': { method: 'GET', path: '/v1/vendor-invoices', summary: 'Get all vendor invoices with pagination', pathParams: [], hasBody: false, isMultipart: false },
12
12
  'create': { method: 'POST', path: '/v1/vendor-invoices', summary: 'Create a new vendor invoice', pathParams: [], hasBody: true, isMultipart: false },
@@ -16,8 +16,10 @@ const ENDPOINTS = {
16
16
  'submit': { method: 'POST', path: '/v1/vendor-invoices/{id}/submit', summary: 'Submit a vendor invoice for approval (transitions DRAFT to PENDING_APPROVAL)', pathParams: ['id'], hasBody: false, isMultipart: false },
17
17
  'approve': { method: 'POST', path: '/v1/vendor-invoices/{id}/approve', summary: 'Approve a vendor invoice (transitions DRAFT/PENDING_APPROVAL to APPROVED)', pathParams: ['id'], hasBody: true, isMultipart: false },
18
18
  'decline': { method: 'POST', path: '/v1/vendor-invoices/{id}/decline', summary: 'Decline a vendor invoice (transitions PENDING_APPROVAL to DECLINED)', pathParams: ['id'], hasBody: true, isMultipart: false },
19
+ 'book': { method: 'POST', path: '/v1/vendor-invoices/{id}/book', summary: 'Manually book a vendor invoice (dispatch to ledger-booking-agent)', pathParams: ['id'], hasBody: false, isMultipart: false },
19
20
  'cancel': { method: 'POST', path: '/v1/vendor-invoices/{id}/cancel', summary: 'Cancel a vendor invoice', pathParams: ['id'], hasBody: true, isMultipart: false },
20
21
  'confirm-cancellation': { method: 'POST', path: '/v1/vendor-invoices/{id}/confirm-cancellation', summary: 'Confirm bank cancellation for a vendor invoice (PENDING_CANCELLATION to CANCELLED)', pathParams: ['id'], hasBody: true, isMultipart: false },
22
+ 'change-vendor': { method: 'POST', path: '/v1/vendor-invoices/{id}/change-vendor', summary: 'Change the vendor on a vendor invoice', pathParams: ['id'], hasBody: true, isMultipart: false },
21
23
  'validate': { method: 'POST', path: '/v1/vendor-invoices/{id}/validate', summary: 'Validate a vendor invoice update without persisting changes', pathParams: ['id'], hasBody: true, isMultipart: false },
22
24
  'create-line-items': { method: 'POST', path: '/v1/vendor-invoices/{invoiceId}/line-items', summary: 'Add a line item to a vendor invoice', pathParams: ['invoiceId'], hasBody: true, isMultipart: false },
23
25
  'update-line-items': { method: 'PATCH', path: '/v1/vendor-invoices/{invoiceId}/line-items/{lineItemId}', summary: 'Update a line item on a vendor invoice', pathParams: ['invoiceId', 'lineItemId'], hasBody: true, isMultipart: false },
@@ -850,32 +852,44 @@ export default class VendorInvoices extends Command {
850
852
  filterFields(data, fields) {
851
853
  const isUnsafeKey = (k) => k === '__proto__' || k === 'constructor' || k === 'prototype';
852
854
  const pick = (obj, keys) => {
853
- const result = {};
855
+ // Group keys by their first segment so sibling sub-paths
856
+ // (e.g. 'items.amount' + 'items._type') resolve in one recursive pass
857
+ // and don't clobber each other on merge.
858
+ const directKeys = new Set();
859
+ const nestedKeys = new Map();
854
860
  for (const key of keys) {
855
861
  if (key.split('.').some(isUnsafeKey))
856
862
  continue;
857
863
  if (key.includes('.')) {
858
- // Handle nested fields like 'customer.name' or 'allocations.salesInvoiceId'
859
864
  const [first, ...rest] = key.split('.');
860
- const val = obj[first];
861
- if (val && typeof val === 'object') {
862
- const restKey = rest.join('.');
863
- if (Array.isArray(val)) {
864
- // Map over array elements (e.g., allocations[].salesInvoiceId)
865
- const existing = Array.isArray(result[first]) ? result[first] : [];
866
- result[first] = val.map((item, i) => ({
867
- ...(existing[i] || {}),
868
- ...pick(item, [restKey]),
869
- }));
870
- }
871
- else {
872
- const nested = pick(val, [restKey]);
873
- result[first] = { ...(result[first] || {}), ...nested };
874
- }
865
+ if (isUnsafeKey(first))
866
+ continue;
867
+ const list = nestedKeys.get(first);
868
+ if (list) {
869
+ list.push(rest.join('.'));
875
870
  }
871
+ else {
872
+ nestedKeys.set(first, [rest.join('.')]);
873
+ }
874
+ }
875
+ else {
876
+ directKeys.add(key);
876
877
  }
877
- else if (key in obj) {
878
+ }
879
+ const result = {};
880
+ for (const key of directKeys) {
881
+ if (key in obj)
878
882
  result[key] = obj[key];
883
+ }
884
+ for (const [first, restKeys] of nestedKeys) {
885
+ const val = obj[first];
886
+ if (!val || typeof val !== 'object')
887
+ continue;
888
+ if (Array.isArray(val)) {
889
+ result[first] = val.map(item => pick(item, restKeys));
890
+ }
891
+ else {
892
+ result[first] = pick(val, restKeys);
879
893
  }
880
894
  }
881
895
  return result;
@@ -821,32 +821,44 @@ export default class Webhooks extends Command {
821
821
  filterFields(data, fields) {
822
822
  const isUnsafeKey = (k) => k === '__proto__' || k === 'constructor' || k === 'prototype';
823
823
  const pick = (obj, keys) => {
824
- const result = {};
824
+ // Group keys by their first segment so sibling sub-paths
825
+ // (e.g. 'items.amount' + 'items._type') resolve in one recursive pass
826
+ // and don't clobber each other on merge.
827
+ const directKeys = new Set();
828
+ const nestedKeys = new Map();
825
829
  for (const key of keys) {
826
830
  if (key.split('.').some(isUnsafeKey))
827
831
  continue;
828
832
  if (key.includes('.')) {
829
- // Handle nested fields like 'customer.name' or 'allocations.salesInvoiceId'
830
833
  const [first, ...rest] = key.split('.');
831
- const val = obj[first];
832
- if (val && typeof val === 'object') {
833
- const restKey = rest.join('.');
834
- if (Array.isArray(val)) {
835
- // Map over array elements (e.g., allocations[].salesInvoiceId)
836
- const existing = Array.isArray(result[first]) ? result[first] : [];
837
- result[first] = val.map((item, i) => ({
838
- ...(existing[i] || {}),
839
- ...pick(item, [restKey]),
840
- }));
841
- }
842
- else {
843
- const nested = pick(val, [restKey]);
844
- result[first] = { ...(result[first] || {}), ...nested };
845
- }
834
+ if (isUnsafeKey(first))
835
+ continue;
836
+ const list = nestedKeys.get(first);
837
+ if (list) {
838
+ list.push(rest.join('.'));
846
839
  }
840
+ else {
841
+ nestedKeys.set(first, [rest.join('.')]);
842
+ }
843
+ }
844
+ else {
845
+ directKeys.add(key);
847
846
  }
848
- else if (key in obj) {
847
+ }
848
+ const result = {};
849
+ for (const key of directKeys) {
850
+ if (key in obj)
849
851
  result[key] = obj[key];
852
+ }
853
+ for (const [first, restKeys] of nestedKeys) {
854
+ const val = obj[first];
855
+ if (!val || typeof val !== 'object')
856
+ continue;
857
+ if (Array.isArray(val)) {
858
+ result[first] = val.map(item => pick(item, restKeys));
859
+ }
860
+ else {
861
+ result[first] = pick(val, restKeys);
850
862
  }
851
863
  }
852
864
  return result;
@@ -813,32 +813,44 @@ export default class Workflows extends Command {
813
813
  filterFields(data, fields) {
814
814
  const isUnsafeKey = (k) => k === '__proto__' || k === 'constructor' || k === 'prototype';
815
815
  const pick = (obj, keys) => {
816
- const result = {};
816
+ // Group keys by their first segment so sibling sub-paths
817
+ // (e.g. 'items.amount' + 'items._type') resolve in one recursive pass
818
+ // and don't clobber each other on merge.
819
+ const directKeys = new Set();
820
+ const nestedKeys = new Map();
817
821
  for (const key of keys) {
818
822
  if (key.split('.').some(isUnsafeKey))
819
823
  continue;
820
824
  if (key.includes('.')) {
821
- // Handle nested fields like 'customer.name' or 'allocations.salesInvoiceId'
822
825
  const [first, ...rest] = key.split('.');
823
- const val = obj[first];
824
- if (val && typeof val === 'object') {
825
- const restKey = rest.join('.');
826
- if (Array.isArray(val)) {
827
- // Map over array elements (e.g., allocations[].salesInvoiceId)
828
- const existing = Array.isArray(result[first]) ? result[first] : [];
829
- result[first] = val.map((item, i) => ({
830
- ...(existing[i] || {}),
831
- ...pick(item, [restKey]),
832
- }));
833
- }
834
- else {
835
- const nested = pick(val, [restKey]);
836
- result[first] = { ...(result[first] || {}), ...nested };
837
- }
826
+ if (isUnsafeKey(first))
827
+ continue;
828
+ const list = nestedKeys.get(first);
829
+ if (list) {
830
+ list.push(rest.join('.'));
838
831
  }
832
+ else {
833
+ nestedKeys.set(first, [rest.join('.')]);
834
+ }
835
+ }
836
+ else {
837
+ directKeys.add(key);
839
838
  }
840
- else if (key in obj) {
839
+ }
840
+ const result = {};
841
+ for (const key of directKeys) {
842
+ if (key in obj)
841
843
  result[key] = obj[key];
844
+ }
845
+ for (const [first, restKeys] of nestedKeys) {
846
+ const val = obj[first];
847
+ if (!val || typeof val !== 'object')
848
+ continue;
849
+ if (Array.isArray(val)) {
850
+ result[first] = val.map(item => pick(item, restKeys));
851
+ }
852
+ else {
853
+ result[first] = pick(val, restKeys);
842
854
  }
843
855
  }
844
856
  return result;
@@ -0,0 +1 @@
1
+ export { run } from '@oclif/core';
@@ -0,0 +1 @@
1
+ export { run } from '@oclif/core';
@@ -1,5 +1,7 @@
1
1
  import { type Context, type TokenData } from './config.js';
2
- export declare function login(context: Context): Promise<TokenData>;
2
+ export declare function login(context: Context, options?: {
3
+ prompt?: 'login' | 'none';
4
+ }): Promise<TokenData>;
3
5
  export declare function refreshTokens(contextName: string): Promise<TokenData>;
4
6
  export declare function getValidToken(contextName: string): Promise<string>;
5
7
  export declare function testAuth(contextName: string): Promise<{
@@ -18,3 +20,16 @@ export declare function getCurrentTenant(contextName: string): Promise<null | {
18
20
  tenantId: string;
19
21
  }>;
20
22
  export declare function switchTenant(contextName: string, tenantId: string): Promise<TokenData>;
23
+ /**
24
+ * Combined login + tenant switch flow that uses a single browser tab.
25
+ *
26
+ * Two PKCE rounds run against one local callback server. After the first round
27
+ * completes (which gives us tokens we can use to PUT the new active tenant),
28
+ * the server responds with HTML that meta-refreshes the same browser tab to a
29
+ * second `/oauth/authorize` URL. The second round mints tokens scoped to the
30
+ * new active tenant.
31
+ *
32
+ * Used by `aderis switch` when the user's session is expired AND they want to
33
+ * end up on a different tenant — avoids the previous two-separate-tab UX.
34
+ */
35
+ export declare function loginAndSwitchTenant(context: Context, tenantId: string): Promise<TokenData>;
package/dist/lib/auth.js CHANGED
@@ -4,7 +4,7 @@ import * as crypto from 'node:crypto';
4
4
  import * as http from 'node:http';
5
5
  import { URL, URLSearchParams } from 'node:url';
6
6
  import open from 'open';
7
- import { getBaseUrls, getContext, saveTokens } from './config.js';
7
+ import { getBaseUrls, getContext, saveCachedTenants, saveTokens, } from './config.js';
8
8
  function buildValidatedUrl(baseUrl, path) {
9
9
  try {
10
10
  // Minimal path validation
@@ -140,19 +140,24 @@ function generatePKCE() {
140
140
  function generateState() {
141
141
  return crypto.randomBytes(16).toString('hex');
142
142
  }
143
- export async function login(context) {
143
+ function buildAuthorizeUrl(context, pkce, state, options = {}) {
144
144
  const { auth: authUrl } = getBaseUrls(context.environment);
145
+ const url = new URL(`${authUrl}/oauth/authorize`);
146
+ url.searchParams.set('client_id', context.client_id);
147
+ url.searchParams.set('redirect_uri', REDIRECT_URI);
148
+ url.searchParams.set('response_type', 'code');
149
+ url.searchParams.set('state', state);
150
+ url.searchParams.set('code_challenge', pkce.challenge);
151
+ url.searchParams.set('code_challenge_method', 'S256');
152
+ url.searchParams.set('scope', 'openid profile email offline_access');
153
+ if (options.prompt)
154
+ url.searchParams.set('prompt', options.prompt);
155
+ return url.toString();
156
+ }
157
+ export async function login(context, options = {}) {
145
158
  const pkce = generatePKCE();
146
159
  const state = generateState();
147
- // Build authorization URL
148
- const authorizationUrl = new URL(`${authUrl}/oauth/authorize`);
149
- authorizationUrl.searchParams.set('client_id', context.client_id);
150
- authorizationUrl.searchParams.set('redirect_uri', REDIRECT_URI);
151
- authorizationUrl.searchParams.set('response_type', 'code');
152
- authorizationUrl.searchParams.set('state', state);
153
- authorizationUrl.searchParams.set('code_challenge', pkce.challenge);
154
- authorizationUrl.searchParams.set('code_challenge_method', 'S256');
155
- authorizationUrl.searchParams.set('scope', 'openid profile email offline_access');
160
+ const authorizationUrl = buildAuthorizeUrl(context, pkce, state, options);
156
161
  return new Promise((resolve, reject) => {
157
162
  const connections = new Set();
158
163
  let timeoutId;
@@ -218,8 +223,8 @@ export async function login(context) {
218
223
  };
219
224
  server.listen(CALLBACK_PORT, () => {
220
225
  console.log(`Opening browser for authentication...`);
221
- console.log(`If the browser doesn't open, visit: ${authorizationUrl.toString()}`);
222
- open(authorizationUrl.toString());
226
+ console.log(`If the browser doesn't open, visit: ${authorizationUrl}`);
227
+ open(authorizationUrl);
223
228
  });
224
229
  server.on('error', (err) => {
225
230
  closeServer();
@@ -357,12 +362,16 @@ export async function listTenants(contextName) {
357
362
  throw error;
358
363
  }
359
364
  // Normalize the response - Frontegg may use different field names
360
- return response.data.map((t) => ({
365
+ const tenants = response.data.map((t) => ({
361
366
  createdAt: t.createdAt,
362
367
  isDefault: t.isDefault,
363
368
  name: t.name || t.tenantName || t.tenantId,
364
369
  tenantId: t.tenantId,
365
370
  }));
371
+ // Cache for use when the access token is expired (e.g. session-expired branch
372
+ // of `aderis switch`, where we want to show the picker before re-authenticating).
373
+ saveCachedTenants(contextName, tenants.map((t) => ({ name: t.name, tenantId: t.tenantId })));
374
+ return tenants;
366
375
  }
367
376
  export async function getCurrentTenant(contextName) {
368
377
  const context = getContext(contextName);
@@ -398,38 +407,14 @@ export async function switchTenant(contextName, tenantId) {
398
407
  if (!context.tokens?.refresh_token) {
399
408
  throw new Error('No refresh token available. Please login again.');
400
409
  }
401
- const { auth: authUrl } = getBaseUrls(context.environment);
402
- const token = await getValidToken(contextName);
403
410
  try {
404
- // Step 1: PUT to switch the user's active tenant
405
- await axios.put(buildValidatedUrl(authUrl, '/identity/resources/users/v1/tenant'), { tenantId }, {
406
- headers: {
407
- Accept: 'application/json',
408
- Authorization: `Bearer ${token}`,
409
- 'Content-Type': 'application/json',
410
- },
411
- });
412
- // Step 2: Refresh tokens to get new tokens for the switched tenant
413
- const response = await axios.post(buildValidatedUrl(authUrl, '/oauth/token'), new URLSearchParams({
414
- client_id: context.client_id,
415
- grant_type: 'refresh_token',
416
- refresh_token: context.tokens.refresh_token,
417
- }).toString(), {
418
- headers: {
419
- Accept: 'application/json',
420
- 'Content-Type': 'application/x-www-form-urlencoded',
421
- },
422
- });
423
- const tokens = response.data;
424
- const decoded = jwt.decode(tokens.access_token);
425
- const tokenData = {
426
- access_token: tokens.access_token,
427
- expires_at: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : undefined,
428
- refresh_token: tokens.refresh_token || context.tokens.refresh_token,
429
- tenant_id: decoded?.tenantId || decoded?.tenant_id,
430
- };
431
- saveTokens(contextName, tokenData);
432
- return tokenData;
411
+ // Delegate to the two-round PKCE flow. Round 1 issues a token for whatever
412
+ // tenant Frontegg's session cookie currently holds (which forces Frontegg
413
+ // to refresh its session state), then we PUT the requested tenant, and
414
+ // round 2 — reached via meta-refresh on the same browser tab — issues a
415
+ // token scoped to the new tenant. No re-authentication is required because
416
+ // the user's Frontegg session cookie is still valid.
417
+ return await loginAndSwitchTenant(context, tenantId);
433
418
  }
434
419
  catch (error) {
435
420
  if (axios.isAxiosError(error) && (error.response?.status === 401 || error.response?.status === 400)) {
@@ -438,3 +423,126 @@ export async function switchTenant(contextName, tenantId) {
438
423
  throw error;
439
424
  }
440
425
  }
426
+ /**
427
+ * Combined login + tenant switch flow that uses a single browser tab.
428
+ *
429
+ * Two PKCE rounds run against one local callback server. After the first round
430
+ * completes (which gives us tokens we can use to PUT the new active tenant),
431
+ * the server responds with HTML that meta-refreshes the same browser tab to a
432
+ * second `/oauth/authorize` URL. The second round mints tokens scoped to the
433
+ * new active tenant.
434
+ *
435
+ * Used by `aderis switch` when the user's session is expired AND they want to
436
+ * end up on a different tenant — avoids the previous two-separate-tab UX.
437
+ */
438
+ export async function loginAndSwitchTenant(context, tenantId) {
439
+ const { auth: authUrl } = getBaseUrls(context.environment);
440
+ const pkce1 = generatePKCE();
441
+ const state1 = generateState();
442
+ const pkce2 = generatePKCE();
443
+ const state2 = generateState();
444
+ const authorizeUrl1 = buildAuthorizeUrl(context, pkce1, state1);
445
+ const authorizeUrl2 = buildAuthorizeUrl(context, pkce2, state2);
446
+ return new Promise((resolve, reject) => {
447
+ const connections = new Set();
448
+ const timeoutRef = {};
449
+ const closeServer = () => {
450
+ if (timeoutRef.id)
451
+ clearTimeout(timeoutRef.id);
452
+ for (const socket of connections) {
453
+ socket.destroy();
454
+ }
455
+ connections.clear();
456
+ server.close();
457
+ };
458
+ const server = http.createServer(async (req, res) => {
459
+ const url = new URL(req.url || '', `http://localhost:${CALLBACK_PORT}`);
460
+ if (url.pathname !== '/oauth/callback') {
461
+ res.writeHead(404);
462
+ res.end('Not Found');
463
+ return;
464
+ }
465
+ const code = url.searchParams.get('code');
466
+ const returnedState = url.searchParams.get('state');
467
+ const error = url.searchParams.get('error');
468
+ const errorDescription = url.searchParams.get('error_description');
469
+ if (error || !code || !returnedState) {
470
+ res.writeHead(400, { 'Content-Type': 'text/html' });
471
+ res.end(renderAuthPage('error', 'Authentication Failed', errorDescription || error || 'Invalid response'));
472
+ closeServer();
473
+ reject(new Error(errorDescription || error || 'Invalid authorization response'));
474
+ return;
475
+ }
476
+ try {
477
+ if (returnedState === state1) {
478
+ // Round 1: fresh login → tokens for whatever tenant Frontegg considers
479
+ // active for this user. PUT to set the requested tenant, then redirect
480
+ // the SAME browser tab to authorize URL #2 so Frontegg re-mints tokens.
481
+ const tokens1 = await exchangeCodeForTokens(context, code, pkce1.verifier);
482
+ saveTokens(context.name, tokens1);
483
+ await axios.put(buildValidatedUrl(authUrl, '/identity/resources/users/v1/tenant'), { tenantId }, {
484
+ headers: {
485
+ Accept: 'application/json',
486
+ Authorization: `Bearer ${tokens1.access_token}`,
487
+ 'Content-Type': 'application/json',
488
+ },
489
+ });
490
+ res.writeHead(200, { 'Content-Type': 'text/html' });
491
+ res.end(`<!DOCTYPE html>
492
+ <html><head>
493
+ <meta charset="UTF-8">
494
+ <meta http-equiv="refresh" content="0; url=${authorizeUrl2}">
495
+ <title>Switching tenant…</title>
496
+ </head><body><p>Switching tenant…</p></body></html>`);
497
+ return;
498
+ }
499
+ if (returnedState === state2) {
500
+ // Round 2: tokens scoped to the new tenant.
501
+ const tokens2 = await exchangeCodeForTokens(context, code, pkce2.verifier);
502
+ saveTokens(context.name, tokens2);
503
+ if (tokens2.tenant_id !== tenantId) {
504
+ res.writeHead(400, { 'Content-Type': 'text/html' });
505
+ res.end(renderAuthPage('error', 'Tenant Switch Failed', `Frontegg returned a token for tenant ${tokens2.tenant_id} instead of ${tenantId}.`));
506
+ closeServer();
507
+ reject(new Error(`Tenant switch did not propagate. Frontegg returned tenant ${tokens2.tenant_id} instead of ${tenantId}. Please try again in a few seconds.`));
508
+ return;
509
+ }
510
+ res.writeHead(200, { Connection: 'close', 'Content-Type': 'text/html' });
511
+ res.end(renderAuthPage('success', 'Tenant Switched', `You have been logged in to <strong>${context.name}</strong> on the selected tenant.`));
512
+ setImmediate(() => {
513
+ closeServer();
514
+ resolve(tokens2);
515
+ });
516
+ return;
517
+ }
518
+ res.writeHead(400, { 'Content-Type': 'text/html' });
519
+ res.end(renderAuthPage('error', 'Authentication Failed', 'Invalid state parameter.'));
520
+ closeServer();
521
+ reject(new Error('Invalid state in callback'));
522
+ }
523
+ catch (error_) {
524
+ res.writeHead(500, { 'Content-Type': 'text/html' });
525
+ res.end(renderAuthPage('error', 'Authentication Failed', error_ instanceof Error ? error_.message : 'Unknown error'));
526
+ closeServer();
527
+ reject(error_);
528
+ }
529
+ });
530
+ server.on('connection', (socket) => {
531
+ connections.add(socket);
532
+ socket.on('close', () => connections.delete(socket));
533
+ });
534
+ server.listen(CALLBACK_PORT, () => {
535
+ console.log('Opening browser for authentication…');
536
+ console.log(`If the browser doesn't open, visit: ${authorizeUrl1}`);
537
+ open(authorizeUrl1);
538
+ });
539
+ server.on('error', (err) => {
540
+ closeServer();
541
+ reject(new Error(`Failed to start local server: ${err.message}`));
542
+ });
543
+ timeoutRef.id = setTimeout(() => {
544
+ closeServer();
545
+ reject(new Error('Authentication timed out'));
546
+ }, 5 * 60 * 1000);
547
+ });
548
+ }
@@ -12,7 +12,12 @@ export interface TokenData {
12
12
  refresh_token?: string;
13
13
  tenant_id?: string;
14
14
  }
15
+ export interface CachedTenant {
16
+ name: string;
17
+ tenantId: string;
18
+ }
15
19
  export interface Context {
20
+ cachedTenants?: CachedTenant[];
16
21
  client_id: string;
17
22
  client_secret?: string;
18
23
  environment: Environment;
@@ -34,6 +39,8 @@ export declare function addContext(context: Context): void;
34
39
  export declare function updateContext(name: string, updates: Partial<Context>): void;
35
40
  export declare function deleteContext(name: string): void;
36
41
  export declare function saveTokens(contextName: string, tokens: TokenData): void;
42
+ export declare function saveCachedTenants(contextName: string, tenants: CachedTenant[]): void;
43
+ export declare function getCachedTenants(contextName: string): CachedTenant[] | undefined;
37
44
  export declare function getTokens(contextName: string): null | TokenData;
38
45
  export declare function clearTokens(contextName: string): void;
39
46
  export declare function getBaseUrls(environment: Environment): BaseUrls;
@@ -100,6 +100,12 @@ export function saveTokens(contextName, tokens) {
100
100
  tokens,
101
101
  });
102
102
  }
103
+ export function saveCachedTenants(contextName, tenants) {
104
+ updateContext(contextName, { cachedTenants: tenants });
105
+ }
106
+ export function getCachedTenants(contextName) {
107
+ return getContext(contextName)?.cachedTenants;
108
+ }
103
109
  export function getTokens(contextName) {
104
110
  const context = getContext(contextName);
105
111
  return context?.tokens || null;