Files
certain/web/src/pages/settings/Geraetemarken.jsx
T
GuidoandClaude Opus 5 f3d80eb6bc feat: Konto-Nummern in der Kurzbefehl-Anleitung
Fuer den Datei-Kurzbefehl braucht man die account_id, und die stand nirgends
in der Oberflaeche – man haette sie ueber die API nachschlagen muessen. Jetzt
listet die Anleitung Name und Nummer je Konto, markierbar zum Kopieren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 22:18:16 +02:00

216 lines
8.1 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../../api";
import { useToast } from "../../toast";
import { useConfirm } from "../../confirm";
import Icon from "../../components/Icon";
import IconChip from "../../components/IconChip";
/**
* Gerätemarken für die Schnellerfassung per iOS-Kurzbefehl.
*
* Ein Kurzbefehl kann sich nicht anmelden, er braucht ein Zugangsmerkmal. Die
* Marke darf ausschliesslich erfassen – nicht lesen, nicht ändern. Geht das
* Telefon verloren, ist damit kein Datenabfluss möglich.
*
* Den Kurzbefehl fertig zum Download anzubieten geht nicht: Seit iOS 15 müssen
* solche Dateien signiert sein, und signieren kann nur ein Mac mit Apple-Konto.
* Deshalb steht hier eine Bauanleitung mit kopierfertigen Werten.
*/
export default function Geraetemarken() {
const { t } = useTranslation();
const toast = useToast();
const confirm = useConfirm();
const [marken, setMarken] = useState([]);
const [konten, setKonten] = useState([]);
const [name, setName] = useState("");
const [konto, setKonto] = useState("");
const [frisch, setFrisch] = useState(null); // { token, name } – nur einmal sichtbar
const [busy, setBusy] = useState(false);
const basis = useMemo(() => window.location.origin, []);
async function laden() {
try {
const [m, k] = await Promise.all([api.listTokens(), api.listAccounts()]);
setMarken(m);
setKonten(k);
if (!konto && k.length) setKonto(String(k[0].id));
} catch (e) {
toast(e.message, "error");
}
}
useEffect(() => { laden(); }, []);
async function anlegen(e) {
e.preventDefault();
if (!name.trim()) return;
setBusy(true);
try {
const neu = await api.createToken({
name: name.trim(),
default_account_id: konto ? Number(konto) : null,
});
setFrisch(neu);
setName("");
laden();
} catch (e) {
toast(e.message, "error");
} finally {
setBusy(false);
}
}
async function widerrufen(m) {
if (!(await confirm({
title: t("tokens.revoke"),
message: t("tokens.revokeConfirm", { name: m.name }),
danger: true,
confirmLabel: t("tokens.revoke"),
}))) return;
try {
await api.deleteToken(m.id);
laden();
} catch (e) {
toast(e.message, "error");
}
}
async function kopieren(text) {
try {
await navigator.clipboard.writeText(text);
toast(t("tokens.copied"));
} catch {
toast(t("tokens.copyFailed"), "error");
}
}
const kontoName = (id) => konten.find((k) => k.id === id)?.name;
return (
<div className="card">
<div className="card-head">
<Icon name="smartphone" size={18} />
<h2>{t("tokens.title")}</h2>
</div>
<p className="muted small">{t("tokens.intro")}</p>
{/* Gestapelt statt nebeneinander: Die Karte steht in einer schmalen
Spalte, waagerecht quetschen sich Beschriftung und Feld ineinander. */}
<form onSubmit={anlegen}>
<label className="field">
<span>{t("tokens.name")}</span>
<input value={name} placeholder={t("tokens.namePlaceholder")}
onChange={(e) => setName(e.target.value)} />
</label>
<label className="field">
<span>{t("tokens.defaultAccount")}</span>
<select value={konto} onChange={(e) => setKonto(e.target.value)}>
{konten.map((k) => <option key={k.id} value={k.id}>{k.name}</option>)}
</select>
</label>
<button className="btn primary" disabled={busy || !name.trim()}>
<Icon name="plus" size={15} /> {t("tokens.create")}
</button>
</form>
{frisch && (
<div className="alert" style={{ display: "block", marginTop: "var(--sp-3)" }}>
<strong>{t("tokens.onceOnly")}</strong>
<div className="input-with-button" style={{ marginTop: "var(--sp-2)" }}>
<input readOnly value={frisch.token} className="selectable" />
<button className="btn sm" onClick={() => kopieren(frisch.token)}>
<Icon name="copy" size={14} /> {t("tokens.copy")}
</button>
</div>
<details style={{ marginTop: "var(--sp-3)" }} open>
<summary>{t("tokens.guideTitle")}</summary>
<p className="muted small">{t("tokens.guideNoDownload")}</p>
<ol className="muted small" style={{ paddingLeft: "1.2em" }}>
<li>{t("tokens.step1")}</li>
<li>{t("tokens.step2")}</li>
<li>{t("tokens.step3")}</li>
<li>
{t("tokens.step4")}
<div className="input-with-button" style={{ marginTop: 4 }}>
<input readOnly className="selectable" value={`${basis}/api/quick/booking`} />
<button type="button" className="btn sm"
onClick={() => kopieren(`${basis}/api/quick/booking`)}>
<Icon name="copy" size={14} />
</button>
</div>
<div className="input-with-button" style={{ marginTop: 4 }}>
<input readOnly className="selectable"
value={`X-Certain-Token: ${frisch.token}`} />
<button type="button" className="btn sm"
onClick={() => kopieren(`X-Certain-Token: ${frisch.token}`)}>
<Icon name="copy" size={14} />
</button>
</div>
</li>
<li>{t("tokens.step5")}</li>
</ol>
<p className="muted small">{t("tokens.guideHomeScreen")}</p>
<p className="muted small">{t("tokens.guideAutomation")}</p>
</details>
<details style={{ marginTop: "var(--sp-3)" }}>
<summary>{t("tokens.fileGuideTitle")}</summary>
<p className="muted small">{t("tokens.fileGuideIntro")}</p>
<ol className="muted small" style={{ paddingLeft: "1.2em" }}>
<li>{t("tokens.fileStep1")}</li>
<li>{t("tokens.fileStep2")}</li>
<li>
{t("tokens.fileStep3")}
<div className="input-with-button" style={{ marginTop: 4 }}>
<input readOnly className="selectable" value={`${basis}/api/quick/statement`} />
<button type="button" className="btn sm"
onClick={() => kopieren(`${basis}/api/quick/statement`)}>
<Icon name="copy" size={14} />
</button>
</div>
</li>
<li>{t("tokens.fileStep4")}</li>
</ol>
<p className="muted small" style={{ marginBottom: 4 }}>
<strong>{t("tokens.accountIds")}</strong>
</p>
<ul className="muted small selectable" style={{ paddingLeft: "1.2em", margin: 0 }}>
{konten.map((k) => (
<li key={k.id}>{k.name}: <strong>{k.id}</strong></li>
))}
</ul>
<p className="muted small">{t("tokens.fileGuideNote")}</p>
</details>
<button className="btn sm ghost" style={{ marginTop: "var(--sp-2)" }}
onClick={() => setFrisch(null)}>
{t("tokens.hide")}
</button>
</div>
)}
{marken.length > 0 && (
<div className="account-list" style={{ marginTop: "var(--sp-3)" }}>
{marken.map((m) => (
<div className="account-row" key={m.id}>
<IconChip name="smartphone" color="#6b7480" size={26} />
<div className="named-text grow">
<div><strong>{m.name}</strong></div>
<div className="named-sub">
{m.default_account_id ? kontoName(m.default_account_id) : t("tokens.noDefault")}
{" · "}
{m.last_used_at ? t("tokens.lastUsed") : t("tokens.neverUsed")}
</div>
</div>
<button className="btn sm danger" onClick={() => widerrufen(m)}>
<Icon name="trash" size={15} /> {t("tokens.revoke")}
</button>
</div>
))}
</div>
)}
</div>
);
}