feat: hierarchical group & permission model (v1.25.0)
- Add utils/groups.js: the group schema + inheritance resolver (god_admin,
{site}_super_admin, {site}_hosts_*/{site}_apps_* aggregates, per-resource
admin/access/<capability>, meta everyone/{site}_everyone). admin implies
access; capabilities explicit; hosts/apps orthogonal; cross-site isolated.
- permission.js: recognize god_admin (legacy app_super_admin aliased) and add
onResource/requireResource for resource-level checks + everyone meta grants.
- user.js isAdmin: recognize god_admin + site-scoped super/app-admin groups.
- Remove the standalone Groups page (nav + route + view); groups are managed on
adopted Directory resources. Add a /docs/groups help link in the Directory
toolbar (GROUPS.md copied into the SSO docs).
- tests/groups.test.js: full resolver coverage (15 tests).
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,7 @@ const DOCS = {
|
||||
agents: {title: 'Plugins', file: path.join(__dirname, '../../docs/plugins.md')},
|
||||
plugins: {title: 'Plugins', file: path.join(__dirname, '../../docs/plugins.md')},
|
||||
vault: {title: 'Vault Secrets', file: path.join(__dirname, '../../docs/vault.md')},
|
||||
groups: {title: 'Groups & Permissions', file: path.join(__dirname, '../../docs/groups.md')},
|
||||
|
||||
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
|
||||
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
|
||||
|
||||
@@ -188,10 +188,6 @@ 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});
|
||||
});
|
||||
|
||||
@@ -90,7 +90,13 @@ router.get('/me', async function(req, res, next){
|
||||
// same answer in both modes.
|
||||
const groups = await groupCns(user);
|
||||
user.groups = groups;
|
||||
user.isAdmin = groups.includes('app_sso_admin') || groups.includes(permission.SUPER_ADMIN_GROUP);
|
||||
// Console admin under the group model (docs/GROUPS.md §11): god_admin,
|
||||
// a site super admin, the SSO-as-app admin ({site}_app_sso_admin), or the
|
||||
// legacy app_sso_admin/app_super_admin during migration.
|
||||
user.isAdmin = groups.some((g) =>
|
||||
g === 'app_sso_admin' || g === 'app_super_admin' ||
|
||||
g === permission.SUPER_ADMIN_GROUP ||
|
||||
g.endsWith('_super_admin') || g.endsWith('_app_sso_admin'));
|
||||
|
||||
return res.json(user);
|
||||
}catch(error){
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
'use strict';
|
||||
|
||||
const {
|
||||
slugify,
|
||||
resourceGroupCns,
|
||||
aggregateGroupCns,
|
||||
siteSuperAdminCns,
|
||||
siteEveryoneCns,
|
||||
isKnownLevel,
|
||||
levelGrants,
|
||||
hasPermission,
|
||||
GOD_ADMIN,
|
||||
} = require('../utils/groups');
|
||||
|
||||
const HOST = { site: 'Main Office', kind: 'host', slug: 'Web 01' };
|
||||
const APP = { site: 'main-office', kind: 'app', slug: 'emby' };
|
||||
const OTHER_SITE_HOST = { site: 'branch-office', kind: 'host', slug: 'db' };
|
||||
|
||||
describe('slugify', () => {
|
||||
test('lowercases, spaces and underscores become hyphens, no leading/trailing dash', () => {
|
||||
expect(slugify('Web 01')).toBe('web-01');
|
||||
expect(slugify('Main Office')).toBe('main-office');
|
||||
expect(slugify('my_host')).toBe('my-host');
|
||||
expect(slugify(' Mixed CASE--name ')).toBe('mixed-case-name');
|
||||
expect(slugify('')).toBe('');
|
||||
});
|
||||
test('never contains an underscore (the structural delimiter)', () => {
|
||||
expect(slugify('a_b_c')).not.toContain('_');
|
||||
expect(resourceGroupCns('Main Office', 'host', 'Web 01', 'access')).not.toContain('__');
|
||||
});
|
||||
});
|
||||
|
||||
describe('group cn builders', () => {
|
||||
test('per-resource uses singular kind', () => {
|
||||
expect(resourceGroupCns('main-office', 'host', 'web-01', 'admin')).toBe('main-office_host_web-01_admin');
|
||||
expect(resourceGroupCns('main-office', 'app', 'emby', 'access')).toBe('main-office_app_emby_access');
|
||||
});
|
||||
test('aggregate uses plural kind', () => {
|
||||
expect(aggregateGroupCns('main-office', 'host', 'admin')).toBe('main-office_hosts_admin');
|
||||
expect(aggregateGroupCns('main-office', 'app', 'access')).toBe('main-office_apps_access');
|
||||
});
|
||||
test('site super admin + everyone', () => {
|
||||
expect(siteSuperAdminCns('Main Office')).toBe('main-office_super_admin');
|
||||
expect(siteEveryoneCns('main-office')).toBe('main-office_everyone');
|
||||
});
|
||||
test('invalid kind throws', () => {
|
||||
expect(() => resourceGroupCns('s', 'service', 'x', 'admin')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('levels', () => {
|
||||
test('admin/access known; capabilities opaque', () => {
|
||||
expect(isKnownLevel('admin')).toBe(true);
|
||||
expect(isKnownLevel('access')).toBe(true);
|
||||
expect(isKnownLevel('reboot')).toBe(false);
|
||||
expect(isKnownLevel('emby_admin')).toBe(false);
|
||||
});
|
||||
test('admin implies access; access does not imply admin', () => {
|
||||
expect(levelGrants('admin', 'access')).toBe(true);
|
||||
expect(levelGrants('access', 'admin')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasPermission — inheritance', () => {
|
||||
test('god_admin grants everything everywhere', () => {
|
||||
expect(hasPermission([GOD_ADMIN], HOST, 'admin')).toBe(true);
|
||||
expect(hasPermission([GOD_ADMIN], HOST, 'access')).toBe(true);
|
||||
expect(hasPermission([GOD_ADMIN], HOST, 'reboot')).toBe(true);
|
||||
expect(hasPermission([GOD_ADMIN], OTHER_SITE_HOST, 'admin')).toBe(true);
|
||||
});
|
||||
|
||||
test('site super admin grants everything on its site, not other sites', () => {
|
||||
expect(hasPermission(['main-office_super_admin'], HOST, 'admin')).toBe(true);
|
||||
expect(hasPermission(['main-office_super_admin'], HOST, 'reboot')).toBe(true);
|
||||
expect(hasPermission(['main-office_super_admin'], OTHER_SITE_HOST, 'admin')).toBe(false);
|
||||
});
|
||||
|
||||
test('aggregate (all hosts) grants on any host at the site', () => {
|
||||
expect(hasPermission(['main-office_hosts_admin'], HOST, 'admin')).toBe(true);
|
||||
expect(hasPermission(['main-office_hosts_access'], HOST, 'access')).toBe(true);
|
||||
expect(hasPermission(['main-office_hosts_admin'], HOST, 'access')).toBe(true);
|
||||
});
|
||||
|
||||
test('specific host group grants only that host', () => {
|
||||
const cn = resourceGroupCns('main-office', 'host', 'web-01', 'admin');
|
||||
expect(hasPermission([cn], HOST, 'admin')).toBe(true);
|
||||
expect(hasPermission([cn], OTHER_SITE_HOST, 'admin')).toBe(false);
|
||||
});
|
||||
|
||||
test('admin implies access; access does not imply admin', () => {
|
||||
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], HOST, 'access')).toBe(true);
|
||||
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'access')], HOST, 'admin')).toBe(false);
|
||||
});
|
||||
|
||||
test('capabilities are exact — admin does not grant a capability', () => {
|
||||
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'reboot')], HOST, 'reboot')).toBe(true);
|
||||
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], HOST, 'reboot')).toBe(false);
|
||||
// aggregate capability
|
||||
expect(hasPermission(['main-office_hosts_reboot'], HOST, 'reboot')).toBe(true);
|
||||
});
|
||||
|
||||
test('hosts and apps are orthogonal namespaces', () => {
|
||||
const hostAdmin = resourceGroupCns('main-office', 'host', 'web-01', 'admin');
|
||||
expect(hasPermission([hostAdmin], APP, 'access')).toBe(false);
|
||||
const appAdmin = resourceGroupCns('main-office', 'app', 'emby', 'admin');
|
||||
expect(hasPermission([appAdmin], APP, 'access')).toBe(true);
|
||||
});
|
||||
|
||||
test('cross-site isolation', () => {
|
||||
const mainHostAdmin = resourceGroupCns('main-office', 'host', 'web-01', 'admin');
|
||||
expect(hasPermission([mainHostAdmin], OTHER_SITE_HOST, 'access')).toBe(false);
|
||||
expect(hasPermission(['branch-office_hosts_admin'], OTHER_SITE_HOST, 'admin')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
'use strict';
|
||||
|
||||
// Theta42 group & permission model.
|
||||
//
|
||||
// Canonical spec: theta-suite/docs/GROUPS.md. Group names follow a fixed,
|
||||
// parseable structure. The structural delimiter is `_`; site/host/app slugs
|
||||
// never contain it. Aggregates use the plural kind (hosts/apps); per-resource
|
||||
// uses the singular (host/app).
|
||||
//
|
||||
// god_admin global — everything, everywhere
|
||||
// {site}_super_admin everything on the site
|
||||
// {site}_hosts_<level> admin/access/capability on ALL hosts at the site
|
||||
// {site}_hosts_<level>
|
||||
// {site}_host_<slug>_<level> admin/access/capability on ONE host
|
||||
// {site}_apps_<level> ... on ALL apps at the site
|
||||
// {site}_app_<slug>_<level> ... on ONE app
|
||||
// {site}_everyone / everyone meta groups (implicit membership)
|
||||
//
|
||||
// `level` is 'admin', 'access', or an opaque `<capability>`. `admin` implies
|
||||
// `access`; capabilities are explicit and never implied by `admin`. Groups are
|
||||
// `groupOfNames` (RBAC) — no gidNumber; hosts map GIDs on the fly (SSSD).
|
||||
//
|
||||
// This module is pure logic (no LDAP/DB) so it is fully unit-testable. Callers
|
||||
// supply the user's group memberships (e.g. from Group.list(user.dn)).
|
||||
|
||||
const GOD_ADMIN = 'god_admin';
|
||||
const KNOWN_LEVELS = ['admin', 'access'];
|
||||
const KINDS = ['host', 'app'];
|
||||
|
||||
// Normalize a site/host/app slug: lowercase; runs of non-alnum -> '-'; never
|
||||
// contains '_' (the structural delimiter), so group names parse unambiguously.
|
||||
function slugify(name) {
|
||||
return String(name || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
// Validate a kind (host/app) — throw on anything else.
|
||||
function assertKind(kind) {
|
||||
if (!KINDS.includes(kind)) throw new Error(`invalid resource kind: ${kind} (must be host or app)`);
|
||||
}
|
||||
|
||||
// {site}_host_<slug>_<level> / {site}_app_<slug>_<level>
|
||||
function resourceGroupCns(site, kind, slug, level) {
|
||||
assertKind(kind);
|
||||
return `${slugify(site)}_${kind}_${slugify(slug)}_${level}`;
|
||||
}
|
||||
|
||||
// {site}_hosts_<level> / {site}_apps_<level> (plural kind — the aggregate).
|
||||
function aggregateGroupCns(site, kind, level) {
|
||||
assertKind(kind);
|
||||
return `${slugify(site)}_${kind}s_${level}`;
|
||||
}
|
||||
|
||||
// {site}_super_admin
|
||||
function siteSuperAdminCns(site) {
|
||||
return `${slugify(site)}_super_admin`;
|
||||
}
|
||||
|
||||
// {site}_everyone
|
||||
function siteEveryoneCns(site) {
|
||||
return `${slugify(site)}_everyone`;
|
||||
}
|
||||
|
||||
// True if `level` is a known admin/access level (not an opaque capability).
|
||||
function isKnownLevel(level) {
|
||||
return KNOWN_LEVELS.includes(level);
|
||||
}
|
||||
|
||||
// True if holding `level` grants `wanted` (admin implies access).
|
||||
function levelGrants(level, wanted) {
|
||||
if (level === wanted) return true;
|
||||
return level === 'admin' && wanted === 'access';
|
||||
}
|
||||
|
||||
// Resolve whether a user (given `memberOf` — the group cns they belong to) has
|
||||
// `level` on a resource. Applies the inheritance lattice:
|
||||
// god_admin ⊇ {site}_super_admin ⊇ aggregate ⊇ specific; admin ⊇ access.
|
||||
//
|
||||
// memberOf: array of group cns the user is a member of.
|
||||
// resource: { site, kind: 'host'|'app', slug }.
|
||||
// level: 'admin' | 'access' | an opaque capability token.
|
||||
//
|
||||
// Meta-group grants (`everyone` / `{site}_everyone`) are NOT handled here — they
|
||||
// are resource-level grants, resolved by the caller against the resource's own
|
||||
// granted groups (see permission.onResource). This keeps the function pure over
|
||||
// the user's membership only.
|
||||
function hasPermission(memberOf, resource, level) {
|
||||
const site = slugify(resource && resource.site);
|
||||
const kind = resource && resource.kind;
|
||||
const slug = slugify(resource && resource.slug);
|
||||
const set = new Set(memberOf || []);
|
||||
|
||||
if (set.has(GOD_ADMIN)) return true;
|
||||
if (set.has(siteSuperAdminCns(site))) return true;
|
||||
|
||||
if (isKnownLevel(level)) {
|
||||
// admin / access
|
||||
if (set.has(aggregateGroupCns(site, kind, level))) return true;
|
||||
if (set.has(resourceGroupCns(site, kind, slug, level))) return true;
|
||||
if (level === 'access' && hasPermission(memberOf, resource, 'admin')) return true;
|
||||
return false;
|
||||
}
|
||||
// Opaque capability — exact aggregate or specific grant only.
|
||||
if (set.has(aggregateGroupCns(site, kind, level))) return true;
|
||||
if (set.has(resourceGroupCns(site, kind, slug, level))) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
GOD_ADMIN,
|
||||
KNOWN_LEVELS,
|
||||
KINDS,
|
||||
slugify,
|
||||
resourceGroupCns,
|
||||
aggregateGroupCns,
|
||||
siteSuperAdminCns,
|
||||
siteEveryoneCns,
|
||||
isKnownLevel,
|
||||
levelGrants,
|
||||
hasPermission,
|
||||
};
|
||||
@@ -1,10 +1,20 @@
|
||||
'use strict';
|
||||
|
||||
const {Group} = require('../models/group_ldap');
|
||||
const groups = require('./groups');
|
||||
|
||||
const SUPER_ADMIN_GROUP = 'app_super_admin';
|
||||
// The global god-admin group (everything, everywhere). During migration the
|
||||
// legacy `app_super_admin` is recognized as an alias (docs/GROUPS.md §10).
|
||||
const SUPER_ADMIN_GROUP = groups.GOD_ADMIN;
|
||||
const LEGACY_SUPER_ADMIN_ALIASES = ['app_super_admin'];
|
||||
|
||||
let byGroup = async function(user, groups, ownerOf){
|
||||
// True if the user (by resolved member cns) is a global god/super admin.
|
||||
async function isSuperAdmin(memberOfCns) {
|
||||
return memberOfCns.includes(groups.GOD_ADMIN) ||
|
||||
memberOfCns.some((cn) => LEGACY_SUPER_ADMIN_ALIASES.includes(cn));
|
||||
}
|
||||
|
||||
let byGroup = async function(user, checkGroups, ownerOf){
|
||||
// Membership is resolved once, transitively: a user placed in an admin group
|
||||
// through a nested group is as much a member as one listed on it directly.
|
||||
// Checking `group.member.includes(user.dn)` per group -- as this used to --
|
||||
@@ -17,9 +27,9 @@ let byGroup = async function(user, groups, ownerOf){
|
||||
// they still catch direct membership if the resolver is unavailable.
|
||||
}
|
||||
|
||||
if(memberOfCns.includes(SUPER_ADMIN_GROUP)) return true;
|
||||
if(await isSuperAdmin(memberOfCns)) return true;
|
||||
|
||||
for(let group of groups){
|
||||
for(let group of checkGroups){
|
||||
if(memberOfCns.includes(group)) return true;
|
||||
}
|
||||
|
||||
@@ -42,4 +52,46 @@ let byGroup = async function(user, groups, ownerOf){
|
||||
throw error;
|
||||
}
|
||||
|
||||
module.exports = {byGroup, SUPER_ADMIN_GROUP};
|
||||
// Resolve whether a user has `level` on a directory resource under the group
|
||||
// model (see utils/groups.js). Applies the inheritance lattice and the
|
||||
// `everyone`/`{site}_everyone` meta grants when the resource grants them.
|
||||
//
|
||||
// user: the auth user ({ dn, isMachine }).
|
||||
// resource:{ site, kind: 'host'|'app', slug }.
|
||||
// level: 'admin' | 'access' | an opaque capability token.
|
||||
// grantedGroups: optional array of the resource's granted group cns (used only
|
||||
// for meta `everyone` handling). Omit to skip meta grants.
|
||||
async function onResource(user, resource, level, grantedGroups) {
|
||||
let memberOfCns = [];
|
||||
try { memberOfCns = await Group.list(user.dn); } catch (e) { /* ignore */ }
|
||||
|
||||
if (await isSuperAdmin(memberOfCns)) return true;
|
||||
if (groups.hasPermission(memberOfCns, resource, level)) return true;
|
||||
|
||||
// Meta grants: `everyone` / `{site}_everyone` confer access to any
|
||||
// authenticated (non-machine) user when the resource grants them.
|
||||
if (level === 'access' && !user.isMachine && Array.isArray(grantedGroups)) {
|
||||
const siteEveryone = groups.siteEveryoneCns(resource.site);
|
||||
if (grantedGroups.includes('everyone') || grantedGroups.includes(siteEveryone)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Like onResource but throws Insufficient Permission when denied — for guards.
|
||||
async function requireResource(user, resource, level, grantedGroups) {
|
||||
if (await onResource(user, resource, level, grantedGroups)) return;
|
||||
const error = new Error('Insufficient Permission');
|
||||
error.name = 'Insufficient Permission';
|
||||
error.status = 401;
|
||||
throw error;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
byGroup,
|
||||
onResource,
|
||||
requireResource,
|
||||
isSuperAdmin,
|
||||
SUPER_ADMIN_GROUP,
|
||||
LEGACY_SUPER_ADMIN_ALIASES,
|
||||
...groups, // group schema builders (slugify, resourceGroupCns, ...)
|
||||
};
|
||||
|
||||
@@ -41,7 +41,6 @@ module.exports = {
|
||||
// Catalog requires login - it's the end-user view of their accessible resources.
|
||||
{href: '/', icon: 'fa-solid fa-compass', label: 'Catalog', groups: ['login']},
|
||||
{href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['app_sso_admin', 'admin']},
|
||||
{href: '/groups', icon: 'fas fa-users-cog', label: 'Groups', groups: ['app_sso_admin']},
|
||||
{href: '/conf', icon: 'fas fa-cogs', label: 'Configuration', groups: ['app_sso_admin']},
|
||||
{href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']},
|
||||
// Vault requires login - per-user secrets at secret/users/<uid>/*.
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||
<div>
|
||||
<i class="fa-solid fa-server"></i> Directory Management
|
||||
<a href="/docs/groups" class="text-reset ms-1" title="Group & permission model"><i class="fa-solid fa-circle-question"></i></a>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center">
|
||||
<input type="text" id="search-filter" class="form-control form-control-sm shadow-sm" placeholder="Search..." onkeyup="renderTable()" style="width: 200px;">
|
||||
|
||||
@@ -1,406 +0,0 @@
|
||||
<%- include('top') %>
|
||||
|
||||
<script type="text/javascript">
|
||||
var userlist;
|
||||
var allGroups = [];
|
||||
|
||||
// A member DN under the groups base is a nested group, not a person. Both
|
||||
// live in the same `member` attribute, so they have to be told apart here --
|
||||
// otherwise a nested group renders as a user whose name happens to be the
|
||||
// group's, and its remove button calls the user endpoint and 404s.
|
||||
function isGroupDn(dn){
|
||||
return /,ou=groups,/i.test(String(dn));
|
||||
}
|
||||
|
||||
function processGroup(value){
|
||||
if (!Array.isArray(value.member)) value.member = value.member ? [value.member] : [];
|
||||
if (!Array.isArray(value.owner)) value.owner = value.owner ? [value.owner] : [];
|
||||
|
||||
// Split before anything else consumes `member`.
|
||||
value.nested = value.member.filter(isGroupDn).map(function(dn){
|
||||
return {
|
||||
dn: dn,
|
||||
cn: dn.match(/cn=[^,]+/)[0].replace('cn=', ''),
|
||||
groupCN: value.cn
|
||||
};
|
||||
});
|
||||
value.member = value.member.filter(function(dn){ return !isGroupDn(dn); });
|
||||
value.nestedCount = value.nested.length;
|
||||
value.hasNested = value.nestedCount > 0;
|
||||
|
||||
// Candidates to nest: every other group not already nested here. Self is
|
||||
// excluded; deeper loops are refused server-side by Group.wouldCycle,
|
||||
// which is the only place that can see the whole graph.
|
||||
var nestedDns = value.nested.map(function(g){ return g.dn.toLowerCase(); });
|
||||
value.toNest = allGroups.filter(function(g){
|
||||
return g.cn !== value.cn && nestedDns.indexOf(String(g.dn).toLowerCase()) === -1;
|
||||
}).map(function(g){ return {cn: g.cn, groupCN: value.cn}; });
|
||||
|
||||
value.toAdd = userlist.filter(function(user){
|
||||
return !value.member.includes(user.dn);
|
||||
});
|
||||
value.toAddOwner = userlist.filter(function(user){
|
||||
return !value.owner.includes(user.dn);
|
||||
});
|
||||
value.member = value.member.map(function(user){
|
||||
return {
|
||||
dn: user,
|
||||
uid: user.match(/cn=[a-zA-Z0-9\_\-\@\.]+/)[0].replace('cn=', '')
|
||||
};
|
||||
});
|
||||
value.owner = value.owner.map(function(user){
|
||||
return {
|
||||
dn: user,
|
||||
uid: user.match(/cn=[a-zA-Z0-9\_\-\@\.]+/)[0].replace('cn=', '')
|
||||
};
|
||||
});
|
||||
value.memberCount = value.member.length;
|
||||
value.createTimestamp = moment(value.createTimestamp, "YYYYMMDDHHmmssZ").fromNow();
|
||||
value.modifyTimestamp = moment(value.modifyTimestamp, "YYYYMMDDHHmmssZ").fromNow();
|
||||
value.groupCN = value.cn;
|
||||
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.messages.action(message, $("#group-card-"+group), 'success');
|
||||
$('a[href="#'+$form.closest('.tab-pane').attr('id')+'"]').tab('show');
|
||||
setTimeout(function(){ app.util.revealItem($("#group-card-" + group)); }, 400);
|
||||
}
|
||||
|
||||
function applySort() {
|
||||
const sort = $('#groupSort').val();
|
||||
const scope = $.scope.groupCard;
|
||||
if (sort === 'name-asc') { scope.__jqOrderBy = 'cn'; scope.__jqOrderReverse = false; }
|
||||
if (sort === 'name-desc') { scope.__jqOrderBy = 'cn'; scope.__jqOrderReverse = true; }
|
||||
if (sort === 'members-desc') { scope.__jqOrderBy = 'memberCount'; scope.__jqOrderReverse = true; }
|
||||
if (sort === 'members-asc') { scope.__jqOrderBy = 'memberCount'; scope.__jqOrderReverse = false; }
|
||||
}
|
||||
|
||||
function matchesSearch(g) {
|
||||
const q = $('#groupSearch').val().toLowerCase().trim();
|
||||
return !q || g.cn.toLowerCase().includes(q) || (g.description || '').toLowerCase().includes(q);
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
applySort();
|
||||
const groups = allGroups.filter(matchesSearch);
|
||||
$.scope.groupCard.empty();
|
||||
$.scope.groupCard.push(...groups);
|
||||
$('#groupCount').text(groups.length + ' of ' + allGroups.length + ' group' + (allGroups.length !== 1 ? 's' : ''));
|
||||
}
|
||||
|
||||
async function tableAJAX(revealCn) {
|
||||
let data = await app.group.list();
|
||||
// processGroup builds each card's "nest a group" list from allGroups, so
|
||||
// it has to see the full set before the map runs -- assigning only the
|
||||
// mapped result would leave every dropdown empty on first load (and one
|
||||
// render stale thereafter). The raw entries carry the cn/dn it needs.
|
||||
allGroups = data.results;
|
||||
allGroups = data.results.map(processGroup);
|
||||
applyFilters();
|
||||
if (revealCn) setTimeout(function(){ app.util.revealItem($('#group-card-' + revealCn)); }, 100);
|
||||
}
|
||||
|
||||
function addNestedClick(event, groupCN, childCN, el){
|
||||
event.preventDefault();
|
||||
const $card = $('#group-card-' + groupCN);
|
||||
(async function(){
|
||||
try {
|
||||
const data = await app.api.put(`group/${groupCN}/nested/${childCN}`, {});
|
||||
const groupData = await app.group.get(groupCN);
|
||||
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
|
||||
app.messages.action(data.message, $card, 'success');
|
||||
} catch(e) {
|
||||
// 409 here is the cycle guard or an already-nested group -- both
|
||||
// carry a specific server message worth showing verbatim.
|
||||
app.messages.action((e && e.message) || 'Failed to nest group', $card, 'danger');
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
async function removeNested(groupCN, childCN, btn) {
|
||||
const $item = $(btn).closest('li');
|
||||
$item.addClass('list-group-item-warning');
|
||||
const confirmed = await app.messages.confirm(
|
||||
`Remove "${childCN}" from "${groupCN}"? Its members lose access granted through this group.`,
|
||||
$item, 'warning');
|
||||
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
|
||||
try {
|
||||
const data = await app.api.delete(`group/${groupCN}/nested/${childCN}`);
|
||||
const groupData = await app.group.get(groupCN);
|
||||
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
|
||||
app.messages.action(data.message, $('#group-card-' + groupCN), 'success');
|
||||
} catch(e) {
|
||||
$item.removeClass('list-group-item-warning');
|
||||
app.messages.action(e.message || 'Failed to un-nest group', $('#group-card-' + groupCN), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMember(groupCN, uid, btn) {
|
||||
const $item = $(btn).closest('li');
|
||||
$item.addClass('list-group-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.messages.action(data.message, $('#group-card-' + groupCN), 'success');
|
||||
} catch(e) {
|
||||
$item.removeClass('list-group-item-warning');
|
||||
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.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.messages.action(data.message, $('#group-card-' + groupCN), 'success');
|
||||
} catch(e) {
|
||||
$item.removeClass('list-group-item-warning');
|
||||
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.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.messages.action(e.message || 'Failed to delete group', $card, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
app.auth.forceLogin(['app_sso_admin', 'admin']);
|
||||
|
||||
$(document).ready(async function(){
|
||||
userlist = (await app.user.list()).results;
|
||||
tableAJAX();
|
||||
});
|
||||
</script>
|
||||
<div class="container mt-4">
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center sticky-top bg-body py-2" style="top: var(--sw-content-offset, 0);">
|
||||
<div class="input-group" style="flex: 1 1 200px;">
|
||||
<span class="input-group-text"><i class="fa-solid fa-magnifying-glass"></i></span>
|
||||
<input type="text" id="groupSearch" class="form-control" placeholder="Search groups…" oninput="applyFilters()">
|
||||
</div>
|
||||
<select id="groupSort" class="form-select" style="width:auto; min-width:175px" onchange="applyFilters()">
|
||||
<option value="name-asc">Name A → Z</option>
|
||||
<option value="name-desc">Name Z → A</option>
|
||||
<option value="members-desc">Most members</option>
|
||||
<option value="members-asc">Fewest members</option>
|
||||
</select>
|
||||
<span id="groupCount" class="text-muted text-nowrap small"></span>
|
||||
</div>
|
||||
<div class="row row-cols-1 row-cols-md-3 g-4 mt-0">
|
||||
<div class="col">
|
||||
<div class="card shadow">
|
||||
<div class="card-header">
|
||||
<i class="fa-solid fa-object-group"></i>
|
||||
Add new group
|
||||
<a href="/docs/accounts" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
</div>
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
<div class="card-body">
|
||||
<form action="group/" method="post" onsubmit="formAJAX(this)" evalAJAX="tableAJAX(data.results.cn)">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" class="form-control shadow" name="name" placeholder="app_gitea_admin" validate=":3" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-control shadow" name="description" placeholder="Admin group for gitea app" validate=":3"></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-outline-dark">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col" jq-repeat="groupCard" jq-index-key="cn" jr-order-by="cn" id="group-card-{{cn}}">
|
||||
<div class="card shadow col">
|
||||
<div class="card-header">
|
||||
<h5>
|
||||
<i class="fa-solid fa-arrows-down-to-people"></i>
|
||||
Group: {{ cn }}
|
||||
<a href="/docs/accounts" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||
</h5>
|
||||
<ul class="nav nav-tabs card-header-tabs" id="myTab" role="tablist">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" id="group-members-tab-{{cn}}" data-bs-toggle="tab" data-bs-target="#group-memmbers-{{cn}}" href="#group-memmbers-{{cn}}" role="tab" aria-controls="member" aria-selected="true">
|
||||
<i class="fa-solid fa-users"></i>
|
||||
Members
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" id="group-nested-tab-{{cn}}" data-bs-toggle="tab" data-bs-target="#group-nested-{{cn}}" href="#group-nested-{{cn}}" role="tab" aria-controls="nested" aria-selected="false">
|
||||
<i class="fa-solid fa-layer-group"></i>
|
||||
Nested{{#hasNested}} <span class="badge bg-secondary">{{nestedCount}}</span>{{/hasNested}}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" id="group-admins-tab-{{cn}}" data-bs-toggle="tab" data-bs-target="#group-admins-{{cn}}" href="#group-admins-{{cn}}" role="tab" aria-controls="admin" aria-selected="false">
|
||||
<i class="fa-solid fa-user-tie"></i>
|
||||
Owners
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item float-end">
|
||||
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
<div class="card-body">
|
||||
<p>
|
||||
{{ description }}
|
||||
</p>
|
||||
<div class="tab-content" id="myTabContent">
|
||||
<div class="tab-pane fade show active" id="group-memmbers-{{cn}}" role="tabpanel" aria-labelledby="member-tab">
|
||||
<p>
|
||||
<ul class="list-group">
|
||||
{{ #member }}
|
||||
<li id="group-card-{{cn}}-{{uid}}" class="list-group-item shadow">
|
||||
<i class="fa-solid fa-user"></i> {{ uid }}
|
||||
<button type="button" onclick="removeMember('{{groupCN}}', '{{uid}}', this)" class="btn btn-sm btn-danger float-end">
|
||||
<i class="fa-solid fa-user-slash"></i>
|
||||
</button>
|
||||
</li>
|
||||
{{ /member }}
|
||||
</ul>
|
||||
</p>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-secondary dropdown-toggle" type="button" id="group_add_member" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<i class="fa-solid fa-user-plus"></i>
|
||||
</button>
|
||||
<div class="dropdown-menu shadow-lg" aria-labelledby="group_add_member">
|
||||
{{ #toAdd }}{{#.}}
|
||||
<a class="dropdown-item" href="#" onclick="return addMemberClick(event, '{{groupCN}}', '{{uid}}', this);">
|
||||
<i class="fa-solid fa-user"></i> {{uid}}
|
||||
</a>
|
||||
{{/.}}{{ /toAdd }}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="group-nested-{{cn}}" role="tabpanel" aria-labelledby="nested-tab">
|
||||
<p class="text-muted small mb-2">
|
||||
Everyone in a nested group is a member of this one, at any depth.
|
||||
</p>
|
||||
<ul class="list-group">
|
||||
{{ #nested }}
|
||||
<li id="group-card-{{groupCN}}-nested-{{cn}}" class="list-group-item shadow">
|
||||
<i class="fa-solid fa-layer-group"></i> {{ cn }}
|
||||
<button type="button" onclick="removeNested('{{groupCN}}', '{{cn}}', this)" class="btn btn-sm btn-danger float-end">
|
||||
<i class="fa-solid fa-link-slash"></i>
|
||||
</button>
|
||||
</li>
|
||||
{{ /nested }}
|
||||
{{ ^hasNested }}
|
||||
<li class="list-group-item text-muted fst-italic">No groups nested here.</li>
|
||||
{{ /hasNested }}
|
||||
</ul>
|
||||
<div class="dropdown mt-2">
|
||||
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<i class="fa-solid fa-diagram-project"></i> Nest a group
|
||||
</button>
|
||||
<div class="dropdown-menu" style="max-height: 300px; overflow-y: auto;">
|
||||
{{ #toNest }}
|
||||
<a class="dropdown-item" href="#" onclick="addNestedClick(event, '{{groupCN}}', '{{cn}}', this)">{{ cn }}</a>
|
||||
{{ /toNest }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="group-admins-{{cn}}" role="tabpanel" aria-labelledby="admin-tab">
|
||||
<p>
|
||||
<ul class="list-group">
|
||||
{{ #owner }}
|
||||
<li class="list-group-item shadow">
|
||||
<i class="fa-solid fa-user"></i> {{ uid }}
|
||||
<button type="button" onclick="removeOwner('{{groupCN}}', '{{uid}}', this)" class="btn btn-sm btn-danger float-end">
|
||||
<i class="fa-solid fa-user-slash"></i>
|
||||
</button>
|
||||
</li>
|
||||
{{ /owner }}
|
||||
</ul>
|
||||
</p>
|
||||
|
||||
<div class="dropdown float-start">
|
||||
<button class="btn btn-secondary dropdown-toggle" type="button" id="group_add_admin" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<i class="fa-solid fa-user-plus"></i>
|
||||
</button>
|
||||
<div class="dropdown-menu shadow-lg" aria-labelledby="group_add_admin">
|
||||
{{ #toAddOwner }}{{#.}}
|
||||
<a class="dropdown-item" action="group/owner/{{groupCN}}/{{uid}}" method="put" onclick="formAJAX(this)" evalAJAX="addedUser(data.message, '{{groupCN}}', '{{uid}}', $form)">
|
||||
<i class="fa-solid fa-user"></i> {{uid}}
|
||||
</a>
|
||||
{{/.}}{{ /toAddOwner }}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="float-end">
|
||||
<button type="button" onclick="" class="btn btn-warning btn-lg shadow">
|
||||
<i class="fa-solid fa-edit"></i>
|
||||
</button>
|
||||
<button type="button" onclick="deleteGroup('{{cn}}', this)" class="btn btn-danger btn-lg">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
Created: {{createTimestamp}}<br />
|
||||
Last Modified: {{modifyTimestamp}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<%- include('bottom') %>
|
||||
Reference in New Issue
Block a user