add profiles tab to settings

This commit is contained in:
Eduard Gert
2026-05-20 13:17:13 +02:00
parent b79b62bee4
commit 1c15e9976b
12 changed files with 450 additions and 36 deletions

View File

@@ -35,7 +35,7 @@ All services live in `services/` and assume a build tag `!android && !ios && !fr
| `Forwarding` | `forwarding.go` | `List` exposed/forwarded services from the daemon's reverse-proxy table. |
| `Debug` | `debug.go` | `Bundle` (debug bundle creation + optional upload) / `Get|SetLogLevel` / `RevealFile` (cross-platform "show in file manager"). |
| `Update` | `update.go` | `Trigger` (enforced installer) / `GetInstallerResult` / `Quit` (used by the `/update` page after a successful install). |
| `WindowManager` | `windowmanager.go` | `OpenSettings` / `OpenBrowserLogin(uri)` / `CloseBrowserLogin`. Auxiliary windows are created on first open and **destroyed** on close (Wails-recommended singleton pattern; prevents the macOS dock-reopen from resurrecting hidden windows). |
| `WindowManager` | `windowmanager.go` | `OpenSettings(tab)` / `OpenBrowserLogin(uri)` / `CloseBrowserLogin` / `OpenSessionExpired` / `OpenSessionAboutToExpire(seconds)`. `OpenSettings("")` opens the General tab; pass a tab id (e.g. `"profiles"`) to deep-link, encoded as `?tab=…` in the start URL. Auxiliary windows are created on first open and **destroyed** on close (Wails-recommended singleton pattern; prevents the macOS dock-reopen from resurrecting hidden windows). |
| `I18n` | `i18n.go` | Thin facade over `i18n.Bundle`. `Languages()` returns the shipped locales (`_index.json`); `Bundle(code)` returns the full key→text map for one language so the React layer can drive its own translation library. |
| `Preferences` | `preferences.go` | Thin facade over `preferences.Store`. `Get()` returns `{language}`; `SetLanguage(code)` validates against `i18n.Bundle.HasLanguage`, persists, and broadcasts `netbird:preferences:changed`. |
@@ -88,7 +88,7 @@ Also: `ProfileSwitcher.SwitchActive` mirrors the daemon switch into the user-sid
The main window is created up front in `main.go`. Auxiliary windows are created on demand by `services.WindowManager`:
- **Settings** (`/#/settings`) — opened from the header gear icon (`layouts/Header.tsx → WindowManager.OpenSettings`) **and** the tray's Settings menu entry (`tray.go openSettings``WindowManager.OpenSettings`). Both call sites go through `WindowManager` so the user sees the same dedicated frameless window from either trigger — the tray used to repurpose the main window via `SetURL("/#/settings")`, which replaced the main UI in place. Frameless-look (translucent macOS backdrop, hidden inset title bar), fixed 900×640, no resize, no minimise/maximise.
- **Settings** (`/#/settings`) — opened from the header gear icon (`layouts/Header.tsx → WindowManager.OpenSettings("")`), the tray's Settings menu entry (`tray.go openSettings`), and the profile dropdown's "Manage Profiles" entry (`WindowManager.OpenSettings("profiles")`, which sets `?tab=profiles` in the start URL — `Settings.tsx` reads it via `useSearchParams`). The window hosts every settings tab — including **Profiles** (`SettingsProfiles.tsx`, `UserCircle` icon, sits between Security and SSH), which lists profiles in a table with Deregister/Delete in a per-row kebab and an Add Profile button. Both call sites go through `WindowManager` so the user sees the same dedicated frameless window from either trigger — the tray used to repurpose the main window via `SetURL("/#/settings")`, which replaced the main UI in place. Frameless-look (translucent macOS backdrop, hidden inset title bar), fixed 900×640, no resize, no minimise/maximise.
- **BrowserLogin** (`/#/browser-login?uri=…`) — opened by the connection toggle's SSO flow (`layouts/ConnectionStatusSwitch.tsx`). 460×440, fixed size. The close button (red X) fires `EventBrowserLoginCancel` so the JS-side `startLogin()` can tear down the daemon's pending `WaitSSOLogin`. `WindowManager.CloseBrowserLogin` closes it programmatically when the flow completes.
- **SessionExpired** (`/#/session-expired`) and **SessionAboutToExpire** (`/#/session-about-to-expire?seconds=<n>`) — opened by `WindowManager.OpenSessionExpired` / `OpenSessionAboutToExpire(seconds)`. 460×380, fixed size, `AlwaysOnTop: true` (the user can't miss them). The React-side buttons close the window via `WindowManager.CloseSession*` and (for Sign-in / Stay-connected) emit `EventTriggerLogin` so the main window's `startLogin()` orchestrator handles the SSO flow. Currently triggered only by the DEV-only "Development" Settings tab; daemon-status integration is a follow-up.

View File

@@ -28,7 +28,7 @@ React 18 + TS 5.7 (`strict`, `noImplicitAny: false`) + Vite 6 + Tailwind 3 (`dar
| `/update` | `Update` (pages) | none | Main window during enforced-update install |
| `/session-expired` | `SessionExpiredDialog` (modules/authentication) | none | Auxiliary window (Go `WindowManager.OpenSessionExpired`, always-on-top) |
| `/session-about-to-expire` | `SessionAboutToExpireDialog` (modules/authentication) | none | Auxiliary window (Go `WindowManager.OpenSessionAboutToExpire(seconds)`, always-on-top, mm:ss countdown via `?seconds=`) |
| `/settings` | `Settings` | `SettingsLayout` | Auxiliary window (Go `WindowManager.OpenSettings`) |
| `/settings` | `Settings` | `SettingsLayout` | Auxiliary window (Go `WindowManager.OpenSettings(tab)`). The `Profiles` tab (`modules/settings/SettingsProfiles.tsx`, `UserCircle` icon, between Security and SSH) lists profiles in a table with Deregister/Delete in a per-row kebab and an Add Profile button. The header `ProfileDropdown`'s "Manage Profiles" entry calls `OpenSettings("profiles")``Settings.tsx` reads `?tab=` via `useSearchParams` so the window opens at that tab. |
| `*` | `<Navigate to="/">` | `AppLayout` | Catch-all |
`AppLayout` wraps `Header + <Outlet/>` in this provider order: `StatusProvider → ProfileProvider → DebugBundleProvider → ClientVersionProvider`. `StatusProvider` (in `modules/daemon-status/StatusContext.tsx`) owns the single `Peers.Get` + `netbird:status` subscription, exposes `{ status, error, refresh, isReady, isDaemonAvailable, isDaemonUnavailable }`, **and only renders its children when the daemon is reachable** — until the first `Peers.Get` resolves and on `DaemonUnavailable` it short-circuits to just the `<DaemonUnavailableOverlay/>` (also owned by the provider). The consequence: every context downstream (`ProfileProvider`, `DebugBundleProvider`, `ClientVersionProvider`) can assume the daemon is reachable at mount time — no per-context `useStatus` gating. When the daemon flips back to unavailable the whole downstream subtree unmounts and remounts fresh once it returns. The remaining order is structural — `DebugBundleProvider` reads `useProfile`, and `ClientVersionProvider` paints `<UpdatingOverlay/>` so it has to be outermost in z-index but innermost in the tree. `AppLayout` also owns the wide/narrow `expanded` state as plain `useState` (no persistence) and passes it to `Header` via props and to `Main` via Outlet context (`MainOutletContext`).

View File

@@ -0,0 +1,64 @@
import { forwardRef, type ComponentType, type HTMLAttributes } from "react";
import type { LucideProps } from "lucide-react";
import { cn } from "@/lib/cn";
export type BadgeVariant =
| "info"
| "neutral"
| "brand"
| "success"
| "warning"
| "danger";
type Props = HTMLAttributes<HTMLSpanElement> & {
/** Visual color scheme. Defaults to `info` (sky), used as the
* "Active profile" indicator. */
variant?: BadgeVariant;
/** Optional leading lucide icon. */
icon?: ComponentType<LucideProps>;
/** Override icon size. Defaults to 10px to match the compact pill. */
iconSize?: number;
};
const VARIANT_CLASSES: Record<BadgeVariant, string> = {
info: "bg-sky-900 border border-sky-700 text-sky-200",
neutral: "bg-nb-gray-900 border border-nb-gray-850 text-nb-gray-200",
brand: "bg-netbird/15 border border-netbird/30 text-netbird",
success: "bg-green-900 border border-green-700 text-green-200",
warning: "bg-yellow-900 border border-yellow-700 text-yellow-200",
danger: "bg-red-900 border border-red-700 text-red-200",
};
// Pill shape sized for inline use next to text. `top-px` nudges the badge
// down so its midline aligns with the surrounding text baseline; `leading-none`
// lets the small text sit flush in the pill without the line-height padding
// inflating it.
export const Badge = forwardRef<HTMLSpanElement, Props>(function Badge(
{
variant = "info",
icon: Icon,
iconSize = 10,
className,
children,
...rest
},
ref,
) {
return (
<span
ref={ref}
className={cn(
"relative top-px inline-flex items-center gap-1 rounded-full px-2 py-[0.2rem]",
"text-[0.65rem] leading-none font-semibold shrink-0",
VARIANT_CLASSES[variant],
className,
)}
{...rest}
>
{Icon && <Icon size={iconSize} />}
{children}
</span>
);
});
export default Badge;

View File

@@ -1,15 +1,17 @@
import { ButtonHTMLAttributes, forwardRef } from "react";
import {
Beaker,
Briefcase,
Building,
Cloud,
Construction,
FlaskConical,
Gamepad2,
GraduationCap,
House,
Cloud,
ServerCog,
Radio,
Server,
SquareCode,
TestTube,
Terminal,
UserCircle,
UserPlus,
Users,
@@ -17,17 +19,21 @@ import {
} from "lucide-react";
import { cn } from "@/lib/cn";
// Patterns match substrings, case-insensitive — "Proxytest" hits FlaskConical
// just like "test" does. The list is scanned in order, so more-specific
// tokens (e.g. "staging" before "stage") should come first when they share
// roots.
const ICON_MAP: ReadonlyArray<[RegExp, LucideIcon]> = [
[/(default|personal)/i, UserCircle],
[/(work|business|office|company|corp|corporate)/i, Briefcase],
[/(home|house|private)/i, House],
[/(dev|development|developer|code|coding|engineering)/i, SquareCode],
[/(local|localhost|loopback)/i, SquareCode],
[/(stage|staging)/i, Beaker],
[/(test|testing|qa)/i, TestTube],
[/(local|localhost|loopback)/i, Terminal],
[/(stage|staging|preprod|pre-prod)/i, Construction],
[/(test|testing|qa)/i, FlaskConical],
[/(prod|production)/i, Cloud],
[/(live)/i, Cloud],
[/(selfhosted|self-hosted|on-prem|onprem)/i, ServerCog],
[/(live)/i, Radio],
[/(selfhosted|self-hosted|on-prem|onprem)/i, Server],
[/(school|university|edu|study|student)/i, GraduationCap],
[/(client|customer)/i, Building],
[/(family)/i, Users],

View File

@@ -74,7 +74,7 @@
"profile.selector.newProfile": "New Profile",
"profile.selector.moreOptions": "More options",
"profile.selector.deregister": "Deregister",
"profile.selector.delete": "Delete Profile",
"profile.selector.delete": "Delete",
"profile.dialog.title": "Enter Profile Name",
"profile.dialog.description": "Choose a memorable name.",
@@ -104,6 +104,13 @@
"profile.dropdown.manageProfiles": "Manage Profiles",
"profile.dropdown.settings": "Settings",
"settings.profiles.section.profiles": "Profiles",
"settings.profiles.intro": "Keep separate NetBird identities side by side, for example work and personal accounts, or different management servers. Add, deregister, or delete profiles below.",
"settings.profiles.addProfile": "Add Profile",
"settings.profiles.active": "Active",
"settings.profiles.emptyTitle": "No Profiles",
"settings.profiles.emptyDescription": "Create a profile to connect to a NetBird management server.",
"settings.error.loadTitle": "Load Settings Failed",
"settings.error.saveTitle": "Save Settings Failed",
"settings.error.debugBundleTitle": "Debug Bundle Failed",
@@ -111,6 +118,7 @@
"settings.tabs.general": "General",
"settings.tabs.network": "Network",
"settings.tabs.security": "Security",
"settings.tabs.profiles": "Profiles",
"settings.tabs.ssh": "SSH",
"settings.tabs.advanced": "Advanced",
"settings.tabs.troubleshooting": "Troubleshooting",

View File

@@ -29,7 +29,11 @@ export const Header = () => {
const openSettings = () => {
setMenuOpen(false);
void WindowManager.OpenSettings().catch(() => {});
void WindowManager.OpenSettings("").catch(() => {});
};
const openManageProfiles = () => {
void WindowManager.OpenSettings("profiles").catch(() => {});
};
const selectMode = (mode: ViewMode) => {
@@ -48,7 +52,7 @@ export const Header = () => {
>
<div />
<div className={"flex justify-center ml-3"}>
<ProfileDropdown />
<ProfileDropdown onManageProfiles={openManageProfiles} />
</div>
<div className={"flex justify-end"}>
<DropdownMenu modal={false} open={menuOpen} onOpenChange={setMenuOpen}>

View File

@@ -28,8 +28,7 @@ for (const path in FLAG_URLS) {
if (match) flagByCode[match[1]] = FLAG_URLS[path];
}
const flagFor = (code: string): string | undefined =>
flagByCode[code.toLowerCase().split("-")[0]];
const flagFor = (code: string): string | undefined => flagByCode[code.toLowerCase().split("-")[0]];
function Flag({ code, label }: { code: string; label: string }) {
const src = flagFor(code);
@@ -132,11 +131,11 @@ export function LanguagePicker() {
<Popover.Portal>
<Popover.Content
align={"start"}
sideOffset={4}
sideOffset={6}
onCloseAutoFocus={(e) => e.preventDefault()}
className={cn(
"w-[var(--radix-popover-trigger-width)]",
"rounded-md border border-nb-gray-700 bg-nb-gray-900 shadow-lg p-1 z-50",
"rounded-md border border-nb-gray-850 bg-nb-gray-920 shadow-lg p-1 z-50",
"origin-[var(--radix-popover-content-transform-origin)]",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0",
@@ -188,11 +187,9 @@ export function LanguagePicker() {
value={`${lang.displayName} ${lang.englishName} ${lang.code}`}
onSelect={() => void select(lang.code)}
className={cn(
"flex items-center gap-2 px-2 py-2 rounded-md cursor-default outline-none",
"text-xs font-semibold text-nb-gray-100",
"data-[selected=true]:bg-nb-gray-850 my-0.5",
checked &&
"bg-nb-gray-800 data-[selected=true]:bg-nb-gray-800",
"flex items-center gap-2 px-2 py-2 rounded-md cursor-default outline-none my-0.5",
"text-xs font-semibold text-nb-gray-200",
"data-[selected=true]:bg-nb-gray-900 data-[selected=true]:text-nb-gray-50",
)}
>
<Flag
@@ -209,8 +206,8 @@ export function LanguagePicker() {
>
{checked && (
<CheckIcon
size={12}
className={"text-white"}
size={14}
className={"text-netbird"}
/>
)}
</span>

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
import { useLocation, useSearchParams } from "react-router-dom";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/cn";
import { MainRightSide } from "@/layouts/MainRightSide.tsx";
@@ -9,20 +9,26 @@ import { SettingsProvider } from "@/modules/settings/SettingsContext.tsx";
import { SettingsGeneral } from "@/modules/settings/SettingsGeneral.tsx";
import { SettingsNetwork } from "@/modules/settings/SettingsNetwork.tsx";
import { SettingsSecurity } from "@/modules/settings/SettingsSecurity.tsx";
import { SettingsProfiles } from "@/modules/settings/SettingsProfiles.tsx";
import { SettingsSSH } from "@/modules/settings/SettingsSSH.tsx";
import { SettingsAdvanced } from "@/modules/settings/SettingsAdvanced.tsx";
import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx";
import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx";
import { SettingsDevelopment } from "@/modules/settings/SettingsDevelopment.tsx";
// The settings window always opens at General. The only way to land on a
// different tab is via navigation state (e.g. the update-available header
// trigger jumps to About). No persistence across opens — a user who wants
// to revisit a deep tab gets there in two clicks.
// The settings window opens at General by default. Navigation state (e.g. the
// update-available header trigger jumps to About) or a `?tab=` query param
// in the window's start URL (e.g. WindowManager.OpenSettings("profiles") from
// the profile dropdown) override the default. No persistence across opens —
// a user who wants to revisit a deep tab gets there in two clicks.
export const Settings = () => {
const location = useLocation();
const [searchParams] = useSearchParams();
const queryTab = searchParams.get("tab");
const navState = location.state as { tab?: string } | null;
const [active, setActive] = useState(() => navState?.tab ?? "general");
const [active, setActive] = useState(
() => navState?.tab ?? queryTab ?? "general",
);
useEffect(() => {
if (navState?.tab) setActive(navState.tab);
@@ -48,6 +54,9 @@ export const Settings = () => {
<VerticalTabs.Content value={"security"}>
<SettingsSecurity />
</VerticalTabs.Content>
<VerticalTabs.Content value={"profiles"}>
<SettingsProfiles />
</VerticalTabs.Content>
<VerticalTabs.Content value={"ssh"}>
<SettingsSSH />
</VerticalTabs.Content>

View File

@@ -12,6 +12,7 @@ import {
ShieldIcon,
SlidersHorizontalIcon,
SquareTerminalIcon,
UserCircleIcon,
} from "lucide-react";
export const SettingsNavigationTriggers = () => {
@@ -42,6 +43,11 @@ export const SettingsNavigationTriggers = () => {
icon={ShieldIcon}
title={t("settings.tabs.security")}
/>
<VerticalTabs.Trigger
value={"profiles"}
icon={UserCircleIcon}
title={t("settings.tabs.profiles")}
/>
<VerticalTabs.Trigger
value={"ssh"}
icon={SquareTerminalIcon}

View File

@@ -0,0 +1,311 @@
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Dialogs } from "@wailsio/runtime";
import { LogOut, PlusCircle, Trash2, UserCircle } from "lucide-react";
import type { Profile } from "@bindings/services/models.js";
import { Badge } from "@/components/Badge";
import { Button } from "@/components/Button";
import HelpText from "@/components/HelpText";
import { NewProfileModal } from "@/components/NewProfileModal";
import { pickProfileIcon } from "@/components/ProfileAvatar";
import { Tooltip } from "@/components/Tooltip";
import i18next from "@/lib/i18n";
import { useProfile } from "@/modules/profile/ProfileContext";
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
import { cn } from "@/lib/cn";
const DEFAULT_PROFILE = "default";
export function SettingsProfiles() {
const { t } = useTranslation();
const {
profiles,
activeProfile,
loaded,
switchProfile,
addProfile,
removeProfile,
logoutProfile,
} = useProfile();
const [newOpen, setNewOpen] = useState(false);
const [busy, setBusy] = useState(false);
const sorted = [...profiles].sort((a, b) => {
if (a.name === activeProfile) return -1;
if (b.name === activeProfile) return 1;
return a.name.localeCompare(b.name);
});
const guarded = async (title: string, fn: () => Promise<void>) => {
if (busy) return;
setBusy(true);
try {
await fn();
} catch (e) {
await Dialogs.Error({
Title: title,
Message: e instanceof Error ? e.message : String(e),
});
} finally {
setBusy(false);
}
};
const handleDeregister = async (name: string) => {
const cancelLabel = i18next.t("common.cancel");
const confirmLabel = i18next.t("profile.deregister.confirm");
const result = await Dialogs.Warning({
Title: i18next.t("profile.deregister.title"),
Message: i18next.t("profile.deregister.message", { name }),
Buttons: [
{ Label: cancelLabel, IsCancel: true },
{ Label: confirmLabel, IsDefault: true },
],
});
if (result !== confirmLabel) return;
void guarded(i18next.t("profile.error.deregisterTitle"), () => logoutProfile(name));
};
const handleDelete = async (name: string) => {
if (name === DEFAULT_PROFILE) return;
const cancelLabel = i18next.t("common.cancel");
const confirmLabel = i18next.t("common.delete");
const result = await Dialogs.Warning({
Title: i18next.t("profile.delete.title"),
Message: i18next.t("profile.delete.message", { name }),
Buttons: [
{ Label: cancelLabel, IsCancel: true },
{ Label: confirmLabel, IsDefault: true },
],
});
if (result !== confirmLabel) return;
void guarded(i18next.t("profile.error.deleteTitle"), () => removeProfile(name));
};
const handleCreate = async (name: string) => {
try {
await addProfile(name);
await switchProfile(name);
} catch (e) {
await Dialogs.Error({
Title: i18next.t("profile.error.createTitle"),
Message: e instanceof Error ? e.message : String(e),
});
}
};
return (
<>
<SectionGroup title={t("settings.profiles.section.profiles")}>
<HelpText className={"-mt-2 mb-0"}>{t("settings.profiles.intro")}</HelpText>
<div
className={cn(
"bg-nb-gray-930/60 border border-nb-gray-900 rounded-xl overflow-hidden",
// Leave room for the absolutely positioned BottomBar
// (~76px) so the last row isn't hidden behind it when
// the list fills the scroll area.
"mb-20",
)}
>
<table className={"w-full text-sm"}>
<tbody>
{sorted.map((profile) => (
<ProfileRow
key={profile.name}
profile={profile}
isActive={profile.name === activeProfile}
onDeregister={() => handleDeregister(profile.name)}
onDelete={() => handleDelete(profile.name)}
/>
))}
</tbody>
</table>
{loaded && sorted.length === 0 && (
<div
className={
"flex flex-col items-center justify-center py-10 text-center"
}
>
<UserCircle size={28} className={"text-nb-gray-500 mb-2"} />
<p className={"text-sm font-semibold text-nb-gray-200"}>
{t("settings.profiles.emptyTitle")}
</p>
<p className={"mt-1 text-xs text-nb-gray-400 max-w-sm text-balance"}>
{t("settings.profiles.emptyDescription")}
</p>
</div>
)}
</div>
<BottomBar>
<Button variant={"primary"} size={"md"} onClick={() => setNewOpen(true)}>
<PlusCircle size={14} />
{t("settings.profiles.addProfile")}
</Button>
</BottomBar>
</SectionGroup>
<NewProfileModal open={newOpen} onOpenChange={setNewOpen} onCreate={handleCreate} />
</>
);
}
function BottomBar({ children }: { children: ReactNode }) {
return (
<div className={"absolute bottom-0 left-0 w-full"}>
<div
className={
"w-full flex justify-end gap-3 px-8 py-5 border-t border-nb-gray-900 bg-nb-gray-935"
}
>
{children}
</div>
</div>
);
}
type ProfileRowProps = {
profile: Profile;
isActive: boolean;
onDeregister: () => void;
onDelete: () => void;
};
const ProfileRow = ({ profile, isActive, onDeregister, onDelete }: ProfileRowProps) => {
const { t } = useTranslation();
const Icon = pickProfileIcon(profile.name) ?? UserCircle;
const showEmail = !!profile.email;
return (
<tr className={"border-b border-nb-gray-910 last:border-b-0"}>
<td className={"px-4 py-2.5 align-middle"}>
<div
className={cn(
"flex gap-2 min-w-0 leading-tight",
showEmail ? "items-start" : "items-center",
)}
>
<Icon
size={15}
className={cn(
"text-nb-gray-200 shrink-0",
showEmail ? "mt-0.5" : "",
)}
/>
<div className={"flex flex-col min-w-0 flex-1 leading-tight"}>
<div className={"flex items-center gap-2 min-w-0"}>
<span className={"truncate font-medium text-nb-gray-100 capitalize"}>
{profile.name}
</span>
{isActive && <Badge>{t("settings.profiles.active")}</Badge>}
</div>
{showEmail && <TruncatedEmail email={profile.email!} />}
</div>
</div>
</td>
<td className={"px-4 py-2.5 text-right align-middle"}>
<RowActions
canDeregister={!!profile.email}
canDelete={profile.name !== DEFAULT_PROFILE}
onDeregister={onDeregister}
onDelete={onDelete}
/>
</td>
</tr>
);
};
const TruncatedEmail = ({ email }: { email: string }) => {
const ref = useRef<HTMLSpanElement>(null);
const [overflowing, setOverflowing] = useState(false);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
setOverflowing(el.scrollWidth > el.clientWidth);
}, [email]);
const span = (
<span ref={ref} className={"text-xs text-nb-gray-300 truncate mt-0.5"}>
{email}
</span>
);
if (!overflowing) return span;
return <Tooltip content={email}>{span}</Tooltip>;
};
type RowActionsProps = {
canDeregister: boolean;
canDelete: boolean;
onDeregister: () => void;
onDelete: () => void;
};
const RowActions = ({ canDeregister, canDelete, onDeregister, onDelete }: RowActionsProps) => {
const { t } = useTranslation();
return (
<div className={"inline-flex items-center gap-1"}>
<ActionIconButton
label={t("profile.selector.deregister")}
icon={LogOut}
onClick={onDeregister}
hidden={!canDeregister}
/>
<ActionIconButton
label={t("profile.selector.delete")}
icon={Trash2}
onClick={onDelete}
variant={"danger"}
hidden={!canDelete}
/>
</div>
);
};
type ActionIconButtonProps = {
label: string;
icon: typeof LogOut;
onClick: () => void;
variant?: "default" | "danger";
/** When true the button still occupies space (preserves row layout)
* but is invisible and non-interactive. */
hidden?: boolean;
};
const ActionIconButton = ({
label,
icon: Icon,
onClick,
variant = "default",
hidden = false,
}: ActionIconButtonProps) => {
const button = (
<button
type={"button"}
onClick={onClick}
aria-label={label}
aria-hidden={hidden || undefined}
tabIndex={hidden ? -1 : undefined}
className={cn(
"h-9 w-9 inline-flex items-center justify-center rounded-md cursor-default outline-none",
"transition-colors duration-150",
variant === "danger"
? "text-nb-gray-400 hover:text-red-500 hover:bg-red-500/10"
: "text-nb-gray-400 hover:text-nb-gray-100 hover:bg-nb-gray-900",
hidden && "opacity-0 pointer-events-none",
)}
>
<Icon size={16} />
</button>
);
if (hidden) return button;
return (
<Tooltip content={label} side={"top"}>
{button}
</Tooltip>
);
};

View File

@@ -53,10 +53,16 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
}
// OpenSettings shows the settings window, creating it on first use (and
// after the user has closed a previous instance).
func (s *WindowManager) OpenSettings() {
// after the user has closed a previous instance). If `tab` is non-empty the
// settings React layer reads it from the start URL and selects that tab
// (e.g. "profiles") instead of the default "general".
func (s *WindowManager) OpenSettings(tab string) {
s.mu.Lock()
defer s.mu.Unlock()
startURL := "/#/settings"
if tab != "" {
startURL = "/#/settings?tab=" + url.QueryEscape(tab)
}
if s.settings == nil {
s.settings = s.app.Window.NewWithOptions(application.WebviewWindowOptions{
Name: "settings",
@@ -68,7 +74,7 @@ func (s *WindowManager) OpenSettings() {
MaximiseButtonState: application.ButtonHidden,
CloseButtonState: application.ButtonEnabled,
BackgroundColour: application.NewRGB(24, 26, 29),
URL: "/#/settings",
URL: startURL,
Mac: application.MacWindow{
InvisibleTitleBarHeight: 38,
Backdrop: application.MacBackdropTranslucent,
@@ -81,6 +87,9 @@ func (s *WindowManager) OpenSettings() {
s.settings = nil
s.mu.Unlock()
})
} else if tab != "" {
// Re-open onto a specific tab when the window is already alive.
s.settings.SetURL(startURL)
}
s.settings.Show()
s.settings.Focus()

View File

@@ -304,7 +304,7 @@ func (t *Tray) buildMenu() *application.Menu {
// block-inbound, auto-connect, notifications) and profile switching
// all live in the in-window Settings page now. The tray menu only
// surfaces the day-to-day actions.
t.settingsItem = menu.Add(t.loc.T("tray.menu.settings")).OnClick(func(*application.Context) { t.svc.WindowManager.OpenSettings() })
t.settingsItem = menu.Add(t.loc.T("tray.menu.settings")).OnClick(func(*application.Context) { t.svc.WindowManager.OpenSettings("") })
t.debugItem = menu.Add(t.loc.T("tray.menu.debugBundle")).OnClick(func(*application.Context) { t.openRoute("/debug") })
menu.AddSeparator()