From 8f0bb94caf5b92f19ef4a794740e2c43eaf0c11d Mon Sep 17 00:00:00 2001 From: Ralksta <16941050+ralksta@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:27:09 +0200 Subject: [PATCH] fix: keep /admin reachable while setup is required (#392) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root layout replaced the entire app with the SetupScreen whenever the config was incomplete (missing gallery.yaml or Immich credentials) — including /admin, which lives under the same root layout. That locked users out of the very tool that fixes an incomplete configuration. The middleware now forwards the request pathname via an x-pathname header, and the root layout skips the setup screen for admin paths, so /admin renders normally (login when ADMIN_PASSWORD is set, the enable-hint otherwise) while every other route keeps showing the setup instructions. Fixes #326 Co-authored-by: ralksta Co-authored-by: Claude Fable 5 --- app/layout.tsx | 9 +++++++-- lib/__tests__/admin-paths.test.ts | 27 +++++++++++++++++++++++++++ lib/admin/paths.ts | 12 ++++++++++++ middleware.ts | 3 +++ 4 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 lib/__tests__/admin-paths.test.ts create mode 100644 lib/admin/paths.ts diff --git a/app/layout.tsx b/app/layout.tsx index c6ef554..0721698 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -5,6 +5,7 @@ */ import type { Metadata } from 'next'; +import { headers } from 'next/headers'; import Link from 'next/link'; import './globals.css'; import { SubpageNav } from '@/components/SubpageNav'; @@ -13,6 +14,7 @@ import { ScrollToTop } from '@/components/ScrollToTop'; import { Footer } from '@/components/Footer'; import { SetupScreen } from '@/components/SetupScreen'; import { getConfig, getGoogleFontsUrl, AppConfig } from '@/lib/config'; +import { isAdminPath } from '@/lib/admin/paths'; // DevToolbarLoader is a Client Component (ssr: false is only allowed there) import { DevToolbarLoader } from '@/components/DevToolbarLoader'; @@ -47,7 +49,7 @@ export async function generateMetadata(): Promise { }; } -export default function RootLayout({ children }: { children: React.ReactNode }) { +export default async function RootLayout({ children }: { children: React.ReactNode }) { const config = getConfig(); const { theme } = config; const fontsUrl = getGoogleFontsUrl(theme); @@ -63,7 +65,10 @@ export default function RootLayout({ children }: { children: React.ReactNode }) '--radius-lg': `${theme.radius * 2}px`, }; - if ((config as AppConfig & { needsSetup?: boolean }).needsSetup) { + // /admin stays reachable during setup — it is the tool that completes the + // setup, so the setup screen must not lock it out (#326). + const pathname = (await headers()).get('x-pathname'); + if ((config as AppConfig & { needsSetup?: boolean }).needsSetup && !isAdminPath(pathname)) { return ( diff --git a/lib/__tests__/admin-paths.test.ts b/lib/__tests__/admin-paths.test.ts new file mode 100644 index 0000000..7744c85 --- /dev/null +++ b/lib/__tests__/admin-paths.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { isAdminPath } from '@/lib/admin/paths'; + +// The root layout must keep /admin reachable while the app is in setup mode +// (missing gallery.yaml or Immich credentials) — the admin panel is the tool +// that fixes exactly that state (#326). +describe('isAdminPath', () => { + it('matches /admin exactly', () => { + expect(isAdminPath('/admin')).toBe(true); + }); + + it('matches nested admin paths', () => { + expect(isAdminPath('/admin/settings')).toBe(true); + }); + + it('does not match the homepage', () => { + expect(isAdminPath('/')).toBe(false); + }); + + it('does not match sibling paths that merely start with "admin"', () => { + expect(isAdminPath('/administration')).toBe(false); + }); + + it('treats a missing pathname header as non-admin', () => { + expect(isAdminPath(null)).toBe(false); + }); +}); diff --git a/lib/admin/paths.ts b/lib/admin/paths.ts new file mode 100644 index 0000000..626cdc6 --- /dev/null +++ b/lib/admin/paths.ts @@ -0,0 +1,12 @@ +/** + * Whether a request pathname belongs to the admin panel. + * + * Used by the root layout to keep /admin reachable while the app is in + * setup mode: the setup screen must not lock out the very tool that fixes + * an incomplete configuration (#326). A null pathname (header missing, + * e.g. a route not covered by the middleware matcher) counts as non-admin + * so the setup screen remains the safe default. + */ +export function isAdminPath(pathname: string | null): boolean { + return pathname === '/admin' || pathname?.startsWith('/admin/') === true; +} diff --git a/middleware.ts b/middleware.ts index 2c14930..2b21b4b 100644 --- a/middleware.ts +++ b/middleware.ts @@ -23,6 +23,9 @@ export function middleware(request: NextRequest) { const requestHeaders = new Headers(request.headers); requestHeaders.set('x-nonce', nonce); + // Layouts cannot read the request URL; the root layout needs the pathname + // to keep /admin reachable while the app is in setup mode. + requestHeaders.set('x-pathname', request.nextUrl.pathname); requestHeaders.set('Content-Security-Policy', cspDirectives); const response = NextResponse.next({