feat(reception): Add activity logging, session metadata and org-selection UI (backend and frontend)

This commit is contained in:
2025-12-01 18:56:16 +00:00
parent d11f5a0c72
commit 8756258324
20 changed files with 2512 additions and 192 deletions
+14
View File
@@ -1,5 +1,19 @@
# Changelog # Changelog
## 2025-12-01 - 1.8.0 - feat(reception)
Add activity logging, session metadata and org-selection UI (backend and frontend)
- Introduce ActivityLog and ActivityLogManager to track user actions (TActivityAction, IActivityLog) for audit/display.
- Export new activity interface (IActivityLog) from ts_interfaces and add type TActivityAction.
- Wire ActivityLogManager into Reception so activity logging is available via the typed router.
- Enhance LoginSession data model with deviceInfo, createdAt and lastActive fields for richer session metadata.
- Add getUserSessions typed handler to return detailed session list (device, browser, os, ip, createdAt, lastActive, isCurrent).
- Revoke session endpoint now logs a 'session_revoked' activity when a session is revoked (and blocks revoking the current session).
- Add request interfaces IReq_GetUserSessions and IReq_GetUserActivity to typed request definitions.
- Frontend: account element now includes org-select and create-org modals, OrgView route, and handlers to open modals and navigate to new org/billing pages.
- Frontend: organization dropdown adds a '+ Create new...' option and wiring to open the creation modal.
- Minor refactors and routing exports: account index exports new modal components and views updated (OrgView).
## 2025-12-01 - 1.7.0 - feat(admin) ## 2025-12-01 - 1.7.0 - feat(admin)
Add global admin functionality: backend admin APIs, model fields and UI integration Add global admin functionality: backend admin APIs, model fields and UI integration
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@idp.global/idp.global', name: '@idp.global/idp.global',
version: '1.7.0', version: '1.8.0',
description: 'An identity provider software managing user authentications, registrations, and sessions.' description: 'An identity provider software managing user authentications, registrations, and sessions.'
} }
+62
View File
@@ -0,0 +1,62 @@
import * as plugins from '../plugins.js';
import { ActivityLogManager } from './classes.activitylogmanager.js';
/**
* ActivityLog tracks user actions for audit and display purposes
*/
@plugins.smartdata.Manager()
export class ActivityLog extends plugins.smartdata.SmartDataDbDoc<
ActivityLog,
plugins.idpInterfaces.data.IActivityLog,
ActivityLogManager
> {
// ======
// static
// ======
public static async createActivityLog(
managerArg: ActivityLogManager,
userId: string,
action: plugins.idpInterfaces.data.TActivityAction,
description: string,
metadata?: {
ip?: string;
userAgent?: string;
targetId?: string;
targetType?: string;
}
) {
const activityLog = new managerArg.CActivityLog();
activityLog.id = plugins.smartunique.shortId();
activityLog.data = {
userId,
action,
timestamp: Date.now(),
metadata: {
description,
...metadata,
},
};
await activityLog.save();
return activityLog;
}
// ========
// INSTANCE
// ========
@plugins.smartdata.unI()
public id: string;
@plugins.smartdata.svDb()
public data: plugins.idpInterfaces.data.IActivityLog['data'] = {
userId: null,
action: null,
timestamp: null,
metadata: {
description: null,
},
};
constructor() {
super();
}
}
@@ -0,0 +1,77 @@
import * as plugins from '../plugins.js';
import { ActivityLog } from './classes.activitylog.js';
import { Reception } from './classes.reception.js';
export class ActivityLogManager {
// refs
public receptionRef: Reception;
public get db() {
return this.receptionRef.db.smartdataDb;
}
public CActivityLog = plugins.smartdata.setDefaultManagerForDoc(this, ActivityLog);
public typedRouter = new plugins.typedrequest.TypedRouter();
constructor(receptionRefArg: Reception) {
this.receptionRef = receptionRefArg;
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
// Get user activity handler
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetUserActivity>(
'getUserActivity',
async (requestArg) => {
const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
if (!jwt) {
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
}
const limit = requestArg.limit || 20;
const offset = requestArg.offset || 0;
// Get activities for this user
const activities = await this.CActivityLog.getInstances({
'data.userId': jwt.data.userId,
});
// Sort by timestamp descending
const sortedActivities = activities
.sort((a, b) => b.data.timestamp - a.data.timestamp)
.slice(offset, offset + limit);
return {
activities: sortedActivities.map((a) => ({
id: a.id,
data: a.data,
})),
total: activities.length,
};
}
)
);
}
/**
* Log a user activity
*/
public async logActivity(
userId: string,
action: plugins.idpInterfaces.data.TActivityAction,
description: string,
metadata?: {
ip?: string;
userAgent?: string;
targetId?: string;
targetType?: string;
}
) {
return await ActivityLog.createActivityLog(
this,
userId,
action,
description,
metadata
);
}
}
+4 -1
View File
@@ -60,7 +60,10 @@ export class LoginSession extends plugins.smartdata.SmartDataDbDoc<
validUntil: Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ weeks: 1 }), validUntil: Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ weeks: 1 }),
invalidated: false, invalidated: false,
refreshToken: null, refreshToken: null,
deviceId: null deviceId: null,
deviceInfo: null,
createdAt: Date.now(),
lastActive: Date.now(),
}; };
public transferToken: string; public transferToken: string;
@@ -259,6 +259,83 @@ export class LoginSessionManager {
ok: false ok: false
} }
}) })
);
// Get all sessions for the current user
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetUserSessions>(
'getUserSessions',
async (requestArg) => {
const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
if (!jwt) {
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
}
// Get the current session's refresh token to identify the current session
const currentRefreshToken = jwt.data.refreshToken;
// Get all sessions for this user
const sessions = await this.CLoginSession.getInstances({
'data.userId': jwt.data.userId,
'data.invalidated': false,
});
return {
sessions: sessions.map((session) => ({
id: session.id,
deviceId: session.data.deviceId || 'unknown',
deviceName: session.data.deviceInfo?.deviceName || 'Unknown Device',
browser: session.data.deviceInfo?.browser || 'Unknown Browser',
os: session.data.deviceInfo?.os || 'Unknown OS',
ip: session.data.deviceInfo?.ip || 'Unknown',
lastActive: session.data.lastActive || session.data.createdAt || Date.now(),
createdAt: session.data.createdAt || Date.now(),
isCurrent: session.data.refreshToken === currentRefreshToken,
})),
};
}
) )
);
// Revoke a specific session
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_RevokeSession>(
'revokeSession',
async (requestArg) => {
const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
if (!jwt) {
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
}
// Get the session to revoke
const sessionToRevoke = await this.CLoginSession.getInstance({
id: requestArg.sessionId,
'data.userId': jwt.data.userId, // Ensure user can only revoke their own sessions
});
if (!sessionToRevoke) {
throw new plugins.typedrequest.TypedResponseError('Session not found');
}
// Don't allow revoking the current session via this method
if (sessionToRevoke.data.refreshToken === jwt.data.refreshToken) {
throw new plugins.typedrequest.TypedResponseError(
'Cannot revoke current session. Use logout instead.'
);
}
await sessionToRevoke.invalidate();
// Log the activity
await this.receptionRef.activityLogManager.logActivity(
jwt.data.userId,
'session_revoked',
`Revoked session on ${sessionToRevoke.data.deviceInfo?.deviceName || 'unknown device'}`
);
return { success: true };
}
)
);
} }
} }
+2
View File
@@ -15,6 +15,7 @@ import { RoleManager } from './classes.rolemanager.js';
import { BillingPlanManager } from './classes.billingplanmanager.js'; import { BillingPlanManager } from './classes.billingplanmanager.js';
import { AppManager } from './classes.appmanager.js'; import { AppManager } from './classes.appmanager.js';
import { AppConnectionManager } from './classes.appconnectionmanager.js'; import { AppConnectionManager } from './classes.appconnectionmanager.js';
import { ActivityLogManager } from './classes.activitylogmanager.js';
export interface IReceptionOptions { export interface IReceptionOptions {
/** /**
@@ -45,6 +46,7 @@ export class Reception {
public billingPlanManager = new BillingPlanManager(this); public billingPlanManager = new BillingPlanManager(this);
public appManager = new AppManager(this); public appManager = new AppManager(this);
public appConnectionManager = new AppConnectionManager(this); public appConnectionManager = new AppConnectionManager(this);
public activityLogManager = new ActivityLogManager(this);
housekeeping = new ReceptionHousekeeping(this); housekeeping = new ReceptionHousekeeping(this);
constructor(public options: IReceptionOptions) { constructor(public options: IReceptionOptions) {
+1
View File
@@ -1,3 +1,4 @@
export * from './loint-reception.activity.js';
export * from './loint-reception.app.js'; export * from './loint-reception.app.js';
export * from './loint-reception.appconnection.js'; export * from './loint-reception.appconnection.js';
export * from './loint-reception.billingplan.js'; export * from './loint-reception.billingplan.js';
@@ -0,0 +1,28 @@
export type TActivityAction =
| 'login'
| 'logout'
| 'session_created'
| 'session_revoked'
| 'org_created'
| 'org_joined'
| 'org_left'
| 'role_changed'
| 'profile_updated'
| 'app_connected'
| 'app_disconnected';
export interface IActivityLog {
id: string;
data: {
userId: string;
action: TActivityAction;
timestamp: number;
metadata: {
ip?: string;
userAgent?: string;
targetId?: string;
targetType?: string;
description: string;
};
};
}
@@ -10,5 +10,22 @@ export interface ILoginSession {
* in different contexts on the same device * in different contexts on the same device
*/ */
deviceId: string; deviceId: string;
/**
* Device metadata for session display
*/
deviceInfo?: {
deviceName: string;
browser: string;
os: string;
ip: string;
};
/**
* When this session was created
*/
createdAt?: number;
/**
* Last time this session was active (e.g., refreshed)
*/
lastActive?: number;
}; };
} }
@@ -84,3 +84,59 @@ export interface IReq_WhoIs {
user: data.IUser; user: data.IUser;
}; };
} }
export interface IReq_GetUserSessions
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_GetUserSessions
> {
method: 'getUserSessions';
request: {
jwt: string;
};
response: {
sessions: Array<{
id: string;
deviceId: string;
deviceName: string;
browser: string;
os: string;
ip: string;
lastActive: number;
createdAt: number;
isCurrent: boolean;
}>;
};
}
export interface IReq_RevokeSession
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_RevokeSession
> {
method: 'revokeSession';
request: {
jwt: string;
sessionId: string;
};
response: {
success: boolean;
};
}
export interface IReq_GetUserActivity
extends plugins.typedRequestInterfaces.implementsTR<
plugins.typedRequestInterfaces.ITypedRequest,
IReq_GetUserActivity
> {
method: 'getUserActivity';
request: {
jwt: string;
limit?: number;
offset?: number;
};
response: {
activities: data.IActivityLog[];
total: number;
};
}
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@idp.global/idp.global', name: '@idp.global/idp.global',
version: '1.7.0', version: '1.8.0',
description: 'An identity provider software managing user authentications, registrations, and sessions.' description: 'An identity provider software managing user authentications, registrations, and sessions.'
} }
+42
View File
@@ -12,6 +12,8 @@ import {
} from '@design.estate/dees-element'; } from '@design.estate/dees-element';
import { LeleAccountNavigation } from './navigation.js'; import { LeleAccountNavigation } from './navigation.js';
import { OrgSelectModal } from './org-select-modal.js';
import { CreateOrgModal } from './create-org-modal.js';
import { accountDesignTokens } from './sharedstyles.js'; import { accountDesignTokens } from './sharedstyles.js';
import * as views from './views/index.js'; import * as views from './views/index.js';
@@ -91,6 +93,8 @@ export class IdpAccountContent extends DeesElement {
<!--<lele-accountview-subscription></lele-accountview-subscription>--> <!--<lele-accountview-subscription></lele-accountview-subscription>-->
</div> </div>
</div> </div>
<idp-org-select-modal></idp-org-select-modal>
<idp-create-org-modal></idp-create-org-modal>
`; `;
} }
@@ -100,6 +104,34 @@ export class IdpAccountContent extends DeesElement {
this.subrouter = this.domtools.router.createSubRouter('/account'); this.subrouter = this.domtools.router.createSubRouter('/account');
const viewcontainer: HTMLDivElement = this.shadowRoot.querySelector('.viewcontainer'); const viewcontainer: HTMLDivElement = this.shadowRoot.querySelector('.viewcontainer');
// Get modal references
const orgSelectModal = this.shadowRoot.querySelector('idp-org-select-modal') as OrgSelectModal;
const createOrgModal = this.shadowRoot.querySelector('idp-create-org-modal') as CreateOrgModal;
// Setup event listeners for modals
this.addEventListener('open-org-select-modal', ((e: CustomEvent) => {
orgSelectModal.show({
targetPath: e.detail.targetPath,
title: e.detail.title,
description: e.detail.description,
});
}) as EventListener);
this.addEventListener('open-create-org-modal', () => {
createOrgModal.show();
});
// Handle org selection from modal
orgSelectModal.addEventListener('org-selected', ((e: CustomEvent) => {
this.subrouter.pushUrl(e.detail.path);
}) as EventListener);
// Handle org creation - navigate to billing
createOrgModal.addEventListener('org-created', ((e: CustomEvent) => {
const org = e.detail.org;
this.subrouter.pushUrl(`/org/${org.data.slug}/billing`);
}) as EventListener);
const cleanupViews = async () => { const cleanupViews = async () => {
for (const child of Array.from(viewcontainer.children)) { for (const child of Array.from(viewcontainer.children)) {
viewcontainer.removeChild(child); viewcontainer.removeChild(child);
@@ -139,6 +171,16 @@ export class IdpAccountContent extends DeesElement {
await this.domtools.convenience.smartdelay.delayFor(300); await this.domtools.convenience.smartdelay.delayFor(300);
}); });
this.subrouter.on('/org/:orgName', async () => {
viewcontainer.classList.add('changing');
await this.domtools.convenience.smartdelay.delayFor(300);
console.log('We are viewing the org overview page');
await cleanupViews();
viewcontainer.append(new views.OrgView());
viewcontainer.classList.remove('changing');
await this.domtools.convenience.smartdelay.delayFor(300);
});
this.subrouter.on('/org/:orgName/apps', async () => { this.subrouter.on('/org/:orgName/apps', async () => {
viewcontainer.classList.add('changing'); viewcontainer.classList.add('changing');
await this.domtools.convenience.smartdelay.delayFor(300); await this.domtools.convenience.smartdelay.delayFor(300);
+455
View File
@@ -0,0 +1,455 @@
import * as plugins from '../../plugins.js';
import {
customElement,
DeesElement,
property,
html,
cssManager,
css,
state,
type TemplateResult,
} from '@design.estate/dees-element';
import { accountDesignTokens } from './sharedstyles.js';
import * as accountStateModule from '../../states/accountstate.js';
import { IdpState } from '../../states/idp.state.js';
declare global {
interface HTMLElementTagNameMap {
'idp-create-org-modal': CreateOrgModal;
}
}
@customElement('idp-create-org-modal')
export class CreateOrgModal extends DeesElement {
@state()
accessor visible: boolean = false;
@state()
accessor orgName: string = '';
@state()
accessor orgSlug: string = '';
@state()
accessor validating: boolean = false;
@state()
accessor validationResult: { available: boolean; message: string } | null = null;
@state()
accessor creating: boolean = false;
@state()
accessor error: string = '';
private validationDebounceTimer: any = null;
public static styles = [
cssManager.defaultStyles,
accountDesignTokens,
css`
:host {
display: none;
}
:host([visible]) {
display: block;
}
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.15s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal {
background: #18181b;
border: 1px solid #27272a;
border-radius: 16px;
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
animation: slideIn 0.2s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid #27272a;
}
.modal-title {
font-size: 18px;
font-weight: 600;
margin: 0 0 4px 0;
color: #fafafa;
}
.modal-description {
font-size: 14px;
color: #71717a;
margin: 0;
}
.modal-body {
padding: 24px;
}
.form-group {
margin-bottom: 20px;
}
.form-group:last-child {
margin-bottom: 0;
}
.form-label {
display: block;
font-size: 13px;
font-weight: 500;
margin-bottom: 8px;
color: #a1a1aa;
}
.form-input {
width: 100%;
padding: 10px 14px;
border-radius: 8px;
border: 1px solid #27272a;
background: #0a0a0a;
color: #fafafa;
font-size: 14px;
box-sizing: border-box;
transition: border-color 0.15s ease;
}
.form-input:focus {
outline: none;
border-color: #3b82f6;
}
.form-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.slug-preview {
margin-top: 12px;
padding: 12px 16px;
background: #0a0a0a;
border: 1px solid #27272a;
border-radius: 8px;
}
.slug-label {
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #71717a;
margin-bottom: 4px;
}
.slug-value {
font-family: 'Geist Mono', monospace;
font-size: 14px;
color: #fafafa;
}
.validation-status {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
}
.validation-status.validating {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.validation-status.available {
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
}
.validation-status.unavailable {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.validation-status dees-icon {
font-size: 16px;
}
.error-message {
margin-top: 16px;
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;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid #27272a;
}
`,
];
public render(): TemplateResult {
if (!this.visible) {
return html``;
}
const canCreate = this.orgName.length > 0 &&
this.validationResult?.available &&
!this.validating &&
!this.creating;
return html`
<div class="overlay" @click=${this.handleOverlayClick}>
<div class="modal" @click=${(e: Event) => e.stopPropagation()}>
<div class="modal-header">
<h2 class="modal-title">Create Organization</h2>
<p class="modal-description">Create a new organization to manage apps, users, and billing.</p>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">Organization Name</label>
<input
type="text"
class="form-input"
placeholder="e.g., Acme Inc."
.value=${this.orgName}
@input=${this.handleNameInput}
?disabled=${this.creating}
/>
</div>
${this.orgSlug ? html`
<div class="slug-preview">
<div class="slug-label">Organization URL Slug</div>
<div class="slug-value">${this.orgSlug}</div>
</div>
` : ''}
${this.renderValidationStatus()}
${this.error ? html`
<div class="error-message">${this.error}</div>
` : ''}
</div>
<div class="modal-footer">
<dees-button type="secondary" @clicked=${this.handleCancel} ?disabled=${this.creating}>
Cancel
</dees-button>
<dees-button @clicked=${this.handleCreate} ?disabled=${!canCreate} .status=${this.creating ? 'pending' : 'normal'}>
${this.creating ? 'Creating...' : 'Create Organization'}
</dees-button>
</div>
</div>
</div>
`;
}
private renderValidationStatus(): TemplateResult | null {
if (!this.orgSlug) {
return null;
}
if (this.validating) {
return html`
<div class="validation-status validating">
<dees-icon .icon=${'lucide:loader-2'}></dees-icon>
Checking availability...
</div>
`;
}
if (this.validationResult) {
if (this.validationResult.available) {
return html`
<div class="validation-status available">
<dees-icon .icon=${'lucide:check-circle'}></dees-icon>
${this.validationResult.message}
</div>
`;
} else {
return html`
<div class="validation-status unavailable">
<dees-icon .icon=${'lucide:x-circle'}></dees-icon>
${this.validationResult.message}
</div>
`;
}
}
return null;
}
public show() {
this.orgName = '';
this.orgSlug = '';
this.validating = false;
this.validationResult = null;
this.creating = false;
this.error = '';
this.visible = true;
this.setAttribute('visible', '');
}
public hide() {
this.visible = false;
this.removeAttribute('visible');
if (this.validationDebounceTimer) {
clearTimeout(this.validationDebounceTimer);
}
}
private handleOverlayClick(e: Event) {
if ((e.target as HTMLElement).classList.contains('overlay') && !this.creating) {
this.hide();
}
}
private handleCancel() {
if (!this.creating) {
this.hide();
}
}
private handleNameInput(e: Event) {
const input = e.target as HTMLInputElement;
this.orgName = input.value;
this.orgSlug = this.generateSlug(this.orgName);
this.error = '';
// Debounce validation
if (this.validationDebounceTimer) {
clearTimeout(this.validationDebounceTimer);
}
if (this.orgSlug) {
this.validating = true;
this.validationResult = null;
this.validationDebounceTimer = setTimeout(() => {
this.validateSlug();
}, 500);
} else {
this.validating = false;
this.validationResult = null;
}
}
private generateSlug(name: string): string {
return name
.replace(/[^a-zA-Z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.toLowerCase();
}
private async validateSlug() {
if (!this.orgSlug) {
this.validating = false;
return;
}
try {
const idpState = await IdpState.getSingletonInstance();
const result = await idpState.idpClient.createOrganization(
this.orgName,
this.orgSlug,
'checkAvailability'
);
this.validationResult = {
available: result.nameAvailable,
message: result.nameAvailable
? 'This name is available!'
: 'This name is already taken. Please choose another.',
};
} catch (error) {
console.error('Validation error:', error);
this.validationResult = {
available: false,
message: 'Unable to validate. Please try again.',
};
} finally {
this.validating = false;
}
}
private async handleCreate() {
if (!this.validationResult?.available || this.creating) {
return;
}
this.creating = true;
this.error = '';
try {
const idpState = await IdpState.getSingletonInstance();
const result = await idpState.idpClient.createOrganization(
this.orgName,
this.orgSlug,
'manifest'
);
// Update state with new organization
const currentState = accountStateModule.accountState.getState();
currentState.organizations.push(result.resultingOrganization);
accountStateModule.accountState.dispatchAction(
accountStateModule.setSelectedOrg,
result.resultingOrganization
);
this.dispatchEvent(new CustomEvent('org-created', {
bubbles: true,
composed: true,
detail: { org: result.resultingOrganization },
}));
this.hide();
} catch (error) {
console.error('Error creating organization:', error);
this.error = error instanceof Error ? error.message : 'Failed to create organization. Please try again.';
} finally {
this.creating = false;
}
}
}
+2
View File
@@ -1,2 +1,4 @@
export * from './content.js'; export * from './content.js';
export * from './navigation.js'; export * from './navigation.js';
export * from './org-select-modal.js';
export * from './create-org-modal.js';
+79 -14
View File
@@ -183,14 +183,6 @@ export class LeleAccountNavigation extends DeesElement {
<dees-icon .icon=${'lucide:shield'}></dees-icon> <dees-icon .icon=${'lucide:shield'}></dees-icon>
Manage Roles Manage Roles
</div> </div>
<div
class="navigationOption"
@click=${async () => {
}}
>
<dees-icon .icon=${'lucide:plus'}></dees-icon>
Create Organization
</div>
<div <div
class="navigationOption" class="navigationOption"
@click=${async () => { @click=${async () => {
@@ -207,15 +199,58 @@ export class LeleAccountNavigation extends DeesElement {
<div class="navigationGroupLabel">Organization</div> <div class="navigationGroupLabel">Organization</div>
<dees-input-dropdown <dees-input-dropdown
.label=${'Select organization'} .label=${'Select organization'}
@selectedOption=${(eventArg: CustomEvent) => { @selectedOption=${async (eventArg: CustomEvent) => {
// Handle "Create new..." option
if (eventArg.detail.key === '__create_new__') {
this.dispatchEvent(new CustomEvent('open-create-org-modal', {
bubbles: true,
composed: true,
}));
return;
}
const currentState = states.accountState.getState(); const currentState = states.accountState.getState();
states.accountState.dispatchAction( const newOrg = currentState.organizations.find((org) => org.data.slug === eventArg.detail.payload);
states.setSelectedOrg, states.accountState.dispatchAction(states.setSelectedOrg, newOrg);
currentState.organizations.find((org) => org.data.slug === eventArg.detail.payload)
); // Auto-navigate to new org's current page type (reactivity)
const currentPath = window.location.pathname;
const subrouter = await this.getAccountRouter();
if (currentPath.includes('/org/') && newOrg) {
// Extract the page type (apps, billing, etc.) and navigate to new org
const pathParts = currentPath.split('/');
const pageType = pathParts[5]; // /account/org/:orgName/:pageType
if (pageType) {
subrouter.pushUrl(`/org/${newOrg.data.slug}/${pageType}`);
} else {
subrouter.pushUrl(`/org/${newOrg.data.slug}`);
}
}
}} }}
></dees-input-dropdown> ></dees-input-dropdown>
<div
class="navigationOption"
@click=${async () => {
const currentState = states.accountState.getState();
if (currentState.selectedOrg) {
const subrouter = await this.getAccountRouter();
subrouter.pushUrl(`/org/${currentState.selectedOrg.data.slug}`);
} else {
this.dispatchEvent(new CustomEvent('open-org-select-modal', {
bubbles: true,
composed: true,
detail: {
targetPath: '/org/:orgName',
title: 'Select Organization',
description: 'Choose an organization to view its overview.',
},
}));
}
}}
>
<dees-icon .icon=${'lucide:home'}></dees-icon>
Overview
</div>
<div <div
class="navigationOption" class="navigationOption"
@click=${async () => { @click=${async () => {
@@ -223,6 +258,16 @@ export class LeleAccountNavigation extends DeesElement {
if (currentState.selectedOrg) { if (currentState.selectedOrg) {
const subrouter = await this.getAccountRouter(); const subrouter = await this.getAccountRouter();
subrouter.pushUrl(`/org/${currentState.selectedOrg.data.slug}/apps`); subrouter.pushUrl(`/org/${currentState.selectedOrg.data.slug}/apps`);
} else {
this.dispatchEvent(new CustomEvent('open-org-select-modal', {
bubbles: true,
composed: true,
detail: {
targetPath: '/org/:orgName/apps',
title: 'Select Organization',
description: 'Choose an organization to view its apps.',
},
}));
} }
}} }}
> >
@@ -250,6 +295,16 @@ export class LeleAccountNavigation extends DeesElement {
if (currentState.selectedOrg) { if (currentState.selectedOrg) {
const subrouter = await this.getAccountRouter(); const subrouter = await this.getAccountRouter();
subrouter.pushUrl(`/org/${currentState.selectedOrg.data.slug}/billing`); subrouter.pushUrl(`/org/${currentState.selectedOrg.data.slug}/billing`);
} else {
this.dispatchEvent(new CustomEvent('open-org-select-modal', {
bubbles: true,
composed: true,
detail: {
targetPath: '/org/:orgName/billing',
title: 'Select Organization',
description: 'Choose an organization to view its billing.',
},
}));
} }
}} }}
> >
@@ -296,11 +351,21 @@ export class LeleAccountNavigation extends DeesElement {
payload: orgArg.data.slug, payload: orgArg.data.slug,
}; };
}; };
// "Create new..." option to add at the end
const createNewOption = {
option: '+ Create new...',
key: '__create_new__',
payload: '__create_new__',
};
states.accountState states.accountState
.select((stateArg) => stateArg.organizations) .select((stateArg) => stateArg.organizations)
.pipe( .pipe(
plugins.deesDomtools.plugins.smartrx.rxjs.ops.map((orgArrayArg) => { plugins.deesDomtools.plugins.smartrx.rxjs.ops.map((orgArrayArg) => {
return orgArrayArg.map(orgToMenuEntry); const orgEntries = orgArrayArg.map(orgToMenuEntry);
// Add "Create new..." at the end
return [...orgEntries, createNewOption];
}) })
) )
.subscribe((menuEntries) => { .subscribe((menuEntries) => {
+318
View File
@@ -0,0 +1,318 @@
import * as plugins from '../../plugins.js';
import {
customElement,
DeesElement,
property,
html,
cssManager,
css,
state,
type TemplateResult,
} from '@design.estate/dees-element';
import { accountDesignTokens } from './sharedstyles.js';
import * as accountStateModule from '../../states/accountstate.js';
declare global {
interface HTMLElementTagNameMap {
'idp-org-select-modal': OrgSelectModal;
}
}
@customElement('idp-org-select-modal')
export class OrgSelectModal extends DeesElement {
@state()
accessor visible: boolean = false;
@state()
accessor organizations: plugins.idpInterfaces.data.IOrganization[] = [];
@state()
accessor targetPath: string = '';
@state()
accessor title: string = 'Select Organization';
@state()
accessor description: string = 'Choose an organization to continue.';
public static styles = [
cssManager.defaultStyles,
accountDesignTokens,
css`
:host {
display: none;
}
:host([visible]) {
display: block;
}
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.15s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal {
background: #18181b;
border: 1px solid #27272a;
border-radius: 16px;
width: 100%;
max-width: 420px;
max-height: 90vh;
overflow-y: auto;
animation: slideIn 0.2s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid #27272a;
}
.modal-title {
font-size: 18px;
font-weight: 600;
margin: 0 0 4px 0;
color: #fafafa;
}
.modal-description {
font-size: 14px;
color: #71717a;
margin: 0;
}
.modal-body {
padding: 0;
}
.org-list {
display: flex;
flex-direction: column;
}
.org-item {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 24px;
border-bottom: 1px solid #27272a;
cursor: pointer;
transition: background 0.15s ease;
}
.org-item:last-child {
border-bottom: none;
}
.org-item:hover {
background: #27272a;
}
.org-icon {
width: 40px;
height: 40px;
border-radius: 10px;
background: #27272a;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.org-item:hover .org-icon {
background: #3f3f46;
}
.org-icon dees-icon {
opacity: 0.7;
}
.org-info {
flex: 1;
min-width: 0;
}
.org-name {
font-size: 14px;
font-weight: 600;
margin-bottom: 2px;
color: #fafafa;
}
.org-slug {
font-size: 12px;
color: #71717a;
}
.org-arrow {
opacity: 0.5;
}
.empty-state {
text-align: center;
padding: 40px 24px;
color: #71717a;
}
.empty-state dees-icon {
font-size: 40px;
opacity: 0.5;
margin-bottom: 12px;
}
.empty-state p {
margin: 0 0 16px 0;
font-size: 14px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid #27272a;
}
`,
];
public render(): TemplateResult {
if (!this.visible) {
return html``;
}
return html`
<div class="overlay" @click=${this.handleOverlayClick}>
<div class="modal" @click=${(e: Event) => e.stopPropagation()}>
<div class="modal-header">
<h2 class="modal-title">${this.title}</h2>
<p class="modal-description">${this.description}</p>
</div>
<div class="modal-body">
${this.organizations.length === 0
? this.renderEmptyState()
: this.renderOrgList()}
</div>
<div class="modal-footer">
<dees-button type="secondary" @clicked=${this.handleCancel}>
Cancel
</dees-button>
</div>
</div>
</div>
`;
}
private renderOrgList(): TemplateResult {
return html`
<div class="org-list">
${this.organizations.map((org) => html`
<div class="org-item" @click=${() => this.handleSelectOrg(org)}>
<div class="org-icon">
<dees-icon .icon=${'lucide:building2'}></dees-icon>
</div>
<div class="org-info">
<div class="org-name">${org.data.name}</div>
<div class="org-slug">${org.data.slug}</div>
</div>
<dees-icon class="org-arrow" .icon=${'lucide:chevron-right'}></dees-icon>
</div>
`)}
</div>
`;
}
private renderEmptyState(): TemplateResult {
return html`
<div class="empty-state">
<dees-icon .icon=${'lucide:building2'}></dees-icon>
<p>You don't have any organizations yet.</p>
<dees-button @clicked=${this.handleCreateOrg}>
<dees-icon .icon=${'lucide:plus'} slot="iconLeft"></dees-icon>
Create Organization
</dees-button>
</div>
`;
}
public show(options: {
targetPath: string;
title?: string;
description?: string;
}) {
this.targetPath = options.targetPath;
this.title = options.title || 'Select Organization';
this.description = options.description || 'Choose an organization to continue.';
// Load organizations from state
const state = accountStateModule.accountState.getState();
this.organizations = state.organizations;
this.visible = true;
this.setAttribute('visible', '');
}
public hide() {
this.visible = false;
this.removeAttribute('visible');
}
private handleOverlayClick(e: Event) {
if ((e.target as HTMLElement).classList.contains('overlay')) {
this.hide();
}
}
private handleCancel() {
this.hide();
}
private handleSelectOrg(org: plugins.idpInterfaces.data.IOrganization) {
accountStateModule.accountState.dispatchAction(accountStateModule.setSelectedOrg, org);
// Replace :orgName placeholder with actual slug
const path = this.targetPath.replace(':orgName', org.data.slug);
this.dispatchEvent(new CustomEvent('org-selected', {
bubbles: true,
composed: true,
detail: { org, path },
}));
this.hide();
}
private handleCreateOrg() {
this.hide();
this.dispatchEvent(new CustomEvent('open-create-org-modal', {
bubbles: true,
composed: true,
}));
}
}
+756 -170
View File
@@ -5,13 +5,14 @@ import {
property, property,
html, html,
cssManager, cssManager,
unsafeCSS,
css, css,
render, state,
directives, type TemplateResult,
} from '@design.estate/dees-element'; } from '@design.estate/dees-element';
import sharedStyles, { accountDesignTokens, cardStyles, typographyStyles } from '../sharedstyles.js'; import { accountDesignTokens } from '../sharedstyles.js';
import * as accountStateModule from '../../../states/accountstate.js';
import { IdpState } from '../../../states/idp.state.js';
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
@@ -19,219 +20,804 @@ declare global {
} }
} }
import * as state from '../../../states/accountstate.js'; interface ISessionDisplay {
id: string;
deviceId: string;
deviceName: string;
browser: string;
os: string;
ip: string;
lastActive: number;
createdAt: number;
isCurrent: boolean;
}
interface IActivityDisplay {
id: string;
data: plugins.idpInterfaces.data.IActivityLog['data'];
}
@customElement('lele-accountview-baseview') @customElement('lele-accountview-baseview')
export class BaseView extends DeesElement { export class BaseView extends DeesElement {
@property({ @state()
type: Array, accessor loading: boolean = true;
})
accessor subscriptions: any[] = [ @state()
{ accessor sessions: ISessionDisplay[] = [];
organization: 'org1',
'subscription type': 'workspace.global SaaS', @state()
price: '4€', accessor activities: IActivityDisplay[] = [];
userFactor: 4,
total: '16.00€', @state()
}, accessor user: plugins.idpInterfaces.data.IUser | null = null;
{
organization: 'org1', @state()
'subscription type': 'workspace.global IaaS Base Access', accessor organizations: plugins.idpInterfaces.data.IOrganization[] = [];
price: '0€',
userFactor: 4, @state()
total: '0€', accessor roles: plugins.idpInterfaces.data.IRole[] = [];
},
{
organization: 'org1',
'subscription type': 'workspace.global SLA Senior',
price: '2000€',
userFactor: 'none',
total: '2000.00€',
},
];
public static styles = [ public static styles = [
cssManager.defaultStyles, cssManager.defaultStyles,
accountDesignTokens, accountDesignTokens,
cardStyles,
typographyStyles,
css` css`
:host { :host {
display: block; display: block;
padding: 48px; min-height: 100%;
background: var(--background);
color: var(--foreground);
} }
.viewHost { .container {
max-width: 600px; max-width: 1000px;
margin: 0 auto; margin: 0 auto;
padding: 32px 24px;
}
.header {
margin-bottom: 32px;
}
h1 {
font-size: 32px;
font-weight: 600;
margin: 0;
letter-spacing: -0.02em;
}
.subtitle {
color: #71717a;
margin-top: 8px;
font-size: 14px;
}
.dashboard-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 768px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
} }
.card { .card {
background: var(--card); background: #18181b;
border: 1px solid var(--border); border: 1px solid #27272a;
border-radius: 12px; border-radius: 12px;
padding: 32px; overflow: hidden;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
} }
.slug { .card.full-width {
color: var(--foreground); grid-column: 1 / -1;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #27272a;
}
.card-title {
font-size: 16px;
font-weight: 600; font-weight: 600;
font-family: 'Geist Mono', monospace; display: flex;
align-items: center;
gap: 8px;
} }
.hint { .card-title dees-icon {
display: block; opacity: 0.7;
font-size: 13px;
color: var(--muted-foreground);
margin: 16px 0;
padding: 12px 16px;
background: var(--muted);
border-radius: 8px;
} }
dees-form { .card-body {
padding: 16px 20px;
}
.card-body.no-padding {
padding: 0;
}
/* Profile Card */
.profile-info {
display: flex;
align-items: center;
gap: 16px;
}
.avatar {
width: 64px;
height: 64px;
border-radius: 50%;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 600;
color: white;
flex-shrink: 0;
}
.profile-details {
flex: 1;
min-width: 0;
}
.profile-name {
font-size: 18px;
font-weight: 600;
margin-bottom: 4px;
}
.profile-email {
font-size: 14px;
color: #71717a;
word-break: break-all;
}
/* Organizations */
.org-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px;
margin-top: 24px;
} }
.orgGrid { .org-item {
display: grid; display: flex;
grid-gap: 16px; align-items: center;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 12px;
margin-top: 24px; padding: 12px 20px;
} border-bottom: 1px solid #27272a;
.org {
padding: 20px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
color: var(--foreground);
transition: all 0.15s ease;
cursor: pointer; cursor: pointer;
transition: background 0.15s ease;
} }
.org:hover { .org-item:last-child {
background: var(--muted); border-bottom: none;
border-color: var(--muted-foreground);
} }
.org dees-icon { .org-item:hover {
background: #27272a;
}
.org-icon {
width: 40px;
height: 40px;
border-radius: 10px;
background: #27272a;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.org-icon dees-icon {
opacity: 0.7; opacity: 0.7;
} }
.org-info {
flex: 1;
min-width: 0;
}
.org-name {
font-size: 14px;
font-weight: 600;
margin-bottom: 2px;
}
.org-role {
font-size: 12px;
color: #71717a;
}
.role-badge {
padding: 4px 10px;
border-radius: 9999px;
font-size: 11px;
font-weight: 500;
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.role-badge.admin {
background: rgba(245, 158, 11, 0.1);
color: #f59e0b;
}
.role-badge.owner {
background: rgba(139, 92, 246, 0.1);
color: #8b5cf6;
}
/* Sessions */
.session-list {
display: flex;
flex-direction: column;
}
.session-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
border-bottom: 1px solid #27272a;
}
.session-item:last-child {
border-bottom: none;
}
.session-icon {
width: 40px;
height: 40px;
border-radius: 10px;
background: #27272a;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.session-icon dees-icon {
opacity: 0.7;
}
.session-icon.current {
background: rgba(34, 197, 94, 0.1);
}
.session-icon.current dees-icon {
color: #22c55e;
opacity: 1;
}
.session-info {
flex: 1;
min-width: 0;
}
.session-device {
font-size: 14px;
font-weight: 500;
margin-bottom: 2px;
display: flex;
align-items: center;
gap: 8px;
}
.current-badge {
padding: 2px 8px;
border-radius: 9999px;
font-size: 10px;
font-weight: 500;
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
}
.session-details {
font-size: 12px;
color: #71717a;
}
.session-actions {
flex-shrink: 0;
}
.revoke-btn {
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
border: 1px solid #27272a;
background: transparent;
color: #fafafa;
cursor: pointer;
transition: all 0.15s ease;
}
.revoke-btn:hover {
background: rgba(239, 68, 68, 0.1);
border-color: #ef4444;
color: #ef4444;
}
/* Activity */
.activity-list {
display: flex;
flex-direction: column;
}
.activity-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 20px;
border-bottom: 1px solid #27272a;
}
.activity-item:last-child {
border-bottom: none;
}
.activity-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: #27272a;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.activity-icon dees-icon {
font-size: 14px;
opacity: 0.7;
}
.activity-icon.login {
background: rgba(34, 197, 94, 0.1);
}
.activity-icon.login dees-icon {
color: #22c55e;
opacity: 1;
}
.activity-icon.logout {
background: rgba(239, 68, 68, 0.1);
}
.activity-icon.logout dees-icon {
color: #ef4444;
opacity: 1;
}
.activity-info {
flex: 1;
min-width: 0;
}
.activity-description {
font-size: 14px;
margin-bottom: 2px;
}
.activity-time {
font-size: 12px;
color: #71717a;
}
/* Empty states */
.empty-state {
text-align: center;
padding: 32px 20px;
color: #71717a;
}
.empty-state dees-icon {
font-size: 32px;
opacity: 0.5;
margin-bottom: 12px;
}
.empty-state p {
margin: 0;
font-size: 14px;
}
/* Loading state */
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 48px;
color: #71717a;
}
/* Create org button */
.create-org-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
border: 1px solid #27272a;
background: transparent;
color: #fafafa;
cursor: pointer;
transition: all 0.15s ease;
}
.create-org-btn:hover {
background: #27272a;
}
.create-org-btn dees-icon {
font-size: 14px;
}
`, `,
]; ];
public render() { public render(): TemplateResult {
if (this.loading) {
return html` return html`
<div class="viewHost"> <div class="container">
<div class="loading">Loading your account...</div>
</div> `;
}
public async firstUpdated(_changedProperties: Map<string | number | symbol, unknown>) {
await this.domtoolsPromise;
super.firstUpdated(_changedProperties);
const viewHost: HTMLDivElement = this.shadowRoot.querySelector('.viewHost');
await state.accountState.dispatchAction(state.getOrganizationsAction, null);
console.log('got orgs');
if (state.accountState.getState().organizations.length === 0) {
render(
html`
<div class="card">
<h1>Setup Your Account</h1>
<p>
There are no organizations for your account. Please create one now. Alternatively you
can ask an admin of an existing organization to invite you.
</p>
<dees-form>
<dees-input-text .label=${'Organization Name'} .key=${'orgName'}></dees-input-text>
</dees-form>
<p>
The organization slug will be:<br />
<span class="slug"
>${directives.subscribe(
state.accountState.select((stateArg) => stateArg.newOrg.chosenSlug)
)}</span
>
</p>
<span class="hint"></span>
<dees-button .disabled=${true}>Create the Organization</dees-button>
</div> </div>
`, `;
viewHost
);
const subscriptions: plugins.deesDomtools.plugins.smartrx.rxjs.Subscription[] = [];
const form = this.shadowRoot.querySelector('dees-form');
const orgInput = this.shadowRoot.querySelector('dees-input-text');
const hint = this.shadowRoot.querySelector('.hint');
const button = this.shadowRoot.querySelector('dees-button');
const newOrgSubscription = state.accountState
.select((stateArg) => stateArg.newOrg)
.subscribe((data) => {
if (data.chosenSlug) {
hint.innerHTML = 'Waiting: Validating...';
} else {
hint.innerHTML = 'Hint: Enter a valid organization name.';
} }
if (data.validated && data.validationOk) {
hint.innerHTML =
'Success: Name is available. Please click the button to create the organization.';
button.disabled = false;
} else if (!data.validated || !data.validationOk) {
hint.innerHTML = `Info: Name not available. Please choose another one.`;
button.disabled = true;
}
});
subscriptions.push(newOrgSubscription);
const formSubscription = form.changeSubject.subscribe(async (dataArg: any) => { const userInitial = this.user?.data?.username?.charAt(0).toUpperCase() ||
await state.accountState.dispatchAction(state.setNewOrgName, dataArg.orgName); this.user?.data?.email?.charAt(0).toUpperCase() || '?';
});
subscriptions.push(formSubscription);
button.addEventListener('clicked', async () => {
orgInput.disabled = true;
button.text = 'creating org...';
button.status = 'pending';
hint.innerHTML = 'Waiting for creation of the organization...';
await state.accountState.dispatchAction(state.manifestNewOrgName, null);
hint.innerHTML = `The Organization with name ${
state.accountState.getState().organizations[0].data.name
} has been created!`;
button.text = 'created!';
button.status = 'success';
const parentElement = (this.getRootNode() as any).host;
parentElement.subrouter.pushUrl(
`/org/${state.accountState.getState().organizations[0].data.slug}/billing`
);
});
} else {
render(
html`
<h1>Select An Organization</h1>
<p>Choose an organization to manage its settings and billing.</p>
<div class="orgGrid">
${state.accountState.getState().organizations.map((orgArg) => {
return html` return html`
<div <div class="container">
class="org" <div class="header">
@click=${() => { <h1>Account Overview</h1>
state.accountState.dispatchAction(state.setSelectedOrg, orgArg); <p class="subtitle">Manage your profile, organizations, and security settings</p>
const parentElement = (this.getRootNode() as any).host; </div>
parentElement.subrouter.pushUrl(`/org/${orgArg.data.slug}/billing`);
}} <div class="dashboard-grid">
> <!-- Profile Card -->
<dees-icon .icon=${'lucide:building2'} style="display: inline-block; transform: translateY(3px); padding-right: 8px;"></dees-icon> ${orgArg.data.name} <div class="card">
<div class="card-header">
<span class="card-title">
<dees-icon .icon=${'lucide:user'}></dees-icon>
Profile
</span>
</div>
<div class="card-body">
<div class="profile-info">
<div class="avatar">${userInitial}</div>
<div class="profile-details">
<div class="profile-name">${this.user?.data?.username || 'Unknown User'}</div>
<div class="profile-email">${this.user?.data?.email || 'No email'}</div>
</div>
</div>
</div>
</div>
<!-- Organizations Card -->
<div class="card">
<div class="card-header">
<span class="card-title">
<dees-icon .icon=${'lucide:building2'}></dees-icon>
Organizations
</span>
<button class="create-org-btn" @click=${this.handleCreateOrg}>
<dees-icon .icon=${'lucide:plus'}></dees-icon>
New
</button>
</div>
<div class="card-body no-padding">
${this.renderOrganizations()}
</div>
</div>
<!-- Sessions Card -->
<div class="card">
<div class="card-header">
<span class="card-title">
<dees-icon .icon=${'lucide:monitor-smartphone'}></dees-icon>
Active Sessions
</span>
</div>
<div class="card-body no-padding">
${this.renderSessions()}
</div>
</div>
<!-- Activity Card -->
<div class="card">
<div class="card-header">
<span class="card-title">
<dees-icon .icon=${'lucide:activity'}></dees-icon>
Recent Activity
</span>
</div>
<div class="card-body no-padding">
${this.renderActivity()}
</div>
</div>
</div>
</div>
`;
}
private renderOrganizations(): TemplateResult {
if (this.organizations.length === 0) {
return html`
<div class="empty-state">
<dees-icon .icon=${'lucide:building2'}></dees-icon>
<p>You're not a member of any organizations yet.</p>
</div>
`;
}
return html`
<div class="org-list">
${this.organizations.map((org) => {
const roleObj = this.roles.find(r => r.data.organizationId === org.id);
const roleName = roleObj?.data.role || 'member';
const roleClass = roleName === 'owner' ? 'owner' :
roleName === 'admin' ? 'admin' : '';
const roleDisplay = roleName.charAt(0).toUpperCase() + roleName.slice(1);
return html`
<div class="org-item" @click=${() => this.handleSelectOrg(org)}>
<div class="org-icon">
<dees-icon .icon=${'lucide:building2'}></dees-icon>
</div>
<div class="org-info">
<div class="org-name">${org.data.name}</div>
<div class="org-role">${org.data.slug}</div>
</div>
<span class="role-badge ${roleClass}">${roleDisplay}</span>
</div> </div>
`; `;
})} })}
</div> </div>
`, `;
viewHost
);
} }
private renderSessions(): TemplateResult {
if (this.sessions.length === 0) {
return html`
<div class="empty-state">
<dees-icon .icon=${'lucide:monitor'}></dees-icon>
<p>No active sessions found.</p>
</div>
`;
}
return html`
<div class="session-list">
${this.sessions.map((session) => html`
<div class="session-item">
<div class="session-icon ${session.isCurrent ? 'current' : ''}">
<dees-icon .icon=${this.getDeviceIcon(session.os)}></dees-icon>
</div>
<div class="session-info">
<div class="session-device">
${session.deviceName || 'Unknown Device'}
${session.isCurrent ? html`<span class="current-badge">Current</span>` : ''}
</div>
<div class="session-details">
${session.browser} · ${session.os} · Last active ${this.formatTimeAgo(session.lastActive)}
</div>
</div>
${!session.isCurrent ? html`
<div class="session-actions">
<button class="revoke-btn" @click=${() => this.handleRevokeSession(session.id)}>
Revoke
</button>
</div>
` : ''}
</div>
`)}
</div>
`;
}
private renderActivity(): TemplateResult {
if (this.activities.length === 0) {
return html`
<div class="empty-state">
<dees-icon .icon=${'lucide:activity'}></dees-icon>
<p>No recent activity.</p>
</div>
`;
}
return html`
<div class="activity-list">
${this.activities.slice(0, 5).map((activity) => html`
<div class="activity-item">
<div class="activity-icon ${this.getActivityIconClass(activity.data.action)}">
<dees-icon .icon=${this.getActivityIcon(activity.data.action)}></dees-icon>
</div>
<div class="activity-info">
<div class="activity-description">${activity.data.metadata.description}</div>
<div class="activity-time">${this.formatTimeAgo(activity.data.timestamp)}</div>
</div>
</div>
`)}
</div>
`;
}
private getDeviceIcon(os: string): string {
const osLower = os?.toLowerCase() || '';
if (osLower.includes('mac') || osLower.includes('ios')) {
return 'lucide:laptop';
} else if (osLower.includes('android')) {
return 'lucide:smartphone';
} else if (osLower.includes('windows')) {
return 'lucide:monitor';
} else if (osLower.includes('linux')) {
return 'lucide:terminal';
}
return 'lucide:monitor';
}
private getActivityIcon(action: string): string {
switch (action) {
case 'login':
return 'lucide:log-in';
case 'logout':
return 'lucide:log-out';
case 'session_created':
return 'lucide:key';
case 'session_revoked':
return 'lucide:shield-off';
case 'org_created':
return 'lucide:building-2';
case 'org_joined':
return 'lucide:user-plus';
case 'org_left':
return 'lucide:user-minus';
case 'role_changed':
return 'lucide:shield';
case 'profile_updated':
return 'lucide:user-cog';
case 'app_connected':
return 'lucide:plug';
case 'app_disconnected':
return 'lucide:unplug';
default:
return 'lucide:activity';
}
}
private getActivityIconClass(action: string): string {
if (action === 'login' || action === 'session_created' || action === 'org_joined' || action === 'app_connected') {
return 'login';
}
if (action === 'logout' || action === 'session_revoked' || action === 'org_left' || action === 'app_disconnected') {
return 'logout';
}
return '';
}
private formatTimeAgo(timestamp: number): string {
const now = Date.now();
const diff = now - timestamp;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return 'Just now';
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return new Date(timestamp).toLocaleDateString();
}
public async firstUpdated() {
await this.loadDashboardData();
}
private async loadDashboardData() {
this.loading = true;
try {
const idpState = await IdpState.getSingletonInstance();
// Load organizations and roles from account state
await accountStateModule.accountState.dispatchAction(accountStateModule.getOrganizationsAction, null);
const state = accountStateModule.accountState.getState();
this.organizations = state.organizations;
this.roles = state.roles;
this.user = state.user;
// Load sessions
await this.loadSessions();
// Load activity
await this.loadActivity();
} catch (error) {
console.error('Error loading dashboard data:', error);
} finally {
this.loading = false;
}
}
private async loadSessions() {
try {
const idpState = await IdpState.getSingletonInstance();
const jwt = await idpState.idpClient.getJwt();
const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_GetUserSessions>(
'/typedrequest',
'getUserSessions'
);
const response = await typedRequest.fire({ jwt });
this.sessions = response?.sessions ?? [];
} catch (error) {
console.error('Error loading sessions:', error);
this.sessions = [];
}
}
private async loadActivity() {
try {
const idpState = await IdpState.getSingletonInstance();
const jwt = await idpState.idpClient.getJwt();
const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_GetUserActivity>(
'/typedrequest',
'getUserActivity'
);
const response = await typedRequest.fire({ jwt, limit: 10 });
this.activities = response?.activities ?? [];
} catch (error) {
console.error('Error loading activity:', error);
this.activities = [];
}
}
private async handleRevokeSession(sessionId: string) {
if (!confirm('Are you sure you want to revoke this session? The device will be logged out.')) {
return;
}
try {
const idpState = await IdpState.getSingletonInstance();
const jwt = await idpState.idpClient.getJwt();
const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_RevokeSession>(
'/typedrequest',
'revokeSession'
);
await typedRequest.fire({ jwt, sessionId });
await this.loadSessions();
} catch (error) {
console.error('Error revoking session:', error);
alert('Failed to revoke session');
}
}
private handleSelectOrg(org: plugins.idpInterfaces.data.IOrganization) {
accountStateModule.accountState.dispatchAction(accountStateModule.setSelectedOrg, org);
const parentElement = (this.getRootNode() as any).host;
parentElement.subrouter.pushUrl(`/org/${org.data.slug}/billing`);
}
private handleCreateOrg() {
// Dispatch event to open create org modal
this.dispatchEvent(new CustomEvent('open-create-org-modal', {
bubbles: true,
composed: true,
}));
} }
} }
+1
View File
@@ -2,5 +2,6 @@ export * from './adminview.js';
export * from './appsview.js'; export * from './appsview.js';
export * from './baseview.js'; export * from './baseview.js';
export * from './orgsetup.js'; export * from './orgsetup.js';
export * from './orgview.js';
export * from './paddlesetup.js'; export * from './paddlesetup.js';
export * from './subscriptions.js'; export * from './subscriptions.js';
+514
View File
@@ -0,0 +1,514 @@
import * as plugins from '../../../plugins.js';
import {
customElement,
DeesElement,
property,
html,
cssManager,
css,
state,
type TemplateResult,
} from '@design.estate/dees-element';
import { accountDesignTokens } from '../sharedstyles.js';
import * as accountStateModule from '../../../states/accountstate.js';
import { IdpState } from '../../../states/idp.state.js';
declare global {
interface HTMLElementTagNameMap {
'lele-accountview-orgview': OrgView;
}
}
interface IOrgStats {
memberCount: number;
appCount: number;
}
@customElement('lele-accountview-orgview')
export class OrgView extends DeesElement {
@state()
accessor loading: boolean = true;
@state()
accessor organization: plugins.idpInterfaces.data.IOrganization | null = null;
@state()
accessor userRole: plugins.idpInterfaces.data.IRole | null = null;
@state()
accessor stats: IOrgStats = { memberCount: 0, appCount: 0 };
public static styles = [
cssManager.defaultStyles,
accountDesignTokens,
css`
:host {
display: block;
min-height: 100%;
background: var(--background);
color: var(--foreground);
}
.container {
max-width: 1000px;
margin: 0 auto;
padding: 32px 24px;
}
.header {
margin-bottom: 32px;
}
h1 {
font-size: 32px;
font-weight: 600;
margin: 0;
letter-spacing: -0.02em;
display: flex;
align-items: center;
gap: 12px;
}
h1 dees-icon {
opacity: 0.7;
}
.subtitle {
color: #71717a;
margin-top: 8px;
font-size: 14px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 24px;
}
@media (max-width: 640px) {
.stats-grid {
grid-template-columns: 1fr;
}
}
.stat-card {
background: #18181b;
border: 1px solid #27272a;
border-radius: 12px;
padding: 20px;
text-align: center;
}
.stat-value {
font-size: 32px;
font-weight: 600;
margin-bottom: 4px;
}
.stat-label {
font-size: 13px;
color: #71717a;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.dashboard-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 768px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
}
.card {
background: #18181b;
border: 1px solid #27272a;
border-radius: 12px;
overflow: hidden;
}
.card.full-width {
grid-column: 1 / -1;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #27272a;
}
.card-title {
font-size: 16px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.card-title dees-icon {
opacity: 0.7;
}
.card-body {
padding: 16px 20px;
}
.card-body.no-padding {
padding: 0;
}
/* Info rows */
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #27272a;
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
font-size: 13px;
color: #71717a;
}
.info-value {
font-size: 14px;
font-weight: 500;
}
.info-value.slug {
font-family: 'Geist Mono', monospace;
background: #27272a;
padding: 4px 8px;
border-radius: 4px;
font-size: 13px;
}
/* Role badge */
.role-badge {
padding: 4px 12px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.role-badge.admin {
background: rgba(245, 158, 11, 0.1);
color: #f59e0b;
}
.role-badge.owner {
background: rgba(139, 92, 246, 0.1);
color: #8b5cf6;
}
/* Quick actions */
.action-list {
display: flex;
flex-direction: column;
}
.action-item {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 20px;
border-bottom: 1px solid #27272a;
cursor: pointer;
transition: background 0.15s ease;
}
.action-item:last-child {
border-bottom: none;
}
.action-item:hover {
background: #27272a;
}
.action-icon {
width: 36px;
height: 36px;
border-radius: 8px;
background: #27272a;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.action-item:hover .action-icon {
background: #3f3f46;
}
.action-icon dees-icon {
opacity: 0.7;
}
.action-info {
flex: 1;
}
.action-name {
font-size: 14px;
font-weight: 500;
margin-bottom: 2px;
}
.action-description {
font-size: 12px;
color: #71717a;
}
.action-arrow {
color: #71717a;
}
/* Billing status */
.billing-status {
display: flex;
align-items: center;
gap: 8px;
}
.billing-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: #71717a;
}
.billing-indicator.active {
background: #22c55e;
}
.billing-indicator.none {
background: #f59e0b;
}
/* Loading state */
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 48px;
color: #71717a;
}
`,
];
public render(): TemplateResult {
if (this.loading) {
return html`
<div class="container">
<div class="loading">Loading organization...</div>
</div>
`;
}
if (!this.organization) {
return html`
<div class="container">
<div class="loading">Organization not found</div>
</div>
`;
}
const roleName = this.userRole?.data.role || 'member';
const roleClass = roleName === 'owner' ? 'owner' : roleName === 'admin' ? 'admin' : '';
const roleDisplay = roleName.charAt(0).toUpperCase() + roleName.slice(1);
return html`
<div class="container">
<div class="header">
<h1>
<dees-icon .icon=${'lucide:building2'}></dees-icon>
${this.organization.data.name}
</h1>
<p class="subtitle">Organization dashboard and settings</p>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">${this.stats.memberCount}</div>
<div class="stat-label">Members</div>
</div>
<div class="stat-card">
<div class="stat-value">${this.stats.appCount}</div>
<div class="stat-label">Connected Apps</div>
</div>
<div class="stat-card">
<div class="stat-value">
<span class="role-badge ${roleClass}">${roleDisplay}</span>
</div>
<div class="stat-label">Your Role</div>
</div>
</div>
<div class="dashboard-grid">
<!-- Organization Info -->
<div class="card">
<div class="card-header">
<span class="card-title">
<dees-icon .icon=${'lucide:info'}></dees-icon>
Organization Info
</span>
</div>
<div class="card-body">
<div class="info-row">
<span class="info-label">Name</span>
<span class="info-value">${this.organization.data.name}</span>
</div>
<div class="info-row">
<span class="info-label">Slug</span>
<span class="info-value slug">${this.organization.data.slug}</span>
</div>
<div class="info-row">
<span class="info-label">Billing</span>
<span class="info-value">
<div class="billing-status">
<span class="billing-indicator ${this.organization.data.billingPlanId ? 'active' : 'none'}"></span>
${this.organization.data.billingPlanId ? 'Active' : 'Not configured'}
</div>
</span>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="card">
<div class="card-header">
<span class="card-title">
<dees-icon .icon=${'lucide:zap'}></dees-icon>
Quick Actions
</span>
</div>
<div class="card-body no-padding">
<div class="action-list">
<div class="action-item" @click=${this.navigateToApps}>
<div class="action-icon">
<dees-icon .icon=${'lucide:box'}></dees-icon>
</div>
<div class="action-info">
<div class="action-name">Manage Apps</div>
<div class="action-description">Connect and configure applications</div>
</div>
<dees-icon class="action-arrow" .icon=${'lucide:chevron-right'}></dees-icon>
</div>
<div class="action-item" @click=${this.navigateToBilling}>
<div class="action-icon">
<dees-icon .icon=${'lucide:wallet'}></dees-icon>
</div>
<div class="action-info">
<div class="action-name">View Billing</div>
<div class="action-description">Manage subscription and invoices</div>
</div>
<dees-icon class="action-arrow" .icon=${'lucide:chevron-right'}></dees-icon>
</div>
<div class="action-item" @click=${this.handleInviteUser}>
<div class="action-icon">
<dees-icon .icon=${'lucide:user-plus'}></dees-icon>
</div>
<div class="action-info">
<div class="action-name">Invite Member</div>
<div class="action-description">Add team members to your organization</div>
</div>
<dees-icon class="action-arrow" .icon=${'lucide:chevron-right'}></dees-icon>
</div>
</div>
</div>
</div>
</div>
</div>
`;
}
public async firstUpdated() {
await this.loadOrgData();
}
private async loadOrgData() {
this.loading = true;
try {
// Get the organization slug from the URL
const pathParts = window.location.pathname.split('/');
const orgSlug = pathParts[3];
const currentState = accountStateModule.accountState.getState();
const selectedOrg = currentState.organizations.find(org => org.data.slug === orgSlug);
if (!selectedOrg) {
console.error('Organization not found');
this.loading = false;
return;
}
this.organization = selectedOrg;
// Find user's role in this org
this.userRole = currentState.roles.find(r => r.data.organizationId === selectedOrg.id) || null;
// Calculate stats
const memberCount = selectedOrg.data.roleIds?.length || 1;
// Get app connections count
let appCount = 0;
try {
const idpState = await IdpState.getSingletonInstance();
const jwt = await idpState.idpClient.getJwt();
const connectionsRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_GetAppConnections>(
'/typedrequest',
'getAppConnections'
);
const connectionsResponse = await connectionsRequest.fire({
jwt,
organizationId: selectedOrg.id,
});
appCount = connectionsResponse.connections?.filter(c => c.data.status === 'active').length || 0;
} catch (error) {
console.error('Error loading app connections:', error);
}
this.stats = { memberCount, appCount };
} catch (error) {
console.error('Error loading org data:', error);
} finally {
this.loading = false;
}
}
private async navigateToApps() {
if (!this.organization) return;
const parentElement = (this.getRootNode() as any).host;
parentElement.subrouter.pushUrl(`/org/${this.organization.data.slug}/apps`);
}
private async navigateToBilling() {
if (!this.organization) return;
const parentElement = (this.getRootNode() as any).host;
parentElement.subrouter.pushUrl(`/org/${this.organization.data.slug}/billing`);
}
private handleInviteUser() {
// TODO: Implement invite user modal
alert('Invite member functionality coming soon');
}
}