diff --git a/src/main.rs b/src/main.rs --- a/src/main.rs +++ b/src/main.rs @@ -442,9 +442,10 @@ let oauth_state_store = DbStateStore::new(db_pool.clone(), db_backend); - // HappyView's own default OAuth client always uses the `atproto` scope. - // API clients configure their own scopes via the API Clients settings page. - let oauth_scopes = vec![Scope::Known(KnownScope::Atproto)]; + let oauth_scopes = vec![ + Scope::Known(KnownScope::Atproto), + Scope::Unknown("identity:*".to_string()), + ]; let oauth_client = if is_loopback { info!("Using loopback OAuth client metadata (local development)"); diff --git a/src/server.rs b/src/server.rs --- a/src/server.rs +++ b/src/server.rs @@ -345,6 +345,7 @@ async fn well_known_did_json( State(state): State, + headers: axum::http::HeaderMap, ) -> Result, AppError> { let identity = crate::service_identity::get_identity(&state.db, state.db_backend).await?; let identity = @@ -356,21 +357,27 @@ )); } + let host = headers + .get(axum::http::header::HOST) + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| AppError::BadRequest("missing Host header".into()))?; + let entries = crate::service_entries::list_entries(&state.db, state.db_backend).await?; let entry_pairs: Vec<(String, String)> = entries .iter() .map(|e| (e.fragment_id.clone(), e.service_type.clone())) .collect(); - let service_endpoint = &state.config.public_url; + let service_endpoint = format!("https://{host}"); let signing_key_multibase = extract_public_key_multibase(&identity, &state)?; let doc = crate::service_identity::generate_did_document( &identity, + host, &signing_key_multibase, &entry_pairs, - service_endpoint, + &service_endpoint, ) .ok_or_else(|| AppError::NotFound("DID document not available".into()))?; diff --git a/src/service_identity.rs b/src/service_identity.rs --- a/src/service_identity.rs +++ b/src/service_identity.rs @@ -52,7 +52,8 @@ } // Row type: (mode, did, signing_key_enc, setup_complete, created_at, updated_at) -type ServiceIdentityRow = (String, Option, Option, bool, String, String); +// setup_complete uses i32 because sqlx's Any driver can't decode SQLite BOOLEAN directly. +type ServiceIdentityRow = (String, Option, Option, i32, String, String); fn parse_row(r: ServiceIdentityRow) -> Result { let mode = IdentityMode::parse(&r.0) @@ -61,7 +62,7 @@ mode, did: r.1, signing_key_enc: r.2, - setup_complete: r.3, + setup_complete: r.3 != 0, created_at: r.4, updated_at: r.5, }) @@ -73,7 +74,7 @@ backend: DatabaseBackend, ) -> Result, AppError> { let sql = adapt_sql( - "SELECT mode, did, signing_key_enc, setup_complete, created_at, updated_at FROM service_identity WHERE id = 1", + "SELECT mode, did, signing_key_enc, CAST(setup_complete AS INTEGER), created_at, updated_at FROM service_identity WHERE id = 1", backend, ); @@ -101,7 +102,10 @@ }), Some(id) => { let plc_verified = matches!(id.mode, IdentityMode::DidPlc) && id.setup_complete; - let identity_configured = id.did.is_some(); + let identity_configured = match id.mode { + IdentityMode::DidWeb => id.signing_key_enc.is_some(), + _ => id.did.is_some(), + }; let setup_complete = id.setup_complete; let identity_mode = Some(id.mode); Ok(SetupStatus { @@ -146,7 +150,7 @@ .bind(signing_key_enc) .bind(rotation_key_enc) .bind(attached_account_did) - .bind(false) + .bind(0i32) .bind(&now) .bind(&now) .execute(db) @@ -165,7 +169,7 @@ ); sqlx::query(&sql) - .bind(true) + .bind(1i32) .bind(&now) .execute(db) .await @@ -175,9 +179,12 @@ } /// Generate a DID document for did:web identity mode. -/// Returns None if the identity mode is not DidWeb or if required fields are missing. +/// The DID is derived dynamically from the request host rather than stored, +/// so the same signing key works across any domain pointing at this server. +/// Returns None if the identity mode is not DidWeb. pub fn generate_did_document( identity: &ServiceIdentity, + host: &str, signing_key_multibase: &str, service_entries: &[(String, String)], service_endpoint: &str, @@ -186,12 +193,12 @@ return None; } - let did = identity.did.as_deref()?; + let did = format!("did:web:{host}"); let verification_method = serde_json::json!([{ - "id": format!("{}#atproto", did), + "id": format!("{did}#atproto"), "type": "Multikey", - "controller": did, + "controller": &did, "publicKeyMultibase": signing_key_multibase }]); @@ -211,7 +218,7 @@ "https://www.w3.org/ns/did/v1", "https://w3id.org/security/multikey/v1" ], - "id": did, + "id": &did, "verificationMethod": verification_method, "service": services })) @@ -255,19 +262,37 @@ #[test] fn generate_did_document_returns_none_for_non_web() { let identity = make_identity(IdentityMode::DidPlc, Some("did:plc:abc123")); - assert!(generate_did_document(&identity, "zKey", &[], "https://example.com").is_none()); + assert!( + generate_did_document(&identity, "example.com", "zKey", &[], "https://example.com") + .is_none() + ); } #[test] - fn generate_did_document_returns_none_without_did() { + fn generate_did_document_derives_did_from_host() { let identity = make_identity(IdentityMode::DidWeb, None); - assert!(generate_did_document(&identity, "zKey", &[], "https://example.com").is_none()); + let doc = generate_did_document( + &identity, + "example.com", + "zKey123", + &[], + "https://example.com", + ) + .unwrap(); + assert_eq!(doc["id"], "did:web:example.com"); } #[test] fn generate_did_document_with_no_entries() { - let identity = make_identity(IdentityMode::DidWeb, Some("did:web:example.com")); - let doc = generate_did_document(&identity, "zKey123", &[], "https://example.com").unwrap(); + let identity = make_identity(IdentityMode::DidWeb, None); + let doc = generate_did_document( + &identity, + "example.com", + "zKey123", + &[], + "https://example.com", + ) + .unwrap(); assert_eq!(doc["id"], "did:web:example.com"); assert_eq!( doc["verificationMethod"][0]["publicKeyMultibase"], @@ -278,13 +303,19 @@ #[test] fn generate_did_document_with_entries() { - let identity = make_identity(IdentityMode::DidWeb, Some("did:web:example.com")); + let identity = make_identity(IdentityMode::DidWeb, None); let entries = vec![ ("#chess".to_string(), "ChessService".to_string()), ("#checkers".to_string(), "CheckersService".to_string()), ]; - let doc = - generate_did_document(&identity, "zKey123", &entries, "https://example.com").unwrap(); + let doc = generate_did_document( + &identity, + "example.com", + "zKey123", + &entries, + "https://example.com", + ) + .unwrap(); let services = doc["service"].as_array().unwrap(); assert_eq!(services.len(), 2); assert_eq!(services[0]["id"], "#chess"); @@ -295,8 +326,10 @@ #[test] fn generate_did_document_context_and_structure() { - let identity = make_identity(IdentityMode::DidWeb, Some("did:web:example.com")); - let doc = generate_did_document(&identity, "zKey", &[], "https://example.com").unwrap(); + let identity = make_identity(IdentityMode::DidWeb, None); + let doc = + generate_did_document(&identity, "example.com", "zKey", &[], "https://example.com") + .unwrap(); let context = doc["@context"].as_array().unwrap(); assert_eq!(context.len(), 2); assert_eq!(context[0], "https://www.w3.org/ns/did/v1"); diff --git a/src/setup.rs b/src/setup.rs --- a/src/setup.rs +++ b/src/setup.rs @@ -11,6 +11,7 @@ use rand::RngCore; use serde::Deserialize; +use crate::admin::auth::UserAuth; use crate::auth::COOKIE_NAME; use crate::event_log::{EventLog, Severity, log_event}; use crate::service_identity::{self, IdentityMode}; @@ -38,6 +39,7 @@ } async fn status( + _auth: UserAuth, State(state): State, ) -> Result, AppError> { let status = service_identity::get_setup_status(&state.db, state.db_backend).await?; @@ -56,6 +58,7 @@ } async fn set_identity( + _auth: UserAuth, State(state): State, Json(body): Json, ) -> Result { @@ -65,18 +68,9 @@ let (did, signing_key_enc, rotation_key_enc, attached_account_did) = match &mode { IdentityMode::DidWeb => { - // Derive domain from public_url: strip https:// prefix and trailing slash - let domain = state - .config - .public_url - .trim_start_matches("https://") - .trim_start_matches("http://") - .trim_end_matches('/'); - let did = format!("did:web:{domain}"); - let signing_key_enc = generate_encrypted_signing_key(&state)?; - (Some(did), Some(signing_key_enc), None, None) + (None::, Some(signing_key_enc), None, None) } IdentityMode::DidPlc => { @@ -148,6 +142,7 @@ /// 6. Submits the signed operation to the PLC directory /// 7. Updates the service_identity row with the new DID async fn plc_register( + _auth: UserAuth, State(state): State, ) -> Result, AppError> { require_setup_incomplete(&state).await?; @@ -240,7 +235,10 @@ Ok(Json(PlcRegisterResponse { did })) } -async fn plc_request(State(state): State) -> Result { +async fn plc_request( + _auth: UserAuth, + State(state): State, +) -> Result { require_setup_incomplete(&state).await?; let identity = service_identity::get_identity(&state.db, state.db_backend).await?; let identity = identity.ok_or_else(|| AppError::BadRequest("no identity configured".into()))?; @@ -283,6 +281,7 @@ } async fn plc_submit( + _auth: UserAuth, State(state): State, Json(body): Json, ) -> Result { @@ -505,7 +504,10 @@ Ok((jar, StatusCode::NO_CONTENT)) } -async fn export_rotation_key(State(state): State) -> Result { +async fn export_rotation_key( + _auth: UserAuth, + State(state): State, +) -> Result { require_setup_incomplete(&state).await?; use base64::Engine; @@ -556,7 +558,7 @@ )) } -async fn complete(State(state): State) -> Result { +async fn complete(_auth: UserAuth, State(state): State) -> Result { require_setup_incomplete(&state).await?; service_identity::mark_setup_complete(&state.db, state.db_backend).await?; @@ -590,6 +592,7 @@ } async fn resolve_identity( + _auth: UserAuth, State(state): State, Query(query): Query, ) -> Result>, AppError> { diff --git a/tests/e2e_service_identity.rs b/tests/e2e_service_identity.rs --- a/tests/e2e_service_identity.rs +++ b/tests/e2e_service_identity.rs @@ -125,7 +125,7 @@ .router .clone() .oneshot( - Request::builder() + app.authed_request() .uri("/api/setup/status") .body(Body::empty()) .unwrap(), @@ -150,7 +150,7 @@ .router .clone() .oneshot( - Request::builder() + app.authed_request() .uri("/api/setup/status") .body(Body::empty()) .unwrap(), @@ -175,7 +175,7 @@ .router .clone() .oneshot( - Request::builder() + app.authed_request() .uri("/api/setup/status") .body(Body::empty()) .unwrap(), @@ -226,6 +226,7 @@ .oneshot( Request::builder() .uri("/.well-known/did.json") + .header("host", "127.0.0.1") .body(Body::empty()) .unwrap(), ) @@ -259,6 +260,7 @@ .oneshot( Request::builder() .uri("/.well-known/did.json") + .header("host", "127.0.0.1") .body(Body::empty()) .unwrap(), ) @@ -282,6 +284,7 @@ .oneshot( Request::builder() .uri("/.well-known/did.json") + .header("host", "127.0.0.1") .body(Body::empty()) .unwrap(), ) @@ -1220,16 +1223,14 @@ async fn set_identity_attach_account_mode() { common::require_db!(); let app = TestApp::new().await; - let cookie = app.admin_cookie(); let resp = app .router .clone() .oneshot( - Request::builder() + app.authed_request() .method("POST") .uri("/api/setup/identity") - .header(cookie.0.clone(), cookie.1.clone()) .header("content-type", "application/json") .body(Body::from( serde_json::to_vec(&json!({ @@ -1250,7 +1251,7 @@ .router .clone() .oneshot( - Request::builder() + app.authed_request() .uri("/api/setup/status") .body(Body::empty()) .unwrap(), @@ -1274,17 +1275,14 @@ app.state.config.token_encryption_key = Some([0x42u8; 32]); app.rebuild_router(); - let cookie = app.admin_cookie(); - // Step 1: POST /api/setup/identity with mode=did_web let resp = app .router .clone() .oneshot( - Request::builder() + app.authed_request() .method("POST") .uri("/api/setup/identity") - .header(cookie.0.clone(), cookie.1.clone()) .header("content-type", "application/json") .body(Body::from( serde_json::to_vec(&json!({"mode": "did_web"})).unwrap(), @@ -1301,10 +1299,9 @@ .router .clone() .oneshot( - Request::builder() + app.authed_request() .method("POST") .uri("/api/setup/complete") - .header(cookie.0.clone(), cookie.1.clone()) .body(Body::empty()) .unwrap(), ) @@ -1322,6 +1319,7 @@ .oneshot( Request::builder() .uri("/.well-known/did.json") + .header("host", "127.0.0.1") .body(Body::empty()) .unwrap(), ) @@ -1330,7 +1328,7 @@ assert_eq!(resp.status(), StatusCode::OK); let doc = json_body(resp).await; - assert!(doc["id"].as_str().unwrap().starts_with("did:web:")); + assert_eq!(doc["id"], "did:web:127.0.0.1"); assert!(!doc["verificationMethod"].as_array().unwrap().is_empty()); // Step 4: Verify status shows complete @@ -1338,7 +1336,7 @@ .router .clone() .oneshot( - Request::builder() + app.authed_request() .uri("/api/setup/status") .body(Body::empty()) .unwrap(), diff --git a/tests/e2e_setup.rs b/tests/e2e_setup.rs --- a/tests/e2e_setup.rs +++ b/tests/e2e_setup.rs @@ -29,7 +29,7 @@ .router .clone() .oneshot( - Request::builder() + app.authed_request() .uri("/api/setup/status") .body(Body::empty()) .unwrap(), @@ -59,7 +59,7 @@ .router .clone() .oneshot( - Request::builder() + app.authed_request() .method("POST") .uri("/api/setup/identity") .header("content-type", "application/json") @@ -77,7 +77,7 @@ .router .clone() .oneshot( - Request::builder() + app.authed_request() .uri("/api/setup/status") .body(Body::empty()) .unwrap(), @@ -106,7 +106,7 @@ .router .clone() .oneshot( - Request::builder() + app.authed_request() .method("POST") .uri("/api/setup/identity") .header("content-type", "application/json") @@ -140,7 +140,7 @@ .router .clone() .oneshot( - Request::builder() + app.authed_request() .method("POST") .uri("/api/setup/identity") .header("content-type", "application/json") @@ -159,7 +159,7 @@ .router .clone() .oneshot( - Request::builder() + app.authed_request() .method("POST") .uri("/api/setup/complete") .body(Body::empty()) @@ -175,7 +175,7 @@ .router .clone() .oneshot( - Request::builder() + app.authed_request() .uri("/api/setup/status") .body(Body::empty()) .unwrap(), @@ -206,7 +206,7 @@ .router .clone() .oneshot( - Request::builder() + app.authed_request() .method("POST") .uri("/api/setup/identity") .header("content-type", "application/json") @@ -227,7 +227,7 @@ .router .clone() .oneshot( - Request::builder() + app.authed_request() .uri("/api/setup/rotation-key") .body(Body::empty()) .unwrap(), @@ -267,7 +267,7 @@ .router .clone() .oneshot( - Request::builder() + app.authed_request() .method("POST") .uri("/api/setup/identity") .header("content-type", "application/json") @@ -286,7 +286,7 @@ .router .clone() .oneshot( - Request::builder() + app.authed_request() .uri("/api/setup/rotation-key") .body(Body::empty()) .unwrap(), @@ -315,7 +315,7 @@ .router .clone() .oneshot( - Request::builder() + app.authed_request() .uri("/api/setup/resolve?q=") .body(Body::empty()) .unwrap(), @@ -340,7 +340,7 @@ .router .clone() .oneshot( - Request::builder() + app.authed_request() .uri("/api/setup/resolve?q=did%3Aplc%3Atestresolver") .body(Body::empty()) .unwrap(), @@ -374,7 +374,7 @@ .router .clone() .oneshot( - Request::builder() + app.authed_request() .method("POST") .uri("/api/setup/identity") .header("content-type", "application/json") @@ -393,7 +393,7 @@ .router .clone() .oneshot( - Request::builder() + app.authed_request() .method("POST") .uri("/api/setup/plc/register") .body(Body::empty()) @@ -438,7 +438,7 @@ .router .clone() .oneshot( - Request::builder() + app.authed_request() .method("POST") .uri("/api/setup/identity") .header("content-type", "application/json") @@ -456,7 +456,7 @@ .router .clone() .oneshot( - Request::builder() + app.authed_request() .method("POST") .uri("/api/setup/plc/register") .body(Body::empty()) @@ -486,7 +486,7 @@ .router .clone() .oneshot( - Request::builder() + app.authed_request() .method("POST") .uri("/api/setup/identity") .header("content-type", "application/json") @@ -505,7 +505,7 @@ .router .clone() .oneshot( - Request::builder() + app.authed_request() .method("POST") .uri("/api/setup/plc/register") .body(Body::empty()) @@ -521,7 +521,7 @@ .router .clone() .oneshot( - Request::builder() + app.authed_request() .method("POST") .uri("/api/setup/plc/register") .body(Body::empty()) @@ -715,7 +715,7 @@ .router .clone() .oneshot( - Request::builder() + app.authed_request() .method("POST") .uri("/api/setup/identity") .header("content-type", "application/json") @@ -733,7 +733,7 @@ .router .clone() .oneshot( - Request::builder() + app.authed_request() .method("POST") .uri("/api/setup/plc/request") .body(Body::empty()) @@ -762,7 +762,7 @@ .router .clone() .oneshot( - Request::builder() + app.authed_request() .method("POST") .uri("/api/setup/identity") .header("content-type", "application/json") @@ -780,7 +780,7 @@ .router .clone() .oneshot( - Request::builder() + app.authed_request() .method("POST") .uri("/api/setup/plc/submit") .header("content-type", "application/json") diff --git a/web/next.config.ts b/web/next.config.ts --- a/web/next.config.ts +++ b/web/next.config.ts @@ -17,6 +17,7 @@ // beforeFiles rewrites run before the trailingSlash redirect, // preventing 308s on API fetch calls. beforeFiles: [ + { source: "/api/:path*", destination: `${apiBase}/api/:path*` }, { source: "/admin/:path*", destination: `${apiBase}/admin/:path*` }, { source: "/auth/:path*", destination: `${apiBase}/auth/:path*` }, { source: "/xrpc/:path*", destination: `${apiBase}/xrpc/:path*` }, @@ -26,6 +27,7 @@ { source: "/config/", destination: `${apiBase}/config` }, { source: "/oauth/:path*", destination: `${apiBase}/oauth/:path*` }, { source: "/external-auth/:path*", destination: `${apiBase}/external-auth/:path*` }, + { source: "/.well-known/:path*", destination: `${apiBase}/.well-known/:path*` }, ], afterFiles: [], fallback: [], diff --git a/web/package-lock.json b/web/package-lock.json --- a/web/package-lock.json +++ b/web/package-lock.json @@ -120,7 +120,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -778,7 +777,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2049,7 +2047,6 @@ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -2165,7 +2162,6 @@ "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright": "1.60.0" }, @@ -4391,7 +4387,6 @@ "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4413,7 +4408,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4424,7 +4418,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4448,7 +4441,8 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@types/unist": { "version": "3.0.3", @@ -4514,7 +4508,6 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -5028,7 +5021,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5415,7 +5407,6 @@ "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/types": "^7.26.0" } @@ -5520,7 +5511,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6482,6 +6472,7 @@ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -6820,7 +6811,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6961,7 +6951,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7440,7 +7429,6 @@ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -7776,7 +7764,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -8254,7 +8241,6 @@ "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -9680,6 +9666,7 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -11499,7 +11486,6 @@ "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.13.0", "pg-pool": "^3.14.0", @@ -12009,7 +11995,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -12040,7 +12025,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -12087,7 +12071,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -12226,8 +12209,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -13432,8 +13414,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.0.tgz", "integrity": "sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.0", @@ -13506,7 +13487,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -13784,7 +13764,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14544,7 +14523,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/web/playwright.config.ts b/web/playwright.config.ts --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -45,6 +45,12 @@ dependencies: ["attach-account"], use: { browserName: "chromium" }, }, + { + name: "setup-features", + testMatch: "setup-features.spec.ts", + dependencies: ["didplc-setup"], + use: { browserName: "chromium" }, + }, ], globalSetup: "./tests/e2e/global-setup.ts", }) diff --git a/src/auth/middleware.rs b/src/auth/middleware.rs --- a/src/auth/middleware.rs +++ b/src/auth/middleware.rs @@ -283,7 +283,11 @@ let is_space_route = path.contains("/dev.happyview.space."); // Try service auth first - if let Ok(service_claims) = try_parse_service_auth(token, state).await { + let host = parts + .headers + .get(axum::http::header::HOST) + .and_then(|v| v.to_str().ok()); + if let Ok(service_claims) = try_parse_service_auth(token, state, host).await { return Ok(XrpcClaims { identity: None, space_credential: None, @@ -324,6 +328,7 @@ async fn try_parse_service_auth( token: &str, state: &AppState, + host: Option<&str>, ) -> Result { // 1. Check if service identity is configured and not "not_exposed" let identity = crate::service_identity::get_identity(&state.db, state.db_backend).await?; @@ -334,10 +339,16 @@ return Err(AppError::Auth("service auth disabled".into())); } - let instance_did = identity - .did - .as_ref() - .ok_or_else(|| AppError::Auth("no DID configured".into()))?; + let instance_did = match &identity.mode { + crate::service_identity::IdentityMode::DidWeb => { + let h = host.ok_or_else(|| AppError::Auth("missing Host header for did:web".into()))?; + format!("did:web:{h}") + } + _ => identity + .did + .clone() + .ok_or_else(|| AppError::Auth("no DID configured".into()))?, + }; // 2. Verify the JWT (this resolves the issuer's DID doc and checks signature) let service_auth = crate::auth::service_auth::ServiceAuth::from_bearer(token, state) @@ -353,14 +364,14 @@ .ok_or_else(|| AppError::Auth("JWT missing aud field".into()))?; // 4. Verify aud starts with instance DID and extract fragment - if !aud.starts_with(instance_did) { + if !aud.starts_with(&*instance_did) { return Err(AppError::Auth(format!( "JWT aud '{}' does not match instance DID '{}'", aud, instance_did ))); } - let fragment = aud.strip_prefix(instance_did).unwrap_or("").to_string(); + let fragment = aud.strip_prefix(&*instance_did).unwrap_or("").to_string(); if fragment.is_empty() || !fragment.starts_with('#') { return Err(AppError::Auth( "JWT aud must include a service fragment".into(), diff --git a/tests/common/app.rs b/tests/common/app.rs --- a/tests/common/app.rs +++ b/tests/common/app.rs @@ -5,6 +5,7 @@ OAuthResolverConfig, Scope, }; use axum::Router; +use axum::http::Request; use base64::Engine as _; use happyview::config::Config; use happyview::db::{DatabaseBackend, adapt_sql, now_rfc3339}; @@ -274,6 +275,12 @@ crate::common::auth::admin_cookie_header(&self.admin_did, &self.state.cookie_key) } + /// Return a `Request::builder()` pre-configured with the admin auth cookie. + pub fn authed_request(&self) -> axum::http::request::Builder { + let cookie = self.admin_cookie(); + Request::builder().header(cookie.0, cookie.1) + } + pub async fn setup_did_web(&mut self) -> String { use p256::ecdsa::SigningKey; use rand::RngCore; @@ -300,7 +307,7 @@ &self.state.db, self.state.db_backend, &happyview::service_identity::IdentityMode::DidWeb, - Some(&did), + None, Some(&enc_b64), None, None, diff --git a/web/src/app/globals.css b/web/src/app/globals.css --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -122,10 +122,8 @@ @layer base { * { @apply border-border outline-ring/50; - @apply border-border outline-ring/50; } body { - @apply bg-background text-foreground; @apply bg-background text-foreground; } } diff --git a/web/src/lib/docs.ts b/web/src/lib/docs.ts new file mode 100644 --- /dev/null +++ b/web/src/lib/docs.ts @@ -0,0 +1,6 @@ +const DOCS_BASE = (process.env.NEXT_PUBLIC_DOCS_URL || "/docs").replace(/\/$/, "") + +export function docsUrl(path: string): string { + const normalized = path.startsWith("/") ? path : `/${path}` + return `${DOCS_BASE}${normalized}` +} diff --git a/web/tests/e2e/setup-attach-account.spec.ts b/web/tests/e2e/setup-attach-account.spec.ts --- a/web/tests/e2e/setup-attach-account.spec.ts +++ b/web/tests/e2e/setup-attach-account.spec.ts @@ -35,22 +35,23 @@ }) test("attach_account flow reaches authenticate step", async ({ page }) => { + await loginAsTestAdmin(page) await page.goto("/setup") await expect( - page.getByText(/how should this appview be identified/i), + page.getByText(/set up your service identity/i), ).toBeVisible({ timeout: 10000 }) - // Select "Attach existing account" - await page.getByText(/attach existing account/i).click() + // Select "Use an existing AT Protocol account" + await page.getByText(/use an existing at protocol account/i).click() await page.getByRole("button", { name: /continue/i }).click() // Configure step: enter the PDS account DID - const identifierInput = page.getByLabel(/account identifier/i) + const identifierInput = page.getByLabel(/handle or did/i) await expect(identifierInput).toBeVisible({ timeout: 5000 }) await identifierInput.fill(account.did) // Wait for the typeahead dropdown and select the suggestion - const suggestion = page.locator(".bg-popover button").first() + const suggestion = page.getByRole("option").first() await expect(suggestion).toBeVisible({ timeout: 5000 }) await suggestion.click() @@ -61,7 +62,7 @@ // Should reach the authenticate step await expect( - page.getByText(/authenticate attached account/i), + page.getByText(/sign in to verify ownership/i), ).toBeVisible({ timeout: 10000 }) // Verify the authenticate button contains the account handle @@ -75,17 +76,17 @@ await loginAsTestAdmin(page) await page.goto("/setup") - // Select "Attach existing account" + // Select "Use an existing AT Protocol account" await expect( - page.getByText(/how should this appview be identified/i), + page.getByText(/set up your service identity/i), ).toBeVisible({ timeout: 10000 }) - await page.getByText(/attach existing account/i).click() + await page.getByText(/use an existing at protocol account/i).click() await page.getByRole("button", { name: /continue/i }).click() // Enter the DID directly without selecting from the typeahead so // attachedHandle stays null and the OAuth flow uses the DID. // Handle resolution for .test domains won't work from inside Docker. - const identifierInput = page.getByLabel(/account identifier/i) + const identifierInput = page.getByLabel(/handle or did/i) await expect(identifierInput).toBeVisible({ timeout: 5000 }) await identifierInput.fill(account.did) @@ -98,7 +99,7 @@ // Reach the authenticate step await expect( - page.getByText(/authenticate attached account/i), + page.getByText(/sign in to verify ownership/i), ).toBeVisible({ timeout: 10000 }) // Click "Authenticate as @handle" — this triggers the OAuth flow: @@ -153,15 +154,16 @@ test.afterAll(async ({ browser }) => { const page = await browser.newPage() try { + await loginAsTestAdmin(page) await page.goto("/setup") - const notExposedCard = page.getByText(/not exposed/i) + const skipCard = page.getByText(/skip for now/i) if ( - await notExposedCard.isVisible({ timeout: 3000 }).catch(() => false) + await skipCard.isVisible({ timeout: 3000 }).catch(() => false) ) { - await notExposedCard.click() + await skipCard.click() await page.getByRole("button", { name: /continue/i }).click() await expect( - page.getByText("Setup Complete"), + page.getByText("Your AppView is ready"), ).toBeVisible({ timeout: 5000 }) } } finally { diff --git a/web/tests/e2e/setup-didplc.spec.ts b/web/tests/e2e/setup-didplc.spec.ts --- a/web/tests/e2e/setup-didplc.spec.ts +++ b/web/tests/e2e/setup-didplc.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "@playwright/test" -import { resetServiceIdentity } from "./auth-helper" +import { loginAsTestAdmin, resetServiceIdentity } from "./auth-helper" test.describe("Setup - did:plc", () => { test.beforeAll(async () => { @@ -7,34 +7,30 @@ }) test("did:plc flow completes successfully", async ({ page }) => { + await loginAsTestAdmin(page) await page.goto("/setup") - // Select "Create did:plc" + // Select "Create a new network identity" await expect( - page.getByText(/how should this appview be identified/i), + page.getByText(/set up your service identity/i), ).toBeVisible({ timeout: 10000 }) - await page.getByText("Create did:plc").click() + await page.getByText("Create a new network identity").click() await page.getByRole("button", { name: /continue/i }).click() - // The configure step shows "Create did:plc" card with Continue button - await expect(page.getByText("Create did:plc").first()).toBeVisible({ timeout: 5000 }) - await page.getByRole("button", { name: /continue/i }).click() + // Wait for either registration in progress or the result + const registeringText = page.getByText("Registering your identity") + const saveKeyText = page.getByText("Save your rotation key") + const registrationFailed = page.getByText("Registration failed") - // Wait for either "Registering DID..." or the result - const registeringText = page.getByText("Registering DID...") - const exportKeyText = page.getByText("Export Rotation Key") - const registrationFailed = page.getByText("Registration Failed") - - // Wait for the registration to start or complete await expect( - registeringText.or(exportKeyText).or(registrationFailed), + registeringText.or(saveKeyText).or(registrationFailed), ).toBeVisible({ timeout: 10000 }) // If registration is in progress, wait for it to finish if (await registeringText.isVisible().catch(() => false)) { await expect( - exportKeyText.or(registrationFailed), + saveKeyText.or(registrationFailed), ).toBeVisible({ timeout: 30000 }) } @@ -53,7 +49,7 @@ await page.getByRole("button", { name: /continue/i }).click() // Verify setup completes - await expect(page.getByText("Setup Complete")).toBeVisible({ timeout: 5000 }) + await expect(page.getByText("Your AppView is ready")).toBeVisible({ timeout: 5000 }) }) // Restore setup state for subsequent tests @@ -61,17 +57,18 @@ await resetServiceIdentity() const page = await browser.newPage() try { + await loginAsTestAdmin(page) await page.goto( (process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:3200") + "/setup", ) - const notExposedCard = page.getByText(/not exposed/i) + const skipCard = page.getByText(/skip for now/i) if ( - await notExposedCard.isVisible({ timeout: 5000 }).catch(() => false) + await skipCard.isVisible({ timeout: 5000 }).catch(() => false) ) { - await notExposedCard.click() + await skipCard.click() await page.getByRole("button", { name: /continue/i }).click() await expect( - page.getByText("Setup Complete"), + page.getByText("Your AppView is ready"), ).toBeVisible({ timeout: 5000 }) } } finally { diff --git a/web/tests/e2e/setup-features.spec.ts b/web/tests/e2e/setup-features.spec.ts new file mode 100644 --- /dev/null +++ b/web/tests/e2e/setup-features.spec.ts @@ -0,0 +1,106 @@ +import { test, expect } from "@playwright/test" +import { loginAsTestAdmin, resetServiceIdentity } from "./auth-helper" + +test.describe("Setup - Features", () => { + test.beforeEach(async () => { + await resetServiceIdentity() + }) + + test("skip for now completes setup", async ({ page }) => { + await loginAsTestAdmin(page) + await page.goto("/setup") + await expect(page.getByText(/set up your service identity/i)).toBeVisible({ timeout: 10000 }) + + await page.getByText(/skip for now/i).click() + await page.getByRole("button", { name: /continue/i }).click() + + await expect(page.getByText("Your AppView is ready")).toBeVisible({ timeout: 5000 }) + await expect(page.getByText(/skipped/i)).toBeVisible() + }) + + test("stepper identity click resets wizard state", async ({ page }) => { + await loginAsTestAdmin(page) + await page.goto("/setup") + await expect(page.getByText(/set up your service identity/i)).toBeVisible({ timeout: 10000 }) + + // Select did:web and advance to the verify step + await page.getByText("Use your domain").click() + await page.getByRole("button", { name: /continue/i }).click() + await expect(page.getByText("Review your domain identity")).toBeVisible({ timeout: 10000 }) + + // Click the Identity stepper step to go back and reset + await page.getByText("Identity").click() + + // Should be back at mode selection with state reset + await expect(page.getByText(/set up your service identity/i)).toBeVisible({ timeout: 5000 }) + + // Verify we can select a different mode (state was fully reset) + await page.getByText("Create a new network identity").click() + await expect(page.getByRole("button", { name: /continue/i })).toBeEnabled() + }) + + test("did:web shows continue anyway when document fetch fails", async ({ page }) => { + await page.route("**/.well-known/did.json", (route) => + route.fulfill({ status: 500 }), + ) + + await loginAsTestAdmin(page) + await page.goto("/setup") + await expect(page.getByText(/set up your service identity/i)).toBeVisible({ timeout: 10000 }) + + await page.getByText("Use your domain").click() + await page.getByRole("button", { name: /continue/i }).click() + + await expect(page.getByText("Review your domain identity")).toBeVisible({ timeout: 10000 }) + + // Should show the fetch error alert + await expect(page.getByText(/could not load your did document/i)).toBeVisible({ timeout: 5000 }) + + // Button should say "Continue anyway" instead of "Looks good" + const continueButton = page.getByRole("button", { name: /continue anyway/i }) + await expect(continueButton).toBeVisible() + await expect(continueButton).toBeEnabled() + }) + + test("focus moves to step content on transitions", async ({ page }) => { + await loginAsTestAdmin(page) + await page.goto("/setup") + await expect(page.getByText(/set up your service identity/i)).toBeVisible({ timeout: 10000 }) + + await page.getByText("Use your domain").click() + await page.getByRole("button", { name: /continue/i }).click() + + await expect(page.getByText("Review your domain identity")).toBeVisible({ timeout: 10000 }) + + // The step content container (div with tabIndex=-1) should receive focus + const focused = await page.evaluate(() => { + const el = document.activeElement + return el?.getAttribute("tabindex") + }) + expect(focused).toBe("-1") + }) + + // Restore setup state for subsequent tests + test.afterAll(async ({ browser }) => { + await resetServiceIdentity() + const page = await browser.newPage() + try { + await loginAsTestAdmin(page) + await page.goto( + (process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:3200") + "/setup", + ) + const skipCard = page.getByText(/skip for now/i) + if ( + await skipCard.isVisible({ timeout: 5000 }).catch(() => false) + ) { + await skipCard.click() + await page.getByRole("button", { name: /continue/i }).click() + await expect( + page.getByText("Your AppView is ready"), + ).toBeVisible({ timeout: 5000 }) + } + } finally { + await page.close() + } + }) +}) diff --git a/web/tests/e2e/setup-gate.spec.ts b/web/tests/e2e/setup-gate.spec.ts --- a/web/tests/e2e/setup-gate.spec.ts +++ b/web/tests/e2e/setup-gate.spec.ts @@ -1,13 +1,22 @@ import { test, expect } from "@playwright/test" +import { loginAsTestAdmin } from "./auth-helper" test.describe("Setup Gate", () => { - test("dashboard redirects to /setup when no identity configured", async ({ page }) => { + test("unauthenticated user is redirected to /login", async ({ page }) => { + await page.goto("/dashboard") + + await expect(page).toHaveURL(/\/login/, { timeout: 10000 }) + }) + + test("authenticated user is redirected to /setup when no identity configured", async ({ page }) => { + await loginAsTestAdmin(page) await page.goto("/dashboard") await expect(page).toHaveURL(/\/setup/, { timeout: 10000 }) }) - test("dashboard settings page redirects to /setup", async ({ page }) => { + test("authenticated user on settings page is redirected to /setup", async ({ page }) => { + await loginAsTestAdmin(page) await page.goto("/dashboard/settings/service-identity") await expect(page).toHaveURL(/\/setup/, { timeout: 10000 }) diff --git a/web/tests/e2e/setup-wizard.spec.ts b/web/tests/e2e/setup-wizard.spec.ts --- a/web/tests/e2e/setup-wizard.spec.ts +++ b/web/tests/e2e/setup-wizard.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "@playwright/test" -import { resetServiceIdentity } from "./auth-helper" +import { loginAsTestAdmin, resetServiceIdentity } from "./auth-helper" test.describe("Setup Wizard", () => { test.beforeAll(async () => { @@ -7,28 +7,27 @@ }) test("did:web flow completes successfully", async ({ page }) => { + await loginAsTestAdmin(page) await page.goto("/setup") - await page.getByText("did:web").click() + await page.getByText("Use your domain").click() await page.getByRole("button", { name: /continue/i }).click() - await expect(page.getByText("Configure did:web")).toBeVisible({ timeout: 5000 }) - await page.getByRole("button", { name: /continue/i }).click() - - await expect(page.getByText("Verify DID Document")).toBeVisible({ timeout: 10000 }) + await expect(page.getByText("Review your domain identity")).toBeVisible({ timeout: 10000 }) // Verify the wizard resumes at the correct step after reload await page.reload() - await expect(page.getByText("Verify DID Document")).toBeVisible({ timeout: 10000 }) + await expect(page.getByText("Review your domain identity")).toBeVisible({ timeout: 10000 }) const completeButton = page.getByRole("button", { name: /looks good/i }) await expect(completeButton).toBeVisible({ timeout: 5000 }) await completeButton.click() - await expect(page.getByText("Setup Complete")).toBeVisible({ timeout: 5000 }) + await expect(page.getByText("Your AppView is ready")).toBeVisible({ timeout: 5000 }) }) test("setup page redirects to dashboard after completion", async ({ page }) => { + await loginAsTestAdmin(page) await page.goto("/setup") await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 }) diff --git a/web/src/app/dashboard/layout.tsx b/web/src/app/dashboard/layout.tsx --- a/web/src/app/dashboard/layout.tsx +++ b/web/src/app/dashboard/layout.tsx @@ -23,22 +23,21 @@ const [setupChecked, setSetupChecked] = useState(false) useEffect(() => { + if (!did) { + router.replace("/login") + return + } + getSetupStatus() .then((status) => { if (!status.setup_complete) { router.replace("/setup") - } else if (!did) { - router.replace("/login") } else { setSetupChecked(true) } }) .catch(() => { - if (!did) { - router.replace("/login") - } else { - setSetupChecked(true) - } + setSetupChecked(true) }) }, [did, router]) diff --git a/web/src/app/setup/page.tsx b/web/src/app/setup/page.tsx --- a/web/src/app/setup/page.tsx +++ b/web/src/app/setup/page.tsx @@ -3,13 +3,22 @@ import { useRouter } from "next/navigation" import { useEffect, useState } from "react" import { getSetupStatus } from "@/lib/api" +import { useAuth } from "@/lib/auth-context" import { SetupWizard } from "@/components/setup/setup-wizard" +import { Skeleton } from "@/components/ui/skeleton" export default function SetupPage() { const router = useRouter() + const { did } = useAuth() const [ready, setReady] = useState(false) + const [backendError, setBackendError] = useState(false) useEffect(() => { + if (!did) { + router.replace("/login") + return + } + getSetupStatus() .then((status) => { if (status.setup_complete) { @@ -19,20 +28,29 @@ } }) .catch(() => { + setBackendError(true) setReady(true) }) - }, [router]) - - if (!ready) return null + }, [did, router]) return (
-

Setup

-

Configure your HappyView instance

+

Welcome to HappyView

+

Let's get your AppView ready for the AT Protocol network.

- + {backendError && ( +
+ Could not reach the backend. Setup steps may not save correctly. +
+ )} + {ready ? : ( +
+ + +
+ )}
) diff --git a/web/src/components/setup/help-tip.tsx b/web/src/components/setup/help-tip.tsx new file mode 100644 --- /dev/null +++ b/web/src/components/setup/help-tip.tsx @@ -0,0 +1,38 @@ +"use client" + +import { HelpCircle } from "lucide-react" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" + +interface HelpTipProps { + label: string + href?: string +} + +export function HelpTip({ label, href }: HelpTipProps) { + return ( + + + + {href ? ( + + + + ) : ( + + + + )} + + + {label} + + + + ) +} diff --git a/web/src/components/setup/setup-attach-auth.tsx b/web/src/components/setup/setup-attach-auth.tsx --- a/web/src/components/setup/setup-attach-auth.tsx +++ b/web/src/components/setup/setup-attach-auth.tsx @@ -1,118 +1,168 @@ -"use client" +"use client"; -import { useEffect, useState } from "react" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { Button } from "@/components/ui/button" -import { confirmAttachAuth } from "@/lib/api" +import { useEffect, useState } from "react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { confirmAttachAuth } from "@/lib/api"; +import { docsUrl } from "@/lib/docs"; -const ATTACH_AUTH_STORAGE_KEY = "happyview_attach_auth" +const ATTACH_AUTH_STORAGE_KEY = "happyview_attach_auth"; +const ATTACH_AUTH_MAX_AGE_MS = 10 * 60 * 1000; interface AttachAuthPayload { - attachedDid: string - originalDid: string + attachedDid: string; + originalDid: string; + timestamp?: number; } interface SetupAttachAuthProps { - attachedDid: string - attachedHandle: string | null - onComplete: () => void + attachedDid: string; + attachedHandle: string | null; + onComplete: () => void; + onBack?: () => void; } -export function SetupAttachAuth({ attachedDid, attachedHandle, onComplete }: SetupAttachAuthProps) { - const [confirming, setConfirming] = useState(false) - const [error, setError] = useState(null) +export function SetupAttachAuth({ + attachedDid, + attachedHandle, + onComplete, + onBack, +}: SetupAttachAuthProps) { + const [confirming, setConfirming] = useState(false); + const [error, setError] = useState(null); - // On mount: check if we're returning from the OAuth redirect useEffect(() => { - const stored = localStorage.getItem(ATTACH_AUTH_STORAGE_KEY) - if (!stored) return + const stored = localStorage.getItem(ATTACH_AUTH_STORAGE_KEY); + if (!stored) return; - let payload: AttachAuthPayload + let payload: AttachAuthPayload; try { - payload = JSON.parse(stored) as AttachAuthPayload + payload = JSON.parse(stored) as AttachAuthPayload; } catch { - localStorage.removeItem(ATTACH_AUTH_STORAGE_KEY) - return + localStorage.removeItem(ATTACH_AUTH_STORAGE_KEY); + return; } - // Only act if the stored DID matches the current attached DID if (payload.attachedDid !== attachedDid) { - localStorage.removeItem(ATTACH_AUTH_STORAGE_KEY) - return + localStorage.removeItem(ATTACH_AUTH_STORAGE_KEY); + return; } - // We're returning from the OAuth flow — confirm and restore the admin session - localStorage.removeItem(ATTACH_AUTH_STORAGE_KEY) - setConfirming(true) + if ( + payload.timestamp && + Date.now() - payload.timestamp > ATTACH_AUTH_MAX_AGE_MS + ) { + localStorage.removeItem(ATTACH_AUTH_STORAGE_KEY); + setError("Your sign-in session expired. Please authenticate again."); + return; + } + + localStorage.removeItem(ATTACH_AUTH_STORAGE_KEY); + setConfirming(true); confirmAttachAuth({ original_did: payload.originalDid }) .then(() => onComplete()) .catch((e) => { - setError(e instanceof Error ? e.message : "Failed to restore admin session") - setConfirming(false) - }) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + setError( + e instanceof Error + ? e.message + : "Failed to restore admin session. Try authenticating again.", + ); + setConfirming(false); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); function handleAuthenticate() { - // We need the admin's current DID to restore it after OAuth. - // Fetch it from /auth/me before redirecting. - setConfirming(true) - setError(null) + setConfirming(true); + setError(null); fetch("/auth/me", { credentials: "same-origin" }) .then((res) => { - if (!res.ok) throw new Error("Failed to fetch current user") - return res.json() as Promise<{ did: string }> + if (!res.ok) throw new Error("Failed to fetch current user"); + return res.json() as Promise<{ did: string }>; }) .then(({ did: originalDid }) => { - const payload: AttachAuthPayload = { attachedDid, originalDid } - localStorage.setItem(ATTACH_AUTH_STORAGE_KEY, JSON.stringify(payload)) + const payload: AttachAuthPayload = { + attachedDid, + originalDid, + timestamp: Date.now(), + }; + localStorage.setItem(ATTACH_AUTH_STORAGE_KEY, JSON.stringify(payload)); - const handle = attachedHandle ?? attachedDid - return fetch(`/auth/login?handle=${encodeURIComponent(handle)}`, { + const handle = attachedHandle ?? attachedDid; + return fetch(`/auth/login?handle=${encodeURIComponent(handle)}&scope=${encodeURIComponent("atproto identity:*")}`, { credentials: "same-origin", - }) + }); }) .then((resp) => { - if (!resp.ok) throw new Error("Login request failed") - return resp.json() as Promise<{ url: string }> + if (!resp.ok) throw new Error("Login request failed"); + return resp.json() as Promise<{ url: string }>; }) .then(({ url }) => { - window.location.href = url + window.location.href = url; }) .catch((e) => { - setError(e instanceof Error ? e.message : "Failed to start authentication") - setConfirming(false) - }) + setError( + e instanceof Error + ? e.message + : "Failed to start authentication. Check your connection and try again.", + ); + setConfirming(false); + }); } - const displayName = attachedHandle ? `@${attachedHandle}` : attachedDid + const displayName = attachedHandle ? `@${attachedHandle}` : attachedDid; return ( - Authenticate Attached Account - - To authorize PLC changes, you need to authenticate as{" "} - {displayName}. - You'll be redirected to sign in, then returned here automatically. - + Sign in to verify ownership + {/* + You'll be redirected to sign in as{" "} + {displayName}, then returned here + automatically.
+
*/}
-
-

What happens next:

-
    -
  1. You'll be redirected to authenticate as {displayName}
  2. -
  3. After sign-in you'll be returned to this page
  4. -
  5. Your admin session will be restored automatically
  6. -
-
- {error &&

{error}

} +

+ You'll leave this page briefly to authenticate through the + account's data server. Once verified, your admin session will be + restored and you'll continue from where you left off. +
+ + Learn more + +

+ {error && ( +

+ {error} +

+ )} {confirming ? ( -

Restoring admin session...

+

+ Restoring admin session… +

) : ( -
+
+ {onBack ? ( + + ) : ( +
+ )} @@ -120,5 +170,5 @@ )} - ) + ); } diff --git a/web/src/components/setup/setup-complete.tsx b/web/src/components/setup/setup-complete.tsx --- a/web/src/components/setup/setup-complete.tsx +++ b/web/src/components/setup/setup-complete.tsx @@ -2,34 +2,95 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Button } from "@/components/ui/button" -import { CheckCircle2 } from "lucide-react" +import { CheckCircle2, FileUp, Settings, LayoutDashboard } from "lucide-react" import { useRouter } from "next/navigation" import { completeSetup } from "@/lib/api" -import { useEffect } from "react" +import { docsUrl } from "@/lib/docs" +import { useEffect, useState } from "react" interface SetupCompleteProps { identityMode: string | null } +const MODE_LABELS: Record = { + did_web: "Domain identity (did:web)", + did_plc: "Network identity (did:plc)", + attach_account: "Linked AT Protocol account", + not_exposed: "Skipped — using built-in auth", +} + export function SetupComplete({ identityMode }: SetupCompleteProps) { const router = useRouter() + const [error, setError] = useState(null) useEffect(() => { - if (identityMode === "not_exposed") { completeSetup() } + if (identityMode === "not_exposed") { + completeSetup().catch((e) => { + setError(e instanceof Error ? e.message : "Failed to finalize setup. You can retry from the dashboard settings.") + }) + } }, [identityMode]) + + const modeLabel = identityMode ? MODE_LABELS[identityMode] ?? identityMode : "Configured" return ( -
- Setup Complete +
+
+ +
+
+ Your AppView is ready {identityMode === "not_exposed" - ? "Your HappyView instance is ready. You can configure service identity later from settings." - : "Your HappyView instance is configured and ready to accept proxied requests."} + ? "HappyView is running with built-in auth. You can configure a service identity anytime from settings." + : "Your service identity is configured and your AppView is ready to accept requests from the AT Protocol network."}
- - - + +
+
Service identity
+
{modeLabel}
+
+ What does this mean? +
+
+ + {error &&

{error}

} + +
+

Next steps

+
+ + +
+
+ +
+ +
) diff --git a/web/src/components/setup/setup-configure.tsx b/web/src/components/setup/setup-configure.tsx --- a/web/src/components/setup/setup-configure.tsx +++ b/web/src/components/setup/setup-configure.tsx @@ -1,6 +1,6 @@ "use client" -import { useCallback, useEffect, useRef, useState } from "react" +import { useCallback, useEffect, useId, useRef, useState } from "react" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -11,71 +11,21 @@ interface SetupConfigureProps { mode: string onComplete: (opts?: { attachedDid?: string; attachedHandle?: string | null }) => void + onBack?: () => void } -export function SetupConfigure({ mode, onComplete }: SetupConfigureProps) { - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) - const [identifier, setIdentifier] = useState("") - - const handleSubmit = async () => { - setLoading(true) - setError(null) - try { - await setSetupIdentity({ - mode, - ...(mode === "attach_account" ? { attached_account_did: identifier } : {}), - }) - onComplete() - } catch (e) { - setError(e instanceof Error ? e.message : "Setup failed") - } finally { - setLoading(false) - } - } - - if (mode === "did_web") { - return ( - - Configure did:web - HappyView will serve a DID document at /.well-known/did.json using your instance domain. - - -

A new P-256 keypair will be generated and encrypted at rest.

-

You can add service entries after setup from the Service Identity settings page.

- {error &&

{error}

} -
-
-
- ) - } - +export function SetupConfigure({ mode, onComplete, onBack }: SetupConfigureProps) { if (mode === "attach_account") { - return ( - onComplete(opts)} /> - ) - } - - if (mode === "did_plc") { - return ( - - Create did:plc - A new DID will be registered in the PLC directory. HappyView will generate and manage the signing and rotation keypairs. - - -

A new P-256 keypair will be generated and encrypted at rest.

-

A separate rotation key will be generated. You'll be able to export it in the next step.

- {error &&

{error}

} -
-
-
- ) + return onComplete(opts)} onBack={onBack} /> } return null } -function AttachAccountForm({ onComplete }: { onComplete: (opts: { attachedDid: string; attachedHandle: string | null }) => void }) { +function AttachAccountForm({ onComplete, onBack }: { + onComplete: (opts: { attachedDid: string; attachedHandle: string | null }) => void + onBack?: () => void +}) { const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [inputValue, setInputValue] = useState("") @@ -83,10 +33,11 @@ const [showSuggestions, setShowSuggestions] = useState(false) const [selectedProfile, setSelectedProfile] = useState(null) const [resolving, setResolving] = useState(false) + const [focusedIndex, setFocusedIndex] = useState(-1) const debounceRef = useRef | null>(null) const containerRef = useRef(null) + const listboxId = useId() - // Close suggestions on outside click useEffect(() => { function handleClickOutside(e: MouseEvent) { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { @@ -109,10 +60,12 @@ try { const results = await resolveIdentity(q) setSuggestions(results) - setShowSuggestions(results.length > 0) + setShowSuggestions(true) + setFocusedIndex(-1) } catch { - // Silently fail — typeahead is optional setSuggestions([]) + setShowSuggestions(false) + setFocusedIndex(-1) } finally { setResolving(false) } @@ -152,49 +105,87 @@ attachedHandle: selectedProfile?.handle ?? null, }) } catch (e) { - setError(e instanceof Error ? e.message : "Setup failed") + setError(e instanceof Error ? e.message : "Failed to link account. Check the identifier and try again.") } finally { setLoading(false) } } + const trimmedInput = inputValue.trim() + const looksValid = selectedProfile != null || /^did:[a-z]+:.+/.test(trimmedInput) || trimmedInput.includes(".") + const showFormatHint = trimmedInput.length >= 2 && !looksValid && !resolving + const displayName = selectedProfile?.display_name ?? selectedProfile?.handle ?? selectedProfile?.did const avatarFallback = displayName?.charAt(0).toUpperCase() ?? "?" + const hasSuggestions = showSuggestions && suggestions.length > 0 + const showEmpty = showSuggestions && suggestions.length === 0 && !resolving && trimmedInput.length >= 2 return ( - Attach Existing Account - Enter the handle or DID of the account to attach. You'll verify ownership in the next step. + Find your account + Search for the AT Protocol account you want to link to this AppView.
- +
handleInputChange(e.target.value)} onFocus={() => { if (suggestions.length > 0) setShowSuggestions(true) }} + onKeyDown={(e) => { + if (e.key === "Enter") { + if (hasSuggestions && focusedIndex >= 0) { + e.preventDefault() + selectResult(suggestions[focusedIndex]) + } else if (!hasSuggestions && looksValid && trimmedInput && !loading) { + e.preventDefault() + handleSubmit() + } + return + } + if (!hasSuggestions) return + if (e.key === "ArrowDown") { + e.preventDefault() + setFocusedIndex((i) => (i + 1) % suggestions.length) + } else if (e.key === "ArrowUp") { + e.preventDefault() + setFocusedIndex((i) => (i <= 0 ? suggestions.length - 1 : i - 1)) + } else if (e.key === "Escape") { + setShowSuggestions(false) + setFocusedIndex(-1) + } + }} autoComplete="off" disabled={loading} + aria-required="true" + role="combobox" + aria-expanded={hasSuggestions} + aria-controls={listboxId} + aria-autocomplete="list" + aria-activedescendant={focusedIndex >= 0 ? `${listboxId}-option-${focusedIndex}` : undefined} /> {resolving && ( - - Resolving... + + Resolving… )} - {showSuggestions && suggestions.length > 0 && ( -
+ {hasSuggestions && ( +
{suggestions.map((result, index) => { const name = result.display_name ?? result.handle ?? result.did const fallback = name.charAt(0).toUpperCase() return (
)} + {showEmpty && ( +
+

No accounts found. Try a full handle (e.g. alice.bsky.social) or a DID.

+
+ )}
+ {showFormatHint && ( +

Enter a handle (e.g. alice.bsky.social) or a DID (e.g. did:plc:...).

+ )}
{selectedProfile && ( @@ -235,20 +234,24 @@

{selectedProfile.did}

- +
)} - {error &&

{error}

} -
- + ) :
} +
diff --git a/web/src/components/setup/setup-identity-mode.tsx b/web/src/components/setup/setup-identity-mode.tsx --- a/web/src/components/setup/setup-identity-mode.tsx +++ b/web/src/components/setup/setup-identity-mode.tsx @@ -1,42 +1,159 @@ -"use client" +"use client"; -import { useState } from "react" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { Button } from "@/components/ui/button" -import { cn } from "@/lib/utils" +import { useState } from "react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { docsUrl } from "@/lib/docs"; +import { HelpTip } from "./help-tip"; -const MODES = [ - { value: "did_web", title: "Use did:web (domain-based)", description: "Auto-generate a DID document served from this domain. Simplest option — just needs DNS you already control." }, - { value: "attach_account", title: "Attach existing account", description: "Add a service entry to an existing AT Protocol account's DID document. Uses the PLC confirmation code flow." }, - { value: "did_plc", title: "Create did:plc", description: "Register a new DID in the PLC directory. Most durable — survives domain changes. HappyView manages the keypairs." }, - { value: "not_exposed", title: "Not exposed", description: "Skip identity setup. This instance won't support service proxying. You can configure this later in settings." }, -] +const IDENTITY_MODES = [ + { + value: "did_web", + title: "Use your domain", + description: + "Your domain name becomes your identity. This is the simplest option, since HappyView will generate everything automatically.", + helpTip: + "Uses your domain as a did:web identifier. Your server hosts a DID document at /.well-known/did.json. The identity is tied to your domain.", + badge: "Recommended", + }, + { + value: "attach_account", + title: "Use an existing AT Protocol account", + description: + "Link this AppView to an account you already own. You'll verify ownership through that account.", + helpTip: + "Links your AppView to an existing account's DID. Authentication goes through that account's Personal Data Server.", + badge: null, + }, + { + value: "did_plc", + title: "Create a new network identity", + description: ( + <> + Register a new identity in the AT Protocol directory. This is the most + durable option because a did:plc will survive domain + changes. + + ), + helpTip: + "Registers a did:plc identity in the AT Protocol directory. Supports key rotation and recovery, and isn't tied to any single domain.", + badge: null, + }, +]; -interface SetupIdentityModeProps { onComplete: (mode: string) => void } +interface SetupIdentityModeProps { + onComplete: (mode: string) => void | Promise; +} export function SetupIdentityMode({ onComplete }: SetupIdentityModeProps) { - const [selected, setSelected] = useState(null) + const [selected, setSelected] = useState(null); + const [submitting, setSubmitting] = useState(false); return ( - How should this AppView be identified? - Choose how other AT Protocol services discover and authenticate with this instance. + Set up your service identity + + AT Protocol apps typically verify requests through a user's data + server before they reach your AppView. To accept those requests, your + AppView needs its own identity on the network. +
+
+ This is optional. HappyView includes its own auth, + but a service identity is recommended for compatibility with standard + AT Protocol apps. +
+ + Learn more + +
- - {MODES.map((mode) => ( - - ))} -
- + +
+ {IDENTITY_MODES.map((mode) => ( + + ))} + +
+ +
+
+ +
+
- ) + ); } diff --git a/web/src/components/setup/setup-verify.tsx b/web/src/components/setup/setup-verify.tsx --- a/web/src/components/setup/setup-verify.tsx +++ b/web/src/components/setup/setup-verify.tsx @@ -6,72 +6,82 @@ import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { completeSetup, plcRequest, plcSubmit, plcRegister } from "@/lib/api" +import { docsUrl } from "@/lib/docs" +import { HelpTip } from "./help-tip" -interface SetupVerifyProps { mode: string; onComplete: () => void } +interface SetupVerifyProps { + mode: string + onComplete: () => void + onBack?: () => void +} // ─── did:web ──────────────────────────────────────────────────────────────── -function VerifyDidWeb({ onComplete }: { onComplete: () => void }) { +function VerifyDidWeb({ onComplete, onBack }: { onComplete: () => void; onBack?: () => void }) { const [loading, setLoading] = useState(false) const [error, setError] = useState(null) - const [didDoc, setDidDoc] = useState | null>(null) - const [fetchingDidDoc, setFetchingDidDoc] = useState(false) - const [fetchError, setFetchError] = useState(null) + const [didId, setDidId] = useState(null) + const [fetching, setFetching] = useState(true) + const [fetchError, setFetchError] = useState(false) useEffect(() => { - const fetchDidDoc = async () => { - setFetchingDidDoc(true) - setFetchError(null) - try { - const response = await fetch("/.well-known/did.json") - if (!response.ok) { - if (response.status === 404) { - setFetchError("DID document not yet available. Add service entries to generate it.") - } else { - setFetchError(`Failed to fetch DID document (${response.status})`) - } - return - } - const doc = await response.json() - setDidDoc(doc) - } catch (e) { - setFetchError(e instanceof Error ? e.message : "Failed to fetch DID document") - } finally { - setFetchingDidDoc(false) - } - } - - fetchDidDoc() + fetch("/.well-known/did.json") + .then((res) => { + if (!res.ok) throw new Error() + return res.json() + }) + .then((doc) => { if (doc?.id) setDidId(doc.id) }) + .catch(() => setFetchError(true)) + .finally(() => setFetching(false)) }, []) const handleConfirm = async () => { setLoading(true) setError(null) try { await completeSetup(); onComplete() } - catch (e) { setError(e instanceof Error ? e.message : "Verification failed") } + catch (e) { setError(e instanceof Error ? e.message : "Failed to complete setup. Check your backend connection and try again.") } finally { setLoading(false) } } return ( - Verify DID Document - Review the DID document that will be served at /.well-known/did.json. + Review your domain identity + A signing key has been generated and your identity document is ready. Learn more - {fetchingDidDoc &&

Loading DID document...

} - {fetchError &&

{fetchError}

} - {didDoc && ( -
-            {JSON.stringify(didDoc, null, 2)}
-          
+ {fetching ? ( +

Checking identity document…

+ ) : ( +
+ {fetchError && ( +
+ Could not load your DID document from /.well-known/did.json. Your identity may not be configured correctly. +
+ )} + {didId && ( +
+
Identity
+
{didId}
+
+ )} +
+
Document URL
+
/.well-known/did.json
+
+
+
Signing key
+
P-256 keypair, encrypted at rest
+
+
)} - {!fetchingDidDoc && !didDoc && !fetchError && ( -

The DID document will be generated from your service entries. You can add service entries after setup.

- )} - {error &&

{error}

} -
- +

You can add service entries after setup from the Service Identity settings page.

+ {error &&

{error}

} +
+ {onBack ? ( + + ) :
} +
@@ -80,7 +90,7 @@ // ─── attach_account ────────────────────────────────────────────────────────── -function VerifyAttachAccount({ onComplete }: { onComplete: () => void }) { +function VerifyAttachAccount({ onComplete, onBack }: { onComplete: () => void; onBack?: () => void }) { const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [token, setToken] = useState("") @@ -88,8 +98,14 @@ const [sendingCode, setSendingCode] = useState(false) useEffect(() => { - handleSendCode() - // eslint-disable-next-line react-hooks/exhaustive-deps + let cancelled = false + setSendingCode(true) + setError(null) + plcRequest() + .then(() => { if (!cancelled) setCodeSent(true) }) + .catch((e) => { if (!cancelled) setError(e instanceof Error ? e.message : "Failed to send confirmation code. Check your connection and try again.") }) + .finally(() => { if (!cancelled) setSendingCode(false) }) + return () => { cancelled = true } }, []) const handleSendCode = async () => { @@ -99,7 +115,7 @@ await plcRequest() setCodeSent(true) } catch (e) { - setError(e instanceof Error ? e.message : "Failed to send code") + setError(e instanceof Error ? e.message : "Failed to send confirmation code. Check your connection and try again.") } finally { setSendingCode(false) } @@ -113,7 +129,7 @@ await completeSetup() onComplete() } catch (e) { - setError(e instanceof Error ? e.message : "Verification failed") + setError(e instanceof Error ? e.message : "Verification failed. Check the code and try again.") } finally { setLoading(false) } @@ -122,37 +138,40 @@ return ( - Verify Account Ownership + Enter your confirmation code {codeSent - ? "A confirmation code has been sent to the account's email. Enter it below." - : "We'll send a confirmation code to the account's email to verify ownership."} + ? "A code has been sent to the email address on this account." + : "We'll send a confirmation code to the email address on this account."}{" "} + Learn more {!codeSent ? ( ) : ( - <> +
{ e.preventDefault(); if (token && !loading) handleSubmitToken() }} className="space-y-4">
- setToken(e.target.value)} className="mt-1.5" /> + setToken(e.target.value)} className="mt-1.5" aria-required="true" />
- - - )} - {error &&

{error}

} - {codeSent && ( -
- -
+ {error &&

{error}

} +
+ {onBack ? ( + + ) :
} + +
+ )} + {!codeSent && error &&

{error}

} ) @@ -160,12 +179,15 @@ // ─── did:plc ───────────────────────────────────────────────────────────────── -function VerifyDidPlc({ onComplete }: { onComplete: () => void }) { +function VerifyDidPlc({ onComplete, onBack }: { onComplete: () => void; onBack?: () => void }) { const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [registering, setRegistering] = useState(true) const [registeredDid, setRegisteredDid] = useState(null) const [regError, setRegError] = useState(null) + const [keyDownloaded, setKeyDownloaded] = useState(false) + const [downloading, setDownloading] = useState(false) + const [downloadError, setDownloadError] = useState(null) useEffect(() => { plcRegister() @@ -173,25 +195,50 @@ setRegisteredDid(result.did) }) .catch((e) => { - setRegError(e instanceof Error ? e.message : "Registration failed") + setRegError(e instanceof Error ? e.message : "Registration failed. Check your backend connection and try again.") }) .finally(() => setRegistering(false)) }, []) + + const handleDownloadKey = async () => { + setDownloading(true) + setDownloadError(null) + try { + const res = await fetch("/api/setup/rotation-key") + if (!res.ok) throw new Error("Server returned an error") + const blob = await res.blob() + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + const disposition = res.headers.get("Content-Disposition") + const filenameMatch = disposition?.match(/filename="?([^";\s]+)"?/) + a.download = filenameMatch?.[1] ?? "rotation-key.json" + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + setKeyDownloaded(true) + } catch { + setDownloadError("Failed to download the rotation key. Check your connection and try again.") + } finally { + setDownloading(false) + } + } const handleConfirm = async () => { setLoading(true) setError(null) try { await completeSetup(); onComplete() } - catch (e) { setError(e instanceof Error ? e.message : "Verification failed") } + catch (e) { setError(e instanceof Error ? e.message : "Failed to complete setup. Check your backend connection and try again.") } finally { setLoading(false) } } if (registering) { return ( - Registering DID... + Registering your identity… -

Creating your DID in the PLC directory...

+

Creating your identity in the AT Protocol directory. This usually takes a few seconds.

) @@ -200,9 +247,12 @@ if (regError) { return ( - Registration Failed - -

{regError}

+ Registration failed + +

{regError}

+ {onBack && ( + + )}
) @@ -211,25 +261,46 @@ return ( - Export Rotation Key - Your DID has been registered. Export the rotation key now — this is your only chance to back it up. + Save your rotation key + Your identity is registered. Download the rotation key now — you won't be able to access it again after this step. Learn more - {registeredDid && ( -
-
Your DID
- {registeredDid} +
+ {registeredDid && ( +
+
Identity
+
{registeredDid}
+
+ )} +
+
Signing key
+
P-256 keypair, encrypted at rest
+
+
Rotation key
+
Generated separately — download it below
+
+
+
+

If you lose this key and this HappyView instance goes down, you won't be able to recover or update your identity.

+
+ + {downloadError &&

{downloadError}

} + {!keyDownloaded ? ( +

You must download the rotation key before continuing.

+ ) : ( +

Store this file somewhere safe and offline — a password manager, encrypted USB drive, or secure backup.

)} -
-

Losing the rotation key means losing the ability to update this DID document if this HappyView instance is lost.

-
-
- -
- {error &&

{error}

} -
- + {error &&

{error}

} +
+ {onBack ? ( + + ) :
} +
@@ -238,9 +309,9 @@ // ─── Root dispatcher ───────────────────────────────────────────────────────── -export function SetupVerify({ mode, onComplete }: SetupVerifyProps) { - if (mode === "did_web") return - if (mode === "attach_account") return - if (mode === "did_plc") return +export function SetupVerify({ mode, onComplete, onBack }: SetupVerifyProps) { + if (mode === "did_web") return + if (mode === "attach_account") return + if (mode === "did_plc") return return null } diff --git a/web/src/components/setup/setup-wizard.tsx b/web/src/components/setup/setup-wizard.tsx --- a/web/src/components/setup/setup-wizard.tsx +++ b/web/src/components/setup/setup-wizard.tsx @@ -1,12 +1,14 @@ "use client" -import { useCallback, useEffect, useState } from "react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { getSetupStatus, setSetupIdentity } from "@/lib/api" import { SetupIdentityMode } from "./setup-identity-mode" import { SetupConfigure } from "./setup-configure" import { SetupAttachAuth } from "./setup-attach-auth" import { SetupVerify } from "./setup-verify" import { SetupComplete } from "./setup-complete" +import { Button } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" import { Stepper, StepperItem, StepperList, StepperIndicator, StepperSeparator, StepperTitle, StepperTrigger, @@ -20,22 +22,32 @@ const [attachedDid, setAttachedDid] = useState(null) const [attachedHandle, setAttachedHandle] = useState(null) const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const lastFailedModeRef = useRef(null) + const stepContentRef = useRef(null) + + const initialLoadRef = useRef(true) + useEffect(() => { + if (initialLoadRef.current) { + initialLoadRef.current = false + return + } + stepContentRef.current?.focus() + }, [currentStep]) useEffect(() => { getSetupStatus() .then((status) => { if (status.setup_complete) { setCurrentStep("complete") - } else if (status.plc_verified || (status.identity_mode === "not_exposed")) { + } else if (status.plc_verified) { setCurrentStep("complete") } else if (status.identity_configured) { setIdentityMode(status.identity_mode) setCurrentStep("verify") - } else if (status.identity_mode) { + } else if (status.identity_mode && status.identity_mode !== "not_exposed") { setIdentityMode(status.identity_mode) - // Returning from OAuth redirect — localStorage has the pending auth payload. - // Jump directly to attach-auth so SetupAttachAuth can process the callback. const pendingAuth = localStorage.getItem("happyview_attach_auth") if (status.identity_mode === "attach_account" && pendingAuth) { try { @@ -50,18 +62,49 @@ } } }) + .catch(() => {}) .finally(() => setLoading(false)) }, []) const handleModeSelected = useCallback(async (mode: string) => { setIdentityMode(mode) + setError(null) + lastFailedModeRef.current = null if (mode === "not_exposed") { - await setSetupIdentity({ mode: "not_exposed" }) - setCurrentStep("complete") - } else { + try { + await setSetupIdentity({ mode: "not_exposed" }) + setCurrentStep("complete") + } catch (e) { + lastFailedModeRef.current = mode + setError(e instanceof Error ? e.message : "Failed to save configuration. Check that your backend is running and try again.") + } + } else if (mode === "attach_account") { setCurrentStep("configure") + } else { + try { + await setSetupIdentity({ mode }) + setCurrentStep("verify") + } catch (e) { + lastFailedModeRef.current = mode + setError(e instanceof Error ? e.message : "Failed to configure identity. Check that your backend is running and try again.") + } } }, []) + + const handleGoBack = useCallback(() => { + setError(null) + switch (currentStep) { + case "configure": + setCurrentStep("mode") + break + case "attach-auth": + setCurrentStep("configure") + break + case "verify": + setCurrentStep(identityMode === "attach_account" ? "configure" : "mode") + break + } + }, [currentStep, identityMode]) const handleConfigureComplete = useCallback((opts?: { attachedDid?: string; attachedHandle?: string | null }) => { if (opts?.attachedDid) { @@ -81,47 +124,91 @@ setCurrentStep("complete") }, []) + const stepOrder = useMemo(() => identityMode === "attach_account" + ? ["mode", "configure", "attach-auth", "verify", "complete"] + : ["mode", "verify", "complete"] + , [identityMode]) + + const handleStepperNav = useCallback((value: string) => { + const target = value as SetupStep + const currentIndex = stepOrder.indexOf(currentStep) + const targetIndex = stepOrder.indexOf(target) + if (targetIndex < 0 || targetIndex > currentIndex) return + if (target === "mode") { + setIdentityMode(null) + setAttachedDid(null) + setAttachedHandle(null) + setError(null) + } + setCurrentStep(target) + }, [currentStep, stepOrder]) + if (loading) { - return
Loading...
+ return ( +
+ + +
+ ) } return ( - setCurrentStep(v as SetupStep)}> + - Identity Mode - - - - Configure + Identity {identityMode === "attach_account" && ( - - Authenticate - - + <> + + Account + + + + Sign In + + + )} - Verify + {identityMode === "did_plc" ? "Key Backup" : identityMode === "attach_account" ? "Verify" : "Review"} - Complete + Done -
+ {error && ( +
+ {error} + {lastFailedModeRef.current && ( + + )} +
+ )} + +
{currentStep === "mode" && } - {currentStep === "configure" && identityMode && } + {currentStep === "configure" && identityMode && ( + + )} {currentStep === "attach-auth" && attachedDid && ( )} - {currentStep === "verify" && identityMode && } + {currentStep === "verify" && identityMode && ( + + )} {currentStep === "complete" && }
diff --git a/packages/docs/content/docs/getting-started/meta.json b/packages/docs/content/docs/getting-started/meta.json --- a/packages/docs/content/docs/getting-started/meta.json +++ b/packages/docs/content/docs/getting-started/meta.json @@ -3,6 +3,7 @@ "pages": [ "quickstart", "configuration", + "service-identity", "dashboard", "authentication", "deployment" diff --git a/packages/docs/content/docs/getting-started/service-identity.md b/packages/docs/content/docs/getting-started/service-identity.md new file mode 100644 --- /dev/null +++ b/packages/docs/content/docs/getting-started/service-identity.md @@ -0,0 +1,51 @@ +--- +title: "Service Identity" +--- + +An AT Protocol service identity lets your AppView authenticate itself to other services on the network. When a user's PDS routes a request, it verifies the destination by resolving the AppView's DID — without a service identity, standard AT Protocol app routing won't reach your instance. + +HappyView can operate without a service identity using its built-in auth, but configuring one is recommended for full network compatibility. + +## Identity modes + +HappyView supports three ways to establish a service identity during setup. + +### Domain identity (did:web) + +Your domain name becomes your identity. HappyView generates a signing keypair and serves a [DID document](https://atproto.com/specs/did#did-web) at `/.well-known/did.json` automatically. + +This is the simplest option — no external registration is needed. The identity is tied to your domain: if you change domains, you'll need to reconfigure. + +### Network identity (did:plc) + +HappyView registers a new identity in the [PLC directory](https://atproto.com/specs/did#did-plc), a public registry that maps DIDs to their metadata. This is the most durable option — the identity survives domain changes because it isn't tied to any single hostname. + +During registration, HappyView generates two keypairs: + +- **Signing key** — Used to authenticate requests from your AppView. Stored encrypted on the server and managed automatically. +- **Rotation key** — Used to recover or update the identity if the signing key is lost or the server goes down. This key is generated once and must be downloaded immediately — it cannot be retrieved later. + +Store the rotation key file somewhere safe and offline (e.g. a password manager, encrypted USB drive, or secure backup). You will need it if you ever need to migrate your identity to a new server or recover from data loss. + +### Linked account + +Link your AppView to an existing AT Protocol account you control. HappyView verifies ownership by redirecting you to sign in through that account's PDS, then uses the account's existing DID as the service identity. + +## Choosing an identity mode + +| | Domain (did:web) | Network (did:plc) | Linked account | +|---|---|---|---| +| Setup complexity | Automatic | Requires key backup | Requires existing account | +| Domain independence | No — tied to your domain | Yes — survives domain changes | Depends on the linked account | +| Key management | Automatic | You must back up the rotation key | Managed by the linked account's PDS | +| Best for | Single-domain deployments | Long-lived production instances | Operators who already have an AT Protocol presence | + +## Skipping setup + +You can skip service identity configuration during setup. Your AppView will work with HappyView's built-in authentication, but standard AT Protocol service-to-service routing won't be available. You can configure a service identity later from **Settings > Service Identity** in the dashboard. + +## Further reading + +- [AT Protocol identity specification](https://atproto.com/guides/identity) +- [DID methods in AT Protocol](https://atproto.com/specs/did) +- [AT Protocol glossary](https://atproto.com/guides/glossary) diff --git a/web/src/app/dashboard/settings/service-identity/page.tsx b/web/src/app/dashboard/settings/service-identity/page.tsx --- a/web/src/app/dashboard/settings/service-identity/page.tsx +++ b/web/src/app/dashboard/settings/service-identity/page.tsx @@ -261,7 +261,7 @@ DID - {identity.did ?? not set} + {identity.did ?? (identity.mode === "did_web" ? `did:web:${window.location.host}` : not set)}