feat(auth): password change (self + admin reset) with rate limiting; dedupe watchlist to user menu
Build & Push Docker Image / build (push) Successful in 2m19s
Build & Push Docker Image / build (push) Successful in 2m19s
This commit is contained in:
@@ -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 = [
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user