New repository page in sections, with OxygenFlow, the wiki and a default bookmark 1062a0f8e985
The page is laid out as Repository, Visibility, Starting files, Wiki, Project board, OxygenFlow and Storage, each with a heading, and the choices are cards side by side that stack on a phone. - OxygenFlow: on the site's runners (site administrators) or the owner's own (when it has one), checked before anything is made. - Wiki: start it with a Home page giving the name and description. - Default bookmark: chosen on the page, else the owner's default, else the site's, else main. Users set theirs under Settings, organisations under their settings, administrators under Administration. - API create takes flow, wiki and default_bookmark; oh repo create takes --flow, --wiki and --bookmark. Closes #371
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key ec728b340028
- When
- · 2026-11-05 00:14 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
1062a0f8e985bb8c65ab90ccae6309f38730152b13ca9e9c579d6f5751053161- Depends on
- 17ca6cddc534 19bb31ff1f5c 1f78655a0466 34beb1a7e376 3556d08e17f9 40a43921ef42 4ac01a732552 5c6e92fd8d71 64caed18236a 66fd9d6b1452 737de0fac722 89e89836cae7 8ddebaa7e856 a160b98fb922 b5668108487f b64e2537d702 c26267b3aba6 c40c2d3edb51 d23464c0ac60 e8e7158bbc73 ef20fdc14e8b fb8f35d025ab
- Depended on by
- nothing yet
- In bookmarks
- main
- Edits
- 88 atoms, 0 tree operations, 17 files
Browse the tree at this change
modified CHANGELOG.md +8 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | OxygenHub runs at https://oxygenhub.io; each deploy's build is on the About page and at `/api/v1/version`. |
|
| 4 | 4 | ||
| 5 | + | ## Unreleased |
|
| 6 | + | ||
| 7 | + | - **The New repository page in sections (#371):** Repository, Visibility, Starting files, Wiki, Project board, OxygenFlow and Storage, each with a heading and a line on what it does, and the choices as cards side by side that stack on a phone. |
|
| 8 | + | - **OxygenFlow with a new repository (#371):** on the site's runners (site administrators) or the owner's own (when the owner has one), set as it is made; left alone, the site's default applies. |
|
| 9 | + | - **Start the wiki (#371):** a Home page with the repository's name and description, written as you with the hub signing as your delegate. |
|
| 10 | + | - **Default bookmark (#371):** chosen on the New repository page, else the owner's default, else the site's, else `main`. A user sets theirs under Settings › New repositories, an organisation under its settings, and a site administrator under Administration › New repositories. |
|
| 11 | + | - The API's create takes `flow` (`on`, `own`), `wiki` and `default_bookmark`; `oh repo create … --flow on|own --wiki --bookmark NAME`. |
|
| 12 | + | ||
| 5 | 13 | ## 0.9.24 (2026-11-04) |
|
| 6 | 14 | ||
| 7 | 15 | - **A fresh New repository page (#360):** owner and name side by side with a live check that the name is free and valid and the address it will have; visibility as three described choices; **Start with** a README, a licence (MIT, BSD 2-Clause or ISC, with what each allows) and an `.oxyignore` for a language, committed as you with the hub signing as your delegate (it says what to do first when you have not delegated, before anything is made); and a **project board** with it: a new one (Backlog, Doing, Done, on by default for an organisation) or one of the owner's, which takes the repository's issues and proposals as they open. Storage moves under its own heading. The API's create takes the same (`project: {"new": TITLE} | {"number": N}`, `readme`, `licence`, `ignore`), and `oh repo create OWNER/NAME [--project new:TITLE|N] [--readme] [--licence MIT] [--ignore Rust]`. |
|
| … | 474 unchanged lines |
modified assets/hub.js +30 −0
| … | 1114 unchanged lines |
||
| 1115 | 1115 | if (exRadio) exRadio.parentElement.hidden = !first; |
|
| 1116 | 1116 | } |
|
| 1117 | 1117 | } |
|
| 1118 | + | // The owner's default bookmark (#371): shown, and taken when left as it is. |
|
| 1119 | + | var bm = form.querySelector("[data-default-bookmark]"); |
|
| 1120 | + | var bmFrom = form.querySelector("[data-bookmark-hint]"); |
|
| 1121 | + | var bmTouched = false; |
|
| 1122 | + | if (bm) bm.addEventListener("input", function () { bmTouched = true; }); |
|
| 1123 | + | function bookmark() { |
|
| 1124 | + | var o = owner.selectedOptions[0]; |
|
| 1125 | + | if (!bm || !o) return; |
|
| 1126 | + | var b = o.getAttribute("data-bookmark") || "main"; |
|
| 1127 | + | if (!bmTouched) bm.value = b; |
|
| 1128 | + | bm.placeholder = b; |
|
| 1129 | + | if (bmFrom) bmFrom.textContent = b + " is " + (o.getAttribute("data-bookmark-from") || "the default") + ". Change it in Settings."; |
|
| 1130 | + | } |
|
| 1131 | + | // On its owner's runners only when the owner has one (#371). |
|
| 1132 | + | var runners = (owner.getAttribute("data-runners") || "").split(" ").filter(Boolean); |
|
| 1133 | + | var own = form.querySelector("[data-flow-own]"); |
|
| 1134 | + | function flow() { |
|
| 1135 | + | if (!own) return; |
|
| 1136 | + | var has = runners.indexOf(owner.value) >= 0; |
|
| 1137 | + | var r = own.querySelector("input"); |
|
| 1138 | + | r.disabled = !has; |
|
| 1139 | + | if (!has && r.checked) form.querySelector("input[name=flow][value='']").checked = true; |
|
| 1140 | + | own.classList.toggle("off", !has); |
|
| 1141 | + | var note = own.querySelector("[data-flow-own-note]"); |
|
| 1142 | + | if (note) note.textContent = has ? "Only " + owner.value + "'s own runners take its jobs." : owner.value + " has no runners of its own yet."; |
|
| 1143 | + | } |
|
| 1118 | 1144 | owner.addEventListener("change", function () { |
|
| 1145 | + | bookmark(); |
|
| 1146 | + | flow(); |
|
| 1119 | 1147 | // An organisation's repository has a board by default. |
|
| 1120 | 1148 | var neu = form.querySelector("[data-board-new]"); |
|
| 1121 | 1149 | if (neu && orgs.indexOf(owner.value) >= 0) neu.checked = true; |
|
| … | 3 unchanged lines |
||
| 1125 | 1153 | form.querySelectorAll("input[name=board]").forEach(function (r) { r.addEventListener("change", boards); }); |
|
| 1126 | 1154 | if (orgs.indexOf(owner.value) >= 0) { var n0 = form.querySelector("[data-board-new]"); if (n0) n0.checked = true; } |
|
| 1127 | 1155 | boards(); |
|
| 1156 | + | bookmark(); |
|
| 1157 | + | flow(); |
|
| 1128 | 1158 | })(); |
modified crates/hub-api/src/lib.rs +12 −0
| … | 1000 unchanged lines |
||
| 1001 | 1001 | pub licence: Option<String>, |
|
| 1002 | 1002 | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 1003 | 1003 | pub ignore: Option<String>, |
|
| 1004 | + | /// OxygenFlow with it (#371): `on` (the site's runners; site |
|
| 1005 | + | /// administrators only) or `own` (the owner's runners, when it has |
|
| 1006 | + | /// one). Left out, the site's default applies. |
|
| 1007 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 1008 | + | pub flow: Option<String>, |
|
| 1009 | + | /// Start its wiki with a Home page giving its name and description. |
|
| 1010 | + | #[serde(default, skip_serializing_if = "std::ops::Not::not")] |
|
| 1011 | + | pub wiki: bool, |
|
| 1012 | + | /// Its default bookmark. Left out, the owner's default, else the |
|
| 1013 | + | /// site's, else `main`. |
|
| 1014 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 1015 | + | pub default_bookmark: Option<String>, |
|
| 1004 | 1016 | } |
|
| 1005 | 1017 | ||
| 1006 | 1018 | /// A new repository's board: a new one, or the owner's existing one. |
|
| … | 1070 unchanged lines |
modified crates/hub/src/api/mod.rs +16 −1
| … | 1086 unchanged lines |
||
| 1087 | 1087 | }, |
|
| 1088 | 1088 | }; |
|
| 1089 | 1089 | crate::repo_setup::check_board(&app, me, &name.owner, &board).await.map_err(|m| ApiError(StatusCode::UNPROCESSABLE_ENTITY, m))?; |
|
| 1090 | + | // OxygenFlow and the wiki with it (#371). |
|
| 1091 | + | let bookmark = body.default_bookmark.clone().unwrap_or_default(); |
|
| 1092 | + | if !bookmark.trim().is_empty() { |
|
| 1093 | + | crate::repo_setup::check_bookmark(bookmark.trim()).map_err(|m| ApiError(StatusCode::BAD_REQUEST, m))?; |
|
| 1094 | + | } |
|
| 1095 | + | let flow = crate::repo_setup::Flow::parse(body.flow.as_deref().unwrap_or("")).map_err(|m| ApiError(StatusCode::BAD_REQUEST, m))?; |
|
| 1096 | + | crate::repo_setup::check_flow(&app, me, &name.owner, flow).await.map_err(|m| ApiError(StatusCode::FORBIDDEN, m))?; |
|
| 1090 | 1097 | let start = |
|
| 1091 | 1098 | Start { readme: body.readme, licence: body.licence.clone().unwrap_or_default(), ignore: body.ignore.clone().unwrap_or_default() }; |
|
| 1092 | 1099 | let holder = if me.display_name.trim().is_empty() { me.username.clone() } else { me.display_name.clone() }; |
|
| 1093 | 1100 | let files = start |
|
| 1094 | 1101 | .files(&name, &body.description, &holder, time::OffsetDateTime::now_utc().year()) |
|
| 1095 | 1102 | .map_err(|m| ApiError(StatusCode::BAD_REQUEST, m))?; |
|
| 1096 | - | if !files.is_empty() { |
|
| 1103 | + | if !files.is_empty() || body.wiki { |
|
| 1097 | 1104 | crate::web::edit::signer_for(&app, me.id).await.map_err(|m| ApiError(StatusCode::CONFLICT, m))?; |
|
| 1098 | 1105 | } |
|
| 1099 | 1106 | app.repos.create_async(&name, None).await?; |
|
| 1100 | 1107 | let row = db::repos::create(&app.db, &name.owner, &name.name, &body.description, body.visibility).await?; |
|
| 1108 | + | let row = crate::repo_setup::set_default_bookmark(&app, row, &bookmark).await?; |
|
| 1101 | 1109 | db::accounts::audit(&app.db, Some(me), "repo.created", &name.to_string(), "api").await.ok(); |
|
| 1102 | 1110 | crate::repo_setup::link_board(&app, &row, &board, body.visibility == Visibility::Public).await?; |
|
| 1111 | + | crate::repo_setup::set_flow(&app, &row, me, flow).await?; |
|
| 1103 | 1112 | crate::repo_setup::commit_start(&app, &row, me, files).await.map_err(|m| ApiError(StatusCode::INTERNAL_SERVER_ERROR, m))?; |
|
| 1113 | + | if body.wiki { |
|
| 1114 | + | crate::repo_setup::start_wiki(&app, &row, me, &body.description) |
|
| 1115 | + | .await |
|
| 1116 | + | .map_err(|m| ApiError(StatusCode::INTERNAL_SERVER_ERROR, m))?; |
|
| 1117 | + | } |
|
| 1118 | + | let row = db::repos::get_by_id(&app.db, row.id).await?.unwrap_or(row); |
|
| 1104 | 1119 | Ok((StatusCode::CREATED, Json(repository(&app, &row)))) |
|
| 1105 | 1120 | } |
|
| 1106 | 1121 | ||
| … | 2433 unchanged lines |
modified crates/hub/src/repo_setup.rs +185 −1
| … | 2 unchanged lines |
||
| 3 | 3 | //! files it starts with (a README, a licence, an `.oxyignore` for a |
|
| 4 | 4 | //! language), committed as its creator the way the starter's are (#347), |
|
| 5 | 5 | //! and a project board made or chosen with it, which then takes its issues |
|
| 6 | - | //! and proposals as they open. |
|
| 6 | + | //! and proposals as they open. OxygenFlow switched on and the wiki |
|
| 7 | + | //! started with a Home page come with it too (#371). |
|
| 7 | 8 | ||
| 8 | 9 | use crate::auth::roles; |
|
| 9 | 10 | use crate::db::{self, projects as pdb}; |
|
| … | 79 unchanged lines |
||
| 89 | 90 | Ok(Some(p.number)) |
|
| 90 | 91 | } |
|
| 91 | 92 | ||
| 93 | + | /// Where a new repository's default bookmark comes from (#371): the |
|
| 94 | + | /// owner's own choice (a user's or an organisation's), else the site's, |
|
| 95 | + | /// else `main`. Kept in the settings table, so nothing else changes. |
|
| 96 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
|
| 97 | + | pub enum BookmarkScope { |
|
| 98 | + | Site, |
|
| 99 | + | Account(i64), |
|
| 100 | + | Org(i64), |
|
| 101 | + | } |
|
| 102 | + | ||
| 103 | + | /// The bookmark a repository has when nothing else is chosen. |
|
| 104 | + | pub const MAIN: &str = "main"; |
|
| 105 | + | ||
| 106 | + | impl BookmarkScope { |
|
| 107 | + | fn key(self) -> String { |
|
| 108 | + | match self { |
|
| 109 | + | BookmarkScope::Site => "default_bookmark".into(), |
|
| 110 | + | BookmarkScope::Account(id) => format!("default_bookmark.account.{id}"), |
|
| 111 | + | BookmarkScope::Org(id) => format!("default_bookmark.org.{id}"), |
|
| 112 | + | } |
|
| 113 | + | } |
|
| 114 | + | } |
|
| 115 | + | ||
| 116 | + | /// The default bookmark set at `scope`, if one is. |
|
| 117 | + | pub async fn bookmark_setting(pool: &db::Pool, scope: BookmarkScope) -> sqlx::Result<Option<String>> { |
|
| 118 | + | Ok(db::accounts::setting(pool, &scope.key()).await?.filter(|v| !v.is_empty())) |
|
| 119 | + | } |
|
| 120 | + | ||
| 121 | + | /// Set (or, empty, clear) the default bookmark at `scope`. |
|
| 122 | + | pub async fn set_bookmark_setting(pool: &db::Pool, scope: BookmarkScope, value: &str) -> Result<(), String> { |
|
| 123 | + | let v = value.trim(); |
|
| 124 | + | if !v.is_empty() { |
|
| 125 | + | check_bookmark(v)?; |
|
| 126 | + | } |
|
| 127 | + | db::accounts::set_setting(pool, &scope.key(), v).await.map_err(|e| e.to_string()) |
|
| 128 | + | } |
|
| 129 | + | ||
| 130 | + | /// A bookmark name a repository may default to. |
|
| 131 | + | pub fn check_bookmark(name: &str) -> Result<(), String> { |
|
| 132 | + | if crate::repos::names::valid_segment(name) { |
|
| 133 | + | Ok(()) |
|
| 134 | + | } else { |
|
| 135 | + | Err(format!( |
|
| 136 | + | "{name:?} is not a bookmark name: letters, digits, dots, dashes and underscores, up to 64, starting with a letter or digit." |
|
| 137 | + | )) |
|
| 138 | + | } |
|
| 139 | + | } |
|
| 140 | + | ||
| 141 | + | /// The default bookmark a new repository under `owner` takes, and where |
|
| 142 | + | /// it comes from (for the page: "develop, from doomsdayone's settings"). |
|
| 143 | + | pub async fn default_bookmark_for(app: &Shared, owner: &str) -> (String, String) { |
|
| 144 | + | let own = match db::accounts::org_by_name(&app.db, owner).await.ok().flatten() { |
|
| 145 | + | Some(org) => Some(BookmarkScope::Org(org.id)), |
|
| 146 | + | None => db::accounts::account_by_name(&app.db, owner).await.ok().flatten().map(|a| BookmarkScope::Account(a.id)), |
|
| 147 | + | }; |
|
| 148 | + | if let Some(scope) = own { |
|
| 149 | + | if let Ok(Some(b)) = bookmark_setting(&app.db, scope).await { |
|
| 150 | + | return (b, format!("{owner}'s default")); |
|
| 151 | + | } |
|
| 152 | + | } |
|
| 153 | + | match bookmark_setting(&app.db, BookmarkScope::Site).await { |
|
| 154 | + | Ok(Some(b)) => (b, "this site's default".into()), |
|
| 155 | + | _ => (MAIN.into(), "the usual default".into()), |
|
| 156 | + | } |
|
| 157 | + | } |
|
| 158 | + | ||
| 159 | + | /// The new repository's default bookmark: the one asked for, or its |
|
| 160 | + | /// owner's, the site's or `main`; set on its row when it is not `main`. |
|
| 161 | + | pub async fn set_default_bookmark(app: &Shared, row: db::repos::RepoRow, asked: &str) -> sqlx::Result<db::repos::RepoRow> { |
|
| 162 | + | let asked = asked.trim(); |
|
| 163 | + | let b = if asked.is_empty() { default_bookmark_for(app, &row.owner).await.0 } else { asked.to_string() }; |
|
| 164 | + | if b == row.default_bookmark { |
|
| 165 | + | return Ok(row); |
|
| 166 | + | } |
|
| 167 | + | db::repos::set_default_bookmark(&app.db, row.id, &b).await?; |
|
| 168 | + | Ok(db::repos::get_by_id(&app.db, row.id).await?.unwrap_or(row)) |
|
| 169 | + | } |
|
| 170 | + | ||
| 171 | + | /// OxygenFlow for a new repository (#371): left to the site's default, |
|
| 172 | + | /// on the site's runners (a site administrator's choice), or on the |
|
| 173 | + | /// owner's own runners. |
|
| 174 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
|
| 175 | + | pub enum Flow { |
|
| 176 | + | Default, |
|
| 177 | + | On, |
|
| 178 | + | Own, |
|
| 179 | + | } |
|
| 180 | + | ||
| 181 | + | impl Flow { |
|
| 182 | + | pub fn parse(choice: &str) -> Result<Flow, String> { |
|
| 183 | + | match choice.trim() { |
|
| 184 | + | "" | "default" | "off" => Ok(Flow::Default), |
|
| 185 | + | "on" => Ok(Flow::On), |
|
| 186 | + | "own" => Ok(Flow::Own), |
|
| 187 | + | other => Err(format!("OxygenFlow is on, own or left to the default, not {other}.")), |
|
| 188 | + | } |
|
| 189 | + | } |
|
| 190 | + | ||
| 191 | + | /// The repository's `flow` setting, as Settings › Pipelines writes it. |
|
| 192 | + | pub fn setting(self) -> &'static str { |
|
| 193 | + | match self { |
|
| 194 | + | Flow::Default => "", |
|
| 195 | + | Flow::On => "on", |
|
| 196 | + | Flow::Own => "own", |
|
| 197 | + | } |
|
| 198 | + | } |
|
| 199 | + | } |
|
| 200 | + | ||
| 201 | + | /// Whether `owner` has runners of its own (not the site's), which |
|
| 202 | + | /// [`Flow::Own`] needs before its repository exists. |
|
| 203 | + | pub async fn owner_has_runner(app: &Shared, owner: &str) -> sqlx::Result<bool> { |
|
| 204 | + | Ok(db::runners::serving_owner(&app.db, owner).await?.iter().any(|r| !r.scope().is_site())) |
|
| 205 | + | } |
|
| 206 | + | ||
| 207 | + | /// Checked before the repository is made, as Settings › Pipelines checks. |
|
| 208 | + | pub async fn check_flow(app: &Shared, account: &db::accounts::Account, owner: &str, flow: Flow) -> Result<(), String> { |
|
| 209 | + | match flow { |
|
| 210 | + | Flow::Default => Ok(()), |
|
| 211 | + | Flow::On if account.is_admin => Ok(()), |
|
| 212 | + | Flow::On => Err("Only site administrators let the site's runners take a repository's jobs.".into()), |
|
| 213 | + | Flow::Own => match owner_has_runner(app, owner).await.map_err(|e| e.to_string())? { |
|
| 214 | + | true => Ok(()), |
|
| 215 | + | false => { |
|
| 216 | + | Err(format!("{owner} has no runners of its own yet: register one, then switch OxygenFlow on in Settings › Pipelines.")) |
|
| 217 | + | } |
|
| 218 | + | }, |
|
| 219 | + | } |
|
| 220 | + | } |
|
| 221 | + | ||
| 222 | + | /// Switch OxygenFlow on for the new repository, as chosen. |
|
| 223 | + | pub async fn set_flow(app: &Shared, row: &db::repos::RepoRow, by: &db::accounts::Account, flow: Flow) -> sqlx::Result<()> { |
|
| 224 | + | if flow == Flow::Default { |
|
| 225 | + | return Ok(()); |
|
| 226 | + | } |
|
| 227 | + | db::repos::set_flow(&app.db, row.id, flow.setting()).await?; |
|
| 228 | + | db::accounts::audit(&app.db, Some(by), "repo.flow", &format!("{}/{}", row.owner, row.name), flow.setting()).await.ok(); |
|
| 229 | + | Ok(()) |
|
| 230 | + | } |
|
| 231 | + | ||
| 232 | + | /// A new wiki's Home page: the repository's name and description. |
|
| 233 | + | pub fn wiki_home(name: &RepoName, description: &str) -> String { |
|
| 234 | + | let about = description.trim(); |
|
| 235 | + | let about = if about.is_empty() { String::new() } else { format!("{about}\n\n") }; |
|
| 236 | + | format!("# {}\n\n{about}This is the wiki's home page. Edit it, or add pages beside it.\n", name.name) |
|
| 237 | + | } |
|
| 238 | + | ||
| 239 | + | /// Start the repository's wiki with its Home page, written as `by` and |
|
| 240 | + | /// signed as their delegate, as a wiki edit on the site is. |
|
| 241 | + | pub async fn start_wiki(app: &Shared, row: &db::repos::RepoRow, by: &db::accounts::Account, description: &str) -> Result<(), String> { |
|
| 242 | + | let name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?; |
|
| 243 | + | let signer = crate::web::edit::signer_for(app, by.id).await?; |
|
| 244 | + | let edit = FileEdit { path: crate::wiki::path_of(crate::wiki::HOME), content: Some(wiki_home(&name, description).into_bytes()) }; |
|
| 245 | + | crate::wiki::commit(app, &name, vec![edit], "Start the wiki".into(), signer).await.map_err(|e| e.to_string())?; |
|
| 246 | + | db::accounts::audit(&app.db, Some(by), "wiki.started", &name.to_string(), "Home").await.ok(); |
|
| 247 | + | Ok(()) |
|
| 248 | + | } |
|
| 249 | + | ||
| 92 | 250 | /// The licences offered, with what each allows, for the page. |
|
| 93 | 251 | pub const LICENCES: &[(&str, &str)] = &[ |
|
| 94 | 252 | ("MIT", "use, change and share it freely, keeping the notice"), |
|
| … | 158 unchanged lines |
||
| 253 | 411 | } |
|
| 254 | 412 | assert!(Start { licence: "GPL".into(), ..Default::default() }.files(&name, "", "X", 2026).is_err()); |
|
| 255 | 413 | assert!(Start::default().is_empty()); |
|
| 414 | + | } |
|
| 415 | + | ||
| 416 | + | #[test] |
|
| 417 | + | fn flow_choices_and_the_wiki_home() { |
|
| 418 | + | assert_eq!(Flow::parse("").unwrap(), Flow::Default); |
|
| 419 | + | assert_eq!(Flow::parse("off").unwrap(), Flow::Default); |
|
| 420 | + | assert_eq!(Flow::parse("on").unwrap().setting(), "on"); |
|
| 421 | + | assert_eq!(Flow::parse("own").unwrap().setting(), "own"); |
|
| 422 | + | assert!(Flow::parse("always").is_err()); |
|
| 423 | + | let name = RepoName::new("andy", "square").unwrap(); |
|
| 424 | + | assert_eq!( |
|
| 425 | + | wiki_home(&name, " A platform game. "), |
|
| 426 | + | "# square\n\nA platform game.\n\nThis is the wiki's home page. Edit it, or add pages beside it.\n" |
|
| 427 | + | ); |
|
| 428 | + | assert!(wiki_home(&name, "").starts_with("# square\n\nThis is")); |
|
| 429 | + | } |
|
| 430 | + | ||
| 431 | + | #[test] |
|
| 432 | + | fn bookmark_defaults_are_kept_apart() { |
|
| 433 | + | assert_eq!(BookmarkScope::Site.key(), "default_bookmark"); |
|
| 434 | + | assert_eq!(BookmarkScope::Account(7).key(), "default_bookmark.account.7"); |
|
| 435 | + | assert_eq!(BookmarkScope::Org(7).key(), "default_bookmark.org.7"); |
|
| 436 | + | assert!(check_bookmark("develop").is_ok()); |
|
| 437 | + | assert!(check_bookmark("release-1.0").is_ok()); |
|
| 438 | + | assert!(check_bookmark("feature/x").is_err()); |
|
| 439 | + | assert!(check_bookmark("-x").is_err()); |
|
| 256 | 440 | } |
|
| 257 | 441 | } |
modified crates/hub/src/web/admin.rs +40 −1
| … | 31 unchanged lines |
||
| 32 | 32 | Diffusion, |
|
| 33 | 33 | Storage, |
|
| 34 | 34 | Backups, |
|
| 35 | + | NewRepositories, |
|
| 35 | 36 | Audit, |
|
| 36 | 37 | } |
|
| 37 | 38 | ||
| … | 16 unchanged lines |
||
| 54 | 55 | ], |
|
| 55 | 56 | ), |
|
| 56 | 57 | ("Integrations", &[AdminSection::Assistants, AdminSection::Diffusion]), |
|
| 57 | - | ("Data", &[AdminSection::Storage, AdminSection::Backups]), |
|
| 58 | + | ("Data", &[AdminSection::NewRepositories, AdminSection::Storage, AdminSection::Backups]), |
|
| 58 | 59 | ("Audit", &[AdminSection::Audit]), |
|
| 59 | 60 | ]; |
|
| 60 | 61 | ||
| … | 24 unchanged lines |
||
| 85 | 86 | AdminSection::Diffusion => "/admin/diffusion", |
|
| 86 | 87 | AdminSection::Storage => "/admin/storage", |
|
| 87 | 88 | AdminSection::Backups => "/admin/backups", |
|
| 89 | + | AdminSection::NewRepositories => "/admin/repositories", |
|
| 88 | 90 | AdminSection::Audit => "/admin/audit", |
|
| 89 | 91 | } |
|
| 90 | 92 | } |
|
| … | 20 unchanged lines |
||
| 111 | 113 | AdminSection::Diffusion => "diffusion", |
|
| 112 | 114 | AdminSection::Storage => "storage", |
|
| 113 | 115 | AdminSection::Backups => "backups", |
|
| 116 | + | AdminSection::NewRepositories => "repositories", |
|
| 114 | 117 | AdminSection::Audit => "audit", |
|
| 115 | 118 | } |
|
| 116 | 119 | } |
|
| … | 18 unchanged lines |
||
| 135 | 138 | AdminSection::Diffusion => "Diffusion", |
|
| 136 | 139 | AdminSection::Storage => "Storage & maintenance", |
|
| 137 | 140 | AdminSection::Backups => "Backups", |
|
| 141 | + | AdminSection::NewRepositories => "New repositories", |
|
| 138 | 142 | AdminSection::Audit => "Audit log", |
|
| 139 | 143 | } |
|
| 140 | 144 | } |
|
| … | 14 unchanged lines |
||
| 155 | 159 | orgs: Vec<accounts::Organisation>, |
|
| 156 | 160 | mode: String, |
|
| 157 | 161 | invitations: Vec<(String, Option<i64>, i64)>, |
|
| 162 | + | /// New repositories' default bookmark across the site (#371); empty, `main`. |
|
| 163 | + | site_bookmark: String, |
|
| 158 | 164 | audit: Vec<accounts::AuditRow>, |
|
| 159 | 165 | repos: usize, |
|
| 160 | 166 | notice: Option<String>, |
|
| … | 56 unchanged lines |
||
| 217 | 223 | let orgs = accounts::orgs(&app.db).await.map_err(e)?; |
|
| 218 | 224 | let mode = super::session::registration_mode(app).await; |
|
| 219 | 225 | let invitations = accounts::invitations(&app.db).await.map_err(e)?; |
|
| 226 | + | let site_bookmark = |
|
| 227 | + | crate::repo_setup::bookmark_setting(&app.db, crate::repo_setup::BookmarkScope::Site).await.map_err(e)?.unwrap_or_default(); |
|
| 220 | 228 | let mut audit = accounts::audit_page(&app.db, audit_before, AUDIT_PAGE + 1).await.map_err(e)?; |
|
| 221 | 229 | let audit_older = if audit.len() as i64 > AUDIT_PAGE { |
|
| 222 | 230 | audit.truncate(AUDIT_PAGE as usize); |
|
| … | 28 unchanged lines |
||
| 251 | 259 | accounts: accounts_list, |
|
| 252 | 260 | orgs, |
|
| 253 | 261 | mode, |
|
| 262 | + | site_bookmark, |
|
| 254 | 263 | invitations, |
|
| 255 | 264 | audit, |
|
| 256 | 265 | repos, |
|
| … | 176 unchanged lines |
||
| 433 | 442 | admin_page(&app, shell, AdminSection::Registration, Some(format!("Registration is {}.", f.mode))).await |
|
| 434 | 443 | } |
|
| 435 | 444 | ||
| 445 | + | #[derive(Deserialize)] |
|
| 446 | + | pub struct BookmarkForm { |
|
| 447 | + | #[serde(rename = "_csrf")] |
|
| 448 | + | csrf_token: String, |
|
| 449 | + | #[serde(default)] |
|
| 450 | + | default_bookmark: String, |
|
| 451 | + | } |
|
| 452 | + | ||
| 453 | + | /// `POST /admin/repositories` (oxygenhub#371): the default bookmark of new |
|
| 454 | + | /// repositories whose owner has not chosen one; empty, `main`. |
|
| 455 | + | pub async fn set_bookmark(State(app): State<Shared>, viewer: Viewer, Form(f): Form<BookmarkForm>) -> Result<Page<AdminPage>, Error> { |
|
| 456 | + | let shell = Shell::new(&app, "/admin", &viewer); |
|
| 457 | + | let me = require_admin(&viewer, &shell)?; |
|
| 458 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 459 | + | let scope = crate::repo_setup::BookmarkScope::Site; |
|
| 460 | + | if let Err(m) = crate::repo_setup::set_bookmark_setting(&app.db, scope, &f.default_bookmark).await { |
|
| 461 | + | return Err(Error::bad(shell, &m)); |
|
| 462 | + | } |
|
| 463 | + | let b = f.default_bookmark.trim(); |
|
| 464 | + | accounts::audit(&app.db, Some(&me), "admin.default_bookmark", b, "").await.ok(); |
|
| 465 | + | let b = if b.is_empty() { crate::repo_setup::MAIN } else { b }; |
|
| 466 | + | admin_page( |
|
| 467 | + | &app, |
|
| 468 | + | shell, |
|
| 469 | + | AdminSection::NewRepositories, |
|
| 470 | + | Some(format!("New repositories start on {b} unless their owner chooses another.")), |
|
| 471 | + | ) |
|
| 472 | + | .await |
|
| 473 | + | } |
|
| 474 | + | ||
| 436 | 475 | pub async fn invite(State(app): State<Shared>, viewer: Viewer, Form(f): Form<super::session::CsrfForm>) -> Result<Page<AdminPage>, Error> { |
|
| 437 | 476 | let shell = Shell::new(&app, "/admin", &viewer); |
|
| 438 | 477 | let me = require_admin(&viewer, &shell)?; |
|
| … | 289 unchanged lines |
modified crates/hub/src/web/mod.rs +2 −0
| … | 117 unchanged lines |
||
| 118 | 118 | .route("/settings", get(settings::profile).post(settings::update_profile)) |
|
| 119 | 119 | .route("/settings/password", post(settings::change_password)) |
|
| 120 | 120 | .route("/settings/appearance", post(settings::appearance)) |
|
| 121 | + | .route("/settings/repositories", post(settings::repositories)) |
|
| 121 | 122 | .route("/settings/security", get(settings::security)) |
|
| 122 | 123 | .route("/settings/security/totp/begin", post(settings::totp_begin)) |
|
| 123 | 124 | .route("/settings/security/totp/enable", post(settings::totp_enable)) |
|
| … | 47 unchanged lines |
||
| 171 | 172 | .route("/admin/organisations", get(admin::section)) |
|
| 172 | 173 | .route("/admin/audit", get(admin::section)) |
|
| 173 | 174 | .route("/admin/registration", get(admin::section).post(admin::set_mode)) |
|
| 175 | + | .route("/admin/repositories", get(admin::section).post(admin::set_bookmark)) |
|
| 174 | 176 | .route("/admin/invite", post(admin::invite)) |
|
| 175 | 177 | .route("/admin/account", post(admin::account)) |
|
| 176 | 178 | .route("/admin/storage", get(admin::storage).post(admin::maintain)) |
|
| … | 802 unchanged lines |
modified crates/hub/src/web/orgs.rs +23 −0
| … | 79 unchanged lines |
||
| 80 | 80 | secrets: super::secrets::Panel, |
|
| 81 | 81 | /// Where its new repositories may live (#96). |
|
| 82 | 82 | policy: crate::storage::Policy, |
|
| 83 | + | /// Its new repositories' default bookmark (#371), and the site's. |
|
| 84 | + | default_bookmark: String, |
|
| 85 | + | site_bookmark: String, |
|
| 83 | 86 | } |
|
| 84 | 87 | ||
| 85 | 88 | async fn org_page( |
|
| … | 28 unchanged lines |
||
| 114 | 117 | let variables = super::variables::panel(app, &org_assistants(&org)).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 115 | 118 | let secrets = super::secrets::panel(app, &org_assistants(&org)).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 116 | 119 | let policy = crate::storage::policy(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 120 | + | let default_bookmark = crate::repo_setup::bookmark_setting(&app.db, crate::repo_setup::BookmarkScope::Org(org.id)) |
|
| 121 | + | .await |
|
| 122 | + | .ok() |
|
| 123 | + | .flatten() |
|
| 124 | + | .unwrap_or_default(); |
|
| 125 | + | let site_bookmark = crate::repo_setup::bookmark_setting(&app.db, crate::repo_setup::BookmarkScope::Site) |
|
| 126 | + | .await |
|
| 127 | + | .ok() |
|
| 128 | + | .flatten() |
|
| 129 | + | .unwrap_or_else(|| crate::repo_setup::MAIN.into()); |
|
| 117 | 130 | Ok(Page(OrgSettingsPage { |
|
| 131 | + | default_bookmark, |
|
| 132 | + | site_bookmark, |
|
| 118 | 133 | policy, |
|
| 119 | 134 | shell, |
|
| 120 | 135 | org, |
|
| … | 41 unchanged lines |
||
| 162 | 177 | display_name: String, |
|
| 163 | 178 | description: String, |
|
| 164 | 179 | default_role: String, |
|
| 180 | + | /// Its new repositories' default bookmark (#371); empty, the site's. |
|
| 181 | + | #[serde(default)] |
|
| 182 | + | default_bookmark: String, |
|
| 165 | 183 | } |
|
| 166 | 184 | ||
| 167 | 185 | pub async fn update( |
|
| … | 6 unchanged lines |
||
| 174 | 192 | let (me, org) = owned_org(&app, &shell, &viewer, &name).await?; |
|
| 175 | 193 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 176 | 194 | let role = if f.default_role == "none" || roles::Role::parse(&f.default_role).is_some() { f.default_role.as_str() } else { "read" }; |
|
| 195 | + | if let Err(m) = |
|
| 196 | + | crate::repo_setup::set_bookmark_setting(&app.db, crate::repo_setup::BookmarkScope::Org(org.id), &f.default_bookmark).await |
|
| 197 | + | { |
|
| 198 | + | return org_page(&app, shell, org, None, Some(m)).await; |
|
| 199 | + | } |
|
| 177 | 200 | accounts::set_org(&app.db, org.id, f.display_name.trim(), f.description.trim(), role) |
|
| 178 | 201 | .await |
|
| 179 | 202 | .map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| … | 429 unchanged lines |
modified crates/hub/src/web/repo_settings.rs +59 −2
| … | 26 unchanged lines |
||
| 27 | 27 | licences: &'static [(&'static str, &'static str)], |
|
| 28 | 28 | ignores: &'static [&'static str], |
|
| 29 | 29 | base_url: String, |
|
| 30 | + | /// Whether the viewer may put a repository on the site's runners (#371). |
|
| 31 | + | admin: bool, |
|
| 32 | + | /// Owners with runners of their own, for "on its owner's runners". |
|
| 33 | + | runner_owners: Vec<String>, |
|
| 34 | + | /// Each owner's default bookmark and where it comes from (#371). |
|
| 35 | + | bookmarks: Vec<(String, String, String)>, |
|
| 30 | 36 | } |
|
| 31 | 37 | ||
| 32 | 38 | /// The New repository page's choices for `me` (#360). |
|
| … | 1 unchanged lines |
||
| 34 | 40 | let owners = owners_for(app, me).await; |
|
| 35 | 41 | let mut boards = Vec::new(); |
|
| 36 | 42 | let mut orgs = Vec::new(); |
|
| 43 | + | let mut runner_owners = Vec::new(); |
|
| 44 | + | let mut bookmarks = Vec::new(); |
|
| 37 | 45 | for o in &owners { |
|
| 46 | + | let (b, from) = crate::repo_setup::default_bookmark_for(app, o).await; |
|
| 47 | + | bookmarks.push((o.clone(), b, from)); |
|
| 48 | + | if crate::repo_setup::owner_has_runner(app, o).await.unwrap_or(false) { |
|
| 49 | + | runner_owners.push(o.clone()); |
|
| 50 | + | } |
|
| 38 | 51 | if accounts::org_by_name(&app.db, o).await.ok().flatten().is_some() { |
|
| 39 | 52 | orgs.push(o.clone()); |
|
| 40 | 53 | } |
|
| … | 13 unchanged lines |
||
| 54 | 67 | licences: crate::repo_setup::LICENCES, |
|
| 55 | 68 | ignores: crate::repo_setup::IGNORES, |
|
| 56 | 69 | base_url: app.config.base_url().trim_end_matches('/').to_string(), |
|
| 70 | + | admin: me.is_admin, |
|
| 71 | + | runner_owners, |
|
| 72 | + | bookmarks, |
|
| 57 | 73 | } |
|
| 58 | 74 | } |
|
| 59 | 75 | ||
| … | 70 unchanged lines |
||
| 130 | 146 | /// The existing board's number, with `board=existing`. |
|
| 131 | 147 | #[serde(default)] |
|
| 132 | 148 | board_n: String, |
|
| 149 | + | /// OxygenFlow (#371): `on`, `own`, or the site default. |
|
| 150 | + | #[serde(default)] |
|
| 151 | + | flow: String, |
|
| 152 | + | /// Start the wiki with a Home page (#371). |
|
| 153 | + | #[serde(default)] |
|
| 154 | + | wiki: Option<String>, |
|
| 155 | + | /// Its default bookmark; empty, the owner's or the site's default (#371). |
|
| 156 | + | #[serde(default)] |
|
| 157 | + | default_bookmark: String, |
|
| 133 | 158 | } |
|
| 134 | 159 | ||
| 135 | 160 | /// A bucket and the credentials for it, as the New repository form and |
|
| … | 52 unchanged lines |
||
| 188 | 213 | Err(m) => return Ok(fail(&m)), |
|
| 189 | 214 | }; |
|
| 190 | 215 | if let Err(m) = crate::repo_setup::check_board(&app, &me.account, &name.owner, &board).await { |
|
| 216 | + | return Ok(fail(&m)); |
|
| 217 | + | } |
|
| 218 | + | let flow = match crate::repo_setup::Flow::parse(&f.flow) { |
|
| 219 | + | Ok(flow) => flow, |
|
| 220 | + | Err(m) => return Ok(fail(&m)), |
|
| 221 | + | }; |
|
| 222 | + | if let Err(m) = crate::repo_setup::check_flow(&app, &me.account, &name.owner, flow).await { |
|
| 191 | 223 | return Ok(fail(&m)); |
|
| 224 | + | } |
|
| 225 | + | let wiki = f.wiki.is_some(); |
|
| 226 | + | if !f.default_bookmark.trim().is_empty() { |
|
| 227 | + | if let Err(m) = crate::repo_setup::check_bookmark(f.default_bookmark.trim()) { |
|
| 228 | + | return Ok(fail(&m)); |
|
| 229 | + | } |
|
| 192 | 230 | } |
|
| 193 | 231 | let start = |
|
| 194 | 232 | crate::repo_setup::Start { readme: f.readme.is_some(), licence: f.licence.trim().to_string(), ignore: f.ignore.trim().to_string() }; |
|
| … | 6 unchanged lines |
||
| 201 | 239 | if !files.is_empty() && f.storage == "bucket" { |
|
| 202 | 240 | return Ok(fail("A repository kept in your own bucket starts empty: push its first files to it.")); |
|
| 203 | 241 | } |
|
| 204 | - | if !files.is_empty() { |
|
| 242 | + | if !files.is_empty() || wiki { |
|
| 205 | 243 | if let Err(m) = crate::web::edit::signer_for(&app, me.account.id).await { |
|
| 206 | - | return Ok(fail(&format!("The hub cannot write the starting files for you yet. {m}"))); |
|
| 244 | + | return Ok(fail(&format!("The hub cannot write the starting files or the wiki for you yet. {m}"))); |
|
| 207 | 245 | } |
|
| 208 | 246 | } |
|
| 209 | 247 | // An organisation's storage policy (oxygenhub#96). |
|
| … | 19 unchanged lines |
||
| 229 | 267 | let row = db::repos::create(&app.db, &name.owner, &name.name, f.description.trim(), visibility) |
|
| 230 | 268 | .await |
|
| 231 | 269 | .map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 270 | + | let row = |
|
| 271 | + | crate::repo_setup::set_default_bookmark(&app, row, &f.default_bookmark).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 232 | 272 | if let Err(e) = crate::storage::create(&app, &name, row.id, url, credentials).await { |
|
| 233 | 273 | db::repos::delete(&app.db, row.id).await.ok(); |
|
| 234 | 274 | return Ok(fail(&format!("The bucket did not pass the check, so nothing was created: {e}"))); |
|
| … | 10 unchanged lines |
||
| 245 | 285 | crate::repo_setup::link_board(&app, &row, &board, visibility == Visibility::Public) |
|
| 246 | 286 | .await |
|
| 247 | 287 | .map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 288 | + | crate::repo_setup::set_flow(&app, &row, &me.account, flow).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 289 | + | if wiki { |
|
| 290 | + | if let Err(e) = crate::repo_setup::start_wiki(&app, &row, &me.account, &f.description).await { |
|
| 291 | + | tracing::warn!("wiki for {name}: {e}"); |
|
| 292 | + | } |
|
| 293 | + | } |
|
| 248 | 294 | return Ok(Redirect::to(&name.url()).into_response()); |
|
| 249 | 295 | } |
|
| 250 | 296 | app.repos.create_async(&name, None).await.map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 251 | 297 | let row = db::repos::create(&app.db, &name.owner, &name.name, f.description.trim(), visibility) |
|
| 252 | 298 | .await |
|
| 253 | 299 | .map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 300 | + | let row = |
|
| 301 | + | crate::repo_setup::set_default_bookmark(&app, row, &f.default_bookmark).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 254 | 302 | accounts::audit(&app.db, Some(&me.account), "repo.created", &name.to_string(), visibility.as_str()).await.ok(); |
|
| 255 | 303 | crate::repo_setup::link_board(&app, &row, &board, visibility == Visibility::Public) |
|
| 256 | 304 | .await |
|
| 257 | 305 | .map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 306 | + | crate::repo_setup::set_flow(&app, &row, &me.account, flow).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 258 | 307 | if let Err(e) = crate::repo_setup::commit_start(&app, &row, &me.account, files).await { |
|
| 259 | 308 | tracing::warn!("starting files for {name}: {e}"); |
|
| 309 | + | } |
|
| 310 | + | if wiki { |
|
| 311 | + | if let Err(e) = crate::repo_setup::start_wiki(&app, &row, &me.account, &f.description).await { |
|
| 312 | + | tracing::warn!("wiki for {name}: {e}"); |
|
| 313 | + | } |
|
| 260 | 314 | } |
|
| 261 | 315 | Ok(Redirect::to(&name.url()).into_response()) |
|
| 262 | 316 | } |
|
| … | 8 unchanged lines |
||
| 271 | 325 | licences: p.licences, |
|
| 272 | 326 | ignores: p.ignores, |
|
| 273 | 327 | base_url: p.base_url.clone(), |
|
| 328 | + | admin: p.admin, |
|
| 329 | + | runner_owners: p.runner_owners.clone(), |
|
| 330 | + | bookmarks: p.bookmarks.clone(), |
|
| 274 | 331 | } |
|
| 275 | 332 | } |
|
| 276 | 333 | ||
| … | 1503 unchanged lines |
modified crates/hub/src/web/settings.rs +58 −11
| … | 50 unchanged lines |
||
| 51 | 51 | backdrops: &'static [(&'static str, &'static str)], |
|
| 52 | 52 | /// Whether the hub sends mail, for the "send a new link" button. |
|
| 53 | 53 | mail: bool, |
|
| 54 | + | /// The default bookmark of the repositories they make (#371), and the |
|
| 55 | + | /// one they get when that is empty. |
|
| 56 | + | default_bookmark: String, |
|
| 57 | + | site_bookmark: String, |
|
| 54 | 58 | } |
|
| 55 | 59 | ||
| 56 | 60 | /// The themes (hub spec §6.1): the cookie value and its label. |
|
| … | 26 unchanged lines |
||
| 83 | 87 | backdrop: backdrop_of(headers), |
|
| 84 | 88 | backdrops: BACKDROPS, |
|
| 85 | 89 | mail: false, |
|
| 90 | + | default_bookmark: String::new(), |
|
| 91 | + | site_bookmark: crate::repo_setup::MAIN.into(), |
|
| 86 | 92 | } |
|
| 87 | 93 | } |
|
| 88 | 94 | ||
| 89 | - | fn with_mail(mut self, app: &Shared) -> Self { |
|
| 95 | + | /// What the page reads from the hub: whether it sends mail, and the |
|
| 96 | + | /// default bookmarks (#371). |
|
| 97 | + | async fn with_hub(mut self, app: &Shared) -> Self { |
|
| 98 | + | use crate::repo_setup::{bookmark_setting, BookmarkScope}; |
|
| 90 | 99 | self.mail = crate::mail::enabled(app); |
|
| 100 | + | self.default_bookmark = bookmark_setting(&app.db, BookmarkScope::Account(self.me.id)).await.ok().flatten().unwrap_or_default(); |
|
| 101 | + | if let Ok(Some(b)) = bookmark_setting(&app.db, BookmarkScope::Site).await { |
|
| 102 | + | self.site_bookmark = b; |
|
| 103 | + | } |
|
| 91 | 104 | self |
|
| 92 | 105 | } |
|
| 93 | 106 | ||
| … | 11 unchanged lines |
||
| 105 | 118 | pub async fn profile(State(app): State<Shared>, viewer: Viewer, headers: HeaderMap) -> Result<Page<SettingsPage>, Error> { |
|
| 106 | 119 | let shell = Shell::new(&app, "/settings", &viewer); |
|
| 107 | 120 | let me = require(&viewer, &shell)?; |
|
| 108 | - | Ok(Page(SettingsPage::new(shell, me.account, &headers).with_mail(&app))) |
|
| 121 | + | Ok(Page(SettingsPage::new(shell, me.account, &headers).with_hub(&app).await)) |
|
| 109 | 122 | } |
|
| 110 | 123 | ||
| 111 | 124 | /// `POST /settings/avatar`: upload or remove the profile picture. |
|
| … | 12 unchanged lines |
||
| 124 | 137 | accounts::audit(&app.db, Some(&me.account), "profile.picture", &me.account.username, "").await.ok(); |
|
| 125 | 138 | } |
|
| 126 | 139 | let fresh = accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account); |
|
| 127 | - | let page = SettingsPage::new(shell, fresh, &headers).with_mail(&app); |
|
| 140 | + | let page = SettingsPage::new(shell, fresh, &headers).with_hub(&app).await; |
|
| 128 | 141 | Ok(Page(match result { |
|
| 129 | 142 | Ok(m) => page.notice(m), |
|
| 130 | 143 | Err(e) => page.error(e), |
|
| … | 10 unchanged lines |
||
| 141 | 154 | let shell = Shell::new(&app, "/settings", &viewer); |
|
| 142 | 155 | let me = require(&viewer, &shell)?; |
|
| 143 | 156 | csrf(&viewer, &f.csrf, &shell)?; |
|
| 144 | - | let page = SettingsPage::new(shell, me.account.clone(), &headers).with_mail(&app); |
|
| 157 | + | let page = SettingsPage::new(shell, me.account.clone(), &headers).with_hub(&app).await; |
|
| 145 | 158 | Ok(Page(match super::recovery::send_verification(&app, &me.account).await { |
|
| 146 | 159 | Ok(()) if me.account.email_verified => page.notice("Your email is already verified."), |
|
| 147 | 160 | Ok(()) => page.notice(format!("A verification link is on its way to {}.", me.account.email)), |
|
| … | 67 unchanged lines |
||
| 215 | 228 | let website = f.website.trim(); |
|
| 216 | 229 | if !(website.is_empty() || website.starts_with("https://") || website.starts_with("http://")) || website.len() > 200 { |
|
| 217 | 230 | return Ok(Page( |
|
| 218 | - | SettingsPage::new(shell, me.account, &headers).with_mail(&app).error("The website must be an http:// or https:// address."), |
|
| 231 | + | SettingsPage::new(shell, me.account, &headers) |
|
| 232 | + | .with_hub(&app) |
|
| 233 | + | .await |
|
| 234 | + | .error("The website must be an http:// or https:// address."), |
|
| 219 | 235 | )); |
|
| 220 | 236 | } |
|
| 221 | 237 | // Accounts elsewhere: checked and normalised, or refused by name. |
|
| … | 13 unchanged lines |
||
| 235 | 251 | })(); |
|
| 236 | 252 | let (bluesky, mastodon, github) = match links { |
|
| 237 | 253 | Ok(l) => l, |
|
| 238 | - | Err(msg) => return Ok(Page(SettingsPage::new(shell, me.account, &headers).with_mail(&app).error(&msg))), |
|
| 254 | + | Err(msg) => return Ok(Page(SettingsPage::new(shell, me.account, &headers).with_hub(&app).await.error(&msg))), |
|
| 239 | 255 | }; |
|
| 240 | 256 | let bio: String = f.bio.trim().chars().take(280).collect(); |
|
| 241 | 257 | let location: String = f.location.trim().chars().take(80).collect(); |
|
| … | 13 unchanged lines |
||
| 255 | 271 | Err(e) => notice = format!("Profile saved. {e}"), |
|
| 256 | 272 | } |
|
| 257 | 273 | } |
|
| 258 | - | Ok(Page(SettingsPage::new(shell, updated, &headers).with_mail(&app).notice(notice))) |
|
| 274 | + | Ok(Page(SettingsPage::new(shell, updated, &headers).with_hub(&app).await.notice(notice))) |
|
| 259 | 275 | } |
|
| 260 | 276 | ||
| 261 | 277 | #[derive(Deserialize)] |
|
| … | 2 unchanged lines |
||
| 264 | 280 | csrf_token: String, |
|
| 265 | 281 | current: String, |
|
| 266 | 282 | password: String, |
|
| 283 | + | } |
|
| 284 | + | ||
| 285 | + | #[derive(Deserialize)] |
|
| 286 | + | pub struct BookmarkForm { |
|
| 287 | + | #[serde(rename = "_csrf")] |
|
| 288 | + | csrf_token: String, |
|
| 289 | + | #[serde(default)] |
|
| 290 | + | default_bookmark: String, |
|
| 291 | + | } |
|
| 292 | + | ||
| 293 | + | /// `POST /settings/repositories` (oxygenhub#371): the default bookmark of |
|
| 294 | + | /// the repositories they make; empty, the site's. |
|
| 295 | + | pub async fn repositories( |
|
| 296 | + | State(app): State<Shared>, |
|
| 297 | + | viewer: Viewer, |
|
| 298 | + | headers: HeaderMap, |
|
| 299 | + | Form(f): Form<BookmarkForm>, |
|
| 300 | + | ) -> Result<Page<SettingsPage>, Error> { |
|
| 301 | + | let shell = Shell::new(&app, "/settings", &viewer); |
|
| 302 | + | let me = require(&viewer, &shell)?; |
|
| 303 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 304 | + | let scope = crate::repo_setup::BookmarkScope::Account(me.account.id); |
|
| 305 | + | let page = match crate::repo_setup::set_bookmark_setting(&app.db, scope, &f.default_bookmark).await { |
|
| 306 | + | Ok(()) => { |
|
| 307 | + | accounts::audit(&app.db, Some(&me.account), "account.default_bookmark", &me.account.username, f.default_bookmark.trim()) |
|
| 308 | + | .await |
|
| 309 | + | .ok(); |
|
| 310 | + | SettingsPage::new(shell, me.account, &headers).with_hub(&app).await.notice("Saved: new repositories start with that bookmark.") |
|
| 311 | + | } |
|
| 312 | + | Err(m) => SettingsPage::new(shell, me.account, &headers).with_hub(&app).await.error(&m), |
|
| 313 | + | }; |
|
| 314 | + | Ok(Page(page)) |
|
| 267 | 315 | } |
|
| 268 | 316 | ||
| 269 | 317 | pub async fn change_password( |
|
| … | 6 unchanged lines |
||
| 276 | 324 | let me = require(&viewer, &shell)?; |
|
| 277 | 325 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 278 | 326 | if !auth::verify_password(&me.account.password_hash, &f.current) { |
|
| 279 | - | return Ok( |
|
| 280 | - | Page(SettingsPage::new(shell, me.account, &headers).with_mail(&app).error("Your current password is wrong.")).into_response() |
|
| 281 | - | ); |
|
| 327 | + | return Ok(Page(SettingsPage::new(shell, me.account, &headers).with_hub(&app).await.error("Your current password is wrong.")) |
|
| 328 | + | .into_response()); |
|
| 282 | 329 | } |
|
| 283 | 330 | if let Err(e) = auth::password_ok(&f.password) { |
|
| 284 | - | return Ok(Page(SettingsPage::new(shell, me.account, &headers).with_mail(&app).error(e)).into_response()); |
|
| 331 | + | return Ok(Page(SettingsPage::new(shell, me.account, &headers).with_hub(&app).await.error(e)).into_response()); |
|
| 285 | 332 | } |
|
| 286 | 333 | let hash = auth::hash_password(&f.password).map_err(|e| Error::bad(shell.clone(), e.to_string()))?; |
|
| 287 | 334 | // A new session salt signs out every other session; this one is renewed. |
|
| … | 576 unchanged lines |
modified crates/hub/tests/api.rs +46 −3
| … | 914 unchanged lines |
||
| 915 | 915 | r.body.split("oxyhub_").nth(1).map(|s| format!("oxyhub_{}", s.split('<').next().unwrap().trim())).unwrap() |
|
| 916 | 916 | } |
|
| 917 | 917 | }; |
|
| 918 | - | let a = mint(andy, "scopes=repo%3Aadmin").await; |
|
| 919 | - | let b = mint(bob, "scopes=repo%3Aread&scopes=repo%3Awrite").await; |
|
| 918 | + | let a = mint(andy.clone(), "scopes=repo%3Aadmin").await; |
|
| 919 | + | let b = mint(bob.clone(), "scopes=repo%3Aread&scopes=repo%3Awrite").await; |
|
| 920 | 920 | api(app, "POST", "/api/v1/repos", Some(&a), Some(r#"{"owner":"andy","name":"demo","visibility":"public"}"#)).await; |
|
| 921 | 921 | api(app, "POST", "/api/v1/repos/andy/demo/issues", Some(&a), Some(r#"{"title":"Crash"}"#)).await; |
|
| 922 | 922 | ||
| … | 242 unchanged lines |
||
| 1165 | 1165 | let (st, _) = api(app, "POST", "/api/v1/repos", Some(&a), Some(r#"{"owner":"andy","name":"nowhere","project":{"number":99}}"#)).await; |
|
| 1166 | 1166 | assert_eq!(st, StatusCode::UNPROCESSABLE_ENTITY, "no such board"); |
|
| 1167 | 1167 | assert_eq!(api(app, "GET", "/api/v1/repos/andy/nowhere", Some(&a), None).await.0, StatusCode::NOT_FOUND); |
|
| 1168 | + | ||
| 1169 | + | // OxygenFlow, the wiki and the default bookmark with it (#371). |
|
| 1170 | + | let a = a.as_str(); |
|
| 1171 | + | let made = |name: &'static str| async move { api(app, "GET", &format!("/api/v1/repos/andy/{name}"), Some(a), None).await.0 }; |
|
| 1172 | + | let bob_admin = mint(bob.clone(), "scopes=repo%3Aadmin").await; |
|
| 1173 | + | let (st, v) = api(app, "POST", "/api/v1/repos", Some(&bob_admin), Some(r#"{"owner":"bob","name":"site","flow":"on"}"#)).await; |
|
| 1174 | + | assert_eq!(st, StatusCode::FORBIDDEN, "only site administrators use the site's runners: {v}"); |
|
| 1175 | + | let (st, v) = api(app, "POST", "/api/v1/repos", Some(a), Some(r#"{"owner":"andy","name":"ownflow","flow":"own"}"#)).await; |
|
| 1176 | + | assert_eq!(st, StatusCode::FORBIDDEN, "andy has no runners of his own: {v}"); |
|
| 1177 | + | assert_eq!(made("ownflow").await, StatusCode::NOT_FOUND, "nothing made"); |
|
| 1178 | + | let (st, _) = api(app, "POST", "/api/v1/repos", Some(a), Some(r#"{"owner":"andy","name":"x","flow":"always"}"#)).await; |
|
| 1179 | + | assert_eq!(st, StatusCode::BAD_REQUEST); |
|
| 1180 | + | let (st, v) = api(app, "POST", "/api/v1/repos", Some(a), Some(r#"{"owner":"andy","name":"flowing","flow":"on"}"#)).await; |
|
| 1181 | + | assert_eq!(st, StatusCode::CREATED, "{v}"); |
|
| 1182 | + | let flow: String = sqlx::query_scalar("SELECT flow FROM repositories WHERE name = 'flowing'").fetch_one(&app.db).await.unwrap(); |
|
| 1183 | + | assert_eq!(flow, "on"); |
|
| 1184 | + | let (st, v) = api(app, "POST", "/api/v1/repos", Some(a), Some(r#"{"owner":"andy","name":"wikied","wiki":true}"#)).await; |
|
| 1185 | + | assert_eq!(st, StatusCode::CONFLICT, "the wiki needs the hub as the caller's delegate: {v}"); |
|
| 1186 | + | assert_eq!(made("wikied").await, StatusCode::NOT_FOUND, "nothing made"); |
|
| 1187 | + | // The bookmark: asked for, else the owner's, else the site's, else main. |
|
| 1188 | + | let bookmark = |name: &'static str| async move { |
|
| 1189 | + | api(app, "GET", &format!("/api/v1/repos/andy/{name}"), Some(a), None).await.1["default_bookmark"].as_str().unwrap().to_string() |
|
| 1190 | + | }; |
|
| 1191 | + | assert_eq!(bookmark("flowing").await, "main"); |
|
| 1192 | + | let (st, _) = api(app, "POST", "/api/v1/repos", Some(a), Some(r#"{"owner":"andy","name":"y","default_bookmark":"a/b"}"#)).await; |
|
| 1193 | + | assert_eq!(st, StatusCode::BAD_REQUEST); |
|
| 1194 | + | let page = call(app, "GET", "/admin/repositories", None, Some(&andy), None, None).await; |
|
| 1195 | + | let token = csrf_of(&page.body); |
|
| 1196 | + | let r = call(app, "POST", "/admin/repositories", None, Some(&andy), None, Some(&format!("_csrf={token}&default_bookmark=trunk"))).await; |
|
| 1197 | + | assert!(r.body.contains("start on trunk"), "{}", r.body); |
|
| 1198 | + | api(app, "POST", "/api/v1/repos", Some(a), Some(r#"{"owner":"andy","name":"sited"}"#)).await; |
|
| 1199 | + | assert_eq!(bookmark("sited").await, "trunk"); |
|
| 1200 | + | let page = call(app, "GET", "/settings", None, Some(&andy), None, None).await; |
|
| 1201 | + | assert!(page.body.contains(r#"placeholder="trunk""#), "the site's default shown"); |
|
| 1202 | + | let token = csrf_of(&page.body); |
|
| 1203 | + | call(app, "POST", "/settings/repositories", None, Some(&andy), None, Some(&format!("_csrf={token}&default_bookmark=develop"))).await; |
|
| 1204 | + | api(app, "POST", "/api/v1/repos", Some(a), Some(r#"{"owner":"andy","name":"mine"}"#)).await; |
|
| 1205 | + | assert_eq!(bookmark("mine").await, "develop"); |
|
| 1206 | + | api(app, "POST", "/api/v1/repos", Some(a), Some(r#"{"owner":"andy","name":"asked","default_bookmark":"next"}"#)).await; |
|
| 1207 | + | assert_eq!(bookmark("asked").await, "next"); |
|
| 1208 | + | let page = call(app, "GET", "/new", None, Some(&andy), None, None).await; |
|
| 1209 | + | assert!(page.body.contains(r#"data-bookmark="develop""#), "{}", page.body); |
|
| 1210 | + | assert!(page.body.contains("<h2>OxygenFlow</h2>") && page.body.contains("<h2>Wiki</h2>")); |
|
| 1168 | 1211 | } |
|
| 1169 | 1212 | ||
| 1170 | 1213 | #[tokio::test(flavor = "multi_thread")] |
|
| … | 100 unchanged lines |
||
| 1271 | 1314 | let again = |
|
| 1272 | 1315 | call(app, "POST", "/new", None, Some(&andy), None, Some(&format!("_csrf={}&owner=andy&name=other&readme=1", csrf_of(&page.body)))) |
|
| 1273 | 1316 | .await; |
|
| 1274 | - | assert!(again.body.contains("cannot write the starting files for you yet"), "no delegate: refused, nothing made"); |
|
| 1317 | + | assert!(again.body.contains("cannot write the starting files or the wiki for you yet"), "no delegate: refused, nothing made"); |
|
| 1275 | 1318 | assert!(oxygenhub::db::repos::get(&app.db, "andy", "other").await.unwrap().is_none()); |
|
| 1276 | 1319 | let row = || async { oxygenhub::db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap() }; |
|
| 1277 | 1320 | assert!(!oxygenhub::flow::enabled(app, &row().await), "not in [flow] repos"); |
|
| … | 672 unchanged lines |
modified crates/oh/src/main.rs +17 −1
| … | 312 unchanged lines |
||
| 313 | 313 | /// Start it with an .oxyignore for a language: Rust, Node, Python, Go, "Java and Kotlin" |
|
| 314 | 314 | #[arg(long)] |
|
| 315 | 315 | ignore: Option<String>, |
|
| 316 | + | /// Switch OxygenFlow on: `on` (the site's runners; site administrators) or `own` (the owner's runners) |
|
| 317 | + | #[arg(long)] |
|
| 318 | + | flow: Option<String>, |
|
| 319 | + | /// Start its wiki with a Home page |
|
| 320 | + | #[arg(long)] |
|
| 321 | + | wiki: bool, |
|
| 322 | + | /// Its default bookmark (default: the owner's, else the site's, else main) |
|
| 323 | + | #[arg(long, short = 'B')] |
|
| 324 | + | bookmark: Option<String>, |
|
| 316 | 325 | }, |
|
| 317 | 326 | } |
|
| 318 | 327 | ||
| … | 713 unchanged lines |
||
| 1032 | 1041 | println!("{text}"); |
|
| 1033 | 1042 | Ok(0) |
|
| 1034 | 1043 | } |
|
| 1035 | - | Cmd::Repo { cmd: RepoCmd::Create { slug, description, visibility, project, readme, licence, ignore } } => { |
|
| 1044 | + | Cmd::Repo { cmd: RepoCmd::Create { slug, description, visibility, project, readme, licence, ignore, flow, wiki, bookmark } } => { |
|
| 1036 | 1045 | let (owner, name) = slug.split_once('/').context("give the repository as OWNER/NAME")?; |
|
| 1037 | 1046 | let project = match project.as_deref() { |
|
| 1038 | 1047 | None => None, |
|
| … | 5 unchanged lines |
||
| 1044 | 1053 | }; |
|
| 1045 | 1054 | let mut body = serde_json::json!({ |
|
| 1046 | 1055 | "owner": owner, "name": name, "description": description.unwrap_or_default(), "visibility": visibility, "readme": readme, |
|
| 1056 | + | "wiki": wiki, |
|
| 1047 | 1057 | }); |
|
| 1058 | + | if let Some(f) = flow { |
|
| 1059 | + | body["flow"] = serde_json::json!(f); |
|
| 1060 | + | } |
|
| 1061 | + | if let Some(b) = bookmark { |
|
| 1062 | + | body["default_bookmark"] = serde_json::json!(b); |
|
| 1063 | + | } |
|
| 1048 | 1064 | if let Some(p) = project { |
|
| 1049 | 1065 | body["project"] = p; |
|
| 1050 | 1066 | } |
|
| … | 1946 unchanged lines |
modified templates/admin.html +11 −0
| … | 59 unchanged lines |
||
| 60 | 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 | 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 | 62 | </section> |
|
| 63 | + | {% when crate::web::admin::AdminSection::NewRepositories %} |
|
| 64 | + | <section class="panel" id="repositories"> |
|
| 65 | + | <h2>New repositories</h2> |
|
| 66 | + | <form class="card" method="post" action="/admin/repositories"> |
|
| 67 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 68 | + | <label for="sitebookmark">Default bookmark</label> |
|
| 69 | + | <input type="text" id="sitebookmark" name="default_bookmark" value="{{ site_bookmark }}" placeholder="main" pattern="[A-Za-z0-9][A-Za-z0-9._-]{0,63}" autocomplete="off" spellcheck="false"> |
|
| 70 | + | <button type="submit">SAVE</button> |
|
| 71 | + | </form> |
|
| 72 | + | <p class="small mute">The bookmark a new repository starts with, such as <code>develop</code>. A user or an organisation may choose their own in their settings, and each repository on the New repository page. Left empty: <code>main</code>.</p> |
|
| 73 | + | </section> |
|
| 63 | 74 | {% when crate::web::admin::AdminSection::Organisations %} |
|
| 64 | 75 | <section class="panel" id="organisations"> |
|
| 65 | 76 | <h2>Organisations</h2> |
|
| … | 75 unchanged lines |
modified templates/org_settings.html +3 −0
| … | 29 unchanged lines |
||
| 30 | 30 | <label for="description">Description</label><input type="text" id="description" name="description" value="{{ org.description }}"> |
|
| 31 | 31 | <label for="default_role">Members' default role on the organisation's repositories</label> |
|
| 32 | 32 | <select id="default_role" name="default_role">{% for r in ["none", "read", "triage", "write", "maintain", "admin"] %}<option value="{{ r }}"{% if org.default_role.as_str() == *r %} selected{% endif %}>{{ r }}</option>{% endfor %}</select> |
|
| 33 | + | <label for="default_bookmark">New repositories' default bookmark</label> |
|
| 34 | + | <input type="text" id="default_bookmark" name="default_bookmark" value="{{ default_bookmark }}" placeholder="{{ site_bookmark }}" pattern="[A-Za-z0-9][A-Za-z0-9._-]{0,63}" autocomplete="off" spellcheck="false"> |
|
| 35 | + | <p class="small mute">Such as <code>develop</code>. Left empty, they take this site's: <code>{{ site_bookmark }}</code>. Each repository can still choose its own.</p> |
|
| 33 | 36 | <button type="submit">SAVE</button> |
|
| 34 | 37 | </form> |
|
| 35 | 38 | <form class="card picture-card" id="logo" method="post" action="/orgs/{{ org.name }}/settings/avatar" enctype="multipart/form-data"> |
|
| … | 133 unchanged lines |
modified templates/repo_new.html +71 −37
| … | 2 unchanged lines |
||
| 3 | 3 | {% block content %} |
|
| 4 | 4 | <div class="head"><h1>New repository</h1></div> |
|
| 5 | 5 | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 6 | - | <form class="card new-repo" method="post" action="/new" data-new-repo> |
|
| 6 | + | <form class="card full new-repo" method="post" action="/new" data-new-repo> |
|
| 7 | 7 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 8 | - | <div class="row name-row"> |
|
| 9 | - | <div><label for="owner">Owner</label><select id="owner" name="owner" data-orgs="{% for o in orgs %}{{ o }} {% endfor %}">{% for o in owners %}<option value="{{ o }}">{{ o }}</option>{% endfor %}</select></div> |
|
| 10 | - | <div class="slash" aria-hidden="true">/</div> |
|
| 11 | - | <div class="grow"><label for="name">Name</label><input type="text" id="name" name="name" required pattern="[A-Za-z0-9][A-Za-z0-9._-]{0,63}" autocomplete="off" aria-describedby="name-check" autofocus></div> |
|
| 12 | - | </div> |
|
| 13 | - | <p id="name-check" class="small name-check" role="status" aria-live="polite" data-base="{{ base_url }}"></p> |
|
| 14 | - | <label for="description">Description <span class="small mute">(optional)</span></label><input type="text" id="description" name="description"> |
|
| 15 | 8 | ||
| 16 | - | <fieldset class="choices"> |
|
| 17 | - | <legend>Visibility</legend> |
|
| 18 | - | <label class="choice-card"><input type="radio" name="visibility" value="public" checked><span><strong>Public</strong><span class="small mute">Anyone can see and clone it. You choose who may push.</span></span></label> |
|
| 19 | - | <label class="choice-card"><input type="radio" name="visibility" value="internal"><span><strong>Internal</strong><span class="small mute">Anyone signed in to this hub can see it.</span></span></label> |
|
| 20 | - | <label class="choice-card"><input type="radio" name="visibility" value="private"><span><strong>Private</strong><span class="small mute">Only you{% if !orgs.is_empty() %}, the organisation's members{% endif %} and the people you add.</span></span></label> |
|
| 21 | - | </fieldset> |
|
| 9 | + | <section class="nr-sec"> |
|
| 10 | + | <div class="nr-head"><h2>Repository</h2><p class="small mute">Where it lives and what it is called.</p></div> |
|
| 11 | + | <div class="nr-body"> |
|
| 12 | + | <div class="row name-row"> |
|
| 13 | + | <div><label for="owner">Owner</label><select id="owner" name="owner" data-orgs="{% for o in orgs %}{{ o }} {% endfor %}" data-runners="{% for o in runner_owners %}{{ o }} {% endfor %}">{% for (o, b, from) in bookmarks %}<option value="{{ o }}" data-bookmark="{{ b }}" data-bookmark-from="{{ from }}">{{ o }}</option>{% endfor %}</select></div> |
|
| 14 | + | <div class="slash" aria-hidden="true">/</div> |
|
| 15 | + | <div class="grow"><label for="name">Name</label><input type="text" id="name" name="name" required pattern="[A-Za-z0-9][A-Za-z0-9._-]{0,63}" autocomplete="off" aria-describedby="name-check" autofocus></div> |
|
| 16 | + | </div> |
|
| 17 | + | <p id="name-check" class="small name-check" role="status" aria-live="polite" data-base="{{ base_url }}"></p> |
|
| 18 | + | <label for="description">Description <span class="small mute">(optional; the README and the wiki start with it)</span></label><input type="text" id="description" name="description"> |
|
| 19 | + | <label for="default_bookmark">Default bookmark</label><input type="text" id="default_bookmark" class="nr-bookmark" name="default_bookmark" pattern="[A-Za-z0-9][A-Za-z0-9._-]{0,63}" autocomplete="off" spellcheck="false" aria-describedby="bookmark-from" data-default-bookmark> |
|
| 20 | + | <p id="bookmark-from" class="small mute nr-hint" data-bookmark-hint></p> |
|
| 21 | + | </div> |
|
| 22 | + | </section> |
|
| 23 | + | ||
| 24 | + | <section class="nr-sec"> |
|
| 25 | + | <div class="nr-head"><h2>Visibility</h2><p class="small mute">Who can see and clone it. You can change it later in Settings.</p></div> |
|
| 26 | + | <div class="nr-body nr-cards three" role="radiogroup" aria-label="Visibility"> |
|
| 27 | + | <label class="choice-card"><input type="radio" name="visibility" value="public" checked><span><strong>Public</strong><span class="small mute">Anyone can see and clone it. You choose who may push.</span></span></label> |
|
| 28 | + | <label class="choice-card"><input type="radio" name="visibility" value="internal"><span><strong>Internal</strong><span class="small mute">Anyone signed in to this hub can see it.</span></span></label> |
|
| 29 | + | <label class="choice-card"><input type="radio" name="visibility" value="private"><span><strong>Private</strong><span class="small mute">Only you{% if !orgs.is_empty() %}, the organisation's members{% endif %} and the people you add.</span></span></label> |
|
| 30 | + | </div> |
|
| 31 | + | </section> |
|
| 32 | + | ||
| 33 | + | <section class="nr-sec"> |
|
| 34 | + | <div class="nr-head"><h2>Starting files</h2><p class="small mute">Optional. Left empty, it starts empty and takes its first push. The hub commits these as you, signed with its key as your delegate.</p></div> |
|
| 35 | + | <div class="nr-body"> |
|
| 36 | + | <label class="nr-toggle"><input type="checkbox" name="readme" value="1"><span><strong>README</strong><span class="small mute">Its name and description, as README.md.</span></span></label> |
|
| 37 | + | <div class="row nr-pair"> |
|
| 38 | + | <div><label for="licence">Licence</label><select id="licence" name="licence"><option value="">none</option>{% for (id, what) in licences %}<option value="{{ id }}">{{ id }}: {{ what }}</option>{% endfor %}</select></div> |
|
| 39 | + | <div><label for="ignore">.oxyignore</label><select id="ignore" name="ignore"><option value="">none</option>{% for l in ignores %}<option>{{ l }}</option>{% endfor %}</select></div> |
|
| 40 | + | </div> |
|
| 41 | + | </div> |
|
| 42 | + | </section> |
|
| 43 | + | ||
| 44 | + | <section class="nr-sec"> |
|
| 45 | + | <div class="nr-head"><h2>Wiki</h2><p class="small mute">Pages beside the code, cloned as its <code>.wiki</code>.</p></div> |
|
| 46 | + | <div class="nr-body"> |
|
| 47 | + | <label class="nr-toggle"><input type="checkbox" name="wiki" value="1"><span><strong>Start the wiki</strong><span class="small mute">A Home page with its name and description, ready to edit. Left off, the first page anyone writes starts it.</span></span></label> |
|
| 48 | + | </div> |
|
| 49 | + | </section> |
|
| 22 | 50 | ||
| 23 | - | <fieldset class="choices"> |
|
| 24 | - | <legend>Start with <span class="small mute">(optional; left empty, it starts empty and takes its first push)</span></legend> |
|
| 25 | - | <label class="check"><input type="checkbox" name="readme" value="1"> a README with its name and description</label> |
|
| 26 | - | <div class="row"> |
|
| 27 | - | <div><label for="licence">Licence</label><select id="licence" name="licence"><option value="">none</option>{% for (id, what) in licences %}<option value="{{ id }}">{{ id }}: {{ what }}</option>{% endfor %}</select></div> |
|
| 28 | - | <div><label for="ignore">.oxyignore</label><select id="ignore" name="ignore"><option value="">none</option>{% for l in ignores %}<option>{{ l }}</option>{% endfor %}</select></div> |
|
| 51 | + | <section class="nr-sec"> |
|
| 52 | + | <div class="nr-head"><h2>Project board</h2><p class="small mute">Its issues and proposals join the board as they open.</p></div> |
|
| 53 | + | <div class="nr-body"> |
|
| 54 | + | <div class="nr-cards three" role="radiogroup" aria-label="Project board"> |
|
| 55 | + | <label class="choice-card"><input type="radio" name="board" value="" checked data-board-none><span><strong>No board</strong><span class="small mute">Add its issues to a board later.</span></span></label> |
|
| 56 | + | <label class="choice-card"><input type="radio" name="board" value="new" data-board-new><span><strong>A new board</strong><span class="small mute">Backlog, Doing and Done; finished work moves to Done.</span></span></label> |
|
| 57 | + | {% if !boards.is_empty() %}<label class="choice-card"><input type="radio" name="board" value="existing" data-board-existing><span><strong>An existing board</strong><span class="small mute">One of the owner's open boards.</span></span></label>{% endif %} |
|
| 58 | + | </div> |
|
| 59 | + | <div class="board-title"><label for="board_title">Board title</label><input type="text" id="board_title" name="board_title" maxlength="100" placeholder="the repository's name"></div> |
|
| 60 | + | {% if !boards.is_empty() %}<div class="board-existing"><label for="board_n">Board</label><select id="board_n" name="board_n" data-board-list>{% for (o, n, t) in boards %}<option value="{{ n }}" data-owner="{{ o }}">{{ o }} #{{ n }}: {{ t }}</option>{% endfor %}</select></div>{% endif %} |
|
| 29 | 61 | </div> |
|
| 30 | - | <p class="small mute">The hub commits these as you, signed with its key as your delegate, as it does for edits on the site.</p> |
|
| 31 | - | </fieldset> |
|
| 62 | + | </section> |
|
| 32 | 63 | ||
| 33 | - | <fieldset class="choices"> |
|
| 34 | - | <legend>Project board</legend> |
|
| 35 | - | <label class="choice-card"><input type="radio" name="board" value="" checked data-board-none><span><strong>No board</strong><span class="small mute">Add its issues to a board later.</span></span></label> |
|
| 36 | - | <label class="choice-card"><input type="radio" name="board" value="new" data-board-new><span><strong>A new board</strong><span class="small mute">Backlog, Doing and Done, with finished work moving to Done. Its issues and proposals join the board as they open.</span></span></label> |
|
| 37 | - | <div class="board-title"><label for="board_title">Board title</label><input type="text" id="board_title" name="board_title" maxlength="100" placeholder="the repository's name"></div> |
|
| 38 | - | {% if !boards.is_empty() %}<label class="choice-card"><input type="radio" name="board" value="existing" data-board-existing><span><strong>An existing board</strong><span class="small mute">Its issues and proposals join it as they open.</span></span></label> |
|
| 39 | - | <div class="board-existing"><label for="board_n">Board</label><select id="board_n" name="board_n" data-board-list>{% for (o, n, t) in boards %}<option value="{{ n }}" data-owner="{{ o }}">{{ o }} #{{ n }}: {{ t }}</option>{% endfor %}</select></div>{% endif %} |
|
| 40 | - | </fieldset> |
|
| 64 | + | <section class="nr-sec"> |
|
| 65 | + | <div class="nr-head"><h2>OxygenFlow</h2><p class="small mute">Pipelines: once it is on, every push with a <code>.flow/pipeline.kdl</code> starts a run, and its jobs show as checks.</p></div> |
|
| 66 | + | <div class="nr-body nr-cards three" role="radiogroup" aria-label="OxygenFlow"> |
|
| 67 | + | <label class="choice-card"><input type="radio" name="flow" value="" checked><span><strong>Not yet</strong><span class="small mute">The site's default. Switch it on later in Settings › Pipelines.</span></span></label> |
|
| 68 | + | <label class="choice-card{% if !admin %} off{% endif %}"><input type="radio" name="flow" value="on"{% if !admin %} disabled{% endif %}><span><strong>On, site runners</strong><span class="small mute">{% if admin %}The site's runners take its jobs.{% else %}A site administrator's choice.{% endif %}</span></span></label> |
|
| 69 | + | <label class="choice-card" data-flow-own><input type="radio" name="flow" value="own"><span><strong>On, own runners</strong><span class="small mute" data-flow-own-note>Only the owner's own runners take its jobs.</span></span></label> |
|
| 70 | + | </div> |
|
| 71 | + | </section> |
|
| 41 | 72 | ||
| 42 | - | <details class="storage"><summary>Storage <span class="small mute">(this server's disk, unless you keep it in your own bucket)</span></summary> |
|
| 43 | - | <label>Storage</label> |
|
| 44 | - | <div class="choice"> |
|
| 45 | - | <label><input type="radio" name="storage" value="hub" checked> this server's disk</label> |
|
| 46 | - | <label><input type="radio" name="storage" value="bucket"> my own bucket (Amazon S3, Google Cloud Storage or Azure)</label> |
|
| 47 | - | </div> |
|
| 73 | + | <details class="nr-sec nr-storage"> |
|
| 74 | + | <summary><h2>Storage</h2><span class="small mute">This server's disk, unless you keep it in your own bucket.</span></summary> |
|
| 75 | + | <div class="nr-body"> |
|
| 76 | + | <div class="nr-cards two"> |
|
| 77 | + | <label class="choice-card"><input type="radio" name="storage" value="hub" checked><span><strong>This server's disk</strong><span class="small mute">The default. Backed up with the hub.</span></span></label> |
|
| 78 | + | <label class="choice-card"><input type="radio" name="storage" value="bucket"><span><strong>My own bucket</strong><span class="small mute">Amazon S3, Google Cloud Storage or Azure; this server keeps a cache. It starts empty.</span></span></label> |
|
| 79 | + | </div> |
|
| 48 | 80 | <fieldset class="storage-bucket"> |
|
| 49 | 81 | <legend>Your bucket <small class="mute">(only when kept in your own bucket)</small></legend> |
|
| 50 | 82 | <p class="small mute">The repository's data lives in the bucket and this server keeps a cache. The bucket, or the prefix in it, must be empty. It is checked before anything is created. The credentials are sealed on this server and used only for this repository; give them read, write, list and delete under the prefix and nothing more.</p> |
|
| … | 12 unchanged lines |
||
| 63 | 95 | <div><label for="client_secret">Client secret</label><input type="password" id="client_secret" name="client_secret" autocomplete="new-password"></div> |
|
| 64 | 96 | </div> |
|
| 65 | 97 | </fieldset> |
|
| 98 | + | </div> |
|
| 66 | 99 | </details> |
|
| 67 | - | <button type="submit">CREATE REPOSITORY</button> |
|
| 100 | + | ||
| 101 | + | <div class="nr-foot"><button type="submit">CREATE REPOSITORY</button></div> |
|
| 68 | 102 | </form> |
|
| 69 | 103 | {% endblock %} |
modified templates/settings.html +9 −0
| … | 9 unchanged lines |
||
| 10 | 10 | <nav class="section-nav" aria-label="Sections"> |
|
| 11 | 11 | <a href="#profile">Profile</a> |
|
| 12 | 12 | <a href="#picture">Picture</a> |
|
| 13 | + | <a href="#repositories">New repositories</a> |
|
| 13 | 14 | <a href="#appearance">Appearance</a> |
|
| 14 | 15 | <a href="#password">Password</a> |
|
| 15 | 16 | </nav> |
|
| … | 27 unchanged lines |
||
| 43 | 44 | </div> |
|
| 44 | 45 | </div> |
|
| 45 | 46 | </form> |
|
| 47 | + | <form class="card" id="repositories" method="post" action="/settings/repositories"> |
|
| 48 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 49 | + | <h2>New repositories</h2> |
|
| 50 | + | <label for="default_bookmark">Default bookmark</label> |
|
| 51 | + | <input type="text" id="default_bookmark" name="default_bookmark" value="{{ default_bookmark }}" placeholder="{{ site_bookmark }}" pattern="[A-Za-z0-9][A-Za-z0-9._-]{0,63}" autocomplete="off" spellcheck="false"> |
|
| 52 | + | <p class="small mute">The bookmark your new repositories start with, such as <code>develop</code>. Left empty, they take this site's: <code>{{ site_bookmark }}</code>. An organisation sets its own under its settings.</p> |
|
| 53 | + | <button type="submit">SAVE</button> |
|
| 54 | + | </form> |
|
| 46 | 55 | <form class="card" id="appearance" method="post" action="/settings/appearance"> |
|
| 47 | 56 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 48 | 57 | <h2>Appearance</h2> |
|
| … | 25 unchanged lines |
modified templates/style.css +44 −10
| … | 1551 unchanged lines |
||
| 1552 | 1552 | .tab-job .ok { color: var(--ok); } |
|
| 1553 | 1553 | .tab-job .bad { color: var(--bad); } |
|
| 1554 | 1554 | .tab-job pre { max-height: 24rem; overflow: auto; } |
|
| 1555 | - | /* The New repository page (#360). */ |
|
| 1555 | + | /* The New repository page (#360, #371): sections with a heading column on |
|
| 1556 | + | the left and their choices on the right, stacked on a phone. */ |
|
| 1557 | + | form.card.new-repo { max-width: 960px; padding: 0; } |
|
| 1558 | + | .new-repo .nr-sec { display: grid; grid-template-columns: minmax(0, 220px) minmax(0, 1fr); gap: 8px 28px; padding: 20px 24px; border-top: 1px solid var(--line); } |
|
| 1559 | + | .new-repo .nr-sec:first-of-type { border-top: 0; } |
|
| 1560 | + | .new-repo .nr-sec > .nr-head h2, .new-repo .nr-storage summary h2 { font-size: 15px; margin: 0 0 4px; color: var(--ink); } |
|
| 1561 | + | .new-repo .nr-sec > .nr-head p { margin: 0; } |
|
| 1562 | + | .new-repo .nr-body { min-width: 0; } |
|
| 1563 | + | .new-repo .nr-body > label:first-child, .new-repo .name-row label { margin-top: 0; } |
|
| 1556 | 1564 | .new-repo .name-row { display: flex; align-items: flex-end; gap: 8px; } |
|
| 1557 | 1565 | .new-repo .name-row .grow { flex: 1; min-width: 0; } |
|
| 1558 | - | .new-repo .name-row .slash { font-size: 20px; color: var(--mute); padding-bottom: 6px; } |
|
| 1559 | - | .new-repo .name-check { min-height: 1.4em; margin: 4px 0 8px; } |
|
| 1566 | + | .new-repo .name-row .slash { flex: none; font-size: 20px; color: var(--mute); padding-bottom: 6px; } |
|
| 1567 | + | .new-repo .name-check { min-height: 1.4em; margin: 4px 0 4px; } |
|
| 1560 | 1568 | .ok-text { color: var(--ok); } |
|
| 1561 | 1569 | .err-text { color: var(--bad); } |
|
| 1562 | - | .new-repo fieldset.choices { border: 1px solid var(--line); padding: 10px 12px; margin: 12px 0; display: grid; gap: 8px; } |
|
| 1563 | - | .new-repo fieldset.choices legend { padding: 0 4px; font-weight: 600; } |
|
| 1564 | - | .choice-card { display: flex; gap: 10px; align-items: flex-start; padding: 8px 10px; border: 1px solid var(--line); background: var(--soft); cursor: pointer; } |
|
| 1565 | - | .choice-card > span { display: grid; gap: 2px; } |
|
| 1566 | - | .choice-card:has(input:checked) { border-color: var(--acc); } |
|
| 1567 | - | .new-repo .board-title, .new-repo .board-existing { margin-left: 28px; } |
|
| 1568 | - | .new-repo details.storage { margin: 12px 0; } |
|
| 1570 | + | .new-repo .nr-cards { display: grid; gap: 10px; align-content: start; } |
|
| 1571 | + | .new-repo .nr-cards.three { grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); } |
|
| 1572 | + | .new-repo .nr-cards.two { grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); margin-bottom: 10px; } |
|
| 1573 | + | .new-repo label.choice-card, .new-repo label.nr-toggle { display: flex; gap: 10px; align-items: flex-start; margin: 0; padding: 10px 12px; border: 1px solid var(--line); background: var(--soft); cursor: pointer; font-size: 14px; text-transform: none; letter-spacing: 0; color: var(--ink); } |
|
| 1574 | + | .new-repo label.choice-card input, .new-repo label.nr-toggle input { margin: 3px 0 0; flex: none; } |
|
| 1575 | + | .new-repo label.choice-card > span, .new-repo label.nr-toggle > span { display: grid; gap: 2px; min-width: 0; } |
|
| 1576 | + | .new-repo label.choice-card:hover, .new-repo label.nr-toggle:hover { border-color: var(--acc); } |
|
| 1577 | + | .new-repo label.choice-card:has(input:checked), .new-repo label.nr-toggle:has(input:checked) { border-color: var(--acc); box-shadow: inset 3px 0 0 var(--acc); } |
|
| 1578 | + | .new-repo label.choice-card:has(input:focus-visible), .new-repo label.nr-toggle:has(input:focus-visible) { outline: 2px solid var(--acc); outline-offset: 1px; } |
|
| 1579 | + | .new-repo label.choice-card.off { cursor: not-allowed; opacity: .6; } |
|
| 1580 | + | .new-repo label.choice-card.off:hover { border-color: var(--line); } |
|
| 1581 | + | .new-repo .nr-pair { margin-top: 4px; flex-wrap: wrap; } |
|
| 1582 | + | .new-repo .nr-pair > div { flex: 1 1 200px; min-width: 0; } |
|
| 1583 | + | .new-repo .nr-hint { margin: 4px 0 0; } |
|
| 1584 | + | .new-repo input.nr-bookmark { max-width: 260px; } |
|
| 1585 | + | .new-repo .name-row > div:first-child { flex: 0 1 220px; min-width: 0; } |
|
| 1586 | + | .new-repo .name-row .grow { flex: 1 1 auto; } |
|
| 1587 | + | .new-repo .name-row select, .new-repo .name-row input { width: 100%; } |
|
| 1588 | + | .new-repo .board-title, .new-repo .board-existing { margin-top: 6px; max-width: 420px; } |
|
| 1589 | + | .new-repo details.nr-storage > summary { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; cursor: pointer; list-style: none; } |
|
| 1590 | + | .new-repo details.nr-storage > summary::-webkit-details-marker { display: none; } |
|
| 1591 | + | .new-repo details.nr-storage > summary h2::before { content: "\25B8"; display: inline-block; width: 1em; color: var(--mute); } |
|
| 1592 | + | .new-repo details.nr-storage[open] > summary h2::before { content: "\25BE"; } |
|
| 1593 | + | .new-repo details.nr-storage > .nr-body { grid-column: 2; } |
|
| 1594 | + | .new-repo .nr-storage:not(:has(input[name=storage][value=bucket]:checked)) .storage-bucket { display: none; } |
|
| 1595 | + | .new-repo .nr-foot { display: flex; justify-content: flex-end; padding: 16px 24px; border-top: 1px solid var(--line); background: var(--soft); } |
|
| 1596 | + | .new-repo .nr-foot button { margin: 0; } |
|
| 1597 | + | @media (max-width: 720px) { |
|
| 1598 | + | .new-repo .nr-sec { grid-template-columns: minmax(0, 1fr); padding: 16px; } |
|
| 1599 | + | .new-repo details.nr-storage > .nr-body { grid-column: 1; } |
|
| 1600 | + | .new-repo .nr-foot { padding: 12px 16px; } |
|
| 1601 | + | .new-repo .nr-foot button { width: 100%; } |
|
| 1602 | + | } |