diff --git a/.changeset/config.json b/.changeset/config.json
index 2be9970bfe..e382cdd043 100644
--- a/.changeset/config.json
+++ b/.changeset/config.json
@@ -14,6 +14,7 @@
"updateInternalDependencies": "patch",
"ignore": [
"@forgerock/device-client",
+ "@forgerock/device-client-app",
"@forgerock/davinci-app",
"@forgerock/davinci-suites",
"@forgerock/mock-api-v2",
diff --git a/e2e/device-client-app/.gitignore b/e2e/device-client-app/.gitignore
new file mode 100644
index 0000000000..a547bf36d8
--- /dev/null
+++ b/e2e/device-client-app/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/e2e/device-client-app/eslint.config.mjs b/e2e/device-client-app/eslint.config.mjs
new file mode 100644
index 0000000000..ef30ce27f4
--- /dev/null
+++ b/e2e/device-client-app/eslint.config.mjs
@@ -0,0 +1,25 @@
+import baseConfig from '../../eslint.config.mjs';
+
+export default [
+ {
+ ignores: [
+ 'node_modules',
+ '*.md',
+ 'LICENSE',
+ '.swcrc',
+ '.babelrc',
+ '.env*',
+ '.bin',
+ 'dist',
+ '.eslintignore',
+ '**/*.html',
+ '**/*.svg',
+ '**/*.css',
+ 'public',
+ '*.json',
+ '*.d.ts',
+ '.gitignore',
+ ],
+ },
+ ...baseConfig,
+];
diff --git a/e2e/device-client-app/package.json b/e2e/device-client-app/package.json
new file mode 100644
index 0000000000..21818ec78a
--- /dev/null
+++ b/e2e/device-client-app/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "@forgerock/device-client-app",
+ "version": "0.0.0",
+ "private": true,
+ "scripts": {
+ "build": "pnpm nx nxBuild",
+ "lint": "pnpm nx nxLint",
+ "preview": "pnpm nx nxPreview",
+ "serve": "pnpm nx nxServe"
+ },
+ "dependencies": {
+ "@forgerock/device-client": "workspace:*",
+ "@forgerock/javascript-sdk": "4.7.0",
+ "effect": "^3.12.7"
+ },
+ "nx": {
+ "tags": ["scope:e2e"]
+ },
+ "devDependencies": {
+ "@effect/language-service": "^0.20.0"
+ }
+}
diff --git a/e2e/device-client-app/public/typescript.svg b/e2e/device-client-app/public/typescript.svg
new file mode 100644
index 0000000000..d91c910cc3
--- /dev/null
+++ b/e2e/device-client-app/public/typescript.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/e2e/device-client-app/public/vite.svg b/e2e/device-client-app/public/vite.svg
new file mode 100644
index 0000000000..e7b8dfb1b2
--- /dev/null
+++ b/e2e/device-client-app/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/e2e/device-client-app/src/_callback/index.html b/e2e/device-client-app/src/_callback/index.html
new file mode 100644
index 0000000000..9662116d79
--- /dev/null
+++ b/e2e/device-client-app/src/_callback/index.html
@@ -0,0 +1,8 @@
+
+
+
+ Logged In | E2E Test | Ping Identity JavaScript SDK
+
+
+
+
diff --git a/e2e/device-client-app/src/device-binding/index.html b/e2e/device-client-app/src/device-binding/index.html
new file mode 100644
index 0000000000..ace4e44aa5
--- /dev/null
+++ b/e2e/device-client-app/src/device-binding/index.html
@@ -0,0 +1,9 @@
+
+
+
+ E2E Test | Ping Identity JavaScript SDK
+
+
+
+
+
diff --git a/e2e/device-client-app/src/device-binding/main.ts b/e2e/device-client-app/src/device-binding/main.ts
new file mode 100644
index 0000000000..9dd9f5f70c
--- /dev/null
+++ b/e2e/device-client-app/src/device-binding/main.ts
@@ -0,0 +1,60 @@
+/*
+ *
+ * Copyright © 2025 Ping Identity Corporation. All right reserved.
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ *
+ */
+
+import { Console, Effect } from 'effect';
+import { getUser, LoginAndGetClient, handleError, handleSuccess } from '../utils/index.js';
+
+const deviceBinding = Effect.gen(function* () {
+ const client = yield* LoginAndGetClient;
+ const user = yield* getUser;
+ const query = {
+ userId: user.sub,
+ realm: 'alpha',
+ };
+
+ const deviceArr = yield* Effect.promise(() => client.bound.get(query));
+
+ if ((Array.isArray(deviceArr) && !deviceArr.length) || 'error' in deviceArr) {
+ yield* Console.log('No devices found or error occurred', deviceArr);
+ return yield* Effect.fail(new Error('No devices found or error occurred'));
+ }
+ yield* Console.log('GET devices', deviceArr);
+
+ const [device] = deviceArr;
+
+ yield* Console.log('device', device);
+
+ const updatedDevice = yield* Effect.promise(() =>
+ client.bound.update({
+ ...query,
+ device: { ...device, deviceName: 'UpdatedDeviceName' },
+ }),
+ );
+
+ if ('error' in updatedDevice) {
+ return yield* Effect.fail(new Error(`Failed to update device: ${updatedDevice.error}`));
+ }
+
+ yield* Console.log('updated device', updatedDevice);
+
+ const deletedDevice = yield* Effect.promise(() =>
+ client.bound.delete({
+ ...query,
+ device: updatedDevice,
+ }),
+ );
+
+ if (deletedDevice !== null && deletedDevice.error) {
+ return yield* Effect.fail(new Error(`Failed to delete device: ${deletedDevice.error}`));
+ }
+
+ yield* Console.log('deleted', deletedDevice);
+});
+
+Effect.runPromise(deviceBinding).then(handleSuccess).catch(handleError);
diff --git a/e2e/device-client-app/src/device-profile/index.html b/e2e/device-client-app/src/device-profile/index.html
new file mode 100644
index 0000000000..ace4e44aa5
--- /dev/null
+++ b/e2e/device-client-app/src/device-profile/index.html
@@ -0,0 +1,9 @@
+
+
+
+ E2E Test | Ping Identity JavaScript SDK
+
+
+
+
+
diff --git a/e2e/device-client-app/src/device-profile/main.ts b/e2e/device-client-app/src/device-profile/main.ts
new file mode 100644
index 0000000000..e455442b88
--- /dev/null
+++ b/e2e/device-client-app/src/device-profile/main.ts
@@ -0,0 +1,60 @@
+/*
+ *
+ * Copyright © 2025 Ping Identity Corporation. All right reserved.
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ *
+ */
+
+import { Console, Effect } from 'effect';
+import { getUser, LoginAndGetClient, handleError, handleSuccess } from '../utils/index.js';
+
+const deviceProfiling = Effect.gen(function* () {
+ const client = yield* LoginAndGetClient;
+ const user = yield* getUser;
+ const query = {
+ userId: user.sub,
+ realm: 'alpha',
+ };
+
+ const deviceArr = yield* Effect.promise(() => client.profile.get(query));
+
+ if ((Array.isArray(deviceArr) && !deviceArr.length) || 'error' in deviceArr) {
+ yield* Console.log('No devices found or error occurred', deviceArr);
+ return yield* Effect.fail(new Error('No devices found or error occurred'));
+ }
+ yield* Console.log('GET devices', deviceArr);
+
+ const [device] = deviceArr;
+
+ yield* Console.log('device', device);
+
+ const updatedDevice = yield* Effect.promise(() =>
+ client.profile.update({
+ ...query,
+ device: { ...device, alias: 'UpdatedDeviceName' },
+ }),
+ );
+
+ if ('error' in updatedDevice) {
+ return yield* Effect.fail(new Error(`Failed to update device: ${updatedDevice.error}`));
+ }
+
+ yield* Console.log('updated device', updatedDevice);
+
+ const deletedDevice = yield* Effect.promise(() =>
+ client.profile.delete({
+ ...query,
+ device: updatedDevice,
+ }),
+ );
+
+ if (deletedDevice !== null && deletedDevice.error) {
+ return yield* Effect.fail(new Error(`Failed to delete device: ${deletedDevice.error}`));
+ }
+
+ yield* Console.log('deleted', deletedDevice);
+});
+
+Effect.runPromise(deviceProfiling).then(handleSuccess).catch(handleError);
diff --git a/e2e/device-client-app/src/index.html b/e2e/device-client-app/src/index.html
new file mode 100644
index 0000000000..a7dc55dd7d
--- /dev/null
+++ b/e2e/device-client-app/src/index.html
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+ Device Client E2E Test Index | Ping Identity JavaScript SDK
+
+
+
+
+
+
+
+
+
+
Click on the Vite and TypeScript logos to learn more
+
Device Client E2E Test Index | Ping Identity JavaScript SDK
+
+
+
+
+
diff --git a/e2e/device-client-app/src/index.ts b/e2e/device-client-app/src/index.ts
new file mode 100644
index 0000000000..df64de15b1
--- /dev/null
+++ b/e2e/device-client-app/src/index.ts
@@ -0,0 +1,10 @@
+/*
+ *
+ * Copyright © 2025 Ping Identity Corporation. All right reserved.
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ *
+ */
+
+import './style.css';
diff --git a/e2e/device-client-app/src/oath/index.html b/e2e/device-client-app/src/oath/index.html
new file mode 100644
index 0000000000..ace4e44aa5
--- /dev/null
+++ b/e2e/device-client-app/src/oath/index.html
@@ -0,0 +1,9 @@
+
+
+
+ E2E Test | Ping Identity JavaScript SDK
+
+
+
+
+
diff --git a/e2e/device-client-app/src/oath/main.ts b/e2e/device-client-app/src/oath/main.ts
new file mode 100644
index 0000000000..04e3758e8b
--- /dev/null
+++ b/e2e/device-client-app/src/oath/main.ts
@@ -0,0 +1,47 @@
+/*
+ *
+ * Copyright © 2025 Ping Identity Corporation. All right reserved.
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ *
+ */
+
+import { Console, Effect } from 'effect';
+import { getUser, LoginAndGetClient, handleError, handleSuccess } from '../utils/index.js';
+
+const oath = Effect.gen(function* () {
+ const client = yield* LoginAndGetClient;
+ const user = yield* getUser;
+ const query = {
+ userId: user.sub,
+ realm: 'alpha',
+ };
+
+ const deviceArr = yield* Effect.promise(() => client.oath.get(query));
+
+ if ((Array.isArray(deviceArr) && !deviceArr.length) || 'error' in deviceArr) {
+ yield* Console.log('No devices found or error occurred', deviceArr);
+ return yield* Effect.fail(new Error('No devices found or error occurred'));
+ }
+ yield* Console.log('GET devices', deviceArr);
+
+ const [device] = deviceArr;
+
+ yield* Console.log('device', device);
+
+ const deletedDevice = yield* Effect.promise(() =>
+ client.oath.delete({
+ ...query,
+ device,
+ }),
+ );
+
+ if (deletedDevice !== null && deletedDevice.error) {
+ return yield* Effect.fail(new Error(`Failed to delete device: ${deletedDevice.error}`));
+ }
+
+ yield* Console.log('deleted', deletedDevice);
+});
+
+Effect.runPromise(oath).then(handleSuccess).catch(handleError);
diff --git a/e2e/device-client-app/src/push/index.html b/e2e/device-client-app/src/push/index.html
new file mode 100644
index 0000000000..ace4e44aa5
--- /dev/null
+++ b/e2e/device-client-app/src/push/index.html
@@ -0,0 +1,9 @@
+
+
+
+ E2E Test | Ping Identity JavaScript SDK
+
+
+
+
+
diff --git a/e2e/device-client-app/src/push/main.ts b/e2e/device-client-app/src/push/main.ts
new file mode 100644
index 0000000000..97f47f0331
--- /dev/null
+++ b/e2e/device-client-app/src/push/main.ts
@@ -0,0 +1,47 @@
+/*
+ *
+ * Copyright © 2025 Ping Identity Corporation. All right reserved.
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ *
+ */
+
+import { Console, Effect } from 'effect';
+import { getUser, LoginAndGetClient, handleError, handleSuccess } from '../utils/index.js';
+
+const push = Effect.gen(function* () {
+ const client = yield* LoginAndGetClient;
+ const user = yield* getUser;
+ const query = {
+ userId: user.sub,
+ realm: 'alpha',
+ };
+
+ const deviceArr = yield* Effect.promise(() => client.push.get(query));
+
+ if ((Array.isArray(deviceArr) && !deviceArr.length) || 'error' in deviceArr) {
+ yield* Console.log('No devices found or error occurred', deviceArr);
+ return yield* Effect.fail(new Error('No devices found or error occurred'));
+ }
+ yield* Console.log('GET devices', deviceArr);
+
+ const [device] = deviceArr;
+
+ yield* Console.log('device', device);
+
+ const deletedDevice = yield* Effect.promise(() =>
+ client.push.delete({
+ ...query,
+ device,
+ }),
+ );
+
+ if (deletedDevice !== null && deletedDevice.error) {
+ return yield* Effect.fail(new Error(`Failed to delete device: ${deletedDevice.error}`));
+ }
+
+ yield* Console.log('deleted', deletedDevice);
+});
+
+Effect.runPromise(push).then(handleSuccess).catch(handleError);
diff --git a/e2e/device-client-app/src/style.css b/e2e/device-client-app/src/style.css
new file mode 100644
index 0000000000..b371f42085
--- /dev/null
+++ b/e2e/device-client-app/src/style.css
@@ -0,0 +1,100 @@
+:root {
+ font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ color-scheme: light dark;
+ color: rgba(255, 255, 255, 0.87);
+ background-color: #242424;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+a {
+ font-weight: 500;
+ color: #646cff;
+ text-decoration: inherit;
+}
+a:hover {
+ color: #535bf2;
+}
+
+#nav > a {
+ display: block;
+}
+
+body {
+ margin: 0;
+ display: flex;
+ place-items: center;
+ min-width: 320px;
+ min-height: 100vh;
+}
+
+h1 {
+ font-size: 3.2em;
+ line-height: 1.1;
+}
+
+#app {
+ max-width: 1280px;
+ margin: 0 auto;
+ padding: 2rem;
+ text-align: center;
+}
+
+.logo {
+ height: 6em;
+ padding: 1.5em;
+ will-change: filter;
+ transition: filter 300ms;
+}
+.logo:hover {
+ filter: drop-shadow(0 0 2em #646cffaa);
+}
+.logo.vanilla:hover {
+ filter: drop-shadow(0 0 2em #3178c6aa);
+}
+
+.card {
+ padding: 2em;
+}
+
+.read-the-docs {
+ color: #888;
+}
+
+button {
+ border-radius: 8px;
+ border: 1px solid transparent;
+ padding: 0.6em 1.2em;
+ font-size: 1em;
+ font-weight: 500;
+ font-family: inherit;
+ background-color: #1a1a1a;
+ cursor: pointer;
+ transition: border-color 0.25s;
+}
+button:hover {
+ border-color: #646cff;
+}
+button:focus,
+button:focus-visible {
+ outline: 4px auto -webkit-focus-ring-color;
+}
+
+@media (prefers-color-scheme: light) {
+ :root {
+ color: #213547;
+ background-color: #ffffff;
+ }
+ a:hover {
+ color: #747bff;
+ }
+ button {
+ background-color: #f9f9f9;
+ }
+}
diff --git a/e2e/device-client-app/src/types.ts b/e2e/device-client-app/src/types.ts
new file mode 100644
index 0000000000..94692f2581
--- /dev/null
+++ b/e2e/device-client-app/src/types.ts
@@ -0,0 +1,3 @@
+import { deviceClient } from '@forgerock/device-client';
+
+export type DeviceClient = ReturnType;
diff --git a/e2e/device-client-app/src/utils/index.ts b/e2e/device-client-app/src/utils/index.ts
new file mode 100644
index 0000000000..a89e05fc85
--- /dev/null
+++ b/e2e/device-client-app/src/utils/index.ts
@@ -0,0 +1,139 @@
+import { deviceClient } from '@forgerock/device-client';
+import {
+ CallbackType,
+ Config,
+ FRAuth,
+ FRLoginFailure,
+ FRLoginSuccess,
+ FRStep,
+ NameCallback,
+ PasswordCallback,
+ SessionManager,
+ TokenManager,
+ UserManager,
+} from '@forgerock/javascript-sdk';
+import { Console, Effect } from 'effect';
+
+const logout = Effect.ignore(
+ Effect.tryPromise({
+ try: () => SessionManager.logout(),
+ catch: (err) => new Error(`Logout failed: ${err}`),
+ }),
+);
+
+const start = Effect.tryPromise({
+ try: () => FRAuth.start(),
+ catch: (err) => new Error(`Authentication start failed: ${err}`),
+}).pipe(Effect.tap((step) => Console.log('Called start', step)));
+
+const checkFRStep = (step: FRStep | FRLoginFailure | FRLoginSuccess) =>
+ Effect.try({
+ try: () => {
+ if (step.type == 'LoginSuccess' || step.type == 'LoginFailure') {
+ throw new Error(`Unexpected step type: ${step.type}`);
+ } else {
+ return step;
+ }
+ },
+ catch: (err) => new Error(`Failed to start authentication: ${err}`),
+ });
+
+const callNext = (step: FRStep) =>
+ Effect.tryPromise({
+ try: () => FRAuth.next(step),
+ catch: (err) => new Error(`Failed to proceed to next step: ${err}`),
+ }).pipe(Effect.tap((step) => Console.log('Got next step', step)));
+
+const getTokens = Effect.tryPromise({
+ try: () => TokenManager.getTokens(),
+ catch: (err) => new Error(`Failed to get tokens: ${err}`),
+}).pipe(Effect.tap((tokens) => Console.log('Got Tokens', tokens)));
+
+const checkForLoginSuccess = (step: FRStep | FRLoginSuccess | FRLoginFailure) => {
+ if (step.type === 'LoginSuccess') {
+ return Effect.succeed(step);
+ } else if (step.type === 'LoginFailure') {
+ return Effect.fail(new Error(`Login failed`));
+ } else {
+ return Effect.fail(
+ new Error(`Unexpected step, expected to be in a LoginSuccess but got ${step.type}`),
+ );
+ }
+};
+
+export const LoginAndGetClient = Effect.gen(function* () {
+ const url = new URL(window.location.href);
+ const amUrl = url.searchParams.get('amUrl') || 'https://openam-sdks.forgeblocks.com/am';
+ const realmPath = url.searchParams.get('realmPath') || 'alpha';
+ const platformHeader = url.searchParams.get('platformHeader') === 'true' ? true : false;
+ const tree = url.searchParams.get('tree') || 'selfservice';
+
+ /**
+ * Make sure this `un` is a real user
+ * this is a manual test and requires a real tenant and a real user
+ * that has devices.
+ */
+ const un = url.searchParams.get('un') || 'devicetestuser';
+ const pw = url.searchParams.get('pw') || 'password';
+
+ const config = {
+ realmPath,
+ tree,
+ clientId: 'WebOAuthClient',
+ scope: 'profile email me.read openid',
+ serverConfig: {
+ baseUrl: amUrl,
+ timeout: 3000,
+ },
+ };
+
+ yield* Effect.try(() =>
+ Config.set({
+ platformHeader,
+ realmPath,
+ tree,
+ clientId: 'WebOAuthClient',
+ scope: 'profile email me.read openid',
+ redirectUri: `${window.location.origin}/src/_callback/index.html`,
+ serverConfig: {
+ baseUrl: amUrl,
+ timeout: 3000,
+ },
+ }),
+ );
+ yield* logout;
+
+ yield* start.pipe(
+ Effect.flatMap((step) => checkFRStep(step)),
+ Effect.map((step) => {
+ step.getCallbackOfType(CallbackType.NameCallback).setName(un);
+ step.getCallbackOfType(CallbackType.PasswordCallback).setPassword(pw);
+ return step;
+ }),
+ Effect.flatMap((step) => callNext(step)),
+ /**
+ * Don't explicitly need this but if the journey changes
+ * maybe we dont get a LoginSuccess
+ */
+ Effect.flatMap((step) => checkForLoginSuccess(step)),
+ Effect.flatMap(() => getTokens),
+ );
+
+ const client = deviceClient(config);
+ return client;
+});
+
+export const getUser = Effect.tryPromise({
+ try: () => UserManager.getCurrentUser() as Promise>,
+ catch: (err) => new Error(`Failed to get current user: ${err}`),
+});
+
+export const handleError = (err: unknown) => {
+ console.error(err);
+ document.body.innerHTML = `Test script failed: ${err}
`;
+};
+
+export const handleSuccess = () => {
+ console.log('Test script complete');
+ document.body.innerHTML = `Test script complete
`;
+};
diff --git a/e2e/device-client-app/src/vite-env.d.ts b/e2e/device-client-app/src/vite-env.d.ts
new file mode 100644
index 0000000000..11f02fe2a0
--- /dev/null
+++ b/e2e/device-client-app/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/e2e/device-client-app/src/webauthn/index.html b/e2e/device-client-app/src/webauthn/index.html
new file mode 100644
index 0000000000..ace4e44aa5
--- /dev/null
+++ b/e2e/device-client-app/src/webauthn/index.html
@@ -0,0 +1,9 @@
+
+
+
+ E2E Test | Ping Identity JavaScript SDK
+
+
+
+
+
diff --git a/e2e/device-client-app/src/webauthn/main.ts b/e2e/device-client-app/src/webauthn/main.ts
new file mode 100644
index 0000000000..89bdb4c941
--- /dev/null
+++ b/e2e/device-client-app/src/webauthn/main.ts
@@ -0,0 +1,60 @@
+/*
+ *
+ * Copyright © 2025 Ping Identity Corporation. All right reserved.
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ *
+ */
+
+import { Console, Effect } from 'effect';
+import { getUser, LoginAndGetClient, handleError, handleSuccess } from '../utils/index.js';
+
+const webauthn = Effect.gen(function* () {
+ const client = yield* LoginAndGetClient;
+ const user = yield* getUser;
+ const query = {
+ userId: user.sub,
+ realm: 'alpha',
+ };
+
+ const deviceArr = yield* Effect.promise(() => client.webAuthn.get(query));
+
+ if ((Array.isArray(deviceArr) && !deviceArr.length) || 'error' in deviceArr) {
+ yield* Console.log('No devices found or error occurred', deviceArr);
+ return yield* Effect.fail(new Error('No devices found or error occurred'));
+ }
+ yield* Console.log('GET devices', deviceArr);
+
+ const [device] = deviceArr;
+
+ yield* Console.log('device', device);
+
+ const updatedDevice = yield* Effect.promise(() =>
+ client.webAuthn.update({
+ ...query,
+ device: { ...device, deviceName: 'UpdatedDeviceName' },
+ }),
+ );
+
+ if ('error' in updatedDevice) {
+ return yield* Effect.fail(new Error(`Failed to update device: ${updatedDevice.error}`));
+ }
+
+ yield* Console.log('updated device', updatedDevice);
+
+ const deletedDevice = yield* Effect.promise(() =>
+ client.webAuthn.delete({
+ ...query,
+ device: updatedDevice,
+ }),
+ );
+
+ if (deletedDevice !== null && deletedDevice.error) {
+ return yield* Effect.fail(new Error(`Failed to delete device: ${deletedDevice.error}`));
+ }
+
+ yield* Console.log('deleted', deletedDevice);
+});
+
+Effect.runPromise(webauthn).then(handleSuccess).catch(handleError);
diff --git a/e2e/device-client-app/tsconfig.app.json b/e2e/device-client-app/tsconfig.app.json
new file mode 100644
index 0000000000..e68f53f5ba
--- /dev/null
+++ b/e2e/device-client-app/tsconfig.app.json
@@ -0,0 +1,32 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "types": ["node"],
+ "rootDir": "src",
+ "target": "esnext",
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "tsBuildInfoFile": "dist/tsconfig.app.tsbuildinfo",
+ "plugins": [
+ {
+ "name": "@effect/language-service"
+ }
+ ]
+ },
+ "exclude": [
+ "out-tsc",
+ "dist",
+ "src/**/*.spec.ts",
+ "src/**/*.test.ts",
+ "eslint.config.js",
+ "eslint.config.cjs",
+ "eslint.config.mjs"
+ ],
+ "include": ["src/**/*.ts"],
+ "references": [
+ {
+ "path": "../../packages/device-client/tsconfig.lib.json"
+ }
+ ]
+}
diff --git a/e2e/device-client-app/tsconfig.json b/e2e/device-client-app/tsconfig.json
new file mode 100644
index 0000000000..301fbe928b
--- /dev/null
+++ b/e2e/device-client-app/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "files": [],
+ "include": [],
+ "references": [
+ {
+ "path": "../../packages/device-client"
+ },
+ {
+ "path": "./tsconfig.app.json"
+ }
+ ]
+}
diff --git a/e2e/device-client-app/vite.config.ts b/e2e/device-client-app/vite.config.ts
new file mode 100644
index 0000000000..f54af97750
--- /dev/null
+++ b/e2e/device-client-app/vite.config.ts
@@ -0,0 +1,56 @@
+///
+import { defineConfig } from 'vite';
+import * as path from 'path';
+
+const pages = ['oath', 'push', 'webauthn', 'device-binding', 'device-profile'];
+
+export default defineConfig(() => ({
+ root: __dirname + '/src',
+ cacheDir: '../../node_modules/.vite/e2e/device-client-app',
+ publicDir: __dirname + '/public',
+ server: {
+ cors: true,
+ port: 8443,
+ host: 'localhost',
+ headers: {
+ 'Access-Control-Allow-Credentials': 'true',
+ 'Access-Control-Allow-Origin': 'null',
+ 'Access-Control-Allow-Headers': 'x-authorize-middleware',
+ },
+ },
+ preview: {
+ port: 8443,
+ host: 'localhost',
+ headers: {
+ 'Access-Control-Allow-Origin': 'http://localhost:8443',
+ },
+ },
+ plugins: [],
+ // Uncomment this if you are using workers.
+ // worker: {
+ // plugins: [ nxViteTsPaths() ],
+ // },
+ build: {
+ outDir: __dirname + '/dist',
+ emptyOutDir: true,
+ reportCompressedSize: true,
+ rollupOptions: {
+ input: {
+ main: path.resolve(__dirname + '/src', 'index.html'),
+ ...pages.reduce(
+ (acc, page) => {
+ acc[page as keyof typeof pages] = path.resolve(
+ __dirname + '/src',
+ `${page}/index.html`,
+ );
+ return acc;
+ },
+ {} as Record,
+ ),
+ },
+ output: {
+ entryFileNames: '[name]/main.js',
+ },
+ },
+ },
+}));
diff --git a/package.json b/package.json
index cfa4502162..c4915cc630 100644
--- a/package.json
+++ b/package.json
@@ -90,7 +90,6 @@
"conventional-changelog-conventionalcommits": "^8.0.0",
"cz-conventional-changelog": "^3.3.0",
"cz-git": "^1.6.1",
- "effect": "^3.12.7",
"eslint": "^9.8.0",
"eslint-config-prettier": "10.1.5",
"eslint-plugin-import": "2.31.0",
diff --git a/packages/device-client/README.md b/packages/device-client/README.md
index ff60e5c6c9..d6ff9ece54 100644
--- a/packages/device-client/README.md
+++ b/packages/device-client/README.md
@@ -44,9 +44,11 @@ const config: ConfigOptions = {
},
realmPath: '/your-realm-path',
};
+```
If there is no realmPath or you wish to override the value, you can do so in the api call itself where you pass in the query.
+```
const apiClient = deviceClient(config);
```
@@ -205,7 +207,10 @@ apiClient.webauthn
### Bound Devices Management Example
-```typescript const bindingQuery: BindingDeviceQuery = { /* your query parameters */ };
+```typescript
+const bindingQuery: BindingDeviceQuery = {
+ /* your query parameters */
+};
apiClient.boundDevices
.get(bindingQuery)
.then((response) => {
diff --git a/packages/device-client/eslint.config.mjs b/packages/device-client/eslint.config.mjs
index 7cbfa2e3e4..dd48071231 100644
--- a/packages/device-client/eslint.config.mjs
+++ b/packages/device-client/eslint.config.mjs
@@ -1,14 +1,5 @@
-import { FlatCompat } from '@eslint/eslintrc';
-import { dirname } from 'path';
-import { fileURLToPath } from 'url';
-import js from '@eslint/js';
import baseConfig from '../../eslint.config.mjs';
-const compat = new FlatCompat({
- baseDirectory: dirname(fileURLToPath(import.meta.url)),
- recommendedConfig: js.configs.recommended,
-});
-
export default [
{
ignores: ['**/dist'],
@@ -39,6 +30,7 @@ export default [
'{projectRoot}/eslint.config.{js,cjs,mjs}',
'{projectRoot}/vite.config.{js,ts,mjs,mts}',
],
+ ignoredDependencies: ['msw'],
},
],
},
diff --git a/packages/device-client/package.json b/packages/device-client/package.json
index 3c4d7a2350..47571cfb10 100644
--- a/packages/device-client/package.json
+++ b/packages/device-client/package.json
@@ -11,13 +11,13 @@
"sideEffects": false,
"type": "module",
"exports": {
- ".": "./dist/index.js",
+ ".": "./dist/src/index.js",
"./package.json": "./package.json",
- "./types": "./dist/lib/types/index.d.ts"
+ "./types": "./dist/src/lib/types/index.d.ts"
},
- "main": "./dist/index.cjs",
- "module": "./dist/index.js",
- "typings": "./dist/index.d.ts",
+ "main": "./dist/src/index.js",
+ "module": "./dist/src/index.js",
+ "typings": "./dist/src/index.d.ts",
"files": ["./dist"],
"scripts": {
"build": "pnpm nx nxBuild",
diff --git a/packages/device-client/src/lib/device.store.test.ts b/packages/device-client/src/lib/device.store.test.ts
new file mode 100644
index 0000000000..1d89feb436
--- /dev/null
+++ b/packages/device-client/src/lib/device.store.test.ts
@@ -0,0 +1,284 @@
+/*
+ * Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ */
+import { afterEach, afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { setupServer } from 'msw/node';
+import { deviceClient } from './device.store.js';
+import { handlers } from './device.store.test.utils.js';
+
+import {
+ MOCK_PUSH_DEVICES,
+ MOCK_BINDING_DEVICES,
+ MOCK_OATH_DEVICES,
+ MOCK_WEBAUTHN_DEVICES,
+ MOCK_DEVICE_PROFILE_SUCCESS,
+} from './mock-data/device.store.mock.js';
+
+export const server = setupServer(...handlers);
+
+// Establish API mocking before all tests.
+beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
+
+// Reset any request handlers that we may add during the tests,
+// so they don't affect other tests.
+afterEach(() => server.resetHandlers());
+
+// Clean up after the tests are finished.
+afterAll(() => server.close());
+
+describe('Device Client Store', () => {
+ const config = {
+ serverConfig: {
+ baseUrl: 'https://api.example.com',
+ },
+ realmPath: 'test-realm',
+ };
+
+ describe('OATH Device Management', () => {
+ const client = deviceClient(config);
+
+ it('should fetch OATH devices', async () => {
+ const result = await client.oath.get({
+ userId: 'test-user',
+ });
+
+ expect(result).toEqual(MOCK_OATH_DEVICES.result);
+ });
+
+ it('should delete OATH device', async () => {
+ const result = await client.oath.delete({
+ userId: 'test-user',
+ device: {
+ deviceManagementStatus: false,
+ _rev: '1221312',
+ uuid: 'oath-uuid-1',
+ deviceName: 'Test OATH Device',
+ _id: 'test-id',
+ createdDate: 1705555555555,
+ lastAccessDate: 1705555555555,
+ },
+ });
+
+ expect(result).toEqual(null);
+ });
+
+ it('should return error obj if a user does not exist', async () => {
+ const badClient = deviceClient(config);
+ const result = await badClient.oath.get({
+ userId: 'bad-user',
+ });
+ expect(result).toStrictEqual({ error: new Error('response did not contain data') });
+ });
+
+ it('should return error obj if a realm does not exist', async () => {
+ const badConfig = { ...config, realmPath: 'fake-realm' };
+ const badClient = deviceClient(badConfig);
+ const result = await badClient.oath.get({
+ userId: 'test-user',
+ });
+ expect(result).toStrictEqual({ error: new Error('response did not contain data') });
+ });
+ });
+
+ describe('Push Device Management', () => {
+ const client = deviceClient(config);
+
+ it('should fetch push devices', async () => {
+ const result = await client.push.get({
+ userId: 'test-user',
+ });
+
+ expect(result).toEqual(MOCK_PUSH_DEVICES);
+ });
+
+ it('should delete push device', async () => {
+ const result = await client.push.delete({
+ userId: 'test-user',
+ device: MOCK_PUSH_DEVICES[0],
+ });
+ expect(result).toEqual(null);
+ });
+
+ it('should fail with a bad uuid', async () => {
+ const client = deviceClient(config);
+ const result1 = await client.push.delete({
+ userId: 'test-user',
+ device: { ...MOCK_PUSH_DEVICES[0], uuid: 'bad-uuid' },
+ });
+
+ expect(result1).toEqual({
+ error: expect.objectContaining({ message: expect.stringContaining('bad uuid') }),
+ });
+ });
+
+ it('should fail with a bad userId', async () => {
+ const badConfig = { ...config, realmPath: 'bad-realm' };
+ const badClient = deviceClient(badConfig);
+ const result1 = await badClient.push.delete({
+ userId: 'bad-user',
+ device: MOCK_PUSH_DEVICES[0],
+ });
+ const result2 = await badClient.push.get({ userId: 'bad-user' });
+
+ expect(result1).toEqual({
+ error: expect.objectContaining({ message: expect.stringContaining('bad user') }),
+ });
+ expect(result2).toStrictEqual({ error: new Error('response did not contain data') });
+ });
+
+ it('should return error obj if a uuid does not exist', async () => {
+ const badClient = deviceClient(config);
+ const result = await badClient.push.delete({
+ userId: 'user',
+ device: { ...MOCK_PUSH_DEVICES[0], uuid: 'bad-uuid' },
+ });
+ expect(result).toEqual({
+ error: expect.objectContaining({ message: expect.stringContaining('bad uuid') }),
+ });
+ });
+
+ it('should return error obj if a user does not exist', async () => {
+ const badClient = deviceClient(config);
+ const result = await badClient.push.get({
+ userId: 'bad-user',
+ });
+ expect(result).toStrictEqual({ error: new Error('response did not contain data') });
+ });
+
+ it('should return error obj if a realm does not exist', async () => {
+ const badConfig = { ...config, realmPath: 'fake-realm' };
+ const badClient = deviceClient(badConfig);
+ const result = await badClient.push.get({
+ userId: 'test-user',
+ });
+ expect(result).toStrictEqual({ error: new Error('response did not contain data') });
+ });
+ });
+
+ describe('WebAuthn Device Management', () => {
+ const client = deviceClient(config);
+
+ it('should fetch webauthn devices', async () => {
+ const result = await client.webAuthn.get({
+ userId: 'test-user',
+ });
+
+ expect(result).toEqual(MOCK_WEBAUTHN_DEVICES);
+ });
+
+ it('should update webauthn device name', async () => {
+ const mockDevice = MOCK_WEBAUTHN_DEVICES.result[0];
+ const result = await client.webAuthn.update({
+ userId: 'test-user',
+ device: {
+ _id: mockDevice._id,
+ _rev: mockDevice._rev,
+ uuid: mockDevice.uuid,
+ deviceName: 'Updated WebAuthn Device',
+ credentialId: mockDevice.credentialId,
+ createdDate: mockDevice.createdDate,
+ lastAccessDate: mockDevice.lastAccessDate,
+ deviceManagementStatus: mockDevice.deviceManagementStatus,
+ },
+ });
+
+ expect(result).toEqual({
+ ...mockDevice,
+ deviceName: 'Updated WebAuthn Device',
+ });
+ });
+
+ it('should error when deleting webauthn device with invalid uuid', async () => {
+ const mockDevice = MOCK_WEBAUTHN_DEVICES.result[0];
+ const result = await client.webAuthn.delete({
+ userId: 'test-user',
+ device: {
+ ...mockDevice,
+ uuid: 'bad-uuid',
+ },
+ });
+
+ expect(result).toEqual({
+ error: expect.objectContaining({ message: expect.stringContaining('bad uuid') }),
+ });
+ });
+
+ it('should delete webauthn device', async () => {
+ const mockDevice = MOCK_WEBAUTHN_DEVICES.result[0];
+ const result = await client.webAuthn.delete({
+ userId: 'test-user',
+ device: mockDevice,
+ });
+
+ expect(result).toEqual(null);
+ });
+ });
+
+ describe('Bound Device Management', () => {
+ const client = deviceClient(config);
+ const mockDevice = MOCK_BINDING_DEVICES.result[0];
+
+ it('should fetch bound devices', async () => {
+ const result = await client.bound.get({
+ userId: 'test-user',
+ ...mockDevice,
+ });
+
+ expect(result).toEqual(MOCK_BINDING_DEVICES);
+ });
+
+ it('should update bound device name', async () => {
+ const result = await client.bound.update({
+ userId: 'test-user',
+ device: mockDevice,
+ });
+
+ expect(result).toEqual({
+ ...mockDevice,
+ deviceName: 'Updated Binding Device',
+ });
+ });
+
+ it('should delete bound device', async () => {
+ const result = await client.bound.delete({
+ userId: 'test-user',
+ device: mockDevice,
+ });
+
+ expect(result).toEqual(null);
+ });
+ });
+
+ describe('Profile Device', () => {
+ const client = deviceClient(config);
+
+ it('should fetch device profiles', async () => {
+ const result = await client.profile.get({ userId: 'test-user', realm: 'test-realm' });
+
+ expect(result).toEqual(MOCK_DEVICE_PROFILE_SUCCESS);
+ });
+
+ it('should update device profiles', async () => {
+ const result = await client.profile.update({
+ userId: 'test-user',
+ realm: 'test-realm',
+ device: MOCK_DEVICE_PROFILE_SUCCESS.result[0],
+ });
+
+ expect(result).toEqual({ ...MOCK_DEVICE_PROFILE_SUCCESS.result[0], alias: 'new-name' });
+ });
+
+ it('should delete device profiles', async () => {
+ const result = await client.profile.delete({
+ userId: 'hello',
+ realm: 'alpha',
+ device: MOCK_DEVICE_PROFILE_SUCCESS.result[0],
+ });
+
+ expect(result).toEqual(null);
+ });
+ });
+});
diff --git a/packages/device-client/src/lib/device.store.test.utils.ts b/packages/device-client/src/lib/device.store.test.utils.ts
new file mode 100644
index 0000000000..32e9c3a08a
--- /dev/null
+++ b/packages/device-client/src/lib/device.store.test.utils.ts
@@ -0,0 +1,188 @@
+/*
+ * Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ */
+
+import { http, HttpResponse } from 'msw';
+
+import {
+ MOCK_PUSH_DEVICES,
+ MOCK_BINDING_DEVICES,
+ MOCK_OATH_DEVICES,
+ MOCK_DELETED_OATH_DEVICE,
+ MOCK_WEBAUTHN_DEVICES,
+ MOCK_DEVICE_PROFILE_SUCCESS,
+} from './mock-data/device.store.mock.js';
+
+// Create mock service worker handlers
+export const handlers = [
+ // OATH Devices
+ http.get('*/json/realms/:realm/users/:userId/devices/2fa/oath', ({ params }) => {
+ if (params['realm'] === 'fake-realm') {
+ return HttpResponse.json({ error: 'bad realm' }, { status: 401 });
+ }
+ if (params['userId'] === 'bad-user') {
+ return HttpResponse.json({ error: 'bad user' }, { status: 401 });
+ }
+ return HttpResponse.json(MOCK_OATH_DEVICES);
+ }),
+
+ http.delete('*/json/realms/:realm/users/:userId/devices/2fa/oath/:uuid', ({ params }) => {
+ if (params['realm'] === 'fake-realm') {
+ return HttpResponse.json({ error: 'bad realm' }, { status: 401 });
+ }
+ if (params['userId'] === 'bad-user') {
+ return HttpResponse.json({ error: 'bad user' }, { status: 401 });
+ }
+ return HttpResponse.json(MOCK_DELETED_OATH_DEVICE);
+ }),
+
+ // Push Devices
+ http.get('*/json/realms/:realm/users/:userId/devices/2fa/push', ({ params }) => {
+ if (params['realm'] === 'fake-realm') {
+ return HttpResponse.json({ error: 'bad realm' }, { status: 401 });
+ }
+ if (params['userId'] === 'bad-user') {
+ return HttpResponse.json({ error: 'bad user' }, { status: 401 });
+ }
+ return HttpResponse.json({ result: MOCK_PUSH_DEVICES });
+ }),
+
+ http.delete('*/json/realms/:realm/users/:userId/devices/2fa/push/:uuid', ({ params }) => {
+ if (params['realm'] === 'fake-realm') {
+ return HttpResponse.json({ error: 'bad realm' }, { status: 401 });
+ }
+ if (params['userId'] === 'bad-user') {
+ return HttpResponse.json({ error: 'bad user' }, { status: 401 });
+ }
+ if (params['uuid'] === 'bad-uuid') {
+ return HttpResponse.json({ error: 'bad uuid' }, { status: 401 });
+ }
+ return HttpResponse.json(MOCK_PUSH_DEVICES[0]);
+ }),
+
+ // WebAuthn Devices
+ http.get('*/json/realms/:realm/users/:userId/devices/2fa/webauthn', ({ params }) => {
+ if (params['realm'] === 'fake-realm') {
+ return HttpResponse.json({ error: 'bad realm' }, { status: 401 });
+ }
+ if (params['userId'] === 'bad-user') {
+ return HttpResponse.json({ error: 'bad user' }, { status: 401 });
+ }
+ return HttpResponse.json({ result: MOCK_WEBAUTHN_DEVICES });
+ }),
+
+ http.put('*/json/realms/:realm/users/:userId/devices/2fa/webauthn/:uuid', ({ params }) => {
+ if (params['userId'] === 'bad-uuid') {
+ return HttpResponse.json({ error: 'bad uuid' }, { status: 401 });
+ }
+ if (params['realm'] === 'fake-realm') {
+ return HttpResponse.json({ error: 'bad realm' }, { status: 401 });
+ }
+ if (params['userId'] === 'bad-user') {
+ return HttpResponse.json({ error: 'bad user' }, { status: 401 });
+ }
+ return HttpResponse.json({
+ ...MOCK_WEBAUTHN_DEVICES.result[0],
+ deviceName: 'Updated WebAuthn Device',
+ });
+ }),
+
+ http.delete('*/json/realms/:realm/users/:userId/devices/2fa/webauthn/:uuid', ({ params }) => {
+ if (params['realm'] === 'fake-realm') {
+ return HttpResponse.json({ error: 'bad realm' }, { status: 401 });
+ }
+ if (params['userId'] === 'bad-user') {
+ return HttpResponse.json({ error: 'bad user' }, { status: 401 });
+ }
+ if (params['uuid'] === 'bad-uuid') {
+ return HttpResponse.json({ error: 'bad uuid' }, { status: 401 });
+ }
+ return HttpResponse.json(MOCK_WEBAUTHN_DEVICES.result[0]);
+ }),
+
+ // Binding Devices
+ http.get('*/json/realms/:realm/users/:userId/devices/2fa/binding', ({ params }) => {
+ if (params['realm'] === 'fake-realm') {
+ return HttpResponse.json({ error: 'bad realm' }, { status: 401 });
+ }
+ if (params['userId'] === 'bad-user') {
+ return HttpResponse.json({ error: 'bad user' }, { status: 401 });
+ }
+ return HttpResponse.json({ result: MOCK_BINDING_DEVICES });
+ }),
+
+ http.put(
+ '*/json/realms/root/realms/:realm/users/:userId/devices/2fa/binding/:uuid',
+ ({ params }) => {
+ if (params['realm'] === 'fake-realm') {
+ return HttpResponse.json({ error: 'bad realm' }, { status: 401 });
+ }
+ if (params['userId'] === 'bad-user') {
+ return HttpResponse.json({ error: 'bad user' }, { status: 401 });
+ }
+ if (params['userId'] === 'bad-uuid') {
+ return HttpResponse.json({ error: 'bad user' }, { status: 401 });
+ }
+ return HttpResponse.json({
+ ...MOCK_BINDING_DEVICES.result[0],
+ deviceName: 'Updated Binding Device',
+ });
+ },
+ ),
+
+ http.delete(
+ '*/json/realms/root/realms/:realm/users/:userId/devices/2fa/binding/:uuid',
+ ({ params }) => {
+ if (params['realm'] === 'fake-realm') {
+ return HttpResponse.json({ error: 'bad realm' }, { status: 401 });
+ }
+ if (params['userId'] === 'bad-user') {
+ return HttpResponse.json({ error: 'bad user' }, { status: 401 });
+ }
+ if (params['userId'] === 'bad-uuid') {
+ return HttpResponse.json({ error: 'bad uuid' }, { status: 401 });
+ }
+ return HttpResponse.json({ result: MOCK_BINDING_DEVICES.result[0] });
+ },
+ ),
+
+ // profile devices
+ http.get('*/json/realms/:realm/users/:userId/devices/profile', ({ params }) => {
+ if (params['realm'] === 'fake-realm') {
+ return HttpResponse.json({ error: 'bad realm' }, { status: 401 });
+ }
+ if (params['userId'] === 'bad-user') {
+ return HttpResponse.json({ error: 'bad user' }, { status: 401 });
+ }
+ return HttpResponse.json({ result: MOCK_DEVICE_PROFILE_SUCCESS });
+ }),
+
+ http.put('*/json/realms/:realm/users/:userId/devices/profile/:uuid', ({ params }) => {
+ if (params['realm'] === 'fake-realm') {
+ return HttpResponse.json({ error: 'bad realm' }, { status: 401 });
+ }
+ if (params['userId'] === 'bad-user') {
+ return HttpResponse.json({ error: 'bad user' }, { status: 401 });
+ }
+ return HttpResponse.json({
+ ...MOCK_DEVICE_PROFILE_SUCCESS.result[0],
+ alias: 'new-name',
+ });
+ }),
+
+ http.delete('*/json/realms/:realm/users/:userId/devices/profile/:uuid', ({ params }) => {
+ if (params['realm'] === 'fake-realm') {
+ return HttpResponse.json({ error: 'bad realm' }, { status: 401 });
+ }
+ if (params['userId'] === 'bad-user') {
+ return HttpResponse.json({ error: 'bad user' }, { status: 401 });
+ }
+ if (params['userId'] === 'bad-uuid') {
+ return HttpResponse.json({ error: 'bad uuid' }, { status: 401 });
+ }
+ return HttpResponse.json(MOCK_DEVICE_PROFILE_SUCCESS.result[0]);
+ }),
+];
diff --git a/packages/device-client/src/lib/device.store.ts b/packages/device-client/src/lib/device.store.ts
index 1fc4a84e4b..02e37bfcb7 100644
--- a/packages/device-client/src/lib/device.store.ts
+++ b/packages/device-client/src/lib/device.store.ts
@@ -7,10 +7,16 @@
import { type ConfigOptions } from '@forgerock/javascript-sdk';
import { configureStore } from '@reduxjs/toolkit';
import { deviceService } from './services/index.js';
-import { DeleteOathQuery, OathDevice, RetrieveOathQuery } from './types/oath.types.js';
-import { DeleteDeviceQuery, PushDeviceQuery } from './types/push-device.types.js';
-import { WebAuthnBody, WebAuthnQuery, WebAuthnQueryWithUUID } from './types/webauthn.types.js';
-import { BindingDeviceQuery } from './types/binding-device.types.js';
+import { OathDevice, RetrieveOathQuery } from './types/oath.types.js';
+import { DeleteDeviceQuery, PushDevice, PushDeviceQuery } from './types/push-device.types.js';
+import { UpdatedWebAuthnDevice, WebAuthnDevice, WebAuthnQuery } from './types/webauthn.types.js';
+import { BoundDeviceQuery, Device, GetBoundDevicesQuery } from './types/bound-device.types.js';
+import {
+ GetProfileDevices,
+ ProfileDevice,
+ ProfileDevicesQuery,
+} from './types/profile-device.types.js';
+import { handleError } from './device.store.utils.js';
export const deviceClient = (config: ConfigOptions) => {
const { middleware, reducerPath, reducer, endpoints } = deviceService({
@@ -43,16 +49,20 @@ export const deviceClient = (config: ConfigOptions) => {
* @async
* @function get
* @param {RetrieveOathQuery} query - The query used to retrieve Oath devices.
- * @returns {Promise} - A promise that resolves to the retrieved data or undefined if the response is not valid.
+ * @returns {Promise} - A promise that resolves to the retrieved data or an error object if the response is not valid.
*/
- get: async function (query: RetrieveOathQuery) {
- const response = await store.dispatch(endpoints.getOAthDevices.initiate(query));
+ get: async function (query: RetrieveOathQuery): Promise {
+ try {
+ const response = await store.dispatch(endpoints.getOathDevices.initiate(query));
- if (!response || !response.data) {
- return undefined;
- }
+ if (!response || !response.data || !response.data.result) {
+ throw new Error('response did not contain data');
+ }
- return response.data;
+ return response.data.result;
+ } catch (error) {
+ return { error };
+ }
},
/**
@@ -61,16 +71,22 @@ export const deviceClient = (config: ConfigOptions) => {
* @async
* @function delete
* @param {DeleteOathQuery & OathDevice} query - The query and device information used to delete the Oath device.
- * @returns {Promise} - A promise that resolves to the response data or undefined if the response is not valid.
+ * @returns {Promise} - A promise that resolves to the response data or an error object if the response is not valid.
*/
- delete: async function (query: DeleteOathQuery & OathDevice) {
- const response = await store.dispatch(endpoints.deleteOathDevice.initiate(query));
-
- if (!response || !response.data) {
- return undefined;
+ delete: async function (
+ query: RetrieveOathQuery & { device: OathDevice },
+ ): Promise {
+ try {
+ const { error } = await store.dispatch(endpoints.deleteOathDevice.initiate(query));
+
+ if (error) {
+ handleError(error, 'Failed to delete device: ');
+ }
+
+ return null;
+ } catch (error) {
+ return { error };
}
-
- return response.data;
},
},
@@ -86,16 +102,20 @@ export const deviceClient = (config: ConfigOptions) => {
* @async
* @function get
* @param {PushDeviceQuery} query - The query used to retrieve Push devices.
- * @returns {Promise} - A promise that resolves to the retrieved data or undefined if the response is not valid.
+ * @returns {Promise} - A promise that resolves to the retrieved data or an error object if the response is not valid.
*/
- get: async function (query: PushDeviceQuery) {
- const response = await store.dispatch(endpoints.getPushDevices.initiate(query));
+ get: async function (query: PushDeviceQuery): Promise {
+ try {
+ const response = await store.dispatch(endpoints.getPushDevices.initiate(query));
- if (!response || !response.data) {
- return undefined;
- }
+ if (!response || !response.data || !response.data.result) {
+ throw new Error('response did not contain data');
+ }
- return response.data;
+ return response.data.result;
+ } catch (error) {
+ return { error };
+ }
},
/**
@@ -104,16 +124,20 @@ export const deviceClient = (config: ConfigOptions) => {
* @async
* @function delete
* @param {DeleteDeviceQuery} query - The query used to delete the Push device.
- * @returns {Promise} - A promise that resolves to the response data or undefined if the response is not valid.
+ * @returns {Promise} - A promise that resolves to the response data or an error object if the response is not valid.
*/
- delete: async function (query: DeleteDeviceQuery) {
- const response = await store.dispatch(endpoints.deletePushDevice.initiate(query));
+ delete: async function (query: DeleteDeviceQuery): Promise {
+ try {
+ const { error } = await store.dispatch(endpoints.deletePushDevice.initiate(query));
- if (!response || !response.data) {
- return undefined;
- }
+ if (error) {
+ handleError(error, 'Failed to delete device: ');
+ }
- return response.data;
+ return null;
+ } catch (error) {
+ return { error };
+ }
},
},
@@ -122,23 +146,27 @@ export const deviceClient = (config: ConfigOptions) => {
*
* @type {WebAuthnManagement}
*/
- webauthn: {
+ webAuthn: {
/**
* Retrieves WebAuthn devices based on the specified query.
*
* @async
* @function get
* @param {WebAuthnQuery} query - The query used to retrieve WebAuthn devices.
- * @returns {Promise} - A promise that resolves to the retrieved data or undefined if the response is not valid.
+ * @returns {Promise} - A promise that resolves to the retrieved data or an error object if the response is not valid.
*/
- get: async function (query: WebAuthnQuery) {
- const response = await store.dispatch(endpoints.getWebAuthnDevices.initiate(query));
+ get: async function (query: WebAuthnQuery): Promise {
+ try {
+ const response = await store.dispatch(endpoints.getWebAuthnDevices.initiate(query));
- if (!response || !response.data) {
- return undefined;
- }
+ if (!response || !response.data || !response.data.result) {
+ throw new Error('response did not contain data');
+ }
- return response.data;
+ return response.data.result;
+ } catch (error) {
+ return { error };
+ }
},
/**
@@ -146,17 +174,23 @@ export const deviceClient = (config: ConfigOptions) => {
*
* @async
* @function update
- * @param {WebAuthnQueryWithUUID & WebAuthnBody} query - The query and body used to update the WebAuthn device name.
- * @returns {Promise} - A promise that resolves to the response data or undefined if the response is not valid.
+ * @param {WebAuthnQueryWithUUID & { device: WebAuthnBody } } query - The query and body used to update the WebAuthn device name.
+ * @returns {Promise} - A promise that resolves to the response data or an error object if the response is not valid.
*/
- update: async function (query: WebAuthnQueryWithUUID & WebAuthnBody) {
- const response = await store.dispatch(endpoints.updateWebAuthnDeviceName.initiate(query));
-
- if (!response || !response.data) {
- return undefined;
+ update: async function (
+ query: WebAuthnQuery & { device: WebAuthnDevice },
+ ): Promise {
+ try {
+ const response = await store.dispatch(endpoints.updateWebAuthnDeviceName.initiate(query));
+
+ if (!response || !response.data) {
+ throw new Error('response did not contain data');
+ }
+
+ return response.data;
+ } catch (error) {
+ return { error };
}
-
- return response.data;
},
/**
@@ -164,17 +198,25 @@ export const deviceClient = (config: ConfigOptions) => {
*
* @async
* @function delete
- * @param {WebAuthnQueryWithUUID & WebAuthnBody} query - The query and body used to delete the WebAuthn device.
- * @returns {Promise} - A promise that resolves to the response data or undefined if the response is not valid.
+ * @param {WebAuthnQueryWithUUID & { device: WebAuthnBody } } query - The query and body used to delete the WebAuthn device.
+ * @returns {Promise} - A promise that resolves to the response data or an error object if the response is not valid.
*/
- delete: async function (query: WebAuthnQueryWithUUID & WebAuthnBody) {
- const response = await store.dispatch(endpoints.deleteWebAuthnDeviceName.initiate(query));
-
- if (!response || !response.data) {
- return undefined;
+ delete: async function (
+ query: WebAuthnQuery & { device: WebAuthnDevice | UpdatedWebAuthnDevice },
+ ): Promise {
+ try {
+ const { error } = await store.dispatch(
+ endpoints.deleteWebAuthnDeviceName.initiate(query),
+ );
+
+ if (error) {
+ handleError(error, 'Failed to delete device: ');
+ }
+
+ return null;
+ } catch (error) {
+ return { error };
}
-
- return response.data;
},
},
@@ -183,23 +225,27 @@ export const deviceClient = (config: ConfigOptions) => {
*
* @type {BoundDevicesManagement}
*/
- boundDevices: {
+ bound: {
/**
* Retrieves bound devices based on the specified query.
*
* @async
* @function get
- * @param {BindingDeviceQuery} query - The query used to retrieve bound devices.
- * @returns {Promise} - A promise that resolves to the retrieved data or undefined if the response is not valid.
+ * @param {BoundDeviceQuery} query - The query used to retrieve bound devices.
+ * @returns {Promise} - A promise that resolves to the retrieved data or an error object if the response is not valid.
*/
- get: async function (query: BindingDeviceQuery) {
- const response = await store.dispatch(endpoints.getBoundDevices.initiate(query));
+ get: async function (query: GetBoundDevicesQuery): Promise {
+ try {
+ const response = await store.dispatch(endpoints.getBoundDevices.initiate(query));
- if (!response || !response.data) {
- return undefined;
- }
+ if (!response || !response.data || !response.data.result) {
+ throw new Error('response did not contain data');
+ }
- return response.data;
+ return response.data.result;
+ } catch (error) {
+ return { error };
+ }
},
/**
@@ -207,17 +253,21 @@ export const deviceClient = (config: ConfigOptions) => {
*
* @async
* @function delete
- * @param {BindingDeviceQuery} query - The query used to delete the bound device.
- * @returns {Promise} - A promise that resolves to the response data or undefined if the response is not valid.
+ * @param {BoundDeviceQuery} query - The query used to delete the bound device.
+ * @returns {Promise} - A promise that resolves to the response data or an error object if the response is not valid.
*/
- delete: async function (query: BindingDeviceQuery) {
- const response = await store.dispatch(endpoints.deleteBindingDevice.initiate(query));
+ delete: async function (query: BoundDeviceQuery): Promise {
+ try {
+ const { error } = await store.dispatch(endpoints.deleteBoundDevice.initiate(query));
- if (!response || !response.data) {
- return undefined;
- }
+ if (error) {
+ handleError(error, 'Failed to delete device: ');
+ }
- return response.data;
+ return null;
+ } catch (error) {
+ return { error };
+ }
},
/**
@@ -225,17 +275,90 @@ export const deviceClient = (config: ConfigOptions) => {
*
* @async
* @function update
- * @param {BindingDeviceQuery} query - The query used to update the bound device name.
- * @returns {Promise} - A promise that resolves to the response data or undefined if the response is not valid.
+ * @param {BoundDeviceQuery} query - The query used to update the bound device name.
+ * @returns {Promise} - A promise that resolves to the response data or an error object if the response is not valid.
*/
- update: async function (query: BindingDeviceQuery) {
- const response = await store.dispatch(endpoints.updateBindingDeviceName.initiate(query));
+ update: async function (query: BoundDeviceQuery): Promise {
+ try {
+ const response = await store.dispatch(endpoints.updateBoundDevice.initiate(query));
+
+ if (!response || !response.data) {
+ throw new Error('response did not contain data');
+ }
- if (!response || !response.data) {
- return undefined;
+ return response.data;
+ } catch (error) {
+ return { error };
+ }
+ },
+ },
+ profile: {
+ /**
+ * Get profile devices
+ *
+ * @async
+ * @function update
+ * @param {GetProfileDevice} query - The query used to get profile devices
+ * @returns {Promise} - A promise that resolves to the response data or an error object if the response is not valid.
+ */
+ get: async function (
+ query: GetProfileDevices,
+ ): Promise {
+ try {
+ const response = await store.dispatch(endpoints.getDeviceProfiles.initiate(query));
+
+ if (!response || !response.data || !response.data.result) {
+ throw new Error('response did not contain data');
+ }
+
+ return response.data.result;
+ } catch (error) {
+ return { error };
}
+ },
+ /**
+ * Get profile devices
+ *
+ * @async
+ * @function update
+ * @param {ProfileDevicesQuery} query - The query used to update a profile device
+ * @returns {Promise} - A promise that resolves to the response data or or an error object if the response is not valid.
+ */
+ update: async function (
+ query: ProfileDevicesQuery,
+ ): Promise {
+ try {
+ const response = await store.dispatch(endpoints.updateDeviceProfile.initiate(query));
+
+ if (!response || !response.data) {
+ throw new Error('response did not contain data');
+ }
+
+ return response.data;
+ } catch (error) {
+ return { error };
+ }
+ },
+ /**
+ * Get profile devices
+ *
+ * @async
+ * @function update
+ * @param {ProfileDevicesQuery} query - The query used to update a profile device
+ * @returns {Promise} - A promise that resolves to the response data or an error object if the response is not valid.
+ */
+ delete: async function (query: ProfileDevicesQuery): Promise {
+ try {
+ const { error } = await store.dispatch(endpoints.deleteDeviceProfile.initiate(query));
- return response.data;
+ if (error) {
+ handleError(error, 'Failed to delete device profile: ');
+ }
+
+ return null;
+ } catch (error) {
+ return { error };
+ }
},
},
};
diff --git a/packages/device-client/src/lib/device.store.utils.ts b/packages/device-client/src/lib/device.store.utils.ts
new file mode 100644
index 0000000000..7e62f5e0c2
--- /dev/null
+++ b/packages/device-client/src/lib/device.store.utils.ts
@@ -0,0 +1,15 @@
+import { SerializedError } from '@reduxjs/toolkit';
+import { FetchBaseQueryError } from '@reduxjs/toolkit/query';
+
+export function handleError(error: FetchBaseQueryError | SerializedError, message?: string) {
+ /**
+ * Handle an RTK Query error after narrowing to either FetchBaseQueryError or SerializedError
+ * https://redux-toolkit.js.org/rtk-query/usage-with-typescript#type-safe-error-handling
+ */
+ if ('status' in error) {
+ const errMsg = 'error' in error ? error.error : JSON.stringify(error.data);
+ throw new Error(`${message ?? ''}${errMsg}`);
+ }
+
+ throw new Error(`${message ?? ''}${error.message}`);
+}
diff --git a/packages/device-client/src/lib/mock-data/device.store.mock.ts b/packages/device-client/src/lib/mock-data/device.store.mock.ts
new file mode 100644
index 0000000000..3c53c7bfd8
--- /dev/null
+++ b/packages/device-client/src/lib/mock-data/device.store.mock.ts
@@ -0,0 +1,172 @@
+/*
+ * Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ */
+import { GeneralResponse } from '../services/index.js';
+import type {
+ OathResponse,
+ DeletedOathDevice,
+ DeviceResponse,
+ ProfileDevice,
+ PushDevice,
+ WebAuthnDevice,
+} from '../types/index.js';
+
+// Mock data
+export const MOCK_OATH_DEVICES: OathResponse = {
+ pagedResultsCookie: 'cookie',
+ remainingPagedResults: -1,
+ resultCount: 2,
+ totalPagedResults: 2,
+ totalPagedResultsPolicy: 'string',
+ result: [
+ {
+ _id: 'oath-1',
+ _rev: '1-oath',
+ createdDate: 1705555555555,
+ lastAccessDate: 1705555555555,
+ deviceName: 'Test OATH Device',
+ uuid: 'oath-uuid-1',
+ deviceManagementStatus: true,
+ },
+ ],
+};
+
+export const MOCK_DELETED_OATH_DEVICE: DeletedOathDevice = {
+ _id: 'oath-1',
+ _rev: '1-oath',
+ uuid: 'oath-uuid-1',
+ recoveryCodes: ['code1', 'code2'],
+ createdDate: 1705555555555,
+ lastAccessDate: 1705555555555,
+ sharedSecret: 'secret123',
+ deviceName: 'Test OATH Device',
+ lastLogin: 1705555555555,
+ counter: 0,
+ checksumDigit: true,
+ truncationOffset: 0,
+ clockDriftSeconds: 0,
+};
+
+export const MOCK_PUSH_DEVICES: PushDevice[] = [
+ {
+ _id: 'push-1',
+ _rev: '1-push',
+ createdDate: 1705555555555,
+ lastAccessDate: 1705555555555,
+ deviceName: 'Test Push Device',
+ uuid: 'push-uuid-1',
+ deviceManagementStatus: true,
+ },
+];
+
+export const MOCK_WEBAUTHN_DEVICES: GeneralResponse = {
+ result: [
+ {
+ _id: 'webauthn-1',
+ _rev: '1-webauthn',
+ createdDate: 1705555555555,
+ lastAccessDate: 1705555555555,
+ credentialId: 'credential-1',
+ deviceName: 'Test WebAuthn Device',
+ uuid: 'webauthn-uuid-1',
+ deviceManagementStatus: true,
+ },
+ ],
+ resultCount: 1,
+ pagedResultsCookie: null,
+ totalPagedResultsPolicy: 'NONE',
+ totalPagedResults: -1,
+ remainingPagedResults: -1,
+};
+
+export const MOCK_BINDING_DEVICES: DeviceResponse = {
+ result: [
+ {
+ _id: 'binding-1',
+ _rev: '1-binding',
+ createdDate: 1705555555555,
+ lastAccessDate: 1705555555555,
+ deviceId: 'device-1',
+ deviceName: 'Test Binding Device',
+ uuid: 'binding-uuid-1',
+ recoveryCodes: ['123456', '789012'],
+ key: {
+ kty: 'RSA',
+ kid: 'key-1',
+ use: 'sig',
+ alg: 'RS256',
+ n: 'mock-n',
+ e: 'mock-e',
+ },
+ deviceManagementStatus: true,
+ },
+ ],
+ resultCount: 1,
+ pagedResultsCookie: null,
+ totalPagedResultsPolicy: 'NONE',
+ totalPagedResults: -1,
+ remainingPagedResults: -1,
+};
+
+export const MOCK_DEVICE_PROFILE_SUCCESS: GeneralResponse = {
+ result: [
+ {
+ _id: 'ce0677ca57da8b38-5bfaa23e9a8ddc7899638da7cccbfe6a8879b6cf',
+ _rev: '755317638',
+ identifier: 'ce0677ca57da8b38-5bfaa23e9a8ddc7899638da7cccbfe6a8879b6cf',
+ metadata: {
+ platform: {
+ platform: 'Android',
+ version: 34,
+ device: 'emu64a',
+ deviceName: 'sdk_gphone64_arm64',
+ model: 'sdk_gphone64_arm64',
+ brand: 'google',
+ locale: 'en_US',
+ timeZone: 'America/Vancouver',
+ jailBreakScore: 0,
+ },
+ hardware: {
+ hardware: 'ranchu',
+ manufacturer: 'Google',
+ storage: 5939,
+ memory: 2981,
+ cpu: 4,
+ display: {
+ width: 1440,
+ height: 2678,
+ orientation: 1,
+ },
+ camera: {
+ numberOfCameras: 2,
+ },
+ },
+ browser: {
+ userAgent:
+ 'Mozilla/5.0 (Linux; Android 14; sdk_gphone64_arm64 Build/UPB4.230623.005; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/128.0.6613.127 Mobile Safari/537.36',
+ },
+ bluetooth: {
+ supported: true,
+ },
+ network: {
+ connected: true,
+ },
+ telephony: {
+ networkCountryIso: 'us',
+ carrierName: 'T-Mobile',
+ },
+ },
+ lastSelectedDate: 1727110785783,
+ alias: 'test',
+ recoveryCodes: [],
+ },
+ ],
+ resultCount: 1,
+ pagedResultsCookie: null,
+ totalPagedResultsPolicy: 'NONE',
+ totalPagedResults: -1,
+ remainingPagedResults: -1,
+};
diff --git a/packages/device-client/src/lib/services/index.ts b/packages/device-client/src/lib/services/index.ts
index fc4f5a8400..fd7be4981d 100644
--- a/packages/device-client/src/lib/services/index.ts
+++ b/packages/device-client/src/lib/services/index.ts
@@ -6,28 +6,34 @@
*/
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query';
import {
- DeletedOAthDevice,
- DeleteOathQuery,
+ DeletedOathDevice,
OathDevice,
- OAthResponse,
+ OathResponse,
RetrieveOathQuery,
} from '../types/oath.types.js';
import {
DeleteDeviceQuery,
+ DeletedPushDevice,
PushDevice,
PushDeviceQuery,
- PushDevicesResponse,
} from '../types/push-device.types.js';
-import { BindingDeviceQuery, Device, DeviceResponse } from '../types/binding-device.types.js';
+import { BoundDeviceQuery, Device, GetBoundDevicesQuery } from '../types/bound-device.types.js';
+import { UpdatedWebAuthnDevice, WebAuthnDevice, WebAuthnQuery } from '../types/webauthn.types.js';
import {
- UpdatedWebAuthnDevice,
- WebAuthnBody,
- WebAuthnDevice,
- WebAuthnDevicesResponse,
- WebAuthnQuery,
- WebAuthnQueryWithUUID,
-} from '../types/webauthn.types.js';
+ ProfileDevice,
+ GetProfileDevices,
+ ProfileDevicesQuery,
+} from '../types/profile-device.types.js';
+
+export interface GeneralResponse {
+ pagedResultsCookie: string | null;
+ remainingPagedResults: number;
+ resultCount: number;
+ totalPagedResults: number;
+ totalPagedResultsPolicy: string;
+ result: T;
+}
export const deviceService = ({ baseUrl, realmPath }: { baseUrl: string; realmPath: string }) =>
createApi({
@@ -39,81 +45,103 @@ export const deviceService = ({ baseUrl, realmPath }: { baseUrl: string; realmPa
headers.set('Accept', 'application/json');
headers.set('x-requested-with', 'forgerock-sdk');
headers.set('x-requested-platform', 'javascript');
+
return headers;
},
baseUrl,
}),
endpoints: (builder) => ({
// oath endpoints
- getOAthDevices: builder.query({
+ getOathDevices: builder.query({
query: ({ realm = realmPath, userId }) =>
`json/realms/${realm}/users/${userId}/devices/2fa/oath?_queryFilter=true`,
}),
- deleteOathDevice: builder.mutation({
- query: ({ realm = realmPath, userId, uuid, ...body }) => ({
+ deleteOathDevice: builder.mutation<
+ DeletedOathDevice,
+ RetrieveOathQuery & { device: OathDevice }
+ >({
+ query: ({ realm = realmPath, userId, device }) => ({
method: 'DELETE',
- url: `json/realms/${realm}/users/${userId}/devices/2fa/oath/${uuid}`,
- body: { uuid, ...body },
+ url: `json/realms/${realm}/users/${userId}/devices/2fa/oath/${device.uuid}`,
+ body: device,
}),
}),
// push device
- getPushDevices: builder.query({
+ getPushDevices: builder.query, PushDeviceQuery>({
query: ({ realm = realmPath, userId }) =>
`/json/realms/${realm}/users/${userId}/devices/2fa/push?_queryFilter=true`,
}),
- deletePushDevice: builder.mutation({
- query: ({ realm = realmPath, userId, uuid }) => ({
- url: `/json/realms/${realm}/users/${userId}/devices/2fa/push/${uuid}`,
+ deletePushDevice: builder.mutation({
+ query: ({ realm = realmPath, userId, device }) => ({
+ url: `/json/realms/${realm}/users/${userId}/devices/2fa/push/${device.uuid}`,
method: 'DELETE',
body: {},
}),
}),
// webauthn devices
- getWebAuthnDevices: builder.query({
+ getWebAuthnDevices: builder.query, WebAuthnQuery>({
query: ({ realm = realmPath, userId }) =>
`/json/realms/${realm}/users/${userId}/devices/2fa/webauthn?_queryFilter=true`,
}),
updateWebAuthnDeviceName: builder.mutation<
UpdatedWebAuthnDevice,
- WebAuthnQueryWithUUID & WebAuthnBody
+ WebAuthnQuery & { device: WebAuthnDevice }
>({
- query: ({ realm = realmPath, userId, ...device }) => ({
+ query: ({ realm = realmPath, userId, device }) => ({
url: `/json/realms/${realm}/users/${userId}/devices/2fa/webauthn/${device.uuid}`,
method: 'PUT',
- body: device satisfies WebAuthnBody,
+ body: device,
}),
}),
deleteWebAuthnDeviceName: builder.mutation<
- WebAuthnDevice,
- WebAuthnQueryWithUUID & WebAuthnBody
+ UpdatedWebAuthnDevice,
+ WebAuthnQuery & { device: UpdatedWebAuthnDevice | WebAuthnDevice }
>({
- query: ({ realm = realmPath, userId, ...device }) => ({
+ query: ({ realm = realmPath, userId, device }) => ({
url: `/json/realms/${realm}/users/${userId}/devices/2fa/webauthn/${device.uuid}`,
method: 'DELETE',
- body: device satisfies WebAuthnBody,
+ body: device,
}),
}),
- getBoundDevices: builder.mutation({
+ getBoundDevices: builder.mutation, GetBoundDevicesQuery>({
query: ({ realm = realmPath, userId }) =>
`/json/realms/${realm}/users/${userId}/devices/2fa/binding?_queryFilter=true`,
}),
- updateBindingDeviceName: builder.mutation({
- query: ({ realm = realmPath, userId, ...device }) => ({
+ updateBoundDevice: builder.mutation({
+ query: ({ realm = realmPath, userId, device }) => ({
url: `/json/realms/root/realms/${realm}/users/${userId}/devices/2fa/binding/${device.uuid}`,
method: 'PUT',
- body: device satisfies Device,
+ body: device,
}),
}),
- deleteBindingDevice: builder.mutation({
- query: ({ realm = realmPath, userId, ...device }) => ({
+ deleteBoundDevice: builder.mutation, BoundDeviceQuery>({
+ query: ({ realm = realmPath, userId, device }) => ({
url: `/json/realms/root/realms/${realm}/users/${userId}/devices/2fa/binding/${device.uuid}`,
method: 'DELETE',
body: device satisfies Device,
}),
}),
+ getDeviceProfiles: builder.query, GetProfileDevices>({
+ query: ({ realm = realmPath, userId }) =>
+ `json/realms/${realm}/users/${userId}/devices/profile?_queryFilter=true`,
+ }),
+ updateDeviceProfile: builder.mutation>({
+ query: ({ realm = realmPath, userId, device }) => ({
+ url: `json/realms/${realm}/users/${userId}/devices/profile/${device.identifier}`,
+ method: 'PUT',
+ body: device,
+ }),
+ }),
+ deleteDeviceProfile: builder.mutation({
+ query: ({ realm = realmPath, userId, device }) => ({
+ url: `json/realms/${realm}/users/${userId}/devices/profile/${device.identifier}`,
+ method: 'DELETE',
+ body: device,
+ }),
+ }),
}),
});
diff --git a/packages/device-client/src/lib/types/binding-device.types.ts b/packages/device-client/src/lib/types/bound-device.types.ts
similarity index 84%
rename from packages/device-client/src/lib/types/binding-device.types.ts
rename to packages/device-client/src/lib/types/bound-device.types.ts
index 73a04c4e98..cbb0c13943 100644
--- a/packages/device-client/src/lib/types/binding-device.types.ts
+++ b/packages/device-client/src/lib/types/bound-device.types.ts
@@ -4,10 +4,11 @@
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
-export type BindingDeviceQuery = {
+export type GetBoundDevicesQuery = {
userId: string;
realm?: string;
-} & Device;
+};
+export type BoundDeviceQuery = GetBoundDevicesQuery & { device: Device };
export type DeviceResponse = {
result: Device[];
@@ -26,6 +27,7 @@ export type Device = {
deviceId: string;
deviceName: string;
uuid: string;
+ recoveryCodes: string[];
key: {
kty: string;
kid: string;
diff --git a/packages/device-client/src/lib/types/index.ts b/packages/device-client/src/lib/types/index.ts
index a5bd473fa4..33a54ff60d 100644
--- a/packages/device-client/src/lib/types/index.ts
+++ b/packages/device-client/src/lib/types/index.ts
@@ -6,6 +6,7 @@
*/
export * from './oath.types.js';
export * from './webauthn.types.js';
+export * from './profile-device.types.js';
export * from './push-device.types.js';
-export * from './binding-device.types.js';
+export * from './bound-device.types.js';
export * from './updateDeviceProfile.types.js';
diff --git a/packages/device-client/src/lib/types/oath.types.ts b/packages/device-client/src/lib/types/oath.types.ts
index be5df5398c..02d96b6bf6 100644
--- a/packages/device-client/src/lib/types/oath.types.ts
+++ b/packages/device-client/src/lib/types/oath.types.ts
@@ -5,11 +5,13 @@
* of the MIT license. See the LICENSE file for details.
*/
export type OathDevice = {
- id: string;
+ _id: string;
+ deviceManagementStatus: boolean;
deviceName: string;
uuid: string;
- createdDate: Date;
- lastAccessDate: Date;
+ createdDate: number;
+ lastAccessDate: number;
+ _rev: string;
};
export type DeleteOathQuery = {
@@ -23,17 +25,16 @@ export type RetrieveOathQuery = {
userId: string;
};
-export type OAthResponse = {
- _id: string;
- _rev: string;
- createdDate: number;
- lastAccessDate: number;
- deviceName: string;
- uuid: string;
- deviceManagementStatus: boolean;
-}[];
+export type OathResponse = {
+ pagedResultsCookie: string | null;
+ remainingPagedResults: number;
+ resultCount: number;
+ totalPagedResults: number;
+ totalPagedResultsPolicy: string;
+ result: OathDevice[];
+};
-export type DeletedOAthDevice = {
+export type DeletedOathDevice = {
_id: string;
_rev: string;
uuid: string;
diff --git a/packages/device-client/src/lib/types/profile-device.types.ts b/packages/device-client/src/lib/types/profile-device.types.ts
new file mode 100644
index 0000000000..f3687bc4c2
--- /dev/null
+++ b/packages/device-client/src/lib/types/profile-device.types.ts
@@ -0,0 +1,64 @@
+/*
+ * Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ */
+export interface GetProfileDevices {
+ realm: string;
+ userId: string;
+}
+
+export interface ProfileDevicesQuery extends GetProfileDevices {
+ device: ProfileDevice;
+}
+
+export type ProfileDevice = {
+ _id: string;
+ _rev: string;
+ identifier: string;
+ metadata: {
+ platform: {
+ platform: string;
+ version: number;
+ device: string;
+ deviceName: string;
+ model: string;
+ brand: string;
+ locale: string;
+ timeZone: string;
+ jailBreakScore: number;
+ };
+ hardware: {
+ hardware: string;
+ manufacturer: string;
+ storage: number;
+ memory: number;
+ cpu: number;
+ display: {
+ width: number;
+ height: number;
+ orientation: number;
+ };
+ camera: {
+ numberOfCameras: number;
+ };
+ };
+ browser: {
+ userAgent: string;
+ };
+ bluetooth: {
+ supported: boolean;
+ };
+ network: {
+ connected: boolean;
+ };
+ telephony: {
+ networkCountryIso: string;
+ carrierName: string;
+ };
+ };
+ lastSelectedDate: number;
+ alias: string;
+ recoveryCodes: string[];
+};
diff --git a/packages/device-client/src/lib/types/push-device.types.ts b/packages/device-client/src/lib/types/push-device.types.ts
index b5fc857ea0..64bcb4043c 100644
--- a/packages/device-client/src/lib/types/push-device.types.ts
+++ b/packages/device-client/src/lib/types/push-device.types.ts
@@ -20,7 +20,7 @@ export type PushDeviceBody = {
export type DeleteDeviceQuery = {
realm?: string;
userId: string;
- uuid: string;
+ device: PushDevice;
};
export type DeviceInfoResponse = {
@@ -81,9 +81,6 @@ export type DeviceInfo = {
alias: string;
recoveryCodes: string[];
};
-
-export type PushDevicesResponse = PushDevice[];
-
export type PushDevice = {
_id: string;
_rev: string;
@@ -93,3 +90,20 @@ export type PushDevice = {
uuid: string;
deviceManagementStatus: boolean;
};
+
+export interface DeletedPushDevice {
+ communicationId: string;
+ communicationType: string;
+ createdDate: number;
+ deviceId: string;
+ deviceMechanismUID: string;
+ deviceName: string;
+ deviceType: string;
+ issuer: string;
+ lastAccessDate: number;
+ recoveryCodes: string[];
+ sharedSecret: string;
+ uuid: string;
+ _id: string;
+ _rev: string;
+}
diff --git a/packages/device-client/src/lib/types/webauthn.types.ts b/packages/device-client/src/lib/types/webauthn.types.ts
index 51dad83698..551921d726 100644
--- a/packages/device-client/src/lib/types/webauthn.types.ts
+++ b/packages/device-client/src/lib/types/webauthn.types.ts
@@ -9,10 +9,6 @@ export type WebAuthnQuery = {
userId: string;
};
-export type WebAuthnQueryWithUUID = {
- uuid: string;
-} & WebAuthnQuery;
-
export type WebAuthnBody = {
id: string;
deviceName: string;
@@ -22,15 +18,6 @@ export type WebAuthnBody = {
lastAccessDate: number;
};
-export type WebAuthnDevicesResponse = {
- result: WebAuthnDevice[];
- resultCount: number;
- pagedResultsCookie: null;
- totalPagedResultsPolicy: 'NONE';
- totalPagedResults: -1;
- remainingPagedResults: -1;
-};
-
export type WebAuthnDevice = {
_id: string;
_rev: string;
diff --git a/packages/device-client/src/types.ts b/packages/device-client/src/types.ts
new file mode 100644
index 0000000000..c6eea56feb
--- /dev/null
+++ b/packages/device-client/src/types.ts
@@ -0,0 +1,7 @@
+/*
+ * Copyright (c) 2025 Ping Identity Corporation. All right reserved.
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ */
+export * from './lib/types/index.js';
diff --git a/packages/device-client/tsconfig.lib.json b/packages/device-client/tsconfig.lib.json
index 706ef39a3d..c0610b334d 100644
--- a/packages/device-client/tsconfig.lib.json
+++ b/packages/device-client/tsconfig.lib.json
@@ -1,9 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
- "module": "NodeNext",
- "moduleResolution": "NodeNext",
- "target": "ES2022",
+ "module": "ES2020",
+ "moduleResolution": "Bundler",
+ "target": "ES2020",
"outDir": "./dist",
"declaration": true,
"declarationMap": true,
@@ -12,11 +12,12 @@
"composite": true,
"lib": ["es2022", "dom", "dom.iterable"]
},
- "include": ["src/**/*.ts"],
+ "include": ["src/**/*.ts", "src/**/*.*.ts"],
"exclude": [
"vite.config.ts",
"src/**/*.spec.ts",
"src/**/*.test.ts",
+ "src/**/*.test.utils.ts",
"src/lib/mock-data/*"
]
}
diff --git a/packages/device-client/tsconfig.spec.json b/packages/device-client/tsconfig.spec.json
index bfa1969edd..0554f18a6d 100644
--- a/packages/device-client/tsconfig.spec.json
+++ b/packages/device-client/tsconfig.spec.json
@@ -2,8 +2,8 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": true,
- "module": "NodeNext",
- "moduleResolution": "NodeNext",
+ "module": "ES2020",
+ "moduleResolution": "bundler",
"target": "ES2022",
"outDir": "../../dist/out-tsc",
"types": ["vitest/globals", "vitest/importMeta", "vite/client", "node"]
@@ -19,6 +19,9 @@
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
- "src/**/*.d.ts"
+ "src/**/*.d.ts",
+ "src/**/*.ts",
+ "src/**/*.test.utils.ts",
+ "src/lib/mock-data/*"
]
}
diff --git a/packages/device-client/vite.config.ts b/packages/device-client/vite.config.ts
index ff9e523881..30eeb5ca57 100644
--- a/packages/device-client/vite.config.ts
+++ b/packages/device-client/vite.config.ts
@@ -11,7 +11,6 @@ export default defineConfig({
watch: false,
reporters: ['default'],
globals: true,
- setupFiles: ['./vitest.setup.ts'],
passWithNoTests: true,
coverage: {
enabled: Boolean(process.env['CI']),
@@ -28,5 +27,6 @@ export default defineConfig({
},
environment: 'jsdom',
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
+ exclude: ['src/**/*.test.utils.ts'],
},
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 08ac9357a3..c969a6bdc1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -137,9 +137,6 @@ importers:
cz-git:
specifier: ^1.6.1
version: 1.11.1
- effect:
- specifier: ^3.12.7
- version: 3.16.0
eslint:
specifier: ^9.8.0
version: 9.27.0(jiti@2.4.2)
@@ -242,6 +239,22 @@ importers:
e2e/davinci-suites: {}
+ e2e/device-client-app:
+ dependencies:
+ '@forgerock/device-client':
+ specifier: workspace:*
+ version: link:../../packages/device-client
+ '@forgerock/javascript-sdk':
+ specifier: 4.7.0
+ version: 4.7.0
+ effect:
+ specifier: ^3.12.7
+ version: 3.16.0
+ devDependencies:
+ '@effect/language-service':
+ specifier: ^0.20.0
+ version: 0.20.1
+
e2e/mock-api-v2:
dependencies:
'@effect/language-service':
@@ -1214,6 +1227,9 @@ packages:
'@effect/language-service@0.2.0':
resolution: {integrity: sha512-DoK41yKGyQv79o0ca8gxEogMlt+IphXkdCXwgenbQjH1BXKD7tJAr0+VsDhblycQcvQ39f1l9NZN9CBqjM9ALA==}
+ '@effect/language-service@0.20.1':
+ resolution: {integrity: sha512-AgFazqxD2rlE0mc8V03BZw1XKghfOv9rrvR0M2xBv5haT4jHw5j07UK+Ln+dyeGmvrVXUT3a8Uc3pEkRJb+XHw==}
+
'@effect/platform-node-shared@0.8.26':
resolution: {integrity: sha512-c7yYFvQwse5ar8JZitBM1fTGAQGfBQUqMRKVxKYux4GDMKw6oaZ8g7eQf9PRpMCxIdBMZlPilIablSJ0DtoPVQ==}
peerDependencies:
@@ -8388,6 +8404,8 @@ snapshots:
'@effect/language-service@0.2.0': {}
+ '@effect/language-service@0.20.1': {}
+
'@effect/platform-node-shared@0.8.26(@effect/platform@0.58.27(@effect/schema@0.68.27(effect@3.16.0))(effect@3.16.0))(effect@3.16.0)':
dependencies:
'@effect/platform': 0.58.27(@effect/schema@0.68.27(effect@3.16.0))(effect@3.16.0)
diff --git a/tsconfig.json b/tsconfig.json
index 63a20e88dd..6e5375fc51 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -28,6 +28,9 @@
{
"path": "./e2e/protect-suites"
},
+ {
+ "path": "./e2e/device-client-app"
+ },
{
"path": "./packages/davinci-client"
},