add: full auth functionality

add: zustand auth store
add: jwt auto refresh
This commit is contained in:
Mahyar Khanbolooki
2025-07-01 22:49:56 +03:30
parent ef63706116
commit 48c458f012
22 changed files with 340 additions and 53 deletions
+2
View File
@@ -1,3 +1,5 @@
"use client";
import { ReactQueryProvider } from '@/components/providers/ReactQueryProvider' import { ReactQueryProvider } from '@/components/providers/ReactQueryProvider'
export default function AuthLayout({ children }: { children: React.ReactNode }) { export default function AuthLayout({ children }: { children: React.ReactNode }) {
+24 -11
View File
@@ -1,7 +1,7 @@
'use client'; 'use client';
import React, { useState, type FormEvent, type ChangeEvent, useEffect } from 'react' import React, { useState, type FormEvent, type ChangeEvent, useEffect } from 'react'
import { useAuthStore } from '@/zustand/userStore'; import { useAuthStore } from '@/zustand/authStore';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import StepEnterPassword from '@/features/auth/components/StepEnterPassword'; import StepEnterPassword from '@/features/auth/components/StepEnterPassword';
import StepEnterNumber from '@/features/auth/components/StepEnterNumber'; import StepEnterNumber from '@/features/auth/components/StepEnterNumber';
@@ -12,6 +12,8 @@ import StepNewPassword from '@/features/auth/components/StepNewPassword';
import { useLogin } from '@/hooks/auth/useLogin'; import { useLogin } from '@/hooks/auth/useLogin';
import { useSignup } from '@/hooks/auth/useSignup'; import { useSignup } from '@/hooks/auth/useSignup';
import { useCheckUserExistsLazy } from '@/hooks/auth/useCheckUserExists'; import { useCheckUserExistsLazy } from '@/hooks/auth/useCheckUserExists';
import { useOtpRequest } from '@/hooks/auth/useOtpRequest';
import { useOtpValidation } from '@/hooks/auth/useOtpValidation';
type Props = object type Props = object
@@ -29,7 +31,9 @@ function AuthIndex({ }: Props) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated) const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
const loginMutation = useLogin() const loginMutation = useLogin()
const signupMutation = useSignup() const signupMutation = useSignup()
const checkUserExistsMuation = useCheckUserExistsLazy() const { run: runUserExistCheck } = useCheckUserExistsLazy()
const { run: runOtpRequest } = useOtpRequest();
const { run: runOtpValidation } = useOtpValidation();
@@ -42,6 +46,7 @@ function AuthIndex({ }: Props) {
useEffect(() => { useEffect(() => {
if (step === AUTH_STEP.ENTER_OTP) { if (step === AUTH_STEP.ENTER_OTP) {
console.log("REQUEST OTP") console.log("REQUEST OTP")
runOtpRequest(number);
} }
}, [step]); }, [step]);
@@ -67,7 +72,7 @@ function AuthIndex({ }: Props) {
} }
} }
const onClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => { const onClick = async (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation(); e.stopPropagation();
const target = e.target as HTMLButtonElement; const target = e.target as HTMLButtonElement;
@@ -82,7 +87,12 @@ function AuthIndex({ }: Props) {
} }
else if (target.id === AUTH_PAGE_ELEMENT.RESEND_OTP) { else if (target.id === AUTH_PAGE_ELEMENT.RESEND_OTP) {
if (!timerRunning) { if (!timerRunning) {
restart(); try {
await runOtpRequest(number);
restart();
} catch {
}
} }
} }
}; };
@@ -91,7 +101,7 @@ function AuthIndex({ }: Props) {
e.preventDefault(); e.preventDefault();
if (step == AUTH_STEP.ENTER_NUMBER) { if (step == AUTH_STEP.ENTER_NUMBER) {
if (await checkUserExistsMuation.run(number)) { if (await runUserExistCheck(number)) {
setStep(AUTH_STEP.ENTER_PASSWORD) setStep(AUTH_STEP.ENTER_PASSWORD)
} else { } else {
setStep(AUTH_STEP.ENTER_OTP); setStep(AUTH_STEP.ENTER_OTP);
@@ -107,13 +117,16 @@ function AuthIndex({ }: Props) {
} }
} }
else if (step == AUTH_STEP.ENTER_OTP) { else if (step == AUTH_STEP.ENTER_OTP) {
if (!await runOtpValidation({ phone: number, otp })) {
setOtp('');
console.log('Wrong otp');
}
stop(); stop();
setStep(AUTH_STEP.ENTER_NEW_PASSWORD) if (await runUserExistCheck(number)) {
// if (await validatePhoneNumber(number)) { setStep(AUTH_STEP.ENTER_RESET_PASSWORD)
// setStep(AUTH_STEP.ENTER_RESET_PASSWORD) } else {
// } else { setStep(AUTH_STEP.ENTER_NEW_PASSWORD)
// setStep(AUTH_STEP.ENTER_NEW_PASSWORD) }
// }
} }
else if (step == AUTH_STEP.ENTER_RESET_PASSWORD) { else if (step == AUTH_STEP.ENTER_RESET_PASSWORD) {
console.log("Password changed") console.log("Password changed")
+10 -4
View File
@@ -1,18 +1,24 @@
'use client'; 'use client';
import { useAuthStore } from "@/zustand/userStore"; import { useAuthStore } from "@/zustand/authStore";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
export default function Home() { export default function Home() {
const logout = useAuthStore((state) => state.logout); const logout = useAuthStore((state) => state.logout);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return ( return (
<div className="grid font-sans grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]"> <div className="grid font-sans grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start"> <main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Link href={"/auth"}>Login</Link> {!isAuthenticated && <Link href={"/auth"}>Login</Link>}
<Link href={"/"} onClick={logout}>Logout</Link> {isAuthenticated &&
<div>
<Link href={"/profile"}>Profile</Link>
<br />
<Link href={""} onClick={logout}>Logout</Link>
</div>}
<Image <Image
className="dark:invert" className="dark:invert"
src="/next.svg" src="/next.svg"
+13
View File
@@ -0,0 +1,13 @@
"use client"
import { ReactQueryProvider } from '@/components/providers/ReactQueryProvider'
export default function ProfileLayout({ children }: { children: React.ReactNode }) {
return (
<ReactQueryProvider>
<div className="min-h-screen grid">
{children}
</div>
</ReactQueryProvider>
)
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { useProfile } from '@/hooks/auth/useProfile';
import { useAuthStore } from '@/zustand/authStore';
import React, { useEffect } from 'react'
type Props = object
function ProfileIndex({ }: Props) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const { mutate, data, isPending } = useProfile();
useEffect(() => {
if (isAuthenticated) {
mutate();
}
}, [isAuthenticated])
return (
<div>
{isPending && 'Loading...'}
<br />
{data && JSON.stringify(data)}
</div>
)
}
export default ProfileIndex
+2 -2
View File
@@ -1,4 +1,4 @@
import { checkUserExists } from "@/lib/api/auth" import { checkUserExists } from "@/lib/api/auth/user-exists"
import { useQueryClient } from "@tanstack/react-query" import { useQueryClient } from "@tanstack/react-query"
export const useCheckUserExistsLazy = () => { export const useCheckUserExistsLazy = () => {
@@ -7,7 +7,7 @@ export const useCheckUserExistsLazy = () => {
const run = (phone: string) => const run = (phone: string) =>
queryClient.fetchQuery({ queryClient.fetchQuery({
queryKey: ['checkUserExists', phone], queryKey: ['checkUserExists', phone],
queryFn: () => checkUserExists(phone), queryFn: () => checkUserExists({ phone }),
}) })
return { run } return { run }
+3 -3
View File
@@ -1,11 +1,11 @@
import { useMutation } from '@tanstack/react-query' import { useMutation } from '@tanstack/react-query'
import { login } from '@/lib/api/auth' import { useAuthStore } from '@/zustand/authStore'
import { useAuthStore } from '@/zustand/userStore' import { login } from '@/lib/api/auth/login'
export const useLogin = () => export const useLogin = () =>
useMutation({ useMutation({
mutationFn: login, mutationFn: login,
onSuccess: (data) => { onSuccess: (data) => {
useAuthStore.getState().login(data.user) useAuthStore.getState().login(data)
}, },
}) })
+14
View File
@@ -0,0 +1,14 @@
import { requestOtp } from "@/lib/api/auth/request-otp"
import { useQueryClient } from "@tanstack/react-query"
export const useOtpRequest = () => {
const queryClient = useQueryClient()
const run = (phone: string) =>
queryClient.fetchQuery({
queryKey: ['requestOtp', phone],
queryFn: () => requestOtp({ phone }),
})
return { run }
}
+14
View File
@@ -0,0 +1,14 @@
import { OtpValidationRequestModel, validateOtp } from "@/lib/api/auth/validate-otp"
import { useQueryClient } from "@tanstack/react-query"
export const useOtpValidation = () => {
const queryClient = useQueryClient()
const run = (model: OtpValidationRequestModel) =>
queryClient.fetchQuery({
queryKey: ['requestOtpValidation', model],
queryFn: () => validateOtp(model),
})
return { run }
}
+11
View File
@@ -0,0 +1,11 @@
import { useMutation } from '@tanstack/react-query'
import { useAuthStore } from '@/zustand/authStore'
import { fetchMe } from '@/lib/api/auth/me'
export const useProfile = () =>
useMutation({
mutationFn: fetchMe,
onSuccess: (data) => {
useAuthStore.getState().update(data)
},
})
+15
View File
@@ -0,0 +1,15 @@
import { refreshToken } from "@/lib/api/auth/refresh-token"
import { useAuthStore } from "@/zustand/authStore"
import { useMutation } from "@tanstack/react-query"
export const useOtpRequest = () => {
useMutation({
mutationFn: refreshToken,
onSuccess: (data) => {
useAuthStore.getState().setToken(data.accessToken)
},
onError: () => {
useAuthStore.getState().logout();
}
})
}
+3 -3
View File
@@ -1,11 +1,11 @@
import { useMutation } from '@tanstack/react-query' import { useMutation } from '@tanstack/react-query'
import { signup } from '@/lib/api/auth' import { signup } from '@/lib/api/auth/signup'
import { useAuthStore } from '@/zustand/userStore' import { useAuthStore } from '@/zustand/authStore'
export const useSignup = () => export const useSignup = () =>
useMutation({ useMutation({
mutationFn: signup, mutationFn: signup,
onSuccess: (data) => { onSuccess: (data) => {
useAuthStore.getState().login(data.user) useAuthStore.getState().login(data)
}, },
}) })
-25
View File
@@ -1,25 +0,0 @@
import { api } from './axiosInstance'
export const login = async ({ phone, password }: { phone: string; password: string }) => {
const res = await api.post('/login', { phone, password })
console.log(res);
return res.data // { user: { id, name, email, token } }
}
export const signup = async ({
phone,
password,
}: {
phone: string
password: string
}) => {
const res = await api.post('/signup', { phone, password })
console.log(res);
return res.data
}
export const checkUserExists = async (phone: string) => {
const res = await api.get(`/exists/${encodeURIComponent(phone)}`)
console.log(res);
return res.data.exists as boolean
}
+11
View File
@@ -0,0 +1,11 @@
import { api } from "../axiosInstance";
export type LoginRequestModel = {
phone: string,
password: string,
}
export const login = async (model: LoginRequestModel) => {
const res = await api.post('/login', model)
return res.data
}
+6
View File
@@ -0,0 +1,6 @@
import { api } from "../axiosInstance";
export const fetchMe = async () => {
const res = await api.get('/user')
return res.data
}
+10
View File
@@ -0,0 +1,10 @@
import { api } from "../axiosInstance";
export type RefreshTokenRequestModel = {
token: string,
}
export const refreshToken = async (model: RefreshTokenRequestModel) => {
const res = await api.post('/token', model)
return res.data
}
+10
View File
@@ -0,0 +1,10 @@
import { api } from "../axiosInstance";
export type OtpRequestModel = {
phone: string,
}
export const requestOtp = async ({ phone }: OtpRequestModel) => {
const res = await api.get(`/otp/${encodeURIComponent(phone)}`)
return res.data
}
+11
View File
@@ -0,0 +1,11 @@
import { api } from "../axiosInstance";
export type SignupRequestModel = {
phone: string,
password: string,
}
export const signup = async (model: SignupRequestModel) => {
const res = await api.post('/signup', model)
return res.data
}
+10
View File
@@ -0,0 +1,10 @@
import { api } from "../axiosInstance";
export type CheckUserExistsModel = {
phone: string,
}
export const checkUserExists = async ({ phone }: CheckUserExistsModel) => {
const res = await api.get(`/exists/${encodeURIComponent(phone)}`)
return res.data.exists as boolean
}
+11
View File
@@ -0,0 +1,11 @@
import { api } from "../axiosInstance";
export type OtpValidationRequestModel = {
phone: string,
otp: string,
}
export const validateOtp = async (model: OtpValidationRequestModel) => {
const res = await api.post(`/otp/check`, model)
return res.data
}
+103 -3
View File
@@ -1,6 +1,106 @@
import axios from 'axios' import { useAuthStore } from '@/zustand/authStore';
import axios from 'axios';
import { refreshToken } from './auth/refresh-token';
export const api = axios.create({ // Create axios instance
const api = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL || 'https://api.example.com', baseURL: process.env.NEXT_PUBLIC_API_URL || 'https://api.example.com',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}) });
let isRefreshing = false;
let failedQueue: {
resolve: (value?: unknown) => void;
reject: (error: unknown) => void;
}[] = [];
const processQueue = (error: unknown, token: string | null = null) => {
failedQueue.forEach(({ resolve, reject }) => {
if (error) {
reject(error);
} else {
resolve(token);
}
});
failedQueue = [];
};
// Attach access token to requests
api.interceptors.request.use((config) => {
const state = useAuthStore.getState();
console.log('AuthStore state:', state);
const token = state.user?.accessToken;
if (token) {
config.headers = config.headers || {};
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Response interceptor to handle 401 and refresh token logic
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (
error.response?.status === 401 &&
!originalRequest._retry // custom flag to prevent infinite loops
) {
if (isRefreshing) {
// Queue the requests while refreshing
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return api(originalRequest);
})
.catch((err) => {
return Promise.reject(err);
});
}
originalRequest._retry = true;
isRefreshing = true;
const token = useAuthStore.getState().user?.refreshToken;
console.log(useAuthStore.getState())
if (!token) {
useAuthStore.getState().logout();
return Promise.reject(error);
}
try {
const data = await refreshToken({ token });
// Update tokens in Zustand store
useAuthStore.getState().login({
...useAuthStore.getState().user!,
accessToken: data.accessToken,
refreshToken: data.refreshToken || token,
});
api.defaults.headers.common.Authorization = `Bearer ${data.accessToken}`;
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
processQueue(null, data.accessToken);
return api(originalRequest);
} catch (err) {
processQueue(err, null);
useAuthStore.getState().logout();
return Promise.reject(err);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
export { api };
@@ -6,7 +6,8 @@ type User = {
id: string id: string
name: string name: string
number: string number: string
token: string accessToken: string
refreshToken: string
} }
type AuthStore = { type AuthStore = {
@@ -14,6 +15,8 @@ type AuthStore = {
isAuthenticated: boolean isAuthenticated: boolean
login: (userData: User) => void login: (userData: User) => void
logout: () => void logout: () => void
update: (userData: User) => void
setToken: (token: string | null) => void;
} }
export const useAuthStore = create<AuthStore>()( export const useAuthStore = create<AuthStore>()(
@@ -25,12 +28,35 @@ export const useAuthStore = create<AuthStore>()(
set(() => ({ set(() => ({
user: userData, user: userData,
isAuthenticated: true, isAuthenticated: true,
})), }))
,
logout: () => logout: () =>
set(() => ({ set(() => ({
user: null, user: null,
isAuthenticated: false, isAuthenticated: false,
})), })),
update: (userData) => {
set((state) => {
if (!state.user) return state; // no user to update
return {
user: {
...state.user,
...userData
},
};
});
},
setToken: (token) => {
set((state) => {
if (!state.user) return state; // no user to update
return {
user: {
...state.user,
accessToken: token ?? '',
},
};
});
}
}), }),
{ {
name: 'auth-storage', // Key in localStorage name: 'auth-storage', // Key in localStorage