Jump to…
snowinitial commitqoxwzsukwmkx1mo
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
12use maud::{html, Markup};
13
14use crate::repo_ctx::RepoContext;
15
16// ─── the user's own settings ─────────────────────────────────────────────────
17
18pub 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
26pub 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
34pub 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
46pub 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
188pub struct CollaboratorRow {
189 pub handle: String,
190 pub role: df_db::models::RepoRole,
191}
192
193pub struct BookmarkRow {
194 pub name: String,
195 pub protected: bool,
196 pub is_default: bool,
197}
198
199pub 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
210pub 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
236fn 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
295fn 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
361fn 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
408fn 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
456pub 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
463pub 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
474pub 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
524fn 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
529pub 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
539fn 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