Jump to…
snowinitial commitqoxwzsukwmkx1mo
Matt W1//! Settings pages — the user's own, and a repository's (spec §7).
Matt W2//!
Matt W3//! Every form here is a plain `<form method="post">`. Spec §7 makes progressive
Matt W4//! enhancement a hard requirement, and settings are exactly where a
Matt W5//! JavaScript-only affordance would be most annoying to hit.
Matt W6//!
Matt W7//! Note the deletion forms POST rather than sending `DELETE`. The spec's route
Matt W8//! table says `DELETE /settings/keys/{id}`, and that route exists for API
Matt W9//! clients — but browsers cannot emit it from a form without JavaScript, so the
Matt W10//! POST alias is what the UI uses. Both are registered; both do the same thing.
Matt W11
Matt W12use maud::{html, Markup};
Matt W13
Matt W14use crate::repo_ctx::RepoContext;
Matt W15
Matt W16// ─── the user's own settings ─────────────────────────────────────────────────
Matt W17
Matt W18pub struct SshKeyRow {
Matt W19 pub id: uuid::Uuid,
Matt W20 pub name: String,
Matt W21 pub key_type: String,
Matt W22 pub fingerprint: String,
Matt W23 pub last_used_at: Option<chrono::DateTime<chrono::Utc>>,
Matt W24}
Matt W25
Matt W26pub struct TokenRow {
Matt W27 pub id: uuid::Uuid,
Matt W28 pub name: String,
Matt W29 pub prefix: String,
Matt W30 pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
Matt W31 pub last_used_at: Option<chrono::DateTime<chrono::Utc>>,
Matt W32}
Matt W33
Matt W34pub struct UserSettings<'a> {
Matt W35 pub user: &'a df_db::models::User,
Matt W36 pub csrf: &'a str,
Matt W37 pub keys: &'a [SshKeyRow],
Matt W38 pub tokens: &'a [TokenRow],
Matt W39 /// A token's plaintext, shown exactly once immediately after minting it.
Matt W40 pub new_token: Option<&'a str>,
Matt W41 pub error: Option<&'a str>,
Matt W42 pub notice: Option<&'a str>,
Matt W43 pub ssh_host: &'a str,
Matt W44}
Matt W45
Matt W46pub fn user_settings(s: UserSettings<'_>) -> Markup {
Matt W47 html! {
Matt W48 h1 { "Settings" }
Matt W49
Matt W50 @if let Some(e) = s.error { div .banner.banner-error role="alert" { (e) } }
Matt W51 @if let Some(n) = s.notice { div .banner.banner-ok role="status" { (n) } }
Matt W52
Matt W53 div .panel {
Matt W54 h2 { "Profile" }
Matt W55 dl .kv {
Matt W56 dt { "Handle" } dd { code { (s.user.handle) } }
Matt W57 dt { "Name" } dd { (s.user.display_name.as_deref().unwrap_or("—")) }
Matt W58 dt { "Email" } dd { (s.user.email.as_deref().unwrap_or("—")) }
Matt W59 dt { "Role" } dd { @if s.user.is_admin { "Site administrator" } @else { "Member" } }
Matt W60 }
Matt W61 p .hint {
Matt W62 "Your name and email come from the identity provider you sign in with, \
Matt W63 and change there rather than here."
Matt W64 }
Matt W65 }
Matt W66
Matt W67 // ─── SSH keys ────────────────────────────────────────────────────────
Matt W68 div .panel {
Matt W69 h2 { "SSH keys" }
Matt W70 p .dim {
Matt W71 "Push over SSH with "
Matt W72 code { "jj git push" } " or " code { "git push" } ". Clone URLs look like "
Matt W73 code { (s.ssh_host) }
Matt W74 "."
Matt W75 }
Matt W76
Matt W77 @if s.keys.is_empty() {
Matt W78 p .hint { "No keys yet." }
Matt W79 } @else {
Matt W80 table .listing {
Matt W81 thead {
Matt W82 tr { th scope="col" { "Name" } th scope="col" { "Fingerprint" } th scope="col" { "Last used" } th scope="col" {} }
Matt W83 }
Matt W84 tbody {
Matt W85 @for k in s.keys {
Matt W86 tr {
Matt W87 td { (k.name) " " span .chip { (k.key_type) } }
Matt W88 td { code .faint { (k.fingerprint) } }
Matt W89 td .faint { (opt_date(k.last_used_at)) }
Matt W90 td {
Matt W91 form method="post" action=(format!("/settings/keys/{}/delete", k.id)) {
Matt W92 input type="hidden" name="_csrf" value=(s.csrf);
Matt W93 button .btn.btn-danger type="submit" { "Remove" }
Matt W94 }
Matt W95 }
Matt W96 }
Matt W97 }
Matt W98 }
Matt W99 }
Matt W100 }
Matt W101
Matt W102 form method="post" action="/settings/keys" .stack {
Matt W103 input type="hidden" name="_csrf" value=(s.csrf);
Matt W104 div .field {
Matt W105 label for="key" { "New key" }
Matt W106 textarea id="key" name="key" rows="4" required
Matt W107 placeholder="ssh-ed25519 AAAA… you@your-machine" {}
Matt W108 p .hint {
Matt W109 "Paste the contents of your "
Matt W110 code { ".pub" }
Matt W111 " file — never the private key."
Matt W112 }
Matt W113 }
Matt W114 div .field {
Matt W115 label for="key_name" { "Name (optional)" }
Matt W116 input type="text" id="key_name" name="name" maxlength="100"
Matt W117 placeholder="taken from the key's comment";
Matt W118 }
Matt W119 button .btn.btn-primary type="submit" { "Add key" }
Matt W120 }
Matt W121 }
Matt W122
Matt W123 // ─── access tokens ───────────────────────────────────────────────────
Matt W124 div .panel {
Matt W125 h2 { "Access tokens" }
Matt W126 p .dim {
Matt W127 "For Git over HTTPS. Use your handle as the username and the token as \
Matt W128 the password."
Matt W129 }
Matt W130
Matt W131 @if let Some(t) = s.new_token {
Matt W132 div .banner.banner-ok role="status" {
Matt W133 p { strong { "Copy this now — it is not shown again." } }
Matt W134 div .clone-box { code { (t) } }
Matt W135 }
Matt W136 }
Matt W137
Matt W138 @if s.tokens.is_empty() {
Matt W139 p .hint { "No tokens yet." }
Matt W140 } @else {
Matt W141 table .listing {
Matt W142 thead {
Matt W143 tr { th scope="col" { "Name" } th scope="col" { "Prefix" } th scope="col" { "Expires" } th scope="col" { "Last used" } th scope="col" {} }
Matt W144 }
Matt W145 tbody {
Matt W146 @for t in s.tokens {
Matt W147 tr {
Matt W148 td { (t.name) }
Matt W149 td { code .faint { (t.prefix) "…" } }
Matt W150 td .faint { (opt_date(t.expires_at)) }
Matt W151 td .faint { (opt_date(t.last_used_at)) }
Matt W152 td {
Matt W153 form method="post" action=(format!("/settings/tokens/{}/delete", t.id)) {
Matt W154 input type="hidden" name="_csrf" value=(s.csrf);
Matt W155 button .btn.btn-danger type="submit" { "Revoke" }
Matt W156 }
Matt W157 }
Matt W158 }
Matt W159 }
Matt W160 }
Matt W161 }
Matt W162 }
Matt W163
Matt W164 form method="post" action="/settings/tokens" .stack {
Matt W165 input type="hidden" name="_csrf" value=(s.csrf);
Matt W166 div .field {
Matt W167 label for="token_name" { "Name" }
Matt W168 input type="text" id="token_name" name="name" required maxlength="100"
Matt W169 placeholder="laptop";
Matt W170 }
Matt W171 div .field {
Matt W172 label for="expires_days" { "Expires in" }
Matt W173 select id="expires_days" name="expires_days" {
Matt W174 option value="30" { "30 days" }
Matt W175 option value="90" selected { "90 days" }
Matt W176 option value="365" { "1 year" }
Matt W177 option value="0" { "Never" }
Matt W178 }
Matt W179 }
Matt W180 button .btn.btn-primary type="submit" { "Generate token" }
Matt W181 }
Matt W182 }
Matt W183 }
Matt W184}
Matt W185
Matt W186// ─── repository settings ─────────────────────────────────────────────────────
Matt W187
Matt W188pub struct CollaboratorRow {
Matt W189 pub handle: String,
Matt W190 pub role: df_db::models::RepoRole,
Matt W191}
Matt W192
Matt W193pub struct BookmarkRow {
Matt W194 pub name: String,
Matt W195 pub protected: bool,
Matt W196 pub is_default: bool,
Matt W197}
Matt W198
Matt W199pub struct RepoSettings<'a> {
Matt W200 pub ctx: &'a RepoContext,
Matt W201 pub csrf: &'a str,
Matt W202 pub tab: &'a str,
Matt W203 pub collaborators: &'a [CollaboratorRow],
Matt W204 pub bookmarks: &'a [BookmarkRow],
Matt W205 pub size_bytes: u64,
Matt W206 pub error: Option<&'a str>,
Matt W207 pub notice: Option<&'a str>,
Matt W208}
Matt W209
Matt W210pub fn repo_settings(s: RepoSettings<'_>) -> Markup {
Matt W211 let base = s.ctx.base();
Matt W212 let settings = format!("{base}/settings");
Matt W213
Matt W214 html! {
Matt W215 @if let Some(e) = s.error { div .banner.banner-error role="alert" { (e) } }
Matt W216 @if let Some(n) = s.notice { div .banner.banner-ok role="status" { (n) } }
Matt W217
Matt W218 nav .subtabs aria-label="Settings sections" {
Matt W219 @for (key, label) in [("general", "General"), ("collaborators", "Collaborators"),
Matt W220 ("bookmarks", "Bookmarks"), ("danger", "Danger zone")] {
Matt W221 a href=(format!("{settings}?tab={key}"))
Matt W222 .active[s.tab == key]
Matt W223 aria-current=[(s.tab == key).then_some("page")] { (label) }
Matt W224 }
Matt W225 }
Matt W226
Matt W227 @match s.tab {
Matt W228 "collaborators" => (collaborators_tab(&s)),
Matt W229 "bookmarks" => (bookmarks_tab(&s)),
Matt W230 "danger" => (danger_tab(&s)),
Matt W231 _ => (general_tab(&s)),
Matt W232 }
Matt W233 }
Matt W234}
Matt W235
Matt W236fn general_tab(s: &RepoSettings<'_>) -> Markup {
Matt W237 let action = format!("{}/settings/general", s.ctx.base());
Matt W238 let private = matches!(s.ctx.repo.visibility, df_db::models::Visibility::Private);
Matt W239
Matt W240 html! {
Matt W241 div .panel {
Matt W242 h2 { "General" }
Matt W243 form method="post" action=(action) .stack {
Matt W244 input type="hidden" name="_csrf" value=(s.csrf);
Matt W245
Matt W246 div .field {
Matt W247 label for="description" { "Description" }
Matt W248 input type="text" id="description" name="description" maxlength="500"
Matt W249 value=(s.ctx.repo.description.as_deref().unwrap_or(""));
Matt W250 }
Matt W251
Matt W252 div .field {
Matt W253 label for="default_bookmark" { "Default bookmark" }
Matt W254 select id="default_bookmark" name="default_bookmark" {
Matt W255 @for b in s.bookmarks {
Matt W256 option value=(b.name) selected[b.is_default] { (b.name) }
Matt W257 }
Matt W258 // A repository with no pushes yet has no bookmark rows,
Matt W259 // so keep the configured name selectable.
Matt W260 @if s.bookmarks.is_empty() {
Matt W261 option value=(s.ctx.repo.default_bookmark) selected {
Matt W262 (s.ctx.repo.default_bookmark)
Matt W263 }
Matt W264 }
Matt W265 }
Matt W266 p .hint { "Where the code view opens, and what changes are measured against." }
Matt W267 }
Matt W268
Matt W269 div .field {
Matt W270 label {
Matt W271 input type="checkbox" name="private" value="1" checked[private];
Matt W272 " Private"
Matt W273 }
Matt W274 p .hint {
Matt W275 "A private repository is invisible to everyone but its collaborators — \
Matt W276 it returns the same 404 a nonexistent repository does."
Matt W277 }
Matt W278 }
Matt W279
Matt W280 button .btn.btn-primary type="submit" { "Save" }
Matt W281 }
Matt W282 }
Matt W283
Matt W284 div .panel {
Matt W285 h2 { "Storage" }
Matt W286 dl .kv {
Matt W287 dt { "On disk" } dd { (crate::views::repo::human_size(s.size_bytes)) }
Matt W288 dt { "Created" } dd { (s.ctx.repo.created_at.format("%Y-%m-%d")) }
Matt W289 dt { "Last push" } dd { (opt_date(s.ctx.repo.pushed_at)) }
Matt W290 }
Matt W291 }
Matt W292 }
Matt W293}
Matt W294
Matt W295fn collaborators_tab(s: &RepoSettings<'_>) -> Markup {
Matt W296 let base = s.ctx.base();
Matt W297 html! {
Matt W298 div .panel {
Matt W299 h2 { "Collaborators" }
Matt W300 p .dim {
Matt W301 "Effective access is the highest of a direct role here, an organization \
Matt W302 role, and repository ownership."
Matt W303 }
Matt W304
Matt W305 @if s.collaborators.is_empty() {
Matt W306 p .hint { "No collaborators. Only the owner can reach this repository." }
Matt W307 } @else {
Matt W308 table .listing {
Matt W309 thead { tr { th scope="col" { "User" } th scope="col" { "Role" } th scope="col" {} } }
Matt W310 tbody {
Matt W311 @for c in s.collaborators {
Matt W312 tr {
Matt W313 td { a href=(format!("/{}", c.handle)) { (c.handle) } }
Matt W314 td {
Matt W315 form method="post" action=(format!("{base}/settings/collaborators")) .row {
Matt W316 input type="hidden" name="_csrf" value=(s.csrf);
Matt W317 input type="hidden" name="handle" value=(c.handle);
Matt W318 select name="role" {
Matt W319 @for r in ["read", "write", "maintain", "admin"] {
Matt W320 option value=(r) selected[role_str(c.role) == r] { (r) }
Matt W321 }
Matt W322 }
Matt W323 button .btn type="submit" { "Update" }
Matt W324 }
Matt W325 }
Matt W326 td {
Matt W327 form method="post" action=(format!("{base}/settings/collaborators/remove")) {
Matt W328 input type="hidden" name="_csrf" value=(s.csrf);
Matt W329 input type="hidden" name="handle" value=(c.handle);
Matt W330 button .btn.btn-danger type="submit" { "Remove" }
Matt W331 }
Matt W332 }
Matt W333 }
Matt W334 }
Matt W335 }
Matt W336 }
Matt W337 }
Matt W338
Matt W339 form method="post" action=(format!("{base}/settings/collaborators")) .stack {
Matt W340 input type="hidden" name="_csrf" value=(s.csrf);
Matt W341 div .field {
Matt W342 label for="handle" { "Add a collaborator" }
Matt W343 input type="text" id="handle" name="handle" required
Matt W344 pattern="[a-z0-9][a-z0-9-]*" maxlength="39" placeholder="handle";
Matt W345 }
Matt W346 div .field {
Matt W347 label for="role" { "Role" }
Matt W348 select id="role" name="role" {
Matt W349 option value="read" { "read — browse and comment" }
Matt W350 option value="write" selected { "write — also push" }
Matt W351 option value="maintain" { "maintain — also manage changes and settings" }
Matt W352 option value="admin" { "admin — also delete" }
Matt W353 }
Matt W354 }
Matt W355 button .btn.btn-primary type="submit" { "Add" }
Matt W356 }
Matt W357 }
Matt W358 }
Matt W359}
Matt W360
Matt W361fn bookmarks_tab(s: &RepoSettings<'_>) -> Markup {
Matt W362 let base = s.ctx.base();
Matt W363 html! {
Matt W364 div .panel {
Matt W365 h2 { "Bookmarks" }
Matt W366 p .dim {
Matt W367 "A protected bookmark cannot be deleted or force-updated by a push. \
Matt W368 The default bookmark is always protected."
Matt W369 }
Matt W370
Matt W371 @if s.bookmarks.is_empty() {
Matt W372 p .hint { "Nothing has been pushed yet." }
Matt W373 } @else {
Matt W374 table .listing {
Matt W375 thead { tr { th scope="col" { "Bookmark" } th scope="col" { "Protected" } th scope="col" {} } }
Matt W376 tbody {
Matt W377 @for b in s.bookmarks {
Matt W378 tr {
Matt W379 td {
Matt W380 code { (b.name) }
Matt W381 @if b.is_default { " " span .chip { "default" } }
Matt W382 }
Matt W383 td { @if b.protected || b.is_default { "yes" } @else { "no" } }
Matt W384 td {
Matt W385 @if !b.is_default {
Matt W386 form method="post" action=(format!("{base}/settings/bookmarks")) {
Matt W387 input type="hidden" name="_csrf" value=(s.csrf);
Matt W388 input type="hidden" name="name" value=(b.name);
Matt W389 input type="hidden" name="protected"
Matt W390 value=(if b.protected { "0" } else { "1" });
Matt W391 button .btn type="submit" {
Matt W392 @if b.protected { "Unprotect" } @else { "Protect" }
Matt W393 }
Matt W394 }
Matt W395 } @else {
Matt W396 span .faint { "always" }
Matt W397 }
Matt W398 }
Matt W399 }
Matt W400 }
Matt W401 }
Matt W402 }
Matt W403 }
Matt W404 }
Matt W405 }
Matt W406}
Matt W407
Matt W408fn danger_tab(s: &RepoSettings<'_>) -> Markup {
Matt W409 let base = s.ctx.base();
Matt W410 let full = format!("{}/{}", s.ctx.owner, s.ctx.repo.name);
Matt W411 html! {
Matt W412 div .panel.panel-danger {
Matt W413 h2 { "Danger zone" }
Matt W414
Matt W415 form method="post" action=(format!("{base}/settings/archive")) .stack {
Matt W416 input type="hidden" name="_csrf" value=(s.csrf);
Matt W417 h3 { @if s.ctx.repo.archived { "Unarchive" } @else { "Archive" } }
Matt W418 p .dim {
Matt W419 "An archived repository is read-only: it still browses, but pushes \
Matt W420 are refused."
Matt W421 }
Matt W422 input type="hidden" name="archived" value=(if s.ctx.repo.archived { "0" } else { "1" });
Matt W423 button .btn type="submit" {
Matt W424 @if s.ctx.repo.archived { "Unarchive repository" } @else { "Archive repository" }
Matt W425 }
Matt W426 }
Matt W427
Matt W428 hr;
Matt W429
Matt W430 @if s.ctx.access.can_delete() {
Matt W431 form method="post" action=(format!("{base}/settings/delete")) .stack {
Matt W432 input type="hidden" name="_csrf" value=(s.csrf);
Matt W433 h3 { "Delete this repository" }
Matt W434 p .dim {
Matt W435 "This removes the repository, its changes, its reviews, and every \
Matt W436 comment on them. It cannot be undone and the stored objects are \
Matt W437 deleted from disk."
Matt W438 }
Matt W439 div .field {
Matt W440 label for="confirm" {
Matt W441 "Type " code { (full) } " to confirm"
Matt W442 }
Matt W443 input type="text" id="confirm" name="confirm" required autocomplete="off";
Matt W444 }
Matt W445 button .btn.btn-danger type="submit" { "Delete repository" }
Matt W446 }
Matt W447 } @else {
Matt W448 p .hint { "Only a repository administrator can delete it." }
Matt W449 }
Matt W450 }
Matt W451 }
Matt W452}
Matt W453
Matt W454// ─── owner profile ───────────────────────────────────────────────────────────
Matt W455
Matt W456pub struct ProfileRepo {
Matt W457 pub name: String,
Matt W458 pub description: Option<String>,
Matt W459 pub private: bool,
Matt W460 pub pushed_at: Option<chrono::DateTime<chrono::Utc>>,
Matt W461}
Matt W462
Matt W463pub struct Profile<'a> {
Matt W464 pub handle: &'a str,
Matt W465 pub display_name: Option<&'a str>,
Matt W466 pub description: Option<&'a str>,
Matt W467 pub is_org: bool,
Matt W468 pub repos: &'a [ProfileRepo],
Matt W469 /// Org members, when the profile is an organization.
Matt W470 pub members: &'a [(String, df_db::models::OrgRole)],
Matt W471 pub joined: chrono::DateTime<chrono::Utc>,
Matt W472}
Matt W473
Matt W474pub fn profile(p: Profile<'_>) -> Markup {
Matt W475 html! {
Matt W476 div .panel {
Matt W477 div .row {
Matt W478 h1 style="margin:0" { (p.display_name.unwrap_or(p.handle)) }
Matt W479 @if p.is_org { span .chip { "organization" } }
Matt W480 }
Matt W481 p .dim { "@" (p.handle) }
Matt W482 @if let Some(d) = p.description { p { (d) } }
Matt W483 p .hint { "Here since " (p.joined.format("%B %Y")) "." }
Matt W484 }
Matt W485
Matt W486 div .panel {
Matt W487 h2 { "Repositories" }
Matt W488 @if p.repos.is_empty() {
Matt W489 p .hint { "Nothing visible to you here." }
Matt W490 } @else {
Matt W491 div .stack {
Matt W492 @for r in p.repos {
Matt W493 div .row {
Matt W494 a href=(format!("/{}/{}", p.handle, r.name)) { strong { (r.name) } }
Matt W495 @if r.private { span .chip { "private" } }
Matt W496 @if let Some(d) = &r.description { span .dim { (d) } }
Matt W497 span .faint style="margin-left:auto" {
Matt W498 @if let Some(t) = r.pushed_at { "pushed " (t.format("%Y-%m-%d")) }
Matt W499 }
Matt W500 }
Matt W501 }
Matt W502 }
Matt W503 }
Matt W504 }
Matt W505
Matt W506 @if p.is_org && !p.members.is_empty() {
Matt W507 div .panel {
Matt W508 h2 { "Members" }
Matt W509 div .stack {
Matt W510 @for (handle, role) in p.members {
Matt W511 div .row {
Matt W512 a href=(format!("/{handle}")) { (handle) }
Matt W513 span .chip { (org_role_str(*role)) }
Matt W514 }
Matt W515 }
Matt W516 }
Matt W517 }
Matt W518 }
Matt W519 }
Matt W520}
Matt W521
Matt W522// ─── helpers ─────────────────────────────────────────────────────────────────
Matt W523
Matt W524fn opt_date(t: Option<chrono::DateTime<chrono::Utc>>) -> String {
Matt W525 t.map(|t| t.format("%Y-%m-%d").to_string())
Matt W526 .unwrap_or_else(|| "never".into())
Matt W527}
Matt W528
Matt W529pub fn role_str(r: df_db::models::RepoRole) -> &'static str {
Matt W530 use df_db::models::RepoRole::*;
Matt W531 match r {
Matt W532 Read => "read",
Matt W533 Write => "write",
Matt W534 Maintain => "maintain",
Matt W535 Admin => "admin",
Matt W536 }
Matt W537}
Matt W538
Matt W539fn org_role_str(r: df_db::models::OrgRole) -> &'static str {
Matt W540 match r {
Matt W541 df_db::models::OrgRole::Member => "member",
Matt W542 df_db::models::OrgRole::Admin => "admin",
Matt W543 }
Matt W544}

544 lines · Rust