fix: clarify desktop local profile (#1095)

* fix: clarify desktop local profile

* cover the AccordionSection hidden branch

The desktop build hides the Security section because the embedded profile signs
in automatically and has no login password, so the controls there would imply a
protection that does not exist. Nothing asserted that hidden actually keeps the
children out of the DOM rather than merely collapsing them.

Export the component and cover both states, including that an expanded hidden
section still renders nothing.
This commit is contained in:
ZacharyZcR
2026-07-28 01:57:52 +08:00
committed by GitHub
parent 8a1db9da37
commit 5f3e840892
3 changed files with 194 additions and 70 deletions
+2
View File
@@ -3323,6 +3323,8 @@
"resetToDefaultsSuccess": "Settings reset to defaults.",
"storageModeSwitch": "Preference Storage",
"sectionAccount": "Account",
"desktopProfileTitle": "Automatic local desktop profile",
"desktopProfileDescription": "This profile is restricted to the embedded backend and signs in automatically. It has no login password; Remote Sync below uses a separate server account.",
"sectionAppearance": "Appearance",
"sectionSecurity": "Security",
"sectionApiKeys": "API Keys",
+91 -70
View File
@@ -144,12 +144,13 @@ const LANGUAGES = [
{ code: "vi", label: "Tiếng Việt" },
];
function AccordionSection({
export function AccordionSection({
id,
label,
icon,
open,
onToggle,
hidden = false,
children,
}: {
id: string;
@@ -157,8 +158,11 @@ function AccordionSection({
icon: React.ReactNode;
open: boolean;
onToggle: () => void;
hidden?: boolean;
children: React.ReactNode;
}) {
if (hidden) return null;
return (
<div className="border border-border bg-card overflow-hidden">
<button
@@ -1372,61 +1376,73 @@ export function UserProfilePanel({
onToggle={() => toggle("account")}
>
<div className="flex flex-col gap-0 pt-2">
<div className="grid grid-cols-2 gap-x-4 gap-y-0">
<div className="flex flex-col py-2">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
{t("newUi.sidebar.userProfile.usernameLabel")}
</span>
<span className="text-sm font-semibold mt-0.5">
{username ?? "—"}
</span>
{isElectron() ? (
<div className="border border-accent-brand/40 bg-accent-brand/10 px-3 py-2.5 mb-2">
<div className="flex items-center gap-1.5 text-xs font-semibold text-accent-brand">
<ShieldCheck className="size-3.5" />
{t("newUi.sidebar.userProfile.desktopProfileTitle")}
</div>
<p className="text-[10px] text-muted-foreground leading-relaxed mt-1">
{t("newUi.sidebar.userProfile.desktopProfileDescription")}
</p>
</div>
<div className="flex flex-col py-2">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
{t("newUi.sidebar.userProfile.roleLabel")}
</span>
<div className="flex flex-wrap gap-1 mt-0.5">
<span className="inline-flex items-center px-1.5 py-0.5 text-[10px] font-semibold border border-accent-brand/40 bg-accent-brand/10 text-accent-brand w-fit">
{userRole || "—"}
) : (
<div className="grid grid-cols-2 gap-x-4 gap-y-0">
<div className="flex flex-col py-2">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
{t("newUi.sidebar.userProfile.usernameLabel")}
</span>
{userRoles.map((r) => (
<span
key={r.roleId}
className="inline-flex items-center px-1.5 py-0.5 text-[10px] font-semibold border border-border bg-muted text-muted-foreground w-fit"
>
{r.roleDisplayName}
<span className="text-sm font-semibold mt-0.5">
{username ?? "—"}
</span>
</div>
<div className="flex flex-col py-2">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
{t("newUi.sidebar.userProfile.roleLabel")}
</span>
<div className="flex flex-wrap gap-1 mt-0.5">
<span className="inline-flex items-center px-1.5 py-0.5 text-[10px] font-semibold border border-accent-brand/40 bg-accent-brand/10 text-accent-brand w-fit">
{userRole || "—"}
</span>
))}
{userRoles.map((r) => (
<span
key={r.roleId}
className="inline-flex items-center px-1.5 py-0.5 text-[10px] font-semibold border border-border bg-muted text-muted-foreground w-fit"
>
{r.roleDisplayName}
</span>
))}
</div>
</div>
<div className="flex flex-col py-2">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
{t("newUi.sidebar.userProfile.authMethodLabel")}
</span>
<span className="text-sm font-semibold mt-0.5">
{authMethod || "—"}
</span>
</div>
<div className="flex flex-col py-2">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
{t("newUi.sidebar.userProfile.twoFaLabel")}
</span>
<span className="flex items-center gap-1 mt-0.5">
{totpEnabled ? (
<>
<ShieldCheck className="size-3.5 text-accent-brand" />
<span className="text-sm font-semibold text-accent-brand">
{t("newUi.sidebar.userProfile.twoFaOn")}
</span>
</>
) : (
<span className="text-sm font-semibold text-muted-foreground">
{t("newUi.sidebar.userProfile.twoFaOff")}
</span>
)}
</span>
</div>
</div>
<div className="flex flex-col py-2">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
{t("newUi.sidebar.userProfile.authMethodLabel")}
</span>
<span className="text-sm font-semibold mt-0.5">
{authMethod || "—"}
</span>
</div>
<div className="flex flex-col py-2">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
{t("newUi.sidebar.userProfile.twoFaLabel")}
</span>
<span className="flex items-center gap-1 mt-0.5">
{totpEnabled ? (
<>
<ShieldCheck className="size-3.5 text-accent-brand" />
<span className="text-sm font-semibold text-accent-brand">
{t("newUi.sidebar.userProfile.twoFaOn")}
</span>
</>
) : (
<span className="text-sm font-semibold text-muted-foreground">
{t("newUi.sidebar.userProfile.twoFaOff")}
</span>
)}
</span>
</div>
</div>
)}
<div className="border-t border-border pt-3 mt-1">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
@@ -1489,26 +1505,28 @@ export function UserProfilePanel({
</div>
)}
<div className="border-t border-border pt-3 mt-3">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium text-destructive">
{t("newUi.sidebar.userProfile.deleteAccount")}
</span>
<span className="text-[10px] text-muted-foreground">
{t("newUi.sidebar.userProfile.deleteAccountDescription")}
</span>
{!isElectron() && (
<div className="border-t border-border pt-3 mt-3">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium text-destructive">
{t("newUi.sidebar.userProfile.deleteAccount")}
</span>
<span className="text-[10px] text-muted-foreground">
{t("newUi.sidebar.userProfile.deleteAccountDescription")}
</span>
</div>
<Button
variant="outline"
size="sm"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive shrink-0 ml-3 text-[10px] h-7"
onClick={() => setShowDeleteConfirm(true)}
>
{t("newUi.sidebar.userProfile.deleteButton")}
</Button>
</div>
<Button
variant="outline"
size="sm"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive shrink-0 ml-3 text-[10px] h-7"
onClick={() => setShowDeleteConfirm(true)}
>
{t("newUi.sidebar.userProfile.deleteButton")}
</Button>
</div>
</div>
)}
</div>
</AccordionSection>
@@ -2032,8 +2050,11 @@ export function UserProfilePanel({
</div>
</AccordionSection>
{/* Security */}
{/* The embedded desktop backend auto-authenticates its machine-local
profile, so server login controls would imply protection they do
not provide. Remote Sync owns its separate account UI above. */}
<AccordionSection
hidden={isElectron()}
id="security"
label={t("newUi.sidebar.userProfile.sectionSecurity")}
icon={<Shield className="size-3.5" />}
@@ -0,0 +1,101 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
// Panel-level dependencies that are irrelevant to AccordionSection but would
// otherwise be pulled in by importing the module.
vi.mock("@/settings/RemoteSyncPanel.tsx", () => ({
RemoteSyncPanel: () => null,
}));
vi.mock("@/user/C2STunnelPresetManager", () => ({
C2STunnelPresetManager: () => null,
}));
vi.mock("@/i18n/i18n", () => ({
changeAppLanguage: vi.fn(),
normalizeLanguageCode: (code: string) => code,
}));
vi.mock("@/components/theme-provider", () => ({
useTheme: () => ({ theme: "dark", setTheme: vi.fn() }),
}));
vi.mock("@/lib/electron", () => ({ isElectron: () => false }));
import { AccordionSection } from "../../sidebar/UserProfilePanel";
afterEach(cleanup);
function renderSection(hidden: boolean, open = false, onToggle = vi.fn()) {
render(
<AccordionSection
id="security"
label="Security"
icon={<span data-testid="icon" />}
open={open}
onToggle={onToggle}
hidden={hidden}
>
<button>Change password</button>
</AccordionSection>,
);
return onToggle;
}
describe("AccordionSection", () => {
it("renders its header when visible, and its content once expanded", () => {
renderSection(false);
expect(screen.getByText("Security")).toBeTruthy();
expect(screen.queryByText("Change password")).toBeNull();
cleanup();
renderSection(false, true);
expect(screen.getByText("Security")).toBeTruthy();
expect(screen.getByText("Change password")).toBeTruthy();
});
it("renders nothing at all when hidden, even expanded", () => {
renderSection(true, true);
expect(screen.queryByText("Security")).toBeNull();
// The children must not reach the DOM either: on the desktop build this
// section holds password and 2FA controls for an account that signs in
// automatically and has no login password, so merely collapsing it would
// still imply a protection that is not there.
expect(screen.queryByText("Change password")).toBeNull();
});
it("is visible by default when hidden is not passed", () => {
render(
<AccordionSection
id="account"
label="Account"
icon={<span />}
open={false}
onToggle={vi.fn()}
>
<span>body</span>
</AccordionSection>,
);
expect(screen.getByText("Account")).toBeTruthy();
});
it("reports its expanded state and toggles on click", () => {
const onToggle = renderSection(false, false);
const header = screen.getByRole("button", { name: /Security/ });
expect(header.getAttribute("aria-expanded")).toBe("false");
expect(header.getAttribute("aria-controls")).toBe("security-content");
fireEvent.click(header);
expect(onToggle).toHaveBeenCalledOnce();
});
it("cannot be toggled while hidden, since there is no header to click", () => {
const onToggle = renderSection(true, false);
expect(screen.queryByRole("button", { name: /Security/ })).toBeNull();
expect(onToggle).not.toHaveBeenCalled();
});
});