Compare commits

..

27 Commits

Author SHA1 Message Date
wmantly 69883836e1 Merge pull request #119 from theta42/release/1.7.0
Release 1.7.0
2026-07-28 13:35:05 -04:00
wmantly 17df21041a Release 1.7.0: fresh-install fixes (loading message, missing messages, login context, directory UX)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 13:30:31 -04:00
wmantly c19fffe3c9 Merge pull request #118 from theta42/feat/directory-tree-only-and-click-detail
Directory: tree view is now the only view; click a name for detail
2026-07-28 13:29:45 -04:00
wmantly b6abfe8f03 Directory: tree view is now the only view; click a name for detail
- Removed the list/tree view toggle -- tree (with indentation/parent
  arrows) is always used. Simplifies renderTable() back down to one
  code path instead of branching on a view mode nobody was toggling
  away from in practice.
- Clicking a resource's name now opens the same modal the pencil/edit
  button does, rather than requiring the small icon click. The edit
  modal already surfaces full detail (parent, addresses, OAuth config,
  groups, edges) for every resource kind, so this reuses it rather than
  building a second, read-only view that would drift from the real one.

Verified live: tree view renders correctly with no toggle present, and
clicking a name (tested on the theta-proxy OAuth resource) opens the
detail modal with the correct parent already selected -- also
confirming the earlier "OAuth client has no parent" fix end-to-end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 13:27:12 -04:00
wmantly 420ccfab3b Merge pull request #117 from theta42/feat/login-redirect-context
Explain why the user landed on the login page
2026-07-28 13:12:31 -04:00
wmantly 8ed4505dc0 Explain why the user landed on the login page
Landing here with ?redirect= and no explanation is exactly what happens
when another app's "Log in with SSO" sends an unauthenticated user
through /oauth/authorize, which bounces them here. Shows a contextual
banner: a specific message when the redirect target is an OAuth
authorize URL, a generic "you'll be sent back" message otherwise.

Verified live for both cases (OAuth-authorize redirect and a plain
redirect) against a local stack.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 13:09:53 -04:00
wmantly 451054f0c2 Merge pull request #116 from theta42/fix/loading-message-and-missing-messages
Fix HTML-escaped loading indicator and missing success messages
2026-07-28 13:07:43 -04:00
wmantly 3a46680c8b Fix HTML-escaped loading indicator and missing success messages
Two regressions surfaced by a fresh production install:

- formAJAX's "loading" indicator passed a raw <div class="spinner-border">
  string to app.messages.action, which HTML-escapes its message by design
  (@simpleworkjs/frontend) -- so every form submit briefly showed the
  literal markup as text instead of a spinner. Replaced with plain text
  ("Saving…"), which needs no escaping workaround.

- POST /api/user/ (create) and PUT /api/user/password didn't include a
  `message` field, so the success toast/banner rendered with an empty
  body -- a green notification with nothing in it right after adding a
  user. Added messages matching the convention already used by every
  other route in this file (activate/deactivate, group membership, etc).

Verified live: created a user through the actual modal, confirmed the
POST response now carries a message, and confirmed app.messages.toast
renders plain text cleanly with no escaping artifacts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 12:52:09 -04:00
wmantly 1b0418e42e Merge pull request #115 from theta42/release/1.6.3
Release 1.6.3
2026-07-28 01:05:12 -04:00
wmantly 4e3aa082d3 Release 1.6.3: fix group-membership cache invalidation, add service-account guardrail
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 01:02:21 -04:00
wmantly 6cb8b259e2 Merge pull request #114 from theta42/ux/service-account-add-confirm
Warn before adding a member to app_sso_service_account
2026-07-28 01:01:45 -04:00
wmantly 2532c492f1 Warn before adding a member to app_sso_service_account
app_sso_service_account is a marker group: membership hides an account
from the Users page's People tab entirely (users.ejs filters it out),
which is exactly right for a non-person account but has no guardrail
against adding a real person by mistake -- which just happened in
production (see #113) and looked exactly like the account had vanished.

Adding a member to any other group via this dropdown is unchanged
(fires immediately, no confirmation); only app_sso_service_account now
asks first, via app.messages.confirm.

Verified live against a local stack: confirmation shows the right
warning, Cancel leaves the group untouched, Confirm adds the member
normally, and every other group's add-member flow is unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 00:59:27 -04:00
wmantly fdc045e166 Merge pull request #113 from theta42/fix/group-cache-invalidation
Fix: group membership changes didn't invalidate the User cache
2026-07-28 00:46:46 -04:00
wmantly 0c2f38f0fe Fix: group membership changes didn't invalidate the User cache
routes/group.js's add/removeMember never called User.clearCache(), unlike
the isServiceAccount handling in routes/user.js (which does this
deliberately, with a comment explaining exactly why). isServiceAccount is
derived at User.get() time from app_sso_service_account membership and
cached for 5 minutes -- so adding or removing a user from ANY group via
this route left group-derived state (isServiceAccount, and by extension
anything else that reads memberOf off a cached User) stale for up to 5
minutes.

In production this manifested as a real user's account appearing to
"vanish": users.ejs's People tab filters out anything with
isServiceAccount truthy, so once that user's membership in
app_sso_service_account changed, they'd disappear from the tab anyone
actually looks at for up to 5 minutes -- looking exactly like data loss,
though the account was never touched. Found by investigating a live "lost
users" report: the account had isServiceAccount: 'yes' and was in fact
still fully present, just hidden.

This does not explain how the account came to be a member of
app_sso_service_account in the first place (unresolved -- possibly a
manual/accidental group-membership change via the Groups UI, which has no
guardrail against adding a real person to what's meant to be a marker
group for non-person accounts). It does fix a real correctness gap: any
admin group-membership change now takes effect immediately instead of on
a timer.

Verified against a real LDAP+Redis harness: the new test fails on the
unfixed code (stale isServiceAccount immediately after the PUT) and
passes with the fix. Full suite: 189/191 passing (2 pre-existing skips).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 00:44:11 -04:00
wmantly fcba782ac7 Merge pull request #112 from theta42/release/1.6.2
Release 1.6.2
2026-07-28 00:20:32 -04:00
wmantly 6162c6d8a1 Release 1.6.2: fix OAuth client DELETE, add regression tests
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 00:14:44 -04:00
wmantly 3be8c7fde2 Merge pull request #111 from theta42/fix/oauth-client-delete
Fix DELETE /api/oauth/client/🆔 client.remove is not a function
2026-07-27 21:15:07 -04:00
wmantly 3852e9ba62 Add regression test: no native alert()/confirm()/prompt()
Native confirm() blocks all further browser events on the page (found
live, mid browser-automation testing, on directory.ejs's "Rotate Client
Secret" -- it froze the tab). Every call site across the app was removed
in favor of app.messages.action/confirm/toast and app.modal.open; this
static check (scans views/ and public/js|lib/js for bare alert(/confirm(/
prompt() calls) keeps a regression from shipping unnoticed the way the
oauth_client.js DELETE bug just did.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 21:10:49 -04:00
wmantly 7f2c71299f Fix DELETE /api/oauth/client/🆔 client.remove is not a function
OAuthClient wraps @simpleworkjs/orm's Resource model, whose instance
delete method is .delete() -- not .remove(), which is what model-redis's
Table instances (e.g. this app's ApiToken, AuthToken) use. The DELETE
route called the wrong one, so every delete silently 500'd; the route's
try/catch turned it into a plain JSON error response rather than a thrown
exception, and the existing tests' cleanup-only delete calls (afterAll,
end of the rotate test) never checked the response status, so the bug
shipped unnoticed. The Directory Management UI was never affected --
routes/api_directory_admin.js's DELETE routes already used .delete()
correctly throughout.

Found and root-caused live against a real deployment's SSO API, then
reproduced and fixed against a local docker stack with a rebuilt image:
confirmed DELETE returned a genuine 500 before the fix and a real 200 +
404-on-subsequent-GET after.

Adds two dedicated tests (PUT and DELETE persistence, each verified by a
follow-up GET rather than trusting the mutating response alone), and
hardens the existing rotate test's incidental delete call with real
assertions. Verified the new DELETE test fails on the old code and
passes on the fix. Full suite (189 tests, real LDAP + Redis) passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 21:03:34 -04:00
wmantly 18119d54aa Merge pull request #110 from theta42/release/1.6.1
Release 1.6.1
2026-07-27 17:24:13 -04:00
wmantly 487e38f1a4 Release 1.6.1: remove native alert()/confirm() calls
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 17:21:37 -04:00
wmantly 2e011dd383 Merge pull request #109 from theta42/fix/no-native-dialogs
Remove all native alert()/confirm() calls
2026-07-27 16:52:39 -04:00
wmantly 3c12ebba16 Remove all native alert()/confirm() calls
Native confirm() dialogs block browser automation entirely (discovered
via a frozen tab while browser-testing the app.messages/app.modal
adoption), and native alert()/confirm() are visually inconsistent with
the rest of the UI. Replaced every call site with
app.messages.action/confirm/toast:

- directory.ejs: rotateSecret/deleteResource confirms and all inline
  save/add/remove-group/edge error alerts now target #resourceModal's
  actionMessage (or, for deleteResource — called from the outer table
  row, not the modal — the page's own card).
- impersonate_modal.ejs, onboarding.ejs: no local .actionMessage target
  exists on these pages, so their alerts became page-wide toasts.
- executive.ejs: two alerts in sendNotification's validation now use the
  existing $compose target; saveTos's alert now reuses the function's
  own msgEl inline-message element instead of introducing a second
  mechanism.
- users.ejs, profile.ejs, proxy's profile.ejs: toggleActive's alert
  (no row context available at the call site) became a toast;
  revokeInvite/revokeToken/rotateToken use the row/card element already
  in scope.
- app.js: removed app.user.remove and app.oauthClient.remove, which
  contained native confirm() guards and had zero callers anywhere in the
  app — dead code, deleted rather than converted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 16:50:07 -04:00
wmantly ffb2e99199 Merge pull request #108 from theta42/release/1.6.0
Release 1.6.0
2026-07-27 14:18:21 -04:00
wmantly 9d5f106863 Release 1.6.0: adopt @simpleworkjs/frontend messages/modal/validate
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:14:32 -04:00
wmantly 7f00d4c845 Merge pull request #107 from theta42/modernize/simpleworkjs-frontend
Adopt @simpleworkjs/frontend messages/modal/validate modules
2026-07-27 14:05:36 -04:00
wmantly 1d1d29d287 Adopt @simpleworkjs/frontend's messages/modal/validate modules
Replaces the vendored app.util.actionMessage/actionConfirm/alert (the
latter added ad hoc to fix "app.util.alert is not a function") with the
published @simpleworkjs/frontend package: app.messages.action/confirm,
app.modal.open, and app.validate.js (which also replaces the identical
vendored val.js). Gains real HTML-escaping on message content and a toast
fallback when there's no inline .actionMessage target, neither of which
the vendored code had.

app.api/app.auth/app.pubsub/app.socket in app-base.js are untouched —
they're app-specific (dual-mode callback/promise API, auth-token header
injection) and not something the generic frontend package's app.js
provides, so it isn't loaded here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 13:35:51 -04:00
25 changed files with 371 additions and 416 deletions
+39
View File
@@ -4,6 +4,45 @@ 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.7.0] - 2026-07-28
### Fixed
- **`formAJAX`'s loading indicator showed literal HTML** ("&lt;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
+12 -2
View File
@@ -1,12 +1,12 @@
{
"name": "t42-sso-manager",
"version": "1.5.0",
"version": "1.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "t42-sso-manager",
"version": "1.5.0",
"version": "1.5.1",
"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.5",
"@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.5",
"resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.5.tgz",
"integrity": "sha512-PxR7UVPv3gRpdF0WsuAZplF1vYvKsEJQevVPhz9d72U+69vP/OH3tlaAXjtO/apMHfhT1viOPw2gMVOrPSxYZw==",
"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",
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "t42-sso-manager",
"version": "1.5.1",
"version": "1.7.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.5",
"@simpleworkjs/ldap": "^1.0.0",
"@simpleworkjs/orm": "^0.2.8",
"bcrypt": "^6.0.0",
"bootstrap": "^5.3.8",
+2 -16
View File
@@ -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){
+21 -78
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
$.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)
-201
View File
@@ -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)";
}
}
}
});
})();
+9 -2
View File
@@ -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){
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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,
+2 -2
View File
@@ -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);
}
+26
View File
@@ -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', () => {
+45
View File
@@ -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([]);
});
+50 -1
View File
@@ -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 () => {
+62 -62
View File
@@ -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}}
@@ -369,15 +368,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 +400,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);
@@ -714,25 +708,26 @@
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', $('#resourceModal'), '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.', $('#resourceModal'), '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', $('#resourceModal'), 'danger');
}
}
@@ -741,7 +736,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', $('#resourceModal'), 'danger');
try {
const res = await app.api.post('directory-admin/groups', {
resourceId,
@@ -753,10 +748,10 @@
$('#new-group-cn').val('');
} catch (err) {
console.error(err);
alert('Failed to add group');
app.messages.action('Failed to add group', $('#resourceModal'), 'danger');
}
}
async function removeGroup(id) {
try {
await app.api.delete('directory-admin/groups/' + id);
@@ -764,7 +759,7 @@
refreshGroupsUI($('#res-id').val());
} catch (err) {
console.error(err);
alert('Failed to remove group');
app.messages.action('Failed to remove group', $('#resourceModal'), 'danger');
}
}
@@ -774,7 +769,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', $('#resourceModal'), 'danger');
const data = { relation };
if (dir === 'parent') {
@@ -792,10 +787,10 @@
$('#new-edge-target').val('');
} catch (err) {
console.error(err);
alert('Failed to add edge');
app.messages.action('Failed to add edge', $('#resourceModal'), 'danger');
}
}
async function removeEdge(id) {
try {
await app.api.delete('directory-admin/edges/' + id);
@@ -803,18 +798,23 @@
refreshEdgesUI($('#res-id').val());
} catch (err) {
console.error(err);
alert('Failed to remove edge');
app.messages.action('Failed to remove edge', $('#resourceModal'), '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
// #resourceModal — 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>
+9 -4
View File
@@ -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) {
+37 -10
View File
@@ -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 }}
+2 -2
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
<%- include('top') %>
<script type="text/javascript">
function tableAJAX(message){
app.util.actionMessage(message);
app.messages.action(message);
}
$(document).ready(function(){
+1 -1
View File
@@ -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>
+16
View File
@@ -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');
+1 -1
View File
@@ -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;
+7 -7
View File
@@ -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');
}
}
+14 -14
View File
@@ -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();
});
+3 -1
View File
@@ -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 -6
View File
@@ -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();