User management
This commit is contained in:
@@ -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()}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 you’re 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;
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
225
ui/src/routes/management.tsx
Normal file
225
ui/src/routes/management.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user