Langlebige API-Tokens fuer externe Zugriffe (Home Assistant)
Die Einkaufsliste war ueber /shopping-list und /shopping-list/groups schon abrufbar, aber nur mit einem Login-JWT, das nach 7 Tagen ablaeuft - fuer HA unpraktisch. - Neue Tabelle api_tokens (Name, SHA-256-Hash, Ersteller, zuletzt genutzt). Der Klartext wird nur einmal beim Anlegen zurueckgegeben. - get_current_user akzeptiert jetzt JWT ODER API-Token im Authorization-Header; bei Nutzung wird last_used_at fortgeschrieben. - Endpunkte /api-tokens (Admin): auflisten, erzeugen, widerrufen. - Einstellungsseite: Tokens verwalten inkl. einmaliger Anzeige und einem aufklappbaren Konfigurationsbeispiel fuer Home Assistant. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -158,6 +158,11 @@ export const api = {
|
||||
updateUser: (id, body) => request(`/users/${id}`, { method: "PATCH", body }),
|
||||
deleteUser: (id) => request(`/users/${id}`, { method: "DELETE" }),
|
||||
|
||||
// API-Tokens (externe Zugriffe)
|
||||
listApiTokens: () => request("/api-tokens"),
|
||||
createApiToken: (body) => request("/api-tokens", { method: "POST", body }),
|
||||
deleteApiToken: (id) => request(`/api-tokens/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Einstellungen
|
||||
listSettings: () => request("/settings"),
|
||||
setSetting: (key, value) =>
|
||||
|
||||
@@ -82,6 +82,114 @@ export default function Settings() {
|
||||
|
||||
<button className="btn primary" disabled={busy}>{busy ? "Speichern…" : "Speichern"}</button>
|
||||
</form>
|
||||
|
||||
<ApiTokensCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Langlebige Tokens für externe Zugriffe (Home Assistant, Skripte). */
|
||||
function ApiTokensCard() {
|
||||
const [tokens, setTokens] = useState([]);
|
||||
const [name, setName] = useState("");
|
||||
const [fresh, setFresh] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setTokens(await api.listApiTokens());
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function create(e) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
try {
|
||||
const created = await api.createApiToken({ name });
|
||||
setFresh(created.token);
|
||||
setName("");
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(token) {
|
||||
if (!confirm(`Token "${token.name}" widerrufen? Zugriffe damit funktionieren sofort nicht mehr.`)) return;
|
||||
try {
|
||||
await api.deleteApiToken(token.id);
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="card-head"><Icon name="users" /><h2>API-Zugriff (z.B. Home Assistant)</h2></div>
|
||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||||
|
||||
<p className="muted small mt-0">
|
||||
Mit einem Token lässt sich die Einkaufsliste dauerhaft abfragen, ohne dass eine
|
||||
Anmeldung abläuft. Der Token wird nur einmal angezeigt – danach ist nur noch
|
||||
sein Fingerabdruck gespeichert.
|
||||
</p>
|
||||
|
||||
{fresh && (
|
||||
<div className="alert ok">
|
||||
<Icon name="check" size={16} />
|
||||
<span>
|
||||
Neuer Token – bitte jetzt kopieren:
|
||||
<div><code className="strong" style={{ wordBreak: "break-all" }}>{fresh}</code></div>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="simple-list">
|
||||
{tokens.map((t) => (
|
||||
<li key={t.id}>
|
||||
<span>
|
||||
<strong>{t.name}</strong>
|
||||
<span className="muted small">
|
||||
{" "}· angelegt {new Date(t.created_at).toLocaleDateString("de-DE")}
|
||||
{t.last_used_at
|
||||
? ` · zuletzt genutzt ${new Date(t.last_used_at).toLocaleDateString("de-DE")}`
|
||||
: " · noch nicht genutzt"}
|
||||
</span>
|
||||
</span>
|
||||
<button className="btn-icon danger" onClick={() => remove(t)} title="Widerrufen">
|
||||
<Icon name="trash" size={16} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{tokens.length === 0 && <li className="muted small">Noch keine Tokens.</li>}
|
||||
</ul>
|
||||
|
||||
<form className="field-inline" onSubmit={create}>
|
||||
<input placeholder="Name, z.B. Home Assistant" value={name}
|
||||
onChange={(e) => setName(e.target.value)} required />
|
||||
<button className="btn"><Icon name="plus" size={16} />Token erzeugen</button>
|
||||
</form>
|
||||
|
||||
<details style={{ marginTop: "var(--sp-3)" }}>
|
||||
<summary className="muted small">Beispiel für Home Assistant</summary>
|
||||
<pre style={{ overflowX: "auto", fontSize: "0.78rem" }}>{`# configuration.yaml
|
||||
rest:
|
||||
- resource: http://DEIN-SERVER:8080/api/shopping-list
|
||||
scan_interval: 900
|
||||
headers:
|
||||
Authorization: "Bearer DEIN_TOKEN"
|
||||
sensor:
|
||||
- name: "Einkaufsliste Anzahl"
|
||||
value_template: "{{ value_json | length }}"
|
||||
json_attributes_path: "$"
|
||||
- name: "Einkaufsliste"
|
||||
value_template: "{{ value_json | map(attribute='name') | join(', ') }}"`}</pre>
|
||||
</details>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user