feat: actionable metrics, LDAP log parsing, UI updates
This commit is contained in:
+7
-1
@@ -4,7 +4,13 @@ All notable changes to this project are documented here. Format loosely
|
|||||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
||||||
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
||||||
|
|
||||||
## [Unreleased]
|
## [1.2.1] - 2026-07-22
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Actionable Metrics**: New real-time metrics tracking for failed logins, top IPs, and service usage per user.
|
||||||
|
- **LDAP Monitor**: Background service to parse OpenLDAP binds over port 389 and track metrics for legacy apps.
|
||||||
|
- **UI Updates**: Executive dashboard now displays actionable metrics cards instead of raw logs. User profiles show individual service usage stats to admins.
|
||||||
|
- **Directory Management**: Integrated site/host/service abstractions into directory UI and allowed associating OAuth apps directly to services.
|
||||||
|
|
||||||
## [1.1.18] - 2026-07-21
|
## [1.1.18] - 2026-07-21
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ RUN apk add --no-cache \
|
|||||||
openldap-overlay-memberof \
|
openldap-overlay-memberof \
|
||||||
openldap-overlay-refint \
|
openldap-overlay-refint \
|
||||||
openldap-overlay-syncprov \
|
openldap-overlay-syncprov \
|
||||||
|
openldap-overlay-auditlog \
|
||||||
openldap-passwd-sha2 \
|
openldap-passwd-sha2 \
|
||||||
dumb-init \
|
dumb-init \
|
||||||
bash \
|
bash \
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
oidc: {
|
||||||
|
clientId: '',
|
||||||
|
clientSecret: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Example secrets configuration file (file-based config).
|
||||||
|
//
|
||||||
|
// Bare-metal: install.sh seeds a filled-in version of this file at
|
||||||
|
// /etc/sso-manager/secrets.js on first run (LDAP + JWT already live; only
|
||||||
|
// SMTP is left as a placeholder). Only write this one by hand if you're
|
||||||
|
// skipping install.sh's LDAP bootstrap (SKIP_LDAP=true) or setting up
|
||||||
|
// manually.
|
||||||
|
// Docker / unified stack: place at ./config/sso-secrets.js and bind-mount
|
||||||
|
// ./config at /config (see docker-compose.yml); docker-entrypoint.sh points
|
||||||
|
// the CONF_SECRETS env var at it so @simpleworkjs/conf reads it.
|
||||||
|
//
|
||||||
|
// Values here override conf/base.js and win over <environment>.js. `app_*` env
|
||||||
|
// vars (if any are set) override this file too — so the Docker stack passes NO
|
||||||
|
// app_* env, keeping this file authoritative.
|
||||||
|
//
|
||||||
|
// The app only reads the keys it knows (port, name, ldap, smtp, voipms, oauth).
|
||||||
|
// The extra `stack`, `bootstrap`, and `serviceAccountPass` keys below are read
|
||||||
|
// by the orchestrator (docker-entrypoint.sh, the bootstrap script, setup.sh)
|
||||||
|
// and ignored by the app — safe to leave them out for bare-metal use.
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
port: 3001,
|
||||||
|
name: 'SSO Manager', // shown in UI and outbound email
|
||||||
|
logo: '/static/img/theta42.svg', // nav/favicon image; point at your own file under public/ to white-label
|
||||||
|
ldap: {
|
||||||
|
url: 'ldap://localhost', // or ldaps://host:636 for TLS
|
||||||
|
bindDN: 'cn=admin,dc=example,dc=com',
|
||||||
|
bindPassword: 'ldap-admin-pass',
|
||||||
|
userBase: 'ou=people,dc=example,dc=com',
|
||||||
|
groupBase: 'ou=groups,dc=example,dc=com',
|
||||||
|
// ldapsHost: 'ldap.internal.example.com', // optional: hostname shown for
|
||||||
|
// direct LDAPS binds on /integrations. Leave empty to derive from the
|
||||||
|
// OAuth issuer. Set an internal-only name to avoid port-forwarding 636.
|
||||||
|
// ldapsPort: 636,
|
||||||
|
},
|
||||||
|
smtp: {
|
||||||
|
host: 'smtp.example.com',
|
||||||
|
port: 587,
|
||||||
|
secure: false, // true for 465, false for other ports
|
||||||
|
user: 'noreply@example.com',
|
||||||
|
pass: 'your-smtp-password',
|
||||||
|
from: 'SSO Manager <noreply@example.com>',
|
||||||
|
},
|
||||||
|
voipms: {
|
||||||
|
username: '', // VoIP.ms username (optional)
|
||||||
|
password: '', // VoIP.ms password (optional)
|
||||||
|
did: '', // VoIP.ms DID (optional)
|
||||||
|
},
|
||||||
|
oauth: {
|
||||||
|
issuer: 'https://sso.example.com', // falls back to the request host at runtime
|
||||||
|
jwtSecret: 'a-long-random-development-jwt-secret-value-1234567890',
|
||||||
|
token_lifetime: {
|
||||||
|
access_token: 3600, // 1 hour in seconds
|
||||||
|
refresh_token: 2592000 // 30 days in seconds
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Orchestrator-only keys (ignored by the app) ──────────────────────────
|
||||||
|
// Read by docker-entrypoint.sh (server-side slapd config + validation), the
|
||||||
|
// superproject bootstrap script, and setup.sh. Omit for bare-metal use.
|
||||||
|
stack: {
|
||||||
|
ldapBaseDn: 'dc=example,dc=com', // slapd suffix (also drives seed OUs).
|
||||||
|
// The base DN also appears in ldap.bindDN/userBase/groupBase above and
|
||||||
|
// in oauth.issuer — keep them consistent with this value
|
||||||
|
// (cn=admin,<dn>, ou=people,<dn>, ou=groups,<dn>, https://<ssoHost>).
|
||||||
|
ldapDomain: 'example.com', // default cert CN + OAuth issuer host
|
||||||
|
ldapCertCn: '', // cert CN; empty -> defaults to ldapDomain
|
||||||
|
ssoHost: 'sso.example.com', // public SSO hostname (OAuth issuer URL)
|
||||||
|
proxyHost: 'proxy.example.com', // public proxy hostname
|
||||||
|
},
|
||||||
|
bootstrap: {
|
||||||
|
adminUid: 'admin', // initial SSO admin username
|
||||||
|
adminPass: 'AdminPass123!', // initial SSO admin password
|
||||||
|
adminEmail: 'admin@example.com', // initial SSO admin email
|
||||||
|
},
|
||||||
|
serviceAccountPass: 'proxy-service-pass', // LDAP password the proxy binds with
|
||||||
|
};
|
||||||
+17
-11
@@ -95,13 +95,24 @@ common query fields can be promoted to columns later.
|
|||||||
| column | type | notes |
|
| column | type | notes |
|
||||||
|--------------|-------------|-------|
|
|--------------|-------------|-------|
|
||||||
| `id` | uuid / pk | |
|
| `id` | uuid / pk | |
|
||||||
| `kind` | enum | `proxmox_node` \| `container` \| `vm` \| `bare_metal` \| `service` |
|
| `kind` | enum | `site` \| `host` \| `service` |
|
||||||
| `name` | text | display name ("Home Assistant", "ct101") |
|
| `name` | text | display name ("Home Assistant", "ct101") |
|
||||||
| `slug` | text unique | url-safe id used by the API |
|
| `slug` | text unique | url-safe id used by the API |
|
||||||
| `description`| text | free text |
|
| `description`| text | free text |
|
||||||
| `metadata` | jsonb | `{ url, icon, fqdn, ip, port, tags[], … }` |
|
| `metadata` | jsonb | `{ subType, ip, macAddress, address, vmid, port, externalPort, gitRepo, installPath, systemdService, os, kernel, isProduction, isExternalReachable, isPublic }` |
|
||||||
| `created_at` / `updated_at` | timestamptz | |
|
| `created_at` / `updated_at` | timestamptz | |
|
||||||
|
|
||||||
|
**Parent Enforcement Rules:**
|
||||||
|
- A **Host** MUST have a parent **Site** or **Host**.
|
||||||
|
- A **Service** MUST have a parent **Host**.
|
||||||
|
- An **OAuth Integration** MUST have a parent **Service**.
|
||||||
|
|
||||||
|
**LDAP Group Auto-Creation:**
|
||||||
|
When a Host or Service is created, the system will automatically create two LDAP groups in the directory (if they do not already exist):
|
||||||
|
- `<slug>_access` (for standard user access)
|
||||||
|
- `<slug>_admin` (for administrative access)
|
||||||
|
Additional groups can still be linked manually.
|
||||||
|
|
||||||
### `resource_edge` — directed relationships (the graph)
|
### `resource_edge` — directed relationships (the graph)
|
||||||
| column | type | notes |
|
| column | type | notes |
|
||||||
|--------------|--------|-------|
|
|--------------|--------|-------|
|
||||||
@@ -109,7 +120,7 @@ common query fields can be promoted to columns later.
|
|||||||
| `child_id` | fk → resource | |
|
| `child_id` | fk → resource | |
|
||||||
| `relation` | enum | `runs_on` \| `hosts` \| `exposes` \| `depends_on` |
|
| `relation` | enum | `runs_on` \| `hosts` \| `exposes` \| `depends_on` |
|
||||||
|
|
||||||
Represents host←container←service (`hosts`/`runs_on`) and service→service
|
Represents site←host←service (`hosts`/`runs_on`) and service→service
|
||||||
(`depends_on`). Directed edges (not a single `parent_id` column) so a node can have
|
(`depends_on`). Directed edges (not a single `parent_id` column) so a node can have
|
||||||
multiple parents/children and multiple relation types.
|
multiple parents/children and multiple relation types.
|
||||||
|
|
||||||
@@ -164,12 +175,7 @@ Write endpoints (POST/PUT/DELETE) are **out of scope for v1**; population is man
|
|||||||
|
|
||||||
- **Interactive users:** existing session auth — `middleware.auth` validating the
|
- **Interactive users:** existing session auth — `middleware.auth` validating the
|
||||||
`auth-token` header (an `AuthToken`, `models/token.js`). No change.
|
`auth-token` header (an `AuthToken`, `models/token.js`). No change.
|
||||||
- **CI/CD (machine) access:** the app does **not yet** have a long-lived service
|
- **CI/CD (machine) access:** scripts and external integrations (like jump hosts) will use the existing `ApiToken` system (Personal Access Tokens) passed in the `Authorization: Bearer sso_...` header. The `ApiToken` inherits the exact LDAP group permissions of the user who created it, seamlessly mapping to existing access controls.
|
||||||
token — `AuthToken` is session-oriented. **Proposed small addition:** a
|
|
||||||
`ServiceToken` subclass in `models/token.js` (mirrors `AuthToken`/`ImpersonationToken`),
|
|
||||||
long-lived, read-only, passed in the same `auth-token` header. Track as its own
|
|
||||||
task; the discovery API should assume it exists but degrade to normal auth tokens
|
|
||||||
until then.
|
|
||||||
- **Read visibility (decision to confirm):** either (a) any authenticated user may
|
- **Read visibility (decision to confirm):** either (a) any authenticated user may
|
||||||
read all resource metadata and only `/me` is filtered, or (b) list endpoints are
|
read all resource metadata and only `/me` is filtered, or (b) list endpoints are
|
||||||
themselves filtered to entitlement. Recommend **(a)** for a home lab — simpler,
|
themselves filtered to entitlement. Recommend **(a)** for a home lab — simpler,
|
||||||
@@ -195,7 +201,7 @@ Write endpoints (POST/PUT/DELETE) are **out of scope for v1**; population is man
|
|||||||
## 7. Roadmap
|
## 7. Roadmap
|
||||||
|
|
||||||
1. **v1 — Discovery API** (this spec's focus): SQL schema + migrations, read models,
|
1. **v1 — Discovery API** (this spec's focus): SQL schema + migrations, read models,
|
||||||
`/api/discovery/*` endpoints, `ServiceToken` for CI/CD.
|
`/api/discovery/*` endpoints, `ApiToken` for CI/CD.
|
||||||
2. **v2 — "My Access" dashboard**: swap `profile.ejs`'s static list for `/me`.
|
2. **v2 — "My Access" dashboard**: swap `profile.ejs`'s static list for `/me`.
|
||||||
3. **v3 — Admin CRUD UI**: manage resources/edges/group links (reusing `app.ui`
|
3. **v3 — Admin CRUD UI**: manage resources/edges/group links (reusing `app.ui`
|
||||||
widgets and the `oauth_clients.ejs` card+modal pattern); gated by
|
widgets and the `oauth_clients.ejs` card+modal pattern); gated by
|
||||||
@@ -213,4 +219,4 @@ Write endpoints (POST/PUT/DELETE) are **out of scope for v1**; population is man
|
|||||||
`description` so LDAP-only external consumers see it? **Default: no** — keep LDAP
|
`description` so LDAP-only external consumers see it? **Default: no** — keep LDAP
|
||||||
for auth, SQL for inventory.
|
for auth, SQL for inventory.
|
||||||
4. **Read-visibility policy:** confirm option (a) vs (b) in §5.
|
4. **Read-visibility policy:** confirm option (a) vs (b) in §5.
|
||||||
5. **Service token scope:** read-only globally, or per-token resource/kind scoping?
|
5. **Service token scope:** Currently `ApiToken` shares the creator's full permissions. A future enhancement could scope tokens specifically to the Directory API.
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ moduleload pw-sha2
|
|||||||
moduleload ppolicy
|
moduleload ppolicy
|
||||||
moduleload memberof
|
moduleload memberof
|
||||||
moduleload refint
|
moduleload refint
|
||||||
|
moduleload auditlog
|
||||||
SYNCPROV_MODULE_PLACEHOLDER
|
SYNCPROV_MODULE_PLACEHOLDER
|
||||||
|
|
||||||
# TLS (LDAPS on 636 + StartTLS on 389). Cert/key paths are fixed; the files are
|
# TLS (LDAPS on 636 + StartTLS on 389). Cert/key paths are fixed; the files are
|
||||||
@@ -187,6 +188,10 @@ memberof-memberof-ad memberOf
|
|||||||
overlay refint
|
overlay refint
|
||||||
refint_attributes memberOf member manager owner
|
refint_attributes memberOf member manager owner
|
||||||
|
|
||||||
|
# auditlog overlay (LDIF audit trail of all changes)
|
||||||
|
overlay auditlog
|
||||||
|
auditlog /var/lib/ldap/auditlog.ldif
|
||||||
|
|
||||||
REPLICATION_BLOCK_PLACEHOLDER
|
REPLICATION_BLOCK_PLACEHOLDER
|
||||||
|
|
||||||
# Access controls
|
# Access controls
|
||||||
@@ -249,7 +254,7 @@ info "Starting OpenLDAP (base DN: ${LDAP_BASE_DN})..."
|
|||||||
# -h listens on ldap:/// (389: plain + StartTLS) and ldaps:/// (636: LDAPS).
|
# -h listens on ldap:/// (389: plain + StartTLS) and ldaps:/// (636: LDAPS).
|
||||||
# ldapi:/// is intentionally omitted: its default socket dir doesn't exist on
|
# ldapi:/// is intentionally omitted: its default socket dir doesn't exist on
|
||||||
# Alpine and the container only uses simple bind over ldap://localhost:389.
|
# Alpine and the container only uses simple bind over ldap://localhost:389.
|
||||||
slapd -d 0 -u ldap -g ldap -f /etc/openldap/slapd.conf -h "ldap:/// ldaps:///" &
|
slapd -d 256 -u ldap -g ldap -f /etc/openldap/slapd.conf -h "ldap:/// ldaps:///" >> /var/lib/ldap/slapd.log 2>&1 &
|
||||||
SLAPD_PID=$!
|
SLAPD_PID=$!
|
||||||
|
|
||||||
# Wait for slapd to answer the root DSE (means it's up, regardless of DB state).
|
# Wait for slapd to answer the root DSE (means it's up, regardless of DB state).
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ nav:
|
|||||||
- title: LDAP
|
- title: LDAP
|
||||||
page: /ldap.html
|
page: /ldap.html
|
||||||
icon: fa-address-book
|
icon: fa-address-book
|
||||||
|
- title: Directory
|
||||||
|
page: /directory.html
|
||||||
|
icon: fa-server
|
||||||
- title: Changelog
|
- title: Changelog
|
||||||
url: https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md
|
url: https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md
|
||||||
icon: fa-list
|
icon: fa-list
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
layout: default
|
||||||
|
title: Directory Management
|
||||||
|
description: Managing your Home-Lab infrastructure, services, and LDAP access relationships via the SSO Directory API.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Directory Management
|
||||||
|
|
||||||
|
The SSO Manager ships with a built-in **Directory & Inventory Management** feature. Instead of just managing bare LDAP groups for your homelab, the Directory allows you to map out your infrastructure graph and assign rich metadata to your services.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The Directory models your homelab infrastructure using a parent-child graph (e.g. `Site -> Host -> Service`).
|
||||||
|
|
||||||
|
There are three primary **Kinds** of resources you can define:
|
||||||
|
- **Site**: A physical location, datacenter, or root node (e.g., `us-east`). Sites do not require parents.
|
||||||
|
- **Host**: A physical machine, Proxmox node, virtual machine, or LXC container. A Host **must** have a parent Site or another Host.
|
||||||
|
- **Service (App)**: An application, web service. A Service **must** have a parent Host or another Service.
|
||||||
|
- **OAuth Integration**: An OAuth 2.0 / OpenID Connect client application. An OAuth integration **must** have a parent Service.
|
||||||
|
|
||||||
|
By defining this hierarchy, the SSO Manager builds a queryable graph of your infrastructure.
|
||||||
|
|
||||||
|
## Automatic LDAP Group Creation
|
||||||
|
|
||||||
|
When you create a new **Host** or **Service** in the Directory via the web UI (or API), the SSO Manager will automatically provision two LDAP groups in your directory to govern access to that resource:
|
||||||
|
|
||||||
|
1. `<slug>_access` (Member level access)
|
||||||
|
2. `<slug>_admin` (Owner level access)
|
||||||
|
|
||||||
|
For example, if you create a Service named "Emby" with the slug `app_emby`, the system will create the LDAP groups `app_emby_access` and `app_emby_admin`. You can then assign users to these groups, and they will immediately see the service populate on their "My Services" dashboard.
|
||||||
|
|
||||||
|
## Resource Metadata
|
||||||
|
|
||||||
|
Resources carry a flexible `metadata` JSON object that can store essential context for your applications. The UI natively supports the following metadata fields:
|
||||||
|
|
||||||
|
### Common Metadata
|
||||||
|
- **Sub Type**: Free-form text to categorize the resource (e.g., `proxmox_node`, `linux`, `lxc`, `web`).
|
||||||
|
- **IP Address**: The internal IP address of the resource.
|
||||||
|
- **MAC Address**: The hardware address of the primary interface.
|
||||||
|
- **Host / URI Address**: The FQDN or URL of the resource (e.g., `https://emby.home.arpa`).
|
||||||
|
- **Production Environment**: A boolean toggle indicating if the resource is in production.
|
||||||
|
|
||||||
|
### Host Metadata
|
||||||
|
- **VMID**: The hypervisor VM or Container ID (e.g. `101`).
|
||||||
|
- **OS**: The operating system name (e.g. `Ubuntu 22.04.3 LTS`).
|
||||||
|
- **Kernel**: The kernel version string (e.g. `5.15.0-100-generic`).
|
||||||
|
|
||||||
|
### Service Metadata
|
||||||
|
- **Internal Port**: The local port the service binds to (e.g. `8080`).
|
||||||
|
- **External Port**: The reverse-proxy or external port (defaults to Internal Port if left blank).
|
||||||
|
- **Public (No Auth)**: Indicates if the service is exposed publicly without authentication.
|
||||||
|
- **External Reachable**: Indicates if the service is accessible outside the VPN/local network.
|
||||||
|
- **Git Repo**: The source code repository for the service (e.g. `https://github.com/...`).
|
||||||
|
- **Install Path**: The filesystem path where the service is installed (e.g. `/opt/app`).
|
||||||
|
- **Systemd Service**: The systemd unit name for the service (e.g. `app.service`).
|
||||||
|
|
||||||
|
## Navigating the UI
|
||||||
|
|
||||||
|
The Directory Management interface provides a **Tree View** toggle that visually nests your resources, making it easy to comprehend your network topography at a glance. You can also filter, search, and sort your entire infrastructure inventory. From the tree view, you can click the green `+` icon next to any resource to instantly add a child resource beneath it.
|
||||||
+8
-12
@@ -53,20 +53,16 @@ An OAuth client represents an app that authenticates against the SSO. Each has:
|
|||||||
|
|
||||||
### Managing clients
|
### Managing clients
|
||||||
|
|
||||||
Clients are managed from the web UI (as a member of the `app_sso_oauth_admin`
|
Clients are managed directly from the **Directory** tab in the web UI. They are modeled as resources of `kind: oauth` and must belong to a parent Service.
|
||||||
group) or the HTTP API at `/api/oauth/client` (auth via the `auth-token` header
|
|
||||||
from a login):
|
|
||||||
|
|
||||||
| Method | Path | Action |
|
| Action | How to do it |
|
||||||
|--------|------|--------|
|
|--------|--------------|
|
||||||
| `GET` | `/api/oauth/client` | list clients |
|
| **Create** | Click the green **+** on a parent Service to add a child resource. Choose **OAuth Integration**. The raw `client_secret` is shown once upon creation. |
|
||||||
| `POST` | `/api/oauth/client` | create a client (returns the raw `client_secret` once) |
|
| **Edit** | Click the edit pencil on the OAuth resource in the Directory list or tree. You can update redirect URIs, scopes, allowed groups, and token TTLs. |
|
||||||
| `GET` | `/api/oauth/client/:id` | get one |
|
| **Delete** | Click the trash can on the OAuth resource in the Directory list. |
|
||||||
| `PUT` | `/api/oauth/client/:id` | update redirect URIs / scopes / groups |
|
| **Rotate Secret** | Open the edit modal for the OAuth resource and click **Rotate Client Secret**. The new raw secret is shown once. |
|
||||||
| `DELETE` | `/api/oauth/client/:id` | delete |
|
|
||||||
| `POST` | `/api/oauth/client/:id/rotate` | rotate the secret (returns the new raw secret once) |
|
|
||||||
|
|
||||||
> All client-management endpoints are gated by the `app_sso_oauth_admin` group.
|
> All client-management actions use the standard Directory API (`/api/directory-admin/resources`) and are gated by the `app_sso_directory_admin` group.
|
||||||
|
|
||||||
## Scopes
|
## Scopes
|
||||||
|
|
||||||
|
|||||||
+8
-3
@@ -25,6 +25,7 @@ app.contoller = require('./controller');
|
|||||||
|
|
||||||
// Background services (self-initializing on require).
|
// Background services (self-initializing on require).
|
||||||
require('./services/update_check');
|
require('./services/update_check');
|
||||||
|
require('./services/ldap_monitor');
|
||||||
|
|
||||||
// Push pubsub over the socket and back.
|
// Push pubsub over the socket and back.
|
||||||
app.onListen.push(function(){
|
app.onListen.push(function(){
|
||||||
@@ -83,15 +84,16 @@ app.use('/api/token', middleware.auth, require('./routes/token'));
|
|||||||
|
|
||||||
app.use('/api/group', middleware.auth, require('./routes/group'));
|
app.use('/api/group', middleware.auth, require('./routes/group'));
|
||||||
app.use('/api/notification', middleware.auth, require('./routes/notification'));
|
app.use('/api/notification', middleware.auth, require('./routes/notification'));
|
||||||
|
app.use('/api/discovery', middleware.auth, require('./routes/discovery'));
|
||||||
|
app.use('/api/directory-admin', middleware.auth, require('./routes/api_directory_admin'));
|
||||||
app.use('/api/update-check', middleware.auth, require('./routes/update_check'));
|
app.use('/api/update-check', middleware.auth, require('./routes/update_check'));
|
||||||
app.use('/api/tos', middleware.auth, require('./routes/tos'));
|
app.use('/api/tos', middleware.auth, require('./routes/tos'));
|
||||||
|
app.use('/api/metrics', middleware.auth, require('./routes/api_metrics'));
|
||||||
// Self-service API tokens (PATs) — owner-scoped, no admin group required.
|
// Self-service API tokens (PATs) — owner-scoped, no admin group required.
|
||||||
app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
|
app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
|
||||||
|
|
||||||
// OAuth 2.0 / OpenID Connect
|
// OAuth 2.0 / OpenID Connect
|
||||||
app.use('/oauth', oauthRouter);
|
app.use('/oauth', oauthRouter);
|
||||||
app.use('/api/oauth/client', middleware.auth, require('./routes/oauth_client'));
|
|
||||||
app.use('/api/oauth', middleware.auth, oauthApiRouter);
|
app.use('/api/oauth', middleware.auth, oauthApiRouter);
|
||||||
app.get('/.well-known/openid-configuration', discovery);
|
app.get('/.well-known/openid-configuration', discovery);
|
||||||
|
|
||||||
@@ -105,7 +107,10 @@ app.use(function(req, res, next) {
|
|||||||
next(err);
|
next(err);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Error handler. This is where `next()` will go on error
|
// Discovery API
|
||||||
|
app.use('/api/discovery', middleware.auth, require('./routes/api_discovery'));
|
||||||
|
|
||||||
|
// Error handling
|
||||||
app.use(function(err, req, res, next) {
|
app.use(function(err, req, res, next) {
|
||||||
const SILENT_404S = ['/.well-known/'];
|
const SILENT_404S = ['/.well-known/'];
|
||||||
const isSilent404 = err.status === 404 && SILENT_404S.some(p => req.url.startsWith(p));
|
const isSilent404 = err.status === 404 && SILENT_404S.some(p => req.url.startsWith(p));
|
||||||
|
|||||||
+9
-3
@@ -25,13 +25,19 @@ var server = http.createServer(app);
|
|||||||
var io = require('socket.io')(server);
|
var io = require('socket.io')(server);
|
||||||
app.io = io;
|
app.io = io;
|
||||||
|
|
||||||
/**
|
const models = require('../models');
|
||||||
* Listen on provided port, on all network interfaces.
|
|
||||||
*/
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize ORM, then Listen on provided port, on all network interfaces.
|
||||||
|
*/
|
||||||
|
models.initORM().then(() => {
|
||||||
server.listen(port);
|
server.listen(port);
|
||||||
server.on('error', onError);
|
server.on('error', onError);
|
||||||
server.on('listening', onListening);
|
server.on('listening', onListening);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Failed to initialize ORM:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize a port into a number, string, or false.
|
* Normalize a port into a number, string, or false.
|
||||||
|
|||||||
Binary file not shown.
@@ -9,11 +9,24 @@ async function auth(req, res, next){
|
|||||||
// the same /api/* routes the UI uses.
|
// the same /api/* routes the UI uses.
|
||||||
const authz = req.header('authorization') || '';
|
const authz = req.header('authorization') || '';
|
||||||
if(authz.slice(0, 7).toLowerCase() === 'bearer '){
|
if(authz.slice(0, 7).toLowerCase() === 'bearer '){
|
||||||
const user = await Auth.checkApiToken(authz.slice(7));
|
const tokenStr = authz.slice(7);
|
||||||
|
if (tokenStr.startsWith('sso_')) {
|
||||||
|
const user = await Auth.checkApiToken(tokenStr);
|
||||||
if(user && user.uid){
|
if(user && user.uid){
|
||||||
req.user = user;
|
req.user = user;
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Machine token (ServiceToken)
|
||||||
|
const { ServiceToken } = require('../models/token');
|
||||||
|
let svcToken;
|
||||||
|
try { svcToken = await ServiceToken.get(tokenStr); } catch(e) {}
|
||||||
|
if (svcToken && svcToken.is_valid) {
|
||||||
|
req.user = { uid: svcToken.resource_id, isMachine: true, name: 'Machine Account' };
|
||||||
|
req.resourceId = svcToken.resource_id;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Browser session: `auth-token: <AuthToken uuid>`.
|
// Browser session: `auth-token: <AuthToken uuid>`.
|
||||||
|
|||||||
+24
-3
@@ -3,12 +3,33 @@
|
|||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
const { setUpTable } = require('model-redis');
|
const { setUpTable } = require('model-redis');
|
||||||
|
|
||||||
|
// Keep model-redis for the ones not yet ported
|
||||||
const Table = setUpTable(conf.redis);
|
const Table = setUpTable(conf.redis);
|
||||||
|
|
||||||
module.exports = Table;
|
module.exports = Table;
|
||||||
|
|
||||||
require('./token');
|
const { Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken } = require('./token');
|
||||||
require('./verification');
|
require('./verification');
|
||||||
require('./oauth_client');
|
|
||||||
require('./oauth_code');
|
require('./oauth_code');
|
||||||
require('./api_token');
|
require('./api_token');
|
||||||
|
|
||||||
|
const { init } = require('@simpleworkjs/orm');
|
||||||
|
const { Resource, ResourceEdge, ResourceGroup } = require('./resource');
|
||||||
|
|
||||||
|
async function initORM() {
|
||||||
|
const ormConf = conf.orm || {
|
||||||
|
dialect: 'sqlite',
|
||||||
|
storage: './config/inventory.sqlite',
|
||||||
|
logging: false
|
||||||
|
};
|
||||||
|
ormConf.redis = conf.redis;
|
||||||
|
|
||||||
|
await init({
|
||||||
|
conf: { orm: ormConf },
|
||||||
|
models: [
|
||||||
|
Resource, ResourceEdge, ResourceGroup,
|
||||||
|
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.initORM = initORM;
|
||||||
|
|||||||
@@ -1,51 +1,90 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const Table = require('.');
|
const { Resource } = require('./resource');
|
||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const UUID = () => crypto.randomUUID();
|
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
const UUID = () => crypto.randomUUID();
|
||||||
|
|
||||||
const defaultLifetime = (conf.oauth && conf.oauth.token_lifetime) || {
|
const defaultLifetime = (conf.oauth && conf.oauth.token_lifetime) || {
|
||||||
access_token: 3600,
|
access_token: 3600,
|
||||||
refresh_token: 2592000
|
refresh_token: 2592000
|
||||||
};
|
};
|
||||||
|
|
||||||
class OAuthClient extends Table {
|
class OAuthClient {
|
||||||
static _key = 'client_id';
|
|
||||||
static _keyMap = {
|
|
||||||
'client_id': {default: UUID, type: 'string'},
|
|
||||||
'client_secret_hash': {isRequired: true, type: 'string', isPrivate: true},
|
|
||||||
'name': {isRequired: true, type: 'string', min: 1, max: 255},
|
|
||||||
'description': {default: '', type: 'string'},
|
|
||||||
'redirect_uris': {default: [], type: 'object'},
|
|
||||||
'scopes': {default: ['openid', 'profile', 'email', 'groups'], type: 'object'},
|
|
||||||
'allowed_groups': {default: [], type: 'object'},
|
|
||||||
'token_lifetime': {default: function(){ return Object.assign({}, defaultLifetime) }, type: 'object'},
|
|
||||||
'created_by': {isRequired: true, type: 'string'},
|
|
||||||
'created_on': {default: function(){ return (new Date).getTime() }},
|
|
||||||
'is_valid': {default: true, type: 'boolean'},
|
|
||||||
}
|
|
||||||
|
|
||||||
static async add(data) {
|
static async add(data) {
|
||||||
const raw_secret = UUID();
|
const raw_secret = crypto.randomUUID();
|
||||||
data.client_secret_hash = await bcrypt.hash(raw_secret, 10);
|
const client_id = crypto.randomUUID();
|
||||||
data.client_id = UUID();
|
const client_secret_hash = await bcrypt.hash(raw_secret, 10);
|
||||||
const client = await this.create(data);
|
|
||||||
client._raw_secret = raw_secret;
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
|
|
||||||
async verifySecret(secret) {
|
const r = await Resource.create({
|
||||||
return bcrypt.compare(secret, this.client_secret_hash);
|
id: client_id,
|
||||||
|
kind: 'oauth',
|
||||||
|
name: data.name,
|
||||||
|
description: data.description || '',
|
||||||
|
owner: data.created_by,
|
||||||
|
metadata: {
|
||||||
|
client_secret_hash,
|
||||||
|
redirect_uris: data.redirect_uris || [],
|
||||||
|
scopes: data.scopes || ['openid', 'profile', 'email', 'groups'],
|
||||||
|
allowed_groups: data.allowed_groups || [],
|
||||||
|
token_lifetime: data.token_lifetime || { ...defaultLifetime }
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async rotateSecret() {
|
r._raw_secret = raw_secret;
|
||||||
const raw_secret = UUID();
|
r.client_id = client_id;
|
||||||
await this.update({ client_secret_hash: await bcrypt.hash(raw_secret, 10) });
|
return r;
|
||||||
|
}
|
||||||
|
static async get(client_id) {
|
||||||
|
const resources = await Resource.list({ where: { id: client_id, kind: 'oauth' } });
|
||||||
|
if (!resources.length) throw new Error('OAuthClient not found');
|
||||||
|
|
||||||
|
const r = resources[0];
|
||||||
|
// Map metadata to top-level properties to satisfy routes/oauth.js without rewriting it
|
||||||
|
r.client_id = r.id;
|
||||||
|
r.client_secret_hash = r.metadata.client_secret_hash;
|
||||||
|
r.redirect_uris = r.metadata.redirect_uris || [];
|
||||||
|
r.scopes = r.metadata.scopes || ['openid', 'profile', 'email', 'groups'];
|
||||||
|
r.allowed_groups = r.metadata.allowed_groups || [];
|
||||||
|
r.token_lifetime = r.metadata.token_lifetime || { ...defaultLifetime };
|
||||||
|
r.verifySecret = async (secret) => bcrypt.compare(secret, r.client_secret_hash);
|
||||||
|
|
||||||
|
r.rotateSecret = async () => {
|
||||||
|
const raw_secret = crypto.randomUUID();
|
||||||
|
r.metadata.client_secret_hash = await bcrypt.hash(raw_secret, 10);
|
||||||
|
await r.update({ metadata: r.metadata });
|
||||||
return raw_secret;
|
return raw_secret;
|
||||||
|
};
|
||||||
|
|
||||||
|
// proxy update to handle metadata correctly
|
||||||
|
const originalUpdate = r.update.bind(r);
|
||||||
|
r.update = async (data) => {
|
||||||
|
if (data.redirect_uris !== undefined) r.metadata.redirect_uris = data.redirect_uris;
|
||||||
|
if (data.scopes !== undefined) r.metadata.scopes = data.scopes;
|
||||||
|
if (data.allowed_groups !== undefined) r.metadata.allowed_groups = data.allowed_groups;
|
||||||
|
if (data.token_lifetime !== undefined) r.metadata.token_lifetime = data.token_lifetime;
|
||||||
|
|
||||||
|
const updateData = { metadata: r.metadata };
|
||||||
|
if (data.name !== undefined) updateData.name = data.name;
|
||||||
|
if (data.description !== undefined) updateData.description = data.description;
|
||||||
|
if (data.is_valid !== undefined) updateData.is_valid = data.is_valid;
|
||||||
|
|
||||||
|
return originalUpdate(updateData);
|
||||||
|
};
|
||||||
|
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async list() {
|
||||||
|
const resources = await Resource.list({ where: { kind: 'oauth' } });
|
||||||
|
return Promise.all(resources.map(r => this.get(r.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
static async verifySecret(client_id, secret) {
|
||||||
|
const client = await this.get(client_id);
|
||||||
|
return client.verifySecret(secret);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
OAuthClient.register();
|
|
||||||
|
|
||||||
module.exports = { OAuthClient };
|
module.exports = { OAuthClient };
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
const { Model } = require('@simpleworkjs/orm');
|
||||||
|
|
||||||
|
const { Group } = require('./group_ldap');
|
||||||
|
|
||||||
|
class Resource extends Model {
|
||||||
|
static exposedMethods = [
|
||||||
|
{ method: 'search', route: 'resources', verb: 'get', args: { from: 'query' } },
|
||||||
|
{ method: 'getBySlug', route: 'resources/:slug', verb: 'get', args: { from: 'params', names: ['slug'] } },
|
||||||
|
{ method: 'getGraph', route: 'graph', verb: 'get' },
|
||||||
|
{ method: 'getMyAccess', route: 'me', verb: 'get', args: { from: 'user' } }
|
||||||
|
];
|
||||||
|
|
||||||
|
static async search(query) {
|
||||||
|
const graph = await this.getGraph();
|
||||||
|
let resources = graph.resources;
|
||||||
|
|
||||||
|
if (query.kind) {
|
||||||
|
resources = resources.filter(r => r.kind === query.kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.group) {
|
||||||
|
const rgs = await ResourceGroup.list({ where: { groupCn: query.group } });
|
||||||
|
const allowedIds = new Set(rgs.map(rg => rg.resourceId));
|
||||||
|
resources = resources.filter(r => allowedIds.has(r.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.parent) {
|
||||||
|
const parents = graph.resources.filter(r => r.slug === query.parent);
|
||||||
|
if (parents.length > 0) {
|
||||||
|
const parentId = parents[0].id;
|
||||||
|
const childIds = new Set(graph.edges.filter(e => e.parentId === parentId).map(e => e.childId));
|
||||||
|
resources = resources.filter(r => childIds.has(r.id));
|
||||||
|
} else {
|
||||||
|
resources = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getBySlug(slug) {
|
||||||
|
const graph = await this.getGraph();
|
||||||
|
const resource = graph.resources.find(r => r.slug === slug);
|
||||||
|
if (!resource) {
|
||||||
|
let err = new Error('Resource not found');
|
||||||
|
err.status = 404;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parents = graph.edges.filter(e => e.childId === resource.id);
|
||||||
|
const children = graph.edges.filter(e => e.parentId === resource.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...resource,
|
||||||
|
parents,
|
||||||
|
children
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getGraph() {
|
||||||
|
const resources = await this.list();
|
||||||
|
const edges = await ResourceEdge.list();
|
||||||
|
|
||||||
|
// Convert to simple objects so we can mutate metadata properties safely
|
||||||
|
const resObjs = resources.map(r => {
|
||||||
|
const obj = r.toJSON ? r.toJSON() : { ...r };
|
||||||
|
obj.metadata = obj.metadata || {};
|
||||||
|
return obj;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bubble up production status: if any child is prod, parent is prod
|
||||||
|
const isProdCache = new Map();
|
||||||
|
function checkProd(resId, visited = new Set()) {
|
||||||
|
if (isProdCache.has(resId)) return isProdCache.get(resId);
|
||||||
|
if (visited.has(resId)) return false; // Cycle prevention
|
||||||
|
|
||||||
|
visited.add(resId);
|
||||||
|
const r = resObjs.find(x => x.id === resId);
|
||||||
|
if (!r) return false;
|
||||||
|
|
||||||
|
// If intrinsically prod, return true
|
||||||
|
if (r.metadata.isProduction) {
|
||||||
|
isProdCache.set(resId, true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check children
|
||||||
|
const childrenIds = edges.filter(e => e.parentId === resId).map(e => e.childId);
|
||||||
|
for (const cid of childrenIds) {
|
||||||
|
if (checkProd(cid, visited)) {
|
||||||
|
isProdCache.set(resId, true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isProdCache.set(resId, false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
resObjs.forEach(r => {
|
||||||
|
r.metadata.isProduction = checkProd(r.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { resources: resObjs, edges };
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getMyAccess(userDn) {
|
||||||
|
const userGroups = await Group.list(userDn);
|
||||||
|
if (!userGroups || userGroups.length === 0) return [];
|
||||||
|
|
||||||
|
const resourceGroups = await ResourceGroup.list({
|
||||||
|
where: { groupCn: { in: userGroups } }
|
||||||
|
});
|
||||||
|
|
||||||
|
const resourceIds = [...new Set(resourceGroups.map(rg => rg.resourceId))];
|
||||||
|
if (resourceIds.length === 0) return [];
|
||||||
|
|
||||||
|
const resources = await this.list({ where: { id: { in: resourceIds } } });
|
||||||
|
|
||||||
|
// Resolve inherited addresses from the graph
|
||||||
|
const graph = await this.getGraph();
|
||||||
|
|
||||||
|
function resolveHost(resId, visited = new Set()) {
|
||||||
|
if (visited.has(resId)) return null; // prevent cycles
|
||||||
|
visited.add(resId);
|
||||||
|
|
||||||
|
const res = graph.resources.find(r => r.id === resId);
|
||||||
|
if (!res) return null;
|
||||||
|
if (res.metadata && res.metadata.address) return res.metadata.address;
|
||||||
|
if (res.metadata && res.metadata.ip) return res.metadata.ip;
|
||||||
|
|
||||||
|
const parentEdges = graph.edges.filter(e => e.childId === resId);
|
||||||
|
for (const edge of parentEdges) {
|
||||||
|
const found = resolveHost(edge.parentId, visited);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resources.map(r => {
|
||||||
|
const data = { ...r };
|
||||||
|
data.metadata = data.metadata || {};
|
||||||
|
data.resolvedAddress = resolveHost(r.id);
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static fields = {
|
||||||
|
id: { type: 'uuid', primaryKey: true },
|
||||||
|
kind: { type: 'string', isRequired: true },
|
||||||
|
name: { type: 'string', isRequired: true },
|
||||||
|
slug: { type: 'string', isRequired: true, unique: true },
|
||||||
|
owner: { type: 'string' },
|
||||||
|
description: { type: 'text' },
|
||||||
|
metadata: { type: 'json', default: {} },
|
||||||
|
edgesAsParent: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'parentId' },
|
||||||
|
edgesAsChild: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'childId' },
|
||||||
|
groups: { type: 'hasMany', model: 'ResourceGroup', remoteKey: 'resourceId' }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class ResourceEdge extends Model {
|
||||||
|
static fields = {
|
||||||
|
id: { type: 'uuid', primaryKey: true },
|
||||||
|
parent: { type: 'hasOne', model: 'Resource' }, // Creates parentId
|
||||||
|
child: { type: 'hasOne', model: 'Resource' }, // Creates childId
|
||||||
|
relation: { type: 'string', isRequired: true }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class ResourceGroup extends Model {
|
||||||
|
static fields = {
|
||||||
|
id: { type: 'uuid', primaryKey: true },
|
||||||
|
resource: { type: 'hasOne', model: 'Resource' }, // Creates resourceId
|
||||||
|
groupCn: { type: 'string', isRequired: true },
|
||||||
|
accessLevel: { type: 'string', isRequired: true }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
Resource,
|
||||||
|
ResourceEdge,
|
||||||
|
ResourceGroup
|
||||||
|
};
|
||||||
+32
-35
@@ -1,22 +1,17 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const Table = require('.');
|
const { Model } = require('@simpleworkjs/orm');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const UUID = () => crypto.randomUUID();
|
const UUID = () => crypto.randomUUID();
|
||||||
|
|
||||||
|
class Token extends Model {
|
||||||
class Token extends Table{
|
static adapterName = 'redis';
|
||||||
static _key = 'token';
|
static fields = {
|
||||||
static _keyMap = {
|
token: { type: 'string', primaryKey: true, default: UUID, isPrivate: true, min: 36, max: 36 },
|
||||||
'created_by': {isRequired: true, type: 'string', min: 3, max: 500},
|
created_by: { isRequired: true, type: 'string', min: 3, max: 500 },
|
||||||
'created_on': {default: function(){return (new Date).getTime()}},
|
created_on: { type: 'integer', default: function(){return (new Date).getTime()} },
|
||||||
'updated_on': {default: function(){return (new Date).getTime()}, always: true},
|
updated_on: { type: 'integer', default: function(){return (new Date).getTime()}, always: true },
|
||||||
'token': {default: UUID, type: 'string', min: 36, max: 36, isPrivate: true},
|
is_valid: { default: true, type: 'boolean' }
|
||||||
'is_valid': {default: true, type: 'boolean'},
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(...args){
|
|
||||||
super(...args);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async check(){
|
async check(){
|
||||||
@@ -28,12 +23,10 @@ class Token extends Table{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Token.register();
|
|
||||||
|
|
||||||
class AuthToken extends Token{
|
class AuthToken extends Token{
|
||||||
static _keyMap = {
|
static fields = {
|
||||||
...super._keyMap,
|
...Token.fields,
|
||||||
user: {model: 'User', rel: 'one', localKey: 'created_by'},
|
user: {model: 'User', type: 'hasOne', localKey: 'created_by'},
|
||||||
}
|
}
|
||||||
|
|
||||||
static async create(data){
|
static async create(data){
|
||||||
@@ -42,11 +35,10 @@ class AuthToken extends Token{
|
|||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AuthToken.register();
|
|
||||||
|
|
||||||
class InviteToken extends Token{
|
class InviteToken extends Token{
|
||||||
static _keyMap = {
|
static fields = {
|
||||||
...super._keyMap,
|
...Token.fields,
|
||||||
claimed_by: {default: '__NONE__', isRequired: false, type: 'string'},
|
claimed_by: {default: '__NONE__', isRequired: false, type: 'string'},
|
||||||
mail: {default: '__NONE__', type: 'string'},
|
mail: {default: '__NONE__', type: 'string'},
|
||||||
mail_token: {default: '__NONE__', type: 'string'},
|
mail_token: {default: '__NONE__', type: 'string'},
|
||||||
@@ -68,14 +60,13 @@ class InviteToken extends Token{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
InviteToken.register();
|
|
||||||
|
|
||||||
class ImpersonationToken extends Token {
|
class ImpersonationToken extends Token {
|
||||||
static _keyMap = {
|
static fields = {
|
||||||
...super._keyMap,
|
...Token.fields,
|
||||||
target_uid: {isRequired: true, type: 'string', min: 1, max: 200},
|
target_uid: {isRequired: true, type: 'string', min: 1, max: 200},
|
||||||
temp_hash: {isRequired: true, type: 'string', min: 1, max: 500},
|
temp_hash: {isRequired: true, type: 'string', min: 1, max: 500},
|
||||||
expires_at: {default: function(){ return (new Date).getTime() + 7200000 }, type: 'number'},
|
expires_at: {default: function(){ return (new Date).getTime() + 7200000 }, type: 'integer'},
|
||||||
}
|
}
|
||||||
|
|
||||||
get isExpired() {
|
get isExpired() {
|
||||||
@@ -87,27 +78,24 @@ class ImpersonationToken extends Token {
|
|||||||
return this.create(data);
|
return this.create(data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ImpersonationToken.register();
|
|
||||||
|
|
||||||
class PasswordResetToken extends Token {}
|
class PasswordResetToken extends Token {}
|
||||||
PasswordResetToken.register();
|
|
||||||
|
|
||||||
class OtpToken extends Token {
|
class OtpToken extends Token {
|
||||||
static _keyMap = {
|
static fields = {
|
||||||
...Token._keyMap,
|
...Token.fields,
|
||||||
uid: {isRequired: true, type: 'string'},
|
uid: {isRequired: true, type: 'string'},
|
||||||
code: {isRequired: true, type: 'string'},
|
code: {isRequired: true, type: 'string'},
|
||||||
method: {isRequired: true, type: 'string'},
|
method: {isRequired: true, type: 'string'},
|
||||||
expires_at: {default: function(){ return (new Date).getTime() + 600000 }, type: 'number'},
|
expires_at: {default: function(){ return (new Date).getTime() + 600000 }, type: 'integer'},
|
||||||
};
|
};
|
||||||
|
|
||||||
get isExpired() {
|
get isExpired() {
|
||||||
return (new Date).getTime() > this.expires_at;
|
return (new Date).getTime() > this.expires_at;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Factory method — named `issue` to avoid shadowing Token's `create(data)`
|
|
||||||
static async issue(uid, method) {
|
static async issue(uid, method) {
|
||||||
const existing = await this.listDetail({uid});
|
const existing = await this.find({uid});
|
||||||
for (const t of existing) {
|
for (const t of existing) {
|
||||||
if (t.is_valid) await t.update({is_valid: false});
|
if (t.is_valid) await t.update({is_valid: false});
|
||||||
}
|
}
|
||||||
@@ -123,6 +111,15 @@ class OtpToken extends Token {
|
|||||||
return match;
|
return match;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
OtpToken.register();
|
class ServiceToken extends Token {
|
||||||
|
static fields = {
|
||||||
|
...Token.fields,
|
||||||
|
resource_id: {isRequired: true, type: 'string'}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {Token, InviteToken, AuthToken, ImpersonationToken, PasswordResetToken, OtpToken};
|
static async issue(resource_id, created_by) {
|
||||||
|
return this.create({resource_id, created_by});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {Token, InviteToken, AuthToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken};
|
||||||
|
|||||||
Generated
+99
-76
@@ -12,6 +12,7 @@
|
|||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@simpleworkjs/conf": "^1.2.0",
|
"@simpleworkjs/conf": "^1.2.0",
|
||||||
|
"@simpleworkjs/orm": "file:../../../simpleworkjs/orm",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"compression": "^1.8.1",
|
"compression": "^1.8.1",
|
||||||
@@ -39,6 +40,24 @@
|
|||||||
"supertest": "^7.2.2"
|
"supertest": "^7.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"../../../simpleworkjs/orm": {
|
||||||
|
"name": "@simpleworkjs/orm",
|
||||||
|
"version": "0.2.6",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"model-redis": "^0.2.1",
|
||||||
|
"sequelize": "^6.37.8",
|
||||||
|
"sqlite3": "^6.0.1",
|
||||||
|
"uuid": "^11.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@simpleworkjs/conf": "file:../conf"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
@@ -1127,6 +1146,65 @@
|
|||||||
"url": "https://opencollective.com/popperjs"
|
"url": "https://opencollective.com/popperjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@redis/bloom": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@redis/client": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@redis/client": {
|
||||||
|
"version": "1.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
|
||||||
|
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cluster-key-slot": "1.1.2",
|
||||||
|
"generic-pool": "3.9.0",
|
||||||
|
"yallist": "4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@redis/graph": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@redis/client": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@redis/json": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@redis/client": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@redis/search": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@redis/client": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@redis/time-series": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@redis/client": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@simpleworkjs/conf": {
|
"node_modules/@simpleworkjs/conf": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
|
||||||
@@ -1139,6 +1217,10 @@
|
|||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@simpleworkjs/orm": {
|
||||||
|
"resolved": "../../../simpleworkjs/orm",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@sinclair/typebox": {
|
"node_modules/@sinclair/typebox": {
|
||||||
"version": "0.34.49",
|
"version": "0.34.49",
|
||||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz",
|
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz",
|
||||||
@@ -4742,82 +4824,6 @@
|
|||||||
"redis": "^4.6.10"
|
"redis": "^4.6.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/model-redis/node_modules/@redis/bloom": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@redis/client": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/model-redis/node_modules/@redis/client": {
|
|
||||||
"version": "1.6.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
|
|
||||||
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"cluster-key-slot": "1.1.2",
|
|
||||||
"generic-pool": "3.9.0",
|
|
||||||
"yallist": "4.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/model-redis/node_modules/@redis/graph": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@redis/client": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/model-redis/node_modules/@redis/json": {
|
|
||||||
"version": "1.0.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz",
|
|
||||||
"integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@redis/client": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/model-redis/node_modules/@redis/search": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@redis/client": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/model-redis/node_modules/@redis/time-series": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@redis/client": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/model-redis/node_modules/redis": {
|
|
||||||
"version": "4.7.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz",
|
|
||||||
"integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"workspaces": [
|
|
||||||
"./packages/*"
|
|
||||||
],
|
|
||||||
"dependencies": {
|
|
||||||
"@redis/bloom": "1.2.0",
|
|
||||||
"@redis/client": "1.6.1",
|
|
||||||
"@redis/graph": "1.1.1",
|
|
||||||
"@redis/json": "1.0.7",
|
|
||||||
"@redis/search": "1.2.0",
|
|
||||||
"@redis/time-series": "1.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/moment": {
|
"node_modules/moment": {
|
||||||
"version": "2.30.1",
|
"version": "2.30.1",
|
||||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
||||||
@@ -5414,6 +5420,23 @@
|
|||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/redis": {
|
||||||
|
"version": "4.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz",
|
||||||
|
"integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"./packages/*"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@redis/bloom": "1.2.0",
|
||||||
|
"@redis/client": "1.6.1",
|
||||||
|
"@redis/graph": "1.1.1",
|
||||||
|
"@redis/json": "1.0.7",
|
||||||
|
"@redis/search": "1.2.0",
|
||||||
|
"@redis/time-series": "1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/require-directory": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.1.18",
|
"version": "1.2.1",
|
||||||
|
"description": "A very simple LDAP management and SSO system",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
"name": "William Mantly",
|
"name": "William Mantly",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@simpleworkjs/conf": "^1.2.0",
|
"@simpleworkjs/conf": "^1.2.0",
|
||||||
|
"@simpleworkjs/orm": "file:../../../simpleworkjs/orm",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"compression": "^1.8.1",
|
"compression": "^1.8.1",
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
'use strict';
|
||||||
|
const router = require('express').Router();
|
||||||
|
const permission = require('../utils/permission');
|
||||||
|
const { Resource, ResourceEdge, ResourceGroup } = require('../models/resource');
|
||||||
|
const { Group } = require('../models/group_ldap');
|
||||||
|
|
||||||
|
// Require the admin group
|
||||||
|
router.use(async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await permission.byGroup(req.user, ['app_sso_directory_admin', 'app_sso_admin']);
|
||||||
|
next();
|
||||||
|
} catch(err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Resources ---
|
||||||
|
router.get('/resources', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const resources = await Resource.list();
|
||||||
|
res.json({ results: resources });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/resources', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!req.body.hostId && req.body.parentSlug) {
|
||||||
|
const parents = await Resource.list({ where: { slug: req.body.parentSlug } });
|
||||||
|
if (parents.length > 0) req.body.hostId = parents[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.body.kind === 'host' && !req.body.hostId) {
|
||||||
|
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
|
||||||
|
}
|
||||||
|
if (req.body.kind === 'service' && !req.body.hostId) {
|
||||||
|
return res.status(400).json({ error: 'Services must have a parent Host' });
|
||||||
|
}
|
||||||
|
if (req.body.kind === 'oauth' && !req.body.hostId) {
|
||||||
|
return res.status(400).json({ error: 'OAuth Integrations must have a parent Service' });
|
||||||
|
}
|
||||||
|
|
||||||
|
req.body.owner = req.body.owner || req.user.uid;
|
||||||
|
|
||||||
|
let r;
|
||||||
|
if (req.body.kind === 'oauth') {
|
||||||
|
const { OAuthClient } = require('../models/oauth_client');
|
||||||
|
// Pass created_by explicitly for the wrapper
|
||||||
|
req.body.created_by = req.body.owner;
|
||||||
|
// In the UI we might pass slug, but OAuthClient wrapper expects name
|
||||||
|
r = await OAuthClient.add(req.body);
|
||||||
|
} else {
|
||||||
|
r = await Resource.create(req.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((r.kind === 'host' || r.kind === 'service' || r.kind === 'oauth') && req.body.hostId) {
|
||||||
|
await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: r.kind === 'oauth' ? 'oauth' : 'hosts' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r.kind === 'host' || r.kind === 'service') {
|
||||||
|
const createGroup = async (suffix, accessLevel) => {
|
||||||
|
const cn = `${r.slug}_${suffix}`;
|
||||||
|
try {
|
||||||
|
await Group.add({
|
||||||
|
name: cn,
|
||||||
|
owner: req.user.dn,
|
||||||
|
description: `${suffix === 'admin' ? 'Admin' : 'Access'} group for ${r.name}`
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name !== 'EntryAlreadyExistsError' && err.code !== 68) {
|
||||||
|
console.error(`Failed to create LDAP group ${cn}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await ResourceGroup.create({ resourceId: r.id, groupCn: cn, accessLevel });
|
||||||
|
} catch(err) { /* ignore duplicate links */ }
|
||||||
|
};
|
||||||
|
await createGroup('access', 'member');
|
||||||
|
await createGroup('admin', 'owner');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ results: r });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'SequelizeUniqueConstraintError') {
|
||||||
|
return res.status(400).json({ error: 'A resource with this slug already exists.' });
|
||||||
|
}
|
||||||
|
if (err.name === 'SequelizeValidationError') {
|
||||||
|
return res.status(400).json({ error: err.message });
|
||||||
|
}
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/resources/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
let r;
|
||||||
|
if (req.body.kind === 'oauth') {
|
||||||
|
const { OAuthClient } = require('../models/oauth_client');
|
||||||
|
r = await OAuthClient.get(req.params.id);
|
||||||
|
} else {
|
||||||
|
r = await Resource.get(req.params.id);
|
||||||
|
}
|
||||||
|
if (!r) return res.status(404).json({ error: 'Not found' });
|
||||||
|
|
||||||
|
if (req.body.kind === 'host' && !req.body.hostId) {
|
||||||
|
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
|
||||||
|
}
|
||||||
|
if (req.body.kind === 'service' && !req.body.hostId) {
|
||||||
|
return res.status(400).json({ error: 'Services must have a parent Host' });
|
||||||
|
}
|
||||||
|
if (req.body.kind === 'oauth' && !req.body.hostId) {
|
||||||
|
return res.status(400).json({ error: 'OAuth Integrations must have a parent Service' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let updated;
|
||||||
|
if (req.body.kind === 'oauth') {
|
||||||
|
updated = await r.update(req.body);
|
||||||
|
} else {
|
||||||
|
updated = await r.update(req.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((updated.kind === 'host' || updated.kind === 'service' || updated.kind === 'oauth') && req.body.hostId !== undefined) {
|
||||||
|
const existingEdges = await ResourceEdge.list({ where: { childId: r.id } });
|
||||||
|
for (const e of existingEdges) {
|
||||||
|
if (e.relation === 'hosts' || e.relation === 'oauth') await e.delete();
|
||||||
|
}
|
||||||
|
if (req.body.hostId) {
|
||||||
|
await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: updated.kind === 'oauth' ? 'oauth' : 'hosts' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ results: updated });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/resources/:id/rotate-secret', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { OAuthClient } = require('../models/oauth_client');
|
||||||
|
const client = await OAuthClient.get(req.params.id);
|
||||||
|
const secret = await client.rotateSecret();
|
||||||
|
res.json({ secret });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/resources/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const r = await Resource.get(req.params.id);
|
||||||
|
if (!r) return res.status(404).json({ error: 'Not found' });
|
||||||
|
await r.delete();
|
||||||
|
// Also delete edges and groups involving this resource
|
||||||
|
const edgesParent = await ResourceEdge.list({ where: { parentId: req.params.id } });
|
||||||
|
const edgesChild = await ResourceEdge.list({ where: { childId: req.params.id } });
|
||||||
|
const groups = await ResourceGroup.list({ where: { resourceId: req.params.id } });
|
||||||
|
for (const e of [...edgesParent, ...edgesChild]) await e.delete();
|
||||||
|
for (const g of groups) await g.delete();
|
||||||
|
res.json({ results: true });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Edges ---
|
||||||
|
router.get('/edges', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const edges = await ResourceEdge.list();
|
||||||
|
res.json({ results: edges });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/edges', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const edge = await ResourceEdge.create(req.body);
|
||||||
|
res.json({ results: edge });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/edges/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const edge = await ResourceEdge.get(req.params.id);
|
||||||
|
if (!edge) return res.status(404).json({ error: 'Not found' });
|
||||||
|
await edge.delete();
|
||||||
|
res.json({ results: true });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Groups ---
|
||||||
|
router.get('/groups', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const groups = await ResourceGroup.list();
|
||||||
|
res.json({ results: groups });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/groups', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const g = await ResourceGroup.create(req.body);
|
||||||
|
res.json({ results: g });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/groups/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const g = await ResourceGroup.get(req.params.id);
|
||||||
|
if (!g) return res.status(404).json({ error: 'Not found' });
|
||||||
|
await g.delete();
|
||||||
|
res.json({ results: true });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/audit-logs', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const fs = require('fs');
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
let ldapLogs = '';
|
||||||
|
let oauthLogs = '';
|
||||||
|
let auditLogs = '';
|
||||||
|
|
||||||
|
try { ldapLogs = execSync('tail -n 100 /var/lib/ldap/slapd.log 2>/dev/null').toString(); } catch(e){}
|
||||||
|
try { oauthLogs = execSync('tail -n 100 /var/lib/ldap/oauth.log 2>/dev/null').toString(); } catch(e){}
|
||||||
|
try { auditLogs = execSync('tail -n 100 /var/lib/ldap/auditlog.ldif 2>/dev/null').toString(); } catch(e){}
|
||||||
|
|
||||||
|
res.json({ results: { ldap: ldapLogs, oauth: oauthLogs, audit: auditLogs } });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const router = require('express').Router();
|
||||||
|
const { Resource, ResourceGroup } = require('../models/resource');
|
||||||
|
|
||||||
|
// GET /api/discovery/me
|
||||||
|
// Returns the list of resources the current user has access to.
|
||||||
|
router.get('/me', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const userGroups = req.user.groups || []; // array of LDAP group CNs
|
||||||
|
const accessibleResourceIds = new Set();
|
||||||
|
|
||||||
|
if (req.user.isMachine) {
|
||||||
|
// Machines only have access to themselves by default
|
||||||
|
accessibleResourceIds.add(req.resourceId);
|
||||||
|
} else {
|
||||||
|
// End users get access via groups
|
||||||
|
const allGroups = await ResourceGroup.list();
|
||||||
|
for (const rg of allGroups) {
|
||||||
|
if (userGroups.includes(rg.groupCn)) {
|
||||||
|
accessibleResourceIds.add(rg.resourceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all resources and filter
|
||||||
|
const allResources = await Resource.list();
|
||||||
|
const accessible = allResources.filter(r => accessibleResourceIds.has(r.id) || r.metadata?.isPublic);
|
||||||
|
|
||||||
|
res.json({ results: accessible });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
'use strict';
|
||||||
|
const router = require('express').Router();
|
||||||
|
const permission = require('../utils/permission');
|
||||||
|
const metrics = require('../utils/metrics');
|
||||||
|
|
||||||
|
// /api/metrics/executive
|
||||||
|
router.get('/executive', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await permission.byGroup(req.user, ['app_sso_admin']);
|
||||||
|
|
||||||
|
const topIps = await metrics.getTopN('metrics:failed_ips', 7, 5);
|
||||||
|
const topUsers = await metrics.getTopN('metrics:failed_users', 7, 5);
|
||||||
|
const topServices = await metrics.getTopN('metrics:service_usage', 7, 5);
|
||||||
|
|
||||||
|
res.json({ results: { ips: topIps, users: topUsers, services: topServices } });
|
||||||
|
} catch(e) {
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// /api/metrics/user/:uid
|
||||||
|
router.get('/user/:uid', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
// Can only view if admin or self
|
||||||
|
if (req.user.uid !== req.params.uid) {
|
||||||
|
await permission.byGroup(req.user, ['app_sso_admin']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failed logins for user is hard if we didn't track it by user, but wait, we did! metrics:failed_users:YYYY-MM-DD
|
||||||
|
// However, we didn't track failed IPs per user. We tracked failed_users as a sorted set.
|
||||||
|
// To get the user's failures, we just query their score from the union.
|
||||||
|
|
||||||
|
// Wait, for services we have user_service_usage:<uid>:<date>. No, in metrics.js I wrote:
|
||||||
|
// `metrics:user_service_usage:${username}:${date}`
|
||||||
|
|
||||||
|
const topServices = await metrics.getTopN('metrics:user_service_usage', 7, 5, req.params.uid);
|
||||||
|
|
||||||
|
res.json({ results: { services: topServices } });
|
||||||
|
} catch(e) {
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -13,6 +13,7 @@ const middleware = require('../middleware/auth');
|
|||||||
const rateLimit = require('../middleware/rate_limit');
|
const rateLimit = require('../middleware/rate_limit');
|
||||||
const permission = require('../utils/permission');
|
const permission = require('../utils/permission');
|
||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
const metrics = require('../utils/metrics');
|
||||||
|
|
||||||
async function findUserByLogin(login) {
|
async function findUserByLogin(login) {
|
||||||
try {
|
try {
|
||||||
@@ -37,12 +38,16 @@ router.get('/username-suggestions', async function(req, res, next) {
|
|||||||
router.post('/login', rateLimit.login, async function(req, res, next){
|
router.post('/login', rateLimit.login, async function(req, res, next){
|
||||||
try{
|
try{
|
||||||
let auth = await Auth.login(req.body);
|
let auth = await Auth.login(req.body);
|
||||||
|
metrics.recordServiceUsage('SSO Web UI', req.body.uid);
|
||||||
return res.json({
|
return res.json({
|
||||||
login: true,
|
login: true,
|
||||||
token: auth.token.token,
|
token: auth.token.token,
|
||||||
message:`${req.body.uid} logged in!`,
|
message:`${req.body.uid} logged in!`,
|
||||||
});
|
});
|
||||||
}catch(error){
|
}catch(error){
|
||||||
|
if (error.name === 'LDAPLoginFailed' || error.status === 401 || error.name === 'UserNotFound') {
|
||||||
|
metrics.recordFailedLogin(req.ip, req.body.uid);
|
||||||
|
}
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
// Parses arguments according to the exposed method config.
|
||||||
|
// Extended to support { from: 'user' } which injects `req.user.dn` (LDAP integration).
|
||||||
|
function extractArgs(req, cfg) {
|
||||||
|
const args = cfg.args;
|
||||||
|
if (!args) return [];
|
||||||
|
if (args.from === 'user') return [req.user.dn];
|
||||||
|
|
||||||
|
const source = args.from === 'params' ? req.params
|
||||||
|
: args.from === 'query' ? req.query
|
||||||
|
: req.body;
|
||||||
|
|
||||||
|
if (Array.isArray(args.names)) return args.names.map(name => source[name]);
|
||||||
|
return [source || {}];
|
||||||
|
}
|
||||||
|
|
||||||
|
// A mini-auto-router that reads `static exposedMethods` from a @simpleworkjs/orm Model
|
||||||
|
// and maps them directly into Express endpoints.
|
||||||
|
function autoRouter(Model) {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
if (Model.getExposedMethods) {
|
||||||
|
for (const cfg of Model.getExposedMethods()) {
|
||||||
|
router[cfg.verb](cfg.routePath, async function(req, res, next) {
|
||||||
|
try {
|
||||||
|
// In a full implementation, we'd load the instance if cfg.kind === 'instance'.
|
||||||
|
// For now, our methods are all static class methods.
|
||||||
|
const target = Model;
|
||||||
|
const result = await target[cfg.method](...extractArgs(req, cfg));
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = autoRouter;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
const autoRouter = require('./autoRouter');
|
||||||
|
const { Resource } = require('../models/resource');
|
||||||
|
|
||||||
|
module.exports = autoRouter(Resource);
|
||||||
+13
-43
@@ -55,17 +55,20 @@ router.get('/tos', async function(req, res, next) {
|
|||||||
|
|
||||||
// Admin dashboard (stats + recent/inactive users) and Notifications
|
// Admin dashboard (stats + recent/inactive users) and Notifications
|
||||||
// (broadcast + history) merged into one page.
|
// (broadcast + history) merged into one page.
|
||||||
router.get('/dashboard', function(req, res) {
|
router.get('/executive', function(req, res) {
|
||||||
res.render('dashboard', {...values});
|
res.render('executive', {...values});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/admin', (req, res) => res.redirect(301, '/dashboard'));
|
router.get('/admin', (req, res) => res.redirect(301, '/executive'));
|
||||||
router.get('/notifications', (req, res) => res.redirect(301, '/dashboard'));
|
router.get('/notifications', (req, res) => res.redirect(301, '/executive'));
|
||||||
|
router.get('/dashboard', (req, res) => res.redirect(301, '/executive'));
|
||||||
|
|
||||||
router.get('/invites', function(req, res) {
|
router.get('/directory', function(req, res) {
|
||||||
res.render('invites', {...values});
|
res.render('directory', {...values});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Route removed since it's now in directory
|
||||||
|
|
||||||
router.get('/onboarding', async function(req, res, next) {
|
router.get('/onboarding', async function(req, res, next) {
|
||||||
try {
|
try {
|
||||||
const tos = await Tos.getCurrent();
|
const tos = await Tos.getCurrent();
|
||||||
@@ -76,6 +79,10 @@ router.get('/onboarding', async function(req, res, next) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.get('/', async function(req, res, next) {
|
router.get('/', async function(req, res, next) {
|
||||||
|
res.render('landing', {...values});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/profile', async function(req, res, next) {
|
||||||
res.render('profile', {...values});
|
res.render('profile', {...values});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -145,44 +152,7 @@ router.get('/token', function(req, res, next) {
|
|||||||
res.render('token', {...values});
|
res.render('token', {...values});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/sites', async function(req, res, next) {
|
|
||||||
const net = require('net');
|
|
||||||
const url = require('url');
|
|
||||||
|
|
||||||
const myId = process.env.LDAP_SERVER_ID || 'Standalone';
|
|
||||||
const hostsStr = process.env.LDAP_REPLICATION_HOSTS || '';
|
|
||||||
const hosts = hostsStr.split(' ').filter(h => h);
|
|
||||||
|
|
||||||
const sites = await Promise.all(hosts.map(hostUrl => {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
try {
|
|
||||||
const u = new url.URL(hostUrl);
|
|
||||||
const port = u.port || (u.protocol === 'ldaps:' ? 636 : 389);
|
|
||||||
const hostname = u.hostname;
|
|
||||||
const socket = new net.Socket();
|
|
||||||
socket.setTimeout(2000);
|
|
||||||
|
|
||||||
socket.on('connect', () => {
|
|
||||||
socket.destroy();
|
|
||||||
resolve({ url: hostUrl, status: 'Online' });
|
|
||||||
});
|
|
||||||
socket.on('timeout', () => {
|
|
||||||
socket.destroy();
|
|
||||||
resolve({ url: hostUrl, status: 'Offline (Timeout)' });
|
|
||||||
});
|
|
||||||
socket.on('error', (err) => {
|
|
||||||
socket.destroy();
|
|
||||||
resolve({ url: hostUrl, status: 'Offline (' + err.code + ')' });
|
|
||||||
});
|
|
||||||
socket.connect(port, hostname);
|
|
||||||
} catch (e) {
|
|
||||||
resolve({ url: hostUrl, status: 'Invalid URL' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}));
|
|
||||||
|
|
||||||
res.render('sites', { ...values, myId, sites });
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
router.get('/login/resetpassword/:token', async function(req, res, next){
|
router.get('/login/resetpassword/:token', async function(req, res, next){
|
||||||
|
|||||||
Binary file not shown.
@@ -1,124 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const router = require('express').Router();
|
|
||||||
const { OAuthClient } = require('../models/oauth_client');
|
|
||||||
const permission = require('../utils/permission');
|
|
||||||
|
|
||||||
const ADMIN_GROUP = 'app_sso_oauth_admin';
|
|
||||||
|
|
||||||
router.get('/', async function(req, res, next) {
|
|
||||||
try {
|
|
||||||
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
|
||||||
return res.json({ results: await OAuthClient.listDetail() });
|
|
||||||
} catch(error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/', async function(req, res, next) {
|
|
||||||
try {
|
|
||||||
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
|
||||||
|
|
||||||
req.body.created_by = req.user.uid;
|
|
||||||
|
|
||||||
// Parse redirect_uris if sent as newline-separated string from the form
|
|
||||||
if (typeof req.body.redirect_uris === 'string') {
|
|
||||||
req.body.redirect_uris = req.body.redirect_uris.split('\n').map(s => s.trim()).filter(Boolean);
|
|
||||||
}
|
|
||||||
// Parse scopes if sent as space-separated string
|
|
||||||
if (typeof req.body.scopes === 'string') {
|
|
||||||
req.body.scopes = req.body.scopes.split(' ').map(s => s.trim()).filter(Boolean);
|
|
||||||
}
|
|
||||||
// Parse allowed_groups if sent as newline-separated string
|
|
||||||
if (typeof req.body.allowed_groups === 'string') {
|
|
||||||
req.body.allowed_groups = req.body.allowed_groups.split('\n').map(s => s.trim()).filter(Boolean);
|
|
||||||
}
|
|
||||||
// jQuery serializeObject sends nested fields as "token_lifetime[access_token]"
|
|
||||||
if (req.body['token_lifetime[access_token]'] || req.body['token_lifetime[refresh_token]']) {
|
|
||||||
req.body.token_lifetime = {
|
|
||||||
access_token: Number(req.body['token_lifetime[access_token]']) || 3600,
|
|
||||||
refresh_token: Number(req.body['token_lifetime[refresh_token]']) || 2592000,
|
|
||||||
};
|
|
||||||
delete req.body['token_lifetime[access_token]'];
|
|
||||||
delete req.body['token_lifetime[refresh_token]'];
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = await OAuthClient.add(req.body);
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
results: client,
|
|
||||||
client_secret: client._raw_secret,
|
|
||||||
message: `OAuth client '${client.name}' created. Save the client secret — it will not be shown again.`,
|
|
||||||
});
|
|
||||||
} catch(error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/:client_id', async function(req, res, next) {
|
|
||||||
try {
|
|
||||||
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
|
||||||
return res.json({ results: await OAuthClient.get(req.params.client_id) });
|
|
||||||
} catch(error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.put('/:client_id', async function(req, res, next) {
|
|
||||||
try {
|
|
||||||
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
|
||||||
|
|
||||||
const client = await OAuthClient.get(req.params.client_id);
|
|
||||||
|
|
||||||
if (typeof req.body.redirect_uris === 'string') {
|
|
||||||
req.body.redirect_uris = req.body.redirect_uris.split('\n').map(s => s.trim()).filter(Boolean);
|
|
||||||
}
|
|
||||||
if (typeof req.body.scopes === 'string') {
|
|
||||||
req.body.scopes = req.body.scopes.split(' ').map(s => s.trim()).filter(Boolean);
|
|
||||||
}
|
|
||||||
if (typeof req.body.allowed_groups === 'string') {
|
|
||||||
req.body.allowed_groups = req.body.allowed_groups.split('\n').map(s => s.trim()).filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
results: await client.update(req.body),
|
|
||||||
message: `OAuth client '${client.name}' updated.`,
|
|
||||||
});
|
|
||||||
} catch(error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.delete('/:client_id', async function(req, res, next) {
|
|
||||||
try {
|
|
||||||
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
|
||||||
|
|
||||||
const client = await OAuthClient.get(req.params.client_id);
|
|
||||||
await client.remove();
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
client_id: req.params.client_id,
|
|
||||||
message: `OAuth client '${client.name}' deleted.`,
|
|
||||||
});
|
|
||||||
} catch(error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/:client_id/rotate', async function(req, res, next) {
|
|
||||||
try {
|
|
||||||
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
|
||||||
|
|
||||||
const client = await OAuthClient.get(req.params.client_id);
|
|
||||||
const new_secret = await client.rotateSecret();
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
client_secret: new_secret,
|
|
||||||
message: `Client secret rotated for '${client.name}'. Save it — it will not be shown again.`,
|
|
||||||
});
|
|
||||||
} catch(error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@@ -231,7 +231,7 @@ router.get('/invite', async function(req, res, next){
|
|||||||
try{
|
try{
|
||||||
await permission.byGroup(req.user, ['app_sso_admin', 'app_sso_invite']);
|
await permission.byGroup(req.user, ['app_sso_admin', 'app_sso_invite']);
|
||||||
const isAdmin = await permission.byGroup(req.user, ['app_sso_admin']).then(() => true).catch(() => false);
|
const isAdmin = await permission.byGroup(req.user, ['app_sso_admin']).then(() => true).catch(() => false);
|
||||||
const all = await InviteToken.listDetail();
|
const all = await InviteToken.list();
|
||||||
const visible = isAdmin ? all : all.filter(t => t.created_by === req.user.uid);
|
const visible = isAdmin ? all : all.filter(t => t.created_by === req.user.uid);
|
||||||
const results = visible.map(t => ({ token: t.token, ...t }));
|
const results = visible.map(t => ({ token: t.token, ...t }));
|
||||||
return res.json({ results });
|
return res.json({ results });
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
'use strict';
|
||||||
|
const fs = require('fs');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const metrics = require('../utils/metrics');
|
||||||
|
|
||||||
|
function startLdapMonitor() {
|
||||||
|
const logFile = '/var/lib/ldap/slapd.log';
|
||||||
|
if (!fs.existsSync(logFile)) {
|
||||||
|
setTimeout(startLdapMonitor, 5000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tail = spawn('tail', ['-F', logFile]);
|
||||||
|
const connections = {}; // connID -> { ip, uid }
|
||||||
|
|
||||||
|
tail.stdout.on('data', (data) => {
|
||||||
|
const lines = data.toString().split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
|
||||||
|
const connMatch = line.match(/conn=(\d+)/);
|
||||||
|
if (!connMatch) continue;
|
||||||
|
const conn = connMatch[1];
|
||||||
|
|
||||||
|
if (!connections[conn]) {
|
||||||
|
connections[conn] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipMatch = line.match(/ACCEPT from IP=([^:]+)/);
|
||||||
|
if (ipMatch) {
|
||||||
|
connections[conn].ip = ipMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindMatch = line.match(/BIND dn="uid=([^,]+)/i) || line.match(/BIND dn="cn=([^,]+)/i);
|
||||||
|
if (bindMatch) {
|
||||||
|
connections[conn].uid = bindMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultMatch = line.match(/RESULT tag=\d+ err=(\d+)/);
|
||||||
|
if (resultMatch) {
|
||||||
|
const errCode = parseInt(resultMatch[1], 10);
|
||||||
|
const { ip, uid } = connections[conn];
|
||||||
|
if (errCode === 0 && uid) {
|
||||||
|
metrics.recordServiceUsage('LDAP Direct', uid);
|
||||||
|
} else if (errCode === 49 || errCode === 32) {
|
||||||
|
metrics.recordFailedLogin(ip, uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.includes('closed') || line.includes('UNBIND')) {
|
||||||
|
delete connections[conn];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tail.on('error', (err) => {
|
||||||
|
console.error('Failed to start LDAP monitor', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
startLdapMonitor();
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
const { init } = require('@simpleworkjs/orm');
|
||||||
|
const { Resource, ResourceEdge, ResourceGroup } = require('./models/resource');
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
try {
|
||||||
|
const models = await init({
|
||||||
|
conf: {
|
||||||
|
orm: {
|
||||||
|
dialect: 'sqlite',
|
||||||
|
storage: ':memory:', // Test in memory
|
||||||
|
logging: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
models: [Resource, ResourceEdge, ResourceGroup]
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('ORM initialized successfully!');
|
||||||
|
|
||||||
|
const r1 = await models.Resource.create({
|
||||||
|
kind: 'proxmox_node',
|
||||||
|
name: 'pve1',
|
||||||
|
slug: 'pve1',
|
||||||
|
metadata: { ip: '10.0.0.1' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const r2 = await models.Resource.create({
|
||||||
|
kind: 'container',
|
||||||
|
name: 'ct101',
|
||||||
|
slug: 'ct101',
|
||||||
|
metadata: { ip: '10.0.0.2' }
|
||||||
|
});
|
||||||
|
|
||||||
|
await models.ResourceEdge.create({
|
||||||
|
parentId: r1.id,
|
||||||
|
childId: r2.id,
|
||||||
|
relation: 'hosts'
|
||||||
|
});
|
||||||
|
|
||||||
|
const edges = await models.ResourceEdge.list();
|
||||||
|
console.log('Edges:', JSON.stringify(edges, null, 2));
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test();
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const request = require('supertest');
|
||||||
|
const app = require('../app');
|
||||||
|
|
||||||
|
// Note: To test this properly, valid LDAP credentials are required in setup.js
|
||||||
|
// Currently tests are skipped or rely on valid auth token to avoid LDAP auth failures
|
||||||
|
describe.skip('Directory Admin API', () => {
|
||||||
|
let token;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// A valid admin token is required
|
||||||
|
token = 'placeholder_token';
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /api/directory-admin/resources requires hostId for services', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/directory-admin/resources')
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({
|
||||||
|
name: 'Test Service',
|
||||||
|
slug: 'app_test_service',
|
||||||
|
kind: 'service'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toContain('parent Host');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /api/directory-admin/resources creates valid service with parent', async () => {
|
||||||
|
// This requires a valid host ID to exist first in a real test
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/directory-admin/resources')
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({
|
||||||
|
name: 'Test Service',
|
||||||
|
slug: 'app_test_service',
|
||||||
|
kind: 'service',
|
||||||
|
hostId: 'some-uuid-here'
|
||||||
|
});
|
||||||
|
|
||||||
|
// In a fully mocked environment this would be 200
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
// Flush all test-prefix Redis keys before each test run so state is always clean.
|
// Flush all test-prefix Redis keys before each test run so state is always clean.
|
||||||
// Uses model-redis's own bundled redis client since redis is not a top-level dep.
|
// Uses model-redis's own bundled redis client since redis is not a top-level dep.
|
||||||
const { createClient } = require('../node_modules/model-redis/node_modules/redis');
|
const { createClient } = require('redis');
|
||||||
|
|
||||||
module.exports = async function() {
|
module.exports = async function() {
|
||||||
const client = createClient();
|
const client = createClient();
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const request = require('supertest');
|
|
||||||
const app = require('../app');
|
|
||||||
const conf = require('@simpleworkjs/conf');
|
|
||||||
|
|
||||||
const ORIG_LDAP = { ...conf.ldap };
|
|
||||||
const ORIG_OAUTH = { ...(conf.oauth || {}) };
|
|
||||||
|
|
||||||
function restoreConf() {
|
|
||||||
conf.ldap = { ...conf.ldap, ...ORIG_LDAP };
|
|
||||||
conf.oauth = { ...(conf.oauth || {}), ...ORIG_OAUTH };
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
// Start each test from a known state; the local secrets.js may set an issuer.
|
|
||||||
conf.ldap = { ...conf.ldap, ldapsHost: '', ldapsPort: 636 };
|
|
||||||
if (conf.oauth) conf.oauth.issuer = '';
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
restoreConf();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('GET /integrations', () => {
|
|
||||||
test('renders and derives LDAPS URL from the request host by default', async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.get('/integrations')
|
|
||||||
.set('Host', 'sso.example.com');
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.text).toContain('ldaps://sso.example.com:636');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('uses conf.ldap.ldapsHost when set', async () => {
|
|
||||||
conf.ldap = { ...conf.ldap, ldapsHost: 'ldap.internal.example.com', ldapsPort: 1636 };
|
|
||||||
|
|
||||||
const res = await request(app)
|
|
||||||
.get('/integrations')
|
|
||||||
.set('Host', 'public.example.com');
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.text).toContain('ldaps://ldap.internal.example.com:1636');
|
|
||||||
expect(res.text).not.toContain('ldaps://public.example.com:636');
|
|
||||||
expect(res.text).toContain('Custom <code>conf.ldap.ldapsHost</code>');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const { login, request, app } = require('./setup');
|
|
||||||
|
|
||||||
const TEST_CLIENT = {
|
|
||||||
name: 'Test Client',
|
|
||||||
description: 'Created by automated tests',
|
|
||||||
redirect_uris: 'https://test.example.com/callback',
|
|
||||||
scopes: 'openid profile email',
|
|
||||||
token_lifetime: { access_token: 3600, refresh_token: 86400 },
|
|
||||||
};
|
|
||||||
|
|
||||||
let token;
|
|
||||||
let clientId;
|
|
||||||
let clientSecret;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
token = await login();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
if (clientId) {
|
|
||||||
await request(app)
|
|
||||||
.delete(`/api/oauth/client/${clientId}`)
|
|
||||||
.set('auth-token', token);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('OAuth Clients — POST /api/oauth/client/', () => {
|
|
||||||
test('creates a new client and returns one-time secret', async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/oauth/client/')
|
|
||||||
.set('auth-token', token)
|
|
||||||
.send(TEST_CLIENT);
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body).toHaveProperty('results');
|
|
||||||
expect(res.body).toHaveProperty('client_secret');
|
|
||||||
expect(res.body.results).toHaveProperty('client_id');
|
|
||||||
expect(res.body.results).toHaveProperty('name', TEST_CLIENT.name);
|
|
||||||
expect(res.body.results.client_id.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
clientId = res.body.results.client_id;
|
|
||||||
clientSecret = res.body.client_secret;
|
|
||||||
});
|
|
||||||
|
|
||||||
test('requires oauth_admin group — 401 not shown here (see group membership)', () => {
|
|
||||||
// If test user is not in app_sso_oauth_admin, the test above will fail with 401.
|
|
||||||
// That itself is the correct behavior to verify.
|
|
||||||
expect(clientId).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('OAuth Clients — GET /api/oauth/client/', () => {
|
|
||||||
test('lists clients including the test client', async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.get('/api/oauth/client/')
|
|
||||||
.set('auth-token', token);
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(Array.isArray(res.body.results)).toBe(true);
|
|
||||||
const found = res.body.results.find(c => c.client_id === clientId);
|
|
||||||
expect(found).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('OAuth Clients — GET /api/oauth/client/:id', () => {
|
|
||||||
test('returns the test client by id', async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.get(`/api/oauth/client/${clientId}`)
|
|
||||||
.set('auth-token', token);
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.results).toHaveProperty('client_id', clientId);
|
|
||||||
expect(res.body.results).toHaveProperty('name', TEST_CLIENT.name);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('unknown client_id returns 404 or error', async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.get('/api/oauth/client/00000000-0000-0000-0000-000000000000')
|
|
||||||
.set('auth-token', token);
|
|
||||||
|
|
||||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('OAuth Clients — PUT /api/oauth/client/:id', () => {
|
|
||||||
test('updates the client description', async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.put(`/api/oauth/client/${clientId}`)
|
|
||||||
.set('auth-token', token)
|
|
||||||
.send({ description: 'Updated by test' });
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body).toHaveProperty('message');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('OAuth Clients — POST /api/oauth/client/:id/rotate', () => {
|
|
||||||
test('rotates the client secret and returns a new one', async () => {
|
|
||||||
const res = await request(app)
|
|
||||||
.post(`/api/oauth/client/${clientId}/rotate`)
|
|
||||||
.set('auth-token', token);
|
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body).toHaveProperty('client_secret');
|
|
||||||
expect(typeof res.body.client_secret).toBe('string');
|
|
||||||
expect(res.body.client_secret).not.toBe(clientSecret);
|
|
||||||
|
|
||||||
clientSecret = res.body.client_secret;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
'use strict';
|
||||||
|
const { createClient } = require('redis');
|
||||||
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
|
||||||
|
let client;
|
||||||
|
async function getClient() {
|
||||||
|
if (!client) {
|
||||||
|
// conf.redis could be an object or a connection string depending on @simpleworkjs/conf
|
||||||
|
// But for sso-manager, Redis runs locally or is configured via environment
|
||||||
|
// The tests use createClient() with no args, so we do the same, allowing env vars to override
|
||||||
|
const url = (conf.redis && typeof conf.redis === 'string') ? conf.redis : (conf.redis && conf.redis.url) ? conf.redis.url : undefined;
|
||||||
|
client = createClient({ url });
|
||||||
|
client.on('error', (err) => console.error('Redis metrics error', err));
|
||||||
|
await client.connect();
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTodayKey() {
|
||||||
|
return new Date().toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordFailedLogin(ip, username) {
|
||||||
|
try {
|
||||||
|
const c = await getClient();
|
||||||
|
const date = getTodayKey();
|
||||||
|
const p = c.multi();
|
||||||
|
if (ip) {
|
||||||
|
p.zIncrBy(`metrics:failed_ips:${date}`, 1, ip);
|
||||||
|
p.expire(`metrics:failed_ips:${date}`, 30 * 86400);
|
||||||
|
}
|
||||||
|
if (username) {
|
||||||
|
p.zIncrBy(`metrics:failed_users:${date}`, 1, username);
|
||||||
|
p.expire(`metrics:failed_users:${date}`, 30 * 86400);
|
||||||
|
}
|
||||||
|
await p.exec();
|
||||||
|
} catch(e) {
|
||||||
|
console.error('Failed to record failed login metric', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordServiceUsage(serviceName, username) {
|
||||||
|
try {
|
||||||
|
const c = await getClient();
|
||||||
|
const date = getTodayKey();
|
||||||
|
const p = c.multi();
|
||||||
|
if (serviceName) {
|
||||||
|
p.zIncrBy(`metrics:service_usage:${date}`, 1, serviceName);
|
||||||
|
p.expire(`metrics:service_usage:${date}`, 30 * 86400);
|
||||||
|
if (username) {
|
||||||
|
p.zIncrBy(`metrics:user_service_usage:${username}:${date}`, 1, serviceName);
|
||||||
|
p.expire(`metrics:user_service_usage:${username}:${date}`, 30 * 86400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await p.exec();
|
||||||
|
} catch(e) {
|
||||||
|
console.error('Failed to record service usage metric', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to aggregate the last N days of a metric prefix
|
||||||
|
async function getTopN(prefix, days, topN, user = null) {
|
||||||
|
try {
|
||||||
|
const c = await getClient();
|
||||||
|
const keys = [];
|
||||||
|
const today = new Date();
|
||||||
|
for (let i = 0; i < days; i++) {
|
||||||
|
const d = new Date(today);
|
||||||
|
d.setDate(d.getDate() - i);
|
||||||
|
const dateStr = d.toISOString().split('T')[0];
|
||||||
|
if (user) {
|
||||||
|
keys.push(`${prefix}:${user}:${dateStr}`);
|
||||||
|
} else {
|
||||||
|
keys.push(`${prefix}:${dateStr}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempKey = `metrics:temp:union:${Date.now()}:${Math.floor(Math.random() * 1000000)}`;
|
||||||
|
// ZUNIONSTORE is replaced by ZUNIONSTORE in redis v4 Node client, usually zUnionStore
|
||||||
|
await c.zUnionStore(tempKey, keys.length, keys);
|
||||||
|
const results = await c.zRangeWithScores(tempKey, 0, topN - 1, { REV: true });
|
||||||
|
await c.del(tempKey);
|
||||||
|
|
||||||
|
return results.map(r => ({ value: r.value, score: r.score }));
|
||||||
|
} catch(e) {
|
||||||
|
console.error('Failed to get top N metrics', e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
recordFailedLogin,
|
||||||
|
recordServiceUsage,
|
||||||
|
getTopN
|
||||||
|
};
|
||||||
@@ -1,442 +0,0 @@
|
|||||||
<%- include('top') %>
|
|
||||||
|
|
||||||
<script type="text/javascript">
|
|
||||||
app.auth.forceLogin('app_sso_admin');
|
|
||||||
|
|
||||||
// ── Overview (stats, recent signups, inactive users) ────────────────────
|
|
||||||
async function loadDashboard() {
|
|
||||||
try {
|
|
||||||
const stats = await app.api.get('user/stats');
|
|
||||||
|
|
||||||
// Stat cards
|
|
||||||
document.getElementById('stat-total').textContent = stats.totalUsers;
|
|
||||||
document.getElementById('stat-active').textContent = stats.activeUsers;
|
|
||||||
document.getElementById('stat-inactive').textContent = stats.inactiveUsers;
|
|
||||||
document.getElementById('stat-groups').textContent = stats.totalGroups;
|
|
||||||
|
|
||||||
// Recent signups
|
|
||||||
stats.recentSignups.forEach(function(u) {
|
|
||||||
u.createTimestamp = moment(u.createTimestamp, 'YYYYMMDDHHmmssZ').fromNow();
|
|
||||||
});
|
|
||||||
$.scope.recentSignups.push(...stats.recentSignups);
|
|
||||||
|
|
||||||
// Inactive users
|
|
||||||
$.scope.inactiveUsers.push(...stats.inactiveList);
|
|
||||||
|
|
||||||
document.getElementById('dashboard-overview').style.display = '';
|
|
||||||
} catch(e) {
|
|
||||||
if (e && (e.status === 401 || e.name === 'Insufficient Permission')) {
|
|
||||||
location.replace('/');
|
|
||||||
} else {
|
|
||||||
console.error('Dashboard load error:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function activateUser(uid) {
|
|
||||||
try {
|
|
||||||
await app.api.put('user/' + uid + '/active', { active: true });
|
|
||||||
$.scope.inactiveUsers.remove('uid', uid);
|
|
||||||
document.getElementById('stat-inactive').textContent =
|
|
||||||
parseInt(document.getElementById('stat-inactive').textContent) - 1;
|
|
||||||
document.getElementById('stat-active').textContent =
|
|
||||||
parseInt(document.getElementById('stat-active').textContent) + 1;
|
|
||||||
} catch(e) {
|
|
||||||
alert('Failed to activate user.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function exportUsers() {
|
|
||||||
const resp = await fetch('/api/user/export', {
|
|
||||||
headers: { 'auth-token': localStorage.getItem('APIToken') }
|
|
||||||
});
|
|
||||||
const blob = await resp.blob();
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = URL.createObjectURL(blob);
|
|
||||||
a.download = 'users.csv';
|
|
||||||
a.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Notifications (compose + history) ────────────────────────────────────
|
|
||||||
async function loadHistory() {
|
|
||||||
try {
|
|
||||||
const data = await app.api.get('notification');
|
|
||||||
const list = data.results || [];
|
|
||||||
list.forEach(function(n) {
|
|
||||||
n.created_on_fmt = moment(n.created_on).fromNow();
|
|
||||||
n.filter_label = formatFilterLabel(n.filter_type, n.filter_value, n.active_only);
|
|
||||||
});
|
|
||||||
$.scope.notificationHistory.push(...list);
|
|
||||||
} catch(e) {
|
|
||||||
if (e && e.status === 401) location.replace('/');
|
|
||||||
else console.error('Failed to load notification history:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFilterLabel(type, value, active_only) {
|
|
||||||
const suffix = active_only ? ' (active only)' : '';
|
|
||||||
if (type === 'group') return 'Groups: ' + value + suffix;
|
|
||||||
if (type === 'users') return 'Specific users';
|
|
||||||
if (type === 'all_active') return 'All active users';
|
|
||||||
if (type === 'all') return 'All users';
|
|
||||||
return type;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleFilterInputs() {
|
|
||||||
// No radio is checked by default (see f-all-active below) — a "send to
|
|
||||||
// everyone" option must be a deliberate choice, not whatever happens to
|
|
||||||
// be pre-selected when someone's just trying the form out.
|
|
||||||
const checked = document.querySelector('input[name="notif-filter"]:checked');
|
|
||||||
const type = checked ? checked.value : null;
|
|
||||||
document.getElementById('notif-group-row').style.display = type === 'group' ? '' : 'none';
|
|
||||||
document.getElementById('notif-users-row').style.display = type === 'users' ? '' : 'none';
|
|
||||||
document.getElementById('notif-active-row').style.display = (type === 'group' || type === 'all') ? '' : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendNotification() {
|
|
||||||
const subject = document.getElementById('notif-subject').value.trim();
|
|
||||||
const message = document.getElementById('notif-message').value.trim();
|
|
||||||
const filterCheck = document.querySelector('input[name="notif-filter"]:checked');
|
|
||||||
const groupValue = document.getElementById('notif-group').value.trim();
|
|
||||||
const usersValue = document.getElementById('notif-users').value.trim();
|
|
||||||
const activeOnly = document.getElementById('notif-active-only').checked;
|
|
||||||
const msgEl = document.getElementById('notif-result');
|
|
||||||
const $compose = $('#notif-subject').closest('.card-body');
|
|
||||||
|
|
||||||
if (!subject || !message) { alert('Subject and message are required.'); return; }
|
|
||||||
if (!filterCheck) { alert('Choose who to send this to.'); return; }
|
|
||||||
const filterType = filterCheck.value;
|
|
||||||
|
|
||||||
let filter_value = '';
|
|
||||||
if (filterType === 'group') filter_value = groupValue;
|
|
||||||
if (filterType === 'users') filter_value = JSON.stringify(usersValue.split(',').map(s => s.trim()).filter(Boolean));
|
|
||||||
|
|
||||||
// Broadcasting to everyone is easy to trigger by accident while just
|
|
||||||
// trying the form out — make it a deliberate, confirmed action.
|
|
||||||
if (filterType === 'all' || filterType === 'all_active') {
|
|
||||||
const label = filterType === 'all' ? 'ALL users (including inactive)' : 'all ACTIVE users';
|
|
||||||
const confirmed = await app.util.actionConfirm(`Send this notification to ${label}?`, $compose, 'warning');
|
|
||||||
if (!confirmed) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
msgEl.className = 'alert alert-info mt-2';
|
|
||||||
msgEl.textContent = 'Sending…';
|
|
||||||
msgEl.style.display = '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await app.api.post('notification', {
|
|
||||||
subject,
|
|
||||||
message,
|
|
||||||
filter_type: filterType,
|
|
||||||
filter_value,
|
|
||||||
active_only: activeOnly,
|
|
||||||
});
|
|
||||||
msgEl.className = 'alert alert-success mt-2';
|
|
||||||
msgEl.textContent = `Sent to ${result.results.sent_count} recipient(s). ${result.results.failed_count} failed.`;
|
|
||||||
|
|
||||||
const n = result.results;
|
|
||||||
n.created_on_fmt = 'just now';
|
|
||||||
n.filter_label = formatFilterLabel(n.filter_type, n.filter_value, n.active_only);
|
|
||||||
$.scope.notificationHistory.unshift([n]);
|
|
||||||
|
|
||||||
// Reset form
|
|
||||||
document.getElementById('notif-subject').value = '';
|
|
||||||
document.getElementById('notif-message').value = '';
|
|
||||||
} catch(e) {
|
|
||||||
msgEl.className = 'alert alert-danger mt-2';
|
|
||||||
msgEl.textContent = 'Failed: ' + ((e.responseJSON && e.responseJSON.message) || e.message || 'Unknown error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Terms of Service ──────────────────────────────────────────────────
|
|
||||||
async function loadTos() {
|
|
||||||
try {
|
|
||||||
const tos = await app.tos.get();
|
|
||||||
document.getElementById('tos-content').value = tos.content;
|
|
||||||
document.getElementById('tos-meta').textContent =
|
|
||||||
'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by;
|
|
||||||
} catch(e) {
|
|
||||||
console.error('Failed to load ToS:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveTos() {
|
|
||||||
const content = document.getElementById('tos-content').value.trim();
|
|
||||||
const resetAcceptance = document.getElementById('tos-reset-acceptance').checked;
|
|
||||||
const msgEl = document.getElementById('tos-result');
|
|
||||||
|
|
||||||
if (!content) { alert('Terms of Service text cannot be empty.'); return; }
|
|
||||||
|
|
||||||
app.tos.update({content, resetAcceptance}, function(error, data) {
|
|
||||||
if (error) {
|
|
||||||
msgEl.className = 'alert alert-danger mt-2';
|
|
||||||
msgEl.textContent = 'Failed: ' + ((data && data.message) || error);
|
|
||||||
msgEl.style.display = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
msgEl.className = 'alert alert-success mt-2';
|
|
||||||
msgEl.textContent = 'Saved.' + (data.resetCount ? ' ' + data.resetCount + ' user(s) will be asked to re-accept.' : '');
|
|
||||||
msgEl.style.display = '';
|
|
||||||
document.getElementById('tos-reset-acceptance').checked = false;
|
|
||||||
loadTos();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$(document).ready(function() {
|
|
||||||
loadDashboard();
|
|
||||||
loadHistory();
|
|
||||||
toggleFilterInputs();
|
|
||||||
loadTos();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="row mb-3 mt-2">
|
|
||||||
<div class="col-12">
|
|
||||||
<h4 class="mb-0"><i class="fa-solid fa-gauge-high"></i> Dashboard</h4>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="dashboard-overview" class="row" style="display:none">
|
|
||||||
<div class="col-12">
|
|
||||||
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
||||||
<h5 class="mb-0"><i class="fa-solid fa-users"></i> Overview</h5>
|
|
||||||
<button class="btn btn-outline-secondary shadow" onclick="exportUsers()">
|
|
||||||
<i class="fa-solid fa-file-csv"></i> Export Users CSV
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Stat cards -->
|
|
||||||
<div class="row g-3 mb-4">
|
|
||||||
<div class="col-6 col-md-3">
|
|
||||||
<div class="card shadow text-center">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="display-6 fw-bold" id="stat-total">—</div>
|
|
||||||
<div class="text-muted small"><i class="fa-solid fa-users"></i> Total Users</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-md-3">
|
|
||||||
<div class="card shadow text-center border-success">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="display-6 fw-bold text-success" id="stat-active">—</div>
|
|
||||||
<div class="text-muted small"><i class="fa-solid fa-circle-check"></i> Active</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-md-3">
|
|
||||||
<div class="card shadow text-center border-danger">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="display-6 fw-bold text-danger" id="stat-inactive">—</div>
|
|
||||||
<div class="text-muted small"><i class="fa-solid fa-lock"></i> Inactive</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-md-3">
|
|
||||||
<div class="card shadow text-center border-info">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="display-6 fw-bold text-info" id="stat-groups">—</div>
|
|
||||||
<div class="text-muted small"><i class="fa-solid fa-users-viewfinder"></i> Groups</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-3">
|
|
||||||
|
|
||||||
<!-- Recent signups -->
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="card shadow-lg">
|
|
||||||
<div class="card-header shadow">
|
|
||||||
<i class="fa-solid fa-user-plus"></i> Recent Signups
|
|
||||||
</div>
|
|
||||||
<div class="card-body p-0">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-sm mb-0">
|
|
||||||
<thead>
|
|
||||||
<tr><th>User</th><th>Email</th><th>Joined</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody jq-repeat="recentSignups">
|
|
||||||
<tr>
|
|
||||||
<td><a href="/users/{{uid}}">{{uid}}</a><br><small class="text-muted">{{givenName}} {{sn}}</small></td>
|
|
||||||
<td><small>{{mail}}</small></td>
|
|
||||||
<td><small class="text-muted">{{createTimestamp}}</small></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Inactive users -->
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="card shadow-lg">
|
|
||||||
<div class="card-header shadow">
|
|
||||||
<i class="fa-solid fa-lock"></i> Inactive Users
|
|
||||||
</div>
|
|
||||||
<div class="card-body p-0">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-sm mb-0">
|
|
||||||
<thead>
|
|
||||||
<tr><th>User</th><th>Email</th><th></th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody jq-repeat="inactiveUsers" jq-index-key="uid">
|
|
||||||
<tr>
|
|
||||||
<td><a href="/users/{{uid}}">{{uid}}</a><br><small class="text-muted">{{givenName}} {{sn}}</small></td>
|
|
||||||
<td><small>{{mail}}</small></td>
|
|
||||||
<td class="text-end">
|
|
||||||
<button class="btn btn-sm btn-outline-success" onclick="activateUser('{{uid}}')">
|
|
||||||
<i class="fa-solid fa-lock-open"></i>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row mt-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<h5 class="mb-3"><i class="fa-solid fa-paper-plane"></i> Notifications</h5>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-3">
|
|
||||||
|
|
||||||
<!-- Compose -->
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="card shadow-lg">
|
|
||||||
<div class="card-header shadow">
|
|
||||||
<i class="fa-solid fa-pencil"></i> Compose
|
|
||||||
</div>
|
|
||||||
<div class="card-header shadow actionMessage" style="display:none"></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Subject</label>
|
|
||||||
<input type="text" class="form-control shadow" id="notif-subject" placeholder="Maintenance window tonight" />
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Message <small class="text-muted">(HTML allowed)</small></label>
|
|
||||||
<textarea class="form-control shadow" id="notif-message" rows="6" placeholder="<p>Hello, we will be performing maintenance...</p>"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Send to</label>
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="radio" name="notif-filter" id="f-all-active" value="all_active" onchange="toggleFilterInputs()">
|
|
||||||
<label class="form-check-label" for="f-all-active">All active users</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="radio" name="notif-filter" id="f-all" value="all" onchange="toggleFilterInputs()">
|
|
||||||
<label class="form-check-label" for="f-all">All users (including inactive)</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="radio" name="notif-filter" id="f-group" value="group" onchange="toggleFilterInputs()">
|
|
||||||
<label class="form-check-label" for="f-group">Group members</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="radio" name="notif-filter" id="f-users" value="users" onchange="toggleFilterInputs()">
|
|
||||||
<label class="form-check-label" for="f-users">Specific users</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="notif-group-row" class="mb-3" style="display:none">
|
|
||||||
<label class="form-label">Groups <small class="text-muted">(comma-separated)</small></label>
|
|
||||||
<input type="text" class="form-control shadow" id="notif-group" placeholder="host_hec-bot_admin, app_sso_admin" />
|
|
||||||
</div>
|
|
||||||
<div id="notif-users-row" class="mb-3" style="display:none">
|
|
||||||
<label class="form-label">UIDs <small class="text-muted">(comma-separated)</small></label>
|
|
||||||
<input type="text" class="form-control shadow" id="notif-users" placeholder="wmantly, jsmith" />
|
|
||||||
</div>
|
|
||||||
<div id="notif-active-row" class="mb-3" style="display:none">
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" id="notif-active-only" checked>
|
|
||||||
<label class="form-check-label" for="notif-active-only">Active members only</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="btn btn-primary shadow" onclick="sendNotification()">
|
|
||||||
<i class="fa-solid fa-paper-plane"></i> Send
|
|
||||||
</button>
|
|
||||||
<div id="notif-result" style="display:none" class="mt-2"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- History -->
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="card shadow-lg">
|
|
||||||
<div class="card-header shadow">
|
|
||||||
<i class="fa-solid fa-clock-rotate-left"></i> History
|
|
||||||
</div>
|
|
||||||
<div class="card-body p-0">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-sm mb-0">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Sent</th>
|
|
||||||
<th>Subject</th>
|
|
||||||
<th>Filter</th>
|
|
||||||
<th class="text-center">✓</th>
|
|
||||||
<th class="text-center">✗</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody jq-repeat="notificationHistory" jq-index-key="notification_id">
|
|
||||||
<tr>
|
|
||||||
<td><small class="text-muted">{{created_on_fmt}}</small></td>
|
|
||||||
<td><small>{{subject}}</small></td>
|
|
||||||
<td><small class="text-muted">{{filter_label}}</small></td>
|
|
||||||
<td class="text-center text-success"><small>{{sent_count}}</small></td>
|
|
||||||
<td class="text-center text-danger"><small>{{failed_count}}</small></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row mt-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<h5 class="mb-3"><i class="fa-solid fa-file-contract"></i> Terms of Service</h5>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card shadow-lg">
|
|
||||||
<div class="card-header shadow">
|
|
||||||
<i class="fa-solid fa-pencil"></i> Editor
|
|
||||||
<small class="text-muted float-end" id="tos-meta"></small>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Content <small class="text-muted">(Markdown)</small></label>
|
|
||||||
<textarea class="form-control shadow" id="tos-content" rows="16"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="form-check mb-3">
|
|
||||||
<input class="form-check-input" type="checkbox" id="tos-reset-acceptance">
|
|
||||||
<label class="form-check-label" for="tos-reset-acceptance">
|
|
||||||
Require all users to re-accept these terms
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary shadow" onclick="saveTos()">
|
|
||||||
<i class="fa-solid fa-floppy-disk"></i> Save
|
|
||||||
</button>
|
|
||||||
<div id="tos-result" style="display:none" class="mt-2"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%- include('impersonate_modal') %>
|
|
||||||
<%- include('bottom') %>
|
|
||||||
@@ -0,0 +1,819 @@
|
|||||||
|
<%- include('top') %>
|
||||||
|
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card shadow">
|
||||||
|
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
|
<div>
|
||||||
|
<i class="fa-solid fa-server"></i> Directory Management
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2 align-items-center">
|
||||||
|
<input type="text" id="search-filter" class="form-control form-control-sm shadow-sm" placeholder="Search..." onkeyup="renderTable()" style="width: 200px;">
|
||||||
|
<select id="sort-by" class="form-select form-select-sm shadow-sm" onchange="renderTable()" style="width: 150px;">
|
||||||
|
<option value="name">Name (A-Z)</option>
|
||||||
|
<option value="kind">Kind</option>
|
||||||
|
<option value="env">Environment</option>
|
||||||
|
</select>
|
||||||
|
<div class="btn-group btn-group-sm shadow-sm" role="group">
|
||||||
|
<input type="radio" class="btn-check" name="viewMode" id="view-list" value="list" autocomplete="off" checked onchange="renderTable()">
|
||||||
|
<label class="btn btn-outline-secondary" for="view-list"><i class="fa-solid fa-list"></i></label>
|
||||||
|
<input type="radio" class="btn-check" name="viewMode" id="view-tree" value="tree" autocomplete="off" onchange="renderTable()">
|
||||||
|
<label class="btn btn-outline-secondary" for="view-tree"><i class="fa-solid fa-folder-tree"></i></label>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-primary ms-1 shadow-sm" onclick="openAddModal()">
|
||||||
|
<i class="fas fa-plus"></i> Add Resource
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
|
<div class="p-3 pb-0 text-muted small border-bottom">
|
||||||
|
<i class="fa-solid fa-circle-info"></i> Manage infrastructure, services, and their relationships.
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="card-body table table-striped mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="ps-3">Kind</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Env</th>
|
||||||
|
<th>Host</th>
|
||||||
|
<th>IP / Address</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="resources-list" jq-repeat="resources">
|
||||||
|
<tr>
|
||||||
|
<td class="ps-3 text-nowrap">
|
||||||
|
{{{indentHtml}}}
|
||||||
|
<span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
|
||||||
|
</td>
|
||||||
|
<td><strong>{{name}}</strong><br><small class="text-muted">{{slug}}</small></td>
|
||||||
|
<td>
|
||||||
|
{{#metadata.isProduction}}<span class="badge bg-danger">Prod</span>{{/metadata.isProduction}}
|
||||||
|
{{^metadata.isProduction}}<span class="badge bg-info">Dev</span>{{/metadata.isProduction}}
|
||||||
|
</td>
|
||||||
|
<td><span class="badge bg-light text-dark border">{{hostName}}</span></td>
|
||||||
|
<td>
|
||||||
|
{{#metadata.ip}}<div><small>IP:</small> {{metadata.ip}}</div>{{/metadata.ip}}
|
||||||
|
{{#metadata.address}}<div><small>URL:</small> {{metadata.address}}</div>{{/metadata.address}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-primary" onclick="openEditModal('{{id}}')" title="Edit">
|
||||||
|
<i class="fa-solid fa-pen"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-success" onclick="openAddModal('{{id}}', '{{kind}}')" title="Add Child Resource">
|
||||||
|
<i class="fa-solid fa-plus"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-danger" onclick="deleteResource('{{id}}')">
|
||||||
|
<i class="fa-solid fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Resource Modal -->
|
||||||
|
<div class="modal fade" id="resourceModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header shadow">
|
||||||
|
<h5 class="modal-title" id="resourceModalTitle">Resource</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="actionMessage mb-3" style="display:none"></div>
|
||||||
|
<input type="hidden" id="res-id">
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">Name</label>
|
||||||
|
<input type="text" id="res-name" class="form-control shadow-sm">
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">Slug</label>
|
||||||
|
<input type="text" id="res-slug" class="form-control shadow-sm font-monospace">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">Kind</label>
|
||||||
|
<select id="res-kind" class="form-select shadow-sm" onchange="toggleFormFields()">
|
||||||
|
<option value="site">Site</option>
|
||||||
|
<option value="host">Host</option>
|
||||||
|
<option value="service">Service (App)</option>
|
||||||
|
<option value="oauth">OAuth Integration</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">Sub Type</label>
|
||||||
|
<input type="text" id="res-subtype" class="form-control shadow-sm" placeholder="e.g. proxmox_node, web, etc.">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3" id="site-details-container" style="display: none;">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="res-is-current-site">
|
||||||
|
<label class="form-check-label" for="res-is-current-site">
|
||||||
|
Mark as Current Site
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3" id="host-parent-container" style="display: none;">
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label text-primary">Parent Resource <span class="text-danger">*</span></label>
|
||||||
|
<select id="res-host-id" class="form-select shadow-sm border-primary">
|
||||||
|
<option value="">-- Select Parent --</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">IP Address</label>
|
||||||
|
<input type="text" id="res-ip" class="form-control shadow-sm font-monospace" placeholder="192.168.1.x">
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">Host / URI Address</label>
|
||||||
|
<input type="text" id="res-address" class="form-control shadow-sm font-monospace" placeholder="https://...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3" id="host-details-container" style="display: none;">
|
||||||
|
<div class="col-4">
|
||||||
|
<label class="form-label">VMID</label>
|
||||||
|
<input type="number" id="res-vmid" class="form-control shadow-sm" placeholder="e.g. 101">
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<label class="form-label">MAC Address</label>
|
||||||
|
<input type="text" id="res-mac" class="form-control shadow-sm font-monospace" placeholder="00:00:00:00:00:00">
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<label class="form-label">OS / Kernel</label>
|
||||||
|
<input type="text" id="res-os" class="form-control shadow-sm" placeholder="Ubuntu / 5.15">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3" id="service-ports-container" style="display: none;">
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">Internal Port</label>
|
||||||
|
<input type="number" id="res-port" class="form-control shadow-sm" placeholder="e.g. 8080">
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">External Port</label>
|
||||||
|
<input type="number" id="res-external-port" class="form-control shadow-sm" placeholder="e.g. 443">
|
||||||
|
<small class="text-muted">Same as Internal if empty</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3" id="service-details-container" style="display: none;">
|
||||||
|
<div class="col-4">
|
||||||
|
<label class="form-label">Git Repo</label>
|
||||||
|
<input type="text" id="res-git-repo" class="form-control shadow-sm" placeholder="https://github.com/...">
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<label class="form-label">Install Path</label>
|
||||||
|
<input type="text" id="res-install-path" class="form-control shadow-sm" placeholder="/opt/app">
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<label class="form-label">Systemd Service</label>
|
||||||
|
<input type="text" id="res-systemd" class="form-control shadow-sm" placeholder="app.service">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="oauth-details-container" style="display: none;">
|
||||||
|
<hr>
|
||||||
|
<h5>OAuth Configuration</h5>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Redirect URIs <small class="text-muted">(one per line)</small></label>
|
||||||
|
<textarea id="res-redirect-uris" class="form-control shadow-sm font-monospace" rows="3"></textarea>
|
||||||
|
<small class="field-help text-muted d-block">
|
||||||
|
<code>*</code> matches one hostname label, <code>**</code> matches any number of labels.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Scopes <small class="text-muted">(space separated)</small></label>
|
||||||
|
<input type="text" id="res-scopes" class="form-control shadow-sm" value="openid profile email groups">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Restrict to Groups <small class="text-muted">(space separated CNs, optional)</small></label>
|
||||||
|
<input type="text" id="res-allowed-groups" class="form-control shadow-sm">
|
||||||
|
</div>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">Access Token TTL <small class="text-muted">(seconds)</small></label>
|
||||||
|
<input type="number" id="res-access-ttl" class="form-control shadow-sm" value="3600" min="60">
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">Refresh Token TTL <small class="text-muted">(seconds)</small></label>
|
||||||
|
<input type="number" id="res-refresh-ttl" class="form-control shadow-sm" value="2592000" min="3600">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3" id="oauth-rotate-container" style="display: none;">
|
||||||
|
<button class="btn btn-outline-warning" onclick="rotateSecret()">
|
||||||
|
<i class="fa-solid fa-arrows-rotate"></i> Rotate Client Secret
|
||||||
|
</button>
|
||||||
|
<small class="d-block text-muted mt-1">Rotating the secret will break any currently running clients until they are updated.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-4">
|
||||||
|
<div class="form-check form-switch mt-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="res-is-production">
|
||||||
|
<label class="form-check-label" for="res-is-production"><strong>Production</strong></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-4" id="external-container" style="display: none;">
|
||||||
|
<div class="form-check form-switch mt-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="res-is-external">
|
||||||
|
<label class="form-check-label" for="res-is-external"><strong>External Reachable</strong></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-4" id="public-container" style="display: none;">
|
||||||
|
<div class="form-check form-switch mt-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="res-is-public">
|
||||||
|
<label class="form-check-label" for="res-is-public"><strong>Public (No Auth)</strong></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Description</label>
|
||||||
|
<textarea id="res-description" class="form-control shadow-sm" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div id="edit-only-section" style="display: none;">
|
||||||
|
<h5>Associated LDAP Groups</h5>
|
||||||
|
<div class="mb-3">
|
||||||
|
<ul class="list-group mb-2 shadow-sm" id="groups-list" jq-repeat="groups">
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<span>
|
||||||
|
<i class="fa-solid fa-users text-muted me-2"></i>
|
||||||
|
<strong>{{groupCn}}</strong>
|
||||||
|
<span class="badge bg-primary ms-2">{{accessLevel}}</span>
|
||||||
|
</span>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="removeGroup('{{id}}')"><i class="fa-solid fa-xmark"></i></button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="input-group shadow-sm mt-2">
|
||||||
|
<input type="text" class="form-control" id="new-group-cn" placeholder="Group CN (e.g. app_emby_users)" list="ldap-groups-datalist">
|
||||||
|
<datalist id="ldap-groups-datalist"></datalist>
|
||||||
|
<select class="form-select" id="new-group-level" style="max-width: 140px;">
|
||||||
|
<option value="member">Member</option>
|
||||||
|
<option value="owner">Owner</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-success" onclick="addGroup()"><i class="fa-solid fa-plus"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h5>Relationships (Graph Edges)</h5>
|
||||||
|
<div class="mb-3">
|
||||||
|
<ul class="list-group mb-2 shadow-sm" id="edges-list" jq-repeat="edges">
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<span>
|
||||||
|
{{#isParent}}
|
||||||
|
<i class="fa-solid fa-arrow-down text-success me-2"></i> Has child: <strong>{{targetName}}</strong> <span class="badge bg-secondary ms-1">{{relation}}</span>
|
||||||
|
{{/isParent}}
|
||||||
|
{{^isParent}}
|
||||||
|
<i class="fa-solid fa-arrow-up text-primary me-2"></i> Is child of: <strong>{{targetName}}</strong> <span class="badge bg-secondary ms-1">{{relation}}</span>
|
||||||
|
{{/isParent}}
|
||||||
|
</span>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="removeEdge('{{id}}')"><i class="fa-solid fa-xmark"></i></button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="input-group shadow-sm mt-2">
|
||||||
|
<select class="form-select" id="new-edge-dir" style="max-width: 140px;">
|
||||||
|
<option value="parent">Has child</option>
|
||||||
|
<option value="child">Is child of</option>
|
||||||
|
</select>
|
||||||
|
<select class="form-select" id="new-edge-target">
|
||||||
|
<option value="">-- Select Resource --</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" class="form-control" id="new-edge-relation" placeholder="Relation (e.g. hosts)" style="max-width: 150px;">
|
||||||
|
<button class="btn btn-success" onclick="addEdge()"><i class="fa-solid fa-plus"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="saveResource()">
|
||||||
|
<i class="fa-solid fa-floppy-disk"></i> Save Resource
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
app.auth.forceLogin(['app_sso_admin', 'app_sso_directory_admin']);
|
||||||
|
|
||||||
|
var resourceModal = new bootstrap.Modal(document.getElementById('resourceModal'));
|
||||||
|
var resourcesById = {};
|
||||||
|
var allGroups = [];
|
||||||
|
var allEdges = [];
|
||||||
|
var rawResources = [];
|
||||||
|
|
||||||
|
$(document).ready(async function() {
|
||||||
|
await loadResources();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadResources() {
|
||||||
|
try {
|
||||||
|
const [resResources, resGroups, resEdges] = await Promise.all([
|
||||||
|
app.api.get('directory-admin/resources'),
|
||||||
|
app.api.get('directory-admin/groups'),
|
||||||
|
app.api.get('directory-admin/edges')
|
||||||
|
]);
|
||||||
|
|
||||||
|
resourcesById = {};
|
||||||
|
|
||||||
|
for (const r of resResources.results) {
|
||||||
|
r.metadata = r.metadata || {};
|
||||||
|
resourcesById[r.id] = r;
|
||||||
|
}
|
||||||
|
|
||||||
|
allGroups = resGroups.results;
|
||||||
|
allEdges = resEdges.results;
|
||||||
|
|
||||||
|
rawResources = [];
|
||||||
|
for (const r of resResources.results) {
|
||||||
|
// Compute hostName from edges
|
||||||
|
r.hostName = '—';
|
||||||
|
r.parentId = null;
|
||||||
|
const parentEdge = allEdges.find(e => e.childId === r.id);
|
||||||
|
if (parentEdge) {
|
||||||
|
r.parentId = parentEdge.parentId;
|
||||||
|
const parent = resourcesById[parentEdge.parentId];
|
||||||
|
if (parent) r.hostName = parent.name;
|
||||||
|
}
|
||||||
|
rawResources.push(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTable();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert('Failed to load data');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
const filter = $('#search-filter').val().toLowerCase();
|
||||||
|
const sort = $('#sort-by').val();
|
||||||
|
const viewMode = $('input[name="viewMode"]:checked').val();
|
||||||
|
|
||||||
|
let filtered = rawResources.filter(r => {
|
||||||
|
if (!filter) return true;
|
||||||
|
return (r.name || '').toLowerCase().includes(filter) ||
|
||||||
|
(r.slug || '').toLowerCase().includes(filter) ||
|
||||||
|
(r.kind || '').toLowerCase().includes(filter) ||
|
||||||
|
(r.metadata?.subType || '').toLowerCase().includes(filter) ||
|
||||||
|
(r.metadata?.ip || '').toLowerCase().includes(filter) ||
|
||||||
|
(r.hostName || '').toLowerCase().includes(filter);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
if (sort === 'name') return a.name.localeCompare(b.name);
|
||||||
|
if (sort === 'kind') return a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name);
|
||||||
|
if (sort === 'env') {
|
||||||
|
const ae = a.metadata?.isProduction ? 0 : 1;
|
||||||
|
const be = b.metadata?.isProduction ? 0 : 1;
|
||||||
|
return ae - be || a.name.localeCompare(b.name);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
let finalRenderList = [];
|
||||||
|
|
||||||
|
if (viewMode === 'tree') {
|
||||||
|
const map = {};
|
||||||
|
const roots = [];
|
||||||
|
filtered.forEach(r => { map[r.id] = { ...r, children: [] }; });
|
||||||
|
|
||||||
|
filtered.forEach(r => {
|
||||||
|
const node = map[r.id];
|
||||||
|
if (node.parentId && map[node.parentId]) {
|
||||||
|
map[node.parentId].children.push(node);
|
||||||
|
} else {
|
||||||
|
roots.push(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const flatten = (nodes, depth) => {
|
||||||
|
nodes.forEach(n => {
|
||||||
|
let indentHtml = '';
|
||||||
|
for(let i = 0; i < depth; i++) {
|
||||||
|
indentHtml += '<span style="display:inline-block; width: 1.5rem;"></span>';
|
||||||
|
}
|
||||||
|
if (depth > 0) {
|
||||||
|
indentHtml += '<i class="fa-solid fa-turn-up fa-rotate-90 text-muted me-2"></i>';
|
||||||
|
}
|
||||||
|
n.indentHtml = indentHtml;
|
||||||
|
finalRenderList.push(n);
|
||||||
|
if (n.children.length > 0) {
|
||||||
|
flatten(n.children, depth + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
flatten(roots, 0);
|
||||||
|
} else {
|
||||||
|
finalRenderList = filtered.map(r => ({ ...r, indentHtml: '' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
$.scope.resources.empty();
|
||||||
|
for (const r of finalRenderList) {
|
||||||
|
$.scope.resources.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFormFields() {
|
||||||
|
const kind = $('#res-kind').val();
|
||||||
|
if (kind === 'host') {
|
||||||
|
$('#host-parent-container').show();
|
||||||
|
$('#host-details-container').show();
|
||||||
|
$('#service-ports-container').hide();
|
||||||
|
$('#service-details-container').hide();
|
||||||
|
$('#oauth-details-container').hide();
|
||||||
|
$('#external-container').hide();
|
||||||
|
$('#public-container').hide();
|
||||||
|
$('#site-details-container').hide();
|
||||||
|
} else if (kind === 'service') {
|
||||||
|
$('#host-parent-container').show();
|
||||||
|
$('#host-details-container').hide();
|
||||||
|
$('#service-ports-container').show();
|
||||||
|
$('#service-details-container').show();
|
||||||
|
$('#oauth-details-container').hide();
|
||||||
|
$('#external-container').show();
|
||||||
|
$('#public-container').show();
|
||||||
|
$('#site-details-container').hide();
|
||||||
|
} else if (kind === 'oauth') {
|
||||||
|
$('#host-parent-container').show();
|
||||||
|
$('#host-details-container').hide();
|
||||||
|
$('#service-ports-container').hide();
|
||||||
|
$('#service-details-container').hide();
|
||||||
|
$('#oauth-details-container').show();
|
||||||
|
$('#external-container').hide();
|
||||||
|
$('#public-container').hide();
|
||||||
|
$('#site-details-container').hide();
|
||||||
|
} else { // site
|
||||||
|
$('#host-parent-container').hide();
|
||||||
|
$('#host-details-container').hide();
|
||||||
|
$('#service-ports-container').hide();
|
||||||
|
$('#service-details-container').hide();
|
||||||
|
$('#oauth-details-container').hide();
|
||||||
|
$('#external-container').hide();
|
||||||
|
$('#public-container').hide();
|
||||||
|
$('#site-details-container').show();
|
||||||
|
}
|
||||||
|
populateHostDropdown($('#res-host-id').val());
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#res-name, #res-kind').on('input change', function() {
|
||||||
|
const id = $('#res-id').val();
|
||||||
|
if (!id && $('#res-name').val()) {
|
||||||
|
const name = $('#res-name').val();
|
||||||
|
const kind = $('#res-kind').val();
|
||||||
|
let prefix = '';
|
||||||
|
if (kind === 'service') prefix = 'app_';
|
||||||
|
if (kind === 'host') prefix = 'host_';
|
||||||
|
if (kind === 'site') prefix = 'site_';
|
||||||
|
|
||||||
|
const slug = prefix + name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
|
||||||
|
$('#res-slug').val(slug);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function openAddModal(parentId, parentKind) {
|
||||||
|
$('#resourceModalTitle').html('<i class="fa-solid fa-plus"></i> Add Resource');
|
||||||
|
$('#res-id').val('');
|
||||||
|
$('#res-name').val('');
|
||||||
|
$('#res-slug').val('');
|
||||||
|
|
||||||
|
let defaultKind = 'service';
|
||||||
|
if (parentKind === 'site') defaultKind = 'host';
|
||||||
|
if (parentKind === 'host') defaultKind = 'service';
|
||||||
|
$('#res-kind').val(defaultKind);
|
||||||
|
|
||||||
|
if (!parentId && defaultKind === 'service') {
|
||||||
|
const currentSite = Object.values(resourcesById).find(r => r.kind === 'site' && r.metadata && r.metadata.isCurrentSite);
|
||||||
|
if (currentSite) parentId = currentSite.id;
|
||||||
|
}
|
||||||
|
if (!parentId && defaultKind === 'host') {
|
||||||
|
const currentSite = Object.values(resourcesById).find(r => r.kind === 'site' && r.metadata && r.metadata.isCurrentSite);
|
||||||
|
if (currentSite) parentId = currentSite.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#res-description').val('');
|
||||||
|
$('#res-ip').val('');
|
||||||
|
$('#res-address').val('');
|
||||||
|
$('#res-subtype').val('');
|
||||||
|
$('#res-vmid').val('');
|
||||||
|
$('#res-mac').val('');
|
||||||
|
$('#res-os').val('');
|
||||||
|
$('#res-port').val('');
|
||||||
|
$('#res-external-port').val('');
|
||||||
|
$('#res-git-repo').val('');
|
||||||
|
$('#res-install-path').val('');
|
||||||
|
$('#res-systemd').val('');
|
||||||
|
$('#res-is-production').prop('checked', false);
|
||||||
|
$('#res-is-external').prop('checked', false);
|
||||||
|
$('#res-is-public').prop('checked', false);
|
||||||
|
$('#res-is-current-site').prop('checked', false);
|
||||||
|
$('#edit-only-section').hide();
|
||||||
|
|
||||||
|
populateHostDropdown(parentId || '');
|
||||||
|
toggleFormFields();
|
||||||
|
|
||||||
|
resourceModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
var ldapGroupsCache = null;
|
||||||
|
async function loadLdapGroups() {
|
||||||
|
if (ldapGroupsCache) return;
|
||||||
|
try {
|
||||||
|
const res = await app.api.get('group');
|
||||||
|
ldapGroupsCache = res.results;
|
||||||
|
const $datalist = $('#ldap-groups-datalist');
|
||||||
|
$datalist.empty();
|
||||||
|
for (const cn of ldapGroupsCache) {
|
||||||
|
$datalist.append($('<option>').val(cn));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load LDAP groups', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateHostDropdown(selectedId) {
|
||||||
|
const kind = $('#res-kind').val();
|
||||||
|
const $target = $('#res-host-id');
|
||||||
|
$target.empty().append('<option value="">-- Select Parent --</option>');
|
||||||
|
Object.values(resourcesById).forEach(r => {
|
||||||
|
if (r.id === $('#res-id').val()) return; // cannot be parent of itself
|
||||||
|
|
||||||
|
if (kind === 'host' && (r.kind === 'site' || r.kind === 'host')) {
|
||||||
|
$target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')'));
|
||||||
|
} else if (kind === 'service' && (r.kind === 'host' || r.kind === 'service')) {
|
||||||
|
$target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (selectedId) $target.val(selectedId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshGroupsUI(resourceId) {
|
||||||
|
const myGroups = allGroups.filter(g => g.resourceId === resourceId);
|
||||||
|
$.scope.groups.empty();
|
||||||
|
for (const g of myGroups) {
|
||||||
|
$.scope.groups.push(g);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshEdgesUI(resourceId) {
|
||||||
|
const myEdges = allEdges.filter(e => e.parentId === resourceId || e.childId === resourceId);
|
||||||
|
$.scope.edges.empty();
|
||||||
|
for (const e of myEdges) {
|
||||||
|
const isParent = e.parentId === resourceId;
|
||||||
|
const targetId = isParent ? e.childId : e.parentId;
|
||||||
|
const target = resourcesById[targetId];
|
||||||
|
if (!target) continue;
|
||||||
|
|
||||||
|
$.scope.edges.push({
|
||||||
|
id: e.id,
|
||||||
|
isParent: isParent,
|
||||||
|
relation: e.relation,
|
||||||
|
targetName: target.name + ' (' + target.slug + ')'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const $target = $('#new-edge-target');
|
||||||
|
$target.empty().append('<option value="">-- Select Resource --</option>');
|
||||||
|
Object.values(resourcesById).forEach(r => {
|
||||||
|
if (r.id !== resourceId) {
|
||||||
|
$target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEditModal(id) {
|
||||||
|
const r = resourcesById[id];
|
||||||
|
if (!r) return;
|
||||||
|
|
||||||
|
$('#resourceModalTitle').html('<i class="fa-solid fa-pen-to-square"></i> Edit Resource');
|
||||||
|
$('#res-id').val(r.id);
|
||||||
|
$('#res-name').val(r.name);
|
||||||
|
$('#res-slug').val(r.slug);
|
||||||
|
$('#res-kind').val(r.kind);
|
||||||
|
$('#res-description').val(r.description || '');
|
||||||
|
$('#res-ip').val(r.metadata.ip || '');
|
||||||
|
$('#res-address').val(r.metadata.address || '');
|
||||||
|
$('#res-subtype').val(r.metadata.subType || '');
|
||||||
|
$('#res-vmid').val(r.metadata.vmid || '');
|
||||||
|
$('#res-mac').val(r.metadata.macAddress || '');
|
||||||
|
let osKernel = '';
|
||||||
|
if (r.metadata.os) osKernel += r.metadata.os;
|
||||||
|
if (r.metadata.kernel) osKernel += (osKernel ? ' / ' : '') + r.metadata.kernel;
|
||||||
|
$('#res-os').val(osKernel);
|
||||||
|
$('#res-port').val(r.metadata.port || '');
|
||||||
|
$('#res-external-port').val(r.metadata.externalPort || '');
|
||||||
|
$('#res-git-repo').val(r.metadata.gitRepo || '');
|
||||||
|
$('#res-install-path').val(r.metadata.installPath || '');
|
||||||
|
$('#res-systemd').val(r.metadata.systemdService || '');
|
||||||
|
$('#res-is-production').prop('checked', !!r.metadata.isProduction);
|
||||||
|
$('#res-is-external').prop('checked', !!r.metadata.isExternalReachable);
|
||||||
|
$('#res-is-public').prop('checked', !!r.metadata.isPublic);
|
||||||
|
$('#res-is-current-site').prop('checked', !!r.metadata.isCurrentSite);
|
||||||
|
|
||||||
|
$('#res-redirect-uris').val((r.metadata.redirect_uris || []).join('\n'));
|
||||||
|
$('#res-scopes').val((r.metadata.scopes || []).join(' '));
|
||||||
|
$('#res-allowed-groups').val((r.metadata.allowed_groups || []).join(' '));
|
||||||
|
$('#res-access-ttl').val((r.metadata.token_lifetime || {}).access_token || 3600);
|
||||||
|
$('#res-refresh-ttl').val((r.metadata.token_lifetime || {}).refresh_token || 2592000);
|
||||||
|
|
||||||
|
if (r.kind === 'oauth') $('#oauth-rotate-container').show();
|
||||||
|
else $('#oauth-rotate-container').hide();
|
||||||
|
|
||||||
|
// Find parent host
|
||||||
|
const parentEdge = allEdges.find(e => e.childId === r.id && (e.relation === 'hosts' || e.relation === 'oauth'));
|
||||||
|
populateHostDropdown(parentEdge ? parentEdge.parentId : '');
|
||||||
|
toggleFormFields();
|
||||||
|
|
||||||
|
$('#edit-only-section').show();
|
||||||
|
|
||||||
|
refreshGroupsUI(r.id);
|
||||||
|
refreshEdgesUI(r.id);
|
||||||
|
await loadLdapGroups();
|
||||||
|
|
||||||
|
resourceModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveResource() {
|
||||||
|
const id = $('#res-id').val();
|
||||||
|
const data = {
|
||||||
|
name: $('#res-name').val(),
|
||||||
|
slug: $('#res-slug').val(),
|
||||||
|
kind: $('#res-kind').val(),
|
||||||
|
hostId: ['host', 'service', 'oauth'].includes($('#res-kind').val()) ? $('#res-host-id').val() : undefined,
|
||||||
|
description: $('#res-description').val(),
|
||||||
|
metadata: {
|
||||||
|
subType: $('#res-subtype').val(),
|
||||||
|
ip: $('#res-ip').val(),
|
||||||
|
address: $('#res-address').val(),
|
||||||
|
vmid: $('#res-vmid').val(),
|
||||||
|
macAddress: $('#res-mac').val(),
|
||||||
|
os: $('#res-os').val(),
|
||||||
|
port: $('#res-port').val(),
|
||||||
|
externalPort: $('#res-external-port').val() || $('#res-port').val(),
|
||||||
|
gitRepo: $('#res-git-repo').val(),
|
||||||
|
installPath: $('#res-install-path').val(),
|
||||||
|
systemdService: $('#res-systemd').val(),
|
||||||
|
isProduction: $('#res-is-production').is(':checked'),
|
||||||
|
isExternalReachable: $('#res-is-external').is(':checked'),
|
||||||
|
isPublic: $('#res-is-public').is(':checked'),
|
||||||
|
isCurrentSite: $('#res-is-current-site').is(':checked')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data.kind === 'oauth') {
|
||||||
|
data.redirect_uris = $('#res-redirect-uris').val().split('\n').map(x => x.trim()).filter(Boolean);
|
||||||
|
data.scopes = $('#res-scopes').val().split(' ').map(x => x.trim()).filter(Boolean);
|
||||||
|
data.allowed_groups = $('#res-allowed-groups').val().split(' ').map(x => x.trim()).filter(Boolean);
|
||||||
|
data.token_lifetime = {
|
||||||
|
access_token: Number($('#res-access-ttl').val()) || 3600,
|
||||||
|
refresh_token: Number($('#res-refresh-ttl').val()) || 2592000
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let res;
|
||||||
|
if (id) {
|
||||||
|
res = await app.api.put('directory-admin/resources/' + id, data);
|
||||||
|
} else {
|
||||||
|
res = await app.api.post('directory-admin/resources', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
resourceModal.hide();
|
||||||
|
await loadResources();
|
||||||
|
|
||||||
|
if (!id && data.kind === 'oauth' && res.results && res.results._raw_secret) {
|
||||||
|
app.util.alert('OAuth Secret', 'Save this client secret, it will not be shown again: <br><br><code>' + res.results._raw_secret + '</code>', 'success');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert(err.message || 'Failed to save');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rotateSecret() {
|
||||||
|
const id = $('#res-id').val();
|
||||||
|
if (!id) return;
|
||||||
|
if (!confirm('Are you sure you want to rotate the OAuth secret? Any existing integrations using the old secret will break.')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await app.api.post(`directory-admin/resources/${id}/rotate-secret`);
|
||||||
|
app.util.alert('Secret Rotated', 'Save this NEW client secret, it will not be shown again: <br><br><code>' + res.secret + '</code>', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert(err.message || 'Failed to rotate secret');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addGroup() {
|
||||||
|
const resourceId = $('#res-id').val();
|
||||||
|
const groupCn = $('#new-group-cn').val().trim();
|
||||||
|
const accessLevel = $('#new-group-level').val();
|
||||||
|
|
||||||
|
if (!groupCn) return alert('Group CN is required');
|
||||||
|
try {
|
||||||
|
const res = await app.api.post('directory-admin/groups', {
|
||||||
|
resourceId,
|
||||||
|
groupCn,
|
||||||
|
accessLevel
|
||||||
|
});
|
||||||
|
allGroups.push(res.results);
|
||||||
|
refreshGroupsUI(resourceId);
|
||||||
|
$('#new-group-cn').val('');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert('Failed to add group');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeGroup(id) {
|
||||||
|
try {
|
||||||
|
await app.api.delete('directory-admin/groups/' + id);
|
||||||
|
allGroups = allGroups.filter(g => g.id !== id);
|
||||||
|
refreshGroupsUI($('#res-id').val());
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert('Failed to remove group');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addEdge() {
|
||||||
|
const resourceId = $('#res-id').val();
|
||||||
|
const dir = $('#new-edge-dir').val();
|
||||||
|
const targetId = $('#new-edge-target').val();
|
||||||
|
const relation = $('#new-edge-relation').val().trim() || 'hosts';
|
||||||
|
|
||||||
|
if (!targetId) return alert('Select a target resource');
|
||||||
|
|
||||||
|
const data = { relation };
|
||||||
|
if (dir === 'parent') {
|
||||||
|
data.parentId = resourceId;
|
||||||
|
data.childId = targetId;
|
||||||
|
} else {
|
||||||
|
data.parentId = targetId;
|
||||||
|
data.childId = resourceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await app.api.post('directory-admin/edges', data);
|
||||||
|
allEdges.push(res.results);
|
||||||
|
refreshEdgesUI(resourceId);
|
||||||
|
$('#new-edge-target').val('');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert('Failed to add edge');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeEdge(id) {
|
||||||
|
try {
|
||||||
|
await app.api.delete('directory-admin/edges/' + id);
|
||||||
|
allEdges = allEdges.filter(e => e.id !== id);
|
||||||
|
refreshEdgesUI($('#res-id').val());
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert('Failed to remove edge');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteResource(id) {
|
||||||
|
if (!confirm('Are you sure you want to delete this resource? All relationships will be destroyed.')) return;
|
||||||
|
try {
|
||||||
|
await app.api.delete('directory-admin/resources/' + id);
|
||||||
|
await loadResources();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert('Failed to delete');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<%- include('bottom') %>
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
<%- include('top') %>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
app.auth.forceLogin('app_sso_admin');
|
||||||
|
|
||||||
|
// ── Overview (stats, recent signups, inactive users) ────────────────────
|
||||||
|
async function loadDashboard() {
|
||||||
|
try {
|
||||||
|
const stats = await app.api.get('user/stats');
|
||||||
|
|
||||||
|
// Stat cards
|
||||||
|
document.getElementById('stat-total').textContent = stats.totalUsers;
|
||||||
|
document.getElementById('stat-active').textContent = stats.activeUsers;
|
||||||
|
document.getElementById('stat-inactive').textContent = stats.inactiveUsers;
|
||||||
|
document.getElementById('stat-groups').textContent = stats.totalGroups;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dirRes = await app.api.get('directory-admin/resources');
|
||||||
|
const resources = dirRes.results || [];
|
||||||
|
document.getElementById('stat-hosts').textContent = resources.filter(r => r.kind === 'host').length;
|
||||||
|
document.getElementById('stat-services').textContent = resources.filter(r => r.kind === 'service').length;
|
||||||
|
document.getElementById('stat-oauth').textContent = resources.filter(r => r.kind === 'oauth').length;
|
||||||
|
document.getElementById('directory-stats-row').style.display = '';
|
||||||
|
} catch (err) {}
|
||||||
|
|
||||||
|
document.getElementById('dashboard-overview').style.display = '';
|
||||||
|
} catch(e) {
|
||||||
|
if (e && (e.status === 401 || e.name === 'Insufficient Permission')) {
|
||||||
|
location.replace('/');
|
||||||
|
} else {
|
||||||
|
console.error('Dashboard load error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportUsers() {
|
||||||
|
const resp = await fetch('/api/user/export', {
|
||||||
|
headers: { 'auth-token': localStorage.getItem('APIToken') }
|
||||||
|
});
|
||||||
|
const blob = await resp.blob();
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = 'users.csv';
|
||||||
|
a.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMetrics() {
|
||||||
|
try {
|
||||||
|
const data = await app.api.get('metrics/executive');
|
||||||
|
if (data && data.results) {
|
||||||
|
const renderList = (items, id) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
el.innerHTML = '';
|
||||||
|
if (!items || items.length === 0) {
|
||||||
|
el.innerHTML = '<li class="list-group-item text-muted">No data available</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
items.forEach(item => {
|
||||||
|
el.innerHTML += `<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
${item.value}
|
||||||
|
<span class="badge bg-primary rounded-pill">${item.score}</span>
|
||||||
|
</li>`;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
renderList(data.results.ips, 'metrics-ips');
|
||||||
|
renderList(data.results.users, 'metrics-users');
|
||||||
|
renderList(data.results.services, 'metrics-services');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load metrics:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Notifications (compose + history) ────────────────────────────────────
|
||||||
|
async function loadHistory() {
|
||||||
|
try {
|
||||||
|
const data = await app.api.get('notification');
|
||||||
|
const list = data.results || [];
|
||||||
|
list.forEach(function(n) {
|
||||||
|
n.created_on_fmt = moment(n.created_on).fromNow();
|
||||||
|
n.filter_label = formatFilterLabel(n.filter_type, n.filter_value, n.active_only);
|
||||||
|
});
|
||||||
|
$.scope.notificationHistory.push(...list);
|
||||||
|
} catch(e) {
|
||||||
|
if (e && e.status === 401) location.replace('/');
|
||||||
|
else console.error('Failed to load notification history:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFilterLabel(type, value, active_only) {
|
||||||
|
const suffix = active_only ? ' (active only)' : '';
|
||||||
|
if (type === 'group') return 'Groups: ' + value + suffix;
|
||||||
|
if (type === 'users') return 'Specific users';
|
||||||
|
if (type === 'all_active') return 'All active users';
|
||||||
|
if (type === 'all') return 'All users';
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFilterInputs() {
|
||||||
|
// No radio is checked by default (see f-all-active below) — a "send to
|
||||||
|
// everyone" option must be a deliberate choice, not whatever happens to
|
||||||
|
// be pre-selected when someone's just trying the form out.
|
||||||
|
const checked = document.querySelector('input[name="notif-filter"]:checked');
|
||||||
|
const type = checked ? checked.value : null;
|
||||||
|
document.getElementById('notif-group-row').style.display = type === 'group' ? '' : 'none';
|
||||||
|
document.getElementById('notif-users-row').style.display = type === 'users' ? '' : 'none';
|
||||||
|
document.getElementById('notif-active-row').style.display = (type === 'group' || type === 'all') ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendNotification() {
|
||||||
|
const subject = document.getElementById('notif-subject').value.trim();
|
||||||
|
const message = document.getElementById('notif-message').value.trim();
|
||||||
|
const filterCheck = document.querySelector('input[name="notif-filter"]:checked');
|
||||||
|
const groupValue = document.getElementById('notif-group').value.trim();
|
||||||
|
const usersValue = document.getElementById('notif-users').value.trim();
|
||||||
|
const activeOnly = document.getElementById('notif-active-only').checked;
|
||||||
|
const msgEl = document.getElementById('notif-result');
|
||||||
|
const $compose = $('#notif-subject').closest('.card-body');
|
||||||
|
|
||||||
|
if (!subject || !message) { alert('Subject and message are required.'); return; }
|
||||||
|
if (!filterCheck) { alert('Choose who to send this to.'); return; }
|
||||||
|
const filterType = filterCheck.value;
|
||||||
|
|
||||||
|
let filter_value = '';
|
||||||
|
if (filterType === 'group') filter_value = groupValue;
|
||||||
|
if (filterType === 'users') filter_value = JSON.stringify(usersValue.split(',').map(s => s.trim()).filter(Boolean));
|
||||||
|
|
||||||
|
// Broadcasting to everyone is easy to trigger by accident while just
|
||||||
|
// trying the form out — make it a deliberate, confirmed action.
|
||||||
|
if (filterType === 'all' || filterType === 'all_active') {
|
||||||
|
const label = filterType === 'all' ? 'ALL users (including inactive)' : 'all ACTIVE users';
|
||||||
|
const confirmed = await app.util.actionConfirm(`Send this notification to ${label}?`, $compose, 'warning');
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
msgEl.className = 'alert alert-info mt-2';
|
||||||
|
msgEl.textContent = 'Sending…';
|
||||||
|
msgEl.style.display = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await app.api.post('notification', {
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
filter_type: filterType,
|
||||||
|
filter_value,
|
||||||
|
active_only: activeOnly,
|
||||||
|
});
|
||||||
|
msgEl.className = 'alert alert-success mt-2';
|
||||||
|
msgEl.textContent = `Sent to ${result.results.sent_count} recipient(s). ${result.results.failed_count} failed.`;
|
||||||
|
|
||||||
|
const n = result.results;
|
||||||
|
n.created_on_fmt = 'just now';
|
||||||
|
n.filter_label = formatFilterLabel(n.filter_type, n.filter_value, n.active_only);
|
||||||
|
$.scope.notificationHistory.unshift([n]);
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
document.getElementById('notif-subject').value = '';
|
||||||
|
document.getElementById('notif-message').value = '';
|
||||||
|
} catch(e) {
|
||||||
|
msgEl.className = 'alert alert-danger mt-2';
|
||||||
|
msgEl.textContent = 'Failed: ' + ((e.responseJSON && e.responseJSON.message) || e.message || 'Unknown error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Terms of Service ──────────────────────────────────────────────────
|
||||||
|
async function loadTos() {
|
||||||
|
try {
|
||||||
|
const tos = await app.tos.get();
|
||||||
|
document.getElementById('tos-content').value = tos.content;
|
||||||
|
document.getElementById('tos-meta').textContent =
|
||||||
|
'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by;
|
||||||
|
} catch(e) {
|
||||||
|
console.error('Failed to load ToS:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveTos() {
|
||||||
|
const content = document.getElementById('tos-content').value.trim();
|
||||||
|
const resetAcceptance = document.getElementById('tos-reset-acceptance').checked;
|
||||||
|
const msgEl = document.getElementById('tos-result');
|
||||||
|
|
||||||
|
if (!content) { alert('Terms of Service text cannot be empty.'); return; }
|
||||||
|
|
||||||
|
app.tos.update({content, resetAcceptance}, function(error, data) {
|
||||||
|
if (error) {
|
||||||
|
msgEl.className = 'alert alert-danger mt-2';
|
||||||
|
msgEl.textContent = 'Failed: ' + ((data && data.message) || error);
|
||||||
|
msgEl.style.display = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
msgEl.className = 'alert alert-success mt-2';
|
||||||
|
msgEl.textContent = 'Saved.' + (data.resetCount ? ' ' + data.resetCount + ' user(s) will be asked to re-accept.' : '');
|
||||||
|
msgEl.style.display = '';
|
||||||
|
document.getElementById('tos-reset-acceptance').checked = false;
|
||||||
|
loadTos();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$(document).ready(function() {
|
||||||
|
loadDashboard();
|
||||||
|
loadHistory();
|
||||||
|
toggleFilterInputs();
|
||||||
|
loadTos();
|
||||||
|
loadMetrics();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<h4 class="mb-0"><i class="fa-solid fa-gauge-high"></i> Executive Dashboard</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Card -->
|
||||||
|
<div class="card shadow mb-4" id="dashboard-overview" style="display:none">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<div><i class="fa-solid fa-chart-pie"></i> Overview Stats</div>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary shadow-sm" onclick="exportUsers()">
|
||||||
|
<i class="fa-solid fa-file-csv"></i> Export Users CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body bg-light">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card shadow-sm text-center">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="display-6 fw-bold" id="stat-total">—</div>
|
||||||
|
<div class="text-muted small"><i class="fa-solid fa-users"></i> Total Users</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card shadow-sm text-center border-success">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="display-6 fw-bold text-success" id="stat-active">—</div>
|
||||||
|
<div class="text-muted small"><i class="fa-solid fa-circle-check"></i> Active</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card shadow-sm text-center border-danger">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="display-6 fw-bold text-danger" id="stat-inactive">—</div>
|
||||||
|
<div class="text-muted small"><i class="fa-solid fa-lock"></i> Inactive</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card shadow-sm text-center border-info">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="display-6 fw-bold text-info" id="stat-groups">—</div>
|
||||||
|
<div class="text-muted small"><i class="fa-solid fa-users-viewfinder"></i> Groups</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mt-1" id="directory-stats-row" style="display:none">
|
||||||
|
<div class="col-4">
|
||||||
|
<div class="card shadow-sm text-center border-secondary">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="display-6 fw-bold text-secondary" id="stat-hosts">—</div>
|
||||||
|
<div class="text-muted small"><i class="fa-solid fa-server"></i> Hosts</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<div class="card shadow-sm text-center border-secondary">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="display-6 fw-bold text-secondary" id="stat-services">—</div>
|
||||||
|
<div class="text-muted small"><i class="fa-solid fa-layer-group"></i> Services</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<div class="card shadow-sm text-center border-secondary">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="display-6 fw-bold text-secondary" id="stat-oauth">—</div>
|
||||||
|
<div class="text-muted small"><i class="fa-solid fa-plug"></i> OAuth Integrations</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notifications Card -->
|
||||||
|
<div class="card shadow mb-4">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<div><i class="fa-solid fa-paper-plane"></i> Notifications</div>
|
||||||
|
</div>
|
||||||
|
<ul class="nav nav-tabs px-3 pt-2 border-bottom-0" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-compose" type="button" role="tab"><i class="fa-solid fa-pencil"></i> Compose</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-history" type="button" role="tab"><i class="fa-solid fa-clock-rotate-left"></i> History</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="tab-content border-top">
|
||||||
|
<div class="tab-pane fade show active p-4" id="tab-compose" role="tabpanel">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Subject</label>
|
||||||
|
<input type="text" class="form-control shadow-sm" id="notif-subject" placeholder="Maintenance window tonight" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Message <small class="text-muted">(HTML allowed)</small></label>
|
||||||
|
<textarea class="form-control shadow-sm" id="notif-message" rows="6" placeholder="<p>Hello, we will be performing maintenance...</p>"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Send to</label>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="notif-filter" id="f-all-active" value="all_active" onchange="toggleFilterInputs()">
|
||||||
|
<label class="form-check-label" for="f-all-active">All active users</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="notif-filter" id="f-all" value="all" onchange="toggleFilterInputs()">
|
||||||
|
<label class="form-check-label" for="f-all">All users (including inactive)</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="notif-filter" id="f-group" value="group" onchange="toggleFilterInputs()">
|
||||||
|
<label class="form-check-label" for="f-group">Group members</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="notif-filter" id="f-users" value="users" onchange="toggleFilterInputs()">
|
||||||
|
<label class="form-check-label" for="f-users">Specific users</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="notif-group-row" class="mb-3" style="display:none">
|
||||||
|
<label class="form-label">Groups <small class="text-muted">(comma-separated)</small></label>
|
||||||
|
<input type="text" class="form-control shadow-sm" id="notif-group" placeholder="host_hec-bot_admin, app_sso_admin" />
|
||||||
|
</div>
|
||||||
|
<div id="notif-users-row" class="mb-3" style="display:none">
|
||||||
|
<label class="form-label">UIDs <small class="text-muted">(comma-separated)</small></label>
|
||||||
|
<input type="text" class="form-control shadow-sm" id="notif-users" placeholder="wmantly, jsmith" />
|
||||||
|
</div>
|
||||||
|
<div id="notif-active-row" class="mb-3" style="display:none">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="notif-active-only" checked>
|
||||||
|
<label class="form-check-label" for="notif-active-only">Active members only</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-primary shadow-sm" onclick="sendNotification()">
|
||||||
|
<i class="fa-solid fa-paper-plane"></i> Send
|
||||||
|
</button>
|
||||||
|
<div id="notif-result" style="display:none" class="mt-3"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-pane fade" id="tab-history" role="tabpanel">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="ps-3">Sent</th>
|
||||||
|
<th>Subject</th>
|
||||||
|
<th>Filter</th>
|
||||||
|
<th class="text-center">✓</th>
|
||||||
|
<th class="text-center pe-3">✗</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody jq-repeat="notificationHistory" jq-index-key="notification_id">
|
||||||
|
<tr>
|
||||||
|
<td class="ps-3"><small class="text-muted">{{created_on_fmt}}</small></td>
|
||||||
|
<td><small>{{subject}}</small></td>
|
||||||
|
<td><small class="text-muted">{{filter_label}}</small></td>
|
||||||
|
<td class="text-center text-success"><small>{{sent_count}}</small></td>
|
||||||
|
<td class="text-center text-danger pe-3"><small>{{failed_count}}</small></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TOS Card -->
|
||||||
|
<div class="card shadow mb-5">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<div><i class="fa-solid fa-file-contract"></i> Terms of Service Editor</div>
|
||||||
|
<small class="text-muted" id="tos-meta"></small>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Content <small class="text-muted">(Markdown)</small></label>
|
||||||
|
<textarea class="form-control shadow-sm" id="tos-content" rows="12"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="tos-reset-acceptance">
|
||||||
|
<label class="form-check-label" for="tos-reset-acceptance">
|
||||||
|
Require all users to re-accept these terms
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary shadow-sm" onclick="saveTos()">
|
||||||
|
<i class="fa-solid fa-floppy-disk"></i> Save
|
||||||
|
</button>
|
||||||
|
<div id="tos-result" style="display:none" class="mt-3"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actionable Metrics Card -->
|
||||||
|
<div class="card shadow mb-5">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<div><i class="fa-solid fa-chart-line"></i> Actionable Metrics (Last 7 Days)</div>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary shadow-sm" onclick="loadMetrics()">
|
||||||
|
<i class="fa-solid fa-rotate-right"></i> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card shadow-sm h-100 border-danger">
|
||||||
|
<div class="card-header bg-danger text-white"><i class="fa-solid fa-shield-halved"></i> Top Failed IPs</div>
|
||||||
|
<ul class="list-group list-group-flush" id="metrics-ips">
|
||||||
|
<li class="list-group-item text-muted">Loading...</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card shadow-sm h-100 border-warning">
|
||||||
|
<div class="card-header bg-warning text-dark"><i class="fa-solid fa-user-xmark"></i> Top Failed Accounts</div>
|
||||||
|
<ul class="list-group list-group-flush" id="metrics-users">
|
||||||
|
<li class="list-group-item text-muted">Loading...</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card shadow-sm h-100 border-success">
|
||||||
|
<div class="card-header bg-success text-white"><i class="fa-solid fa-plug"></i> Top Services Used</div>
|
||||||
|
<ul class="list-group list-group-flush" id="metrics-services">
|
||||||
|
<li class="list-group-item text-muted">Loading...</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%- include('impersonate_modal') %>
|
||||||
|
<%- include('bottom') %>
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
tableAJAX();
|
tableAJAX();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<div class="row" style="display:none;">
|
<div class="container mt-4">
|
||||||
|
|
||||||
<div class="d-flex flex-wrap gap-2 align-items-center">
|
<div class="d-flex flex-wrap gap-2 align-items-center">
|
||||||
<div class="input-group" style="flex: 1 1 200px;">
|
<div class="input-group" style="flex: 1 1 200px;">
|
||||||
|
|||||||
@@ -1,598 +0,0 @@
|
|||||||
<%- include('top') %>
|
|
||||||
|
|
||||||
<!-- Edit OAuth client modal -->
|
|
||||||
<div class="modal fade" id="editModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog modal-lg">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title"><i class="fa-solid fa-pen-to-square"></i> Edit OAuth Client</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="card-header actionMessage mb-3" style="display:none"></div>
|
|
||||||
<input type="hidden" id="edit-client-id">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Name</label>
|
|
||||||
<input type="text" id="edit-name" class="form-control shadow">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Description</label>
|
|
||||||
<input type="text" id="edit-description" class="form-control shadow">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Redirect URIs <small class="text-muted">(one per line)</small></label>
|
|
||||||
<textarea id="edit-redirect_uris" class="form-control shadow font-monospace" rows="3"></textarea>
|
|
||||||
<small class="field-help text-muted d-block">
|
|
||||||
<code>*</code> matches one hostname label, <code>**</code> matches any number of labels.
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Scopes</label>
|
|
||||||
<div id="edit-scopes"></div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Restrict to Groups <small class="text-muted">(optional)</small></label>
|
|
||||||
<div id="edit-allowed_groups"></div>
|
|
||||||
</div>
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col">
|
|
||||||
<label class="form-label">Access Token TTL <small class="text-muted">(seconds)</small></label>
|
|
||||||
<input type="number" id="edit-access_ttl" class="form-control shadow" min="60">
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<label class="form-label">Refresh Token TTL <small class="text-muted">(seconds)</small></label>
|
|
||||||
<input type="number" id="edit-refresh_ttl" class="form-control shadow" min="3600">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
<button type="button" class="btn btn-primary" onclick="saveEdit(this)"><i class="fa-solid fa-floppy-disk"></i> Save</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Secret modal — shared by OAuth client secrets and service account passwords -->
|
|
||||||
<div class="modal fade" id="secretModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title" id="secretModalTitle"><i class="fa-solid fa-key"></i> Secret</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<p class="text-danger"><i class="fa-solid fa-triangle-exclamation"></i> Save this now — it will <strong>not</strong> be shown again.</p>
|
|
||||||
<div class="input-group">
|
|
||||||
<input type="text" id="secretValue" class="form-control font-monospace" readonly>
|
|
||||||
<button class="btn btn-outline-secondary" onclick="copySecret()" title="Copy">
|
|
||||||
<i class="fa-solid fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Done</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script type="text/javascript">
|
|
||||||
app.auth.forceLogin(['app_sso_admin', 'app_sso_oauth_admin']);
|
|
||||||
|
|
||||||
// The scopes this provider actually understands (see routes/oauth.js discovery).
|
|
||||||
var VALID_SCOPES = ['openid', 'profile', 'email', 'groups'];
|
|
||||||
var DEFAULT_SCOPES = ['openid', 'profile', 'email', 'groups'];
|
|
||||||
|
|
||||||
var secretModal = new bootstrap.Modal(document.getElementById('secretModal'));
|
|
||||||
var editModal = new bootstrap.Modal(document.getElementById('editModal'));
|
|
||||||
|
|
||||||
// Widget handles + a lookup of the latest client data (for the edit modal).
|
|
||||||
var createScopes, createGroups, editScopes, editGroups;
|
|
||||||
var clientsById = {};
|
|
||||||
|
|
||||||
function showSecret(secret, title){
|
|
||||||
document.getElementById('secretModalTitle').innerHTML = '<i class="fa-solid fa-key"></i> ' + (title || 'Secret');
|
|
||||||
document.getElementById('secretValue').value = secret;
|
|
||||||
secretModal.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
function copySecret(){
|
|
||||||
copyField('secretValue');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy the value of an input by id; briefly flips the button icon to a check.
|
|
||||||
function copyField(id, btn){
|
|
||||||
var el = document.getElementById(id);
|
|
||||||
if(!el) return;
|
|
||||||
el.select();
|
|
||||||
el.setSelectionRange(0, 99999);
|
|
||||||
document.execCommand('copy');
|
|
||||||
if(btn){
|
|
||||||
var $i = $(btn).find('i');
|
|
||||||
var prev = $i.attr('class');
|
|
||||||
$i.attr('class', 'fa-solid fa-check');
|
|
||||||
setTimeout(function(){ $i.attr('class', prev); }, 1200);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtTTL(seconds){
|
|
||||||
if(seconds < 3600) return seconds + 's';
|
|
||||||
if(seconds < 86400) return (seconds / 3600).toFixed(1) + 'h';
|
|
||||||
return (seconds / 86400).toFixed(1) + 'd';
|
|
||||||
}
|
|
||||||
|
|
||||||
function processClient(client){
|
|
||||||
clientsById[client.client_id] = client; // keep raw data for the edit modal
|
|
||||||
client.scopes_display = (client.scopes || []).join(' ');
|
|
||||||
client.allowed_groups_display = (client.allowed_groups || []).join(', ');
|
|
||||||
client.has_group_restriction = (client.allowed_groups || []).length > 0;
|
|
||||||
client.access_token_ttl = fmtTTL((client.token_lifetime || {}).access_token || 3600);
|
|
||||||
client.refresh_token_ttl = fmtTTL((client.token_lifetime || {}).refresh_token || 2592000);
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function tableAJAX(){
|
|
||||||
let data = await app.oauthClient.list();
|
|
||||||
$.scope.oauthClientCard.empty();
|
|
||||||
$.each(data.results, function(_, client){
|
|
||||||
$.scope.oauthClientCard.push(processClient(client));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteClient(client_id, name, btn){
|
|
||||||
const $card = $(btn).closest('.card');
|
|
||||||
$card.addClass('table-warning');
|
|
||||||
const confirmed = await app.util.actionConfirm('Delete OAuth client "' + name + '"?', $card, 'warning');
|
|
||||||
$card.removeClass('table-warning');
|
|
||||||
if (!confirmed) return;
|
|
||||||
app.api.delete('oauth/client/' + client_id, function(error, data){
|
|
||||||
if(error){ app.util.actionMessage('Error: ' + data.message, $card, 'danger'); return; }
|
|
||||||
$.scope.oauthClientCard.remove('client_id', client_id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rotateSecret(client_id, name, btn){
|
|
||||||
const $card = $(btn).closest('.card');
|
|
||||||
const confirmed = await app.util.actionConfirm('Rotate secret for "' + name + '"? The old secret will stop working immediately.', $card, 'warning');
|
|
||||||
if (!confirmed) return;
|
|
||||||
app.oauthClient.rotateSecret({client_id: client_id}, function(error, data){
|
|
||||||
if(error){ app.util.actionMessage('Error: ' + data.message, $card, 'danger'); return; }
|
|
||||||
showSecret(data.client_secret, 'Client Secret');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open the edit modal pre-filled from the client's current values.
|
|
||||||
function editClient(client_id){
|
|
||||||
var c = clientsById[client_id];
|
|
||||||
if(!c) return;
|
|
||||||
$('#edit-client-id').val(client_id);
|
|
||||||
$('#edit-name').val(c.name || '');
|
|
||||||
$('#edit-description').val(c.description || '');
|
|
||||||
$('#edit-redirect_uris').val((c.redirect_uris || []).join('\n'));
|
|
||||||
$('#edit-access_ttl').val((c.token_lifetime || {}).access_token || 3600);
|
|
||||||
$('#edit-refresh_ttl').val((c.token_lifetime || {}).refresh_token || 2592000);
|
|
||||||
|
|
||||||
// (Re)build the tag widgets fresh each open so they reflect this client.
|
|
||||||
editScopes = app.ui.tagInput('#edit-scopes', {
|
|
||||||
values: c.scopes || [], options: VALID_SCOPES, freeSolo: false,
|
|
||||||
separator: ' ', placeholder: 'Add a scope…',
|
|
||||||
});
|
|
||||||
editGroups = app.ui.groupSelect('#edit-allowed_groups', {
|
|
||||||
values: c.allowed_groups || [], placeholder: 'Type a group name…',
|
|
||||||
});
|
|
||||||
editModal.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveEdit(btn){
|
|
||||||
var $msg = $('#editModal .actionMessage');
|
|
||||||
var payload = {
|
|
||||||
client_id: $('#edit-client-id').val(),
|
|
||||||
name: $('#edit-name').val(),
|
|
||||||
description: $('#edit-description').val(),
|
|
||||||
redirect_uris: $('#edit-redirect_uris').val().split('\n').map(function(s){ return s.trim(); }).filter(Boolean),
|
|
||||||
scopes: editScopes.get(),
|
|
||||||
allowed_groups: editGroups.get(),
|
|
||||||
token_lifetime: {
|
|
||||||
access_token: Number($('#edit-access_ttl').val()) || 3600,
|
|
||||||
refresh_token: Number($('#edit-refresh_ttl').val()) || 2592000,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
app.oauthClient.update(payload, function(error, data){
|
|
||||||
if(error){
|
|
||||||
app.util.actionMessage((data && data.message) || 'Update failed.', $msg.parent(), 'danger');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
editModal.hide();
|
|
||||||
tableAJAX();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$(document).ready(function(){
|
|
||||||
tableAJAX();
|
|
||||||
|
|
||||||
// Initialise the create-form tag widgets.
|
|
||||||
createScopes = app.ui.tagInput('#create-scopes', {
|
|
||||||
name: 'scopes', values: DEFAULT_SCOPES, options: VALID_SCOPES,
|
|
||||||
freeSolo: false, separator: ' ', placeholder: 'Add a scope…',
|
|
||||||
});
|
|
||||||
createGroups = app.ui.groupSelect('#create-allowed_groups', {
|
|
||||||
name: 'allowed_groups', values: [], placeholder: 'Type a group name…',
|
|
||||||
});
|
|
||||||
|
|
||||||
// After a successful create, reset the widgets too (form reset ignores them).
|
|
||||||
$('form[action="oauth/client/"]').attr('evalAJAX',
|
|
||||||
'showSecret(data.client_secret, "Client Secret"); tableAJAX(); $form.trigger("reset"); createScopes.set(DEFAULT_SCOPES); createGroups.clear();'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<h4><i class="fa-solid fa-plug"></i> Integrations</h4>
|
|
||||||
|
|
||||||
<ul class="nav nav-tabs mb-3" role="tablist">
|
|
||||||
<li class="nav-item" role="presentation">
|
|
||||||
<button class="nav-link active" id="tab-oauth-btn" data-bs-toggle="tab" data-bs-target="#tab-oauth" type="button" role="tab">
|
|
||||||
<i class="fa-solid fa-key"></i> OAuth Apps
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item" role="presentation">
|
|
||||||
<button class="nav-link" id="tab-ldap-btn" data-bs-toggle="tab" data-bs-target="#tab-ldap" type="button" role="tab">
|
|
||||||
<i class="fa-solid fa-network-wired"></i> LDAP
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div class="tab-content">
|
|
||||||
<div class="tab-pane fade show active" id="tab-oauth" role="tabpanel">
|
|
||||||
<div class="row" style="display:none">
|
|
||||||
<div class="col-12 mb-3">
|
|
||||||
<div class="card shadow-sm border-info">
|
|
||||||
<div class="card-header bg-info bg-opacity-10">
|
|
||||||
<i class="fa-solid fa-circle-info"></i>
|
|
||||||
OpenID Connect Endpoints
|
|
||||||
<a href="/docs/oauth-apps" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p class="mb-2 text-muted small">
|
|
||||||
Point OIDC/OAuth clients (e.g. Home Assistant) at the discovery URL below.
|
|
||||||
It advertises the authorization, token, and userinfo endpoints automatically.
|
|
||||||
</p>
|
|
||||||
<dl class="row mb-0">
|
|
||||||
<dt class="col-sm-2">Issuer</dt>
|
|
||||||
<dd class="col-sm-10"><code><%= issuer %></code></dd>
|
|
||||||
<dt class="col-sm-2">Discovery URL</dt>
|
|
||||||
<dd class="col-sm-10">
|
|
||||||
<div class="input-group input-group-sm">
|
|
||||||
<input type="text" id="discoveryUrl" class="form-control font-monospace" readonly value="<%= discoveryUrl %>">
|
|
||||||
<a class="btn btn-outline-secondary" href="<%= discoveryUrl %>" target="_blank" title="Open"><i class="fa-solid fa-arrow-up-right-from-square"></i></a>
|
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('discoveryUrl', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card shadow-lg">
|
|
||||||
<div class="card-header">
|
|
||||||
<i class="fa-solid fa-plus"></i>
|
|
||||||
Register OAuth Client
|
|
||||||
<a href="/docs/oauth-apps" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
|
||||||
</div>
|
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<form action="oauth/client/" method="post" onsubmit="formAJAX(this)">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Name</label>
|
|
||||||
<input type="text" class="form-control shadow" name="name" placeholder="Home Assistant" validate=":1">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Description</label>
|
|
||||||
<input type="text" class="form-control shadow" name="description" placeholder="Home automation dashboard">
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Redirect URIs <small class="text-muted">(one per line)</small></label>
|
|
||||||
<textarea class="form-control shadow font-monospace" name="redirect_uris" rows="3"
|
|
||||||
placeholder="https://ha.example.com/auth/external/callback" validate=":1"></textarea>
|
|
||||||
<small class="field-help text-muted d-block">
|
|
||||||
<code>*</code> matches one hostname label and <code>**</code> matches any
|
|
||||||
number of labels, e.g. <code>https://*.example.com/__proxy_auth/callback</code>
|
|
||||||
covers every host theta42/proxy fronts under example.com without registering
|
|
||||||
each one individually.
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Scopes</label>
|
|
||||||
<div id="create-scopes"></div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Restrict to Groups <small class="text-muted">(optional)</small></label>
|
|
||||||
<div id="create-allowed_groups"></div>
|
|
||||||
<small class="text-muted">Leave empty to allow any user. If set, only members of a listed LDAP group can log in.</small>
|
|
||||||
</div>
|
|
||||||
<div class="row mb-3">
|
|
||||||
<div class="col">
|
|
||||||
<label class="form-label">Access Token TTL <small class="text-muted">(seconds)</small></label>
|
|
||||||
<input type="number" class="form-control shadow" name="token_lifetime[access_token]" value="3600" min="60">
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<label class="form-label">Refresh Token TTL <small class="text-muted">(seconds)</small></label>
|
|
||||||
<input type="number" class="form-control shadow" name="token_lifetime[refresh_token]" value="2592000" min="3600">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-outline-dark">
|
|
||||||
<i class="fa-solid fa-plus"></i> Register
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-8" style="background-color: initial; border: none">
|
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
|
||||||
|
|
||||||
<div jq-repeat="oauthClientCard" jq-index-key="client_id" id="oauth-card-{{client_id}}" class="card shadow mb-3">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5>
|
|
||||||
<i class="fa-solid fa-server"></i>
|
|
||||||
{{ name }}
|
|
||||||
</h5>
|
|
||||||
<small class="text-muted font-monospace">{{ client_id }}</small>
|
|
||||||
</div>
|
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
|
||||||
<div class="card-body">
|
|
||||||
{{ #description }}
|
|
||||||
<p>{{ description }}</p>
|
|
||||||
{{ /description }}
|
|
||||||
<dl class="row mb-0">
|
|
||||||
<dt class="col-sm-3">Client ID</dt>
|
|
||||||
<dd class="col-sm-9">
|
|
||||||
<div class="input-group input-group-sm">
|
|
||||||
<input type="text" id="clientid-{{client_id}}" class="form-control font-monospace" readonly value="{{client_id}}">
|
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('clientid-{{client_id}}', this)" title="Copy Client ID"><i class="fa-solid fa-copy"></i></button>
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
<dt class="col-sm-3">Redirect URIs</dt>
|
|
||||||
<dd class="col-sm-9">
|
|
||||||
<ul class="list-unstyled mb-0">
|
|
||||||
{{ #redirect_uris }}
|
|
||||||
<li><code>{{ . }}</code></li>
|
|
||||||
{{ /redirect_uris }}
|
|
||||||
</ul>
|
|
||||||
</dd>
|
|
||||||
<dt class="col-sm-3">Scopes</dt>
|
|
||||||
<dd class="col-sm-9"><code>{{ scopes_display }}</code></dd>
|
|
||||||
<dt class="col-sm-3">Access</dt>
|
|
||||||
<dd class="col-sm-9">
|
|
||||||
{{ #has_group_restriction }}
|
|
||||||
<span class="badge bg-warning text-dark"><i class="fa-solid fa-user-lock"></i> Restricted</span>
|
|
||||||
<code>{{ allowed_groups_display }}</code>
|
|
||||||
{{ /has_group_restriction }}
|
|
||||||
{{ ^has_group_restriction }}
|
|
||||||
<span class="badge bg-secondary"><i class="fa-solid fa-users"></i> Any user</span>
|
|
||||||
{{ /has_group_restriction }}
|
|
||||||
</dd>
|
|
||||||
<dt class="col-sm-3">Access Token</dt>
|
|
||||||
<dd class="col-sm-9">{{ access_token_ttl }}</dd>
|
|
||||||
<dt class="col-sm-3">Refresh Token</dt>
|
|
||||||
<dd class="col-sm-9">{{ refresh_token_ttl }}</dd>
|
|
||||||
<dt class="col-sm-3">Created by</dt>
|
|
||||||
<dd class="col-sm-9">{{ created_by }}</dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer">
|
|
||||||
<button type="button"
|
|
||||||
onclick="editClient('{{client_id}}')"
|
|
||||||
class="btn btn-primary btn-sm">
|
|
||||||
<i class="fa-solid fa-pen-to-square"></i> Edit
|
|
||||||
</button>
|
|
||||||
<button type="button"
|
|
||||||
onclick="rotateSecret('{{client_id}}', '{{name}}', this)"
|
|
||||||
class="btn btn-warning btn-sm">
|
|
||||||
<i class="fa-solid fa-arrows-rotate"></i> Rotate Secret
|
|
||||||
</button>
|
|
||||||
<button type="button"
|
|
||||||
onclick="deleteClient('{{client_id}}', '{{name}}', this)"
|
|
||||||
class="btn btn-danger btn-sm float-end">
|
|
||||||
<i class="fa-solid fa-trash"></i> Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tab-pane fade" id="tab-ldap" role="tabpanel">
|
|
||||||
<p class="text-muted">
|
|
||||||
Everything a 3rd-party app or host needs to bind this directory, filled in
|
|
||||||
for <b><%= ssoUrl %></b>.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="row g-3">
|
|
||||||
<div class="col-12">
|
|
||||||
<div class="card shadow-sm border-warning">
|
|
||||||
<div class="card-header bg-warning bg-opacity-10">
|
|
||||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
|
||||||
LDAPS hostname: keep LDAP binds off the public internet
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p class="small mb-2">
|
|
||||||
LDAPS requires a <strong>hostname</strong>, not a bare IP address, because
|
|
||||||
the TLS client verifies the server name against the certificate.
|
|
||||||
The URL below <% if (ldapsHostExplicit) { %>is set to <code><%= ldapHost %></code> from
|
|
||||||
<code>conf.ldap.ldapsHost</code>.<% } else { %>currently matches the public
|
|
||||||
OAuth issuer host — convenient, but that implies clients reach it through
|
|
||||||
your router on port 636. <strong>Do not port-forward 636 to the internet</strong>
|
|
||||||
for LDAP simple binds; instead pick an internal-only hostname and set
|
|
||||||
<code>conf.ldap.ldapsHost</code>.<% } %>
|
|
||||||
</p>
|
|
||||||
<ul class="small mb-2">
|
|
||||||
<li><strong>Same Docker/network host (recommended for the proxy or apps on this machine):</strong>
|
|
||||||
use <code>ldaps://sso-manager:636</code> (the internal service name).
|
|
||||||
Set <code>conf.ldap.ldapsHost = 'sso-manager'</code>.</li>
|
|
||||||
<li><strong>LAN host:</strong> create an internal DNS record like
|
|
||||||
<code>ldap.internal.example.com</code> → the local IP, get or generate a cert
|
|
||||||
whose SAN matches that name, and set <code>conf.ldap.ldapsHost</code>.
|
|
||||||
A wildcard for <code>*.internal.example.com</code> works well.</li>
|
|
||||||
<li><strong>Public hostname:</strong> only acceptable behind a VPN or firewall
|
|
||||||
lockdown — never exposed to the open internet.</li>
|
|
||||||
</ul>
|
|
||||||
<p class="small mb-0">
|
|
||||||
<b>Trusting the cert:</b> The bundled slapd uses a self-signed cert unless you
|
|
||||||
mount your own at <code>/etc/openldap/certs</code>. Clients must either trust
|
|
||||||
that cert, or set <code>TLS_REQCERT never</code> / <code>rejectUnauthorized: false</code>
|
|
||||||
for LAN-only use. See <a href="/docs/ldap">LDAP docs</a> for the full
|
|
||||||
runbook, including how to set <code>ldapsHost</code> in
|
|
||||||
<code>conf/secrets.js</code> or via <code>app_ldap__ldapsHost=...</code>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="card shadow-lg">
|
|
||||||
<div class="card-header shadow">
|
|
||||||
<i class="fa-solid fa-circle-info"></i> Connection details
|
|
||||||
<a href="/docs/ldap" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p class="text-muted small">
|
|
||||||
For a single app's own "LDAP authentication" settings — see
|
|
||||||
<a href="https://theta42.github.io/sso-manager-node/ldap.html#connecting-a-3rd-party-app-or-container" target="_blank">Connecting a 3rd-party app or container</a>
|
|
||||||
for a field-by-field walkthrough (Gitea, generic Docker <code>LDAP_*</code> env vars, …).
|
|
||||||
</p>
|
|
||||||
<dl class="row mb-0">
|
|
||||||
<dt class="col-sm-4">LDAPS URL</dt>
|
|
||||||
<dd class="col-sm-8">
|
|
||||||
<div class="input-group input-group-sm">
|
|
||||||
<input type="text" id="f-ldapsUrl" class="form-control font-monospace" readonly value="<%= ldapsUrl %>">
|
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-ldapsUrl', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
|
||||||
</div>
|
|
||||||
<% if (ldapsHostExplicit) { %>
|
|
||||||
<small class="field-help text-muted d-block">
|
|
||||||
Custom <code>conf.ldap.ldapsHost</code> — override in your secrets file if this name doesn't resolve from the client.
|
|
||||||
</small>
|
|
||||||
<% } %>
|
|
||||||
</dd>
|
|
||||||
|
|
||||||
<dt class="col-sm-4">Base DN</dt>
|
|
||||||
<dd class="col-sm-8">
|
|
||||||
<div class="input-group input-group-sm">
|
|
||||||
<input type="text" id="f-baseDn" class="form-control font-monospace" readonly value="<%= baseDn %>">
|
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-baseDn', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
|
|
||||||
<dt class="col-sm-4">User search base</dt>
|
|
||||||
<dd class="col-sm-8">
|
|
||||||
<div class="input-group input-group-sm">
|
|
||||||
<input type="text" id="f-userBase" class="form-control font-monospace" readonly value="<%= userBase %>">
|
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-userBase', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
|
|
||||||
<dt class="col-sm-4">Group search base</dt>
|
|
||||||
<dd class="col-sm-8">
|
|
||||||
<div class="input-group input-group-sm">
|
|
||||||
<input type="text" id="f-groupBase" class="form-control font-monospace" readonly value="<%= groupBase %>">
|
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-groupBase', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
|
|
||||||
<dt class="col-sm-4">User filter</dt>
|
|
||||||
<dd class="col-sm-8">
|
|
||||||
<div class="input-group input-group-sm">
|
|
||||||
<input type="text" id="f-userFilter" class="form-control font-monospace" readonly value="<%= userFilter %>">
|
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-userFilter', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
|
|
||||||
<dt class="col-sm-4">Username attribute</dt>
|
|
||||||
<dd class="col-sm-8">
|
|
||||||
<div class="input-group input-group-sm">
|
|
||||||
<input type="text" id="f-userNameAttribute" class="form-control font-monospace" readonly value="<%= userNameAttribute %>">
|
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-userNameAttribute', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
|
|
||||||
<dt class="col-sm-4">Example bind DN</dt>
|
|
||||||
<dd class="col-sm-8">
|
|
||||||
<div class="input-group input-group-sm">
|
|
||||||
<input type="text" id="f-bindDn" class="form-control font-monospace" readonly value="<%= exampleBindDn %>">
|
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-bindDn', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
|
||||||
</div>
|
|
||||||
<small class="field-help text-muted d-block">
|
|
||||||
A read-only bind account — create one from
|
|
||||||
<a href="/users">Users > Service Accounts</a> (don't reuse a real
|
|
||||||
person's login or the admin DN).
|
|
||||||
</small>
|
|
||||||
</dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="card shadow-lg">
|
|
||||||
<div class="card-header shadow">
|
|
||||||
<i class="fa-solid fa-terminal"></i> Set up a Linux host (ldap-client)
|
|
||||||
<a href="/docs/ldap" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p class="text-muted small">
|
|
||||||
For full host login, SSH keys, and sudo via LDAP (not just one app) —
|
|
||||||
clone <a href="https://github.com/theta42/ldap-client" target="_blank">theta42/ldap-client</a>
|
|
||||||
and run this on the host. Fill in a service account's password
|
|
||||||
(create one from <a href="/users">Users > Service Accounts</a>) and,
|
|
||||||
if you want this host's access/sudo groups auto-registered, an
|
|
||||||
<a href="/">API token</a> from your Profile.
|
|
||||||
</p>
|
|
||||||
<div class="input-group">
|
|
||||||
<textarea id="f-bashSnippet" class="form-control font-monospace" rows="16" readonly style="font-size:.8rem"></textarea>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-outline-secondary btn-sm mt-2" type="button" onclick="copyField('f-bashSnippet', this)">
|
|
||||||
<i class="fa-solid fa-copy"></i> Copy
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script type="text/javascript">
|
|
||||||
(function(){
|
|
||||||
var lines = [
|
|
||||||
'git clone https://github.com/theta42/ldap-client.git',
|
|
||||||
'cd ldap-client',
|
|
||||||
'cat > ldap.vars << \'EOF\'',
|
|
||||||
'# LDAPS host advertised on the Integrations page. If this is an internal-only',
|
|
||||||
'# hostname, make sure it resolves from this host and the cert SAN matches it.',
|
|
||||||
'export ldap_host="<%= ldapHost %>"',
|
|
||||||
'export ldap_base_dn="<%= baseDn %>"',
|
|
||||||
'',
|
|
||||||
'# A read-only service account -- create one under Users > Service',
|
|
||||||
'# Accounts, then fill in its password below.',
|
|
||||||
'export ldap_bind_dn="<%= exampleBindDn %>"',
|
|
||||||
'export ldap_bind_password="CHANGE-ME"',
|
|
||||||
'',
|
|
||||||
'# Optional: auto-register this host\'s access/sudo groups in the SSO',
|
|
||||||
'# Manager. Create a personal access token under Profile > API Tokens',
|
|
||||||
'# and paste it here; leave blank to skip.',
|
|
||||||
'export sso_url="<%= ssoUrl %>"',
|
|
||||||
'export sso_token=""',
|
|
||||||
'',
|
|
||||||
'# Optional: set this if you run ldap-client against more than one site.',
|
|
||||||
'export ldap_location=""',
|
|
||||||
'',
|
|
||||||
'ldap_access_groups=( "${ldap_location}_access" "${ldap_location}_host_$(hostname)_access" )',
|
|
||||||
'EOF',
|
|
||||||
'',
|
|
||||||
'sudo ./index.sh',
|
|
||||||
];
|
|
||||||
document.getElementById('f-bashSnippet').value = lines.join('\n');
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<%- include('bottom') %>
|
|
||||||
@@ -1,306 +0,0 @@
|
|||||||
<%- include('top') %>
|
|
||||||
|
|
||||||
<script type="text/javascript">
|
|
||||||
app.auth.forceLogin(['app_sso_admin', 'app_sso_invite']);
|
|
||||||
|
|
||||||
let allGroups = [];
|
|
||||||
let currentUserUid = null;
|
|
||||||
let isAdmin = false;
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
const [user, groupData] = await Promise.all([
|
|
||||||
app.api.get('user/me'),
|
|
||||||
app.api.get('group/'),
|
|
||||||
]);
|
|
||||||
currentUserUid = user.uid;
|
|
||||||
isAdmin = (user.memberOf || []).some(dn => dn.startsWith('cn=app_sso_admin,'));
|
|
||||||
allGroups = groupData.results || [];
|
|
||||||
|
|
||||||
// Populate create-form group select
|
|
||||||
populateGroupSelect('create-groups', allGroups, []);
|
|
||||||
|
|
||||||
loadInvites();
|
|
||||||
}
|
|
||||||
|
|
||||||
function fuzzyMatch(query, text) {
|
|
||||||
query = query.toLowerCase();
|
|
||||||
text = text.toLowerCase();
|
|
||||||
let qi = 0;
|
|
||||||
for (let i = 0; i < text.length && qi < query.length; i++) {
|
|
||||||
if (text[i] === query[qi]) qi++;
|
|
||||||
}
|
|
||||||
return qi === query.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterGroups(inputEl, selectId) {
|
|
||||||
const query = inputEl.value;
|
|
||||||
[...document.getElementById(selectId).options].forEach(function(opt) {
|
|
||||||
opt.hidden = query ? !fuzzyMatch(query, opt.value) : false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function populateGroupSelect(id, groups, selected) {
|
|
||||||
const sel = document.getElementById(id);
|
|
||||||
sel.innerHTML = '';
|
|
||||||
groups.forEach(function(cn) {
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.value = cn;
|
|
||||||
opt.textContent = cn;
|
|
||||||
opt.selected = selected.includes(cn);
|
|
||||||
sel.appendChild(opt);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function inviteStatus(t) {
|
|
||||||
if (t.claimed_by && t.claimed_by !== '__NONE__') return { label: 'Claimed', cls: 'text-secondary' };
|
|
||||||
if (!t.is_valid) return { label: 'Revoked', cls: 'text-danger' };
|
|
||||||
return { label: 'Pending', cls: 'text-success' };
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupsDisplay(groups) {
|
|
||||||
try {
|
|
||||||
const arr = JSON.parse(groups || '[]');
|
|
||||||
return arr.length ? arr.join(', ') : '—';
|
|
||||||
} catch(_) { return '—'; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadInvites() {
|
|
||||||
try {
|
|
||||||
const data = await app.api.get('user/invite');
|
|
||||||
const tbody = document.getElementById('invite-tbody');
|
|
||||||
tbody.innerHTML = '';
|
|
||||||
(data.results || [])
|
|
||||||
.sort((a, b) => (b.created_on || 0) - (a.created_on || 0))
|
|
||||||
.forEach(function(t) {
|
|
||||||
const status = inviteStatus(t);
|
|
||||||
const canEdit = isAdmin || t.created_by === currentUserUid;
|
|
||||||
const mail = (!t.mail || t.mail === '__NONE__') ? '—' : t.mail;
|
|
||||||
const isPending = t.is_valid && (!t.claimed_by || t.claimed_by === '__NONE__');
|
|
||||||
|
|
||||||
const tr = document.createElement('tr');
|
|
||||||
tr.dataset.token = t.token;
|
|
||||||
tr.innerHTML = `
|
|
||||||
<td><small class="text-muted">${moment(Number(t.created_on)).fromNow()}</small></td>
|
|
||||||
<td><small>${t.created_by}</small></td>
|
|
||||||
<td><small>${mail}</small></td>
|
|
||||||
<td><small class="text-muted">${groupsDisplay(t.groups)}</small></td>
|
|
||||||
<td><small class="${status.cls}">${status.label}</small></td>
|
|
||||||
<td class="text-nowrap">
|
|
||||||
${canEdit && isPending ? `
|
|
||||||
<button class="btn btn-sm btn-outline-secondary me-1" onclick="openEdit('${t.token}')">
|
|
||||||
<i class="fa-solid fa-pen"></i>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-outline-danger" onclick="revokeInvite('${t.token}', this)">
|
|
||||||
<i class="fa-solid fa-ban"></i>
|
|
||||||
</button>
|
|
||||||
` : ''}
|
|
||||||
${isPending ? `
|
|
||||||
<button class="btn btn-sm btn-outline-dark ms-1" onclick="copyLink('${t.token}', this)">
|
|
||||||
<i class="fa-solid fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
` : ''}
|
|
||||||
</td>
|
|
||||||
`;
|
|
||||||
tbody.appendChild(tr);
|
|
||||||
});
|
|
||||||
} catch(e) {
|
|
||||||
if (e && e.status === 401) location.replace('/');
|
|
||||||
else console.error('Failed to load invites:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createInvite() {
|
|
||||||
const mail = document.getElementById('create-email').value.trim();
|
|
||||||
const groups = [...document.getElementById('create-groups').selectedOptions].map(o => o.value);
|
|
||||||
const result = document.getElementById('create-result');
|
|
||||||
result.style.display = 'none';
|
|
||||||
try {
|
|
||||||
const data = await app.api.post('user/invite', { mail, groups });
|
|
||||||
result.style.display = '';
|
|
||||||
if (data.mail_sent) {
|
|
||||||
result.className = 'alert alert-success mt-2';
|
|
||||||
result.textContent = `Invite sent to ${mail}`;
|
|
||||||
} else {
|
|
||||||
result.className = 'alert alert-info mt-2';
|
|
||||||
result.innerHTML = `Link: <a href="${data.link}" target="_blank">${data.link}</a>`;
|
|
||||||
}
|
|
||||||
document.getElementById('create-email').value = '';
|
|
||||||
[...document.getElementById('create-groups').options].forEach(o => o.selected = false);
|
|
||||||
loadInvites();
|
|
||||||
} catch(e) {
|
|
||||||
result.style.display = '';
|
|
||||||
result.className = 'alert alert-danger mt-2';
|
|
||||||
result.textContent = 'Failed: ' + ((e.responseJSON && e.responseJSON.message) || 'Unknown error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEdit(tokenId) {
|
|
||||||
// Find cached data from the DOM isn't reliable — re-fetch
|
|
||||||
app.api.get('user/invite').then(function(data) {
|
|
||||||
const t = (data.results || []).find(x => x.token === tokenId);
|
|
||||||
if (!t) return;
|
|
||||||
|
|
||||||
document.getElementById('edit-token').value = tokenId;
|
|
||||||
document.getElementById('edit-email').value = (t.mail && t.mail !== '__NONE__') ? t.mail : '';
|
|
||||||
|
|
||||||
const selected = JSON.parse(t.groups || '[]');
|
|
||||||
populateGroupSelect('edit-groups', allGroups, selected);
|
|
||||||
|
|
||||||
const modal = new bootstrap.Modal(document.getElementById('editModal'));
|
|
||||||
modal.show();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveEdit() {
|
|
||||||
const tokenId = document.getElementById('edit-token').value;
|
|
||||||
const mail = document.getElementById('edit-email').value.trim();
|
|
||||||
const groups = [...document.getElementById('edit-groups').selectedOptions].map(o => o.value);
|
|
||||||
const result = document.getElementById('edit-result');
|
|
||||||
result.style.display = 'none';
|
|
||||||
try {
|
|
||||||
await app.api.put(`user/invite/${tokenId}`, { mail, groups });
|
|
||||||
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
|
|
||||||
loadInvites();
|
|
||||||
} catch(e) {
|
|
||||||
result.style.display = '';
|
|
||||||
result.className = 'alert alert-danger mt-2';
|
|
||||||
result.textContent = 'Failed: ' + ((e.responseJSON && e.responseJSON.message) || 'Unknown error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function revokeInvite(tokenId, btn) {
|
|
||||||
$thisRow = $(btn).closest('tr');
|
|
||||||
$thisRow.addClass('table-warning');
|
|
||||||
let confirmation = await app.util.actionConfirm('Revoke selected invite token?', $thisRow, 'warning');
|
|
||||||
|
|
||||||
if(!confirmation){
|
|
||||||
$thisRow.removeClass('table-warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// lets not use `confirm``
|
|
||||||
// if (!confirm('Revoke this invite token?')) return;
|
|
||||||
try {
|
|
||||||
await app.api.delete(`user/invite/${tokenId}`);
|
|
||||||
loadInvites();
|
|
||||||
} catch(e) {
|
|
||||||
alert('Failed to revoke invite.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyLink(tokenId, btn) {
|
|
||||||
const link = `${location.origin}/login/invite/${tokenId}`;
|
|
||||||
navigator.clipboard.writeText(link).then(function() {
|
|
||||||
const orig = btn.innerHTML;
|
|
||||||
btn.innerHTML = '<i class="fa-solid fa-check"></i>';
|
|
||||||
setTimeout(() => btn.innerHTML = orig, 1500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$(document).ready(function() {
|
|
||||||
init();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12">
|
|
||||||
|
|
||||||
<div class="d-flex align-items-center mb-3 mt-2">
|
|
||||||
<h4 class="mb-0"><i class="fa-solid fa-envelope-open-text"></i> Invites</h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-3">
|
|
||||||
|
|
||||||
<!-- Create invite -->
|
|
||||||
<div class="col-lg-4">
|
|
||||||
<div class="card shadow-lg">
|
|
||||||
<div class="card-header shadow">
|
|
||||||
<i class="fa-solid fa-plus"></i> Create Invite
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="mb-2">
|
|
||||||
<label class="form-label small">Email <small class="text-muted">(optional)</small></label>
|
|
||||||
<input type="email" id="create-email" class="form-control shadow" placeholder="user@example.com" />
|
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
|
||||||
<label class="form-label small">Groups <small class="text-muted">(Ctrl/⌘ for multiple)</small></label>
|
|
||||||
<input type="text" class="form-control form-control-sm shadow mb-1" placeholder="Filter groups…" oninput="filterGroups(this, 'create-groups')" />
|
|
||||||
<select id="create-groups" class="form-select shadow" multiple size="5"></select>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary shadow" onclick="createInvite()">
|
|
||||||
<i class="fa-solid fa-paper-plane"></i> Send Invite
|
|
||||||
</button>
|
|
||||||
<div id="create-result" style="display:none" class="mt-2"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Invite list -->
|
|
||||||
<div class="col-lg-8">
|
|
||||||
<div class="card shadow-lg">
|
|
||||||
<div class="card-header shadow d-flex justify-content-between align-items-center">
|
|
||||||
<span><i class="fa-solid fa-list"></i> Invite Tokens</span>
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" onclick="loadInvites()">
|
|
||||||
<i class="fa-solid fa-rotate"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="card-header actionMessage" style="display:none;"></div>
|
|
||||||
<div class="card-body p-0">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-sm mb-0">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Created</th>
|
|
||||||
<th>By</th>
|
|
||||||
<th>Email</th>
|
|
||||||
<th>Groups</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="invite-tbody">
|
|
||||||
<tr><td colspan="6" class="text-center text-muted py-3">Loading…</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Edit modal -->
|
|
||||||
<div class="modal fade" id="editModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title"><i class="fa-solid fa-pen"></i> Edit Invite</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<input type="hidden" id="edit-token" />
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Email <small class="text-muted">(leave blank to clear; changing sends a new verification email)</small></label>
|
|
||||||
<input type="email" id="edit-email" class="form-control shadow" placeholder="user@example.com" />
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Groups <small class="text-muted">(Ctrl/⌘ for multiple)</small></label>
|
|
||||||
<input type="text" class="form-control form-control-sm shadow mb-1" placeholder="Filter groups…" oninput="filterGroups(this, 'edit-groups')" />
|
|
||||||
<select id="edit-groups" class="form-select shadow" multiple size="6"></select>
|
|
||||||
</div>
|
|
||||||
<div id="edit-result" style="display:none"></div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
<button type="button" class="btn btn-primary" onclick="saveEdit()">
|
|
||||||
<i class="fa-solid fa-floppy-disk"></i> Save
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%- include('impersonate_modal') %>
|
|
||||||
<%- include('bottom') %>
|
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<%- include('top') %>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* App Portal styling using Bootstrap defaults */
|
||||||
|
.portal-banner {
|
||||||
|
background-color: var(--bs-primary);
|
||||||
|
color: white;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
border-radius: .5rem;
|
||||||
|
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.portal-banner h1 {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.carousel-container {
|
||||||
|
display: flex;
|
||||||
|
overflow-x: auto;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
.service-card {
|
||||||
|
min-width: 280px;
|
||||||
|
height: 100%;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.service-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 .5rem 1rem rgba(0,0,0,.15)!important;
|
||||||
|
}
|
||||||
|
.service-card .card-body {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="portal-banner text-center">
|
||||||
|
<h1>SSO Portal</h1>
|
||||||
|
<p class="lead">Explore and access all your services in one place.</p>
|
||||||
|
<a href="/profile" class="btn btn-light shadow-sm mt-2"><i class="fa-solid fa-user"></i> My Profile</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="mb-3"><i class="fa-solid fa-layer-group text-primary"></i> My Apps & Services</h3>
|
||||||
|
<div class="carousel-container mb-5" id="my-services" jq-repeat="myservices">
|
||||||
|
<a href="{{resolvedAddress}}" target="_blank" style="text-decoration: none; color: inherit; min-width: 280px;">
|
||||||
|
<div class="card shadow-sm service-card border-success">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title text-success"><i class="fa-solid fa-rocket"></i> {{name}}</h5>
|
||||||
|
<p class="card-text text-muted mb-1">{{kind}}{{#metadata.subType}} - {{metadata.subType}}{{/metadata.subType}}</p>
|
||||||
|
<p class="card-text text-truncate small" title="{{description}}">{{description}}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer bg-transparent border-top-0 pt-0">
|
||||||
|
<span class="badge bg-success">Access Granted</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="mb-3"><i class="fa-solid fa-compass text-secondary"></i> Discover More Services</h3>
|
||||||
|
<div class="carousel-container mb-5" id="other-services" jq-repeat="otherservices">
|
||||||
|
<div class="card shadow-sm service-card" style="min-width: 280px;" onclick="requestAccess('{{id}}')">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title"><i class="fa-solid fa-cloud"></i> {{name}}</h5>
|
||||||
|
<p class="card-text text-muted mb-1">{{kind}}{{#metadata.subType}} - {{metadata.subType}}{{/metadata.subType}}</p>
|
||||||
|
<p class="card-text text-truncate small" title="{{description}}">{{description}}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer bg-transparent border-top-0 pt-0">
|
||||||
|
<span class="badge bg-secondary">Request Access</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="mb-3"><i class="fa-solid fa-server text-info"></i> Hosts & Infrastructure</h3>
|
||||||
|
<div class="carousel-container mb-5" id="hosts" jq-repeat="hosts">
|
||||||
|
<div class="card shadow-sm service-card" style="min-width: 280px;">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title"><i class="fa-solid fa-desktop"></i> {{name}}</h5>
|
||||||
|
<p class="card-text text-muted mb-1">IP: {{metadata.ip}}</p>
|
||||||
|
<p class="card-text small mb-0">OS: {{metadata.os}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
app.auth.forceLogin();
|
||||||
|
|
||||||
|
$(document).ready(async function() {
|
||||||
|
try {
|
||||||
|
let res = await app.api.get('discovery/me');
|
||||||
|
let allAccessible = res.results || [];
|
||||||
|
|
||||||
|
let allRes = await app.api.get('directory-admin/resources').catch(e => { return {results:[]}; });
|
||||||
|
|
||||||
|
let myServices = [];
|
||||||
|
let otherServices = [];
|
||||||
|
let hosts = [];
|
||||||
|
|
||||||
|
allAccessible.forEach(r => {
|
||||||
|
r.resolvedAddress = (r.metadata && r.metadata.address) || (r.metadata && r.metadata.ip) || '#';
|
||||||
|
r.description = r.description || 'No description provided';
|
||||||
|
if (r.kind === 'service' || r.kind === 'oauth') myServices.push(r);
|
||||||
|
if (r.kind === 'host') hosts.push(r);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (allRes && allRes.results) {
|
||||||
|
allRes.results.forEach(r => {
|
||||||
|
r.description = r.description || 'No description provided';
|
||||||
|
if (r.kind === 'service' && !myServices.find(s => s.id === r.id)) {
|
||||||
|
otherServices.push(r);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$.scope.myservices.empty();
|
||||||
|
$.scope.myservices.push(...myServices);
|
||||||
|
|
||||||
|
$.scope.otherservices.empty();
|
||||||
|
$.scope.otherservices.push(...otherServices);
|
||||||
|
|
||||||
|
$.scope.hosts.empty();
|
||||||
|
$.scope.hosts.push(...hosts);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load discovery data:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function requestAccess(id) {
|
||||||
|
app.util.alert('Access Request', 'This feature is coming soon!', 'info');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -89,6 +89,47 @@
|
|||||||
renderPersonalGroupMembers(currentUser);
|
renderPersonalGroupMembers(currentUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function renderMyServices(){
|
||||||
|
try{
|
||||||
|
let res = await app.api.get('discovery/me');
|
||||||
|
res.results.forEach(r => {
|
||||||
|
r.resolvedAddress = (r.metadata && r.metadata.address) || (r.metadata && r.metadata.ip) || 'N/A';
|
||||||
|
});
|
||||||
|
$.scope.myservices.empty();
|
||||||
|
$.scope.myservices.push(...res.results);
|
||||||
|
}catch(error){
|
||||||
|
console.error('renderMyServices error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderMyMetrics(){
|
||||||
|
try{
|
||||||
|
let uid = currentUser.uid;
|
||||||
|
let res = await app.api.get(`metrics/user/${uid}`);
|
||||||
|
if (res && res.results) {
|
||||||
|
const renderList = (items, id) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if(!el) return;
|
||||||
|
el.innerHTML = '';
|
||||||
|
if (!items || items.length === 0) {
|
||||||
|
el.innerHTML = '<li class="list-group-item text-muted">No data available</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
items.forEach(item => {
|
||||||
|
el.innerHTML += `<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
${item.value}
|
||||||
|
<span class="badge bg-primary rounded-pill">${item.score}</span>
|
||||||
|
</li>`;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
renderList(res.results.services, 'metrics-user-services');
|
||||||
|
}
|
||||||
|
}catch(error){
|
||||||
|
console.error('renderMyMetrics error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async function determinUser(){
|
async function determinUser(){
|
||||||
if(location.pathname.includes('/users/')){
|
if(location.pathname.includes('/users/')){
|
||||||
let uid = location.pathname.replace('/users/', '');
|
let uid = location.pathname.replace('/users/', '');
|
||||||
@@ -160,6 +201,11 @@
|
|||||||
renderProfile(currentUser);
|
renderProfile(currentUser);
|
||||||
renderUserGroups(currentUser);
|
renderUserGroups(currentUser);
|
||||||
renderPersonalGroupMembers(currentUser);
|
renderPersonalGroupMembers(currentUser);
|
||||||
|
renderMyServices();
|
||||||
|
if(!isOwnProfile) {
|
||||||
|
renderMyMetrics();
|
||||||
|
$('#user-metrics-card').show();
|
||||||
|
}
|
||||||
$('#personal-group-uid-label').text(currentUser.uid);
|
$('#personal-group-uid-label').text(currentUser.uid);
|
||||||
|
|
||||||
addGroupSelect = app.ui.groupSelect('#add-group-select', {
|
addGroupSelect = app.ui.groupSelect('#add-group-select', {
|
||||||
@@ -397,6 +443,60 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="shadow-lg card card-default mb-8">
|
||||||
|
<div class="card-header shadow">
|
||||||
|
<i class="fa-solid fa-layer-group"></i>
|
||||||
|
My Services
|
||||||
|
<div class="float-end">
|
||||||
|
<i class="fa-solid fa-arrows-up-down"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Address / IP</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Kind</th>
|
||||||
|
</thead>
|
||||||
|
<tbody jq-repeat="myservices">
|
||||||
|
<tr>
|
||||||
|
<td>{{name}} <small class="text-muted">({{slug}})</small></td>
|
||||||
|
<td><a href="{{resolvedAddress}}" target="_blank"><code>{{resolvedAddress}}</code></a></td>
|
||||||
|
<td>{{description}}</td>
|
||||||
|
<td>
|
||||||
|
{{#metadata.isProduction}}<span class="badge bg-danger mb-1">Prod</span><br>{{/metadata.isProduction}}
|
||||||
|
<span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="shadow-lg card card-default mb-8 group-required group-required-app_sso_admin" id="user-metrics-card" style="display:none">
|
||||||
|
<div class="card-header shadow bg-success text-white">
|
||||||
|
<i class="fa-solid fa-chart-line"></i>
|
||||||
|
Security & Usage Stats (Last 7 Days)
|
||||||
|
<div class="float-end">
|
||||||
|
<i class="fa-solid fa-arrows-up-down"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="row m-0">
|
||||||
|
<div class="col-12 p-3">
|
||||||
|
<h6 class="text-success"><i class="fa-solid fa-plug"></i> Top Services Used</h6>
|
||||||
|
<ul class="list-group list-group-flush border rounded" id="metrics-user-services">
|
||||||
|
<li class="list-group-item text-muted">Loading...</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="shadow-lg card card-default mb-8 group-required group-required-app_sso_admin">
|
<div class="shadow-lg card card-default mb-8 group-required group-required-app_sso_admin">
|
||||||
<div class="card-header shadow">
|
<div class="card-header shadow">
|
||||||
<i class="fa-solid fa-people-group"></i>
|
<i class="fa-solid fa-people-group"></i>
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
<%- include('top') %>
|
|
||||||
|
|
||||||
<div class="container-fluid" style="margin-top: 80px;">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-8 offset-md-2">
|
|
||||||
<div class="card shadow-lg mb-4">
|
|
||||||
<div class="card-header">
|
|
||||||
<i class="fa-solid fa-network-wired"></i> Sites & Replication
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p class="mb-4">
|
|
||||||
This page shows the status of Multi-Master LDAP replication peers.
|
|
||||||
<br/>Your Server ID: <strong><%= myId %></strong>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<table class="table table-striped table-bordered">
|
|
||||||
<thead class="table-dark">
|
|
||||||
<tr>
|
|
||||||
<th>Site LDAP URL</th>
|
|
||||||
<th>Replication Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<% if (sites.length === 0) { %>
|
|
||||||
<tr>
|
|
||||||
<td colspan="2" class="text-center text-muted">No replication peers configured in environment (LDAP_REPLICATION_HOSTS is empty).</td>
|
|
||||||
</tr>
|
|
||||||
<% } else { %>
|
|
||||||
<% sites.forEach(function(site) { %>
|
|
||||||
<tr>
|
|
||||||
<td class="align-middle"><strong><%= site.url %></strong></td>
|
|
||||||
<td class="align-middle">
|
|
||||||
<% if (site.status === 'Online') { %>
|
|
||||||
<span class="badge bg-success"><i class="fa-solid fa-circle-check"></i> Online</span>
|
|
||||||
<% } else { %>
|
|
||||||
<span class="badge bg-danger"><i class="fa-solid fa-circle-xmark"></i> <%= site.status %></span>
|
|
||||||
<% } %>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<% }); %>
|
|
||||||
<% } %>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%- include('bottom') %>
|
|
||||||
+9
-20
@@ -28,7 +28,7 @@
|
|||||||
<body>
|
<body>
|
||||||
|
|
||||||
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
|
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
|
||||||
<a class="navbar-brand" href="#"><img src="<%- logo %>" height="28" class="me-2" alt=""><%- name %> <%- titleIcon %></a>
|
<a class="navbar-brand" href="/"><img src="<%- logo %>" height="28" class="me-2" alt=""><%- name %> <%- titleIcon %></a>
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
@@ -44,33 +44,22 @@
|
|||||||
Groups
|
Groups
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item group-required group-required-app_sso_admin group-required-app_sso_oauth_admin">
|
<li class="nav-item group-required group-required-app_sso_admin group-required-app_sso_directory_admin">
|
||||||
<a class="nav-link" href="/integrations">
|
<a class="nav-link" href="/directory"><i class="fa-solid fa-server"></i>
|
||||||
<i class="fa-solid fa-plug"></i>
|
Directory
|
||||||
Integrations
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item group-required group-required-app_sso_admin group-required-app_sso_invite">
|
|
||||||
<a class="nav-link" href="/invites">
|
|
||||||
<i class="fa-solid fa-envelope-open-text"></i>
|
|
||||||
Invites
|
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
|
||||||
<li class="nav-item group-required group-required-app_sso_admin">
|
<li class="nav-item group-required group-required-app_sso_admin">
|
||||||
<a class="nav-link" href="/sites">
|
<a class="nav-link" href="/executive">
|
||||||
<i class="fa-solid fa-network-wired"></i>
|
|
||||||
Sites
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item group-required group-required-app_sso_admin">
|
|
||||||
<a class="nav-link" href="/dashboard">
|
|
||||||
<i class="fa-solid fa-gauge-high"></i>
|
<i class="fa-solid fa-gauge-high"></i>
|
||||||
Dashboard
|
Executive
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="form-inline mt-2 mt-md-0">
|
<div class="form-inline mt-2 mt-md-0">
|
||||||
<a id="cl-username" class="navbar-text text-light me-3" href="/" style="display: none;">
|
<a id="cl-username" class="navbar-text text-light me-3" href="/profile" style="display: none;">
|
||||||
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
||||||
</a>
|
</a>
|
||||||
<a id="cl-login-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.forceLogin()" style="display: none;">
|
<a id="cl-login-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.forceLogin()" style="display: none;">
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ async function fetchUsernameSuggestions() {
|
|||||||
// simply can't bind). Disabling (not just hiding) keeps disabled
|
// simply can't bind). Disabling (not just hiding) keeps disabled
|
||||||
// fields out of both form serialization and validation.
|
// fields out of both form serialization and validation.
|
||||||
$form.find('[name=mail]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
|
$form.find('[name=mail]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
|
||||||
|
$form.find('[name=mobile]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
|
||||||
$form.find('[name=userPassword]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
|
$form.find('[name=userPassword]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
|
||||||
$form.find('[name=passwordMatch]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
|
$form.find('[name=passwordMatch]').prop('disabled', checked).closest('.mb-3').toggle(!checked);
|
||||||
|
|
||||||
@@ -166,3 +167,18 @@ async function fetchUsernameSuggestions() {
|
|||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-outline-dark">Add</button>
|
<button type="submit" class="btn btn-outline-dark">Add</button>
|
||||||
</form>
|
</form>
|
||||||
|
<script>
|
||||||
|
$(document).ready(async function() {
|
||||||
|
const $loc = $('[name="location"]');
|
||||||
|
if (!$loc.val()) {
|
||||||
|
try {
|
||||||
|
const dirRes = await app.api.get('directory-admin/resources');
|
||||||
|
const resources = dirRes.results || [];
|
||||||
|
const site = resources.find(r => r.kind === 'site' && r.metadata && r.metadata.isCurrentSite);
|
||||||
|
if (site) {
|
||||||
|
$loc.val(site.name);
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|||||||
+263
-64
@@ -24,6 +24,132 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function inviteStatus(t) {
|
||||||
|
if (t.claimed_by && t.claimed_by !== '__NONE__') return { label: 'Claimed', cls: 'text-secondary' };
|
||||||
|
if (!t.is_valid) return { label: 'Revoked', cls: 'text-danger' };
|
||||||
|
return { label: 'Pending', cls: 'text-success' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupsDisplay(groups) {
|
||||||
|
try {
|
||||||
|
const arr = JSON.parse(groups || '[]');
|
||||||
|
return arr.length ? arr.join(', ') : '—';
|
||||||
|
} catch(_) { return '—'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInvites() {
|
||||||
|
try {
|
||||||
|
const data = await app.api.get('user/invite');
|
||||||
|
const tbody = document.getElementById('invite-tbody');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
(data.results || [])
|
||||||
|
.sort((a, b) => (b.created_on || 0) - (a.created_on || 0))
|
||||||
|
.forEach(function(t) {
|
||||||
|
const status = inviteStatus(t);
|
||||||
|
const canEdit = true; // all users on this page are admins
|
||||||
|
const mail = (!t.mail || t.mail === '__NONE__') ? '—' : t.mail;
|
||||||
|
const isPending = t.is_valid && (!t.claimed_by || t.claimed_by === '__NONE__');
|
||||||
|
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.dataset.token = t.token;
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td><small class="text-muted">${moment(Number(t.created_on)).fromNow()}</small></td>
|
||||||
|
<td><small>${t.created_by}</small></td>
|
||||||
|
<td><small>${mail}</small></td>
|
||||||
|
<td><small class="text-muted">${groupsDisplay(t.groups)}</small></td>
|
||||||
|
<td><small class="${status.cls}">${status.label}</small></td>
|
||||||
|
<td class="text-nowrap text-end">
|
||||||
|
${canEdit && isPending ? `
|
||||||
|
<button class="btn btn-sm btn-outline-secondary me-1" onclick="openEdit('${t.token}')">
|
||||||
|
<i class="fa-solid fa-pen"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="revokeInvite('${t.token}', this)">
|
||||||
|
<i class="fa-solid fa-ban"></i>
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
${isPending ? `
|
||||||
|
<button class="btn btn-sm btn-outline-dark ms-1" onclick="copyLink('${t.token}', this)">
|
||||||
|
<i class="fa-solid fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
} catch(e) {
|
||||||
|
if (e && e.status === 401) location.replace('/');
|
||||||
|
else console.error('Failed to load invites:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateGroupSelect(id, groups, selected) {
|
||||||
|
const sel = document.getElementById(id);
|
||||||
|
sel.innerHTML = '';
|
||||||
|
groups.forEach(function(cn) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = cn;
|
||||||
|
opt.textContent = cn;
|
||||||
|
opt.selected = selected.includes(cn);
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(tokenId) {
|
||||||
|
app.api.get('user/invite').then(function(data) {
|
||||||
|
const t = (data.results || []).find(x => x.token === tokenId);
|
||||||
|
if (!t) return;
|
||||||
|
document.getElementById('edit-token').value = tokenId;
|
||||||
|
document.getElementById('edit-email').value = (t.mail && t.mail !== '__NONE__') ? t.mail : '';
|
||||||
|
const selected = JSON.parse(t.groups || '[]');
|
||||||
|
const allGroups = [...document.getElementById('invite-groups').options].map(o => o.value);
|
||||||
|
populateGroupSelect('edit-groups', allGroups, selected);
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('editModal'));
|
||||||
|
modal.show();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit() {
|
||||||
|
const tokenId = document.getElementById('edit-token').value;
|
||||||
|
const mail = document.getElementById('edit-email').value.trim();
|
||||||
|
const groups = [...document.getElementById('edit-groups').selectedOptions].map(o => o.value);
|
||||||
|
const result = document.getElementById('edit-result');
|
||||||
|
result.style.display = 'none';
|
||||||
|
try {
|
||||||
|
await app.api.put(`user/invite/${tokenId}`, { mail, groups });
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
|
||||||
|
loadInvites();
|
||||||
|
} catch(e) {
|
||||||
|
result.style.display = '';
|
||||||
|
result.className = 'alert alert-danger mt-2';
|
||||||
|
result.textContent = 'Failed: ' + ((e.responseJSON && e.responseJSON.message) || 'Unknown error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeInvite(tokenId, btn) {
|
||||||
|
$thisRow = $(btn).closest('tr');
|
||||||
|
$thisRow.addClass('table-warning');
|
||||||
|
let confirmation = await app.util.actionConfirm('Revoke selected invite token?', $thisRow, 'warning');
|
||||||
|
if(!confirmation){
|
||||||
|
$thisRow.removeClass('table-warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await app.api.delete(`user/invite/${tokenId}`);
|
||||||
|
loadInvites();
|
||||||
|
} catch(e) {
|
||||||
|
alert('Failed to revoke invite.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyLink(tokenId, btn) {
|
||||||
|
const link = `${location.origin}/login/invite/${tokenId}`;
|
||||||
|
navigator.clipboard.writeText(link).then(function() {
|
||||||
|
const orig = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<i class="fa-solid fa-check"></i>';
|
||||||
|
setTimeout(() => btn.innerHTML = orig, 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteUser(uid, btn){
|
async function deleteUser(uid, btn){
|
||||||
const $row = $(btn).closest('tr');
|
const $row = $(btn).closest('tr');
|
||||||
$row.addClass('table-warning');
|
$row.addClass('table-warning');
|
||||||
@@ -84,6 +210,11 @@
|
|||||||
}
|
}
|
||||||
document.getElementById('invite-email').value = '';
|
document.getElementById('invite-email').value = '';
|
||||||
[...document.getElementById('invite-groups').options].forEach(o => o.selected = false);
|
[...document.getElementById('invite-groups').options].forEach(o => o.selected = false);
|
||||||
|
loadInvites();
|
||||||
|
setTimeout(() => {
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById("inviteUserModal"))?.hide();
|
||||||
|
result.style.display = 'none';
|
||||||
|
}, 3000);
|
||||||
}catch(e){
|
}catch(e){
|
||||||
result.style.display = '';
|
result.style.display = '';
|
||||||
result.className = 'alert alert-danger mt-2';
|
result.className = 'alert alert-danger mt-2';
|
||||||
@@ -97,14 +228,36 @@
|
|||||||
$(document).ready(function(){
|
$(document).ready(function(){
|
||||||
renderUsers();
|
renderUsers();
|
||||||
loadInviteGroups();
|
loadInviteGroups();
|
||||||
$('form[action="user/"]').attr('evalAJAX', 'renderUsers("User added", "success")')
|
loadInvites();
|
||||||
|
$('form[action="user/"]').attr('evalAJAX', 'renderUsers("User added", "success"); bootstrap.Modal.getInstance(document.getElementById("addUserModal"))?.hide();')
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
<h4><i class="fa-solid fa-users"></i> Users</h4>
|
<div class="container mt-4">
|
||||||
|
|
||||||
<ul class="nav nav-tabs mb-3" role="tablist">
|
<div class="row">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="card shadow">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<i class="fa-solid fa-users"></i>
|
||||||
|
User List
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-sm btn-outline-primary me-2 group-required group-required-app_sso_admin" data-bs-toggle="modal" data-bs-target="#inviteUserModal">
|
||||||
|
<i class="fas fa-envelope"></i> Invite User
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-primary me-2" data-bs-toggle="modal" data-bs-target="#addUserModal">
|
||||||
|
<i class="fas fa-user-plus"></i> Add User
|
||||||
|
</button>
|
||||||
|
<a href="/docs/accounts" class="text-reset" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
|
|
||||||
|
<div class="px-3 pt-3 border-bottom">
|
||||||
|
<ul class="nav nav-tabs border-bottom-0" role="tablist">
|
||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<button class="nav-link active" id="tab-people-btn" data-bs-toggle="tab" data-bs-target="#tab-people" type="button" role="tab">
|
<button class="nav-link active" id="tab-people-btn" data-bs-toggle="tab" data-bs-target="#tab-people" type="button" role="tab">
|
||||||
<i class="fa-solid fa-user"></i> People
|
<i class="fa-solid fa-user"></i> People
|
||||||
@@ -115,59 +268,16 @@
|
|||||||
<i class="fa-solid fa-gears"></i> Service Accounts
|
<i class="fa-solid fa-gears"></i> Service Accounts
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="tab-invites-btn" data-bs-toggle="tab" data-bs-target="#tab-invites" type="button" role="tab">
|
||||||
|
<i class="fa-solid fa-envelope-open-text"></i> Invites
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
<div class="tab-pane fade show active" id="tab-people" role="tabpanel">
|
<div class="tab-pane fade show active" id="tab-people" role="tabpanel">
|
||||||
<div class="row" style="display:none">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="shadow-lg card mb-3 card-default group-required group-required-app_sso_admin">
|
|
||||||
<div class="card-header shadow">
|
|
||||||
<i class="fas fa-user-plus"></i>
|
|
||||||
Invite User
|
|
||||||
<span class="float-end">
|
|
||||||
<a href="/docs/accounts" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
|
||||||
<i class="fa-solid fa-arrows-up-down"></i>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="card-header shadow actionMessage" style="display: none;"></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="mb-2">
|
|
||||||
<label class="form-label small">Email <small class="text-muted">(optional — sends invite immediately)</small></label>
|
|
||||||
<input type="email" id="invite-email" class="form-control form-control-sm shadow" placeholder="user@example.com" />
|
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
|
||||||
<label class="form-label small">Groups <small class="text-muted">(optional — hold Ctrl/⌘ for multiple)</small></label>
|
|
||||||
<input type="text" class="form-control form-control-sm shadow mb-1" placeholder="Filter groups…" oninput="filterGroups(this, 'invite-groups')" />
|
|
||||||
<select id="invite-groups" class="form-select form-select-sm shadow" multiple size="4"></select>
|
|
||||||
</div>
|
|
||||||
<button onclick="sendInvite()" class="btn btn-sm btn-outline-dark shadow">
|
|
||||||
<i class="fa-solid fa-envelope"></i> Send Invite
|
|
||||||
</button>
|
|
||||||
<div id="invite-result" style="display:none" class="mt-2"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card shadow-lg">
|
|
||||||
<div class="card-header">
|
|
||||||
<i class="fas fa-user-plus"></i>
|
|
||||||
Add new user
|
|
||||||
<small class="text-muted">(check <b>This is a service account</b> below to create one — it'll show up under the Service Accounts tab)</small>
|
|
||||||
<a href="/docs/accounts" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
|
||||||
</div>
|
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<%- include('user_form', {adminMode: true}) %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-8">
|
|
||||||
<div class="card shadow">
|
|
||||||
<div class="card-header">
|
|
||||||
<i class="fa-solid fa-users"></i>
|
|
||||||
User List
|
|
||||||
<a href="/docs/accounts" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
|
||||||
</div>
|
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="card-body table table-striped" style="margin-bottom:0">
|
<table class="card-body table table-striped" style="margin-bottom:0">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -224,20 +334,11 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="tab-pane fade" id="tab-service-accounts" role="tabpanel">
|
<div class="tab-pane fade" id="tab-service-accounts" role="tabpanel">
|
||||||
<div class="row" style="display:none">
|
<div class="p-3 pb-0 text-muted small border-bottom">
|
||||||
<div class="col-12">
|
<i class="fa-solid fa-circle-info"></i> Unix/POSIX accounts something runs as, not a person. Create one from the People tab's "Add User" form.
|
||||||
<div class="card shadow">
|
|
||||||
<div class="card-header">
|
|
||||||
<i class="fa-solid fa-gears"></i>
|
|
||||||
Service Accounts
|
|
||||||
<small class="text-muted">— Unix/POSIX accounts something runs as, not a person. Create one from the People tab's "Add new user" form.</small>
|
|
||||||
<a href="/docs/accounts" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="card-body table table-striped" style="margin-bottom:0">
|
<table class="card-body table table-striped" style="margin-bottom:0">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -286,6 +387,104 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-pane fade" id="tab-invites" role="tabpanel">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="card-body table table-striped" style="margin-bottom:0">
|
||||||
|
<thead>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>By</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Groups</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</thead>
|
||||||
|
<tbody id="invite-tbody">
|
||||||
|
<tr><td colspan="6" class="text-center text-muted py-3">Loading…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Invite User Modal -->
|
||||||
|
<div class="modal fade" id="inviteUserModal" tabindex="-1" aria-labelledby="inviteUserModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header shadow">
|
||||||
|
<h5 class="modal-title" id="inviteUserModalLabel"><i class="fas fa-user-plus"></i> Invite User</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label small">Email <small class="text-muted">(optional — sends invite immediately)</small></label>
|
||||||
|
<input type="email" id="invite-email" class="form-control form-control-sm shadow" placeholder="user@example.com" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label small">Groups <small class="text-muted">(optional — hold Ctrl/⌘ for multiple)</small></label>
|
||||||
|
<input type="text" class="form-control form-control-sm shadow mb-1" placeholder="Filter groups…" oninput="filterGroups(this, 'invite-groups')" />
|
||||||
|
<select id="invite-groups" class="form-select form-select-sm shadow" multiple size="4"></select>
|
||||||
|
</div>
|
||||||
|
<div id="invite-result" style="display:none" class="mt-2"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
<button type="button" onclick="sendInvite()" class="btn btn-primary shadow">
|
||||||
|
<i class="fa-solid fa-envelope"></i> Send Invite
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add User Modal -->
|
||||||
|
<div class="modal fade" id="addUserModal" tabindex="-1" aria-labelledby="addUserModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header shadow">
|
||||||
|
<h5 class="modal-title" id="addUserModalLabel">
|
||||||
|
<i class="fas fa-user-plus"></i> Add new user
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="small text-muted mb-3">Check <b>This is a service account</b> below to create one — it'll show up under the Service Accounts tab.</p>
|
||||||
|
<%- include('user_form', {adminMode: true}) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Invite Modal -->
|
||||||
|
<div class="modal fade" id="editModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header shadow">
|
||||||
|
<h5 class="modal-title"><i class="fa-solid fa-pen"></i> Edit Invite</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" id="edit-token" />
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Email <small class="text-muted">(leave blank to clear; changing sends a new verification email)</small></label>
|
||||||
|
<input type="email" id="edit-email" class="form-control shadow" placeholder="user@example.com" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Groups <small class="text-muted">(Ctrl/⌘ for multiple)</small></label>
|
||||||
|
<input type="text" class="form-control form-control-sm shadow mb-1" placeholder="Filter groups…" oninput="filterGroups(this, 'edit-groups')" />
|
||||||
|
<select id="edit-groups" class="form-select shadow" multiple size="6"></select>
|
||||||
|
</div>
|
||||||
|
<div id="edit-result" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="saveEdit()">
|
||||||
|
<i class="fa-solid fa-floppy-disk"></i> Save
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user