@aderis-erp/cli 1.0.1 → 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.
- package/README.md +1 -1
- package/dist/commands/access-groups.js +36 -22
- package/dist/commands/account-transactions.js +30 -18
- package/dist/commands/accounts.js +30 -18
- package/dist/commands/agent-runs.js +33 -21
- package/dist/commands/{processing-logs.d.ts → async-endpoints-internal.d.ts} +2 -2
- package/dist/commands/{processing-logs.js → async-endpoints-internal.js} +39 -28
- package/dist/commands/audit-log.js +30 -18
- package/dist/commands/auth/tenant.d.ts +1 -0
- package/dist/commands/auth/tenant.js +23 -7
- package/dist/commands/bank-accounts.js +30 -18
- package/dist/commands/bank-integrations.d.ts +2 -0
- package/dist/commands/bank-integrations.js +41 -19
- package/dist/commands/business-partners---contacts.js +30 -18
- package/dist/commands/business-partners.js +30 -18
- package/dist/commands/credit-card-accounts.js +30 -18
- package/dist/commands/documents.js +36 -22
- package/dist/commands/dunning-configs.js +30 -18
- package/dist/commands/dunning-reminders.js +30 -18
- package/dist/commands/dunning-runs.js +30 -18
- package/dist/commands/employees.js +30 -18
- package/dist/commands/event-processor-mappings.js +30 -18
- package/dist/commands/files-internal.js +30 -18
- package/dist/commands/health.js +30 -18
- package/dist/commands/inbox.js +34 -20
- package/dist/commands/integrations.js +30 -18
- package/dist/commands/item-categories.js +30 -18
- package/dist/commands/items.js +30 -18
- package/dist/commands/journal-lines.js +30 -18
- package/dist/commands/journals.js +30 -18
- package/dist/commands/ledgers.js +30 -18
- package/dist/commands/office-locations.js +30 -18
- package/dist/commands/payment-method-ledger-mappings.js +30 -18
- package/dist/commands/payment-methods.js +30 -18
- package/dist/commands/payment-runs.js +30 -18
- package/dist/commands/payment-terms.js +30 -18
- package/dist/commands/price-lists.js +30 -18
- package/dist/commands/processor-mappings.js +30 -18
- package/dist/commands/processors.js +31 -19
- package/dist/commands/reconciliation.js +30 -18
- package/dist/commands/reports.js +30 -18
- package/dist/commands/salary-types.js +30 -18
- package/dist/commands/sales-invoices.js +32 -19
- package/dist/commands/sales-orders.js +33 -19
- package/dist/commands/switch.d.ts +1 -0
- package/dist/commands/switch.js +58 -3
- package/dist/commands/tags.js +30 -18
- package/dist/commands/tax-codes.js +30 -18
- package/dist/commands/tax-matrix.js +30 -18
- package/dist/commands/tenants.js +30 -18
- package/dist/commands/text-templates.js +30 -18
- package/dist/commands/tools.js +30 -18
- package/dist/commands/vendor-invoices.js +33 -19
- package/dist/commands/webhooks.js +30 -18
- package/dist/commands/workflows.js +30 -18
- package/dist/index 2.js +1 -0
- package/dist/index.d 2.ts +1 -0
- package/dist/lib/auth.d.ts +16 -1
- package/dist/lib/auth.js +167 -50
- package/dist/lib/config.d.ts +7 -0
- package/dist/lib/config.js +6 -0
- package/oclif.manifest.json +65 -385
- package/package.json +2 -2
- package/dist/commands/account-statement-runs.d.ts +0 -40
- package/dist/commands/account-statement-runs.js +0 -1004
- package/dist/commands/bank-statements.d.ts +0 -40
- package/dist/commands/bank-statements.js +0 -998
package/dist/commands/tools.js
CHANGED
|
@@ -812,32 +812,44 @@ export default class Tools extends Command {
|
|
|
812
812
|
filterFields(data, fields) {
|
|
813
813
|
const isUnsafeKey = (k) => k === '__proto__' || k === 'constructor' || k === 'prototype';
|
|
814
814
|
const pick = (obj, keys) => {
|
|
815
|
-
|
|
815
|
+
// Group keys by their first segment so sibling sub-paths
|
|
816
|
+
// (e.g. 'items.amount' + 'items._type') resolve in one recursive pass
|
|
817
|
+
// and don't clobber each other on merge.
|
|
818
|
+
const directKeys = new Set();
|
|
819
|
+
const nestedKeys = new Map();
|
|
816
820
|
for (const key of keys) {
|
|
817
821
|
if (key.split('.').some(isUnsafeKey))
|
|
818
822
|
continue;
|
|
819
823
|
if (key.includes('.')) {
|
|
820
|
-
// Handle nested fields like 'customer.name' or 'allocations.salesInvoiceId'
|
|
821
824
|
const [first, ...rest] = key.split('.');
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
const existing = Array.isArray(result[first]) ? result[first] : [];
|
|
828
|
-
result[first] = val.map((item, i) => ({
|
|
829
|
-
...(existing[i] || {}),
|
|
830
|
-
...pick(item, [restKey]),
|
|
831
|
-
}));
|
|
832
|
-
}
|
|
833
|
-
else {
|
|
834
|
-
const nested = pick(val, [restKey]);
|
|
835
|
-
result[first] = { ...(result[first] || {}), ...nested };
|
|
836
|
-
}
|
|
825
|
+
if (isUnsafeKey(first))
|
|
826
|
+
continue;
|
|
827
|
+
const list = nestedKeys.get(first);
|
|
828
|
+
if (list) {
|
|
829
|
+
list.push(rest.join('.'));
|
|
837
830
|
}
|
|
831
|
+
else {
|
|
832
|
+
nestedKeys.set(first, [rest.join('.')]);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
else {
|
|
836
|
+
directKeys.add(key);
|
|
838
837
|
}
|
|
839
|
-
|
|
838
|
+
}
|
|
839
|
+
const result = {};
|
|
840
|
+
for (const key of directKeys) {
|
|
841
|
+
if (key in obj)
|
|
840
842
|
result[key] = obj[key];
|
|
843
|
+
}
|
|
844
|
+
for (const [first, restKeys] of nestedKeys) {
|
|
845
|
+
const val = obj[first];
|
|
846
|
+
if (!val || typeof val !== 'object')
|
|
847
|
+
continue;
|
|
848
|
+
if (Array.isArray(val)) {
|
|
849
|
+
result[first] = val.map(item => pick(item, restKeys));
|
|
850
|
+
}
|
|
851
|
+
else {
|
|
852
|
+
result[first] = pick(val, restKeys);
|
|
841
853
|
}
|
|
842
854
|
}
|
|
843
855
|
return result;
|
|
@@ -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
|
-
|
|
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
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
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
|
-
|
|
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;
|
package/dist/index 2.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { run } from '@oclif/core';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { run } from '@oclif/core';
|
package/dist/lib/auth.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type Context, type TokenData } from './config.js';
|
|
2
|
-
export declare function login(context: Context
|
|
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,8 +4,8 @@ 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';
|
|
8
|
-
function buildValidatedUrl(baseUrl) {
|
|
7
|
+
import { getBaseUrls, getContext, saveCachedTenants, saveTokens, } from './config.js';
|
|
8
|
+
function buildValidatedUrl(baseUrl, path) {
|
|
9
9
|
try {
|
|
10
10
|
// Minimal path validation
|
|
11
11
|
if (baseUrl.includes('/../') || /\/%2e%2e\//i.test(baseUrl)) {
|
|
@@ -24,6 +24,15 @@ function buildValidatedUrl(baseUrl) {
|
|
|
24
24
|
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
25
25
|
throw new Error('Invalid protocol');
|
|
26
26
|
}
|
|
27
|
+
// Add path if provided
|
|
28
|
+
if (path) {
|
|
29
|
+
if (path.includes('/../') || /\/%2e%2e\//i.test(path)) {
|
|
30
|
+
throw new Error('Invalid path');
|
|
31
|
+
}
|
|
32
|
+
// Ensure path starts with / and append to pathname
|
|
33
|
+
const cleanPath = path.startsWith('/') ? path : '/' + path;
|
|
34
|
+
url.pathname = url.pathname.replace(/\/$/, '') + cleanPath;
|
|
35
|
+
}
|
|
27
36
|
return url.href;
|
|
28
37
|
}
|
|
29
38
|
catch {
|
|
@@ -131,19 +140,24 @@ function generatePKCE() {
|
|
|
131
140
|
function generateState() {
|
|
132
141
|
return crypto.randomBytes(16).toString('hex');
|
|
133
142
|
}
|
|
134
|
-
|
|
143
|
+
function buildAuthorizeUrl(context, pkce, state, options = {}) {
|
|
135
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 = {}) {
|
|
136
158
|
const pkce = generatePKCE();
|
|
137
159
|
const state = generateState();
|
|
138
|
-
|
|
139
|
-
const authorizationUrl = new URL(`${authUrl}/oauth/authorize`);
|
|
140
|
-
authorizationUrl.searchParams.set('client_id', context.client_id);
|
|
141
|
-
authorizationUrl.searchParams.set('redirect_uri', REDIRECT_URI);
|
|
142
|
-
authorizationUrl.searchParams.set('response_type', 'code');
|
|
143
|
-
authorizationUrl.searchParams.set('state', state);
|
|
144
|
-
authorizationUrl.searchParams.set('code_challenge', pkce.challenge);
|
|
145
|
-
authorizationUrl.searchParams.set('code_challenge_method', 'S256');
|
|
146
|
-
authorizationUrl.searchParams.set('scope', 'openid profile email offline_access');
|
|
160
|
+
const authorizationUrl = buildAuthorizeUrl(context, pkce, state, options);
|
|
147
161
|
return new Promise((resolve, reject) => {
|
|
148
162
|
const connections = new Set();
|
|
149
163
|
let timeoutId;
|
|
@@ -209,8 +223,8 @@ export async function login(context) {
|
|
|
209
223
|
};
|
|
210
224
|
server.listen(CALLBACK_PORT, () => {
|
|
211
225
|
console.log(`Opening browser for authentication...`);
|
|
212
|
-
console.log(`If the browser doesn't open, visit: ${authorizationUrl
|
|
213
|
-
open(authorizationUrl
|
|
226
|
+
console.log(`If the browser doesn't open, visit: ${authorizationUrl}`);
|
|
227
|
+
open(authorizationUrl);
|
|
214
228
|
});
|
|
215
229
|
server.on('error', (err) => {
|
|
216
230
|
closeServer();
|
|
@@ -234,7 +248,7 @@ async function exchangeCodeForTokens(context, code, codeVerifier) {
|
|
|
234
248
|
if (context.client_secret) {
|
|
235
249
|
params.set('client_secret', context.client_secret);
|
|
236
250
|
}
|
|
237
|
-
const response = await axios.post(buildValidatedUrl(
|
|
251
|
+
const response = await axios.post(buildValidatedUrl(authUrl, '/oauth/token'), params.toString(), {
|
|
238
252
|
headers: {
|
|
239
253
|
Accept: 'application/json',
|
|
240
254
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
@@ -267,7 +281,7 @@ export async function refreshTokens(contextName) {
|
|
|
267
281
|
}
|
|
268
282
|
let response;
|
|
269
283
|
try {
|
|
270
|
-
response = await axios.post(buildValidatedUrl(
|
|
284
|
+
response = await axios.post(buildValidatedUrl(authUrl, '/oauth/token'), params.toString(), {
|
|
271
285
|
headers: {
|
|
272
286
|
Accept: 'application/json',
|
|
273
287
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
@@ -316,7 +330,7 @@ export async function testAuth(contextName) {
|
|
|
316
330
|
}
|
|
317
331
|
const token = await getValidToken(contextName);
|
|
318
332
|
const { api: apiUrl } = getBaseUrls(context.environment);
|
|
319
|
-
const response = await axios.get(buildValidatedUrl(
|
|
333
|
+
const response = await axios.get(buildValidatedUrl(apiUrl, '/v1/auth/me'), {
|
|
320
334
|
headers: {
|
|
321
335
|
Accept: 'application/json',
|
|
322
336
|
Authorization: `Bearer ${token}`,
|
|
@@ -334,7 +348,7 @@ export async function listTenants(contextName) {
|
|
|
334
348
|
let response;
|
|
335
349
|
try {
|
|
336
350
|
// Frontegg endpoint to list user's tenants
|
|
337
|
-
response = await axios.get(buildValidatedUrl(
|
|
351
|
+
response = await axios.get(buildValidatedUrl(authUrl, '/identity/resources/users/v2/me/tenants'), {
|
|
338
352
|
headers: {
|
|
339
353
|
Accept: 'application/json',
|
|
340
354
|
Authorization: `Bearer ${token}`,
|
|
@@ -348,12 +362,16 @@ export async function listTenants(contextName) {
|
|
|
348
362
|
throw error;
|
|
349
363
|
}
|
|
350
364
|
// Normalize the response - Frontegg may use different field names
|
|
351
|
-
|
|
365
|
+
const tenants = response.data.map((t) => ({
|
|
352
366
|
createdAt: t.createdAt,
|
|
353
367
|
isDefault: t.isDefault,
|
|
354
368
|
name: t.name || t.tenantName || t.tenantId,
|
|
355
369
|
tenantId: t.tenantId,
|
|
356
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;
|
|
357
375
|
}
|
|
358
376
|
export async function getCurrentTenant(contextName) {
|
|
359
377
|
const context = getContext(contextName);
|
|
@@ -389,38 +407,14 @@ export async function switchTenant(contextName, tenantId) {
|
|
|
389
407
|
if (!context.tokens?.refresh_token) {
|
|
390
408
|
throw new Error('No refresh token available. Please login again.');
|
|
391
409
|
}
|
|
392
|
-
const { auth: authUrl } = getBaseUrls(context.environment);
|
|
393
|
-
const token = await getValidToken(contextName);
|
|
394
410
|
try {
|
|
395
|
-
//
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
});
|
|
403
|
-
// Step 2: Refresh tokens to get new tokens for the switched tenant
|
|
404
|
-
const response = await axios.post(buildValidatedUrl(`${authUrl}/oauth/token`), new URLSearchParams({
|
|
405
|
-
client_id: context.client_id,
|
|
406
|
-
grant_type: 'refresh_token',
|
|
407
|
-
refresh_token: context.tokens.refresh_token,
|
|
408
|
-
}).toString(), {
|
|
409
|
-
headers: {
|
|
410
|
-
Accept: 'application/json',
|
|
411
|
-
'Content-Type': 'application/x-www-form-urlencoded',
|
|
412
|
-
},
|
|
413
|
-
});
|
|
414
|
-
const tokens = response.data;
|
|
415
|
-
const decoded = jwt.decode(tokens.access_token);
|
|
416
|
-
const tokenData = {
|
|
417
|
-
access_token: tokens.access_token,
|
|
418
|
-
expires_at: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : undefined,
|
|
419
|
-
refresh_token: tokens.refresh_token || context.tokens.refresh_token,
|
|
420
|
-
tenant_id: decoded?.tenantId || decoded?.tenant_id,
|
|
421
|
-
};
|
|
422
|
-
saveTokens(contextName, tokenData);
|
|
423
|
-
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);
|
|
424
418
|
}
|
|
425
419
|
catch (error) {
|
|
426
420
|
if (axios.isAxiosError(error) && (error.response?.status === 401 || error.response?.status === 400)) {
|
|
@@ -429,3 +423,126 @@ export async function switchTenant(contextName, tenantId) {
|
|
|
429
423
|
throw error;
|
|
430
424
|
}
|
|
431
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
|
+
}
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/config.js
CHANGED
|
@@ -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;
|