| 1 | //! Settings pages — the user's own, and a repository's (spec §7). |
| 2 | //! |
| 3 | //! Every form here is a plain `<form method="post">`. Spec §7 makes progressive |
| 4 | //! enhancement a hard requirement, and settings are exactly where a |
| 5 | //! JavaScript-only affordance would be most annoying to hit. |
| 6 | //! |
| 7 | //! Note the deletion forms POST rather than sending `DELETE`. The spec's route |
| 8 | //! table says `DELETE /settings/keys/{id}`, and that route exists for API |
| 9 | //! clients — but browsers cannot emit it from a form without JavaScript, so the |
| 10 | //! POST alias is what the UI uses. Both are registered; both do the same thing. |
| 11 | |
| 12 | use maud::{html, Markup}; |
| 13 | |
| 14 | use crate::repo_ctx::RepoContext; |
| 15 | |
| 16 | // ─── the user's own settings ───────────────────────────────────────────────── |
| 17 | |
| 18 | pub struct SshKeyRow { |
| 19 | pub id: uuid::Uuid, |
| 20 | pub name: String, |
| 21 | pub key_type: String, |
| 22 | pub fingerprint: String, |
| 23 | pub last_used_at: Option<chrono::DateTime<chrono::Utc>>, |
| 24 | } |
| 25 | |
| 26 | pub struct TokenRow { |
| 27 | pub id: uuid::Uuid, |
| 28 | pub name: String, |
| 29 | pub prefix: String, |
| 30 | pub expires_at: Option<chrono::DateTime<chrono::Utc>>, |
| 31 | pub last_used_at: Option<chrono::DateTime<chrono::Utc>>, |
| 32 | } |
| 33 | |
| 34 | pub struct UserSettings<'a> { |
| 35 | pub user: &'a df_db::models::User, |
| 36 | pub csrf: &'a str, |
| 37 | pub keys: &'a [SshKeyRow], |
| 38 | pub tokens: &'a [TokenRow], |
| 39 | /// A token's plaintext, shown exactly once immediately after minting it. |
| 40 | pub new_token: Option<&'a str>, |
| 41 | pub error: Option<&'a str>, |
| 42 | pub notice: Option<&'a str>, |
| 43 | pub ssh_host: &'a str, |
| 44 | } |
| 45 | |
| 46 | pub fn user_settings(s: UserSettings<'_>) -> Markup { |
| 47 | html! { |
| 48 | h1 { "Settings" } |
| 49 | |
| 50 | @if let Some(e) = s.error { div .banner.banner-error role="alert" { (e) } } |
| 51 | @if let Some(n) = s.notice { div .banner.banner-ok role="status" { (n) } } |
| 52 | |
| 53 | div .panel { |
| 54 | h2 { "Profile" } |
| 55 | dl .kv { |
| 56 | dt { "Handle" } dd { code { (s.user.handle) } } |
| 57 | dt { "Name" } dd { (s.user.display_name.as_deref().unwrap_or("—")) } |
| 58 | dt { "Email" } dd { (s.user.email.as_deref().unwrap_or("—")) } |
| 59 | dt { "Role" } dd { @if s.user.is_admin { "Site administrator" } @else { "Member" } } |
| 60 | } |
| 61 | p .hint { |
| 62 | "Your name and email come from the identity provider you sign in with, \ |
| 63 | and change there rather than here." |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | // ─── SSH keys ──────────────────────────────────────────────────────── |
| 68 | div .panel { |
| 69 | h2 { "SSH keys" } |
| 70 | p .dim { |
| 71 | "Push over SSH with " |
| 72 | code { "jj git push" } " or " code { "git push" } ". Clone URLs look like " |
| 73 | code { (s.ssh_host) } |
| 74 | "." |
| 75 | } |
| 76 | |
| 77 | @if s.keys.is_empty() { |
| 78 | p .hint { "No keys yet." } |
| 79 | } @else { |
| 80 | table .listing { |
| 81 | thead { |
| 82 | tr { th scope="col" { "Name" } th scope="col" { "Fingerprint" } th scope="col" { "Last used" } th scope="col" {} } |
| 83 | } |
| 84 | tbody { |
| 85 | @for k in s.keys { |
| 86 | tr { |
| 87 | td { (k.name) " " span .chip { (k.key_type) } } |
| 88 | td { code .faint { (k.fingerprint) } } |
| 89 | td .faint { (opt_date(k.last_used_at)) } |
| 90 | td { |
| 91 | form method="post" action=(format!("/settings/keys/{}/delete", k.id)) { |
| 92 | input type="hidden" name="_csrf" value=(s.csrf); |
| 93 | button .btn.btn-danger type="submit" { "Remove" } |
| 94 | } |
| 95 | } |
| 96 | } |
| 97 | } |
| 98 | } |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | form method="post" action="/settings/keys" .stack { |
| 103 | input type="hidden" name="_csrf" value=(s.csrf); |
| 104 | div .field { |
| 105 | label for="key" { "New key" } |
| 106 | textarea id="key" name="key" rows="4" required |
| 107 | placeholder="ssh-ed25519 AAAA… you@your-machine" {} |
| 108 | p .hint { |
| 109 | "Paste the contents of your " |
| 110 | code { ".pub" } |
| 111 | " file — never the private key." |
| 112 | } |
| 113 | } |
| 114 | div .field { |
| 115 | label for="key_name" { "Name (optional)" } |
| 116 | input type="text" id="key_name" name="name" maxlength="100" |
| 117 | placeholder="taken from the key's comment"; |
| 118 | } |
| 119 | button .btn.btn-primary type="submit" { "Add key" } |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | // ─── access tokens ─────────────────────────────────────────────────── |
| 124 | div .panel { |
| 125 | h2 { "Access tokens" } |
| 126 | p .dim { |
| 127 | "For Git over HTTPS. Use your handle as the username and the token as \ |
| 128 | the password." |
| 129 | } |
| 130 | |
| 131 | @if let Some(t) = s.new_token { |
| 132 | div .banner.banner-ok role="status" { |
| 133 | p { strong { "Copy this now — it is not shown again." } } |
| 134 | div .clone-box { code { (t) } } |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | @if s.tokens.is_empty() { |
| 139 | p .hint { "No tokens yet." } |
| 140 | } @else { |
| 141 | table .listing { |
| 142 | thead { |
| 143 | tr { th scope="col" { "Name" } th scope="col" { "Prefix" } th scope="col" { "Expires" } th scope="col" { "Last used" } th scope="col" {} } |
| 144 | } |
| 145 | tbody { |
| 146 | @for t in s.tokens { |
| 147 | tr { |
| 148 | td { (t.name) } |
| 149 | td { code .faint { (t.prefix) "…" } } |
| 150 | td .faint { (opt_date(t.expires_at)) } |
| 151 | td .faint { (opt_date(t.last_used_at)) } |
| 152 | td { |
| 153 | form method="post" action=(format!("/settings/tokens/{}/delete", t.id)) { |
| 154 | input type="hidden" name="_csrf" value=(s.csrf); |
| 155 | button .btn.btn-danger type="submit" { "Revoke" } |
| 156 | } |
| 157 | } |
| 158 | } |
| 159 | } |
| 160 | } |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | form method="post" action="/settings/tokens" .stack { |
| 165 | input type="hidden" name="_csrf" value=(s.csrf); |
| 166 | div .field { |
| 167 | label for="token_name" { "Name" } |
| 168 | input type="text" id="token_name" name="name" required maxlength="100" |
| 169 | placeholder="laptop"; |
| 170 | } |
| 171 | div .field { |
| 172 | label for="expires_days" { "Expires in" } |
| 173 | select id="expires_days" name="expires_days" { |
| 174 | option value="30" { "30 days" } |
| 175 | option value="90" selected { "90 days" } |
| 176 | option value="365" { "1 year" } |
| 177 | option value="0" { "Never" } |
| 178 | } |
| 179 | } |
| 180 | button .btn.btn-primary type="submit" { "Generate token" } |
| 181 | } |
| 182 | } |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | // ─── repository settings ───────────────────────────────────────────────────── |
| 187 | |
| 188 | pub struct CollaboratorRow { |
| 189 | pub handle: String, |
| 190 | pub role: df_db::models::RepoRole, |
| 191 | } |
| 192 | |
| 193 | pub struct BookmarkRow { |
| 194 | pub name: String, |
| 195 | pub protected: bool, |
| 196 | pub is_default: bool, |
| 197 | } |
| 198 | |
| 199 | pub struct RepoSettings<'a> { |
| 200 | pub ctx: &'a RepoContext, |
| 201 | pub csrf: &'a str, |
| 202 | pub tab: &'a str, |
| 203 | pub collaborators: &'a [CollaboratorRow], |
| 204 | pub bookmarks: &'a [BookmarkRow], |
| 205 | pub size_bytes: u64, |
| 206 | pub error: Option<&'a str>, |
| 207 | pub notice: Option<&'a str>, |
| 208 | } |
| 209 | |
| 210 | pub fn repo_settings(s: RepoSettings<'_>) -> Markup { |
| 211 | let base = s.ctx.base(); |
| 212 | let settings = format!("{base}/settings"); |
| 213 | |
| 214 | html! { |
| 215 | @if let Some(e) = s.error { div .banner.banner-error role="alert" { (e) } } |
| 216 | @if let Some(n) = s.notice { div .banner.banner-ok role="status" { (n) } } |
| 217 | |
| 218 | nav .subtabs aria-label="Settings sections" { |
| 219 | @for (key, label) in [("general", "General"), ("collaborators", "Collaborators"), |
| 220 | ("bookmarks", "Bookmarks"), ("danger", "Danger zone")] { |
| 221 | a href=(format!("{settings}?tab={key}")) |
| 222 | .active[s.tab == key] |
| 223 | aria-current=[(s.tab == key).then_some("page")] { (label) } |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | @match s.tab { |
| 228 | "collaborators" => (collaborators_tab(&s)), |
| 229 | "bookmarks" => (bookmarks_tab(&s)), |
| 230 | "danger" => (danger_tab(&s)), |
| 231 | _ => (general_tab(&s)), |
| 232 | } |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | fn general_tab(s: &RepoSettings<'_>) -> Markup { |
| 237 | let action = format!("{}/settings/general", s.ctx.base()); |
| 238 | let private = matches!(s.ctx.repo.visibility, df_db::models::Visibility::Private); |
| 239 | |
| 240 | html! { |
| 241 | div .panel { |
| 242 | h2 { "General" } |
| 243 | form method="post" action=(action) .stack { |
| 244 | input type="hidden" name="_csrf" value=(s.csrf); |
| 245 | |
| 246 | div .field { |
| 247 | label for="description" { "Description" } |
| 248 | input type="text" id="description" name="description" maxlength="500" |
| 249 | value=(s.ctx.repo.description.as_deref().unwrap_or("")); |
| 250 | } |
| 251 | |
| 252 | div .field { |
| 253 | label for="default_bookmark" { "Default bookmark" } |
| 254 | select id="default_bookmark" name="default_bookmark" { |
| 255 | @for b in s.bookmarks { |
| 256 | option value=(b.name) selected[b.is_default] { (b.name) } |
| 257 | } |
| 258 | // A repository with no pushes yet has no bookmark rows, |
| 259 | // so keep the configured name selectable. |
| 260 | @if s.bookmarks.is_empty() { |
| 261 | option value=(s.ctx.repo.default_bookmark) selected { |
| 262 | (s.ctx.repo.default_bookmark) |
| 263 | } |
| 264 | } |
| 265 | } |
| 266 | p .hint { "Where the code view opens, and what changes are measured against." } |
| 267 | } |
| 268 | |
| 269 | div .field { |
| 270 | label { |
| 271 | input type="checkbox" name="private" value="1" checked[private]; |
| 272 | " Private" |
| 273 | } |
| 274 | p .hint { |
| 275 | "A private repository is invisible to everyone but its collaborators — \ |
| 276 | it returns the same 404 a nonexistent repository does." |
| 277 | } |
| 278 | } |
| 279 | |
| 280 | button .btn.btn-primary type="submit" { "Save" } |
| 281 | } |
| 282 | } |
| 283 | |
| 284 | div .panel { |
| 285 | h2 { "Storage" } |
| 286 | dl .kv { |
| 287 | dt { "On disk" } dd { (crate::views::repo::human_size(s.size_bytes)) } |
| 288 | dt { "Created" } dd { (s.ctx.repo.created_at.format("%Y-%m-%d")) } |
| 289 | dt { "Last push" } dd { (opt_date(s.ctx.repo.pushed_at)) } |
| 290 | } |
| 291 | } |
| 292 | } |
| 293 | } |
| 294 | |
| 295 | fn collaborators_tab(s: &RepoSettings<'_>) -> Markup { |
| 296 | let base = s.ctx.base(); |
| 297 | html! { |
| 298 | div .panel { |
| 299 | h2 { "Collaborators" } |
| 300 | p .dim { |
| 301 | "Effective access is the highest of a direct role here, an organization \ |
| 302 | role, and repository ownership." |
| 303 | } |
| 304 | |
| 305 | @if s.collaborators.is_empty() { |
| 306 | p .hint { "No collaborators. Only the owner can reach this repository." } |
| 307 | } @else { |
| 308 | table .listing { |
| 309 | thead { tr { th scope="col" { "User" } th scope="col" { "Role" } th scope="col" {} } } |
| 310 | tbody { |
| 311 | @for c in s.collaborators { |
| 312 | tr { |
| 313 | td { a href=(format!("/{}", c.handle)) { (c.handle) } } |
| 314 | td { |
| 315 | form method="post" action=(format!("{base}/settings/collaborators")) .row { |
| 316 | input type="hidden" name="_csrf" value=(s.csrf); |
| 317 | input type="hidden" name="handle" value=(c.handle); |
| 318 | select name="role" { |
| 319 | @for r in ["read", "write", "maintain", "admin"] { |
| 320 | option value=(r) selected[role_str(c.role) == r] { (r) } |
| 321 | } |
| 322 | } |
| 323 | button .btn type="submit" { "Update" } |
| 324 | } |
| 325 | } |
| 326 | td { |
| 327 | form method="post" action=(format!("{base}/settings/collaborators/remove")) { |
| 328 | input type="hidden" name="_csrf" value=(s.csrf); |
| 329 | input type="hidden" name="handle" value=(c.handle); |
| 330 | button .btn.btn-danger type="submit" { "Remove" } |
| 331 | } |
| 332 | } |
| 333 | } |
| 334 | } |
| 335 | } |
| 336 | } |
| 337 | } |
| 338 | |
| 339 | form method="post" action=(format!("{base}/settings/collaborators")) .stack { |
| 340 | input type="hidden" name="_csrf" value=(s.csrf); |
| 341 | div .field { |
| 342 | label for="handle" { "Add a collaborator" } |
| 343 | input type="text" id="handle" name="handle" required |
| 344 | pattern="[a-z0-9][a-z0-9-]*" maxlength="39" placeholder="handle"; |
| 345 | } |
| 346 | div .field { |
| 347 | label for="role" { "Role" } |
| 348 | select id="role" name="role" { |
| 349 | option value="read" { "read — browse and comment" } |
| 350 | option value="write" selected { "write — also push" } |
| 351 | option value="maintain" { "maintain — also manage changes and settings" } |
| 352 | option value="admin" { "admin — also delete" } |
| 353 | } |
| 354 | } |
| 355 | button .btn.btn-primary type="submit" { "Add" } |
| 356 | } |
| 357 | } |
| 358 | } |
| 359 | } |
| 360 | |
| 361 | fn bookmarks_tab(s: &RepoSettings<'_>) -> Markup { |
| 362 | let base = s.ctx.base(); |
| 363 | html! { |
| 364 | div .panel { |
| 365 | h2 { "Bookmarks" } |
| 366 | p .dim { |
| 367 | "A protected bookmark cannot be deleted or force-updated by a push. \ |
| 368 | The default bookmark is always protected." |
| 369 | } |
| 370 | |
| 371 | @if s.bookmarks.is_empty() { |
| 372 | p .hint { "Nothing has been pushed yet." } |
| 373 | } @else { |
| 374 | table .listing { |
| 375 | thead { tr { th scope="col" { "Bookmark" } th scope="col" { "Protected" } th scope="col" {} } } |
| 376 | tbody { |
| 377 | @for b in s.bookmarks { |
| 378 | tr { |
| 379 | td { |
| 380 | code { (b.name) } |
| 381 | @if b.is_default { " " span .chip { "default" } } |
| 382 | } |
| 383 | td { @if b.protected || b.is_default { "yes" } @else { "no" } } |
| 384 | td { |
| 385 | @if !b.is_default { |
| 386 | form method="post" action=(format!("{base}/settings/bookmarks")) { |
| 387 | input type="hidden" name="_csrf" value=(s.csrf); |
| 388 | input type="hidden" name="name" value=(b.name); |
| 389 | input type="hidden" name="protected" |
| 390 | value=(if b.protected { "0" } else { "1" }); |
| 391 | button .btn type="submit" { |
| 392 | @if b.protected { "Unprotect" } @else { "Protect" } |
| 393 | } |
| 394 | } |
| 395 | } @else { |
| 396 | span .faint { "always" } |
| 397 | } |
| 398 | } |
| 399 | } |
| 400 | } |
| 401 | } |
| 402 | } |
| 403 | } |
| 404 | } |
| 405 | } |
| 406 | } |
| 407 | |
| 408 | fn danger_tab(s: &RepoSettings<'_>) -> Markup { |
| 409 | let base = s.ctx.base(); |
| 410 | let full = format!("{}/{}", s.ctx.owner, s.ctx.repo.name); |
| 411 | html! { |
| 412 | div .panel.panel-danger { |
| 413 | h2 { "Danger zone" } |
| 414 | |
| 415 | form method="post" action=(format!("{base}/settings/archive")) .stack { |
| 416 | input type="hidden" name="_csrf" value=(s.csrf); |
| 417 | h3 { @if s.ctx.repo.archived { "Unarchive" } @else { "Archive" } } |
| 418 | p .dim { |
| 419 | "An archived repository is read-only: it still browses, but pushes \ |
| 420 | are refused." |
| 421 | } |
| 422 | input type="hidden" name="archived" value=(if s.ctx.repo.archived { "0" } else { "1" }); |
| 423 | button .btn type="submit" { |
| 424 | @if s.ctx.repo.archived { "Unarchive repository" } @else { "Archive repository" } |
| 425 | } |
| 426 | } |
| 427 | |
| 428 | hr; |
| 429 | |
| 430 | @if s.ctx.access.can_delete() { |
| 431 | form method="post" action=(format!("{base}/settings/delete")) .stack { |
| 432 | input type="hidden" name="_csrf" value=(s.csrf); |
| 433 | h3 { "Delete this repository" } |
| 434 | p .dim { |
| 435 | "This removes the repository, its changes, its reviews, and every \ |
| 436 | comment on them. It cannot be undone and the stored objects are \ |
| 437 | deleted from disk." |
| 438 | } |
| 439 | div .field { |
| 440 | label for="confirm" { |
| 441 | "Type " code { (full) } " to confirm" |
| 442 | } |
| 443 | input type="text" id="confirm" name="confirm" required autocomplete="off"; |
| 444 | } |
| 445 | button .btn.btn-danger type="submit" { "Delete repository" } |
| 446 | } |
| 447 | } @else { |
| 448 | p .hint { "Only a repository administrator can delete it." } |
| 449 | } |
| 450 | } |
| 451 | } |
| 452 | } |
| 453 | |
| 454 | // ─── owner profile ─────────────────────────────────────────────────────────── |
| 455 | |
| 456 | pub struct ProfileRepo { |
| 457 | pub name: String, |
| 458 | pub description: Option<String>, |
| 459 | pub private: bool, |
| 460 | pub pushed_at: Option<chrono::DateTime<chrono::Utc>>, |
| 461 | } |
| 462 | |
| 463 | pub struct Profile<'a> { |
| 464 | pub handle: &'a str, |
| 465 | pub display_name: Option<&'a str>, |
| 466 | pub description: Option<&'a str>, |
| 467 | pub is_org: bool, |
| 468 | pub repos: &'a [ProfileRepo], |
| 469 | /// Org members, when the profile is an organization. |
| 470 | pub members: &'a [(String, df_db::models::OrgRole)], |
| 471 | pub joined: chrono::DateTime<chrono::Utc>, |
| 472 | } |
| 473 | |
| 474 | pub fn profile(p: Profile<'_>) -> Markup { |
| 475 | html! { |
| 476 | div .panel { |
| 477 | div .row { |
| 478 | h1 style="margin:0" { (p.display_name.unwrap_or(p.handle)) } |
| 479 | @if p.is_org { span .chip { "organization" } } |
| 480 | } |
| 481 | p .dim { "@" (p.handle) } |
| 482 | @if let Some(d) = p.description { p { (d) } } |
| 483 | p .hint { "Here since " (p.joined.format("%B %Y")) "." } |
| 484 | } |
| 485 | |
| 486 | div .panel { |
| 487 | h2 { "Repositories" } |
| 488 | @if p.repos.is_empty() { |
| 489 | p .hint { "Nothing visible to you here." } |
| 490 | } @else { |
| 491 | div .stack { |
| 492 | @for r in p.repos { |
| 493 | div .row { |
| 494 | a href=(format!("/{}/{}", p.handle, r.name)) { strong { (r.name) } } |
| 495 | @if r.private { span .chip { "private" } } |
| 496 | @if let Some(d) = &r.description { span .dim { (d) } } |
| 497 | span .faint style="margin-left:auto" { |
| 498 | @if let Some(t) = r.pushed_at { "pushed " (t.format("%Y-%m-%d")) } |
| 499 | } |
| 500 | } |
| 501 | } |
| 502 | } |
| 503 | } |
| 504 | } |
| 505 | |
| 506 | @if p.is_org && !p.members.is_empty() { |
| 507 | div .panel { |
| 508 | h2 { "Members" } |
| 509 | div .stack { |
| 510 | @for (handle, role) in p.members { |
| 511 | div .row { |
| 512 | a href=(format!("/{handle}")) { (handle) } |
| 513 | span .chip { (org_role_str(*role)) } |
| 514 | } |
| 515 | } |
| 516 | } |
| 517 | } |
| 518 | } |
| 519 | } |
| 520 | } |
| 521 | |
| 522 | // ─── helpers ───────────────────────────────────────────────────────────────── |
| 523 | |
| 524 | fn opt_date(t: Option<chrono::DateTime<chrono::Utc>>) -> String { |
| 525 | t.map(|t| t.format("%Y-%m-%d").to_string()) |
| 526 | .unwrap_or_else(|| "never".into()) |
| 527 | } |
| 528 | |
| 529 | pub fn role_str(r: df_db::models::RepoRole) -> &'static str { |
| 530 | use df_db::models::RepoRole::*; |
| 531 | match r { |
| 532 | Read => "read", |
| 533 | Write => "write", |
| 534 | Maintain => "maintain", |
| 535 | Admin => "admin", |
| 536 | } |
| 537 | } |
| 538 | |
| 539 | fn org_role_str(r: df_db::models::OrgRole) -> &'static str { |
| 540 | match r { |
| 541 | df_db::models::OrgRole::Member => "member", |
| 542 | df_db::models::OrgRole::Admin => "admin", |
| 543 | } |
| 544 | } |
544 lines · Rust