diff --git a/changelog.md b/changelog.md index d2c8cf2..b4bc26a 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # 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 and 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) Add activity logging, session metadata and org-selection UI (backend and frontend) diff --git a/readme.hints.md b/readme.hints.md index 95390a2..d047f84 100644 --- a/readme.hints.md +++ b/readme.hints.md @@ -1,3 +1,23 @@ # 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) diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 47ea5de..14a6a44 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@idp.global/idp.global', - version: '1.8.0', + version: '1.9.0', description: 'An identity provider software managing user authentications, registrations, and sessions.' } diff --git a/ts_interfaces/data/index.ts b/ts_interfaces/data/index.ts index ef44008..9785c1a 100644 --- a/ts_interfaces/data/index.ts +++ b/ts_interfaces/data/index.ts @@ -9,3 +9,4 @@ export * from './loint-reception.organization.js'; export * from './loint-reception.paddlecheckoutdata.js'; export * from './loint-reception.role.js'; export * from './loint-reception.user.js'; +export * from './loint-reception.userinvitation.js'; diff --git a/ts_interfaces/data/loint-reception.role.ts b/ts_interfaces/data/loint-reception.role.ts index a422ea9..93de81a 100644 --- a/ts_interfaces/data/loint-reception.role.ts +++ b/ts_interfaces/data/loint-reception.role.ts @@ -1,13 +1,18 @@ 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 { id: string; data: { userId: string; organizationId: string; - role: 'owner' | 'admin' | 'editor' | 'guest' | 'viewer' | 'outlaw'; + /** Array of roles - supports standard roles and custom role names */ + roles: string[]; }; } diff --git a/ts_interfaces/data/loint-reception.userinvitation.ts b/ts_interfaces/data/loint-reception.userinvitation.ts new file mode 100644 index 0000000..eff0874 --- /dev/null +++ b/ts_interfaces/data/loint-reception.userinvitation.ts @@ -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[]; +} diff --git a/ts_interfaces/request/index.ts b/ts_interfaces/request/index.ts index d93c809..6ee4299 100644 --- a/ts_interfaces/request/index.ts +++ b/ts_interfaces/request/index.ts @@ -9,3 +9,4 @@ export * from './loint-reception.organization.js'; export * from './loint-reception.plan.js'; export * from './loint-reception.registration.js'; export * from './loint-reception.user.js'; +export * from './loint-reception.userinvitation.js'; diff --git a/ts_interfaces/request/loint-reception.userinvitation.ts b/ts_interfaces/request/loint-reception.userinvitation.ts new file mode 100644 index 0000000..536eafb --- /dev/null +++ b/ts_interfaces/request/loint-reception.userinvitation.ts @@ -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; + }; +} diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 47ea5de..14a6a44 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@idp.global/idp.global', - version: '1.8.0', + version: '1.9.0', description: 'An identity provider software managing user authentications, registrations, and sessions.' } diff --git a/ts_web/elements/account/content.ts b/ts_web/elements/account/content.ts index 0cb8910..d83d29b 100644 --- a/ts_web/elements/account/content.ts +++ b/ts_web/elements/account/content.ts @@ -12,7 +12,7 @@ import { } from '@design.estate/dees-element'; 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 { accountDesignTokens } from './sharedstyles.js'; @@ -93,8 +93,6 @@ export class IdpAccountContent extends DeesElement { - - `; } @@ -104,34 +102,25 @@ export class IdpAccountContent extends DeesElement { this.subrouter = this.domtools.router.createSubRouter('/account'); 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 - this.addEventListener('open-org-select-modal', ((e: CustomEvent) => { - orgSelectModal.show({ + this.addEventListener('open-org-select-modal', (async (e: CustomEvent) => { + const result = await OrgSelectModal.show({ targetPath: e.detail.targetPath, title: e.detail.title, description: e.detail.description, }); + if (result) { + this.subrouter.pushUrl(result.path); + } }) as EventListener); - this.addEventListener('open-create-org-modal', () => { - createOrgModal.show(); + this.addEventListener('open-create-org-modal', async () => { + 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 () => { for (const child of Array.from(viewcontainer.children)) { viewcontainer.removeChild(child); diff --git a/ts_web/elements/account/create-org-modal.ts b/ts_web/elements/account/create-org-modal.ts index 838a079..97fb360 100644 --- a/ts_web/elements/account/create-org-modal.ts +++ b/ts_web/elements/account/create-org-modal.ts @@ -2,10 +2,9 @@ import * as plugins from '../../plugins.js'; import { customElement, DeesElement, - property, html, - cssManager, css, + cssManager, state, type TemplateResult, } from '@design.estate/dees-element'; @@ -14,17 +13,9 @@ import { accountDesignTokens } from './sharedstyles.js'; import * as accountStateModule from '../../states/accountstate.js'; import { IdpState } from '../../states/idp.state.js'; -declare global { - interface HTMLElementTagNameMap { - 'idp-create-org-modal': CreateOrgModal; - } -} - -@customElement('idp-create-org-modal') -export class CreateOrgModal extends DeesElement { - @state() - accessor visible: boolean = false; - +// Internal form element for reactive state management +@customElement('idp-create-org-form') +class CreateOrgForm extends DeesElement { @state() accessor orgName: string = ''; @@ -44,126 +35,21 @@ export class CreateOrgModal extends DeesElement { accessor error: string = ''; 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 = [ 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: 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 { margin-top: 12px; padding: 12px 16px; - background: #0a0a0a; - border: 1px solid #27272a; + background: var(--dees-color-background); + border: 1px solid var(--dees-color-line); border-radius: 8px; } @@ -172,14 +58,14 @@ export class CreateOrgModal extends DeesElement { font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; - color: #71717a; + color: var(--dees-color-muted); margin-bottom: 4px; } .slug-value { font-family: 'Geist Mono', monospace; font-size: 14px; - color: #fafafa; + color: var(--dees-color-text); } .validation-status { @@ -221,69 +107,36 @@ export class CreateOrgModal extends DeesElement { font-size: 13px; } - .modal-footer { - display: flex; - justify-content: flex-end; - gap: 12px; - padding: 16px 24px; - border-top: 1px solid #27272a; + .description { + color: var(--dees-color-muted); + font-size: 14px; + margin-bottom: 20px; } `, ]; public render(): TemplateResult { - if (!this.visible) { - return html``; - } - - const canCreate = this.orgName.length > 0 && - this.validationResult?.available && - !this.validating && - !this.creating; - return html` -
- + ` : ''} + + ${this.renderValidationStatus()} + + ${this.error ? html` +
${this.error}
+ ` : ''} `; } @@ -322,40 +175,17 @@ export class CreateOrgModal extends DeesElement { return null; } - public show() { - this.orgName = ''; - this.orgSlug = ''; - this.validating = false; - this.validationResult = null; - 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); + public async firstUpdated() { + const inputElement = this.shadowRoot.querySelector('dees-input-text') as any; + if (inputElement) { + inputElement.changeSubject.subscribe((element: any) => { + this.handleNameInput(element.value); + }); } } - private handleOverlayClick(e: Event) { - if ((e.target as HTMLElement).classList.contains('overlay') && !this.creating) { - this.hide(); - } - } - - private handleCancel() { - if (!this.creating) { - this.hide(); - } - } - - private handleNameInput(e: Event) { - const input = e.target as HTMLInputElement; - this.orgName = input.value; + private handleNameInput(value: string) { + this.orgName = value; this.orgSlug = this.generateSlug(this.orgName); this.error = ''; @@ -414,8 +244,15 @@ export class CreateOrgModal extends DeesElement { } } - private async handleCreate() { - if (!this.validationResult?.available || this.creating) { + public canCreate(): boolean { + return this.orgName.length > 0 && + this.validationResult?.available === true && + !this.validating && + !this.creating; + } + + public async handleCreate(): Promise { + if (!this.canCreate()) { return; } @@ -438,18 +275,52 @@ export class CreateOrgModal extends DeesElement { result.resultingOrganization ); - this.dispatchEvent(new CustomEvent('org-created', { - bubbles: true, - composed: true, - detail: { org: result.resultingOrganization }, - })); - - this.hide(); + this.modal?.destroy(); + this.resolveWith?.(result.resultingOrganization); } catch (error) { console.error('Error creating organization:', error); this.error = error instanceof Error ? error.message : 'Failed to create organization. Please try again.'; - } finally { 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 { + return new Promise((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; + }); + }); + } } diff --git a/ts_web/elements/account/navigation.ts b/ts_web/elements/account/navigation.ts index 34d94af..825afc4 100644 --- a/ts_web/elements/account/navigation.ts +++ b/ts_web/elements/account/navigation.ts @@ -14,6 +14,8 @@ import * as plugins from '../../plugins.js'; import * as states from '../../states/accountstate.js'; import { IdpState } from '../../states/idp.state.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'; @@ -28,10 +30,39 @@ export class LeleAccountNavigation extends DeesElement { @state() accessor isGlobalAdmin: boolean = false; + @state() + accessor currentPath: string = window.location.pathname; + constructor() { 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 = [ cssManager.defaultStyles, accountDesignTokens, @@ -136,6 +167,15 @@ export class LeleAccountNavigation extends DeesElement { opacity: 1; } + .navigationOption.active { + background: var(--muted); + color: var(--foreground); + } + + .navigationOption.active dees-icon { + opacity: 1; + } + .divider { height: 1px; background: var(--border); @@ -165,11 +205,8 @@ export class LeleAccountNavigation extends DeesElement {