feat: add BetterAuth authentication
- Add better-auth to backend and frontend - Create auth tables (users, sessions, accounts, verifications) - Mount BetterAuth handler on /api/auth/* - Protect GET /api/tasks with session auth - Add login page with email/password - Add invite route for creating users - Add logout button to header - Cross-subdomain cookies for .donovankelly.xyz - Fix page title to 'Hammer Queue' - Keep bearer token for admin mutations (separate from session auth) - Update docker-compose with BETTER_AUTH_SECRET and COOKIE_DOMAIN
This commit is contained in:
@@ -2,20 +2,23 @@ import { useState, useMemo } from "react";
|
||||
import { useTasks } from "./hooks/useTasks";
|
||||
import { TaskCard } from "./components/TaskCard";
|
||||
import { CreateTaskModal } from "./components/CreateTaskModal";
|
||||
import { LoginPage } from "./components/LoginPage";
|
||||
import { useSession, signOut } from "./lib/auth-client";
|
||||
import { updateTask, reorderTasks, createTask } from "./lib/api";
|
||||
import type { TaskStatus } from "./lib/types";
|
||||
|
||||
// Token stored in localStorage for dashboard admin operations
|
||||
// Token stored in localStorage for bearer-token admin operations
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("hammer-queue-token") || "";
|
||||
}
|
||||
|
||||
function App() {
|
||||
function Dashboard() {
|
||||
const { tasks, loading, error, refresh } = useTasks(5000);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [showCompleted, setShowCompleted] = useState(false);
|
||||
const [tokenInput, setTokenInput] = useState("");
|
||||
const [showTokenInput, setShowTokenInput] = useState(false);
|
||||
const session = useSession();
|
||||
|
||||
const token = getToken();
|
||||
const hasToken = !!token;
|
||||
@@ -77,6 +80,11 @@ function App() {
|
||||
setShowTokenInput(false);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await signOut();
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
@@ -87,34 +95,34 @@ function App() {
|
||||
<h1 className="text-xl font-bold text-gray-900">Hammer Queue</h1>
|
||||
<span className="text-xs text-gray-400 mt-1">Task Dashboard</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasToken ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="text-sm bg-amber-500 text-white px-3 py-1.5 rounded-lg hover:bg-amber-600 transition font-medium"
|
||||
>
|
||||
+ New Task
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
localStorage.removeItem("hammer-queue-token");
|
||||
refresh();
|
||||
}}
|
||||
className="text-xs text-gray-400 hover:text-gray-600 px-2 py-1"
|
||||
title="Log out"
|
||||
>
|
||||
🔓
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
{hasToken && (
|
||||
<button
|
||||
onClick={() => setShowTokenInput(true)}
|
||||
className="text-sm text-gray-500 hover:text-gray-700 px-3 py-1.5 border rounded-lg"
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="text-sm bg-amber-500 text-white px-3 py-1.5 rounded-lg hover:bg-amber-600 transition font-medium"
|
||||
>
|
||||
🔑 Set Token
|
||||
+ New Task
|
||||
</button>
|
||||
)}
|
||||
{!hasToken && (
|
||||
<button
|
||||
onClick={() => setShowTokenInput(true)}
|
||||
className="text-xs text-gray-400 hover:text-gray-600 px-2 py-1 border border-gray-200 rounded-lg"
|
||||
title="Set API token for admin actions"
|
||||
>
|
||||
🔑 Admin
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span className="hidden sm:inline">{session.data?.user?.email}</span>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-xs text-gray-400 hover:text-red-500 px-2 py-1 border border-gray-200 rounded-lg transition"
|
||||
title="Sign out"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -123,7 +131,10 @@ function App() {
|
||||
{showTokenInput && (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-2xl p-6 w-full max-w-sm mx-4">
|
||||
<h2 className="text-lg font-bold mb-3">API Token</h2>
|
||||
<h2 className="text-lg font-bold mb-2">API Token</h2>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
Enter the bearer token for admin actions (create, update, delete tasks).
|
||||
</p>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Bearer token..."
|
||||
@@ -264,4 +275,22 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const session = useSession();
|
||||
|
||||
if (session.isPending) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-gray-400">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session.data) {
|
||||
return <LoginPage onSuccess={() => window.location.reload()} />;
|
||||
}
|
||||
|
||||
return <Dashboard />;
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
94
frontend/src/components/LoginPage.tsx
Normal file
94
frontend/src/components/LoginPage.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useState } from "react";
|
||||
import { signIn } from "../lib/auth-client";
|
||||
|
||||
interface LoginPageProps {
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function LoginPage({ onSuccess }: LoginPageProps) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await signIn.email({ email, password });
|
||||
if (result.error) {
|
||||
setError(result.error.message || "Invalid credentials");
|
||||
} else {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Something went wrong");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="text-center mb-8">
|
||||
<span className="text-5xl">🔨</span>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mt-3">Hammer Queue</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Sign in to access the dashboard</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-xl shadow-lg border border-gray-200 p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 rounded-lg p-3 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 focus:border-transparent"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 focus:border-transparent"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-amber-500 text-white rounded-lg py-2.5 text-sm font-semibold hover:bg-amber-600 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? "Signing in..." : "Sign In"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-xs text-gray-400 mt-6">
|
||||
Invite-only access · Contact admin for an account
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,11 @@ export function useTasks(pollInterval = 5000) {
|
||||
setTasks(data);
|
||||
setError(null);
|
||||
} catch (e: any) {
|
||||
if (e.message === "Unauthorized") {
|
||||
// Session expired — reload to show login
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -3,8 +3,8 @@ import type { Task } from "./types";
|
||||
const BASE = "/api/tasks";
|
||||
|
||||
export async function fetchTasks(): Promise<Task[]> {
|
||||
const res = await fetch(BASE);
|
||||
if (!res.ok) throw new Error("Failed to fetch tasks");
|
||||
const res = await fetch(BASE, { credentials: "include" });
|
||||
if (!res.ok) throw new Error(res.status === 401 ? "Unauthorized" : "Failed to fetch tasks");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export async function updateTask(
|
||||
): Promise<Task> {
|
||||
const res = await fetch(`${BASE}/${id}`, {
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
@@ -28,6 +29,7 @@ export async function updateTask(
|
||||
export async function reorderTasks(ids: string[], token: string): Promise<void> {
|
||||
const res = await fetch(`${BASE}/reorder`, {
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
@@ -43,6 +45,7 @@ export async function createTask(
|
||||
): Promise<Task> {
|
||||
const res = await fetch(BASE, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
@@ -56,6 +59,7 @@ export async function createTask(
|
||||
export async function deleteTask(id: string, token: string): Promise<void> {
|
||||
const res = await fetch(`${BASE}/${id}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to delete task");
|
||||
|
||||
7
frontend/src/lib/auth-client.ts
Normal file
7
frontend/src/lib/auth-client.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: window.location.origin,
|
||||
});
|
||||
|
||||
export const { useSession, signIn, signOut, signUp } = authClient;
|
||||
Reference in New Issue
Block a user