WIP PWA for Grain

docs: add login flow implementation plan

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

+494
+494
docs/plans/2025-12-25-login-flow.md
··· 1 + # Login Flow Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add OAuth login via Bluesky using quickslice-client-js, with login button in header that shows user avatar when authenticated. 6 + 7 + **Architecture:** Auth service wraps quickslice-client-js and provides reactive state. App initializes auth before rendering to handle OAuth callbacks. Header subscribes to auth state and shows login button or user avatar. Login dialog collects handle before OAuth redirect. 8 + 9 + **Tech Stack:** Lit 3.x, quickslice-client-js (OAuth with PKCE/DPoP), CSS custom properties 10 + 11 + --- 12 + 13 + ### Task 1: Install quickslice-client-js 14 + 15 + **Files:** 16 + - Modify: `package.json` 17 + 18 + **Step 1: Add dependency** 19 + 20 + ```bash 21 + npm install quickslice-client-js 22 + ``` 23 + 24 + **Step 2: Verify installation** 25 + 26 + Run: `ls node_modules/quickslice-client-js` 27 + Expected: Directory exists with dist folder 28 + 29 + **Step 3: Commit** 30 + 31 + ```bash 32 + git add package.json package-lock.json 33 + git commit -m "chore: add quickslice-client-js dependency" 34 + ``` 35 + 36 + --- 37 + 38 + ### Task 2: Create Auth Service 39 + 40 + **Files:** 41 + - Create: `src/services/auth.js` 42 + 43 + **Step 1: Create auth service** 44 + 45 + ```javascript 46 + import { createQuicksliceClient } from 'quickslice-client-js'; 47 + 48 + class AuthService { 49 + #client = null; 50 + #user = null; 51 + #listeners = new Set(); 52 + #initialized = false; 53 + 54 + async init() { 55 + if (this.#initialized) return; 56 + 57 + this.#client = await createQuicksliceClient({ 58 + server: 'https://quickslice-production-9cf4.up.railway.app', 59 + clientId: 'client_h62Ea0FUeXTJ4pWBg4ZIkQ', 60 + scope: 'atproto transition:generic' 61 + }); 62 + 63 + // Handle OAuth callback if present 64 + if (window.location.search.includes('code=')) { 65 + await this.#client.handleRedirectCallback(); 66 + window.history.replaceState({}, '', window.location.pathname); 67 + } 68 + 69 + // Load user if authenticated 70 + if (await this.#client.isAuthenticated()) { 71 + await this.#loadUser(); 72 + } 73 + 74 + this.#initialized = true; 75 + } 76 + 77 + async #loadUser() { 78 + const { did } = this.#client.getUser(); 79 + const result = await this.#client.query(` 80 + query { viewer { handle displayName avatar { url } } } 81 + `); 82 + this.#user = { did, ...result.data.viewer }; 83 + this.#notify(); 84 + } 85 + 86 + async login(handle) { 87 + await this.#client.loginWithRedirect({ handle }); 88 + } 89 + 90 + logout() { 91 + this.#client.logout(); 92 + } 93 + 94 + get user() { 95 + return this.#user; 96 + } 97 + 98 + get isAuthenticated() { 99 + return !!this.#user; 100 + } 101 + 102 + subscribe(callback) { 103 + this.#listeners.add(callback); 104 + return () => this.#listeners.delete(callback); 105 + } 106 + 107 + #notify() { 108 + this.#listeners.forEach(cb => cb(this.#user)); 109 + } 110 + } 111 + 112 + export const auth = new AuthService(); 113 + ``` 114 + 115 + **Step 2: Commit** 116 + 117 + ```bash 118 + git add src/services/auth.js 119 + git commit -m "feat: add auth service wrapping quickslice-client-js" 120 + ``` 121 + 122 + --- 123 + 124 + ### Task 3: Update App Initialization 125 + 126 + **Files:** 127 + - Modify: `src/main.js` 128 + - Modify: `index.html` 129 + 130 + **Step 1: Update main.js to init auth before app** 131 + 132 + Replace contents of `src/main.js`: 133 + 134 + ```javascript 135 + import { auth } from './services/auth.js'; 136 + import './components/pages/grain-app.js'; 137 + 138 + async function init() { 139 + await auth.init(); 140 + document.body.appendChild(document.createElement('grain-app')); 141 + } 142 + 143 + init(); 144 + ``` 145 + 146 + **Step 2: Remove static grain-app from index.html** 147 + 148 + In `index.html`, change: 149 + ```html 150 + <body> 151 + <grain-app></grain-app> 152 + <script type="module" src="/src/main.js"></script> 153 + </body> 154 + ``` 155 + 156 + To: 157 + ```html 158 + <body> 159 + <script type="module" src="/src/main.js"></script> 160 + </body> 161 + ``` 162 + 163 + **Step 3: Commit** 164 + 165 + ```bash 166 + git add src/main.js index.html 167 + git commit -m "feat: init auth before app renders" 168 + ``` 169 + 170 + --- 171 + 172 + ### Task 4: Create Login Dialog Component 173 + 174 + **Files:** 175 + - Create: `src/components/molecules/grain-login-dialog.js` 176 + 177 + **Step 1: Create login dialog** 178 + 179 + ```javascript 180 + import { LitElement, html, css } from 'lit'; 181 + 182 + export class GrainLoginDialog extends LitElement { 183 + static properties = { 184 + _open: { state: true }, 185 + _handle: { state: true }, 186 + _loading: { state: true } 187 + }; 188 + 189 + static styles = css` 190 + :host { 191 + display: contents; 192 + } 193 + .overlay { 194 + position: fixed; 195 + top: 0; 196 + left: 0; 197 + right: 0; 198 + bottom: 0; 199 + background: rgba(0, 0, 0, 0.8); 200 + display: flex; 201 + align-items: center; 202 + justify-content: center; 203 + z-index: 1000; 204 + } 205 + .dialog { 206 + background: var(--color-bg-primary); 207 + border-radius: 12px; 208 + padding: var(--space-lg); 209 + width: 90%; 210 + max-width: 320px; 211 + } 212 + h2 { 213 + margin: 0 0 var(--space-md); 214 + font-size: var(--font-size-md); 215 + font-weight: var(--font-weight-semibold); 216 + color: var(--color-text-primary); 217 + } 218 + input { 219 + width: 100%; 220 + padding: var(--space-sm); 221 + border: 1px solid var(--color-border); 222 + border-radius: 6px; 223 + font-size: var(--font-size-sm); 224 + background: var(--color-bg-primary); 225 + color: var(--color-text-primary); 226 + margin-bottom: var(--space-md); 227 + box-sizing: border-box; 228 + } 229 + input:focus { 230 + outline: none; 231 + border-color: var(--color-text-primary); 232 + } 233 + button { 234 + width: 100%; 235 + padding: var(--space-sm); 236 + background: var(--color-text-primary); 237 + color: var(--color-bg-primary); 238 + border: none; 239 + border-radius: 6px; 240 + font-size: var(--font-size-sm); 241 + font-weight: var(--font-weight-semibold); 242 + cursor: pointer; 243 + } 244 + button:disabled { 245 + opacity: 0.5; 246 + cursor: not-allowed; 247 + } 248 + `; 249 + 250 + constructor() { 251 + super(); 252 + this._open = false; 253 + this._handle = ''; 254 + this._loading = false; 255 + } 256 + 257 + open() { 258 + this._open = true; 259 + this._handle = ''; 260 + this._loading = false; 261 + } 262 + 263 + close() { 264 + this._open = false; 265 + this._handle = ''; 266 + this._loading = false; 267 + } 268 + 269 + #handleSubmit(e) { 270 + e.preventDefault(); 271 + if (!this._handle.trim()) return; 272 + 273 + this._loading = true; 274 + this.dispatchEvent(new CustomEvent('login', { 275 + detail: { handle: this._handle.trim() } 276 + })); 277 + } 278 + 279 + #handleOverlayClick(e) { 280 + if (e.target === e.currentTarget) { 281 + this.close(); 282 + } 283 + } 284 + 285 + render() { 286 + if (!this._open) return null; 287 + 288 + return html` 289 + <div class="overlay" @click=${this.#handleOverlayClick}> 290 + <form class="dialog" @submit=${this.#handleSubmit}> 291 + <h2>Login with Bluesky</h2> 292 + <input 293 + type="text" 294 + placeholder="handle.bsky.social" 295 + .value=${this._handle} 296 + @input=${e => this._handle = e.target.value} 297 + ?disabled=${this._loading} 298 + /> 299 + <button type="submit" ?disabled=${this._loading || !this._handle.trim()}> 300 + ${this._loading ? 'Redirecting...' : 'Continue'} 301 + </button> 302 + </form> 303 + </div> 304 + `; 305 + } 306 + } 307 + 308 + customElements.define('grain-login-dialog', GrainLoginDialog); 309 + ``` 310 + 311 + **Step 2: Commit** 312 + 313 + ```bash 314 + git add src/components/molecules/grain-login-dialog.js 315 + git commit -m "feat: add login dialog component" 316 + ``` 317 + 318 + --- 319 + 320 + ### Task 5: Update Header with Login/Avatar 321 + 322 + **Files:** 323 + - Modify: `src/components/organisms/grain-header.js` 324 + 325 + **Step 1: Update header component** 326 + 327 + Replace contents of `src/components/organisms/grain-header.js`: 328 + 329 + ```javascript 330 + import { LitElement, html, css } from 'lit'; 331 + import { router } from '../../router.js'; 332 + import { auth } from '../../services/auth.js'; 333 + import '../molecules/grain-login-dialog.js'; 334 + import '../atoms/grain-avatar.js'; 335 + 336 + export class GrainHeader extends LitElement { 337 + static properties = { 338 + _user: { state: true } 339 + }; 340 + 341 + static styles = css` 342 + :host { 343 + display: block; 344 + position: sticky; 345 + top: 0; 346 + background: var(--color-bg-primary); 347 + border-bottom: 1px solid var(--color-border); 348 + z-index: 100; 349 + } 350 + header { 351 + display: flex; 352 + align-items: center; 353 + justify-content: space-between; 354 + max-width: var(--feed-max-width); 355 + margin: 0 auto; 356 + height: 48px; 357 + } 358 + .logo { 359 + font-family: 'Bebas Neue', sans-serif; 360 + font-size: 28px; 361 + font-weight: 400; 362 + line-height: 1; 363 + color: var(--color-text-primary); 364 + text-decoration: none; 365 + cursor: pointer; 366 + background: none; 367 + border: none; 368 + padding: 0 8px; 369 + } 370 + .login-button { 371 + font-size: var(--font-size-sm); 372 + font-weight: var(--font-weight-semibold); 373 + color: var(--color-text-primary); 374 + background: none; 375 + border: 1px solid var(--color-border); 376 + border-radius: 6px; 377 + padding: 6px 12px; 378 + margin-right: 8px; 379 + cursor: pointer; 380 + } 381 + .avatar-button { 382 + background: none; 383 + border: none; 384 + padding: 0; 385 + margin-right: 8px; 386 + cursor: pointer; 387 + } 388 + `; 389 + 390 + constructor() { 391 + super(); 392 + this._user = auth.user; 393 + } 394 + 395 + connectedCallback() { 396 + super.connectedCallback(); 397 + this._unsubscribe = auth.subscribe(user => { 398 + this._user = user; 399 + }); 400 + } 401 + 402 + disconnectedCallback() { 403 + super.disconnectedCallback(); 404 + this._unsubscribe?.(); 405 + } 406 + 407 + #handleLogoClick() { 408 + router.push('/'); 409 + } 410 + 411 + #openLogin() { 412 + this.shadowRoot.querySelector('grain-login-dialog').open(); 413 + } 414 + 415 + #handleLogin(e) { 416 + auth.login(e.detail.handle); 417 + } 418 + 419 + #goToProfile() { 420 + if (this._user?.handle) { 421 + router.push(`/profile/${this._user.handle}`); 422 + } 423 + } 424 + 425 + render() { 426 + return html` 427 + <header> 428 + <button class="logo" @click=${this.#handleLogoClick}>Grain</button> 429 + ${this._user ? html` 430 + <button class="avatar-button" @click=${this.#goToProfile}> 431 + <grain-avatar 432 + src=${this._user.avatar?.url || ''} 433 + alt=${this._user.handle || ''} 434 + size="sm" 435 + ></grain-avatar> 436 + </button> 437 + ` : html` 438 + <button class="login-button" @click=${this.#openLogin}>Login</button> 439 + `} 440 + </header> 441 + <grain-login-dialog @login=${this.#handleLogin}></grain-login-dialog> 442 + `; 443 + } 444 + } 445 + 446 + customElements.define('grain-header', GrainHeader); 447 + ``` 448 + 449 + **Step 2: Commit** 450 + 451 + ```bash 452 + git add src/components/organisms/grain-header.js 453 + git commit -m "feat: show login button or user avatar in header" 454 + ``` 455 + 456 + --- 457 + 458 + ### Task 6: Test Login Flow 459 + 460 + **Step 1: Start dev server** 461 + 462 + ```bash 463 + npm run dev 464 + ``` 465 + 466 + **Step 2: Manual testing** 467 + 468 + 1. Open app in browser 469 + 2. Verify "Login" button appears in header 470 + 3. Click Login, enter a Bluesky handle 471 + 4. Verify redirect to Bluesky OAuth 472 + 5. After auth, verify redirect back with avatar showing 473 + 6. Click avatar, verify navigation to profile 474 + 7. Refresh page, verify still logged in 475 + 476 + **Step 3: Commit any fixes if needed** 477 + 478 + --- 479 + 480 + ### Task 7: Final Cleanup 481 + 482 + **Step 1: Verify no console errors** 483 + 484 + Open DevTools, check for errors during: 485 + - Initial load 486 + - Login flow 487 + - Page navigation 488 + 489 + **Step 2: Final commit if any tweaks** 490 + 491 + ```bash 492 + git add -A 493 + git commit -m "fix: login flow polish" 494 + ```