Merge w10/settingsmisc into release/v1.18.10

Align the Vorsorge settings card to the canonical pattern, neutralize the danger
zone card, mark the bug-report feature deprecated, make ipwho.is the default geo
resolver, and keep the mobile More-hub labels on one line.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 16:57:55 +02:00
commit 28dd3b10af
12 files changed
+266 -162

No files matched your search

+3
View File
@@ -4934,6 +4934,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.",
@@ -6473,6 +6475,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."
}
},
+3
View File
@@ -4934,6 +4934,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.",
@@ -6473,6 +6475,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."
}
},
+3
View File
@@ -4934,6 +4934,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.",
@@ -6473,6 +6475,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."
}
},
+3
View File
@@ -4934,6 +4934,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.",
@@ -6473,6 +6475,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."
}
},
+3
View File
@@ -4934,6 +4934,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.",
@@ -6473,6 +6475,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."
}
},
+3
View File
@@ -4934,6 +4934,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.",
@@ -6473,6 +6475,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."
}
},
+21 -1
View File
@@ -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() {
<SettingsCardHeader
icon={Bug}
title={t("admin.bugReportGithub")}
description={t("admin.bugReportGithubDescription")}
titleAccessory={
// v1.18.10 (W7) — the feedback / bug-report feature is deprecated.
// The badge + note signal the upcoming removal without pulling the
// functionality yet, so existing operators keep the surface working.
<Badge
variant="outline"
data-testid="bug-report-deprecated-badge"
className="border-amber-500/50 text-amber-600 dark:text-amber-400"
>
{t("admin.bugReportDeprecatedBadge")}
</Badge>
}
description={
<>
<p>{t("admin.bugReportGithubDescription")}</p>
<p className="text-amber-600 dark:text-amber-400">
{t("admin.bugReportDeprecatedNote")}
</p>
</>
}
status={configured ? <ConfiguredBadge /> : null}
/>
+9 -8
View File
@@ -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.
<div className="bg-destructive/5 border-destructive/30 rounded-xl border p-4 sm:p-6">
// 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.
<SettingsCard>
<SettingsCardHeader
icon={AlertTriangle}
title={t("admin.deleteAllData")}
@@ -176,6 +177,6 @@ export function DangerZoneSection() {
{wipeMsg}
</p>
)}
</div>
</SettingsCard>
);
}
+12 -9
View File
@@ -247,15 +247,18 @@ export function BottomNav() {
)}
>
<item.icon className="h-5 w-5 shrink-0" aria-hidden="true" />
{/* v1.18.0 — the label must stay inside the tile across all
six locales. The longest single-word label (German
"Benachrichtigungen") has no natural break opportunity, so
`min-w-0` alone is not enough — the unbroken word forces the
span past the grid track and overflows the tile. `min-w-0`
lets the flex child shrink below its content width,
`break-words` introduces a break inside the long word, and
the auto-height tile (`min-h-14`) absorbs the second line. */}
<span className="min-w-0 text-sm leading-tight font-medium break-words">
{/* v1.18.10 (W7) — keep every label on ONE line. The longest
single-word label (German "Benachrichtigungen") used to
wrap to two lines via `break-words`, which left the tile
visually unbalanced beside its single-line neighbours.
`min-w-0` lets the flex child shrink below its content
width and `truncate` (nowrap + ellipsis) trims the
overflow instead of breaking the word. `title` keeps the
full label discoverable on hover/long-press. */}
<span
className="min-w-0 truncate text-sm leading-tight font-medium"
title={t(item.tKey)}
>
{t(item.tKey)}
</span>
</Link>
@@ -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
// `<section>` blocks with `text-sm` headings.
return (
<div className="space-y-8">
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold">
{t("moduleList.viewHeading")}
</h2>
<ModuleViewToggle view={prefs.view} onChange={setView} />
</div>
<p className="text-muted-foreground text-sm">
{t("moduleList.viewDescription")}
</p>
</section>
<div className="space-y-6">
<SettingsCard>
<SettingsCardHeader
icon={LayoutGrid}
title={t("moduleList.viewHeading")}
description={t("moduleList.viewDescription")}
status={<ModuleViewToggle view={prefs.view} onChange={setView} />}
/>
</SettingsCard>
<section className="space-y-3">
<h2 className="text-sm font-semibold">
{t("moduleList.reorder.heading")}
</h2>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 3 }, (_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : (
<ModuleOrderEditor items={reorderItems} onChange={setOrder} />
)}
</section>
<section className="space-y-3">
<h2 className="text-sm font-semibold">
{t("measurementReminders.manage.title")}
</h2>
<p className="text-muted-foreground text-sm">
{t("measurementReminders.manage.description")}
</p>
{isLoading ? (
<Skeleton className="h-24 w-full" />
) : (reminders?.length ?? 0) === 0 ? (
<p className="text-muted-foreground text-sm">
{t("measurementReminders.manage.empty")}
</p>
) : (
<Card>
<CardContent className="divide-border divide-y p-0">
{ordered.map((reminder) => (
<div
key={reminder.id}
className="flex items-center justify-between gap-3 px-4 py-3"
>
<span
id={`vorsorge-reminder-label-${reminder.id}`}
className="min-w-0 truncate text-sm"
>
{resolveLabel(reminder, t)}
</span>
<Switch
checked={reminder.enabled}
onCheckedChange={(next) =>
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}`}
/>
</div>
<SettingsCard>
<SettingsCardHeader
icon={ArrowUpDown}
title={t("moduleList.reorder.heading")}
description={t("moduleList.reorder.description")}
/>
<div className="mt-4 pl-7">
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 3 }, (_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
)}
</section>
</div>
) : (
<ModuleOrderEditor items={reorderItems} onChange={setOrder} />
)}
</div>
</SettingsCard>
<SettingsCard>
<SettingsCardHeader
icon={ListChecks}
title={t("measurementReminders.manage.title")}
description={t("measurementReminders.manage.description")}
/>
<div className="mt-4 pl-7">
{isLoading ? (
<Skeleton className="h-24 w-full" />
) : (reminders?.length ?? 0) === 0 ? (
<p className="text-muted-foreground text-sm">
{t("measurementReminders.manage.empty")}
</p>
) : (
<Card>
<CardContent className="divide-border divide-y p-0">
{ordered.map((reminder) => (
<div
key={reminder.id}
className="flex items-center justify-between gap-3 px-4 py-3"
>
<span
id={`vorsorge-reminder-label-${reminder.id}`}
className="min-w-0 truncate text-sm"
>
{resolveLabel(reminder, t)}
</span>
<Switch
checked={reminder.enabled}
onCheckedChange={(next) =>
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}`}
/>
</div>
))}
</CardContent>
</Card>
)}
</div>
</SettingsCard>
</div>
);
}
+91 -47
View File
@@ -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();
+31 -24
View File
@@ -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;
}
/**