From 91f06ccae16141751bbfa0ccd73b8209b0a617f3 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Thu, 7 May 2026 15:35:37 +0000 Subject: [PATCH] feat(app): wire dashboard administration flows --- npmextra.json => .smartconfig.json | 21 +- .vscode/settings.json | 2 +- html/index.html | 7 +- package.json | 56 +- pnpm-lock.yaml | 3589 +++++------------ readme.md | 46 +- stories/{README.md => readme.md} | 29 + test/test.appconnection.node.ts | 168 + test/test.oidc.node.ts | 95 +- test/test.organization.node.ts | 302 ++ ts/index.ts | 47 +- ts/plugins.ts | 2 +- ts/readme.md | 20 +- ts/reception/classes.alertmanager.ts | 5 + ts/reception/classes.appconnectionmanager.ts | 112 + ts/reception/classes.housekeeping.ts | 2 + ts/reception/classes.oidcmanager.ts | 48 +- ts/reception/classes.organization.ts | 1 + ts/reception/classes.organizationmanager.ts | 472 +++ ts/reception/classes.reception.ts | 4 +- ts/reception/classes.userinvitationmanager.ts | 41 +- ts_idpcli/plugins.ts | 4 +- ts_idpclient/classes.idpclient.ts | 2 +- ts_idpclient/classes.idprequests.ts | 30 + ts_idpclient/plugins.ts | 4 +- ts_idpclient/readme.md | 3 + ts_interfaces/00_commitinfo_data.ts | 8 - ts_interfaces/data/abusewindow.ts | 13 - ts_interfaces/data/activity.ts | 32 - ts_interfaces/data/alert.ts | 35 - ts_interfaces/data/alertrule.ts | 22 - ts_interfaces/data/app.ts | 99 - ts_interfaces/data/appconnection.ts | 16 - ts_interfaces/data/billingplan.ts | 47 - ts_interfaces/data/device.ts | 3 - ts_interfaces/data/emailactiontoken.ts | 12 - ts_interfaces/data/index.ts | 21 - ts_interfaces/data/jwt.ts | 43 - ts_interfaces/data/loginsession.ts | 38 - ts_interfaces/data/oidc.ts | 275 -- ts_interfaces/data/organization.ts | 13 - ts_interfaces/data/paddlecheckoutdata.ts | 316 -- ts_interfaces/data/passportchallenge.ts | 80 - ts_interfaces/data/passportdevice.ts | 46 - ts_interfaces/data/passportnonce.ts | 9 - ts_interfaces/data/property.ts | 12 - ts_interfaces/data/registrationsession.ts | 31 - ts_interfaces/data/role.ts | 18 - ts_interfaces/data/user.ts | 36 - ts_interfaces/data/userinvitation.ts | 58 - ts_interfaces/index.ts | 6 - ts_interfaces/plugins.ts | 9 - ts_interfaces/readme.md | 128 - ts_interfaces/request/admin.ts | 130 - ts_interfaces/request/alert.ts | 113 - ts_interfaces/request/apitoken.ts | 1 - ts_interfaces/request/app.ts | 52 - ts_interfaces/request/authorization.ts | 72 - ts_interfaces/request/billingplan.ts | 55 - ts_interfaces/request/index.ts | 14 - ts_interfaces/request/jwt.ts | 79 - ts_interfaces/request/login.ts | 181 - ts_interfaces/request/organization.ts | 51 - ts_interfaces/request/passport.ts | 227 -- ts_interfaces/request/plan.ts | 17 - ts_interfaces/request/registration.ts | 90 - ts_interfaces/request/user.ts | 142 - ts_interfaces/request/userinvitation.ts | 247 -- ts_interfaces/tags/index.ts | 12 - ts_interfaces/tspublish.json | 3 - ts_seed/classes.seedrunner.ts | 312 ++ ts_seed/cli.ts | 3 + ts_seed/index.ts | 136 + ts_seed/plugins.ts | 11 + ts_seed/readme.md | 22 + ts_seed/tspublish.json | 3 + ts_web/elements/account/content.ts | 926 ++++- ts_web/elements/account/navigation.ts | 76 +- ts_web/elements/account/views/baseview.ts | 77 +- .../elements/account/views/subscriptions.ts | 4 +- ts_web/elements/account/views/usersview.ts | 21 +- ts_web/elements/idp-centercontainer.ts | 71 +- ts_web/elements/idp-loginprompt.ts | 97 +- ts_web/elements/idp-registerprompt.ts | 64 +- ts_web/elements/idp-registration-stepper.ts | 2 +- ts_web/elements/idp-welcome.ts | 32 +- ts_web/index.ts | 32 +- ts_web/plugins.ts | 5 +- ts_web/readme.md | 4 +- ts_web/states/idp.state.ts | 24 +- ts_web/views/viewcontainer.ts | 4 +- 91 files changed, 4087 insertions(+), 5863 deletions(-) rename npmextra.json => .smartconfig.json (86%) rename stories/{README.md => readme.md} (70%) create mode 100644 test/test.appconnection.node.ts create mode 100644 test/test.organization.node.ts delete mode 100644 ts_interfaces/00_commitinfo_data.ts delete mode 100644 ts_interfaces/data/abusewindow.ts delete mode 100644 ts_interfaces/data/activity.ts delete mode 100644 ts_interfaces/data/alert.ts delete mode 100644 ts_interfaces/data/alertrule.ts delete mode 100644 ts_interfaces/data/app.ts delete mode 100644 ts_interfaces/data/appconnection.ts delete mode 100644 ts_interfaces/data/billingplan.ts delete mode 100644 ts_interfaces/data/device.ts delete mode 100644 ts_interfaces/data/emailactiontoken.ts delete mode 100644 ts_interfaces/data/index.ts delete mode 100644 ts_interfaces/data/jwt.ts delete mode 100644 ts_interfaces/data/loginsession.ts delete mode 100644 ts_interfaces/data/oidc.ts delete mode 100644 ts_interfaces/data/organization.ts delete mode 100644 ts_interfaces/data/paddlecheckoutdata.ts delete mode 100644 ts_interfaces/data/passportchallenge.ts delete mode 100644 ts_interfaces/data/passportdevice.ts delete mode 100644 ts_interfaces/data/passportnonce.ts delete mode 100644 ts_interfaces/data/property.ts delete mode 100644 ts_interfaces/data/registrationsession.ts delete mode 100644 ts_interfaces/data/role.ts delete mode 100644 ts_interfaces/data/user.ts delete mode 100644 ts_interfaces/data/userinvitation.ts delete mode 100644 ts_interfaces/index.ts delete mode 100644 ts_interfaces/plugins.ts delete mode 100644 ts_interfaces/readme.md delete mode 100644 ts_interfaces/request/admin.ts delete mode 100644 ts_interfaces/request/alert.ts delete mode 100644 ts_interfaces/request/apitoken.ts delete mode 100644 ts_interfaces/request/app.ts delete mode 100644 ts_interfaces/request/authorization.ts delete mode 100644 ts_interfaces/request/billingplan.ts delete mode 100644 ts_interfaces/request/index.ts delete mode 100644 ts_interfaces/request/jwt.ts delete mode 100644 ts_interfaces/request/login.ts delete mode 100644 ts_interfaces/request/organization.ts delete mode 100644 ts_interfaces/request/passport.ts delete mode 100644 ts_interfaces/request/plan.ts delete mode 100644 ts_interfaces/request/registration.ts delete mode 100644 ts_interfaces/request/user.ts delete mode 100644 ts_interfaces/request/userinvitation.ts delete mode 100644 ts_interfaces/tags/index.ts delete mode 100644 ts_interfaces/tspublish.json create mode 100644 ts_seed/classes.seedrunner.ts create mode 100644 ts_seed/cli.ts create mode 100644 ts_seed/index.ts create mode 100644 ts_seed/plugins.ts create mode 100644 ts_seed/readme.md create mode 100644 ts_seed/tspublish.json diff --git a/npmextra.json b/.smartconfig.json similarity index 86% rename from npmextra.json rename to .smartconfig.json index 6bb4b1f..facd00c 100644 --- a/npmextra.json +++ b/.smartconfig.json @@ -2,7 +2,7 @@ "npmci": { "npmGlobalTools": [], "dockerRegistryRepoMap": { - "registry.gitlab.com": "code.foss.global/idp.global/idp.global" + "registry.gitlab.com": "code.foss.global/idp.global/app" }, "dockerBuildargEnvMap": { "NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2" @@ -17,7 +17,7 @@ "module": { "githost": "code.foss.global", "gitscope": "idp.global", - "gitrepo": "idp.global", + "gitrepo": "app", "description": "An identity provider software managing user authentications, registrations, and sessions.", "npmPackagename": "@idp.global/idp.global", "license": "MIT", @@ -58,12 +58,13 @@ "to": "./dist_serve/bundle.js", "outputMode": "bundle", "bundler": "esbuild", - "production": true + "production": true, + "includeFiles": ["./html/index.html", "./assets/**/*"] } ] }, "@git.zone/tswatch": { - "preset": "service", + "preset": "website", "server": { "enabled": false }, @@ -71,7 +72,7 @@ { "name": "backend", "watch": "./ts/**/*", - "command": "npm run startTs", + "command": "pnpm run startTs", "restart": true, "debounce": 300, "runOnStart": true @@ -82,7 +83,15 @@ "name": "website", "from": "./ts_web/index.ts", "to": "./dist_serve/bundle.js", - "watchPatterns": ["./ts_web/**/*"] + "watchPatterns": ["./ts_web/**/*"], + "triggerReload": false + }, + { + "name": "html", + "from": "./html/index.html", + "to": "./dist_serve/index.html", + "watchPatterns": ["./html/**/*"], + "triggerReload": false } ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 3648eaa..7b514e3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,7 @@ { "json.schemas": [ { - "fileMatch": ["/npmextra.json"], + "fileMatch": ["/.smartconfig.json"], "schema": { "type": "object", "properties": { diff --git a/html/index.html b/html/index.html index 7048dad..9ef710a 100644 --- a/html/index.html +++ b/html/index.html @@ -10,14 +10,9 @@ - + - - - - - -
- -
- -
-
+ `; } + private setAdminPage(pageArg: plugins.idpCatalog.IdpAdminShell['page']) { + this.adminPage = pageArg; + if (this.subrouter) { + void this.loadAdminShellData(); + } + } + + private getSelectedOrgSlug(): string { + const currentState = states.accountState.getState(); + const selectedOrg = currentState.selectedOrg + || currentState.organizations.find((orgArg) => orgArg.id === this.selectedOrgId) + || currentState.organizations[0]; + return selectedOrg?.data?.slug || this.adminOrgs.find((orgArg) => orgArg.id === this.selectedOrgId)?.slug || this.adminOrgs[0]?.slug || ''; + } + + private getPathForPage(pageArg: plugins.idpCatalog.IdpAdminShell['page']): string | null { + const orgSlug = this.getSelectedOrgSlug(); + const orgPath = (suffixArg = '') => orgSlug ? `/org/${orgSlug}${suffixArg}` : null; + + const pageMap: Record = { + overview: '/overview', + profile: '/account/profile', + security: '/account/security', + sessions: '/account/sessions', + apps: '/account/apps', + 'org-general': orgPath(), + 'org-settings': orgPath('/settings'), + 'org-members': orgPath('/users'), + 'org-apps': orgPath('/apps'), + support: '/support', + 'ga-users': '/admin/users', + 'ga-orgs': '/admin/orgs', + 'ga-apps': '/admin/apps', + }; + + return pageMap[pageArg]; + } + + private pushDashPath(pathArg: string) { + const normalizedPath = pathArg || ''; + const absolutePath = `/dash${normalizedPath}`.replace(/\/$/, '') || '/dash'; + if (window.location.pathname.replace(/\/$/, '') === absolutePath) { + return; + } + this.subrouter.pushUrl(normalizedPath); + } + + private async handleAdminNavigate(eventArg: CustomEvent) { + const page = eventArg.detail.page; + this.setAdminPage(page); + const path = this.getPathForPage(page); + if (path !== null) { + this.pushDashPath(path); + } + } + + private async handleOrgSelect(eventArg: CustomEvent) { + const currentState = states.accountState.getState(); + const selectedOrg = currentState.organizations.find((orgArg) => orgArg.id === eventArg.detail.orgId) + || currentState.organizations.find((orgArg) => orgArg.data.slug === eventArg.detail.org?.slug); + + this.selectedOrgId = eventArg.detail.orgId; + this.setAdminPage('org-general'); + + if (selectedOrg) { + await states.accountState.dispatchAction(states.setSelectedOrg, selectedOrg); + this.pushDashPath(`/org/${selectedOrg.data.slug}`); + } else if (eventArg.detail.org?.slug) { + this.pushDashPath(`/org/${eventArg.detail.org.slug}`); + } + } + + private async handleOrgCreate() { + const org = await CreateOrgModal.show(); + if (!org) { + return; + } + + this.applyAccountState(); + this.selectedOrgId = org.id; + this.setAdminPage('org-general'); + this.pushDashPath(`/org/${org.data.slug}`); + } + + private async handleOrgUpdate(eventArg: CustomEvent) { + await this.runAdminAction(async () => { + const idpState = await IdpState.getSingletonInstance(); + const request = idpState.idpClient.typedsocket.createTypedRequest('updateOrganization'); + const response = await request.fire({ + jwt: await idpState.idpClient.getJwt(), + organizationId: eventArg.detail.organizationId, + name: eventArg.detail.name, + slug: eventArg.detail.slug, + confirmationText: eventArg.detail.confirmationText, + }); + if (!response.success) { + throw new Error(response.message || 'Organization update failed.'); + } + + await states.accountState.dispatchAction(states.getOrganizationsAction, null); + const refreshedOrg = states.accountState.getState().organizations.find((orgArg) => orgArg.id === response.organization.id) || response.organization; + await states.accountState.dispatchAction(states.setSelectedOrg, refreshedOrg); + this.applyAccountState(); + this.selectedOrgId = refreshedOrg.id; + this.setAdminPage('org-settings'); + this.pushDashPath(`/org/${refreshedOrg.data.slug}/settings`); + }); + } + + private async handleOrgTransfer(eventArg: CustomEvent) { + await this.runAdminAction(async () => { + const idpState = await IdpState.getSingletonInstance(); + const request = idpState.idpClient.typedsocket.createTypedRequest('transferOwnership'); + const response = await request.fire({ + jwt: await idpState.idpClient.getJwt(), + organizationId: eventArg.detail.organizationId, + newOwnerId: eventArg.detail.newOwnerId, + confirmationText: eventArg.detail.confirmationText, + }); + if (!response.success) { + throw new Error(response.message || 'Ownership transfer failed.'); + } + + await states.accountState.dispatchAction(states.getOrganizationsAction, null); + const refreshedOrg = states.accountState.getState().organizations.find((orgArg) => orgArg.id === eventArg.detail.organizationId); + if (refreshedOrg) { + await states.accountState.dispatchAction(states.setSelectedOrg, refreshedOrg); + this.selectedOrgId = refreshedOrg.id; + } + this.applyAccountState(); + this.setAdminPage('org-settings'); + }); + } + + private async handleOrgDelete(eventArg: CustomEvent) { + await this.runAdminAction(async () => { + const idpState = await IdpState.getSingletonInstance(); + const request = idpState.idpClient.typedsocket.createTypedRequest('deleteOrganization'); + const response = await request.fire({ + jwt: await idpState.idpClient.getJwt(), + organizationId: eventArg.detail.organizationId, + confirmationText: eventArg.detail.confirmationText, + }); + if (!response.success) { + throw new Error(response.message || 'Organization deletion failed.'); + } + + await states.accountState.dispatchAction(states.getOrganizationsAction, null); + const nextOrg = states.accountState.getState().organizations[0] || null; + if (nextOrg) { + await states.accountState.dispatchAction(states.setSelectedOrg, nextOrg); + } else { + await states.accountState.dispatchAction(states.setSelectedOrg, null as any); + } + this.selectedOrgId = nextOrg?.id || ''; + this.applyAccountState(); + this.setAdminPage('overview'); + this.pushDashPath('/overview'); + }); + } + + private async syncSelectedOrgFromPath() { + const orgSlug = window.location.pathname.match(/^\/dash\/org\/([^/]+)/)?.[1]; + if (!orgSlug) { + return; + } + + const currentState = states.accountState.getState(); + const selectedOrg = currentState.organizations.find((orgArg) => orgArg.data.slug === orgSlug); + if (!selectedOrg) { + return; + } + + this.selectedOrgId = selectedOrg.id; + if (currentState.selectedOrg?.id !== selectedOrg.id) { + await states.accountState.dispatchAction(states.setSelectedOrg, selectedOrg); + } + } + + private applyAccountState() { + const currentState = states.accountState.getState(); + const user = currentState.user; + + if (user) { + this.adminUser = { + name: user.data.name || user.data.username || user.data.email, + email: user.data.email, + username: user.data.username, + mobileNumber: user.data.mobileNumber, + status: user.data.status, + }; + this.globalAdmin = Boolean(user.data.isGlobalAdmin); + } + + this.adminOrgs = currentState.organizations.map((orgArg) => { + const role = currentState.roles.find((roleArg) => roleArg.data.organizationId === orgArg.id); + return { + id: orgArg.id, + name: orgArg.data.name, + slug: orgArg.data.slug, + myRole: role?.data.roles?.[0] || 'member', + }; + }); + + this.selectedOrgId = currentState.selectedOrg?.id || this.selectedOrgId || currentState.organizations[0]?.id || ''; + const selectedOrg = currentState.organizations.find((orgArg) => orgArg.id === this.selectedOrgId) || currentState.selectedOrg || currentState.organizations[0]; + this.orgRoleDefinitions = selectedOrg?.data.roleDefinitions || []; + } + + private async setOrgPage(pageArg: plugins.idpCatalog.IdpAdminShell['page']) { + await this.syncSelectedOrgFromPath(); + this.setAdminPage(pageArg); + } + + private getSelectedOrganization(): plugins.idpInterfaces.data.IOrganization | null { + const currentState = states.accountState.getState(); + return currentState.selectedOrg + || currentState.organizations.find((orgArg) => orgArg.id === this.selectedOrgId) + || currentState.organizations[0] + || null; + } + + private async loadSessions(idpStateArg: IdpState, jwtArg: string): Promise { + const request = idpStateArg.idpClient.typedsocket.createTypedRequest('getUserSessions'); + const response = await request.fire({ jwt: jwtArg }); + return (response.sessions || []).map((sessionArg) => ({ + id: sessionArg.id, + deviceName: sessionArg.deviceName, + browser: sessionArg.browser, + os: sessionArg.os, + ip: sessionArg.ip, + lastActive: sessionArg.lastActive, + createdAt: sessionArg.createdAt, + isCurrent: sessionArg.isCurrent, + })); + } + + private async loadActivities(idpStateArg: IdpState, jwtArg: string): Promise { + const request = idpStateArg.idpClient.typedsocket.createTypedRequest('getUserActivity'); + const response = await request.fire({ jwt: jwtArg, limit: 20 }); + return (response.activities || []).map((activityArg) => ({ + id: activityArg.id, + action: activityArg.data.action, + description: activityArg.data.metadata.description, + timestamp: activityArg.data.timestamp, + ip: activityArg.data.metadata.ip, + targetType: activityArg.data.metadata.targetType, + })); + } + + private async loadOrgMembers(idpStateArg: IdpState, jwtArg: string, organizationIdArg: string): Promise { + const currentState = states.accountState.getState(); + const request = idpStateArg.idpClient.typedsocket.createTypedRequest('getOrgMembers'); + const response = await request.fire({ jwt: jwtArg, organizationId: organizationIdArg }); + return (response.members || []).map((memberArg) => ({ + userId: memberArg.user.id, + name: memberArg.user.data.name || memberArg.user.data.username || memberArg.user.data.email, + email: memberArg.user.data.email, + roles: memberArg.role.data.roles || [], + isCurrentUser: currentState.user?.id === memberArg.user.id, + })); + } + + private async loadOrgInvitations(idpStateArg: IdpState, jwtArg: string, organizationIdArg: string): Promise { + const request = idpStateArg.idpClient.typedsocket.createTypedRequest('getOrgInvitations'); + const response = await request.fire({ jwt: jwtArg, organizationId: organizationIdArg }); + return (response.invitations || []).map((invitationArg) => { + const orgRef = invitationArg.data.organizationRefs.find((refArg) => refArg.organizationId === organizationIdArg) + || invitationArg.data.organizationRefs[0]; + return { + id: invitationArg.id, + email: invitationArg.data.email, + roles: orgRef?.roles || [], + invitedAt: orgRef?.invitedAt || invitationArg.data.createdAt, + expiresAt: invitationArg.data.expiresAt, + status: invitationArg.data.status, + }; + }); + } + + private async loadOrgApps(idpStateArg: IdpState, jwtArg: string, organizationIdArg: string): Promise { + const appsRequest = idpStateArg.idpClient.typedsocket.createTypedRequest('getGlobalApps'); + const connectionsRequest = idpStateArg.idpClient.typedsocket.createTypedRequest('getAppConnections'); + const [appsResponse, connectionsResponse] = await Promise.all([ + appsRequest.fire({ jwt: jwtArg }), + connectionsRequest.fire({ jwt: jwtArg, organizationId: organizationIdArg }), + ]); + const activeConnectionMap = new Map((connectionsResponse.connections || []) + .filter((connectionArg) => connectionArg.data.status === 'active') + .map((connectionArg) => [connectionArg.data.appId, connectionArg])); + return (appsResponse.apps || []).map((appArg) => ({ + id: appArg.id, + name: appArg.data.name, + description: appArg.data.description, + logoUrl: appArg.data.logoUrl, + appUrl: appArg.data.appUrl, + category: appArg.data.category, + type: appArg.type, + status: appArg.data.isActive ? 'active' : 'inactive', + isConnected: activeConnectionMap.has(appArg.id), + roleMappings: activeConnectionMap.get(appArg.id)?.data.roleMappings || [], + clientId: appArg.data.oauthCredentials.clientId, + scopes: activeConnectionMap.get(appArg.id)?.data.grantedScopes || appArg.data.oauthCredentials.allowedScopes || [], + grants: appArg.data.oauthCredentials.grantTypes || [], + })); + } + + private async loadAdminApps(idpStateArg: IdpState, jwtArg: string): Promise { + if (!this.globalAdmin) { + return []; + } + + const request = idpStateArg.idpClient.typedsocket.createTypedRequest('getGlobalAppStats'); + const response = await request.fire({ jwt: jwtArg }); + return (response.apps || []).map((entryArg) => ({ + id: entryArg.app.id, + name: entryArg.app.data.name, + description: entryArg.app.data.description, + logoUrl: entryArg.app.data.logoUrl, + appUrl: entryArg.app.data.appUrl, + category: entryArg.app.data.category, + type: entryArg.app.type, + status: entryArg.app.data.isActive ? 'active' : 'inactive', + connectionCount: entryArg.connectionCount, + clientId: entryArg.app.data.oauthCredentials.clientId, + scopes: entryArg.app.data.oauthCredentials.allowedScopes || [], + grants: entryArg.app.data.oauthCredentials.grantTypes || [], + })); + } + + private async loadPassportDevices(idpStateArg: IdpState, jwtArg: string): Promise { + const request = idpStateArg.idpClient.typedsocket.createTypedRequest('getPassportDevices'); + const response = await request.fire({ jwt: jwtArg }); + return (response.devices || []).map((deviceArg) => ({ + id: deviceArg.id, + label: deviceArg.data.label, + platform: deviceArg.data.platform, + status: deviceArg.data.status, + capabilities: deviceArg.data.capabilities, + appVersion: deviceArg.data.appVersion, + createdAt: deviceArg.data.createdAt, + lastSeenAt: deviceArg.data.lastSeenAt, + lastChallengeAt: deviceArg.data.lastChallengeAt, + pushRegistered: Boolean(deviceArg.data.pushRegistration), + })); + } + + private async loadAdminShellData() { + const currentRun = ++this.dataLoadRun; + this.dataLoading = true; + this.dataError = ''; + + try { + const idpState = await IdpState.getSingletonInstance(); + const jwt = await idpState.idpClient.getJwt(); + const selectedOrg = this.getSelectedOrganization(); + const orgId = selectedOrg?.id || ''; + + const [sessions, activities, members, invitations, orgApps, adminApps, passportDevices] = await Promise.all([ + this.loadSessions(idpState, jwt).catch((error) => { + console.error('Error loading sessions:', error); + return this.sessions; + }), + this.loadActivities(idpState, jwt).catch((error) => { + console.error('Error loading activity:', error); + return this.activities; + }), + orgId ? this.loadOrgMembers(idpState, jwt, orgId).catch((error) => { + console.error('Error loading org members:', error); + return this.orgMembers; + }) : Promise.resolve([]), + orgId ? this.loadOrgInvitations(idpState, jwt, orgId).catch((error) => { + console.error('Error loading org invitations:', error); + return this.orgInvitations; + }) : Promise.resolve([]), + orgId ? this.loadOrgApps(idpState, jwt, orgId).catch((error) => { + console.error('Error loading org apps:', error); + return this.orgApps; + }) : Promise.resolve([]), + this.loadAdminApps(idpState, jwt).catch((error) => { + console.error('Error loading admin apps:', error); + return this.adminApps; + }), + this.loadPassportDevices(idpState, jwt).catch((error) => { + console.error('Error loading passport devices:', error); + return this.passportDevices; + }), + ]); + + if (currentRun !== this.dataLoadRun) { + return; + } + + this.sessions = sessions; + this.activities = activities; + this.orgMembers = members; + this.orgInvitations = invitations; + this.orgApps = orgApps; + this.adminApps = adminApps; + this.passportDevices = passportDevices; + } catch (error) { + console.error('Error loading admin shell data:', error); + if (currentRun === this.dataLoadRun) { + this.dataError = error instanceof Error ? error.message : 'Failed to load admin console data.'; + } + } finally { + if (currentRun === this.dataLoadRun) { + this.dataLoading = false; + } + } + } + + private async runAdminAction(actionArg: () => Promise) { + this.dataError = ''; + try { + await actionArg(); + await this.loadAdminShellData(); + } catch (error) { + console.error('Admin console action failed:', error); + this.dataError = error instanceof Error ? error.message : 'Action failed. Please try again.'; + } + } + + private async handleSessionRevoke(eventArg: CustomEvent) { + await this.runAdminAction(async () => { + const idpState = await IdpState.getSingletonInstance(); + const request = idpState.idpClient.typedsocket.createTypedRequest('revokeSession'); + await request.fire({ jwt: await idpState.idpClient.getJwt(), sessionId: eventArg.detail.sessionId }); + }); + } + + private async handleAppToggle(eventArg: CustomEvent) { + const selectedOrg = this.getSelectedOrganization(); + if (!selectedOrg) { + this.dataError = 'Select an organisation before changing app connections.'; + return; + } + + await this.runAdminAction(async () => { + const idpState = await IdpState.getSingletonInstance(); + const request = idpState.idpClient.typedsocket.createTypedRequest('toggleAppConnection'); + await request.fire({ + jwt: await idpState.idpClient.getJwt(), + organizationId: selectedOrg.id, + appId: eventArg.detail.appId, + action: eventArg.detail.connected ? 'connect' : 'disconnect', + }); + }); + } + + private async handlePasswordChange(eventArg: CustomEvent) { + const email = states.accountState.getState().user?.data.email; + if (!email) { + this.credentialMessage = ''; + this.dataError = 'Cannot change password before account data is loaded.'; + return; + } + + await this.runAdminAction(async () => { + const idpState = await IdpState.getSingletonInstance(); + const request = idpState.idpClient.typedsocket.createTypedRequest('setNewPassword'); + const response = await request.fire({ + email, + oldPassword: eventArg.detail.currentPassword, + newPassword: eventArg.detail.newPassword, + }); + if (response.status !== 'ok') { + throw new Error('Password change failed.'); + } + this.credentialMessage = 'Password changed successfully.'; + }); + } + + private async handlePassportEnroll(eventArg: CustomEvent) { + await this.runAdminAction(async () => { + const idpState = await IdpState.getSingletonInstance(); + const request = idpState.idpClient.typedsocket.createTypedRequest('createPassportEnrollmentChallenge'); + const response = await request.fire({ + jwt: await idpState.idpClient.getJwt(), + deviceLabel: eventArg.detail.deviceLabel, + platform: 'web', + capabilities: { + gps: false, + nfc: false, + push: false, + }, + }); + this.passportEnrollment = response; + this.credentialMessage = 'Passport enrollment challenge created.'; + }); + } + + private async handlePassportRevoke(eventArg: CustomEvent) { + const device = this.passportDevices.find((deviceArg) => deviceArg.id === eventArg.detail.deviceId); + if (!device || !confirm(`Revoke passport device ${device.label}?`)) { + return; + } + + await this.runAdminAction(async () => { + const idpState = await IdpState.getSingletonInstance(); + const request = idpState.idpClient.typedsocket.createTypedRequest('revokePassportDevice'); + await request.fire({ + jwt: await idpState.idpClient.getJwt(), + deviceId: eventArg.detail.deviceId, + }); + this.credentialMessage = 'Passport device revoked.'; + }); + } + + private async handleMemberInvite() { + const selectedOrg = this.getSelectedOrganization(); + if (!selectedOrg) { + this.dataError = 'Select an organisation before inviting members.'; + return; + } + + const result = await BulkInviteModal.show({ + organizationId: selectedOrg.id, + organizationName: selectedOrg.data.name, + }); + if (result?.invitedCount) { + await this.loadAdminShellData(); + } + } + + private async handleMemberRemove(eventArg: CustomEvent) { + const selectedOrg = this.getSelectedOrganization(); + const member = this.orgMembers.find((memberArg) => memberArg.userId === eventArg.detail.userId); + if (!selectedOrg || !member || !confirm(`Remove ${member.name} from ${selectedOrg.data.name}?`)) { + return; + } + + await this.runAdminAction(async () => { + const idpState = await IdpState.getSingletonInstance(); + const request = idpState.idpClient.typedsocket.createTypedRequest('removeMember'); + await request.fire({ jwt: await idpState.idpClient.getJwt(), organizationId: selectedOrg.id, userId: member.userId }); + }); + } + + private async handleMemberRolesUpdate(eventArg: CustomEvent) { + const selectedOrg = this.getSelectedOrganization(); + if (!selectedOrg) { + this.dataError = 'Select an organisation before editing member roles.'; + return; + } + + await this.runAdminAction(async () => { + const idpState = await IdpState.getSingletonInstance(); + const request = idpState.idpClient.typedsocket.createTypedRequest('updateMemberRoles'); + const response = await request.fire({ + jwt: await idpState.idpClient.getJwt(), + organizationId: selectedOrg.id, + userId: eventArg.detail.userId, + roles: eventArg.detail.roles, + }); + if (!response.success) { + throw new Error(response.message || 'Member role update failed.'); + } + await states.accountState.dispatchAction(states.getOrganizationsAction, null); + this.applyAccountState(); + }); + } + + private async handleOrgRoleUpsert(eventArg: CustomEvent) { + await this.runAdminAction(async () => { + const idpState = await IdpState.getSingletonInstance(); + const request = idpState.idpClient.typedsocket.createTypedRequest('upsertOrgRoleDefinition'); + const response = await request.fire({ + jwt: await idpState.idpClient.getJwt(), + organizationId: eventArg.detail.organizationId, + roleDefinition: eventArg.detail.roleDefinition, + }); + if (!response.success) { + throw new Error(response.message || 'Organization role update failed.'); + } + await states.accountState.dispatchAction(states.getOrganizationsAction, null); + this.applyAccountState(); + }); + } + + private async handleOrgRoleDelete(eventArg: CustomEvent) { + await this.runAdminAction(async () => { + const idpState = await IdpState.getSingletonInstance(); + const request = idpState.idpClient.typedsocket.createTypedRequest('deleteOrgRoleDefinition'); + const response = await request.fire({ + jwt: await idpState.idpClient.getJwt(), + organizationId: eventArg.detail.organizationId, + roleKey: eventArg.detail.roleKey, + confirmationText: eventArg.detail.confirmationText, + }); + if (!response.success) { + throw new Error(response.message || 'Organization role delete failed.'); + } + await states.accountState.dispatchAction(states.getOrganizationsAction, null); + this.applyAccountState(); + }); + } + + private async handleAppRoleMappingsUpdate(eventArg: CustomEvent) { + await this.runAdminAction(async () => { + const idpState = await IdpState.getSingletonInstance(); + const request = idpState.idpClient.typedsocket.createTypedRequest('updateAppRoleMappings'); + const response = await request.fire({ + jwt: await idpState.idpClient.getJwt(), + organizationId: eventArg.detail.organizationId, + appId: eventArg.detail.appId, + roleMappings: eventArg.detail.roleMappings, + }); + if (!response.success) { + throw new Error(response.message || 'App role mapping update failed.'); + } + }); + } + + private async handleInvitationResend(eventArg: CustomEvent) { + const selectedOrg = this.getSelectedOrganization(); + if (!selectedOrg) { + return; + } + + await this.runAdminAction(async () => { + const idpState = await IdpState.getSingletonInstance(); + const request = idpState.idpClient.typedsocket.createTypedRequest('resendInvitation'); + await request.fire({ jwt: await idpState.idpClient.getJwt(), organizationId: selectedOrg.id, invitationId: eventArg.detail.invitationId }); + }); + } + + private async handleInvitationCancel(eventArg: CustomEvent) { + const selectedOrg = this.getSelectedOrganization(); + if (!selectedOrg || !confirm('Cancel this invitation?')) { + return; + } + + await this.runAdminAction(async () => { + const idpState = await IdpState.getSingletonInstance(); + const request = idpState.idpClient.typedsocket.createTypedRequest('cancelInvitation'); + await request.fire({ jwt: await idpState.idpClient.getJwt(), organizationId: selectedOrg.id, invitationId: eventArg.detail.invitationId }); + }); + } + public async firstUpdated(_changedProperties: Map): Promise { super.firstUpdated(_changedProperties); await this.domtoolsPromise; - this.subrouter = this.domtools.router.createSubRouter('/account'); - const viewcontainer: HTMLDivElement = this.shadowRoot.querySelector('.viewcontainer'); - - // Setup event listeners for modals - this.addEventListener('open-org-select-modal', (async (e: CustomEvent) => { - const result = await OrgSelectModal.show({ - targetPath: e.detail.targetPath, - title: e.detail.title, - description: e.detail.description, - }); - if (result) { - this.subrouter.pushUrl(result.path); - } - }) as EventListener); - - this.addEventListener('open-create-org-modal', async () => { - const org = await CreateOrgModal.show(); - if (org) { - this.subrouter.pushUrl(`/org/${org.data.slug}/billing`); - } - }); - - const cleanupViews = async () => { - for (const child of Array.from(viewcontainer.children)) { - viewcontainer.removeChild(child); - } - }; - - viewcontainer.append(new views.BaseView()); - console.log(`loaded base view`); + this.subrouter = this.domtools.router.createSubRouter('/dash'); + await states.accountState.dispatchAction(states.getOrganizationsAction, null); + this.applyAccountState(); this.subrouter.on('', async () => { - viewcontainer.classList.add('changing'); - await this.domtools.convenience.smartdelay.delayFor(300); - console.log('We are viewing the account overview'); - await cleanupViews(); - viewcontainer.append(new views.BaseView()); - viewcontainer.classList.remove('changing'); - await this.domtools.convenience.smartdelay.delayFor(300); + this.pushDashPath('/overview'); }); - this.subrouter.on('/org/:orgName/billing', async () => { - viewcontainer.classList.add('changing'); - await this.domtools.convenience.smartdelay.delayFor(300); - console.log('We are viewing the billing page'); - await cleanupViews(); - viewcontainer.append(new views.SubscriptionView()); - viewcontainer.classList.remove('changing'); - await this.domtools.convenience.smartdelay.delayFor(300); + this.subrouter.on('/overview', async () => { + this.setAdminPage('overview'); }); - this.subrouter.on('/org/:orgName/paddlesetup', async () => { - viewcontainer.classList.add('changing'); - await this.domtools.convenience.smartdelay.delayFor(300); - console.log('We are viewing the paddle setup page'); - await cleanupViews(); - viewcontainer.append(new views.PaddleSetupView()); - viewcontainer.classList.remove('changing'); - await this.domtools.convenience.smartdelay.delayFor(300); + this.subrouter.on('/account/profile', async () => { + this.setAdminPage('profile'); + }); + + this.subrouter.on('/account/security', async () => { + this.setAdminPage('security'); + }); + + this.subrouter.on('/account/sessions', async () => { + this.setAdminPage('sessions'); + }); + + this.subrouter.on('/account/apps', async () => { + this.setAdminPage('apps'); + }); + + this.subrouter.on('/support', async () => { + this.setAdminPage('support'); }); 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); + await this.setOrgPage('org-general'); + }); + + this.subrouter.on('/org/:orgName/settings', async () => { + await this.setOrgPage('org-settings'); }); this.subrouter.on('/org/:orgName/apps', async () => { - viewcontainer.classList.add('changing'); - await this.domtools.convenience.smartdelay.delayFor(300); - console.log('We are viewing the apps page'); - await cleanupViews(); - viewcontainer.append(new views.AppsView()); - viewcontainer.classList.remove('changing'); - await this.domtools.convenience.smartdelay.delayFor(300); + await this.setOrgPage('org-apps'); }); this.subrouter.on('/org/:orgName/users', async () => { - viewcontainer.classList.add('changing'); - await this.domtools.convenience.smartdelay.delayFor(300); - console.log('We are viewing the users page'); - await cleanupViews(); - viewcontainer.append(new views.UsersView()); - viewcontainer.classList.remove('changing'); - await this.domtools.convenience.smartdelay.delayFor(300); + await this.setOrgPage('org-members'); }); this.subrouter.on('/admin', async () => { - viewcontainer.classList.add('changing'); - await this.domtools.convenience.smartdelay.delayFor(300); - console.log('We are viewing the admin page'); - await cleanupViews(); - viewcontainer.append(new views.AdminView()); - viewcontainer.classList.remove('changing'); - await this.domtools.convenience.smartdelay.delayFor(300); + this.pushDashPath('/admin/apps'); + }); + + this.subrouter.on('/admin/users', async () => { + this.setAdminPage('ga-users'); + }); + + this.subrouter.on('/admin/orgs', async () => { + this.setAdminPage('ga-orgs'); + }); + + this.subrouter.on('/admin/apps', async () => { + this.setAdminPage('ga-apps'); }); this.subrouter._handleRouteState(); + states.accountState.select((stateArg) => stateArg.user).subscribe(() => this.applyAccountState()); + states.accountState.select((stateArg) => stateArg.organizations).subscribe(() => this.applyAccountState()); + states.accountState.select((stateArg) => stateArg.roles).subscribe(() => this.applyAccountState()); + states.accountState.select((stateArg) => stateArg.selectedOrg).subscribe(() => this.applyAccountState()); + this.registerGarbageFunction(async () => { this.subrouter.destroy(); }) diff --git a/ts_web/elements/account/navigation.ts b/ts_web/elements/account/navigation.ts index 07efb11..e6f99f9 100644 --- a/ts_web/elements/account/navigation.ts +++ b/ts_web/elements/account/navigation.ts @@ -58,7 +58,7 @@ export class LeleAccountNavigation extends DeesElement { description, }); if (result) { - await this.navigateTo(result.path.replace('/account', '')); + await this.navigateTo(result.path.replace('/dash', '')); } } } @@ -101,8 +101,7 @@ export class LeleAccountNavigation extends DeesElement { opacity: 0.8; } - .logo dees-icon { - font-size: 24px; + .logo idp-icon { opacity: 0.9; } @@ -157,13 +156,12 @@ export class LeleAccountNavigation extends DeesElement { color: var(--foreground); } - .navigationOption dees-icon { - font-size: 16px; + .navigationOption idp-icon { opacity: 0.7; flex-shrink: 0; } - .navigationOption:hover dees-icon { + .navigationOption:hover idp-icon { opacity: 1; } @@ -172,7 +170,7 @@ export class LeleAccountNavigation extends DeesElement { color: var(--foreground); } - .navigationOption.active dees-icon { + .navigationOption.active idp-icon { opacity: 1; } @@ -182,7 +180,7 @@ export class LeleAccountNavigation extends DeesElement { margin: 8px 16px; } - dees-input-dropdown { + idp-select { margin: 8px; } `, @@ -197,7 +195,7 @@ export class LeleAccountNavigation extends DeesElement { return html`
@@ -208,7 +206,7 @@ export class LeleAccountNavigation extends DeesElement { class="navigationOption ${this.isActive('') ? 'active' : ''}" @click=${() => this.navigateTo('')} > - + Overview
- + Manage Roles
- + Log Out
- { + ) => { // Handle "Create new..." option if (eventArg.detail.key === '__create_new__') { const org = await CreateOrgModal.show(); if (org) { - await this.navigateTo(`/org/${org.data.slug}/billing`); + await this.navigateTo(`/org/${org.data.slug}/settings`); } return; } @@ -252,9 +250,9 @@ export class LeleAccountNavigation extends DeesElement { // Auto-navigate to new org's current page type (reactivity) const currentPath = window.location.pathname; if (currentPath.includes('/org/') && newOrg) { - // Extract the page type (apps, billing, etc.) and navigate to new org + // Extract the page type (apps, settings, etc.) and navigate to new org const pathParts = currentPath.split('/'); - const pageType = pathParts[5]; // /account/org/:orgName/:pageType + const pageType = pathParts[4]; // /dash/org/:orgName/:pageType if (pageType) { await this.navigateTo(`/org/${newOrg.data.slug}/${pageType}`); } else { @@ -262,42 +260,42 @@ export class LeleAccountNavigation extends DeesElement { } } }} - > + > ${this.renderAdminLink()} @@ -318,7 +316,7 @@ export class LeleAccountNavigation extends DeesElement { class="navigationOption ${this.isActive('admin') ? 'active' : ''}" @click=${() => this.navigateTo('/admin')} > - + Global Admin `; @@ -328,11 +326,11 @@ export class LeleAccountNavigation extends DeesElement { const path = this.currentPath; if (page === '') { // Account overview - exact match - return path === '/account' || path === '/account/'; + return path === '/dash' || path === '/dash/'; } if (page === 'org-overview') { - // Org overview - /account/org/:slug without trailing page type - return /^\/account\/org\/[^\/]+\/?$/.test(path); + // Org overview - /dash/org/:slug without trailing page type + return /^\/dash\/org\/[^\/]+\/?$/.test(path); } // For other pages, check if the path contains the page segment return path.includes(`/${page}`); @@ -355,8 +353,8 @@ export class LeleAccountNavigation extends DeesElement { }; requestAnimationFrame(checkPath); - const deesInputDropdown = this.shadowRoot.querySelector('dees-input-dropdown'); - const orgToMenuEntry = (orgArg?: plugins.idpInterfaces.data.IOrganization) => { + const orgSelect = this.shadowRoot.querySelector('idp-select') as plugins.idpCatalog.IdpSelect | null; + const orgToMenuEntry = (orgArg?: plugins.idpInterfaces.data.IOrganization): plugins.idpCatalog.IIdpSelectOption | null => { if (!orgArg) { return null; } @@ -378,19 +376,25 @@ export class LeleAccountNavigation extends DeesElement { .select((stateArg) => stateArg.organizations) .pipe( plugins.deesDomtools.plugins.smartrx.rxjs.ops.map((orgArrayArg) => { - const orgEntries = orgArrayArg.map(orgToMenuEntry); + const orgEntries = orgArrayArg + .map(orgToMenuEntry) + .filter((entryArg): entryArg is plugins.idpCatalog.IIdpSelectOption => Boolean(entryArg)); // Add "Create new..." at the end return [...orgEntries, createNewOption]; }) ) .subscribe((menuEntries) => { - deesInputDropdown.options = menuEntries; + if (orgSelect) { + orgSelect.options = menuEntries; + } }); states.accountState .select((stateArg) => stateArg.selectedOrg) .pipe(plugins.deesDomtools.plugins.smartrx.rxjs.ops.map(orgToMenuEntry)) .subscribe((selectedOrgArg) => { - deesInputDropdown.selectedOption = selectedOrgArg; + if (orgSelect) { + orgSelect.selectedOption = selectedOrgArg; + } }); // Check if user is global admin diff --git a/ts_web/elements/account/views/baseview.ts b/ts_web/elements/account/views/baseview.ts index 771bc9d..9a8c88b 100644 --- a/ts_web/elements/account/views/baseview.ts +++ b/ts_web/elements/account/views/baseview.ts @@ -97,14 +97,12 @@ export class BaseView extends DeesElement { } } - .card { - background: #18181b; - border: 1px solid #27272a; - border-radius: 12px; + idp-card.card::part(card) { + padding: 0; overflow: hidden; } - .card.full-width { + idp-card.card.full-width { grid-column: 1 / -1; } @@ -124,7 +122,7 @@ export class BaseView extends DeesElement { gap: 8px; } - .card-title dees-icon { + .card-title idp-icon { opacity: 0.7; } @@ -209,7 +207,7 @@ export class BaseView extends DeesElement { flex-shrink: 0; } - .org-icon dees-icon { + .org-icon idp-icon { opacity: 0.7; } @@ -290,7 +288,7 @@ export class BaseView extends DeesElement { flex-shrink: 0; } - .session-icon dees-icon { + .session-icon idp-icon { opacity: 0.7; } @@ -298,7 +296,7 @@ export class BaseView extends DeesElement { background: rgba(34, 197, 94, 0.1); } - .session-icon.current dees-icon { + .session-icon.current idp-icon { color: #22c55e; opacity: 1; } @@ -382,8 +380,7 @@ export class BaseView extends DeesElement { flex-shrink: 0; } - .activity-icon dees-icon { - font-size: 14px; + .activity-icon idp-icon { opacity: 0.7; } @@ -391,7 +388,7 @@ export class BaseView extends DeesElement { background: rgba(34, 197, 94, 0.1); } - .activity-icon.login dees-icon { + .activity-icon.login idp-icon { color: #22c55e; opacity: 1; } @@ -400,7 +397,7 @@ export class BaseView extends DeesElement { background: rgba(239, 68, 68, 0.1); } - .activity-icon.logout dees-icon { + .activity-icon.logout idp-icon { color: #ef4444; opacity: 1; } @@ -427,8 +424,7 @@ export class BaseView extends DeesElement { color: #71717a; } - .empty-state dees-icon { - font-size: 32px; + .empty-state idp-icon { opacity: 0.5; margin-bottom: 12px; } @@ -467,7 +463,7 @@ export class BaseView extends DeesElement { background: #27272a; } - .create-org-btn dees-icon { + .create-org-btn idp-icon { font-size: 14px; } `, @@ -494,10 +490,10 @@ export class BaseView extends DeesElement {
-
+
- + Profile
@@ -510,50 +506,49 @@ export class BaseView extends DeesElement {
- + -
+
- + Organizations - +
${this.renderOrganizations()}
-
+ -
+
- + Active Sessions
${this.renderSessions()}
-
+ -
+
- + Recent Activity
${this.renderActivity()}
-
+ `; @@ -563,7 +558,7 @@ export class BaseView extends DeesElement { if (this.organizations.length === 0) { return html`
- +

You're not a member of any organizations yet.

`; @@ -580,13 +575,13 @@ export class BaseView extends DeesElement { return html`
this.handleSelectOrg(org)}>
- +
${org.data.name}
${org.data.slug}
- ${roleDisplay} + ${roleDisplay}
`; })} @@ -598,7 +593,7 @@ export class BaseView extends DeesElement { if (this.sessions.length === 0) { return html`
- +

No active sessions found.

`; @@ -609,12 +604,12 @@ export class BaseView extends DeesElement { ${this.sessions.map((session) => html`
- +
${session.deviceName || 'Unknown Device'} - ${session.isCurrent ? html`Current` : ''} + ${session.isCurrent ? html`Current` : ''}
${session.browser} · ${session.os} · Last active ${this.formatTimeAgo(session.lastActive)} @@ -622,9 +617,9 @@ export class BaseView extends DeesElement {
${!session.isCurrent ? html`
- +
` : ''}
@@ -637,7 +632,7 @@ export class BaseView extends DeesElement { if (this.activities.length === 0) { return html`
- +

No recent activity.

`; @@ -648,7 +643,7 @@ export class BaseView extends DeesElement { ${this.activities.slice(0, 5).map((activity) => html`
- +
${activity.data.metadata.description}
diff --git a/ts_web/elements/account/views/subscriptions.ts b/ts_web/elements/account/views/subscriptions.ts index aac16cf..b98c133 100644 --- a/ts_web/elements/account/views/subscriptions.ts +++ b/ts_web/elements/account/views/subscriptions.ts @@ -100,7 +100,7 @@ export class SubscriptionView extends DeesElement {

Paddle

{ - // Extract org slug from current URL: /account/org/{orgSlug}/billing + // Extract org slug from current URL: /dash/org/{orgSlug}/settings const pathParts = window.location.pathname.split('/'); const orgSlug = pathParts[3]; // Use parent's subrouter for proper navigation within account section @@ -152,4 +152,4 @@ export class SubscriptionView extends DeesElement {
`; } -} \ No newline at end of file +} diff --git a/ts_web/elements/account/views/usersview.ts b/ts_web/elements/account/views/usersview.ts index e242dca..86179e0 100644 --- a/ts_web/elements/account/views/usersview.ts +++ b/ts_web/elements/account/views/usersview.ts @@ -56,6 +56,9 @@ export class UsersView extends DeesElement { @state() accessor organizationName: string = ''; + @state() + accessor organizationSlug: string = ''; + @state() accessor inviteEmail: string = ''; @@ -631,6 +634,7 @@ export class UsersView extends DeesElement { this.organizationId = selectedOrg.id; this.organizationName = selectedOrg.data.name; + this.organizationSlug = selectedOrg.data.slug; this.currentUserId = currentState.user?.id || ''; // Check if current user is admin/owner @@ -855,8 +859,8 @@ export class UsersView extends DeesElement { } private async handleTransferOwnership(newOwnerId: string, name: string) { - const confirmed = await this.showTransferConfirmation(name); - if (!confirmed) return; + const confirmationText = await this.showTransferConfirmation(name); + if (!confirmationText) return; this.submitting = true; this.actionMessage = null; @@ -873,6 +877,7 @@ export class UsersView extends DeesElement { jwt, organizationId: this.organizationId, newOwnerId, + confirmationText, }); if (response.success) { @@ -889,8 +894,10 @@ export class UsersView extends DeesElement { } } - private async showTransferConfirmation(name: string): Promise { + private async showTransferConfirmation(name: string): Promise { return new Promise((resolve) => { + const expectedText = `transfer ${this.organizationSlug}`; + let confirmationText = ''; plugins.deesCatalog.DeesModal.createAndShow({ heading: 'Transfer Ownership', content: html` @@ -899,11 +906,15 @@ export class UsersView extends DeesElement {

You will be demoted to admin role and will no longer be the owner of this organization.

+

+ Type ${expectedText} to confirm. +

+ { confirmationText = (eventArg.target as HTMLInputElement).value; }} />
`, menuOptions: [ - { name: 'Cancel', action: async (modal) => { modal.destroy(); resolve(false); } }, - { name: 'Transfer Ownership', action: async (modal) => { modal.destroy(); resolve(true); } }, + { name: 'Cancel', action: async (modal) => { modal.destroy(); resolve(null); } }, + { name: 'Transfer Ownership', action: async (modal) => { modal.destroy(); resolve(confirmationText.trim() === expectedText ? confirmationText.trim() : null); } }, ], width: 420, }); diff --git a/ts_web/elements/idp-centercontainer.ts b/ts_web/elements/idp-centercontainer.ts index 7b91516..3d500c6 100644 --- a/ts_web/elements/idp-centercontainer.ts +++ b/ts_web/elements/idp-centercontainer.ts @@ -58,7 +58,7 @@ export class IdpCenterContainer extends DeesElement { /* Left Panel - Branding */ .brand-panel { - background: linear-gradient(135deg, hsl(240 10% 8%) 0%, hsl(240 10% 4%) 50%, hsl(240 12% 6%) 100%); + background: #09090B; display: flex; flex-direction: column; justify-content: center; @@ -74,8 +74,9 @@ export class IdpCenterContainer extends DeesElement { left: 0; right: 0; bottom: 0; - background: radial-gradient(ellipse at 30% 20%, hsla(240 20% 20% / 0.3) 0%, transparent 50%), - radial-gradient(ellipse at 70% 80%, hsla(240 20% 15% / 0.2) 0%, transparent 50%); + background: + radial-gradient(ellipse at 50% -10%, rgb(110 91 230 / 0.18) 0%, transparent 58%), + radial-gradient(circle at 2px 2px, rgb(255 255 255 / 0.04) 1px, transparent 0) 0 0 / 32px 32px; pointer-events: none; } @@ -87,18 +88,41 @@ export class IdpCenterContainer extends DeesElement { .logo { font-family: 'Cal Sans', 'Geist Sans', sans-serif; - font-size: 42px; - font-weight: 600; + font-size: clamp(44px, 6vw, 72px); + font-weight: 900; color: var(--foreground); - margin: 0 0 12px 0; - letter-spacing: -0.02em; + margin: 0 0 8px 0; + letter-spacing: -0.05em; + line-height: 1; } .tagline { font-size: 18px; color: var(--muted-foreground); - margin: 0 0 48px 0; - line-height: 1.5; + margin: 0 0 44px 0; + line-height: 1.65; + max-width: 420px; + } + + .badge { + display: inline-flex; + align-items: center; + gap: 7px; + margin-bottom: 28px; + padding: 5px 12px; + border: 1px solid rgb(255 255 255 / 0.1); + border-radius: 999px; + background: rgb(255 255 255 / 0.05); + color: rgb(255 255 255 / 0.5); + font-size: 12px; + font-weight: 500; + } + + .badge-dot { + width: 6px; + height: 6px; + border-radius: 999px; + background: #16A34A; } .features { @@ -117,17 +141,16 @@ export class IdpCenterContainer extends DeesElement { width: 40px; height: 40px; border-radius: 10px; - background: hsla(240 10% 20% / 0.5); - border: 1px solid hsla(240 10% 30% / 0.3); + background: rgb(255 255 255 / 0.045); + border: 1px solid rgb(255 255 255 / 0.08); display: flex; align-items: center; justify-content: center; flex-shrink: 0; } - .feature-icon dees-icon { + .feature-icon idp-icon { color: var(--muted-foreground); - font-size: 18px; } .feature-text h3 { @@ -146,6 +169,9 @@ export class IdpCenterContainer extends DeesElement { .learn-more { margin-top: 48px; + display: flex; + gap: 12px; + flex-wrap: wrap; } /* Right Panel - Form */ @@ -258,12 +284,13 @@ export class IdpCenterContainer extends DeesElement {

idp.global

-

Your permanent identity on the web

+
Open identity infrastructure
+

One Identity. Any Scale. Yours Forever.

- +

Open Source

@@ -273,7 +300,7 @@ export class IdpCenterContainer extends DeesElement {
- +

Always Free

@@ -283,7 +310,7 @@ export class IdpCenterContainer extends DeesElement {
- +

Permanent Identity

@@ -293,10 +320,14 @@ export class IdpCenterContainer extends DeesElement {
- window.open('https://about.idp.global', '_blank')} - >Learn more + >Learn more + window.open('https://code.foss.global/idp.global/app', '_blank')} + >Source code
diff --git a/ts_web/elements/idp-loginprompt.ts b/ts_web/elements/idp-loginprompt.ts index 7187e6c..9d0521f 100644 --- a/ts_web/elements/idp-loginprompt.ts +++ b/ts_web/elements/idp-loginprompt.ts @@ -14,8 +14,6 @@ import { import '@uptime.link/webwidget'; -import '@design.estate/dees-catalog'; -import { DeesForm, DeesFormSubmit, DeesInputText } from '@design.estate/dees-catalog'; import { IdpState } from '../states/idp.state.js'; declare global { @@ -146,7 +144,7 @@ export class IdpLoginPrompt extends DeesElement { return false; } - const loginForm = this.shadowRoot.querySelector('#loginForm') as DeesForm | null; + const loginForm = this.shadowRoot.querySelector('#loginForm') as plugins.idpCatalog.IdpForm | null; loginForm?.setStatus('pending', 'preparing application authorization...'); this.oidcConsentError = ''; @@ -177,7 +175,7 @@ export class IdpLoginPrompt extends DeesElement { } const idpState = await IdpState.getSingletonInstance(); - const loginForm = this.shadowRoot.querySelector('#loginForm') as DeesForm | null; + const loginForm = this.shadowRoot.querySelector('#loginForm') as plugins.idpCatalog.IdpForm | null; loginForm?.setStatus('pending', 'authorizing application...'); this.oidcConsentError = ''; @@ -233,7 +231,7 @@ export class IdpLoginPrompt extends DeesElement { margin: 0; } - dees-form { + idp-form { display: flex; flex-direction: column; gap: 16px; @@ -318,25 +316,6 @@ export class IdpLoginPrompt extends DeesElement { gap: 12px; } - .consent-button { - border: none; - border-radius: 999px; - padding: 12px 18px; - font: inherit; - cursor: pointer; - } - - .consent-button-secondary { - background: rgba(255, 255, 255, 0.08); - color: var(--foreground); - } - - .consent-button-primary { - background: linear-gradient(135deg, #9b7bff, #5fd1ff); - color: #0a0a0a; - font-weight: 600; - } - .consent-error { color: #ff9a9a; font-size: 14px; @@ -370,16 +349,16 @@ export class IdpLoginPrompt extends DeesElement {
${this.oidcConsentError ? html`` : null}
@@ -404,29 +383,31 @@ export class IdpLoginPrompt extends DeesElement {

Sign in to your account

Enter your credentials to continue

- { + @idp-submit=${(eventArg: CustomEvent) => { this.login({ - emailAddress: eventArg.detail.data.emailAddress, - passwordArg: eventArg.detail.data.password, + emailAddress: String(eventArg.detail.data.emailAddress || ''), + passwordArg: String(eventArg.detail.data.password || ''), }); }} > - - - - + autocomplete="username" + > + + + - - - - Send Verification Email - + type="email" + autocomplete="email" + > + + Send Verification Email +
- { const idpState = await IdpState.getSingletonInstance(); - idpState.domtools.router.pushUrl('/account'); + idpState.domtools.router.pushUrl('/dash/overview'); }} - >Manage your account - Open dashboard + { const idpState = await IdpState.getSingletonInstance(); idpState.domtools.router.pushUrl('/logout'); }} - >Sign out + >Sign out
`; } @@ -124,29 +125,30 @@ export class IdpWelcome extends DeesElement {

Sign in to your account or create a new one

- { const idpState = await IdpState.getSingletonInstance(); idpState.domtools.router.pushUrl('/login'); }} - >Sign In - Sign In + { const idpState = await IdpState.getSingletonInstance(); idpState.domtools.router.pushUrl('/register'); }} - >Create Account + >Create Account
`; })}
- { - window.open('https://code.foss.global/idp.global/idp.global', '_blank'); + window.open('https://code.foss.global/idp.global/app', '_blank'); }} - >View Source Code + >View Source Code
`; diff --git a/ts_web/index.ts b/ts_web/index.ts index 78a572e..ea061c6 100644 --- a/ts_web/index.ts +++ b/ts_web/index.ts @@ -15,24 +15,26 @@ const run = async () => { 'Your permanent identity on the web', canonicalDomain: 'https://idp.global', ldCompany: { + type: 'company', name: 'Task Venture Capital GmbH', - status: 'active', - contact: { - address: { - name: 'Task Venture Capital GmbH', - city: 'Grasberg', - country: 'Germany', - houseNumber: '24', - postalCode: '28879', - streetName: 'Eickedorfer Vorweide', - }, - description: 'work', + description: 'work', + address: { name: 'Task Venture Capital GmbH', - type: 'company', - website: 'https://task.vc', - phone: '+49 421 16767 548', + city: 'Grasberg', + country: 'Germany', + countryCode: 'DE', + houseNumber: '24', + postalCode: '28879', + streetName: 'Eickedorfer Vorweide', }, - closedDate: null, + website: 'https://task.vc', + phone: '+49 421 16767 548', + registrationDetails: { + vatId: '', + registrationId: 'HRB 35230 HB', + registrationName: 'District court Bremen', + }, + status: 'active', foundedDate: { day: 1, month: 1, diff --git a/ts_web/plugins.ts b/ts_web/plugins.ts index 5b6590d..565b9ea 100644 --- a/ts_web/plugins.ts +++ b/ts_web/plugins.ts @@ -1,10 +1,11 @@ // node native // project native -import * as idpInterfaces from '../dist_ts_interfaces/index.js'; +import * as idpCatalog from '@idp.global/catalog'; +import * as idpInterfaces from '@idp.global/interfaces'; import * as leleReceptionclient from '../dist_ts_idpclient/index.js'; -export { idpInterfaces, leleReceptionclient as idpClient }; +export { idpCatalog, idpInterfaces, leleReceptionclient as idpClient }; // @api.global scope import * as typedrequest from '@api.global/typedrequest'; diff --git a/ts_web/readme.md b/ts_web/readme.md index 7696cbf..8ff0738 100644 --- a/ts_web/readme.md +++ b/ts_web/readme.md @@ -1,6 +1,6 @@ # `ts_web/` Web App Module -The `ts_web/` folder contains the frontend for `idp.global`: login, registration, account management, org management, billing, and admin UI. +The `ts_web/` folder contains the frontend for `idp.global`: login, logout, registration, account management, org management, billing, and admin UI. It is built with `@design.estate/dees-element`, `@design.estate/dees-domtools`, and the shared `idp.global` client and interface packages. @@ -42,6 +42,7 @@ The module currently includes: | `register` | `/register` | | `finishregistration` | `/finishregistration` | | `account` | `/account` | +| `logout` | `/logout` | ## Build And Run @@ -60,6 +61,7 @@ pnpm watch - The app metadata in `ts_web/index.ts` identifies the site as `idp.global`. - The frontend uses the shared client package for auth state and backend communication. - Account-related UI is split into reusable elements plus state containers in `states/`. +- The router treats `/account{/*path}` as the signed-in account area, so account subroutes can stay in the SPA shell. ## License and Legal Information diff --git a/ts_web/states/idp.state.ts b/ts_web/states/idp.state.ts index 02935c8..6d0a653 100644 --- a/ts_web/states/idp.state.ts +++ b/ts_web/states/idp.state.ts @@ -19,7 +19,7 @@ export class IdpState { public idpClient = new plugins.idpClient.IdpClient(this.receptionUrl); public domtools: domtools.DomTools; public mainStatePart: plugins.deesDomtools.plugins.smartstate.StatePart<'main', { - view: 'welcome' | 'login' | 'register' | 'finishregistration' | 'account' | 'logout'; + view: 'welcome' | 'login' | 'register' | 'finishregistration' | 'dash' | 'logout'; }> public async init() { @@ -38,6 +38,12 @@ export class IdpState { }); this.domtools.router.on('/login', async () => { + const isOauthLogin = new URL(window.location.href).searchParams.get('oauth') === 'true'; + if (!isOauthLogin && await this.idpClient.determineLoginStatus(false)) { + this.domtools.router.pushUrl('/dash/overview'); + return; + } + await this.mainStatePart.setState({ ...this.mainStatePart.getState(), view: 'login', @@ -53,6 +59,11 @@ export class IdpState { }); this.domtools.router.on('/register', async () => { + if (await this.idpClient.determineLoginStatus(false)) { + this.domtools.router.pushUrl('/dash/overview'); + return; + } + await this.mainStatePart.setState({ ...this.mainStatePart.getState(), view: 'register', @@ -66,13 +77,18 @@ export class IdpState { }) }); - this.domtools.router.on('/account{/*path}', async () => { + this.domtools.router.on('/dash{/*path}', async () => { + if (!await this.idpClient.determineLoginStatus(false)) { + this.domtools.router.pushUrl('/login'); + return; + } + await this.mainStatePart.setState({ ...this.mainStatePart.getState(), - view: 'account', + view: 'dash', }) }); this.domtools.router._handleRouteState(); } -} \ No newline at end of file +} diff --git a/ts_web/views/viewcontainer.ts b/ts_web/views/viewcontainer.ts index ad6d0ff..218c884 100644 --- a/ts_web/views/viewcontainer.ts +++ b/ts_web/views/viewcontainer.ts @@ -114,8 +114,8 @@ export class IdpViewcontainer extends DeesElement { case 'finishregistration': await this.loadElement(elements.IdpRegistrationStepper); break; - case 'account': - console.log('now on /account'); + case 'dash': + console.log('now on /dash'); await this.loadElement(elements.IdpAccountContent); break; }