feat(auth): password change (self + admin reset) with rate limiting; dedupe watchlist to user menu
Build & Push Docker Image / build (push) Successful in 2m19s

This commit is contained in:
opencode
2026-08-03 07:43:24 +02:00
parent 63f0bdbab6
commit 68a81ec775
20 changed files with 825 additions and 16 deletions
+2 -3
View File
@@ -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 = [
+125 -2
View File
@@ -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 (
<DropdownMenu open={open} onOpenChange={setOpen}>
<>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
@@ -122,6 +140,16 @@ export function UserMenu() {
<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
className="text-destructive focus:text-destructive"
onClick={logout}
@@ -132,5 +160,100 @@ export function UserMenu() {
</DropdownMenuItem>
</DropdownMenuContent>
</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>
<Input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
data-testid="input-current-password"
/>
</div>
<div className="space-y-2">
<Label>{t("auth.newPassword")}</Label>
<Input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="min. 8 characters"
data-testid="input-new-password"
/>
</div>
<div className="space-y-2">
<Label>{t("auth.confirmPassword")}</Label>
<Input
type="password"
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 < 8) {
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>
</>
);
}
+11 -1
View File
@@ -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",
+11 -1
View File
@@ -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",
+54 -5
View File
@@ -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 })}
>
<Pencil className="w-3.5 h-3.5" />
</Button>
@@ -385,7 +408,7 @@ export default function Admin() {
</DialogContent>
</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">
<DialogHeader>
<DialogTitle>Edit User {editUser?.username}</DialogTitle>
@@ -422,15 +445,41 @@ export default function Admin() {
</SelectContent>
</Select>
</div>
{editUser?.authProvider !== "oidc" ? (
<div className="space-y-2 border-t pt-4">
<Label>Set Password</Label>
<Input
type="password"
value={editPassword}
onChange={(e) => setEditPassword(e.target.value)}
placeholder="min. 8 characters"
data-testid="input-set-password"
/>
<p className="text-xs text-muted-foreground">Resets the user's password immediately.</p>
</div>
) : (
<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>
<Button variant="outline" onClick={() => setEditUser(null)}>Cancel</Button>
<DialogFooter className="flex-col sm:flex-row sm:justify-end gap-2">
<Button variant="outline" onClick={() => { setEditUser(null); setEditPassword(""); }}>Cancel</Button>
<Button
onClick={handleUpdateUser}
disabled={updateUser.isPending}
>
Save
</Button>
{editUser?.authProvider !== "oidc" && (
<Button
variant="outline"
onClick={handleSetUserPassword}
disabled={setUserPassword.isPending || !editPassword}
>
{setUserPassword.isPending ? "Setting…" : "Set Password"}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>