diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 323f63544..93d60ed88 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -36058,6 +36058,18 @@ components: type: string description: Host of the online geolocation provider the fallback would use, so an operator surface names the real one instead of assuming the default. + geoLookup: + type: string + enum: + - offline + - online + - off + description: "How login IPs are placed (since v1.42): `offline` from the GeoLite2 databases, `online` by the provider + above, `off` not at all (`IP_GEO_LOOKUP_DISABLED`)." + geoProviderChosen: + type: boolean + description: The operator named the online provider in `IP_GEO_LOOKUP_URL` (since v1.42). False while the default is in + use. required: - version - buildSha @@ -36068,6 +36080,8 @@ components: - docs - offlineGeoEnabled - geoProviderHost + - geoLookup + - geoProviderChosen additionalProperties: false description: The running build's identity plus the offline-geolocation state. Public — no authentication, no account data. AssistantFlagsEnvelope: diff --git a/messages/de.json b/messages/de.json index b7cc77b50..795904f21 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7292,6 +7292,8 @@ "offlineGeoLabel": "Geodaten", "offlineGeoEnabled": "Offline (GeoLite2)", "offlineGeoFallback": "Online ({host}) — GeoLite2-Datenbanken einbinden und GEOLITE2_DIR setzen", + "offlineGeoOff": "Aus", + "offlineGeoChosen": "Online ({host})", "apiGlobal": "API-Endpunkte und API-Tokens erlauben.", "umamiTitle": "Umami", "umamiDescription": "Web-Analyse mit Umami.", diff --git a/messages/en.json b/messages/en.json index bdea7e6a7..a30ac986b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7292,6 +7292,8 @@ "offlineGeoLabel": "Geo data", "offlineGeoEnabled": "Offline (GeoLite2)", "offlineGeoFallback": "Online ({host}) — mount GeoLite2 databases and set GEOLITE2_DIR", + "offlineGeoOff": "Off", + "offlineGeoChosen": "Online ({host})", "apiGlobal": "Allow API endpoints and API tokens.", "umamiTitle": "Umami", "umamiDescription": "Web analytics with Umami.", diff --git a/messages/es.json b/messages/es.json index 3e9806ff9..c46dc490e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7292,6 +7292,8 @@ "offlineGeoLabel": "Datos geográficos", "offlineGeoEnabled": "Sin conexión (GeoLite2)", "offlineGeoFallback": "En línea ({host}) — monta las bases GeoLite2 y define GEOLITE2_DIR", + "offlineGeoOff": "Desactivado", + "offlineGeoChosen": "En línea ({host})", "apiGlobal": "Permitir endpoints de API y tokens de API.", "umamiTitle": "Umami", "umamiDescription": "Analítica web con Umami.", diff --git a/messages/fr.json b/messages/fr.json index 4ffa3c91a..3b61b97e9 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7292,6 +7292,8 @@ "offlineGeoLabel": "Données géographiques", "offlineGeoEnabled": "Hors ligne (GeoLite2)", "offlineGeoFallback": "En ligne ({host}) — montez les bases GeoLite2 et définissez GEOLITE2_DIR", + "offlineGeoOff": "Désactivé", + "offlineGeoChosen": "En ligne ({host})", "apiGlobal": "Autoriser les points de terminaison d'API et les jetons d'API.", "umamiTitle": "Umami", "umamiDescription": "Analyse web avec Umami.", diff --git a/messages/it.json b/messages/it.json index 7fb859048..62db0cedd 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7292,6 +7292,8 @@ "offlineGeoLabel": "Dati geografici", "offlineGeoEnabled": "Offline (GeoLite2)", "offlineGeoFallback": "Online ({host}) — monta i database GeoLite2 e imposta GEOLITE2_DIR", + "offlineGeoOff": "Disattivato", + "offlineGeoChosen": "Online ({host})", "apiGlobal": "Consentire endpoint API e token API.", "umamiTitle": "Umami", "umamiDescription": "Analisi web con Umami.", diff --git a/messages/ko.json b/messages/ko.json index 3405b2f39..244aa0850 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7268,6 +7268,8 @@ "offlineGeoLabel": "위치 데이터", "offlineGeoEnabled": "오프라인 (GeoLite2)", "offlineGeoFallback": "온라인 ({host}) — GeoLite2 데이터베이스를 마운트하고 GEOLITE2_DIR을 설정하세요", + "offlineGeoOff": "꺼짐", + "offlineGeoChosen": "온라인 ({host})", "apiGlobal": "API 엔드포인트와 API 토큰을 허용해요.", "umamiTitle": "Umami", "umamiDescription": "Umami 웹 분석이에요.", diff --git a/messages/pl.json b/messages/pl.json index 7100d719b..2c66a69ef 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7292,6 +7292,8 @@ "offlineGeoLabel": "Dane geograficzne", "offlineGeoEnabled": "Offline (GeoLite2)", "offlineGeoFallback": "Online ({host}) — podłącz bazy GeoLite2 i ustaw GEOLITE2_DIR", + "offlineGeoOff": "Wyłączone", + "offlineGeoChosen": "Online ({host})", "apiGlobal": "Zezwól na punkty końcowe API i tokeny API.", "umamiTitle": "Umami", "umamiDescription": "Analityka internetowa z Umami.", diff --git a/src/app/api/version/__tests__/route.test.ts b/src/app/api/version/__tests__/route.test.ts index ec988a145..cba8c7961 100644 --- a/src/app/api/version/__tests__/route.test.ts +++ b/src/app/api/version/__tests__/route.test.ts @@ -9,6 +9,8 @@ const resolveGeoProviderHostMock = vi.fn(() => "ipwho.is"); vi.mock("@/lib/geo", () => ({ offlineGeoReady: () => offlineGeoReadyMock(), resolveGeoProviderHost: () => resolveGeoProviderHostMock(), + geoLookupMode: () => (offlineGeoReadyMock() ? "offline" : "online"), + geoProviderChosen: () => false, })); import { GET } from "../route"; diff --git a/src/app/api/version/route.ts b/src/app/api/version/route.ts index 01177527e..f737480be 100644 --- a/src/app/api/version/route.ts +++ b/src/app/api/version/route.ts @@ -1,6 +1,11 @@ import { apiHandler } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; -import { offlineGeoReady, resolveGeoProviderHost } from "@/lib/geo"; +import { + geoLookupMode, + geoProviderChosen, + offlineGeoReady, + resolveGeoProviderHost, +} from "@/lib/geo"; import packageJson from "../../../../package.json"; // v1.4.27 R5 — `offlineGeoEnabled` reads from the same source the geo @@ -64,5 +69,7 @@ export const GET = apiHandler(async () => { docs: "https://docs.healthlog.dev", offlineGeoEnabled, geoProviderHost, + geoLookup: geoLookupMode(), + geoProviderChosen: geoProviderChosen(), }); }); diff --git a/src/components/admin/__tests__/geo-lookup-state.test.ts b/src/components/admin/__tests__/geo-lookup-state.test.ts new file mode 100644 index 000000000..17da78f9c --- /dev/null +++ b/src/components/admin/__tests__/geo-lookup-state.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { geoLookupState, type PublicVersion } from "../_shared"; + +const base: PublicVersion = { + version: "1.42.0", + buildSha: null, + builtAt: null, +}; + +describe("geoLookupState", () => { + it("warns only about the unconfigured default provider", () => { + expect( + geoLookupState({ + ...base, + offlineGeoEnabled: false, + geoLookup: "online", + }), + ).toBe("default"); + }); + + it("treats a provider the operator named as a decision", () => { + expect( + geoLookupState({ + ...base, + offlineGeoEnabled: false, + geoLookup: "online", + geoProviderChosen: true, + }), + ).toBe("chosen"); + }); + + it("reads a switched-off lookup and the offline databases as settled", () => { + expect( + geoLookupState({ ...base, offlineGeoEnabled: false, geoLookup: "off" }), + ).toBe("off"); + expect(geoLookupState({ ...base, offlineGeoEnabled: true })).toBe( + "offline", + ); + }); +}); diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx index 5335e3c6b..4c3855915 100644 --- a/src/components/admin/_shared.tsx +++ b/src/components/admin/_shared.tsx @@ -590,6 +590,23 @@ export interface PublicVersion { // offline tier is absent (host of IP_GEO_LOOKUP_URL, default ipwho.is). // Optional so legacy /api/version responses still satisfy the shape. geoProviderHost?: string; + /** v1.42 — `offline`, `online` or `off` (IP_GEO_LOOKUP_DISABLED). */ + geoLookup?: "offline" | "online" | "off"; + /** v1.42 — the operator named the provider in IP_GEO_LOOKUP_URL. */ + geoProviderChosen?: boolean; +} + +/** + * The geo row's state. Offline, switched off, or a provider the operator + * named are all decisions and read as fine; only the unconfigured default + * warns, because it sends every login IP to a third party nobody chose. + */ +export function geoLookupState( + version: PublicVersion, +): "offline" | "off" | "chosen" | "default" { + if (version.offlineGeoEnabled) return "offline"; + if (version.geoLookup === "off") return "off"; + return version.geoProviderChosen ? "chosen" : "default"; } /** Shared `/api/version` reader — the system-status section + overview diff --git a/src/components/admin/system-status-section.tsx b/src/components/admin/system-status-section.tsx index a8a49569f..334a83e2d 100644 --- a/src/components/admin/system-status-section.tsx +++ b/src/components/admin/system-status-section.tsx @@ -23,6 +23,7 @@ import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { + geoLookupState, StatusItem, usePublicVersion, useSystemStatus, @@ -245,13 +246,20 @@ export function SystemStatusSection() { icon={Map} label={t("admin.offlineGeoLabel")} value={ - version.offlineGeoEnabled - ? t("admin.offlineGeoEnabled") - : t("admin.offlineGeoFallback", { - host: version.geoProviderHost ?? "ipwho.is", - }) + { + offline: t("admin.offlineGeoEnabled"), + off: t("admin.offlineGeoOff"), + chosen: t("admin.offlineGeoChosen", { + host: version.geoProviderHost ?? "ipwho.is", + }), + default: t("admin.offlineGeoFallback", { + host: version.geoProviderHost ?? "ipwho.is", + }), + }[geoLookupState(version)] + } + tone={ + geoLookupState(version) === "default" ? "warning" : "success" } - tone={version.offlineGeoEnabled ? "success" : "warning"} /> )} diff --git a/src/components/admin/system-status-summary.tsx b/src/components/admin/system-status-summary.tsx index 0eb76b6c9..19ae088dd 100644 --- a/src/components/admin/system-status-summary.tsx +++ b/src/components/admin/system-status-summary.tsx @@ -26,7 +26,12 @@ import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { QueryErrorRow } from "@/components/ui/query-error-row"; import { useTranslations } from "@/lib/i18n/context"; -import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared"; +import { + geoLookupState, + StatusItem, + usePublicVersion, + useSystemStatus, +} from "./_shared"; import { useKeyBackupStatus } from "./use-key-backup-status"; export function SystemStatusSummary() { @@ -95,13 +100,20 @@ export function SystemStatusSummary() { icon={Globe} label={t("admin.overview.snapshotOfflineGeo")} value={ - version.offlineGeoEnabled - ? t("admin.overview.snapshotOfflineGeoOn") - : t("admin.overview.snapshotOfflineGeoOff", { - host: version.geoProviderHost ?? "ipwho.is", - }) + { + offline: t("admin.overview.snapshotOfflineGeoOn"), + off: t("admin.offlineGeoOff"), + chosen: t("admin.offlineGeoChosen", { + host: version.geoProviderHost ?? "ipwho.is", + }), + default: t("admin.overview.snapshotOfflineGeoOff", { + host: version.geoProviderHost ?? "ipwho.is", + }), + }[geoLookupState(version)] + } + tone={ + geoLookupState(version) === "default" ? "warning" : "success" } - tone={version.offlineGeoEnabled ? "success" : "warning"} /> )} diff --git a/src/lib/geo.ts b/src/lib/geo.ts index 775cbc41e..b8c647e14 100644 --- a/src/lib/geo.ts +++ b/src/lib/geo.ts @@ -169,6 +169,24 @@ export function resolveGeoProviderHost(): string { } } +/** + * How login IPs are placed on this host: from the mounted GeoLite2 databases, + * by the online provider, or not at all (`IP_GEO_LOOKUP_DISABLED`). + */ +export function geoLookupMode(): "offline" | "online" | "off" { + if (offlineGeoReady()) return "offline"; + return envFlag("IP_GEO_LOOKUP_DISABLED") ? "off" : "online"; +} + +/** + * Whether the operator named the online provider (`IP_GEO_LOOKUP_URL`). + * An explicit provider is a decision about where login IPs go; the default + * is not, so the admin status only warns about the default. + */ +export function geoProviderChosen(): boolean { + return Boolean(process.env.IP_GEO_LOOKUP_URL?.trim()); +} + /** * Directory the offline MMDBs are read from. * diff --git a/src/lib/openapi/routes/meta.ts b/src/lib/openapi/routes/meta.ts index 088cb1032..864da8252 100644 --- a/src/lib/openapi/routes/meta.ts +++ b/src/lib/openapi/routes/meta.ts @@ -239,6 +239,16 @@ const versionResponse = z .describe( "Host of the online geolocation provider the fallback would use, so an operator surface names the real one instead of assuming the default.", ), + geoLookup: z + .enum(["offline", "online", "off"]) + .describe( + "How login IPs are placed (since v1.42): `offline` from the GeoLite2 databases, `online` by the provider above, `off` not at all (`IP_GEO_LOOKUP_DISABLED`).", + ), + geoProviderChosen: z + .boolean() + .describe( + "The operator named the online provider in `IP_GEO_LOOKUP_URL` (since v1.42). False while the default is in use.", + ), }) .meta({ id: "VersionResponse",