Files
app/ts_web/elements/account/bulk-invite-modal.ts
T

586 lines
15 KiB
TypeScript
Raw Normal View History

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`
<div class="description">
Upload a CSV file with email addresses to invite multiple people at once.
</div>
${this.error ? html`
<div class="error-message">${this.error}</div>
` : ''}
${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`
<div
class="file-upload-area ${hasData ? 'has-data' : ''}"
@click=${() => this.triggerFileInput()}
@dragover=${(e: DragEvent) => { e.preventDefault(); }}
@drop=${(e: DragEvent) => this.handleFileDrop(e)}
>
<input
type="file"
accept=".csv,.txt"
@change=${(e: Event) => this.handleFileSelect(e)}
/>
${hasData ? html`
<div class="upload-icon">
<dees-icon .icon=${'lucide:check-circle'}></dees-icon>
</div>
<div class="upload-text">${validCount} valid email(s) loaded</div>
<div class="upload-hint">Click to replace with a different file</div>
` : html`
<div class="upload-icon">
<dees-icon .icon=${'lucide:upload'}></dees-icon>
</div>
<div class="upload-text">Drop CSV file here or click to browse</div>
<div class="upload-hint">
<span class="sample-link" @click=${(e: Event) => { e.stopPropagation(); this.downloadSampleCSV(); }}>Download sample CSV</span>
</div>
`}
</div>
`;
}
private renderPreview(): TemplateResult {
const validCount = this.parsedEmails.filter(e => e.valid).length;
const invalidCount = this.parsedEmails.filter(e => !e.valid).length;
return html`
<div class="preview-section">
<div class="preview-header">
<span class="preview-title">Email Preview</span>
<span class="preview-stats">
<span class="valid">${validCount} valid</span>
${invalidCount > 0 ? html`, <span class="invalid">${invalidCount} invalid</span>` : ''}
</span>
<button class="clear-button" @click=${() => this.clearEmails()}>Clear</button>
</div>
<div class="preview-list">
${this.parsedEmails.map(item => html`
<div class="preview-item ${item.valid ? '' : 'invalid'}">
<span class="preview-email">${item.email}</span>
<span class="preview-status ${item.valid ? 'valid' : 'invalid'}">
${item.valid ? 'Valid' : (item.error || 'Invalid')}
</span>
</div>
`)}
</div>
</div>
`;
}
private renderRoleSelector(): TemplateResult {
return html`
<div class="role-section">
<div class="section-label">Assign Role</div>
<div class="role-selector">
${BulkInviteForm.AVAILABLE_ROLES.map(role => html`
<button
class="role-option ${this.selectedRoles.includes(role) ? 'selected' : ''}"
@click=${() => this.toggleRole(role)}
?disabled=${this.submitting}
>
${role}
</button>
`)}
</div>
</div>
`;
}
private renderResults(): TemplateResult {
const hasFailures = this.results!.failedCount > 0 || this.results!.alreadyMemberCount > 0;
return html`
<div class="results-section ${hasFailures ? 'has-failures' : ''}">
<div class="results-title">Bulk Invite Complete</div>
<div class="results-stats">
${this.results!.invitedCount} invitation(s) sent successfully.
${this.results!.alreadyMemberCount > 0 ? html`<br>${this.results!.alreadyMemberCount} already member(s).` : ''}
${this.results!.failedCount > 0 ? html`<br>${this.results!.failedCount} failed.` : ''}
</div>
</div>
`;
}
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<void> {
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<string>();
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<IBulkInviteResult | null> {
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<plugins.idpInterfaces.request.IReq_BulkCreateInvitations>(
'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<IBulkInviteResult | null> {
return new Promise<IBulkInviteResult | null>((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;
});
});
}
}