0e955abc73
* Add Resource audit fields (created/updated by/on) and site-slug group prefixing
Resource had no created_by/created_on/updated_by/updated_on fields at all,
unlike proxy's Host and jump-host's ApiToken which already track this --
needed for the upcoming resource-modal footer. @simpleworkjs/orm has no
auto-timestamp hook, so these are set explicitly in the directory-admin
route handlers on every create/update.
Also: when a host/service resource is created, its two auto-created LDAP
groups (<slug>_access/_admin) now get prefixed with the nearest ancestor
site's slug (via a new Resource.findAncestorSiteSlug walk), so groups from
different sites don't collide/look identical. Falls back to today's
unprefixed naming when a resource has no site ancestor.
Included the checked-in dev inventory.sqlite's ALTER TABLE for the new
columns, since @simpleworkjs/orm's sync() only creates missing tables, never
alters existing ones -- the raw model change alone would have broken every
Resource read/write against this file with "no such column: created_by".
* Migrate Resource modal onto app.modal's tabs/footer/URL, add Children tab
The Directory's resource modal was a separate, hand-rolled, always-in-DOM
Bootstrap modal, independent of the shared app.modal singleton -- migrating
it onto app.modal (now published with tabs/footer/url support in
@simpleworkjs/frontend 0.2.6) is the pilot for standardizing entity modals
across the stack.
- General/Details/Associated LDAP Groups/Children tabs, replacing the old
single long form (Details keeps every kind-conditional container
unchanged; toggleFormFields() didn't need to change at all).
- Footer shows created/updated by/on (via the new Resource audit fields)
and the Save button; Groups/Children tabs are hidden in add-mode since
they need an existing resource id.
- New Children tab lists a resource's existing children (reusing the
already-loaded edges/resourcesById data, no new endpoint) and an "Add
Child Resource" button that reuses openAddModal's existing preset-parent
support. Folded the pre-existing generic "Relationships (Graph Edges)"
section in underneath, under an "advanced" subheading, rather than
dropping it or giving it a 5th tab of its own.
- GET /directory/:slug (mirroring the existing /users/:uid precedent) plus
a client-side app.modal.deepLinkSlug() check makes a resource's modal
linkable and directly loadable.
- Converted the groups/edges lists from jq-repeat to plain manual DOM
rendering: jq-repeat's MutationObserver-based scope (re)registration for
an element that's destroyed and recreated on every modal open runs
asynchronously, so populating synchronously right after open() (as
refreshGroupsUI/refreshEdgesUI must) raced it -- on the second and later
opens, the old scope's destroy() ran after the new data was pushed onto
it, silently discarding it. Manual rendering (matching the new Children
tab) sidesteps the race entirely.
- The #res-name/#res-kind auto-slug handler is now bound via
app.modal.on() (delegated) instead of directly -- a direct bind would
have silently stopped firing after the first Add/Edit, since the modal
body is rebuilt from scratch on every open().
Verified live against the running dev stack: tabs/footer/groups/children
all render and populate correctly (including on a second open, confirming
the jq-repeat race fix), the address bar updates to /directory/{slug} and
reverts on close, browser Back closes the modal via popstate without a
page reload, and a resource created under a Site gets correctly
site-slug-prefixed LDAP groups.
205 lines
7.1 KiB
JavaScript
Executable File
205 lines
7.1 KiB
JavaScript
Executable File
'use strict';
|
|
|
|
const path = require('path');
|
|
var express = require('express');
|
|
var router = express.Router();
|
|
const moment = require('moment');
|
|
const {marked} = require('marked');
|
|
const xss = require('xss');
|
|
const {InviteToken, PasswordResetToken} = require('./../models/token');
|
|
const {Tos} = require('../models/tos');
|
|
const conf = require('@simpleworkjs/conf');
|
|
const buildInfo = require('../utils/build_info');
|
|
const { mountStaticModules } = require('@simpleworkjs/app-stack');
|
|
|
|
const values ={
|
|
title: conf.environment !== 'production' ? `dev` : '',
|
|
titleIcon: conf.environment !== 'production' ? `<i class="fa-brands fa-dev"></i>` : '',
|
|
name: conf.name,
|
|
logo: conf.logo,
|
|
...buildInfo,
|
|
}
|
|
|
|
// List of front end node modules to be served
|
|
// Vendor libraries only change when package versions are bumped (a rebuild),
|
|
// so they're safe to cache aggressively; ETag/Last-Modified (on by default)
|
|
// still cover that rare case with a cheap 304 instead of a stale asset. The
|
|
// app's own JS/CSS/img from public/ gets a shorter maxAge since it changes on
|
|
// every deploy and isn't cache-busted/fingerprinted.
|
|
mountStaticModules(router, {
|
|
root: path.join(__dirname, '..'),
|
|
deps: ['bootstrap', 'mustache', 'jquery', '@fortawesome', 'moment', '@popper', 'jq-repeat', '@simpleworkjs/frontend'],
|
|
});
|
|
|
|
// Public health endpoint for container/orchestration healthchecks.
|
|
// Mounted at / (no auth) in app.js, so this is intentionally unauthenticated.
|
|
router.get('/health', function(req, res) {
|
|
res.json({ status: 'ok' });
|
|
});
|
|
|
|
router.get('/tos', async function(req, res, next) {
|
|
try {
|
|
const tos = await Tos.getCurrent();
|
|
res.render('tos', {...values, tosHtml: xss(marked(tos.content)), tosUpdatedOnFmt: moment(tos.updated_on, 'x').format('MMMM YYYY')});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// Admin dashboard (stats + recent/inactive users) and Notifications
|
|
// (broadcast + history) merged into one page.
|
|
router.get('/executive', function(req, res) {
|
|
res.render('executive', {...values});
|
|
});
|
|
|
|
router.get('/admin', (req, res) => res.redirect(301, '/executive'));
|
|
router.get('/notifications', (req, res) => res.redirect(301, '/executive'));
|
|
router.get('/dashboard', (req, res) => res.redirect(301, '/executive'));
|
|
|
|
router.get('/directory', function(req, res) {
|
|
res.render('directory', {...values});
|
|
});
|
|
|
|
// Linkable deep-link to a single resource's modal, e.g. from the resource
|
|
// modal's app.modal `url` option. Mirrors /users/:uid below: no server-side
|
|
// use of :slug at all -- the client reads location.pathname itself and opens
|
|
// the matching resource's modal once the page's own data has loaded.
|
|
router.get('/directory/:slug', function(req, res) {
|
|
res.render('directory', {...values});
|
|
});
|
|
|
|
// Route removed since it's now in directory
|
|
|
|
router.get('/onboarding', async function(req, res, next) {
|
|
try {
|
|
const tos = await Tos.getCurrent();
|
|
res.render('onboarding', {...values, tosHtml: xss(marked(tos.content))});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
router.get('/', async function(req, res, next) {
|
|
res.render('landing', {...values});
|
|
});
|
|
|
|
router.get('/profile', async function(req, res, next) {
|
|
res.render('profile', {...values});
|
|
});
|
|
|
|
router.get('/users', async function(req, res, next) {
|
|
res.render('users', {...values});
|
|
});
|
|
|
|
router.get('/login', async function(req, res, next) {
|
|
res.render('login', {...values, redirect: req.query.redirect});
|
|
});
|
|
|
|
// OAuth client management and LDAP connection info, merged into one page
|
|
// (tabs) -- both are "how do other apps/hosts plug into this SSO" concerns.
|
|
// LDAP values are derived from the running config + request host rather than
|
|
// hardcoded in a doc, so they're always right for *this* deployment.
|
|
router.get('/integrations', function(req, res, next) {
|
|
const issuer = ((conf.oauth && conf.oauth.issuer) || `${req.protocol}://${req.get('host')}`).replace(/\/$/, '');
|
|
// The public-facing host (from the OAuth issuer). Used for OIDC links.
|
|
const issuerHost = issuer.replace(/^https?:\/\//, '').replace(/:\d+$/, '');
|
|
|
|
// The hostname advertised for direct LDAPS binds may be a separate,
|
|
// internal-only name so admins don't have to port-forward 636 publicly.
|
|
// Defaults to the issuer host to preserve prior behavior.
|
|
const ldapsHost = (conf.ldap && conf.ldap.ldapsHost) || issuerHost;
|
|
const ldapsPort = Number((conf.ldap && conf.ldap.ldapsPort) || 636) || 636;
|
|
|
|
const userBase = (conf.ldap && conf.ldap.userBase) || 'ou=people,dc=example,dc=com';
|
|
const groupBase = (conf.ldap && conf.ldap.groupBase) || 'ou=groups,dc=example,dc=com';
|
|
// The base DN isn't stored as its own config value -- derive it by
|
|
// stripping the leading "ou=...," off userBase (ou=people,dc=example,dc=com
|
|
// -> dc=example,dc=com).
|
|
const baseDn = userBase.replace(/^ou=[^,]+,/i, '');
|
|
|
|
res.render('integrations', {
|
|
...values,
|
|
issuer,
|
|
discoveryUrl: `${issuer}/.well-known/openid-configuration`,
|
|
ldapHost: ldapsHost,
|
|
ldapsUrl: `ldaps://${ldapsHost}:${ldapsPort}`,
|
|
ldapsHostExplicit: !!(conf.ldap && conf.ldap.ldapsHost),
|
|
baseDn,
|
|
userBase,
|
|
groupBase,
|
|
userFilter: (conf.ldap && conf.ldap.userFilter) || '(objectClass=posixAccount)',
|
|
userNameAttribute: (conf.ldap && conf.ldap.userNameAttribute) || 'uid',
|
|
exampleBindDn: `cn=ldapclient,${userBase}`,
|
|
ssoUrl: issuer,
|
|
});
|
|
});
|
|
router.get('/oauth-clients', (req, res) => res.redirect(301, '/integrations'));
|
|
router.get('/ldap-info', (req, res) => res.redirect(301, '/integrations'));
|
|
|
|
// API Tokens is now a section on the Profile page (own profile only).
|
|
router.get('/api-tokens', (req, res) => res.redirect(301, '/'));
|
|
|
|
|
|
|
|
router.get('/users/:uid', function(req, res, next) {
|
|
res.render('profile', {...values});
|
|
});
|
|
|
|
router.get('/groups', function(req, res, next) {
|
|
res.render('groups', {...values});
|
|
});
|
|
|
|
router.get('/token', function(req, res, next) {
|
|
res.render('token', {...values});
|
|
});
|
|
|
|
|
|
|
|
|
|
router.get('/login/resetpassword/:token', async function(req, res, next){
|
|
let token = await PasswordResetToken.get(req.params.token);
|
|
|
|
if(token.is_valid && 86400000+Number(token.created_on) > (new Date).getTime()){
|
|
res.render('reset_password', {token:token, ...values });
|
|
}else{
|
|
next({message: 'token not found', status: 404});
|
|
}
|
|
});
|
|
|
|
router.get('/login/invite/:token/:mailToken', async function(req, res, next){
|
|
try{
|
|
|
|
let token = await InviteToken.get(req.params.token);
|
|
if(token.is_valid && token.mail !== '__NONE__' && token.mail_token === req.params.mailToken){
|
|
token.created_on = moment(token.created_on, 'x').fromNow();
|
|
res.render('invite', {invite: token, ...values});
|
|
}else{
|
|
next({message: 'token not found', status: 404});
|
|
}
|
|
}catch(error){
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
router.get('/login/invite/:token', async function(req, res, next){
|
|
try{
|
|
let token = await InviteToken.get(req.params.token);
|
|
token.created_on = moment(token.created_on, 'x').fromNow();
|
|
|
|
if(token.is_valid){
|
|
res.render('invite_email', {invite: token, ...values});
|
|
}else{
|
|
next({message: 'token not found', status: 404});
|
|
}
|
|
}catch(error){
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
|
|
router.get('/login/*splat', async function(req, res, next) {
|
|
res.render('login', {...values, redirect: req.query.redirect});
|
|
});
|
|
|
|
module.exports = router;
|