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:
2026-08-04 15:03:36 -04:00
parent bbcc235b68
commit 6d9c2f05ba
11 changed files with 620 additions and 417 deletions
+1
View File
@@ -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')},
-4
View File
@@ -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});
});
+7 -1
View File
@@ -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){
+114
View File
@@ -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);
});
});
+123
View File
@@ -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,
};
+57 -5
View File
@@ -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, ...)
};
-1
View File
@@ -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>/*.
+1
View File
@@ -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;">
-406
View File
@@ -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') %>