From aae65b8bb14ada6aedb3a9e2ca1b12704e182f77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 16:56:20 +0200 Subject: [PATCH 1/5] feat(geo): default IP geolocation to the online ipwho.is resolver Make the keyless ipwho.is HTTPS lookup the primary resolver for every self-host. The bundled MaxMind GeoLite2 offline tier becomes an optional fallback, consulted only when the online lookup misses or when egress is disabled. A missing offline tier is now the expected baseline rather than a gap, so the one-shot admin alert fires only when neither resolver can produce a location. --- src/lib/__tests__/geo-asn.test.ts | 138 ++++++++++++++++++++---------- src/lib/geo.ts | 55 ++++++------ 2 files changed, 122 insertions(+), 71 deletions(-) diff --git a/src/lib/__tests__/geo-asn.test.ts b/src/lib/__tests__/geo-asn.test.ts index 0664b16a5..6a59dfc7b 100644 --- a/src/lib/__tests__/geo-asn.test.ts +++ b/src/lib/__tests__/geo-asn.test.ts @@ -16,7 +16,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; * - Reader throws are swallowed (the helper is called from a * fire-and-forget audit-log path; a thrown error would surface as * an unhandled rejection). - * - The offline-first `lookupIpLocation` path picks the German city + * - The online-first `lookupIpLocation` path (v1.18.10 W7) resolves via + * `ipwho.is` by default and only consults the offline MMDB as a + * fallback (online miss or egress disabled), picking the German city * name first, English second, with the country ISO code from * `country` or `registered_country`. * @@ -171,57 +173,29 @@ describe("lookupIpAsn — offline ASN resolver (v1.4.27 B3)", () => { }); }); -describe("lookupIpLocation — offline-first city resolver (v1.4.27 B3)", () => { - it("prefers the German exonym from the MMDB Names record", async () => { +// v1.18.10 (W7) — online-first resolver. The `ipwho.is` HTTPS lookup is the +// DEFAULT path; the bundled GeoLite2 offline tier is an OPTIONAL fallback +// consulted only when the online lookup misses (provider down / non-ok) or +// when egress is disabled via `IP_GEO_LOOKUP_DISABLED=1`. +describe("lookupIpLocation — online-first city resolver (v1.18.10 W7)", () => { + // Helper: a non-ok online response forces the offline fallback to run. + function onlineMiss(): Response { + return new Response("", { + status: 503, + headers: { "content-type": "application/json" }, + }); + } + + it("prefers the online provider even when the offline DB is present", async () => { readerState.cityExists = true; readerState.city = () => ({ city: { names: { de: "München", en: "Munich" } }, country: { iso_code: "DE" }, }); - const fetchSpy = vi.fn(); - vi.stubGlobal("fetch", fetchSpy); - const { lookupIpLocation } = await import("../geo"); - - expect(await lookupIpLocation("85.214.0.1")).toBe("München, DE"); - // Offline hit — no online fallback was needed. - expect(fetchSpy).not.toHaveBeenCalled(); - }); - - it("falls back to the English city name when the German exonym is absent", async () => { - readerState.cityExists = true; - readerState.city = () => ({ - city: { names: { en: "Birmingham" } }, - country: { iso_code: "GB" }, - }); - const fetchSpy = vi.fn(); - vi.stubGlobal("fetch", fetchSpy); - const { lookupIpLocation } = await import("../geo"); - - expect(await lookupIpLocation("212.58.224.1")).toBe("Birmingham, GB"); - }); - - it("accepts the registered_country ISO when country is absent", async () => { - readerState.cityExists = true; - readerState.city = () => ({ - city: { names: { en: "Geneva" } }, - registered_country: { iso_code: "CH" }, - }); - const { lookupIpLocation } = await import("../geo"); - - expect(await lookupIpLocation("194.158.0.1")).toBe("Geneva, CH"); - }); - - it("falls back to the online provider when the offline DB has no city/country", async () => { - readerState.cityExists = true; - readerState.city = () => ({ city: { names: {} } }); const fetchSpy = vi.fn().mockResolvedValue( new Response( new TextEncoder().encode( - JSON.stringify({ - success: true, - city: "Hamburg", - country_code: "DE", - }), + JSON.stringify({ success: true, city: "Berlin", country_code: "DE" }), ).buffer as ArrayBuffer, { status: 200, @@ -232,11 +206,64 @@ describe("lookupIpLocation — offline-first city resolver (v1.4.27 B3)", () => vi.stubGlobal("fetch", fetchSpy); const { lookupIpLocation } = await import("../geo"); - expect(await lookupIpLocation("8.8.8.8")).toBe("Hamburg, DE"); + // Online wins by default; the offline München record is not consulted. + expect(await lookupIpLocation("85.214.0.1")).toBe("Berlin, DE"); expect(fetchSpy).toHaveBeenCalledTimes(1); }); - it("falls back to the online provider when the offline DB is missing entirely", async () => { + it("falls back to the offline German exonym when the online lookup misses", async () => { + readerState.cityExists = true; + readerState.city = () => ({ + city: { names: { de: "München", en: "Munich" } }, + country: { iso_code: "DE" }, + }); + const fetchSpy = vi.fn().mockResolvedValue(onlineMiss()); + vi.stubGlobal("fetch", fetchSpy); + const { lookupIpLocation } = await import("../geo"); + + expect(await lookupIpLocation("85.214.0.1")).toBe("München, DE"); + // Online was tried first (and missed), then the offline tier resolved. + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("offline fallback uses the English city name when the German exonym is absent", async () => { + readerState.cityExists = true; + readerState.city = () => ({ + city: { names: { en: "Birmingham" } }, + country: { iso_code: "GB" }, + }); + const fetchSpy = vi.fn().mockResolvedValue(onlineMiss()); + vi.stubGlobal("fetch", fetchSpy); + const { lookupIpLocation } = await import("../geo"); + + expect(await lookupIpLocation("212.58.224.1")).toBe("Birmingham, GB"); + }); + + it("offline fallback accepts the registered_country ISO when country is absent", async () => { + readerState.cityExists = true; + readerState.city = () => ({ + city: { names: { en: "Geneva" } }, + registered_country: { iso_code: "CH" }, + }); + const fetchSpy = vi.fn().mockResolvedValue(onlineMiss()); + vi.stubGlobal("fetch", fetchSpy); + const { lookupIpLocation } = await import("../geo"); + + expect(await lookupIpLocation("194.158.0.1")).toBe("Geneva, CH"); + }); + + it("returns null when both online and the offline DB miss", async () => { + readerState.cityExists = true; + readerState.city = () => ({ city: { names: {} } }); + const fetchSpy = vi.fn().mockResolvedValue(onlineMiss()); + vi.stubGlobal("fetch", fetchSpy); + const { lookupIpLocation } = await import("../geo"); + + expect(await lookupIpLocation("8.8.8.8")).toBeNull(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("resolves online when the offline DB is missing entirely", async () => { readerState.cityExists = false; const fetchSpy = vi.fn().mockResolvedValue( new Response( @@ -256,7 +283,24 @@ describe("lookupIpLocation — offline-first city resolver (v1.4.27 B3)", () => expect(fetchSpy).toHaveBeenCalledTimes(1); }); - it("skips the online fallback when IP_GEO_LOOKUP_DISABLED=1 and offline misses", async () => { + it("uses the offline tier when IP_GEO_LOOKUP_DISABLED=1 and the offline DB is present", async () => { + process.env.IP_GEO_LOOKUP_DISABLED = "1"; + readerState.cityExists = true; + readerState.city = () => ({ + city: { names: { de: "München" } }, + country: { iso_code: "DE" }, + }); + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + const { lookupIpLocation } = await import("../geo"); + + // Egress disabled → online short-circuits to null without a request, then + // the offline tier resolves. + expect(await lookupIpLocation("85.214.0.1")).toBe("München, DE"); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("returns null when IP_GEO_LOOKUP_DISABLED=1 and the offline DB is missing", async () => { process.env.IP_GEO_LOOKUP_DISABLED = "1"; readerState.cityExists = false; const fetchSpy = vi.fn(); diff --git a/src/lib/geo.ts b/src/lib/geo.ts index 70d8ff6ce..d7326b0e0 100644 --- a/src/lib/geo.ts +++ b/src/lib/geo.ts @@ -1,19 +1,18 @@ /** * IP geolocation lookup for audit-log enrichment. * - * v1.15.12 E2: online-first resolver. The baseline that always works is - * the `ipwho.is` HTTPS lookup (free, no key) — every self-host resolves - * a location out of the box with no MaxMind licence configured. The + * v1.18.10 (W7): online-first resolver. The `ipwho.is` HTTPS lookup + * (free, no key) is the DEFAULT path — every self-host resolves a + * location out of the box with no MaxMind licence configured. The * bundled MaxMind GeoLite2-City MMDB at `/opt/geolite2/GeoLite2-City.mmdb` - * is an OPTIONAL upgrade: when the offline databases are present - * (`offlineGeoReady()`), the resolver prefers the microsecond-scale - * local read for both speed and zero egress; when they are absent it - * goes straight online. + * is an OPTIONAL fallback (perspective: removed later): it is consulted + * only when the online lookup misses (provider down / rate-limited) or + * when egress is disabled via `IP_GEO_LOOKUP_DISABLED=1`. * * 1. Private / loopback / opt-out → null (no lookup). * 2. Per-IP cache hit → return immediately. - * 3. Offline MMDB present → local read; on a miss fall through online. - * 4. Otherwise → online `ipwho.is` lookup. + * 3. Online `ipwho.is` lookup → return on success. + * 4. Offline MMDB present → local read fallback on an online miss. * * The legacy contract still holds: `lookupIpLocation` returns a * `"City, CC"` string or `null`, never throws. `lookupIpAsn` is the @@ -369,31 +368,39 @@ export async function lookupIpLocation( const cached = getCachedLocation(ip); if (cached) return cached.value; - // v1.15.12 E2 — online-first precedence. The optional MaxMind offline - // tier is preferred ONLY when its databases are actually present - // (`offlineGeoReady()`): a local hit is faster and egress-free. When - // the offline DBs are absent — the default for a self-host with no - // `MAXMIND_LICENSE_KEY` — we skip straight to the online provider, - // which is the baseline that always resolves a location. + // v1.18.10 (W7) — online-first by default. The `ipwho.is` HTTPS lookup + // is now the primary resolver for every self-host: it needs no MaxMind + // licence and resolves a location out of the box. The bundled GeoLite2 + // offline tier is an OPTIONAL fallback kept only for the egress-disabled + // case (`IP_GEO_LOOKUP_DISABLED=1`) or for an online miss when the DBs + // happen to be present — it is no longer the default path, and a missing + // offline tier is the expected baseline rather than a gap to alert on. + const online = await lookupIpLocationOnline(ip); + if (online) { + setCachedLocation(ip, online); + return online; + } + + // Online missed (provider down, rate-limited, or egress disabled). Fall + // back to the offline MMDB if the operator configured one. if (offlineGeoReady()) { const offline = lookupIpLocationOffline(ip); if (offline) { setCachedLocation(ip, offline); return offline; } - // Offline DBs present but this IP missed (freshly-allocated range - // lagging the monthly GeoLite2 roll) → fall through to online. } else { - // No offline tier configured — fire the one-shot admin alert so the - // maintainer can wire `MAXMIND_LICENSE_KEY` for the offline upgrade - // when convenient. Online still resolves; the alert is informational. - // Fire-and-forget so the audit-log path stays fast. + // Neither resolver produced a location: the online lookup missed (or + // egress is disabled) AND no offline tier is configured. That is the + // only genuine "no resolver at all" gap — fire the one-shot admin alert + // so the maintainer can wire `MAXMIND_LICENSE_KEY` or re-enable egress. + // The happy path (ipwho.is resolving by default) never reaches here, so + // a self-host without the offline DBs is not nagged on every lookup. void notifyOfflineGeoUnavailable(); } - const online = await lookupIpLocationOnline(ip); - setCachedLocation(ip, online); - return online; + setCachedLocation(ip, null); + return null; } /** From ed99d207e426dc8365e984ab5d48978a286ba582 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 16:56:42 +0200 Subject: [PATCH 2/5] fix(settings): align the Vorsorge settings card with the shared pattern Adopt the standard SettingsCard + SettingsCardHeader contract for the three Vorsorge settings groups (view, order, manage): bordered card surface, neutral muted section icon, text-lg title, and a short muted description, replacing the bare sections with text-sm headings so the surface reads like every other Settings section. Add a reorder description string across all locales. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + .../vorsorge-settings.tsx | 157 ++++++++++-------- 7 files changed, 90 insertions(+), 73 deletions(-) diff --git a/messages/de.json b/messages/de.json index 17022908c..ee24a0605 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6472,6 +6472,7 @@ "viewDescription": "Lege fest, ob die Einträge als Karten oder als kompakte Liste angezeigt werden.", "reorder": { "heading": "Reihenfolge", + "description": "Ziehe die Einträge, um ihre Reihenfolge festzulegen.", "empty": "Noch keine Einträge zum Sortieren." } }, diff --git a/messages/en.json b/messages/en.json index 2f03a8198..e531d45ae 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6472,6 +6472,7 @@ "viewDescription": "Choose whether entries show as cards or as a compact list.", "reorder": { "heading": "Order", + "description": "Drag the entries to set the order they appear in.", "empty": "No entries to reorder yet." } }, diff --git a/messages/es.json b/messages/es.json index 5612ce688..45552dd4d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6472,6 +6472,7 @@ "viewDescription": "Elige si las entradas se muestran como tarjetas o como lista compacta.", "reorder": { "heading": "Orden", + "description": "Arrastra las entradas para definir el orden en que aparecen.", "empty": "Aún no hay entradas para ordenar." } }, diff --git a/messages/fr.json b/messages/fr.json index fd4b6835b..bd8cfacd8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6472,6 +6472,7 @@ "viewDescription": "Choisissez si les entrées s'affichent en cartes ou en liste compacte.", "reorder": { "heading": "Ordre", + "description": "Faites glisser les entrées pour définir leur ordre d'affichage.", "empty": "Aucune entrée à réordonner pour l'instant." } }, diff --git a/messages/it.json b/messages/it.json index 1b26acae6..a2f2e8def 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6472,6 +6472,7 @@ "viewDescription": "Scegli se le voci appaiono come schede o come elenco compatto.", "reorder": { "heading": "Ordine", + "description": "Trascina le voci per definire l'ordine in cui appaiono.", "empty": "Nessuna voce da riordinare." } }, diff --git a/messages/pl.json b/messages/pl.json index 4b7dc46f0..d0d4ade09 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6472,6 +6472,7 @@ "viewDescription": "Wybierz, czy pozycje mają być pokazywane jako karty czy jako zwarta lista.", "reorder": { "heading": "Kolejność", + "description": "Przeciągnij pozycje, aby ustawić kolejność ich wyświetlania.", "empty": "Brak pozycji do uporządkowania." } }, diff --git a/src/components/measurement-reminders/vorsorge-settings.tsx b/src/components/measurement-reminders/vorsorge-settings.tsx index e710f983c..979394b31 100644 --- a/src/components/measurement-reminders/vorsorge-settings.tsx +++ b/src/components/measurement-reminders/vorsorge-settings.tsx @@ -10,10 +10,14 @@ * `useModuleListPrefs("vorsorge")`; the toggles persist through the existing * reminder PATCH route. */ +import { ArrowUpDown, LayoutGrid, ListChecks } from "lucide-react"; + import { Card, CardContent } from "@/components/ui/card"; import { Switch } from "@/components/ui/switch"; import { Skeleton } from "@/components/ui/skeleton"; import { useTranslations } from "@/lib/i18n/context"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { ModuleViewToggle } from "@/components/module-list/module-view-toggle"; import { ModuleOrderEditor, @@ -52,82 +56,89 @@ export function VorsorgeSettings() { : t("measurementReminders.selfPlanned"), })); + // v1.18.10 (W7) — the three Vorsorge settings groups adopt the shared + // `SettingsCard` + `SettingsCardHeader` contract (rounded-xl bordered + // surface, neutral muted icon, `text-lg` title, short muted description) + // so this surface reads like every other Settings section instead of bare + // `
` blocks with `text-sm` headings. return ( -
-
-
-

- {t("moduleList.viewHeading")} -

- -
-

- {t("moduleList.viewDescription")} -

-
+
+ + } + /> + -
-

- {t("moduleList.reorder.heading")} -

- {isLoading ? ( -
- {Array.from({ length: 3 }, (_, i) => ( - - ))} -
- ) : ( - - )} -
- -
-

- {t("measurementReminders.manage.title")} -

-

- {t("measurementReminders.manage.description")} -

- {isLoading ? ( - - ) : (reminders?.length ?? 0) === 0 ? ( -

- {t("measurementReminders.manage.empty")} -

- ) : ( - - - {ordered.map((reminder) => ( -
- - {resolveLabel(reminder, t)} - - - update.mutate({ - id: reminder.id, - body: { enabled: next }, - }) - } - disabled={update.isPending} - // L3 — label each row's Switch by its own name span so SR - // users hear the reminder name (plus the switch role's - // on/off state) instead of N identical generic labels. - aria-labelledby={`vorsorge-reminder-label-${reminder.id}`} - /> -
+ + +
+ {isLoading ? ( +
+ {Array.from({ length: 3 }, (_, i) => ( + ))} - - - )} -
+
+ ) : ( + + )} +
+ + + + +
+ {isLoading ? ( + + ) : (reminders?.length ?? 0) === 0 ? ( +

+ {t("measurementReminders.manage.empty")} +

+ ) : ( + + + {ordered.map((reminder) => ( +
+ + {resolveLabel(reminder, t)} + + + update.mutate({ + id: reminder.id, + body: { enabled: next }, + }) + } + disabled={update.isPending} + // L3 — label each row's Switch by its own name span so SR + // users hear the reminder name (plus the switch role's + // on/off state) instead of N identical generic labels. + aria-labelledby={`vorsorge-reminder-label-${reminder.id}`} + /> +
+ ))} +
+
+ )} +
+
); } From 17a7d1c74772a75f006b5c56bc0001e3c9dd45ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 16:56:49 +0200 Subject: [PATCH 3/5] fix(admin): neutralise the danger-zone card surface Render the danger-zone section on the shared SettingsCard surface (bg-card + border-border) instead of the destructive-tinted card edge. A red card surface reads as alarming rather than premium; the danger signal now lives only in the destructive delete button and its typed confirmation dialog, with the AlertTriangle header icon still flagging intent. --- src/components/admin/danger-zone-section.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/components/admin/danger-zone-section.tsx b/src/components/admin/danger-zone-section.tsx index 4135d3a4a..923099bde 100644 --- a/src/components/admin/danger-zone-section.tsx +++ b/src/components/admin/danger-zone-section.tsx @@ -17,6 +17,7 @@ import { import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { apiDelete } from "@/lib/api/api-fetch"; @@ -83,13 +84,13 @@ export function DangerZoneSection() { }); return ( - // v1.18.1 E4 — adopt the shared card shape (rounded-xl border + the - // `SettingsCardHeader` icon/title/description/status layout) so the - // Danger Zone reads like every other admin section. The destructive - // tint + AlertTriangle icon keep it visually flagged as dangerous; - // the delete affordance now sits in the header status slot, properly - // aligned beside its label rather than floating below a lone icon. -
+ // v1.18.10 (W7) — the card SURFACE is neutral like every other admin / + // settings section (`SettingsCard`: bg-card + border-border): a red card + // edge reads as alarming, not premium. The danger signal lives ONLY in + // the destructive delete button + its typed-confirmation dialog, which + // are alarm enough. The header keeps the `AlertTriangle` icon so the + // section still flags its intent. + )} -
+ ); } From 5b6544ec637c2e4d7c1810cc7f50f3915ab31ef6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 16:56:55 +0200 Subject: [PATCH 4/5] feat(admin): mark the feedback feature as deprecated Add a discreet 'Deprecated' badge and a short note ('will be removed in a future version') to the bug-report settings entry. The functionality stays intact; this only signals the upcoming removal across all locales. --- messages/de.json | 2 ++ messages/en.json | 2 ++ messages/es.json | 2 ++ messages/fr.json | 2 ++ messages/it.json | 2 ++ messages/pl.json | 2 ++ src/components/admin/bug-report-section.tsx | 22 ++++++++++++++++++++- 7 files changed, 33 insertions(+), 1 deletion(-) diff --git a/messages/de.json b/messages/de.json index ee24a0605..47d5f25f2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4933,6 +4933,8 @@ "bugReportGithubDescription": "Hinterlege GitHub-Issue-Token und Repository für eingehende Bugreports.", "bugReportEnabledLabel": "Bug-Reports aktiviert", "bugReportEnabledDescription": "Wenn deaktiviert, ist der Report-Button im Frontend nicht mehr sichtbar — Token und Repository bleiben gespeichert.", + "bugReportDeprecatedBadge": "Veraltet", + "bugReportDeprecatedNote": "Diese Funktion ist veraltet und wird in einer künftigen Version entfernt.", "assistant": { "title": "Assistenz-Oberflächen", "description": "Jeder Toggle versteckt eine modellgestützte Oberfläche app-weit, ohne die Provider-Konfiguration zu entfernen. Der Hauptschalter erzwingt jede Unter-Option aus.", diff --git a/messages/en.json b/messages/en.json index e531d45ae..d33b013d3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4933,6 +4933,8 @@ "bugReportGithubDescription": "Configure GitHub issue token and repository for incoming bug reports.", "bugReportEnabledLabel": "Bug reports enabled", "bugReportEnabledDescription": "When off, the in-app report button hides — token and repository stay configured.", + "bugReportDeprecatedBadge": "Deprecated", + "bugReportDeprecatedNote": "This feature is deprecated and will be removed in a future version.", "assistant": { "title": "Assistant Surfaces", "description": "Each toggle hides a model-driven surface app-wide without removing the provider configuration. The master kills every sub-flag at once.", diff --git a/messages/es.json b/messages/es.json index 45552dd4d..84096f693 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4933,6 +4933,8 @@ "bugReportGithubDescription": "Configura el token de issues de GitHub y el repositorio para los informes de errores entrantes.", "bugReportEnabledLabel": "Informes de errores activados", "bugReportEnabledDescription": "Cuando está desactivado, el botón de informe en la app se oculta; el token y el repositorio permanecen configurados.", + "bugReportDeprecatedBadge": "Obsoleto", + "bugReportDeprecatedNote": "Esta función está obsoleta y se eliminará en una versión futura.", "assistant": { "title": "Superficies del asistente", "description": "Cada conmutador oculta una superficie impulsada por el modelo en toda la aplicación sin retirar la configuración del proveedor. El interruptor maestro fuerza cada opción secundaria a off.", diff --git a/messages/fr.json b/messages/fr.json index bd8cfacd8..bdedc77d1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4933,6 +4933,8 @@ "bugReportGithubDescription": "Configurez le jeton d'issues GitHub et le dépôt pour les rapports de bug entrants.", "bugReportEnabledLabel": "Rapports de bug activés", "bugReportEnabledDescription": "Lorsque cette option est désactivée, le bouton de signalement dans l'app est masqué — le jeton et le dépôt restent configurés.", + "bugReportDeprecatedBadge": "Obsolète", + "bugReportDeprecatedNote": "Cette fonctionnalité est obsolète et sera supprimée dans une version future.", "assistant": { "title": "Surfaces d'assistance", "description": "Chaque bascule masque une surface pilotée par le modèle dans toute l'application sans retirer la configuration du fournisseur. Le commutateur principal force chaque option secondaire à off.", diff --git a/messages/it.json b/messages/it.json index a2f2e8def..44b1d4ff0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4933,6 +4933,8 @@ "bugReportGithubDescription": "Configura il token delle issue di GitHub e il repository per le segnalazioni di bug in arrivo.", "bugReportEnabledLabel": "Segnalazioni di bug attive", "bugReportEnabledDescription": "Se disattivato, il pulsante di segnalazione nell'app viene nascosto: token e repository restano configurati.", + "bugReportDeprecatedBadge": "Obsoleto", + "bugReportDeprecatedNote": "Questa funzione è obsoleta e verrà rimossa in una versione futura.", "assistant": { "title": "Superfici dell'assistente", "description": "Ogni interruttore nasconde una superficie guidata dal modello in tutta l'app senza rimuovere la configurazione del provider. Il master forza ogni opzione secondaria su off.", diff --git a/messages/pl.json b/messages/pl.json index d0d4ade09..05a1d4942 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4933,6 +4933,8 @@ "bugReportGithubDescription": "Skonfiguruj token zgłoszeń GitHub i repozytorium dla przychodzących zgłoszeń błędów.", "bugReportEnabledLabel": "Zgłaszanie błędów włączone", "bugReportEnabledDescription": "Gdy wyłączone, przycisk zgłaszania w aplikacji jest ukryty — token i repozytorium pozostają skonfigurowane.", + "bugReportDeprecatedBadge": "Wycofane", + "bugReportDeprecatedNote": "Ta funkcja jest wycofywana i zostanie usunięta w przyszłej wersji.", "assistant": { "title": "Powierzchnie asystenta", "description": "Każdy przełącznik ukrywa powierzchnię opartą na modelu w całej aplikacji bez usuwania konfiguracji dostawcy. Główny wyłącznik wymusza wyłączenie każdej opcji podrzędnej.", diff --git a/src/components/admin/bug-report-section.tsx b/src/components/admin/bug-report-section.tsx index 36c4c7275..7bbad77b9 100644 --- a/src/components/admin/bug-report-section.tsx +++ b/src/components/admin/bug-report-section.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { Bug, Loader2 } from "lucide-react"; import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -54,7 +55,26 @@ export function BugReportSection() { + {t("admin.bugReportDeprecatedBadge")} + + } + description={ + <> +

{t("admin.bugReportGithubDescription")}

+

+ {t("admin.bugReportDeprecatedNote")} +

+ + } status={configured ? : null} /> From 6ee7df30986a463f6b309a199683e7ec24669b95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 20 Jun 2026 16:57:01 +0200 Subject: [PATCH 5/5] fix(nav): keep mobile More-menu labels on one line Trim the longest label ('Benachrichtigungen') with truncate (nowrap + ellipsis) instead of break-words so every More-hub tile reads on a single line on a phone, matching its neighbours. The full label stays discoverable via the title attribute; the desktop layout is untouched. --- src/components/layout/bottom-nav.tsx | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx index f2653962d..b75121493 100644 --- a/src/components/layout/bottom-nav.tsx +++ b/src/components/layout/bottom-nav.tsx @@ -247,15 +247,18 @@ export function BottomNav() { )} >