diff --git a/crates/server/src/api/card.rs b/crates/server/src/api/card.rs index d75ca10..fd09867 100644 --- a/crates/server/src/api/card.rs +++ b/crates/server/src/api/card.rs @@ -106,7 +106,13 @@ mod tests { #[tokio::test] async fn test_create_card_success() { let state = create_test_state(); - let user = UserContext { did: "did:plc:test123".to_string(), handle: "test.handle".to_string() }; + let user = UserContext { + did: "did:plc:test123".to_string(), + handle: "test.handle".to_string(), + access_token: "test_token".to_string(), + pds_url: "https://bsky.social".to_string(), + has_dpop: false, + }; let payload = CreateCardRequest { deck_id: "550e8400-e29b-41d4-a716-446655440000".to_string(), diff --git a/crates/server/src/api/deck.rs b/crates/server/src/api/deck.rs index 0260267..68d44fd 100644 --- a/crates/server/src/api/deck.rs +++ b/crates/server/src/api/deck.rs @@ -176,7 +176,7 @@ pub async fn publish_deck( } }; - match crate::pds::publish::publish_deck_to_pds(state.oauth_repo.clone(), &user.did, &deck, &cards).await { + match crate::pds::publish::publish_deck_to_pds(state.oauth_repo.clone(), &user, &deck, &cards).await { Ok(result) => { deck_at_uri = Some(result.deck_at_uri.clone()); @@ -292,7 +292,13 @@ mod tests { Arc::new(crate::repository::oauth::mock::MockOAuthRepository::new()), ); - let user = UserContext { did: "did:plc:alice".to_string(), handle: "alice.bsky.social".to_string() }; + let user = UserContext { + did: "did:plc:alice".to_string(), + handle: "alice.bsky.social".to_string(), + access_token: "test_token".to_string(), + pds_url: "https://bsky.social".to_string(), + has_dpop: false, + }; let payload = CreateDeckRequest { title: "My New Deck".to_string(), diff --git a/crates/server/src/api/feed.rs b/crates/server/src/api/feed.rs index 7614a2a..5997646 100644 --- a/crates/server/src/api/feed.rs +++ b/crates/server/src/api/feed.rs @@ -89,7 +89,13 @@ mod tests { async fn test_get_feed_follows_success() { let social_repo = Arc::new(MockSocialRepository::new()); let state = create_test_state_with_social(social_repo); - let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() }; + let user = UserContext { + did: "did:plc:test".to_string(), + handle: "test.handle".to_string(), + access_token: "test_token".to_string(), + pds_url: "https://bsky.social".to_string(), + has_dpop: false, + }; let response = get_feed_follows(State(state), Some(Extension(user))) .await .into_response(); diff --git a/crates/server/src/api/note.rs b/crates/server/src/api/note.rs index 80e04d4..4db1d12 100644 --- a/crates/server/src/api/note.rs +++ b/crates/server/src/api/note.rs @@ -152,7 +152,13 @@ mod tests { #[tokio::test] async fn test_create_note_success() { let state = create_test_state(); - let user = UserContext { did: "did:plc:test123".to_string(), handle: "test.handle".to_string() }; + let user = UserContext { + did: "did:plc:test123".to_string(), + handle: "test.handle".to_string(), + access_token: "test_token".to_string(), + pds_url: "https://bsky.social".to_string(), + has_dpop: false, + }; let payload = CreateNoteRequest { title: "Test Note".to_string(), @@ -254,7 +260,13 @@ mod tests { let state = AppState::new_with_repos(pool, card_repo, note_repo, oauth_repo); - let owner = UserContext { did: "did:plc:owner".to_string(), handle: "owner.handle".to_string() }; + let owner = UserContext { + did: "did:plc:owner".to_string(), + handle: "owner.handle".to_string(), + access_token: "test_token".to_string(), + pds_url: "https://bsky.social".to_string(), + has_dpop: false, + }; let response = get_note( axum::extract::State(state.clone()), @@ -266,7 +278,13 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); - let other_user = UserContext { did: "did:plc:other".to_string(), handle: "other.handle".to_string() }; + let other_user = UserContext { + did: "did:plc:other".to_string(), + handle: "other.handle".to_string(), + access_token: "test_token".to_string(), + pds_url: "https://bsky.social".to_string(), + has_dpop: false, + }; let response = get_note(axum::extract::State(state), Some(Extension(other_user)), Path(note_id)) .await .into_response(); diff --git a/crates/server/src/api/preferences.rs b/crates/server/src/api/preferences.rs index 3daba88..5dde3dd 100644 --- a/crates/server/src/api/preferences.rs +++ b/crates/server/src/api/preferences.rs @@ -148,7 +148,13 @@ mod tests { let prefs_repo = Arc::new(MockPreferencesRepository::new()) as Arc; let state = create_test_state_with_prefs(prefs_repo); - let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() }; + let user = UserContext { + did: "did:plc:test".to_string(), + handle: "test.handle".to_string(), + access_token: "test_token".to_string(), + pds_url: "https://bsky.social".to_string(), + has_dpop: false, + }; let response = get_preferences(State(state), Some(Extension(user))) .await .into_response(); @@ -161,7 +167,13 @@ mod tests { let prefs_repo = Arc::new(MockPreferencesRepository::new()) as Arc; let state = create_test_state_with_prefs(prefs_repo); - let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() }; + let user = UserContext { + did: "did:plc:test".to_string(), + handle: "test.handle".to_string(), + access_token: "test_token".to_string(), + pds_url: "https://bsky.social".to_string(), + has_dpop: false, + }; let payload = UpdatePreferencesRequest { persona: Some("creator".to_string()), complete_onboarding: Some(true), @@ -181,7 +193,13 @@ mod tests { let prefs_repo = Arc::new(MockPreferencesRepository::new()) as Arc; let state = create_test_state_with_prefs(prefs_repo); - let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() }; + let user = UserContext { + did: "did:plc:test".to_string(), + handle: "test.handle".to_string(), + access_token: "test_token".to_string(), + pds_url: "https://bsky.social".to_string(), + has_dpop: false, + }; let payload = UpdatePreferencesRequest { persona: Some("invalid".to_string()), complete_onboarding: None, diff --git a/crates/server/src/api/review.rs b/crates/server/src/api/review.rs index 3c53bf0..726303c 100644 --- a/crates/server/src/api/review.rs +++ b/crates/server/src/api/review.rs @@ -201,7 +201,13 @@ mod tests { let review_repo = Arc::new(MockReviewRepository::with_cards(cards)) as Arc; let state = create_test_state_with_review(review_repo); - let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() }; + let user = UserContext { + did: "did:plc:test".to_string(), + handle: "test.handle".to_string(), + access_token: "test_token".to_string(), + pds_url: "https://bsky.social".to_string(), + has_dpop: false, + }; let response = get_due_cards( State(state), Some(Extension(user)), @@ -218,7 +224,13 @@ mod tests { let review_repo = Arc::new(MockReviewRepository::new()) as Arc; let state = create_test_state_with_review(review_repo); - let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() }; + let user = UserContext { + did: "did:plc:test".to_string(), + handle: "test.handle".to_string(), + access_token: "test_token".to_string(), + pds_url: "https://bsky.social".to_string(), + has_dpop: false, + }; let payload = SubmitReviewRequest { card_id: "card-1".to_string(), grade: 3 }; let response = submit_review(State(state), Some(Extension(user)), Json(payload)) @@ -233,7 +245,13 @@ mod tests { let review_repo = Arc::new(MockReviewRepository::new()) as Arc; let state = create_test_state_with_review(review_repo); - let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() }; + let user = UserContext { + did: "did:plc:test".to_string(), + handle: "test.handle".to_string(), + access_token: "test_token".to_string(), + pds_url: "https://bsky.social".to_string(), + has_dpop: false, + }; let payload = SubmitReviewRequest { card_id: "card-1".to_string(), grade: 10 }; let response = submit_review(State(state), Some(Extension(user)), Json(payload)) @@ -248,7 +266,13 @@ mod tests { let review_repo = Arc::new(MockReviewRepository::new()) as Arc; let state = create_test_state_with_review(review_repo); - let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() }; + let user = UserContext { + did: "did:plc:test".to_string(), + handle: "test.handle".to_string(), + access_token: "test_token".to_string(), + pds_url: "https://bsky.social".to_string(), + has_dpop: false, + }; let response = get_stats(State(state), Some(Extension(user))).await.into_response(); assert_eq!(response.status(), StatusCode::OK); diff --git a/crates/server/src/api/search.rs b/crates/server/src/api/search.rs index 1dc9551..40d5ef9 100644 --- a/crates/server/src/api/search.rs +++ b/crates/server/src/api/search.rs @@ -134,7 +134,13 @@ mod tests { .await; let state = create_test_state_with_search(search_repo.clone()); - let auth_ctx = Extension(UserContext { did: "did:alice".to_string(), handle: "alice.test".to_string() }); + let auth_ctx = Extension(UserContext { + did: "did:alice".to_string(), + handle: "alice.test".to_string(), + access_token: "test_token".to_string(), + pds_url: "https://bsky.social".to_string(), + has_dpop: false, + }); let response = search( State(state.clone()), Some(auth_ctx), diff --git a/crates/server/src/api/social.rs b/crates/server/src/api/social.rs index d625132..451289b 100644 --- a/crates/server/src/api/social.rs +++ b/crates/server/src/api/social.rs @@ -216,7 +216,13 @@ mod tests { async fn test_follow_success() { let social_repo = Arc::new(MockSocialRepository::new()); let state = create_test_state_with_social(social_repo.clone()); - let user = UserContext { did: "did:plc:follower".to_string(), handle: "follower".to_string() }; + let user = UserContext { + did: "did:plc:follower".to_string(), + handle: "follower".to_string(), + access_token: "test_token".to_string(), + pds_url: "https://bsky.social".to_string(), + has_dpop: false, + }; let response = follow(State(state), Some(Extension(user)), Path("did:plc:subject".to_string())) .await @@ -234,7 +240,13 @@ mod tests { social_repo.follow("did:plc:follower", "did:plc:subject").await.unwrap(); let state = create_test_state_with_social(social_repo.clone()); - let user = UserContext { did: "did:plc:follower".to_string(), handle: "follower".to_string() }; + let user = UserContext { + did: "did:plc:follower".to_string(), + handle: "follower".to_string(), + access_token: "test_token".to_string(), + pds_url: "https://bsky.social".to_string(), + has_dpop: false, + }; let response = unfollow(State(state), Some(Extension(user)), Path("did:plc:subject".to_string())) .await @@ -272,7 +284,13 @@ mod tests { async fn test_add_comment_success() { let social_repo = Arc::new(MockSocialRepository::new()); let state = create_test_state_with_social(social_repo.clone()); - let user = UserContext { did: "did:plc:author".to_string(), handle: "author".to_string() }; + let user = UserContext { + did: "did:plc:author".to_string(), + handle: "author".to_string(), + access_token: "test_token".to_string(), + pds_url: "https://bsky.social".to_string(), + has_dpop: false, + }; let payload = AddCommentRequest { content: "Great deck!".to_string(), parent_id: None }; diff --git a/crates/server/src/middleware/auth.rs b/crates/server/src/middleware/auth.rs index f2bbb7e..3bfae41 100644 --- a/crates/server/src/middleware/auth.rs +++ b/crates/server/src/middleware/auth.rs @@ -10,10 +10,16 @@ use axum::{ use serde_json::json; use std::time::{Duration, Instant}; +/// User context extracted from authentication. +/// +/// Contains the user's identity and authentication details needed for PDS operations. #[derive(Clone, Debug)] pub struct UserContext { pub did: String, pub handle: String, + pub access_token: String, + pub pds_url: String, + pub has_dpop: bool, } /// Cache expiry time (5 minutes) @@ -190,7 +196,13 @@ pub async fn auth_middleware(State(state): State, mut req: Request, let body: serde_json::Value = response.json().await.unwrap_or_default(); let did = body["did"].as_str().unwrap_or("").to_string(); let handle = body["handle"].as_str().unwrap_or("").to_string(); - let user_ctx = UserContext { did: did.clone(), handle }; + let user_ctx = UserContext { + did: did.clone(), + handle, + access_token: token.to_string(), + pds_url: target_pds_url.to_string(), + has_dpop: stored_token.is_some(), + }; tracing::debug!("PDS verification successful for DID: {}", did); @@ -262,7 +274,13 @@ pub async fn optional_auth_middleware(mut req: Request, next: Next) -> Response let did = body["did"].as_str().unwrap_or("").to_string(); let handle = body["handle"].as_str().unwrap_or("").to_string(); - req.extensions_mut().insert(UserContext { did, handle }); + req.extensions_mut().insert(UserContext { + did, + handle, + access_token: token.to_string(), + pds_url: pds_url.clone(), + has_dpop: false, + }); } _ => {} } diff --git a/crates/server/src/pds/client.rs b/crates/server/src/pds/client.rs index db0f9f4..117b0c8 100644 --- a/crates/server/src/pds/client.rs +++ b/crates/server/src/pds/client.rs @@ -7,11 +7,13 @@ use malfestio_core::at_uri::AtUri; use serde::{Deserialize, Serialize}; /// A client for interacting with a user's PDS. +/// +/// Supports both DPoP-bound tokens (OAuth) and Bearer tokens (app passwords). pub struct PdsClient { http_client: reqwest::Client, pds_url: String, access_token: String, - dpop_keypair: DpopKeypair, + dpop_keypair: Option, } /// Request body for putRecord XRPC. @@ -101,9 +103,24 @@ impl std::fmt::Display for PdsError { impl std::error::Error for PdsError {} impl PdsClient { - /// Create a new PDS client. + /// Create a new PDS client with DPoP support (OAuth tokens). + /// + /// Uses DPoP proof-of-possession for enhanced security. + pub fn new_with_dpop(pds_url: String, access_token: String, dpop_keypair: DpopKeypair) -> Self { + Self { http_client: reqwest::Client::new(), pds_url, access_token, dpop_keypair: Some(dpop_keypair) } + } + + /// Create a new PDS client with Bearer authentication (app password tokens). + /// + /// Uses standard Bearer token authentication without DPoP. + pub fn new_bearer(pds_url: String, access_token: String) -> Self { + Self { http_client: reqwest::Client::new(), pds_url, access_token, dpop_keypair: None } + } + + /// Create a new PDS client (deprecated - use new_with_dpop or new_bearer). + #[deprecated(since = "0.1.0", note = "Use new_with_dpop or new_bearer instead")] pub fn new(pds_url: String, access_token: String, dpop_keypair: DpopKeypair) -> Self { - Self { http_client: reqwest::Client::new(), pds_url, access_token, dpop_keypair } + Self::new_with_dpop(pds_url, access_token, dpop_keypair) } /// Create or update a record in the repository. @@ -119,8 +136,6 @@ impl PdsClient { ) -> Result { let url = format!("{}/xrpc/com.atproto.repo.putRecord", self.pds_url); - let dpop_proof = self.dpop_keypair.generate_proof("POST", &url, Some(&self.access_token)); - let request = PutRecordRequest { repo: did.to_string(), collection: collection.to_string(), @@ -131,11 +146,21 @@ impl PdsClient { validate: Some(true), }; - let response = self - .http_client - .post(&url) - .header("Authorization", format!("DPoP {}", self.access_token)) - .header("DPoP", dpop_proof) + let mut request_builder = self.http_client.post(&url); + + // Conditionally add DPoP or Bearer authentication + if let Some(ref dpop_keypair) = self.dpop_keypair { + // OAuth with DPoP + let dpop_proof = dpop_keypair.generate_proof("POST", &url, Some(&self.access_token)); + request_builder = request_builder + .header("Authorization", format!("DPoP {}", self.access_token)) + .header("DPoP", dpop_proof); + } else { + // App password with Bearer + request_builder = request_builder.header("Authorization", format!("Bearer {}", self.access_token)); + } + + let response = request_builder .json(&request) .send() .await @@ -148,8 +173,6 @@ impl PdsClient { pub async fn delete_record(&self, did: &str, collection: &str, rkey: &str) -> Result<(), PdsError> { let url = format!("{}/xrpc/com.atproto.repo.deleteRecord", self.pds_url); - let dpop_proof = self.dpop_keypair.generate_proof("POST", &url, Some(&self.access_token)); - let request = DeleteRecordRequest { repo: did.to_string(), collection: collection.to_string(), @@ -158,11 +181,21 @@ impl PdsClient { swap_commit: None, }; - let response = self - .http_client - .post(&url) - .header("Authorization", format!("DPoP {}", self.access_token)) - .header("DPoP", dpop_proof) + let mut request_builder = self.http_client.post(&url); + + // Conditionally add DPoP or Bearer authentication + if let Some(ref dpop_keypair) = self.dpop_keypair { + // OAuth with DPoP + let dpop_proof = dpop_keypair.generate_proof("POST", &url, Some(&self.access_token)); + request_builder = request_builder + .header("Authorization", format!("DPoP {}", self.access_token)) + .header("DPoP", dpop_proof); + } else { + // App password with Bearer + request_builder = request_builder.header("Authorization", format!("Bearer {}", self.access_token)); + } + + let response = request_builder .json(&request) .send() .await @@ -181,13 +214,21 @@ impl PdsClient { pub async fn upload_blob(&self, data: Vec, mime_type: &str) -> Result { let url = format!("{}/xrpc/com.atproto.repo.uploadBlob", self.pds_url); - let dpop_proof = self.dpop_keypair.generate_proof("POST", &url, Some(&self.access_token)); + let mut request_builder = self.http_client.post(&url); - let response = self - .http_client - .post(&url) - .header("Authorization", format!("DPoP {}", self.access_token)) - .header("DPoP", dpop_proof) + // Conditionally add DPoP or Bearer authentication + if let Some(ref dpop_keypair) = self.dpop_keypair { + // OAuth with DPoP + let dpop_proof = dpop_keypair.generate_proof("POST", &url, Some(&self.access_token)); + request_builder = request_builder + .header("Authorization", format!("DPoP {}", self.access_token)) + .header("DPoP", dpop_proof); + } else { + // App password with Bearer + request_builder = request_builder.header("Authorization", format!("Bearer {}", self.access_token)); + } + + let response = request_builder .header("Content-Type", mime_type) .body(data) .send() @@ -299,4 +340,38 @@ mod tests { let err = PdsError::NetworkError("Connection refused".to_string()); assert!(err.to_string().contains("Connection refused")); } + + #[test] + fn test_pds_client_new_with_dpop() { + use crate::oauth::dpop::DpopKeypair; + + let keypair = DpopKeypair::generate(); + let client = PdsClient::new_with_dpop("https://bsky.social".to_string(), "test_token".to_string(), keypair); + + assert_eq!(client.pds_url, "https://bsky.social"); + assert_eq!(client.access_token, "test_token"); + assert!(client.dpop_keypair.is_some()); + } + + #[test] + fn test_pds_client_new_bearer() { + let client = PdsClient::new_bearer("https://bsky.social".to_string(), "test_token".to_string()); + + assert_eq!(client.pds_url, "https://bsky.social"); + assert_eq!(client.access_token, "test_token"); + assert!(client.dpop_keypair.is_none()); + } + + #[test] + #[allow(deprecated)] + fn test_pds_client_new_deprecated() { + use crate::oauth::dpop::DpopKeypair; + + let keypair = DpopKeypair::generate(); + let client = PdsClient::new("https://bsky.social".to_string(), "test_token".to_string(), keypair); + + assert_eq!(client.pds_url, "https://bsky.social"); + assert_eq!(client.access_token, "test_token"); + assert!(client.dpop_keypair.is_some()); + } } diff --git a/crates/server/src/pds/publish.rs b/crates/server/src/pds/publish.rs index b22ea3f..d120059 100644 --- a/crates/server/src/pds/publish.rs +++ b/crates/server/src/pds/publish.rs @@ -2,9 +2,10 @@ //! //! Encapsulates the logic for publishing records to a user's PDS. +use crate::middleware::auth::UserContext; use crate::pds::client::{PdsClient, PdsError}; use crate::pds::records::{prepare_card_record, prepare_deck_record}; -use crate::repository::oauth::{OAuthRepoError, OAuthRepository, StoredToken}; +use crate::repository::oauth::{OAuthRepoError, OAuthRepository}; use malfestio_core::model::{Card, Deck}; use std::sync::Arc; @@ -63,38 +64,56 @@ pub struct PublishDeckResult { /// Publish a deck and its cards to the user's PDS. /// /// This function: -/// 1. Retrieves OAuth tokens for the user -/// 2. Creates a PDS client -/// 3. Publishes each card (with placeholder deck ref initially) -/// 4. Publishes the deck with card AT-URIs +/// 1. Tries to use OAuth tokens with DPoP if available +/// 2. Falls back to current session (supports app passwords with Bearer auth) +/// 3. Creates a PDS client with appropriate authentication +/// 4. Publishes each card (with placeholder deck ref initially) +/// 5. Publishes the deck with card AT-URIs /// /// Note: Cards are published with an empty deck_ref since we don't have the /// deck's AT-URI yet. This is acceptable per the Lexicon - the deck holds /// the authoritative list of card references. pub async fn publish_deck_to_pds( - oauth_repo: Arc, did: &str, deck: &Deck, cards: &[Card], + oauth_repo: Arc, user_ctx: &UserContext, deck: &Deck, cards: &[Card], ) -> Result { - let stored_token: StoredToken = oauth_repo.get_tokens(did).await?; - let dpop_keypair = stored_token.dpop_keypair().ok_or(PublishError::InvalidKeypair)?; - - let pds_client = PdsClient::new( - stored_token.pds_url.clone(), - stored_token.access_token.clone(), - dpop_keypair, - ); + let pds_client = if user_ctx.has_dpop { + if let Ok(stored_token) = oauth_repo.get_tokens(&user_ctx.did).await { + if let Some(dpop_keypair) = stored_token.dpop_keypair() { + tracing::info!("Using stored OAuth tokens with DPoP for publishing"); + PdsClient::new_with_dpop( + stored_token.pds_url.clone(), + stored_token.access_token.clone(), + dpop_keypair, + ) + } else { + tracing::info!( + "Current session has DPoP flag but stored token lacks keypair, using current session with Bearer auth" + ); + PdsClient::new_bearer(user_ctx.pds_url.clone(), user_ctx.access_token.clone()) + } + } else { + tracing::info!( + "Current session has DPoP flag but no stored tokens found, using current session with Bearer auth" + ); + PdsClient::new_bearer(user_ctx.pds_url.clone(), user_ctx.access_token.clone()) + } + } else { + tracing::info!("Using current session with Bearer auth for publishing (app password)"); + PdsClient::new_bearer(user_ctx.pds_url.clone(), user_ctx.access_token.clone()) + }; let mut card_at_uris = Vec::with_capacity(cards.len()); for card in cards { let prepared = prepare_card_record(card, ""); let at_uri = pds_client - .put_record(did, &prepared.collection, &prepared.rkey, prepared.record) + .put_record(&user_ctx.did, &prepared.collection, &prepared.rkey, prepared.record) .await?; card_at_uris.push(at_uri.to_string()); } let prepared = prepare_deck_record(deck, card_at_uris.clone()); let deck_at_uri = pds_client - .put_record(did, &prepared.collection, &prepared.rkey, prepared.record) + .put_record(&user_ctx.did, &prepared.collection, &prepared.rkey, prepared.record) .await?; Ok(PublishDeckResult { deck_at_uri: deck_at_uri.to_string(), card_at_uris }) diff --git a/web/package.json b/web/package.json index fed96dd..1ea3aaa 100644 --- a/web/package.json +++ b/web/package.json @@ -34,6 +34,7 @@ "@egoist/tailwindcss-icons": "^1.9.0", "@eslint/js": "^9.39.2", "@iconify-json/bi": "^1.2.7", + "@iconify-json/ri": "^1.2.7", "@resvg/resvg-js": "^2.6.2", "@solidjs/testing-library": "^0.8.10", "@testing-library/jest-dom": "^6.9.1", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 3af1bc4..a4ccb56 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -69,6 +69,9 @@ importers: '@iconify-json/bi': specifier: ^1.2.7 version: 1.2.7 + '@iconify-json/ri': + specifier: ^1.2.7 + version: 1.2.7 '@resvg/resvg-js': specifier: ^2.6.2 version: 2.6.2 @@ -500,6 +503,9 @@ packages: '@iconify-json/bi@1.2.7': resolution: {integrity: sha512-IPz8WNxmLkH1I9msl+0Q4OnmjjvP4uU0Z61a4i4sqonB6vKSbMGUWuGn8/YuuszlReVj8rf+3gNv5JU8Xoljyg==} + '@iconify-json/ri@1.2.7': + resolution: {integrity: sha512-j/Fkb8GlWY5y/zLj1BGxWRtDzuJFrI7562zLw+iQVEykieBgew43+r8qAvtSajvb75MfUIHjsNOYQPRD8FfLfw==} + '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} @@ -2589,6 +2595,10 @@ snapshots: dependencies: '@iconify/types': 2.0.0 + '@iconify-json/ri@1.2.7': + dependencies: + '@iconify/types': 2.0.0 + '@iconify/types@2.0.0': {} '@iconify/utils@2.3.0': diff --git a/web/src/components/StudySession.tsx b/web/src/components/StudySession.tsx index dd4e3da..b44b527 100644 --- a/web/src/components/StudySession.tsx +++ b/web/src/components/StudySession.tsx @@ -10,12 +10,12 @@ import { Motion } from "solid-motionone"; type StudySessionProps = { cards: ReviewCard[]; onComplete: () => void; onExit: () => void }; const GRADE_LABELS: { [key in Grade]: { label: string; color: string; key: string } } = { - 0: { label: "Again", color: "bg-red-600 hover:bg-red-500", key: "1" }, - 1: { label: "Hard", color: "bg-orange-600 hover:bg-orange-500", key: "2" }, - 2: { label: "Okay", color: "bg-yellow-600 hover:bg-yellow-500", key: "3" }, - 3: { label: "Good", color: "bg-green-600 hover:bg-green-500", key: "4" }, - 4: { label: "Easy", color: "bg-emerald-600 hover:bg-emerald-500", key: "5" }, - 5: { label: "Perfect", color: "bg-cyan-600 hover:bg-cyan-500", key: "5" }, + 0: { label: "Again", color: "text-red-500", key: "1" }, + 1: { label: "Hard", color: "text-orange-500", key: "2" }, + 2: { label: "Okay", color: "text-yellow-500", key: "3" }, + 3: { label: "Good", color: "text-green-500", key: "4" }, + 4: { label: "Easy", color: "text-emerald-500", key: "5" }, + 5: { label: "Perfect", color: "text-cyan-500", key: "6" }, }; export const StudySession: Component = (props) => { @@ -27,7 +27,7 @@ export const StudySession: Component = (props) => { const currentCard = () => props.cards[currentIndex()]; const progress = () => ((currentIndex() + 1) / props.cards.length) * 100; const isComplete = () => currentIndex() >= props.cards.length; - const handleFlip = () => !isFlipped() ? setIsFlipped(true) : void 0; + const handleFlip = () => setIsFlipped((f) => !f); const handleGrade = async (grade: Grade) => { const card = currentCard(); @@ -63,12 +63,15 @@ export const StudySession: Component = (props) => { if (isFlipped()) handleGrade(1); break; case "3": - if (isFlipped()) handleGrade(3); + if (isFlipped()) handleGrade(2); break; case "4": - if (isFlipped()) handleGrade(4); + if (isFlipped()) handleGrade(3); break; case "5": + if (isFlipped()) handleGrade(4); + break; + case "6": if (isFlipped()) handleGrade(5); break; case "e": @@ -96,9 +99,8 @@ export const StudySession: Component = (props) => { }); return ( -
- {/* Progress Header */} -
+
+
Card {currentIndex() + 1} of {props.cards.length}
- - {(card) => ( - -
+
+ + {(card) => ( +
- {card().deck_title} -

{card().front}

- -

Press Space or click to reveal

-
-
+ onClick={handleFlip} + class="relative w-full h-full cursor-pointer" + style={{ "perspective": "1000px" }}> +
+
+ {card.deck_title} +

{card.front}

+

Press Space or click to reveal

+
-
- Answer -

{card().back}

- 0}> -
- {(hint) =>

💡 {hint}

}
+
+ Answer +

{card.back}

+ 0}> +
+ {(hint) =>

💡 {hint}

}
+
+
- +
+ + )} + +
+ +
+ + +

How well did you know this?

+
+ + {(grade) => ( + + )} +
- )} -
- - - -

How well did you know this?

-
- - {(grade) => ( - - )} - -
-
-
+ +
Space: Flip - 1-5: Grade - E: Edit + + 1-6: Grade + E: Edit + Esc: Exit
diff --git a/web/src/components/tests/StudySession.test.tsx b/web/src/components/tests/StudySession.test.tsx index 6005642..653120b 100644 --- a/web/src/components/tests/StudySession.test.tsx +++ b/web/src/components/tests/StudySession.test.tsx @@ -68,16 +68,42 @@ describe("StudySession", () => { expect(await screen.findByText("How well did you know this?")).toBeInTheDocument(); }); - it("shows keyboard hints", () => { + it("flips back to front on second click", async () => { + const onComplete = vi.fn(); + const onExit = vi.fn(); + + render(() => ); + + const cardElement = screen.getByText("What is 2+2?").closest("div[class*='cursor-pointer']"); + if (cardElement) fireEvent.click(cardElement); + + expect(await screen.findByText("How well did you know this?")).toBeInTheDocument(); + + if (cardElement) fireEvent.click(cardElement); + expect(await screen.findByText("Press Space or click to reveal")).toBeInTheDocument(); + expect(screen.queryByText("How well did you know this?")).not.toBeInTheDocument(); + }); + + it("shows keyboard hints conditionally", async () => { const onComplete = vi.fn(); const onExit = vi.fn(); render(() => ); expect(screen.getByText("Space: Flip")).toBeInTheDocument(); - expect(screen.getByText("1-5: Grade")).toBeInTheDocument(); - expect(screen.getByText("E: Edit")).toBeInTheDocument(); expect(screen.getByText("Esc: Exit")).toBeInTheDocument(); + + // Initially hidden + expect(screen.queryByText("1-6: Grade")).not.toBeInTheDocument(); + expect(screen.queryByText("E: Edit")).not.toBeInTheDocument(); + + // Flip card + const cardElement = screen.getByText("What is 2+2?").closest("div[class*='cursor-pointer']"); + if (cardElement) fireEvent.click(cardElement); + + // Now visible + expect(await screen.findByText("1-6: Grade")).toBeInTheDocument(); + expect(screen.getByText("E: Edit")).toBeInTheDocument(); }); it("calls onExit when exit button is clicked", () => { diff --git a/web/src/pages/DeckView.tsx b/web/src/pages/DeckView.tsx index ffe00ef..454e62b 100644 --- a/web/src/pages/DeckView.tsx +++ b/web/src/pages/DeckView.tsx @@ -1,5 +1,6 @@ import { CommentSection } from "$components/social/CommentSection"; import { FollowButton } from "$components/social/FollowButton"; +import { StudySession } from "$components/StudySession"; import { Button } from "$components/ui/Button"; import { Card } from "$components/ui/Card"; import { Dialog } from "$components/ui/Dialog"; @@ -7,7 +8,7 @@ import { EmptyState } from "$components/ui/EmptyState"; import { Skeleton } from "$components/ui/Skeleton"; import { Tag } from "$components/ui/Tag"; import { api } from "$lib/api"; -import type { Card as CardType, Deck } from "$lib/model"; +import type { Card as CardType, Deck, ReviewCard } from "$lib/model"; import { toast } from "$lib/toast"; import { A, useNavigate, useParams } from "@solidjs/router"; import type { Component } from "solid-js"; @@ -45,6 +46,13 @@ const DeckView: Component = () => { return res.ok ? ((await res.json()) as CardType[]) : []; }); + const [dueCards, { refetch: refetchDueCards }] = createResource(() => params.id, async (id) => { + const res = await api.getDueCards(id); + return res.ok ? ((await res.json()) as ReviewCard[]) : []; + }); + + const [isStudying, setIsStudying] = createSignal(false); + const handleFork = async () => { if (deck()) { try { @@ -65,153 +73,183 @@ const DeckView: Component = () => { } }; + const handleStudyComplete = () => { + setIsStudying(false); + refetchDueCards(); + toast.success("Session complete!"); + }; + return ( - - - - - -
- - -
-
- }> + + {(cards) => ( + setIsStudying(false)} /> + )} + + }> + } - action={ - - - - } /> +
+ + + +
+ + +
+
}> - {(deckValue) => ( - <> - -
-

{deckValue().title}

- - - -
+ } + action={ + + + + } /> + }> + {(deckValue) => ( + <> + +
+

{deckValue().title}

+ + + +
-
-
By {deckValue().owner_did}
- -
+
+
By {deckValue().owner_did}
+ +
-

{deckValue().description}

+

{deckValue().description}

- 0}> -
- {(tag) => } -
-
+ 0}> +
+ {(tag) => } +
+
-
- - - - - -
-
+
+ + + + + +
+
- -

- Cards {(value) => ({value().length})} -

+ +

+ Cards{" "} + {(value) => ({value().length})} +

- {() => }}> -
- } /> - }> - {(card, i) => ( - - -
- CARD {i() + 1} -
-
-
-
Front
-
{card.front}
+ {() => }}> +
+ } /> + }> + {(card, i) => ( + + +
+ CARD {i() + 1}
-
-
Back
-
- {card.back || Empty} +
+
+
Front
+
{card.front}
+
+
+
Back
+
+ {card.back || Empty} +
-
- - - )} - -
- -
+ + + )} +
+
+
+ - - - - - )} + + + + + )} + - - - setShowForkDialog(false)} - title="Fork Deck" - actions={ - <> - - - - }> -

Are you sure you want to fork "{deck()?.title}"?

-

- This will create a copy of this deck in your library that you can study and edit. -

-
- + setShowForkDialog(false)} + title="Fork Deck" + actions={ + <> + + + + }> +

Are you sure you want to fork "{deck()?.title}"?

+

+ This will create a copy of this deck in your library that you can study and edit. +

+
+ + ); }; diff --git a/web/src/pages/tests/DeckView.test.tsx b/web/src/pages/tests/DeckView.test.tsx index 2600ab4..e59aa04 100644 --- a/web/src/pages/tests/DeckView.test.tsx +++ b/web/src/pages/tests/DeckView.test.tsx @@ -10,7 +10,15 @@ const { mockNavigate } = vi.hoisted(() => ({ mockNavigate: vi.fn() })); vi.mock( "$lib/api", () => ({ - api: { getDeck: vi.fn(), getDeckCards: vi.fn(), forkDeck: vi.fn(), getComments: vi.fn(), addComment: vi.fn() }, + api: { + getDeck: vi.fn(), + getDeckCards: vi.fn(), + forkDeck: vi.fn(), + getComments: vi.fn(), + addComment: vi.fn(), + getDueCards: vi.fn(), + submitReview: vi.fn(), + }, }), ); @@ -49,6 +57,7 @@ describe("DeckView", () => { vi.mocked(api.getDeckCards).mockResolvedValue( { ok: true, json: () => Promise.resolve(mockCards) } as unknown as Response, ); + vi.mocked(api.getDueCards).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response); vi.mocked(api.getComments).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response); render(() => ); @@ -66,6 +75,7 @@ describe("DeckView", () => { vi.mocked(api.getDeckCards).mockResolvedValue( { ok: true, json: () => Promise.resolve(mockCards) } as unknown as Response, ); + vi.mocked(api.getDueCards).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response); vi.mocked(api.forkDeck).mockResolvedValue( { ok: true, json: () => Promise.resolve({ id: "456" }) } as unknown as Response, ); @@ -75,10 +85,10 @@ describe("DeckView", () => { await waitFor(() => expect(screen.getByText("Test Deck")).toBeInTheDocument()); - const forkButton = screen.getByText("Fork Deck", { selector: "button" }); + const forkButton = screen.getByRole("button", { name: /Fork Deck/i }); fireEvent.click(forkButton); - const dialog = screen.getByRole("dialog"); + const dialog = await screen.findByRole("dialog"); expect(within(dialog).getByText(/Are you sure you want to fork/)).toBeInTheDocument(); const confirmButton = within(dialog).getByRole("button", { name: /Fork Deck/i }); @@ -98,6 +108,7 @@ describe("DeckView", () => { vi.mocked(api.getDeckCards).mockResolvedValue( { ok: true, json: () => Promise.resolve(mockCards) } as unknown as Response, ); + vi.mocked(api.getDueCards).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response); vi.mocked(api.forkDeck).mockResolvedValue({ ok: false } as unknown as Response); vi.mocked(api.getComments).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response); @@ -105,10 +116,10 @@ describe("DeckView", () => { await waitFor(() => expect(screen.getByText("Test Deck")).toBeInTheDocument()); - const forkButton = screen.getByText("Fork Deck", { selector: "button" }); + const forkButton = screen.getByRole("button", { name: /Fork Deck/i }); fireEvent.click(forkButton); - const dialog = screen.getByRole("dialog"); + const dialog = await screen.findByRole("dialog"); const confirmButton = within(dialog).getByRole("button", { name: /Fork Deck/i }); fireEvent.click(confirmButton); @@ -121,7 +132,66 @@ describe("DeckView", () => { it("renders not found state when deck returns error", async () => { vi.mocked(api.getDeck).mockResolvedValue({ ok: false } as unknown as Response); + vi.mocked(api.getDueCards).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response); render(() => ); await waitFor(() => expect(screen.getByText(/Deck not found/i)).toBeInTheDocument()); }); + it("renders study button with due cards count", async () => { + vi.mocked(api.getDeck).mockResolvedValue( + { ok: true, json: () => Promise.resolve(mockDeck) } as unknown as Response, + ); + vi.mocked(api.getDeckCards).mockResolvedValue( + { ok: true, json: () => Promise.resolve(mockCards) } as unknown as Response, + ); + vi.mocked(api.getDueCards).mockResolvedValue( + { + ok: true, + json: () => Promise.resolve([{ review_id: "r1", card_id: "c1", deck_id: "123", front: "F", back: "B" }]), + } as unknown as Response, + ); + vi.mocked(api.getComments).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response); + + render(() => ); + + await waitFor(() => expect(screen.getByText("Test Deck")).toBeInTheDocument()); + + const studyButton = await screen.findByRole("button", { name: /Study Deck \(1 due\)/i }); + expect(studyButton).toBeInTheDocument(); + expect(studyButton).not.toBeDisabled(); + }); + + it("enters study mode when study button is clicked", async () => { + vi.mocked(api.getDeck).mockResolvedValue( + { ok: true, json: () => Promise.resolve(mockDeck) } as unknown as Response, + ); + vi.mocked(api.getDeckCards).mockResolvedValue( + { ok: true, json: () => Promise.resolve(mockCards) } as unknown as Response, + ); + vi.mocked(api.getDueCards).mockResolvedValue( + { + ok: true, + json: () => + Promise.resolve([{ + review_id: "r1", + card_id: "c1", + deck_id: "123", + front: "Study Front", + back: "Study Back", + deck_title: "Test Deck", + hints: [], + }]), + } as unknown as Response, + ); + vi.mocked(api.getComments).mockResolvedValue({ ok: true, json: () => Promise.resolve([]) } as unknown as Response); + + render(() => ); + + await waitFor(() => expect(screen.getByText("Test Deck")).toBeInTheDocument()); + + const studyButton = await screen.findByRole("button", { name: /Study Deck \(1 due\)/i }); + fireEvent.click(studyButton); + + await waitFor(() => expect(screen.getByText("Card 1 of 1")).toBeInTheDocument()); + expect(screen.getByText("Study Front")).toBeInTheDocument(); + }); });