Compare commits

...

1 Commits

Author SHA1 Message Date
wmantly ecc9b62842 SSO profile and catalog page improvements
- Profile page: password reset as modal, tabs for My Groups/My Services/Security/Members
- Catalog page: remove portal banner, split My Access into Services/Hosts tabs
- Users list: fix double checkmark for users with multiple SSH keys
- Directory page: remove parent badge and slug, align names with badges
2026-07-31 14:25:18 -04:00
5 changed files with 360 additions and 380 deletions
+3
View File
@@ -295,6 +295,9 @@ User.listDetail = async function(){
obj.onboardingRequired = obj.onboardingNeeds.length > 0 ? 'yes' : ''; obj.onboardingRequired = obj.onboardingNeeds.length > 0 ? 'yes' : '';
obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : ''; obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : '';
obj.managerUids = obj.manager.map(dn => dnToUid.get(String(dn).toLowerCase()) || dn); obj.managerUids = obj.manager.map(dn => dnToUid.get(String(dn).toLowerCase()) || dn);
// hasSshKey is a boolean flag for the UI -- sshPublicKey may be an array,
// and Mustache's {{#sshPublicKey}}...{{/sshPublicKey}} iterates over each item.
obj.hasSshKey = obj.sshPublicKey ? 'yes' : '';
return obj; return obj;
})); }));
+1 -4
View File
@@ -49,12 +49,9 @@
<span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span> <span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
{{#metadata.isProduction}}<span class="badge bg-danger">Prod</span>{{/metadata.isProduction}} {{#metadata.isProduction}}<span class="badge bg-danger">Prod</span>{{/metadata.isProduction}}
{{^metadata.isProduction}}<span class="badge bg-info">Dev</span>{{/metadata.isProduction}} {{^metadata.isProduction}}<span class="badge bg-info">Dev</span>{{/metadata.isProduction}}
<span class="badge bg-light text-dark border">{{hostName}}</span> <a href="#" class="text-reset text-decoration-none ms-2" onclick="openEditModal('{{id}}'); return false;" title="View details">
<br>
<a href="#" class="text-reset text-decoration-none" onclick="openEditModal('{{id}}'); return false;" title="View details">
<strong>{{name}}</strong> <strong>{{name}}</strong>
</a> </a>
<small class="text-muted">{{slug}}</small>
</td> </td>
<td> <td>
{{#metadata.ip}}<div><small>IP:</small> {{metadata.ip}}</div>{{/metadata.ip}} {{#metadata.ip}}<div><small>IP:</small> {{metadata.ip}}</div>{{/metadata.ip}}
+63 -21
View File
@@ -1,15 +1,6 @@
<%- include('top') %> <%- include('top') %>
<style> <style>
.portal-banner {
background-color: var(--bs-primary);
color: white;
padding: 2.5rem 1rem;
margin-bottom: 2rem;
border-radius: .5rem;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.portal-banner h1 { font-weight: 700; }
.catalog-grid { .catalog-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
@@ -23,7 +14,19 @@
} }
.service-card:hover { transform: translateY(-3px); box-shadow: 0 .5rem 1rem rgba(0,0,0,.15)!important; } .service-card:hover { transform: translateY(-3px); box-shadow: 0 .5rem 1rem rgba(0,0,0,.15)!important; }
.service-card .card-body { flex: 1; } .service-card .card-body { flex: 1; }
.card-icon { font-size: 1.4rem; width: 1.8rem; text-align: center; } .card-icon {
font-size: 1.4rem;
width: 1.8rem;
text-align: center;
display: inline-flex;
align-items: center;
justify-content: center;
}
.card-icon-img {
width: 1.8rem;
height: 1.8rem;
object-fit: contain;
}
.howto code { .howto code {
display: block; display: block;
background: var(--bs-tertiary-bg, #f1f3f5); background: var(--bs-tertiary-bg, #f1f3f5);
@@ -37,12 +40,6 @@
</style> </style>
<div class="container mt-4"> <div class="container mt-4">
<div class="portal-banner text-center">
<h1><%- name %> Portal</h1>
<p class="lead mb-3">Everything the lab offers — what you can reach, and how to reach it.</p>
<a href="/profile" class="btn btn-light shadow-sm"><i class="fa-solid fa-user"></i> My Profile</a>
</div>
<div class="row mb-4"> <div class="row mb-4">
<div class="col-md-8"> <div class="col-md-8">
<input type="text" id="catalog-search" class="form-control shadow-sm" <input type="text" id="catalog-search" class="form-control shadow-sm"
@@ -68,8 +65,36 @@
<ul class="list-group mb-5 shadow-sm" id="approvals"></ul> <ul class="list-group mb-5 shadow-sm" id="approvals"></ul>
</div> </div>
<h3 class="mb-3"><i class="fa-solid fa-layer-group text-success"></i> My Access</h3> <!-- My Access section with tabs -->
<div class="catalog-grid mb-5" id="my-services"></div> <div class="card shadow mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fa-solid fa-layer-group text-success"></i> My Access</span>
</div>
<div class="px-3 pt-3 border-bottom">
<ul class="nav nav-tabs border-bottom-0" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#my-services-tab" type="button" role="tab">
<i class="fa-solid fa-cube"></i> Services
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#my-hosts-tab" type="button" role="tab">
<i class="fa-solid fa-server"></i> Hosts
</button>
</li>
</ul>
</div>
<div class="card-body">
<div class="tab-content">
<div class="tab-pane fade show active" id="my-services-tab" role="tabpanel">
<div class="catalog-grid" id="my-services"></div>
</div>
<div class="tab-pane fade" id="my-hosts-tab" role="tabpanel">
<div class="catalog-grid" id="my-hosts"></div>
</div>
</div>
</div>
</div>
<h3 class="mb-3"><i class="fa-solid fa-compass text-secondary"></i> Discover More</h3> <h3 class="mb-3"><i class="fa-solid fa-compass text-secondary"></i> Discover More</h3>
<p class="text-muted small">Things you don't have access to yet. Request what you need.</p> <p class="text-muted small">Things you don't have access to yet. Request what you need.</p>
@@ -99,6 +124,17 @@
}); });
} }
// Render icon as either Font Awesome class or <img> for URL
function renderIcon(icon, kind) {
if (!icon) icon = KIND_ICONS[kind] || 'fa-solid fa-cube';
// If it starts with http, treat it as an image URL
if (/^https?:\/\//i.test(icon)) {
return '<img src="' + esc(icon) + '" alt="" class="card-icon-img">';
}
// Otherwise it's a Font Awesome class
return '<i class="' + esc(icon) + ' card-icon"></i>';
}
// "How do I actually use this?" — the question the directory exists to // "How do I actually use this?" — the question the directory exists to
// answer and the one the old portal never did. Everything here is derived // answer and the one the old portal never did. Everything here is derived
// from directory metadata; nothing is hardcoded per-service. // from directory metadata; nothing is hardcoded per-service.
@@ -142,7 +178,6 @@
function cardHtml(r, accessible) { function cardHtml(r, accessible) {
var md = r.metadata || {}; var md = r.metadata || {};
var icon = md.icon || KIND_ICONS[r.kind] || 'fa-solid fa-cube';
var blurb = md.tagline || r.description || 'No description provided'; var blurb = md.tagline || r.description || 'No description provided';
var href = accessible ? linkFor(r) : null; var href = accessible ? linkFor(r) : null;
var lines = accessible ? howTo(r) : []; var lines = accessible ? howTo(r) : [];
@@ -166,10 +201,12 @@
+ '<i class="fa-solid fa-hand"></i> Request access</button>'; + '<i class="fa-solid fa-hand"></i> Request access</button>';
} }
var iconHtml = renderIcon(md.icon, r.kind);
return '<div class="card shadow-sm service-card ' + (accessible ? 'border-success' : '') + '">' return '<div class="card shadow-sm service-card ' + (accessible ? 'border-success' : '') + '">'
+ '<div class="card-body">' + '<div class="card-body">'
+ '<h5 class="card-title d-flex align-items-start gap-2">' + '<h5 class="card-title d-flex align-items-start gap-2">'
+ '<i class="' + esc(icon) + ' card-icon ' + (accessible ? 'text-success' : 'text-secondary') + '"></i>' + iconHtml
+ '<span>' + esc(r.name) + '</span>' + '<span>' + esc(r.name) + '</span>'
+ '</h5>' + '</h5>'
+ '<div class="mb-2"><span class="badge bg-secondary">' + esc(r.kind) + '<div class="mb-2"><span class="badge bg-secondary">' + esc(r.kind)
@@ -235,7 +272,12 @@
} }
function renderAll() { function renderAll() {
renderGrid('my-services', state.mine, true); // Split mine into services and hosts
var myServices = state.mine.filter(function(r) { return r.kind === 'service' || r.kind === 'oauth'; });
var myHosts = state.mine.filter(function(r) { return r.kind === 'host'; });
renderGrid('my-services', myServices, true);
renderGrid('my-hosts', myHosts, true);
renderGrid('other-services', state.others, false); renderGrid('other-services', state.others, false);
renderRequests(); renderRequests();
renderApprovals(); renderApprovals();
+237 -299
View File
@@ -6,13 +6,10 @@
var isOwnProfile = !location.pathname.includes('/users/'); var isOwnProfile = !location.pathname.includes('/users/');
function renderProfile(user){ function renderProfile(user){
// data.photo = unescape(encodeURIComponent(data.jpegPhoto));
user.createTimestamp = moment(user.createTimestamp, "YYYYMMDDHHmmssZ").fromNow(); user.createTimestamp = moment(user.createTimestamp, "YYYYMMDDHHmmssZ").fromNow();
user.modifyTimestamp = moment(user.modifyTimestamp, "YYYYMMDDHHmmssZ").fromNow(); user.modifyTimestamp = moment(user.modifyTimestamp, "YYYYMMDDHHmmssZ").fromNow();
user.managerUids = (user.manager || []).map(app.user.dnToUid); user.managerUids = (user.manager || []).map(app.user.dnToUid);
$.scope.user.update(user); $.scope.user.update(user);
$.scope.passwordReset.update(user);
}; };
async function renderUserGroups(user){ async function renderUserGroups(user){
@@ -92,9 +89,6 @@
async function renderMyServices(){ async function renderMyServices(){
try{ try{
let res = await app.api.get('discovery/me'); let res = await app.api.get('discovery/me');
// The server already resolves this by walking the graph, so a service
// with no address of its own inherits its host's. Only fall back
// locally when it genuinely resolved to nothing.
res.results.forEach(r => { res.results.forEach(r => {
r.resolvedAddress = r.resolvedAddress r.resolvedAddress = r.resolvedAddress
|| (r.metadata && r.metadata.address) || (r.metadata && r.metadata.address)
@@ -112,11 +106,11 @@
try{ try{
let uid = currentUser.uid; let uid = currentUser.uid;
let res = await app.api.get(`metrics/user/${uid}`); let res = await app.api.get(`metrics/user/${uid}`);
if (res && res.results) { if (res && res.results && res.results.services) {
const renderList = (items, id) => { const el = document.getElementById('metrics-user-services');
const el = document.getElementById(id);
if(!el) return; if(!el) return;
el.innerHTML = ''; el.innerHTML = '';
const items = res.results.services;
if (!items || items.length === 0) { if (!items || items.length === 0) {
el.innerHTML = '<li class="list-group-item text-muted">No data available</li>'; el.innerHTML = '<li class="list-group-item text-muted">No data available</li>';
return; return;
@@ -127,19 +121,17 @@
<span class="badge bg-primary rounded-pill">${item.score}</span> <span class="badge bg-primary rounded-pill">${item.score}</span>
</li>`; </li>`;
}); });
};
renderList(res.results.services, 'metrics-user-services');
} }
}catch(error){ }catch(error){
console.error('renderMyMetrics error:', error) console.error('renderMyMetrics error:', error);
const el = document.getElementById('metrics-user-services');
if(el) el.innerHTML = '<li class="list-group-item text-danger">Failed to load metrics</li>';
} }
} }
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/', '');
return (await app.api.get('user/'+uid)).results; return (await app.api.get('user/'+uid)).results;
}else{ }else{
return await app.auth.asyncUser; return await app.auth.asyncUser;
@@ -148,16 +140,9 @@
function editUser(user){ function editUser(user){
user = user || currentUser; user = user || currentUser;
var $profileCard = $('#userProfile'); var $profileCard = $('#userProfile');
var $editCard = $('#editProfile'); var $editCard = $('#editProfile');
$.scope.editProfile.update(user); $.scope.editProfile.update(user);
// jq-repeat's update() is trailing-edge throttled (~50ms) as of 2.1.0 --
// wait for the throttle tick to land before sliding the updated card
// into view, or it can briefly show stale/empty data. The manager
// picker is a JS widget, not a mustache-bound input, so it also has to
// wait for update() to (re-)render its empty mount div before attaching.
setTimeout(function(){ setTimeout(function(){
app.ui.userSelect('#edit-manager', { app.ui.userSelect('#edit-manager', {
name: 'manager', name: 'manager',
@@ -172,8 +157,6 @@
function editUserSeccess(data){ function editUserSeccess(data){
currentUser = data.results; currentUser = data.results;
renderProfile(currentUser); renderProfile(currentUser);
// Same throttle-tick wait as editUser() above -- renderProfile() calls
// $.scope.user.update()/passwordReset.update() internally.
setTimeout(function(){ setTimeout(function(){
$('#editProfile').slideUp(); $('#editProfile').slideUp();
$('#userProfile').slideDown() $('#userProfile').slideDown()
@@ -201,6 +184,29 @@
}); });
} }
// Password reset modal
function openPasswordResetModal(){
app.modal.open({
title: 'Reset Password for ' + currentUser.uid,
bodyHtml:
'<div class="actionMessage mb-3" style="display:none"></div>'
+ '<form id="passwordResetForm" action="user/' + currentUser.uid + '/password" method="put" onsubmit="formAJAX(this)" evalAJAX="app.modal.close(); app.messages.toast(\'Password updated\', \'success\');">'
+ '<div class="mb-3">'
+ '<label class="form-label">New Password</label>'
+ '<input type="password" class="form-control shadow" name="userPassword" placeholder="8+ chars; mix upper/lower/number/symbol" validate="password">'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label">Confirm Password</label>'
+ '<input type="password" class="form-control shadow" name="password" placeholder="Retype password" validate="eq:userPassword">'
+ '</div>'
+ '</form>',
footer: {
buttonsHtml: '<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>'
+ '<button type="submit" form="passwordResetForm" class="btn btn-warning"><i class="fa-solid fa-key"></i> Reset Password</button>',
},
});
}
$(document).ready(async function(){ $(document).ready(async function(){
currentUser = await determinUser(); currentUser = await determinUser();
@@ -210,7 +216,7 @@
renderMyServices(); renderMyServices();
if(!isOwnProfile) { if(!isOwnProfile) {
renderMyMetrics(); renderMyMetrics();
$('#user-metrics-card').show(); $('#tab-metrics').parent().show();
} }
$('#personal-group-uid-label').text(currentUser.uid); $('#personal-group-uid-label').text(currentUser.uid);
@@ -221,8 +227,6 @@
name: 'members', values: [], placeholder: 'Type a username…', name: 'members', values: [], placeholder: 'Type a username…',
}); });
// API Tokens are self-service only — never shown when an admin is
// viewing someone else's profile via /users/:uid.
if(isOwnProfile){ if(isOwnProfile){
$('#own-api-tokens-section').show(); $('#own-api-tokens-section').show();
tableAJAX(); tableAJAX();
@@ -230,145 +234,248 @@
}); });
</script> </script>
<div class="row mb-3" style="display:none"> <div class="container mt-4">
<div class="col-md-4"> <div class="row" style="display:none">
<div class="shadow-lg card"> <div class="col-12">
<div class="card-header shadow"> <div id="userProfile" class="shadow-lg card card-default mb-4">
<i class="fa-solid fa-arrow-rotate-left"></i> <div class="card-header shadow d-flex justify-content-between align-items-center">
Password Reset <div>
<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 jq-repeat="passwordReset" class="card-body">
<h3>
Reset Password for {{uid}}
</h3>
<form action="user/{{uid}}/password" method="put" onsubmit="formAJAX(this)" class="mb-3">
<div class="mb-3">
<label class="form-label">Password</label>
<div class="input-group shadow">
<span class="input-group-text" id="addon-wrapping"><i class="fa-solid fa-key"></i></span>
<input type="password" name="userPassword" class="form-control" placeholder="huunteR!23" aria-label="Username" aria-describedby="addon-wrapping">
</div>
</div>
<div class="mb-3">
<label class="form-label">Again</label>
<div class="input-group shadow">
<span class="input-group-text" id="addon-wrapping"><i class="fa-solid fa-key"></i></span>
<input type="password" name="password" class="form-control" placeholder="huunteR!23" aria-label="Username" aria-describedby="addon-wrapping">
</div>
</div>
<button type="submit" class="btn btn-outline-secondary shadow">Change</button>
</form>
</div>
</div>
</div>
<div class="col-md-8">
<div id="userProfile" class="shadow-lg card card-default mb-8">
<div class="card-header shadow">
<i class="fa-regular fa-id-card"></i> <i class="fa-regular fa-id-card"></i>
Profile Profile: <strong>{{user.uid}}</strong>
<div class="float-end"> </div>
<i class="fa-solid fa-arrows-up-down"></i> <div class="d-flex gap-2">
<button type="button" onclick="openPasswordResetModal()" class="btn btn-outline-warning btn-sm">
<i class="fa-solid fa-key"></i> Reset Password
</button>
<button type="button" onclick="editUser()" class="btn btn-warning btn-sm">
<i class="fa-solid fa-user-pen"></i> Edit
</button>
</div> </div>
</div> </div>
<div class="card-header shadow actionMessage" style="display:none"> <div class="card-header shadow actionMessage" style="display:none"></div>
<div class="px-3 pt-3 border-bottom">
<ul class="nav nav-tabs border-bottom-0" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-profile" type="button" role="tab">
<i class="fa-solid fa-user"></i> Profile
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-groups" type="button" role="tab">
<i class="fa-solid fa-users"></i> My Groups
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-services" type="button" role="tab">
<i class="fa-solid fa-layer-group"></i> My Services
</button>
</li>
<li class="nav-item" role="presentation" id="tab-metrics-parent" style="display:none">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-metrics" type="button" role="tab">
<i class="fa-solid fa-chart-line"></i> Security & Usage
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-members" type="button" role="tab">
<i class="fa-solid fa-people-group"></i> Members of {{user.uid}}'s Group
</button>
</li>
</ul>
</div> </div>
<div class="profile-body" jq-repeat="user">
<div class="card-body profile-body-{{uid}}"> <div class="card-body">
<h2><i>User Name:</i> <b>{{uid}}</b></h2> <div class="tab-content">
{{^isServiceAccount}}<i>Name:</i> <b>{{givenName}} {{sn}}</b><br />{{/isServiceAccount}} <!-- Profile Tab -->
<i>Email:</i> <b>{{mail}}</b> <div class="tab-pane fade show active" id="tab-profile" role="tabpanel">
<div jq-repeat="user">
<div class="row">
<div class="col-md-6">
<p><i>Name:</i> <b>{{givenName}} {{sn}}</b></p>
<p><i>Email:</i> <b>{{mail}}</b>
{{#emailVerified}}<span class="badge bg-success ms-1"><i class="fa-solid fa-circle-check"></i> Verified</span>{{/emailVerified}} {{#emailVerified}}<span class="badge bg-success ms-1"><i class="fa-solid fa-circle-check"></i> Verified</span>{{/emailVerified}}
<br /> </p>
<i>Phone:</i> <b>{{mobile}}</b> <p><i>Phone:</i> <b>{{mobile}}</b>
{{#phoneVerified}}<span class="badge bg-success ms-1"><i class="fa-solid fa-circle-check"></i> Verified</span>{{/phoneVerified}} {{#phoneVerified}}<span class="badge bg-success ms-1"><i class="fa-solid fa-circle-check"></i> Verified</span>{{/phoneVerified}}
<br /> </p>
<i>Location (Site):</i> <b>{{location}} </b><br /> <p><i>Location (Site):</i> <b>{{location}}</b></p>
<i>LDAP DN:</i> <b>{{dn}} </b><br /> <p><i>LDAP DN:</i> <b>{{dn}}</b></p>
<i>Home Directory:</i> <b>{{homeDirectory}} </b><br /> <p><i>Home Directory:</i> <b>{{homeDirectory}}</b></p>
<i>Login Shell:</i> <b>{{loginShell}} </b><br /> </div>
<i>Manager(s):</i> <div class="col-md-6">
<p><i>Login Shell:</i> <b>{{loginShell}}</b></p>
<p><i>Manager(s):</i>
{{#managerUids}}<span class="badge bg-secondary me-1">{{.}}</span>{{/managerUids}} {{#managerUids}}<span class="badge bg-secondary me-1">{{.}}</span>{{/managerUids}}
<br /> </p>
<i>Status:</i> <p><i>Status:</i>
{{#isActive}}<span class="badge bg-success">Active</span>{{/isActive}} {{#isActive}}<span class="badge bg-success">Active</span>{{/isActive}}
{{#isInactive}}<span class="badge bg-danger">Inactive</span>{{/isInactive}} {{#isInactive}}<span class="badge bg-danger">Inactive</span>{{/isInactive}}
<br /> </p>
<i>Date of Birth:</i> <b>{{dateOfBirth}}</b><br /> <p><i>Date of Birth:</i> <b>{{dateOfBirth}}</b></p>
<i>TOS:</i> <p><i>TOS:</i>
{{#tosAccepted}}<span class="badge bg-success">Accepted</span>{{/tosAccepted}} {{#tosAccepted}}<span class="badge bg-success">Accepted</span>{{/tosAccepted}}
{{#tosNotAccepted}}<span class="badge bg-warning text-dark">Pending</span>{{/tosNotAccepted}} {{#tosNotAccepted}}<span class="badge bg-warning text-dark">Pending</span>{{/tosNotAccepted}}
<br />
<i>SSH Public Key:</i> <b>{{sshPublicKey}}</b><br />
<i>Unix User ID:</i> <b>{{uidNumber}} </b><br />
<i>Unix Group ID:</i> <b>{{gidNumber}} </b><br />
<i>Description:</i><br>
<p>
{{description}}
</p> </p>
<!-- <img id="profile_photo" /> --> <p><i>SSH Public Key:</i> <b>{{sshPublicKey}}</b></p>
<p><i>Unix User ID:</i> <b>{{uidNumber}}</b></p>
<p><i>Unix Group ID:</i> <b>{{gidNumber}}</b></p>
</div> </div>
<div class="card-footer profile-body-{{uid}}"> </div>
<div class="float-end"> {{#description}}
<button type="button" onclick="editUser()" class="btn btn-warning btn shadow "> <hr>
<i class="fa-solid fa-user-pen"></i> <p><i>Description:</i><br>{{description}}</p>
</button> {{/description}}
<hr>
<p class="text-muted small mb-0">
<i>Joined:</i> <b>{{createTimestamp}}</b> | <i>Edited:</i> <b>{{modifyTimestamp}}</b>
</p>
</div>
<div class="mt-3 border-top pt-3">
<h6 class="text-muted">Admin Actions</h6>
<div class="d-flex gap-2 flex-wrap group-required group-required-app_sso_admin">
{{#isActive}} {{#isActive}}
<button type="button" class="btn btn-outline-warning shadow group-required group-required-app_sso_admin" title="Deactivate user" onclick="toggleActive('{{uid}}', false)"> <button type="button" class="btn btn-outline-warning" title="Deactivate user" onclick="toggleActive('{{uid}}', false)">
<i class="fa-solid fa-lock"></i> <i class="fa-solid fa-lock"></i> Deactivate
</button> </button>
{{/isActive}} {{/isActive}}
{{#isInactive}} {{#isInactive}}
<button type="button" class="btn btn-warning shadow group-required group-required-app_sso_admin" title="Activate user" onclick="toggleActive('{{uid}}', true)"> <button type="button" class="btn btn-warning" title="Activate user" onclick="toggleActive('{{uid}}', true)">
<i class="fa-solid fa-lock-open"></i> <i class="fa-solid fa-lock-open"></i> Activate
</button> </button>
{{/isInactive}} {{/isInactive}}
<button type="button" class="btn btn-secondary shadow group-required group-required-app_sso_admin" title="Impersonate this user" onclick="startImpersonate('{{uid}}')"> <button type="button" class="btn btn-secondary" title="Impersonate this user" onclick="startImpersonate('{{uid}}')">
<i class="fa-solid fa-user-secret"></i> <i class="fa-solid fa-user-secret"></i> Impersonate
</button> </button>
<button type="button" class="btn btn-danger shadow group-required group-required-app_sso_admin" onclick="deleteUser('{{uid}}', this)"> <button type="button" class="btn btn-danger" onclick="deleteUser('{{uid}}', this)">
<i class="fa-solid fa-user-slash"></i> <i class="fa-solid fa-user-slash"></i> Delete User
</button> </button>
</div> </div>
<div class="float-left">
<i>Joined:</i> <b>{{createTimestamp}} </b> <br/>
<i>Edited:</i> <b>{{modifyTimestamp}} </b>
</div>
</div>
</div> </div>
</div> </div>
<div id="editProfile" class="shadow-lg card card-default mb-8" style="display:none"> <!-- My Groups Tab -->
<div class="tab-pane fade" id="tab-groups" role="tabpanel">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<th>Name</th>
<th>Description</th>
<th class="group-required group-required-app_sso_admin text-end">Actions</th>
</thead>
<tbody jq-repeat="mygroups">
<tr>
<td>{{cn}}</td>
<td>{{description}}</td>
<td class="text-end group-required group-required-app_sso_admin">
<button type="button" class="btn btn-sm btn-outline-danger" title="Remove from group" onclick="removeFromGroup('{{cn}}', this)">
<i class="fa-solid fa-xmark"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="group-required group-required-app_sso_admin mt-3">
<label class="form-label small">Add to group</label>
<div class="d-flex gap-2 align-items-start">
<div id="add-group-select" class="flex-grow-1"></div>
<button type="button" class="btn btn-outline-dark" onclick="addToGroups(this)">Add</button>
</div>
</div>
</div>
<!-- My Services Tab -->
<div class="tab-pane fade" id="tab-services" role="tabpanel">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<th>Name</th>
<th>Address / IP</th>
<th>Description</th>
<th>Kind</th>
</thead>
<tbody jq-repeat="myservices">
<tr>
<td>{{name}} <small class="text-muted">({{slug}})</small></td>
<td><a href="{{resolvedAddress}}" target="_blank"><code>{{resolvedAddress}}</code></a></td>
<td>{{description}}</td>
<td>
{{#metadata.isProduction}}<span class="badge bg-danger mb-1">Prod</span><br>{{/metadata.isProduction}}
<span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Security & Usage Tab -->
<div class="tab-pane fade" id="tab-metrics" role="tabpanel">
<div class="row">
<div class="col-12">
<h6 class="text-success"><i class="fa-solid fa-plug"></i> Top Services Used (Last 7 Days)</h6>
<ul class="list-group list-group-flush border rounded" id="metrics-user-services">
<li class="list-group-item text-muted">Loading...</li>
</ul>
</div>
</div>
</div>
<!-- Members Tab -->
<div class="tab-pane fade" id="tab-members" role="tabpanel">
<p class="text-muted small">
Every account gets a personal Unix group (its primary GID) — add
other accounts here as supplementary members.
</p>
<div class="table-responsive">
<table class="table table-striped">
<thead>
<th>Username</th>
<th class="text-end">Actions</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>
<div class="mt-3">
<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>
<!-- Edit Profile Card (hidden by default) -->
<div id="editProfile" class="shadow-lg card card-default mb-4" style="display:none">
<div class="card-header shadow"> <div class="card-header shadow">
<i class="fad fa-id-card"></i> <i class="fad fa-id-card"></i>
Edit Profile Edit Profile
<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>
<div class="card-header shadow actionMessage" style="display:none"></div>
<div class="card-body" jq-repeat="editProfile"> <div class="card-body" jq-repeat="editProfile">
<div id="tableAJAX">
<h3>Editing {{uid}}</h3> <h3>Editing {{uid}}</h3>
<form action="user/{{uid}}" method="put" onsubmit="formAJAX(this)" evalAJAX="editUserSeccess(data)"> <form action="user/{{uid}}" method="put" onsubmit="formAJAX(this)" evalAJAX="editUserSeccess(data)">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Date of Birth</label> <label class="form-label">Date of Birth</label>
<input type="date" class="form-control shadow" name="dateOfBirth" value="{{dateOfBirth}}" /> <input type="date" class="form-control shadow" name="dateOfBirth" value="{{dateOfBirth}}" />
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">SSH Public Key</label> <label class="form-label">SSH Public Key</label>
<input type="text" class="form-control" name="sshPublicKey" placeholder="ssh-rsa AAAAB3NzaC1yc2EAAAADAQ..." value="{{sshPublicKey}}" /> <input type="text" class="form-control" name="sshPublicKey" placeholder="ssh-rsa AAAAB3NzaC1yc2EAAAADAQ..." value="{{sshPublicKey}}" />
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Mobile Phone</label> <label class="form-label">Mobile Phone</label>
<input type="text" class="form-control" name="mobile" placeholder="9175551234" value="{{mobile}}" /> <input type="text" class="form-control" name="mobile" placeholder="9175551234" value="{{mobile}}" />
@@ -390,172 +497,22 @@
<div id="edit-manager"></div> <div id="edit-manager"></div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">User Description (Optional)</label> <label class="form-label">User Description (optional)</label>
<textarea class="form-control" name="description" placeholder="Admin group for gitea app">{{description}}</textarea> <textarea class="form-control" name="description" placeholder="Admin group for gitea app">{{description}}</textarea>
</div> </div>
<button type="submit" class="btn btn-outline-dark btn-warning">Change</button> <button type="submit" class="btn btn-outline-dark btn-warning">Save Changes</button>
<button type="button" class="btn btn-secondary" onclick="$('#editProfile').slideUp(); $('#userProfile').slideDown();">Cancel</button>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
<div class="shadow-lg card card-default mb-8">
<div class="card-header shadow">
<i class="fa-solid fa-users-viewfinder"></i>
My groups
<div class="float-end">
<a href="/docs/accounts" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<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">
<div class="table-responsive">
<table class="table">
<thead>
<th>
Name
</th>
<th>
Description
</th>
<th class="group-required group-required-app_sso_admin"></th>
</thead>
<tbody jq-repeat="mygroups">
<tr>
<td>{{cn}}</td>
<td>{{description}}</td>
<td class="text-end group-required group-required-app_sso_admin">
<button type="button" class="btn btn-sm btn-outline-danger" title="Remove from group" onclick="removeFromGroup('{{cn}}', this)">
<i class="fa-solid fa-xmark"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="group-required group-required-app_sso_admin">
<label class="form-label small">Add to group</label>
<div class="d-flex gap-2 align-items-start">
<div id="add-group-select" class="flex-grow-1"></div>
<button type="button" class="btn btn-outline-dark" onclick="addToGroups(this)">Add</button>
</div>
</div>
</div>
</div>
<div class="shadow-lg card card-default mb-8">
<div class="card-header shadow">
<i class="fa-solid fa-layer-group"></i>
My Services
<div class="float-end">
<i class="fa-solid fa-arrows-up-down"></i>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table">
<thead>
<th>Name</th>
<th>Address / IP</th>
<th>Description</th>
<th>Kind</th>
</thead>
<tbody jq-repeat="myservices">
<tr>
<td>{{name}} <small class="text-muted">({{slug}})</small></td>
<td><a href="{{resolvedAddress}}" target="_blank"><code>{{resolvedAddress}}</code></a></td>
<td>{{description}}</td>
<td>
{{#metadata.isProduction}}<span class="badge bg-danger mb-1">Prod</span><br>{{/metadata.isProduction}}
<span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="shadow-lg card card-default mb-8 group-required group-required-app_sso_admin" id="user-metrics-card" style="display:none">
<div class="card-header shadow bg-success text-white">
<i class="fa-solid fa-chart-line"></i>
Security & Usage Stats (Last 7 Days)
<div class="float-end">
<i class="fa-solid fa-arrows-up-down"></i>
</div>
</div>
<div class="card-body p-0">
<div class="row m-0">
<div class="col-12 p-3">
<h6 class="text-success"><i class="fa-solid fa-plug"></i> Top Services Used</h6>
<ul class="list-group list-group-flush border rounded" id="metrics-user-services">
<li class="list-group-item text-muted">Loading...</li>
</ul>
</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">
<a href="/docs/accounts" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<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>
<script type="text/javascript"> <script type="text/javascript">
// Any logged-in user can manage their own API tokens (self-service). // API Tokens section (same as before)
// Section is only revealed (see $(document).ready above) when isOwnProfile.
var tokensById = {}; var tokensById = {};
// Shared "reveal secret once" display -- same pattern as jump-host's and
// proxy's showToken().
function showToken(title, token){ function showToken(title, token){
app.modal.open({title: title, bodyHtml: app.modal.open({title: title, bodyHtml:
'<p class="text-danger"><i class="fa-solid fa-triangle-exclamation"></i> Save this token now — it will <strong>not</strong> be shown again.</p>' '<p class="text-danger"><i class="fa-solid fa-triangle-exclamation"></i> Save this token now — it will <strong>not</strong> be shown again.</p>'
@@ -564,10 +521,7 @@
+ '<p class="mt-3 mb-0 text-muted small">Use it as a bearer token:<br><code>Authorization: Bearer ' + app.util.escapeHtml(token) + '</code></p>' + '<p class="mt-3 mb-0 text-muted small">Use it as a bearer token:<br><code>Authorization: Bearer ' + app.util.escapeHtml(token) + '</code></p>'
}); });
} }
// Not the checkmark-flash technique this file used to use for its copy
// buttons -- FontAwesome replaces <i> icons with inline <svg>, so
// swapping the <i>'s class silently no-ops. A toast doesn't have that
// problem.
function copyFieldValue(sel){ function copyFieldValue(sel){
var $el = $(sel); var $el = $(sel);
var text = $el.val(); var text = $el.val();
@@ -580,17 +534,12 @@
} }
function fmtTime(ms){ function fmtTime(ms){
// created_on/last_used_on come back from Redis as strings (model-redis
// only coerces fields with an explicit `type`); moment(value, "x") parses
// a numeric string-or-number as a Unix-ms timestamp, unlike new Date(str).
if(!ms || Number(ms) === 0) return '—'; if(!ms || Number(ms) === 0) return '—';
var t = moment(ms, "x"); var t = moment(ms, "x");
if(!t.isValid()) return '—'; if(!t.isValid()) return '—';
return t.fromNow() + ' <span class="text-muted">(' + t.format('YYYY-MM-DD HH:mm') + ')</span>'; return t.fromNow() + ' <span class="text-muted">(' + t.format('YYYY-MM-DD HH:mm') + ')</span>';
} }
function fmtExpiry(token){ function fmtExpiry(token){
// expires_at is type:number (a real number); isExpired is a class getter
// that is NOT serialized to the client, so compute expiry here.
var exp = Number(token.expires_at); var exp = Number(token.expires_at);
if(!exp) return '<span class="badge text-bg-secondary">never</span>'; if(!exp) return '<span class="badge text-bg-secondary">never</span>';
if(Date.now() > exp) return '<span class="badge text-bg-danger">expired</span>'; if(Date.now() > exp) return '<span class="badge text-bg-danger">expired</span>';
@@ -639,14 +588,6 @@
}); });
} }
// Create is a native <form>+formAJAX submission (matching this app's own
// hostModal-style convention) rather than a JS-built payload. Deliberately
// does NOT call app.modal.close() before showToken() -- app.modal is a
// singleton, and close() immediately followed by open() in the same tick
// collides with Bootstrap's hide-transition guard (show() silently
// no-ops while _isTransitioning is still true from the just-started
// hide()). open() alone already overwrites the (already-visible) modal's
// content in place.
function createApiToken(){ function createApiToken(){
var $body = app.modal.open({ var $body = app.modal.open({
title: 'New API Token', title: 'New API Token',
@@ -714,9 +655,6 @@
} }
</script> </script>
<!-- Not class="row": app-base.js's `$('div.row').fadeIn('slow')` page-reveal
runs before this page's own ready handler and would unhide any div.row
unconditionally, defeating the isOwnProfile check below. -->
<div id="own-api-tokens-section" style="display:none"> <div id="own-api-tokens-section" style="display:none">
<div class="row mt-3 justify-content-center"> <div class="row mt-3 justify-content-center">
<div class="col-md-8"> <div class="col-md-8">
+1 -1
View File
@@ -301,7 +301,7 @@
{{mail}} {{mail}}
</td> </td>
<td> <td>
{{#sshPublicKey}}<i class="fa-regular fa-circle-check text-success"></i>{{/sshPublicKey}} {{#hasSshKey}}<i class="fa-regular fa-circle-check text-success"></i>{{/hasSshKey}}
</td> </td>
<td> <td>
{{#isActive}}<i class="fa-regular fa-circle-check text-success"></i>{{/isActive}} {{#isActive}}<i class="fa-regular fa-circle-check text-success"></i>{{/isActive}}