Administration: a sidebar of grouped pages, one page per section (closes #288) 737de0fac722
The admin area was one long page plus separate pages for runners, images, Diffusion, backups and storage. Every admin page now has the same sidebar (as repository settings, #278): Overview, People, Sign-in, Mail, Flow, Integrations, Data and Audit, each section at its own address. /admin is an overview of the site's numbers. Forms keep their addresses and answer with their own page; a kept /admin#section link opens that page.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key ff123dd70f37
- When
- · 2026-10-20 23:59 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
737de0fac722a374682d543f516fc6db72e23720c34476f3c4de1fa2921bbfcb- Depends on
- 155cab142b63 19bb31ff1f5c 1e8a6e8bb91b 2a83f5303458 34beb1a7e376 3556d08e17f9 3dd6582de060 42fa95540468 43e8ca33bc5d 4ac01a732552 5dead1f4f26d 6246d1e51f4f 6a1780558353 6ca0b971ff07 73ef9f767132 757195ad7a5d 83940d53664f 8fb35df7d66e 93be9ce4653e a7e6e8e6094c b349e15a3eab b5668108487f b6b7e4cf6b24 c7067c7bda56 c8684d44d549 cd46e56b1709 d23464c0ac60 e71846aa07b9 e8e7158bbc73
- Depended on by
- 1062a0f8e985 8501fd5815b9 3b3a51afde59
- In bookmarks
- api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
- In tags
- v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 93 atoms, 1 tree operation, 18 files
Browse the tree at this change
added templates/admin_nav.html +9 −0
| 1 | + | {# The admin area's sidebar (oxygenhub#288): grouped pages, the current one marked. #} |
|
| 2 | + | {% macro nav(current) %} |
|
| 3 | + | <nav class="settings-nav" aria-label="Administration"{% if current == crate::web::admin::AdminSection::Overview %} data-settings-anchors{% endif %}> |
|
| 4 | + | {% for (group, pages) in crate::web::admin::admin_groups() %}<div class="settings-group"> |
|
| 5 | + | <h2>{{ group }}</h2> |
|
| 6 | + | <ul>{% for p in pages.iter() %}<li><a href="{{ p.href() }}" data-slug="{{ p.slug() }}"{% if *p == current %} class="on" aria-current="page"{% endif %}>{{ p.title() }}</a></li>{% endfor %}</ul> |
|
| 7 | + | </div>{% endfor %} |
|
| 8 | + | </nav> |
|
| 9 | + | {% endmacro %} |
modified CHANGELOG.md +1 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **Administration has a sidebar, one page per section (#288):** the admin area was one long page plus separate pages for runners, images, Diffusion, backups and storage. Every page now has the same sidebar, grouped as Overview, People (accounts, registration and invitations, organisations), Sign-in (providers, security contact), Mail, Flow (runners, images, runner settings, insights, variables, secrets), Integrations (AI assistants, Diffusion), Data (storage and maintenance, backups) and Audit, each at its own address (`/admin/mail`, `/admin/audit`, …). `/admin` is an overview of the site's numbers. Forms keep their addresses and answer with their own page, and a kept `/admin#mail` link opens the Mail page. |
|
| 7 | 8 | - **Help: AI identities (#287):** a section on letting an AI agent work for you: keyless agents (a grant with `oh agent grant`, an agent home with `oxy auth grant`, ten-minute certified keys, nothing secret on disk), how its work shows (`via agent:NAME@YOU`), admitting it to a repository's trust policy (`oxy policy grant-subject`), agents with keys of their own (delegation), agent-marked tokens, approval gates, and revoking. "Letting the hub act for you" now says to export your identity again after delegating and upload that. |
|
| 8 | 9 | - **Settings › Keys says when each key was last used (#286):** like tokens, each linked key shows when it was linked, when the hub last accepted a push it signed (a change or a seal; "never" until then) and when the identity expires. |
|
| 9 | 10 | - **Settings › Security is balanced (#285):** every box runs the page's width: two-factor shows whether it is on in its head, with its field and button on one row; connected accounts are rows with Connect or Disconnect; signed-in browsers are rows with Sign out, and Sign out everywhere else sits in that box's head with the session limits under it. |
|
| … | 270 unchanged lines |
modified crates/hub/src/web/admin.rs +201 −23
| … | 9 unchanged lines |
||
| 10 | 10 | use axum::extract::{Form, State}; |
|
| 11 | 11 | use serde::Deserialize; |
|
| 12 | 12 | ||
| 13 | + | /// A page of the admin area (oxygenhub#288): grouped in a sidebar, one |
|
| 14 | + | /// page per section, as a repository's settings are (#278). |
|
| 15 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
|
| 16 | + | pub enum AdminSection { |
|
| 17 | + | Overview, |
|
| 18 | + | Accounts, |
|
| 19 | + | Registration, |
|
| 20 | + | Organisations, |
|
| 21 | + | SignIn, |
|
| 22 | + | SecurityContact, |
|
| 23 | + | Mail, |
|
| 24 | + | Runners, |
|
| 25 | + | Images, |
|
| 26 | + | RunnerSettings, |
|
| 27 | + | Insights, |
|
| 28 | + | Variables, |
|
| 29 | + | Secrets, |
|
| 30 | + | Assistants, |
|
| 31 | + | Diffusion, |
|
| 32 | + | Storage, |
|
| 33 | + | Backups, |
|
| 34 | + | Audit, |
|
| 35 | + | } |
|
| 36 | + | ||
| 37 | + | /// The sidebar: each group's heading and its pages, in order. |
|
| 38 | + | const ADMIN_GROUPS: &[(&str, &[AdminSection])] = &[ |
|
| 39 | + | ("Overview", &[AdminSection::Overview]), |
|
| 40 | + | ("People", &[AdminSection::Accounts, AdminSection::Registration, AdminSection::Organisations]), |
|
| 41 | + | ("Sign-in", &[AdminSection::SignIn, AdminSection::SecurityContact]), |
|
| 42 | + | ("Mail", &[AdminSection::Mail]), |
|
| 43 | + | ( |
|
| 44 | + | "Flow", |
|
| 45 | + | &[ |
|
| 46 | + | AdminSection::Runners, |
|
| 47 | + | AdminSection::Images, |
|
| 48 | + | AdminSection::RunnerSettings, |
|
| 49 | + | AdminSection::Insights, |
|
| 50 | + | AdminSection::Variables, |
|
| 51 | + | AdminSection::Secrets, |
|
| 52 | + | ], |
|
| 53 | + | ), |
|
| 54 | + | ("Integrations", &[AdminSection::Assistants, AdminSection::Diffusion]), |
|
| 55 | + | ("Data", &[AdminSection::Storage, AdminSection::Backups]), |
|
| 56 | + | ("Audit", &[AdminSection::Audit]), |
|
| 57 | + | ]; |
|
| 58 | + | ||
| 59 | + | /// The admin sidebar's groups, for templates. |
|
| 60 | + | pub fn admin_groups() -> &'static [(&'static str, &'static [AdminSection])] { |
|
| 61 | + | ADMIN_GROUPS |
|
| 62 | + | } |
|
| 63 | + | ||
| 64 | + | impl AdminSection { |
|
| 65 | + | /// Its page's address. |
|
| 66 | + | pub fn href(self) -> &'static str { |
|
| 67 | + | match self { |
|
| 68 | + | AdminSection::Overview => "/admin", |
|
| 69 | + | AdminSection::Accounts => "/admin/accounts", |
|
| 70 | + | AdminSection::Registration => "/admin/registration", |
|
| 71 | + | AdminSection::Organisations => "/admin/organisations", |
|
| 72 | + | AdminSection::SignIn => "/admin/signin", |
|
| 73 | + | AdminSection::SecurityContact => "/admin/security", |
|
| 74 | + | AdminSection::Mail => "/admin/mail", |
|
| 75 | + | AdminSection::Runners => "/admin/runners", |
|
| 76 | + | AdminSection::Images => "/admin/runners/images", |
|
| 77 | + | AdminSection::RunnerSettings => "/admin/runners/settings", |
|
| 78 | + | AdminSection::Insights => "/admin/insights", |
|
| 79 | + | AdminSection::Variables => "/admin/variables", |
|
| 80 | + | AdminSection::Secrets => "/admin/secrets", |
|
| 81 | + | AdminSection::Assistants => "/admin/assistants", |
|
| 82 | + | AdminSection::Diffusion => "/admin/diffusion", |
|
| 83 | + | AdminSection::Storage => "/admin/storage", |
|
| 84 | + | AdminSection::Backups => "/admin/backups", |
|
| 85 | + | AdminSection::Audit => "/admin/audit", |
|
| 86 | + | } |
|
| 87 | + | } |
|
| 88 | + | ||
| 89 | + | /// The anchor it had on the one-page admin, so a kept `/admin#mail` |
|
| 90 | + | /// link opens its page (hub.js). |
|
| 91 | + | pub fn slug(self) -> &'static str { |
|
| 92 | + | match self { |
|
| 93 | + | AdminSection::Overview => "overview", |
|
| 94 | + | AdminSection::Accounts => "accounts", |
|
| 95 | + | AdminSection::Registration => "registration", |
|
| 96 | + | AdminSection::Organisations => "organisations", |
|
| 97 | + | AdminSection::SignIn => "signin", |
|
| 98 | + | AdminSection::SecurityContact => "security", |
|
| 99 | + | AdminSection::Mail => "mail", |
|
| 100 | + | AdminSection::Runners => "runners", |
|
| 101 | + | AdminSection::Images => "images", |
|
| 102 | + | AdminSection::RunnerSettings => "runner-settings", |
|
| 103 | + | AdminSection::Insights => "insights", |
|
| 104 | + | AdminSection::Variables => "variables", |
|
| 105 | + | AdminSection::Secrets => "secrets", |
|
| 106 | + | AdminSection::Assistants => "assistants", |
|
| 107 | + | AdminSection::Diffusion => "diffusion", |
|
| 108 | + | AdminSection::Storage => "storage", |
|
| 109 | + | AdminSection::Backups => "backups", |
|
| 110 | + | AdminSection::Audit => "audit", |
|
| 111 | + | } |
|
| 112 | + | } |
|
| 113 | + | ||
| 114 | + | pub fn title(self) -> &'static str { |
|
| 115 | + | match self { |
|
| 116 | + | AdminSection::Overview => "Overview", |
|
| 117 | + | AdminSection::Accounts => "Accounts", |
|
| 118 | + | AdminSection::Registration => "Registration & invitations", |
|
| 119 | + | AdminSection::Organisations => "Organisations", |
|
| 120 | + | AdminSection::SignIn => "Sign in with", |
|
| 121 | + | AdminSection::SecurityContact => "Security contact", |
|
| 122 | + | AdminSection::Mail => "Mail", |
|
| 123 | + | AdminSection::Runners => "Runners", |
|
| 124 | + | AdminSection::Images => "Images", |
|
| 125 | + | AdminSection::RunnerSettings => "Runner settings", |
|
| 126 | + | AdminSection::Insights => "Insights", |
|
| 127 | + | AdminSection::Variables => "Variables", |
|
| 128 | + | AdminSection::Secrets => "Secrets", |
|
| 129 | + | AdminSection::Assistants => "AI assistants", |
|
| 130 | + | AdminSection::Diffusion => "Diffusion", |
|
| 131 | + | AdminSection::Storage => "Storage & maintenance", |
|
| 132 | + | AdminSection::Backups => "Backups", |
|
| 133 | + | AdminSection::Audit => "Audit log", |
|
| 134 | + | } |
|
| 135 | + | } |
|
| 136 | + | ||
| 137 | + | /// The section a page of admin.html shows, by its address. |
|
| 138 | + | fn of_path(path: &str) -> Option<AdminSection> { |
|
| 139 | + | ADMIN_GROUPS.iter().flat_map(|(_, pages)| pages.iter().copied()).find(|s| s.href() == path) |
|
| 140 | + | } |
|
| 141 | + | } |
|
| 142 | + | ||
| 13 | 143 | #[derive(Template)] |
|
| 14 | 144 | #[template(path = "admin.html")] |
|
| 15 | 145 | pub struct AdminPage { |
|
| 16 | 146 | shell: Shell, |
|
| 147 | + | /// The page shown (oxygenhub#288). |
|
| 148 | + | section: AdminSection, |
|
| 17 | 149 | accounts: Vec<accounts::Account>, |
|
| 18 | 150 | orgs: Vec<accounts::Organisation>, |
|
| 19 | 151 | mode: String, |
|
| … | 36 unchanged lines |
||
| 56 | 188 | } |
|
| 57 | 189 | } |
|
| 58 | 190 | ||
| 59 | - | pub(super) async fn admin_page(app: &Shared, shell: Shell, notice: Option<String>) -> Result<Page<AdminPage>, Error> { |
|
| 60 | - | admin_page_at(app, shell, notice, None).await |
|
| 191 | + | pub(super) async fn admin_page( |
|
| 192 | + | app: &Shared, |
|
| 193 | + | shell: Shell, |
|
| 194 | + | section: AdminSection, |
|
| 195 | + | notice: Option<String>, |
|
| 196 | + | ) -> Result<Page<AdminPage>, Error> { |
|
| 197 | + | admin_page_at(app, shell, section, notice, None).await |
|
| 61 | 198 | } |
|
| 62 | 199 | ||
| 63 | 200 | /// The audit log shows this many rows a page. |
|
| 64 | 201 | const AUDIT_PAGE: i64 = 50; |
|
| 65 | 202 | ||
| 66 | - | async fn admin_page_at(app: &Shared, shell: Shell, notice: Option<String>, audit_before: Option<i64>) -> Result<Page<AdminPage>, Error> { |
|
| 203 | + | async fn admin_page_at( |
|
| 204 | + | app: &Shared, |
|
| 205 | + | shell: Shell, |
|
| 206 | + | section: AdminSection, |
|
| 207 | + | notice: Option<String>, |
|
| 208 | + | audit_before: Option<i64>, |
|
| 209 | + | ) -> Result<Page<AdminPage>, Error> { |
|
| 67 | 210 | let e = |e| Error::from_db(shell.clone(), e); |
|
| 68 | 211 | let accounts_list = accounts::accounts(&app.db).await.map_err(e)?; |
|
| 69 | 212 | let orgs = accounts::orgs(&app.db).await.map_err(e)?; |
|
| … | 19 unchanged lines |
||
| 89 | 232 | let (security_contact, security_policy) = super::security_txt::settings(app).await; |
|
| 90 | 233 | let (security_email, security_page) = super::security_txt::page_settings(app).await; |
|
| 91 | 234 | Ok(Page(AdminPage { |
|
| 235 | + | section, |
|
| 92 | 236 | providers, |
|
| 93 | 237 | security_contact, |
|
| 94 | 238 | security_policy, |
|
| … | 83 unchanged lines |
||
| 178 | 322 | }; |
|
| 179 | 323 | match crate::mail::compose(&asked, ¤t.smtp) { |
|
| 180 | 324 | Ok(url) => url, |
|
| 181 | - | Err(why) => return admin_page(&app, shell, Some(why)).await, |
|
| 325 | + | Err(why) => return admin_page(&app, shell, AdminSection::Mail, Some(why)).await, |
|
| 182 | 326 | } |
|
| 183 | 327 | } |
|
| 184 | 328 | }; |
|
| … | 2 unchanged lines |
||
| 187 | 331 | return admin_page( |
|
| 188 | 332 | &app, |
|
| 189 | 333 | shell, |
|
| 334 | + | AdminSection::Mail, |
|
| 190 | 335 | Some("The SMTP URL starts with smtp:// or smtps:// (file:///dir writes messages to a directory, for testing).".into()), |
|
| 191 | 336 | ) |
|
| 192 | 337 | .await; |
|
| 193 | 338 | } |
|
| 194 | 339 | if !smtp.is_empty() && from.is_empty() { |
|
| 195 | - | return admin_page(&app, shell, Some("A From address is needed: OxygenHub <[email protected]>.".into())).await; |
|
| 340 | + | return admin_page(&app, shell, AdminSection::Mail, Some("A From address is needed: OxygenHub <[email protected]>.".into())) |
|
| 341 | + | .await; |
|
| 196 | 342 | } |
|
| 197 | 343 | crate::mail::save(&app, &smtp, &from).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 198 | 344 | accounts::audit(&app.db, Some(&me), "admin.mail", if smtp.is_empty() { "off" } else { "on" }, &from).await.ok(); |
|
| 199 | - | admin_page(&app, shell, Some(if smtp.is_empty() { "Mail is off.".into() } else { "Mail settings saved.".into() })).await |
|
| 345 | + | admin_page(&app, shell, AdminSection::Mail, Some(if smtp.is_empty() { "Mail is off.".into() } else { "Mail settings saved.".into() })) |
|
| 346 | + | .await |
|
| 200 | 347 | } |
|
| 201 | 348 | ||
| 202 | 349 | /// `POST /admin/mail/test`: a message to the administrator's own address. |
|
| … | 6 unchanged lines |
||
| 209 | 356 | let me = require_admin(&viewer, &shell)?; |
|
| 210 | 357 | csrf(&viewer, &f.csrf, &shell)?; |
|
| 211 | 358 | if !crate::mail::enabled(&app) { |
|
| 212 | - | return admin_page(&app, shell, Some("Mail is off; save an SMTP URL and a From address first.".into())).await; |
|
| 359 | + | return admin_page(&app, shell, AdminSection::Mail, Some("Mail is off; save an SMTP URL and a From address first.".into())).await; |
|
| 213 | 360 | } |
|
| 214 | 361 | if me.email.is_empty() { |
|
| 215 | - | return admin_page(&app, shell, Some("Your account has no email address to send the test to.".into())).await; |
|
| 362 | + | return admin_page(&app, shell, AdminSection::Mail, Some("Your account has no email address to send the test to.".into())).await; |
|
| 216 | 363 | } |
|
| 217 | 364 | let base = app.config.base_url().to_string(); |
|
| 218 | 365 | let notice = match crate::mail::send_letter( |
|
| … | 10 unchanged lines |
||
| 229 | 376 | Ok(()) => format!("Sent a test message to {}.", me.email), |
|
| 230 | 377 | Err(e) => format!("Sending failed: {e}"), |
|
| 231 | 378 | }; |
|
| 232 | - | admin_page(&app, shell, Some(notice)).await |
|
| 379 | + | admin_page(&app, shell, AdminSection::Mail, Some(notice)).await |
|
| 233 | 380 | } |
|
| 234 | 381 | ||
| 235 | 382 | #[derive(Deserialize)] |
|
| … | 7 unchanged lines |
||
| 243 | 390 | axum::extract::Query(q): axum::extract::Query<IndexQuery>, |
|
| 244 | 391 | ) -> Result<Page<AdminPage>, Error> { |
|
| 245 | 392 | let shell = Shell::new(&app, "/admin", &viewer); |
|
| 393 | + | require_admin(&viewer, &shell)?; |
|
| 394 | + | admin_page_at(&app, shell, AdminSection::Overview, None, q.audit_before).await |
|
| 395 | + | } |
|
| 396 | + | ||
| 397 | + | /// `GET /admin/{page}` for the sections admin.html shows (oxygenhub#288): |
|
| 398 | + | /// accounts, registration, organisations, sign-in, the security contact, |
|
| 399 | + | /// mail, variables, secrets, AI assistants and the audit log. |
|
| 400 | + | pub async fn section( |
|
| 401 | + | State(app): State<Shared>, |
|
| 402 | + | viewer: Viewer, |
|
| 403 | + | uri: axum::http::Uri, |
|
| 404 | + | axum::extract::Query(q): axum::extract::Query<IndexQuery>, |
|
| 405 | + | ) -> Result<Page<AdminPage>, Error> { |
|
| 406 | + | let shell = Shell::new(&app, uri.path(), &viewer); |
|
| 246 | 407 | require_admin(&viewer, &shell)?; |
|
| 247 | - | admin_page_at(&app, shell, None, q.audit_before).await |
|
| 408 | + | let section = AdminSection::of_path(uri.path()).ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 409 | + | admin_page_at(&app, shell, section, None, q.audit_before).await |
|
| 248 | 410 | } |
|
| 249 | 411 | ||
| 250 | 412 | #[derive(Deserialize)] |
|
| … | 12 unchanged lines |
||
| 263 | 425 | } |
|
| 264 | 426 | accounts::set_setting(&app.db, "registration_mode", &f.mode).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 265 | 427 | accounts::audit(&app.db, Some(&me), "admin.registration", &f.mode, "").await.ok(); |
|
| 266 | - | admin_page(&app, shell, Some(format!("Registration is {}.", f.mode))).await |
|
| 428 | + | admin_page(&app, shell, AdminSection::Registration, Some(format!("Registration is {}.", f.mode))).await |
|
| 267 | 429 | } |
|
| 268 | 430 | ||
| 269 | 431 | pub async fn invite(State(app): State<Shared>, viewer: Viewer, Form(f): Form<super::session::CsrfForm>) -> Result<Page<AdminPage>, Error> { |
|
| … | 3 unchanged lines |
||
| 273 | 435 | let code = auth::random_hex(12); |
|
| 274 | 436 | accounts::create_invitation(&app.db, &code, Some(me.id)).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 275 | 437 | accounts::audit(&app.db, Some(&me), "admin.invite", &code, "").await.ok(); |
|
| 276 | - | admin_page(&app, shell, Some(format!("Invitation created: {}/register?invite={code}", app.config.base_url()))).await |
|
| 438 | + | admin_page( |
|
| 439 | + | &app, |
|
| 440 | + | shell, |
|
| 441 | + | AdminSection::Registration, |
|
| 442 | + | Some(format!("Invitation created: {}/register?invite={code}", app.config.base_url())), |
|
| 443 | + | ) |
|
| 444 | + | .await |
|
| 277 | 445 | } |
|
| 278 | 446 | ||
| 279 | 447 | #[derive(Deserialize)] |
|
| … | 12 unchanged lines |
||
| 292 | 460 | let me = require_admin(&viewer, &shell)?; |
|
| 293 | 461 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 294 | 462 | let Some(target) = accounts::account_by_name(&app.db, f.username.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))? else { |
|
| 295 | - | return admin_page(&app, shell, Some("No such account.".into())).await; |
|
| 463 | + | return admin_page(&app, shell, AdminSection::Accounts, Some("No such account.".into())).await; |
|
| 296 | 464 | }; |
|
| 297 | 465 | if target.id == me.id && matches!(f.action.as_str(), "ban" | "demote" | "disable" | "delete") { |
|
| 298 | - | return admin_page(&app, shell, Some("Not on yourself.".into())).await; |
|
| 466 | + | return admin_page(&app, shell, AdminSection::Accounts, Some("Not on yourself.".into())).await; |
|
| 299 | 467 | } |
|
| 300 | 468 | let e = |e| Error::from_db(shell.clone(), e); |
|
| 301 | 469 | // The hub must keep an admin who can sign in (oxygenhub#81). |
|
| … | 1 unchanged lines |
||
| 303 | 471 | let others = |
|
| 304 | 472 | accounts::accounts(&app.db).await.map_err(e)?.into_iter().filter(|a| a.is_admin && !a.blocked() && a.id != target.id).count(); |
|
| 305 | 473 | if others == 0 { |
|
| 306 | - | return admin_page(&app, shell, Some(format!("{} is the last admin who can sign in.", target.username))).await; |
|
| 474 | + | return admin_page( |
|
| 475 | + | &app, |
|
| 476 | + | shell, |
|
| 477 | + | AdminSection::Accounts, |
|
| 478 | + | Some(format!("{} is the last admin who can sign in.", target.username)), |
|
| 479 | + | ) |
|
| 480 | + | .await; |
|
| 307 | 481 | } |
|
| 308 | 482 | } |
|
| 309 | 483 | if f.action == "delete" { |
|
| 310 | 484 | if f.confirm.trim() != target.username { |
|
| 311 | - | return admin_page(&app, shell, Some(format!("Not deleted: type {} to confirm.", target.username))).await; |
|
| 485 | + | return admin_page(&app, shell, AdminSection::Accounts, Some(format!("Not deleted: type {} to confirm.", target.username))) |
|
| 486 | + | .await; |
|
| 312 | 487 | } |
|
| 313 | 488 | let repos = crate::db::repos::list_by_owner(&app.db, &target.username).await.map_err(e)?; |
|
| 314 | 489 | let orgs = accounts::orgs_owned_only_by(&app.db, target.id).await.map_err(e)?; |
|
| … | 3 unchanged lines |
||
| 318 | 493 | return admin_page( |
|
| 319 | 494 | &app, |
|
| 320 | 495 | shell, |
|
| 496 | + | AdminSection::Accounts, |
|
| 321 | 497 | Some(format!("Not deleted: {} still owns {}. Transfer or delete them first.", target.username, held.join(", "))), |
|
| 322 | 498 | ) |
|
| 323 | 499 | .await; |
|
| 324 | 500 | } |
|
| 325 | 501 | accounts::delete_account(&app.db, target.id, &target.username).await.map_err(e)?; |
|
| 326 | 502 | accounts::audit(&app.db, Some(&me), "admin.account.delete", &target.username, "").await.ok(); |
|
| 327 | - | return admin_page(&app, shell, Some(format!("{} deleted; the name stays retired.", target.username))).await; |
|
| 503 | + | return admin_page(&app, shell, AdminSection::Accounts, Some(format!("{} deleted; the name stays retired.", target.username))) |
|
| 504 | + | .await; |
|
| 328 | 505 | } |
|
| 329 | 506 | if f.action == "reset_link" { |
|
| 330 | 507 | if target.blocked() { |
|
| 331 | - | return admin_page(&app, shell, Some("A banned or disabled account cannot be reset.".into())).await; |
|
| 508 | + | return admin_page(&app, shell, AdminSection::Accounts, Some("A banned or disabled account cannot be reset.".into())).await; |
|
| 332 | 509 | } |
|
| 333 | 510 | let url = super::recovery::admin_reset_link(&app, &me, &target).await.map_err(e)?; |
|
| 334 | 511 | return admin_page( |
|
| 335 | 512 | &app, |
|
| 336 | 513 | shell, |
|
| 514 | + | AdminSection::Accounts, |
|
| 337 | 515 | Some(format!("Reset link for {} (works once, for 24 hours; shown only now): {url}", target.username)), |
|
| 338 | 516 | ) |
|
| 339 | 517 | .await; |
|
| … | 8 unchanged lines |
||
| 348 | 526 | _ => return Err(Error::bad(shell, "unknown action")), |
|
| 349 | 527 | } |
|
| 350 | 528 | accounts::audit(&app.db, Some(&me), &format!("admin.account.{}", f.action), &target.username, "").await.ok(); |
|
| 351 | - | admin_page(&app, shell, Some(format!("{}: {}.", target.username, f.action))).await |
|
| 529 | + | admin_page(&app, shell, AdminSection::Accounts, Some(format!("{}: {}.", target.username, f.action))).await |
|
| 352 | 530 | } |
|
| 353 | 531 | ||
| 354 | 532 | // ------------------------------------------------------------------ storage |
|
| … | 134 unchanged lines |
||
| 489 | 667 | require_admin(&viewer, &shell)?; |
|
| 490 | 668 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 491 | 669 | let outcome = super::assistants::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 492 | - | admin_page(&app, shell, Some(outcome.unwrap_or_else(|e| e))).await |
|
| 670 | + | admin_page(&app, shell, AdminSection::Assistants, Some(outcome.unwrap_or_else(|e| e))).await |
|
| 493 | 671 | } |
|
| 494 | 672 | ||
| 495 | 673 | /// `POST /admin/variables`: the site's Flow variables (#102). |
|
| … | 6 unchanged lines |
||
| 502 | 680 | require_admin(&viewer, &shell)?; |
|
| 503 | 681 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 504 | 682 | let outcome = super::variables::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 505 | - | admin_page(&app, shell, Some(outcome.unwrap_or_else(|e| e))).await |
|
| 683 | + | admin_page(&app, shell, AdminSection::Variables, Some(outcome.unwrap_or_else(|e| e))).await |
|
| 506 | 684 | } |
|
| 507 | 685 | ||
| 508 | 686 | /// `POST /admin/secrets`: the site's Flow secrets (#107). |
|
| … | 6 unchanged lines |
||
| 515 | 693 | require_admin(&viewer, &shell)?; |
|
| 516 | 694 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 517 | 695 | let outcome = super::secrets::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 518 | - | admin_page(&app, shell, Some(outcome.unwrap_or_else(|e| e))).await |
|
| 696 | + | admin_page(&app, shell, AdminSection::Secrets, Some(outcome.unwrap_or_else(|e| e))).await |
|
| 519 | 697 | } |
|
| 520 | 698 | ||
| 521 | 699 | /// `POST /admin/signin`: a sign-in provider's client id, secret and switch (#106). |
|
| … | 6 unchanged lines |
||
| 528 | 706 | require_admin(&viewer, &shell)?; |
|
| 529 | 707 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 530 | 708 | let outcome = super::signin::apply(&app, &viewer, f).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 531 | - | admin_page(&app, shell, Some(outcome.unwrap_or_else(|e| e))).await |
|
| 709 | + | admin_page(&app, shell, AdminSection::SignIn, Some(outcome.unwrap_or_else(|e| e))).await |
|
| 532 | 710 | } |
|
| 533 | 711 | ||
| 534 | 712 | /// `POST /admin/assistants/models`: what a provider offers, as JSON (#109). |
|
| … | 10 unchanged lines |
modified crates/hub/src/web/mod.rs +10 −7
| … | 151 unchanged lines |
||
| 152 | 152 | .route("/settings/notifications", get(inbox::unsubscribe).post(inbox::prefs)) |
|
| 153 | 153 | .route("/search", get(inbox::search)) |
|
| 154 | 154 | .route("/admin", get(admin::index)) |
|
| 155 | - | .route("/admin/registration", post(admin::set_mode)) |
|
| 155 | + | .route("/admin/accounts", get(admin::section)) |
|
| 156 | + | .route("/admin/organisations", get(admin::section)) |
|
| 157 | + | .route("/admin/audit", get(admin::section)) |
|
| 158 | + | .route("/admin/registration", get(admin::section).post(admin::set_mode)) |
|
| 156 | 159 | .route("/admin/invite", post(admin::invite)) |
|
| 157 | 160 | .route("/admin/account", post(admin::account)) |
|
| 158 | 161 | .route("/admin/storage", get(admin::storage).post(admin::maintain)) |
|
| … | 11 unchanged lines |
||
| 170 | 173 | .route("/admin/runners/{id}/state", post(runners::set_state)) |
|
| 171 | 174 | .route("/admin/runners/{id}/scope", post(runners::set_scope)) |
|
| 172 | 175 | .route("/admin/runners/{id}/limits", post(runners::set_limits)) |
|
| 173 | - | .route("/admin/mail", post(admin::set_mail)) |
|
| 176 | + | .route("/admin/mail", get(admin::section).post(admin::set_mail)) |
|
| 174 | 177 | .route("/admin/mail/test", post(admin::test_mail)) |
|
| 175 | - | .route("/admin/assistants", post(admin::assistants)) |
|
| 178 | + | .route("/admin/assistants", get(admin::section).post(admin::assistants)) |
|
| 176 | 179 | .route("/admin/assistants/models", post(admin::assistant_models)) |
|
| 177 | - | .route("/admin/signin", post(admin::signin)) |
|
| 178 | - | .route("/admin/security", post(security_txt::save)) |
|
| 180 | + | .route("/admin/signin", get(admin::section).post(admin::signin)) |
|
| 181 | + | .route("/admin/security", get(admin::section).post(security_txt::save)) |
|
| 179 | 182 | .route("/login/{provider}", get(signin::start)) |
|
| 180 | 183 | .route("/login/{provider}/callback", get(signin::callback)) |
|
| 181 | 184 | .route("/settings/security/connect/{provider}", get(signin::connect)) |
|
| 182 | 185 | .route("/settings/security/disconnect/{provider}", post(signin::disconnect)) |
|
| 183 | - | .route("/admin/variables", post(admin::variables)) |
|
| 184 | - | .route("/admin/secrets", post(admin::secrets)) |
|
| 186 | + | .route("/admin/variables", get(admin::section).post(admin::variables)) |
|
| 187 | + | .route("/admin/secrets", get(admin::section).post(admin::secrets)) |
|
| 185 | 188 | .route("/admin/runners/{id}/remove", post(runners::remove)) |
|
| 186 | 189 | .nest("/api/v1", crate::api::router(app.config.limits.asset_limit.0 as usize)) |
|
| 187 | 190 | // Drops (#279): `drops` is reserved, as an owner and as a repository name. |
|
| … | 711 unchanged lines |
modified crates/hub/src/web/security_txt.rs +1 −1
| … | 143 unchanged lines |
||
| 144 | 144 | } else { |
|
| 145 | 145 | "Security contact saved: /.well-known/security.txt names it.".to_string() |
|
| 146 | 146 | }; |
|
| 147 | - | super::admin::admin_page(&app, shell, Some(notice)).await |
|
| 147 | + | super::admin::admin_page(&app, shell, super::admin::AdminSection::SecurityContact, Some(notice)).await |
|
| 148 | 148 | } |
|
| 149 | 149 | ||
| 150 | 150 | #[cfg(test)] |
|
| … | 26 unchanged lines |
modified crates/hub/tests/accounts.rs +36 −10
| … | 799 unchanged lines |
||
| 800 | 800 | let h = hub().await; |
|
| 801 | 801 | let app = &h.app; |
|
| 802 | 802 | let andy = register(app, "andy", "").await; |
|
| 803 | - | let page = get(app, "/admin", Some(&andy)).await.body; |
|
| 803 | + | let page = get(app, "/admin/mail", Some(&andy)).await.body; |
|
| 804 | 804 | assert!(page.contains("Mail is off"), "{page}"); |
|
| 805 | 805 | let csrf = csrf_of(&page); |
|
| 806 | 806 | // A directory store for messages, as hub.toml's file:// would be. |
|
| … | 99 unchanged lines |
||
| 906 | 906 | let (endpoint, seen) = mock_assistant().await; |
|
| 907 | 907 | ||
| 908 | 908 | // The site level: an administrator adds Claude with a token. |
|
| 909 | - | let page = get(app, "/admin", Some(&andy)).await.body; |
|
| 909 | + | let page = get(app, "/admin/assistants", Some(&andy)).await.body; |
|
| 910 | 910 | assert!(page.contains("AI assistants") && page.contains("Add an assistant"), "{page}"); |
|
| 911 | 911 | let csrf = csrf_of(&page); |
|
| 912 | 912 | let add = |name: &str, extra: &str| { |
|
| … | 246 unchanged lines |
||
| 1159 | 1159 | ||
| 1160 | 1160 | // Nothing to sign in with until an administrator sets a provider up. |
|
| 1161 | 1161 | assert!(!get(app, "/login", None).await.body.contains("/login/github")); |
|
| 1162 | - | let admin = get(app, "/admin", Some(&andy)).await.body; |
|
| 1162 | + | let admin = get(app, "/admin/signin", Some(&andy)).await.body; |
|
| 1163 | 1163 | assert!(admin.contains("Sign in with") && admin.contains(&format!("http://127.0.0.1:{port}/login/github/callback")), "{admin}"); |
|
| 1164 | 1164 | let csrf = csrf_of(&admin); |
|
| 1165 | 1165 | let r = |
|
| … | 117 unchanged lines |
||
| 1283 | 1283 | let andy_row = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap(); |
|
| 1284 | 1284 | for i in 0..60 { |
|
| 1285 | 1285 | db::accounts::audit(&app.db, Some(&andy_row), "test.event", &format!("t{i}"), "").await.unwrap(); |
|
| 1286 | + | } |
|
| 1287 | + | // oxygenhub#288: a sidebar of grouped pages; each section its own page, |
|
| 1288 | + | // marked current there; the overview forwards a kept /admin#section. |
|
| 1289 | + | let overview = get(app, "/admin", Some(&andy)).await.body; |
|
| 1290 | + | assert!(overview.contains("class=\"settings-nav\"") && overview.contains("data-settings-anchors"), "{overview}"); |
|
| 1291 | + | for (id, path) in [ |
|
| 1292 | + | ("accounts", "/admin/accounts"), |
|
| 1293 | + | ("registration", "/admin/registration"), |
|
| 1294 | + | ("organisations", "/admin/organisations"), |
|
| 1295 | + | ("signin", "/admin/signin"), |
|
| 1296 | + | ("mail", "/admin/mail"), |
|
| 1297 | + | ("security", "/admin/security"), |
|
| 1298 | + | ("variables", "/admin/variables"), |
|
| 1299 | + | ("secrets", "/admin/secrets"), |
|
| 1300 | + | ("assistants", "/admin/assistants"), |
|
| 1301 | + | ("audit", "/admin/audit"), |
|
| 1302 | + | ] { |
|
| 1303 | + | assert!(overview.contains(&format!("href=\"{path}\" data-slug=\"{id}\"")), "{id} in the sidebar: {overview}"); |
|
| 1304 | + | let page = get(app, path, Some(&andy)).await.body; |
|
| 1305 | + | assert!(page.contains(&format!("id=\"{id}\"")), "section {id}: {page}"); |
|
| 1306 | + | assert!(page.contains(&format!("href=\"{path}\" data-slug=\"{id}\" class=\"on\" aria-current=\"page\"")), "{id} marked: {page}"); |
|
| 1286 | 1307 | } |
|
| 1287 | - | let page = get(app, "/admin", Some(&andy)).await.body; |
|
| 1288 | - | for id in ["accounts", "registration", "organisations", "signin", "mail", "variables", "secrets", "assistants", "audit"] { |
|
| 1289 | - | assert!(page.contains(&format!("href=\"#{id}\"")) && page.contains(&format!("id=\"{id}\"")), "section {id}: {page}"); |
|
| 1308 | + | for path in |
|
| 1309 | + | ["/admin/runners", "/admin/runners/images", "/admin/runners/settings", "/admin/storage", "/admin/diffusion", "/admin/backups"] |
|
| 1310 | + | { |
|
| 1311 | + | let page = get(app, path, Some(&andy)).await.body; |
|
| 1312 | + | assert!(page.contains("class=\"settings-nav\"") && page.contains(&format!("href=\"{path}\"")), "{path} has the sidebar"); |
|
| 1290 | 1313 | } |
|
| 1291 | - | assert!(page.contains("class=\"section-nav\"")); |
|
| 1314 | + | // Others are not shown it. |
|
| 1315 | + | assert_eq!(get(app, "/admin/mail", None).await.status, StatusCode::NOT_FOUND); |
|
| 1316 | + | assert_eq!(get(app, "/admin/audit", None).await.status, StatusCode::NOT_FOUND); |
|
| 1317 | + | let page = get(app, "/admin/audit", Some(&andy)).await.body; |
|
| 1292 | 1318 | assert!(page.contains(">t59<") && !page.contains(">t5<"), "the newest page only"); |
|
| 1293 | 1319 | let i = page.find("audit_before=").expect("an older link") + "audit_before=".len(); |
|
| 1294 | 1320 | let before: String = page[i..].chars().take_while(|c| c.is_ascii_digit()).collect(); |
|
| 1295 | - | let older = get(app, &format!("/admin?audit_before={before}"), Some(&andy)).await.body; |
|
| 1321 | + | let older = get(app, &format!("/admin/audit?audit_before={before}"), Some(&andy)).await.body; |
|
| 1296 | 1322 | assert!(older.contains(">t5<") && !older.contains(">t59<"), "the next page: {older}"); |
|
| 1297 | 1323 | } |
|
| 1298 | 1324 | ||
| … | 21 unchanged lines |
||
| 1320 | 1346 | tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); |
|
| 1321 | 1347 | let ep = format!("http%3A%2F%2F127.0.0.1%3A{port}%2Fv1%2Fchat%2Fcompletions"); |
|
| 1322 | 1348 | ||
| 1323 | - | let page = get(app, "/admin", Some(&andy)).await.body; |
|
| 1349 | + | let page = get(app, "/admin/assistants", Some(&andy)).await.body; |
|
| 1324 | 1350 | assert!(page.contains("data-preset-select") && page.contains("Google Gemini") && page.contains("Other (enter the endpoint)"), "{page}"); |
|
| 1325 | 1351 | assert!(page.contains("LIST MODELS")); |
|
| 1326 | 1352 | let csrf = csrf_of(&page); |
|
| … | 243 unchanged lines |
||
| 1570 | 1596 | let app = &h.app; |
|
| 1571 | 1597 | assert_eq!(get(app, "/.well-known/security.txt", None).await.status, StatusCode::NOT_FOUND, "no contact, no file"); |
|
| 1572 | 1598 | let andy = register(app, "andy", "").await; |
|
| 1573 | - | let page = get(app, "/admin", Some(&andy)).await.body; |
|
| 1599 | + | let page = get(app, "/admin/security", Some(&andy)).await.body; |
|
| 1574 | 1600 | assert!(page.contains("No contact yet"), "the panel"); |
|
| 1575 | 1601 | let csrf = csrf_of(&page); |
|
| 1576 | 1602 | let set = |contact: &'static str, policy: &'static str| { |
|
| … | 197 unchanged lines |
modified crates/hub/tests/flow.rs +1 −1
| … | 2213 unchanged lines |
||
| 2214 | 2214 | assert_eq!(got["env"], serde_json::json!({ "CC": "clang", "LANG": "C", "RUST_LOG": "debug" }), "{got}"); |
|
| 2215 | 2215 | ||
| 2216 | 2216 | // Removing a site variable from the admin page takes effect at the next claim. |
|
| 2217 | - | let (_, admin) = page("/admin", None).await; |
|
| 2217 | + | let (_, admin) = page("/admin/variables", None).await; |
|
| 2218 | 2218 | assert!(admin.contains("Variables") && admin.contains("<code>gcc</code>"), "{admin}"); |
|
| 2219 | 2219 | let (_, body) = page("/admin/variables", Some(format!("_csrf={}&action=delete&name=LANG", csrf_of(&admin)))).await; |
|
| 2220 | 2220 | assert!(body.contains("LANG removed."), "{body}"); |
|
| … | 3985 unchanged lines |
modified crates/hub/tests/recovery.rs +1 −1
| … | 201 unchanged lines |
||
| 202 | 202 | ||
| 203 | 203 | // An administrator's link, for an account without a verified email. |
|
| 204 | 204 | post(app, "/register", None, "username=bob&email=&password=bob-password-long-enough&setup=test-setup").await; |
|
| 205 | - | let admin = get(app, "/admin", Some(&session)).await; |
|
| 205 | + | let admin = get(app, "/admin/accounts", Some(&session)).await; |
|
| 206 | 206 | assert!(admin.body.contains("value=\"reset_link\"")); |
|
| 207 | 207 | let r = post(app, "/admin/account", Some(&session), &format!("_csrf={}&username=bob&action=reset_link", csrf_of(&admin.body))).await; |
|
| 208 | 208 | assert!(r.body.contains("Reset link for bob"), "{}", r.body); |
|
| … | 17 unchanged lines |
modified docs/spec.md +1 −1
| … | 298 unchanged lines |
||
| 299 | 299 | | `/projects/{owner}`, `/projects/{owner}/{n}` | Projects of a person or organisation (§8.2) | |
|
| 300 | 300 | | `/search` | §11 | |
|
| 301 | 301 | | `/help` | Getting started with this hub's own address, remote name and certificate filled in (identity, pushing, cloning, proposals, delegating to the hub, pipelines and stamps), the keyboard, and a glossary for people coming from GitHub. Linked from the header band, the footer and the keyboard sheet | |
|
| 302 | - | | `/admin/*` | Site: accounts, registration mode, invitations, audit log; `/admin/storage` lists every repository's size on disk, loose objects and packs, and runs maintenance (`oxy gc` with the grace period kept, under the repository's write lock) on one repository or all, audited | |
|
| 302 | + | | `/admin/*` | Site administration (site administrators only; others get not found), a sidebar of grouped pages beside the page shown, the current one marked (`aria-current`), as a repository's settings (oxygenhub#288): **Overview** (`/admin`: the site's numbers and a card per group), **People** (Accounts `/admin/accounts`, Registration & invitations `/admin/registration`, Organisations `/admin/organisations`), **Sign-in** (Sign in with `/admin/signin`, Security contact `/admin/security`), **Mail** (`/admin/mail`), **Flow** (Runners, Images, Runner settings, Insights, Variables `/admin/variables`, Secrets `/admin/secrets`), **Integrations** (AI assistants `/admin/assistants`, Diffusion), **Data** (Storage & maintenance, Backups), **Audit** (`/admin/audit`, paged with `?audit_before=`). Each form posts to its own path, as before, and answers with its own page; a kept `/admin#section` link opens that section's page (`hub.js`). Accounts, registration mode, invitations, audit log; `/admin/storage` lists every repository's size on disk, loose objects and packs, and runs maintenance (`oxy gc` with the grace period kept, under the repository's write lock) on one repository or all, audited | |
|
| 303 | 303 | ||
| 304 | 304 | ### 6.4 Repository overview |
|
| 305 | 305 | ||
| … | 582 unchanged lines |
modified templates/admin.html +38 −21
| … | 1 unchanged lines |
||
| 2 | 2 | {% import "assistants_panel.html" as ai %} |
|
| 3 | 3 | {% import "variables_panel.html" as va %} |
|
| 4 | 4 | {% import "secrets_panel.html" as se %} |
|
| 5 | - | {% block title %}Administration{% endblock %} |
|
| 5 | + | {% import "admin_nav.html" as an %} |
|
| 6 | + | {% block title %}{% if section != crate::web::admin::AdminSection::Overview %}{{ section.title() }} · {% endif %}Administration{% endblock %} |
|
| 6 | 7 | {% block content %} |
|
| 7 | 8 | <div class="head"><h1>Administration <small>{{ accounts.len() }} accounts · {{ orgs.len() }} organisations · {{ repos }} repositories</small></h1></div> |
|
| 8 | - | <div class="sectioned"> |
|
| 9 | - | <nav class="section-nav" aria-label="Sections"> |
|
| 10 | - | <a href="#accounts">Accounts</a> |
|
| 11 | - | <a href="#registration">Registration</a> |
|
| 12 | - | <a href="#organisations">Organisations</a> |
|
| 13 | - | <a href="#signin">Sign in with</a> |
|
| 14 | - | <a href="#mail">Mail</a> |
|
| 15 | - | <a href="#security">Security contact</a> |
|
| 16 | - | <a href="#variables">Variables</a> |
|
| 17 | - | <a href="#secrets">Secrets</a> |
|
| 18 | - | <a href="#assistants">AI assistants</a> |
|
| 19 | - | <a href="#audit">Audit log</a> |
|
| 20 | - | <a href="/admin/runners">Runners ↗</a> |
|
| 21 | - | <a href="/admin/runners/images">Images ↗</a> |
|
| 22 | - | <a href="/admin/diffusion">Diffusion ↗</a> |
|
| 23 | - | <a href="/admin/backups">Backups ↗</a> |
|
| 24 | - | <a href="/admin/storage">Storage and maintenance ↗</a> |
|
| 25 | - | </nav> |
|
| 26 | - | <div class="sections"> |
|
| 9 | + | <div class="settings-layout"> |
|
| 10 | + | {% call an::nav(section) %} |
|
| 11 | + | <div class="sections settings-body"> |
|
| 27 | 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 %} |
|
| 28 | 34 | <section class="panel" id="accounts"> |
|
| 29 | 35 | <h2>Accounts</h2> |
|
| 30 | 36 | <div class="table"><table class="small"><tr><th>Username</th><th>Email</th><th>Flags</th><th>Last seen</th><th></th></tr> |
|
| … | 11 unchanged lines |
||
| 42 | 48 | {% endfor %} |
|
| 43 | 49 | </table></div> |
|
| 44 | 50 | </section> |
|
| 51 | + | {% when crate::web::admin::AdminSection::Registration %} |
|
| 45 | 52 | <section class="panel" id="registration"> |
|
| 46 | 53 | <h2>Registration and invitations</h2> |
|
| 47 | 54 | <form class="card" method="post" action="/admin/registration"> |
|
| … | 5 unchanged lines |
||
| 53 | 60 | <form class="card" method="post" action="/admin/invite"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit">NEW INVITATION</button></form> |
|
| 54 | 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 %} |
|
| 55 | 62 | </section> |
|
| 63 | + | {% when crate::web::admin::AdminSection::Organisations %} |
|
| 56 | 64 | <section class="panel" id="organisations"> |
|
| 57 | 65 | <h2>Organisations</h2> |
|
| 58 | 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 %} |
|
| 59 | 67 | </section> |
|
| 68 | + | {% when crate::web::admin::AdminSection::SignIn %} |
|
| 60 | 69 | <section class="panel" id="signin"> |
|
| 61 | 70 | <h2>Sign in with</h2> |
|
| 62 | 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> |
|
| … | 11 unchanged lines |
||
| 74 | 83 | </form> |
|
| 75 | 84 | {% endfor %} |
|
| 76 | 85 | </section> |
|
| 86 | + | {% when crate::web::admin::AdminSection::Mail %} |
|
| 77 | 87 | <section class="panel" id="mail"> |
|
| 78 | 88 | <h2>Mail</h2> |
|
| 79 | 89 | <form class="card mail-panel" method="post" action="/admin/mail"> |
|
| … | 16 unchanged lines |
||
| 96 | 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> |
|
| 97 | 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> |
|
| 98 | 108 | </section> |
|
| 109 | + | {% when crate::web::admin::AdminSection::SecurityContact %} |
|
| 99 | 110 | <section class="panel" id="security"> |
|
| 100 | 111 | <h2>Security contact</h2> |
|
| 101 | 112 | <form class="card" method="post" action="/admin/security"> |
|
| … | 6 unchanged lines |
||
| 108 | 119 | </form> |
|
| 109 | 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> |
|
| 110 | 121 | </section> |
|
| 122 | + | {% when crate::web::admin::AdminSection::Variables %} |
|
| 111 | 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 %} |
|
| 112 | 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 %} |
|
| 113 | 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 %} |
|
| 114 | 129 | <section class="panel" id="audit"> |
|
| 115 | 130 | <h2>Audit log</h2> |
|
| 116 | 131 | <div class="table"><table class="small"><tr><th>When</th><th>Who</th><th>Action</th><th>Target</th><th>Detail</th></tr> |
|
| 117 | 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 %} |
|
| 118 | 133 | </table></div> |
|
| 119 | - | <p class="pager small"><a href="/admin#audit">newest</a>{% if let Some(o) = audit_older %} <a class="go small" href="/admin?audit_before={{ o }}#audit">older</a>{% endif %}</p> |
|
| 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> |
|
| 120 | 135 | </section> |
|
| 136 | + | {% else %} |
|
| 137 | + | {% endmatch %} |
|
| 121 | 138 | </div> |
|
| 122 | 139 | </div> |
|
| 123 | 140 | {% endblock %} |
modified templates/admin_backups.html +6 −0
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | + | {% import "admin_nav.html" as an %} |
|
| 2 | 3 | {% block title %}Backups · Administration{% endblock %} |
|
| 3 | 4 | {% block mainclass %} class="wide"{% endblock %} |
|
| 4 | 5 | {% block content %} |
|
| 5 | 6 | <div class="head"><h1>Backups <small>{{ owners.len()|plural("owner", "owners") }} · <a href="/admin">Administration</a></small></h1></div> |
|
| 7 | + | <div class="settings-layout"> |
|
| 8 | + | {% call an::nav(crate::web::admin::AdminSection::Backups) %} |
|
| 9 | + | <div class="sections settings-body"> |
|
| 6 | 10 | <p class="small mute">Each user and organisation backs up its own repositories, to a place it controls (docs/backups.md). Here is whether each one ran and how it went; what a run wrote stays with its owner. This site's own backup runs on its host, outside the hub.</p> |
|
| 7 | 11 | <section class="card-box"> |
|
| 8 | 12 | <div class="card-head"><h2>Owners' backups</h2><span class="count-chip">{{ owners.len() }}</span></div> |
|
| … | 23 unchanged lines |
||
| 32 | 36 | <ul class="plain">{% for (owner, n) in uncovered %}<li><a href="/{{ owner }}">{{ owner }}</a> <span class="mute small">{{ n|plural("repository", "repositories") }}</span></li>{% endfor %}</ul> |
|
| 33 | 37 | {% endif %} |
|
| 34 | 38 | </section> |
|
| 39 | + | </div> |
|
| 40 | + | </div> |
|
| 35 | 41 | {% endblock %} |
modified templates/admin_diffusion.html +6 −0
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | + | {% import "admin_nav.html" as an %} |
|
| 2 | 3 | {% import "diffusion_rows.html" as d %} |
|
| 3 | 4 | {% block title %}Diffusion · Administration{% endblock %} |
|
| 4 | 5 | {% block mainclass %} class="wide"{% endblock %} |
|
| 5 | 6 | {% block content %} |
|
| 6 | 7 | <div class="head"><h1>Diffusion <small>{{ upstreams.len()|plural("upstream", "upstreams") }} · <a href="/admin">Administration</a></small></h1></div> |
|
| 8 | + | <div class="settings-layout"> |
|
| 9 | + | {% call an::nav(crate::web::admin::AdminSection::Diffusion) %} |
|
| 10 | + | <div class="sections settings-body"> |
|
| 7 | 11 | <p class="small mute df-key">Each pill has two halves. Left, the upstream: <span class="df-pill"><span class="df-half df-green">checked</span></span> its own run passed, <span class="df-pill"><span class="df-half df-amber">untested</span></span> it has no checks, <span class="df-pill"><span class="df-half df-red">failing</span></span> its run failed. Right, the repository that pins it: <span class="df-pill"><span class="df-half df-green">passes</span></span> its run on the bump passed, <span class="df-pill"><span class="df-half df-red">fails</span></span> it failed, <span class="df-pill"><span class="df-half df-grey">testing</span></span> still running, <span class="df-pill"><span class="df-half df-green">up to date</span></span> nothing to bump.</p> |
|
| 8 | 12 | {% if upstreams.is_empty() %}<p class="empty">No repository on this hub is pinned by another yet.</p>{% endif %} |
|
| 9 | 13 | {% for (up, list, unverified) in upstreams %} |
|
| 10 | 14 | <h2><a href="/{{ up.owner }}/{{ up.name }}/diffusion">{{ up.owner }}/{{ up.name }}</a> <small>{{ list.len()|plural("consumer", "consumers") }}{% if *unverified %} · <span class="df-pill"><span class="df-half df-amber">untested</span></span>{% endif %}</small></h2> |
|
| 11 | 15 | {% call d::rows(list) %} |
|
| 12 | 16 | {% endfor %} |
|
| 17 | + | </div> |
|
| 18 | + | </div> |
|
| 13 | 19 | {% endblock %} |
modified templates/admin_images.html +6 −0
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | + | {% import "admin_nav.html" as an %} |
|
| 2 | 3 | {% block title %}Images · Administration{% endblock %} |
|
| 3 | 4 | {% block content %} |
|
| 4 | 5 | {# The swarm (docs/backgrounds.md §2): the hub and the runners, and the |
|
| … | 2 unchanged lines |
||
| 7 | 8 | {% include "backdrop.html" %} |
|
| 8 | 9 | <div data-backdrop-avoid> |
|
| 9 | 10 | <div class="head"><h1>Runners <small>{{ images.len()|plural("image", "images") }} · {{ on_hub }} on the hub · <a href="/admin">Administration</a></small></h1></div> |
|
| 11 | + | <div class="settings-layout"> |
|
| 12 | + | {% call an::nav(crate::web::admin::AdminSection::Images) %} |
|
| 13 | + | <div class="sections settings-body"> |
|
| 10 | 14 | <div class="bar"><span class="modes"><a href="/admin/runners">runners</a><a href="/admin/runners/images" class="on">images</a><a href="/admin/runners/settings">settings</a></span></div> |
|
| 11 | 15 | <p class="small mute">Every rootfs image a runner holds, the hub keeps, or a job ran on in the last 90 days, newest use first. {% if swarm.is_empty() %}No runner shares images peer to peer yet: give one a <code>[runner.swarm]</code> section.{% else %}Sharing images peer to peer: {% for s in swarm %}{{ s }}{% if !loop.last %}, {% endif %}{% endfor %}.{% endif %} Send an image to the hub with <code>flow rootfs push HASH</code>.</p> |
|
| 12 | 16 | {% if images.is_empty() %} |
|
| … | 13 unchanged lines |
||
| 26 | 30 | </table></div> |
|
| 27 | 31 | {% endif %} |
|
| 28 | 32 | </div> |
|
| 33 | + | </div> |
|
| 34 | + | </div> |
|
| 29 | 35 | {% endblock %} |
modified templates/admin_runner.html +6 −0
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | + | {% import "admin_nav.html" as an %} |
|
| 2 | 3 | {% block title %}{{ r.name }} · Runners{% endblock %} |
|
| 3 | 4 | {% block mainclass %} class="wide"{% endblock %} |
|
| 4 | 5 | {% block content %} |
|
| 5 | 6 | <div class="head"><h1>{{ r.name }} <small><span class="runner-status st-{{ r.status() }}">{{ r.status() }}</span> · <a href="/admin/runners">Runners</a></small></h1></div> |
|
| 7 | + | <div class="settings-layout"> |
|
| 8 | + | {% call an::nav(crate::web::admin::AdminSection::Runners) %} |
|
| 9 | + | <div class="sections settings-body"> |
|
| 6 | 10 | <section class="panel"> |
|
| 7 | 11 | <dl class="facts"> |
|
| 8 | 12 | <dt>Signing key</dt><dd>{% if r.key.is_empty() %}<span class="mute">bound on its first signed receipt</span>{% else %}<code class="full">{{ r.key }}</code> {{ r.key_name }}{% endif %}</dd> |
|
| … | 51 unchanged lines |
||
| 60 | 64 | {% if let Some(b) = older %}<p><a href="?before={{ b }}">Older jobs</a></p>{% endif %} |
|
| 61 | 65 | {% endif %} |
|
| 62 | 66 | </section> |
|
| 67 | + | </div> |
|
| 68 | + | </div> |
|
| 63 | 69 | {% endblock %} |
modified templates/admin_runner_settings.html +6 −0
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | + | {% import "admin_nav.html" as an %} |
|
| 2 | 3 | {% block title %}Runner settings · Administration{% endblock %} |
|
| 3 | 4 | {% block content %} |
|
| 4 | 5 | <div class="head"><h1>Runners <small><a href="/admin">Administration</a></small></h1></div> |
|
| 6 | + | <div class="settings-layout"> |
|
| 7 | + | {% call an::nav(crate::web::admin::AdminSection::RunnerSettings) %} |
|
| 8 | + | <div class="sections settings-body"> |
|
| 5 | 9 | <div class="bar"><span class="modes"><a href="/admin/runners">runners</a><a href="/admin/runners/images">images</a><a href="/admin/runners/settings" class="on">settings</a></span></div> |
|
| 6 | 10 | {% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %} |
|
| 7 | 11 | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| … | 24 unchanged lines |
||
| 32 | 36 | <h2>Repositories that run Flow</h2> |
|
| 33 | 37 | {% if flow_repos.is_empty() %}<p class="mute">None listed in hub.toml; each repository's administrator switches Flow on in its Settings.</p>{% else %}<ul class="plain">{% for r in flow_repos %}<li><code>{{ r }}</code></li>{% endfor %}</ul><p class="small mute">From hub.toml's <code>[flow] repos</code>; others are switched on in each repository's Settings.</p>{% endif %} |
|
| 34 | 38 | </section> |
|
| 39 | + | </div> |
|
| 40 | + | </div> |
|
| 35 | 41 | {% endblock %} |
modified templates/admin_runners.html +6 −0
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | + | {% import "admin_nav.html" as an %} |
|
| 2 | 3 | {% block title %}Runners · Administration{% endblock %} |
|
| 3 | 4 | {% block mainclass %} class="wide"{% endblock %} |
|
| 4 | 5 | {% block content %} |
|
| 5 | 6 | <div class="head"><h1>Runners <small>{{ runners.len()|plural("runner", "runners") }} · <a href="/admin">Administration</a></small></h1></div> |
|
| 7 | + | <div class="settings-layout"> |
|
| 8 | + | {% call an::nav(crate::web::admin::AdminSection::Runners) %} |
|
| 9 | + | <div class="sections settings-body"> |
|
| 6 | 10 | <div class="bar"><span class="modes"><a href="/admin/runners" class="on">runners</a><a href="/admin/runners/images">images</a><a href="/admin/runners/settings">settings</a></span></div> |
|
| 7 | 11 | {% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %} |
|
| 8 | 12 | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| … | 57 unchanged lines |
||
| 66 | 70 | <button type="submit" class="primary-btn">Create a registration token</button></form> |
|
| 67 | 71 | {% if !pending.is_empty() %}<p class="small mute">Unused tokens: {% for (by, made, expires) in pending %}by {{ by }} {{ made|ago_i }}, expires {{ expires|when_i }}{% if !loop.last %}; {% endif %}{% endfor %}</p>{% endif %} |
|
| 68 | 72 | </section> |
|
| 73 | + | </div> |
|
| 74 | + | </div> |
|
| 69 | 75 | {% endblock %} |
modified templates/admin_storage.html +6 −0
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | + | {% import "admin_nav.html" as an %} |
|
| 2 | 3 | {% block title %}Storage · Administration{% endblock %} |
|
| 3 | 4 | {% block content %} |
|
| 4 | 5 | <div class="head"><h1>Storage <small>{{ rows.len() }} repositories · {{ total|bytes }}</small></h1></div> |
|
| 6 | + | <div class="settings-layout"> |
|
| 7 | + | {% call an::nav(crate::web::admin::AdminSection::Storage) %} |
|
| 8 | + | <div class="sections settings-body"> |
|
| 5 | 9 | <p><a href="/admin">← administration</a></p> |
|
| 6 | 10 | {% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %} |
|
| 7 | 11 | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| … | 8 unchanged lines |
||
| 16 | 20 | {% endfor %} |
|
| 17 | 21 | </tbody> |
|
| 18 | 22 | </table> |
|
| 23 | + | </div> |
|
| 24 | + | </div> |
|
| 19 | 25 | {% endblock %} |
modified templates/style.css +5 −0
| … | 450 unchanged lines |
||
| 451 | 451 | .sections .panel form.card { max-width: 720px; } |
|
| 452 | 452 | .sections > details > form.card { max-width: none; } |
|
| 453 | 453 | /* A repository's settings (#278): grouped pages in a sidebar, one page beside it. */ |
|
| 454 | + | /* The admin overview (oxygenhub#288): the site's numbers, then a card per |
|
| 455 | + | group of pages. */ |
|
| 456 | + | .admin-counts { display: flex; flex-wrap: wrap; gap: 8px 24px; align-items: baseline; } |
|
| 457 | + | .admin-counts strong { font-size: 18px; margin-right: 4px; } |
|
| 458 | + | .admin-groups { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 0 16px; } |
|
| 454 | 459 | .settings-layout { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 28px; align-items: start; } |
|
| 455 | 460 | .settings-nav { position: sticky; top: 14px; display: flex; flex-direction: column; gap: 14px; } |
|
| 456 | 461 | .settings-group h2 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: var(--mute); margin: 0 0 4px; padding: 0 12px; } |
|
| … | 969 unchanged lines |