feat(settings): introduce dynamic-route shell + redirect /settings → /settings/account (#139)

* 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
<SettingsShell> 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/<slug> 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 <mbombeck@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 authored and GitHub committed 2026-05-08 14:15:15 +02:00
1 parent ddab92e42b
commit 3221f86eea
18 files changed
+3879 -3253

No files matched your search

+9 -9
View File
@@ -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`,
);
}
});
+12 -7
View File
@@ -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!,
),
);
+1 -1
View File
@@ -163,7 +163,7 @@ export default function NotificationsPage() {
{t("notifications.noChannels")}
</p>
<Button asChild variant="outline">
<Link href="/settings">
<Link href="/settings/notifications">
<Settings className="mr-2 h-4 w-4" />
{t("notifications.goToSettings")}
</Link>
+7 -2
View File
@@ -32,7 +32,12 @@ const CHANNEL_OPTIONS: ReadonlyArray<{
Icon: typeof MessageCircle;
titleKey: string;
hintKey: string;
/** Hash on /settings to scroll to. NONE = skip, no hash. */
/**
* Hash anchor on the notifications settings page to scroll to. NONE = skip,
* no hash. The wizard pushes the user to `/settings/notifications#<hash>`
* after onboarding finishes — see the v1.4 settings split (PR A2-shell)
* for the new route shape.
*/
hash: string | null;
}> = [
{
@@ -117,7 +122,7 @@ export default function OnboardingPage() {
toast.success(t("onboarding.v2.doneToast"));
if (opts?.gotoSettingsHash) {
router.replace(`/settings#${opts.gotoSettingsHash}`);
router.replace(`/settings/notifications#${opts.gotoSettingsHash}`);
} else {
router.replace("/");
}
+1 -1
View File
@@ -861,7 +861,7 @@ export default function DashboardPage() {
* exactly the chart-width below. When the active tile count
* exceeds what fits the viewport, the strip horizontal-scrolls
* instead of wrapping; the user trims the set in
* Settings → Dashboard Layout (`/settings#dashboard-layout`).
* Settings → Dashboard (`/settings/dashboard`).
* Each tile keeps a `min-w-[10rem]` so a single tile still
* looks substantial on a wide screen, and `snap-x snap-mandatory`
* makes the scroll feel deliberate rather than arbitrary on
+45
View File
@@ -0,0 +1,45 @@
import { notFound } from "next/navigation";
import { SectionPlaceholder } from "@/components/settings/section-placeholder";
import {
SETTINGS_SECTION_SLUGS,
isSettingsSectionSlug,
} from "@/components/settings/section-slugs";
import { SettingsShell } from "@/components/settings/settings-shell";
/**
* Dynamic settings section route. Each of the eight `SETTINGS_SECTION_SLUGS`
* is pre-rendered at build via `generateStaticParams()` so the URLs are
* statically known to Next.js, while the `dynamicParams = false` flag below
* tells the router to 404 (instead of attempting on-demand rendering) for any
* slug not in the list — which is exactly what `notFound()` would do at
* request time, just earlier and without rendering.
*/
export const dynamicParams = false;
export function generateStaticParams() {
return SETTINGS_SECTION_SLUGS.map((section) => ({ section }));
}
interface PageProps {
// Next.js 16 made route `params` an async Promise. We `await` it before use.
params: Promise<{ section: string }>;
}
export default async function SettingsSectionPage({ params }: PageProps) {
const { section } = await params;
// Defence-in-depth — `dynamicParams = false` already 404s unknown slugs at
// routing time, but we re-check here so a hand-rolled override of the route
// config can never silently fall through to a typo'd slug.
if (!isSettingsSectionSlug(section)) {
notFound();
}
return (
<SettingsShell active={section}>
<SectionPlaceholder slug={section} />
</SettingsShell>
);
}
File diff suppressed because it is too large. Load diff
+18 -3216
View File
File diff suppressed because it is too large. Load diff