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>
</>
);
}