Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 15 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,56 +1,51 @@
A set of functions to simplify building Salable applications in
JavaScript/Node.js.
A set of functions to simplify building Salable applications on the client.

## Installation

```sh
# npm
npm install @salable/js

# pnpm
pnpm add @salable/js

# yarn
yarn add @salable/js

# pnpm
pnpm install @salable/js
```

## Functions

The library features exports for both ECMAScript Modules (ESM) and CommonJS
(CJS).
The library exports both ECMAScript Modules (ESM) and CommonJS (CJS) builds.

```js
import salableJs from '@salable/js'
// or
const salableJs = require('@salable/js')
```

For convenience, the functions documented are also added to the `window` object
on the web under the `salable` object. So, `getGrantee` can be accessed via
`window.salable.getGrantee`.
For convenience, the functions documented are also added to the browser's
`window` object as part of the `salable` object. So, `getGrantee` can be
accessed via `window.salable.getGrantee`.

### `getGrantee({ apiKey: string, productUuid: string, granteeId?: string })`

Returns a list of capabilities for the current user. This function is scoped to
a grantee (through the provided `granteeId`) and a product (through the provided
`productUuid`).
Returns the features the provided grantee has access to.

Also returns a `hasCapability` utility function that simplifies the checking of
the provided user's capabilities.
Also returns a `hasFeature` utility function that simplifies feature checking.

#### Example

```js
import { getGrantee } from '@salable/js'

const { hasCapability, licenses, capabilities, isTest } = await getGrantee({
const { features, hasFeature } = await getGrantee({
apiKey: 'your-api-key',
productUuid: 'your-product-uuid',
granteeId: 'your-users-grantee-id',
})

if (hasCapability('edit')) {
console.log('You have the edit capability!')
if (hasFeature('edit')) {
console.log('Grantee has access to the edit feature!')
}
```

Expand All @@ -75,7 +70,7 @@ const { name, plans } = await getProduct({
})
```

### `getCheckoutLink({ apiKey: string, planUuid: string, successUrl: string, cancelUrl: string, granteeId: string, member: string })`
### `getCheckoutLink({ apiKey: string, planUuid: string, successUrl: string, cancelUrl: string, granteeId: string, owner: string })`

Returns a checkout link for the specified `planUuid`.

Expand All @@ -90,7 +85,7 @@ const checkoutLink = await getCheckoutLink({
successUrl: 'https://your.apps/success',
cancelUrl: 'https://your.apps/cancel',
granteeId: 'your-users-id',
member: 'your-users-id',
owner: 'your-users-id',
checkoutEmail: '', // optional, pre-fills email field in Stripe checkout
quantity: 5, // optional, the number of seats purchased on checkout (if using per-seat plan, default is minimum number set on plan)
currency: 'EUR', // optional, defaults to the product's default currency in Salable
Expand Down
2 changes: 1 addition & 1 deletion lib/__tests__/getCheckoutLink.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ describe('getCheckoutLink', () => {
cancelUrl: 'https://www.example.com',
successUrl: 'https://www.example.com',
granteeId: 'test-user-1',
member: 'test-owner-1',
owner: 'test-owner-1',
})

expect(checkoutUrl).toEqual('https://stripe.com/')
Expand Down
38 changes: 19 additions & 19 deletions lib/__tests__/getGrantee.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const baseGetUserValues = {
}

describe('getGrantee', () => {
describe('without license data', () => {
describe('without subscription data', () => {
beforeEach(() => {
fetchMock.mockOnce(null, { status: 204, statusText: 'No Content' })
})
Expand All @@ -19,7 +19,7 @@ describe('getGrantee', () => {
})
})

describe('with license data', () => {
describe('with subscription data', () => {
beforeEach(() => {
fetchMock.mockOnce(JSON.stringify(mockResponseData))
})
Expand All @@ -28,45 +28,45 @@ describe('getGrantee', () => {
expect(getGrantee(baseGetUserValues)).toBeTypeOf('function')
})

it('returns the correct capabilities', async () => {
const { capabilities } = await getGrantee({
it('returns the correct features', async () => {
const { features } = await getGrantee({
...baseGetUserValues,
granteeId: 'hi',
})

expect(capabilities).toMatchObject(['create', 'read', 'update', 'delete'])
expect(capabilities).toHaveLength(4)
expect(features).toMatchObject(['create', 'read', 'update', 'delete'])
expect(features).toHaveLength(4)
})

describe('hasCapability', () => {
it('correctly checks for individual capabilities', async () => {
const { hasCapability } = await getGrantee({
describe('hasFeature', () => {
it('correctly checks for individual features', async () => {
const { hasFeature } = await getGrantee({
...baseGetUserValues,
granteeId: 'test-user-1',
})

expect(hasCapability('Edit')).toEqual(false)
expect(hasCapability('Create')).toEqual(true)
expect(hasFeature('Edit')).toEqual(false)
expect(hasFeature('Create')).toEqual(true)
})

it('treats capability names as case-insensitive', async () => {
const { hasCapability } = await getGrantee({
it('treats feature names as case-insensitive', async () => {
const { hasFeature } = await getGrantee({
...baseGetUserValues,
granteeId: 'test-user-1',
})

expect(hasCapability('Create')).toEqual(true)
expect(hasCapability('create')).toEqual(true)
expect(hasCapability('CReaTE')).toEqual(true)
expect(hasFeature('Create')).toEqual(true)
expect(hasFeature('create')).toEqual(true)
expect(hasFeature('CReaTE')).toEqual(true)
})

it('correctly checks multiple capabilities', async () => {
const { hasCapability } = await getGrantee({
it('correctly checks multiple features', async () => {
const { hasFeature } = await getGrantee({
...baseGetUserValues,
granteeId: 'test-user-1',
})

expect(hasCapability(['edit', 'create'])).toMatchObject({
expect(hasFeature(['edit', 'create'])).toMatchObject({
edit: false,
create: true,
})
Expand Down
21 changes: 8 additions & 13 deletions lib/getCheckoutLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export type GetCheckoutLinkArgs = {
successUrl: string
cancelUrl: string
granteeId: string
member: string
owner: string
checkoutEmail?: string
quantity?: number
currency?: 'EUR' | 'USD' | 'GBP'
Expand All @@ -16,28 +16,23 @@ export async function getCheckoutLink({
successUrl,
cancelUrl,
granteeId,
member,
owner,
checkoutEmail,
quantity,
currency,
}: GetCheckoutLinkArgs) {
const rawSearchParams = {
const searchParams = Object.entries({
successUrl,
cancelUrl,
granteeId,
member,
owner,
customerEmail: checkoutEmail,
quantity: quantity?.toString(),
currency,
}

const searchParams = Object.entries(rawSearchParams).reduce(
(acc, [key, value]) => {
if (!value) return acc
return { ...acc, [key]: value }
},
{},
)
}).reduce((acc, [key, value]) => {
if (!value) return acc
return { ...acc, [key]: value }
}, {})

const url =
`https://api.salable.app/plans/${planUuid}/checkoutlink?` +
Expand Down
62 changes: 27 additions & 35 deletions lib/getGrantee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,38 +4,32 @@ type GetGranteeParams = {
granteeId: string
}

type HasCapability = {
(capability: string): boolean
<T extends string>(capabilities: readonly T[]): { [Key in T]: boolean }
type HasFeature = {
(feature: string): boolean
<T extends string>(features: readonly T[]): { [Key in T]: boolean }
}

export type UserData = {
/**
* A list of combined capabilities that the user has access to based on their
* active licenses. This array contains both active and in-active
* capabilities.
* A list of features that the grantee has access to based on their
* active subscriptions.
*/
capabilities: string[]
features: string[]
/**
* Used to check whether a user has either an active capability, or a list of
* supplied capabilities. Capability names are case insensitive.
* Used to check whether a grantee has an active feature or number of
* features. Feature names are case insensitive.
*
* All inactive capabilities will return false. This includes capabilities
* that are on 'canceled' licenses. If you want to check for a capability
* that isn't active, or is on a canceled license, use the `capabilities`
* array returned by this hook.
*
* To check a single capability:
* To check a single feature:
* ```
* const hasCreate = hasCapability('create');
* const hasCreate = hasFeature('create');
* ```
*
* To check a list of capabilities:
* To check a list of features:
* ```
* const { create, update } = hasCapability(['create', 'update']);
* const { create, update } = hasFeature(['create', 'update']);
* ```
*/
hasCapability: HasCapability
hasFeature: HasFeature
}

async function _getGrantee({
Expand All @@ -55,42 +49,40 @@ async function _getGrantee({
if (!response.status.toString().startsWith('2'))
throw new Error('Could not fetch user licenses...')

let capabilities: string[] = []
let features: string[] = []
try {
capabilities = (await response.json()).capabilities
features = (await response.json()).capabilities
} catch (error) {}

// Overload for checking an individual capability.
function hasCapability(capability: string): boolean
function hasFeature(feature: string): boolean
// Overload for checking a list of capabilities and receiving an object with
// those capabilities as keys.
function hasCapability<T extends string>(
capabilities: readonly T[],
function hasFeature<T extends string>(
features: readonly T[],
): { [Key in T]: boolean }

// Implementation of the hasCapability function based on the above overloads.
function hasCapability<T extends string>(
capabilityOrCapabilities: string | readonly string[],
function hasFeature<T extends string>(
featureOrFeatures: string | readonly string[],
): boolean | { [Key in T]: boolean } {
const activeCapabilities = capabilities.map((capability) =>
capability.toLowerCase(),
)
const lowerCasedFeatures = features.map((f) => f.toLowerCase())

if (typeof capabilityOrCapabilities === 'string') {
return activeCapabilities.includes(capabilityOrCapabilities.toLowerCase())
if (typeof featureOrFeatures === 'string') {
return lowerCasedFeatures.includes(featureOrFeatures.toLowerCase())
}

return capabilityOrCapabilities.reduce((acc, curr) => {
return featureOrFeatures.reduce((acc, curr) => {
return {
...acc,
[curr]: activeCapabilities.includes(curr.toLowerCase()),
[curr]: lowerCasedFeatures.includes(curr.toLowerCase()),
}
}, {}) as { [Key in T]: boolean }
}

return {
capabilities,
hasCapability,
features,
hasFeature,
}
}

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@salable/js",
"version": "4.1.0",
"description": "A set of functions to simplify building Salable applications with JS",
"description": "A set of functions to simplify building Salable applications on the client.",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
Expand Down