Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a83858beb0 | |||
| 5f29edf449 | |||
| 173735a84e | |||
| 8756258324 | |||
| d11f5a0c72 | |||
| cc040e5088 | |||
| af0c24f7ca |
@@ -1,5 +1,38 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-12-01 - 1.9.0 - feat(account)
|
||||||
|
Refactor account UI: migrate modals to promise-based show() API and improve navigation URL tracking
|
||||||
|
|
||||||
|
- Replace inline modal elements with programmatic / static show() calls for OrgSelectModal and CreateOrgModal; navigation now reacts to the results returned from show() and pushes appropriate URLs.
|
||||||
|
- Remove embedded <idp-org-select-modal> and <idp-create-org-modal> elements from the account template to use on-demand modal invocation.
|
||||||
|
- Navigation component now exposes currentPath state, listens to popstate, and watches for external URL changes (requestAnimationFrame loop) to keep UI in sync with location changes.
|
||||||
|
- Updated readme.hints.md with guidance for dees-catalog components and clarified dees-input-* event pattern (use RxJS Subjects, subscribe to changeSubject and access element.value).
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
- Backend: Add AppManager admin endpoints (getGlobalAppStats, create/update/delete/global apps, regenerate credentials) and checkGlobalAdmin handler; enforce admin checks via verifyGlobalAdmin
|
||||||
|
- Data models: Add createdAt and createdByUserId to global app data; add optional isGlobalAdmin flag to user data (IUser)
|
||||||
|
- Typed requests: Add new request definitions in loint-reception.admin.ts and export it from request index
|
||||||
|
- UI: Expose Global Admin entry in account navigation (isGlobalAdmin reactive state), add /admin subroute and AdminView export
|
||||||
|
- Account state: Fetch whoIs() on load to populate user information for admin checks
|
||||||
|
- App seeding: Seed global apps with createdAt and createdByUserId metadata
|
||||||
|
- Docs: Story index updated to include ADM-008 Manage Global Apps and adjust priority summary
|
||||||
|
|
||||||
## 2025-12-01 - 1.6.0 - feat(apps)
|
## 2025-12-01 - 1.6.0 - feat(apps)
|
||||||
Add Apps subsystem: App and AppConnection models, managers, typed request handlers, web UI routes and documentation
|
Add Apps subsystem: App and AppConnection models, managers, typed request handlers, web UI routes and documentation
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@idp.global/idp.global",
|
"name": "@idp.global/idp.global",
|
||||||
"version": "1.6.0",
|
"version": "1.9.0",
|
||||||
"description": "An identity provider software managing user authentications, registrations, and sessions.",
|
"description": "An identity provider software managing user authentications, registrations, and sessions.",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
"typings": "dist_ts/index.d.ts",
|
"typings": "dist_ts/index.d.ts",
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
"@design.estate/dees-element": "^2.1.3",
|
"@design.estate/dees-element": "^2.1.3",
|
||||||
"@push.rocks/lik": "^6.2.2",
|
"@push.rocks/lik": "^6.2.2",
|
||||||
"@push.rocks/qenv": "^6.1.3",
|
"@push.rocks/qenv": "^6.1.3",
|
||||||
"@push.rocks/smartdata": "^7.0.14",
|
"@push.rocks/smartdata": "^7.0.15",
|
||||||
"@push.rocks/smartdelay": "^3.0.5",
|
"@push.rocks/smartdelay": "^3.0.5",
|
||||||
"@push.rocks/smarthash": "^3.2.6",
|
"@push.rocks/smarthash": "^3.2.6",
|
||||||
"@push.rocks/smartjson": "^5.2.0",
|
"@push.rocks/smartjson": "^5.2.0",
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
"@git.zone/tsbuild": "^3.1.2",
|
"@git.zone/tsbuild": "^3.1.2",
|
||||||
"@git.zone/tsbundle": "^2.6.2",
|
"@git.zone/tsbundle": "^2.6.2",
|
||||||
"@git.zone/tsrun": "^2.0.0",
|
"@git.zone/tsrun": "^2.0.0",
|
||||||
"@git.zone/tswatch": "^2.2.1",
|
"@git.zone/tswatch": "^2.2.2",
|
||||||
"@push.rocks/projectinfo": "^5.0.1",
|
"@push.rocks/projectinfo": "^5.0.1",
|
||||||
"@types/node": "^24.10.1"
|
"@types/node": "^24.10.1"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+25
-23
@@ -39,8 +39,8 @@ importers:
|
|||||||
specifier: ^6.1.3
|
specifier: ^6.1.3
|
||||||
version: 6.1.3
|
version: 6.1.3
|
||||||
'@push.rocks/smartdata':
|
'@push.rocks/smartdata':
|
||||||
specifier: ^7.0.14
|
specifier: ^7.0.15
|
||||||
version: 7.0.14
|
version: 7.0.15
|
||||||
'@push.rocks/smartdelay':
|
'@push.rocks/smartdelay':
|
||||||
specifier: ^3.0.5
|
specifier: ^3.0.5
|
||||||
version: 3.0.5
|
version: 3.0.5
|
||||||
@@ -112,8 +112,8 @@ importers:
|
|||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.0.0
|
version: 2.0.0
|
||||||
'@git.zone/tswatch':
|
'@git.zone/tswatch':
|
||||||
specifier: ^2.2.1
|
specifier: ^2.2.2
|
||||||
version: 2.2.1
|
version: 2.2.2
|
||||||
'@push.rocks/projectinfo':
|
'@push.rocks/projectinfo':
|
||||||
specifier: ^5.0.1
|
specifier: ^5.0.1
|
||||||
version: 5.0.2
|
version: 5.0.2
|
||||||
@@ -687,16 +687,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-o2/jvNsdLC8SRdH1kQ7JjNOQNu9el0FpJ/QOW3mgiC5C9reuTp18iU4kijsVVLgvw4KZv6Z289SoKPh3HPsS0g==}
|
resolution: {integrity: sha512-o2/jvNsdLC8SRdH1kQ7JjNOQNu9el0FpJ/QOW3mgiC5C9reuTp18iU4kijsVVLgvw4KZv6Z289SoKPh3HPsS0g==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
'@git.zone/tsrun@1.6.2':
|
|
||||||
resolution: {integrity: sha512-SOHbQqBg3/769/jPQcdpPCmugdEtIJINiG0O6aWx+su91GvGhheha5dAhccsCutJYErr+aJcBqBYuUYfhOfkFQ==}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
'@git.zone/tsrun@2.0.0':
|
'@git.zone/tsrun@2.0.0':
|
||||||
resolution: {integrity: sha512-yA6zCjL+kn7xfZe6sL/m4K+zYqgkznG/pF6++i/E17iwzpG6dHmW+VZmYldHe86sW4DcLMvqM6CxM+KlgaEpKw==}
|
resolution: {integrity: sha512-yA6zCjL+kn7xfZe6sL/m4K+zYqgkznG/pF6++i/E17iwzpG6dHmW+VZmYldHe86sW4DcLMvqM6CxM+KlgaEpKw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
'@git.zone/tswatch@2.2.1':
|
'@git.zone/tswatch@2.2.2':
|
||||||
resolution: {integrity: sha512-Q3CS0c2wEioeX8thyjZBZsriLsi6znCcV9S6j8ENb11986SS5N8YvhgPaOHkgcxFHQ/ShZpfC+VxS7GrxLvuMg==}
|
resolution: {integrity: sha512-dscBvB1Pg8bIvMLHMPrOnkh0AHXE9v5zuSz9t9BBmWL1ecR94gPSmIYalObMvyMrtXW4L7mBne1kU8N7DY9Otw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
'@happy-dom/global-registrator@15.11.7':
|
'@happy-dom/global-registrator@15.11.7':
|
||||||
@@ -922,8 +918,8 @@ packages:
|
|||||||
'@push.rocks/smartdata@5.16.7':
|
'@push.rocks/smartdata@5.16.7':
|
||||||
resolution: {integrity: sha512-bu/YSIjQcwxWXkAsuhqE6zs7eT+bTIKV8+/H7TbbjpzeioLCyB3dZ/41cLZk37c/EYt4d4GHgZ0ww80OiKOUMg==}
|
resolution: {integrity: sha512-bu/YSIjQcwxWXkAsuhqE6zs7eT+bTIKV8+/H7TbbjpzeioLCyB3dZ/41cLZk37c/EYt4d4GHgZ0ww80OiKOUMg==}
|
||||||
|
|
||||||
'@push.rocks/smartdata@7.0.14':
|
'@push.rocks/smartdata@7.0.15':
|
||||||
resolution: {integrity: sha512-FOb7E2gxzQo5G6McJa76YMrUp8tIeMo6pitDPKvb6q1x3k5r+CiulPui40EA9xklj4aT6wVMZo6Aozm+pOARMg==}
|
resolution: {integrity: sha512-j09BUekmjiGZuvXmdGBiIpBTXFFnxrzG4rOBjZvPO/hG1BwNrvSkIVq20mIwdYomn8JGgya6oJ4Y7NL+FKTqEA==}
|
||||||
|
|
||||||
'@push.rocks/smartdelay@3.0.5':
|
'@push.rocks/smartdelay@3.0.5':
|
||||||
resolution: {integrity: sha512-mUuI7kj2f7ztjpic96FvRIlf2RsKBa5arw81AHNsndbxO6asRcxuWL8dTVxouEIK8YsBUlj0AsrCkHhMbLQdHw==}
|
resolution: {integrity: sha512-mUuI7kj2f7ztjpic96FvRIlf2RsKBa5arw81AHNsndbxO6asRcxuWL8dTVxouEIK8YsBUlj0AsrCkHhMbLQdHw==}
|
||||||
@@ -1087,6 +1083,10 @@ packages:
|
|||||||
'@push.rocks/smartversion@3.0.5':
|
'@push.rocks/smartversion@3.0.5':
|
||||||
resolution: {integrity: sha512-8MZSo1yqyaKxKq0Q5N188l4un++9GFWVbhCAX5mXJwewZHn97ujffTeL+eOQYpWFTEpUhaq1QhL4NhqObBCt1Q==}
|
resolution: {integrity: sha512-8MZSo1yqyaKxKq0Q5N188l4un++9GFWVbhCAX5mXJwewZHn97ujffTeL+eOQYpWFTEpUhaq1QhL4NhqObBCt1Q==}
|
||||||
|
|
||||||
|
'@push.rocks/smartwatch@5.0.0':
|
||||||
|
resolution: {integrity: sha512-uuWUlTo0l5LWOWoOuTMG7zzxpUNKBcyqoB+zyQ24NHTtSYNcaUJtaQzTO2gxMXr5sqiZDkohlThS0KvsBc3g7w==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
'@push.rocks/smartxml@2.0.0':
|
'@push.rocks/smartxml@2.0.0':
|
||||||
resolution: {integrity: sha512-1d06zYJX4Zt8s5w5qFOUg2LAEz9ykrh9d6CQPK4WAgOBIefb1xzVEWHc7yoxicc2OkzNgC3IBCEg3s6BncZKWw==}
|
resolution: {integrity: sha512-1d06zYJX4Zt8s5w5qFOUg2LAEz9ykrh9d6CQPK4WAgOBIefb1xzVEWHc7yoxicc2OkzNgC3IBCEg3s6BncZKWw==}
|
||||||
|
|
||||||
@@ -4731,32 +4731,26 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- vue
|
- vue
|
||||||
|
|
||||||
'@git.zone/tsrun@1.6.2':
|
|
||||||
dependencies:
|
|
||||||
'@push.rocks/smartfile': 11.2.7
|
|
||||||
'@push.rocks/smartshell': 3.3.0
|
|
||||||
tsx: 4.20.6
|
|
||||||
|
|
||||||
'@git.zone/tsrun@2.0.0':
|
'@git.zone/tsrun@2.0.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@push.rocks/smartfile': 11.2.7
|
'@push.rocks/smartfile': 11.2.7
|
||||||
'@push.rocks/smartshell': 3.3.0
|
'@push.rocks/smartshell': 3.3.0
|
||||||
tsx: 4.20.6
|
tsx: 4.20.6
|
||||||
|
|
||||||
'@git.zone/tswatch@2.2.1':
|
'@git.zone/tswatch@2.2.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@api.global/typedserver': 3.0.80
|
'@api.global/typedserver': 3.0.80
|
||||||
'@git.zone/tsbundle': 2.6.2
|
'@git.zone/tsbundle': 2.6.2
|
||||||
'@git.zone/tsrun': 1.6.2
|
'@git.zone/tsrun': 2.0.0
|
||||||
'@push.rocks/early': 4.0.4
|
'@push.rocks/early': 4.0.4
|
||||||
'@push.rocks/lik': 6.2.2
|
'@push.rocks/lik': 6.2.2
|
||||||
'@push.rocks/smartchok': 1.2.0
|
|
||||||
'@push.rocks/smartcli': 4.0.19
|
'@push.rocks/smartcli': 4.0.19
|
||||||
'@push.rocks/smartdelay': 3.0.5
|
'@push.rocks/smartdelay': 3.0.5
|
||||||
'@push.rocks/smartfile': 11.2.7
|
'@push.rocks/smartfs': 1.1.3
|
||||||
'@push.rocks/smartlog': 3.1.10
|
'@push.rocks/smartlog': 3.1.10
|
||||||
'@push.rocks/smartlog-destination-local': 9.0.2
|
'@push.rocks/smartlog-destination-local': 9.0.2
|
||||||
'@push.rocks/smartshell': 3.3.0
|
'@push.rocks/smartshell': 3.3.0
|
||||||
|
'@push.rocks/smartwatch': 5.0.0
|
||||||
'@push.rocks/taskbuffer': 3.4.0
|
'@push.rocks/taskbuffer': 3.4.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@nuxt/kit'
|
- '@nuxt/kit'
|
||||||
@@ -5172,7 +5166,7 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- vue
|
- vue
|
||||||
|
|
||||||
'@push.rocks/smartdata@7.0.14':
|
'@push.rocks/smartdata@7.0.15':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@push.rocks/lik': 6.2.2
|
'@push.rocks/lik': 6.2.2
|
||||||
'@push.rocks/smartdelay': 3.0.5
|
'@push.rocks/smartdelay': 3.0.5
|
||||||
@@ -5626,6 +5620,14 @@ snapshots:
|
|||||||
'@types/semver': 7.7.1
|
'@types/semver': 7.7.1
|
||||||
semver: 7.7.3
|
semver: 7.7.3
|
||||||
|
|
||||||
|
'@push.rocks/smartwatch@5.0.0':
|
||||||
|
dependencies:
|
||||||
|
'@push.rocks/lik': 6.2.2
|
||||||
|
'@push.rocks/smartenv': 6.0.0
|
||||||
|
'@push.rocks/smartpromise': 4.2.3
|
||||||
|
'@push.rocks/smartrx': 3.0.10
|
||||||
|
picomatch: 4.0.3
|
||||||
|
|
||||||
'@push.rocks/smartxml@2.0.0':
|
'@push.rocks/smartxml@2.0.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-xml-parser: 5.3.2
|
fast-xml-parser: 5.3.2
|
||||||
|
|||||||
+21
-1
@@ -1,3 +1,23 @@
|
|||||||
# Project Readme Hints
|
# Project Readme Hints
|
||||||
|
|
||||||
This is the initial readme hints file.
|
## UI Components
|
||||||
|
Always check dees-catalog for available elements before implementing custom solutions:
|
||||||
|
- Documentation: https://code.foss.global/design.estate/dees-catalog
|
||||||
|
- Key components: `dees-modal`, `dees-button`, `dees-input-*`, `dees-form`, etc.
|
||||||
|
|
||||||
|
### dees-input-* Event Pattern
|
||||||
|
All dees-input components use **RxJS Subjects** for value changes, NOT DOM events:
|
||||||
|
```typescript
|
||||||
|
// Subscribe to value changes in firstUpdated():
|
||||||
|
const inputElement = this.shadowRoot.querySelector('dees-input-text');
|
||||||
|
inputElement.changeSubject.subscribe((element) => {
|
||||||
|
const value = element.value;
|
||||||
|
// handle value change
|
||||||
|
});
|
||||||
|
```
|
||||||
|
- Do NOT use `@changeValue` or similar DOM events - they don't exist
|
||||||
|
- The Subject emits the element itself, access value via `element.value`
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
- `ts_web/elements/account/` - Account dashboard components
|
||||||
|
- `ts_web/states/` - State management (accountstate, idp.state)
|
||||||
|
|||||||
+3
-2
@@ -9,7 +9,7 @@ stories/
|
|||||||
├── end-user/ # Stories for regular users (8)
|
├── end-user/ # Stories for regular users (8)
|
||||||
├── organization-owner/ # Stories for organization admins (11)
|
├── organization-owner/ # Stories for organization admins (11)
|
||||||
├── developer/ # Stories for API/SDK consumers (8)
|
├── developer/ # Stories for API/SDK consumers (8)
|
||||||
└── admin/ # Stories for platform administrators (7)
|
└── admin/ # Stories for platform administrators (8)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Story Index
|
## Story Index
|
||||||
@@ -63,13 +63,14 @@ stories/
|
|||||||
| ADM-005 | [Security Monitoring Dashboard](admin/ADM-005-security-dashboard.md) | Medium | New |
|
| ADM-005 | [Security Monitoring Dashboard](admin/ADM-005-security-dashboard.md) | Medium | New |
|
||||||
| ADM-006 | [Impersonate Users for Support](admin/ADM-006-user-impersonation.md) | Low | New |
|
| ADM-006 | [Impersonate Users for Support](admin/ADM-006-user-impersonation.md) | Low | New |
|
||||||
| ADM-007 | [Manage JWT Blocklist](admin/ADM-007-blocklist-management.md) | Medium | Enhance |
|
| ADM-007 | [Manage JWT Blocklist](admin/ADM-007-blocklist-management.md) | Medium | Enhance |
|
||||||
|
| ADM-008 | [Manage Global Apps](admin/ADM-008-global-app-management.md) | High | In Development |
|
||||||
|
|
||||||
## Priority Summary
|
## Priority Summary
|
||||||
|
|
||||||
| Priority | Count | Stories |
|
| Priority | Count | Stories |
|
||||||
|----------|-------|---------|
|
|----------|-------|---------|
|
||||||
| Critical | 3 | EU-002, ORG-002, ADM-001 |
|
| Critical | 3 | EU-002, ORG-002, ADM-001 |
|
||||||
| High | 11 | EU-001, EU-004, ORG-001, ORG-003, ORG-006, ORG-009, DEV-001, DEV-002, DEV-004, ADM-002, ADM-003 |
|
| High | 12 | EU-001, EU-004, ORG-001, ORG-003, ORG-006, ORG-009, DEV-001, DEV-002, DEV-004, ADM-002, ADM-003, ADM-008 |
|
||||||
| Medium | 14 | EU-003, EU-005, EU-006, ORG-004, ORG-005, ORG-007, ORG-008, ORG-010, ORG-011, DEV-003, DEV-005, DEV-007, ADM-004, ADM-005, ADM-007 |
|
| Medium | 14 | EU-003, EU-005, EU-006, ORG-004, ORG-005, ORG-007, ORG-008, ORG-010, ORG-011, DEV-003, DEV-005, DEV-007, ADM-004, ADM-005, ADM-007 |
|
||||||
| Low | 6 | EU-007, EU-008, DEV-006, DEV-008, ADM-006 |
|
| Low | 6 | EU-007, EU-008, DEV-006, DEV-008, ADM-006 |
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@idp.global/idp.global',
|
name: '@idp.global/idp.global',
|
||||||
version: '1.6.0',
|
version: '1.9.0',
|
||||||
description: 'An identity provider software managing user authentications, registrations, and sessions.'
|
description: 'An identity provider software managing user authentications, registrations, and sessions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import type { Reception } from './classes.reception.js';
|
import type { Reception } from './classes.reception.js';
|
||||||
import { App } from './classes.app.js';
|
import { App } from './classes.app.js';
|
||||||
|
// Note: App class is imported for use with setDefaultManagerForDoc
|
||||||
|
|
||||||
export class AppManager {
|
export class AppManager {
|
||||||
public receptionRef: Reception;
|
public receptionRef: Reception;
|
||||||
@@ -15,7 +16,7 @@ export class AppManager {
|
|||||||
this.receptionRef = receptionRefArg;
|
this.receptionRef = receptionRefArg;
|
||||||
this.receptionRef.typedrouter.addTypedRouter(this.typedrouter);
|
this.receptionRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
|
||||||
// Handler: Get all global apps
|
// Handler: Get all global apps (for org owners)
|
||||||
this.typedrouter.addTypedHandler(
|
this.typedrouter.addTypedHandler(
|
||||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetGlobalApps>(
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetGlobalApps>(
|
||||||
'getGlobalApps',
|
'getGlobalApps',
|
||||||
@@ -26,6 +27,7 @@ export class AppManager {
|
|||||||
// Get all active global apps
|
// Get all active global apps
|
||||||
const globalApps = await this.CApp.getInstances({
|
const globalApps = await this.CApp.getInstances({
|
||||||
type: 'global',
|
type: 'global',
|
||||||
|
'data.isActive': true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const appObjects = await Promise.all(
|
const appObjects = await Promise.all(
|
||||||
@@ -38,6 +40,199 @@ export class AppManager {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handler: Check if user is global admin
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_CheckGlobalAdmin>(
|
||||||
|
'checkGlobalAdmin',
|
||||||
|
async (requestArg) => {
|
||||||
|
const user = await this.receptionRef.userManager.getUserByJwt(requestArg.jwt);
|
||||||
|
return {
|
||||||
|
isGlobalAdmin: user?.data?.isGlobalAdmin ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handler: Get global apps with stats (admin only)
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetGlobalAppStats>(
|
||||||
|
'getGlobalAppStats',
|
||||||
|
async (requestArg) => {
|
||||||
|
await this.verifyGlobalAdmin(requestArg.jwt);
|
||||||
|
|
||||||
|
// Get all global apps (including inactive)
|
||||||
|
const globalApps = await this.CApp.getInstances({
|
||||||
|
type: 'global',
|
||||||
|
});
|
||||||
|
|
||||||
|
const appsWithStats = await Promise.all(
|
||||||
|
globalApps.map(async (app) => {
|
||||||
|
const connections = await this.receptionRef.appConnectionManager.CAppConnection.getInstances({
|
||||||
|
'data.appId': app.id,
|
||||||
|
'data.status': 'active',
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
app: await app.createSavableObject() as plugins.idpInterfaces.data.IGlobalApp,
|
||||||
|
connectionCount: connections.length,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return { apps: appsWithStats };
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handler: Create global app (admin only)
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_CreateGlobalApp>(
|
||||||
|
'createGlobalApp',
|
||||||
|
async (requestArg) => {
|
||||||
|
const jwtData = await this.verifyGlobalAdmin(requestArg.jwt);
|
||||||
|
|
||||||
|
// Generate OAuth credentials
|
||||||
|
const clientId = `app-${plugins.smartunique.shortId(12)}`;
|
||||||
|
const clientSecret = plugins.smartunique.shortId(32);
|
||||||
|
const clientSecretHash = await plugins.smarthash.sha256FromString(clientSecret);
|
||||||
|
|
||||||
|
const app = new this.CApp();
|
||||||
|
app.id = `app-${plugins.smartunique.shortId(8)}`;
|
||||||
|
app.type = 'global';
|
||||||
|
app.data = {
|
||||||
|
name: requestArg.name,
|
||||||
|
description: requestArg.description,
|
||||||
|
logoUrl: requestArg.logoUrl,
|
||||||
|
appUrl: requestArg.appUrl,
|
||||||
|
category: requestArg.category,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
createdByUserId: jwtData.data.userId,
|
||||||
|
oauthCredentials: {
|
||||||
|
clientId,
|
||||||
|
clientSecretHash,
|
||||||
|
redirectUris: requestArg.redirectUris,
|
||||||
|
allowedScopes: requestArg.allowedScopes,
|
||||||
|
grantTypes: ['authorization_code', 'refresh_token'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await app.save();
|
||||||
|
|
||||||
|
return {
|
||||||
|
app: await app.createSavableObject() as plugins.idpInterfaces.data.IGlobalApp,
|
||||||
|
clientSecret, // Only shown once
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handler: Update global app (admin only)
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_UpdateGlobalApp>(
|
||||||
|
'updateGlobalApp',
|
||||||
|
async (requestArg) => {
|
||||||
|
await this.verifyGlobalAdmin(requestArg.jwt);
|
||||||
|
|
||||||
|
const app = await this.CApp.getInstance({ id: requestArg.appId });
|
||||||
|
if (!app) {
|
||||||
|
throw new Error('App not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!app.isGlobalApp()) {
|
||||||
|
throw new Error('Can only update global apps');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update allowed fields - cast data to global app type after type guard
|
||||||
|
const appData = app.data as plugins.idpInterfaces.data.IGlobalApp['data'];
|
||||||
|
if (requestArg.updates.name !== undefined) appData.name = requestArg.updates.name;
|
||||||
|
if (requestArg.updates.description !== undefined) appData.description = requestArg.updates.description;
|
||||||
|
if (requestArg.updates.logoUrl !== undefined) appData.logoUrl = requestArg.updates.logoUrl;
|
||||||
|
if (requestArg.updates.appUrl !== undefined) appData.appUrl = requestArg.updates.appUrl;
|
||||||
|
if (requestArg.updates.category !== undefined) appData.category = requestArg.updates.category;
|
||||||
|
if (requestArg.updates.isActive !== undefined) appData.isActive = requestArg.updates.isActive;
|
||||||
|
if (requestArg.updates.redirectUris !== undefined) appData.oauthCredentials.redirectUris = requestArg.updates.redirectUris;
|
||||||
|
if (requestArg.updates.allowedScopes !== undefined) appData.oauthCredentials.allowedScopes = requestArg.updates.allowedScopes;
|
||||||
|
|
||||||
|
await app.save();
|
||||||
|
|
||||||
|
return {
|
||||||
|
app: await app.createSavableObject() as plugins.idpInterfaces.data.IGlobalApp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handler: Delete global app (admin only)
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_DeleteGlobalApp>(
|
||||||
|
'deleteGlobalApp',
|
||||||
|
async (requestArg) => {
|
||||||
|
await this.verifyGlobalAdmin(requestArg.jwt);
|
||||||
|
|
||||||
|
const app = await this.CApp.getInstance({ id: requestArg.appId });
|
||||||
|
if (!app) {
|
||||||
|
throw new Error('App not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get and disconnect all connections
|
||||||
|
const connections = await this.receptionRef.appConnectionManager.CAppConnection.getInstances({
|
||||||
|
'data.appId': requestArg.appId,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const connection of connections) {
|
||||||
|
await connection.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
await app.delete();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
disconnectedOrganizations: connections.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handler: Regenerate OAuth credentials (admin only)
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_RegenerateAppCredentials>(
|
||||||
|
'regenerateAppCredentials',
|
||||||
|
async (requestArg) => {
|
||||||
|
await this.verifyGlobalAdmin(requestArg.jwt);
|
||||||
|
|
||||||
|
const app = await this.CApp.getInstance({ id: requestArg.appId });
|
||||||
|
if (!app) {
|
||||||
|
throw new Error('App not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new credentials
|
||||||
|
const clientId = `app-${plugins.smartunique.shortId(12)}`;
|
||||||
|
const clientSecret = plugins.smartunique.shortId(32);
|
||||||
|
const clientSecretHash = await plugins.smarthash.sha256FromString(clientSecret);
|
||||||
|
|
||||||
|
app.data.oauthCredentials.clientId = clientId;
|
||||||
|
app.data.oauthCredentials.clientSecretHash = clientSecretHash;
|
||||||
|
await app.save();
|
||||||
|
|
||||||
|
return {
|
||||||
|
clientId,
|
||||||
|
clientSecret, // Only shown once
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify that the user is a global admin
|
||||||
|
*/
|
||||||
|
private async verifyGlobalAdmin(jwt: string) {
|
||||||
|
const jwtData = await this.receptionRef.jwtManager.verifyJWTAndGetData(jwt);
|
||||||
|
const user = await this.receptionRef.userManager.getUserByJwt(jwt);
|
||||||
|
if (!user?.data?.isGlobalAdmin) {
|
||||||
|
throw new Error('Access denied: Global admin privileges required');
|
||||||
|
}
|
||||||
|
return jwtData;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -80,6 +275,8 @@ export class AppManager {
|
|||||||
},
|
},
|
||||||
isActive: true,
|
isActive: true,
|
||||||
category: 'Development',
|
category: 'Development',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
createdByUserId: 'system',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -99,6 +296,8 @@ export class AppManager {
|
|||||||
},
|
},
|
||||||
isActive: true,
|
isActive: true,
|
||||||
category: 'Productivity',
|
category: 'Productivity',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
createdByUserId: 'system',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -106,7 +305,7 @@ export class AppManager {
|
|||||||
for (const appData of defaultGlobalApps) {
|
for (const appData of defaultGlobalApps) {
|
||||||
const existing = await this.CApp.getInstance({ id: appData.id });
|
const existing = await this.CApp.getInstance({ id: appData.id });
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
const app = new App();
|
const app = new this.CApp();
|
||||||
app.id = appData.id!;
|
app.id = appData.id!;
|
||||||
app.type = appData.type!;
|
app.type = appData.type!;
|
||||||
app.data = appData.data as any;
|
app.data = appData.data as any;
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export class JwtManager {
|
|||||||
|
|
||||||
public async verifyJWTAndGetData(jwtArg: string): Promise<Jwt> {
|
public async verifyJWTAndGetData(jwtArg: string): Promise<Jwt> {
|
||||||
const jwtData: plugins.idpInterfaces.data.IJwt = await this.smartjwtInstance.verifyJWTAndGetData(jwtArg);
|
const jwtData: plugins.idpInterfaces.data.IJwt = await this.smartjwtInstance.verifyJWTAndGetData(jwtArg);
|
||||||
const jwt = await Jwt.getInstance({
|
const jwt = await this.CJwt.getInstance({
|
||||||
id: jwtData.id,
|
id: jwtData.id,
|
||||||
});
|
});
|
||||||
if (jwt.blocked) {
|
if (jwt.blocked) {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export class UserManager {
|
|||||||
connectedOrgs: user.data.connectedOrgs,
|
connectedOrgs: user.data.connectedOrgs,
|
||||||
status: null,
|
status: null,
|
||||||
password: null,
|
password: null,
|
||||||
|
isGlobalAdmin: user.data.isGlobalAdmin,
|
||||||
} as plugins.idpInterfaces.data.IUser['data']
|
} as plugins.idpInterfaces.data.IUser['data']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -8,3 +9,4 @@ export * from './loint-reception.organization.js';
|
|||||||
export * from './loint-reception.paddlecheckoutdata.js';
|
export * from './loint-reception.paddlecheckoutdata.js';
|
||||||
export * from './loint-reception.role.js';
|
export * from './loint-reception.role.js';
|
||||||
export * from './loint-reception.user.js';
|
export * from './loint-reception.user.js';
|
||||||
|
export * from './loint-reception.userinvitation.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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -27,6 +27,8 @@ export interface IGlobalApp {
|
|||||||
oauthCredentials: IOAuthCredentials;
|
oauthCredentials: IOAuthCredentials;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
category: string;
|
category: string;
|
||||||
|
createdAt: number;
|
||||||
|
createdByUserId: 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;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
import * as plugins from '../loint-reception.plugins.js';
|
||||||
|
|
||||||
|
/** Standard role types available in all organizations */
|
||||||
|
export type TStandardRole = 'owner' | 'admin' | 'editor' | 'guest' | 'viewer' | 'outlaw';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* a role describes a
|
* A role describes a user's permissions within an organization.
|
||||||
|
* Users can have multiple roles (e.g., ['owner', 'billing-admin']).
|
||||||
*/
|
*/
|
||||||
export interface IRole {
|
export interface IRole {
|
||||||
id: string;
|
id: string;
|
||||||
data: {
|
data: {
|
||||||
userId: string;
|
userId: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
role: 'owner' | 'admin' | 'editor' | 'guest' | 'viewer' | 'outlaw';
|
/** Array of roles - supports standard roles and custom role names */
|
||||||
|
roles: string[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,5 +26,11 @@ export interface IUser {
|
|||||||
* speeds up lookup
|
* speeds up lookup
|
||||||
*/
|
*/
|
||||||
connectedOrgs: string[];
|
connectedOrgs: string[];
|
||||||
|
/**
|
||||||
|
* Platform-level admin flag
|
||||||
|
* Users with this flag can access the global admin panel
|
||||||
|
* to manage global apps, view platform stats, etc.
|
||||||
|
*/
|
||||||
|
isGlobalAdmin?: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import * as plugins from '../loint-reception.plugins.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A UserInvitation represents an invitation to join an organization.
|
||||||
|
* Key characteristics:
|
||||||
|
* - Unique by email (multiple orgs can share the same invitation)
|
||||||
|
* - Converts to real User on registration or folds into existing user
|
||||||
|
* - Auto-expires after 90 days
|
||||||
|
*/
|
||||||
|
export interface IUserInvitation {
|
||||||
|
id: string;
|
||||||
|
data: {
|
||||||
|
/** The invited email address - unique key for sharing across orgs */
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
/** Secure token for invitation link validation */
|
||||||
|
token: string;
|
||||||
|
|
||||||
|
/** Current status of the invitation */
|
||||||
|
status: 'pending' | 'accepted' | 'expired' | 'cancelled';
|
||||||
|
|
||||||
|
/** When the invitation was first created */
|
||||||
|
createdAt: number;
|
||||||
|
|
||||||
|
/** When the invitation expires (createdAt + 90 days) */
|
||||||
|
expiresAt: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Organizations that have invited this email.
|
||||||
|
* Multiple orgs can link to the same invitation.
|
||||||
|
*/
|
||||||
|
organizationRefs: IOrganizationInvitationRef[];
|
||||||
|
|
||||||
|
/** When the invitation was accepted (user registered/folded) */
|
||||||
|
acceptedAt?: number;
|
||||||
|
|
||||||
|
/** The User ID after conversion (when accepted) */
|
||||||
|
convertedToUserId?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents one organization's invitation to the user.
|
||||||
|
* Stored as part of IUserInvitation.organizationRefs array.
|
||||||
|
*/
|
||||||
|
export interface IOrganizationInvitationRef {
|
||||||
|
/** The organization that sent this invitation */
|
||||||
|
organizationId: string;
|
||||||
|
|
||||||
|
/** The user who sent the invitation */
|
||||||
|
invitedByUserId: string;
|
||||||
|
|
||||||
|
/** When this org invited the user */
|
||||||
|
invitedAt: number;
|
||||||
|
|
||||||
|
/** Roles to assign when the invitation is accepted */
|
||||||
|
roles: string[];
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export * from './loint-reception.admin.js';
|
||||||
export * from './loint-reception.apitoken.js';
|
export * from './loint-reception.apitoken.js';
|
||||||
export * from './loint-reception.app.js';
|
export * from './loint-reception.app.js';
|
||||||
export * from './loint-reception.authorization.js';
|
export * from './loint-reception.authorization.js';
|
||||||
@@ -8,3 +9,4 @@ export * from './loint-reception.organization.js';
|
|||||||
export * from './loint-reception.plan.js';
|
export * from './loint-reception.plan.js';
|
||||||
export * from './loint-reception.registration.js';
|
export * from './loint-reception.registration.js';
|
||||||
export * from './loint-reception.user.js';
|
export * from './loint-reception.user.js';
|
||||||
|
export * from './loint-reception.userinvitation.js';
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import * as plugins from '../loint-reception.plugins.js';
|
||||||
|
import * as data from '../data/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current user is a global admin
|
||||||
|
*/
|
||||||
|
export interface IReq_CheckGlobalAdmin
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_CheckGlobalAdmin
|
||||||
|
> {
|
||||||
|
method: 'checkGlobalAdmin';
|
||||||
|
request: {
|
||||||
|
jwt: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
isGlobalAdmin: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all global apps with statistics (admin only)
|
||||||
|
*/
|
||||||
|
export interface IReq_GetGlobalAppStats
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetGlobalAppStats
|
||||||
|
> {
|
||||||
|
method: 'getGlobalAppStats';
|
||||||
|
request: {
|
||||||
|
jwt: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
apps: Array<{
|
||||||
|
app: data.IGlobalApp;
|
||||||
|
connectionCount: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new global app (admin only)
|
||||||
|
*/
|
||||||
|
export interface IReq_CreateGlobalApp
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_CreateGlobalApp
|
||||||
|
> {
|
||||||
|
method: 'createGlobalApp';
|
||||||
|
request: {
|
||||||
|
jwt: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
logoUrl: string;
|
||||||
|
appUrl: string;
|
||||||
|
category: string;
|
||||||
|
redirectUris: string[];
|
||||||
|
allowedScopes: string[];
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
app: data.IGlobalApp;
|
||||||
|
clientSecret: string; // Only shown once on creation
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing global app (admin only)
|
||||||
|
*/
|
||||||
|
export interface IReq_UpdateGlobalApp
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_UpdateGlobalApp
|
||||||
|
> {
|
||||||
|
method: 'updateGlobalApp';
|
||||||
|
request: {
|
||||||
|
jwt: string;
|
||||||
|
appId: string;
|
||||||
|
updates: {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
appUrl?: string;
|
||||||
|
category?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
redirectUris?: string[];
|
||||||
|
allowedScopes?: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
app: data.IGlobalApp;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a global app (admin only)
|
||||||
|
*/
|
||||||
|
export interface IReq_DeleteGlobalApp
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_DeleteGlobalApp
|
||||||
|
> {
|
||||||
|
method: 'deleteGlobalApp';
|
||||||
|
request: {
|
||||||
|
jwt: string;
|
||||||
|
appId: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
disconnectedOrganizations: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate OAuth credentials for a global app (admin only)
|
||||||
|
*/
|
||||||
|
export interface IReq_RegenerateAppCredentials
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_RegenerateAppCredentials
|
||||||
|
> {
|
||||||
|
method: 'regenerateAppCredentials';
|
||||||
|
request: {
|
||||||
|
jwt: string;
|
||||||
|
appId: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string; // Only shown once
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import * as data from '../data/index.js';
|
||||||
|
import * as plugins from '../loint-reception.plugins.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an invitation to join an organization
|
||||||
|
*/
|
||||||
|
export interface IReq_CreateInvitation
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_CreateInvitation
|
||||||
|
> {
|
||||||
|
method: 'createInvitation';
|
||||||
|
request: {
|
||||||
|
jwt: string;
|
||||||
|
organizationId: string;
|
||||||
|
email: string;
|
||||||
|
roles: string[];
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
invitation?: data.IUserInvitation;
|
||||||
|
message?: string;
|
||||||
|
/** True if a new invitation was created, false if email was added to existing */
|
||||||
|
isNew: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pending invitations for an organization
|
||||||
|
*/
|
||||||
|
export interface IReq_GetOrgInvitations
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetOrgInvitations
|
||||||
|
> {
|
||||||
|
method: 'getOrgInvitations';
|
||||||
|
request: {
|
||||||
|
jwt: string;
|
||||||
|
organizationId: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
invitations: data.IUserInvitation[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get members of an organization (users with roles)
|
||||||
|
*/
|
||||||
|
export interface IReq_GetOrgMembers
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetOrgMembers
|
||||||
|
> {
|
||||||
|
method: 'getOrgMembers';
|
||||||
|
request: {
|
||||||
|
jwt: string;
|
||||||
|
organizationId: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
members: Array<{
|
||||||
|
user: data.IUser;
|
||||||
|
role: data.IRole;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel a pending invitation
|
||||||
|
*/
|
||||||
|
export interface IReq_CancelInvitation
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_CancelInvitation
|
||||||
|
> {
|
||||||
|
method: 'cancelInvitation';
|
||||||
|
request: {
|
||||||
|
jwt: string;
|
||||||
|
organizationId: string;
|
||||||
|
invitationId: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resend invitation email
|
||||||
|
*/
|
||||||
|
export interface IReq_ResendInvitation
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_ResendInvitation
|
||||||
|
> {
|
||||||
|
method: 'resendInvitation';
|
||||||
|
request: {
|
||||||
|
jwt: string;
|
||||||
|
organizationId: string;
|
||||||
|
invitationId: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a member from an organization
|
||||||
|
*/
|
||||||
|
export interface IReq_RemoveMember
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_RemoveMember
|
||||||
|
> {
|
||||||
|
method: 'removeMember';
|
||||||
|
request: {
|
||||||
|
jwt: string;
|
||||||
|
organizationId: string;
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a member's roles
|
||||||
|
*/
|
||||||
|
export interface IReq_UpdateMemberRoles
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_UpdateMemberRoles
|
||||||
|
> {
|
||||||
|
method: 'updateMemberRoles';
|
||||||
|
request: {
|
||||||
|
jwt: string;
|
||||||
|
organizationId: string;
|
||||||
|
userId: string;
|
||||||
|
roles: string[];
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
role?: data.IRole;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transfer organization ownership to another member
|
||||||
|
*/
|
||||||
|
export interface IReq_TransferOwnership
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_TransferOwnership
|
||||||
|
> {
|
||||||
|
method: 'transferOwnership';
|
||||||
|
request: {
|
||||||
|
jwt: string;
|
||||||
|
organizationId: string;
|
||||||
|
newOwnerId: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept an invitation (called during registration or email verification)
|
||||||
|
*/
|
||||||
|
export interface IReq_AcceptInvitation
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_AcceptInvitation
|
||||||
|
> {
|
||||||
|
method: 'acceptInvitation';
|
||||||
|
request: {
|
||||||
|
token: string;
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
organizations?: data.IOrganization[];
|
||||||
|
roles?: data.IRole[];
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get invitation by token (for invitation landing page)
|
||||||
|
*/
|
||||||
|
export interface IReq_GetInvitationByToken
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetInvitationByToken
|
||||||
|
> {
|
||||||
|
method: 'getInvitationByToken';
|
||||||
|
request: {
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
invitation?: data.IUserInvitation;
|
||||||
|
organizations?: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}>;
|
||||||
|
isExpired: boolean;
|
||||||
|
requiresRegistration: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@idp.global/idp.global',
|
name: '@idp.global/idp.global',
|
||||||
version: '1.6.0',
|
version: '1.9.0',
|
||||||
description: 'An identity provider software managing user authentications, registrations, and sessions.'
|
description: 'An identity provider software managing user authentications, registrations, and sessions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, type IOrgSelectResult } 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';
|
||||||
@@ -100,6 +102,25 @@ 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');
|
||||||
|
|
||||||
|
// 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 () => {
|
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 +160,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);
|
||||||
@@ -149,6 +180,16 @@ export class IdpAccountContent extends DeesElement {
|
|||||||
await this.domtools.convenience.smartdelay.delayFor(300);
|
await this.domtools.convenience.smartdelay.delayFor(300);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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.subrouter._handleRouteState();
|
this.subrouter._handleRouteState();
|
||||||
|
|
||||||
this.registerGarbageFunction(async () => {
|
this.registerGarbageFunction(async () => {
|
||||||
|
|||||||
@@ -0,0 +1,326 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import {
|
||||||
|
customElement,
|
||||||
|
DeesElement,
|
||||||
|
html,
|
||||||
|
css,
|
||||||
|
cssManager,
|
||||||
|
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';
|
||||||
|
|
||||||
|
// Internal form element for reactive state management
|
||||||
|
@customElement('idp-create-org-form')
|
||||||
|
class CreateOrgForm extends DeesElement {
|
||||||
|
@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 resolveWith: ((org: plugins.idpInterfaces.data.IOrganization | null) => void) | null = null;
|
||||||
|
public modal: plugins.deesCatalog.DeesModal | null = null;
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slug-preview {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--dees-color-background);
|
||||||
|
border: 1px solid var(--dees-color-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slug-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--dees-color-muted);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slug-value {
|
||||||
|
font-family: 'Geist Mono', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--dees-color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
color: var(--dees-color-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="description">Create a new organization to manage apps, users, and billing.</div>
|
||||||
|
<dees-input-text
|
||||||
|
.label=${'Organization Name'}
|
||||||
|
.placeholder=${'e.g., Acme Inc.'}
|
||||||
|
.value=${this.orgName}
|
||||||
|
?disabled=${this.creating}
|
||||||
|
></dees-input-text>
|
||||||
|
|
||||||
|
${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>
|
||||||
|
` : ''}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 async firstUpdated() {
|
||||||
|
const inputElement = this.shadowRoot.querySelector('dees-input-text') as any;
|
||||||
|
if (inputElement) {
|
||||||
|
inputElement.changeSubject.subscribe((element: any) => {
|
||||||
|
this.handleNameInput(element.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleNameInput(value: string) {
|
||||||
|
this.orgName = 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public canCreate(): boolean {
|
||||||
|
return this.orgName.length > 0 &&
|
||||||
|
this.validationResult?.available === true &&
|
||||||
|
!this.validating &&
|
||||||
|
!this.creating;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async handleCreate(): Promise<void> {
|
||||||
|
if (!this.canCreate()) {
|
||||||
|
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.modal?.destroy();
|
||||||
|
this.resolveWith?.(result.resultingOrganization);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating organization:', error);
|
||||||
|
this.error = error instanceof Error ? error.message : 'Failed to create organization. Please try again.';
|
||||||
|
this.creating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public handleCancel(): void {
|
||||||
|
if (this.validationDebounceTimer) {
|
||||||
|
clearTimeout(this.validationDebounceTimer);
|
||||||
|
}
|
||||||
|
this.modal?.destroy();
|
||||||
|
this.resolveWith?.(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export the modal utility class
|
||||||
|
export class CreateOrgModal {
|
||||||
|
public static async show(): Promise<plugins.idpInterfaces.data.IOrganization | null> {
|
||||||
|
return new Promise<plugins.idpInterfaces.data.IOrganization | null>((resolve) => {
|
||||||
|
const formElement = new CreateOrgForm();
|
||||||
|
formElement.resolveWith = resolve;
|
||||||
|
|
||||||
|
plugins.deesCatalog.DeesModal.createAndShow({
|
||||||
|
heading: 'Create Organization',
|
||||||
|
content: html`${formElement}`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Cancel',
|
||||||
|
action: async () => {
|
||||||
|
formElement.handleCancel();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Create Organization',
|
||||||
|
action: async () => {
|
||||||
|
await formElement.handleCreate();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
width: 480,
|
||||||
|
}).then((modal) => {
|
||||||
|
formElement.modal = modal;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
cssManager,
|
cssManager,
|
||||||
unsafeCSS,
|
unsafeCSS,
|
||||||
css,
|
css,
|
||||||
|
state,
|
||||||
type TemplateResult,
|
type TemplateResult,
|
||||||
} from '@design.estate/dees-element';
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
@@ -13,6 +14,8 @@ import * as plugins from '../../plugins.js';
|
|||||||
import * as states from '../../states/accountstate.js';
|
import * as states from '../../states/accountstate.js';
|
||||||
import { IdpState } from '../../states/idp.state.js';
|
import { IdpState } from '../../states/idp.state.js';
|
||||||
import { accountDesignTokens } from './sharedstyles.js';
|
import { accountDesignTokens } from './sharedstyles.js';
|
||||||
|
import { CreateOrgModal } from './create-org-modal.js';
|
||||||
|
import { OrgSelectModal } from './org-select-modal.js';
|
||||||
|
|
||||||
import { commitinfo } from '../../../dist_ts/00_commitinfo_data.js';
|
import { commitinfo } from '../../../dist_ts/00_commitinfo_data.js';
|
||||||
|
|
||||||
@@ -24,10 +27,42 @@ declare global {
|
|||||||
|
|
||||||
@customElement('lele-accountnavigation')
|
@customElement('lele-accountnavigation')
|
||||||
export class LeleAccountNavigation extends DeesElement {
|
export class LeleAccountNavigation extends DeesElement {
|
||||||
|
@state()
|
||||||
|
accessor isGlobalAdmin: boolean = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor currentPath: string = window.location.pathname;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async navigateTo(path: string) {
|
||||||
|
const subrouter = await this.getAccountRouter();
|
||||||
|
subrouter.pushUrl(path);
|
||||||
|
// Update state after navigation to trigger re-render
|
||||||
|
this.currentPath = window.location.pathname;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async navigateToOrgPage(page: string) {
|
||||||
|
const currentState = states.accountState.getState();
|
||||||
|
if (currentState.selectedOrg) {
|
||||||
|
const path = page ? `/org/${currentState.selectedOrg.data.slug}/${page}` : `/org/${currentState.selectedOrg.data.slug}`;
|
||||||
|
await this.navigateTo(path);
|
||||||
|
} else {
|
||||||
|
const targetPath = page ? `/org/:orgName/${page}` : '/org/:orgName';
|
||||||
|
const description = page ? `Choose an organization to view its ${page}.` : 'Choose an organization to view its overview.';
|
||||||
|
const result = await OrgSelectModal.show({
|
||||||
|
targetPath,
|
||||||
|
title: 'Select Organization',
|
||||||
|
description,
|
||||||
|
});
|
||||||
|
if (result) {
|
||||||
|
await this.navigateTo(result.path.replace('/account', ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static styles = [
|
public static styles = [
|
||||||
cssManager.defaultStyles,
|
cssManager.defaultStyles,
|
||||||
accountDesignTokens,
|
accountDesignTokens,
|
||||||
@@ -132,6 +167,15 @@ export class LeleAccountNavigation extends DeesElement {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.navigationOption.active {
|
||||||
|
background: var(--muted);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navigationOption.active dees-icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.divider {
|
.divider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
@@ -161,11 +205,8 @@ export class LeleAccountNavigation extends DeesElement {
|
|||||||
<div class="navContent">
|
<div class="navContent">
|
||||||
<div class="navigationGroupLabel">Account</div>
|
<div class="navigationGroupLabel">Account</div>
|
||||||
<div
|
<div
|
||||||
class="navigationOption"
|
class="navigationOption ${this.isActive('') ? 'active' : ''}"
|
||||||
@click=${async () => {
|
@click=${() => this.navigateTo('')}
|
||||||
const subrouter = await this.getAccountRouter();
|
|
||||||
subrouter.pushUrl('');
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<dees-icon .icon=${'lucide:home'}></dees-icon>
|
<dees-icon .icon=${'lucide:home'}></dees-icon>
|
||||||
Overview
|
Overview
|
||||||
@@ -179,14 +220,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 () => {
|
||||||
@@ -203,24 +236,44 @@ 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__') {
|
||||||
|
const org = await CreateOrgModal.show();
|
||||||
|
if (org) {
|
||||||
|
await this.navigateTo(`/org/${org.data.slug}/billing`);
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
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) {
|
||||||
|
await this.navigateTo(`/org/${newOrg.data.slug}/${pageType}`);
|
||||||
|
} else {
|
||||||
|
await this.navigateTo(`/org/${newOrg.data.slug}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
></dees-input-dropdown>
|
></dees-input-dropdown>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="navigationOption"
|
class="navigationOption ${this.isActive('org-overview') ? 'active' : ''}"
|
||||||
@click=${async () => {
|
@click=${() => this.navigateToOrgPage('')}
|
||||||
const currentState = states.accountState.getState();
|
>
|
||||||
if (currentState.selectedOrg) {
|
<dees-icon .icon=${'lucide:home'}></dees-icon>
|
||||||
const subrouter = await this.getAccountRouter();
|
Overview
|
||||||
subrouter.pushUrl(`/org/${currentState.selectedOrg.data.slug}/apps`);
|
</div>
|
||||||
}
|
<div
|
||||||
}}
|
class="navigationOption ${this.isActive('apps') ? 'active' : ''}"
|
||||||
|
@click=${() => this.navigateToOrgPage('apps')}
|
||||||
>
|
>
|
||||||
<dees-icon .icon=${'lucide:box'}></dees-icon>
|
<dees-icon .icon=${'lucide:box'}></dees-icon>
|
||||||
Apps
|
Apps
|
||||||
@@ -240,25 +293,68 @@ export class LeleAccountNavigation extends DeesElement {
|
|||||||
Activity
|
Activity
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="navigationOption"
|
class="navigationOption ${this.isActive('billing') ? 'active' : ''}"
|
||||||
@click=${async () => {
|
@click=${() => this.navigateToOrgPage('billing')}
|
||||||
const currentState = states.accountState.getState();
|
|
||||||
if (currentState.selectedOrg) {
|
|
||||||
const subrouter = await this.getAccountRouter();
|
|
||||||
subrouter.pushUrl(`/org/${currentState.selectedOrg.data.slug}/billing`);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<dees-icon .icon=${'lucide:wallet'}></dees-icon>
|
<dees-icon .icon=${'lucide:wallet'}></dees-icon>
|
||||||
Billing
|
Billing
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${this.renderAdminLink()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="commitinfo">v${commitinfo.version}</div>
|
<div class="commitinfo">v${commitinfo.version}</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
public firstUpdated() {
|
private renderAdminLink(): TemplateResult | null {
|
||||||
|
if (!this.isGlobalAdmin) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return html`
|
||||||
|
<div class="divider"></div>
|
||||||
|
<div class="navigationGroupLabel">Platform</div>
|
||||||
|
<div
|
||||||
|
class="navigationOption ${this.isActive('admin') ? 'active' : ''}"
|
||||||
|
@click=${() => this.navigateTo('/admin')}
|
||||||
|
>
|
||||||
|
<dees-icon .icon=${'lucide:shield'}></dees-icon>
|
||||||
|
Global Admin
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isActive(page: string): boolean {
|
||||||
|
const path = this.currentPath;
|
||||||
|
if (page === '') {
|
||||||
|
// Account overview - exact match
|
||||||
|
return path === '/account' || path === '/account/';
|
||||||
|
}
|
||||||
|
if (page === 'org-overview') {
|
||||||
|
// Org overview - /account/org/:slug without trailing page type
|
||||||
|
return /^\/account\/org\/[^\/]+\/?$/.test(path);
|
||||||
|
}
|
||||||
|
// For other pages, check if the path contains the page segment
|
||||||
|
return path.includes(`/${page}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async firstUpdated() {
|
||||||
|
// Listen for popstate (browser back/forward)
|
||||||
|
window.addEventListener('popstate', () => {
|
||||||
|
this.currentPath = window.location.pathname;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Watch for URL changes from external navigation (e.g., modals)
|
||||||
|
let lastPath = this.currentPath;
|
||||||
|
const checkPath = () => {
|
||||||
|
if (window.location.pathname !== lastPath) {
|
||||||
|
lastPath = window.location.pathname;
|
||||||
|
this.currentPath = lastPath;
|
||||||
|
}
|
||||||
|
requestAnimationFrame(checkPath);
|
||||||
|
};
|
||||||
|
requestAnimationFrame(checkPath);
|
||||||
|
|
||||||
const deesInputDropdown = this.shadowRoot.querySelector('dees-input-dropdown');
|
const deesInputDropdown = this.shadowRoot.querySelector('dees-input-dropdown');
|
||||||
const orgToMenuEntry = (orgArg?: plugins.idpInterfaces.data.IOrganization) => {
|
const orgToMenuEntry = (orgArg?: plugins.idpInterfaces.data.IOrganization) => {
|
||||||
if (!orgArg) {
|
if (!orgArg) {
|
||||||
@@ -270,11 +366,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) => {
|
||||||
@@ -286,5 +392,12 @@ export class LeleAccountNavigation extends DeesElement {
|
|||||||
.subscribe((selectedOrgArg) => {
|
.subscribe((selectedOrgArg) => {
|
||||||
deesInputDropdown.selectedOption = selectedOrgArg;
|
deesInputDropdown.selectedOption = selectedOrgArg;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check if user is global admin
|
||||||
|
states.accountState
|
||||||
|
.select((stateArg) => stateArg.user)
|
||||||
|
.subscribe((user) => {
|
||||||
|
this.isGlobalAdmin = user?.data?.isGlobalAdmin ?? false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import * as plugins from '../../plugins.js';
|
||||||
|
import {
|
||||||
|
html,
|
||||||
|
css,
|
||||||
|
cssManager,
|
||||||
|
type TemplateResult,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
import { accountDesignTokens } from './sharedstyles.js';
|
||||||
|
import * as accountStateModule from '../../states/accountstate.js';
|
||||||
|
|
||||||
|
export interface IOrgSelectResult {
|
||||||
|
org: plugins.idpInterfaces.data.IOrganization;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalStyles = css`
|
||||||
|
.org-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-bottom: 1px solid var(--dees-color-line);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-item:hover {
|
||||||
|
background: var(--dees-color-softBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--dees-color-softBackground);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-item:hover .org-icon {
|
||||||
|
background: var(--dees-color-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: var(--dees-color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-slug {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--dees-color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-arrow {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 24px;
|
||||||
|
color: var(--dees-color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state dees-icon {
|
||||||
|
font-size: 40px;
|
||||||
|
opacity: 0.5;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
color: var(--dees-color-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export class OrgSelectModal {
|
||||||
|
public static async show(options: {
|
||||||
|
targetPath: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
}): Promise<IOrgSelectResult | null> {
|
||||||
|
const title = options.title || 'Select Organization';
|
||||||
|
const description = options.description || 'Choose an organization to continue.';
|
||||||
|
|
||||||
|
// Load organizations from state
|
||||||
|
const state = accountStateModule.accountState.getState();
|
||||||
|
const organizations = state.organizations;
|
||||||
|
|
||||||
|
return new Promise<IOrgSelectResult | null>((resolve) => {
|
||||||
|
let modal: plugins.deesCatalog.DeesModal | null = null;
|
||||||
|
let resolved = false;
|
||||||
|
|
||||||
|
const handleSelectOrg = (org: plugins.idpInterfaces.data.IOrganization) => {
|
||||||
|
if (resolved) return;
|
||||||
|
resolved = true;
|
||||||
|
|
||||||
|
accountStateModule.accountState.dispatchAction(accountStateModule.setSelectedOrg, org);
|
||||||
|
const path = options.targetPath.replace(':orgName', org.data.slug);
|
||||||
|
|
||||||
|
modal?.destroy();
|
||||||
|
resolve({ org, path });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateOrg = async () => {
|
||||||
|
if (resolved) return;
|
||||||
|
resolved = true;
|
||||||
|
modal?.destroy();
|
||||||
|
|
||||||
|
// Import dynamically to avoid circular dependency
|
||||||
|
const { CreateOrgModal } = await import('./create-org-modal.js');
|
||||||
|
const createdOrg = await CreateOrgModal.show();
|
||||||
|
|
||||||
|
if (createdOrg) {
|
||||||
|
const path = options.targetPath.replace(':orgName', createdOrg.data.slug);
|
||||||
|
resolve({ org: createdOrg, path });
|
||||||
|
} else {
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderOrgList = (): TemplateResult => {
|
||||||
|
return html`
|
||||||
|
<style>${modalStyles}</style>
|
||||||
|
<div class="description">${description}</div>
|
||||||
|
<div class="org-list">
|
||||||
|
${organizations.map((org) => html`
|
||||||
|
<div class="org-item" @click=${() => 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>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderEmptyState = (): TemplateResult => {
|
||||||
|
return html`
|
||||||
|
<style>${modalStyles}</style>
|
||||||
|
<div class="empty-state">
|
||||||
|
<dees-icon .icon=${'lucide:building2'}></dees-icon>
|
||||||
|
<p>You don't have any organizations yet.</p>
|
||||||
|
<dees-button @clicked=${handleCreateOrg}>
|
||||||
|
<dees-icon .icon=${'lucide:plus'} slot="iconLeft"></dees-icon>
|
||||||
|
Create Organization
|
||||||
|
</dees-button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const content = organizations.length === 0 ? renderEmptyState() : renderOrgList();
|
||||||
|
|
||||||
|
plugins.deesCatalog.DeesModal.createAndShow({
|
||||||
|
heading: title,
|
||||||
|
content,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Cancel',
|
||||||
|
action: async (modalRef) => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
modalRef.destroy();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
width: 420,
|
||||||
|
}).then((m) => {
|
||||||
|
modal = m;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,759 @@
|
|||||||
|
import * as plugins from '../../../plugins.js';
|
||||||
|
import {
|
||||||
|
customElement,
|
||||||
|
DeesElement,
|
||||||
|
property,
|
||||||
|
html,
|
||||||
|
cssManager,
|
||||||
|
css,
|
||||||
|
state,
|
||||||
|
type TemplateResult,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
import { IdpState } from '../../../states/idp.state.js';
|
||||||
|
import { accountDesignTokens } from '../sharedstyles.js';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'lele-accountview-admin': AdminView;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IAppWithStats {
|
||||||
|
app: plugins.idpInterfaces.data.IGlobalApp;
|
||||||
|
connectionCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('lele-accountview-admin')
|
||||||
|
export class AdminView extends DeesElement {
|
||||||
|
@state()
|
||||||
|
accessor apps: IAppWithStats[] = [];
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor loading: boolean = true;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor showCreateDialog: boolean = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor editingApp: plugins.idpInterfaces.data.IGlobalApp | null = null;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor newClientSecret: string | null = null;
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
accountDesignTokens,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
min-height: 100%;
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: #18181b;
|
||||||
|
border: 1px solid #27272a;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #71717a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apps-section {
|
||||||
|
background: #18181b;
|
||||||
|
border: 1px solid #27272a;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px solid #27272a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-list {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-bottom: 1px solid #27272a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-logo {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #27272a;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-logo img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-logo dees-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-details {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #71717a;
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-status.active {
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-status.inactive {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1px solid #27272a;
|
||||||
|
background: transparent;
|
||||||
|
color: #fafafa;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
background: #27272a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.danger:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border-color: #ef4444;
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px;
|
||||||
|
color: #71717a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state dees-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
opacity: 0.5;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px;
|
||||||
|
color: #71717a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dialog styles */
|
||||||
|
.dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
background: #18181b;
|
||||||
|
border: 1px solid #27272a;
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header {
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px solid #27272a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-body {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-top: 1px solid #27272a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-textarea {
|
||||||
|
min-height: 80px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secret-display {
|
||||||
|
background: #0a0a0a;
|
||||||
|
border: 1px solid #27272a;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secret-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #71717a;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secret-value {
|
||||||
|
font-family: 'Geist Mono', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
word-break: break-all;
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secret-warning {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #f59e0b;
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<div>
|
||||||
|
<h1>Global Admin</h1>
|
||||||
|
<p class="subtitle">Manage platform-wide settings and global apps</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-row">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">${this.apps.length}</div>
|
||||||
|
<div class="stat-label">Total Global Apps</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">${this.apps.filter(a => a.app.data.isActive).length}</div>
|
||||||
|
<div class="stat-label">Active Apps</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">${this.apps.reduce((sum, a) => sum + a.connectionCount, 0)}</div>
|
||||||
|
<div class="stat-label">Total Connections</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="apps-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">Global Apps</span>
|
||||||
|
<dees-button
|
||||||
|
@clicked=${() => this.showCreateDialog = true}
|
||||||
|
>
|
||||||
|
<dees-icon .icon=${'lucide:plus'} slot="iconLeft"></dees-icon>
|
||||||
|
Create App
|
||||||
|
</dees-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.loading ? this.renderLoading() : this.renderAppList()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.showCreateDialog ? this.renderCreateDialog() : null}
|
||||||
|
${this.editingApp ? this.renderEditDialog() : null}
|
||||||
|
${this.newClientSecret ? this.renderSecretDialog() : null}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderLoading(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="loading">
|
||||||
|
<span>Loading apps...</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderAppList(): TemplateResult {
|
||||||
|
if (this.apps.length === 0) {
|
||||||
|
return html`
|
||||||
|
<div class="empty-state">
|
||||||
|
<dees-icon .icon=${'lucide:box'}></dees-icon>
|
||||||
|
<h3>No Global Apps</h3>
|
||||||
|
<p>Create your first global app to get started.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="app-list">
|
||||||
|
${this.apps.map(({ app, connectionCount }) => html`
|
||||||
|
<div class="app-item">
|
||||||
|
<div class="app-logo">
|
||||||
|
${app.data.logoUrl
|
||||||
|
? html`<img src="${app.data.logoUrl}" alt="${app.data.name}" />`
|
||||||
|
: html`<dees-icon .icon=${'lucide:box'}></dees-icon>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="app-info">
|
||||||
|
<div class="app-name">${app.data.name}</div>
|
||||||
|
<div class="app-details">
|
||||||
|
<span>${app.data.category}</span>
|
||||||
|
<span>${connectionCount} connections</span>
|
||||||
|
<span>${app.data.appUrl}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="app-status ${app.data.isActive ? 'active' : 'inactive'}">
|
||||||
|
${app.data.isActive ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
<div class="app-actions">
|
||||||
|
<button class="action-btn" @click=${() => this.editingApp = app}>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button class="action-btn" @click=${() => this.regenerateCredentials(app.id)}>
|
||||||
|
Regenerate
|
||||||
|
</button>
|
||||||
|
<button class="action-btn danger" @click=${() => this.deleteApp(app.id)}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderCreateDialog(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="dialog-overlay" @click=${(e: Event) => {
|
||||||
|
if ((e.target as HTMLElement).classList.contains('dialog-overlay')) {
|
||||||
|
this.showCreateDialog = false;
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<div class="dialog">
|
||||||
|
<div class="dialog-header">
|
||||||
|
<h2 class="dialog-title">Create Global App</h2>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">App Name</label>
|
||||||
|
<input type="text" class="form-input" id="app-name" placeholder="e.g., foss.global" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Description</label>
|
||||||
|
<textarea class="form-input form-textarea" id="app-description" placeholder="Describe what this app does..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">App URL</label>
|
||||||
|
<input type="url" class="form-input" id="app-url" placeholder="https://app.example.com" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Logo URL</label>
|
||||||
|
<input type="url" class="form-input" id="app-logo" placeholder="https://example.com/logo.png" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Category</label>
|
||||||
|
<input type="text" class="form-input" id="app-category" placeholder="e.g., Productivity" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Redirect URIs (comma-separated)</label>
|
||||||
|
<input type="text" class="form-input" id="app-redirects" placeholder="https://app.example.com/callback" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Allowed Scopes (comma-separated)</label>
|
||||||
|
<input type="text" class="form-input" id="app-scopes" placeholder="openid, profile, email" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<dees-button type="secondary" @clicked=${() => this.showCreateDialog = false}>
|
||||||
|
Cancel
|
||||||
|
</dees-button>
|
||||||
|
<dees-button @clicked=${this.createApp}>
|
||||||
|
Create App
|
||||||
|
</dees-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderEditDialog(): TemplateResult {
|
||||||
|
const app = this.editingApp!;
|
||||||
|
return html`
|
||||||
|
<div class="dialog-overlay" @click=${(e: Event) => {
|
||||||
|
if ((e.target as HTMLElement).classList.contains('dialog-overlay')) {
|
||||||
|
this.editingApp = null;
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<div class="dialog">
|
||||||
|
<div class="dialog-header">
|
||||||
|
<h2 class="dialog-title">Edit ${app.data.name}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">App Name</label>
|
||||||
|
<input type="text" class="form-input" id="edit-name" .value=${app.data.name} />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Description</label>
|
||||||
|
<textarea class="form-input form-textarea" id="edit-description">${app.data.description}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">App URL</label>
|
||||||
|
<input type="url" class="form-input" id="edit-url" .value=${app.data.appUrl} />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Logo URL</label>
|
||||||
|
<input type="url" class="form-input" id="edit-logo" .value=${app.data.logoUrl} />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Category</label>
|
||||||
|
<input type="text" class="form-input" id="edit-category" .value=${app.data.category} />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Status</label>
|
||||||
|
<dees-input-checkbox
|
||||||
|
.label=${'App is active'}
|
||||||
|
.value=${app.data.isActive}
|
||||||
|
id="edit-active"
|
||||||
|
></dees-input-checkbox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<dees-button type="secondary" @clicked=${() => this.editingApp = null}>
|
||||||
|
Cancel
|
||||||
|
</dees-button>
|
||||||
|
<dees-button @clicked=${this.updateApp}>
|
||||||
|
Save Changes
|
||||||
|
</dees-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderSecretDialog(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="dialog-overlay" @click=${(e: Event) => {
|
||||||
|
if ((e.target as HTMLElement).classList.contains('dialog-overlay')) {
|
||||||
|
this.newClientSecret = null;
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<div class="dialog">
|
||||||
|
<div class="dialog-header">
|
||||||
|
<h2 class="dialog-title">Client Secret Generated</h2>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-body">
|
||||||
|
<p>Your new client secret has been generated. Copy it now - you won't be able to see it again.</p>
|
||||||
|
<div class="secret-display">
|
||||||
|
<div class="secret-label">Client Secret</div>
|
||||||
|
<div class="secret-value">${this.newClientSecret}</div>
|
||||||
|
</div>
|
||||||
|
<div class="secret-warning">
|
||||||
|
<dees-icon .icon=${'lucide:alert-triangle'}></dees-icon>
|
||||||
|
This secret will only be shown once. Store it securely.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<dees-button @clicked=${() => {
|
||||||
|
navigator.clipboard.writeText(this.newClientSecret!);
|
||||||
|
}}>
|
||||||
|
Copy to Clipboard
|
||||||
|
</dees-button>
|
||||||
|
<dees-button type="secondary" @clicked=${() => this.newClientSecret = null}>
|
||||||
|
Close
|
||||||
|
</dees-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async firstUpdated() {
|
||||||
|
await this.loadApps();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadApps() {
|
||||||
|
this.loading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const idpState = await IdpState.getSingletonInstance();
|
||||||
|
const jwt = await idpState.idpClient.getJwt();
|
||||||
|
|
||||||
|
const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_GetGlobalAppStats>(
|
||||||
|
'/typedrequest',
|
||||||
|
'getGlobalAppStats'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await typedRequest.fire({ jwt });
|
||||||
|
this.apps = response?.apps ?? [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading apps:', error);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createApp() {
|
||||||
|
const nameInput = this.shadowRoot!.querySelector('#app-name') as HTMLInputElement;
|
||||||
|
const descInput = this.shadowRoot!.querySelector('#app-description') as HTMLTextAreaElement;
|
||||||
|
const urlInput = this.shadowRoot!.querySelector('#app-url') as HTMLInputElement;
|
||||||
|
const logoInput = this.shadowRoot!.querySelector('#app-logo') as HTMLInputElement;
|
||||||
|
const categoryInput = this.shadowRoot!.querySelector('#app-category') as HTMLInputElement;
|
||||||
|
const redirectsInput = this.shadowRoot!.querySelector('#app-redirects') as HTMLInputElement;
|
||||||
|
const scopesInput = this.shadowRoot!.querySelector('#app-scopes') as HTMLInputElement;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const idpState = await IdpState.getSingletonInstance();
|
||||||
|
const jwt = await idpState.idpClient.getJwt();
|
||||||
|
|
||||||
|
const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_CreateGlobalApp>(
|
||||||
|
'/typedrequest',
|
||||||
|
'createGlobalApp'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await typedRequest.fire({
|
||||||
|
jwt,
|
||||||
|
name: nameInput.value,
|
||||||
|
description: descInput.value,
|
||||||
|
appUrl: urlInput.value,
|
||||||
|
logoUrl: logoInput.value,
|
||||||
|
category: categoryInput.value,
|
||||||
|
redirectUris: redirectsInput.value.split(',').map(s => s.trim()).filter(Boolean),
|
||||||
|
allowedScopes: scopesInput.value.split(',').map(s => s.trim()).filter(Boolean),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.showCreateDialog = false;
|
||||||
|
this.newClientSecret = response.clientSecret;
|
||||||
|
await this.loadApps();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating app:', error);
|
||||||
|
alert('Failed to create app');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateApp() {
|
||||||
|
const app = this.editingApp!;
|
||||||
|
const nameInput = this.shadowRoot!.querySelector('#edit-name') as HTMLInputElement;
|
||||||
|
const descInput = this.shadowRoot!.querySelector('#edit-description') as HTMLTextAreaElement;
|
||||||
|
const urlInput = this.shadowRoot!.querySelector('#edit-url') as HTMLInputElement;
|
||||||
|
const logoInput = this.shadowRoot!.querySelector('#edit-logo') as HTMLInputElement;
|
||||||
|
const categoryInput = this.shadowRoot!.querySelector('#edit-category') as HTMLInputElement;
|
||||||
|
const activeCheckbox = this.shadowRoot!.querySelector('#edit-active') as any;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const idpState = await IdpState.getSingletonInstance();
|
||||||
|
const jwt = await idpState.idpClient.getJwt();
|
||||||
|
|
||||||
|
const typedRequest = new plugins.deesDomtools.plugins.typedrequest.TypedRequest<plugins.idpInterfaces.request.IReq_UpdateGlobalApp>(
|
||||||
|
'/typedrequest',
|
||||||
|
'updateGlobalApp'
|
||||||
|
);
|
||||||
|
|
||||||
|
await typedRequest.fire({
|
||||||
|
jwt,
|
||||||
|
appId: app.id,
|
||||||
|
updates: {
|
||||||
|
name: nameInput.value,
|
||||||
|
description: descInput.value,
|
||||||
|
appUrl: urlInput.value,
|
||||||
|
logoUrl: logoInput.value,
|
||||||
|
category: categoryInput.value,
|
||||||
|
isActive: activeCheckbox.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.editingApp = null;
|
||||||
|
await this.loadApps();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating app:', error);
|
||||||
|
alert('Failed to update app');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async regenerateCredentials(appId: string) {
|
||||||
|
if (!confirm('Are you sure you want to regenerate credentials? The current credentials will stop working.')) {
|
||||||
|
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_RegenerateAppCredentials>(
|
||||||
|
'/typedrequest',
|
||||||
|
'regenerateAppCredentials'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await typedRequest.fire({ jwt, appId });
|
||||||
|
this.newClientSecret = response.clientSecret;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error regenerating credentials:', error);
|
||||||
|
alert('Failed to regenerate credentials');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteApp(appId: string) {
|
||||||
|
if (!confirm('Are you sure you want to delete this app? All organizations will be disconnected.')) {
|
||||||
|
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_DeleteGlobalApp>(
|
||||||
|
'/typedrequest',
|
||||||
|
'deleteGlobalApp'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await typedRequest.fire({ jwt, appId });
|
||||||
|
|
||||||
|
if (response.disconnectedOrganizations > 0) {
|
||||||
|
alert(`App deleted. ${response.disconnectedOrganizations} organizations were disconnected.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.loadApps();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting app:', error);
|
||||||
|
alert('Failed to delete app');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
return html`
|
if (this.loading) {
|
||||||
<div class="viewHost">
|
return html`
|
||||||
|
<div class="container">
|
||||||
|
<div class="loading">Loading your account...</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
</div> `;
|
const userInitial = this.user?.data?.username?.charAt(0).toUpperCase() ||
|
||||||
|
this.user?.data?.email?.charAt(0).toUpperCase() || '?';
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>Account Overview</h1>
|
||||||
|
<p class="subtitle">Manage your profile, organizations, and security settings</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-grid">
|
||||||
|
<!-- Profile Card -->
|
||||||
|
<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>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async firstUpdated(_changedProperties: Map<string | number | symbol, unknown>) {
|
private renderOrganizations(): TemplateResult {
|
||||||
await this.domtoolsPromise;
|
if (this.organizations.length === 0) {
|
||||||
super.firstUpdated(_changedProperties);
|
return html`
|
||||||
const viewHost: HTMLDivElement = this.shadowRoot.querySelector('.viewHost');
|
<div class="empty-state">
|
||||||
await state.accountState.dispatchAction(state.getOrganizationsAction, null);
|
<dees-icon .icon=${'lucide:building2'}></dees-icon>
|
||||||
console.log('got orgs');
|
<p>You're not a member of any organizations yet.</p>
|
||||||
if (state.accountState.getState().organizations.length === 0) {
|
</div>
|
||||||
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>
|
|
||||||
`,
|
|
||||||
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) => {
|
return html`
|
||||||
await state.accountState.dispatchAction(state.setNewOrgName, dataArg.orgName);
|
<div class="org-list">
|
||||||
});
|
${this.organizations.map((org) => {
|
||||||
subscriptions.push(formSubscription);
|
const roleObj = this.roles.find(r => r.data.organizationId === org.id);
|
||||||
button.addEventListener('clicked', async () => {
|
const roleName = roleObj?.data.role || 'member';
|
||||||
orgInput.disabled = true;
|
const roleClass = roleName === 'owner' ? 'owner' :
|
||||||
button.text = 'creating org...';
|
roleName === 'admin' ? 'admin' : '';
|
||||||
button.status = 'pending';
|
const roleDisplay = roleName.charAt(0).toUpperCase() + roleName.slice(1);
|
||||||
hint.innerHTML = 'Waiting for creation of the organization...';
|
return html`
|
||||||
await state.accountState.dispatchAction(state.manifestNewOrgName, null);
|
<div class="org-item" @click=${() => this.handleSelectOrg(org)}>
|
||||||
hint.innerHTML = `The Organization with name ${
|
<div class="org-icon">
|
||||||
state.accountState.getState().organizations[0].data.name
|
<dees-icon .icon=${'lucide:building2'}></dees-icon>
|
||||||
} has been created!`;
|
</div>
|
||||||
button.text = 'created!';
|
<div class="org-info">
|
||||||
button.status = 'success';
|
<div class="org-name">${org.data.name}</div>
|
||||||
const parentElement = (this.getRootNode() as any).host;
|
<div class="org-role">${org.data.slug}</div>
|
||||||
parentElement.subrouter.pushUrl(
|
</div>
|
||||||
`/org/${state.accountState.getState().organizations[0].data.slug}/billing`
|
<span class="role-badge ${roleClass}">${roleDisplay}</span>
|
||||||
);
|
</div>
|
||||||
});
|
`;
|
||||||
} else {
|
})}
|
||||||
render(
|
</div>
|
||||||
html`
|
`;
|
||||||
<h1>Select An Organization</h1>
|
}
|
||||||
<p>Choose an organization to manage its settings and billing.</p>
|
|
||||||
<div class="orgGrid">
|
private renderSessions(): TemplateResult {
|
||||||
${state.accountState.getState().organizations.map((orgArg) => {
|
if (this.sessions.length === 0) {
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div class="empty-state">
|
||||||
class="org"
|
<dees-icon .icon=${'lucide:monitor'}></dees-icon>
|
||||||
@click=${() => {
|
<p>No active sessions found.</p>
|
||||||
state.accountState.dispatchAction(state.setSelectedOrg, orgArg);
|
</div>
|
||||||
const parentElement = (this.getRootNode() as any).host;
|
`;
|
||||||
parentElement.subrouter.pushUrl(`/org/${orgArg.data.slug}/billing`);
|
}
|
||||||
}}
|
|
||||||
>
|
return html`
|
||||||
<dees-icon .icon=${'lucide:building2'} style="display: inline-block; transform: translateY(3px); padding-right: 8px;"></dees-icon> ${orgArg.data.name}
|
<div class="session-list">
|
||||||
</div>
|
${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>
|
||||||
`,
|
`)}
|
||||||
viewHost
|
</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,5 +1,7 @@
|
|||||||
|
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';
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,11 @@ export const getOrganizationsAction = accountState.createAction<void>(
|
|||||||
const response = await idpState.idpClient.getRolesAndOrganizations();
|
const response = await idpState.idpClient.getRolesAndOrganizations();
|
||||||
currentState.organizations = response.organizations;
|
currentState.organizations = response.organizations;
|
||||||
currentState.roles = response.roles;
|
currentState.roles = response.roles;
|
||||||
|
// Also fetch user data for admin checks
|
||||||
|
const whoIsResponse = await idpState.idpClient.whoIs().catch(() => null);
|
||||||
|
if (whoIsResponse?.user) {
|
||||||
|
currentState.user = whoIsResponse.user;
|
||||||
|
}
|
||||||
return currentState;
|
return currentState;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user