2026-04-08 09:01:08 +00:00
|
|
|
import * as appstate from '../../appstate.js';
|
|
|
|
|
import { viewHostCss } from '../shared/css.js';
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
DeesElement,
|
|
|
|
|
css,
|
|
|
|
|
cssManager,
|
|
|
|
|
customElement,
|
|
|
|
|
html,
|
|
|
|
|
state,
|
|
|
|
|
type TemplateResult,
|
|
|
|
|
} from '@design.estate/dees-element';
|
|
|
|
|
|
|
|
|
|
@customElement('ops-view-users')
|
|
|
|
|
export class OpsViewUsers extends DeesElement {
|
|
|
|
|
@state() accessor usersState: appstate.IUsersState = {
|
|
|
|
|
users: [],
|
|
|
|
|
isLoading: false,
|
|
|
|
|
error: null,
|
|
|
|
|
lastUpdated: 0,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
@state() accessor loginState: appstate.ILoginState = {
|
|
|
|
|
identity: null,
|
|
|
|
|
isLoggedIn: false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
constructor() {
|
|
|
|
|
super();
|
|
|
|
|
const usersSub = appstate.usersStatePart
|
|
|
|
|
.select((s) => s)
|
|
|
|
|
.subscribe((usersState) => {
|
|
|
|
|
this.usersState = usersState;
|
|
|
|
|
});
|
|
|
|
|
this.rxSubscriptions.push(usersSub);
|
|
|
|
|
|
|
|
|
|
const loginSub = appstate.loginStatePart
|
|
|
|
|
.select((s) => s)
|
|
|
|
|
.subscribe((loginState) => {
|
|
|
|
|
this.loginState = loginState;
|
|
|
|
|
// Re-fetch users when user logs in (fixes race condition where
|
|
|
|
|
// the view is created before authentication completes)
|
|
|
|
|
if (loginState.isLoggedIn) {
|
|
|
|
|
appstate.usersStatePart.dispatchAction(appstate.fetchUsersAction, null);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
this.rxSubscriptions.push(loginSub);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static styles = [
|
|
|
|
|
cssManager.defaultStyles,
|
|
|
|
|
viewHostCss,
|
|
|
|
|
css`
|
|
|
|
|
.usersContainer {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 24px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.roleBadge {
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
padding: 3px 10px;
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
letter-spacing: 0.02em;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.roleBadge.admin {
|
|
|
|
|
background: ${cssManager.bdTheme('#fef3c7', '#451a03')};
|
|
|
|
|
color: ${cssManager.bdTheme('#92400e', '#fbbf24')};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.roleBadge.user {
|
|
|
|
|
background: ${cssManager.bdTheme('#e0f2fe', '#0c4a6e')};
|
|
|
|
|
color: ${cssManager.bdTheme('#075985', '#7dd3fc')};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.sessionBadge {
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
padding: 3px 10px;
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
letter-spacing: 0.02em;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
background: ${cssManager.bdTheme('#dcfce7', '#14532d')};
|
|
|
|
|
color: ${cssManager.bdTheme('#166534', '#4ade80')};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.userIdCell {
|
|
|
|
|
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, monospace;
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
|
|
|
|
}
|
|
|
|
|
`,
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
public render(): TemplateResult {
|
|
|
|
|
const { users } = this.usersState;
|
|
|
|
|
const currentUserId = this.loginState.identity?.userId;
|
|
|
|
|
|
|
|
|
|
return html`
|
2026-04-08 11:08:18 +00:00
|
|
|
<dees-heading level="3">Users</dees-heading>
|
2026-04-08 09:01:08 +00:00
|
|
|
|
|
|
|
|
<div class="usersContainer">
|
|
|
|
|
<dees-table
|
|
|
|
|
.heading1=${'Users'}
|
|
|
|
|
.heading2=${'OpsServer user accounts'}
|
|
|
|
|
.data=${users}
|
|
|
|
|
.dataName=${'user'}
|
|
|
|
|
.searchable=${true}
|
|
|
|
|
.showColumnFilters=${true}
|
|
|
|
|
.displayFunction=${(user: appstate.IUser) => ({
|
|
|
|
|
ID: html`<span class="userIdCell">${user.id}</span>`,
|
2026-05-19 17:06:50 +00:00
|
|
|
Email: user.email || user.username,
|
|
|
|
|
Name: user.name || '',
|
2026-04-08 09:01:08 +00:00
|
|
|
Role: this.renderRoleBadge(user.role),
|
2026-05-19 17:06:50 +00:00
|
|
|
Status: user.status || 'active',
|
|
|
|
|
Auth: (user.authSources || []).join(', ') || 'bootstrap',
|
2026-04-08 09:01:08 +00:00
|
|
|
Session: user.id === currentUserId
|
|
|
|
|
? html`<span class="sessionBadge">current</span>`
|
|
|
|
|
: '',
|
|
|
|
|
})}
|
2026-05-19 17:06:50 +00:00
|
|
|
.dataActions=${[
|
|
|
|
|
{
|
|
|
|
|
name: 'Create User',
|
|
|
|
|
iconName: 'lucide:userPlus',
|
|
|
|
|
type: ['header'],
|
|
|
|
|
actionFunc: async () => await this.showCreateUserDialog(),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: 'Delete',
|
|
|
|
|
iconName: 'lucide:trash2',
|
|
|
|
|
type: ['inRow', 'contextmenu'] as any,
|
|
|
|
|
actionFunc: async (actionData: any) => {
|
|
|
|
|
await this.showDeleteUserDialog(actionData.item as appstate.IUser);
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
]}
|
2026-04-08 09:01:08 +00:00
|
|
|
></dees-table>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private renderRoleBadge(role: string): TemplateResult {
|
|
|
|
|
const cls = role === 'admin' ? 'admin' : 'user';
|
|
|
|
|
return html`<span class="roleBadge ${cls}">${role}</span>`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 17:06:50 +00:00
|
|
|
private async showCreateUserDialog(): Promise<void> {
|
|
|
|
|
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
|
|
|
|
|
|
|
|
|
|
await DeesModal.createAndShow({
|
|
|
|
|
heading: 'Create User',
|
|
|
|
|
content: html`
|
|
|
|
|
<dees-form>
|
|
|
|
|
<dees-input-text .key=${'email'} .label=${'Email'} .required=${true}></dees-input-text>
|
|
|
|
|
<dees-input-text .key=${'name'} .label=${'Display name'}></dees-input-text>
|
|
|
|
|
<dees-input-dropdown
|
|
|
|
|
.key=${'role'}
|
|
|
|
|
.label=${'Role'}
|
|
|
|
|
.options=${[
|
|
|
|
|
{ option: 'User', key: 'user' },
|
|
|
|
|
{ option: 'Admin', key: 'admin' },
|
|
|
|
|
]}
|
|
|
|
|
.selectedOption=${{ option: 'User', key: 'user' }}
|
|
|
|
|
.required=${true}
|
|
|
|
|
></dees-input-dropdown>
|
|
|
|
|
<dees-input-text .key=${'password'} .label=${'Password'} .required=${true} .isPasswordBool=${true}></dees-input-text>
|
|
|
|
|
<dees-input-text .key=${'passwordConfirm'} .label=${'Confirm password'} .required=${true} .isPasswordBool=${true}></dees-input-text>
|
|
|
|
|
<dees-input-checkbox
|
|
|
|
|
.key=${'enableIdpGlobalAuth'}
|
|
|
|
|
.label=${'Allow idp.global login for this email'}
|
|
|
|
|
.description=${'Uses https://idp.global by default; the local dcrouter account and role remain authoritative.'}
|
|
|
|
|
></dees-input-checkbox>
|
|
|
|
|
</dees-form>
|
|
|
|
|
`,
|
|
|
|
|
menuOptions: [
|
|
|
|
|
{
|
|
|
|
|
name: 'Cancel',
|
|
|
|
|
iconName: 'lucide:x',
|
|
|
|
|
action: async (modalArg: any) => await modalArg.destroy(),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: 'Create',
|
|
|
|
|
iconName: 'lucide:userPlus',
|
|
|
|
|
action: async (modalArg: any) => {
|
|
|
|
|
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form') as any;
|
|
|
|
|
if (!form) return;
|
|
|
|
|
const data = await form.collectFormData();
|
|
|
|
|
const email = String(data.email || '').trim();
|
|
|
|
|
const name = String(data.name || '').trim();
|
|
|
|
|
const password = String(data.password || '');
|
|
|
|
|
const passwordConfirm = String(data.passwordConfirm || '');
|
|
|
|
|
const roleValue = String(data.role?.key ?? data.role ?? 'user');
|
|
|
|
|
|
|
|
|
|
if (!email || !password) {
|
|
|
|
|
form.setStatus?.('error', 'Email and password are required.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (password !== passwordConfirm) {
|
|
|
|
|
form.setStatus?.('error', 'Passwords do not match.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
form.setStatus?.('pending', 'Creating user...');
|
|
|
|
|
await appstate.usersStatePart.dispatchAction(appstate.createUserAction, {
|
|
|
|
|
email,
|
|
|
|
|
name,
|
|
|
|
|
role: roleValue === 'admin' ? 'admin' : 'user',
|
|
|
|
|
password,
|
|
|
|
|
enableIdpGlobalAuth: Boolean(data.enableIdpGlobalAuth),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const state = appstate.usersStatePart.getState();
|
|
|
|
|
if (state?.error) {
|
|
|
|
|
form.setStatus?.('error', state.error);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
DeesToast.show({ message: `User created for ${email}`, type: 'success', duration: 3000 });
|
|
|
|
|
await modalArg.destroy();
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async showDeleteUserDialog(userArg: appstate.IUser): Promise<void> {
|
|
|
|
|
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
|
|
|
|
|
const currentUserId = this.loginState.identity?.userId;
|
|
|
|
|
if (userArg.id === currentUserId) {
|
|
|
|
|
DeesToast.show({ message: 'You cannot delete the current user.', type: 'error', duration: 4000 });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await DeesModal.createAndShow({
|
|
|
|
|
heading: 'Delete User',
|
|
|
|
|
content: html`
|
|
|
|
|
<div style="padding: 8px 0; font-size: 14px; line-height: 1.5;">
|
|
|
|
|
<p>Delete <strong>${userArg.email || userArg.username}</strong>?</p>
|
|
|
|
|
<p style="color: #f59e0b; margin-top: 12px;">This removes the local dcrouter account and cannot be undone.</p>
|
|
|
|
|
</div>
|
|
|
|
|
`,
|
|
|
|
|
menuOptions: [
|
|
|
|
|
{
|
|
|
|
|
name: 'Cancel',
|
|
|
|
|
iconName: 'lucide:x',
|
|
|
|
|
action: async (modalArg: any) => await modalArg.destroy(),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: 'Delete',
|
|
|
|
|
iconName: 'lucide:trash2',
|
|
|
|
|
action: async (modalArg: any) => {
|
|
|
|
|
await appstate.usersStatePart.dispatchAction(appstate.deleteUserAction, userArg.id);
|
|
|
|
|
const state = appstate.usersStatePart.getState();
|
|
|
|
|
if (state?.error) {
|
|
|
|
|
DeesToast.show({ message: state.error, type: 'error', duration: 4000 });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
DeesToast.show({ message: 'User deleted.', type: 'success', duration: 3000 });
|
|
|
|
|
await modalArg.destroy();
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 09:01:08 +00:00
|
|
|
async firstUpdated() {
|
|
|
|
|
if (this.loginState.isLoggedIn) {
|
|
|
|
|
await appstate.usersStatePart.dispatchAction(appstate.fetchUsersAction, null);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|