feat: redesign nav — home shows public links, user menu with dropdown

- 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
This commit is contained in:
Hermes Agent
2026-06-20 15:09:19 +10:00
parent fd647bcc2e
commit b357e8aa80
11 changed files with 655 additions and 23 deletions
+45 -18
View File
@@ -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<RouteId>(() => 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 (
<div className="app-shell">
<header className="app-bar">
<a className="brand" href="#/private">Heygo</a>
<a className="brand" href="#/" onClick={() => navigate('home')}>Heygo</a>
<nav className="tabs" aria-label="Primary">
{ROUTES.map((entry) => (
{navTabs.map((entry) => (
<a
key={entry.id}
href={`#/${entry.id}`}
@@ -53,14 +66,19 @@ export default function App() {
</a>
))}
</nav>
<div className="app-bar-right">
<UserMenu user={user} loading={loading} onLogout={handleLogout} />
</div>
</header>
<main className="app-main">
{route === 'private' ? <PrivateLinksPage /> : null}
{route === 'public' ? <PublicLinksPage /> : null}
{route === 'home' ? <PublicLinksPage /> : null}
{route === 'my-links' ? <PrivateLinksPage /> : null}
{route === 'admin' ? <AdminReviewPage /> : null}
{route === 'login' ? <LoginPage /> : null}
{route === 'dev-login' ? <DevLoginPage /> : null}
{route === 'profile' ? <PlaceholderPage title="Profile" /> : null}
{route === 'settings' ? <PlaceholderPage title="Settings" /> : null}
</main>
<footer className="app-footer">
@@ -69,3 +87,12 @@ export default function App() {
</div>
);
}
function PlaceholderPage({ title }: { title: string }) {
return (
<section className="panel">
<h1>{title}</h1>
<p className="muted">{title} page is coming soon.</p>
</section>
);
}
+55
View File
@@ -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 <div className="user-menu-loading" aria-label="Loading user" />;
}
if (!user) {
return (
<div className="user-menu">
<a className="signin-button" href="#/login">Sign In</a>
</div>
);
}
const displayName = user.name || user.email?.split('@')[0] || 'User';
return (
<div className="user-menu">
<div className="user-trigger" tabIndex={0}>
<span className="user-avatar">
{user.imageUrl ? (
<img src={user.imageUrl} alt="" width="28" height="28" />
) : (
<span className="user-avatar-fallback">
{displayName.charAt(0).toUpperCase()}
</span>
)}
</span>
<span className="user-name">{displayName}</span>
{user.role === 'admin' && <span className="admin-badge">admin</span>}
<svg className="chevron" width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<div className="user-dropdown" role="menu">
<a href="#/profile" className="dropdown-item" role="menuitem">Profile</a>
<a href="#/settings" className="dropdown-item" role="menuitem">Settings</a>
{user.role === 'admin' && (
<a href="#/admin" className="dropdown-item" role="menuitem">Admin Settings</a>
)}
<div className="dropdown-divider" />
<button type="button" className="dropdown-item dropdown-item--danger" onClick={onLogout}>
Logout
</button>
</div>
</div>
);
}
+34
View File
@@ -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<CurrentUser | null>(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 };
}
+2 -2
View File
@@ -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');
+1 -1
View File
@@ -12,7 +12,7 @@ export default function LoginPage() {
<hr className="divider" />
<p className="muted">
Don't want to sign in? You can still browse the{' '}
<a href="#/public">public directory</a>.
<a href="#/">public links</a>.
</p>
</section>
);
+1 -1
View File
@@ -117,7 +117,7 @@ export default function PrivateLinksPage() {
<section className="panel">
<header className="panel-header">
<div>
<h1>Private links</h1>
<h1>My Links</h1>
<p className="muted">Your personal shortlinks. Only visible to you.</p>
</div>
<div className="panel-actions">
+1 -1
View File
@@ -83,7 +83,7 @@ export default function PublicLinksPage() {
<section className="panel">
<header className="panel-header">
<div>
<h1>Public directory</h1>
<h1>Public Links</h1>
<p className="muted">Public shortlinks anyone can resolve.</p>
</div>
<button type="button" onClick={() => void refresh()} disabled={loading}>Refresh</button>
+186
View File
@@ -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;
}
}
+296
View File
@@ -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<T>(): Promise<T | null> {
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<Env>;
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<Response> {
const url = `http://localhost:8787${path}`;
const request = new Request(url, init);
const ctx = new FakeExecutionContext();
return worker.fetch(
request as unknown as Parameters<typeof worker.fetch>[0],
env as unknown as Parameters<typeof worker.fetch>[1],
ctx as unknown as Parameters<typeof worker.fetch>[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<typeof worker.fetch>[0],
env as unknown as Parameters<typeof worker.fetch>[1],
ctx as unknown as Parameters<typeof worker.fetch>[2],
);
expect(response.status).toBe(200);
expect(response.headers.get('cache-control')).toContain('no-store');
});
});
+5
View File
@@ -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);
+29
View File
@@ -0,0 +1,29 @@
import { getCurrentUser } from '../auth';
import type { Env } from '../env';
export async function handleAuthApi(request: Request, env: Env): Promise<Response | null> {
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,
},
});
}