User management

This commit is contained in:
2026-03-03 23:15:04 +02:00
parent 667fa25525
commit 2d1923d68d
17 changed files with 1046 additions and 74 deletions

View File

@@ -2,7 +2,9 @@ import { buildApiUrl } from "./url";
type AuthTokenResponse = {
accessToken: string;
email: string;
username: string;
displayName: string;
isAdmin: boolean;
tokenType: string;
expiresIn: number;
};
@@ -57,6 +59,27 @@ export type LokOpenHoursInput = {
kitchenNotice: string;
};
export type User = {
username: string;
added: string;
lastUpdated: string;
isAdmin: boolean;
displayName: string;
};
export type CreateUserInput = {
username: string;
password: string;
isAdmin: boolean;
displayName: string;
};
export type UpdateUserInput = {
password?: string;
isAdmin: boolean;
displayName: string;
};
export async function queryApiVersion(): Promise<string> {
const data = await fetchApi<{ version: string }>("/");
return data.version;
@@ -163,14 +186,54 @@ export async function setActiveLokOpenHours(
}
export async function requestAuthToken(
email: string,
username: string,
password: string,
): Promise<AuthTokenResponse> {
return await fetchApi<AuthTokenResponse>("/auth/token", {
method: "POST",
body: JSON.stringify({
email,
username,
password,
}),
});
}
export async function queryUsers(): Promise<User[]> {
return await fetchApi<User[]>("/users", {
headers: {
Authorization: `Bearer ${getAccessToken()}`,
},
});
}
export async function createUser(input: CreateUserInput): Promise<User> {
return await fetchApi<User>("/users", {
method: "POST",
headers: {
Authorization: `Bearer ${getAccessToken()}`,
},
body: JSON.stringify(input),
});
}
export async function updateUser(
username: string,
input: UpdateUserInput,
): Promise<User> {
return await fetchApi<User>(`/users/${encodeURIComponent(username)}`, {
method: "PUT",
headers: {
Authorization: `Bearer ${getAccessToken()}`,
},
body: JSON.stringify(input),
});
}
export async function deleteUser(username: string): Promise<void> {
await fetchApi<void>(`/users/${encodeURIComponent(username)}`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${getAccessToken()}`,
},
});
}

View File

@@ -1,10 +1,11 @@
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { useEffect } from "react";
import { useSetRecoilState } from "recoil";
import { useRecoilValue, useSetRecoilState } from "recoil";
import Nav from "~/components/Nav";
import Home from "~/routes/index";
import About from "~/routes/about";
import Login from "~/routes/login";
import Management from "~/routes/management";
import NotFound from "~/routes/[...404]";
import Toasts from "~/components/Toasts";
import { initializeLanguage, useLanguage } from "~/i18n";
@@ -13,6 +14,7 @@ import "./app.css";
function AppShell() {
const { language, setLanguage } = useLanguage();
const session = useRecoilValue(sessionAtom);
const setSession = useSetRecoilState(sessionAtom);
useEffect(() => {
@@ -20,15 +22,19 @@ function AppShell() {
}, [setLanguage]);
useEffect(() => {
const storedEmail = localStorage.getItem("session-email");
const storedUsername = localStorage.getItem("session-username");
const storedDisplayName = localStorage.getItem("session-display-name");
const storedIsAdmin = localStorage.getItem("session-is-admin");
const storedToken = localStorage.getItem("session-token");
if (!storedEmail || !storedToken) {
if (!storedUsername || !storedDisplayName || !storedToken || !storedIsAdmin) {
setSession(null);
return;
}
setSession({
email: storedEmail,
username: storedUsername,
displayName: storedDisplayName,
isAdmin: storedIsAdmin === "true",
token: storedToken,
});
}, [setSession]);
@@ -44,6 +50,10 @@ function AppShell() {
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/login" element={<Login />} />
<Route
path="/management"
element={session?.isAdmin ? <Management /> : <Navigate to="/" replace />}
/>
<Route path="*" element={<NotFound />} />
<Route path="/index.html" element={<Navigate to="/" replace />} />
</Routes>

View File

@@ -12,7 +12,9 @@ export default function Nav() {
const signOut = () => {
setSession(null);
localStorage.removeItem("session-email");
localStorage.removeItem("session-username");
localStorage.removeItem("session-display-name");
localStorage.removeItem("session-is-admin");
localStorage.removeItem("session-token");
navigate("/login");
};
@@ -27,9 +29,12 @@ export default function Nav() {
<nav className="fixed top-0 left-0 z-50 flex w-full items-center justify-between bg-[#70421E] px-4 py-3 text-sm font-medium shadow-sm">
<Link to="/" className={linkClass("/")}>{t("nav.home")}</Link>
<Link to="/about" className={linkClass("/about")}>{t("nav.about")}</Link>
{session?.isAdmin ? (
<Link to="/management" className={linkClass("/management")}>{t("nav.management")}</Link>
) : null}
<div className="ml-auto flex items-center gap-2">
<b className="text-[#F5D1A9]">{session?.email ?? ""}</b>
<b className="text-[#F5D1A9]">{session?.displayName ?? ""}</b>
<div className="flex items-center gap-1 rounded-md border border-[#8E4F24] bg-[#8E4F24]/45 p-1">
<button
type="button"

View File

@@ -8,6 +8,7 @@ const translations = {
en: {
"nav.home": "Home",
"nav.about": "About",
"nav.management": "Management",
"nav.login": "Login",
"nav.signOut": "Sign Out",
"nav.language.fi": "FI",
@@ -44,29 +45,53 @@ const translations = {
"about.title": "About",
"about.apiVersion": "API version",
"about.loading": "Loading...",
"login.title": "Sign In",
"login.heading": "Sign in",
"login.email": "Email",
"login.title": "Klapi",
"login.heading": "Klapi",
"login.subheading": "Livonsaaren Tietokonepaja's administration console",
"login.username": "Username",
"login.password": "Password",
"login.submit": "Submit",
"management.title": "User Management",
"management.heading": "User Management",
"management.users": "Users",
"management.create": "Create user",
"management.edit": "Edit user",
"management.update": "Save changes",
"management.cancel": "Cancel",
"management.delete": "Delete",
"management.username": "Username",
"management.password": "Password",
"management.displayName": "Display name",
"management.isAdmin": "Is admin",
"management.added": "Added",
"management.updated": "Last updated",
"management.admin": "Admin",
"management.user": "User",
"management.loading": "Loading users...",
"management.requiredFields":
"Username, display name and password are required",
"management.loadError": "Failed to load users",
"management.saveError": "Failed to save user",
"management.deleteError": "Failed to delete user",
"notFound.title": "Page Not Found",
"notFound.heading": "Not Found",
"notFound.message": "Sorry, the page youre looking for doesn't exist",
"notFound.goHome": "Go Home",
"error.title": "Error",
"errors.requiredEmailPassword": "Email and password are required",
"errors.invalidEmailOrPassword": "Invalid email or password",
"errors.requiredUsernamePassword": "Username and password are required",
"errors.invalidUsernameOrPassword": "Invalid username or password",
},
fi: {
"nav.home": "Etusivu",
"nav.about": "Tietoja",
"nav.management": "Hallinta",
"nav.login": "Kirjaudu",
"nav.signOut": "Kirjaudu ulos",
"nav.language.fi": "FI",
"nav.language.en": "EN",
"meta.description": "React + Recoil -esimerkki",
"home.title": "Etusivu",
"home.heading": "KlAPI",
"home.heading": "Klapi",
"home.signedInAs": "Olet kirjautunut käyttäjänä",
"home.logoAlt": "logo",
"home.openHours.heading": "Aukioloaikaversiot",
@@ -96,18 +121,42 @@ const translations = {
"about.title": "Tietoja",
"about.apiVersion": "API-versio",
"about.loading": "Ladataan...",
"login.title": "Kirjaudu",
"login.heading": "Kirjaudu sisään",
"login.email": "Sähköposti",
"login.title": "Klapi",
"login.heading": "Klapi",
"login.subheading": "Livonsaaren Tietokonepajan hallintakonsoli",
"login.username": "Käyttäjätunnus",
"login.password": "Salasana",
"login.submit": "Lähetä",
"management.title": "Käyttäjähallinta",
"management.heading": "Käyttäjähallinta",
"management.users": "Käyttäjät",
"management.create": "Luo käyttäjä",
"management.edit": "Muokkaa käyttäjää",
"management.update": "Tallenna muutokset",
"management.cancel": "Peruuta",
"management.delete": "Poista",
"management.username": "Käyttäjätunnus",
"management.password": "Salasana",
"management.displayName": "Näyttönimi",
"management.isAdmin": "Ylläpitäjä",
"management.added": "Lisätty",
"management.updated": "Viimeksi päivitetty",
"management.admin": "Ylläpitäjä",
"management.user": "Käyttäjä",
"management.loading": "Ladataan käyttäjiä...",
"management.requiredFields":
"Käyttäjätunnus, näyttönimi ja salasana vaaditaan",
"management.loadError": "Käyttäjien haku epäonnistui",
"management.saveError": "Käyttäjän tallennus epäonnistui",
"management.deleteError": "Käyttäjän poisto epäonnistui",
"notFound.title": "Sivua ei löytynyt",
"notFound.heading": "Ei löytynyt",
"notFound.message": "Valitettavasti etsimääsi sivua ei ole olemassa",
"notFound.goHome": "Takaisin etusivulle",
"error.title": "Virhe",
"errors.requiredEmailPassword": "Sähköposti ja salasana vaaditaan",
"errors.invalidEmailOrPassword": "Virheellinen sähköposti tai salasana",
"errors.requiredUsernamePassword": "Käyttäjätunnus ja salasana vaaditaan",
"errors.invalidUsernameOrPassword":
"Virheellinen käyttäjätunnus tai salasana",
},
} as const;

View File

@@ -9,7 +9,7 @@ export default function Login() {
const t = useT();
const navigate = useNavigate();
const [session, setSession] = useRecoilState(sessionAtom);
const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
@@ -25,45 +25,50 @@ export default function Login() {
const submit = async (event: FormEvent) => {
event.preventDefault();
if (!email.trim() || !password.trim()) {
setError(t("errors.requiredEmailPassword"));
if (!username.trim() || !password.trim()) {
setError(t("errors.requiredUsernamePassword"));
return;
}
const normalizedEmail = email.trim().toLowerCase();
const normalizedUsername = username.trim().toLowerCase();
try {
const auth = await requestAuthToken(normalizedEmail, password);
const auth = await requestAuthToken(normalizedUsername, password);
setSession({
email: auth.email,
username: auth.username,
displayName: auth.displayName,
isAdmin: auth.isAdmin,
token: auth.accessToken,
});
localStorage.setItem("session-email", auth.email);
localStorage.setItem("session-username", auth.username);
localStorage.setItem("session-display-name", auth.displayName);
localStorage.setItem("session-is-admin", auth.isAdmin ? "true" : "false");
localStorage.setItem("session-token", auth.accessToken);
setError("");
navigate("/");
} catch {
setError(t("errors.invalidEmailOrPassword"));
setError(t("errors.invalidUsernameOrPassword"));
}
};
return (
<main>
<h1>{t("login.heading")}</h1>
<h2>{t("login.subheading")}</h2>
<form onSubmit={submit} className="w-full max-w-md space-y-4 px-4">
<label htmlFor="email" className="block w-full text-left">
{t("login.email")}
<label htmlFor="username" className="block w-full text-left">
{t("login.username")}
<input
id="email"
name="email"
type="email"
autoComplete="email"
placeholder="john@doe.com"
id="username"
name="username"
type="text"
autoComplete="username"
placeholder={t("login.username")}
required
value={email}
onChange={(event) => setEmail(event.target.value)}
value={username}
onChange={(event) => setUsername(event.target.value)}
className="mt-1 block w-full rounded-md border border-[#C99763] bg-[#FFF7EE] px-4 py-2 focus:outline-none focus:ring-2 focus:ring-[#A56C38]"
/>
</label>
@@ -75,7 +80,7 @@ export default function Login() {
name="password"
type="password"
autoComplete="current-password"
minLength={6}
placeholder={t("login.password")}
required
value={password}
onChange={(event) => setPassword(event.target.value)}

View File

@@ -0,0 +1,225 @@
import { FormEvent, useEffect, useState } from "react";
import { createUser, deleteUser, queryUsers, updateUser, type User } from "~/api";
import { useT } from "~/i18n";
type Mode = "create" | "edit";
export default function Management() {
const t = useT();
const [users, setUsers] = useState<User[]>([]);
const [mode, setMode] = useState<Mode>("create");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [displayName, setDisplayName] = useState("");
const [isAdmin, setIsAdmin] = useState(false);
const [selectedUsername, setSelectedUsername] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
document.title = t("management.title");
}, [t]);
useEffect(() => {
void loadUsers();
}, []);
const loadUsers = async () => {
try {
setLoading(true);
const items = await queryUsers();
setUsers(items);
setError("");
} catch {
setError(t("management.loadError"));
} finally {
setLoading(false);
}
};
const resetForm = () => {
setMode("create");
setUsername("");
setPassword("");
setDisplayName("");
setIsAdmin(false);
setSelectedUsername("");
};
const onEdit = (user: User) => {
setMode("edit");
setUsername(user.username);
setPassword("");
setDisplayName(user.displayName);
setIsAdmin(user.isAdmin);
setSelectedUsername(user.username);
setError("");
};
const onSubmit = async (event: FormEvent) => {
event.preventDefault();
if (!username.trim() || !displayName.trim() || (mode === "create" && !password.trim())) {
setError(t("management.requiredFields"));
return;
}
try {
if (mode === "create") {
await createUser({
username: username.trim(),
password: password,
displayName: displayName.trim(),
isAdmin,
});
} else {
await updateUser(selectedUsername, {
password: password.trim() ? password : undefined,
displayName: displayName.trim(),
isAdmin,
});
}
resetForm();
await loadUsers();
} catch {
setError(t("management.saveError"));
}
};
const onDelete = async (targetUsername: string) => {
try {
await deleteUser(targetUsername);
if (selectedUsername === targetUsername) {
resetForm();
}
await loadUsers();
} catch {
setError(t("management.deleteError"));
}
};
return (
<main className="w-full px-4">
<h1>{t("management.heading")}</h1>
<form onSubmit={onSubmit} className="mx-auto mt-4 w-full max-w-2xl space-y-3 rounded-md border border-[#C99763] bg-[#FFF7EE] p-4">
<h2 className="text-lg font-semibold text-[#4C250E]">
{mode === "create" ? t("management.create") : t("management.edit")}
</h2>
<label htmlFor="management-username" className="block text-left">
{t("management.username")}
<input
id="management-username"
type="text"
value={username}
disabled={mode === "edit"}
onChange={(event) => setUsername(event.target.value)}
className="mt-1 block w-full rounded-md border border-[#C99763] bg-[#FFF7EE] px-4 py-2 focus:outline-none focus:ring-2 focus:ring-[#A56C38]"
/>
</label>
<label htmlFor="management-display-name" className="block text-left">
{t("management.displayName")}
<input
id="management-display-name"
type="text"
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
className="mt-1 block w-full rounded-md border border-[#C99763] bg-[#FFF7EE] px-4 py-2 focus:outline-none focus:ring-2 focus:ring-[#A56C38]"
/>
</label>
<label htmlFor="management-password" className="block text-left">
{t("management.password")}
<input
id="management-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
className="mt-1 block w-full rounded-md border border-[#C99763] bg-[#FFF7EE] px-4 py-2 focus:outline-none focus:ring-2 focus:ring-[#A56C38]"
/>
</label>
<label htmlFor="management-is-admin" className="flex items-center gap-2 text-left">
<input
id="management-is-admin"
type="checkbox"
checked={isAdmin}
onChange={(event) => setIsAdmin(event.target.checked)}
/>
{t("management.isAdmin")}
</label>
<div className="flex gap-2">
<button
type="submit"
className="rounded-md border border-[#A56C38] bg-[#8E4F24] px-4 py-2 text-[#F5D1A9] transition-colors duration-200 hover:bg-[#A56C38] hover:text-[#FFF7EE]"
>
{mode === "create" ? t("management.create") : t("management.update")}
</button>
{mode === "edit" ? (
<button
type="button"
onClick={resetForm}
className="rounded-md border border-[#A56C38] bg-[#FFF7EE] px-4 py-2 text-[#8E4F24] transition-colors duration-200 hover:bg-[#FCE6CF]"
>
{t("management.cancel")}
</button>
) : null}
</div>
</form>
{error ? <p className="mt-3 text-center text-sm text-[#8E4F24]">{error}</p> : null}
<section className="mx-auto mt-6 w-full max-w-2xl">
<h2 className="text-lg font-semibold text-[#4C250E]">{t("management.users")}</h2>
{loading ? (
<p className="mt-2 text-sm text-[#8E4F24]">{t("management.loading")}</p>
) : (
<ul className="mt-3 space-y-2">
{users.map((user) => (
<li key={user.username} className="rounded-md border border-[#C99763] bg-[#FFF7EE] p-3 text-left">
<div className="flex items-center justify-between gap-2">
<div>
<strong>{user.displayName}</strong>
<div className="text-sm text-[#70421E]">{user.username}</div>
<div className="text-xs text-[#8E4F24]">
{t("management.added")}: {new Date(user.added).toLocaleString()}
</div>
<div className="text-xs text-[#8E4F24]">
{t("management.updated")}: {new Date(user.lastUpdated).toLocaleString()}
</div>
<div className="text-xs text-[#8E4F24]">
{user.isAdmin ? t("management.admin") : t("management.user")}
</div>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => onEdit(user)}
className="rounded-md border border-[#A56C38] bg-[#8E4F24] px-3 py-1 text-[#F5D1A9] transition-colors duration-200 hover:bg-[#A56C38] hover:text-[#FFF7EE]"
>
{t("management.edit")}
</button>
<button
type="button"
onClick={() => void onDelete(user.username)}
className="rounded-md border border-[#A56C38] bg-[#FFF7EE] px-3 py-1 text-[#8E4F24] transition-colors duration-200 hover:bg-[#FCE6CF]"
>
{t("management.delete")}
</button>
</div>
</div>
</li>
))}
</ul>
)}
</section>
</main>
);
}

View File

@@ -4,7 +4,9 @@ import type { LokOpenHours } from "~/api";
export type Language = "fi" | "en";
export type Session = {
email: string;
username: string;
displayName: string;
isAdmin: boolean;
token: string;
};