feat(account): Refactor account UI: migrate modals to promise-based show() API and improve navigation URL tracking
This commit is contained in:
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,34 +102,25 @@ 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) {
|
||||||
|
this.subrouter.pushUrl(`/org/${org.data.slug}/billing`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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`);
|
|
||||||
}) as EventListener);
|
|
||||||
|
|
||||||
const cleanupViews = async () => {
|
const cleanupViews = async () => {
|
||||||
for (const child of Array.from(viewcontainer.children)) {
|
for (const child of Array.from(viewcontainer.children)) {
|
||||||
viewcontainer.removeChild(child);
|
viewcontainer.removeChild(child);
|
||||||
|
|||||||
@@ -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,69 +107,36 @@ 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>
|
.value=${this.orgName}
|
||||||
</div>
|
?disabled=${this.creating}
|
||||||
<div class="modal-body">
|
></dees-input-text>
|
||||||
<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}
|
|
||||||
@input=${this.handleNameInput}
|
|
||||||
?disabled=${this.creating}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${this.orgSlug ? html`
|
${this.orgSlug ? html`
|
||||||
<div class="slug-preview">
|
<div class="slug-preview">
|
||||||
<div class="slug-label">Organization URL Slug</div>
|
<div class="slug-label">Organization URL Slug</div>
|
||||||
<div class="slug-value">${this.orgSlug}</div>
|
<div class="slug-value">${this.orgSlug}</div>
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
|
|
||||||
${this.renderValidationStatus()}
|
|
||||||
|
|
||||||
${this.error ? html`
|
|
||||||
<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>
|
||||||
</div>
|
` : ''}
|
||||||
|
|
||||||
|
${this.renderValidationStatus()}
|
||||||
|
|
||||||
|
${this.error ? html`
|
||||||
|
<div class="error-message">${this.error}</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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -1,318 +1,209 @@
|
|||||||
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')
|
const modalStyles = css`
|
||||||
export class OrgSelectModal extends DeesElement {
|
.org-list {
|
||||||
@state()
|
display: flex;
|
||||||
accessor visible: boolean = false;
|
flex-direction: column;
|
||||||
|
|
||||||
@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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.org-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.org-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 14px 24px;
|
|
||||||
border-bottom: 1px solid #27272a;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.org-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.org-item:hover {
|
|
||||||
background: #27272a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.org-icon {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #27272a;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.org-item:hover .org-icon {
|
|
||||||
background: #3f3f46;
|
|
||||||
}
|
|
||||||
|
|
||||||
.org-icon dees-icon {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.org-info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.org-name {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
color: #fafafa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.org-slug {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #71717a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.org-arrow {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px 24px;
|
|
||||||
color: #71717a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state dees-icon {
|
|
||||||
font-size: 40px;
|
|
||||||
opacity: 0.5;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state p {
|
|
||||||
margin: 0 0 16px 0;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 16px 24px;
|
|
||||||
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 {
|
.org-item {
|
||||||
return html`
|
display: flex;
|
||||||
<div class="org-list">
|
align-items: center;
|
||||||
${this.organizations.map((org) => html`
|
gap: 12px;
|
||||||
<div class="org-item" @click=${() => this.handleSelectOrg(org)}>
|
padding: 14px 20px;
|
||||||
<div class="org-icon">
|
border-bottom: 1px solid var(--dees-color-line);
|
||||||
<dees-icon .icon=${'lucide:building2'}></dees-icon>
|
cursor: pointer;
|
||||||
</div>
|
transition: background 0.15s ease;
|
||||||
<div class="org-info">
|
|
||||||
<div class="org-name">${org.data.name}</div>
|
|
||||||
<div class="org-slug">${org.data.slug}</div>
|
|
||||||
</div>
|
|
||||||
<dees-icon class="org-arrow" .icon=${'lucide:chevron-right'}></dees-icon>
|
|
||||||
</div>
|
|
||||||
`)}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderEmptyState(): TemplateResult {
|
.org-item:last-child {
|
||||||
return html`
|
border-bottom: none;
|
||||||
<div class="empty-state">
|
|
||||||
<dees-icon .icon=${'lucide:building2'}></dees-icon>
|
|
||||||
<p>You don't have any organizations yet.</p>
|
|
||||||
<dees-button @clicked=${this.handleCreateOrg}>
|
|
||||||
<dees-icon .icon=${'lucide:plus'} slot="iconLeft"></dees-icon>
|
|
||||||
Create Organization
|
|
||||||
</dees-button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public show(options: {
|
.org-item:hover {
|
||||||
|
background: var(--dees-color-softBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--dees-color-softBackground);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-item:hover .org-icon {
|
||||||
|
background: var(--dees-color-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-icon dees-icon {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
color: var(--dees-color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-slug {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--dees-color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-arrow {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 24px;
|
||||||
|
color: var(--dees-color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state dees-icon {
|
||||||
|
font-size: 40px;
|
||||||
|
opacity: 0.5;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
color: var(--dees-color-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export class OrgSelectModal {
|
||||||
|
public static async show(options: {
|
||||||
targetPath: string;
|
targetPath: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
}) {
|
}): Promise<IOrgSelectResult | null> {
|
||||||
this.targetPath = options.targetPath;
|
const title = options.title || 'Select Organization';
|
||||||
this.title = options.title || 'Select Organization';
|
const description = options.description || 'Choose an organization to continue.';
|
||||||
this.description = options.description || 'Choose an organization to continue.';
|
|
||||||
|
|
||||||
// Load organizations from state
|
// Load organizations from state
|
||||||
const state = accountStateModule.accountState.getState();
|
const state = accountStateModule.accountState.getState();
|
||||||
this.organizations = state.organizations;
|
const organizations = state.organizations;
|
||||||
|
|
||||||
this.visible = true;
|
return new Promise<IOrgSelectResult | null>((resolve) => {
|
||||||
this.setAttribute('visible', '');
|
let modal: plugins.deesCatalog.DeesModal | null = null;
|
||||||
}
|
let resolved = false;
|
||||||
|
|
||||||
public hide() {
|
const handleSelectOrg = (org: plugins.idpInterfaces.data.IOrganization) => {
|
||||||
this.visible = false;
|
if (resolved) return;
|
||||||
this.removeAttribute('visible');
|
resolved = true;
|
||||||
}
|
|
||||||
|
|
||||||
private handleOverlayClick(e: Event) {
|
accountStateModule.accountState.dispatchAction(accountStateModule.setSelectedOrg, org);
|
||||||
if ((e.target as HTMLElement).classList.contains('overlay')) {
|
const path = options.targetPath.replace(':orgName', org.data.slug);
|
||||||
this.hide();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleCancel() {
|
modal?.destroy();
|
||||||
this.hide();
|
resolve({ org, path });
|
||||||
}
|
};
|
||||||
|
|
||||||
private handleSelectOrg(org: plugins.idpInterfaces.data.IOrganization) {
|
const handleCreateOrg = async () => {
|
||||||
accountStateModule.accountState.dispatchAction(accountStateModule.setSelectedOrg, org);
|
if (resolved) return;
|
||||||
|
resolved = true;
|
||||||
|
modal?.destroy();
|
||||||
|
|
||||||
// Replace :orgName placeholder with actual slug
|
// Import dynamically to avoid circular dependency
|
||||||
const path = this.targetPath.replace(':orgName', org.data.slug);
|
const { CreateOrgModal } = await import('./create-org-modal.js');
|
||||||
|
const createdOrg = await CreateOrgModal.show();
|
||||||
|
|
||||||
this.dispatchEvent(new CustomEvent('org-selected', {
|
if (createdOrg) {
|
||||||
bubbles: true,
|
const path = options.targetPath.replace(':orgName', createdOrg.data.slug);
|
||||||
composed: true,
|
resolve({ org: createdOrg, path });
|
||||||
detail: { org, path },
|
} else {
|
||||||
}));
|
resolve(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
this.hide();
|
const renderOrgList = (): TemplateResult => {
|
||||||
}
|
return html`
|
||||||
|
<style>${modalStyles}</style>
|
||||||
|
<div class="description">${description}</div>
|
||||||
|
<div class="org-list">
|
||||||
|
${organizations.map((org) => html`
|
||||||
|
<div class="org-item" @click=${() => handleSelectOrg(org)}>
|
||||||
|
<div class="org-icon">
|
||||||
|
<dees-icon .icon=${'lucide:building2'}></dees-icon>
|
||||||
|
</div>
|
||||||
|
<div class="org-info">
|
||||||
|
<div class="org-name">${org.data.name}</div>
|
||||||
|
<div class="org-slug">${org.data.slug}</div>
|
||||||
|
</div>
|
||||||
|
<dees-icon class="org-arrow" .icon=${'lucide:chevron-right'}></dees-icon>
|
||||||
|
</div>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
private handleCreateOrg() {
|
const renderEmptyState = (): TemplateResult => {
|
||||||
this.hide();
|
return html`
|
||||||
this.dispatchEvent(new CustomEvent('open-create-org-modal', {
|
<style>${modalStyles}</style>
|
||||||
bubbles: true,
|
<div class="empty-state">
|
||||||
composed: true,
|
<dees-icon .icon=${'lucide:building2'}></dees-icon>
|
||||||
}));
|
<p>You don't have any organizations yet.</p>
|
||||||
|
<dees-button @clicked=${handleCreateOrg}>
|
||||||
|
<dees-icon .icon=${'lucide:plus'} slot="iconLeft"></dees-icon>
|
||||||
|
Create Organization
|
||||||
|
</dees-button>
|
||||||
|
</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();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
width: 420,
|
||||||
|
}).then((m) => {
|
||||||
|
modal = m;
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user