Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,11 @@ AUTH_ZITADEL_ISSUER=https://auth.catholicdigitalcommons.org
# openssl rand -base64 32
# Different value per env (prod / staging / dev); never share between them.
AUTH_SECRET=

# AUTH_URL: only needed if NEXT_PUBLIC_SITE_URL isn't already set to the
# public origin. lib/auth.ts auto-promotes NEXT_PUBLIC_SITE_URL → AUTH_URL
# when AUTH_URL is unset, which covers the Plesk Passenger case where
# Next.js standalone otherwise generates an OIDC redirect_uri pointing at
# the bind address (0.0.0.0:3000) instead of the public hostname. Set this
# explicitly if you need to override (e.g. behind multiple proxies).
# AUTH_URL=
104 changes: 104 additions & 0 deletions app/[lang]/my-bio/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { redirect } from 'next/navigation'
import { getTranslations } from 'next-intl/server'
import { auth } from '@/lib/auth'
import {
BioApiError,
fetchMyTeamMember,
fetchTeamMemberPost,
type BioDiscovery,
type BioPostContent,
} from '@/lib/bio-api'
import BioEditor from '@/components/BioEditor'

// Server component for /[lang]/my-bio. Resolves the caller's linked
// team_member post, picks an initial language (Zitadel locale → URL
// locale → first available), fetches that language's current content,
// and hands everything to the client-side editor.
//
// Anonymous → redirect to sign-in.
// Authenticated but no team_member link → friendly "contact ops" message
// (no internal error surfaced).
export default async function MyBioPage({
params,
}: {
params: Promise<{ lang: string }>
}) {
const { lang: urlLocale } = await params
const session = await auth()
if (!session?.user) {
redirect('/api/auth/signin')
}
const t = await getTranslations('MyBio')

let discovery: BioDiscovery
try {
discovery = await fetchMyTeamMember(session)
} catch (err) {
if (err instanceof BioApiError && (err.status === 401 || err.status === 403)) {
return (
<main className="cdcf-section">
<div className="prose max-w-prose">
<h1>{t('title')}</h1>
<p>{t('notLinked')}</p>
</div>
</main>
)
}
throw err
}

const available = discovery.available_languages
if (available.length === 0) {
return (
<main className="cdcf-section">
<div className="prose max-w-prose">
<h1>{t('title')}</h1>
<p>{t('noLanguages')}</p>
</div>
</main>
)
}

// Prefer Zitadel locale claim → URL locale → first available.
const preferred =
pickAvailable(session.user.locale, available) ??
pickAvailable(urlLocale, available) ??
available[0]

let initialPost: BioPostContent
try {
initialPost = await fetchTeamMemberPost(session, preferred.post_id)
} catch (err) {
// Stale group entry — should be caught by the WP-side post-type
// check too, but fall through gracefully here as well.
if (err instanceof BioApiError && err.status === 404) {
return (
<main className="cdcf-section">
<div className="prose max-w-prose">
<h1>{t('title')}</h1>
<p>{t('loadError')}</p>
</div>
</main>
)
}
throw err
}

return (
<main className="cdcf-section">
<BioEditor
availableLanguages={available}
initialLang={preferred.slug}
initialPost={initialPost}
/>
</main>
)
}

function pickAvailable<L extends { slug: string }>(
slug: string | undefined,
list: L[]
): L | undefined {
if (!slug) return undefined
return list.find((entry) => entry.slug === slug)
}
34 changes: 34 additions & 0 deletions app/api/my-bio/check/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { fetchMyTeamMember, type BioDiscovery } from '@/lib/bio-api'

// GET /api/my-bio/check
// Tiny proxy used by the AuthButton header dropdown to decide whether
// to show the "Edit my bio" entry. Returns {linked, available_languages}
// rather than just a boolean so the editor page can reuse the same
// fetch without a second round-trip if desired.
//
// Fails soft on EVERY error path: this is a UI-decoration endpoint, not
// a security boundary. Showing or hiding the dropdown entry must never
// surface a 500 to the browser console. Anonymous, unlinked, expired
// token, unreachable WP, missing env vars — all collapse to
// {linked: false} with HTTP 200, and the underlying error is
// console.error'd server-side for diagnosis. The /[lang]/my-bio page
// itself still surfaces real errors as the appropriate localized copy.
export async function GET() {
const session = await auth()
if (!session?.user) {
return NextResponse.json({ linked: false, available_languages: [] })
}
try {
const discovery: BioDiscovery = await fetchMyTeamMember(session)
return NextResponse.json({
linked: true,
team_member_id: discovery.team_member_id,
available_languages: discovery.available_languages,
})
} catch (err) {
console.error('[my-bio/check] discovery failed (degrading to linked=false):', err)
return NextResponse.json({ linked: false, available_languages: [] })
}
}
46 changes: 46 additions & 0 deletions app/api/my-bio/load/[lang]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { NextResponse, type NextRequest } from 'next/server'
import { auth } from '@/lib/auth'
import {
BioApiError,
fetchMyTeamMember,
fetchTeamMemberPost,
} from '@/lib/bio-api'

// GET /api/my-bio/load/{lang}
// Resolves the requested language's post id via the discovery endpoint
// (so the post_id never has to be trusted from the client) and returns
// the editable post content. Used by BioEditor when the user switches
// the language selector.
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ lang: string }> }
) {
const { lang } = await params
if (!/^[a-z]{2}$/.test(lang)) {
return NextResponse.json({ error: 'invalid_lang' }, { status: 400 })
}
const session = await auth()
if (!session?.user) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
}
try {
const discovery = await fetchMyTeamMember(session)
const entry = discovery.available_languages.find((l) => l.slug === lang)
if (!entry) {
return NextResponse.json(
{ error: 'no_translation_for_lang' },
{ status: 404 }
)
}
const post = await fetchTeamMemberPost(session, entry.post_id)
return NextResponse.json(post)
} catch (err) {
if (err instanceof BioApiError) {
return NextResponse.json(
{ error: err.code ?? 'wp_error', message: err.message },
{ status: err.status }
)
}
throw err
}
}
69 changes: 69 additions & 0 deletions app/api/my-bio/save/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { NextResponse, type NextRequest } from 'next/server'
import { auth } from '@/lib/auth'
import { BioApiError, saveMyTeamMember, type BioSavePayload } from '@/lib/bio-api'

// Allow-list of payload fields we forward to the WP PATCH endpoint.
// Extra keys in the request body are dropped silently (forward-compat
// friendly); any allow-listed field present with a non-string value
// rejects the whole request 400 (catches bug/abuse alike).
const ALLOWED_PAYLOAD_FIELDS = [
'content',
'member_title',
'member_linkedin_url',
'member_github_url',
] as const

// POST /api/my-bio/save
// Forwards the bio editor's payload to the WP PATCH endpoint, attaching
// the user's Zitadel access token server-side so it never reaches the
// browser. The client posts:
// { lang: 'de', content: '...', member_title: '...', ... }
// We pull `lang` out, allow-list + type-check the remaining fields, and
// hand the sanitized object to saveMyTeamMember. WP-side
// sanitize_callbacks (wp_kses_post, sanitize_text_field, esc_url_raw)
// and the URL hostname allow-list run downstream; this is the first
// validation checkpoint.
export async function POST(request: NextRequest) {
const session = await auth()
if (!session?.user) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
}
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'invalid_json' }, { status: 400 })
}
if (!body || typeof body !== 'object' || Array.isArray(body)) {
return NextResponse.json({ error: 'invalid_body' }, { status: 400 })
}
const record = body as Record<string, unknown>
const lang = record.lang
if (typeof lang !== 'string') {
return NextResponse.json({ error: 'missing_lang' }, { status: 400 })
}
const payload: BioSavePayload = {}
for (const field of ALLOWED_PAYLOAD_FIELDS) {
if (!(field in record)) continue
const value = record[field]
if (typeof value !== 'string') {
return NextResponse.json(
{ error: 'invalid_field', field, message: `${field} must be a string` },
{ status: 400 }
)
}
payload[field] = value
}
try {
const result = await saveMyTeamMember(session, lang, payload)
return NextResponse.json(result)
} catch (err) {
if (err instanceof BioApiError) {
return NextResponse.json(
{ error: err.code ?? 'wp_error', message: err.message },
{ status: err.status }
)
}
throw err
}
}
42 changes: 39 additions & 3 deletions components/AuthButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,21 @@ import { useSession, signIn, signOut } from 'next-auth/react'
import { useTranslations } from 'next-intl'
import { ChevronDownIcon, UserIcon } from '@heroicons/react/24/outline'
import clsx from 'clsx'
import { Link } from '@/src/i18n/navigation'

/**
* Header sign-in / sign-out control.
*
* Unauthenticated → "Sign in" button kicking off the Zitadel OIDC flow.
* Authenticated → user dropdown with email + sign-out. Future entries
* (e.g. "Edit my bio") will be conditional on session.user.roles or on
* a server-resolved teamMemberId — kept out of this Phase 2 cut.
* Authenticated → user dropdown with email + sign-out, plus an "Edit
* my bio" entry when the caller is linked to a team_member post (resolved
* via the /api/my-bio/check route).
*/
export default function AuthButton() {
const { data: session, status } = useSession()
const t = useTranslations('Auth')
const [isOpen, setIsOpen] = useState(false)
const [hasBioLink, setHasBioLink] = useState(false)
const closeTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)

const openDropdown = useCallback(() => {
Expand Down Expand Up @@ -56,6 +58,30 @@ export default function AuthButton() {
}
}, [])

// Resolve the user's team_member link via the server route once the
// session is authenticated. The route is cheap (it short-circuits to
// 200 for anon and for authenticated-but-not-linked users), so a
// single fire-and-forget request keeps the dropdown decision off the
// hot path. We don't reset hasBioLink when the session disappears —
// the dropdown is gated on `session.user` below, so any stale `true`
// is invisible until the user signs in again, at which point the
// effect re-fires.
useEffect(() => {
if (status !== 'authenticated') return
const controller = new AbortController()
fetch('/api/my-bio/check', { signal: controller.signal, cache: 'no-store' })
.then((res) => (res.ok ? res.json() : null))
.then((body) => {
if (body && typeof body === 'object' && 'linked' in body) {
setHasBioLink(Boolean(body.linked))
}
})
.catch(() => {
/* aborted or network error — leave hasBioLink as-is */
})
return () => controller.abort()
}, [status])

if (status === 'loading') {
return (
<div className="flex h-9 items-center px-3 text-sm text-gray-500">
Expand Down Expand Up @@ -122,6 +148,16 @@ export default function AuthButton() {
{session.user.email}
</div>
)}
{hasBioLink && (
<Link
href="/my-bio"
role="menuitem"
onClick={() => setIsOpen(false)}
className="block px-4 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-100 hover:text-cdcf-navy"
>
{t('editMyBio')}
</Link>
)}
<button
type="button"
role="menuitem"
Expand Down
Loading