diff --git a/app/[locale]/(app)/admin/layout.tsx b/app/[locale]/(app)/admin/layout.tsx
new file mode 100644
index 0000000..e326549
--- /dev/null
+++ b/app/[locale]/(app)/admin/layout.tsx
@@ -0,0 +1,30 @@
+// WARNING: Do NOT add `'use cache'` here — requireRole() reads cookies().
+
+/**
+ * Admin route-group layout — Server Component.
+ *
+ * Second gate for all routes under /admin/**. The parent (app)/layout.tsx
+ * already validated that a session exists; this layout additionally enforces
+ * role === 'admin'.
+ *
+ * Non-admin authenticated users are redirected to /dashboard rather than
+ * receiving a 404, which would confirm that restricted admin routes exist.
+ */
+
+import type { ReactNode } from "react";
+import { requireRole } from "@/lib/auth/require-role";
+
+export default async function AdminLayout({
+ children,
+ params,
+}: {
+ children: ReactNode;
+ params: Promise<{ locale: string }>;
+}) {
+ const { locale } = await params;
+
+ // Redirects to /dashboard if role !== 'admin'.
+ await requireRole(["admin"], locale);
+
+ return <>{children}>;
+}
diff --git a/app/[locale]/(app)/dashboard/page.tsx b/app/[locale]/(app)/dashboard/page.tsx
new file mode 100644
index 0000000..5ec1bdc
--- /dev/null
+++ b/app/[locale]/(app)/dashboard/page.tsx
@@ -0,0 +1,35 @@
+/**
+ * Dashboard placeholder — Server Component.
+ *
+ * Displays a welcome message with the user's email and role.
+ * Real dashboard content (stats, queues, etc.) lands in Phase 2+.
+ */
+
+import { getTranslations } from "next-intl/server";
+import { getServerSession } from "@/lib/auth/get-server-session";
+
+export default async function DashboardPage({ params }: { params: Promise<{ locale: string }> }) {
+ // params must be awaited in Next.js 16 App Router.
+ await params;
+
+ const t = await getTranslations("dashboard");
+ const session = await getServerSession();
+
+ // session is guaranteed non-null by the parent (app)/layout.tsx gate,
+ // but we guard here to satisfy TypeScript's strict null checks.
+ const email = session?.user.email ?? "";
+ const role = session?.role ?? "";
+
+ return (
+
+
{t("welcome", { email })}
+
+ {t("roleLabel")}{" "}
+
+ {role}
+
+
+
{t("placeholder")}
+
+ );
+}
diff --git a/app/[locale]/(app)/layout.tsx b/app/[locale]/(app)/layout.tsx
new file mode 100644
index 0000000..b9d5082
--- /dev/null
+++ b/app/[locale]/(app)/layout.tsx
@@ -0,0 +1,60 @@
+// WARNING: Do NOT add `'use cache'` here — getServerSession() reads cookies().
+// Caching this scope would serve stale auth state across users.
+
+/**
+ * (app) route-group layout — Server Component.
+ *
+ * Gate: requires a valid session AND a non-null role.
+ * - No session (unauthenticated) → redirect to /sign-in
+ * - Session but role is null → user is authed but unenrolled (e.g. admin
+ * deleted their app_users row mid-session). Sign out and redirect to /sign-in
+ * so the user is not silently stuck in a broken state.
+ * - Session + role present → render the AppShell.
+ *
+ * The proxy (middleware) already redirects unauthenticated requests away from
+ * /dashboard and /admin. This layout is defense-in-depth and handles the
+ * "authed but unenrolled" edge case the proxy cannot detect.
+ */
+
+import type { ReactNode } from "react";
+import { redirect } from "@/i18n/navigation";
+import { getServerSession } from "@/lib/auth/get-server-session";
+import { signOutAction } from "@/app/[locale]/(auth)/sign-in/actions";
+import { AppShell } from "@/components/app-shell/app-shell";
+
+export default async function AppLayout({
+ children,
+ params,
+}: {
+ children: ReactNode;
+ params: Promise<{ locale: string }>;
+}) {
+ const { locale } = await params;
+
+ const session = await getServerSession();
+
+ // Unauthenticated — defense-in-depth (proxy already handles most cases).
+ if (!session) {
+ redirect({ href: `/${locale}/sign-in`, locale });
+ // TypeScript: redirect() throws a Next.js redirect symbol; unreachable.
+ return null;
+ }
+
+ // Authed but unenrolled — sign out so the Supabase cookie is cleared, then
+ // redirect. Prevents a user whose app_users row was deleted from looping on
+ // the dashboard with a valid JWT but no BSK role.
+ if (!session.role) {
+ await signOutAction();
+ // signOutAction calls redirect() internally and never returns normally;
+ // the line below satisfies TypeScript's control-flow analysis.
+ return null;
+ }
+
+ const { user, role } = session;
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/components/app-shell/app-shell.tsx b/components/app-shell/app-shell.tsx
new file mode 100644
index 0000000..1da9769
--- /dev/null
+++ b/components/app-shell/app-shell.tsx
@@ -0,0 +1,30 @@
+/**
+ * AppShell — Server Component.
+ *
+ * Composes the full authenticated layout: fixed sidebar on the left,
+ * scrollable main content area on the right. Receives user info from the
+ * (app) layout which has already validated session + role.
+ *
+ * No client state here — sidebar is server-rendered, top-bar lives inside
+ * the sidebar's bottom section for Phase 1 simplicity.
+ */
+
+import type { ReactNode } from "react";
+import type { AppRole } from "@/lib/db/roles";
+import { Sidebar } from "@/components/app-shell/sidebar";
+
+type AppShellProps = {
+ email: string;
+ role: AppRole;
+ locale: string;
+ children: ReactNode;
+};
+
+export function AppShell({ email, role, locale, children }: AppShellProps) {
+ return (
+