diff --git a/messages/de.json b/messages/de.json index 621dd6bfc..0d3c2cedd 100644 --- a/messages/de.json +++ b/messages/de.json @@ -873,6 +873,46 @@ "settings": { "title": "Einstellungen", "subtitle": "Profil, Sicherheit & Verbindungen", + "shell": { + "sectionsNav": "Einstellungs-Bereiche" + }, + "sections": { + "account": { + "title": "Konto", + "description": "Profil, Anmeldemethoden und Konto-Löschung." + }, + "integrations": { + "title": "Integrationen", + "description": "Withings, moodLog und weitere verbundene Dienste." + }, + "notifications": { + "title": "Benachrichtigungen", + "description": "Telegram, ntfy, Web Push." + }, + "dashboard": { + "title": "Übersicht", + "description": "Kachel-Layout, Schwellwerte." + }, + "ai": { + "title": "KI-Auswertungen", + "description": "Provider, Modell, API-Key." + }, + "api": { + "title": "API & Tokens", + "description": "Bearer-Tokens für Drittanbieter-Clients." + }, + "advanced": { + "title": "Erweitert", + "description": "Export, Import, Daten löschen." + }, + "about": { + "title": "Über", + "description": "Version, Lizenz, Links." + }, + "placeholder": { + "coming_soon": "Wird mit dem nächsten Release-Update hierher umziehen." + } + }, "sectionsTitle": "Settings", "categoryGeneral": "Allgemein", "categoryNotifications": "Benachrichtigungen", diff --git a/messages/en.json b/messages/en.json index 871bdf261..cffd9e743 100644 --- a/messages/en.json +++ b/messages/en.json @@ -873,6 +873,46 @@ "settings": { "title": "Settings", "subtitle": "Profile, Security & Connections", + "shell": { + "sectionsNav": "Settings sections" + }, + "sections": { + "account": { + "title": "Account", + "description": "Profile, sign-in methods, and account deletion." + }, + "integrations": { + "title": "Integrations", + "description": "Withings, moodLog, and other connected services." + }, + "notifications": { + "title": "Notifications", + "description": "Telegram, ntfy, Web Push." + }, + "dashboard": { + "title": "Dashboard", + "description": "Tile layout, thresholds." + }, + "ai": { + "title": "AI Insights", + "description": "Provider, model, key." + }, + "api": { + "title": "API & Tokens", + "description": "Bearer tokens for headless clients." + }, + "advanced": { + "title": "Advanced", + "description": "Export, import, danger zone." + }, + "about": { + "title": "About", + "description": "Version, license, links." + }, + "placeholder": { + "coming_soon": "Moving here with the next release update." + } + }, "sectionsTitle": "Settings", "categoryGeneral": "General", "categoryNotifications": "Notifications", diff --git a/src/app/api/auth/codex/callback/route.ts b/src/app/api/auth/codex/callback/route.ts index c4c58bacb..2bd6301f4 100644 --- a/src/app/api/auth/codex/callback/route.ts +++ b/src/app/api/auth/codex/callback/route.ts @@ -14,7 +14,7 @@ export const GET = apiHandler(async (request: NextRequest) => { const rl = await checkRateLimit(`codex-callback:${user.id}`, 10, 60_000); if (!rl.allowed) { const appUrl = process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL; - return NextResponse.redirect(`${appUrl}/settings?codex_error=rate_limited`); + return NextResponse.redirect(`${appUrl}/settings/integrations?codex_error=rate_limited`); } const url = new URL(request.url); @@ -29,13 +29,13 @@ export const GET = apiHandler(async (request: NextRequest) => { if (error) { annotate({ meta: { oauth_error: error } }); return NextResponse.redirect( - `${appUrl}/settings?codex_error=${encodeURIComponent(error)}`, + `${appUrl}/settings/integrations?codex_error=${encodeURIComponent(error)}`, ); } if (!code || !state) { return NextResponse.redirect( - `${appUrl}/settings?codex_error=missing_params`, + `${appUrl}/settings/integrations?codex_error=missing_params`, ); } @@ -48,7 +48,7 @@ export const GET = apiHandler(async (request: NextRequest) => { if (!storedState || !storedVerifier || state !== storedState) { return NextResponse.redirect( - `${appUrl}/settings?codex_error=invalid_state`, + `${appUrl}/settings/integrations?codex_error=invalid_state`, ); } @@ -70,9 +70,7 @@ export const GET = apiHandler(async (request: NextRequest) => { data: { codexAccessTokenEncrypted: encrypted.accessEncrypted, codexRefreshTokenEncrypted: encrypted.refreshEncrypted, - codexTokenExpiresAt: new Date( - Date.now() + tokens.expires_in * 1000, - ), + codexTokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000), codexConnectedAt: new Date(), codexConnectionStatus: "connected", insightsCachedAt: null, @@ -86,12 +84,14 @@ export const GET = apiHandler(async (request: NextRequest) => { }); annotate({ action: { name: "codex.oauth.callback.success" } }); - return NextResponse.redirect(`${appUrl}/settings?codex_connected=true`); + return NextResponse.redirect( + `${appUrl}/settings/integrations?codex_connected=true`, + ); } catch (err) { const message = err instanceof Error ? err.message : "unknown"; annotate({ meta: { codex_token_error: message } }); return NextResponse.redirect( - `${appUrl}/settings?codex_error=token_exchange_failed`, + `${appUrl}/settings/integrations?codex_error=token_exchange_failed`, ); } }); diff --git a/src/app/api/withings/callback/route.ts b/src/app/api/withings/callback/route.ts index 18d437ae8..55a9ba214 100644 --- a/src/app/api/withings/callback/route.ts +++ b/src/app/api/withings/callback/route.ts @@ -30,7 +30,7 @@ export const GET = apiHandler(async (request: NextRequest) => { ) { return NextResponse.redirect( new URL( - "/settings?withings=error&reason=state", + "/settings/integrations?withings=error&reason=state", process.env.NEXT_PUBLIC_APP_URL!, ), ); @@ -41,7 +41,7 @@ export const GET = apiHandler(async (request: NextRequest) => { if (stateUserId !== user.id) { return NextResponse.redirect( new URL( - "/settings?withings=error&reason=user", + "/settings/integrations?withings=error&reason=user", process.env.NEXT_PUBLIC_APP_URL!, ), ); @@ -50,7 +50,7 @@ export const GET = apiHandler(async (request: NextRequest) => { if (!code) { return NextResponse.redirect( new URL( - "/settings?withings=error&reason=nocode", + "/settings/integrations?withings=error&reason=nocode", process.env.NEXT_PUBLIC_APP_URL!, ), ); @@ -61,7 +61,7 @@ export const GET = apiHandler(async (request: NextRequest) => { if (!creds) { return NextResponse.redirect( new URL( - "/settings?withings=error&reason=nocreds", + "/settings/integrations?withings=error&reason=nocreds", process.env.NEXT_PUBLIC_APP_URL!, ), ); @@ -90,7 +90,9 @@ export const GET = apiHandler(async (request: NextRequest) => { }); // Subscribe to webhooks in background - setupWebhook(user.id).catch((err) => getEvent()?.addWarning("Webhook setup failed: " + err)); + setupWebhook(user.id).catch((err) => + getEvent()?.addWarning("Webhook setup failed: " + err), + ); await auditLog("withings.connect", { userId: user.id, @@ -98,7 +100,10 @@ export const GET = apiHandler(async (request: NextRequest) => { }); const response = NextResponse.redirect( - new URL("/settings?withings=connected", process.env.NEXT_PUBLIC_APP_URL!), + new URL( + "/settings/integrations?withings=connected", + process.env.NEXT_PUBLIC_APP_URL!, + ), ); response.cookies.delete("withings_state"); return response; @@ -106,7 +111,7 @@ export const GET = apiHandler(async (request: NextRequest) => { getEvent()?.setError(err); return NextResponse.redirect( new URL( - "/settings?withings=error&reason=token", + "/settings/integrations?withings=error&reason=token", process.env.NEXT_PUBLIC_APP_URL!, ), ); diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx index f9127b01d..affa7140f 100644 --- a/src/app/notifications/page.tsx +++ b/src/app/notifications/page.tsx @@ -163,7 +163,7 @@ export default function NotificationsPage() { {t("notifications.noChannels")}

+ + ); +} + +interface GlobalServiceAvailability { + telegramGlobal: boolean; + ntfyGlobal: boolean; + webPushGlobal: boolean; + apiGlobal: boolean; + moodLogGlobal: boolean; +} + +export function LegacySettingsPage() { + const { user, isLoading, isAuthenticated, refetch } = useAuth(); + const { t, locale, setLocale } = useTranslations(); + const queryClient = useQueryClient(); + const router = useRouter(); + + const [email, setEmail] = useState(""); + const [heightCm, setHeightCm] = useState(""); + const [dateOfBirth, setDateOfBirth] = useState(""); + const [gender, setGender] = useState(""); + const [saving, setSaving] = useState(false); + const [saveMsg, setSaveMsg] = useState(null); + const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>( + null, + ); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [passwordSaving, setPasswordSaving] = useState(false); + const [passwordMsg, setPasswordMsg] = useState(null); + const [passwordMsgType, setPasswordMsgType] = useState< + "success" | "error" | null + >(null); + const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); + const [passkeyLoading, setPasskeyLoading] = useState(false); + const [passkeyMsg, setPasskeyMsg] = useState(null); + const [passkeyMsgType, setPasskeyMsgType] = useState< + "success" | "error" | null + >(null); + + const { data: globalServices } = useQuery({ + queryKey: ["settings", "global-services"], + queryFn: async () => { + const res = await fetch("/api/settings/global-services"); + if (!res.ok) throw new Error("Failed"); + return (await res.json()).data as GlobalServiceAvailability; + }, + enabled: isAuthenticated, + }); + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + router.push("/auth/login"); + } + }, [isLoading, isAuthenticated, router]); + + useEffect(() => { + if (user) { + setEmail(user.email ?? ""); + setHeightCm(user.heightCm?.toString() ?? ""); + setDateOfBirth( + user.dateOfBirth + ? new Date(user.dateOfBirth).toISOString().slice(0, 10) + : "", + ); + setGender(user.gender ?? ""); + } + }, [user]); + + async function handleSaveProfile(e: React.FormEvent) { + e.preventDefault(); + setSaving(true); + setSaveMsg(null); + setSaveMsgType(null); + + const res = await fetch("/api/auth/profile", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: email.trim() || null, + heightCm: heightCm ? parseFloat(heightCm) : null, + dateOfBirth: dateOfBirth || null, + gender: gender || null, + }), + }); + + if (res.ok) { + setSaveMsg(t("settings.profileSaved")); + setSaveMsgType("success"); + await refetch(); + } else { + const json = await res.json(); + setSaveMsg(json.error || t("settings.savingError")); + setSaveMsgType("error"); + } + setSaving(false); + } + + async function handleAddPasskey() { + setPasskeyLoading(true); + setPasskeyMsg(null); + setPasskeyMsgType(null); + + try { + const optRes = await fetch("/api/auth/passkey/register-options", { + method: "POST", + }); + + if (!optRes.ok) { + setPasskeyMsg(t("settings.passkeyOptionsError")); + setPasskeyMsgType("error"); + setPasskeyLoading(false); + return; + } + + const optJson = await optRes.json(); + const { options, challengeId } = optJson.data; + + const { startRegistration } = await import("@simplewebauthn/browser"); + const credential = await startRegistration({ optionsJSON: options }); + + const verifyRes = await fetch("/api/auth/passkey/register-verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ challengeId, credential }), + }); + + if (verifyRes.ok) { + setPasskeyMsg(t("settings.passkeyAdded")); + setPasskeyMsgType("success"); + queryClient.invalidateQueries({ queryKey: ["passkeys"] }); + } else { + const verifyJson = await verifyRes.json(); + setPasskeyMsg( + verifyJson.error || t("settings.passkeyRegistrationFailed"), + ); + setPasskeyMsgType("error"); + } + } catch (err) { + const { key, params } = describePasskeyError(err); + setPasskeyMsg(t(key, params)); + setPasskeyMsgType("error"); + } finally { + setPasskeyLoading(false); + } + } + + async function handleChangePassword(e: React.FormEvent) { + e.preventDefault(); + setPasswordSaving(true); + setPasswordMsg(null); + setPasswordMsgType(null); + + if (newPassword !== confirmPassword) { + setPasswordMsg(t("settings.passwordMismatch")); + setPasswordMsgType("error"); + setPasswordSaving(false); + return; + } + + try { + const res = await fetch("/api/auth/password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + currentPassword, + newPassword, + confirmPassword, + }), + }); + const json = await res.json(); + + if (!res.ok) { + setPasswordMsg(json.error || t("settings.savingError")); + setPasswordMsgType("error"); + return; + } + + setPasswordMsg(t("settings.passwordUpdated")); + setPasswordMsgType("success"); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + } catch { + setPasswordMsg(t("common.networkError")); + setPasswordMsgType("error"); + } finally { + setPasswordSaving(false); + } + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!user) return null; + const serviceAvailability: GlobalServiceAvailability = { + telegramGlobal: globalServices?.telegramGlobal ?? true, + ntfyGlobal: globalServices?.ntfyGlobal ?? true, + webPushGlobal: globalServices?.webPushGlobal ?? true, + apiGlobal: globalServices?.apiGlobal ?? true, + moodLogGlobal: globalServices?.moodLogGlobal ?? true, + }; + const hasNotificationServices = + serviceAvailability.telegramGlobal || + serviceAvailability.ntfyGlobal || + serviceAvailability.webPushGlobal; + + return ( +
+
+

+ {t("settings.title")} +

+

+ {t("settings.subtitle")} +

+
+ + {user.role === "ADMIN" && ( +
+
+
+
+ +

+ {t("settings.adminAreaTitle")} +

+
+

+ {t("settings.adminAreaDescription")} +

+
+ +
+
+ )} + +
+
+

+ {t("settings.categoryGeneral")} +

+ + {/* Profile Section */} +
+
+ +

{t("settings.profile")}

+
+
+
+
+ + +
+
+ + setEmail(e.target.value)} + placeholder={t("auth.emailPlaceholder")} + maxLength={320} + /> +
+
+ +
+
+ + +

+ {t("settings.genderHint")} +

+
+
+ + setHeightCm(e.target.value)} + placeholder="175" + min={50} + max={300} + step={0.1} + /> +
+
+ +
+
+ + setDateOfBirth(e.target.value)} + max={new Date().toISOString().slice(0, 10)} + /> +

+ {t("settings.dateOfBirthHint")} +

+
+
+ + +

+ {t("settings.languageDescription")} +

+
+
+ + {saveMsg && ( +

+ {saveMsg} +

+ )} + +
+ +
+
+
+
+ +
+

+ {t("settings.security")} +

+ +
+
+ +

+ {t("settings.passkeys")} +

+
+ +
+ + {passkeyMsg && ( +

+ {passkeyMsg} +

+ )} +
+
+ +
+
+
+ +

+ {t("settings.passwordReset")} +

+
+ +
+

+ {t("settings.changePasswordDescription")} +

+
+ + { + setPasswordDialogOpen(open); + if (!open) { + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + setPasswordMsg(null); + setPasswordMsgType(null); + } + }} + > + + + {t("settings.passwordReset")} + + {t("settings.changePasswordDescription")} + + + +
+
+
+ + setCurrentPassword(e.target.value)} + /> +
+
+ + setNewPassword(e.target.value)} + /> +
+
+ + setConfirmPassword(e.target.value)} + /> +
+
+ + {passwordMsg && ( +

+ {passwordMsg} +

+ )} + + +
+
+
+
+ + {hasNotificationServices && ( +
+

+ {t("settings.categoryNotifications")} +

+ {serviceAvailability.telegramGlobal && ( + + )} + {serviceAvailability.ntfyGlobal && ( + + )} + {serviceAvailability.webPushGlobal && ( + + )} +
+ )} + +
+

+ {t("settings.categoryPersonalization")} +

+ + +
+ +
+

+ {t("settings.categoryIntegration")} +

+ + {serviceAvailability.moodLogGlobal !== false && ( + + )} + +
+ + {serviceAvailability.apiGlobal && ( +
+

+ {t("settings.categoryApi")} +

+ + +
+ )} + +
+

+ {t("settings.export")} +

+ +
+ +
+

+ {t("settings.dangerZoneTitle")} +

+ +
+
+
+ ); +} + +/* ─────────────────────── Passkey List ─────────────────────── */ + +interface PasskeyInfo { + id: string; + name: string; + credentialDeviceType: string; + credentialBackedUp: boolean; + createdAt: string; +} + +function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [deleteMsg, setDeleteMsg] = useState(null); + + const { data: passkeys } = useQuery({ + queryKey: ["passkeys"], + queryFn: async () => { + const res = await fetch("/api/auth/passkeys"); + if (!res.ok) throw new Error("Failed"); + return (await res.json()).data as PasskeyInfo[]; + }, + enabled: isAuthenticated, + }); + + const deletePasskey = useMutation({ + mutationFn: async (id: string) => { + const res = await fetch(`/api/auth/passkeys/${id}`, { + method: "DELETE", + }); + const json = await res.json(); + if (!res.ok) throw new Error(json.error || t("common.error")); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["passkeys"] }); + setDeleteMsg(null); + }, + onError: (err: Error) => { + setDeleteMsg(err.message); + }, + }); + + const DEVICE_TYPE_LABELS: Record = { + singleDevice: t("settings.singleDevice"), + multiDevice: t("settings.multiDevice"), + }; + + if (!passkeys || passkeys.length === 0) { + return ( +
+

{t("settings.passkeys")}

+

+ {t("settings.noPasskeys")} +

+
+ ); + } + + return ( +
+

+ {t("settings.registeredPasskeys")} +

+

+ {t("settings.passkeysDescription")} +

+
+ + + + + + + + + + + + {passkeys.map((pk, idx) => ( + + + + + + + + ))} + +
+ {t("settings.passkeyName")} + + {t("settings.passkeyDevice")} + + {t("settings.passkeyBackup")} + + {t("settings.passkeyCreated")} + + {t("settings.passkeyActions")} +
{pk.name} + {DEVICE_TYPE_LABELS[pk.credentialDeviceType] ?? + pk.credentialDeviceType} + + + {pk.credentialBackedUp + ? t("settings.backedUp") + : t("common.no")} + + + {formatDate(pk.createdAt)} + + + + + + + + + {t("settings.deletePasskey")} + + + {t("settings.deletePasskeyDescription")} + + + + + {t("common.cancel")} + + deletePasskey.mutate(pk.id)} + > + {t("common.delete")} + + + + +
+
+ {deleteMsg && ( +
+ + {deleteMsg} +
+ )} +
+ ); +} + +/* ─────────────────────── Withings Integration ─────────────────────── */ + +function WithingsSection({ + id, + isAuthenticated, +}: { + id: string; + isAuthenticated: boolean; +}) { + const { t } = useTranslations(); + const [syncing, setSyncing] = useState(false); + const [syncMsg, setSyncMsg] = useState(null); + const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>( + null, + ); + const [clientId, setClientId] = useState(""); + const [clientSecret, setClientSecret] = useState(""); + const [credsSaving, setCredsSaving] = useState(false); + const [credsMsg, setCredsMsg] = useState(null); + const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>( + null, + ); + const queryClient = useQueryClient(); + + const { data: status } = useQuery({ + queryKey: ["withings", "status"], + queryFn: async () => { + const res = await fetch("/api/withings/status"); + if (!res.ok) throw new Error("Failed"); + const json = await res.json(); + return json.data as { + connected: boolean; + configured: boolean; + lastSyncedAt?: string | null; + connectedAt?: string; + tokenExpired?: boolean; + }; + }, + enabled: isAuthenticated, + }); + + const disconnect = useMutation({ + mutationFn: async () => { + const res = await fetch("/api/withings/disconnect", { method: "POST" }); + if (!res.ok) throw new Error("Failed"); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["withings"] }); + }, + }); + + async function handleSync(fullSync = false) { + setSyncing(true); + setSyncMsg(null); + setSyncMsgType(null); + try { + const res = await fetch("/api/withings/sync", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fullSync }), + }); + const json = await res.json(); + if (res.ok) { + setSyncMsg( + fullSync + ? t("settings.withingsFullSyncResult", { + count: json.data.imported, + }) + : t("settings.withingsSyncResult", { count: json.data.imported }), + ); + setSyncMsgType("success"); + void invalidateKeys(queryClient, measurementDependentKeys); + } else { + setSyncMsg(json.error || t("settings.withingsSyncFailed")); + setSyncMsgType("error"); + } + } catch { + setSyncMsg(t("settings.withingsSyncFailed")); + setSyncMsgType("error"); + } finally { + setSyncing(false); + } + } + + async function handleSaveCredentials(e: React.FormEvent) { + e.preventDefault(); + setCredsSaving(true); + setCredsMsg(null); + setCredsMsgType(null); + + try { + const res = await fetch("/api/withings/credentials", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + clientId: clientId.trim(), + clientSecret: clientSecret.trim(), + }), + }); + + if (res.ok) { + setCredsMsg(t("settings.withingsCredentialsSaved")); + setCredsMsgType("success"); + setClientId(""); + setClientSecret(""); + queryClient.invalidateQueries({ queryKey: ["withings"] }); + } else { + try { + const json = await res.json(); + setCredsMsg(json.error || t("settings.savingError")); + } catch { + setCredsMsg(t("settings.savingError")); + } + setCredsMsgType("error"); + } + } catch { + setCredsMsg(t("common.networkError")); + setCredsMsgType("error"); + } + setCredsSaving(false); + } + + return ( +
+
+
+ +

{t("settings.withings")}

+
+
+ {status?.configured && !status?.connected && ( + + {t("settings.configured")} + + )} + {status?.connected && ( + + {t("settings.withingsConnected")} + + )} + {status?.lastSyncedAt && ( + + {t("settings.withingsLastSync")}:{" "} + {formatDateTime(status.lastSyncedAt)} + + )} + {status?.tokenExpired && ( + + {t("settings.withingsTokenExpired")} + + )} +
+
+

+ {t("settings.withingsDescription")} +

+ +
+ {/* Credentials section */} +
+

+ {t("settings.withingsCredentials")} +

+
+
+
+ + setClientId(e.target.value)} + placeholder={ + status?.configured + ? t("settings.withingsCredentialsSavedPlaceholder") + : t("settings.withingsClientId") + } + maxLength={200} + /> +
+
+ + setClientSecret(e.target.value)} + placeholder={ + status?.configured + ? t("settings.withingsCredentialsSavedPlaceholderSecret") + : t("settings.withingsClientSecret") + } + maxLength={200} + /> +
+
+ + +
+
+ {credsMsg && ( +

+ {credsMsg} +

+ )} +
+
+ + {/* Connection status */} + {status?.connected ? ( + <> +
+ + + + + + + + + {t("settings.withingsFullSyncTitle")} + + + {t("settings.withingsFullSyncDescription")} + + + + {t("common.cancel")} + handleSync(true)}> + {t("settings.withingsSynchronize")} + + + + + + + + + + + + {t("settings.withingsDisconnectTitle")} + + + {t("settings.withingsDisconnectDescription")} + + + + {t("common.cancel")} + disconnect.mutate()} + > + {t("settings.withingsDisconnect")} + + + + +
+ {syncMsg && ( +

+ {syncMsg} +

+ )} + + ) : status?.configured ? ( + + ) : ( +
+ {t("settings.withingsNoCredentials")} +
+ )} +
+
+ ); +} + +/* ─────────────────────── API Tokens Section ─────────────────────── */ + +function ApiEndpointsSection({ id }: { id: string }) { + const { t } = useTranslations(); + + return ( +
+
+ +

+ {t("settings.apiEndpointsTitle")} +

+
+

+ {t("settings.apiEndpointsDescription")} +

+ +
+ + + + + + + + + + + + + + + + + +
+ {t("settings.apiEndpointMethod")} + + {t("settings.apiEndpointPath")} + + {t("settings.apiEndpointAuth")} + + {t("settings.apiEndpointExample")} +
POST/api/ingest/medication + Authorization: Bearer hlk_... + + {`{ "medicationName": "...", "scheduledFor": "..." }`} +
+
+
+ ); +} + +interface ApiTokenInfo { + id: string; + name: string; + permissions: string[]; + lastUsedAt: string | null; + expiresAt: string | null; + createdAt: string; + revoked: boolean; +} + +function ApiTokensSection({ + id, + isAuthenticated, +}: { + id: string; + isAuthenticated: boolean; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [newName, setNewName] = useState(""); + const [newToken, setNewToken] = useState(null); + const [creating, setCreating] = useState(false); + const [tokenMsg, setTokenMsg] = useState(null); + const [showRevokedTokens, setShowRevokedTokens] = useState(false); + + const { data: tokens } = useQuery({ + queryKey: ["tokens"], + queryFn: async () => { + const res = await fetch("/api/tokens"); + if (!res.ok) throw new Error("Failed"); + return (await res.json()).data as ApiTokenInfo[]; + }, + enabled: isAuthenticated, + }); + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + setCreating(true); + setTokenMsg(null); + setNewToken(null); + + try { + const res = await fetch("/api/tokens", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: newName.trim() }), + }); + const json = await res.json(); + if (res.ok) { + setNewToken(json.data.token); + setNewName(""); + queryClient.invalidateQueries({ queryKey: ["tokens"] }); + } else { + setTokenMsg(json.error || t("common.error")); + } + } catch { + setTokenMsg(t("common.networkError")); + } finally { + setCreating(false); + } + } + + async function handleRevoke(tokenId: string) { + const res = await fetch(`/api/tokens/${tokenId}`, { method: "DELETE" }); + if (res.ok) { + queryClient.invalidateQueries({ queryKey: ["tokens"] }); + } + } + + const activeTokens = (tokens ?? []).filter((tok) => !tok.revoked); + const revokedTokens = (tokens ?? []).filter((tok) => tok.revoked); + const latestActiveUse = activeTokens.reduce((latest, tok) => { + if (!tok.lastUsedAt) return latest; + if (!latest || new Date(tok.lastUsedAt) > new Date(latest)) { + return tok.lastUsedAt; + } + return latest; + }, null); + + return ( +
+
+
+ +

{t("settings.apiTokens")}

+
+
+ {activeTokens.length > 0 && ( + + {t("settings.configured")} + + )} + {latestActiveUse && ( + + {t("settings.tokenTableLastUsed")}:{" "} + {formatDateTime(latestActiveUse)} + + )} +
+
+

+ {t("settings.apiTokensDescription")} +

+ +
+ {/* Create new token */} +
+ setNewName(e.target.value)} + placeholder={t("settings.tokenNamePlaceholder")} + maxLength={100} + className="flex-1" + /> + +
+ + {/* New token display */} + {newToken && ( +
+

+ {t("settings.tokenCreated")} +

+ + {newToken} + +
+ )} + + {tokenMsg && ( +

+ {tokenMsg} +

+ )} + + {/* Token table (active) */} +
+

+ {t("settings.activeTokensTitle")} +

+
+ + + + + + + + + + + + + {activeTokens.length === 0 && ( + + + + )} + {activeTokens.map((tok, index) => { + const isExpired = + tok.expiresAt && new Date(tok.expiresAt) < new Date(); + return ( + + + + + + + + + ); + })} + +
+ {t("settings.tokenTableName")} + + {t("settings.tokenTablePermissions")} + + {t("settings.tokenTableStatus")} + + {t("settings.tokenTableCreated")} + + {t("settings.tokenTableLastUsed")} + + {t("settings.tokenTableActions")} +
+ {t("settings.noActiveTokens")} +
{tok.name} + {tok.permissions.join(", ")} + + {isExpired ? ( + + {t("settings.tokenExpired")} + + ) : ( + + {t("settings.tokenActive")} + + )} + + {formatDate(tok.createdAt)} + + {tok.lastUsedAt + ? formatDateTime(tok.lastUsedAt) + : t("settings.tokenNeverUsed")} + + + + + + + + + {t("settings.tokenRevoke")} + + + {t("settings.tokenRevokeDescription")} + + + + + {t("common.cancel")} + + handleRevoke(tok.id)} + > + {t("settings.tokenRevoked")} + + + + +
+
+
+ + {/* Revoked tokens (collapsible, separate table) */} + {revokedTokens.length > 0 && ( +
+ + {showRevokedTokens && ( +
+ + + + + + + + + + + {revokedTokens.map((tok, index) => ( + + + + + + + ))} + +
+ {t("settings.tokenTableName")} + + {t("settings.tokenTablePermissions")} + + {t("settings.tokenTableCreated")} + + {t("settings.tokenTableLastUsed")} +
{tok.name} + {tok.permissions.join(", ")} + + {formatDate(tok.createdAt)} + + {tok.lastUsedAt + ? formatDateTime(tok.lastUsedAt) + : t("settings.tokenNeverUsed")} +
+
+ )} +
+ )} +
+
+ ); +} + +/* ─────────────────────── Export Section ─────────────────────── */ + +function ExportSection({ id }: { id: string }) { + const { t, locale } = useTranslations(); + const [exporting, setExporting] = useState(false); + const [generatingReport, setGeneratingReport] = useState(false); + + async function handleExport(format: "csv" | "json") { + setExporting(true); + try { + const res = await fetch(`/api/export?format=${format}&type=all`); + if (!res.ok) return; + + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `healthlog-export-${new Date().toISOString().slice(0, 10)}.${format}`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } finally { + setExporting(false); + } + } + + async function handleDoctorReport() { + setGeneratingReport(true); + try { + const res = await fetch("/api/doctor-report", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ days: 90 }), + }); + if (!res.ok) return; + const json = await res.json(); + + const { generateDoctorReportPDF } = + await import("@/lib/doctor-report-pdf"); + const doc = generateDoctorReportPDF(json.data, { t, locale }); + const fileSlug = locale === "de" ? "gesundheitsbericht" : "health-report"; + doc.save(`${fileSlug}-${new Date().toISOString().slice(0, 10)}.pdf`); + } finally { + setGeneratingReport(false); + } + } + + return ( +
+
+ +

{t("settings.export")}

+
+

+ {t("settings.exportDescription")} +

+
+ + + +
+
+ ); +} + +/* ─────────────────────── Data Reset Section ─────────────────────── */ + +function DataResetSection({ id }: { id: string }) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [deleting, setDeleting] = useState(false); + const [msg, setMsg] = useState(null); + const [msgType, setMsgType] = useState<"success" | "error" | null>(null); + + async function handleDeleteAllData() { + setDeleting(true); + setMsg(null); + setMsgType(null); + try { + const res = await fetch("/api/settings/data", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ confirm: "DELETE" }), + }); + const json = await res.json(); + if (!res.ok) { + setMsg(json.error || t("settings.dangerZoneDeleteFailed")); + setMsgType("error"); + return; + } + + await queryClient.invalidateQueries(); + setMsg(t("settings.dangerZoneSuccess")); + setMsgType("success"); + } catch { + setMsg(t("settings.dangerZoneDeleteFailed")); + setMsgType("error"); + } finally { + setDeleting(false); + } + } + + return ( +
+
+ +

+ {t("settings.dangerZone")} +

+
+

+ {t("settings.dangerZoneDescription")} +

+ +
+ + + + + + + + {t("settings.dangerZoneConfirm")} + + + {t("settings.dangerZoneConfirmDescription")} + + + + {t("common.cancel")} + + {t("settings.finalDelete")} + + + + +
+ + {msg && ( +

+ {msg} +

+ )} +
+ ); +} + +/* ─────────────────────── Telegram Notifications ─────────────────────── */ + +function TelegramSection({ + id, + isAuthenticated, +}: { + id: string; + isAuthenticated: boolean; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [botToken, setBotToken] = useState(""); + const [chatId, setChatId] = useState(""); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); + const [msg, setMsg] = useState(null); + const [msgType, setMsgType] = useState<"success" | "error" | null>(null); + + const { data: settings } = useQuery({ + queryKey: ["telegram", "settings"], + queryFn: async () => { + const res = await fetch("/api/settings/telegram"); + if (!res.ok) throw new Error("Failed"); + const json = await res.json(); + return json.data as { + enabled: boolean; + hasBotToken: boolean; + chatId: string | null; + }; + }, + enabled: isAuthenticated, + }); + + const [enabled, setEnabled] = useState(false); + const [initialized, setInitialized] = useState(false); + if (settings && !initialized) { + setEnabled(settings.enabled); + if (settings.chatId) setChatId(settings.chatId); + setInitialized(true); + } + + async function handleSave(e: React.FormEvent) { + e.preventDefault(); + setSaving(true); + setMsg(null); + setMsgType(null); + + const body: Record = { enabled }; + if (botToken.trim()) body.botToken = botToken.trim(); + if (chatId !== (settings?.chatId ?? "")) body.chatId = chatId; + + const res = await fetch("/api/settings/telegram", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (res.ok) { + setMsg(t("settings.telegramSaved")); + setMsgType("success"); + setBotToken(""); + queryClient.invalidateQueries({ queryKey: ["telegram"] }); + } else { + const json = await res.json(); + setMsg(json.error || t("settings.savingError")); + setMsgType("error"); + } + setSaving(false); + } + + async function handleTest() { + setTesting(true); + setMsg(null); + setMsgType(null); + + const res = await fetch("/api/settings/telegram/test", { method: "POST" }); + if (res.ok) { + setMsg(t("settings.testSent")); + setMsgType("success"); + } else { + const json = await res.json(); + setMsg(json.error || t("common.error")); + setMsgType("error"); + } + setTesting(false); + } + + return ( +
+
+
+ +

{t("settings.telegram")}

+
+
+ {settings?.hasBotToken && settings?.chatId && ( + + {t("settings.configured")} + + )} + {settings?.enabled && ( + + {t("common.enabled")} + + )} +
+
+

+ {t("settings.telegramDescription")} +

+ +
+
+
+
+ + setBotToken(e.target.value)} + placeholder={ + settings?.hasBotToken + ? t("settings.withingsCredentialsSavedPlaceholder") + : "123456:ABC-DEF..." + } + maxLength={100} + /> +
+
+ + setChatId(e.target.value)} + placeholder="123456789" + maxLength={50} + /> +
+
+ +
+ + +
+ +
+

{t("settings.telegramStep1")}

+

{t("settings.telegramStep2")}

+

{t("settings.telegramStep3")}

+
+ + {msg && ( +

+ {msg} +

+ )} + +
+ + +
+
+
+
+ ); +} + +/* ─────────────────────── ntfy Settings ─────────────────────── */ + +interface NtfySettings { + enabled: boolean; + serverUrl: string; + topic: string; + hasAuthToken: boolean; +} + +function NtfySection({ + id, + isAuthenticated, +}: { + id: string; + isAuthenticated: boolean; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [serverUrl, setServerUrl] = useState("https://ntfy.sh"); + const [topic, setTopic] = useState(""); + const [authToken, setAuthToken] = useState(""); + const [saveMsg, setSaveMsg] = useState(null); + const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>( + null, + ); + + const { data: settings } = useQuery({ + queryKey: ["settings", "ntfy"], + queryFn: async () => { + const res = await fetch("/api/settings/ntfy"); + if (!res.ok) throw new Error("Failed"); + const json = await res.json(); + return json.data as NtfySettings; + }, + enabled: isAuthenticated, + }); + + // Sync from server + useState(() => { + if (settings) { + setServerUrl(settings.serverUrl); + setTopic(settings.topic); + } + }); + + const save = useMutation({ + mutationFn: async (enabled: boolean) => { + const res = await fetch("/api/settings/ntfy", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + serverUrl, + topic, + authToken: authToken || undefined, + enabled, + }), + }); + if (!res.ok) { + const json = await res.json(); + throw new Error(json.error || t("common.error")); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["settings", "ntfy"] }); + setSaveMsg(t("settings.saved")); + setSaveMsgType("success"); + setAuthToken(""); + }, + onError: (err: Error) => { + setSaveMsg(err.message); + setSaveMsgType("error"); + }, + }); + + const test = useMutation({ + mutationFn: async () => { + const res = await fetch("/api/settings/ntfy/test", { method: "POST" }); + if (!res.ok) { + const json = await res.json(); + throw new Error(json.error || t("common.error")); + } + }, + onSuccess: () => { + setSaveMsg(t("settings.testSent")); + setSaveMsgType("success"); + }, + onError: (err: Error) => { + setSaveMsg(err.message); + setSaveMsgType("error"); + }, + }); + + return ( +
+
+
+ +

{t("settings.ntfy")}

+
+
+ {settings?.serverUrl && settings?.topic && ( + + {t("settings.configured")} + + )} + {settings?.enabled && ( + + {t("common.enabled")} + + )} +
+
+

+ {t("settings.ntfyDescription")} +

+ +
+
+ + save.mutate(checked)} + disabled={save.isPending} + /> +
+ +
{ + e.preventDefault(); + save.mutate(settings?.enabled ?? false); + }} + className="space-y-4" + > +
+
+ + setServerUrl(e.target.value)} + /> +
+
+ + setTopic(e.target.value)} + /> +
+
+ + setAuthToken(e.target.value)} + /> +

+ {t("settings.ntfyAuthTokenHint")} +

+
+
+ + {saveMsg && ( +

+ {saveMsg} +

+ )} + +
+ + +
+
+
+
+ ); +} + +/* ─────────────────────── Web Push Notifications ─────────────────────── */ + +function WebPushSection({ id }: { id: string }) { + const { t } = useTranslations(); + const [isSubscribed, setIsSubscribed] = useState(false); + const [isSupported, setIsSupported] = useState(true); + const [isDenied, setIsDenied] = useState(false); + const [loading, setLoading] = useState(true); + const [actionLoading, setActionLoading] = useState(false); + const [msg, setMsg] = useState(null); + const [msgType, setMsgType] = useState<"success" | "error" | null>(null); + + useEffect(() => { + async function checkSubscription() { + if (!("serviceWorker" in navigator) || !("PushManager" in window)) { + setIsSupported(false); + setLoading(false); + return; + } + + if (Notification.permission === "denied") { + setIsDenied(true); + setLoading(false); + return; + } + + try { + const registration = + await navigator.serviceWorker.getRegistration("/sw.js"); + if (registration) { + const subscription = await registration.pushManager.getSubscription(); + setIsSubscribed(!!subscription); + } + } catch { + // Ignore errors during check + } + setLoading(false); + } + + checkSubscription(); + }, []); + + async function handleSubscribe() { + setActionLoading(true); + setMsg(null); + setMsgType(null); + + try { + // Get VAPID public key from server + const vapidRes = await fetch("/api/notifications/vapid"); + if (!vapidRes.ok) { + setMsg(t("settings.webPushNotConfigured")); + setMsgType("error"); + setActionLoading(false); + return; + } + const vapidData = await vapidRes.json(); + const vapidPublicKey = vapidData.data.publicKey; + + // Register service worker + const registration = await navigator.serviceWorker.register("/sw.js"); + await navigator.serviceWorker.ready; + + // Subscribe to push + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToArrayBuffer(vapidPublicKey), + }); + + const subJson = subscription.toJSON(); + + // Save subscription on server + const res = await fetch("/api/notifications/web-push", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + endpoint: subJson.endpoint, + keys: { + p256dh: subJson.keys?.p256dh ?? "", + auth: subJson.keys?.auth ?? "", + }, + }), + }); + + if (res.ok) { + setIsSubscribed(true); + setMsg(t("settings.webPushSubscribed")); + setMsgType("success"); + } else { + setMsg(t("settings.webPushSubscribeFailed")); + setMsgType("error"); + } + } catch { + if (Notification.permission === "denied") { + setIsDenied(true); + setMsg(t("settings.webPushDenied")); + } else { + setMsg(t("settings.webPushSubscribeFailed")); + } + setMsgType("error"); + } finally { + setActionLoading(false); + } + } + + async function handleUnsubscribe() { + setActionLoading(true); + setMsg(null); + setMsgType(null); + + try { + const registration = + await navigator.serviceWorker.getRegistration("/sw.js"); + if (registration) { + const subscription = await registration.pushManager.getSubscription(); + if (subscription) { + // Delete from server + await fetch("/api/notifications/web-push", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ endpoint: subscription.endpoint }), + }); + + await subscription.unsubscribe(); + } + } + + setIsSubscribed(false); + setMsg(t("settings.webPushUnsubscribed")); + setMsgType("success"); + } catch { + setMsg(t("settings.webPushSubscribeFailed")); + setMsgType("error"); + } finally { + setActionLoading(false); + } + } + + return ( +
+
+
+ +

{t("settings.webPush")}

+
+
+ {!loading && isSubscribed && ( + + {t("settings.configured")} + + )} + {!loading && isSubscribed && ( + + {t("settings.webPushActive")} + + )} +
+
+

+ {t("settings.webPushDescription")} +

+ +
+ {loading ? ( +
+ +
+ ) : !isSupported ? ( +

+ {t("settings.webPushNotSupported")} +

+ ) : isDenied ? ( +

+ {t("settings.webPushDenied")} +

+ ) : isSubscribed ? ( + + ) : ( + + )} + + {msg && ( +

+ {msg} +

+ )} +
+
+ ); +} + +/** Convert a URL-safe base64 string to an ArrayBuffer (for VAPID key). */ +function urlBase64ToArrayBuffer(base64String: string): ArrayBuffer { + const padding = "=".repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); + const raw = atob(base64); + const buffer = new ArrayBuffer(raw.length); + const view = new Uint8Array(buffer); + for (let i = 0; i < raw.length; ++i) { + view[i] = raw.charCodeAt(i); + } + return buffer; +} + +/* ─────────────────────── moodLog Integration ─────────────────────── */ + +function MoodLogSection({ t }: { t: (key: string) => string }) { + const [url, setUrl] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [saving, setSaving] = useState(false); + const [syncing, setSyncing] = useState(false); + const [msg, setMsg] = useState(null); + const [msgType, setMsgType] = useState<"success" | "error" | null>(null); + + const { data: status, refetch: refetchStatus } = useQuery({ + queryKey: ["moodlog-status"], + queryFn: async () => { + const res = await fetch("/api/integrations/moodlog/status"); + if (!res.ok) throw new Error("Failed"); + return (await res.json()).data as { + configured: boolean; + enabled: boolean; + lastSyncedAt: string | null; + entryCount: number; + webhookSecret: string | null; + }; + }, + }); + + async function handleSave(e: React.FormEvent) { + e.preventDefault(); + setSaving(true); + setMsg(null); + const res = await fetch("/api/settings/moodlog", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: url.trim(), apiKey: apiKey.trim() }), + }); + if (res.ok) { + setMsg(t("settings.moodLogSaved")); + setMsgType("success"); + setUrl(""); + setApiKey(""); + await refetchStatus(); + } else { + const json = await res.json(); + setMsg(json.error || t("settings.savingError")); + setMsgType("error"); + } + setSaving(false); + } + + async function handleSync(fullSync = false) { + setSyncing(true); + setMsg(null); + try { + const res = await fetch("/api/integrations/moodlog/sync", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fullSync }), + }); + if (res.ok) { + const json = await res.json(); + setMsg( + t("settings.moodLogSyncResult").replace( + "{count}", + String(json.data.imported), + ), + ); + setMsgType("success"); + await refetchStatus(); + } else { + setMsg(t("settings.moodLogSyncFailed")); + setMsgType("error"); + } + } catch { + setMsg(t("settings.moodLogSyncFailed")); + setMsgType("error"); + } + setSyncing(false); + } + + async function handleDisconnect() { + const res = await fetch("/api/settings/moodlog", { method: "DELETE" }); + if (res.ok) { + setMsg(t("settings.moodLogDisconnected")); + setMsgType("success"); + await refetchStatus(); + } + } + + return ( +
+
+ + {status?.configured && ( + + {status.enabled + ? t("settings.withingsConnected") + : t("settings.configured")} + + )} +
+

+ {t("settings.moodLogDescription")} +

+ + {/* Credentials form */} +
+
+ + setUrl(e.target.value)} + /> +
+
+ + setApiKey(e.target.value)} + /> +
+ +
+ + {/* Connected state */} + {status?.configured && ( +
+ {/* Webhook Secret */} + {status.webhookSecret && ( +
+ +
+ + +
+

+ {t("settings.moodLogWebhookSecretHelp")} +

+
+ )} + + {/* Status info */} +
+ {status.lastSyncedAt && ( + + {t("settings.moodLogLastSync")}:{" "} + {formatDateTime(status.lastSyncedAt)} + + )} + + {t("settings.moodLogEntries")}: {status.entryCount} + +
+ + {/* Sync buttons */} +
+ + + + + + + + + + {t("settings.moodLogFullSyncTitle")} + + + {t("settings.moodLogFullSyncDescription")} + + + + {t("common.cancel")} + handleSync(true)}> + {t("settings.moodLogFullSync")} + + + + + + + + + + + + + {t("settings.moodLogDisconnectTitle")} + + + {t("settings.moodLogDisconnectDescription")} + + + + {t("common.cancel")} + + {t("settings.moodLogDisconnect")} + + + + +
+
+ )} + + {/* Status message */} + {msg && ( +

+ {msg} +

+ )} +
+ ); +} + +/* ─────────────────────── OpenAI Insights Settings ─────────────────────── */ + +function InsightsSettingsSection({ + id, + isAuthenticated, +}: { + id: string; + isAuthenticated: boolean; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [disconnecting, setDisconnecting] = useState(false); + const [regenerating, setRegenerating] = useState(false); + const [msg, setMsg] = useState(null); + const [msgType, setMsgType] = useState<"success" | "error" | null>(null); + + const { data: settings } = useQuery({ + queryKey: ["insights", "settings"], + queryFn: async () => { + const res = await fetch("/api/insights/settings"); + if (!res.ok) return null; + const json = await res.json(); + return json.data as { + codexStatus: string; + codexConnectedAt: string | null; + hasAdminKey: boolean; + privacyMode: string; + lastInsightAt: string | null; + }; + }, + enabled: isAuthenticated, + }); + + const updateSettings = useMutation({ + mutationFn: async (body: Record) => { + const res = await fetch("/api/insights/settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const json = await res.json(); + if (!res.ok) throw new Error(json.error); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["insights"] }); + }, + }); + + // Handle OAuth callback query params + useEffect(() => { + const params = new URLSearchParams(window.location.search); + if (params.get("codex_connected") === "true") { + setMsg(t("settings.codexConnected")); + setMsgType("success"); + queryClient.invalidateQueries({ queryKey: ["insights"] }); + window.history.replaceState({}, "", window.location.pathname); + } else if (params.get("codex_error")) { + setMsg(t("settings.codexConnectionFailed")); + setMsgType("error"); + window.history.replaceState({}, "", window.location.pathname); + } + }, [queryClient, t]); + + const hasProvider = + settings?.codexStatus === "connected" || settings?.hasAdminKey; + + async function handleConnect() { + window.location.href = "/api/auth/codex/authorize"; + } + + async function handleDisconnect() { + setDisconnecting(true); + setMsg(null); + try { + const res = await fetch("/api/auth/codex/disconnect", { + method: "DELETE", + }); + if (!res.ok) { + const json = await res.json(); + throw new Error(json.error); + } + setMsg(t("settings.codexDisconnected")); + setMsgType("success"); + queryClient.invalidateQueries({ queryKey: ["insights"] }); + } catch (err) { + setMsg(err instanceof Error ? err.message : t("settings.savingError")); + setMsgType("error"); + } finally { + setDisconnecting(false); + } + } + + async function togglePrivacyMode() { + const newMode = settings?.privacyMode === "raw" ? "aggregated" : "raw"; + await updateSettings.mutateAsync({ privacyMode: newMode }); + } + + async function handleRegenerate() { + setRegenerating(true); + setMsg(null); + setMsgType(null); + try { + const res = await fetch("/api/insights/generate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ force: true }), + }); + const json = await res.json(); + if (!res.ok) { + if (res.status === 429) { + setMsg(t("settings.regenerateRateLimit")); + } else { + setMsg(json.error || t("settings.savingError")); + } + setMsgType("error"); + return; + } + setMsg(t("settings.regenerateSuccess")); + setMsgType("success"); + queryClient.invalidateQueries({ queryKey: ["insights"] }); + } catch { + setMsg(t("settings.savingError")); + setMsgType("error"); + } finally { + setRegenerating(false); + } + } + + return ( +
+
+
+ +

{t("settings.kiInsights")}

+
+
+ {settings?.codexStatus === "connected" && ( + + ChatGPT verbunden + + )} + {settings?.codexStatus !== "connected" && settings?.hasAdminKey && ( + + Admin-KI aktiv + + )} + {settings?.codexStatus === "expired" && ( + + Verbindung abgelaufen + + )} + {settings?.lastInsightAt && ( + + {t("settings.lastGeneratedAt")}:{" "} + {formatDateTime(settings.lastInsightAt)} + + )} +
+
+

+ {t("settings.kiInsightsDescription")} +

+ +
+ {/* ChatGPT Connection */} +
+ {settings?.codexStatus === "connected" ? ( +
+
+

ChatGPT verbunden

+

+ Insights werden über dein ChatGPT-Abo generiert — keine + zusätzlichen Kosten. + {settings.codexConnectedAt && ( + <> + {" "} + Verbunden seit {formatDateTime(settings.codexConnectedAt)} + . + + )} +

+
+ +
+ ) : ( +
+
+

Mit ChatGPT verbinden

+

+ Verbinde dein ChatGPT Pro/Max-Konto um KI-gestützte + Gesundheitsanalysen basierend auf aktuellen medizinischen + Leitlinien zu erhalten. Keine zusätzlichen API-Kosten. +

+
+ + {settings?.hasAdminKey && ( +

+ Alternativ nutzt HealthLog den vom Administrator + konfigurierten KI-Anbieter. +

+ )} +
+ )} +
+ + {msg && ( +

+ {msg} +

+ )} + + {/* Privacy Mode */} + {hasProvider && ( +
+
+
+

{t("settings.rawData")}

+

+ {settings?.privacyMode === "raw" + ? t("settings.rawDataOnDescription") + : t("settings.rawDataOffDescription")} +

+
+
+ +
+
+ {settings?.privacyMode === "raw" && ( +
+ {t("settings.rawDataWarning")} +
+ )} +
+ )} + + {/* Regenerate Reports */} + {hasProvider && ( + + )} + + {/* Multi-provider AI selection (per-user override) */} + +
+
+ ); +} + +/* ─────────────────────── User AI Provider Override ─────────────────────── */ + +function UserAIProviderSubsection() { + const queryClient = useQueryClient(); + const [provider, setProvider] = useState(""); + const [model, setModel] = useState(""); + const [baseUrl, setBaseUrl] = useState(""); + const [anthropicKey, setAnthropicKey] = useState(""); + const [localKey, setLocalKey] = useState(""); + const [saveMsg, setSaveMsg] = useState(null); + const [saveOk, setSaveOk] = useState(false); + const [testMsg, setTestMsg] = useState(null); + const [testOk, setTestOk] = useState(false); + const [testing, setTesting] = useState(false); + + const { data, isLoading } = useQuery({ + queryKey: ["user", "ai-provider"], + queryFn: async () => { + const res = await fetch("/api/user/ai-provider"); + if (!res.ok) return null; + const json = await res.json(); + return json.data as { + provider: string | null; + model: string | null; + baseUrl: string | null; + hasAnthropicKey: boolean; + anthropicKeyPreview: string | null; + hasLocalKey: boolean; + }; + }, + }); + + useEffect(() => { + if (!data) return; + // Sync-from-server pattern: form draft state is initialised from the + // fetched user record. Could be reworked into a derived-state approach, + // but that's a non-trivial refactor of this monolithic settings page — + // tracked for the 1.4.0 settings split. + setProvider(data.provider ?? ""); + setModel(data.model ?? ""); + setBaseUrl(data.baseUrl ?? ""); + }, [data]); + + const saveMutation = useMutation({ + mutationFn: async () => { + const body: Record = { + provider: provider || null, + model: model || null, + baseUrl: baseUrl || null, + }; + if (anthropicKey.trim()) body.anthropicKey = anthropicKey.trim(); + if (localKey.trim()) body.localKey = localKey.trim(); + const res = await fetch("/api/user/ai-provider", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const json = await res.json(); + if (!res.ok) throw new Error(json.error || "Save failed"); + }, + onSuccess: () => { + setSaveMsg("Gespeichert"); + setSaveOk(true); + setAnthropicKey(""); + setLocalKey(""); + queryClient.invalidateQueries({ queryKey: ["user", "ai-provider"] }); + queryClient.invalidateQueries({ queryKey: ["insights"] }); + }, + onError: (e) => { + setSaveMsg(e instanceof Error ? e.message : "Fehler"); + setSaveOk(false); + }, + }); + + async function runTest() { + setTesting(true); + setTestMsg(null); + try { + // v1.4: pass the *current* dropdown / input selection so the test + // honours unsaved changes. Plaintext keys never persist server-side. + const overrideBody: Record = {}; + if (provider) overrideBody.provider = provider; + if (model.trim()) overrideBody.model = model.trim(); + if (baseUrl.trim()) overrideBody.baseUrl = baseUrl.trim(); + if (anthropicKey.trim()) overrideBody.anthropicKey = anthropicKey.trim(); + if (localKey.trim()) overrideBody.localKey = localKey.trim(); + const hasOverride = Object.keys(overrideBody).length > 0; + const res = await fetch("/api/ai/test", { + method: "POST", + headers: hasOverride ? { "Content-Type": "application/json" } : {}, + body: hasOverride ? JSON.stringify(overrideBody) : undefined, + }); + const json = await res.json(); + if (!res.ok) { + setTestMsg(json.error || `HTTP ${res.status}`); + setTestOk(false); + return; + } + setTestMsg( + `OK — ${json.data.providerType} (${json.data.model})${ + json.data.tokensUsed ? `, ${json.data.tokensUsed} tokens` : "" + }`, + ); + setTestOk(true); + } catch (e) { + setTestMsg(e instanceof Error ? e.message : "Test fehlgeschlagen"); + setTestOk(false); + } finally { + setTesting(false); + } + } + + if (isLoading) return null; + + return ( +
+
+

KI-Provider (persönlich)

+

+ Eigener KI-Anbieter überschreibt die Admin-Einstellung. Leer lassen + für Standard. +

+
+ +
+
+ + +
+ +
+ + setModel(e.target.value)} + placeholder={ + provider === "ANTHROPIC" + ? "claude-3-5-sonnet-latest" + : provider === "LOCAL" + ? "llama3:8b" + : "gpt-4o-mini" + } + className="mt-1" + /> +
+ + {provider === "ANTHROPIC" && ( +
+ + setAnthropicKey(e.target.value)} + placeholder="sk-ant-..." + className="mt-1" + /> +
+ )} + + {provider === "LOCAL" && ( + <> +
+ + setBaseUrl(e.target.value)} + placeholder="http://localhost:11434/v1" + className="mt-1" + /> +
+
+ + setLocalKey(e.target.value)} + placeholder={data?.hasLocalKey ? "(gespeichert)" : ""} + className="mt-1" + /> +
+ + )} +
+ +
+ + +
+ + {saveMsg && ( +

+ {saveMsg} +

+ )} + {testMsg && ( +

+ {testMsg} +

+ )} +
+ ); +} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 33d474d1c..dd87ccfd6 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -1,3218 +1,20 @@ -"use client"; +import { permanentRedirect } from "next/navigation"; -import { useState, useEffect } from "react"; -import { useAuth } from "@/hooks/use-auth"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { useRouter } from "next/navigation"; -import Link from "next/link"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Badge } from "@/components/ui/badge"; -import { Switch } from "@/components/ui/switch"; -import { - KeyRound, - Bell, - BellRing, - Loader2, - MessageCircle, - Save, - Send, - Shield, - Key, - Trash2, - Link2, - Unlink, - RefreshCw, - Download, - Sparkles, - AlertTriangle, - ChevronDown, - Eye, - EyeOff, - User, - Smile, -} from "lucide-react"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { formatDate, formatDateTime } from "@/lib/format"; -import { useTranslations } from "@/lib/i18n/context"; -import { locales, localeLabels, type Locale } from "@/lib/i18n/config"; -import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys"; -import { describePasskeyError } from "@/lib/passkey-errors"; -import { ThresholdsSection } from "@/components/settings/thresholds-section"; -import { DashboardLayoutSection } from "@/components/settings/dashboard-layout-section"; - -function PasswordInput(props: React.ComponentProps) { - const [visible, setVisible] = useState(false); - return ( -
- - -
- ); -} - -interface GlobalServiceAvailability { - telegramGlobal: boolean; - ntfyGlobal: boolean; - webPushGlobal: boolean; - apiGlobal: boolean; - moodLogGlobal: boolean; -} - -export default function SettingsPage() { - const { user, isLoading, isAuthenticated, refetch } = useAuth(); - const { t, locale, setLocale } = useTranslations(); - const queryClient = useQueryClient(); - const router = useRouter(); - - const [email, setEmail] = useState(""); - const [heightCm, setHeightCm] = useState(""); - const [dateOfBirth, setDateOfBirth] = useState(""); - const [gender, setGender] = useState(""); - const [saving, setSaving] = useState(false); - const [saveMsg, setSaveMsg] = useState(null); - const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>( - null, - ); - const [currentPassword, setCurrentPassword] = useState(""); - const [newPassword, setNewPassword] = useState(""); - const [confirmPassword, setConfirmPassword] = useState(""); - const [passwordSaving, setPasswordSaving] = useState(false); - const [passwordMsg, setPasswordMsg] = useState(null); - const [passwordMsgType, setPasswordMsgType] = useState< - "success" | "error" | null - >(null); - const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); - const [passkeyLoading, setPasskeyLoading] = useState(false); - const [passkeyMsg, setPasskeyMsg] = useState(null); - const [passkeyMsgType, setPasskeyMsgType] = useState< - "success" | "error" | null - >(null); - - const { data: globalServices } = useQuery({ - queryKey: ["settings", "global-services"], - queryFn: async () => { - const res = await fetch("/api/settings/global-services"); - if (!res.ok) throw new Error("Failed"); - return (await res.json()).data as GlobalServiceAvailability; - }, - enabled: isAuthenticated, - }); - - useEffect(() => { - if (!isLoading && !isAuthenticated) { - router.push("/auth/login"); - } - }, [isLoading, isAuthenticated, router]); - - useEffect(() => { - if (user) { - setEmail(user.email ?? ""); - setHeightCm(user.heightCm?.toString() ?? ""); - setDateOfBirth( - user.dateOfBirth - ? new Date(user.dateOfBirth).toISOString().slice(0, 10) - : "", - ); - setGender(user.gender ?? ""); - } - }, [user]); - - async function handleSaveProfile(e: React.FormEvent) { - e.preventDefault(); - setSaving(true); - setSaveMsg(null); - setSaveMsgType(null); - - const res = await fetch("/api/auth/profile", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - email: email.trim() || null, - heightCm: heightCm ? parseFloat(heightCm) : null, - dateOfBirth: dateOfBirth || null, - gender: gender || null, - }), - }); - - if (res.ok) { - setSaveMsg(t("settings.profileSaved")); - setSaveMsgType("success"); - await refetch(); - } else { - const json = await res.json(); - setSaveMsg(json.error || t("settings.savingError")); - setSaveMsgType("error"); - } - setSaving(false); - } - - async function handleAddPasskey() { - setPasskeyLoading(true); - setPasskeyMsg(null); - setPasskeyMsgType(null); - - try { - const optRes = await fetch("/api/auth/passkey/register-options", { - method: "POST", - }); - - if (!optRes.ok) { - setPasskeyMsg(t("settings.passkeyOptionsError")); - setPasskeyMsgType("error"); - setPasskeyLoading(false); - return; - } - - const optJson = await optRes.json(); - const { options, challengeId } = optJson.data; - - const { startRegistration } = await import("@simplewebauthn/browser"); - const credential = await startRegistration({ optionsJSON: options }); - - const verifyRes = await fetch("/api/auth/passkey/register-verify", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ challengeId, credential }), - }); - - if (verifyRes.ok) { - setPasskeyMsg(t("settings.passkeyAdded")); - setPasskeyMsgType("success"); - queryClient.invalidateQueries({ queryKey: ["passkeys"] }); - } else { - const verifyJson = await verifyRes.json(); - setPasskeyMsg( - verifyJson.error || t("settings.passkeyRegistrationFailed"), - ); - setPasskeyMsgType("error"); - } - } catch (err) { - const { key, params } = describePasskeyError(err); - setPasskeyMsg(t(key, params)); - setPasskeyMsgType("error"); - } finally { - setPasskeyLoading(false); - } - } - - async function handleChangePassword(e: React.FormEvent) { - e.preventDefault(); - setPasswordSaving(true); - setPasswordMsg(null); - setPasswordMsgType(null); - - if (newPassword !== confirmPassword) { - setPasswordMsg(t("settings.passwordMismatch")); - setPasswordMsgType("error"); - setPasswordSaving(false); - return; - } - - try { - const res = await fetch("/api/auth/password", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - currentPassword, - newPassword, - confirmPassword, - }), - }); - const json = await res.json(); - - if (!res.ok) { - setPasswordMsg(json.error || t("settings.savingError")); - setPasswordMsgType("error"); - return; - } - - setPasswordMsg(t("settings.passwordUpdated")); - setPasswordMsgType("success"); - setCurrentPassword(""); - setNewPassword(""); - setConfirmPassword(""); - } catch { - setPasswordMsg(t("common.networkError")); - setPasswordMsgType("error"); - } finally { - setPasswordSaving(false); - } - } - - if (isLoading) { - return ( -
- -
- ); - } - - if (!user) return null; - const serviceAvailability: GlobalServiceAvailability = { - telegramGlobal: globalServices?.telegramGlobal ?? true, - ntfyGlobal: globalServices?.ntfyGlobal ?? true, - webPushGlobal: globalServices?.webPushGlobal ?? true, - apiGlobal: globalServices?.apiGlobal ?? true, - moodLogGlobal: globalServices?.moodLogGlobal ?? true, - }; - const hasNotificationServices = - serviceAvailability.telegramGlobal || - serviceAvailability.ntfyGlobal || - serviceAvailability.webPushGlobal; - - return ( -
-
-

- {t("settings.title")} -

-

- {t("settings.subtitle")} -

-
- - {user.role === "ADMIN" && ( -
-
-
-
- -

- {t("settings.adminAreaTitle")} -

-
-

- {t("settings.adminAreaDescription")} -

-
- -
-
- )} - -
-
-

- {t("settings.categoryGeneral")} -

- - {/* Profile Section */} -
-
- -

{t("settings.profile")}

-
-
-
-
- - -
-
- - setEmail(e.target.value)} - placeholder={t("auth.emailPlaceholder")} - maxLength={320} - /> -
-
- -
-
- - -

- {t("settings.genderHint")} -

-
-
- - setHeightCm(e.target.value)} - placeholder="175" - min={50} - max={300} - step={0.1} - /> -
-
- -
-
- - setDateOfBirth(e.target.value)} - max={new Date().toISOString().slice(0, 10)} - /> -

- {t("settings.dateOfBirthHint")} -

-
-
- - -

- {t("settings.languageDescription")} -

-
-
- - {saveMsg && ( -

- {saveMsg} -

- )} - -
- -
-
-
-
- -
-

- {t("settings.security")} -

- -
-
- -

- {t("settings.passkeys")} -

-
- -
- - {passkeyMsg && ( -

- {passkeyMsg} -

- )} -
-
- -
-
-
- -

- {t("settings.passwordReset")} -

-
- -
-

- {t("settings.changePasswordDescription")} -

-
- - { - setPasswordDialogOpen(open); - if (!open) { - setCurrentPassword(""); - setNewPassword(""); - setConfirmPassword(""); - setPasswordMsg(null); - setPasswordMsgType(null); - } - }} - > - - - {t("settings.passwordReset")} - - {t("settings.changePasswordDescription")} - - - -
-
-
- - setCurrentPassword(e.target.value)} - /> -
-
- - setNewPassword(e.target.value)} - /> -
-
- - setConfirmPassword(e.target.value)} - /> -
-
- - {passwordMsg && ( -

- {passwordMsg} -

- )} - - -
-
-
-
- - {hasNotificationServices && ( -
-

- {t("settings.categoryNotifications")} -

- {serviceAvailability.telegramGlobal && ( - - )} - {serviceAvailability.ntfyGlobal && ( - - )} - {serviceAvailability.webPushGlobal && ( - - )} -
- )} - -
-

- {t("settings.categoryPersonalization")} -

- - -
- -
-

- {t("settings.categoryIntegration")} -

- - {serviceAvailability.moodLogGlobal !== false && ( - - )} - -
- - {serviceAvailability.apiGlobal && ( -
-

- {t("settings.categoryApi")} -

- - -
- )} - -
-

- {t("settings.export")} -

- -
- -
-

- {t("settings.dangerZoneTitle")} -

- -
-
-
- ); -} - -/* ─────────────────────── Passkey List ─────────────────────── */ - -interface PasskeyInfo { - id: string; - name: string; - credentialDeviceType: string; - credentialBackedUp: boolean; - createdAt: string; -} - -function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) { - const { t } = useTranslations(); - const queryClient = useQueryClient(); - const [deleteMsg, setDeleteMsg] = useState(null); - - const { data: passkeys } = useQuery({ - queryKey: ["passkeys"], - queryFn: async () => { - const res = await fetch("/api/auth/passkeys"); - if (!res.ok) throw new Error("Failed"); - return (await res.json()).data as PasskeyInfo[]; - }, - enabled: isAuthenticated, - }); - - const deletePasskey = useMutation({ - mutationFn: async (id: string) => { - const res = await fetch(`/api/auth/passkeys/${id}`, { - method: "DELETE", - }); - const json = await res.json(); - if (!res.ok) throw new Error(json.error || t("common.error")); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["passkeys"] }); - setDeleteMsg(null); - }, - onError: (err: Error) => { - setDeleteMsg(err.message); - }, - }); - - const DEVICE_TYPE_LABELS: Record = { - singleDevice: t("settings.singleDevice"), - multiDevice: t("settings.multiDevice"), - }; - - if (!passkeys || passkeys.length === 0) { - return ( -
-

{t("settings.passkeys")}

-

- {t("settings.noPasskeys")} -

-
- ); - } - - return ( -
-

- {t("settings.registeredPasskeys")} -

-

- {t("settings.passkeysDescription")} -

-
- - - - - - - - - - - - {passkeys.map((pk, idx) => ( - - - - - - - - ))} - -
- {t("settings.passkeyName")} - - {t("settings.passkeyDevice")} - - {t("settings.passkeyBackup")} - - {t("settings.passkeyCreated")} - - {t("settings.passkeyActions")} -
{pk.name} - {DEVICE_TYPE_LABELS[pk.credentialDeviceType] ?? - pk.credentialDeviceType} - - - {pk.credentialBackedUp - ? t("settings.backedUp") - : t("common.no")} - - - {formatDate(pk.createdAt)} - - - - - - - - - {t("settings.deletePasskey")} - - - {t("settings.deletePasskeyDescription")} - - - - - {t("common.cancel")} - - deletePasskey.mutate(pk.id)} - > - {t("common.delete")} - - - - -
-
- {deleteMsg && ( -
- - {deleteMsg} -
- )} -
- ); -} - -/* ─────────────────────── Withings Integration ─────────────────────── */ - -function WithingsSection({ - id, - isAuthenticated, -}: { - id: string; - isAuthenticated: boolean; -}) { - const { t } = useTranslations(); - const [syncing, setSyncing] = useState(false); - const [syncMsg, setSyncMsg] = useState(null); - const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>( - null, - ); - const [clientId, setClientId] = useState(""); - const [clientSecret, setClientSecret] = useState(""); - const [credsSaving, setCredsSaving] = useState(false); - const [credsMsg, setCredsMsg] = useState(null); - const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>( - null, - ); - const queryClient = useQueryClient(); - - const { data: status } = useQuery({ - queryKey: ["withings", "status"], - queryFn: async () => { - const res = await fetch("/api/withings/status"); - if (!res.ok) throw new Error("Failed"); - const json = await res.json(); - return json.data as { - connected: boolean; - configured: boolean; - lastSyncedAt?: string | null; - connectedAt?: string; - tokenExpired?: boolean; - }; - }, - enabled: isAuthenticated, - }); - - const disconnect = useMutation({ - mutationFn: async () => { - const res = await fetch("/api/withings/disconnect", { method: "POST" }); - if (!res.ok) throw new Error("Failed"); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["withings"] }); - }, - }); - - async function handleSync(fullSync = false) { - setSyncing(true); - setSyncMsg(null); - setSyncMsgType(null); - try { - const res = await fetch("/api/withings/sync", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ fullSync }), - }); - const json = await res.json(); - if (res.ok) { - setSyncMsg( - fullSync - ? t("settings.withingsFullSyncResult", { - count: json.data.imported, - }) - : t("settings.withingsSyncResult", { count: json.data.imported }), - ); - setSyncMsgType("success"); - void invalidateKeys(queryClient, measurementDependentKeys); - } else { - setSyncMsg(json.error || t("settings.withingsSyncFailed")); - setSyncMsgType("error"); - } - } catch { - setSyncMsg(t("settings.withingsSyncFailed")); - setSyncMsgType("error"); - } finally { - setSyncing(false); - } - } - - async function handleSaveCredentials(e: React.FormEvent) { - e.preventDefault(); - setCredsSaving(true); - setCredsMsg(null); - setCredsMsgType(null); - - try { - const res = await fetch("/api/withings/credentials", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - clientId: clientId.trim(), - clientSecret: clientSecret.trim(), - }), - }); - - if (res.ok) { - setCredsMsg(t("settings.withingsCredentialsSaved")); - setCredsMsgType("success"); - setClientId(""); - setClientSecret(""); - queryClient.invalidateQueries({ queryKey: ["withings"] }); - } else { - try { - const json = await res.json(); - setCredsMsg(json.error || t("settings.savingError")); - } catch { - setCredsMsg(t("settings.savingError")); - } - setCredsMsgType("error"); - } - } catch { - setCredsMsg(t("common.networkError")); - setCredsMsgType("error"); - } - setCredsSaving(false); - } - - return ( -
-
-
- -

{t("settings.withings")}

-
-
- {status?.configured && !status?.connected && ( - - {t("settings.configured")} - - )} - {status?.connected && ( - - {t("settings.withingsConnected")} - - )} - {status?.lastSyncedAt && ( - - {t("settings.withingsLastSync")}:{" "} - {formatDateTime(status.lastSyncedAt)} - - )} - {status?.tokenExpired && ( - - {t("settings.withingsTokenExpired")} - - )} -
-
-

- {t("settings.withingsDescription")} -

- -
- {/* Credentials section */} -
-

- {t("settings.withingsCredentials")} -

-
-
-
- - setClientId(e.target.value)} - placeholder={ - status?.configured - ? t("settings.withingsCredentialsSavedPlaceholder") - : t("settings.withingsClientId") - } - maxLength={200} - /> -
-
- - setClientSecret(e.target.value)} - placeholder={ - status?.configured - ? t("settings.withingsCredentialsSavedPlaceholderSecret") - : t("settings.withingsClientSecret") - } - maxLength={200} - /> -
-
- - -
-
- {credsMsg && ( -

- {credsMsg} -

- )} -
-
- - {/* Connection status */} - {status?.connected ? ( - <> -
- - - - - - - - - {t("settings.withingsFullSyncTitle")} - - - {t("settings.withingsFullSyncDescription")} - - - - {t("common.cancel")} - handleSync(true)}> - {t("settings.withingsSynchronize")} - - - - - - - - - - - - {t("settings.withingsDisconnectTitle")} - - - {t("settings.withingsDisconnectDescription")} - - - - {t("common.cancel")} - disconnect.mutate()} - > - {t("settings.withingsDisconnect")} - - - - -
- {syncMsg && ( -

- {syncMsg} -

- )} - - ) : status?.configured ? ( - - ) : ( -
- {t("settings.withingsNoCredentials")} -
- )} -
-
- ); -} - -/* ─────────────────────── API Tokens Section ─────────────────────── */ - -function ApiEndpointsSection({ id }: { id: string }) { - const { t } = useTranslations(); - - return ( -
-
- -

- {t("settings.apiEndpointsTitle")} -

-
-

- {t("settings.apiEndpointsDescription")} -

- -
- - - - - - - - - - - - - - - - - -
- {t("settings.apiEndpointMethod")} - - {t("settings.apiEndpointPath")} - - {t("settings.apiEndpointAuth")} - - {t("settings.apiEndpointExample")} -
POST/api/ingest/medication - Authorization: Bearer hlk_... - - {`{ "medicationName": "...", "scheduledFor": "..." }`} -
-
-
- ); -} - -interface ApiTokenInfo { - id: string; - name: string; - permissions: string[]; - lastUsedAt: string | null; - expiresAt: string | null; - createdAt: string; - revoked: boolean; -} - -function ApiTokensSection({ - id, - isAuthenticated, -}: { - id: string; - isAuthenticated: boolean; -}) { - const { t } = useTranslations(); - const queryClient = useQueryClient(); - const [newName, setNewName] = useState(""); - const [newToken, setNewToken] = useState(null); - const [creating, setCreating] = useState(false); - const [tokenMsg, setTokenMsg] = useState(null); - const [showRevokedTokens, setShowRevokedTokens] = useState(false); - - const { data: tokens } = useQuery({ - queryKey: ["tokens"], - queryFn: async () => { - const res = await fetch("/api/tokens"); - if (!res.ok) throw new Error("Failed"); - return (await res.json()).data as ApiTokenInfo[]; - }, - enabled: isAuthenticated, - }); - - async function handleCreate(e: React.FormEvent) { - e.preventDefault(); - setCreating(true); - setTokenMsg(null); - setNewToken(null); - - try { - const res = await fetch("/api/tokens", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: newName.trim() }), - }); - const json = await res.json(); - if (res.ok) { - setNewToken(json.data.token); - setNewName(""); - queryClient.invalidateQueries({ queryKey: ["tokens"] }); - } else { - setTokenMsg(json.error || t("common.error")); - } - } catch { - setTokenMsg(t("common.networkError")); - } finally { - setCreating(false); - } - } - - async function handleRevoke(tokenId: string) { - const res = await fetch(`/api/tokens/${tokenId}`, { method: "DELETE" }); - if (res.ok) { - queryClient.invalidateQueries({ queryKey: ["tokens"] }); - } - } - - const activeTokens = (tokens ?? []).filter((tok) => !tok.revoked); - const revokedTokens = (tokens ?? []).filter((tok) => tok.revoked); - const latestActiveUse = activeTokens.reduce((latest, tok) => { - if (!tok.lastUsedAt) return latest; - if (!latest || new Date(tok.lastUsedAt) > new Date(latest)) { - return tok.lastUsedAt; - } - return latest; - }, null); - - return ( -
-
-
- -

{t("settings.apiTokens")}

-
-
- {activeTokens.length > 0 && ( - - {t("settings.configured")} - - )} - {latestActiveUse && ( - - {t("settings.tokenTableLastUsed")}:{" "} - {formatDateTime(latestActiveUse)} - - )} -
-
-

- {t("settings.apiTokensDescription")} -

- -
- {/* Create new token */} -
- setNewName(e.target.value)} - placeholder={t("settings.tokenNamePlaceholder")} - maxLength={100} - className="flex-1" - /> - -
- - {/* New token display */} - {newToken && ( -
-

- {t("settings.tokenCreated")} -

- - {newToken} - -
- )} - - {tokenMsg && ( -

- {tokenMsg} -

- )} - - {/* Token table (active) */} -
-

- {t("settings.activeTokensTitle")} -

-
- - - - - - - - - - - - - {activeTokens.length === 0 && ( - - - - )} - {activeTokens.map((tok, index) => { - const isExpired = - tok.expiresAt && new Date(tok.expiresAt) < new Date(); - return ( - - - - - - - - - ); - })} - -
- {t("settings.tokenTableName")} - - {t("settings.tokenTablePermissions")} - - {t("settings.tokenTableStatus")} - - {t("settings.tokenTableCreated")} - - {t("settings.tokenTableLastUsed")} - - {t("settings.tokenTableActions")} -
- {t("settings.noActiveTokens")} -
{tok.name} - {tok.permissions.join(", ")} - - {isExpired ? ( - - {t("settings.tokenExpired")} - - ) : ( - - {t("settings.tokenActive")} - - )} - - {formatDate(tok.createdAt)} - - {tok.lastUsedAt - ? formatDateTime(tok.lastUsedAt) - : t("settings.tokenNeverUsed")} - - - - - - - - - {t("settings.tokenRevoke")} - - - {t("settings.tokenRevokeDescription")} - - - - - {t("common.cancel")} - - handleRevoke(tok.id)} - > - {t("settings.tokenRevoked")} - - - - -
-
-
- - {/* Revoked tokens (collapsible, separate table) */} - {revokedTokens.length > 0 && ( -
- - {showRevokedTokens && ( -
- - - - - - - - - - - {revokedTokens.map((tok, index) => ( - - - - - - - ))} - -
- {t("settings.tokenTableName")} - - {t("settings.tokenTablePermissions")} - - {t("settings.tokenTableCreated")} - - {t("settings.tokenTableLastUsed")} -
{tok.name} - {tok.permissions.join(", ")} - - {formatDate(tok.createdAt)} - - {tok.lastUsedAt - ? formatDateTime(tok.lastUsedAt) - : t("settings.tokenNeverUsed")} -
-
- )} -
- )} -
-
- ); -} - -/* ─────────────────────── Export Section ─────────────────────── */ - -function ExportSection({ id }: { id: string }) { - const { t, locale } = useTranslations(); - const [exporting, setExporting] = useState(false); - const [generatingReport, setGeneratingReport] = useState(false); - - async function handleExport(format: "csv" | "json") { - setExporting(true); - try { - const res = await fetch(`/api/export?format=${format}&type=all`); - if (!res.ok) return; - - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `healthlog-export-${new Date().toISOString().slice(0, 10)}.${format}`; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); - } finally { - setExporting(false); - } - } - - async function handleDoctorReport() { - setGeneratingReport(true); - try { - const res = await fetch("/api/doctor-report", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ days: 90 }), - }); - if (!res.ok) return; - const json = await res.json(); - - const { generateDoctorReportPDF } = - await import("@/lib/doctor-report-pdf"); - const doc = generateDoctorReportPDF(json.data, { t, locale }); - const fileSlug = locale === "de" ? "gesundheitsbericht" : "health-report"; - doc.save(`${fileSlug}-${new Date().toISOString().slice(0, 10)}.pdf`); - } finally { - setGeneratingReport(false); - } - } - - return ( -
-
- -

{t("settings.export")}

-
-

- {t("settings.exportDescription")} -

-
- - - -
-
- ); -} - -/* ─────────────────────── Data Reset Section ─────────────────────── */ - -function DataResetSection({ id }: { id: string }) { - const { t } = useTranslations(); - const queryClient = useQueryClient(); - const [deleting, setDeleting] = useState(false); - const [msg, setMsg] = useState(null); - const [msgType, setMsgType] = useState<"success" | "error" | null>(null); - - async function handleDeleteAllData() { - setDeleting(true); - setMsg(null); - setMsgType(null); - try { - const res = await fetch("/api/settings/data", { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ confirm: "DELETE" }), - }); - const json = await res.json(); - if (!res.ok) { - setMsg(json.error || t("settings.dangerZoneDeleteFailed")); - setMsgType("error"); - return; - } - - await queryClient.invalidateQueries(); - setMsg(t("settings.dangerZoneSuccess")); - setMsgType("success"); - } catch { - setMsg(t("settings.dangerZoneDeleteFailed")); - setMsgType("error"); - } finally { - setDeleting(false); - } - } - - return ( -
-
- -

- {t("settings.dangerZone")} -

-
-

- {t("settings.dangerZoneDescription")} -

- -
- - - - - - - - {t("settings.dangerZoneConfirm")} - - - {t("settings.dangerZoneConfirmDescription")} - - - - {t("common.cancel")} - - {t("settings.finalDelete")} - - - - -
- - {msg && ( -

- {msg} -

- )} -
- ); -} - -/* ─────────────────────── Telegram Notifications ─────────────────────── */ - -function TelegramSection({ - id, - isAuthenticated, -}: { - id: string; - isAuthenticated: boolean; -}) { - const { t } = useTranslations(); - const queryClient = useQueryClient(); - const [botToken, setBotToken] = useState(""); - const [chatId, setChatId] = useState(""); - const [saving, setSaving] = useState(false); - const [testing, setTesting] = useState(false); - const [msg, setMsg] = useState(null); - const [msgType, setMsgType] = useState<"success" | "error" | null>(null); - - const { data: settings } = useQuery({ - queryKey: ["telegram", "settings"], - queryFn: async () => { - const res = await fetch("/api/settings/telegram"); - if (!res.ok) throw new Error("Failed"); - const json = await res.json(); - return json.data as { - enabled: boolean; - hasBotToken: boolean; - chatId: string | null; - }; - }, - enabled: isAuthenticated, - }); - - const [enabled, setEnabled] = useState(false); - const [initialized, setInitialized] = useState(false); - if (settings && !initialized) { - setEnabled(settings.enabled); - if (settings.chatId) setChatId(settings.chatId); - setInitialized(true); - } - - async function handleSave(e: React.FormEvent) { - e.preventDefault(); - setSaving(true); - setMsg(null); - setMsgType(null); - - const body: Record = { enabled }; - if (botToken.trim()) body.botToken = botToken.trim(); - if (chatId !== (settings?.chatId ?? "")) body.chatId = chatId; - - const res = await fetch("/api/settings/telegram", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - - if (res.ok) { - setMsg(t("settings.telegramSaved")); - setMsgType("success"); - setBotToken(""); - queryClient.invalidateQueries({ queryKey: ["telegram"] }); - } else { - const json = await res.json(); - setMsg(json.error || t("settings.savingError")); - setMsgType("error"); - } - setSaving(false); - } - - async function handleTest() { - setTesting(true); - setMsg(null); - setMsgType(null); - - const res = await fetch("/api/settings/telegram/test", { method: "POST" }); - if (res.ok) { - setMsg(t("settings.testSent")); - setMsgType("success"); - } else { - const json = await res.json(); - setMsg(json.error || t("common.error")); - setMsgType("error"); - } - setTesting(false); - } - - return ( -
-
-
- -

{t("settings.telegram")}

-
-
- {settings?.hasBotToken && settings?.chatId && ( - - {t("settings.configured")} - - )} - {settings?.enabled && ( - - {t("common.enabled")} - - )} -
-
-

- {t("settings.telegramDescription")} -

- -
-
-
-
- - setBotToken(e.target.value)} - placeholder={ - settings?.hasBotToken - ? t("settings.withingsCredentialsSavedPlaceholder") - : "123456:ABC-DEF..." - } - maxLength={100} - /> -
-
- - setChatId(e.target.value)} - placeholder="123456789" - maxLength={50} - /> -
-
- -
- - -
- -
-

{t("settings.telegramStep1")}

-

{t("settings.telegramStep2")}

-

{t("settings.telegramStep3")}

-
- - {msg && ( -

- {msg} -

- )} - -
- - -
-
-
-
- ); -} - -/* ─────────────────────── ntfy Settings ─────────────────────── */ - -interface NtfySettings { - enabled: boolean; - serverUrl: string; - topic: string; - hasAuthToken: boolean; -} - -function NtfySection({ - id, - isAuthenticated, -}: { - id: string; - isAuthenticated: boolean; -}) { - const { t } = useTranslations(); - const queryClient = useQueryClient(); - const [serverUrl, setServerUrl] = useState("https://ntfy.sh"); - const [topic, setTopic] = useState(""); - const [authToken, setAuthToken] = useState(""); - const [saveMsg, setSaveMsg] = useState(null); - const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>( - null, - ); - - const { data: settings } = useQuery({ - queryKey: ["settings", "ntfy"], - queryFn: async () => { - const res = await fetch("/api/settings/ntfy"); - if (!res.ok) throw new Error("Failed"); - const json = await res.json(); - return json.data as NtfySettings; - }, - enabled: isAuthenticated, - }); - - // Sync from server - useState(() => { - if (settings) { - setServerUrl(settings.serverUrl); - setTopic(settings.topic); - } - }); - - const save = useMutation({ - mutationFn: async (enabled: boolean) => { - const res = await fetch("/api/settings/ntfy", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - serverUrl, - topic, - authToken: authToken || undefined, - enabled, - }), - }); - if (!res.ok) { - const json = await res.json(); - throw new Error(json.error || t("common.error")); - } - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["settings", "ntfy"] }); - setSaveMsg(t("settings.saved")); - setSaveMsgType("success"); - setAuthToken(""); - }, - onError: (err: Error) => { - setSaveMsg(err.message); - setSaveMsgType("error"); - }, - }); - - const test = useMutation({ - mutationFn: async () => { - const res = await fetch("/api/settings/ntfy/test", { method: "POST" }); - if (!res.ok) { - const json = await res.json(); - throw new Error(json.error || t("common.error")); - } - }, - onSuccess: () => { - setSaveMsg(t("settings.testSent")); - setSaveMsgType("success"); - }, - onError: (err: Error) => { - setSaveMsg(err.message); - setSaveMsgType("error"); - }, - }); - - return ( -
-
-
- -

{t("settings.ntfy")}

-
-
- {settings?.serverUrl && settings?.topic && ( - - {t("settings.configured")} - - )} - {settings?.enabled && ( - - {t("common.enabled")} - - )} -
-
-

- {t("settings.ntfyDescription")} -

- -
-
- - save.mutate(checked)} - disabled={save.isPending} - /> -
- -
{ - e.preventDefault(); - save.mutate(settings?.enabled ?? false); - }} - className="space-y-4" - > -
-
- - setServerUrl(e.target.value)} - /> -
-
- - setTopic(e.target.value)} - /> -
-
- - setAuthToken(e.target.value)} - /> -

- {t("settings.ntfyAuthTokenHint")} -

-
-
- - {saveMsg && ( -

- {saveMsg} -

- )} - -
- - -
-
-
-
- ); -} - -/* ─────────────────────── Web Push Notifications ─────────────────────── */ - -function WebPushSection({ id }: { id: string }) { - const { t } = useTranslations(); - const [isSubscribed, setIsSubscribed] = useState(false); - const [isSupported, setIsSupported] = useState(true); - const [isDenied, setIsDenied] = useState(false); - const [loading, setLoading] = useState(true); - const [actionLoading, setActionLoading] = useState(false); - const [msg, setMsg] = useState(null); - const [msgType, setMsgType] = useState<"success" | "error" | null>(null); - - useEffect(() => { - async function checkSubscription() { - if (!("serviceWorker" in navigator) || !("PushManager" in window)) { - setIsSupported(false); - setLoading(false); - return; - } - - if (Notification.permission === "denied") { - setIsDenied(true); - setLoading(false); - return; - } - - try { - const registration = - await navigator.serviceWorker.getRegistration("/sw.js"); - if (registration) { - const subscription = await registration.pushManager.getSubscription(); - setIsSubscribed(!!subscription); - } - } catch { - // Ignore errors during check - } - setLoading(false); - } - - checkSubscription(); - }, []); - - async function handleSubscribe() { - setActionLoading(true); - setMsg(null); - setMsgType(null); - - try { - // Get VAPID public key from server - const vapidRes = await fetch("/api/notifications/vapid"); - if (!vapidRes.ok) { - setMsg(t("settings.webPushNotConfigured")); - setMsgType("error"); - setActionLoading(false); - return; - } - const vapidData = await vapidRes.json(); - const vapidPublicKey = vapidData.data.publicKey; - - // Register service worker - const registration = await navigator.serviceWorker.register("/sw.js"); - await navigator.serviceWorker.ready; - - // Subscribe to push - const subscription = await registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToArrayBuffer(vapidPublicKey), - }); - - const subJson = subscription.toJSON(); - - // Save subscription on server - const res = await fetch("/api/notifications/web-push", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - endpoint: subJson.endpoint, - keys: { - p256dh: subJson.keys?.p256dh ?? "", - auth: subJson.keys?.auth ?? "", - }, - }), - }); - - if (res.ok) { - setIsSubscribed(true); - setMsg(t("settings.webPushSubscribed")); - setMsgType("success"); - } else { - setMsg(t("settings.webPushSubscribeFailed")); - setMsgType("error"); - } - } catch { - if (Notification.permission === "denied") { - setIsDenied(true); - setMsg(t("settings.webPushDenied")); - } else { - setMsg(t("settings.webPushSubscribeFailed")); - } - setMsgType("error"); - } finally { - setActionLoading(false); - } - } - - async function handleUnsubscribe() { - setActionLoading(true); - setMsg(null); - setMsgType(null); - - try { - const registration = - await navigator.serviceWorker.getRegistration("/sw.js"); - if (registration) { - const subscription = await registration.pushManager.getSubscription(); - if (subscription) { - // Delete from server - await fetch("/api/notifications/web-push", { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ endpoint: subscription.endpoint }), - }); - - await subscription.unsubscribe(); - } - } - - setIsSubscribed(false); - setMsg(t("settings.webPushUnsubscribed")); - setMsgType("success"); - } catch { - setMsg(t("settings.webPushSubscribeFailed")); - setMsgType("error"); - } finally { - setActionLoading(false); - } - } - - return ( -
-
-
- -

{t("settings.webPush")}

-
-
- {!loading && isSubscribed && ( - - {t("settings.configured")} - - )} - {!loading && isSubscribed && ( - - {t("settings.webPushActive")} - - )} -
-
-

- {t("settings.webPushDescription")} -

- -
- {loading ? ( -
- -
- ) : !isSupported ? ( -

- {t("settings.webPushNotSupported")} -

- ) : isDenied ? ( -

- {t("settings.webPushDenied")} -

- ) : isSubscribed ? ( - - ) : ( - - )} - - {msg && ( -

- {msg} -

- )} -
-
- ); -} - -/** Convert a URL-safe base64 string to an ArrayBuffer (for VAPID key). */ -function urlBase64ToArrayBuffer(base64String: string): ArrayBuffer { - const padding = "=".repeat((4 - (base64String.length % 4)) % 4); - const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); - const raw = atob(base64); - const buffer = new ArrayBuffer(raw.length); - const view = new Uint8Array(buffer); - for (let i = 0; i < raw.length; ++i) { - view[i] = raw.charCodeAt(i); - } - return buffer; -} - -/* ─────────────────────── moodLog Integration ─────────────────────── */ - -function MoodLogSection({ t }: { t: (key: string) => string }) { - const [url, setUrl] = useState(""); - const [apiKey, setApiKey] = useState(""); - const [saving, setSaving] = useState(false); - const [syncing, setSyncing] = useState(false); - const [msg, setMsg] = useState(null); - const [msgType, setMsgType] = useState<"success" | "error" | null>(null); - - const { data: status, refetch: refetchStatus } = useQuery({ - queryKey: ["moodlog-status"], - queryFn: async () => { - const res = await fetch("/api/integrations/moodlog/status"); - if (!res.ok) throw new Error("Failed"); - return (await res.json()).data as { - configured: boolean; - enabled: boolean; - lastSyncedAt: string | null; - entryCount: number; - webhookSecret: string | null; - }; - }, - }); - - async function handleSave(e: React.FormEvent) { - e.preventDefault(); - setSaving(true); - setMsg(null); - const res = await fetch("/api/settings/moodlog", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ url: url.trim(), apiKey: apiKey.trim() }), - }); - if (res.ok) { - setMsg(t("settings.moodLogSaved")); - setMsgType("success"); - setUrl(""); - setApiKey(""); - await refetchStatus(); - } else { - const json = await res.json(); - setMsg(json.error || t("settings.savingError")); - setMsgType("error"); - } - setSaving(false); - } - - async function handleSync(fullSync = false) { - setSyncing(true); - setMsg(null); - try { - const res = await fetch("/api/integrations/moodlog/sync", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ fullSync }), - }); - if (res.ok) { - const json = await res.json(); - setMsg( - t("settings.moodLogSyncResult").replace( - "{count}", - String(json.data.imported), - ), - ); - setMsgType("success"); - await refetchStatus(); - } else { - setMsg(t("settings.moodLogSyncFailed")); - setMsgType("error"); - } - } catch { - setMsg(t("settings.moodLogSyncFailed")); - setMsgType("error"); - } - setSyncing(false); - } - - async function handleDisconnect() { - const res = await fetch("/api/settings/moodlog", { method: "DELETE" }); - if (res.ok) { - setMsg(t("settings.moodLogDisconnected")); - setMsgType("success"); - await refetchStatus(); - } - } - - return ( -
-
- - {status?.configured && ( - - {status.enabled - ? t("settings.withingsConnected") - : t("settings.configured")} - - )} -
-

- {t("settings.moodLogDescription")} -

- - {/* Credentials form */} -
-
- - setUrl(e.target.value)} - /> -
-
- - setApiKey(e.target.value)} - /> -
- -
- - {/* Connected state */} - {status?.configured && ( -
- {/* Webhook Secret */} - {status.webhookSecret && ( -
- -
- - -
-

- {t("settings.moodLogWebhookSecretHelp")} -

-
- )} - - {/* Status info */} -
- {status.lastSyncedAt && ( - - {t("settings.moodLogLastSync")}:{" "} - {formatDateTime(status.lastSyncedAt)} - - )} - - {t("settings.moodLogEntries")}: {status.entryCount} - -
- - {/* Sync buttons */} -
- - - - - - - - - - {t("settings.moodLogFullSyncTitle")} - - - {t("settings.moodLogFullSyncDescription")} - - - - {t("common.cancel")} - handleSync(true)}> - {t("settings.moodLogFullSync")} - - - - - - - - - - - - - {t("settings.moodLogDisconnectTitle")} - - - {t("settings.moodLogDisconnectDescription")} - - - - {t("common.cancel")} - - {t("settings.moodLogDisconnect")} - - - - -
-
- )} - - {/* Status message */} - {msg && ( -

- {msg} -

- )} -
- ); -} - -/* ─────────────────────── OpenAI Insights Settings ─────────────────────── */ - -function InsightsSettingsSection({ - id, - isAuthenticated, -}: { - id: string; - isAuthenticated: boolean; -}) { - const { t } = useTranslations(); - const queryClient = useQueryClient(); - const [disconnecting, setDisconnecting] = useState(false); - const [regenerating, setRegenerating] = useState(false); - const [msg, setMsg] = useState(null); - const [msgType, setMsgType] = useState<"success" | "error" | null>(null); - - const { data: settings } = useQuery({ - queryKey: ["insights", "settings"], - queryFn: async () => { - const res = await fetch("/api/insights/settings"); - if (!res.ok) return null; - const json = await res.json(); - return json.data as { - codexStatus: string; - codexConnectedAt: string | null; - hasAdminKey: boolean; - privacyMode: string; - lastInsightAt: string | null; - }; - }, - enabled: isAuthenticated, - }); - - const updateSettings = useMutation({ - mutationFn: async (body: Record) => { - const res = await fetch("/api/insights/settings", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - const json = await res.json(); - if (!res.ok) throw new Error(json.error); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["insights"] }); - }, - }); - - // Handle OAuth callback query params - useEffect(() => { - const params = new URLSearchParams(window.location.search); - if (params.get("codex_connected") === "true") { - setMsg(t("settings.codexConnected")); - setMsgType("success"); - queryClient.invalidateQueries({ queryKey: ["insights"] }); - window.history.replaceState({}, "", window.location.pathname); - } else if (params.get("codex_error")) { - setMsg(t("settings.codexConnectionFailed")); - setMsgType("error"); - window.history.replaceState({}, "", window.location.pathname); - } - }, [queryClient, t]); - - const hasProvider = - settings?.codexStatus === "connected" || settings?.hasAdminKey; - - async function handleConnect() { - window.location.href = "/api/auth/codex/authorize"; - } - - async function handleDisconnect() { - setDisconnecting(true); - setMsg(null); - try { - const res = await fetch("/api/auth/codex/disconnect", { - method: "DELETE", - }); - if (!res.ok) { - const json = await res.json(); - throw new Error(json.error); - } - setMsg(t("settings.codexDisconnected")); - setMsgType("success"); - queryClient.invalidateQueries({ queryKey: ["insights"] }); - } catch (err) { - setMsg(err instanceof Error ? err.message : t("settings.savingError")); - setMsgType("error"); - } finally { - setDisconnecting(false); - } - } - - async function togglePrivacyMode() { - const newMode = settings?.privacyMode === "raw" ? "aggregated" : "raw"; - await updateSettings.mutateAsync({ privacyMode: newMode }); - } - - async function handleRegenerate() { - setRegenerating(true); - setMsg(null); - setMsgType(null); - try { - const res = await fetch("/api/insights/generate", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ force: true }), - }); - const json = await res.json(); - if (!res.ok) { - if (res.status === 429) { - setMsg(t("settings.regenerateRateLimit")); - } else { - setMsg(json.error || t("settings.savingError")); - } - setMsgType("error"); - return; - } - setMsg(t("settings.regenerateSuccess")); - setMsgType("success"); - queryClient.invalidateQueries({ queryKey: ["insights"] }); - } catch { - setMsg(t("settings.savingError")); - setMsgType("error"); - } finally { - setRegenerating(false); - } - } - - return ( -
-
-
- -

{t("settings.kiInsights")}

-
-
- {settings?.codexStatus === "connected" && ( - - ChatGPT verbunden - - )} - {settings?.codexStatus !== "connected" && settings?.hasAdminKey && ( - - Admin-KI aktiv - - )} - {settings?.codexStatus === "expired" && ( - - Verbindung abgelaufen - - )} - {settings?.lastInsightAt && ( - - {t("settings.lastGeneratedAt")}:{" "} - {formatDateTime(settings.lastInsightAt)} - - )} -
-
-

- {t("settings.kiInsightsDescription")} -

- -
- {/* ChatGPT Connection */} -
- {settings?.codexStatus === "connected" ? ( -
-
-

ChatGPT verbunden

-

- Insights werden über dein ChatGPT-Abo generiert — keine - zusätzlichen Kosten. - {settings.codexConnectedAt && ( - <> - {" "} - Verbunden seit {formatDateTime(settings.codexConnectedAt)} - . - - )} -

-
- -
- ) : ( -
-
-

Mit ChatGPT verbinden

-

- Verbinde dein ChatGPT Pro/Max-Konto um KI-gestützte - Gesundheitsanalysen basierend auf aktuellen medizinischen - Leitlinien zu erhalten. Keine zusätzlichen API-Kosten. -

-
- - {settings?.hasAdminKey && ( -

- Alternativ nutzt HealthLog den vom Administrator - konfigurierten KI-Anbieter. -

- )} -
- )} -
- - {msg && ( -

- {msg} -

- )} - - {/* Privacy Mode */} - {hasProvider && ( -
-
-
-

{t("settings.rawData")}

-

- {settings?.privacyMode === "raw" - ? t("settings.rawDataOnDescription") - : t("settings.rawDataOffDescription")} -

-
-
- -
-
- {settings?.privacyMode === "raw" && ( -
- {t("settings.rawDataWarning")} -
- )} -
- )} - - {/* Regenerate Reports */} - {hasProvider && ( - - )} - - {/* Multi-provider AI selection (per-user override) */} - -
-
- ); -} - -/* ─────────────────────── User AI Provider Override ─────────────────────── */ - -function UserAIProviderSubsection() { - const queryClient = useQueryClient(); - const [provider, setProvider] = useState(""); - const [model, setModel] = useState(""); - const [baseUrl, setBaseUrl] = useState(""); - const [anthropicKey, setAnthropicKey] = useState(""); - const [localKey, setLocalKey] = useState(""); - const [saveMsg, setSaveMsg] = useState(null); - const [saveOk, setSaveOk] = useState(false); - const [testMsg, setTestMsg] = useState(null); - const [testOk, setTestOk] = useState(false); - const [testing, setTesting] = useState(false); - - const { data, isLoading } = useQuery({ - queryKey: ["user", "ai-provider"], - queryFn: async () => { - const res = await fetch("/api/user/ai-provider"); - if (!res.ok) return null; - const json = await res.json(); - return json.data as { - provider: string | null; - model: string | null; - baseUrl: string | null; - hasAnthropicKey: boolean; - anthropicKeyPreview: string | null; - hasLocalKey: boolean; - }; - }, - }); - - useEffect(() => { - if (!data) return; - // Sync-from-server pattern: form draft state is initialised from the - // fetched user record. Could be reworked into a derived-state approach, - // but that's a non-trivial refactor of this monolithic settings page — - // tracked for the 1.4.0 settings split. - // eslint-disable-next-line react-hooks/set-state-in-effect - setProvider(data.provider ?? ""); - // eslint-disable-next-line react-hooks/set-state-in-effect - setModel(data.model ?? ""); - // eslint-disable-next-line react-hooks/set-state-in-effect - setBaseUrl(data.baseUrl ?? ""); - }, [data]); - - const saveMutation = useMutation({ - mutationFn: async () => { - const body: Record = { - provider: provider || null, - model: model || null, - baseUrl: baseUrl || null, - }; - if (anthropicKey.trim()) body.anthropicKey = anthropicKey.trim(); - if (localKey.trim()) body.localKey = localKey.trim(); - const res = await fetch("/api/user/ai-provider", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - const json = await res.json(); - if (!res.ok) throw new Error(json.error || "Save failed"); - }, - onSuccess: () => { - setSaveMsg("Gespeichert"); - setSaveOk(true); - setAnthropicKey(""); - setLocalKey(""); - queryClient.invalidateQueries({ queryKey: ["user", "ai-provider"] }); - queryClient.invalidateQueries({ queryKey: ["insights"] }); - }, - onError: (e) => { - setSaveMsg(e instanceof Error ? e.message : "Fehler"); - setSaveOk(false); - }, - }); - - async function runTest() { - setTesting(true); - setTestMsg(null); - try { - // v1.4: pass the *current* dropdown / input selection so the test - // honours unsaved changes. Plaintext keys never persist server-side. - const overrideBody: Record = {}; - if (provider) overrideBody.provider = provider; - if (model.trim()) overrideBody.model = model.trim(); - if (baseUrl.trim()) overrideBody.baseUrl = baseUrl.trim(); - if (anthropicKey.trim()) overrideBody.anthropicKey = anthropicKey.trim(); - if (localKey.trim()) overrideBody.localKey = localKey.trim(); - const hasOverride = Object.keys(overrideBody).length > 0; - const res = await fetch("/api/ai/test", { - method: "POST", - headers: hasOverride ? { "Content-Type": "application/json" } : {}, - body: hasOverride ? JSON.stringify(overrideBody) : undefined, - }); - const json = await res.json(); - if (!res.ok) { - setTestMsg(json.error || `HTTP ${res.status}`); - setTestOk(false); - return; - } - setTestMsg( - `OK — ${json.data.providerType} (${json.data.model})${ - json.data.tokensUsed ? `, ${json.data.tokensUsed} tokens` : "" - }`, - ); - setTestOk(true); - } catch (e) { - setTestMsg(e instanceof Error ? e.message : "Test fehlgeschlagen"); - setTestOk(false); - } finally { - setTesting(false); - } - } - - if (isLoading) return null; - - return ( -
-
-

KI-Provider (persönlich)

-

- Eigener KI-Anbieter überschreibt die Admin-Einstellung. Leer lassen - für Standard. -

-
- -
-
- - -
- -
- - setModel(e.target.value)} - placeholder={ - provider === "ANTHROPIC" - ? "claude-3-5-sonnet-latest" - : provider === "LOCAL" - ? "llama3:8b" - : "gpt-4o-mini" - } - className="mt-1" - /> -
- - {provider === "ANTHROPIC" && ( -
- - setAnthropicKey(e.target.value)} - placeholder="sk-ant-..." - className="mt-1" - /> -
- )} - - {provider === "LOCAL" && ( - <> -
- - setBaseUrl(e.target.value)} - placeholder="http://localhost:11434/v1" - className="mt-1" - /> -
-
- - setLocalKey(e.target.value)} - placeholder={data?.hasLocalKey ? "(gespeichert)" : ""} - className="mt-1" - /> -
- - )} -
- -
- - -
- - {saveMsg && ( -

- {saveMsg} -

- )} - {testMsg && ( -

- {testMsg} -

- )} -
- ); +/** + * `/settings` is a thin permanent redirect to `/settings/account`. + * + * Pre-v1.4 the entire settings UI lived in this single 3000+ LOC file. As part + * of the v1.4 settings split (PR series A2), each concern moved to its own + * route under `/settings/[section]/page.tsx`. The historic monolith is + * preserved verbatim in `page.legacy.tsx` for section-by-section extraction in + * the follow-up PRs (A2-account, A2-about, A2-ai, A2-integrations, + * A2-notifications, A2-rest). + * + * 308 (`permanentRedirect`) keeps the request method intact and is the right + * answer for a permanent address change — bookmarks, deep-links, and any + * external systems that POST to `/settings` will follow the redirect with + * their original method. + */ +export default function SettingsRoot(): never { + permanentRedirect("/settings/account"); } diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx index ccfc513ee..e16d6584b 100644 --- a/src/components/layout/sidebar-nav.tsx +++ b/src/components/layout/sidebar-nav.tsx @@ -158,7 +158,10 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
- + {gravatarUrl && ( @@ -193,7 +196,10 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) { )}
- + {dropdownContent} @@ -364,7 +370,9 @@ export function SidebarNav() { - + {t("nav.settings")} diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx new file mode 100644 index 000000000..06c1a9fcc --- /dev/null +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -0,0 +1,153 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +// `` reads the active route from `usePathname()`. We stub +// next/navigation so the SSR test render works without an App-Router +// runtime. The mock reads `mockPathnameRef.value` at call-time (not at +// module-load) so each test can mutate the ref before rendering and the +// shell will see the updated path. +const mockPathnameRef = { value: "/settings/account" }; +vi.mock("next/navigation", () => ({ + usePathname: () => mockPathnameRef.value, +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { + SETTINGS_SECTION_SLUGS, + SETTINGS_SECTIONS, + SettingsShell, + isSettingsSectionSlug, +} from "../settings-shell"; + +function renderShell(props: { + active?: (typeof SETTINGS_SECTION_SLUGS)[number]; + pathname?: string; + locale?: "en" | "de"; +}) { + mockPathnameRef.value = props.pathname ?? "/settings/account"; + return renderToStaticMarkup( + + +
section body
+
+
, + ); +} + +describe("SETTINGS_SECTION_SLUGS", () => { + it("declares exactly the eight sections agreed for v1.4", () => { + // Order matters — `generateStaticParams()` and the sidebar derive their + // ordering from this constant, so a reorder is a behaviour change. + expect([...SETTINGS_SECTION_SLUGS]).toEqual([ + "account", + "integrations", + "notifications", + "dashboard", + "ai", + "api", + "advanced", + "about", + ]); + }); + + it("`isSettingsSectionSlug` accepts known slugs and rejects others", () => { + for (const slug of SETTINGS_SECTION_SLUGS) { + expect(isSettingsSectionSlug(slug)).toBe(true); + } + expect(isSettingsSectionSlug("nope")).toBe(false); + expect(isSettingsSectionSlug("")).toBe(false); + expect(isSettingsSectionSlug("Account")).toBe(false); // case-sensitive + }); + + it("each section has a non-empty title key under settings.sections.", () => { + for (const section of SETTINGS_SECTIONS) { + expect(section.titleKey).toBe(`settings.sections.${section.slug}.title`); + } + }); +}); + +describe("", () => { + it("renders all eight section links — once for the mobile strip and once for the desktop sidebar", () => { + const html = renderShell({ active: "account" }); + for (const slug of SETTINGS_SECTION_SLUGS) { + const matches = html.match(new RegExp(`href="/settings/${slug}"`, "g")); + // Two renders — mobile strip + desktop sidebar — guarantee the link + // exists in both layouts. Tablet/desktop hide the strip with `md:`, + // but the markup is always present so it's keyboard-discoverable + // before media queries resolve. + expect(matches?.length ?? 0).toBe(2); + } + }); + + it("links use the correct `/settings/` href — regression guard against typos", () => { + const html = renderShell({ active: "account" }); + // Spot-check a couple of slugs that have historic anchor aliases (e.g. + // `/settings#withings` → `/settings/integrations`) — they must NOT + // sneak back in. + expect(html).toContain('href="/settings/integrations"'); + expect(html).toContain('href="/settings/ai"'); + expect(html).not.toContain('href="/settings#'); + expect(html).not.toContain('href="/settings"'); + }); + + it('marks the active section with aria-current="page" (and only that one)', () => { + const html = renderShell({ active: "notifications" }); + // Both layouts emit the active link, so two `aria-current="page"`. + const occurrences = html.match(/aria-current="page"/g); + expect(occurrences?.length ?? 0).toBe(2); + + // And the active link is on the notifications href. Attribute order + // in React SSR is alphabetic, so `aria-current` precedes `href` — + // the regex deliberately doesn't pin order to stay robust against + // future React renderer changes. + const activeLinkRegex = + /]*\baria-current="page"[^>]*\bhref="\/settings\/notifications"|]*\bhref="\/settings\/notifications"[^>]*\baria-current="page"/g; + expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2); + }); + + it("derives the active slug from the current pathname when `active` is omitted", () => { + const html = renderShell({ pathname: "/settings/api" }); + const activeLinkRegex = + /]*\baria-current="page"[^>]*\bhref="\/settings\/api"|]*\bhref="\/settings\/api"[^>]*\baria-current="page"/g; + expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2); + }); + + it("falls back to `account` when the pathname doesn't match a known slug", () => { + const html = renderShell({ pathname: "/settings/totally-bogus" }); + const activeLinkRegex = + /]*\baria-current="page"[^>]*\bhref="\/settings\/account"|]*\bhref="\/settings\/account"[^>]*\baria-current="page"/g; + expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2); + }); + + it("resolves every section title via the i18n provider — English", () => { + const html = renderShell({ active: "account", locale: "en" }); + expect(html).toContain("Account"); + expect(html).toContain("Integrations"); + expect(html).toContain("Notifications"); + expect(html).toContain("Dashboard"); + expect(html).toContain("AI Insights"); + // The ampersand is HTML-escaped by React SSR — assert on the encoded + // form so we don't accidentally match a parser that double-escapes. + expect(html).toContain("API & Tokens"); + expect(html).toContain("Advanced"); + expect(html).toContain("About"); + }); + + it("resolves every section title via the i18n provider — German", () => { + const html = renderShell({ active: "account", locale: "de" }); + expect(html).toContain("Konto"); + expect(html).toContain("Integrationen"); + expect(html).toContain("Benachrichtigungen"); + expect(html).toContain("Übersicht"); + expect(html).toContain("KI-Auswertungen"); + // API & Tokens is identical in both locales (proper noun + ampersand) + expect(html).toContain("API & Tokens"); + expect(html).toContain("Erweitert"); + expect(html).toContain("Über"); + }); + + it("does NOT surface the raw key when a translation resolves — guards against missing JSON entries", () => { + const html = renderShell({ active: "account", locale: "en" }); + expect(html).not.toContain("settings.sections."); + }); +}); diff --git a/src/components/settings/section-placeholder.tsx b/src/components/settings/section-placeholder.tsx new file mode 100644 index 000000000..5aff0d212 --- /dev/null +++ b/src/components/settings/section-placeholder.tsx @@ -0,0 +1,73 @@ +"use client"; + +/** + * `` — the temporary body of every `/settings/[section]` + * route until the matching extraction PR lands. + * + * The shell + routing surface ship in PR A2-shell so reviewers can validate + * the URL structure in isolation. The follow-up PRs (A2-account, A2-about, + * A2-ai, A2-integrations, A2-notifications, A2-rest) replace this placeholder + * with the real content for each slug, copying from + * `src/app/settings/page.legacy.tsx`. + */ + +import { + Bell, + Info, + KeyRound, + LayoutDashboard, + Link2, + Settings2, + Sparkles, + User, + type LucideIcon, +} from "lucide-react"; + +import { EmptyState } from "@/components/ui/empty-state"; +import { useTranslations } from "@/lib/i18n/context"; +import type { SettingsSectionSlug } from "./section-slugs"; + +const SLUG_ICON: Record = { + account: User, + integrations: Link2, + notifications: Bell, + dashboard: LayoutDashboard, + ai: Sparkles, + api: KeyRound, + advanced: Settings2, + about: Info, +}; + +export interface SectionPlaceholderProps { + slug: SettingsSectionSlug; +} + +export function SectionPlaceholder({ slug }: SectionPlaceholderProps) { + const { t } = useTranslations(); + const Icon = SLUG_ICON[slug]; + const sectionTitle = t(`settings.sections.${slug}.title`); + const sectionDescription = t(`settings.sections.${slug}.description`); + + return ( +
+
+

+ {sectionTitle} +

+

{sectionDescription}

+
+ + } + title={sectionTitle} + description={t("settings.sections.placeholder.coming_soon")} + /> +
+ ); +} diff --git a/src/components/settings/section-slugs.ts b/src/components/settings/section-slugs.ts new file mode 100644 index 000000000..7198be4ea --- /dev/null +++ b/src/components/settings/section-slugs.ts @@ -0,0 +1,26 @@ +/** + * Server-safe constants for the v1.4 settings split. Kept in a separate + * module from `` (a client component) so server-rendered code + * paths — `generateStaticParams()`, the page guard, future tests — can import + * the slug list without dragging the whole client bundle into the server + * graph. + */ + +export const SETTINGS_SECTION_SLUGS = [ + "account", + "integrations", + "notifications", + "dashboard", + "ai", + "api", + "advanced", + "about", +] as const; + +export type SettingsSectionSlug = (typeof SETTINGS_SECTION_SLUGS)[number]; + +export function isSettingsSectionSlug( + value: string, +): value is SettingsSectionSlug { + return (SETTINGS_SECTION_SLUGS as readonly string[]).includes(value); +} diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx new file mode 100644 index 000000000..d329e3bbd --- /dev/null +++ b/src/components/settings/settings-shell.tsx @@ -0,0 +1,190 @@ +"use client"; + +/** + * `` — sidebar + main column shell for the v1.4 settings split. + * + * Each section lives at its own route (`/settings/[section]/page.tsx`) and + * mounts inside this shell. The shell owns nothing but navigation: the active + * section is read from the route, the section heading + content are rendered + * by the page itself. + * + * Mobile-first per `docs/ui-guidelines.md`: + * - =md: sticky 220px sidebar + content column with `max-w-screen-xl`. + */ + +import * as React from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { + Bell, + Info, + KeyRound, + LayoutDashboard, + Link2, + Settings2, + Sparkles, + User, + type LucideIcon, +} from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { useTranslations } from "@/lib/i18n/context"; +import { + SETTINGS_SECTION_SLUGS, + isSettingsSectionSlug, + type SettingsSectionSlug, +} from "./section-slugs"; + +// Re-export so existing call-sites importing from this module keep working. +// New server-side call-sites (e.g. `generateStaticParams()`) should import +// from `./section-slugs` directly, since this file is `"use client"` and +// values exported from a client module become unusable proxies on the +// server. (Discovered the hard way: `SETTINGS_SECTION_SLUGS.map(...)` in +// `generateStaticParams()` blows up at build time when imported through a +// client boundary.) +export { + SETTINGS_SECTION_SLUGS, + isSettingsSectionSlug, + type SettingsSectionSlug, +}; + +interface SettingsSection { + slug: SettingsSectionSlug; + /** i18n key under `settings.sections..title`. */ + titleKey: string; + icon: LucideIcon; +} + +/** + * Source of truth for the section list. Order matches the in-app navigation + * order so the sidebar, mobile strip, and `generateStaticParams()` all line + * up. Don't reorder without updating tests. + */ +export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ + { slug: "account", titleKey: "settings.sections.account.title", icon: User }, + { + slug: "integrations", + titleKey: "settings.sections.integrations.title", + icon: Link2, + }, + { + slug: "notifications", + titleKey: "settings.sections.notifications.title", + icon: Bell, + }, + { + slug: "dashboard", + titleKey: "settings.sections.dashboard.title", + icon: LayoutDashboard, + }, + { slug: "ai", titleKey: "settings.sections.ai.title", icon: Sparkles }, + { slug: "api", titleKey: "settings.sections.api.title", icon: KeyRound }, + { + slug: "advanced", + titleKey: "settings.sections.advanced.title", + icon: Settings2, + }, + { slug: "about", titleKey: "settings.sections.about.title", icon: Info }, +] as const; + +export interface SettingsShellProps { + /** + * Optional override for the active section. When omitted the shell reads + * the active slug from `usePathname()` (the production behaviour). Tests + * pass an explicit value so they don't need to mock the router. + */ + active?: SettingsSectionSlug; + children: React.ReactNode; +} + +function deriveActiveSlug( + pathname: string | null, + override?: SettingsSectionSlug, +): SettingsSectionSlug { + if (override) return override; + if (!pathname) return "account"; + // Match `/settings/` and ignore any trailing segments (none today, + // but cheap insurance for future nested routes). + const match = pathname.match(/^\/settings\/([^/]+)/); + const candidate = match?.[1] ?? ""; + return isSettingsSectionSlug(candidate) ? candidate : "account"; +} + +export function SettingsShell({ active, children }: SettingsShellProps) { + const pathname = usePathname(); + const { t } = useTranslations(); + const activeSlug = deriveActiveSlug(pathname, active); + + return ( +
+ {/* Mobile section strip — horizontal scroll, hidden on md+. */} + + +
+ {/* Desktop sticky sidebar */} + + + {/* Main column — page renders its own h1 + subtitle */} +
{children}
+
+
+ ); +} diff --git a/src/lib/onboarding/__tests__/checklist.test.ts b/src/lib/onboarding/__tests__/checklist.test.ts index 2190c9658..3e1e5f948 100644 --- a/src/lib/onboarding/__tests__/checklist.test.ts +++ b/src/lib/onboarding/__tests__/checklist.test.ts @@ -83,11 +83,11 @@ describe("buildChecklist", () => { it("attaches deep-link hrefs", () => { const items = buildChecklist(inputs()); const hrefs = Object.fromEntries(items.map((i) => [i.id, i.href])); - expect(hrefs.profile).toBe("/settings#profile"); + expect(hrefs.profile).toBe("/settings/account"); expect(hrefs.measurement).toBe("/measurements"); expect(hrefs.medication).toBe("/medications"); - expect(hrefs.withings).toBe("/settings#withings"); - expect(hrefs.notifications).toBe("/settings#notifications"); + expect(hrefs.withings).toBe("/settings/integrations"); + expect(hrefs.notifications).toBe("/settings/notifications"); }); }); diff --git a/src/lib/onboarding/checklist.ts b/src/lib/onboarding/checklist.ts index 179d1fc9d..3e8bc2bec 100644 --- a/src/lib/onboarding/checklist.ts +++ b/src/lib/onboarding/checklist.ts @@ -19,7 +19,12 @@ export type ChecklistItemId = (typeof CHECKLIST_ITEM_IDS)[number]; export interface ChecklistItem { id: ChecklistItemId; done: boolean; - /** Pre-built href the CTA should jump to. Hash anchors target settings sections. */ + /** + * Pre-built href the CTA should jump to. The settings entries point at + * the v1.4 split routes under `/settings/[section]` (introduced by PR + * A2-shell), so the CTA lands directly on the right section instead of + * relying on a hash anchor on the legacy monolith. + */ href: string; /** True if the user explicitly hid this row. */ dismissed: boolean; @@ -58,7 +63,7 @@ export function buildChecklist(inputs: ChecklistInputs): ChecklistItem[] { { id: "profile", done: profileDone, - href: "/settings#profile", + href: "/settings/account", dismissed: inputs.dismissedIds.has("profile"), }, { @@ -76,13 +81,13 @@ export function buildChecklist(inputs: ChecklistInputs): ChecklistItem[] { { id: "withings", done: inputs.withingsConnected, - href: "/settings#withings", + href: "/settings/integrations", dismissed: inputs.dismissedIds.has("withings"), }, { id: "notifications", done: inputs.notificationsConfigured, - href: "/settings#notifications", + href: "/settings/notifications", dismissed: inputs.dismissedIds.has("notifications"), }, ];