Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
assets/base.css +11 −1
| @@ -119,13 +119,16 @@ code, pre, .mono { | |||
| 119 | } | 119 | } |
| 120 | 120 | ||
| 121 | /* Top-bar search: a growing input with an inline submit icon. */ | 121 | /* Top-bar search: a growing input with an inline submit icon. */ |
| 122 | /* The new-repo button and search group at the right of the top bar. */ | ||
| 123 | .topbar-new { | ||
| 124 | margin-left: auto; | ||
| 125 | } | ||
| 122 | .topbar-search { | 126 | .topbar-search { |
| 123 | display: flex; | 127 | display: flex; |
| 124 | align-items: center; | 128 | align-items: center; |
| 125 | gap: 0.25rem; | 129 | gap: 0.25rem; |
| 126 | flex: 1; | 130 | flex: 1; |
| 127 | max-width: 28rem; | 131 | max-width: 28rem; |
| 128 | margin-left: auto; | ||
| 129 | } | 132 | } |
| 130 | .topbar-search input[type="search"] { | 133 | .topbar-search input[type="search"] { |
| 131 | width: 100%; | 134 | width: 100%; |
| @@ -395,6 +398,11 @@ select:focus { | |||
| 395 | display: none !important; | 398 | display: none !important; |
| 396 | } | 399 | } |
| 397 | 400 | ||
| 401 | /* New-repository form: a comfortably narrow column. */ | ||
| 402 | .new-repo { | ||
| 403 | max-width: 42rem; | ||
| 404 | } | ||
| 405 | |||
| 398 | /* Centered auth card (sign in, invite activation). */ | 406 | /* Centered auth card (sign in, invite activation). */ |
| 399 | .auth-wrap { | 407 | .auth-wrap { |
| 400 | display: flex; | 408 | display: flex; |
| @@ -595,6 +603,8 @@ select:focus { | |||
| 595 | } | 603 | } |
| 596 | .heatmap { | 604 | .heatmap { |
| 597 | display: block; | 605 | display: block; |
| 606 | width: 100%; | ||
| 607 | height: auto; | ||
| 598 | } | 608 | } |
| 599 | .hm-label { | 609 | .hm-label { |
| 600 | fill: var(--fb-fg-muted); | 610 | fill: var(--fb-fg-muted); |
crates/web/src/activity.rs +3 −1
| @@ -205,8 +205,10 @@ impl Activity { | |||
| 205 | 205 | ||
| 206 | html! { | 206 | html! { |
| 207 | div class="heatmap-wrap" { | 207 | div class="heatmap-wrap" { |
| 208 | // width:100% + the viewBox aspect ratio stretches the graph to | ||
| 209 | // fill the activity column (styled in base.css). | ||
| 208 | svg class="heatmap" viewBox=(format!("0 0 {width} {height}")) | 210 | svg class="heatmap" viewBox=(format!("0 0 {width} {height}")) |
| 209 | width=(width) height=(height) role="img" | 211 | preserveAspectRatio="xMidYMid meet" role="img" |
| 210 | aria-label=(format!("{} contributions in the last year", self.total)) { | 212 | aria-label=(format!("{} contributions in the last year", self.total)) { |
| 211 | (PreEscaped(body)) | 213 | (PreEscaped(body)) |
| 212 | } | 214 | } |
crates/web/src/icons.rs +2 −0
| @@ -21,6 +21,7 @@ pub enum Icon { | |||
| 21 | ChevronDown, | 21 | ChevronDown, |
| 22 | Search, | 22 | Search, |
| 23 | Copy, | 23 | Copy, |
| 24 | Plus, | ||
| 24 | Box, | 25 | Box, |
| 25 | Link, | 26 | Link, |
| 26 | User, | 27 | User, |
| @@ -61,6 +62,7 @@ impl Icon { | |||
| 61 | r#"<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>"# | 62 | r#"<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>"# |
| 62 | } | 63 | } |
| 63 | Icon::ChevronDown => r#"<path d="m6 9 6 6 6-6"/>"#, | 64 | Icon::ChevronDown => r#"<path d="m6 9 6 6 6-6"/>"#, |
| 65 | Icon::Plus => r#"<path d="M5 12h14"/><path d="M12 5v14"/>"#, | ||
| 64 | Icon::Search => r#"<path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/>"#, | 66 | Icon::Search => r#"<path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/>"#, |
| 65 | Icon::Copy => { | 67 | Icon::Copy => { |
| 66 | r#"<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>"# | 68 | r#"<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>"# |
crates/web/src/layout.rs +4 −0
| @@ -72,6 +72,10 @@ fn topbar(chrome: &Chrome) -> Markup { | |||
| 72 | button class="icon-btn" type="button" data-drawer-toggle aria-label="Menu" | 72 | button class="icon-btn" type="button" data-drawer-toggle aria-label="Menu" |
| 73 | aria-controls="drawer" aria-expanded="false" { (icon(Icon::PanelLeft)) } | 73 | aria-controls="drawer" aria-expanded="false" { (icon(Icon::PanelLeft)) } |
| 74 | span class="slug" { a href="/" { (chrome.instance_name) } } | 74 | span class="slug" { a href="/" { (chrome.instance_name) } } |
| 75 | @if chrome.user.is_some() { | ||
| 76 | a class="icon-btn topbar-new" href="/new" aria-label="New repository" | ||
| 77 | title="New repository" { (icon(Icon::Plus)) } | ||
| 78 | } | ||
| 75 | form class="topbar-search" method="get" action="/search" role="search" { | 79 | form class="topbar-search" method="get" action="/search" role="search" { |
| 76 | input type="search" name="q" placeholder="Search…" aria-label="Search"; | 80 | input type="search" name="q" placeholder="Search…" aria-label="Search"; |
| 77 | } | 81 | } |
crates/web/src/lib.rs +4 −0
| @@ -129,6 +129,10 @@ pub fn build_router(state: AppState) -> Router { | |||
| 129 | Router::new() | 129 | Router::new() |
| 130 | .nest_service("/api/v1", api) | 130 | .nest_service("/api/v1", api) |
| 131 | .route("/", get(pages::home)) | 131 | .route("/", get(pages::home)) |
| 132 | .route( | ||
| 133 | "/new", | ||
| 134 | get(pages::new_repo_form).post(pages::new_repo_submit), | ||
| 135 | ) | ||
| 132 | .route("/explore", get(pages::explore)) | 136 | .route("/explore", get(pages::explore)) |
| 133 | .route("/explore/users", get(pages::explore_users)) | 137 | .route("/explore/users", get(pages::explore_users)) |
| 134 | .route("/login", get(pages::login_form).post(pages::login_submit)) | 138 | .route("/login", get(pages::login_form).post(pages::login_submit)) |
crates/web/src/pages.rs +166 −0
| @@ -246,6 +246,172 @@ async fn dashboard_body(state: &AppState, user: &User, q: &str) -> AppResult<Mar | |||
| 246 | }) | 246 | }) |
| 247 | } | 247 | } |
| 248 | 248 | ||
| 249 | /// `GET /new` — the new-repository form (requires sign-in). | ||
| 250 | pub async fn new_repo_form( | ||
| 251 | State(state): State<AppState>, | ||
| 252 | RequireUser(user): RequireUser, | ||
| 253 | jar: CookieJar, | ||
| 254 | uri: Uri, | ||
| 255 | ) -> Response { | ||
| 256 | let (jar, chrome) = build_chrome(&state, jar, Some(user), uri.path().to_string()); | ||
| 257 | let body = new_repo_body(&state, &chrome.csrf, None, ""); | ||
| 258 | (jar, page(&chrome, "New repository", body)).into_response() | ||
| 259 | } | ||
| 260 | |||
| 261 | /// New-repository form fields. | ||
| 262 | #[derive(Debug, Deserialize)] | ||
| 263 | pub struct NewRepoForm { | ||
| 264 | /// The repo name (may include a group path). | ||
| 265 | #[serde(default)] | ||
| 266 | name: String, | ||
| 267 | /// Optional one-line description. | ||
| 268 | #[serde(default)] | ||
| 269 | description: String, | ||
| 270 | /// Visibility token (`public` | `internal` | `private`). | ||
| 271 | #[serde(default)] | ||
| 272 | visibility: String, | ||
| 273 | /// Optional default branch override. | ||
| 274 | #[serde(default)] | ||
| 275 | default_branch: String, | ||
| 276 | /// CSRF token. | ||
| 277 | #[serde(rename = "_csrf")] | ||
| 278 | csrf: String, | ||
| 279 | } | ||
| 280 | |||
| 281 | /// `POST /new` — create the repository (row + bare repo on disk) and redirect. | ||
| 282 | pub async fn new_repo_submit( | ||
| 283 | State(state): State<AppState>, | ||
| 284 | RequireUser(user): RequireUser, | ||
| 285 | jar: CookieJar, | ||
| 286 | headers: HeaderMap, | ||
| 287 | Form(form): Form<NewRepoForm>, | ||
| 288 | ) -> Response { | ||
| 289 | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | ||
| 290 | return AppError::Forbidden.into_response(); | ||
| 291 | } | ||
| 292 | match create_repo_from_form(&state, &user, &form).await { | ||
| 293 | Ok(path) => Redirect::to(&format!("/{}/{path}", user.username)).into_response(), | ||
| 294 | Err(msg) => { | ||
| 295 | let (jar, chrome) = build_chrome(&state, jar, Some(user), "/new".to_string()); | ||
| 296 | let body = new_repo_body(&state, &chrome.csrf, Some(&msg), form.name.trim()); | ||
| 297 | ( | ||
| 298 | axum::http::StatusCode::BAD_REQUEST, | ||
| 299 | jar, | ||
| 300 | page(&chrome, "New repository", body), | ||
| 301 | ) | ||
| 302 | .into_response() | ||
| 303 | } | ||
| 304 | } | ||
| 305 | } | ||
| 306 | |||
| 307 | /// Create the repo described by `form`, returning its path or a user-facing error. | ||
| 308 | async fn create_repo_from_form( | ||
| 309 | state: &AppState, | ||
| 310 | user: &User, | ||
| 311 | form: &NewRepoForm, | ||
| 312 | ) -> Result<String, String> { | ||
| 313 | let name = form.name.trim(); | ||
| 314 | if name.is_empty() { | ||
| 315 | return Err("Repository name is required.".to_string()); | ||
| 316 | } | ||
| 317 | let (group, leaf) = match name.rsplit_once('/') { | ||
| 318 | Some((g, l)) => (Some(g), l), | ||
| 319 | None => (None, name), | ||
| 320 | }; | ||
| 321 | let group_id = match group { | ||
| 322 | Some(g) => Some( | ||
| 323 | state | ||
| 324 | .store | ||
| 325 | .ensure_group_path(&user.id, g) | ||
| 326 | .await | ||
| 327 | .map_err(|e| e.to_string())? | ||
| 328 | .id, | ||
| 329 | ), | ||
| 330 | None => None, | ||
| 331 | }; | ||
| 332 | let branch = { | ||
| 333 | let b = form.default_branch.trim(); | ||
| 334 | if b.is_empty() { | ||
| 335 | state.config.instance.default_branch.clone() | ||
| 336 | } else { | ||
| 337 | b.to_string() | ||
| 338 | } | ||
| 339 | }; | ||
| 340 | let visibility = model::Visibility::from_token(&form.visibility).unwrap_or_else(|| { | ||
| 341 | model::Visibility::from_token(state.config.instance.default_visibility.as_token()) | ||
| 342 | .unwrap_or(model::Visibility::Private) | ||
| 343 | }); | ||
| 344 | let description = { | ||
| 345 | let d = form.description.trim(); | ||
| 346 | (!d.is_empty()).then(|| d.to_string()) | ||
| 347 | }; | ||
| 348 | let repo = state | ||
| 349 | .store | ||
| 350 | .create_repo(store::NewRepo { | ||
| 351 | owner_id: user.id.clone(), | ||
| 352 | group_id, | ||
| 353 | name: leaf.to_string(), | ||
| 354 | path: name.to_string(), | ||
| 355 | description, | ||
| 356 | visibility, | ||
| 357 | default_branch: branch.clone(), | ||
| 358 | }) | ||
| 359 | .await | ||
| 360 | .map_err(|e| match e { | ||
| 361 | store::StoreError::Conflict { .. } => { | ||
| 362 | "A repository with that name already exists.".to_string() | ||
| 363 | } | ||
| 364 | store::StoreError::Name(_) => { | ||
| 365 | "Invalid name: use letters, digits, '-', '_', and '/' for groups.".to_string() | ||
| 366 | } | ||
| 367 | other => other.to_string(), | ||
| 368 | })?; | ||
| 369 | |||
| 370 | let hook = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("fabrica")); | ||
| 371 | if let Err(e) = git::create_bare(&state.config.storage.repo_dir, &repo.id, &branch, &hook) { | ||
| 372 | let _ = state.store.delete_repo(&repo.id).await; | ||
| 373 | return Err(format!("Could not create the repository on disk: {e}")); | ||
| 374 | } | ||
| 375 | Ok(repo.path) | ||
| 376 | } | ||
| 377 | |||
| 378 | /// Render the new-repository form with an optional error and prefilled name. | ||
| 379 | fn new_repo_body(state: &AppState, csrf: &str, error: Option<&str>, name: &str) -> Markup { | ||
| 380 | let default_vis = state.config.instance.default_visibility.as_token(); | ||
| 381 | let vis_option = |value: &str, label: &str| { | ||
| 382 | html! { option value=(value) selected[value == default_vis] { (label) } } | ||
| 383 | }; | ||
| 384 | html! { | ||
| 385 | div class="new-repo" { | ||
| 386 | h1 { "New repository" } | ||
| 387 | @if let Some(err) = error { | ||
| 388 | div class="notice notice-error" { (err) } | ||
| 389 | } | ||
| 390 | div class="card" { | ||
| 391 | form method="post" action="/new" { | ||
| 392 | input type="hidden" name="_csrf" value=(csrf); | ||
| 393 | label for="name" { "Repository name" } | ||
| 394 | input type="text" id="name" name="name" value=(name) required | ||
| 395 | placeholder="my-project (or group/my-project)" autofocus; | ||
| 396 | label for="description" { "Description" } | ||
| 397 | input type="text" id="description" name="description" | ||
| 398 | placeholder="Optional one-line description"; | ||
| 399 | label for="visibility" { "Visibility" } | ||
| 400 | select id="visibility" name="visibility" { | ||
| 401 | (vis_option("public", "Public — visible to everyone")) | ||
| 402 | (vis_option("internal", "Internal — any signed-in user")) | ||
| 403 | (vis_option("private", "Private — only you and collaborators")) | ||
| 404 | } | ||
| 405 | label for="default_branch" { "Default branch" } | ||
| 406 | input type="text" id="default_branch" name="default_branch" | ||
| 407 | placeholder=(state.config.instance.default_branch); | ||
| 408 | button class="btn btn-primary" type="submit" { "Create repository" } | ||
| 409 | } | ||
| 410 | } | ||
| 411 | } | ||
| 412 | } | ||
| 413 | } | ||
| 414 | |||
| 249 | /// Sign-in form fields. | 415 | /// Sign-in form fields. |
| 250 | #[derive(Debug, Deserialize)] | 416 | #[derive(Debug, Deserialize)] |
| 251 | pub struct LoginForm { | 417 | pub struct LoginForm { |
crates/web/src/tests.rs +22 −0
| @@ -430,6 +430,28 @@ async fn repo_settings_are_owner_only() { | |||
| 430 | } | 430 | } |
| 431 | 431 | ||
| 432 | #[tokio::test] | 432 | #[tokio::test] |
| 433 | async fn new_repo_page_requires_auth_and_renders() { | ||
| 434 | let (_state, app) = app().await; | ||
| 435 | // Anonymous → redirected to sign in. | ||
| 436 | let res = app.clone().oneshot(get("/new")).await.unwrap(); | ||
| 437 | assert_eq!(res.status(), StatusCode::SEE_OTHER); | ||
| 438 | assert_eq!(res.headers().get(header::LOCATION).unwrap(), "/login"); | ||
| 439 | |||
| 440 | // Signed in → the creation form. | ||
| 441 | let cookie = login(&app).await; | ||
| 442 | let req = Request::builder() | ||
| 443 | .uri("/new") | ||
| 444 | .header(header::COOKIE, cookie) | ||
| 445 | .body(Body::empty()) | ||
| 446 | .unwrap(); | ||
| 447 | let res = app.oneshot(req).await.unwrap(); | ||
| 448 | assert_eq!(res.status(), StatusCode::OK); | ||
| 449 | let html = body_string(res).await; | ||
| 450 | assert!(html.contains("New repository")); | ||
| 451 | assert!(html.contains("name=\"visibility\"")); | ||
| 452 | } | ||
| 453 | |||
| 454 | #[tokio::test] | ||
| 433 | async fn settings_requires_authentication() { | 455 | async fn settings_requires_authentication() { |
| 434 | let (_state, app) = app().await; | 456 | let (_state, app) = app().await; |
| 435 | let res = app.oneshot(get("/settings")).await.unwrap(); | 457 | let res = app.oneshot(get("/settings")).await.unwrap(); |