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" },