From 3221f86eeade467f5e4c2f7fb0fe5c8fe6a28d38 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 8 May 2026 14:15:15 +0200
Subject: [PATCH] =?UTF-8?q?feat(settings):=20introduce=20dynamic-route=20s?=
=?UTF-8?q?hell=20+=20redirect=20/settings=20=E2=86=92=20/settings/account?=
=?UTF-8?q?=20(#139)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(settings): introduce dynamic-route shell + redirect /settings → /settings/account
Splits the 3218-LOC /settings monolith into a routing surface only — no
section content moves in this PR. Each of the eight slugs (account,
integrations, notifications, dashboard, ai, api, advanced, about) now lives
at its own /settings/[section] route and renders inside a new
sidebar+main layout. Every section currently shows a
placeholder ("Moving here with the next release update") so reviewers can
validate the URL structure in isolation. The historic monolith is preserved
verbatim as page.legacy.tsx for section-by-section extraction by the
follow-up PRs (A2-account, A2-about, A2-ai, A2-integrations,
A2-notifications, A2-rest).
Behaviour change: existing entry points (sidebar nav, topbar dropdown,
onboarding hand-off, dashboard checklist, Withings callback, Codex
callback) now point at /settings/ instead of /settings#anchor. The
/settings root is a 308 (permanentRedirect) to /settings/account so old
bookmarks and external links keep working.
Co-Authored-By: Marc-André Bombeck
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix(settings): route every Codex OAuth callback redirect to /settings/integrations
Review-pass H1: only the late success/failure paths in the Codex OAuth
callback were updated to point at the new section route. The four early
branches — rate-limited, provider-supplied `?error=...`, missing params,
invalid state — still redirected to `/settings?codex_error=...`.
After the section split that path 308-redirects to `/settings/account`,
where the Codex error toast/banner doesn't live. Users hitting any of
those edge cases would land on the wrong screen with the error code
silently lost.
All six branches now go to `/settings/integrations?codex_error=...`,
matching the success and token-exchange-failure paths and the existing
Withings callback shape.
---------
Co-authored-by: Claude Opus 4.7 (1M context)
---
messages/de.json | 40 +
messages/en.json | 40 +
src/app/api/auth/codex/callback/route.ts | 18 +-
src/app/api/withings/callback/route.ts | 19 +-
src/app/notifications/page.tsx | 2 +-
src/app/onboarding/page.tsx | 9 +-
src/app/page.tsx | 2 +-
src/app/settings/[section]/page.tsx | 45 +
src/app/settings/page.legacy.tsx | 3230 ++++++++++++++++
src/app/settings/page.tsx | 3234 +----------------
src/components/layout/sidebar-nav.tsx | 30 +-
src/components/layout/top-bar.tsx | 2 +-
.../__tests__/settings-shell.test.tsx | 153 +
.../settings/section-placeholder.tsx | 73 +
src/components/settings/section-slugs.ts | 26 +
src/components/settings/settings-shell.tsx | 190 +
.../onboarding/__tests__/checklist.test.ts | 6 +-
src/lib/onboarding/checklist.ts | 13 +-
18 files changed, 3879 insertions(+), 3253 deletions(-)
create mode 100644 src/app/settings/[section]/page.tsx
create mode 100644 src/app/settings/page.legacy.tsx
create mode 100644 src/components/settings/__tests__/settings-shell.test.tsx
create mode 100644 src/components/settings/section-placeholder.tsx
create mode 100644 src/components/settings/section-slugs.ts
create mode 100644 src/components/settings/settings-shell.tsx
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")}