ccbbceaa47
- seed god_admin + nest into app_super_admin; auto-provision site groups (S_super_admin, S_hosts_*/S_apps_* aggregates, S_everyone) on site create + self-heal on Directory load - map service resources to the app kind (site_local_app_<slug>_*); nest per-resource groups into site aggregates (physical inheritance lattice) - enforce the group naming convention server-side on POST /groups; surface god_admin + site groups on the site resource modal - fix in-app /docs/<slug> 500s (Dockerfile never copied docs/); serve doc images at /docs/images - fix Directory status dots (neutral grey when agent endpoint unreachable); align Profile/API cards full-width - group resolver: keep the site slug verbatim (site_local not re-slugified) - bump to 1.26.0
707 lines
28 KiB
Plaintext
707 lines
28 KiB
Plaintext
<%- include('top') %>
|
|
|
|
<script type="text/javascript">
|
|
app.auth.forceLogin();
|
|
var currentUser;
|
|
var isOwnProfile = !location.pathname.includes('/users/');
|
|
|
|
function renderProfile(user){
|
|
user.createTimestamp = moment(user.createTimestamp, "YYYYMMDDHHmmssZ").fromNow();
|
|
user.modifyTimestamp = moment(user.modifyTimestamp, "YYYYMMDDHHmmssZ").fromNow();
|
|
user.managerUids = (user.manager || []).map(app.user.dnToUid);
|
|
$('#profile-uid-header').text(user.uid);
|
|
$.scope.user.update(user);
|
|
};
|
|
|
|
async function renderUserGroups(user){
|
|
try{
|
|
let res = await app.api.get('group/?detail=true&member='+user.uid);
|
|
$.scope.mygroups.empty();
|
|
$.scope.mygroups.push(...res.results);
|
|
}catch(error){
|
|
console.error('renderUserGroups error:', error)
|
|
}
|
|
}
|
|
|
|
async function removeFromGroup(cn, btn){
|
|
const $row = $(btn).closest('tr');
|
|
const confirmed = await app.messages.confirm(`Remove ${currentUser.uid} from "${cn}"?`, $row, 'warning');
|
|
if (!confirmed) return;
|
|
app.api.delete('group/' + encodeURIComponent(cn) + '/' + encodeURIComponent(currentUser.uid), function(error, data){
|
|
if(error){ app.messages.action((data && data.message) || 'Failed to remove from group', $row, 'danger'); return; }
|
|
$.scope.mygroups.remove('cn', cn);
|
|
});
|
|
}
|
|
|
|
var addGroupSelect;
|
|
async function addToGroups(btn){
|
|
const cns = addGroupSelect.get();
|
|
if(!cns.length) return;
|
|
const $card = $(btn).closest('.card-body');
|
|
for(const cn of cns){
|
|
await new Promise(function(resolve){
|
|
app.api.put('group/' + encodeURIComponent(cn) + '/' + encodeURIComponent(currentUser.uid), {}, function(error, data){
|
|
if(error) app.messages.action((data && data.message) || `Failed to add to "${cn}"`, $card, 'danger');
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
addGroupSelect.clear();
|
|
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.messages.confirm(`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.messages.action((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.messages.action((data && data.message) || `Failed to add "${uid}"`, $card, 'danger');
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
addPersonalGroupMemberSelect.clear();
|
|
renderPersonalGroupMembers(currentUser);
|
|
}
|
|
|
|
async function renderMyServices(){
|
|
try{
|
|
let res = await app.api.get('discovery/me');
|
|
res.results.forEach(r => {
|
|
r.resolvedAddress = r.resolvedAddress
|
|
|| (r.metadata && r.metadata.address)
|
|
|| (r.metadata && r.metadata.ip)
|
|
|| 'N/A';
|
|
});
|
|
$.scope.myservices.empty();
|
|
$.scope.myservices.push(...res.results);
|
|
}catch(error){
|
|
console.error('renderMyServices error:', error)
|
|
}
|
|
}
|
|
|
|
async function renderMyMetrics(){
|
|
try{
|
|
let uid = currentUser.uid;
|
|
let res = await app.api.get(`metrics/user/${uid}`);
|
|
if (res && res.results && res.results.services) {
|
|
const el = document.getElementById('metrics-user-services');
|
|
if(!el) return;
|
|
el.innerHTML = '';
|
|
const items = res.results.services;
|
|
if (!items || items.length === 0) {
|
|
el.innerHTML = '<li class="list-group-item text-muted">No data available</li>';
|
|
return;
|
|
}
|
|
items.forEach(item => {
|
|
el.innerHTML += `<li class="list-group-item d-flex justify-content-between align-items-center">
|
|
${item.value}
|
|
<span class="badge bg-primary rounded-pill">${item.score}</span>
|
|
</li>`;
|
|
});
|
|
}
|
|
}catch(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(){
|
|
if(location.pathname.includes('/users/')){
|
|
let uid = location.pathname.replace('/users/', '');
|
|
return (await app.api.get('user/'+uid)).results;
|
|
}else{
|
|
return await app.auth.asyncUser;
|
|
}
|
|
}
|
|
|
|
function editUser(user){
|
|
user = user || currentUser;
|
|
var $profileCard = $('#userProfile');
|
|
var $editCard = $('#editProfile');
|
|
$.scope.editProfile.update(user);
|
|
setTimeout(function(){
|
|
app.ui.userSelect('#edit-manager', {
|
|
name: 'manager',
|
|
values: user.managerUids || [],
|
|
placeholder: 'Type a username…',
|
|
});
|
|
$profileCard.slideUp();
|
|
$editCard.slideDown();
|
|
}, 60);
|
|
}
|
|
|
|
function editUserSeccess(data){
|
|
currentUser = data.results;
|
|
renderProfile(currentUser);
|
|
setTimeout(function(){
|
|
$('#editProfile').slideUp();
|
|
$('#userProfile').slideDown()
|
|
}, 60);
|
|
}
|
|
|
|
async function toggleActive(uid, active){
|
|
app.user.setActive(uid, active, async function(error, data){
|
|
if(error) return app.messages.toast('Failed to update user status', 'danger');
|
|
currentUser = await determinUser();
|
|
renderProfile(currentUser);
|
|
});
|
|
}
|
|
|
|
async function deleteUser(uid, btn){
|
|
const $card = $(btn).closest('.card');
|
|
const confirmed = await app.messages.confirm(`Delete user "${uid}"?`, $card, 'warning');
|
|
if (!confirmed) return;
|
|
app.api.delete('user/' + uid, function(error, data){
|
|
if (error) {
|
|
app.messages.action(data.message || 'Failed to delete user', $card, 'danger');
|
|
return;
|
|
}
|
|
window.location.href = '/users';
|
|
});
|
|
}
|
|
|
|
// 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(){
|
|
currentUser = await determinUser();
|
|
|
|
renderProfile(currentUser);
|
|
renderUserGroups(currentUser);
|
|
renderPersonalGroupMembers(currentUser);
|
|
renderMyServices();
|
|
if(!isOwnProfile) {
|
|
renderMyMetrics();
|
|
$('#tab-metrics').parent().show();
|
|
}
|
|
$('#personal-group-uid-label').text(currentUser.uid);
|
|
|
|
addGroupSelect = app.ui.groupSelect('#add-group-select', {
|
|
name: 'groups', values: [], placeholder: 'Type a group name…',
|
|
});
|
|
addPersonalGroupMemberSelect = app.ui.userSelect('#add-personal-group-member-select', {
|
|
name: 'members', values: [], placeholder: 'Type a username…',
|
|
});
|
|
|
|
if(isOwnProfile){
|
|
$('#own-api-tokens-section').show();
|
|
tableAJAX();
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<div class="container mt-4">
|
|
<div class="row" style="display:none">
|
|
<div class="col-12">
|
|
<div id="userProfile" class="shadow-lg card card-default mb-4">
|
|
<div class="card-header shadow d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<i class="fa-regular fa-id-card"></i>
|
|
Profile: <strong id="profile-uid-header"></strong>
|
|
</div>
|
|
<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 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 <span id="personal-group-uid-label"></span>'s Group
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="card-body">
|
|
<div class="tab-content">
|
|
<!-- Profile Tab -->
|
|
<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}}
|
|
</p>
|
|
<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}}
|
|
</p>
|
|
<p><i>Location (Site):</i> <b>{{location}}</b></p>
|
|
<p><i>LDAP DN:</i> <b>{{dn}}</b></p>
|
|
<p><i>Home Directory:</i> <b>{{homeDirectory}}</b></p>
|
|
</div>
|
|
<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}}
|
|
</p>
|
|
<p><i>Status:</i>
|
|
{{#isActive}}<span class="badge bg-success">Active</span>{{/isActive}}
|
|
{{#isInactive}}<span class="badge bg-danger">Inactive</span>{{/isInactive}}
|
|
</p>
|
|
<p><i>Date of Birth:</i> <b>{{dateOfBirth}}</b></p>
|
|
<p><i>TOS:</i>
|
|
{{#tosAccepted}}<span class="badge bg-success">Accepted</span>{{/tosAccepted}}
|
|
{{#tosNotAccepted}}<span class="badge bg-warning text-dark">Pending</span>{{/tosNotAccepted}}
|
|
</p>
|
|
<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>
|
|
{{#description}}
|
|
<hr>
|
|
<p><i>Description:</i><br>{{description}}</p>
|
|
{{/description}}
|
|
<hr>
|
|
<p class="text-muted small mb-0">
|
|
<i>Joined:</i> <b>{{createTimestamp}}</b> | <i>Edited:</i> <b>{{modifyTimestamp}}</b>
|
|
</p>
|
|
|
|
<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}}
|
|
<button type="button" class="btn btn-outline-warning" title="Deactivate user" onclick="toggleActive('{{uid}}', false)">
|
|
<i class="fa-solid fa-lock"></i> Deactivate
|
|
</button>
|
|
{{/isActive}}
|
|
{{#isInactive}}
|
|
<button type="button" class="btn btn-warning" title="Activate user" onclick="toggleActive('{{uid}}', true)">
|
|
<i class="fa-solid fa-lock-open"></i> Activate
|
|
</button>
|
|
{{/isInactive}}
|
|
<button type="button" class="btn btn-secondary" title="Impersonate this user" onclick="startImpersonate('{{uid}}')">
|
|
<i class="fa-solid fa-user-secret"></i> Impersonate
|
|
</button>
|
|
<button type="button" class="btn btn-danger" onclick="deleteUser('{{uid}}', this)">
|
|
<i class="fa-solid fa-user-slash"></i> Delete User
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 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">
|
|
<i class="fad fa-id-card"></i>
|
|
Edit Profile
|
|
</div>
|
|
<div class="card-header shadow actionMessage" style="display:none"></div>
|
|
<div class="card-body" jq-repeat="editProfile">
|
|
<h3>Editing {{uid}}</h3>
|
|
<form action="user/{{uid}}" method="put" onsubmit="formAJAX(this)" evalAJAX="editUserSeccess(data)">
|
|
<div class="mb-3">
|
|
<label class="form-label">Date of Birth</label>
|
|
<input type="date" class="form-control shadow" name="dateOfBirth" value="{{dateOfBirth}}" />
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">SSH Public Key</label>
|
|
<input type="text" class="form-control" name="sshPublicKey" placeholder="ssh-rsa AAAAB3NzaC1yc2EAAAADAQ..." value="{{sshPublicKey}}" />
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Mobile Phone</label>
|
|
<input type="text" class="form-control" name="mobile" placeholder="9175551234" value="{{mobile}}" />
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Location (Site)</label>
|
|
<input type="text" class="form-control" name="location" placeholder="Site One" value="{{location}}" />
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Home Directory</label>
|
|
<input type="text" class="form-control" name="homeDirectory" placeholder="/home/jsmith" value="{{homeDirectory}}" />
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Login Shell</label>
|
|
<input type="text" class="form-control" name="loginShell" placeholder="/bin/bash" value="{{loginShell}}" />
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Manager(s)</label>
|
|
<div id="edit-manager"></div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">User Description (optional)</label>
|
|
<textarea class="form-control" name="description" placeholder="Admin group for gitea app">{{description}}</textarea>
|
|
</div>
|
|
<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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script type="text/javascript">
|
|
// API Tokens section (same as before)
|
|
var tokensById = {};
|
|
|
|
function showToken(title, token){
|
|
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>'
|
|
+ '<div class="input-group"><input type="text" class="form-control font-monospace" id="revealed-token" readonly value="' + app.util.escapeHtml(token) + '">'
|
|
+ '<button class="btn btn-outline-secondary" onclick="copyFieldValue(\'#revealed-token\')" title="Copy"><i class="fa-solid fa-copy"></i></button></div>'
|
|
+ '<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>'
|
|
});
|
|
}
|
|
|
|
function copyFieldValue(sel){
|
|
var $el = $(sel);
|
|
var text = $el.val();
|
|
if(!text) return;
|
|
navigator.clipboard.writeText(text).then(function(){
|
|
app.messages.toast('Copied to clipboard', 'success');
|
|
}, function(){
|
|
app.messages.toast('Could not copy — select and copy manually', 'danger');
|
|
});
|
|
}
|
|
|
|
function fmtTime(ms){
|
|
if(!ms || Number(ms) === 0) return '—';
|
|
var t = moment(ms, "x");
|
|
if(!t.isValid()) return '—';
|
|
return t.fromNow() + ' <span class="text-muted">(' + t.format('YYYY-MM-DD HH:mm') + ')</span>';
|
|
}
|
|
function fmtExpiry(token){
|
|
var exp = Number(token.expires_at);
|
|
if(!exp) return '<span class="badge text-bg-secondary">never</span>';
|
|
if(Date.now() > exp) return '<span class="badge text-bg-danger">expired</span>';
|
|
return '<span class="badge text-bg-warning">' + moment(exp, "x").fromNow() + '</span>';
|
|
}
|
|
|
|
function processToken(token){
|
|
tokensById[token.id] = token;
|
|
token.id_short = token.id.slice(0, 12) + '…';
|
|
token.expires_display = fmtExpiry(token);
|
|
token.created_display = fmtTime(token.created_on);
|
|
token.last_used_display = fmtTime(token.last_used_on);
|
|
return token;
|
|
}
|
|
|
|
async function tableAJAX(){
|
|
let data = await app.apiToken.list();
|
|
var tokens = data.results || [];
|
|
$.scope.apiTokenCard.empty();
|
|
tokens.forEach(function(token){
|
|
$.scope.apiTokenCard.push(processToken(token));
|
|
});
|
|
$('#api-tokens-empty').toggle(tokens.length === 0);
|
|
}
|
|
|
|
async function revokeToken(id, name, btn){
|
|
var $card = $(btn).closest('.card');
|
|
$card.addClass('table-warning');
|
|
var confirmed = await app.messages.confirm('Revoke API token "' + name + '"? It stops working immediately.', $card, 'warning');
|
|
$card.removeClass('table-warning');
|
|
if(!confirmed) return;
|
|
app.apiToken.remove({id: id}, function(error, data){
|
|
if(error){ app.messages.action('Error: ' + data.message, $card, 'danger'); return; }
|
|
$.scope.apiTokenCard.remove('id', id);
|
|
});
|
|
}
|
|
|
|
async function rotateToken(id, name, btn){
|
|
var $card = $(btn).closest('.card');
|
|
var confirmed = await app.messages.confirm('Rotate API token "' + name + '"? The old token stops working immediately.', $card, 'warning');
|
|
if(!confirmed) return;
|
|
app.apiToken.rotate({id: id}, function(error, data){
|
|
if(error){ app.messages.action('Error: ' + data.message, $card, 'danger'); return; }
|
|
showToken('API Token Rotated', data.token);
|
|
tableAJAX();
|
|
});
|
|
}
|
|
|
|
function createApiToken(){
|
|
var $body = app.modal.open({
|
|
title: 'New API Token',
|
|
bodyHtml:
|
|
'<div class="actionMessage mb-3" style="display:none"></div>'
|
|
+ '<form id="newTokenForm" action="api-token/" method="post" onsubmit="formAJAX(this)" evalAJAX="showToken(\'API Token Created\', data.token); tableAJAX();">'
|
|
+ '<div class="mb-3">'
|
|
+ '<label class="form-label">Name</label>'
|
|
+ '<input type="text" class="form-control shadow" name="name" placeholder="CI user sync" validate=":1">'
|
|
+ '</div>'
|
|
+ '<div class="mb-3">'
|
|
+ '<label class="form-label">Description</label>'
|
|
+ '<input type="text" class="form-control shadow" name="description" placeholder="Used by the nightly sync job">'
|
|
+ '</div>'
|
|
+ '<div class="mb-3">'
|
|
+ '<label class="form-label">Expires in (days) <small class="text-muted">(0 = never)</small></label>'
|
|
+ '<input type="number" class="form-control shadow" name="expires_in_days" value="0" min="0">'
|
|
+ '</div>'
|
|
+ '</form>',
|
|
footer: {
|
|
buttonsHtml: '<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>'
|
|
+ '<button type="submit" form="newTokenForm" class="btn btn-outline-dark"><i class="fa-solid fa-plus"></i> Create</button>',
|
|
},
|
|
});
|
|
$body.find('[name=name]').focus();
|
|
}
|
|
|
|
function editToken(id){
|
|
var t = tokensById[id]; if(!t) return;
|
|
app.modal.open({
|
|
title: 'Edit Token',
|
|
bodyHtml:
|
|
'<input type="hidden" id="edit-token-id" value="' + app.util.escapeHtml(id) + '">'
|
|
+ '<div class="mb-3">'
|
|
+ '<label class="form-label">Name</label>'
|
|
+ '<input type="text" class="form-control shadow" id="edit-token-name" value="' + app.util.escapeHtml(t.name || '') + '">'
|
|
+ '</div>'
|
|
+ '<div class="mb-3">'
|
|
+ '<label class="form-label">Description</label>'
|
|
+ '<input type="text" class="form-control shadow" id="edit-token-description" value="' + app.util.escapeHtml(t.description || '') + '">'
|
|
+ '</div>'
|
|
+ '<div class="mb-3">'
|
|
+ '<label class="form-label">Expires in (days, blank = keep as-is, 0 = never)</label>'
|
|
+ '<input type="number" class="form-control shadow" id="edit-token-days" min="0">'
|
|
+ '</div>',
|
|
footer: {
|
|
metaHtml: 'Created by ' + app.util.escapeHtml(t.created_by || '—') + ' on ' + fmtTime(t.created_on),
|
|
buttonsHtml: app.modal.footerButtons({onSave: 'saveEditToken()', saveLabel: 'Save'}),
|
|
},
|
|
});
|
|
}
|
|
|
|
function saveEditToken(){
|
|
var payload = {
|
|
id: $('#edit-token-id').val(),
|
|
name: $('#edit-token-name').val(),
|
|
description: $('#edit-token-description').val(),
|
|
expires_in_days: $('#edit-token-days').val(),
|
|
};
|
|
app.apiToken.update(payload, function(error, data){
|
|
if(error){ app.messages.action((data && data.message) || 'Update failed.', app.modal.body(), 'danger'); return; }
|
|
app.modal.close();
|
|
tableAJAX();
|
|
});
|
|
}
|
|
</script>
|
|
|
|
<div id="own-api-tokens-section" style="display:none">
|
|
<div class="row mt-3">
|
|
<div class="col-12">
|
|
<div class="card shadow-lg">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<span><i class="fa-solid fa-key me-1"></i> API Tokens</span>
|
|
<span>
|
|
<a href="/docs/api-tokens" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
|
<button class="btn btn-sm btn-primary" onclick="createApiToken()"><i class="fa-solid fa-plus"></i> New token</button>
|
|
</span>
|
|
</div>
|
|
<div class="card-header actionMessage" style="display:none"></div>
|
|
<p class="text-muted small px-3 pt-3 mb-0">A personal access token lets scripts and services call the SSO management API as you, with your permissions. Treat it like a password.</p>
|
|
<div class="card-body">
|
|
<p id="api-tokens-empty" class="text-muted mb-0" style="display:none">No API tokens.</p>
|
|
<div jq-repeat="apiTokenCard" jq-index-key="id" id="apitoken-card-{{id}}" class="card shadow mb-3">
|
|
<div class="card-header">
|
|
<h5><i class="fa-solid fa-key"></i> {{ name }}</h5>
|
|
<small class="text-muted font-monospace">{{ id_short }}</small>
|
|
</div>
|
|
<div class="card-header actionMessage" style="display:none"></div>
|
|
<div class="card-body">
|
|
{{ #description }}<p>{{ description }}</p>{{ /description }}
|
|
<dl class="row mb-0">
|
|
<dt class="col-sm-3">Token ID</dt>
|
|
<dd class="col-sm-9"><code>{{ id_short }}</code></dd>
|
|
<dt class="col-sm-3">Created</dt>
|
|
<dd class="col-sm-9">{{{ created_display }}}</dd>
|
|
<dt class="col-sm-3">Last used</dt>
|
|
<dd class="col-sm-9">{{{ last_used_display }}}</dd>
|
|
<dt class="col-sm-3">Expires</dt>
|
|
<dd class="col-sm-9">{{{ expires_display }}}</dd>
|
|
</dl>
|
|
</div>
|
|
<div class="card-footer">
|
|
<button type="button" onclick="editToken('{{id}}')" class="btn btn-primary btn-sm"><i class="fa-solid fa-pen-to-square"></i> Edit</button>
|
|
<button type="button" onclick="rotateToken('{{id}}', '{{name}}', this)" class="btn btn-warning btn-sm"><i class="fa-solid fa-arrows-rotate"></i> Rotate</button>
|
|
<button type="button" onclick="revokeToken('{{id}}', '{{name}}', this)" class="btn btn-danger btn-sm float-end"><i class="fa-solid fa-trash"></i> Revoke</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<%- include('impersonate_modal') %>
|
|
<%- include('bottom') %>
|