import * as plugins from '../../plugins.js'; import { customElement, DeesElement, html, css, cssManager, state, type TemplateResult, } from '@design.estate/dees-element'; import { accountDesignTokens } from './sharedstyles.js'; import { IdpState } from '../../states/idp.state.js'; interface IParsedEmail { email: string; valid: boolean; error?: string; } interface IBulkInviteResult { invitedCount: number; failedCount: number; alreadyMemberCount: number; } // Internal form element for reactive state management @customElement('idp-bulk-invite-form') export class BulkInviteForm extends DeesElement { @state() accessor organizationId: string = ''; @state() accessor organizationName: string = ''; @state() accessor parsedEmails: IParsedEmail[] = []; @state() accessor selectedRoles: string[] = ['viewer']; @state() accessor submitting: boolean = false; @state() accessor error: string = ''; @state() accessor results: IBulkInviteResult | null = null; private static readonly AVAILABLE_ROLES = ['admin', 'editor', 'viewer', 'guest']; public resolveWith: ((result: IBulkInviteResult | null) => void) | null = null; public modal: plugins.deesCatalog.DeesModal | null = null; public static styles = [ cssManager.defaultStyles, accountDesignTokens, css` :host { display: block; } .description { color: var(--muted-foreground); font-size: 14px; margin-bottom: 20px; } .file-upload-area { border: 2px dashed var(--border); border-radius: 12px; padding: 32px; text-align: center; cursor: pointer; transition: all 0.15s ease; margin-bottom: 20px; } .file-upload-area:hover { border-color: var(--muted-foreground); background: var(--muted); } .file-upload-area.has-data { border-style: solid; border-color: #22c55e; background: rgba(34, 197, 94, 0.05); } .upload-icon { font-size: 32px; color: var(--muted-foreground); margin-bottom: 12px; } .upload-text { font-size: 14px; color: var(--foreground); margin-bottom: 4px; } .upload-hint { font-size: 12px; color: var(--muted-foreground); } .sample-link { color: #3b82f6; cursor: pointer; text-decoration: underline; } input[type="file"] { display: none; } .preview-section { margin-bottom: 20px; } .preview-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } .preview-title { font-size: 13px; font-weight: 600; color: var(--foreground); } .preview-stats { font-size: 12px; color: var(--muted-foreground); } .preview-stats .valid { color: #22c55e; } .preview-stats .invalid { color: #ef4444; } .preview-list { max-height: 200px; overflow-y: auto; border: 1px solid var(--border); border-radius: 8px; } .preview-item { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; border-bottom: 1px solid var(--border); font-size: 13px; } .preview-item:last-child { border-bottom: none; } .preview-item.invalid { background: rgba(239, 68, 68, 0.05); } .preview-email { color: var(--foreground); } .preview-status { font-size: 11px; padding: 2px 8px; border-radius: 4px; } .preview-status.valid { background: rgba(34, 197, 94, 0.2); color: #22c55e; } .preview-status.invalid { background: rgba(239, 68, 68, 0.2); color: #ef4444; } .role-section { margin-bottom: 20px; } .section-label { font-size: 13px; font-weight: 500; color: var(--foreground); margin-bottom: 10px; } .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; } .error-message { padding: 12px 16px; background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.3); border-radius: 8px; color: #ef4444; font-size: 13px; margin-bottom: 16px; } .results-section { padding: 16px; background: rgba(34, 197, 94, 0.1); border: 1px solid rgba(34, 197, 94, 0.3); border-radius: 8px; margin-bottom: 16px; } .results-section.has-failures { background: rgba(234, 179, 8, 0.1); border-color: rgba(234, 179, 8, 0.3); } .results-title { font-weight: 600; margin-bottom: 8px; color: var(--foreground); } .results-stats { font-size: 13px; color: var(--muted-foreground); } .clear-button { font-size: 12px; color: #ef4444; background: none; border: none; cursor: pointer; padding: 4px 8px; } .clear-button:hover { text-decoration: underline; } `, ]; public render(): TemplateResult { if (this.results) { return this.renderResults(); } return html`
Upload a CSV file with email addresses to invite multiple people at once.
${this.error ? html`
${this.error}
` : ''} ${this.renderFileUpload()} ${this.parsedEmails.length > 0 ? this.renderPreview() : ''} ${this.parsedEmails.length > 0 ? this.renderRoleSelector() : ''} `; } private renderFileUpload(): TemplateResult { const validCount = this.parsedEmails.filter(e => e.valid).length; const hasData = this.parsedEmails.length > 0; return html`
this.triggerFileInput()} @dragover=${(e: DragEvent) => { e.preventDefault(); }} @drop=${(e: DragEvent) => this.handleFileDrop(e)} > this.handleFileSelect(e)} /> ${hasData ? html`
${validCount} valid email(s) loaded
Click to replace with a different file
` : html`
Drop CSV file here or click to browse
{ e.stopPropagation(); this.downloadSampleCSV(); }}>Download sample CSV
`}
`; } private renderPreview(): TemplateResult { const validCount = this.parsedEmails.filter(e => e.valid).length; const invalidCount = this.parsedEmails.filter(e => !e.valid).length; return html`
Email Preview ${validCount} valid ${invalidCount > 0 ? html`, ${invalidCount} invalid` : ''}
${this.parsedEmails.map(item => html`
${item.email} ${item.valid ? 'Valid' : (item.error || 'Invalid')}
`)}
`; } private renderRoleSelector(): TemplateResult { return html`
Assign Role
${BulkInviteForm.AVAILABLE_ROLES.map(role => html` `)}
`; } private renderResults(): TemplateResult { const hasFailures = this.results!.failedCount > 0 || this.results!.alreadyMemberCount > 0; return html`
Bulk Invite Complete
${this.results!.invitedCount} invitation(s) sent successfully. ${this.results!.alreadyMemberCount > 0 ? html`
${this.results!.alreadyMemberCount} already member(s).` : ''} ${this.results!.failedCount > 0 ? html`
${this.results!.failedCount} failed.` : ''}
`; } private triggerFileInput(): void { const input = this.shadowRoot?.querySelector('input[type="file"]') as HTMLInputElement; input?.click(); } private handleFileDrop(e: DragEvent): void { e.preventDefault(); const file = e.dataTransfer?.files[0]; if (file) { this.parseCSVFile(file); } } private handleFileSelect(e: Event): void { const input = e.target as HTMLInputElement; const file = input.files?.[0]; if (file) { this.parseCSVFile(file); } } private async parseCSVFile(file: File): Promise { const text = await file.text(); const lines = text.split(/\r?\n/).filter(line => line.trim()); const parsed: IParsedEmail[] = []; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const seen = new Set(); for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); // Skip header row if it looks like "email" or similar if (i === 0 && (line.toLowerCase() === 'email' || line.toLowerCase() === 'emails' || line.toLowerCase() === 'e-mail')) { continue; } // Extract email from line (handle quoted values, commas) const email = line.replace(/["']/g, '').split(',')[0].trim().toLowerCase(); if (!email) { continue; } if (seen.has(email)) { parsed.push({ email, valid: false, error: 'Duplicate' }); continue; } seen.add(email); if (!emailRegex.test(email)) { parsed.push({ email, valid: false, error: 'Invalid format' }); continue; } parsed.push({ email, valid: true }); } this.parsedEmails = parsed; this.error = ''; } private downloadSampleCSV(): void { const content = 'email\nuser1@example.com\nuser2@example.com\nuser3@example.com'; const blob = new Blob([content], { type: 'text/csv' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'sample-invite-list.csv'; a.click(); URL.revokeObjectURL(url); } private clearEmails(): void { this.parsedEmails = []; this.error = ''; } private toggleRole(role: string): void { if (this.selectedRoles.includes(role)) { this.selectedRoles = this.selectedRoles.filter(r => r !== role); } else { this.selectedRoles = [...this.selectedRoles, role]; } if (this.selectedRoles.length === 0) { this.selectedRoles = ['viewer']; } } public canSubmit(): boolean { const validEmails = this.parsedEmails.filter(e => e.valid); return validEmails.length > 0 && this.selectedRoles.length > 0 && !this.submitting && !this.results; } public async handleSubmit(): Promise { if (!this.canSubmit()) { return null; } this.submitting = true; this.error = ''; try { const idpState = await IdpState.getSingletonInstance(); const jwt = await idpState.idpClient.getJwt(); const validEmails = this.parsedEmails.filter(e => e.valid); const request = idpState.idpClient.typedsocket.createTypedRequest( 'bulkCreateInvitations' ); const response = await request.fire({ jwt, organizationId: this.organizationId, invitations: validEmails.map(e => ({ email: e.email })), defaultRoles: this.selectedRoles, }); this.results = { invitedCount: response.summary.invited, failedCount: response.summary.errors + response.summary.invalid, alreadyMemberCount: response.summary.alreadyMembers, }; return this.results; } catch (error) { console.error('Error sending bulk invitations:', error); this.error = error instanceof Error ? error.message : 'Failed to send invitations. Please try again.'; return null; } finally { this.submitting = false; } } public handleCancel(): void { this.modal?.destroy(); this.resolveWith?.(null); } public handleClose(): void { this.modal?.destroy(); this.resolveWith?.(this.results); } } // Export the modal utility class export class BulkInviteModal { public static async show(options: { organizationId: string; organizationName: string; }): Promise { return new Promise((resolve) => { const formElement = new BulkInviteForm(); formElement.organizationId = options.organizationId; formElement.organizationName = options.organizationName; formElement.resolveWith = resolve; plugins.deesCatalog.DeesModal.createAndShow({ heading: 'Bulk Invite Members', content: html`${formElement}`, menuOptions: [ { name: 'Cancel', action: async () => { formElement.handleCancel(); }, }, { name: 'Send Invitations', action: async () => { const result = await formElement.handleSubmit(); if (result) { // Wait a bit for user to see results, then close setTimeout(() => { formElement.handleClose(); }, 2000); } }, }, ], width: 520, }).then((modal) => { formElement.modal = modal; }); }); } }