the best lightweight web dev stack built on bun
at main 419 lines 9.2 kB view raw
1import { css, html, LitElement } from "lit"; 2import { customElement, state } from "lit/decorators.js"; 3import { 4 authenticateWithPasskey, 5 isPasskeySupported, 6 registerPasskey, 7} from "../lib/client-passkey"; 8 9interface User { 10 username: string; 11 name: string | null; 12 avatar: string; 13} 14 15@customElement("auth-component") 16export class AuthComponent extends LitElement { 17 @state() user: User | null = null; 18 @state() loading = true; 19 @state() showModal = false; 20 @state() username = ""; 21 @state() error = ""; 22 @state() isSubmitting = false; 23 @state() passkeySupported = false; 24 @state() showRegisterForm = false; 25 26 static override styles = css` 27 :host { 28 display: block; 29 } 30 31 .auth-container { 32 position: relative; 33 } 34 35 .auth-button { 36 display: flex; 37 align-items: center; 38 gap: 0.5rem; 39 padding: 0.5rem 1rem; 40 background: var(--primary); 41 color: white; 42 border: 2px solid var(--primary); 43 border-radius: 8px; 44 cursor: pointer; 45 font-size: 1rem; 46 font-weight: 500; 47 transition: all 0.2s; 48 font-family: inherit; 49 } 50 51 .auth-button:hover { 52 background: transparent; 53 color: var(--primary); 54 } 55 56 .user-info { 57 display: flex; 58 align-items: center; 59 gap: 0.75rem; 60 } 61 62 .email { 63 font-weight: 500; 64 color: white; 65 font-size: 0.875rem; 66 transition: all 0.2s; 67 } 68 69 .auth-button:hover .email { 70 color: var(--primary); 71 } 72 73 .modal-overlay { 74 position: fixed; 75 top: 0; 76 left: 0; 77 right: 0; 78 bottom: 0; 79 background: rgba(0, 0, 0, 0.5); 80 display: flex; 81 align-items: center; 82 justify-content: center; 83 z-index: 1000; 84 } 85 86 .modal { 87 background: white; 88 padding: 2rem; 89 border-radius: 12px; 90 max-width: 400px; 91 width: 90%; 92 box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); 93 } 94 95 .modal h2 { 96 margin: 0 0 1.5rem; 97 color: var(--text); 98 } 99 100 .form-group { 101 margin-bottom: 1rem; 102 } 103 104 label { 105 display: block; 106 margin-bottom: 0.5rem; 107 color: var(--text); 108 font-weight: 500; 109 } 110 111 input { 112 width: 100%; 113 padding: 0.75rem; 114 border: 2px solid var(--secondary); 115 border-radius: 8px; 116 font-size: 1rem; 117 font-family: inherit; 118 box-sizing: border-box; 119 } 120 121 input:focus { 122 outline: none; 123 border-color: var(--primary); 124 } 125 126 .error { 127 color: var(--accent); 128 margin-bottom: 1rem; 129 font-size: 0.875rem; 130 } 131 132 .button-group { 133 display: flex; 134 gap: 0.5rem; 135 margin-top: 1.5rem; 136 } 137 138 button { 139 flex: 1; 140 padding: 0.75rem; 141 border: 2px solid var(--primary); 142 background: var(--primary); 143 color: white; 144 border-radius: 8px; 145 cursor: pointer; 146 font-size: 1rem; 147 font-weight: 500; 148 transition: all 0.2s; 149 font-family: inherit; 150 } 151 152 button:hover { 153 background: transparent; 154 color: var(--primary); 155 } 156 157 button.secondary { 158 background: transparent; 159 color: var(--primary); 160 } 161 162 button.secondary:hover { 163 background: var(--primary); 164 color: white; 165 } 166 167 button:disabled { 168 opacity: 0.5; 169 cursor: not-allowed; 170 } 171 172 .avatar { 173 width: 32px; 174 height: 32px; 175 border-radius: 50%; 176 } 177 178 .loading { 179 text-align: center; 180 color: var(--text); 181 } 182 `; 183 184 override connectedCallback() { 185 super.connectedCallback(); 186 this.checkAuth(); 187 this.passkeySupported = isPasskeySupported(); 188 } 189 190 async checkAuth() { 191 try { 192 const response = await fetch("/api/auth/me"); 193 if (response.ok) { 194 this.user = await response.json(); 195 } 196 } catch (error) { 197 console.error("Auth check failed:", error); 198 } finally { 199 this.loading = false; 200 } 201 } 202 203 async handleLogin() { 204 this.isSubmitting = true; 205 this.error = ""; 206 207 try { 208 // Get authentication options 209 const optionsRes = await fetch("/api/auth/passkey/authenticate/options"); 210 if (!optionsRes.ok) { 211 throw new Error("Failed to get authentication options"); 212 } 213 const options = await optionsRes.json(); 214 215 // Start authentication 216 const credential = await authenticateWithPasskey(options); 217 218 // Verify authentication 219 const verifyRes = await fetch("/api/auth/passkey/authenticate/verify", { 220 method: "POST", 221 headers: { "Content-Type": "application/json" }, 222 body: JSON.stringify({ credential, challenge: options.challenge }), 223 }); 224 225 if (!verifyRes.ok) { 226 throw new Error("Authentication failed"); 227 } 228 229 const user = await verifyRes.json(); 230 this.user = user; 231 this.showModal = false; 232 this.username = ""; 233 234 // Reload to update counter 235 window.location.reload(); 236 } catch (error) { 237 this.error = 238 error instanceof Error ? error.message : "Authentication failed"; 239 } finally { 240 this.isSubmitting = false; 241 } 242 } 243 244 async handleRegister() { 245 this.isSubmitting = true; 246 this.error = ""; 247 248 try { 249 if (!this.username.trim()) { 250 throw new Error("Username required"); 251 } 252 253 // Get passkey registration options 254 const optionsRes = await fetch( 255 `/api/auth/passkey/register/options?username=${encodeURIComponent(this.username)}`, 256 ); 257 if (!optionsRes.ok) { 258 throw new Error("Failed to get registration options"); 259 } 260 const options = await optionsRes.json(); 261 262 // Create passkey (this can be cancelled by user) 263 const credential = await registerPasskey(options); 264 265 // Register user with passkey atomically 266 const registerRes = await fetch("/api/auth/register", { 267 method: "POST", 268 headers: { "Content-Type": "application/json" }, 269 body: JSON.stringify({ 270 username: this.username, 271 credential, 272 challenge: options.challenge, 273 }), 274 }); 275 276 if (!registerRes.ok) { 277 const data = await registerRes.json(); 278 throw new Error(data.error || "Registration failed"); 279 } 280 281 const user = await registerRes.json(); 282 this.user = user; 283 this.showModal = false; 284 this.username = ""; 285 286 // Reload to update counter 287 window.location.reload(); 288 } catch (error) { 289 this.error = 290 error instanceof Error ? error.message : "Registration failed"; 291 } finally { 292 this.isSubmitting = false; 293 } 294 } 295 296 async handleLogout() { 297 try { 298 await fetch("/api/auth/logout", { method: "POST" }); 299 this.user = null; 300 window.location.reload(); 301 } catch (error) { 302 console.error("Logout failed:", error); 303 } 304 } 305 306 override render() { 307 if (this.loading) { 308 return html`<div class="loading">Loading...</div>`; 309 } 310 311 return html` 312 <div class="auth-container"> 313 ${ 314 this.user 315 ? html` 316 <button class="auth-button" @click=${this.handleLogout}> 317 <div class="user-info"> 318 <img 319 class="avatar" 320 src="https://api.dicebear.com/7.x/shapes/svg?seed=${this.user.avatar}" 321 alt="Avatar" 322 /> 323 <span class="email">${this.user.username}</span> 324 </div> 325 </button> 326 ` 327 : html` 328 <button class="auth-button" @click=${() => (this.showModal = true)}> 329 Sign In 330 </button> 331 ` 332 } 333 ${ 334 this.showModal 335 ? html` 336 <div class="modal-overlay" @click=${() => { 337 this.showModal = false; 338 this.showRegisterForm = false; 339 }}> 340 <div class="modal" @click=${(e: Event) => e.stopPropagation()}> 341 <h2>Welcome</h2> 342 ${this.error ? html`<div class="error">${this.error}</div>` : ""} 343 ${ 344 !this.passkeySupported 345 ? html` 346 <div class="error"> 347 Passkeys are not supported in this browser. 348 </div> 349 ` 350 : "" 351 } 352 ${ 353 this.showRegisterForm 354 ? html` 355 <div class="form-group"> 356 <label for="username">Username</label> 357 <input 358 type="text" 359 id="username" 360 placeholder="Choose a username" 361 .value=${this.username} 362 @input=${(e: Event) => 363 (this.username = ( 364 e.target as HTMLInputElement 365 ).value)} 366 ?disabled=${this.isSubmitting} 367 /> 368 </div> 369 <div class="button-group"> 370 <button 371 class="secondary" 372 @click=${() => { 373 this.showRegisterForm = false; 374 this.username = ""; 375 this.error = ""; 376 }} 377 ?disabled=${this.isSubmitting} 378 > 379 Back 380 </button> 381 <button 382 @click=${this.handleRegister} 383 ?disabled=${ 384 this.isSubmitting || 385 !this.username.trim() || 386 !this.passkeySupported 387 } 388 > 389 Register 390 </button> 391 </div> 392 ` 393 : html` 394 <div class="button-group"> 395 <button 396 @click=${this.handleLogin} 397 ?disabled=${this.isSubmitting || !this.passkeySupported} 398 > 399 Sign In 400 </button> 401 <button 402 class="secondary" 403 @click=${() => (this.showRegisterForm = true)} 404 ?disabled=${this.isSubmitting || !this.passkeySupported} 405 > 406 Register 407 </button> 408 </div> 409 ` 410 } 411 </div> 412 </div> 413 ` 414 : "" 415 } 416 </div> 417 `; 418 } 419}