From 68a81ec7759ccc686872c5f1f280ed65b501bdea Mon Sep 17 00:00:00 2001 From: opencode Date: Mon, 3 Aug 2026 07:43:24 +0200 Subject: [PATCH] feat(auth): password change (self + admin reset) with rate limiting; dedupe watchlist to user menu --- artifacts/api-server/package.json | 5 +- artifacts/api-server/src/lib/rate-limit.ts | 19 ++ artifacts/api-server/src/routes/auth.ts | 56 ++++- artifacts/api-server/src/routes/users.ts | 44 +++- artifacts/toolrate/src/components/layout.tsx | 5 +- .../toolrate/src/components/user-menu.tsx | 127 +++++++++- artifacts/toolrate/src/i18n/locales/de.json | 12 +- artifacts/toolrate/src/i18n/locales/en.json | 12 +- artifacts/toolrate/src/pages/admin.tsx | 59 ++++- .../src/generated/api.schemas.ts | 26 ++ lib/api-client-react/src/generated/api.ts | 223 ++++++++++++++++++ lib/api-spec/openapi.yaml | 127 ++++++++++ lib/api-zod/src/generated/api.ts | 44 ++++ .../generated/types/changePasswordInput.ts | 14 ++ lib/api-zod/src/generated/types/index.ts | 4 + .../src/generated/types/passwordRedirect.ts | 12 + .../src/generated/types/setPasswordInput.ts | 12 + lib/api-zod/src/generated/types/user.ts | 2 + .../src/generated/types/userAuthProvider.ts | 15 ++ pnpm-lock.yaml | 23 ++ 20 files changed, 825 insertions(+), 16 deletions(-) create mode 100644 artifacts/api-server/src/lib/rate-limit.ts create mode 100644 lib/api-zod/src/generated/types/changePasswordInput.ts create mode 100644 lib/api-zod/src/generated/types/passwordRedirect.ts create mode 100644 lib/api-zod/src/generated/types/setPasswordInput.ts create mode 100644 lib/api-zod/src/generated/types/userAuthProvider.ts diff --git a/artifacts/api-server/package.json b/artifacts/api-server/package.json index 781d2fd..b7973d9 100644 --- a/artifacts/api-server/package.json +++ b/artifacts/api-server/package.json @@ -13,16 +13,17 @@ "@workspace/api-zod": "workspace:*", "@workspace/db": "workspace:*", "bcryptjs": "^3.0.3", - "zod": "catalog:", "connect-pg-simple": "^10.0.0", "cookie-parser": "^1.4.7", "cors": "^2.8.6", "drizzle-orm": "catalog:", "express": "^5.2.1", + "express-rate-limit": "^8.6.1", "express-session": "^1.19.0", "openid-client": "^5.7.1", "pino": "^9.14.0", - "pino-http": "^10.5.0" + "pino-http": "^10.5.0", + "zod": "catalog:" }, "devDependencies": { "@types/bcryptjs": "^3.0.0", diff --git a/artifacts/api-server/src/lib/rate-limit.ts b/artifacts/api-server/src/lib/rate-limit.ts new file mode 100644 index 0000000..1aace03 --- /dev/null +++ b/artifacts/api-server/src/lib/rate-limit.ts @@ -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." }, +}); diff --git a/artifacts/api-server/src/routes/auth.ts b/artifacts/api-server/src/routes/auth.ts index c3f8923..6a31037 100644 --- a/artifacts/api-server/src/routes/auth.ts +++ b/artifacts/api-server/src/routes/auth.ts @@ -5,6 +5,8 @@ import { eq, and, inArray, isNull } from "drizzle-orm"; import { z } from "zod"; import { db, usersTable, toolsTable, ratingsTable } from "@workspace/db"; import { logger } from "../lib/logger"; +import { writeAuditLog } from "../lib/audit"; +import { loginRateLimit, passwordRateLimit } from "../lib/rate-limit"; import { getEntitlements, requireFeature } from "../middleware/feature"; const router: IRouter = Router(); @@ -107,7 +109,7 @@ router.get("/auth/mode", (_req, res): void => { res.json({ mode: isOidcConfigured() ? "oidc" : "local" }); }); -router.post("/auth/login", async (req, res): Promise => { +router.post("/auth/login", loginRateLimit, async (req, res): Promise => { if (isOidcConfigured()) { res.status(400).json({ error: "Use OIDC login when Keycloak is configured." }); return; @@ -279,6 +281,58 @@ router.get("/auth/me", async (req, res): Promise => { }); }); +router.get("/auth/password-redirect", async (req, res): Promise => { + 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(8), +}); + +router.post("/auth/me/password", passwordRateLimit, async (req, res): Promise => { + 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((resolve, reject) => { + req.session.regenerate((err) => (err ? reject(err) : resolve())); + }); + res.sendStatus(204); +}); + type SessionUser = NonNullable; async function resolveDbUser(u: SessionUser) { diff --git a/artifacts/api-server/src/routes/users.ts b/artifacts/api-server/src/routes/users.ts index 4fb0f12..bc42437 100644 --- a/artifacts/api-server/src/routes/users.ts +++ b/artifacts/api-server/src/routes/users.ts @@ -4,6 +4,7 @@ import bcrypt from "bcryptjs"; import { db, usersTable } from "@workspace/db"; import { requireAdmin } from "../middleware/auth"; import { writeAuditLog } from "../lib/audit"; +import { passwordRateLimit } from "../lib/rate-limit"; import { z } from "zod"; const router: IRouter = Router(); @@ -23,6 +24,46 @@ const UserUpdateSchema = z.object({ tier: Tier.optional(), }); +const SetPasswordSchema = z.object({ + password: z.string().min(8), +}); + +router.patch("/users/:id/password", requireAdmin, passwordRateLimit, async (req, res): Promise => { + 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 => { const users = await db .select({ @@ -31,6 +72,7 @@ router.get("/users", requireAdmin, async (req, res): Promise => { email: usersTable.email, role: usersTable.role, tier: usersTable.tier, + authProvider: usersTable.authProvider, createdAt: usersTable.createdAt, }) .from(usersTable) @@ -56,7 +98,7 @@ router.post("/users", requireAdmin, async (req, res): Promise => { return; } - const passwordHash = await bcrypt.hash(parsed.data.password, 10); + const passwordHash = await bcrypt.hash(parsed.data.password, 12); const [user] = await db .insert(usersTable) .values({ diff --git a/artifacts/toolrate/src/components/layout.tsx b/artifacts/toolrate/src/components/layout.tsx index 5d5eba4..7863b8b 100644 --- a/artifacts/toolrate/src/components/layout.tsx +++ b/artifacts/toolrate/src/components/layout.tsx @@ -1,5 +1,5 @@ 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 { useAuth } from "@/hooks/use-auth"; import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react"; @@ -30,7 +30,7 @@ import { export function Layout({ children }: { children: React.ReactNode }) { const [location] = useLocation(); const { t } = useTranslation(); - const { isAuthenticated, isLoading, isAdmin, hasFeature, login, logout } = useAuth(); + const { isAuthenticated, isLoading, isAdmin, login, logout } = useAuth(); const { data: version } = useGetVersion({ 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: "/tools", label: t("nav.browseTools"), icon: Wrench }, { href: "/analytics", label: t("nav.analytics"), icon: BarChart3 }, - ...(hasFeature("watchlist") ? [{ href: "/watchlist", label: t("nav.watchlist"), icon: Bookmark }] : []), ]; const adminLinks = [ diff --git a/artifacts/toolrate/src/components/user-menu.tsx b/artifacts/toolrate/src/components/user-menu.tsx index cc25396..e21dc97 100644 --- a/artifacts/toolrate/src/components/user-menu.tsx +++ b/artifacts/toolrate/src/components/user-menu.tsx @@ -14,7 +14,13 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Bookmark, LogIn, LogOut, Trash2 } from "lucide-react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +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"; function initials(name?: string | null): string { @@ -32,6 +38,17 @@ export function UserMenu() { const { t } = useTranslation(); const { user, isLoading, isAuthenticated, isAdmin, tier, hasFeature, login, logout } = useAuth(); 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 showTrash = hasFeature("trash"); @@ -67,7 +84,8 @@ export function UserMenu() { } return ( - + <> + + )} + + ) : ( +
+
+ + setCurrentPassword(e.target.value)} + data-testid="input-current-password" + /> +
+
+ + setNewPassword(e.target.value)} + placeholder="min. 8 characters" + data-testid="input-new-password" + /> +
+
+ + setConfirmPassword(e.target.value)} + data-testid="input-confirm-password" + /> +
+
+ )} + + + {user?.isLocal && ( + + )} + + + + ); } diff --git a/artifacts/toolrate/src/i18n/locales/de.json b/artifacts/toolrate/src/i18n/locales/de.json index 7365796..5c0927b 100644 --- a/artifacts/toolrate/src/i18n/locales/de.json +++ b/artifacts/toolrate/src/i18n/locales/de.json @@ -24,7 +24,17 @@ "invalidCredentials": "Ungültiger Benutzername oder ungültiges Passwort", "notAuthenticated": "Nicht angemeldet", "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": { "cancel": "Abbrechen", diff --git a/artifacts/toolrate/src/i18n/locales/en.json b/artifacts/toolrate/src/i18n/locales/en.json index 3373eef..68192da 100644 --- a/artifacts/toolrate/src/i18n/locales/en.json +++ b/artifacts/toolrate/src/i18n/locales/en.json @@ -24,7 +24,17 @@ "invalidCredentials": "Invalid username or password", "notAuthenticated": "Not authenticated", "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": { "cancel": "Cancel", diff --git a/artifacts/toolrate/src/pages/admin.tsx b/artifacts/toolrate/src/pages/admin.tsx index 0cd3f10..10c8063 100644 --- a/artifacts/toolrate/src/pages/admin.tsx +++ b/artifacts/toolrate/src/pages/admin.tsx @@ -5,6 +5,7 @@ import { useCreateUser, useUpdateUser, useDeleteUser, + useSetUserPassword, useListAuditLogs, useGetVersion, getListUsersQueryKey, @@ -35,7 +36,8 @@ export default function Admin() { const queryClient = useQueryClient(); 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 [newUsername, setNewUsername] = useState(""); @@ -58,6 +60,7 @@ export default function Admin() { const createUser = useCreateUser(); const updateUser = useUpdateUser(); const deleteUser = useDeleteUser(); + const setUserPassword = useSetUserPassword(); if (!authLoading && !isAdmin) { return ( @@ -110,6 +113,26 @@ export default function Admin() { ); }; + const handleSetUserPassword = () => { + if (!editUser || !editPassword) return; + if (editPassword.length < 8) { + toast({ title: "Password too short", description: "Minimum 8 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 = () => { if (!deleteConfirm) return; deleteUser.mutate( @@ -206,7 +229,7 @@ export default function Admin() { variant="ghost" size="icon" 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 })} > @@ -385,7 +408,7 @@ export default function Admin() { - !open && setEditUser(null)}> + { if (!open) { setEditUser(null); setEditPassword(""); } }}> Edit User — {editUser?.username} @@ -422,15 +445,41 @@ export default function Admin() { + {editUser?.authProvider !== "oidc" ? ( +
+ + setEditPassword(e.target.value)} + placeholder="min. 8 characters" + data-testid="input-set-password" + /> +

Resets the user's password immediately.

+
+ ) : ( +
+ Password is managed by the identity provider (Keycloak). Reset it there. +
+ )} - - + + + {editUser?.authProvider !== "oidc" && ( + + )}
diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index ab4aa68..f065a7b 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -52,6 +52,14 @@ export const UserTier = { enterprise: 'enterprise', } as const; +export type UserAuthProvider = typeof UserAuthProvider[keyof typeof UserAuthProvider]; + + +export const UserAuthProvider = { + local: 'local', + oidc: 'oidc', +} as const; + export interface User { id: number; username: string; @@ -59,6 +67,7 @@ export interface User { email?: string | null; role: UserRole; tier?: UserTier; + authProvider?: UserAuthProvider; createdAt: string; } @@ -111,6 +120,23 @@ export interface UserRoleUpdate { tier?: UserRoleUpdateTier; } +export interface ChangePasswordInput { + /** @minLength 1 */ + currentPassword: string; + /** @minLength 8 */ + newPassword: string; +} + +export interface SetPasswordInput { + /** @minLength 8 */ + password: string; +} + +export interface PasswordRedirect { + /** @nullable */ + url: string | null; +} + export interface AuditLog { id: number; entityType: string; diff --git a/lib/api-client-react/src/generated/api.ts b/lib/api-client-react/src/generated/api.ts index a8ef830..81939f4 100644 --- a/lib/api-client-react/src/generated/api.ts +++ b/lib/api-client-react/src/generated/api.ts @@ -25,6 +25,7 @@ import type { AuthMode, AuthUser, CategoryStats, + ChangePasswordInput, EmptyTrash200, ErrorResponse, GetRatingDistributionParams, @@ -35,11 +36,13 @@ import type { ListToolsParams, ListTrashedToolsParams, LocalLoginInput, + PasswordRedirect, Rating, RatingDistribution, RatingHistoryItem, RatingInput, RestoreTools200, + SetPasswordInput, Tool, ToolInput, ToolUpdate, @@ -2051,6 +2054,154 @@ export function useGetMe>, 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 => { + + return customFetch(getChangeMyPasswordUrl(), + { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify( + changePasswordInput,) + } +);} + + + + +export const getChangeMyPasswordMutationOptions = , + TContext = unknown>(options?: { mutation?:UseMutationOptions>, TError,{data: BodyType}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{data: BodyType}, 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>, {data: BodyType}> = (props) => { + const {data} = props ?? {}; + + return changeMyPassword(data,requestOptions) + } + + + + + + + return { mutationFn, ...mutationOptions }} + + export type ChangeMyPasswordMutationResult = NonNullable>> + export type ChangeMyPasswordMutationBody = BodyType + export type ChangeMyPasswordMutationError = ErrorType + + /** + * @summary Change own password (local users only) + */ +export const useChangeMyPassword = , + TContext = unknown>(options?: { mutation?:UseMutationOptions>, TError,{data: BodyType}, TContext>, request?: SecondParameter} + ): UseMutationResult< + Awaited>, + TError, + {data: BodyType}, + 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 => { + + return customFetch(getGetPasswordRedirectUrl(), + { + ...options, + method: 'GET' + + + } +);} + + + + + +export const getGetPasswordRedirectQueryKey = () => { + return [ + `/api/auth/password-redirect` + ] as const; + } + + +export const getGetPasswordRedirectQueryOptions = >, TError = ErrorType>( options?: { query?:UseQueryOptions>, TError, TData>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetPasswordRedirectQueryKey(); + + + + const queryFn: QueryFunction>> = ({ signal }) => getPasswordRedirect({ signal, ...requestOptions }); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: QueryKey } +} + +export type GetPasswordRedirectQueryResult = NonNullable>> +export type GetPasswordRedirectQueryError = ErrorType + + +/** + * @summary Get redirect URL for managing credentials in the identity provider + */ + +export function useGetPasswordRedirect>, TError = ErrorType>( + options?: { query?:UseQueryOptions>, TError, TData>, request?: SecondParameter} + + ): UseQueryResult & { queryKey: QueryKey } { + + const queryOptions = getGetPasswordRedirectQueryOptions(options) + + const query = useQuery(queryOptions) as UseQueryResult & { queryKey: QueryKey }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + + + + + + + export const getGetMePreferencesUrl = () => { @@ -2566,6 +2717,78 @@ export const useDeleteUser = , 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 => { + + return customFetch(getSetUserPasswordUrl(id), + { + ...options, + method: 'PATCH', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify( + setPasswordInput,) + } +);} + + + + +export const getSetUserPasswordMutationOptions = , + TContext = unknown>(options?: { mutation?:UseMutationOptions>, TError,{id: number;data: BodyType}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{id: number;data: BodyType}, 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>, {id: number;data: BodyType}> = (props) => { + const {id,data} = props ?? {}; + + return setUserPassword(id,data,requestOptions) + } + + + + + + + return { mutationFn, ...mutationOptions }} + + export type SetUserPasswordMutationResult = NonNullable>> + export type SetUserPasswordMutationBody = BodyType + export type SetUserPasswordMutationError = ErrorType + + /** + * @summary Set/reset a user's password (admin only, local users only) + */ +export const useSetUserPassword = , + TContext = unknown>(options?: { mutation?:UseMutationOptions>, TError,{id: number;data: BodyType}, TContext>, request?: SecondParameter} + ): UseMutationResult< + Awaited>, + TError, + {id: number;data: BodyType}, + TContext + > => { + return useMutation(getSetUserPasswordMutationOptions(options)); + } + export const getListAuditLogsUrl = (params?: ListAuditLogsParams,) => { const normalizedParams = new URLSearchParams(); diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 84e4910..a7786b0 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -614,6 +614,58 @@ paths: schema: $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: get: operationId: getMePreferences @@ -783,6 +835,51 @@ paths: "204": 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: get: operationId: listAuditLogs @@ -870,6 +967,10 @@ components: tier: type: string enum: [free, premium, enterprise] + authProvider: + type: string + enum: [local, oidc] + default: local createdAt: type: string format: date-time @@ -903,6 +1004,32 @@ components: type: string enum: [free, premium, enterprise] + ChangePasswordInput: + type: object + required: [currentPassword, newPassword] + properties: + currentPassword: + type: string + minLength: 1 + newPassword: + type: string + minLength: 8 + + SetPasswordInput: + type: object + required: [password] + properties: + password: + type: string + minLength: 8 + + PasswordRedirect: + type: object + required: [url] + properties: + url: + type: ["string", "null"] + AuditLog: type: object required: [id, entityType, action, userId, username, createdAt] diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index e2b22da..615ed1d 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -480,6 +480,28 @@ export const GetMeResponse = zod.object({ }) +/** + * @summary Change own password (local users only) + */ + +export const changeMyPasswordBodyNewPasswordMin = 8; + + + +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 */ @@ -532,12 +554,15 @@ export const GetMeWatchlistResponse = zod.array(GetMeWatchlistResponseItem) /** * @summary List all local users (admin only) */ +export const listUsersResponseAuthProviderDefault = `local`; + export const ListUsersResponseItem = zod.object({ "id": zod.number(), "username": zod.string(), "email": zod.string().nullish(), "role": zod.enum(['admin', 'user']), "tier": zod.enum(['free', 'premium', 'enterprise']).optional(), + "authProvider": zod.enum(['local', 'oidc']).default(listUsersResponseAuthProviderDefault), "createdAt": zod.coerce.date() }) export const ListUsersResponse = zod.array(ListUsersResponseItem) @@ -573,12 +598,15 @@ export const UpdateUserBody = zod.object({ "tier": zod.enum(['free', 'premium', 'enterprise']).optional() }) +export const updateUserResponseAuthProviderDefault = `local`; + export const UpdateUserResponse = zod.object({ "id": zod.number(), "username": zod.string(), "email": zod.string().nullish(), "role": zod.enum(['admin', 'user']), "tier": zod.enum(['free', 'premium', 'enterprise']).optional(), + "authProvider": zod.enum(['local', 'oidc']).default(updateUserResponseAuthProviderDefault), "createdAt": zod.coerce.date() }) @@ -591,6 +619,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 = 8; + + + +export const SetUserPasswordBody = zod.object({ + "password": zod.string().min(setUserPasswordBodyPasswordMin) +}) + + /** * @summary List audit log entries (admin only) */ diff --git a/lib/api-zod/src/generated/types/changePasswordInput.ts b/lib/api-zod/src/generated/types/changePasswordInput.ts new file mode 100644 index 0000000..a64a691 --- /dev/null +++ b/lib/api-zod/src/generated/types/changePasswordInput.ts @@ -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 8 */ + newPassword: string; +} diff --git a/lib/api-zod/src/generated/types/index.ts b/lib/api-zod/src/generated/types/index.ts index da1bfaf..22023aa 100644 --- a/lib/api-zod/src/generated/types/index.ts +++ b/lib/api-zod/src/generated/types/index.ts @@ -14,6 +14,7 @@ export * from './authUser'; export * from './authUserRole'; export * from './authUserTier'; export * from './categoryStats'; +export * from './changePasswordInput'; export * from './emptyTrash200'; export * from './errorResponse'; export * from './getRatingDistributionParams'; @@ -26,12 +27,14 @@ export * from './listToolsParams'; export * from './listToolsSort'; export * from './listTrashedToolsParams'; export * from './localLoginInput'; +export * from './passwordRedirect'; export * from './rating'; export * from './ratingDistribution'; export * from './ratingHistoryItem'; export * from './ratingInput'; export * from './restoreTools200'; export * from './scoreBucket'; +export * from './setPasswordInput'; export * from './tool'; export * from './toolInput'; export * from './toolUpdate'; @@ -40,6 +43,7 @@ export * from './topToolEntry'; export * from './trashTools200'; export * from './trashToolsInput'; export * from './user'; +export * from './userAuthProvider'; export * from './userCreateInput'; export * from './userCreateInputRole'; export * from './userCreateInputTier'; diff --git a/lib/api-zod/src/generated/types/passwordRedirect.ts b/lib/api-zod/src/generated/types/passwordRedirect.ts new file mode 100644 index 0000000..5ed4546 --- /dev/null +++ b/lib/api-zod/src/generated/types/passwordRedirect.ts @@ -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; +} diff --git a/lib/api-zod/src/generated/types/setPasswordInput.ts b/lib/api-zod/src/generated/types/setPasswordInput.ts new file mode 100644 index 0000000..95e8e73 --- /dev/null +++ b/lib/api-zod/src/generated/types/setPasswordInput.ts @@ -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 8 */ + password: string; +} diff --git a/lib/api-zod/src/generated/types/user.ts b/lib/api-zod/src/generated/types/user.ts index 5620836..227052a 100644 --- a/lib/api-zod/src/generated/types/user.ts +++ b/lib/api-zod/src/generated/types/user.ts @@ -5,6 +5,7 @@ * ToolRate API — Tool listing and rating platform * OpenAPI spec version: 0.1.0 */ +import type { UserAuthProvider } from './userAuthProvider'; import type { UserRole } from './userRole'; import type { UserTier } from './userTier'; @@ -15,5 +16,6 @@ export interface User { email?: string | null; role: UserRole; tier?: UserTier; + authProvider?: UserAuthProvider; createdAt: Date; } diff --git a/lib/api-zod/src/generated/types/userAuthProvider.ts b/lib/api-zod/src/generated/types/userAuthProvider.ts new file mode 100644 index 0000000..0a83075 --- /dev/null +++ b/lib/api-zod/src/generated/types/userAuthProvider.ts @@ -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; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98396f2..9f256b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -204,6 +204,9 @@ importers: express: specifier: ^5.2.1 version: 5.2.1 + express-rate-limit: + specifier: ^8.6.1 + version: 8.6.1(express@5.2.1) express-session: specifier: ^1.19.0 version: 1.19.0 @@ -2246,6 +2249,12 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} 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: resolution: {integrity: sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==} engines: {node: '>= 0.8.0'} @@ -2429,6 +2438,10 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + ip-address@10.4.0: + resolution: {integrity: sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -4935,6 +4948,14 @@ snapshots: strip-final-newline: 4.0.0 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: dependencies: cookie: 0.7.2 @@ -5144,6 +5165,8 @@ snapshots: internmap@2.0.3: {} + ip-address@10.4.0: {} + ipaddr.js@1.9.1: {} is-extglob@2.1.1: {}