mirror of
https://github.com/wahyd4/heygo.git
synced 2026-08-08 21:05:48 +10:00
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:
+45
-18
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user