From b357e8aa80f01edd41f3eda70227399b53c0bb5c Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sat, 20 Jun 2026 15:09:19 +1000 Subject: [PATCH] =?UTF-8?q?feat:=20redesign=20nav=20=E2=80=94=20home=20sho?= =?UTF-8?q?ws=20public=20links,=20user=20menu=20with=20dropdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove Public Directory tab; public links now show on home page - Rename Private Links to My Links - Add right-aligned user menu with hover dropdown (Profile, Settings, Admin, Logout) - Show admin badge next to username when role=admin - Show Sign In button when not logged in - Admin tab only visible to admins - New GET /api/auth/me endpoint returns current user from session - useCurrentUser hook for auth state in React - CSS-only hover dropdown with fade-in animation - Placeholder pages for Profile and Settings --- src/App.tsx | 63 +++++-- src/components/UserMenu.tsx | 55 ++++++ src/lib/auth.ts | 34 ++++ src/routes/DevLoginPage.tsx | 4 +- src/routes/LoginPage.tsx | 2 +- src/routes/PrivateLinksPage.tsx | 2 +- src/routes/PublicLinksPage.tsx | 2 +- src/styles.css | 186 ++++++++++++++++++++ tests/auth-api.test.ts | 296 ++++++++++++++++++++++++++++++++ worker/index.ts | 5 + worker/routes/api.auth.ts | 29 ++++ 11 files changed, 655 insertions(+), 23 deletions(-) create mode 100644 src/components/UserMenu.tsx create mode 100644 src/lib/auth.ts create mode 100644 tests/auth-api.test.ts create mode 100644 worker/routes/api.auth.ts diff --git a/src/App.tsx b/src/App.tsx index 3216d9b..28bb976 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,34 +1,32 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import LoginPage from './routes/LoginPage'; import PrivateLinksPage from './routes/PrivateLinksPage'; import PublicLinksPage from './routes/PublicLinksPage'; import AdminReviewPage from './routes/AdminReviewPage'; import DevLoginPage from './routes/DevLoginPage'; +import UserMenu from './components/UserMenu'; +import { useCurrentUser } from './lib/auth'; -type RouteId = 'private' | 'public' | 'admin' | 'login' | 'dev-login'; +type RouteId = 'home' | 'my-links' | 'admin' | 'login' | 'dev-login' | 'profile' | 'settings'; -const ROUTES: readonly { id: RouteId; label: string }[] = [ - { id: 'private', label: 'Private Links' }, - { id: 'public', label: 'Public Directory' }, - { id: 'admin', label: 'Admin Review' }, - { id: 'login', label: 'Login' }, - { id: 'dev-login', label: 'Dev Login' }, +const NAV_TABS: readonly { id: RouteId; label: string }[] = [ + { id: 'my-links', label: 'My Links' }, ]; function readRouteFromHash(): RouteId { - const match = window.location.hash.match(/^#\/(private|public|admin|login|dev-login)/); - return (match?.[1] as RouteId) ?? 'private'; + const hash = window.location.hash; + if (!hash || hash === '#/' || hash === '#') return 'home'; + const match = hash.match(/^#\/(my-links|admin|login|dev-login|profile|settings)/); + return (match?.[1] as RouteId) ?? 'home'; } function navigate(route: RouteId) { - if (readRouteFromHash() === route) { - return; - } - window.location.hash = `/${route}`; + window.location.hash = route === 'home' ? '/' : `/${route}`; } export default function App() { const [route, setRoute] = useState(() => readRouteFromHash()); + const { user, loading, refresh } = useCurrentUser(); useEffect(() => { const onHashChange = () => setRoute(readRouteFromHash()); @@ -36,12 +34,27 @@ export default function App() { return () => window.removeEventListener('hashchange', onHashChange); }, []); + const handleLogout = useCallback(async () => { + try { + await fetch('/api/auth/dev-logout', { method: 'POST', credentials: 'include' }); + } catch { + // Ignore — cookie clear is best-effort + } + await refresh(); + navigate('home'); + }, [refresh]); + + // Show admin tab only when user is admin + const navTabs = user?.role === 'admin' + ? [...NAV_TABS, { id: 'admin' as RouteId, label: 'Admin' }] + : NAV_TABS; + return (
- Heygo + navigate('home')}>Heygo +
+ +
- {route === 'private' ? : null} - {route === 'public' ? : null} + {route === 'home' ? : null} + {route === 'my-links' ? : null} {route === 'admin' ? : null} {route === 'login' ? : null} {route === 'dev-login' ? : null} + {route === 'profile' ? : null} + {route === 'settings' ? : null}
@@ -69,3 +87,12 @@ export default function App() {
); } + +function PlaceholderPage({ title }: { title: string }) { + return ( +
+

{title}

+

{title} page is coming soon.

+
+ ); +} diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx new file mode 100644 index 0000000..dfc8815 --- /dev/null +++ b/src/components/UserMenu.tsx @@ -0,0 +1,55 @@ +import type { CurrentUser } from '../lib/auth'; + +interface UserMenuProps { + user: CurrentUser | null; + loading: boolean; + onLogout: () => void; +} + +export default function UserMenu({ user, loading, onLogout }: UserMenuProps) { + if (loading) { + return
; + } + + if (!user) { + return ( +
+ Sign In +
+ ); + } + + const displayName = user.name || user.email?.split('@')[0] || 'User'; + + return ( +
+
+ + {user.imageUrl ? ( + + ) : ( + + {displayName.charAt(0).toUpperCase()} + + )} + + {displayName} + {user.role === 'admin' && admin} + + + +
+
+ Profile + Settings + {user.role === 'admin' && ( + Admin Settings + )} +
+ +
+
+ ); +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..c42a4c0 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,34 @@ +import { useCallback, useEffect, useState } from 'react'; + +export interface CurrentUser { + id: string; + email: string | null; + name: string | null; + imageUrl: string | null; + role: 'user' | 'admin'; +} + +export function useCurrentUser() { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(async () => { + try { + const res = await fetch('/api/auth/me', { credentials: 'include' }); + if (res.ok) { + const data = (await res.json()) as { user: CurrentUser | null }; + setUser(data.user); + } + } catch { + // Network error — stay logged out + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + return { user, loading, refresh }; +} diff --git a/src/routes/DevLoginPage.tsx b/src/routes/DevLoginPage.tsx index bc8910b..dd9724c 100644 --- a/src/routes/DevLoginPage.tsx +++ b/src/routes/DevLoginPage.tsx @@ -23,9 +23,9 @@ export default function DevLoginPage() { throw new Error(data.error || 'Login failed'); } setStatus('success'); - // Redirect to private links page after short delay + // Redirect to My Links page after short delay setTimeout(() => { - window.location.hash = '/private'; + window.location.hash = '/my-links'; }, 500); } catch (err) { setStatus('error'); diff --git a/src/routes/LoginPage.tsx b/src/routes/LoginPage.tsx index 3cf4f62..49690a7 100644 --- a/src/routes/LoginPage.tsx +++ b/src/routes/LoginPage.tsx @@ -12,7 +12,7 @@ export default function LoginPage() {

Don't want to sign in? You can still browse the{' '} - public directory. + public links.

); diff --git a/src/routes/PrivateLinksPage.tsx b/src/routes/PrivateLinksPage.tsx index 4b3d910..5b8947d 100644 --- a/src/routes/PrivateLinksPage.tsx +++ b/src/routes/PrivateLinksPage.tsx @@ -117,7 +117,7 @@ export default function PrivateLinksPage() {
-

Private links

+

My Links

Your personal shortlinks. Only visible to you.

diff --git a/src/routes/PublicLinksPage.tsx b/src/routes/PublicLinksPage.tsx index bcab942..aa31a30 100644 --- a/src/routes/PublicLinksPage.tsx +++ b/src/routes/PublicLinksPage.tsx @@ -83,7 +83,7 @@ export default function PublicLinksPage() {
-

Public directory

+

Public Links

Public shortlinks anyone can resolve.

diff --git a/src/styles.css b/src/styles.css index 93aafbd..57baf62 100644 --- a/src/styles.css +++ b/src/styles.css @@ -462,3 +462,189 @@ button.link-action--danger:hover { background: var(--danger-soft); } .link-table { font-size: 0.85rem; } .truncate { max-width: 12rem; } } + +/* ---- App bar right section ---- */ + +.app-bar-right { + margin-left: auto; + position: relative; +} + +/* ---- User menu ---- */ + +.user-menu { + position: relative; +} + +.user-menu-loading { + width: 80px; + height: 32px; + border-radius: 8px; + background: var(--accent-soft); + animation: pulse 1.5s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 0.5; } + 50% { opacity: 1; } +} + +.signin-button { + background: var(--accent); + border-radius: 8px; + color: white; + font-weight: 600; + padding: 0.45rem 1rem; + text-decoration: none; + transition: background 0.15s; +} + +.signin-button:hover { + background: #3543b3; + color: white; +} + +.user-trigger { + align-items: center; + border-radius: 8px; + cursor: pointer; + display: flex; + gap: 0.5rem; + padding: 0.35rem 0.6rem; + transition: background 0.15s; +} + +.user-trigger:hover, +.user-trigger:focus { + background: var(--accent-soft); + outline: none; +} + +.user-avatar { + border-radius: 50%; + flex-shrink: 0; + height: 28px; + overflow: hidden; + width: 28px; +} + +.user-avatar img { + height: 100%; + object-fit: cover; + width: 100%; +} + +.user-avatar-fallback { + align-items: center; + background: var(--accent); + border-radius: 50%; + color: white; + display: flex; + font-size: 0.8rem; + font-weight: 700; + height: 100%; + justify-content: center; + width: 100%; +} + +.user-name { + font-weight: 600; + font-size: 0.9rem; + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-badge { + background: #fef3c7; + border-radius: 999px; + color: #92400e; + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.03em; + padding: 0.1rem 0.4rem; + text-transform: uppercase; +} + +.chevron { + color: var(--muted); + flex-shrink: 0; + transition: transform 0.15s; +} + +.user-trigger:hover .chevron { + transform: rotate(180deg); +} + +/* Dropdown — pure CSS hover */ + +.user-dropdown { + background: white; + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 12px 40px rgb(23 32 51 / 12%); + min-width: 180px; + opacity: 0; + padding: 0.35rem; + position: absolute; + right: 0; + top: calc(100% + 4px); + transform: translateY(-4px); + transition: opacity 0.15s, transform 0.15s; + visibility: hidden; + z-index: 20; +} + +.user-menu:hover .user-dropdown, +.user-trigger:focus + .user-dropdown { + opacity: 1; + transform: translateY(0); + visibility: visible; +} + +.dropdown-item { + border: none; + border-radius: 6px; + color: #172033; + cursor: pointer; + display: block; + font: inherit; + font-weight: 500; + padding: 0.5rem 0.75rem; + text-align: left; + text-decoration: none; + width: 100%; +} + +.dropdown-item:hover { + background: var(--accent-soft); + color: var(--accent); + text-decoration: none; +} + +.dropdown-item--danger { + color: var(--danger); +} + +.dropdown-item--danger:hover { + background: var(--danger-soft); + color: var(--danger); +} + +.dropdown-divider { + border-top: 1px solid var(--border); + margin: 0.3rem 0; +} + +/* ---- Responsive ---- */ + +@media (max-width: 640px) { + .user-name { + display: none; + } + + .user-dropdown { + right: -0.5rem; + } +} diff --git a/tests/auth-api.test.ts b/tests/auth-api.test.ts new file mode 100644 index 0000000..bd4ce27 --- /dev/null +++ b/tests/auth-api.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, it } from 'vitest'; +import worker from '../worker/index'; +import { AUTH_SESSION_COOKIE_NAME, hashSessionToken } from '../worker/auth'; +import type { Env } from '../worker/env'; + +type UserRole = 'user' | 'admin'; + +type SessionUserRow = { + id: string; + email: string | null; + name: string | null; + image_url: string | null; + role: UserRole; + expires_at: string; + session_token_hash: string; +}; + +type FirstCall = { + sql: string; + params: unknown[]; +}; + +class FakeAuthD1 { + readonly rows: SessionUserRow[] = []; + readonly firstCalls: FirstCall[] = []; + + prepare(sql: string): FakeAuthStatement { + return new FakeAuthStatement(this, sql); + } + + findSessionByHash(hash: string): SessionUserRow | null { + return this.rows.find((r) => r.session_token_hash === hash) ?? null; + } +} + +class FakeAuthStatement { + private params: unknown[] = []; + + constructor( + private readonly db: FakeAuthD1, + private readonly sql: string, + ) {} + + bind(...params: unknown[]): this { + this.params = params; + return this; + } + + async first(): Promise { + this.db.firstCalls.push({ sql: this.sql, params: this.params }); + + // The getCurrentUser session lookup: SELECT users ... JOIN sessions + if (this.sql.includes('FROM sessions') && this.sql.includes('JOIN users')) { + const hash = String(this.params[0]); + const row = this.db.findSessionByHash(hash); + if (!row) { + return null; + } + return { + id: row.id, + email: row.email, + name: row.name, + image_url: row.image_url, + role: row.role, + expires_at: row.expires_at, + } as T; + } + + return null; + } +} + +class FakeExecutionContext { + waitUntil(): void {} + passThroughOnException(): void {} +} + +type EnvOverrides = Partial; + +function makeEnv(db: FakeAuthD1, overrides: EnvOverrides = {}): Env { + return { + DB: db as unknown as D1Database, + PUBLIC_HOST: 'localhost', + PRIVATE_HOST: 'localhost', + APP_BASE_URL: 'http://localhost:8787', + COOKIE_DOMAIN: '', + ...overrides, + } as Env; +} + +async function fetchWorker( + path: string, + env: Env, + init: RequestInit = {}, +): Promise { + const url = `http://localhost:8787${path}`; + const request = new Request(url, init); + const ctx = new FakeExecutionContext(); + return worker.fetch( + request as unknown as Parameters[0], + env as unknown as Parameters[1], + ctx as unknown as Parameters[2], + ); +} + +function futureIso(): string { + return new Date(Date.now() + 60 * 60 * 1000).toISOString(); +} + +function cookieHeader(token: string): string { + return `${AUTH_SESSION_COOKIE_NAME}=${token}`; +} + +describe('GET /api/auth/me', () => { + it('returns { user: null } with 200 when no Cookie header is present', async () => { + const db = new FakeAuthD1(); + const env = makeEnv(db); + + const response = await fetchWorker('/api/auth/me', env, { method: 'GET' }); + + expect(response.status).toBe(200); + const data = (await response.json()) as { user: unknown }; + expect(data).toEqual({ user: null }); + // No session lookup should have run when there's no cookie + expect(db.firstCalls).toHaveLength(0); + }); + + it('returns { user: null } with 200 when the session cookie is missing', async () => { + const db = new FakeAuthD1(); + const env = makeEnv(db); + + const response = await fetchWorker('/api/auth/me', env, { + method: 'GET', + headers: { cookie: 'other_cookie=abc' }, + }); + + expect(response.status).toBe(200); + const data = (await response.json()) as { user: unknown }; + expect(data).toEqual({ user: null }); + expect(db.firstCalls).toHaveLength(0); + }); + + it('returns the user payload when a valid session cookie exists', async () => { + const token = 'valid-me-token'; + const hash = await hashSessionToken(token); + const db = new FakeAuthD1(); + db.rows.push({ + id: 'user_1', + email: 'admin@heygo.cc', + name: 'Admin', + image_url: 'https://example.com/avatar.png', + role: 'admin', + expires_at: futureIso(), + session_token_hash: hash, + }); + const env = makeEnv(db); + + const response = await fetchWorker('/api/auth/me', env, { + method: 'GET', + headers: { cookie: cookieHeader(token) }, + }); + + expect(response.status).toBe(200); + const data = (await response.json()) as { + user: { + id: string; + email: string | null; + name: string | null; + imageUrl: string | null; + role: 'user' | 'admin'; + } | null; + }; + expect(data.user).toEqual({ + id: 'user_1', + email: 'admin@heygo.cc', + name: 'Admin', + imageUrl: 'https://example.com/avatar.png', + role: 'admin', + }); + // One session lookup was performed + expect(db.firstCalls).toHaveLength(1); + expect(db.firstCalls[0].sql).toContain('FROM sessions'); + expect(db.firstCalls[0].sql).toContain('JOIN users'); + }); + + it('returns the user payload for a non-admin user', async () => { + const token = 'normal-me-token'; + const hash = await hashSessionToken(token); + const db = new FakeAuthD1(); + db.rows.push({ + id: 'user_2', + email: 'person@heygo.cc', + name: 'Person', + image_url: null, + role: 'user', + expires_at: futureIso(), + session_token_hash: hash, + }); + const env = makeEnv(db); + + const response = await fetchWorker('/api/auth/me', env, { + method: 'GET', + headers: { cookie: cookieHeader(token) }, + }); + + expect(response.status).toBe(200); + const data = (await response.json()) as { user: { role: string } | null }; + expect(data.user?.role).toBe('user'); + }); + + it('returns { user: null } when the session token has no matching row', async () => { + const db = new FakeAuthD1(); + const env = makeEnv(db); + + const response = await fetchWorker('/api/auth/me', env, { + method: 'GET', + headers: { cookie: cookieHeader('unknown-token') }, + }); + + expect(response.status).toBe(200); + const data = (await response.json()) as { user: unknown }; + expect(data).toEqual({ user: null }); + expect(db.firstCalls).toHaveLength(1); + }); + + it('returns { user: null } for an expired session', async () => { + const token = 'expired-me-token'; + const hash = await hashSessionToken(token); + const db = new FakeAuthD1(); + db.rows.push({ + id: 'user_3', + email: 'expired@heygo.cc', + name: 'Expired', + image_url: null, + role: 'user', + expires_at: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + session_token_hash: hash, + }); + const env = makeEnv(db); + + const response = await fetchWorker('/api/auth/me', env, { + method: 'GET', + headers: { cookie: cookieHeader(token) }, + }); + + expect(response.status).toBe(200); + const data = (await response.json()) as { user: unknown }; + expect(data).toEqual({ user: null }); + }); + + it('rejects non-GET methods with 405', async () => { + const db = new FakeAuthD1(); + const env = makeEnv(db); + + const response = await fetchWorker('/api/auth/me', env, { method: 'POST' }); + + expect(response.status).toBe(405); + const data = (await response.json()) as { error: string }; + expect(data.error).toMatch(/method/i); + // No session lookup should have run + expect(db.firstCalls).toHaveLength(0); + }); + + it('falls through (returns null) for non-matching paths so the worker 404s', async () => { + const db = new FakeAuthD1(); + const env = makeEnv(db); + + // A path that starts with /api/ but is not /api/auth/me should fall through + // to the other handlers and ultimately the worker's 404 fallback. + const response = await fetchWorker('/api/some-other-endpoint', env, { method: 'GET' }); + + expect(response.status).toBe(404); + expect(db.firstCalls).toHaveLength(0); + }); + + it('applies private-host no-store headers on private host', async () => { + const db = new FakeAuthD1(); + // Use a private host so withPrivateHostNoStoreHeaders adds no-store + const env = makeEnv(db, { + PUBLIC_HOST: 'heygo.cc', + PRIVATE_HOST: 'my.heygo.cc', + }); + + const url = 'https://my.heygo.cc/api/auth/me'; + const request = new Request(url, { method: 'GET' }); + const ctx = new FakeExecutionContext(); + const response = await worker.fetch( + request as unknown as Parameters[0], + env as unknown as Parameters[1], + ctx as unknown as Parameters[2], + ); + + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toContain('no-store'); + }); +}); diff --git a/worker/index.ts b/worker/index.ts index 87fd4f4..86a850d 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -1,5 +1,6 @@ import type { Env } from './env'; import { withPrivateNoStoreHeaders } from './lib/responses'; +import { handleAuthApi } from './routes/api.auth'; import { handleDevAuth } from './routes/api.dev-auth'; import { handleLinksApi } from './routes/api.links'; import { handlePromotionsApi } from './routes/api.promotions'; @@ -35,6 +36,10 @@ export default { } if (url.pathname.startsWith('/api/')) { + const authResponse = await handleAuthApi(request, env); + if (authResponse) { + return withPrivateHostNoStoreHeaders(url, authResponse, env.PRIVATE_HOST); + } const devAuthResponse = await handleDevAuth(request, env); if (devAuthResponse) { return withPrivateHostNoStoreHeaders(url, devAuthResponse, env.PRIVATE_HOST); diff --git a/worker/routes/api.auth.ts b/worker/routes/api.auth.ts new file mode 100644 index 0000000..e8b536d --- /dev/null +++ b/worker/routes/api.auth.ts @@ -0,0 +1,29 @@ +import { getCurrentUser } from '../auth'; +import type { Env } from '../env'; + +export async function handleAuthApi(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (url.pathname !== '/api/auth/me') { + return null; + } + + if (request.method !== 'GET') { + return Response.json({ error: 'Method not allowed' }, { status: 405 }); + } + + const user = await getCurrentUser(request, env); + if (!user) { + return Response.json({ user: null }, { status: 200 }); + } + + return Response.json({ + user: { + id: user.id, + email: user.email, + name: user.name, + imageUrl: user.imageUrl, + role: user.role, + }, + }); +}