Compare commits

...

23 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
18 changed files with 297 additions and 105 deletions
+34
View File
@@ -4,6 +4,40 @@ All notable changes to this project are documented here. Format loosely
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`. correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [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 ## [1.6.0] - 2026-07-27
### Changed ### Changed
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.6.0", "version": "1.7.0",
"description": "A very simple LDAP management and SSO system", "description": "A very simple LDAP management and SSO system",
"author": [ "author": [
{ {
+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){ function changePassword(args, callack){
app.api.put('users/'+ arg.uid || '', args, function(error, data){ app.api.put('users/'+ arg.uid || '', args, function(error, data){
callack(error, data); callack(error, data);
@@ -110,7 +103,7 @@ app.user = (function(app){
return m ? m[1] : dn; return m ? m[1] : dn;
} }
return {list, remove, createInvite, setActive, dnToUid}; return {list, createInvite, setActive, dnToUid};
})(app); })(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){ function update(args, callack){
app.api.put('oauth/client/' + args.client_id, args, function(error, data){ app.api.put('oauth/client/' + args.client_id, args, function(error, data){
callack(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);
app.tos = (function(app){ app.tos = (function(app){
+4 -7
View File
@@ -679,13 +679,10 @@ function formAJAX(btn){
return false; return false;
} }
app.messages.action( // Plain text: app.messages.action HTML-escapes its message (by design,
`<div class="spinner-border" role="status"> // see @simpleworkjs/frontend), so raw markup like a spinner <div> would
<span class="visually-hidden">Loading...</span> // render literally instead of as an element.
</div>`, app.messages.action('Saving…', $form, 'info');
$form,
'info'
);
app.api[method]($form.attr('action'), formData, function(error, data){ app.api[method]($form.attr('action'), formData, function(error, data){
app.messages.action(data.message, $form, error ? 'danger' : 'success'); //re-populate table app.messages.action(data.message, $form, error ? 'danger' : 'success'); //re-populate table
+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 group = await Group.get(req.params.group);
var user = await User.get(req.params.uid); 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({ return res.json({
results: await group.addMember(user), results,
message: `Added user ${req.params.uid} to ${req.params.group} group.` message: `Added user ${req.params.uid} to ${req.params.group} group.`
}); });
}catch(error){ }catch(error){
@@ -98,8 +103,10 @@ router.delete('/:group/:uid', async function(req, res, next){
var group = await Group.get(req.params.group); var group = await Group.get(req.params.group);
var user = await User.get(req.params.uid); var user = await User.get(req.params.uid);
const results = await group.removeMember(user);
User.clearCache();
return res.json({ return res.json({
results: await group.removeMember(user), results,
message: `Removed user ${req.params.uid} from ${req.params.group} group.` message: `Removed user ${req.params.uid} from ${req.params.group} group.`
}); });
}catch(error){ }catch(error){
+1 -1
View File
@@ -97,7 +97,7 @@ router.delete('/:client_id', async function(req, res, next) {
await permission.byGroup(req.user, [ADMIN_GROUP]); await permission.byGroup(req.user, [ADMIN_GROUP]);
const client = await OAuthClient.get(req.params.client_id); const client = await OAuthClient.get(req.params.client_id);
await client.remove(); await client.delete();
return res.json({ return res.json({
client_id: req.params.client_id, 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){ }catch(error){
next(error); next(error);
} }
@@ -107,7 +107,7 @@ router.put('/password', async function(req, res, next){
const verif = await UserVerification.getOrCreate(req.user.uid); const verif = await UserVerification.getOrCreate(req.user.uid);
await verif.update({ password_must_change: false }); await verif.update({ password_must_change: false });
User.clearCache(); User.clearCache();
return res.json({results: result}); return res.json({results: result, message: 'Password changed.'});
}catch(error){ }catch(error){
next(error); next(error);
} }
+26
View File
@@ -151,6 +151,32 @@ describe('Groups — member management', () => {
const members = Array.isArray(group.member) ? group.member : [group.member]; const members = Array.isArray(group.member) ? group.member : [group.member];
expect(members.some(dn => dn && dn.includes(MEMBER_UID))).toBe(false); 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', () => { 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'); 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 () => { 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, // Reproduces exactly what the theta-env bootstrap does: create, list,
// find by name, rotate by the client_id from the list response. Uses a // 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.status).toBe(200);
expect(rotated.body.client_secret).toBeTruthy(); 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 () => { test('GET /:id unknown id returns 404, not 500', async () => {
+52 -52
View File
@@ -15,12 +15,6 @@
<option value="kind">Kind</option> <option value="kind">Kind</option>
<option value="env">Environment</option> <option value="env">Environment</option>
</select> </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()"> <button class="btn btn-sm btn-primary ms-1 shadow-sm" onclick="openAddModal()">
<i class="fas fa-plus"></i> Add Resource <i class="fas fa-plus"></i> Add Resource
</button> </button>
@@ -49,7 +43,12 @@
{{{indentHtml}}} {{{indentHtml}}}
<span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span> <span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
</td> </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> <td>
{{#metadata.isProduction}}<span class="badge bg-danger">Prod</span>{{/metadata.isProduction}} {{#metadata.isProduction}}<span class="badge bg-danger">Prod</span>{{/metadata.isProduction}}
{{^metadata.isProduction}}<span class="badge bg-info">Dev</span>{{/metadata.isProduction}} {{^metadata.isProduction}}<span class="badge bg-info">Dev</span>{{/metadata.isProduction}}
@@ -369,14 +368,13 @@
renderTable(); renderTable();
} catch (err) { } catch (err) {
console.error(err); console.error(err);
alert('Failed to load data'); app.messages.toast('Failed to load data', 'danger');
} }
} }
function renderTable() { function renderTable() {
const filter = $('#search-filter').val().toLowerCase(); const filter = $('#search-filter').val().toLowerCase();
const sort = $('#sort-by').val(); const sort = $('#sort-by').val();
const viewMode = $('input[name="viewMode"]:checked').val();
let filtered = rawResources.filter(r => { let filtered = rawResources.filter(r => {
if (!filter) return true; if (!filter) return true;
@@ -402,41 +400,37 @@
let finalRenderList = []; let finalRenderList = [];
if (viewMode === 'tree') { const map = {};
const map = {}; const roots = [];
const roots = []; filtered.forEach(r => { map[r.id] = { ...r, children: [] }; });
filtered.forEach(r => { map[r.id] = { ...r, children: [] }; });
filtered.forEach(r => { filtered.forEach(r => {
const node = map[r.id]; const node = map[r.id];
if (node.parentId && map[node.parentId]) { if (node.parentId && map[node.parentId]) {
map[node.parentId].children.push(node); map[node.parentId].children.push(node);
} else { } else {
roots.push(node); roots.push(node);
} }
}); });
const flatten = (nodes, depth) => { const flatten = (nodes, depth) => {
nodes.forEach(n => { nodes.forEach(n => {
let indentHtml = ''; let indentHtml = '';
for(let i = 0; i < depth; i++) { for(let i = 0; i < depth; i++) {
indentHtml += '<span style="display:inline-block; width: 1.5rem;"></span>'; indentHtml += '<span style="display:inline-block; width: 1.5rem;"></span>';
} }
if (depth > 0) { if (depth > 0) {
indentHtml += '<i class="fa-solid fa-turn-up fa-rotate-90 text-muted me-2"></i>'; indentHtml += '<i class="fa-solid fa-turn-up fa-rotate-90 text-muted me-2"></i>';
} }
n.indentHtml = indentHtml; n.indentHtml = indentHtml;
finalRenderList.push(n); finalRenderList.push(n);
if (n.children.length > 0) { if (n.children.length > 0) {
flatten(n.children, depth + 1); flatten(n.children, depth + 1);
} }
}); });
}; };
flatten(roots, 0); flatten(roots, 0);
} else {
finalRenderList = filtered.map(r => ({ ...r, indentHtml: '' }));
}
$.scope.resources.empty(); $.scope.resources.empty();
for (const r of finalRenderList) { for (const r of finalRenderList) {
@@ -718,21 +712,22 @@
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
alert(err.message || 'Failed to save'); app.messages.action(err.message || 'Failed to save', $('#resourceModal'), 'danger');
} }
} }
async function rotateSecret() { async function rotateSecret() {
const id = $('#res-id').val(); const id = $('#res-id').val();
if (!id) return; 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 { try {
const res = await app.api.post(`directory-admin/resources/${id}/rotate-secret`); const res = await app.api.post(`directory-admin/resources/${id}/rotate-secret`);
app.modal.open({title: 'Secret Rotated', bodyHtml: 'Save this NEW client secret, it will not be shown again: <br><br><code>' + res.secret + '</code>'}); 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) { } catch (err) {
console.error(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 groupCn = $('#new-group-cn').val().trim();
const accessLevel = $('#new-group-level').val(); 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 { try {
const res = await app.api.post('directory-admin/groups', { const res = await app.api.post('directory-admin/groups', {
resourceId, resourceId,
@@ -753,7 +748,7 @@
$('#new-group-cn').val(''); $('#new-group-cn').val('');
} catch (err) { } catch (err) {
console.error(err); console.error(err);
alert('Failed to add group'); app.messages.action('Failed to add group', $('#resourceModal'), 'danger');
} }
} }
@@ -764,7 +759,7 @@
refreshGroupsUI($('#res-id').val()); refreshGroupsUI($('#res-id').val());
} catch (err) { } catch (err) {
console.error(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 targetId = $('#new-edge-target').val();
const relation = $('#new-edge-relation').val().trim() || 'hosts'; 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 }; const data = { relation };
if (dir === 'parent') { if (dir === 'parent') {
@@ -792,7 +787,7 @@
$('#new-edge-target').val(''); $('#new-edge-target').val('');
} catch (err) { } catch (err) {
console.error(err); console.error(err);
alert('Failed to add edge'); app.messages.action('Failed to add edge', $('#resourceModal'), 'danger');
} }
} }
@@ -803,18 +798,23 @@
refreshEdgesUI($('#res-id').val()); refreshEdgesUI($('#res-id').val());
} catch (err) { } catch (err) {
console.error(err); console.error(err);
alert('Failed to remove edge'); app.messages.action('Failed to remove edge', $('#resourceModal'), 'danger');
} }
} }
async function deleteResource(id) { 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 { try {
await app.api.delete('directory-admin/resources/' + id); await app.api.delete('directory-admin/resources/' + id);
await loadResources(); await loadResources();
} catch (err) { } catch (err) {
console.error(err); console.error(err);
alert('Failed to delete'); app.messages.action('Failed to delete', $target, 'danger');
} }
} }
</script> </script>
+8 -3
View File
@@ -117,8 +117,8 @@
const msgEl = document.getElementById('notif-result'); const msgEl = document.getElementById('notif-result');
const $compose = $('#notif-subject').closest('.card-body'); const $compose = $('#notif-subject').closest('.card-body');
if (!subject || !message) { alert('Subject and message are required.'); return; } if (!subject || !message) { app.messages.action('Subject and message are required.', $compose, 'danger'); return; }
if (!filterCheck) { alert('Choose who to send this to.'); return; } if (!filterCheck) { app.messages.action('Choose who to send this to.', $compose, 'danger'); return; }
const filterType = filterCheck.value; const filterType = filterCheck.value;
let filter_value = ''; let filter_value = '';
@@ -179,7 +179,12 @@
const resetAcceptance = document.getElementById('tos-reset-acceptance').checked; const resetAcceptance = document.getElementById('tos-reset-acceptance').checked;
const msgEl = document.getElementById('tos-result'); 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) { app.tos.update({content, resetAcceptance}, function(error, data) {
if (error) { if (error) {
+28 -1
View File
@@ -32,6 +32,33 @@
return value; 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){ async function addedUser(message, group, user, $form){
let data = await app.group.get(group); let data = await app.group.get(group);
$.scope.groupCard.update('cn', group, processGroup(data.results)); $.scope.groupCard.update('cn', group, processGroup(data.results));
@@ -214,7 +241,7 @@
</button> </button>
<div class="dropdown-menu shadow-lg" aria-labelledby="group_add_member"> <div class="dropdown-menu shadow-lg" aria-labelledby="group_add_member">
{{ #toAdd }}{{#.}} {{ #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}} <i class="fa-solid fa-user"></i> {{uid}}
</a> </a>
{{/.}}{{ /toAdd }} {{/.}}{{ /toAdd }}
+1 -1
View File
@@ -68,7 +68,7 @@
function startImpersonate(uid){ function startImpersonate(uid){
app.impersonate.create(uid, function(error, data){ app.impersonate.create(uid, function(error, data){
if(error){ 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; return;
} }
$('#impersonateModalTitle').text(data.uid); $('#impersonateModalTitle').text(data.uid);
+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) { function setOtpMethod(method) {
$('#otpMethodInput').val(method); $('#otpMethodInput').val(method);
$('#otpMethodEmail').toggleClass('active', method === 'email').toggleClass('btn-secondary', method === 'email').toggleClass('btn-outline-secondary', method !== 'email'); $('#otpMethodEmail').toggleClass('active', method === 'email').toggleClass('btn-secondary', method === 'email').toggleClass('btn-outline-secondary', method !== 'email');
+7 -7
View File
@@ -38,7 +38,7 @@
async function acceptTos() { async function acceptTos() {
var checkbox = document.getElementById('tosCheckbox'); var checkbox = document.getElementById('tosCheckbox');
if (!checkbox.checked) { 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; return;
} }
try { try {
@@ -50,14 +50,14 @@
document.getElementById('section-tos').style.display = 'none'; document.getElementById('section-tos').style.display = 'none';
checkAllDone(); checkAllDone();
} catch(e) { } 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() { async function saveDob() {
var dob = document.getElementById('dobInput').value; var dob = document.getElementById('dobInput').value;
if (!dob) { if (!dob) {
alert('Please enter your date of birth.'); app.messages.toast('Please enter your date of birth.', 'danger');
return; return;
} }
try { try {
@@ -73,7 +73,7 @@
document.getElementById('section-dob').style.display = 'none'; document.getElementById('section-dob').style.display = 'none';
checkAllDone(); checkAllDone();
} catch(e) { } 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 pw = document.getElementById('pwInput').value;
var pw2 = document.getElementById('pwInput2').value; var pw2 = document.getElementById('pwInput2').value;
if (!pw || pw.length < 5) { 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; return;
} }
if (pw !== pw2) { if (pw !== pw2) {
alert('Passwords do not match.'); app.messages.toast('Passwords do not match.', 'danger');
return; return;
} }
try { try {
@@ -101,7 +101,7 @@
document.getElementById('section-password').style.display = 'none'; document.getElementById('section-password').style.display = 'none';
checkAllDone(); checkAllDone();
} catch(e) { } catch(e) {
alert('Could not change password. Please try again.'); app.messages.toast('Could not change password. Please try again.', 'danger');
} }
} }
+1 -1
View File
@@ -176,7 +176,7 @@
async function toggleActive(uid, active){ async function toggleActive(uid, active){
app.user.setActive(uid, active, async function(error, data){ 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(); currentUser = await determinUser();
renderProfile(currentUser); renderProfile(currentUser);
}); });
+2 -2
View File
@@ -19,7 +19,7 @@
function toggleActive(uid, active){ function toggleActive(uid, active){
app.user.setActive(uid, active, function(error, data){ 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(); renderUsers();
}); });
} }
@@ -137,7 +137,7 @@
await app.api.delete(`user/invite/${tokenId}`); await app.api.delete(`user/invite/${tokenId}`);
loadInvites(); loadInvites();
} catch(e) { } catch(e) {
alert('Failed to revoke invite.'); app.messages.action('Failed to revoke invite.', $thisRow, 'danger');
} }
} }