audio streaming app plyr.fm

feat: preserve jam link through login flow (#993)

* feat: preserve jam link through login flow

unauthenticated users hitting a jam invite link now see a preview card
(host avatar, name, participant count) with a "sign in to join" button
instead of a confusing error. the jam path is stored in a cookie that
survives the OAuth round-trip, redirecting back after login or profile
setup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address self-review — open redirect, design consistency, docs

- validate return_to param in login page back link (was unsanitized href)
- re-validate cookie value in getReturnUrl() (cookies are client-writable)
- extract isValidReturnPath() for shared validation logic
- use WaveLoading component instead of plain text for auth loading state
- add card surface (bg-tertiary, border, radius) to jam preview card
- remove unique avatar border to match other avatar patterns
- add docs/frontend/redirect-after-login.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

authored by zzstoatzz.io

Claude Opus 4.6 and committed by
GitHub
75d5cadb 6f8e1dd0

+241 -6
+112
docs/frontend/redirect-after-login.md
··· 1 + # redirect after login 2 + 3 + ## the problem 4 + 5 + unauthenticated users who follow a deep link (e.g. a jam invite `/jam/abc123`) get redirected to the login page. after completing the OAuth flow, they land on `/portal` with no memory of where they were trying to go. the invite link is lost. 6 + 7 + ## the solution 8 + 9 + a short-lived cookie (`plyr_return_to`) stores the intended destination before redirecting to login. after the OAuth exchange completes, the portal (or profile setup page) reads the cookie, clears it, and redirects the user to the original URL. 10 + 11 + ## flow 12 + 13 + ``` 14 + 1. user visits /jam/abc123 (unauthenticated) 15 + 2. jam page renders preview UI with "sign in" button 16 + 3. user clicks sign in → handleSignIn() calls setReturnUrl('/jam/abc123') 17 + 4. user proceeds through OAuth login at their PDS 18 + 5. PDS redirects back → /portal?exchange_token=… 19 + 6. portal exchanges token, sets session cookie 20 + 7. portal reads plyr_return_to cookie → '/jam/abc123' 21 + 8. portal clears the cookie and redirects to /jam/abc123 22 + 9. jam page runs onMount, sees auth, joins the jam 23 + ``` 24 + 25 + if the user is new and goes through profile setup (`/profile/setup`), the redirect happens after profile creation instead of at the portal step — same cookie, same logic. 26 + 27 + ## implementation 28 + 29 + ### `frontend/src/lib/utils/return-url.ts` 30 + 31 + three functions manage the cookie: 32 + 33 + ```typescript 34 + setReturnUrl(path: string): void // write cookie (validates path first) 35 + getReturnUrl(): string | null // read cookie value 36 + clearReturnUrl(): void // delete cookie (max-age=0) 37 + ``` 38 + 39 + ### cookie parameters 40 + 41 + | parameter | value | rationale | 42 + |------------|-------------|-----------| 43 + | name | `plyr_return_to` | distinct from session cookie | 44 + | path | `/` | accessible from any route (portal, profile/setup) | 45 + | max-age | `600` (10 min) | short TTL — stale return URLs are worse than no redirect | 46 + | SameSite | `Lax` | survives the OAuth redirect chain (top-level navigations) | 47 + | HttpOnly | no | intentional — client-side JS needs to read it; value is a URL path, not a secret | 48 + | Secure | not set | works on localhost; cookie contains no sensitive data | 49 + 50 + ### open redirect prevention 51 + 52 + `setReturnUrl` rejects any path that does not start with `/` or starts with `//`: 53 + 54 + ```typescript 55 + if (!path.startsWith('/') || path.startsWith('//')) return; 56 + ``` 57 + 58 + this prevents an attacker from crafting a link like `/jam/x` that somehow sets the return URL to `//evil.com` or `https://evil.com`. only same-origin relative paths are accepted. 59 + 60 + ## integration points 61 + 62 + ### jam page (`frontend/src/routes/jam/[code]/+page.svelte`) 63 + 64 + the "sign in" button handler calls `setReturnUrl` with the current jam path before the user navigates to login: 65 + 66 + ```typescript 67 + function handleSignIn() { 68 + setReturnUrl(`/jam/${data.code}`); 69 + } 70 + ``` 71 + 72 + ### portal (`frontend/src/routes/portal/+page.svelte`) 73 + 74 + after a successful token exchange, checks for a return URL and redirects if one exists: 75 + 76 + ```typescript 77 + await auth.refresh(); 78 + await preferences.fetch(); 79 + const r = getReturnUrl(); 80 + if (r) { clearReturnUrl(); window.location.href = r; return; } 81 + ``` 82 + 83 + ### profile setup (`frontend/src/routes/profile/setup/+page.svelte`) 84 + 85 + new users who complete profile creation also check the cookie: 86 + 87 + ```typescript 88 + if (response.ok) { 89 + const returnTo = getReturnUrl(); 90 + if (returnTo) { 91 + clearReturnUrl(); 92 + window.location.href = returnTo; 93 + return; 94 + } 95 + window.location.href = '/portal'; 96 + } 97 + ``` 98 + 99 + both consumers use `window.location.href` (full navigation) rather than SvelteKit's `goto()` to ensure a clean page load at the destination. 100 + 101 + ## extending to other pages 102 + 103 + to preserve any pre-login destination, call `setReturnUrl` before the user leaves for login: 104 + 105 + ```typescript 106 + import { setReturnUrl } from '$lib/utils/return-url'; 107 + 108 + // in your "sign in" handler: 109 + setReturnUrl(window.location.pathname + window.location.search); 110 + ``` 111 + 112 + the portal and profile/setup pages already handle the read side — no changes needed there. the cookie expires after 10 minutes, so stale redirects are not a concern.
+23
frontend/src/lib/utils/return-url.ts
··· 1 + const COOKIE_NAME = 'plyr_return_to'; 2 + const MAX_AGE = 600; // 10 minutes 3 + 4 + /** validate a path is safe for redirect (relative, no protocol-relative) */ 5 + export function isValidReturnPath(path: string): boolean { 6 + return path.startsWith('/') && !path.startsWith('//'); 7 + } 8 + 9 + export function setReturnUrl(path: string): void { 10 + if (!isValidReturnPath(path)) return; 11 + document.cookie = `${COOKIE_NAME}=${encodeURIComponent(path)}; path=/; max-age=${MAX_AGE}; SameSite=Lax`; 12 + } 13 + 14 + export function getReturnUrl(): string | null { 15 + const match = document.cookie.match(new RegExp(`(?:^|; )${COOKIE_NAME}=([^;]*)`)); 16 + if (!match) return null; 17 + const path = decodeURIComponent(match[1]); 18 + return isValidReturnPath(path) ? path : null; 19 + } 20 + 21 + export function clearReturnUrl(): void { 22 + document.cookie = `${COOKIE_NAME}=; path=/; max-age=0`; 23 + }
+90 -2
frontend/src/routes/jam/[code]/+page.svelte
··· 2 2 import { onMount } from 'svelte'; 3 3 import { goto } from '$app/navigation'; 4 4 import { jam } from '$lib/jam.svelte'; 5 + import { auth } from '$lib/auth.svelte'; 5 6 import { toast } from '$lib/toast.svelte'; 7 + import { setReturnUrl } from '$lib/utils/return-url'; 6 8 import { APP_NAME, APP_CANONICAL_URL } from '$lib/branding'; 9 + import WaveLoading from '$lib/components/WaveLoading.svelte'; 7 10 import type { PageData } from './$types'; 8 11 9 12 let { data }: { data: PageData } = $props(); ··· 11 14 let error = $state<string | null>(null); 12 15 13 16 onMount(async () => { 17 + // wait for auth check to complete (runs in layout) 18 + while (auth.loading) { 19 + await new Promise((resolve) => setTimeout(resolve, 50)); 20 + } 21 + 22 + if (!auth.isAuthenticated) { 23 + // don't try to join — show preview UI instead 24 + return; 25 + } 26 + 27 + // authenticated — proceed with join 14 28 const result = await jam.join(data.code); 15 29 if (result === true) { 16 - // SvelteKit navigation preserves runtime — jam state survives 17 30 goto('/'); 18 31 } else { 19 32 error = result; 20 33 toast.error(result); 21 34 } 22 35 }); 36 + 37 + function handleSignIn() { 38 + setReturnUrl(`/jam/${data.code}`); 39 + } 23 40 </script> 24 41 25 42 <svelte:head> ··· 60 77 </svelte:head> 61 78 62 79 <div class="join-page"> 63 - {#if error} 80 + {#if auth.loading} 81 + <WaveLoading size="sm" message="loading..." /> 82 + {:else if !auth.isAuthenticated} 83 + <div class="preview-card"> 84 + {#if data.preview} 85 + {#if data.preview.host_avatar_url} 86 + <img src={data.preview.host_avatar_url} alt="" class="host-avatar" /> 87 + {/if} 88 + <h2>{data.preview.name ?? `${data.preview.host_display_name}'s jam`}</h2> 89 + <p class="preview-description"> 90 + {data.preview.participant_count > 1 91 + ? `${data.preview.host_display_name} and ${data.preview.participant_count - 1} others are listening` 92 + : `${data.preview.host_display_name} is listening`} 93 + </p> 94 + {:else} 95 + <h2>join a jam</h2> 96 + {/if} 97 + <a href="/login?return_to=/jam/{data.code}" class="sign-in-button" onclick={handleSignIn}> 98 + sign in to join 99 + </a> 100 + </div> 101 + {:else if error} 64 102 <div class="error-state"> 65 103 <p>{error}</p> 66 104 <a href="/">go home</a> ··· 82 120 .joining { 83 121 color: var(--text-tertiary); 84 122 font-size: var(--text-base); 123 + } 124 + 125 + .preview-card { 126 + text-align: center; 127 + display: flex; 128 + flex-direction: column; 129 + align-items: center; 130 + gap: 1rem; 131 + max-width: 360px; 132 + width: 100%; 133 + background: var(--bg-tertiary); 134 + border: 1px solid var(--border-subtle); 135 + border-radius: var(--radius-lg); 136 + padding: 2rem; 137 + } 138 + 139 + .host-avatar { 140 + width: 64px; 141 + height: 64px; 142 + border-radius: 50%; 143 + object-fit: cover; 144 + } 145 + 146 + .preview-card h2 { 147 + font-size: var(--text-xl); 148 + color: var(--text-primary); 149 + margin: 0; 150 + } 151 + 152 + .preview-description { 153 + color: var(--text-secondary); 154 + font-size: var(--text-base); 155 + margin: 0; 156 + } 157 + 158 + .sign-in-button { 159 + display: inline-block; 160 + margin-top: 0.5rem; 161 + padding: 0.75rem 1.5rem; 162 + background: var(--accent); 163 + color: white; 164 + border-radius: var(--radius-md); 165 + text-decoration: none; 166 + font-weight: 500; 167 + font-size: var(--text-base); 168 + transition: opacity 0.15s; 169 + } 170 + 171 + .sign-in-button:hover { 172 + opacity: 0.9; 85 173 } 86 174 87 175 .error-state {
+7 -1
frontend/src/routes/login/+page.svelte
··· 3 3 import { APP_NAME } from '$lib/branding'; 4 4 import { API_URL } from '$lib/config'; 5 5 import HandleAutocomplete from '$lib/components/HandleAutocomplete.svelte'; 6 + import { isValidReturnPath } from '$lib/utils/return-url'; 7 + 8 + const returnTo = (() => { 9 + const raw = new URLSearchParams(typeof window !== 'undefined' ? window.location.search : '').get('return_to'); 10 + return raw && isValidReturnPath(raw) ? raw : null; 11 + })(); 6 12 7 13 type Mode = 'signin' | 'create'; 8 14 ··· 235 241 {/if} 236 242 </div> 237 243 238 - <a href="/" class="back-link">← back to home</a> 244 + <a href={returnTo ?? '/'} class="back-link">← back</a> 239 245 </div> 240 246 </div> 241 247
+2 -2
frontend/src/routes/portal/+page.svelte
··· 15 15 import { API_URL } from '$lib/config'; 16 16 import { toast } from '$lib/toast.svelte'; 17 17 import { auth } from '$lib/auth.svelte'; 18 - import { preferences } from '$lib/preferences.svelte'; 18 + import { preferences } from '$lib/preferences.svelte'; import { getReturnUrl, clearReturnUrl } from '$lib/utils/return-url'; 19 19 let loading = $state(true); 20 20 let error = $state(''); 21 21 let tracks = $state<Track[]>([]); ··· 123 123 // invalidate all load functions so they rerun with the new session cookie 124 124 await invalidateAll(); 125 125 await auth.refresh(); 126 - await preferences.fetch(); 126 + await preferences.fetch(); const r = getReturnUrl(); if (r) { clearReturnUrl(); window.location.href = r; return; } 127 127 } 128 128 } catch (_e) { 129 129 console.error('failed to exchange token:', _e);
+7 -1
frontend/src/routes/profile/setup/+page.svelte
··· 4 4 import { invalidateAll, replaceState } from '$app/navigation'; 5 5 import { API_URL } from '$lib/config'; 6 6 import { auth } from '$lib/auth.svelte'; 7 + import { getReturnUrl, clearReturnUrl } from '$lib/utils/return-url'; 7 8 8 9 let loading = true; 9 10 let saving = false; ··· 107 108 }); 108 109 109 110 if (response.ok) { 110 - // redirect to portal 111 + const returnTo = getReturnUrl(); 112 + if (returnTo) { 113 + clearReturnUrl(); 114 + window.location.href = returnTo; 115 + return; 116 + } 111 117 window.location.href = '/portal'; 112 118 } else { 113 119 const errorData = await response.json();