import * as plugins from '../../../plugins.js'; import { customElement, DeesElement, html, cssManager, css, state, type TemplateResult, } from '@design.estate/dees-element'; import sharedStyles, { accountDesignTokens, cardStyles, typographyStyles } from '../sharedstyles.js'; import * as accountState from '../../../states/accountstate.js'; import { IdpState } from '../../../states/idp.state.js'; import { BulkInviteModal } from '../bulk-invite-modal.js'; declare global { interface HTMLElementTagNameMap { 'lele-accountview-users': UsersView; } } interface IMemberDisplay { userId: string; name: string; email: string; roles: string[]; isOwner: boolean; } interface IInvitationDisplay { id: string; email: string; roles: string[]; invitedAt: number; expiresAt: number; } @customElement('lele-accountview-users') export class UsersView extends DeesElement { @state() accessor members: IMemberDisplay[] = []; @state() accessor invitations: IInvitationDisplay[] = []; @state() accessor loading: boolean = true; @state() accessor activeTab: 'members' | 'pending' | 'invite' = 'members'; @state() accessor organizationId: string = ''; @state() accessor organizationName: string = ''; @state() accessor inviteEmail: string = ''; @state() accessor inviteRoles: string[] = ['viewer']; @state() accessor isAdmin: boolean = false; @state() accessor isOwner: boolean = false; @state() accessor currentUserId: string = ''; @state() accessor submitting: boolean = false; @state() accessor actionMessage: { type: 'success' | 'error'; text: string } | null = null; private static readonly AVAILABLE_ROLES = ['owner', 'admin', 'editor', 'viewer', 'guest']; private emailInputSubscribed: boolean = false; public static styles = [ cssManager.defaultStyles, accountDesignTokens, cardStyles, typographyStyles, css` :host { display: block; padding: 48px; max-width: 1000px; margin: 0 auto; } .tabs { display: flex; gap: 4px; margin-bottom: 32px; border-bottom: 1px solid var(--border); padding-bottom: 8px; } .tab { padding: 10px 20px; border-radius: 8px 8px 0 0; font-size: 14px; font-weight: 500; color: var(--muted-foreground); cursor: pointer; transition: all 0.15s ease; border: none; background: transparent; } .tab:hover { color: var(--foreground); background: var(--muted); } .tab.active { color: var(--foreground); background: var(--muted); } .member-list { display: flex; flex-direction: column; gap: 12px; } .member-card { display: flex; align-items: center; justify-content: space-between; background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 16px 20px; transition: all 0.15s ease; } .member-card:hover { border-color: var(--muted-foreground); } .member-info { display: flex; align-items: center; gap: 16px; } .member-avatar { width: 40px; height: 40px; border-radius: 50%; background: var(--muted); display: flex; align-items: center; justify-content: center; font-size: 16px; font-weight: 600; color: var(--foreground); } .member-details { display: flex; flex-direction: column; gap: 2px; } .member-name { font-size: 14px; font-weight: 600; color: var(--foreground); } .member-email { font-size: 13px; color: var(--muted-foreground); } .member-roles { display: flex; gap: 6px; flex-wrap: wrap; } .role-badge { padding: 4px 10px; border-radius: 12px; font-size: 11px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; } .role-badge.owner { background: rgba(234, 179, 8, 0.2); color: #eab308; } .role-badge.admin { background: rgba(59, 130, 246, 0.2); color: #3b82f6; } .role-badge.editor { background: rgba(34, 197, 94, 0.2); color: #22c55e; } .role-badge.viewer { background: rgba(148, 163, 184, 0.2); color: #94a3b8; } .role-badge.guest { background: rgba(168, 162, 158, 0.2); color: #a8a29e; } .member-actions { display: flex; gap: 8px; } .action-button { padding: 8px 12px; border-radius: 6px; font-size: 12px; font-weight: 500; border: 1px solid var(--border); background: transparent; color: var(--muted-foreground); cursor: pointer; transition: all 0.15s ease; } .action-button:hover { border-color: var(--foreground); color: var(--foreground); } .action-button.danger:hover { border-color: #ef4444; color: #ef4444; } .action-button:disabled { opacity: 0.5; cursor: not-allowed; } .invitation-card { display: flex; align-items: center; justify-content: space-between; background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 16px 20px; } .invitation-info { display: flex; flex-direction: column; gap: 4px; } .invitation-email { font-size: 14px; font-weight: 500; color: var(--foreground); } .invitation-meta { font-size: 12px; color: var(--muted-foreground); } .invite-form { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 24px; } .form-group { margin-bottom: 20px; } .form-label { display: block; font-size: 13px; font-weight: 500; color: var(--foreground); margin-bottom: 8px; } .role-selector { display: flex; flex-wrap: wrap; gap: 8px; } .role-option { padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 500; border: 1px solid var(--border); background: transparent; color: var(--muted-foreground); cursor: pointer; transition: all 0.15s ease; } .role-option:hover { border-color: var(--foreground); color: var(--foreground); } .role-option.selected { border-color: #3b82f6; background: rgba(59, 130, 246, 0.1); color: #3b82f6; } .message { padding: 12px 16px; border-radius: 8px; font-size: 13px; margin-bottom: 20px; } .message.success { background: rgba(34, 197, 94, 0.1); color: #22c55e; border: 1px solid rgba(34, 197, 94, 0.3); } .message.error { background: rgba(239, 68, 68, 0.1); color: #ef4444; border: 1px solid rgba(239, 68, 68, 0.3); } .empty-state { text-align: center; padding: 48px; color: var(--muted-foreground); } .empty-state dees-icon { font-size: 48px; opacity: 0.5; margin-bottom: 16px; } .loading { display: flex; align-items: center; justify-content: center; padding: 48px; color: var(--muted-foreground); } .you-badge { font-size: 10px; padding: 2px 6px; background: rgba(59, 130, 246, 0.2); color: #3b82f6; border-radius: 4px; margin-left: 8px; } `, ]; public render() { return html`

Users

Manage members and invitations for ${this.organizationName || 'your organization'}.

${this.actionMessage ? html`
${this.actionMessage.text}
` : ''}
${this.isAdmin ? html` ` : ''}
${this.renderTabContent()} `; } private renderTabContent() { if (this.loading) { return html`
Loading users...
`; } switch (this.activeTab) { case 'members': return this.renderMembers(); case 'pending': return this.renderPendingInvitations(); case 'invite': return this.renderInviteForm(); } } private renderMembers() { if (this.members.length === 0) { return html`

No Members

This organization has no members yet.

`; } return html`
${this.members.map(member => html`
${member.name.charAt(0).toUpperCase()}
${member.name} ${member.userId === this.currentUserId ? html`You` : ''} ${member.email}
${member.roles.map(role => html` ${role} `)}
${member.userId !== this.currentUserId ? html`
${this.isOwner && !member.isOwner ? html` ` : ''} ${this.isAdmin ? html` ` : ''}
` : ''}
`)}
`; } private renderPendingInvitations() { if (this.invitations.length === 0) { return html`

No Pending Invitations

There are no pending invitations for this organization.

`; } return html`
${this.invitations.map(inv => html`
${inv.email} Invited ${this.formatDate(inv.invitedAt)} ยท Expires ${this.formatDate(inv.expiresAt)}
${inv.roles.map(role => html` ${role} `)}
${this.isAdmin ? html`
` : ''}
`)}
`; } private renderInviteForm(): TemplateResult { return html`
${UsersView.AVAILABLE_ROLES.filter(r => r !== 'owner').map(role => html` `)}
this.handleSendInvitation()} >

Need to invite multiple people at once?

this.handleBulkImport()} >
`; } public async firstUpdated() { await this.loadData(); } public updated(changedProperties: Map) { super.updated(changedProperties); // Subscribe to email input when Invite tab is shown if (this.activeTab === 'invite' && !this.emailInputSubscribed) { const emailInput = this.shadowRoot?.querySelector('.invite-form dees-input-text') as any; if (emailInput?.changeSubject) { emailInput.changeSubject.subscribe((element: any) => { this.inviteEmail = element.value; }); this.emailInputSubscribed = true; } } } private async loadData() { this.loading = true; try { // Get the organization from URL const pathParts = window.location.pathname.split('/'); const orgSlug = pathParts[3]; const currentState = accountState.accountState.getState(); const selectedOrg = currentState.organizations.find(org => org.data.slug === orgSlug); if (!selectedOrg) { console.error('Organization not found'); this.loading = false; return; } this.organizationId = selectedOrg.id; this.organizationName = selectedOrg.data.name; this.currentUserId = currentState.user?.id || ''; // Check if current user is admin/owner const currentUserRole = currentState.roles.find( r => r.data.organizationId === this.organizationId && r.data.userId === this.currentUserId ); this.isAdmin = currentUserRole?.data?.roles?.some(r => ['owner', 'admin'].includes(r)) ?? false; this.isOwner = currentUserRole?.data?.roles?.includes('owner') ?? false; // Get JWT from IdpState const idpState = await IdpState.getSingletonInstance(); const jwt = await idpState.idpClient.getJwt(); // Fetch members const membersRequest = idpState.idpClient.typedsocket.createTypedRequest( 'getOrgMembers' ); const membersResponse = await membersRequest.fire({ jwt, organizationId: this.organizationId, }); this.members = membersResponse.members.map(m => ({ userId: m.user.id, name: m.user.data.name || m.user.data.username || 'Unknown', email: m.user.data.email, roles: m.role.data.roles || [], isOwner: m.role.data.roles?.includes('owner') ?? false, })); // Fetch invitations if admin if (this.isAdmin) { const invitationsRequest = idpState.idpClient.typedsocket.createTypedRequest( 'getOrgInvitations' ); const invitationsResponse = await invitationsRequest.fire({ jwt, organizationId: this.organizationId, }); this.invitations = invitationsResponse.invitations.map(inv => { const orgRef = inv.data.organizationRefs.find(ref => ref.organizationId === this.organizationId); return { id: inv.id, email: inv.data.email, roles: orgRef?.roles || [], invitedAt: orgRef?.invitedAt || inv.data.createdAt, expiresAt: inv.data.expiresAt, }; }); } } catch (error) { console.error('Error loading users:', error); } finally { this.loading = false; } } private toggleRole(role: string) { if (this.inviteRoles.includes(role)) { this.inviteRoles = this.inviteRoles.filter(r => r !== role); } else { this.inviteRoles = [...this.inviteRoles, role]; } // Ensure at least one role is selected if (this.inviteRoles.length === 0) { this.inviteRoles = ['viewer']; } } private async handleSendInvitation() { if (!this.inviteEmail.trim()) { this.showMessage('error', 'Please enter an email address.'); return; } if (this.inviteRoles.length === 0) { this.showMessage('error', 'Please select at least one role.'); return; } this.submitting = true; this.actionMessage = null; try { const idpState = await IdpState.getSingletonInstance(); const jwt = await idpState.idpClient.getJwt(); const request = idpState.idpClient.typedsocket.createTypedRequest( 'createInvitation' ); const response = await request.fire({ jwt, organizationId: this.organizationId, email: this.inviteEmail.trim(), roles: this.inviteRoles, }); if (response.success) { this.showMessage('success', response.message || 'Invitation sent successfully!'); this.inviteEmail = ''; this.inviteRoles = ['viewer']; await this.loadData(); this.activeTab = 'pending'; } else { this.showMessage('error', response.message || 'Failed to send invitation.'); } } catch (error) { console.error('Error sending invitation:', error); this.showMessage('error', 'Failed to send invitation. Please try again.'); } finally { this.submitting = false; } } private async handleResendInvitation(invitationId: string) { this.submitting = true; this.actionMessage = null; try { const idpState = await IdpState.getSingletonInstance(); const jwt = await idpState.idpClient.getJwt(); const request = idpState.idpClient.typedsocket.createTypedRequest( 'resendInvitation' ); const response = await request.fire({ jwt, organizationId: this.organizationId, invitationId, }); if (response.success) { this.showMessage('success', 'Invitation resent successfully!'); await this.loadData(); } else { this.showMessage('error', response.message || 'Failed to resend invitation.'); } } catch (error) { console.error('Error resending invitation:', error); this.showMessage('error', 'Failed to resend invitation. Please try again.'); } finally { this.submitting = false; } } private async handleCancelInvitation(invitationId: string, email: string) { if (!confirm(`Cancel invitation for ${email}?`)) { return; } this.submitting = true; this.actionMessage = null; try { const idpState = await IdpState.getSingletonInstance(); const jwt = await idpState.idpClient.getJwt(); const request = idpState.idpClient.typedsocket.createTypedRequest( 'cancelInvitation' ); const response = await request.fire({ jwt, organizationId: this.organizationId, invitationId, }); if (response.success) { this.showMessage('success', 'Invitation cancelled.'); await this.loadData(); } else { this.showMessage('error', response.message || 'Failed to cancel invitation.'); } } catch (error) { console.error('Error cancelling invitation:', error); this.showMessage('error', 'Failed to cancel invitation. Please try again.'); } finally { this.submitting = false; } } private async handleRemoveMember(userId: string, name: string) { if (!confirm(`Remove ${name} from this organization?`)) { return; } this.submitting = true; this.actionMessage = null; try { const idpState = await IdpState.getSingletonInstance(); const jwt = await idpState.idpClient.getJwt(); const request = idpState.idpClient.typedsocket.createTypedRequest( 'removeMember' ); const response = await request.fire({ jwt, organizationId: this.organizationId, userId, }); if (response.success) { this.showMessage('success', `${name} has been removed from the organization.`); await this.loadData(); } else { this.showMessage('error', response.message || 'Failed to remove member.'); } } catch (error) { console.error('Error removing member:', error); this.showMessage('error', 'Failed to remove member. Please try again.'); } finally { this.submitting = false; } } private async handleTransferOwnership(newOwnerId: string, name: string) { const confirmed = await this.showTransferConfirmation(name); if (!confirmed) return; this.submitting = true; this.actionMessage = null; try { const idpState = await IdpState.getSingletonInstance(); const jwt = await idpState.idpClient.getJwt(); const request = idpState.idpClient.typedsocket.createTypedRequest( 'transferOwnership' ); const response = await request.fire({ jwt, organizationId: this.organizationId, newOwnerId, }); if (response.success) { this.showMessage('success', `Ownership transferred to ${name}. You are now an admin.`); await this.loadData(); } else { this.showMessage('error', response.message || 'Failed to transfer ownership.'); } } catch (error) { console.error('Error transferring ownership:', error); this.showMessage('error', 'Failed to transfer ownership. Please try again.'); } finally { this.submitting = false; } } private async showTransferConfirmation(name: string): Promise { return new Promise((resolve) => { plugins.deesCatalog.DeesModal.createAndShow({ heading: 'Transfer Ownership', content: html`

Are you sure you want to transfer ownership to ${name}?

You will be demoted to admin role and will no longer be the owner of this organization.

`, menuOptions: [ { name: 'Cancel', action: async (modal) => { modal.destroy(); resolve(false); } }, { name: 'Transfer Ownership', action: async (modal) => { modal.destroy(); resolve(true); } }, ], width: 420, }); }); } private async handleBulkImport() { const result = await BulkInviteModal.show({ organizationId: this.organizationId, organizationName: this.organizationName, }); if (result && result.invitedCount > 0) { this.showMessage('success', `${result.invitedCount} invitation(s) sent successfully.`); await this.loadData(); this.activeTab = 'pending'; } } private showMessage(type: 'success' | 'error', text: string) { this.actionMessage = { type, text }; // Auto-hide after 5 seconds setTimeout(() => { this.actionMessage = null; }, 5000); } private formatDate(timestamp: number): string { return new Date(timestamp).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', }); } }