fix(dashboard): conceal locked hidden badge details (#11605)

Merged via /merge-batch (lote 2026-08-26 batch 2, v3.8.51). Boarded no worktree combinado junto com outras ~20 PRs; validação única: typecheck/complexity/cognitive-complexity/changelog-integrity verdes, file-size rebaseado onde necessário (crescimento legítimo), lint com os mesmos 228 achados pré-existentes confirmados via sonda contra o tip puro (não introduzidos por este lote), e 292 testes focados (unit) + 18 (vitest) passando. Obrigado pela contribuição.
This commit is contained in:
Paco Cartones
2026-08-26 14:22:22 +02:00
committed by GitHub
parent c4d149c2eb
commit 9237a0562f
3 changed files with 120 additions and 3 deletions

View File

@@ -0,0 +1 @@
- **fix(dashboard):** Prevent locked hidden badges from revealing their icon or opening private badge details before they are earned ([#11605](https://github.com/diegosouzapw/OmniRoute/pull/11605)) — thanks @pacocartones

View File

@@ -249,21 +249,23 @@ export default function ProfilePage() {
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{allBadges.map((badge) => {
const isEarned = earnedIds.has(badge.id);
const isHiddenAndLocked = Boolean(badge.hidden) && !isEarned;
const earnedInfo = earnedBadges.find((b) => b.badgeId === badge.id);
const rarityColor = RARITY_COLORS[badge.rarity] || RARITY_COLORS.common;
return (
<button
key={badge.id}
onClick={() => setSelectedBadge(badge)}
onClick={() => !isHiddenAndLocked && setSelectedBadge(badge)}
disabled={isHiddenAndLocked}
className={`relative p-4 rounded-xl border transition-all text-left ${
isEarned
? `${rarityColor} bg-surface hover:shadow-md`
: "border-border/50 bg-surface/50 opacity-50 grayscale hover:opacity-70"
: "border-border/50 bg-surface/50 opacity-50 grayscale enabled:hover:opacity-70 disabled:cursor-default"
}`}
>
<div className="text-3xl mb-2">
<BadgeIcon icon={badge.icon} earned={isEarned} />
<BadgeIcon icon={isHiddenAndLocked ? null : badge.icon} earned={isEarned} />
</div>
<p className="font-semibold text-sm truncate">
{badge.hidden && !isEarned ? "???" : translateBadge(badge, "name")}

View File

@@ -0,0 +1,114 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
const translate = (key: string) => key;
vi.mock("next-intl", () => ({
useLocale: () => "en",
useTranslations: () => Object.assign(translate, { has: () => false }),
}));
const { default: ProfilePage } = await import("@/app/(dashboard)/dashboard/profile/page");
const roots: Array<{ root: ReturnType<typeof createRoot>; container: HTMLDivElement }> = [];
async function renderProfile(earned = false) {
const hiddenBadge = {
id: "secret-badge",
name: "Secret Badge Name",
description: "Secret badge description",
icon: "rocket",
category: "secret-category",
rarity: "legendary",
criteria: '{"type":"secret-condition"}',
hidden: 1,
createdAt: "2026-08-26T00:00:00.000Z",
};
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/level")) {
return { ok: true, json: async () => ({ level: { totalXp: 0, currentLevel: 1 } }) };
}
if (url.endsWith("/earned")) {
return {
ok: true,
json: async () => ({
badges: earned
? [{ badgeId: hiddenBadge.id, unlockedAt: "2026-08-26T00:00:00.000Z" }]
: [],
}),
};
}
return { ok: true, json: async () => ({ badges: [hiddenBadge] }) };
})
);
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push({ root, container });
act(() => root.render(<ProfilePage />));
for (let i = 0; i < 40 && !container.querySelector("button"); i++) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
}
return container;
}
afterEach(() => {
for (const { root, container } of roots.splice(0)) {
act(() => root.unmount());
container.remove();
}
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("Profile hidden badge confidentiality", () => {
it("does not disclose locked hidden badge metadata when activated", async () => {
const container = await renderProfile();
const badgeButton = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("???")
);
expect(badgeButton).toBeDefined();
expect(container.textContent).not.toContain("rocket_launch");
await act(async () => {
badgeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).not.toContain("Secret Badge Name");
expect(container.textContent).not.toContain("Secret badge description");
expect(container.textContent).not.toContain("secret-category");
expect(container.textContent).not.toContain("legendary");
expect(container.textContent).not.toContain("secret-condition");
});
it("reveals an earned hidden badge normally", async () => {
const container = await renderProfile(true);
const badgeButton = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Secret Badge Name")
);
expect(badgeButton).toBeDefined();
expect((badgeButton as HTMLButtonElement).disabled).toBe(false);
await act(async () => {
badgeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("secret-category");
expect(container.textContent).toContain("rocket_launch");
expect(container.textContent).toContain("Secret Badge Name");
expect(container.textContent).toContain("Secret badge description");
expect(container.textContent).toContain("legendary");
});
});