refactor: pure JWT auth, no cookies
All checks were successful
Deploy Web Client / deploy (push) Successful in 12s

- Remove middleware (no SSR auth check)
- AuthGuard component checks localStorage token
- Protected route group (protected) wraps all pages
- Login page is public
- All API calls use Authorization: Bearer header
This commit is contained in:
Markov 2026-02-15 19:26:25 +01:00
parent 747ad8d7a8
commit 5ce87e088b
8 changed files with 96 additions and 77 deletions

View File

@ -0,0 +1,5 @@
import AuthGuard from "@/components/AuthGuard";
export default function ProtectedLayout({ children }: { children: React.ReactNode }) {
return <AuthGuard>{children}</AuthGuard>;
}

View File

@ -1,15 +1,20 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { getProjects } from "@/lib/api";
import { getProjects, Project } from "@/lib/api";
import { logout } from "@/lib/auth-client";
export const dynamic = "force-dynamic";
export default function Home() {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
export default async function Home() {
let projects;
try {
projects = await getProjects();
} catch {
projects = [];
}
useEffect(() => {
getProjects()
.then(setProjects)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
return (
<div className="flex h-screen">
@ -18,7 +23,9 @@ export default async function Home() {
<h1 className="text-4xl font-bold mb-2">Team Board</h1>
<p className="text-[var(--muted)] mb-8">AI Agent Collaboration Platform</p>
{projects.length > 0 ? (
{loading ? (
<p className="text-[var(--muted)]">Загрузка...</p>
) : projects.length > 0 ? (
<div className="flex flex-col gap-2">
{projects.map((p) => (
<Link
@ -35,6 +42,13 @@ export default async function Home() {
) : (
<p className="text-[var(--muted)]">Нет проектов. Создайте первый через API.</p>
)}
<button
onClick={logout}
className="mt-8 text-xs text-[var(--muted)] hover:text-[var(--fg)] transition-colors"
>
Выйти
</button>
</div>
</main>
</div>

View File

@ -1,31 +1,37 @@
import { getProjects, getTasks } from "@/lib/api";
import { notFound } from "next/navigation";
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { getProjects, Project } from "@/lib/api";
import Sidebar from "@/components/Sidebar";
import KanbanBoard from "@/components/KanbanBoard";
export const dynamic = "force-dynamic";
export default function ProjectPage() {
const { slug } = useParams<{ slug: string }>();
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
interface Props {
params: Promise<{ slug: string }>;
}
export default async function ProjectPage({ params }: Props) {
const { slug } = await params;
let projects;
try {
projects = await getProjects();
} catch {
projects = [];
}
useEffect(() => {
getProjects()
.then(setProjects)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const project = projects.find((p) => p.slug === slug);
if (!project) return notFound();
if (loading) {
return <div className="flex h-screen items-center justify-center text-[var(--muted)]">Загрузка...</div>;
}
if (!project) {
return <div className="flex h-screen items-center justify-center text-[var(--muted)]">Проект не найден</div>;
}
return (
<div className="flex h-screen">
<Sidebar projects={projects} activeSlug={slug} />
<main className="flex-1 flex flex-col overflow-hidden">
{/* Header */}
<header className="border-b border-[var(--border)] px-6 py-4 flex items-center gap-4">
<div>
<h1 className="text-xl font-bold">{project.name}</h1>
@ -34,8 +40,6 @@ export default async function ProjectPage({ params }: Props) {
)}
</div>
</header>
{/* Kanban */}
<div className="flex-1 overflow-hidden">
<KanbanBoard projectId={project.id} />
</div>

View File

@ -1,4 +1,3 @@
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import { createToken } from "@/lib/auth";
@ -14,17 +13,6 @@ export async function POST(req: NextRequest) {
name: username,
provider: "local",
});
// Set cookie server-side (reliable, httpOnly)
const jar = await cookies();
jar.set("tb_token", token, {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 7 * 24 * 3600,
path: "/",
});
return NextResponse.json({ token, user: { name: username, provider: "local" } });
}

View File

@ -1,14 +1,13 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { setToken } from "@/lib/auth-client";
export default function LoginPage() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@ -22,9 +21,7 @@ export default function LoginPage() {
});
const data = await res.json();
if (res.ok && data.token) {
// Token stored in localStorage for API calls
localStorage.setItem("tb_token", data.token);
// Cookie set server-side via Set-Cookie header
setToken(data.token);
window.location.href = "/";
} else {
setError(data.error || "Ошибка авторизации");

View File

@ -0,0 +1,28 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { isAuthenticated } from "@/lib/auth-client";
export default function AuthGuard({ children }: { children: React.ReactNode }) {
const router = useRouter();
const [checked, setChecked] = useState(false);
useEffect(() => {
if (!isAuthenticated()) {
router.replace("/login");
} else {
setChecked(true);
}
}, [router]);
if (!checked) {
return (
<div className="flex h-screen items-center justify-center text-[var(--muted)]">
Загрузка...
</div>
);
}
return <>{children}</>;
}

View File

@ -1,10 +1,21 @@
/**
* Client-side auth helpers.
* Client-side JWT auth.
* Token stored in localStorage, sent as Authorization: Bearer header.
*/
const TOKEN_KEY = "tb_token";
export function getToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem("tb_token");
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string) {
localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken() {
localStorage.removeItem(TOKEN_KEY);
}
export function isAuthenticated(): boolean {
@ -12,7 +23,6 @@ export function isAuthenticated(): boolean {
}
export function logout() {
localStorage.removeItem("tb_token");
document.cookie = "tb_token=; path=/; max-age=0";
clearToken();
window.location.href = "/login";
}

View File

@ -1,27 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { verifyToken } from "@/lib/auth";
export async function middleware(req: NextRequest) {
// Check Authorization header (API calls)
const authHeader = req.headers.get("authorization");
if (authHeader) {
const token = authHeader.replace("Bearer ", "");
const payload = await verifyToken(token);
if (payload) return NextResponse.next();
return NextResponse.json({ error: "Invalid token" }, { status: 401 });
}
// Check cookie (browser navigation)
const token = req.cookies.get("tb_token")?.value;
if (token) {
const payload = await verifyToken(token);
if (payload) return NextResponse.next();
}
// Redirect to login
return NextResponse.redirect(new URL("/login", req.url));
}
export const config = {
matcher: ["/((?!login|api/auth|_next/static|_next/image|favicon.ico).*)"],
};