feat: Update organization member management and bulk invite functionality
- Marked the status of "Invite and Manage Team Members" story as Complete in README. - Updated the status of ORG-002 to Complete in the corresponding markdown file. - Modified OrganizationManager to assign roles as 'owner' during organization creation. - Implemented bulk invitation feature in UserInvitationManager, allowing multiple users to be invited via CSV upload. - Added IReq_BulkCreateInvitations interface for bulk invitation requests. - Enhanced CreateOrgForm to update state with new roles upon organization creation. - Introduced BulkInviteModal for bulk inviting users, including email validation and role assignment. - Updated UsersView to support ownership transfer and bulk invitation functionality. - Improved account state management to handle new roles and organizations.
This commit is contained in:
+2
-2
@@ -16,9 +16,9 @@
|
||||
"author": "Task Venture Capital GmbH",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@api.global/typedrequest": "^3.1.11",
|
||||
"@api.global/typedrequest": "^3.2.5",
|
||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
||||
"@api.global/typedserver": "^7.7.0",
|
||||
"@api.global/typedserver": "^7.8.17",
|
||||
"@api.global/typedsocket": "^4.1.0",
|
||||
"@consent.software/catalog": "^2.0.1",
|
||||
"@design.estate/dees-catalog": "^2.0.3",
|
||||
|
||||
Generated
+225
-483
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -30,7 +30,7 @@ stories/
|
||||
| ID | Title | Priority | Source |
|
||||
|----|-------|----------|--------|
|
||||
| ORG-001 | [Sync Billing Plans with Users](organization-owner/ORG-001-billing-sync.md) | High | TODO |
|
||||
| ORG-002 | [Invite and Manage Team Members](organization-owner/ORG-002-member-management.md) | Critical | New |
|
||||
| ORG-002 | [Invite and Manage Team Members](organization-owner/ORG-002-member-management.md) | Critical | Complete |
|
||||
| ORG-003 | [Assign Roles to Members](organization-owner/ORG-003-role-assignment.md) | High | Partial |
|
||||
| ORG-004 | [Customize Organization Branding](organization-owner/ORG-004-org-branding.md) | Medium | New |
|
||||
| ORG-005 | [View Organization Usage Analytics](organization-owner/ORG-005-usage-analytics.md) | Medium | New |
|
||||
@@ -69,7 +69,7 @@ stories/
|
||||
|
||||
| Priority | Count | Stories |
|
||||
|----------|-------|---------|
|
||||
| Critical | 3 | EU-002, ORG-002, ADM-001 |
|
||||
| Critical | 2 | EU-002, ADM-001 |
|
||||
| High | 12 | EU-001, EU-004, ORG-001, ORG-003, ORG-006, ORG-009, DEV-001, DEV-002, DEV-004, ADM-002, ADM-003, ADM-008 |
|
||||
| Medium | 14 | EU-003, EU-005, EU-006, ORG-004, ORG-005, ORG-007, ORG-008, ORG-010, ORG-011, DEV-003, DEV-005, DEV-007, ADM-004, ADM-005, ADM-007 |
|
||||
| Low | 6 | EU-007, EU-008, DEV-006, DEV-008, ADM-006 |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**ID:** ORG-002
|
||||
**Priority:** Critical
|
||||
**Status:** In Development
|
||||
**Status:** Complete
|
||||
|
||||
## User Story
|
||||
As an organization owner, I want to invite team members to my organization and manage their access so that my team can collaborate securely.
|
||||
@@ -14,8 +14,8 @@ As an organization owner, I want to invite team members to my organization and m
|
||||
- [x] Owner can view pending invitations and resend/cancel them
|
||||
- [x] Owner can see all current members with their roles
|
||||
- [x] Owner can remove members from organization
|
||||
- [ ] Owner can transfer ownership to another member
|
||||
- [ ] Bulk invite via CSV upload
|
||||
- [x] Owner can transfer ownership to another member
|
||||
- [x] Bulk invite via CSV upload
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
|
||||
@@ -50,13 +50,14 @@ export class OrganizationManager {
|
||||
action: 'create',
|
||||
organizationId: newOrg.id,
|
||||
userId: userData.id,
|
||||
role: 'admin',
|
||||
roles: ['owner'],
|
||||
});
|
||||
newOrg.data.roleIds.push(role.id);
|
||||
await newOrg.save();
|
||||
return {
|
||||
nameAvailable: true,
|
||||
resultingOrganization: await newOrg.createSavableObject()
|
||||
resultingOrganization: await newOrg.createSavableObject(),
|
||||
role: await role.createSavableObject(),
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -456,6 +456,159 @@ export class UserInvitationManager {
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Bulk create invitations
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_BulkCreateInvitations>(
|
||||
'bulkCreateInvitations',
|
||||
async (requestArg) => {
|
||||
const user = await this.receptionRef.userManager.getUserByJwtValidation(requestArg.jwt);
|
||||
await this.verifyUserIsAdminOfOrg(user.id, requestArg.organizationId);
|
||||
|
||||
const org = await this.receptionRef.organizationmanager.COrganization.getInstance({
|
||||
id: requestArg.organizationId,
|
||||
});
|
||||
const orgName = org?.data.name || 'an organization';
|
||||
|
||||
const results: Array<{
|
||||
email: string;
|
||||
success: boolean;
|
||||
status: 'invited' | 'already_member' | 'invalid_email' | 'error';
|
||||
message?: string;
|
||||
}> = [];
|
||||
const summary = {
|
||||
total: 0,
|
||||
invited: 0,
|
||||
alreadyMembers: 0,
|
||||
invalid: 0,
|
||||
errors: 0,
|
||||
};
|
||||
|
||||
// Deduplicate emails in the batch
|
||||
const processedEmails = new Set<string>();
|
||||
|
||||
for (const inv of requestArg.invitations) {
|
||||
summary.total++;
|
||||
const email = inv.email?.toLowerCase().trim();
|
||||
|
||||
// Validate email format
|
||||
if (!email || !this.isValidEmail(email)) {
|
||||
results.push({
|
||||
email: inv.email || '',
|
||||
success: false,
|
||||
status: 'invalid_email',
|
||||
message: 'Invalid email format',
|
||||
});
|
||||
summary.invalid++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip duplicates within batch
|
||||
if (processedEmails.has(email)) {
|
||||
results.push({
|
||||
email,
|
||||
success: false,
|
||||
status: 'invalid_email',
|
||||
message: 'Duplicate email in batch',
|
||||
});
|
||||
summary.invalid++;
|
||||
continue;
|
||||
}
|
||||
processedEmails.add(email);
|
||||
|
||||
try {
|
||||
// Check if user with this email already exists
|
||||
const existingUser = await this.receptionRef.userManager.CUser.getInstance({
|
||||
data: { email },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
// Check if already a member
|
||||
const existingRole = await this.receptionRef.roleManager.CRole.getInstance({
|
||||
data: {
|
||||
userId: existingUser.id,
|
||||
organizationId: requestArg.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingRole) {
|
||||
results.push({
|
||||
email,
|
||||
success: false,
|
||||
status: 'already_member',
|
||||
message: 'Already a member of this organization',
|
||||
});
|
||||
summary.alreadyMembers++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add existing user to org
|
||||
const roles = inv.roles?.length ? inv.roles : requestArg.defaultRoles;
|
||||
await this.receptionRef.roleManager.modifyRoleForUserAtOrg({
|
||||
action: 'create',
|
||||
userId: existingUser.id,
|
||||
organizationId: requestArg.organizationId,
|
||||
roles,
|
||||
});
|
||||
results.push({
|
||||
email,
|
||||
success: true,
|
||||
status: 'invited',
|
||||
message: 'Existing user added to organization',
|
||||
});
|
||||
summary.invited++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if invitation already exists
|
||||
let invitation = await this.CUserInvitation.getInstance({
|
||||
data: { email },
|
||||
});
|
||||
|
||||
const roles = inv.roles?.length ? inv.roles : requestArg.defaultRoles;
|
||||
|
||||
if (invitation) {
|
||||
// Add org to existing invitation
|
||||
await invitation.addOrganization(requestArg.organizationId, user.id, roles);
|
||||
} else {
|
||||
// Create new invitation
|
||||
invitation = await UserInvitation.createNewInvitation(
|
||||
email,
|
||||
requestArg.organizationId,
|
||||
user.id,
|
||||
roles
|
||||
);
|
||||
}
|
||||
|
||||
// Send invitation email
|
||||
await this.receptionRef.receptionMailer.sendInvitationEmail(
|
||||
email,
|
||||
orgName,
|
||||
invitation.data.token,
|
||||
this.receptionRef.options.baseUrl
|
||||
);
|
||||
|
||||
results.push({
|
||||
email,
|
||||
success: true,
|
||||
status: 'invited',
|
||||
});
|
||||
summary.invited++;
|
||||
} catch (error: any) {
|
||||
results.push({
|
||||
email,
|
||||
success: false,
|
||||
status: 'error',
|
||||
message: error.message || 'Unknown error',
|
||||
});
|
||||
summary.errors++;
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, results, summary };
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -553,4 +706,12 @@ export class UserInvitationManager {
|
||||
throw new plugins.typedrequest.TypedResponseError('Not a member of this organization.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate email format
|
||||
*/
|
||||
private isValidEmail(email: string): boolean {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,3 +209,39 @@ export interface IReq_GetInvitationByToken
|
||||
requiresRegistration: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk create invitations from a list (typically from CSV import)
|
||||
*/
|
||||
export interface IReq_BulkCreateInvitations
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_BulkCreateInvitations
|
||||
> {
|
||||
method: 'bulkCreateInvitations';
|
||||
request: {
|
||||
jwt: string;
|
||||
organizationId: string;
|
||||
invitations: Array<{
|
||||
email: string;
|
||||
roles?: string[];
|
||||
}>;
|
||||
defaultRoles: string[];
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
results: Array<{
|
||||
email: string;
|
||||
success: boolean;
|
||||
status: 'invited' | 'already_member' | 'invalid_email' | 'error';
|
||||
message?: string;
|
||||
}>;
|
||||
summary: {
|
||||
total: number;
|
||||
invited: number;
|
||||
alreadyMembers: number;
|
||||
invalid: number;
|
||||
errors: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,585 @@
|
||||
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;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -267,9 +267,12 @@ class CreateOrgForm extends DeesElement {
|
||||
'manifest'
|
||||
);
|
||||
|
||||
// Update state with new organization
|
||||
// Update state with new organization and role
|
||||
const currentState = accountStateModule.accountState.getState();
|
||||
currentState.organizations.push(result.resultingOrganization);
|
||||
if (result.role) {
|
||||
currentState.roles.push(result.role);
|
||||
}
|
||||
accountStateModule.accountState.dispatchAction(
|
||||
accountStateModule.setSelectedOrg,
|
||||
result.resultingOrganization
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
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 {
|
||||
@@ -64,6 +65,9 @@ export class UsersView extends DeesElement {
|
||||
@state()
|
||||
accessor isAdmin: boolean = false;
|
||||
|
||||
@state()
|
||||
accessor isOwner: boolean = false;
|
||||
|
||||
@state()
|
||||
accessor currentUserId: string = '';
|
||||
|
||||
@@ -75,6 +79,8 @@ export class UsersView extends DeesElement {
|
||||
|
||||
private static readonly AVAILABLE_ROLES = ['owner', 'admin', 'editor', 'viewer', 'guest'];
|
||||
|
||||
private emailInputSubscribed: boolean = false;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
accountDesignTokens,
|
||||
@@ -459,8 +465,19 @@ export class UsersView extends DeesElement {
|
||||
<span class="role-badge ${role}">${role}</span>
|
||||
`)}
|
||||
</div>
|
||||
${this.isAdmin && member.userId !== this.currentUserId ? html`
|
||||
${member.userId !== this.currentUserId ? html`
|
||||
<div class="member-actions">
|
||||
${this.isOwner && !member.isOwner ? html`
|
||||
<button
|
||||
class="action-button"
|
||||
@click=${() => this.handleTransferOwnership(member.userId, member.name)}
|
||||
?disabled=${this.submitting}
|
||||
title="Transfer ownership to this member"
|
||||
>
|
||||
Transfer Ownership
|
||||
</button>
|
||||
` : ''}
|
||||
${this.isAdmin ? html`
|
||||
<button
|
||||
class="action-button danger"
|
||||
@click=${() => this.handleRemoveMember(member.userId, member.name)}
|
||||
@@ -469,6 +486,7 @@ export class UsersView extends DeesElement {
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
@@ -560,21 +578,38 @@ export class UsersView extends DeesElement {
|
||||
.status=${this.submitting ? 'pending' : 'normal'}
|
||||
@click=${() => this.handleSendInvitation()}
|
||||
></dees-button>
|
||||
|
||||
<div style="margin-top: 24px; padding-top: 24px; border-top: 1px solid var(--border);">
|
||||
<p style="color: var(--muted-foreground); font-size: 13px; margin: 0 0 12px 0;">
|
||||
Need to invite multiple people at once?
|
||||
</p>
|
||||
<dees-button
|
||||
.text=${'Import from CSV'}
|
||||
.type=${'secondary'}
|
||||
@click=${() => this.handleBulkImport()}
|
||||
></dees-button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
public async firstUpdated() {
|
||||
// Subscribe to email input changes
|
||||
await this.updateComplete;
|
||||
const emailInput = this.shadowRoot?.querySelector('dees-input-text') as any;
|
||||
if (emailInput) {
|
||||
emailInput.changeSubject?.subscribe((element: any) => {
|
||||
this.inviteEmail = element.value;
|
||||
});
|
||||
await this.loadData();
|
||||
}
|
||||
|
||||
await this.loadData();
|
||||
public updated(changedProperties: Map<string, any>) {
|
||||
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() {
|
||||
@@ -598,11 +633,12 @@ export class UsersView extends DeesElement {
|
||||
this.organizationName = selectedOrg.data.name;
|
||||
this.currentUserId = currentState.user?.id || '';
|
||||
|
||||
// Check if current user is admin
|
||||
// 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();
|
||||
@@ -818,6 +854,75 @@ export class UsersView extends DeesElement {
|
||||
}
|
||||
}
|
||||
|
||||
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<plugins.idpInterfaces.request.IReq_TransferOwnership>(
|
||||
'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<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'Transfer Ownership',
|
||||
content: html`
|
||||
<div style="padding: 16px 0;">
|
||||
<p style="margin: 0 0 12px 0;">Are you sure you want to transfer ownership to <strong>${name}</strong>?</p>
|
||||
<p style="margin: 0; color: var(--muted-foreground);">
|
||||
You will be demoted to admin role and will no longer be the owner of this organization.
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
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
|
||||
|
||||
@@ -87,6 +87,9 @@ export const manifestNewOrgName = accountState.createAction(async (statePartArg,
|
||||
'manifest'
|
||||
);
|
||||
currentState.organizations.push(result.resultingOrganization);
|
||||
if (result.role) {
|
||||
currentState.roles.push(result.role);
|
||||
}
|
||||
currentState.selectedOrg = result.resultingOrganization;
|
||||
return currentState;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user