diff --git a/README.md b/README.md index c09fd04..f1c3eb0 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,12 @@ const card = await client.accessCards.provision({ email: "employee@yourwebsite.com", phoneNumber: "+19547212241", classification: "full_time", + department: "Engineering", + location: "San Francisco", + siteName: "HQ Building A", + workstation: "4F-207", + mailStop: "MS-401", + companyAddress: "123 Main St, San Francisco, CA 94105", startDate: new Date().toISOString(), expirationDate: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(), employeePhoto: "[image_in_base64_encoded_format]", @@ -290,6 +296,37 @@ console.log(`Status: ${result.status}`); ### Webhooks +#### Create a webhook + +```javascript +const webhook = await client.console.webhooks.create({ + name: 'Production', + url: 'https://example.com/webhooks', + subscribedEvents: ['ag.access_pass.issued'] +}); + +console.log(`Webhook created: ${webhook.id}`); +console.log(`Private key: ${webhook.privateKey}`); +``` + +#### List webhooks + +```javascript +const webhooks = await client.console.webhooks.list(); + +webhooks.forEach(webhook => { + console.log(`ID: ${webhook.id}, Name: ${webhook.name}`); +}); +``` + +#### Delete a webhook + +```javascript +await client.console.webhooks.delete('abc123'); +``` + +#### Receiving webhook payloads + ```javascript const express = require('express'); const app = express(); @@ -321,6 +358,77 @@ app.post('/webhooks', (req, res) => { }); ``` +### Landing Pages + +#### List landing pages + +```javascript +const landingPages = await client.console.listLandingPages(); + +landingPages.forEach(page => { + console.log(`ID: ${page.id}, Name: ${page.name}, Kind: ${page.kind}`); + console.log(` Password Protected: ${page.passwordProtected}`); + if (page.logoUrl) console.log(` Logo URL: ${page.logoUrl}`); +}); +``` + +#### Create a landing page + +```javascript +const landingPage = await client.console.createLandingPage({ + name: "Miami Office Access Pass", + kind: "universal", + additionalText: "Welcome to the Miami Office", + bgColor: "#f1f5f9", + allowImmediateDownload: true +}); + +console.log(`Landing page created: ${landingPage.id}`); +console.log(`Name: ${landingPage.name}, Kind: ${landingPage.kind}`); +``` + +#### Update a landing page + +```javascript +const landingPage = await client.console.updateLandingPage({ + landingPageId: "0xlandingpage1d", + name: "Updated Miami Office Access Pass", + additionalText: "Welcome! Tap below to get your access pass.", + bgColor: "#e2e8f0" +}); + +console.log(`Landing page updated: ${landingPage.id}`); +console.log(`Name: ${landingPage.name}`); +``` + +### Credential Profiles + +#### List credential profiles + +```javascript +const profiles = await client.console.credentialProfiles.list(); + +profiles.forEach(profile => { + console.log(`ID: ${profile.id}, Name: ${profile.name}, AID: ${profile.aid}`); +}); +``` + +#### Create a credential profile + +```javascript +const profile = await client.console.credentialProfiles.create({ + name: 'Main Office Profile', + appName: 'KEY-ID-main', + keys: [ + { value: 'your_32_char_hex_master_key_here' }, + { value: 'your_32_char_hex__read_key__here' } + ] +}); + +console.log(`Profile created: ${profile.id}`); +console.log(`AID: ${profile.aid}`); +``` + ## Configuration ```javascript @@ -373,7 +481,14 @@ MIT License - See LICENSE file for details. | GET /v1/console/pass-template-pairs | `console.listPassTemplatePairs()` | Y | | GET /v1/console/ledger-items | `console.ledgerItems()` | Y | | POST .../ios_preflight | `console.iosPreflight()` | Y | +| GET /v1/console/webhooks | `console.webhooks.list()` | Y | +| POST /v1/console/webhooks | `console.webhooks.create()` | Y | +| DELETE /v1/console/webhooks/{id} | `console.webhooks.delete()` | Y | +| GET /v1/console/landing-pages | `console.listLandingPages()` | Y | +| POST /v1/console/landing-pages | `console.createLandingPage()` | Y | +| PUT /v1/console/landing-pages/{id} | `console.updateLandingPage()` | Y | +| GET /v1/console/credential-profiles | `console.credentialProfiles.list()` | Y | +| POST /v1/console/credential-profiles | `console.credentialProfiles.create()` | Y | | POST /v1/console/hid/orgs | `console.hid.orgs.create()` | Y | | GET /v1/console/hid/orgs | `console.hid.orgs.list()` | Y | | POST /v1/console/hid/orgs/activate | `console.hid.orgs.activate()` | Y | -| Webhooks (payload) | CloudEvents receiver | Y | diff --git a/package.json b/package.json index 11ef9d2..f8a1d5f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "accessgrid", - "version": "1.2.1", + "version": "1.3.0", "description": "JavaScript SDK for the AccessGrid API", "main": "dist/index.js", "module": "dist/index.mjs", diff --git a/src/index.js b/src/index.js index 5d0fe54..1be2ac5 100644 --- a/src/index.js +++ b/src/index.js @@ -29,6 +29,10 @@ class AccessCard { this.fileData = data.file_data; this.directInstallUrl = data.direct_install_url; this.title = data.title; + this.temporary = data.temporary; + this.employeeId = data.employee_id; + this.organizationName = data.organization_name; + this.createdAt = data.created_at; this.devices = data.devices || []; this.metadata = data.metadata || {}; } @@ -153,7 +157,7 @@ class BaseApi { this.accountId = accountId; this.secretKey = secretKey; this.baseUrl = baseUrl.replace(/\/$/, ""); // Remove trailing slash if present - this.version = "1.2.0"; // Should come from package.json + this.version = "1.3.0"; // Should come from package.json } async request(path, options = {}) { @@ -321,6 +325,12 @@ class AccessCardsApi extends BaseApi { isPassReadyToTransact: "is_pass_ready_to_transact", tileData: "tile_data", reservations: "reservations", + department: "department", + location: "location", + siteName: "site_name", + workstation: "workstation", + mailStop: "mail_stop", + companyAddress: "company_address", siteCode: "site_code", cardNumber: "card_number", fileData: "file_data", @@ -452,6 +462,12 @@ class ConsoleApi extends BaseApi { this.hid = { orgs: new HIDOrgsApi(accountId, secretKey, baseUrl), }; + this.webhooks = new WebhooksApi(accountId, secretKey, baseUrl); + this.credentialProfiles = new CredentialProfilesApi( + accountId, + secretKey, + baseUrl, + ); } _buildTemplateBody(params) { @@ -599,6 +615,63 @@ class ConsoleApi extends BaseApi { return response; } + async listLandingPages() { + const response = await this.request("/v1/console/landing-pages"); + const pages = Array.isArray(response) ? response : []; + return pages.map((p) => new LandingPage(p)); + } + + async createLandingPage(params) { + const paramMapping = { + name: "name", + kind: "kind", + additionalText: "additional_text", + bgColor: "bg_color", + allowImmediateDownload: "allow_immediate_download", + password: "password", + is2faEnabled: "is_2fa_enabled", + logo: "logo", + }; + + const body = {}; + for (const [jsKey, apiKey] of Object.entries(paramMapping)) { + if (params[jsKey] !== undefined) { + body[apiKey] = params[jsKey]; + } + } + + const response = await this.request("/v1/console/landing-pages", { + method: "POST", + body, + }); + return new LandingPage(response); + } + + async updateLandingPage(params) { + const paramMapping = { + name: "name", + additionalText: "additional_text", + bgColor: "bg_color", + allowImmediateDownload: "allow_immediate_download", + password: "password", + is2faEnabled: "is_2fa_enabled", + logo: "logo", + }; + + const body = {}; + for (const [jsKey, apiKey] of Object.entries(paramMapping)) { + if (params[jsKey] !== undefined) { + body[apiKey] = params[jsKey]; + } + } + + const response = await this.request( + `/v1/console/landing-pages/${params.landingPageId}`, + { method: "PUT", body }, + ); + return new LandingPage(response); + } + async listLedgerItems(params = {}) { const queryParams = new URLSearchParams(); if (params.page) queryParams.append("page", params.page); @@ -648,7 +721,8 @@ class HIDOrgsApi extends BaseApi { async list() { const response = await this.request("/v1/console/hid/orgs"); - return (response.hid_orgs || []).map((org) => new HIDOrg(org)); + const orgs = Array.isArray(response) ? response : response.hid_orgs || []; + return orgs.map((org) => new HIDOrg(org)); } async activate(params) { @@ -663,6 +737,109 @@ class HIDOrgsApi extends BaseApi { } } +// LandingPage model class +class LandingPage { + constructor(data = {}) { + this.id = data.id; + this.name = data.name; + this.createdAt = data.created_at; + this.kind = data.kind; + this.passwordProtected = data.password_protected; + this.logoUrl = data.logo_url; + } +} + +// CredentialProfile model class +class CredentialProfile { + constructor(data = {}) { + this.id = data.id; + this.aid = data.aid; + this.name = data.name; + this.appleId = data.apple_id; + this.createdAt = data.created_at; + this.cardStorage = data.card_storage; + this.keys = data.keys || []; + this.files = data.files || []; + } +} + +// Webhook model class +class Webhook { + constructor(data = {}) { + this.id = data.id; + this.name = data.name; + this.url = data.url; + this.authMethod = data.auth_method; + this.subscribedEvents = data.subscribed_events || []; + this.createdAt = data.created_at; + this.privateKey = data.private_key; + this.clientCert = data.client_cert; + this.certExpiresAt = data.cert_expires_at; + } +} + +// Webhooks API handling +class WebhooksApi extends BaseApi { + constructor(accountId, secretKey, baseUrl) { + super(accountId, secretKey, baseUrl); + } + + async create(params) { + const body = { + name: params.name, + url: params.url, + subscribed_events: params.subscribedEvents, + }; + if (params.authMethod) body.auth_method = params.authMethod; + + const response = await this.request("/v1/console/webhooks", { + method: "POST", + body, + }); + return new Webhook(response); + } + + async list() { + const response = await this.request("/v1/console/webhooks"); + const webhooks = response.webhooks || []; + return webhooks.map((w) => new Webhook(w)); + } + + async delete(webhookId) { + await this.request(`/v1/console/webhooks/${webhookId}`, { + method: "DELETE", + }); + } +} + +// Credential Profiles API handling +class CredentialProfilesApi extends BaseApi { + constructor(accountId, secretKey, baseUrl) { + super(accountId, secretKey, baseUrl); + } + + async create(params) { + const body = { + name: params.name, + app_name: params.appName, + keys: params.keys, + }; + if (params.fileId) body.file_id = params.fileId; + + const response = await this.request("/v1/console/credential-profiles", { + method: "POST", + body, + }); + return new CredentialProfile(response); + } + + async list() { + const response = await this.request("/v1/console/credential-profiles"); + const profiles = Array.isArray(response) ? response : []; + return profiles.map((p) => new CredentialProfile(p)); + } +} + // Main AccessGrid class class AccessGrid { constructor(accountId, secretKey, options = {}) { @@ -689,6 +866,9 @@ export { LedgerItem, LedgerItemAccessPass, LedgerItemPassTemplate, + LandingPage, + CredentialProfile, + Webhook, }; // Default export diff --git a/test/index.test.js b/test/index.test.js index 10fbe6d..702f5b1 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -1,4 +1,4 @@ -import AccessGrid, { AccessGridError, AuthenticationError, AccessCard, Template, PassTemplatePair, TemplateInfo, HIDOrg, LedgerItem, LedgerItemAccessPass, LedgerItemPassTemplate } from '../src/index'; +import AccessGrid, { AccessGridError, AuthenticationError, AccessCard, Template, PassTemplatePair, TemplateInfo, HIDOrg, LedgerItem, LedgerItemAccessPass, LedgerItemPassTemplate, LandingPage, CredentialProfile, Webhook } from '../src/index'; // ══════════════════════════════════════════════════════════════════════════════ // Global mocks @@ -1305,5 +1305,366 @@ describe('AccessGrid SDK', () => { const callBody = JSON.parse(fetch.mock.calls[0][1].body); expect(callBody.title).toBe('Senior Developer'); }); + + test('should include department, location, siteName, workstation, mailStop, companyAddress in provision body', async () => { + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ id: 'card-123' }) + }); + + await client.accessCards.provision({ + cardTemplateId: '0xd3adb00b5', + fullName: 'Test User', + startDate: '2025-01-01T00:00:00Z', + expirationDate: '2025-12-31T00:00:00Z', + department: 'Engineering', + location: 'San Francisco', + siteName: 'HQ Building A', + workstation: '4F-207', + mailStop: 'MS-401', + companyAddress: '123 Main St, San Francisco, CA 94105' + }); + + const callBody = JSON.parse(fetch.mock.calls[0][1].body); + expect(callBody.department).toBe('Engineering'); + expect(callBody.location).toBe('San Francisco'); + expect(callBody.site_name).toBe('HQ Building A'); + expect(callBody.workstation).toBe('4F-207'); + expect(callBody.mail_stop).toBe('MS-401'); + expect(callBody.company_address).toBe('123 Main St, San Francisco, CA 94105'); + }); + }); + + // ════════════════════════════════════════════════════════════════════════════ + // AccessCard model — new fields + // ════════════════════════════════════════════════════════════════════════════ + + describe('AccessCard new fields', () => { + test('should have employeeId, organizationName, temporary, createdAt', () => { + const card = new AccessCard({ + id: 'card-123', + employee_id: 'emp_789', + organization_name: 'Acme Corp', + temporary: true, + created_at: '2025-06-01T00:00:00Z' + }); + + expect(card.employeeId).toBe('emp_789'); + expect(card.organizationName).toBe('Acme Corp'); + expect(card.temporary).toBe(true); + expect(card.createdAt).toBe('2025-06-01T00:00:00Z'); + }); + + test('should default new fields to undefined when missing', () => { + const card = new AccessCard({ id: 'card-minimal' }); + + expect(card.employeeId).toBeUndefined(); + expect(card.organizationName).toBeUndefined(); + expect(card.temporary).toBeUndefined(); + expect(card.createdAt).toBeUndefined(); + }); + }); + + // ════════════════════════════════════════════════════════════════════════════ + // Landing Pages + // ════════════════════════════════════════════════════════════════════════════ + + describe('Landing Pages', () => { + test('LandingPage model should have correct properties', () => { + const page = new LandingPage({ + id: 'lp-1', + name: 'Miami Office', + created_at: '2025-01-01T00:00:00Z', + kind: 'universal', + password_protected: false, + logo_url: 'https://example.com/logo.png' + }); + + expect(page.id).toBe('lp-1'); + expect(page.name).toBe('Miami Office'); + expect(page.createdAt).toBe('2025-01-01T00:00:00Z'); + expect(page.kind).toBe('universal'); + expect(page.passwordProtected).toBe(false); + expect(page.logoUrl).toBe('https://example.com/logo.png'); + }); + + test('listLandingPages should return LandingPage instances', async () => { + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve([ + { id: 'lp-1', name: 'Page 1', kind: 'universal', password_protected: false }, + { id: 'lp-2', name: 'Page 2', kind: 'specific', password_protected: true } + ]) + }); + + const pages = await client.console.listLandingPages(); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/v1/console/landing-pages'), + expect.objectContaining({ method: 'GET' }) + ); + expect(pages).toHaveLength(2); + expect(pages[0]).toBeInstanceOf(LandingPage); + expect(pages[0].name).toBe('Page 1'); + }); + + test('listLandingPages should handle empty array', async () => { + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve([]) + }); + + const pages = await client.console.listLandingPages(); + expect(pages).toHaveLength(0); + }); + + test('createLandingPage should send correct params', async () => { + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + id: 'lp-new', name: 'Miami Office', kind: 'universal' + }) + }); + + const page = await client.console.createLandingPage({ + name: 'Miami Office Access Pass', + kind: 'universal', + additionalText: 'Welcome to the Miami Office', + bgColor: '#f1f5f9', + allowImmediateDownload: true + }); + + const callBody = JSON.parse(fetch.mock.calls[0][1].body); + expect(callBody.name).toBe('Miami Office Access Pass'); + expect(callBody.kind).toBe('universal'); + expect(callBody.additional_text).toBe('Welcome to the Miami Office'); + expect(callBody.bg_color).toBe('#f1f5f9'); + expect(callBody.allow_immediate_download).toBe(true); + expect(page).toBeInstanceOf(LandingPage); + }); + + test('updateLandingPage should send correct params', async () => { + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + id: 'lp-1', name: 'Updated Page' + }) + }); + + const page = await client.console.updateLandingPage({ + landingPageId: 'lp-1', + name: 'Updated Miami Office Access Pass', + additionalText: 'Welcome! Tap below to get your access pass.', + bgColor: '#e2e8f0' + }); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/v1/console/landing-pages/lp-1'), + expect.objectContaining({ method: 'PUT' }) + ); + const callBody = JSON.parse(fetch.mock.calls[0][1].body); + expect(callBody.name).toBe('Updated Miami Office Access Pass'); + expect(callBody.additional_text).toBe('Welcome! Tap below to get your access pass.'); + expect(callBody.bg_color).toBe('#e2e8f0'); + expect(page).toBeInstanceOf(LandingPage); + }); + }); + + // ════════════════════════════════════════════════════════════════════════════ + // Credential Profiles + // ════════════════════════════════════════════════════════════════════════════ + + describe('Credential Profiles', () => { + test('CredentialProfile model should have correct properties', () => { + const profile = new CredentialProfile({ + id: 'cp-1', + aid: 'F00001', + name: 'Main Office Profile', + apple_id: 'apple-123', + created_at: '2025-01-01T00:00:00Z', + card_storage: '8K', + keys: [{ ex_id: 'key-1', label: 'Master Key' }], + files: [{ ex_id: 'file-1', file_type: 'standard' }] + }); + + expect(profile.id).toBe('cp-1'); + expect(profile.aid).toBe('F00001'); + expect(profile.name).toBe('Main Office Profile'); + expect(profile.appleId).toBe('apple-123'); + expect(profile.createdAt).toBe('2025-01-01T00:00:00Z'); + expect(profile.cardStorage).toBe('8K'); + expect(profile.keys).toHaveLength(1); + expect(profile.files).toHaveLength(1); + }); + + test('CredentialProfile defaults keys and files to empty arrays', () => { + const profile = new CredentialProfile({ id: 'cp-1' }); + expect(profile.keys).toEqual([]); + expect(profile.files).toEqual([]); + }); + + test('credentialProfiles.list should return CredentialProfile instances', async () => { + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve([ + { id: 'cp-1', aid: 'F00001', name: 'Profile 1' }, + { id: 'cp-2', aid: 'F00002', name: 'Profile 2' } + ]) + }); + + const profiles = await client.console.credentialProfiles.list(); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/v1/console/credential-profiles'), + expect.objectContaining({ method: 'GET' }) + ); + expect(profiles).toHaveLength(2); + expect(profiles[0]).toBeInstanceOf(CredentialProfile); + expect(profiles[0].aid).toBe('F00001'); + }); + + test('credentialProfiles.list should handle empty array', async () => { + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve([]) + }); + + const profiles = await client.console.credentialProfiles.list(); + expect(profiles).toHaveLength(0); + }); + + test('credentialProfiles.create should send correct params', async () => { + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + id: 'cp-new', aid: 'F00003', name: 'Main Office Profile' + }) + }); + + const profile = await client.console.credentialProfiles.create({ + name: 'Main Office Profile', + appName: 'KEY-ID-main', + keys: [ + { value: 'your_32_char_hex_master_key_here' }, + { value: 'your_32_char_hex__read_key__here' } + ] + }); + + const callBody = JSON.parse(fetch.mock.calls[0][1].body); + expect(callBody.name).toBe('Main Office Profile'); + expect(callBody.app_name).toBe('KEY-ID-main'); + expect(callBody.keys).toHaveLength(2); + expect(profile).toBeInstanceOf(CredentialProfile); + }); + }); + + // ════════════════════════════════════════════════════════════════════════════ + // Webhooks + // ════════════════════════════════════════════════════════════════════════════ + + describe('Webhooks', () => { + test('Webhook model should have correct properties', () => { + const webhook = new Webhook({ + id: 'wh-1', + name: 'Production', + url: 'https://example.com/webhooks', + auth_method: 'bearer_token', + subscribed_events: ['ag.access_pass.issued'], + created_at: '2025-01-01T00:00:00Z', + private_key: 'pk_123' + }); + + expect(webhook.id).toBe('wh-1'); + expect(webhook.name).toBe('Production'); + expect(webhook.url).toBe('https://example.com/webhooks'); + expect(webhook.authMethod).toBe('bearer_token'); + expect(webhook.subscribedEvents).toEqual(['ag.access_pass.issued']); + expect(webhook.createdAt).toBe('2025-01-01T00:00:00Z'); + expect(webhook.privateKey).toBe('pk_123'); + }); + + test('webhooks.create should send correct params', async () => { + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + id: 'wh-new', + name: 'Production', + url: 'https://example.com/webhooks', + subscribed_events: ['ag.access_pass.issued'], + private_key: 'pk_secret' + }) + }); + + const webhook = await client.console.webhooks.create({ + name: 'Production', + url: 'https://example.com/webhooks', + subscribedEvents: ['ag.access_pass.issued'] + }); + + const callBody = JSON.parse(fetch.mock.calls[0][1].body); + expect(callBody.name).toBe('Production'); + expect(callBody.url).toBe('https://example.com/webhooks'); + expect(callBody.subscribed_events).toEqual(['ag.access_pass.issued']); + expect(webhook).toBeInstanceOf(Webhook); + expect(webhook.privateKey).toBe('pk_secret'); + }); + + test('webhooks.list should return Webhook instances', async () => { + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + webhooks: [ + { id: 'wh-1', name: 'Prod', url: 'https://example.com/wh1' }, + { id: 'wh-2', name: 'Staging', url: 'https://example.com/wh2' } + ] + }) + }); + + const webhooks = await client.console.webhooks.list(); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/v1/console/webhooks'), + expect.objectContaining({ method: 'GET' }) + ); + expect(webhooks).toHaveLength(2); + expect(webhooks[0]).toBeInstanceOf(Webhook); + expect(webhooks[0].name).toBe('Prod'); + }); + + test('webhooks.delete should make correct API call', async () => { + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({}) + }); + + await client.console.webhooks.delete('wh-1'); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/v1/console/webhooks/wh-1'), + expect.objectContaining({ method: 'DELETE' }) + ); + }); + }); + + // ════════════════════════════════════════════════════════════════════════════ + // HID Orgs — flat array fix + // ════════════════════════════════════════════════════════════════════════════ + + describe('HID Orgs flat array response', () => { + test('should handle flat array response from API', async () => { + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve([ + { id: 'org-1', name: 'Org 1', slug: 'org-1' }, + { id: 'org-2', name: 'Org 2', slug: 'org-2' } + ]) + }); + + const orgs = await client.console.hid.orgs.list(); + + expect(orgs).toHaveLength(2); + expect(orgs[0]).toBeInstanceOf(HIDOrg); + expect(orgs[0].name).toBe('Org 1'); + }); }); });