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",
|
"author": "Task Venture Capital GmbH",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@api.global/typedrequest": "^3.1.11",
|
"@api.global/typedrequest": "^3.2.5",
|
||||||
"@api.global/typedrequest-interfaces": "^3.0.19",
|
"@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",
|
"@api.global/typedsocket": "^4.1.0",
|
||||||
"@consent.software/catalog": "^2.0.1",
|
"@consent.software/catalog": "^2.0.1",
|
||||||
"@design.estate/dees-catalog": "^2.0.3",
|
"@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 |
|
| ID | Title | Priority | Source |
|
||||||
|----|-------|----------|--------|
|
|----|-------|----------|--------|
|
||||||
| ORG-001 | [Sync Billing Plans with Users](organization-owner/ORG-001-billing-sync.md) | High | TODO |
|
| 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-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-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 |
|
| ORG-005 | [View Organization Usage Analytics](organization-owner/ORG-005-usage-analytics.md) | Medium | New |
|
||||||
@@ -69,7 +69,7 @@ stories/
|
|||||||
|
|
||||||
| Priority | Count | 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 |
|
| 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 |
|
| 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 |
|
| Low | 6 | EU-007, EU-008, DEV-006, DEV-008, ADM-006 |
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**ID:** ORG-002
|
**ID:** ORG-002
|
||||||
**Priority:** Critical
|
**Priority:** Critical
|
||||||
**Status:** In Development
|
**Status:** Complete
|
||||||
|
|
||||||
## User Story
|
## 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.
|
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 view pending invitations and resend/cancel them
|
||||||
- [x] Owner can see all current members with their roles
|
- [x] Owner can see all current members with their roles
|
||||||
- [x] Owner can remove members from organization
|
- [x] Owner can remove members from organization
|
||||||
- [ ] Owner can transfer ownership to another member
|
- [x] Owner can transfer ownership to another member
|
||||||
- [ ] Bulk invite via CSV upload
|
- [x] Bulk invite via CSV upload
|
||||||
|
|
||||||
## Technical Implementation
|
## Technical Implementation
|
||||||
|
|
||||||
|
|||||||
@@ -50,13 +50,14 @@ export class OrganizationManager {
|
|||||||
action: 'create',
|
action: 'create',
|
||||||
organizationId: newOrg.id,
|
organizationId: newOrg.id,
|
||||||
userId: userData.id,
|
userId: userData.id,
|
||||||
role: 'admin',
|
roles: ['owner'],
|
||||||
});
|
});
|
||||||
newOrg.data.roleIds.push(role.id);
|
newOrg.data.roleIds.push(role.id);
|
||||||
await newOrg.save();
|
await newOrg.save();
|
||||||
return {
|
return {
|
||||||
nameAvailable: true,
|
nameAvailable: true,
|
||||||
resultingOrganization: await newOrg.createSavableObject()
|
resultingOrganization: await newOrg.createSavableObject(),
|
||||||
|
role: await role.createSavableObject(),
|
||||||
}
|
}
|
||||||
break;
|
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.');
|
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;
|
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'
|
'manifest'
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update state with new organization
|
// Update state with new organization and role
|
||||||
const currentState = accountStateModule.accountState.getState();
|
const currentState = accountStateModule.accountState.getState();
|
||||||
currentState.organizations.push(result.resultingOrganization);
|
currentState.organizations.push(result.resultingOrganization);
|
||||||
|
if (result.role) {
|
||||||
|
currentState.roles.push(result.role);
|
||||||
|
}
|
||||||
accountStateModule.accountState.dispatchAction(
|
accountStateModule.accountState.dispatchAction(
|
||||||
accountStateModule.setSelectedOrg,
|
accountStateModule.setSelectedOrg,
|
||||||
result.resultingOrganization
|
result.resultingOrganization
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import sharedStyles, { accountDesignTokens, cardStyles, typographyStyles } from '../sharedstyles.js';
|
import sharedStyles, { accountDesignTokens, cardStyles, typographyStyles } from '../sharedstyles.js';
|
||||||
import * as accountState from '../../../states/accountstate.js';
|
import * as accountState from '../../../states/accountstate.js';
|
||||||
import { IdpState } from '../../../states/idp.state.js';
|
import { IdpState } from '../../../states/idp.state.js';
|
||||||
|
import { BulkInviteModal } from '../bulk-invite-modal.js';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface HTMLElementTagNameMap {
|
interface HTMLElementTagNameMap {
|
||||||
@@ -64,6 +65,9 @@ export class UsersView extends DeesElement {
|
|||||||
@state()
|
@state()
|
||||||
accessor isAdmin: boolean = false;
|
accessor isAdmin: boolean = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor isOwner: boolean = false;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
accessor currentUserId: string = '';
|
accessor currentUserId: string = '';
|
||||||
|
|
||||||
@@ -75,6 +79,8 @@ export class UsersView extends DeesElement {
|
|||||||
|
|
||||||
private static readonly AVAILABLE_ROLES = ['owner', 'admin', 'editor', 'viewer', 'guest'];
|
private static readonly AVAILABLE_ROLES = ['owner', 'admin', 'editor', 'viewer', 'guest'];
|
||||||
|
|
||||||
|
private emailInputSubscribed: boolean = false;
|
||||||
|
|
||||||
public static styles = [
|
public static styles = [
|
||||||
cssManager.defaultStyles,
|
cssManager.defaultStyles,
|
||||||
accountDesignTokens,
|
accountDesignTokens,
|
||||||
@@ -459,16 +465,28 @@ export class UsersView extends DeesElement {
|
|||||||
<span class="role-badge ${role}">${role}</span>
|
<span class="role-badge ${role}">${role}</span>
|
||||||
`)}
|
`)}
|
||||||
</div>
|
</div>
|
||||||
${this.isAdmin && member.userId !== this.currentUserId ? html`
|
${member.userId !== this.currentUserId ? html`
|
||||||
<div class="member-actions">
|
<div class="member-actions">
|
||||||
<button
|
${this.isOwner && !member.isOwner ? html`
|
||||||
class="action-button danger"
|
<button
|
||||||
@click=${() => this.handleRemoveMember(member.userId, member.name)}
|
class="action-button"
|
||||||
?disabled=${this.submitting || member.isOwner}
|
@click=${() => this.handleTransferOwnership(member.userId, member.name)}
|
||||||
title=${member.isOwner ? 'Cannot remove owner' : 'Remove member'}
|
?disabled=${this.submitting}
|
||||||
>
|
title="Transfer ownership to this member"
|
||||||
Remove
|
>
|
||||||
</button>
|
Transfer Ownership
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
${this.isAdmin ? html`
|
||||||
|
<button
|
||||||
|
class="action-button danger"
|
||||||
|
@click=${() => this.handleRemoveMember(member.userId, member.name)}
|
||||||
|
?disabled=${this.submitting || member.isOwner}
|
||||||
|
title=${member.isOwner ? 'Cannot remove owner' : 'Remove member'}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
@@ -560,23 +578,40 @@ export class UsersView extends DeesElement {
|
|||||||
.status=${this.submitting ? 'pending' : 'normal'}
|
.status=${this.submitting ? 'pending' : 'normal'}
|
||||||
@click=${() => this.handleSendInvitation()}
|
@click=${() => this.handleSendInvitation()}
|
||||||
></dees-button>
|
></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>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async firstUpdated() {
|
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() {
|
private async loadData() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
||||||
@@ -598,11 +633,12 @@ export class UsersView extends DeesElement {
|
|||||||
this.organizationName = selectedOrg.data.name;
|
this.organizationName = selectedOrg.data.name;
|
||||||
this.currentUserId = currentState.user?.id || '';
|
this.currentUserId = currentState.user?.id || '';
|
||||||
|
|
||||||
// Check if current user is admin
|
// Check if current user is admin/owner
|
||||||
const currentUserRole = currentState.roles.find(
|
const currentUserRole = currentState.roles.find(
|
||||||
r => r.data.organizationId === this.organizationId && r.data.userId === this.currentUserId
|
r => r.data.organizationId === this.organizationId && r.data.userId === this.currentUserId
|
||||||
);
|
);
|
||||||
this.isAdmin = currentUserRole?.data?.roles?.some(r => ['owner', 'admin'].includes(r)) ?? false;
|
this.isAdmin = currentUserRole?.data?.roles?.some(r => ['owner', 'admin'].includes(r)) ?? false;
|
||||||
|
this.isOwner = currentUserRole?.data?.roles?.includes('owner') ?? false;
|
||||||
|
|
||||||
// Get JWT from IdpState
|
// Get JWT from IdpState
|
||||||
const idpState = await IdpState.getSingletonInstance();
|
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) {
|
private showMessage(type: 'success' | 'error', text: string) {
|
||||||
this.actionMessage = { type, text };
|
this.actionMessage = { type, text };
|
||||||
// Auto-hide after 5 seconds
|
// Auto-hide after 5 seconds
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ export const manifestNewOrgName = accountState.createAction(async (statePartArg,
|
|||||||
'manifest'
|
'manifest'
|
||||||
);
|
);
|
||||||
currentState.organizations.push(result.resultingOrganization);
|
currentState.organizations.push(result.resultingOrganization);
|
||||||
|
if (result.role) {
|
||||||
|
currentState.roles.push(result.role);
|
||||||
|
}
|
||||||
currentState.selectedOrg = result.resultingOrganization;
|
currentState.selectedOrg = result.resultingOrganization;
|
||||||
return currentState;
|
return currentState;
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user