Add CORS config and auth with JWT

This commit is contained in:
2026-03-02 22:26:50 +02:00
parent 154b9b66ce
commit 2beeadd42c
17 changed files with 307 additions and 23 deletions

View File

@@ -1,5 +1,12 @@
import { buildApiUrl } from "./url";
type AuthTokenResponse = {
accessToken: string;
email: string;
tokenType: string;
expiresIn: number;
};
async function fetchApi<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(buildApiUrl(path), {
...init,
@@ -21,6 +28,14 @@ async function fetchApi<T>(path: string, init?: RequestInit): Promise<T> {
return (await response.json()) as T;
}
function getAccessToken() {
if (typeof window === "undefined") {
return "";
}
return localStorage.getItem("session-token") ?? "";
}
export type LokOpenHours = {
id: number;
name: string;
@@ -74,6 +89,9 @@ export async function createLokOpenHours(
return await fetchApi<LokOpenHours>("/lok/open-hours", {
method: "POST",
headers: {
Authorization: `Bearer ${getAccessToken()}`,
},
body: JSON.stringify(payload),
});
}
@@ -106,6 +124,9 @@ export async function updateLokOpenHours(
return await fetchApi<LokOpenHours>(`/lok/open-hours/${id}`, {
method: "PUT",
headers: {
Authorization: `Bearer ${getAccessToken()}`,
},
body: JSON.stringify(payload),
});
}
@@ -117,6 +138,9 @@ export async function deleteLokOpenHours(id: number): Promise<void> {
await fetchApi<void>(`/lok/open-hours/${id}`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${getAccessToken()}`,
},
});
}
@@ -131,6 +155,22 @@ export async function setActiveLokOpenHours(
`/lok/open-hours/${id}/active`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${getAccessToken()}`,
},
},
);
}
export async function requestAuthToken(
email: string,
password: string,
): Promise<AuthTokenResponse> {
return await fetchApi<AuthTokenResponse>("/auth/token", {
method: "POST",
body: JSON.stringify({
email,
password,
}),
});
}

View File

@@ -3,14 +3,10 @@ import { buildApiUrl } from "./url";
describe("buildApiUrl", () => {
it("joins base url and relative path without duplicate slashes", () => {
expect(buildApiUrl("/lok/open-hours")).toBe(
"http://localhost:5013/lok/open-hours",
);
expect(buildApiUrl("/lok/open-hours")).toBe("/api/lok/open-hours");
});
it("accepts path without leading slash", () => {
expect(buildApiUrl("lok/open-hours/1")).toBe(
"http://localhost:5013/lok/open-hours/1",
);
expect(buildApiUrl("lok/open-hours/1")).toBe("/api/lok/open-hours/1");
});
});

View File

@@ -21,12 +21,16 @@ function AppShell() {
useEffect(() => {
const storedEmail = localStorage.getItem("session-email");
if (!storedEmail) {
const storedToken = localStorage.getItem("session-token");
if (!storedEmail || !storedToken) {
setSession(null);
return;
}
setSession({ email: storedEmail });
setSession({
email: storedEmail,
token: storedToken,
});
}, [setSession]);
useEffect(() => {

View File

@@ -13,6 +13,7 @@ export default function Nav() {
const signOut = () => {
setSession(null);
localStorage.removeItem("session-email");
localStorage.removeItem("session-token");
navigate("/login");
};
@@ -34,8 +35,8 @@ export default function Nav() {
type="button"
onClick={() => setLanguage("fi")}
className={`rounded px-2 py-1 text-xs ${language === "fi"
? "bg-[#E3A977] text-[#4C250E]"
: "text-[#F5D1A9] hover:text-[#FFF7EE]"
? "bg-[#E3A977] text-[#4C250E]"
: "text-[#F5D1A9] hover:text-[#FFF7EE]"
}`}
>
{t("nav.language.fi")}
@@ -44,8 +45,8 @@ export default function Nav() {
type="button"
onClick={() => setLanguage("en")}
className={`rounded px-2 py-1 text-xs ${language === "en"
? "bg-[#E3A977] text-[#4C250E]"
: "text-[#F5D1A9] hover:text-[#FFF7EE]"
? "bg-[#E3A977] text-[#4C250E]"
: "text-[#F5D1A9] hover:text-[#FFF7EE]"
}`}
>
{t("nav.language.en")}

View File

@@ -55,6 +55,7 @@ const translations = {
"notFound.goHome": "Go Home",
"error.title": "Error",
"errors.requiredEmailPassword": "Email and password are required",
"errors.invalidEmailOrPassword": "Invalid email or password",
},
fi: {
"nav.home": "Etusivu",
@@ -106,6 +107,7 @@ const translations = {
"notFound.goHome": "Takaisin etusivulle",
"error.title": "Virhe",
"errors.requiredEmailPassword": "Sähköposti ja salasana vaaditaan",
"errors.invalidEmailOrPassword": "Virheellinen sähköposti tai salasana",
},
} as const;

View File

@@ -1,6 +1,7 @@
import { FormEvent, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useRecoilState } from "recoil";
import { requestAuthToken } from "~/api";
import { useT } from "~/i18n";
import { sessionAtom } from "~/state/appState";
@@ -21,7 +22,7 @@ export default function Login() {
navigate("/");
}, [session, navigate]);
const submit = (event: FormEvent) => {
const submit = async (event: FormEvent) => {
event.preventDefault();
if (!email.trim() || !password.trim()) {
@@ -30,9 +31,22 @@ export default function Login() {
}
const normalizedEmail = email.trim().toLowerCase();
setSession({ email: normalizedEmail });
localStorage.setItem("session-email", normalizedEmail);
navigate("/");
try {
const auth = await requestAuthToken(normalizedEmail, password);
setSession({
email: auth.email,
token: auth.accessToken,
});
localStorage.setItem("session-email", auth.email);
localStorage.setItem("session-token", auth.accessToken);
setError("");
navigate("/");
} catch {
setError(t("errors.invalidEmailOrPassword"));
}
};
return (

View File

@@ -5,6 +5,7 @@ export type Language = "fi" | "en";
export type Session = {
email: string;
token: string;
};
export type Toast = {