@descope-ui/descope-trusted-devices 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver).
4
+
5
+ ## 0.1.0 (2025-09-02)
6
+
7
+ ### Dependency Updates
8
+
9
+ * `e2e-utils` updated to version `0.1.0`
10
+ * `@descope-ui/common` updated to version `0.1.0`
11
+ * `@descope-ui/theme-globals` updated to version `0.0.20`
12
+ * `@descope-ui/descope-list` updated to version `0.1.0`
13
+ * `@descope-ui/descope-list-item` updated to version `0.1.0`
14
+ * `@descope-ui/descope-text` updated to version `0.0.20`
15
+ * `@descope-ui/descope-link` updated to version `0.1.0`
16
+ * `@descope-ui/descope-badge` updated to version `0.1.0`
17
+
18
+ ### Features
19
+
20
+ * Trusted Devices ([#697](https://github.com/descope/web-components-ui/issues/697)) ([fb2f0eb](https://github.com/descope/web-components-ui/commit/fb2f0eb6773ed624354ed9f0b97d713bb8b10fce))
21
+
22
+ # Changelog
@@ -0,0 +1,163 @@
1
+ import { test, expect } from '@playwright/test';
2
+ import { getStoryUrl, loopConfig, loopPresets } from 'e2e-utils';
3
+
4
+ const componentAttributes = {
5
+ direction: ['ltr', 'rtl'],
6
+ format: ['MM-DD-YYYY', 'DD-MM-YYYY', 'YYYY-MM-DD'],
7
+ readonly: ['false', 'true'],
8
+ useLongNames: ['false', 'true'],
9
+ 'hide-actions': ['false', 'true'],
10
+ 'full-width': ['false', 'true'],
11
+ numberOfItems: [0, 1, 2, 5, 10],
12
+ 'st-list-items-gap': ['0', '4px', '16px', '50px'],
13
+ 'st-item-horizontal-padding': ['0', '4px', '16px', '50px'],
14
+ 'st-item-vertical-padding': ['0', '4px', '16px', '50px'],
15
+ 'remove-device-label': [
16
+ '',
17
+ 'Remove',
18
+ 'Long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long Sign out',
19
+ ],
20
+ 'current-device-label': [
21
+ '',
22
+ 'This device',
23
+ 'Long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long current device',
24
+ ],
25
+ 'last-login-label': [
26
+ '',
27
+ 'Last seen',
28
+ 'Long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long long last login',
29
+ ],
30
+ };
31
+
32
+ const customLabels = {
33
+ 'remove-device-label': componentAttributes['remove-device-label'][2],
34
+ 'current-device-label': componentAttributes['current-device-label'][2],
35
+ 'last-login-label': componentAttributes['last-login-label'][2],
36
+ }
37
+
38
+ const presets = {
39
+ 'long content ltr': {
40
+ useLongNames: 'true',
41
+ ...customLabels
42
+ },
43
+ 'long content rtl': {
44
+ direction: 'rtl',
45
+ useLongNames: 'true',
46
+ ...customLabels
47
+ },
48
+ };
49
+
50
+ const storyName = 'descope-trusted-devices';
51
+ const componentName = 'descope-trusted-devices';
52
+
53
+ test.describe('theme', () => {
54
+ loopConfig(componentAttributes, (attr, value) => {
55
+ test.describe(`${attr}: ${value}`, () => {
56
+ test.beforeEach(async ({ page }) => {
57
+ await page.goto(getStoryUrl(storyName, { [attr]: value }), {
58
+ waitUntil: 'networkidle',
59
+ });
60
+ });
61
+
62
+ test('style', async ({ page }) => {
63
+ const componentParent = page.locator(componentName);
64
+ await page.waitForTimeout(2000);
65
+
66
+ expect(await componentParent.screenshot()).toMatchSnapshot();
67
+ });
68
+ });
69
+ });
70
+ });
71
+
72
+ const setupEventListener = (eventName: string) => {
73
+ return `
74
+ window.eventDetails = [];
75
+ document.addEventListener('${eventName}', (event) => {
76
+ window.eventDetails.push(event.detail);
77
+ });
78
+ `;
79
+ };
80
+
81
+ test.describe('logic', () => {
82
+ test('dispatch remove action with appId', async ({ page }) => {
83
+ await page.goto(getStoryUrl(storyName), {
84
+ waitUntil: 'networkidle',
85
+ });
86
+
87
+ await page.addInitScript(setupEventListener('remove-device-clicked'));
88
+
89
+ await page.reload({ waitUntil: 'networkidle' });
90
+
91
+ await page.getByText('Sign out').first().click();
92
+ await page.waitForTimeout(500);
93
+
94
+ const eventDetail = await page.evaluate(
95
+ () => (window as any).eventDetails[0],
96
+ );
97
+
98
+ expect(eventDetail).toBeDefined();
99
+ expect(eventDetail).toHaveProperty('action', 'remove-device');
100
+ expect(eventDetail).toHaveProperty('id', 'device-id-5');
101
+ });
102
+
103
+ test('prevent dispatch when readonly', async ({ page }) => {
104
+ await page.goto(getStoryUrl(storyName, { readonly: 'true' }), {
105
+ waitUntil: 'networkidle',
106
+ });
107
+
108
+ await page.addInitScript(setupEventListener('remove-device-clicked'));
109
+
110
+ await page.reload({ waitUntil: 'networkidle' });
111
+
112
+ await page.getByText('Sign out').first().click();
113
+ await page.waitForTimeout(500);
114
+
115
+ const eventDetail = await page.evaluate(
116
+ () => (window as any).eventDetails[0],
117
+ );
118
+
119
+ expect(eventDetail).not.toBeDefined();
120
+ });
121
+ });
122
+
123
+ test.describe('presets', () => {
124
+ loopPresets(presets, (preset, name) => {
125
+ test(name, async ({ page }) => {
126
+ await page.goto(getStoryUrl(storyName, preset));
127
+ await page.waitForSelector(componentName);
128
+
129
+ const component = page.locator(componentName);
130
+
131
+ expect(
132
+ await component.screenshot({
133
+ animations: 'disabled',
134
+ timeout: 3000,
135
+ }),
136
+ ).toMatchSnapshot();
137
+ });
138
+ });
139
+
140
+ test('empty state', async ({ page, browserName }) => {
141
+ test.skip(browserName === 'webkit');
142
+ await page.goto(
143
+ getStoryUrl(storyName, {
144
+ numberOfItems: 0,
145
+ 'empty-state': 'No devices found!',
146
+ }),
147
+ {
148
+ waitUntil: 'networkidle',
149
+ },
150
+ );
151
+
152
+ await page.waitForSelector(componentName);
153
+
154
+ const component = page.locator(componentName);
155
+
156
+ expect(
157
+ await component.screenshot({
158
+ animations: 'disabled',
159
+ timeout: 3000,
160
+ }),
161
+ ).toMatchSnapshot();
162
+ });
163
+ });
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@descope-ui/descope-trusted-devices",
3
+ "version": "0.1.0",
4
+ "exports": {
5
+ ".": {
6
+ "import": "./src/component/index.js"
7
+ },
8
+ "./theme": {
9
+ "import": "./src/theme.js"
10
+ },
11
+ "./class": {
12
+ "import": "./src/component/TrustedDevicesClass.js"
13
+ }
14
+ },
15
+ "devDependencies": {
16
+ "@playwright/test": "1.38.1",
17
+ "e2e-utils": "0.0.1"
18
+ },
19
+ "dependencies": {
20
+ "@descope-ui/common": "0.1.0",
21
+ "@descope-ui/theme-globals": "0.0.20",
22
+ "@descope-ui/descope-list": "0.1.0",
23
+ "@descope-ui/descope-list-item": "0.1.0",
24
+ "@descope-ui/descope-text": "0.0.20",
25
+ "@descope-ui/descope-link": "0.1.0",
26
+ "@descope-ui/descope-badge": "0.1.0"
27
+ },
28
+ "publishConfig": {
29
+ "link-workspace-packages": false
30
+ },
31
+ "scripts": {
32
+ "test": "echo 'No tests defined' && exit 0",
33
+ "test:e2e": "echo 'No e2e tests defined' && exit 0"
34
+ }
35
+ }
package/project.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@descope-ui/descope-trusted-devices",
3
+ "$schema": "../../node_modules/nx/schemas/project-schema.json",
4
+ "sourceRoot": "packages/web-components/components/descope-trusted-devices/src",
5
+ "projectType": "library",
6
+ "targets": {
7
+ "version": {
8
+ "executor": "@jscutlery/semver:version",
9
+ "options": {
10
+ "trackDeps": true,
11
+ "push": false,
12
+ "preset": "conventional"
13
+ }
14
+ }
15
+ },
16
+ "tags": []
17
+ }
@@ -0,0 +1,395 @@
1
+ import { compose } from '@descope-ui/common/utils';
2
+ import {
3
+ getComponentName,
4
+ injectStyle,
5
+ } from '@descope-ui/common/components-helpers';
6
+ import {
7
+ createStyleMixin,
8
+ draggableMixin,
9
+ componentNameValidationMixin,
10
+ createDynamicDataMixin,
11
+ } from '@descope-ui/common/components-mixins';
12
+ import { createBaseClass } from '@descope-ui/common/base-classes';
13
+ import { BadgeClass } from '@descope-ui/descope-badge/class';
14
+ import { ListClass } from '@descope-ui/descope-list/class';
15
+ import { ListItemClass } from '@descope-ui/descope-list-item/class';
16
+ import { getDeviceIcon, parseDate, sortFn } from './helpers';
17
+
18
+ export const componentName = getComponentName('trusted-devices');
19
+
20
+ const itemRenderer = (
21
+ { id, name, lastLoginDate, deviceType, isCurrent },
22
+ _,
23
+ ref,
24
+ ) => {
25
+ const itemClassName = isCurrent ? 'class="current-device"' : '';
26
+
27
+ const { iconSrc, iconSrcDark } = getDeviceIcon(deviceType);
28
+
29
+ const loginLabel = ref.lastLoginLabel ? `${ref.lastLoginLabel} ` : '';
30
+
31
+ const loginDate = parseDate(lastLoginDate, ref.format);
32
+
33
+ const badge = isCurrent
34
+ ? `<descope-badge
35
+ bordered="true"
36
+ size="xs"
37
+ mode="primary"
38
+ st-host-direction="${ref.direction}"
39
+ >
40
+ ${ref.currentDeviceLabel}
41
+ </descope-badge>`
42
+ : '';
43
+
44
+ const removeDeviceLink =
45
+ !ref.hideActions && !isCurrent
46
+ ? `<descope-link
47
+ class="remove-device"
48
+ variant="body1"
49
+ mode="primary"
50
+ data-action="remove-device"
51
+ data-device-id="${id}"
52
+ st-host-direction="${ref.direction}"
53
+ ellipsis="true"
54
+ >
55
+ ${ref.removeDeviceLabel}
56
+ </descope-link>`
57
+ : '';
58
+
59
+ const template = document.createElement('template');
60
+
61
+ template.innerHTML = `
62
+ <descope-list-item ${itemClassName}>
63
+ <div class="content">
64
+ <div class="main">
65
+ <span class="device">
66
+ <descope-icon
67
+ class="device-icon"
68
+ src="${iconSrc}"
69
+ src-dark="${iconSrcDark}">
70
+ </descope-icon>
71
+ <descope-text
72
+ class="device-name"
73
+ variant="body1"
74
+ mode="primary">
75
+ </descope-text>
76
+ </span>
77
+ <span class="panel">
78
+ ${badge}
79
+ ${removeDeviceLink}
80
+ </span>
81
+ </div>
82
+ <div class="meta">
83
+ <descope-text
84
+ variant="body2"
85
+ mode="primary"
86
+ >
87
+ ${loginLabel}
88
+ </descope-text>
89
+ <descope-text
90
+ class="login-date"
91
+ variant="body2"
92
+ mode="primary"
93
+ >
94
+ ${loginDate}
95
+ </descope-text>
96
+ </div>
97
+ </div>
98
+ </descope-list-item>
99
+ `;
100
+
101
+ // we return a template instead of returning a string so we can avoid XSS on device name
102
+ template.content.querySelector('.device-name').textContent = name;
103
+ return template;
104
+ };
105
+
106
+ const BaseClass = createBaseClass({
107
+ componentName,
108
+ baseSelector: ListClass.componentName,
109
+ });
110
+
111
+ class RawTrustedDevicesClass extends BaseClass {
112
+ constructor() {
113
+ super();
114
+
115
+ this.attachShadow({ mode: 'open' }).innerHTML = `
116
+ <descope-list>
117
+ <slot name="empty-state" slot="empty-state"></slot>
118
+ </descope-list>
119
+ `;
120
+
121
+ this.appsList = this.shadowRoot.querySelector('descope-list');
122
+
123
+ injectStyle(
124
+ `
125
+ :host {
126
+ display: inline-block;
127
+ }
128
+
129
+ .descope-list-item {
130
+ min-width: 0;
131
+ }
132
+
133
+ .current-device {
134
+ order: -1;
135
+ }
136
+
137
+ .content {
138
+ display: flex;
139
+ flex-direction: column;
140
+ width: 100%;
141
+ }
142
+
143
+ .main {
144
+ display: flex;
145
+ align-items: center;
146
+ justify-content: space-between;
147
+ }
148
+
149
+ .panel {
150
+ display: flex;
151
+ flex-shrink: 0;
152
+ max-width: 75%;
153
+ overflow: hidden;
154
+ }
155
+
156
+ .device {
157
+ display: flex;
158
+ min-width: 0;
159
+ }
160
+
161
+ .device-icon {
162
+ flex-shrink: 0;
163
+ }
164
+
165
+ .meta {
166
+ display: flex;
167
+ }
168
+
169
+ .login-date {
170
+ min-width: fit-content;
171
+ }
172
+
173
+ descope-badge {
174
+ min-width: 0;
175
+ }
176
+
177
+ descope-text {
178
+ display: flex;
179
+ align-items: center;
180
+ min-width: 0;
181
+ }
182
+ descope-text::part(text-wrapper) {
183
+ text-overflow: ellipsis;
184
+ overflow: hidden;
185
+ white-space: nowrap;
186
+ }
187
+
188
+ descope-link {
189
+ overflow: hidden;
190
+ }
191
+ descope-link.remove-device::part(wrapper) {
192
+ display: flex;
193
+ width: 100%;
194
+ }
195
+ descope-link.remove-device {
196
+ width: 100%;
197
+ }
198
+ `,
199
+ this,
200
+ );
201
+ }
202
+
203
+ init() {
204
+ super.init?.();
205
+
206
+ this.appsList.itemRenderer = itemRenderer;
207
+
208
+ this.appsList.addEventListener('click', this.onRemoveClick.bind(this));
209
+ }
210
+
211
+ onRemoveClick(e) {
212
+ if (this.readOnly) return;
213
+
214
+ const target = e.target.closest('[data-device-id]');
215
+ const deviceId = target?.getAttribute('data-device-id');
216
+
217
+ if (deviceId) {
218
+ this.dispatchEvent(
219
+ new CustomEvent('remove-device-clicked', {
220
+ bubbles: true,
221
+ detail: { id: deviceId, action: 'remove-device' },
222
+ }),
223
+ );
224
+ }
225
+ }
226
+
227
+ get readOnly() {
228
+ return this.getAttribute('readonly') === 'true';
229
+ }
230
+
231
+ get hideActions() {
232
+ return this.getAttribute('hide-actions') === 'true';
233
+ }
234
+
235
+ get format() {
236
+ return this.getAttribute('format')?.toUpperCase() || 'MM-DD-YYYY';
237
+ }
238
+
239
+ get removeDeviceLabel() {
240
+ return this.getAttribute('remove-device-label') || 'Sign out';
241
+ }
242
+
243
+ get currentDeviceLabel() {
244
+ return this.getAttribute('current-device-label') || 'Current device';
245
+ }
246
+
247
+ get lastLoginLabel() {
248
+ return this.getAttribute('last-login-label') || 'Last login:';
249
+ }
250
+
251
+ get direction() {
252
+ return this.getAttribute('st-host-direction');
253
+ }
254
+ }
255
+
256
+ const { host } = {
257
+ host: { selector: () => ':host' },
258
+ };
259
+
260
+ export const TrustedDevicesClass = compose(
261
+ createStyleMixin({
262
+ mappings: {
263
+ hostWidth: { ...host, property: 'width' },
264
+ hostMinWidth: { ...host, property: 'min-width' },
265
+ hostDirection: [
266
+ { ...host, property: 'direction' },
267
+ {
268
+ selector: () => 'descope-list-item',
269
+ property: 'direction',
270
+ },
271
+ {
272
+ selector: () => 'descope-text',
273
+ property: 'direction',
274
+ },
275
+ ],
276
+
277
+ listItemsGap: {
278
+ property: ListClass.cssVarList.gap,
279
+ },
280
+ listBackgroundColor: {
281
+ selector: () => ListClass.componentName,
282
+ property: ListClass.cssVarList.backgroundColor,
283
+ },
284
+ listBorderRadius: {
285
+ selector: () => ListClass.componentName,
286
+ property: ListClass.cssVarList.borderRadius,
287
+ },
288
+ listBorderWidth: {
289
+ selector: () => ListClass.componentName,
290
+ property: ListClass.cssVarList.borderWidth,
291
+ },
292
+ listBoxShadow: {
293
+ selector: () => ListClass.componentName,
294
+ property: ListClass.cssVarList.boxShadow,
295
+ },
296
+ listPadding: [
297
+ {
298
+ selector: () => ListClass.componentName,
299
+ property: ListClass.cssVarList.verticalPadding,
300
+ },
301
+ {
302
+ selector: () => ListClass.componentName,
303
+ property: ListClass.cssVarList.horizontalPadding,
304
+ },
305
+ ],
306
+
307
+ itemVerticalPadding: {
308
+ selector: ListItemClass.componentName,
309
+ property: ListItemClass.cssVarList.verticalPadding,
310
+ },
311
+ itemHorizontalPadding: {
312
+ selector: ListItemClass.componentName,
313
+ property: ListItemClass.cssVarList.horizontalPadding,
314
+ },
315
+ itemCursor: {
316
+ selector: ListItemClass.componentName,
317
+ property: ListItemClass.cssVarList.cursor,
318
+ },
319
+ itemOutline: {
320
+ selector: ListItemClass.componentName,
321
+ property: ListItemClass.cssVarList.outline,
322
+ },
323
+ itemBorderColor: {
324
+ selector: ListItemClass.componentName,
325
+ property: ListItemClass.cssVarList.borderColor,
326
+ },
327
+ itemBorderRadius: {
328
+ selector: ListItemClass.componentName,
329
+ property: ListItemClass.cssVarList.borderRadius,
330
+ },
331
+ itemBackgroundColor: {
332
+ selector: ListItemClass.componentName,
333
+ property: ListItemClass.cssVarList.backgroundColor,
334
+ },
335
+ itemContentGap: {
336
+ selector: () => '.content',
337
+ property: 'gap',
338
+ },
339
+
340
+ badgeBorderColor: {
341
+ selector: BadgeClass.componentName,
342
+ property: BadgeClass.cssVarList.borderColor,
343
+ },
344
+ badgeTextColor: {
345
+ selector: BadgeClass.componentName,
346
+ property: BadgeClass.cssVarList.textColor,
347
+ },
348
+ badgeBackgroundColor: {
349
+ selector: BadgeClass.componentName,
350
+ property: BadgeClass.cssVarList.backgroundColor,
351
+ },
352
+ badgeBorderRadius: {
353
+ selector: BadgeClass.componentName,
354
+ property: BadgeClass.cssVarList.borderRadius,
355
+ },
356
+
357
+ devicePanelGap: {
358
+ selector: () => '.main',
359
+ property: 'gap',
360
+ },
361
+ deviceIconGap: {
362
+ selector: () => '.device',
363
+ property: 'gap',
364
+ },
365
+ deviceIconSize: [
366
+ {
367
+ selector: () => '.device-icon',
368
+ property: 'width',
369
+ },
370
+ {
371
+ selector: () => '.device-icon',
372
+ property: 'height',
373
+ },
374
+ ],
375
+
376
+ lastLoginLabelGap: {
377
+ selector: ' .meta',
378
+ property: 'gap',
379
+ },
380
+ },
381
+ }),
382
+ draggableMixin,
383
+ createDynamicDataMixin({
384
+ itemRenderer,
385
+ sortFn,
386
+ rerenderAttrsList: [
387
+ 'remove-device-label',
388
+ 'current-device-label',
389
+ 'last-login-label',
390
+ 'format',
391
+ 'hide-actions',
392
+ ],
393
+ }),
394
+ componentNameValidationMixin,
395
+ )(RawTrustedDevicesClass);
@@ -0,0 +1,67 @@
1
+ import desktopDeviceIconLight from './icons/desktop-device-light.svg';
2
+ import mobileDeviceIconLight from './icons/mobile-device-light.svg';
3
+ import tabletDeviceIconLight from './icons/tablet-device-light.svg';
4
+ import unknownDeviceIconLight from './icons/unknown-device-light.svg';
5
+ import desktopDeviceIconDark from './icons/desktop-device-dark.svg';
6
+ import mobileDeviceIconDark from './icons/mobile-device-dark.svg';
7
+ import tabletDeviceIconDark from './icons/tablet-device-dark.svg';
8
+ import unknownDeviceIconDark from './icons/unknown-device-dark.svg';
9
+
10
+ const ensureDate = (loginDate) => {
11
+ const numVal = parseInt(loginDate, 10);
12
+ if (Number.isNaN(numVal)) return 0;
13
+ return numVal;
14
+ }
15
+
16
+ export const sortFn = (a, b) =>
17
+ ensureDate(b.lastLoginDate) - ensureDate(a.lastLoginDate);
18
+
19
+ export const parseDate = (epoch, format) => {
20
+ if (Number.isNaN(parseInt(epoch, 10))) return '';
21
+
22
+ const date = new Date(epoch);
23
+ const year = date.getFullYear();
24
+ const month = String(date.getMonth() + 1).padStart(2, '0');
25
+ const day = String(date.getDate()).padStart(2, '0');
26
+ const time = date.toLocaleTimeString('en-US', {
27
+ hour12: false,
28
+ hour: '2-digit',
29
+ minute: '2-digit',
30
+ });
31
+
32
+ const formatMap = {
33
+ 'DD-MM-YYYY': `${day}/${month}/${year}`,
34
+ 'YYYY-MM-DD': `${year}/${month}/${day}`,
35
+ 'MM-DD-YYYY': `${month}/${day}/${year}`,
36
+ };
37
+
38
+ const dateStr = formatMap[format] || formatMap['MM/DD/YYYY'];
39
+ return `${dateStr} ${time}`;
40
+ };
41
+
42
+ export const deviceIconMap = {
43
+ desktop: {
44
+ light: desktopDeviceIconLight,
45
+ dark: desktopDeviceIconDark,
46
+ },
47
+ mobile: {
48
+ light: mobileDeviceIconLight,
49
+ dark: mobileDeviceIconDark,
50
+ },
51
+ tablet: {
52
+ light: tabletDeviceIconLight,
53
+ dark: tabletDeviceIconDark,
54
+ },
55
+ unknown: {
56
+ light: unknownDeviceIconLight,
57
+ dark: unknownDeviceIconDark,
58
+ },
59
+ };
60
+
61
+ export const getDeviceIcon = (deviceType) => {
62
+ const icon = deviceIconMap[deviceType] || deviceIconMap.unknown;
63
+ return {
64
+ iconSrc: icon.light,
65
+ iconSrcDark: icon.dark,
66
+ };
67
+ };
@@ -0,0 +1,3 @@
1
+ <svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M20 18.5C21.1 18.5 21.99 17.6 21.99 16.5L22 6.5C22 5.4 21.1 4.5 20 4.5H4C2.9 4.5 2 5.4 2 6.5V16.5C2 17.6 2.9 18.5 4 18.5H0V20.5H24V18.5H20ZM4 6.5H20V16.5H4V6.5Z" fill="#F4F5F6"/>
3
+ </svg>
@@ -0,0 +1,3 @@
1
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M20 18C21.1 18 21.99 17.1 21.99 16L22 6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V16C2 17.1 2.9 18 4 18H0V20H24V18H20ZM4 6H20V16H4V6Z" fill="#636C74"/>
3
+ </svg>
@@ -0,0 +1,3 @@
1
+ <svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M16 1.5H8C6.34 1.5 5 2.84 5 4.5V20.5C5 22.16 6.34 23.5 8 23.5H16C17.66 23.5 19 22.16 19 20.5V4.5C19 2.84 17.66 1.5 16 1.5ZM17 18.5H7V4.5H17V18.5ZM14 21.5H10V20.5H14V21.5Z" fill="#F4F5F6"/>
3
+ </svg>
@@ -0,0 +1,3 @@
1
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M16 1H8C6.34 1 5 2.34 5 4V20C5 21.66 6.34 23 8 23H16C17.66 23 19 21.66 19 20V4C19 2.34 17.66 1 16 1ZM17 18H7V4H17V18ZM14 21H10V20H14V21Z" fill="#636C74"/>
3
+ </svg>
@@ -0,0 +1,10 @@
1
+ <svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <g clip-path="url(#clip0_5303_863)">
3
+ <path d="M18.5 0.5H4.5C3.12 0.5 2 1.62 2 3V22C2 23.38 3.12 24.5 4.5 24.5H18.5C19.88 24.5 21 23.38 21 22V3C21 1.62 19.88 0.5 18.5 0.5ZM11.5 23.5C10.67 23.5 10 22.83 10 22C10 21.17 10.67 20.5 11.5 20.5C12.33 20.5 13 21.17 13 22C13 22.83 12.33 23.5 11.5 23.5ZM19 19.5H4V3.5H19V19.5Z" fill="#F4F5F6"/>
4
+ </g>
5
+ <defs>
6
+ <clipPath id="clip0_5303_863">
7
+ <rect width="24" height="24" fill="white" transform="translate(0 0.5)"/>
8
+ </clipPath>
9
+ </defs>
10
+ </svg>
@@ -0,0 +1,10 @@
1
+ <svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <g clip-path="url(#clip0_5252_768)">
3
+ <path d="M18.5 0.5H4.5C3.12 0.5 2 1.62 2 3V22C2 23.38 3.12 24.5 4.5 24.5H18.5C19.88 24.5 21 23.38 21 22V3C21 1.62 19.88 0.5 18.5 0.5ZM11.5 23.5C10.67 23.5 10 22.83 10 22C10 21.17 10.67 20.5 11.5 20.5C12.33 20.5 13 21.17 13 22C13 22.83 12.33 23.5 11.5 23.5ZM19 19.5H4V3.5H19V19.5Z" fill="#636C74"/>
4
+ </g>
5
+ <defs>
6
+ <clipPath id="clip0_5252_768">
7
+ <rect width="24" height="24" fill="white" transform="translate(0 0.5)"/>
8
+ </clipPath>
9
+ </defs>
10
+ </svg>
@@ -0,0 +1,3 @@
1
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M4 6H22V4H4C2.9 4 2 4.9 2 6V17H0V20H14V17H4V6ZM23 8H17C16.45 8 16 8.45 16 9V19C16 19.55 16.45 20 17 20H23C23.55 20 24 19.55 24 19V9C24 8.45 23.55 8 23 8ZM22 17H18V10H22V17Z" fill="white"/>
3
+ </svg>
@@ -0,0 +1,3 @@
1
+ <svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M4 6.5H22V4.5H4C2.9 4.5 2 5.4 2 6.5V17.5H0V20.5H14V17.5H4V6.5ZM23 8.5H17C16.45 8.5 16 8.95 16 9.5V19.5C16 20.05 16.45 20.5 17 20.5H23C23.55 20.5 24 20.05 24 19.5V9.5C24 8.95 23.55 8.5 23 8.5ZM22 17.5H18V10.5H22V17.5Z" fill="#636C74"/>
3
+ </svg>
@@ -0,0 +1,11 @@
1
+ import '@descope-ui/descope-list';
2
+ import '@descope-ui/descope-list-item';
3
+ import '@descope-ui/descope-text';
4
+ import '@descope-ui/descope-link';
5
+ import '@descope-ui/descope-badge';
6
+
7
+ import { componentName, TrustedDevicesClass } from './TrustedDevicesClass';
8
+
9
+ customElements.define(componentName, TrustedDevicesClass);
10
+
11
+ export { TrustedDevicesClass, componentName };
package/src/theme.js ADDED
@@ -0,0 +1,42 @@
1
+ import { TrustedDevicesClass } from './component/TrustedDevicesClass';
2
+ import globals from '@descope-ui/theme-globals';
3
+
4
+ export const vars = TrustedDevicesClass.cssVarList;
5
+
6
+ const TrustedDevices = {
7
+ [vars.hostWidth]: 'auto',
8
+ [vars.hostWidth]: '300px',
9
+ [vars.hostMinWidth]: '300px',
10
+
11
+ [vars.listBackgroundColor]: 'transparent',
12
+ [vars.listBorderRadius]: '0',
13
+ [vars.listBorderWidth]: '0',
14
+ [vars.listPadding]: '0',
15
+ [vars.listBoxShadow]: 'none',
16
+ [vars.listItemsGap]: globals.spacing.lg,
17
+
18
+ [vars.itemBorderColor]: globals.colors.surface.light,
19
+ [vars.itemVerticalPadding]: globals.spacing.lg,
20
+ [vars.itemHorizontalPadding]: globals.spacing.lg,
21
+ [vars.itemBorderRadius]: globals.radius.xs,
22
+ [vars.itemOutline]: 'transparent',
23
+ [vars.itemBackgroundColor]: 'transparent',
24
+ [vars.itemCursor]: 'default',
25
+ [vars.itemContentGap]: globals.spacing.sm,
26
+
27
+ [vars.badgeBorderColor]: globals.colors.surface.light,
28
+ [vars.badgeTextColor]: globals.colors.surface.dark,
29
+ [vars.badgeBorderRadius]: globals.radius.xs,
30
+ [vars.badgeBackgroundColor]: globals.colors.surface.main,
31
+
32
+ [vars.devicePanelGap]: globals.spacing.md,
33
+ [vars.deviceIconGap]: globals.spacing.md,
34
+ [vars.deviceIconSize]: '24px',
35
+ [vars.lastLoginLabelGap]: globals.spacing.xs,
36
+
37
+ _fullWidth: {
38
+ [vars.hostWidth]: '100%',
39
+ },
40
+ };
41
+
42
+ export default TrustedDevices;
@@ -0,0 +1,148 @@
1
+ import { componentName } from '../src/component';
2
+ import {
3
+ directionControl,
4
+ fullWidthControl,
5
+ readOnlyControl,
6
+ } from '@descope-ui/common/sb-controls';
7
+
8
+ const createUTCDate = (year, month, day, index = 0) => {
9
+ const hours = (index * 2 + 8) % 24; // Start at 8AM, increment by 2 hours per index
10
+ const minutes = (index * 15) % 60; // Increment by 15 minutes per index
11
+ return Date.UTC(year, month - 1, day, hours, minutes);
12
+ };
13
+
14
+ const generateDeviceName = (index, useLongNames = false) => {
15
+ if (useLongNames) {
16
+ const longNames = [
17
+ 'MacBook Pro 16-inch 2023 M2 Max with 32GB RAM and 1TB SSD Storage - Ultra High Performance Workstation for Professional Development and Creative Work - Featuring Advanced Neural Engine, ProMotion Display Technology, and Premium Build Quality for Enterprise Applications',
18
+ 'iPhone 15 Pro Max 512GB Natural Titanium with Advanced Camera System - Flagship Mobile Device with Pro Photography Capabilities and 5G Connectivity - Enhanced with Action Button, USB-C Port, and Revolutionary A17 Pro Chip with Ray Tracing for Mobile Gaming Excellence',
19
+ 'Dell XPS 13 Plus Developer Edition with Ubuntu 22.04 LTS and Intel i7 - Premium Ultrabook Laptop Optimized for Software Development and Programming - Featuring Edge-to-Edge Display, Capacitive Touch Function Row, and Zero Lattice Keyboard for Modern Productivity',
20
+ 'Samsung Galaxy S24 Ultra 1TB Phantom Black with S Pen and 200MP Camera - Premium Android Smartphone with Advanced Productivity Features and AI Integration - Powered by Snapdragon 8 Gen 3 Processor with Galaxy AI and Enhanced Night Photography Capabilities',
21
+ 'Surface Laptop Studio 2 with Intel Core i7 and NVIDIA GeForce RTX 4060 - Convertible Windows Laptop with Touch Screen and Dedicated Graphics for Creative Professionals - Featuring Dynamic Woven Hinge, Surface Pen Support, and Studio Mode for Digital Artists',
22
+ ];
23
+ return longNames[index % longNames.length];
24
+ }
25
+ return `Device ${index + 1}`;
26
+ };
27
+
28
+ const generateDeviceData = (numberOfItems, useLongNames = false) => {
29
+ return Array.from({ length: Number(numberOfItems) }, (_, i) => {
30
+ const numItems = Number(numberOfItems);
31
+ const currentDeviceIndex = Math.min(1, numItems - 1); // Ensure current device exists
32
+ return {
33
+ id: `device-id-${i + 1}`,
34
+ name: generateDeviceName(i, useLongNames),
35
+ deviceType: ['desktop', 'mobile', 'tablet', 'unknown'][i % 4],
36
+ lastLoginDate: createUTCDate(2025, i + 1, i + 4, i),
37
+ isCurrent: i === currentDeviceIndex,
38
+ };
39
+ });
40
+ };
41
+
42
+ const Template = ({
43
+ direction,
44
+ 'full-width': fullWidth,
45
+ 'hide-actions': hideActions,
46
+ 'remove-device-label': removeButtonLabel,
47
+ 'current-device-label': currentDeviceLabel,
48
+ 'last-login-label': lastLoginLabel,
49
+ readonly,
50
+ emptyState,
51
+ format,
52
+ 'st-list-items-gap': stListItemsGap,
53
+ 'st-item-horizontal-padding': stItemHorizontalPadding,
54
+ 'st-item-vertical-padding': stItemVerticalPadding
55
+ }) => `
56
+ <descope-trusted-devices
57
+ full-width="${fullWidth || false}"
58
+ hide-actions="${hideActions || false}"
59
+ remove-device-label="${removeButtonLabel || ''}"
60
+ current-device-label="${currentDeviceLabel || ''}"
61
+ last-login-label="${lastLoginLabel || ''}"
62
+ readonly="${readonly || false}"
63
+ format="${format || ''}"
64
+ st-host-direction="${direction || ''}"
65
+ st-list-items-gap="${stListItemsGap || ''}"
66
+ st-item-horizontal-padding="${stItemHorizontalPadding || ''}"
67
+ st-item-vertical-padding="${stItemVerticalPadding || ''}"
68
+ >
69
+ <div slot="empty-state">${emptyState}</div>
70
+ </descope-trusted-devices>
71
+ `;
72
+
73
+ export default {
74
+ component: componentName,
75
+ title: 'descope-trusted-devices',
76
+ decorators: [
77
+ (story, { args }) => {
78
+ setTimeout(() => {
79
+ const comp = document.querySelector('descope-trusted-devices');
80
+ comp.data = generateDeviceData(args.numberOfItems, args.useLongNames);
81
+ });
82
+ return story();
83
+ },
84
+ (story) => {
85
+ setTimeout(() => {
86
+ const component = document.querySelector('descope-trusted-devices');
87
+ if (component) {
88
+ component.addEventListener('remove-device-clicked', (e) => {
89
+ if (window && window.__STORYBOOK_ADDONS_CHANNEL__) {
90
+ window.__STORYBOOK_ADDONS_CHANNEL__.emit(
91
+ 'storybook/actions/action-event',
92
+ {
93
+ id: 'RemoveDeviceClicked',
94
+ data: [e.detail],
95
+ options: { name: 'Remove Device Clicked' },
96
+ },
97
+ );
98
+ }
99
+ });
100
+ }
101
+ });
102
+ return story();
103
+ },
104
+ ],
105
+ argTypes: {
106
+ ...directionControl,
107
+ ...readOnlyControl,
108
+ ...fullWidthControl,
109
+ 'remove-device-label': {
110
+ control: { type: 'text' },
111
+ },
112
+ 'current-device-label': {
113
+ control: { type: 'text' },
114
+ },
115
+ 'last-login-label': {
116
+ control: { type: 'text' },
117
+ },
118
+ format: {
119
+ control: { type: 'select' },
120
+ options: ['MM-DD-YYYY', 'DD-MM-YYYY', 'YYYY-MM-DD'],
121
+ },
122
+ 'hide-actions': {
123
+ control: { type: 'boolean' },
124
+ },
125
+ useLongNames: {
126
+ control: { type: 'boolean' },
127
+ name: 'Use Long Device Names',
128
+ },
129
+ numberOfItems: {
130
+ control: { type: 'number', min: 0 },
131
+ }
132
+ },
133
+ };
134
+
135
+ export const Default = Template.bind({});
136
+
137
+ Default.args = {
138
+ numberOfItems: 5,
139
+ emptyState: 'No devices found',
140
+ format: 'MM-DD-YYYY',
141
+ 'current-device-label': 'Current device',
142
+ 'last-login-label': 'Last login:',
143
+ 'remove-device-label': 'Sign out',
144
+ useLongNames: false,
145
+ 'st-list-items-gap': '',
146
+ 'st-item-horizontal-padding': '',
147
+ 'st-item-vertical-padding': ''
148
+ };