OXYGENHUB
# 09ffa7b600a9 oxygenhub / templates / admin.html
13.8 KiB · HTML · last changed Administration: a sidebar of grouped pages, one page per section (closes #288) 15 days ago source blame history raw
1
{% extends "base.html" %}
2
{% import "assistants_panel.html" as ai %}
3
{% import "variables_panel.html" as va %}
4
{% import "secrets_panel.html" as se %}
5
{% import "admin_nav.html" as an %}
6
{% block title %}{% if section != crate::web::admin::AdminSection::Overview %}{{ section.title() }} · {% endif %}Administration{% endblock %}
7
{% block content %}
8
<div class="head"><h1>Administration <small>{{ accounts.len() }} accounts · {{ orgs.len() }} organisations · {{ repos }} repositories</small></h1></div>
9
<div class="settings-layout">
10
{% call an::nav(section) %}
11
<div class="sections settings-body">
12
{% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %}
13
{% match section %}
14
{% when crate::web::admin::AdminSection::Overview %}
15
<section class="card-box admin-overview">
16
  <div class="card-head"><h2>This site</h2></div>
17
  <div class="pad admin-counts">
18
    <a href="/admin/accounts"><strong>{{ accounts.len() }}</strong> accounts</a>
19
    <a href="/admin/organisations"><strong>{{ orgs.len() }}</strong> organisations</a>
20
    <span><strong>{{ repos }}</strong> repositories</span>
21
    <a href="/admin/registration">registration <strong>{{ mode }}</strong></a>
22
    <a href="/admin/mail">mail <strong>{% if mail_enabled %}on{% else %}off{% endif %}</strong></a>
23
  </div>
24
</section>
25
<div class="admin-groups">
26
{% for (group, pages) in crate::web::admin::admin_groups() %}{% if *group != "Overview" %}
27
<section class="card-box">
28
  <div class="card-head"><h2>{{ group }}</h2></div>
29
  <ul class="card-list plain">{% for p in pages.iter() %}<li><a href="{{ p.href() }}">{{ p.title() }}</a></li>{% endfor %}</ul>
30
</section>
31
{% endif %}{% endfor %}
32
</div>
33
{% when crate::web::admin::AdminSection::Accounts %}
34
<section class="panel" id="accounts">
35
  <h2>Accounts</h2>
36
  <div class="table"><table class="small"><tr><th>Username</th><th>Email</th><th>Flags</th><th>Last seen</th><th></th></tr>
37
  {% for a in accounts %}
38
  <tr><td><a href="/{{ a.username }}">{{ a.username }}</a></td><td>{{ a.email }}{% if !a.email.is_empty() %} <span class="small mute" title="{% if a.email_verified %}verified{% else %}not verified{% endif %}">{% if a.email_verified %}✔{% else %}○{% endif %}</span>{% endif %}</td><td>{% if a.is_admin %}<span class="tag">admin</span>{% endif %}{% if a.banned %} <span class="tag bad">banned</span>{% endif %}{% if a.disabled %} <span class="tag warn">disabled</span>{% endif %}{% if a.totp_enabled() %} <span class="tag">2fa</span>{% endif %}</td>
39
  <td>{% if a.last_seen_ms > 0 %}<time title="{{ a.last_seen_ms|when_i }}">{{ a.last_seen_ms|ago_i }}</time>{% else %}<span class="mute">never</span>{% endif %}</td>
40
  <td><form class="tiny" method="post" action="/admin/account"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="username" value="{{ a.username }}">
41
    {% if a.banned %}<button name="action" value="unban">unban</button>{% else %}<button name="action" value="ban">ban</button>{% endif %}
42
    {% if a.is_admin %}<button name="action" value="demote">demote</button>{% else %}<button name="action" value="promote">make admin</button>{% endif %}
43
    {% if a.disabled %}<button name="action" value="enable">enable</button>{% else %}<button name="action" value="disable" title="Refuse sign-in, sessions, tokens and pushes until enabled again">disable</button>{% endif %}
44
    {% if !a.blocked() %}<button name="action" value="reset_link" title="A one-time password reset link to hand over">reset link</button>{% endif %}</form>
45
    <details class="tiny delete-account"><summary>delete</summary><form class="tiny" method="post" action="/admin/account"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="username" value="{{ a.username }}">
46
    <label class="small">Deleting removes the account, its tokens, keys and memberships for good; what it wrote stays under its name, which stays retired. Type <strong>{{ a.username }}</strong> to confirm:
47
    <input name="confirm" autocomplete="off" required></label> <button class="danger" name="action" value="delete">delete {{ a.username }}</button></form></details></td></tr>
48
  {% endfor %}
49
  </table></div>
50
</section>
51
{% when crate::web::admin::AdminSection::Registration %}
52
<section class="panel" id="registration">
53
  <h2>Registration and invitations</h2>
54
  <form class="card" method="post" action="/admin/registration">
55
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
56
    <label for="regmode">Who may register</label>
57
    <select id="regmode" name="mode">{% for m in ["open", "invite", "closed"] %}<option value="{{ m }}"{% if mode.as_str() == *m %} selected{% endif %}>{{ m }}</option>{% endfor %}</select>
58
    <button type="submit">SET</button>
59
  </form>
60
  <form class="card" method="post" action="/admin/invite"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit">NEW INVITATION</button></form>
61
  {% if !invitations.is_empty() %}<ul class="plain small">{% for (code, used, at) in invitations %}<li><code>{{ code }}</code> {% if used.is_some() %}<span class="mute">used</span>{% else %}<a href="/register?invite={{ code }}">link</a>{% endif %} · {{ at|ago_i }}</li>{% endfor %}</ul>{% endif %}
62
</section>
63
{% when crate::web::admin::AdminSection::Organisations %}
64
<section class="panel" id="organisations">
65
  <h2>Organisations</h2>
66
  {% if orgs.is_empty() %}<p class="mute small">None yet.</p>{% else %}<ul class="plain">{% for o in orgs %}<li><img class="avatar xs" src="/avatar/{{ o.name }}" alt=""> <a href="/{{ o.name }}">{{ o.name }}</a> · <a class="small" href="/orgs/{{ o.name }}/settings">settings</a></li>{% endfor %}</ul>{% endif %}
67
</section>
68
{% when crate::web::admin::AdminSection::SignIn %}
69
<section class="panel" id="signin">
70
  <h2>Sign in with</h2>
71
  <p class="small mute">A first sign-in links the account with the same verified email, or makes an account when registration is open. Secrets are kept sealed.</p>
72
  {% for p in providers %}
73
  <form class="card" method="post" action="/admin/signin">
74
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="provider" value="{{ p.id }}">
75
    <h3>{{ p.name }} {% if p.enabled && p.has_secret && !p.client_id.is_empty() %}<span class="tag head">on</span>{% else %}<span class="tag dim">off</span>{% endif %}</h3>
76
    <label for="cid-{{ p.id }}">Client id</label><input type="text" id="cid-{{ p.id }}" name="client_id" value="{{ p.client_id }}" autocomplete="off">
77
    <label for="sec-{{ p.id }}">Client secret</label><input type="password" id="sec-{{ p.id }}" name="secret" placeholder="{% if p.has_secret %}unchanged{% else %}none{% endif %}" autocomplete="new-password">
78
    {% if let Some(d) = p.default_base %}<label for="base-{{ p.id }}">{{ p.name }} address</label><input type="url" id="base-{{ p.id }}" name="base" value="{{ p.base }}" placeholder="{{ d }}" autocomplete="off">
79
    <p class="small mute">Empty for {{ d }}; or a self-managed {{ p.name }}, like https://gitlab.example.org. Register the hub there as an application with the scopes <code>openid profile email</code>.</p>{% endif %}
80
    <div class="choice"><label><input type="checkbox" name="enabled" value="1"{% if p.enabled %} checked{% endif %}> on</label></div>
81
    <p class="small mute">Callback URL to register with {{ p.name }}: <code>{{ p.callback }}</code></p>
82
    <button type="submit">SAVE</button>
83
  </form>
84
  {% endfor %}
85
</section>
86
{% when crate::web::admin::AdminSection::Mail %}
87
<section class="panel" id="mail">
88
  <h2>Mail</h2>
89
  <form class="card mail-panel" method="post" action="/admin/mail">
90
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
91
    <label for="mailservice">Service</label>
92
    <select id="mailservice" name="service" data-mail-service>{% for (v, label) in mail_services() %}<option value="{{ v }}"{% if mail.service == *v %} selected{% endif %}>{{ label }}</option>{% endfor %}</select>
93
    <p class="small mute" data-for="resend">Resend sends through <code>smtp.resend.com</code>; the login is <code>resend</code> and the password your API key. Verify your domain in Resend first.</p>
94
    <p class="small mute" data-for="ses">Amazon SES: the region your identity is verified in, and the SMTP credentials from SES › SMTP settings (not an AWS access key).</p>
95
    <p class="small mute" data-for="mailgun">Mailgun: <code>us</code> or <code>eu</code>, and the domain's SMTP login and password.</p>
96
    <div data-for="ses mailgun"><label for="mailregion">Region</label><input type="text" id="mailregion" name="region" value="{{ mail.region }}" placeholder="eu-west-2 for SES; us or eu for Mailgun"></div>
97
    <div data-for="smtp"><label for="mailhost">Server</label><input type="text" id="mailhost" name="host" value="{{ mail.host }}" placeholder="mail.example.org">
98
      <label for="mailport">Port</label><input type="text" id="mailport" name="port" value="{{ mail.port }}" placeholder="587">
99
      <label for="mailsecurity">Security</label><select id="mailsecurity" name="security"><option value="starttls"{% if mail.security == "starttls" %} selected{% endif %}>STARTTLS (usually port 587)</option><option value="tls"{% if mail.security == "tls" %} selected{% endif %}>TLS from the start (usually port 465)</option><option value="none"{% if mail.security == "none" %} selected{% endif %}>None (a relay on this machine)</option></select></div>
100
    <div data-for="ses mailgun smtp"><label for="mailuser">User name</label><input type="text" id="mailuser" name="user" value="{% if mail.service != "resend" %}{{ mail.user }}{% endif %}" autocomplete="off"></div>
101
    <div data-for="resend ses mailgun smtp"><label for="mailsecret">{% if mail.service == "resend" %}API key{% else %}Password or API key{% endif %}</label><input type="password" id="mailsecret" name="secret" value="" autocomplete="new-password" placeholder="{% if mail.has_secret %}saved: leave empty to keep it{% else %}{% endif %}"></div>
102
    <div data-for="url"><label for="smtp">SMTP URL</label><input type="text" id="smtp" name="smtp" value="{{ mail_smtp }}" placeholder="smtps://user:[email protected]:465"></div>
103
    <div data-for="resend ses mailgun smtp url"><label for="mailfrom">From</label><input type="text" id="mailfrom" name="from" value="{{ mail_from }}" placeholder="OxygenHub &lt;[email protected]&gt;"></div>
104
    <button type="submit">SAVE</button>
105
  </form>
106
  <form class="card" method="post" action="/admin/mail/test"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit"{% if !mail_enabled || my_email.is_empty() %} disabled{% endif %}>SEND A TEST{% if !my_email.is_empty() %} TO {{ my_email }}{% endif %}</button></form>
107
  <p class="small mute">{% if mail_enabled %}Mail is on: verification and reset links go by email.{% else %}Mail is off: verification and reset links are issued by hand.{% endif %} Off turns it off. Passwords and keys stay on the server and are never shown again. Every service is SMTP underneath, so any provider works: <code>smtps://USER:PASSWORD@HOST:465</code> for TLS from the start, or <code>smtp://USER:PASSWORD@HOST:587</code>, which the hub sends with STARTTLS required (add <code>?tls=opportunistic</code> or <code>?tls=none</code> to say otherwise; a relay on this machine needs none).</p>
108
</section>
109
{% when crate::web::admin::AdminSection::SecurityContact %}
110
<section class="panel" id="security">
111
  <h2>Security contact</h2>
112
  <form class="card" method="post" action="/admin/security">
113
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
114
    <label for="seccontact">Contact</label><input type="text" id="seccontact" name="contact" value="{{ security_contact }}" placeholder="[email protected] or https://example.org/report" autocomplete="off">
115
    <label for="secpolicy">Policy page</label><input type="url" id="secpolicy" name="policy" value="{{ security_policy }}" placeholder="https://example.org/security-policy (optional)" autocomplete="off">
116
    <label for="secemail">Reporting address, shown on <a href="/security-contact">/security-contact</a></label><input type="email" id="secemail" name="email" value="{{ security_email }}" placeholder="[email protected]" autocomplete="off">
117
    <label for="secpage">That page's text (Markdown; empty for the text below)</label><textarea id="secpage" name="page" rows="8" placeholder="{{ security_default }}">{{ security_page }}</textarea>
118
    <button type="submit">SAVE</button>
119
  </form>
120
  <p class="small mute">{% if security_contact.is_empty() %}No contact yet, so there is no <code>security.txt</code>.{% else %}Published at <a href="/.well-known/security.txt">/.well-known/security.txt</a>.{% endif %} Researchers look there (RFC 9116) for where to report a vulnerability in this hub. Its expiry date renews itself.</p>
121
</section>
122
{% when crate::web::admin::AdminSection::Variables %}
123
{% call va::panel("/admin", variables, shell.csrf, "Every repository's jobs get these, unless an organisation or repository sets the same name.") %}
124
{% when crate::web::admin::AdminSection::Secrets %}
125
{% call se::panel("/admin", secrets, shell.csrf, "Every repository's jobs may use these, unless an organisation or repository sets the same name.") %}
126
{% when crate::web::admin::AdminSection::Assistants %}
127
{% call ai::panel("/admin", assistants, shell.csrf, "Every repository on the site may ask these, unless an organisation or repository turns one off.") %}
128
{% when crate::web::admin::AdminSection::Audit %}
129
<section class="panel" id="audit">
130
  <h2>Audit log</h2>
131
  <div class="table"><table class="small"><tr><th>When</th><th>Who</th><th>Action</th><th>Target</th><th>Detail</th></tr>
132
  {% for r in audit %}<tr><td><time title="{{ r.at_ms|when_i }}">{{ r.at_ms|ago_i }}</time></td><td>{{ r.actor }}</td><td><code>{{ r.action }}</code></td><td>{{ r.target }}</td><td class="mute">{{ r.detail }}</td></tr>{% endfor %}
133
  </table></div>
134
  <p class="pager small"><a href="/admin/audit">newest</a>{% if let Some(o) = audit_older %} <a class="go small" href="/admin/audit?audit_before={{ o }}">older</a>{% endif %}</p>
135
</section>
136
{% else %}
137
{% endmatch %}
138
</div>
139
</div>
140
{% endblock %}