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.

This commit is contained in:
Robert Hajdu 2025-08-10 14:42:33 +02:00
parent 1c4c97e02a
commit 610ca29cb5
11 changed files with 122 additions and 55 deletions

View file

@ -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 $$;

View file

@ -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 };

View file

@ -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() {
<Logo />
</Icon>
<div className={styles.title}>umami</div>
<Form className={styles.form} onSubmit={handleSubmit} error={getMessage(error)}>
<Form className={styles.form} onSubmit={handleSubmit}>
<FormRow label={formatMessage(labels.username)}>
<FormInput
data-test="input-username"
name="username"
rules={{ required: formatMessage(labels.required) }}
>
<TextField autoComplete="off" />
<TextField autoComplete="username" />
</FormInput>
</FormRow>
<FormRow label={formatMessage(labels.password)}>
@ -56,16 +54,11 @@ export function LoginForm() {
name="password"
rules={{ required: formatMessage(labels.required) }}
>
<PasswordField />
<PasswordField autoComplete="current-password" />
</FormInput>
</FormRow>
<FormButtons>
<SubmitButton
data-test="button-submit"
className={styles.button}
variant="primary"
disabled={isPending}
>
<SubmitButton data-test="button-submit" className={styles.button} variant="primary">
{formatMessage(labels.login)}
</SubmitButton>
</FormButtons>

View file

@ -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;
}

View file

@ -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]);

2
src/auth.ts Normal file
View file

@ -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.

View file

@ -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) => {

View file

@ -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) {

46
src/lib/authOptions.ts Normal file
View file

@ -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;

9
src/middleware.ts Normal file
View file

@ -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).*)'],
};

13
src/types/next-auth.d.ts vendored Normal file
View file

@ -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;
};
}
}