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:
Zoltan Papp
2026-06-18 11:40:51 +02:00
77 changed files with 2789 additions and 1647 deletions
@@ -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,