From 610ca29cb5be8332ef7e0b76b89429454cde2962 Mon Sep 17 00:00:00 2001 From: Robert Hajdu Date: Sun, 10 Aug 2025 14:42:33 +0200 Subject: [PATCH] Refactor authentication flow in LoginForm and LogoutPage to use next-auth. Remove unnecessary API calls and improve form handling. Update SSOPage to eliminate client token management. Adjust useApi hook to remove client token dependency. --- .../14_add_auth_columns/migration.sql | 14 ++++++ src/app/api/auth/[...nextauth]/route.ts | 6 +++ src/app/login/LoginForm.tsx | 41 +++++++---------- src/app/logout/LogoutPage.tsx | 14 ++---- src/app/sso/SSOPage.tsx | 3 -- src/auth.ts | 2 + src/components/hooks/useApi.ts | 4 +- src/lib/auth.ts | 25 +++++----- src/lib/authOptions.ts | 46 +++++++++++++++++++ src/middleware.ts | 9 ++++ src/types/next-auth.d.ts | 13 ++++++ 11 files changed, 122 insertions(+), 55 deletions(-) create mode 100644 db/postgresql/migrations/14_add_auth_columns/migration.sql create mode 100644 src/app/api/auth/[...nextauth]/route.ts create mode 100644 src/auth.ts create mode 100644 src/lib/authOptions.ts create mode 100644 src/middleware.ts create mode 100644 src/types/next-auth.d.ts diff --git a/db/postgresql/migrations/14_add_auth_columns/migration.sql b/db/postgresql/migrations/14_add_auth_columns/migration.sql new file mode 100644 index 000000000..3eefde2f1 --- /dev/null +++ b/db/postgresql/migrations/14_add_auth_columns/migration.sql @@ -0,0 +1,14 @@ +-- Add optional email and email_verified to user table for Auth.js compatibility +ALTER TABLE "user" + ADD COLUMN IF NOT EXISTS email varchar(255), + ADD COLUMN IF NOT EXISTS email_verified timestamptz; + +-- Ensure email is unique if present +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_indexes WHERE schemaname = current_schema() AND indexname = 'user_email_key' + ) THEN + CREATE UNIQUE INDEX user_email_key ON "user" (email); + END IF; +END $$; \ No newline at end of file diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 000000000..46c345ac7 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,6 @@ +import NextAuth from 'next-auth'; +import authOptions from '@/lib/authOptions'; + +const handler = NextAuth(authOptions); + +export { handler as GET, handler as POST }; diff --git a/src/app/login/LoginForm.tsx b/src/app/login/LoginForm.tsx index a808c622d..9bc9dfd22 100644 --- a/src/app/login/LoginForm.tsx +++ b/src/app/login/LoginForm.tsx @@ -9,29 +9,27 @@ import { Icon, } from 'react-basics'; import { useRouter } from 'next/navigation'; -import { useApi, useMessages } from '@/components/hooks'; -import { setUser } from '@/store/app'; -import { setClientAuthToken } from '@/lib/client'; +import { useMessages } from '@/components/hooks'; import Logo from '@/assets/logo.svg'; import styles from './LoginForm.module.css'; +import { signIn } from 'next-auth/react'; export function LoginForm() { - const { formatMessage, labels, getMessage } = useMessages(); + const { formatMessage, labels } = useMessages(); const router = useRouter(); - const { post, useMutation } = useApi(); - const { mutate, error, isPending } = useMutation({ - mutationFn: (data: any) => post('/auth/login', data), - }); const handleSubmit = async (data: any) => { - mutate(data, { - onSuccess: async ({ token, user }) => { - setClientAuthToken(token); - setUser(user); - - router.push('/dashboard'); - }, + const res = await signIn('credentials', { + username: data.username, + password: data.password, + redirect: false, }); + + if (res?.error) { + throw new Error(res.error); + } + + router.push('/dashboard'); }; return ( @@ -40,14 +38,14 @@ export function LoginForm() {
umami
-
+ - + @@ -56,16 +54,11 @@ export function LoginForm() { name="password" rules={{ required: formatMessage(labels.required) }} > - + - + {formatMessage(labels.login)} diff --git a/src/app/logout/LogoutPage.tsx b/src/app/logout/LogoutPage.tsx index 2914c4eb7..5173c179d 100644 --- a/src/app/logout/LogoutPage.tsx +++ b/src/app/logout/LogoutPage.tsx @@ -1,30 +1,22 @@ 'use client'; import { useEffect } from 'react'; import { useRouter } from 'next/navigation'; -import { useApi } from '@/components/hooks'; -import { setUser } from '@/store/app'; -import { removeClientAuthToken } from '@/lib/client'; +import { signOut } from 'next-auth/react'; export function LogoutPage() { const router = useRouter(); - const { post } = useApi(); const disabled = process.env.cloudMode; useEffect(() => { async function logout() { - await post('/auth/logout'); + await signOut({ redirect: false }); } if (!disabled) { - removeClientAuthToken(); - logout(); - router.push('/login'); - - return () => setUser(null); } - }, [disabled, router, post]); + }, [disabled, router]); return null; } diff --git a/src/app/sso/SSOPage.tsx b/src/app/sso/SSOPage.tsx index eb7c0f0a9..b5fe13a29 100644 --- a/src/app/sso/SSOPage.tsx +++ b/src/app/sso/SSOPage.tsx @@ -2,7 +2,6 @@ import { useEffect } from 'react'; import { Loading } from 'react-basics'; import { useRouter, useSearchParams } from 'next/navigation'; -import { setClientAuthToken } from '@/lib/client'; export default function SSOPage() { const router = useRouter(); @@ -12,8 +11,6 @@ export default function SSOPage() { useEffect(() => { if (url && token) { - setClientAuthToken(token); - router.push(url); } }, [router, url, token]); diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 000000000..20712ab3f --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,2 @@ +// Auth.js initialization lives in src/lib/authOptions.ts and app/api/auth/[...nextauth]/route.ts +// This file is intentionally left empty to avoid duplicate initialization. diff --git a/src/components/hooks/useApi.ts b/src/components/hooks/useApi.ts index dfa48e2ff..8e1c897bb 100644 --- a/src/components/hooks/useApi.ts +++ b/src/components/hooks/useApi.ts @@ -1,6 +1,5 @@ import { useCallback } from 'react'; import * as reactQuery from '@tanstack/react-query'; -import { getClientAuthToken } from '@/lib/client'; import { SHARE_TOKEN_HEADER } from '@/lib/constants'; import { httpGet, httpPost, httpPut, httpDelete, FetchResponse } from '@/lib/fetch'; import useStore from '@/store/app'; @@ -22,9 +21,8 @@ export function useApi() { const shareToken = useStore(selector); const defaultHeaders = { - authorization: `Bearer ${getClientAuthToken()}`, [SHARE_TOKEN_HEADER]: shareToken?.token, - }; + } as any; const basePath = process.env.basePath; const getUrl = (url: string) => { diff --git a/src/lib/auth.ts b/src/lib/auth.ts index d67566b82..7fb8416c6 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -4,10 +4,12 @@ import redis from '@/lib/redis'; import debug from 'debug'; import { PERMISSIONS, ROLE_PERMISSIONS, ROLES, SHARE_TOKEN_HEADER } from '@/lib/constants'; import { secret, getRandomChars } from '@/lib/crypto'; -import { createSecureToken, parseSecureToken, parseToken } from '@/lib/jwt'; +import { createSecureToken, parseToken } from '@/lib/jwt'; import { ensureArray } from '@/lib/utils'; import { getTeamUser, getUser, getWebsite } from '@/queries'; import { Auth } from './types'; +import { getServerSession } from 'next-auth'; +import authOptions from '@/lib/authOptions'; const log = debug('umami:auth'); const cloudMode = process.env.CLOUD_MODE; @@ -22,25 +24,20 @@ export function checkPassword(password: string, passwordHash: string) { } export async function checkAuth(request: Request) { - const token = request.headers.get('authorization')?.split(' ')?.[1]; - const payload = parseSecureToken(token, secret()); + const session = await getServerSession(authOptions); const shareToken = await parseShareToken(request.headers); - let user = null; - const { userId, authKey, grant } = payload || {}; + let user: any = null; + const grant = undefined as any; + const token = undefined as any; + const authKey = undefined as any; - if (userId) { - user = await getUser(userId); - } else if (redis.enabled && authKey) { - const key = await redis.client.get(authKey); - - if (key?.userId) { - user = await getUser(key.userId); - } + if ((session as any)?.user?.id) { + user = await getUser((session as any).user.id as string); } if (process.env.NODE_ENV === 'development') { - log('checkAuth:', { token, shareToken, payload, user, grant }); + log('checkAuth:', { session, shareToken, user, grant }); } if (!user?.id && !shareToken) { diff --git a/src/lib/authOptions.ts b/src/lib/authOptions.ts new file mode 100644 index 000000000..0212df467 --- /dev/null +++ b/src/lib/authOptions.ts @@ -0,0 +1,46 @@ +import type { NextAuthOptions } from 'next-auth'; +import CredentialsProvider from 'next-auth/providers/credentials'; +import { checkPassword } from '@/lib/auth'; +import { getUserByUsername } from '@/queries'; + +const AUTH_SECRET = process.env.NEXTAUTH_SECRET || process.env.APP_SECRET; + +const authOptions: NextAuthOptions = { + secret: AUTH_SECRET, + session: { strategy: 'jwt' }, + providers: [ + CredentialsProvider({ + name: 'Credentials', + credentials: { + username: { label: 'Username', type: 'text' }, + password: { label: 'Password', type: 'password' }, + }, + authorize: async credentials => { + if (!credentials?.username || !credentials?.password) return null; + const user = await getUserByUsername(credentials.username, { + includePassword: true, + } as any); + if (!user) return null; + const ok = checkPassword(credentials.password, user.password as string); + if (!ok) return null; + return { id: user.id, name: user.username, image: undefined, role: user.role } as any; + }, + }), + ], + callbacks: { + async session({ session, token }) { + (session as any).user.id = (token as any).id as string; + (session as any).user.role = (token as any).role as string; + return session; + }, + async jwt({ token, user }) { + if (user) { + (token as any).id = (user as any).id; + (token as any).role = (user as any).role; + } + return token; + }, + }, +}; + +export default authOptions; diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 000000000..2f0873e6a --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,9 @@ +import { NextResponse } from 'next/server'; + +export function middleware() { + return NextResponse.next(); +} + +export const config = { + matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'], +}; diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts new file mode 100644 index 000000000..c28b58206 --- /dev/null +++ b/src/types/next-auth.d.ts @@ -0,0 +1,13 @@ +import 'next-auth'; + +declare module 'next-auth' { + interface Session { + user: { + id: string; + name?: string | null; + email?: string | null; + image?: string | null; + role?: string | null; + }; + } +}