File indexing completed on 2026-10-20 10:15:30
0001 {% extends "base.html" %}
0002 {% block title %}User admin{% endblock %}
0003
0004 {% block content %}
0005 <div class="container-fluid px-3">
0006 <h2>User admin</h2>
0007
0008 {% if forbidden %}
0009 <p>This page is for staff and accounts holding operations rights.</p>
0010 {% else %}
0011
0012 <p>
0013 Reading the monitor needs a signed-in account and nothing more. Acting on
0014 the production system needs authority: membership of the eic GitHub
0015 organization, or rights granted here.
0016 {{ acting_count }} of {{ rows|length }} accounts may act.
0017 </p>
0018
0019 <p>
0020 Membership is observed at sign-in and is shown here, not set here: a hand
0021 edit would be overwritten the next time the person signs in. Rights are
0022 set here, and a sign-in never touches them.
0023 </p>
0024
0025 <ul>
0026 <li><strong>unset</strong> — the ordinary state: the account draws
0027 authority from its eic membership alone.</li>
0028 {% for level in rights_levels %}
0029 <li><strong>{{ level.value }}</strong> — {{ level.meaning }}</li>
0030 {% endfor %}
0031 </ul>
0032 <p>Roles are held beside the level, together or apart:</p>
0033 <ul>
0034 <li><strong>PAC</strong> — {{ pac_meaning }}
0035 {{ pac_count }} account{{ pac_count|pluralize }} hold it.</li>
0036 <li><strong>Ops</strong> — {{ ops_meaning }}
0037 {{ ops_count }} account{{ ops_count|pluralize }} hold it.</li>
0038 </ul>
0039
0040 {% csrf_token %}
0041 <div id="user-admin-status" class="mb-2" style="min-height:1.5em;"></div>
0042
0043 <table class="table table-striped table-bordered table-sm align-middle w-auto swf-sortable">
0044 <thead>
0045 <tr>
0046 <th>Account</th>
0047 <th>GitHub login</th>
0048 <th>Origin</th>
0049 <th>eic</th>
0050 <th title="When eic membership was last observed for the account: at sign-in through epic-devcloud.org, or by the backfill">Membership checked</th>
0051 <th>May act</th>
0052 <th>Rights</th>
0053 <th title="Physics analysis coordinator: may set request priorities">PAC</th>
0054 <th title="Operations: administers accounts, sets priorities, acts on the production system">Ops</th>
0055 <th></th>
0056 </tr>
0057 </thead>
0058 <tbody>
0059 {% for row in rows %}
0060 <tr data-username="{{ row.username }}">
0061 <td>{{ row.username }}{% if row.is_staff %} <span class="text-muted">(staff)</span>{% endif %}</td>
0062 <td>{% if row.github %}{{ row.github }}{% else %}<span class="text-muted">—</span>{% endif %}</td>
0063 <td>{{ row.origin|default:"—" }}</td>
0064 <td>
0065 {% if row.eic is True %}member
0066 {% elif row.eic is False %}not a member
0067 {% else %}<span class="text-muted">unset</span>{% endif %}
0068 {% if row.check_failed %}
0069 <div class="text-danger fw-semibold" title="{{ row.check_failed }}">last check failed
0070 <span data-relative-time="{{ row.check_failed_at }}"></span></div>
0071 {% endif %}
0072 </td>
0073 <td>
0074 {% if row.last_seen %}
0075 <span data-relative-time="{{ row.last_seen }}"></span>
0076 {% elif row.last_login %}
0077 <span data-relative-time="{{ row.last_login|date:'c' }}"></span>
0078 {% else %}
0079 <span class="text-muted">—</span>
0080 {% endif %}
0081 </td>
0082 <td class="js-may-act">{% if row.may_act %}yes{% else %}no{% endif %}</td>
0083 <td>
0084 <select class="form-select form-select-sm w-auto d-inline-block js-rights">
0085 <option value=""{% if not row.rights %} selected{% endif %}>unset</option>
0086 {% for value in rights_values %}
0087 <option value="{{ value }}"{% if row.rights == value %} selected{% endif %}>{{ value }}</option>
0088 {% endfor %}
0089 </select>
0090 </td>
0091 <td><input type="checkbox" class="form-check-input js-pac"{% if row.pac %} checked{% endif %} title="Physics analysis coordinator"></td>
0092 <td><input type="checkbox" class="form-check-input js-ops"{% if row.ops %} checked{% endif %} title="Operations"></td>
0093 {# Outline until the row is changed, then filled: Save per line, quiet. #}
0094 {# Row-size type in a visible colour, never smaller or greyer. #}
0095 <td><button type="button" class="btn btn-sm btn-outline-primary js-save">Save</button></td>
0096 </tr>
0097 {% endfor %}
0098 </tbody>
0099 </table>
0100 {% endif %}
0101 </div>
0102
0103 <script>
0104 (function () {
0105 var url = "{% url 'monitor_app:user-rights' %}";
0106 var status = document.getElementById('user-admin-status');
0107 if (!status) { return; }
0108 // The internal face is a CILogon session with CSRF in force; the tunnel
0109 // exempts it. Send the token both ways (house pattern, pcs/find.html).
0110 var csrfInput = document.querySelector('input[name="csrfmiddlewaretoken"]');
0111 var csrf = csrfInput ? csrfInput.value : '';
0112
0113 function say(text, ok) {
0114 status.textContent = text;
0115 status.style.color = ok ? '' : '#b02a37';
0116 }
0117
0118 function setDirty(button, dirty) {
0119 button.classList.toggle('btn-primary', dirty);
0120 button.classList.toggle('btn-outline-primary', !dirty);
0121 }
0122
0123 document.querySelectorAll('tr[data-username]').forEach(function (row) {
0124 var button = row.querySelector('.js-save');
0125 if (!button) { return; }
0126 var select = row.querySelector('.js-rights');
0127 var pac = row.querySelector('.js-pac');
0128 var ops = row.querySelector('.js-ops');
0129 var saved = select.value;
0130 var savedPac = pac.checked;
0131 var savedOps = ops.checked;
0132 function dirty() {
0133 return select.value !== saved || pac.checked !== savedPac || ops.checked !== savedOps;
0134 }
0135 select.addEventListener('change', function () { setDirty(button, dirty()); });
0136 pac.addEventListener('change', function () { setDirty(button, dirty()); });
0137 ops.addEventListener('change', function () { setDirty(button, dirty()); });
0138 button.addEventListener('click', function () {
0139 var username = row.getAttribute('data-username');
0140 var value = select.value;
0141 button.disabled = true;
0142 say('Saving ' + username + '...', true);
0143 fetch(url, {
0144 method: 'POST',
0145 headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrf},
0146 body: JSON.stringify({username: username, rights: value || null,
0147 pac: pac.checked, ops: ops.checked})
0148 }).then(function (response) {
0149 return response.json().then(function (data) {
0150 return {ok: response.ok, data: data};
0151 });
0152 }).then(function (result) {
0153 button.disabled = false;
0154 if (!result.ok) {
0155 say(username + ': ' + (result.data.error || 'refused'), false);
0156 return;
0157 }
0158 row.querySelector('.js-may-act').textContent =
0159 result.data.may_act ? 'yes' : 'no';
0160 saved = result.data.authority.rights || '';
0161 savedPac = !!result.data.authority.pac;
0162 pac.checked = savedPac;
0163 savedOps = !!result.data.authority.ops;
0164 ops.checked = savedOps;
0165 setDirty(button, false);
0166 var rights = result.data.authority.rights;
0167 say(username + ': rights ' + (rights || 'unset') + ', may act ' +
0168 (result.data.may_act ? 'yes' : 'no') + ', PAC ' +
0169 (savedPac ? 'yes' : 'no') + ', Ops ' + (savedOps ? 'yes' : 'no'), true);
0170 }).catch(function (error) {
0171 button.disabled = false;
0172 say(username + ': ' + error, false);
0173 });
0174 });
0175 });
0176 })();
0177 </script>
0178 {% endblock %}