mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-02 04:51:29 +02:00
allow to edit profiles
This commit is contained in:
10
.github/workflows/frontend-ui.yml
vendored
10
.github/workflows/frontend-ui.yml
vendored
@@ -81,14 +81,8 @@ jobs:
|
||||
- name: Generate Wails bindings
|
||||
run: pnpm run bindings
|
||||
|
||||
- name: ESLint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Type-check
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Prettier check
|
||||
run: pnpm format:check
|
||||
- name: Lint, typecheck, format
|
||||
run: pnpm check
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,css,json,md}\"",
|
||||
"lint": "eslint \"src/**/*.{ts,tsx}\"",
|
||||
"lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix",
|
||||
"lint:report": "eslint \"src/**/*.{ts,tsx}\" --format json --output-file eslint-report.json || true"
|
||||
"check": "pnpm lint && pnpm typecheck && pnpm format:check",
|
||||
"check:fix": "pnpm lint:fix && pnpm format && pnpm typecheck"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
|
||||
@@ -15,8 +15,8 @@ const menuItemVariants = cva("", {
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"text-nb-gray-200 focus:bg-nb-gray-900 focus:text-nb-gray-50 data-[state=open]:bg-nb-gray-900 data-[state=open]:text-nb-gray-50",
|
||||
danger: "text-red-500 focus:bg-red-900/20 focus:text-red-500",
|
||||
"text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50 focus-visible:bg-nb-gray-900 focus-visible:text-nb-gray-50 data-[state=open]:bg-nb-gray-900 data-[state=open]:text-nb-gray-50",
|
||||
danger: "text-red-500 hover:bg-red-900/20 hover:text-red-500 focus-visible:bg-red-900/20 focus-visible:text-red-500",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default" },
|
||||
@@ -135,7 +135,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none",
|
||||
"text-nb-gray-200 transition-colors focus:bg-nb-gray-900 focus:text-nb-gray-50",
|
||||
"text-nb-gray-200 transition-colors hover:bg-nb-gray-900 hover:text-nb-gray-50 focus-visible:bg-nb-gray-900 focus-visible:text-nb-gray-50",
|
||||
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
@@ -160,7 +160,7 @@ const DropdownMenuRadioItem = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none",
|
||||
"text-nb-gray-200 transition-colors focus:bg-nb-gray-900 focus:text-nb-gray-50",
|
||||
"text-nb-gray-200 transition-colors hover:bg-nb-gray-900 hover:text-nb-gray-50 focus-visible:bg-nb-gray-900 focus-visible:text-nb-gray-50",
|
||||
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -61,7 +61,7 @@ export const ConfirmModal = ({
|
||||
<DialogActions className={"flex-row justify-end gap-2.5"}>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
size={"xs2"}
|
||||
size={"sm"}
|
||||
disabled={busy}
|
||||
onClick={onCancel}
|
||||
>
|
||||
@@ -70,7 +70,7 @@ export const ConfirmModal = ({
|
||||
<Button
|
||||
autoFocus
|
||||
variant={danger ? "danger" : "primary"}
|
||||
size={"xs2"}
|
||||
size={"sm"}
|
||||
disabled={busy}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
|
||||
@@ -28,6 +28,9 @@ export const SearchInput = forwardRef<HTMLInputElement, Props>(function SearchIn
|
||||
type={"search"}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel ?? props.placeholder ?? t("common.search")}
|
||||
autoCorrect={"off"}
|
||||
autoCapitalize={"off"}
|
||||
spellCheck={false}
|
||||
{...props}
|
||||
className={cn(
|
||||
"w-full bg-transparent text-sm text-nb-gray-200 placeholder:text-nb-gray-400",
|
||||
|
||||
@@ -30,6 +30,7 @@ type ProfileContextValue = {
|
||||
switchProfile: (id: string) => Promise<void>;
|
||||
addProfile: (name: string) => Promise<string>;
|
||||
removeProfile: (id: string) => Promise<void>;
|
||||
renameProfile: (id: string, newName: string) => Promise<void>;
|
||||
logoutProfile: (id: string) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -130,6 +131,16 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
||||
[username, refresh],
|
||||
);
|
||||
|
||||
// The daemon resolves the handle (exact ID, ID prefix, or unique display
|
||||
// name) — passing the ID is precise and avoids collisions on rename.
|
||||
const renameProfile = useCallback(
|
||||
async (id: string, newName: string) => {
|
||||
await ProfilesSvc.Rename({ handle: id, newName, username });
|
||||
await refresh();
|
||||
},
|
||||
[username, refresh],
|
||||
);
|
||||
|
||||
const logoutProfile = useCallback(
|
||||
async (id: string) => {
|
||||
await Connection.Logout({ profileName: id, username });
|
||||
@@ -149,6 +160,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
||||
switchProfile,
|
||||
addProfile,
|
||||
removeProfile,
|
||||
renameProfile,
|
||||
logoutProfile,
|
||||
}),
|
||||
[
|
||||
@@ -161,6 +173,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
||||
switchProfile,
|
||||
addProfile,
|
||||
removeProfile,
|
||||
renameProfile,
|
||||
logoutProfile,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -16,53 +16,102 @@ import {
|
||||
} from "@/hooks/useManagementUrl";
|
||||
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
|
||||
|
||||
export type ProfileFormInitial = {
|
||||
name: string;
|
||||
managementUrl: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreate: (name: string, managementUrl: string) => void;
|
||||
onSubmit: (name: string, managementUrl: string) => void | Promise<void>;
|
||||
initial?: ProfileFormInitial;
|
||||
};
|
||||
|
||||
// The daemon (profilemanager.sanitizeDisplayName) accepts free-form display
|
||||
// names — spaces, emoji, punctuation, any valid UTF-8 — stripping only control
|
||||
// characters and capping the length. Since #6367 the on-disk ID is separate
|
||||
// from the display name, so the raw input no longer needs to be coerced into a
|
||||
// filename-safe slug client-side; just trim and let the daemon canonicalize.
|
||||
const MAX_PROFILE_NAME_LEN = 128;
|
||||
|
||||
export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) => {
|
||||
export const ProfileCreationModal = ({ open, onOpenChange, onSubmit, initial }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { mdm } = useRestrictions();
|
||||
const managedManagementUrl = mdm.managementURL;
|
||||
const nameId = useId();
|
||||
const urlId = useId();
|
||||
const [name, setName] = useState("");
|
||||
const isEdit = !!initial;
|
||||
const initialModeFromUrl = (u: string): ManagementMode =>
|
||||
u && u !== CLOUD_MANAGEMENT_URL ? ManagementMode.SelfHosted : ManagementMode.Cloud;
|
||||
const initialSelfHostedUrl = (u: string): string => (u && u !== CLOUD_MANAGEMENT_URL ? u : "");
|
||||
|
||||
const [name, setName] = useState(initial?.name ?? "");
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
const nameRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [mode, setMode] = useState<ManagementMode>(ManagementMode.Cloud);
|
||||
const [url, setUrl] = useState("");
|
||||
const [mode, setMode] = useState<ManagementMode>(
|
||||
initial ? initialModeFromUrl(initial.managementUrl) : ManagementMode.Cloud,
|
||||
);
|
||||
const [url, setUrl] = useState(initial ? initialSelfHostedUrl(initial.managementUrl) : "");
|
||||
const [urlError, setUrlError] = useState<string | null>(null);
|
||||
const [unreachable, setUnreachable] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const urlRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setName("");
|
||||
if (open) {
|
||||
setName(initial?.name ?? "");
|
||||
setMode(initial ? initialModeFromUrl(initial.managementUrl) : ManagementMode.Cloud);
|
||||
setUrl(initial ? initialSelfHostedUrl(initial.managementUrl) : "");
|
||||
setNameError(null);
|
||||
setMode(ManagementMode.Cloud);
|
||||
setUrl("");
|
||||
setUrlError(null);
|
||||
setUnreachable(false);
|
||||
setChecking(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, initial?.name, initial?.managementUrl]);
|
||||
|
||||
const initialModeRef = useRef<ManagementMode>(ManagementMode.Cloud);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
initialModeRef.current = mode;
|
||||
const id = window.setTimeout(() => {
|
||||
nameRef.current?.focus();
|
||||
nameRef.current?.select();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
// When the user toggles to Self-hosted inside the dialog (not on initial
|
||||
// open), move focus to the URL input so they can start typing immediately.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (mode === initialModeRef.current) return;
|
||||
if (mode !== ManagementMode.SelfHosted) return;
|
||||
urlRef.current?.focus();
|
||||
}, [open, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
setUrlError(null);
|
||||
setUnreachable(false);
|
||||
}, [url, mode]);
|
||||
|
||||
const resolveTargetUrl = (): { url: string; needsReachCheck: boolean } | null => {
|
||||
if (managedManagementUrl) {
|
||||
return { url: managedManagementUrl, needsReachCheck: false };
|
||||
}
|
||||
if (mode === ManagementMode.Cloud) {
|
||||
return { url: CLOUD_MANAGEMENT_URL, needsReachCheck: false };
|
||||
}
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed || !isValidManagementUrl(trimmed)) {
|
||||
setUrlError(t("settings.general.management.urlError"));
|
||||
urlRef.current?.focus();
|
||||
return null;
|
||||
}
|
||||
const target = normalizeManagementUrl(trimmed);
|
||||
|
||||
const unchanged = initial && target === initial.managementUrl;
|
||||
return { url: target, needsReachCheck: !unchanged };
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (checking) return;
|
||||
@@ -74,35 +123,20 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
|
||||
return;
|
||||
}
|
||||
|
||||
if (managedManagementUrl) {
|
||||
onCreate(sanitized, managedManagementUrl);
|
||||
onOpenChange(false);
|
||||
return;
|
||||
const target = resolveTargetUrl();
|
||||
if (!target) return;
|
||||
|
||||
if (target.needsReachCheck) {
|
||||
setChecking(true);
|
||||
const reachable = await checkManagementUrlReachable(target.url);
|
||||
setChecking(false);
|
||||
if (!reachable && !unreachable) {
|
||||
setUnreachable(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === ManagementMode.Cloud) {
|
||||
onCreate(sanitized, CLOUD_MANAGEMENT_URL);
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed || !isValidManagementUrl(trimmed)) {
|
||||
setUrlError(t("settings.general.management.urlError"));
|
||||
urlRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const target = normalizeManagementUrl(trimmed);
|
||||
setChecking(true);
|
||||
const reachable = await checkManagementUrlReachable(target);
|
||||
setChecking(false);
|
||||
if (!reachable && !unreachable) {
|
||||
setUnreachable(true);
|
||||
return;
|
||||
}
|
||||
|
||||
onCreate(sanitized, target);
|
||||
await onSubmit(sanitized, target.url);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
@@ -128,9 +162,15 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
|
||||
maxWidthClass={"max-w-md"}
|
||||
showClose={false}
|
||||
className={"py-7"}
|
||||
srTitle={t("profile.dialog.title")}
|
||||
srTitle={isEdit ? t("profile.edit.title") : t("profile.dialog.title")}
|
||||
srDescription={t("profile.dialog.description")}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onOpenAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
// Focus + select-all so editing an existing name is one
|
||||
// keystroke away from overwriting it.
|
||||
nameRef.current?.focus();
|
||||
nameRef.current?.select();
|
||||
}}
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className={"flex flex-col gap-6 px-7"}>
|
||||
@@ -178,7 +218,6 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
|
||||
<Input
|
||||
id={urlId}
|
||||
ref={urlRef}
|
||||
autoFocus
|
||||
aria-label={t("settings.general.management.label")}
|
||||
placeholder={t(
|
||||
"settings.general.management.urlPlaceholder",
|
||||
@@ -201,7 +240,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
|
||||
<Button
|
||||
type={"button"}
|
||||
variant={"secondary"}
|
||||
size={"xs2"}
|
||||
size={"sm"}
|
||||
disabled={checking}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
@@ -210,10 +249,10 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
|
||||
<Button
|
||||
type={"submit"}
|
||||
variant={"primary"}
|
||||
size={"xs2"}
|
||||
size={"sm"}
|
||||
loading={checking}
|
||||
>
|
||||
{t("profile.dialog.submit")}
|
||||
{isEdit ? t("profile.edit.submit") : t("profile.dialog.submit")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
import { type KeyboardEvent, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CircleMinus, LogIn, PlusCircle, Trash2, UserCircle } from "lucide-react";
|
||||
import {
|
||||
CircleMinus,
|
||||
LogIn,
|
||||
MoreVertical,
|
||||
PencilLine,
|
||||
PlusCircle,
|
||||
Trash2,
|
||||
UserCircle,
|
||||
} from "lucide-react";
|
||||
import type { Profile } from "@bindings/services/models.js";
|
||||
import { Badge } from "@/components/Badge";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import HelpText from "@/components/typography/HelpText";
|
||||
import { ProfileCreationModal } from "@/modules/profiles/ProfileCreationModal";
|
||||
import {
|
||||
ProfileCreationModal,
|
||||
type ProfileFormInitial,
|
||||
} from "@/modules/profiles/ProfileCreationModal";
|
||||
import { pickProfileIcon } from "@/modules/profiles/ProfileAvatar";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/DropdownMenu";
|
||||
import i18next from "@/lib/i18n";
|
||||
import { useProfile } from "@/contexts/ProfileContext";
|
||||
import { useConfirm } from "@/contexts/DialogContext";
|
||||
@@ -31,11 +48,16 @@ export function ProfilesTab() {
|
||||
switchProfile,
|
||||
addProfile,
|
||||
removeProfile,
|
||||
renameProfile,
|
||||
logoutProfile,
|
||||
} = useProfile();
|
||||
|
||||
const confirm = useConfirm();
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<{
|
||||
profile: Profile;
|
||||
initial: ProfileFormInitial;
|
||||
} | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// Order is held stable so switching only flips the badge, never reorders rows
|
||||
@@ -118,6 +140,37 @@ export function ProfilesTab() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = async (id: string, name: string) => {
|
||||
await guarded(i18next.t("profile.error.editTitle"), async () => {
|
||||
const config = await SettingsSvc.GetConfig({ profileName: id, username });
|
||||
const profile = profiles.find((p) => p.id === id);
|
||||
if (!profile) return;
|
||||
setEditTarget({
|
||||
profile,
|
||||
initial: { name, managementUrl: config.managementUrl },
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async (name: string, managementUrl: string) => {
|
||||
if (!editTarget) return;
|
||||
const { profile, initial } = editTarget;
|
||||
await guarded(i18next.t("profile.error.editTitle"), async () => {
|
||||
if (name !== initial.name) {
|
||||
await renameProfile(profile.id, name);
|
||||
}
|
||||
if (managementUrl !== initial.managementUrl) {
|
||||
await SettingsSvc.SetConfig(
|
||||
new SetConfigParams({
|
||||
profileName: profile.id,
|
||||
username,
|
||||
managementUrl,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionGroup title={t("settings.profiles.section.profiles")}>
|
||||
@@ -132,6 +185,7 @@ export function ProfilesTab() {
|
||||
ordered={ordered}
|
||||
activeProfileId={activeProfileId}
|
||||
onSwitch={handleSwitch}
|
||||
onEdit={handleEdit}
|
||||
onDeregister={handleDeregister}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
@@ -168,7 +222,16 @@ export function ProfilesTab() {
|
||||
<ProfileCreationModal
|
||||
open={newOpen}
|
||||
onOpenChange={setNewOpen}
|
||||
onCreate={handleCreate}
|
||||
onSubmit={handleCreate}
|
||||
/>
|
||||
|
||||
<ProfileCreationModal
|
||||
open={editTarget !== null}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setEditTarget(null);
|
||||
}}
|
||||
initial={editTarget?.initial}
|
||||
onSubmit={handleSave}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -178,6 +241,7 @@ type ProfilesTableProps = {
|
||||
ordered: Profile[];
|
||||
activeProfileId: string | undefined;
|
||||
onSwitch: (id: string, name: string) => void;
|
||||
onEdit: (id: string, name: string) => void;
|
||||
onDeregister: (id: string, name: string) => void;
|
||||
onDelete: (id: string, name: string) => void;
|
||||
};
|
||||
@@ -186,6 +250,7 @@ const ProfilesTable = ({
|
||||
ordered,
|
||||
activeProfileId,
|
||||
onSwitch,
|
||||
onEdit,
|
||||
onDeregister,
|
||||
onDelete,
|
||||
}: ProfilesTableProps) => {
|
||||
@@ -291,6 +356,7 @@ const ProfilesTable = ({
|
||||
onKeyDown={(e) => handleRowKeyDown(e, index)}
|
||||
onFocus={() => setFocusedIndex(index)}
|
||||
onSwitch={() => onSwitch(profile.id, profile.name)}
|
||||
onEdit={() => onEdit(profile.id, profile.name)}
|
||||
onDeregister={() => onDeregister(profile.id, profile.name)}
|
||||
onDelete={() => onDelete(profile.id, profile.name)}
|
||||
/>
|
||||
@@ -310,6 +376,7 @@ type ProfileRowProps = {
|
||||
onKeyDown: (e: KeyboardEvent<HTMLTableRowElement>) => void;
|
||||
onFocus: () => void;
|
||||
onSwitch: () => void;
|
||||
onEdit: () => void;
|
||||
onDeregister: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
@@ -324,6 +391,7 @@ const ProfileRow = ({
|
||||
onKeyDown,
|
||||
onFocus,
|
||||
onSwitch,
|
||||
onEdit,
|
||||
onDeregister,
|
||||
onDelete,
|
||||
}: ProfileRowProps) => {
|
||||
@@ -380,6 +448,7 @@ const ProfileRow = ({
|
||||
isActive={isActive}
|
||||
rowFocused={isFocused}
|
||||
onSwitch={onSwitch}
|
||||
onEdit={onEdit}
|
||||
onDeregister={onDeregister}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
@@ -417,6 +486,7 @@ type RowActionsProps = {
|
||||
isActive: boolean;
|
||||
rowFocused: boolean;
|
||||
onSwitch: () => void;
|
||||
onEdit: () => void;
|
||||
onDeregister: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
@@ -428,32 +498,19 @@ const RowActions = ({
|
||||
isActive,
|
||||
rowFocused,
|
||||
onSwitch,
|
||||
onEdit,
|
||||
onDeregister,
|
||||
onDelete,
|
||||
}: RowActionsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const deleteDisabled = isDefault || isActive;
|
||||
const nonDefaultDeleteLabel = isActive
|
||||
? t("profile.delete.disabledActive")
|
||||
: t("profile.selector.delete");
|
||||
const deleteLabel = isDefault ? t("profile.delete.disabledDefault") : nonDefaultDeleteLabel;
|
||||
const deleteDisabledReason = isDefault
|
||||
? t("profile.delete.disabledDefault")
|
||||
: isActive
|
||||
? t("profile.delete.disabledActive")
|
||||
: null;
|
||||
return (
|
||||
<div className={"inline-flex items-center gap-1"}>
|
||||
<ActionIconButton
|
||||
label={t("profile.selector.deregister")}
|
||||
icon={CircleMinus}
|
||||
onClick={onDeregister}
|
||||
hidden={!canDeregister}
|
||||
tabbable={rowFocused}
|
||||
/>
|
||||
<ActionIconButton
|
||||
label={deleteLabel}
|
||||
icon={Trash2}
|
||||
onClick={onDelete}
|
||||
variant={"danger"}
|
||||
disabled={deleteDisabled}
|
||||
tabbable={rowFocused}
|
||||
/>
|
||||
<ActionIconButton
|
||||
label={t("profile.selector.switchTo")}
|
||||
icon={LogIn}
|
||||
@@ -461,10 +518,114 @@ const RowActions = ({
|
||||
hidden={!canSwitch}
|
||||
tabbable={rowFocused}
|
||||
/>
|
||||
<RowMoreMenu
|
||||
canDeregister={canDeregister}
|
||||
deleteDisabled={deleteDisabled}
|
||||
deleteDisabledReason={deleteDisabledReason}
|
||||
rowFocused={rowFocused}
|
||||
onEdit={onEdit}
|
||||
onDeregister={onDeregister}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type RowMoreMenuProps = {
|
||||
canDeregister: boolean;
|
||||
deleteDisabled: boolean;
|
||||
deleteDisabledReason: string | null;
|
||||
rowFocused: boolean;
|
||||
onEdit: () => void;
|
||||
onDeregister: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
const RowMoreMenu = ({
|
||||
canDeregister,
|
||||
deleteDisabled,
|
||||
deleteDisabledReason,
|
||||
rowFocused,
|
||||
onEdit,
|
||||
onDeregister,
|
||||
onDelete,
|
||||
}: RowMoreMenuProps) => {
|
||||
const { t } = useTranslation();
|
||||
const moreLabel = t("profile.selector.moreOptions");
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type={"button"}
|
||||
aria-label={moreLabel}
|
||||
tabIndex={rowFocused ? 0 : -1}
|
||||
className={cn(
|
||||
"inline-flex h-9 w-9 cursor-default items-center justify-center rounded-md outline-none",
|
||||
"text-nb-gray-400 hover:bg-nb-gray-900 hover:text-nb-gray-100",
|
||||
"transition-colors duration-150",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"data-[state=open]:bg-nb-gray-900 data-[state=open]:text-nb-gray-100",
|
||||
)}
|
||||
>
|
||||
<MoreVertical size={16} aria-hidden={"true"} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={"end"} sideOffset={4} className={"min-w-36 select-none"}>
|
||||
<DropdownMenuItem onClick={onEdit}>
|
||||
<div className={"flex w-full items-center gap-2"}>
|
||||
<PencilLine size={14} aria-hidden={"true"} />
|
||||
<span className={"flex-1"}>{t("profile.selector.edit")}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
{canDeregister && (
|
||||
<DropdownMenuItem onClick={onDeregister}>
|
||||
<div className={"flex w-full items-center gap-2"}>
|
||||
<CircleMinus size={14} aria-hidden={"true"} />
|
||||
<span className={"flex-1"}>{t("profile.selector.deregister")}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DeleteMenuItem
|
||||
disabled={deleteDisabled}
|
||||
disabledReason={deleteDisabledReason}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
type DeleteMenuItemProps = {
|
||||
disabled: boolean;
|
||||
disabledReason: string | null;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
const DeleteMenuItem = ({ disabled, disabledReason, onDelete }: DeleteMenuItemProps) => {
|
||||
const { t } = useTranslation();
|
||||
const item = (
|
||||
<DropdownMenuItem
|
||||
disabled={disabled}
|
||||
onClick={disabled ? undefined : onDelete}
|
||||
className={cn(!disabled && "text-red-500 hover:!text-red-500 focus:text-red-500")}
|
||||
>
|
||||
<div className={"flex w-full items-center gap-2"}>
|
||||
<Trash2 size={14} aria-hidden={"true"} />
|
||||
<span className={"flex-1"}>{t("profile.selector.delete")}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
if (!disabled || !disabledReason) return item;
|
||||
return (
|
||||
<Tooltip
|
||||
content={<span className={"block max-w-[260px] leading-snug"}>{disabledReason}</span>}
|
||||
side={"left"}
|
||||
>
|
||||
<span className={"block"}>{item}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
type ActionIconButtonProps = {
|
||||
label: string;
|
||||
icon: typeof CircleMinus;
|
||||
|
||||
@@ -323,6 +323,15 @@
|
||||
"profile.selector.switchTo": {
|
||||
"message": "Zu diesem Profil wechseln"
|
||||
},
|
||||
"profile.selector.edit": {
|
||||
"message": "Bearbeiten"
|
||||
},
|
||||
"profile.edit.title": {
|
||||
"message": "Profil bearbeiten"
|
||||
},
|
||||
"profile.edit.submit": {
|
||||
"message": "Änderungen speichern"
|
||||
},
|
||||
"profile.dialog.title": {
|
||||
"message": "Neues Profil"
|
||||
},
|
||||
@@ -437,6 +446,9 @@
|
||||
"profile.error.createTitle": {
|
||||
"message": "Erstellen des Profils fehlgeschlagen"
|
||||
},
|
||||
"profile.error.editTitle": {
|
||||
"message": "Bearbeiten des Profils fehlgeschlagen"
|
||||
},
|
||||
"profile.error.loadTitle": {
|
||||
"message": "Laden der Profile fehlgeschlagen"
|
||||
},
|
||||
|
||||
@@ -431,6 +431,18 @@
|
||||
"message": "Switch to this profile",
|
||||
"description": "Tooltip / label for the action that switches to a profile."
|
||||
},
|
||||
"profile.selector.edit": {
|
||||
"message": "Edit",
|
||||
"description": "Per-profile menu action: open the edit dialog to rename or change the management server."
|
||||
},
|
||||
"profile.edit.title": {
|
||||
"message": "Edit Profile",
|
||||
"description": "Title of the dialog for editing an existing profile."
|
||||
},
|
||||
"profile.edit.submit": {
|
||||
"message": "Save Changes",
|
||||
"description": "Submit button on the edit-profile dialog. Keep short."
|
||||
},
|
||||
"profile.dialog.title": {
|
||||
"message": "Enter Profile Name",
|
||||
"description": "Title of the dialog for naming a new profile."
|
||||
@@ -444,8 +456,8 @@
|
||||
"description": "Helper text under the profile-name field."
|
||||
},
|
||||
"profile.dialog.placeholder": {
|
||||
"message": "e.g. work",
|
||||
"description": "Example placeholder shown in the profile-name field. 'work' is a sample value; translate it to a natural example."
|
||||
"message": "e.g. Work",
|
||||
"description": "Example placeholder shown in the profile-name field. 'Work' is a sample value; translate it to a natural example."
|
||||
},
|
||||
"profile.dialog.submit": {
|
||||
"message": "Add Profile",
|
||||
@@ -579,6 +591,10 @@
|
||||
"message": "Create Profile Failed",
|
||||
"description": "Error-dialog title when creating a profile fails."
|
||||
},
|
||||
"profile.error.editTitle": {
|
||||
"message": "Edit Profile Failed",
|
||||
"description": "Error-dialog title when editing a profile (rename or management URL change) fails."
|
||||
},
|
||||
"profile.error.loadTitle": {
|
||||
"message": "Load Profiles Failed",
|
||||
"description": "Error-dialog title when loading profiles fails."
|
||||
|
||||
@@ -323,6 +323,15 @@
|
||||
"profile.selector.switchTo": {
|
||||
"message": "Cambiar a este perfil"
|
||||
},
|
||||
"profile.selector.edit": {
|
||||
"message": "Editar"
|
||||
},
|
||||
"profile.edit.title": {
|
||||
"message": "Editar perfil"
|
||||
},
|
||||
"profile.edit.submit": {
|
||||
"message": "Guardar cambios"
|
||||
},
|
||||
"profile.dialog.title": {
|
||||
"message": "Introduzca el nombre del perfil"
|
||||
},
|
||||
@@ -437,6 +446,9 @@
|
||||
"profile.error.createTitle": {
|
||||
"message": "Error al crear el perfil"
|
||||
},
|
||||
"profile.error.editTitle": {
|
||||
"message": "Error al editar el perfil"
|
||||
},
|
||||
"profile.error.loadTitle": {
|
||||
"message": "Error al cargar los perfiles"
|
||||
},
|
||||
|
||||
@@ -323,6 +323,15 @@
|
||||
"profile.selector.switchTo": {
|
||||
"message": "Basculer vers ce profil"
|
||||
},
|
||||
"profile.selector.edit": {
|
||||
"message": "Modifier"
|
||||
},
|
||||
"profile.edit.title": {
|
||||
"message": "Modifier le profil"
|
||||
},
|
||||
"profile.edit.submit": {
|
||||
"message": "Enregistrer les modifications"
|
||||
},
|
||||
"profile.dialog.title": {
|
||||
"message": "Saisir le nom du profil"
|
||||
},
|
||||
@@ -437,6 +446,9 @@
|
||||
"profile.error.createTitle": {
|
||||
"message": "Échec de la création du profil"
|
||||
},
|
||||
"profile.error.editTitle": {
|
||||
"message": "Échec de la modification du profil"
|
||||
},
|
||||
"profile.error.loadTitle": {
|
||||
"message": "Échec du chargement des profils"
|
||||
},
|
||||
|
||||
@@ -323,6 +323,15 @@
|
||||
"profile.selector.switchTo": {
|
||||
"message": "Váltás erre a profilra"
|
||||
},
|
||||
"profile.selector.edit": {
|
||||
"message": "Szerkesztés"
|
||||
},
|
||||
"profile.edit.title": {
|
||||
"message": "Profil szerkesztése"
|
||||
},
|
||||
"profile.edit.submit": {
|
||||
"message": "Módosítások mentése"
|
||||
},
|
||||
"profile.dialog.title": {
|
||||
"message": "Új profil"
|
||||
},
|
||||
@@ -437,6 +446,9 @@
|
||||
"profile.error.createTitle": {
|
||||
"message": "Profil létrehozása sikertelen"
|
||||
},
|
||||
"profile.error.editTitle": {
|
||||
"message": "Profil szerkesztése sikertelen"
|
||||
},
|
||||
"profile.error.loadTitle": {
|
||||
"message": "Profilok betöltése sikertelen"
|
||||
},
|
||||
|
||||
@@ -323,6 +323,15 @@
|
||||
"profile.selector.switchTo": {
|
||||
"message": "Passa a questo profilo"
|
||||
},
|
||||
"profile.selector.edit": {
|
||||
"message": "Modifica"
|
||||
},
|
||||
"profile.edit.title": {
|
||||
"message": "Modifica profilo"
|
||||
},
|
||||
"profile.edit.submit": {
|
||||
"message": "Salva modifiche"
|
||||
},
|
||||
"profile.dialog.title": {
|
||||
"message": "Inserisci il nome del profilo"
|
||||
},
|
||||
@@ -437,6 +446,9 @@
|
||||
"profile.error.createTitle": {
|
||||
"message": "Creazione profilo non riuscita"
|
||||
},
|
||||
"profile.error.editTitle": {
|
||||
"message": "Modifica del profilo non riuscita"
|
||||
},
|
||||
"profile.error.loadTitle": {
|
||||
"message": "Caricamento profili non riuscito"
|
||||
},
|
||||
|
||||
@@ -323,6 +323,15 @@
|
||||
"profile.selector.switchTo": {
|
||||
"message": "Alternar para este perfil"
|
||||
},
|
||||
"profile.selector.edit": {
|
||||
"message": "Editar"
|
||||
},
|
||||
"profile.edit.title": {
|
||||
"message": "Editar perfil"
|
||||
},
|
||||
"profile.edit.submit": {
|
||||
"message": "Salvar alterações"
|
||||
},
|
||||
"profile.dialog.title": {
|
||||
"message": "Insira o nome do perfil"
|
||||
},
|
||||
@@ -437,6 +446,9 @@
|
||||
"profile.error.createTitle": {
|
||||
"message": "Falha ao criar o perfil"
|
||||
},
|
||||
"profile.error.editTitle": {
|
||||
"message": "Falha ao editar o perfil"
|
||||
},
|
||||
"profile.error.loadTitle": {
|
||||
"message": "Falha ao carregar os perfis"
|
||||
},
|
||||
|
||||
@@ -323,6 +323,15 @@
|
||||
"profile.selector.switchTo": {
|
||||
"message": "Переключиться на этот профиль"
|
||||
},
|
||||
"profile.selector.edit": {
|
||||
"message": "Изменить"
|
||||
},
|
||||
"profile.edit.title": {
|
||||
"message": "Изменить профиль"
|
||||
},
|
||||
"profile.edit.submit": {
|
||||
"message": "Сохранить изменения"
|
||||
},
|
||||
"profile.dialog.title": {
|
||||
"message": "Введите имя профиля"
|
||||
},
|
||||
@@ -437,6 +446,9 @@
|
||||
"profile.error.createTitle": {
|
||||
"message": "Не удалось создать профиль"
|
||||
},
|
||||
"profile.error.editTitle": {
|
||||
"message": "Не удалось изменить профиль"
|
||||
},
|
||||
"profile.error.loadTitle": {
|
||||
"message": "Не удалось загрузить профили"
|
||||
},
|
||||
|
||||
@@ -323,6 +323,15 @@
|
||||
"profile.selector.switchTo": {
|
||||
"message": "切换到此配置文件"
|
||||
},
|
||||
"profile.selector.edit": {
|
||||
"message": "编辑"
|
||||
},
|
||||
"profile.edit.title": {
|
||||
"message": "编辑配置文件"
|
||||
},
|
||||
"profile.edit.submit": {
|
||||
"message": "保存更改"
|
||||
},
|
||||
"profile.dialog.title": {
|
||||
"message": "输入配置文件名称"
|
||||
},
|
||||
@@ -437,6 +446,9 @@
|
||||
"profile.error.createTitle": {
|
||||
"message": "创建配置文件失败"
|
||||
},
|
||||
"profile.error.editTitle": {
|
||||
"message": "编辑配置文件失败"
|
||||
},
|
||||
"profile.error.loadTitle": {
|
||||
"message": "加载配置文件失败"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user