From 53f3d1b6955e86ce20e94379fac2908d06fca00a Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Tue, 22 Oct 2024 15:30:44 +0800 Subject: [PATCH] feat(elements): render profile data --- packages/elements/index.html | 18 +++- packages/elements/package.json | 1 + .../src/account/components/logto-icon.ts | 28 ++++++ .../account/components/logto-identity-info.ts | 88 +++++++++++++++++ .../account/components/logto-profile-item.ts | 81 ++++++++++++++++ .../elements/LogtoProfileItemElement.ts | 42 ++++++++ .../elements/logto-account-center.test.ts | 95 +++++++++++++++++++ .../account/elements/logto-account-center.ts | 56 +++++++++++ .../elements/logto-social-identity.test.ts | 85 +++++++++++++++++ .../account/elements/logto-social-identity.ts | 52 ++++++++++ .../account/elements/logto-user-email.test.ts | 65 +++++++++++++ .../src/account/elements/logto-user-email.ts | 35 +++++++ .../elements/logto-user-password.test.ts | 68 +++++++++++++ .../account/elements/logto-user-password.ts | 59 ++++++++++++ .../account/elements/logto-user-phone.test.ts | 67 +++++++++++++ .../src/account/elements/logto-user-phone.ts | 38 ++++++++ .../account/elements/logto-username.test.ts | 67 +++++++++++++ .../src/account/elements/logto-username.ts | 35 +++++++ packages/elements/src/account/icons/email.svg | 3 + .../src/account/icons/fallback-avatar.svg | 13 +++ .../elements/src/account/icons/index.d.ts | 4 + .../elements/src/account/icons/password.svg | 3 + packages/elements/src/account/icons/phone.svg | 3 + .../elements/src/account/icons/username.svg | 3 + packages/elements/src/account/index.ts | 11 +++ packages/elements/src/account/react.ts | 40 +++++++- packages/elements/src/account/style.css | 27 ++++++ packages/elements/src/account/utils/format.ts | 15 +++ pnpm-lock.yaml | 26 +++-- 29 files changed, 1114 insertions(+), 14 deletions(-) create mode 100644 packages/elements/src/account/components/logto-icon.ts create mode 100644 packages/elements/src/account/components/logto-identity-info.ts create mode 100644 packages/elements/src/account/components/logto-profile-item.ts create mode 100644 packages/elements/src/account/elements/LogtoProfileItemElement.ts create mode 100644 packages/elements/src/account/elements/logto-account-center.test.ts create mode 100644 packages/elements/src/account/elements/logto-account-center.ts create mode 100644 packages/elements/src/account/elements/logto-social-identity.test.ts create mode 100644 packages/elements/src/account/elements/logto-social-identity.ts create mode 100644 packages/elements/src/account/elements/logto-user-email.test.ts create mode 100644 packages/elements/src/account/elements/logto-user-email.ts create mode 100644 packages/elements/src/account/elements/logto-user-password.test.ts create mode 100644 packages/elements/src/account/elements/logto-user-password.ts create mode 100644 packages/elements/src/account/elements/logto-user-phone.test.ts create mode 100644 packages/elements/src/account/elements/logto-user-phone.ts create mode 100644 packages/elements/src/account/elements/logto-username.test.ts create mode 100644 packages/elements/src/account/elements/logto-username.ts create mode 100644 packages/elements/src/account/icons/email.svg create mode 100644 packages/elements/src/account/icons/fallback-avatar.svg create mode 100644 packages/elements/src/account/icons/index.d.ts create mode 100644 packages/elements/src/account/icons/password.svg create mode 100644 packages/elements/src/account/icons/phone.svg create mode 100644 packages/elements/src/account/icons/username.svg create mode 100644 packages/elements/src/account/style.css create mode 100644 packages/elements/src/account/utils/format.ts diff --git a/packages/elements/index.html b/packages/elements/index.html index 1c1dc6a03..e8929d125 100644 --- a/packages/elements/index.html +++ b/packages/elements/index.html @@ -5,13 +5,23 @@ Logto elements dev page - + + - - + - Logto Account Provider + + + diff --git a/packages/elements/package.json b/packages/elements/package.json index a0e5e6804..5671502c1 100644 --- a/packages/elements/package.json +++ b/packages/elements/package.json @@ -49,6 +49,7 @@ "@lit/react": "^1.0.5", "@silverhand/essentials": "^2.9.1", "ky": "^1.2.3", + "libphonenumber-js": "^1.11.12", "lit": "^3.1.4" }, "devDependencies": { diff --git a/packages/elements/src/account/components/logto-icon.ts b/packages/elements/src/account/components/logto-icon.ts new file mode 100644 index 000000000..0471ccdac --- /dev/null +++ b/packages/elements/src/account/components/logto-icon.ts @@ -0,0 +1,28 @@ +import { css, html, LitElement } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +const tagName = 'logto-icon'; + +@customElement(tagName) +export class LogtoIcon extends LitElement { + static tagName = tagName; + + static styles = css` + ::slotted(svg) { + display: block; + width: var(--logto-icon-size, 24px); + height: var(--logto-icon-size, 24px); + } + `; + + render() { + return html``; + } +} + +declare global { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface HTMLElementTagNameMap { + [tagName]: LogtoIcon; + } +} diff --git a/packages/elements/src/account/components/logto-identity-info.ts b/packages/elements/src/account/components/logto-identity-info.ts new file mode 100644 index 000000000..1b7e16eb5 --- /dev/null +++ b/packages/elements/src/account/components/logto-identity-info.ts @@ -0,0 +1,88 @@ +import { css, html, LitElement } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; + +import fallbackAvatar from '../icons/fallback-avatar.svg'; + +const tagName = 'logto-identity-info'; + +@customElement(tagName) +export class LogtoIdentityInfo extends LitElement { + static tagName = tagName; + + static styles = css` + :host { + display: flex; + align-items: center; + gap: var(--logto-spacing-sm); + } + + .avatar { + --logto-icon-size: var(--logto-identity-info-avatar-size, 36px); + + > img { + display: block; + width: var(--logto-identity-info-avatar-size, 36px); + height: var(--logto-identity-info-avatar-size, 36px); + border-radius: var(--logto-identity-info-avatar-shape, var(--logto-shape-corner-md)); + } + } + + .info { + flex: 1; + flex-direction: column; + + .name { + font: var(--logto-identity-info-name-font-size, var(--logto-font-body-md)); + color: var( + --logto-identity-info-name-color, + var(--logto-color---logto-color-typeface-primary) + ); + } + + .email { + font: var(--logto-identity-info-email-font, var(--logto-font-body-sm)); + color: var( + --logto-identity-info-email-color, + var(--logto-color---logto-color-typeface-primary) + ); + } + } + `; + + @property({ type: String }) + avatar = ''; + + @property({ type: String }) + name = ''; + + @property({ type: String }) + email = ''; + + @state() + failedToLoadAvatar = false; + + render() { + return html` +
+ ${this.avatar && !this.failedToLoadAvatar + ? html`user avatar` + : html`${fallbackAvatar}`} +
+
+
${this.name}
+ +
+ `; + } + + private handleAvatarError() { + this.failedToLoadAvatar = true; + } +} + +declare global { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface HTMLElementTagNameMap { + [tagName]: LogtoIdentityInfo; + } +} diff --git a/packages/elements/src/account/components/logto-profile-item.ts b/packages/elements/src/account/components/logto-profile-item.ts new file mode 100644 index 000000000..3737a2e24 --- /dev/null +++ b/packages/elements/src/account/components/logto-profile-item.ts @@ -0,0 +1,81 @@ +import { css, html, LitElement } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +const tagName = 'logto-profile-item'; + +@customElement(tagName) +export class LogtoProfileItem extends LitElement { + static tagName = tagName; + + static styles = css` + :host { + display: flex; + align-items: center; + background-color: var(--logto-profile-item-container-color, var(--logto-color-background)); + border-radius: var(--logto-profile-item-container-shape, var(--logto-shape-corner-lg)); + padding-inline-start: var( + --logto-profile-item-container-leading-space, + var(--logto-spacing-xl) + ); + padding-inline-end: var( + --logto-profile-item-container-trailing-space, + var(--logto-spacing-xl) + ); + height: var(--logto-profile-item-height, 64px); + } + + .label { + flex: 1; + display: flex; + align-items: center; + gap: var(--logto-profile-item-label-gap, var(--logto-spacing-sm)); + } + + ::slotted([slot='label-icon']) { + color: var(--logto-profile-item-label-icon-color, var(--logto-color-typeface-secondary)); + + --logto-icon-size: var(--logto-profile-item-label-icon-size, 24px); + } + + ::slotted([slot='label-text']) { + font: var(--logto-profile-item-label-font, var(--logto-font-label-md)); + color: var(--logto-profile-item-label-color, var(--logto-color-typeface-primary)); + } + + ::slotted([slot='content']), + slot[name='content'] { + display: flex; + flex: 2; + font: var(--logto-profile-item-value-font, var(--logto-font-body-md)); + color: var(--logto-profile-item--color, var(--logto-color-typeface-primary)); + } + + .no-value { + font: var(--logto-profile-item-no-value-font, var(--logto-font-body-md)); + color: var(--logto-profile-item-no-value-color, var(--logto-color-typeface-secondary)); + } + + ::slotted([slot='actions']) { + display: flex; + flex: 1; + } + `; + + render() { + return html` +
+ + +
+ Not set + + `; + } +} + +declare global { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface HTMLElementTagNameMap { + [tagName]: LogtoProfileItem; + } +} diff --git a/packages/elements/src/account/elements/LogtoProfileItemElement.ts b/packages/elements/src/account/elements/LogtoProfileItemElement.ts new file mode 100644 index 000000000..0f427b608 --- /dev/null +++ b/packages/elements/src/account/elements/LogtoProfileItemElement.ts @@ -0,0 +1,42 @@ +import { consume } from '@lit/context'; +import { html, LitElement, nothing, type TemplateResult } from 'lit'; + +import { + logtoAccountContext, + type LogtoAccountContextType, +} from '../providers/logto-account-provider.js'; + +export abstract class LogtoProfileItemElement extends LitElement { + @consume({ context: logtoAccountContext, subscribe: true }) + protected readonly accountContext?: LogtoAccountContextType; + + constructor( + private readonly icon: string, + private readonly label: string + ) { + super(); + } + + render() { + if (!this.accountContext) { + return html`Unable to retrieve account context.`; + } + + if (!this.isAccessible()) { + // Render nothing if the user lacks permission to view phone number information + return nothing; + } + + return html` + + ${this.icon} +
${this.label}
+ ${this.renderContent()} +
+ `; + } + + protected abstract isAccessible(): boolean; + + protected abstract renderContent(): TemplateResult; +} diff --git a/packages/elements/src/account/elements/logto-account-center.test.ts b/packages/elements/src/account/elements/logto-account-center.test.ts new file mode 100644 index 000000000..25983ed71 --- /dev/null +++ b/packages/elements/src/account/elements/logto-account-center.test.ts @@ -0,0 +1,95 @@ +import { assert, fixture, html, waitUntil } from '@open-wc/testing'; + +import { createMockAccountApi } from '../__mocks__/account-api.js'; +import { type LogtoAccountProvider } from '../providers/logto-account-provider.js'; + +import { LogtoAccountCenter } from './logto-account-center.js'; + +suite('logto-account-center', () => { + test('is defined', () => { + const element = document.createElement(LogtoAccountCenter.tagName); + assert.instanceOf(element, LogtoAccountCenter); + }); + + test('should render error message when account context is not available', async () => { + const element = await fixture( + html`` + ); + await element.updateComplete; + + assert.equal(element.shadowRoot?.textContent?.trim(), 'Unable to retrieve account context.'); + }); + + test('should render components correctly based on user info', async () => { + const mockAccountApi = createMockAccountApi({ + fetchUserProfile: async () => ({ + username: 'testuser', + primaryEmail: 'test@example.com', + primaryPhone: '1234567890', + hasPassword: true, + identities: { + google: { + userId: 'google-123', + details: { name: 'John Doe', email: 'john@example.com' }, + }, + facebook: { + userId: 'facebook-123', + details: { name: 'Jane Doe', email: 'jane@example.com' }, + }, + }, + }), + }); + + const provider = await fixture( + html` + + ` + ); + + await provider.updateComplete; + + const accountCenter = provider.querySelector(LogtoAccountCenter.tagName); + await accountCenter?.updateComplete; + + await waitUntil(() => { + const shadowRoot = accountCenter?.shadowRoot; + return ( + shadowRoot?.querySelector('logto-username') && + shadowRoot.querySelector('logto-user-email') && + shadowRoot.querySelector('logto-user-phone') && + shadowRoot.querySelector('logto-user-password') && + shadowRoot.querySelectorAll('logto-social-identity').length === 2 + ); + }, 'Unable to render all expected components'); + }); + + test('should only render components for existing user info', async () => { + const mockAccountApi = createMockAccountApi({ + fetchUserProfile: async () => ({ + username: 'testuser', + primaryEmail: undefined, + primaryPhone: undefined, + hasPassword: undefined, + identities: {}, + }), + }); + + const provider = await fixture( + html` + + ` + ); + + await provider.updateComplete; + + const accountCenter = provider.querySelector(LogtoAccountCenter.tagName); + await accountCenter?.updateComplete; + + const shadowRoot = accountCenter?.shadowRoot; + assert.exists(shadowRoot?.querySelector('logto-username')); + assert.notExists(shadowRoot.querySelector('logto-user-email')); + assert.notExists(shadowRoot.querySelector('logto-user-phone')); + assert.notExists(shadowRoot.querySelector('logto-user-password')); + assert.notExists(shadowRoot.querySelector('logto-social-identity')); + }); +}); diff --git a/packages/elements/src/account/elements/logto-account-center.ts b/packages/elements/src/account/elements/logto-account-center.ts new file mode 100644 index 000000000..56646b543 --- /dev/null +++ b/packages/elements/src/account/elements/logto-account-center.ts @@ -0,0 +1,56 @@ +import { consume } from '@lit/context'; +import { css, html, LitElement } from 'lit'; +import { customElement } from 'lit/decorators.js'; +import { when } from 'lit/directives/when.js'; + +import { + logtoAccountContext, + type LogtoAccountContextType, +} from '../providers/logto-account-provider.js'; + +const tagName = 'logto-account-center'; + +@customElement(tagName) +export class LogtoAccountCenter extends LitElement { + static tagName = tagName; + + static styles = css` + :host { + display: flex; + flex-direction: column; + gap: var(--logto-account-center-item-spacing, var(--logto-spacing-md)); + } + `; + + @consume({ context: logtoAccountContext, subscribe: true }) + private readonly accountContext?: LogtoAccountContextType; + + render() { + if (!this.accountContext) { + return html`Unable to retrieve account context.`; + } + + const { + userProfile: { username, primaryEmail, primaryPhone, hasPassword, identities }, + } = this.accountContext; + + return html` + ${when(username, () => html``)} + ${when(primaryEmail, () => html``)} + ${when(primaryPhone, () => html``)} + ${when(hasPassword, () => html``)} + ${when(identities, (_identities) => + Object.entries(_identities).map( + ([target]) => html`` + ) + )} + `; + } +} + +declare global { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface HTMLElementTagNameMap { + [tagName]: LogtoAccountCenter; + } +} diff --git a/packages/elements/src/account/elements/logto-social-identity.test.ts b/packages/elements/src/account/elements/logto-social-identity.test.ts new file mode 100644 index 000000000..89517afd5 --- /dev/null +++ b/packages/elements/src/account/elements/logto-social-identity.test.ts @@ -0,0 +1,85 @@ +import { assert, fixture, html, waitUntil } from '@open-wc/testing'; + +import { createMockAccountApi } from '../__mocks__/account-api.js'; +import { LogtoIdentityInfo } from '../components/logto-identity-info.js'; +import { type LogtoAccountProvider } from '../providers/logto-account-provider.js'; + +import { LogtoSocialIdentity } from './logto-social-identity.js'; + +suite('logto-social-identity', () => { + test('is defined', () => { + const element = document.createElement(LogtoSocialIdentity.tagName); + assert.instanceOf(element, LogtoSocialIdentity); + }); + + test('should render error message when account context is not available', async () => { + const element = await fixture( + html`` + ); + await element.updateComplete; + + assert.equal(element.shadowRoot?.textContent, 'Unable to retrieve account context.'); + }); + + test('should render correctly when user has permission to view social identity information', async () => { + const mockAccountApi = createMockAccountApi({ + fetchUserProfile: async () => ({ + identities: { + github: { + userId: '123', + details: { + name: 'John Doe', + email: 'john@example.com', + }, + }, + }, + }), + }); + + const provider = await fixture( + html` + + ` + ); + + await provider.updateComplete; + + const logtoSocialIdentity = provider.querySelector( + LogtoSocialIdentity.tagName + ); + + const identityInfo = logtoSocialIdentity?.shadowRoot?.querySelector( + LogtoIdentityInfo.tagName + ); + + await waitUntil( + () => + identityInfo?.shadowRoot?.querySelector('div[class=name]')?.textContent === 'John Doe' && + identityInfo.shadowRoot.querySelector('div[class=email]')?.textContent === + 'john@example.com', + 'Unable to get social identity information from account context' + ); + }); + + test('should render nothing if the user lacks permission to view social identity information', async () => { + const mockAccountApi = createMockAccountApi({ + fetchUserProfile: async () => ({ + identities: undefined, + }), + }); + + const provider = await fixture( + html` + + ` + ); + + await provider.updateComplete; + const logtoSocialIdentity = provider.querySelector( + LogtoSocialIdentity.tagName + ); + + await logtoSocialIdentity?.updateComplete; + assert.equal(logtoSocialIdentity?.shadowRoot?.children.length, 0); + }); +}); diff --git a/packages/elements/src/account/elements/logto-social-identity.ts b/packages/elements/src/account/elements/logto-social-identity.ts new file mode 100644 index 000000000..034910522 --- /dev/null +++ b/packages/elements/src/account/elements/logto-social-identity.ts @@ -0,0 +1,52 @@ +import { html, type TemplateResult } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { when } from 'lit/directives/when.js'; + +import usernameIcon from '../icons/username.svg'; + +import { LogtoProfileItemElement } from './LogtoProfileItemElement.js'; + +const tagName = 'logto-social-identity'; + +@customElement(tagName) +export class LogtoSocialIdentity extends LogtoProfileItemElement { + static tagName = tagName; + + @property({ type: String }) + target = ''; + + constructor() { + // Todo: @xiaoyijun replace with correct label text when related connector API is ready + super(usernameIcon, 'Social'); + } + + protected isAccessible(): boolean { + return this.accountContext?.userProfile.identities !== undefined; + } + + protected renderContent(): TemplateResult { + const { identities } = this.accountContext?.userProfile ?? {}; + + const identity = identities?.[this.target]; + // Todo: @xiaoyijun add identifier fallback logic + const { avatar = '', name = '', email = '' } = identity?.details ?? {}; + + return when( + identity, + () => + html`` + ); + } +} + +declare global { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface HTMLElementTagNameMap { + [tagName]: LogtoSocialIdentity; + } +} diff --git a/packages/elements/src/account/elements/logto-user-email.test.ts b/packages/elements/src/account/elements/logto-user-email.test.ts new file mode 100644 index 000000000..034c2c885 --- /dev/null +++ b/packages/elements/src/account/elements/logto-user-email.test.ts @@ -0,0 +1,65 @@ +import { assert, fixture, html, waitUntil } from '@open-wc/testing'; + +import { createMockAccountApi } from '../__mocks__/account-api.js'; +import { type LogtoAccountProvider } from '../providers/logto-account-provider.js'; + +import { LogtoUserEmail } from './logto-user-email.js'; + +suite('logto-user-email', () => { + test('is defined', () => { + const element = document.createElement(LogtoUserEmail.tagName); + assert.instanceOf(element, LogtoUserEmail); + }); + + test('should render error message when account context is not available', async () => { + const element = await fixture(html``); + await element.updateComplete; + + assert.equal(element.shadowRoot?.textContent, 'Unable to retrieve account context.'); + }); + + test('should render email if the user has permission to view email information', async () => { + const mockAccountApi = createMockAccountApi({ + fetchUserProfile: async () => ({ + primaryEmail: 'user@example.com', + }), + }); + + const provider = await fixture( + html` + + ` + ); + + await provider.updateComplete; + + const logtoUserEmail = provider.querySelector(LogtoUserEmail.tagName); + + await waitUntil( + () => + logtoUserEmail?.shadowRoot?.querySelector('div[slot="content"]')?.textContent === + 'user@example.com', + 'Unable to get email from account context' + ); + }); + + test('should render nothing if the user lacks permission to view email information', async () => { + const mockAccountApi = createMockAccountApi({ + fetchUserProfile: async () => ({ + primaryEmail: undefined, + }), + }); + + const provider = await fixture( + html` + + ` + ); + + await provider.updateComplete; + const logtoUserEmail = provider.querySelector(LogtoUserEmail.tagName); + + await logtoUserEmail?.updateComplete; + assert.equal(logtoUserEmail?.shadowRoot?.children.length, 0); + }); +}); diff --git a/packages/elements/src/account/elements/logto-user-email.ts b/packages/elements/src/account/elements/logto-user-email.ts new file mode 100644 index 000000000..d16fd5606 --- /dev/null +++ b/packages/elements/src/account/elements/logto-user-email.ts @@ -0,0 +1,35 @@ +import { html, type TemplateResult } from 'lit'; +import { customElement } from 'lit/decorators.js'; +import { when } from 'lit/directives/when.js'; + +import emailIcon from '../icons/email.svg'; + +import { LogtoProfileItemElement } from './LogtoProfileItemElement.js'; + +const tagName = 'logto-user-email'; + +@customElement(tagName) +export class LogtoUserEmail extends LogtoProfileItemElement { + static tagName = tagName; + + constructor() { + super(emailIcon, 'Email address'); + } + + protected isAccessible(): boolean { + return this.accountContext?.userProfile.primaryEmail !== undefined; + } + + protected renderContent(): TemplateResult { + const { primaryEmail } = this.accountContext?.userProfile ?? {}; + + return when(primaryEmail, (email) => html`
${email}
`); + } +} + +declare global { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface HTMLElementTagNameMap { + [tagName]: LogtoUserEmail; + } +} diff --git a/packages/elements/src/account/elements/logto-user-password.test.ts b/packages/elements/src/account/elements/logto-user-password.test.ts new file mode 100644 index 000000000..0fd05bc2f --- /dev/null +++ b/packages/elements/src/account/elements/logto-user-password.test.ts @@ -0,0 +1,68 @@ +import { assert, fixture, html, waitUntil } from '@open-wc/testing'; + +import { createMockAccountApi } from '../__mocks__/account-api.js'; +import { type LogtoAccountProvider } from '../providers/logto-account-provider.js'; + +import { LogtoUserPassword } from './logto-user-password.js'; + +suite('logto-user-password', () => { + test('is defined', () => { + const element = document.createElement(LogtoUserPassword.tagName); + assert.instanceOf(element, LogtoUserPassword); + }); + + test('should render error message when account context is not available', async () => { + const element = await fixture( + html`` + ); + await element.updateComplete; + + assert.equal(element.shadowRoot?.textContent, 'Unable to retrieve account context.'); + }); + + test('should render configured status when user has password', async () => { + const mockAccountApi = createMockAccountApi({ + fetchUserProfile: async () => ({ + hasPassword: true, + }), + }); + + const provider = await fixture( + html` + + ` + ); + + await provider.updateComplete; + + const logtoUserPassword = provider.querySelector(LogtoUserPassword.tagName); + + await waitUntil( + () => + logtoUserPassword?.shadowRoot + ?.querySelector('.status') + ?.textContent?.includes('Configured'), + 'Unable to get password status from account context' + ); + }); + + test('should not render status when user has no password', async () => { + const mockAccountApi = createMockAccountApi({ + fetchUserProfile: async () => ({ + hasPassword: false, + }), + }); + + const provider = await fixture( + html` + + ` + ); + + await provider.updateComplete; + const logtoUserPassword = provider.querySelector(LogtoUserPassword.tagName); + + await logtoUserPassword?.updateComplete; + assert.isNull(logtoUserPassword?.shadowRoot?.querySelector('.status')); + }); +}); diff --git a/packages/elements/src/account/elements/logto-user-password.ts b/packages/elements/src/account/elements/logto-user-password.ts new file mode 100644 index 000000000..c81a008b6 --- /dev/null +++ b/packages/elements/src/account/elements/logto-user-password.ts @@ -0,0 +1,59 @@ +import { css, html, type TemplateResult } from 'lit'; +import { customElement } from 'lit/decorators.js'; +import { when } from 'lit/directives/when.js'; + +import passwordIcon from '../icons/password.svg'; + +import { LogtoProfileItemElement } from './LogtoProfileItemElement.js'; + +const tagName = 'logto-user-password'; + +@customElement(tagName) +export class LogtoUserPassword extends LogtoProfileItemElement { + static tagName = tagName; + + static styles = css` + .status { + display: flex; + align-items: center; + gap: var(--logto-spacing-sm); + } + + .status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--logto-color-container-on-success); + } + `; + + constructor() { + super(passwordIcon, 'Password'); + } + + protected isAccessible(): boolean { + // The password is always accessible + return true; + } + + protected renderContent(): TemplateResult { + const { hasPassword } = this.accountContext?.userProfile ?? {}; + return when( + hasPassword, + () => + html`
+
+ + Configured +
+
` + ); + } +} + +declare global { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface HTMLElementTagNameMap { + [tagName]: LogtoUserPassword; + } +} diff --git a/packages/elements/src/account/elements/logto-user-phone.test.ts b/packages/elements/src/account/elements/logto-user-phone.test.ts new file mode 100644 index 000000000..3a7900b5c --- /dev/null +++ b/packages/elements/src/account/elements/logto-user-phone.test.ts @@ -0,0 +1,67 @@ +import { assert, fixture, html, waitUntil } from '@open-wc/testing'; + +import { createMockAccountApi } from '../__mocks__/account-api.js'; +import { type LogtoAccountProvider } from '../providers/logto-account-provider.js'; + +import { LogtoUserPhone } from './logto-user-phone.js'; + +const fakeLogtoEndpoint = 'https://logto.dev'; + +suite('logto-user-phone', () => { + test('is defined', () => { + const element = document.createElement(LogtoUserPhone.tagName); + assert.instanceOf(element, LogtoUserPhone); + }); + + test('should render error message when account context is not available', async () => { + const element = await fixture(html``); + await element.updateComplete; + + assert.equal(element.shadowRoot?.textContent, 'Unable to retrieve account context.'); + }); + + test('should render phone number if the user has permission to view phone information', async () => { + const mockAccountApi = createMockAccountApi({ + fetchUserProfile: async () => ({ + primaryPhone: '12025550179', + }), + }); + + const provider = await fixture( + html` + + ` + ); + + await provider.updateComplete; + + const logtoUserPhone = provider.querySelector(LogtoUserPhone.tagName); + + await waitUntil( + () => + logtoUserPhone?.shadowRoot?.querySelector('div[slot="content"]')?.textContent === + '+1 202 555 0179', + 'Unable to get phone number from account context' + ); + }); + + test('should render nothing if the user lacks permission to view phone information', async () => { + const mockAccountApi = createMockAccountApi({ + fetchUserProfile: async () => ({ + primaryPhone: undefined, + }), + }); + + const provider = await fixture( + html` + + ` + ); + + await provider.updateComplete; + const logtoUserPhone = provider.querySelector(LogtoUserPhone.tagName); + + await logtoUserPhone?.updateComplete; + assert.equal(logtoUserPhone?.shadowRoot?.children.length, 0); + }); +}); diff --git a/packages/elements/src/account/elements/logto-user-phone.ts b/packages/elements/src/account/elements/logto-user-phone.ts new file mode 100644 index 000000000..4887eae9a --- /dev/null +++ b/packages/elements/src/account/elements/logto-user-phone.ts @@ -0,0 +1,38 @@ +import { html, type TemplateResult } from 'lit'; +import { customElement } from 'lit/decorators.js'; +import { when } from 'lit/directives/when.js'; + +import phoneIcon from '../icons/phone.svg'; +import { formatToInternationalPhoneNumber } from '../utils/format.js'; + +import { LogtoProfileItemElement } from './LogtoProfileItemElement.js'; + +const tagName = 'logto-user-phone'; + +@customElement(tagName) +export class LogtoUserPhone extends LogtoProfileItemElement { + static tagName = tagName; + + constructor() { + super(phoneIcon, 'Phone number'); + } + + protected isAccessible(): boolean { + return this.accountContext?.userProfile.primaryPhone !== undefined; + } + + protected renderContent(): TemplateResult { + const { primaryPhone } = this.accountContext?.userProfile ?? {}; + return when( + primaryPhone, + (phone) => html`
${formatToInternationalPhoneNumber(phone)}
` + ); + } +} + +declare global { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface HTMLElementTagNameMap { + [tagName]: LogtoUserPhone; + } +} diff --git a/packages/elements/src/account/elements/logto-username.test.ts b/packages/elements/src/account/elements/logto-username.test.ts new file mode 100644 index 000000000..961474e93 --- /dev/null +++ b/packages/elements/src/account/elements/logto-username.test.ts @@ -0,0 +1,67 @@ +import { assert, fixture, html, waitUntil } from '@open-wc/testing'; + +import { createMockAccountApi } from '../__mocks__/account-api.js'; +import { type LogtoAccountProvider } from '../providers/logto-account-provider.js'; + +import { LogtoUsername } from './logto-username.js'; + +const fakeLogtoEndpoint = 'https://logto.dev'; + +suite('logto-username', () => { + test('is defined', () => { + const element = document.createElement(LogtoUsername.tagName); + assert.instanceOf(element, LogtoUsername); + }); + + test('should render error message when account context is not available', async () => { + const element = await fixture(html``); + await element.updateComplete; + + assert.equal(element.shadowRoot?.textContent, 'Unable to retrieve account context.'); + }); + + test('should render username if the user has permission to view username information', async () => { + const mockAccountApi = createMockAccountApi({ + fetchUserProfile: async () => ({ + username: 'test_username', + }), + }); + + const provider = await fixture( + html` + + ` + ); + + await provider.updateComplete; + + const logtoUsername = provider.querySelector(LogtoUsername.tagName); + + await waitUntil( + () => + logtoUsername?.shadowRoot?.querySelector('div[slot="content"]')?.textContent === + 'test_username', + 'Unable to get username from account context' + ); + }); + + test('should render nothing if the user lacks permission to view username information', async () => { + const mockAccountApi = createMockAccountApi({ + fetchUserProfile: async () => ({ + username: undefined, + }), + }); + + const provider = await fixture( + html` + + ` + ); + + await provider.updateComplete; + const logtoUsername = provider.querySelector(LogtoUsername.tagName); + + await logtoUsername?.updateComplete; + assert.equal(logtoUsername?.shadowRoot?.children.length, 0); + }); +}); diff --git a/packages/elements/src/account/elements/logto-username.ts b/packages/elements/src/account/elements/logto-username.ts new file mode 100644 index 000000000..99cb77495 --- /dev/null +++ b/packages/elements/src/account/elements/logto-username.ts @@ -0,0 +1,35 @@ +import { html, type TemplateResult } from 'lit'; +import { customElement } from 'lit/decorators.js'; +import { when } from 'lit/directives/when.js'; + +import usernameIcon from '../icons/username.svg'; + +import { LogtoProfileItemElement } from './LogtoProfileItemElement.js'; + +const tagName = 'logto-username'; + +@customElement(tagName) +export class LogtoUsername extends LogtoProfileItemElement { + static tagName = tagName; + + constructor() { + super(usernameIcon, 'Username'); + } + + protected isAccessible(): boolean { + return this.accountContext?.userProfile.username !== undefined; + } + + protected renderContent(): TemplateResult { + const { username } = this.accountContext?.userProfile ?? {}; + + return when(username, (_username) => html`
${_username}
`); + } +} + +declare global { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface HTMLElementTagNameMap { + [tagName]: LogtoUsername; + } +} diff --git a/packages/elements/src/account/icons/email.svg b/packages/elements/src/account/icons/email.svg new file mode 100644 index 000000000..60b2738fb --- /dev/null +++ b/packages/elements/src/account/icons/email.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/elements/src/account/icons/fallback-avatar.svg b/packages/elements/src/account/icons/fallback-avatar.svg new file mode 100644 index 000000000..69018863c --- /dev/null +++ b/packages/elements/src/account/icons/fallback-avatar.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/packages/elements/src/account/icons/index.d.ts b/packages/elements/src/account/icons/index.d.ts new file mode 100644 index 000000000..934842437 --- /dev/null +++ b/packages/elements/src/account/icons/index.d.ts @@ -0,0 +1,4 @@ +declare module '*.svg' { + const value: string; + export default value; +} diff --git a/packages/elements/src/account/icons/password.svg b/packages/elements/src/account/icons/password.svg new file mode 100644 index 000000000..355ffafa9 --- /dev/null +++ b/packages/elements/src/account/icons/password.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/elements/src/account/icons/phone.svg b/packages/elements/src/account/icons/phone.svg new file mode 100644 index 000000000..2a98f8f39 --- /dev/null +++ b/packages/elements/src/account/icons/phone.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/elements/src/account/icons/username.svg b/packages/elements/src/account/icons/username.svg new file mode 100644 index 000000000..4bc64cf49 --- /dev/null +++ b/packages/elements/src/account/icons/username.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/elements/src/account/index.ts b/packages/elements/src/account/index.ts index f2f1a5732..78e16354a 100644 --- a/packages/elements/src/account/index.ts +++ b/packages/elements/src/account/index.ts @@ -1,3 +1,14 @@ export * from './api/index.js'; +export * from './components/logto-icon.js'; +export * from './components/logto-profile-item.js'; +export * from './components/logto-identity-info.js'; + export * from './providers/logto-account-provider.js'; + +export * from './elements/logto-username.js'; +export * from './elements/logto-user-email.js'; +export * from './elements/logto-user-phone.js'; +export * from './elements/logto-user-password.js'; +export * from './elements/logto-social-identity.js'; +export * from './elements/logto-account-center.js'; diff --git a/packages/elements/src/account/react.ts b/packages/elements/src/account/react.ts index 216635604..8199f9c32 100644 --- a/packages/elements/src/account/react.ts +++ b/packages/elements/src/account/react.ts @@ -1,6 +1,14 @@ import { createComponent } from '@lit/react'; -import { LogtoAccountProvider } from './index.js'; +import { LogtoUsername } from './elements/logto-username.js'; +import { + LogtoAccountProvider, + LogtoUserEmail, + LogtoUserPhone, + LogtoUserPassword, + LogtoSocialIdentity, + LogtoAccountCenter, +} from './index.js'; export * from './api/index.js'; @@ -11,5 +19,35 @@ export const createReactComponents = (react: Parameters[ elementClass: LogtoAccountProvider, react, }), + LogtoUsername: createComponent({ + tagName: LogtoUsername.tagName, + elementClass: LogtoUsername, + react, + }), + LogtoUserEmail: createComponent({ + tagName: LogtoUserEmail.tagName, + elementClass: LogtoUserEmail, + react, + }), + LogtoUserPhone: createComponent({ + tagName: LogtoUserPhone.tagName, + elementClass: LogtoUserPhone, + react, + }), + LogtoUserPassword: createComponent({ + tagName: LogtoUserPassword.tagName, + elementClass: LogtoUserPassword, + react, + }), + LogtoSocialIdentity: createComponent({ + tagName: LogtoSocialIdentity.tagName, + elementClass: LogtoSocialIdentity, + react, + }), + LogtoAccountCenter: createComponent({ + tagName: LogtoAccountCenter.tagName, + elementClass: LogtoAccountCenter, + react, + }), }; }; diff --git a/packages/elements/src/account/style.css b/packages/elements/src/account/style.css new file mode 100644 index 000000000..5538eb794 --- /dev/null +++ b/packages/elements/src/account/style.css @@ -0,0 +1,27 @@ +:root { + /* Colors */ + --logto-color-background: #ffffff; + --logto-color-typeface-primary: #191C1D; + --logto-color-typeface-secondary: #747778; + --logto-color-container-on-success: #68BE6C; + + /* Shapes */ + --logto-shape-corner-lg: 12px; + --logto-shape-corner-md: 8px; + + /* Spacing */ + --logto-spacing-xl: 20px; + --logto-spacing-lg: 16px; + --logto-spacing-md: 12px; + --logto-spacing-sm: 8px; + --logto-spacing-xs: 4px; + + /* Fonts */ + --logto-font-label-lg: 500 16px/24px 'SF Pro Text', sans-serif; + --logto-font-label-md: 500 14px/20px 'SF Pro Text', sans-serif; + --logto-font-label-sm: 500 12px/16px 'SF Pro Text', sans-serif; + + --logto-font-body-lg: 400 16px/24px 'SF Pro Text', sans-serif; + --logto-font-body-md: 400 14px/20px 'SF Pro Text', sans-serif; + --logto-font-body-sm: 400 12px/16px 'SF Pro Text', sans-serif; +} diff --git a/packages/elements/src/account/utils/format.ts b/packages/elements/src/account/utils/format.ts new file mode 100644 index 000000000..ca9f8af7b --- /dev/null +++ b/packages/elements/src/account/utils/format.ts @@ -0,0 +1,15 @@ +import { parsePhoneNumberWithError } from 'libphonenumber-js'; + +/** + * Parse phone number to readable international format. + * E.g. 16502530000 -> +1 650 253 0000 + */ +export const formatToInternationalPhoneNumber = (phone: string) => { + try { + const phoneNumber = phone.startsWith('+') ? phone : `+${phone}`; + return parsePhoneNumberWithError(phoneNumber).formatInternational(); + } catch { + // If parsing fails, return the original phone number + return phone; + } +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 529d82ba2..60a97251d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3395,6 +3395,9 @@ importers: ky: specifier: ^1.2.3 version: 1.2.3 + libphonenumber-js: + specifier: ^1.11.12 + version: 1.11.12 lit: specifier: ^3.1.4 version: 3.1.4 @@ -10445,6 +10448,9 @@ packages: libphonenumber-js@1.10.51: resolution: {integrity: sha512-vY2I+rQwrDQzoPds0JeTEpeWzbUJgqoV0O4v31PauHBb/e+1KCXKylHcDnBMgJZ9fH9mErsEbROJY3Z3JtqEmg==} + libphonenumber-js@1.11.12: + resolution: {integrity: sha512-QkJn9/D7zZ1ucvT++TQSvZuSA2xAWeUytU+DiEQwbPKLyrDpvbul2AFs1CGbRAPpSCCk47aRAb5DX5mmcayp4g==} + lighthouse-logger@1.4.2: resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==} @@ -16291,10 +16297,10 @@ snapshots: eslint-config-prettier: 9.1.0(eslint@8.57.0) eslint-config-xo: 0.44.0(eslint@8.57.0) eslint-config-xo-typescript: 4.0.0(@typescript-eslint/eslint-plugin@7.7.0(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0)(typescript@5.5.3))(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0)(typescript@5.5.3) - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1)(eslint@8.57.0) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0))(eslint@8.57.0) eslint-plugin-consistent-default-export-name: 0.0.15 eslint-plugin-eslint-comments: 3.2.0(eslint@8.57.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) eslint-plugin-n: 17.2.1(eslint@8.57.0) eslint-plugin-no-use-extend-native: 0.5.0 eslint-plugin-prettier: 5.1.3(eslint-config-prettier@9.1.0(eslint@8.57.0))(eslint@8.57.0)(prettier@3.0.0) @@ -19465,13 +19471,13 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1)(eslint@8.57.0): + eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0))(eslint@8.57.0): dependencies: debug: 4.3.5 enhanced-resolve: 5.16.0 eslint: 8.57.0 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) fast-glob: 3.3.2 get-tsconfig: 4.7.3 is-core-module: 2.13.1 @@ -19482,14 +19488,14 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.8.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0): + eslint-module-utils@2.8.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0): dependencies: debug: 3.2.7(supports-color@5.5.0) optionalDependencies: '@typescript-eslint/parser': 7.7.0(eslint@8.57.0)(typescript@5.5.3) eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1)(eslint@8.57.0) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0))(eslint@8.57.0) transitivePeerDependencies: - supports-color @@ -19511,7 +19517,7 @@ snapshots: eslint: 8.57.0 ignore: 5.3.1 - eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0): + eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0): dependencies: array-includes: 3.1.8 array.prototype.findlastindex: 1.2.5 @@ -19521,7 +19527,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.7.0(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) hasown: 2.0.2 is-core-module: 2.13.1 is-glob: 4.0.3 @@ -21806,6 +21812,8 @@ snapshots: libphonenumber-js@1.10.51: {} + libphonenumber-js@1.11.12: {} + lighthouse-logger@1.4.2: dependencies: debug: 2.6.9