8f2fd89847
Build & Push Docker Image / build (push) Successful in 2m49s
- Add POST /admin/tools/import with format auto-detect, CSV delimiters
(comma/semicolon/tab), per-row validation via CreateToolBody, bulk insert,
audit log entries per imported tool; gated by new 'tool-import' feature
flag (premium/enterprise; admins always pass)
- Add tool-import-dialog UI (format tabs, delimiter select, textarea, file
upload, result/error list) behind hasFeature('tool-import')
- Replace FieldHelp question marks and bare GuideHelp links with a NetBox-style
'Hilfe/Help' outline button (HelpCircle + text) in form headers only
- Sync locales to 482 keys per language (de/en), update handbook docs
(administration import section, index/plaene feature tables), regenerate
API client + zod schemas, add yaml dependency
259 lines
10 KiB
TypeScript
259 lines
10 KiB
TypeScript
import { useState } from "react";
|
|
import { useLocation } from "wouter";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useAuth } from "@/hooks/use-auth";
|
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
|
import { Label } from "@/components/ui/label";
|
|
import { PasswordInput } from "@/components/password-input";
|
|
import { GuideHelp } from "@/components/guide-help";
|
|
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 {
|
|
if (!name) return "?";
|
|
const parts = name.trim().split(/\s+/).filter(Boolean);
|
|
if (parts.length === 0) return "?";
|
|
return parts
|
|
.slice(0, 2)
|
|
.map((p) => p[0]?.toUpperCase() ?? "")
|
|
.join("");
|
|
}
|
|
|
|
export function UserMenu() {
|
|
const [location, navigate] = useLocation();
|
|
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");
|
|
const displayName = user?.name || user?.preferredUsername || "User";
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center gap-3 px-3 py-2 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:gap-0">
|
|
<Skeleton className="h-8 w-8 rounded-full" />
|
|
<Skeleton className="h-4 w-24 group-data-[collapsible=icon]:hidden" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!isAuthenticated || !user) {
|
|
return (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-full gap-2 group-data-[collapsible=icon]:w-auto group-data-[collapsible=icon]:gap-0 group-data-[collapsible=icon]:justify-center"
|
|
onClick={() => login(location)}
|
|
data-testid="button-login"
|
|
>
|
|
<LogIn className="w-4 h-4" />
|
|
<span className="group-data-[collapsible=icon]:hidden">{t("auth.signIn")}</span>
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
function go(path: string) {
|
|
setOpen(false);
|
|
navigate(path);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="w-full justify-start gap-3 px-3 py-2 h-auto group-data-[collapsible=icon]:w-auto group-data-[collapsible=icon]:gap-0 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-1"
|
|
data-testid="button-user-menu"
|
|
>
|
|
<Avatar className="h-8 w-8">
|
|
<AvatarFallback className="bg-primary/15 text-primary text-xs font-semibold">
|
|
{initials(displayName)}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<span className="min-w-0 flex-1 text-left group-data-[collapsible=icon]:hidden">
|
|
<span className="block text-sm font-medium truncate">{displayName}</span>
|
|
<span className="block text-[11px] text-muted-foreground truncate">
|
|
{user.email ?? tier}
|
|
</span>
|
|
</span>
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" className="w-64">
|
|
<DropdownMenuLabel className="flex items-center justify-between gap-2 font-normal">
|
|
<span className="min-w-0">
|
|
<span className="block text-sm font-medium text-foreground truncate">{displayName}</span>
|
|
{user.email && <span className="block text-xs text-muted-foreground truncate">{user.email}</span>}
|
|
</span>
|
|
<Badge
|
|
variant="outline"
|
|
className={cn(
|
|
"shrink-0 text-[10px] uppercase tracking-wider",
|
|
isAdmin && "border-primary text-primary",
|
|
)}
|
|
>
|
|
{isAdmin ? "admin" : tier}
|
|
</Badge>
|
|
</DropdownMenuLabel>
|
|
<DropdownMenuSeparator />
|
|
{(showWatchlist || showTrash) && (
|
|
<>
|
|
{showWatchlist && (
|
|
<DropdownMenuItem onSelect={() => go("/watchlist")}>
|
|
<Bookmark className="mr-2 h-4 w-4" />
|
|
{t("nav.watchlist")}
|
|
</DropdownMenuItem>
|
|
)}
|
|
{showTrash && (
|
|
<DropdownMenuItem onSelect={() => go("/trash")}>
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
|
{t("nav.trash")}
|
|
</DropdownMenuItem>
|
|
)}
|
|
<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}
|
|
data-testid="button-logout"
|
|
>
|
|
<LogOut className="mr-2 h-4 w-4" />
|
|
{t("auth.signOut")}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<Dialog open={pwOpen} onOpenChange={setPwOpen}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader className="flex flex-row items-start justify-between gap-4 space-y-0">
|
|
<DialogTitle>{t("auth.changePassword")}</DialogTitle>
|
|
<GuideHelp guide="konto" label={t("auth.changePassword")} />
|
|
</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={t("auth.passwordMinLength")}
|
|
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>
|
|
</>
|
|
);
|
|
}
|