A Minecraft Fabric mod that connects the game with ATProtocol ⛏️
8
fork

Configure Feed

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

feat(ui): add OAuth login button to config screen

Replace the single password-only login form with a dual-mode interface:
- "OAuth Login (Recommended)" opens browser for ATProto OAuth
- "App Password Login" remains as fallback
- Updated help text, status display, and security notice
- Shows auth type (OAuth vs app-password) in session info

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta Code <noreply@letta.com>

+184 -92
+184 -92
src/client/kotlin/com/jollywhoppers/screen/AtProtoConfigScreen.kt
··· 1 1 package com.jollywhoppers.screen 2 2 3 3 import com.jollywhoppers.AtprotoconnectClient 4 + import com.jollywhoppers.atproto.oauth.OAuthManager 4 5 import com.jollywhoppers.network.AtProtoPackets 5 6 import kotlinx.coroutines.CoroutineScope 6 7 import kotlinx.coroutines.Dispatchers ··· 12 13 import net.minecraft.client.gui.components.EditBox 13 14 import net.minecraft.client.gui.screens.Screen 14 15 import net.minecraft.network.chat.Component 15 - import net.minecraft.util.FormattedCharSequence 16 16 import org.slf4j.LoggerFactory 17 17 18 18 /** 19 19 * Configuration screen for ATProto Connect. 20 - * Provides GUI-based authentication without requiring commands. 21 - * 20 + * Provides GUI-based authentication with both OAuth and app-password login. 21 + * 22 22 * Security Features: 23 23 * - Client-side only authentication 24 24 * - Passwords never sent to server 25 + * - OAuth browser-based login (recommended) 26 + * - App-password fallback 25 27 * - Local session storage 26 28 * - Clear visual feedback 27 29 */ 28 - class AtProtoConfigScreen(private val parent: Screen?) : Screen(Component.literal("ATProto Connect Settings")) { 29 - 30 + class AtProtoConfigScreen(private val parent: Screen?) : Screen(Component.literal("SocialSync Settings")) { 31 + 30 32 private val logger = LoggerFactory.getLogger("atproto-connect-ui") 31 33 private val coroutineScope = CoroutineScope(Dispatchers.IO) 32 - 34 + 33 35 // UI Components 34 36 private var handleField: EditBox? = null 35 37 private var passwordField: EditBox? = null 38 + private var oauthButton: Button? = null 36 39 private var loginButton: Button? = null 37 40 private var logoutButton: Button? = null 38 41 private var statusText: Component? = null 39 42 private var isAuthenticating = false 40 - 43 + 41 44 // Session state 42 45 private val sessionManager = AtprotoconnectClient.sessionManager 43 - 46 + private val oAuthManager = AtprotoconnectClient.oAuthManager 47 + 44 48 override fun init() { 45 49 super.init() 46 - 50 + 47 51 val centerX = width / 2 48 52 val startY = 60 49 - 50 - // Title is rendered automatically by Screen 51 - 53 + 52 54 // Handle input field 53 55 handleField = EditBox( 54 56 minecraft!!.font, ··· 60 62 ).apply { 61 63 setHint(Component.literal("alice.bsky.social or did:plc:...")) 62 64 setMaxLength(256) 63 - 65 + 64 66 if (!sessionManager.hasSession()) { 65 67 value = "" 66 68 } 67 69 } 68 70 addRenderableWidget(handleField!!) 69 - 70 - // Password input field 71 + 72 + // Password input field (for app-password fallback) 71 73 passwordField = EditBox( 72 74 minecraft!!.font, 73 75 centerX - 150, ··· 76 78 20, 77 79 Component.literal("App Password") 78 80 ).apply { 79 - setHint(Component.literal("App Password (NOT main password)")) 81 + setHint(Component.literal("App Password (optional — for fallback login)")) 80 82 setMaxLength(256) 81 - // Note: Password masking not available in this API version 82 - // The password is client-side only and never sent to the server 83 83 } 84 84 addRenderableWidget(passwordField!!) 85 - 86 - // Login button 85 + 86 + // OAuth Login button (recommended, primary) 87 + oauthButton = Button.builder( 88 + Component.literal("OAuth Login (Recommended)"), 89 + Button.OnPress { onOAuthClicked() } 90 + ) 91 + .bounds(centerX - 155, startY + 60, 150, 20) 92 + .build() 93 + .also { addRenderableWidget(it) } 94 + 95 + // App-password Login button (fallback) 87 96 loginButton = Button.builder( 88 - Component.literal("Login"), 97 + Component.literal("App Password Login"), 89 98 Button.OnPress { onLoginClicked() } 90 99 ) 91 - .bounds(centerX - 155, startY + 60, 150, 20) 100 + .bounds(centerX + 5, startY + 60, 150, 20) 92 101 .build() 93 102 .also { addRenderableWidget(it) } 94 - 103 + 95 104 // Logout button 96 105 logoutButton = Button.builder( 97 106 Component.literal("Logout"), 98 107 Button.OnPress { onLogoutClicked() } 99 108 ) 100 - .bounds(centerX + 5, startY + 60, 150, 20) 109 + .bounds(centerX - 75, startY + 90, 150, 20) 101 110 .build() 102 111 .also { addRenderableWidget(it) } 103 - 112 + 104 113 // Done button 105 114 addRenderableWidget( 106 115 Button.builder( ··· 110 119 .bounds(centerX - 75, height - 30, 150, 20) 111 120 .build() 112 121 ) 113 - 114 - // Get app password help button 122 + 123 + // Help button 115 124 addRenderableWidget( 116 125 Button.builder( 117 - Component.literal("How to get App Password?"), 126 + Component.literal("Help"), 118 127 Button.OnPress { onHelpClicked() } 119 128 ) 120 - .bounds(centerX - 100, startY + 90, 200, 20) 129 + .bounds(centerX + 85, height - 30, 70, 20) 121 130 .build() 122 131 ) 123 - 132 + 124 133 updateUIState() 125 134 } 126 - 135 + 127 136 override fun render(graphics: GuiGraphics, mouseX: Int, mouseY: Int, partialTick: Float) { 128 137 // Render background 129 138 renderBackground(graphics, mouseX, mouseY, partialTick) 130 - 139 + 131 140 // Render title 132 141 graphics.drawCenteredString( 133 142 font, ··· 136 145 15, 137 146 0xFFFFFF 138 147 ) 139 - 148 + 140 149 // Render labels 141 150 graphics.drawString( 142 151 font, ··· 145 154 48, 146 155 0xA0A0A0 147 156 ) 148 - 157 + 149 158 graphics.drawString( 150 159 font, 151 - "App Password:", 160 + "App Password (optional):", 152 161 width / 2 - 150, 153 162 78, 154 163 0xA0A0A0 155 164 ) 156 - 165 + 157 166 // Render status 158 167 statusText?.let { status -> 159 168 val lines = font.split(status, width - 40) 160 - var y = 140 169 + var y = 130 161 170 for (line in lines) { 162 171 graphics.drawCenteredString( 163 172 font, ··· 169 178 y += 12 170 179 } 171 180 } 172 - 181 + 173 182 // Render security notice 174 - val securityNotice = Component.literal("Your password never leaves your computer") 183 + val securityNotice = if (sessionManager.isOAuthSession()) { 184 + Component.literal("OAuth session active — your data is secure") 185 + } else if (sessionManager.hasSession()) { 186 + Component.literal("App-password session — your password never leaves your computer") 187 + } else { 188 + Component.literal("OAuth is recommended — no password needed!") 189 + } 175 190 graphics.drawCenteredString( 176 191 font, 177 192 securityNotice, ··· 179 194 height - 50, 180 195 0x40FF40 181 196 ) 182 - 197 + 183 198 super.render(graphics, mouseX, mouseY, partialTick) 184 199 } 185 - 200 + 201 + /** 202 + * OAuth browser-based login. 203 + * Opens the user's browser for ATProto OAuth authorization. 204 + */ 205 + private fun onOAuthClicked() { 206 + val handle = handleField?.value?.trim() ?: "" 207 + 208 + if (handle.isEmpty()) { 209 + statusText = Component.literal("§cPlease enter your handle or DID") 210 + return 211 + } 212 + 213 + isAuthenticating = true 214 + oauthButton?.active = false 215 + loginButton?.active = false 216 + logoutButton?.active = false 217 + statusText = Component.literal("§eOpening browser for OAuth...") 218 + .append(Component.literal("\n§7Please complete login in your browser")) 219 + 220 + coroutineScope.launch { 221 + try { 222 + val result = oAuthManager.authorize(handle).getOrThrow() 223 + val session = result.session 224 + 225 + // Send authenticated session to server for verification 226 + val packet = AtProtoPackets.AuthenticatePacket( 227 + did = session.did, 228 + handle = session.handle, 229 + pdsUrl = session.pdsUrl, 230 + accessJwt = session.accessToken, 231 + refreshJwt = session.refreshToken, 232 + authType = "oauth", 233 + ) 234 + 235 + ClientPlayNetworking.send(packet) 236 + 237 + // Store OAuth session locally 238 + sessionManager.storeOAuthSession(session, result.dpopKeyPair) 239 + 240 + minecraft?.execute { 241 + statusText = Component.literal("§aOAuth authorisation successful!") 242 + .append(Component.literal("\n§7Handle: §f${session.handle}")) 243 + .append(Component.literal("\n§7DID: §f${session.did}")) 244 + .append(Component.literal("\n§7Scope: §f${session.scope}")) 245 + .append(Component.literal("\n§eWaiting for server confirmation...")) 246 + 247 + // Also send to chat for better visibility 248 + minecraft?.gui?.chat?.addMessage( 249 + Component.literal("§aOAuth login successful!") 250 + .append(Component.literal("\n§7Handle: §f${session.handle}")) 251 + ) 252 + 253 + passwordField?.value = "" 254 + updateUIState() 255 + logger.info("OAuth authenticated as ${session.handle}, sent session to server") 256 + } 257 + } catch (e: Exception) { 258 + minecraft?.execute { 259 + statusText = Component.literal("§cOAuth authorisation failed") 260 + .append(Component.literal("\n§7${e.message ?: "Unknown error"}")) 261 + .append(Component.literal("\n\n§7Try app-password login instead")) 262 + 263 + minecraft?.gui?.chat?.addMessage( 264 + Component.literal("§cOAuth failed: ${e.message ?: "Unknown error"}") 265 + ) 266 + 267 + updateUIState() 268 + logger.error("OAuth authorisation failed: ${e.javaClass.simpleName} - ${e.message}") 269 + } 270 + } 271 + } 272 + } 273 + 274 + /** 275 + * App-password login (fallback). 276 + */ 186 277 private fun onLoginClicked() { 187 278 val handle = handleField?.value?.trim() ?: "" 188 279 val password = passwordField?.value ?: "" 189 - 280 + 190 281 if (handle.isEmpty()) { 191 282 statusText = Component.literal("§cPlease enter your handle or DID") 192 283 return 193 284 } 194 - 285 + 195 286 if (password.isEmpty()) { 196 287 statusText = Component.literal("§cPlease enter your app password") 197 288 return 198 289 } 199 - 290 + 200 291 isAuthenticating = true 292 + oauthButton?.active = false 201 293 loginButton?.active = false 202 294 logoutButton?.active = false 203 295 statusText = Component.literal("§eAuthenticating with AT Protocol...") 204 - 296 + 205 297 coroutineScope.launch { 206 298 try { 207 299 // Authenticate with AT Protocol servers (client-side only) 208 300 val session = sessionManager.createSession(handle, password).getOrThrow() 209 - 301 + 210 302 // Send authenticated session to server for verification 211 303 val packet = AtProtoPackets.AuthenticatePacket( 212 304 did = session.did, 213 305 handle = session.handle, 214 306 pdsUrl = session.pdsUrl, 215 307 accessJwt = session.accessJwt, 216 - refreshJwt = session.refreshJwt 308 + refreshJwt = session.refreshJwt, 309 + authType = "app_password", 217 310 ) 218 311 ClientPlayNetworking.send(packet) 219 - 312 + 220 313 // Update UI on main thread 221 314 minecraft?.execute { 222 - statusText = Component.literal("§a[SUCCESS] Successfully authenticated!") 315 + statusText = Component.literal("§aAuthenticated successfully!") 223 316 .append(Component.literal("\n§7Handle: §f${session.handle}")) 224 317 .append(Component.literal("\n§7DID: §f${session.did}")) 225 - .append(Component.literal("\n\n§aYou can now sync your Minecraft data!")) 226 - 227 - // Also send to chat for better visibility 318 + 228 319 minecraft?.gui?.chat?.addMessage( 229 - Component.literal("§a[SUCCESS] Authenticated with AT Protocol!") 230 - .append(Component.literal("\n§7Handle: §f${session.handle}")) 320 + Component.literal("§aAuthenticated with AT Protocol!") 231 321 .append(Component.literal("\n§7Waiting for server confirmation...")) 232 322 ) 233 - 323 + 234 324 // Clear password field 235 325 passwordField?.value = "" 236 - 326 + 237 327 updateUIState() 238 - 328 + 239 329 logger.info("Successfully authenticated as ${session.handle}, sent session to server") 240 330 } 241 331 } catch (e: Exception) { 242 332 minecraft?.execute { 243 - statusText = Component.literal("§c[FAILED] Authentication failed") 333 + statusText = Component.literal("§cAuthentication failed") 244 334 .append(Component.literal("\n§7${e.message ?: "Unknown error"}")) 245 335 .append(Component.literal("\n\n§7Tip: Use an §fApp Password§7 from your")) 246 336 .append(Component.literal("\n§7AT Protocol account settings")) 247 337 .append(Component.literal("\n§c§lNever use your main password!")) 248 - 249 - // Also send to chat 338 + 250 339 minecraft?.gui?.chat?.addMessage( 251 - Component.literal("§c[FAILED] Authentication failed: ${e.message ?: "Unknown error"}") 340 + Component.literal("§cAuthentication failed: ${e.message ?: "Unknown error"}") 252 341 ) 253 - 342 + 254 343 updateUIState() 255 - 344 + 256 345 logger.error("Authentication failed: ${e.javaClass.simpleName} - ${e.message}") 257 346 } 258 347 } 259 348 } 260 349 } 261 - 350 + 262 351 private fun onLogoutClicked() { 263 352 sessionManager.deleteSession() 264 - 353 + 265 354 // Notify server 266 355 val packet = AtProtoPackets.LogoutPacket() 267 356 ClientPlayNetworking.send(packet) 268 - 269 - statusText = Component.literal("§a[SUCCESS] Logged out successfully") 357 + 358 + statusText = Component.literal("§aLogged out successfully") 270 359 .append(Component.literal("\n§7Session cleared from your computer")) 271 - 272 - // Also send to chat 360 + 273 361 minecraft?.gui?.chat?.addMessage( 274 - Component.literal("§a[SUCCESS] Logged out from AT Protocol") 362 + Component.literal("§aLogged out from SocialSync") 275 363 ) 276 - 364 + 277 365 // Clear fields 278 366 handleField?.value = "" 279 367 passwordField?.value = "" 280 - 368 + 281 369 updateUIState() 282 - 370 + 283 371 logger.info("Logged out, notified server") 284 372 } 285 - 373 + 286 374 private fun onHelpClicked() { 287 - val helpText = Component.literal("§e§lHow to get an App Password:") 288 - .append(Component.literal("\n\n§71. Go to your AT Protocol account settings")) 375 + val helpText = Component.literal("§e§lSocialSync Login Help") 376 + .append(Component.literal("\n\n§b§lOAuth Login (Recommended)")) 377 + .append(Component.literal("\n§71. Enter your handle (e.g. alice.bsky.social)")) 378 + .append(Component.literal("\n§72. Click §fOAuth Login§7")) 379 + .append(Component.literal("\n§73. Complete login in your browser")) 380 + .append(Component.literal("\n§74. Return to Minecraft")) 381 + .append(Component.literal("\n\n§b§lApp Password Login (Fallback)")) 382 + .append(Component.literal("\n§71. Go to your AT Protocol account settings")) 289 383 .append(Component.literal("\n §7(e.g. Bluesky → Settings → Privacy & Security)")) 290 - .append(Component.literal("\n\n§72. Find \"App Passwords\"")) 291 - .append(Component.literal("\n\n§73. Create a new app password")) 292 - .append(Component.literal("\n §7(e.g. \"Minecraft Server\")")) 293 - .append(Component.literal("\n\n§74. Copy it immediately!")) 294 - .append(Component.literal("\n §7(You won't see it again)")) 295 - .append(Component.literal("\n\n§75. Use it in the password field above")) 384 + .append(Component.literal("\n§72. Find \"App Passwords\"")) 385 + .append(Component.literal("\n§73. Create a new app password")) 386 + .append(Component.literal("\n§74. Enter it in the password field above")) 296 387 .append(Component.literal("\n\n§c§lNEVER use your main account password!")) 297 388 .append(Component.literal("\n§cApp Passwords can be revoked anytime.")) 298 - 389 + 299 390 statusText = helpText 300 391 } 301 - 392 + 302 393 private fun updateUIState() { 303 394 val hasSession = sessionManager.hasSession() 304 - 395 + 305 396 isAuthenticating = false 306 - 397 + 307 398 handleField?.active = !hasSession 308 399 passwordField?.active = !hasSession 400 + oauthButton?.active = !hasSession && !isAuthenticating 309 401 loginButton?.active = !hasSession && !isAuthenticating 310 402 logoutButton?.active = hasSession 311 - 403 + 312 404 if (hasSession && statusText == null) { 313 405 // Show current session info 314 406 coroutineScope.launch { 315 407 try { 316 408 val session = sessionManager.getSession().getOrThrow() 409 + val authLabel = if (sessionManager.isOAuthSession()) "§bOAuth" else "§eApp Password" 317 410 minecraft?.execute { 318 - statusText = Component.literal("§a[SUCCESS] Currently logged in") 411 + statusText = Component.literal("§aCurrently logged in ($authLabel§a)") 319 412 .append(Component.literal("\n§7Handle: §f${session.handle}")) 320 413 .append(Component.literal("\n§7DID: §f${session.did}")) 321 414 } 322 415 } catch (e: Exception) { 323 - // Session exists but couldn't be retrieved 324 416 minecraft?.execute { 325 - statusText = Component.literal("§e[WARNING] Session may be expired") 417 + statusText = Component.literal("§eSession may be expired") 326 418 .append(Component.literal("\n§7Try logging out and back in")) 327 419 } 328 420 } 329 421 } 330 422 } 331 423 } 332 - 424 + 333 425 override fun onClose() { 334 426 minecraft?.setScreen(parent) 335 427 } 336 - 428 + 337 429 override fun isPauseScreen(): Boolean { 338 430 return true 339 431 }