-
{t('app_passwords.copy_now_warning')}
+
{tk('copy_now_warning')}
-
+
{createdSecret}
+
+
+
);
}
+function AppPasswordsSection() {
+ const { appPasswords, createAppPassword, removeAppPassword } = useAccountSecurityStore();
+ return (
+
+ );
+}
+
+function ApiKeysSection() {
+ const { apiKeys, createApiKey, removeApiKey } = useAccountSecurityStore();
+ return (
+
+ );
+}
+
function EncryptionSection() {
const t = useTranslations('settings.security');
const { encryptionType, isLoadingCrypto } = useAccountSecurityStore();
@@ -630,6 +698,9 @@ export function AccountSecuritySettings() {
+
+
+
{isOAuth && (
<>
diff --git a/locales/de/common.json b/locales/de/common.json
index 08a05646..21a595fe 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -1107,7 +1107,22 @@
"none": "Keine App-Passwörter konfiguriert",
"done": "Fertig",
"expires_label": "Läuft ab (optional)",
- "copy_now_warning": "Kopieren Sie dieses Passwort jetzt - es wird nicht erneut angezeigt."
+ "copy_now_warning": "Kopieren Sie dieses Passwort jetzt - es wird nicht erneut angezeigt.",
+ "allowed_ips_label": "Erlaubte IPs (optional)",
+ "allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
+ "allowed_ips_hint": "Komma- oder leerzeichengetrennt. Leer lassen, um jede IP zuzulassen."
+ },
+ "api_keys": {
+ "title": "API-Schlüssel",
+ "description": "Erstellen Sie API-Schlüssel für Skripte und Integrationen, die direkt mit dem Server kommunizieren",
+ "name_label": "Schlüsselname",
+ "name_placeholder": "z.B. Backup-Skript, CI-Runner",
+ "copy_now_warning": "Kopieren Sie diesen API-Schlüssel jetzt - er wird nicht erneut angezeigt.",
+ "added": "API-Schlüssel erstellt",
+ "removed": "API-Schlüssel entfernt",
+ "add_error": "API-Schlüssel konnte nicht erstellt werden",
+ "remove_error": "API-Schlüssel konnte nicht entfernt werden",
+ "none": "Keine API-Schlüssel konfiguriert"
},
"encryption": {
"section_title": "Verschlüsselung im Ruhezustand",
diff --git a/locales/en/common.json b/locales/en/common.json
index f2687826..157e0506 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -1100,6 +1100,9 @@
"name_label": "App Name",
"name_placeholder": "e.g. Thunderbird, iPhone Mail",
"expires_label": "Expires (optional)",
+ "allowed_ips_label": "Allowed IPs (optional)",
+ "allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
+ "allowed_ips_hint": "Comma- or space-separated. Leave empty to allow any IP.",
"password_label": "Password (leave empty to auto-generate)",
"password_placeholder": "Auto-generated if empty",
"copy_now_warning": "Copy this password now - it will not be shown again.",
@@ -1109,6 +1112,18 @@
"remove_error": "Failed to remove app password",
"none": "No app passwords configured"
},
+ "api_keys": {
+ "title": "API Keys",
+ "description": "Create API keys for scripts and integrations that talk to the server directly",
+ "name_label": "Key Name",
+ "name_placeholder": "e.g. Backup script, CI runner",
+ "copy_now_warning": "Copy this API key now - it will not be shown again.",
+ "added": "API key created",
+ "removed": "API key removed",
+ "add_error": "Failed to create API key",
+ "remove_error": "Failed to remove API key",
+ "none": "No API keys configured"
+ },
"encryption": {
"section_title": "Encryption at Rest",
"label": "Email Encryption",
diff --git a/locales/es/common.json b/locales/es/common.json
index aa153c88..37f993b5 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -1107,7 +1107,22 @@
"none": "No hay contraseñas de aplicación configuradas",
"done": "Hecho",
"expires_label": "Caduca (opcional)",
- "copy_now_warning": "Copia esta contraseña ahora - no se volverá a mostrar."
+ "copy_now_warning": "Copia esta contraseña ahora - no se volverá a mostrar.",
+ "allowed_ips_label": "IPs permitidas (opcional)",
+ "allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
+ "allowed_ips_hint": "Separadas por coma o espacio. Dejar vacío para permitir cualquier IP."
+ },
+ "api_keys": {
+ "title": "Claves API",
+ "description": "Crea claves API para scripts e integraciones que se comunican directamente con el servidor",
+ "name_label": "Nombre de la clave",
+ "name_placeholder": "p. ej. Script de copia de seguridad, CI runner",
+ "copy_now_warning": "Copia esta clave API ahora - no se mostrará de nuevo.",
+ "added": "Clave API creada",
+ "removed": "Clave API eliminada",
+ "add_error": "No se pudo crear la clave API",
+ "remove_error": "No se pudo eliminar la clave API",
+ "none": "No hay claves API configuradas"
},
"encryption": {
"section_title": "Cifrado en reposo",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 28b15d32..7ba16d47 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -1107,7 +1107,22 @@
"none": "Aucun mot de passe d'application configuré",
"done": "Terminé",
"expires_label": "Expire (facultatif)",
- "copy_now_warning": "Copiez ce mot de passe maintenant - il ne sera plus affiché."
+ "copy_now_warning": "Copiez ce mot de passe maintenant - il ne sera plus affiché.",
+ "allowed_ips_label": "IP autorisées (facultatif)",
+ "allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
+ "allowed_ips_hint": "Séparées par virgule ou espace. Laisser vide pour autoriser toute IP."
+ },
+ "api_keys": {
+ "title": "Clés API",
+ "description": "Créez des clés API pour les scripts et intégrations qui communiquent directement avec le serveur",
+ "name_label": "Nom de la clé",
+ "name_placeholder": "ex. Script de sauvegarde, CI runner",
+ "copy_now_warning": "Copiez cette clé API maintenant - elle ne sera plus affichée.",
+ "added": "Clé API créée",
+ "removed": "Clé API supprimée",
+ "add_error": "Échec de la création de la clé API",
+ "remove_error": "Échec de la suppression de la clé API",
+ "none": "Aucune clé API configurée"
},
"encryption": {
"section_title": "Chiffrement au repos",
diff --git a/locales/it/common.json b/locales/it/common.json
index 246304ce..1a6f7276 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -1107,7 +1107,22 @@
"none": "Nessuna password per le app configurata",
"done": "Fatto",
"expires_label": "Scadenza (facoltativa)",
- "copy_now_warning": "Copia questa password ora - non verrà più mostrata."
+ "copy_now_warning": "Copia questa password ora - non verrà più mostrata.",
+ "allowed_ips_label": "IP consentiti (opzionale)",
+ "allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
+ "allowed_ips_hint": "Separati da virgola o spazio. Lasciare vuoto per consentire qualsiasi IP."
+ },
+ "api_keys": {
+ "title": "Chiavi API",
+ "description": "Crea chiavi API per script e integrazioni che comunicano direttamente con il server",
+ "name_label": "Nome chiave",
+ "name_placeholder": "es. Script di backup, CI runner",
+ "copy_now_warning": "Copia subito questa chiave API - non verrà mostrata di nuovo.",
+ "added": "Chiave API creata",
+ "removed": "Chiave API rimossa",
+ "add_error": "Impossibile creare la chiave API",
+ "remove_error": "Impossibile rimuovere la chiave API",
+ "none": "Nessuna chiave API configurata"
},
"encryption": {
"section_title": "Crittografia a riposo",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index ca3806a0..9e3c0286 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -1107,7 +1107,22 @@
"none": "アプリパスワードは設定されていません",
"done": "完了",
"expires_label": "有効期限(任意)",
- "copy_now_warning": "今すぐこのパスワードをコピーしてください - 再表示されません。"
+ "copy_now_warning": "今すぐこのパスワードをコピーしてください - 再表示されません。",
+ "allowed_ips_label": "許可するIP(任意)",
+ "allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
+ "allowed_ips_hint": "カンマまたは空白で区切ります。空欄の場合、すべてのIPを許可します。"
+ },
+ "api_keys": {
+ "title": "APIキー",
+ "description": "サーバーと直接通信するスクリプトや連携用にAPIキーを作成します",
+ "name_label": "キー名",
+ "name_placeholder": "例: バックアップスクリプト、CIランナー",
+ "copy_now_warning": "このAPIキーを今すぐコピーしてください - 二度と表示されません。",
+ "added": "APIキーを作成しました",
+ "removed": "APIキーを削除しました",
+ "add_error": "APIキーの作成に失敗しました",
+ "remove_error": "APIキーの削除に失敗しました",
+ "none": "APIキーは設定されていません"
},
"encryption": {
"section_title": "保存時の暗号化",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 728b893b..74295523 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -1107,7 +1107,22 @@
"none": "설정된 앱 비밀번호가 없어요",
"done": "완료",
"expires_label": "만료 (선택 사항)",
- "copy_now_warning": "지금 이 비밀번호를 복사하세요 - 다시 표시되지 않습니다."
+ "copy_now_warning": "지금 이 비밀번호를 복사하세요 - 다시 표시되지 않습니다.",
+ "allowed_ips_label": "허용된 IP(선택)",
+ "allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
+ "allowed_ips_hint": "쉼표 또는 공백으로 구분합니다. 비워두면 모든 IP가 허용됩니다."
+ },
+ "api_keys": {
+ "title": "API 키",
+ "description": "서버와 직접 통신하는 스크립트 및 통합용 API 키를 만듭니다",
+ "name_label": "키 이름",
+ "name_placeholder": "예: 백업 스크립트, CI 러너",
+ "copy_now_warning": "이 API 키를 지금 복사하세요 - 다시 표시되지 않습니다.",
+ "added": "API 키가 생성되었습니다",
+ "removed": "API 키가 삭제되었습니다",
+ "add_error": "API 키 생성에 실패했습니다",
+ "remove_error": "API 키 삭제에 실패했습니다",
+ "none": "구성된 API 키가 없습니다"
},
"encryption": {
"section_title": "저장 데이터 암호화",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index 5ea41c24..987c6632 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -1107,7 +1107,22 @@
"none": "Lietotņu paroles nav iestatītas",
"done": "Gatavs",
"expires_label": "Derīguma termiņš (pēc izvēles)",
- "copy_now_warning": "Kopējiet šo paroli tagad - tā vairs netiks rādīta."
+ "copy_now_warning": "Kopējiet šo paroli tagad - tā vairs netiks rādīta.",
+ "allowed_ips_label": "Atļautās IP (neobligāti)",
+ "allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
+ "allowed_ips_hint": "Atdalītas ar komatu vai atstarpi. Atstājiet tukšu, lai atļautu jebkuru IP."
+ },
+ "api_keys": {
+ "title": "API atslēgas",
+ "description": "Izveidojiet API atslēgas skriptiem un integrācijām, kas tieši sazinās ar serveri",
+ "name_label": "Atslēgas nosaukums",
+ "name_placeholder": "piem. Dublēšanas skripts, CI palaidējs",
+ "copy_now_warning": "Kopējiet šo API atslēgu tagad - tā vairs netiks parādīta.",
+ "added": "API atslēga izveidota",
+ "removed": "API atslēga noņemta",
+ "add_error": "Neizdevās izveidot API atslēgu",
+ "remove_error": "Neizdevās noņemt API atslēgu",
+ "none": "API atslēgas nav konfigurētas"
},
"encryption": {
"section_title": "Krātuves šifrēšana",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index bc66e2d2..8b6f09e9 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -1107,7 +1107,22 @@
"none": "Geen app-wachtwoorden geconfigureerd",
"done": "Klaar",
"expires_label": "Verloopt (optioneel)",
- "copy_now_warning": "Kopieer dit wachtwoord nu - het wordt niet opnieuw weergegeven."
+ "copy_now_warning": "Kopieer dit wachtwoord nu - het wordt niet opnieuw weergegeven.",
+ "allowed_ips_label": "Toegestane IP's (optioneel)",
+ "allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
+ "allowed_ips_hint": "Gescheiden door komma of spatie. Laat leeg om elk IP toe te staan."
+ },
+ "api_keys": {
+ "title": "API-sleutels",
+ "description": "Maak API-sleutels voor scripts en integraties die rechtstreeks met de server praten",
+ "name_label": "Sleutelnaam",
+ "name_placeholder": "bijv. Back-upscript, CI-runner",
+ "copy_now_warning": "Kopieer deze API-sleutel nu - hij wordt niet opnieuw getoond.",
+ "added": "API-sleutel aangemaakt",
+ "removed": "API-sleutel verwijderd",
+ "add_error": "API-sleutel aanmaken mislukt",
+ "remove_error": "API-sleutel verwijderen mislukt",
+ "none": "Geen API-sleutels geconfigureerd"
},
"encryption": {
"section_title": "Versleuteling in rust",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index ebf36bc3..7dc7b153 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -1107,7 +1107,22 @@
"none": "Brak skonfigurowanych haseł aplikacji",
"done": "Gotowe",
"expires_label": "Wygasa (opcjonalnie)",
- "copy_now_warning": "Skopiuj to hasło teraz - nie zostanie ponownie wyświetlone."
+ "copy_now_warning": "Skopiuj to hasło teraz - nie zostanie ponownie wyświetlone.",
+ "allowed_ips_label": "Dozwolone adresy IP (opcjonalnie)",
+ "allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
+ "allowed_ips_hint": "Oddzielone przecinkiem lub spacją. Pozostaw puste, aby zezwolić na dowolny IP."
+ },
+ "api_keys": {
+ "title": "Klucze API",
+ "description": "Twórz klucze API dla skryptów i integracji komunikujących się bezpośrednio z serwerem",
+ "name_label": "Nazwa klucza",
+ "name_placeholder": "np. Skrypt kopii zapasowej, CI runner",
+ "copy_now_warning": "Skopiuj ten klucz API teraz - nie zostanie pokazany ponownie.",
+ "added": "Klucz API utworzony",
+ "removed": "Klucz API usunięty",
+ "add_error": "Nie udało się utworzyć klucza API",
+ "remove_error": "Nie udało się usunąć klucza API",
+ "none": "Brak skonfigurowanych kluczy API"
},
"encryption": {
"section_title": "Szyfrowanie danych w spoczynku",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 9ef1464b..c6c9c494 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -1107,7 +1107,22 @@
"none": "Nenhuma senha de aplicativo configurada",
"done": "Concluído",
"expires_label": "Expira (opcional)",
- "copy_now_warning": "Copie esta senha agora - ela não será exibida novamente."
+ "copy_now_warning": "Copie esta senha agora - ela não será exibida novamente.",
+ "allowed_ips_label": "IPs permitidos (opcional)",
+ "allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
+ "allowed_ips_hint": "Separados por vírgula ou espaço. Deixe vazio para permitir qualquer IP."
+ },
+ "api_keys": {
+ "title": "Chaves de API",
+ "description": "Crie chaves de API para scripts e integrações que se comunicam diretamente com o servidor",
+ "name_label": "Nome da chave",
+ "name_placeholder": "ex. Script de backup, CI runner",
+ "copy_now_warning": "Copie esta chave de API agora - ela não será mostrada novamente.",
+ "added": "Chave de API criada",
+ "removed": "Chave de API removida",
+ "add_error": "Falha ao criar chave de API",
+ "remove_error": "Falha ao remover chave de API",
+ "none": "Nenhuma chave de API configurada"
},
"encryption": {
"section_title": "Criptografia em repouso",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index 06762096..3b20f3b1 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -1107,7 +1107,22 @@
"none": "Пароли приложений не настроены",
"done": "Готово",
"expires_label": "Срок действия (необязательно)",
- "copy_now_warning": "Скопируйте этот пароль сейчас - он больше не будет показан."
+ "copy_now_warning": "Скопируйте этот пароль сейчас - он больше не будет показан.",
+ "allowed_ips_label": "Разрешённые IP (необязательно)",
+ "allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
+ "allowed_ips_hint": "Через запятую или пробел. Оставьте пустым, чтобы разрешить любой IP."
+ },
+ "api_keys": {
+ "title": "API-ключи",
+ "description": "Создавайте API-ключи для скриптов и интеграций, обращающихся к серверу напрямую",
+ "name_label": "Название ключа",
+ "name_placeholder": "напр. Скрипт резервного копирования, CI runner",
+ "copy_now_warning": "Скопируйте этот API-ключ сейчас — он больше не будет показан.",
+ "added": "API-ключ создан",
+ "removed": "API-ключ удалён",
+ "add_error": "Не удалось создать API-ключ",
+ "remove_error": "Не удалось удалить API-ключ",
+ "none": "API-ключи не настроены"
},
"encryption": {
"section_title": "Шифрование хранилища",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index 6940904c..0ba4ef08 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -1107,7 +1107,22 @@
"none": "Паролі програм не налаштовано",
"done": "Готово",
"expires_label": "Термін дії (необов’язково)",
- "copy_now_warning": "Скопіюйте цей пароль зараз - він більше не буде показаний."
+ "copy_now_warning": "Скопіюйте цей пароль зараз - він більше не буде показаний.",
+ "allowed_ips_label": "Дозволені IP (необов’язково)",
+ "allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
+ "allowed_ips_hint": "Через кому або пробіл. Залиште порожнім, щоб дозволити будь-який IP."
+ },
+ "api_keys": {
+ "title": "API-ключі",
+ "description": "Створюйте API-ключі для скриптів та інтеграцій, які звертаються до сервера напряму",
+ "name_label": "Назва ключа",
+ "name_placeholder": "напр. Скрипт резервної копії, CI runner",
+ "copy_now_warning": "Скопіюйте цей API-ключ зараз — він більше не буде показаний.",
+ "added": "API-ключ створено",
+ "removed": "API-ключ видалено",
+ "add_error": "Не вдалося створити API-ключ",
+ "remove_error": "Не вдалося видалити API-ключ",
+ "none": "API-ключі не налаштовано"
},
"encryption": {
"section_title": "Шифрування в спокої",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 51879c5e..8909df13 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -1107,7 +1107,22 @@
"none": "未配置应用密码",
"done": "完成",
"expires_label": "过期时间(可选)",
- "copy_now_warning": "立即复制此密码--它将不再显示。"
+ "copy_now_warning": "立即复制此密码--它将不再显示。",
+ "allowed_ips_label": "允许的 IP(可选)",
+ "allowed_ips_placeholder": "10.0.0.5, 192.168.1.0/24",
+ "allowed_ips_hint": "用逗号或空格分隔。留空则允许任意 IP。"
+ },
+ "api_keys": {
+ "title": "API 密钥",
+ "description": "为直接与服务器通信的脚本和集成创建 API 密钥",
+ "name_label": "密钥名称",
+ "name_placeholder": "如:备份脚本、CI runner",
+ "copy_now_warning": "请立即复制此 API 密钥 - 它将不会再次显示。",
+ "added": "API 密钥已创建",
+ "removed": "API 密钥已删除",
+ "add_error": "创建 API 密钥失败",
+ "remove_error": "删除 API 密钥失败",
+ "none": "未配置 API 密钥"
},
"encryption": {
"section_title": "静态加密",
diff --git a/stores/__tests__/account-security-store.test.ts b/stores/__tests__/account-security-store.test.ts
index c7915c5c..470f4b46 100644
--- a/stores/__tests__/account-security-store.test.ts
+++ b/stores/__tests__/account-security-store.test.ts
@@ -49,18 +49,21 @@ describe('account-security-store', () => {
mockedJmap.mockResolvedValueOnce([
['x:AccountPassword/get', { list: [{ id: 'singleton', otpAuth: { otpUrl: 'otpauth://totp/x' } }] }, '0'],
['x:AppPassword/query', { ids: [] }, '1'],
+ ['x:ApiKey/query', { ids: [] }, '2'],
]);
await useAccountSecurityStore.getState().fetchAuthInfo();
expect(useAccountSecurityStore.getState().otpEnabled).toBe(true);
expect(useAccountSecurityStore.getState().appPasswords).toEqual([]);
+ expect(useAccountSecurityStore.getState().apiKeys).toEqual([]);
});
it('reports TOTP disabled when otpAuth is empty', async () => {
mockedJmap.mockResolvedValueOnce([
['x:AccountPassword/get', { list: [{ id: 'singleton', otpAuth: {} }] }, '0'],
['x:AppPassword/query', { ids: [] }, '1'],
+ ['x:ApiKey/query', { ids: [] }, '2'],
]);
await useAccountSecurityStore.getState().fetchAuthInfo();
@@ -68,11 +71,12 @@ describe('account-security-store', () => {
expect(useAccountSecurityStore.getState().otpEnabled).toBe(false);
});
- it('resolves app password rows via a follow-up Get when query returns ids', async () => {
+ it('resolves app password and api key rows via a single follow-up batch when queries return ids', async () => {
mockedJmap
.mockResolvedValueOnce([
['x:AccountPassword/get', { list: [{ otpAuth: {} }] }, '0'],
['x:AppPassword/query', { ids: ['p1'] }, '1'],
+ ['x:ApiKey/query', { ids: ['k1'] }, '2'],
])
.mockResolvedValueOnce([
['x:AppPassword/get', {
@@ -83,7 +87,16 @@ describe('account-security-store', () => {
expiresAt: null,
allowedIps: { '10.0.0.1': true },
}],
- }, '0'],
+ }, 'app'],
+ ['x:ApiKey/get', {
+ list: [{
+ id: 'k1',
+ description: 'CI bot',
+ createdAt: '2026-02-01T00:00:00Z',
+ expiresAt: '2027-01-01T00:00:00Z',
+ allowedIps: {},
+ }],
+ }, 'key'],
]);
await useAccountSecurityStore.getState().fetchAuthInfo();
@@ -96,6 +109,13 @@ describe('account-security-store', () => {
expiresAt: null,
allowedIps: ['10.0.0.1'],
});
+ const k = useAccountSecurityStore.getState().apiKeys[0];
+ expect(k).toMatchObject({
+ id: 'k1',
+ description: 'CI bot',
+ expiresAt: '2027-01-01T00:00:00Z',
+ allowedIps: [],
+ });
expect(mockedJmap).toHaveBeenCalledTimes(2);
});
@@ -257,24 +277,48 @@ describe('account-security-store', () => {
.mockResolvedValueOnce([
['x:AccountPassword/get', { list: [{ otpAuth: {} }] }, '0'],
['x:AppPassword/query', { ids: [] }, '1'],
+ ['x:ApiKey/query', { ids: [] }, '2'],
]);
- const result = await useAccountSecurityStore.getState().createAppPassword('CLI', '2026-12-01T00:00:00Z');
+ const result = await useAccountSecurityStore
+ .getState()
+ .createAppPassword({ description: 'CLI', expiresAt: '2026-12-01T00:00:00Z', allowedIps: ['10.0.0.1', '192.168.1.0/24'] });
expect(result).toEqual({ id: 'p-new', secret: 'S3CR3T' });
const createArgs = mockedJmap.mock.calls[0][0][0][1];
- expect(createArgs.create.new).toEqual({ description: 'CLI', expiresAt: '2026-12-01T00:00:00Z' });
+ expect(createArgs.create.new).toEqual({
+ description: 'CLI',
+ expiresAt: '2026-12-01T00:00:00Z',
+ allowedIps: { '10.0.0.1': true, '192.168.1.0/24': true },
+ });
expect(mockedJmap).toHaveBeenCalledTimes(2);
});
+ it('omits allowedIps when none provided', async () => {
+ mockedJmap
+ .mockResolvedValueOnce([
+ ['x:AppPassword/set', { created: { new: { id: 'p', secret: 's' } } }, '0'],
+ ])
+ .mockResolvedValueOnce([
+ ['x:AccountPassword/get', { list: [{ otpAuth: {} }] }, '0'],
+ ['x:AppPassword/query', { ids: [] }, '1'],
+ ['x:ApiKey/query', { ids: [] }, '2'],
+ ]);
+
+ await useAccountSecurityStore.getState().createAppPassword({ description: 'CLI' });
+
+ const createArgs = mockedJmap.mock.calls[0][0][0][1];
+ expect(createArgs.create.new).toEqual({ description: 'CLI' });
+ });
+
it('throws with server-provided description when notCreated is returned', async () => {
mockedJmap.mockResolvedValueOnce([
['x:AppPassword/set', { notCreated: { new: { type: 'invalidProperties', description: 'description too short' } } }, '0'],
]);
await expect(
- useAccountSecurityStore.getState().createAppPassword('x')
+ useAccountSecurityStore.getState().createAppPassword({ description: 'x' })
).rejects.toThrow('description too short');
});
@@ -284,7 +328,7 @@ describe('account-security-store', () => {
]);
await expect(
- useAccountSecurityStore.getState().createAppPassword('x')
+ useAccountSecurityStore.getState().createAppPassword({ description: 'x' })
).rejects.toThrow(/did not return/i);
});
});
@@ -296,6 +340,7 @@ describe('account-security-store', () => {
.mockResolvedValueOnce([
['x:AccountPassword/get', { list: [{ otpAuth: {} }] }, '0'],
['x:AppPassword/query', { ids: [] }, '1'],
+ ['x:ApiKey/query', { ids: [] }, '2'],
]);
await useAccountSecurityStore.getState().removeAppPassword('p1');
@@ -306,12 +351,48 @@ describe('account-security-store', () => {
});
});
+ describe('createApiKey / removeApiKey', () => {
+ it('routes through x:ApiKey/set and refreshes auth info', async () => {
+ mockedJmap
+ .mockResolvedValueOnce([
+ ['x:ApiKey/set', { created: { new: { id: 'k1', secret: 'API_KEY' } } }, '0'],
+ ])
+ .mockResolvedValueOnce([
+ ['x:AccountPassword/get', { list: [{ otpAuth: {} }] }, '0'],
+ ['x:AppPassword/query', { ids: [] }, '1'],
+ ['x:ApiKey/query', { ids: [] }, '2'],
+ ]);
+
+ const result = await useAccountSecurityStore.getState().createApiKey({ description: 'bot', allowedIps: ['127.0.0.1'] });
+
+ expect(result).toEqual({ id: 'k1', secret: 'API_KEY' });
+ const createArgs = mockedJmap.mock.calls[0][0][0][1];
+ expect(createArgs.create.new).toEqual({ description: 'bot', allowedIps: { '127.0.0.1': true } });
+ });
+
+ it('removes via x:ApiKey/set destroy', async () => {
+ mockedJmap
+ .mockResolvedValueOnce([['x:ApiKey/set', { destroyed: ['k1'] }, '0']])
+ .mockResolvedValueOnce([
+ ['x:AccountPassword/get', { list: [{ otpAuth: {} }] }, '0'],
+ ['x:AppPassword/query', { ids: [] }, '1'],
+ ['x:ApiKey/query', { ids: [] }, '2'],
+ ]);
+
+ await useAccountSecurityStore.getState().removeApiKey('k1');
+
+ const args = mockedJmap.mock.calls[0][0][0][1];
+ expect(args).toEqual({ accountId: 'acc-primary', destroy: ['k1'] });
+ });
+ });
+
describe('clearState', () => {
it('resets all derived fields back to defaults', () => {
useAccountSecurityStore.setState({
isStalwart: true,
otpEnabled: true,
appPasswords: [{ id: 'p', description: 'd', createdAt: null, expiresAt: null, allowedIps: [] }],
+ apiKeys: [{ id: 'k', description: 'd', createdAt: null, expiresAt: null, allowedIps: [] }],
encryptionType: 'Aes256',
displayName: 'user',
emails: ['a@b'],
@@ -326,6 +407,7 @@ describe('account-security-store', () => {
expect(state.isStalwart).toBeNull();
expect(state.otpEnabled).toBe(false);
expect(state.appPasswords).toEqual([]);
+ expect(state.apiKeys).toEqual([]);
expect(state.encryptionType).toBe('Disabled');
expect(state.displayName).toBe('');
expect(state.emails).toEqual([]);
diff --git a/stores/account-security-store.ts b/stores/account-security-store.ts
index 400339d0..07027fef 100644
--- a/stores/account-security-store.ts
+++ b/stores/account-security-store.ts
@@ -13,6 +13,20 @@ export interface AppPasswordInfo {
allowedIps: string[];
}
+export interface ApiKeyInfo {
+ id: string;
+ description: string;
+ createdAt: string | null;
+ expiresAt: string | null;
+ allowedIps: string[];
+}
+
+export interface AppCredentialInput {
+ description: string;
+ expiresAt?: string | null;
+ allowedIps?: string[];
+}
+
interface AccountSecurityState {
isStalwart: boolean | null;
isProbing: boolean;
@@ -20,6 +34,7 @@ interface AccountSecurityState {
// Auth info
otpEnabled: boolean;
appPasswords: AppPasswordInfo[];
+ apiKeys: ApiKeyInfo[];
isLoadingAuth: boolean;
// Encryption-at-rest
@@ -48,9 +63,12 @@ interface AccountSecurityState {
enableTotp: (currentPassword: string, otpUrl: string, otpCode: string) => Promise
;
disableTotp: (currentPassword: string) => Promise;
- createAppPassword: (description: string, expiresAt?: string | null) => Promise<{ id: string; secret: string }>;
+ createAppPassword: (input: AppCredentialInput) => Promise<{ id: string; secret: string }>;
removeAppPassword: (id: string) => Promise;
+ createApiKey: (input: AppCredentialInput) => Promise<{ id: string; secret: string }>;
+ removeApiKey: (id: string) => Promise;
+
clearState: () => void;
}
@@ -60,7 +78,7 @@ function getPrimaryAccountId(): string {
return client.getAccountId();
}
-function appPasswordFromResult(raw: Record): AppPasswordInfo {
+function credentialFromResult(raw: Record): AppPasswordInfo {
const allowedIps = raw.allowedIps && typeof raw.allowedIps === 'object'
? Object.keys(raw.allowedIps as Record)
: [];
@@ -73,6 +91,88 @@ function appPasswordFromResult(raw: Record): AppPasswordInfo {
};
}
+function ipsToMap(ips?: string[]): Record | undefined {
+ if (!ips || ips.length === 0) return undefined;
+ return Object.fromEntries(ips.map((ip) => [ip, true]));
+}
+
+function buildCreateBody(input: AppCredentialInput): Record {
+ const body: Record = { description: input.description };
+ if (input.expiresAt) body.expiresAt = input.expiresAt;
+ const allowed = ipsToMap(input.allowedIps);
+ if (allowed) body.allowedIps = allowed;
+ return body;
+}
+
+type SetMethod = 'x:AppPassword/set' | 'x:ApiKey/set';
+
+type StoreGet = () => AccountSecurityState;
+type StoreSet = (partial: Partial) => void;
+
+async function createCredential(
+ get: StoreGet,
+ set: StoreSet,
+ method: SetMethod,
+ input: AppCredentialInput,
+ fallbackError: string,
+): Promise<{ id: string; secret: string }> {
+ set({ isSaving: true, error: null });
+ try {
+ const accountId = getPrimaryAccountId();
+ const tmpId = 'new';
+ const responses = await stalwartJmap([
+ [method, { accountId, create: { [tmpId]: buildCreateBody(input) } }, '0'],
+ ]);
+ const result = requireResult<{
+ created?: Record;
+ notCreated?: Record;
+ }>(responses, method);
+
+ const notCreated = result.notCreated?.[tmpId];
+ if (notCreated) {
+ throw new Error(notCreated.description || notCreated.type || fallbackError);
+ }
+ const created = result.created?.[tmpId];
+ if (!created?.id || !created.secret) {
+ throw new Error(`Server did not return created credential`);
+ }
+
+ await get().fetchAuthInfo();
+ set({ isSaving: false });
+ return { id: created.id, secret: created.secret };
+ } catch (error) {
+ set({
+ isSaving: false,
+ error: error instanceof Error ? error.message : fallbackError,
+ });
+ throw error;
+ }
+}
+
+async function removeCredential(
+ get: StoreGet,
+ set: StoreSet,
+ method: SetMethod,
+ id: string,
+ fallbackError: string,
+): Promise {
+ set({ isSaving: true, error: null });
+ try {
+ const accountId = getPrimaryAccountId();
+ await stalwartJmap([
+ [method, { accountId, destroy: [id] }, '0'],
+ ]);
+ await get().fetchAuthInfo();
+ set({ isSaving: false });
+ } catch (error) {
+ set({
+ isSaving: false,
+ error: error instanceof Error ? error.message : fallbackError,
+ });
+ throw error;
+ }
+}
+
function extractEncryptionType(raw: unknown): EncryptionType {
if (!raw || typeof raw !== 'object') return 'Disabled';
const type = (raw as { ['@type']?: string })['@type'];
@@ -85,6 +185,7 @@ export const useAccountSecurityStore = create()((set, get)
isProbing: false,
otpEnabled: false,
appPasswords: [],
+ apiKeys: [],
isLoadingAuth: false,
encryptionType: 'Disabled',
isLoadingCrypto: false,
@@ -117,27 +218,42 @@ export const useAccountSecurityStore = create()((set, get)
const responses = await stalwartJmap([
['x:AccountPassword/get', { accountId, ids: ['singleton'] }, '0'],
['x:AppPassword/query', { accountId }, '1'],
+ ['x:ApiKey/query', { accountId }, '2'],
]);
const passwordResult = requireResult<{ list: Array<{ otpAuth?: { otpUrl?: string | null } }> }>(
responses,
'x:AccountPassword/get',
);
- const queryResult = requireResult<{ ids: string[] }>(responses, 'x:AppPassword/query');
+ const appPwQuery = requireResult<{ ids: string[] }>(responses, 'x:AppPassword/query');
+ const apiKeyQuery = requireResult<{ ids: string[] }>(responses, 'x:ApiKey/query');
const otpAuth = passwordResult.list?.[0]?.otpAuth;
const otpEnabled = !!(otpAuth && typeof otpAuth === 'object' && otpAuth.otpUrl);
- let appPasswords: AppPasswordInfo[] = [];
- if (queryResult.ids?.length) {
- const getResponses = await stalwartJmap([
- ['x:AppPassword/get', { accountId, ids: queryResult.ids }, '0'],
- ]);
- const getResult = requireResult<{ list: Array> }>(getResponses, 'x:AppPassword/get');
- appPasswords = (getResult.list ?? []).map(appPasswordFromResult);
+ const followUps: [string, Record, string][] = [];
+ if (appPwQuery.ids?.length) {
+ followUps.push(['x:AppPassword/get', { accountId, ids: appPwQuery.ids }, 'app']);
+ }
+ if (apiKeyQuery.ids?.length) {
+ followUps.push(['x:ApiKey/get', { accountId, ids: apiKeyQuery.ids }, 'key']);
}
- set({ otpEnabled, appPasswords, isLoadingAuth: false });
+ let appPasswords: AppPasswordInfo[] = [];
+ let apiKeys: ApiKeyInfo[] = [];
+ if (followUps.length) {
+ const followUpResponses = await stalwartJmap(followUps);
+ if (appPwQuery.ids?.length) {
+ const r = requireResult<{ list: Array> }>(followUpResponses, 'x:AppPassword/get');
+ appPasswords = (r.list ?? []).map(credentialFromResult);
+ }
+ if (apiKeyQuery.ids?.length) {
+ const r = requireResult<{ list: Array> }>(followUpResponses, 'x:ApiKey/get');
+ apiKeys = (r.list ?? []).map(credentialFromResult);
+ }
+ }
+
+ set({ otpEnabled, appPasswords, apiKeys, isLoadingAuth: false });
} catch (error) {
debug.error('Failed to fetch auth info:', error);
set({
@@ -319,68 +435,20 @@ export const useAccountSecurityStore = create()((set, get)
}
},
- createAppPassword: async (description, expiresAt) => {
- set({ isSaving: true, error: null });
- try {
- const accountId = getPrimaryAccountId();
- const tmpId = 'new';
- const responses = await stalwartJmap([
- [
- 'x:AppPassword/set',
- {
- accountId,
- create: {
- [tmpId]: {
- description,
- ...(expiresAt ? { expiresAt } : {}),
- },
- },
- },
- '0',
- ],
- ]);
- const result = requireResult<{
- created?: Record;
- notCreated?: Record;
- }>(responses, 'x:AppPassword/set');
-
- const notCreated = result.notCreated?.[tmpId];
- if (notCreated) {
- throw new Error(notCreated.description || notCreated.type || 'Failed to create app password');
- }
- const created = result.created?.[tmpId];
- if (!created?.id || !created.secret) {
- throw new Error('Server did not return created app password');
- }
-
- await get().fetchAuthInfo();
- set({ isSaving: false });
- return { id: created.id, secret: created.secret };
- } catch (error) {
- set({
- isSaving: false,
- error: error instanceof Error ? error.message : 'Failed to create app password',
- });
- throw error;
- }
+ createAppPassword: async (input) => {
+ return createCredential(get, set, 'x:AppPassword/set', input, 'Failed to create app password');
},
removeAppPassword: async (id) => {
- set({ isSaving: true, error: null });
- try {
- const accountId = getPrimaryAccountId();
- await stalwartJmap([
- ['x:AppPassword/set', { accountId, destroy: [id] }, '0'],
- ]);
- await get().fetchAuthInfo();
- set({ isSaving: false });
- } catch (error) {
- set({
- isSaving: false,
- error: error instanceof Error ? error.message : 'Failed to remove app password',
- });
- throw error;
- }
+ return removeCredential(get, set, 'x:AppPassword/set', id, 'Failed to remove app password');
+ },
+
+ createApiKey: async (input) => {
+ return createCredential(get, set, 'x:ApiKey/set', input, 'Failed to create API key');
+ },
+
+ removeApiKey: async (id) => {
+ return removeCredential(get, set, 'x:ApiKey/set', id, 'Failed to remove API key');
},
clearState: () => set({
@@ -388,6 +456,7 @@ export const useAccountSecurityStore = create()((set, get)
isProbing: false,
otpEnabled: false,
appPasswords: [],
+ apiKeys: [],
isLoadingAuth: false,
encryptionType: 'Disabled',
isLoadingCrypto: false,