diff --git a/server/src/auth/mod.rs b/server/src/auth/mod.rs index d4a85ef52c15dc35148e4c743f0d646cbbdb056d..5906f93b13853421e96a3c37bc9d8202a47842bf 100644 --- a/server/src/auth/mod.rs +++ b/server/src/auth/mod.rs @@ -94,63 +94,169 @@ fn alias_list(db: &durable::Db, uuid: &str) -> Vec { .unwrap_or_default() } -fn login_body( - session: Option<&session::SessionActor>, +fn alias_claim_forms(return_to: &str, submit_label: &str) -> Result { + let check_rpc = template_json_compact(&serde_json::json!({ + "action": "check_pseudonym", + "pseudonym": {"$form": "pseudonym"}, + })) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let claim_rpc = template_json_compact(&serde_json::json!({ + "action": "claim_pseudonym", + "pseudonym": {"$form": "pseudonym"}, + "return_to": return_to, + })) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(html! { + div class="alias-claim" { + form id="alias-check-form" method="POST" action="/ui" { + input type="hidden" name=(UI_RPC_FIELD) value=(check_rpc); + label for="alias-input" { "alias" } + input type="text" id="alias-input" name="pseudonym" autocomplete="off" + data-testid="alias-input" maxlength="64" placeholder="letters, numbers, _ -"; + p id="alias-status" class="muted" data-testid="alias-status" { "type to check availability" } + } + form id="alias-claim-form" method="POST" action="/ui" { + input type="hidden" name=(UI_RPC_FIELD) value=(claim_rpc); + input type="hidden" name="pseudonym" id="alias-claim-field" value=""; + button type="submit" class="btn-primary" data-testid="alias-claim" { (submit_label) } + } + } + }) +} + +fn signed_out_body(providers: &[(&str, String)]) -> Markup { + html! { + main class="panel login-page" { + section class="login-section" { + h1 { "sign in" } + p class="muted" { "link an account to vote under a lasting alias" } + @if providers.is_empty() { + p class="muted" { + "OAuth is not configured. Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET." + } + } @else { + ul class="oauth-provider-list" { + @for (name, href) in providers { + li { + a href=(href) class="btn-primary oauth-provider" + data-testid=(format!("oauth-{}", name.to_lowercase())) { + (format!("Continue with {name}")) + } + } + } + } + } + } + p class="login-back" { a href="/" { "← back" } } + } + } +} + +fn account_body( + actor: &session::SessionActor, aliases: &[String], providers: &[(&str, String)], + claim_forms: Markup, ) -> Markup { + let current = actor.pseudonym.trim(); html! { - main class="panel login-page" { - div class="login-grid" { - section class="login-oauth" { - h1 { "sign in" } - @if providers.is_empty() { - p class="muted" { - "OAuth is not configured. Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET." - } - } @else { - ul class="oauth-provider-list" { - @for (name, href) in providers { - li { - a href=(href) class="button oauth-provider" data-testid=(format!("oauth-{}", name.to_lowercase())) { - (format!("Continue with {name}")) + main class="panel login-page account-page" { + section class="login-section" { + h1 { "account" } + @if current.is_empty() { + p class="muted" { "finish setup by choosing an alias below" } + } @else { + p class="account-current" { + "voting as " + strong data-testid="account-current" { (current) } + } + } + p class="muted small" data-testid="account-weight" { + "trust weight " (format!("{:.1}", actor.trust_weight)) + " · rises when you link more OAuth providers" + } + } + + section class="login-section" { + h2 { "aliases" } + @if aliases.is_empty() { + p class="muted" data-testid="alias-list-empty" { "none yet — claim one below" } + } @else { + ul id="alias-list" class="alias-list" data-testid="alias-list" { + @for alias in aliases { + @let is_current = alias == current; + li class=(if is_current { "alias-item alias-current" } else { "alias-item" }) { + span class="alias-name" { (alias) } + @if is_current { + span class="alias-badge" data-testid="alias-current-badge" { "current" } + } @else { + form class="alias-switch" method="post" action="/auth/switch" + data-navigate="full" { + input type="hidden" name="pseudonym" value=(alias); + button type="submit" class="btn-secondary" + data-testid=(format!("alias-switch-{alias}")) { + "use" + } } } } } } - @if let Some(actor) = session { - p class="muted small" { - "session active · weight " (format!("{:.1}", actor.trust_weight)) - } - form method="post" action="/auth/logout" data-navigate="full" { - button type="submit" { "log out" } - } - } } - section class="login-aliases" { - h2 { "your aliases" } - ul id="alias-list" class="alias-list" { - @if aliases.is_empty() { - li class="muted" data-testid="alias-list-empty" { "none yet" } - } @else { - @for alias in aliases { - li { (alias) } + } + + section class="login-section" { + h2 { "add alias" } + p class="muted small" { "each alias is unique across sorter2" } + (claim_forms) + } + + @if !providers.is_empty() { + section class="login-section" { + h2 { "linked sign-in" } + p class="muted small" { "sign in again with the same provider to return to this account" } + ul class="oauth-provider-list" { + @for (name, href) in providers { + li { + a href=(href) class="btn-secondary oauth-provider" + data-testid=(format!("oauth-relink-{}", name.to_lowercase())) { + (format!("Re-link {name}")) + } } } } } } - p { a href="/" { "← back" } } + + section class="login-section login-actions" { + form method="post" action="/auth/logout" data-navigate="full" { + button type="submit" class="btn-secondary" data-testid="account-logout" { "log out" } + } + } + + p class="login-back" { a href="/" { "← back" } } } } } +fn login_body( + session: Option<&session::SessionActor>, + aliases: &[String], + providers: &[(&str, String)], + claim_forms: Option, +) -> Markup { + match (session, claim_forms) { + (Some(actor), Some(forms)) => account_body(actor, aliases, providers, forms), + _ => signed_out_body(providers), + } +} + pub async fn login_page( State(state): State, jar: CookieJar, Query(query): Query, -) -> Response { +) -> Result { let return_to = return_from_query_or_jar(&jar, query.return_to.as_deref()); let jar = jar.add(session::auth_return_cookie_value(&return_to)); @@ -164,16 +270,26 @@ pub async fn login_page( .unwrap_or_default(); let providers = oauth_providers(&base_url_from_env(state.cfg.port), &return_to); + let claim_forms = if session.is_some() { + Some(alias_claim_forms("/login", "claim alias")?) + } else { + None + }; + let markup = layout( - "login · sorter2", - login_body(session.as_ref(), &aliases, &providers), + if session.is_some() { + "account · sorter2" + } else { + "login · sorter2" + }, + login_body(session.as_ref(), &aliases, &providers, claim_forms), state.views.get_views("/login"), session .as_ref() .filter(|s| !s.pseudonym.trim().is_empty()) .map(|s| s.pseudonym.as_str()), ); - (jar, Html(markup.into_string())).into_response() + Ok((jar, Html(markup.into_string())).into_response()) } pub async fn alias_page( @@ -186,38 +302,17 @@ pub async fn alias_page( let db = state.projection_store.db(); let session = session::load_valid_session(db, &session_id).ok_or(StatusCode::UNAUTHORIZED)?; if session::session_has_pseudonym(&session) { - return Ok(Redirect::to(&return_to).into_response()); + // Already onboarded — manage aliases on the account page. + return Ok(Redirect::to("/login").into_response()); } - let check_rpc = template_json_compact(&serde_json::json!({ - "action": "check_pseudonym", - "pseudonym": {"$form": "pseudonym"}, - })) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let claim_rpc = template_json_compact(&serde_json::json!({ - "action": "claim_pseudonym", - "pseudonym": {"$form": "pseudonym"}, - "return_to": return_to, - })) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - + let claim_forms = alias_claim_forms(&return_to, "continue")?; let body = html! { main class="panel alias-page" { h1 { "choose alias" } p class="muted" { "pick a unique display name for your votes" } - form id="alias-check-form" method="POST" action="/ui" { - input type="hidden" name=(UI_RPC_FIELD) value=(check_rpc); - label { "alias" } - input type="text" id="alias-input" name="pseudonym" autocomplete="off" - data-testid="alias-input" maxlength="64"; - p id="alias-status" class="muted" data-testid="alias-status" { "type to check availability" } - } - form id="alias-claim-form" method="POST" action="/ui" { - input type="hidden" name=(UI_RPC_FIELD) value=(claim_rpc); - input type="hidden" name="pseudonym" id="alias-claim-field" value=""; - button type="submit" class="btn-primary" data-testid="alias-claim" { "continue" } - } - p { a href="/login" { "← back to login" } } + (claim_forms) + p class="login-back" { a href="/login" { "← back to login" } } } }; diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs index 3cc3d7bdf55b5cb5d009600f4ade1fcd201a410b..5b9b73c7ad24499737f0576b3603c3ff5355251e 100644 --- a/server/src/html/mod.rs +++ b/server/src/html/mod.rs @@ -149,7 +149,7 @@ pub(crate) fn layout(title: &str, body: Markup, views: u64, nav_user: Option<&st nav class="top-nav" { @if let Some(name) = nav_user { span class="top-nav-user" data-testid="nav-user" { (name) } - a href="/login" { "account" } + a href="/login" data-testid="nav-account" { "account" } form class="top-nav-logout" method="post" action="/auth/logout" data-navigate="full" { button type="submit" data-testid="nav-logout" { "log out" } } diff --git a/server/static/sorter.css b/server/static/sorter.css index 257280b6b490c65222e580a325b77351cac6cc6b..e7aca23b426d504938b90b5a25ad06b07843ba02 100644 --- a/server/static/sorter.css +++ b/server/static/sorter.css @@ -108,6 +108,152 @@ body { cursor: wait; } +.panel { + max-width: 36rem; + margin: 1.5rem auto; + padding: 1.25rem 1.5rem; + background: var(--panel); + border: 1px solid var(--border); +} + +.login-page h1, +.alias-page h1 { + margin: 0 0 0.35rem; + font-size: 1.5rem; +} + +.login-page h2, +.alias-page h2 { + margin: 0 0 0.5rem; + font-size: 1.05rem; + font-weight: 600; +} + +.login-section { + margin-bottom: 1.5rem; +} + +.login-section:last-of-type { + margin-bottom: 0.75rem; +} + +.account-current { + margin: 0.25rem 0 0.5rem; + font-size: 1.1rem; +} + +.oauth-provider-list { + list-style: none; + margin: 1rem 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.oauth-provider-list a { + text-align: center; + text-decoration: none; + margin-top: 0; +} + +.alias-list { + list-style: none; + margin: 0.5rem 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.alias-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.55rem 0.75rem; + border: 1px solid var(--border); + background: var(--bg); +} + +.alias-item.alias-current { + border-color: var(--accent); +} + +.alias-name { + font-weight: 600; +} + +.alias-badge { + font-size: 0.75rem; + color: var(--accent); + text-transform: lowercase; +} + +.alias-switch { + margin: 0; +} + +.alias-switch .btn-secondary { + margin-top: 0; +} + +.alias-claim label { + display: block; + margin-bottom: 0.35rem; + color: var(--muted); + font-size: 0.875rem; +} + +.alias-claim input[type="text"] { + width: 100%; + padding: 0.5rem 0.65rem; + background: var(--bg); + border: 1px solid var(--border); + color: var(--fg); + font: inherit; +} + +.alias-claim input[type="text"]:focus { + outline: 1px solid var(--accent); + border-color: var(--accent); +} + +#alias-status { + margin: 0.4rem 0 0.75rem; + min-height: 1.25em; +} + +#alias-status.alias-ok { + color: #8fd19e; +} + +#alias-status.alias-bad { + color: #e8a0a0; +} + +.login-actions { + padding-top: 0.75rem; + border-top: 1px solid var(--border); +} + +.login-actions .btn-secondary { + margin-top: 0; +} + +.login-back { + margin: 1rem 0 0; +} + +.login-back a { + color: var(--muted); + text-decoration: none; +} + +.login-back a:hover { + color: var(--fg); +} + .fetch-entity-form { margin-top: 0.5rem; } diff --git a/test/auth_login.clj b/test/auth_login.clj index a81e064ebb88fe359c56cf6718880dc1e4c1a7d9..6f2f00d7aa7340e63d1ac465b0a2234cec7a983f 100644 --- a/test/auth_login.clj +++ b/test/auth_login.clj @@ -55,7 +55,6 @@ (move-vote-slider-left pg) (loc/click (page/get-by-test-id pg "vote-post")) (page/wait-for-selector pg "[data-testid=oauth-github]" {:timeout 15000}) - (is (str/includes? (or (element-text pg "alias-list-empty") "") "none yet")) (loc/click (page/get-by-test-id pg "oauth-github")) (page/wait-for-selector pg "[data-testid=alias-input]" {:timeout 15000}) (type-alias! pg "seeder")