Compare commits

...
55 Commits
Author SHA1 Message Date
Do SikiandClaude Sonnet 5 d699bf4787 sync: mark MITHOME-95 done in TODO.md
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Canceled after 0s
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Canceled after 0s
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Canceled after 0s
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 12:34:24 +02:00
Do SikiandClaude Sonnet 5 758ee2cfed docs(agent): refresh architecture/CLAUDE.md docs for Payload CMS (MITHOME-95)
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Canceled after 0s
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Canceled after 0s
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Canceled after 0s
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Canceled after 0s
The Content layer description across the AI-agent steering docs still
described the pre-migration JSON system as the live, current mechanism
— rule #1 in CLAUDE.md and the Content row in architecture.md
explicitly told future work (and future agent sessions) to edit
proto/src/content/pages/*.json directly, which the live site hasn't
read since MITHOME-91.

Rewrote, per the ticket's named targets:
- .agent/steering/architecture.md: system overview, the Content layer
  table row, rule #1, and the Content Management code example now
  describe Payload Globals/Collections + the payload-content.ts Local
  API adapter, with /admin as the editing surface.
- CLAUDE.md: Stack line now names Payload CMS, rule #1 rewritten the
  same way, and the project-structure tree gets the (frontend)/(payload)
  route groups, globals/, collections/, and payload.config.ts that were
  entirely missing from it.

Also fixed directly-adjacent staleness found while doing this pass —
same "how is content edited" documentation debt, just not literally
named in the ticket:
- .agent/AGENTS.md: same project-structure tree gap as CLAUDE.md.
- .agent/workflows/new-feature.md: step 3 told contributors to edit
  the JSON file directly; now points at the Payload config + admin.
- .agent/workflows/review.md: the example "flag hardcoded text" review
  comment suggested moving it into the JSON file.
- .agent/steering/development-rules.md: the TRADEOFF code example
  literally used "JSON content management vs CMS" as a live open
  decision — that decision is resolved, updated the example to match.
- README.md: the entire "Tartalomkezelés" section and a "CMS
  Publikálás gomb" paragraph described the retired content-editor.js
  workflow (git-push-based publish, JSON directory tour) as current;
  also dropped a dead link to docs/felhasznaloi-utmutato.md, deleted
  in MITHOME-93.

Doc-only change, no proto/ source touched — no gate to run beyond a
sanity re-read of each edited file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 12:33:33 +02:00
Do SikiandClaude Sonnet 5 3573d2accb sync: mark MITHOME-96 done, log partners grid fix in TODO.md
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Canceled after 0s
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Canceled after 0s
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Canceled after 0s
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 16:49:42 +02:00
Do SikiandClaude Sonnet 5 1bf32dd076 fix(web): arrange partner logos in a grid, max 3 per row
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Canceled after 0s
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Canceled after 0s
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Canceled after 0s
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Canceled after 0s
User request: the partner logos were laid out with flex-wrap, which
doesn't align items into clean rows/columns. Switched to CSS grid
(grid-cols-1 sm:grid-cols-2 md:grid-cols-3), so it never exceeds 3
columns regardless of viewport width or partner count.

w-fit + mx-auto (not inline-grid + mx-auto) so the grid centers
correctly even with fewer partners than 3 — auto margins don't center
inline-level boxes, but do for block-level ones sized to their content.

Verified live in the browser at 1200px width with 5 temporary test
partners (reusing an existing Media doc, deleted after): renders as a
3-then-2 grid, centered, no console errors.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 16:38:05 +02:00
Do SikiandClaude Sonnet 5 e2b0c62628 test(cms): Payload config + Local API tests, fix stale Docker tests (MITHOME-96)
Adds the two test categories the ticket asked for, replacing the old
content-JSON-schema-only testing story now that Payload is the real
source of truth:

1. src/payload-config.test.ts — fast, DB-free unit tests over the
   Collection/Global config objects themselves. WHY not importing
   payload.config.ts directly: it pulls in the real `payload` and
   `@payloadcms/db-mongodb` packages as values (not just types), which
   are ESM-only and Jest's default transformIgnorePatterns skips
   node_modules entirely — confirmed by trying it
   (`SyntaxError: Cannot use import statement outside a module` from
   payload's own dist). Individual collection/global files only ever
   `import type` from 'payload' (erased at compile time), so they're
   safely importable in isolation. Two of the assertions are
   deliberate regression guards for real bugs found earlier this
   session (MITHOME-121): Media.access.read must stay public, and
   Partners.logo must stay optional.

2. scripts/test-payload-local-api.ts (npm run test:payload) — exercises
   every src/lib/payload-content.ts getter plus a full
   create/findByID/delete cycle against a real, already-migrated
   MongoDB. WHY a plain node script instead of a Jest integration
   config: same ESM problem as above, and Payload's dependency graph
   is too broad to safely add to transformIgnorePatterns — this
   follows the same working `node --import tsx` pattern already used
   by migrate-content-to-payload.ts. Not wired into
   pre-deploy-tests.sh (needs a live, pre-migrated MongoDB, same
   reasoning as the removed CMS integration tests); run manually or
   before a release.

3. Fixed the pre-existing (Docker-stack-gated, so silently never
   caught) staleness in integration.test.ts and e2e-docker.test.ts:
   unprefixed routes (/rolunk) → locale-prefixed (/hu/rolunk), and
   Mongo assertions against the old site_config/contact_submissions
   collections → the real globals/legal-pages/partners/
   contact-submissions collections Payload actually uses. Also fixed
   an unrelated stale error-message assertion for spam detection that
   never matched the route's real string.

Verified: full gate green (tsc, lint, 63 unit tests — 13 new — build),
and separately ran test:payload live against the dev MongoDB (8/8
passed, including the write/read/delete cycle actually hitting Mongo).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 16:37:57 +02:00
Do SikiandClaude Sonnet 5 a7e0530068 sync: add MITHOME-122 (admin UI translation) to TODO.md
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Canceled after 0s
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Canceled after 0s
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Canceled after 0s
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 15:26:42 +02:00
Do SikiandClaude Sonnet 5 bf6c8f7abd sync: mark MITHOME-94 done in TODO.md
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Canceled after 0s
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Canceled after 0s
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Canceled after 0s
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 15:12:19 +02:00
Do SikiandClaude Sonnet 5 d2f960207e feat(cms): ContactSubmissions Payload collection (MITHOME-94)
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Canceled after 0s
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Canceled after 0s
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Canceled after 0s
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Canceled after 0s
/api/contact used to write straight to a raw, Payload-external MongoDB
collection (contact_submissions, via proto/src/lib/mongodb.ts's
getCollection) — the client had no way to see incoming messages except
by reading the database directly. Rate limiting, length caps, email
validation, and spam-keyword filtering all stay on the route exactly
as before; only the persistence target changed.

New: src/collections/ContactSubmissions.ts (name, email, subject,
message, gdprConsent checkbox, status select defaulting to "new").
Deliberately no custom `access` block — Payload's default
(authenticated-only for every REST operation) is exactly right here:
the client reads submissions in the admin, nobody can read or write
them through the public REST API, and the route's own write uses the
Local API (payload.create), which runs with overrideAccess: true by
default and so isn't blocked by that same rule. No versions/drafts
(a submission is a fact, not editable content) and no field-level
length/format validation duplicated in the collection, matching the
ticket's explicit scope: those checks live on the route.

route.ts: replaced getCollection()/insertOne() with
getPayload({config}).create({ collection: 'contact-submissions', ... }).
Removed the now-unused getCollection() helper from lib/mongodb.ts
(checkMongoConnection/getDb stay, used by /api/health) and its test.

Test gotcha worth documenting: next/jest's SWC transform rewrites the
`@payload-config` tsconfig-path alias to a real relative specifier at
transform time, so `jest.mock('@payload-config', ...)` never actually
intercepts what route.ts requires — it silently falls through to the
real payload.config.ts (mongooseAdapter, live Mongo needed). Fixed by
mocking the resolved relative path instead
(`jest.mock('../../../payload.config', ...)`); documented inline in
route.unit.test.ts for whoever hits this next (MITHOME-96 will need
the same trick for other Payload-backed routes/collections).

Verified live end-to-end, not just the test suite: submitted the real
contact form on /hu/kapcsolat, got the success message, found the
submission in /admin/collections/contact-submissions with all fields
correct (including gdprConsent checked and status "Új"/New), then
deleted the test record. Zero console errors in a fresh tab. Gate:
tsc, lint, unit tests (50 passed — one fewer than before, the removed
getCollection test), production build all green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 01:25:05 +02:00
Do SikiandClaude Sonnet 5 d60c38f65a sync: add admin view-site link follow-up to TODO.md
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Canceled after 0s
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Canceled after 0s
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Canceled after 0s
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 01:01:58 +02:00
Do SikiandClaude Sonnet 5 314b129259 feat(cms): "View site" link in admin header (MITHOME-97 follow-up)
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Canceled after 0s
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Canceled after 0s
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Canceled after 0s
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Canceled after 0s
User request: there was no way to get from the Payload admin back to
the public website — confirmed live (read_page on the admin nav showed
only internal links: collections, globals, account, logout).

Added a "🌐 Honlap megnyitása" link next to the QuickSearch box
(src/components/admin/QuickSearch.tsx — same header slot, no new
admin.components entry or importmap regen needed since the export name
didn't change). Opens in a new tab, points at siteConfig.general.url
(NEXT_PUBLIC_SITE_URL), so it resolves to the correct site per
environment automatically — stage.mozdit.hu on staging, the production
domain once that's live.

Verified live in the browser: link renders next to the search box,
resolves to the expected URL (find() confirmed the href), zero new
console errors in a fresh tab. Gate: tsc, lint, unit tests (51 passed),
production build all green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 00:59:55 +02:00
Do SikiandClaude Sonnet 5 c07ec2298c sync: add MITHOME-121 to TODO.md (Partners logo optional + Media access fix)
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Canceled after 0s
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Canceled after 0s
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Canceled after 0s
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 00:51:16 +02:00
Do SikiandClaude Sonnet 5 d207e5653e fix(cms): make Partners.logo optional; fix Media read access (public site was broken)
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Canceled after 0s
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Canceled after 0s
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Canceled after 0s
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Canceled after 0s
User request: don't force a logo upload to save a Partner — allow
saving name+url first, logo later. Removed `required: true` from
Partners.ts logo field. The frontend (getPartners(), payload-content.ts)
already filters out logo-less partners before rendering, so this is
safe: a partner without a logo just doesn't show on the public site
yet, nothing breaks.

While verifying this live (create a partner without a logo, check the
public homepage doesn't break), found a real, previously-undetected
bug that predates this change: partner logos never actually loaded on
the public site at all. Payload's default collection read access is
"authenticated users only" (Boolean(user)), and Media.ts never
overrode it — so GET /api/media/file/<name> always 403'd for anyone
not logged into the admin. Next.js's image optimizer (/_next/image)
fetches that URL server-side without forwarding the browser's admin
session cookie, so it always got a 403 back, which it reports as "The
requested resource isn't a valid image" (400) — the <img> silently
rendered as a broken image icon on the homepage the whole time. Fixed
by adding `access: { read: () => true }` to Media.ts — write
operations (create/update/delete) stay admin-only via Payload's
default.

Verified live in the browser: created a Partner with only name+url via
the admin (saved successfully, no required-field error), confirmed it
correctly does NOT appear on the public homepage (no logo yet), then
deleted that test record. Separately, in a fresh unauthenticated tab,
confirmed the existing Partner's logo now actually renders on /hu
(previously a broken image icon) — curl-verified both
/api/media/file/<name> and /_next/image?url=... return 200 without any
auth. Zero console errors. Gate: tsc, lint, unit tests (51 passed),
production build all green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 00:41:42 +02:00
Do SikiandClaude Sonnet 5 02a73b3702 sync: mark MITHOME-120 done in TODO.md
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Canceled after 0s
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Canceled after 0s
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Canceled after 0s
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 00:05:06 +02:00
Do SikiandClaude Sonnet 5 74e40329a0 feat(cms): admin quick-search across all content (MITHOME-120)
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Canceled after 0s
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Canceled after 0s
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Canceled after 0s
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Canceled after 0s
Adds a search box to the top of every Payload admin page
(admin.components.header) that searches by text across every field of
every Global and Collection, in both locales — something Payload has
no built-in equivalent for: collection list views only search their
own title/slug fields, and Globals have no list view at all.

Implementation is deliberately client-side and index-free rather than
@payloadcms/plugin-search (a server-side search collection kept in
sync via hooks): this project's entire content is 5 Globals + 2 small
Collections, so a plugin-managed search index would be disproportionate
maintenance for the actual data volume — the same reasoning already
applied to the logo editor (MITHOME-118). On first use, the component
fetches every Global/Collection doc in both locales via the existing
REST API (same-origin, admin session cookie), recursively flattens
every field to (path, value) pairs client-side, and filters by
case-insensitive substring as the user types. Each result links
straight to the right edit view (global or collection/id).

New: src/components/admin/QuickSearch.tsx. Registered via
payload.config.ts admin.components.header, which required a
generate:importmap run — the useful gotcha this surfaced: Payload
resolves component paths against admin.importMap.baseDir, which
defaults to process.cwd() (the proto/ the CLI is run from), not
dirname(payload.config.ts) — so the path needed to be
'./src/components/admin/QuickSearch#QuickSearch', not
'./components/admin/QuickSearch#QuickSearch'. Documented inline.

Verified live in the browser (not just tsc/lint): search finds matches
in both a Global (Home hero.cta.secondary.text / services description)
and a Collection (Partners name/url), shows source + field path +
locale + a snippet per result, clicking a result navigates to the
correct edit view, and the search state persists across client-side
admin navigation since the header component doesn't remount. Zero
console errors in a fresh tab. Full gate green: tsc, lint, unit tests
(51 passed), production build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 00:03:13 +02:00
Do SikiandClaude Sonnet 5 3f72d714df sync: add cms.stage.llmdev.mozdit.hu nginx vhost log entry to TODO.md
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Canceled after 0s
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Canceled after 0s
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Canceled after 0s
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 23:33:33 +02:00
Do SikiandClaude Sonnet 5 265b656df9 docs: document the cms.stage.llmdev.mozdit.hu nginx vhost (MITHOME-97 follow-up)
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Canceled after 0s
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Canceled after 0s
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Canceled after 0s
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Canceled after 0s
Server-side nginx config isn't tracked anywhere in this repo, which is
exactly how an orphaned vhost went unnoticed: it used to reverse-proxy
to the old CMS (content-editor.js, port 4001), retired in MITHOME-93.
After that service was stopped/disabled, the vhost silently kept
serving its still-valid TLS cert as nginx's apparent fallback for any
unmatched *.mozdit.hu subdomain — which is what produced a real-looking
(but harmless) Firefox "this site may be impersonating" warning when
the user mistyped a URL.

Repointed the vhost (server-side, not in this repo) to reverse-proxy
/admin, /api/ and /_next/ to the same staging app container Payload
already runs on (127.0.0.1:8081) — same backend as stage.mozdit.hu,
just a friendlier admin-specific URL. Root redirects straight to
/admin; anything else redirects to the canonical stage.mozdit.hu to
avoid serving the public site twice. Reused the existing Let's Encrypt
cert (no new certbot run needed).

This commit only adds docs/nginx-vhosts.md, documenting what's live on
the server and why, since nginx config itself isn't part of this
repo's source of truth.

Verified live: TLS cert now matches cms.stage.llmdev.mozdit.hu (no more
mismatch warning), GET / redirects to /admin (302), GET /admin returns
200 with correctly loaded assets (checked in a real browser, zero
console errors), GET /rolunk redirects to stage.mozdit.hu, GET
/api/health proxies through correctly, and POST /api/users/login with
the real staging admin credentials returns 200 through this domain too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 23:33:01 +02:00
Do SikiandClaude Sonnet 5 00d2b82877 sync: add MITHOME-97 admin-seed follow-up log entry to TODO.md
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Canceled after 0s
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Canceled after 0s
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Canceled after 0s
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 23:21:17 +02:00
Do SikiandClaude Sonnet 5 729268a3fe feat(deploy): auto-seed a known Payload admin user on every deploy
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Canceled after 0s
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Canceled after 0s
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Canceled after 0s
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Canceled after 0s
Follow-up to MITHOME-97: after every deploy, a brand-new database
(first-ever deploy, or a volume wipe) leaves the Payload admin behind
the "Create first user" screen — someone has to notice and fill it in
by hand, which means environments can silently end up with no known
admin credentials, or with whatever a random person happened to type
in at the time.

deploy.sh now calls Payload's built-in `POST /api/users/first-register`
REST endpoint right after the healthcheck passes, using ADMIN_EMAIL /
ADMIN_PASSWORD from the environment's .env file. That endpoint only
succeeds when the `users` collection is completely empty (throws 403
Forbidden otherwise) — which makes this naturally idempotent: the
first deploy against a fresh database creates the known admin, every
later deploy gets a harmless 403 and skips it. It never overwrites an
existing user's password. Missing ADMIN_EMAIL/ADMIN_PASSWORD in the
env file just skips the step with a warning, it doesn't fail the
deploy.

Documented the new variables in .env.staging.example and
.env.production.example (next to the existing PAYLOAD_SECRET
instructions), and the new deploy.sh step 4 in
.agent/workflows/deploy.md. Also dropped a stale "Content Editor is
staging feliratot kap" line from the same doc (that behavior belonged
to the CMS retired in MITHOME-93 and no longer exists).

Verified locally against real Payload instances (not just reading the
code): a fresh, empty MongoDB returns 200 and creates the user; a
second call against the same now-non-empty database returns 403 and
changes nothing; a database that already had a different user (the
existing dev DB) also correctly returns 403.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 23:12:12 +02:00
Do SikiandClaude Sonnet 5 94647d5293 sync: mark MITHOME-97 done, add MITHOME-92/93/97 log entries to TODO.md
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Canceled after 0s
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Canceled after 0s
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Canceled after 0s
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 22:52:53 +02:00
Do SikiandClaude Sonnet 5 d8cccc1a65 fix(deploy): drop stale content-editor systemd restart from staging script
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Canceled after 0s
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Canceled after 0s
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Canceled after 0s
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Canceled after 0s
scripts/deploy_to_stage_on_local.sh restarted a
"mozdit-content-editor.service" systemd unit after every staging deploy
— the old custom CMS's own service (content-editor.js, retired in
MITHOME-93). Left in place, the next staging deploy would pull the
commit that deletes content-editor.js and then try to restart a
service pointing at a now-missing script.

Removed the restart from the deploy script; the server-side systemd
unit itself (if still installed) needs a manual stop/disable — out of
reach from here (no SSH access in this session), flagging it to the
user instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 22:13:47 +02:00
Do SikiandClaude Sonnet 5 1b3ae07811 feat(deploy): staging/production Docker deploy for Payload (MITHOME-97)
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Canceled after 0s
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Canceled after 0s
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Canceled after 0s
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Canceled after 0s
Fixes a real, previously-undiscovered build failure and a bigger
architectural gap found while testing an actual `docker build` of
proto/Dockerfile for the first time since the Payload migration:

1. Docker build failure: the (frontend)/[locale] pages use
   generateStaticParams, so `next build` fully prerenders them (SSG) —
   which calls the Payload Local API during the build. With no
   MONGODB_URI/PAYLOAD_SECRET reachable in the build stage, `docker
   build` failed outright ("missing secret key").

2. Bigger problem underneath: even if the build could reach a DB,
   full SSG means a Payload admin edit would NOT appear on the public
   site until a full rebuild + redeploy — directly undermining the
   project's whole reason for migrating to Payload (self-service
   content editing for the client).

Fix (user-confirmed direction: force-dynamic): dropped
generateStaticParams from (frontend)/[locale]/layout.tsx and
[locale]/[slug]/page.tsx, added `export const dynamic = 'force-dynamic'`
to layout.tsx + both page.tsx files. Every request now reads Payload
live — publishing in the admin is visible immediately, and the Docker
build no longer needs any DB connectivity at all (verified: a full
`docker build --target builder` now succeeds with zero env vars set).

Docker/Compose changes:
- docker-compose.staging.yml / docker-compose.prod.yml: wired
  PAYLOAD_SECRET through to the app container (was documented in
  .env.*.example since MITHOME-86 but never actually passed to the
  container — Payload would have refused to start). No fallback,
  same fail-loudly pattern as MONGODB_URI.
- Same two files: added a named `media_data_{staging,prod}` volume
  mounted at /app/media — Payload's local upload storage (Media.ts)
  writes there at the container's runtime cwd; without a volume,
  `deploy.sh`'s `--force-recreate` would silently wipe every uploaded
  logo/image on each deploy.
- docker-compose.dev.yml: was missing PAYLOAD_SECRET entirely (only
  discovered because the same "missing secret key" error reproduces
  there too) — added a dev-only literal value. Media persistence
  already works there via the existing `./proto:/app` bind mount, no
  volume needed. Also dropped the obsolete `version: '3.8'` key
  (compose warns it's ignored).
- DOCKER.md: one-paragraph note on the new PAYLOAD_SECRET requirement
  and where the admin account gets created.

Verified:
- `docker build --target builder` succeeds from a clean context with
  zero environment variables (previously failed).
- `docker build --target runner` + `docker run` against the real dev
  MongoDB (PAYLOAD_SECRET + MONGODB_URI supplied at runtime only):
  /hu, /admin and /api/health all return 200 inside the container;
  confirmed live in the browser that Payload content renders
  correctly end-to-end through the production Next.js server, not
  just `next dev`.
- `docker compose -f docker-compose.{staging,prod,dev}.yml config`
  parses cleanly.
- Full gate green: tsc, lint, proto unit tests (51 passed),
  scripts/pre-deploy-tests.sh (proto tests, tsc, lint, content schema,
  plane-sync — all pass).

deploy.sh itself needs no changes: it already just runs
`docker compose up --build`, and that now works without any
build-time DB wiring.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 22:12:01 +02:00
Do SikiandClaude Sonnet 5 19622d9703 sync: mark MITHOME-93 done in TODO.md
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Canceled after 0s
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Canceled after 0s
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Canceled after 0s
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 21:50:00 +02:00
Do SikiandClaude Sonnet 5 12b2711168 chore: retire legacy custom CMS (content-editor.js) (MITHOME-93)
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Canceled after 0s
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Canceled after 0s
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Canceled after 0s
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Canceled after 0s
Removes the standalone, git-push-based content editor that predates
Payload CMS: content-editor.js, its scripts/cms-*.js modules, its
scripts/test-content-editor-*.js + scripts/test-cms-publish.js test
suite, scripts/markdown-render.js (only used by the editor's guide
renderer), the proto-side test doubles (cms-editor-client.test.ts,
cms-editor-shortcuts.test.ts), and the editor's own user guide
(docs/felhasznaloi-utmutato.md).

Kept: proto/src/content/*.json (still the source for
migrate-content-to-payload.ts and test fixtures for Header/Footer,
per MITHOME-96), proto/src/content/schema.js + scripts/test-content-schema.js
(still validate those JSON files), and docs/content-editor-recovery.md
(historical incident record, not user-facing tool docs).

Safety net before deletion (per user request): added
proto/scripts/export-content-snapshot.ts, a reusable Payload Local API
exporter, and ran it to produce docs/backups/payload-content-snapshot-*.json
— a full hu/en snapshot of every Global + LegalPages + Partners document
at the moment of retirement. Also confirmed no data-loss risk otherwise:
.content-backups/ (the editor's own gitignored backup dir) tops out at
2026-08-23, well before today's fresh migration run, and every JSON
edit ever made through the editor already exists as its own git commit
("content: frissítve a CMS-ből").

Updated dangling references: pre-deploy-tests.sh and
.agent/steering/testing.md (dropped the CMS test block),
.agent/workflows/deploy.md (publish flow is now Payload draft/publish,
not git push), CLAUDE.md + .agent/AGENTS.md (dropped the /cms-feature
workflow, deleted alongside it), README.md (stack description),
.agent/steering/development-rules.md (the guide-maintenance rule no
longer has a guide to maintain).

Verified: tsc, lint, proto unit tests (51 passed), root
test-content-schema.js, plane-sync unit tests, production build all
green after the deletion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 21:48:50 +02:00
Do SikiandClaude Sonnet 5 21a0d73639 sync: mark MITHOME-92 done in TODO.md
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Canceled after 0s
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Canceled after 0s
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Canceled after 0s
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 21:36:49 +02:00
Do SikiandClaude Sonnet 5 d445f0f31a feat(cms): enable draft/publish + version history on Globals/Collections (MITHOME-92)
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Canceled after 0s
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Canceled after 0s
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Canceled after 0s
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Canceled after 0s
Adds versions.drafts to all content-bearing Globals (Home, About,
Services, Contact, Common) and Collections (LegalPages, Partners) via
shared config in src/lib/payload-versions.ts. Media and Users are
deliberately excluded (no draft workflow needed for uploads/auth).

This is the direct successor to the old custom CMS's "Verziók panel"
(MITHOME-64): the Payload admin now shows Save Draft / Publish changes
and a Versions tab with history/diff/restore per document.

Fixes a real bug found during manual verification: Payload's `_status`
field defaults to 'draft' when a create/update call's data omits it,
and that value is preserved on subsequent updates rather than being
overwritten. Since migrate-content-to-payload.ts never passed
`_status`, every migrated document ended up in draft status, which
would have broken public pages once combined with any future explicit
draft read. Fixed by explicitly setting `_status: 'published'` on
every write in the migration script.

Verified live in the browser:
- Payload admin: Home global shows Status: Published, Save Draft /
  Publish changes buttons, Versions tab with history (draft -> current).
- Saving a draft edit (title change, not published) does NOT change
  what /hu and /en render — confirmed by reloading the public page
  before publishing.
- Publishing the change updates the public page as expected.
- Full gate green: tsc, lint, tests (58 passed), production build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 21:36:03 +02:00
Do SikiandClaude Sonnet 5 b5590175b7 sync: mark MITHOME-91 + MITHOME-114 done in TODO.md
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Canceled after 0s
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Canceled after 0s
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Canceled after 0s
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Canceled after 0s
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Canceled after 0s
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 16:16:47 +02:00
Do SikiandClaude Sonnet 5 d6f3dda9e5 feat(frontend): Payload Local API + hu/en locale routing (MITHOME-91/114)
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions
Replaces the JSON content system with Payload's Local API across every
frontend page, and introduces symmetric locale-prefixed routing
(/hu/..., /en/...) with per-locale translated slugs — supersedes the
earlier "hu unprefixed" decision (see chat 2026-09-10).

Routing structure:
- src/app/(frontend)/layout.tsx: now a minimal shell (html/body, theme
  script, ThemeProvider) — no longer locale-aware.
- src/app/(frontend)/page.tsx: redirects bare "/" to the default
  locale (/hu).
- src/app/(frontend)/not-found.tsx: explicit 404 for the (frontend)
  group — without it, Next's built-in fallback collided with the
  (payload) group's own root and reproduced the "double html / script
  tag" symptom from MITHOME-87, but only on notFound() paths. Verified
  fixed in both dev and a real production (standalone) server; the
  remaining "script tag" console warning on invalid routes turned out
  to be Turbopack dev-mode-only noise (zero console errors in
  production) — confirmed by building and running .next/standalone
  directly.
- src/app/(frontend)/[locale]/layout.tsx: validates the locale segment
  (generateStaticParams hu/en, notFound() otherwise), fetches Common +
  Home via Payload, renders Header/Footer/staging-banner.
- src/app/(frontend)/[locale]/page.tsx: home, fetches Home global +
  Partners collection.
- src/app/(frontend)/[locale]/[slug]/page.tsx: catch-all for about/
  services/contact/privacy/terms — resolves slug -> PageKey via
  src/lib/i18n.ts's PAGE_SLUGS map (generateStaticParams pre-renders
  all 10 locale×slug combinations), generateMetadata per page.

New lib layer:
- src/lib/i18n.ts: Locale/PageKey types, PAGE_SLUGS (translated slugs
  per locale), localePath()/resolvePageKey()/switchLocalePath()
  helpers (the last one already shaped for MITHOME-115).
- src/lib/payload-content.ts: Local API getters that also unwrap
  Payload's `{ value: string }[]` array-field shape back into plain
  string[] (see src/globals/fields/stringArray.ts) — keeps the page
  JSX consuming the exact shape the old content/types.ts had, so the
  migration is a data-source swap, not a markup rewrite.

Presentational split: page bodies moved to src/components/views/
(HomeView, AboutView, ServicesView, ContactView, LegalPageView — the
last one shared by both legal pages, identical shape) as prop-driven
components; the app-router page.tsx files became thin server-side
fetch + render wrappers. Header/Footer converted from importing
content directly to accepting nav/locale/content props, since they're
'use client' and can't call the Payload Local API themselves —
config/site.ts's navigation arrays became getMainNavigation(locale)/
getFooterNavigation(locale)/getFooterLegalLinks(locale) functions.

Two real, pre-existing bugs fixed along the way (not introduced by
this migration):
- Services and Contact pages' "Webmail belépés" links used the
  primary CTA's href (/kapcsolat) with target="_blank" instead of the
  actual webmail URL (home.hero.cta.secondary.href) — now correct.
- The GDPR checkbox link pointed to "/adatkezelesi-tajekoztato", which
  never matched the real privacy page route under any past URL
  scheme. contact.json's gdpr.label now carries a {privacyHref}
  placeholder that ContactView replaces with the locale-correct path
  — also fixes the adatvedelem page's own <title> tag, which
  previously read "Adatvédelmi Tájékoztató | Szolgáltatás jellemzők:"
  (a copy-paste bug using common.labels.features instead of the site
  name).

Known, accepted limitation: the outer shell layout hardcodes
<html lang="hu"> because it sits above the [locale] segment and can't
read the param — every [locale]/[slug] page's own generateMetadata is
locale-correct, but the initial lang attribute isn't. Documented as a
MITHOME-116 (SEO/hreflang) follow-up rather than restructured now.

Verified end to end in a real browser: /hu matches the
https://stage.mozdit.hu visual baseline (MITHOME-117) exactly; /en
renders with English nav/metadata (content body still Hungarian-only,
as expected — MITHOME-111/113 not done yet); /hu/kapcsolat's GDPR link
resolves to /hu/adatvedelem; dark mode still works; invalid locale
(/fr/about) and invalid slug (/hu/nemletezo-oldal) both 404 correctly;
bare "/" redirects to /hu. build/lint/tsc/test (58 passed) all clean,
including a clean production standalone-server run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 16:15:09 +02:00
Do SikiandClaude Sonnet 5 594865ea9a sync: mark MITHOME-90 done, add MITHOME-119 (MFA prep) to TODO.md
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 13:28:57 +02:00
Do SikiandClaude Sonnet 5 8407b45367 feat(cms): Users access control + lockout policy (MITHOME-90)
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions
Explicit, documented decisions instead of relying on implicit Payload
defaults:

- auth.maxLoginAttempts: 5, lockTime: 10 min — codifies the lockout
  policy rather than leaving it as an unstated library default.
- auth.cookies: { secure: NODE_ENV === 'production', sameSite: 'Lax' }
  — secure cookies once behind HTTPS (MITHOME-15), harmless over plain
  HTTP in local dev.
- access.{create,read,update,delete,unlock}: explicit
  requireAuthenticatedUser (== Payload's defaultAccess, Boolean(user)).
  Investigated the known open advisory flagged in MITHOME-86
  (GHSA-jg8r-5jh2-v2xj — any authenticated user can unlock any other
  account) by reading Payload's unlock operation source: the gap only
  matters when a less-privileged authenticated identity exists that
  needs protecting from a more-privileged one. This project's single
  "admin" role model (no role hierarchy — MITHOME-85 epic decision)
  has no such identity, so the default is accepted as-is, with the
  reasoning and a MITHOME-46 (central IDM/SSO) revisit trigger written
  into the code comment rather than left implicit.
- Added an optional `name` field for a nicer admin identity than a
  bare email (audit trail, header display).

Verified live: existing dev@mozdit.hu user unaffected (name column
shows "<No Name>", backward compatible). Reproduced the lockout for
real — 5 wrong POST /api/users/login attempts, 6th attempt with the
*correct* password still rejected ("locked due to too many failed
login attempts"), unlocked via Local API (overrideAccess), then the
correct password logged in successfully. build/lint/tsc/test (58
passed) all clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 13:26:41 +02:00
Do SikiandClaude Sonnet 5 71ab1e08ac sync: mark MITHOME-118 cancelled in TODO.md
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 13:17:00 +02:00
Do SikiandClaude Sonnet 5 fb11eff18b docs(cms): note the manual pre-processing workflow on the Partners logo field
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions
Following the MITHOME-118 scope decision (cancelled — see Plane
comment): partner logos are prepared with a free external tool
(e.g. remove.bg) before upload, not edited inside Payload admin.
Surface that expectation right on the field so an editor isn't
looking for a crop/transparency tool that doesn't exist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 13:16:14 +02:00
Do SikiandClaude Sonnet 5 30f271651a sync: mark MITHOME-110 done in TODO.md
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 13:08:07 +02:00
Do SikiandClaude Sonnet 5 6589289878 feat(cms): enable Payload localization — hu default + en (MITHOME-110)
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions
payload.config.ts: localization: { locales: ['hu','en'], defaultLocale:
'hu', fallback: true }. URL/routing side is separate (MITHOME-114).

Proved the mechanism on a real field rather than a throwaway one:
Common.buttons.* (contact/learnMore/webmail/sendMessage) marked
localized: true — these are genuinely translatable UI labels, so this
doubles as a first, correct slice of the full MITHOME-111 retrofit
instead of being disposable test scaffolding.

Gotcha discovered and documented in the migration script: marking an
existing field `localized: true` after data was already written non-
localized makes that value unreadable via `locale: defaultLocale` (the
storage shape changed) — the migration script must be re-run so it
gets rewritten under the localized shape. This will matter again for
the full MITHOME-111/112 retrofit.

Verified: Local API round-trip (set en, defaultLocale/hu re-seeded via
re-running the migration script) — hu reads "Kapcsolatfelvétel", en
reads "Contact". Real browser: admin UI locale switcher (hu/en) in the
top bar, fields show "— hu"/"— en" per-locale labels, switching
locale swaps the visible value correctly on the Common global editor.
build/lint/tsc/test (58 passed) all clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 13:07:36 +02:00
Do SikiandClaude Sonnet 5 1b587ac373 sync: mark MITHOME-89 done, add MITHOME-118 to TODO.md
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 12:34:25 +02:00
Do SikiandClaude Sonnet 5 776fa66bc8 feat(cms): Partners + Media collection MVP (MITHOME-89)
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions
Payload built-in upload collection (Media) + a Partners collection
(name, url, logo -> Media relationship) mirroring home.json's
partners.items.

Scope decision (see Plane MITHOME-89/118): the old logo editor
(crop/rotate/transparent-background — MITHOME-76/84) is entirely
client-side canvas logic (scripts/cms-logo-client.js, 345 lines), not
server-side processing. Porting that UX into the Payload admin is a
real custom React field component, split into its own ticket
(MITHOME-118) rather than bundled here. This ticket covers plain
upload only.

- src/collections/Media.ts, src/collections/Partners.ts, registered
  in payload.config.ts.
- migrate-content-to-payload.ts: upsertPartner() uploads the existing
  processed logo file (filePath) into Media (idempotent — matched by
  `alt` == partner name) and upserts the Partner document (matched by
  `name`).
- .gitignore: Payload's default local upload storage lands at
  proto/media/ (not proto/public/) — runtime data, not source, needs
  a persistent volume in staging/production (flagged for MITHOME-97).
  Also ignored the generated src/payload-types.ts.

Verified: migration run twice against the real dev MongoDB produced
exactly 1 Media doc + 1 Partner doc (no duplicates, confirmed via
mongosh) with the correct file size (12289 bytes, matching the source
PNG). Real browser: logged into /admin, Partners list shows the
migrated entry, and the document editor renders the logo thumbnail
(270x80, 12KB) correctly. build/lint/tsc/test (58 passed) all clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 12:33:34 +02:00
Do SikiandClaude Sonnet 5 928439bc7b sync: add MITHOME-117 visual design-protection ticket
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 12:25:18 +02:00
Do SikiandClaude Sonnet 5 1c9e231a16 sync: add localization epic (MITHOME-109..116) to TODO.md
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 12:22:08 +02:00
Do SikiandClaude Sonnet 5 329269aeb1 sync: mark MITHOME-88 done in TODO.md
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 12:16:35 +02:00
Do SikiandClaude Sonnet 5 17c6d63ae3 feat(cms): LegalPages collection + migration (MITHOME-88)
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions
Slug-based collection mirroring LegalPageContent (proto/src/content/
types.ts) for the two legal pages (adatvedelem, hasznalati-feltetelek).

- src/collections/LegalPages.ts: slug (unique), title, lastUpdated,
  sections[] (id/title/content). `content` stays a plain textarea, not
  lexical richText — the current frontend
  (src/app/(frontend)/adatvedelem/page.tsx) renders it through a
  hand-rolled "•"/"**bold**" regex converter, not a real Markdown/
  richText parser, matching the same bootstrap-scope call made for
  Contact.gdpr.label in MITHOME-87.
- Registered in payload.config.ts.
- migrate-content-to-payload.ts: added an idempotent upsertLegalPage
  helper (find-by-slug, then update or create — Collections don't have
  Globals' fixed-slug updateGlobal) and seeded both legal pages from
  their existing JSON.

Verified: migration run twice against the real dev MongoDB produced
exactly 2 documents (no duplicates) — confirmed via mongosh. Real
browser: logged into /admin, Legal Pages list shows both entries with
correct titles/slugs, opened the adatvedelem document and the slug/
title/body fields all show the migrated content correctly. build/lint/
tsc/test (58 passed) all clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 12:16:09 +02:00
Do SikiandClaude Sonnet 5 570f4b45e5 docs: add program-hid-update skill for the Program Híd artifact
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions
Project-scoped Claude Code skill that re-queries the MITHOME and PLATFM
Plane projects, recomputes the roadmap-phase/bridge/epic-progress data,
and republishes the same Program Híd artifact
(claude.ai/code/artifact/95d7def2-c648-4fb7-a3f3-0d172b1c21ab) in place.

Includes the canonical artifact source (source.html) so future runs
edit a versioned file instead of a session-local scratchpad copy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 12:05:23 +02:00
Do SikiandClaude Sonnet 5 91a9d97a59 docs: note PLATFM-1 program-level convergence map in TODO.md
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 11:52:54 +02:00
Do SikiandClaude Sonnet 5 72a5a10476 sync: reconcile Hermes epic with PLATFM-10/PLATFM-12
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions
MITHOME-99 (Hermes Agent EPIC) and its 6 pure-AI-pilot subtasks
(MITHOME-100/101/104/106/107/108) duplicated work already planned
under PLATFM-10 (managed AI features pilot). Cancelled them in Plane,
linked to PLATFM-10 and the new PLATFM-12 engine-choice ADR.

The genuine MITHOME-side responsibility (the website's own event
source) survives as MITHOME-102/103/105, reparented under the Payload
epic (MITHOME-85) and reworded to target a PLATFM-2-compatible event
schema (idempotencyKey, eventType, data) instead of a Hermes-specific
webhook.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 11:44:26 +02:00
Do SikiandClaude Sonnet 5 c4e3d91640 sync: add Hermes Agent epic (MITHOME-99..108) to TODO.md
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 11:03:37 +02:00
Do SikiandClaude Sonnet 5 a3d7e9ce75 chore(cms): disable Payload's anonymous telemetry
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions
Payload sends anonymous usage telemetry to its own servers by default.
The site is self-hosted specifically so the client's data never leaves
our own infrastructure (see the legal/GDPR discussion in chat) — that
reasoning extends to Payload's own runtime telemetry too, so opt out
via telemetry: false in payload.config.ts.

Verified: build and dev server produce no telemetry notice, and the
admin dashboard's network requests (checked in a real browser) are
all to localhost — no outbound telemetry calls.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 10:52:54 +02:00
Do SikiandClaude Sonnet 5 db2f38743f sync: mark MITHOME-98 done in TODO.md
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 10:39:58 +02:00
Do SikiandClaude Sonnet 5 4c54c639e5 fix(docker): authenticate the dev app's MongoDB URI (MITHOME-98)
docker-compose.dev.yml's mongodb service sets MONGO_INITDB_ROOT_USERNAME/
PASSWORD, which makes the official mongo image enable --auth — but the
app service's MONGODB_URI was unauthenticated
(mongodb://mongodb:27017/mozdit). Same bug class as MITHOME-32
(staging/production), but that ticket covers docker-compose.staging.yml
/docker-compose.prod.yml specifically, not this dev file — hence the
separate MITHOME-98.

Why /api/health never caught it: that route is a pure liveness check
and never touches MongoDB. Only an endpoint that actually performs a
DB operation exercises the bug.

Verified live (docker compose -f docker-compose.dev.yml up --build):
- Reproduced the failure first: inside the running app container, a
  plain `mongodb://mongodb:27017/mozdit` connection's findOne() throws
  "Command find requires authentication" — confirms the hypothesis
  that the app fails only on a real query, not at startup.
- With the fix in place: POST /api/contact returns 200 with a
  submissionId, and the document is actually present in
  contact_submissions (checked via mongosh) — a real, previously-
  broken write path now works end to end.
- npm test: 58/58 passed (unaffected, as expected for a
  docker-compose/doc-only change).

Also fixed the same stale unauthenticated example in DOCKER.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 10:39:29 +02:00
Do SikiandClaude Sonnet 5 018a1f2767 sync: mark MITHOME-87 done in TODO.md
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 03:42:12 +02:00
Do SikiandClaude Sonnet 5 3132fcb8eb feat(cms): Home/About/Services/Contact/Common Globals + JSON migration (MITHOME-87)
Payload Global configs mirroring proto/src/content/types.ts:
- src/globals/{Home,About,Services,Contact,Common}.ts
- src/globals/fields/stringArray.ts — shared helper: Payload has no
  native string[] field, so every plain string array from the old
  content types (trustBullets, paragraphs, features, spec items, …)
  becomes an array of one-field { value } objects.
- home.partners is intentionally NOT included — that becomes its own
  Partners collection (logo -> Media upload) in MITHOME-89, to avoid
  two disagreeing sources for the same data.
- contact.form.fields.gdpr.label stays a plain textarea (not lexical
  richText): the JSON source is a hand-written HTML string with an
  <a> tag; richText's node-tree serialization would need its own
  migration/render logic, out of scope for this bootstrap pass.

scripts/migrate-content-to-payload.ts: one-shot, idempotent Local API
migration reading the existing JSON files and calling updateGlobal —
does not touch or delete the JSON files. Run via the new
`npm run migrate:content` script.

Registered the five Globals in payload.config.ts.

Also included here (belongs with the previous "resolve double-root-
layout conflict" commit but didn't actually get staged there —
verified only now by diffing HEAD against the working tree):
(frontend)/layout.tsx's relative imports corrected to ../../ instead
of ../ (one directory deeper than the original src/app/layout.tsx).

Verified:
- npm run build / lint, tsc --noEmit, npm test (58 passed) all clean
- npm run migrate:content against the real dev MongoDB container,
  then read back via payload.findGlobal() — hero.title, trustBullets,
  services.items[0].features, footer.address, faq.items.length all
  match the JSON source
- Real browser: logged into /admin, opened the Home global editor —
  Hero/Trust bullets group renders and shows the migrated Hungarian
  content correctly (see screenshot shared in chat)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 03:41:03 +02:00
Do SikiandClaude Sonnet 5 3183098926 chore(deps): pin tsx 4.23.13 and add migrate:content script
tsx 4.22.4 (the version resolved before this was an explicit
dependency) fails on Node v25.6.1 with "T.registerHooks is not a
function" — its CommonJS-require hook feature-detection finds
node:module's registerHooks but calls it with a shape that version of
Node no longer accepts. This broke both the `payload` CLI (needed for
`generate:importmap`, see next commit) and would have broken
`npm run migrate:content` (MITHOME-87) the same way. 4.23.13 fixes it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 03:40:24 +02:00
Do SikiandClaude Sonnet 5 a661739844 fix(cms): resolve double-root-layout conflict between the site and Payload admin
Next.js 16 requires either one shared root layout for every route, or
each top-level route group to be fully self-contained (no ancestor
layout.tsx above it). MITHOME-86 added (payload)/layout.tsx — which
itself renders Payload's own <html>/<body> via <RootLayout> — while
src/app/layout.tsx still existed as a shared ancestor and did the same.
The result was two React trees fighting over <html>/<body>/<head>,
surfacing in the browser as hydration failures and "You are mounting a
new html component" errors on /admin (invisible to curl-based checks
in the MITHOME-86 verification, since they only exercise SSR, not
client hydration — that verification's "confirmed live" claim was
therefore incomplete; caught now with an actual browser).

Fix, per Payload's documented multi-root pattern: move every existing
site route (page.tsx, globals.css, kapcsolat/szolgaltatasok/rolunk/
adatvedelem/felhasznalasi-feltetelek) into a new (frontend) route
group with its own layout.tsx (renamed from src/app/layout.tsx, with
relative imports adjusted one level deeper), as a sibling of (payload).
No shared layout.tsx remains directly under src/app/.

Verified in a real browser (not just curl): /admin/login and the
dashboard now render and hydrate cleanly, and all site routes
(/, /rolunk, /szolgaltatasok, /kapcsolat, /adatvedelem,
/felhasznalasi-feltetelek) are unaffected — same output, just moved.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 03:40:00 +02:00
Do SikiandClaude Sonnet 5 2a4f33172b sync: mark MITHOME-86 done in TODO.md
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 03:21:32 +02:00
Do SikiandClaude Sonnet 5 849acf4c34 fix(config): disable Next.js 16 auto-generated agent-rules files
\`next dev\` (Next.js 16) writes proto/AGENTS.md and proto/CLAUDE.md on
every run to brief AI agents on framework changes. This project already
has its own agent instruction system (root CLAUDE.md -> .agent/) — a
second, unrelated proto/CLAUDE.md stub would conflict with/shadow it
for anyone working inside proto/. Disabled via agentRules: false.

Verified live: MITHOME-86's admin route renders end-to-end against a
real MongoDB (docker-compose.dev.yml mongodb service) — GET /admin 200
with the create-first-user flow, GET /api/users correctly 403s for an
anonymous request, /api/health unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 03:19:25 +02:00
Do SikiandClaude Sonnet 5 1600d99c5d feat(cms): Payload CMS bootstrap — config, MongoDB adapter, admin routes (MITHOME-86)
Alapinstalláció a Payload CMS-re való áttéréshez (EPIC MITHOME-85):

- payload@3.88.0, @payloadcms/next, @payloadcms/db-mongodb,
  @payloadcms/richtext-lexical, graphql, sharp telepítve
- src/payload.config.ts: mongooseAdapter a meglévő MONGODB_URI-ra
  (ugyanaz az adatbázis, mint a Mongoose/mongodb rétegnek), lexical
  editor, PAYLOAD_SECRET env-ből
- src/collections/Users.ts: minimális auth collection — Payload nem
  tud admin felületet renderelni auth collection nélkül. Ez csak a
  bootstraphez kell; a valódi access control/jelszó-politika MITHOME-90
  feladata.
- App Router route group (src/app/(payload)/): admin UI
  ([[...segments]]), REST (api/[...slug]), GraphQL + playground route-ok,
  root layout — a szokásos Payload v3 Next.js integrációs minta szerint
- next.config.ts: withPayload() wrapper a route handler bundling-hoz
- tsconfig.json: @payload-config path alias -> src/payload.config.ts
  (ez oldja fel a webpack/turbopack importot is, nem csak a type-checket)
- PAYLOAD_SECRET env var: generált dev érték a .env.local-ban
  (gitignore-olt), changeme placeholder + generálási megjegyzés a
  staging/production .env példafájlokban, dokumentálva a CLAUDE.md
  env-lista részében

Ismert, még nem javított biztonsági advisory a felvett payload@3.88.0-ban
(GHSA-jg8r-5jh2-v2xj, moderate, CWE-307: az admin account-unlock alapból
más fiókok lockoutját is felold hitelesített usernek) — nincs újabb
patch-elt verzió jelenleg, nyomon követve MITHOME-90 alatt.

Ellenőrizve ezen a commiton: npm run build, npm run lint, tsc --noEmit,
npm test (58 passed) — mind zöld. Az admin bejelentkezés/DB-kapcsolat
élő tesztje MongoDB-t igényel (jelen környezetben Docker daemon nem fut,
ez lokálisan `docker compose -f docker-compose.dev.yml up -d mongodb`
után `npm run dev` + http://localhost:3000/admin-mal ellenőrizhető).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 03:14:51 +02:00
Do SikiandClaude Sonnet 5 30364fcab7 sync: update TODO.md with Payload CMS epic (MITHOME-85..97)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 03:14:31 +02:00
Do SikiandClaude Sonnet 5 6cb99f603a chore(deps): upgrade Next.js to 16.3.4 for Payload CMS compatibility
Payload 3.88.0's peer range for `next` is >=15.2.9 <15.3.0 ||
>=15.3.9 <15.4.0 || >=15.4.11 <15.5.0 || >=16.2.6 <17.0.0 — the
project's previous 15.5.23 fell in the unsupported gap between the
15.4.x and 16.2.x ranges. Upgrading to 16.3.4 (React 19.1.0 stays
compatible) unblocks MITHOME-86.

- next.config.ts: drop the removed `eslint.ignoreDuringBuilds` option
  (Next 16 no longer runs ESLint during `next build`)
- eslint.config.mjs: import eslint-config-next's native flat-config
  arrays directly instead of bridging through FlatCompat, which threw
  "Converting circular structure to JSON" under ESLint 9 with the
  upgraded config
- tsconfig.json: Next 16's own migration set `jsx: react-jsx` and added
  `.next/dev/types/**/*.ts` to `include`
- ThemeProvider.tsx: suppress two react-hooks/set-state-in-effect
  false positives (new rule shipped with eslint-config-next 16) —
  these effects intentionally sync state from localStorage/DOM on
  mount, there is no subscription to move the setState into

Verified: npm run build, npm run lint, tsc --noEmit, npm test (58
passed) all clean on this change alone, before installing Payload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 03:04:54 +02:00
114 changed files with 9326 additions and 5139 deletions
+8 -4
View File
@@ -80,10 +80,15 @@ sync: update Plane issues + TODO [leírás]
websitedev/
├── proto/ # Fő Next.js alkalmazás (itt futtatsd a parancsokat!)
│ ├── src/
│ │ ├── app/ # Next.js App Router (pages és API routes)
│ │ ├── app/
│ │ │ ├── (frontend)/ # Publikus oldalak: [locale]/[slug] catch-all
│ │ │ └── (payload)/ # Payload admin (/admin) + REST/GraphQL API
│ │ ├── components/ # React komponensek (Header, Footer, ThemeProvider)
│ │ ├── content/ # JSON tartalom-kezelő rendszer
│ │ ├── lib/ # Utility könyvtárak (MongoDB, Logger, Site Config)
│ │ ├── globals/ # Payload Globals (Home, About, Services, Contact, Common)
│ │ ├── collections/ # Payload Collections (LegalPages, Partners, Media, ContactSubmissions, Users)
│ │ ├── payload.config.ts # Payload CMS konfiguráció
│ │ ├── content/ # RÉGI JSON rendszer — csak migrációs seed + teszt-fixture (MITHOME-91/93/96)
│ │ ├── lib/ # payload-content.ts (Local API adapter), MongoDB health-check, Logger
│ │ ├── config/ # Statikus site konfiguráció
│ │ └── types/ # TypeScript típusdefiníciók
│ └── public/ # Statikus fájlok
@@ -279,7 +284,6 @@ Az `.agent/workflows/` mappában találhatók az elérhető slash command workfl
| --- | --- |
| `/new-feature` | Új funkció fejlesztési folyamata |
| `/fix-bug` | Hibajavítás folyamata |
| `/cms-feature` | CMS (Content Editor) fejlesztési folyamata |
| `/review` | Kód review checklist |
| `/deploy` | Deployment folyamata |
+17 -9
View File
@@ -8,11 +8,12 @@ Ez a fájl minden interakcióban automatikusan betöltődik az AI számára a pr
## Rendszer áttekintés
A projekt egy **Next.js 15 alapú marketing weboldal** a mozdIT Bt. számára, App Router architektúrával, TypeScript-tel és Tailwind CSS 4-gyel.
A projekt egy **Next.js 15 alapú marketing weboldal** a mozdIT Bt. számára, App Router architektúrával, TypeScript-tel és Tailwind CSS 4-gyel, **Payload CMS**-sel (self-hosted, `/admin`) mint tartalomkezelő réteggel (MITHOME-85 epic).
- **Frontend**: Next.js 15 App Router (SSR/SSG), React 19, Tailwind CSS 4
- **Backend**: Next.js API Routes (`/api/*`)
- **Adatbázis**: MongoDB (Mongoose ODM) — site config és contact form logok
- **Frontend**: Next.js 15 App Router (dinamikus renderelés — lásd MITHOME-97 WHY: SSG helyett, hogy egy admin publikálás azonnal látszódjon), React 19, Tailwind CSS 4
- **CMS**: Payload CMS — Globals (Home/About/Services/Contact/Common) + Collections (LegalPages/Partners/Media/ContactSubmissions/Users), hu/en lokalizáció, draft/publish + verziózás
- **Backend**: Next.js API Routes (`/api/*`), Payload REST/GraphQL API + Local API
- **Adatbázis**: MongoDB — Payload-collection-önként (mongooseAdapter), plusz egy vékony natív `mongodb` driveres réteg (`proto/src/lib/mongodb.ts`) csak az `/api/health` connectivity-ellenőrzéséhez
- **Logging**: Winston + Loki (strukturált naplózás)
- **Infrastructure**: Docker + Docker Compose, natív deploy (`deploy.sh`)
@@ -24,13 +25,13 @@ A projekt egy **Next.js 15 alapú marketing weboldal** a mozdIT Bt. számára, A
| **Component** | Újrahasználható UI elemek | `proto/src/components/` |
| **API Route** | Route handling, validáció | `proto/src/app/api/` |
| **Service/Lib** | Üzleti logika, DB kapcsolat | `proto/src/lib/` |
| **Content** | JSON alapú tartalom | `proto/src/content/` |
| **Content** | Payload CMS Globals/Collections (admin: `/admin`), Local API adapter | `proto/src/globals/`, `proto/src/collections/`, `proto/src/lib/payload-content.ts` |
| **Config** | Statikus site konfiguráció | `proto/src/config/` |
| **Agent / Tools** | AI ügynökök eszközei | `.agent/` |
## Fontos Architektúra Szabályok
1. **Tartalom JSON-ban**: SOHA ne égess be szöveget közvetlenül komponensbe — használd a `src/content/` rendszert.
1. **Tartalom Payload-ban**: SOHA ne égess be szöveget közvetlenül komponensbe — a tartalom Payload Global/Collection mezőkben él, az ügyfél a `/admin` felületen szerkeszti, a frontend a `src/lib/payload-content.ts` adapteren (Payload Local API) keresztül olvassa. (Kivétel: `proto/src/content/*.json` még mindig létezik, de csak a `migrate-content-to-payload.ts` seed-script forrásaként és néhány komponens-teszt fixture-jeként — élő oldal nem olvassa közvetlenül, lásd MITHOME-91/93/96.)
2. **Stateless API**: A backend API teljesen stateless — session-t a kliens kezel.
3. **Observability**: Minden hiba legyen logolva Winstonnal. API válaszok egységes formátumban: `{ success, data, error }`.
4. **Security-by-design**: Input validáció minden API route-on, secrets csak env változókból.
@@ -55,14 +56,21 @@ A projekt egy **Next.js 15 alapú marketing weboldal** a mozdIT Bt. számára, A
## Content Management
```typescript
// Helyes: content rendszer használata
import { content, getPageContent } from '@/content'
const pageData = content.pages.about
// Helyes: Payload Local API adapter használata (Server Component-ben)
import { getAboutContent } from '@/lib/payload-content'
const about = await getAboutContent(locale) // locale: 'hu' | 'en'
// TILOS: közvetlen szöveg komponensben
<h1>Rólunk</h1>
```
Az ügyfél a tartalmat a Payload admin felületen (`/admin`) szerkeszti —
Global (pl. Home, About) vagy Collection (pl. LegalPages, Partners) dokumentum
formájában, "Save Draft"/"Publish changes" munkafolyamattal (MITHOME-92). Új
mező felvételekor a megfelelő `proto/src/globals/*.ts` vagy
`proto/src/collections/*.ts` fájlt bővítsd, majd generáld újra a típusokat
(`payload generate:types`).
## Dependency szabályok
- Új NPM package bevezetéséhez **jóváhagyás szükséges**
+5 -5
View File
@@ -76,9 +76,8 @@ git commit -m "fix(<scope>): <mi volt a hiba és hogyan lett javítva>"
## Felhasználói dokumentáció karbantartása
- A CMS **❓ Súgó** menüpontja a `docs/felhasznaloi-utmutato.md` fájlt rendereli (`/guide`).
- **Kötelező**: minden CMS- vagy honlapfunkció változtatásánál (új gomb, viselkedésváltozás, útvonal) ugyanabban a commitban frissítsd az útmutatót.
- Támogatott markdown forma a `scripts/markdown-render.js` részhalmaza: címsorok, **félkövér**, `kód`, listák, linkek, `---` elválasztók (táblázat nem).
- A régi egyedi CMS-nek (`content-editor.js`, saját **❓ Súgó** menüponttal, `docs/felhasznaloi-utmutato.md`) volt saját, karbantartott felhasználói útmutatója — ezt a Payload CMS-re állás után leépítettük (MITHOME-93). A Payload admin felület (`/admin`) saját, upstream dokumentációval rendelkezik; ügyfél-facing Payload-specifikus útmutató készítése külön feladat (MITHOME-95), ha szükséges.
- **Kötelező**: minden honlapfunkció-változtatásnál (új útvonal, viselkedésváltozás) ellenőrizd, hogy a `docs/` alatti releváns dokumentáció ne legyen elavult.
## Nyelvhasználat
@@ -143,8 +142,9 @@ const rateLimitStore = new Map<string, number[]>();
// DECISION: fire-and-forget MongoDB write — ne várakozzon a válasz a cache írásra
dbClient.logContact(data).catch(() => null);
// TRADEOFF: JSON content management vs CMS — könnyebb deploy, de nem non-tech szerkeszthető
const content = await import('@/content/pages/home.json');
// TRADEOFF: Payload Local API vs statikus JSON import — élő, adminból
// szerkeszthető tartalom, cserébe minden kérésnél DB-olvasás kell (MITHOME-97)
const content = await getHomeContent(locale);
```
## Pre-modification kockázatbecslés
+26 -17
View File
@@ -44,6 +44,7 @@ npm run test:coverage # lefedettség riport
npm run test:browser # browser integration
npm run test:integration # Docker integration (Docker kell!)
npm run test:e2e # E2E tesztek (Docker kell!)
npm run test:payload # Payload Local API integráció (élő MongoDB + migrált tartalom kell!)
npm run test:all # teljes proto suite
npm run test:smoke:staging # Playwright smoke a staging ellen
```
@@ -77,22 +78,30 @@ Minden deploy előtt az **egyetlen belépési pont** futtatandó:
scripts/pre-deploy-tests.sh
```
Ez lefedi: proto unit + `tsc --noEmit` + `eslint`, content séma-validáció, a CMS
integrációs tesztjei (`scripts/test-content-editor-*.js`), a publish-teszt és a
Ez lefedi: proto unit + `tsc --noEmit` + `eslint`, content séma-validáció és a
Plane-sync unit tesztek. Bármelyik hibája megszakítja a kiadást.
A CMS (content-editor.js) tesztjei külön is futtathatók (valódi szervert indítanak):
```bash
node scripts/test-content-editor-security.js # auth, CSRF, XFF
node scripts/test-content-editor-serializer.js # collect/reindex regresszió
node scripts/test-content-editor-save.js # atomikus mentés + validáció
node scripts/test-content-editor-conflict.js # optimista zárolás (409)
node scripts/test-content-editor-versions.js # verziók panel (diff, restore)
node scripts/test-content-editor-logo.js # logó feltöltés
node scripts/test-content-editor-login.js # login flow (session, Safari)
node scripts/test-content-editor-logout.js # logout + rate-limit
node scripts/test-content-editor-guide.js # /guide + markdown renderer
node scripts/test-content-editor-bottombar.js # alsó sáv layout guard
node scripts/test-cms-publish.js # publish parancs + integráció
```
> A régi egyedi CMS (content-editor.js) saját teszt-szkriptjeit (auth, CSRF,
> serializer, optimista zárolás, verziók panel, logó feltöltés, login/logout,
> guide, publish) a Payload CMS-re állás után (MITHOME-93) eltávolítottuk —
> a Payload admin felület a saját upstream tesztelésével fedett, ezt itt nem
> duplikáljuk.
>
> **MITHOME-96**: Payload collection/global config tesztek —
> `src/payload-config.test.ts` (a `npm test`/pre-deploy suite része, nincs
> hozzá élő DB, csak a Collection/Global config objektumokat vizsgálja —
> köztük két, MITHOME-121-ben talált éles hibára regresszió-őrt: a Media
> publikus olvashatósága, a Partners.logo opcionalitása). A Local API elleni,
> élő MongoDB-t igénylő tesztek külön scriptben vannak
> (`scripts/test-payload-local-api.ts`, `npm run test:payload`) — WHY nem
> Jest: a `payload` csomag ESM-only dist-et ad ki, amit a next/jest
> transzform alapból nem fordít le (a teljes függőségi fa
> `transformIgnorePatterns`-be vétele törékeny lenne), ezért ugyanazt a
> bevált `node --import tsx` mintát követi, mint `migrate-content-to-payload.ts`.
> Nincs a `pre-deploy-tests.sh`-ban (élő, migrált tartalmú MongoDB kell hozzá,
> mint a régi, eltávolított CMS-teszteknek is), de a `npm run test:payload`
> paranccsal bármikor lefuttatható egy futó dev MongoDB ellen. A régi,
> Docker-stack-hez kötött `integration.test.ts`/`e2e-docker.test.ts` is
> frissítve lett a locale-prefixes útvonalakra és a Payload collection-nevekre
> (korábban a pre-Payload, unprefixelt útvonalakra és `site_config`/
> `contact_submissions` nyers Mongo collection-ökre hivatkoztak).
-86
View File
@@ -1,86 +0,0 @@
---
description: CMS (Content Editor) fejlesztési munkafolyamata — módosítástól a staging élesítésig
---
# CMS Fejlesztési Workflow (/cms-feature)
A `content-editor.js`, a hozzá tartozó scriptek (`scripts/markdown-render.js`, `scripts/test-content-editor-*.js`) és a CMS-en keresztül szerkeszthető tartalmi sémák (`proto/src/content/schema.js`) módosításakor kövesd ezt a folyamatot.
A szabály (a *miért*): `.agent/steering/development-rules.md` → „Felhasználói dokumentáció karbantartása". Ez a fájl a *hogyan*-t írja le.
---
## 1. Tervezés
- Fogalmazd meg, mi változik a felhasználó szemszögéből (új gomb? más viselkedés? új hibaüzenet?)
- **Új endpoint vagy UI elem esetén**: tervezd meg az útmutató érintett szakaszának frissítését is.
- Content séma (`schema.js`) változásnál ellenőrizd, hogy a meglévő JSON fájlok mind átmegyek az új sémán.
## 2. Implementáció
- A CMS system Nodeon fut, **nincs node_modules** — csak beépítő modulokat használj (`http`, `fs`, `path`, `crypto`, `child_process`).
- Fájlméret: a `content-editor.js` közelít a 300 soros soft limit felé — nagyobb funkciót szervezz ki külön modulba (pl. `scripts/markdown-render.js` minta).
- Biztonsági követelmények:
- Minden új POST endpoint **CSRF token ellenőrzéssel**.
- Új GET endpoint **auth után** serviced (kivétel: `/logout` minta).
- User input escape-elés (lásd `escapeHtml` a markdown rendererben).
- Titok soha nem kerül kimenetre — válaszkóddal (401/200) igazolj.
## 3. Útmutató frissítése (KÖTELEZŐ, ugyanabban a commitban)
- Forrás: `docs/felhasznaloi-utmutato.md` — a felhasználó nyelvén, nem műszaki részletekkel.
- Új UI elem → felület áttekintés szakasz + adott funkció szakasz frissítése.
- Támogatott markdown részhalmaz: címsorok, **félkövér**, `kód`, listák, linkek, `---` (táblázat nem — a `scripts/markdown-render.js` nem jeleníti meg).
## 4. Tesztek
Minden CMS-változtatás után futtasd mindet:
```bash
node --check content-editor.js
node scripts/test-content-editor-security.js # auth, CSRF, XFF
node scripts/test-content-editor-serializer.js # collect/reindex regresszió
node scripts/test-content-editor-save.js # atomic save + validáció
node scripts/test-content-editor-logout.js # /logout viselkedés
node scripts/test-content-editor-guide.js # /guide + Súgó link
```
- Új funkcióhoz **új tesztfájl** a fenti minta szerint (valós szervert indító integration teszt ajánlott).
- Content séma változásnál: `node scripts/test-content-schema.js` is.
## 5. Commit
```bash
git add content-editor.js scripts/ docs/felhasznaloi-utmutato.md
git commit -m "feat(cms): <rövid leírás>" # vagy fix(cms):
```
- Az útmutató frissítése **ugyanabban a commitban** landol, mint a funkció.
- Plane ticket (`Closes MITHOME-XX`) + `node plane-sync.js --yes` a TODO.md szinkronhoz.
## 6. Staging élesítés és ellenőrzés
A CMS a websitetől **független szolgáltatás** — deploy szkript nélkül, közvetlenül élesítjük:
```bash
ssh sadmin@llmdev.mozdit.hu '
cd /home/sadmin/websitedev && git pull --ff-only origin main &&
sudo systemctl restart mozdit-content-editor.service &&
systemctl is-active mozdit-content-editor.service'
```
Ellenőrzés (a hitelesítő adatokat az `/etc/mozdit-content-editor.env`-ből olvasd, **soha ne írd ki**):
```bash
# kulcs nélkül 401-et várunk
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:4001/
# hitelesített kérést sudo sh -c ". /etc/mozdit-content-editor.env; curl -u ..." mintával futtass
```
- Az érintett funkciókra vonatkozó válaszkódok ellenőrzése (401/200/429 stb.).
- Ha a honlap (`proto/`) is változott: `./scripts/deploy_to_stage_on_local.sh` a teljes deployhoz.
## 7. Bezárás
- Plane ticket → Done.
- `node plane-sync.js --yes` + `git commit -m "sync: ..."` + push.
+4 -3
View File
@@ -9,7 +9,7 @@ description: Deployment munkafolyamata — lokális teszttől a stagingen át a
- **Nincs Dokploy / registry push** — a deploy natív Docker Compose-zal, a szerveren buildelve történik.
- **Környezetenként külön compose fájl**: `docker-compose.staging.yml` (app: 127.0.0.1:8081, saját Mongo) és `docker-compose.prod.yml` (app: 8080). A két környezet ugyanazon a hoston, külön hálózaton és volume-mal fut.
- **Környezeti változók**: kötelező `.env.staging` / `.env.production` fájlból jönnek (minta: `.env.staging.example`, `.env.production.example`). Bizalmas értékeknek (MONGO_ROOT_PASSWORD, MONGODB_URI) **nincs fallback** — hiányzó env hangosan hibázik.
- **Staging megkülönböztetés**: a `NEXT_PUBLIC_DEPLOY_ENV=staging` build arg amber banner-t jelenít meg a weboldalon; a Content Editor is staging feliratot kap.
- **Staging megkülönböztetés**: a `NEXT_PUBLIC_DEPLOY_ENV=staging` build arg amber banner-t jelenít meg a weboldalon.
## Előfeltételek
@@ -44,10 +44,11 @@ Bármely lépés hibája azonnal leállítja a folyamatot. Paraméterek környez
1. Kötelező `.env.<env>` fájl betöltése (`--env-file` a compose-nak).
2. `docker compose -f docker-compose.<env>.yml up --build --force-recreate -d` — a friss Dockerfile/build-arg változások is érvényesüljenek.
3. Healthcheck: max 60s-ig kérdezi a `http://localhost:<port>/api/health`-t (staging: 8081, prod: 8080). Sikertelenség esetén app-logok kiírása + exit 1.
4. **Payload admin-seedelés** (MITHOME-97): healthcheck után meghívja a Payload beépített `POST /api/users/first-register` endpointját az `.env.<env>`-ben megadott `ADMIN_EMAIL`/`ADMIN_PASSWORD`-del. Ez az endpoint csakis akkor enged létrehozni felhasználót, ha a `users` collection teljesen üres — ezért idempotens: egy vadonatúj adatbázisnál (első deploy, vagy volume-törlés utáni újrakezdés) automatikusan létrehozza az ismert admin fiókot, minden további deployon pedig 403-at kap és ártalmatlanul kihagyja (a jelszót utólag sosem írja felül). Ha az `ADMIN_EMAIL`/`ADMIN_PASSWORD` hiányzik az env fájlból, ezt a lépést egyszerűen kihagyja (figyelmeztetéssel, nem hibával) — ilyenkor az első felhasználót manuálisan kell létrehozni `/admin` alatt.
## CMS-ből történő publikálás
## Tartalom publikálása (Payload admin)
A Content Editor (`content-editor.js`) Publikálás gombja: commit + push (`content: frissítve a CMS-ből`), majd a `CMS_DEPLOY_ENV`-ben beállított környezet deployja a háttérben. **Soha nem deployol productionre implicit** — a `CMS_DEPLOY_ENV` kötelező, érvényes érték nélkül az editor el sem indul.
A régi Content Editor (`content-editor.js`, git push-alapú publikálás) helyét a Payload admin (`/admin`) draft/publish + verziózás funkciója vette át (MITHOME-92/93) — ez nem jár git commit-tal vagy automatikus deployjal, a tartalom közvetlenül a MongoDB-be íródik. Kódváltozás (Globals/Collections séma, frontend) továbbra is a lenti git-alapú deploy folyamaton megy át.
## Production deploy (élesítés)
+6 -2
View File
@@ -55,8 +55,12 @@ git checkout -b feature/[leíró-feature-neve]
```
### 3. Content frissítés (ha szöveges tartalom kell)
- Módosítsd a megfelelő `proto/src/content/pages/*.json` fájlt
- Frissítsd a `types.ts`-t ha új content struktúra kell
- Új mező esetén bővítsd a megfelelő `proto/src/globals/*.ts` vagy
`proto/src/collections/*.ts` Payload config fájlt, majd generáld újra a
típusokat (`payload generate:types`)
- Meglévő mező szövegét az ügyfél a Payload admin felületen (`/admin`)
szerkeszti — ne írj bele közvetlenül `proto/src/content/*.json`-ba (az már
csak a migrációs seed-script forrása, élő oldal nem olvassa, MITHOME-91/93)
### 4. UI / Komponens implementáció (ha szükséges)
- Server Component alapértelmezetten — csak indokolt esetben `"use client"`
+2 -1
View File
@@ -74,7 +74,8 @@ description: Kód review checklist — PR előtt és review során
✅ JÓ:
[BLOCK] Ez hardcoded szöveget tartalmaz a komponensben.
Át kell tenni: proto/src/content/pages/home.json → content.pages.home.*
Át kell tenni Payload mezőbe (proto/src/globals/*.ts vagy collections/*.ts),
és a payload-content.ts adapteren keresztül kell beolvasni.
[SUGGEST] Ezt a logikát érdemes egy helper funkcióba kiszervezni,
ha több helyen is szükség lesz rá.
@@ -0,0 +1,70 @@
---
name: program-hid-update
description: Frissíti a "Program Híd" publikus Artifactot (MITHOME ↔ PLATFM konvergencia-térkép, PLATFM roadmap-sín, MITHOME Payload-epic progresssáv) a Plane MITHOME és PLATFM projektek friss állapota alapján. Használd, ha a felhasználó a "Program Híd" frissítését/szinkronizálását kéri, "állapotkép"-et, "program térkép"-et említ, vagy miután jelentős haladás történt a MITHOME Payload CMS epicben (MITHOME-85 alá tartozó ticketek) vagy a PLATFM roadmapben/AI-pilotban (PLATFM-1..12).
---
# Program Híd frissítés
Ez a skill szinkronban tartja a publikált **Program Híd** Artifactot (interaktív konvergencia-térkép a mozdIT MITHOME weboldal- és PLATFM platform-projektje között) a Plane aktuális állapotával. Az Artifact statikus HTML+JS — nincs élő Plane-kapcsolata, ezért kézzel (ezzel a skillel) kell frissen tartani.
## Állandók
- **Artifact URL** (mindig ugyanerre frissíts, ne hozz létre új linket): `https://claude.ai/code/artifact/95d7def2-c648-4fb7-a3f3-0d172b1c21ab`
- **MITHOME Plane project_id**: `643f7055-1237-4912-912f-99ec49fd0f0e`
- **PLATFM Plane project_id**: `b3e5b750-5a24-48f9-a3b6-e79925475b64`
- **Kanonikus forrásfájl ebben a repóban**: `.claude/skills/program-hid-update/source.html` — ez van feltöltve az Artifactra. Mindig ezt szerkeszd, ne a régi scratchpad-másolatot (az session-specifikus, nem marad meg).
## Lépések
### 1. Artifact olvasása jóváhagyás előtt
Hívd meg `Artifact` action `"read"`-et a fenti URL-lel, mielőtt bármit publikálsz — ez a tool előírása (nem publikálhatsz olyan artifactra, amit a beszélgetés még nem olvasott). Ha a visszakapott tartalom eltér a helyi `source.html`-től (pl. valaki kézzel szerkesztette a publikált oldalt), a Plane-ből frissen lekért adatot építsd *arra* a verzióra, ne a helyi fájlra.
### 2. Friss Plane-állapot lekérése
**MITHOME oldal** — kérdezd le ezeket (readable identifier vagy `get_issue_using_readable_identifier`):
- MITHOME-85 epic gyerekei: 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 102, 103, 105 — állapot (Done/Backlog/Todo/In Progress/Cancelled) és cím.
- Ellenőrizd `list_project_issues`-szal (project_id fent), hogy nem került-e be **új** gyerek a MITHOME-85 alá az utolsó frissítés óta — ha igen, vedd fel az `epicItems` tömbbe.
- MITHOME-31, MITHOME-40 (a hídon szereplő két satellit-ticket) állapota/prioritása.
**PLATFM oldal** — kérdezd le mind a 12 (vagy annál több, ha új került fel) issue-t `list_project_issues`-szal a PLATFM project_id-n:
- Melyik fázishoz tartoznak (a fázis-hozzárendelés statikus, lásd `source.html` `phases` tömb — csak akkor változtasd, ha a PLATFM-11 roadmap issue szövege explicit átsorolást ír le).
- Állapot/prioritás (kiemelten: PLATFM-5 BCDR és PLATFM-10 AI-pilot, mert ezek adják a híd "hot"/"live" jelölését).
### 3. Adatok újraszámolása
- **`epicItems`** (MITHOME Payload epic): `done: true`, ha a Plane state "Done"; frissítsd a címeket, ha változtak; vedd fel az új ticketeket.
- **`phases`** (PLATFM roadmap-sín): egy fázis `status` legyen `"progress"`, ha van benne legalább egy In Progress vagy Done issue és van még nyitott is; `"urgent"`, ha van benne urgent prioritású *nyitott* issue (ma ez az 1. fázis, PLATFM-5 miatt); egyébként `"open"`. Az `issues` tömb `p` mezője (`urgent`/`high`/`med`) kövesse a Plane priority mezőt.
- **`rows`** (híd, 6 konvergencia-pont): a `cls` mező jelzi az állapotot —
- `"live"` (zöld, animált): a konvergencia-pont mindkét oldala aktívan halad vagy már összekötött (pl. AI-triázs, amíg a MITHOME esemény-emisszió és a PLATFM-10 pilot is legalább In Progress).
- `"hot"` (piros, pulzáló): sürgős, még nincs megoldva — ma ez a BCDR sor (MITHOME-40 + PLATFM-5), amíg mindkettő nyitott.
- `""` (semleges): még nem aktuális, a trigger-feltétel nem teljesült.
- Ha egy konvergencia-pont mindkét oldala Done lesz, változtasd a sor feliratát "lezárva"-ra, és `cls` maradjon `"live"` (zöld), de a `trig` szöveg mondja ki, hogy megtörtént.
- **Fejléc chipek** (`.chiprow`): számold újra a "X aktív · Y kész · Z lezárt duplikátum" (MITHOME) és "N feladat · M urgent · 8 roadmap-fázis" (PLATFM) szövegeket a friss számokból.
- **`.stamp`** dátum: írd át a mai dátumra (`YYYY-MM-DD`, éles Plane-lekérdezés napja).
### 4. A `source.html` szerkesztése
Az adatok a fájl végén, a `<script>` blokkban vannak (`phases`, `unplaced`, `rows`, `epicItems` JS tömbök) — ezeket szerkeszd Edit-tel, ne írd újra az egész fájlt. A CSS/HTML-váz (fejléc, szekciók, SVG-koordináták) stabil marad, hacsak nem változik a konvergencia-pontok *száma* (akkor a bridge SVG `viewBox` magassága és a `rowH`/`top` számítás is igazításra szorul — lásd a JS-ben a `rows.forEach` ciklust).
Ha egy phase `issues` tömbje bővül/csökken, a fejlécben lévő `pcount` automatikusan követi (JS-ből generálódik), nincs kézi szinkron.
### 5. Ellenőrzés publikálás előtt
- Nézd át, hogy minden JS tömb szintaktikailag zárt (vesszők, zárójelek) — egy törött `<script>` az egész oldalt elviszi.
- Számold meg a `{`/`}` és `(`/`)` párokat a módosított szakaszban, ha bizonytalan vagy.
- Ha van rá mód (bejelentkezett böngésző-session), nyisd meg az Artifact URL-t és nézd meg egyszer, mielőtt publikálod a végleges verziót — lásd az `artifact-design` skill "write, look once, publish" szabályát.
### 6. Publikálás
`Artifact` action `"publish"` (alapértelmezett), `file_path` a `source.html`-re, **`url`** a fenti Artifact URL-re (így ugyanaz a link marad, nem jön létre új Artifact). `favicon`-t és `title`-t **ne** add meg újra — a redeploy megtartja a meglévőt.
### 7. Nyomon követés
- Írj egy rövid sort a `TODO.md` "Frissítési Napló" szekciójába: `**YYYY-MM-DD**: Program Híd frissítve (X/Y MITHOME Payload-feladat kész, N nyitott PLATFM-item).`
- Commitold a `source.html` változást (`git add .claude/skills/program-hid-update/source.html TODO.md && git commit` a projekt konvenciói szerint — `docs:` vagy `chore:` prefix).
- Foglald össze a felhasználónak, mi változott a térképen (melyik konvergencia-pont mozdult, melyik fázis indult el).
## Ha rendszeres/automatikus futtatást szeretne a felhasználó
Ez a skill önmagában **kézi indítású** (a felhasználó vagy egy másik munkamenet hívja meg). Ha valódi, felügyelet nélküli, időzített frissítést szeretnének (pl. hetente egyszer), azt a `schedule` skillel/CronCreate-tel lehet erre a skillre ráépíteni — ezt csak akkor állítsd be, ha a felhasználó explicit kéri, és mondd el neki, hogy ez azt jelenti: rendszeres, felügyelet nélküli Plane-olvasás és Artifact-publikálás fog lezajlani a háttérben.
@@ -0,0 +1,685 @@
<title>Program Híd</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap">
<style>
:root{
--bg:#F2F6F6;
--surface:#FFFFFF;
--surface-2:#E7EEEE;
--ink:#152029;
--ink-soft:#4C606D;
--ink-faint:#7E909B;
--line:#C7D5D8;
--line-strong:#A9BCC0;
--accent:#0F7A8F;
--accent-ink:#FFFFFF;
--accent-soft:#DCEEF1;
--success:#2C8A57;
--success-soft:#DEF1E6;
--warning:#AD6F0B;
--warning-soft:#F6E9D2;
--critical:#C13B30;
--critical-soft:#F8DEDB;
--shadow: 0 1px 2px rgba(21,32,41,.06), 0 8px 24px -12px rgba(21,32,41,.18);
--radius: 10px;
font-synthesis: none;
}
@media (prefers-color-scheme: dark){
:root:not([data-theme="light"]){
--bg:#0D151D;
--surface:#131E28;
--surface-2:#1A2733;
--ink:#E7EFF2;
--ink-soft:#9BAEBB;
--ink-faint:#6C8090;
--line:#2A3C49;
--line-strong:#3B5062;
--accent:#3FBBD1;
--accent-ink:#052229;
--accent-soft:#163642;
--success:#4FBE84;
--success-soft:#173428;
--warning:#E3A741;
--warning-soft:#3A2B10;
--critical:#E2685C;
--critical-soft:#3A1D1A;
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 12px 28px -14px rgba(0,0,0,.6);
}
}
:root[data-theme="dark"]{
--bg:#0D151D;
--surface:#131E28;
--surface-2:#1A2733;
--ink:#E7EFF2;
--ink-soft:#9BAEBB;
--ink-faint:#6C8090;
--line:#2A3C49;
--line-strong:#3B5062;
--accent:#3FBBD1;
--accent-ink:#052229;
--accent-soft:#163642;
--success:#4FBE84;
--success-soft:#173428;
--warning:#E3A741;
--warning-soft:#3A2B10;
--critical:#E2685C;
--critical-soft:#3A1D1A;
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 12px 28px -14px rgba(0,0,0,.6);
}
*{ box-sizing:border-box; }
body{
margin:0;
background:var(--bg);
color:var(--ink);
font-family:"IBM Plex Sans", system-ui, -apple-system, sans-serif;
line-height:1.5;
}
.mono{ font-family:"IBM Plex Mono", ui-monospace, "SF Mono", monospace; }
.wrap{
max-width:1180px;
margin:0 auto;
padding:36px 20px 64px;
}
/* ---------- header ---------- */
header.hero{
display:flex;
flex-direction:column;
gap:14px;
margin-bottom:34px;
}
.eyebrow{
font-family:"IBM Plex Mono", monospace;
font-size:11.5px;
letter-spacing:.14em;
text-transform:uppercase;
color:var(--accent);
font-weight:600;
}
h1{
font-size:clamp(28px, 4.4vw, 42px);
line-height:1.08;
margin:0;
letter-spacing:-0.01em;
text-wrap:balance;
font-weight:700;
}
.lede{
max-width:64ch;
color:var(--ink-soft);
font-size:15.5px;
margin:0;
}
.stamp{
font-family:"IBM Plex Mono", monospace;
font-size:12px;
color:var(--ink-faint);
}
.chiprow{
display:flex;
flex-wrap:wrap;
gap:10px;
margin-top:6px;
}
.chip{
display:flex;
align-items:baseline;
gap:8px;
background:var(--surface);
border:1px solid var(--line);
border-radius:999px;
padding:7px 14px 7px 12px;
box-shadow:var(--shadow);
}
.chip .dot{ width:8px; height:8px; border-radius:50%; flex:none; }
.chip .proj{ font-family:"IBM Plex Mono", monospace; font-weight:600; font-size:12.5px; letter-spacing:.02em; }
.chip .stat{ font-size:12.5px; color:var(--ink-soft); }
.chip.mithome .dot{ background:var(--accent); }
.chip.platfm .dot{ background:#8B6BC9; }
.filterbar{
display:flex;
gap:6px;
margin-top:4px;
}
.filterbar button{
font-family:"IBM Plex Mono", monospace;
font-size:12px;
letter-spacing:.02em;
border:1px solid var(--line);
background:var(--surface);
color:var(--ink-soft);
padding:6px 12px;
border-radius:7px;
cursor:pointer;
transition: background .15s ease, color .15s ease, border-color .15s ease;
}
.filterbar button:hover{ border-color:var(--line-strong); color:var(--ink); }
.filterbar button[aria-pressed="true"]{
background:var(--ink);
color:var(--bg);
border-color:var(--ink);
}
:root[data-theme="dark"] .filterbar button[aria-pressed="true"]{
background:var(--accent); color:var(--accent-ink); border-color:var(--accent);
}
@media (prefers-color-scheme:dark){
:root:not([data-theme="light"]) .filterbar button[aria-pressed="true"]{
background:var(--accent); color:var(--accent-ink); border-color:var(--accent);
}
}
/* ---------- section shell ---------- */
section{ margin-top:46px; }
.sectionhead{
display:flex;
align-items:baseline;
justify-content:space-between;
gap:12px;
margin-bottom:16px;
border-bottom:1px solid var(--line);
padding-bottom:10px;
}
.sectionhead h2{
font-size:14px;
text-transform:uppercase;
letter-spacing:.1em;
margin:0;
font-weight:600;
color:var(--ink-soft);
}
.sectionhead .note{ font-size:12.5px; color:var(--ink-faint); font-family:"IBM Plex Mono", monospace; }
/* ---------- phase rail ---------- */
.railscroll{ overflow-x:auto; padding-bottom:6px; margin: 0 -4px; }
.rail{
position:relative;
display:flex;
gap:0;
min-width:840px;
padding:34px 4px 4px;
}
.rail::before{
content:"";
position:absolute;
top:14px; left:44px; right:44px; height:2px;
background: repeating-linear-gradient(90deg, var(--line-strong) 0 8px, transparent 8px 14px);
background-size: 200% 100%;
animation: flow 6s linear infinite;
}
@media (prefers-reduced-motion: reduce){ .rail::before{ animation:none; } }
@keyframes flow{ from{ background-position:0 0; } to{ background-position:-28px 0; } }
.phase{
flex:1 1 0;
display:flex;
flex-direction:column;
align-items:center;
text-align:center;
padding:0 8px;
position:relative;
cursor:pointer;
transition:opacity .25s ease;
}
.phase .node{
width:26px; height:26px; border-radius:50%;
background:var(--surface);
border:2px solid var(--line-strong);
display:flex; align-items:center; justify-content:center;
font-family:"IBM Plex Mono", monospace;
font-size:11px; font-weight:600; color:var(--ink-soft);
z-index:1;
transition: border-color .15s ease, transform .15s ease, color .15s ease;
}
.phase[data-status="progress"] .node{ border-color:var(--accent); color:var(--accent); background:var(--accent-soft); }
.phase[data-status="urgent"] .node{ border-color:var(--critical); color:var(--critical); background:var(--critical-soft); }
.phase:hover .node{ transform:scale(1.12); }
.phase .pname{
margin-top:10px; font-size:12.5px; font-weight:600; max-width:15ch; text-wrap:balance;
}
.phase .pcount{
margin-top:3px; font-size:11px; color:var(--ink-faint); font-family:"IBM Plex Mono", monospace;
}
.phase.dim{ opacity:.32; }
.phase-extra{
flex:0 0 auto;
width:150px;
padding-left:14px;
margin-left:6px;
border-left:1px dashed var(--line-strong);
display:flex; flex-direction:column; align-items:flex-start; text-align:left;
cursor:pointer;
}
.phase-extra .tag{
font-family:"IBM Plex Mono", monospace; font-size:10.5px; text-transform:uppercase; letter-spacing:.08em;
color:var(--warning); font-weight:600; margin-bottom:6px;
}
.phase-extra .pname{ font-size:12.5px; font-weight:600; }
.phase-extra .pcount{ font-size:11px; color:var(--ink-faint); font-family:"IBM Plex Mono", monospace; margin-top:3px;}
.detail{
margin-top:18px;
background:var(--surface);
border:1px solid var(--line);
border-radius:var(--radius);
box-shadow:var(--shadow);
padding:18px 20px;
display:none;
}
.detail.open{ display:block; }
.detail h3{ margin:0 0 3px; font-size:15px; }
.detail .sub{ margin:0 0 14px; font-size:12.5px; color:var(--ink-faint); font-family:"IBM Plex Mono", monospace; }
.issuelist{ display:flex; flex-direction:column; gap:8px; }
.issue{
display:flex; align-items:center; gap:10px;
padding:8px 10px;
background:var(--surface-2);
border-radius:7px;
}
.issue .id{
font-family:"IBM Plex Mono", monospace; font-size:11.5px; font-weight:600;
background:var(--surface); border:1px solid var(--line); border-radius:5px;
padding:2px 7px; flex:none; color:var(--ink-soft);
}
.issue .t{ font-size:13px; flex:1; }
.issue .p{ font-size:10.5px; font-family:"IBM Plex Mono", monospace; text-transform:uppercase; letter-spacing:.06em; flex:none; padding:2px 7px; border-radius:5px; }
.p.urgent{ background:var(--critical-soft); color:var(--critical); }
.p.high{ background:var(--warning-soft); color:var(--warning); }
.p.med{ background:var(--surface); color:var(--ink-faint); border:1px solid var(--line); }
.empty-note{ font-size:13px; color:var(--ink-faint); font-style:italic; }
/* ---------- bridge ---------- */
.bridgescroll{ overflow-x:auto; }
.bridge-wrap{ min-width:760px; }
svg.bridge{ width:100%; height:auto; display:block; overflow:visible; }
.col-label{
font-family:"IBM Plex Mono", monospace; font-size:11.5px; font-weight:600; letter-spacing:.06em; text-transform:uppercase;
}
.node-card{ cursor:pointer; }
.node-card rect{ fill:var(--surface); stroke:var(--line-strong); stroke-width:1.3; transition:stroke .15s ease, filter .15s ease; }
.node-card:hover rect{ stroke:var(--accent); }
.node-title{ font-size:12.5px; font-weight:600; fill:var(--ink); font-family:"IBM Plex Sans", sans-serif; }
.node-sub{ font-size:10.5px; fill:var(--ink-faint); font-family:"IBM Plex Mono", monospace; }
.conn-label{ font-size:10px; fill:var(--ink-faint); font-family:"IBM Plex Mono", monospace; }
.conn-label.hot{ fill:var(--critical); font-weight:600; }
.conn-label.live{ fill:var(--success); font-weight:600; }
.flowpath{ fill:none; stroke:var(--line-strong); stroke-width:1.6; stroke-dasharray:1 7; stroke-linecap:round; }
.flowpath.live{ stroke:var(--success); stroke-dasharray:5 5; animation:dash 1.8s linear infinite; }
.flowpath.hot{ stroke:var(--critical); stroke-dasharray:5 5; animation:dash 1.2s linear infinite; }
@keyframes dash{ to{ stroke-dashoffset:-20; } }
@media (prefers-reduced-motion: reduce){ .flowpath.live, .flowpath.hot{ animation:none; } }
.ghost rect{ fill:none; stroke:var(--line); stroke-dasharray:3 3; }
.ghost text{ fill:var(--ink-faint); }
.ghostpath{ fill:none; stroke:var(--line); stroke-width:1.2; stroke-dasharray:2 4; }
.side.dim{ opacity:.28; transition:opacity .25s ease; }
.flowpath.dim, .conn-label.dim{ opacity:.15; }
/* ---------- epic progress ---------- */
.epicbar{
display:flex; flex-wrap:wrap; gap:8px;
}
.seg{
display:flex; align-items:center; gap:8px;
background:var(--surface); border:1px solid var(--line); border-radius:7px;
padding:8px 10px;
min-width:200px; flex:1 1 220px;
}
.seg .id{ font-family:"IBM Plex Mono", monospace; font-size:11px; font-weight:600; color:var(--ink-soft); flex:none; }
.seg .t{ font-size:12.5px; flex:1; }
.seg .state{ width:9px; height:9px; border-radius:50%; flex:none; }
.state.done{ background:var(--success); }
.state.open{ background:var(--ink-faint); }
.progresswrap{
display:flex; align-items:center; gap:12px; margin-bottom:16px;
}
.progresstrack{
flex:1; height:8px; border-radius:99px; background:var(--surface-2); overflow:hidden;
}
.progressfill{
height:100%; border-radius:99px;
background:linear-gradient(90deg, var(--accent), var(--success));
transition:width .6s ease;
}
.progresslabel{ font-family:"IBM Plex Mono", monospace; font-size:12.5px; color:var(--ink-soft); flex:none; }
footer{
margin-top:50px; padding-top:18px; border-top:1px solid var(--line);
font-size:12px; color:var(--ink-faint); font-family:"IBM Plex Mono", monospace;
display:flex; flex-wrap:wrap; gap:6px 18px; justify-content:space-between;
}
@media (max-width:640px){
.wrap{ padding:26px 14px 48px; }
.sectionhead{ flex-direction:column; align-items:flex-start; gap:4px; }
}
</style>
<div class="wrap">
<header class="hero">
<span class="eyebrow">mozdIT Bt. · program áttekintés</span>
<h1>Program Híd</h1>
<p class="lede">MITHOME (mozdIT weboldal + Payload CMS-migráció) és PLATFM (a szélesebb vállalati platform) között hat ponton ér össze a munka. Ez az oldal azt mutatja, hol tartunk, és mi indítja el a következő találkozási pontot.</p>
<p class="stamp">Statikus állapotkép — 2026&#8209;09&#8209;10, a Plane MITHOME + PLATFM projektek alapján</p>
<div class="chiprow">
<div class="chip mithome"><span class="dot"></span><span class="proj">MITHOME</span><span class="stat">15 aktív · 2 kész · 7 lezárt duplikátum</span></div>
<div class="chip platfm"><span class="dot"></span><span class="proj">PLATFM</span><span class="stat">12 feladat · 1 urgent · 8 roadmap-fázis</span></div>
</div>
<div class="filterbar" role="group" aria-label="Szűrés projekt szerint">
<button type="button" data-filter="all" aria-pressed="true">Mindkettő</button>
<button type="button" data-filter="mithome" aria-pressed="false">MITHOME</button>
<button type="button" data-filter="platfm" aria-pressed="false">PLATFM</button>
</div>
</header>
<!-- ===================== PHASE RAIL ===================== -->
<section>
<div class="sectionhead">
<h2>PLATFM roadmap · 8 fázis</h2>
<span class="note">sorrend van, dátum nincs — kattints egy fázisra</span>
</div>
<div class="railscroll">
<div class="rail" id="rail">
<!-- phases injected by JS -->
</div>
</div>
<div class="detail" id="phaseDetail"></div>
</section>
<!-- ===================== BRIDGE ===================== -->
<section>
<div class="sectionhead">
<h2>Konvergencia-híd</h2>
<span class="note">6 pont, ahol a két projekt ugyanazt a dolgot érinti</span>
</div>
<div class="bridgescroll">
<div class="bridge-wrap">
<svg class="bridge" viewBox="0 0 940 566" role="img" aria-label="MITHOME és PLATFM közötti hat konvergencia-pont, összekötő nyilakkal és aktiválási feltétellel">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="var(--line-strong)"></path>
</marker>
<marker id="arrowLive" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="var(--success)"></path>
</marker>
<marker id="arrowHot" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="var(--critical)"></path>
</marker>
</defs>
<text x="40" y="26" class="col-label" fill="var(--accent)">MITHOME · weboldal</text>
<text x="900" y="26" class="col-label" fill="#8B6BC9" text-anchor="end">PLATFM · vállalati platform</text>
<!-- connector paths (drawn first, under nodes) -->
<g id="connectors"></g>
<!-- nodes -->
<g id="leftNodes"></g>
<g id="rightNodes"></g>
<!-- ghost story node -->
<g class="ghost" id="ghostNode" transform="translate(392,470)">
<rect x="0" y="0" width="156" height="46" rx="8"></rect>
<text x="12" y="18" class="node-sub">MITHOME-99 EPIC</text>
<text x="12" y="33" class="node-sub">duplikátum · lezárva 09-10</text>
</g>
</svg>
</div>
</div>
</section>
<!-- ===================== EPIC PROGRESS ===================== -->
<section id="mithomeSection">
<div class="sectionhead">
<h2>MITHOME · Payload CMS epic</h2>
<span class="note">MITHOME-85 alá tartozó feladatok</span>
</div>
<div class="progresswrap">
<div class="progresstrack"><div class="progressfill" id="epicFill" style="width:0%"></div></div>
<span class="progresslabel" id="epicLabel">2 / 15 kész</span>
</div>
<div class="epicbar" id="epicBar"></div>
</section>
<footer>
<span>Forrás: Plane · MITHOME + PLATFM projektek</span>
<span>Frissítve: 2026-09-10 · nem szinkronizál élőben</span>
</footer>
</div>
<script>
(function(){
var phases = [
{n:1, name:"Platform-alapok és governance", status:"progress", issues:[
{id:"PLATFM-1", t:"Platform architektúra és komponensfüggőségi nyilvántartás", p:"high"},
{id:"PLATFM-5", t:"Üzletmenet-folytonossági és katasztrófa-helyreállítási terv (BCDR)", p:"urgent"},
{id:"PLATFM-6", t:"Build vs. Open Source döntési keret és komponensenkénti ADR-ek", p:"high"},
{id:"PLATFM-8", t:"Infrastruktúra-kapacitás, értékesíthető készlet és bővítési ROI modell", p:"high"},
{id:"PLATFM-9", t:"Magyar mikro- és KKV webszolgáltatási piac kutatása és termékpriorizálás", p:"high"}
]},
{n:2, name:"IDM v1 (identitás, RBAC, audit)", status:"open", issues:[
{id:"PLATFM-4", t:"Közös jogosultsági modell (RBAC + adatbesorolás) alapjai", p:"high"}
]},
{n:3, name:"Content Release réteg", status:"open", issues:[]},
{n:4, name:"Központi termékkatalógus", status:"open", issues:[
{id:"PLATFM-3", t:"Központi termék- és szolgáltatáskatalógus tervezése (mIT Start/Mid/Pro/Pro Plus)", p:"high"}
]},
{n:5, name:"Belső mIT Portal v1", status:"open", issues:[]},
{n:6, name:"Messaging Gateway", status:"open", issues:[
{id:"PLATFM-2", t:"Központi Messaging Gateway tervezése és első e-mail csatorna", p:"high"}
]},
{n:7, name:"Rendelés / számlázás / provisioning", status:"open", issues:[
{id:"PLATFM-7", t:"Felügyelt szolgáltatás-teljesítési lánc (rendelés → számlázás → provisioning → üzemeltetés)", p:"high"}
]},
{n:8, name:"RAG és tudásréteg", status:"open", issues:[]}
];
var unplaced = {name:"Nincs fázisba sorolva", issues:[
{id:"PLATFM-10", t:"Kis kapacitásigényű, felügyelt AI-funkciók pilot és ROI-validáció", p:"med"},
{id:"PLATFM-12", t:"ADR: AI Gateway / triázs-motor — Hermes Agent vs. alternatívák", p:"high"}
]};
var rail = document.getElementById("rail");
var detail = document.getElementById("phaseDetail");
function statusLabel(s){
if(s==="progress") return "folyamatban";
if(s==="urgent") return "sürgős";
return "nem indult";
}
function renderDetail(name, issues, sub){
var html = "<h3>"+name+"</h3><p class=\"sub\">"+sub+"</p>";
if(issues.length===0){
html += "<p class=\"empty-note\">Ehhez a fázishoz még nincs Plane-feladat — az előtte lévő fázisok lezárása után nyílik meg.</p>";
} else {
html += "<div class=\"issuelist\">" + issues.map(function(i){
return "<div class=\"issue\"><span class=\"id\">"+i.id+"</span><span class=\"t\">"+i.t+"</span><span class=\"p "+i.p+"\">"+i.p+"</span></div>";
}).join("") + "</div>";
}
detail.innerHTML = html;
detail.classList.add("open");
}
var openPhase = null;
function togglePhase(el, name, issues, sub){
if(openPhase === el){
detail.classList.remove("open");
openPhase = null;
return;
}
openPhase = el;
renderDetail(name, issues, sub);
}
phases.forEach(function(ph){
var el = document.createElement("div");
el.className = "phase";
el.dataset.status = ph.status;
el.dataset.side = "platfm";
el.innerHTML = "<div class=\"node\">"+ph.n+"</div><div class=\"pname\">"+ph.name+"</div><div class=\"pcount\">"+ph.issues.length+" feladat</div>";
el.addEventListener("click", function(){
togglePhase(el, ph.n+". fázis · "+ph.name, ph.issues, "Roadmap-sorrend szerinti "+ph.n+". lépés · státusz: "+statusLabel(ph.status));
});
rail.appendChild(el);
});
var extra = document.createElement("div");
extra.className = "phase-extra";
extra.dataset.side = "platfm";
extra.innerHTML = "<div class=\"tag\">függőben</div><div class=\"pname\">"+unplaced.name+"</div><div class=\"pcount\">"+unplaced.issues.length+" feladat</div>";
extra.addEventListener("click", function(){
togglePhase(extra, unplaced.name, unplaced.issues, "PLATFM-10 pilot elindult izoláltan, mielőtt a roadmap besorolta volna — 2026-09-10-i döntés");
});
rail.appendChild(extra);
// ---------------- bridge ----------------
var leftG = document.getElementById("leftNodes");
var rightG = document.getElementById("rightNodes");
var connG = document.getElementById("connectors");
var rows = [
{
left:{t:"Esemény-forrás", sub:"MITHOME-102 · 103 · 105"},
right:{t:"AI-triázs pilot + ADR", sub:"PLATFM-10 · 12"},
trig:"már aktív", cls:"live"
},
{
left:{t:"Payload Users", sub:"MITHOME-90 · helyi auth"},
right:{t:"IDM v1 / RBAC", sub:"PLATFM-4 · 2. fázis"},
trig:"IDM v1 indulásakor", cls:""
},
{
left:{t:"Draft & régi CMS", sub:"MITHOME-92 · 93"},
right:{t:"Content Release réteg", sub:"3. fázis"},
trig:"réteg indulásakor", cls:""
},
{
left:{t:"Szolgáltatások oldal", sub:"Payload Services global"},
right:{t:"Termékkatalógus", sub:"PLATFM-3 · 4. fázis"},
trig:"katalógus MVP-nél", cls:""
},
{
left:{t:"Kapcsolati form", sub:"e-mail értesítés"},
right:{t:"Messaging Gateway", sub:"PLATFM-2 · 6. fázis"},
trig:"gateway kész", cls:""
},
{
left:{t:"Mongo mentés", sub:"MITHOME-40 · urgent"},
right:{t:"BCDR terv", sub:"PLATFM-5 · RPO 15p / RTO 4ó"},
trig:"production induláskor", cls:"hot"
}
];
var top = 60, rowH = 68, boxW = 210, boxH = 46;
var leftX = 40, rightX = 940 - 40 - boxW;
rows.forEach(function(r, idx){
var y = top + idx*rowH;
var lg = document.createElementNS("http://www.w3.org/2000/svg","g");
lg.setAttribute("class","node-card"); lg.setAttribute("data-side","mithome");
lg.setAttribute("transform","translate("+leftX+","+y+")");
lg.innerHTML = '<rect width="'+boxW+'" height="'+boxH+'" rx="8"></rect>'+
'<text x="14" y="19" class="node-title">'+r.left.t+'</text>'+
'<text x="14" y="34" class="node-sub">'+r.left.sub+'</text>';
leftG.appendChild(lg);
var rg = document.createElementNS("http://www.w3.org/2000/svg","g");
rg.setAttribute("class","node-card"); rg.setAttribute("data-side","platfm");
rg.setAttribute("transform","translate("+rightX+","+y+")");
rg.innerHTML = '<rect width="'+boxW+'" height="'+boxH+'" rx="8"></rect>'+
'<text x="14" y="19" class="node-title">'+r.right.t+'</text>'+
'<text x="14" y="34" class="node-sub">'+r.right.sub+'</text>';
rightG.appendChild(rg);
var x1 = leftX+boxW, y1 = y+boxH/2, x2 = rightX, y2 = y+boxH/2;
var mx = (x1+x2)/2;
var d = "M"+x1+","+y1+" C "+mx+","+y1+" "+mx+","+y2+" "+(x2-8)+","+y2;
var marker = r.cls==="live" ? "url(#arrowLive)" : (r.cls==="hot" ? "url(#arrowHot)" : "url(#arrow)");
var path = document.createElementNS("http://www.w3.org/2000/svg","path");
path.setAttribute("d", d);
path.setAttribute("class","flowpath "+r.cls);
path.setAttribute("marker-end", marker);
path.setAttribute("data-side","bridge");
connG.appendChild(path);
var label = document.createElementNS("http://www.w3.org/2000/svg","text");
label.setAttribute("x", mx);
label.setAttribute("y", y1 - 8);
label.setAttribute("text-anchor","middle");
label.setAttribute("class","conn-label"+(r.cls==="hot"?" hot":r.cls==="live"?" live":""));
label.setAttribute("data-side","bridge");
label.textContent = r.trig;
connG.appendChild(label);
});
// ghost connector: MITHOME-99 (cancelled) -> PLATFM-10
var ghostPath = document.createElementNS("http://www.w3.org/2000/svg","path");
var gx1 = 392, gy1 = 470+10, gx2 = rightX+8, gy2 = top + 0*rowH + boxH/2;
ghostPath.setAttribute("d","M"+gx1+","+gy1+" C "+(gx1-120)+","+gy1+" "+(gx2-40)+","+gy2+" "+gx2+","+gy2);
ghostPath.setAttribute("class","ghostpath");
connG.insertBefore(ghostPath, connG.firstChild);
// ---------------- epic progress ----------------
var epicItems = [
{id:"MITHOME-86", t:"Alapinstallálás: config, MongoDB adapter, admin route", done:true},
{id:"MITHOME-87", t:"Home/About/Services/Contact/Common Globals + JSON migráció", done:true},
{id:"MITHOME-88", t:"LegalPages collection", done:false},
{id:"MITHOME-89", t:"Partners + Media collection", done:false},
{id:"MITHOME-90", t:"Users collection: admin bejelentkezés", done:false},
{id:"MITHOME-91", t:"Frontend Local API átállás", done:false},
{id:"MITHOME-92", t:"Draft/preview és verziózás", done:false},
{id:"MITHOME-93", t:"Régi egyedi CMS leépítése", done:false},
{id:"MITHOME-94", t:"ContactSubmissions collection", done:false},
{id:"MITHOME-95", t:"Dokumentáció frissítése", done:false},
{id:"MITHOME-96", t:"Tesztek migrálása", done:false},
{id:"MITHOME-97", t:"Deploy/Docker frissítés", done:false},
{id:"MITHOME-102", t:"Esemény-emisszió — PLATFM-2 séma", done:false},
{id:"MITHOME-103", t:"Esemény-emisszió biztonsága", done:false},
{id:"MITHOME-105", t:"MITHOME-31 lezárása a pilottal", done:false}
];
var epicBar = document.getElementById("epicBar");
epicItems.forEach(function(it){
var el = document.createElement("div");
el.className = "seg";
el.dataset.side = "mithome";
el.innerHTML = '<span class="state '+(it.done?"done":"open")+'"></span>'+
'<span class="id">'+it.id+'</span><span class="t">'+it.t+'</span>';
epicBar.appendChild(el);
});
var doneCount = epicItems.filter(function(i){return i.done;}).length;
document.getElementById("epicFill").style.width = Math.round(doneCount/epicItems.length*100)+"%";
document.getElementById("epicLabel").textContent = doneCount+" / "+epicItems.length+" kész";
// ---------------- filter ----------------
var buttons = document.querySelectorAll(".filterbar button");
buttons.forEach(function(btn){
btn.addEventListener("click", function(){
buttons.forEach(function(b){ b.setAttribute("aria-pressed", b===btn ? "true":"false"); });
var f = btn.dataset.filter;
document.querySelectorAll('[data-side="mithome"]').forEach(function(n){
n.classList.toggle("dim", f==="platfm");
});
document.querySelectorAll('[data-side="platfm"]').forEach(function(n){
n.classList.toggle("dim", f==="mithome");
});
document.querySelectorAll('[data-side="bridge"]').forEach(function(n){
n.classList.toggle("dim", f!=="all");
});
});
});
})();
</script>
+15
View File
@@ -7,6 +7,21 @@ MONGODB_DB=mozdit
MONGO_ROOT_USER=admin
MONGO_ROOT_PASSWORD=changeme
# Payload CMS (MITHOME-86) — generálj egyedi, erős titkot ehhez a
# környezethez (pl. `openssl rand -hex 32`); ne ossz meg értéket másik
# environmenttel.
PAYLOAD_SECRET=changeme
# Payload admin — ismert, biztosan létező admin fiók (MITHOME-97). A
# deploy.sh minden deploy után meghívja a Payload "first-register" REST
# endpointját ezekkel az adatokkal: teljesen friss (üres users collection-ű)
# adatbázisnál létrehozza ezt a fiókot, egyébként (ha már van user)
# ártalmatlanul kihagyja — nem írja felül a jelszót utólag. Generálj egyedi,
# erős jelszót (pl. jelszókezelővel), NE ossz meg értéket a staginggel; a
# hiányzó két érték esetén a deploy.sh csak kihagyja ezt a lépést.
ADMIN_EMAIL=admin@mozdit.hu
ADMIN_PASSWORD=changeme
# Publikus site URL (beég build időben a Dockerfile-ba)
NEXT_PUBLIC_SITE_URL=https://mozdit.hu
+15
View File
@@ -7,6 +7,21 @@ MONGODB_DB=mozdit
MONGO_ROOT_USER=admin
MONGO_ROOT_PASSWORD=changeme
# Payload CMS (MITHOME-86) — generálj egyedi, erős titkot ehhez a
# környezethez (pl. `openssl rand -hex 32`); ne ossz meg értéket másik
# environmenttel.
PAYLOAD_SECRET=changeme
# Payload admin — ismert, biztosan létező admin fiók (MITHOME-97). A
# deploy.sh minden deploy után meghívja a Payload "first-register" REST
# endpointját ezekkel az adatokkal: teljesen friss (üres users collection-ű)
# adatbázisnál létrehozza ezt a fiókot, egyébként (ha már van user)
# ártalmatlanul kihagyja — nem írja felül a jelszót utólag. Generálj egyedi,
# erős jelszót (pl. jelszókezelővel); a hiányzó két érték esetén a
# deploy.sh csak kihagyja ezt a lépést, nem hibázik el.
ADMIN_EMAIL=admin@mozdit.hu
ADMIN_PASSWORD=changeme
# Publikus site URL (beég build időben a Dockerfile-ba)
NEXT_PUBLIC_SITE_URL=https://stage.mozdit.hu
+15 -9
View File
@@ -10,14 +10,14 @@
- **Projekt**: mozdIT Bt. weboldal (websitedev)
- **Plane workspace**: `developments``pm.llmdev.mozdit.hu`
- **Stack**: Next.js 15, React 19, TypeScript, Tailwind CSS 4, MongoDB, Winston
- **Stack**: Next.js 15, React 19, TypeScript, Tailwind CSS 4, Payload CMS (self-hosted, `/admin`), MongoDB, Winston
- **Fő könyvtár**: `proto/`**minden parancsot innen futtatunk**
---
## Gyors referencia — Alapszabályok
1. **Szöveg sosem kerülhet közvetlenül komponensbe**`proto/src/content/pages/*.json`
1. **Szöveg sosem kerülhet közvetlenül komponensbe**Payload CMS Global/Collection mező (admin: `/admin`), a frontend `proto/src/lib/payload-content.ts`-en át olvassa. (`proto/src/content/pages/*.json` már csak a migrációs seed-script forrása + teszt-fixture, élő oldal nem használja — MITHOME-91/93/96.)
2. **Feladatok**: `TODO.md` (Plane tükörképe), szinkron: `node plane-sync.js`
3. **Tesztek**: minden feature-höz kötelező; commit előtt `npm test` zöld
4. **Commit**: Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`)
@@ -47,19 +47,24 @@ node plane-sync.js # Plane szinkronizáció
websitedev/
├── proto/ # Fő Next.js alkalmazás
│ └── src/
│ ├── app/ # App Router: oldalak és API route-ok
│ ├── app/
│ │ ├── (frontend)/ # Publikus oldalak: [locale]/[slug] catch-all
│ │ └── (payload)/ # Payload admin (/admin) + REST/GraphQL API
│ ├── components/ # React komponensek (PascalCase.tsx)
│ ├── content/ # JSON tartalom-kezelő rendszer
├── common.json # Közös szövegek
│ └── pages/ # Oldal-specifikus JSONok
│ ├── lib/ # MongoDB, Logger, Site Config
│ ├── globals/ # Payload Globals (Home, About, Services, Contact, Common)
│ ├── collections/ # Payload Collections (LegalPages, Partners, Media, ContactSubmissions, Users)
├── payload.config.ts # Payload CMS konfiguráció
│ ├── content/ # RÉGI JSON rendszer — csak migrációs seed + teszt-fixture (MITHOME-91/93/96)
│ │ ├── common.json
│ │ └── pages/
│ ├── lib/ # payload-content.ts (Local API adapter), MongoDB health-check, Logger
│ ├── config/ # Statikus site konfiguráció
│ └── types/ # TypeScript típusok
├── docs/ # Projekt dokumentáció (Magyar)
├── .agent/ # AI szabályrendszer ← OLVASD EL
│ ├── AGENTS.md # Elsődleges szabályok
│ ├── steering/ # Auto-betöltődő irányelvek
│ ├── workflows/ # /new-feature, /fix-bug, /cms-feature, /review, /deploy
│ ├── workflows/ # /new-feature, /fix-bug, /review, /deploy
│ └── references/ # Accessibility checklist
├── TODO.md # Feladatlista (Plane szinkron)
└── plane-sync.js # Plane szinkronizáló script
@@ -76,6 +81,8 @@ Szükséges változók (`.env` és `proto/.env.local`):
- `NEXT_PUBLIC_WEBMAIL_URL` — Webmail service URL
- `NEXT_PUBLIC_CONTACT_EMAIL` — Kapcsolati email cím
- `PLANE_API_KEY` — Plane szinkronizációhoz (a `.mcp.json`-ban is lehet)
- `PAYLOAD_SECRET` — Payload CMS JWT/session aláíráshoz (MITHOME-86); környezetenként egyedi, erős érték
- `ADMIN_EMAIL` / `ADMIN_PASSWORD` — staging/production: `deploy.sh` ezekkel hoz létre egy ismert admin usert egy vadonatúj adatbázison (MITHOME-97 follow-up), csak akkor, ha a `users` collection még üres
---
@@ -97,7 +104,6 @@ Szükséges változók (`.env` és `proto/.env.local`):
| `.agent/steering/testing.md` | Tesztelési stratégia, coverage elvárások |
| `.agent/workflows/new-feature.md` | Új funkció fejlesztési lépései |
| `.agent/workflows/fix-bug.md` | Hibajavítás lépései (TDD) |
| `.agent/workflows/cms-feature.md` | CMS fejlesztés + útmutató karbantartás |
| `.agent/workflows/review.md` | Code review checklist |
| `.agent/workflows/deploy.md` | Deployment folyamat |
| `.agent/references/accessibility-checklist.md` | WCAG 2.1 AA ellenőrzőlista |
+3 -1
View File
@@ -37,6 +37,8 @@ A Docker stack a következő szolgáltatásokat indítja:
- Username: `admin`
- Password: `admin123`
**Payload CMS admin** (http://localhost:8080/admin): nincs előre létrehozott felhasználó — az első betöltéskor a Payload felkínálja az admin fiók létrehozását. A `docker-compose.dev.yml` egy fix, nem titkos `PAYLOAD_SECRET`-et ad át (MITHOME-97) — staging/production környezetben ez kötelezően a saját `.env.<env>` fájlból jön, nincs alapértelmezett érték (a konténer el sem indul nélküle).
## 📊 MongoDB Hozzáférés
### 1. Mongo Express Web UI
@@ -56,7 +58,7 @@ mongosh "mongodb://admin:password123@localhost:27017/mozdit"
### 3. Alkalmazásból
Az alkalmazás automatikusan csatlakozik:
```
MONGODB_URI=mongodb://mongodb:27017/mozdit
MONGODB_URI=mongodb://admin:password123@mongodb:27017/mozdit?authSource=admin
```
## 🛠️ Fejlesztési Parancsok
+16 -15
View File
@@ -5,10 +5,10 @@ Modern Next.js weboldal a mozdIT Bt. számára — webtárhely, email- és DNS-s
## Stack
- **Frontend**: Next.js 15 (Turbopack), React 19, TypeScript, Tailwind CSS 4
- **Tartalom**: JSON-alapú, séma-validált content rendszer (`proto/src/content/`)
- **CMS**: saját, dependency-mentes `content-editor.js` (böngészős szerkesztő)
- **CMS**: Payload CMS (self-hosted, `/admin`), MongoDB adapter, draft/publish + verziózás, hu/en lokalizáció
- **Tartalom**: Payload Globals/Collections (a korábbi JSON-alapú content rendszer, `proto/src/content/`, csak a migrációs script forrásaként és teszt-fixture-ként él tovább — MITHOME-91/93)
- **Backend**: Next.js API routes, MongoDB
- **Tesztelés**: Jest, React Testing Library, Playwright (smoke), valódi szervert indító CMS-tesztek
- **Tesztelés**: Jest, React Testing Library, Playwright (smoke)
- **Deploy**: natív Docker Compose (`deploy.sh`) + Gitea Actions nélkül, lokálisan vezérelt
- **Monitoring**: Winston + Loki, plusz `scripts/security-scan.sh` (ntfy riasztással)
@@ -39,32 +39,33 @@ scripts/pre-deploy-tests.sh
./deploy.sh production # éles (szerveren, staging ellenőrzése után)
```
A CMS „Publikálás" gombja szintén commitol + pushol + deployol (csak a beállított környezetre). Részletek: `.agent/workflows/deploy.md`, `docs/helyi-staging-deploy.md`.
A Payload admin (`/admin`) draft/publish + verziózás munkafolyamata (MITHOME-92) nem jár git commit-tal vagy automatikus deployjal — a tartalom közvetlenül a MongoDB-be íródik. Kódváltozás (Globals/Collections séma, frontend) továbbra is a fenti git-alapú deploy folyamaton megy át. Részletek: `.agent/workflows/deploy.md`.
## Tartalomkezelés
A weboldal szövegei és a CMS a `proto/src/content/` JSON-fájljaiból jönnek:
A weboldal szövegei Payload CMS Globals/Collections mezőkben élnek, az ügyfél a `/admin` felületen szerkeszti őket:
```
proto/src/content/
├── schema.js # közös séma-validátor (Next + CMS)
├── types.ts # TypeScript típusok
├── index.ts # tartalom-betöltő
├── common.json # közös szövegek (gombok, lábléc, a11y)
└── pages/ # oldalankénti tartalom (home, about, services, …)
proto/src/globals/ # Home, About, Services, Contact, Common
proto/src/collections/ # LegalPages, Partners, Media, ContactSubmissions, Users
proto/src/payload.config.ts
```
Használat:
Használat Server Component-ből (a Payload Local API-n keresztül):
```typescript
import { content, getPageContent } from '@/content'
const about = content.pages.about
import { getAboutContent } from '@/lib/payload-content'
const about = await getAboutContent(locale) // locale: 'hu' | 'en'
```
A régi, JSON-alapú content rendszer (`proto/src/content/`) már csak a
`migrate-content-to-payload.ts` seed-script forrásaként és néhány
komponens-teszt fixture-jeként él tovább — élő oldal nem olvassa (MITHOME-91/93).
## Dokumentáció
- **Agent-szabályok**: `.agent/AGENTS.md`, `.agent/steering/`, `.agent/workflows/`
- **CMS felhasználói útmutató**: `docs/felhasznaloi-utmutato.md` (a CMS-ben a ❓ Súgó is ezt rendereli)
- **nginx vhost-ok**: `docs/nginx-vhosts.md`
- **Plane szinkron**: `PLANE-SYNC-GUIDE.md`
- **Staging deploy**: `docs/helyi-staging-deploy.md`
- **Gitea runner**: `docs/gitea-runner-telepites.md`
+76
View File
@@ -63,6 +63,7 @@ Next.js 15 alapú weboldal a mozdIT Bt. számára, Docker Compose-szal deployolv
| MITHOME-82 | Biztonsági monitoring: detektáló script + cron | ✅ |
| MITHOME-83 | Partnerek szekció a honlapon (logó + URL, CMS feltöltéssel) | ✅ |
| MITHOME-84 | Logószerkesztő: háttér áttetszővé tétele (fehér eltávolítás) | ✅ |
| MITHOME-98 | Bug: dev docker-compose MongoDB URI nem hitelesített (auth hiányzott) | ✅ |
---
@@ -96,6 +97,54 @@ Next.js 15 alapú weboldal a mozdIT Bt. számára, Docker Compose-szal deployolv
## 📋 Backlog (v1.0.2+)
### EPIC: Payload CMS migráció (MITHOME-85)
Cél: az ügyfél önállóan, admin felületen szerkeszthesse a tartalmat — a JSON content rendszer (`proto/src/content/pages/*.json`) és a hozzá épült egyedi CMS (`content-editor.js`) lecserélése Payload CMS-re (Next.js App Router-be épülő, MongoDB adapterrel).
| Plane | Feladat | Státusz |
|-------|---------|---------|
| MITHOME-85 | **EPIC**: Áttérés Payload CMS-re (admin felület az ügyfélnek) | 📋 |
| MITHOME-86 | Payload CMS alapinstallálás: config, MongoDB adapter, admin route | ✅ |
| MITHOME-87 | Home/About/Services/Contact/Common Globals kialakítása és JSON tartalom migrálása | ✅ |
| MITHOME-88 | LegalPages collection (adatvedelem, hasznalati-feltetelek) migrálása | ✅ |
| MITHOME-89 | Partners + Media collection: logófeltöltés (MVP) | ✅ |
| MITHOME-118 | Logószerkesztő Payload admin komponensként — **lezárva, nem valósítjuk meg** (3rd party előfeldolgozás marad) | ❌ |
| MITHOME-90 | Users collection: ügyfél admin bejelentkezés és access control | ✅ |
| MITHOME-119 | MFA előkészítés a Users collection-höz (kutatás/terv, nem implementáció) | 📋 |
| MITHOME-91 | Frontend átállítása Payload Local API-ra, JSON content rendszer kivezetése | ✅ |
| MITHOME-92 | Draft/preview és verziózás bekapcsolása (Verziók panel utódja) | ✅ |
| MITHOME-93 | Régi egyedi CMS (content-editor.js) leépítése | ✅ |
| MITHOME-94 | Opcionális: ContactSubmissions collection a Mongoose modell helyett | ✅ |
| MITHOME-95 | .agent/ és CLAUDE.md dokumentáció frissítése Payload architektúrára | ✅ |
| MITHOME-96 | Tesztek migrálása: content schema tesztek helyett Payload collection/global tesztek | ✅ |
| MITHOME-97 | Deploy/Docker frissítés: PAYLOAD_SECRET, admin bundle, Dockerfile/deploy.sh | ✅ |
| MITHOME-117 | Vizuális design-védelem: színek/design tokenek változatlansága (MITHOME-91/93 elfogadási kritériuma) | 📋 |
| MITHOME-102 | ContactSubmissions afterChange hook: PLATFM-2-kompatibilis esemény-emisszió (függ: MITHOME-94) | 📋 |
| MITHOME-103 | Esemény-emisszió biztonsága: hitelesítés, rate limit, payload-validáció, elérhetetlenség-riasztás | 📋 |
| MITHOME-105 | MITHOME-31 lezárása: elveszett üzenet bug összekötése a PLATFM-10 triázs pilottal | 📋 |
| MITHOME-120 | Admin gyorskeresés (teljes szöveges kereső a Payload admin tetején) | ✅ |
| MITHOME-121 | Partner logók soha nem töltődtek be a publikus oldalon (Media read access) + logo mező opcionálissá tétele | ✅ |
| MITHOME-122 | Payload admin UI fordítása (menü/csoport/mező címkék nyelvfüggőek legyenek) | 📋 |
### EPIC: Többnyelvűség bevezetése — hu alapértelmezett + en (MITHOME-109)
Cél: a weboldal magyar (alapértelmezett, prefix nélkül, pl. `/rolunk`) és angol (`/en/` alatt, pl. `/en/about`) nyelven is elérhető legyen, Payload beépített mező-szintű lokalizációjára építve. Az angol tartalom első körben AI-draft fordítás, admin felületen jelezve, emberi jóváhagyásig. Retroaktívan érinti a már kész MITHOME-87/88-at (localized mezők utólagos bejelölése).
| Plane | Feladat | Státusz |
|-------|---------|---------|
| MITHOME-109 | **EPIC**: Többnyelvűség bevezetése (hu alapértelmezett + en) | 📋 |
| MITHOME-110 | Payload localization config bekapcsolása | ✅ |
| MITHOME-111 | Globals retrofit: localized mezők (Home/About/Services/Contact/Common) | 📋 |
| MITHOME-112 | LegalPages collection retrofit: localized mezők | 📋 |
| MITHOME-113 | AI-draft angol tartalom + migrációs script bővítése (en locale seed) | 📋 |
| MITHOME-114 | Next.js /hu/ + /en/ szimmetrikus prefix routing (egy menetben MITHOME-91-gyel) | ✅ |
| MITHOME-115 | Nyelvválasztó UI komponens (Header) | 📋 |
| MITHOME-116 | SEO: hreflang tagek és kétnyelvű sitemap | 📋 |
### EPIC: Hermes Agent — kapcsolatfelvételi triázs pilot (MITHOME-99) — **LEZÁRVA, duplikátum**
2026-09-10: kiderült, hogy ugyanez a pilot már meg volt tervezve (jobban kidolgozva) a **PLATFM-10** ("Kis kapacitásigényű, felügyelt AI-funkciók pilot") alatt a `PLATFM` projektben. A motor-választás (Hermes vs. alternatívák) külön ADR-ben dől el: **PLATFM-12**. A MITHOME-99 EPIC és a hozzá tartozó MITHOME-100/101/104/106/107/108 issue-k Cancelled státuszban maradnak, Plane-kommenttel a PLATFM-re mutatva. Ami valódi MITHOME-felelősség (a weboldal saját eseményforrása), az a Payload-epic alá került fentebb (MITHOME-102/103/105).
| Plane | Feladat | Státusz |
|-------|---------|---------|
| MITHOME-15 | Production domain, Nginx reverse proxy és HTTPS | 📋 |
@@ -159,6 +208,33 @@ docker-compose -f docker-compose.dev.yml down
---
## Frissítési Napló
- **2026-09-13**: MITHOME-95 (dokumentáció frissítése) kész — `.agent/steering/architecture.md` és `CLAUDE.md` már a Payload Global/Collection + `/admin` + `payload-content.ts` Local API adaptert írja le a régi JSON rendszer helyett (rendszer-áttekintés, Content sor/szabály, kódpélda, projekt-struktúra fa). Emellett javítva több, ugyanebbe a dokumentációs adósságba tartozó, de a ticketben nem nevesített hely is: `.agent/AGENTS.md` projekt-fája, `new-feature.md`/`review.md` workflow-ok, `development-rules.md` TRADEOFF példakódja, és a `README.md` teljes "Tartalomkezelés" szekciója + egy halott link a MITHOME-93-ban törölt CMS-útmutatóra. Tisztán dokumentáció-változtatás.
- **2026-09-12**: Felhasználói kérésre a főoldal "Partnereink" szekciója flex-wrap helyett CSS grid-re váltott (max 3 oszlop, `grid-cols-1 sm:grid-cols-2 md:grid-cols-3`, `w-fit mx-auto` a középre igazításhoz kevesebb partnernél is) — élőben ellenőrizve 5 teszt-partnerrel (3+2-es elrendezés, középre igazítva).
- **2026-09-12**: MITHOME-96 (tesztek migrálása) kész — `src/payload-config.test.ts` (gyors, DB nélküli config-tesztek, két regresszió-őrrel a MITHOME-121-es hibákra) + `scripts/test-payload-local-api.ts`/`npm run test:payload` (élő MongoDB elleni Local API teszt, sima node script Jest helyett — a Payload csomag ESM-only, Jest alapból nem fordítja). Emellett javítva a régi, Docker-stack-hez kötött `integration.test.ts`/`e2e-docker.test.ts` eddig észrevétlen elavulása (unprefixelt útvonalak, `site_config`/`contact_submissions` nyers Mongo collection-ök a valódi Payload collection-ök helyett). Gate zöld (63 unit teszt), `test:payload` külön lefuttatva élőben (8/8).
- **2026-09-12**: MITHOME-94 (ContactSubmissions collection) kész — a `/api/contact` a nyers, Payload-on kívüli Mongo collection helyett most a Payload Local API-n keresztül egy `ContactSubmissions` collection-be ír (name/email/subject/message/gdprConsent/status), amit az ügyfél az adminban lát. Rate limiting/validáció/spam-szűrés változatlanul a route-on maradt. Nincs egyedi access-blokk — a Payload alapértelmezése (csak bejelentkezett usernek REST-en) pont a kívánt, a route saját írása a Local API-n (`overrideAccess: true`) nem ütközik ezzel. Talált és dokumentált teszt-gotcha: a next/jest SWC transform lecseréli a `@payload-config` alias-t valódi relatív útvonalra, ezért a mockot is a feloldott útvonalra kell tenni (`jest.mock('../../../payload.config', ...)`), nem az alias-ra. Élesben ellenőrizve mindkét helyen (helyi dev valódi űrlap-küldéssel + staging curl-lal).
- **2026-09-12**: MITHOME-97 follow-up #3 — a felhasználó kérdésére ("el lehet jutni a honlapra az admin oldalról?") kiderült, hogy nem: az admin navigációban csak belső linkek voltak. Hozzáadva egy "🌐 Honlap megnyitása" link a keresősáv mellé (`QuickSearch.tsx`), ami `NEXT_PUBLIC_SITE_URL`-re mutat — környezetenként automatikusan helyes cím. Élesben ellenőrizve, staging-re deployolva.
- **2026-09-12**: MITHOME-121 kész — a felhasználó kérésére a Partners `logo` mezője opcionálissá vált (`required: true` törölve; a frontend már eleve kiszűrte a logó nélküli partnereket). Eközben egy valódi, MITHOME-89 óta jelen lévő hiba is előkerült: a `Media` collection sosem kapott explicit `access.read`-et, így a Payload alapértelmezett "csak bejelentkezett user" szabálya miatt a `/api/media/file/*` route mindig 403-at adott — a Next.js image-optimizer emiatt sosem tudta betölteni a partner logókat a publikus oldalon (törött kép ikon, senki nem vette észre). Javítva: `access: { read: () => true }`. Staging-en emellett egy második, kapcsolódó hibát is találtam: a médiafájl fizikai byte-jai elvesztek egy korábbi (a `media_data_staging` volume bevezetése előtti) konténer-újraépítéskor — az árva Media rekord törlésével és a migráció volume-mountolt konténerből való újrafuttatásával helyreállítva. Élesben ellenőrizve mindkét helyen (helyi dev + staging).
- **2026-09-12**: MITHOME-120 (admin gyorskeresés) kész — a felhasználó kérésére egy keresőmező került a Payload admin minden oldalának tetejére (`admin.components.header`), ami az összes Global + Collection összes szöveges mezőjét átkeresi mindkét locale-ban (kliens-oldali, index nélküli megoldás — `@payloadcms/plugin-search` aránytalan lenne a projekt méretéhez, ugyanaz az érvelés, mint MITHOME-118-nál). Útközbeni gotcha: a Payload komponens-útvonalak `process.cwd()`-hez (nem a config fájl mappájához) relatívak — dokumentálva. Élesben ellenőrizve böngészőben (Global + Collection találat is, helyes navigáció, 0 console hiba), a lint egy valódi hibát is elkapott (ref helyett state kellett). Staging-re is kideployolva.
- **2026-09-11**: MITHOME-97 follow-up #2 — a felhasználó véletlen elgépelt egy URL-t (`stage.llmdev.mozdit.hu`), amire a Firefox valódinak tűnő "site could be impersonating" figyelmeztetést adott. Kiderült: egy elárvult `cms.stage.llmdev.mozdit.hu` nginx vhost a régi, leépített CMS-re (content-editor.js, port 4001, MITHOME-93) mutatott — a backend leállítva, de a vhost/tanúsítványa élt, nginx fallback-ként adta ezt bármilyen nem egyező `*.mozdit.hu` aldomainre. Megoldás: a vhost most a Payload admin felé proxyz (`/admin`, `/api/`, `/_next/` → staging app 127.0.0.1:8081), minden más redirect a kanonikus `stage.mozdit.hu`-ra — kényelmi admin-URL, meglévő tanúsítvány újrahasznosítva. Dokumentálva: `docs/nginx-vhosts.md` (eddig egyetlen nginx-konfig sem volt nyomon követve a repóban). Élesben ellenőrizve böngészőben (0 console hiba, bejelentkezés is működik ezen a domain-en).
- **2026-09-11**: MITHOME-97 follow-up — automatikus, ismert Payload admin-felhasználó minden deploy után. `deploy.sh` a healthcheck után meghívja a Payload beépített `POST /api/users/first-register` végpontját `ADMIN_EMAIL`/`ADMIN_PASSWORD` alapján; ez a végpont csak üres `users` collection-nél enged létrehozást (403 minden további hívásra) — idempotens, sosem ír felül meglévő jelszót. Élesben ellenőrizve (friss DB → 200, ismételt hívás → 403, meglévő más-user-es DB → 403), majd staging-en ténylegesen bevezetve: `.env.staging`-hez szerveren generált admin jelszó, két egymást követő `deploy.sh staging` (első létrehozta, második helyesen kihagyta), bejelentkezés-teszt 200.
- **2026-09-11**: MITHOME-92 (draft/preview + verziózás) kész — Payload versions.drafts minden tartalmi Global/Collection-ön, admin UI Save Draft/Publish + Versions tab, ez a régi CMS "Verziók panel" (MITHOME-64) utódja. Valódi hiba javítva: a `_status` mező defaultValue-ja 'draft', a migrációs script eddig sosem adott át explicit `_status`-t → minden migrált dokumentum draft állapotban landolt; javítva `_status: 'published'` explicit átadásával. Élesben ellenőrizve (draft mentés nem látszik a publikus oldalon, publish után igen).
- **2026-09-11**: MITHOME-93 (régi egyedi CMS leépítése) kész — `content-editor.js` + `scripts/cms-*.js` + a hozzá tartozó tesztkészlet eltávolítva. Felhasználói kérésre biztonsági mentés készült a törlés előtt: `proto/scripts/export-content-snapshot.ts` (Payload Local API export) → `docs/backups/payload-content-snapshot-*.json`, plusz ellenőrizve, hogy a régi CMS `.content-backups/` mappájának legutóbbi bejegyzése a mai migráció előtti, és minden JSON-szerkesztés saját git commitként is megvan.
- **2026-09-11**: MITHOME-97 (Deploy/Docker) kész, ÉS staging deploy ténylegesen megtörtént (https://stage.mozdit.hu). Két valódi hiba a Docker build első élő tesztjén: (1) a `(frontend)/[locale]` oldalak SSG-ként (`generateStaticParams`) épültek, build közben hívva a Payload Local API-t — DB nélkül a build elhalt; (2) ennél súlyosabb, hogy SSG mellett egy admin publikálás csak redeploy után látszott volna, ami az önkiszolgáló szerkesztési cél ellen dolgozott. Felhasználói jóváhagyással `force-dynamic`-ra váltva — minden kérés élő Payload-olvasás, build DB-független. `PAYLOAD_SECRET` bekötve staging/prod/dev compose-ba (eddig csak `.env.*.example`-ben létezett, sosem jutott el a konténerig). Named media volume a Payload uploadoknak. Külön javítva: a staging deploy script egy már nem létező `mozdit-content-editor.service`-t indított újra — eltávolítva, a szerveren a valós unit is leállítva/letiltva. Staging: hiányzó `PAYLOAD_SECRET` generálva a szerveren, `deploy.sh staging` lefuttatva, tartalom-migráció lefuttatva a staging DB ellen, élesben ellenőrizve böngészőben (`/hu`, `/en`, `/admin` mind működik).
- **2026-09-10**: MITHOME-91 + MITHOME-114 (Frontend Payload Local API-ra állítás + hu/en locale routing) kész, egy menetben. URL-stratégia véglegesítve: mindkét nyelv szimmetrikus prefixet kap (`/hu/rolunk`, `/en/about`, lefordított szlögekkel), felülírva a korábbi "hu prefix nélkül" döntést. Minden oldal Payload Local API-ról megy, `@/content` kivezetve az app-kódból. Két valódi, régről létező bug javítva (webmail-linkek, GDPR-checkbox linkje). Talált és javított: Next.js beépített 404-fallback ütközött a (payload) route group-pal — saját `not-found.tsx` oldotta meg, éles production-standalone szerverrel is ellenőrizve (0 console hiba). Build/lint/tsc/teszt zöld. **Folyamat-eltérés**: ez a commit közvetlenül a `main`-re ment feature branch nélkül (egy interrupt miatt kimaradt a branch-nyitás) — a végeredmény megegyezik azzal, mintha branch+merge lett volna, mert eddig is minden branch azonnali fast-forwarddal ment be.
- **2026-09-10**: MITHOME-90 (Users access control) kész — explicit maxLoginAttempts/lockTime, cookie security, access control minden op-ra. GHSA-jg8r-5jh2-v2xj advisory megvizsgálva és tudatosan elfogadva az egyetlen "admin" szerepkör modellben. Élesben ellenőrizve valódi lockout-teszttel. Follow-up: MITHOME-119 (MFA előkészítés, felhasználói kérésre, külön taszk) — mert a Payloadnak nincs natív MFA-ja, és ez ugyanabba a konvergencia-pontba esik, mint az Auth/IDM (PLATFM-4/PLATFM-11 IDM v1), ezért csak ADR-szintű döntést csinálunk, nem implementációt. PLATFM-1 kapcsolati térkép frissítve ("Auth/IDM/MFA" sor).
- **2026-09-10**: MITHOME-118 (logószerkesztő Payload-ba építése) lezárva, tudatosan nem valósítjuk meg — determinisztikus képfeldolgozás, nem illik sem a Payload admin-ba (aránytalan karbantartási teher egy ritkán használt funkcióért), sem Hermes/AI-agent munkafolyamatba (az nyelvi/döntési feladatokra való, nem pixel-transzformációra). Döntés: 3rd party előfeldolgozás (pl. remove.bg) marad — ez rögzítve a Partners collection `logo` mezőjének admin leírásában is.
- **2026-09-10**: MITHOME-110 (Payload localization bekapcsolása) kész — hu alapértelmezett, en, fallback:true. `Common.buttons.*` localized:true (valódi, előremutató teszt-mező, része a MITHOME-111 retrofitnak). Fontos tapasztalat dokumentálva: utólagos localized:true után a migrációs scriptet újra kell futtatni, mert a defaultLocale alatti régi érték "eltűnik". Élesben ellenőrizve admin UI nyelvváltóval.
- **2026-09-10**: MITHOME-89 (Partners + Media collection MVP) kész. Payload upload (Media) + Partners collection, migrálva a meglévő partner logóval — élesben ellenőrizve (1+1 dokumentum, idempotens, logó thumbnail rendben). A régi crop/rotate/transparent-logo szerkesztő (kliens-oldali canvas, 345 sor) külön ticketre bontva: MITHOME-118. Fontos: a Payload uploadok `proto/media/`-ba kerülnek (gitignore-olva) — staging/production docker-compose-ban perzisztens volume kell (jelezve MITHOME-97-nél).
- **2026-09-10**: MITHOME-117 létrehozva — explicit elfogadási kritérium, hogy a Payload-átállás (MITHOME-91/93) ne változtasson a publikus oldal jelenlegi színein/design tokenjein (`proto/src/app/(frontend)/globals.css`, márka-színek: Logo Blue #1f4e9d, Logo Orange #e43e26). Before/after screenshot-összehasonlítás lesz az elfogadási feltétel.
- **2026-09-10**: Többnyelvűség EPIC (MITHOME-109) és 7 alfeladat (MITHOME-110116) létrehozva Plane-ben — hu (alapértelmezett, prefix nélkül) + en (/en/ alatt), Payload natív lokalizációra építve, AI-draft angol tartalommal. MITHOME-91-hez (Frontend Local API átállás) hozzáadva a MITHOME-114 (locale routing) függőségi megjegyzés — egy menetben érdemes elvégezni.
- **2026-09-10**: MITHOME-88 (LegalPages collection) kész. Slug-alapú collection (adatvedelem, hasznalati-feltetelek), migrálva `migrate-content-to-payload.ts`-sel (idempotens upsert, kétszeri futtatás után is pontosan 2 dokumentum). Élesben ellenőrizve admin UI-ban.
- **2026-09-10**: PLATFM-1 ("Platform architektúra és komponensfüggőségi nyilvántartás") kiegészítve egy MITHOME↔PLATFM kapcsolati térképpel (6 konvergencia-pont: AI-triázs, Auth/IDM, Content Release, Termékkatalógus, Messaging, BCDR) — ez a Program-szintű koordináció, nem kell hozzá külön Plane-projekt. A MITHOME-40 (production MongoDB mentés) prioritása medium→urgentre emelve, mert a PLATFM-5 (BCDR) explicit RPO≤15p/RTO≤4ó célt ad a kapcsolatfelvételi üzenetekre.
- **2026-09-10**: MITHOME/PLATFM rendrakás. Kiderült, hogy a Hermes Agent EPIC (MITHOME-99) duplikálta a PLATFM-10 AI-pilotot (kapcsolatfelvételi triázs) — MITHOME-99/100/101/104/106/107/108 lezárva (Cancelled), PLATFM-10-re hivatkozva. Új ADR nyitva a motor-választáshoz: PLATFM-12. Ami valódi MITHOME-felelősség maradt (ContactSubmissions eseményforrás, PLATFM-2-kompatibilis sémával), az a Payload-epic alá került: MITHOME-102/103/105.
- **2026-09-10**: Hermes Agent EPIC (MITHOME-99) és 9 alfeladat (MITHOME-100108) létrehozva Plane-ben — pilot: self-hosted AI agent (Nous Research, MCP-támogatás) a kapcsolatfelvételi triázshoz, kapcsolódva a nyitott MITHOME-31 bughoz és a MITHOME-47 (belső operatív munkaasztal) irányhoz.
- **2026-09-10**: MITHOME-98 (dev docker-compose Mongo URI auth) kész. A `docker-compose.dev.yml` `app` service-e hitelesítés nélkül próbált kapcsolódni egy authot igénylő MongoDB-hez — élesben reprodukálva (findOne → "Command find requires authentication"), majd javítva és ellenőrizve (POST /api/contact valódi Mongo-írása). Ugyanaz a hibaosztály, mint MITHOME-32, de külön, dev-specifikus ticket, mert MITHOME-32 csak staging/production compose-t fed le. Branch: `fix/dev-mongo-uri-auth`.
- **2026-09-10**: MITHOME-87 (Home/About/Services/Contact/Common Globals + JSON migráció) kész. Munka közben két, MITHOME-86-hoz visszanyúló hibát javítottunk: (1) a gyökér src/app/layout.tsx ütközött a Payload (payload)/layout.tsx-ével — a webodal saját route-jai átkerültek egy (frontend) route groupba; (2) a kézzel írt importMap.js stub nem volt elég — tsx 4.22.4→4.23.13 (Node 25 kompatibilitás) után legenerálva a valódit. Mindkettő valós böngészőben ellenőrizve (curl nem buktatta fel korábban). `npm run migrate:content` script + `npm test`/lint/build zöld.
- **2026-09-10**: MITHOME-86 (Payload alapinstalláció) kész: Next.js 15.5→16.3.4 upgrade (Payload peer dep miatt), payload.config.ts + mongooseAdapter + minimális Users collection + admin/API/GraphQL route-ok. Élesben ellenőrizve valódi MongoDB-vel. Ismert nyitott advisory a payload@3.88.0-ban (GHSA-jg8r-5jh2-v2xj), nyomon követve MITHOME-90 alatt. Branch: `feature/payload-cms-setup`.
- **2026-09-10**: Payload CMS migrációs EPIC (MITHOME-85) és 12 alfeladat (MITHOME-8697) létrehozva Plane-ben — cél: JSON content + egyedi CMS lecserélése Payload-ra, ügyfél önálló admin szerkesztéshez.
- **2026-08-17**: Plane sync (`plane-sync.js`). Új MITHOME-27 (Production környezet) Backlog-ba; Custom CMS és Webmail bevéve a Plane-be (28/29, Done). `regenerate()` hiba javítva (szekció-elválasztó).
- **2026-08-17**: Plane sync futtatása (`plane-sync.js`). MITHOME-15, MITHOME-18 visszaminősítve Backlog-ba (Plane szerint), MITHOME-27/28 megtartva.
- **2026-04-26**: Átállás Linear → Plane (MITHOME projekt). TODO.md teljes újraírva, 26 issue szinkronizálva.
-362
View File
@@ -1,362 +0,0 @@
#!/usr/bin/env node
/**
* mozdIT Content Editor Server v2
* Szerkesztő felület a JSON tartalom fájlokhoz
* Támogatja: szöveg szerkesztés, tömbelem hozzáadás/törlés
* Futtatás: node content-editor.js
* Megnyitás: http://localhost:4001
*/
const http = require('http');
const fs = require('fs');
const path = require('path');
const { exec, execSync } = require('child_process');
const crypto = require('crypto');
const { validateContent } = require('./proto/src/content/schema');
const { renderMarkdown } = require('./scripts/markdown-render');
const { buildPublishCommand, interpretPublishResult } = require('./scripts/cms-publish');
const { handleVersionRoutes, listVersions } = require('./scripts/cms-versions');
const { handleSaveRoute } = require('./scripts/cms-save');
const { LOGO_TARGETS, handleLogoRoutes } = require('./scripts/cms-logo');
const PORT = Number(process.env.CONTENT_EDITOR_PORT) || 4001;
// WHY: overridable so the publish integration test can run against a throwaway
// git clone instead of the real repository.
const CONTENT_DIR = process.env.CONTENT_EDITOR_CONTENT_DIR || path.join(__dirname, 'proto', 'src', 'content');
const BACKUP_DIR = path.join(__dirname, '.content-backups');
const MAX_REQUEST_BODY_BYTES = 256 * 1024;
const AUDIT_LOG_FILE = process.env.CONTENT_EDITOR_AUDIT_FILE || path.join(__dirname, '.content-editor-audit.jsonl');
const GUIDE_FILE = process.env.CONTENT_EDITOR_GUIDE_FILE || path.join(__dirname, 'docs', 'felhasznaloi-utmutato.md');
const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000;
const AUTH_MAX_ATTEMPTS = 5;
const PUBLISH_MAX_ATTEMPTS = 3;
let isPublishing = false;
const FILES = {
common: path.join(CONTENT_DIR, 'common.json'),
home: path.join(CONTENT_DIR, 'pages', 'home.json'),
about: path.join(CONTENT_DIR, 'pages', 'about.json'),
services: path.join(CONTENT_DIR, 'pages', 'services.json'),
contact: path.join(CONTENT_DIR, 'pages', 'contact.json'),
adatvedelem: path.join(CONTENT_DIR, 'pages', 'adatvedelem.json'),
hasznalatiFeltetelek: path.join(CONTENT_DIR, 'pages', 'hasznalati-feltetelek.json'),
};
const FILE_LABELS = {
common: '⚙️ Közös szövegek',
home: '🏠 Kezdőlap',
about: '👥 Rólunk',
services: '🛠️ Szolgáltatások',
contact: '📬 Kapcsolat',
adatvedelem: '🔒 Adatvédelem',
hasznalatiFeltetelek: '⚖️ ÁSZF',
};
const { HTML, GUIDE_PAGE, LOGIN_PAGE, VERSIONS_PAGE } = require('./scripts/cms-pages');
const { LOGO_PAGE } = require('./scripts/cms-logo-page');
const { validateLogin, createSessionCookie, clearSessionCookie, hasValidSession, deleteSession } = require('./scripts/cms-session');
// Browser script is kept in its own file and inlined into the HTML template at render time.
const clientJs = fs.readFileSync(path.join(__dirname, 'scripts', 'cms-editor-client.js'), 'utf8')
+ '\n' + fs.readFileSync(path.join(__dirname, 'scripts', 'cms-editor-shortcuts.js'), 'utf8');
// ── Server ───────────────────────────────────────────────────────────────────
// Security/infra helpers live in scripts/cms-core.js (file-size limits).
const core = require('./scripts/cms-core');
const { CMS_USER, CMS_PASS, CMS_DEPLOY_ENV, CSRF_TOKEN, securityConfigIsValid, getClientAddress, hasValidCsrfToken, backupAndWriteAtomically } = core;
const exceedsRateLimit = (key, limit) => core.exceedsRateLimit(key, limit, RATE_LIMIT_WINDOW_MS);
const isRateLimited = (key, limit) => core.isRateLimited(key, limit, RATE_LIMIT_WINDOW_MS);
const recordRateLimitAttempt = key => core.recordRateLimitAttempt(key, RATE_LIMIT_WINDOW_MS);
const hasValidCredentials = req => core.hasValidCredentials(req, validateLogin);
const isAuthenticated = core.makeIsAuthenticated(hasValidSession, validateLogin);
const isBrowserNavigation = core.isBrowserNavigation;
const writeAudit = core.makeWriteAudit(AUDIT_LOG_FILE);
// Deploy version = git short SHA of the checked-out commit. Read once at startup:
// a CMS "deploy" is git pull + service restart, so this identifies the running code.
function readDeployVersion() {
try {
return execSync('git rev-parse --short HEAD', { cwd: __dirname, encoding: 'utf8' }).trim();
} catch {
return 'unknown';
}
}
const DEPLOY_VERSION = readDeployVersion();
const server = http.createServer(async (req, res) => {
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-Content-Type-Options', 'nosniff');
const clientAddress = getClientAddress(req);
if (!securityConfigIsValid()) {
res.writeHead(503, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Content Editor is disabled: CMS_USER and CMS_PASS must be configured.');
return;
}
const u = new URL(req.url, `http://localhost:${PORT}`);
// WHY: Basic Auth credentials are cached by the browser until it closes, so there is
// no native logout. The client calls /logout with deliberately invalid credentials,
// which overwrites the cached pair; the next navigation prompts for login again.
// Deliberately exempt from the auth rate limiter so logging out never locks the user out.
if (u.pathname === '/logout' && req.method === 'GET') {
// Legacy cache-buster endpoint; no WWW-Authenticate — Safari would show its
// native auth dialog on any fetch hitting this challenge.
res.writeHead(401, { 'Cache-Control': 'no-store' });
res.end('Logged out');
return;
}
// Public: logo asset for the login page.
if (req.method === 'GET' && u.pathname === '/logo.png') {
try {
// ?variant=header serves the website header logo (branding page preview).
const file = u.searchParams.get('variant') === 'header' ? LOGO_TARGETS.header : LOGO_TARGETS.icon;
const logo = fs.readFileSync(path.join(__dirname, 'proto', 'public', file));
res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600' });
res.end(logo);
} catch {
res.writeHead(404); res.end('Not found');
}
return;
}
// Public: deploy version (git SHA only — no secrets) for quick "is the fix live?" checks.
if (req.method === 'GET' && u.pathname === '/version') {
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify({ version: DEPLOY_VERSION, env: CMS_DEPLOY_ENV }));
return;
}
// Public: styled login page (shown after logout and for unauthenticated browser visits).
if (req.method === 'GET' && u.pathname === '/login') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(LOGIN_PAGE());
return;
}
// Public: login form endpoint. Shares the auth rate-limit budget with failed
// Basic attempts so the form cannot be brute-forced either.
if (req.method === 'POST' && u.pathname === '/login') {
let body = '';
let bodyTooLarge = false;
req.on('data', c => {
if (body.length + c.length > 1024) { bodyTooLarge = true; return; }
body += c;
});
req.on('end', () => {
if (isRateLimited(`auth:${clientAddress}`, AUTH_MAX_ATTEMPTS)) {
writeAudit('login_failed', { clientAddress, result: 'rate_limited' });
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': String(RATE_LIMIT_WINDOW_MS / 1000) });
res.end(JSON.stringify({ ok: false, error: 'Túl sok belépési kísérlet — próbáld újra később.' }));
return;
}
let user = '';
let pass = '';
try {
const parsed = JSON.parse(body);
user = String(parsed.user || '');
pass = String(parsed.pass || '');
} catch { /* empty credentials fail validation below */ }
if (!bodyTooLarge && validateLogin(user, pass, CMS_USER, CMS_PASS)) {
// WHY: successful logins must not consume the failure budget — tests and
// multi-tab users log in repeatedly and would lock themselves out.
const isSecure = req.headers['x-forwarded-proto'] === 'https';
writeAudit('login_success', { clientAddress });
res.writeHead(200, { 'Content-Type': 'application/json', 'Set-Cookie': createSessionCookie(isSecure) });
res.end(JSON.stringify({ ok: true }));
return;
}
recordRateLimitAttempt(`auth:${clientAddress}`);
writeAudit('login_failed', { clientAddress, result: bodyTooLarge ? 'request_too_large' : 'invalid_credentials' });
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'Hibás felhasználónév vagy jelszó.' }));
});
return;
}
if (isRateLimited(`auth:${clientAddress}`, AUTH_MAX_ATTEMPTS)) {
writeAudit('authentication_failed', { clientAddress, limited: true });
res.writeHead(429, { 'Retry-After': String(RATE_LIMIT_WINDOW_MS / 1000) });
res.end('Too many authentication attempts');
return;
}
if (!isAuthenticated(req)) {
recordRateLimitAttempt(`auth:${clientAddress}`);
writeAudit('authentication_failed', { clientAddress, limited: false });
// Browser navigations land on the styled login page; API/curl gets a plain 401.
// WHY no WWW-Authenticate: Safari pops its native auth dialog on fetch() calls
// that receive a Basic challenge — the styled /login page handles browsers.
if (isBrowserNavigation(req)) {
res.writeHead(302, { Location: '/login', 'Cache-Control': 'no-store' });
res.end();
return;
}
res.writeHead(401, { 'Cache-Control': 'no-store' });
res.end('Access denied');
return;
}
const fileKey = u.searchParams.get('file') || 'home';
const activeFile = FILES[fileKey] ? fileKey : 'home';
if (req.method === 'POST' && !hasValidCsrfToken(req)) {
writeAudit('csrf_rejected', { clientAddress, path: u.pathname, file: activeFile });
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'Érvénytelen vagy hiányzó CSRF token' }));
return;
}
// POST /logout — invalidate the browser session (Basic Auth stays valid by design).
if (req.method === 'POST' && u.pathname === '/logout') {
deleteSession(req);
writeAudit('logout', { clientAddress, user: CMS_USER });
res.writeHead(200, { 'Content-Type': 'application/json', 'Set-Cookie': clearSessionCookie() });
res.end(JSON.stringify({ ok: true }));
return;
}
// GET /guide — user guide rendered from the maintained markdown in the repo.
if (req.method === 'GET' && u.pathname === '/guide') {
let contentHtml;
try {
contentHtml = renderMarkdown(fs.readFileSync(GUIDE_FILE, 'utf8'));
} catch (error) {
contentHtml = '<p>Az útmutató jelenleg nem elérhető. Kérlek, szólj a fejlesztőnek.</p>';
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(GUIDE_PAGE(contentHtml));
return;
}
// POST /save — handled in scripts/cms-save.js (optimistic lock + validation + backup).
if (handleSaveRoute({
req, res, u, activeFile, files: FILES, maxBodyBytes: MAX_REQUEST_BODY_BYTES,
validate: validateContent, writeAudit, backupAndWrite: backupAndWriteAtomically,
backupDir: BACKUP_DIR, user: CMS_USER, clientAddress, cmsDirname: __dirname,
})) return;
// POST /publish — Git Commit, Pull Rebase & Push
if (req.method === 'POST' && u.pathname === '/publish') {
if (isPublishing) {
res.writeHead(423, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'Már folyamatban van egy publikálás. Kérlek, várj.' }));
return;
}
isPublishing = true;
if (exceedsRateLimit(`publish:${clientAddress}`, PUBLISH_MAX_ATTEMPTS)) {
isPublishing = false;
writeAudit('publish_rate_limited', { clientAddress, user: CMS_USER });
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': String(RATE_LIMIT_WINDOW_MS / 1000) });
res.end(JSON.stringify({ ok: false, error: 'Túl sok publikálási kísérlet' }));
return;
}
// Command shape and result classification live in scripts/cms-publish.js
// (WHY comments there): commit only when staged changes exist, rebase with
// abort-on-failure, deterministic no-changes marker instead of output matching.
exec(buildPublishCommand('content: frissítve a CMS-ből'), { cwd: CONTENT_DIR }, (error, stdout, stderr) => {
isPublishing = false;
res.writeHead(200, { 'Content-Type': 'application/json' });
const outcome = interpretPublishResult(error, stdout, stderr);
writeAudit('publish_finished', { clientAddress, user: CMS_USER, result: outcome.result });
if (!outcome.ok) {
res.end(JSON.stringify({ ok: false, error: outcome.error }));
return;
}
// Deploy only when content actually changed — a no-op publish must not
// trigger a rebuild. Deploy only the explicitly configured environment;
// never default to production. Overridable for tests.
if (outcome.hadChanges) {
// WHY direct child instead of a detached `cmd &`: under the systemd unit's
// hardening (NoNewPrivileges/PrivateTmp) the backgrounded grandchild died
// silently (observed twice: stale site after a publish). A direct child is
// not detached, runs to completion, and the callback turns the audit entry
// into a real "deploy finished/failed" signal. The HTTP response is already
// sent; deploy output goes to deploy.log so the pipes stay quiet.
const deployCmd = process.env.CONTENT_EDITOR_DEPLOY_CMD
|| `cd ../../../ && ./deploy.sh ${CMS_DEPLOY_ENV} > deploy.log 2>&1`;
writeAudit('deploy_spawned', { clientAddress, user: CMS_USER, env: CMS_DEPLOY_ENV });
// WHY cwd: without it the child starts in the process working directory
// (repo root), where `cd ../../../` lands on "/" — no write access, so
// deploy.log creation failed with Permission denied and the deploy never
// ran. CONTENT_DIR is the same base the git publish command uses.
exec(deployCmd, { cwd: CONTENT_DIR, maxBuffer: 8 * 1024 * 1024 }, deployError => {
writeAudit('deploy_exec_exit', {
clientAddress,
user: CMS_USER,
result: deployError ? 'error' : 'ok',
error: deployError ? String(deployError.message).slice(0, 300) : undefined,
});
});
}
res.end(JSON.stringify({ ok: true, output: outcome.output }));
});
return;
}
// GET /versions + POST /restore — handled in scripts/cms-versions.js.
if (handleVersionRoutes({
req, res, u, activeFile,
backupDir: BACKUP_DIR,
currentFile: FILES[activeFile],
validate: validateContent,
writeAudit, clientAddress, user: CMS_USER,
versionsPage: (fileKey, diff) => VERSIONS_PAGE(fileKey, FILE_LABELS[fileKey] || fileKey, listVersions(BACKUP_DIR, fileKey), diff, CSRF_TOKEN),
})) return;
// GET /branding + POST /logo — handled in scripts/cms-logo.js.
if (handleLogoRoutes({
req, res, u,
publicDir: path.join(__dirname, 'proto', 'public'),
backupDir: BACKUP_DIR,
writeAudit, clientAddress, user: CMS_USER,
logoPage: () => LOGO_PAGE(CSRF_TOKEN),
})) return;
// GET / — editor UI
let message = null;
let jsonData = '{}';
try {
jsonData = fs.readFileSync(FILES[activeFile], 'utf8').trim();
} catch (e) {
message = { type: 'err', text: 'Fájl olvasási hiba: ' + e.message };
}
// WHY: fingerprint of the file content at page load. The editor sends it back
// on save (X-Content-Hash); a mismatch means the file changed since this tab
// was opened (deploy, another tab, git) and a blind save would silently
// overwrite those changes.
const contentHash = crypto.createHash('sha256').update(jsonData).digest('hex');
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(HTML(activeFile, jsonData, message, CSRF_TOKEN, FILE_LABELS, clientJs, contentHash, DEPLOY_VERSION));
});
if (require.main === module) {
if (!securityConfigIsValid()) {
throw new Error('CMS_USER, CMS_PASS és érvényes CMS_DEPLOY_ENV nélkül a Content Editor nem indítható el.');
}
server.listen(PORT, '127.0.0.1', () => {
writeAudit('startup', { version: DEPLOY_VERSION, env: CMS_DEPLOY_ENV });
console.log(`\n✅ mozdIT Content Editor fut: http://localhost:${PORT} (v${DEPLOY_VERSION})\n`);
console.log(' Szerkeszthető fájlok:');
Object.entries(FILE_LABELS).forEach(([k, l]) => {
const rel = k === 'common' ? 'common.json' : `pages/${k}.json`;
console.log(`${l}: proto/src/content/${rel}`);
});
console.log('\n Ctrl+C a leállításhoz\n');
});
}
module.exports = {
backupAndWriteAtomically,
validateContent,
hasValidCredentials,
hasValidCsrfToken,
getClientAddress,
securityConfigIsValid,
csrfToken: CSRF_TOKEN,
};
+57 -7
View File
@@ -42,8 +42,12 @@ fi
echo "🔑 Környezeti változók betöltése ($ENV_FILE)..."
# WHY: a --env-file kapcsoló csak a compose változó-helyettesítését táplálja;
# a shell nem látja belőle az APP_PORT-ot, ezért a healthcheckhez expliciten kiolvassuk.
APP_PORT_VALUE="$(grep -E '^APP_PORT=' "$ENV_FILE" | tail -n 1 | cut -d= -f2- | tr -d '[:space:]' | tr -d '"' | tr -d "'")"
# a shell nem látja belőle ezeket az értékeket, ezért a healthcheckhez és az
# admin-seedeléshez expliciten kiolvassuk.
env_value() {
grep -E "^$1=" "$ENV_FILE" | tail -n 1 | cut -d= -f2- | tr -d '[:space:]' | tr -d '"' | tr -d "'"
}
APP_PORT_VALUE="$(env_value APP_PORT)"
# 3. Docker konténerek újraépítése és indítása
echo "🐳 Build és indítás..."
@@ -53,15 +57,61 @@ docker compose --env-file "$ENV_FILE" -f "$COMPOSE_FILE" up --build --force-recr
# 4. Healthcheck
echo "⏳ Healthcheck (max 60s)..."
HEALTH_URL="http://localhost:${APP_PORT_VALUE:-$DEFAULT_PORT}/api/health"
HEALTHY=0
for i in $(seq 1 30); do
if curl -sf "$HEALTH_URL" > /dev/null 2>&1; then
echo "✅ Healthcheck OK: $HEALTH_URL"
echo "✅ Deploy sikeres: [$ENV]"
exit 0
HEALTHY=1
break
fi
sleep 2
done
echo "❌ Healthcheck sikertelen: $HEALTH_URL"
docker compose -f "$COMPOSE_FILE" logs app --tail 50
exit 1
if [ "$HEALTHY" -ne 1 ]; then
echo "❌ Healthcheck sikertelen: $HEALTH_URL"
docker compose -f "$COMPOSE_FILE" logs app --tail 50
exit 1
fi
# 5. Payload admin felhasználó biztosítása (MITHOME-97 follow-up)
#
# WHY: minden friss adatbázisú deploy (első staging/prod indítás, vagy egy
# volume-törlés utáni újrakezdés) a Payload "Create first user" képernyőjét
# mutatná — valaki ott manuálisan hozná létre a saját fiókját, ami könnyen
# elmarad vagy elfelejtődik, és ismeretlen/inkonzisztens admin-hozzáférést
# eredményez környezetenként. Payload REST API-ja egy beépített
# `POST /<usersCollection>/first-register` endpointot ad erre — DE csakis
# akkor enged bármit létrehozni, ha a `users` collection még teljesen üres
# (0 dokumentum); ha már van akár egy felhasználó is, 403 Forbidden-t ad.
# Ez pont idempotenssé teszi: minden deploy után lefuttatjuk, első alkalommal
# létrehozza az ismert admin fiókot ADMIN_EMAIL/ADMIN_PASSWORD alapján,
# utána minden további deploy-on ártalmatlanul 403-at kap és kihagyja.
ADMIN_EMAIL_VALUE="$(env_value ADMIN_EMAIL)"
ADMIN_PASSWORD_VALUE="$(env_value ADMIN_PASSWORD)"
if [ -z "$ADMIN_EMAIL_VALUE" ] || [ -z "$ADMIN_PASSWORD_VALUE" ]; then
echo "⚠️ ADMIN_EMAIL/ADMIN_PASSWORD nincs beállítva $ENV_FILE-ban — admin-seedelés kihagyva."
echo " (Egy teljesen friss adatbázisnál manuálisan kell létrehozni az első usert /admin alatt.)"
else
echo "👤 Admin felhasználó biztosítása..."
ADMIN_HTTP_CODE="$(curl -sS -o /tmp/mozdit-admin-seed-response.json -w '%{http_code}' \
-X POST "http://localhost:${APP_PORT_VALUE:-$DEFAULT_PORT}/api/users/first-register" \
-H 'Content-Type: application/json' \
--data-binary @- <<EOF
{"email":"${ADMIN_EMAIL_VALUE}","password":"${ADMIN_PASSWORD_VALUE}"}
EOF
)"
rm -f /tmp/mozdit-admin-seed-response.json
case "$ADMIN_HTTP_CODE" in
200)
echo "✅ Admin felhasználó létrehozva ($ADMIN_EMAIL_VALUE)."
;;
403)
echo "✅ Admin felhasználó már létezik — nincs teendő."
;;
*)
echo "⚠️ Admin-seedelés váratlan válasza: HTTP $ADMIN_HTTP_CODE (a deploy egyébként sikeres, ez nem állítja meg)."
;;
esac
fi
echo "✅ Deploy sikeres: [$ENV]"
+8 -3
View File
@@ -1,5 +1,3 @@
version: '3.8'
services:
# Next.js Application
app:
@@ -12,8 +10,15 @@ services:
- "127.0.0.1:8080:3000"
environment:
- NODE_ENV=development
- MONGODB_URI=mongodb://mongodb:27017/mozdit
# WHY authSource=admin: the mongodb service below only creates a root
# user (via MONGO_INITDB_ROOT_USERNAME/PASSWORD), which forces --auth
# on the mongod process — an unauthenticated URI fails every query
# with "Command find requires authentication" (MITHOME-98).
- MONGODB_URI=mongodb://admin:password123@mongodb:27017/mozdit?authSource=admin
- MONGODB_DB=mozdit
# Dev-only, nem titkos (MITHOME-97) — Payload ez nélkül el sem indul
# ("missing secret key"), és ez a compose fájl eddig nem adta át.
- PAYLOAD_SECRET=dev-only-insecure-secret-do-not-use-in-staging-or-prod
- NEXT_PUBLIC_SITE_URL=http://localhost:8080
- NEXT_PUBLIC_COMPANY_NAME=mozdIT Bt.
- NEXT_PUBLIC_CONTACT_EMAIL=info@mozdit.hu
+9
View File
@@ -17,10 +17,17 @@ services:
# unauthenticated default URI would silently break the app — fail loudly instead.
- MONGODB_URI=${MONGODB_URI}
- MONGODB_DB=${MONGODB_DB:-mozdit}
# No fallback either (MITHOME-97): Payload refuses to start without a real
# secret ("missing secret key"), which is exactly what we want here.
- PAYLOAD_SECRET=${PAYLOAD_SECRET}
- NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-https://mozdit.hu}
- NEXT_PUBLIC_COMPANY_NAME=${NEXT_PUBLIC_COMPANY_NAME:-mozdIT Bt.}
- NEXT_PUBLIC_CONTACT_EMAIL=${NEXT_PUBLIC_CONTACT_EMAIL:-info@mozdit.hu}
- LOKI_HOST=${LOKI_HOST:-http://loki:3100}
volumes:
# Payload helyi upload storage — lásd docker-compose.staging.yml azonos
# kommentjét (MITHOME-97).
- media_data_prod:/app/media
depends_on:
- mongodb
networks:
@@ -52,6 +59,8 @@ services:
volumes:
mongodb_data_prod:
driver: local
media_data_prod:
driver: local
networks:
mozdit-network:
+11
View File
@@ -18,11 +18,20 @@ services:
# unauthenticated default URI would silently break the app — fail loudly instead.
- MONGODB_URI=${MONGODB_URI}
- MONGODB_DB=${MONGODB_DB:-mozdit}
# No fallback either (MITHOME-97): Payload refuses to start without a real
# secret ("missing secret key"), which is exactly what we want here.
- PAYLOAD_SECRET=${PAYLOAD_SECRET}
- NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-https://stage.mozdit.hu}
- NEXT_PUBLIC_DEPLOY_ENV=staging
- NEXT_PUBLIC_COMPANY_NAME=${NEXT_PUBLIC_COMPANY_NAME:-mozdIT Bt.}
- NEXT_PUBLIC_CONTACT_EMAIL=${NEXT_PUBLIC_CONTACT_EMAIL:-info@mozdit.hu}
- LOKI_HOST=${LOKI_HOST:-http://loki:3100}
volumes:
# Payload helyi upload storage (proto/src/collections/Media.ts) — futásidőben
# a konténer WORKDIR-je (/app) alatti media/ mappába ír. Named volume nélkül
# a `deploy.sh` minden újratelepítéskor (--force-recreate) elveszítené a
# korábban feltöltött logókat/képeket (MITHOME-97).
- media_data_staging:/app/media
depends_on:
- mongodb
networks:
@@ -54,6 +63,8 @@ services:
volumes:
mongodb_data_staging:
driver: local
media_data_staging:
driver: local
networks:
mozdit-network-staging:
File diff suppressed because it is too large Load Diff
-122
View File
@@ -1,122 +0,0 @@
# mozdIT — Felhasználói útmutató
Ez az útmutató a mozdIT weboldalt és a hozzá tartozó **Content Editor** (CMS) felületet írja le nem műszaki felhasználóknak.
A dokumentum a repó része, és **folyamatosan karbantartott**: minden funkcióváltozásnál a fejlesztő frissíti. A CMS ❓ Súgó menüpontja ezt a fájlt jeleníti meg.
---
## 1. A weboldal
### Hol érhető el?
- **Staging (teszt) oldal**: [https://stage.mozdit.hu](https://stage.mozdit.hu) — itt ellenőrizhetők a friss változtatások éles környezetben, még a véglegesítés előtt.
- A staging oldal tetején **sárga figyelmeztető sáv** jelzi, hogy tesztkörnyezetet látsz.
### Oldalak
- **Kezdőlap** — `https://stage.mozdit.hu/`
- **Rólunk** — `/rolunk`
- **Szolgáltatások** — `/szolgaltatasok`
- **Kapcsolat** — `/kapcsolat` (űrlap, ami beérkező üzenetként tárolódik)
- **Adatvédelmi tájékoztató** — `/adatvedelem`
- **Felhasználási feltételek** — `/felhasznalasi-feltetelek`
### Hogyan változik a weboldal tartalma?
1. A szerkesztő a **Content Editorban** módosítja a szövegeket (2. fejezet).
2. **💾 Mentés** — a módosítás elmentődik, azonnali biztonsági mentéssel.
3. **🚀 Publikálás** — a változtatás bekerül a Git repóba, és automatikusan deployol a staging oldalra.
4. Az éles (production) weboldalra a tartalom csak ellenőrzött, szándékos deploy lépéssel kerül fel — a CMS-ből soha nem publisholódik automatikusan productionre.
---
## 2. Content Editor (CMS)
### Belépés és kilépés
- A CMS a kiadott címen érhető el (staging: `https://cms.stage.llmdev.mozdit.hu`).
- **Bejelentkezés**: a logós bejelentkező oldalon add meg a **felhasználónevet és jelszót** (ezt az adminisztrátor adja), majd kattints a Belépés gombra.
- Több **sikertelen próbálkozás** (5) után a belépés kb. 15 percre zárolásra kerül.
- A belépés **8 óráig érvényes** — ezután a CMS visszairányít a bejelentkező oldalra, ahol újra meg kell adni a jelszót.
- **🚪 Kilépés**: az alsó sáv gombja — egy megerősítő kérdés („Biztosan ki szeretnél lépni?") után kijelentkezel, és megjelenik a bejelentkező oldal.
### Felület áttekintés
- **Fájl fülek** (felül): oldalankénti tartalom — Kezdőlap, Rólunk, Szolgáltatások, Kapcsolat, jogi oldalak, közös szövegek.
- **Szerkesztőfelület**: a kiválasztott oldal összes szerkeszthető mezője.
- **Alsó sáv**: 💾 Mentés, 🚀 Publikálás, 🔗 Előnézet, ❓ Súgó, 🚪 Kilépés, valamint a **futó verzió** (pl. `va7b1a2c`) — ha a fejlesztő megkér, hogy ellenőrizd a verziót, ezt a jelölést mondd neki.
### Szöveg szerkesztése
- A mezők fölötti **útvonal** (pl. `hero.title`) jelzi, hol jelenik meg a szöveg az oldalon.
- Mezőtípusok:
- **Egysoros / több soros szövegmező** — általános szöveg; a hosszabb szöveg automatikusan nagyobb mezőben szerkeszthető.
- **Jelölőnégyzet** — be/ki (igen/nem) érték.
- **Számmező** — numerikus érték.
- A módosítás **nem kerül azonnal az oldalra** — ahhoz Mentés, majd Publikálás kell.
### Listák szerkesztése
- Lista elem (pl. egy jelszó, egy szolgáltatás tulajdonság): **❌ gombbal törölhető**.
- ** Új elem hozzáadása** gomb: új elem beszúrása a lista végére (üres, a meglévőkhöz hasonló űrlappal).
- Kártyás listáknál (pl. szolgáltatások) minden kártya külön törölhető a kártya alján lévő gombbal.
### 🎨 Logó kezelése
- Az alsó sáv **🎨 Logó** gombja megnyitja a logókezelő oldalt.
- Két logó cserélhető: a **weboldal fejléclogója** (szöveges) és a **CMS bejelentkező oldal ikonja**.
- **Partner logó feltöltése**: ugyanitt — a fájlnév megadása után a feltöltött PNG a `/partners/…` elérési útra kerül; ezt az utat másold a partner **logo** mezőjébe.
- Csak **PNG**, max. **1 MB**; ajánlott átlátszó háttér a sötét fejléchez.
- A régi logó mentésre kerül — a csere biztonságos és visszavonható (a mentések a `.content-backups` mappában).
- A **CMS azonnal** az új logót mutatja; a **weboldalon a Publikálás (deploy) után** jelenik meg.
### 🤝 Partnerek
- A kezdőlap **„Partnereink"** szekciója a Szolgáltatások alatt jelenik meg (logó + név + hivatkozás).
- A **Kezdőlap** fülön a `partners.items` listában szerkeszthetők: `name` (név), `url` (hivatkozás), `logo` (elérési út, pl. `/partners/acme.png`).
- Új logó: a **🎨 Logó** oldalon töltsd fel, a visszaadott utat illeszd a `logo` mezőbe.
### ⌨️ Gyorsbillentyűk
- **Ctrl/Cmd + S** — Mentés
- **Ctrl/Cmd + P** — Publikálás
- **Ctrl/Cmd + Shift + V** — Verziók panel megnyitása új fülön
- **?** — gyorsbillentyű-súgó megjelenítése (Esc vagy kattintás zárja)
A gyorsbillentyűk csak a szerkesztő főoldalán működnek; beviteli mezőben gépelve a normál karakterként viselkednek.
### 🕘 Verziók — korábbi állapotok
- Az alsó sáv **🕘 Verziók** gombja megnyitja az éppen szerkesztett fájl mentéseit (minden Mentés automatikus másolatot készít).
- **⚖ Összehasonlítás**: megmutatja, mi változott az adott mentéshez képest (piros = a mentésben lévő régi szöveg, zöld = a jelenlegi).
- **↩ Visszaállítás**: egy kattintással visszaállítja a mentést. A visszaállítás **előtt a jelenlegi tartalom is mentésre kerül**, tehát a visszaállítás is visszavonható.
- A visszaállítás sémaillesztésen megy át — hibás mentést nem lehet visszaállítani.
- Visszaállítás után a nyitott szerkesztő fülek frissítést kérnek (a tartalom megváltozott).
### 💾 Mentés
- A Mentés **ellenőrzi a tartalmat**: hiányzó vagy rossz típusú mező esetén hibaüzenetet kapsz, és a mentés nem történik meg — az oldal így nem tud elromlani.
- **Ha a tartalom megváltozott, mióta a lapot megnyitottad** (pl. közben deploy történt vagy egy másik fülben mentett valaki), a Mentés figyelmeztet: ilyenkor döntsd el, hogy frissíted a lapot az új tartalomra (a szerkesztésed elvész), vagy megszakítod. Ezzel a védelemmel nem írható véletlenül felül senki módosítása.
- Minden sikeres mentés **biztonsági mentést** készít a szerveren (`.content-backups/`), és naplózza a műveletet.
- Ha a Mentés sikeres, a mentett állapotot **Előnézet** gombbal nézheted meg a staging oldalon.
### 🚀 Publikálás
- A Publikálás **commitolja és feltolja** a változtatásokat, majd elindítja a staging deployt.
- „Nincs új változtatás." üzenet: nincs új mentett változtatás — ez **nem hiba**, ilyenkor deploy sem indul.
- A publikálás korlátozva van (3 próbálkozás / 15 perc) a véletlen tömeges deploy elkerülésére.
- A deploy eltarthat 1-2 percig; az eredményt az Előnézet gombbal ellenőrizheted.
### Biztonság
- Több **sikertelen belépési kísérlet** után a rendszer átmenetileg letiltja a belépést a gépedről (kb. 15 percre).
- Minden mentés és publikálás **naplózva** van (audit log) a nyomonkövethetőség érdekében.
---
## Karbantartás (fejlesztőknek)
- Forrás: `docs/felhasznaloi-utmutato.md` — a CMS a `/guide` útvonalon rendereli ki.
- **Szabály**: minden CMS- vagy honlapfunkció változásnál frissítsd ezt a fájlt ugyanabban a commitban.
- Az útmutató támogatott formátuma: címsorok, **félkövér**, `kód`, listák, linkek, elválasztó vonalak.
+50
View File
@@ -0,0 +1,50 @@
# nginx vhost-ok (szerveroldali, nincs git-ben verziózva)
> Ez a fájl **dokumentáció, nem forrás** — a tényleges konfiguráció a
> szerveren (`sadmin@llmdev.mozdit.hu`) él, `/etc/nginx/sites-available/`
> alatt, ott kell szerkeszteni és `nginx -t` + `systemctl reload nginx`-szel
> érvényesíteni. Ez a projekt nem tart fenn saját IaC-t/Ansible-t az
> nginx-hez; ez a dokumentum azért létezik, hogy a vhost-ok célja és
> létezése ne csak a szerver `/etc/nginx/`-jében legyen fellelhető (lásd
> MITHOME-97 follow-up: egy elárvult, régi CMS-re mutató vhost tanúsítványa
> okozott zavaró Firefox biztonsági figyelmeztetést, mert semmilyen
> dokumentáció nem jelezte a létezését).
A szerver (`llmdev.mozdit.hu`) **több, egymástól független projektet is
kiszolgál** (pl. `n8n.llmdev.mozdit.hu`, `gradia.hu`, `tippom-stage` stb.) —
ez a dokumentum csak a **websitedev / mozdIT** projekthez tartozó
vhost-okat írja le.
## `stage.mozdit.hu`
A publikus staging weboldal — `docker-compose.staging.yml` `app` service,
`127.0.0.1:8081` felé proxyz. Ez a kanonikus staging URL.
## `cms.stage.llmdev.mozdit.hu`
Kényelmi URL a Payload admin felülethez, **ugyanarra a staging
app-konténerre** proxyzva (`127.0.0.1:8081`) — nem külön szolgáltatás, nem
külön adatbázis.
- `/` → 302 redirect `/admin`-ra
- `/admin`, `/api/`, `/_next/` → proxyzva a staging app-ra
- minden más (publikus oldalak) → 302 redirect `https://stage.mozdit.hu`-ra
(nincs duplikált tartalom a két domain alatt)
TLS: Certbot-kezelt Let's Encrypt tanúsítvány, saját magára a
`cms.stage.llmdev.mozdit.hu` névre kiállítva.
**Történet**: ez a vhost eredetileg a régi, egyedi CMS-nek
(`content-editor.js`, `127.0.0.1:4001`) szólt. Miután a CMS-t leépítettük
(MITHOME-93) és a `mozdit-content-editor.service`-t leállítottuk/letiltottuk,
a vhost egy ideig egy halott backendre mutatott, és tanúsítványa lett az
nginx véletlenszerű fallback-je más, nem konfigurált `*.mozdit.hu`
albdomainekre (pl. elgépelt URL-ekre) — ez okozott egy valódinak tűnő, de
ártalmatlan Firefox "site could be impersonating" figyelmeztetést. A vhost-ot
2026-09-11-én átállítottuk a Payload admin felé (lásd fent).
## Production megfelelő (ha/amikor lesz)
Ha production is élesedik, érdemes ugyanezt a mintát követni: egy
`cms.mozdit.hu` (vagy hasonló) vhost, ugyanazzal a proxy-scope-pal
(`/admin`, `/api/`, `/_next/`), a production app portjára (8080) mutatva.
+6
View File
@@ -39,3 +39,9 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# Payload CMS — local upload storage (MITHOME-89). Runtime data, not source;
# needs a persistent Docker volume in staging/production (see MITHOME-97).
/media
# Payload CMS — generated TS types (payload.config.ts typescript.outputFile)
src/payload-types.ts
+9 -11
View File
@@ -1,16 +1,14 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
// NOTE (Next.js 16 / eslint-config-next 16): the FlatCompat("next/core-web-vitals")
// bridge to the legacy .eslintrc-style config caused a circular-JSON crash in
// ESLint 9 (the "react" plugin object closes a cycle when re-validated through
// FlatCompat). eslint-config-next now ships native flat-config arrays, so
// import those directly instead of going through the compat layer.
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
import nextTypescript from "eslint-config-next/typescript";
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
...nextCoreWebVitals,
...nextTypescript,
{
ignores: [
"node_modules/**",
+15 -5
View File
@@ -1,13 +1,19 @@
import type { NextConfig } from "next";
import { withPayload } from "@payloadcms/next/withPayload";
const nextConfig: NextConfig = {
// Enable standalone output for Docker deployment
output: 'standalone',
// Skip linting during build for faster Docker builds
eslint: {
ignoreDuringBuilds: true,
},
// NOTE (Next.js 16): `next dev` auto-generates AGENTS.md/CLAUDE.md stub
// files describing the framework to AI agents. This project already has
// its own agent instruction system (root CLAUDE.md -> .agent/) — a second,
// unrelated proto/CLAUDE.md would conflict with it, so this is disabled.
agentRules: false,
// NOTE (Next.js 16): `eslint.ignoreDuringBuilds` was removed — `next build`
// no longer runs ESLint itself (lint is now only `next lint` / `npm run lint`),
// so there is nothing left to ignore here.
// Skip TypeScript checking during build (for faster Docker builds)
typescript: {
@@ -71,4 +77,8 @@ const nextConfig: NextConfig = {
},
};
export default nextConfig;
// MITHOME-86: bundles the admin UI's route handlers correctly under
// Turbopack/webpack. `@payload-config` itself resolves via the `paths`
// alias in tsconfig.json (./src/payload.config.ts) — this wrapper's
// installed version has no separate configPath option.
export default withPayload(nextConfig);
+4216 -466
View File
File diff suppressed because it is too large Load Diff
+13 -4
View File
@@ -27,14 +27,22 @@
"docker:dev:down": "cd .. && docker-compose -f docker-compose.dev.yml down",
"docker:dev:logs": "cd .. && docker-compose -f docker-compose.dev.yml logs -f app",
"docker:build": "docker build -t mozdit-app .",
"docker:run": "docker run -p 3000:3000 --env-file .env.local mozdit-app"
"docker:run": "docker run -p 3000:3000 --env-file .env.local mozdit-app",
"migrate:content": "node --env-file=.env.local --import tsx scripts/migrate-content-to-payload.ts",
"test:payload": "node --env-file=.env.local --import tsx scripts/test-payload-local-api.ts"
},
"dependencies": {
"@payloadcms/db-mongodb": "^3.88.0",
"@payloadcms/next": "^3.88.0",
"@payloadcms/richtext-lexical": "^3.88.0",
"graphql": "^16.14.2",
"mongodb": "^6.5",
"mongoose": "^8.2",
"next": "^15.5.23",
"next": "^16.3.4",
"payload": "^3.88.0",
"react": "19.1.0",
"react-dom": "19.1.0"
"react-dom": "19.1.0",
"sharp": "^0.35.4"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@@ -49,10 +57,11 @@
"@types/react-dom": "^19",
"@types/winston": "^2.4",
"eslint": "^9",
"eslint-config-next": "15.5.2",
"eslint-config-next": "^16.3.4",
"jest": "^29.7",
"jest-environment-jsdom": "^29.7",
"tailwindcss": "^4",
"tsx": "^4.23.13",
"typescript": "^5",
"undici": "^7.15.0",
"winston": "^3.11",
+70
View File
@@ -0,0 +1,70 @@
/**
* MITHOME-93 biztonsági lépés: a régi egyedi CMS (content-editor.js +
* scripts/cms-*.js) eltávolítása előtt exportálja a Payload jelenlegi
* (publikált) szöveges tartalmát egy olvasható JSON fájlba — mindkét
* locale-lal (hu, en) —, hogy git-committolt, ember által is átnézhető
* biztonsági mentés maradjon a leépítés pillanatáról.
*
* NEM helyettesíti a git history-t (a src/content/*.json fájlok minden
* korábbi szerkesztése megvan commit-onként), és nem helyettesíti a
* MongoDB-t (az az élő forrás) — ez egy plusz, könnyen olvasható
* pillanatkép a "mielőtt törlünk, mentsünk" elv jegyében.
*
* Futtatás (proto/ mappából, futó MongoDB-vel és beállított env-ekkel):
* node --env-file=.env.local --import tsx scripts/export-content-snapshot.ts
*/
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import { getPayload, type Payload } from 'payload'
import config from '../src/payload.config'
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const outDir = path.resolve(scriptDir, '../../docs/backups')
const GLOBAL_SLUGS = ['home', 'about', 'services', 'contact', 'common'] as const
const LOCALES = ['hu', 'en'] as const
async function exportGlobals(payload: Payload) {
const result: Record<string, Record<string, unknown>> = {}
for (const slug of GLOBAL_SLUGS) {
result[slug] = {}
for (const locale of LOCALES) {
result[slug][locale] = await payload.findGlobal({ slug, locale })
}
}
return result
}
async function exportCollection(payload: Payload, collection: 'legal-pages' | 'partners') {
const result: Record<string, unknown> = {}
for (const locale of LOCALES) {
const { docs } = await payload.find({ collection, locale, limit: 1000 })
result[locale] = docs
}
return result
}
async function run() {
const payload = await getPayload({ config })
const snapshot = {
exportedAt: new Date().toISOString(),
reason: 'MITHOME-93 — régi CMS (content-editor.js) leépítése előtti biztonsági mentés',
globals: await exportGlobals(payload),
collections: {
legalPages: await exportCollection(payload, 'legal-pages'),
partners: await exportCollection(payload, 'partners'),
},
}
fs.mkdirSync(outDir, { recursive: true })
const filename = `payload-content-snapshot-${snapshot.exportedAt.replace(/[:.]/g, '-')}.json`
const outPath = path.join(outDir, filename)
fs.writeFileSync(outPath, JSON.stringify(snapshot, null, 2), 'utf8')
payload.logger.info(`Snapshot kiírva: ${outPath}`)
process.exit(0)
}
run()
+217
View File
@@ -0,0 +1,217 @@
/**
* MITHOME-87/88: egyszeri migrációs script — a proto/src/content/pages/*.json
* + common.json tartalmát átemeli a Payload Globals-ekbe (Home, About,
* Services, Contact, Common) és a LegalPages collection-be a Local API-n
* keresztül.
*
* Futtatás (proto/ mappából, futó MongoDB-vel és beállított env-ekkel):
* node --env-file=.env.local --import tsx scripts/migrate-content-to-payload.ts
*
* Idempotens: updateGlobal-t / slug-alapú upsert-et hív, tetszőlegesen
* többször futtatható — mindig a JSON az aktuális "forrás igazság", felül-
* írja a Payload-ban lévő korábbi állapotot. NEM törli/nem érinti a JSON
* fájlokat.
*
* MITHOME-92 tapasztalat: a versions.drafts bekapcsolása után a `_status`
* mező defaultValue-ja 'draft' — ha egy create/update hívás data-jában
* nincs explicit `_status`, ÚJ dokumentum létrehozásakor 'draft' lesz, és
* frissítéskor a meglévő (draft) érték marad meg (nem íródik felül
* automatikusan 'published'-re). Ezért itt minden Globals/LegalPages/
* Partners írás explicit `_status: 'published'`-t ad át — a migrációs
* script eredménye mindig publikált tartalom legyen, sosem draft.
*
* MITHOME-110 tapasztalat: ha egy mezőt utólag `localized: true`-ra
* állítasz (MITHOME-111/112 retrofit), a korábban beírt érték a régi,
* nem-lokalizált tárolási alakban marad, és `locale: defaultLocale`-lal
* nem olvasható vissza (üresnek látszik) — ilyenkor ezt a scriptet újra
* kell futtatni, hogy a defaultLocale (hu) alá újra beírja az értéket a
* lokalizált alakban. Explicit `locale` paramétert egyik updateGlobal/
* update hívás sem ad meg itt, ezért mindig a `defaultLocale` (hu) alá ír.
*/
import path from 'path'
import { fileURLToPath } from 'url'
import { getPayload, type Payload } from 'payload'
import config from '../src/payload.config'
import homeJson from '../src/content/pages/home.json'
import aboutJson from '../src/content/pages/about.json'
import servicesJson from '../src/content/pages/services.json'
import contactJson from '../src/content/pages/contact.json'
import commonJson from '../src/content/common.json'
import adatvedelemJson from '../src/content/pages/adatvedelem.json'
import hasznalatiFeltetelekJson from '../src/content/pages/hasznalati-feltetelek.json'
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const publicDir = path.resolve(scriptDir, '../public')
/** string[] -> [{ value: string }] — lásd src/globals/fields/stringArray.ts */
function toStringArray(items: readonly string[]): { value: string }[] {
return items.map((value) => ({ value }))
}
function buildHomeData(json: typeof homeJson) {
const { hero, about, services, cta, serviceFeatures } = json
return {
hero: {
...hero,
trustBullets: toStringArray(hero.trustBullets),
},
about,
services: {
...services,
items: services.items.map((item) => ({
...item,
features: toStringArray(item.features),
})),
},
cta,
serviceFeatures,
}
}
function buildAboutData(json: typeof aboutJson) {
const { meta, hero, story, mission, team, cta } = json
return {
meta,
hero,
story: { ...story, paragraphs: toStringArray(story.paragraphs) },
mission,
team: { ...team, paragraphs: toStringArray(team.paragraphs) },
cta,
}
}
function buildServicesData(json: typeof servicesJson) {
const { meta, hero, details, support, cta } = json
return {
meta,
hero,
details: {
...details,
services: details.services.map((service) => ({
...service,
specs: {
...service.specs,
items: toStringArray(service.specs.items),
},
})),
},
support,
cta,
}
}
type LegalPageJson = {
title: string
lastUpdated: string
sections: { id: string; title: string; content: string }[]
}
/** Collections have no per-document "known slug" API like Globals do —
* find-by-slug, then update or create. Idempotent across re-runs. */
async function upsertLegalPage(payload: Payload, slug: string, json: LegalPageJson) {
const existing = await payload.find({
collection: 'legal-pages',
where: { slug: { equals: slug } },
limit: 1,
})
if (existing.docs.length > 0) {
await payload.update({
collection: 'legal-pages',
id: existing.docs[0].id,
data: { slug, ...json, _status: 'published' },
})
} else {
await payload.create({
collection: 'legal-pages',
data: { slug, ...json, _status: 'published' },
})
}
}
type PartnerJson = { name: string; url: string; logo: string }
/**
* MITHOME-89 (MVP): a home.json partners.items publikus /partners/*.png
* útvonalait tölti fel Media collection dokumentumként, majd Partner
* rekordot hoz létre/frissít rá hivatkozva. Idempotens: a Media dokumentumot
* `alt` (== partner név) alapján, a Partnert `name` alapján keresi.
*/
async function upsertPartner(payload: Payload, partner: PartnerJson) {
const existingMedia = await payload.find({
collection: 'media',
where: { alt: { equals: partner.name } },
limit: 1,
})
let mediaId: string | number
if (existingMedia.docs.length > 0) {
mediaId = existingMedia.docs[0].id
} else {
const filePath = path.resolve(publicDir, partner.logo.replace(/^\//, ''))
const created = await payload.create({
collection: 'media',
data: { alt: partner.name },
filePath,
})
mediaId = created.id
}
const existingPartner = await payload.find({
collection: 'partners',
where: { name: { equals: partner.name } },
limit: 1,
})
if (existingPartner.docs.length > 0) {
await payload.update({
collection: 'partners',
id: existingPartner.docs[0].id,
data: { name: partner.name, url: partner.url, logo: mediaId, _status: 'published' },
})
} else {
await payload.create({
collection: 'partners',
data: { name: partner.name, url: partner.url, logo: mediaId, _status: 'published' },
})
}
}
async function run() {
const payload = await getPayload({ config })
await payload.updateGlobal({ slug: 'home', data: { ...buildHomeData(homeJson), _status: 'published' } })
payload.logger.info('Home global migrálva')
await payload.updateGlobal({ slug: 'about', data: { ...buildAboutData(aboutJson), _status: 'published' } })
payload.logger.info('About global migrálva')
await payload.updateGlobal({ slug: 'services', data: { ...buildServicesData(servicesJson), _status: 'published' } })
payload.logger.info('Services global migrálva')
await payload.updateGlobal({ slug: 'contact', data: { ...contactJson, _status: 'published' } })
payload.logger.info('Contact global migrálva')
await payload.updateGlobal({ slug: 'common', data: { ...commonJson, _status: 'published' } })
payload.logger.info('Common global migrálva')
await upsertLegalPage(payload, 'adatvedelem', adatvedelemJson)
payload.logger.info('LegalPages/adatvedelem migrálva')
await upsertLegalPage(payload, 'hasznalati-feltetelek', hasznalatiFeltetelekJson)
payload.logger.info('LegalPages/hasznalati-feltetelek migrálva')
for (const partner of homeJson.partners.items) {
await upsertPartner(payload, partner)
payload.logger.info(`Partners/${partner.name} migrálva`)
}
payload.logger.info('MITHOME-87/88/89 migráció kész.')
process.exit(0)
}
run().catch((error) => {
console.error('Migráció sikertelen:', error)
process.exit(1)
})
+134
View File
@@ -0,0 +1,134 @@
/**
* MITHOME-96 — Payload Local API integrációs teszt.
*
* Ez a "Local API integrációs tesztek" rész a ticketből: valódi, futó
* MongoDB-vel ellenőrzi, hogy a src/lib/payload-content.ts adapter-réteg
* ténylegesen jó alakú adatot ad vissza a Globals/Collections-ökből — nem
* mock-olt Payload-dal, hanem éles Local API hívásokkal.
*
* WHY sima node script és nem Jest teszt: a Payload csomag (és több
* függősége, pl. @payloadcms/richtext-lexical) ESM-only dist-et ad ki —
* Jest (a next/jest SWC transformjával is) alapból nem transzformálja a
* node_modules-t, így `import { getPayload } from 'payload'` egy Jest
* tesztben `SyntaxError: Cannot use import statement outside a module`-lel
* bukik (kipróbálva). A `transformIgnorePatterns` kiterjesztése a teljes
* Payload-függőségi fára törékeny és karbantartás-igényes lenne — ehelyett
* ugyanazt a bevált mintát követjük, mint a migrate-content-to-payload.ts /
* export-content-snapshot.ts scriptek: `node --import tsx`, valódi Node
* ESM-mel, semmilyen Jest-transzform nem kell.
*
* Futtatás (proto/ mappából, futó MongoDB-vel, migrált tartalommal):
* MONGODB_URI="mongodb://admin:password123@localhost:27018/mozdit?authSource=admin" \
* npm run test:payload
*/
import assert from 'assert/strict'
import { getPayload } from 'payload'
import config from '../src/payload.config'
import {
getCommonContent,
getHomeContent,
getAboutContent,
getServicesContent,
getContactContent,
getLegalPage,
getPartners,
} from '../src/lib/payload-content'
let passed = 0
async function test(name: string, fn: () => Promise<void>) {
try {
await fn()
passed++
console.log(`${name}`)
} catch (error) {
console.error(`${name}`)
throw error
}
}
async function run() {
const payload = await getPayload({ config })
await test('getCommonContent visszaad hu és en tartalmat', async () => {
const hu = await getCommonContent('hu')
const en = await getCommonContent('en')
assert.ok(hu.buttons.webmail, 'hiányzó hu webmail gomb szöveg')
assert.ok(en.buttons.webmail, 'hiányzó en webmail gomb szöveg')
})
await test('getHomeContent unwrap-eli a trustBullets és services.items.features tömböket', async () => {
const home = await getHomeContent('hu')
assert.ok(Array.isArray(home.hero.trustBullets))
assert.ok(home.hero.trustBullets.every((v) => typeof v === 'string'))
for (const item of home.services.items) {
assert.ok(Array.isArray(item.features))
assert.ok(item.features.every((v) => typeof v === 'string'))
}
})
await test('getAboutContent unwrap-eli a story és team paragraphs tömböket', async () => {
const about = await getAboutContent('hu')
assert.ok(Array.isArray(about.story.paragraphs))
assert.ok(Array.isArray(about.team.paragraphs))
})
await test('getServicesContent unwrap-eli a specs.items tömböket', async () => {
const services = await getServicesContent('hu')
for (const service of services.details.services) {
assert.ok(Array.isArray(service.specs.items))
}
})
await test('getContactContent visszaadja a form mezőket', async () => {
const contact = await getContactContent('hu')
assert.ok(contact.form.fields.email.label)
})
await test('getLegalPage megtalálja mindkét jogi oldalt', async () => {
const privacy = await getLegalPage('adatvedelem', 'hu')
const terms = await getLegalPage('hasznalati-feltetelek', 'hu')
assert.equal(privacy?.slug, 'adatvedelem')
assert.equal(terms?.slug, 'hasznalati-feltetelek')
})
await test('getPartners csak logóval rendelkező partnereket ad vissza', async () => {
const partners = await getPartners()
for (const partner of partners) {
assert.ok(partner.logo.url)
assert.ok(partner.name)
}
})
await test('create/findByID/delete ciklus működik (contact-submissions)', async () => {
const created = await payload.create({
collection: 'contact-submissions',
data: {
name: 'Local API Test',
email: 'local-api-test@example.com',
subject: 'MITHOME-96 Local API teszt',
message: 'Ez a rekord a test-payload-local-api.ts futása során jön létre és törlődik.',
gdprConsent: true,
status: 'new',
},
})
assert.ok(created.id)
const found = await payload.findByID({ collection: 'contact-submissions', id: created.id })
assert.equal(found.email, 'local-api-test@example.com')
await payload.delete({ collection: 'contact-submissions', id: created.id })
await assert.rejects(
payload.findByID({ collection: 'contact-submissions', id: created.id })
)
})
console.log(`\n${passed}/${passed} Payload Local API teszt zöld.`)
process.exit(0)
}
run().catch((error) => {
console.error('\nPayload Local API teszt sikertelen:', error)
process.exit(1)
})
@@ -1,91 +0,0 @@
/**
* Regression test for the Content Editor browser script: deleting an array
* item via its ❌ button must reindex the remaining items, otherwise collect()
* produces sparse arrays (null holes) that fail schema validation
* ("$.details.services[1].specs.items[0]: string érték szükséges").
*
* Runs the REAL scripts/cms-editor-client.js in jsdom and clicks the actual
* delete buttons — earlier coverage only exercised reindexItems() directly,
* which missed that the onclick handler removed the node BEFORE looking up
* its container (detached node → closest() === null → no reindex).
*/
import fs from 'fs'
import path from 'path'
const clientJs = fs.readFileSync(path.join(__dirname, '../../../scripts/cms-editor-client.js'), 'utf8')
const service = (n: number) => ({
id: `svc-${n}`,
title: `Szolgáltatás ${n}`,
description: `Leírás ${n}`,
icon: '🔧',
features: [`feature ${n}`],
ctaText: 'CTA',
})
const makeData = () => ({
details: {
title: 'Részletek',
subtitle: 'Alcím',
services: [
{ icon: 'a', title: 's0', description: 'd0', specs: { title: 't0', items: ['a0', 'b0', 'c0'] } },
{ icon: 'b', title: 's1', description: 'd1', specs: { title: 't1', items: ['a1', 'b1', 'c1'] } },
{ icon: 'c', title: 's2', description: 'd2', specs: { title: 't2', items: ['a2', 'b2', 'c2'] } },
],
},
})
function bootClient(data: unknown) {
;(global as any).DATA = data
;(global as any).FILE = 'services'
;(global as any).CSRF_TOKEN = 'test-token'
;(global as any).fetch = jest.fn()
document.body.innerHTML = '<div id="editor"></div>'
// sloppy-mode eval publishes the script's functions on the global object
;(0, eval)(clientJs)
}
function deleteButtonFor(dataPath: string): HTMLButtonElement {
const field = document.querySelector(`[data-path="${CSS.escape(dataPath)}"]`) as HTMLElement
expect(field).not.toBeNull()
const wrap = field.closest('.str-item') as HTMLElement
expect(wrap).not.toBeNull()
return wrap.querySelector('.btn-del') as HTMLButtonElement
}
afterEach(() => {
delete (global as any).DATA
delete (global as any).FILE
delete (global as any).CSRF_TOKEN
})
describe('Content Editor client delete/reindex', () => {
it('deleting a nested string array item keeps the remaining items dense', () => {
const data = makeData()
bootClient(data)
deleteButtonFor('details.services[1].specs.items[0]').click()
const collected = (global as any).collect()
expect(collected.details.services[1].specs.items).toEqual(['b1', 'c1'])
expect(collected.details.services[0].specs.items).toEqual(['a0', 'b0', 'c0'])
expect(collected.details.services[2].specs.items).toEqual(['a2', 'b2', 'c2'])
})
it('deleting an object card reindexes the outer array', () => {
const data = makeData()
bootClient(data)
const cardHeader = Array.from(document.querySelectorAll('.card-header'))
.find(h => h.textContent === 'details.services[1]') as HTMLElement
expect(cardHeader).not.toBeNull()
const card = cardHeader.closest('.obj-card') as HTMLElement
;(card.querySelector('.btn-del-card') as HTMLButtonElement).click()
const collected = (global as any).collect()
expect(collected.details.services).toHaveLength(2)
expect(collected.details.services[0].title).toBe('s0')
expect(collected.details.services[1].title).toBe('s2')
expect(collected.details.services[1].specs.items).toEqual(['a2', 'b2', 'c2'])
})
})
@@ -1,102 +0,0 @@
/**
* Regression tests for the Content Editor keyboard shortcuts (MITHOME-75).
* Runs the real scripts/cms-editor-client.js in jsdom, dispatches actual
* KeyboardEvents and asserts:
* 1. Ctrl+S calls save (fetch /save) and the browser save dialog is
* suppressed (preventDefault)
* 2. Ctrl+P calls publish
* 3. Ctrl+Shift+V opens the versions page in a new tab
* 4. '?' opens the shortcuts overlay, Esc closes it
* 5. plain 's' inside an input does NOT trigger save
*/
import fs from 'fs'
import path from 'path'
const clientJs = fs.readFileSync(path.join(__dirname, '../../../scripts/cms-editor-client.js'), 'utf8')
+ '\n' + fs.readFileSync(path.join(__dirname, '../../../scripts/cms-editor-shortcuts.js'), 'utf8')
const makeData = () => ({
hero: { title: 'T', subtitle: 'S', description: 'D' },
})
function press(target: Document | Element, init: KeyboardEventInit) {
const ev = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init })
target.dispatchEvent(ev)
return ev
}
const flush = () => new Promise(r => setTimeout(r, 0))
// Globals must exist BEFORE the eval — the client script boots immediately
// (render(DATA, …)). The eval runs ONCE: every eval would add another keydown
// listener to the shared jsdom document, and the toggle-style overlay handler
// would then open/close itself multiple times per keypress.
;(global as any).DATA = makeData()
;(global as any).FILE = 'home'
;(global as any).CSRF_TOKEN = 'boot'
;(global as any).CONTENT_HASH = 'x'.repeat(64)
;(global as any).fetch = jest.fn(async () => ({ status: 200, ok: true, json: async () => ({ ok: true }) }))
;(global as any).window = global
document.body.innerHTML = '<div id="editor"></div>'
;(0, eval)(clientJs)
describe('Content Editor keyboard shortcuts', () => {
let fetchCalls: Array<{ url: string; init?: RequestInit }>
beforeEach(() => {
fetchCalls = []
;(global as any).DATA = makeData()
;(global as any).FILE = 'home'
;(global as any).CSRF_TOKEN = 'test-csrf'
;(global as any).CONTENT_HASH = 'x'.repeat(64)
;(global as any).fetch = jest.fn(async (url: string, init?: RequestInit) => {
fetchCalls.push({ url, init })
return { status: 200, ok: true, json: async () => ({ ok: true, contentHash: 'y'.repeat(64) }) }
})
;(global as any).window = global
;(window as any).open = jest.fn()
document.body.innerHTML = '<div id="editor"></div><span id="saveStatus" style="display:none"></span><button id="publishBtn">pub</button>'
})
afterEach(() => {
delete (global as any).DATA
delete (global as any).FILE
delete (global as any).CSRF_TOKEN
delete (global as any).CONTENT_HASH
})
it('Ctrl+S saves via fetch and suppresses the browser save dialog', () => {
const ev = press(document, { key: 's', ctrlKey: true })
expect(ev.defaultPrevented).toBe(true)
expect(fetchCalls.length).toBeGreaterThanOrEqual(1)
expect(fetchCalls[0].url).toContain('/save?file=home')
})
it('Cmd+P publishes', async () => {
const ev = press(document, { key: 'p', metaKey: true })
expect(ev.defaultPrevented).toBe(true)
await flush() // publish awaits save() before its own fetch
expect(fetchCalls.some(c => c.url === '/publish')).toBe(true)
})
it('Ctrl+Shift+V opens the versions page in a new tab', () => {
const ev = press(document, { key: 'V', ctrlKey: true, shiftKey: true })
expect(ev.defaultPrevented).toBe(true)
expect((window as any).open).toHaveBeenCalledWith('/versions?file=home', '_blank')
})
it("'?' opens the shortcuts overlay and Esc closes it", () => {
press(document, { key: '?' })
expect(document.getElementById('shortcuts-overlay')).not.toBeNull()
press(document, { key: 'Escape' })
expect(document.getElementById('shortcuts-overlay')).toBeNull()
})
it('plain typing in an input never triggers save', () => {
const input = document.createElement('input')
document.body.appendChild(input)
const ev = press(input, { key: 's' })
expect(ev.defaultPrevented).toBe(false)
expect(fetchCalls.length).toBe(0)
})
})
+13 -9
View File
@@ -1,6 +1,10 @@
/**
* End-to-End tests for the Docker environment
* These tests verify the full application flow in the Docker stack
*
* MITHOME-96: routes are locale-prefixed (/hu/..., /en/...) since the
* Payload CMS migration (MITHOME-91/114) — updated from the old unprefixed
* paths.
*/
describe('Docker E2E Tests', () => {
@@ -27,24 +31,24 @@ describe('Docker E2E Tests', () => {
expect(html).toContain('mozdIT Bt.')
// Test navigation links exist in homepage
expect(html).toContain('href="/rolunk"')
expect(html).toContain('href="/szolgaltatasok"')
expect(html).toContain('href="/kapcsolat"')
expect(html).toContain('href="/hu/rolunk"')
expect(html).toContain('href="/hu/szolgaltatasok"')
expect(html).toContain('href="/hu/kapcsolat"')
// Test about page
response = await fetch(`${APP_URL}/rolunk`)
response = await fetch(`${APP_URL}/hu/rolunk`)
expect(response.status).toBe(200)
html = await response.text()
expect(html).toContain('Rólunk')
// Test services page
response = await fetch(`${APP_URL}/szolgaltatasok`)
response = await fetch(`${APP_URL}/hu/szolgaltatasok`)
expect(response.status).toBe(200)
html = await response.text()
expect(html).toContain('Szolgáltatásaink')
// Test contact page
response = await fetch(`${APP_URL}/kapcsolat`)
response = await fetch(`${APP_URL}/hu/kapcsolat`)
expect(response.status).toBe(200)
html = await response.text()
expect(html).toContain('Kapcsolat')
@@ -210,9 +214,9 @@ describe('Docker E2E Tests', () => {
const pages = [
{ url: '', title: 'mozdIT Bt.' },
{ url: '/rolunk', title: 'Rólunk' },
{ url: '/szolgaltatasok', title: 'Szolgáltatásaink' },
{ url: '/kapcsolat', title: 'Kapcsolatfelvétel' }
{ url: '/hu/rolunk', title: 'Rólunk' },
{ url: '/hu/szolgaltatasok', title: 'Szolgáltatásaink' },
{ url: '/hu/kapcsolat', title: 'Kapcsolat' }
]
for (const page of pages) {
+31 -21
View File
@@ -1,6 +1,14 @@
/**
* Integration tests for the Docker development environment
* These tests run against the real services in the Docker stack
*
* MITHOME-96: updated for the Payload CMS migration (MITHOME-85 epic) —
* routes are now locale-prefixed (/hu/..., /en/...) and content lives in
* Payload collections/globals (`globals`, `legal-pages`, `partners`,
* `contact-submissions`), not the old raw `site_config`/`contact_submissions`
* Mongoose collections. Assumes the Docker dev DB already has content
* migrated (`npm run migrate:content`) — see docs/backups for a snapshot if
* you need to reseed a throwaway environment.
*/
import { MongoClient, ObjectId } from 'mongodb'
@@ -74,12 +82,16 @@ describe('Docker Environment Integration Tests', () => {
const collections = await mozditDb.listCollections().toArray()
const collectionNames = collections.map(c => c.name)
expect(collectionNames).toContain('site_config')
expect(collectionNames).toContain('contact_submissions')
// Payload Globals (Home/About/Services/Contact/Common) live in a
// single `globals` collection, one document per global.
expect(collectionNames).toContain('globals')
expect(collectionNames).toContain('legal-pages')
expect(collectionNames).toContain('partners')
expect(collectionNames).toContain('contact-submissions')
expect(collectionNames).toContain('users')
})
it('should verify site config data exists', async () => {
it('should verify the Home global exists and is published', async () => {
if (process.env.NODE_ENV === 'test' && !process.env.INTEGRATION_TESTS) {
return
}
@@ -90,15 +102,12 @@ describe('Docker Environment Integration Tests', () => {
}
const mozditDb = mongoClient.db('mozdit')
const siteConfig = await mozditDb.collection('site_config').findOne()
const home = await mozditDb.collection('globals').findOne({ globalType: 'home' })
if (!siteConfig) throw new Error('site_config document not found')
expect(siteConfig).toBeTruthy()
expect(siteConfig).toHaveProperty('type', 'site_config')
expect(siteConfig).toHaveProperty('environment', 'development')
expect(siteConfig).toHaveProperty('data')
expect(siteConfig.data).toHaveProperty('general')
expect(siteConfig.data.general).toHaveProperty('name', 'mozdIT Bt.')
if (!home) throw new Error('Home global document not found')
expect(home).toHaveProperty('_status', 'published')
expect(home).toHaveProperty('hero')
expect(home.hero).toHaveProperty('title')
})
it('should access Mongo Express UI', async () => {
@@ -179,7 +188,7 @@ describe('Docker Environment Integration Tests', () => {
}
const savedSubmission = await mongoClient
.db('mozdit')
.collection('contact_submissions')
.collection('contact-submissions')
.findOne({ _id: new ObjectId(result.submissionId) })
expect(savedSubmission).toEqual(expect.objectContaining({
name: contactData.name,
@@ -256,7 +265,7 @@ describe('Docker Environment Integration Tests', () => {
const result = await response.json()
if (response.status === 400) {
expect(result).toHaveProperty('error', 'Spam gyanús tartalom észlelve')
expect(result).toHaveProperty('error', 'Az üzenet spam gyanús tartalmat tartalmaz.')
} else if (response.status === 429) {
expect(result).toHaveProperty('error')
expect(result.error).toContain('Túl sok')
@@ -270,13 +279,14 @@ describe('Docker Environment Integration Tests', () => {
return
}
// MITHOME-91/114: bare "/" 307-redirects to "/hu" — fetch follows
// redirects by default, so this still lands on the real homepage.
const response = await fetch(DOCKER_SERVICES.app)
expect(response.status).toBe(200)
const html = await response.text()
expect(html).toContain('mozdIT Bt.')
expect(html).toContain('Megbízható web és emailszolgáltatás')
expect(html).toContain('Webmail Ugrás')
expect(html).toContain('Megbízható web- és email szolgáltatás')
})
it('should load about page', async () => {
@@ -284,7 +294,7 @@ describe('Docker Environment Integration Tests', () => {
return
}
const response = await fetch(`${DOCKER_SERVICES.app}/rolunk`)
const response = await fetch(`${DOCKER_SERVICES.app}/hu/rolunk`)
expect(response.status).toBe(200)
const html = await response.text()
@@ -297,14 +307,14 @@ describe('Docker Environment Integration Tests', () => {
return
}
const response = await fetch(`${DOCKER_SERVICES.app}/szolgaltatasok`)
const response = await fetch(`${DOCKER_SERVICES.app}/hu/szolgaltatasok`)
expect(response.status).toBe(200)
const html = await response.text()
expect(html).toContain('Szolgáltatásaink')
expect(html).toContain('Web Hosting')
expect(html).toContain('Email Szolgáltatás')
expect(html).toContain('DNS Adminisztráció')
expect(html).toContain('Webtárhely')
expect(html).toContain('E-mail szolgáltatás')
expect(html).toContain('DNS adminisztráció')
})
it('should load contact page', async () => {
@@ -312,7 +322,7 @@ describe('Docker Environment Integration Tests', () => {
return
}
const response = await fetch(`${DOCKER_SERVICES.app}/kapcsolat`)
const response = await fetch(`${DOCKER_SERVICES.app}/hu/kapcsolat`)
expect(response.status).toBe(200)
const html = await response.text()
@@ -0,0 +1,123 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import AboutView from '@/components/views/AboutView'
import ServicesView from '@/components/views/ServicesView'
import ContactView from '@/components/views/ContactView'
import LegalPageView from '@/components/views/LegalPageView'
import { resolvePageKey, isLocale, type Locale } from '@/lib/i18n'
import {
getAboutContent,
getServicesContent,
getContactContent,
getHomeContent,
getCommonContent,
getLegalPage,
} from '@/lib/payload-content'
import { siteConfig, getOgLocale } from '@/config/site'
type Params = { locale: string; slug: string }
// WHY force-dynamic: lásd ../layout.tsx — nincs generateStaticParams, minden
// kérés élőben olvassa a Payload-ot, admin publikálás azonnal látszik.
export const dynamic = 'force-dynamic'
const LEGAL_META: Record<'privacy' | 'terms', Record<Locale, string>> = {
privacy: {
hu: 'Adatvédelmi tájékoztató - ismerje meg, hogyan kezeljük személyes adatait.',
en: 'Privacy policy — learn how we handle your personal data.',
},
terms: {
hu: 'Általános Szerződési Feltételek - Ismerje meg a mozdIT Bt. szolgáltatásainak használati feltételeit.',
en: 'Terms of Service — learn about the terms and conditions of mozdIT Bt.s services.',
},
}
export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
const { locale: rawLocale, slug } = await params
if (!isLocale(rawLocale)) return {}
const locale: Locale = rawLocale
const key = resolvePageKey(locale, slug)
if (!key) return {}
const base = (title: string, description: string, ogDescription = description) => ({
title: `${title} | ${siteConfig.general.name}`,
description,
openGraph: {
title: `${title} | ${siteConfig.general.name}`,
description: ogDescription,
url: `${siteConfig.general.url}${'/' + locale}/${slug}`,
locale: getOgLocale(locale),
},
})
if (key === 'about') {
const about = await getAboutContent(locale)
return base(about.meta.title, about.meta.description, about.meta.ogDescription)
}
if (key === 'services') {
const services = await getServicesContent(locale)
return base(services.meta.title, services.meta.description, services.meta.ogDescription)
}
if (key === 'contact') {
const contact = await getContactContent(locale)
return base(contact.meta.title, contact.meta.description)
}
if (key === 'privacy') {
const page = await getLegalPage('adatvedelem', locale)
return base(page?.title ?? 'Adatvédelem', LEGAL_META.privacy[locale])
}
// terms
const page = await getLegalPage('hasznalati-feltetelek', locale)
return base(page?.title ?? 'ÁSZF', LEGAL_META.terms[locale])
}
export default async function CatchAllPage({ params }: { params: Promise<Params> }) {
const { locale: rawLocale, slug } = await params
if (!isLocale(rawLocale)) notFound()
const locale: Locale = rawLocale
const key = resolvePageKey(locale, slug)
if (!key) notFound()
if (key === 'about') {
const content = await getAboutContent(locale)
return <AboutView content={content} locale={locale} />
}
if (key === 'services') {
const [content, home, common] = await Promise.all([
getServicesContent(locale),
getHomeContent(locale),
getCommonContent(locale),
])
return (
<ServicesView
content={content}
homeServices={home.services.items}
featuresLabel={common.labels.features}
webmailHref={home.hero.cta.secondary?.href ?? siteConfig.general.url}
locale={locale}
/>
)
}
if (key === 'contact') {
const [content, home, common] = await Promise.all([
getContactContent(locale),
getHomeContent(locale),
getCommonContent(locale),
])
return (
<ContactView
content={content}
contactEmail={siteConfig.contact.email}
footerAddress={common.footer.address}
webmailHref={home.hero.cta.secondary?.href ?? siteConfig.general.url}
locale={locale}
/>
)
}
const page = await getLegalPage(key === 'privacy' ? 'adatvedelem' : 'hasznalati-feltetelek', locale)
if (!page) notFound()
return <LegalPageView page={page} locale={locale} />
}
@@ -0,0 +1,63 @@
import { notFound } from 'next/navigation'
import Header from '../../../components/Header'
import Footer from '../../../components/Footer'
import { isLocale, localePath, type Locale } from '@/lib/i18n'
import { getCommonContent, getHomeContent } from '@/lib/payload-content'
import { siteConfig, getMainNavigation, getFooterNavigation, getFooterLegalLinks, getSiteDescription } from '@/config/site'
// WHY force-dynamic (MITHOME-97): ezek az oldalak a Payload Local API-t hívják
// (élő MongoDB-olvasás). generateStaticParams + SSG mellett a build időben
// sütött ki minden oldal, és a Payload adminban végzett publikálás csak egy
// teljes redeploy után jelent volna meg a publikus oldalon — ez pont az
// ellentéte az önkiszolgáló szerkesztés céljának, amiért a Payload-migráció
// történt. force-dynamic-kal minden kérés friss Payload-olvasást kap, és a
// Docker build sem függ többé egy build-idejű MongoDB-kapcsolattól.
export const dynamic = 'force-dynamic'
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ locale: string }>
}) {
const { locale: rawLocale } = await params
if (!isLocale(rawLocale)) notFound()
const locale: Locale = rawLocale
const [common, home] = await Promise.all([
getCommonContent(locale),
getHomeContent(locale),
])
const isStaging = process.env.NEXT_PUBLIC_DEPLOY_ENV === 'staging'
return (
<>
{isStaging && (
<div className="bg-amber-400 px-4 py-2 text-center text-xs font-extrabold tracking-[0.18em] text-amber-950 sm:text-sm">
{common.staging.banner}
</div>
)}
<Header
nav={getMainNavigation(locale)}
homeHref={localePath(locale)}
a11y={common.a11y}
/>
<main className="flex-1">
{children}
</main>
<Footer
nav={getFooterNavigation(locale)}
legalLinks={getFooterLegalLinks(locale)}
homeHref={localePath(locale)}
description={getSiteDescription(locale)}
contactEmail={siteConfig.contact.email}
footerAddress={common.footer.address}
footerCopyright={common.footer.copyright}
homeServices={home.services.items.map((item) => ({ id: item.id, title: item.title, icon: item.icon }))}
locale={locale}
/>
</>
)
}
+44
View File
@@ -0,0 +1,44 @@
import type { Metadata } from 'next'
import HomeView from '@/components/views/HomeView'
import { isLocale, type Locale } from '@/lib/i18n'
import { getHomeContent, getPartners } from '@/lib/payload-content'
import { siteConfig, getSiteDescription, getOgLocale } from '@/config/site'
import { notFound } from 'next/navigation'
type Params = { locale: string }
// WHY force-dynamic: lásd [slug]/page.tsx és ../layout.tsx.
export const dynamic = 'force-dynamic'
export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
const { locale: rawLocale } = await params
if (!isLocale(rawLocale)) return {}
const locale: Locale = rawLocale
const description = getSiteDescription(locale)
return {
title: `${siteConfig.general.name} | ${description}`,
description,
openGraph: {
title: siteConfig.general.name,
description,
url: siteConfig.general.url,
siteName: siteConfig.general.name,
images: [{ url: siteConfig.general.ogImage, width: 1200, height: 630, alt: siteConfig.general.name }],
locale: getOgLocale(locale),
type: 'website',
},
}
}
export default async function HomePage({ params }: { params: Promise<Params> }) {
const { locale: rawLocale } = await params
if (!isLocale(rawLocale)) notFound()
const locale: Locale = rawLocale
const [content, partners] = await Promise.all([
getHomeContent(locale),
getPartners(),
])
return <HomeView content={content} partners={partners} locale={locale} />
}
@@ -2,11 +2,8 @@ import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import Header from "../components/Header";
import Footer from "../components/Footer";
import { ThemeProvider } from "../components/ThemeProvider";
import { siteConfig } from "../config/site";
import { common } from "../content";
import { ThemeProvider } from "../../components/ThemeProvider";
import { siteConfig, getSiteDescription } from "../../config/site";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -18,47 +15,18 @@ const geistMono = Geist_Mono({
subsets: ["latin"],
});
// WHY hu itt: ez a legkülső, nyelv-független layout (html/body/theme-script
// csak egyszer, MITHOME-91/114) — a metadataBase és az alap description a
// magyar (alapértelmezett) nyelvet tükrözi, de minden [locale]/[slug]
// oldal a saját generateMetadata()-jával felülírja title/description-t
// nyelvhelyesen. Ez csak a legelső, JS nélküli betöltéskori fallback.
export const metadata: Metadata = {
// WHY metadataBase: without it Next resolves relative OG/twitter image URLs
// against localhost, producing broken social previews in production.
metadataBase: new URL(siteConfig.general.url),
title: `${siteConfig.general.name} | ${siteConfig.general.description}`,
description: siteConfig.general.description,
title: siteConfig.general.name,
description: getSiteDescription("hu"),
authors: [{ name: siteConfig.general.name }],
keywords: ["web hosting", "email szolgáltatás", "DNS adminisztráció", "IT szolgáltatás", "mozdIT"],
openGraph: {
title: siteConfig.general.name,
description: siteConfig.general.description,
url: siteConfig.general.url,
siteName: siteConfig.general.name,
images: [
{
url: siteConfig.general.ogImage,
width: 1200,
height: 630,
alt: siteConfig.general.name,
},
],
locale: siteConfig.general.locale,
type: "website",
},
twitter: {
card: "summary_large_image",
title: siteConfig.general.name,
description: siteConfig.general.description,
images: [siteConfig.general.ogImage],
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
};
// Keep Safari's browser chrome neutral; only the in-page staging strip is amber.
@@ -71,7 +39,6 @@ export default function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
const isStaging = process.env.NEXT_PUBLIC_DEPLOY_ENV === 'staging';
return (
<html lang="hu" suppressHydrationWarning>
<head>
@@ -95,16 +62,7 @@ export default function RootLayout({
style={{ background: 'var(--color-background)', color: 'var(--color-foreground)' }}
>
<ThemeProvider>
{isStaging && (
<div className="bg-amber-400 px-4 py-2 text-center text-xs font-extrabold tracking-[0.18em] text-amber-950 sm:text-sm">
{common.staging.banner}
</div>
)}
<Header />
<main className="flex-1">
{children}
</main>
<Footer />
</ThemeProvider>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
import Link from 'next/link'
import { DEFAULT_LOCALE, localePath } from '@/lib/i18n'
/**
* MITHOME-91/114: explicit not-found a (frontend) route group szintjén.
*
* WHY kell ez expliciten: notFound() hívásra (érvénytelen locale vagy slug
* a [locale]/[slug] catch-all-ban) Next.js enélkül a beépített, kétértelmű
* fallback UI-t próbálja renderelni — ez a (payload) route group saját
* <html> gyökerével ütközve ugyanazt a "script tag" / dupla-html hibát
* okozta, amit a MITHOME-87-ben már egyszer megoldottunk a (frontend)/
* (payload) szétválasztással. Egy saját not-found.tsx a (frontend) alatt
* egyértelművé teszi, melyik gyökér html-be kell renderelni.
*/
export default function NotFound() {
return (
<div className="flex flex-1 flex-col items-center justify-center py-24 px-4 text-center">
<h1 className="text-4xl font-bold mb-4" style={{ color: 'var(--color-foreground)' }}>404</h1>
<p className="mb-8" style={{ color: 'var(--color-foreground-muted)' }}>
A keresett oldal nem található. / The page youre looking for could not be found.
</p>
<Link href={localePath(DEFAULT_LOCALE)} className="btn btn-primary">
mozdIT Bt.
</Link>
</div>
)
}
+8
View File
@@ -0,0 +1,8 @@
import { redirect } from 'next/navigation'
import { DEFAULT_LOCALE, localePath } from '@/lib/i18n'
// A puszta domain-gyökér (pl. mozdit.hu/) az alapértelmezett nyelvre
// (hu) irányít — a tényleges főoldal a /hu alatt él (MITHOME-91/114).
export default function RootRedirect() {
redirect(localePath(DEFAULT_LOCALE))
}
@@ -0,0 +1,17 @@
import type { Metadata } from 'next'
import config from '@payload-config'
import { NotFoundPage, generatePageMetadata } from '@payloadcms/next/views'
import { importMap } from '../importMap'
type Args = {
params: Promise<{ segments: string[] }>
searchParams: Promise<{ [key: string]: string | string[] }>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const NotFound = ({ params, searchParams }: Args) =>
NotFoundPage({ config, params, searchParams, importMap })
export default NotFound
@@ -0,0 +1,16 @@
import type { Metadata } from 'next'
import config from '@payload-config'
import { RootPage, generatePageMetadata } from '@payloadcms/next/views'
import { importMap } from '../importMap'
type Args = {
params: Promise<{ segments: string[] }>
searchParams: Promise<{ [key: string]: string | string[] }>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const Page = ({ params, searchParams }: Args) => RootPage({ config, params, searchParams, importMap })
export default Page
@@ -0,0 +1,8 @@
import { QuickSearch as QuickSearch_899db48c9ce30e524aae8643dea53f6d } from '../../../../src/components/admin/QuickSearch'
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
/** @type import('payload').ImportMap */
export const importMap = {
"./src/components/admin/QuickSearch#QuickSearch": QuickSearch_899db48c9ce30e524aae8643dea53f6d,
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
}
@@ -0,0 +1,16 @@
import config from '@payload-config'
import {
REST_DELETE,
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
REST_PUT,
} from '@payloadcms/next/routes'
export const GET = REST_GET(config)
export const POST = REST_POST(config)
export const DELETE = REST_DELETE(config)
export const PATCH = REST_PATCH(config)
export const PUT = REST_PUT(config)
export const OPTIONS = REST_OPTIONS(config)
@@ -0,0 +1,4 @@
import config from '@payload-config'
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes'
export const GET = GRAPHQL_PLAYGROUND_GET(config)
@@ -0,0 +1,4 @@
import config from '@payload-config'
import { GRAPHQL_POST } from '@payloadcms/next/routes'
export const POST = GRAPHQL_POST(config)
+29
View File
@@ -0,0 +1,29 @@
import type { ServerFunctionClient } from 'payload'
import config from '@payload-config'
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts'
import React from 'react'
import { importMap } from './admin/importMap'
import '@payloadcms/next/css'
type Args = {
children: React.ReactNode
}
const serverFunction: ServerFunctionClient = async function (args) {
'use server'
return handleServerFunctions({
...args,
config,
importMap,
})
}
const Layout = ({ children }: Args) => (
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
{children}
</RootLayout>
)
export default Layout
+10 -18
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import { getCollection } from '@/lib/mongodb'
import { getPayload } from 'payload'
import config from '@payload-config'
interface ContactFormData {
name: string
@@ -9,16 +10,6 @@ interface ContactFormData {
gdprConsent: boolean
}
interface ContactSubmission {
name: string
email: string
subject: string
message: string
gdprConsent: true
status: 'new'
createdAt: Date
}
// Simple spam protection - rate limiting by IP
const rateLimitMap = new Map<string, { count: number; timestamp: number }>()
const RATE_LIMIT_WINDOW = 60 * 1000 // 1 minute
@@ -127,20 +118,21 @@ export async function POST(request: NextRequest) {
)
}
const submissions = await getCollection<ContactSubmission>('contact_submissions')
const submission: ContactSubmission = {
const payload = await getPayload({ config })
const submission = await payload.create({
collection: 'contact-submissions',
data: {
...sanitizedData,
gdprConsent: true,
status: 'new',
createdAt: new Date(),
}
const result = await submissions.insertOne(submission)
},
})
return NextResponse.json(
{
message: 'Üzenet sikeresen elküldve!',
timestamp: submission.createdAt.toISOString(),
submissionId: result.insertedId.toHexString()
timestamp: submission.createdAt,
submissionId: String(submission.id)
},
{ status: 200 }
)
+27 -12
View File
@@ -3,8 +3,9 @@
* These tests focus on testing the business logic without complex mocking
*/
import { POST } from './route'
import { getPayload } from 'payload'
const mockInsertOne = jest.fn()
const mockCreate = jest.fn()
// Mock the logger to avoid complex setup
jest.mock('@/lib/logger', () => ({
@@ -15,16 +16,28 @@ jest.mock('@/lib/logger', () => ({
})
}))
jest.mock('@/lib/mongodb', () => ({
getCollection: jest.fn(async () => ({ insertOne: mockInsertOne })),
// WHY mock the resolved relative path instead of the '@payload-config' alias:
// next/jest's SWC transform rewrites the tsconfig path alias to a real
// relative specifier at transform time (Jest itself doesn't understand
// tsconfig `paths`), so a `jest.mock('@payload-config', ...)` never actually
// intercepts what route.ts ends up requiring — it silently falls through to
// the real proto/src/payload.config.ts (mongooseAdapter, Users/LegalPages/etc
// imports), which needs a live MongoDB and PAYLOAD_SECRET, exactly what these
// tests avoid. The route only ever passes this value through to the (also
// mocked) getPayload(), so its actual shape doesn't matter here.
jest.mock('../../../payload.config', () => ({ __esModule: true, default: {} }))
jest.mock('payload', () => ({
getPayload: jest.fn(async () => ({ create: mockCreate })),
}))
describe('/api/contact Unit Tests', () => {
beforeEach(() => {
mockInsertOne.mockReset()
mockCreate.mockReset()
;(getPayload as jest.Mock).mockClear()
})
describe('MongoDB persistence', () => {
describe('Payload persistence', () => {
const validData = {
name: 'Test User',
email: 'test@example.com',
@@ -34,7 +47,7 @@ describe('/api/contact Unit Tests', () => {
}
it('persists a valid submission before reporting success', async () => {
mockInsertOne.mockResolvedValue({ insertedId: { toHexString: () => 'submission-123' } })
mockCreate.mockResolvedValue({ id: 'submission-123', createdAt: '2026-09-12T00:00:00.000Z' })
const request = {
headers: new Headers({ 'x-forwarded-for': 'persistence-success' }),
json: async () => validData,
@@ -45,15 +58,17 @@ describe('/api/contact Unit Tests', () => {
expect(response.status).toBe(200)
expect(body.submissionId).toBe('submission-123')
expect(mockInsertOne).toHaveBeenCalledWith(expect.objectContaining({
expect(mockCreate).toHaveBeenCalledWith({
collection: 'contact-submissions',
data: expect.objectContaining({
...validData,
status: 'new',
createdAt: expect.any(Date),
}))
}),
})
})
it('returns a server error when persistence fails', async () => {
mockInsertOne.mockRejectedValue(new Error('MongoDB unavailable'))
mockCreate.mockRejectedValue(new Error('MongoDB unavailable'))
const request = {
headers: new Headers({ 'x-forwarded-for': 'persistence-failure' }),
json: async () => validData,
@@ -62,7 +77,7 @@ describe('/api/contact Unit Tests', () => {
const response = await POST(request)
expect(response.status).toBe(500)
expect(mockInsertOne).toHaveBeenCalledTimes(1)
expect(mockCreate).toHaveBeenCalledTimes(1)
})
it('rejects an over-long message before persisting', async () => {
@@ -75,7 +90,7 @@ describe('/api/contact Unit Tests', () => {
const response = await POST(request)
expect(response.status).toBe(400)
expect(mockInsertOne).not.toHaveBeenCalled()
expect(mockCreate).not.toHaveBeenCalled()
})
})
@@ -1,53 +0,0 @@
import { content } from '@/content'
export const metadata = {
title: `${content.pages.hasznalatiFeltetelek.title} | ${content.common.labels.features || 'mozdIT Bt.'}`,
description: 'Általános Szerződési Feltételek - Ismerje meg a mozdIT Bt. szolgáltatásainak használati feltételeit.',
}
export default function TermsOfService() {
const pageContent = content.pages.hasznalatiFeltetelek
return (
<div className="py-20 lg:py-28 max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="mb-12">
<h1
className="text-4xl md:text-5xl font-bold mb-4"
style={{ color: 'var(--color-foreground)' }}
>
{pageContent.title}
</h1>
<p
className="text-sm"
style={{ color: 'var(--color-foreground-muted)' }}
>
Utolsó frissítés: {pageContent.lastUpdated}
</p>
</div>
<div className="space-y-12">
{pageContent.sections.map((section) => (
<section key={section.id}>
<h2
className="text-2xl font-semibold mb-4"
style={{ color: 'var(--color-foreground)' }}
>
{section.title}
</h2>
<div
className="prose max-w-none"
style={{ color: 'var(--color-foreground-muted)' }}
dangerouslySetInnerHTML={{
__html: section.content
.replace(/\n\n/g, '</p><p class="mb-4">')
.replace(/• \*\*(.*?)\*\*/g, '<br/>• <strong>$1</strong>')
.replace(/^/, '<p class="mb-4">')
.replace(/$/, '</p>')
}}
/>
</section>
))}
</div>
</div>
)
}
-23
View File
@@ -1,23 +0,0 @@
import { siteConfig } from '@/config/site'
import { content } from '@/content'
import type { Metadata } from 'next'
const { contact: pageContent } = content.pages
export const metadata: Metadata = {
title: `${pageContent.meta.title} | ${siteConfig.general.name}`,
description: pageContent.meta.description,
openGraph: {
title: `${pageContent.meta.title} | ${siteConfig.general.name}`,
description: pageContent.meta.description,
url: `${siteConfig.general.url}/kapcsolat`,
},
}
export default function ContactLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}
@@ -0,0 +1,59 @@
import type { CollectionConfig } from 'payload'
/**
* MITHOME-94 — a /api/contact beérkező üzeneteinek tárolója.
*
* WHY nincs egyedi `access` blokk: a Payload alapértelmezése (csak
* bejelentkezett usernek engedélyezett minden művelet) itt pont a kívánt
* viselkedés — az ügyfél az admin felületen látja a beérkezéseket, kívülről
* senki nem olvashatja/írhatja a REST API-n keresztül. A src/app/api/contact
* route.ts a Local API-t használja (`payload.create`), ami alapból
* `overrideAccess: true`-val fut — ez a szerver-oldali írás nem ütközik az
* access control-lal, csak a publikus REST hozzáférés van letiltva.
*
* WHY nincs mezőnkénti hosszkorlát/validáció itt duplikálva: a rate limiting
* és a bemenet-validáció (hossz, email formátum, spam-szűrés) szándékosan
* az API route-on marad (lásd a ticket leírását) — ez a collection csak a
* már ellenőrzött adat tárolója.
*
* WHY nincs versions/drafts: ez tényadat (egy beérkezett üzenet), nem
* szerkesztendő tartalom — a `status` mező követi a feldolgozás állapotát.
*/
export const ContactSubmissions: CollectionConfig = {
slug: 'contact-submissions',
admin: {
useAsTitle: 'subject',
defaultColumns: ['subject', 'name', 'email', 'status', 'createdAt'],
group: 'Kapcsolatfelvételek',
},
defaultSort: '-createdAt',
fields: [
{ name: 'name', type: 'text', required: true },
{ name: 'email', type: 'text', required: true },
{ name: 'subject', type: 'text', required: true },
{ name: 'message', type: 'textarea', required: true },
{
name: 'gdprConsent',
type: 'checkbox',
required: true,
admin: {
description: 'Az űrlapon elfogadott adatkezelési hozzájárulás — a route csak true értékkel enged menteni.',
},
},
{
name: 'status',
type: 'select',
required: true,
defaultValue: 'new',
options: [
{ label: 'Új', value: 'new' },
{ label: 'Elolvasva', value: 'read' },
{ label: 'Megválaszolva', value: 'replied' },
{ label: 'Archiválva', value: 'archived' },
],
admin: {
description: 'Kézzel karbantartott feldolgozási állapot — a route mindig "new"-ként hozza létre.',
},
},
],
}
+53
View File
@@ -0,0 +1,53 @@
import type { CollectionConfig } from 'payload'
import { collectionVersions } from '../lib/payload-versions'
/**
* Mirrors LegalPageContent (proto/src/content/types.ts) — MITHOME-88.
* Slug-alapú collection a jogi oldalaknak (adatvedelem, hasznalati-feltetelek).
*
* WHY `content` textarea és nem lexical richText: a frontend
* (proto/src/components/views/LegalPageView.tsx) a szekció-tartalmat egy
* kézzel írt regex-alapú "•"/"**bold**" -> HTML konverzióval rendereli, nem
* valódi Markdown- vagy richText-parserrel. Ugyanaz a döntés, mint a
* Contact global gdpr.label mezőjénél (MITHOME-87) — a bootstrap szinten
* nem vezetünk be külön szerializációt egy még nem létező render-rétegért.
*/
export const LegalPages: CollectionConfig = {
slug: 'legal-pages',
admin: {
useAsTitle: 'title',
group: 'Oldalak',
},
versions: collectionVersions,
fields: [
{
name: 'slug',
type: 'text',
required: true,
unique: true,
admin: {
description: 'URL-azonosító, pl. "adatvedelem" vagy "hasznalati-feltetelek".',
},
},
{ name: 'title', type: 'text', required: true },
{
name: 'lastUpdated',
type: 'text',
required: true,
admin: {
description: 'Szabad szöveg (pl. "2026. augusztus 22."), nem dátum mező — így marad a JSON forrással kompatibilis.',
},
},
{
name: 'sections',
type: 'array',
required: true,
fields: [
{ name: 'id', type: 'text', required: true },
{ name: 'title', type: 'text', required: true },
{ name: 'content', type: 'textarea', required: true },
],
},
],
}
+46
View File
@@ -0,0 +1,46 @@
import type { CollectionConfig } from 'payload'
/**
* MITHOME-89 (MVP scope) — Payload beépített upload collection.
*
* WHY nincs saját `staticDir` a `proto/public/`-ba mutatva: a fájlokat a
* Payload a saját REST route-jain (src/app/(payload)/api/[...slug]/route.ts)
* szolgálja ki, nem közvetlen statikus mappából — így nem ütközik a régi
* content-editor.js-féle /public/partners/*.png fájlokkal, amik MITHOME-93
* leépítéséig még élnek.
*
* A tényleges vágás/forgatás/áttetszővé tétel szerkesztő NEM ide tartozik —
* lásd MITHOME-118 (külön, egyedi admin field component).
*/
export const Media: CollectionConfig = {
slug: 'media',
admin: {
group: 'Média',
},
// WHY explicit public read access: Payload alapértelmezés szerint minden
// collection read-je csak bejelentkezett usernek engedélyezett
// (defaultAccess = Boolean(user)) — enélkül a /api/media/file/*
// REST route 403-at adott VOLNA vissza kijelentkezve, tehát a publikus
// oldalon a partner logók (Home) sosem töltődtek volna be (a Next.js
// image-optimizer szerver-oldali proxyja sem küld auth cookie-t).
// Valódi böngészős teszttel derült ki (MITHOME-89 óta jelen lévő hiba,
// csak eddig senki nem nézte meg közelről a renderelt <img>-et). Write
// műveletek (create/update/delete) változatlanul csak bejelentkezett
// usernek engedélyezettek (Payload alapértelmezés).
access: {
read: () => true,
},
upload: {
mimeTypes: ['image/*'],
},
fields: [
{
name: 'alt',
type: 'text',
required: true,
admin: {
description: 'Alternatív szöveg (accessibility) — pl. "Angele Pihenőház logó".',
},
},
],
}
+44
View File
@@ -0,0 +1,44 @@
import type { CollectionConfig } from 'payload'
import { collectionVersions } from '../lib/payload-versions'
/**
* Mirrors home.json `partners.items` (proto/src/content/pages/home.json) —
* MITHOME-89 (MVP). Korábban a Home Global szándékosan NEM tartalmazta ezt
* a listát (lásd src/globals/Home.ts megjegyzése) — itt lesz az önálló
* collection, logo = Media upload reference.
*/
export const Partners: CollectionConfig = {
slug: 'partners',
admin: {
useAsTitle: 'name',
group: 'Oldalak',
},
versions: collectionVersions,
fields: [
{ name: 'name', type: 'text', required: true },
{ name: 'url', type: 'text', required: true },
{
name: 'logo',
type: 'upload',
relationTo: 'media',
// WHY opcionális (felhasználói döntés): így elmenthető egy partner
// név+URL-lel, logó nélkül is — pl. amíg a logó előkészítése
// (remove.bg stb.) még folyamatban van. A frontend (getPartners,
// src/lib/payload-content.ts) eleve kiszűri a logó nélküli
// partnereket, tehát ilyenkor egyszerűen nem jelenik meg a publikus
// oldalon, amíg nincs logó feltöltve — nem törik el semmi.
//
// WHY nincs beépített vágó/áttetszővé tevő szerkesztő: a régi CMS-ben
// ez kliens-oldali canvas-logika volt (scripts/cms-logo-client.js),
// amit tudatosan nem ültettünk át Payload admin komponensként —
// egyedi, ritkán használt funkcióhoz aránytalan a karbantartási teher
// (lásd MITHOME-118, lezárva). Helyette: már kész, átlátszó hátterű
// PNG-t kell feltölteni, azt egy külső eszközzel (pl. remove.bg)
// előkészítve.
admin: {
description: 'Már kész, átlátszó hátterű PNG-t tölts fel (pl. remove.bg-vel előkészítve) — az admin felület nem vág/forgat/tesz átlátszóvá. Logó nélkül is elmenthető a partner, de a publikus oldalon csak logóval jelenik meg.',
},
},
],
}
+60
View File
@@ -0,0 +1,60 @@
import type { Access, CollectionConfig } from 'payload'
/**
* Admin bejelentkezés + access control (MITHOME-90). A projekt egyetlen
* "admin" szerepkörrel dolgozik (nincs role-hierarchia — lásd MITHOME-85
* epic döntés); ez a collection az ügyfél és a fejlesztők közös, teljes
* jogú admin bejelentkezését adja.
*/
/** Bejelentkezett felhasználó — a REST/GraphQL create endpoint így nem
* használható publikus regisztrációra. Az admin UI "create first user"
* folyamata ettől függetlenül működik (Payload kivételt kezel, ha még
* nincs egyetlen user sem). */
const requireAuthenticatedUser: Access = ({ req: { user } }) => Boolean(user)
export const Users: CollectionConfig = {
slug: 'users',
auth: {
// WHY explicit (nem csak a Payload defaultra hagyva): a fiókzárolás
// szabályait tudatos döntésnek szánjuk, nem implicit könyvtár-alapértéknek.
maxLoginAttempts: 5,
lockTime: 10 * 60 * 1000, // 10 perc
cookies: {
// WHY NODE_ENV-alapú: helyi fejlesztésben (http://localhost) a secure
// cookie sosem menne át; staging/production HTTPS mögött viszont
// kötelező. MITHOME-15 (production domain/HTTPS) előfeltétele ennek
// valódi hatásba lépéséhez.
secure: process.env.NODE_ENV === 'production',
sameSite: 'Lax',
},
},
admin: {
useAsTitle: 'email',
},
// WHY minden op explicit requireAuthenticatedUser, holott ez a Payload
// defaultAccess-szel (Boolean(user)) megegyezik: az egyetlen "admin"
// szerepkör modellben ez helyes és elégséges — bármely bejelentkezett
// user már admin. Ismert, nyitott Payload advisory (GHSA-jg8r-5jh2-v2xj,
// <=3.88.0): az "unlock" op alapból bármely bejelentkezett usernek
// engedi más fiókok zárolásának feloldását — TÖBB szerepkör esetén ez
// jogosultság-átlépés lenne, de itt nincs "kevésbé jogosult" bejelentkezett
// user, akitől védeni kellene. Ha a MITHOME-46 (központi IDM/SSO) miatt
// több szerepkör/collection jön be, ezt itt újra kell értékelni.
access: {
create: requireAuthenticatedUser,
read: requireAuthenticatedUser,
update: requireAuthenticatedUser,
delete: requireAuthenticatedUser,
unlock: requireAuthenticatedUser,
},
fields: [
{
name: 'name',
type: 'text',
admin: {
description: 'Opcionális megjelenítendő név (pl. audit naplóban, admin fejlécben).',
},
},
],
}
+27 -10
View File
@@ -1,27 +1,44 @@
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import Footer from './Footer'
import { common } from '@/content'
import { common, content } from '@/content'
import { getFooterNavigation, getFooterLegalLinks, getSiteDescription } from '@/config/site'
// MITHOME-91/114: Footer lett props-alapú (locale-aware tartalom a szülő
// [locale] layoutból jön, Payload Local API-n keresztül) — a teszt a
// content/*.json fixture-öket + a valódi config/site.ts helper-eket adja
// át, hogy a viselkedés a ténylegeshez hasonló maradjon.
const footerProps = {
nav: getFooterNavigation('hu'),
legalLinks: getFooterLegalLinks('hu'),
homeHref: '/hu',
description: getSiteDescription('hu'),
contactEmail: 'info@mozdit.hu',
footerAddress: common.footer.address,
footerCopyright: common.footer.copyright,
homeServices: content.pages.home.services.items.map((item) => ({ id: item.id, title: item.title, icon: item.icon })),
locale: 'hu' as const,
}
describe('Footer', () => {
it('should render company information', () => {
render(<Footer />)
render(<Footer {...footerProps} />)
expect(screen.getByText('mozdIT Bt.')).toBeInTheDocument()
expect(screen.getByText('Megbízható web- és email szolgáltatás személyre szabott támogatással. Stabil tárhely, üzembiztos levelezés és DNS adminisztráció gyors reakcióval.')).toBeInTheDocument()
})
it('should render email and company details', () => {
render(<Footer />)
render(<Footer {...footerProps} />)
expect(screen.getByText('info@mozdit.hu')).toBeInTheDocument()
expect(screen.getAllByText('mozdIT Bt.').length).toBeGreaterThan(0)
// Address is CMS-editable (common.json footer.address) — assert the source value
// Address is CMS-editable (Payload Common global footer.address) — assert the source value
expect(screen.getByText(common.footer.address)).toBeInTheDocument()
})
it('should render navigation links', () => {
render(<Footer />)
render(<Footer {...footerProps} />)
expect(screen.getByText('Kezdőlap')).toBeInTheDocument()
expect(screen.getByText('Rólunk')).toBeInTheDocument()
@@ -30,7 +47,7 @@ describe('Footer', () => {
})
it('should render service sections', () => {
render(<Footer />)
render(<Footer {...footerProps} />)
expect(screen.getByText('Webtárhely (Hosting)')).toBeInTheDocument()
expect(screen.getByText('E-mail szolgáltatás')).toBeInTheDocument()
@@ -39,21 +56,21 @@ describe('Footer', () => {
})
it('should render copyright notice from common.json with the current year', () => {
render(<Footer />)
render(<Footer {...footerProps} />)
const currentYear = new Date().getFullYear()
expect(screen.getByText(`© 2002${currentYear} mozdIT Bt. Minden jog fenntartva.`)).toBeInTheDocument()
})
it('should render legal links', () => {
render(<Footer />)
render(<Footer {...footerProps} />)
expect(screen.getAllByText('Adatvédelmi tájékoztató')).toHaveLength(2) // Appears in both sections
expect(screen.getAllByText('Használati feltételek')).toHaveLength(2) // Appears in both sections
})
it('should render with proper grid layout', () => {
const { container } = render(<Footer />)
const { container } = render(<Footer {...footerProps} />)
const gridContainer = container.querySelector('.grid.grid-cols-1.md\\:grid-cols-4')
expect(gridContainer).toBeInTheDocument()
@@ -63,7 +80,7 @@ describe('Footer', () => {
})
it('should render with proper semantic structure', () => {
const { container } = render(<Footer />)
const { container } = render(<Footer {...footerProps} />)
// Should have a footer element
const footer = container.firstChild as HTMLElement
+36 -15
View File
@@ -1,10 +1,31 @@
'use client'
import { siteConfig } from '@/config/site'
import { content } from '@/content'
import Link from 'next/link'
import type { NavigationItem } from '@/types/site'
import type { Locale } from '@/lib/i18n'
export default function Footer() {
type FooterServiceItem = { id: string; title: string; icon: string }
type FooterProps = {
nav: NavigationItem[]
legalLinks: NavigationItem[]
homeHref: string
description: string
contactEmail: string
footerAddress: string
footerCopyright: string
homeServices: FooterServiceItem[]
locale: Locale
}
const SECTION_LABELS: Record<Locale, { navigation: string; services: string; support: string }> = {
hu: { navigation: 'Navigáció', services: 'Szolgáltatások', support: 'Műszaki támogatás' },
en: { navigation: 'Navigation', services: 'Services', support: 'Technical support' },
}
export default function Footer({ nav, legalLinks, homeHref, description, contactEmail, footerAddress, footerCopyright, homeServices, locale }: FooterProps) {
const t = SECTION_LABELS[locale]
return (
<footer
className="border-t"
@@ -18,7 +39,7 @@ export default function Footer() {
{/* Company Info */}
<div className="md:col-span-2">
<Link
href="/"
href={homeHref}
className="inline-flex items-center gap-2 text-xl font-bold mb-4 transition-colors duration-200"
style={{ color: 'var(--color-primary-600)' }}
>
@@ -32,11 +53,11 @@ export default function Footer() {
className="mb-6 max-w-md leading-relaxed"
style={{ color: 'var(--color-foreground-muted)' }}
>
{siteConfig.general.description}
{description}
</p>
<div className="space-y-2 text-sm" style={{ color: 'var(--color-foreground-muted)' }}>
<a
href={`mailto:${siteConfig.contact.email}`}
href={`mailto:${contactEmail}`}
className="flex items-center gap-2 group transition-colors duration-200 hover:text-blue-600"
style={{ color: 'var(--color-foreground-secondary)' }}
>
@@ -46,7 +67,7 @@ export default function Footer() {
>
</span>
<span>{siteConfig.contact.email}</span>
<span>{contactEmail}</span>
</a>
<div
className="flex items-center gap-2"
@@ -58,7 +79,7 @@ export default function Footer() {
>
🏢
</span>
<span>{content.common.footer.address}</span>
<span>{footerAddress}</span>
</div>
</div>
</div>
@@ -69,10 +90,10 @@ export default function Footer() {
className="text-sm font-semibold uppercase tracking-wider mb-4"
style={{ color: 'var(--color-foreground)' }}
>
Navigáció
{t.navigation}
</h3>
<ul className="space-y-3">
{siteConfig.navigation.footer.map((item) => (
{nav.map((item) => (
<li key={item.href}>
<a
href={item.href}
@@ -102,10 +123,10 @@ export default function Footer() {
className="text-sm font-semibold uppercase tracking-wider mb-4"
style={{ color: 'var(--color-foreground)' }}
>
Szolgáltatások
{t.services}
</h3>
<ul className="space-y-3">
{content.pages.home.services.items.map((service) => (
{homeServices.map((service) => (
<li
key={service.id}
className="flex items-center gap-2 text-sm"
@@ -120,7 +141,7 @@ export default function Footer() {
style={{ color: 'var(--color-foreground-muted)' }}
>
<span className="text-base">🛠</span>
Műszaki támogatás
{t.support}
</li>
</ul>
</div>
@@ -136,11 +157,11 @@ export default function Footer() {
className="text-sm"
style={{ color: 'var(--color-foreground-muted)' }}
>
{/* Copyright text is CMS-editable (common.json); {year} resolves to the current year */}
{content.common.footer.copyright.replace('{year}', String(new Date().getFullYear()))}
{/* Copyright text is CMS-editable (Payload Common global); {year} resolves to the current year */}
{footerCopyright.replace('{year}', String(new Date().getFullYear()))}
</p>
<div className="flex items-center gap-6">
{siteConfig.footer.links.map((link) => (
{legalLinks.map((link) => (
<a
key={link.href}
href={link.href}
+24 -14
View File
@@ -3,6 +3,7 @@ import '@testing-library/jest-dom'
import userEvent from '@testing-library/user-event'
import Header from './Header'
import { common } from '@/content'
import { getMainNavigation } from '@/config/site'
// Mock Next.js Link component
jest.mock('next/link', () => {
@@ -11,14 +12,23 @@ jest.mock('next/link', () => {
)
})
// MITHOME-91/114: Header lett props-alapú (locale-aware nav/a11y a szülő
// [locale] layoutból jön) — a teszt a valódi getMainNavigation('hu')-t adja
// át, hogy a feliratok/hrefek a tényleges alkalmazás-viselkedést tükrözzék.
const headerProps = {
nav: getMainNavigation('hu'),
homeHref: '/hu',
a11y: common.a11y,
}
describe('Header', () => {
it('should render the company logo', () => {
render(<Header />)
render(<Header {...headerProps} />)
expect(screen.getByAltText('mozdIT Bt.')).toBeInTheDocument()
})
it('should render all navigation links in desktop menu', () => {
render(<Header />)
render(<Header {...headerProps} />)
// Desktop menu should contain all links with specific structures
const desktopMenu = document.querySelector('.hidden.md\\:flex')
@@ -32,7 +42,7 @@ describe('Header', () => {
})
it('should render contact button with correct styling', () => {
render(<Header />)
render(<Header {...headerProps} />)
const contactButtons = screen.getAllByText('Kapcsolat')
expect(contactButtons.length).toBeGreaterThan(0)
@@ -49,7 +59,7 @@ describe('Header', () => {
})
it('should render hamburger menu button on mobile', () => {
render(<Header />)
render(<Header {...headerProps} />)
// The hamburger menu button is hidden by default in desktop view
// We can test its presence even if not visible
@@ -58,14 +68,14 @@ describe('Header', () => {
})
it('should have proper accessibility attributes', () => {
render(<Header />)
render(<Header {...headerProps} />)
const hamburgerButton = screen.getByRole('button', { name: new RegExp(common.a11y.openMenu, 'i') })
expect(hamburgerButton).toHaveAttribute('aria-expanded', 'false')
})
it('should render with proper semantic structure', () => {
const { container } = render(<Header />)
const { container } = render(<Header {...headerProps} />)
// Should have header element with proper structure
const header = container.firstChild as HTMLElement
@@ -81,7 +91,7 @@ describe('Header', () => {
it('should toggle mobile menu when hamburger button is clicked', async () => {
const user = userEvent.setup()
render(<Header />)
render(<Header {...headerProps} />)
const hamburgerButton = screen.getByRole('button', { name: new RegExp(common.a11y.openMenu, 'i') })
@@ -99,7 +109,7 @@ describe('Header', () => {
it('should close mobile menu when navigation link is clicked', async () => {
const user = userEvent.setup()
render(<Header />)
render(<Header {...headerProps} />)
const hamburgerButton = screen.getByRole('button', { name: new RegExp(common.a11y.openMenu, 'i') })
@@ -120,31 +130,31 @@ describe('Header', () => {
})
it('should have correct navigation links with proper hrefs', () => {
render(<Header />)
render(<Header {...headerProps} />)
// Check for home link
const homeLinks = screen.getAllByText('Kezdőlap')
expect(homeLinks.length).toBeGreaterThan(0)
expect(homeLinks[0].closest('a')).toHaveAttribute('href', '/')
expect(homeLinks[0].closest('a')).toHaveAttribute('href', '/hu')
// Check for about link
const aboutLinks = screen.getAllByText('Rólunk')
expect(aboutLinks.length).toBeGreaterThan(0)
expect(aboutLinks[0].closest('a')).toHaveAttribute('href', '/rolunk')
expect(aboutLinks[0].closest('a')).toHaveAttribute('href', '/hu/rolunk')
// Check for services link
const servicesLinks = screen.getAllByText('Szolgáltatások')
expect(servicesLinks.length).toBeGreaterThan(0)
expect(servicesLinks[0].closest('a')).toHaveAttribute('href', '/szolgaltatasok')
expect(servicesLinks[0].closest('a')).toHaveAttribute('href', '/hu/szolgaltatasok')
// Check for contact link
const contactLinks = screen.getAllByText('Kapcsolat')
expect(contactLinks.length).toBeGreaterThan(0)
expect(contactLinks[0].closest('a')).toHaveAttribute('href', '/kapcsolat')
expect(contactLinks[0].closest('a')).toHaveAttribute('href', '/hu/kapcsolat')
})
it('should have proper responsive classes', () => {
const { container } = render(<Header />)
const { container } = render(<Header {...headerProps} />)
// Desktop menu should be hidden on mobile
const desktopMenu = container.querySelector('.hidden.md\\:flex')
+12 -6
View File
@@ -1,13 +1,19 @@
'use client'
import { siteConfig } from '@/config/site'
import { common } from '@/content'
import { useState, useEffect } from 'react'
import { ThemeToggle } from './ThemeProvider'
import Link from 'next/link'
import Image from 'next/image'
import type { NavigationItem } from '@/types/site'
export default function Header() {
type HeaderProps = {
nav: NavigationItem[]
homeHref: string
a11y: { openMenu: string; closeMenu: string }
}
export default function Header({ nav, homeHref, a11y }: HeaderProps) {
const [isMenuOpen, setIsMenuOpen] = useState(false)
const [isScrolled, setIsScrolled] = useState(false)
@@ -49,7 +55,7 @@ export default function Header() {
{/* Logo */}
<div className="flex-shrink-0">
<Link
href="/"
href={homeHref}
className="group flex items-center gap-2 transition-all duration-200"
>
<Image
@@ -64,7 +70,7 @@ export default function Header() {
{/* Desktop Navigation */}
<div className="hidden md:flex items-center space-x-1">
{siteConfig.navigation.main.map((item) => (
{nav.map((item) => (
<a
key={item.href}
href={item.href}
@@ -115,7 +121,7 @@ export default function Header() {
}}
aria-expanded={isMenuOpen}
>
<span className="sr-only">{isMenuOpen ? common.a11y.closeMenu : common.a11y.openMenu}</span>
<span className="sr-only">{isMenuOpen ? a11y.closeMenu : a11y.openMenu}</span>
<div className="relative w-6 h-6">
{/* Hamburger to X animation */}
<span
@@ -151,7 +157,7 @@ export default function Header() {
className="py-3 space-y-1 border-t"
style={{ borderColor: 'var(--color-border)' }}
>
{siteConfig.navigation.main.map((item, index) => (
{nav.map((item, index) => (
<a
key={item.href}
href={item.href}
+9
View File
@@ -38,6 +38,13 @@ export function ThemeProvider({ children, defaultTheme = 'system' }: ThemeProvid
const [mounted, setMounted] = useState(false)
useEffect(() => {
// WHY: this effect synchronizes React state with two external systems —
// the DOM (hydration-safe mount flag) and localStorage (persisted theme
// preference) — read once on mount. There is no subscription to wrap the
// setState calls in, so the new react-hooks/set-state-in-effect rule
// (added with the Next.js 16 / eslint-config-next upgrade) is a false
// positive here; suppress rather than restructure a working component.
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true)
const savedTheme = localStorage.getItem('theme') as Theme | null
if (savedTheme) {
@@ -59,6 +66,8 @@ export function ThemeProvider({ children, defaultTheme = 'system' }: ThemeProvid
root.setAttribute('data-theme', theme)
}
// WHY: syncs React state with the resolved DOM/OS theme — see note above.
// eslint-disable-next-line react-hooks/set-state-in-effect
setResolvedTheme(resolved)
}, [theme, mounted])
+308
View File
@@ -0,0 +1,308 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { siteConfig } from '@/config/site'
/**
* MITHOME-120 — teljes szöveges gyorskeresés a Payload admin tetején.
*
* WHY kliens-oldali, egyszerű megoldás és nem `@payloadcms/plugin-search`:
* az a plugin egy külön "search" collection-t tart karban hookokkal
* szinkronban — szerver-oldali index, extra karbantartási teher. Ennek a
* projektnek 5 Global + 2 kis Collection a teljes tartalma (lásd
* payload.config.ts) — ennyi dokumentumnál egyszerűbb és megbízhatóbb
* minden alkalommal frissen lekérdezni a REST API-t (a bejelentkezett admin
* session-jével, cookie-alapú auth, nincs külön hitelesítési logika itt),
* kliens-oldalon szöveges mezőkre lapítani, és substring-alapján szűrni.
*
* Regisztrálva: payload.config.ts `admin.components.header` — minden admin
* oldalon megjelenik. Az importMap.js-t a `payload generate:importmap`
* generálja újra, ha ez a fájl elmozdul/átnevezik.
*
* A keresés mellett egy "Honlap megnyitása" link is itt kapott helyet
* (felhasználói kérés) — korábban semmilyen link nem vezetett az admin
* felületről a publikus oldalra. `siteConfig.general.url` a
* NEXT_PUBLIC_SITE_URL-ből jön, tehát környezetenként (dev/staging/prod)
* automatikusan a helyes címre mutat.
*/
type Locale = 'hu' | 'en'
const LOCALES: Locale[] = ['hu', 'en']
type SearchTarget =
| { type: 'global'; slug: string; label: string }
| { type: 'collection'; slug: string; label: string }
// A projekt tényleges Globals/Collections listája (payload.config.ts) —
// szándékosan nincs dinamikusan introspektálva, mert ahhoz szerver-oldali
// config-hozzáférés kellene ebből a kliens komponensből.
const SEARCH_TARGETS: SearchTarget[] = [
{ type: 'global', slug: 'home', label: 'Home' },
{ type: 'global', slug: 'about', label: 'About' },
{ type: 'global', slug: 'services', label: 'Services' },
{ type: 'global', slug: 'contact', label: 'Contact' },
{ type: 'global', slug: 'common', label: 'Common' },
{ type: 'collection', slug: 'legal-pages', label: 'Legal Pages' },
{ type: 'collection', slug: 'partners', label: 'Partners' },
]
// Payload belső/rendszer mezői — nem érdekesek szöveges keresésre, és csak
// zajt jelentenének (id-k, időbélyegek, belső flag-ek).
const SKIP_KEYS = new Set([
'id',
'_id',
'createdAt',
'updatedAt',
'globalType',
'blockType',
'_status',
'sizes',
])
type SearchEntry = {
key: string
source: string
editHref: string
locale: Locale
fieldPath: string
value: string
}
function flatten(
value: unknown,
path: string,
out: { fieldPath: string; value: string }[]
): void {
if (value == null) return
if (typeof value === 'string') {
if (value.trim().length > 0) out.push({ fieldPath: path, value })
return
}
if (typeof value === 'number' || typeof value === 'boolean') return
if (Array.isArray(value)) {
value.forEach((item, index) => flatten(item, `${path}[${index}]`, out))
return
}
if (typeof value === 'object') {
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
if (SKIP_KEYS.has(key)) continue
flatten(val, path ? `${path}.${key}` : key, out)
}
}
}
async function fetchIndex(): Promise<SearchEntry[]> {
const entries: SearchEntry[] = []
await Promise.all(
SEARCH_TARGETS.flatMap((target) =>
LOCALES.map(async (locale) => {
try {
if (target.type === 'global') {
const res = await fetch(`/api/globals/${target.slug}?locale=${locale}&depth=0`, {
credentials: 'include',
})
if (!res.ok) return
const doc = await res.json()
const flat: { fieldPath: string; value: string }[] = []
flatten(doc, '', flat)
for (const f of flat) {
entries.push({
key: `${target.slug}:${locale}:${f.fieldPath}`,
source: target.label,
editHref: `/admin/globals/${target.slug}`,
locale,
fieldPath: f.fieldPath,
value: f.value,
})
}
} else {
const res = await fetch(
`/api/${target.slug}?locale=${locale}&depth=0&limit=200`,
{ credentials: 'include' }
)
if (!res.ok) return
const { docs } = await res.json()
for (const doc of docs ?? []) {
const flat: { fieldPath: string; value: string }[] = []
flatten(doc, '', flat)
for (const f of flat) {
entries.push({
key: `${target.slug}:${doc.id}:${locale}:${f.fieldPath}`,
source: `${target.label}${doc.name ?? doc.title ?? doc.id}`,
editHref: `/admin/collections/${target.slug}/${doc.id}`,
locale,
fieldPath: f.fieldPath,
value: f.value,
})
}
}
}
} catch {
// Egy célpont hibája (pl. időleges hálózati hiba) ne akassza meg a
// többi találatot — csendben kihagyjuk.
}
})
)
)
return entries
}
function snippetAround(value: string, query: string, radius = 40): string {
const idx = value.toLowerCase().indexOf(query.toLowerCase())
if (idx === -1) return value.length > 80 ? `${value.slice(0, 80)}` : value
const start = Math.max(0, idx - radius)
const end = Math.min(value.length, idx + query.length + radius)
const prefix = start > 0 ? '…' : ''
const suffix = end < value.length ? '…' : ''
return `${prefix}${value.slice(start, end)}${suffix}`
}
export function QuickSearch() {
const [query, setQuery] = useState('')
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
// WHY state és nem ref: a keresési index közvetlenül a render kimenetét
// (a találati listát) befolyásolja, tehát a react-hooks/refs szabály
// szerint is state-nek kell lennie, nem ref-nek (ref olvasása render
// közben nem váltana ki újra-renderelést, ha közben módosulna).
const [index, setIndex] = useState<SearchEntry[] | null>(null)
// Csak azt jelzi, hogy a fetch elindult-e már — ez NEM befolyásolja a
// render kimenetét, csak elkerüli a duplikált egyidejű lekérdezést, ezért
// maradhat ref.
const fetchStartedRef = useRef(false)
const containerRef = useRef<HTMLDivElement>(null)
const ensureIndex = useCallback(async () => {
if (fetchStartedRef.current) return
fetchStartedRef.current = true
setLoading(true)
try {
const entries = await fetchIndex()
setIndex(entries)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
function onClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', onClickOutside)
return () => document.removeEventListener('mousedown', onClickOutside)
}, [])
const results =
query.trim().length >= 2 && index
? index.filter((e) => e.value.toLowerCase().includes(query.toLowerCase())).slice(0, 40)
: []
return (
<div
ref={containerRef}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
maxWidth: 620,
margin: '0 auto',
padding: '10px 16px',
}}
>
<div style={{ position: 'relative', flex: 1 }}>
<input
type="search"
value={query}
placeholder="🔍 Keresés a teljes tartalomban… (min. 2 karakter)"
onFocus={() => {
setOpen(true)
void ensureIndex()
}}
onChange={(e) => {
setQuery(e.target.value)
setOpen(true)
void ensureIndex()
}}
style={{
width: '100%',
boxSizing: 'border-box',
padding: '8px 12px',
fontSize: 14,
borderRadius: 4,
border: '1px solid var(--theme-elevation-150, #ccc)',
background: 'var(--theme-input-bg, #fff)',
color: 'var(--theme-text, #000)',
}}
/>
{open && query.trim().length >= 2 && (
<div
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
marginTop: 4,
maxHeight: 420,
overflowY: 'auto',
background: 'var(--theme-elevation-0, #fff)',
border: '1px solid var(--theme-elevation-150, #ccc)',
borderRadius: 4,
boxShadow: '0 4px 16px rgba(0,0,0,0.15)',
zIndex: 100,
}}
>
{loading && !index && (
<div style={{ padding: 12, fontSize: 13, opacity: 0.7 }}>Tartalom betöltése</div>
)}
{index && results.length === 0 && (
<div style={{ padding: 12, fontSize: 13, opacity: 0.7 }}>Nincs találat.</div>
)}
{results.map((r) => (
<Link
key={r.key}
href={r.editHref}
onClick={() => setOpen(false)}
style={{
display: 'block',
padding: '8px 12px',
borderBottom: '1px solid var(--theme-elevation-100, #eee)',
textDecoration: 'none',
color: 'inherit',
}}
>
<div style={{ fontSize: 12, opacity: 0.65 }}>
{r.source} · {r.fieldPath} · {r.locale}
</div>
<div style={{ fontSize: 14 }}>{snippetAround(r.value, query)}</div>
</Link>
))}
</div>
)}
</div>
<a
href={siteConfig.general.url}
target="_blank"
rel="noopener noreferrer"
style={{
flexShrink: 0,
padding: '8px 12px',
fontSize: 14,
borderRadius: 4,
border: '1px solid var(--theme-elevation-150, #ccc)',
color: 'var(--theme-text, #000)',
textDecoration: 'none',
whiteSpace: 'nowrap',
}}
>
🌐 Honlap megnyitása
</a>
</div>
)
}
export default QuickSearch
@@ -1,20 +1,13 @@
import { siteConfig } from '@/config/site'
import { content } from '@/content'
import type { Metadata } from 'next'
import { localePath, type Locale } from '@/lib/i18n'
import type { getAboutContent } from '@/lib/payload-content'
const { about: pageContent } = content.pages
export const metadata: Metadata = {
title: `${pageContent.meta.title} | ${siteConfig.general.name}`,
description: pageContent.meta.description,
openGraph: {
title: `${pageContent.meta.title} | ${siteConfig.general.name}`,
description: pageContent.meta.ogDescription,
url: `${siteConfig.general.url}/rolunk`,
},
type AboutViewProps = {
content: Awaited<ReturnType<typeof getAboutContent>>
locale: Locale
}
export default function AboutPage() {
export default function AboutView({ content: pageContent, locale }: AboutViewProps) {
const contactHref = localePath(locale, 'contact')
return (
<div className="space-y-0">
{/* Hero Section */}
@@ -92,7 +85,7 @@ export default function AboutPage() {
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 stagger-children">
{pageContent.mission.values.map((item, index) => (
{(pageContent.mission.values ?? []).map((item, index) => (
<div
key={item.title}
className="group text-center p-8 rounded-xl hover-lift animate-fade-in-up"
@@ -182,7 +175,7 @@ export default function AboutPage() {
{pageContent.cta.subtitle}
</p>
<a
href="/kapcsolat"
href={contactHref}
className="btn btn-primary text-lg px-8 py-4 hover-glow"
>
{pageContent.cta.button}
@@ -1,12 +1,25 @@
'use client'
import { siteConfig } from '@/config/site'
import { content } from '@/content'
import { useState } from 'react'
import { siteConfig } from '@/config/site'
import { localePath, type Locale } from '@/lib/i18n'
import type { getContactContent } from '@/lib/payload-content'
const { contact: pageContent } = content.pages
type ContactViewProps = {
content: Awaited<ReturnType<typeof getContactContent>>
contactEmail: string
footerAddress: string
webmailHref: string
locale: Locale
}
export default function ContactPage() {
export default function ContactView({ content: pageContent, contactEmail, footerAddress, webmailHref, locale }: ContactViewProps) {
// WHY placeholder-csere: a GDPR-szöveg (Payload Contact.form.fields.gdpr.label)
// egy {privacyHref} tokent tartalmaz a beágyazott <a> linkben, mert a
// tényleges adatvédelmi oldal útvonala nyelvenként eltér (MITHOME-114) — a
// korábbi, hardcode-olt "/adatkezelesi-tajekoztato" út sosem egyezett a
// valódi oldallal, ez javítja azt is.
const gdprLabel = pageContent.form.fields.gdpr.label.replace('{privacyHref}', localePath(locale, 'privacy'))
const [formData, setFormData] = useState({
name: '',
email: '',
@@ -135,7 +148,7 @@ export default function ContactPage() {
{submitStatus === 'error' && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-md">
<p className="text-red-800">
{pageContent.form.errorMessage.replace('{email}', siteConfig.contact.email)}
{pageContent.form.errorMessage.replace('{email}', contactEmail)}
</p>
</div>
)}
@@ -224,7 +237,7 @@ export default function ContactPage() {
/>
<span
className="text-sm text-gray-700"
dangerouslySetInnerHTML={{ __html: pageContent.form.fields.gdpr.label + ' *' }}
dangerouslySetInnerHTML={{ __html: gdprLabel + ' *' }}
/>
</label>
{errors.gdprConsent && <p className="mt-1 text-sm text-red-600">{errors.gdprConsent}</p>}
@@ -256,10 +269,10 @@ export default function ContactPage() {
<div>
<h3 className="font-semibold text-gray-900 mb-1">{pageContent.info.email.title}</h3>
<a
href={`mailto:${siteConfig.contact.email}`}
href={`mailto:${contactEmail}`}
className="text-blue-600 hover:text-blue-700"
>
{siteConfig.contact.email}
{contactEmail}
</a>
<p className="text-sm text-gray-600 mt-1">
{pageContent.info.email.responseTime}
@@ -274,7 +287,7 @@ export default function ContactPage() {
<div>
<h3 className="font-semibold text-gray-900 mb-1">{pageContent.info.company.title}</h3>
<p className="text-gray-700">{siteConfig.general.name}</p>
<p className="text-sm text-gray-600">{content.common.footer.address}</p>
<p className="text-sm text-gray-600">{footerAddress}</p>
</div>
</div>
@@ -285,7 +298,11 @@ export default function ContactPage() {
<div>
<h3 className="font-semibold text-gray-900 mb-1">{pageContent.info.webmail.title}</h3>
<a
href={content.pages.home.hero.cta.primary.href}
// WHY webmailHref: a migráció előtti kód itt is a
// "/kapcsolat" hrefet használta (ugyanaz a bug, mint a
// Szolgáltatások oldalon) — javítva a tényleges webmail
// URL-re.
href={webmailHref}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-700"
@@ -307,7 +324,7 @@ export default function ContactPage() {
</h2>
<div className="space-y-4">
{pageContent.faq.items.map((item, index) => (
{(pageContent.faq.items ?? []).map((item, index) => (
<div key={index}>
<h3 className="font-semibold text-gray-900 mb-2">{item.question}</h3>
<p className="text-gray-600 text-sm">{item.answer}</p>
@@ -1,9 +1,25 @@
import { content } from '@/content'
import Image from 'next/image'
import { localePath, type Locale } from '@/lib/i18n'
import type { getHomeContent, PartnerView } from '@/lib/payload-content'
const { home: pageContent } = content.pages
type HomeViewProps = {
content: Awaited<ReturnType<typeof getHomeContent>>
partners: PartnerView[]
locale: Locale
}
export default function Home() {
// WHY hardcoded itt: a Partners collection (MITHOME-89) csak name/url/logo-t
// tárol, a szekció saját címe/alcíme sosem volt Payload-tartalom (a Home
// Globalból is szándékosan kimaradt, lásd src/globals/Home.ts) — ugyanaz a
// minta, mint a config/site.ts navigáció-feliratoknál.
const PARTNERS_COPY: Record<Locale, { title: string; subtitle: string }> = {
hu: { title: 'Partnereink', subtitle: 'Akikkel együtt dolgozunk' },
en: { title: 'Our Partners', subtitle: 'Who we work with' },
}
export default function HomeView({ content: pageContent, partners, locale }: HomeViewProps) {
const contactHref = localePath(locale, 'contact')
const partnersCopy = PARTNERS_COPY[locale]
return (
<div className="space-y-0">
{/* Hero Section */}
@@ -94,7 +110,7 @@ export default function Home() {
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 stagger-children">
{pageContent.about.usps.map((usp, index) => (
{(pageContent.about.usps ?? []).map((usp, index) => (
<div
key={usp.id}
className="group text-center p-6 rounded-xl transition-all duration-300 hover-lift animate-fade-in-up"
@@ -191,7 +207,7 @@ export default function Home() {
</ul>
</div>
<a
href="/kapcsolat"
href={contactHref}
className="inline-flex items-center font-medium transition-all duration-200 group/link"
style={{ color: 'var(--color-primary-600)' }}
>
@@ -212,17 +228,23 @@ export default function Home() {
</section>
{/* Partners Section */}
{pageContent.partners.items.length > 0 && (
{partners.length > 0 && (
<section className="py-16">
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 className="text-3xl font-bold text-center mb-3" style={{ color: 'var(--color-foreground)' }}>
{pageContent.partners.title}
{partnersCopy.title}
</h2>
<p className="text-center mb-10" style={{ color: 'var(--color-foreground-muted)' }}>
{pageContent.partners.subtitle}
{partnersCopy.subtitle}
</p>
<div className="flex flex-wrap justify-center items-center gap-10">
{pageContent.partners.items.map((partner) => (
{/* WHY grid + w-fit + mx-auto: a felhasználó kérésére valódi
mátrix (max 3 oszlop), nem folyó flex-wrap. `w-fit` zsugorítja
a rácsot a tényleges (legfeljebb 3 oszlopnyi) tartalom
szélességére, a `mx-auto` pedig ettől tudja középre venni
sima `inline-grid`-en az auto margó nem középre igazítana,
mert az egy inline-szintű doboz. */}
<div className="grid w-fit grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-10 justify-items-center mx-auto">
{partners.map((partner) => (
<a
key={partner.name}
href={partner.url}
@@ -233,8 +255,8 @@ export default function Home() {
>
<span className="relative h-12 w-40">
<Image
src={partner.logo}
alt={partner.name}
src={partner.logo.url}
alt={partner.logo.alt}
fill
sizes="160px"
className="object-contain"
@@ -285,7 +307,7 @@ export default function Home() {
{pageContent.cta.subtitle}
</p>
<a
href="/kapcsolat"
href={contactHref}
className="btn btn-primary text-lg px-8 py-4 hover-glow"
>
{pageContent.cta.button}
@@ -1,13 +1,19 @@
import { content } from '@/content'
import type { Locale } from '@/lib/i18n'
import type { getLegalPage } from '@/lib/payload-content'
export const metadata = {
title: `${content.pages.adatvedelem.title} | ${content.common.labels.features || 'mozdIT Bt.'}`,
description: 'Adatvédelmi tájékoztató - ismerje meg, hogyan kezeljük személyes adatait.',
type LegalPageViewProps = {
page: NonNullable<Awaited<ReturnType<typeof getLegalPage>>>
locale: Locale
}
export default function PrivacyPolicy() {
const pageContent = content.pages.adatvedelem
const LAST_UPDATED_LABEL: Record<Locale, string> = {
hu: 'Utolsó frissítés',
en: 'Last updated',
}
/** Közös nézet az adatvédelmi tájékoztatóhoz és a használati feltételekhez
* mindkettő azonos szerkezetű (title/lastUpdated/sections). */
export default function LegalPageView({ page, locale }: LegalPageViewProps) {
return (
<div className="py-20 lg:py-28 max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="mb-12">
@@ -15,18 +21,18 @@ export default function PrivacyPolicy() {
className="text-4xl md:text-5xl font-bold mb-4"
style={{ color: 'var(--color-foreground)' }}
>
{pageContent.title}
{page.title}
</h1>
<p
className="text-sm"
style={{ color: 'var(--color-foreground-muted)' }}
>
Utolsó frissítés: {pageContent.lastUpdated}
{LAST_UPDATED_LABEL[locale]}: {page.lastUpdated}
</p>
</div>
<div className="space-y-12">
{pageContent.sections.map((section) => (
{page.sections.map((section) => (
<section key={section.id}>
<h2
className="text-2xl font-semibold mb-4"
@@ -1,20 +1,16 @@
import { siteConfig } from '@/config/site'
import { content } from '@/content'
import type { Metadata } from 'next'
import { localePath, type Locale } from '@/lib/i18n'
import type { getHomeContent, getServicesContent } from '@/lib/payload-content'
const { services: pageContent } = content.pages
export const metadata: Metadata = {
title: `${pageContent.meta.title} | ${siteConfig.general.name}`,
description: pageContent.meta.description,
openGraph: {
title: `${pageContent.meta.title} | ${siteConfig.general.name}`,
description: pageContent.meta.ogDescription,
url: `${siteConfig.general.url}/szolgaltatasok`,
},
type ServicesViewProps = {
content: Awaited<ReturnType<typeof getServicesContent>>
homeServices: Awaited<ReturnType<typeof getHomeContent>>['services']['items']
featuresLabel: string
webmailHref: string
locale: Locale
}
export default function ServicesPage() {
export default function ServicesView({ content: pageContent, homeServices, featuresLabel, webmailHref, locale }: ServicesViewProps) {
const contactHref = localePath(locale, 'contact')
return (
<div className="space-y-16 py-8">
{/* Hero Section */}
@@ -32,7 +28,7 @@ export default function ServicesPage() {
{/* Services Grid */}
<section className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{content.pages.home.services.items.map((service) => (
{homeServices.map((service) => (
<div key={service.id} className="bg-white rounded-xl shadow-sm border border-gray-200 p-8 hover:shadow-md transition-shadow">
<div className="w-16 h-16 bg-blue-100 rounded-lg flex items-center justify-center mb-6">
<span className="text-2xl">{service.icon}</span>
@@ -42,7 +38,7 @@ export default function ServicesPage() {
<p className="text-gray-600 leading-relaxed mb-6">{service.description}</p>
<div className="mb-6">
<h3 className="text-lg font-semibold text-gray-900 mb-3">{content.common.labels.features}</h3>
<h3 className="text-lg font-semibold text-gray-900 mb-3">{featuresLabel}</h3>
<ul className="space-y-2">
{service.features.map((feature, index) => (
<li key={index} className="flex items-start">
@@ -54,7 +50,7 @@ export default function ServicesPage() {
</div>
<a
href="/kapcsolat"
href={contactHref}
className="inline-flex items-center text-blue-600 hover:text-blue-700 font-medium transition-colors"
>
{service.ctaText}
@@ -115,7 +111,7 @@ export default function ServicesPage() {
{pageContent.support.subtitle}
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 text-sm">
{pageContent.support.channels.map((channel) => (
{(pageContent.support.channels ?? []).map((channel) => (
<div key={channel.title}>
<h3 className="font-semibold text-gray-900 mb-2">{channel.title}</h3>
<p className="text-gray-600">{channel.description}</p>
@@ -136,13 +132,17 @@ export default function ServicesPage() {
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<a
href="/kapcsolat"
href={contactHref}
className="inline-block bg-blue-600 hover:bg-blue-700 text-white font-medium px-8 py-3 rounded-md transition-colors"
>
{pageContent.cta.primaryButton}
</a>
<a
href={content.pages.home.hero.cta.primary.href}
// WHY webmailHref és nem "/kapcsolat" ismét: a migráció előtti
// kód itt (bugként) a primary CTA hrefjét (/kapcsolat) használta
// target="_blank"-kal a "Webmail belépés" gombhoz — javítva a
// tényleges webmail URL-re.
href={webmailHref}
target="_blank"
rel="noopener noreferrer"
className="inline-block border-2 border-white text-white hover:bg-white hover:text-gray-900 font-medium px-8 py-3 rounded-md transition-colors"
+62 -36
View File
@@ -1,47 +1,73 @@
import { SiteConfig } from '@/types/site'
import type { NavigationItem } from '@/types/site'
import { localePath, type Locale } from '@/lib/i18n'
/**
* Site Configuration - Centralized configuration for all public content
* All content is easily modifiable without code changes
* This structure supports easy expansion for CMS integration later
* Site Configuration - statikus, nyelvfüggetlen alapadatok.
*
* MITHOME-91/114: a navigáció és a lábláb jogi linkjei nyelvfüggővé váltak
* (a szlögök nyelvenként eltérnek — lásd src/lib/i18n.ts PAGE_SLUGS), ezért
* ezek most függvények, nem statikus tömbök. A navigáció-feliratok itt
* maradnak (nem Payload-tartalom) — ez sosem volt része a JSON content
* rendszernek, csak ez a config fájl, ezért a MITHOME-91 hatóköre ("JSON
* content rendszer kivezetése") nem érinti; a hu/en feliratpárok itt kézzel
* tartott, statikus fordítások.
*/
export const siteConfig: SiteConfig = {
export const siteConfig = {
general: {
name: 'mozdIT Bt.',
description: 'Megbízható web- és email szolgáltatás személyre szabott támogatással. Stabil tárhely, üzembiztos levelezés és DNS adminisztráció gyors reakcióval.',
url: process.env.NEXT_PUBLIC_SITE_URL || 'https://localhost:3000',
ogImage: '/mozdit_logo_text.png',
locale: 'hu-HU'
},
navigation: {
main: [
{ label: 'Kezdőlap', href: '/' },
{ label: 'Rólunk', href: '/rolunk' },
{ label: 'Szolgáltatások', href: '/szolgaltatasok' },
{ label: 'Kapcsolat', href: '/kapcsolat', primary: true }
],
footer: [
{ label: 'Kezdőlap', href: '/' },
{ label: 'Rólunk', href: '/rolunk' },
{ label: 'Szolgáltatások', href: '/szolgaltatasok' },
{ label: 'Kapcsolat', href: '/kapcsolat' },
{ label: 'Adatvédelmi tájékoztató', href: '/adatvedelem' },
{ label: 'Használati feltételek', href: '/felhasznalasi-feltetelek' }
]
},
footer: {
// Copyright text lives in content/common.json (CMS-editable) — only links remain here.
links: [
{ label: 'Adatvédelmi tájékoztató', href: '/adatvedelem' },
{ label: 'Használati feltételek', href: '/felhasznalasi-feltetelek' }
]
},
contact: {
email: process.env.NEXT_PUBLIC_CONTACT_EMAIL || 'info@mozdit.hu',
// address lives in content/common.json (footer.address) — CMS-editable.
// The form fields live in content/pages/contact.json.
}
// address lives in the Payload Common global (footer.address) — CMS-editable.
},
}
const DESCRIPTIONS: Record<Locale, string> = {
hu: 'Megbízható web- és email szolgáltatás személyre szabott támogatással. Stabil tárhely, üzembiztos levelezés és DNS adminisztráció gyors reakcióval.',
en: 'Reliable web hosting and business email with personal support. Stable hosting, dependable mail delivery and DNS administration with a fast response.',
}
export function getSiteDescription(locale: Locale): string {
return DESCRIPTIONS[locale]
}
export function getOgLocale(locale: Locale): string {
return locale === 'hu' ? 'hu-HU' : 'en-US'
}
const NAV_LABELS: Record<Locale, { home: string; about: string; services: string; contact: string; privacy: string; terms: string }> = {
hu: { home: 'Kezdőlap', about: 'Rólunk', services: 'Szolgáltatások', contact: 'Kapcsolat', privacy: 'Adatvédelmi tájékoztató', terms: 'Használati feltételek' },
en: { home: 'Home', about: 'About', services: 'Services', contact: 'Contact', privacy: 'Privacy Policy', terms: 'Terms of Service' },
}
export function getMainNavigation(locale: Locale): NavigationItem[] {
const t = NAV_LABELS[locale]
return [
{ label: t.home, href: localePath(locale) },
{ label: t.about, href: localePath(locale, 'about') },
{ label: t.services, href: localePath(locale, 'services') },
{ label: t.contact, href: localePath(locale, 'contact'), primary: true },
]
}
export function getFooterNavigation(locale: Locale): NavigationItem[] {
const t = NAV_LABELS[locale]
return [
{ label: t.home, href: localePath(locale) },
{ label: t.about, href: localePath(locale, 'about') },
{ label: t.services, href: localePath(locale, 'services') },
{ label: t.contact, href: localePath(locale, 'contact') },
{ label: t.privacy, href: localePath(locale, 'privacy') },
{ label: t.terms, href: localePath(locale, 'terms') },
]
}
export function getFooterLegalLinks(locale: Locale): NavigationItem[] {
const t = NAV_LABELS[locale]
return [
{ label: t.privacy, href: localePath(locale, 'privacy') },
{ label: t.terms, href: localePath(locale, 'terms') },
]
}
+1 -1
View File
@@ -35,7 +35,7 @@
"errorMinLength": "Az üzenet legalább 10 karakter hosszú legyen"
},
"gdpr": {
"label": "Elfogadom az <a href=\"/adatkezelesi-tajekoztato\" class=\"text-blue-600 hover:text-blue-700 underline\">adatkezelési tájékoztatót</a> és hozzájárulok személyes adataim kezeléséhez a kapcsolatfelvétel céljából.",
"label": "Elfogadom az <a href=\"{privacyHref}\" class=\"text-blue-600 hover:text-blue-700 underline\">adatkezelési tájékoztatót</a> és hozzájárulok személyes adataim kezeléséhez a kapcsolatfelvétel céljából.",
"error": "Az adatkezelési tájékoztató elfogadása kötelező"
}
},
+6 -2
View File
@@ -1,5 +1,9 @@
// Shared runtime schema for the content JSON files.
// Kept dependency-free so it can run in both Next.js and content-editor.js.
// Shared runtime schema for the content JSON files (proto/src/content/*.json).
// Kept dependency-free — used by src/content/index.ts (test fixtures for
// Header/Footer, MITHOME-96) and scripts/test-content-schema.js. The JSON
// files themselves remain the source for scripts/migrate-content-to-payload.ts.
// The standalone content-editor.js CMS that used to run this too was retired
// in MITHOME-93 (superseded by Payload CMS).
const string = { type: 'string' };
const boolean = { type: 'boolean' };
const array = items => ({ type: 'array', items });
+75
View File
@@ -0,0 +1,75 @@
import type { GlobalConfig } from 'payload'
import { stringArrayField } from './fields/stringArray'
import { globalVersions } from '../lib/payload-versions'
/** Mirrors AboutPageContent (proto/src/content/types.ts) — MITHOME-87. */
export const About: GlobalConfig = {
slug: 'about',
admin: {
group: 'Oldalak',
},
versions: globalVersions,
fields: [
{
name: 'meta',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'description', type: 'textarea', required: true },
{ name: 'ogDescription', type: 'textarea', required: true },
],
},
{
name: 'hero',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'subtitle', type: 'text', required: true },
],
},
{
name: 'story',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
stringArrayField('paragraphs', 'Paragraphs'),
],
},
{
name: 'mission',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'subtitle', type: 'text', required: true },
{
name: 'values',
type: 'array',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'description', type: 'textarea', required: true },
{ name: 'icon', type: 'text', required: true },
],
},
],
},
{
name: 'team',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'subtitle', type: 'text', required: true },
stringArrayField('paragraphs', 'Paragraphs'),
],
},
{
name: 'cta',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'subtitle', type: 'textarea', required: true },
{ name: 'button', type: 'text', required: true },
],
},
],
}
+84
View File
@@ -0,0 +1,84 @@
import type { GlobalConfig } from 'payload'
import { globalVersions } from '../lib/payload-versions'
/**
* Mirrors CommonContent (proto/src/content/types.ts) — MITHOME-87.
*
* `buttons.*` localized: true (MITHOME-110) — ez a lokalizáció-mechanizmus
* bizonyítéka (admin nyelvváltó + Local/REST API locale param), és egyben
* előremutató, helyes darabja a MITHOME-111 teljes retrofitnak: ezek
* ténylegesen fordítandó UI-feliratok. A többi mező itt még nem lokalizált,
* azt a MITHOME-111 teljes körűen elvégzi.
*/
export const Common: GlobalConfig = {
slug: 'common',
admin: {
group: 'Oldalak',
},
versions: globalVersions,
fields: [
{
name: 'buttons',
type: 'group',
fields: [
{ name: 'contact', type: 'text', required: true, localized: true },
{ name: 'learnMore', type: 'text', required: true, localized: true },
{ name: 'webmail', type: 'text', required: true, localized: true },
{ name: 'sendMessage', type: 'text', required: true, localized: true },
],
},
{
name: 'labels',
type: 'group',
fields: [
{ name: 'required', type: 'text', required: true },
{ name: 'features', type: 'text', required: true },
],
},
{
name: 'validation',
type: 'group',
fields: [
{ name: 'required', type: 'text', required: true },
{ name: 'invalidEmail', type: 'text', required: true },
{
name: 'minLength',
type: 'text',
required: true,
admin: {
description: 'A {min} placeholder futásidőben cserélődik ki.',
},
},
],
},
{
name: 'staging',
type: 'group',
fields: [{ name: 'banner', type: 'text', required: true }],
},
{
name: 'footer',
type: 'group',
fields: [
{
name: 'copyright',
type: 'text',
required: true,
admin: {
description: 'A {year} placeholder futásidőben cserélődik ki.',
},
},
{ name: 'address', type: 'text', required: true },
],
},
{
name: 'a11y',
type: 'group',
fields: [
{ name: 'openMenu', type: 'text', required: true },
{ name: 'closeMenu', type: 'text', required: true },
],
},
],
}
+145
View File
@@ -0,0 +1,145 @@
import type { GlobalConfig } from 'payload'
import { globalVersions } from '../lib/payload-versions'
/**
* Mirrors ContactPageContent (proto/src/content/types.ts) — MITHOME-87.
*
* WHY `gdpr.label` textarea és nem richText: a JSON forrás egy kézzel
* beírt <a> taget tartalmazó HTML string (lásd contact.json). A lexical
* richText mező más (JSON node-fa) szerializációt használna, ami extra
* migrációs/render logikát igényelne — ezt itt, bootstrap szinten nem
* bontjuk ki, marad egyszerű (HTML-t tartalmazó) szöveg mező.
*/
export const Contact: GlobalConfig = {
slug: 'contact',
admin: {
group: 'Oldalak',
},
versions: globalVersions,
fields: [
{
name: 'meta',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'description', type: 'textarea', required: true },
],
},
{
name: 'hero',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'subtitle', type: 'text', required: true },
],
},
{
name: 'form',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'successMessage', type: 'text', required: true },
{ name: 'errorMessage', type: 'text', required: true },
{
name: 'fields',
type: 'group',
fields: [
{
name: 'name',
type: 'group',
fields: [
{ name: 'label', type: 'text', required: true },
{ name: 'placeholder', type: 'text', required: true },
{ name: 'error', type: 'text', required: true },
],
},
{
name: 'email',
type: 'group',
fields: [
{ name: 'label', type: 'text', required: true },
{ name: 'placeholder', type: 'text', required: true },
{ name: 'errorRequired', type: 'text', required: true },
{ name: 'errorInvalid', type: 'text', required: true },
],
},
{
name: 'subject',
type: 'group',
fields: [
{ name: 'label', type: 'text', required: true },
{ name: 'placeholder', type: 'text', required: true },
{ name: 'error', type: 'text', required: true },
],
},
{
name: 'message',
type: 'group',
fields: [
{ name: 'label', type: 'text', required: true },
{ name: 'placeholder', type: 'text', required: true },
{ name: 'errorRequired', type: 'text', required: true },
{ name: 'errorMinLength', type: 'text', required: true },
],
},
{
name: 'gdpr',
type: 'group',
fields: [
{ name: 'label', type: 'textarea', required: true },
{ name: 'error', type: 'text', required: true },
],
},
],
},
{ name: 'submitButton', type: 'text', required: true },
{ name: 'submittingButton', type: 'text', required: true },
],
},
{
name: 'info',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{
name: 'email',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'responseTime', type: 'text', required: true },
],
},
{
name: 'company',
type: 'group',
fields: [{ name: 'title', type: 'text', required: true }],
},
{
name: 'webmail',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'linkText', type: 'text', required: true },
{ name: 'subtitle', type: 'text', required: true },
],
},
],
},
{
name: 'faq',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{
name: 'items',
type: 'array',
fields: [
{ name: 'question', type: 'text', required: true },
{ name: 'answer', type: 'textarea', required: true },
],
},
],
},
],
}
+107
View File
@@ -0,0 +1,107 @@
import type { GlobalConfig } from 'payload'
import { stringArrayField } from './fields/stringArray'
import { globalVersions } from '../lib/payload-versions'
/**
* Mirrors HomePageContent (proto/src/content/types.ts) — MITHOME-87.
*
* WHY nincs `partners` mező: a home.partners.items a JSON-ban ma egy
* beágyazott lista, de a Payload oldalon önálló `Partners` collection lesz
* (logó = Media upload reference) — lásd MITHOME-89. Itt szándékosan
* kihagyjuk, nehogy két, egymásnak ellentmondó forrás legyen.
*/
export const Home: GlobalConfig = {
slug: 'home',
admin: {
group: 'Oldalak',
},
versions: globalVersions,
fields: [
{
name: 'hero',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'subtitle', type: 'text', required: true },
{ name: 'description', type: 'textarea', required: true },
stringArrayField('trustBullets', 'Trust bullets'),
{
name: 'cta',
type: 'group',
fields: [
{
name: 'primary',
type: 'group',
fields: [
{ name: 'text', type: 'text', required: true },
{ name: 'href', type: 'text', required: true },
{ name: 'external', type: 'checkbox', defaultValue: false },
],
},
{
name: 'secondary',
type: 'group',
fields: [
{ name: 'text', type: 'text', required: true },
{ name: 'href', type: 'text', required: true },
{ name: 'external', type: 'checkbox', defaultValue: false },
],
},
],
},
],
},
{
name: 'about',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{
name: 'usps',
type: 'array',
fields: [
{ name: 'id', type: 'text', required: true },
{ name: 'title', type: 'text', required: true },
{ name: 'description', type: 'textarea', required: true },
{ name: 'icon', type: 'text', required: true },
],
},
],
},
{
name: 'services',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'subtitle', type: 'text', required: true },
{
name: 'items',
type: 'array',
fields: [
{ name: 'id', type: 'text', required: true },
{ name: 'title', type: 'text', required: true },
{ name: 'description', type: 'textarea', required: true },
{ name: 'icon', type: 'text', required: true },
stringArrayField('features', 'Features'),
{ name: 'ctaText', type: 'text', required: true },
],
},
],
},
{
name: 'cta',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'subtitle', type: 'textarea', required: true },
{ name: 'button', type: 'text', required: true },
],
},
{
name: 'serviceFeatures',
type: 'group',
fields: [{ name: 'title', type: 'text', required: true }],
},
],
}
+84
View File
@@ -0,0 +1,84 @@
import type { GlobalConfig } from 'payload'
import { stringArrayField } from './fields/stringArray'
import { globalVersions } from '../lib/payload-versions'
/** Mirrors ServicesPageContent (proto/src/content/types.ts) — MITHOME-87. */
export const Services: GlobalConfig = {
slug: 'services',
admin: {
group: 'Oldalak',
},
versions: globalVersions,
fields: [
{
name: 'meta',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'description', type: 'textarea', required: true },
{ name: 'ogDescription', type: 'textarea', required: true },
],
},
{
name: 'hero',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'subtitle', type: 'text', required: true },
],
},
{
name: 'details',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'subtitle', type: 'text', required: true },
{
name: 'services',
type: 'array',
fields: [
{ name: 'icon', type: 'text', required: true },
{ name: 'title', type: 'text', required: true },
{ name: 'description', type: 'textarea', required: true },
{
name: 'specs',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
stringArrayField('items', 'Items'),
],
},
],
},
],
},
{
name: 'support',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'subtitle', type: 'text', required: true },
{
name: 'channels',
type: 'array',
fields: [
{ name: 'icon', type: 'text', required: true },
{ name: 'title', type: 'text', required: true },
{ name: 'description', type: 'text', required: true },
],
},
],
},
{
name: 'cta',
type: 'group',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'subtitle', type: 'textarea', required: true },
{ name: 'primaryButton', type: 'text', required: true },
{ name: 'secondaryButton', type: 'text', required: true },
],
},
],
}
+24
View File
@@ -0,0 +1,24 @@
import type { ArrayField } from 'payload'
/**
* Payload has no native "array of plain strings" field — the closest
* built-in shape is an array of one-field objects. Used for every
* string[] in the old content/types.ts (trustBullets, paragraphs,
* features, spec items, …) so the JSON -> Payload migration script has a
* single, consistent shape to transform into.
*/
export function stringArrayField(name: string, label?: string, required = false): ArrayField {
return {
name,
type: 'array',
label,
fields: [
{
name: 'value',
type: 'text',
required: true,
},
],
required,
}
}
+52
View File
@@ -0,0 +1,52 @@
/**
* MITHOME-91/114: locale + útvonal segédfüggvények.
*
* URL-stratégia (2026-09-10, felülírja a korábbi "hu prefix nélkül" döntést):
* mindkét nyelv prefixet kap, szimmetrikusan (`/hu/...`, `/en/...`), a
* szlögök nyelvenként lefordítva (pl. /hu/rolunk vs /en/about). Nincs
* redirect a régi, prefix nélküli URL-ekről — a projekt még nincs
* production-ben (MITHOME-15 backlog).
*/
export const LOCALES = ['hu', 'en'] as const
export type Locale = (typeof LOCALES)[number]
export const DEFAULT_LOCALE: Locale = 'hu'
export function isLocale(value: string): value is Locale {
return (LOCALES as readonly string[]).includes(value)
}
/** A catch-all [slug] route alá tartozó oldalak azonosítói. */
export type PageKey = 'about' | 'services' | 'contact' | 'privacy' | 'terms'
/** Nyelvenkénti szlög minden oldalhoz — a nyelvváltó (MITHOME-115) és a
* sitemap/hreflang (MITHOME-116) is ezt a táblát fogja használni. */
export const PAGE_SLUGS: Record<PageKey, Record<Locale, string>> = {
about: { hu: 'rolunk', en: 'about' },
services: { hu: 'szolgaltatasok', en: 'services' },
contact: { hu: 'kapcsolat', en: 'contact' },
privacy: { hu: 'adatvedelem', en: 'privacy-policy' },
terms: { hu: 'felhasznalasi-feltetelek', en: 'terms-of-service' },
}
/** slug -> PageKey visszakeresés egy adott nyelven belül. */
export function resolvePageKey(locale: Locale, slug: string): PageKey | undefined {
return (Object.keys(PAGE_SLUGS) as PageKey[]).find((key) => PAGE_SLUGS[key][locale] === slug)
}
/** Útvonal a főoldalhoz vagy egy PageKey-hez, adott nyelven. */
export function localePath(locale: Locale, key?: PageKey): string {
if (!key) return `/${locale}`
return `/${locale}/${PAGE_SLUGS[key][locale]}`
}
/**
* Ugyanaz az oldal a másik nyelven — a nyelvváltóhoz (MITHOME-115).
* Ha a jelenlegi útvonal nem ismert PageKey (pl. 404), a másik nyelv
* főoldalára esik vissza.
*/
export function switchLocalePath(currentLocale: Locale, targetLocale: Locale, slug?: string): string {
if (!slug) return localePath(targetLocale)
const key = resolvePageKey(currentLocale, slug)
return localePath(targetLocale, key)
}
-10
View File
@@ -93,16 +93,6 @@ describe('MongoDB Connection (Unit Tests)', () => {
expect(typeof result.collection).toBe('function')
})
it('should return collection from database', async () => {
const { getCollection } = await import('./mongodb')
const collection = await getCollection('test_collection')
expect(collection).toBeDefined()
expect(mockDb.collection).toHaveBeenCalledWith('test_collection')
expect(typeof collection.findOne).toBe('function')
})
it('should check MongoDB connection successfully', async () => {
const { checkMongoConnection } = await import('./mongodb')
+7 -6
View File
@@ -1,4 +1,10 @@
import { MongoClient, Db, Collection, Document } from 'mongodb'
import { MongoClient, Db } from 'mongodb'
// WHY this file only exposes getDb/checkMongoConnection now: it used to also
// export getCollection(), used by src/app/api/contact/route.ts for a raw,
// Payload-external `contact_submissions` collection. MITHOME-94 moved that
// write to a proper Payload collection (ContactSubmissions) via the Local
// API — getDb() itself stays alive only for /api/health's connectivity check.
const options = {
maxPoolSize: 10,
@@ -50,11 +56,6 @@ export async function getDb(): Promise<Db> {
return client.db(process.env.MONGODB_DB || 'mozdit')
}
export async function getCollection<T extends Document = Document>(collectionName: string): Promise<Collection<T>> {
const db = await getDb()
return db.collection<T>(collectionName)
}
// Health check for MongoDB connection
export async function checkMongoConnection(): Promise<boolean> {
try {
+103
View File
@@ -0,0 +1,103 @@
/**
* MITHOME-91: Payload Local API adat-adapter réteg.
*
* WHY adapterek: a Payload Globals/Collections mezői (stringArrayField,
* lásd src/globals/fields/stringArray.ts) `{ value: string }[]` alakban
* tárolják azt, ami a JSON content rendszerben egyszerű `string[]` volt.
* Ezek a getterek visszaadaptálják az eredeti alakra, hogy a page
* komponensek JSX-e (ami a régi content/types.ts formát várja) NE
* változzon csak az adatforrás.
*/
import { getPayload } from 'payload'
import config from '@payload-config'
import type { Locale } from './i18n'
let cached: ReturnType<typeof getPayload> | undefined
function payloadClient() {
cached ??= getPayload({ config })
return cached
}
function unwrap(items: readonly { value: string }[] | null | undefined): string[] {
return (items ?? []).map((item) => item.value)
}
export async function getCommonContent(locale: Locale) {
const payload = await payloadClient()
return payload.findGlobal({ slug: 'common', locale })
}
export async function getHomeContent(locale: Locale) {
const payload = await payloadClient()
const home = await payload.findGlobal({ slug: 'home', locale })
return {
...home,
hero: { ...home.hero, trustBullets: unwrap(home.hero?.trustBullets) },
services: {
...home.services,
items: (home.services?.items ?? []).map((item) => ({
...item,
features: unwrap(item.features),
})),
},
}
}
export async function getAboutContent(locale: Locale) {
const payload = await payloadClient()
const about = await payload.findGlobal({ slug: 'about', locale })
return {
...about,
story: { ...about.story, paragraphs: unwrap(about.story?.paragraphs) },
team: { ...about.team, paragraphs: unwrap(about.team?.paragraphs) },
}
}
export async function getServicesContent(locale: Locale) {
const payload = await payloadClient()
const services = await payload.findGlobal({ slug: 'services', locale })
return {
...services,
details: {
...services.details,
services: (services.details?.services ?? []).map((service) => ({
...service,
specs: { ...service.specs, items: unwrap(service.specs?.items) },
})),
},
}
}
export async function getContactContent(locale: Locale) {
const payload = await payloadClient()
return payload.findGlobal({ slug: 'contact', locale })
}
export async function getLegalPage(slug: 'adatvedelem' | 'hasznalati-feltetelek', locale: Locale) {
const payload = await payloadClient()
const result = await payload.find({
collection: 'legal-pages',
where: { slug: { equals: slug } },
locale,
limit: 1,
})
return result.docs[0]
}
export type PartnerView = { name: string; url: string; logo: { url: string; alt: string } }
export async function getPartners(): Promise<PartnerView[]> {
const payload = await payloadClient()
const result = await payload.find({ collection: 'partners', limit: 100, sort: 'name' })
return result.docs
.filter((doc) => typeof doc.logo === 'object' && doc.logo?.url)
.map((doc) => ({
name: doc.name,
url: doc.url,
logo: {
url: (doc.logo as { url: string }).url,
alt: (doc.logo as { alt?: string }).alt ?? doc.name,
},
}))
}
+27
View File
@@ -0,0 +1,27 @@
import type { GlobalConfig, CollectionConfig } from 'payload'
/**
* MITHOME-92: közös draft/verziózás beállítás ez az elődje a régi egyedi
* CMS "Verziók panel"-jének (MITHOME-64: mentéslista, diff-nézet, egy-
* kattintásos visszaállítás). A Payload beépített verziózása/draft-rendszere
* ugyanezt adja natívan (Payload admin "Versions" fül minden dokumentumon),
* nem kellett újraépíteni.
*
* WHY `autosave` nincs bekapcsolva: az ügyfél explicit "Save Draft" /
* "Publish" gombokkal dolgozzon, ne automatikus mentés ugyanaz a
* szándékos, kézi mentés-modell, mint a régi CMS-nél volt.
*
* WHY a Local API hívásaink (src/lib/payload-content.ts) nem törnek el
* ettől: `findGlobal`/`find` alapból a PUBLISHED verziót adja vissza, amíg
* explicit `draft: true` paramétert nem adunk át ezt sehol nem tesszük,
* így a publikus oldal renderelése változatlan marad.
*/
export const globalVersions: GlobalConfig['versions'] = {
drafts: true,
max: 50,
}
export const collectionVersions: CollectionConfig['versions'] = {
drafts: true,
maxPerDoc: 50,
}
+145
View File
@@ -0,0 +1,145 @@
/**
* MITHOME-96 Payload collection/global config tesztek.
*
* WHY nem a teljes payload.config.ts-t importáljuk: az a valódi `payload` és
* `@payloadcms/db-mongodb` csomagokat importálja értékként (nem csak
* típusként), amik ESM-only, natív node_modules-forrást futtatnak Jest
* (a next/jest transzformmal is) nem tudja lefordítani őket anélkül, hogy a
* teljes payload-függőségi fát is a transformIgnorePatterns kivételévé
* tennénk (próbáltuk: `SyntaxError: Cannot use import statement outside a
* module` a payload csomag saját forrásából). Az egyes Collection/Global
* fájlok viszont csak `import type` formában hivatkoznak a `payload`
* csomagra ez típus-only import, a build kitörli, így ezek a fájlok
* önmagukban, gyors unit tesztként importálhatók, é MongoDB/PAYLOAD_SECRET
* nélkül. Ez fedi le a ticket "Payload config tesztek" részét; a Local API
* ellen futó, é adatbázist igénylő tesztek külön (integrációs) fájlban
* vannak lásd src/__tests__/payload-local-api.test.ts.
*
* Ezek a tesztek szándékosan regresszió-őrök is: mindkettő egy-egy valódi,
* élesben megtalált hibát rögzít (MITHOME-121: Media publikus olvasása,
* Partners.logo opcionalitása), hogy soha többé ne térjenek vissza észrevétlenül.
*/
import { Users } from './collections/Users'
import { LegalPages } from './collections/LegalPages'
import { Media } from './collections/Media'
import { Partners } from './collections/Partners'
import { ContactSubmissions } from './collections/ContactSubmissions'
import { Home } from './globals/Home'
import { About } from './globals/About'
import { Services } from './globals/Services'
import { Contact } from './globals/Contact'
import { Common } from './globals/Common'
type FieldLike = { name?: string; required?: boolean; type?: string }
function findField(fields: unknown[], name: string): FieldLike | undefined {
return (fields as FieldLike[]).find((f) => f.name === name)
}
describe('Payload collections', () => {
it('all collection slugs are present and unique', () => {
const slugs = [Users, LegalPages, Media, Partners, ContactSubmissions].map((c) => c.slug)
expect(slugs).toEqual(['users', 'legal-pages', 'media', 'partners', 'contact-submissions'])
expect(new Set(slugs).size).toBe(slugs.length)
})
describe('Media', () => {
// Regresszió-őr — MITHOME-121: a Media collection read access-e sokáig
// (MITHOME-89 óta) alapértelmezetten csak bejelentkezett usernek volt
// engedélyezett, emiatt a partner logók sosem töltődtek be a publikus
// oldalon (403 -> a Next.js image-optimizer "nem érvényes kép" hibája).
it('allows public (unauthenticated) read access', () => {
expect(Media.access?.read).toBeDefined()
const result = Media.access!.read!({ req: { user: null } } as never)
expect(result).toBe(true)
})
it('only accepts image uploads', () => {
expect(Media.upload).toEqual(expect.objectContaining({ mimeTypes: ['image/*'] }))
})
})
describe('Partners', () => {
it('requires name and url', () => {
expect(findField(Partners.fields, 'name')?.required).toBe(true)
expect(findField(Partners.fields, 'url')?.required).toBe(true)
})
// Regresszió-őr — MITHOME-121: a felhasználó kérésére a logo mező
// opcionálissá vált, hogy egy partner logó nélkül is menthető legyen.
it('does not require a logo', () => {
const logo = findField(Partners.fields, 'logo')
expect(logo).toBeDefined()
expect(logo?.required).not.toBe(true)
})
})
describe('ContactSubmissions', () => {
// WHY nincs egyedi access blokk itt tesztelve mint "hiányzik": a Payload
// defaultAccess (csak bejelentkezett user) itt a kívánt viselkedés —
// lásd a collection saját WHY-kommentjét. Nincs mit tesztelni rajta
// (nincs felülírás), de a mezőszerkezetet igen.
it('requires the core submission fields', () => {
for (const name of ['name', 'email', 'subject', 'message', 'gdprConsent']) {
expect(findField(ContactSubmissions.fields, name)?.required).toBe(true)
}
})
it('defaults status to "new"', () => {
const status = findField(ContactSubmissions.fields, 'status') as FieldLike & {
defaultValue?: string
options?: { value: string }[]
}
expect(status?.defaultValue).toBe('new')
expect(status?.options?.map((o) => o.value)).toEqual(['new', 'read', 'replied', 'archived'])
})
})
describe('LegalPages', () => {
it('has a required, unique slug field', () => {
const slug = findField(LegalPages.fields, 'slug') as FieldLike & { unique?: boolean }
expect(slug?.required).toBe(true)
expect(slug?.unique).toBe(true)
})
})
describe('Users', () => {
it('locks accounts after 5 failed attempts for 10 minutes', () => {
expect(Users.auth).toEqual(
expect.objectContaining({ maxLoginAttempts: 5, lockTime: 10 * 60 * 1000 })
)
})
it('requires an authenticated user for every access-controlled operation', () => {
const access = Users.access!
for (const op of ['create', 'read', 'update', 'delete', 'unlock'] as const) {
expect(access[op]!({ req: { user: null } } as never)).toBe(false)
expect(access[op]!({ req: { user: {} } } as never)).toBe(true)
}
})
})
})
describe('Payload globals', () => {
it('all global slugs are present and unique', () => {
const slugs = [Home, About, Services, Contact, Common].map((g) => g.slug)
expect(slugs).toEqual(['home', 'about', 'services', 'contact', 'common'])
expect(new Set(slugs).size).toBe(slugs.length)
})
it('Home does not duplicate the Partners collection', () => {
// MITHOME-89 döntés: a partnerek önálló collection-ök, a Home global
// szándékosan nem tartalmaz "partners" mezőt.
expect(findField(Home.fields, 'partners')).toBeUndefined()
})
it('Common.buttons.* fields are localized (MITHOME-110 bizonyíték)', () => {
const buttonsGroup = findField(Common.fields, 'buttons') as FieldLike & {
fields?: (FieldLike & { localized?: boolean })[]
}
expect(buttonsGroup?.fields?.length).toBeGreaterThan(0)
for (const field of buttonsGroup!.fields!) {
expect(field.localized).toBe(true)
}
})
})
+92
View File
@@ -0,0 +1,92 @@
/**
* Payload CMS configuration (MITHOME-86 alapinstalláció).
*
* WHY a saját MongoDB kapcsolatot használjuk: a projekt már MongoDB-t futtat
* (proto/src/lib/mongodb.ts) Payload a `mongooseAdapter`-en keresztül
* ugyanabba az adatbázisba ír, nincs szükség külön DB-re/szerverre.
*
* MITHOME-87: Home/About/Services/Contact/Common Globals hozzáadva ezek
* tükrözik a proto/src/content/pages/*.json + common.json struktúráját.
* MITHOME-88: LegalPages collection hozzáadva (adatvedelem, hasznalati-
* feltetelek).
* MITHOME-89: Partners + Media collection (MVP feltöltés, a vágás/
* áttetszővé tétel szerkesztő külön, MITHOME-118).
* MITHOME-110: lokalizáció bekapcsolva (hu alapértelmezett, en). A mezőnkénti
* `localized: true` retrofit a Globals/LegalPages configokon külön ticket
* (MITHOME-111/112) ez a ticket csak magát a mechanizmust kapcsolja be.
* MITHOME-120: admin gyorskeresés (QuickSearch) az admin.components.header
* slotba regisztrálva teljes szöveges keresés minden Global/Collection
* mezőjében, mindkét locale-ban.
* MITHOME-94: ContactSubmissions collection a /api/contact korábban egy
* nyers, Payload-on kívüli Mongo collection-be (`contact_submissions`,
* proto/src/lib/mongodb.ts) írt, amit az ügyfél nem látott sehol az admin
* felületen. Mostantól Payload collection, a route.ts a Local API-n ír bele.
*/
import path from 'path'
import { fileURLToPath } from 'url'
import { buildConfig } from 'payload'
import { mongooseAdapter } from '@payloadcms/db-mongodb'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import sharp from 'sharp'
import { Users } from './collections/Users'
import { LegalPages } from './collections/LegalPages'
import { Media } from './collections/Media'
import { Partners } from './collections/Partners'
import { ContactSubmissions } from './collections/ContactSubmissions'
import { Home } from './globals/Home'
import { About } from './globals/About'
import { Services } from './globals/Services'
import { Contact } from './globals/Contact'
import { Common } from './globals/Common'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfig({
// Admin felület — bejelentkezés az Users collection-nel. Access control /
// jelszó-politika: MITHOME-90 (src/collections/Users.ts).
admin: {
user: Users.slug,
components: {
// MITHOME-120: gyorskeresés minden admin oldal tetején.
// WHY 'src/...' és nem './components/...': a Payload komponens-útvonal
// az admin.importMap.baseDir-hez relatív, ami alapértelmezésben
// process.cwd() (a `generate:importmap` a proto/ mappából fut, NEM a
// payload.config.ts mappájából).
header: ['./src/components/admin/QuickSearch#QuickSearch'],
},
},
editor: lexicalEditor(),
collections: [Users, LegalPages, Media, Partners, ContactSubmissions],
globals: [Home, About, Services, Contact, Common],
// MITHOME-109/110: hu alapértelmezett (URL-prefix nélkül), en /en/ alatt
// (Next.js routing oldala: MITHOME-114). fallback:true -> amíg egy mezőnek
// nincs angol fordítása, a magyar érték jelenik meg — nem üres oldal.
localization: {
locales: ['hu', 'en'],
defaultLocale: 'hu',
fallback: true,
},
secret: process.env.PAYLOAD_SECRET || '',
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
db: mongooseAdapter({
url: process.env.MONGODB_URI || '',
}),
// WHY: Payload sends anonymous usage telemetry to its own servers by
// default — GDPR-tudatosan kikapcsolva, mivel a projekt self-hosted és
// az ügyfél adatai nem hagyhatják el a saját infrastruktúránkat.
telemetry: false,
sharp,
})
+22 -5
View File
@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -11,7 +15,7 @@
"module": "esnext",
"moduleResolution": "bundler",
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -19,9 +23,22 @@
}
],
"paths": {
"@/*": ["./src/*"]
"@/*": [
"./src/*"
],
"@payload-config": [
"./src/payload.config.ts"
]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
-135
View File
@@ -1,135 +0,0 @@
// Security and infrastructure helpers for the Content Editor, extracted so
// content-editor.js stays focused on HTTP routing (file-size limits).
// Dependencies (validateLogin, hasValidSession) are injected to avoid cycles.
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const CMS_USER = process.env.CMS_USER;
const CMS_PASS = process.env.CMS_PASS;
const CMS_DEPLOY_ENV = process.env.CMS_DEPLOY_ENV;
const CSRF_TOKEN = process.env.CMS_CSRF_TOKEN || crypto.randomBytes(32).toString('hex');
const rateLimits = new Map();
function securityConfigIsValid() {
return Boolean(CMS_USER && CMS_PASS && ['staging', 'production'].includes(CMS_DEPLOY_ENV));
}
function getClientAddress(req) {
// The editor only listens on 127.0.0.1; the staging Nginx proxy supplies this header.
// WHY: take the LAST entry. Nginx ($proxy_add_x_forwarded_for) appends the real client
// IP to the list, so the first entry may be a spoofed value sent by the client — using
// it would let attackers bypass the rate limiter with a fresh "IP" per request.
const forwarded = req.headers['x-forwarded-for'];
if (typeof forwarded === 'string' && forwarded.trim()) {
const parts = forwarded.split(',').map(part => part.trim()).filter(Boolean);
if (parts.length > 0) return parts[parts.length - 1];
}
return req.socket.remoteAddress || 'unknown';
}
function isRateLimited(key, limit, windowMs) {
const now = Date.now();
const attempts = (rateLimits.get(key) || []).filter(time => now - time < windowMs);
if (attempts.length === 0) {
rateLimits.delete(key);
return false;
}
rateLimits.set(key, attempts);
return attempts.length >= limit;
}
function recordRateLimitAttempt(key, windowMs) {
const now = Date.now();
const attempts = (rateLimits.get(key) || []).filter(time => now - time < windowMs);
attempts.push(now);
rateLimits.set(key, attempts);
}
function exceedsRateLimit(key, limit, windowMs) {
const now = Date.now();
const attempts = (rateLimits.get(key) || []).filter(time => now - time < windowMs);
attempts.push(now);
rateLimits.set(key, attempts);
return attempts.length > limit;
}
function hasValidCredentials(req, validateLogin) {
const b64auth = (req.headers.authorization || '').split(' ')[1] || '';
const str = Buffer.from(b64auth, 'base64').toString();
const colonIdx = str.indexOf(':');
const login = colonIdx !== -1 ? str.slice(0, colonIdx) : str;
const password = colonIdx !== -1 ? str.slice(colonIdx + 1) : '';
return validateLogin(login, password, CMS_USER, CMS_PASS);
}
function isBrowserNavigation(req) {
return req.method === 'GET' && String(req.headers.accept || '').includes('text/html');
}
// WHY: Safari (and other browsers) cache Basic Auth credentials and resend them
// automatically, which would let an already-logged-out browser straight back in.
// Browser navigations therefore authenticate ONLY via the session cookie, so
// logout is final. Non-browser requests (curl, API clients) keep Basic Auth.
function makeIsAuthenticated(hasValidSession, validateLogin) {
return function isAuthenticated(req) {
if (isBrowserNavigation(req)) return hasValidSession(req);
return hasValidCredentials(req, validateLogin) || hasValidSession(req);
};
}
function hasValidCsrfToken(req) {
const token = req.headers['x-csrf-token'];
return typeof token === 'string'
&& token.length === CSRF_TOKEN.length
&& crypto.timingSafeEqual(Buffer.from(token), Buffer.from(CSRF_TOKEN));
}
function makeWriteAudit(auditFile) {
return function writeAudit(event, details = {}) {
const record = { timestamp: new Date().toISOString(), event, ...details };
fs.appendFileSync(auditFile, JSON.stringify(record) + '\n', { encoding: 'utf8', mode: 0o600 });
};
}
function backupAndWriteAtomically(targetFile, data, backupDir) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupName = `${path.basename(targetFile, '.json')}.${timestamp}.json`;
const backupFile = path.join(backupDir, backupName);
const tempFile = `${targetFile}.${process.pid}.${Date.now()}.tmp`;
fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
fs.copyFileSync(targetFile, backupFile);
fs.writeFileSync(tempFile, JSON.stringify(data, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
fs.renameSync(tempFile, targetFile);
return backupFile;
}
const RATE_LIMIT_GC_INTERVAL_MS = 5 * 60 * 1000;
setInterval(() => {
const now = Date.now();
for (const [key, attempts] of rateLimits) {
const valid = attempts.filter(t => now - t < 15 * 60 * 1000);
if (valid.length === 0) rateLimits.delete(key);
else rateLimits.set(key, valid);
}
}, RATE_LIMIT_GC_INTERVAL_MS).unref();
module.exports = {
CMS_USER,
CMS_PASS,
CMS_DEPLOY_ENV,
CSRF_TOKEN,
securityConfigIsValid,
getClientAddress,
isRateLimited,
recordRateLimitAttempt,
exceedsRateLimit,
hasValidCredentials,
isBrowserNavigation,
makeIsAuthenticated,
hasValidCsrfToken,
makeWriteAudit,
backupAndWriteAtomically,
};
-65
View File
@@ -1,65 +0,0 @@
// Dependency-free line diff (LCS) for the CMS version comparison view.
// Input lines are plain text; output entries are typed add/del/ctx rows.
function diffLines(oldLines, newLines) {
const n = oldLines.length;
const m = newLines.length;
// LCS lengths DP (files are small, a few hundred lines — O(n*m) is fine)
const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
dp[i][j] = oldLines[i] === newLines[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
const out = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (oldLines[i] === newLines[j]) {
out.push({ type: 'ctx', text: oldLines[i] });
i++;
j++;
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
out.push({ type: 'del', text: oldLines[i] });
i++;
} else {
out.push({ type: 'add', text: newLines[j] });
j++;
}
}
while (i < n) { out.push({ type: 'del', text: oldLines[i] }); i++; }
while (j < m) { out.push({ type: 'add', text: newLines[j] }); j++; }
return out;
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// Keep only ±contextAround context lines around changes to keep pages small.
function trimContext(entries, contextAround = 3) {
const keep = new Array(entries.length).fill(false);
entries.forEach((e, idx) => {
if (e.type !== 'ctx') {
for (let k = Math.max(0, idx - contextAround); k <= Math.min(entries.length - 1, idx + contextAround); k++) keep[k] = true;
}
});
const out = [];
let skipping = false;
entries.forEach((e, idx) => {
if (keep[idx]) { out.push(e); skipping = false; }
else if (!skipping) { out.push({ type: 'skip', text: '…' }); skipping = true; }
});
return out;
}
function renderDiffHtml(oldText, newText) {
const entries = trimContext(diffLines(oldText.split('\n'), newText.split('\n')));
return entries.map(e => `<div class="diff-${e.type}">${escapeHtml(e.text) || '&nbsp;'}</div>`).join('\n');
}
module.exports = { diffLines, trimContext, renderDiffHtml, escapeHtml };
-345
View File
@@ -1,345 +0,0 @@
// Browser-side script of the Content Editor editor page.
// Inlined into the HTML template at render time by content-editor.js.
// Test coverage: scripts/test-content-editor-serializer.js runs this exact code.
// ── Render ──────────────────────────────────────────────────────────────────
function render(obj, container) {
container.innerHTML = '';
renderObject(obj, container, '');
}
function renderObject(obj, container, prefix) {
for (const [key, val] of Object.entries(obj)) {
const path = prefix ? prefix + '.' + key : key;
if (Array.isArray(val)) {
renderArray(key, val, container, path);
} else if (typeof val === 'object' && val !== null) {
renderObject(val, container, path);
} else {
renderPrimitive(path, val, container);
}
}
}
function renderPrimitive(path, val, container) {
const isLong = String(val).length > 80 || String(val).includes('<');
const div = document.createElement('div');
div.className = 'field';
const type = val === null ? 'null' : typeof val;
let control;
if (type === 'boolean') {
control = `<input type="checkbox" data-path="${esc(path)}" data-type="boolean" ${val ? 'checked' : ''}>`;
} else if (type === 'number') {
control = `<input type="number" data-path="${esc(path)}" data-type="number" value="${esc(val)}">`;
} else {
control = isLong
? `<textarea data-path="${esc(path)}" data-type="${type}" rows="${Math.min(8,Math.max(2,Math.ceil(String(val).length/80)))}">${esc(val ?? '')}<\/textarea>`
: `<input type="text" data-path="${esc(path)}" data-type="${type}" value="${esc(val ?? '')}">`;
}
div.innerHTML = `
<label>${path}</label>
${control}
`;
container.appendChild(div);
}
function renderArray(key, arr, container, path) {
const section = document.createElement('div');
section.className = 'array-section';
section.dataset.arrayPath = path;
const label = document.createElement('div');
label.className = 'array-label';
label.textContent = path;
section.appendChild(label);
const items = document.createElement('div');
items.className = 'array-items';
items.dataset.arrayItems = path;
section.appendChild(items);
arr.forEach((item, i) => {
if (typeof item === 'object' && item !== null) {
items.appendChild(makeObjCard(item, i, path));
} else {
items.appendChild(makeStrItem(item, i, path));
}
});
// Template for adding new items
const sample = arr.length > 0 ? arr[arr.length - 1] : '';
const isObj = typeof sample === 'object' && sample !== null;
const addBtn = document.createElement('button');
addBtn.className = 'btn-add';
addBtn.textContent = ' Új elem hozzáadása';
addBtn.onclick = () => {
const idx = items.children.length;
if (isObj) {
const blank = blankLike(sample);
items.appendChild(makeObjCard(blank, idx, path));
} else {
items.appendChild(makeStrItem('', idx, path));
}
reindexItems(items);
};
section.appendChild(addBtn);
container.appendChild(section);
}
function blankLike(value) {
if (Array.isArray(value)) return [];
if (value && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, blankLike(child)]));
}
if (typeof value === 'boolean') return false;
if (typeof value === 'number') return 0;
return '';
}
function makeStrItem(val, idx, path) {
const wrap = document.createElement('div');
wrap.className = 'str-item';
const type = val === null ? 'null' : typeof val;
const ta = type === 'boolean' ? document.createElement('input') : document.createElement('textarea');
ta.dataset.path = path + '[' + idx + ']';
ta.dataset.type = type;
if (type === 'boolean') {
ta.type = 'checkbox';
ta.checked = val;
} else {
ta.value = val ?? '';
ta.rows = Math.min(6, Math.max(2, Math.ceil(String(val ?? '').length / 80)));
}
const del = document.createElement('button');
del.className = 'btn-del';
del.textContent = '❌';
del.title = 'Törlés';
del.onclick = () => {
// WHY: capture the container BEFORE removing — a detached node has no
// ancestors, so closest() would return null and reindexing would silently
// not run (sparse arrays → schema errors on save).
const container = wrap.closest('.array-items');
wrap.remove();
reindexItems(container);
};
wrap.appendChild(ta);
wrap.appendChild(del);
return wrap;
}
function makeObjCard(obj, idx, path) {
const card = document.createElement('div');
card.className = 'obj-card';
const hdr = document.createElement('div');
hdr.className = 'card-header';
hdr.textContent = path + '[' + idx + ']';
card.appendChild(hdr);
renderObject(obj, card, path + '[' + idx + ']');
const del = document.createElement('button');
del.className = 'btn-del-card';
del.textContent = '❌ Törlés';
del.onclick = () => {
// Same as above: capture before detaching, or reindexing is skipped.
const container = card.closest('.array-items');
card.remove();
reindexItems(container);
};
card.appendChild(del);
return card;
}
function reindexItems(itemsEl) {
if (!itemsEl) return;
const path = itemsEl.dataset.arrayItems;
// WHY: rewrite only the index that directly follows THIS array's own path prefix.
// A generic "replace first [n]" rule corrupts nested arrays (e.g. deleting from
// services[1].specs.items rewrites the OUTER services index and scatters paths
// across services[0..n], producing sparse arrays and schema errors).
const prefix = path + '[';
Array.from(itemsEl.children).forEach((child, i) => {
child.querySelectorAll('[data-path]').forEach(el => {
const old = el.dataset.path;
if (typeof old !== 'string' || !old.startsWith(prefix)) return;
const rest = old.slice(prefix.length);
const bracketEnd = rest.indexOf(']');
const suffix = bracketEnd === -1 ? '' : rest.slice(bracketEnd);
el.dataset.path = prefix + i + suffix;
});
// Update card header
const hdr = child.querySelector('.card-header');
if (hdr) hdr.textContent = path + '[' + i + ']';
});
}
// ── Collect & Save ───────────────────────────────────────────────────────────
function collect() {
const result = JSON.parse(JSON.stringify(DATA)); // deep clone as base
// Wipe all arrays so we rebuild them from DOM
clearArrays(result);
document.querySelectorAll('[data-path]').forEach(el => {
setPath(result, el.dataset.path, readValue(el));
});
return result;
}
function readValue(el) {
switch (el.dataset.type) {
case 'boolean': return el.checked;
case 'number': return Number(el.value);
case 'null': return el.value === '' ? null : el.value;
default: return el.value;
}
}
function clearArrays(obj) {
for (const k of Object.keys(obj)) {
if (Array.isArray(obj[k])) obj[k] = [];
else if (typeof obj[k] === 'object' && obj[k] !== null) clearArrays(obj[k]);
}
}
function setPath(obj, path, value) {
const parts = parsePath(path);
let cur = obj;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (cur[part] === undefined || cur[part] === null) {
cur[part] = typeof parts[i + 1] === 'number' ? [] : {};
}
cur = cur[part];
}
cur[parts[parts.length - 1]] = value;
}
function parsePath(path) {
const parts = [];
let token = '';
let inIndex = false;
for (const char of path) {
if (char === '.') {
if (!inIndex && token) parts.push(token);
token = '';
} else if (char === '[') {
if (token) parts.push(token);
token = '';
inIndex = true;
} else if (char === ']') {
parts.push(Number(token));
token = '';
inIndex = false;
} else {
token += char;
}
}
if (token) parts.push(token);
return parts;
}
async function save() {
const status = document.getElementById('saveStatus');
try {
const data = collect();
const res = await fetch('/save?file=' + FILE, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF_TOKEN, 'X-Content-Hash': CONTENT_HASH },
body: JSON.stringify(data, null, 2)
});
if (res.status === 401) { location.href = '/login'; return false; }
if (res.status === 409) {
if (confirm('A tartalom megváltozott, mióta ez a lap megnyílt (pl. deploy vagy másik fül mentett).\n\nOK = lap frissítése az új tartalommal (a szerkesztésed elvész)\nMégse = maradsz ezen a lapon, a mentés nem történt meg.')) {
location.reload();
}
return false;
}
const json = await res.json();
if (json.ok) {
// Refresh the optimistic-lock fingerprint with the server-computed hash of
// the saved content, so the user's own subsequent saves don't trip 409.
if (json.contentHash) CONTENT_HASH = json.contentHash;
status.textContent = '✅ Mentve!';
status.style.color = '#10b981';
status.style.visibility = 'visible';
setTimeout(() => status.style.visibility = 'hidden', 3000);
return true;
} else {
status.textContent = '❌ Hiba: ' + json.error;
status.style.color = '#f87171';
status.style.visibility = 'visible';
setTimeout(() => status.style.visibility = 'hidden', 5000);
return false;
}
} catch (e) {
status.textContent = '❌ Hálózati hiba mentéskor';
status.style.color = '#f87171';
status.style.visibility = 'visible';
setTimeout(() => status.style.visibility = 'hidden', 5000);
return false;
}
}
async function publish() {
const btn = document.getElementById('publishBtn');
const status = document.getElementById('saveStatus');
// Save first — abort publish if save failed (e.g. 409 conflict, validation error)
const saved = await save();
if (!saved) return;
// WHY: lock the button width and remember the label so the running state
// neither resizes the bottom bar nor permanently swaps the env-specific label.
const originalLabel = btn.textContent;
btn.style.minWidth = btn.offsetWidth + 'px';
btn.textContent = '⏳ Élesítés folyamatban...';
btn.disabled = true;
try {
const res = await fetch('/publish', { method: 'POST', headers: { 'X-CSRF-Token': CSRF_TOKEN } });
if (res.status === 401) { location.href = '/login'; return; }
const json = await res.json();
if (json.ok) {
status.textContent = '🚀 Sikeresen elküldve a szerverre!';
status.style.color = '#10b981';
} else {
status.textContent = '❌ Hiba az élesítésnél: ' + json.error;
status.style.color = '#f87171';
}
} catch (e) {
status.textContent = '❌ Hálózati hiba';
status.style.color = '#f87171';
}
btn.textContent = originalLabel;
btn.style.minWidth = '';
btn.disabled = false;
status.style.visibility = 'visible';
setTimeout(() => status.style.visibility = 'hidden', 5000);
}
async function logout() {
if (!confirm('Biztosan ki szeretnél lépni?')) return;
try {
// Invalidates the server-side session cookie (Basic Auth cache is not
// affected — the login page is public, no 401-overwrite is needed).
await fetch('/logout', { method: 'POST', headers: { 'X-CSRF-Token': CSRF_TOKEN } });
} catch (e) { /* network error — continue to the login page */ }
location.href = '/login';
}
function esc(v) {
return String(v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// Boot
render(DATA, document.getElementById('editor'));
// Auto-dismiss toast
const toast = document.querySelector('.toast');
if (toast) setTimeout(() => toast.remove(), 3500);
-56
View File
@@ -1,56 +0,0 @@
// Keyboard shortcuts for the Content Editor editor page. Inlined after the
// main client script; all referenced functions are global at that point.
// Ctrl/Cmd+S save · Ctrl/Cmd+P publish · Ctrl/Cmd+Shift+V versions · ? help
// Plain typing in inputs never triggers actions — the handler requires the
// modifier key (or, for '?', a non-editing target).
function showShortcutsOverlay() {
const existing = document.getElementById('shortcuts-overlay');
if (existing) { existing.remove(); return; }
const overlay = document.createElement('div');
overlay.id = 'shortcuts-overlay';
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(15,17,23,.75);z-index:300;display:flex;align-items:center;justify-content:center;padding:24px;';
overlay.innerHTML = `
<div style="background:#1a2035;border:1px solid #2d3748;border-radius:14px;padding:28px 32px;max-width:420px;width:100%;font-size:14px;line-height:2;color:#e2e8f0;">
<h2 style="font-size:16px;color:#93c5fd;margin-bottom:12px;"> Gyorsbillentyűk</h2>
<div><kbd style="background:#0f1420;border:1px solid #2d3748;border-radius:5px;padding:2px 8px;font-family:monospace;">Ctrl/Cmd + S</kbd> Mentés</div>
<div><kbd style="background:#0f1420;border:1px solid #2d3748;border-radius:5px;padding:2px 8px;font-family:monospace;">Ctrl/Cmd + P</kbd> Publikálás</div>
<div><kbd style="background:#0f1420;border:1px solid #2d3748;border-radius:5px;padding:2px 8px;font-family:monospace;">Ctrl/Cmd + Shift + V</kbd> Verziók</div>
<div><kbd style="background:#0f1420;border:1px solid #2d3748;border-radius:5px;padding:2px 8px;font-family:monospace;">?</kbd> ez a súgó (Esc: bezárás)</div>
</div>`;
overlay.addEventListener('click', () => overlay.remove());
document.body.appendChild(overlay);
}
document.addEventListener('keydown', e => {
// Esc closes the shortcut overlay if open
if (e.key === 'Escape') {
const overlay = document.getElementById('shortcuts-overlay');
if (overlay) { overlay.remove(); e.preventDefault(); }
return;
}
const mod = e.ctrlKey || e.metaKey;
if (mod && !e.shiftKey && !e.altKey && (e.key === 's' || e.key === 'S')) {
e.preventDefault();
save();
return;
}
if (mod && !e.shiftKey && !e.altKey && (e.key === 'p' || e.key === 'P')) {
e.preventDefault();
publish();
return;
}
if (mod && e.shiftKey && (e.key === 'v' || e.key === 'V')) {
e.preventDefault();
window.open('/versions?file=' + encodeURIComponent(FILE), '_blank');
return;
}
if (!mod && !e.ctrlKey && !e.metaKey && !e.altKey && e.key === '?') {
const target = e.target;
const isEditing = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable);
if (!isEditing) {
e.preventDefault();
showShortcutsOverlay();
}
}
});
-345
View File
@@ -1,345 +0,0 @@
function setMsg(target, text, ok) {
const el = document.getElementById('msg-' + target);
el.textContent = text;
el.className = 'msg ' + (ok ? 'ok' : 'err');
}
async function upload(target) {
const file = document.getElementById('file-' + target).files[0];
const btn = document.getElementById('btn-' + target);
const origText = btn.textContent;
const msg = t => setMsg(target, t, false);
if (!file) { msg('Először válassz egy új PNG fájlt a mentéshez.'); return; }
if (file.type !== 'image/png') { msg('Csak PNG fájl tölthető fel.'); return; }
if (file.size > 1024 * 1024) { msg('A fájl nagyobb, mint 1 MB.'); return; }
btn.disabled = true;
btn.textContent = '⏳ Mentés folyamatban...';
try {
const bytes = new Uint8Array(await file.arrayBuffer());
await sendLogoBinary(target, bytes);
} catch (e) { msg('❌ Hálózati hiba mentés közben'); }
btn.disabled = false;
btn.textContent = origText;
}
async function sendLogoBinary(target, bytes) {
const res = await fetch('/logo?target=' + target, {
method: 'POST',
headers: { 'Content-Type': 'image/png', 'X-CSRF-Token': CSRF_TOKEN },
body: bytes
});
if (res.status === 401) { location.href = '/login'; return; }
const json = await res.json();
if (json.ok) {
setMsg(target, '✅ Logó sikeresen elmentve! (A weboldalon a Publikálás után jelenik meg.)', true);
const variantParam = target === 'header' ? 'variant=header&' : '';
document.getElementById('prev-' + target).src = '/logo.png?' + variantParam + 't=' + Date.now();
const meta = document.getElementById('meta-' + target);
if (meta) meta.textContent = 'Módosítva (' + (bytes.length / 1024).toFixed(1) + ' KB) — elmentve';
} else setMsg(target, '❌ ' + json.error, false);
}
document.querySelectorAll('input[type=file]').forEach(inp => {
inp.addEventListener('change', () => {
const target = inp.id.replace('file-', '');
const f = inp.files[0];
const meta = document.getElementById('meta-' + target);
const prev = document.getElementById('prev-' + target);
if (!f) return;
if (f.type !== 'image/png') {
setMsg(target, 'Csak PNG formátumú kép választható ki.', false);
if (meta) meta.textContent = '';
return;
}
if (f.size > 1024 * 1024) {
setMsg(target, 'A fájl nagyobb 1 MB-nál.', false);
if (meta) meta.textContent = '';
return;
}
setMsg(target, 'Új fájl kiválasztva. Kattints a Mentés vagy a ✏️ Szerkesztés gombra.', true);
if (meta) meta.textContent = f.name + ' — ' + (f.size / 1024).toFixed(1) + ' KB (még nincs mentve)';
prev.src = URL.createObjectURL(f);
});
});
/* ── Interactive Canvas Editor Logic ────────────────────────────── */
let currentEditTarget = 'header';
let editImg = new Image();
let editState = {
zoom: 1, panX: 0, panY: 0, rotation: 0,
flipH: 1, flipV: 1, padding: 0, aspect: 0,
brightness: 100, contrast: 100, invert: false
};
const canvas = document.getElementById('edit-canvas');
const ctx = canvas.getContext('2d');
const wrap = document.getElementById('canvas-wrap');
let isDragging = false, startX = 0, startY = 0;
function openEditor(target) {
currentEditTarget = target;
document.getElementById('modal-title').textContent = '🎨 Logó szerkesztése — ' + (target === 'header' ? 'Weboldal fejléc' : 'CMS ikon');
editState.aspect = (target === 'icon' ? 1 : 0);
updateAspectBtns();
resetFilters();
resetPan();
const fileInput = document.getElementById('file-' + target);
if (fileInput.files && fileInput.files[0]) {
const reader = new FileReader();
reader.onload = e => { loadImg(e.target.result); };
reader.readAsDataURL(fileInput.files[0]);
} else {
const previewSrc = document.getElementById('prev-' + target).src;
loadImg(previewSrc);
}
}
function loadImg(src) {
editImg = new Image();
editImg.crossOrigin = 'anonymous';
editImg.onload = () => {
document.getElementById('editor-modal').classList.add('open');
fitToCrop();
render();
};
editImg.src = src;
}
function closeEditor() {
document.getElementById('editor-modal').classList.remove('open');
}
function setAspect(ratio) {
editState.aspect = ratio;
updateAspectBtns();
render();
}
function updateAspectBtns() {
document.querySelectorAll('#aspect-btns button').forEach(b => {
const a = parseFloat(b.dataset.aspect);
b.classList.toggle('active', (editState.aspect === 0 && a === 0) || (Math.abs(editState.aspect - a) < 0.01));
});
}
function setZoom(val) {
editState.zoom = parseFloat(val);
document.getElementById('zoom-val').textContent = Math.round(editState.zoom * 100) + '%';
render();
}
function setPadding(val) {
editState.padding = parseInt(val, 10);
document.getElementById('pad-val').textContent = editState.padding + 'px';
render();
}
function setFilter(name, val) {
editState[name] = parseInt(val, 10);
document.getElementById(name.slice(0, 6) + '-val').textContent = val + '%';
render();
}
function toggleInvert() {
editState.invert = !editState.invert;
document.getElementById('btn-invert').classList.toggle('active', editState.invert);
render();
}
// WHY: many partner/site logos arrive with a solid (usually white) background.
// Removing it lets the logo sit cleanly on any page background. Pixels brighter
// than the threshold become transparent; a soft ramp just below it keeps the
// edges smooth instead of jagged.
function makeTransparent() {
if (!editImg.width) return;
const off = document.createElement('canvas');
off.width = editImg.width;
off.height = editImg.height;
const octx = off.getContext('2d');
octx.drawImage(editImg, 0, 0);
const imgData = octx.getImageData(0, 0, off.width, off.height);
const d = imgData.data;
const thresh = 235; // fully transparent above this luminance
const soft = 30; // smooth ramp below the threshold
for (let i = 0; i < d.length; i += 4) {
const lum = (d[i] + d[i + 1] + d[i + 2]) / 3;
if (lum > thresh) {
d[i + 3] = 0;
} else if (lum > thresh - soft) {
const t = (lum - (thresh - soft)) / soft; // 0..1
d[i + 3] = Math.round(d[i + 3] * (1 - t));
}
}
octx.putImageData(imgData, 0, 0);
const next = new Image();
next.onload = () => { editImg = next; render(); };
next.src = off.toDataURL('image/png');
}
function resetFilters() {
editState.brightness = 100; editState.contrast = 100; editState.invert = false; editState.padding = 0;
document.getElementById('bright-range').value = 100; document.getElementById('bright-val').textContent = '100%';
document.getElementById('contrast-range').value = 100; document.getElementById('contrast-val').textContent = '100%';
document.getElementById('pad-range').value = 0; document.getElementById('pad-val').textContent = '0px';
document.getElementById('btn-invert').classList.remove('active');
render();
}
function rotate(deg) {
editState.rotation = (editState.rotation + deg) % 360;
render();
}
function toggleFlip(dir) {
if (dir === 'h') editState.flipH *= -1;
if (dir === 'v') editState.flipV *= -1;
render();
}
function resetPan() {
editState.panX = 0; editState.panY = 0;
render();
}
function getCropRect() {
const cw = canvas.width, ch = canvas.height;
let rw = cw * 0.85, rh = ch * 0.85;
if (editState.aspect > 0) {
if (rw / rh > editState.aspect) rw = rh * editState.aspect;
else rh = rw / editState.aspect;
}
return { x: (cw - rw) / 2, y: (ch - rh) / 2, w: rw, h: rh };
}
function fitToCrop() {
if (!editImg.width || !editImg.height) return;
const crop = getCropRect();
const isRotated = Math.abs(editState.rotation) === 90 || Math.abs(editState.rotation) === 270;
const iw = isRotated ? editImg.height : editImg.width;
const ih = isRotated ? editImg.width : editImg.height;
const scale = Math.min(crop.w / iw, crop.h / ih);
editState.zoom = Math.max(0.3, Math.min(3, scale));
document.getElementById('zoom-range').value = editState.zoom;
document.getElementById('zoom-val').textContent = Math.round(editState.zoom * 100) + '%';
editState.panX = 0; editState.panY = 0;
render();
}
function render() {
if (!editImg.width) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
const crop = getCropRect();
// Draw image
ctx.save();
ctx.filter = 'brightness(' + editState.brightness + '%) contrast(' + editState.contrast + '%)' + (editState.invert ? ' invert(100%)' : '');
ctx.translate(canvas.width / 2 + editState.panX, canvas.height / 2 + editState.panY);
ctx.rotate((editState.rotation * Math.PI) / 180);
ctx.scale(editState.zoom * editState.flipH, editState.zoom * editState.flipV);
const pad = editState.padding / (editState.zoom || 1);
const dw = Math.max(10, editImg.width - pad * 2);
const dh = Math.max(10, editImg.height - pad * 2);
ctx.drawImage(editImg, -dw / 2, -dh / 2, dw, dh);
ctx.restore();
// Dark overlay outside crop rect
ctx.save();
ctx.fillStyle = 'rgba(15, 17, 23, 0.75)';
ctx.fillRect(0, 0, canvas.width, crop.y);
ctx.fillRect(0, crop.y + crop.h, canvas.width, canvas.height - (crop.y + crop.h));
ctx.fillRect(0, crop.y, crop.x, crop.h);
ctx.fillRect(crop.x + crop.w, crop.y, canvas.width - (crop.x + crop.w), crop.h);
// Crop border
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 2;
ctx.setLineDash([6, 4]);
ctx.strokeRect(crop.x, crop.y, crop.w, crop.h);
ctx.restore();
}
// Drag & Pan handlers
wrap.addEventListener('mousedown', e => { isDragging = true; startX = e.clientX - editState.panX; startY = e.clientY - editState.panY; wrap.classList.add('grabbing'); });
window.addEventListener('mousemove', e => { if (!isDragging) return; editState.panX = e.clientX - startX; editState.panY = e.clientY - startY; render(); });
window.addEventListener('mouseup', () => { isDragging = false; wrap.classList.remove('grabbing'); });
wrap.addEventListener('wheel', e => {
e.preventDefault();
const delta = e.deltaY < 0 ? 0.05 : -0.05;
setZoom(Math.max(0.3, Math.min(3, editState.zoom + delta)));
document.getElementById('zoom-range').value = editState.zoom;
}, { passive: false });
async function saveEditedLogo() {
const crop = getCropRect();
const outCanvas = document.createElement('canvas');
outCanvas.width = Math.round(crop.w * 2); // 2x for retina sharpness
outCanvas.height = Math.round(crop.h * 2);
const octx = outCanvas.getContext('2d');
octx.save();
octx.scale(2, 2);
octx.translate(-crop.x, -crop.y);
octx.filter = 'brightness(' + editState.brightness + '%) contrast(' + editState.contrast + '%)' + (editState.invert ? ' invert(100%)' : '');
octx.translate(canvas.width / 2 + editState.panX, canvas.height / 2 + editState.panY);
octx.rotate((editState.rotation * Math.PI) / 180);
octx.scale(editState.zoom * editState.flipH, editState.zoom * editState.flipV);
const pad = editState.padding / (editState.zoom || 1);
const dw = Math.max(10, editImg.width - pad * 2);
const dh = Math.max(10, editImg.height - pad * 2);
octx.drawImage(editImg, -dw / 2, -dh / 2, dw, dh);
octx.restore();
const saveBtn = document.getElementById('modal-save-btn');
saveBtn.disabled = true;
saveBtn.textContent = '⏳ Mentés folyamatban...';
outCanvas.toBlob(async blob => {
if (!blob) { alert('Hiba a kép exportálásakor'); saveBtn.disabled = false; return; }
try {
const bytes = new Uint8Array(await blob.arrayBuffer());
await sendLogoBinary(currentEditTarget, bytes);
closeEditor();
} catch (e) {
alert('Hiba történt a mentés során.');
}
saveBtn.disabled = false;
saveBtn.textContent = '💾 Szerkesztett logó mentése';
}, 'image/png');
}
async function uploadPartner() {
const name = document.getElementById('partner-name').value.trim();
const file = document.getElementById('file-partner').files[0];
const msg = document.getElementById('msg-partner');
const pathOut = document.getElementById('path-partner');
const btn = document.getElementById('btn-partner');
pathOut.textContent = '';
if (!name) { msg.textContent = '❌ Adj meg egy fájlnevet.'; msg.className = 'msg err'; return; }
if (!file) { msg.textContent = '❌ Válassz PNG fájlt.'; msg.className = 'msg err'; return; }
if (file.type !== 'image/png') { msg.textContent = '❌ Csak PNG tölthető fel.'; msg.className = 'msg err'; return; }
btn.disabled = true;
try {
const bytes = new Uint8Array(await file.arrayBuffer());
const res = await fetch('/partner-logo?name=' + encodeURIComponent(name), {
method: 'POST',
headers: { 'Content-Type': 'image/png', 'X-CSRF-Token': CSRF_TOKEN },
body: bytes
});
if (res.status === 401) { location.href = '/login'; return; }
const json = await res.json();
if (json.ok) {
msg.textContent = '✅ Feltöltve.';
msg.className = 'msg ok';
pathOut.textContent = 'Elérési út: ' + json.path;
} else {
msg.textContent = '❌ ' + json.error;
msg.className = 'msg err';
}
} catch (e) {
msg.textContent = '❌ Hálózati hiba';
msg.className = 'msg err';
}
btn.disabled = false;
}
-203
View File
@@ -1,203 +0,0 @@
// Branding page for the Content Editor: upload/replace and edit logos with
// interactive Canvas editor (crop, zoom/pan, rotate/flip, padding, filters).
// The browser-side editor script is inlined from cms-logo-client.js.
const fs = require('fs');
const path = require('path');
const { LOGO_TARGETS } = require('./cms-logo');
const logoClientJs = fs.readFileSync(path.join(__dirname, 'cms-logo-client.js'), 'utf8');
const LOGO_PAGE = (csrfToken) => `<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>mozdIT Logó kezelése</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e2e8f0; line-height: 1.6; padding-bottom: 64px; }
header { background: linear-gradient(135deg,#1a1f2e,#252d40); border-bottom: 1px solid #2d3748; padding: 14px 32px; display: flex; align-items: center; gap: 12px; position: sticky; top: 0; z-index: 10; }
header h1 { font-size: 17px; font-weight: 700; background: linear-gradient(135deg,#60a5fa,#a78bfa); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
header a { color: #94a3b8; text-decoration: none; font-size: 14px; margin-left: auto; }
header a:hover { color: #e2e8f0; }
main { max-width: 720px; margin: 0 auto; padding: 28px 24px; }
.note { color: #94a3b8; font-size: 14px; margin-bottom: 22px; }
.card { background: #1a2035; border: 1px solid #2d3748; border-radius: 12px; padding: 20px 22px; margin-bottom: 18px; }
.card h2 { font-size: 16px; color: #93c5fd; margin-bottom: 4px; }
.card .where { color: #64748b; font-size: 13px; margin-bottom: 14px; }
.field-label { display: block; font-size: 13px; color: #94a3b8; margin: 12px 0 6px; }
#partner-name { width: 100%; background: #0f1420; border: 1px solid #2d3748; border-radius: 8px; color: #e2e8f0; padding: 9px 12px; font-size: 14px; margin-bottom: 10px; }
.path-out { font-family: monospace; font-size: 13px; color: #6ee7b7; margin-top: 10px; word-break: break-all; }
.preview { background: repeating-conic-gradient(#1e293b 0% 25%, #0f1420 0% 50%) 50% / 22px 22px; border: 1px solid #2d3748; border-radius: 10px; padding: 16px; margin-bottom: 14px; text-align: center; min-height: 90px; }
.preview img { max-width: 100%; max-height: 72px; }
input[type=file] { color: #94a3b8; font-size: 14px; margin-bottom: 12px; width: 100%; }
.meta { font-size: 13px; color: #94a3b8; min-height: 20px; margin-bottom: 12px; }
.actions-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.btn-save { background: linear-gradient(135deg,#059669,#10b981); color: #fff; border: none; border-radius: 8px; padding: 9px 18px; font-size: 14px; font-weight: 700; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; }
.btn-edit { background: linear-gradient(135deg,#3b82f6,#6366f1); color: #fff; border: none; border-radius: 8px; padding: 9px 16px; font-size: 14px; font-weight: 600; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; }
.btn-secondary { background: #334155; color: #e2e8f0; border: 1px solid #475569; border-radius: 8px; padding: 9px 16px; font-size: 14px; font-weight: 600; cursor: pointer; }
button:hover { filter: brightness(1.1); }
button:disabled { opacity: .5; cursor: wait; }
.msg { font-size: 14px; margin-top: 12px; min-height: 20px; }
.ok { color: #6ee7b7; } .err { color: #fca5a5; }
/* Modal Styles */
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.85); backdrop-filter: blur(4px); z-index: 1000; display: none; align-items: center; justify-content: center; padding: 14px; }
.modal-backdrop.open { display: flex; }
.modal-box { background: #1a2035; border: 1px solid #334155; border-radius: 14px; width: 100%; max-width: 860px; max-height: 92vh; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 20px 40px rgba(0,0,0,0.6); }
.modal-header { padding: 12px 20px; border-bottom: 1px solid #2d3748; display: flex; align-items: center; justify-content: space-between; background: #141824; }
.modal-header h3 { font-size: 16px; color: #93c5fd; }
.btn-close { background: transparent; border: none; color: #94a3b8; font-size: 18px; cursor: pointer; padding: 4px 8px; }
.modal-body { display: grid; grid-template-columns: 1fr 280px; gap: 16px; padding: 16px; overflow-y: auto; max-height: calc(92vh - 120px); }
@media (max-width: 720px) { .modal-body { grid-template-columns: 1fr; } }
.canvas-container { background: repeating-conic-gradient(#1e293b 0% 25%, #0f1420 0% 50%) 50% / 20px 20px; border: 1px solid #334155; border-radius: 10px; display: flex; align-items: center; justify-content: center; min-height: 320px; position: relative; overflow: hidden; cursor: grab; user-select: none; }
.canvas-container.grabbing { cursor: grabbing; }
canvas { max-width: 100%; max-height: 100%; display: block; }
.editor-controls { display: flex; flex-direction: column; gap: 14px; font-size: 13px; color: #cbd5e1; }
.ctrl-group { background: #141824; border: 1px solid #2d3748; border-radius: 8px; padding: 10px 12px; }
.ctrl-group h4 { font-size: 12px; text-transform: uppercase; color: #94a3b8; margin-bottom: 8px; letter-spacing: 0.5px; }
.btn-row { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 6px; }
.btn-tool { background: #1e293b; border: 1px solid #475569; color: #cbd5e1; border-radius: 6px; padding: 5px 9px; font-size: 12px; cursor: pointer; }
.btn-tool.active { background: #2563eb; color: #fff; border-color: #3b82f6; }
.slider-row { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
.slider-row label { width: 68px; font-size: 12px; color: #94a3b8; }
.slider-row input[type=range] { flex: 1; accent-color: #3b82f6; }
.slider-row span { width: 38px; font-size: 11px; text-align: right; color: #cbd5e1; }
.modal-footer { padding: 12px 20px; border-top: 1px solid #2d3748; background: #141824; display: flex; justify-content: flex-end; gap: 10px; align-items: center; }
</style>
</head>
<body>
<header>
<h1>🎨 Logó kezelése</h1>
<a href="/"> Vissza a szerkesztőhöz</a>
</header>
<main>
<p class="note">Csak <strong>PNG</strong> fájl tölthető fel (max. 1 MB). A régi logóról automatikus biztonsági mentés készül. A szerkesztővel közvetlenül vágatod, méretezheted, forgathatod és korrigálhatod a logókat a mentés előtt. A változás a <strong>weboldalon a Publikálás (deploy) után</strong> jelenik meg.</p>
<div class="card">
<h2>Weboldal fejléc logója (szöveges)</h2>
<p class="where">Használat: weboldal fejléc jelenlegi fájl: /${LOGO_TARGETS.header}</p>
<div class="preview"><img id="prev-header" src="/logo.png?variant=header&t=${Date.now()}" alt="fejléc logó előnézet"></div>
<input type="file" id="file-header" accept="image/png">
<div class="meta" id="meta-header"></div>
<div class="actions-row">
<button class="btn-save" id="btn-header" onclick="upload('header')">💾 Fejléc logó mentése</button>
<button class="btn-edit" onclick="openEditor('header')"> Logó szerkesztése</button>
</div>
<p class="msg" id="msg-header"></p>
</div>
<div class="card">
<h2>CMS logó (ikon)</h2>
<p class="where">Használat: CMS bejelentkező oldal jelenlegi fájl: /${LOGO_TARGETS.icon}</p>
<div class="preview"><img id="prev-icon" src="/logo.png?t=${Date.now()}" alt="ikon logó előnézet"></div>
<input type="file" id="file-icon" accept="image/png">
<div class="meta" id="meta-icon"></div>
<div class="actions-row">
<button class="btn-save" id="btn-icon" onclick="upload('icon')">💾 Ikon logó mentése</button>
<button class="btn-edit" onclick="openEditor('icon')"> Logó szerkesztése</button>
</div>
<p class="msg" id="msg-icon"></p>
</div>
<div class="card">
<h2>Partner logó feltöltése</h2>
<p class="where">Használat: a Kezdőlap Partnereink" szekciójához. A feltöltés után a visszaadott elérési utat másold a partner „logo" mezőjébe (pl. /partners/nev.png).</p>
<label for="partner-name" class="field-label">Fájlnév (szóközök nélkül, pl. acme")</label>
<input type="text" id="partner-name" placeholder="acme">
<input type="file" id="file-partner" accept="image/png">
<div class="meta" id="meta-partner"></div>
<button class="btn-save" id="btn-partner" onclick="uploadPartner()"> Partner logó feltöltése</button>
<p class="msg" id="msg-partner"></p>
<p class="path-out" id="path-partner"></p>
</div>
</main>
<!-- Editor Modal -->
<div class="modal-backdrop" id="editor-modal">
<div class="modal-box">
<div class="modal-header">
<h3 id="modal-title">🎨 Logó szerkesztése</h3>
<button class="btn-close" onclick="closeEditor()"></button>
</div>
<div class="modal-body">
<div class="canvas-container" id="canvas-wrap">
<canvas id="edit-canvas" width="500" height="350"></canvas>
</div>
<div class="editor-controls">
<div class="ctrl-group">
<h4>Képarány / Vágás</h4>
<div class="btn-row" id="aspect-btns">
<button class="btn-tool" data-aspect="0" onclick="setAspect(0)">Szabad</button>
<button class="btn-tool active" data-aspect="1" onclick="setAspect(1)">1:1</button>
<button class="btn-tool" data-aspect="3" onclick="setAspect(3)">3:1</button>
<button class="btn-tool" data-aspect="4" onclick="setAspect(4)">4:1</button>
<button class="btn-tool" data-aspect="1.777" onclick="setAspect(1.777)">16:9</button>
</div>
</div>
<div class="ctrl-group">
<h4>Méret és Pozíció</h4>
<div class="slider-row">
<label>Nagyítás:</label>
<input type="range" id="zoom-range" min="0.3" max="3" step="0.05" value="1" oninput="setZoom(this.value)">
<span id="zoom-val">100%</span>
</div>
<div class="btn-row" style="margin-top:8px;">
<button class="btn-tool" onclick="resetPan()"> Középre</button>
<button class="btn-tool" onclick="fitToCrop()"> Keretbe illesztés</button>
</div>
</div>
<div class="ctrl-group">
<h4>Forgatás & Tükrözés</h4>
<div class="btn-row">
<button class="btn-tool" onclick="rotate(-90)"> Balra 90°</button>
<button class="btn-tool" onclick="rotate(90)"> Jobbra 90°</button>
<button class="btn-tool" onclick="toggleFlip('h')"> Vízszintes</button>
<button class="btn-tool" onclick="toggleFlip('v')"> Függőleges</button>
</div>
</div>
<div class="ctrl-group">
<h4>Margó / Padding</h4>
<div class="slider-row">
<label>Margó:</label>
<input type="range" id="pad-range" min="0" max="60" step="2" value="0" oninput="setPadding(this.value)">
<span id="pad-val">0px</span>
</div>
</div>
<div class="ctrl-group">
<h4>Képkorrekció</h4>
<div class="slider-row">
<label>Fényerő:</label>
<input type="range" id="bright-range" min="50" max="200" value="100" oninput="setFilter('brightness', this.value)">
<span id="bright-val">100%</span>
</div>
<div class="slider-row">
<label>Kontraszt:</label>
<input type="range" id="contrast-range" min="50" max="200" value="100" oninput="setFilter('contrast', this.value)">
<span id="contrast-val">100%</span>
</div>
<div class="btn-row" style="margin-top:8px;">
<button class="btn-tool" id="btn-invert" onclick="toggleInvert()">🌓 Invertálás</button>
<button class="btn-tool" onclick="resetFilters()"> Alaphelyzet</button>
<button class="btn-tool" onclick="makeTransparent()"> Háttér átlátszóvá (fehér)</button>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick="closeEditor()">Mégse</button>
<button class="btn-save" id="modal-save-btn" onclick="saveEditedLogo()">💾 Szerkesztett logó mentése</button>
</div>
</div>
</div>
<script>
const CSRF_TOKEN = "${csrfToken}";
${logoClientJs}
</script>
</body>
</html>`;
module.exports = { LOGO_PAGE };
-156
View File
@@ -1,156 +0,0 @@
// Logo upload handling for the Content Editor: PNG validation, timestamped
// backup and atomic binary replace.
const fs = require('fs');
const path = require('path');
const MAX_LOGO_BYTES = 1024 * 1024; // 1 MiB — plenty for a logo
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
// WHY fixed targets instead of a client-supplied filename: arbitrary write
// paths would be a traversal risk; the two known logos are the only assets
// the site consumes.
const LOGO_TARGETS = {
icon: 'mozdit_logo.png', // CMS login page
header: 'mozdit_logo_text.png', // website Header
};
function isPng(buffer) {
return Buffer.isBuffer(buffer) && buffer.length >= PNG_MAGIC.length && buffer.subarray(0, PNG_MAGIC.length).equals(PNG_MAGIC);
}
function saveLogoAtomically(publicDir, targetKey, buffer, backupDir) {
const fileName = LOGO_TARGETS[targetKey];
if (!fileName) throw new Error('Ismeretlen logó célpont');
const targetFile = path.join(publicDir, fileName);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupName = `${fileName}.${timestamp}.bak`;
fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
fs.copyFileSync(targetFile, path.join(backupDir, backupName));
const tempFile = `${targetFile}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempFile, buffer, { mode: 0o644 });
fs.renameSync(tempFile, targetFile);
return { targetFile, backupName };
}
// WHY: partner logos are a variable set — the filename comes from the editor,
// so it must be sanitized to a safe slug (no traversal, no separators).
function slugifyName(raw) {
return String(raw)
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64);
}
function savePartnerLogo(publicDir, filename, buffer) {
const slug = slugifyName(filename) || `partner-${Date.now()}`;
const dir = path.join(publicDir, 'partners');
fs.mkdirSync(dir, { recursive: true, mode: 0o755 });
const targetFile = path.join(dir, `${slug}.png`);
const tempFile = `${targetFile}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempFile, buffer, { mode: 0o644 });
fs.renameSync(tempFile, targetFile);
return `/partners/${slug}.png`;
}
// WHY: route handling lives here so content-editor.js stays under the
// 400-line limit. Returns true when the request was handled.
function handleLogoRoutes({ req, res, u, publicDir, backupDir, writeAudit, clientAddress, user, logoPage }) {
if (req.method === 'GET' && u.pathname === '/branding') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(logoPage());
return true;
}
if (req.method === 'POST' && u.pathname === '/logo') {
const target = u.searchParams.get('target') || '';
if (!LOGO_TARGETS[target]) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'Ismeretlen logó célpont.' }));
return true;
}
const chunks = [];
let total = 0;
let tooLarge = false;
req.on('data', c => {
total += c.length;
if (total > MAX_LOGO_BYTES) { tooLarge = true; return; }
chunks.push(c);
});
req.on('end', () => {
const buffer = Buffer.concat(chunks);
if (tooLarge) {
writeAudit('logo_updated', { clientAddress, user, target, result: 'request_too_large' });
res.writeHead(413, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: `A fájl túl nagy (maximum ${MAX_LOGO_BYTES} byte).` }));
return;
}
if (!isPng(buffer)) {
writeAudit('logo_updated', { clientAddress, user, target, result: 'invalid_type' });
res.writeHead(415, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'Csak érvényes PNG fájl tölthető fel.' }));
return;
}
try {
const { backupName } = saveLogoAtomically(publicDir, target, buffer, backupDir);
writeAudit('logo_updated', { clientAddress, user, target, result: 'ok', backup: backupName });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, backup: backupName }));
} catch (e) {
writeAudit('logo_updated', { clientAddress, user, target, result: 'error' });
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: e.message }));
}
});
return true;
}
if (req.method === 'POST' && u.pathname === '/partner-logo') {
const filename = u.searchParams.get('name') || '';
if (!slugifyName(filename)) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'Adj meg egy érvényes fájlnevet.' }));
return true;
}
const chunks = [];
let total = 0;
let tooLarge = false;
req.on('data', c => {
total += c.length;
if (total > MAX_LOGO_BYTES) { tooLarge = true; return; }
chunks.push(c);
});
req.on('end', () => {
const buffer = Buffer.concat(chunks);
if (tooLarge) {
writeAudit('partner_logo_upload', { clientAddress, user, result: 'request_too_large' });
res.writeHead(413, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: `A fájl túl nagy (maximum ${MAX_LOGO_BYTES} byte).` }));
return;
}
if (!isPng(buffer)) {
writeAudit('partner_logo_upload', { clientAddress, user, result: 'invalid_type' });
res.writeHead(415, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'Csak érvényes PNG fájl tölthető fel.' }));
return;
}
try {
const publicPath = savePartnerLogo(publicDir, filename, buffer);
writeAudit('partner_logo_upload', { clientAddress, user, result: 'ok', path: publicPath });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, path: publicPath }));
} catch (e) {
writeAudit('partner_logo_upload', { clientAddress, user, result: 'error' });
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: e.message }));
}
});
return true;
}
return false;
}
module.exports = { MAX_LOGO_BYTES, LOGO_TARGETS, isPng, saveLogoAtomically, savePartnerLogo, slugifyName, handleLogoRoutes };
-325
View File
@@ -1,325 +0,0 @@
function escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// Page templates for the Content Editor. Kept separate so content-editor.js
// stays focused on routing/handling and below the file-size limits.
const isStaging = () => process.env.CMS_DEPLOY_ENV === 'staging';
// FILE_LABELS is injected to avoid a circular dependency with the main file.
const HTML = (activeFile, jsonData, message, csrfToken, fileLabels, clientJs, contentHash, deployVersion) => `<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${isStaging() ? 'STAGING — ' : ''}mozdIT Content Editor</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e2e8f0; min-height: 100vh; }
.environment-banner { background: #f59e0b; color: #111827; padding: 9px 32px; text-align: center; font-size: 13px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
header { background: linear-gradient(135deg,#1a1f2e,#252d40); border-bottom: 1px solid #2d3748; padding: 14px 32px; display: flex; align-items: center; gap: 12px; }
header h1 { font-size: 17px; font-weight: 700; background: linear-gradient(135deg,#60a5fa,#a78bfa); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
header span { color: #64748b; font-size: 13px; }
.tabs { display: flex; gap: 2px; padding: 14px 32px 0; border-bottom: 1px solid #2d3748; background: #13192a; }
.tab { text-decoration: none; color: #94a3b8; padding: 9px 16px; border-radius: 8px 8px 0 0; font-size: 13px; font-weight: 500; transition: all .2s; border: 1px solid transparent; border-bottom: none; margin-bottom: -1px; }
.tab:hover { color: #e2e8f0; background: #1e2535; }
.tab.active { color: #60a5fa; background: #0f1117; border-color: #2d3748; }
.page { max-width: 860px; margin: 28px auto 120px; padding: 0 24px; }
.hint { color: #475569; font-size: 12px; margin-bottom: 20px; }
/* Primitive field */
.field { background: #1a2035; border: 1px solid #2d3748; border-radius: 10px; padding: 14px 16px; transition: border-color .2s; margin-bottom: 10px; }
.field:focus-within { border-color: #60a5fa; }
.field label { display: block; font-size: 11px; font-weight: 600; color: #60a5fa; text-transform: uppercase; letter-spacing:.05em; margin-bottom: 7px; font-family: monospace; }
.field input, .field textarea { width: 100%; background: transparent; border: none; outline: none; color: #e2e8f0; font-size: 14px; line-height: 1.6; resize: vertical; font-family: inherit; }
.field textarea { min-height: 52px; }
/* Array section */
.array-section { margin-bottom: 20px; }
.array-label { font-size: 12px; font-weight: 700; color: #a78bfa; text-transform: uppercase; letter-spacing:.06em; font-family: monospace; margin-bottom: 10px; display: flex; align-items: center; gap: 8px; }
.array-label::after { content:''; flex: 1; height: 1px; background: #2d3748; }
.array-items { display: flex; flex-direction: column; gap: 8px; }
/* Simple string array item */
.str-item { display: flex; gap: 8px; align-items: flex-start; }
.str-item textarea { flex: 1; background: #1a2035; border: 1px solid #2d3748; border-radius: 8px; padding: 10px 12px; color: #e2e8f0; font-size: 14px; font-family: inherit; outline: none; resize: vertical; min-height: 44px; transition: border-color .2s; }
.str-item textarea:focus { border-color: #60a5fa; }
/* Object array item (card) */
.obj-card { background: #1a2035; border: 1px solid #2d3748; border-radius: 10px; padding: 14px; position: relative; }
.obj-card .card-header { font-size: 11px; color: #64748b; font-family: monospace; margin-bottom: 10px; }
.obj-card .inner-field { margin-bottom: 8px; }
.obj-card .inner-field:last-child { margin-bottom: 0; }
.obj-card .inner-label { font-size: 10px; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing:.05em; font-family: monospace; margin-bottom: 4px; }
.obj-card input, .obj-card textarea { width: 100%; background: #0f1420; border: 1px solid #2d3748; border-radius: 6px; padding: 8px 10px; color: #e2e8f0; font-size: 13px; font-family: inherit; outline: none; resize: vertical; transition: border-color .2s; }
.obj-card input:focus, .obj-card textarea:focus { border-color: #60a5fa; }
/* Buttons */
.btn-del { background: transparent; border: 1px solid #3f1c1c; color: #f87171; border-radius: 7px; padding: 6px 10px; cursor: pointer; font-size: 13px; transition: all .2s; white-space: nowrap; flex-shrink: 0; }
.btn-del:hover { background: #3f1c1c; }
.btn-del-card { position: absolute; top: 10px; right: 10px; background: transparent; border: 1px solid #3f1c1c; color: #f87171; border-radius: 6px; padding: 4px 8px; cursor: pointer; font-size: 12px; transition: all .2s; }
.btn-del-card:hover { background: #3f1c1c; }
.btn-add { background: transparent; border: 1px dashed #334155; color: #64748b; border-radius: 8px; padding: 9px 16px; cursor: pointer; font-size: 13px; width: 100%; text-align: center; transition: all .2s; margin-top: 6px; }
.btn-add:hover { border-color: #a78bfa; color: #a78bfa; background: #1a1535; }
/* Bottom bar */
.bottom-bar { position: fixed; bottom: 0; left: 0; right: 0; background: #0f1117; border-top: 1px solid #2d3748; padding: 14px 32px; display: flex; gap: 14px; align-items: center; z-index: 50; }
.btn-logout { background: #1f2937; color: #e2e8f0; border: 1px solid #374151; border-radius: 8px; padding: 9px 16px; font-size: 14px; cursor: pointer; }
.btn-logout:hover { background: #374151; }
.version-tag { color: #475569; font-size: 12px; font-family: monospace; }
.btn-save { background: linear-gradient(135deg,#3b82f6,#6366f1); color: #fff; border: none; padding: 11px 26px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; transition: opacity .2s, transform .1s; }
.btn-save:hover { opacity: .9; transform: translateY(-1px); }
.btn-save:active { transform: translateY(0); }
.btn-publish { background: linear-gradient(135deg,#10b981,#059669); color: #fff; border: none; padding: 11px 26px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; transition: opacity .2s, transform .1s; }
.btn-publish:hover { opacity: .9; transform: translateY(-1px); }
.btn-publish:active { transform: translateY(0); }
.preview-link { color: #64748b; font-size: 13px; text-decoration: none; }
.preview-link:hover { color: #94a3b8; }
/* WHY: the status slot always occupies the same flex space (visibility, not
display) so showing/hiding messages never shifts the other bar items. */
.save-status { flex: 1 1 0; min-width: 0; margin: 0 8px; font-size: 13px; font-weight: 500; visibility: hidden; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Toast */
.toast { position: fixed; top: 20px; right: 20px; padding: 13px 18px; border-radius: 9px; font-size: 14px; font-weight: 500; z-index: 200; animation: slideIn .3s ease; }
.toast.ok { background: #064e3b; border: 1px solid #10b981; color: #6ee7b7; }
.toast.err { background: #450a0a; border: 1px solid #ef4444; color: #fca5a5; }
@keyframes slideIn { from { opacity:0; transform: translateX(20px); } to { opacity:1; transform: translateX(0); } }
</style>
</head>
<body>
${isStaging() ? '<div class="environment-banner">⚠ STAGING / TESZTKÖRNYEZET — itt végzett publikálás csak a staging oldalt frissíti</div>' : ''}
${message ? `<div class="toast ${message.type === 'ok' ? 'ok' : 'err'}">${escHtml(message.text)}</div>` : ''}
<header>
<h1>mozdIT Content Editor</h1>
<span> JSON fájlok szerkesztése vizuálisan</span>
</header>
<nav class="tabs">
${Object.entries(fileLabels).map(([k, l]) =>
`<a href="/?file=${k}" class="tab ${activeFile === k ? 'active' : ''}">${l}</a>`
).join('')}
</nav>
<div class="page">
<p class="hint">📝 Szerkeszd a mezőket. Tömbökből elemet törölhetsz () vagy hozzáadhatsz (). Mentés gomb menti a fájlt.</p>
<div id="editor"></div>
</div>
<div class="bottom-bar">
<button class="btn-save" onclick="save()">💾 Mentés</button>
<button class="btn-publish" onclick="publish()" id="publishBtn">🚀 Publikálás & ${isStaging() ? 'Staging deploy' : 'Élesítés'}</button>
<span class="save-status" id="saveStatus"></span>
<a href="${isStaging() ? 'https://stage.mozdit.hu' : 'http://localhost:3000'}" target="_blank" class="preview-link">🔗 Előnézet </a>
<a href="/guide" target="_blank" class="preview-link"> Súgó</a>
<a href="/versions?file=${activeFile}" target="_blank" class="preview-link">🕘 Verziók</a>
<a href="/branding" target="_blank" class="preview-link">🎨 Logó</a>
<span class="version-tag" title="Futó kód verziója (git SHA)">v${deployVersion}</span>
<button class="btn-logout" onclick="logout()">🚪 Kilépés</button>
</div>
<script id="page-data" type="application/json">${jsonData.replace(/<\//g, '<\\/')}</script>
<script>
const DATA = JSON.parse(document.getElementById('page-data').textContent);
const FILE = "${activeFile}";
const CSRF_TOKEN = "${csrfToken}";
let CONTENT_HASH = "${contentHash}";
${clientJs}
</script>
</script>
</body>
</html>`;
// User guide page — renders docs/felhasznaloi-utmutato.md with the shared dark theme.
const GUIDE_PAGE = (contentHtml) => `<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>mozdIT Felhasználói útmutató</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e2e8f0; line-height: 1.65; padding-bottom: 64px; }
header { background: linear-gradient(135deg,#1a1f2e,#252d40); border-bottom: 1px solid #2d3748; padding: 14px 32px; display: flex; align-items: center; gap: 12px; position: sticky; top: 0; z-index: 10; }
header h1 { font-size: 17px; font-weight: 700; background: linear-gradient(135deg,#60a5fa,#a78bfa); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
header a { color: #94a3b8; text-decoration: none; font-size: 14px; margin-left: auto; }
header a:hover { color: #e2e8f0; }
main { max-width: 760px; margin: 0 auto; padding: 32px 24px; }
h1 { font-size: 24px; margin: 16px 0 12px; color: #f1f5f9; }
h2 { font-size: 20px; margin: 28px 0 10px; color: #93c5fd; border-bottom: 1px solid #2d3748; padding-bottom: 6px; }
h3 { font-size: 16px; margin: 20px 0 8px; color: #c4b5fd; }
h4 { font-size: 14px; margin: 16px 0 6px; color: #c4b5fd; }
p { margin: 8px 0; }
ul, ol { margin: 8px 0 8px 22px; }
li { margin: 4px 0; }
a { color: #7dd3fc; }
code { background: #1e293b; border-radius: 4px; padding: 1px 6px; font-size: 0.9em; color: #fbbf24; }
pre { background: #1e293b; border: 1px solid #2d3748; border-radius: 8px; padding: 12px 16px; overflow-x: auto; margin: 12px 0; }
pre code { background: none; padding: 0; color: #e2e8f0; }
hr { border: none; border-top: 1px solid #2d3748; margin: 24px 0; }
</style>
</head>
<body>
<header>
<h1>mozdIT Felhasználói útmutató</h1>
<a href="/"> Vissza a szerkesztőhöz</a>
</header>
<main>
${contentHtml}
</main>
</body>
</html>`;
// Login page — simple logo page shown after logout (and for unauthenticated browser visits).
const LOGIN_PAGE = () => `<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>mozdIT CMS Belépés</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
.environment-banner { background: #f59e0b; color: #111827; padding: 9px 32px; text-align: center; font-size: 13px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; position: fixed; top: 0; left: 0; right: 0; }
.card { background: linear-gradient(160deg,#1a1f2e,#252d40); border: 1px solid #2d3748; border-radius: 16px; padding: 40px 36px; width: 100%; max-width: 380px; box-shadow: 0 20px 50px rgba(0,0,0,.45); }
.logo { text-align: center; margin-bottom: 28px; }
.logo img { height: 56px; }
h1 { font-size: 20px; font-weight: 700; text-align: center; margin-bottom: 4px; }
.subtitle { color: #94a3b8; font-size: 14px; text-align: center; margin-bottom: 26px; }
label { display: block; font-size: 13px; color: #94a3b8; margin: 14px 0 6px; }
input { width: 100%; background: #0f1117; border: 1px solid #2d3748; border-radius: 8px; color: #e2e8f0; padding: 11px 14px; font-size: 15px; }
input:focus { outline: none; border-color: #60a5fa; }
button { width: 100%; margin-top: 24px; background: linear-gradient(135deg,#3b82f6,#8b5cf6); color: #fff; border: none; border-radius: 8px; padding: 12px; font-size: 15px; font-weight: 700; cursor: pointer; }
button:hover { filter: brightness(1.1); }
button:disabled { opacity: .6; cursor: wait; }
.error { color: #f87171; font-size: 14px; text-align: center; margin-top: 14px; min-height: 20px; }
</style>
</head>
<body>
${isStaging() ? '<div class="environment-banner">⚠ STAGING / TESZTKÖRNYEZET</div>' : ''}
<div class="card">
<div class="logo"><img src="/logo.png" alt="mozdIT"></div>
<h1>Content Editor</h1>
<p class="subtitle">Belépés a tartalomszerkesztőbe</p>
<form onsubmit="return login(event)">
<label for="user">Felhasználónév</label>
<input id="user" name="user" autocomplete="username" autofocus required>
<label for="pass">Jelszó</label>
<input id="pass" name="pass" type="password" autocomplete="current-password" required>
<button type="submit" id="btn">Belépés</button>
</form>
<p class="error" id="err"></p>
</div>
<script>
async function login(e) {
e.preventDefault();
const btn = document.getElementById('btn');
const err = document.getElementById('err');
btn.disabled = true; err.textContent = '';
try {
const res = await fetch('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user: document.getElementById('user').value, pass: document.getElementById('pass').value })
});
if (res.ok) { location.href = '/'; return; }
const json = await res.json().catch(() => ({}));
err.textContent = json.error || 'Sikertelen belépés — ellenőrizd a felhasználónevet és a jelszót.';
} catch (e2) {
err.textContent = 'Hálózati hiba — próbáld újra.';
}
btn.disabled = false;
}
</script>
</body>
</html>`;
// Version history page: lists automatic backups of the selected file with a
// diff view (?show=) and one-click restore (POST /restore).
const VERSIONS_PAGE = (fileKey, fileLabel, versions, diff, csrfToken) => `<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>mozdIT Verziók: ${fileLabel}</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e2e8f0; line-height: 1.6; padding-bottom: 64px; }
header { background: linear-gradient(135deg,#1a1f2e,#252d40); border-bottom: 1px solid #2d3748; padding: 14px 32px; display: flex; align-items: center; gap: 12px; position: sticky; top: 0; z-index: 10; }
header h1 { font-size: 17px; font-weight: 700; background: linear-gradient(135deg,#60a5fa,#a78bfa); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
header a { color: #94a3b8; text-decoration: none; font-size: 14px; margin-left: auto; }
header a:hover { color: #e2e8f0; }
main { max-width: 860px; margin: 0 auto; padding: 28px 24px; }
.note { color: #94a3b8; font-size: 14px; margin-bottom: 18px; }
.ver { background: #1a2035; border: 1px solid #2d3748; border-radius: 10px; padding: 14px 18px; margin-bottom: 10px; display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.ver .when { font-family: monospace; font-size: 14px; color: #93c5fd; }
.ver .size { color: #64748b; font-size: 13px; }
.ver .actions { margin-left: auto; display: flex; gap: 8px; }
.btn { background: #1f2937; color: #e2e8f0; border: 1px solid #374151; border-radius: 8px; padding: 8px 14px; font-size: 13px; cursor: pointer; text-decoration: none; }
.btn:hover { background: #374151; }
.btn-restore { background: #14532d; border-color: #10b981; color: #6ee7b7; }
.btn-restore:hover { background: #166534; }
h2 { font-size: 16px; margin: 26px 0 10px; color: #93c5fd; }
.diff { background: #0f1420; border: 1px solid #2d3748; border-radius: 10px; padding: 14px; font-family: monospace; font-size: 13px; overflow-x: auto; }
.diff div { padding: 1px 10px; white-space: pre-wrap; word-break: break-all; }
.diff-add { background: #064e3b; color: #6ee7b7; }
.diff-del { background: #450a0a; color: #fca5a5; text-decoration: line-through; }
.diff-skip { color: #475569; }
.diff-ctx { color: #94a3b8; }
.empty { color: #64748b; padding: 24px; text-align: center; }
</style>
</head>
<body>
<header>
<h1>🕘 Verziók ${fileLabel}</h1>
<a href="/?file=${fileKey}"> Vissza a szerkesztőhöz</a>
</header>
<main>
<p class="note">Minden Mentés automatikus másolatot készít. A Összehasonlítás megmutatja az adott mentés és a <strong>jelenlegi</strong> tartalom különbségét (piros = mentésben volt, zöld = most van). A visszaállítás előtt a jelenlegi állapot is mentésre kerül, tehát a visszaállítás is visszavonható.</p>
${versions.length === 0 ? '<div class="empty">Ehhez a fájlhoz még nincs mentés.</div>' : versions.map(v => `
<div class="ver">
<span class="when">${v.when}</span>
<span class="size">${v.size} B</span>
<span class="actions">
<a class="btn" href="/versions?file=${fileKey}&show=${v.name}"> Összehasonlítás</a>
<button class="btn btn-restore" onclick="restore('${v.name}')"> Visszaállítás</button>
</span>
</div>`).join('')}
${diff ? `
<h2>Különbség: mentés (${diff.when}) jelenlegi tartalom</h2>
<div class="diff">${diff.diffHtml}</div>` : ''}
</main>
<script>
const CSRF_TOKEN = "${csrfToken}";
const FILE = "${fileKey}";
async function restore(name) {
if (!confirm('Biztosan visszaállítod ezt a mentést?\\nA jelenlegi tartalom mentésre kerül, így ez később is visszavonható.')) return;
try {
const res = await fetch('/restore?file=' + FILE + '&backup=' + encodeURIComponent(name), {
method: 'POST',
headers: { 'X-CSRF-Token': CSRF_TOKEN }
});
if (res.status === 401) { location.href = '/login'; return; }
const json = await res.json();
if (json.ok) { alert('✅ Visszaállítva.'); location.href = '/?file=' + FILE; }
else alert('❌ Hiba: ' + json.error);
} catch (e) { alert('❌ Hálózati hiba'); }
}
</script>
</body>
</html>`;
module.exports = { HTML, GUIDE_PAGE, LOGIN_PAGE, VERSIONS_PAGE };
-43
View File
@@ -1,43 +0,0 @@
// Publish (git commit + push) command construction and result interpretation
// for the Content Editor. Extracted so it is unit-testable in isolation.
//
// WHY the shell shape:
// - `git diff --cached --quiet && echo MARKER || git commit` — commit only when
// staged changes exist; a skipped commit must NOT produce a failing exit code
// (that was the original bug: "nothing added to commit" surfaced as an error).
// - the MARKER echo is the only reliable signal for "no content changes": plain
// output matching ("Already up to date", "Everything up-to-date") also appears
// after REAL publishes (the pull prints it when the remote did not move), which
// used to misclassify genuine publishes as no-ops.
// - `git pull --rebase || (git rebase --abort; false)` — a failed rebase must be
// aborted, otherwise the repo stays mid-rebase and every later publish fails
// with "cannot pull with rebase".
const NO_CHANGES_MARKER = '__NO_CONTENT_CHANGES__';
function buildPublishCommand(commitMessage) {
return [
'git add .',
// WHY: logo uploads live in proto/public — 2 levels above the content cwd — so
// stage them too (tolerant: optional path in test throwaway repos, stderr muted).
'(git add ../../public 2>/dev/null || true)',
`(git diff --cached --quiet && echo ${NO_CHANGES_MARKER} || git commit -m "${commitMessage}")`,
'(git pull --rebase origin main || (git rebase --abort; false))',
'git push origin main',
].join(' && ');
}
function interpretPublishResult(error, stdout, stderr) {
const hadChanges = !stdout.includes(NO_CHANGES_MARKER);
if (error) {
return { ok: false, hadChanges, result: 'error', error: stderr || stdout || error.message };
}
return {
ok: true,
hadChanges,
result: hadChanges ? 'ok' : 'no_changes',
output: hadChanges ? stdout : 'Nincs új változtatás.',
};
}
module.exports = { NO_CHANGES_MARKER, buildPublishCommand, interpretPublishResult };
-65
View File
@@ -1,65 +0,0 @@
// POST /save handler for the Content Editor — extracted to keep content-editor.js
// under the 400-line hard limit. Returns true when the request was handled.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
function handleSaveRoute({
req, res, u, activeFile, files, maxBodyBytes, validate,
writeAudit, backupAndWrite, backupDir, user, clientAddress, cmsDirname,
}) {
if (req.method !== 'POST' || u.pathname !== '/save') return false;
let body = '';
let bodyTooLarge = false;
let bodySize = 0;
req.on('data', c => {
bodySize += c.length;
if (bodySize > maxBodyBytes) { bodyTooLarge = true; return; }
body += c;
});
req.on('end', () => {
try {
if (bodyTooLarge) {
writeAudit('content_saved', { clientAddress, user, file: activeFile, result: 'request_too_large' });
res.writeHead(413, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: `A kérés túl nagy (maximum ${maxBodyBytes} byte)` }));
return;
}
const data = JSON.parse(body);
// Optimistic locking: the editor echoes the fingerprint of the content it
// loaded. If the file changed since (deploy, another tab, git), a blind
// save would silently overwrite those changes — reject with 409 instead.
const clientHash = req.headers['x-content-hash'];
const currentOnDisk = fs.readFileSync(files[activeFile], 'utf8').trim();
const currentHash = crypto.createHash('sha256').update(currentOnDisk).digest('hex');
if (typeof clientHash !== 'string' || clientHash !== currentHash) {
writeAudit('content_saved', { clientAddress, user, file: activeFile, result: 'conflict' });
res.writeHead(409, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'A tartalom megváltozott, mióta ezt a lapot megnyitottad (pl. deploy vagy másik fül mentett). Frissítsd az oldalt, és végezd el újra a módosításokat.' }));
return;
}
const validation = validate(activeFile, data);
if (!validation.ok) {
writeAudit('content_saved', { clientAddress, user, file: activeFile, result: 'validation_failed' });
res.writeHead(422, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: validation.errors.join('; '), errors: validation.errors }));
return;
}
const backupFile = backupAndWrite(files[activeFile], data, backupDir);
// Return the hash of the written content so the editor tab can refresh its
// fingerprint — otherwise the user's OWN next save would trip the lock.
const newHash = crypto.createHash('sha256').update(JSON.stringify(data, null, 2).trim()).digest('hex');
writeAudit('content_saved', { clientAddress, user, file: activeFile, result: 'ok' });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, backup: path.relative(cmsDirname, backupFile), contentHash: newHash }));
} catch (e) {
writeAudit('content_saved', { clientAddress, user, file: activeFile, result: 'error' });
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: e.message }));
}
});
return true;
}
module.exports = { handleSaveRoute };
-66
View File
@@ -1,66 +0,0 @@
// WHY: Basic Auth has no native logout and its dialog cannot be styled, so a
// successful /login form submit receives a server-side session token in an
// HttpOnly cookie. Basic Auth remains valid in parallel (curl, API use).
const crypto = require('crypto');
const SESSION_COOKIE = 'cms_session';
const SESSION_TTL_MS = 8 * 60 * 60 * 1000;
const sessions = new Map(); // token -> expiresAt (ms)
function timingSafeMatch(candidate, expected) {
if (typeof candidate !== 'string' || typeof expected !== 'string') return false;
const cHash = crypto.createHash('sha256').update(candidate).digest();
const eHash = crypto.createHash('sha256').update(expected).digest();
return crypto.timingSafeEqual(cHash, eHash);
}
function validateLogin(user, pass, expectedUser, expectedPass) {
if (!expectedUser || !expectedPass) return false;
const userOk = timingSafeMatch(user, expectedUser);
const passOk = timingSafeMatch(pass, expectedPass);
return Boolean(userOk && passOk);
}
function createSessionCookie(isSecure) {
const token = crypto.randomBytes(32).toString('hex');
sessions.set(token, Date.now() + SESSION_TTL_MS);
return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}${isSecure ? '; Secure' : ''}`;
}
function clearSessionCookie() {
return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`;
}
function getSessionToken(req) {
const cookies = req.headers.cookie || '';
const match = cookies.match(new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([a-f0-9]+)`));
return match ? match[1] : null;
}
function hasValidSession(req) {
const token = getSessionToken(req);
if (!token) return false;
const expiresAt = sessions.get(token);
if (!expiresAt) return false;
if (Date.now() > expiresAt) {
sessions.delete(token);
return false;
}
return true;
}
function deleteSession(req) {
const token = getSessionToken(req);
if (token) sessions.delete(token);
}
module.exports = {
SESSION_COOKIE,
SESSION_TTL_MS,
timingSafeMatch,
validateLogin,
createSessionCookie,
clearSessionCookie,
hasValidSession,
deleteSession,
};
-112
View File
@@ -1,112 +0,0 @@
// Version history helpers for the CMS: listing automatic backups from
// .content-backups, safe backup-name validation, diff assembly and the
// /versions + /restore route handlers.
const fs = require('fs');
const path = require('path');
const { renderDiffHtml } = require('./cms-diff');
const { backupAndWriteAtomically: backupAndWrite } = require('./cms-core');
// Backup files are named `<fileKey>.<ISO-ish timestamp>.json`
const BACKUP_NAME_RE = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/;
// WHY: the backup name arrives as a query parameter — only allow the exact
// `<fileKey>.<timestamp>.json` shape so path traversal (`../`) is impossible.
function safeBackupName(fileKey, candidate) {
if (typeof candidate !== 'string' || !candidate.startsWith(`${fileKey}.`) || !candidate.endsWith('.json')) return null;
const ts = candidate.slice(fileKey.length + 1, -5);
if (!BACKUP_NAME_RE.test(ts)) return null;
return candidate;
}
function formatBackupTimestamp(fileKey, backupName) {
const ts = backupName.slice(fileKey.length + 1, -5);
const m = ts.match(BACKUP_NAME_RE);
if (!m) return ts;
return `${m[1]} ${m[2]}:${m[3]}:${m[4]}`;
}
function listVersions(backupDir, fileKey) {
try {
return fs.readdirSync(backupDir)
.filter(name => safeBackupName(fileKey, name))
.map(name => {
const full = path.join(backupDir, name);
const stat = fs.statSync(full);
return { name, size: stat.size, when: formatBackupTimestamp(fileKey, name) };
})
.sort((a, b) => b.name.localeCompare(a.name)); // newest first
} catch {
return [];
}
}
function readBackupContent(backupDir, backupName) {
return fs.readFileSync(path.join(backupDir, backupName), 'utf8');
}
// Compare a backup with the current file content; returns both pretty texts and
// the rendered diff HTML (backup = old/left, current = new/right).
function buildVersionDiff(backupDir, currentFilePath, fileKey, backupName) {
const backupText = readBackupContent(backupDir, backupName);
const currentText = fs.readFileSync(currentFilePath, 'utf8');
return {
backupName,
when: formatBackupTimestamp(fileKey, backupName),
backupText: backupText.trim(),
currentText: currentText.trim(),
diffHtml: renderDiffHtml(backupText, currentText),
};
}
// WHY: route handling extracted here so content-editor.js stays under the
// 400-line limit. Returns true when the request was handled.
function handleVersionRoutes({ req, res, u, activeFile, backupDir, currentFile, validate, writeAudit, csrfOk, clientAddress, user, versionsPage }) {
if (req.method === 'GET' && u.pathname === '/versions') {
const versions = listVersions(backupDir, activeFile);
let diff = null;
const showRaw = u.searchParams.get('show');
if (showRaw) {
const safe = safeBackupName(activeFile, showRaw);
if (safe) {
try {
diff = buildVersionDiff(backupDir, currentFile, activeFile, safe);
} catch { /* unreadable backup: render list only */ }
}
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(versionsPage(activeFile, diff));
return true;
}
if (req.method === 'POST' && u.pathname === '/restore') {
const backup = safeBackupName(activeFile, u.searchParams.get('backup') || '');
if (!backup) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'Érvénytelen mentésnév.' }));
return true;
}
try {
const data = JSON.parse(readBackupContent(backupDir, backup));
const validation = validate(activeFile, data);
if (!validation.ok) {
writeAudit('version_restored', { clientAddress, user, file: activeFile, backup, result: 'validation_failed' });
res.writeHead(422, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'A mentés nem felel meg a sémának: ' + validation.errors.join('; ') }));
return true;
}
backupAndWrite(currentFile, data, backupDir);
writeAudit('version_restored', { clientAddress, user, file: activeFile, backup, result: 'ok' });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
} catch (e) {
writeAudit('version_restored', { clientAddress, user, file: activeFile, backup, result: 'error' });
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: e.message }));
}
return true;
}
return false;
}
module.exports = { safeBackupName, listVersions, readBackupContent, buildVersionDiff, formatBackupTimestamp, handleVersionRoutes };
+7 -1
View File
@@ -45,8 +45,14 @@ printf '2/4 Commit feltolása a Gitea-ra…\n'
git push origin "${DEPLOY_BRANCH}"
printf '3/4 Staging deploy (%s:%s)…\n' "${STAGE_HOST}" "${STAGE_PATH}"
# WHY nincs itt már "sudo systemctl restart mozdit-content-editor.service": az a
# régi egyedi CMS (content-editor.js) külön systemd szolgáltatása volt — a
# fájlt MITHOME-93 leépítette, a Docker-compose alapú app (deploy.sh) a
# régóta egyedüli kiszolgáló. A szerveren futó mozdit-content-editor.service
# unit-ot (ha még létezik) manuálisan kell leállítani/letiltani — ezt a
# scriptet nem futtatjuk SSH-n keresztül automatikusan.
ssh -o BatchMode=yes -o ConnectTimeout=15 "${STAGE_HOST}" \
"cd '${STAGE_PATH}' && git pull --ff-only origin '${DEPLOY_BRANCH}' && ./deploy.sh staging && sudo systemctl restart mozdit-content-editor.service"
"cd '${STAGE_PATH}' && git pull --ff-only origin '${DEPLOY_BRANCH}' && ./deploy.sh staging"
printf '4/4 Staging smoke teszt (%s)…\n' "${STAGE_URL}"
(

Some files were not shown because too many files have changed in this diff Show More