Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 099638057e | |||
| 077c41844d | |||
| 96adf60cf7 | |||
| 82f703f560 |
+14
-1
@@ -6,6 +6,17 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.1.10] - 2026-07-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- A help icon (❓) in the top-right header now deep-links to the doc most relevant to the current page (falls back to the docs index elsewhere).
|
||||||
|
- The in-app docs viewer (`/docs`) is now searchable — a simple line-substring search over the same local doc set, no new dependency, still works with no internet access.
|
||||||
|
|
||||||
|
## [1.1.9] - 2026-07-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Every account's personal Unix group (its primary GID holder) can now have supplementary members managed from the account's profile page ("Members of `<uid>`'s group", admin-only) — e.g. to share write access to files owned by that group. Uses the standard `memberUid` attribute (RFC 2307 `posixGroup`).
|
||||||
|
|
||||||
## [1.1.8] - 2026-07-17
|
## [1.1.8] - 2026-07-17
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -72,7 +83,9 @@ First tagged release. Establishes the `vX.Y.Z` tag convention that the in-app up
|
|||||||
- Unix/POSIX and LDAP bind-only service account support, distinct from real-person accounts.
|
- Unix/POSIX and LDAP bind-only service account support, distinct from real-person accounts.
|
||||||
- Merged OAuth Apps + LDAP Info into a single Integrations page.
|
- Merged OAuth Apps + LDAP Info into a single Integrations page.
|
||||||
|
|
||||||
[Unreleased]: https://github.com/theta42/sso-manager-node/compare/v1.1.8...HEAD
|
[Unreleased]: https://github.com/theta42/sso-manager-node/compare/v1.1.10...HEAD
|
||||||
|
[1.1.10]: https://github.com/theta42/sso-manager-node/compare/v1.1.9...v1.1.10
|
||||||
|
[1.1.9]: https://github.com/theta42/sso-manager-node/compare/v1.1.8...v1.1.9
|
||||||
[1.1.8]: https://github.com/theta42/sso-manager-node/compare/v1.1.7...v1.1.8
|
[1.1.8]: https://github.com/theta42/sso-manager-node/compare/v1.1.7...v1.1.8
|
||||||
[1.1.7]: https://github.com/theta42/sso-manager-node/compare/v1.1.6...v1.1.7
|
[1.1.7]: https://github.com/theta42/sso-manager-node/compare/v1.1.6...v1.1.7
|
||||||
[1.1.6]: https://github.com/theta42/sso-manager-node/compare/v1.1.5...v1.1.6
|
[1.1.6]: https://github.com/theta42/sso-manager-node/compare/v1.1.5...v1.1.6
|
||||||
|
|||||||
@@ -57,6 +57,19 @@ membership (`memberOf` on the user); `refint` keeps it consistent on
|
|||||||
add/remove. **Admin permission checks read the group's `member` list**, not
|
add/remove. **Admin permission checks read the group's `member` list**, not
|
||||||
`memberOf` on the user.
|
`memberOf` on the user.
|
||||||
|
|
||||||
|
### Personal groups
|
||||||
|
|
||||||
|
Every user (person or service account) also gets a **personal Unix group**
|
||||||
|
at creation — `cn=<uid>,ou=groups,<base>`, `objectClass: posixGroup` (RFC
|
||||||
|
2307), holding just `cn` and `gidNumber` (the user's primary GID). This is a
|
||||||
|
different schema than the `groupOfNames` groups above — its membership
|
||||||
|
attribute is `memberUid` (a bare username, not a DN), and unlike
|
||||||
|
`groupOfNames` it's valid with zero members. It's excluded from the
|
||||||
|
`/groups` page (which filters on `objectClass=groupOfNames`) and managed
|
||||||
|
instead from the owning user's own profile page ("Members of `<uid>`'s
|
||||||
|
group", admin-only) — add other accounts as supplementary members, e.g. to
|
||||||
|
share write access to files owned by this group.
|
||||||
|
|
||||||
The SSO requires three groups (seeded automatically by the entrypoint /
|
The SSO requires three groups (seeded automatically by the entrypoint /
|
||||||
`install.sh`):
|
`install.sh`):
|
||||||
|
|
||||||
|
|||||||
@@ -794,6 +794,53 @@ User.addSSHkey = async function(data) {
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Every user gets a personal Unix group of the same name at creation (see
|
||||||
|
// addPosixGroup) -- just a GID holder, cn always equal to the user's uid.
|
||||||
|
// memberUid (RFC 2307, posixGroup) is a bare username, not a DN, unlike
|
||||||
|
// groupOfNames' `member` used by app_sso_* groups in group_ldap.js.
|
||||||
|
function personalGroupDN(uid){
|
||||||
|
return `cn=${uid},${conf.groupBase}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
User.getPersonalGroupMembers = async function(uid) {
|
||||||
|
try {
|
||||||
|
return await withClient(async (client) => {
|
||||||
|
const res = await client.search(personalGroupDN(uid), {
|
||||||
|
scope: 'base',
|
||||||
|
filter: '(objectClass=posixGroup)',
|
||||||
|
attributes: ['memberUid'],
|
||||||
|
});
|
||||||
|
const entry = res.searchEntries[0];
|
||||||
|
return [].concat((entry && entry.memberUid) || []).filter(Boolean);
|
||||||
|
});
|
||||||
|
} catch(error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
User.addPersonalGroupMember = async function(uid, memberUid) {
|
||||||
|
await this.get(memberUid); // throws UserNotFound if the target uid doesn't exist
|
||||||
|
await withClient(async (client) => {
|
||||||
|
await client.modify(personalGroupDN(uid), [
|
||||||
|
new Change({
|
||||||
|
operation: 'add',
|
||||||
|
modification: new Attribute({ type: 'memberUid', values: [memberUid] }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
User.removePersonalGroupMember = async function(uid, memberUid) {
|
||||||
|
await withClient(async (client) => {
|
||||||
|
await client.modify(personalGroupDN(uid), [
|
||||||
|
new Change({
|
||||||
|
operation: 'delete',
|
||||||
|
modification: new Attribute({ type: 'memberUid', values: [memberUid] }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
User.invite = async function(data = {}){
|
User.invite = async function(data = {}){
|
||||||
try{
|
try{
|
||||||
let token = await InviteToken.create({
|
let token = await InviteToken.create({
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.1.8",
|
"version": "1.1.10",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.1.8",
|
"version": "1.1.10",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.1.8",
|
"version": "1.1.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -52,6 +52,30 @@ router.get('/', function(req, res) {
|
|||||||
res.render('docs_index', {...values, docs: docList});
|
res.render('docs_index', {...values, docs: docList});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Plain, dependency-free line-substring search over the same allowlisted
|
||||||
|
// doc set -- no separate index to build/maintain, no new dependency, and it
|
||||||
|
// keeps working with no internet access (same reasoning as the rest of this
|
||||||
|
// route). Must be registered before the /:slug catch-all below, or "search"
|
||||||
|
// would be treated as a (nonexistent) doc slug and 404.
|
||||||
|
router.get('/search', function(req, res) {
|
||||||
|
const q = (req.query.q || '').trim();
|
||||||
|
if (!q) return res.json({results: []});
|
||||||
|
const qLower = q.toLowerCase();
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
for (const [slug, doc] of Object.entries(DOCS)) {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(doc.file, 'utf8');
|
||||||
|
const matchLine = content.split('\n').find(line => line.toLowerCase().includes(qLower));
|
||||||
|
if (matchLine) {
|
||||||
|
results.push({slug, title: doc.title, snippet: matchLine.trim().slice(0, 200)});
|
||||||
|
}
|
||||||
|
} catch (error) { /* unreadable doc file -- skip it */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({results});
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/:slug', function(req, res, next) {
|
router.get('/:slug', function(req, res, next) {
|
||||||
const doc = DOCS[req.params.slug];
|
const doc = DOCS[req.params.slug];
|
||||||
if (!doc) return next({status: 404, message: 'Doc not found'});
|
if (!doc) return next({status: 404, message: 'Doc not found'});
|
||||||
|
|||||||
@@ -144,6 +144,41 @@ router.put('/:uid/active', async function(req, res, next){
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/:uid/group-members', async function(req, res, next){
|
||||||
|
try{
|
||||||
|
await permission.byGroup(req.user, ['app_sso_admin']);
|
||||||
|
return res.json({results: await User.getPersonalGroupMembers(req.params.uid)});
|
||||||
|
}catch(error){
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:uid/group-member/:memberUid', async function(req, res, next){
|
||||||
|
try{
|
||||||
|
await permission.byGroup(req.user, ['app_sso_admin']);
|
||||||
|
await User.addPersonalGroupMember(req.params.uid, req.params.memberUid);
|
||||||
|
return res.json({
|
||||||
|
results: true,
|
||||||
|
message: `Added ${req.params.memberUid} to ${req.params.uid}'s group`
|
||||||
|
});
|
||||||
|
}catch(error){
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:uid/group-member/:memberUid', async function(req, res, next){
|
||||||
|
try{
|
||||||
|
await permission.byGroup(req.user, ['app_sso_admin']);
|
||||||
|
await User.removePersonalGroupMember(req.params.uid, req.params.memberUid);
|
||||||
|
return res.json({
|
||||||
|
results: true,
|
||||||
|
message: `Removed ${req.params.memberUid} from ${req.params.uid}'s group`
|
||||||
|
});
|
||||||
|
}catch(error){
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.put('/:uid', async function(req, res, next){
|
router.put('/:uid', async function(req, res, next){
|
||||||
try{
|
try{
|
||||||
let user;
|
let user;
|
||||||
|
|||||||
@@ -10,7 +10,12 @@
|
|||||||
A local copy of this project's documentation, readable from the
|
A local copy of this project's documentation, readable from the
|
||||||
running app -- no internet access required.
|
running app -- no internet access required.
|
||||||
</p>
|
</p>
|
||||||
<ul class="list-group">
|
<div class="input-group mb-3">
|
||||||
|
<span class="input-group-text"><i class="fa-solid fa-magnifying-glass"></i></span>
|
||||||
|
<input type="search" id="docs-search-input" class="form-control" placeholder="Search the docs…" oninput="docsSearch(this.value)">
|
||||||
|
</div>
|
||||||
|
<div id="docs-search-results" style="display:none"></div>
|
||||||
|
<ul id="docs-list" class="list-group">
|
||||||
<% docs.forEach(function(doc){ %>
|
<% docs.forEach(function(doc){ %>
|
||||||
<li class="list-group-item">
|
<li class="list-group-item">
|
||||||
<a href="/docs/<%= doc.slug %>"><%= doc.title %></a>
|
<a href="/docs/<%= doc.slug %>"><%= doc.title %></a>
|
||||||
@@ -21,4 +26,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script type="text/javascript">
|
||||||
|
var docsSearchTimer;
|
||||||
|
function docsSearch(q){
|
||||||
|
clearTimeout(docsSearchTimer);
|
||||||
|
docsSearchTimer = setTimeout(function(){ docsSearchRun(q); }, 200);
|
||||||
|
}
|
||||||
|
function docsSearchRun(q){
|
||||||
|
q = (q || '').trim();
|
||||||
|
var $results = $('#docs-search-results');
|
||||||
|
var $list = $('#docs-list');
|
||||||
|
if(!q){
|
||||||
|
$results.hide().empty();
|
||||||
|
$list.show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Not app.api.get() -- routes/docs.js is mounted at /docs directly,
|
||||||
|
// not under /api, unlike the rest of this app's endpoints.
|
||||||
|
$.getJSON('/docs/search', {q: q}, function(data){
|
||||||
|
$list.hide();
|
||||||
|
$results.empty().show();
|
||||||
|
var hits = (data && data.results) || [];
|
||||||
|
if(!hits.length){
|
||||||
|
$results.append($('<p class="text-muted"></p>').text('No results for "' + q + '".'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var $ul = $('<ul class="list-group"></ul>');
|
||||||
|
hits.forEach(function(hit){
|
||||||
|
var $li = $('<li class="list-group-item"></li>');
|
||||||
|
$('<a></a>').attr('href', '/docs/' + hit.slug).text(hit.title).appendTo($li);
|
||||||
|
$('<div class="text-muted small"></div>').text(hit.snippet).appendTo($li);
|
||||||
|
$ul.append($li);
|
||||||
|
});
|
||||||
|
$results.append($ul);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
<%- include('bottom') %>
|
<%- include('bottom') %>
|
||||||
|
|||||||
@@ -52,6 +52,43 @@
|
|||||||
renderUserGroups(currentUser);
|
renderUserGroups(currentUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function renderPersonalGroupMembers(user){
|
||||||
|
try{
|
||||||
|
let res = await app.api.get('user/' + user.uid + '/group-members');
|
||||||
|
$.scope.personalGroupMembers.empty();
|
||||||
|
$.scope.personalGroupMembers.push(...(res.results || []).map(uid => ({uid})));
|
||||||
|
}catch(error){
|
||||||
|
console.error('renderPersonalGroupMembers error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removePersonalGroupMember(memberUid, btn){
|
||||||
|
const $row = $(btn).closest('tr');
|
||||||
|
const confirmed = await app.util.actionConfirm(`Remove ${memberUid} from ${currentUser.uid}'s group?`, $row, 'warning');
|
||||||
|
if (!confirmed) return;
|
||||||
|
app.api.delete('user/' + encodeURIComponent(currentUser.uid) + '/group-member/' + encodeURIComponent(memberUid), function(error, data){
|
||||||
|
if(error){ app.util.actionMessage((data && data.message) || 'Failed to remove from group', $row, 'danger'); return; }
|
||||||
|
$.scope.personalGroupMembers.remove('uid', memberUid);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var addPersonalGroupMemberSelect;
|
||||||
|
async function addPersonalGroupMembers(btn){
|
||||||
|
const uids = addPersonalGroupMemberSelect.get();
|
||||||
|
if(!uids.length) return;
|
||||||
|
const $card = $(btn).closest('.card-body');
|
||||||
|
for(const uid of uids){
|
||||||
|
await new Promise(function(resolve){
|
||||||
|
app.api.put('user/' + encodeURIComponent(currentUser.uid) + '/group-member/' + encodeURIComponent(uid), {}, function(error, data){
|
||||||
|
if(error) app.util.actionMessage((data && data.message) || `Failed to add "${uid}"`, $card, 'danger');
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
addPersonalGroupMemberSelect.clear();
|
||||||
|
renderPersonalGroupMembers(currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
async function determinUser(){
|
async function determinUser(){
|
||||||
if(location.pathname.includes('/users/')){
|
if(location.pathname.includes('/users/')){
|
||||||
let uid = location.pathname.replace('/users/', '');
|
let uid = location.pathname.replace('/users/', '');
|
||||||
@@ -122,10 +159,15 @@
|
|||||||
|
|
||||||
renderProfile(currentUser);
|
renderProfile(currentUser);
|
||||||
renderUserGroups(currentUser);
|
renderUserGroups(currentUser);
|
||||||
|
renderPersonalGroupMembers(currentUser);
|
||||||
|
$('#personal-group-uid-label').text(currentUser.uid);
|
||||||
|
|
||||||
addGroupSelect = app.ui.groupSelect('#add-group-select', {
|
addGroupSelect = app.ui.groupSelect('#add-group-select', {
|
||||||
name: 'groups', values: [], placeholder: 'Type a group name…',
|
name: 'groups', values: [], placeholder: 'Type a group name…',
|
||||||
});
|
});
|
||||||
|
addPersonalGroupMemberSelect = app.ui.userSelect('#add-personal-group-member-select', {
|
||||||
|
name: 'members', values: [], placeholder: 'Type a username…',
|
||||||
|
});
|
||||||
|
|
||||||
// API Tokens are self-service only — never shown when an admin is
|
// API Tokens are self-service only — never shown when an admin is
|
||||||
// viewing someone else's profile via /users/:uid.
|
// viewing someone else's profile via /users/:uid.
|
||||||
@@ -348,8 +390,53 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="shadow-lg card card-default mb-8 group-required group-required-app_sso_admin">
|
||||||
|
<div class="card-header shadow">
|
||||||
|
<i class="fa-solid fa-people-group"></i>
|
||||||
|
Members of <span id="personal-group-uid-label"></span>'s group
|
||||||
|
<div class="float-end">
|
||||||
|
<i class="fa-solid fa-arrows-up-down"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-header shadow actionMessage" style="display:none">
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted small">
|
||||||
|
Every account gets a personal Unix group (its primary GID) — add
|
||||||
|
other accounts here as supplementary members (e.g. to share write
|
||||||
|
access to files owned by this group).
|
||||||
|
</p>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<th>
|
||||||
|
Username
|
||||||
|
</th>
|
||||||
|
<th class="text-end"></th>
|
||||||
|
</thead>
|
||||||
|
<tbody jq-repeat="personalGroupMembers">
|
||||||
|
<tr>
|
||||||
|
<td>{{uid}}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger" title="Remove from group" onclick="removePersonalGroupMember('{{uid}}', this)">
|
||||||
|
<i class="fa-solid fa-xmark"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<label class="form-label small">Add member</label>
|
||||||
|
<div class="d-flex gap-2 align-items-start">
|
||||||
|
<div id="add-personal-group-member-select" class="flex-grow-1"></div>
|
||||||
|
<button type="button" class="btn btn-outline-dark" onclick="addPersonalGroupMembers(this)">Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Token modal (shown once on create/rotate) -->
|
<!-- Token modal (shown once on create/rotate) -->
|
||||||
<div class="modal fade" id="secretModal" tabindex="-1">
|
<div class="modal fade" id="secretModal" tabindex="-1">
|
||||||
|
|||||||
@@ -64,6 +64,9 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="form-inline mt-2 mt-md-0">
|
<div class="form-inline mt-2 mt-md-0">
|
||||||
|
<a id="cl-help" class="nav-link text-light me-3" href="/docs" title="Help">
|
||||||
|
<i class="fa-solid fa-circle-question"></i>
|
||||||
|
</a>
|
||||||
<a id="cl-username" class="navbar-text text-light me-3" href="/" style="display: none;">
|
<a id="cl-username" class="navbar-text text-light me-3" href="/" style="display: none;">
|
||||||
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
||||||
</a>
|
</a>
|
||||||
@@ -98,6 +101,19 @@
|
|||||||
sessionStorage.setItem('update-banner-dismissed', '1');
|
sessionStorage.setItem('update-banner-dismissed', '1');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deep-link the header help icon to whichever doc is most relevant to
|
||||||
|
// the current page. No server-side "current section" local exists (every
|
||||||
|
// res.render() call shares one values object, see routes/index.js), so
|
||||||
|
// this follows the same client-side path-matching convention already
|
||||||
|
// used for the top-nav active-link highlighting just below. Unmapped
|
||||||
|
// pages fall back to the docs index (already /docs, the anchor's default).
|
||||||
|
var HELP_DOCS_BY_PATH = {
|
||||||
|
'/users': 'ldap',
|
||||||
|
'/groups': 'ldap',
|
||||||
|
'/integrations': 'ldap',
|
||||||
|
'/oauth/authorize': 'oauth',
|
||||||
|
};
|
||||||
|
|
||||||
$(document).ready(async function(){
|
$(document).ready(async function(){
|
||||||
|
|
||||||
// Set the correct link to active in the top nav bar
|
// Set the correct link to active in the top nav bar
|
||||||
@@ -109,6 +125,12 @@
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// /users/:uid (profile pages) should still map like /users.
|
||||||
|
var path = window.location.pathname.toLocaleLowerCase();
|
||||||
|
var helpSlug = HELP_DOCS_BY_PATH[path] ||
|
||||||
|
(path.indexOf('/users/') === 0 ? HELP_DOCS_BY_PATH['/users'] : null);
|
||||||
|
if(helpSlug) $('#cl-help').attr('href', '/docs/' + helpSlug);
|
||||||
|
|
||||||
// Set the correct login/logout button, and reveal the current user's
|
// Set the correct login/logout button, and reveal the current user's
|
||||||
// name (linking to their profile) once we know who they are.
|
// name (linking to their profile) once we know who they are.
|
||||||
var me = await app.auth.isLoggedIn();
|
var me = await app.auth.isLoggedIn();
|
||||||
|
|||||||
Reference in New Issue
Block a user