586 lines
15 KiB
TypeScript
586 lines
15 KiB
TypeScript
|
|
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;
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|