From 5ce87e088bec88986540cacd08f47dcafcb40056 Mon Sep 17 00:00:00 2001 From: Markov Date: Sun, 15 Feb 2026 19:26:25 +0100 Subject: [PATCH] refactor: pure JWT auth, no cookies - 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 --- src/app/(protected)/layout.tsx | 5 +++ src/app/{ => (protected)}/page.tsx | 34 ++++++++++----- .../projects/[slug]/page.tsx | 42 ++++++++++--------- src/app/api/auth/login/route.ts | 12 ------ src/app/login/page.tsx | 7 +--- src/components/AuthGuard.tsx | 28 +++++++++++++ src/lib/auth-client.ts | 18 ++++++-- src/middleware.ts | 27 ------------ 8 files changed, 96 insertions(+), 77 deletions(-) create mode 100644 src/app/(protected)/layout.tsx rename src/app/{ => (protected)}/page.tsx (59%) rename src/app/{ => (protected)}/projects/[slug]/page.tsx (51%) create mode 100644 src/components/AuthGuard.tsx delete mode 100644 src/middleware.ts diff --git a/src/app/(protected)/layout.tsx b/src/app/(protected)/layout.tsx new file mode 100644 index 0000000..ed8517d --- /dev/null +++ b/src/app/(protected)/layout.tsx @@ -0,0 +1,5 @@ +import AuthGuard from "@/components/AuthGuard"; + +export default function ProtectedLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/src/app/page.tsx b/src/app/(protected)/page.tsx similarity index 59% rename from src/app/page.tsx rename to src/app/(protected)/page.tsx index c9b5984..9705b0f 100644 --- a/src/app/page.tsx +++ b/src/app/(protected)/page.tsx @@ -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([]); + 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 (
@@ -18,7 +23,9 @@ export default async function Home() {

Team Board

AI Agent Collaboration Platform

- {projects.length > 0 ? ( + {loading ? ( +

Загрузка...

+ ) : projects.length > 0 ? (
{projects.map((p) => ( Нет проектов. Создайте первый через API.

)} + +
diff --git a/src/app/projects/[slug]/page.tsx b/src/app/(protected)/projects/[slug]/page.tsx similarity index 51% rename from src/app/projects/[slug]/page.tsx rename to src/app/(protected)/projects/[slug]/page.tsx index 8a54b44..bc2bafc 100644 --- a/src/app/projects/[slug]/page.tsx +++ b/src/app/(protected)/projects/[slug]/page.tsx @@ -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([]); + 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
Загрузка...
; + } + + if (!project) { + return
Проект не найден
; + } return (
- {/* Header */}

{project.name}

@@ -34,8 +40,6 @@ export default async function ProjectPage({ params }: Props) { )}
- - {/* Kanban */}
diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 2d90bd2..2785c46 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -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" } }); } diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 6690a1c..50acd41 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -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 || "Ошибка авторизации"); diff --git a/src/components/AuthGuard.tsx b/src/components/AuthGuard.tsx new file mode 100644 index 0000000..fbadf48 --- /dev/null +++ b/src/components/AuthGuard.tsx @@ -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 ( +
+ Загрузка... +
+ ); + } + + return <>{children}; +} diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index 01467b1..ab0ca98 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -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"; } diff --git a/src/middleware.ts b/src/middleware.ts deleted file mode 100644 index 4b4fa1e..0000000 --- a/src/middleware.ts +++ /dev/null @@ -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).*)"], -};