Jump to…
snowfeat: given a new redesign and emulated terminal homepagerxkspqmsoknz1mo
1//! Page bodies for the M0 surface.
2
3use chrono::{DateTime, Utc};
4use maud::{html, Markup};
5
6use df_db::models::User;
7
8use crate::views::change::change_chip;
9use crate::views::relative_time;
10
11/// One row of the public "shipping right now" feed.
12///
13/// Sourced from the event log rather than from `changes.updated_at`, so the row
14/// can say what actually happened — pushed, approved, merged, conflicted —
15/// instead of the uninformative "was touched" that a timestamp alone supports.
16pub struct FeedItem {
17 pub owner: String,
18 pub repo: String,
19 pub number: i64,
20 pub change_id: String,
21 pub synthetic: bool,
22 pub title: String,
23 /// `None` for an event by somebody with no Dogfood account — the indexer
24 /// records those, and dropping the row would misrepresent activity.
25 pub actor: Option<String>,
26 /// The name the commit itself carries, used when no account matched.
27 pub actor_name: Option<String>,
28 /// The event kind, e.g. `change.pushed`.
29 pub kind: String,
30 pub when: DateTime<Utc>,
31}
32
33/// An open change that is part of a stack — the "in flight now" list.
34pub struct StackItem {
35 pub owner: String,
36 pub repo: String,
37 pub number: i64,
38 pub change_id: String,
39 pub synthetic: bool,
40 pub title: String,
41 pub conflicted: bool,
42 pub when: DateTime<Utc>,
43}
44
45/// A bookmark and where it currently points.
46pub struct BookmarkItem {
47 pub owner: String,
48 pub repo: String,
49 pub name: String,
50 pub protected: bool,
51 pub updated_at: DateTime<Utc>,
52}
53
54/// The glyph and colour class for an event kind.
55///
56/// An unrecognised kind gets the neutral glyph rather than being dropped: an
57/// event the UI does not know about still happened.
58fn feed_glyph(kind: &str) -> (&'static str, &'static str) {
59 match kind {
60 "change.pushed" => ("↑", "is-push"),
61 "change.opened" | "change.reopened" | "change.ready" => ("○", "is-open"),
62 "change.reviewed" => ("✓", "is-review"),
63 "change.resolved" => ("✓", "is-review"),
64 "change.merged" => ("⤳", "is-merge"),
65 "change.conflicted" => ("◆", "is-conflict"),
66 "change.abandoned" => ("×", "is-abandon"),
67 _ => ("●", "is-open"),
68 }
69}
70
71/// Render whoever is responsible for something.
72///
73/// Three cases, in descending order of what we actually know:
74/// a matched account links to its profile; an unmatched commit shows the name
75/// git recorded, which is a real person even though we cannot link them; and
76/// only a commit with no author name at all falls through to "someone".
77///
78/// The middle case is the common one on a fresh instance — an account links to
79/// commits by email, and until the account records an email nothing matches —
80/// so showing the git name rather than "someone" is the difference between a
81/// readable history and an anonymous one.
82pub fn actor(handle: Option<&str>, name: Option<&str>) -> Markup {
83 html! {
84 span .feed-actor { (crate::views::person(handle, name)) }
85 }
86}
87
88/// The pitch, stated as a diff. Deliberately not configurable: it is copy.
89const COMPARISONS: &[(&str, &str)] = &[
90 ("detached HEAD, lost work", "every state is recoverable"),
91 ("rebase hell across a stack", "stacks rebase themselves"),
92 ("merge conflicts block you", "conflicts are just a state"),
93 ("force-push rewrites history", "stable ids survive rewrites"),
94];
95
96const TICKER: &[&str] = &[
97 "changes, not commits",
98 "conflicts are first-class",
99 "stacks that actually stack",
100 "stable change ids",
101 "undo anything",
102 "no staging area",
103];
104
105/// Everything the landing page renders.
106pub struct Landing<'a> {
107 /// The instance's own clone URL, e.g. `jj git clone https://host/owner/repo`.
108 pub clone_hint: &'a str,
109 /// `owner/name` of a real public repository, used in the example session so
110 /// the transcript names something a visitor can actually go and open.
111 pub sample_repo: Option<&'a str>,
112 pub feed: &'a [FeedItem],
113 pub repos: &'a [RepoSummary],
114 /// Open public changes that are part of a stack.
115 pub in_flight: &'a [StackItem],
116 /// Recently moved bookmarks across public repositories.
117 pub bookmarks: &'a [BookmarkItem],
118}
119
120/// Landing page for signed-out visitors.
121pub fn landing(l: Landing<'_>) -> Markup {
122 let now = Utc::now();
123 let first_repo = l.repos.first().map(|r| format!("/{}/{}", r.owner, r.name));
124
125 html! {
126 section .band.band-surface.hero aria-labelledby="hero-h" {
127 div .wrap {
128 div .hero-body {
129 span .hero-eyebrow.label-condensed { "code hosting on jujutsu" }
130 h1 #hero-h .display.hero-title { "A branch is a pointer. Your work is not." }
131 p .hero-lede {
132 "Push with " span .mono { "jj git push" } " and review opens itself. \
133 The " span style="color:var(--text)" { "change" } " keeps one identity \
134 through every amend, rebase, and force-push."
135 }
136 div .hero-actions {
137 a .btn.btn-primary href="/login" { "Start for free" }
138 @if let Some(href) = &first_repo {
139 a .btn href=(href) { "Browse the code" }
140 }
141 }
142 div .clone-box {
143 span .prompt aria-hidden="true" { "$" }
144 code { (l.clone_hint) }
145 }
146 }
147 (terminal(l.sample_repo))
148 }
149 }
150
151 // Decorative taglines. Static text rather than a marquee — it wraps
152 // instead of scrolling, so nothing moves for a reader who did not ask
153 // for motion.
154 div .band.ticker aria-hidden="true" {
155 @for t in TICKER {
156 span .ticker-item {
157 span .ticker-dot { "◆" }
158 (t)
159 }
160 }
161 }
162
163 div .band { div .wrap {
164 div .columns {
165 section .columns-main aria-labelledby="feed-h" {
166 div .section-head {
167 h2 #feed-h style="margin:0;font-size:var(--text-lg);line-height:28px" { "Live" }
168 span .live-indicator {
169 span .live-dot aria-hidden="true" {}
170 "public activity"
171 }
172 }
173 @if l.feed.is_empty() {
174 div .empty {
175 h2 { "Nothing public yet" }
176 p { "Activity in public repositories will show up here." }
177 }
178 } @else {
179 ul .feed {
180 @for item in l.feed { (feed_row(item, now)) }
181 }
182 div .feed-foot {
183 span {
184 "Every row is a change id. The commit behind it may be \
185 rewritten; the row will not move."
186 }
187 }
188 }
189 }
190
191 aside .columns-aside {
192 @if !l.in_flight.is_empty() {
193 section .aside-block aria-labelledby="inflight-h" {
194 h2 #inflight-h .label-condensed { "In flight now" }
195 @for s in l.in_flight { (stack_mini_row(s, now)) }
196 }
197 }
198 @if !l.bookmarks.is_empty() {
199 section .aside-block aria-labelledby="marks-h" {
200 h2 #marks-h .label-condensed { "Bookmarks" }
201 @for b in l.bookmarks { (bookmark_line(b, now)) }
202 }
203 }
204 }
205 }
206 } }
207
208 section .band.band-surface aria-labelledby="why-h" {
209 div .wrap {
210 div .band-head {
211 h2 #why-h { "Why switch" }
212 span .band-note { "Four things a branch pointer cannot represent." }
213 }
214 ul .compare {
215 @for (them, us) in COMPARISONS {
216 li .compare-item {
217 span .compare-them {
218 span .compare-sign aria-hidden="true" { "−" }
219 (them)
220 }
221 span .compare-us {
222 span .compare-sign aria-hidden="true" { "+" }
223 (us)
224 }
225 }
226 }
227 }
228 }
229 }
230
231 @if !l.repos.is_empty() {
232 section .band aria-labelledby="repos-h" {
233 div .wrap {
234 div .band-head {
235 h2 #repos-h { "Explore public repos" }
236 span .band-note {
237 "No account needed to read a repo, a change, or a stack."
238 }
239 }
240 div .repo-cards {
241 @for r in l.repos { (repo_card(r, now)) }
242 }
243 }
244 }
245 }
246
247 section .band.band-surface.cta aria-labelledby="cta-h" {
248 div .wrap {
249 div {
250 h2 #cta-h .cta-title { "Stop fighting your version control." }
251 p .cta-lede {
252 "Bring your team to a forge that matches how you already work."
253 }
254 }
255 span .spacer {}
256 div .hero-actions {
257 a .btn.btn-primary href="/login" { "Start for free" }
258 @if let Some(href) = &first_repo {
259 a .btn href=(href) { "Browse a repo first" }
260 }
261 }
262 }
263 }
264 }
265}
266
267/// The hero's terminal panel.
268///
269/// A transcript of a `jj` session that animates like a live terminal: commands
270/// type out character by character, and output lines reveal after each command
271/// finishes. The animation is driven by `terminal.js` using data attributes
272/// on each line, respects `prefers-reduced-motion`, and only plays once when
273/// the terminal scrolls into view.
274fn terminal(sample_repo: Option<&str>) -> Markup {
275 let repo = sample_repo.unwrap_or("your-org/your-repo");
276
277 html! {
278 figure .term {
279 div .term-bar {
280 span .term-dots aria-hidden="true" { span {} span {} span {} }
281 span .term-host { "~/" (repo.rsplit('/').next().unwrap_or("repo")) }
282 span .spacer {}
283 figcaption .label-condensed { "example session" }
284 }
285 pre .term-body data-term-animate="" {
286 span .term-cmd data-term-line="" data-term-cmd="" data-term-text="jj new main -m 'Cache shortest-unique prefixes per repo'" { "jj new main -m 'Cache shortest-unique prefixes per repo'" } "\n"
287 span .term-out data-term-line="" { "Working copy now at: vlrxqmpd 8f21c0ab (empty) Cache shortest-unique prefixes" } "\n"
288 span .term-out.is-faint data-term-line="" { "Parent commit : mrukztqx c40d1f8 main | Drop the legacy branch-tip fallback" } "\n"
289 span data-term-line="" data-term-blank="" { "" } "\n"
290 span .term-cmd data-term-line="" data-term-cmd="" data-term-text="jj status" { "jj status" } "\n"
291 span .term-out data-term-line="" { "Working copy changes:" } "\n"
292 span .term-out.is-add data-term-line="" { "M crates/store/prefix.rs" } "\n"
293 span .term-out.is-add data-term-line="" { "A crates/store/prefix_cache.rs" } "\n"
294 span data-term-line="" data-term-blank="" { "" } "\n"
295 span .term-cmd data-term-line="" data-term-cmd="" data-term-text="jj git push --change @" { "jj git push --change @" } "\n"
296 span .term-out data-term-line="" { "Changes to push to origin:" } "\n"
297 span .term-out.is-add data-term-line="" { " Add bookmark push-vlrxqmpd to 8f21c0ab" } "\n"
298 span .term-out.is-action data-term-line="" { "remote: change vlrx is open for review" } "\n"
299 span .term-out.is-action data-term-line="" { "remote: /" (repo) "/changes/vlrx" } "\n"
300 span data-term-line="" data-term-blank="" { "" } "\n"
301 span .term-cmd data-term-line="" data-term-cmd="" data-term-text="jj log -r 'stack(@)'" { "jj log -r 'stack(@)'" } "\n"
302 span .term-out.is-conflict data-term-line="" { "◆ zmltxruqpvks conflict in revset.rs" } "\n"
303 span .term-out.is-open data-term-line="" { "○ wpqvkrtnmzox approved" } "\n"
304 span .term-out.is-identity data-term-line="" { "@ vlrxqmpdtwzk open for review" } "\n"
305 span .term-out.is-faint data-term-line="" { "┴─ main c40d1f8" } "\n"
306 }
307 div .term-foot {
308 "Push a bookmark, the change opens for review. No web form, no PR button."
309 }
310 }
311 }
312}
313
314/// One feed row.
315///
316/// The exact timestamp goes in `title` because the visible label is coarse on
317/// purpose — see `relative_time`.
318fn feed_row(item: &FeedItem, now: DateTime<Utc>) -> Markup {
319 let href = format!("/{}/{}/changes/{}", item.owner, item.repo, item.number);
320 let (glyph, class) = feed_glyph(&item.kind);
321
322 html! {
323 li .feed-row {
324 span .feed-glyph.(class) aria-hidden="true" { (glyph) }
325 a .feed-repo href=(format!("/{}/{}", item.owner, item.repo)) {
326 span .owner { (item.owner) }
327 "/" (item.repo)
328 }
329 span .feed-verb {
330 (actor(item.actor.as_deref(), item.actor_name.as_deref()))
331 " " (activity_verb(&item.kind))
332 }
333 a .feed-title href=(href) { (item.title) }
334 (change_chip(&item.change_id, item.synthetic))
335 span .feed-age title=(item.when.format("%Y-%m-%d %H:%M UTC").to_string()) {
336 (relative_time(item.when, now))
337 }
338 }
339 }
340}
341
342/// A change in an aside list, with the stack rail beside it.
343fn stack_mini_row(s: &StackItem, now: DateTime<Utc>) -> Markup {
344 let href = format!("/{}/{}/changes/{}", s.owner, s.repo, s.number);
345 let (glyph, class) = if s.conflicted {
346 ("◆", "is-conflict")
347 } else {
348 ("○", "is-open")
349 };
350
351 html! {
352 a .mini-row href=(href) {
353 span .mini-rail aria-hidden="true" {}
354 span .mini-glyph.(class) aria-hidden="true" { (glyph) }
355 (change_chip(&s.change_id, s.synthetic))
356 span .mini-title { (s.title) }
357 span .mini-age title=(s.when.format("%Y-%m-%d %H:%M UTC").to_string()) {
358 (relative_time(s.when, now))
359 }
360 }
361 }
362}
363
364/// A bookmark and when it last moved.
365///
366/// Links to its repository's bookmark list rather than to the bookmark itself:
367/// a bookmark has no page of its own, because it is a pointer, not a thing —
368/// which is the distinction the whole product turns on.
369fn bookmark_line(b: &BookmarkItem, now: DateTime<Utc>) -> Markup {
370 html! {
371 a .bookmark-line href=(format!("/{}/{}/bookmarks", b.owner, b.repo)) {
372 span .chip { (b.name) }
373 @if b.protected {
374 span .bookmark-flag { "protected" }
375 }
376 span .spacer {}
377 span .mini-age title=(b.updated_at.format("%Y-%m-%d %H:%M UTC").to_string()) {
378 (relative_time(b.updated_at, now))
379 }
380 }
381 }
382}
383
384fn repo_card(r: &RepoSummary, now: DateTime<Utc>) -> Markup {
385 html! {
386 a .repo-card href=(format!("/{}/{}", r.owner, r.name)) {
387 span .repo-card-name {
388 (r.owner) "/" (r.name)
389 @if r.private { " " span .chip { "private" } }
390 }
391 @if let Some(d) = &r.description {
392 span .repo-card-desc { (d) }
393 }
394 span .repo-card-meta {
395 @if r.open_changes > 0 {
396 span .is-open { "○ " (r.open_changes) }
397 }
398 @if r.conflicted > 0 {
399 span .is-conflict { "◆ " (r.conflicted) }
400 }
401 @if let Some(t) = r.pushed_at {
402 span .at-end title=(t.format("%Y-%m-%d %H:%M UTC").to_string()) {
403 (relative_time(t, now))
404 }
405 }
406 }
407 }
408 }
409}
410
411/// A change as it appears in a dashboard list.
412pub struct DashChange {
413 pub owner: String,
414 pub repo: String,
415 pub number: i64,
416 pub change_id: String,
417 pub synthetic: bool,
418 pub title: String,
419 pub state: String,
420 pub conflicted: bool,
421 pub author: Option<String>,
422 /// The name the commit itself carries, used when no account matched.
423 pub author_name: Option<String>,
424 pub updated_at: DateTime<Utc>,
425}
426
427/// One row of the watched-activity feed.
428pub struct ActivityItem {
429 pub owner: String,
430 pub repo: String,
431 pub actor: Option<String>,
432 pub kind: String,
433 /// The change this event was about, when it was about one.
434 pub change_number: Option<i64>,
435 pub change_title: Option<String>,
436 pub when: DateTime<Utc>,
437}
438
439/// Everything the dashboard renders.
440pub struct Dashboard<'a> {
441 pub user: &'a User,
442 /// Open changes the viewer did not write and has not yet reviewed.
443 pub awaiting: &'a [DashChange],
444 pub mine: &'a [DashChange],
445 pub activity: &'a [ActivityItem],
446 pub repos: &'a [RepoSummary],
447}
448
449/// Signed-in dashboard.
450///
451/// The same two-column shape as the landing page, with the marketing bands
452/// removed and the personalised lists in their place. The aside answers "what
453/// is waiting on me" first, because that is the only question a dashboard is
454/// actually for.
455pub fn dashboard(d: Dashboard<'_>) -> Markup {
456 let now = Utc::now();
457
458 html! {
459 div .dash-head {
460 div {
461 h1 .dash-title {
462 "Welcome back, " span .dash-name { (d.user.label()) }
463 }
464 p .dim.section-note { "Here's what needs you across your repositories." }
465 }
466 span .spacer {}
467 a .btn.btn-primary href="/new" { "New repository" }
468 }
469
470 @if d.repos.is_empty() {
471 div .empty {
472 h2 { "No repositories yet" }
473 p { "Create one, then push to it with " code { "jj git push" } " or " code { "git push" } "." }
474 p { a .btn.btn-primary href="/new" { "New repository" } }
475 }
476 } @else {
477 div .columns {
478 div .columns-main {
479 section .dash-section aria-labelledby="awaiting-h" {
480 div .section-head {
481 h2 #awaiting-h .label-condensed { "Awaiting your review" }
482 span .live-indicator.tnum { (d.awaiting.len()) }
483 }
484 @if d.awaiting.is_empty() {
485 p .dim.section-note { "Nothing is waiting on you." }
486 } @else {
487 ul .feed {
488 @for c in d.awaiting { (dash_change_row(c, now)) }
489 }
490 }
491 }
492
493 section .dash-section aria-labelledby="mine-h" {
494 div .section-head {
495 h2 #mine-h .label-condensed { "Your open changes" }
496 }
497 @if d.mine.is_empty() {
498 p .dim.section-note { "You have no open changes." }
499 } @else {
500 ul .feed {
501 @for c in d.mine { (dash_change_row(c, now)) }
502 }
503 }
504 }
505
506 section .dash-section aria-labelledby="activity-h" {
507 div .section-head {
508 h2 #activity-h .label-condensed { "Watched activity" }
509 }
510 @if d.activity.is_empty() {
511 p .dim.section-note { "No recent activity in your repositories." }
512 } @else {
513 ul .activity {
514 @for a in d.activity { (activity_row(a, now)) }
515 }
516 }
517 }
518 }
519
520 aside .columns-aside aria-labelledby="dash-repos-h" {
521 section .aside-block {
522 h2 #dash-repos-h .label-condensed { "Your repositories" }
523 div .repo-cards style="grid-template-columns:minmax(0,1fr)" {
524 @for r in d.repos { (repo_card(r, now)) }
525 a .repo-card-new href="/new" { "new repository" }
526 }
527 }
528 }
529 }
530 }
531 }
532}
533
534/// A change in one of the dashboard's lists.
535///
536/// Same row grammar as the public feed — glyph, where, who, what, id, when —
537/// so the two lists scan identically. Here the glyph is the change's *state*
538/// rather than an event kind, because these rows are things that are still
539/// true, not things that happened.
540fn dash_change_row(c: &DashChange, now: DateTime<Utc>) -> Markup {
541 let href = format!("/{}/{}/changes/{}", c.owner, c.repo, c.number);
542 let (glyph, class) = match (c.conflicted, c.state.as_str()) {
543 (true, _) => ("◆", "is-conflict"),
544 (_, "merged") => ("⤳", "is-merge"),
545 (_, "abandoned") => ("×", "is-abandon"),
546 (_, "draft") => ("·", "is-abandon"),
547 _ => ("○", "is-review"),
548 };
549
550 html! {
551 li .feed-row {
552 span .feed-glyph.(class) aria-hidden="true" { (glyph) }
553 a .feed-repo href=(format!("/{}/{}", c.owner, c.repo)) {
554 span .owner { (c.owner) }
555 "/" (c.repo)
556 }
557 span .feed-verb {
558 (actor(c.author.as_deref(), c.author_name.as_deref()))
559 }
560 a .feed-title href=(href) { (c.title) }
561 (change_chip(&c.change_id, c.synthetic))
562 span .feed-age title=(c.updated_at.format("%Y-%m-%d %H:%M UTC").to_string()) {
563 (relative_time(c.updated_at, now))
564 }
565 }
566 }
567}
568
569fn activity_row(a: &ActivityItem, now: DateTime<Utc>) -> Markup {
570 let repo_href = format!("/{}/{}", a.owner, a.repo);
571
572 html! {
573 li .activity-row {
574 @match a.actor.as_deref() {
575 Some(h) => a .activity-actor href=(format!("/{h}")) { (h) },
576 None => span .activity-actor.faint { "someone" },
577 }
578 span .dim { " " (activity_verb(&a.kind)) " " }
579 @if let (Some(n), Some(t)) = (a.change_number, &a.change_title) {
580 a href=(format!("{repo_href}/changes/{n}")) { (t) }
581 span .dim { " in " }
582 }
583 a .mono.faint href=(repo_href) { (a.owner) "/" (a.repo) }
584 span .faint.tnum.activity-when
585 title=(a.when.format("%Y-%m-%d %H:%M UTC").to_string()) {
586 (relative_time(a.when, now))
587 }
588 }
589 }
590}
591
592/// The verb phrase for an event kind.
593///
594/// Unknown kinds fall through to the raw kind rather than being dropped or
595/// silently relabelled — the same rule the change timeline follows, and for the
596/// same reason: an event the UI does not recognise still happened.
597fn activity_verb(kind: &str) -> &str {
598 match kind {
599 "change.opened" => "opened",
600 "change.pushed" => "pushed to",
601 "change.rebased" => "rewrote",
602 "change.conflicted" => "hit a conflict on",
603 "change.resolved" => "resolved conflicts on",
604 "change.merged" => "merged",
605 "change.abandoned" => "abandoned",
606 "change.reopened" => "reopened",
607 "change.drafted" => "drafted",
608 "change.ready" => "marked ready",
609 "change.reviewed" => "reviewed",
610 "comments.rebased" => "re-anchored comments on",
611 other => other,
612 }
613}
614
615pub struct RepoSummary {
616 pub owner: String,
617 pub name: String,
618 pub description: Option<String>,
619 pub private: bool,
620 pub open_changes: i64,
621 pub conflicted: i64,
622 /// `None` for a repository nobody has pushed to yet.
623 pub pushed_at: Option<DateTime<Utc>>,
624}
625
626/// The sign-in page.
627///
628/// SSO is the only method that exists. Passkeys and self-service sign-up are
629/// rendered as explicitly disabled rather than omitted: the design shows them,
630/// and a disabled control that says why is more honest than a live-looking
631/// button that does nothing. They carry `aria-disabled` and no `href`, so they
632/// are not in the tab order as if they were usable.
633pub fn signin(sso_href: &str, clone_hint: &str, sample_repo: Option<&str>) -> Markup {
634 html! {
635 div .signin {
636 div .signin-intro {
637 h1 .signin-title { "Sign in to Dogfood" }
638 p .dim.measure {
639 "Reading public repositories needs no account. Sign in to push, \
640 review, and open issues."
641 }
642 }
643
644 div .signin-card {
645 a .btn.btn-primary.btn-block href=(sso_href) {
646 span aria-hidden="true" .mono { "↗" }
647 " Continue with your SSO provider"
648 }
649
650 div .signin-or {
651 span .signin-rule aria-hidden="true" {}
652 span .label-condensed { "or" }
653 span .signin-rule aria-hidden="true" {}
654 }
655
656 // Present but not offered. A live-looking control that silently
657 // does nothing is worse than one that says why it cannot.
658 span .btn.btn-block.is-disabled aria-disabled="true"
659 title="Email sign-in links are not available on this instance" {
660 "Email me a sign-in link"
661 }
662 span .btn.btn-block.is-disabled aria-disabled="true"
663 title="Passkey sign-in is not available on this instance yet" {
664 "Continue with a passkey"
665 }
666 }
667
668 div .signin-note {
669 span .label-condensed { "No account yet" }
670 span .dim { "This instance is invitation-only — but you can read anything public first:" }
671 code .mono { (clone_hint) }
672 }
673
674 @if let Some(r) = sample_repo {
675 a href=(format!("/{r}")) { "Browse a repo instead →" }
676 }
677 }
678 }
679}
680
681/// Shown when a valid login belongs to somebody who is not admitted.
682///
683/// Deliberately says nothing about why, beyond that access is by invitation —
684/// enumerating the allowlist would be a disclosure.
685pub fn not_invited() -> Markup {
686 html! {
687 div .panel {
688 h1 { "This instance is invitation-only" }
689 p .lede {
690 "You signed in successfully, but this account has not been invited to \
691 Dogfood. Ask an administrator for an invitation."
692 }
693 form method="post" action="/logout" {
694 button .btn type="submit" { "Sign out" }
695 }
696 }
697 }
698}
699
700/// Handle chooser, shown when one cannot be derived from the OIDC claims.
701pub fn choose_handle(suggested: Option<&str>, csrf: &str, error: Option<&str>) -> Markup {
702 html! {
703 div .panel {
704 h1 { "Choose a handle" }
705 p .lede {
706 "Your handle appears in URLs, like "
707 code { "dogfood.sh/your-handle/your-repo" } "."
708 }
709 @if let Some(e) = error {
710 div .banner.banner-error role="alert" { (e) }
711 }
712 form method="post" action="/auth/handle" {
713 input type="hidden" name="_csrf" value=(csrf);
714 div .field {
715 label for="handle" { "Handle" }
716 input type="text" id="handle" name="handle"
717 value=(suggested.unwrap_or(""))
718 required
719 minlength="1" maxlength="39"
720 pattern="[a-z0-9][a-z0-9-]*"
721 autocomplete="off" autofocus;
722 p .hint { "Lowercase letters, digits and hyphens. Up to 39 characters." }
723 }
724 button .btn.btn-primary type="submit" { "Continue" }
725 }
726 }
727 }
728}
729
730/// One-time site-admin claim.
731pub fn setup(csrf: &str, error: Option<&str>) -> Markup {
732 html! {
733 div .panel {
734 h1 { "Claim site administrator" }
735 p .lede {
736 "Paste the setup token this instance printed to its logs on first boot. \
737 It can be used once."
738 }
739 @if let Some(e) = error {
740 div .banner.banner-error role="alert" { (e) }
741 }
742 form method="post" action="/setup" {
743 input type="hidden" name="_csrf" value=(csrf);
744 div .field {
745 label for="token" { "Setup token" }
746 input type="password" id="token" name="token" required autocomplete="off" autofocus;
747 }
748 button .btn.btn-primary type="submit" { "Claim" }
749 }
750 }
751 }
752}
753
754pub fn setup_done() -> Markup {
755 html! {
756 div .panel {
757 div .banner.banner-ok role="status" { "You are now a site administrator." }
758 p { a href="/" { "Go to the dashboard" } }
759 }
760 }
761}

761 lines · Rust