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
+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');
});
});