From c373ffffbcf9022f63d73f8c973c544072a1d221 Mon Sep 17 00:00:00 2001 From: Orvanix Date: Thu, 12 Mar 2026 11:07:18 +0000 Subject: [PATCH] feat(auth):add login via token in frontend --- src/app/api/auth/token/login/route.ts | 77 +++++++++++++++++++++++++++ src/app/auth/token/login/page.tsx | 54 +++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 src/app/api/auth/token/login/route.ts create mode 100644 src/app/auth/token/login/page.tsx diff --git a/src/app/api/auth/token/login/route.ts b/src/app/api/auth/token/login/route.ts new file mode 100644 index 0000000..ac952a3 --- /dev/null +++ b/src/app/api/auth/token/login/route.ts @@ -0,0 +1,77 @@ +/** + * Component: Token Login Route + * Documentation: documentation/backend/services/auth.md + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/db'; +import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt'; +import { RMABLogger } from '@/lib/utils/logger'; +import crypto from 'crypto'; + +const logger = RMABLogger.create('API.Auth.TokenLogin'); + +export async function GET(request: NextRequest) { + try { + const token = request.nextUrl.searchParams.get('token'); + + if (!token) { + return NextResponse.json({ error: 'Missing token parameter' }, { status: 400 }); + } + + const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); + + const user = await prisma.user.findFirst({ + where: { + loginTokenHash: tokenHash, + deletedAt: null, + }, + select: { + id: true, + plexId: true, + plexUsername: true, + plexEmail: true, + avatarUrl: true, + role: true, + }, + }); + + if (!user) { + logger.warn('Token login failed - not found or user deleted'); + return NextResponse.json({ error: 'Invalid token' }, { status: 401 }); + } + + await prisma.user.update({ + where: { id: user.id }, + data: { lastLoginAt: new Date() }, + }); + + const accessToken = generateAccessToken({ + sub: user.id, + plexId: user.plexId, + username: user.plexUsername, + role: user.role, + }); + + const refreshToken = generateRefreshToken(user.id); + + logger.info('Token login successful', { username: user.plexUsername }); + + return NextResponse.json({ + accessToken, + refreshToken, + user: { + id: user.id, + username: user.plexUsername, + email: user.plexEmail, + avatarUrl: user.avatarUrl, + role: user.role, + }, + }); + } catch (error) { + logger.error('Token login error', { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json({ error: 'Authentication failed' }, { status: 500 }); + } +} diff --git a/src/app/auth/token/login/page.tsx b/src/app/auth/token/login/page.tsx new file mode 100644 index 0000000..a8b7cce --- /dev/null +++ b/src/app/auth/token/login/page.tsx @@ -0,0 +1,54 @@ +/** + * Component: Token Login Page + * Documentation: documentation/backend/services/auth.md + */ + +'use client'; + +import { Suspense, useEffect } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useAuth } from '@/contexts/AuthContext'; + +function TokenLoginContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { setAuthData } = useAuth(); + + useEffect(() => { + const token = searchParams.get('token'); + + if (!token) { + router.replace('/login'); + return; + } + + fetch(`/api/auth/token/login?token=${encodeURIComponent(token)}`) + .then((res) => res.json()) + .then((data) => { + if (data.error) { + router.replace('/login'); + return; + } + + localStorage.setItem('accessToken', data.accessToken); + localStorage.setItem('refreshToken', data.refreshToken); + localStorage.setItem('user', JSON.stringify(data.user)); + + setAuthData(data.user, data.accessToken); + router.push('/'); + }) + .catch(() => { + router.replace('/login'); + }); + }, [searchParams, router, setAuthData]); + + return null; +} + +export default function TokenLoginPage() { + return ( + + + + ); +}