mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-20 15:49:55 +00:00
add profiles tab to settings
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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`).
|
||||
|
||||
64
client/ui/frontend/src/components/Badge.tsx
Normal file
64
client/ui/frontend/src/components/Badge.tsx
Normal 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;
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
311
client/ui/frontend/src/modules/settings/SettingsProfiles.tsx
Normal file
311
client/ui/frontend/src/modules/settings/SettingsProfiles.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user