//! The pages the browser lands on when a login finishes. //! //! `atgc auth login` runs a loopback server for exactly one request. What it //! serves back is the only part of atgc a person sees rendered rather than //! printed, and it is HTML and SVG rather than Rust. //! //! Everything interpolated into a page goes through [`super::escape`] first. //! The success page carries a handle, a display name and an avatar URL that //! came from a profile record somebody else controls. //! //! Serving these is not here — see [`super`] on where the line falls. use super::brand::{self, FIELD_CSS, HEADER_CSS, Scheme}; use super::escape; use crate::clients::atproto::did::Identity; /// The page the browser lands on once the grant is in: who you signed in as, /// on the same field of DNA `atgc about` draws, prerendered by /// [`super::field::field`] and hung behind the card. /// /// The art is a `
` of some nineteen thousand cells, which is most of
/// this page's fifty-odd kilobytes and all of it goes over loopback once,
/// to a browser that has already been opened. Everything animated is a
/// property the compositor can handle on its own — an opacity breath on the
/// field and a light band drifting along the axis of the helices — so nothing
/// here relayouts, and `prefers-reduced-motion` turns both off.
pub(crate) fn success_page(who: &Identity, did: &str) -> String {
let field = brand::field_markup();
let favicon = brand::favicon_link();
let theme = brand::theme_css(Scheme::System);
let avatar = who
.avatar
.as_deref()
// The avatar is fetched by the browser — from Bluesky's CDN, or from
// the account's own PDS for the accounts that appview has never
// indexed — and this page has to look right whether or not that host
// answers: a profile whose picture 404s should lose the picture, not
// gain a broken-image glyph in the middle of the card.
.map(|url| {
format!(
r#"
"#,
escape(url)
)
})
.unwrap_or_default();
let name = escape(
who.display_name
.as_deref()
.unwrap_or_else(|| who.handle.as_deref().unwrap_or(did)),
);
// Linked to the account's profile rather than left as inert text — the
// one piece of this card most worth clicking through to, and bsky.app
// resolves a profile by handle or DID for any atproto account, not just
// ones with a Bluesky-specific presence.
let handle = format!(
r#"{}"#,
escape(who.handle.as_deref().unwrap_or(did)),
escape(&who.display())
);
let did = escape(did);
// The scheme is dropped for display; a PDS the browser doesn't already
// trust doesn't gain anything from looking clickable, and "via
// bsky.social" reads as the confirmation it is rather than a URL.
let pds = who
.pds
.as_deref()
.map(|p| {
p.trim_start_matches("https://")
.trim_start_matches("http://")
})
.map(|p| format!(r#"via {}"#, escape(p)))
.unwrap_or_default();
let header = brand::header_markup();
format!(
r#"
atgc: logged in
{favicon}
{field}
{header}
{avatar}
{name}
{handle}
{did}
{pds}
You're logged in to atgc. You can close this tab.
{CREDIT_LINE}
"#
)
}
/// Static, unlike everything above it on the card: the account signing in
/// is never the account that built atgc, so this has no business coming
/// from [`Identity`]. `permadeath.com` is already the identity every commit
/// on this branch is attributed to (see `.git/config`), so naming it again
/// here is not a new disclosure.
const CREDIT_LINE: &str = r#"Built by @permadeath.com · source on Tangled
"#;
/// The page the browser lands on when the login did not finish: what went
/// wrong, in the window the person is looking at.
///
/// `auth login` is the one command whose output is split across two screens,
/// and the browser holds the attention at the moment it ends. This page used
/// to be a fixed "Login failed — return to the terminal for details", which
/// is a second lookup for something atgc already knows, and it was worse than
/// that in the case that rewrote it: a panic inside the token exchange took
/// the process down before anything could be written, so the terminal held a
/// Rust backtrace and the browser held a connection error.
///
/// `detail` is a failure as Rust worded it — atgc's own error chain, or a
/// panic's location and payload — so it is prose from a dependency at worst
/// and goes through [`super::escape`] like every other interpolation here.
/// Nothing about the session reaches this page: there is no session, which
/// is the point.
pub(crate) fn failure_page(detail: &str) -> String {
let detail = escape(detail);
let favicon = brand::favicon_link();
let theme = brand::theme_css(Scheme::System);
let header = brand::header_markup();
format!(
r#"
atgc: login failed
{favicon}
{header}
Login failed
{detail}
No session was saved. atgc printed the same thing and exited 1: you can close this tab.
{CREDIT_LINE}
"#
)
}
#[cfg(test)]
mod tests {
use super::{CREDIT_LINE, failure_page, success_page};
use crate::clients::atproto::did::Identity;
use crate::html::brand::{MARK_SVG, SITE};
fn someone() -> Identity {
Identity {
handle: Some("permadeath.com".into()),
display_name: Some("permadeath".into()),
avatar: Some("https://cdn.bsky.app/img/avatar/plain/did:plc:abc/x@jpeg".into()),
pds: Some("https://bsky.social".into()),
}
}
/// Who you signed in as, on the field of DNA, with the avatar the profile
/// gave. The field is the prerendering: it has to arrive in the page as
/// `about` drew it, not as something the page rebuilds.
#[test]
fn the_page_shows_the_account_on_the_field() {
let page = success_page(&someone(), "did:plc:abc");
assert!(page.contains(&crate::html::field::field()), "no field");
assert!(page.contains("@permadeath.com") && page.contains("did:plc:abc"));
assert!(
page.contains(r#"
@permadeath.com"#),
"the handle was not linked"
);
// Both animations are declared, and both are given up under
// `prefers-reduced-motion` rather than only slowed down.
assert!(page.contains("@keyframes breathe") && page.contains("@keyframes drift"));
assert!(page.contains("prefers-reduced-motion"));
}
/// The card leads with the mark from `brand/` and the running version,
/// above the account it already showed — a header, not a redesign, so
/// this only ever adds an assertion to `the_page_shows_the_account_on_the_field`
/// rather than replacing one. Drawn from the page's own `--a`/`--t`/`--g`/
/// `--c`/`--bond` custom properties rather than a second, hardcoded
/// palette that could drift from the one `:root` and its dark-scheme
/// override already declare.
#[test]
fn the_card_leads_with_the_brand_mark_and_version() {
let page = success_page(&someone(), "did:plc:abc");
assert!(page.contains(MARK_SVG), "no mark");
for var in [
"var(--a)",
"var(--t)",
"var(--g)",
"var(--c)",
"var(--bond)",
] {
assert!(MARK_SVG.contains(var), "mark does not use {var}");
}
assert!(!MARK_SVG.contains('#'), "mark hardcodes a color");
assert!(
page.contains(&format!("v{}", env!("CARGO_PKG_VERSION"))),
"no version"
);
}
/// The mark and the wordmark are one link to the project's page, on both
/// pages, and the version is left outside it: a person who has just met
/// atgc through a browser window their own command opened is exactly who
/// the site is for, and the header is where anybody already looks for it.
/// It opens in a new tab, since closing this one is what both pages say
/// to do next.
#[test]
fn the_header_links_to_the_project_s_page() {
let link = format!(
r#""#
);
for page in [
success_page(&someone(), "did:plc:abc"),
failure_page("something went wrong"),
] {
assert!(page.contains(&link), "the header is not a link to {SITE}");
assert!(
page.contains("https://atgc.codes"),
"the link does not go to the site"
);
// Inside the link, the mark and the name; outside it, the
// version, which is a fact about this binary and not the mark.
let (linked, rest) = page.split_once("").expect("the header link closes");
let (_, linked) = linked.split_once(&link).expect("the link opens");
assert!(linked.contains(MARK_SVG), "the mark is outside the link");
assert!(
linked.contains(r#"atgc"#),
"the wordmark is outside the link"
);
assert!(
rest.contains(&format!(
r#"v{}"#,
env!("CARGO_PKG_VERSION")
)),
"the version is inside the link"
);
}
}
/// Static credit, unlike everything else on the card: it names who
/// built atgc, not who is signed into it, so it does not move when the
/// account does.
#[test]
fn the_card_credits_its_author_and_the_repo() {
let page = success_page(&someone(), "did:plc:abc");
assert!(page.contains(CREDIT_LINE), "no credit line");
assert!(
CREDIT_LINE.contains(r#"href="https://bsky.app/profile/permadeath.com""#),
"author not linked to bsky"
);
assert!(
CREDIT_LINE.contains(r#"href="https://tangled.org/permadeath.com/atgc""#),
"repo not linked to tangled"
);
}
/// A handle can be unresolvable — a DID document with no `alsoKnownAs`
/// entry the Bluesky profile lookup also missed — and the card still
/// says "(unknown handle)" per [`Identity::display`]. The link has to
/// go somewhere that isn't a 404, so it falls back to the DID, which
/// bsky.app resolves the same as a handle.
#[test]
fn an_unresolved_handle_links_by_did_instead() {
let anonymous = Identity {
handle: None,
display_name: None,
avatar: None,
pds: None,
};
let page = success_page(&anonymous, "did:plc:abc");
assert!(
page.contains(r#"(unknown handle)"#),
"no DID fallback link"
);
}
/// A display name is whatever the account typed into its Bluesky profile,
/// so it reaches this page as somebody else's markup unless it is escaped.
/// The page is served on loopback by a process holding fresh tokens; a
/// `".into()),
avatar: Some(r#"https://x/" onload="alert(1)"#.into()),
pds: Some("https://".into()),
};
let page = success_page(&hostile, "did:plc:abc");
assert!(!page.contains("");
assert!(!page.contains("