feat(admin-i18n): add cookie-based locale runtime and switcher baseline

This commit is contained in:
2026-02-10 20:56:03 +01:00
parent 07e5f53793
commit b618c8cb51
18 changed files with 931 additions and 156 deletions

View File

@@ -1,6 +1,7 @@
import type { Metadata } from "next"
import type { ReactNode } from "react"
import { getAdminMessages, resolveAdminLocale } from "@/i18n/server"
import "./globals.css"
import { Providers } from "./providers"
@@ -9,11 +10,16 @@ export const metadata: Metadata = {
description: "Admin dashboard for the CMS monorepo",
}
export default function RootLayout({ children }: { children: ReactNode }) {
export default async function RootLayout({ children }: { children: ReactNode }) {
const locale = await resolveAdminLocale()
const messages = await getAdminMessages(locale)
return (
<html lang="en">
<html lang={locale}>
<body>
<Providers>{children}</Providers>
<Providers locale={locale} messages={messages}>
{children}
</Providers>
</body>
</html>
)

View File

@@ -4,6 +4,9 @@ import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { type FormEvent, useMemo, useState } from "react"
import { AdminLocaleSwitcher } from "@/components/admin-locale-switcher"
import { useAdminT } from "@/providers/admin-i18n-provider"
type LoginFormProps = {
mode: "signin" | "signup-owner" | "signup-user"
}
@@ -27,6 +30,7 @@ function persistRoleCookie(role: unknown) {
export function LoginForm({ mode }: LoginFormProps) {
const router = useRouter()
const searchParams = useSearchParams()
const t = useAdminT()
const nextPath = useMemo(() => searchParams.get("next") || "/", [searchParams])
@@ -60,7 +64,7 @@ export function LoginForm({ mode }: LoginFormProps) {
const payload = (await response.json().catch(() => null)) as AuthResponse | null
if (!response.ok) {
setError(payload?.message ?? "Sign in failed")
setError(payload?.message ?? t("auth.errors.signInFailed", "Sign in failed"))
return
}
@@ -68,7 +72,7 @@ export function LoginForm({ mode }: LoginFormProps) {
router.push(nextPath)
router.refresh()
} catch {
setError("Network error while signing in")
setError(t("auth.errors.networkSignIn", "Network error while signing in"))
} finally {
setIsBusy(false)
}
@@ -78,7 +82,7 @@ export function LoginForm({ mode }: LoginFormProps) {
event.preventDefault()
if (!name.trim()) {
setError("Name is required for account creation")
setError(t("auth.errors.nameRequired", "Name is required for account creation"))
return
}
@@ -104,20 +108,20 @@ export function LoginForm({ mode }: LoginFormProps) {
const payload = (await response.json().catch(() => null)) as AuthResponse | null
if (!response.ok) {
setError(payload?.message ?? "Sign up failed")
setError(payload?.message ?? t("auth.errors.signUpFailed", "Sign up failed"))
return
}
persistRoleCookie(payload?.user?.role)
setSuccess(
mode === "signup-owner"
? "Owner account created. Registration is now disabled."
: "Account created.",
? t("auth.messages.ownerCreated", "Owner account created. Registration is now disabled.")
: t("auth.messages.accountCreated", "Account created."),
)
router.push(nextPath)
router.refresh()
} catch {
setError("Network error while signing up")
setError(t("auth.errors.networkSignUp", "Network error while signing up"))
} finally {
setIsBusy(false)
}
@@ -126,24 +130,28 @@ export function LoginForm({ mode }: LoginFormProps) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-md flex-col justify-center px-6 py-16">
<div className="space-y-3">
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">Admin Auth</p>
<div className="flex items-center justify-between gap-3">
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">
{t("auth.badge", "Admin Auth")}
</p>
<AdminLocaleSwitcher />
</div>
<h1 className="text-3xl font-semibold tracking-tight">
{mode === "signin"
? "Sign in to CMS Admin"
? t("auth.titles.signIn", "Sign in to CMS Admin")
: mode === "signup-owner"
? "Welcome to CMS Admin"
: "Create an admin account"}
? t("auth.titles.signUpOwner", "Welcome to CMS Admin")
: t("auth.titles.signUpUser", "Create an admin account")}
</h1>
<p className="text-sm text-neutral-600">
{mode === "signin" ? (
<>
Better Auth is active on this app via <code>/api/auth</code>.
</>
) : mode === "signup-owner" ? (
"Create the first owner account to initialize this admin instance."
) : (
"Self-registration is enabled for admin users."
)}
{mode === "signin"
? t("auth.descriptions.signIn", "Better Auth is active on this app via /api/auth.")
: mode === "signup-owner"
? t(
"auth.descriptions.signUpOwner",
"Create the first owner account to initialize this admin instance.",
)
: t("auth.descriptions.signUpUser", "Self-registration is enabled for admin users.")}
</p>
</div>
@@ -154,7 +162,7 @@ export function LoginForm({ mode }: LoginFormProps) {
>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="email">
Email or username
{t("auth.fields.emailOrUsername", "Email or username")}
</label>
<input
id="email"
@@ -168,84 +176,7 @@ export function LoginForm({ mode }: LoginFormProps) {
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="password">
Password
</label>
<input
id="password"
type="password"
minLength={8}
required
value={password}
onChange={(event) => setPassword(event.target.value)}
className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
/>
</div>
<button
type="submit"
disabled={isBusy}
className="w-full rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white disabled:opacity-60"
>
{isBusy ? "Signing in..." : "Sign in"}
</button>
<p className="text-xs text-neutral-600">
Need an account?{" "}
<Link href={`/register?next=${encodeURIComponent(nextPath)}`} className="underline">
Register
</Link>
</p>
{error ? <p className="text-sm text-red-600">{error}</p> : null}
</form>
) : (
<form
onSubmit={handleSignUp}
className="mt-8 space-y-4 rounded-xl border border-neutral-200 p-6"
>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="name">
Name
</label>
<input
id="name"
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="email">
Email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="username">
Username (optional)
</label>
<input
id="username"
type="text"
value={username}
onChange={(event) => setUsername(event.target.value)}
className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="password">
Password
{t("auth.fields.password", "Password")}
</label>
<input
id="password"
@@ -264,16 +195,95 @@ export function LoginForm({ mode }: LoginFormProps) {
className="w-full rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white disabled:opacity-60"
>
{isBusy
? "Creating account..."
: mode === "signup-owner"
? "Create owner account"
: "Create account"}
? t("auth.actions.signInBusy", "Signing in...")
: t("auth.actions.signInIdle", "Sign in")}
</button>
<p className="text-xs text-neutral-600">
Already have an account?{" "}
{t("auth.links.needAccount", "Need an account?")}{" "}
<Link href={`/register?next=${encodeURIComponent(nextPath)}`} className="underline">
{t("auth.links.register", "Register")}
</Link>
</p>
{error ? <p className="text-sm text-red-600">{error}</p> : null}
</form>
) : (
<form
onSubmit={handleSignUp}
className="mt-8 space-y-4 rounded-xl border border-neutral-200 p-6"
>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="name">
{t("auth.fields.name", "Name")}
</label>
<input
id="name"
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="email">
{t("auth.fields.email", "Email")}
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="username">
{t("auth.fields.username", "Username (optional)")}
</label>
<input
id="username"
type="text"
value={username}
onChange={(event) => setUsername(event.target.value)}
className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="password">
{t("auth.fields.password", "Password")}
</label>
<input
id="password"
type="password"
minLength={8}
required
value={password}
onChange={(event) => setPassword(event.target.value)}
className="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
/>
</div>
<button
type="submit"
disabled={isBusy}
className="w-full rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white disabled:opacity-60"
>
{isBusy
? t("auth.actions.signUpBusy", "Creating account...")
: mode === "signup-owner"
? t("auth.actions.signUpOwnerIdle", "Create owner account")
: t("auth.actions.signUpUserIdle", "Create account")}
</button>
<p className="text-xs text-neutral-600">
{t("auth.links.alreadyHaveAccount", "Already have an account?")}{" "}
<Link href={`/login?next=${encodeURIComponent(nextPath)}`} className="underline">
Go to sign in
{t("auth.links.goToSignIn", "Go to sign in")}
</Link>
</p>

View File

@@ -5,6 +5,9 @@ import { revalidatePath } from "next/cache"
import Link from "next/link"
import { redirect } from "next/navigation"
import { AdminLocaleSwitcher } from "@/components/admin-locale-switcher"
import { translateMessage } from "@/i18n/messages"
import { getAdminMessages, resolveAdminLocale } from "@/i18n/server"
import { resolveRoleFromServerContext } from "@/lib/access-server"
import { LogoutButton } from "./logout-button"
@@ -58,10 +61,18 @@ function redirectWithState(params: { notice?: string; error?: string }) {
redirect(value ? `/?${value}` : "/")
}
async function getDashboardTranslator() {
const locale = await resolveAdminLocale()
const messages = await getAdminMessages(locale)
return (key: string, fallback: string) => translateMessage(messages, key, fallback)
}
async function createPostAction(formData: FormData) {
"use server"
await requireNewsWritePermission()
const t = await getDashboardTranslator()
const status = readRequiredField(formData, "status")
@@ -74,23 +85,28 @@ async function createPostAction(formData: FormData) {
status: status === "published" ? "published" : "draft",
})
} catch {
redirectWithState({ error: "Create failed. Please check your input." })
redirectWithState({
error: t("dashboard.posts.errors.createFailed", "Create failed. Please check your input."),
})
}
revalidatePath("/")
redirectWithState({ notice: "Post created." })
redirectWithState({ notice: t("dashboard.posts.success.created", "Post created.") })
}
async function updatePostAction(formData: FormData) {
"use server"
await requireNewsWritePermission()
const t = await getDashboardTranslator()
const id = readRequiredField(formData, "id")
const status = readRequiredField(formData, "status")
if (!id) {
redirectWithState({ error: "Update failed. Missing post id." })
redirectWithState({
error: t("dashboard.posts.errors.updateMissingId", "Update failed. Missing post id."),
})
}
try {
@@ -102,32 +118,37 @@ async function updatePostAction(formData: FormData) {
status: status === "published" ? "published" : "draft",
})
} catch {
redirectWithState({ error: "Update failed. Please check your input." })
redirectWithState({
error: t("dashboard.posts.errors.updateFailed", "Update failed. Please check your input."),
})
}
revalidatePath("/")
redirectWithState({ notice: "Post updated." })
redirectWithState({ notice: t("dashboard.posts.success.updated", "Post updated.") })
}
async function deletePostAction(formData: FormData) {
"use server"
await requireNewsWritePermission()
const t = await getDashboardTranslator()
const id = readRequiredField(formData, "id")
if (!id) {
redirectWithState({ error: "Delete failed. Missing post id." })
redirectWithState({
error: t("dashboard.posts.errors.deleteMissingId", "Delete failed. Missing post id."),
})
}
try {
await deletePost(id)
} catch {
redirectWithState({ error: "Delete failed." })
redirectWithState({ error: t("dashboard.posts.errors.deleteFailed", "Delete failed.") })
}
revalidatePath("/")
redirectWithState({ notice: "Post deleted." })
redirectWithState({ notice: t("dashboard.posts.success.deleted", "Post deleted.") })
}
export default async function AdminHomePage({
@@ -145,24 +166,39 @@ export default async function AdminHomePage({
redirect("/unauthorized?required=news:read&scope=team")
}
const resolvedSearchParams = await searchParams
const [resolvedSearchParams, locale, posts] = await Promise.all([
searchParams,
resolveAdminLocale(),
listPosts(),
])
const messages = await getAdminMessages(locale)
const t = (key: string, fallback: string) => translateMessage(messages, key, fallback)
const notice = readFirstValue(resolvedSearchParams.notice)
const error = readFirstValue(resolvedSearchParams.error)
const canCreatePost = hasPermission(role, "news:write", "team")
const posts = await listPosts()
return (
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col gap-8 px-6 py-16">
<header className="space-y-3">
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">Admin App</p>
<h1 className="text-4xl font-semibold tracking-tight">Content Dashboard</h1>
<p className="text-neutral-600">Manage posts from a dedicated admin surface.</p>
<div className="flex items-center justify-between gap-3">
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">
{t("dashboard.badge", "Admin App")}
</p>
<AdminLocaleSwitcher />
</div>
<h1 className="text-4xl font-semibold tracking-tight">
{t("dashboard.title", "Content Dashboard")}
</h1>
<p className="text-neutral-600">
{t("dashboard.description", "Manage posts from a dedicated admin surface.")}
</p>
<div className="flex items-center gap-3 pt-2">
<Link
href="/todo"
className="inline-flex rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium hover:bg-neutral-100"
>
Open roadmap and progress
{t("dashboard.actions.openRoadmap", "Open roadmap and progress")}
</Link>
<LogoutButton />
</div>
@@ -183,8 +219,12 @@ export default async function AdminHomePage({
<section className="rounded-xl border border-neutral-200 p-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-medium">Posts CRUD Sandbox</h2>
<p className="text-xs uppercase tracking-wide text-neutral-500">MVP0 functional test</p>
<h2 className="text-xl font-medium">
{t("dashboard.posts.title", "Posts CRUD Sandbox")}
</h2>
<p className="text-xs uppercase tracking-wide text-neutral-500">
{t("dashboard.notices.crudSandboxTag", "MVP0 functional test")}
</p>
</div>
{canCreatePost ? (
@@ -192,10 +232,14 @@ export default async function AdminHomePage({
action={createPostAction}
className="space-y-3 rounded-lg border border-neutral-200 p-4"
>
<h3 className="text-sm font-semibold">Create post</h3>
<h3 className="text-sm font-semibold">
{t("dashboard.posts.createTitle", "Create post")}
</h3>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Title</span>
<span className="text-xs text-neutral-600">
{t("dashboard.posts.fields.title", "Title")}
</span>
<input
name="title"
required
@@ -204,7 +248,9 @@ export default async function AdminHomePage({
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Slug</span>
<span className="text-xs text-neutral-600">
{t("dashboard.posts.fields.slug", "Slug")}
</span>
<input
name="slug"
required
@@ -214,14 +260,18 @@ export default async function AdminHomePage({
</label>
</div>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Excerpt</span>
<span className="text-xs text-neutral-600">
{t("dashboard.posts.fields.excerpt", "Excerpt")}
</span>
<input
name="excerpt"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Body</span>
<span className="text-xs text-neutral-600">
{t("dashboard.posts.fields.body", "Body")}
</span>
<textarea
name="body"
required
@@ -231,21 +281,28 @@ export default async function AdminHomePage({
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Status</span>
<span className="text-xs text-neutral-600">
{t("dashboard.posts.fields.status", "Status")}
</span>
<select
name="status"
defaultValue="draft"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="draft">Draft</option>
<option value="published">Published</option>
<option value="draft">{t("dashboard.posts.status.draft", "Draft")}</option>
<option value="published">
{t("dashboard.posts.status.published", "Published")}
</option>
</select>
</label>
<Button type="submit">Create post</Button>
<Button type="submit">{t("dashboard.posts.actions.create", "Create post")}</Button>
</form>
) : (
<div className="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800">
You can read posts, but your role cannot create/update/delete posts.
{t(
"dashboard.notices.noCrudPermission",
"You can read posts, but your role cannot create/update/delete posts.",
)}
</div>
)}
</div>
@@ -259,7 +316,9 @@ export default async function AdminHomePage({
<input type="hidden" name="id" value={post.id} />
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Title</span>
<span className="text-xs text-neutral-600">
{t("dashboard.posts.fields.title", "Title")}
</span>
<input
name="title"
required
@@ -269,7 +328,9 @@ export default async function AdminHomePage({
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Slug</span>
<span className="text-xs text-neutral-600">
{t("dashboard.posts.fields.slug", "Slug")}
</span>
<input
name="slug"
required
@@ -280,7 +341,9 @@ export default async function AdminHomePage({
</label>
</div>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Excerpt</span>
<span className="text-xs text-neutral-600">
{t("dashboard.posts.fields.excerpt", "Excerpt")}
</span>
<input
name="excerpt"
defaultValue={post.excerpt ?? ""}
@@ -288,7 +351,9 @@ export default async function AdminHomePage({
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Body</span>
<span className="text-xs text-neutral-600">
{t("dashboard.posts.fields.body", "Body")}
</span>
<textarea
name="body"
required
@@ -299,22 +364,28 @@ export default async function AdminHomePage({
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Status</span>
<span className="text-xs text-neutral-600">
{t("dashboard.posts.fields.status", "Status")}
</span>
<select
name="status"
defaultValue={post.status}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="draft">Draft</option>
<option value="published">Published</option>
<option value="draft">{t("dashboard.posts.status.draft", "Draft")}</option>
<option value="published">
{t("dashboard.posts.status.published", "Published")}
</option>
</select>
</label>
<Button type="submit">Save changes</Button>
<Button type="submit">
{t("dashboard.posts.actions.save", "Save changes")}
</Button>
</form>
<form action={deletePostAction} className="mt-3">
<input type="hidden" name="id" value={post.id} />
<Button type="submit" variant="secondary">
Delete
{t("dashboard.posts.actions.delete", "Delete")}
</Button>
</form>
</>
@@ -327,7 +398,9 @@ export default async function AdminHomePage({
</span>
</div>
<p className="mt-2 text-sm text-neutral-600">{post.slug}</p>
<p className="mt-2 text-sm text-neutral-600">{post.excerpt ?? "No excerpt"}</p>
<p className="mt-2 text-sm text-neutral-600">
{post.excerpt ?? t("dashboard.posts.fallback.noExcerpt", "No excerpt")}
</p>
</>
)}
</article>

View File

@@ -1,9 +1,24 @@
"use client"
import type { AppLocale } from "@cms/i18n"
import type { ReactNode } from "react"
import type { AdminMessages } from "@/i18n/messages"
import { AdminI18nProvider } from "@/providers/admin-i18n-provider"
import { QueryProvider } from "@/providers/query-provider"
export function Providers({ children }: { children: ReactNode }) {
return <QueryProvider>{children}</QueryProvider>
export function Providers({
children,
locale,
messages,
}: {
children: ReactNode
locale: AppLocale
messages: AdminMessages
}) {
return (
<AdminI18nProvider locale={locale} messages={messages}>
<QueryProvider>{children}</QueryProvider>
</AdminI18nProvider>
)
}