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>
216 lines
8.1 KiB
React
216 lines
8.1 KiB
React
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>
|
||
);
|
||
}
|