feat: add API Keys management and IP allowlist for App Passwords
This commit is contained in:
@@ -4,11 +4,11 @@ import { useState, useEffect, useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import QRCode from 'qrcode';
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import { Shield, Key, Smartphone, Lock, Trash2, Plus, Eye, EyeOff, Copy, Check, Loader2, Monitor } from 'lucide-react';
|
||||
import { Shield, Key, Smartphone, Lock, Trash2, Plus, Eye, EyeOff, Copy, Check, Loader2, Monitor, Terminal } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import { useAccountSecurityStore, type AppPasswordInfo } from '@/stores/account-security-store';
|
||||
import { useAccountSecurityStore, type AppPasswordInfo, type ApiKeyInfo, type AppCredentialInput } from '@/stores/account-security-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -345,24 +345,43 @@ function TotpSection() {
|
||||
);
|
||||
}
|
||||
|
||||
function AppPasswordRow({ password, onRemove, isSaving }: { password: AppPasswordInfo; onRemove: (id: string) => void; isSaving: boolean }) {
|
||||
function parseIpList(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[\s,]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function CredentialRow({ entry, onRemove, isSaving }: { entry: AppPasswordInfo | ApiKeyInfo; onRemove: (id: string) => void; isSaving: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2 px-3 bg-muted/50 rounded-md">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-foreground">{password.description || password.id}</span>
|
||||
{password.createdAt && (
|
||||
<div className="flex items-start justify-between py-2 px-3 bg-muted/50 rounded-md gap-2">
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-sm text-foreground truncate">{entry.description || entry.id}</span>
|
||||
{entry.createdAt && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(password.createdAt).toLocaleDateString()}
|
||||
{password.expiresAt ? ` · expires ${new Date(password.expiresAt).toLocaleDateString()}` : ''}
|
||||
{new Date(entry.createdAt).toLocaleDateString()}
|
||||
{entry.expiresAt ? ` · expires ${new Date(entry.expiresAt).toLocaleDateString()}` : ''}
|
||||
</span>
|
||||
)}
|
||||
{entry.allowedIps.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{entry.allowedIps.map((ip) => (
|
||||
<span
|
||||
key={ip}
|
||||
className="text-[10px] font-mono bg-background border border-border rounded px-1.5 py-0.5 text-muted-foreground"
|
||||
>
|
||||
{ip}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRemove(password.id)}
|
||||
onClick={() => onRemove(entry.id)}
|
||||
disabled={isSaving}
|
||||
className="text-destructive hover:text-destructive"
|
||||
className="text-destructive hover:text-destructive shrink-0"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
@@ -370,12 +389,22 @@ function AppPasswordRow({ password, onRemove, isSaving }: { password: AppPasswor
|
||||
);
|
||||
}
|
||||
|
||||
function AppPasswordsSection() {
|
||||
interface CredentialSectionProps {
|
||||
icon: typeof Smartphone;
|
||||
i18nNamespace: 'app_passwords' | 'api_keys';
|
||||
entries: Array<AppPasswordInfo | ApiKeyInfo>;
|
||||
onCreate: (input: AppCredentialInput) => Promise<{ id: string; secret: string }>;
|
||||
onRemove: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function CredentialSection({ icon: Icon, i18nNamespace, entries, onCreate, onRemove }: CredentialSectionProps) {
|
||||
const t = useTranslations('settings.security');
|
||||
const { appPasswords, createAppPassword, removeAppPassword, isSaving, isLoadingAuth } = useAccountSecurityStore();
|
||||
const tk = (key: string) => t(`${i18nNamespace}.${key}`);
|
||||
const { isSaving, isLoadingAuth } = useAccountSecurityStore();
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [newDescription, setNewDescription] = useState('');
|
||||
const [expiresAt, setExpiresAt] = useState('');
|
||||
const [allowedIpsRaw, setAllowedIpsRaw] = useState('');
|
||||
const [createdSecret, setCreatedSecret] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
@@ -384,26 +413,28 @@ function AppPasswordsSection() {
|
||||
if (!newDescription.trim()) return;
|
||||
|
||||
try {
|
||||
const result = await createAppPassword(
|
||||
newDescription.trim(),
|
||||
expiresAt ? new Date(expiresAt).toISOString() : null,
|
||||
);
|
||||
const result = await onCreate({
|
||||
description: newDescription.trim(),
|
||||
expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null,
|
||||
allowedIps: parseIpList(allowedIpsRaw),
|
||||
});
|
||||
setCreatedSecret(result.secret);
|
||||
setNewDescription('');
|
||||
setExpiresAt('');
|
||||
setAllowedIpsRaw('');
|
||||
setShowAdd(false);
|
||||
toast.success(t('app_passwords.added'));
|
||||
toast.success(tk('added'));
|
||||
} catch (err) {
|
||||
toast.error(t('app_passwords.add_error'), err instanceof Error ? err.message : undefined);
|
||||
toast.error(tk('add_error'), err instanceof Error ? err.message : undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (id: string) => {
|
||||
try {
|
||||
await removeAppPassword(id);
|
||||
toast.success(t('app_passwords.removed'));
|
||||
await onRemove(id);
|
||||
toast.success(tk('removed'));
|
||||
} catch (err) {
|
||||
toast.error(t('app_passwords.remove_error'), err instanceof Error ? err.message : undefined);
|
||||
toast.error(tk('remove_error'), err instanceof Error ? err.message : undefined);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -419,8 +450,8 @@ function AppPasswordsSection() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Smartphone className="w-4 h-4 text-muted-foreground" />
|
||||
<h4 className="text-sm font-medium text-foreground">{t('app_passwords.title')}</h4>
|
||||
<Icon className="w-4 h-4 text-muted-foreground" />
|
||||
<h4 className="text-sm font-medium text-foreground">{tk('title')}</h4>
|
||||
</div>
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
@@ -431,21 +462,21 @@ function AppPasswordsSection() {
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Smartphone className="w-4 h-4 text-muted-foreground" />
|
||||
<h4 className="text-sm font-medium text-foreground">{t('app_passwords.title')}</h4>
|
||||
<Icon className="w-4 h-4 text-muted-foreground" />
|
||||
<h4 className="text-sm font-medium text-foreground">{tk('title')}</h4>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowAdd(!showAdd)}>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
{t('app_passwords.add')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('app_passwords.description')}</p>
|
||||
<p className="text-xs text-muted-foreground">{tk('description')}</p>
|
||||
|
||||
{createdSecret && (
|
||||
<div className="p-3 bg-muted rounded-md space-y-2">
|
||||
<p className="text-xs text-muted-foreground">{t('app_passwords.copy_now_warning')}</p>
|
||||
<p className="text-xs text-muted-foreground">{tk('copy_now_warning')}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs bg-background px-2 py-1 rounded border border-border flex-1 font-mono">
|
||||
<code className="text-xs bg-background px-2 py-1 rounded border border-border flex-1 font-mono break-all">
|
||||
{createdSecret}
|
||||
</code>
|
||||
<Button variant="outline" size="sm" onClick={handleCopySecret}>
|
||||
@@ -461,11 +492,11 @@ function AppPasswordsSection() {
|
||||
{showAdd && (
|
||||
<form onSubmit={handleAdd} className="p-3 bg-muted rounded-md space-y-2">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.name_label')}</label>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{tk('name_label')}</label>
|
||||
<Input
|
||||
value={newDescription}
|
||||
onChange={(e) => setNewDescription(e.target.value)}
|
||||
placeholder={t('app_passwords.name_placeholder')}
|
||||
placeholder={tk('name_placeholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -473,6 +504,17 @@ function AppPasswordsSection() {
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.expires_label')}</label>
|
||||
<Input type="date" value={expiresAt} onChange={(e) => setExpiresAt(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.allowed_ips_label')}</label>
|
||||
<textarea
|
||||
value={allowedIpsRaw}
|
||||
onChange={(e) => setAllowedIpsRaw(e.target.value)}
|
||||
placeholder={t('app_passwords.allowed_ips_placeholder')}
|
||||
rows={2}
|
||||
className="w-full text-xs font-mono px-3 py-2 rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">{t('app_passwords.allowed_ips_hint')}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" size="sm" disabled={isSaving || !newDescription.trim()}>
|
||||
{isSaving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
|
||||
@@ -485,19 +527,45 @@ function AppPasswordsSection() {
|
||||
</form>
|
||||
)}
|
||||
|
||||
{appPasswords.length > 0 ? (
|
||||
{entries.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{appPasswords.map((p) => (
|
||||
<AppPasswordRow key={p.id} password={p} onRemove={handleRemove} isSaving={isSaving} />
|
||||
{entries.map((entry) => (
|
||||
<CredentialRow key={entry.id} entry={entry} onRemove={handleRemove} isSaving={isSaving} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground italic">{t('app_passwords.none')}</p>
|
||||
<p className="text-xs text-muted-foreground italic">{tk('none')}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppPasswordsSection() {
|
||||
const { appPasswords, createAppPassword, removeAppPassword } = useAccountSecurityStore();
|
||||
return (
|
||||
<CredentialSection
|
||||
icon={Smartphone}
|
||||
i18nNamespace="app_passwords"
|
||||
entries={appPasswords}
|
||||
onCreate={createAppPassword}
|
||||
onRemove={removeAppPassword}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeysSection() {
|
||||
const { apiKeys, createApiKey, removeApiKey } = useAccountSecurityStore();
|
||||
return (
|
||||
<CredentialSection
|
||||
icon={Terminal}
|
||||
i18nNamespace="api_keys"
|
||||
entries={apiKeys}
|
||||
onCreate={createApiKey}
|
||||
onRemove={removeApiKey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EncryptionSection() {
|
||||
const t = useTranslations('settings.security');
|
||||
const { encryptionType, isLoadingCrypto } = useAccountSecurityStore();
|
||||
@@ -630,6 +698,9 @@ export function AccountSecuritySettings() {
|
||||
|
||||
<AppPasswordsSection />
|
||||
|
||||
<div className="border-t border-border" />
|
||||
<ApiKeysSection />
|
||||
|
||||
{isOAuth && (
|
||||
<>
|
||||
<div className="border-t border-border" />
|
||||
|
||||
+16
-1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+16
-1
@@ -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",
|
||||
|
||||
+16
-1
@@ -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",
|
||||
|
||||
+16
-1
@@ -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",
|
||||
|
||||
+16
-1
@@ -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": "保存時の暗号化",
|
||||
|
||||
+16
-1
@@ -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": "저장 데이터 암호화",
|
||||
|
||||
+16
-1
@@ -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",
|
||||
|
||||
+16
-1
@@ -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",
|
||||
|
||||
+16
-1
@@ -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",
|
||||
|
||||
+16
-1
@@ -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",
|
||||
|
||||
+16
-1
@@ -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": "Шифрование хранилища",
|
||||
|
||||
+16
-1
@@ -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": "Шифрування в спокої",
|
||||
|
||||
+16
-1
@@ -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": "静态加密",
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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<void>;
|
||||
disableTotp: (currentPassword: string) => Promise<void>;
|
||||
|
||||
createAppPassword: (description: string, expiresAt?: string | null) => Promise<{ id: string; secret: string }>;
|
||||
createAppPassword: (input: AppCredentialInput) => Promise<{ id: string; secret: string }>;
|
||||
removeAppPassword: (id: string) => Promise<void>;
|
||||
|
||||
createApiKey: (input: AppCredentialInput) => Promise<{ id: string; secret: string }>;
|
||||
removeApiKey: (id: string) => Promise<void>;
|
||||
|
||||
clearState: () => void;
|
||||
}
|
||||
|
||||
@@ -60,7 +78,7 @@ function getPrimaryAccountId(): string {
|
||||
return client.getAccountId();
|
||||
}
|
||||
|
||||
function appPasswordFromResult(raw: Record<string, unknown>): AppPasswordInfo {
|
||||
function credentialFromResult(raw: Record<string, unknown>): AppPasswordInfo {
|
||||
const allowedIps = raw.allowedIps && typeof raw.allowedIps === 'object'
|
||||
? Object.keys(raw.allowedIps as Record<string, unknown>)
|
||||
: [];
|
||||
@@ -73,6 +91,88 @@ function appPasswordFromResult(raw: Record<string, unknown>): AppPasswordInfo {
|
||||
};
|
||||
}
|
||||
|
||||
function ipsToMap(ips?: string[]): Record<string, true> | undefined {
|
||||
if (!ips || ips.length === 0) return undefined;
|
||||
return Object.fromEntries(ips.map((ip) => [ip, true]));
|
||||
}
|
||||
|
||||
function buildCreateBody(input: AppCredentialInput): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = { 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<AccountSecurityState>) => 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<string, { id: string; secret: string; createdAt?: string }>;
|
||||
notCreated?: Record<string, { type: string; description?: string }>;
|
||||
}>(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<void> {
|
||||
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<AccountSecurityState>()((set, get)
|
||||
isProbing: false,
|
||||
otpEnabled: false,
|
||||
appPasswords: [],
|
||||
apiKeys: [],
|
||||
isLoadingAuth: false,
|
||||
encryptionType: 'Disabled',
|
||||
isLoadingCrypto: false,
|
||||
@@ -117,27 +218,42 @@ export const useAccountSecurityStore = create<AccountSecurityState>()((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<Record<string, unknown>> }>(getResponses, 'x:AppPassword/get');
|
||||
appPasswords = (getResult.list ?? []).map(appPasswordFromResult);
|
||||
const followUps: [string, Record<string, unknown>, 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<Record<string, unknown>> }>(followUpResponses, 'x:AppPassword/get');
|
||||
appPasswords = (r.list ?? []).map(credentialFromResult);
|
||||
}
|
||||
if (apiKeyQuery.ids?.length) {
|
||||
const r = requireResult<{ list: Array<Record<string, unknown>> }>(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<AccountSecurityState>()((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<string, { id: string; secret: string; createdAt?: string }>;
|
||||
notCreated?: Record<string, { type: string; description?: string }>;
|
||||
}>(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<AccountSecurityState>()((set, get)
|
||||
isProbing: false,
|
||||
otpEnabled: false,
|
||||
appPasswords: [],
|
||||
apiKeys: [],
|
||||
isLoadingAuth: false,
|
||||
encryptionType: 'Disabled',
|
||||
isLoadingCrypto: false,
|
||||
|
||||
Reference in New Issue
Block a user