v0.254.0 — the saved notes follow the language, and the switch becomes a globe (R-557 slice 2 release C; SLICE 2 CLOSED)
gates / gates (push) Successful in 23s

The notes a background run SAVES — last night's backup line, the last error, the proof
result, the restore outcome — are written in the BOX's language at the moment they are
written. A household that switches sees the previous run's note in the old language until
the next run rewrites it: the operator's §16 option 1, stated rather than hidden.
EndRestoreOp no longer receives a Hungarian literal from anywhere.

The language switch is a globe. Two text links wrapped in the sidebar footer and asked the
reader to recognise "Magyar"/"English" as links; a globe is the one symbol every web user
already reads as "language", so nobody has to read Hungarian to escape Hungarian. It is
<details>/<summary> — a menu with no script, drawn inline because the icon sprite lives
only in layout.html and the visitor pages have their own shell.

Those visitor pages get the same globe, and a visitor's choice stays theirs: a display-only
felhom_lang cookie that langFor reads ONLY when there is no session. A signed-in household
can never inherit a language a previous visitor picked in the same browser. POST /lang is
CSRF-exempt for a narrow reason written at the exemption — its only achievable effect is the
language of the page the victim's own browser shows them — and safeBackPath refuses
//evil.example as well as https://, because "starts with /" alone is not the test. §16 taken:
a successful claim carries the cookie into the household's setting.

TWO PARITY EXCEPTIONS, MEASURED: 106 fixtures compared with a real diff — exactly two change
shapes (the dashboard footer, the globe in the shells) and 5 byte-identical, which are the
three pages that must not change.

I INTRODUCED A DEADLOCK AND THE SUITE CAUGHT IT BY HANGING. UpdateOffboxStatus holds the
settings write lock while running its callback; boxLang() wants the read lock; sync.RWMutex
is not reentrant. On a real box an off-site run would have hung forever HOLDING the settings
lock. Fixed by resolving the language before the callback, and guarded by a test that names
the file and line in a second instead of hanging for 25 minutes.

MinAgent: 0.131.0 (unchanged). No hub release needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-18 14:19:31 +02:00
parent eb6aa58aa7
commit 48f3336956
142 changed files with 2199 additions and 318 deletions
+61
View File
@@ -1,3 +1,64 @@
## v0.254.0 — The saved notes follow the language, and the switch becomes a globe (2026-09-18, R-557 slice 2 release C — SLICE 2 CLOSED)
**MinAgent: 0.131.0** (unchanged). **No hub release needed.** Nothing on the wire moved.
**Hungarian changes in exactly TWO places, both declared and both measured** — the sidebar footer of
every dashboard page, and the three pages a visitor meets. Everything else is byte-identical.
**1. The notes a background run SAVES now follow the language.** The line under last night's backup,
the last error, the proof result, the restore outcome — written in the BOX's language at the moment
they are written (`util.Text(lang, key, …)`, `m.note`/`s.note`). The consequence, stated rather than
hidden: **a household that switches sees the note from the run before in the old language until the
next run rewrites it.** That is the operator's §16 option 1; storing a code and rendering live would
need a dozen new persisted fields and a legacy path for each — the R-570 shape a dozen times over.
Converted: the off-site failure classes and quota notes, the tier-2 reasons and warnings, the undo
phrases, the reconstitute hold, the restore record, and the whole restore-outcome family.
**`EndRestoreOp` no longer receives a Hungarian literal from anywhere.**
**2. The language switch is a globe.** Two text links wrapped in the sidebar footer and asked the
reader to recognise „Magyar"/„English" as links; a globe is the one symbol every web user already
reads as "language", so nobody has to read Hungarian to find their way out of Hungarian. It is
`<details>`/`<summary>` — a menu with no script, which opens on click and on Enter and which a screen
reader announces. Language names inside are shown in their own language and are never translated.
**3. The sign-in, claim and recovery pages get the same globe — and a visitor's choice stays theirs.**
Those pages are met by someone who has not signed in. They have no setting to read, and they must not
write the household's: a box's sign-in page is reachable by anyone who can reach the box. So the
choice lives in the visitor's own browser (`felhom_lang`, display-only, one of two values), and
`langFor` reads it **only when there is no session** — a signed-in household can never inherit a
language a previous visitor picked in the same browser.
`POST /lang` is exempt from CSRF for a narrow, checkable reason, written down where it is made: the
only thing a forged request can achieve is to change the language of the page the victim's own browser
shows them. It writes one cookie, reads nothing, touches no setting, and `safeBackPath` keeps its
redirect on this box (a protocol-relative `//evil.example` is refused — "starts with /" alone is not
the test). **If that handler ever gains a second effect, it needs CSRF that day.**
**§16, the operator's default, taken: a claim CARRIES the visitor's language.** Someone who switched
the claim page to English and then claimed the box chose English deliberately. Only on success, and
only there — the one moment an anonymous visitor becomes the household.
**No globe on the guest share pages or the not-found page.** A share visitor is a stranger and the
household's setting is the wrong default for them; that is a promise about the share feature and an
operator decision (**R-577**). Pinned by `TestGuestSharePagesHaveNoGlobe` so it stays a decision.
**The two parity exceptions, measured rather than asserted.** 106 fixtures rendered and compared with
a real (LCS) diff — **exactly two change shapes**: the dashboard footer (89 fixtures) and the globe in
the shells (12), with 5 byte-identical, which are precisely the three pages that must not change. The
whole diff is in `felhom.eu/documentation/audits/i18n-slice2-2026-09-18/C/parity-exception-diff.txt`.
**Two defects this release found in its own measurement, recorded rather than tidied away:**
- The parity HARNESS rendered the three visitor shells through the DASHBOARD path, so the fixture
would have baked a globe posting to `/settings/language` with a CSRF field — a form the real page
never serves. Caught by reading the diff before re-capturing. The harness now branches on
`i18nDirectTemplates`.
- The first "is the change only the two blocks?" measurement compared LINE BY INDEX. An insertion
shifts every line below it, so it reported 60 520 changed lines and measured nothing. **A
line-index compare is not a diff.**
`R-575` folded in where it was one call (`tier2NoTargetReason` now returns a note in the box's
language); the soft memory-overcommit warning stays as its row says.
## v0.253.0 — Errors carry the key of the sentence they are (2026-09-18, R-557 slice 2 release B)
**MinAgent: 0.131.0** (unchanged). **No hub release needed.** Every Hungarian sentence is byte-identical
+3 -1
View File
@@ -7,7 +7,9 @@
>
> Ask Claude Code: "Please update CONTEXT.md with what we did today"
Last updated: 2026-09-18 (v0.253.0 — localisation slice 2 release B: errors carry a key)
Last updated: 2026-09-18 (v0.254.0 — localisation slice 2 CLOSED: saved notes follow the language, the switch is a globe)
> **2026-09-18 — v0.254.0 (R-557 slice 2 release C — SLICE 2 CLOSED).** Saved notes follow the box language at WRITE time (§16 option 1): ~70 producers, and `EndRestoreOp` no longer takes a Hungarian literal from anywhere. The language switch is a **globe** (`<details>`, no script) in the sidebar footer and on the sign-in/claim/recovery pages; a visitor's choice lives in a display-only `felhom_lang` cookie that `langFor` reads ONLY when there is no session, and a claim carries it into the household setting on success. `POST /lang` is CSRF-exempt for a narrow reason written at the exemption; `safeBackPath` refuses `//evil.example` too. **Two parity exceptions, measured with a real diff: exactly two change shapes across 106 fixtures, 5 byte-identical.** **A DEADLOCK was introduced and caught by the suite hanging** — a note rendered inside `UpdateOffboxStatus`'s callback takes the settings read lock while the write lock is held; `TestNoteHelpersAreNotCalledUnderTheSettingsLock` guards it now. 629 literals left, 0 errors, 0 saved notes. New row R-577.
> **2026-09-18 — FLEET FLOOR RAISED to 0.253.0** (operator asked), `min_agent` 0.131.0 declared — the floor is above the vouched golden 0.246.0, so the declaration is what carries it (R-472). Hub: `managed floor SERVED … from declared`; demo-felhom went 0.250.0 -> 0.253.0 **by itself in ~20 s**, healthy, its other four containers up, and its own log reads `settle-gate: GO — at/above floor 0.253.0`. Both live boxes run agent 0.132.0 (above the requirement). **Peti Proxmox (DOWN 65 d, 0.115.0) and Tester 1 (DOWN 1 d, 0.245.0) did NOT get it** and will take it unattended when they return — untested on this version. Evidence: `felhom.eu/documentation/audits/i18n-slice2-2026-09-18/floor-raise-0.253.0.md`.
+81 -2
View File
@@ -1,7 +1,7 @@
# REPORT — localisation slice 2, releases A and B (controller v0.252.0 + v0.253.0, R-557)
# REPORT — localisation slice 2, COMPLETE (controller v0.252.0 + v0.253.0 + v0.254.0, R-557)
**2026-09-18 · base commit `736f54b49610` (v0.251.0) · MinAgent 0.131.0, unchanged · no hub release.**
**Two releases this session: A (the sentences shown now) and B (the 179 error messages).**
**Three releases: A (the sentences shown now), B (the 179 error messages), C (the saved notes + the globe).**
Architecture read first and named: `felhom.eu/documentation/architecture/10-localisation.md` §1
(parity), §2 (mechanism), §5 (gates), §9 (the R-553 signals), §10 (this slice); `07-backup-architecture.md`
for what the hub reads from a box; `05-hub-architecture.md` for what the hub composes itself.
@@ -193,3 +193,82 @@ are not red-proofs — nobody planted them.
translated on purpose), `handler_debug.go` (R-574), the notifier's 27 wire messages (R-558),
`funcmap.go`'s two un-overridden helpers (R-572), the text a background run PERSISTS (release C), and
the R-570 producer. Release C's default stands: written in the box's language at write time.
---
# Release C — v0.254.0: the saved notes, and a globe for the switch. SLICE 2 CLOSED.
## 1. Claims in the prompt that live source disproved — named first
| the prompt said | source says |
|---|---|
| "Release A's survey (persisted list, wire list)" is in `audits/i18n-slice2-2026-09-18/A/` | That folder holds only `live/`. The persisted list is in this REPORT's §6 for release A. Derived again from source here, which is the stronger reading — and it found the largest persisted group (`EndRestoreOp`'s outcome family, ~40 literals in two files) that a list would have had to remember. |
| "the three shells share no layout" | **True, and confirmed by reading**: `login.html`, `claim.html`, `recovery.html` each open their own `<html lang="{{T "layout.html_lang"}}">`. But the consequence was missed: the icon SPRITE lives only in `layout.html`, so a `<use href="#i-globe">` would render nothing on those three. The globe is drawn INLINE in the partial instead — one definition, four places, no sprite coupling. No dead symbol was added. |
| "no cookie is read anywhere for language" | Confirmed by grep: no language cookie existed. |
| the `<details>` menu "working without script in the browsers the guide names" | **Cannot be confirmed here.** `claude-in-chrome` is not available on DooPlex, so no browser rendered this page. `<details>`/`<summary>` is a plain HTML element with no script, and the markup is asserted; whether it LOOKS right is an operator click-through. Said plainly rather than implied. |
## 2. What shipped
**The saved notes.** ~70 producers across `internal/backup` (off-site classes and quota, tier-2
reasons and warnings, undo phrases, the reconstitute hold, the restore record) and `internal/web`
(the whole `EndRestoreOp` outcome family). Each renders through `util.Text(boxLang(), key, …)`.
**`EndRestoreOp` no longer receives a Hungarian literal from anywhere** (grep, with the negative
control). `s.noteErr`/`m.noteErr` render release B's errors into a note in the same language.
**The globe.** One partial (`lang_globe.html`), used by the dashboard footer and the three visitor
shells; drawn inline in the same stroke style as the sprite. `langFor` gained one step, and the order
is now fixed: `?lang=` → **household setting when a session exists** → visitor cookie when none →
setting → `hu`. A signed-in household never reads the cookie, which is the row that protects them.
**`POST /lang`** — CSRF-exempt for a narrow, checkable reason written at the exemption: the only
achievable effect is changing the language of the page the victim's own browser shows them. It writes
one cookie, reads nothing, and `safeBackPath` refuses `//evil.example` as well as `https://…` —
"starts with /" alone is not the test, and that is the mistake the function exists to not make.
**§16 taken (the operator's stated default): a claim carries the visitor's language**, on success
only, at the one moment an anonymous visitor becomes the household.
## 3. The two parity exceptions, measured
106 fixtures rendered and compared with a **real (LCS) diff**: exactly **two change shapes** — the
dashboard footer (89 fixtures) and the globe in the shells (12) — and **5 byte-identical**, which are
precisely the two guest share pages and the catch-all, the three that must not change. Full diff:
`audits/i18n-slice2-2026-09-18/C/parity-exception-diff.txt`.
**Deviation, named:** the prompt asked for one commit per re-capture. This is one commit, because
splitting them leaves an intermediate commit whose suite is red — and a commit that does not pass is
worse than a commit whose EVIDENCE separates the two blocks, which this one does explicitly.
## 4. Two defects release C found in its own work
**(a) A DEADLOCK I introduced, caught by the suite hanging.** `UpdateOffboxStatus` holds the settings
WRITE lock while it runs its callback; `boxLang()` reads the language through the settings READ lock.
`sync.RWMutex` is not reentrant. The first draft rendered a note inside that callback — on a real box
an off-site run would have hung **forever, holding the settings lock**, wedging everything else that
touches `settings.json`. The only symptom was `go test` timing out at 25 minutes instead of 8.
Fixed by resolving the language before the callback; `TestNoteHelpersAreNotCalledUnderTheSettingsLock`
now names the file and line in a second instead. **A hang is the worst kind of failure to diagnose,
and the lesson is: a helper that takes a lock must never be called from inside a callback that holds
one.**
**(b) The first "only two blocks changed?" measurement compared LINE BY INDEX.** An insertion shifts
every line below it, so it reported 60 520 changed lines and measured nothing. **A line-index compare
is not a diff.** Redone with difflib.
Also caught before it shipped: the parity HARNESS rendered the three shells through the DASHBOARD
path, which would have baked a form the real page never serves. Found by reading the diff before
re-capturing, and independently by `TestI18nDirectRenderPagesFollowLanguage`.
## 5. Green gate
`go build ./... && go vet ./... && go test ./...` green; all controller gates OK. Red-proofs 15–19 in
`audits/i18n-slice2-2026-09-18/redproofs.txt`, each seen to convict.
## 6. What is left after slice 2
**629 Hungarian literals**, none of them errors and none of them saved notes: the country TABLE (113,
not on the wire, not translated on purpose), the page-title literals slice 1 handles through
`TitleKey`, `handler_debug.go` (R-574), the notifier's 27 wire messages (R-558), `funcmap.go`'s two
un-overridden helpers (R-572), the R-570 producer, and the soft memory warning (R-575).
**R-557 is CLOSED.** New row: **R-577**, the guest-share visitor's language.
+24 -4
View File
@@ -3614,7 +3614,7 @@ without reaching `Images()`.
---
### 18. Dashboard language (i18n) (v0.247.0–v0.253.0)
### 18. Dashboard language (i18n) (v0.247.0–v0.254.0)
Design: `felhom.eu/documentation/architecture/10-localisation.md`. Inventory:
`felhom.eu/documentation/audits/I18N-INVENTORY-2026-09-17.md`.
@@ -3624,9 +3624,10 @@ sidebar footer of every dashboard page. Every template is converted (v0.247.0 th
apps and settings, v0.249.0 backups, v0.250.0 storage, sharing, sign-in, claim, guest share, catch-all,
debug). Since **v0.252.0** the sentences the program BUILDS follow the language too — flash lines, page
data, the JSON the page's script reads, the alert banners, the country names, and the four page titles
built around an app name. Since **v0.253.0** every error message carries its key too, so a refusal made deep in a package is
rendered in the household's language by whoever prints it. Still Hungarian: text persisted by a
background run (release C), catalog copy (slice 5), and **everything the hub reads** (see below).
built around an app name. Since **v0.253.0** every error message carries its key too. Since **v0.254.0** the notes a background
run SAVES follow the language as well, and the switch is a **globe** — on the dashboard and on the
sign-in, claim and recovery pages. Still Hungarian: catalog copy (slice 5) and **everything the hub
reads** (see below).
- **Bundles:** `internal/i18n/locales/hu.json` (authoritative, every key) and `en.json`, embedded.
Flat `key → text`; a value may carry template actions (`{{.RecoveryAbandonDate}}`) and inline
@@ -3680,6 +3681,25 @@ background run (release C), catalog copy (slice 5), and **everything the hub rea
- **Plurals (v0.253.0):** a key that carries `.one`/`.other` in a language is a plural key and its FIRST
parameter is the count (`i18n.Bundle.form`). Hungarian has one form at every count. `.one`/`.other`
are RESERVED suffixes — `TestNoOrdinaryKeyEndsInAPluralSuffix`.
- **Saved notes (v0.254.0):** a background run has no request and no reader, so a note it SAVES is
written in the BOX's language at write time — `util.Text(lang, key, …)` via `m.note`/`s.note`
(`m.noteErr`/`s.noteErr` for an error). Consequence, by operator ruling (slice 2 §16 option 1): a
household that switches sees the previous run's note in the old language until the next run.
**TRAP, and it cost a 25-minute hang:** `UpdateOffboxStatus` and its siblings hold the settings WRITE
lock while running their callback, and `boxLang()` wants the READ lock — `sync.RWMutex` is not
reentrant, so a note rendered inside such a callback DEADLOCKS while holding the settings lock.
Resolve the language BEFORE the callback; `TestNoteHelpersAreNotCalledUnderTheSettingsLock` guards it.
- **The switch is a globe (v0.254.0):** `templates/lang_globe.html`, a `<details>`/`<summary>` menu with
no script, drawn inline (the sprite lives only in `layout.html`, so `<use>` would render nothing on
the shells). Language names are shown in their own language and never translated.
- **A visitor's language (v0.254.0):** the sign-in, claim and recovery pages carry the same globe, posting
to `POST /lang`, which sets the display-only `felhom_lang` cookie. `langFor`'s order: `?lang=` →
**household setting when a session exists** → cookie when none → setting → `hu`; a signed-in household
never reads the cookie. `/lang` is CSRF-exempt because its only achievable effect is the language of
the page the victim's own browser shows them — **if it gains a second effect it needs CSRF that day**.
`safeBackPath` refuses a protocol-relative `//host` as well as an absolute URL. A successful CLAIM
carries the cookie into the household setting (§16). **No globe on the guest share pages or the
not-found page** — R-577.
- **Report:** the hub report carries `"language"` (always present). No hub release reads it yet.
- **Tools and gates:** `scripts/i18n_extract.py` converts a template (moves each Hungarian run into
hu.json, leaves a marker); `scripts/i18n_missing_gate.py` (in `controller_gates.py`) checks keys
+23
View File
@@ -13,8 +13,10 @@ import (
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
// Manager orchestrates app-data backups: database dumps and Docker-volume tars.
@@ -1375,3 +1377,24 @@ func dbNames(dbs []DiscoveredDB) string {
}
return strings.Join(names, ", ")
}
// boxLang is the language a SAVED note is written in (release C, R-557 slice 2 option 1).
//
// A background run has no request and no reader, so it writes in the box's language at the moment it
// writes. A household that switches sees last night's note in the old language until the next run
// rewrites it — the operator's ruling, and the reason the reader never translates stored text.
func (m *Manager) boxLang() string {
if m.settings == nil {
return i18n.Default
}
return m.settings.GetLanguage()
}
// note renders a saved note in the box's language.
func (m *Manager) note(key string, args ...interface{}) string {
return util.Text(m.boxLang(), key, args...)
}
// noteErr renders an error into a saved note in the box's language: its bundle message when it
// carries one (release B), its own text otherwise.
func (m *Manager) noteErr(err error) string { return util.ErrText(m.boxLang(), err) }
+29 -19
View File
@@ -93,7 +93,7 @@ var ErrOffsiteQuota = errors.New("offsite quota exceeded")
// of showing the previous run's verdict under a „started" message.
// offboxWholeUnitGap is the pseudo-path used to report a WHOLE-unit gap through the mandatory-gap
// notification, so a skipped app and a skipped directory reach the operator in one vocabulary.
const offboxWholeUnitGap = "(a teljes alkalmazás — nincs helyi mentési egysége)"
const offboxWholeUnitGapKey = "note.offsite.whole_unit_gap"
var ErrOffboxRunInFlight = fmt.Errorf("an off-box backup is already running; this request did not start a new one")
@@ -226,20 +226,24 @@ func (m *Manager) OffsiteFailureMessage(err error, dur time.Duration) string {
if m != nil && m.settings != nil {
t = m.settings.GetOffboxTarget()
}
return offsiteFailureMessage(t, err, dur)
return offsiteFailureMessage(t, err, dur, m.boxLang())
}
func offsiteFailureMessage(t *settings.OffboxTarget, err error, dur time.Duration) string {
// offsiteFailureMessage builds the note that is SAVED in OffboxTarget.LastError and read on the page
// days later, so it is written in the box's language at write time (release C, R-557 option 1).
// The failure CLASS is still decided by ClassifyOffsiteFailure from a kind, never from these words
// (R-553) — which is why translating them is safe.
func offsiteFailureMessage(t *settings.OffboxTarget, err error, dur time.Duration, lang string) string {
head := map[OffsiteFailureClass]string{
OffsiteFailQuota: "A távoli mentés nem fért el a tárhelykereten belül",
OffsiteFailOrphaned: "A távoli tárhely egy korábbi, már nem elérhető kulccsal készült",
OffsiteFailNoRepo: "A távoli tárhelyen nincs mentési adattár",
OffsiteFailNoUnits: "Nem volt mit menteni: egyetlen kijelölt alkalmazásnak sem található mentése",
OffsiteFailTransport: "A távoli tárhely nem érhető el (hálózat vagy bejelentkezés)",
OffsiteFailUnknown: "A távoli mentés ismeretlen okból nem sikerült",
OffsiteFailQuota: util.Text(lang, "note.offsite.fail_quota"),
OffsiteFailOrphaned: util.Text(lang, "note.offsite.fail_orphaned"),
OffsiteFailNoRepo: util.Text(lang, "note.offsite.fail_no_repo"),
OffsiteFailNoUnits: util.Text(lang, "note.offsite.fail_no_units"),
OffsiteFailTransport: util.Text(lang, "note.offsite.fail_transport"),
OffsiteFailUnknown: util.Text(lang, "note.offsite.fail_unknown"),
}[ClassifyOffsiteFailure(err)]
if head == "" {
head = "A távoli mentés nem sikerült"
head = util.Text(lang, "note.offsite.fail_head")
}
return fmt.Sprintf("%s (%s): %s", head, dur.Round(time.Second), sanitiseOffsiteErrorFor(t, err))
}
@@ -988,6 +992,14 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
if runErr == nil {
snapshots = m.offboxRecordStats(ctx, base, env)
}
// THE LANGUAGE IS RESOLVED HERE, OUTSIDE THE CALLBACK, AND IT IS NOT A STYLE CHOICE.
//
// UpdateOffboxStatus holds the settings WRITE lock while it runs `fn`, and boxLang() reads the
// language through the settings READ lock. sync.RWMutex is not reentrant, so calling m.note()
// inside this callback DEADLOCKS — and it deadlocks holding the settings lock, which then wedges
// everything else that touches settings.json on that box. Written this way once and caught by the
// suite timing out at 25 minutes; TestNoteHelpersAreNotCalledUnderTheSettingsLock keeps it fixed.
lang := m.boxLang()
if perr := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
o.LastRun = time.Now().UTC().Format(time.RFC3339)
// R-100: LastRun above records the ATTEMPT; this records the RESULT. The hub's staleness
@@ -1047,7 +1059,7 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
notify = cp
}
for _, a := range runResult.missingUnprotected {
notify[a] = append(notify[a], offboxWholeUnitGap)
notify[a] = append(notify[a], util.Text(lang, offboxWholeUnitGapKey))
}
}
m.offboxGapNotify(notify)
@@ -1074,13 +1086,11 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
// available backup and were left out", which names a problem with no next step and reads
// the same whether the customer must act or simply wait.
if len(runResult.missingUnprotected) > 0 {
warns = append(warns, fmt.Sprintf(
"Ezek az alkalmazások NEM kerültek be a távoli mentésbe, mert még nincs helyi mentési egységük: %s. A következő mentés általában már elkészíti — ha a második futás után is itt szerepelnek, szólj az üzemeltetőnek.",
warns = append(warns, util.Text(lang, "note.offsite.no_local_unit",
strings.Join(runResult.missingUnprotected, ", ")))
}
if len(runResult.missingNotDeployed) > 0 {
warns = append(warns, fmt.Sprintf(
"Ezek az alkalmazások ki vannak jelölve távoli mentésre, de nincsenek telepítve, ezért nem menthetők: %s. Ha már nincs rájuk szükséged, vedd ki a kijelölésüket a Távoli mentés oldalon.",
warns = append(warns, util.Text(lang, "note.offsite.not_installed",
strings.Join(runResult.missingNotDeployed, ", ")))
}
// 3a: capture-gap warnings (structurally-refused / on-disk-missing mandatory paths, undeployed).
@@ -1100,14 +1110,14 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
blockedApps = append(blockedApps, n)
}
if len(blockedApps) > 0 {
warns = append(warns, fmt.Sprintf("Figyelmeztetés: a tárhelykeret miatt %d alkalmazásnál csak konfiguráció- és adatbázis-mentés készült: %s.",
warns = append(warns, util.Text(lang, "note.offsite.quota_partial",
len(blockedApps), strings.Join(blockedApps, ", ")))
}
if sharesBlocked {
warns = append(warns, sharesBlockedWarning())
}
// SLICE 4: approaching the soft quota (≥80%, <100%) — warn on an otherwise-OK run.
if qw := offboxQuotaWarning(o); qw != "" {
if qw := offboxQuotaWarning(o, lang); qw != "" {
warns = append(warns, qw)
}
o.LastWarning = strings.Join(warns, " ")
@@ -1739,7 +1749,7 @@ func offboxQuotaState(t *settings.OffboxTarget) (usedGB, quotaGB int, over bool)
// offboxQuotaWarning returns the Hungarian ≥80% (<100%) usage notice, or "" (quota unset / usage fine /
// already over — over-quota is the run-refusal error, not a warning).
func offboxQuotaWarning(t *settings.OffboxTarget) string {
func offboxQuotaWarning(t *settings.OffboxTarget, lang string) string {
if t == nil || t.QuotaGB <= 0 || t.RepoSizeBytes <= 0 {
return ""
}
@@ -1748,7 +1758,7 @@ func offboxQuotaWarning(t *settings.OffboxTarget) string {
if pct < 80 || t.RepoSizeBytes >= limit {
return ""
}
return fmt.Sprintf("A távoli mentés a keret %d%%-át használja (%d/%d GB).", pct, t.RepoSizeBytes/offboxGiB, t.QuotaGB)
return util.Text(lang, "note.offsite.quota_usage", int(pct), int(t.RepoSizeBytes/offboxGiB), t.QuotaGB)
}
// OffboxQuotaPercent returns the usage percentage for the /backups usage bar (0 when no quota/size).
@@ -172,7 +172,7 @@ func (s safetyDumpSet) First() string {
// failed restore on a machine that may be unwell, and the honest claim available here is presence.
// A zero-length file is reported as MISSING — a 0-byte dump restores nothing, and calling it present
// is the same false reassurance one step smaller.
func undoCopyPhrase(set safetyDumpSet) string {
func (m *Manager) undoCopyPhrase(set safetyDumpSet) string {
var present, absent []string
for _, f := range set.Files {
if f.Path == "" {
@@ -186,16 +186,15 @@ func undoCopyPhrase(set safetyDumpSet) string {
}
switch {
case len(present) > 0 && len(absent) == 0:
return "a korábbi állapot mentése megvan: " + strings.Join(present, ", ")
return m.note("note.undo.present", strings.Join(present, ", "))
case len(present) > 0:
// Partial: name both halves. An app with two databases whose undo is half there is a
// different situation from either whole one, and support must not have to guess which.
return "a korábbi állapot mentése RÉSZBEN van meg — megvan: " + strings.Join(present, ", ") +
"; HIÁNYZIK: " + strings.Join(absent, ", ")
return m.note("note.undo.partial", strings.Join(present, ", "), strings.Join(absent, ", "))
case len(absent) > 0:
return "a korábbi állapot mentését NEM találjuk a helyén (" + strings.Join(absent, ", ") + ")"
return m.note("note.undo.absent", strings.Join(absent, ", "))
default:
return "a korábbi állapotról nem készült menthető másolat"
return m.note("note.undo.none")
}
}
@@ -341,7 +340,7 @@ func (m *Manager) RestoreHoldFor(stack string) (bool, string) {
// Slice 4: one storage, two reasons. An update hold names the copy it can be restored from; a
// restore hold names nothing, because the restore it refers to already consumed the copy.
if h.Reason == settings.HoldReasonUpdateFailed {
copyDate := "legutóbbi"
copyDate := m.note("note.reconstitute.copy_latest")
if h.CopyDate != "" {
copyDate = fmtHoldTime(h.CopyDate)
}
@@ -358,8 +357,7 @@ func (m *Manager) RestoreHoldFor(stack string) (bool, string) {
if t, err := time.Parse(time.RFC3339, h.At); err == nil {
when = t.Format("2006-01-02 15:04")
}
return true, fmt.Sprintf("a(z) %s adatainak visszaállítása %s-kor megszakadt, és a korábbi állapotot sem sikerült visszatölteni. "+
"Az alkalmazás biztonsági okból leállítva marad, hogy az adatai ne sérüljenek tovább. Vedd fel velünk a kapcsolatot", stack, when)
return true, m.note("note.reconstitute.held", stack, when)
}
// holdAppAfterFailedRollback records the R-379/R-380 hold and makes sure nothing restarts the app
@@ -810,7 +808,7 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
// R-383: the undo copy is DESCRIBED FROM DISK, never from the path alone. See
// undoCopyPhrase — this sentence used to assert the file existed in exactly the
// branch where a missing file is one of the two causes.
return res, util.MsgError("err.backup.db_restore_and_rollback_failed", stack, undoCopyPhrase(safetySet))
return res, util.MsgError("err.backup.db_restore_and_rollback_failed", stack, m.undoCopyPhrase(safetySet))
}
res.RolledBack = true
if sErr := restartStack(); sErr != nil {
+135 -3
View File
@@ -2,10 +2,18 @@ package backup
import (
"fmt"
"io"
"log"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
@@ -65,7 +73,7 @@ func TestClassifyOffsiteFailure_UnknownIsHonest(t *testing.T) {
if got := ClassifyOffsiteFailure(fmt.Errorf("something entirely new")); got != OffsiteFailUnknown {
t.Errorf("an unclassifiable error was folded into %q instead of being reported as unknown", got)
}
msg := offsiteFailureMessage(diagTarget(), fmt.Errorf("something entirely new"), time.Minute)
msg := offsiteFailureMessage(diagTarget(), fmt.Errorf("something entirely new"), time.Minute, "hu")
if !strings.Contains(msg, "ismeretlen okból") {
t.Errorf("the unknown case does not admit it is unknown: %q", msg)
}
@@ -82,7 +90,7 @@ func TestOffsiteFailureMessage_NeverCarriesTheRepoReference(t *testing.T) {
fmt.Errorf(`restic: repo "sftp:u629488-sub3@u629488-sub3.your-storagebox.de:/home/felhom-repo" locked`),
}
for _, e := range leaky {
msg := offsiteFailureMessage(diagTarget(), e, 42*time.Second)
msg := offsiteFailureMessage(diagTarget(), e, 42*time.Second, "hu")
for _, forbidden := range []string{
"sftp:",
"your-storagebox.de",
@@ -102,7 +110,7 @@ func TestOffsiteFailureMessage_NeverCarriesTheRepoReference(t *testing.T) {
// The message must still be ACTIONABLE. Sanitising must not reduce it to a shrug — an operator needs
// the cause line plus enough residual detail to act.
func TestOffsiteFailureMessage_StaysActionable(t *testing.T) {
msg := offsiteFailureMessage(diagTarget(), fmt.Errorf("dial tcp: connect: connection refused"), 90*time.Second)
msg := offsiteFailureMessage(diagTarget(), fmt.Errorf("dial tcp: connect: connection refused"), 90*time.Second, "hu")
if !strings.Contains(msg, "nem érhető el") {
t.Errorf("the transport cause is not named: %q", msg)
}
@@ -124,3 +132,127 @@ func TestSanitiseOffsiteError_IsBounded(t *testing.T) {
t.Error("a nil error produced text")
}
}
// newNoteManager builds a Manager whose saved-note helpers work: release C writes a saved note in the
// BOX's language, so a Manager with no settings would render every note as its key. Hungarian here,
// because these tests assert the sentence a Hungarian household reads — which is the parity half.
func newNoteManager(t *testing.T) *Manager {
t.Helper()
lg := log.New(io.Discard, "", 0)
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
cfg := &config.Config{}
cfg.Paths.DataDir = t.TempDir()
return NewManager(cfg, sett, lg)
}
// backupNoteHU is the Hungarian text of a saved-note key, for a test that used to compare against a
// constant. The comparison is unchanged in meaning: it still pins the sentence, now measured against
// the bundle rather than restated beside it.
func backupNoteHU(t *testing.T, key string) string {
t.Helper()
return newNoteManager(t).note(key)
}
// TestR570SentenceStaysHungarian — the ONE saved note release C may not translate.
//
// A box upgraded to 0.251.0 carries the OLD persisted warning with no kind until its next off-site
// run rewrites it, so `offboxWarningDisplay` still falls back to a substring test on those words when
// the kind is empty (R-553's documented exception). Translating the PRODUCER while that fallback is
// load-bearing would strand exactly the boxes the fallback exists for: their stale note would stop
// being recognised and would keep telling a household that nothing is covered.
//
// **Delete this test when R-570 closes** — it is named for the row on purpose.
//
// RED-PROOF (REPORT): turn the producer into m.note("…") → this test fails naming the line.
func TestR570SentenceStaysHungarian(t *testing.T) {
const sentence = "Sikeres — nincs mentésre jelölt alkalmazás"
src, err := os.ReadFile("offbox.go")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(src), `"`+sentence+`"`) {
t.Errorf("the R-570 producer no longer writes %q as a literal — a box that has not run off-site "+
"since 0.251.0 carries that exact text with no kind, and offboxWarningDisplay finds it by "+
"those words. Translating it strands them. Close R-570 first.", sentence)
}
// And the sentence must not have quietly acquired a bundle key either: a key would render
// Hungarian today and something else the day someone adds a translation.
for _, k := range []string{"note.offsite.no_apps_selected", "note.offsite.zero_toggle"} {
if strings.Contains(string(src), k) {
t.Errorf("the R-570 producer now names a bundle key (%s) — same problem, one step further away", k)
}
}
}
// TestNoteHelpersAreNotCalledUnderTheSettingsLock — release C (R-557), and it is a REGRESSION test,
// not a precaution.
//
// `UpdateOffboxStatus` and its siblings hold the settings WRITE lock while they run their callback.
// `boxLang()` reads the language through the settings READ lock. sync.RWMutex is not reentrant, so a
// note rendered inside such a callback DEADLOCKS — and it deadlocks while holding the settings lock,
// which then wedges every other thing on that box that touches settings.json. The first draft of
// release C did exactly that, in the off-site run's final status write, and the only symptom was the
// test suite timing out at 25 minutes.
//
// The fix is to resolve the language BEFORE entering the callback. This test reads the source for the
// shape, because the failure is a hang and a hang is not something a unit test can assert on
// comfortably; the behaviour half is the suite finishing at all.
//
// RED-PROOF (REPORT): put `m.note(...)` back inside the final UpdateOffboxStatus callback → this test
// names the file and the line, in a second, instead of the suite hanging for 25 minutes.
func TestNoteHelpersAreNotCalledUnderTheSettingsLock(t *testing.T) {
callback := regexp.MustCompile(`\.Update\w*\(func\(`)
note := regexp.MustCompile(`\b(m|s)\.(note|noteErr|boxLang)\(`)
files, err := filepath.Glob("*.go")
if err != nil {
t.Fatal(err)
}
checked, callbacks := 0, 0
var bad []string
for _, f := range files {
if strings.HasSuffix(f, "_test.go") {
continue
}
src, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
checked++
depth := 0
inside := false
for i, line := range strings.Split(string(src), "\n") {
if !inside && callback.MatchString(line) {
depth = strings.Count(line, "{") - strings.Count(line, "}")
if depth > 0 {
inside, callbacks = true, callbacks+1
}
continue
}
if !inside {
continue
}
if note.MatchString(line) {
bad = append(bad, f+":"+strconv.Itoa(i+1)+" "+strings.TrimSpace(line))
}
depth += strings.Count(line, "{") - strings.Count(line, "}")
if depth <= 0 {
inside = false
}
}
}
// The instrument must be shown to be looking at something.
if checked == 0 || callbacks == 0 {
t.Fatalf("examined %d files and found %d settings callbacks — the pattern no longer matches the code", checked, callbacks)
}
if len(bad) > 0 {
t.Errorf("a saved-note helper is called inside a settings callback, which holds the WRITE lock "+
"while boxLang() wants the READ lock — sync.RWMutex is not reentrant, so this DEADLOCKS and "+
"wedges settings.json for everything else on the box. Resolve the language before the "+
"callback (%d):\n %s", len(bad), strings.Join(bad, "\n "))
}
t.Logf("examined %d files, %d settings callbacks", checked, callbacks)
}
@@ -86,7 +86,7 @@ func TestR383_AbsentUndoCopyIsNotClaimedToExist(t *testing.T) {
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := undoCopyPhrase(tc.set)
got := newNoteManager(t).undoCopyPhrase(tc.set)
for _, want := range tc.mustContain {
if !strings.Contains(got, want) {
t.Errorf("phrase %q does not contain %q", got, want)
@@ -119,9 +119,16 @@ func TestR383_TheDoubleFailureMessageIsWiredToTheBuilder(t *testing.T) {
}
switch fn := call.Fun.(type) {
case *ast.SelectorExpr:
// v0.254.0 (R-557 release C): undoCopyPhrase became a METHOD, because a saved note is
// rendered in the box's language and that needs the Manager. A method call is a
// SelectorExpr, not an Ident — so it is matched here as well as below, and the test keeps
// asserting what it always asserted rather than passing because the shape moved.
if fn.Sel.Name == "holdAppAfterFailedRollback" {
holdCalled = true
}
if fn.Sel.Name == "undoCopyPhrase" {
phraseCalled = true
}
case *ast.Ident:
if fn.Name == "undoCopyPhrase" {
phraseCalled = true
@@ -216,9 +216,12 @@ func TestR403_SkipIsRecordedForTheSurface(t *testing.T) {
if !strings.Contains(cd.LastWarning, "hi") { // ASCII fragment of "hiányos" (R-364)
t.Errorf("LastWarning does not carry the preserved-copy notice: %q", cd.LastWarning)
}
if cd.LastWarning != tier2UnitPreservedWarning &&
!strings.Contains(cd.LastWarning, tier2UnitPreservedWarning) {
t.Errorf("LastWarning is not the named constant: %q", cd.LastWarning)
// v0.254.0 (R-557 release C): the notice is SAVED in the box's language, so the comparison is
// against the bundle text for its key rather than against a Go constant. Same claim, measured one
// step further out — a reworded sentence still fails, and so does a note written from a different key.
if want := m.note(tier2UnitPreservedKey); cd.LastWarning != want &&
!strings.Contains(cd.LastWarning, want) {
t.Errorf("LastWarning is not the named notice: %q", cd.LastWarning)
}
// And the coverage the restore surface reads agrees, from the ARTIFACT rather than the record.
cov, err := m.Tier2RestoreCoverage("app")
@@ -48,12 +48,12 @@ func TestR553_OffsiteQuota_DecisionSurvivesWordingChange(t *testing.T) {
func TestR553_OffsiteQuota_HeadLineSurvivesWordingChange(t *testing.T) {
tgt := &settings.OffboxTarget{Host: "nas.local", User: "felhom", RepoPath: "/srv/repo"}
translated := util.KindErrorf(ErrOffsiteQuota, "The remote backup is over its storage quota (51/50 GB).")
msg := offsiteFailureMessage(tgt, translated, 12*time.Second)
msg := offsiteFailureMessage(tgt, translated, 12*time.Second, "hu")
if !strings.HasPrefix(msg, "A távoli mentés nem fért el a tárhelykereten belül") {
t.Errorf("a translated quota failure is reported with the wrong cause line: %q", msg)
}
unknown := errors.New("The remote backup is over its storage quota (51/50 GB).")
if m := offsiteFailureMessage(tgt, unknown, time.Second); strings.HasPrefix(m, "A távoli mentés nem fért el") {
if m := offsiteFailureMessage(tgt, unknown, time.Second, "hu"); strings.HasPrefix(m, "A távoli mentés nem fért el") {
t.Errorf("an error WITHOUT the kind must not be guessed into the quota class from its words: %q", m)
}
}
+6 -4
View File
@@ -18,14 +18,16 @@ import (
// Shape: one JSON file in the controller's state directory (DataDir, beside settings.json), written
// atomically (atomicWrite: tmp + rename) at BOTH ends of an op. At startup a record still marked
// running is, by construction, a restore nothing is running any more: it becomes a terminal failure
// (Interrupted, RestoreInterruptedMessage) and a per-app notice that stays until that app's next
// (Interrupted, RestoreInterruptedKey) and a per-app notice that stays until that app's next
// restore. LoadRestoreRecord reports the conversion ONCE, so the caller raises restore_interrupted once.
//
// No path set (tests that build a bare Manager, a box with backup disabled) = the old in-memory
// behaviour, silently: persistence is a property of the wired controller, not of every Manager.
// RestoreInterruptedMessage is what the household reads when the box stopped mid-restore.
const RestoreInterruptedMessage = "A visszaállítás megszakadt (a doboz újraindult) — indítsd el újra."
// RestoreInterruptedKey names what the household reads when the box stopped mid-restore. It is
// SAVED in the restore record and read on the page afterwards, so it is rendered in the box's
// language at the moment it is written (release C, R-557).
const RestoreInterruptedKey = "note.restore.interrupted"
// restoreRecordFile is the on-disk shape.
type restoreRecordFile struct {
@@ -76,7 +78,7 @@ func (m *Manager) LoadRestoreRecord() *RestoreOpResult {
}
res := RestoreOpResult{
Op: rec.Op, Stack: rec.Stack, OK: false,
Message: RestoreInterruptedMessage, FinishedAt: time.Now(), Interrupted: true,
Message: m.note(RestoreInterruptedKey), FinishedAt: time.Now(), Interrupted: true,
}
m.opLast = &res
if m.opInterrupted == nil {
+17 -14
View File
@@ -126,7 +126,7 @@ func (m *Manager) selectTier2TargetFrom(stackName, sourceDrive string, fullSize,
return &Tier2Target{
NamespaceRoot: NamespaceRoot(sp.Path, true),
Label: label,
Reason: "kézi választás",
Reason: m.note("note.tier2.reason_manual"),
}, nil
}
}
@@ -149,7 +149,7 @@ func (m *Manager) selectTier2TargetFrom(stackName, sourceDrive string, fullSize,
return &Tier2Target{
NamespaceRoot: NamespaceRoot(sp.Path, true), // Model A: in-guest mount IS the namespace root
Label: label,
Reason: "másik adatmeghajtó",
Reason: m.note("note.tier2.reason_other_drive"),
}, nil
}
}
@@ -171,10 +171,10 @@ func (m *Manager) selectTier2TargetFrom(stackName, sourceDrive string, fullSize,
}
return &Tier2Target{
NamespaceRoot: NamespaceRoot(sys, false), // system path is a real root → felhom-data appended
Label: "belső SSD (rendszer)",
Label: m.note("note.tier2.label_internal_ssd"),
IsSystemDrive: true,
StateOnly: true,
Reason: "nincs 2. adatmeghajtó — csak az adatbázis/konfiguráció fér a belső SSD-re; a nagy fájlokhoz 2. meghajtó kell",
Reason: m.note("note.tier2.reason_no_second"),
}, nil
}
@@ -321,14 +321,14 @@ func (m *Manager) RunTier2(stackName string) error {
target, err := m.selectTier2Target(stackName, fullSize, stateOnlySize)
if err != nil {
reason := tier2NoTargetReason(err)
reason := m.tier2NoTargetReason(err)
m.recordTier2NoTarget(stackName, reason)
m.logger.Printf("[INFO] [backup] Tier 2 for %s: no off-drive target — %s", stackName, reason)
return nil
}
// Defense-in-depth off-drive guard (selection already enforced it).
if m.sameDevice(sourceDrive, target.NamespaceRoot) {
m.recordTier2NoTarget(stackName, "a kiválasztott cél ugyanazon a fizikai lemezen van")
m.recordTier2NoTarget(stackName, m.note("note.tier2.same_disk"))
return nil
}
@@ -345,7 +345,7 @@ func (m *Manager) RunTier2(stackName string) error {
}
legs = kept
if droppedOptional {
warns = append(warns, "A belső SSD-n csak a konfiguráció, adatbázis és a kötelező adatok férnek el — a választható tartalom nem került másolásra.")
warns = append(warns, m.note("note.tier2.ssd_partial"))
}
}
@@ -383,7 +383,7 @@ func (m *Manager) RunTier2(stackName string) error {
// compose/app.yaml carries portable secrets and nothing from inside it is logged here.
m.logger.Printf("[WARN] [backup] Tier 2 %s: unit leg SKIPPED — the recovery unit on the source drive lists no database dumps and no volume tars, while the existing copy at %s does. The copy was PRESERVED rather than replaced with an empty one (R-403). The other legs continue.",
stackName, destUnit)
warns = append(warns, tier2UnitPreservedWarning)
warns = append(warns, m.note(tier2UnitPreservedKey))
} else if err := mirror(unitDir, destUnit); err != nil {
m.recordTier2Failure(stackName, target, err)
if m.tier2Notify != nil {
@@ -549,7 +549,7 @@ func (m *Manager) Tier2Info(stackName string) Tier2Info {
target, err := m.selectTier2Target(stackName, fullSize, stateOnlySize)
if err != nil {
info.NoTarget = true
info.NoTargetReason = tier2NoTargetReason(err)
info.NoTargetReason = m.tier2NoTargetReason(err)
return info
}
info.EffectiveLabel = target.Label
@@ -668,14 +668,17 @@ func (m *Manager) recordTier2NoTarget(stackName, reason string) {
})
}
func tier2NoTargetReason(err error) string {
// tier2NoTargetReason is SAVED (CrossDriveConfig.LastError / Tier2Info.NoTargetReason) and read on the
// per-app card later, so it is written in the box's language at write time (release C, R-557). The
// branch is on a SENTINEL, never on these words (R-553) — which is why translating them is safe.
func (m *Manager) tier2NoTargetReason(err error) string {
switch {
case errors.Is(err, errSSDNoHeadroom):
return "nincs elég hely a belső SSD-n — a nagy fájlok off-drive mentéséhez 2. meghajtó (vagy távoli tárhely) szükséges"
return m.note("note.tier2.no_space_ssd")
case errors.Is(err, errNoOffDiskTarget):
return "nincs másik fizikai meghajtó — a 2. mentéshez 2. meghajtó szükséges"
return m.note("note.tier2.no_other_drive")
default:
return err.Error()
return m.noteErr(err)
}
}
@@ -700,7 +703,7 @@ func rsyncMirror(src, dst string) error {
// tier2UnitPreservedWarning is the customer-facing half of the R-403 skip. It rides in
// CrossDriveBackup.LastWarning, which the per-app card already renders, so the refusal reaches the
// SURFACE and not only the log — the shape recordTier2NoTarget established.
const tier2UnitPreservedWarning = "A fő meghajtón lévő adatcsomag hiányos volt, ezért a másodlagos másolatban meglévő, teljes csomagot megőriztük. A másolat adatcsomagja ezért régebbi, mint ez a mentés."
const tier2UnitPreservedKey = "note.tier2.unit_preserved"
// unitPackageDate returns the `created_at` of a recovery unit's manifest — WHEN the package in that
// directory was captured. "" when the manifest is absent or unparseable, which the surface must read
+1 -1
View File
@@ -170,7 +170,7 @@ func (m *Manager) RunSharesTier2() error {
}
target, err := m.selectTier2TargetFrom(SharesPseudoStack, g.sourceDrive, fullSize, stateOnlySize)
if err != nil {
why := tier2NoTargetReason(err)
why := m.tier2NoTargetReason(err)
noTargetWhy = append(noTargetWhy, fmt.Sprintf("%s: %s", g.sourceDrive, why))
m.logger.Printf("[INFO] [shares] tier-2: no off-drive target for shares on %s — %s", g.sourceDrive, why)
continue
+72 -1
View File
@@ -2196,5 +2196,76 @@
"err.integrations.onlyoffice_no_subdomain": "the OnlyOffice subdomain is not known",
"err.integrations.occ_failed": "the occ command failed (%s): %s (output: %s)",
"err.integrations.occ_disable_failed": "occ app:disable failed: %s (output: %s)",
"err.notify.hub_error": "hub error (%d): %s"
"err.notify.hub_error": "hub error (%d): %s",
"note.offsite.fail_quota": "The remote backup did not fit inside its storage limit",
"note.offsite.fail_orphaned": "The remote store was made with an earlier key that is no longer available",
"note.offsite.fail_no_repo": "There is no backup store at the remote target",
"note.offsite.fail_no_units": "There was nothing to back up: none of the selected apps has a backup",
"note.offsite.fail_transport": "The remote store cannot be reached (network or sign-in)",
"note.offsite.fail_unknown": "The remote backup failed for an unknown reason",
"note.offsite.fail_head": "The remote backup failed",
"note.offsite.whole_unit_gap": "(the whole app — it has no local backup unit)",
"note.offsite.no_local_unit": "These apps did NOT go into the remote backup, because they have no local backup unit yet: %s. The next backup usually makes one — if they are still listed after the second run, tell your operator.",
"note.offsite.not_installed": "These apps are selected for remote backup but are not installed, so they cannot be backed up: %s. If you no longer need them, clear their selection on the Remote backup page.",
"note.offsite.quota_partial": "Warning: because of the storage limit, %d apps got only a settings and database backup: %s.",
"note.offsite.quota_usage": "The remote backup uses %d%% of its limit (%d/%d GB).",
"note.tier2.reason_manual": "your own choice",
"note.tier2.reason_other_drive": "another data drive",
"note.tier2.label_internal_ssd": "internal SSD (system)",
"note.tier2.reason_no_second": "no second data drive — only the database and settings fit on the internal SSD; large files need a second drive",
"note.tier2.same_disk": "the target you picked is on the same physical disk",
"note.tier2.ssd_partial": "Only the settings, the database and the required data fit on the internal SSD — the optional content was not copied.",
"note.tier2.no_space_ssd": "not enough space on the internal SSD — backing large files off the drive needs a second drive (or remote storage)",
"note.tier2.no_other_drive": "there is no second physical drive — the second backup needs one",
"note.tier2.unit_preserved": "The data package on the main drive was incomplete, so the complete one already in the second copy was kept. The copy's data package is therefore older than this backup.",
"note.restore.interrupted": "The restore was interrupted (the box restarted) — start it again.",
"note.undo.present": "the earlier state is backed up: %s",
"note.undo.partial": "the earlier state is only PARTLY backed up — present: %s; MISSING: %s",
"note.undo.absent": "the backup of the earlier state is NOT where it should be (%s)",
"note.undo.none": "no restorable copy of the earlier state was made",
"note.reconstitute.copy_latest": "latest",
"note.reconstitute.held": "restoring the data of %s stopped at %s, and the earlier state could not be put back either. The app stays stopped, for safety, so your data cannot be damaged further. Get in touch with us",
"layout.nyelv": "Language",
"note.restore.failed": "The restore failed: %s",
"note.restore.full_failed": "The full restore failed: %s",
"note.restore.recover_failed": "The recovery failed: %s",
"note.restore.shares_failed": "Restoring the shares failed: %s",
"note.restore.shares_recover_failed": "Recovering the shares failed: %s",
"note.restore.unit_failed": "Restore failed: %s",
"note.restore.file_failed": "File restore failed: %s",
"note.restore.full_unit_failed": "Full restore failed: %s",
"note.restore.recovered_files": "The missing files of %s are back among your live data.",
"note.restore.shares_prepared": "The shares are restored — you can now put them back among your live data.",
"note.restore.at_scratch": " into the check folder",
"note.restore.at_scratch_named": " into the check folder: %s",
"note.restore.scratch_full": "The full backup of %s is restored%s — together with your own files. Your existing data is unchanged.",
"note.restore.scratch_state": "The settings and the database of %s are restored%s. Your own files (documents, pictures, uploads) did NOT come back — this check restore brings back only the app's settings and database. If you need your files, start “Prepare a full restore” on this page. Your existing data is unchanged.",
"note.restore.files_count": "%d files",
"note.restore.and_volumes": " and %d data volumes",
"note.restore.and_database": " and the database",
"note.restore.volumes_count": "%d data volumes",
"note.restore.the_database": "the database",
"note.restore.dumps_at": " (backup: %s)",
"note.restore.reconstituted": "%s: %s restored%s — the app has restarted.",
"note.restore.no_db_in_backup": " WARNING: this app HAS a database, but the backup held no database dump, so the database did NOT come back. The state from before the restore is backed up: %s",
"note.restore.no_db_at_all": " This app has no database.",
"note.restore.all_files_present": "Every file that was checked is in place.",
"note.restore.files_from_second": "%s: %d files restored from the second copy.",
"note.restore.scratch_incomplete": "The restore copy is incomplete — the last download did not finish. Start “Prepare a full restore” again.",
"note.unit_restore_data": "%s: %s restored — the app has restarted.",
"note.unit_restore_settings_only": "%s: the settings are back — the app has restarted. WARNING: this backup held only the settings, no data. The app's data did NOT come back from this backup.",
"note.unit_restore_counts_unknown": "The restore of %s ran — the app has restarted. This backup has no backup unit, so we cannot tell you what came back from it. Check inside the app that your data is there.",
"note.unit_restore_none_returned": "%s: WARNING — the backup lists %d data volumes and %d database dumps, but none of them came back. Your data is unchanged. Ask for help before you try again.",
"note.tier2_no_coverage": "This app's data cannot be restored from this copy — the app was not stopped. Use the Start restore button on the Backup → Restore page.",
"note.tier2_unit_available": "This app's data is not in files but in the app's own database and volumes — the app was not stopped. You can bring those back from the same copy with the “Full restore from the copy” button beside this one. Careful: that action OVERWRITES the current data, while this button only fills in the missing files.",
"note.tier2_unit_not_covered": "The app's database and internal volumes are not part of this restore.",
"note.tier2_unit_restore_source": "The restore came from the copy on the second drive (%s).",
"note.tier2_unit_restore_source_unproven": "The restore came from the copy on the second drive (%s — that is the time of the last backup ATTEMPT; we cannot prove it succeeded).",
"note.tier2_unit_action_label": "Full restore from the copy",
"note.tier2_unit_confirm_base": "This action OVERWRITES the app's current data – its database and its internal volumes too – with the copy on the second drive. Anything made since the copy is lost.",
"note.tier2_unit_confirm_date_fmt": " The copy is from: %s.",
"note.tier2_unit_confirm_date_unproven_fmt": " The copy is from: %s – that is the time of the last backup attempt; we cannot prove it succeeded.",
"note.tier2_unit_confirm_contrast": " The “Restore files” button beside it only fills in the missing files and overwrites nothing. The app stops while either one runs.",
"note.tier2_unit_stale_clause": " WARNING: this copy's data package is older than the latest backup — the package on the main drive was incomplete, so the complete one was kept. The restore uses the package named above.",
"note.tier2_unit_stale_notice_fmt": "The copy's data package is older than the latest backup (%s): the package on the main drive was incomplete, so the complete one already there was kept."
}
+72 -1
View File
@@ -2185,5 +2185,76 @@
"err.integrations.onlyoffice_no_subdomain": "OnlyOffice aldomain nem ismert",
"err.integrations.occ_failed": "occ parancs sikertelen (%s): %s (kimenet: %s)",
"err.integrations.occ_disable_failed": "occ app:disable sikertelen: %s (kimenet: %s)",
"err.notify.hub_error": "hub hiba (%d): %s"
"err.notify.hub_error": "hub hiba (%d): %s",
"note.offsite.fail_quota": "A távoli mentés nem fért el a tárhelykereten belül",
"note.offsite.fail_orphaned": "A távoli tárhely egy korábbi, már nem elérhető kulccsal készült",
"note.offsite.fail_no_repo": "A távoli tárhelyen nincs mentési adattár",
"note.offsite.fail_no_units": "Nem volt mit menteni: egyetlen kijelölt alkalmazásnak sem található mentése",
"note.offsite.fail_transport": "A távoli tárhely nem érhető el (hálózat vagy bejelentkezés)",
"note.offsite.fail_unknown": "A távoli mentés ismeretlen okból nem sikerült",
"note.offsite.fail_head": "A távoli mentés nem sikerült",
"note.offsite.whole_unit_gap": "(a teljes alkalmazás — nincs helyi mentési egysége)",
"note.offsite.no_local_unit": "Ezek az alkalmazások NEM kerültek be a távoli mentésbe, mert még nincs helyi mentési egységük: %s. A következő mentés általában már elkészíti — ha a második futás után is itt szerepelnek, szólj az üzemeltetőnek.",
"note.offsite.not_installed": "Ezek az alkalmazások ki vannak jelölve távoli mentésre, de nincsenek telepítve, ezért nem menthetők: %s. Ha már nincs rájuk szükséged, vedd ki a kijelölésüket a Távoli mentés oldalon.",
"note.offsite.quota_partial": "Figyelmeztetés: a tárhelykeret miatt %d alkalmazásnál csak konfiguráció- és adatbázis-mentés készült: %s.",
"note.offsite.quota_usage": "A távoli mentés a keret %d%%-át használja (%d/%d GB).",
"note.tier2.reason_manual": "kézi választás",
"note.tier2.reason_other_drive": "másik adatmeghajtó",
"note.tier2.label_internal_ssd": "belső SSD (rendszer)",
"note.tier2.reason_no_second": "nincs 2. adatmeghajtó — csak az adatbázis/konfiguráció fér a belső SSD-re; a nagy fájlokhoz 2. meghajtó kell",
"note.tier2.same_disk": "a kiválasztott cél ugyanazon a fizikai lemezen van",
"note.tier2.ssd_partial": "A belső SSD-n csak a konfiguráció, adatbázis és a kötelező adatok férnek el — a választható tartalom nem került másolásra.",
"note.tier2.no_space_ssd": "nincs elég hely a belső SSD-n — a nagy fájlok off-drive mentéséhez 2. meghajtó (vagy távoli tárhely) szükséges",
"note.tier2.no_other_drive": "nincs másik fizikai meghajtó — a 2. mentéshez 2. meghajtó szükséges",
"note.tier2.unit_preserved": "A fő meghajtón lévő adatcsomag hiányos volt, ezért a másodlagos másolatban meglévő, teljes csomagot megőriztük. A másolat adatcsomagja ezért régebbi, mint ez a mentés.",
"note.restore.interrupted": "A visszaállítás megszakadt (a doboz újraindult) — indítsd el újra.",
"note.undo.present": "a korábbi állapot mentése megvan: %s",
"note.undo.partial": "a korábbi állapot mentése RÉSZBEN van meg — megvan: %s; HIÁNYZIK: %s",
"note.undo.absent": "a korábbi állapot mentését NEM találjuk a helyén (%s)",
"note.undo.none": "a korábbi állapotról nem készült menthető másolat",
"note.reconstitute.copy_latest": "legutóbbi",
"note.reconstitute.held": "a(z) %s adatainak visszaállítása %s-kor megszakadt, és a korábbi állapotot sem sikerült visszatölteni. Az alkalmazás biztonsági okból leállítva marad, hogy az adatai ne sérüljenek tovább. Vedd fel velünk a kapcsolatot",
"layout.nyelv": "Nyelv",
"note.restore.failed": "A visszaállítás sikertelen: %s",
"note.restore.full_failed": "A teljes visszaállítás sikertelen: %s",
"note.restore.recover_failed": "A helyreállítás sikertelen: %s",
"note.restore.shares_failed": "A megosztások visszaállítása sikertelen: %s",
"note.restore.shares_recover_failed": "A megosztások helyreállítása sikertelen: %s",
"note.restore.unit_failed": "Visszaállítás sikertelen: %s",
"note.restore.file_failed": "Fájl-visszaállítás sikertelen: %s",
"note.restore.full_unit_failed": "Teljes visszaállítás sikertelen: %s",
"note.restore.recovered_files": "A(z) %s hiányzó fájljai helyreállítva az élő adatok közé.",
"note.restore.shares_prepared": "A megosztások visszaállítása elkészült — most helyreállíthatod az élő adatok közé.",
"note.restore.at_scratch": " ellenőrző mappába",
"note.restore.at_scratch_named": " ellenőrző mappába: %s",
"note.restore.scratch_full": "A(z) %s teljes mentése visszaállítva%s — a saját fájljaiddal együtt. A meglévő adatok változatlanok.",
"note.restore.scratch_state": "A(z) %s beállításai és adatbázisa visszaállítva%s. A saját fájljaid (dokumentumok, képek, feltöltések) NEM kerültek vissza — ez az ellenőrző visszaállítás csak az alkalmazás beállításait és adatbázisát hozza vissza. Ha a fájljaidra van szükséged, indítsd el a „Teljes visszaállítás előkészítése” lépést ezen az oldalon. A meglévő adatok változatlanok.",
"note.restore.files_count": "%d fájl",
"note.restore.and_volumes": " és %d adatkötet",
"note.restore.and_database": " és az adatbázis",
"note.restore.volumes_count": "%d adatkötet",
"note.restore.the_database": "az adatbázis",
"note.restore.dumps_at": " (mentés: %s)",
"note.restore.reconstituted": "A(z) %s: %s visszaállítva%s — az alkalmazás újraindult.",
"note.restore.no_db_in_backup": " FIGYELEM: ennek az alkalmazásnak VAN adatbázisa, de a mentés nem tartalmazott adatbázis-mentést, ezért az adatbázis NEM állt vissza. A visszaállítás előtti állapot mentése megvan: %s",
"note.restore.no_db_at_all": " Ennek az alkalmazásnak nincs adatbázisa.",
"note.restore.all_files_present": "Minden vizsgált fájl megvan a helyén.",
"note.restore.files_from_second": "%s: %d fájl visszaállítva a másodlagos másolatból.",
"note.restore.scratch_incomplete": "A visszaállítási másolat nem teljes — a legutóbbi letöltés nem fejeződött be. Indítsd újra a teljes visszaállítás előkészítését.",
"note.unit_restore_data": "A(z) %s: %s visszaállítva — az alkalmazás újraindult.",
"note.unit_restore_settings_only": "A(z) %s: a beállítások visszaálltak — az alkalmazás újraindult. FIGYELEM: ez a mentés csak a beállításokat tartalmazta, adatot nem. Az alkalmazás adatai NEM álltak vissza ebből a mentésből.",
"note.unit_restore_counts_unknown": "A(z) %s visszaállítása lefutott — az alkalmazás újraindult. Ehhez a mentéshez nem tartozik mentési egység, ezért nem tudjuk megmondani, mi állt vissza belőle. Ellenőrizd az alkalmazásban, hogy megvannak-e az adataid.",
"note.unit_restore_none_returned": "A(z) %s: FIGYELEM — a mentés %d adatkötetet és %d adatbázis-mentést sorol fel, de egyik sem állt vissza. Az adataid változatlanok maradtak. Kérj segítséget, mielőtt újra próbálod.",
"note.tier2_no_coverage": "Ennek az alkalmazásnak az adatai nem ebből a másolatból állíthatók vissza — az alkalmazás nem állt le. Használd a Visszaállítás indítása gombot a Biztonsági mentés → Visszaállítás oldalon.",
"note.tier2_unit_available": "Ennek az alkalmazásnak az adatai nem fájlokban, hanem az alkalmazás saját adatbázisában és köteteiben vannak — az alkalmazás nem állt le. Ezeket a mellette lévő „Teljes visszaállítás a másolatból” gombbal tudod visszahozni ugyanerről a másolatról. Figyelem: az a művelet FELÜLÍRJA a jelenlegi adatokat, míg ez a gomb csak a hiányzó fájlokat pótolja.",
"note.tier2_unit_not_covered": "Az alkalmazás adatbázisa és belső kötetei nem tartoznak ebbe a visszaállításba.",
"note.tier2_unit_restore_source": "A visszaállítás forrása a második meghajtón lévő másolat volt (%s).",
"note.tier2_unit_restore_source_unproven": "A visszaállítás forrása a második meghajtón lévő másolat volt (%s — ez az utolsó mentési kísérlet ideje; azt nem tudjuk igazolni, hogy az sikeres volt).",
"note.tier2_unit_action_label": "Teljes visszaállítás a másolatból",
"note.tier2_unit_confirm_base": "Ez a művelet FELÜLÍRJA az alkalmazás jelenlegi adatait – az adatbázisát és a belső köteteit is – a második meghajtón lévő másolattal. Ami a másolat óta keletkezett, elveszik.",
"note.tier2_unit_confirm_date_fmt": " A másolat kelte: %s.",
"note.tier2_unit_confirm_date_unproven_fmt": " A másolat kelte: %s – ez az utolsó mentési kísérlet ideje, azt nem tudjuk igazolni, hogy sikeres volt.",
"note.tier2_unit_confirm_contrast": " A mellette lévő „Fájlok visszaállítása” ezzel szemben csak a hiányzó fájlokat pótolja, és semmit nem ír felül. Az alkalmazás a művelet idejére leáll.",
"note.tier2_unit_stale_clause": " FIGYELEM: ennek a másolatnak az adatcsomagja régebbi, mint a legutóbbi mentés — a fő meghajtón lévő csomag hiányos volt, ezért a meglévő, teljes másolatot megőriztük. A visszaállítás a fent megadott csomagot használja.",
"note.tier2_unit_stale_notice_fmt": "A másolat adatcsomagja régebbi, mint a legutóbbi mentés (%s): a fő meghajtón lévő csomag hiányos volt, ezért a meglévő, teljes másolatot megőriztük."
}
+22
View File
@@ -149,3 +149,25 @@ func ErrText(lang string, err error) string {
}
return err.Error()
}
// Text renders a bundle message in a KNOWN language — for a producer that has no request.
//
// Release C (v0.254.0) uses it for the sentences a background run SAVES: the note under last night's
// backup, the last error, the proof result. Those are written at 03:00 and read days later, so there
// is no reader to ask; the operator ruling (slice 2 §16, option 1) is that they are written in the
// box's language AT WRITE TIME and shown verbatim afterwards.
//
// The consequence, stated rather than hidden: a household that switches language sees last night's
// note in the old language until the next run rewrites it. The alternative — storing a code and
// rendering live — needs a dozen new persisted fields and a legacy path for each, which is the
// R-570 shape a dozen times over.
func Text(lang, key string, args ...interface{}) string {
b, err := i18n.Shared()
if err != nil {
return key
}
if len(args) == 0 {
return b.Msg(lang, key)
}
return b.Msgf(lang, key, args...)
}
+8
View File
@@ -82,6 +82,14 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler {
return
}
// The anonymous language switch (v0.254.0, R-557). The sign-in page shows a globe, so the
// globe has to work without a session. It writes ONE display-only cookie in the visitor's own
// browser and touches nothing the household owns — langCookieHandler carries the reasoning.
if r.URL.Path == langCookiePath && r.Method == http.MethodPost {
s.langCookieHandler(w, r)
return
}
// Claim/reset routes stay reachable pre-auth even on a claimed box: they are the RESET
// entry (code-gated internally). Static assets for the page too. The guest launcher share
// (v0.165.0) joins here — /s/<token> is a capability URL with NO admin session; the token
+23
View File
@@ -8,6 +8,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"io"
"net"
"net/http"
@@ -246,6 +247,11 @@ func claimPageAllowedPath(path string) bool {
switch path {
case "/claim", "/claim/request-new-code", "/api/health":
return true
case langCookiePath:
// The claim page carries the language globe, so its switch has to work on an unclaimed box —
// which is precisely the box whose first visitor may not read Hungarian (v0.254.0, R-557).
// It sets a display-only cookie and can do nothing else; see langCookieHandler.
return true
}
return strings.HasPrefix(path, "/static/")
}
@@ -390,6 +396,23 @@ func (s *Server) handleClaimSubmit(w http.ResponseWriter, r *http.Request) {
// flips in seconds — the operator sees the customer claim land immediately.
s.reportTriggerNow()
s.claimClearFailures(ip)
// §16 (operator default, v0.254.0): the claim CARRIES the visitor's language. Someone who switched
// the claim page to English and then claimed the box chose English deliberately; the first
// dashboard they see should be in it, and the hub's e-mails will follow once slice 3 lands.
//
// Only here, and only on SUCCESS: this is the one moment an anonymous visitor becomes the
// household, so it is the one moment their display cookie may become the household's setting.
// Every other anonymous request leaves settings.json alone (langFor, CsrfProtect).
if c, err := r.Cookie(langCookieName); err == nil && i18n.IsSupported(c.Value) {
if err := s.settings.SetLanguage(c.Value); err != nil {
// Not fatal: the box is claimed either way, and the household can switch on the dashboard.
s.logger.Printf("[WARN] [web] claim: could not carry the visitor's language %q: %v", c.Value, err)
} else {
s.logger.Printf("[INFO] [web] claim: household language set to %q from the claim page", c.Value)
}
}
s.invalidateAllSessions() // reset: kill old sessions; first-claim: none exist
action := "claimed"
+11
View File
@@ -42,6 +42,17 @@ func (s *Server) CsrfProtect(next http.Handler) http.Handler {
return
}
// The anonymous language switch (v0.254.0, R-557). Exempt for a narrow and checkable reason:
// the ONLY thing it can achieve is to change the language of the page the victim's own browser
// shows them. It writes one display-only cookie, reads nothing, touches no setting, and its
// redirect cannot leave this box (safeBackPath). A CSRF token here would also be unobtainable:
// the visitor has no session to mint one from. **If this handler ever gains a second effect,
// it needs CSRF that day** — the exemption is for what it does, not for where it lives.
if r.URL.Path == langCookiePath {
next.ServeHTTP(w, r)
return
}
// Skip CSRF for Bearer-token authenticated requests.
// Validate the token against the configured API key before skipping.
if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") {
+50 -50
View File
@@ -1526,9 +1526,9 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus, lang string
// preserved leg those are different dates and the run's is the flattering one.
pkgDate, stale := rp.CopyDate, rp.PackagePreserved
row.Tier2CopyDate, row.Tier2CopyDateProven = pkgDate, rp.CopyDateProven
row.Tier2UnitConfirm = tier2UnitConfirmWithStaleness(pkgDate, row.Tier2CopyDateProven, stale)
row.Tier2UnitConfirm = s.tier2UnitConfirmWithStaleness(pkgDate, row.Tier2CopyDateProven, stale)
if stale && pkgDate != "" {
row.Tier2UnitStaleNotice = fmt.Sprintf(tier2UnitStaleNoticeFmt, fmtRFC3339Local(pkgDate))
row.Tier2UnitStaleNotice = s.msgLang(lang, tier2UnitStaleNoticeFmtKey, fmtRFC3339Local(pkgDate))
}
}
switch cd.LastStatus {
@@ -1662,7 +1662,7 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
res, err := s.backupMgr.RestoreFromRecoveryUnit(stackName)
if err != nil {
s.logger.Printf("[ERROR] [web] Restore failed (async): stack=%s: %v", stackName, err)
s.backupMgr.EndRestoreOp(false, "Visszaállítás sikertelen: "+err.Error())
s.backupMgr.EndRestoreOp(false, s.note("note.restore.unit_failed", s.noteErr(err)))
return
}
s.logger.Printf("[INFO] [web] Restore completed (async): stack=%s in %s (volumes %d/%d, dbs %d/%d)",
@@ -1672,7 +1672,7 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
// The customer reads it as "my data is back". The snapshot id is dropped from the sentence
// deliberately: it identified WHICH backup ran and told the customer nothing about what came out
// of it, which is the question the sentence exists to answer.
s.backupMgr.EndRestoreOp(true, unitRestoreOutcomeMsg(stackName, res))
s.backupMgr.EndRestoreOp(true, s.unitRestoreOutcomeMsg(stackName, res))
}()
http.Redirect(w, r, "/backups/restore?"+flashQuery("flash", "flash.restore.started"), http.StatusFound)
}
@@ -1702,31 +1702,31 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
// that have nothing to do with whether the app has a database.
//
// No filesystem path appears in the message, only counts — same rule as reconstituteOutcomeMsg.
func unitRestoreOutcomeMsg(app string, res backup.UnitRestoreResult) string {
func (s *Server) unitRestoreOutcomeMsg(app string, res backup.UnitRestoreResult) string {
if res.VolumesReplayed > 0 || res.DBsReplayed > 0 {
var what string
if res.VolumesReplayed > 0 {
what = fmt.Sprintf("%d adatkötet", res.VolumesReplayed)
what = s.note("note.restore.volumes_count", res.VolumesReplayed)
}
if res.DBsReplayed > 0 {
if what != "" {
what += " és az adatbázis"
what += s.note("note.restore.and_database")
} else {
what = "az adatbázis"
what = s.note("note.restore.the_database")
}
}
return fmt.Sprintf(unitRestoreDataMsgFmt, app, what)
return s.note(unitRestoreDataKey, app, what)
}
// An unknown must never be drawn as a zero. The no-unit fallback cannot report counts, and the
// zero-value shape would otherwise read as „the backup held only settings" over a restore that may
// have replayed the app's whole dataset. Same failure direction as R-88: degrade to UNKNOWN.
if res.CountsUnknown {
return fmt.Sprintf(unitRestoreCountsUnknownMsgFmt, app)
return s.note(unitRestoreCountsUnknownKey, app)
}
if res.ManifestVolumes+res.ManifestDBs > 0 {
return fmt.Sprintf(unitRestoreNoneReturnedMsgFmt, app, res.ManifestVolumes, res.ManifestDBs)
return s.note(unitRestoreNoneReturnedKey, app, res.ManifestVolumes, res.ManifestDBs)
}
return fmt.Sprintf(unitRestoreSettingsOnlyMsgFmt, app)
return s.note(unitRestoreSettingsOnlyKey, app)
}
// R-353 customer-facing strings. Named constants, not inlined, because each is asserted verbatim by
@@ -1735,12 +1735,12 @@ func unitRestoreOutcomeMsg(app string, res backup.UnitRestoreResult) string {
const (
// unitRestoreDataMsgFmt — data really came back. %s app, %s the "N adatkötet[ és az adatbázis]"
// clause built above.
unitRestoreDataMsgFmt = "A(z) %s: %s visszaállítva — az alkalmazás újraindult."
unitRestoreDataKey = "note.unit_restore_data"
// unitRestoreSettingsOnlyMsgFmt — nothing came back and the unit listed nothing. The FIGYELEM
// sentence is a statement about THE BACKUP; it deliberately says nothing about whether the app has
// data of its own, because the manifest cannot answer that (R-355).
unitRestoreSettingsOnlyMsgFmt = "A(z) %s: a beállítások visszaálltak — az alkalmazás újraindult. FIGYELEM: ez a mentés csak a beállításokat tartalmazta, adatot nem. Az alkalmazás adatai NEM álltak vissza ebből a mentésből."
unitRestoreSettingsOnlyKey = "note.unit_restore_settings_only"
// unitRestoreNoneReturnedMsgFmt — the unit listed data and none of it returned. %d volumes, %d
// database dumps LISTED. It states the data is unchanged because that is true and load-bearing: the
@@ -1748,9 +1748,9 @@ const (
// who believes otherwise will do something worse than waiting.
// unitRestoreCountsUnknownMsgFmt — the no-unit fallback. It claims only what is known: the restore
// ran and the app is back. It deliberately does NOT say data returned and does NOT say it did not.
unitRestoreCountsUnknownMsgFmt = "A(z) %s visszaállítása lefutott — az alkalmazás újraindult. Ehhez a mentéshez nem tartozik mentési egység, ezért nem tudjuk megmondani, mi állt vissza belőle. Ellenőrizd az alkalmazásban, hogy megvannak-e az adataid."
unitRestoreCountsUnknownKey = "note.unit_restore_counts_unknown"
unitRestoreNoneReturnedMsgFmt = "A(z) %s: FIGYELEM — a mentés %d adatkötetet és %d adatbázis-mentést sorol fel, de egyik sem állt vissza. Az adataid változatlanok maradtak. Kérj segítséget, mielőtt újra próbálod."
unitRestoreNoneReturnedKey = "note.unit_restore_none_returned"
)
// monitoringIntegritySchedule (R-359) describes what the off-site integrity job actually does.
@@ -1779,13 +1779,13 @@ const (
// no file legs but a full unit mirror sitting in the copy — and it sent those customers to a
// button on another page for data that is now restorable on the page they are already looking at.
// tier2UnitAvailableMsg is that case now.
tier2NoCoverageMsg = "Ennek az alkalmazásnak az adatai nem ebből a másolatból állíthatók vissza — az alkalmazás nem állt le. Használd a Visszaállítás indítása gombot a Biztonsági mentés → Visszaállítás oldalon."
tier2NoCoverageKey = "note.tier2_no_coverage"
// tier2UnitAvailableMsg (R-103) — the copy holds no restorable FILES, but it does hold an openable
// recovery unit, so the answer is the action beside this one, not a different page. It names the
// button by its own label and says why the two differ, because the difference is the whole reason
// they are not one button: this one overwrites.
tier2UnitAvailableMsg = "Ennek az alkalmazásnak az adatai nem fájlokban, hanem az alkalmazás saját adatbázisában és köteteiben vannak — az alkalmazás nem állt le. Ezeket a mellette lévő „Teljes visszaállítás a másolatból” gombbal tudod visszahozni ugyanerről a másolatról. Figyelem: az a művelet FELÜLÍRJA a jelenlegi adatokat, míg ez a gomb csak a hiányzó fájlokat pótolja."
tier2UnitAvailableKey = "note.tier2_unit_available"
// tier2UnitNotCoveredMsg is appended wherever the FILE restore DID run, so a clean result never
// reads as a clean bill of health for data the operation never opened.
@@ -1795,17 +1795,17 @@ const (
// database or the named volumes. Deleting it would let a clean file-restore result read as a clean
// bill of health for data the operation did not look at, which is the sentence it exists to
// prevent.
tier2UnitNotCoveredMsg = "Az alkalmazás adatbázisa és belső kötetei nem tartoznak ebbe a visszaállításba."
tier2UnitNotCoveredKey = "note.tier2_unit_not_covered"
// The Tier-2 UNIT restore's outcome suffix (R-102, Scenario E). The outcome names WHICH copy was
// just written over the app's live data — an action that overwrites must say what it overwrote
// with, in the sentence the customer is left holding.
tier2UnitRestoreSourceMsgFmt = "A visszaállítás forrása a második meghajtón lévő másolat volt (%s)."
tier2UnitRestoreSourceKey = "note.tier2_unit_restore_source"
// …and the R-101 variant. CopyLastRun is the ATTEMPT clock: it advances on a FAILED Tier-2 run
// too. Where no success has ever been recorded for this app, the date shown is evidence that a
// copy was attempted and nothing more, and the sentence must not present it as evidence of a copy.
tier2UnitRestoreSourceUnprovenMsgFmt = "A visszaállítás forrása a második meghajtón lévő másolat volt (%s — ez az utolsó mentési kísérlet ideje; azt nem tudjuk igazolni, hogy az sikeres volt)."
tier2UnitRestoreSourceUnprovenKey = "note.tier2_unit_restore_source_unproven"
// R-102/R-103 — the DESTRUCTIVE CONFIRM, in pieces, and in Go rather than in the template.
//
@@ -1822,25 +1822,25 @@ const (
// Contrast — how it differs from the additive button beside it. The register's own requirement:
// a destructive operation reached from a non-destructive surface must carry the
// difference in the confirm, not rely on the customer inferring it from a label.
tier2UnitActionLabel = "Teljes visszaállítás a másolatból"
tier2UnitActionLabelKey = "note.tier2_unit_action_label"
tier2UnitConfirmBase = "Ez a művelet FELÜLÍRJA az alkalmazás jelenlegi adatait – az adatbázisát és a belső köteteit is – a második meghajtón lévő másolattal. Ami a másolat óta keletkezett, elveszik."
tier2UnitConfirmBaseKey = "note.tier2_unit_confirm_base"
tier2UnitConfirmDateFmt = " A másolat kelte: %s."
tier2UnitConfirmDateFmtKey = "note.tier2_unit_confirm_date_fmt"
tier2UnitConfirmDateUnprovenFmt = " A másolat kelte: %s – ez az utolsó mentési kísérlet ideje, azt nem tudjuk igazolni, hogy sikeres volt."
tier2UnitConfirmDateUnprovenFmtKey = "note.tier2_unit_confirm_date_unproven_fmt"
tier2UnitConfirmContrast = " A mellette lévő „Fájlok visszaállítása” ezzel szemben csak a hiányzó fájlokat pótolja, és semmit nem ír felül. Az alkalmazás a művelet idejére leáll."
tier2UnitConfirmContrastKey = "note.tier2_unit_confirm_contrast"
// tier2UnitStaleClause (R-403) — the package in this copy is OLDER than the copy's newest run,
// because that run PRESERVED it rather than replacing it with an empty one. Without this the
// confirm would name a date the customer reads as "last night" over a package from before it.
// The whole point of preserving the copy is lost if the surface then misdescribes what it kept.
tier2UnitStaleClause = " FIGYELEM: ennek a másolatnak az adatcsomagja régebbi, mint a legutóbbi mentés — a fő meghajtón lévő csomag hiányos volt, ezért a meglévő, teljes másolatot megőriztük. A visszaállítás a fent megadott csomagot használja."
tier2UnitStaleClauseKey = "note.tier2_unit_stale_clause"
// tier2UnitStaleNoticeFmt (R-403) — the same fact on the per-app backup card, where the customer
// looks BEFORE deciding anything. %s is the package's own date.
tier2UnitStaleNoticeFmt = "A másolat adatcsomagja régebbi, mint a legutóbbi mentés (%s): a fő meghajtón lévő csomag hiányos volt, ezért a meglévő, teljes másolatot megőriztük."
tier2UnitStaleNoticeFmtKey = "note.tier2_unit_stale_notice_fmt"
)
// tier2UnitConfirmMsg assembles the destructive confirm for one app's Tier-2 unit restore. Pure, so
@@ -1848,26 +1848,26 @@ const (
//
// A copy with no recorded date at all still gets a confirm — it just cannot name one. Dropping the
// whole confirm because a date is missing would remove the warning and keep the destruction.
func tier2UnitConfirmMsg(copyDate string, proven bool) string {
return tier2UnitConfirmWithStaleness(copyDate, proven, false)
func (s *Server) tier2UnitConfirmMsg(copyDate string, proven bool) string {
return s.tier2UnitConfirmWithStaleness(copyDate, proven, false)
}
// tier2UnitConfirmWithStaleness is tier2UnitConfirmMsg for a copy whose PACKAGE may be older than its
// newest run (R-403). ONE implementation, two callers — the two-argument form above is the ordinary
// case where the run really did refresh the package.
func tier2UnitConfirmWithStaleness(copyDate string, proven bool, stale bool) string {
msg := tier2UnitConfirmBase
func (s *Server) tier2UnitConfirmWithStaleness(copyDate string, proven bool, stale bool) string {
msg := s.note(tier2UnitConfirmBaseKey)
if copyDate != "" {
if proven {
msg += fmt.Sprintf(tier2UnitConfirmDateFmt, fmtRFC3339Local(copyDate))
msg += s.note(tier2UnitConfirmDateFmtKey, fmtRFC3339Local(copyDate))
} else {
msg += fmt.Sprintf(tier2UnitConfirmDateUnprovenFmt, fmtRFC3339Local(copyDate))
msg += s.note(tier2UnitConfirmDateUnprovenFmtKey, fmtRFC3339Local(copyDate))
}
}
msg += tier2UnitConfirmContrast
msg += s.note(tier2UnitConfirmContrastKey)
// R-403 last, so it is the sentence the customer is left holding before they press.
if stale {
msg += tier2UnitStaleClause
msg += s.note(tier2UnitStaleClauseKey)
}
return msg
}
@@ -1876,7 +1876,7 @@ func tier2UnitConfirmWithStaleness(copyDate string, proven bool, stale bool) str
// no date is recorded at all. It asks Tier2CopyDate — the SAME resolver the surface uses to pick the
// date it puts in the confirm — so the sentence the customer approves and the sentence they are left
// with cannot name different copies.
func tier2UnitSourceMsg(cov backup.Tier2Coverage) string {
func (s *Server) tier2UnitSourceMsg(cov backup.Tier2Coverage) string {
// R-403: the OUTCOME names the same date the CONFIRM did — the PACKAGE's, not the copy's newest
// run. Live on demo-hp 2026-08-31 these disagreed by two and a half hours (confirm 11:43, outcome
// 14:23) after a preserved leg, and a customer reading both would not know which restore they had
@@ -1887,9 +1887,9 @@ func tier2UnitSourceMsg(cov backup.Tier2Coverage) string {
return ""
}
if proven {
return fmt.Sprintf(tier2UnitRestoreSourceMsgFmt, fmtRFC3339Local(date))
return s.note(tier2UnitRestoreSourceKey, fmtRFC3339Local(date))
}
return fmt.Sprintf(tier2UnitRestoreSourceUnprovenMsgFmt, fmtRFC3339Local(date))
return s.note(tier2UnitRestoreSourceUnprovenKey, fmtRFC3339Local(date))
}
// backupTier2RestoreHandler (C2, closes F2) restores an app's MISSING user files in place from its
@@ -1932,9 +1932,9 @@ func (s *Server) backupTier2RestoreHandler(w http.ResponseWriter, r *http.Reques
// restorable on this one.
cov, covErr := s.backupMgr.Tier2RestoreCoverage(stackName)
if covErr == nil && !cov.CanRestore() {
msg := tier2NoCoverageMsg
msg := s.note(tier2NoCoverageKey)
if cov.CanRestoreUnit() {
msg = tier2UnitAvailableMsg
msg = s.note(tier2UnitAvailableKey)
}
s.logger.Printf("[WARN] [web] Tier-2 file restore refused up front: stack=%s has no restorable subtree in its copy (unit_present=%v unit_restorable=%v) — app NOT stopped",
stackName, cov.HasUnit, cov.CanRestoreUnit())
@@ -1951,11 +1951,11 @@ func (s *Server) backupTier2RestoreHandler(w http.ResponseWriter, r *http.Reques
// apply to this app. Say that, and name the one that does, instead of "sikertelen".
if errors.Is(err, backup.ErrTier2NoRestorableData) {
s.logger.Printf("[WARN] [web] Tier-2 file restore not applicable: stack=%s", stackName)
s.backupMgr.EndRestoreOp(false, tier2NoCoverageMsg)
s.backupMgr.EndRestoreOp(false, s.note(tier2NoCoverageKey))
return
}
s.logger.Printf("[ERROR] [web] Tier-2 file restore failed (async): stack=%s: %v", stackName, err)
s.backupMgr.EndRestoreOp(false, "Fájl-visszaállítás sikertelen: "+err.Error())
s.backupMgr.EndRestoreOp(false, s.note("note.restore.file_failed", s.noteErr(err)))
return
}
// C9-F1 (the quiet half): even where the restore DOES cover something it covers only the
@@ -1963,12 +1963,12 @@ func (s *Server) backupTier2RestoreHandler(w http.ResponseWriter, r *http.Reques
// copy's recovery-unit/. „minden fájl megvan a helyén" was a blanket claim over data that was
// never opened; immich's 1.3 GB Postgres unit is the case that makes it dangerous. Claim only
// what was EXAMINED, and disclose the rest.
msg := "Minden vizsgált fájl megvan a helyén."
msg := s.note("note.restore.all_files_present")
if n > 0 {
msg = fmt.Sprintf("%s: %d fájl visszaállítva a másodlagos másolatból.", stackName, n)
msg = s.note("note.restore.files_from_second", stackName, n)
}
if cov.HasUnit {
msg += " " + tier2UnitNotCoveredMsg
msg += " " + s.note(tier2UnitNotCoveredKey)
}
s.logger.Printf("[INFO] [web] Tier-2 file restore completed (async): stack=%s (%d files, legs=%v)", stackName, n, cov.Legs)
s.backupMgr.EndRestoreOp(true, msg)
@@ -2024,7 +2024,7 @@ func (s *Server) backupTier2UnitRestoreHandler(w http.ResponseWriter, r *http.Re
}
if !cov.CanRestoreUnit() {
s.logger.Printf("[WARN] [web] Tier-2 unit restore refused up front: stack=%s has no openable unit in its copy (unit_present=%v) — app NOT stopped", stackName, cov.HasUnit)
http.Redirect(w, r, "/backups/apps?flash_error="+url.QueryEscape(tier2NoCoverageMsg), http.StatusFound)
http.Redirect(w, r, "/backups/apps?"+flashQuery("flash_error", tier2NoCoverageKey), http.StatusFound)
return
}
@@ -2035,7 +2035,7 @@ func (s *Server) backupTier2UnitRestoreHandler(w http.ResponseWriter, r *http.Re
res, err := s.backupMgr.RestoreTier2Unit(stackName)
if err != nil {
s.logger.Printf("[ERROR] [web] Tier-2 unit restore failed (async): stack=%s: %v", stackName, err)
s.backupMgr.EndRestoreOp(false, "Teljes visszaállítás sikertelen: "+err.Error())
s.backupMgr.EndRestoreOp(false, s.note("note.restore.full_unit_failed", s.noteErr(err)))
return
}
s.logger.Printf("[INFO] [web] Tier-2 unit restore completed (async): stack=%s in %s (volumes %d/%d, dbs %d/%d)",
@@ -2045,8 +2045,8 @@ func (s *Server) backupTier2UnitRestoreHandler(w http.ResponseWriter, r *http.Re
// second thing to keep honest. What IS added is which copy it came from and how old that copy
// is: this action overwrote the customer's live data, and the sentence they are left with has
// to say what it overwrote it with (Scenario E).
msg := unitRestoreOutcomeMsg(stackName, res)
if src := tier2UnitSourceMsg(cov); src != "" {
msg := s.unitRestoreOutcomeMsg(stackName, res)
if src := s.tier2UnitSourceMsg(cov); src != "" {
msg += " " + src
}
s.backupMgr.EndRestoreOp(true, msg)
+25 -3
View File
@@ -293,15 +293,37 @@ func renderI18nCase(t *testing.T, s *Server, lang string, c i18nCase) string {
t.Helper()
var buf bytes.Buffer
data := c.data()
// The production choke point's language step (executeTemplate → addLanguageData), with a request that
// carries no ?lang= — so the switch and the translated title behave exactly as on a real page.
s.addLanguageData(data, httptest.NewRequest("GET", "/i18n-fixture", nil), lang)
r := httptest.NewRequest("GET", "/i18n-fixture", nil)
// TWO production paths, and the fixture has to be rendered through the RIGHT one or it is a
// picture of something no visitor sees (v0.254.0, R-557 release C).
//
// * a dashboard page goes through executeTemplate → addLanguageData: session CSRF, and its
// language globe posts to the HOUSEHOLD switch;
// * the pages outside the dashboard chrome go through executeTemplateLang: no CSRF, and their
// globe posts to /lang, the visitor's own display cookie.
//
// Rendering a shell through addLanguageData would bake a form the real page never serves — which
// is exactly what the first attempt at this release did, and what the diff caught.
if i18nDirectTemplates[c.tmpl] {
data["Lang"] = lang
addLangOptions(data, lang, langCookiePath, "/i18n-fixture", "")
} else {
s.addLanguageData(data, r, lang)
}
if err := s.templatesFor(lang).ExecuteTemplate(&buf, c.tmpl, data); err != nil {
t.Fatalf("%s [%s]: render: %v", c.name, lang, err)
}
return relativeAgeRe.ReplaceAllString(buf.String(), "# $1")
}
// i18nDirectTemplates are the templates rendered by executeTemplateLang — the pages a request with no
// household session meets. Kept beside the harness that has to branch on it; i18nDirectPages (in
// i18n_wiring_test.go) names the same set with one case and an English probe each.
var i18nDirectTemplates = map[string]bool{
"login": true, "claim": true, "recovery": true,
"launcher_shared": true, "launcher_share_password": true, "catchall": true,
}
func i18nTestServer(t *testing.T) *Server {
s := testServer(t)
s.loadTemplates()
+127
View File
@@ -193,6 +193,10 @@ func (s *Server) addLanguageData(data map[string]interface{}, r *http.Request, l
if r != nil {
data["LangSwitch"] = true
data["LangSwitchBack"] = r.URL.Path
// v0.254.0: the dashboard globe posts to the HOUSEHOLD switch — session CSRF, and it writes
// settings.json. It never writes the visitor cookie: a signed-in household's choice belongs in
// their settings, not in whichever browser they happen to be using.
addLangOptions(data, lang, "/settings/language", r.URL.Path, s.csrfField(r))
}
// The alert banners were stored by a background health cycle and put into the page data by
// baseData, which has no request and therefore no language. Re-rendered here, where the language
@@ -218,6 +222,38 @@ func (s *Server) addLanguageData(data map[string]interface{}, r *http.Request, l
}
}
// LangOption is one entry in the globe menu. Name is the language's OWN name and is never
// translated — a person looking for their language looks for the word they know.
type LangOption struct {
Code string
Name string
Current bool
}
// langNativeNames — a language's own name, in itself. Not in the bundle on purpose: a bundle entry
// would invite a translation, and „Magyar" translated into English is still „Magyar".
var langNativeNames = map[string]string{"hu": "Magyar", "en": "English"}
// addLangOptions puts everything the lang_globe partial needs into a page's data.
//
// `csrf` is template.HTML and may be empty: the dashboard posts to the household switch and needs a
// session CSRF field, the anonymous shells post to /lang and have no session to mint one from (the
// exemption and its reasoning are in CsrfProtect).
func addLangOptions(data map[string]interface{}, lang, action, back string, csrf template.HTML) {
opts := make([]LangOption, 0, len(i18n.Supported))
for _, code := range i18n.Supported {
name := langNativeNames[code]
if name == "" {
name = code
}
opts = append(opts, LangOption{Code: code, Name: name, Current: code == lang})
}
data["LangOptions"] = opts
data["LangAction"] = action
data["LangBack"] = back
data["LangCSRF"] = csrf
}
// languageSwitchHandler stores the household's language (POST /settings/language) and goes back to
// the page it came from. CSRF is enforced by the CsrfProtect middleware wrapping "/" (main.go), as for
// every other dashboard form; the switch form carries {{.CSRFField}}.
@@ -358,3 +394,94 @@ func stateLabelKey(state stacks.ContainerState) string {
return "func.state.unknown"
}
}
// ── The visitor's language, for a page with no household signed in (v0.254.0, R-557 release C) ──
//
// The sign-in, claim and recovery pages are met by someone who has not signed in. They have no
// setting to read, and they must not be able to write the household's: a box's sign-in page is
// reachable by anyone who can reach the box, and changing what the HOUSEHOLD reads from there would
// be an anonymous write to something they own. Changing what THEY THEMSELVES read is not, and that is
// the whole of what this cookie does.
//
// So: display-only, one of two values, their browser, never the household's setting. `langFor` reads
// it only when there is no session (server.go), so a signed-in household can never inherit a language
// a previous visitor picked in the same browser.
// langCookiePath is the anonymous switch's route.
const langCookiePath = "/lang"
// langCookieName is the visitor's display-language cookie.
const langCookieName = "felhom_lang"
// langCookieMaxAge — a year. A visitor who set it once should not have to set it again, and there is
// nothing here worth expiring.
const langCookieMaxAge = 365 * 24 * 60 * 60
// langCookieHandler serves POST /lang: the anonymous language switch.
//
// NO CSRF, deliberately and narrowly: the only thing a forged request can achieve is to change the
// language of the page the VICTIM'S OWN BROWSER shows them, which is neither a secret nor the
// household's. The handler cannot touch settings.json, cannot read anything, and cannot redirect off
// this box (see the `back` check). If it ever gains a second effect, it needs CSRF that day.
func (s *Server) langCookieHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
lang := r.FormValue("lang")
if !i18n.IsSupported(lang) {
// Refuse rather than guess: an unsupported value is a bug or a probe, and silently writing
// Hungarian would hide both.
http.Error(w, "unsupported language", http.StatusBadRequest)
return
}
http.SetCookie(w, &http.Cookie{
Name: langCookieName,
Value: lang,
Path: "/",
HttpOnly: true, // nothing on the page needs to read it; the server does
SameSite: http.SameSiteLaxMode,
Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
MaxAge: langCookieMaxAge,
})
http.Redirect(w, r, safeBackPath(r.FormValue("back")), http.StatusSeeOther)
}
// safeBackPath keeps an open redirect out of the one handler that takes a destination from an
// anonymous request.
//
// Only a same-origin PATH is allowed. `//evil.example` is rejected too: a browser reads a
// protocol-relative URL as another origin, so "starts with /" alone is not the test — which is the
// mistake this function exists to not make.
func safeBackPath(back string) string {
if back == "" || !strings.HasPrefix(back, "/") || strings.HasPrefix(back, "//") {
return "/"
}
if strings.Contains(back, "\\") || strings.ContainsAny(back, "\r\n") {
return "/"
}
return back
}
// ── Saved notes (v0.254.0, release C) ──────────────────────────────────────────────────────────
//
// A restore runs in a goroutine with no request and finishes minutes later; its outcome is SAVED and
// read on a page afterwards. There is no reader to ask, so the note is written in the BOX's language
// at the moment it is written — the operator's ruling (slice 2 §16, option 1).
//
// The consequence, stated rather than hidden: a household that switches language sees the note from
// the run before in the old language, until the next run rewrites it.
// boxLang is the language a SAVED note is written in.
func (s *Server) boxLang() string {
if s.settings == nil {
return i18n.Default
}
return s.settings.GetLanguage()
}
// note renders a saved note in the box's language.
func (s *Server) note(key string, args ...interface{}) string {
return util.Text(s.boxLang(), key, args...)
}
// noteErr renders an error into a saved note in the box's language: its bundle message when it
// carries one (release B), its own text otherwise.
func (s *Server) noteErr(err error) string { return util.ErrText(s.boxLang(), err) }
+4 -1
View File
@@ -303,7 +303,10 @@ func TestI18nDirectRenderPagesFollowLanguage(t *testing.T) {
t.Fatal(err)
}
var buf strings.Builder
if err := s.executeTemplateLang(&buf, httptest.NewRequest(http.MethodGet, "/x", nil), p.tmpl, c.data()); err != nil {
// The SAME request path the parity harness renders with. Since v0.254.0 the page carries a
// language globe whose `back` is the path the visitor is on, so a different path here would
// differ from the fixture in one attribute and say nothing about the language.
if err := s.executeTemplateLang(&buf, httptest.NewRequest(http.MethodGet, "/i18n-fixture", nil), p.tmpl, c.data()); err != nil {
t.Fatalf("%s [%s]: %v", p.tmpl, lang, err)
}
got := relativeAgeRe.ReplaceAllString(buf.String(), "# $1")
@@ -0,0 +1,375 @@
package web
import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
)
// Localisation slice 2 release C (R-557), scenarios S2–S4.
//
// The sign-in, claim and recovery pages are met by someone who has not signed in. They have no
// setting to read and must not be able to write the household's — a box's sign-in page is reachable
// by anyone who can reach the box. So their choice lives in their own browser, and the household's
// setting is untouched until the one moment an anonymous visitor BECOMES the household: a successful
// claim (§16).
func langReq(method, path string, cookies ...*http.Cookie) *http.Request {
r := httptest.NewRequest(method, path, nil)
for _, c := range cookies {
r.AddCookie(c)
}
return r
}
func langCookie(v string) *http.Cookie { return &http.Cookie{Name: langCookieName, Value: v} }
// newTestSession mints a real session on a Server built by testServer, whose session map the
// production constructor would have made. A REAL session (not a made-up cookie value) because
// langFor asks isValidSession, and a test that handed it an invalid one would be testing the
// no-session path while claiming to test the session path.
func newTestSession(s *Server) string {
s.sessionsMu.Lock()
if s.sessions == nil {
s.sessions = map[string]*session{}
}
s.sessionsMu.Unlock()
return s.createSession()
}
// TestLangForPrecedence — the order is fixed and each step exists for a different reader. A table,
// because the interesting failures are the CROSSINGS: a signed-in household inheriting a visitor's
// cookie, or a visitor's `?lang=` leaking into the setting.
//
// RED-PROOF (REPORT): drop the `!s.hasSession(r)` guard in langFor → the "session wins over cookie"
// rows fail, which is the row that protects the household.
func TestLangForPrecedence(t *testing.T) {
cases := []struct {
name string
query string
session bool
cookie string
saved string
want string
}{
{name: "nothing at all → Hungarian", saved: "hu", want: "hu"},
{name: "the household's saved setting", saved: "en", want: "en"},
{name: "?lang= overrides the setting (testing door)", query: "en", saved: "hu", want: "en"},
{name: "?lang= overrides a session too", query: "hu", session: true, saved: "en", want: "hu"},
{name: "?lang= with an unsupported value is ignored", query: "de", saved: "hu", want: "hu"},
{name: "no session → the visitor's cookie wins", cookie: "en", saved: "hu", want: "en"},
{name: "no session, unsupported cookie → the setting", cookie: "de", saved: "hu", want: "hu"},
{name: "no session, no cookie → the setting", saved: "en", want: "en"},
// The one that matters: a household that signed in must never read a language a previous
// visitor picked in the same browser.
{name: "SESSION → the household's setting, cookie NOT read", session: true, cookie: "en", saved: "hu", want: "hu"},
{name: "SESSION → the household's setting, the other way round", session: true, cookie: "hu", saved: "en", want: "en"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s := testServer(t)
if err := s.settings.SetLanguage(tc.saved); err != nil {
t.Fatal(err)
}
path := "/launcher"
if tc.query != "" {
path += "?lang=" + tc.query
}
var cookies []*http.Cookie
if tc.cookie != "" {
cookies = append(cookies, langCookie(tc.cookie))
}
if tc.session {
cookies = append(cookies, &http.Cookie{Name: sessionCookieName, Value: newTestSession(s)})
}
if got := s.langFor(langReq(http.MethodGet, path, cookies...)); got != tc.want {
t.Errorf("langFor = %q, want %q", got, tc.want)
}
// Whatever happened, reading a page never writes the household's setting.
if got := s.settings.GetLanguage(); got != tc.saved {
t.Errorf("the household's saved language changed to %q — reading a page must never write it", got)
}
})
}
}
// TestLangCookieHandler — POST /lang. The whole of what it may do, and the whole of what it must
// refuse.
func TestLangCookieHandler(t *testing.T) {
t.Run("a supported language sets the cookie and returns to the page", func(t *testing.T) {
s := testServer(t)
if err := s.settings.SetLanguage("hu"); err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPost, "/lang", strings.NewReader("lang=en&back=%2Flogin"))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
s.langCookieHandler(w, r)
if w.Code != http.StatusSeeOther {
t.Errorf("status %d, want 303", w.Code)
}
if loc := w.Header().Get("Location"); loc != "/login" {
t.Errorf("Location %q, want /login", loc)
}
var found *http.Cookie
for _, c := range w.Result().Cookies() {
if c.Name == langCookieName {
found = c
}
}
if found == nil {
t.Fatal("no felhom_lang cookie was set")
}
if found.Value != "en" || !found.HttpOnly || found.SameSite != http.SameSiteLaxMode || found.Path != "/" {
t.Errorf("cookie attributes wrong: %+v", found)
}
// THE POINT: the household's setting is untouched by an anonymous request.
if got := s.settings.GetLanguage(); got != "hu" {
t.Errorf("an anonymous POST changed the household's language to %q", got)
}
})
t.Run("an unsupported language is refused and sets nothing", func(t *testing.T) {
s := testServer(t)
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPost, "/lang", strings.NewReader("lang=de&back=%2Flogin"))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
s.langCookieHandler(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("status %d, want 400 — silently writing Hungarian would hide the bug", w.Code)
}
if len(w.Result().Cookies()) != 0 {
t.Errorf("a cookie was set for an unsupported language: %v", w.Result().Cookies())
}
})
t.Run("back may only be a same-origin path", func(t *testing.T) {
for _, tc := range []struct{ back, want string }{
{"/login", "/login"},
{"/settings/security", "/settings/security"},
{"", "/"},
{"https://evil.example/x", "/"},
{"//evil.example/x", "/"}, // protocol-relative: a browser reads this as another origin
{"http://evil.example", "/"},
{"/x\r\nSet-Cookie: a=b", "/"}, // header injection through the redirect
{`/x\..\y`, "/"},
} {
s := testServer(t)
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPost, "/lang", strings.NewReader("lang=en&back="+urlQueryEscape(tc.back)))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
s.langCookieHandler(w, r)
if loc := w.Header().Get("Location"); loc != tc.want {
t.Errorf("back=%q → Location %q, want %q", tc.back, loc, tc.want)
}
}
})
}
// TestGlobeOnAnonymousShells — the three pages a visitor meets carry the globe, it posts to /lang with
// NO CSRF field, and `<html lang>` follows the visitor's cookie rather than the household's setting.
func TestGlobeOnAnonymousShells(t *testing.T) {
cases := map[string]i18nCase{}
for _, c := range i18nCases() {
cases[c.name] = c
}
for _, p := range i18nDirectPages {
if p.tmpl == "launcher_shared" || p.tmpl == "launcher_share_password" || p.tmpl == "catchall" {
continue // deliberately NO globe — a share visitor is a stranger (R-577), and the
// not-found page has nothing to do
}
c := cases[p.caseName]
t.Run(p.tmpl, func(t *testing.T) {
s := i18nTestServer(t)
if err := s.settings.SetLanguage("hu"); err != nil {
t.Fatal(err)
}
var b strings.Builder
r := langReq(http.MethodGet, "/i18n-fixture", langCookie("en"))
if err := s.executeTemplateLang(&b, r, p.tmpl, c.data()); err != nil {
t.Fatal(err)
}
got := b.String()
if !strings.Contains(got, `class="shell-lang"`) {
t.Error("the page carries no language globe")
}
if !strings.Contains(got, `action="/lang"`) {
t.Error("the globe does not post to /lang — a visitor must not write the household's setting")
}
// Scoped to the GLOBE block. The claim page carries its own pre-auth CSRF field for the
// claim form itself, so a page-wide search would convict the wrong form — and did, on the
// first run.
gi := strings.Index(got, `class="shell-lang"`)
ge := strings.Index(got[gi:], "</details></div>")
if gi < 0 || ge < 0 {
t.Fatal("could not isolate the globe block")
}
globe := got[gi : gi+ge]
if strings.Contains(globe, `name="_csrf"`) {
t.Error("the anonymous globe carries a CSRF field; there is no session to mint one from")
}
if !strings.Contains(globe, `action="/lang"`) {
t.Error("the globe block does not post to /lang")
}
// The cookie decided the language, not the household's `hu`.
if !strings.Contains(got, `<html lang="en"`) {
t.Error("the visitor's cookie did not decide the language")
}
if got := s.settings.GetLanguage(); got != "hu" {
t.Errorf("rendering the page changed the household's language to %q", got)
}
})
}
}
// TestGuestSharePagesHaveNoGlobe — the pages a STRANGER meets. Their language is not the household's
// to lend and not theirs to keep here; that is R-577, an operator decision about what the share
// feature promises. Pinned so it stays a decision rather than an oversight.
func TestGuestSharePagesHaveNoGlobe(t *testing.T) {
cases := map[string]i18nCase{}
for _, c := range i18nCases() {
cases[c.name] = c
}
for _, name := range []string{"launcher_shared_apps", "launcher_share_password", "catchall_unknown"} {
c, ok := cases[name]
if !ok {
t.Fatalf("no parity case %q — this test no longer covers what it names", name)
}
s := i18nTestServer(t)
var b strings.Builder
if err := s.executeTemplateLang(&b, langReq(http.MethodGet, "/x"), c.tmpl, c.data()); err != nil {
t.Fatal(err)
}
if strings.Contains(b.String(), "lang-globe") {
t.Errorf("%s carries a language globe — R-577 is the decision that would put one there", name)
}
}
}
// TestFooterDoesNotWrap — the sidebar footer holds version, globe and sign-out in ONE flex row, in
// that order. The old switch was two text links that wrapped at the sidebar's width; the globe is one
// icon, and this pins that the three items stay siblings of a single flex container rather than
// drifting into a second line's worth of markup.
//
// It reads the MARKUP, which is what a test without a browser can read; the visual half is the
// operator's click-through.
func TestFooterDoesNotWrap(t *testing.T) {
s := i18nTestServer(t)
cases := map[string]i18nCase{}
for _, c := range i18nCases() {
cases[c.name] = c
}
c := cases["launcher_full"]
got := renderI18nCase(t, s, "hu", c)
i := strings.Index(got, `class="sidebar-footer"`)
if i < 0 {
t.Fatal("no sidebar footer in the rendered page")
}
foot := got[i:]
if j := strings.Index(foot, "</div>\n </div>"); j > 0 {
foot = foot[:j]
}
for _, want := range []string{`class="version"`, `class="lang-globe"`, `class="logout-link"`} {
if !strings.Contains(foot, want) {
t.Errorf("the footer is missing %s", want)
}
}
// Order: version, then globe, then sign-out.
v, g, l := strings.Index(foot, `class="version"`), strings.Index(foot, `class="lang-globe"`), strings.Index(foot, `class="logout-link"`)
if !(v < g && g < l) {
t.Errorf("footer order is version=%d globe=%d logout=%d, want version < globe < logout", v, g, l)
}
// And the OLD two-link switch is gone — otherwise both could be present and the test would pass.
if strings.Contains(got, "lang-switch-btn") {
t.Error("the old two-text-link switch is still rendered beside the globe")
}
}
// TestClaimCarriesLanguage — §16. A visitor who switched the claim page to English and then claimed
// the box gets an English dashboard. Only on SUCCESS, and only here: this is the one moment an
// anonymous visitor becomes the household.
func TestClaimCarriesLanguage(t *testing.T) {
t.Run("a successful claim carries the cookie into the setting", func(t *testing.T) {
s := testServer(t)
if err := s.settings.SetLanguage("hu"); err != nil {
t.Fatal(err)
}
// The carry is one block in handleClaimSubmit, after every gate. Exercised here through the
// same two calls it makes, because a full claim needs a live code the fixture cannot mint.
r := langReq(http.MethodPost, "/claim", langCookie("en"))
if c, err := r.Cookie(langCookieName); err == nil && i18n.IsSupported(c.Value) {
if err := s.settings.SetLanguage(c.Value); err != nil {
t.Fatal(err)
}
}
if got := s.settings.GetLanguage(); got != "en" {
t.Errorf("the household's language is %q, want en", got)
}
})
t.Run("an unsupported cookie is ignored", func(t *testing.T) {
s := testServer(t)
if err := s.settings.SetLanguage("hu"); err != nil {
t.Fatal(err)
}
r := langReq(http.MethodPost, "/claim", langCookie("de"))
if c, err := r.Cookie(langCookieName); err == nil && i18n.IsSupported(c.Value) {
t.Fatal("an unsupported cookie was accepted")
}
if got := s.settings.GetLanguage(); got != "hu" {
t.Errorf("the household's language changed to %q", got)
}
})
// The SOURCE half: the carry is inside handleClaimSubmit, AFTER the failure paths return, so a
// failed claim cannot reach it. A behaviour test cannot show that without a live claim code; the
// position in the function is what makes it true, and the position is what this reads.
t.Run("the carry sits after every refusal", func(t *testing.T) {
src := mustReadSource(t, "claim.go")
fn := src[strings.Index(src, "func (s *Server) handleClaimSubmit"):]
carry := strings.Index(fn, "claim: household language set to")
clear := strings.Index(fn, "s.claimClearFailures(ip)")
if carry < 0 || clear < 0 {
t.Fatal("the claim carry or the success marker moved — this test no longer reads what it names")
}
if carry < clear {
t.Error("the language carry runs BEFORE the failures are cleared — a failed claim could reach it")
}
})
}
// mustReadSource reads a file in this package, for the few tests whose claim is about WHERE code sits
// rather than what it returns.
func mustReadSource(t *testing.T, name string) string {
t.Helper()
b, err := os.ReadFile(name)
if err != nil {
t.Fatal(err)
}
return string(b)
}
// noteServer builds a Server whose saved-note helpers work — a note is written in the BOX's language
// (release C), so a Server with no settings would render every note as its key. Hungarian, because
// these tests assert the sentence a Hungarian household reads: the parity half.
func noteServer(t *testing.T) *Server {
t.Helper()
s := i18nTestServer(t)
if err := s.settings.SetLanguage("hu"); err != nil {
t.Fatal(err)
}
return s
}
// noteHU is the Hungarian text of a saved-note key, for a test that used to compare against a Go
// constant. Same claim, now measured against the bundle instead of restated beside it.
func noteHU(t *testing.T, key string) string {
t.Helper()
return noteServer(t).note(key)
}
+26 -29
View File
@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"path/filepath"
@@ -14,6 +13,7 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
// Off-box (NAS) restic-SFTP backup handlers (Part B). Form POSTs that redirect to /backups with a flash.
@@ -377,7 +377,7 @@ func (s *Server) offboxRestoreHandler(w http.ResponseWriter, r *http.Request) {
defer cancel()
if err := s.backupMgr.RestoreOffboxScratch(ctx, app, full); err != nil {
s.logger.Printf("[ERROR] [web] off-box restore %s (full=%v, async): %v", app, full, err)
s.backupMgr.EndRestoreOp(false, "A visszaállítás sikertelen: "+err.Error())
s.backupMgr.EndRestoreOp(false, s.note("note.restore.failed", s.noteErr(err)))
return
}
s.logger.Printf("[INFO] [web] off-box restore %s completed (full=%v, async)", app, full)
@@ -386,7 +386,7 @@ func (s *Server) offboxRestoreHandler(w http.ResponseWriter, r *http.Request) {
// customer had no way to look at what they had just asked for. Resolve the real path and say
// it. Fall back to the vague wording only if the path can no longer be resolved.
where := s.backupMgr.OffsiteRestoreScratchPath(app)
s.backupMgr.EndRestoreOp(true, restoreScratchOutcomeMsg(app, where, full))
s.backupMgr.EndRestoreOp(true, restoreScratchOutcomeMsg(app, where, full, s.boxLang()))
}()
offboxRedirectTo(w, r, restoreWizardPath(app), "flash.offbox.restore_started", false)
}
@@ -406,18 +406,15 @@ func (s *Server) offboxRestoreHandler(w http.ResponseWriter, r *http.Request) {
// So the unit case states three things in order: what came back, what did NOT, and the next step
// that gets it. The full case says the files came with it, because otherwise the absence of the
// warning would be the only difference and an absence is not a statement.
func restoreScratchOutcomeMsg(app, where string, full bool) string {
at := " ellenőrző mappába"
func restoreScratchOutcomeMsg(app, where string, full bool, lang string) string {
at := util.Text(lang, "note.restore.at_scratch")
if where != "" {
at = " ellenőrző mappába: " + where
at = util.Text(lang, "note.restore.at_scratch_named", where)
}
if full {
return "A(z) " + app + " teljes mentése visszaállítva" + at +
" — a saját fájljaiddal együtt. A meglévő adatok változatlanok."
return util.Text(lang, "note.restore.scratch_full", app, at)
}
return "A(z) " + app + " beállításai és adatbázisa visszaállítva" + at +
". A saját fájljaid (dokumentumok, képek, feltöltések) NEM kerültek vissza — ez az ellenőrző visszaállítás csak az alkalmazás beállításait és adatbázisát hozza vissza. " +
"Ha a fájljaidra van szükséged, indítsd el a „Teljes visszaállítás előkészítése” lépést ezen az oldalon. A meglévő adatok változatlanok."
return util.Text(lang, "note.restore.scratch_state", app, at)
}
// offboxReconstituteHandler is the TRUE offsite restore (R-43, v0.148.0): files overwritten to the
@@ -455,7 +452,7 @@ func (s *Server) offboxReconstituteHandler(w http.ResponseWriter, r *http.Reques
// this controls the operation.
if !s.backupMgr.OffboxFullScratchReady(app) {
s.logger.Printf("[WARN] [web] off-box reconstitute refused for %s: the restore scratch carries no completion marker", app)
offboxRedirectTo(w, r, restoreWizardPath(app), offsiteScratchIncompleteMsg, true)
offboxRedirectTo(w, r, restoreWizardPath(app), offsiteScratchIncompleteKey, true)
return
}
// R-351: a SEPARATE field from `confirm`. The restore's own confirm answers "overwrite my live
@@ -469,12 +466,12 @@ func (s *Server) offboxReconstituteHandler(w http.ResponseWriter, r *http.Reques
res, err := s.backupMgr.ReconstituteFromOffsite(ctx, app, ackPlacement)
if err != nil {
s.logger.Printf("[ERROR] [web] off-box reconstitute %s (async): %v", app, err)
s.backupMgr.EndRestoreOp(false, "A teljes visszaállítás sikertelen: "+err.Error())
s.backupMgr.EndRestoreOp(false, s.note("note.restore.full_failed", s.noteErr(err)))
return
}
s.logger.Printf("[INFO] [web] off-box reconstitute %s completed (async): files=%d dbs=%d snapshot=%s",
app, res.FilesPlaced, res.DBsReplayed, res.SnapshotID)
s.backupMgr.EndRestoreOp(true, reconstituteOutcomeMsg(app, res))
s.backupMgr.EndRestoreOp(true, reconstituteOutcomeMsg(app, res, s.boxLang()))
}()
offboxRedirectTo(w, r, restoreWizardPath(app), "flash.offbox.full_restore_started", false)
}
@@ -483,16 +480,16 @@ func (s *Server) offboxReconstituteHandler(w http.ResponseWriter, r *http.Reques
// attempted over a scratch that carries no completion marker. Named because two handlers assert it and
// two tests assert it verbatim. It names the action that works — Lane 1 is customer-owned, so a refusal
// that leaves the customer with no next step is not a refusal, it is a dead end.
const offsiteScratchIncompleteMsg = "A visszaállítási másolat nem teljes — a legutóbbi letöltés nem fejeződött be. Indítsd újra a teljes visszaállítás előkészítését."
const offsiteScratchIncompleteKey = "note.restore.scratch_incomplete"
// reconstituteOutcomeMsg builds the OUTCOME flash for a completed reconstitution. Pure, so the
// wording is unit-testable — this string is the customer's only evidence that the operation did
// what its label promised, and the zero-file and no-database cases must each read truthfully rather
// than borrowing the confident sentence that belongs to the full case.
func reconstituteOutcomeMsg(app string, res backup.OffsiteReconstituteResult) string {
func reconstituteOutcomeMsg(app string, res backup.OffsiteReconstituteResult, lang string) string {
when := ""
if !res.DumpsAt.IsZero() {
when = " (mentés: " + res.DumpsAt.In(getTimezone()).Format("2006-01-02 15:04") + ")"
when = util.Text(lang, "note.restore.dumps_at", res.DumpsAt.In(getTimezone()).Format("2006-01-02 15:04"))
}
// R-354 — WHAT ACTUALLY CAME BACK, NAMED. The volume leg is stated whenever it returned anything,
// because a restore that replayed an app's entire dataset and mentioned only its file count is
@@ -500,14 +497,14 @@ func reconstituteOutcomeMsg(app string, res backup.OffsiteReconstituteResult) st
// „5 fájl visszaállítva" over a run that had dropped a 1 422 848-byte volume archive. Every clause
// here is conditional on having done the thing, so a snapshot with no volumes produces the exact
// sentence it produced before (pinned by TestReconstituteOutcome_NoVolumesWordingUnchanged).
what := fmt.Sprintf("%d fájl", res.FilesPlaced)
what := util.Text(lang, "note.restore.files_count", res.FilesPlaced)
if res.VolumesReplayed > 0 {
what += fmt.Sprintf(" és %d adatkötet", res.VolumesReplayed)
what += util.Text(lang, "note.restore.and_volumes", res.VolumesReplayed)
}
if res.DBsReplayed > 0 {
what += " és az adatbázis"
what += util.Text(lang, "note.restore.and_database")
}
msg := fmt.Sprintf("A(z) %s: %s visszaállítva%s — az alkalmazás újraindult.", app, what, when)
msg := util.Text(lang, "note.restore.reconstituted", app, what, when)
if res.DBsReplayed == 0 {
// A no-database app: saying "és az adatbázis" here would be a lie, and this is precisely the
@@ -521,9 +518,9 @@ func reconstituteOutcomeMsg(app string, res backup.OffsiteReconstituteResult) st
// path only when a live database for this app was found AND successfully dumped, so a non-empty
// value proves the app HAS one. Same counter, two different facts, and now two sentences.
if res.SafetyDump != "" {
return msg + fmt.Sprintf(" FIGYELEM: ennek az alkalmazásnak VAN adatbázisa, de a mentés nem tartalmazott adatbázis-mentést, ezért az adatbázis NEM állt vissza. A visszaállítás előtti állapot mentése megvan: %s", filepath.Base(res.SafetyDump))
return msg + util.Text(lang, "note.restore.no_db_in_backup", filepath.Base(res.SafetyDump))
}
return msg + " Ennek az alkalmazásnak nincs adatbázisa."
return msg + util.Text(lang, "note.restore.no_db_at_all")
}
return msg
}
@@ -599,7 +596,7 @@ func (s *Server) offboxPlaceHandler(w http.ResponseWriter, r *http.Request) {
// and before v0.226.0 a POST over a failed download was accepted and reported success.
if !s.backupMgr.OffboxFullScratchReady(app) {
s.logger.Printf("[WARN] [web] off-box place refused for %s: the restore scratch carries no completion marker", app)
offboxRedirectTo(w, r, restoreWizardPath(app), offsiteScratchIncompleteMsg, true)
offboxRedirectTo(w, r, restoreWizardPath(app), offsiteScratchIncompleteKey, true)
return
}
s.backupMgr.BeginRestoreOp("offbox-place", app)
@@ -608,11 +605,11 @@ func (s *Server) offboxPlaceHandler(w http.ResponseWriter, r *http.Request) {
defer cancel()
if err := s.backupMgr.PlaceOffsiteRestore(ctx, app); err != nil {
s.logger.Printf("[ERROR] [web] off-box place %s (async): %v", app, err)
s.backupMgr.EndRestoreOp(false, "A helyreállítás sikertelen: "+err.Error())
s.backupMgr.EndRestoreOp(false, s.note("note.restore.recover_failed", s.noteErr(err)))
return
}
s.logger.Printf("[INFO] [web] off-box place %s completed (async)", app)
s.backupMgr.EndRestoreOp(true, "A(z) "+app+" hiányzó fájljai helyreállítva az élő adatok közé.")
s.backupMgr.EndRestoreOp(true, s.note("note.restore.recovered_files", app))
}()
offboxRedirectTo(w, r, restoreWizardPath(app), "flash.offbox.recover_started", false)
}
@@ -641,11 +638,11 @@ func (s *Server) sharesRestoreHandler(w http.ResponseWriter, r *http.Request) {
defer cancel()
if err := s.backupMgr.RestoreSharesScratch(ctx); err != nil {
s.logger.Printf("[ERROR] [web] shares restore (async): %v", err)
s.backupMgr.EndRestoreOp(false, "A megosztások visszaállítása sikertelen: "+err.Error())
s.backupMgr.EndRestoreOp(false, s.note("note.restore.shares_failed", s.noteErr(err)))
return
}
s.logger.Printf("[INFO] [web] shares restore completed (async)")
s.backupMgr.EndRestoreOp(true, "A megosztások visszaállítása elkészült — most helyreállíthatod az élő adatok közé.")
s.backupMgr.EndRestoreOp(true, s.note("note.restore.shares_prepared"))
}()
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.shares_restore_started", false)
}
@@ -668,7 +665,7 @@ func (s *Server) sharesPlaceHandler(w http.ResponseWriter, r *http.Request) {
res, err := s.backupMgr.PlaceSharesRestore(ctx)
if err != nil {
s.logger.Printf("[ERROR] [web] shares place (async): %v", err)
s.backupMgr.EndRestoreOp(false, "A megosztások helyreállítása sikertelen: "+err.Error())
s.backupMgr.EndRestoreOp(false, s.note("note.restore.shares_recover_failed", s.noteErr(err)))
return
}
s.logger.Printf("[INFO] [web] shares place completed (async): %d file(s), %d definition(s)",
@@ -32,7 +32,7 @@ import (
// r103Row is a Tier-2-configured row with a successful copy — the shape the template needs before it
// will render the actions block at all.
func r103Row(unitRestorable bool, date string, proven bool) AppBackupRow {
func r103Row(t *testing.T, unitRestorable bool, date string, proven bool) AppBackupRow {
row := AppBackupRow{
StackName: "docmost", DisplayName: "Docmost",
Tier2Configured: true, Tier2Dest: "flash", Tier2Schedule: "Naponta",
@@ -42,7 +42,7 @@ func r103Row(unitRestorable bool, date string, proven bool) AppBackupRow {
}
if unitRestorable {
row.Tier2CopyDate, row.Tier2CopyDateProven = date, proven
row.Tier2UnitConfirm = tier2UnitConfirmMsg(date, proven)
row.Tier2UnitConfirm = noteServer(t).tier2UnitConfirmMsg(date, proven)
}
return row
}
@@ -50,13 +50,13 @@ func r103Row(unitRestorable bool, date string, proven bool) AppBackupRow {
// D1 — TestR103_UnitActionOfferedWhenTheMirrorExists.
func TestR103_UnitActionOfferedWhenTheMirrorExists(t *testing.T) {
html := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{
r103Row(true, "2026-08-25T03:30:00Z", true),
r103Row(t, true, "2026-08-25T03:30:00Z", true),
}))
if !strings.Contains(html, `action="/backup/tier2/unit-restore"`) {
t.Error("the unit-restore form is not on the page — the refusal is still a dead end")
}
if !strings.Contains(html, tier2UnitActionLabel) {
t.Errorf("the action is not labelled %q", tier2UnitActionLabel)
if !strings.Contains(html, noteHU(t, tier2UnitActionLabelKey)) {
t.Errorf("the action is not labelled %q", noteHU(t, tier2UnitActionLabelKey))
}
// The additive action must still be there, separately. Merging them is forbidden: they are
// different promises (one adds, one overwrites).
@@ -74,12 +74,12 @@ func TestR103_UnitActionOfferedWhenTheMirrorExists(t *testing.T) {
// button, because it is a promise withdrawn at the moment of use.
func TestR103_UnitActionAbsentWhenItDoesNot(t *testing.T) {
html := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{
r103Row(false, "2026-08-25T03:30:00Z", true),
r103Row(t, false, "2026-08-25T03:30:00Z", true),
}))
if strings.Contains(html, "/backup/tier2/unit-restore") {
t.Error("the unit action was offered for a copy with no openable unit")
}
if strings.Contains(html, tier2UnitActionLabel) {
if strings.Contains(html, noteHU(t, tier2UnitActionLabelKey)) {
t.Error("the unit action's label leaked onto a row that must not offer it")
}
// NEGATIVE CONTROL for D1's assertions: the row itself did render, so D1's positives were not
@@ -93,18 +93,18 @@ func TestR103_UnitActionAbsentWhenItDoesNot(t *testing.T) {
// requires: a destructive operation reached from a non-destructive surface says so, and says how it
// differs from the action beside it.
func TestR103_ConfirmStatesTheOverwrite(t *testing.T) {
confirm := tier2UnitConfirmMsg("2026-08-25T03:30:00Z", true)
confirm := noteServer(t).tier2UnitConfirmMsg("2026-08-25T03:30:00Z", true)
if !strings.Contains(confirm, tier2UnitConfirmBase) {
if !strings.Contains(confirm, noteHU(t, tier2UnitConfirmBaseKey)) {
t.Error("the confirm does not state that the operation OVERWRITES live data")
}
if !strings.Contains(confirm, tier2UnitConfirmContrast) {
if !strings.Contains(confirm, noteHU(t, tier2UnitConfirmContrastKey)) {
t.Error("the confirm does not state how it differs from the additive restore beside it")
}
// NEGATIVE CONTROL: the additive restore's own confirm must NOT have acquired this language.
// Without it, a test that passed because both buttons warn about overwriting would look green.
html := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{
r103Row(true, "2026-08-25T03:30:00Z", true),
r103Row(t, true, "2026-08-25T03:30:00Z", true),
}))
fileConfirmStart := strings.Index(html, "Visszaállítja a hiányzó fájlokat")
if fileConfirmStart < 0 {
@@ -136,11 +136,11 @@ func TestR103_ConfirmNamesTheCopyDate(t *testing.T) {
t.Fatal("the stamp did not render as a local date — the assertion below would be vacuous")
}
proven := tier2UnitConfirmMsg(stamp, true)
proven := noteServer(t).tier2UnitConfirmMsg(stamp, true)
if !strings.Contains(proven, rendered) {
t.Errorf("the confirm does not name the copy's date: %q", proven)
}
unproven := tier2UnitConfirmMsg(stamp, false)
unproven := noteServer(t).tier2UnitConfirmMsg(stamp, false)
if !strings.Contains(unproven, rendered) {
t.Errorf("the unproven confirm does not name the date: %q", unproven)
}
@@ -148,13 +148,13 @@ func TestR103_ConfirmNamesTheCopyDate(t *testing.T) {
t.Error("a proven copy and a bare attempt got the SAME sentence — R-101 exactly")
}
// A copy with no recorded date still gets the warning; it just cannot name one.
none := tier2UnitConfirmMsg("", false)
if !strings.Contains(none, tier2UnitConfirmBase) || !strings.Contains(none, tier2UnitConfirmContrast) {
none := noteServer(t).tier2UnitConfirmMsg("", false)
if !strings.Contains(none, noteHU(t, tier2UnitConfirmBaseKey)) || !strings.Contains(none, noteHU(t, tier2UnitConfirmContrastKey)) {
t.Errorf("a dateless copy lost its confirm entirely: %q", none)
}
// And it is on the page, not merely constructible.
html := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{r103Row(true, stamp, true)}))
html := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{r103Row(t, true, stamp, true)}))
if !strings.Contains(html, rendered) {
t.Errorf("the copy's date %q is not in the rendered row", rendered)
}
@@ -163,23 +163,25 @@ func TestR103_ConfirmNamesTheCopyDate(t *testing.T) {
// TestR103_AvailableMsgNamesTheButtonByItsLabel — the refusal that now points AT the new action must
// name it by the label the button actually carries, or it points at nothing.
func TestR103_AvailableMsgNamesTheButtonByItsLabel(t *testing.T) {
if !strings.Contains(tier2UnitAvailableMsg, tier2UnitActionLabel) {
t.Errorf("tier2UnitAvailableMsg does not name %q", tier2UnitActionLabel)
if !strings.Contains(noteHU(t, tier2UnitAvailableKey), noteHU(t, tier2UnitActionLabelKey)) {
t.Errorf("noteHU(t, tier2UnitAvailableKey) does not name %q", noteHU(t, tier2UnitActionLabelKey))
}
// It must NOT send anyone to another page any more; that is the R-103 defect it replaces.
if strings.Contains(tier2UnitAvailableMsg, "oldalon") {
if strings.Contains(noteHU(t, tier2UnitAvailableKey), "oldalon") {
t.Error("the message still routes the customer to another page for a copy restorable here")
}
// The surviving no-coverage message still names the route that works.
if !strings.Contains(tier2NoCoverageMsg, "oldalon") {
t.Error("tier2NoCoverageMsg no longer names any route — Scenario G requires one")
if !strings.Contains(noteHU(t, tier2NoCoverageKey), "oldalon") {
t.Error("noteHU(t, tier2NoCoverageKey) no longer names any route — Scenario G requires one")
}
// C3 — tier2UnitNotCoveredMsg is NOT deleted: it is still appended where the FILE restore ran.
if tier2UnitNotCoveredMsg == "" {
t.Fatal("tier2UnitNotCoveredMsg was deleted")
// C3 — the not-covered sentence is NOT deleted: it is still appended where the FILE restore ran.
// Since v0.254.0 it is a SAVED note written in the box's language, so the key is what the source
// names and the bundle is what carries the sentence; both halves are checked.
if noteHU(t, tier2UnitNotCoveredKey) == "" {
t.Fatal("the not-covered sentence was deleted from the bundle")
}
if !strings.Contains(readSourceFile(t, "handlers.go"), `msg += " " + tier2UnitNotCoveredMsg`) {
t.Error("tier2UnitNotCoveredMsg is no longer appended by the file-restore handler")
if !strings.Contains(readSourceFile(t, "handlers.go"), `msg += " " + s.note(tier2UnitNotCoveredKey)`) {
t.Error("the not-covered sentence is no longer appended by the file-restore handler")
}
}
@@ -345,8 +347,8 @@ func TestR103_HandlerPublishesTheOutcome(t *testing.T) {
}
// The sentence is yesterday's honest outcome PLUS the clause naming which copy overwrote the live
// data (Scenario E). Asserted as an exact composition so neither half can silently vanish.
wantOutcome := unitRestoreOutcomeMsg("app", backup.UnitRestoreResult{})
wantSource := tier2UnitSourceMsg(backup.Tier2Coverage{CopyLastSuccess: r103CopyStamp})
wantOutcome := noteServer(t).unitRestoreOutcomeMsg("app", backup.UnitRestoreResult{})
wantSource := noteServer(t).tier2UnitSourceMsg(backup.Tier2Coverage{CopyLastSuccess: r103CopyStamp})
if wantSource == "" {
t.Fatal("the fixture recorded no copy date — the assertion below would be vacuous")
}
@@ -369,8 +371,10 @@ func TestR103_UnitRestoreRefusesAnUnopenableMirrorWithoutStopping(t *testing.T)
if !strings.Contains(loc, "flash_error") {
t.Fatalf("the restore was accepted over an unopenable mirror: %q", loc)
}
if !strings.Contains(loc, url.QueryEscape(tier2NoCoverageMsg)) {
t.Errorf("refusal flash = %q, want tier2NoCoverageMsg", loc)
// The flash carries a KEY since v0.252.0; the sentence is what this test is about, so it is
// resolved the way the page resolves it.
if got := flashSentence(t, loc); got != noteHU(t, tier2NoCoverageKey) {
t.Errorf("refusal flash = %q, want the no-coverage sentence", got)
}
if st := m.RestoreStatus(); st.Running || st.Last != nil {
t.Errorf("an operation was begun despite the refusal: %+v", st)
@@ -404,10 +408,10 @@ func TestR103_FileRestoreRefusalPointsAtTheActionThatWorks(t *testing.T) {
s, _ := newR103Server(t, true)
rec := postTier2Restore(t, s, "app")
loc := rec.Header().Get("Location")
if !strings.Contains(loc, url.QueryEscape(tier2UnitAvailableMsg)) {
t.Errorf("refusal flash = %q, want tier2UnitAvailableMsg", loc)
if !strings.Contains(loc, url.QueryEscape(noteHU(t, tier2UnitAvailableKey))) {
t.Errorf("refusal flash = %q, want noteHU(t, tier2UnitAvailableKey)", loc)
}
if strings.Contains(loc, url.QueryEscape(tier2NoCoverageMsg)) {
if strings.Contains(loc, url.QueryEscape(noteHU(t, tier2NoCoverageKey))) {
t.Error("the old dead-end message is still shown for a copy restorable here")
}
}
@@ -31,7 +31,7 @@ import (
// whether the app has data (R-361 destroyed apps' canonical .sql files for four months).
func TestUnitRestoreOutcome_VolumesAndDatabaseNamed(t *testing.T) {
msg := unitRestoreOutcomeMsg("kimai", backup.UnitRestoreResult{
msg := noteServer(t).unitRestoreOutcomeMsg("kimai", backup.UnitRestoreResult{
VolumesReplayed: 2, DBsReplayed: 1, ManifestVolumes: 2, ManifestDBs: 1,
})
for _, want := range []string{"2 adatkötet", "az adatbázis"} {
@@ -48,7 +48,7 @@ func TestUnitRestoreOutcome_VolumesAndDatabaseNamed(t *testing.T) {
}
func TestUnitRestoreOutcome_BackupHeldOnlySettings(t *testing.T) {
msg := unitRestoreOutcomeMsg("opengist", backup.UnitRestoreResult{})
msg := noteServer(t).unitRestoreOutcomeMsg("opengist", backup.UnitRestoreResult{})
for _, want := range []string{"csak a beállításokat tartalmazta", "NEM álltak vissza"} {
if !strings.Contains(msg, want) {
t.Errorf("a restore that returned no data must say so plainly; missing %q in %q", want, msg)
@@ -64,7 +64,7 @@ func TestUnitRestoreOutcome_BackupHeldOnlySettings(t *testing.T) {
}
func TestUnitRestoreOutcome_ManifestListedDataThatDidNotReturn(t *testing.T) {
msg := unitRestoreOutcomeMsg("paperless-ngx", backup.UnitRestoreResult{
msg := noteServer(t).unitRestoreOutcomeMsg("paperless-ngx", backup.UnitRestoreResult{
VolumesReplayed: 0, DBsReplayed: 0, ManifestVolumes: 2, ManifestDBs: 1,
})
for _, want := range []string{"2 adatkötetet", "1 adatbázis-mentést", "változatlanok maradtak"} {
@@ -79,7 +79,7 @@ func TestUnitRestoreOutcome_ManifestListedDataThatDidNotReturn(t *testing.T) {
}
func TestUnitRestoreOutcome_DatabaseOnly(t *testing.T) {
msg := unitRestoreOutcomeMsg("bookstack", backup.UnitRestoreResult{
msg := noteServer(t).unitRestoreOutcomeMsg("bookstack", backup.UnitRestoreResult{
VolumesReplayed: 0, DBsReplayed: 1, ManifestVolumes: 0, ManifestDBs: 1,
})
if !strings.Contains(msg, "az adatbázis visszaállítva") {
@@ -199,7 +199,7 @@ func TestR353_HandlerPublishesTheOutcome(t *testing.T) {
// entire dataset. **An unknown drawn as a zero is the R-88 failure direction**, and it is exactly what
// this whole change exists to remove — so it must not be re-introduced by the fix itself.
func TestUnitRestoreOutcome_NoUnitFallbackSaysUnknownNotEmpty(t *testing.T) {
msg := unitRestoreOutcomeMsg("legacyapp", backup.UnitRestoreResult{CountsUnknown: true})
msg := noteServer(t).unitRestoreOutcomeMsg("legacyapp", backup.UnitRestoreResult{CountsUnknown: true})
if strings.Contains(msg, "csak a beállításokat tartalmazta") {
t.Fatal("FALSE CLAIM: told the customer the backup held no data when the counts were never " +
@@ -17,7 +17,7 @@ import (
func TestReconstituteOutcome_NoDatabaseOnlyWhenThereIsNone(t *testing.T) {
msg := reconstituteOutcomeMsg("opengist", backup.OffsiteReconstituteResult{
FilesPlaced: 3, DBsReplayed: 0, SafetyDump: "",
})
}, "hu")
if !strings.Contains(msg, "nincs adatbázisa") {
t.Errorf("an app with genuinely no database should still say so; got %q", msg)
}
@@ -29,7 +29,7 @@ func TestReconstituteOutcome_DatabaseExistsButWasNotRestored(t *testing.T) {
msg := reconstituteOutcomeMsg("paperless-ngx", backup.OffsiteReconstituteResult{
FilesPlaced: 0, DBsReplayed: 0,
SafetyDump: "/mnt/x/db-dumps/pre-restore-20260822T060000Z-paperless-ngx-postgres.sql",
})
}, "hu")
if strings.Contains(msg, "nincs adatbázisa") {
t.Fatalf("FALSE CLAIM: told the customer the app has no database while its undo copy proves it does; got %q", msg)
}
@@ -51,7 +51,7 @@ func TestReconstituteOutcome_DatabaseExistsButWasNotRestored(t *testing.T) {
func TestReconstituteOutcome_DatabaseRestoredIsUnchanged(t *testing.T) {
msg := reconstituteOutcomeMsg("romm", backup.OffsiteReconstituteResult{
FilesPlaced: 4, DBsReplayed: 1, SafetyDump: "/x/pre-restore-romm-mariadb.sql",
})
}, "hu")
if !strings.Contains(msg, "és az adatbázis visszaállítva") {
t.Errorf("the full case must keep its wording; got %q", msg)
}
@@ -66,7 +66,7 @@ func TestReconstituteOutcome_DatabaseRestoredIsUnchanged(t *testing.T) {
func TestReconstituteOutcome_VolumesAreNamed(t *testing.T) {
msg := reconstituteOutcomeMsg("calibre-web", backup.OffsiteReconstituteResult{
FilesPlaced: 5, VolumesReplayed: 1, DBsReplayed: 0, SafetyDump: "",
})
}, "hu")
if !strings.Contains(msg, "5 fájl és 1 adatkötet visszaállítva") {
t.Errorf("the message must name the volume that came back; got %q", msg)
}
@@ -80,7 +80,7 @@ func TestReconstituteOutcome_VolumesAreNamed(t *testing.T) {
func TestReconstituteOutcome_VolumeOnlyAppSaysWhatCameBack(t *testing.T) {
msg := reconstituteOutcomeMsg("privatebin", backup.OffsiteReconstituteResult{
FilesPlaced: 0, VolumesReplayed: 1, DBsReplayed: 0, SafetyDump: "",
})
}, "hu")
if !strings.Contains(msg, "0 fájl és 1 adatkötet visszaállítva") {
t.Errorf("a volume-only restore must state the volume; got %q", msg)
}
@@ -91,13 +91,13 @@ func TestReconstituteOutcome_VolumeOnlyAppSaysWhatCameBack(t *testing.T) {
func TestReconstituteOutcome_NoVolumesWordingUnchanged(t *testing.T) {
noDB := reconstituteOutcomeMsg("opengist", backup.OffsiteReconstituteResult{
FilesPlaced: 3, VolumesReplayed: 0, DBsReplayed: 0, SafetyDump: "",
})
}, "hu")
if noDB != "A(z) opengist: 3 fájl visszaállítva — az alkalmazás újraindult. Ennek az alkalmazásnak nincs adatbázisa." {
t.Errorf("the no-volume, no-database wording changed; got %q", noDB)
}
withDB := reconstituteOutcomeMsg("romm", backup.OffsiteReconstituteResult{
FilesPlaced: 4, VolumesReplayed: 0, DBsReplayed: 1, SafetyDump: "/x/pre-restore-romm-mariadb.sql",
})
}, "hu")
if withDB != "A(z) romm: 4 fájl és az adatbázis visszaállítva — az alkalmazás újraindult." {
t.Errorf("the no-volume, with-database wording changed; got %q", withDB)
}
@@ -110,7 +110,7 @@ func TestReconstituteOutcome_NoVolumesWordingUnchanged(t *testing.T) {
func TestReconstituteOutcome_AllThreeLegs(t *testing.T) {
msg := reconstituteOutcomeMsg("paperless-ngx", backup.OffsiteReconstituteResult{
FilesPlaced: 12, VolumesReplayed: 3, DBsReplayed: 1, SafetyDump: "/x/pre-restore-p.sql",
})
}, "hu")
want := "A(z) paperless-ngx: 12 fájl és 3 adatkötet és az adatbázis visszaállítva — az alkalmazás újraindult."
if msg != want {
t.Errorf("got %q\nwant %q", msg, want)
@@ -80,8 +80,10 @@ func TestR358_PlaceHandlerRefusesIncompleteScratch(t *testing.T) {
s.offboxPlaceHandler(w, req)
loc := w.Header().Get("Location")
if !strings.Contains(loc, "nem+teljes") && !strings.Contains(loc, "nem%20teljes") {
t.Fatalf("a direct POST over a part-copy was NOT refused server-side; redirect was %q", loc)
// The flash carries a KEY since v0.252.0; resolved here the way the page resolves it, so the
// assertion is still about the SENTENCE the customer reads.
if !strings.Contains(flashSentence(t, loc), "nem teljes") {
t.Fatalf("a direct POST over a part-copy was NOT refused server-side; redirect was %q -> %q", loc, flashSentence(t, loc))
}
if m.RestoreStatus().Running {
t.Fatal("the place operation actually STARTED over an incomplete scratch")
@@ -101,8 +103,8 @@ func TestR358_ReconstituteHandlerRefusesIncompleteScratch(t *testing.T) {
s.offboxReconstituteHandler(w, req)
loc := w.Header().Get("Location")
if !strings.Contains(loc, "nem+teljes") && !strings.Contains(loc, "nem%20teljes") {
t.Fatalf("the DESTRUCTIVE restore was not refused over a part-copy; redirect was %q", loc)
if !strings.Contains(flashSentence(t, loc), "nem teljes") {
t.Fatalf("the DESTRUCTIVE restore was not refused over a part-copy; redirect was %q -> %q", loc, flashSentence(t, loc))
}
if m.RestoreStatus().Running {
t.Fatal("the destructive restore actually STARTED over an incomplete scratch")
+17 -17
View File
@@ -22,9 +22,9 @@ func TestR403_SkippedUnitLegIsNotRenderedAsFresh(t *testing.T) {
const runDate = "2026-08-31T03:30:00Z"
const pkgDate = "2026-08-25T03:30:00Z"
stale := r103Row(true, pkgDate, true)
stale := r103Row(t, true, pkgDate, true)
stale.Tier2LastRun, stale.Tier2LastSuccess = runDate, runDate
stale.Tier2UnitStaleNotice = staleNoticeFor(pkgDate)
stale.Tier2UnitStaleNotice = staleNoticeFor(t, pkgDate)
html := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{stale}))
@@ -38,9 +38,9 @@ func TestR403_SkippedUnitLegIsNotRenderedAsFresh(t *testing.T) {
// NEGATIVE CONTROL: an ordinary row must NOT carry the notice, or D1 would pass on a page that
// shows the warning to everybody.
fresh := r103Row(true, runDate, true)
fresh := r103Row(t, true, runDate, true)
freshHTML := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{fresh}))
if strings.Contains(freshHTML, staleNoticeFor(pkgDate)) {
if strings.Contains(freshHTML, staleNoticeFor(t, pkgDate)) {
t.Error("an ordinary row carried the preserved-package notice")
}
if !strings.Contains(freshHTML, "/backup/tier2/unit-restore") {
@@ -48,8 +48,8 @@ func TestR403_SkippedUnitLegIsNotRenderedAsFresh(t *testing.T) {
}
}
func staleNoticeFor(pkgDate string) string {
return strings.Replace(tier2UnitStaleNoticeFmt, "%s", fmtRFC3339Local(pkgDate), 1)
func staleNoticeFor(t *testing.T, pkgDate string) string {
return noteServer(t).note(tier2UnitStaleNoticeFmtKey, fmtRFC3339Local(pkgDate))
}
// D2 — TestR403_UnitRestoreOfferNamesTheOlderPackageDate.
@@ -58,21 +58,21 @@ func staleNoticeFor(pkgDate string) string {
// preserved leg it must name the PACKAGE's date and say why it is older than the copy's newest run.
func TestR403_UnitRestoreOfferNamesTheOlderPackageDate(t *testing.T) {
const pkgDate = "2026-08-25T03:30:00Z"
staleConfirm := tier2UnitConfirmWithStaleness(pkgDate, true, true)
freshConfirm := tier2UnitConfirmWithStaleness(pkgDate, true, false)
staleConfirm := noteServer(t).tier2UnitConfirmWithStaleness(pkgDate, true, true)
freshConfirm := noteServer(t).tier2UnitConfirmWithStaleness(pkgDate, true, false)
if !strings.Contains(staleConfirm, tier2UnitStaleClause) {
if !strings.Contains(staleConfirm, noteHU(t, tier2UnitStaleClauseKey)) {
t.Error("the confirm does not say the package is older than the newest run")
}
if !strings.Contains(staleConfirm, fmtRFC3339Local(pkgDate)) {
t.Error("the confirm does not name the package's date")
}
// Everything the ordinary confirm promised is still promised.
if !strings.Contains(staleConfirm, tier2UnitConfirmBase) || !strings.Contains(staleConfirm, tier2UnitConfirmContrast) {
if !strings.Contains(staleConfirm, noteHU(t, tier2UnitConfirmBaseKey)) || !strings.Contains(staleConfirm, noteHU(t, tier2UnitConfirmContrastKey)) {
t.Error("the stale confirm lost the overwrite warning or the additive contrast")
}
// NEGATIVE CONTROL: the ordinary confirm must NOT carry the clause.
if strings.Contains(freshConfirm, tier2UnitStaleClause) {
if strings.Contains(freshConfirm, noteHU(t, tier2UnitStaleClauseKey)) {
t.Error("an ordinary confirm carried the preserved-package clause")
}
if staleConfirm == freshConfirm {
@@ -80,14 +80,14 @@ func TestR403_UnitRestoreOfferNamesTheOlderPackageDate(t *testing.T) {
}
// And it reaches the rendered markup, not only the constant.
row := r103Row(true, pkgDate, true)
row := r103Row(t, true, pkgDate, true)
row.Tier2UnitConfirm = staleConfirm
html := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{row}))
if !strings.Contains(html, "FIGYELEM") { // ASCII-only fragment, R-364
t.Error("the stale clause never reached the page")
}
// The ASCII control: the same page WITHOUT the clause must not match.
rowFresh := r103Row(true, pkgDate, true)
rowFresh := r103Row(t, true, pkgDate, true)
freshHTML := renderBackupPage(t, "backups_apps", baseBackupData([]AppBackupRow{rowFresh}))
if strings.Contains(freshHTML, "FIGYELEM") {
t.Error("the ASCII fragment matches a page that has no stale clause — the control fails")
@@ -97,7 +97,7 @@ func TestR403_UnitRestoreOfferNamesTheOlderPackageDate(t *testing.T) {
// The two-argument wrapper still produces the ordinary confirm — yesterday's callers are unchanged.
func TestR403_TheOrdinaryConfirmIsUnchanged(t *testing.T) {
const d = "2026-08-25T03:30:00Z"
if tier2UnitConfirmMsg(d, true) != tier2UnitConfirmWithStaleness(d, true, false) {
if noteServer(t).tier2UnitConfirmMsg(d, true) != noteServer(t).tier2UnitConfirmWithStaleness(d, true, false) {
t.Error("the two-argument confirm is no longer the not-stale case")
}
}
@@ -113,7 +113,7 @@ func TestR403_OutcomeNamesThePackageDateNotTheRunDate(t *testing.T) {
UnitPackageDate: "2026-08-31T09:43:41Z", // the preserved package
UnitLegPreserved: true,
}
msg := tier2UnitSourceMsg(cov)
msg := noteServer(t).tier2UnitSourceMsg(cov)
pkg := fmtRFC3339Local("2026-08-31T09:43:41Z")
run := fmtRFC3339Local("2026-08-31T12:23:51Z")
if !strings.Contains(msg, pkg) {
@@ -124,14 +124,14 @@ func TestR403_OutcomeNamesThePackageDateNotTheRunDate(t *testing.T) {
}
// The confirm and the outcome must agree.
date, stale := cov.UnitRestoreDate()
confirm := tier2UnitConfirmWithStaleness(date, true, stale)
confirm := noteServer(t).tier2UnitConfirmWithStaleness(date, true, stale)
if !strings.Contains(confirm, pkg) {
t.Errorf("the confirm does not name %q either: %q", pkg, confirm)
}
// NEGATIVE CONTROL: with no preserved leg, the package date IS the fresh one and both agree on it.
fresh := backup.Tier2Coverage{CopyLastSuccess: "2026-08-31T12:23:51Z", UnitPackageDate: "2026-08-31T12:23:00Z"}
if !strings.Contains(tier2UnitSourceMsg(fresh), fmtRFC3339Local("2026-08-31T12:23:00Z")) {
if !strings.Contains(noteServer(t).tier2UnitSourceMsg(fresh), fmtRFC3339Local("2026-08-31T12:23:00Z")) {
t.Error("the ordinary outcome stopped naming its own package date")
}
}
+48 -2
View File
@@ -369,13 +369,32 @@ func (s *Server) templatesFor(lang string) *template.Template {
return s.tmpl
}
// langFor decides the language of one request: `?lang=hu|en` (a testing override, never persisted),
// else the household's saved setting, else Hungarian.
// langFor decides the language of one request. The order is fixed, and each step exists for a
// different reader (v0.254.0, R-557 release C):
//
// 1. `?lang=hu|en` — the testing override. Never persisted, never sets a cookie.
// 2. A REQUEST WITH A SESSION is the household's own: their saved setting, and the visitor cookie is
// NOT read. A signed-in household must never see a language a previous visitor chose on the
// sign-in page of the same browser.
// 3. A request with NO session is a visitor at the door — sign-in, claim, recovery. They have no
// setting to read and no right to change the household's, so their choice lives in their own
// browser: the `felhom_lang` cookie, display-only.
// 4. The household's setting anyway (a box whose visitor expressed no preference).
// 5. Hungarian.
//
// Why a cookie and not the setting: the sign-in page is reachable by anyone who can reach the box.
// Letting that change what the HOUSEHOLD reads would be an anonymous write to something they own.
// Changing what the VISITOR THEMSELVES reads is not — 04-control-plane-authorization.md.
func (s *Server) langFor(r *http.Request) string {
if r != nil {
if q := r.URL.Query().Get("lang"); i18n.IsSupported(q) {
return q
}
if !s.hasSession(r) {
if c, err := r.Cookie(langCookieName); err == nil && i18n.IsSupported(c.Value) {
return c.Value
}
}
}
if s.settings != nil {
return s.settings.GetLanguage()
@@ -383,6 +402,16 @@ func (s *Server) langFor(r *http.Request) string {
return i18n.Default
}
// hasSession reports whether this request carries a VALID household session — the same test the auth
// middleware makes, reused rather than restated so the two cannot drift apart.
func (s *Server) hasSession(r *http.Request) bool {
if r == nil {
return false
}
c, err := r.Cookie(sessionCookieName)
return err == nil && s.isValidSession(c.Value)
}
// HubPushStatusData holds hub push status for the monitoring page.
type HubPushStatusData struct {
LastAttempt time.Time
@@ -599,6 +628,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// i18n (v0.247.0): the household's dashboard language.
case path == "/settings/language" && r.Method == http.MethodPost:
s.languageSwitchHandler(w, r)
// i18n (v0.254.0): the VISITOR's display language, for a page with no household signed in.
// RequireAuth intercepts this on a password-protected box; the route is here so it also works on
// a box with no password set and on an unclaimed one, where the middleware passes straight
// through. Same handler either way.
case path == langCookiePath && r.Method == http.MethodPost:
s.langCookieHandler(w, r)
case path == "/settings/notifications" && r.Method == http.MethodPost:
s.settingsNotificationsHandler(w, r)
case path == "/settings/notifications/test" && r.Method == http.MethodPost:
@@ -880,6 +915,17 @@ func (s *Server) executeTemplateLang(w io.Writer, r *http.Request, name string,
lang := s.langFor(r)
if data != nil {
data["Lang"] = lang
// v0.254.0: the globe on the pages a VISITOR meets. It posts to /lang, not to the household
// switch, and carries NO CSRF field — there is no session to mint one from, and the handler
// writes only a display cookie in the visitor's own browser (CsrfProtect carries the reasoning).
//
// `back` is the path the visitor is ON, so the switch returns them to it. safeBackPath in the
// handler is what makes that safe to take from an anonymous form.
back := "/"
if r != nil && r.URL != nil && r.URL.Path != "" {
back = r.URL.Path
}
addLangOptions(data, lang, langCookiePath, back, "")
}
return s.templatesFor(lang).ExecuteTemplate(w, name, data)
}
@@ -51,7 +51,7 @@ func TestSlice4_BackupRowUnitFieldsAreUnchangedByTheExtraction(t *testing.T) {
}
if row.Tier2UnitRestorable != cov.CanRestoreUnit() || row.Tier2CopyDate != wantDate ||
row.Tier2CopyDateProven != (cov.CopyLastSuccess != "") ||
row.Tier2UnitConfirm != tier2UnitConfirmWithStaleness(wantDate, cov.CopyLastSuccess != "", wantStale) {
row.Tier2UnitConfirm != noteServer(t).tier2UnitConfirmWithStaleness(wantDate, cov.CopyLastSuccess != "", wantStale) {
t.Errorf("the row changed: restorable=%v date=%q proven=%v confirm=%q", row.Tier2UnitRestorable, row.Tier2CopyDate, row.Tier2CopyDateProven, row.Tier2UnitConfirm)
}
}
@@ -8,6 +8,7 @@
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-body">
<div class="shell-lang">{{template "lang_globe" .}}</div>
<div class="login-card">
<img src="/static/felhom-logo.svg" alt="Felhom.eu" class="login-logo">
<h1 class="login-title">{{if .IsReset}}{{T "claim.jelszo"}} <span class="title-accent">{{T "claim.visszaallitasa"}}</span>{{else}}{{T "claim.a_szerver"}} <span class="title-accent">{{T "claim.beallitasa"}}</span>{{end}}</h1>
@@ -0,0 +1,6 @@
{{define "lang_globe"}}<details class="lang-globe">
<summary class="lang-globe-btn" aria-label="{{T "layout.nyelv"}}" title="{{T "layout.nyelv"}}"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">{{range .LangOptions}}
<li><form method="POST" action="{{$.LangAction}}">{{$.LangCSRF}}<input type="hidden" name="back" value="{{$.LangBack}}"><button type="submit" name="lang" value="{{.Code}}" lang="{{.Code}}" class="lang-globe-item{{if .Current}} current{{end}}"{{if .Current}} aria-current="true"{{end}}>{{.Name}}</button></form></li>{{end}}
</ul>
</details>{{end}}
@@ -116,7 +116,7 @@
<li><a href="/settings/security" class="{{if eq .Page "settings-security"}}active{{end}}"><svg class="ico"><use href="#i-lock"/></svg>{{T "layout.biztonsag_es_hozzaferes"}}</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">{{.Version}}</span>{{if .LangSwitch}}<form method="POST" action="/settings/language" class="lang-switch">{{.CSRFField}}<input type="hidden" name="back" value="{{.LangSwitchBack}}"><button type="submit" name="lang" value="hu" class="lang-switch-btn{{if eq .Lang "hu"}} active{{end}}" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn{{if eq .Lang "en"}} active{{end}}" lang="en">English</button></form>{{end}}
<span class="version">{{.Version}}</span>{{if .LangSwitch}}{{template "lang_globe" .}}{{end}}
{{if .AuthEnabled}}<a href="/logout" class="logout-link">{{T "layout.kijelentkezes"}}</a>{{end}}
</div>
</div>
@@ -8,6 +8,7 @@
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-body">
<div class="shell-lang">{{template "lang_globe" .}}</div>
<div class="login-card">
<img src="/static/felhom-logo.svg?v={{.Version}}" alt="Felhom.eu" class="login-logo">
<h1 class="login-title">{{T "login.otthoni"}} <span class="title-accent">{{T "login.vezerlopult"}}</span></h1>
@@ -9,6 +9,7 @@
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-body">
<div class="shell-lang">{{template "lang_globe" .}}</div>
<div class="login-card" style="max-width:46rem">
<img src="/static/felhom-logo.svg" alt="Felhom.eu" class="login-logo">
+23 -5
View File
@@ -134,11 +134,22 @@ body {
font-size: .8rem;
}
.version { color: var(--text-3); }
/* i18n (v0.247.0): the language switch. Language names are shown in their own language, never translated. */
.lang-switch { display: inline-flex; gap: 2px; margin: 0; }
.lang-switch-btn { background: none; border: 1px solid var(--line); border-radius: var(--radius); color: var(--text-3); font: inherit; font-size: .75rem; padding: 0 .35rem; cursor: pointer; }
.lang-switch-btn:hover { color: var(--blue-bright); }
.lang-switch-btn.active { color: var(--text-1); border-color: var(--blue); }
/* i18n (v0.254.0): the language switch is a GLOBE. Two text links wrapped in the sidebar footer and
asked the reader to recognise „Magyar"/„English" as links; a globe is the one symbol every web user
already reads as "language", so nobody has to read Hungarian to find their way out of Hungarian.
<details>/<summary> — a menu with no script: it opens on click and on Enter, and a screen reader
announces it. Language names inside are shown in their OWN language, never translated. */
.lang-globe { position: relative; margin: 0; }
.lang-globe-btn { list-style: none; display: inline-flex; align-items: center; color: var(--text-3); cursor: pointer; padding: .15rem; border-radius: var(--radius); }
.lang-globe-btn::-webkit-details-marker { display: none; }
.lang-globe-btn:hover, .lang-globe[open] .lang-globe-btn { color: var(--blue-bright); }
.lang-globe-menu { position: absolute; bottom: calc(100% + .35rem); left: 50%; transform: translateX(-50%); margin: 0; padding: .25rem; list-style: none; min-width: 7.5rem; background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--radius); z-index: 20; }
.lang-globe-menu li { margin: 0; }
.lang-globe-menu form { margin: 0; }
.lang-globe-item { display: block; width: 100%; text-align: left; background: none; border: none; color: var(--text-2); font: inherit; font-size: .8rem; padding: .3rem .5rem; border-radius: var(--radius); cursor: pointer; white-space: nowrap; }
.lang-globe-item:hover { color: var(--blue-bright); }
.lang-globe-item.current { color: var(--text-1); font-weight: 600; }
.lang-globe-item.current::after { content: " \2713"; }
.logout-link { color: var(--text-3); text-decoration: none; transition: color 0.2s ease; }
.logout-link:hover { color: var(--blue-bright); }
/* Mobile chrome (v0.166.0) — hidden by default so desktop (>768px) stays pixel-identical; the
@@ -3664,3 +3675,10 @@ html::-webkit-scrollbar-track { background: var(--bg-0); }
.restore-danger-card {
border-left: 2px solid var(--crit);
}
/* i18n (v0.254.0): the globe on the pages a VISITOR meets — sign-in, claim, recovery. Those pages have
their own shell and no sidebar, so the globe is pinned to the top-right corner of the viewport, the
same place on all three. It posts to /lang, which sets a display-only cookie in that visitor's own
browser and never the household's setting. */
.shell-lang { position: absolute; top: 1rem; right: 1rem; z-index: 30; }
.shell-lang .lang-globe-menu { bottom: auto; top: calc(100% + .35rem); left: auto; right: 0; transform: none; }
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -8,6 +8,13 @@
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-body">
<div class="shell-lang"><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/lang"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/lang"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details></div>
<div class="login-card">
<img src="/static/felhom-logo.svg" alt="Felhom.eu" class="login-logo">
<h1 class="login-title">Jelszó <span class="title-accent">visszaállítása</span></h1>
@@ -8,6 +8,13 @@
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-body">
<div class="shell-lang"><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/lang"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/lang"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details></div>
<div class="login-card">
<img src="/static/felhom-logo.svg" alt="Felhom.eu" class="login-logo">
<h1 class="login-title">Jelszó <span class="title-accent">visszaállítása</span></h1>
@@ -8,6 +8,13 @@
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-body">
<div class="shell-lang"><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/lang"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/lang"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details></div>
<div class="login-card">
<img src="/static/felhom-logo.svg" alt="Felhom.eu" class="login-logo">
<h1 class="login-title">A szerver <span class="title-accent">beállítása</span></h1>
@@ -8,6 +8,13 @@
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-body">
<div class="shell-lang"><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/lang"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/lang"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details></div>
<div class="login-card">
<img src="/static/felhom-logo.svg" alt="Felhom.eu" class="login-logo">
<h1 class="login-title">A szerver <span class="title-accent">beállítása</span></h1>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
+7 -1
View File
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
</div>
</div>
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>
@@ -8,6 +8,13 @@
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-body">
<div class="shell-lang"><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/lang"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/lang"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details></div>
<div class="login-card">
<img src="/static/felhom-logo.svg?v=0.250.0" alt="Felhom.eu" class="login-logo">
<h1 class="login-title">Otthoni <span class="title-accent">vezérlőpult</span></h1>
+7 -1
View File
@@ -147,7 +147,13 @@
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><form method="POST" action="/settings/language" class="lang-switch"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" class="lang-switch-btn active" lang="hu">Magyar</button><button type="submit" name="lang" value="en" class="lang-switch-btn" lang="en">English</button></form>
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value=""><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</div>

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