Skip to content

Commit 85420b0

Browse files
authored
Merge pull request #55 from codersforcauses/issue-28-Setup_frontend_authentication_v2
Add files conected to auth, ready to pr
2 parents 9adcf85 + 6269df8 commit 85420b0

File tree

8 files changed

+356
-7
lines changed

8 files changed

+356
-7
lines changed

client/package-lock.json

Lines changed: 41 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

client/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,14 @@
2323
"class-variance-authority": "^0.7.1",
2424
"clsx": "^2.1.1",
2525
"is-inside-container": "^1.0.0",
26+
"jwt-decode": "^4.0.0",
2627
"lucide-react": "^0.516.0",
2728
"next": "15.3.3",
2829
"react": "19.1.0",
2930
"react-dom": "19.1.0",
3031
"tailwind-merge": "^3.3.1",
31-
"tailwindcss-animate": "^1.0.7"
32+
"tailwindcss-animate": "^1.0.7",
33+
"zustand": "^5.0.6"
3234
},
3335
"devDependencies": {
3436
"@csstools/postcss-oklab-function": "^4.0.10",

client/src/context/auth-provider.tsx

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import axios from "axios";
2+
// import Cookies from "js-cookie";
3+
import { createContext, useContext, useEffect } from "react";
4+
5+
import { useTokenStore } from "@/store/TokenStore";
6+
7+
type AuthContextType = {
8+
userId: string | null;
9+
isLoggedIn: boolean;
10+
login: (email: string, password: string) => Promise<boolean>;
11+
logout: () => Promise<void>;
12+
};
13+
14+
type TokenResponse = {
15+
access: string;
16+
refresh: string;
17+
};
18+
19+
export const AuthContext = createContext<AuthContextType | null>(null);
20+
21+
export function AuthProvider({ children }: { children: React.ReactNode }) {
22+
const accessToken = useTokenStore((state) => state.access);
23+
const setTokens = useTokenStore((state) => state.setTokens);
24+
const clearTokens = useTokenStore((state) => state.clear);
25+
26+
// id is generated automatically by DB.
27+
const userId =
28+
(accessToken?.decoded as { user_id?: string })?.user_id ?? null;
29+
const isLoggedIn = userId !== null;
30+
31+
// Automatically set/remove a cookie when the access token changes
32+
// This is used by NextJS middleware to determine if the user is able to access certain routes
33+
// TODO: Currently role is always "PATICIPANT". Roles are here: server/event_registration/models.py.
34+
// TODO: no interaction with roles so far
35+
36+
useEffect(() => {
37+
// if (accessToken) {
38+
// Cookies.set("user_role", "user", { sameSite: "strict", secure: true });
39+
// } else {
40+
// Cookies.remove("user_role");
41+
// }
42+
}, [accessToken]);
43+
44+
// LOG IN
45+
46+
const login = async (email: string, password: string) => {
47+
try {
48+
const result = await axios.post<TokenResponse>(
49+
`${process.env.NEXT_PUBLIC_BACKEND_URL}/token/`,
50+
{ email, password },
51+
{
52+
validateStatus: (status) =>
53+
status === 200 || status === 401 || status === 400,
54+
},
55+
);
56+
57+
if (result.status !== 200 || !result.data) {
58+
// Try to get error message from backend
59+
const data = result.data as any;
60+
const errorMsg = data?.detail || data?.error || "Invalid credentials";
61+
return errorMsg;
62+
}
63+
64+
setTokens(result.data.access, result.data.refresh);
65+
return true;
66+
} catch (err: any) {
67+
// Network or unexpected error
68+
return err?.response?.data?.detail || err?.message || "Network error";
69+
}
70+
};
71+
72+
// LOG OUT
73+
74+
const logout = async () => {
75+
clearTokens();
76+
};
77+
const context = { userId, isLoggedIn, login, logout };
78+
79+
return (
80+
<AuthContext.Provider value={context}>{children}</AuthContext.Provider>
81+
);
82+
}
83+
84+
export const useAuth = () => useContext(AuthContext)!;

client/src/hooks/useUser.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import {
2+
useMutation,
3+
UseMutationOptions,
4+
useQuery,
5+
} from "@tanstack/react-query";
6+
import { AxiosError } from "axios";
7+
8+
import { useAuth } from "@/context/auth-provider";
9+
import api from "@/lib/api";
10+
import { User } from "@/types/user";
11+
12+
type RegistrationDetails = {
13+
email: string;
14+
password: string;
15+
firstName: string;
16+
lastName: string;
17+
phone: string;
18+
branch: number;
19+
};
20+
21+
export const useUser = () => {
22+
const { userId } = useAuth();
23+
return useQuery<User, AxiosError>({
24+
queryKey: ["user", userId],
25+
staleTime: 5 * 1000 * 60,
26+
enabled: userId != null,
27+
queryFn: () =>
28+
api.get("users/me/").then((res) => {
29+
return res.data;
30+
}),
31+
});
32+
};
33+
34+
export const useRegister = (
35+
args?: Omit<
36+
UseMutationOptions<
37+
unknown,
38+
AxiosError<{ [key: string]: unknown }>,
39+
RegistrationDetails
40+
>,
41+
"mutationKey" | "mutationFn"
42+
>,
43+
) => {
44+
return useMutation({
45+
...args,
46+
mutationKey: ["register"],
47+
mutationFn: (details: RegistrationDetails) => {
48+
const correctDetails = {
49+
first_name: details.firstName,
50+
last_name: details.lastName,
51+
city: details.branch,
52+
...details,
53+
};
54+
return api.post("users/register/", correctDetails);
55+
},
56+
});
57+
};

client/src/lib/api.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,92 @@
1-
import axios from "axios";
1+
import axios, { AxiosError } from "axios";
2+
3+
import { useTokenStore } from "@/store/TokenStore";
24

35
const api = axios.create({ baseURL: process.env.NEXT_PUBLIC_BACKEND_URL });
6+
api.interceptors.request.use(
7+
async (config) => {
8+
const accessToken = useTokenStore.getState().access;
9+
if (accessToken) {
10+
config.headers.Authorization = `Bearer ${accessToken.encoded}`;
11+
}
12+
return config;
13+
},
14+
(error) => Promise.reject(error),
15+
);
16+
17+
// Queue of failed request promises waiting for refreshed token
18+
let failedQueue: {
19+
resolve: () => void;
20+
reject: (_: AxiosError | null) => void;
21+
}[] = [];
22+
let isRefreshing = false;
23+
24+
const processQueue = (error: AxiosError | null) => {
25+
failedQueue.forEach((prom) => {
26+
if (error) {
27+
prom.reject(error);
28+
} else {
29+
prom.resolve();
30+
}
31+
});
32+
failedQueue = [];
33+
};
34+
35+
api.interceptors.response.use(
36+
(response) => response,
37+
async (error) => {
38+
const tokenState = useTokenStore.getState();
39+
const originalRequest = error.config;
40+
41+
const handleError = (error: AxiosError | null) => {
42+
processQueue(error);
43+
tokenState.clear();
44+
return Promise.reject(error);
45+
};
46+
47+
const refreshTokenValid =
48+
tokenState.refresh != undefined && tokenState.refresh.expiry > Date.now();
49+
const isAuthError =
50+
error.response?.status === 401 || error.response?.status === 403;
51+
52+
if (
53+
refreshTokenValid == true &&
54+
isAuthError &&
55+
originalRequest.url !== "/auth/refresh/" &&
56+
originalRequest._retry !== true
57+
) {
58+
if (isRefreshing) {
59+
return new Promise<void>(function (resolve, reject) {
60+
failedQueue.push({ resolve, reject });
61+
})
62+
.then(() => api(originalRequest))
63+
.catch((err) => Promise.reject(err));
64+
}
65+
isRefreshing = true;
66+
originalRequest._retry = true;
67+
return api
68+
.post("/auth/refresh/", {
69+
refresh: tokenState.refresh!.encoded,
70+
})
71+
.then((res) => {
72+
if (res.data.access) {
73+
tokenState.setAccess(res.data.access);
74+
}
75+
processQueue(null);
76+
77+
return api(originalRequest);
78+
}, handleError)
79+
.finally(() => {
80+
isRefreshing = false;
81+
});
82+
}
83+
84+
if (isAuthError) {
85+
return handleError(error);
86+
}
87+
88+
return Promise.reject(error);
89+
},
90+
);
491

592
export default api;

client/src/pages/_app.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
55
import type { AppProps } from "next/app";
66
import { DM_Sans } from "next/font/google";
77

8+
import { AuthProvider, useAuth } from "@/context/auth-provider";
9+
810
const dmSans = DM_Sans({
911
subsets: ["latin"],
1012
variable: "--font-dm-sans",
@@ -15,10 +17,12 @@ const queryClient = new QueryClient();
1517
export default function App({ Component, pageProps }: AppProps) {
1618
return (
1719
<QueryClientProvider client={queryClient}>
18-
<div className={dmSans.variable}>
19-
<ReactQueryDevtools initialIsOpen={false} />
20-
<Component {...pageProps} />
21-
</div>
20+
<AuthProvider>
21+
<div className={dmSans.variable}>
22+
<ReactQueryDevtools initialIsOpen={false} />
23+
<Component {...pageProps} />
24+
</div>
25+
</AuthProvider>
2226
</QueryClientProvider>
2327
);
2428
}

0 commit comments

Comments
 (0)