Client lib for working with aID user and associated data
npm install @amedia/userThe goal of this module is to simplify the usage of aID data and services in Amedia frontend code. The module is
designed to be used several places on a webpage independently, and the module will ensure that requests from different
parts of the webpage are handled efficiently.
- UserDataRequest
User data like name, access, KV-storage, etc. Call it as much as you like, but only ask for what you
actually need.
- PaywallUnlockRequest
When you need to actively verify login state and access. May perform redirects and expensive calls for subscription
activation, and thus limited in how much it accepts to be called.
- SiteAccessRequest
When you need to actively verify access. Specify which access feature(s) you need. May attempt subscription
activation.
- User actions
Login, logout, that sort of thing.
Please be advised that you are required to use ESM to use this module.
Add this module to your project by running npm install -d @amedia/user. This will allow for type-hinting. @amedia/user
requires a browser to run. If your app performs server-side rendering, consult framework documentation on how to do this.
To get user data, you first need to create a new UserDataRequest() instance, then specify what data you need and
lastly decide if you want to wait until all data is available, or you want to get partial updates.
#### Attributes
| name | type | description |
| ------------------ | ------------- | ------------------------------------------------------- |
| uuid | string | The users unique ID. (NOT for tracking). |
| trackingKey | string | Unique ID of user that can be used for tracking. |
| sessionTrackingKey | string | Tracking key for the user session. |
| name | string | Users full name. |
| access | Array
Attributes are requested by calling .withAttributes(attributes: Array on a UserDataRequest with a list of
the attributes you need.
In the response, attributes is an object where each requested attribute is an attribute. So you can get the name by
accessing attributes.name for example.
NOTE: Don't request attributes you don't intend to use. This previously had a latency-cost attached to it. This is no
longer the case. However, it's best not to keep user data around unnecessarily.
#### Storage
Storage contains namespaces stored in aID (Europa). A namespace is a named set of key value pairs stored for a user. An
example could be favourite_teams namespace, which could contain team_id as a key and timestamp it was stored as a value.
Storage is requested by calling .withStorage(namespaces: Array on a UserDataRequest. If a namespace does
not exist, it will be "created" in that you'll get an empty object back. Saving data to it will perform the actual
creation.
In the response, storage is an object with each namespace as an attribute which leads to an object with attributes for
each key in the namespace. So you can access storage.favourite_teams.myteamid and get the value stored for teamid in
the favourite_teams namespace. This value will be whatever was saved there, but always a string.
``ts`
type Storage = { [namespace: string]: Record
#### Client Storage
This works just as regular storage, but the namespaces are scoped to oauth clients.
Client Storage is requested by calling .withClientStorage(clientId: string, namespaces: Array on a
UserDataRequest.
In the response, clientStorage[clientId] is an object with each namespace as en attribute that works as described
above.
`ts`
type ClientStorage = { [clientId: string]: Storage };
#### Writing to storage namespaces
A namespace is a proxied instance of Namespace. You access this as any old object, but it provides a save()-method for
when you wish to persist this namespace for later use.
`ts
import { UserDataRequest } from '@amedia/user';
const { storage } = await new UserDataRequest()
.withStorage(['my_namespace'])
.fetch();
storage.my_namespace.my_key = 'my data';
storage.my_namespace.save();
delete storage.my_namespace.my_key;
storage.my_namespace.save();
`
In this example, my_key in my_namespace would be updated to my data (and then deleted). Other data would probably
be left "untouched." Meaning, if this namespace sees a lot of changes outside this instance, you may end up
overwriting other changes if you delay a lot between reading and writing!
#### State
State is meant to describe the current context the user is in right now. It's not directly requested but will always be
a part of the response.
Current attributes in state:
| name | type | description |
| ------------- | ------------- | ----------------------------------------------------------------------------- |
| isLoggedIn | boolean | Is the user currently logged in? If not, most attributes will be undefined |
| emergencyMode | Array
#### Access not directly attached to a user
A visitor may have access through their IP-address, or a specific token that we periodically enable for "avis i skolen".
Current elements in the array nonUserAccess:
| name | type | descriptions |
| -------------- | ------------- | -------------------- |
| customer | string | "conocophillips" etc |
| accessFeatures | Array
Once you have created a request and specified the data you need, the final step is to decide how you want to get the
data once it's available. You can either choose to subscribe to data as they get loaded from the various backends or
wait until all fetching is done and get everything in one package.
The response to the request will be an object with attributes for state, attributes and storage. In the examples below
these are unpacked for easier access.
#### Subscribing to updates
The most flexible way to get userdata is by subscribing to updates. This is done by calling .subscribe() on theUserDataRequest with a callback function that can handle the updates once they arrive. This is especially useful when
the time it can take to get the data can vary. Getting the user's name is quick, so you can display the user's name
before you have received storage, for example.
Full usage example:
`ts
import { UserDataRequest } from '@amedia/user';
const unsubscribe = new UserDataRequest()
.withAttributes(['name', 'trackingKey'])
.withStorage(['my_namespace'])
.withClientStorage('client-id', ['my-client-namespace'])
.withNonUserAccess()
.subscribe(({ attributes, state, storage, clientStorage, nonUserAccess }) => {
console.log(attributes, state, storage, clientStorage, nonUserAccess);
});
// If you wish to unsubscribe from further updates:
unsubscribe();
`
The callback you supplied to subscribe is called whenever there are any changes to the properties you have requested, so
be prepared to re-render.
Also, your requested properties might not arrive all at once. This callback will be called whenever we get data in from
the various services.
You should check the state object for potential emergency modes and act accordingly.
Should you wish to end your subscription, just call the returned function from subscribe.
#### Fetching all the data at once (no re-renders)
In many use-cases, it's more convenient to just wait until everything is ready, so you don't have to handle multiple
callbacks. In these cases, it's easier to call .fetch(). This method will return a Promise that resolves the samesubscribe
object you would get in the callback.
Full usage example:
`ts
import { UserDataRequest } from '@amedia/user';
new UserDataRequest()
.withContext('my-app') // Tell us who you are :) This helps with tracability.
.withAttributes(['name'])
.withStorage(['my_namespace'])
.withClientStorage('client-id', ['my-client-namespace'])
.withNonUserAccess()
.fetch()
.then(({ attributes, state, storage, clientStorage, nonUserAccess }) => {
console.log(attributes, state, storage, clientStorage, nonUserAccess);
});
`
Fetch will wait until it has all the data you requested before resolving, unless the user is not currently logged in.
Internally .fetch() will use .subscribe() and just wait until everything is ready before unsubscribing and then
resolving the returned promise.
If you really need to render the user's name or get their uuid etc. as fast as possible, use .subscribe(), if not,
this is probably easier.
##### Timeouts when using fetch
Especially poor connections may result in almost eternal wait times - which is no good if your app is locked while
waiting on the data. Because of this we have introduced a timeout. A timeout will cause the fetch to reject with
FetchTimeoutError.
_FetchTimeoutError will include any data we might have gotten before we timed out_
`ts`
new UserDataRequest()
.withContext('my-app')
.withAttributes(['name'])
.withStorage(['my_namespace'])
.fetch()
.catch((e) => {
if (e instanceof FetchTimeoutError) {
console.log(e.partialData);
// Maybe you're able to render your app in a reduced state (using partialData).
}
});
If you're running a background task and don't mind waiting longer than default (10 seconds), you may set a longer
timeout in fetch:
`ts`
new UserDataRequest()
.withContext('my-app')
.withAttributes(['name'])
.withStorage(['my_namespace'])
.fetch({ timeout: 60000 / milliseconds / });
This may also be lowered below the default, but network request do take some time, even on fast networks, so don't shoot
your app in the foot by lowering it too much.
##### Emergency mode when using fetch
To avoid hanging on timeout, fetch rejects with EmergencyModeError, when a relevant emergency mode is active.
If you receive this error, there will be no user data available.
Your app should act accordingly, eg:
- Render non-personalized
- Show a placeholder with an error message
- Not rendering at all
`ts`
new UserDataRequest()
.withContext('my-app')
.withAttributes(['name'])
.withStorage(['my_namespace'])
.fetch()
.catch((e) => {
if (e instanceof FetchTimeoutError) {
console.log(e.partialData);
// Maybe you're able to render your app in a reduced state (using partialData).
} else if (e instanceof EmergencyModeError) {
console.log(e.activeEmergencyModes);
// Maybe you're able to render your app in a non-personalized way?
}
});
Builds and return the aID login URL. See goToLoginPage for details
Send the user to the aID login page. It will take care of building the correct URL, including your current site domain,
so the login page is skinned correctly. By default, the user will be sent back to the current url after login has been
completed. You may include a requestedUrl if you need to control where the user should end up.
Note: We also support sending a context string when requesting a login. Please include your app-name or more
recognizable string. If your app has multiple places for requesting login, it's nice to include that info as well.
Eg: goToLoginPage({context: my-app_switch-user}). The context will help us understand where logins originate from to
assist in debugging.
If you need to ensure the latest data is available, you may request a data refresh. @amedia/user will look up all data
it has in the cache and contact various endpoints to ensure they are up to date. If you're subscribing to updates, your
callback will be executed if anything changes.
Note: A data refresh will be automatically performed every time the page regains focus (eg. user switches tabs)
This action will log the user out of aID. The logout is global, meaning it will log the session out of all sites and
aid.no. After logout has completed the promise will resolve, giving your app the opportunity to reset state / reload the
page etc.
Polling for expected access. Will either return true when required access
features are attained, or TimeoutError.
Get various, environment-aware URLs to pages often linked to from other apps. This includes pages like the aID profile
page, Terms of Service, Privacy Statement, Privacy Preferences, Family Sharing and the like.
Sites hosted through Amedia may use a Paywall. This is based on a JWT-cookie called aid.jwt that is validated and parsed
by Varnish. When content is requested from a backend, it returns with header information used by varnish to decide if
the content is available to the reader, and a header telling varnish where to get the version that includes the paywall.
The paywall (incentive) will use PaywallUnlockRequest to see if it can somehow grant access to the user.
In some cases it makes sense to create a different user interface for a paywall. If so we recommend that you use
PaywallUnlockRequest in order to get the same functionality.
More information about the paywall can be found here:
How the Paywall works
Information about the incentive can also be useful, since you probably need to replicate this functionality if you
create a new user interface: How the Incentive works
To unlock the paywall, you first need to create a new PaywallUnlockRequest. Once created, you can set some options by
calling methods on this request:
- reloadOnAccess(): If the paywall unlocker finds sufficient access for the user, it will reload the current pageredirectOnAccess(requestedUrl: string)
- : If the paywall unlocker finds sufficient access for the user, it will sendrequestedUrl
the user to . (Provides more flexibility than reloadOnAccess())withAccessFeatures(accessFeatures: string[])
- Deprecated (pass in constructor instead): : Specify what access features
will give access to the current content.
More details about the access model can be found here:
Access model
Once your options have been set up, you can call tryUnlock(). This is when all the magic happens. The unlocker willPromise
try to autologin the user on the current domain, check access, activate subscriptions in the subscription system, check
for stuff like IP-based access, etc. When this is done, it will resolve the that was returned bytryUnlock().
The resolution to the promise contains an object with attributes you can use to get details about why the user did not
get access (or if he had access if you don't opt for reload or redirect). The object contains the following:
- isLoggedIn: True if the user is logged in, false otherwise. Useful if you want to show a login button, which makes no
sense for logged-in users.
- hasAccess: True if the user has access. Always false if you called reloadOnAccess() or redirectOnAccess().
- accessFeatures: List of access features the user has access to. Empty list if noy logged in or has no access. This
list is useful if you want to upgrade from 'pluss' to 'plussalt' for instance. You might want to give a different user
experience to users with some access than users with no access.
Full usage example:
`ts
import { PaywallUnlockRequest } from '...';
new PaywallUnlockRequest(['pluss'])
.redirectOnAccess('https://my.neat/place') // Optional
.reloadOnAccess() // Optional
.tryUnlock()
.then(({ isLoggedIn, hasAccess, accessFeatures }) => {
// If you did not specify any "on access"-action above
// we will return here afterwards.
console.log({ isLoggedIn, hasAccess, accessFeatures });
})
.catch((error) => {
// Known potential errors are:
// This guards potential reload/redirect loop. Access should have been granted at this time
PaywallError(
'Unlock check already performed.',
PaywallError.ERROR_RELOADED_NO_ACCESS
);
// Internal error. Access should have been granted at this time.
PaywallError(
'New subscription was attached and checked successfully, but internal systems disagree about current site access.',
PaywallError.ERROR_NO_ACCESS_AFTER_SUCCESSFUL_ACTIVATION_WITH_REQUESTED_ACCESS_FEATURES
);
// Internal error. We are unable to get access tokens/cookies to set.
PaywallError(
'Could not set access cookies',
PaywallError.ERROR_ENABLING_ACCESS_FAILED
);
});
`
Note that this _might result in redirects/reloads_ if the user is not already logged in. In that case you will end up
here again if logging in did not result in access.
SiteAccessRequest checks the current users' access to a specific site. If you're on a site that has its own access
features (e.g. has a paywall), you should use PaywallUnlockRequest instead. This will set cookies as well.
- withSiteDomain(siteDomain: string) The domain you with to check access for / attempt activation against.withAccessFeatures(accessFeatures: Array
- Which access features do you require in order to consider access
is granted.
Once your options have been set up, you can call requestAccess(). This is when all the magic happens. It will try toPromise
autologin the user on the current domain, check access, activate subscriptions in the subscription systems, check for
stuff like IP-based access etc. When this is done, it will resolve the that was returned.
The resolved promise contains the following:
- isLoggedIn: True if the user is logged in, false otherwise. Useful if you want to show a login button (see
goToLoginPage(...)).
- hasAccess: True if the user has access that fulfils your withAccessFeatures-requirements.
- accessFeatures: List of access features the user actually has at this time. Empty list if user is logged out or has no
access. This list is useful if you want to upgrade from 'newspaper' to 'plussalt' for instance. You might want to give
a different user experience to users with some access than users with no access.
- primarySite: Best guess of where the user may attain the access you requested.
Full usage example:
`ts
import { SiteAccessRequest } from '@amedia/user';
import { SiteAccessResolverError } from './SiteAccessRequest';
new SiteAccessRequest(['sport_premium'])
.withSiteDomain('www.direktesport.no')
.requestAccess()
.then(({ isLoggedIn, hasAccess, primarySite, accessFeatures }) => {
console.log({ isLoggedIn, hasAccess, primarySite, accessFeatures });
})
.catch((error) => {
// Known potential errors are:
// You would have to show some error / try again mechanic etc here.
SiteAccessRequestError(
'Unable to resolve access for site (www.direktesport.no)'
);
// If you attempt this on localhost
Error(
@amedia/user cannot request site access for (www.direktesport.no) on localhost`
);
});
Note that this might result in redirects/reloads if the user is not already logged in. In that case you will end up here
again if logging in did not result in access.
Subscription activation can only happen against the subscription system related to the withSiteDomain. #team-abo will
need to look into activation across all subscription systems to improve this behavior.
This app aims to be able to deliver a lot of data fast. Still, we might need to call several services to get what you
need. We do try to cache whenever this makes sense, but please don't ask for data you don't need.
There are a few techniques used to avoid over-fetching and fetching multiple times when once would do.
Much like node-userdata-browser/userdata-client did, we use events to
message that data is being requested. This allowed us to decouple your request from the particular instance that will
serve it. That in turn allows us to have this library loaded with multiple different components across the page and only
process the requests in one place. In addition, if the component currently processing requests should be unloaded for
whatever reason, one of the other instances will automatically take over.
Since we now process from a single instance of the package it's easy to avoid fetching the same resource multiple times.
@amedia/user has a caching layer that easily lets us switch out the caching mechanism.
We have two strategies:
- SessionStorage: (to survive page loads) Used if available.
- Global: Stores to a global variable on the current page.
If you're curious about calls, response times, etc., there is a debug flag you can set on the URL debug=something. IflocalStorage.setItem('amedia-user:debug', 'something')`
you'd like it to survive page loads, you can add a key to localStorage or sessionStorage: