//! 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("