learn and share notes on atproto (wip) 🦉 malfestio.stormlightlabs.org/
readability solid axum atproto srs
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: OAuth login flow and add DNS TXT record identity resolution for handles

+346 -67
+92 -11
Cargo.lock
··· 109 109 "async-trait", 110 110 "ecdsa", 111 111 "elliptic-curve", 112 - "hickory-resolver", 112 + "hickory-resolver 0.25.2", 113 113 "k256", 114 114 "lru", 115 115 "multibase", ··· 120 120 "serde", 121 121 "serde_ipld_dagcbor", 122 122 "serde_json", 123 - "thiserror", 123 + "thiserror 2.0.17", 124 124 "tokio", 125 125 "tracing", 126 126 "urlencoding", ··· 139 139 "http 1.4.0", 140 140 "serde", 141 141 "serde_json", 142 - "thiserror", 142 + "thiserror 2.0.17", 143 143 "tokio", 144 144 "tokio-util", 145 145 "tokio-websockets", ··· 1036 1036 1037 1037 [[package]] 1038 1038 name = "hickory-proto" 1039 + version = "0.24.4" 1040 + source = "registry+https://github.com/rust-lang/crates.io-index" 1041 + checksum = "92652067c9ce6f66ce53cc38d1169daa36e6e7eb7dd3b63b5103bd9d97117248" 1042 + dependencies = [ 1043 + "async-trait", 1044 + "cfg-if", 1045 + "data-encoding", 1046 + "enum-as-inner", 1047 + "futures-channel", 1048 + "futures-io", 1049 + "futures-util", 1050 + "idna", 1051 + "ipnet", 1052 + "once_cell", 1053 + "rand 0.8.5", 1054 + "thiserror 1.0.69", 1055 + "tinyvec", 1056 + "tokio", 1057 + "tracing", 1058 + "url", 1059 + ] 1060 + 1061 + [[package]] 1062 + name = "hickory-proto" 1039 1063 version = "0.25.2" 1040 1064 source = "registry+https://github.com/rust-lang/crates.io-index" 1041 1065 checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" ··· 1052 1076 "once_cell", 1053 1077 "rand 0.9.2", 1054 1078 "ring", 1055 - "thiserror", 1079 + "thiserror 2.0.17", 1056 1080 "tinyvec", 1057 1081 "tokio", 1058 1082 "tracing", ··· 1061 1085 1062 1086 [[package]] 1063 1087 name = "hickory-resolver" 1088 + version = "0.24.4" 1089 + source = "registry+https://github.com/rust-lang/crates.io-index" 1090 + checksum = "cbb117a1ca520e111743ab2f6688eddee69db4e0ea242545a604dce8a66fd22e" 1091 + dependencies = [ 1092 + "cfg-if", 1093 + "futures-util", 1094 + "hickory-proto 0.24.4", 1095 + "ipconfig", 1096 + "lru-cache", 1097 + "once_cell", 1098 + "parking_lot", 1099 + "rand 0.8.5", 1100 + "resolv-conf", 1101 + "smallvec", 1102 + "thiserror 1.0.69", 1103 + "tokio", 1104 + "tracing", 1105 + ] 1106 + 1107 + [[package]] 1108 + name = "hickory-resolver" 1064 1109 version = "0.25.2" 1065 1110 source = "registry+https://github.com/rust-lang/crates.io-index" 1066 1111 checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" 1067 1112 dependencies = [ 1068 1113 "cfg-if", 1069 1114 "futures-util", 1070 - "hickory-proto", 1115 + "hickory-proto 0.25.2", 1071 1116 "ipconfig", 1072 1117 "moka", 1073 1118 "once_cell", ··· 1075 1120 "rand 0.9.2", 1076 1121 "resolv-conf", 1077 1122 "smallvec", 1078 - "thiserror", 1123 + "thiserror 2.0.17", 1079 1124 "tokio", 1080 1125 "tracing", 1081 1126 ] ··· 1543 1588 ] 1544 1589 1545 1590 [[package]] 1591 + name = "linked-hash-map" 1592 + version = "0.5.6" 1593 + source = "registry+https://github.com/rust-lang/crates.io-index" 1594 + checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" 1595 + 1596 + [[package]] 1546 1597 name = "linux-raw-sys" 1547 1598 version = "0.11.0" 1548 1599 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1579 1630 ] 1580 1631 1581 1632 [[package]] 1633 + name = "lru-cache" 1634 + version = "0.1.2" 1635 + source = "registry+https://github.com/rust-lang/crates.io-index" 1636 + checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" 1637 + dependencies = [ 1638 + "linked-hash-map", 1639 + ] 1640 + 1641 + [[package]] 1582 1642 name = "lru-slab" 1583 1643 version = "0.1.2" 1584 1644 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1609 1669 "chrono", 1610 1670 "serde", 1611 1671 "serde_json", 1612 - "thiserror", 1672 + "thiserror 2.0.17", 1613 1673 ] 1614 1674 1615 1675 [[package]] ··· 1626 1686 "dotenvy", 1627 1687 "ed25519-dalek", 1628 1688 "getrandom 0.3.4", 1689 + "hickory-resolver 0.24.4", 1629 1690 "malfestio-core", 1630 1691 "readability", 1631 1692 "regex", ··· 2155 2216 "rustc-hash", 2156 2217 "rustls", 2157 2218 "socket2 0.6.1", 2158 - "thiserror", 2219 + "thiserror 2.0.17", 2159 2220 "tokio", 2160 2221 "tracing", 2161 2222 "web-time", ··· 2176 2237 "rustls", 2177 2238 "rustls-pki-types", 2178 2239 "slab", 2179 - "thiserror", 2240 + "thiserror 2.0.17", 2180 2241 "tinyvec", 2181 2242 "tracing", 2182 2243 "web-time", ··· 2697 2758 dependencies = [ 2698 2759 "percent-encoding", 2699 2760 "serde", 2700 - "thiserror", 2761 + "thiserror 2.0.17", 2701 2762 ] 2702 2763 2703 2764 [[package]] ··· 3004 3065 3005 3066 [[package]] 3006 3067 name = "thiserror" 3068 + version = "1.0.69" 3069 + source = "registry+https://github.com/rust-lang/crates.io-index" 3070 + checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 3071 + dependencies = [ 3072 + "thiserror-impl 1.0.69", 3073 + ] 3074 + 3075 + [[package]] 3076 + name = "thiserror" 3007 3077 version = "2.0.17" 3008 3078 source = "registry+https://github.com/rust-lang/crates.io-index" 3009 3079 checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" 3010 3080 dependencies = [ 3011 - "thiserror-impl", 3081 + "thiserror-impl 2.0.17", 3082 + ] 3083 + 3084 + [[package]] 3085 + name = "thiserror-impl" 3086 + version = "1.0.69" 3087 + source = "registry+https://github.com/rust-lang/crates.io-index" 3088 + checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" 3089 + dependencies = [ 3090 + "proc-macro2", 3091 + "quote", 3092 + "syn 2.0.111", 3012 3093 ] 3013 3094 3014 3095 [[package]]
+1
crates/server/Cargo.toml
··· 36 36 tracing = "0.1.44" 37 37 tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } 38 38 uuid = { version = "1.19.0", features = ["v4", "fast-rng"] } 39 + hickory-resolver = "0.24.0"
+47 -9
crates/server/src/oauth/resolver.rs
··· 41 41 42 42 impl std::error::Error for ResolveError {} 43 43 44 + #[allow(deprecated)] 45 + use hickory_resolver::TokioAsyncResolver; 46 + #[allow(deprecated)] 47 + use hickory_resolver::name_server::TokioConnectionProvider; 48 + 44 49 /// Resolver for AT Protocol identities. 45 50 /// 46 51 /// Handles resolution of: ··· 49 54 pub struct IdentityResolver { 50 55 client: reqwest::Client, 51 56 plc_directory: String, 57 + #[allow(deprecated)] 58 + dns_resolver: TokioAsyncResolver, 52 59 } 53 60 54 61 impl Default for IdentityResolver { ··· 60 67 impl IdentityResolver { 61 68 /// Create a new resolver with default settings. 62 69 pub fn new() -> Self { 63 - Self { client: reqwest::Client::new(), plc_directory: "https://plc.directory".to_string() } 70 + #[allow(deprecated)] 71 + let (config, options) = hickory_resolver::system_conf::read_system_conf().expect("Failed to read system conf"); 72 + #[allow(deprecated)] 73 + let dns_resolver = TokioAsyncResolver::new(config, options, TokioConnectionProvider::default()); 74 + 75 + Self { client: reqwest::Client::new(), plc_directory: "https://plc.directory".to_string(), dns_resolver } 64 76 } 65 77 66 78 /// Create a resolver with a custom PLC directory URL. 67 79 pub fn with_plc_directory(plc_directory: &str) -> Self { 68 - Self { client: reqwest::Client::new(), plc_directory: plc_directory.to_string() } 80 + #[allow(deprecated)] 81 + let (config, options) = hickory_resolver::system_conf::read_system_conf().expect("Failed to read system conf"); 82 + #[allow(deprecated)] 83 + let dns_resolver = TokioAsyncResolver::new(config, options, TokioConnectionProvider::default()); 84 + 85 + Self { client: reqwest::Client::new(), plc_directory: plc_directory.to_string(), dns_resolver } 69 86 } 70 87 71 88 /// Resolve a handle to a DID. 72 89 /// 73 90 /// Tries HTTP well-known first, then falls back to DNS TXT. 74 91 pub async fn resolve_handle(&self, handle: &str) -> Result<String, ResolveError> { 75 - // Try HTTP well-known first 76 92 if let Ok(did) = self.resolve_handle_http(handle).await { 77 93 return Ok(did); 78 94 } 95 + self.resolve_handle_dns(handle).await 96 + } 79 97 80 - // Fall back to DNS TXT (simplified - just return error for now) 81 - Err(ResolveError::HandleNotFound(handle.to_string())) 98 + /// Resolve handle via DNS TXT record (_atproto.<handle>). 99 + async fn resolve_handle_dns(&self, handle: &str) -> Result<String, ResolveError> { 100 + let query = format!("_atproto.{}", handle); 101 + 102 + match self.dns_resolver.txt_lookup(query).await { 103 + Ok(records) => { 104 + for record in records.iter() { 105 + let text = record.to_string(); 106 + if let Some(did) = text.strip_prefix("did=") { 107 + return Ok(did.trim().to_string()); 108 + } 109 + } 110 + Err(ResolveError::HandleNotFound(handle.to_string())) 111 + } 112 + Err(e) => Err(ResolveError::NetworkError(e.to_string())), 113 + } 82 114 } 83 115 84 116 /// Resolve handle via HTTP well-known. ··· 143 175 .await 144 176 .map_err(|e| ResolveError::NetworkError(e.to_string()))?; 145 177 146 - // Extract PDS URL from service array 147 178 let pds_url = doc["service"] 148 179 .as_array() 149 180 .and_then(|services| { ··· 155 186 .ok_or_else(|| ResolveError::DidNotFound(did.to_string()))? 156 187 .to_string(); 157 188 158 - // Extract handle from alsoKnownAs 159 189 let handle = doc["alsoKnownAs"] 160 190 .as_array() 161 191 .and_then(|aka| { ··· 170 200 171 201 /// Resolve a did:web via HTTP. 172 202 async fn resolve_web_did(&self, did: &str) -> Result<ResolvedIdentity, ResolveError> { 173 - // did:web:example.com -> https://example.com/.well-known/did.json 174 203 let domain = did 175 204 .strip_prefix("did:web:") 176 205 .ok_or_else(|| ResolveError::InvalidDid(did.to_string()))?; ··· 216 245 217 246 /// Check if a string is a valid handle. 218 247 pub fn is_valid_handle(s: &str) -> bool { 219 - // Simple validation: contains at least one dot, no spaces 220 248 s.contains('.') && !s.contains(' ') && !s.starts_with("did:") 221 249 } 222 250 ··· 257 285 258 286 let err = ResolveError::InvalidDid("bad:did".to_string()); 259 287 assert!(err.to_string().contains("bad:did")); 288 + } 289 + 290 + #[tokio::test] 291 + async fn test_resolve_handle_fallback_logic() { 292 + let resolver = IdentityResolver::new(); 293 + let result = resolver.resolve_handle("nonexistent.invalid").await; 294 + assert!(matches!( 295 + result, 296 + Err(ResolveError::HandleNotFound(_)) | Err(ResolveError::NetworkError(_)) 297 + )); 260 298 } 261 299 }
+7 -2
docs/core-user-journeys.md
··· 172 172 ### Login 173 173 174 174 1. Navigate to `/login` 175 - 2. Enter Bluesky handle and app password 176 - 3. Submit → redirected to Library 175 + 2. **Option A (OAuth - Recommended)**: 176 + - Enter handle only 177 + - Click "Continue" → redirected to PDS login page 178 + - Approve access → redirected back to Library 179 + 3. **Option B (Legacy)**: 180 + - Enter handle and App Password 181 + - Click "Continue" → redirected to Library 177 182 178 183 ### Logout 179 184
+3 -3
docs/todo.md
··· 51 51 - [ ] Open Graph / Twitter Card meta tags 52 52 - [ ] Scripted with HTML2Canvas/PNG generation 53 53 - Graph paper background 54 - - Floating flash cards, notes 54 + - Floating flash cards, notes (scribbles instead of text) 55 55 - [ ] Sitemap.xml generation 56 56 - [ ] robots.txt configuration 57 57 ··· 91 91 92 92 **Identity & Auth:** 93 93 94 - - [ ] OAuth login directly to user's PDS (vs. local-only auth) 95 - - [ ] Handle resolution via DNS TXT or `/.well-known/atproto-did` 94 + - [x] OAuth login directly to user's PDS 95 + - [x] Handle resolution via DNS TXT or `/.well-known/atproto-did` 96 96 - <https://malfestio.stormlightlabs.org> 97 97 - [ ] DPoP token binding for secure API calls 98 98
+9
web/src/lib/api.ts
··· 80 80 updatePreferences: (updates: import("./model").UpdatePreferencesPayload) => { 81 81 return apiFetch("/preferences", { method: "PUT", body: JSON.stringify(updates) }); 82 82 }, 83 + startOAuth: async (handle: string) => { 84 + const res = await apiFetch("/oauth/authorize", { method: "POST", body: JSON.stringify({ handle }) }); 85 + if (res.ok) { 86 + const data = await res.json(); 87 + window.location.href = data.authorization_url; 88 + return { ok: true }; 89 + } 90 + return res; 91 + }, 83 92 };
+64 -42
web/src/pages/Login.tsx
··· 1 + import { AppLayout } from "$components/layout/AppLayout"; 1 2 import { api } from "$lib/api"; 2 3 import { authStore } from "$lib/store"; 3 4 import { useNavigate } from "@solidjs/router"; ··· 17 18 setError(""); 18 19 19 20 try { 21 + if (!password()) { 22 + const res = await api.startOAuth(identifier()); 23 + if (!res.ok) { 24 + let errorMsg = "OAuth init failed"; 25 + if ("json" in res) { 26 + const err = await res.json(); 27 + errorMsg = err.error || errorMsg; 28 + } 29 + setError(errorMsg); 30 + setIsLoading(false); 31 + } 32 + 33 + return; 34 + } 35 + 20 36 const response = await api.post("/auth/login", { identifier: identifier(), password: password() }); 21 37 22 38 if (response.ok) { ··· 35 51 }; 36 52 37 53 return ( 38 - <div class="min-h-[calc(100vh-4rem)] flex items-center justify-center bg-[#161616] p-4 font-sans text-[#F4F4F4]"> 39 - <div class="w-full max-w-md bg-[#262626] border border-[#393939] p-8 shadow-lg"> 40 - <h1 class="text-3xl font-light text-[#F4F4F4] mb-2 tracking-tight">Log in</h1> 41 - <p class="text-[#C6C6C6] text-sm mb-8 font-light">Continue to Malfestio</p> 54 + <AppLayout> 55 + <div class="min-h-[calc(100vh-8rem)] flex items-center justify-center p-4"> 56 + <div class="w-full max-w-md bg-[#262626] border border-[#393939] p-8 shadow-lg section-entry"> 57 + <h1 class="text-3xl font-light text-[#F4F4F4] mb-2 tracking-tight">Log in</h1> 58 + <p class="text-[#C6C6C6] text-sm mb-8 font-light">Continue to Malfestio</p> 59 + 60 + <form onSubmit={handleLogin} class="space-y-6"> 61 + {error() && ( 62 + <div class="bg-red-900/20 text-red-400 text-sm p-4 border-l-2 border-red-500 flex items-start gap-2 animate-in fade-in slide-in-from-top-2"> 63 + <span class="font-bold">Error:</span> {error()} 64 + </div> 65 + )} 42 66 43 - <form onSubmit={handleLogin} class="space-y-6"> 44 - {error() && ( 45 - <div class="bg-red-900/20 text-red-400 text-sm p-4 border-l-2 border-red-500 flex items-start gap-2"> 46 - <span class="font-bold">Error:</span> {error()} 67 + <div class="space-y-2"> 68 + <label class="block text-xs font-semibold text-[#8D8D8D] uppercase tracking-wider">Handle</label> 69 + <input 70 + type="text" 71 + value={identifier()} 72 + onInput={(e) => setIdentifier(e.currentTarget.value)} 73 + class="w-full bg-[#161616] border-b border-[#8D8D8D] focus:border-[#0F62FE] focus:outline-none p-4 transition-colors text-[#F4F4F4] placeholder-[#525252]" 74 + placeholder="user.bsky.social" 75 + required /> 47 76 </div> 48 - )} 49 77 50 - <div class="space-y-2"> 51 - <label class="block text-xs font-semibold text-[#8D8D8D] uppercase tracking-wider">Handle</label> 52 - <input 53 - type="text" 54 - value={identifier()} 55 - onInput={(e) => setIdentifier(e.currentTarget.value)} 56 - class="w-full bg-[#161616] border-b border-[#8D8D8D] focus:border-[#0F62FE] focus:outline-none p-4 transition-colors text-[#F4F4F4] placeholder-[#525252]" 57 - placeholder="user.bsky.social" 58 - required /> 59 - </div> 78 + <div class="space-y-2"> 79 + <div class="flex justify-between"> 80 + <label class="block text-xs font-semibold text-[#8D8D8D] uppercase tracking-wider"> 81 + App Password (Optional) 82 + </label> 83 + <span class="text-xs text-[#8D8D8D] italic">Leave blank for OAuth</span> 84 + </div> 85 + <input 86 + type="password" 87 + value={password()} 88 + onInput={(e) => setPassword(e.currentTarget.value)} 89 + class="w-full bg-[#161616] border-b border-[#8D8D8D] focus:border-[#0F62FE] focus:outline-none p-4 transition-colors text-[#F4F4F4] placeholder-[#525252]" 90 + placeholder="••••••••" /> 91 + </div> 60 92 61 - <div class="space-y-2"> 62 - <label class="block text-xs font-semibold text-[#8D8D8D] uppercase tracking-wider">App Password</label> 63 - <input 64 - type="password" 65 - value={password()} 66 - onInput={(e) => setPassword(e.currentTarget.value)} 67 - class="w-full bg-[#161616] border-b border-[#8D8D8D] focus:border-[#0F62FE] focus:outline-none p-4 transition-colors text-[#F4F4F4] placeholder-[#525252]" 68 - placeholder="••••••••" 69 - required /> 70 - </div> 93 + <div class="pt-4"> 94 + <button 95 + type="submit" 96 + disabled={isLoading()} 97 + class="w-full bg-[#0F62FE] hover:bg-[#0353E9] text-white py-4 font-medium text-sm text-left px-4 flex justify-between items-center transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-[#393939]"> 98 + {isLoading() ? "Authenticating..." : (password() ? "Log in with Password" : "Continue with OAuth")} 99 + <span class="text-lg">→</span> 100 + </button> 101 + </div> 102 + </form> 71 103 72 - <div class="pt-4"> 73 - <button 74 - type="submit" 75 - disabled={isLoading()} 76 - class="w-full bg-[#0F62FE] hover:bg-[#0353E9] text-white py-4 font-medium text-sm text-left px-4 flex justify-between items-center transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-[#393939]"> 77 - {isLoading() ? "Authenticating..." : "Continue"} 78 - <span class="text-lg">→</span> 79 - </button> 104 + <div class="mt-8 text-xs text-[#8D8D8D] border-t border-[#393939] pt-4"> 105 + <p>Use your Handle to log in via your PDS (OAuth), or provide an App Password directly.</p> 80 106 </div> 81 - </form> 82 - 83 - <div class="mt-8 text-xs text-[#8D8D8D] border-t border-[#393939] pt-4"> 84 - <p>Use your BlueSky App Password, not your main password.</p> 85 107 </div> 86 108 </div> 87 - </div> 109 + </AppLayout> 88 110 ); 89 111 }; 90 112
+123
web/src/pages/tests/Login.test.tsx
··· 1 + import { api } from "$lib/api"; 2 + import { authStore } from "$lib/store"; 3 + import { cleanup, fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 4 + import { JSX } from "solid-js"; 5 + import { afterEach, describe, expect, it, vi } from "vitest"; 6 + import Login from "../Login"; 7 + 8 + const { mockNavigate } = vi.hoisted(() => ({ mockNavigate: vi.fn() })); 9 + 10 + vi.mock("$lib/api", () => ({ api: { startOAuth: vi.fn(), post: vi.fn() } })); 11 + 12 + vi.mock("$lib/store", () => ({ authStore: { login: vi.fn() } })); 13 + 14 + vi.mock("@solidjs/router", () => ({ useNavigate: () => mockNavigate })); 15 + 16 + vi.mock( 17 + "$components/layout/AppLayout", 18 + () => ({ AppLayout: (props: { children: JSX.Element }) => <div data-testid="app-layout">{props.children}</div> }), 19 + ); 20 + 21 + describe("Login Page", () => { 22 + afterEach(() => { 23 + cleanup(); 24 + vi.clearAllMocks(); 25 + }); 26 + 27 + it("renders login form correctly", () => { 28 + render(() => <Login />); 29 + expect(screen.getByText("Log in")).toBeInTheDocument(); 30 + expect(screen.getByPlaceholderText("user.bsky.social")).toBeInTheDocument(); 31 + expect(screen.getByPlaceholderText("••••••••")).toBeInTheDocument(); 32 + expect(screen.getByText("Continue with OAuth")).toBeInTheDocument(); 33 + }); 34 + 35 + it("switches button text based on password", async () => { 36 + render(() => <Login />); 37 + const passwordInput = screen.getByPlaceholderText("••••••••"); 38 + const button = screen.getByRole("button"); 39 + 40 + expect(button).toHaveTextContent("Continue with OAuth"); 41 + 42 + fireEvent.input(passwordInput, { target: { value: "password123" } }); 43 + expect(button).toHaveTextContent("Log in with Password"); 44 + 45 + fireEvent.input(passwordInput, { target: { value: "" } }); 46 + expect(button).toHaveTextContent("Continue with OAuth"); 47 + }); 48 + 49 + it("initiates OAuth flow when password is empty", async () => { 50 + vi.mocked(api.startOAuth).mockResolvedValue({ ok: true } as Response); 51 + 52 + render(() => <Login />); 53 + const handleInput = screen.getByPlaceholderText("user.bsky.social"); 54 + const button = screen.getByRole("button"); 55 + 56 + fireEvent.input(handleInput, { target: { value: "alice.bsky.social" } }); 57 + fireEvent.click(button); 58 + 59 + await waitFor(() => { 60 + expect(api.startOAuth).toHaveBeenCalledWith("alice.bsky.social"); 61 + }); 62 + }); 63 + 64 + it("initiates Legacy flow when password is provided", async () => { 65 + vi.mocked(api.post).mockResolvedValue( 66 + { ok: true, json: () => Promise.resolve({ accessJwt: "token", did: "did:plc:123" }) } as Response, 67 + ); 68 + 69 + render(() => <Login />); 70 + const handleInput = screen.getByPlaceholderText("user.bsky.social"); 71 + const passwordInput = screen.getByPlaceholderText("••••••••"); 72 + const button = screen.getByRole("button"); 73 + 74 + fireEvent.input(handleInput, { target: { value: "alice.bsky.social" } }); 75 + fireEvent.input(passwordInput, { target: { value: "password123" } }); 76 + fireEvent.click(button); 77 + 78 + await waitFor(() => { 79 + expect(api.post).toHaveBeenCalledWith("/auth/login", { 80 + identifier: "alice.bsky.social", 81 + password: "password123", 82 + }); 83 + expect(authStore.login).toHaveBeenCalled(); 84 + expect(mockNavigate).toHaveBeenCalledWith("/"); 85 + }); 86 + }); 87 + 88 + it("displays error on OAuth failure", async () => { 89 + vi.mocked(api.startOAuth).mockResolvedValue( 90 + { ok: false, json: () => Promise.resolve({ error: "Invalid handle" }) } as Response, 91 + ); 92 + 93 + render(() => <Login />); 94 + const handleInput = screen.getByPlaceholderText("user.bsky.social"); 95 + const button = screen.getByRole("button"); 96 + 97 + fireEvent.input(handleInput, { target: { value: "bad.handle" } }); 98 + fireEvent.click(button); 99 + 100 + await waitFor(() => { 101 + expect(screen.getByText(/Invalid handle/)).toBeInTheDocument(); 102 + }); 103 + }); 104 + 105 + it("displays error on Legacy Login failure", async () => { 106 + vi.mocked(api.post).mockResolvedValue( 107 + { ok: false, json: () => Promise.resolve({ error: "Wrong password" }) } as Response, 108 + ); 109 + 110 + render(() => <Login />); 111 + const handleInput = screen.getByPlaceholderText("user.bsky.social"); 112 + const passwordInput = screen.getByPlaceholderText("••••••••"); 113 + const button = screen.getByRole("button"); 114 + 115 + fireEvent.input(handleInput, { target: { value: "alice.bsky.social" } }); 116 + fireEvent.input(passwordInput, { target: { value: "wrongpass" } }); 117 + fireEvent.click(button); 118 + 119 + await waitFor(() => { 120 + expect(screen.getByText(/Wrong password/)).toBeInTheDocument(); 121 + }); 122 + }); 123 + });