mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-12 08:29:06 +02:00
Merge branch 'main' into ui-refactor
Resolve conflicts and complete the profile display-name -> ID migration across the daemon, CLI, UI services, tray, and React frontend. - Regenerate client/proto daemon.pb.go from the merged proto so it carries both branches' RPCs (ui-refactor: SubscribeStatus/RegisterUILog/ExtendAuthSession, main: RenameProfile + id fields); keep the v6.33.1 generator header - server.go: combine ui-refactor's profile-list-changed events with main's id-bearing responses; publish profile-list-changed on rename - Drop the deleted Fyne client_ui.go/profile.go and port main's profile-ID changes into the refactored services/tray - UI services, tray and React: send the profile ID as the daemon handle and keep the display name for rendering only (activeProfileId vs activeProfile) - Relax the profile-name input to match the daemon's sanitizeDisplayName (spaces, emoji, any valid UTF-8); cap at 128 - Expose RenameProfile via a Profiles.Rename services wrapper (+ regenerated bindings) for the frontend to wire up - cmd/login.go: use the profile ID for GetProfileState
This commit is contained in:
@@ -18,14 +18,19 @@ const EVENT_PROFILE_CHANGED = "netbird:profile:changed";
|
||||
|
||||
type ProfileContextValue = {
|
||||
username: string;
|
||||
// activeProfile is the display NAME of the active profile (for rendering
|
||||
// and the "default" check). activeProfileId is its stable on-disk ID, used
|
||||
// as the handle for daemon requests and for active-profile comparisons,
|
||||
// since display names can collide.
|
||||
activeProfile: string;
|
||||
activeProfileId: string;
|
||||
profiles: Profile[];
|
||||
loaded: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
switchProfile: (name: string) => Promise<void>;
|
||||
addProfile: (name: string) => Promise<void>;
|
||||
removeProfile: (name: string) => Promise<void>;
|
||||
logoutProfile: (name: string) => Promise<void>;
|
||||
switchProfile: (id: string) => Promise<void>;
|
||||
addProfile: (name: string) => Promise<string>;
|
||||
removeProfile: (id: string) => Promise<void>;
|
||||
logoutProfile: (id: string) => Promise<void>;
|
||||
};
|
||||
|
||||
const ProfileContext = createContext<ProfileContextValue | null>(null);
|
||||
@@ -41,6 +46,7 @@ export const useProfile = () => {
|
||||
export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [username, setUsername] = useState("");
|
||||
const [activeProfile, setActiveProfile] = useState("");
|
||||
const [activeProfileId, setActiveProfileId] = useState("");
|
||||
const [profiles, setProfiles] = useState<Profile[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const retryRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -58,6 +64,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
||||
]);
|
||||
setUsername(u);
|
||||
setActiveProfile(active.profileName || "default");
|
||||
setActiveProfileId(active.id || "default");
|
||||
setProfiles(list);
|
||||
setLoaded(true);
|
||||
} catch (e) {
|
||||
@@ -94,33 +101,38 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
// id is a handle: the daemon resolves an exact ID, ID prefix, or unique
|
||||
// display name. The UI passes the profile's ID for precision.
|
||||
const switchProfile = useCallback(
|
||||
async (name: string) => {
|
||||
await ProfileSwitcher.SwitchActive({ profileName: name, username });
|
||||
async (id: string) => {
|
||||
await ProfileSwitcher.SwitchActive({ profileName: id, username });
|
||||
await refresh();
|
||||
},
|
||||
[username, refresh],
|
||||
);
|
||||
|
||||
// addProfile creates a profile by display name and returns the
|
||||
// daemon-generated ID, so the caller can immediately address it by ID.
|
||||
const addProfile = useCallback(
|
||||
async (name: string) => {
|
||||
await ProfilesSvc.Add({ profileName: name, username });
|
||||
const id = await ProfilesSvc.Add({ profileName: name, username });
|
||||
await refresh();
|
||||
return id;
|
||||
},
|
||||
[username, refresh],
|
||||
);
|
||||
|
||||
const removeProfile = useCallback(
|
||||
async (name: string) => {
|
||||
await ProfilesSvc.Remove({ profileName: name, username });
|
||||
async (id: string) => {
|
||||
await ProfilesSvc.Remove({ profileName: id, username });
|
||||
await refresh();
|
||||
},
|
||||
[username, refresh],
|
||||
);
|
||||
|
||||
const logoutProfile = useCallback(
|
||||
async (name: string) => {
|
||||
await Connection.Logout({ profileName: name, username });
|
||||
async (id: string) => {
|
||||
await Connection.Logout({ profileName: id, username });
|
||||
await refresh();
|
||||
},
|
||||
[username, refresh],
|
||||
@@ -130,6 +142,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
||||
() => ({
|
||||
username,
|
||||
activeProfile,
|
||||
activeProfileId,
|
||||
profiles,
|
||||
loaded,
|
||||
refresh,
|
||||
@@ -141,6 +154,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
|
||||
[
|
||||
username,
|
||||
activeProfile,
|
||||
activeProfileId,
|
||||
profiles,
|
||||
loaded,
|
||||
refresh,
|
||||
|
||||
@@ -58,7 +58,7 @@ export const useAutostartSetting = () => {
|
||||
type LoadedConfig = { profileName: string; data: Config };
|
||||
|
||||
const useSettingsState = () => {
|
||||
const { username, activeProfile, loaded: profileLoaded } = useProfile();
|
||||
const { username, activeProfileId, loaded: profileLoaded } = useProfile();
|
||||
const [loaded, setLoaded] = useState<LoadedConfig | null>(null);
|
||||
const [guiVersion, setGuiVersion] = useState<string>("—");
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -69,18 +69,18 @@ const useSettingsState = () => {
|
||||
}, [loaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!profileLoaded || !activeProfile) return;
|
||||
if (!profileLoaded || !activeProfileId) return;
|
||||
let cancelled = false;
|
||||
|
||||
const load = async (showError: boolean) => {
|
||||
try {
|
||||
const data = await SettingsSvc.GetConfig({
|
||||
profileName: activeProfile,
|
||||
profileName: activeProfileId,
|
||||
username,
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (saveTimer.current) return;
|
||||
setLoaded({ profileName: activeProfile, data });
|
||||
setLoaded({ profileName: activeProfileId, data });
|
||||
} catch (e) {
|
||||
if (cancelled || !showError) return;
|
||||
await errorDialog({
|
||||
@@ -103,7 +103,7 @@ const useSettingsState = () => {
|
||||
cancelled = true;
|
||||
off();
|
||||
};
|
||||
}, [profileLoaded, activeProfile, username]);
|
||||
}, [profileLoaded, activeProfileId, username]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@@ -43,7 +43,7 @@ const errorMessage = formatErrorMessage;
|
||||
export const MainConnectionStatusSwitch = () => {
|
||||
const { t } = useTranslation();
|
||||
const { status, refresh } = useStatus();
|
||||
const { activeProfile, username } = useProfile();
|
||||
const { activeProfileId, username } = useProfile();
|
||||
|
||||
const daemonState = status?.status ?? "Idle";
|
||||
const needsLogin = NEEDS_LOGIN_STATES.has(daemonState);
|
||||
@@ -91,7 +91,7 @@ export const MainConnectionStatusSwitch = () => {
|
||||
setAction("connect");
|
||||
try {
|
||||
await Connection.Up({
|
||||
profileName: activeProfile,
|
||||
profileName: activeProfileId,
|
||||
username,
|
||||
});
|
||||
await refresh();
|
||||
|
||||
@@ -22,13 +22,12 @@ type Props = {
|
||||
onCreate: (name: string, managementUrl: string) => void;
|
||||
};
|
||||
|
||||
// Must match the daemon's silent profilemanager.sanitizeProfileName, else the in-flight
|
||||
// raw name diverges from what's stored, spawning a ghost row and breaking delete.
|
||||
const sanitizeProfileInput = (value: string): string =>
|
||||
value
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^a-z0-9_-]/g, "");
|
||||
// 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) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -66,7 +65,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
|
||||
e.preventDefault();
|
||||
if (checking) return;
|
||||
|
||||
const sanitized = sanitizeProfileInput(name);
|
||||
const sanitized = name.trim();
|
||||
if (sanitized.length === 0) {
|
||||
setNameError(t("profile.dialog.required"));
|
||||
nameRef.current?.focus();
|
||||
@@ -106,7 +105,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(sanitizeProfileInput(value));
|
||||
setName(value);
|
||||
if (nameError) setNameError(null);
|
||||
};
|
||||
|
||||
@@ -147,7 +146,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
error={nameError ?? undefined}
|
||||
maxLength={64}
|
||||
maxLength={MAX_PROFILE_NAME_LEN}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
|
||||
@@ -19,13 +19,13 @@ const MANAGE_VALUE = "__manage_profiles__";
|
||||
|
||||
export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { activeProfile, profiles, switchProfile, loaded } = useProfile();
|
||||
const { activeProfile, activeProfileId, profiles, switchProfile, loaded } = useProfile();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const sortedProfiles = [...profiles].sort((a, b) => {
|
||||
if (a.name === activeProfile) return -1;
|
||||
if (b.name === activeProfile) return 1;
|
||||
if (a.id === activeProfileId) return -1;
|
||||
if (b.id === activeProfileId) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
@@ -44,10 +44,10 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (name: string) => {
|
||||
const handleSelect = (id: string) => {
|
||||
setOpen(false);
|
||||
if (name === activeProfile) return;
|
||||
void guarded(t("profile.error.switchTitle"), () => switchProfile(name));
|
||||
if (id === activeProfileId) return;
|
||||
void guarded(t("profile.error.switchTitle"), () => switchProfile(id));
|
||||
};
|
||||
|
||||
const handleManage = () => {
|
||||
@@ -57,7 +57,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
||||
|
||||
if (!loaded) return <ProfileTriggerSkeleton />;
|
||||
|
||||
const hasProfile = !!activeProfile;
|
||||
const hasProfile = !!activeProfileId;
|
||||
const displayName = hasProfile ? activeProfile : t("profile.selector.noProfile");
|
||||
|
||||
return (
|
||||
@@ -90,9 +90,9 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
||||
<Command.List>
|
||||
{sortedProfiles.map((profile) => (
|
||||
<ProfileRow
|
||||
key={profile.name}
|
||||
key={profile.id}
|
||||
profile={profile}
|
||||
isActive={profile.name === activeProfile}
|
||||
isActive={profile.id === activeProfileId}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
@@ -178,15 +178,15 @@ const ProfileTriggerButton = forwardRef<HTMLButtonElement, ProfileTriggerButtonP
|
||||
type ProfileRowProps = {
|
||||
profile: Profile;
|
||||
isActive: boolean;
|
||||
onSelect: (name: string) => void;
|
||||
onSelect: (id: string) => void;
|
||||
};
|
||||
|
||||
const ProfileRow = ({ profile, isActive, onSelect }: ProfileRowProps) => {
|
||||
const showEmail = !!profile.email;
|
||||
return (
|
||||
<Command.Item
|
||||
value={profile.name}
|
||||
onSelect={() => onSelect(profile.name)}
|
||||
value={profile.id}
|
||||
onSelect={() => onSelect(profile.id)}
|
||||
className={cn(
|
||||
"flex gap-2 px-2 py-2 pr-3 w-auto last:mb-1",
|
||||
"rounded-md outline-none cursor-default text-sm",
|
||||
|
||||
@@ -25,7 +25,7 @@ export function ProfilesTab() {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
profiles,
|
||||
activeProfile,
|
||||
activeProfileId,
|
||||
loaded,
|
||||
username,
|
||||
switchProfile,
|
||||
@@ -45,16 +45,16 @@ export function ProfilesTab() {
|
||||
const { order, items } = reconcileOrder(
|
||||
orderRef.current,
|
||||
profiles,
|
||||
(p) => p.name,
|
||||
(p) => p.id,
|
||||
(a, b) => {
|
||||
if (a.name === activeProfile) return -1;
|
||||
if (b.name === activeProfile) return 1;
|
||||
if (a.id === activeProfileId) return -1;
|
||||
if (b.id === activeProfileId) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
},
|
||||
);
|
||||
orderRef.current = order;
|
||||
return items;
|
||||
}, [profiles, activeProfile]);
|
||||
}, [profiles, activeProfileId]);
|
||||
|
||||
const guarded = async (title: string, fn: () => Promise<void>) => {
|
||||
if (busy) return;
|
||||
@@ -71,27 +71,27 @@ export function ProfilesTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwitch = async (name: string) => {
|
||||
const handleSwitch = async (id: string, name: string) => {
|
||||
const ok = await confirm({
|
||||
title: t("profile.switch.title", { name }),
|
||||
description: t("profile.switch.message", { name }),
|
||||
confirmLabel: t("profile.switch.confirm"),
|
||||
});
|
||||
if (!ok) return;
|
||||
await guarded(i18next.t("profile.error.switchTitle"), () => switchProfile(name));
|
||||
await guarded(i18next.t("profile.error.switchTitle"), () => switchProfile(id));
|
||||
};
|
||||
|
||||
const handleDeregister = async (name: string) => {
|
||||
const handleDeregister = async (id: string, name: string) => {
|
||||
const ok = await confirm({
|
||||
title: t("profile.deregister.title", { name }),
|
||||
description: t("profile.deregister.message", { name }),
|
||||
confirmLabel: t("profile.deregister.confirm"),
|
||||
});
|
||||
if (!ok) return;
|
||||
void guarded(i18next.t("profile.error.deregisterTitle"), () => logoutProfile(name));
|
||||
void guarded(i18next.t("profile.error.deregisterTitle"), () => logoutProfile(id));
|
||||
};
|
||||
|
||||
const handleDelete = async (name: string) => {
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (name === DEFAULT_PROFILE) return;
|
||||
const ok = await confirm({
|
||||
title: t("profile.delete.title", { name }),
|
||||
@@ -100,20 +100,21 @@ export function ProfilesTab() {
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
void guarded(i18next.t("profile.error.deleteTitle"), () => removeProfile(name));
|
||||
void guarded(i18next.t("profile.error.deleteTitle"), () => removeProfile(id));
|
||||
};
|
||||
|
||||
const handleCreate = async (name: string, managementUrl: string) => {
|
||||
await guarded(i18next.t("profile.error.createTitle"), async () => {
|
||||
await addProfile(name);
|
||||
// SetConfig is keyed by profile name, so it writes the not-yet-active
|
||||
// profile. Write before switching so any reconnect targets the right deployment.
|
||||
const id = await addProfile(name);
|
||||
// SetConfig is keyed by the new profile's ID, so it writes the
|
||||
// not-yet-active profile. Write before switching so any reconnect
|
||||
// targets the right deployment.
|
||||
if (!isNetbirdCloud(managementUrl)) {
|
||||
await SettingsSvc.SetConfig(
|
||||
new SetConfigParams({ profileName: name, username, managementUrl }),
|
||||
new SetConfigParams({ profileName: id, username, managementUrl }),
|
||||
);
|
||||
}
|
||||
await switchProfile(name);
|
||||
await switchProfile(id);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -131,12 +132,12 @@ export function ProfilesTab() {
|
||||
<tbody>
|
||||
{ordered.map((profile) => (
|
||||
<ProfileRow
|
||||
key={profile.name}
|
||||
key={profile.id}
|
||||
profile={profile}
|
||||
isActive={profile.name === activeProfile}
|
||||
onSwitch={() => handleSwitch(profile.name)}
|
||||
onDeregister={() => handleDeregister(profile.name)}
|
||||
onDelete={() => handleDelete(profile.name)}
|
||||
isActive={profile.id === activeProfileId}
|
||||
onSwitch={() => handleSwitch(profile.id, profile.name)}
|
||||
onDeregister={() => handleDeregister(profile.id, profile.name)}
|
||||
onDelete={() => handleDelete(profile.id, profile.name)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -134,7 +134,7 @@ export default function SessionExpirationDialog() {
|
||||
const username = await ProfilesSvc.Username();
|
||||
const active = await ProfilesSvc.GetActive();
|
||||
await Connection.Logout({
|
||||
profileName: active.profileName || "default",
|
||||
profileName: active.id || "default",
|
||||
username,
|
||||
});
|
||||
WindowManager.CloseSessionExpiration().catch(console.error);
|
||||
|
||||
@@ -19,13 +19,15 @@ const WINDOW_WIDTH = 360;
|
||||
type WelcomeStep = "tray" | "management";
|
||||
|
||||
function shouldShowManagementStep(
|
||||
activeProfile: string,
|
||||
activeProfileId: string,
|
||||
email: string,
|
||||
managementUrl: string,
|
||||
managedManagementUrl: string,
|
||||
): boolean {
|
||||
if (managedManagementUrl) return false;
|
||||
if (activeProfile !== "default") return false;
|
||||
// The default profile's ID equals the literal "default", so this check
|
||||
// holds whether we pass an ID or the legacy name.
|
||||
if (activeProfileId !== "default") return false;
|
||||
if (email.trim() !== "") return false;
|
||||
return isNetbirdCloud(managementUrl);
|
||||
}
|
||||
@@ -51,21 +53,21 @@ export default function WelcomeDialog() {
|
||||
ProfilesSvc.Username(),
|
||||
ProfilesSvc.GetActive(),
|
||||
]);
|
||||
const profileName = active.profileName || "default";
|
||||
const profileId = active.id || "default";
|
||||
const [config, list, restrictions] = await Promise.all([
|
||||
SettingsSvc.GetConfig({ profileName, username }),
|
||||
SettingsSvc.GetConfig({ profileName: profileId, username }),
|
||||
ProfilesSvc.List(username),
|
||||
SettingsSvc.GetRestrictions().catch(() => new Restrictions()),
|
||||
]);
|
||||
const profile = list.find((p) => p.name === profileName);
|
||||
const profile = list.find((p) => p.id === profileId);
|
||||
const email = profile?.email ?? "";
|
||||
if (cancelled) return;
|
||||
setInitial({
|
||||
profileName,
|
||||
profileName: profileId,
|
||||
username,
|
||||
managementUrl: config.managementUrl,
|
||||
needsManagementStep: shouldShowManagementStep(
|
||||
profileName,
|
||||
profileId,
|
||||
email,
|
||||
config.managementUrl,
|
||||
restrictions.mdm.managementURL,
|
||||
|
||||
@@ -122,7 +122,9 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err
|
||||
username := p.Username
|
||||
if profileName == "" {
|
||||
if active, aerr := cli.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{}); aerr == nil {
|
||||
profileName = active.GetProfileName()
|
||||
// Address the active profile by ID (the daemon resolves it as a
|
||||
// handle); names can collide, the ID cannot.
|
||||
profileName = active.GetId()
|
||||
if username == "" {
|
||||
username = active.GetUsername()
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ import (
|
||||
)
|
||||
|
||||
type Profile struct {
|
||||
// ID is the daemon-generated on-disk identity of the profile. Display
|
||||
// names can collide and be renamed, so the ID is the stable handle the
|
||||
// daemon resolves switch/remove/logout requests against.
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
IsActive bool `json:"isActive"`
|
||||
// Email is read from the user-owned per-profile state file (CLI writes it
|
||||
@@ -25,10 +29,28 @@ type ProfileRef struct {
|
||||
}
|
||||
|
||||
type ActiveProfile struct {
|
||||
// ID is the active profile's stable on-disk identity. Use it (not the
|
||||
// display name) as the handle for daemon requests and active-profile
|
||||
// comparisons, since names can collide.
|
||||
ID string `json:"id"`
|
||||
ProfileName string `json:"profileName"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// RenameProfileParams selects a profile by handle and carries its new display
|
||||
// name.
|
||||
type RenameProfileParams struct {
|
||||
// Handle selects the profile to rename: an exact ID, a unique ID prefix,
|
||||
// or a unique display name. The daemon resolves it server-side.
|
||||
Handle string `json:"handle"`
|
||||
// NewName is the new free-form display name. The daemon sanitizes it
|
||||
// (strips control characters, trims, caps length) but keeps spaces, emoji,
|
||||
// punctuation, and non-ASCII letters.
|
||||
NewName string `json:"newName"`
|
||||
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
type Profiles struct {
|
||||
conn DaemonConn
|
||||
}
|
||||
@@ -58,8 +80,8 @@ func (s *Profiles) List(ctx context.Context, username string) ([]Profile, error)
|
||||
pm := profilemanager.NewProfileManager()
|
||||
out := make([]Profile, 0, len(resp.GetProfiles()))
|
||||
for _, p := range resp.GetProfiles() {
|
||||
prof := Profile{Name: p.GetName(), IsActive: p.GetIsActive()}
|
||||
if state, err := pm.GetProfileState(p.GetName()); err == nil {
|
||||
prof := Profile{ID: p.GetId(), Name: p.GetName(), IsActive: p.GetIsActive()}
|
||||
if state, err := pm.GetProfileState(profilemanager.ID(p.GetId())); err == nil {
|
||||
prof.Email = state.Email
|
||||
}
|
||||
out = append(out, prof)
|
||||
@@ -77,15 +99,20 @@ func (s *Profiles) GetActive(ctx context.Context) (ActiveProfile, error) {
|
||||
return ActiveProfile{}, err
|
||||
}
|
||||
return ActiveProfile{
|
||||
ID: resp.GetId(),
|
||||
ProfileName: resp.GetProfileName(),
|
||||
Username: resp.GetUsername(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Profiles) Switch(ctx context.Context, p ProfileRef) error {
|
||||
// Switch sends a profile switch to the daemon and returns the resolved
|
||||
// on-disk ID of the now-active profile. ProfileName is treated as a handle
|
||||
// (exact ID, unique ID prefix, or unique display name); the daemon resolves
|
||||
// it server-side and echoes back the canonical ID.
|
||||
func (s *Profiles) Switch(ctx context.Context, p ProfileRef) (string, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
req := &proto.SwitchProfileRequest{}
|
||||
if p.ProfileName != "" {
|
||||
@@ -94,20 +121,29 @@ func (s *Profiles) Switch(ctx context.Context, p ProfileRef) error {
|
||||
if p.Username != "" {
|
||||
req.Username = ptrStr(p.Username)
|
||||
}
|
||||
_, err = cli.SwitchProfile(ctx, req)
|
||||
return err
|
||||
resp, err := cli.SwitchProfile(ctx, req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.GetId(), nil
|
||||
}
|
||||
|
||||
func (s *Profiles) Add(ctx context.Context, p ProfileRef) error {
|
||||
// Add creates a profile with the given display name and returns its
|
||||
// daemon-generated on-disk ID, so callers can address the new profile by ID
|
||||
// (e.g. to write config or switch to it) without re-resolving the name.
|
||||
func (s *Profiles) Add(ctx context.Context, p ProfileRef) (string, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
_, err = cli.AddProfile(ctx, &proto.AddProfileRequest{
|
||||
resp, err := cli.AddProfile(ctx, &proto.AddProfileRequest{
|
||||
ProfileName: p.ProfileName,
|
||||
Username: p.Username,
|
||||
})
|
||||
return err
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.GetId(), nil
|
||||
}
|
||||
|
||||
func (s *Profiles) Remove(ctx context.Context, p ProfileRef) error {
|
||||
@@ -121,3 +157,23 @@ func (s *Profiles) Remove(ctx context.Context, p ProfileRef) error {
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Rename changes a profile's display name. The on-disk ID is unaffected, so
|
||||
// the active profile and any ID-based references stay valid (the default
|
||||
// profile can be renamed too — only its display name changes). Returns the
|
||||
// profile's previous display name as confirmation.
|
||||
func (s *Profiles) Rename(ctx context.Context, p RenameProfileParams) (string, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := cli.RenameProfile(ctx, &proto.RenameProfileRequest{
|
||||
Username: p.Username,
|
||||
Handle: p.Handle,
|
||||
NewProfileName: p.NewName,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.GetOldProfileName(), nil
|
||||
}
|
||||
|
||||
@@ -55,15 +55,18 @@ func (s *ProfileSwitcher) SwitchActive(ctx context.Context, p ProfileRef) error
|
||||
s.feed.BeginProfileSwitch()
|
||||
}
|
||||
|
||||
if err := s.profiles.Switch(ctx, p); err != nil {
|
||||
resolvedID, err := s.profiles.Switch(ctx, p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("switch profile %q: %w", p.ProfileName, err)
|
||||
}
|
||||
|
||||
// Mirror into the user-side ProfileManager state: the CLI's `netbird up`
|
||||
// reads this file and sends the name back in the Up RPC, so if it diverges
|
||||
// reads this file and sends the ID back in the Up RPC, so if it diverges
|
||||
// the daemon reverts the UI switch on the next CLI `up`. Best-effort — the
|
||||
// daemon is authoritative; a failure only leaves the CLI's view stale.
|
||||
if err := profilemanager.NewProfileManager().SwitchProfile(p.ProfileName); err != nil {
|
||||
// Use the daemon-resolved ID rather than the handle we sent, since the
|
||||
// on-disk state is keyed by ID, not display name.
|
||||
if err := profilemanager.NewProfileManager().SwitchProfile(profilemanager.ID(resolvedID)); err != nil {
|
||||
log.Warnf("profileswitcher: mirror to user-side ProfileManager failed: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) {
|
||||
return Restrictions{}, fmt.Errorf("get active profile: %w", err)
|
||||
}
|
||||
cfgResp, err := cli.GetConfig(ctx, &proto.GetConfigRequest{
|
||||
ProfileName: active.GetProfileName(),
|
||||
ProfileName: active.GetId(),
|
||||
Username: active.GetUsername(),
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
+44
-11
@@ -10,9 +10,26 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/ui/services"
|
||||
)
|
||||
|
||||
// formatProfileLabel returns the display label for a profile. Profiles can
|
||||
// share the same Name, so when more than one profile in profiles carries this
|
||||
// Name, a short form of the ID is appended to disambiguate the entries.
|
||||
func formatProfileLabel(profile services.Profile, profiles []services.Profile) string {
|
||||
count := 0
|
||||
for _, p := range profiles {
|
||||
if p.Name == profile.Name {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count <= 1 {
|
||||
return profile.Name
|
||||
}
|
||||
return fmt.Sprintf("%s (%s)", profile.Name, profilemanager.ID(profile.ID).ShortID())
|
||||
}
|
||||
|
||||
// loadConfig caches the active-profile identity and the notifications gate.
|
||||
// Runs in a startup goroutine so a slow daemon does not block menu construction.
|
||||
func (t *Tray) loadConfig() {
|
||||
@@ -23,7 +40,13 @@ func (t *Tray) loadConfig() {
|
||||
log.Debugf("get active profile: %v", err)
|
||||
return
|
||||
}
|
||||
cfg, err := t.svc.Settings.GetConfig(ctx, services.ConfigParams(active))
|
||||
// Address the active profile by ID (the daemon resolves it as a handle),
|
||||
// since display names can collide. ConfigParams no longer matches
|
||||
// ActiveProfile's shape for a struct conversion now that it carries an ID.
|
||||
cfg, err := t.svc.Settings.GetConfig(ctx, services.ConfigParams{
|
||||
ProfileName: active.ID,
|
||||
Username: active.Username,
|
||||
})
|
||||
if err != nil {
|
||||
log.Debugf("get config: %v", err)
|
||||
return
|
||||
@@ -75,7 +98,12 @@ func (t *Tray) fillProfileSubmenu() {
|
||||
username := t.profilesUser
|
||||
t.profilesMu.Unlock()
|
||||
|
||||
sort.Slice(profiles, func(i, j int) bool { return profiles[i].Name < profiles[j].Name })
|
||||
sort.Slice(profiles, func(i, j int) bool {
|
||||
if profiles[i].Name != profiles[j].Name {
|
||||
return profiles[i].Name < profiles[j].Name
|
||||
}
|
||||
return profiles[i].ID < profiles[j].ID
|
||||
})
|
||||
|
||||
// Wails' systray does not reliably propagate a disabled parent to its
|
||||
// children on every platform, so disable each row explicitly.
|
||||
@@ -84,26 +112,28 @@ func (t *Tray) fillProfileSubmenu() {
|
||||
t.profileSubmenu.Clear()
|
||||
var activeName, activeEmail string
|
||||
for _, p := range profiles {
|
||||
name := p.Name
|
||||
id := p.ID
|
||||
// Display names can collide, so disambiguate with a short ID suffix.
|
||||
display := formatProfileLabel(p, profiles)
|
||||
active := p.IsActive
|
||||
// Add, not AddCheckbox: Wails auto-toggles a checkbox on click before
|
||||
// OnClick fires, so both old and new would briefly show checked during
|
||||
// the switch. A plain item with a "✓ " prefix avoids the race.
|
||||
label := name
|
||||
label := display
|
||||
if active {
|
||||
label = "✓ " + name
|
||||
label = "✓ " + display
|
||||
}
|
||||
item := t.profileSubmenu.Add(label)
|
||||
item.OnClick(func(*application.Context) {
|
||||
log.Infof("tray profile click: profile=%q wasActive=%v", name, active)
|
||||
log.Infof("tray profile click: profile=%q id=%q wasActive=%v", display, id, active)
|
||||
if active {
|
||||
return
|
||||
}
|
||||
t.switchProfile(name)
|
||||
t.switchProfile(id, display)
|
||||
})
|
||||
item.SetEnabled(!disableProfiles)
|
||||
if active {
|
||||
activeName = name
|
||||
activeName = display
|
||||
activeEmail = p.Email
|
||||
}
|
||||
}
|
||||
@@ -130,7 +160,10 @@ func (t *Tray) fillProfileSubmenu() {
|
||||
// switchProfile cancels any in-flight switch before starting a new one, so
|
||||
// rapid clicks converge to the last selected profile. Optimistic paint and
|
||||
// event suppression live in ProfileSwitcher, shared with the React Status page.
|
||||
func (t *Tray) switchProfile(name string) {
|
||||
// switchProfile sends handle (the profile's ID) to the daemon, which resolves
|
||||
// it precisely even when display names collide. display is used only for the
|
||||
// failure notification.
|
||||
func (t *Tray) switchProfile(handle, display string) {
|
||||
t.profileMu.Lock()
|
||||
if t.switchCancel != nil {
|
||||
t.switchCancel()
|
||||
@@ -146,14 +179,14 @@ func (t *Tray) switchProfile(name string) {
|
||||
return
|
||||
}
|
||||
if err := t.svc.ProfileSwitcher.SwitchActive(ctx, services.ProfileRef{
|
||||
ProfileName: name,
|
||||
ProfileName: handle,
|
||||
Username: username,
|
||||
}); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
log.Errorf("tray switchProfile: %v", err)
|
||||
t.notifyError(t.loc.T("notify.error.switchProfile", "profile", name))
|
||||
t.notifyError(t.loc.T("notify.error.switchProfile", "profile", display))
|
||||
return
|
||||
}
|
||||
t.loadProfiles()
|
||||
|
||||
Reference in New Issue
Block a user