diff --git a/changelog.md b/changelog.md index 9f4c049..d2c8cf2 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,19 @@ # 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) Add global admin functionality: backend admin APIs, model fields and UI integration diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 4602983..47ea5de 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@idp.global/idp.global', - version: '1.7.0', + version: '1.8.0', description: 'An identity provider software managing user authentications, registrations, and sessions.' } diff --git a/ts/reception/classes.activitylog.ts b/ts/reception/classes.activitylog.ts new file mode 100644 index 0000000..599e8d8 --- /dev/null +++ b/ts/reception/classes.activitylog.ts @@ -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(); + } +} diff --git a/ts/reception/classes.activitylogmanager.ts b/ts/reception/classes.activitylogmanager.ts new file mode 100644 index 0000000..6e2928d --- /dev/null +++ b/ts/reception/classes.activitylogmanager.ts @@ -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( + '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 + ); + } +} diff --git a/ts/reception/classes.loginsession.ts b/ts/reception/classes.loginsession.ts index 41f298d..5e313f8 100644 --- a/ts/reception/classes.loginsession.ts +++ b/ts/reception/classes.loginsession.ts @@ -60,7 +60,10 @@ export class LoginSession extends plugins.smartdata.SmartDataDbDoc< validUntil: Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ weeks: 1 }), invalidated: false, refreshToken: null, - deviceId: null + deviceId: null, + deviceInfo: null, + createdAt: Date.now(), + lastActive: Date.now(), }; public transferToken: string; diff --git a/ts/reception/classes.loginsessionmanager.ts b/ts/reception/classes.loginsessionmanager.ts index 79f3bac..6daef48 100644 --- a/ts/reception/classes.loginsessionmanager.ts +++ b/ts/reception/classes.loginsessionmanager.ts @@ -259,6 +259,83 @@ export class LoginSessionManager { ok: false } }) - ) + ); + + // Get all sessions for the current user + this.typedRouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + '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( + '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 }; + } + ) + ); } } diff --git a/ts/reception/classes.reception.ts b/ts/reception/classes.reception.ts index 5cb56ca..969f212 100644 --- a/ts/reception/classes.reception.ts +++ b/ts/reception/classes.reception.ts @@ -15,6 +15,7 @@ import { RoleManager } from './classes.rolemanager.js'; import { BillingPlanManager } from './classes.billingplanmanager.js'; import { AppManager } from './classes.appmanager.js'; import { AppConnectionManager } from './classes.appconnectionmanager.js'; +import { ActivityLogManager } from './classes.activitylogmanager.js'; export interface IReceptionOptions { /** @@ -45,6 +46,7 @@ export class Reception { public billingPlanManager = new BillingPlanManager(this); public appManager = new AppManager(this); public appConnectionManager = new AppConnectionManager(this); + public activityLogManager = new ActivityLogManager(this); housekeeping = new ReceptionHousekeeping(this); constructor(public options: IReceptionOptions) { diff --git a/ts_interfaces/data/index.ts b/ts_interfaces/data/index.ts index 63909bc..ef44008 100644 --- a/ts_interfaces/data/index.ts +++ b/ts_interfaces/data/index.ts @@ -1,3 +1,4 @@ +export * from './loint-reception.activity.js'; export * from './loint-reception.app.js'; export * from './loint-reception.appconnection.js'; export * from './loint-reception.billingplan.js'; diff --git a/ts_interfaces/data/loint-reception.activity.ts b/ts_interfaces/data/loint-reception.activity.ts new file mode 100644 index 0000000..d5f5266 --- /dev/null +++ b/ts_interfaces/data/loint-reception.activity.ts @@ -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; + }; + }; +} diff --git a/ts_interfaces/data/loint-reception.loginsession.ts b/ts_interfaces/data/loint-reception.loginsession.ts index 12029a0..872f802 100644 --- a/ts_interfaces/data/loint-reception.loginsession.ts +++ b/ts_interfaces/data/loint-reception.loginsession.ts @@ -10,5 +10,22 @@ export interface ILoginSession { * in different contexts on the same device */ 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; }; } diff --git a/ts_interfaces/request/loint-reception.user.ts b/ts_interfaces/request/loint-reception.user.ts index d721ce1..b7bd66b 100644 --- a/ts_interfaces/request/loint-reception.user.ts +++ b/ts_interfaces/request/loint-reception.user.ts @@ -84,3 +84,59 @@ export interface IReq_WhoIs { 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; + }; +} diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 4602983..47ea5de 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@idp.global/idp.global', - version: '1.7.0', + version: '1.8.0', description: 'An identity provider software managing user authentications, registrations, and sessions.' } diff --git a/ts_web/elements/account/content.ts b/ts_web/elements/account/content.ts index f416590..0cb8910 100644 --- a/ts_web/elements/account/content.ts +++ b/ts_web/elements/account/content.ts @@ -12,6 +12,8 @@ import { } from '@design.estate/dees-element'; 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 * as views from './views/index.js'; @@ -91,6 +93,8 @@ export class IdpAccountContent extends DeesElement { + + `; } @@ -100,6 +104,34 @@ export class IdpAccountContent extends DeesElement { this.subrouter = this.domtools.router.createSubRouter('/account'); 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 () => { for (const child of Array.from(viewcontainer.children)) { viewcontainer.removeChild(child); @@ -139,6 +171,16 @@ export class IdpAccountContent extends DeesElement { 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 () => { viewcontainer.classList.add('changing'); await this.domtools.convenience.smartdelay.delayFor(300); diff --git a/ts_web/elements/account/create-org-modal.ts b/ts_web/elements/account/create-org-modal.ts new file mode 100644 index 0000000..838a079 --- /dev/null +++ b/ts_web/elements/account/create-org-modal.ts @@ -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` +
+ +
+ `; + } + + private renderValidationStatus(): TemplateResult | null { + if (!this.orgSlug) { + return null; + } + + if (this.validating) { + return html` +
+ + Checking availability... +
+ `; + } + + if (this.validationResult) { + if (this.validationResult.available) { + return html` +
+ + ${this.validationResult.message} +
+ `; + } else { + return html` +
+ + ${this.validationResult.message} +
+ `; + } + } + + 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; + } + } +} diff --git a/ts_web/elements/account/index.ts b/ts_web/elements/account/index.ts index 90886cd..a34d645 100644 --- a/ts_web/elements/account/index.ts +++ b/ts_web/elements/account/index.ts @@ -1,2 +1,4 @@ export * from './content.js'; export * from './navigation.js'; +export * from './org-select-modal.js'; +export * from './create-org-modal.js'; diff --git a/ts_web/elements/account/navigation.ts b/ts_web/elements/account/navigation.ts index 6e35bf8..34d94af 100644 --- a/ts_web/elements/account/navigation.ts +++ b/ts_web/elements/account/navigation.ts @@ -183,14 +183,6 @@ export class LeleAccountNavigation extends DeesElement { Manage Roles -