0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(elements): render profile data

This commit is contained in:
Xiao Yijun 2024-10-22 15:30:44 +08:00
parent 27e44c287f
commit 53f3d1b695
No known key found for this signature in database
GPG key ID: 6F648FC1262DB420
29 changed files with 1114 additions and 14 deletions

View file

@ -5,13 +5,23 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Logto elements dev page</title>
<script type="module" src="./src/account/index.ts"></script>
<link rel="stylesheet" href="src/account/style.css">
<script type="module" src="src/account/index.ts"></script>
</head>
<body style="background: #ecebf5;">
<body style="background-color: #ecebf5;">
<logto-account-provider>
Logto Account Provider
<logto-account-center></logto-account-center>
</logto-account-provider>
</body>
<script type="module">
import { LogtoAccountApi } from './src/account/api/index.ts';
const ACCESS_TOKEN = 'TEST_ACCESS_TOKEN';
const provider = document.querySelector('logto-account-provider');
const api = new LogtoAccountApi('http://localhost:3001', () => ACCESS_TOKEN);
provider.accountApi = api;
</script>
</html>

View file

@ -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": {

View file

@ -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`<slot></slot>`;
}
}
declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface HTMLElementTagNameMap {
[tagName]: LogtoIcon;
}
}

View file

@ -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`
<div class="avatar">
${this.avatar && !this.failedToLoadAvatar
? html`<img src="${this.avatar}" alt="user avatar" @error=${this.handleAvatarError} />`
: html`<logto-icon>${fallbackAvatar}</logto-icon>`}
</div>
<div class="info">
<div class="name">${this.name}</div>
<div class="email">${this.email}</div>
</div>
`;
}
private handleAvatarError() {
this.failedToLoadAvatar = true;
}
}
declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface HTMLElementTagNameMap {
[tagName]: LogtoIdentityInfo;
}
}

View file

@ -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`
<div class="label">
<slot name="label-icon"></slot>
<slot name="label-text"></slot>
</div>
<slot name="content"><span class="no-value">Not set</span></slot>
<slot name="actions"></slot>
`;
}
}
declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface HTMLElementTagNameMap {
[tagName]: LogtoProfileItem;
}
}

View file

@ -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`<span>Unable to retrieve account context.</span>`;
}
if (!this.isAccessible()) {
// Render nothing if the user lacks permission to view phone number information
return nothing;
}
return html`
<logto-profile-item>
<logto-icon slot="label-icon">${this.icon}</logto-icon>
<div slot="label-text">${this.label}</div>
${this.renderContent()}
</logto-profile-item>
`;
}
protected abstract isAccessible(): boolean;
protected abstract renderContent(): TemplateResult;
}

View file

@ -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<LogtoAccountCenter>(
html`<logto-account-center></logto-account-center>`
);
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<LogtoAccountProvider>(
html`<logto-account-provider .accountApi=${mockAccountApi}>
<logto-account-center></logto-account-center>
</logto-account-provider>`
);
await provider.updateComplete;
const accountCenter = provider.querySelector<LogtoAccountCenter>(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<LogtoAccountProvider>(
html`<logto-account-provider .accountApi=${mockAccountApi}>
<logto-account-center></logto-account-center>
</logto-account-provider>`
);
await provider.updateComplete;
const accountCenter = provider.querySelector<LogtoAccountCenter>(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'));
});
});

View file

@ -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`<span>Unable to retrieve account context.</span>`;
}
const {
userProfile: { username, primaryEmail, primaryPhone, hasPassword, identities },
} = this.accountContext;
return html`
${when(username, () => html`<logto-username></logto-username>`)}
${when(primaryEmail, () => html`<logto-user-email></logto-user-email>`)}
${when(primaryPhone, () => html`<logto-user-phone></logto-user-phone>`)}
${when(hasPassword, () => html`<logto-user-password></logto-user-password>`)}
${when(identities, (_identities) =>
Object.entries(_identities).map(
([target]) => html`<logto-social-identity target=${target}></logto-social-identity>`
)
)}
`;
}
}
declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface HTMLElementTagNameMap {
[tagName]: LogtoAccountCenter;
}
}

View file

@ -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<LogtoSocialIdentity>(
html`<logto-social-identity></logto-social-identity>`
);
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<LogtoAccountProvider>(
html`<logto-account-provider .accountApi=${mockAccountApi}>
<logto-social-identity target="github"></logto-social-identity>
</logto-account-provider>`
);
await provider.updateComplete;
const logtoSocialIdentity = provider.querySelector<LogtoSocialIdentity>(
LogtoSocialIdentity.tagName
);
const identityInfo = logtoSocialIdentity?.shadowRoot?.querySelector<LogtoIdentityInfo>(
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<LogtoAccountProvider>(
html`<logto-account-provider .accountApi=${mockAccountApi}>
<logto-social-identity target="github" labelText="GitHub"></logto-social-identity>
</logto-account-provider>`
);
await provider.updateComplete;
const logtoSocialIdentity = provider.querySelector<LogtoSocialIdentity>(
LogtoSocialIdentity.tagName
);
await logtoSocialIdentity?.updateComplete;
assert.equal(logtoSocialIdentity?.shadowRoot?.children.length, 0);
});
});

View file

@ -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`<logto-identity-info
slot="content"
.avatar=${String(avatar)}
.name=${String(name)}
.email=${String(email)}
></logto-identity-info>`
);
}
}
declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface HTMLElementTagNameMap {
[tagName]: LogtoSocialIdentity;
}
}

View file

@ -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<LogtoUserEmail>(html`<logto-user-email></logto-user-email>`);
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<LogtoAccountProvider>(
html`<logto-account-provider .accountApi=${mockAccountApi}>
<logto-user-email></logto-user-email>
</logto-account-provider>`
);
await provider.updateComplete;
const logtoUserEmail = provider.querySelector<LogtoUserEmail>(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<LogtoAccountProvider>(
html`<logto-account-provider .accountApi=${mockAccountApi}>
<logto-user-email></logto-user-email>
</logto-account-provider>`
);
await provider.updateComplete;
const logtoUserEmail = provider.querySelector<LogtoUserEmail>(LogtoUserEmail.tagName);
await logtoUserEmail?.updateComplete;
assert.equal(logtoUserEmail?.shadowRoot?.children.length, 0);
});
});

View file

@ -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`<div slot="content">${email}</div>`);
}
}
declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface HTMLElementTagNameMap {
[tagName]: LogtoUserEmail;
}
}

View file

@ -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<LogtoUserPassword>(
html`<logto-user-password></logto-user-password>`
);
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<LogtoAccountProvider>(
html`<logto-account-provider .accountApi=${mockAccountApi}>
<logto-user-password></logto-user-password>
</logto-account-provider>`
);
await provider.updateComplete;
const logtoUserPassword = provider.querySelector<LogtoUserPassword>(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<LogtoAccountProvider>(
html`<logto-account-provider .accountApi=${mockAccountApi}>
<logto-user-password></logto-user-password>
</logto-account-provider>`
);
await provider.updateComplete;
const logtoUserPassword = provider.querySelector<LogtoUserPassword>(LogtoUserPassword.tagName);
await logtoUserPassword?.updateComplete;
assert.isNull(logtoUserPassword?.shadowRoot?.querySelector('.status'));
});
});

View file

@ -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`<div slot="content">
<div class="status">
<span class="status-dot"></span>
Configured
</div>
</div>`
);
}
}
declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface HTMLElementTagNameMap {
[tagName]: LogtoUserPassword;
}
}

View file

@ -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<LogtoUserPhone>(html`<logto-user-phone></logto-user-phone>`);
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<LogtoAccountProvider>(
html`<logto-account-provider .accountApi=${mockAccountApi}>
<logto-user-phone></logto-user-phone>
</logto-account-provider>`
);
await provider.updateComplete;
const logtoUserPhone = provider.querySelector<LogtoUserPhone>(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<LogtoAccountProvider>(
html`<logto-account-provider .accountApi=${mockAccountApi}>
<logto-user-phone></logto-user-phone>
</logto-account-provider>`
);
await provider.updateComplete;
const logtoUserPhone = provider.querySelector<LogtoUserPhone>(LogtoUserPhone.tagName);
await logtoUserPhone?.updateComplete;
assert.equal(logtoUserPhone?.shadowRoot?.children.length, 0);
});
});

View file

@ -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`<div slot="content">${formatToInternationalPhoneNumber(phone)}</div>`
);
}
}
declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface HTMLElementTagNameMap {
[tagName]: LogtoUserPhone;
}
}

View file

@ -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<LogtoUsername>(html`<logto-username></logto-username>`);
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<LogtoAccountProvider>(
html`<logto-account-provider .accountApi=${mockAccountApi}>
<logto-username></logto-username>
</logto-account-provider>`
);
await provider.updateComplete;
const logtoUsername = provider.querySelector<LogtoUsername>(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<LogtoAccountProvider>(
html`<logto-account-provider .accountApi=${mockAccountApi}>
<logto-username></logto-username>
</logto-account-provider>`
);
await provider.updateComplete;
const logtoUsername = provider.querySelector<LogtoUsername>(LogtoUsername.tagName);
await logtoUsername?.updateComplete;
assert.equal(logtoUsername?.shadowRoot?.children.length, 0);
});
});

View file

@ -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`<div slot="content">${_username}</div>`);
}
}
declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface HTMLElementTagNameMap {
[tagName]: LogtoUsername;
}
}

View file

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.8334 3.33331H4.16675C3.50371 3.33331 2.86782 3.59671 2.39898 4.06555C1.93014 4.53439 1.66675 5.17027 1.66675 5.83331V14.1666C1.66675 14.8297 1.93014 15.4656 2.39898 15.9344C2.86782 16.4033 3.50371 16.6666 4.16675 16.6666H15.8334C16.4965 16.6666 17.1323 16.4033 17.6012 15.9344C18.07 15.4656 18.3334 14.8297 18.3334 14.1666V5.83331C18.3334 5.17027 18.07 4.53439 17.6012 4.06555C17.1323 3.59671 16.4965 3.33331 15.8334 3.33331ZM4.16675 4.99998H15.8334C16.0544 4.99998 16.2664 5.08778 16.4227 5.24406C16.579 5.40034 16.6667 5.6123 16.6667 5.83331L10.0001 9.89998L3.33341 5.83331C3.33341 5.6123 3.42121 5.40034 3.57749 5.24406C3.73377 5.08778 3.94573 4.99998 4.16675 4.99998ZM16.6667 14.1666C16.6667 14.3877 16.579 14.5996 16.4227 14.7559C16.2664 14.9122 16.0544 15 15.8334 15H4.16675C3.94573 15 3.73377 14.9122 3.57749 14.7559C3.42121 14.5996 3.33341 14.3877 3.33341 14.1666V7.73331L9.56675 11.5416C9.69343 11.6148 9.83713 11.6533 9.98341 11.6533C10.1297 11.6533 10.2734 11.6148 10.4001 11.5416L16.6667 7.73331V14.1666Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,13 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_969_33139)">
<path d="M34 0H6C2.68629 0 0 2.68629 0 6V34C0 37.3137 2.68629 40 6 40H34C37.3137 40 40 37.3137 40 34V6C40 2.68629 37.3137 0 34 0Z" fill="#F7F8F8"/>
<path d="M34 0H6C2.68629 0 0 2.68629 0 6V34C0 37.3137 2.68629 40 6 40H34C37.3137 40 40 37.3137 40 34V6C40 2.68629 37.3137 0 34 0Z" fill="#78767F" fill-opacity="0.02"/>
<path d="M34 0H6C2.68629 0 0 2.68629 0 6V34C0 37.3137 2.68629 40 6 40H34C37.3137 40 40 37.3137 40 34V6C40 2.68629 37.3137 0 34 0Z" fill="#5D34F2" fill-opacity="0.16"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.1449 8.3335C17.6236 8.3335 15.5796 10.3774 15.5796 12.8987V13.9132C15.5796 16.4345 17.6236 18.4784 20.1449 18.4784C22.6662 18.4784 24.7101 16.4345 24.7101 13.9132V12.8987C24.7101 10.3774 22.6662 8.3335 20.1449 8.3335ZM20.1449 31.6668C30.2899 31.6668 30.2899 28.369 30.2899 28.369C30.2899 24.0916 28.1669 20.1045 22.6812 20.0002C22.6579 19.9997 22.4995 20.1538 22.2699 20.3772C21.6671 20.9635 20.5736 22.0272 20.1449 22.0272C19.7162 22.0272 18.6227 20.9635 18.02 20.3772C17.7904 20.1538 17.6319 19.9997 17.6087 20.0002C12.123 20.1045 10 24.0916 10 28.369C10 28.369 10 31.6668 20.1449 31.6668Z" fill="#947DFF"/>
</g>
<defs>
<clipPath id="clip0_969_33139">
<rect width="40" height="40" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,4 @@
declare module '*.svg' {
const value: string;
export default value;
}

View file

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.5001 3.66667L18.0834 3.08333C18.4167 2.75 18.4167 2.25 18.0834 1.91667C17.7501 1.58333 17.2501 1.58333 16.9167 1.91667L8.16675 10.6667C7.50008 10.25 6.66675 10 5.83342 10C3.50008 10 1.66675 11.8333 1.66675 14.1667C1.66675 16.5 3.50008 18.3333 5.83342 18.3333C8.16675 18.3333 10.0001 16.5 10.0001 14.1667C10.0001 13.3333 9.75008 12.5 9.33342 11.8333L14.0001 7.16667L15.7501 8.91667C16.0834 9.25 16.5834 9.25 16.9167 8.91667C17.2501 8.58333 17.2501 8.08333 16.9167 7.75L15.1667 6L16.3334 4.83333L16.9167 5.41667C17.2501 5.75 17.7501 5.75 18.0834 5.41667C18.4167 5.08333 18.4167 4.58333 18.0834 4.25L17.5001 3.66667ZM5.83342 16.6667C4.41675 16.6667 3.33341 15.5833 3.33341 14.1667C3.33341 12.75 4.41675 11.6667 5.83342 11.6667C7.25008 11.6667 8.33342 12.75 8.33342 14.1667C8.33342 15.5833 7.25008 16.6667 5.83342 16.6667Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 958 B

View file

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.5917 13.575L10.4667 13.475C10.4203 13.4435 10.3699 13.4183 10.3167 13.4L10.1667 13.3334C10.0316 13.3051 9.89155 13.3109 9.75918 13.3501C9.62682 13.3894 9.50628 13.461 9.40841 13.5584C9.33481 13.6394 9.27562 13.7324 9.23341 13.8334C9.17035 13.9852 9.15361 14.1523 9.1853 14.3136C9.21698 14.4749 9.29568 14.6233 9.41149 14.74C9.52731 14.8567 9.67505 14.9365 9.83613 14.9695C9.99721 15.0024 10.1644 14.9869 10.3167 14.925C10.4163 14.8767 10.5088 14.815 10.5917 14.7417C10.7074 14.6245 10.7857 14.4757 10.8169 14.314C10.848 14.1524 10.8306 13.9851 10.7667 13.8334C10.7252 13.737 10.6658 13.6493 10.5917 13.575ZM13.3334 1.66669H6.66675C6.00371 1.66669 5.36782 1.93008 4.89898 2.39892C4.43014 2.86776 4.16675 3.50365 4.16675 4.16669V15.8334C4.16675 16.4964 4.43014 17.1323 4.89898 17.6011C5.36782 18.07 6.00371 18.3334 6.66675 18.3334H13.3334C13.9965 18.3334 14.6323 18.07 15.1012 17.6011C15.57 17.1323 15.8334 16.4964 15.8334 15.8334V4.16669C15.8334 3.50365 15.57 2.86776 15.1012 2.39892C14.6323 1.93008 13.9965 1.66669 13.3334 1.66669ZM14.1667 15.8334C14.1667 16.0544 14.079 16.2663 13.9227 16.4226C13.7664 16.5789 13.5544 16.6667 13.3334 16.6667H6.66675C6.44573 16.6667 6.23377 16.5789 6.07749 16.4226C5.92121 16.2663 5.83341 16.0544 5.83341 15.8334V4.16669C5.83341 3.94567 5.92121 3.73371 6.07749 3.57743C6.23377 3.42115 6.44573 3.33335 6.66675 3.33335H13.3334C13.5544 3.33335 13.7664 3.42115 13.9227 3.57743C14.079 3.73371 14.1667 3.94567 14.1667 4.16669V15.8334Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.0001 1.66669C8.38385 1.66976 6.80336 2.14278 5.45115 3.02814C4.09893 3.91349 3.03337 5.17297 2.38424 6.65316C1.73511 8.13335 1.53044 9.77037 1.79514 11.3648C2.05985 12.9593 2.78252 14.4423 3.87512 15.6334C4.65547 16.4792 5.60257 17.1543 6.65674 17.616C7.7109 18.0778 8.84927 18.3161 10.0001 18.3161C11.151 18.3161 12.2893 18.0778 13.3435 17.616C14.3977 17.1543 15.3448 16.4792 16.1251 15.6334C17.2177 14.4423 17.9404 12.9593 18.2051 11.3648C18.4698 9.77037 18.2651 8.13335 17.616 6.65316C16.9669 5.17297 15.9013 3.91349 14.5491 3.02814C13.1969 2.14278 11.6164 1.66976 10.0001 1.66669ZM10.0001 16.6667C8.27384 16.6641 6.61588 15.9919 5.37512 14.7917C5.75183 13.8746 6.39265 13.0903 7.21617 12.5383C8.03968 11.9863 9.00871 11.6915 10.0001 11.6915C10.9915 11.6915 11.9606 11.9863 12.7841 12.5383C13.6076 13.0903 14.2484 13.8746 14.6251 14.7917C13.3844 15.9919 11.7264 16.6641 10.0001 16.6667ZM8.33346 8.33335C8.33346 8.00372 8.4312 7.68149 8.61434 7.4074C8.79748 7.13332 9.05777 6.9197 9.36232 6.79355C9.66686 6.66741 10.002 6.6344 10.3253 6.69871C10.6486 6.76302 10.9455 6.92176 11.1786 7.15484C11.4117 7.38793 11.5705 7.6849 11.6348 8.0082C11.6991 8.33151 11.6661 8.66662 11.5399 8.97116C11.4138 9.2757 11.2002 9.536 10.9261 9.71914C10.652 9.90227 10.3298 10 10.0001 10C9.55809 10 9.13417 9.82443 8.82161 9.51187C8.50905 9.1993 8.33346 8.77538 8.33346 8.33335ZM15.7585 13.3334C15.0139 12.0598 13.868 11.0692 12.5001 10.5167C12.9244 10.0356 13.2009 9.44224 13.2963 8.80788C13.3918 8.17353 13.3022 7.52511 13.0382 6.94043C12.7743 6.35575 12.3473 5.85965 11.8084 5.51166C11.2695 5.16366 10.6416 4.97856 10.0001 4.97856C9.35863 4.97856 8.73076 5.16366 8.19187 5.51166C7.65297 5.85965 7.22593 6.35575 6.962 6.94043C6.69806 7.52511 6.60844 8.17353 6.7039 8.80788C6.79935 9.44224 7.07582 10.0356 7.50012 10.5167C6.13229 11.0692 4.98635 12.0598 4.24179 13.3334C3.64841 12.3226 3.33489 11.1721 3.33346 10C3.33346 8.23191 4.03583 6.53622 5.28608 5.28598C6.53632 4.03573 8.23201 3.33335 10.0001 3.33335C11.7682 3.33335 13.4639 4.03573 14.7142 5.28598C15.9644 6.53622 16.6668 8.23191 16.6668 10C16.6654 11.1721 16.3518 12.3226 15.7585 13.3334Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -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';

View file

@ -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<typeof createComponent>[
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,
}),
};
};

View file

@ -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;
}

View file

@ -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;
}
};

View file

@ -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