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`
${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;
});
});
}
}