The family gate (decisions 63/64, R-780): family members with their own logins, a permanent forwardAuth door per family app, anchored exceptions, min_controller
gates / gates (push) Successful in 27s

- internal/family: the family list (bcrypt, generated 4x4 passwords shown once) + 30-day sessions in family.json
  (0600, atomic); a reset (generation), a removal or a logout ends sessions at the next request.
- internal/stacks/family_gate.go: family_gate / family_gate_except / min_controller in .felhom.yml; the door is written
  BEFORE the first start (install and a removed app's restore), a life record in app.yaml, reconciled by the gate loop;
  priority below the install hold, setup gate and sign-up block; every exception anchored ^/prefix(/|$) (finding F1).
- internal/web/family_gate.go: forwardAuth /__felhom_gate/family (app cookie felhom_famgate, host-only, names a store
  session); /__family/start|login|logout on the dashboard host (session cookie felhom_family, Path=/__family);
  sign-in counted per visitor (clientIP) AND per name, short windows; the household's dashboard session vouches.
  RequireAuth never reads a family cookie. The "Család" card on the security page: add / new password / remove.
Red-proofs RP-F1..RP-F7 (felhom.eu audits/family-gate-2026-10-02/A/).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-02 07:42:47 +02:00
parent a4a753b9e2
commit 977665d8c0
25 changed files with 2168 additions and 10 deletions
+476
View File
@@ -0,0 +1,476 @@
package web
import (
"crypto/hmac"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/family"
)
// ── The family gate's answerer (v0.287.0, `09` §3 decisions 63 and 64; R-780) ─────────────────────────────
//
// traefik asks GET /__felhom_gate/family (forwardAuth) for every request to a family-gated app (except the anchored
// paths the template leaves to the app's own login — internal/stacks/family_gate.go writes that file). The answer:
//
// - a valid FAMILY APP COOKIE (`felhom_famgate`, host-only on the app host) → 200;
// - /__felhom_gate/fcb?t=<token> → the token (60 s, one use, bound to the host and to a family session) becomes the
// app cookie, and the browser goes back where it was going;
// - a browser GET without one → 302 to https://felhom.<domain>/__family/start?rd=<where it was going>;
// - anything else → 401 JSON.
//
// On the DASHBOARD host, outside the dashboard's own auth (CatchAllMiddleware answers them first):
//
// - /__family/start — with a valid FAMILY SESSION (`felhom_family`, Path=/__family, so the browser never even sends
// it to a dashboard page) or the household's dashboard session: a token and a 302 to the app; without: the family
// sign-in page;
// - /__family/login — a member's OWN name and password; counted per VISITOR (clientIP, R-753) and per NAME, short
// windows, never "everyone";
// - /__family/logout — ends the family session, and with it every app cookie minted from it (the store is asked on
// every request).
//
// THE RULE: a family cookie never opens the dashboard. RequireAuth reads only `felhom_session`; nothing here ever sets
// it. An app cookie names a store session, so a removed member, a reset password or a logout ends access at the next
// request on every app. Pinned by internal/web/family_gate_test.go.
const (
familySessionCookie = "felhom_family"
familyAppCookie = "felhom_famgate"
familyAuthPath = "/__felhom_gate/family"
familyCallbackURI = "/__felhom_gate/fcb"
familyStartPath = "/__family/start"
familyLoginPath = "/__family/login"
familyLogoutPath = "/__family/logout"
familyCookiePath = "/__family"
familyFormLife = time.Hour
familyVisitorMax = 5
familyVisitorWindow = time.Minute
familyNameMax = 10
familyNameWindow = 10 * time.Minute
)
type familyState struct {
once sync.Once
store *family.Store
mu sync.Mutex
visitor map[string][]time.Time
name map[string][]time.Time
}
// familyStore opens the family list once. An unreadable list is logged and answers "nobody" (fail closed — the gate
// stays shut; the household still passes with its dashboard session).
func (s *Server) familyStore() *family.Store {
s.fam.once.Do(func() {
s.fam.visitor = map[string][]time.Time{}
s.fam.name = map[string][]time.Time{}
if s.familyStoreOverride != nil {
s.fam.store = s.familyStoreOverride
return
}
if s.cfg == nil || s.cfg.Paths.DataDir == "" {
return
}
st, err := family.Open(s.cfg.Paths.DataDir)
if err != nil {
s.logger.Printf("[ERROR] [web] family gate: the family list could not be read (%v) — only the household passes until it is fixed", err)
return
}
s.fam.store = st
})
return s.fam.store
}
// familyLocked reports whether this visitor or this name is out of tries. Windows slide; a success clears both.
func (s *Server) familyLocked(visitor, name string) bool {
s.familyStore()
now := s.gateNow()
s.fam.mu.Lock()
defer s.fam.mu.Unlock()
prune := func(m map[string][]time.Time, k string, win time.Duration) int {
var keep []time.Time
for _, t := range m[k] {
if now.Sub(t) < win {
keep = append(keep, t)
}
}
if len(keep) == 0 {
delete(m, k)
} else {
m[k] = keep
}
return len(keep)
}
v := prune(s.fam.visitor, visitor, familyVisitorWindow)
n := 0
if name != "" {
n = prune(s.fam.name, name, familyNameWindow)
}
return v >= familyVisitorMax || n >= familyNameMax
}
func (s *Server) familyFailed(visitor, name string) {
now := s.gateNow()
s.fam.mu.Lock()
defer s.fam.mu.Unlock()
s.fam.visitor[visitor] = append(s.fam.visitor[visitor], now)
if name != "" {
s.fam.name[name] = append(s.fam.name[name], now)
}
}
func (s *Server) familyCleared(visitor, name string) {
s.fam.mu.Lock()
defer s.fam.mu.Unlock()
delete(s.fam.visitor, visitor)
delete(s.fam.name, name)
}
// familyRDHost: the host of a return address that may be used — https, on this household's domain, a family-gated app.
func (s *Server) familyRDHost(rd string) (string, bool) {
u, err := url.Parse(rd)
if err != nil || u.Scheme != "https" || u.User != nil || u.Host == "" || s.stackMgr == nil || s.cfg == nil {
return "", false
}
host := strings.ToLower(u.Hostname())
if u.Port() != "" || !strings.HasSuffix(host, "."+strings.ToLower(s.cfg.Customer.Domain)) {
return "", false
}
if _, found := s.stackMgr.FamilyGateHost(host); !found {
return "", false
}
return host, true
}
type familyToken struct {
Host string `json:"h"`
Sid string `json:"s"`
Exp int64 `json:"e"`
Nonce string `json:"n"`
RD string `json:"r"`
MAC string `json:"m"`
}
func (s *Server) mintFamilyToken(host, sid, rd string) string {
nb := make([]byte, 12)
_, _ = rand.Read(nb)
t := familyToken{Host: host, Sid: sid, Exp: s.gateNow().Add(gateTokenLife).Unix(), Nonce: hex.EncodeToString(nb), RD: rd}
t.MAC = s.gateMAC("ftoken", t.Host, t.Sid, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD)
b, _ := json.Marshal(t)
return base64.RawURLEncoding.EncodeToString(b)
}
// takeFamilyToken checks a token for host, uses it up, and returns the session and where the browser was going.
func (s *Server) takeFamilyToken(raw, host string) (sid, rd string, err error) {
b, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil {
return "", "", errors.New("malformed")
}
var t familyToken
if json.Unmarshal(b, &t) != nil {
return "", "", errors.New("malformed")
}
if !hmac.Equal([]byte(t.MAC), []byte(s.gateMAC("ftoken", t.Host, t.Sid, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD))) {
return "", "", errors.New("bad signature")
}
if t.Host != host {
return "", "", errors.New("for another app")
}
now := s.gateNow()
if now.Unix() > t.Exp {
return "", "", errors.New("expired")
}
s.gateKey()
s.gate.mu.Lock()
defer s.gate.mu.Unlock()
for n, until := range s.gate.used {
if now.After(until) {
delete(s.gate.used, n)
}
}
if _, seen := s.gate.used["f:"+t.Nonce]; seen {
return "", "", errors.New("already used")
}
s.gate.used["f:"+t.Nonce] = time.Unix(t.Exp, 0).Add(time.Second)
return t.Sid, t.RD, nil
}
// familyAppCookieSession: "<sid>.<unix expiry>.<mac over host+sid+expiry>" → the session, if the cookie is genuine for
// this host, unexpired, and the store still holds the session (a removed member / reset / logout fails here).
func (s *Server) familyAppCookieSession(r *http.Request, host string) (family.Session, bool) {
c, err := r.Cookie(familyAppCookie)
if err != nil {
return family.Session{}, false
}
parts := strings.Split(c.Value, ".")
if len(parts) != 3 {
return family.Session{}, false
}
n, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil || s.gateNow().Unix() > n {
return family.Session{}, false
}
if !hmac.Equal([]byte(parts[2]), []byte(s.gateMAC("famcookie", host, parts[0], parts[1]))) {
return family.Session{}, false
}
return s.familyStore().Valid(parts[0])
}
func familyRefuse(w http.ResponseWriter, code int) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(code)
_, _ = w.Write([]byte(`{"error":"sign in with your family login"}`))
}
// ServeFamilyGateAuth is traefik's forwardAuth answer for a family-gated app. Like the setup gate it trusts only the
// X-Forwarded-Host/-Uri/-Method traefik writes from the request it forwards (forwardAuth's own, never the client's).
func (s *Server) ServeFamilyGateAuth(w http.ResponseWriter, r *http.Request) {
host := strings.ToLower(r.Header.Get("X-Forwarded-Host"))
if i := strings.LastIndex(host, ":"); i != -1 {
host = host[:i]
}
uri := r.Header.Get("X-Forwarded-Uri")
if uri == "" {
uri = "/"
}
method := r.Header.Get("X-Forwarded-Method")
if s.stackMgr == nil {
familyRefuse(w, http.StatusForbidden)
return
}
app, found := s.stackMgr.FamilyGateHost(host)
if !found {
s.logger.Printf("[WARN] [web] family gate: asked about %q, which no family app owns — refused", host)
familyRefuse(w, http.StatusForbidden)
return
}
if u, err := url.Parse(uri); err == nil && u.Path == familyCallbackURI {
sid, rd, err := s.takeFamilyToken(u.Query().Get("t"), host)
if err == nil {
if _, ok := s.familyStore().Valid(sid); !ok {
err = errors.New("the session ended")
}
}
if err != nil {
s.logger.Printf("[WARN] [web] family gate %s: a sign-in token was refused (%v) — visitor %s", app, err, clientIP(r))
familyRefuse(w, http.StatusForbidden)
return
}
exp := strconv.FormatInt(s.gateNow().Add(family.SessionLife).Unix(), 10)
http.SetCookie(w, &http.Cookie{
Name: familyAppCookie, Value: sid + "." + exp + "." + s.gateMAC("famcookie", host, sid, exp), Path: "/",
MaxAge: int(family.SessionLife.Seconds()), HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
})
s.logger.Printf("[INFO] [web] family gate %s: a signed-in browser passed — visitor %s", app, clientIP(r))
w.Header().Set("Cache-Control", "no-store")
http.Redirect(w, r, rd, http.StatusFound)
return
}
if _, ok := s.familyAppCookieSession(r, host); ok {
w.WriteHeader(http.StatusOK)
return
}
if (method == "" || method == http.MethodGet) && strings.Contains(r.Header.Get("Accept"), "text/html") {
w.Header().Set("Cache-Control", "no-store")
http.Redirect(w, r, "https://felhom."+s.cfg.Customer.Domain+familyStartPath+"?"+url.Values{"rd": {"https://" + host + uri}}.Encode(), http.StatusFound)
return
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] family gate %s: %s %s without a pass — 401 (visitor %s)", app, method, uri, clientIP(r))
}
familyRefuse(w, http.StatusUnauthorized)
}
// familySessionFromCookie: the family session this browser holds on the dashboard host.
func (s *Server) familySessionFromCookie(r *http.Request) (family.Session, bool) {
c, err := r.Cookie(familySessionCookie)
if err != nil {
return family.Session{}, false
}
return s.familyStore().Valid(c.Value)
}
// ServeFamilyStart is /__family/start on the dashboard host.
func (s *Server) ServeFamilyStart(w http.ResponseWriter, r *http.Request) {
rd := r.URL.Query().Get("rd")
host, ok := s.familyRDHost(rd)
if !ok {
s.renderFamilyPage(w, r, "", "", http.StatusOK)
return
}
w.Header().Set("Cache-Control", "no-store")
se, ok := s.familySessionFromCookie(r)
if !ok && s.hasSession(r) {
// The household's own dashboard session vouches (decision 46's rule) — a household session in the family store,
// never the dashboard session itself.
if st := s.familyStore(); st != nil {
if sid, err := st.NewSession(""); err == nil {
se, ok = family.Session{ID: sid}, true
}
}
}
if ok {
http.Redirect(w, r, "https://"+host+familyCallbackURI+"?"+url.Values{"t": {s.mintFamilyToken(host, se.ID, rd)}}.Encode(), http.StatusFound)
return
}
s.renderFamilyPage(w, r, rd, "", http.StatusOK)
}
// familyFormToken is the sign-in form's own CSRF (the visitor has no session): an HMAC over its expiry.
func (s *Server) familyFormToken() string {
exp := strconv.FormatInt(s.gateNow().Add(familyFormLife).Unix(), 10)
return exp + "." + s.gateMAC("famform", exp)
}
func (s *Server) familyFormTokenValid(v string) bool {
exp, mac, ok := strings.Cut(v, ".")
n, err := strconv.ParseInt(exp, 10, 64)
if !ok || err != nil || s.gateNow().Unix() > n {
return false
}
return hmac.Equal([]byte(mac), []byte(s.gateMAC("famform", exp)))
}
// ServeFamilyLogin is /__family/login: GET = the page, POST = a member's own name and password.
func (s *Server) ServeFamilyLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
s.renderFamilyPage(w, r, r.URL.Query().Get("rd"), "", http.StatusOK)
return
}
_ = r.ParseForm()
rd := r.FormValue("rd")
if !s.familyFormTokenValid(r.FormValue("_ft")) {
s.renderFamilyPage(w, r, rd, s.msg(r, "family_gate.msg.form_expired"), http.StatusForbidden)
return
}
name := strings.ToLower(strings.TrimSpace(r.FormValue("name")))
visitor := rateKey(r)
if s.familyLocked(visitor, name) {
s.logger.Printf("[WARN] [web] family sign-in: too many wrong tries — visitor %s, name %q", visitor, name)
s.renderFamilyPage(w, r, rd, s.msg(r, "family_gate.msg.locked"), http.StatusTooManyRequests)
return
}
st := s.familyStore()
if st == nil || !st.Verify(name, r.FormValue("password")) {
s.familyFailed(visitor, name)
s.logger.Printf("[WARN] [web] family sign-in failed — visitor %s", visitor)
s.renderFamilyPage(w, r, rd, s.msg(r, "family_gate.msg.wrong"), http.StatusUnauthorized)
return
}
sid, err := st.NewSession(name)
if err != nil {
s.logger.Printf("[ERROR] [web] family sign-in: the session could not be saved: %v", err)
s.renderFamilyPage(w, r, rd, s.msg(r, "family_gate.msg.wrong"), http.StatusInternalServerError)
return
}
s.familyCleared(visitor, name)
http.SetCookie(w, &http.Cookie{Name: familySessionCookie, Value: sid, Path: familyCookiePath,
MaxAge: int(family.SessionLife.Seconds()), HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode})
s.logger.Printf("[INFO] [web] family sign-in: %s — visitor %s", name, visitor)
if _, ok := s.familyRDHost(rd); ok {
http.Redirect(w, r, familyStartPath+"?"+url.Values{"rd": {rd}}.Encode(), http.StatusFound)
return
}
s.renderFamilyPage(w, r, "", s.msg(r, "family_gate.msg.signed_in"), http.StatusOK)
}
// ServeFamilyLogout is /__family/logout: the family session ends, and every app cookie minted from it with it.
func (s *Server) ServeFamilyLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(familySessionCookie); err == nil {
if err := s.familyStore().EndSession(c.Value); err != nil {
s.logger.Printf("[ERROR] [web] family sign-out: %v", err)
}
}
http.SetCookie(w, &http.Cookie{Name: familySessionCookie, Value: "", Path: familyCookiePath, MaxAge: -1, HttpOnly: true, Secure: true})
s.renderFamilyPage(w, r, "", s.msg(r, "family_gate.msg.signed_out"), http.StatusOK)
}
func (s *Server) renderFamilyPage(w http.ResponseWriter, r *http.Request, rd, notice string, code int) {
data := map[string]interface{}{"RD": rd, "Notice": notice, "FormToken": s.familyFormToken(), "Version": s.version}
if host, ok := s.familyRDHost(rd); ok {
data["Host"] = host
if app, found := s.stackMgr.FamilyGateHost(host); found {
if st, ok := s.stackMgr.GetStack(app); ok {
data["AppName"] = st.Meta.DisplayName
}
}
}
if _, ok := s.familySessionFromCookie(r); ok {
data["SignedIn"] = true
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(code)
if err := s.executeTemplateLang(w, r, "familygate", data); err != nil {
s.logger.Printf("[ERROR] [web] family page: %v", err)
}
}
// ── the dashboard's „Család" card (authenticated, CSRF-protected) ───────────────────────────────────────────
func (s *Server) familyMembersHandler(w http.ResponseWriter, r *http.Request) {
st := s.familyStore()
if st == nil {
escrowJSON(w, http.StatusServiceUnavailable, nil, s.msg(r, "family_gate.msg.store_unreadable"))
return
}
escrowJSON(w, http.StatusOK, map[string]any{"members": st.Names()}, "")
}
// familyMemberActionHandler: POST /family/members/{add,reset,remove} with `name`. add/reset answer the new password
// ONCE (never logged, never in a page render).
func (s *Server) familyMemberActionHandler(w http.ResponseWriter, r *http.Request, action string) {
st := s.familyStore()
if st == nil {
escrowJSON(w, http.StatusServiceUnavailable, nil, s.msg(r, "family_gate.msg.store_unreadable"))
return
}
_ = r.ParseForm()
name := strings.ToLower(strings.TrimSpace(r.FormValue("name")))
w.Header().Set("Cache-Control", "no-store")
var pw string
var err error
switch action {
case "add":
pw, err = st.Add(name)
case "reset":
pw, err = st.Reset(name)
case "remove":
err = st.Remove(name)
default:
escrowJSON(w, http.StatusNotFound, nil, "")
return
}
switch {
case errors.Is(err, family.ErrBadName):
escrowJSON(w, http.StatusBadRequest, nil, s.msg(r, "family_gate.msg.bad_name"))
return
case errors.Is(err, family.ErrExists):
escrowJSON(w, http.StatusConflict, nil, s.msg(r, "family_gate.msg.exists"))
return
case errors.Is(err, family.ErrNoMember):
escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "family_gate.msg.no_member"))
return
case err != nil:
s.logger.Printf("[ERROR] [web] family %s %q: %v", action, name, err)
escrowJSON(w, http.StatusInternalServerError, nil, s.msg(r, "family_gate.msg.store_unreadable"))
return
}
s.logger.Printf("[INFO] [web] family: the household's %s of %q from %s (password never logged)", action, name, clientIP(r))
out := map[string]any{"name": name, "members": st.Names()}
if pw != "" {
out["password"] = pw
}
escrowJSON(w, http.StatusOK, out, "")
}
+375
View File
@@ -0,0 +1,375 @@
package web
import (
"html"
"io"
"log"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/family"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// v0.287.0 (`09` §3 decisions 63/64, R-780) — the family gate's answerer and its sign-in pages, driven through the
// handlers traefik and the browser reach (ServeFamilyGateAuth, ServeFamilyStart/Login/Logout) and through the
// dashboard's own RequireAuth. Docker is a stub on PATH.
func familyHarness(t *testing.T) (*Server, *family.Store) {
t.Helper()
dir := t.TempDir()
bin := filepath.Join(dir, "bin")
for _, d := range []string{bin, filepath.Join(dir, "data"), filepath.Join(dir, "stacks", "fapp"), filepath.Join(dir, "stacks", "gapp")} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(bin, "docker"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin)
write := func(app, name, body string) {
if err := os.WriteFile(filepath.Join(dir, "stacks", app, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
write("fapp", "docker-compose.yml", "services:\n fapp:\n image: busybox\n")
write("fapp", ".felhom.yml", "display_name: Family App\nslug: fapp\nfamily_gate: true\n")
write("fapp", "app.yaml", "deployed: true\nfamily_gate:\n since: \"2026-10-02T00:00:00Z\"\n hosts: [fapp.example.hu]\n")
write("gapp", "docker-compose.yml", "services:\n gapp:\n image: busybox\n")
write("gapp", ".felhom.yml", "display_name: Gated App\nslug: gapp\nsetup_gate: true\n")
write("gapp", "app.yaml", "deployed: true\nsetup_gate:\n state: closed\n since: \"2026-09-29T00:00:00Z\"\n hosts: [gapp.example.hu]\n")
lg := log.New(io.Discard, "", 0)
cfg := config.Default()
cfg.Customer.Domain = "example.hu"
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
cfg.Paths.DataDir = filepath.Join(dir, "data")
h, _ := bcrypt.GenerateFromPassword([]byte("dashboard-pass"), bcrypt.MinCost)
cfg.Web.PasswordHash = string(h)
sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
mgr, err := stacks.NewManager(cfg, lg)
if err != nil {
t.Fatal(err)
}
if err := mgr.ScanStacks(); err != nil {
t.Fatal(err)
}
st, err := family.Open(cfg.Paths.DataDir)
if err != nil {
t.Fatal(err)
}
st.SetCost(bcrypt.MinCost)
s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test", sessions: map[string]*session{},
loginAttempts: map[string]*loginAttempt{}, familyStoreOverride: st}
s.loadTemplates()
return s, st
}
func famAsk(s *Server, host, method, uri, accept string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodGet, "http://felhom-controller:8080"+familyAuthPath, nil)
r.Header.Set("X-Forwarded-Host", host)
r.Header.Set("X-Forwarded-Method", method)
r.Header.Set("X-Forwarded-Uri", uri)
r.Header.Set("Accept", accept)
for _, c := range cookies {
r.AddCookie(c)
}
w := httptest.NewRecorder()
s.ServeFamilyGateAuth(w, r)
return w
}
func famCookie(w *httptest.ResponseRecorder, name string) *http.Cookie {
for _, c := range w.Result().Cookies() {
if c.Name == name {
return c
}
}
return nil
}
// famLogin posts the sign-in form as visitor `remote` (a direct peer — clientIP is the peer).
func famLogin(s *Server, remote, name, pw, rd string) *httptest.ResponseRecorder {
form := url.Values{"_ft": {s.familyFormToken()}, "name": {name}, "password": {pw}, "rd": {rd}}
r := httptest.NewRequest(http.MethodPost, "https://felhom.example.hu"+familyLoginPath, strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.RemoteAddr = remote + ":5000"
w := httptest.NewRecorder()
s.ServeFamilyLogin(w, r)
return w
}
// famPass walks a signed-in browser (its family session cookie) through start → callback → app cookie.
func famPass(t *testing.T, s *Server, sess *http.Cookie) *http.Cookie {
t.Helper()
rd := "https://fapp.example.hu/books"
r := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu"+familyStartPath+"?"+url.Values{"rd": {rd}}.Encode(), nil)
r.AddCookie(sess)
w := httptest.NewRecorder()
s.ServeFamilyStart(w, r)
loc := w.Header().Get("Location")
if w.Code != http.StatusFound || !strings.HasPrefix(loc, "https://fapp.example.hu"+familyCallbackURI+"?t=") {
t.Fatalf("start with a family session: %d %q", w.Code, loc)
}
u, _ := url.Parse(loc)
cb := famAsk(s, "fapp.example.hu", "GET", u.RequestURI(), "text/html")
app := famCookie(cb, familyAppCookie)
if cb.Code != http.StatusFound || cb.Header().Get("Location") != rd || app == nil {
t.Fatalf("callback: %d %q cookie %v", cb.Code, cb.Header().Get("Location"), app)
}
return app
}
// Exit item 1: a stranger reaches nothing — a browser is sent to the family sign-in, anything else is refused.
func TestFamilyGate_StrangerReachesNothing(t *testing.T) {
s, _ := familyHarness(t)
w := famAsk(s, "fapp.example.hu", "GET", "/", "text/html")
if w.Code != http.StatusFound || !strings.HasPrefix(w.Header().Get("Location"), "https://felhom.example.hu"+familyStartPath+"?rd=") {
t.Fatalf("a browser must be sent to the family sign-in: %d %q", w.Code, w.Header().Get("Location"))
}
for _, m := range []string{"GET", "POST"} {
if w := famAsk(s, "fapp.example.hu", m, "/api/x", "application/json"); w.Code != http.StatusUnauthorized {
t.Fatalf("%s API without a pass: %d", m, w.Code)
}
}
if w := famAsk(s, "other.example.hu", "GET", "/", "text/html"); w.Code != http.StatusForbidden {
t.Fatalf("a host no family app owns must be refused: %d", w.Code)
}
forged := &http.Cookie{Name: familyAppCookie, Value: "abc." + "99999999999" + ".deadbeef"}
if w := famAsk(s, "fapp.example.hu", "GET", "/", "application/json", forged); w.Code != http.StatusUnauthorized {
t.Fatalf("a forged app cookie must be refused: %d", w.Code)
}
}
// Exit item 2 + rule 1: a member's OWN login opens the app; the family session never opens the dashboard and the
// family pages never set the dashboard cookie.
// COMPANION RED-PROOF: make RequireAuth accept felhom_family → the dashboard assertion fails.
func TestFamilyGate_MemberPassesButNeverTheDashboard(t *testing.T) {
s, st := familyHarness(t)
pw, _ := st.Add("anna")
w := famLogin(s, "203.0.113.10", "anna", pw, "https://fapp.example.hu/books")
sess := famCookie(w, familySessionCookie)
if w.Code != http.StatusFound || sess == nil || sess.Path != familyCookiePath || sess.MaxAge < 7*24*3600 {
t.Fatalf("sign-in: %d cookie %+v", w.Code, sess)
}
if famCookie(w, sessionCookieName) != nil {
t.Fatal("the family sign-in must never set the dashboard cookie")
}
app := famPass(t, s, sess)
if app.MaxAge < 7*24*3600 || app.Domain != "" {
t.Fatalf("the app cookie must last days and be host-only: %+v", app)
}
if w := famAsk(s, "fapp.example.hu", "GET", "/books", "text/html", app); w.Code != http.StatusOK {
t.Fatalf("the member's app cookie must pass: %d", w.Code)
}
// the same cookies at the dashboard: refused
h := s.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(299) }))
for _, p := range []string{"/launcher", "/api/stacks", "/settings/security"} {
r := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu"+p, nil)
r.AddCookie(&http.Cookie{Name: familySessionCookie, Value: sess.Value})
r.AddCookie(&http.Cookie{Name: familyAppCookie, Value: app.Value})
rw := httptest.NewRecorder()
h.ServeHTTP(rw, r)
if rw.Code == 299 {
t.Fatalf("a family cookie opened the dashboard at %s", p)
}
}
// a cookie for another app host does not pass
if w := famAsk(s, "fapp2.example.hu", "GET", "/", "application/json", app); w.Code == http.StatusOK {
t.Fatal("an app cookie must not pass another host")
}
}
// Rule 3 + exit item 2's logout: a reset, a removal and a logout each end access on the NEXT request.
func TestFamilyGate_ResetRemoveLogoutEndAccessAtOnce(t *testing.T) {
s, st := familyHarness(t)
pw, _ := st.Add("anna")
login := func() *http.Cookie {
w := famLogin(s, "203.0.113.10", "anna", pw, "")
return famCookie(w, familySessionCookie)
}
ok := func(app *http.Cookie) bool {
return famAsk(s, "fapp.example.hu", "GET", "/", "application/json", app).Code == http.StatusOK
}
sess := login()
app := famPass(t, s, sess)
pw, _ = st.Reset("anna")
if ok(app) {
t.Fatal("a reset password must end the member's app access at once")
}
sess = login()
app = famPass(t, s, sess)
r := httptest.NewRequest(http.MethodPost, "https://felhom.example.hu"+familyLogoutPath, nil)
r.AddCookie(sess)
s.ServeFamilyLogout(httptest.NewRecorder(), r)
if ok(app) {
t.Fatal("logout must end the app access minted from that session")
}
sess = login()
app = famPass(t, s, sess)
st.Remove("anna")
if ok(app) {
t.Fatal("a removed member must lose access at once")
}
}
// Rule 2: the sign-in is counted per VISITOR and per NAME — a stranger locks only himself, and a name under attack is
// locked for minutes, never the household.
func TestFamilyGate_LockPerVisitorAndPerName(t *testing.T) {
s, st := familyHarness(t)
now := time.Date(2026, 10, 2, 9, 0, 0, 0, time.UTC)
s.gateClock = func() time.Time { return now }
pa, _ := st.Add("anna")
pb, _ := st.Add("bela")
for i := 0; i < familyVisitorMax; i++ {
famLogin(s, "198.51.100.66", "anna", "wrong", "")
}
if w := famLogin(s, "198.51.100.66", "anna", pa, ""); w.Code != http.StatusTooManyRequests {
t.Fatalf("the stranger's own address must be locked even with the right password: %d", w.Code)
}
if w := famLogin(s, "203.0.113.10", "anna", pa, ""); w.Code != http.StatusOK && w.Code != http.StatusFound {
t.Fatalf("anna from her own address must get in at once: %d", w.Code)
}
// a name under a spread attack (many addresses)
for i := 0; i < familyNameMax; i++ {
famLogin(s, "198.51.100."+string(rune('a'+i)), "bela", "wrong", "")
}
if w := famLogin(s, "203.0.113.20", "bela", pb, ""); w.Code != http.StatusTooManyRequests {
t.Fatalf("a name under a spread attack must be locked: %d", w.Code)
}
if w := famLogin(s, "203.0.113.20", "anna", pa, ""); w.Code == http.StatusTooManyRequests {
t.Fatal("another member must not be locked by bela's attack")
}
now = now.Add(familyNameWindow + time.Second)
if w := famLogin(s, "203.0.113.20", "bela", pb, ""); w.Code == http.StatusTooManyRequests {
t.Fatal("the name lock must pass after its window (minutes, not forever)")
}
}
// The household's dashboard session vouches (decision 46's rule) — as a household session in the family store.
func TestFamilyGate_HouseholdPassesWithItsDashboardSession(t *testing.T) {
s, _ := familyHarness(t)
rd := "https://fapp.example.hu/"
r := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu"+familyStartPath+"?"+url.Values{"rd": {rd}}.Encode(), nil)
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: newTestSession(s)})
w := httptest.NewRecorder()
s.ServeFamilyStart(w, r)
if w.Code != http.StatusFound || !strings.Contains(w.Header().Get("Location"), familyCallbackURI) {
t.Fatalf("the household must pass: %d %q", w.Code, w.Header().Get("Location"))
}
// no session at all: the sign-in page, never a token
r2 := httptest.NewRequest(http.MethodGet, "https://felhom.example.hu"+familyStartPath+"?"+url.Values{"rd": {rd}}.Encode(), nil)
w2 := httptest.NewRecorder()
s.ServeFamilyStart(w2, r2)
if w2.Code != http.StatusOK || strings.Contains(w2.Header().Get("Location"), "t=") || !strings.Contains(w2.Body.String(), `name="password"`) {
t.Fatalf("no session → the sign-in page: %d", w2.Code)
}
}
// A token is one-use, bound to its host, and refused once its session ended.
func TestFamilyGate_TokenOneUseBoundToHost(t *testing.T) {
s, st := familyHarness(t)
st.Add("anna")
sid, _ := st.NewSession("anna")
tok := s.mintFamilyToken("fapp.example.hu", sid, "https://fapp.example.hu/")
if _, _, err := s.takeFamilyToken(tok, "other.example.hu"); err == nil {
t.Fatal("a token for another host must be refused")
}
if _, _, err := s.takeFamilyToken(tok, "fapp.example.hu"); err != nil {
t.Fatalf("first use: %v", err)
}
if _, _, err := s.takeFamilyToken(tok, "fapp.example.hu"); err == nil {
t.Fatal("a token must be one-use")
}
tok2 := s.mintFamilyToken("fapp.example.hu", sid, "https://fapp.example.hu/")
st.EndSession(sid)
if w := famAsk(s, "fapp.example.hu", "GET", familyCallbackURI+"?t="+tok2, "text/html"); w.Code != http.StatusForbidden {
t.Fatalf("a token whose session ended must be refused: %d", w.Code)
}
}
// Rule 4: the family gate leaves the setup gate as it was — the setup-gated app answers the setup gate's handler, the
// family handler knows nothing of it.
func TestFamilyGate_SetupGateUntouched(t *testing.T) {
s, _ := familyHarness(t)
if w := famAsk(s, "gapp.example.hu", "GET", "/", "text/html"); w.Code != http.StatusForbidden {
t.Fatalf("the family handler must not answer for a setup-gated app: %d", w.Code)
}
if w := gateAsk(s, "gapp.example.hu", "GET", "/", "text/html"); w.Code != http.StatusFound {
t.Fatalf("the setup gate must still send a browser to its start: %d", w.Code)
}
if w := gateAsk(s, "fapp.example.hu", "GET", "/", "application/json"); w.Code != http.StatusForbidden {
t.Fatalf("the setup gate must not answer for a family app: %d", w.Code)
}
}
// The family sign-in's messages follow the reader (it has no session, so the language cookie decides).
func TestFamilyGate_MessagesFollowTheReader(t *testing.T) {
s, st := familyHarness(t)
st.Add("anna")
for _, c := range []struct{ lang, want, not string }{{"en", "Wrong name or password.", "Hibás név"}, {"hu", "Hibás név vagy jelszó.", "Wrong name"}} {
form := url.Values{"_ft": {s.familyFormToken()}, "name": {"anna"}, "password": {"x"}}
r := httptest.NewRequest(http.MethodPost, "https://felhom.example.hu"+familyLoginPath, strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.RemoteAddr = "192.168.0.7:1"
r.AddCookie(&http.Cookie{Name: langCookieName, Value: c.lang})
w := httptest.NewRecorder()
s.ServeFamilyLogin(w, r)
body := html.UnescapeString(w.Body.String())
if !strings.Contains(body, c.want) || strings.Contains(body, c.not) {
t.Errorf("%s: want %q not %q", c.lang, c.want, c.not)
}
}
// an expired or forged form token is refused before any password check
form := url.Values{"_ft": {"1.deadbeef"}, "name": {"anna"}, "password": {"x"}}
r := httptest.NewRequest(http.MethodPost, "https://felhom.example.hu"+familyLoginPath, strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.ServeFamilyLogin(w, r)
if w.Code != http.StatusForbidden {
t.Fatalf("a forged form token: %d", w.Code)
}
}
// The dashboard card's acts: the password is in the answer ONCE (JSON), never in the page.
func TestFamilyGate_CardActsAndNoPasswordInThePage(t *testing.T) {
s, st := familyHarness(t)
act := func(a, name string) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodPost, "/family/members/"+a, strings.NewReader(url.Values{"name": {name}}.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.familyMemberActionHandler(w, r, a)
return w
}
w := act("add", "anna")
m := regexp.MustCompile(`"password":"([a-z0-9-]{19})"`).FindStringSubmatch(w.Body.String())
if w.Code != http.StatusOK || m == nil || !st.Verify("anna", m[1]) || w.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("add: %d %s", w.Code, w.Body.String())
}
if w := act("add", "anna"); w.Code != http.StatusConflict {
t.Fatalf("duplicate: %d", w.Code)
}
if w := act("add", "Not Valid"); w.Code != http.StatusBadRequest {
t.Fatalf("bad name: %d", w.Code)
}
if w := act("remove", "anna"); w.Code != http.StatusOK || strings.Contains(w.Body.String(), "password") {
t.Fatalf("remove: %d %s", w.Code, w.Body.String())
}
if w := act("reset", "anna"); w.Code != http.StatusNotFound {
t.Fatalf("reset of a removed member: %d", w.Code)
}
}
@@ -158,6 +158,12 @@ func i18nCasesC() []i18nCase {
{"setupgate", "setupgate", func() map[string]interface{} {
return m{"AppName": "Immich", "Host": "photos.example.hu", "LoginURL": "/login?next=%2F__gate%2Fstart%3Frd%3Dhttps%253A%252F%252Fphotos.example.hu%252F"}
}},
{"familygate", "familygate", func() map[string]interface{} {
return m{"AppName": "Grimmory", "Host": "books.example.hu", "RD": "https://books.example.hu/", "FormToken": "FT"}
}},
{"familygate_signed_in", "familygate", func() map[string]interface{} {
return m{"SignedIn": true, "Notice": "Beléptél. Nyisd meg újra az alkalmazást."}
}},
{"catchall_unknown", "catchall", func() map[string]interface{} {
return m{"ControllerURL": "https://felhom.example.hu", "Status": "unknown", "StatusText": "Ez a cím nem tartozik alkalmazáshoz", "Host": "x.example.hu"}
}},
@@ -477,6 +477,7 @@ var i18nDirectTemplates = map[string]bool{
"launcher_shared": true, "launcher_share_password": true, "catchall": true,
"setupgate": true, // v0.280.0 (decision 46): the gate page a stranger meets
"signupclosed": true, // v0.281.0 (decision 47): an app's sign-up address once closed
"familygate": true, // v0.287.0 (decisions 63/64): the family sign-in page
}
func i18nTestServer(t *testing.T) *Server {
@@ -315,6 +315,7 @@ var i18nDirectPages = []struct{ tmpl, caseName, enProbe string }{
{"catchall", "catchall_app", "Manage app"},
{"setupgate", "setupgate", "waiting for its first setup"},
{"signupclosed", "signupclosed", "You cannot sign up on this app"},
{"familygate", "familygate", "Open this app with your family name and password."},
}
// TestI18nDirectRenderPagesFollowLanguage — with the household language saved as English the page is
+30 -5
View File
@@ -6,6 +6,7 @@ import (
"crypto/sha256"
"encoding/hex"
"fmt"
"gitea.dooplex.hu/admin/felhom-controller/internal/family"
"html/template"
"io"
"io/fs"
@@ -48,11 +49,13 @@ type Server struct {
version string
encKey []byte // AES-256 key for decrypting app.yaml values
// gate / gateClock (v0.280.0, decision 46): the setup gate's key + used tokens; the clock is a test seam.
gate gateState
gateClock func() time.Time
tmpl *template.Template // the Hungarian set (i18n.Default) — every pre-i18n caller renders this
tmplByLang map[string]*template.Template
i18n *i18n.Bundle
gate gateState
fam familyState // v0.287.0: the family gate (family_gate.go)
familyStoreOverride *family.Store // test seam: the family list without a data dir
gateClock func() time.Time
tmpl *template.Template // the Hungarian set (i18n.Default) — every pre-i18n caller renders this
tmplByLang map[string]*template.Template
i18n *i18n.Bundle
// versionPosition (v0.275.0) — where a just-restored app stands against the catalog; nil → the stack
// manager's RestoredVersionPosition. A seam so the restore sentence is testable without a catalog.
@@ -673,6 +676,11 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.sharingShareOffsiteHandler(w, r)
case path == "/settings/notifications" && r.Method == http.MethodGet:
s.settingsNotificationsPageHandler(w, r)
// v0.287.0 (decisions 63/64): the „Család" card's member list and its three acts (session + CSRF like every POST).
case path == "/family/members" && r.Method == http.MethodGet:
s.familyMembersHandler(w, r)
case strings.HasPrefix(path, "/family/members/") && r.Method == http.MethodPost:
s.familyMemberActionHandler(w, r, strings.TrimPrefix(path, "/family/members/"))
case path == "/settings/security" && r.Method == http.MethodGet:
s.settingsSecurityPageHandler(w, r)
case path == "/settings/password" && r.Method == http.MethodPost:
@@ -862,6 +870,23 @@ func (s *Server) CatchAllMiddleware(next http.Handler) http.Handler {
s.ServeGateAuth(w, r)
return
}
if r.URL.Path == familyAuthPath { // v0.287.0 (decisions 63/64): the family gate's forwardAuth, any host
s.ServeFamilyGateAuth(w, r)
return
}
if strings.EqualFold(host, controllerHost) {
switch r.URL.Path { // v0.287.0: the family sign-in pages, outside the dashboard's own auth on purpose
case familyStartPath:
s.ServeFamilyStart(w, r)
return
case familyLoginPath:
s.ServeFamilyLogin(w, r)
return
case familyLogoutPath:
s.ServeFamilyLogout(w, r)
return
}
}
if r.URL.Path == signupClosedPath { // v0.281.0 (decision 47): an app's own sign-up address while closed
s.ServeSignupClosed(w, r)
return
@@ -0,0 +1,48 @@
{{define "familygate"}}
<!DOCTYPE html>
<html lang="{{T "layout.html_lang"}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex">
<title>{{if .AppName}}{{.AppName}} — {{end}}{{T "family_gate.page_title"}}</title>
<link rel="stylesheet" href="/static/style.css?v={{.Version}}">
</head>
<body class="login-body">
<div class="login-card">
<img src="/static/felhom-logo.svg?v={{.Version}}" alt="Felhom.eu" class="login-logo">
{{- if .AppName}}
<h1 class="login-title">{{.AppName}}</h1>
{{- end}}
{{- if .Notice}}
<p class="alert alert-info" id="family-gate-notice">{{.Notice}}</p>
{{- end}}
{{- if .SignedIn}}
<p id="family-gate-signed-in">{{T "family_gate.signed_in_note"}}</p>
<form method="post" action="/__family/logout">
<button type="submit" class="btn btn-outline btn-full">{{T "family_gate.sign_out"}}</button>
</form>
{{- else}}
<p id="family-gate-text">{{T "family_gate.page_body"}}</p>
<form method="post" action="/__family/login" class="login-form">
<input type="hidden" name="_ft" value="{{.FormToken}}">
<input type="hidden" name="rd" value="{{.RD}}">
<div class="form-group">
<label for="family-name">{{T "family_gate.name"}}</label>
<input type="text" id="family-name" name="name" autocomplete="username" autocapitalize="none" required autofocus class="form-control">
</div>
<div class="form-group">
<label for="family-password">{{T "family_gate.password"}}</label>
<input type="password" id="family-password" name="password" autocomplete="current-password" required class="form-control">
</div>
<button type="submit" class="btn btn-primary btn-full">{{T "family_gate.sign_in"}}</button>
</form>
{{- end}}
{{- if .Host}}
<p class="login-footer">{{.Host}}</p>
{{- end}}
<div class="shell-lang">{{template "lang_globe" .}}</div>
</div>
</body>
</html>
{{end}}
@@ -403,6 +403,65 @@ function openDialog(opts){
})();
</script>
<!-- Section: Family (v0.287.0, decisions 63/64) — the family gate's members. Passwords come from the server ONCE,
in the answer to the add/reset press; the page never contains one. -->
<div class="settings-card" id="family-card">
<h3>{{T "family_gate.card_title"}}</h3>
<p class="settings-card-desc">{{T "family_gate.card_desc"}}</p>
<div id="family-list" class="settings-grid"></div>
<p id="family-none" class="form-hint" style="display:none">{{T "family_gate.none"}}</p>
<div id="family-pw" class="alert alert-info" style="display:none">
{{T "family_gate.pw_once"}} <strong id="family-pw-name"></strong> — <span id="family-pw-value" class="mono"></span>
</div>
<form id="family-add" class="inline-form" onsubmit="familyAct(event, 'add', document.getElementById('family-new').value)">
<input type="text" id="family-new" class="form-control" placeholder="{{T "family_gate.name_placeholder"}}" autocapitalize="none" required>
<button type="submit" class="btn btn-sm btn-primary">{{T "family_gate.add"}}</button>
</form>
<span id="family-err" class="form-hint" style="display:none;color:var(--red)"></span>
</div>
<script>
(function () {
var T = {reset: '{{T "family_gate.reset"}}', remove: '{{T "family_gate.remove"}}',
cReset: '{{T "family_gate.confirm_reset"}}', cRemove: '{{T "family_gate.confirm_remove"}}', err: '{{T "family_gate.error"}}'};
function render(members) {
var list = document.getElementById('family-list');
list.textContent = '';
document.getElementById('family-none').style.display = members.length ? 'none' : '';
members.forEach(function (n) {
var row = document.createElement('div'); row.className = 'settings-row'; row.setAttribute('data-family-member', n);
var label = document.createElement('span'); label.className = 'settings-label mono'; label.textContent = n;
var val = document.createElement('span'); val.className = 'settings-value';
[['reset', T.reset, T.cReset, 'btn-outline'], ['remove', T.remove, T.cRemove, 'btn-danger']].forEach(function (a) {
var b = document.createElement('button'); b.type = 'button'; b.className = 'btn btn-xs ' + a[3]; b.textContent = a[1];
b.addEventListener('click', function () { felhomConfirm(b, a[2], function () { familyAct(null, a[0], n); }); });
val.appendChild(b);
});
row.appendChild(label); row.appendChild(val); list.appendChild(row);
});
}
window.familyAct = function (e, action, name) {
if (e) e.preventDefault();
var err = document.getElementById('family-err'); err.style.display = 'none';
var body = new URLSearchParams(); body.set('name', name);
fetch('/family/members/' + action, {method: 'POST', credentials: 'same-origin',
headers: {'X-CSRF-Token': '{{.CSRFToken}}', 'Content-Type': 'application/x-www-form-urlencoded'}, body: body})
.then(function (r) { return r.json(); }).then(function (j) {
if (!j.ok) { err.textContent = j.error || T.err; err.style.display = 'inline'; return; }
render(j.data.members || []);
var box = document.getElementById('family-pw');
if (j.data.password) {
document.getElementById('family-pw-name').textContent = j.data.name;
document.getElementById('family-pw-value').textContent = j.data.password;
box.style.display = '';
} else { box.style.display = 'none'; }
if (action === 'add') document.getElementById('family-new').value = '';
}).catch(function () { err.textContent = T.err; err.style.display = 'inline'; });
};
fetch('/family/members', {credentials: 'same-origin'}).then(function (r) { return r.json(); })
.then(function (j) { if (j.ok) render(j.data.members || []); });
})();
</script>
<!-- Section: Recovery Info -->
{{if .HasRetrievalPassword}}
<div class="settings-card">
@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex">
<title>Grimmory — Családi belépés</title>
<link rel="stylesheet" href="/static/style.css?v=test">
</head>
<body class="login-body">
<div class="login-card">
<img src="/static/felhom-logo.svg?v=test" alt="Felhom.eu" class="login-logo">
<h1 class="login-title">Grimmory</h1>
<p id="family-gate-text">Ezt az alkalmazást a családi neveddel és jelszavaddal nyithatod meg.</p>
<form method="post" action="/__family/login" class="login-form">
<input type="hidden" name="_ft" value="FT">
<input type="hidden" name="rd" value="https://books.example.hu/">
<div class="form-group">
<label for="family-name">Neved</label>
<input type="text" id="family-name" name="name" autocomplete="username" autocapitalize="none" required autofocus class="form-control">
</div>
<div class="form-group">
<label for="family-password">Jelszavad</label>
<input type="password" id="family-password" name="password" autocomplete="current-password" required class="form-control">
</div>
<button type="submit" class="btn btn-primary btn-full">Belépés</button>
</form>
<p class="login-footer">books.example.hu</p>
<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>
</body>
</html>
@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex">
<title>Családi belépés</title>
<link rel="stylesheet" href="/static/style.css?v=test">
</head>
<body class="login-body">
<div class="login-card">
<img src="/static/felhom-logo.svg?v=test" alt="Felhom.eu" class="login-logo">
<p class="alert alert-info" id="family-gate-notice">Beléptél. Nyisd meg újra az alkalmazást.</p>
<p id="family-gate-signed-in">Be vagy lépve a családi fiókoddal.</p>
<form method="post" action="/__family/logout">
<button type="submit" class="btn btn-outline btn-full">Kilépés</button>
</form>
<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>
</body>
</html>
@@ -562,6 +562,64 @@ function openDialog(opts){
</script>
<div class="settings-card" id="family-card">
<h3>Család</h3>
<p class="settings-card-desc">A családtagok a saját nevükkel és jelszavukkal lépnek be a családi kapus alkalmazásokba. Ezzel a vezérlőpultot nem érik el.</p>
<div id="family-list" class="settings-grid"></div>
<p id="family-none" class="form-hint" style="display:none">Még nincs családtag.</p>
<div id="family-pw" class="alert alert-info" style="display:none">
A jelszó csak most látszik. Írd fel, vagy add át a családtagnak: <strong id="family-pw-name"></strong> — <span id="family-pw-value" class="mono"></span>
</div>
<form id="family-add" class="inline-form" onsubmit="familyAct(event, 'add', document.getElementById('family-new').value)">
<input type="text" id="family-new" class="form-control" placeholder="pl. anna" autocapitalize="none" required>
<button type="submit" class="btn btn-sm btn-primary">Családtag hozzáadása</button>
</form>
<span id="family-err" class="form-hint" style="display:none;color:var(--red)"></span>
</div>
<script>
(function () {
var T = {reset: 'Új jelszó', remove: 'Eltávolítás',
cReset: 'Új jelszót adsz? A régi azonnal megszűnik.', cRemove: 'Eltávolítod? A belépései azonnal megszűnnek.', err: 'Nem sikerült. Próbáld újra.'};
function render(members) {
var list = document.getElementById('family-list');
list.textContent = '';
document.getElementById('family-none').style.display = members.length ? 'none' : '';
members.forEach(function (n) {
var row = document.createElement('div'); row.className = 'settings-row'; row.setAttribute('data-family-member', n);
var label = document.createElement('span'); label.className = 'settings-label mono'; label.textContent = n;
var val = document.createElement('span'); val.className = 'settings-value';
[['reset', T.reset, T.cReset, 'btn-outline'], ['remove', T.remove, T.cRemove, 'btn-danger']].forEach(function (a) {
var b = document.createElement('button'); b.type = 'button'; b.className = 'btn btn-xs ' + a[3]; b.textContent = a[1];
b.addEventListener('click', function () { felhomConfirm(b, a[2], function () { familyAct(null, a[0], n); }); });
val.appendChild(b);
});
row.appendChild(label); row.appendChild(val); list.appendChild(row);
});
}
window.familyAct = function (e, action, name) {
if (e) e.preventDefault();
var err = document.getElementById('family-err'); err.style.display = 'none';
var body = new URLSearchParams(); body.set('name', name);
fetch('/family/members/' + action, {method: 'POST', credentials: 'same-origin',
headers: {'X-CSRF-Token': 'tok', 'Content-Type': 'application/x-www-form-urlencoded'}, body: body})
.then(function (r) { return r.json(); }).then(function (j) {
if (!j.ok) { err.textContent = j.error || T.err; err.style.display = 'inline'; return; }
render(j.data.members || []);
var box = document.getElementById('family-pw');
if (j.data.password) {
document.getElementById('family-pw-name').textContent = j.data.name;
document.getElementById('family-pw-value').textContent = j.data.password;
box.style.display = '';
} else { box.style.display = 'none'; }
if (action === 'add') document.getElementById('family-new').value = '';
}).catch(function () { err.textContent = T.err; err.style.display = 'inline'; });
};
fetch('/family/members', {credentials: 'same-origin'}).then(function (r) { return r.json(); })
.then(function (j) { if (j.ok) render(j.data.members || []); });
})();
</script>
<div class="settings-card">
<h3>Vészhelyzeti információk</h3>
@@ -493,6 +493,64 @@ function openDialog(opts){
</script>
<div class="settings-card" id="family-card">
<h3>Család</h3>
<p class="settings-card-desc">A családtagok a saját nevükkel és jelszavukkal lépnek be a családi kapus alkalmazásokba. Ezzel a vezérlőpultot nem érik el.</p>
<div id="family-list" class="settings-grid"></div>
<p id="family-none" class="form-hint" style="display:none">Még nincs családtag.</p>
<div id="family-pw" class="alert alert-info" style="display:none">
A jelszó csak most látszik. Írd fel, vagy add át a családtagnak: <strong id="family-pw-name"></strong> — <span id="family-pw-value" class="mono"></span>
</div>
<form id="family-add" class="inline-form" onsubmit="familyAct(event, 'add', document.getElementById('family-new').value)">
<input type="text" id="family-new" class="form-control" placeholder="pl. anna" autocapitalize="none" required>
<button type="submit" class="btn btn-sm btn-primary">Családtag hozzáadása</button>
</form>
<span id="family-err" class="form-hint" style="display:none;color:var(--red)"></span>
</div>
<script>
(function () {
var T = {reset: 'Új jelszó', remove: 'Eltávolítás',
cReset: 'Új jelszót adsz? A régi azonnal megszűnik.', cRemove: 'Eltávolítod? A belépései azonnal megszűnnek.', err: 'Nem sikerült. Próbáld újra.'};
function render(members) {
var list = document.getElementById('family-list');
list.textContent = '';
document.getElementById('family-none').style.display = members.length ? 'none' : '';
members.forEach(function (n) {
var row = document.createElement('div'); row.className = 'settings-row'; row.setAttribute('data-family-member', n);
var label = document.createElement('span'); label.className = 'settings-label mono'; label.textContent = n;
var val = document.createElement('span'); val.className = 'settings-value';
[['reset', T.reset, T.cReset, 'btn-outline'], ['remove', T.remove, T.cRemove, 'btn-danger']].forEach(function (a) {
var b = document.createElement('button'); b.type = 'button'; b.className = 'btn btn-xs ' + a[3]; b.textContent = a[1];
b.addEventListener('click', function () { felhomConfirm(b, a[2], function () { familyAct(null, a[0], n); }); });
val.appendChild(b);
});
row.appendChild(label); row.appendChild(val); list.appendChild(row);
});
}
window.familyAct = function (e, action, name) {
if (e) e.preventDefault();
var err = document.getElementById('family-err'); err.style.display = 'none';
var body = new URLSearchParams(); body.set('name', name);
fetch('/family/members/' + action, {method: 'POST', credentials: 'same-origin',
headers: {'X-CSRF-Token': 'tok', 'Content-Type': 'application/x-www-form-urlencoded'}, body: body})
.then(function (r) { return r.json(); }).then(function (j) {
if (!j.ok) { err.textContent = j.error || T.err; err.style.display = 'inline'; return; }
render(j.data.members || []);
var box = document.getElementById('family-pw');
if (j.data.password) {
document.getElementById('family-pw-name').textContent = j.data.name;
document.getElementById('family-pw-value').textContent = j.data.password;
box.style.display = '';
} else { box.style.display = 'none'; }
if (action === 'add') document.getElementById('family-new').value = '';
}).catch(function () { err.textContent = T.err; err.style.display = 'inline'; });
};
fetch('/family/members', {credentials: 'same-origin'}).then(function (r) { return r.json(); })
.then(function (j) { if (j.ok) render(j.data.members || []); });
})();
</script>
</main>