the best lightweight web dev stack built on bun
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}