Mealie anbinden: Passwort einmal, danach nur noch ein Dauertoken

Unter Einstellungen gibt es jetzt eine Karte "Mealie (Rezepte)": Adresse,
Benutzername, Passwort. Vorrania meldet sich damit EINMAL an
(POST /api/auth/token), laesst sich von Mealie ueber
POST /api/users/api-tokens ein Dauertoken ausstellen und verwirft das
Passwort sofort - es steht in keiner Zeile der Datenbank, in keiner
Antwort und in keinem Fehlertext. Wer sein Passwort gar nicht erst
eintippen will, haekelt "Ich gebe lieber selbst ein Token ein" an.

Das Token liegt NICHT in den Einstellungen: GET /settings liefert jedem
angemeldeten Nutzer alle Zeilen im Klartext. Eine Ausschlussliste dort
waere beim naechsten Geheimnis vergessen, deshalb eine eigene Tabelle ohne
jeden Leseweg - setzen, loeschen, und die Auskunft "ist gesetzt". Was
keinen Ausgang hat, kann nicht durchrutschen. Ein Test prueft genau das.

Der Dienst folgt services/oidc.py, nicht off.py: Bei Open Food Facts ist
"kein Treffer" der Normalfall und wird still zu None, hier ist "Mealie
antwortet nicht" ein Fehler, den der Nutzer sehen muss. Die Kennungen
trennen auch, was verschieden ist - ein falsches Passwort (400) ist etwas
anderes als ein weggetretener Server (502).

Mealie 1.x legt die Haushalts-Routen unter /api/groups/, 2.x unter
/api/households/. Erkannt wird das zur Laufzeit mit einer Anfrage und
dann eine Stunde gepuffert; ueber die Versionsnummer waere es unzuverlaessig,
die melden Installationen uneinheitlich.

Rezeptliste und -abgleich kommen als Naechstes; dies ist der Teil, mit dem
sich der Server ueberhaupt erst verbinden laesst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-02 12:54:43 +02:00
co-authored by Claude Opus 5
parent 37ab1ba47d
commit 7d8a54a3fc
9 changed files with 942 additions and 0 deletions
+7
View File
@@ -444,6 +444,13 @@ export const api = {
resetAll: () => request("/maintenance/reset/all", { method: "POST" }),
// Einstellungen
// Mealie: Adresse und Token nimmt der Server entgegen, gibt sie aber nie
// zurueck – die Oberflaeche erfaehrt nur „eingerichtet ja/nein".
mealieStatus: (pruefen = false) => request(`/mealie/status${pruefen ? "?pruefen=true" : ""}`),
mealieVerfuegbar: () => request("/mealie/available"),
mealieKonfigurieren: (body) => request("/mealie/config", { method: "PUT", body }),
mealieToken: (body) => request("/mealie/token", { method: "PUT", body }),
mealieTrennen: () => request("/mealie/config", { method: "DELETE" }),
listSettings: () => request("/settings"),
setSetting: (key, value) =>
request(`/settings/${key}?value=${encodeURIComponent(value)}`, { method: "PUT" }),
+138
View File
@@ -171,6 +171,7 @@ export default function Settings() {
<button className="btn primary" disabled={busy}>{busy ? "Speichern…" : "Speichern"}</button>
</form>
<MealieCard />
<ApiTokensCard />
<DangerZoneCard />
</div>
@@ -178,6 +179,143 @@ export default function Settings() {
}
/** Langlebige Tokens für externe Zugriffe (Home Assistant, Skripte). */
/**
* Verbindung zu einer selbst gehosteten Mealie-Instanz.
*
* Das Passwort wird nicht gespeichert: Vorrania meldet sich damit einmal an,
* laesst sich von Mealie ein Dauertoken ausstellen und vergisst es. Deshalb
* wird das Feld nach dem Speichern geleert und nie wieder gefuellt – es gibt
* nichts, was man zurueckladen koennte.
*/
function MealieCard() {
const toast = useToast();
const confirm = useConfirm();
const [status, setStatus] = useState(null);
const [url, setUrl] = useState("");
const [benutzer, setBenutzer] = useState("");
const [passwort, setPasswort] = useState("");
const [token, setToken] = useState("");
const [mitToken, setMitToken] = useState(false);
const [busy, setBusy] = useState(false);
const [fehler, setFehler] = useState(null);
async function laden(pruefen = false) {
try {
const s = await api.mealieStatus(pruefen);
setStatus(s);
if (s.base_url) setUrl(s.base_url);
} catch (err) { setFehler(err.message); }
}
useEffect(() => { laden(); }, []);
async function verbinden(e) {
e.preventDefault();
setBusy(true);
setFehler(null);
try {
const s = mitToken
? await api.mealieToken({ base_url: url, token })
: await api.mealieKonfigurieren({ base_url: url, username: benutzer, password: passwort });
setStatus(s);
setPasswort(""); // bewusst sofort leeren – es ist nirgends gespeichert
setToken("");
toast(s.reachable ? "Mit Mealie verbunden." : "Gespeichert – Mealie antwortet aber nicht.");
} catch (err) { setFehler(err.message); } finally { setBusy(false); }
}
async function trennen() {
const ok = await confirm({
title: "Verbindung zu Mealie lösen?",
message: "Adresse und Token werden hier entfernt. In Mealie selbst bleibt das "
+ "Token bestehen – dort unter Profil ▸ API-Tokens löschbar.",
confirmLabel: "Trennen", danger: true,
});
if (!ok) return;
try {
await api.mealieTrennen();
setStatus({ configured: false, base_url: "", token_set: false });
setUrl(""); setBenutzer(""); setPasswort(""); setToken("");
toast("Verbindung gelöst.");
} catch (err) { setFehler(err.message); }
}
return (
<form className="card form-narrow" onSubmit={verbinden} style={{ marginTop: "var(--sp-4)" }}>
<div className="card-head"><Icon name="food" /><h2>Mealie (Rezepte)</h2></div>
<p className="muted small mt-0">
Verbindet Vorrania mit einer selbst gehosteten Mealie-Instanz, um Rezepte
gegen den Bestand zu prüfen. Dein Passwort wird <strong>nicht</strong>{" "}
gespeichert: Vorrania meldet sich damit einmal an, lässt sich ein
Dauertoken ausstellen und vergisst es.
</p>
{status?.configured && (
<div className={`alert ${status.reachable === false ? "error" : "ok"}`}>
<Icon name={status.reachable === false ? "alert" : "check"} size={16} />
<span>
{status.base_url}
{status.version ? ` · Mealie ${status.version}` : ""}
{" · "}
{status.reachable === false
? (status.error || "antwortet nicht")
: status.reachable === true ? "erreichbar" : "Token gesetzt"}
</span>
</div>
)}
{fehler && <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>}
<label>
Adresse
<input value={url} placeholder="https://mealie.example.com"
onChange={(e) => setUrl(e.target.value)} />
</label>
{!mitToken ? (
<div className="row">
<label className="grow">
Benutzername in Mealie
<input value={benutzer} onChange={(e) => setBenutzer(e.target.value)} />
</label>
<label className="grow">
Passwort
<input type="password" value={passwort} autoComplete="new-password"
onChange={(e) => setPasswort(e.target.value)} />
</label>
</div>
) : (
<label>
Dauertoken aus Mealie
<input value={token} onChange={(e) => setToken(e.target.value)}
placeholder="in Mealie unter Profil ▸ API-Tokens erzeugen" />
</label>
)}
<label className="check-inline">
<input type="checkbox" checked={mitToken}
onChange={(e) => setMitToken(e.target.checked)} />
Ich gebe lieber selbst ein Token ein
</label>
<div className="field-inline" style={{ flexWrap: "wrap", marginBottom: 0 }}>
<button className="btn primary" disabled={busy || !url}>
<Icon name="check" size={16} />{busy ? "Verbinde…" : "Verbinden"}
</button>
{status?.configured && (
<>
<button type="button" className="btn" onClick={() => laden(true)}>
Verbindung prüfen
</button>
<button type="button" className="btn danger" onClick={trennen}>
Trennen
</button>
</>
)}
</div>
</form>
);
}
function ApiTokensCard() {
const confirm = useConfirm();
const [tokens, setTokens] = useState([]);