Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee

feat: banner on expired oauth

pdewey.com 11c8ac0b 0c6f5fb3

verified
+77 -18
+6 -1
internal/atproto/client.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "errors" 5 6 "fmt" 6 7 7 8 "arabica/internal/metrics" ··· 11 12 "github.com/bluesky-social/indigo/atproto/syntax" 12 13 "github.com/rs/zerolog/log" 13 14 ) 15 + 16 + // ErrSessionExpired is returned when the OAuth session cannot be resumed, 17 + // indicating the user's authorization grant has expired and they need to log in again. 18 + var ErrSessionExpired = errors.New("oauth session expired") 14 19 15 20 // Client wraps the atproto API client for making authenticated requests to a PDS 16 21 type Client struct { ··· 30 35 // Resume the OAuth session - this returns a ClientSession that handles DPOP 31 36 session, err := c.oauth.app.ResumeSession(ctx, did, sessionID) 32 37 if err != nil { 33 - return nil, fmt.Errorf("failed to resume session: %w", err) 38 + return nil, fmt.Errorf("%w: %w", ErrSessionExpired, err) 34 39 } 35 40 36 41 // Get the authenticated API client from the session
+2 -2
internal/handlers/brew.go
··· 567 567 568 568 _, err := store.CreateBrew(r.Context(), req, 1) // User ID not used with atproto 569 569 if err != nil { 570 - http.Error(w, "Failed to create brew", http.StatusInternalServerError) 571 570 log.Error().Err(err).Msg("Failed to create brew") 571 + handleStoreError(w, err, "Failed to create brew") 572 572 return 573 573 } 574 574 ··· 655 655 656 656 err := store.UpdateBrewByRKey(r.Context(), rkey, req) 657 657 if err != nil { 658 - http.Error(w, "Failed to update brew", http.StatusInternalServerError) 659 658 log.Error().Err(err).Str("rkey", rkey).Msg("Failed to update brew") 659 + handleStoreError(w, err, "Failed to update brew") 660 660 return 661 661 } 662 662
+8 -8
internal/handlers/entities.go
··· 200 200 201 201 bean, err := store.CreateBean(r.Context(), &req) 202 202 if err != nil { 203 - http.Error(w, "Failed to create bean", http.StatusInternalServerError) 204 203 log.Error().Err(err).Msg("Failed to create bean") 204 + handleStoreError(w, err, "Failed to create bean") 205 205 return 206 206 } 207 207 ··· 243 243 244 244 roaster, err := store.CreateRoaster(r.Context(), &req) 245 245 if err != nil { 246 - http.Error(w, "Failed to create roaster", http.StatusInternalServerError) 247 246 log.Error().Err(err).Msg("Failed to create roaster") 247 + handleStoreError(w, err, "Failed to create roaster") 248 248 return 249 249 } 250 250 ··· 329 329 } 330 330 331 331 if err := store.UpdateBeanByRKey(r.Context(), rkey, &req); err != nil { 332 - http.Error(w, "Failed to update bean", http.StatusInternalServerError) 333 332 log.Error().Err(err).Str("rkey", rkey).Msg("Failed to update bean") 333 + handleStoreError(w, err, "Failed to update bean") 334 334 return 335 335 } 336 336 ··· 392 392 } 393 393 394 394 if err := store.UpdateRoasterByRKey(r.Context(), rkey, &req); err != nil { 395 - http.Error(w, "Failed to update roaster", http.StatusInternalServerError) 396 395 log.Error().Err(err).Str("rkey", rkey).Msg("Failed to update roaster") 396 + handleStoreError(w, err, "Failed to update roaster") 397 397 return 398 398 } 399 399 ··· 452 452 453 453 grinder, err := store.CreateGrinder(r.Context(), &req) 454 454 if err != nil { 455 - http.Error(w, "Failed to create grinder", http.StatusInternalServerError) 456 455 log.Error().Err(err).Msg("Failed to create grinder") 456 + handleStoreError(w, err, "Failed to create grinder") 457 457 return 458 458 } 459 459 ··· 499 499 } 500 500 501 501 if err := store.UpdateGrinderByRKey(r.Context(), rkey, &req); err != nil { 502 - http.Error(w, "Failed to update grinder", http.StatusInternalServerError) 503 502 log.Error().Err(err).Str("rkey", rkey).Msg("Failed to update grinder") 503 + handleStoreError(w, err, "Failed to update grinder") 504 504 return 505 505 } 506 506 ··· 558 558 559 559 brewer, err := store.CreateBrewer(r.Context(), &req) 560 560 if err != nil { 561 - http.Error(w, "Failed to create brewer", http.StatusInternalServerError) 562 561 log.Error().Err(err).Msg("Failed to create brewer") 562 + handleStoreError(w, err, "Failed to create brewer") 563 563 return 564 564 } 565 565 ··· 604 604 } 605 605 606 606 if err := store.UpdateBrewerByRKey(r.Context(), rkey, &req); err != nil { 607 - http.Error(w, "Failed to update brewer", http.StatusInternalServerError) 608 607 log.Error().Err(err).Str("rkey", rkey).Msg("Failed to update brewer") 608 + handleStoreError(w, err, "Failed to update brewer") 609 609 return 610 610 } 611 611
+13 -1
internal/handlers/handlers.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "errors" 6 7 "fmt" 7 8 "net/http" 8 9 "strings" ··· 226 227 return 227 228 } 228 229 230 + // handleStoreError writes the appropriate HTTP error for a store operation failure. 231 + // If the error indicates an expired OAuth session, it returns 401 Unauthorized with 232 + // a user-friendly message. Otherwise it returns 500 with the fallbackMessage. 233 + func handleStoreError(w http.ResponseWriter, err error, fallbackMessage string) { 234 + if errors.Is(err, atproto.ErrSessionExpired) { 235 + http.Error(w, "Your session has expired. Please log in again.", http.StatusUnauthorized) 236 + return 237 + } 238 + http.Error(w, fallbackMessage, http.StatusInternalServerError) 239 + } 240 + 229 241 // deleteEntity validates the rkey, calls the delete function, and returns 200. 230 242 func (h *Handler) deleteEntity(w http.ResponseWriter, r *http.Request, deleteFn func(context.Context, string) error, entityName string) { 231 243 rkey := validateRKey(w, r.PathValue("id")) ··· 233 245 return 234 246 } 235 247 if err := deleteFn(r.Context(), rkey); err != nil { 236 - http.Error(w, "Failed to delete "+entityName, http.StatusInternalServerError) 237 248 log.Error().Err(err).Str("rkey", rkey).Msg("Failed to delete " + entityName) 249 + handleStoreError(w, err, "Failed to delete "+entityName) 238 250 return 239 251 } 240 252 w.WriteHeader(http.StatusOK)
+4 -4
internal/web/components/dialog_modals.templ
··· 27 27 } 28 28 hx-trigger="submit" 29 29 hx-swap="none" 30 - hx-on::after-request="if(event.detail.successful) { this.closest('dialog').close(); htmx.trigger('body', 'refreshManage'); }" 30 + hx-on::after-request="if(event.detail.successful) { this.closest('dialog').close(); htmx.trigger('body', 'refreshManage'); } else if(event.detail.xhr && event.detail.xhr.status === 401) { document.body.dispatchEvent(new CustomEvent('auth-expired', { bubbles: true })); this.closest('dialog').close(); }" 31 31 class="space-y-4" 32 32 > 33 33 if bean == nil { ··· 189 189 } 190 190 hx-trigger="submit" 191 191 hx-swap="none" 192 - hx-on::after-request="if(event.detail.successful) { this.closest('dialog').close(); htmx.trigger('body', 'refreshManage'); }" 192 + hx-on::after-request="if(event.detail.successful) { this.closest('dialog').close(); htmx.trigger('body', 'refreshManage'); } else if(event.detail.xhr && event.detail.xhr.status === 401) { document.body.dispatchEvent(new CustomEvent('auth-expired', { bubbles: true })); this.closest('dialog').close(); }" 193 193 class="space-y-4" 194 194 > 195 195 if grinder == nil { ··· 312 312 } 313 313 hx-trigger="submit" 314 314 hx-swap="none" 315 - hx-on::after-request="if(event.detail.successful) { this.closest('dialog').close(); htmx.trigger('body', 'refreshManage'); }" 315 + hx-on::after-request="if(event.detail.successful) { this.closest('dialog').close(); htmx.trigger('body', 'refreshManage'); } else if(event.detail.xhr && event.detail.xhr.status === 401) { document.body.dispatchEvent(new CustomEvent('auth-expired', { bubbles: true })); this.closest('dialog').close(); }" 316 316 class="space-y-4" 317 317 > 318 318 if brewer == nil { ··· 409 409 } 410 410 hx-trigger="submit" 411 411 hx-swap="none" 412 - hx-on::after-request="if(event.detail.successful) { this.closest('dialog').close(); htmx.trigger('body', 'refreshManage'); }" 412 + hx-on::after-request="if(event.detail.successful) { this.closest('dialog').close(); htmx.trigger('body', 'refreshManage'); } else if(event.detail.xhr && event.detail.xhr.status === 401) { document.body.dispatchEvent(new CustomEvent('auth-expired', { bubbles: true })); this.closest('dialog').close(); }" 413 413 class="space-y-4" 414 414 > 415 415 if roaster == nil {
+33
internal/web/components/layout.templ
··· 91 91 // Increase history cache size to prevent cache misses 92 92 htmx.config.historyCacheSize = 20; 93 93 94 + // Show session-expired banner on 401 responses 95 + document.body.addEventListener('htmx:afterRequest', function(evt) { 96 + if (evt.detail.xhr && evt.detail.xhr.status === 401) { 97 + document.body.dispatchEvent(new CustomEvent('auth-expired', { bubbles: true })); 98 + } 99 + }); 100 + 94 101 // Clean up styles before taking history snapshot 95 102 document.body.addEventListener('htmx:beforeHistorySave', function(evt) { 96 103 // Remove all transition classes and styles from elements being cached ··· 134 141 data-user-did={ data.UserDID } 135 142 } 136 143 > 144 + <!-- Session expired banner --> 145 + <div 146 + x-data="{ show: false }" 147 + x-on:auth-expired.window="show = true" 148 + x-show="show" 149 + x-cloak 150 + x-transition:enter="transition ease-out duration-300" 151 + x-transition:enter-start="opacity-0 -translate-y-2" 152 + x-transition:enter-end="opacity-100 translate-y-0" 153 + class="fixed top-0 inset-x-0 z-50 bg-amber-100 border-b border-amber-400 text-amber-900 px-4 py-3 flex items-center justify-between gap-4 shadow-md" 154 + role="alert" 155 + > 156 + <p class="text-sm font-medium"> 157 + Your session has expired. 158 + <a href="/login" class="underline underline-offset-2 hover:text-amber-700 font-semibold ml-1">Log in again</a> 159 + </p> 160 + <button 161 + @click="show = false" 162 + class="shrink-0 text-amber-700 hover:text-amber-900 transition-colors" 163 + aria-label="Dismiss" 164 + > 165 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> 166 + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"></path> 167 + </svg> 168 + </button> 169 + </div> 137 170 @HeaderWithProps(HeaderProps{ 138 171 IsAuthenticated: data.IsAuthenticated, 139 172 UserProfile: data.UserProfile,
+3 -2
internal/web/pages/feed.templ
··· 26 26 27 27 // feedFilterTab defines a filter tab option 28 28 type feedFilterTab struct { 29 - Label string 30 - Value string 29 + Label string 30 + Value string 31 31 } 32 32 33 33 func feedFilterTabs() []feedFilterTab { ··· 332 332 <span class="font-medium">🏭 { item.Brew.Bean.Roaster.Name }</span> 333 333 </div> 334 334 } 335 + // TODO: can we share code with the bean card here? (and the roaster?) 335 336 <div class="text-xs text-brown-600 mt-1 flex flex-wrap gap-x-2 gap-y-0.5"> 336 337 if item.Brew.Bean.Origin != "" { 337 338 <span class="inline-flex items-center gap-0.5">📍 { item.Brew.Bean.Origin }</span>
+8
static/js/entity-manager.js
··· 97 97 }); 98 98 99 99 if (!response.ok) { 100 + if (response.status === 401) { 101 + document.body.dispatchEvent(new CustomEvent('auth-expired', { bubbles: true })); 102 + return; 103 + } 100 104 const errorText = await response.text(); 101 105 throw new Error(errorText); 102 106 } ··· 148 152 }); 149 153 150 154 if (!response.ok) { 155 + if (response.status === 401) { 156 + document.body.dispatchEvent(new CustomEvent('auth-expired', { bubbles: true })); 157 + return false; 158 + } 151 159 const errorText = await response.text(); 152 160 throw new Error(errorText); 153 161 }