Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 782ef69fb8 | |||
| 0e955abc73 | |||
| 69883836e1 | |||
| 17df21041a | |||
| c19fffe3c9 | |||
| b6abfe8f03 | |||
| 420ccfab3b | |||
| 8ed4505dc0 | |||
| 451054f0c2 | |||
| 3a46680c8b | |||
| 1b0418e42e | |||
| 4e3aa082d3 | |||
| 6cb8b259e2 | |||
| 2532c492f1 | |||
| fdc045e166 | |||
| 0c2f38f0fe | |||
| fcba782ac7 | |||
| 6162c6d8a1 | |||
| 3be8c7fde2 | |||
| 3852e9ba62 | |||
| 7f2c71299f | |||
| 18119d54aa | |||
| 487e38f1a4 | |||
| 2e011dd383 | |||
| 3c12ebba16 | |||
| ffb2e99199 | |||
| 9d5f106863 | |||
| 7f00d4c845 | |||
| 1d1d29d287 | |||
| 5665504bc1 | |||
| 2ac1c30112 | |||
| 04c18eaf30 | |||
| 6835074b8b |
@@ -4,6 +4,68 @@ All notable changes to this project are documented here. Format loosely
|
||||
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`.
|
||||
|
||||
## [1.8.0] - 2026-07-28
|
||||
|
||||
### Added
|
||||
- **Directory resource modal: General / Details / Associated LDAP Groups / Children tabs**, replacing one long form. The new Children tab lists a resource's existing children and lets you add another right from the modal.
|
||||
- **Resource audit trail**: `created_by`/`created_on`/`updated_by`/`updated_on`, shown in the modal's new footer (mirrors the convention already used by proxy's `Host` and jump-host's `ApiToken`). Existing resources predating this change show "—" until next edited.
|
||||
- **Linkable resource URLs**: `GET /directory/:slug` plus a client-side deep-link check make a resource's modal directly bookmarkable/shareable; the address bar updates to `/directory/{slug}` while its modal is open and reverts on close (including via the browser Back button).
|
||||
- **Auto-created LDAP groups are now prefixed with their nearest ancestor Site's slug** (e.g. `site_local_myhost_access` instead of `myhost_access`), so groups for same-named hosts/services under different sites no longer collide or look identical. Resources with no Site ancestor keep the old unprefixed naming.
|
||||
|
||||
### Changed
|
||||
- `@simpleworkjs/frontend` bumped to 0.2.6: `app.modal` gained the `tabs`/`footer`/`url` options (all opt-in, existing callers unaffected) plus `showTab`/`on`/`deepLinkSlug`/`formatAudit`/`footerButtons` helpers — the shared building blocks behind this release's modal work, reusable by future entity modals in any of the 3 apps.
|
||||
|
||||
### Fixed
|
||||
- The Directory's Associated LDAP Groups / Relationships lists no longer risk silently dropping their contents on a second modal open (a `jq-repeat`/DOM-rebuild timing race, now rendered manually instead).
|
||||
|
||||
### Operational note
|
||||
The new `Resource` audit fields require a schema migration on any existing deployment: `ALTER TABLE Resource ADD COLUMN created_by VARCHAR(255); ALTER TABLE Resource ADD COLUMN created_on INTEGER; ALTER TABLE Resource ADD COLUMN updated_by VARCHAR(255); ALTER TABLE Resource ADD COLUMN updated_on INTEGER;` (adjust types for non-sqlite dialects) — `@simpleworkjs/orm`'s `sync()` only creates missing tables, it never alters existing ones.
|
||||
|
||||
## [1.7.0] - 2026-07-28
|
||||
|
||||
### Fixed
|
||||
- **`formAJAX`'s loading indicator showed literal HTML** ("<div class=..."), not a spinner — it passed raw markup to `app.messages.action`, which HTML-escapes its message by design. Replaced with plain text.
|
||||
- **`POST /api/user/` (create) and `PUT /api/user/password` had no `message` field** in their response, so the success notification rendered empty. Added messages matching every other route's convention.
|
||||
- **The user landing on `/login` with a `?redirect=` had no explanation why** — happens whenever another app's "Log in with SSO" bounces an unauthenticated user through `/oauth/authorize`. Now shows a contextual banner explaining what's happening.
|
||||
|
||||
### Changed
|
||||
- **Directory: tree view is now the only view** (the list/tree toggle is gone) — simpler, one code path.
|
||||
- **Directory: clicking a resource's name opens its detail modal**, not just the pencil/edit icon.
|
||||
|
||||
Found via a fresh production install's feedback — see the [theta-env v1.13.0 release](https://github.com/theta42/theta-env/releases) for the full cross-repo summary.
|
||||
|
||||
## [1.6.3] - 2026-07-28
|
||||
|
||||
### Fixed
|
||||
- **Group membership changes (`PUT`/`DELETE /api/group/:group/:uid`) didn't invalidate the User cache**, so `isServiceAccount` (and anything else derived from `memberOf`) could stay stale for up to 5 minutes after a change. This is what caused a real "lost user" report — the account had landed in `app_sso_service_account` (which `users.ejs`'s People tab filters out entirely) and looked exactly like data loss, though nothing was ever deleted.
|
||||
|
||||
### Added
|
||||
- **A confirmation before adding anyone to `app_sso_service_account`** via the Groups page — that group's whole purpose is to hide an account from the People tab, and there was no guardrail against doing that to a real person by mistake (which is how the bug above happened). Every other group's add-member flow is unchanged.
|
||||
|
||||
## [1.6.2] - 2026-07-28
|
||||
|
||||
### Fixed
|
||||
- **`DELETE /api/oauth/client/:id` 500'd** (`client.remove is not a function`) — `OAuthClient` wraps `@simpleworkjs/orm`'s `Resource` model, whose instance delete method is `.delete()`, not `.remove()`. The Directory Management UI was unaffected (its own delete routes already used `.delete()` correctly); only this legacy/raw API endpoint was broken. Found live against a real deployment's SSO API.
|
||||
|
||||
### Added
|
||||
- **Regression tests**: PUT/DELETE on `/api/oauth/client/:id` now verify persistence with a follow-up GET rather than trusting the mutating response alone (this is what would have caught the bug above). A static check across all views/client-side scripts fails CI if any native `alert()`/`confirm()`/`prompt()` call appears — these block all further browser events on the page and were fully removed in 1.6.1.
|
||||
|
||||
## [1.6.1] - 2026-07-27
|
||||
|
||||
### Fixed
|
||||
- **Removed every native `alert()`/`confirm()` call**, replacing them with `app.messages.action`/`confirm`/`toast`. Native `confirm()` blocks all further browser events on the page (discovered live, mid browser-verification of the 1.6.0 `app.messages`/`app.modal` adoption, on `directory.ejs`'s "Rotate Client Secret" — it froze the whole tab). Also deleted `app.user.remove`/`app.oauthClient.remove` in `public/js/app.js`, which had native `confirm()` guards and zero callers anywhere in the app.
|
||||
|
||||
## [1.6.0] - 2026-07-27
|
||||
|
||||
### Changed
|
||||
- **Adopted `@simpleworkjs/frontend`'s `app.messages`, `app.modal`, and `app.validate` modules**, replacing the vendored `app.util.actionMessage`/`actionConfirm`/`alert` in `public/lib/js/app-base.js` and the vendored `public/lib/js/val.js`. Message content is now HTML-escaped (the vendored `alert()` this replaces had no escaping), and `app.messages.action` falls back to a page-wide toast when there's no inline `.actionMessage` target. `app.api`/`app.auth`/`app.pubsub`/`app.socket` are untouched — they're app-specific (dual-mode callback/promise API, `auth-token` header injection) and not something the frontend package's generic `app.js` provides.
|
||||
|
||||
## [1.5.1] - 2026-07-27
|
||||
|
||||
### Fixed
|
||||
- **`PUT /api/user/:uid` 500'd with `ObjectClassViolationError` (LDAP `0x41`) when setting `sshPublicKey`** on any account created before the `ldapPublicKey` auxiliary objectClass was added to new-user creation (e.g. the bootstrap `admin` account). `User.update`'s `sshPublicKey` handling and `User.addSSHkey` (`nodejs/models/user_ldap.js`) now add the `ldapPublicKey` objectClass first (ignoring `TypeOrValueExistsError` if already present), the same pattern already used for `dateOfBirth`/`theta42Person`.
|
||||
- **OAuth Integration parent dropdown was blank.** `populateHostDropdown` in `nodejs/views/directory.ejs` only built options for `kind === 'host'` and `kind === 'service'` — there was no branch for `kind === 'oauth'`, so choosing "OAuth Integration" in the Directory's add-resource modal left the parent-Service picker empty except the placeholder. Added the missing branch.
|
||||
|
||||
## [1.5.0] - 2026-07-26
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -46,7 +46,7 @@ on, just like anyone else's.
|
||||
|
||||
A **group** is just a named list of accounts, used to control access. This
|
||||
app has a handful of built-in groups that grant admin powers (e.g. only
|
||||
people in the `app_sso_admin` group can see the Users/Groups/Integrations
|
||||
people in the `app_sso_admin` group can see the Users/Groups/Directory/Executive
|
||||
pages at all), but you can also make your own groups for any app you
|
||||
connect — say, a group listing everyone who should be allowed into your
|
||||
photo server. Once a group exists, add or remove members from the
|
||||
|
||||
@@ -28,7 +28,7 @@ what matters practically is the handful of concepts below.
|
||||
## What's a "client"?
|
||||
|
||||
Every app you connect is registered here as a **client** — a single entry
|
||||
on the Integrations page representing that one app. Registering a client
|
||||
in the Directory representing that one app. Registering a client
|
||||
gives you a **Client ID** and **Client Secret**: think of these like a
|
||||
username and password, but for the *app itself* rather than for a person.
|
||||
You paste them into the other app's own "Single Sign-On" or "OIDC" setup
|
||||
|
||||
@@ -58,6 +58,8 @@ Resources carry a flexible `metadata` JSON object that can store essential conte
|
||||
|
||||
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.
|
||||
|
||||
<a href="images/directory.png" target="_blank"><img src="images/directory.png" alt="Directory & inventory list view" width="80%"></a>
|
||||
|
||||
## Slug conventions
|
||||
|
||||
Slugs are the stable identifiers automation keys off, so the tooling around the SSO Manager follows a shared convention:
|
||||
|
||||
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 392 KiB |
|
Before Width: | Height: | Size: 362 KiB After Width: | Height: | Size: 430 KiB |
|
Before Width: | Height: | Size: 357 KiB After Width: | Height: | Size: 313 KiB |
|
Before Width: | Height: | Size: 284 KiB After Width: | Height: | Size: 221 KiB |
@@ -22,10 +22,11 @@ one command).
|
||||
|
||||
## Screenshots
|
||||
|
||||
<a href="images/dashboard.png" target="_blank"><img src="images/dashboard.png" alt="Dashboard" width="49%"></a>
|
||||
<a href="images/dashboard.png" target="_blank"><img src="images/dashboard.png" alt="Executive dashboard" width="49%"></a>
|
||||
<a href="images/users.png" target="_blank"><img src="images/users.png" alt="User list" width="49%"></a>
|
||||
<a href="images/groups.png" target="_blank"><img src="images/groups.png" alt="Groups" width="49%"></a>
|
||||
<a href="images/oauth-clients.png" target="_blank"><img src="images/oauth-clients.png" alt="OAuth clients" width="49%"></a>
|
||||
<a href="images/directory.png" target="_blank"><img src="images/directory.png" alt="Directory & inventory" width="49%"></a>
|
||||
<a href="images/oauth-clients.png" target="_blank"><img src="images/oauth-clients.png" alt="OAuth client (edit view)" width="49%"></a>
|
||||
|
||||
*(click any screenshot to view full size)*
|
||||
|
||||
|
||||
@@ -64,6 +64,8 @@ Clients are managed directly from the **Directory** tab in the web UI. They are
|
||||
|
||||
> All client-management actions use the standard Directory API (`/api/directory-admin/resources`) and are gated by the `app_sso_directory_admin` group.
|
||||
|
||||
<a href="images/oauth-clients.png" target="_blank"><img src="images/oauth-clients.png" alt="Editing an OAuth client resource" width="80%"></a>
|
||||
|
||||
## Scopes
|
||||
|
||||
| Scope | Claims / access |
|
||||
|
||||
@@ -152,10 +152,36 @@ class Resource extends Model {
|
||||
owner: { type: 'string' },
|
||||
description: { type: 'text' },
|
||||
metadata: { type: 'json', default: {} },
|
||||
// Not isRequired: @simpleworkjs/orm has no auto-timestamp hook, so these
|
||||
// are set explicitly by the route handler on every create/update (see
|
||||
// routes/api_directory_admin.js). Existing rows predating this change
|
||||
// simply read back undefined -- callers must render a fallback.
|
||||
created_by: { type: 'string' },
|
||||
created_on: { type: 'integer' },
|
||||
updated_by: { type: 'string' },
|
||||
updated_on: { type: 'integer' },
|
||||
edgesAsParent: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'parentId' },
|
||||
edgesAsChild: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'childId' },
|
||||
groups: { type: 'hasMany', model: 'ResourceGroup', remoteKey: 'resourceId' }
|
||||
};
|
||||
|
||||
// Walk parent ResourceEdges from resourceId up to the nearest ancestor
|
||||
// whose kind === 'site', returning its slug (or null if none exists -- a
|
||||
// top-level resource with no site parent keeps its unprefixed group name).
|
||||
static async findAncestorSiteSlug(resourceId, visited = new Set()) {
|
||||
if (visited.has(resourceId)) return null;
|
||||
visited.add(resourceId);
|
||||
|
||||
const parentEdges = await ResourceEdge.list({ where: { childId: resourceId } });
|
||||
for (const edge of parentEdges) {
|
||||
const parent = await this.get(edge.parentId);
|
||||
if (!parent) continue;
|
||||
if (parent.kind === 'site') return parent.slug;
|
||||
const found = await this.findAncestorSiteSlug(parent.id, visited);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class ResourceEdge extends Model {
|
||||
|
||||
@@ -473,6 +473,19 @@ User.update = async function(data){
|
||||
}
|
||||
|
||||
if(data.sshPublicKey){
|
||||
// Ensure the auxiliary objectClass is present before setting the attribute
|
||||
// -- accounts created before ldapPublicKey was added to addPosixAccount's
|
||||
// objectclass list (e.g. the bootstrap admin) won't have it yet.
|
||||
try {
|
||||
await client.modify(this.dn, [
|
||||
new Change({
|
||||
operation: 'add',
|
||||
modification: new Attribute({ type: 'objectClass', values: ['ldapPublicKey'] }),
|
||||
}),
|
||||
]);
|
||||
} catch(e) {
|
||||
if(e.name !== 'TypeOrValueExistsError') throw e;
|
||||
}
|
||||
await client.modify(this.dn, [
|
||||
new Change({
|
||||
operation: 'replace',
|
||||
@@ -784,6 +797,19 @@ User.addSSHkey = async function(data) {
|
||||
let result;
|
||||
try {
|
||||
await withClient(async (client) => {
|
||||
// Ensure the auxiliary objectClass is present before setting the attribute
|
||||
// -- accounts created before ldapPublicKey was added to addPosixAccount's
|
||||
// objectclass list (e.g. the bootstrap admin) won't have it yet.
|
||||
try {
|
||||
await client.modify(user.dn, [
|
||||
new Change({
|
||||
operation: 'add',
|
||||
modification: new Attribute({ type: 'objectClass', values: ['ldapPublicKey'] }),
|
||||
}),
|
||||
]);
|
||||
} catch(e) {
|
||||
if (e.name !== 'TypeOrValueExistsError') throw e;
|
||||
}
|
||||
await client.modify(user.dn, [
|
||||
new Change({
|
||||
operation: 'add',
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.5.0",
|
||||
"version": "1.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.5.0",
|
||||
"version": "1.7.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
@@ -14,6 +14,7 @@
|
||||
"@simpleworkjs/app-stack": "^1.0.0",
|
||||
"@simpleworkjs/conf": "^1.2.0",
|
||||
"@simpleworkjs/directory-schema": "^1.0.0",
|
||||
"@simpleworkjs/frontend": "^0.2.6",
|
||||
"@simpleworkjs/ldap": "^1.0.0",
|
||||
"@simpleworkjs/orm": "^0.2.8",
|
||||
"bcrypt": "^6.0.0",
|
||||
@@ -1278,6 +1279,15 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@simpleworkjs/frontend": {
|
||||
"version": "0.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.6.tgz",
|
||||
"integrity": "sha512-2uqvEjxyZ2LE+sfhP6rJcEMmqdViazJ3ZkitWJXInPMWF6DiEZuP5MYqBqJvfDko63CCHEt1/ChFQd7Ry85Pzg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@simpleworkjs/ldap": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/ldap/-/ldap-1.0.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.5.0",
|
||||
"version": "1.8.0",
|
||||
"description": "A very simple LDAP management and SSO system",
|
||||
"author": [
|
||||
{
|
||||
@@ -23,10 +23,11 @@
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@simpleworkjs/conf": "^1.2.0",
|
||||
"@simpleworkjs/app-stack": "^1.0.0",
|
||||
"@simpleworkjs/ldap": "^1.0.0",
|
||||
"@simpleworkjs/conf": "^1.2.0",
|
||||
"@simpleworkjs/directory-schema": "^1.0.0",
|
||||
"@simpleworkjs/frontend": "^0.2.6",
|
||||
"@simpleworkjs/ldap": "^1.0.0",
|
||||
"@simpleworkjs/orm": "^0.2.8",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
|
||||
@@ -67,13 +67,6 @@ app.user = (function(app){
|
||||
});
|
||||
}
|
||||
|
||||
function remove(args, callack){
|
||||
if(!confirm('Delete '+ args.uid+ 'user?')) return false;
|
||||
app.api.delete('user/'+ args.uid, function(error, data){
|
||||
callack(error, data);
|
||||
});
|
||||
}
|
||||
|
||||
function changePassword(args, callack){
|
||||
app.api.put('users/'+ arg.uid || '', args, function(error, data){
|
||||
callack(error, data);
|
||||
@@ -110,7 +103,7 @@ app.user = (function(app){
|
||||
return m ? m[1] : dn;
|
||||
}
|
||||
|
||||
return {list, remove, createInvite, setActive, dnToUid};
|
||||
return {list, createInvite, setActive, dnToUid};
|
||||
|
||||
})(app);
|
||||
|
||||
@@ -306,13 +299,6 @@ app.oauthClient = (function(app){
|
||||
});
|
||||
}
|
||||
|
||||
function remove(args, callack){
|
||||
if(!confirm('Delete OAuth client "' + args.client_id + '"?')) return false;
|
||||
app.api.delete('oauth/client/' + args.client_id, function(error, data){
|
||||
callack(error, data);
|
||||
});
|
||||
}
|
||||
|
||||
function update(args, callack){
|
||||
app.api.put('oauth/client/' + args.client_id, args, function(error, data){
|
||||
callack(error, data);
|
||||
@@ -325,7 +311,7 @@ app.oauthClient = (function(app){
|
||||
});
|
||||
}
|
||||
|
||||
return { list, add, remove, update, rotateSecret };
|
||||
return { list, add, update, rotateSecret };
|
||||
})(app);
|
||||
|
||||
app.tos = (function(app){
|
||||
|
||||
@@ -363,7 +363,7 @@ app.auth = (function(app){
|
||||
}
|
||||
|
||||
if(requiredGroups && !await memberOf(requiredGroups, user)){
|
||||
app.util.actionMessage(
|
||||
app.messages.action(
|
||||
`<h1>
|
||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||
<b>You do not have permission to be here.</b>
|
||||
@@ -520,68 +520,15 @@ app.util = (function(app){
|
||||
return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' '));
|
||||
};
|
||||
|
||||
function actionMessage(message, $targetPassed, type, callback){
|
||||
message = message || '';
|
||||
|
||||
let $target = $targetPassed.closest('div.card').find('.actionMessage');
|
||||
if(!$target.length) $target = $($targetPassed.find('.actionMessage')[0]);
|
||||
|
||||
type = type || 'info';
|
||||
callback = callback || function(){};
|
||||
|
||||
if($target.html() === message) return;
|
||||
|
||||
if($target.html()){
|
||||
$target.slideUp('fast', function(){
|
||||
$target.html('')
|
||||
$target.removeClass (function(index, className){
|
||||
return (className.match (/(^|\s)bg-\S+/g) || []).join(' ');
|
||||
});
|
||||
if(message) return actionMessage(message, $target, type, callback);
|
||||
$target.hide()
|
||||
})
|
||||
}else{
|
||||
if(type) $target.addClass('bg-' + type);
|
||||
|
||||
// Messages that bring their own buttons (actionConfirm) are left
|
||||
// alone; everything else gets the standard dismiss button.
|
||||
if(!message.includes('<button')) message = `
|
||||
<span class="align-middle">${message}</span>
|
||||
<button class="action-close btn btn-sm btn-outline-dark float-end">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
`
|
||||
$target.html(message).slideDown('fast');
|
||||
}
|
||||
setTimeout(callback,10)
|
||||
}
|
||||
|
||||
function actionConfirm(message, $target, type, callback){
|
||||
return new Promise((resolve, reject) =>{
|
||||
let id = crypto.randomUUID();
|
||||
message = `
|
||||
<h4 class"align-middle" >
|
||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||
<b>${message}</b>
|
||||
<span class="float-end">
|
||||
<button type="button" class="btn btn-success confirm-${id}" data-confirm="true">
|
||||
<i class="fa-solid fa-circle-check"></i>
|
||||
Confirm
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger confirm-${id}">
|
||||
<i class="fa-solid fa-circle-stop"></i>
|
||||
Cancel
|
||||
</button>
|
||||
</span>
|
||||
</h4>
|
||||
`
|
||||
actionMessage(message, $target, type);
|
||||
$("body").on('click', `.confirm-${id}`, function(){
|
||||
actionMessage('', $target, type);
|
||||
resolve(!!$(this).data('confirm'));
|
||||
});
|
||||
});
|
||||
|
||||
// escapeHtml/actionMessage/actionConfirm moved to @simpleworkjs/frontend's
|
||||
// app.util.escapeHtml and app.messages.action/confirm.
|
||||
function escapeHtml(s){
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
$.fn.serializeObject = function() {
|
||||
@@ -640,8 +587,7 @@ app.util = (function(app){
|
||||
return {
|
||||
downloadFile: downloadFile,
|
||||
getUrlParameter: getUrlParameter,
|
||||
actionMessage: actionMessage,
|
||||
actionConfirm,
|
||||
escapeHtml: escapeHtml,
|
||||
}
|
||||
})(app);
|
||||
|
||||
@@ -696,9 +642,9 @@ $( document ).ready(async function(){
|
||||
$(this).closest('.card').slideUp('fast');
|
||||
});
|
||||
|
||||
$('.actionMessage').on('click', 'button.action-close', function(event){
|
||||
app.util.actionMessage(null, $(this));
|
||||
});
|
||||
// action-close click handling is wired by @simpleworkjs/frontend's
|
||||
// app.messages.js (delegated on document, so it also covers messages
|
||||
// rendered after this ready handler runs).
|
||||
|
||||
setInterval(()=>{
|
||||
$('.momentFromNow').each((idx, el)=>{
|
||||
@@ -729,20 +675,17 @@ function formAJAX(btn){
|
||||
var method = ($form.attr('method') || 'post').toLowerCase();
|
||||
|
||||
if($form.validate && !$form.validate()){
|
||||
app.util.actionMessage('Please fix the form errors.', $form, 'danger')
|
||||
app.messages.action('Please fix the form errors.', $form, 'danger')
|
||||
return false;
|
||||
}
|
||||
|
||||
app.util.actionMessage(
|
||||
`<div class="spinner-border" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>`,
|
||||
$form,
|
||||
'info'
|
||||
);
|
||||
// Plain text: app.messages.action HTML-escapes its message (by design,
|
||||
// see @simpleworkjs/frontend), so raw markup like a spinner <div> would
|
||||
// render literally instead of as an element.
|
||||
app.messages.action('Saving…', $form, 'info');
|
||||
|
||||
app.api[method]($form.attr('action'), formData, function(error, data){
|
||||
app.util.actionMessage(data.message, $form, error ? 'danger' : 'success'); //re-populate table
|
||||
app.messages.action(data.message, $form, error ? 'danger' : 'success'); //re-populate table
|
||||
$form.validateClear();
|
||||
if(!error){
|
||||
$form.trigger("reset");
|
||||
@@ -750,7 +693,7 @@ function formAJAX(btn){
|
||||
}else{
|
||||
console.log('formAJAX res error', error, data)
|
||||
if(data && data.name === 'ObjectValidateError'){
|
||||
app.util.actionMessage('Please fix the form errors', $form, 'danger'); //re-populate table
|
||||
app.messages.action('Please fix the form errors', $form, 'danger'); //re-populate table
|
||||
}
|
||||
if(data && data.keys){
|
||||
console.log('form key errors', data.keys)
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
( function( $ ) {
|
||||
var settings = {
|
||||
rule: {
|
||||
eq: function(value, options){
|
||||
var compare = $('[name=' + options + ']').val();
|
||||
|
||||
if ( value != compare ) {
|
||||
return "Miss-match";
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
$.fn.validate = function(event) {
|
||||
// let thisSettings = $.extend(true, settings, settingsObj);
|
||||
let hasErrors = false;
|
||||
|
||||
if(this.is('[validate]')) return this.validateField(event);
|
||||
|
||||
if(!this.attr('isValid')){
|
||||
console.log('adding reset event')
|
||||
this.on('reset', function(){
|
||||
$(this).attr('isValid', false);
|
||||
$(this).validateClear();
|
||||
})
|
||||
}
|
||||
|
||||
this.find('[validate]').each(function(){
|
||||
if(!$(this).validateField()) hasErrors = true;
|
||||
});
|
||||
|
||||
this.attr('isValid', !hasErrors);
|
||||
|
||||
if(hasErrors && event) event.preventDefault();
|
||||
|
||||
return !hasErrors;
|
||||
};
|
||||
|
||||
$.fn.validateClear = function(){
|
||||
$(this).find('input').each(function(){
|
||||
$(this).removeClass('is-invalid');
|
||||
$(this).removeClass('is-valid');
|
||||
})
|
||||
}
|
||||
|
||||
$.fn.validateField = function(){
|
||||
var attr = this.attr('validate').split(':'); //array of params
|
||||
var rule = attr[0];
|
||||
var options = attr[1];
|
||||
var value = this.val(); //link to input value
|
||||
var message;
|
||||
|
||||
if(this.prop('disabled')) return true;
|
||||
|
||||
|
||||
//checks if field is required, and length
|
||||
if(!isNaN(options) && value.length < options){
|
||||
message = `Must be ${options} characters`;
|
||||
}
|
||||
|
||||
//checks if empty to stop processing
|
||||
if(!isNaN(options) && value.length === 0) {
|
||||
}else if(rule in settings.rule){
|
||||
message = settings.rule[rule].apply(this, [value, options]);
|
||||
}
|
||||
|
||||
this.validateMessage(message)
|
||||
return !message;
|
||||
}
|
||||
|
||||
$.fn.validateMessage = function(message){
|
||||
if(message && message !== true){
|
||||
this.closest('.form-group').find('b.invalid-feedback').html(message);
|
||||
this.addClass('is-invalid');
|
||||
}else{
|
||||
this.removeClass('is-invalid');
|
||||
this.addClass('is-valid');
|
||||
}
|
||||
return this;
|
||||
};
|
||||
|
||||
jQuery.extend({
|
||||
validateSettings: function( settingsObj ) {
|
||||
$.extend( true, settings, settingsObj );
|
||||
},
|
||||
|
||||
validateInit: function( ettingsObj ) {
|
||||
$( '[action]' ).on( 'submit', function ( event, settingsObj ){
|
||||
$( this ).validate( settingsObj, event );
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
}( jQuery ));
|
||||
|
||||
// Host / target validation, mirrored from the backend (utils/hostname_validate.js):
|
||||
// a bare hostname or IPv4 address, no protocol / "/" / ":" / whitespace. The
|
||||
// incoming host may be a wildcard ("*.example.com"); the target may not.
|
||||
(function(){
|
||||
var LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
|
||||
// Either one bare label (Docker service names, /etc/hosts entries) or a
|
||||
// dotted hostname with an alphabetic TLD.
|
||||
var HOSTNAME = /^(?=.{1,253}$)(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}|[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)$/i;
|
||||
var FORBIDDEN = /[\s/:]/;
|
||||
|
||||
function isIPv4( value ) {
|
||||
var parts = value.split( '.' );
|
||||
if ( parts.length !== 4 ) return false;
|
||||
return parts.every( function( p ) {
|
||||
return /^(0|[1-9]\d{0,2})$/.test( p ) && Number( p ) <= 255;
|
||||
});
|
||||
}
|
||||
|
||||
// Incoming-host pattern: labels may be normal, "*" (one fragment), or "**"
|
||||
// (any number of fragments, incl. a bare "**" global catch-all).
|
||||
function isHostPattern( value ) {
|
||||
if ( value.length > 253 ) return false;
|
||||
return value.split( '.' ).every( function( l ) {
|
||||
return l === '*' || l === '**' || LABEL.test( l );
|
||||
});
|
||||
}
|
||||
|
||||
function forbidden( value ) {
|
||||
return FORBIDDEN.test( value ) || value.includes( '://' );
|
||||
}
|
||||
|
||||
// Incoming host: IPv4 or a wildcard host pattern.
|
||||
function checkHost( value ) {
|
||||
if ( typeof value !== 'string' || value.length === 0 ) return "Required";
|
||||
if ( forbidden( value ) ) return 'No protocol, "/", or ":"';
|
||||
if ( isIPv4( value ) || isHostPattern( value ) ) return;
|
||||
return "Enter a valid host or wildcard (*, **)";
|
||||
}
|
||||
|
||||
// Downstream target: IPv4 or a strict hostname, no wildcard.
|
||||
function checkTarget( value ) {
|
||||
if ( typeof value !== 'string' || value.length === 0 ) return "Required";
|
||||
if ( forbidden( value ) ) return 'No protocol, "/", or ":"';
|
||||
if ( isIPv4( value ) || HOSTNAME.test( value ) ) return;
|
||||
return "Enter a valid hostname or IP";
|
||||
}
|
||||
|
||||
$.validateSettings({
|
||||
rule:{
|
||||
ip: function( value ) {
|
||||
value = value.split( '.' );
|
||||
|
||||
if ( value.length != 4 ) {
|
||||
return "Malformed IP";
|
||||
}
|
||||
|
||||
$.each( value, function( key, value ) {
|
||||
if( value > 255 || value < 0 ) {
|
||||
return "Malformed IP";
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Incoming host name — hostname, IPv4, or wildcard pattern (*, **).
|
||||
host: function( value ) {
|
||||
return checkHost( value );
|
||||
},
|
||||
|
||||
// Downstream target — hostname or IPv4, no wildcard.
|
||||
target: function( value ) {
|
||||
return checkTarget( value );
|
||||
},
|
||||
|
||||
// Back-compat alias (no wildcard).
|
||||
hostname: function( value ) {
|
||||
return checkTarget( value );
|
||||
},
|
||||
|
||||
user: function( value ) {
|
||||
var reg = /^[a-z0-9\_\-\@\.]{1,32}$/;
|
||||
if ( reg.test( value ) === false ) {
|
||||
return "Invalid";
|
||||
}
|
||||
},
|
||||
|
||||
// Mirrors utils/password_policy.js: >= 8 chars, and either 12+ chars
|
||||
// or at least 3 of {lowercase, uppercase, number, symbol}.
|
||||
password: function( value ) {
|
||||
if ( typeof value !== 'string' || value.length < 8 ) {
|
||||
return "Password must be at least 8 characters";
|
||||
}
|
||||
if ( value.length >= 12 ) return;
|
||||
|
||||
var classes = 0;
|
||||
if ( /[a-z]/.test( value ) ) classes++;
|
||||
if ( /[A-Z]/.test( value ) ) classes++;
|
||||
if ( /[0-9]/.test( value ) ) classes++;
|
||||
if ( /[^A-Za-z0-9]/.test( value ) ) classes++;
|
||||
|
||||
if ( classes < 3 ) {
|
||||
return "Use 3 of: lowercase, uppercase, number, symbol (or 12+ chars)";
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -43,25 +43,33 @@ router.post('/resources', async (req, res, next) => {
|
||||
}
|
||||
|
||||
req.body.owner = req.body.owner || req.user.uid;
|
||||
|
||||
|
||||
const now = Date.now();
|
||||
req.body.created_by = req.body.created_by || req.user.uid;
|
||||
req.body.created_on = now;
|
||||
req.body.updated_by = req.user.uid;
|
||||
req.body.updated_on = now;
|
||||
|
||||
let r;
|
||||
if (req.body.kind === 'oauth') {
|
||||
const { OAuthClient } = require('../models/oauth_client');
|
||||
// Pass created_by explicitly for the wrapper
|
||||
// Pass created_by explicitly for the wrapper (overrides the generic
|
||||
// assignment above -- this is OAuthClient-wrapper-specific behavior).
|
||||
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 siteSlug = await Resource.findAncestorSiteSlug(r.id);
|
||||
const createGroup = async (suffix, accessLevel) => {
|
||||
const cn = `${r.slug}_${suffix}`;
|
||||
const cn = siteSlug ? `${siteSlug}_${r.slug}_${suffix}` : `${r.slug}_${suffix}`;
|
||||
try {
|
||||
await Group.add({
|
||||
name: cn,
|
||||
@@ -103,7 +111,10 @@ router.put('/resources/:id', async (req, res, next) => {
|
||||
r = await Resource.get(req.params.id);
|
||||
}
|
||||
if (!r) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
|
||||
req.body.updated_by = req.user.uid;
|
||||
req.body.updated_on = Date.now();
|
||||
|
||||
if (req.body.kind === 'host' && !req.body.hostId) {
|
||||
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
|
||||
}
|
||||
|
||||
@@ -82,8 +82,13 @@ router.put('/:group/:uid', async function(req, res, next){
|
||||
|
||||
var group = await Group.get(req.params.group);
|
||||
var user = await User.get(req.params.uid);
|
||||
const results = await group.addMember(user);
|
||||
// Group membership feeds directly into cached-User-derived state
|
||||
// (isServiceAccount, isAdmin, group-gated nav/UI) -- without this,
|
||||
// a membership change here is invisible for up to the cache's TTL.
|
||||
User.clearCache();
|
||||
return res.json({
|
||||
results: await group.addMember(user),
|
||||
results,
|
||||
message: `Added user ${req.params.uid} to ${req.params.group} group.`
|
||||
});
|
||||
}catch(error){
|
||||
@@ -98,8 +103,10 @@ router.delete('/:group/:uid', async function(req, res, next){
|
||||
|
||||
var group = await Group.get(req.params.group);
|
||||
var user = await User.get(req.params.uid);
|
||||
const results = await group.removeMember(user);
|
||||
User.clearCache();
|
||||
return res.json({
|
||||
results: await group.removeMember(user),
|
||||
results,
|
||||
message: `Removed user ${req.params.uid} from ${req.params.group} group.`
|
||||
});
|
||||
}catch(error){
|
||||
|
||||
@@ -28,7 +28,7 @@ const values ={
|
||||
// every deploy and isn't cache-busted/fingerprinted.
|
||||
mountStaticModules(router, {
|
||||
root: path.join(__dirname, '..'),
|
||||
deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', '@popper', 'jq-repeat'],
|
||||
deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', '@popper', 'jq-repeat', '@simpleworkjs/frontend'],
|
||||
});
|
||||
|
||||
// Public health endpoint for container/orchestration healthchecks.
|
||||
@@ -60,6 +60,14 @@ router.get('/directory', function(req, res) {
|
||||
res.render('directory', {...values});
|
||||
});
|
||||
|
||||
// Linkable deep-link to a single resource's modal, e.g. from the resource
|
||||
// modal's app.modal `url` option. Mirrors /users/:uid below: no server-side
|
||||
// use of :slug at all -- the client reads location.pathname itself and opens
|
||||
// the matching resource's modal once the page's own data has loaded.
|
||||
router.get('/directory/:slug', function(req, res) {
|
||||
res.render('directory', {...values});
|
||||
});
|
||||
|
||||
// Route removed since it's now in directory
|
||||
|
||||
router.get('/onboarding', async function(req, res, next) {
|
||||
|
||||
@@ -97,7 +97,7 @@ router.delete('/:client_id', async function(req, res, next) {
|
||||
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
||||
|
||||
const client = await OAuthClient.get(req.params.client_id);
|
||||
await client.remove();
|
||||
await client.delete();
|
||||
|
||||
return res.json({
|
||||
client_id: req.params.client_id,
|
||||
|
||||
@@ -49,7 +49,7 @@ router.post('/', async function(req, res, next){
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({results: user});
|
||||
return res.json({results: user, message: `User ${user.uid} created.`});
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
@@ -107,7 +107,7 @@ router.put('/password', async function(req, res, next){
|
||||
const verif = await UserVerification.getOrCreate(req.user.uid);
|
||||
await verif.update({ password_must_change: false });
|
||||
User.clearCache();
|
||||
return res.json({results: result});
|
||||
return res.json({results: result, message: 'Password changed.'});
|
||||
}catch(error){
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -151,6 +151,32 @@ describe('Groups — member management', () => {
|
||||
const members = Array.isArray(group.member) ? group.member : [group.member];
|
||||
expect(members.some(dn => dn && dn.includes(MEMBER_UID))).toBe(false);
|
||||
});
|
||||
|
||||
// Regression: adding/removing a member here didn't clear User's LRU
|
||||
// cache (ttl 5 minutes), so isServiceAccount -- derived from
|
||||
// app_sso_service_account membership at GET /api/user/:uid time -- could
|
||||
// stay wrong for up to 5 minutes after the group change. In production
|
||||
// this hid a real person's account from the Users page's "People" tab
|
||||
// (it filters out anything with isServiceAccount) for however long the
|
||||
// stale cache entry lived, which looked exactly like the account had
|
||||
// vanished.
|
||||
test('PUT app_sso_service_account/:uid immediately flips isServiceAccount (no stale cache)', async () => {
|
||||
const added = await request(app)
|
||||
.put(`/api/group/app_sso_service_account/${MEMBER_UID}`)
|
||||
.set('auth-token', token);
|
||||
expect(added.status).toBe(200);
|
||||
|
||||
const afterAdd = await request(app).get(`/api/user/${MEMBER_UID}`).set('auth-token', token);
|
||||
expect(afterAdd.body.results.isServiceAccount).toBeTruthy();
|
||||
|
||||
const removed = await request(app)
|
||||
.delete(`/api/group/app_sso_service_account/${MEMBER_UID}`)
|
||||
.set('auth-token', token);
|
||||
expect(removed.status).toBe(200);
|
||||
|
||||
const afterRemove = await request(app).get(`/api/user/${MEMBER_UID}`).set('auth-token', token);
|
||||
expect(afterRemove.body.results.isServiceAccount).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Groups — owner management', () => {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
'use strict';
|
||||
|
||||
// Regression guard: native alert()/confirm()/prompt() calls block all further
|
||||
// browser events on the page (found live, mid browser-automation testing, on
|
||||
// directory.ejs's "Rotate Client Secret" — it froze the tab entirely) and are
|
||||
// visually inconsistent with the rest of the UI. Every call site was removed
|
||||
// in favor of app.messages.action/confirm/toast and app.modal.open; this test
|
||||
// keeps it that way.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOTS = ['views', 'public/js', 'public/lib/js'].map((d) => path.join(__dirname, '..', d));
|
||||
|
||||
// Matches a bare alert(/confirm(/prompt( call, but not app.messages.*,
|
||||
// app.modal.*, or identifiers merely containing these words (e.g.
|
||||
// "confirmation", ".confirmed").
|
||||
const NATIVE_DIALOG_RE = /(^|[^.\w$])(alert|confirm|prompt)\s*\(/g;
|
||||
|
||||
function walk(dir) {
|
||||
let files = [];
|
||||
if (!fs.existsSync(dir)) return files;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) files = files.concat(walk(full));
|
||||
else if (/\.(ejs|js)$/.test(entry.name)) files.push(full);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
test('no view or client-side script calls native alert()/confirm()/prompt()', () => {
|
||||
const offenders = [];
|
||||
for (const root of ROOTS) {
|
||||
for (const file of walk(root)) {
|
||||
const src = fs.readFileSync(file, 'utf8');
|
||||
let m;
|
||||
NATIVE_DIALOG_RE.lastIndex = 0;
|
||||
while ((m = NATIVE_DIALOG_RE.exec(src))) {
|
||||
const line = src.slice(0, m.index).split('\n').length;
|
||||
offenders.push(`${path.relative(path.join(__dirname, '..'), file)}:${line} — ${m[2]}(`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
@@ -69,6 +69,51 @@ describe('OAuth client management API — /api/oauth/client', () => {
|
||||
expect(res.body.results).not.toHaveProperty('client_secret_hash');
|
||||
});
|
||||
|
||||
test('PUT persists — a changed name survives a fresh GET', async () => {
|
||||
const created = await request(app)
|
||||
.post('/api/oauth/client/')
|
||||
.set('auth-token', token)
|
||||
.send({ name: 'put-persist-test', redirect_uris: REDIRECT_URI });
|
||||
expect(created.status).toBe(200);
|
||||
const id = created.body.results.client_id;
|
||||
|
||||
const updated = await request(app)
|
||||
.put(`/api/oauth/client/${id}`)
|
||||
.set('auth-token', token)
|
||||
.send({ name: 'put-persist-test-renamed' });
|
||||
expect(updated.status).toBe(200);
|
||||
expect(updated.body.results.name).toBe('put-persist-test-renamed');
|
||||
|
||||
const fetched = await request(app).get(`/api/oauth/client/${id}`).set('auth-token', token);
|
||||
expect(fetched.status).toBe(200);
|
||||
expect(fetched.body.results.name).toBe('put-persist-test-renamed');
|
||||
|
||||
await request(app).delete(`/api/oauth/client/${id}`).set('auth-token', token);
|
||||
});
|
||||
|
||||
// Regression: this route called client.remove(), but OAuthClient wraps
|
||||
// @simpleworkjs/orm's Resource model, whose instance method is .delete()
|
||||
// — .remove() doesn't exist on it (unlike the model-redis Tables
|
||||
// elsewhere in this app, e.g. api_token.js, which really do have
|
||||
// .remove()). The route's try/catch turned the resulting TypeError into
|
||||
// a plain 500 JSON response rather than a thrown exception, so every
|
||||
// prior DELETE call in this file's cleanup hooks silently "succeeded"
|
||||
// from Jest's point of view while leaving the client un-deleted.
|
||||
test('DELETE persists — the client is actually gone, not just a 200', async () => {
|
||||
const created = await request(app)
|
||||
.post('/api/oauth/client/')
|
||||
.set('auth-token', token)
|
||||
.send({ name: 'delete-persist-test', redirect_uris: REDIRECT_URI });
|
||||
expect(created.status).toBe(200);
|
||||
const id = created.body.results.client_id;
|
||||
|
||||
const deleted = await request(app).delete(`/api/oauth/client/${id}`).set('auth-token', token);
|
||||
expect(deleted.status).toBe(200);
|
||||
|
||||
const fetched = await request(app).get(`/api/oauth/client/${id}`).set('auth-token', token);
|
||||
expect(fetched.status).toBe(404);
|
||||
});
|
||||
|
||||
test('list then rotate a client by its returned client_id (the bootstrap path)', async () => {
|
||||
// Reproduces exactly what the theta-env bootstrap does: create, list,
|
||||
// find by name, rotate by the client_id from the list response. Uses a
|
||||
@@ -90,7 +135,11 @@ describe('OAuth client management API — /api/oauth/client', () => {
|
||||
expect(rotated.status).toBe(200);
|
||||
expect(rotated.body.client_secret).toBeTruthy();
|
||||
|
||||
await request(app).delete(`/api/oauth/client/${found.client_id}`).set('auth-token', token);
|
||||
const deleted = await request(app).delete(`/api/oauth/client/${found.client_id}`).set('auth-token', token);
|
||||
expect(deleted.status).toBe(200);
|
||||
|
||||
const afterDelete = await request(app).get(`/api/oauth/client/${found.client_id}`).set('auth-token', token);
|
||||
expect(afterDelete.status).toBe(404);
|
||||
});
|
||||
|
||||
test('GET /:id unknown id returns 404, not 500', async () => {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
'use strict';
|
||||
|
||||
// findAncestorSiteSlug has no LDAP dependency (unlike most of this test
|
||||
// suite, which needs a live LDAP server) -- it's pure Resource/ResourceEdge
|
||||
// graph traversal against the ORM, so it's tested directly here rather than
|
||||
// through the (LDAP-gated) directory-admin HTTP routes.
|
||||
|
||||
const { initORM } = require('../models');
|
||||
const { Resource, ResourceEdge } = require('../models/resource');
|
||||
|
||||
const marker = 'test_site_slug_' + Date.now();
|
||||
const created = [];
|
||||
|
||||
async function makeResource(kind, name) {
|
||||
const r = await Resource.create({ kind, name, slug: `${marker}_${name}` });
|
||||
created.push(r);
|
||||
return r;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await initORM();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const r of created) {
|
||||
try { await r.delete(); } catch (_) {}
|
||||
}
|
||||
});
|
||||
|
||||
describe('Resource.findAncestorSiteSlug', () => {
|
||||
test('returns the direct parent site\'s slug', async () => {
|
||||
const site = await makeResource('site', 'site-direct');
|
||||
const host = await makeResource('host', 'host-direct');
|
||||
await ResourceEdge.create({ parentId: site.id, childId: host.id, relation: 'hosts' });
|
||||
|
||||
await expect(Resource.findAncestorSiteSlug(host.id)).resolves.toBe(site.slug);
|
||||
});
|
||||
|
||||
test('walks up through an intermediate host to find the owning site', async () => {
|
||||
const site = await makeResource('site', 'site-nested');
|
||||
const host = await makeResource('host', 'host-nested');
|
||||
const service = await makeResource('service', 'service-nested');
|
||||
await ResourceEdge.create({ parentId: site.id, childId: host.id, relation: 'hosts' });
|
||||
await ResourceEdge.create({ parentId: host.id, childId: service.id, relation: 'hosts' });
|
||||
|
||||
await expect(Resource.findAncestorSiteSlug(service.id)).resolves.toBe(site.slug);
|
||||
});
|
||||
|
||||
test('returns null for a top-level resource with no site ancestor', async () => {
|
||||
const host = await makeResource('host', 'host-orphan');
|
||||
|
||||
await expect(Resource.findAncestorSiteSlug(host.id)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
test('does not loop forever on a cyclic parent chain', async () => {
|
||||
const a = await makeResource('host', 'host-cycle-a');
|
||||
const b = await makeResource('host', 'host-cycle-b');
|
||||
await ResourceEdge.create({ parentId: a.id, childId: b.id, relation: 'hosts' });
|
||||
await ResourceEdge.create({ parentId: b.id, childId: a.id, relation: 'hosts' });
|
||||
|
||||
await expect(Resource.findAncestorSiteSlug(a.id)).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -15,12 +15,6 @@
|
||||
<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>
|
||||
@@ -49,7 +43,12 @@
|
||||
{{{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>
|
||||
<a href="#" class="text-reset text-decoration-none" onclick="openEditModal('{{id}}'); return false;" title="View details">
|
||||
<strong>{{name}}</strong>
|
||||
</a>
|
||||
<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}}
|
||||
@@ -79,252 +78,269 @@
|
||||
</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'));
|
||||
|
||||
// --- Resource modal tab content, built once. Populated via .val() in
|
||||
// openAddModal/openEditModal AFTER app.modal.open() has (re)built the
|
||||
// DOM from these strings -- order matters, unlike the old always-in-DOM
|
||||
// static modal. ---
|
||||
|
||||
var generalTabHtml = `
|
||||
<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">
|
||||
<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">
|
||||
<div class="col-6">
|
||||
<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>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea id="res-description" class="form-control shadow-sm" rows="2"></textarea>
|
||||
</div>
|
||||
`;
|
||||
|
||||
var detailsTabHtml = `
|
||||
<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" 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-6" 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-6" 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>
|
||||
`;
|
||||
|
||||
var groupsTabHtml = `
|
||||
<div class="mb-3">
|
||||
<ul class="list-group mb-2 shadow-sm" id="groups-list"></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>
|
||||
`;
|
||||
|
||||
var childrenTabHtml = `
|
||||
<div class="mb-3">
|
||||
<ul class="list-group mb-2 shadow-sm" id="children-list"></ul>
|
||||
<button class="btn btn-success btn-sm" onclick="openAddModal($('#res-id').val(), $('#res-kind').val())">
|
||||
<i class="fa-solid fa-plus"></i> Add Child Resource
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h6 class="text-muted">Other Relationships (advanced)</h6>
|
||||
<div class="mb-3">
|
||||
<ul class="list-group mb-2 shadow-sm" id="edges-list"></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>
|
||||
`;
|
||||
|
||||
// Shared by openAddModal/openEditModal: builds the tabbed/footer/(optionally
|
||||
// URL-tracked) modal DOM. Callers then populate fields via .val() and hide
|
||||
// the Groups/Children tabs in add-mode (no resource id to scope them to).
|
||||
function openResourceModal(title, id) {
|
||||
app.modal.open({
|
||||
title: title,
|
||||
size: 'lg',
|
||||
tabs: [
|
||||
{id: 'general', label: 'General', bodyHtml: generalTabHtml, active: true},
|
||||
{id: 'details', label: 'Details', bodyHtml: detailsTabHtml},
|
||||
{id: 'groups', label: 'Associated LDAP Groups', bodyHtml: groupsTabHtml},
|
||||
{id: 'children', label: 'Children', bodyHtml: childrenTabHtml},
|
||||
],
|
||||
footer: {
|
||||
metaHtml: id ? app.modal.formatAudit(resourcesById[id], {formatDate: function(ms){ return moment(ms).format('YYYY-MM-DD HH:mm'); }}) : '',
|
||||
buttonsHtml: app.modal.footerButtons({onSave: 'saveResource()', saveLabel: 'Save Resource'}),
|
||||
},
|
||||
url: id ? {path: '/directory/' + resourcesById[id].slug} : null,
|
||||
});
|
||||
$('#sw-modal-tab-groups-btn, #sw-modal-tab-children-btn').closest('li').toggle(!!id);
|
||||
}
|
||||
|
||||
function refreshChildrenUI(resourceId) {
|
||||
const kids = allEdges.filter(e => e.parentId === resourceId)
|
||||
.map(e => resourcesById[e.childId]).filter(Boolean);
|
||||
const $list = $('#children-list').empty();
|
||||
if (!kids.length) {
|
||||
$list.append('<li class="list-group-item text-muted">No child resources.</li>');
|
||||
return;
|
||||
}
|
||||
for (const kid of kids) {
|
||||
const $li = $('<li class="list-group-item d-flex justify-content-between align-items-center"></li>');
|
||||
$('<a href="#" class="text-reset text-decoration-none"></a>')
|
||||
.text(kid.name + ' (' + kid.slug + ')')
|
||||
.on('click', function(e){ e.preventDefault(); openEditModal(kid.id); })
|
||||
.appendTo($li);
|
||||
$('<span class="badge bg-secondary"></span>').text(kid.kind).appendTo($li);
|
||||
$list.append($li);
|
||||
}
|
||||
}
|
||||
|
||||
var resourcesById = {};
|
||||
var allGroups = [];
|
||||
var allEdges = [];
|
||||
@@ -332,6 +348,11 @@
|
||||
|
||||
$(document).ready(async function() {
|
||||
await loadResources();
|
||||
const slug = app.modal.deepLinkSlug('/directory');
|
||||
if (slug) {
|
||||
const match = Object.values(resourcesById).find(r => r.slug === slug);
|
||||
if (match) openEditModal(match.id);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadResources() {
|
||||
@@ -369,15 +390,14 @@
|
||||
renderTable();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Failed to load data');
|
||||
app.messages.toast('Failed to load data', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
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) ||
|
||||
@@ -402,42 +422,38 @@
|
||||
|
||||
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: '' }));
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
$.scope.resources.empty();
|
||||
for (const r of finalRenderList) {
|
||||
$.scope.resources.push(r);
|
||||
@@ -486,7 +502,10 @@
|
||||
populateHostDropdown($('#res-host-id').val());
|
||||
}
|
||||
|
||||
$('#res-name, #res-kind').on('input change', function() {
|
||||
// Delegated (not direct) since app.modal.open() rebuilds the modal body
|
||||
// from scratch every call -- a direct .on() bind here would silently stop
|
||||
// firing after the first Add/Edit.
|
||||
app.modal.on('input change', '#res-name, #res-kind', function() {
|
||||
const id = $('#res-id').val();
|
||||
if (!id && $('#res-name').val()) {
|
||||
const name = $('#res-name').val();
|
||||
@@ -495,18 +514,18 @@
|
||||
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');
|
||||
openResourceModal('Add Resource', null);
|
||||
$('#res-id').val('');
|
||||
$('#res-name').val('');
|
||||
$('#res-slug').val('');
|
||||
|
||||
|
||||
let defaultKind = 'service';
|
||||
if (parentKind === 'site') defaultKind = 'host';
|
||||
if (parentKind === 'host') defaultKind = 'service';
|
||||
@@ -537,12 +556,9 @@
|
||||
$('#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;
|
||||
@@ -572,36 +588,66 @@
|
||||
$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 + ')'));
|
||||
} else if (kind === 'oauth' && r.kind === 'service') {
|
||||
$target.append($('<option>').val(r.id).text(r.name + ' (' + r.slug + ')'));
|
||||
}
|
||||
});
|
||||
if (selectedId) $target.val(selectedId);
|
||||
}
|
||||
|
||||
// Plain DOM rendering, not jq-repeat: #groups-list is injected fresh into
|
||||
// the modal body on every app.modal.open() call, and jq-repeat's
|
||||
// MutationObserver-based (re)registration for a same-id element that's
|
||||
// destroyed and recreated runs asynchronously -- populating it
|
||||
// synchronously right after open() (as this function is called) would race
|
||||
// that and silently lose the pushed data on the second and later opens.
|
||||
function refreshGroupsUI(resourceId) {
|
||||
const myGroups = allGroups.filter(g => g.resourceId === resourceId);
|
||||
$.scope.groups.empty();
|
||||
const $list = $('#groups-list').empty();
|
||||
if (!myGroups.length) {
|
||||
$list.append('<li class="list-group-item text-muted">No associated groups.</li>');
|
||||
return;
|
||||
}
|
||||
for (const g of myGroups) {
|
||||
$.scope.groups.push(g);
|
||||
const $li = $('<li class="list-group-item d-flex justify-content-between align-items-center"></li>');
|
||||
const $span = $('<span></span>');
|
||||
$span.append('<i class="fa-solid fa-users text-muted me-2"></i>');
|
||||
$('<strong></strong>').text(g.groupCn).appendTo($span);
|
||||
$('<span class="badge bg-primary ms-2"></span>').text(g.accessLevel).appendTo($span);
|
||||
$li.append($span);
|
||||
$('<button class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-xmark"></i></button>')
|
||||
.on('click', function(){ removeGroup(g.id); })
|
||||
.appendTo($li);
|
||||
$list.append($li);
|
||||
}
|
||||
}
|
||||
|
||||
function refreshEdgesUI(resourceId) {
|
||||
const myEdges = allEdges.filter(e => e.parentId === resourceId || e.childId === resourceId);
|
||||
$.scope.edges.empty();
|
||||
const $list = $('#edges-list').empty();
|
||||
if (!myEdges.length) {
|
||||
$list.append('<li class="list-group-item text-muted">No other relationships.</li>');
|
||||
}
|
||||
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 $li = $('<li class="list-group-item d-flex justify-content-between align-items-center"></li>');
|
||||
const $span = $('<span></span>');
|
||||
$span.append(isParent
|
||||
? '<i class="fa-solid fa-arrow-down text-success me-2"></i> Has child: '
|
||||
: '<i class="fa-solid fa-arrow-up text-primary me-2"></i> Is child of: ');
|
||||
$('<strong></strong>').text(target.name + ' (' + target.slug + ')').appendTo($span);
|
||||
$('<span class="badge bg-secondary ms-1"></span>').text(e.relation).appendTo($span);
|
||||
$li.append($span);
|
||||
$('<button class="btn btn-sm btn-outline-danger"><i class="fa-solid fa-xmark"></i></button>')
|
||||
.on('click', function(){ removeEdge(e.id); })
|
||||
.appendTo($li);
|
||||
$list.append($li);
|
||||
}
|
||||
|
||||
|
||||
const $target = $('#new-edge-target');
|
||||
$target.empty().append('<option value="">-- Select Resource --</option>');
|
||||
Object.values(resourcesById).forEach(r => {
|
||||
@@ -614,8 +660,8 @@
|
||||
async function openEditModal(id) {
|
||||
const r = resourcesById[id];
|
||||
if (!r) return;
|
||||
|
||||
$('#resourceModalTitle').html('<i class="fa-solid fa-pen-to-square"></i> Edit Resource');
|
||||
|
||||
openResourceModal('Edit Resource', id);
|
||||
$('#res-id').val(r.id);
|
||||
$('#res-name').val(r.name);
|
||||
$('#res-slug').val(r.slug);
|
||||
@@ -653,14 +699,11 @@
|
||||
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);
|
||||
refreshChildrenUI(r.id);
|
||||
await loadLdapGroups();
|
||||
|
||||
resourceModal.show();
|
||||
}
|
||||
|
||||
async function saveResource() {
|
||||
@@ -708,29 +751,30 @@
|
||||
res = await app.api.post('directory-admin/resources', data);
|
||||
}
|
||||
|
||||
resourceModal.hide();
|
||||
app.modal.close();
|
||||
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');
|
||||
app.modal.open({title: 'OAuth Secret', bodyHtml: 'Save this client secret, it will not be shown again: <br><br><code>' + res.results._raw_secret + '</code>'});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert(err.message || 'Failed to save');
|
||||
app.messages.action(err.message || 'Failed to save', app.modal.body(), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const ok = await app.messages.confirm('Are you sure you want to rotate the OAuth secret? Any existing integrations using the old secret will break.', app.modal.body(), 'warning');
|
||||
if (!ok) 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');
|
||||
app.modal.open({title: 'Secret Rotated', bodyHtml: 'Save this NEW client secret, it will not be shown again: <br><br><code>' + res.secret + '</code>'});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert(err.message || 'Failed to rotate secret');
|
||||
app.messages.action(err.message || 'Failed to rotate secret', app.modal.body(), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -739,7 +783,7 @@
|
||||
const groupCn = $('#new-group-cn').val().trim();
|
||||
const accessLevel = $('#new-group-level').val();
|
||||
|
||||
if (!groupCn) return alert('Group CN is required');
|
||||
if (!groupCn) return app.messages.action('Group CN is required', app.modal.body(), 'danger');
|
||||
try {
|
||||
const res = await app.api.post('directory-admin/groups', {
|
||||
resourceId,
|
||||
@@ -751,10 +795,10 @@
|
||||
$('#new-group-cn').val('');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Failed to add group');
|
||||
app.messages.action('Failed to add group', app.modal.body(), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function removeGroup(id) {
|
||||
try {
|
||||
await app.api.delete('directory-admin/groups/' + id);
|
||||
@@ -762,7 +806,7 @@
|
||||
refreshGroupsUI($('#res-id').val());
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Failed to remove group');
|
||||
app.messages.action('Failed to remove group', app.modal.body(), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -772,7 +816,7 @@
|
||||
const targetId = $('#new-edge-target').val();
|
||||
const relation = $('#new-edge-relation').val().trim() || 'hosts';
|
||||
|
||||
if (!targetId) return alert('Select a target resource');
|
||||
if (!targetId) return app.messages.action('Select a target resource', app.modal.body(), 'danger');
|
||||
|
||||
const data = { relation };
|
||||
if (dir === 'parent') {
|
||||
@@ -790,10 +834,10 @@
|
||||
$('#new-edge-target').val('');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Failed to add edge');
|
||||
app.messages.action('Failed to add edge', app.modal.body(), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function removeEdge(id) {
|
||||
try {
|
||||
await app.api.delete('directory-admin/edges/' + id);
|
||||
@@ -801,18 +845,23 @@
|
||||
refreshEdgesUI($('#res-id').val());
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Failed to remove edge');
|
||||
app.messages.action('Failed to remove edge', app.modal.body(), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteResource(id) {
|
||||
if (!confirm('Are you sure you want to delete this resource? All relationships will be destroyed.')) return;
|
||||
// Called from the outer table's row button, not from inside the resource
|
||||
// modal — target the page's own card so the confirm/error renders
|
||||
// somewhere actually visible.
|
||||
const $target = $('#resources-list');
|
||||
const ok = await app.messages.confirm('Are you sure you want to delete this resource? All relationships will be destroyed.', $target, 'danger');
|
||||
if (!ok) return;
|
||||
try {
|
||||
await app.api.delete('directory-admin/resources/' + id);
|
||||
await loadResources();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Failed to delete');
|
||||
app.messages.action('Failed to delete', $target, 'danger');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -117,8 +117,8 @@
|
||||
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; }
|
||||
if (!subject || !message) { app.messages.action('Subject and message are required.', $compose, 'danger'); return; }
|
||||
if (!filterCheck) { app.messages.action('Choose who to send this to.', $compose, 'danger'); return; }
|
||||
const filterType = filterCheck.value;
|
||||
|
||||
let filter_value = '';
|
||||
@@ -129,7 +129,7 @@
|
||||
// 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');
|
||||
const confirmed = await app.messages.confirm(`Send this notification to ${label}?`, $compose, 'warning');
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
@@ -179,7 +179,12 @@
|
||||
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; }
|
||||
if (!content) {
|
||||
msgEl.className = 'alert alert-danger mt-2';
|
||||
msgEl.textContent = 'Terms of Service text cannot be empty.';
|
||||
msgEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
app.tos.update({content, resetAcceptance}, function(error, data) {
|
||||
if (error) {
|
||||
|
||||
@@ -32,10 +32,37 @@
|
||||
return value;
|
||||
}
|
||||
|
||||
// app_sso_service_account is a marker group: membership hides an account
|
||||
// from the Users page's People tab entirely (see users.ejs), which is
|
||||
// exactly right for a non-person account but has silently made a real
|
||||
// person's account look "gone" before (nothing else about it changes).
|
||||
// Everywhere else in this dropdown just fires the PUT directly; only
|
||||
// this one group gets a confirmation first.
|
||||
function addMemberClick(event, groupCN, uid, el){
|
||||
event.preventDefault();
|
||||
const $el = $(el);
|
||||
(async function(){
|
||||
if (groupCN === 'app_sso_service_account') {
|
||||
const ok = await app.messages.confirm(
|
||||
`Mark "${uid}" as a service account? This hides them from the Users page's People tab (Service Accounts tab only) — only do this for a non-person account.`,
|
||||
$el.closest('.card'), 'warning'
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
try {
|
||||
const data = await app.api.put(`group/${groupCN}/${uid}`, {});
|
||||
await addedUser(data.message, groupCN, uid, $el);
|
||||
} catch(e) {
|
||||
app.messages.action(e.message || 'Failed to add member', $el.closest('.card'), 'danger');
|
||||
}
|
||||
})();
|
||||
return false;
|
||||
}
|
||||
|
||||
async function addedUser(message, group, user, $form){
|
||||
let data = await app.group.get(group);
|
||||
$.scope.groupCard.update('cn', group, processGroup(data.results));
|
||||
app.util.actionMessage(message, $("#group-card-"+group), 'success');
|
||||
app.messages.action(message, $("#group-card-"+group), 'success');
|
||||
$('a[href="#'+$form.closest('.tab-pane').attr('id')+'"]').tab('show');
|
||||
setTimeout(function(group){
|
||||
$("body,html").animate({ scrollTop: $("#group-card-" + group).offset().top }, 0);
|
||||
@@ -73,44 +100,44 @@
|
||||
async function removeMember(groupCN, uid, btn) {
|
||||
const $item = $(btn).closest('li');
|
||||
$item.addClass('list-group-item-warning');
|
||||
const confirmed = await app.util.actionConfirm(`Remove "${uid}" from "${groupCN}"?`, $item, 'warning');
|
||||
const confirmed = await app.messages.confirm(`Remove "${uid}" from "${groupCN}"?`, $item, 'warning');
|
||||
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
|
||||
try {
|
||||
const data = await app.api.delete(`group/${groupCN}/${uid}`);
|
||||
const groupData = await app.group.get(groupCN);
|
||||
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
|
||||
app.util.actionMessage(data.message, $('#group-card-' + groupCN), 'success');
|
||||
app.messages.action(data.message, $('#group-card-' + groupCN), 'success');
|
||||
} catch(e) {
|
||||
$item.removeClass('list-group-item-warning');
|
||||
app.util.actionMessage(e.message || 'Failed to remove member', $('#group-card-' + groupCN), 'danger');
|
||||
app.messages.action(e.message || 'Failed to remove member', $('#group-card-' + groupCN), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function removeOwner(groupCN, uid, btn) {
|
||||
const $item = $(btn).closest('li');
|
||||
$item.addClass('list-group-item-warning');
|
||||
const confirmed = await app.util.actionConfirm(`Remove "${uid}" as owner of "${groupCN}"?`, $item, 'warning');
|
||||
const confirmed = await app.messages.confirm(`Remove "${uid}" as owner of "${groupCN}"?`, $item, 'warning');
|
||||
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
|
||||
try {
|
||||
const data = await app.api.delete(`group/owner/${groupCN}/${uid}`);
|
||||
const groupData = await app.group.get(groupCN);
|
||||
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
|
||||
app.util.actionMessage(data.message, $('#group-card-' + groupCN), 'success');
|
||||
app.messages.action(data.message, $('#group-card-' + groupCN), 'success');
|
||||
} catch(e) {
|
||||
$item.removeClass('list-group-item-warning');
|
||||
app.util.actionMessage(e.message || 'Failed to remove owner', $('#group-card-' + groupCN), 'danger');
|
||||
app.messages.action(e.message || 'Failed to remove owner', $('#group-card-' + groupCN), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteGroup(cn, btn) {
|
||||
const $card = $(btn).closest('.card');
|
||||
const confirmed = await app.util.actionConfirm(`Delete group "${cn}"?`, $card, 'danger');
|
||||
const confirmed = await app.messages.confirm(`Delete group "${cn}"?`, $card, 'danger');
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await app.api.delete(`group/${cn}`);
|
||||
$.scope.groupCard.remove('cn', cn);
|
||||
} catch(e) {
|
||||
app.util.actionMessage(e.message || 'Failed to delete group', $card, 'danger');
|
||||
app.messages.action(e.message || 'Failed to delete group', $card, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,7 +241,7 @@
|
||||
</button>
|
||||
<div class="dropdown-menu shadow-lg" aria-labelledby="group_add_member">
|
||||
{{ #toAdd }}{{#.}}
|
||||
<a class="dropdown-item" action="group/{{groupCN}}/{{uid}}" method="put" onclick="formAJAX(this)" evalAJAX="addedUser(data.message, '{{groupCN}}', '{{uid}}', $form);">
|
||||
<a class="dropdown-item" href="#" onclick="return addMemberClick(event, '{{groupCN}}', '{{uid}}', this);">
|
||||
<i class="fa-solid fa-user"></i> {{uid}}
|
||||
</a>
|
||||
{{/.}}{{ /toAdd }}
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
function startImpersonate(uid){
|
||||
app.impersonate.create(uid, function(error, data){
|
||||
if(error){
|
||||
alert('Could not start impersonation: ' + (data && data.message ? data.message : 'Unknown error'));
|
||||
app.messages.toast('Could not start impersonation: ' + (data && data.message ? data.message : 'Unknown error'), 'danger');
|
||||
return;
|
||||
}
|
||||
$('#impersonateModalTitle').text(data.uid);
|
||||
@@ -79,7 +79,7 @@ function startImpersonate(uid){
|
||||
$('#impersonateStopBtn').off('click').on('click', function(){
|
||||
app.impersonate.revoke(data.uid, function(err){
|
||||
$('#impersonateModal').modal('hide');
|
||||
if(!err) app.util.actionMessage('Impersonation ended for ' + data.uid, $('body'), 'success');
|
||||
if(!err) app.messages.action('Impersonation ended for ' + data.uid, $('body'), 'success');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<%- include('top') %>
|
||||
<script type="text/javascript">
|
||||
function tableAJAX(message){
|
||||
app.util.actionMessage(message);
|
||||
app.messages.action(message);
|
||||
}
|
||||
|
||||
$(document).ready(function(){
|
||||
|
||||
@@ -131,6 +131,6 @@
|
||||
});
|
||||
|
||||
function requestAccess(id) {
|
||||
app.util.alert('Access Request', 'This feature is coming soon!', 'info');
|
||||
app.modal.open({title: 'Access Request', bodyHtml: 'This feature is coming soon!'});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -7,6 +7,22 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Landing here with no explanation ("why am I on the SSO login page?") is
|
||||
// exactly what happens when another app's "Log in with SSO" button sends
|
||||
// an unauthenticated user through /oauth/authorize, which bounces them
|
||||
// here with ?redirect=. Tell them what's happening instead of leaving it
|
||||
// a mystery.
|
||||
$(document).ready(function(){
|
||||
var redirect = <%- JSON.stringify(redirect || '') %>;
|
||||
if(redirect){
|
||||
var isOauth = /\/oauth\/authorize/.test(redirect);
|
||||
var message = isOauth
|
||||
? 'Log in to continue — an application is requesting access to your account.'
|
||||
: "Log in to continue to what you were doing — you'll be sent back afterward.";
|
||||
app.messages.action(message, $('.card').first(), 'info');
|
||||
}
|
||||
});
|
||||
|
||||
function setOtpMethod(method) {
|
||||
$('#otpMethodInput').val(method);
|
||||
$('#otpMethodEmail').toggleClass('active', method === 'email').toggleClass('btn-secondary', method === 'email').toggleClass('btn-outline-secondary', method !== 'email');
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
app.api.post('oauth/authorize', oauthParams, function(error, data){
|
||||
if(error){
|
||||
$btn.prop('disabled', false).html('<i class="fa-solid fa-check"></i> Allow');
|
||||
app.util.actionMessage(data.message || 'Authorization failed.', $('#authorize-card'), 'danger');
|
||||
app.messages.action(data.message || 'Authorization failed.', $('#authorize-card'), 'danger');
|
||||
return;
|
||||
}
|
||||
window.location.href = data.redirect_url;
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
async function acceptTos() {
|
||||
var checkbox = document.getElementById('tosCheckbox');
|
||||
if (!checkbox.checked) {
|
||||
alert('Please read and check the box to accept the Terms of Service.');
|
||||
app.messages.toast('Please read and check the box to accept the Terms of Service.', 'danger');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -50,14 +50,14 @@
|
||||
document.getElementById('section-tos').style.display = 'none';
|
||||
checkAllDone();
|
||||
} catch(e) {
|
||||
alert('Could not save TOS acceptance. Please try again.');
|
||||
app.messages.toast('Could not save TOS acceptance. Please try again.', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDob() {
|
||||
var dob = document.getElementById('dobInput').value;
|
||||
if (!dob) {
|
||||
alert('Please enter your date of birth.');
|
||||
app.messages.toast('Please enter your date of birth.', 'danger');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -73,7 +73,7 @@
|
||||
document.getElementById('section-dob').style.display = 'none';
|
||||
checkAllDone();
|
||||
} catch(e) {
|
||||
alert('Could not save date of birth. Please try again.');
|
||||
app.messages.toast('Could not save date of birth. Please try again.', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,11 +81,11 @@
|
||||
var pw = document.getElementById('pwInput').value;
|
||||
var pw2 = document.getElementById('pwInput2').value;
|
||||
if (!pw || pw.length < 5) {
|
||||
alert('Password must be at least 5 characters.');
|
||||
app.messages.toast('Password must be at least 5 characters.', 'danger');
|
||||
return;
|
||||
}
|
||||
if (pw !== pw2) {
|
||||
alert('Passwords do not match.');
|
||||
app.messages.toast('Passwords do not match.', 'danger');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -101,7 +101,7 @@
|
||||
document.getElementById('section-password').style.display = 'none';
|
||||
checkAllDone();
|
||||
} catch(e) {
|
||||
alert('Could not change password. Please try again.');
|
||||
app.messages.toast('Could not change password. Please try again.', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,10 +27,10 @@
|
||||
|
||||
async function removeFromGroup(cn, btn){
|
||||
const $row = $(btn).closest('tr');
|
||||
const confirmed = await app.util.actionConfirm(`Remove ${currentUser.uid} from "${cn}"?`, $row, 'warning');
|
||||
const confirmed = await app.messages.confirm(`Remove ${currentUser.uid} from "${cn}"?`, $row, 'warning');
|
||||
if (!confirmed) return;
|
||||
app.api.delete('group/' + encodeURIComponent(cn) + '/' + encodeURIComponent(currentUser.uid), function(error, data){
|
||||
if(error){ app.util.actionMessage((data && data.message) || 'Failed to remove from group', $row, 'danger'); return; }
|
||||
if(error){ app.messages.action((data && data.message) || 'Failed to remove from group', $row, 'danger'); return; }
|
||||
$.scope.mygroups.remove('cn', cn);
|
||||
});
|
||||
}
|
||||
@@ -43,7 +43,7 @@
|
||||
for(const cn of cns){
|
||||
await new Promise(function(resolve){
|
||||
app.api.put('group/' + encodeURIComponent(cn) + '/' + encodeURIComponent(currentUser.uid), {}, function(error, data){
|
||||
if(error) app.util.actionMessage((data && data.message) || `Failed to add to "${cn}"`, $card, 'danger');
|
||||
if(error) app.messages.action((data && data.message) || `Failed to add to "${cn}"`, $card, 'danger');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
@@ -64,10 +64,10 @@
|
||||
|
||||
async function removePersonalGroupMember(memberUid, btn){
|
||||
const $row = $(btn).closest('tr');
|
||||
const confirmed = await app.util.actionConfirm(`Remove ${memberUid} from ${currentUser.uid}'s group?`, $row, 'warning');
|
||||
const confirmed = await app.messages.confirm(`Remove ${memberUid} from ${currentUser.uid}'s group?`, $row, 'warning');
|
||||
if (!confirmed) return;
|
||||
app.api.delete('user/' + encodeURIComponent(currentUser.uid) + '/group-member/' + encodeURIComponent(memberUid), function(error, data){
|
||||
if(error){ app.util.actionMessage((data && data.message) || 'Failed to remove from group', $row, 'danger'); return; }
|
||||
if(error){ app.messages.action((data && data.message) || 'Failed to remove from group', $row, 'danger'); return; }
|
||||
$.scope.personalGroupMembers.remove('uid', memberUid);
|
||||
});
|
||||
}
|
||||
@@ -80,7 +80,7 @@
|
||||
for(const uid of uids){
|
||||
await new Promise(function(resolve){
|
||||
app.api.put('user/' + encodeURIComponent(currentUser.uid) + '/group-member/' + encodeURIComponent(uid), {}, function(error, data){
|
||||
if(error) app.util.actionMessage((data && data.message) || `Failed to add "${uid}"`, $card, 'danger');
|
||||
if(error) app.messages.action((data && data.message) || `Failed to add "${uid}"`, $card, 'danger');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
@@ -176,7 +176,7 @@
|
||||
|
||||
async function toggleActive(uid, active){
|
||||
app.user.setActive(uid, active, async function(error, data){
|
||||
if(error) return alert('Failed to update user status');
|
||||
if(error) return app.messages.toast('Failed to update user status', 'danger');
|
||||
currentUser = await determinUser();
|
||||
renderProfile(currentUser);
|
||||
});
|
||||
@@ -184,11 +184,11 @@
|
||||
|
||||
async function deleteUser(uid, btn){
|
||||
const $card = $(btn).closest('.card');
|
||||
const confirmed = await app.util.actionConfirm(`Delete user "${uid}"?`, $card, 'warning');
|
||||
const confirmed = await app.messages.confirm(`Delete user "${uid}"?`, $card, 'warning');
|
||||
if (!confirmed) return;
|
||||
app.api.delete('user/' + uid, function(error, data){
|
||||
if (error) {
|
||||
app.util.actionMessage(data.message || 'Failed to delete user', $card, 'danger');
|
||||
app.messages.action(data.message || 'Failed to delete user', $card, 'danger');
|
||||
return;
|
||||
}
|
||||
window.location.href = '/users';
|
||||
@@ -662,21 +662,21 @@
|
||||
async function revokeToken(id, name, btn){
|
||||
var $card = $(btn).closest('.card');
|
||||
$card.addClass('table-warning');
|
||||
var confirmed = await app.util.actionConfirm('Revoke API token "' + name + '"? It stops working immediately.', $card, 'warning');
|
||||
var confirmed = await app.messages.confirm('Revoke API token "' + name + '"? It stops working immediately.', $card, 'warning');
|
||||
$card.removeClass('table-warning');
|
||||
if(!confirmed) return;
|
||||
app.apiToken.remove({id: id}, function(error, data){
|
||||
if(error){ app.util.actionMessage('Error: ' + data.message, $card, 'danger'); return; }
|
||||
if(error){ app.messages.action('Error: ' + data.message, $card, 'danger'); return; }
|
||||
$.scope.apiTokenCard.remove('id', id);
|
||||
});
|
||||
}
|
||||
|
||||
async function rotateToken(id, name, btn){
|
||||
var $card = $(btn).closest('.card');
|
||||
var confirmed = await app.util.actionConfirm('Rotate API token "' + name + '"? The old token stops working immediately.', $card, 'warning');
|
||||
var confirmed = await app.messages.confirm('Rotate API token "' + name + '"? The old token stops working immediately.', $card, 'warning');
|
||||
if(!confirmed) return;
|
||||
app.apiToken.rotate({id: id}, function(error, data){
|
||||
if(error){ app.util.actionMessage('Error: ' + data.message, $card, 'danger'); return; }
|
||||
if(error){ app.messages.action('Error: ' + data.message, $card, 'danger'); return; }
|
||||
showSecret(data.token);
|
||||
tableAJAX();
|
||||
});
|
||||
@@ -700,7 +700,7 @@
|
||||
expires_in_days: $('#edit-expires_in_days').val(),
|
||||
};
|
||||
app.apiToken.update(payload, function(error, data){
|
||||
if(error){ app.util.actionMessage((data && data.message) || 'Update failed.', $msg.parent(), 'danger'); return; }
|
||||
if(error){ app.messages.action((data && data.message) || 'Update failed.', $msg.parent(), 'danger'); return; }
|
||||
editModal.hide();
|
||||
tableAJAX();
|
||||
});
|
||||
|
||||
@@ -21,9 +21,11 @@
|
||||
<script type="text/javascript" src="/static-modules/@fortawesome/fontawesome-free/js/all.min.js"></script>
|
||||
<script type="text/javascript" src='/static-modules/mustache/mustache.min.js'></script>
|
||||
<script type="text/javascript" src='/static-modules/jq-repeat/dist/js/jq-repeat.js'></script>
|
||||
<script type="text/javascript" src='/static/lib/js/val.js'></script>
|
||||
<script type="text/javascript" src="/static-modules/moment/moment.js"></script>
|
||||
<script type="text/javascript" src="/static/lib/js/app-base.js"></script>
|
||||
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.messages.js"></script>
|
||||
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.modal.js"></script>
|
||||
<script type="text/javascript" src="/static-modules/@simpleworkjs/frontend/lib/app.validate.js"></script>
|
||||
<script type="text/javascript" src="/static/js/app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
function renderUsers(){
|
||||
app.user.list(function(error, data){
|
||||
if(error){
|
||||
app.util.actionMessage(data.message, $('#tab-people'), 'danger');
|
||||
app.messages.action(data.message, $('#tab-people'), 'danger');
|
||||
return;
|
||||
}
|
||||
$.scope.userRow.empty();
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
function toggleActive(uid, active){
|
||||
app.user.setActive(uid, active, function(error, data){
|
||||
if(error) return alert('Failed to update user status');
|
||||
if(error) return app.messages.toast('Failed to update user status', 'danger');
|
||||
renderUsers();
|
||||
});
|
||||
}
|
||||
@@ -128,7 +128,7 @@
|
||||
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');
|
||||
let confirmation = await app.messages.confirm('Revoke selected invite token?', $thisRow, 'warning');
|
||||
if(!confirmation){
|
||||
$thisRow.removeClass('table-warning');
|
||||
return;
|
||||
@@ -137,7 +137,7 @@
|
||||
await app.api.delete(`user/invite/${tokenId}`);
|
||||
loadInvites();
|
||||
} catch(e) {
|
||||
alert('Failed to revoke invite.');
|
||||
app.messages.action('Failed to revoke invite.', $thisRow, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,12 +153,12 @@
|
||||
async function deleteUser(uid, btn){
|
||||
const $row = $(btn).closest('tr');
|
||||
$row.addClass('table-warning');
|
||||
const confirmed = await app.util.actionConfirm(`Delete user "${uid}"?`, $row, 'warning');
|
||||
const confirmed = await app.messages.confirm(`Delete user "${uid}"?`, $row, 'warning');
|
||||
$row.removeClass('table-warning');
|
||||
if (!confirmed) return;
|
||||
app.api.delete('user/' + uid, function(error, data){
|
||||
if (error) {
|
||||
app.util.actionMessage(data.message || 'Failed to delete user', $row, 'danger');
|
||||
app.messages.action(data.message || 'Failed to delete user', $row, 'danger');
|
||||
return;
|
||||
}
|
||||
renderUsers();
|
||||
|
||||