fix: keep /admin reachable while setup is required (#392)
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 <ralksta@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
import { SubpageNav } from '@/components/SubpageNav';
|
import { SubpageNav } from '@/components/SubpageNav';
|
||||||
@@ -13,6 +14,7 @@ import { ScrollToTop } from '@/components/ScrollToTop';
|
|||||||
import { Footer } from '@/components/Footer';
|
import { Footer } from '@/components/Footer';
|
||||||
import { SetupScreen } from '@/components/SetupScreen';
|
import { SetupScreen } from '@/components/SetupScreen';
|
||||||
import { getConfig, getGoogleFontsUrl, AppConfig } from '@/lib/config';
|
import { getConfig, getGoogleFontsUrl, AppConfig } from '@/lib/config';
|
||||||
|
import { isAdminPath } from '@/lib/admin/paths';
|
||||||
// DevToolbarLoader is a Client Component (ssr: false is only allowed there)
|
// DevToolbarLoader is a Client Component (ssr: false is only allowed there)
|
||||||
import { DevToolbarLoader } from '@/components/DevToolbarLoader';
|
import { DevToolbarLoader } from '@/components/DevToolbarLoader';
|
||||||
|
|
||||||
@@ -47,7 +49,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
const { theme } = config;
|
const { theme } = config;
|
||||||
const fontsUrl = getGoogleFontsUrl(theme);
|
const fontsUrl = getGoogleFontsUrl(theme);
|
||||||
@@ -63,7 +65,10 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
'--radius-lg': `${theme.radius * 2}px`,
|
'--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 (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
27
lib/__tests__/admin-paths.test.ts
Normal file
27
lib/__tests__/admin-paths.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
12
lib/admin/paths.ts
Normal file
12
lib/admin/paths.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -23,6 +23,9 @@ export function middleware(request: NextRequest) {
|
|||||||
|
|
||||||
const requestHeaders = new Headers(request.headers);
|
const requestHeaders = new Headers(request.headers);
|
||||||
requestHeaders.set('x-nonce', nonce);
|
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);
|
requestHeaders.set('Content-Security-Policy', cspDirectives);
|
||||||
|
|
||||||
const response = NextResponse.next({
|
const response = NextResponse.next({
|
||||||
|
|||||||
Reference in New Issue
Block a user