Updated frontend

This commit is contained in:
2026-07-01 11:10:09 -04:00
parent ee21bdbf22
commit 5644bfa5ec
8104 changed files with 14976 additions and 386337 deletions
+168
View File
@@ -0,0 +1,168 @@
<%- include('top') %>
<script type="text/javascript">
app.auth.forceLogin('app_sso_admin');
async function loadDashboard() {
try {
const stats = await app.api.get('user/stats');
// Stat cards
document.getElementById('stat-total').textContent = stats.totalUsers;
document.getElementById('stat-active').textContent = stats.activeUsers;
document.getElementById('stat-inactive').textContent = stats.inactiveUsers;
document.getElementById('stat-groups').textContent = stats.totalGroups;
// Recent signups
stats.recentSignups.forEach(function(u) {
u.createTimestamp = moment(u.createTimestamp, 'YYYYMMDDHHmmssZ').fromNow();
});
$.scope.recentSignups.push(...stats.recentSignups);
// Inactive users
$.scope.inactiveUsers.push(...stats.inactiveList);
document.getElementById('dashboard').style.display = '';
} catch(e) {
if (e && (e.status === 401 || e.name === 'Insufficient Permission')) {
location.replace('/');
} else {
console.error('Dashboard load error:', e);
}
}
}
async function activateUser(uid) {
try {
await app.api.put('user/' + uid + '/active', { active: true });
$.scope.inactiveUsers.remove('uid', uid);
document.getElementById('stat-inactive').textContent =
parseInt(document.getElementById('stat-inactive').textContent) - 1;
document.getElementById('stat-active').textContent =
parseInt(document.getElementById('stat-active').textContent) + 1;
} catch(e) {
alert('Failed to activate user.');
}
}
async function exportUsers() {
const resp = await fetch('/api/user/export', {
headers: { 'auth-token': localStorage.getItem('APIToken') }
});
const blob = await resp.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'users.csv';
a.click();
}
$(document).ready(function() {
loadDashboard();
});
</script>
<div id="dashboard" class="row" style="display:none">
<div class="col-12">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-3 mt-2">
<h4 class="mb-0"><i class="fa-solid fa-screwdriver-wrench"></i> Admin Dashboard</h4>
<button class="btn btn-outline-secondary shadow" onclick="exportUsers()">
<i class="fa-solid fa-file-csv"></i> Export Users CSV
</button>
</div>
<!-- Stat cards -->
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="card shadow text-center">
<div class="card-body">
<div class="display-6 fw-bold" id="stat-total">—</div>
<div class="text-muted small"><i class="fa-solid fa-users"></i> Total Users</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow text-center border-success">
<div class="card-body">
<div class="display-6 fw-bold text-success" id="stat-active">—</div>
<div class="text-muted small"><i class="fa-solid fa-circle-check"></i> Active</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow text-center border-danger">
<div class="card-body">
<div class="display-6 fw-bold text-danger" id="stat-inactive">—</div>
<div class="text-muted small"><i class="fa-solid fa-lock"></i> Inactive</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow text-center border-info">
<div class="card-body">
<div class="display-6 fw-bold text-info" id="stat-groups">—</div>
<div class="text-muted small"><i class="fa-solid fa-users-viewfinder"></i> Groups</div>
</div>
</div>
</div>
</div>
<div class="row g-3">
<!-- Recent signups -->
<div class="col-md-6">
<div class="card shadow-lg">
<div class="card-header shadow">
<i class="fa-solid fa-user-plus"></i> Recent Signups
</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<thead>
<tr><th>User</th><th>Email</th><th>Joined</th></tr>
</thead>
<tbody jq-repeat="recentSignups">
<tr>
<td><a href="/users/{{uid}}">{{uid}}</a><br><small class="text-muted">{{givenName}} {{sn}}</small></td>
<td><small>{{mail}}</small></td>
<td><small class="text-muted">{{createTimestamp}}</small></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Inactive users -->
<div class="col-md-6">
<div class="card shadow-lg">
<div class="card-header shadow">
<i class="fa-solid fa-lock"></i> Inactive Users
</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<thead>
<tr><th>User</th><th>Email</th><th></th></tr>
</thead>
<tbody jq-repeat="inactiveUsers" jq-index-key="uid">
<tr>
<td><a href="/users/{{uid}}">{{uid}}</a><br><small class="text-muted">{{givenName}} {{sn}}</small></td>
<td><small>{{mail}}</small></td>
<td class="text-end">
<button class="btn btn-sm btn-outline-success" onclick="activateUser('{{uid}}')">
<i class="fa-solid fa-lock-open"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<%- include('impersonate_modal') %>
<%- include('bottom') %>
+14 -1
View File
@@ -1,3 +1,16 @@
</div><!-- end spa-shell -->
<footer class="py-2 bg-dark text-light mt-4">
<div class="container-fluid d-flex justify-content-between align-items-center small">
<span>
<a href="https://theta42.com" target="_blank">
<img width="64" src="/static/img/theta42.svg"/>
</a>
&copy; <%- buildYear %> <%- name %>. All rights reserved.
</span>
<a href="/tos" class="text-light text-decoration-none">Terms of Service</a>
<span >v<%- buildVersion %> (<%- buildHash %>)</span>
</div>
</footer>
</body>
</html>
</html>
@@ -0,0 +1,10 @@
module.exports = {
subject: '{{subject}}',
message: `
<p>Hi {{givenName}},</p>
{{{message}}}
<p>—<br>{{ name }}</p>
`,
};
+11
View File
@@ -0,0 +1,11 @@
module.exports = {
subject: 'Your {{ name }} login code',
message: `
<p>Hi {{ givenName }},</p>
<p>Your one-time login code is:</p>
<h2 style="letter-spacing:0.3em;font-family:monospace;font-size:2.5em">{{ code }}</h2>
<p>This code expires in <strong>10 minutes</strong> and can only be used once.</p>
<p>If you did not request this code, you can safely ignore this email.</p>
<p>Thank you,<br/>{{ name }}</p>
`
};
+252 -202
View File
@@ -1,224 +1,274 @@
<%- include('top') %>
<script id="rowTemplate" type="text/html">
<div id="group-card-{{cn}}" class="card shadow">
<div class="card-header">
<h5>
<i class="fad fa-users-class"></i>
Group: {{ cn }}
</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-toggle="tab" href="#group-memmbers-{{cn}}" role="tab" aria-controls="member" aria-selected="true">
<i class="fad fa-users"></i>
Members
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="group-admins-tab-{{cn}}" data-toggle="tab" href="#group-admins-{{cn}}" role="tab" aria-controls="admin" aria-selected="false">
<i class="fad fa-users-crown"></i>
Owners
</a>
</li>
<li class="nav-item float-right">
</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="fad fa-user"></i> {{ uid }}
<button type="button" action="group/{{groupCN}}/{{uid}}" method="delete" onclick="formAJAX(this)" evalAJAX="addedUser(data.message, '{{groupCN}}', '{{uid}}', $form)" class="btn btn-sm btn-danger float-right">
<i class="fad 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-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fad fa-user-plus"></i>
</button>
<div class="dropdown-menu shadow-lg" aria-labelledby="group_add_member">
{{ #toAdd }}{{#.}}
<a class="dropdown-item" action="group/{{groupCN}}/{{uid}}" method="put" onclick="formAJAX(this)" evalAJAX="addedUser(data.message, '{{groupCN}}', '{{uid}}', $form);">
<i class="fad fa-user"></i> {{uid}}
</a>
{{/.}}{{ /toAdd }}
</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="fad fa-user"></i> {{ uid }}
<button type="button" action="group/owner/{{groupCN}}/{{uid}}" method="delete" onclick="formAJAX(this)" evalAJAX="addedUser(data.message, '{{groupCN}}', '{{uid}}', $form)" class="btn btn-sm btn-danger float-right">
<i class="fad fa-user-slash"></i>
</button>
</li>
{{ /owner }}
</ul>
</p>
<div class="dropdown float-left">
<button class="btn btn-secondary dropdown-toggle" type="button" id="group_add_admin" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fad fa-user-crown"></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="fad fa-user"></i> {{uid}}
</a>
{{/.}}{{ /toAddOwner }}
</div>
</div>
</div>
</div>
</div>
<div class="card-footer">
<div class="float-left">
Created: {{createTimestamp}}<br />
Last Mortified: {{modifyTimestamp}}
</div>
<div class="float-right">
<button type="button" onclick="" class="btn btn-warning btn-lg shadow">
<i class="fad fa-edit"></i>
</button>
<button type="button" onclick="app.group.remove({cn: '{{cn}}'}, function(){tableAJAX('Group {{cn}} deleted.')})" class="btn btn-danger btn-lg">
<i class="fad fa-trash"></i>
</button>
</div>
</div>
</div>
<br/>
</script>
<script type="text/javascript">
var userlist;
var allGroups = [];
function getUserList(callback){
app.user.list(function(error, data){
var $target = $('#tableAJAX');
if(error){
app.util.actionMessage(data.message, $target, 'danger');
return;
}
userlist = data.results;
callback()
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] : [];
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;
}
function addedUser(message, group, user, $form){
tableAJAX(null, function(){
app.util.actionMessage(message, $("#group-card-"+group), 'success');
$('a[href="#'+$form.closest('.tab-pane').attr('id')+'"]').tab('show');
setTimeout(function(group){
$("body,html").animate({
scrollTop: $("#group-card-" + group).offset().top
}, 0);
}, 400, group);
});
async function addedUser(message, group, user, $form){
let data = await app.group.get(group);
$.scope.groupCard.update('cn', group, processGroup(data.results));
app.util.actionMessage(message, $("#group-card-"+group), 'success');
$('a[href="#'+$form.closest('.tab-pane').attr('id')+'"]').tab('show');
setTimeout(function(group){
$("body,html").animate({ scrollTop: $("#group-card-" + group).offset().top }, 0);
}, 400, group);
}
function tableAJAX(actionMessage, callback){
var rowTemplate = $('#rowTemplate').html();
var $target = $('#tableAJAX');
$target.html('').hide();
app.util.actionMessage('Refreshing user list...', $target);
app.group.list(function(error, data){
console.log('error 5555', error, data);
var out = ''
$.each(data.results, function(key, value) {
value.toAdd = userlist.map(function(user){
if(!value.member.includes(user.dn)) return user;
})
value.toAddOwner = userlist.map(function(user){
if(!value.owner.includes(user.dn)) return user;
})
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.createTimestamp = moment(value.createTimestamp, "YYYYMMDDHHmmssZ").fromNow();
value.modifyTimestamp = moment(value.modifyTimestamp, "YYYYMMDDHHmmssZ").fromNow();
value.groupCN = value.cn;
out += Mustache.render(rowTemplate, value);
});
$target.html(out);
$target.fadeIn('slow', ($.isFunction(callback) ? callback: function(){})());
app.util.actionMessage(actionMessage || '', $target, 'info');
});
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; }
}
$(document).ready(function(){
getUserList(tableAJAX);
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() {
let data = await app.group.list();
allGroups = data.results.map(processGroup);
applyFilters();
}
async function removeMember(groupCN, uid, btn) {
const $item = $(btn).closest('li');
$item.addClass('list-group-item-warning');
const confirmed = await app.util.actionConfirm(`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.util.actionMessage(data.message, $('#group-card-' + groupCN), 'success');
} catch(e) {
$item.removeClass('list-group-item-warning');
app.util.actionMessage(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.util.actionConfirm(`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.util.actionMessage(data.message, $('#group-card-' + groupCN), 'success');
} catch(e) {
$item.removeClass('list-group-item-warning');
app.util.actionMessage(e.message || 'Failed to remove owner', $('#group-card-' + groupCN), 'danger');
}
}
async function deleteGroup(cn, btn) {
const $card = $(btn).closest('.card');
const confirmed = await app.util.actionConfirm(`Delete group "${cn}"?`, $card, 'danger');
if (!confirmed) return;
try {
await app.api.delete(`group/${cn}`);
$.scope.groupCard.remove('cn', cn);
} catch(e) {
app.util.actionMessage(e.message || 'Failed to delete group', $card, 'danger');
}
}
app.auth.forceLogin('app_sso_admin');
$(document).ready(async function(){
userlist = (await app.user.list()).results;
tableAJAX();
});
</script>
<div class="row" style="display:none">
<div class="col-md-4">
<div class="card shadow-lg">
<div class="card-header">
<i class="fas fa-layer-plus"></i>
Add new group
<div class="row" style="display:none;">
<div class="d-flex gap-2 align-items-center">
<div class="input-group">
<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
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
<form action="group/" method="post" onsubmit="formAJAX(this)" evalAJAX="tableAJAX('')">
<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 class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
<form action="group/" method="post" onsubmit="formAJAX(this)" evalAJAX="tableAJAX('')">
<div class="form-group">
<label class="control-label">Name</label>
<input type="text" class="form-control shadow" name="name" placeholder="app_gitea_admin" validate=":3" />
</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 }}
</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-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 class="form-group">
<label class="control-label">Description</label>
<textarea class="form-control shadow" name="description" placeholder="Admin group for gitea app" validate=":3"></textarea>
</div>
</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" action="group/{{groupCN}}/{{uid}}" method="put" onclick="formAJAX(this)" evalAJAX="addedUser(data.message, '{{groupCN}}', '{{uid}}', $form);">
<i class="fa-solid fa-user"></i> {{uid}}
</a>
{{/.}}{{ /toAdd }}
</div>
</div>
</div>
<button type="submit" class="btn btn-outline-dark">Add</button>
</form>
<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 class="col-md-8 card border-light" id="tableAJAX" style="background-color: initial; border: none">
<div class="card-header actionMessage" style="display:none"></div>
</div>
</div>
<%- include('bottom') %>
-337
View File
@@ -1,337 +0,0 @@
<%- include('top') %>
<script id="profileTemplate" type="text/html">
<div class="card-body profile-body-{{uid}}">
<h2><i>User Name:</i> <b>{{uid}}</b></h2>
<i>Name:</i> <b>{{givenName}} {{sn}}</b><br />
<i>Email:</i> <b>{{mail}} </b><br />
<i>Phone:</i> <b>{{mobile}} </b><br />
<i>LDAP DN:</i> <b>{{dn}} </b><br />
<i>Home Directory:</i> <b>{{homeDirectory}} </b><br />
<i>Login Shell:</i> <b>{{loginShell}} </b><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>
<!-- <img id="profile_photo" /> -->
</div>
<div class="card-footer profile-body-{{uid}}">
<div class="float-left">
<i>Joined:</i> <b>{{createTimestamp}} </b> <br/>
<i>Edited:</i> <b>{{modifyTimestamp}} </b>
</div>
<div class="float-right">
<button type="button" onclick="editUser()" class="btn btn-warning btn shadow ">
<i class="fad fa-user-edit"></i>
</button>
<button type="submit" class="btn btn btn-danger" action="user/{{uid}}" method="delete" onsubmit="formAJAX(this)" evalAJAX="window.location.href = '/users'">
<i class="fad fa-user-slash"></i>
</button>
</div>
</div>
</script>
<script id="groupRowTemplate" type="text/html">
<tr>
<td>{{cn}}</td>
<td>{{description}}</td>
</tr>
</script>
<script id="userEditTemplate" type="text/html">
<h3>Editing {{uid}}</h3>
<form action="user/{{uid}}" method="put" onsubmit="formAJAX(this)" evalAJAX="editUserSeccess(data.message)">
<div class="form-group">
<label class="control-label">SSH Public Key</label>
<input type="text" class="form-control" name="sshPublicKey" placeholder="ssh-rsa AAAAB3NzaC1yc2EAAAADAQ..." value="{{sshPublicKey}}" />
</div>
<div class="form-group">
<label class="control-label">Mobile Phone</label>
<input type="text" class="form-control" name="mobile" placeholder="9175551234" validate=":9" value="{{mobile}}" />
</div>
<div class="form-group">
<label class="control-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">Change</button>
</form>
</script>
<script id="paswordResetTemplate" type="text/html">
<h3>
Reset Password for {{uid}}
</h3>
<form action="user/{{uid}}/password" method="put" onsubmit="formAJAX(this)" class="form-group">
<div class="form-group">
<label class="control-label">Password</label>
<div class="input-group mb-3 shadow">
<div class="input-group-prepend">
<span class="input-group-text" ><i class="fad fa-key"></i></span>
</div>
<input type="password" name="userPassword" class="form-control" placeholder="hunter123!" validate=":3" />
</div>
</div>
<div class="form-group">
<label class="control-label">Again</label>
<div class="input-group mb-3 shadow">
<div class="input-group-prepend">
<span class="input-group-text" ><i class="fad fa-key"></i></span>
</div>
<input type="password" name="password" class="form-control" placeholder="hunter123!" validate="eq:userPassword" />
</div>
</div>
<button type="submit" class="btn btn-outline-secondary shadow">Change</button>
</form>
</script>
<script type="text/javascript">
var currentUser;
function getInvite(){
app.user.createInvite(function(error, data){
$('#invite_token').html(location.origin+"/login/invite/"+data.token);
});
}
function renderProfile(user, message){
var profileTemplate = $('#profileTemplate').html();
var paswordResetTemplate = $('#paswordResetTemplate').html();
var $target = $('#userProfile div.actionMessage');
$('.profile-body-'+user.uid).remove();
app.util.actionMessage(message, $target, 'success')
// data.photo = unescape(encodeURIComponent(data.jpegPhoto));
user.createTimestamp = moment(user.createTimestamp, "YYYYMMDDHHmmssZ").fromNow();
user.modifyTimestamp = moment(user.modifyTimestamp, "YYYYMMDDHHmmssZ").fromNow();
$target.after(Mustache.render(profileTemplate, user));
$('#passwordReset').html(Mustache.render(paswordResetTemplate, user))
};
function renderUserGroups(user){
app.api.get('group/?detail=true&member='+user.uid, function(error, data){
var groupRowTemplate = $('#groupRowTemplate').html();
var $target = $('#mygroups');
$target.html('')
if(error){
app.util.actionMessage(data.message, $target, 'danger');
}else{
for(var group of data.results){
$target.append(Mustache.render(groupRowTemplate, group));
}
}
});
}
function determinUser(callback){
if(location.pathname.includes('/users/')){
var uid = location.pathname.replace('/users/', '');
app.api.get('user/'+uid, function(err, res){
callback(res.results)
})
}else{
callback(app.auth.user)
}
}
function editUser(){
determinUser(function(user){
var $profileCard = $('#userProfile');
var $editCard = $('#editProfile');
var userEditTemplate = $('#userEditTemplate').html()
$editCard.find('.card-body').html(Mustache.render(userEditTemplate, user))
$profileCard.slideUp();
$editCard.slideDown();
});
}
function editUserSeccess(message){
console.log('edit done')
$('#editProfile').slideUp();
app.auth.isLoggedIn(function(){
determinUser(function(user){
currentUser = user;
app.auth.user = user;
renderProfile(user, message);
renderUserGroups(user);
$('#userProfile').slideDown();
});
});
}
$(document).ready(function(){
determinUser(function(user){
currentUser = user;
renderProfile(user);
renderUserGroups(user);
$('#username').text(user.uid);
$('form[action="user/{{uid}}"]').attr('action', 'user/'+user.uid)
});
});
</script>
<style type="text/css">
.services-list i{
padding-right: .5em;
}
</style>
<div class="row" style="display:none">
<div class="col-md-4">
<div class="shadow-lg card mb-3 card-default">
<div class="card-header shadow">
<i class="fas fa-user-plus"></i>
Invite User
<div class="float-right">
<i class="far fa-arrows-v"></i>
</div>
</div>
<div class="card-header shadow actionMessage" style="display:none">
</div>
<div class="card-body">
<button onclick="getInvite(this)" class="btn btn-outline-dark shadow">New Invite Token</button>
<div>
<b id="invite_token"></b>
</div>
</div>
</div>
<div class="shadow-lg card mb-3 card-default">
<div class="card-header shadow">
<div class="float-right">
<i class="far fa-arrows-v"></i>
</div>
<i class="fad fa-th-list"></i>
Services
</div>
<div class="card-header shadow actionMessage" style="display:none">
</div>
<div class="card-body">
<ul class="list-group text-dark services-list">
<li class="list-group-item text-dark">
<i class="fad fa-terminal"></i>
SSH <b id="username"></b>@718it.biz:22
</li>
<a href="https://emby.718it.biz/" target="_blank" class="text-dark">
<li class="list-group-item text-dark">
<i class="fad fa-film"></i>
Emby
</li>
</a>
<a href="https://git.theta42.com" target="_blank" class="text-dark">
<li class="list-group-item text-dark">
<i class="fab fa-git"></i>
Git server
</li>
</a>
<a href="https://rdp.vm42.us" target="_blank" class="text-dark">
<li class="list-group-item text-dark">
<i class="fad fa-desktop"></i>
Virtual Desktops
</li>
</a>
<a href="https://pve.admin.vm42.us" target="_blank" class="text-dark">
<li class="list-group-item text-dark">
<i class="fad fa-server"></i>
Promox (contact wmanlty for access)
</li>
</a>
</ul>
</div>
</div>
<div class="shadow-lg card">
<div class="card-header shadow">
<i class="fad fa-undo-alt"></i>
Password Reset
<div class="float-right">
<i class="far fa-arrows-v"></i>
</div>
</div>
<div class="card-header shadow actionMessage" style="display:none">
</div>
<div id="passwordReset" class="card-body">
</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="fad fa-id-card"></i>
Profile
<div class="float-right">
<i class="far fa-arrows-v"></i>
</div>
</div>
<div class="card-header shadow actionMessage" style="display:none">
</div>
<div class="profile-body">
</div>
</div>
<div id="editProfile" class="shadow-lg card card-default mb-8" style="display:none">
<div class="card-header shadow">
<i class="fad fa-id-card"></i>
Edit Profile
<div class="float-right">
<i class="far fa-arrows-v"></i>
</div>
</div>
<div class="card-header shadow actionMessage" style="display:none">
</div>
<div class="card-body">
<div id="tableAJAX">
</div>
</div>
</div>
<br />
<div class="shadow-lg card card-default mb-8">
<div class="card-header shadow">
<i class="fad fa-id-card"></i>
My groups
<div class="float-right">
<i class="far fa-arrows-v"></i>
</div>
</div>
<div class="card-header shadow actionMessage" style="display:none">
</div>
<div class="card-body" style="padding-bottom:0">
<div class="table-responsive">
<table class="table">
<thead>
<th>
Name
</th>
<th>
Description
</th>
</thead>
<tbody id="mygroups">
</tbody>
</table>
</div>
</div>
</div>
<br />
</div>
</div>
<%- include('bottom') %>
+89
View File
@@ -0,0 +1,89 @@
<!-- Impersonation credentials modal — include in any page that has an impersonate button -->
<div class="modal fade" id="impersonateModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-dark text-white">
<h5 class="modal-title">
<i class="fa-solid fa-user-secret"></i>
Impersonating <span id="impersonateModalTitle" class="fw-bold"></span>
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning d-flex align-items-center mb-3" role="alert">
<i class="fa-solid fa-triangle-exclamation me-2"></i>
<div>The real password is <strong>unchanged</strong>. This temporary credential expires automatically.</div>
</div>
<div class="mb-3">
<label class="form-label fw-bold">Username</label>
<input type="text" class="form-control font-monospace" id="impersonateModalUid" readonly>
</div>
<div class="mb-3">
<label class="form-label fw-bold">Temporary Password</label>
<div class="input-group">
<input type="text" class="form-control font-monospace" id="impersonateModalPassword" readonly>
<button class="btn btn-outline-secondary" id="impersonateCopyBtn" type="button" title="Copy password">
<i class="fa-solid fa-copy"></i>
</button>
</div>
</div>
<p class="text-muted small mb-0">
<i class="fa-solid fa-clock"></i>
Expires at <strong id="impersonateModalExpires"></strong> &mdash; works for any LDAP-backed service.
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger" id="impersonateStopBtn">
<i class="fa-solid fa-circle-stop"></i> Stop Impersonating
</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<script type="text/javascript">
(function(){
// Avoid re-binding if the modal JS was already loaded (e.g. two includes on same page)
if(window._impersonateModalBound) return;
window._impersonateModalBound = true;
$(document).ready(function(){
$('#impersonateCopyBtn').on('click', function(){
var pw = $('#impersonateModalPassword').val();
navigator.clipboard.writeText(pw).then(function(){
$('#impersonateCopyBtn')
.html('<i class="fa-solid fa-check"></i>')
.addClass('btn-success').removeClass('btn-outline-secondary');
setTimeout(function(){
$('#impersonateCopyBtn')
.html('<i class="fa-solid fa-copy"></i>')
.addClass('btn-outline-secondary').removeClass('btn-success');
}, 1500);
});
});
});
})();
function startImpersonate(uid){
app.impersonate.create(uid, function(error, data){
if(error){
alert('Could not start impersonation: ' + (data && data.message ? data.message : 'Unknown error'));
return;
}
$('#impersonateModalTitle').text(data.uid);
$('#impersonateModalUid').val(data.uid);
$('#impersonateModalPassword').val(data.temp_password);
$('#impersonateModalExpires').text(new Date(data.expires_at).toLocaleTimeString());
$('#impersonateStopBtn').off('click').on('click', function(){
app.impersonate.revoke(data.uid, function(err){
$('#impersonateModal').modal('hide');
if(!err) app.util.actionMessage('Impersonation ended for ' + data.uid, $('body'), 'success');
});
});
$('#impersonateModal').modal('show');
});
}
</script>
+304
View File
@@ -0,0 +1,304 @@
<%- include('top') %>
<script type="text/javascript">
app.auth.forceLogin(['app_sso_admin', 'app_sso_invite']);
let allGroups = [];
let currentUserUid = null;
let isAdmin = false;
async function init() {
const [user, groupData] = await Promise.all([
app.api.get('user/me'),
app.api.get('group/'),
]);
currentUserUid = user.uid;
isAdmin = (user.memberOf || []).some(dn => dn.startsWith('cn=app_sso_admin,'));
allGroups = groupData.results || [];
// Populate create-form group select
populateGroupSelect('create-groups', allGroups, []);
loadInvites();
}
function fuzzyMatch(query, text) {
query = query.toLowerCase();
text = text.toLowerCase();
let qi = 0;
for (let i = 0; i < text.length && qi < query.length; i++) {
if (text[i] === query[qi]) qi++;
}
return qi === query.length;
}
function filterGroups(inputEl, selectId) {
const query = inputEl.value;
[...document.getElementById(selectId).options].forEach(function(opt) {
opt.hidden = query ? !fuzzyMatch(query, opt.value) : false;
});
}
function populateGroupSelect(id, groups, selected) {
const sel = document.getElementById(id);
sel.innerHTML = '';
groups.forEach(function(cn) {
const opt = document.createElement('option');
opt.value = cn;
opt.textContent = cn;
opt.selected = selected.includes(cn);
sel.appendChild(opt);
});
}
function inviteStatus(t) {
if (t.claimed_by && t.claimed_by !== '__NONE__') return { label: 'Claimed', cls: 'text-secondary' };
if (!t.is_valid) return { label: 'Revoked', cls: 'text-danger' };
return { label: 'Pending', cls: 'text-success' };
}
function groupsDisplay(groups) {
try {
const arr = JSON.parse(groups || '[]');
return arr.length ? arr.join(', ') : '—';
} catch(_) { return '—'; }
}
async function loadInvites() {
try {
const data = await app.api.get('user/invite');
const tbody = document.getElementById('invite-tbody');
tbody.innerHTML = '';
(data.results || [])
.sort((a, b) => (b.created_on || 0) - (a.created_on || 0))
.forEach(function(t) {
const status = inviteStatus(t);
const canEdit = isAdmin || t.created_by === currentUserUid;
const mail = (!t.mail || t.mail === '__NONE__') ? '—' : t.mail;
const isPending = t.is_valid && (!t.claimed_by || t.claimed_by === '__NONE__');
const tr = document.createElement('tr');
tr.dataset.token = t.token;
tr.innerHTML = `
<td><small class="text-muted">${moment(Number(t.created_on)).fromNow()}</small></td>
<td><small>${t.created_by}</small></td>
<td><small>${mail}</small></td>
<td><small class="text-muted">${groupsDisplay(t.groups)}</small></td>
<td><small class="${status.cls}">${status.label}</small></td>
<td class="text-nowrap">
${canEdit && isPending ? `
<button class="btn btn-sm btn-outline-secondary me-1" onclick="openEdit('${t.token}')">
<i class="fa-solid fa-pen"></i>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="revokeInvite('${t.token}', this)">
<i class="fa-solid fa-ban"></i>
</button>
` : ''}
${isPending ? `
<button class="btn btn-sm btn-outline-dark ms-1" onclick="copyLink('${t.token}', this)">
<i class="fa-solid fa-copy"></i>
</button>
` : ''}
</td>
`;
tbody.appendChild(tr);
});
} catch(e) {
if (e && e.status === 401) location.replace('/');
else console.error('Failed to load invites:', e);
}
}
async function createInvite() {
const mail = document.getElementById('create-email').value.trim();
const groups = [...document.getElementById('create-groups').selectedOptions].map(o => o.value);
const result = document.getElementById('create-result');
result.style.display = 'none';
try {
const data = await app.api.post('user/invite', { mail, groups });
result.style.display = '';
if (data.mail_sent) {
result.className = 'alert alert-success mt-2';
result.textContent = `Invite sent to ${mail}`;
} else {
result.className = 'alert alert-info mt-2';
result.innerHTML = `Link: <a href="${data.link}" target="_blank">${data.link}</a>`;
}
document.getElementById('create-email').value = '';
[...document.getElementById('create-groups').options].forEach(o => o.selected = false);
loadInvites();
} catch(e) {
result.style.display = '';
result.className = 'alert alert-danger mt-2';
result.textContent = 'Failed: ' + ((e.responseJSON && e.responseJSON.message) || 'Unknown error');
}
}
function openEdit(tokenId) {
// Find cached data from the DOM isn't reliable — re-fetch
app.api.get('user/invite').then(function(data) {
const t = (data.results || []).find(x => x.token === tokenId);
if (!t) return;
document.getElementById('edit-token').value = tokenId;
document.getElementById('edit-email').value = (t.mail && t.mail !== '__NONE__') ? t.mail : '';
const selected = JSON.parse(t.groups || '[]');
populateGroupSelect('edit-groups', allGroups, selected);
const modal = new bootstrap.Modal(document.getElementById('editModal'));
modal.show();
});
}
async function saveEdit() {
const tokenId = document.getElementById('edit-token').value;
const mail = document.getElementById('edit-email').value.trim();
const groups = [...document.getElementById('edit-groups').selectedOptions].map(o => o.value);
const result = document.getElementById('edit-result');
result.style.display = 'none';
try {
await app.api.put(`user/invite/${tokenId}`, { mail, groups });
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
loadInvites();
} catch(e) {
result.style.display = '';
result.className = 'alert alert-danger mt-2';
result.textContent = 'Failed: ' + ((e.responseJSON && e.responseJSON.message) || 'Unknown error');
}
}
async function revokeInvite(tokenId, btn) {
$thisRow = $(btn).closest('tr');
$thisRow.addClass('table-warning');
let confirmation = await app.util.actionConfirm('Revoke selected invite token?', $thisRow, 'warning');
if(!confirmation){
$thisRow.removeClass('table-warning');
return;
}
// lets not use `confirm``
// if (!confirm('Revoke this invite token?')) return;
try {
await app.api.delete(`user/invite/${tokenId}`);
loadInvites();
} catch(e) {
alert('Failed to revoke invite.');
}
}
function copyLink(tokenId, btn) {
const link = `${location.origin}/login/invite/${tokenId}`;
navigator.clipboard.writeText(link).then(function() {
const orig = btn.innerHTML;
btn.innerHTML = '<i class="fa-solid fa-check"></i>';
setTimeout(() => btn.innerHTML = orig, 1500);
});
}
$(document).ready(function() {
init();
});
</script>
<div class="row">
<div class="col-12">
<div class="d-flex align-items-center mb-3 mt-2">
<h4 class="mb-0"><i class="fa-solid fa-envelope-open-text"></i> Invites</h4>
</div>
<div class="row g-3">
<!-- Create invite -->
<div class="col-lg-4">
<div class="card shadow-lg">
<div class="card-header shadow">
<i class="fa-solid fa-plus"></i> Create Invite
</div>
<div class="card-body">
<div class="mb-2">
<label class="form-label small">Email <small class="text-muted">(optional)</small></label>
<input type="email" id="create-email" class="form-control shadow" placeholder="user@example.com" />
</div>
<div class="mb-2">
<label class="form-label small">Groups <small class="text-muted">(Ctrl/⌘ for multiple)</small></label>
<input type="text" class="form-control form-control-sm shadow mb-1" placeholder="Filter groups…" oninput="filterGroups(this, 'create-groups')" />
<select id="create-groups" class="form-select shadow" multiple size="5"></select>
</div>
<button class="btn btn-primary shadow" onclick="createInvite()">
<i class="fa-solid fa-paper-plane"></i> Send Invite
</button>
<div id="create-result" style="display:none" class="mt-2"></div>
</div>
</div>
</div>
<!-- Invite list -->
<div class="col-lg-8">
<div class="card shadow-lg">
<div class="card-header shadow d-flex justify-content-between align-items-center">
<span><i class="fa-solid fa-list"></i> Invite Tokens</span>
<button class="btn btn-sm btn-outline-secondary" onclick="loadInvites()">
<i class="fa-solid fa-rotate"></i>
</button>
</div>
<div class="card-header actionMessage" style="display:none;"></div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<thead>
<tr>
<th>Created</th>
<th>By</th>
<th>Email</th>
<th>Groups</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody id="invite-tbody">
<tr><td colspan="6" class="text-center text-muted py-3">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Edit modal -->
<div class="modal fade" id="editModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fa-solid fa-pen"></i> Edit Invite</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="edit-token" />
<div class="mb-3">
<label class="form-label">Email <small class="text-muted">(leave blank to clear; changing sends a new verification email)</small></label>
<input type="email" id="edit-email" class="form-control shadow" placeholder="user@example.com" />
</div>
<div class="mb-3">
<label class="form-label">Groups <small class="text-muted">(Ctrl/⌘ for multiple)</small></label>
<input type="text" class="form-control form-control-sm shadow mb-1" placeholder="Filter groups…" oninput="filterGroups(this, 'edit-groups')" />
<select id="edit-groups" class="form-select shadow" multiple size="6"></select>
</div>
<div id="edit-result" style="display:none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveEdit()">
<i class="fa-solid fa-floppy-disk"></i> Save
</button>
</div>
</div>
</div>
</div>
<%- include('impersonate_modal') %>
<%- include('bottom') %>
+102 -33
View File
@@ -3,44 +3,55 @@
app.auth.isLoggedIn(function(error, isLoggedIn){
if(isLoggedIn){
window.location.href = app.util.getUrlParameter('redirect') || '/';
app.auth.logInRedirect();
}
})
$(document).ready(function(){
});
function setOtpMethod(method) {
$('#otpMethodInput').val(method);
$('#otpMethodEmail').toggleClass('active', method === 'email').toggleClass('btn-secondary', method === 'email').toggleClass('btn-outline-secondary', method !== 'email');
$('#otpMethodSms').toggleClass('active', method === 'sms').toggleClass('btn-secondary', method === 'sms').toggleClass('btn-outline-secondary', method !== 'sms');
}
function resetOtpForm() {
$('#otpStep2').hide();
$('#otpStep1').show().find('input[name=login]').val('');
setOtpMethod('email');
}
</script>
<div class="row">
<div class="card-deck">
<div class="shadow-lg card mb-3">
<div class="col-md-1">
</div>
<div class="col-md-3">
<div class="shadow-lg card">
<div class="card-header shadow">
Password Log in
</div>
<div class="card-header shadow actionMessage" style="display:none">
</div>
<div class="card-body">
<form action="auth/login" onsubmit="formAJAX(this)" evalAJAX="app.auth.setToken(data.token);window.location.href = app.util.getUrlParameter('redirect') || '/';">
<input type="hidden" name="redirect" value="<%= redirect %>">
<div class="form-group">
<label class="control-label">User name</label>
<div class="input-group mb-3 shadow">
<div class="input-group-prepend">
<span class="input-group-text" ><i class="fad fa-user"></i></span>
</div>
<input type="text" name="uid" class="form-control" placeholder="jsmith" validate="user:3" />
<form action="auth/login" onsubmit="formAJAX(this)" evalAJAX="
app.auth.setToken(data.token);
app.auth.logInRedirect();
">
<input type="hidden" name="redirect" value="<%= redirect %>">
<div class="mb-3">
<label></label>
<div class="input-group">
<span class="input-group-text" id="addon-wrapping"><i class="fa-solid fa-user-tie"></i></span>
<input type="text" name="username" class="form-control" placeholder="jsmith" aria-label="Username" aria-describedby="addon-wrapping">
</div>
</div>
<div class="form-group">
<label class="control-label">Password</label>
<div class="input-group mb-3 shadow">
<div class="input-group-prepend">
<span class="input-group-text" ><i class="fad fa-key"></i></span>
</div>
<input type="password" name="password" class="form-control" placeholder="hunter123!" validate=":3" />
<div class="mb-3">
<div class="input-group">
<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>
@@ -48,7 +59,9 @@
</form>
</div>
</div>
<div class="shadow-lg card border-danger mb-3">
</div>
<div class="col-md-3">
<div class="shadow-lg card border-danger">
<div class="card-header shadow">
Social Login
</div>
@@ -65,7 +78,65 @@
</p>
</div>
</div>
<div class="shadow-lg card mb-3">
</div>
<div class="col-md-3">
<div class="shadow-lg card">
<div class="card-header shadow">
<i class="fa-solid fa-mobile-screen"></i>
Login with a Code
</div>
<div class="card-header shadow actionMessage" style="display:none"></div>
<div class="card-body">
<form id="otpStep1" action="auth/otp/request" onsubmit="formAJAX(this)" evalAJAX="
$('#otpVerifyLogin').val(formData.login);
$('#otpSentMsg').text('Code sent via ' + data.method + '. Expires at ' + new Date(data.expires_at).toLocaleTimeString() + '.');
$('#otpStep1').hide();
$('#otpStep2').show();
setTimeout(function(){ $('#otpCode').focus(); }, 100);
">
<input type="hidden" name="method" id="otpMethodInput" value="email">
<div class="mb-3">
<div class="input-group">
<span class="input-group-text"><i class="fa-solid fa-user-tie"></i></span>
<input type="text" name="login" class="form-control" placeholder="username or email">
</div>
</div>
<div class="mb-3">
<div class="btn-group w-100" role="group">
<button type="button" id="otpMethodEmail" class="btn btn-secondary active" onclick="setOtpMethod('email')">
<i class="fa-solid fa-envelope"></i> Email
</button>
<button type="button" id="otpMethodSms" class="btn btn-outline-secondary" onclick="setOtpMethod('sms')">
<i class="fa-solid fa-mobile-screen"></i> SMS
</button>
</div>
</div>
<button type="submit" class="btn btn-outline-dark">
<i class="fa-solid fa-paper-plane"></i> Send Code
</button>
</form>
<form id="otpStep2" action="auth/otp/verify" onsubmit="formAJAX(this)" style="display:none" evalAJAX="
app.auth.setToken(data.token);
app.auth.logInRedirect();
">
<input type="hidden" name="login" id="otpVerifyLogin">
<p class="text-muted small mb-3" id="otpSentMsg"></p>
<div class="mb-3">
<div class="input-group">
<span class="input-group-text"><i class="fa-solid fa-hashtag"></i></span>
<input type="text" name="code" id="otpCode" class="form-control" placeholder="123456" maxlength="6" inputmode="numeric" autocomplete="one-time-code">
</div>
</div>
<button type="submit" class="btn btn-outline-dark me-2">
<i class="fa-solid fa-check"></i> Verify
</button>
<button type="button" class="btn btn-link btn-sm text-muted" onclick="resetOtpForm()">Start over</button>
</form>
</div>
</div>
</div>
<div class="col-md-3">
<div class="shadow-lg card">
<div class="card-header shadow">
Password Reset
</div>
@@ -81,13 +152,11 @@
<form action="auth/resetpassword" onsubmit="formAJAX(this)">
<input type="hidden" name="redirect" value="<%= redirect %>">
<div class="form-group">
<label class="control-label">Email</label>
<div class="input-group mb-3 shadow">
<div class="input-group-prepend">
<span class="input-group-text" ><i class="fad fa-at"></i></span>
</div>
<input type="email" name="mail" class="form-control" placeholder="jsmith@gmail.com" validate="email:3" />
<div class="mb-3">
<label></label>
<div class="input-group">
<span class="input-group-text" id="addon-wrapping"><i class="fad fa-at"></i></span>
<input type="text" name="mail" class="form-control" placeholder="jsmith" aria-label="Email" aria-describedby="addon-wrapping">
</div>
</div>
+190
View File
@@ -0,0 +1,190 @@
<%- include('top') %>
<script type="text/javascript">
app.auth.forceLogin('app_sso_admin');
async function loadHistory() {
try {
const data = await app.api.get('notification');
const list = data.results || [];
list.forEach(function(n) {
n.created_on_fmt = moment(n.created_on).fromNow();
n.filter_label = formatFilterLabel(n.filter_type, n.filter_value, n.active_only);
});
$.scope.notificationHistory.push(...list);
} catch(e) {
if (e && e.status === 401) location.replace('/');
else console.error('Failed to load notification history:', e);
}
}
function formatFilterLabel(type, value, active_only) {
const suffix = active_only ? ' (active only)' : '';
if (type === 'group') return 'Groups: ' + value + suffix;
if (type === 'users') return 'Specific users';
if (type === 'all_active') return 'All active users';
if (type === 'all') return 'All users';
return type;
}
function toggleFilterInputs() {
const type = document.querySelector('input[name="notif-filter"]:checked').value;
document.getElementById('notif-group-row').style.display = type === 'group' ? '' : 'none';
document.getElementById('notif-users-row').style.display = type === 'users' ? '' : 'none';
document.getElementById('notif-active-row').style.display = (type === 'group' || type === 'all') ? '' : 'none';
}
async function sendNotification() {
const subject = document.getElementById('notif-subject').value.trim();
const message = document.getElementById('notif-message').value.trim();
const filterType = document.querySelector('input[name="notif-filter"]:checked').value;
const groupValue = document.getElementById('notif-group').value.trim();
const usersValue = document.getElementById('notif-users').value.trim();
const activeOnly = document.getElementById('notif-active-only').checked;
const msgEl = document.getElementById('notif-result');
if (!subject || !message) { alert('Subject and message are required.'); return; }
let filter_value = '';
if (filterType === 'group') filter_value = groupValue;
if (filterType === 'users') filter_value = JSON.stringify(usersValue.split(',').map(s => s.trim()).filter(Boolean));
msgEl.className = 'alert alert-info mt-2';
msgEl.textContent = 'Sending…';
msgEl.style.display = '';
try {
const result = await app.api.post('notification', {
subject,
message,
filter_type: filterType,
filter_value,
active_only: activeOnly,
});
msgEl.className = 'alert alert-success mt-2';
msgEl.textContent = `Sent to ${result.results.sent_count} recipient(s). ${result.results.failed_count} failed.`;
const n = result.results;
n.created_on_fmt = 'just now';
n.filter_label = formatFilterLabel(n.filter_type, n.filter_value, n.active_only);
$.scope.notificationHistory.unshift([n]);
// Reset form
document.getElementById('notif-subject').value = '';
document.getElementById('notif-message').value = '';
} catch(e) {
msgEl.className = 'alert alert-danger mt-2';
msgEl.textContent = 'Failed: ' + ((e.responseJSON && e.responseJSON.message) || e.message || 'Unknown error');
}
}
$(document).ready(function() {
loadHistory();
toggleFilterInputs();
});
</script>
<div class="row">
<div class="col-12">
<div class="d-flex align-items-center mb-3 mt-2">
<h4 class="mb-0"><i class="fa-solid fa-paper-plane"></i> Notifications</h4>
</div>
<div class="row g-3">
<!-- Compose -->
<div class="col-lg-6">
<div class="card shadow-lg">
<div class="card-header shadow">
<i class="fa-solid fa-pencil"></i> Compose
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Subject</label>
<input type="text" class="form-control shadow" id="notif-subject" placeholder="Maintenance window tonight" />
</div>
<div class="mb-3">
<label class="form-label">Message <small class="text-muted">(HTML allowed)</small></label>
<textarea class="form-control shadow" id="notif-message" rows="6" placeholder="<p>Hello, we will be performing maintenance...</p>"></textarea>
</div>
<div class="mb-3">
<label class="form-label">Send to</label>
<div class="form-check">
<input class="form-check-input" type="radio" name="notif-filter" id="f-all-active" value="all_active" checked onchange="toggleFilterInputs()">
<label class="form-check-label" for="f-all-active">All active users</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="notif-filter" id="f-all" value="all" onchange="toggleFilterInputs()">
<label class="form-check-label" for="f-all">All users (including inactive)</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="notif-filter" id="f-group" value="group" onchange="toggleFilterInputs()">
<label class="form-check-label" for="f-group">Group members</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="notif-filter" id="f-users" value="users" onchange="toggleFilterInputs()">
<label class="form-check-label" for="f-users">Specific users</label>
</div>
</div>
<div id="notif-group-row" class="mb-3" style="display:none">
<label class="form-label">Groups <small class="text-muted">(comma-separated)</small></label>
<input type="text" class="form-control shadow" id="notif-group" placeholder="host_hec-bot_admin, app_sso_admin" />
</div>
<div id="notif-users-row" class="mb-3" style="display:none">
<label class="form-label">UIDs <small class="text-muted">(comma-separated)</small></label>
<input type="text" class="form-control shadow" id="notif-users" placeholder="wmantly, jsmith" />
</div>
<div id="notif-active-row" class="mb-3" style="display:none">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="notif-active-only" checked>
<label class="form-check-label" for="notif-active-only">Active members only</label>
</div>
</div>
<button class="btn btn-primary shadow" onclick="sendNotification()">
<i class="fa-solid fa-paper-plane"></i> Send
</button>
<div id="notif-result" style="display:none" class="mt-2"></div>
</div>
</div>
</div>
<!-- History -->
<div class="col-lg-6">
<div class="card shadow-lg">
<div class="card-header shadow">
<i class="fa-solid fa-clock-rotate-left"></i> History
</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<thead>
<tr>
<th>Sent</th>
<th>Subject</th>
<th>Filter</th>
<th class="text-center">✓</th>
<th class="text-center">✗</th>
</tr>
</thead>
<tbody jq-repeat="notificationHistory" jq-index-key="notification_id">
<tr>
<td><small class="text-muted">{{created_on_fmt}}</small></td>
<td><small>{{subject}}</small></td>
<td><small class="text-muted">{{filter_label}}</small></td>
<td class="text-center text-success"><small>{{sent_count}}</small></td>
<td class="text-center text-danger"><small>{{failed_count}}</small></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<%- include('impersonate_modal') %>
<%- include('bottom') %>
+90
View File
@@ -0,0 +1,90 @@
<%- include('top') %>
<script type="text/javascript">
// OAuth params embedded by server — not user input
var oauthParams = <%- JSON.stringify(params) %>;
var clientInfo = <%- JSON.stringify(oauthClient) %>;
$(document).ready(function(){
// Override the global hold-ready redirect so we control the login destination
app.auth.isLoggedIn(function(error, isLoggedIn){
if(error || !isLoggedIn){
app.auth.logOut(function(){});
var returnUrl = '/oauth/authorize?' + $.param(oauthParams);
location.replace('/login?redirect=' + encodeURIComponent(returnUrl));
return;
}
$('div.row').fadeIn('slow');
});
$('#btn-deny').on('click', function(){
var redirectUrl = new URL(oauthParams.redirect_uri);
redirectUrl.searchParams.set('error', 'access_denied');
if(oauthParams.state) redirectUrl.searchParams.set('state', oauthParams.state);
window.location.href = redirectUrl.toString();
});
$('#btn-allow').on('click', function(){
var $btn = $(this);
$btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm"></span> Authorizing...');
app.api.post('oauth/authorize', oauthParams, function(error, data){
if(error){
$btn.prop('disabled', false).html('<i class="fa-solid fa-check"></i> Allow');
app.util.actionMessage(data.message || 'Authorization failed.', $('#authorize-card'), 'danger');
return;
}
window.location.href = data.redirect_url;
});
});
});
</script>
<div class="row" style="display:none">
<div class="col-md-4 offset-md-4">
<div id="authorize-card" class="card shadow-lg">
<div class="card-header">
<i class="fa-solid fa-key"></i>
Authorization Request
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
<h5><i class="fa-solid fa-server"></i> <%= oauthClient.name %></h5>
<% if(oauthClient.description){ %>
<p class="text-muted"><%= oauthClient.description %></p>
<% } %>
<hr>
<p><strong><%= oauthClient.name %></strong> is requesting access to:</p>
<ul class="list-group mb-3">
<% for(var i = 0; i < scopes.length; i++){ %>
<li class="list-group-item">
<% if(scopes[i] === 'openid'){ %>
<i class="fa-solid fa-id-card"></i> Verify your identity
<% } else if(scopes[i] === 'profile'){ %>
<i class="fa-solid fa-user"></i> Read your name and username
<% } else if(scopes[i] === 'email'){ %>
<i class="fa-solid fa-envelope"></i> Read your email address
<% } else { %>
<i class="fa-solid fa-circle-question"></i> <%= scopes[i] %>
<% } %>
</li>
<% } %>
</ul>
<div class="d-grid gap-2">
<button id="btn-allow" class="btn btn-success">
<i class="fa-solid fa-check"></i> Allow
</button>
<button id="btn-deny" class="btn btn-outline-danger">
<i class="fa-solid fa-xmark"></i> Deny
</button>
</div>
</div>
<div class="card-footer text-muted small">
You are granting access to: <code><%= params.redirect_uri %></code>
</div>
</div>
</div>
</div>
<%- include('bottom') %>
+190
View File
@@ -0,0 +1,190 @@
<%- include('top') %>
<!-- Secret modal -->
<div class="modal fade" id="secretModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fa-solid fa-key"></i> Client Secret</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="text-danger"><i class="fa-solid fa-triangle-exclamation"></i> Save this secret now — it will <strong>not</strong> be shown again.</p>
<div class="input-group">
<input type="text" id="secretValue" class="form-control font-monospace" readonly>
<button class="btn btn-outline-secondary" onclick="copySecret()" title="Copy">
<i class="fa-solid fa-copy"></i>
</button>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Done</button>
</div>
</div>
</div>
</div>
<script type="text/javascript">
app.auth.forceLogin('app_sso_oauth_admin');
var secretModal = new bootstrap.Modal(document.getElementById('secretModal'));
function showSecret(secret){
document.getElementById('secretValue').value = secret;
secretModal.show();
}
function copySecret(){
var el = document.getElementById('secretValue');
el.select();
document.execCommand('copy');
}
function fmtTTL(seconds){
if(seconds < 3600) return seconds + 's';
if(seconds < 86400) return (seconds / 3600).toFixed(1) + 'h';
return (seconds / 86400).toFixed(1) + 'd';
}
function processClient(client){
client.scopes_display = (client.scopes || []).join(' ');
client.access_token_ttl = fmtTTL((client.token_lifetime || {}).access_token || 3600);
client.refresh_token_ttl = fmtTTL((client.token_lifetime || {}).refresh_token || 2592000);
return client;
}
async function tableAJAX(){
let data = await app.oauthClient.list();
$.scope.oauthClientCard.empty();
$.each(data.results, function(_, client){
$.scope.oauthClientCard.push(processClient(client));
});
}
async function deleteClient(client_id, name, btn){
const $card = $(btn).closest('.card');
$card.addClass('table-warning');
const confirmed = await app.util.actionConfirm('Delete OAuth client "' + name + '"?', $card, 'warning');
$card.removeClass('table-warning');
if (!confirmed) return;
app.api.delete('oauth/client/' + client_id, function(error, data){
if(error){ app.util.actionMessage('Error: ' + data.message, $card, 'danger'); return; }
$.scope.oauthClientCard.remove('client_id', client_id);
});
}
async function rotateSecret(client_id, name, btn){
const $card = $(btn).closest('.card');
const confirmed = await app.util.actionConfirm('Rotate secret for "' + name + '"? The old secret will stop working immediately.', $card, 'warning');
if (!confirmed) return;
app.oauthClient.rotateSecret({client_id: client_id}, function(error, data){
if(error){ app.util.actionMessage('Error: ' + data.message, $card, 'danger'); return; }
showSecret(data.client_secret);
});
}
$(document).ready(function(){
tableAJAX();
$('form[action="oauth/client/"]').attr('evalAJAX',
'showSecret(data.client_secret); tableAJAX(); $form.trigger("reset");'
);
});
</script>
<div class="row" style="display:none">
<div class="col-md-4">
<div class="card shadow-lg">
<div class="card-header">
<i class="fa-solid fa-plus"></i>
Register OAuth Client
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
<form action="oauth/client/" method="post" onsubmit="formAJAX(this)">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control shadow" name="name" placeholder="Home Assistant" validate=":1">
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<input type="text" class="form-control shadow" name="description" placeholder="Home automation dashboard">
</div>
<div class="mb-3">
<label class="form-label">Redirect URIs <small class="text-muted">(one per line)</small></label>
<textarea class="form-control shadow font-monospace" name="redirect_uris" rows="3"
placeholder="https://ha.example.com/auth/external/callback" validate=":1"></textarea>
</div>
<div class="mb-3">
<label class="form-label">Scopes <small class="text-muted">(space-separated)</small></label>
<input type="text" class="form-control shadow" name="scopes" value="openid profile email">
</div>
<div class="row mb-3">
<div class="col">
<label class="form-label">Access Token TTL <small class="text-muted">(seconds)</small></label>
<input type="number" class="form-control shadow" name="token_lifetime[access_token]" value="3600" min="60">
</div>
<div class="col">
<label class="form-label">Refresh Token TTL <small class="text-muted">(seconds)</small></label>
<input type="number" class="form-control shadow" name="token_lifetime[refresh_token]" value="2592000" min="3600">
</div>
</div>
<button type="submit" class="btn btn-outline-dark">
<i class="fa-solid fa-plus"></i> Register
</button>
</form>
</div>
</div>
</div>
<div class="col-md-8" style="background-color: initial; border: none">
<div class="card-header actionMessage" style="display:none"></div>
<div jq-repeat="oauthClientCard" jq-index-key="client_id" id="oauth-card-{{client_id}}" class="card shadow mb-3">
<div class="card-header">
<h5>
<i class="fa-solid fa-server"></i>
{{ name }}
</h5>
<small class="text-muted font-monospace">{{ client_id }}</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">Redirect URIs</dt>
<dd class="col-sm-9">
<ul class="list-unstyled mb-0">
{{ #redirect_uris }}
<li><code>{{ . }}</code></li>
{{ /redirect_uris }}
</ul>
</dd>
<dt class="col-sm-3">Scopes</dt>
<dd class="col-sm-9"><code>{{ scopes_display }}</code></dd>
<dt class="col-sm-3">Access Token</dt>
<dd class="col-sm-9">{{ access_token_ttl }}</dd>
<dt class="col-sm-3">Refresh Token</dt>
<dd class="col-sm-9">{{ refresh_token_ttl }}</dd>
<dt class="col-sm-3">Created by</dt>
<dd class="col-sm-9">{{ created_by }}</dd>
</dl>
</div>
<div class="card-footer">
<button type="button"
onclick="rotateSecret('{{client_id}}', '{{name}}', this)"
class="btn btn-warning btn-sm">
<i class="fa-solid fa-arrows-rotate"></i> Rotate Secret
</button>
<button type="button"
onclick="deleteClient('{{client_id}}', '{{name}}', this)"
class="btn btn-danger btn-sm float-end">
<i class="fa-solid fa-trash"></i> Delete
</button>
</div>
</div>
</div>
</div>
<%- include('bottom') %>
+187
View File
@@ -0,0 +1,187 @@
<%- include('top') %>
<script type="text/javascript">
app.auth.forceLogin();
var currentUser;
async function loadOnboarding() {
currentUser = await app.auth.asyncUser;
var needs = currentUser.onboardingNeeds || [];
if (!needs.length) {
location.replace('/');
return;
}
if (needs.includes('tos')) {
document.getElementById('section-tos').style.display = '';
}
if (needs.includes('dob')) {
document.getElementById('section-dob').style.display = '';
}
if (needs.includes('password')) {
document.getElementById('section-password').style.display = '';
}
}
function checkAllDone() {
var sections = document.querySelectorAll('.onboarding-section');
var anyVisible = false;
sections.forEach(function(el) {
if (el.style.display !== 'none') anyVisible = true;
});
if (!anyVisible) location.replace('/');
}
async function acceptTos() {
var checkbox = document.getElementById('tosCheckbox');
if (!checkbox.checked) {
alert('Please read and check the box to accept the Terms of Service.');
return;
}
try {
var resp = await fetch('/api/user/accept-tos', {
method: 'POST',
headers: { 'auth-token': localStorage.getItem('APIToken') }
});
if (!resp.ok) throw new Error('Failed');
document.getElementById('section-tos').style.display = 'none';
checkAllDone();
} catch(e) {
alert('Could not save TOS acceptance. Please try again.');
}
}
async function saveDob() {
var dob = document.getElementById('dobInput').value;
if (!dob) {
alert('Please enter your date of birth.');
return;
}
try {
var resp = await fetch('/api/user/' + currentUser.uid, {
method: 'PUT',
headers: {
'auth-token': localStorage.getItem('APIToken'),
'Content-Type': 'application/json'
},
body: JSON.stringify({ dateOfBirth: dob })
});
if (!resp.ok) throw new Error('Failed');
document.getElementById('section-dob').style.display = 'none';
checkAllDone();
} catch(e) {
alert('Could not save date of birth. Please try again.');
}
}
async function changePassword() {
var pw = document.getElementById('pwInput').value;
var pw2 = document.getElementById('pwInput2').value;
if (!pw || pw.length < 5) {
alert('Password must be at least 5 characters.');
return;
}
if (pw !== pw2) {
alert('Passwords do not match.');
return;
}
try {
var resp = await fetch('/api/user/password', {
method: 'PUT',
headers: {
'auth-token': localStorage.getItem('APIToken'),
'Content-Type': 'application/json'
},
body: JSON.stringify({ userPassword: pw, password: pw2 })
});
if (!resp.ok) throw new Error('Failed');
document.getElementById('section-password').style.display = 'none';
checkAllDone();
} catch(e) {
alert('Could not change password. Please try again.');
}
}
$(document).ready(function() {
loadOnboarding();
});
</script>
<div class="row justify-content-center" style="display:none">
<div class="col-md-8">
<div class="card shadow-lg mb-4 mt-2">
<div class="card-header shadow">
<i class="fa-solid fa-circle-user"></i> Account Setup
</div>
<div class="card-body">
<p class="text-muted mb-0">Please complete the steps below before continuing.</p>
</div>
</div>
<!-- TOS -->
<div id="section-tos" class="onboarding-section card shadow-lg mb-4" style="display:none">
<div class="card-header shadow">
<i class="fa-solid fa-file-contract"></i> Terms of Service
</div>
<div class="card-body">
<div class="border rounded p-3 mb-3" style="max-height:300px;overflow-y:auto">
<%- tosHtml %>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="tosCheckbox">
<label class="form-check-label" for="tosCheckbox">
I have read and agree to the Terms of Service
</label>
</div>
<button class="btn btn-primary shadow" onclick="acceptTos()">
<i class="fa-solid fa-check"></i> Accept
</button>
</div>
</div>
<!-- DOB -->
<div id="section-dob" class="onboarding-section card shadow-lg mb-4" style="display:none">
<div class="card-header shadow">
<i class="fa-solid fa-calendar"></i> Date of Birth
</div>
<div class="card-body">
<p class="text-muted">Your date of birth is required for account verification.</p>
<div class="mb-3">
<label class="form-label">Date of Birth</label>
<input type="date" class="form-control shadow" id="dobInput" />
</div>
<button class="btn btn-primary shadow" onclick="saveDob()">
<i class="fa-solid fa-check"></i> Save
</button>
</div>
</div>
<!-- Password -->
<div id="section-password" class="onboarding-section card shadow-lg mb-4" style="display:none">
<div class="card-header shadow">
<i class="fa-solid fa-key"></i> Change Password
</div>
<div class="card-body">
<p class="text-muted">You must set a new password before continuing.</p>
<div class="mb-3">
<label class="form-label">New Password</label>
<input type="password" class="form-control shadow" id="pwInput" placeholder="At least 5 characters" />
</div>
<div class="mb-3">
<label class="form-label">Confirm Password</label>
<input type="password" class="form-control shadow" id="pwInput2" placeholder="Retype password" />
</div>
<button class="btn btn-primary shadow" onclick="changePassword()">
<i class="fa-solid fa-check"></i> Change Password
</button>
</div>
</div>
</div>
</div>
<%- include('bottom') %>
+310
View File
@@ -0,0 +1,310 @@
<%- include('top') %>
<script type="text/javascript">
app.auth.forceLogin();
var currentUser;
function renderProfile(user){
// data.photo = unescape(encodeURIComponent(data.jpegPhoto));
user.createTimestamp = moment(user.createTimestamp, "YYYYMMDDHHmmssZ").fromNow();
user.modifyTimestamp = moment(user.modifyTimestamp, "YYYYMMDDHHmmssZ").fromNow();
$.scope.user.update(user);
$.scope.passwordReset.update(user);
};
async function renderUserGroups(user){
try{
let res = await app.api.get('group/?detail=true&member='+user.uid);
$.scope.mygroups.push(...res.results);
}catch(error){
console.error('renderUserGroups error:', error)
}
}
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);
$profileCard.slideUp();
$editCard.slideDown();
}
function editUserSeccess(data){
currentUser = data.results;
renderProfile(currentUser);
$('#editProfile').slideUp();
$('#userProfile').slideDown()
}
async function toggleActive(uid, active){
app.user.setActive(uid, active, async function(error, data){
if(error) return alert('Failed to update user status');
currentUser = await determinUser();
renderProfile(currentUser);
});
}
async function deleteUser(uid, btn){
const $card = $(btn).closest('.card');
const confirmed = await app.util.actionConfirm(`Delete user "${uid}"?`, $card, 'warning');
if (!confirmed) return;
app.api.delete('user/' + uid, function(error, data){
if (error) {
app.util.actionMessage(data.message || 'Failed to delete user', $card, 'danger');
return;
}
window.location.href = '/users';
});
}
$(document).ready(async function(){
currentUser = await determinUser();
renderProfile(currentUser);
renderUserGroups(currentUser);
});
</script>
<div class="row mb-3" style="display:none">
<div class="col-md-4">
<div class="shadow-lg card mb-3 card-default">
<div class="card-header shadow">
<div class="float-end">
<i class="fa-solid fa-arrows-up-down"></i>
</div>
<i class="fa-solid fa-list-ul"></i>
Services
</div>
<div class="card-header shadow actionMessage" style="display:none">
</div>
<div class="card-body">
<ul class="list-group text-dark services-list">
<li class="list-group-item text-dark">
<i class="fa-solid fa-terminal"></i>
SSH <b id="username"></b>@718it.biz:22
</li>
<a href="https://emby.718it.biz/" target="_blank" class="text-dark">
<li class="list-group-item text-dark">
<i class="fa-solid fa-film"></i>
Emby
</li>
</a>
<a href="https://git.theta42.com" target="_blank" class="text-dark">
<li class="list-group-item text-dark">
<i class="fab fa-git"></i>
Git server
</li>
</a>
<a href="https://rdp.vm42.us" target="_blank" class="text-dark">
<li class="list-group-item text-dark">
<i class="fa-solid fa-display"></i>
Virtual Desktops
</li>
</a>
<a href="https://pve.admin.vm42.us" target="_blank" class="text-dark">
<li class="list-group-item text-dark">
<i class="fa-solid fa-server"></i>
Promox (contact wmanlty for access)
</li>
</a>
</ul>
</div>
</div>
<div class="shadow-lg card">
<div class="card-header shadow">
<i class="fa-solid fa-arrow-rotate-left"></i>
Password Reset
<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>
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 class="profile-body" jq-repeat="user">
<div class="card-body profile-body-{{uid}}">
<h2><i>User Name:</i> <b>{{uid}}</b></h2>
<i>Name:</i> <b>{{givenName}} {{sn}}</b><br />
<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}}
<br />
<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}}
<br />
<i>LDAP DN:</i> <b>{{dn}} </b><br />
<i>Home Directory:</i> <b>{{homeDirectory}} </b><br />
<i>Login Shell:</i> <b>{{loginShell}} </b><br />
<i>Status:</i>
{{#isActive}}<span class="badge bg-success">Active</span>{{/isActive}}
{{#isInactive}}<span class="badge bg-danger">Inactive</span>{{/isInactive}}
<br />
<i>Date of Birth:</i> <b>{{dateOfBirth}}</b><br />
<i>TOS:</i>
{{#tosAccepted}}<span class="badge bg-success">Accepted</span>{{/tosAccepted}}
{{#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>
<!-- <img id="profile_photo" /> -->
</div>
<div class="card-footer profile-body-{{uid}}">
<div class="float-end">
<button type="button" onclick="editUser()" class="btn btn-warning btn shadow ">
<i class="fa-solid fa-user-pen"></i>
</button>
{{#isActive}}
<button type="button" class="btn btn-outline-warning shadow group-required group-required-app_sso_admin" title="Deactivate user" onclick="toggleActive('{{uid}}', false)">
<i class="fa-solid fa-lock"></i>
</button>
{{/isActive}}
{{#isInactive}}
<button type="button" class="btn btn-warning shadow group-required group-required-app_sso_admin" title="Activate user" onclick="toggleActive('{{uid}}', true)">
<i class="fa-solid fa-lock-open"></i>
</button>
{{/isInactive}}
<button type="button" class="btn btn-secondary shadow group-required group-required-app_sso_admin" title="Impersonate this user" onclick="startImpersonate('{{uid}}')">
<i class="fa-solid fa-user-secret"></i>
</button>
<button type="button" class="btn btn-danger shadow group-required group-required-app_sso_admin" onclick="deleteUser('{{uid}}', this)">
<i class="fa-solid fa-user-slash"></i>
</button>
</div>
<div class="float-left">
<i>Joined:</i> <b>{{createTimestamp}} </b> <br/>
<i>Edited:</i> <b>{{modifyTimestamp}} </b>
</div>
</div>
</div>
</div>
<div id="editProfile" class="shadow-lg card card-default mb-8" style="display:none">
<div class="card-header shadow">
<i class="fad fa-id-card"></i>
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 class="card-body" jq-repeat="editProfile">
<div id="tableAJAX">
<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" validate=":9" value="{{mobile}}" />
</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">Change</button>
</form>
</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">
<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" style="padding-bottom:0">
<div class="table-responsive">
<table class="table">
<thead>
<th>
Name
</th>
<th>
Description
</th>
</thead>
<tbody jq-repeat="mygroups">
<tr>
<td>{{cn}}</td>
<td>{{description}}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<%- include('impersonate_modal') %>
<%- include('bottom') %>
+50 -195
View File
@@ -1,217 +1,72 @@
<%- include('top') %>
<script id="rowTemplate" type="text/html">
<div id="group-card-{{cn}}" class="card shadow">
<div class="card-header">
<h5>
<i class="fad fa-users-class"></i>
Group: {{ cn }}
</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-toggle="tab" href="#group-memmbers-{{cn}}" role="tab" aria-controls="member" aria-selected="true">
<i class="fad fa-users"></i>
Members
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="group-admins-tab-{{cn}}" data-toggle="tab" href="#group-admins-{{cn}}" role="tab" aria-controls="admin" aria-selected="false">
<i class="fad fa-users-crown"></i>
Owners
</a>
</li>
<li class="nav-item float-right">
</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="fad fa-user"></i> {{ uid }}
<button type="button" action="group/{{groupCN}}/{{uid}}" method="delete" onclick="formAJAX(this)" evalAJAX="addedUser(data.message, '{{groupCN}}', '{{uid}}', $form)" class="btn btn-sm btn-danger float-right">
<i class="fad 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-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fad fa-user-plus"></i>
</button>
<div class="dropdown-menu shadow-lg" aria-labelledby="group_add_member">
{{ #toAdd }}{{#.}}
<a class="dropdown-item" action="group/{{groupCN}}/{{uid}}" method="put" onclick="formAJAX(this)" evalAJAX="addedUser(data.message, '{{groupCN}}', '{{uid}}', $form);">
<i class="fad fa-user"></i> {{uid}}
</a>
{{/.}}{{ /toAdd }}
</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="fad fa-user"></i> {{ uid }}
<button type="button" action="group/owner/{{groupCN}}/{{uid}}" method="delete" onclick="formAJAX(this)" evalAJAX="addedUser(data.message, '{{groupCN}}', '{{uid}}', $form)" class="btn btn-sm btn-danger float-right">
<i class="fad fa-user-slash"></i>
</button>
</li>
{{ /owner }}
</ul>
</p>
<div class="dropdown float-left">
<button class="btn btn-secondary dropdown-toggle" type="button" id="group_add_admin" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fad fa-user-crown"></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="fad fa-user"></i> {{uid}}
</a>
{{/.}}{{ /toAddOwner }}
</div>
</div>
</div>
</div>
</div>
<div class="card-footer">
<div class="float-left">
Created: {{createTimestamp}}<br />
Last Mortified: {{modifyTimestamp}}
</div>
<div class="float-right">
<button type="button" onclick="" class="btn btn-warning btn-lg shadow">
<i class="fad fa-edit"></i>
</button>
<button type="button" onclick="app.group.remove({cn: '{{cn}}'}, function(){tableAJAX('Group {{cn}} deleted.')})" class="btn btn-danger btn-lg">
<i class="fad fa-trash"></i>
</button>
</div>
</div>
</div>
<br/>
</script>
<script type="text/javascript">
app.auth.forceLogin();
var userlist;
function getUserList(callback){
app.user.list(function(error, data){
userlist = data.results;
callback()
async function loadTypes(){
let data = await app.token.list();
let $select = $('#tokenType');
$select.empty().append('<option value="">-- Select type --</option>');
$.each(data.results, function(_, name){
$select.append($('<option>').val(name).text(name));
});
}
function addedUser(message, group, user, $form){
tableAJAX(null, function(){
app.util.actionMessage(message, $("#group-card-"+group), 'success');
$('a[href="#'+$form.closest('.tab-pane').attr('id')+'"]').tab('show');
setTimeout(function(group){
$("body,html").animate({
scrollTop: $("#group-card-" + group).offset().top
}, 0);
}, 400, group);
async function loadTokens(name){
$.scope.tokenRow.empty();
if(!name) return;
let data = await app.token.list(name);
if(!data.results || !data.results.length){
$.scope.tokenRow.push({created_by: '(none)', created_on: '', is_valid: ''});
return;
}
$.each(data.results, function(_, token){
token.created_on = token.created_on ? moment(token.created_on).fromNow() : '';
token.is_valid_label = token.is_valid ? 'Yes' : 'No';
$.scope.tokenRow.push(token);
});
}
function tableAJAX(actionMessage, callback){
var rowTemplate = $('#rowTemplate').html();
var $target = $('#tableAJAX');
$target.html('').hide();
app.util.actionMessage('Refreshing user list...', $target);
app.group.list(function(error, data){
var out = ''
$.each(data.results, function(key, value) {
value.toAdd = userlist.map(function(user){
if(!value.member.includes(user.dn)) return user;
})
value.toAddOwner = userlist.map(function(user){
if(!value.owner.includes(user.dn)) return user;
})
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.createTimestamp = moment(value.createTimestamp, "YYYYMMDDHHmmssZ").fromNow();
value.modifyTimestamp = moment(value.modifyTimestamp, "YYYYMMDDHHmmssZ").fromNow();
value.groupCN = value.cn;
out += Mustache.render(rowTemplate, value);
});
$target.html(out);
$target.fadeIn('slow', ($.isFunction(callback) ? callback: function(){})());
app.util.actionMessage(actionMessage || '', $target, 'info');
$(document).ready(async function(){
await loadTypes();
$('#tokenType').on('change', function(){
loadTokens($(this).val());
});
}
$(document).ready(function(){
getUserList(tableAJAX);
});
</script>
<div class="row" style="display:none">
<div class="col-md-4">
<div class="col-12">
<div class="card shadow-lg">
<div class="card-header">
<i class="fas fa-layer-plus"></i>
Add new group
<i class="fa-solid fa-ticket"></i>
Token Viewer
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
<form action="group/" method="post" onsubmit="formAJAX(this)" evalAJAX="tableAJAX('')">
<div class="form-group">
<label class="control-label">Name</label>
<input type="text" class="form-control shadow" name="name" placeholder="app_gitea_admin" validate=":3" />
</div>
<div class="form-group">
<label class="control-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 class="mb-3">
<label class="form-label">Token Type</label>
<select id="tokenType" class="form-select shadow" style="max-width:300px">
<option value="">-- Select type --</option>
</select>
</div>
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Created By</th>
<th>Created</th>
<th>Valid</th>
</tr>
</thead>
<tbody>
<tr jq-repeat="tokenRow">
<td>{{ created_by }}</td>
<td>{{ created_on }}</td>
<td>{{ is_valid_label }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-md-8" id="tableAJAX">
</div>
</div>
<%- include('bottom') %>
+116 -31
View File
@@ -3,38 +3,123 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>SSO Manager - <%= name %></title>
<!-- CSS are placed here -->
<link rel="stylesheet" href="/static/css/bootstrap-4.4.1.min.css">
<link rel='stylesheet' href='/static/css/styles.css' />
<!-- Scripts are placed here -->
<script type="text/javascript" src='/static/js/jquery-3.5.0.min.js'></script>
<script type="text/javascript" src="/static/js/popper-1.16.0.min.js"></script>
<script type="text/javascript" src="/static/js/bootstrap-4.4.1.min.js"></script>
<script type="text/javascript" src="/static/js/all.min.js"></script>
<script type="text/javascript" src='/static/js/mustache.min.js'></script>
<script type="text/javascript" src="/static/js/val.js"></script>
<script type="text/javascript" src="/static/js/moment.js"></script>
<script type="text/javascript" src="/static/js/jq-repeat.js"></script>
<script type="text/javascript" src="/static/js/app.js"></script>
<title>SSO - Theta 42 <%- title %></title>
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<!-- CSS are placed here -->
<link rel="stylesheet" href="/static-modules/bootstrap/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="/static-modules/@fortawesome/fontawesome-free/css/all.min.css">
<link rel='stylesheet' href='/static/css/styles.css' />
<!-- Scripts are placed here -->
<script type="text/javascript" src="/socket.io/socket.io.js"></script>
<script type="text/javascript" src='/static-modules/jquery/dist/jquery.js'></script>
<!-- <script type="text/javascript" src="/static/lib/js/popper-1.16.0.min.js"></script> -->
<!-- <script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.min.js"></script> -->
<script type="text/javascript" src="/static-modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script type="text/javascript" src="/static-modules/@fortawesome/fontawesome-free/js/all.min.js"></script>
<script type="text/javascript" src='/static-modules/mustache/mustache.min.js'></script>
<script type="text/javascript" src='/static-modules/jq-repeat/dist/js/jq-repeat.js'></script>
<script type="text/javascript" src='/static/lib/js/val.js'></script>
<script type="text/javascript" src="/static-modules/moment/moment.js"></script>
<script type="text/javascript" src="/static/lib/js/app-base.js"></script>
<script type="text/javascript" src="/static/js/app.js"></script>
<!-- HTML5 shim, for IE6-8 support of HTML5 elements -->
<!--[if lt IE 9]>
<script src="http://html5shim.googlecode.com/svn/trunk/html5.js"></script>
<![endif]-->
</head>
<body>
<header class="shadow d-flex flex-column flex-md-row align-items-center p-3 px-md-4 mb-3 bg-white border-bottom shadow-sm">
<h5 class="hover-effect my-0 mr-md-auto font-weight-normal">SSO Manager - <%= name %></h5>
<nav class="my-2 my-md-0 mr-md-3">
<a class="text-dark hover-effect" href="/"><i class="fad fa-tachometer-alt-fastest"></i>Home</a>
<a class="text-dark hover-effect" href="/users"><i class="fad fa-users"></i>Users</a>
<a class="text-dark hover-effect" href="/groups"><i class="fad fa-users-class"></i>Groups</a>
</nav>
<a class="hover-effect btn btn-outline-primary" onclick="app.auth.logOut(e => window.location.href='/')"><i class="fas fa-sign-out"></i> Log Out</a>
<!-- HTML5 shim, for IE6-8 support of HTML5 elements -->
<!--[if lt IE 9]>
<script src="http://html5shim.googlecode.com/svn/trunk/html5.js"></script>
<![endif]-->
</head>
<body>
</header>
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-dark">
<a class="navbar-brand" href="#">SSO Manager <%- titleIcon %></a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse justify-content-end" id="navbarSupportedContent">
<ul class="navbar-nav top-nav">
<li class="nav-item">
<a class="nav-link active" href="/">
<i class="fa-regular fa-id-card"></i>
Profile
</a>
</li>
<li class="nav-item group-required group-required-app_sso_admin">
<a class="nav-link" href="/users"><i class="fa-solid fa-users"></i>
Users
</a>
</li>
<li class="nav-item group-required group-required-app_sso_admin">
<a class="nav-link" href="/groups"><i class="fa-solid fa-users-viewfinder"></i>
Groups
</a>
</li>
<li class="nav-item group-required group-required-app_sso_admin">
<a class="nav-link" href="/oauth-clients">
<i class="fa-solid fa-key"></i>
OAuth Apps
</a>
</li>
<li class="nav-item group-required group-required-app_sso_admin group-required-app_sso_invite">
<a class="nav-link" href="/invites">
<i class="fa-solid fa-envelope-open-text"></i>
Invites
</a>
</li>
<li class="nav-item group-required group-required-app_sso_admin">
<a class="nav-link" href="/notifications">
<i class="fa-solid fa-paper-plane"></i>
Notifications
</a>
</li>
<li class="nav-item group-required group-required-app_sso_admin">
<a class="nav-link" href="/admin">
<i class="fa-solid fa-screwdriver-wrench"></i>
Admin
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="https://github.com/theta42/sso-manager-node" target="_blank">
<i class="fa-brands fa-github"></i>
</a>
</li>
</ul>
<div class="form-inline mt-2 mt-md-0">
<a id="cl-login-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.forceLogin()" style="display: none;">
<i class="fas fa-sign-out"></i>
Login
</a>
<!-- Container -->
<div class="container">
<button id="cl-logout-button" class="btn btn-outline-danger my-2 my-sm-0" onclick="app.auth.logOut(e => window.location.href='/')" style="display: none;">
<i class="fas fa-sign-out"></i>
Log Out
</button>
</div>
</div>
</nav>
<script type="text/javascript">
$(document).ready(async function(){
// Set the correct link to active in the top nav bar
$('.top-nav a').each(function(index){
let $this = $(this);
$this.removeClass('active');
if($this.attr('href').toLocaleLowerCase() === window.location.pathname.toLocaleLowerCase()){
$this.addClass('active')
}
})
// Set the correct login/logout button
if(await app.auth.isLoggedIn()) $('#cl-logout-button').show();
else $('#cl-login-button').show();
});
</script>
<!-- Container -->
<div id="spa-shell" class="container-fluid">
<div class="actionMessage" style="display:none;"></div>
+14
View File
@@ -0,0 +1,14 @@
<%- include('top') %>
<div class="row justify-content-center" style="display:none">
<div class="col-md-8">
<div class="card shadow-lg mt-4 mb-4">
<div class="card-header shadow">
<i class="fa-solid fa-file-contract"></i> Terms of Service
</div>
<div class="card-body">
<%- tosHtml %>
</div>
</div>
</div>
</div>
<%- include('bottom') %>
+87 -19
View File
@@ -1,41 +1,109 @@
<script>
function renderUsernameSuggestions(suggestions) {
const container = document.getElementById('usernameOptions');
const hidden = document.getElementById('selectedUid');
const selector = document.getElementById('usernameSelector');
container.innerHTML = '';
if (!suggestions || !suggestions.length) return;
selector.style.display = '';
suggestions.forEach(function(uid, i) {
const btn = document.createElement('button');
btn.type = 'button';
btn.dataset.uid = uid;
btn.textContent = uid;
btn.className = 'btn ' + (i === 0 ? 'btn-secondary' : 'btn-outline-secondary');
btn.onclick = function() {
container.querySelectorAll('.btn').forEach(function(b) {
b.className = 'btn btn-outline-secondary';
});
btn.className = 'btn btn-secondary';
hidden.value = uid;
const override = document.getElementById('adminUidOverride');
if (override) override.value = '';
};
container.appendChild(btn);
});
hidden.value = suggestions[0];
}
async function fetchUsernameSuggestions() {
const gnEl = document.querySelector('[name="givenName"]');
const snEl = document.querySelector('[name="sn"]');
const dobEl = document.querySelector('[name="dob"]');
if (!gnEl || !snEl) return;
const gn = gnEl.value.trim();
const sn = snEl.value.trim();
const dob = dobEl ? dobEl.value.trim() : '';
if (gn.length < 2 || sn.length < 2) return;
try {
const params = new URLSearchParams({ givenName: gn, sn, dob });
const resp = await fetch('/api/auth/username-suggestions?' + params);
const data = await resp.json();
renderUsernameSuggestions(data.suggestions);
} catch(e) {}
}
</script>
<form action="user/" method="post" onsubmit="formAJAX(this)">
<input type="hidden" class="form-control" name="delete" value="false" />
<div class="form-group">
<label class="control-label">First name</label>
<input type="text" class="form-control shadow" name="givenName" placeholder="John" validate=":3" />
<div class="mb-3">
<label class="form-label">First name</label>
<input type="text" class="form-control shadow" name="givenName" placeholder="John" validate=":3" onblur="fetchUsernameSuggestions()" />
<div class="invalid-feedback"></div>
</div>
<div class="form-group">
<label class="control-label">Last name</label>
<input type="text" class="form-control shadow" name="sn" placeholder="smith" validate=":3" />
<div class="mb-3">
<label class="form-label">Last name</label>
<input type="text" class="form-control shadow" name="sn" placeholder="smith" validate=":3" onblur="fetchUsernameSuggestions()" />
</div>
<div class="form-group">
<label class="control-label">Email</label>
<div class="mb-3">
<label class="form-label">Date of Birth</label>
<input type="date" class="form-control shadow" name="dob" required onchange="fetchUsernameSuggestions()" />
</div>
<div class="mb-3" id="usernameSelector" style="display:none">
<label class="form-label">Username</label>
<div id="usernameOptions" class="d-flex flex-wrap gap-2 mb-2"></div>
<input type="hidden" name="uid" id="selectedUid" />
<% if (locals.adminMode) { %>
<input type="text" class="form-control shadow mt-2" id="adminUidOverride"
placeholder="Custom username (admin only)"
oninput="document.getElementById('selectedUid').value = this.value.trim() || (document.querySelector('#usernameOptions .btn-secondary') && document.querySelector('#usernameOptions .btn-secondary').dataset.uid) || ''" />
<% } %>
</div>
<div class="mb-3">
<label class="form-label">Email</label>
<input type="text" class="form-control shadow" name="mail" placeholder="jsmith@gmail.com" validate="email:3" />
</div>
<div class="form-group">
<label class="control-label">SSH Public Key</label>
<div class="mb-3">
<label class="form-label">SSH Public Key</label>
<input type="text" class="form-control shadow" name="sshPublicKey" placeholder="ssh-rsa AAAAB3NzaC1yc2EAAAADAQ..." />
</div>
<div class="form-group">
<label class="control-label">Mobile Phone</label>
<input type="text" class="form-control shadow" name="mobile" placeholder="9175551234" validate=":9" />
<div class="mb-3">
<label class="form-label">Mobile Phone <small class="text-muted">(optional, include country code e.g. +14155551234)</small></label>
<input type="text" class="form-control shadow" name="mobile" placeholder="+14155551234" />
</div>
<div class="form-group">
<label class="control-label">Password</label>
<div class="mb-3">
<label class="form-label">Password</label>
<input type="password" class="form-control shadow" name="userPassword" placeholder="Atleast 5 char. long" validate="password:5"/>
</div>
<div class="form-group">
<label class="control-label">Again</label>
<div class="mb-3">
<label class="form-label">Again</label>
<input type="password" class="form-control shadow" name="passwordMatch" placeholder="Retype password" validate="eq:userPassword"/>
</div>
<div class="form-group">
<label class="control-label">User Description (Optional)</label>
<div class="mb-3">
<label class="form-label">User Description (Optional)</label>
<textarea class="form-control shadow" name="description" placeholder="Admin group for gitea app"></textarea>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="tosAgree" name="tosAgree" required>
<label class="form-check-label" for="tosAgree">
I have read and agree to the <a href="/tos" target="_blank">Terms of Service</a>
</label>
</div>
<button type="submit" class="btn btn-outline-dark">Add</button>
</form>
+157 -52
View File
@@ -1,68 +1,132 @@
<%- include('top') %>
<script id="rowTemplate" type="text/html">
<tr class="" action="user/password/{{ username }}" method="put" evalAJAX="$form.trigger('reset')">
<td>
{{ uidNumber }}
</td>
<td>
<a href='/users/{{uid}}'>{{ uid }}</a>
</td>
<td>
{{givenName}} {{sn}}
</td>
<td>
{{mail}}
</td>
<td>
{{#sudoUser}}<i class="fad fa-check-square"></i>{{/sudoUser}}
</td>
<td>
{{#sshPublicKey}}<i class="fad fa-check-square"></i>{{/sshPublicKey}}
</td>
<td>
<form action="user/{{uid}}" method="delete" onsubmit="formAJAX(this)" evalAJAX="renderUsers('Deleted {{uid}}', 'success')">
<button type="submit" class="btn btn-sm btn-danger">
<i class="fad fa-user-slash"></i>
</button>
</form>
</td>
</tr>
</script>
<script type="text/javascript">
function renderUsers(actionMessage, type){
var rowTemplate = $('#rowTemplate').html();
var $target = $('#tableAJAX');
$target.html('').hide();
app.util.actionMessage('Refreshing user list...', $target);
app.user.list(function(error, data){
if(error){
console.log('data', data)
app.util.actionMessage(data.message, $target, 'danger');
return;
}
$.each( data.results, function(key, value) {
if(value.uidNumber<1500) return;
user_row = Mustache.render(rowTemplate, value);
$target.append(user_row);
});
$target.fadeIn('slow');
app.util.actionMessage(actionMessage || '', $target, type || 'info');
$.scope.userRow.push(...data.results);
});
}
$(document).ready(function(){
renderUsers(); //populate the table
$('form[action="user/"]').attr('evalAJAX', 'renderUsers("User added", "success")')
});
function toggleActive(uid, active){
app.user.setActive(uid, active, function(error, data){
if(error) return alert('Failed to update user status');
renderUsers();
});
}
async function deleteUser(uid, btn){
const $row = $(btn).closest('tr');
$row.addClass('table-warning');
const confirmed = await app.util.actionConfirm(`Delete user "${uid}"?`, $row, 'warning');
$row.removeClass('table-warning');
if (!confirmed) return;
app.api.delete('user/' + uid, function(error, data){
if (error) {
app.util.actionMessage(data.message || 'Failed to delete user', $row, 'danger');
return;
}
renderUsers();
});
}
function fuzzyMatch(query, text) {
query = query.toLowerCase();
text = text.toLowerCase();
let qi = 0;
for (let i = 0; i < text.length && qi < query.length; i++) {
if (text[i] === query[qi]) qi++;
}
return qi === query.length;
}
function filterGroups(inputEl, selectId) {
const query = inputEl.value;
[...document.getElementById(selectId).options].forEach(function(opt) {
opt.hidden = query ? !fuzzyMatch(query, opt.value) : false;
});
}
async function loadInviteGroups(){
const data = await app.api.get('group/');
const sel = document.getElementById('invite-groups');
(data.results || []).forEach(function(cn){
const opt = document.createElement('option');
opt.value = cn;
opt.textContent = cn;
sel.appendChild(opt);
});
}
async function sendInvite(){
const mail = document.getElementById('invite-email').value.trim();
const groups = [...document.getElementById('invite-groups').selectedOptions].map(o => o.value);
const result = document.getElementById('invite-result');
result.style.display = 'none';
try{
const data = await app.api.post('user/invite', { mail, groups });
result.style.display = '';
if(data.mail_sent){
result.className = 'alert alert-success mt-2';
result.textContent = `Invite sent to ${mail}`;
} else {
result.className = 'alert alert-info mt-2';
result.innerHTML = `Link: <a href="${data.link}" target="_blank">${data.link}</a>`;
}
document.getElementById('invite-email').value = '';
[...document.getElementById('invite-groups').options].forEach(o => o.selected = false);
}catch(e){
result.style.display = '';
result.className = 'alert alert-danger mt-2';
result.textContent = 'Failed: ' + ((e.responseJSON && e.responseJSON.message) || 'Unknown error');
}
}
(async function(){
await app.auth.forceLogin('app_sso_admin');
$(document).ready(function(){
renderUsers();
loadInviteGroups();
$('form[action="user/"]').attr('evalAJAX', 'renderUsers("User added", "success")')
});
})();
</script>
<div class="row" style="display:none">
<div class="col-md-4">
<div class="shadow-lg card mb-3 card-default group-required group-required-app_sso_admin">
<div class="card-header shadow">
<i class="fas fa-user-plus"></i>
Invite User
<span class="float-end">
<i class="fa-solid fa-arrows-up-down"></i>
</span>
</div>
<div class="card-header shadow actionMessage" style="display: none;"></div>
<div class="card-body">
<div class="mb-2">
<label class="form-label small">Email <small class="text-muted">(optional — sends invite immediately)</small></label>
<input type="email" id="invite-email" class="form-control form-control-sm shadow" placeholder="user@example.com" />
</div>
<div class="mb-2">
<label class="form-label small">Groups <small class="text-muted">(optional — hold Ctrl/⌘ for multiple)</small></label>
<input type="text" class="form-control form-control-sm shadow mb-1" placeholder="Filter groups…" oninput="filterGroups(this, 'invite-groups')" />
<select id="invite-groups" class="form-select form-select-sm shadow" multiple size="4"></select>
</div>
<button onclick="sendInvite()" class="btn btn-sm btn-outline-dark shadow">
<i class="fa-solid fa-envelope"></i> Send Invite
</button>
<div id="invite-result" style="display:none" class="mt-2"></div>
</div>
</div>
<div class="card shadow-lg">
<div class="card-header">
<i class="fas fa-user-plus"></i>
@@ -70,32 +134,73 @@
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
<%- include('user_form') %>
<%- include('user_form', {adminMode: true}) %>
</div>
</div>
</div>
<div class="col-md-8">
<div class="card shadow">
<div class="card-header">
<i class="fad fa-th-list"></i>
<i class="fa-solid fa-users"></i>
User List
</div>
<div class="card-header actionMessage" style="display:none"></div>
<table class="card-body table table-striped" style="margin-bottom:0">
<thead>
<th>ID</th>
<th>User Name</th>
<th>Name</th>
<th>eMail</th>
<th>Sudo</th>
<th>Key</th>
<th>Active</th>
<th>TOS</th>
<th></th>
</thead>
<tbody id="tableAJAX">
<!-- ajax loaded table -->
<tr jq-repeat="userRow">
<td>
{{ uidNumber }}
</td>
<td>
<a href='/users/{{uid}}'>{{givenName}} {{sn}}</a>
</td>
<td>
{{mail}}
</td>
<td>
{{#sshPublicKey}}<i class="fa-regular fa-circle-check text-success"></i>{{/sshPublicKey}}
</td>
<td>
{{#isActive}}<i class="fa-regular fa-circle-check text-success"></i>{{/isActive}}
{{#isInactive}}<i class="fa-solid fa-circle-xmark text-danger"></i>{{/isInactive}}
</td>
<td>
{{#tosAccepted}}<i class="fa-solid fa-circle-check text-success" title="TOS accepted"></i>{{/tosAccepted}}
{{#tosNotAccepted}}<i class="fa-solid fa-circle-xmark text-danger" title="TOS not accepted"></i>{{/tosNotAccepted}}
</td>
<td class="text-nowrap">
{{#isActive}}
<button class="btn btn-sm btn-outline-warning me-1" title="Deactivate" onclick="toggleActive('{{uid}}', false)">
<i class="fa-solid fa-lock"></i>
</button>
{{/isActive}}
{{#isInactive}}
<button class="btn btn-sm btn-warning me-1" title="Activate" onclick="toggleActive('{{uid}}', true)">
<i class="fa-solid fa-lock-open"></i>
</button>
{{/isInactive}}
<button class="btn btn-sm btn-outline-secondary me-1" title="Impersonate" onclick="startImpersonate('{{uid}}')">
<i class="fa-solid fa-user-secret"></i>
</button>
<button class="btn btn-sm btn-danger" onclick="deleteUser('{{uid}}', this)">
<i class="fa-solid fa-user-slash"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<%- include('impersonate_modal') %>
<%- include('bottom') %>