Files
app/ts_web/elements/account/views/usersview.ts
T

953 lines
26 KiB
TypeScript

import * as plugins from '../../../plugins.js';
import {
customElement,
DeesElement,
html,
cssManager,
css,
state,
type TemplateResult,
} from '@design.estate/dees-element';
import * as sharedStyles 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 {
'lele-accountview-users': UsersView;
}
}
interface IMemberDisplay {
userId: string;
name: string;
email: string;
roles: string[];
isOwner: boolean;
}
interface IInvitationDisplay {
id: string;
email: string;
roles: string[];
invitedAt: number;
expiresAt: number;
}
@customElement('lele-accountview-users')
export class UsersView extends DeesElement {
@state()
accessor members: IMemberDisplay[] = [];
@state()
accessor invitations: IInvitationDisplay[] = [];
@state()
accessor loading: boolean = true;
@state()
accessor activeTab: 'members' | 'pending' | 'invite' = 'members';
@state()
accessor organizationId: string = '';
@state()
accessor organizationName: string = '';
@state()
accessor organizationSlug: string = '';
@state()
accessor inviteEmail: string = '';
@state()
accessor inviteRoles: string[] = ['viewer'];
@state()
accessor isAdmin: boolean = false;
@state()
accessor isOwner: boolean = false;
@state()
accessor currentUserId: string = '';
@state()
accessor submitting: boolean = false;
@state()
accessor actionMessage: { type: 'success' | 'error'; text: string } | null = null;
private static readonly AVAILABLE_ROLES = ['owner', 'admin', 'editor', 'viewer', 'guest'];
private emailInputSubscribed: boolean = false;
public static styles = [
cssManager.defaultStyles,
sharedStyles.accountDesignTokens,
sharedStyles.viewBaseStyles,
sharedStyles.cardStyles,
sharedStyles.typographyStyles,
css`
:host {
padding: 48px;
max-width: 1000px;
margin: 0 auto;
}
.tabs {
display: flex;
gap: 4px;
margin-bottom: 32px;
border-bottom: 1px solid var(--border);
padding-bottom: 8px;
}
.tab {
padding: 10px 20px;
border-radius: 8px 8px 0 0;
font-size: 14px;
font-weight: 500;
color: var(--muted-foreground);
cursor: pointer;
transition: all 0.15s ease;
border: none;
background: transparent;
}
.tab:hover {
color: var(--foreground);
background: var(--muted);
}
.tab.active {
color: var(--foreground);
background: var(--muted);
}
.member-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.member-card {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px 20px;
transition: all 0.15s ease;
}
.member-card:hover {
border-color: var(--muted-foreground);
}
.member-info {
display: flex;
align-items: center;
gap: 16px;
}
.member-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--muted);
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 600;
color: var(--foreground);
}
.member-details {
display: flex;
flex-direction: column;
gap: 2px;
}
.member-name {
font-size: 14px;
font-weight: 600;
color: var(--foreground);
}
.member-email {
font-size: 13px;
color: var(--muted-foreground);
}
.member-roles {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.role-badge {
padding: 4px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.role-badge.owner {
background: rgba(234, 179, 8, 0.2);
color: #eab308;
}
.role-badge.admin {
background: rgba(59, 130, 246, 0.2);
color: #3b82f6;
}
.role-badge.editor {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
.role-badge.viewer {
background: rgba(148, 163, 184, 0.2);
color: #94a3b8;
}
.role-badge.guest {
background: rgba(168, 162, 158, 0.2);
color: #a8a29e;
}
.member-actions {
display: flex;
gap: 8px;
}
.action-button {
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
border: 1px solid var(--border);
background: transparent;
color: var(--muted-foreground);
cursor: pointer;
transition: all 0.15s ease;
}
.action-button:hover {
border-color: var(--foreground);
color: var(--foreground);
}
.action-button.danger:hover {
border-color: #ef4444;
color: #ef4444;
}
.action-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.invitation-card {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px 20px;
}
.invitation-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.invitation-email {
font-size: 14px;
font-weight: 500;
color: var(--foreground);
}
.invitation-meta {
font-size: 12px;
color: var(--muted-foreground);
}
.invite-form {
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
}
.form-group {
margin-bottom: 20px;
}
.form-label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--foreground);
margin-bottom: 8px;
}
.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;
}
.message {
padding: 12px 16px;
border-radius: 8px;
font-size: 13px;
margin-bottom: 20px;
}
.message.success {
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
border: 1px solid rgba(34, 197, 94, 0.3);
}
.message.error {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
border: 1px solid rgba(239, 68, 68, 0.3);
}
.empty-state {
text-align: center;
padding: 48px;
color: var(--muted-foreground);
}
.empty-state dees-icon {
font-size: 48px;
opacity: 0.5;
margin-bottom: 16px;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 48px;
color: var(--muted-foreground);
}
.you-badge {
font-size: 10px;
padding: 2px 6px;
background: rgba(59, 130, 246, 0.2);
color: #3b82f6;
border-radius: 4px;
margin-left: 8px;
}
`,
];
public render() {
return html`
<h1>Users</h1>
<p>Manage members and invitations for ${this.organizationName || 'your organization'}.</p>
${this.actionMessage ? html`
<div class="message ${this.actionMessage.type}">${this.actionMessage.text}</div>
` : ''}
<div class="tabs">
<button
class="tab ${this.activeTab === 'members' ? 'active' : ''}"
@click=${() => this.activeTab = 'members'}
>
Members (${this.members.length})
</button>
<button
class="tab ${this.activeTab === 'pending' ? 'active' : ''}"
@click=${() => this.activeTab = 'pending'}
>
Pending (${this.invitations.length})
</button>
${this.isAdmin ? html`
<button
class="tab ${this.activeTab === 'invite' ? 'active' : ''}"
@click=${() => this.activeTab = 'invite'}
>
Invite
</button>
` : ''}
</div>
${this.renderTabContent()}
`;
}
private renderTabContent() {
if (this.loading) {
return html`
<div class="loading">
<span>Loading users...</span>
</div>
`;
}
switch (this.activeTab) {
case 'members':
return this.renderMembers();
case 'pending':
return this.renderPendingInvitations();
case 'invite':
return this.renderInviteForm();
}
}
private renderMembers() {
if (this.members.length === 0) {
return html`
<div class="empty-state">
<dees-icon .icon=${'lucide:users'}></dees-icon>
<h2>No Members</h2>
<p>This organization has no members yet.</p>
</div>
`;
}
return html`
<div class="member-list">
${this.members.map(member => html`
<div class="member-card">
<div class="member-info">
<div class="member-avatar">
${member.name.charAt(0).toUpperCase()}
</div>
<div class="member-details">
<span class="member-name">
${member.name}
${member.userId === this.currentUserId ? html`<span class="you-badge">You</span>` : ''}
</span>
<span class="member-email">${member.email}</span>
</div>
</div>
<div class="member-roles">
${member.roles.map(role => html`
<span class="role-badge ${role}">${role}</span>
`)}
</div>
${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)}
?disabled=${this.submitting || member.isOwner}
title=${member.isOwner ? 'Cannot remove owner' : 'Remove member'}
>
Remove
</button>
` : ''}
</div>
` : ''}
</div>
`)}
</div>
`;
}
private renderPendingInvitations() {
if (this.invitations.length === 0) {
return html`
<div class="empty-state">
<dees-icon .icon=${'lucide:mail'}></dees-icon>
<h2>No Pending Invitations</h2>
<p>There are no pending invitations for this organization.</p>
</div>
`;
}
return html`
<div class="member-list">
${this.invitations.map(inv => html`
<div class="invitation-card">
<div class="invitation-info">
<span class="invitation-email">${inv.email}</span>
<span class="invitation-meta">
Invited ${this.formatDate(inv.invitedAt)} · Expires ${this.formatDate(inv.expiresAt)}
</span>
</div>
<div class="member-roles">
${inv.roles.map(role => html`
<span class="role-badge ${role}">${role}</span>
`)}
</div>
${this.isAdmin ? html`
<div class="member-actions">
<button
class="action-button"
@click=${() => this.handleResendInvitation(inv.id)}
?disabled=${this.submitting}
>
Resend
</button>
<button
class="action-button danger"
@click=${() => this.handleCancelInvitation(inv.id, inv.email)}
?disabled=${this.submitting}
>
Cancel
</button>
</div>
` : ''}
</div>
`)}
</div>
`;
}
private renderInviteForm(): TemplateResult {
return html`
<div class="invite-form">
<div class="form-group">
<label class="form-label">Email Address</label>
<dees-input-text
.label=${''}
.placeholder=${'Enter email address'}
.value=${this.inviteEmail}
?disabled=${this.submitting}
></dees-input-text>
</div>
<div class="form-group">
<label class="form-label">Role</label>
<div class="role-selector">
${UsersView.AVAILABLE_ROLES.filter(r => r !== 'owner').map(role => html`
<button
class="role-option ${this.inviteRoles.includes(role) ? 'selected' : ''}"
@click=${() => this.toggleRole(role)}
?disabled=${this.submitting}
>
${role}
</button>
`)}
</div>
</div>
<dees-button
.text=${'Send Invitation'}
.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() {
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() {
this.loading = true;
try {
// Get the organization from URL
const pathParts = window.location.pathname.split('/');
const orgSlug = pathParts[3];
const currentState = accountState.accountState.getState();
const selectedOrg = currentState.organizations.find(org => org.data.slug === orgSlug);
if (!selectedOrg) {
console.error('Organization not found');
this.loading = false;
return;
}
this.organizationId = selectedOrg.id;
this.organizationName = selectedOrg.data.name;
this.organizationSlug = selectedOrg.data.slug;
this.currentUserId = currentState.user?.id || '';
// 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();
const jwt = await idpState.idpClient.getJwt();
// Fetch members
const membersRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetOrgMembers>(
'getOrgMembers'
);
const membersResponse = await membersRequest.fire({
jwt,
organizationId: this.organizationId,
});
this.members = membersResponse.members.map(m => ({
userId: m.user.id,
name: m.user.data.name || m.user.data.username || 'Unknown',
email: m.user.data.email,
roles: m.role.data.roles || [],
isOwner: m.role.data.roles?.includes('owner') ?? false,
}));
// Fetch invitations if admin
if (this.isAdmin) {
const invitationsRequest = idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_GetOrgInvitations>(
'getOrgInvitations'
);
const invitationsResponse = await invitationsRequest.fire({
jwt,
organizationId: this.organizationId,
});
this.invitations = invitationsResponse.invitations.map(inv => {
const orgRef = inv.data.organizationRefs.find(ref => ref.organizationId === this.organizationId);
return {
id: inv.id,
email: inv.data.email,
roles: orgRef?.roles || [],
invitedAt: orgRef?.invitedAt || inv.data.createdAt,
expiresAt: inv.data.expiresAt,
};
});
}
} catch (error) {
console.error('Error loading users:', error);
} finally {
this.loading = false;
}
}
private toggleRole(role: string) {
if (this.inviteRoles.includes(role)) {
this.inviteRoles = this.inviteRoles.filter(r => r !== role);
} else {
this.inviteRoles = [...this.inviteRoles, role];
}
// Ensure at least one role is selected
if (this.inviteRoles.length === 0) {
this.inviteRoles = ['viewer'];
}
}
private async handleSendInvitation() {
if (!this.inviteEmail.trim()) {
this.showMessage('error', 'Please enter an email address.');
return;
}
if (this.inviteRoles.length === 0) {
this.showMessage('error', 'Please select at least one role.');
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_CreateInvitation>(
'createInvitation'
);
const response = await request.fire({
jwt,
organizationId: this.organizationId,
email: this.inviteEmail.trim(),
roles: this.inviteRoles,
});
if (response.success) {
this.showMessage('success', response.message || 'Invitation sent successfully!');
this.inviteEmail = '';
this.inviteRoles = ['viewer'];
await this.loadData();
this.activeTab = 'pending';
} else {
this.showMessage('error', response.message || 'Failed to send invitation.');
}
} catch (error) {
console.error('Error sending invitation:', error);
this.showMessage('error', 'Failed to send invitation. Please try again.');
} finally {
this.submitting = false;
}
}
private async handleResendInvitation(invitationId: string) {
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_ResendInvitation>(
'resendInvitation'
);
const response = await request.fire({
jwt,
organizationId: this.organizationId,
invitationId,
});
if (response.success) {
this.showMessage('success', 'Invitation resent successfully!');
await this.loadData();
} else {
this.showMessage('error', response.message || 'Failed to resend invitation.');
}
} catch (error) {
console.error('Error resending invitation:', error);
this.showMessage('error', 'Failed to resend invitation. Please try again.');
} finally {
this.submitting = false;
}
}
private async handleCancelInvitation(invitationId: string, email: string) {
if (!confirm(`Cancel invitation for ${email}?`)) {
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_CancelInvitation>(
'cancelInvitation'
);
const response = await request.fire({
jwt,
organizationId: this.organizationId,
invitationId,
});
if (response.success) {
this.showMessage('success', 'Invitation cancelled.');
await this.loadData();
} else {
this.showMessage('error', response.message || 'Failed to cancel invitation.');
}
} catch (error) {
console.error('Error cancelling invitation:', error);
this.showMessage('error', 'Failed to cancel invitation. Please try again.');
} finally {
this.submitting = false;
}
}
private async handleRemoveMember(userId: string, name: string) {
if (!confirm(`Remove ${name} from this organization?`)) {
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_RemoveMember>(
'removeMember'
);
const response = await request.fire({
jwt,
organizationId: this.organizationId,
userId,
});
if (response.success) {
this.showMessage('success', `${name} has been removed from the organization.`);
await this.loadData();
} else {
this.showMessage('error', response.message || 'Failed to remove member.');
}
} catch (error) {
console.error('Error removing member:', error);
this.showMessage('error', 'Failed to remove member. Please try again.');
} finally {
this.submitting = false;
}
}
private async handleTransferOwnership(newOwnerId: string, name: string) {
const confirmationText = await this.showTransferConfirmation(name);
if (!confirmationText) 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,
confirmationText,
});
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<string | null> {
return new Promise((resolve) => {
const expectedText = `transfer ${this.organizationSlug}`;
let confirmationText = '';
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>
<p style="margin: 12px 0 8px 0; color: var(--muted-foreground);">
Type <code>${expectedText}</code> to confirm.
</p>
<input style="box-sizing:border-box;width:100%;padding:8px;border:1px solid var(--border);border-radius:8px;" @input=${(eventArg: Event) => { confirmationText = (eventArg.target as HTMLInputElement).value; }} />
</div>
`,
menuOptions: [
{ name: 'Cancel', action: async (modal) => { modal.destroy(); resolve(null); } },
{ name: 'Transfer Ownership', action: async (modal) => { modal.destroy(); resolve(confirmationText.trim() === expectedText ? confirmationText.trim() : null); } },
],
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
setTimeout(() => {
this.actionMessage = null;
}, 5000);
}
private formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
}