feat(account): Refactor account UI: migrate modals to promise-based show() API and improve navigation URL tracking

This commit is contained in:
2025-12-01 20:03:34 +00:00
parent 173735a84e
commit 5f29edf449
13 changed files with 672 additions and 602 deletions
+8
View File
@@ -1,5 +1,13 @@
# Changelog # Changelog
## 2025-12-01 - 1.9.0 - feat(account)
Refactor account UI: migrate modals to promise-based show() API and improve navigation URL tracking
- Replace inline modal elements with programmatic / static show() calls for OrgSelectModal and CreateOrgModal; navigation now reacts to the results returned from show() and pushes appropriate URLs.
- Remove embedded <idp-org-select-modal> and <idp-create-org-modal> elements from the account template to use on-demand modal invocation.
- Navigation component now exposes currentPath state, listens to popstate, and watches for external URL changes (requestAnimationFrame loop) to keep UI in sync with location changes.
- Updated readme.hints.md with guidance for dees-catalog components and clarified dees-input-* event pattern (use RxJS Subjects, subscribe to changeSubject and access element.value).
## 2025-12-01 - 1.8.0 - feat(reception) ## 2025-12-01 - 1.8.0 - feat(reception)
Add activity logging, session metadata and org-selection UI (backend and frontend) Add activity logging, session metadata and org-selection UI (backend and frontend)
+21 -1
View File
@@ -1,3 +1,23 @@
# Project Readme Hints # Project Readme Hints
This is the initial readme hints file. ## UI Components
Always check dees-catalog for available elements before implementing custom solutions:
- Documentation: https://code.foss.global/design.estate/dees-catalog
- Key components: `dees-modal`, `dees-button`, `dees-input-*`, `dees-form`, etc.
### dees-input-* Event Pattern
All dees-input components use **RxJS Subjects** for value changes, NOT DOM events:
```typescript
// Subscribe to value changes in firstUpdated():
const inputElement = this.shadowRoot.querySelector('dees-input-text');
inputElement.changeSubject.subscribe((element) => {
const value = element.value;
// handle value change
});
```
- Do NOT use `@changeValue` or similar DOM events - they don't exist
- The Subject emits the element itself, access value via `element.value`
## Project Structure
- `ts_web/elements/account/` - Account dashboard components
- `ts_web/states/` - State management (accountstate, idp.state)
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@idp.global/idp.global', name: '@idp.global/idp.global',
version: '1.8.0', version: '1.9.0',
description: 'An identity provider software managing user authentications, registrations, and sessions.' description: 'An identity provider software managing user authentications, registrations, and sessions.'
} }
+1
View File
@@ -9,3 +9,4 @@ export * from './loint-reception.organization.js';
export * from './loint-reception.paddlecheckoutdata.js'; export * from './loint-reception.paddlecheckoutdata.js';
export * from './loint-reception.role.js'; export * from './loint-reception.role.js';
export * from './loint-reception.user.js'; export * from './loint-reception.user.js';
export * from './loint-reception.userinvitation.js';
+7 -2
View File
@@ -1,13 +1,18 @@
import * as plugins from '../loint-reception.plugins.js'; import * as plugins from '../loint-reception.plugins.js';
/** Standard role types available in all organizations */
export type TStandardRole = 'owner' | 'admin' | 'editor' | 'guest' | 'viewer' | 'outlaw';
/** /**
* a role describes a * A role describes a user's permissions within an organization.
* Users can have multiple roles (e.g., ['owner', 'billing-admin']).
*/ */
export interface IRole { export interface IRole {
id: string; id: string;
data: { data: {
userId: string; userId: string;
organizationId: string; organizationId: string;
role: 'owner' | 'admin' | 'editor' | 'guest' | 'viewer' | 'outlaw'; /** Array of roles - supports standard roles and custom role names */
roles: string[];
}; };
} }
@@ -0,0 +1,58 @@
import * as plugins from '../loint-reception.plugins.js';
/**
* A UserInvitation represents an invitation to join an organization.
* Key characteristics:
* - Unique by email (multiple orgs can share the same invitation)
* - Converts to real User on registration or folds into existing user
* - Auto-expires after 90 days
*/
export interface IUserInvitation {
id: string;
data: {
/** The invited email address - unique key for sharing across orgs */
email: string;
/** Secure token for invitation link validation */
token: string;
/** Current status of the invitation */
status: 'pending' | 'accepted' | 'expired' | 'cancelled';
/** When the invitation was first created */
createdAt: number;
/** When the invitation expires (createdAt + 90 days) */
expiresAt: number;
/**
* Organizations that have invited this email.
* Multiple orgs can link to the same invitation.
*/
organizationRefs: IOrganizationInvitationRef[];
/** When the invitation was accepted (user registered/folded) */
acceptedAt?: number;
/** The User ID after conversion (when accepted) */
convertedToUserId?: string;
};
}
/**
* Represents one organization's invitation to the user.
* Stored as part of IUserInvitation.organizationRefs array.
*/
export interface IOrganizationInvitationRef {
/** The organization that sent this invitation */
organizationId: string;
/** The user who sent the invitation */
invitedByUserId: string;
/** When this org invited the user */
invitedAt: number;
/** Roles to assign when the invitation is accepted */
roles: string[];
}
+1
View File
@@ -9,3 +9,4 @@ export * from './loint-reception.organization.js';
export * from './loint-reception.plan.js'; export * from './loint-reception.plan.js';
export * from './loint-reception.registration.js'; export * from './loint-reception.registration.js';
export * from './loint-reception.user.js'; export * from './loint-reception.user.js';
export * from './loint-reception.userinvitation.js';
@@ -0,0 +1,211 @@
import * as data from '../data/index.js';
import * as plugins from '../loint-reception.plugins.js';
/**
* Create an invitation to join an organization
*/
export interface IReq_CreateInvitation
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_CreateInvitation
> {
method: 'createInvitation';
request: {
jwt: string;
organizationId: string;
email: string;
roles: string[];
};
response: {
success: boolean;
invitation?: data.IUserInvitation;
message?: string;
/** True if a new invitation was created, false if email was added to existing */
isNew: boolean;
};
}
/**
* Get pending invitations for an organization
*/
export interface IReq_GetOrgInvitations
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_GetOrgInvitations
> {
method: 'getOrgInvitations';
request: {
jwt: string;
organizationId: string;
};
response: {
invitations: data.IUserInvitation[];
};
}
/**
* Get members of an organization (users with roles)
*/
export interface IReq_GetOrgMembers
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_GetOrgMembers
> {
method: 'getOrgMembers';
request: {
jwt: string;
organizationId: string;
};
response: {
members: Array<{
user: data.IUser;
role: data.IRole;
}>;
};
}
/**
* Cancel a pending invitation
*/
export interface IReq_CancelInvitation
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_CancelInvitation
> {
method: 'cancelInvitation';
request: {
jwt: string;
organizationId: string;
invitationId: string;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Resend invitation email
*/
export interface IReq_ResendInvitation
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_ResendInvitation
> {
method: 'resendInvitation';
request: {
jwt: string;
organizationId: string;
invitationId: string;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Remove a member from an organization
*/
export interface IReq_RemoveMember
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_RemoveMember
> {
method: 'removeMember';
request: {
jwt: string;
organizationId: string;
userId: string;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Update a member's roles
*/
export interface IReq_UpdateMemberRoles
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_UpdateMemberRoles
> {
method: 'updateMemberRoles';
request: {
jwt: string;
organizationId: string;
userId: string;
roles: string[];
};
response: {
success: boolean;
role?: data.IRole;
message?: string;
};
}
/**
* Transfer organization ownership to another member
*/
export interface IReq_TransferOwnership
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_TransferOwnership
> {
method: 'transferOwnership';
request: {
jwt: string;
organizationId: string;
newOwnerId: string;
};
response: {
success: boolean;
message?: string;
};
}
/**
* Accept an invitation (called during registration or email verification)
*/
export interface IReq_AcceptInvitation
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_AcceptInvitation
> {
method: 'acceptInvitation';
request: {
token: string;
userId: string;
};
response: {
success: boolean;
organizations?: data.IOrganization[];
roles?: data.IRole[];
message?: string;
};
}
/**
* Get invitation by token (for invitation landing page)
*/
export interface IReq_GetInvitationByToken
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_GetInvitationByToken
> {
method: 'getInvitationByToken';
request: {
token: string;
};
response: {
invitation?: data.IUserInvitation;
organizations?: Array<{
id: string;
name: string;
}>;
isExpired: boolean;
requiresRegistration: boolean;
};
}
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@idp.global/idp.global', name: '@idp.global/idp.global',
version: '1.8.0', version: '1.9.0',
description: 'An identity provider software managing user authentications, registrations, and sessions.' description: 'An identity provider software managing user authentications, registrations, and sessions.'
} }
+11 -22
View File
@@ -12,7 +12,7 @@ import {
} from '@design.estate/dees-element'; } from '@design.estate/dees-element';
import { LeleAccountNavigation } from './navigation.js'; import { LeleAccountNavigation } from './navigation.js';
import { OrgSelectModal } from './org-select-modal.js'; import { OrgSelectModal, type IOrgSelectResult } from './org-select-modal.js';
import { CreateOrgModal } from './create-org-modal.js'; import { CreateOrgModal } from './create-org-modal.js';
import { accountDesignTokens } from './sharedstyles.js'; import { accountDesignTokens } from './sharedstyles.js';
@@ -93,8 +93,6 @@ export class IdpAccountContent extends DeesElement {
<!--<lele-accountview-subscription></lele-accountview-subscription>--> <!--<lele-accountview-subscription></lele-accountview-subscription>-->
</div> </div>
</div> </div>
<idp-org-select-modal></idp-org-select-modal>
<idp-create-org-modal></idp-create-org-modal>
`; `;
} }
@@ -104,33 +102,24 @@ export class IdpAccountContent extends DeesElement {
this.subrouter = this.domtools.router.createSubRouter('/account'); this.subrouter = this.domtools.router.createSubRouter('/account');
const viewcontainer: HTMLDivElement = this.shadowRoot.querySelector('.viewcontainer'); const viewcontainer: HTMLDivElement = this.shadowRoot.querySelector('.viewcontainer');
// Get modal references
const orgSelectModal = this.shadowRoot.querySelector('idp-org-select-modal') as OrgSelectModal;
const createOrgModal = this.shadowRoot.querySelector('idp-create-org-modal') as CreateOrgModal;
// Setup event listeners for modals // Setup event listeners for modals
this.addEventListener('open-org-select-modal', ((e: CustomEvent) => { this.addEventListener('open-org-select-modal', (async (e: CustomEvent) => {
orgSelectModal.show({ const result = await OrgSelectModal.show({
targetPath: e.detail.targetPath, targetPath: e.detail.targetPath,
title: e.detail.title, title: e.detail.title,
description: e.detail.description, description: e.detail.description,
}); });
if (result) {
this.subrouter.pushUrl(result.path);
}
}) as EventListener); }) as EventListener);
this.addEventListener('open-create-org-modal', () => { this.addEventListener('open-create-org-modal', async () => {
createOrgModal.show(); const org = await CreateOrgModal.show();
}); if (org) {
// Handle org selection from modal
orgSelectModal.addEventListener('org-selected', ((e: CustomEvent) => {
this.subrouter.pushUrl(e.detail.path);
}) as EventListener);
// Handle org creation - navigate to billing
createOrgModal.addEventListener('org-created', ((e: CustomEvent) => {
const org = e.detail.org;
this.subrouter.pushUrl(`/org/${org.data.slug}/billing`); this.subrouter.pushUrl(`/org/${org.data.slug}/billing`);
}) as EventListener); }
});
const cleanupViews = async () => { const cleanupViews = async () => {
for (const child of Array.from(viewcontainer.children)) { for (const child of Array.from(viewcontainer.children)) {
+78 -207
View File
@@ -2,10 +2,9 @@ import * as plugins from '../../plugins.js';
import { import {
customElement, customElement,
DeesElement, DeesElement,
property,
html, html,
cssManager,
css, css,
cssManager,
state, state,
type TemplateResult, type TemplateResult,
} from '@design.estate/dees-element'; } from '@design.estate/dees-element';
@@ -14,17 +13,9 @@ import { accountDesignTokens } from './sharedstyles.js';
import * as accountStateModule from '../../states/accountstate.js'; import * as accountStateModule from '../../states/accountstate.js';
import { IdpState } from '../../states/idp.state.js'; import { IdpState } from '../../states/idp.state.js';
declare global { // Internal form element for reactive state management
interface HTMLElementTagNameMap { @customElement('idp-create-org-form')
'idp-create-org-modal': CreateOrgModal; class CreateOrgForm extends DeesElement {
}
}
@customElement('idp-create-org-modal')
export class CreateOrgModal extends DeesElement {
@state()
accessor visible: boolean = false;
@state() @state()
accessor orgName: string = ''; accessor orgName: string = '';
@@ -44,126 +35,21 @@ export class CreateOrgModal extends DeesElement {
accessor error: string = ''; accessor error: string = '';
private validationDebounceTimer: any = null; private validationDebounceTimer: any = null;
public resolveWith: ((org: plugins.idpInterfaces.data.IOrganization | null) => void) | null = null;
public modal: plugins.deesCatalog.DeesModal | null = null;
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
accountDesignTokens,
css` css`
:host { :host {
display: none;
}
:host([visible]) {
display: block; display: block;
} }
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.15s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal {
background: #18181b;
border: 1px solid #27272a;
border-radius: 16px;
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
animation: slideIn 0.2s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid #27272a;
}
.modal-title {
font-size: 18px;
font-weight: 600;
margin: 0 0 4px 0;
color: #fafafa;
}
.modal-description {
font-size: 14px;
color: #71717a;
margin: 0;
}
.modal-body {
padding: 24px;
}
.form-group {
margin-bottom: 20px;
}
.form-group:last-child {
margin-bottom: 0;
}
.form-label {
display: block;
font-size: 13px;
font-weight: 500;
margin-bottom: 8px;
color: #a1a1aa;
}
.form-input {
width: 100%;
padding: 10px 14px;
border-radius: 8px;
border: 1px solid #27272a;
background: #0a0a0a;
color: #fafafa;
font-size: 14px;
box-sizing: border-box;
transition: border-color 0.15s ease;
}
.form-input:focus {
outline: none;
border-color: #3b82f6;
}
.form-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.slug-preview { .slug-preview {
margin-top: 12px; margin-top: 12px;
padding: 12px 16px; padding: 12px 16px;
background: #0a0a0a; background: var(--dees-color-background);
border: 1px solid #27272a; border: 1px solid var(--dees-color-line);
border-radius: 8px; border-radius: 8px;
} }
@@ -172,14 +58,14 @@ export class CreateOrgModal extends DeesElement {
font-weight: 500; font-weight: 500;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
color: #71717a; color: var(--dees-color-muted);
margin-bottom: 4px; margin-bottom: 4px;
} }
.slug-value { .slug-value {
font-family: 'Geist Mono', monospace; font-family: 'Geist Mono', monospace;
font-size: 14px; font-size: 14px;
color: #fafafa; color: var(--dees-color-text);
} }
.validation-status { .validation-status {
@@ -221,45 +107,23 @@ export class CreateOrgModal extends DeesElement {
font-size: 13px; font-size: 13px;
} }
.modal-footer { .description {
display: flex; color: var(--dees-color-muted);
justify-content: flex-end; font-size: 14px;
gap: 12px; margin-bottom: 20px;
padding: 16px 24px;
border-top: 1px solid #27272a;
} }
`, `,
]; ];
public render(): TemplateResult { public render(): TemplateResult {
if (!this.visible) {
return html``;
}
const canCreate = this.orgName.length > 0 &&
this.validationResult?.available &&
!this.validating &&
!this.creating;
return html` return html`
<div class="overlay" @click=${this.handleOverlayClick}> <div class="description">Create a new organization to manage apps, users, and billing.</div>
<div class="modal" @click=${(e: Event) => e.stopPropagation()}> <dees-input-text
<div class="modal-header"> .label=${'Organization Name'}
<h2 class="modal-title">Create Organization</h2> .placeholder=${'e.g., Acme Inc.'}
<p class="modal-description">Create a new organization to manage apps, users, and billing.</p>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">Organization Name</label>
<input
type="text"
class="form-input"
placeholder="e.g., Acme Inc."
.value=${this.orgName} .value=${this.orgName}
@input=${this.handleNameInput}
?disabled=${this.creating} ?disabled=${this.creating}
/> ></dees-input-text>
</div>
${this.orgSlug ? html` ${this.orgSlug ? html`
<div class="slug-preview"> <div class="slug-preview">
@@ -273,17 +137,6 @@ export class CreateOrgModal extends DeesElement {
${this.error ? html` ${this.error ? html`
<div class="error-message">${this.error}</div> <div class="error-message">${this.error}</div>
` : ''} ` : ''}
</div>
<div class="modal-footer">
<dees-button type="secondary" @clicked=${this.handleCancel} ?disabled=${this.creating}>
Cancel
</dees-button>
<dees-button @clicked=${this.handleCreate} ?disabled=${!canCreate} .status=${this.creating ? 'pending' : 'normal'}>
${this.creating ? 'Creating...' : 'Create Organization'}
</dees-button>
</div>
</div>
</div>
`; `;
} }
@@ -322,40 +175,17 @@ export class CreateOrgModal extends DeesElement {
return null; return null;
} }
public show() { public async firstUpdated() {
this.orgName = ''; const inputElement = this.shadowRoot.querySelector('dees-input-text') as any;
this.orgSlug = ''; if (inputElement) {
this.validating = false; inputElement.changeSubject.subscribe((element: any) => {
this.validationResult = null; this.handleNameInput(element.value);
this.creating = false; });
this.error = '';
this.visible = true;
this.setAttribute('visible', '');
}
public hide() {
this.visible = false;
this.removeAttribute('visible');
if (this.validationDebounceTimer) {
clearTimeout(this.validationDebounceTimer);
} }
} }
private handleOverlayClick(e: Event) { private handleNameInput(value: string) {
if ((e.target as HTMLElement).classList.contains('overlay') && !this.creating) { this.orgName = value;
this.hide();
}
}
private handleCancel() {
if (!this.creating) {
this.hide();
}
}
private handleNameInput(e: Event) {
const input = e.target as HTMLInputElement;
this.orgName = input.value;
this.orgSlug = this.generateSlug(this.orgName); this.orgSlug = this.generateSlug(this.orgName);
this.error = ''; this.error = '';
@@ -414,8 +244,15 @@ export class CreateOrgModal extends DeesElement {
} }
} }
private async handleCreate() { public canCreate(): boolean {
if (!this.validationResult?.available || this.creating) { return this.orgName.length > 0 &&
this.validationResult?.available === true &&
!this.validating &&
!this.creating;
}
public async handleCreate(): Promise<void> {
if (!this.canCreate()) {
return; return;
} }
@@ -438,18 +275,52 @@ export class CreateOrgModal extends DeesElement {
result.resultingOrganization result.resultingOrganization
); );
this.dispatchEvent(new CustomEvent('org-created', { this.modal?.destroy();
bubbles: true, this.resolveWith?.(result.resultingOrganization);
composed: true,
detail: { org: result.resultingOrganization },
}));
this.hide();
} catch (error) { } catch (error) {
console.error('Error creating organization:', error); console.error('Error creating organization:', error);
this.error = error instanceof Error ? error.message : 'Failed to create organization. Please try again.'; this.error = error instanceof Error ? error.message : 'Failed to create organization. Please try again.';
} finally {
this.creating = false; this.creating = false;
} }
} }
public handleCancel(): void {
if (this.validationDebounceTimer) {
clearTimeout(this.validationDebounceTimer);
}
this.modal?.destroy();
this.resolveWith?.(null);
}
}
// Export the modal utility class
export class CreateOrgModal {
public static async show(): Promise<plugins.idpInterfaces.data.IOrganization | null> {
return new Promise<plugins.idpInterfaces.data.IOrganization | null>((resolve) => {
const formElement = new CreateOrgForm();
formElement.resolveWith = resolve;
plugins.deesCatalog.DeesModal.createAndShow({
heading: 'Create Organization',
content: html`${formElement}`,
menuOptions: [
{
name: 'Cancel',
action: async () => {
formElement.handleCancel();
},
},
{
name: 'Create Organization',
action: async () => {
await formElement.handleCreate();
},
},
],
width: 480,
}).then((modal) => {
formElement.modal = modal;
});
});
}
} }
+87 -72
View File
@@ -14,6 +14,8 @@ import * as plugins from '../../plugins.js';
import * as states from '../../states/accountstate.js'; import * as states from '../../states/accountstate.js';
import { IdpState } from '../../states/idp.state.js'; import { IdpState } from '../../states/idp.state.js';
import { accountDesignTokens } from './sharedstyles.js'; import { accountDesignTokens } from './sharedstyles.js';
import { CreateOrgModal } from './create-org-modal.js';
import { OrgSelectModal } from './org-select-modal.js';
import { commitinfo } from '../../../dist_ts/00_commitinfo_data.js'; import { commitinfo } from '../../../dist_ts/00_commitinfo_data.js';
@@ -28,10 +30,39 @@ export class LeleAccountNavigation extends DeesElement {
@state() @state()
accessor isGlobalAdmin: boolean = false; accessor isGlobalAdmin: boolean = false;
@state()
accessor currentPath: string = window.location.pathname;
constructor() { constructor() {
super(); super();
} }
private async navigateTo(path: string) {
const subrouter = await this.getAccountRouter();
subrouter.pushUrl(path);
// Update state after navigation to trigger re-render
this.currentPath = window.location.pathname;
}
private async navigateToOrgPage(page: string) {
const currentState = states.accountState.getState();
if (currentState.selectedOrg) {
const path = page ? `/org/${currentState.selectedOrg.data.slug}/${page}` : `/org/${currentState.selectedOrg.data.slug}`;
await this.navigateTo(path);
} else {
const targetPath = page ? `/org/:orgName/${page}` : '/org/:orgName';
const description = page ? `Choose an organization to view its ${page}.` : 'Choose an organization to view its overview.';
const result = await OrgSelectModal.show({
targetPath,
title: 'Select Organization',
description,
});
if (result) {
await this.navigateTo(result.path.replace('/account', ''));
}
}
}
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
accountDesignTokens, accountDesignTokens,
@@ -136,6 +167,15 @@ export class LeleAccountNavigation extends DeesElement {
opacity: 1; opacity: 1;
} }
.navigationOption.active {
background: var(--muted);
color: var(--foreground);
}
.navigationOption.active dees-icon {
opacity: 1;
}
.divider { .divider {
height: 1px; height: 1px;
background: var(--border); background: var(--border);
@@ -165,11 +205,8 @@ export class LeleAccountNavigation extends DeesElement {
<div class="navContent"> <div class="navContent">
<div class="navigationGroupLabel">Account</div> <div class="navigationGroupLabel">Account</div>
<div <div
class="navigationOption" class="navigationOption ${this.isActive('') ? 'active' : ''}"
@click=${async () => { @click=${() => this.navigateTo('')}
const subrouter = await this.getAccountRouter();
subrouter.pushUrl('');
}}
> >
<dees-icon .icon=${'lucide:home'}></dees-icon> <dees-icon .icon=${'lucide:home'}></dees-icon>
Overview Overview
@@ -202,10 +239,10 @@ export class LeleAccountNavigation extends DeesElement {
@selectedOption=${async (eventArg: CustomEvent) => { @selectedOption=${async (eventArg: CustomEvent) => {
// Handle "Create new..." option // Handle "Create new..." option
if (eventArg.detail.key === '__create_new__') { if (eventArg.detail.key === '__create_new__') {
this.dispatchEvent(new CustomEvent('open-create-org-modal', { const org = await CreateOrgModal.show();
bubbles: true, if (org) {
composed: true, await this.navigateTo(`/org/${org.data.slug}/billing`);
})); }
return; return;
} }
const currentState = states.accountState.getState(); const currentState = states.accountState.getState();
@@ -214,62 +251,29 @@ export class LeleAccountNavigation extends DeesElement {
// Auto-navigate to new org's current page type (reactivity) // Auto-navigate to new org's current page type (reactivity)
const currentPath = window.location.pathname; const currentPath = window.location.pathname;
const subrouter = await this.getAccountRouter();
if (currentPath.includes('/org/') && newOrg) { if (currentPath.includes('/org/') && newOrg) {
// Extract the page type (apps, billing, etc.) and navigate to new org // Extract the page type (apps, billing, etc.) and navigate to new org
const pathParts = currentPath.split('/'); const pathParts = currentPath.split('/');
const pageType = pathParts[5]; // /account/org/:orgName/:pageType const pageType = pathParts[5]; // /account/org/:orgName/:pageType
if (pageType) { if (pageType) {
subrouter.pushUrl(`/org/${newOrg.data.slug}/${pageType}`); await this.navigateTo(`/org/${newOrg.data.slug}/${pageType}`);
} else { } else {
subrouter.pushUrl(`/org/${newOrg.data.slug}`); await this.navigateTo(`/org/${newOrg.data.slug}`);
} }
} }
}} }}
></dees-input-dropdown> ></dees-input-dropdown>
<div <div
class="navigationOption" class="navigationOption ${this.isActive('org-overview') ? 'active' : ''}"
@click=${async () => { @click=${() => this.navigateToOrgPage('')}
const currentState = states.accountState.getState();
if (currentState.selectedOrg) {
const subrouter = await this.getAccountRouter();
subrouter.pushUrl(`/org/${currentState.selectedOrg.data.slug}`);
} else {
this.dispatchEvent(new CustomEvent('open-org-select-modal', {
bubbles: true,
composed: true,
detail: {
targetPath: '/org/:orgName',
title: 'Select Organization',
description: 'Choose an organization to view its overview.',
},
}));
}
}}
> >
<dees-icon .icon=${'lucide:home'}></dees-icon> <dees-icon .icon=${'lucide:home'}></dees-icon>
Overview Overview
</div> </div>
<div <div
class="navigationOption" class="navigationOption ${this.isActive('apps') ? 'active' : ''}"
@click=${async () => { @click=${() => this.navigateToOrgPage('apps')}
const currentState = states.accountState.getState();
if (currentState.selectedOrg) {
const subrouter = await this.getAccountRouter();
subrouter.pushUrl(`/org/${currentState.selectedOrg.data.slug}/apps`);
} else {
this.dispatchEvent(new CustomEvent('open-org-select-modal', {
bubbles: true,
composed: true,
detail: {
targetPath: '/org/:orgName/apps',
title: 'Select Organization',
description: 'Choose an organization to view its apps.',
},
}));
}
}}
> >
<dees-icon .icon=${'lucide:box'}></dees-icon> <dees-icon .icon=${'lucide:box'}></dees-icon>
Apps Apps
@@ -289,24 +293,8 @@ export class LeleAccountNavigation extends DeesElement {
Activity Activity
</div> </div>
<div <div
class="navigationOption" class="navigationOption ${this.isActive('billing') ? 'active' : ''}"
@click=${async () => { @click=${() => this.navigateToOrgPage('billing')}
const currentState = states.accountState.getState();
if (currentState.selectedOrg) {
const subrouter = await this.getAccountRouter();
subrouter.pushUrl(`/org/${currentState.selectedOrg.data.slug}/billing`);
} else {
this.dispatchEvent(new CustomEvent('open-org-select-modal', {
bubbles: true,
composed: true,
detail: {
targetPath: '/org/:orgName/billing',
title: 'Select Organization',
description: 'Choose an organization to view its billing.',
},
}));
}
}}
> >
<dees-icon .icon=${'lucide:wallet'}></dees-icon> <dees-icon .icon=${'lucide:wallet'}></dees-icon>
Billing Billing
@@ -327,11 +315,8 @@ export class LeleAccountNavigation extends DeesElement {
<div class="divider"></div> <div class="divider"></div>
<div class="navigationGroupLabel">Platform</div> <div class="navigationGroupLabel">Platform</div>
<div <div
class="navigationOption" class="navigationOption ${this.isActive('admin') ? 'active' : ''}"
@click=${async () => { @click=${() => this.navigateTo('/admin')}
const subrouter = await this.getAccountRouter();
subrouter.pushUrl('/admin');
}}
> >
<dees-icon .icon=${'lucide:shield'}></dees-icon> <dees-icon .icon=${'lucide:shield'}></dees-icon>
Global Admin Global Admin
@@ -339,7 +324,37 @@ export class LeleAccountNavigation extends DeesElement {
`; `;
} }
public firstUpdated() { private isActive(page: string): boolean {
const path = this.currentPath;
if (page === '') {
// Account overview - exact match
return path === '/account' || path === '/account/';
}
if (page === 'org-overview') {
// Org overview - /account/org/:slug without trailing page type
return /^\/account\/org\/[^\/]+\/?$/.test(path);
}
// For other pages, check if the path contains the page segment
return path.includes(`/${page}`);
}
public async firstUpdated() {
// Listen for popstate (browser back/forward)
window.addEventListener('popstate', () => {
this.currentPath = window.location.pathname;
});
// Watch for URL changes from external navigation (e.g., modals)
let lastPath = this.currentPath;
const checkPath = () => {
if (window.location.pathname !== lastPath) {
lastPath = window.location.pathname;
this.currentPath = lastPath;
}
requestAnimationFrame(checkPath);
};
requestAnimationFrame(checkPath);
const deesInputDropdown = this.shadowRoot.querySelector('dees-input-dropdown'); const deesInputDropdown = this.shadowRoot.querySelector('dees-input-dropdown');
const orgToMenuEntry = (orgArg?: plugins.idpInterfaces.data.IOrganization) => { const orgToMenuEntry = (orgArg?: plugins.idpInterfaces.data.IOrganization) => {
if (!orgArg) { if (!orgArg) {
+95 -204
View File
@@ -1,117 +1,20 @@
import * as plugins from '../../plugins.js'; import * as plugins from '../../plugins.js';
import { import {
customElement,
DeesElement,
property,
html, html,
cssManager,
css, css,
state, cssManager,
type TemplateResult, type TemplateResult,
} from '@design.estate/dees-element'; } from '@design.estate/dees-element';
import { accountDesignTokens } from './sharedstyles.js'; import { accountDesignTokens } from './sharedstyles.js';
import * as accountStateModule from '../../states/accountstate.js'; import * as accountStateModule from '../../states/accountstate.js';
declare global { export interface IOrgSelectResult {
interface HTMLElementTagNameMap { org: plugins.idpInterfaces.data.IOrganization;
'idp-org-select-modal': OrgSelectModal; path: string;
}
}
@customElement('idp-org-select-modal')
export class OrgSelectModal extends DeesElement {
@state()
accessor visible: boolean = false;
@state()
accessor organizations: plugins.idpInterfaces.data.IOrganization[] = [];
@state()
accessor targetPath: string = '';
@state()
accessor title: string = 'Select Organization';
@state()
accessor description: string = 'Choose an organization to continue.';
public static styles = [
cssManager.defaultStyles,
accountDesignTokens,
css`
:host {
display: none;
}
:host([visible]) {
display: block;
}
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.15s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal {
background: #18181b;
border: 1px solid #27272a;
border-radius: 16px;
width: 100%;
max-width: 420px;
max-height: 90vh;
overflow-y: auto;
animation: slideIn 0.2s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid #27272a;
}
.modal-title {
font-size: 18px;
font-weight: 600;
margin: 0 0 4px 0;
color: #fafafa;
}
.modal-description {
font-size: 14px;
color: #71717a;
margin: 0;
}
.modal-body {
padding: 0;
} }
const modalStyles = css`
.org-list { .org-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -121,8 +24,8 @@ export class OrgSelectModal extends DeesElement {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 14px 24px; padding: 14px 20px;
border-bottom: 1px solid #27272a; border-bottom: 1px solid var(--dees-color-line);
cursor: pointer; cursor: pointer;
transition: background 0.15s ease; transition: background 0.15s ease;
} }
@@ -132,14 +35,14 @@ export class OrgSelectModal extends DeesElement {
} }
.org-item:hover { .org-item:hover {
background: #27272a; background: var(--dees-color-softBackground);
} }
.org-icon { .org-icon {
width: 40px; width: 40px;
height: 40px; height: 40px;
border-radius: 10px; border-radius: 10px;
background: #27272a; background: var(--dees-color-softBackground);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -147,7 +50,7 @@ export class OrgSelectModal extends DeesElement {
} }
.org-item:hover .org-icon { .org-item:hover .org-icon {
background: #3f3f46; background: var(--dees-color-line);
} }
.org-icon dees-icon { .org-icon dees-icon {
@@ -163,12 +66,12 @@ export class OrgSelectModal extends DeesElement {
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
margin-bottom: 2px; margin-bottom: 2px;
color: #fafafa; color: var(--dees-color-text);
} }
.org-slug { .org-slug {
font-size: 12px; font-size: 12px;
color: #71717a; color: var(--dees-color-muted);
} }
.org-arrow { .org-arrow {
@@ -178,7 +81,7 @@ export class OrgSelectModal extends DeesElement {
.empty-state { .empty-state {
text-align: center; text-align: center;
padding: 40px 24px; padding: 40px 24px;
color: #71717a; color: var(--dees-color-muted);
} }
.empty-state dees-icon { .empty-state dees-icon {
@@ -192,48 +95,66 @@ export class OrgSelectModal extends DeesElement {
font-size: 14px; font-size: 14px;
} }
.modal-footer { .description {
display: flex; color: var(--dees-color-muted);
justify-content: flex-end; font-size: 14px;
gap: 12px; margin-bottom: 16px;
padding: 16px 24px; padding: 0 20px;
border-top: 1px solid #27272a;
} }
`,
];
public render(): TemplateResult {
if (!this.visible) {
return html``;
}
return html`
<div class="overlay" @click=${this.handleOverlayClick}>
<div class="modal" @click=${(e: Event) => e.stopPropagation()}>
<div class="modal-header">
<h2 class="modal-title">${this.title}</h2>
<p class="modal-description">${this.description}</p>
</div>
<div class="modal-body">
${this.organizations.length === 0
? this.renderEmptyState()
: this.renderOrgList()}
</div>
<div class="modal-footer">
<dees-button type="secondary" @clicked=${this.handleCancel}>
Cancel
</dees-button>
</div>
</div>
</div>
`; `;
}
private renderOrgList(): TemplateResult { export class OrgSelectModal {
public static async show(options: {
targetPath: string;
title?: string;
description?: string;
}): Promise<IOrgSelectResult | null> {
const title = options.title || 'Select Organization';
const description = options.description || 'Choose an organization to continue.';
// Load organizations from state
const state = accountStateModule.accountState.getState();
const organizations = state.organizations;
return new Promise<IOrgSelectResult | null>((resolve) => {
let modal: plugins.deesCatalog.DeesModal | null = null;
let resolved = false;
const handleSelectOrg = (org: plugins.idpInterfaces.data.IOrganization) => {
if (resolved) return;
resolved = true;
accountStateModule.accountState.dispatchAction(accountStateModule.setSelectedOrg, org);
const path = options.targetPath.replace(':orgName', org.data.slug);
modal?.destroy();
resolve({ org, path });
};
const handleCreateOrg = async () => {
if (resolved) return;
resolved = true;
modal?.destroy();
// Import dynamically to avoid circular dependency
const { CreateOrgModal } = await import('./create-org-modal.js');
const createdOrg = await CreateOrgModal.show();
if (createdOrg) {
const path = options.targetPath.replace(':orgName', createdOrg.data.slug);
resolve({ org: createdOrg, path });
} else {
resolve(null);
}
};
const renderOrgList = (): TemplateResult => {
return html` return html`
<style>${modalStyles}</style>
<div class="description">${description}</div>
<div class="org-list"> <div class="org-list">
${this.organizations.map((org) => html` ${organizations.map((org) => html`
<div class="org-item" @click=${() => this.handleSelectOrg(org)}> <div class="org-item" @click=${() => handleSelectOrg(org)}>
<div class="org-icon"> <div class="org-icon">
<dees-icon .icon=${'lucide:building2'}></dees-icon> <dees-icon .icon=${'lucide:building2'}></dees-icon>
</div> </div>
@@ -246,73 +167,43 @@ export class OrgSelectModal extends DeesElement {
`)} `)}
</div> </div>
`; `;
} };
private renderEmptyState(): TemplateResult { const renderEmptyState = (): TemplateResult => {
return html` return html`
<style>${modalStyles}</style>
<div class="empty-state"> <div class="empty-state">
<dees-icon .icon=${'lucide:building2'}></dees-icon> <dees-icon .icon=${'lucide:building2'}></dees-icon>
<p>You don't have any organizations yet.</p> <p>You don't have any organizations yet.</p>
<dees-button @clicked=${this.handleCreateOrg}> <dees-button @clicked=${handleCreateOrg}>
<dees-icon .icon=${'lucide:plus'} slot="iconLeft"></dees-icon> <dees-icon .icon=${'lucide:plus'} slot="iconLeft"></dees-icon>
Create Organization Create Organization
</dees-button> </dees-button>
</div> </div>
`; `;
};
const content = organizations.length === 0 ? renderEmptyState() : renderOrgList();
plugins.deesCatalog.DeesModal.createAndShow({
heading: title,
content,
menuOptions: [
{
name: 'Cancel',
action: async (modalRef) => {
if (!resolved) {
resolved = true;
resolve(null);
} }
modalRef.destroy();
public show(options: { },
targetPath: string; },
title?: string; ],
description?: string; width: 420,
}) { }).then((m) => {
this.targetPath = options.targetPath; modal = m;
this.title = options.title || 'Select Organization'; });
this.description = options.description || 'Choose an organization to continue.'; });
// Load organizations from state
const state = accountStateModule.accountState.getState();
this.organizations = state.organizations;
this.visible = true;
this.setAttribute('visible', '');
}
public hide() {
this.visible = false;
this.removeAttribute('visible');
}
private handleOverlayClick(e: Event) {
if ((e.target as HTMLElement).classList.contains('overlay')) {
this.hide();
}
}
private handleCancel() {
this.hide();
}
private handleSelectOrg(org: plugins.idpInterfaces.data.IOrganization) {
accountStateModule.accountState.dispatchAction(accountStateModule.setSelectedOrg, org);
// Replace :orgName placeholder with actual slug
const path = this.targetPath.replace(':orgName', org.data.slug);
this.dispatchEvent(new CustomEvent('org-selected', {
bubbles: true,
composed: true,
detail: { org, path },
}));
this.hide();
}
private handleCreateOrg() {
this.hide();
this.dispatchEvent(new CustomEvent('open-create-org-modal', {
bubbles: true,
composed: true,
}));
} }
} }