- {contents}
+
+
+
+ {contents}
+
);
async function populateWeatherData() {
const response = await fetch("/api/weatherforecast", {
- headers: {
- Authorization: `Bearer ${jwtToken}`,
- },
+ credentials: "include",
});
try {
const data: Forecast[] = await response.json();
diff --git a/ClientApp/src/Context/LoggedInUserContext.ts b/ClientApp/src/Context/LoggedInUserContext.ts
index 9714c33..8d00632 100644
--- a/ClientApp/src/Context/LoggedInUserContext.ts
+++ b/ClientApp/src/Context/LoggedInUserContext.ts
@@ -1,13 +1,15 @@
import { createContext, useContext } from "react";
import type { AppUser } from "@/types/AppUser";
-import type { AuthSession } from "@/types/AuthSession.ts";
+
+export type AuthStatus = "loading" | "authenticated" | "anonymous";
export type LoggedInUserContextValue = {
- jwtToken: string | null;
+ status: AuthStatus;
user: AppUser | null;
- isAuthenticated: boolean;
- login: (session: AuthSession) => void;
- logout: () => void;
+ clearUser: () => void;
+ refreshUser: () => Promise
;
+ login: (user: AppUser) => void;
+ logout: () => Promise;
};
export const LoggedInUserContext = createContext<
diff --git a/ClientApp/src/Context/UserContext.tsx b/ClientApp/src/Context/UserContext.tsx
index 1cd23d6..b599f99 100644
--- a/ClientApp/src/Context/UserContext.tsx
+++ b/ClientApp/src/Context/UserContext.tsx
@@ -1,10 +1,18 @@
-import { useState, type ReactNode } from "react";
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+ type ReactNode,
+} from "react";
import type { AppUser } from "@/types/AppUser";
-import type { AuthSession } from "@/types/AuthSession.ts";
-import { LoggedInUserContext } from "@/Context/LoggedInUserContext";
+import {
+ type AuthStatus,
+ LoggedInUserContext,
+} from "@/Context/LoggedInUserContext";
import zod, { type ZodType } from "zod";
+import { toast } from "@heroui/react";
-const JWT_STORAGE_KEY = "jwtToken";
const USER_STORAGE_KEY = "loggedInUser";
const AppUserSchema = zod.object({
@@ -29,35 +37,105 @@ function getStoredUser(): AppUser | null {
}
export function LoggedInUserProvider({ children }: { children: ReactNode }) {
- const [jwtToken, setJwtToken] = useState(() =>
- localStorage.getItem(JWT_STORAGE_KEY),
- );
const [user, setUser] = useState(() => getStoredUser());
+ const [status, setStatus] = useState(() =>
+ getStoredUser() ? "authenticated" : "loading",
+ );
- const login = (session: AuthSession) => {
- setJwtToken(session.accessToken);
- setUser(session.user);
- localStorage.setItem(JWT_STORAGE_KEY, session.accessToken);
- localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(session.user));
- };
+ const login = useCallback((nextUser: AppUser) => {
+ setUser(nextUser);
+ setStatus("authenticated");
+ localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(nextUser));
+ }, []);
- const logout = () => {
- setJwtToken(null);
- setUser(null);
- localStorage.removeItem(JWT_STORAGE_KEY);
+ const clearUser = useCallback((): void => {
localStorage.removeItem(USER_STORAGE_KEY);
- };
+ setUser(null);
+ setStatus("anonymous");
+ }, []);
+
+ const refreshUser = useCallback(
+ async (signal?: AbortSignal): Promise => {
+ const response = await fetch("/api/me", {
+ credentials: "include",
+ signal,
+ });
+
+ if (!response.ok) {
+ clearUser();
+ return null;
+ }
+
+ const data = await response.json();
+ const parsedData = AppUserSchema.safeParse(data);
+ if (!parsedData.success) {
+ clearUser();
+ return null;
+ }
+
+ login(parsedData.data);
+ return parsedData.data;
+ },
+ [clearUser, login],
+ );
+
+ const logout = useCallback(async () => {
+ clearUser();
+ try {
+ await fetch("/api/logout", { method: "POST", credentials: "include" });
+ } catch {
+ toast("Something went wrong!");
+ }
+ }, [clearUser]);
+
+ useEffect(() => {
+ const controller = new AbortController();
+ const user = getStoredUser();
+
+ if (user === null) {
+ setStatus("anonymous");
+ return;
+ }
+
+ const bootstrapAuth = async () => {
+ try {
+ await refreshUser(controller.signal);
+ } catch (error) {
+ if (error instanceof DOMException && error.name === "AbortError") {
+ return;
+ }
+
+ clearUser();
+ } finally {
+ if (!controller.signal.aborted) {
+ setStatus((currentStatus) =>
+ currentStatus === "loading" ? "anonymous" : currentStatus,
+ );
+ }
+ }
+ };
+
+ void bootstrapAuth();
+
+ return () => {
+ controller.abort();
+ };
+ }, [clearUser, refreshUser]);
+
+ const contextValue = useMemo(
+ () => ({
+ status,
+ user,
+ clearUser,
+ refreshUser,
+ login,
+ logout,
+ }),
+ [status, user, clearUser, refreshUser, login, logout],
+ );
return (
-
+
{children}
);
diff --git a/ClientApp/src/LoginForm.tsx b/ClientApp/src/LoginForm.tsx
index 6e9b50d..80a7122 100644
--- a/ClientApp/src/LoginForm.tsx
+++ b/ClientApp/src/LoginForm.tsx
@@ -4,121 +4,93 @@ import {
Button,
TextField,
Label,
+ toast,
FieldError,
+ Surface,
} from "@heroui/react";
import { useState } from "react";
-import zod from "zod";
-import useLoggedInUserContext from "@/Context/LoggedInUserContext";
-import type { AppUser } from "@/types/AppUser";
+import useLoggedInUserContext from "@/Context/LoggedInUserContext.ts";
export interface LoginFormData {
- username: string;
+ email: string;
password: string;
}
-const jwtResponseSchema = zod.object({
- accessToken: zod.string(),
- expiresAtUtc: zod.coerce.date(),
- user: zod.object({
- id: zod.string(),
- username: zod.string(),
- role: zod.string(),
- createdAtUtc: zod.string(),
- }),
-});
-
export default function LoginForm() {
- const { login } = useLoggedInUserContext();
-
+ const { refreshUser } = useLoggedInUserContext();
const [formData, setFormData] = useState({
- username: "",
+ email: "",
password: "",
});
- const [errors, setErrors] = useState>({});
-
- const [generalError, setGeneralError] = useState(null);
-
- const validatePassword = (value: string | null | undefined) => {
- if ((value?.match(/[^a-z]/gi) || []).length < 1) {
- setErrors((prev) => ({
- ...prev,
- password: "Password needs at least 1 symbol",
- }));
- }
+ const [error, setError] = useState(undefined);
- return null;
- };
const onSubmit = async (
ev: React.FormEvent,
): Promise => {
ev.preventDefault();
- setGeneralError(null);
-
- validatePassword(formData.password);
try {
- const response = await fetch("/api/auth/token", {
+ const response = await fetch("/api/login?useCookies=true", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
+ credentials: "include",
body: JSON.stringify(formData),
});
if (!response.ok) {
- setGeneralError("Invalid username or password.");
+ setError("Invalid username or password");
return;
}
- const data = await response.json();
- const parsedData = jwtResponseSchema.safeParse(data);
- if (!parsedData.success) {
- throw new Error("Invalid response from server");
+ const user = await refreshUser();
+ if (!user) {
+ toast("Failed to load your account. Please try again later");
}
-
- login({
- accessToken: parsedData.data.accessToken,
- user: parsedData.data.user as AppUser,
- });
- } catch (error) {
- console.log(error);
- setGeneralError("An unexpected error occurred. Please try again.");
+ } catch {
+ toast("An unexpected error occurred. Please try again.");
}
};
return (
-
- Login
-
- {generalError && {generalError}
}
-
+
);
}
diff --git a/ClientApp/src/types/AuthSession.ts b/ClientApp/src/types/AuthSession.ts
deleted file mode 100644
index dcc2858..0000000
--- a/ClientApp/src/types/AuthSession.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import type { AppUser } from "@/types/AppUser.ts";
-
-export type AuthSession = {
- accessToken: string;
- user: AppUser;
-};