Compare commits

...

4 Commits

Author SHA1 Message Date
opencode bcae59626f feat(security): add CSRF protection for all state-changing API routes
Build & Push Docker Image / build (push) Successful in 2m16s
- Synchronizer token stored in session; GET /auth/csrf to obtain it
- csrfProtection middleware requires X-CSRF-Token on non-safe methods
- customFetch injects the header via setCsrfTokenGetter
- toolrate boot loads token; reload after local login (session regenerate)
- OpenAPI GET /auth/csrf + CsrfToken schema, orval regenerated
2026-08-03 10:30:13 +02:00
opencode f851305d78 fix(tools): OR'ed where clauses now AND'd so search excludes soft-deleted tools; localize delete confirm dialog
Build & Push Docker Image / build (push) Successful in 2m25s
2026-08-03 08:48:08 +02:00
opencode 743b177c89 fix(auth): password min length 6 + show/hide toggle on password inputs
Build & Push Docker Image / build (push) Successful in 2m21s
2026-08-03 08:01:52 +02:00
opencode 68a81ec775 feat(auth): password change (self + admin reset) with rate limiting; dedupe watchlist to user menu
Build & Push Docker Image / build (push) Successful in 2m19s
2026-08-03 07:43:24 +02:00
32 changed files with 1084 additions and 33 deletions
+3 -2
View File
@@ -13,16 +13,17 @@
"@workspace/api-zod": "workspace:*", "@workspace/api-zod": "workspace:*",
"@workspace/db": "workspace:*", "@workspace/db": "workspace:*",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"zod": "catalog:",
"connect-pg-simple": "^10.0.0", "connect-pg-simple": "^10.0.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.6", "cors": "^2.8.6",
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.6.1",
"express-session": "^1.19.0", "express-session": "^1.19.0",
"openid-client": "^5.7.1", "openid-client": "^5.7.1",
"pino": "^9.14.0", "pino": "^9.14.0",
"pino-http": "^10.5.0" "pino-http": "^10.5.0",
"zod": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^3.0.0", "@types/bcryptjs": "^3.0.0",
@@ -0,0 +1,19 @@
import rateLimit from "express-rate-limit";
import type { Request } from "express";
export const loginRateLimit = rateLimit({
windowMs: 60 * 1000,
limit: 10,
standardHeaders: "draft-7",
legacyHeaders: false,
message: { error: "Too many login attempts, please try again later." },
});
export const passwordRateLimit = rateLimit({
windowMs: 60 * 1000,
limit: 5,
standardHeaders: "draft-7",
legacyHeaders: false,
keyGenerator: (req: Request): string => String(req.session.user?.sub ?? req.ip ?? "unknown"),
message: { error: "Too many password attempts, please try again later." },
});
@@ -0,0 +1,25 @@
import crypto from "node:crypto";
import type { Request, Response, NextFunction } from "express";
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS", "TRACE"]);
export function getCsrfToken(req: Request): string {
if (!req.session.csrfToken) {
req.session.csrfToken = crypto.randomBytes(24).toString("hex");
}
return req.session.csrfToken;
}
export function csrfProtection(req: Request, res: Response, next: NextFunction): void {
if (SAFE_METHODS.has(req.method.toUpperCase())) {
next();
return;
}
const provided = req.headers["x-csrf-token"];
const token = getCsrfToken(req);
if (typeof provided === "string" && provided && provided === token) {
next();
return;
}
res.status(403).json({ error: "CSRF token missing or invalid" });
}
+60 -1
View File
@@ -5,7 +5,10 @@ import { eq, and, inArray, isNull } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { db, usersTable, toolsTable, ratingsTable } from "@workspace/db"; import { db, usersTable, toolsTable, ratingsTable } from "@workspace/db";
import { logger } from "../lib/logger"; import { logger } from "../lib/logger";
import { writeAuditLog } from "../lib/audit";
import { loginRateLimit, passwordRateLimit } from "../lib/rate-limit";
import { getEntitlements, requireFeature } from "../middleware/feature"; import { getEntitlements, requireFeature } from "../middleware/feature";
import { getCsrfToken } from "../middleware/csrf";
const router: IRouter = Router(); const router: IRouter = Router();
@@ -107,7 +110,11 @@ router.get("/auth/mode", (_req, res): void => {
res.json({ mode: isOidcConfigured() ? "oidc" : "local" }); res.json({ mode: isOidcConfigured() ? "oidc" : "local" });
}); });
router.post("/auth/login", async (req, res): Promise<void> => { router.get("/auth/csrf", (req, res): void => {
res.json({ token: getCsrfToken(req) });
});
router.post("/auth/login", loginRateLimit, async (req, res): Promise<void> => {
if (isOidcConfigured()) { if (isOidcConfigured()) {
res.status(400).json({ error: "Use OIDC login when Keycloak is configured." }); res.status(400).json({ error: "Use OIDC login when Keycloak is configured." });
return; return;
@@ -279,6 +286,58 @@ router.get("/auth/me", async (req, res): Promise<void> => {
}); });
}); });
router.get("/auth/password-redirect", async (req, res): Promise<void> => {
const client = await getClient();
if (!client) {
res.json({ url: null });
return;
}
const realm = client.issuer.metadata.issuer ?? "";
res.json({ url: `${realm}/account/password` });
});
const ChangePasswordSchema = z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(6),
});
router.post("/auth/me/password", passwordRateLimit, async (req, res): Promise<void> => {
if (!req.session.user) {
res.status(401).json({ error: "Not authenticated" });
return;
}
const dbUser = await resolveDbUser(req.session.user);
if (!dbUser) {
res.status(401).json({ error: "User not found" });
return;
}
if (dbUser.authProvider !== "local") {
res.status(422).json({ error: "oidc" });
return;
}
const parsed = ChangePasswordSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const { currentPassword, newPassword } = parsed.data;
if (currentPassword === newPassword) {
res.status(400).json({ error: "New password must differ from current password" });
return;
}
if (!dbUser.passwordHash || !(await bcrypt.compare(currentPassword, dbUser.passwordHash))) {
res.status(400).json({ error: "Current password is incorrect" });
return;
}
const passwordHash = await bcrypt.hash(newPassword, 12);
await db.update(usersTable).set({ passwordHash }).where(eq(usersTable.id, dbUser.id));
await writeAuditLog(req, "user", dbUser.id, "change_password", {});
await new Promise<void>((resolve, reject) => {
req.session.regenerate((err) => (err ? reject(err) : resolve()));
});
res.sendStatus(204);
});
type SessionUser = NonNullable<import("express-session").SessionData["user"]>; type SessionUser = NonNullable<import("express-session").SessionData["user"]>;
async function resolveDbUser(u: SessionUser) { async function resolveDbUser(u: SessionUser) {
+3
View File
@@ -8,9 +8,12 @@ import usersRouter from "./users";
import auditRouter from "./audit"; import auditRouter from "./audit";
import costsRouter from "./costs"; import costsRouter from "./costs";
import adminRouter from "./admin"; import adminRouter from "./admin";
import { csrfProtection } from "../middleware/csrf";
const router: IRouter = Router(); const router: IRouter = Router();
router.use(csrfProtection);
router.use(authRouter); router.use(authRouter);
router.use(healthRouter); router.use(healthRouter);
router.use(toolsRouter); router.use(toolsRouter);
+8 -6
View File
@@ -1,5 +1,5 @@
import { Router, type IRouter } from "express"; import { Router, type IRouter } from "express";
import { eq, desc, asc, sql, and, not, isNull, inArray } from "drizzle-orm"; import { eq, desc, asc, sql, and, not, isNull, inArray, type SQL } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { db, toolsTable, ratingsTable, toolRelationsTable } from "@workspace/db"; import { db, toolsTable, ratingsTable, toolRelationsTable } from "@workspace/db";
import { import {
@@ -57,21 +57,23 @@ router.get("/tools", async (req, res): Promise<void> => {
const tagList = (tags ?? "").split(",").map((t) => t.trim()).filter(Boolean); const tagList = (tags ?? "").split(",").map((t) => t.trim()).filter(Boolean);
const featureList = (features ?? "").split(",").map((f) => f.trim()).filter(Boolean); const featureList = (features ?? "").split(",").map((f) => f.trim()).filter(Boolean);
let query = db.select().from(toolsTable).where(isNull(toolsTable.deletedAt)).$dynamic(); const conditions: SQL[] = [isNull(toolsTable.deletedAt)];
if (category) { if (category) {
query = query.where(eq(toolsTable.category, category)); conditions.push(eq(toolsTable.category, category));
} }
if (search) { if (search) {
const escaped = search.replace(/[%_\\]/g, (m) => `\\${m}`); const escaped = search.replace(/[%_\\]/g, (m) => `\\${m}`);
query = query.where(sql`${toolsTable.name} ilike ${`%${escaped}%`} escape '\\'`); conditions.push(sql`${toolsTable.name} ilike ${`%${escaped}%`} escape '\\'`);
} }
if (tagList.length > 0) { if (tagList.length > 0) {
query = query.where(sql`${toolsTable.tags} @> ARRAY[${sql.join(tagList.map((t) => sql`${t}`), sql`, `)}]::text[]`); conditions.push(sql`${toolsTable.tags} @> ARRAY[${sql.join(tagList.map((t) => sql`${t}`), sql`, `)}]::text[]`);
} }
if (featureList.length > 0) { if (featureList.length > 0) {
query = query.where(sql`${toolsTable.features} @> ARRAY[${sql.join(featureList.map((f) => sql`${f}`), sql`, `)}]::text[]`); conditions.push(sql`${toolsTable.features} @> ARRAY[${sql.join(featureList.map((f) => sql`${f}`), sql`, `)}]::text[]`);
} }
const query = db.select().from(toolsTable).where(and(...conditions));
const tools = await query.orderBy(desc(toolsTable.createdAt)); const tools = await query.orderBy(desc(toolsTable.createdAt));
const toolIds = tools.map((t) => t.id); const toolIds = tools.map((t) => t.id);
+43 -1
View File
@@ -4,6 +4,7 @@ import bcrypt from "bcryptjs";
import { db, usersTable } from "@workspace/db"; import { db, usersTable } from "@workspace/db";
import { requireAdmin } from "../middleware/auth"; import { requireAdmin } from "../middleware/auth";
import { writeAuditLog } from "../lib/audit"; import { writeAuditLog } from "../lib/audit";
import { passwordRateLimit } from "../lib/rate-limit";
import { z } from "zod"; import { z } from "zod";
const router: IRouter = Router(); const router: IRouter = Router();
@@ -23,6 +24,46 @@ const UserUpdateSchema = z.object({
tier: Tier.optional(), tier: Tier.optional(),
}); });
const SetPasswordSchema = z.object({
password: z.string().min(6),
});
router.patch("/users/:id/password", requireAdmin, passwordRateLimit, async (req, res): Promise<void> => {
const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) {
res.status(400).json({ error: "Invalid user id" });
return;
}
const parsed = SetPasswordSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
const [target] = await db
.select({ id: usersTable.id, authProvider: usersTable.authProvider, username: usersTable.username })
.from(usersTable)
.where(eq(usersTable.id, id))
.limit(1);
if (!target) {
res.status(404).json({ error: "User not found" });
return;
}
if (target.authProvider !== "local") {
res.status(422).json({ error: "oidc" });
return;
}
const passwordHash = await bcrypt.hash(parsed.data.password, 12);
await db.update(usersTable).set({ passwordHash }).where(eq(usersTable.id, id));
await writeAuditLog(req, "user", id, "set_password", { username: target.username });
res.sendStatus(204);
});
router.get("/users", requireAdmin, async (req, res): Promise<void> => { router.get("/users", requireAdmin, async (req, res): Promise<void> => {
const users = await db const users = await db
.select({ .select({
@@ -31,6 +72,7 @@ router.get("/users", requireAdmin, async (req, res): Promise<void> => {
email: usersTable.email, email: usersTable.email,
role: usersTable.role, role: usersTable.role,
tier: usersTable.tier, tier: usersTable.tier,
authProvider: usersTable.authProvider,
createdAt: usersTable.createdAt, createdAt: usersTable.createdAt,
}) })
.from(usersTable) .from(usersTable)
@@ -56,7 +98,7 @@ router.post("/users", requireAdmin, async (req, res): Promise<void> => {
return; return;
} }
const passwordHash = await bcrypt.hash(parsed.data.password, 10); const passwordHash = await bcrypt.hash(parsed.data.password, 12);
const [user] = await db const [user] = await db
.insert(usersTable) .insert(usersTable)
.values({ .values({
+1
View File
@@ -14,5 +14,6 @@ declare module "express-session" {
codeVerifier?: string; codeVerifier?: string;
returnTo?: string; returnTo?: string;
oidcState?: string; oidcState?: string;
csrfToken?: string;
} }
} }
+5
View File
@@ -1,7 +1,9 @@
import { Switch, Route, Router as WouterRouter } from "wouter"; import { Switch, Route, Router as WouterRouter } from "wouter";
import { useEffect } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nextProvider } from "react-i18next"; import { I18nextProvider } from "react-i18next";
import i18n from "@/i18n"; import i18n from "@/i18n";
import { loadCsrfToken } from "@/lib/csrf";
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
@@ -50,6 +52,9 @@ function Router() {
} }
function App() { function App() {
useEffect(() => {
void loadCsrfToken();
}, []);
return ( return (
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
+2 -3
View File
@@ -1,5 +1,5 @@
import { Link, useLocation } from "wouter"; import { Link, useLocation } from "wouter";
import { LayoutDashboard, Wrench, BarChart3, LogIn, LogOut, ShieldCheck, AlertTriangle, Bookmark, Search } from "lucide-react"; import { LayoutDashboard, Wrench, BarChart3, LogIn, LogOut, ShieldCheck, AlertTriangle, Search } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react"; import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react";
@@ -30,7 +30,7 @@ import {
export function Layout({ children }: { children: React.ReactNode }) { export function Layout({ children }: { children: React.ReactNode }) {
const [location] = useLocation(); const [location] = useLocation();
const { t } = useTranslation(); const { t } = useTranslation();
const { isAuthenticated, isLoading, isAdmin, hasFeature, login, logout } = useAuth(); const { isAuthenticated, isLoading, isAdmin, login, logout } = useAuth();
const { data: version } = useGetVersion({ const { data: version } = useGetVersion({
query: { queryKey: getGetVersionQueryKey(), staleTime: Infinity, retry: false }, query: { queryKey: getGetVersionQueryKey(), staleTime: Infinity, retry: false },
}); });
@@ -39,7 +39,6 @@ export function Layout({ children }: { children: React.ReactNode }) {
{ href: "/", label: t("nav.home"), icon: LayoutDashboard }, { href: "/", label: t("nav.home"), icon: LayoutDashboard },
{ href: "/tools", label: t("nav.browseTools"), icon: Wrench }, { href: "/tools", label: t("nav.browseTools"), icon: Wrench },
{ href: "/analytics", label: t("nav.analytics"), icon: BarChart3 }, { href: "/analytics", label: t("nav.analytics"), icon: BarChart3 },
...(hasFeature("watchlist") ? [{ href: "/watchlist", label: t("nav.watchlist"), icon: Bookmark }] : []),
]; ];
const adminLinks = [ const adminLinks = [
@@ -0,0 +1,25 @@
import { useState } from "react";
import { Eye, EyeOff } from "lucide-react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
export function PasswordInput({
className,
...props
}: Omit<React.ComponentProps<"input">, "type">) {
const [visible, setVisible] = useState(false);
return (
<div className={cn("relative", className)}>
<Input type={visible ? "text" : "password"} className="pr-9" {...props} />
<button
type="button"
onClick={() => setVisible((v) => !v)}
className="absolute right-0 top-0 flex h-9 w-9 items-center justify-center text-muted-foreground hover:text-foreground"
aria-label={visible ? "Hide password" : "Show password"}
tabIndex={-1}
>
{visible ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
);
}
+121 -1
View File
@@ -14,7 +14,13 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Bookmark, LogIn, LogOut, Trash2 } from "lucide-react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/password-input";
import { useToast } from "@/hooks/use-toast";
import { useChangeMyPassword, useGetPasswordRedirect, getGetPasswordRedirectQueryKey } from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query";
import { Bookmark, LogIn, LogOut, Trash2, KeyRound } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function initials(name?: string | null): string { function initials(name?: string | null): string {
@@ -32,6 +38,17 @@ export function UserMenu() {
const { t } = useTranslation(); const { t } = useTranslation();
const { user, isLoading, isAuthenticated, isAdmin, tier, hasFeature, login, logout } = useAuth(); const { user, isLoading, isAuthenticated, isAdmin, tier, hasFeature, login, logout } = useAuth();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [pwOpen, setPwOpen] = useState(false);
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const { toast } = useToast();
const queryClient = useQueryClient();
const changePassword = useChangeMyPassword();
const { data: passwordRedirect } = useGetPasswordRedirect({
query: { queryKey: getGetPasswordRedirectQueryKey(), enabled: isAuthenticated && !!user && !user.isLocal },
});
const showWatchlist = hasFeature("watchlist"); const showWatchlist = hasFeature("watchlist");
const showTrash = hasFeature("trash"); const showTrash = hasFeature("trash");
@@ -67,6 +84,7 @@ export function UserMenu() {
} }
return ( return (
<>
<DropdownMenu open={open} onOpenChange={setOpen}> <DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
@@ -122,6 +140,16 @@ export function UserMenu() {
<DropdownMenuSeparator /> <DropdownMenuSeparator />
</> </>
)} )}
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
setPwOpen(true);
}}
data-testid="button-change-password"
>
<KeyRound className="mr-2 h-4 w-4" />
{t("auth.changePassword")}
</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
onClick={logout} onClick={logout}
@@ -132,5 +160,97 @@ export function UserMenu() {
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<Dialog open={pwOpen} onOpenChange={setPwOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("auth.changePassword")}</DialogTitle>
</DialogHeader>
{!user?.isLocal ? (
<div className="space-y-4 py-2">
<p className="text-sm text-muted-foreground">{t("auth.oidcPasswordHint")}</p>
{passwordRedirect?.url && (
<Button asChild className="w-full">
<a href={passwordRedirect.url} target="_blank" rel="noreferrer">
<KeyRound className="w-4 h-4 mr-2" />
{t("auth.manageInIdp")}
</a>
</Button>
)}
</div>
) : (
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>{t("auth.currentPassword")}</Label>
<PasswordInput
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
data-testid="input-current-password"
/>
</div>
<div className="space-y-2">
<Label>{t("auth.newPassword")}</Label>
<PasswordInput
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="min. 6 characters"
data-testid="input-new-password"
/>
</div>
<div className="space-y-2">
<Label>{t("auth.confirmPassword")}</Label>
<PasswordInput
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
data-testid="input-confirm-password"
/>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setPwOpen(false)}>{t("common.cancel")}</Button>
{user?.isLocal && (
<Button
onClick={() => {
if (newPassword !== confirmPassword) {
toast({ title: t("auth.pwMismatch"), variant: "destructive" });
return;
}
if (newPassword.length < 6) {
toast({ title: t("auth.pwTooShort"), variant: "destructive" });
return;
}
changePassword.mutate(
{ data: { currentPassword, newPassword } },
{
onSuccess: () => {
toast({ title: t("auth.pwChanged") });
setPwOpen(false);
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
queryClient.invalidateQueries({ queryKey: getGetPasswordRedirectQueryKey() });
},
onError: (err) => {
const code = (err.data as { error?: string } | null)?.error;
if (code === "oidc") {
toast({ title: t("auth.oidcPasswordHint"), variant: "destructive" });
} else {
toast({ title: t("auth.pwChangeFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
}
},
},
);
}}
disabled={changePassword.isPending || !currentPassword || !newPassword || !confirmPassword}
data-testid="button-submit-change-password"
>
{changePassword.isPending ? "…" : t("auth.save")}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</>
); );
} }
+17 -2
View File
@@ -24,7 +24,17 @@
"invalidCredentials": "Ungültiger Benutzername oder ungültiges Passwort", "invalidCredentials": "Ungültiger Benutzername oder ungültiges Passwort",
"notAuthenticated": "Nicht angemeldet", "notAuthenticated": "Nicht angemeldet",
"loginSubtitle": "Melde dich bei deinem Konto an", "loginSubtitle": "Melde dich bei deinem Konto an",
"loginDescription": "Gib deine Zugangsdaten ein, um fortzufahren" "loginDescription": "Gib deine Zugangsdaten ein, um fortzufahren",
"changePassword": "Passwort ändern",
"currentPassword": "Aktuelles Passwort",
"newPassword": "Neues Passwort",
"confirmPassword": "Passwort bestätigen",
"pwMismatch": "Passwörter stimmen nicht überein",
"pwTooShort": "Passwort muss mindestens 8 Zeichen haben",
"pwChanged": "Passwort geändert",
"pwChangeFailed": "Passwort konnte nicht geändert werden",
"oidcPasswordHint": "Ihr Passwort wird vom Identity-Provider (Keycloak) verwaltet.",
"manageInIdp": "In Keycloak verwalten"
}, },
"common": { "common": {
"cancel": "Abbrechen", "cancel": "Abbrechen",
@@ -117,7 +127,12 @@
"relatedTools": "Ähnliche Tools", "relatedTools": "Ähnliche Tools",
"costs": "Kosten", "costs": "Kosten",
"addCost": "Kosten hinzufügen", "addCost": "Kosten hinzufügen",
"recentRatings": "Letzte Bewertungen" "recentRatings": "Letzte Bewertungen",
"deleteConfirmTitle": "Dieses Tool löschen?",
"deleteToTrash": "Dies verschiebt {{name}} in den Papierkorb. Es kann später wiederhergestellt werden.",
"deletePermanent": "Dies entfernt {{name}} dauerhaft inklusive aller Bewertungen. Das kann nicht rückgängig gemacht werden.",
"deleting": "Löschen…",
"deleteAction": "Löschen"
}, },
"compare": { "compare": {
"title": "Tools vergleichen", "title": "Tools vergleichen",
+17 -2
View File
@@ -24,7 +24,17 @@
"invalidCredentials": "Invalid username or password", "invalidCredentials": "Invalid username or password",
"notAuthenticated": "Not authenticated", "notAuthenticated": "Not authenticated",
"loginSubtitle": "Sign in to your account", "loginSubtitle": "Sign in to your account",
"loginDescription": "Enter your credentials to continue" "loginDescription": "Enter your credentials to continue",
"changePassword": "Change password",
"currentPassword": "Current password",
"newPassword": "New password",
"confirmPassword": "Confirm password",
"pwMismatch": "Passwords do not match",
"pwTooShort": "Password must be at least 8 characters",
"pwChanged": "Password changed",
"pwChangeFailed": "Could not change password",
"oidcPasswordHint": "Your password is managed by the identity provider (Keycloak).",
"manageInIdp": "Manage in Keycloak"
}, },
"common": { "common": {
"cancel": "Cancel", "cancel": "Cancel",
@@ -117,7 +127,12 @@
"relatedTools": "Related Tools", "relatedTools": "Related Tools",
"costs": "Costs", "costs": "Costs",
"addCost": "Add Cost", "addCost": "Add Cost",
"recentRatings": "Recent Ratings" "recentRatings": "Recent Ratings",
"deleteConfirmTitle": "Delete this tool?",
"deleteToTrash": "This will move {{name}} to the trash. It can be restored later.",
"deletePermanent": "This will permanently remove {{name}} and all its ratings. This cannot be undone.",
"deleting": "Deleting…",
"deleteAction": "Delete"
}, },
"compare": { "compare": {
"title": "Compare Tools", "title": "Compare Tools",
+27
View File
@@ -0,0 +1,27 @@
import { setCsrfTokenGetter } from "@workspace/api-client-react";
let token: string | null = null;
setCsrfTokenGetter(() => token);
export function getCsrfToken(): string | null {
return token;
}
export async function loadCsrfToken(): Promise<string | null> {
try {
const res = await fetch(`/api/auth/csrf`, {
credentials: "include",
});
if (!res.ok) {
token = null;
return null;
}
const data = (await res.json()) as { token?: string };
token = data.token ?? null;
return token;
} catch {
token = null;
return null;
}
}
+55 -6
View File
@@ -5,6 +5,7 @@ import {
useCreateUser, useCreateUser,
useUpdateUser, useUpdateUser,
useDeleteUser, useDeleteUser,
useSetUserPassword,
useListAuditLogs, useListAuditLogs,
useGetVersion, useGetVersion,
getListUsersQueryKey, getListUsersQueryKey,
@@ -16,6 +17,7 @@ import { useAuth } from "@/hooks/use-auth";
import { Layout } from "@/components/layout"; import { Layout } from "@/components/layout";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { PasswordInput } from "@/components/password-input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
@@ -35,7 +37,8 @@ export default function Admin() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [editUser, setEditUser] = useState<{ id: number; username: string; role: string; tier: string } | null>(null); const [editUser, setEditUser] = useState<{ id: number; username: string; role: string; tier: string; authProvider?: string } | null>(null);
const [editPassword, setEditPassword] = useState("");
const [deleteConfirm, setDeleteConfirm] = useState<{ id: number; username: string } | null>(null); const [deleteConfirm, setDeleteConfirm] = useState<{ id: number; username: string } | null>(null);
const [newUsername, setNewUsername] = useState(""); const [newUsername, setNewUsername] = useState("");
@@ -58,6 +61,7 @@ export default function Admin() {
const createUser = useCreateUser(); const createUser = useCreateUser();
const updateUser = useUpdateUser(); const updateUser = useUpdateUser();
const deleteUser = useDeleteUser(); const deleteUser = useDeleteUser();
const setUserPassword = useSetUserPassword();
if (!authLoading && !isAdmin) { if (!authLoading && !isAdmin) {
return ( return (
@@ -110,6 +114,26 @@ export default function Admin() {
); );
}; };
const handleSetUserPassword = () => {
if (!editUser || !editPassword) return;
if (editPassword.length < 6) {
toast({ title: "Password too short", description: "Minimum 6 characters.", variant: "destructive" });
return;
}
setUserPassword.mutate(
{ id: editUser.id, data: { password: editPassword } },
{
onSuccess: () => {
toast({ title: "Password updated", description: `Password for ${editUser.username} has been set.` });
setEditPassword("");
},
onError: (err) => {
toast({ title: "Failed to set password", description: (err.data as { error?: string } | null)?.error ?? err.message, variant: "destructive" });
},
},
);
};
const handleDeleteUser = () => { const handleDeleteUser = () => {
if (!deleteConfirm) return; if (!deleteConfirm) return;
deleteUser.mutate( deleteUser.mutate(
@@ -206,7 +230,7 @@ export default function Admin() {
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8" className="h-8 w-8"
onClick={() => setEditUser({ id: u.id, username: u.username, role: u.role, tier: u.tier ?? "free" })} onClick={() => setEditUser({ id: u.id, username: u.username, role: u.role, tier: u.tier ?? "free", authProvider: u.authProvider })}
> >
<Pencil className="w-3.5 h-3.5" /> <Pencil className="w-3.5 h-3.5" />
</Button> </Button>
@@ -344,7 +368,7 @@ export default function Admin() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Password</Label> <Label>Password</Label>
<Input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} placeholder="min. 6 characters" /> <PasswordInput value={newPassword} onChange={(e) => setNewPassword(e.target.value)} placeholder="min. 6 characters" />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Email (Optional)</Label> <Label>Email (Optional)</Label>
@@ -385,7 +409,7 @@ export default function Admin() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog open={!!editUser} onOpenChange={(open) => !open && setEditUser(null)}> <Dialog open={!!editUser} onOpenChange={(open) => { if (!open) { setEditUser(null); setEditPassword(""); } }}>
<DialogContent className="sm:max-w-sm"> <DialogContent className="sm:max-w-sm">
<DialogHeader> <DialogHeader>
<DialogTitle>Edit User {editUser?.username}</DialogTitle> <DialogTitle>Edit User {editUser?.username}</DialogTitle>
@@ -422,15 +446,40 @@ export default function Admin() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{editUser?.authProvider !== "oidc" ? (
<div className="space-y-2 border-t pt-4">
<Label>Set Password</Label>
<PasswordInput
value={editPassword}
onChange={(e) => setEditPassword(e.target.value)}
placeholder="min. 6 characters"
data-testid="input-set-password"
/>
<p className="text-xs text-muted-foreground">Resets the user's password immediately.</p>
</div> </div>
<DialogFooter> ) : (
<Button variant="outline" onClick={() => setEditUser(null)}>Cancel</Button> <div className="space-y-2 border-t pt-4 text-sm text-muted-foreground">
Password is managed by the identity provider (Keycloak). Reset it there.
</div>
)}
</div>
<DialogFooter className="flex-col sm:flex-row sm:justify-end gap-2">
<Button variant="outline" onClick={() => { setEditUser(null); setEditPassword(""); }}>Cancel</Button>
<Button <Button
onClick={handleUpdateUser} onClick={handleUpdateUser}
disabled={updateUser.isPending} disabled={updateUser.isPending}
> >
Save Save
</Button> </Button>
{editUser?.authProvider !== "oidc" && (
<Button
variant="outline"
onClick={handleSetUserPassword}
disabled={setUserPassword.isPending || !editPassword}
>
{setUserPassword.isPending ? "Setting…" : "Set Password"}
</Button>
)}
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
+4 -2
View File
@@ -5,6 +5,8 @@ import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/password-input";
import { loadCsrfToken } from "@/lib/csrf";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { ThemeToggle } from "@/components/theme-toggle"; import { ThemeToggle } from "@/components/theme-toggle";
import { Wrench, AlertCircle } from "lucide-react"; import { Wrench, AlertCircle } from "lucide-react";
@@ -32,6 +34,7 @@ export default function Login() {
{ {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries(); queryClient.invalidateQueries();
void loadCsrfToken();
setLocation(returnTo); setLocation(returnTo);
}, },
onError: (err) => { onError: (err) => {
@@ -76,9 +79,8 @@ export default function Login() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password">{t("auth.password")}</Label> <Label htmlFor="password">{t("auth.password")}</Label>
<Input <PasswordInput
id="password" id="password"
type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
+5 -5
View File
@@ -914,23 +914,23 @@ export default function ToolDetail() {
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}> <AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete this tool?</AlertDialogTitle> <AlertDialogTitle>{t("detail.deleteConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
{hasTrash ? ( {hasTrash ? (
<>This will move <span className="font-medium">{tool?.name}</span> to the trash. It can be restored later.</> <>{t("detail.deleteToTrash", { name: tool?.name })}</>
) : ( ) : (
<>This will permanently remove <span className="font-medium">{tool?.name}</span> and all its ratings. This cannot be undone.</> <>{t("detail.deletePermanent", { name: tool?.name })}</>
)} )}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDelete} onClick={handleDelete}
disabled={deleteTool.isPending} disabled={deleteTool.isPending}
> >
{deleteTool.isPending ? "Deleting" : "Delete"} {deleteTool.isPending ? t("detail.deleting") : t("detail.deleteAction")}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
+18
View File
@@ -17,6 +17,7 @@ const DEFAULT_JSON_ACCEPT = "application/json, application/problem+json";
let _baseUrl: string | null = null; let _baseUrl: string | null = null;
let _authTokenGetter: AuthTokenGetter | null = null; let _authTokenGetter: AuthTokenGetter | null = null;
let _csrfTokenGetter: (() => string | null) | null = null;
/** /**
* Set a base URL that is prepended to every relative request URL * Set a base URL that is prepended to every relative request URL
@@ -44,6 +45,15 @@ export function setAuthTokenGetter(getter: AuthTokenGetter | null): void {
_authTokenGetter = getter; _authTokenGetter = getter;
} }
/**
* Register a getter that supplies a CSRF token. Before every state-changing
* fetch an `X-CSRF-Token` header is attached when the getter returns a value.
* Pass `null` to clear the getter.
*/
export function setCsrfTokenGetter(getter: (() => string | null) | null): void {
_csrfTokenGetter = getter;
}
function isRequest(input: RequestInfo | URL): input is Request { function isRequest(input: RequestInfo | URL): input is Request {
return typeof Request !== "undefined" && input instanceof Request; return typeof Request !== "undefined" && input instanceof Request;
} }
@@ -349,6 +359,14 @@ export async function customFetch<T = unknown>(
headers.set("accept", DEFAULT_JSON_ACCEPT); headers.set("accept", DEFAULT_JSON_ACCEPT);
} }
// Attach CSRF token for state-changing requests, unless one is already set.
if (_csrfTokenGetter && !headers.has("x-csrf-token")) {
const csrf = _csrfTokenGetter();
if (csrf) {
headers.set("x-csrf-token", csrf);
}
}
// Attach bearer token when an auth getter is configured and no // Attach bearer token when an auth getter is configured and no
// Authorization header has been explicitly provided. // Authorization header has been explicitly provided.
if (_authTokenGetter && !headers.has("authorization")) { if (_authTokenGetter && !headers.has("authorization")) {
@@ -30,6 +30,10 @@ export interface AuthMode {
mode: AuthModeMode; mode: AuthModeMode;
} }
export interface CsrfToken {
token: string;
}
export interface LocalLoginInput { export interface LocalLoginInput {
username: string; username: string;
password: string; password: string;
@@ -52,6 +56,14 @@ export const UserTier = {
enterprise: 'enterprise', enterprise: 'enterprise',
} as const; } as const;
export type UserAuthProvider = typeof UserAuthProvider[keyof typeof UserAuthProvider];
export const UserAuthProvider = {
local: 'local',
oidc: 'oidc',
} as const;
export interface User { export interface User {
id: number; id: number;
username: string; username: string;
@@ -59,6 +71,7 @@ export interface User {
email?: string | null; email?: string | null;
role: UserRole; role: UserRole;
tier?: UserTier; tier?: UserTier;
authProvider?: UserAuthProvider;
createdAt: string; createdAt: string;
} }
@@ -111,6 +124,23 @@ export interface UserRoleUpdate {
tier?: UserRoleUpdateTier; tier?: UserRoleUpdateTier;
} }
export interface ChangePasswordInput {
/** @minLength 1 */
currentPassword: string;
/** @minLength 6 */
newPassword: string;
}
export interface SetPasswordInput {
/** @minLength 6 */
password: string;
}
export interface PasswordRedirect {
/** @nullable */
url: string | null;
}
export interface AuditLog { export interface AuditLog {
id: number; id: number;
entityType: string; entityType: string;
+301
View File
@@ -25,6 +25,8 @@ import type {
AuthMode, AuthMode,
AuthUser, AuthUser,
CategoryStats, CategoryStats,
ChangePasswordInput,
CsrfToken,
EmptyTrash200, EmptyTrash200,
ErrorResponse, ErrorResponse,
GetRatingDistributionParams, GetRatingDistributionParams,
@@ -35,11 +37,13 @@ import type {
ListToolsParams, ListToolsParams,
ListTrashedToolsParams, ListTrashedToolsParams,
LocalLoginInput, LocalLoginInput,
PasswordRedirect,
Rating, Rating,
RatingDistribution, RatingDistribution,
RatingHistoryItem, RatingHistoryItem,
RatingInput, RatingInput,
RestoreTools200, RestoreTools200,
SetPasswordInput,
Tool, Tool,
ToolInput, ToolInput,
ToolUpdate, ToolUpdate,
@@ -1903,6 +1907,83 @@ export function useGetAuthMode<TData = Awaited<ReturnType<typeof getAuthMode>>,
export const getGetCsrfTokenUrl = () => {
return `/api/auth/csrf`
}
/**
* @summary Get a CSRF token for state-changing requests
*/
export const getCsrfToken = async ( options?: RequestInit): Promise<CsrfToken> => {
return customFetch<CsrfToken>(getGetCsrfTokenUrl(),
{
...options,
method: 'GET'
}
);}
export const getGetCsrfTokenQueryKey = () => {
return [
`/api/auth/csrf`
] as const;
}
export const getGetCsrfTokenQueryOptions = <TData = Awaited<ReturnType<typeof getCsrfToken>>, TError = ErrorType<unknown>>( options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof getCsrfToken>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetCsrfTokenQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof getCsrfToken>>> = ({ signal }) => getCsrfToken({ signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getCsrfToken>>, TError, TData> & { queryKey: QueryKey }
}
export type GetCsrfTokenQueryResult = NonNullable<Awaited<ReturnType<typeof getCsrfToken>>>
export type GetCsrfTokenQueryError = ErrorType<unknown>
/**
* @summary Get a CSRF token for state-changing requests
*/
export function useGetCsrfToken<TData = Awaited<ReturnType<typeof getCsrfToken>>, TError = ErrorType<unknown>>(
options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof getCsrfToken>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetCsrfTokenQueryOptions(options)
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
return { ...query, queryKey: queryOptions.queryKey };
}
export const getLocalLoginUrl = () => { export const getLocalLoginUrl = () => {
@@ -2051,6 +2132,154 @@ export function useGetMe<TData = Awaited<ReturnType<typeof getMe>>, TError = Err
export const getChangeMyPasswordUrl = () => {
return `/api/auth/me/password`
}
/**
* @summary Change own password (local users only)
*/
export const changeMyPassword = async (changePasswordInput: ChangePasswordInput, options?: RequestInit): Promise<void> => {
return customFetch<void>(getChangeMyPasswordUrl(),
{
...options,
method: 'POST',
headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify(
changePasswordInput,)
}
);}
export const getChangeMyPasswordMutationOptions = <TError = ErrorType<ErrorResponse>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof changeMyPassword>>, TError,{data: BodyType<ChangePasswordInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof changeMyPassword>>, TError,{data: BodyType<ChangePasswordInput>}, TContext> => {
const mutationKey = ['changeMyPassword'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof changeMyPassword>>, {data: BodyType<ChangePasswordInput>}> = (props) => {
const {data} = props ?? {};
return changeMyPassword(data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type ChangeMyPasswordMutationResult = NonNullable<Awaited<ReturnType<typeof changeMyPassword>>>
export type ChangeMyPasswordMutationBody = BodyType<ChangePasswordInput>
export type ChangeMyPasswordMutationError = ErrorType<ErrorResponse>
/**
* @summary Change own password (local users only)
*/
export const useChangeMyPassword = <TError = ErrorType<ErrorResponse>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof changeMyPassword>>, TError,{data: BodyType<ChangePasswordInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationResult<
Awaited<ReturnType<typeof changeMyPassword>>,
TError,
{data: BodyType<ChangePasswordInput>},
TContext
> => {
return useMutation(getChangeMyPasswordMutationOptions(options));
}
export const getGetPasswordRedirectUrl = () => {
return `/api/auth/password-redirect`
}
/**
* @summary Get redirect URL for managing credentials in the identity provider
*/
export const getPasswordRedirect = async ( options?: RequestInit): Promise<PasswordRedirect> => {
return customFetch<PasswordRedirect>(getGetPasswordRedirectUrl(),
{
...options,
method: 'GET'
}
);}
export const getGetPasswordRedirectQueryKey = () => {
return [
`/api/auth/password-redirect`
] as const;
}
export const getGetPasswordRedirectQueryOptions = <TData = Awaited<ReturnType<typeof getPasswordRedirect>>, TError = ErrorType<unknown>>( options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof getPasswordRedirect>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetPasswordRedirectQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof getPasswordRedirect>>> = ({ signal }) => getPasswordRedirect({ signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getPasswordRedirect>>, TError, TData> & { queryKey: QueryKey }
}
export type GetPasswordRedirectQueryResult = NonNullable<Awaited<ReturnType<typeof getPasswordRedirect>>>
export type GetPasswordRedirectQueryError = ErrorType<unknown>
/**
* @summary Get redirect URL for managing credentials in the identity provider
*/
export function useGetPasswordRedirect<TData = Awaited<ReturnType<typeof getPasswordRedirect>>, TError = ErrorType<unknown>>(
options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof getPasswordRedirect>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetPasswordRedirectQueryOptions(options)
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
return { ...query, queryKey: queryOptions.queryKey };
}
export const getGetMePreferencesUrl = () => { export const getGetMePreferencesUrl = () => {
@@ -2566,6 +2795,78 @@ export const useDeleteUser = <TError = ErrorType<unknown>,
return useMutation(getDeleteUserMutationOptions(options)); return useMutation(getDeleteUserMutationOptions(options));
} }
export const getSetUserPasswordUrl = (id: number,) => {
return `/api/users/${id}/password`
}
/**
* @summary Set/reset a user's password (admin only, local users only)
*/
export const setUserPassword = async (id: number,
setPasswordInput: SetPasswordInput, options?: RequestInit): Promise<void> => {
return customFetch<void>(getSetUserPasswordUrl(id),
{
...options,
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify(
setPasswordInput,)
}
);}
export const getSetUserPasswordMutationOptions = <TError = ErrorType<ErrorResponse>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof setUserPassword>>, TError,{id: number;data: BodyType<SetPasswordInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof setUserPassword>>, TError,{id: number;data: BodyType<SetPasswordInput>}, TContext> => {
const mutationKey = ['setUserPassword'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof setUserPassword>>, {id: number;data: BodyType<SetPasswordInput>}> = (props) => {
const {id,data} = props ?? {};
return setUserPassword(id,data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type SetUserPasswordMutationResult = NonNullable<Awaited<ReturnType<typeof setUserPassword>>>
export type SetUserPasswordMutationBody = BodyType<SetPasswordInput>
export type SetUserPasswordMutationError = ErrorType<ErrorResponse>
/**
* @summary Set/reset a user's password (admin only, local users only)
*/
export const useSetUserPassword = <TError = ErrorType<ErrorResponse>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof setUserPassword>>, TError,{id: number;data: BodyType<SetPasswordInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationResult<
Awaited<ReturnType<typeof setUserPassword>>,
TError,
{id: number;data: BodyType<SetPasswordInput>},
TContext
> => {
return useMutation(getSetUserPasswordMutationOptions(options));
}
export const getListAuditLogsUrl = (params?: ListAuditLogsParams,) => { export const getListAuditLogsUrl = (params?: ListAuditLogsParams,) => {
const normalizedParams = new URLSearchParams(); const normalizedParams = new URLSearchParams();
+1 -1
View File
@@ -1,4 +1,4 @@
export * from "./generated/api"; export * from "./generated/api";
export * from "./generated/api.schemas"; export * from "./generated/api.schemas";
export { setBaseUrl, setAuthTokenGetter, customFetch } from "./custom-fetch"; export { setBaseUrl, setAuthTokenGetter, setCsrfTokenGetter, customFetch } from "./custom-fetch";
export type { AuthTokenGetter } from "./custom-fetch"; export type { AuthTokenGetter } from "./custom-fetch";
+147
View File
@@ -570,6 +570,19 @@ paths:
schema: schema:
$ref: "#/components/schemas/AuthMode" $ref: "#/components/schemas/AuthMode"
/auth/csrf:
get:
operationId: getCsrfToken
tags: [auth]
summary: Get a CSRF token for state-changing requests
responses:
"200":
description: CSRF token
content:
application/json:
schema:
$ref: "#/components/schemas/CsrfToken"
/auth/login: /auth/login:
post: post:
operationId: localLogin operationId: localLogin
@@ -614,6 +627,58 @@ paths:
schema: schema:
$ref: "#/components/schemas/ErrorResponse" $ref: "#/components/schemas/ErrorResponse"
/auth/me/password:
post:
operationId: changeMyPassword
tags: [auth]
summary: Change own password (local users only)
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ChangePasswordInput"
responses:
"204":
description: Password changed
"400":
description: Invalid input or wrong current password
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"401":
description: Not authenticated
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"422":
description: OIDC user - password is managed by the identity provider
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"429":
description: Too many attempts
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/auth/password-redirect:
get:
operationId: getPasswordRedirect
tags: [auth]
summary: Get redirect URL for managing credentials in the identity provider
responses:
"200":
description: Redirect URL (null in local mode)
content:
application/json:
schema:
$ref: "#/components/schemas/PasswordRedirect"
/auth/me/preferences: /auth/me/preferences:
get: get:
operationId: getMePreferences operationId: getMePreferences
@@ -783,6 +848,51 @@ paths:
"204": "204":
description: Deleted description: Deleted
/users/{id}/password:
patch:
operationId: setUserPassword
tags: [users]
summary: Set/reset a user's password (admin only, local users only)
parameters:
- name: id
in: path
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/SetPasswordInput"
responses:
"204":
description: Password updated
"400":
description: Validation error
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"404":
description: User not found
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"422":
description: OIDC user - password is managed by the identity provider
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"429":
description: Too many attempts
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/audit-logs: /audit-logs:
get: get:
operationId: listAuditLogs operationId: listAuditLogs
@@ -845,6 +955,13 @@ components:
type: string type: string
enum: [oidc, local] enum: [oidc, local]
CsrfToken:
type: object
required: [token]
properties:
token:
type: string
LocalLoginInput: LocalLoginInput:
type: object type: object
required: [username, password] required: [username, password]
@@ -870,6 +987,10 @@ components:
tier: tier:
type: string type: string
enum: [free, premium, enterprise] enum: [free, premium, enterprise]
authProvider:
type: string
enum: [local, oidc]
default: local
createdAt: createdAt:
type: string type: string
format: date-time format: date-time
@@ -903,6 +1024,32 @@ components:
type: string type: string
enum: [free, premium, enterprise] enum: [free, premium, enterprise]
ChangePasswordInput:
type: object
required: [currentPassword, newPassword]
properties:
currentPassword:
type: string
minLength: 1
newPassword:
type: string
minLength: 6
SetPasswordInput:
type: object
required: [password]
properties:
password:
type: string
minLength: 6
PasswordRedirect:
type: object
required: [url]
properties:
url:
type: ["string", "null"]
AuditLog: AuditLog:
type: object type: object
required: [id, entityType, action, userId, username, createdAt] required: [id, entityType, action, userId, username, createdAt]
+52
View File
@@ -445,6 +445,14 @@ export const GetAuthModeResponse = zod.object({
}) })
/**
* @summary Get a CSRF token for state-changing requests
*/
export const GetCsrfTokenResponse = zod.object({
"token": zod.string()
})
/** /**
* @summary Local username/password login * @summary Local username/password login
*/ */
@@ -480,6 +488,28 @@ export const GetMeResponse = zod.object({
}) })
/**
* @summary Change own password (local users only)
*/
export const changeMyPasswordBodyNewPasswordMin = 6;
export const ChangeMyPasswordBody = zod.object({
"currentPassword": zod.string().min(1),
"newPassword": zod.string().min(changeMyPasswordBodyNewPasswordMin)
})
/**
* @summary Get redirect URL for managing credentials in the identity provider
*/
export const GetPasswordRedirectResponse = zod.object({
"url": zod.string().nullable()
})
/** /**
* @summary Get current user's browse preferences * @summary Get current user's browse preferences
*/ */
@@ -532,12 +562,15 @@ export const GetMeWatchlistResponse = zod.array(GetMeWatchlistResponseItem)
/** /**
* @summary List all local users (admin only) * @summary List all local users (admin only)
*/ */
export const listUsersResponseAuthProviderDefault = `local`;
export const ListUsersResponseItem = zod.object({ export const ListUsersResponseItem = zod.object({
"id": zod.number(), "id": zod.number(),
"username": zod.string(), "username": zod.string(),
"email": zod.string().nullish(), "email": zod.string().nullish(),
"role": zod.enum(['admin', 'user']), "role": zod.enum(['admin', 'user']),
"tier": zod.enum(['free', 'premium', 'enterprise']).optional(), "tier": zod.enum(['free', 'premium', 'enterprise']).optional(),
"authProvider": zod.enum(['local', 'oidc']).default(listUsersResponseAuthProviderDefault),
"createdAt": zod.coerce.date() "createdAt": zod.coerce.date()
}) })
export const ListUsersResponse = zod.array(ListUsersResponseItem) export const ListUsersResponse = zod.array(ListUsersResponseItem)
@@ -573,12 +606,15 @@ export const UpdateUserBody = zod.object({
"tier": zod.enum(['free', 'premium', 'enterprise']).optional() "tier": zod.enum(['free', 'premium', 'enterprise']).optional()
}) })
export const updateUserResponseAuthProviderDefault = `local`;
export const UpdateUserResponse = zod.object({ export const UpdateUserResponse = zod.object({
"id": zod.number(), "id": zod.number(),
"username": zod.string(), "username": zod.string(),
"email": zod.string().nullish(), "email": zod.string().nullish(),
"role": zod.enum(['admin', 'user']), "role": zod.enum(['admin', 'user']),
"tier": zod.enum(['free', 'premium', 'enterprise']).optional(), "tier": zod.enum(['free', 'premium', 'enterprise']).optional(),
"authProvider": zod.enum(['local', 'oidc']).default(updateUserResponseAuthProviderDefault),
"createdAt": zod.coerce.date() "createdAt": zod.coerce.date()
}) })
@@ -591,6 +627,22 @@ export const DeleteUserParams = zod.object({
}) })
/**
* @summary Set/reset a user's password (admin only, local users only)
*/
export const SetUserPasswordParams = zod.object({
"id": zod.coerce.number()
})
export const setUserPasswordBodyPasswordMin = 6;
export const SetUserPasswordBody = zod.object({
"password": zod.string().min(setUserPasswordBodyPasswordMin)
})
/** /**
* @summary List audit log entries (admin only) * @summary List audit log entries (admin only)
*/ */
@@ -0,0 +1,14 @@
/**
* Generated by orval v8.9.1 🍺
* Do not edit manually.
* Api
* ToolRate API Tool listing and rating platform
* OpenAPI spec version: 0.1.0
*/
export interface ChangePasswordInput {
/** @minLength 1 */
currentPassword: string;
/** @minLength 6 */
newPassword: string;
}
@@ -0,0 +1,11 @@
/**
* Generated by orval v8.9.1 🍺
* Do not edit manually.
* Api
* ToolRate API Tool listing and rating platform
* OpenAPI spec version: 0.1.0
*/
export interface CsrfToken {
token: string;
}
+5
View File
@@ -14,6 +14,8 @@ export * from './authUser';
export * from './authUserRole'; export * from './authUserRole';
export * from './authUserTier'; export * from './authUserTier';
export * from './categoryStats'; export * from './categoryStats';
export * from './changePasswordInput';
export * from './csrfToken';
export * from './emptyTrash200'; export * from './emptyTrash200';
export * from './errorResponse'; export * from './errorResponse';
export * from './getRatingDistributionParams'; export * from './getRatingDistributionParams';
@@ -26,12 +28,14 @@ export * from './listToolsParams';
export * from './listToolsSort'; export * from './listToolsSort';
export * from './listTrashedToolsParams'; export * from './listTrashedToolsParams';
export * from './localLoginInput'; export * from './localLoginInput';
export * from './passwordRedirect';
export * from './rating'; export * from './rating';
export * from './ratingDistribution'; export * from './ratingDistribution';
export * from './ratingHistoryItem'; export * from './ratingHistoryItem';
export * from './ratingInput'; export * from './ratingInput';
export * from './restoreTools200'; export * from './restoreTools200';
export * from './scoreBucket'; export * from './scoreBucket';
export * from './setPasswordInput';
export * from './tool'; export * from './tool';
export * from './toolInput'; export * from './toolInput';
export * from './toolUpdate'; export * from './toolUpdate';
@@ -40,6 +44,7 @@ export * from './topToolEntry';
export * from './trashTools200'; export * from './trashTools200';
export * from './trashToolsInput'; export * from './trashToolsInput';
export * from './user'; export * from './user';
export * from './userAuthProvider';
export * from './userCreateInput'; export * from './userCreateInput';
export * from './userCreateInputRole'; export * from './userCreateInputRole';
export * from './userCreateInputTier'; export * from './userCreateInputTier';
@@ -0,0 +1,12 @@
/**
* Generated by orval v8.9.1 🍺
* Do not edit manually.
* Api
* ToolRate API Tool listing and rating platform
* OpenAPI spec version: 0.1.0
*/
export interface PasswordRedirect {
/** @nullable */
url: string | null;
}
@@ -0,0 +1,12 @@
/**
* Generated by orval v8.9.1 🍺
* Do not edit manually.
* Api
* ToolRate API Tool listing and rating platform
* OpenAPI spec version: 0.1.0
*/
export interface SetPasswordInput {
/** @minLength 6 */
password: string;
}
+2
View File
@@ -5,6 +5,7 @@
* ToolRate API Tool listing and rating platform * ToolRate API Tool listing and rating platform
* OpenAPI spec version: 0.1.0 * OpenAPI spec version: 0.1.0
*/ */
import type { UserAuthProvider } from './userAuthProvider';
import type { UserRole } from './userRole'; import type { UserRole } from './userRole';
import type { UserTier } from './userTier'; import type { UserTier } from './userTier';
@@ -15,5 +16,6 @@ export interface User {
email?: string | null; email?: string | null;
role: UserRole; role: UserRole;
tier?: UserTier; tier?: UserTier;
authProvider?: UserAuthProvider;
createdAt: Date; createdAt: Date;
} }
@@ -0,0 +1,15 @@
/**
* Generated by orval v8.9.1 🍺
* Do not edit manually.
* Api
* ToolRate API Tool listing and rating platform
* OpenAPI spec version: 0.1.0
*/
export type UserAuthProvider = typeof UserAuthProvider[keyof typeof UserAuthProvider];
export const UserAuthProvider = {
local: 'local',
oidc: 'oidc',
} as const;
+23
View File
@@ -204,6 +204,9 @@ importers:
express: express:
specifier: ^5.2.1 specifier: ^5.2.1
version: 5.2.1 version: 5.2.1
express-rate-limit:
specifier: ^8.6.1
version: 8.6.1(express@5.2.1)
express-session: express-session:
specifier: ^1.19.0 specifier: ^1.19.0
version: 1.19.0 version: 1.19.0
@@ -2246,6 +2249,12 @@ packages:
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
engines: {node: ^18.19.0 || >=20.5.0} engines: {node: ^18.19.0 || >=20.5.0}
express-rate-limit@8.6.1:
resolution: {integrity: sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==}
engines: {node: '>= 16'}
peerDependencies:
express: '>= 4.11'
express-session@1.19.0: express-session@1.19.0:
resolution: {integrity: sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==} resolution: {integrity: sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -2429,6 +2438,10 @@ packages:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'} engines: {node: '>=12'}
ip-address@10.4.0:
resolution: {integrity: sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==}
engines: {node: '>= 12'}
ipaddr.js@1.9.1: ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
@@ -4935,6 +4948,14 @@ snapshots:
strip-final-newline: 4.0.0 strip-final-newline: 4.0.0
yoctocolors: 2.1.2 yoctocolors: 2.1.2
express-rate-limit@8.6.1(express@5.2.1):
dependencies:
debug: 4.4.3
express: 5.2.1
ip-address: 10.4.0
transitivePeerDependencies:
- supports-color
express-session@1.19.0: express-session@1.19.0:
dependencies: dependencies:
cookie: 0.7.2 cookie: 0.7.2
@@ -5144,6 +5165,8 @@ snapshots:
internmap@2.0.3: {} internmap@2.0.3: {}
ip-address@10.4.0: {}
ipaddr.js@1.9.1: {} ipaddr.js@1.9.1: {}
is-extglob@2.1.1: {} is-extglob@2.1.1: {}