Manage public keys, over Stalwart's x:PublicKey registry
A new Settings section, next to Identities & signatures: list, add, rename and remove the OpenPGP public keys and S/MIME certificates published on this account. Only public material -- no private key is stored, requested or sent by any of this. This is PR #67 revived. That branch was built against 0.16.19, closed unmerged on 2026-08-26, and shares no ancestry with main after the email scrub, so it is ported rather than rebased: the four files it added are carried over, the three it edited are applied by hand, and everything it claimed was re-probed against the live 0.16.20 on 2026-09-05. The i18n work is new -- nine catalogues landed on 2026-08-31, after that branch was written. What the re-probe confirmed, unchanged from 0.16.19: - An ordinary user may read *and* write their own keys, though the permissions table lists every sysPublicKey* permission as administrative. get and query both answered for a normal account, and a malformed create came back invalidProperties naming `key` rather than forbidden -- a rejection of the key, not of the person. - The server parses the key and says precisely what is wrong. So ihasmail does not validate key material; the server's message is shown verbatim, as password-policy rejections already are. - urn:stalwart:jmap is still absent from the session's top-level capabilities and present per-account, so the check that reads all three places is still the one that works. What it added, none of which was known before: - A key can parse perfectly and still be refused, with different words: a sign-and-certify key with no encryption subkey -- what `gpg --quick-generate-key` produces -- gets "Could not find any suitable keys in OpenPGP public key". That is the rejection somebody exporting from GnuPG will actually meet, and it is not a paste error, so collapsing both to "invalid key" would send them back to the clipboard for a problem that is in the key. - emailAddresses comes back as {} when empty -- an object where a JMAP list property should be an array. It type-checks, then throws in join() while the list renders. normalize() checked the shape already; there is now a test saying why, and the mock answers {} the same way, because one that helpfully returned [] would let that crash ship. - A create answers with the id alone, no createdAt, so adding a key reloads rather than believing the response. - destroy works and leaves the registry empty. PR #67 shipped that path untested -- its live probe was refused before anything was created, so there was nothing to destroy. - Patching `key` is allowed by the server. The mock still refuses it, now deliberately rather than for want of evidence: ihasmail replaces a key by adding one and removing the old, which keeps createdAt meaning what it says. x:EncryptionAtRest still does not exist on 0.16.20 -- asking for it is an unknownMethod. encryptionAtRest is a field on x:AccountSettings, and its value is a typed object ({"@type":"Disabled"}) rather than the bare string ROADMAP described. Nothing here writes it. An empty description is now sent as empty rather than filled in with "Key". The description is stored on the server, so a default invented in the client would be whichever language the adder happened to be using; the list labels a blank one at render time instead. Verified in a browser against the mock, not only in tests: both rejections reach the toast in the server's own words with the form still filled in, a good key renders its card, the kind is labelled from the armour header, renaming persists, removing asks first and empties the list, and the whole section reads correctly in German.
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const call = vi.fn();
|
||||
vi.mock("@/jmap/client", async (orig) => {
|
||||
const real = await orig<typeof import("@/jmap/client")>();
|
||||
return { ...real, client: { ...real.client, call: (...a: unknown[]) => call(...a), hasCapabilityAnywhere: () => true } };
|
||||
});
|
||||
vi.mock("@/store/session", () => ({
|
||||
useSession: { getState: () => ({ session: { primaryAccounts: { "urn:stalwart:jmap": "v" } }, accountFor: () => "v" }) },
|
||||
}));
|
||||
|
||||
const { isExpired, keyExcerpt, keyKind, keyKindLabel, listPublicKeys } = await import("@/lib/publicKeys");
|
||||
|
||||
/**
|
||||
* These read a key without parsing one. Stalwart parses it — with a real
|
||||
* OpenPGP implementation that says precisely what is wrong — so anything
|
||||
* checked here could only be a second opinion, and the one that counts would
|
||||
* still be the server's. What is left is labelling: which sort of key this is,
|
||||
* whether its stated expiry has passed, and enough of the body to tell two
|
||||
* keys apart in a list.
|
||||
*/
|
||||
|
||||
const PGP = "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBGAbCdEFGh\nijKLmnOPqrSt\n=aBc1\n-----END PGP PUBLIC KEY BLOCK-----";
|
||||
const X509 = "-----BEGIN CERTIFICATE-----\nMIIDdzCCAl+gAwIBAgIE\n-----END CERTIFICATE-----";
|
||||
|
||||
describe("which sort of key this is", () => {
|
||||
it("reads the armour header, and only the header", () => {
|
||||
expect(keyKind(PGP)).toBe("openpgp");
|
||||
expect(keyKind(X509)).toBe("smime");
|
||||
});
|
||||
|
||||
it("tolerates leading whitespace from a paste", () => {
|
||||
expect(keyKind("\n\n " + PGP)).toBe("openpgp");
|
||||
});
|
||||
|
||||
it("says so rather than guessing when the header is not one it knows", () => {
|
||||
// Not "invalid" — that is the server's call to make, not this function's.
|
||||
expect(keyKind("ssh-ed25519 AAAAC3Nz")).toBe("unknown");
|
||||
expect(keyKind("")).toBe("unknown");
|
||||
expect(keyKindLabel(keyKind("nonsense"))).toBe("Unrecognised");
|
||||
});
|
||||
});
|
||||
|
||||
describe("expiry", () => {
|
||||
const now = new Date("2026-08-26T12:00:00Z");
|
||||
|
||||
it("is not expired when no expiry was set", () => {
|
||||
expect(isExpired({ expiresAt: null }, now)).toBe(false);
|
||||
});
|
||||
|
||||
it("compares against the given moment, not the machine clock", () => {
|
||||
expect(isExpired({ expiresAt: "2026-08-25T12:00:00Z" }, now)).toBe(true);
|
||||
expect(isExpired({ expiresAt: "2026-08-27T12:00:00Z" }, now)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats an unreadable date as no expiry rather than as expired", () => {
|
||||
// Marking a usable key "Expired" over a date we could not read would be
|
||||
// worse than saying nothing about it.
|
||||
expect(isExpired({ expiresAt: "whenever" }, now)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("telling two keys apart", () => {
|
||||
it("excerpts the body, skipping armour, headers and the checksum", () => {
|
||||
const x = keyExcerpt(PGP, 12);
|
||||
expect(x).toBe("mQINBGAbCdEF");
|
||||
expect(x).not.toContain("-----");
|
||||
expect(x).not.toContain("=aBc1");
|
||||
});
|
||||
|
||||
it("gives something rather than nothing for a key with no body", () => {
|
||||
expect(keyExcerpt("-----BEGIN PGP PUBLIC KEY BLOCK-----\n-----END PGP PUBLIC KEY BLOCK-----")).toBe("—");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reading the registry back", () => {
|
||||
/*
|
||||
* Stalwart answers an empty `emailAddresses` with `{}` -- an object, where a
|
||||
* JMAP list property should be an array. Confirmed against a live 0.16.20 on
|
||||
* 2026-09-05. Trusting the type would put an object through `.join(", ")`
|
||||
* and throw in the middle of rendering the list, so the shape is checked
|
||||
* rather than believed, and this is the test that says why.
|
||||
*/
|
||||
it("survives emailAddresses arriving as an object instead of an array", async () => {
|
||||
call.mockResolvedValueOnce({ list: [{ id: "k1", key: PGP, description: "Work", createdAt: "2026-09-05T07:48:03Z", expiresAt: null, emailAddresses: {} }] });
|
||||
const [k] = await listPublicKeys();
|
||||
expect(Array.isArray(k!.emailAddresses)).toBe(true);
|
||||
expect(k!.emailAddresses).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the addresses when the server does send a list", async () => {
|
||||
call.mockResolvedValueOnce({ list: [{ id: "k1", key: PGP, emailAddresses: ["[email protected]", 7, "[email protected]"] }] });
|
||||
const [k] = await listPublicKeys();
|
||||
// The stray number is dropped rather than rendered as "7".
|
||||
expect(k!.emailAddresses).toEqual(["[email protected]", "[email protected]"]);
|
||||
});
|
||||
|
||||
it("fills in what a sparse object leaves out, so the card never renders undefined", async () => {
|
||||
call.mockResolvedValueOnce({ list: [{ id: "k1" }] });
|
||||
const [k] = await listPublicKeys();
|
||||
expect(k).toEqual({ id: "k1", key: "", description: "", createdAt: null, expiresAt: null, emailAddresses: [] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Public keys, over Stalwart's `x:PublicKey` registry.
|
||||
*
|
||||
* These are the keys other people use to encrypt mail *to* this account, and
|
||||
* the ones a signature is checked against. Nothing secret is involved: no
|
||||
* private key is held, asked for, or sent anywhere by any of this.
|
||||
*
|
||||
* Established against a live 0.16.20 on 2026-09-05 rather than assumed. The
|
||||
* first two because the documentation says otherwise; the rest because they
|
||||
* decide how this file has to be written:
|
||||
*
|
||||
* - An ordinary user may read *and* write their own keys. Stalwart's
|
||||
* permissions table lists the `sysPublicKey*` permissions as
|
||||
* administrative; the server granted them to a normal account. A create
|
||||
* with a malformed key came back `invalidProperties`, not `forbidden`,
|
||||
* which is a rejection of the key rather than of the person.
|
||||
*
|
||||
* - Stalwart parses the key itself, with a real OpenPGP implementation, and
|
||||
* says precisely what is wrong: "Failed to decode OpenPGP public key:
|
||||
* Malformed packet: Malformed CTB…". So ihasmail does not validate key
|
||||
* material. Anything it checked would only be a second opinion, and the
|
||||
* one that mattered would still be the server's. It refuses a *readable*
|
||||
* key that cannot encrypt just as firmly, and differently — "Could not
|
||||
* find any suitable keys in OpenPGP public key" — which is the rejection
|
||||
* an exported sign-only key gets.
|
||||
*
|
||||
* - `emailAddresses` comes back as `{}` when it is empty: an object where a
|
||||
* JMAP list property should be an array. `normalize` therefore checks the
|
||||
* shape instead of trusting it, and every reader of this type gets a real
|
||||
* array. Deleting that check reintroduces a crash in `.join()`.
|
||||
*
|
||||
* - A create answers with the id alone — no `createdAt` — so adding a key
|
||||
* reloads the list rather than believing what it got back.
|
||||
*
|
||||
* - `destroy` works, and leaves the registry empty. That path had never been
|
||||
* run against a real server before this probe.
|
||||
*/
|
||||
import { CAP, client, setErrorMessage } from "@/jmap/client";
|
||||
import type { GetResponse, Id, SetResponse } from "@/jmap/types";
|
||||
import { useSession } from "@/store/session";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
const STALWART = "urn:stalwart:jmap";
|
||||
const USING = [CAP.core, STALWART];
|
||||
|
||||
export interface PublicKey {
|
||||
id: Id;
|
||||
key: string;
|
||||
description: string;
|
||||
createdAt: string | null;
|
||||
expiresAt: string | null;
|
||||
emailAddresses: string[];
|
||||
}
|
||||
|
||||
/** What a key can be edited to; `key` itself is replaced by adding a new one. */
|
||||
export type PublicKeyPatch = Partial<Pick<PublicKey, "description" | "expiresAt" | "emailAddresses">>;
|
||||
|
||||
const PROPS = ["id", "key", "description", "createdAt", "expiresAt", "emailAddresses"];
|
||||
|
||||
/** Whether this server offers the registry at all. */
|
||||
export function publicKeysAvailable(): boolean {
|
||||
return client.hasCapabilityAnywhere(STALWART) && Boolean(accountId());
|
||||
}
|
||||
|
||||
function accountId(): Id | null {
|
||||
const s = useSession.getState();
|
||||
return s.session?.primaryAccounts?.[STALWART] ?? s.accountFor(CAP.mail);
|
||||
}
|
||||
|
||||
function normalize(raw: Partial<PublicKey> & { id: Id }): PublicKey {
|
||||
return {
|
||||
id: raw.id,
|
||||
key: typeof raw.key === "string" ? raw.key : "",
|
||||
description: typeof raw.description === "string" ? raw.description : "",
|
||||
createdAt: typeof raw.createdAt === "string" ? raw.createdAt : null,
|
||||
expiresAt: typeof raw.expiresAt === "string" ? raw.expiresAt : null,
|
||||
emailAddresses: Array.isArray(raw.emailAddresses) ? raw.emailAddresses.filter((a): a is string => typeof a === "string") : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function listPublicKeys(): Promise<PublicKey[]> {
|
||||
const id = accountId();
|
||||
if (!id) return [];
|
||||
const res = await client.call<GetResponse<PublicKey>>("x:PublicKey/get", { accountId: id, ids: null, properties: PROPS }, USING);
|
||||
return res.list.map((k) => normalize(k as Partial<PublicKey> & { id: Id }));
|
||||
}
|
||||
|
||||
export async function addPublicKey(key: string, description: string, extra: PublicKeyPatch = {}): Promise<PublicKey> {
|
||||
const id = accountId();
|
||||
if (!id) throw new Error(t("No account to add a key to."));
|
||||
const res = await client.call<SetResponse<PublicKey>>(
|
||||
"x:PublicKey/set",
|
||||
{ accountId: id, create: { k: { key: key.trim(), description: description.trim(), ...clean(extra) } } },
|
||||
USING,
|
||||
);
|
||||
const err = res.notCreated?.k;
|
||||
// The server's own words: it parsed the key and knows what is wrong with it.
|
||||
if (err) throw new Error(setErrorMessage(err));
|
||||
return normalize((res.created?.k ?? { id: "" }) as Partial<PublicKey> & { id: Id });
|
||||
}
|
||||
|
||||
export async function updatePublicKey(keyId: Id, patch: PublicKeyPatch): Promise<void> {
|
||||
const id = accountId();
|
||||
if (!id) return;
|
||||
const res = await client.call<SetResponse<PublicKey>>("x:PublicKey/set", { accountId: id, update: { [keyId]: clean(patch) } }, USING);
|
||||
const err = res.notUpdated?.[keyId];
|
||||
if (err) throw new Error(setErrorMessage(err));
|
||||
}
|
||||
|
||||
export async function removePublicKey(keyId: Id): Promise<void> {
|
||||
const id = accountId();
|
||||
if (!id) return;
|
||||
const res = await client.call<SetResponse<PublicKey>>("x:PublicKey/set", { accountId: id, destroy: [keyId] }, USING);
|
||||
const err = res.notDestroyed?.[keyId];
|
||||
if (err) throw new Error(setErrorMessage(err));
|
||||
}
|
||||
|
||||
/** Drop keys the caller left undefined, so a patch never blanks a field by accident. */
|
||||
function clean(patch: PublicKeyPatch): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(patch)) if (v !== undefined) out[k] = v;
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Reading a key without parsing one */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export type KeyKind = "openpgp" | "smime" | "unknown";
|
||||
|
||||
/**
|
||||
* Which kind of key this is, from its armour header alone.
|
||||
*
|
||||
* Deliberately not a parse. The header is a label, and reading a label is not
|
||||
* the same as validating the thing it is stuck to — the server does that, and
|
||||
* a second opinion here could only ever disagree with the one that counts.
|
||||
*/
|
||||
export function keyKind(key: string): KeyKind {
|
||||
const head = key.trimStart().slice(0, 120).toUpperCase();
|
||||
if (head.includes("BEGIN PGP PUBLIC KEY BLOCK")) return "openpgp";
|
||||
if (head.includes("BEGIN CERTIFICATE") || head.includes("BEGIN PKCS7")) return "smime";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* "OpenPGP" and "S/MIME" are the formats' own names and stay as they are in
|
||||
* every language; only the fallback is a word rather than a name, so only the
|
||||
* fallback is translated.
|
||||
*/
|
||||
export function keyKindLabel(kind: KeyKind): string {
|
||||
return kind === "openpgp" ? "OpenPGP" : kind === "smime" ? "S/MIME" : t("Unrecognised");
|
||||
}
|
||||
|
||||
/** Whether a key has an expiry that has already passed. */
|
||||
export function isExpired(k: Pick<PublicKey, "expiresAt">, now = new Date()): boolean {
|
||||
if (!k.expiresAt) return false;
|
||||
const at = Date.parse(k.expiresAt);
|
||||
return Number.isFinite(at) && at < now.getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* A short, stable excerpt of the key body, for telling two keys apart in a
|
||||
* list. Not a fingerprint: computing a real one means parsing the key, and
|
||||
* calling this a fingerprint would invite someone to verify against it.
|
||||
*/
|
||||
export function keyExcerpt(key: string, length = 24): string {
|
||||
const body = key
|
||||
.split(/\r?\n/)
|
||||
.filter((l) => l && !l.startsWith("-----") && !l.includes(":") && !l.startsWith("="))
|
||||
.join("");
|
||||
return body.slice(0, length) || "—";
|
||||
}
|
||||
@@ -1331,6 +1331,36 @@ export const catalog: Catalog = {
|
||||
"Empty “{name}”?": "„{name}“ leeren?",
|
||||
"Delete them": "Alle löschen",
|
||||
"Nothing was deleted": "Es wurde nichts gelöscht",
|
||||
// ── Encryption keys, over Stalwart's x:PublicKey registry (#67) ──
|
||||
"Encryption keys": "Verschlüsselungsschlüssel",
|
||||
"Not available on this server": "Auf diesem Server nicht verfügbar",
|
||||
"Public keys are kept in Stalwart's registry, which this server does not offer. ihasmail needs Stalwart 0.16 or newer for it.": "Öffentliche Schlüssel liegen in der Registry von Stalwart, die dieser Server nicht anbietet. ihasmail benötigt dafür Stalwart 0.16 oder neuer.",
|
||||
"Public keys for this account — what other people encrypt to, and what a signature is checked against. These are public by nature: no private key is stored, requested, or sent by ihasmail.": "Öffentliche Schlüssel für dieses Konto — das, woran andere verschlüsseln und woran eine Signatur geprüft wird. Sie sind naturgemäß öffentlich: ihasmail speichert, erfragt und sendet keinen privaten Schlüssel.",
|
||||
"No keys yet": "Noch keine Schlüssel",
|
||||
"Add an OpenPGP public key or an S/MIME certificate to publish it on this account.": "Fügen Sie einen öffentlichen OpenPGP-Schlüssel oder ein S/MIME-Zertifikat hinzu, um es für dieses Konto zu veröffentlichen.",
|
||||
"Key added": "Schlüssel hinzugefügt",
|
||||
"Key removed": "Schlüssel entfernt",
|
||||
"Remove “{name}”?": "„{name}“ entfernen?",
|
||||
"this key": "diesen Schlüssel",
|
||||
"Anyone holding it can still use it — removing it here only stops this account offering it.": "Wer ihn besitzt, kann ihn weiterhin verwenden — das Entfernen hier bewirkt nur, dass dieses Konto ihn nicht mehr anbietet.",
|
||||
"Remove key": "Schlüssel entfernen",
|
||||
"Key description": "Schlüsselbeschreibung",
|
||||
"Untitled key": "Unbenannter Schlüssel",
|
||||
"Click to rename": "Zum Umbenennen klicken",
|
||||
"Expired": "Abgelaufen",
|
||||
"Added": "Hinzugefügt",
|
||||
"Addresses": "Adressen",
|
||||
"No expiry set": "Kein Ablaufdatum festgelegt",
|
||||
"Any address on this account": "Jede Adresse dieses Kontos",
|
||||
"cryptography\u0004Key": "Schlüssel",
|
||||
"Public key": "Öffentlicher Schlüssel",
|
||||
"Work key": "Arbeitsschlüssel",
|
||||
"Paste the whole armoured block, headers included. The server checks it and says what is wrong if it cannot read it.": "Fügen Sie den gesamten ASCII-Block samt Kopfzeilen ein. Der Server prüft ihn und sagt, was nicht stimmt, falls er ihn nicht lesen kann.",
|
||||
"Add key": "Schlüssel hinzufügen",
|
||||
"Add a key": "Einen Schlüssel hinzufügen",
|
||||
"Stalwart stores these keys, and this release does no more than manage them: ihasmail does not yet sign, encrypt, decrypt or verify anything with them. Adding one does not by itself start encrypting your mail.": "Stalwart speichert diese Schlüssel, und diese Version verwaltet sie lediglich: ihasmail signiert, verschlüsselt, entschlüsselt und prüft damit noch nichts. Einen hinzuzufügen beginnt nicht von selbst, Ihre E-Mails zu verschlüsseln.",
|
||||
"Unrecognised": "Nicht erkannt",
|
||||
"No account to add a key to.": "Kein Konto vorhanden, zu dem ein Schlüssel hinzugefügt werden könnte.",
|
||||
},
|
||||
plurals: {
|
||||
// ── Third pass ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -1304,6 +1304,36 @@ export const catalog: Catalog = {
|
||||
"Empty “{name}”?": "¿Vaciar «{name}»?",
|
||||
"Delete them": "Eliminarlos",
|
||||
"Nothing was deleted": "No se ha eliminado nada",
|
||||
// ── Encryption keys, over Stalwart's x:PublicKey registry (#67) ──
|
||||
"Encryption keys": "Claves de cifrado",
|
||||
"Not available on this server": "No disponible en este servidor",
|
||||
"Public keys are kept in Stalwart's registry, which this server does not offer. ihasmail needs Stalwart 0.16 or newer for it.": "Las claves públicas se guardan en el registro de Stalwart, que este servidor no ofrece. ihasmail necesita Stalwart 0.16 o posterior para ello.",
|
||||
"Public keys for this account — what other people encrypt to, and what a signature is checked against. These are public by nature: no private key is stored, requested, or sent by ihasmail.": "Claves públicas de esta cuenta: aquello con lo que otras personas cifran, y aquello con lo que se comprueba una firma. Son públicas por naturaleza: ihasmail no almacena, no solicita ni envía ninguna clave privada.",
|
||||
"No keys yet": "Aún no hay claves",
|
||||
"Add an OpenPGP public key or an S/MIME certificate to publish it on this account.": "Añada una clave pública OpenPGP o un certificado S/MIME para publicarlo en esta cuenta.",
|
||||
"Key added": "Clave añadida",
|
||||
"Key removed": "Clave quitada",
|
||||
"Remove “{name}”?": "¿Quitar «{name}»?",
|
||||
"this key": "esta clave",
|
||||
"Anyone holding it can still use it — removing it here only stops this account offering it.": "Quien la tenga podrá seguir usándola: quitarla aquí solo hace que esta cuenta deje de ofrecerla.",
|
||||
"Remove key": "Quitar clave",
|
||||
"Key description": "Descripción de la clave",
|
||||
"Untitled key": "Clave sin título",
|
||||
"Click to rename": "Haga clic para cambiar el nombre",
|
||||
"Expired": "Caducada",
|
||||
"Added": "Añadida",
|
||||
"Addresses": "Direcciones",
|
||||
"No expiry set": "Sin caducidad establecida",
|
||||
"Any address on this account": "Cualquier dirección de esta cuenta",
|
||||
"cryptography\u0004Key": "Clave",
|
||||
"Public key": "Clave pública",
|
||||
"Work key": "Clave del trabajo",
|
||||
"Paste the whole armoured block, headers included. The server checks it and says what is wrong if it cannot read it.": "Pegue el bloque blindado completo, cabeceras incluidas. El servidor lo comprueba y dice qué falla si no puede leerlo.",
|
||||
"Add key": "Añadir clave",
|
||||
"Add a key": "Añadir una clave",
|
||||
"Stalwart stores these keys, and this release does no more than manage them: ihasmail does not yet sign, encrypt, decrypt or verify anything with them. Adding one does not by itself start encrypting your mail.": "Stalwart guarda estas claves y esta versión no hace más que gestionarlas: ihasmail todavía no firma, cifra, descifra ni verifica nada con ellas. Añadir una no empieza por sí sola a cifrar su correo.",
|
||||
"Unrecognised": "No reconocida",
|
||||
"No account to add a key to.": "No hay ninguna cuenta a la que añadir una clave.",
|
||||
},
|
||||
plurals: {
|
||||
// ── Third pass ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -1309,6 +1309,36 @@ export const catalog: Catalog = {
|
||||
"Empty “{name}”?": "Vider « {name} » ?",
|
||||
"Delete them": "Les supprimer",
|
||||
"Nothing was deleted": "Rien n’a été supprimé",
|
||||
// ── Encryption keys, over Stalwart's x:PublicKey registry (#67) ──
|
||||
"Encryption keys": "Clés de chiffrement",
|
||||
"Not available on this server": "Non disponible sur ce serveur",
|
||||
"Public keys are kept in Stalwart's registry, which this server does not offer. ihasmail needs Stalwart 0.16 or newer for it.": "Les clés publiques sont conservées dans le registre de Stalwart, que ce serveur ne propose pas. ihasmail nécessite Stalwart 0.16 ou plus récent pour cela.",
|
||||
"Public keys for this account — what other people encrypt to, and what a signature is checked against. These are public by nature: no private key is stored, requested, or sent by ihasmail.": "Clés publiques de ce compte — ce avec quoi les autres chiffrent, et ce sur quoi une signature est vérifiée. Elles sont publiques par nature : ihasmail ne stocke, ne demande ni n'envoie aucune clé privée.",
|
||||
"No keys yet": "Aucune clé pour l'instant",
|
||||
"Add an OpenPGP public key or an S/MIME certificate to publish it on this account.": "Ajoutez une clé publique OpenPGP ou un certificat S/MIME pour le publier sur ce compte.",
|
||||
"Key added": "Clé ajoutée",
|
||||
"Key removed": "Clé retirée",
|
||||
"Remove “{name}”?": "Retirer « {name} » ?",
|
||||
"this key": "cette clé",
|
||||
"Anyone holding it can still use it — removing it here only stops this account offering it.": "Quiconque la détient peut toujours l'utiliser — la retirer ici empêche seulement ce compte de la proposer.",
|
||||
"Remove key": "Retirer la clé",
|
||||
"Key description": "Description de la clé",
|
||||
"Untitled key": "Clé sans titre",
|
||||
"Click to rename": "Cliquez pour renommer",
|
||||
"Expired": "Expirée",
|
||||
"Added": "Ajoutée",
|
||||
"Addresses": "Adresses",
|
||||
"No expiry set": "Aucune expiration définie",
|
||||
"Any address on this account": "Toute adresse de ce compte",
|
||||
"cryptography\u0004Key": "Clé",
|
||||
"Public key": "Clé publique",
|
||||
"Work key": "Clé professionnelle",
|
||||
"Paste the whole armoured block, headers included. The server checks it and says what is wrong if it cannot read it.": "Collez le bloc ASCII complet, en-têtes compris. Le serveur le vérifie et indique ce qui ne va pas s'il ne peut pas le lire.",
|
||||
"Add key": "Ajouter la clé",
|
||||
"Add a key": "Ajouter une clé",
|
||||
"Stalwart stores these keys, and this release does no more than manage them: ihasmail does not yet sign, encrypt, decrypt or verify anything with them. Adding one does not by itself start encrypting your mail.": "Stalwart conserve ces clés, et cette version ne fait que les gérer : ihasmail ne signe, ne chiffre, ne déchiffre et ne vérifie encore rien avec elles. En ajouter une ne commence pas à chiffrer votre courrier.",
|
||||
"Unrecognised": "Non reconnue",
|
||||
"No account to add a key to.": "Aucun compte auquel ajouter une clé.",
|
||||
},
|
||||
plurals: {
|
||||
// ── Third pass ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -1312,6 +1312,36 @@ export const catalog: Catalog = {
|
||||
"Empty “{name}”?": "「{name}」を空にしますか?",
|
||||
"Delete them": "削除する",
|
||||
"Nothing was deleted": "何も削除されませんでした",
|
||||
// ── Encryption keys, over Stalwart's x:PublicKey registry (#67) ──
|
||||
"Encryption keys": "暗号鍵",
|
||||
"Not available on this server": "このサーバーでは利用できません",
|
||||
"Public keys are kept in Stalwart's registry, which this server does not offer. ihasmail needs Stalwart 0.16 or newer for it.": "公開鍵は Stalwart のレジストリに保存されますが、このサーバーはそれを提供していません。ihasmail には Stalwart 0.16 以降が必要です。",
|
||||
"Public keys for this account — what other people encrypt to, and what a signature is checked against. These are public by nature: no private key is stored, requested, or sent by ihasmail.": "このアカウントの公開鍵です。他の人が暗号化に使い、署名の検証にも使われます。公開鍵は本来公開されるものです。ihasmail は秘密鍵を保存も要求も送信もしません。",
|
||||
"No keys yet": "鍵はまだありません",
|
||||
"Add an OpenPGP public key or an S/MIME certificate to publish it on this account.": "OpenPGP 公開鍵または S/MIME 証明書を追加して、このアカウントで公開します。",
|
||||
"Key added": "鍵を追加しました",
|
||||
"Key removed": "鍵を削除しました",
|
||||
"Remove “{name}”?": "「{name}」を削除しますか?",
|
||||
"this key": "この鍵",
|
||||
"Anyone holding it can still use it — removing it here only stops this account offering it.": "すでに持っている人は引き続き使用できます。ここで削除しても、このアカウントが提供しなくなるだけです。",
|
||||
"Remove key": "鍵を削除",
|
||||
"Key description": "鍵の説明",
|
||||
"Untitled key": "名前のない鍵",
|
||||
"Click to rename": "クリックして名前を変更",
|
||||
"Expired": "期限切れ",
|
||||
"Added": "追加日",
|
||||
"Addresses": "アドレス",
|
||||
"No expiry set": "有効期限なし",
|
||||
"Any address on this account": "このアカウントのすべてのアドレス",
|
||||
"cryptography\u0004Key": "鍵",
|
||||
"Public key": "公開鍵",
|
||||
"Work key": "仕事用の鍵",
|
||||
"Paste the whole armoured block, headers included. The server checks it and says what is wrong if it cannot read it.": "ヘッダーを含め、ブロック全体を貼り付けてください。サーバーが検証し、読み取れない場合は理由を表示します。",
|
||||
"Add key": "鍵を追加",
|
||||
"Add a key": "鍵を追加",
|
||||
"Stalwart stores these keys, and this release does no more than manage them: ihasmail does not yet sign, encrypt, decrypt or verify anything with them. Adding one does not by itself start encrypting your mail.": "Stalwart はこれらの鍵を保存し、このリリースでは管理のみを行います。ihasmail はまだ署名も暗号化も復号も検証も行いません。鍵を追加しただけでメールの暗号化が始まるわけではありません。",
|
||||
"Unrecognised": "認識できません",
|
||||
"No account to add a key to.": "鍵を追加するアカウントがありません。",
|
||||
},
|
||||
plurals: {
|
||||
// ── Third pass ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -1300,6 +1300,36 @@ export const catalog: Catalog = {
|
||||
"Empty “{name}”?": "„{name}” leegmaken?",
|
||||
"Delete them": "Verwijderen",
|
||||
"Nothing was deleted": "Er is niets verwijderd",
|
||||
// ── Encryption keys, over Stalwart's x:PublicKey registry (#67) ──
|
||||
"Encryption keys": "Versleutelingssleutels",
|
||||
"Not available on this server": "Niet beschikbaar op deze server",
|
||||
"Public keys are kept in Stalwart's registry, which this server does not offer. ihasmail needs Stalwart 0.16 or newer for it.": "Openbare sleutels worden bewaard in het register van Stalwart, dat deze server niet aanbiedt. ihasmail heeft daarvoor Stalwart 0.16 of nieuwer nodig.",
|
||||
"Public keys for this account — what other people encrypt to, and what a signature is checked against. These are public by nature: no private key is stored, requested, or sent by ihasmail.": "Openbare sleutels voor dit account — waarmee anderen naar u versleutelen, en waaraan een handtekening wordt gecontroleerd. Ze zijn van nature openbaar: ihasmail bewaart, vraagt en verstuurt geen enkele privésleutel.",
|
||||
"No keys yet": "Nog geen sleutels",
|
||||
"Add an OpenPGP public key or an S/MIME certificate to publish it on this account.": "Voeg een openbare OpenPGP-sleutel of een S/MIME-certificaat toe om het op dit account te publiceren.",
|
||||
"Key added": "Sleutel toegevoegd",
|
||||
"Key removed": "Sleutel verwijderd",
|
||||
"Remove “{name}”?": "“{name}” verwijderen?",
|
||||
"this key": "deze sleutel",
|
||||
"Anyone holding it can still use it — removing it here only stops this account offering it.": "Wie hem al heeft, kan hem blijven gebruiken — hem hier verwijderen zorgt er alleen voor dat dit account hem niet meer aanbiedt.",
|
||||
"Remove key": "Sleutel verwijderen",
|
||||
"Key description": "Sleutelomschrijving",
|
||||
"Untitled key": "Naamloze sleutel",
|
||||
"Click to rename": "Klik om te hernoemen",
|
||||
"Expired": "Verlopen",
|
||||
"Added": "Toegevoegd",
|
||||
"Addresses": "Adressen",
|
||||
"No expiry set": "Geen vervaldatum ingesteld",
|
||||
"Any address on this account": "Elk adres van dit account",
|
||||
"cryptography\u0004Key": "Sleutel",
|
||||
"Public key": "Openbare sleutel",
|
||||
"Work key": "Werksleutel",
|
||||
"Paste the whole armoured block, headers included. The server checks it and says what is wrong if it cannot read it.": "Plak het hele beveiligde blok, inclusief de kopregels. De server controleert het en zegt wat er mis is als hij het niet kan lezen.",
|
||||
"Add key": "Sleutel toevoegen",
|
||||
"Add a key": "Een sleutel toevoegen",
|
||||
"Stalwart stores these keys, and this release does no more than manage them: ihasmail does not yet sign, encrypt, decrypt or verify anything with them. Adding one does not by itself start encrypting your mail.": "Stalwart bewaart deze sleutels, en deze versie doet niet meer dan ze beheren: ihasmail ondertekent, versleutelt, ontsleutelt en verifieert er nog niets mee. Er een toevoegen begint niet vanzelf uw e-mail te versleutelen.",
|
||||
"Unrecognised": "Niet herkend",
|
||||
"No account to add a key to.": "Geen account om een sleutel aan toe te voegen.",
|
||||
},
|
||||
plurals: {
|
||||
// ── Third pass ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -1307,6 +1307,36 @@ export const catalog: Catalog = {
|
||||
"Empty “{name}”?": "Esvaziar “{name}”?",
|
||||
"Delete them": "Excluir todos",
|
||||
"Nothing was deleted": "Nada foi excluído",
|
||||
// ── Encryption keys, over Stalwart's x:PublicKey registry (#67) ──
|
||||
"Encryption keys": "Chaves de criptografia",
|
||||
"Not available on this server": "Não disponível neste servidor",
|
||||
"Public keys are kept in Stalwart's registry, which this server does not offer. ihasmail needs Stalwart 0.16 or newer for it.": "As chaves públicas ficam no registro do Stalwart, que este servidor não oferece. O ihasmail precisa do Stalwart 0.16 ou mais recente para isso.",
|
||||
"Public keys for this account — what other people encrypt to, and what a signature is checked against. These are public by nature: no private key is stored, requested, or sent by ihasmail.": "Chaves públicas desta conta — o que outras pessoas usam para criptografar, e aquilo com que uma assinatura é conferida. Elas são públicas por natureza: o ihasmail não armazena, não pede e não envia nenhuma chave privada.",
|
||||
"No keys yet": "Ainda não há chaves",
|
||||
"Add an OpenPGP public key or an S/MIME certificate to publish it on this account.": "Adicione uma chave pública OpenPGP ou um certificado S/MIME para publicá-lo nesta conta.",
|
||||
"Key added": "Chave adicionada",
|
||||
"Key removed": "Chave removida",
|
||||
"Remove “{name}”?": "Remover “{name}”?",
|
||||
"this key": "esta chave",
|
||||
"Anyone holding it can still use it — removing it here only stops this account offering it.": "Quem já a tem continua podendo usá-la — removê-la aqui apenas faz esta conta parar de oferecê-la.",
|
||||
"Remove key": "Remover chave",
|
||||
"Key description": "Descrição da chave",
|
||||
"Untitled key": "Chave sem título",
|
||||
"Click to rename": "Clique para renomear",
|
||||
"Expired": "Expirada",
|
||||
"Added": "Adicionada",
|
||||
"Addresses": "Endereços",
|
||||
"No expiry set": "Sem validade definida",
|
||||
"Any address on this account": "Qualquer endereço desta conta",
|
||||
"cryptography\u0004Key": "Chave",
|
||||
"Public key": "Chave pública",
|
||||
"Work key": "Chave do trabalho",
|
||||
"Paste the whole armoured block, headers included. The server checks it and says what is wrong if it cannot read it.": "Cole o bloco blindado inteiro, cabeçalhos incluídos. O servidor confere e diz o que está errado se não conseguir lê-lo.",
|
||||
"Add key": "Adicionar chave",
|
||||
"Add a key": "Adicionar uma chave",
|
||||
"Stalwart stores these keys, and this release does no more than manage them: ihasmail does not yet sign, encrypt, decrypt or verify anything with them. Adding one does not by itself start encrypting your mail.": "O Stalwart guarda essas chaves, e esta versão não faz mais do que gerenciá-las: o ihasmail ainda não assina, criptografa, descriptografa nem verifica nada com elas. Adicionar uma não começa a criptografar seus e-mails por si só.",
|
||||
"Unrecognised": "Não reconhecida",
|
||||
"No account to add a key to.": "Não há conta à qual adicionar uma chave.",
|
||||
},
|
||||
plurals: {
|
||||
// ── Third pass ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -1306,6 +1306,36 @@ export const catalog: Catalog = {
|
||||
"Empty “{name}”?": "Очистить «{name}»?",
|
||||
"Delete them": "Удалить их",
|
||||
"Nothing was deleted": "Ничего не удалено",
|
||||
// ── Encryption keys, over Stalwart's x:PublicKey registry (#67) ──
|
||||
"Encryption keys": "Ключи шифрования",
|
||||
"Not available on this server": "Недоступно на этом сервере",
|
||||
"Public keys are kept in Stalwart's registry, which this server does not offer. ihasmail needs Stalwart 0.16 or newer for it.": "Открытые ключи хранятся в реестре Stalwart, которого этот сервер не предоставляет. Для этого ihasmail требуется Stalwart 0.16 или новее.",
|
||||
"Public keys for this account — what other people encrypt to, and what a signature is checked against. These are public by nature: no private key is stored, requested, or sent by ihasmail.": "Открытые ключи этой учётной записи — то, чем другие шифруют письма для вас, и то, по чему проверяется подпись. Они открыты по своей природе: ihasmail не хранит, не запрашивает и не отправляет закрытые ключи.",
|
||||
"No keys yet": "Ключей пока нет",
|
||||
"Add an OpenPGP public key or an S/MIME certificate to publish it on this account.": "Добавьте открытый ключ OpenPGP или сертификат S/MIME, чтобы опубликовать его в этой учётной записи.",
|
||||
"Key added": "Ключ добавлен",
|
||||
"Key removed": "Ключ убран",
|
||||
"Remove “{name}”?": "Убрать «{name}»?",
|
||||
"this key": "этот ключ",
|
||||
"Anyone holding it can still use it — removing it here only stops this account offering it.": "У того, кто его уже получил, он останется — здесь это лишь прекращает предлагать его от этой учётной записи.",
|
||||
"Remove key": "Убрать ключ",
|
||||
"Key description": "Описание ключа",
|
||||
"Untitled key": "Ключ без названия",
|
||||
"Click to rename": "Нажмите, чтобы переименовать",
|
||||
"Expired": "Истёк",
|
||||
"Added": "Добавлен",
|
||||
"Addresses": "Адреса",
|
||||
"No expiry set": "Срок действия не задан",
|
||||
"Any address on this account": "Любой адрес этой учётной записи",
|
||||
"cryptography\u0004Key": "Ключ",
|
||||
"Public key": "Открытый ключ",
|
||||
"Work key": "Рабочий ключ",
|
||||
"Paste the whole armoured block, headers included. The server checks it and says what is wrong if it cannot read it.": "Вставьте весь блок целиком, вместе с заголовками. Сервер проверит его и сообщит, что не так, если не сможет прочитать.",
|
||||
"Add key": "Добавить ключ",
|
||||
"Add a key": "Добавить ключ",
|
||||
"Stalwart stores these keys, and this release does no more than manage them: ihasmail does not yet sign, encrypt, decrypt or verify anything with them. Adding one does not by itself start encrypting your mail.": "Stalwart хранит эти ключи, а эта версия лишь управляет ими: ihasmail пока ничего ими не подписывает, не шифрует, не расшифровывает и не проверяет. Добавление ключа само по себе не начинает шифровать вашу почту.",
|
||||
"Unrecognised": "Не распознан",
|
||||
"No account to add a key to.": "Нет учётной записи, в которую можно добавить ключ.",
|
||||
},
|
||||
plurals: {
|
||||
// ── Third pass ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -1300,6 +1300,36 @@ export const catalog: Catalog = {
|
||||
"Empty “{name}”?": "Очистити «{name}»?",
|
||||
"Delete them": "Видалити їх",
|
||||
"Nothing was deleted": "Нічого не видалено",
|
||||
// ── Encryption keys, over Stalwart's x:PublicKey registry (#67) ──
|
||||
"Encryption keys": "Ключі шифрування",
|
||||
"Not available on this server": "Недоступно на цьому сервері",
|
||||
"Public keys are kept in Stalwart's registry, which this server does not offer. ihasmail needs Stalwart 0.16 or newer for it.": "Відкриті ключі зберігаються в реєстрі Stalwart, якого цей сервер не надає. Для цього ihasmail потребує Stalwart 0.16 або новішої версії.",
|
||||
"Public keys for this account — what other people encrypt to, and what a signature is checked against. These are public by nature: no private key is stored, requested, or sent by ihasmail.": "Відкриті ключі цього облікового запису — те, чим інші шифрують листи для вас, і те, за чим перевіряється підпис. Вони відкриті за своєю природою: ihasmail не зберігає, не запитує і не надсилає закритих ключів.",
|
||||
"No keys yet": "Ключів ще немає",
|
||||
"Add an OpenPGP public key or an S/MIME certificate to publish it on this account.": "Додайте відкритий ключ OpenPGP або сертифікат S/MIME, щоб опублікувати його в цьому обліковому записі.",
|
||||
"Key added": "Ключ додано",
|
||||
"Key removed": "Ключ прибрано",
|
||||
"Remove “{name}”?": "Прибрати «{name}»?",
|
||||
"this key": "цей ключ",
|
||||
"Anyone holding it can still use it — removing it here only stops this account offering it.": "У того, хто вже його має, він залишиться — тут це лише припиняє пропонувати його від цього облікового запису.",
|
||||
"Remove key": "Прибрати ключ",
|
||||
"Key description": "Опис ключа",
|
||||
"Untitled key": "Ключ без назви",
|
||||
"Click to rename": "Натисніть, щоб перейменувати",
|
||||
"Expired": "Закінчився",
|
||||
"Added": "Додано",
|
||||
"Addresses": "Адреси",
|
||||
"No expiry set": "Термін дії не задано",
|
||||
"Any address on this account": "Будь-яка адреса цього облікового запису",
|
||||
"cryptography\u0004Key": "Ключ",
|
||||
"Public key": "Відкритий ключ",
|
||||
"Work key": "Робочий ключ",
|
||||
"Paste the whole armoured block, headers included. The server checks it and says what is wrong if it cannot read it.": "Вставте весь блок цілком, разом із заголовками. Сервер перевірить його і скаже, що не так, якщо не зможе прочитати.",
|
||||
"Add key": "Додати ключ",
|
||||
"Add a key": "Додати ключ",
|
||||
"Stalwart stores these keys, and this release does no more than manage them: ihasmail does not yet sign, encrypt, decrypt or verify anything with them. Adding one does not by itself start encrypting your mail.": "Stalwart зберігає ці ключі, а ця версія лише керує ними: ihasmail поки нічого ними не підписує, не шифрує, не розшифровує і не перевіряє. Додавання ключа саме собою не починає шифрувати вашу пошту.",
|
||||
"Unrecognised": "Не розпізнано",
|
||||
"No account to add a key to.": "Немає облікового запису, до якого можна додати ключ.",
|
||||
},
|
||||
plurals: {
|
||||
// ── Third pass ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -1311,6 +1311,36 @@ export const catalog: Catalog = {
|
||||
"Empty “{name}”?": "清空“{name}”?",
|
||||
"Delete them": "删除",
|
||||
"Nothing was deleted": "未删除任何内容",
|
||||
// ── Encryption keys, over Stalwart's x:PublicKey registry (#67) ──
|
||||
"Encryption keys": "加密密钥",
|
||||
"Not available on this server": "此服务器不支持",
|
||||
"Public keys are kept in Stalwart's registry, which this server does not offer. ihasmail needs Stalwart 0.16 or newer for it.": "公钥保存在 Stalwart 的注册表中,而此服务器未提供该功能。ihasmail 需要 Stalwart 0.16 或更高版本。",
|
||||
"Public keys for this account — what other people encrypt to, and what a signature is checked against. These are public by nature: no private key is stored, requested, or sent by ihasmail.": "此账户的公钥——他人用它加密,签名也据此校验。公钥本就是公开的:ihasmail 不会存储、索取或发送任何私钥。",
|
||||
"No keys yet": "尚无密钥",
|
||||
"Add an OpenPGP public key or an S/MIME certificate to publish it on this account.": "添加 OpenPGP 公钥或 S/MIME 证书,将其发布到此账户。",
|
||||
"Key added": "已添加密钥",
|
||||
"Key removed": "已移除密钥",
|
||||
"Remove “{name}”?": "移除「{name}」?",
|
||||
"this key": "此密钥",
|
||||
"Anyone holding it can still use it — removing it here only stops this account offering it.": "已经持有它的人仍可继续使用——在此移除只会让此账户不再提供它。",
|
||||
"Remove key": "移除密钥",
|
||||
"Key description": "密钥说明",
|
||||
"Untitled key": "未命名密钥",
|
||||
"Click to rename": "点击以重命名",
|
||||
"Expired": "已过期",
|
||||
"Added": "添加时间",
|
||||
"Addresses": "地址",
|
||||
"No expiry set": "未设置有效期",
|
||||
"Any address on this account": "此账户的任意地址",
|
||||
"cryptography\u0004Key": "密钥",
|
||||
"Public key": "公钥",
|
||||
"Work key": "工作密钥",
|
||||
"Paste the whole armoured block, headers included. The server checks it and says what is wrong if it cannot read it.": "请粘贴完整的密钥文本块,包括头尾行。服务器会进行校验,无法读取时会说明原因。",
|
||||
"Add key": "添加密钥",
|
||||
"Add a key": "添加密钥",
|
||||
"Stalwart stores these keys, and this release does no more than manage them: ihasmail does not yet sign, encrypt, decrypt or verify anything with them. Adding one does not by itself start encrypting your mail.": "Stalwart 会保存这些密钥,而此版本仅负责管理:ihasmail 尚不会用它们签名、加密、解密或验证。添加密钥本身并不会开始加密您的邮件。",
|
||||
"Unrecognised": "无法识别",
|
||||
"No account to add a key to.": "没有可添加密钥的账户。",
|
||||
},
|
||||
plurals: {
|
||||
// ── Third pass ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { KeyRound, Plus, Trash2 } from "lucide-react";
|
||||
import {
|
||||
addPublicKey,
|
||||
isExpired,
|
||||
keyExcerpt,
|
||||
keyKind,
|
||||
keyKindLabel,
|
||||
listPublicKeys,
|
||||
publicKeysAvailable,
|
||||
removePublicKey,
|
||||
updatePublicKey,
|
||||
type PublicKey,
|
||||
} from "@/lib/publicKeys";
|
||||
import { formatFullDate } from "@/lib/format";
|
||||
import { confirmDialog } from "@/ui/dialog";
|
||||
import { Empty, Spinner } from "@/ui/misc";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { t, tc } from "@/lib/i18n";
|
||||
|
||||
/**
|
||||
* Public keys for this account: the ones other people encrypt to, and the ones
|
||||
* a signature is checked against. Nothing here handles a private key, and
|
||||
* nothing here asks for one.
|
||||
*
|
||||
* The page is careful not to overstate what adding a key achieves. Stalwart
|
||||
* stores keys, and `encryptionAtRest` on `x:AccountSettings` is the one thing
|
||||
* known to consume one; nothing in ihasmail signs, encrypts, decrypts or
|
||||
* verifies with them yet. Saying otherwise would be a guess dressed as a
|
||||
* feature.
|
||||
*/
|
||||
export function KeysSettings() {
|
||||
const [keys, setKeys] = useState<PublicKey[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const [draftKey, setDraftKey] = useState("");
|
||||
const [draftName, setDraftName] = useState("");
|
||||
const available = publicKeysAvailable();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!available) {
|
||||
setKeys([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setKeys(await listPublicKeys());
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
setKeys([]);
|
||||
}
|
||||
}, [available]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const add = async () => {
|
||||
if (!draftKey.trim()) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
// An empty description is left empty rather than filled in with an
|
||||
// English default: the description is stored on the server, so a name
|
||||
// invented here would be whatever language the adder happened to use.
|
||||
// The list labels a blank one at render time instead.
|
||||
await addPublicKey(draftKey, draftName);
|
||||
setDraftKey("");
|
||||
setDraftName("");
|
||||
setAdding(false);
|
||||
toast.success(t("Key added"));
|
||||
await load();
|
||||
} catch (err) {
|
||||
// Stalwart parsed the key and knows exactly what is wrong with it, in
|
||||
// more detail than anything invented here could manage.
|
||||
toast.error((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const rename = async (k: PublicKey, description: string) => {
|
||||
if (description === k.description) return;
|
||||
try {
|
||||
await updatePublicKey(k.id, { description });
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
await load();
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (k: PublicKey) => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("Remove “{name}”?", { name: k.description || t("this key") }),
|
||||
message: t("Anyone holding it can still use it — removing it here only stops this account offering it."),
|
||||
confirmLabel: t("Remove key"),
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await removePublicKey(k.id);
|
||||
toast.success(t("Key removed"));
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
await load();
|
||||
};
|
||||
|
||||
if (!available) {
|
||||
return (
|
||||
<div>
|
||||
<h1>{t("Encryption keys")}</h1>
|
||||
<Empty icon={<KeyRound size={40} />} title={t("Not available on this server")}>
|
||||
{t("Public keys are kept in Stalwart's registry, which this server does not offer. ihasmail needs Stalwart 0.16 or newer for it.")}
|
||||
</Empty>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{t("Encryption keys")}</h1>
|
||||
<p className="lead">
|
||||
{t("Public keys for this account — what other people encrypt to, and what a signature is checked against. These are public by nature: no private key is stored, requested, or sent by ihasmail.")}
|
||||
</p>
|
||||
|
||||
{error && <div className="error-box mb-16" role="alert">{error}</div>}
|
||||
|
||||
{keys === null ? (
|
||||
<div className="center p-16"><Spinner /></div>
|
||||
) : keys.length === 0 && !adding ? (
|
||||
<Empty icon={<KeyRound size={40} />} title={t("No keys yet")}>
|
||||
{t("Add an OpenPGP public key or an S/MIME certificate to publish it on this account.")}
|
||||
</Empty>
|
||||
) : (
|
||||
keys.map((k) => {
|
||||
const kind = keyKind(k.key);
|
||||
const expired = isExpired(k);
|
||||
return (
|
||||
<div key={k.id} className="card">
|
||||
<div className="card-head">
|
||||
<KeyRound size={16} />
|
||||
{editing === k.id ? (
|
||||
<input
|
||||
className="input sm"
|
||||
aria-label={t("Key description")}
|
||||
autoFocus
|
||||
defaultValue={k.description}
|
||||
style={{ width: 240 }}
|
||||
onBlur={(e) => { void rename(k, e.target.value.trim() || k.description); setEditing(null); }}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); if (e.key === "Escape") setEditing(null); }}
|
||||
/>
|
||||
) : (
|
||||
<h3 style={{ cursor: "text" }} onClick={() => setEditing(k.id)} title={t("Click to rename")}>{k.description || t("Untitled key")}</h3>
|
||||
)}
|
||||
<span className="chip">{keyKindLabel(kind)}</span>
|
||||
{expired && <span className="chip" style={{ color: "var(--danger)" }}>{t("Expired")}</span>}
|
||||
<button className="icon-btn sm danger" aria-label={t("Remove key")} onClick={() => void remove(k)}><Trash2 size={16} /></button>
|
||||
</div>
|
||||
<table className="sessions-table" style={{ marginTop: 8 }}>
|
||||
<tbody>
|
||||
<tr><td>{t("Added")}</td><td>{k.createdAt ? formatFullDate(k.createdAt) : "—"}</td></tr>
|
||||
<tr><td>{t("Expires")}</td><td>{k.expiresAt ? formatFullDate(k.expiresAt) : t("No expiry set")}</td></tr>
|
||||
<tr><td>{t("Addresses")}</td><td>{k.emailAddresses.length ? k.emailAddresses.join(", ") : t("Any address on this account")}</td></tr>
|
||||
<tr><td>{tc("cryptography", "Key")}</td><td className="mono" style={{ fontSize: ".85em" }}>{keyExcerpt(k.key)}…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{adding ? (
|
||||
<div className="card">
|
||||
<div className="field">
|
||||
<label htmlFor="key-name">{t("Description")}</label>
|
||||
<input id="key-name" className="input" value={draftName} placeholder={t("Work key")} onChange={(e) => setDraftName(e.target.value)} />
|
||||
</div>
|
||||
<div className="field" style={{ marginTop: 8 }}>
|
||||
<label htmlFor="key-body">{t("Public key")}</label>
|
||||
<textarea
|
||||
id="key-body"
|
||||
className="input mono"
|
||||
rows={8}
|
||||
spellCheck={false}
|
||||
value={draftKey}
|
||||
placeholder={"-----BEGIN PGP PUBLIC KEY BLOCK-----\n…\n-----END PGP PUBLIC KEY BLOCK-----"}
|
||||
onChange={(e) => setDraftKey(e.target.value)}
|
||||
/>
|
||||
<p className="hint">
|
||||
{t("Paste the whole armoured block, headers included. The server checks it and says what is wrong if it cannot read it.")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="row gap-8" style={{ marginTop: 12 }}>
|
||||
<button className="btn btn-primary" disabled={busy || !draftKey.trim()} onClick={() => void add()}>
|
||||
{busy ? <span className="spinner" /> : null} {t("Add key")}
|
||||
</button>
|
||||
<button className="btn" disabled={busy} onClick={() => { setAdding(false); setDraftKey(""); setDraftName(""); }}>{t("Cancel")}</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn" onClick={() => setAdding(true)}><Plus size={16} /> {t("Add a key")}</button>
|
||||
)}
|
||||
|
||||
<p className="hint mt-8">
|
||||
{t("Stalwart stores these keys, and this release does no more than manage them: ihasmail does not yet sign, encrypt, decrypt or verify anything with them. Adding one does not by itself start encrypting your mail.")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { lazy, Suspense, type ReactNode } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { ArrowLeft, Bell, EyeOff, Filter, Folder, Info, Keyboard, LayoutTemplate, Palette, PenLine, Plane, Settings as SettingsIcon, ShieldCheck, Tag, Users, Calendar } from "lucide-react";
|
||||
import { ArrowLeft, Bell, EyeOff, Filter, Folder, Info, Keyboard, KeyRound, LayoutTemplate, Palette, PenLine, Plane, Settings as SettingsIcon, ShieldCheck, Tag, Users, Calendar } from "lucide-react";
|
||||
import { Spinner } from "@/ui/misc";
|
||||
import { GeneralSettings } from "./GeneralSettings";
|
||||
import { AppearanceSettings } from "./AppearanceSettings";
|
||||
import { IdentitiesSettings } from "./IdentitiesSettings";
|
||||
import { KeysSettings } from "./KeysSettings";
|
||||
import { FoldersSettings } from "./FoldersSettings";
|
||||
import { LabelsSettings } from "./LabelsSettings";
|
||||
import { TemplatesSettings } from "./TemplatesSettings";
|
||||
@@ -23,6 +24,7 @@ const SECTIONS: Array<{ id: string; label: string; icon: ReactNode; el: ReactNod
|
||||
{ id: "general", label: "General", icon: <SettingsIcon size={18} />, el: <GeneralSettings /> },
|
||||
{ id: "appearance", label: "Appearance", icon: <Palette size={18} />, el: <AppearanceSettings /> },
|
||||
{ id: "identities", label: "Identities & signatures", icon: <PenLine size={18} />, el: <IdentitiesSettings /> },
|
||||
{ id: "keys", label: "Encryption keys", icon: <KeyRound size={18} />, el: <KeysSettings /> },
|
||||
{ id: "filters", label: "Filters & rules", icon: <Filter size={18} />, el: <FiltersSettings /> },
|
||||
{ id: "vacation", label: "Out of office", icon: <Plane size={18} />, el: <VacationSettings /> },
|
||||
{ id: "folders", label: "Folders", icon: <Folder size={18} />, el: <FoldersSettings /> },
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useToasts } from "@/ui/toast";
|
||||
import type { PublicKey } from "@/lib/publicKeys";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
/*
|
||||
* The store tests cover reading a key without parsing one. What they cannot
|
||||
* reach is the thing this section exists to get right: when the server refuses
|
||||
* a key it says exactly what is wrong with it, and that sentence has to arrive
|
||||
* in front of the person who pasted it, with what they pasted still on screen.
|
||||
* A component that swallowed the message and cleared the box would pass every
|
||||
* assertion in publicKeys.test.ts.
|
||||
*/
|
||||
let available = true;
|
||||
let keys: PublicKey[] = [];
|
||||
let addError: string | null = null;
|
||||
const added = vi.fn();
|
||||
|
||||
vi.mock("@/lib/publicKeys", async (orig) => {
|
||||
const real = await orig<typeof import("@/lib/publicKeys")>();
|
||||
return {
|
||||
...real,
|
||||
publicKeysAvailable: () => available,
|
||||
listPublicKeys: async () => keys,
|
||||
addPublicKey: async (key: string, description: string) => {
|
||||
if (addError) throw new Error(addError);
|
||||
added(key, description);
|
||||
return { id: "pk1", key, description, createdAt: null, expiresAt: null, emailAddresses: [] };
|
||||
},
|
||||
updatePublicKey: async () => {},
|
||||
removePublicKey: async () => {},
|
||||
};
|
||||
});
|
||||
|
||||
const { KeysSettings } = await import("../KeysSettings");
|
||||
|
||||
const key = (over: Partial<PublicKey> = {}): PublicKey => ({
|
||||
id: "k1",
|
||||
key: "-----BEGIN PGP PUBLIC KEY BLOCK-----\nmDMEZabcdefghijklmnopqrstuvwxyz0123456789\n-----END PGP PUBLIC KEY BLOCK-----",
|
||||
description: "Work key",
|
||||
createdAt: "2026-09-01T10:00:00Z",
|
||||
expiresAt: null,
|
||||
emailAddresses: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("Encryption keys", () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
const render = async () => {
|
||||
await act(async () => {
|
||||
root.render(<KeysSettings />);
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
available = true;
|
||||
keys = [];
|
||||
addError = null;
|
||||
added.mockClear();
|
||||
useToasts.setState({ toasts: [] });
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
const type = async (el: HTMLTextAreaElement | HTMLInputElement, value: string) => {
|
||||
const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
||||
Object.getOwnPropertyDescriptor(proto, "value")!.set!.call(el, value);
|
||||
await act(async () => {
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
};
|
||||
|
||||
const click = async (el: Element) => {
|
||||
await act(async () => {
|
||||
el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
};
|
||||
|
||||
it("says the server does not offer the registry, rather than showing no keys", async () => {
|
||||
available = false;
|
||||
await render();
|
||||
expect(host.textContent).toContain("Not available on this server");
|
||||
expect(host.textContent).not.toContain("No keys yet");
|
||||
});
|
||||
|
||||
it("shows the server's own complaint and keeps what was pasted", async () => {
|
||||
// Verbatim from a live Stalwart rejecting a block that was not OpenPGP.
|
||||
addError = "Failed to decode OpenPGP public key: Malformed packet: Malformed CTB: MSB of ptag not set.";
|
||||
await render();
|
||||
|
||||
await click([...host.querySelectorAll("button")].find((b) => b.textContent?.includes("Add a key"))!);
|
||||
const body = host.querySelector("#key-body") as HTMLTextAreaElement;
|
||||
await type(body, "not a key at all");
|
||||
await click([...host.querySelectorAll("button")].find((b) => b.textContent?.includes("Add key"))!);
|
||||
|
||||
const shown = useToasts.getState().toasts;
|
||||
expect(shown.at(-1)?.kind).toBe("error");
|
||||
expect(shown.at(-1)?.message).toBe(addError);
|
||||
// The form is still filled in: retyping a key block by hand is the one
|
||||
// thing a rejection must not cost.
|
||||
expect((host.querySelector("#key-body") as HTMLTextAreaElement).value).toBe("not a key at all");
|
||||
});
|
||||
|
||||
it("labels the kind from the armour header", async () => {
|
||||
keys = [key(), key({ id: "k2", key: "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----", description: "Corporate" })];
|
||||
await render();
|
||||
const chips = [...host.querySelectorAll(".chip")].map((c) => c.textContent);
|
||||
expect(chips).toContain("OpenPGP");
|
||||
expect(chips).toContain("S/MIME");
|
||||
});
|
||||
|
||||
it("renames through an input with an accessible name, not a bare heading", async () => {
|
||||
keys = [key()];
|
||||
await render();
|
||||
await click(host.querySelector("h3")!);
|
||||
const input = host.querySelector('input[aria-label="Key description"]') as HTMLInputElement;
|
||||
expect(input).not.toBeNull();
|
||||
expect(input.value).toBe("Work key");
|
||||
});
|
||||
|
||||
it("labels a key with no description rather than showing an empty heading", async () => {
|
||||
keys = [key({ description: "" })];
|
||||
await render();
|
||||
expect(host.querySelector("h3")?.textContent).toBe("Untitled key");
|
||||
});
|
||||
|
||||
it("sends an empty description as empty, so no English is stored on the server", async () => {
|
||||
await render();
|
||||
await click([...host.querySelectorAll("button")].find((b) => b.textContent?.includes("Add a key"))!);
|
||||
await type(host.querySelector("#key-body") as HTMLTextAreaElement, "-----BEGIN PGP PUBLIC KEY BLOCK-----\nx\n-----END PGP PUBLIC KEY BLOCK-----");
|
||||
await click([...host.querySelectorAll("button")].find((b) => b.textContent?.includes("Add key"))!);
|
||||
expect(added).toHaveBeenCalledWith(expect.stringContaining("BEGIN PGP"), "");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user