Aktueller Stand
This commit is contained in:
1280
frontend/src/App.tsx
1280
frontend/src/App.tsx
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,7 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
const [selectedJobIds, setSelectedJobIds] = useState<string[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<"tenants" | "users" | "accounts" | "jobs" | "settings">(
|
||||
(localStorage.getItem("ui.adminTab") as "tenants" | "users" | "accounts" | "jobs" | "settings") ?? "tenants"
|
||||
);
|
||||
@@ -66,6 +67,12 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
const [exportStatus, setExportStatus] = useState<"idle" | "loading" | "done" | "failed">("idle");
|
||||
const [exportJobId, setExportJobId] = useState<string | null>(null);
|
||||
const [exportHistory, setExportHistory] = useState<{ id: string; status: string; expiresAt?: string | null; createdAt?: string; progress?: number }[]>([]);
|
||||
const [confirmDialog, setConfirmDialog] = useState<{
|
||||
open: boolean;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
onConfirm?: () => Promise<void> | void;
|
||||
}>({ open: false, message: "" });
|
||||
const [exportFilter, setExportFilter] = useState<"all" | "active" | "done" | "failed" | "expired">("all");
|
||||
const [exportScope, setExportScope] = useState<"all" | "users" | "accounts" | "jobs" | "rules">("all");
|
||||
const [exportFormat, setExportFormat] = useState<"json" | "csv" | "zip">("json");
|
||||
@@ -77,7 +84,8 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
const [settingsDraft, setSettingsDraft] = useState({
|
||||
googleClientId: "",
|
||||
googleClientSecret: "",
|
||||
googleRedirectUri: ""
|
||||
googleRedirectUri: "",
|
||||
cleanupScanLimit: ""
|
||||
});
|
||||
const [settingsStatus, setSettingsStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||
const [showGoogleSecret, setShowGoogleSecret] = useState(false);
|
||||
@@ -118,10 +126,30 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
return t("toastGenericError");
|
||||
};
|
||||
|
||||
const openConfirmDialog = (message: string, onConfirm: () => Promise<void> | void, confirmLabel?: string) => {
|
||||
setConfirmDialog({ open: true, message, onConfirm, confirmLabel });
|
||||
};
|
||||
|
||||
const closeConfirmDialog = () => {
|
||||
setConfirmDialog({ open: false, message: "" });
|
||||
};
|
||||
|
||||
const handleConfirmAction = async () => {
|
||||
const action = confirmDialog.onConfirm;
|
||||
closeConfirmDialog();
|
||||
if (action) {
|
||||
await action();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadAll().catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedJobIds((prev) => prev.filter((id) => jobs.some((job) => job.id === id)));
|
||||
}, [jobs]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("ui.adminTab", activeTab);
|
||||
}, [activeTab]);
|
||||
@@ -133,7 +161,8 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
setSettingsDraft({
|
||||
googleClientId: next["google.client_id"]?.value ?? "",
|
||||
googleClientSecret: next["google.client_secret"]?.value ?? "",
|
||||
googleRedirectUri: next["google.redirect_uri"]?.value ?? ""
|
||||
googleRedirectUri: next["google.redirect_uri"]?.value ?? "",
|
||||
cleanupScanLimit: next["cleanup.scan_limit"]?.value ?? ""
|
||||
});
|
||||
};
|
||||
|
||||
@@ -217,14 +246,19 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
};
|
||||
|
||||
const deleteTenant = async (tenant: Tenant) => {
|
||||
if (!confirm(t("adminDeleteConfirm", { name: tenant.name }))) return;
|
||||
try {
|
||||
await apiFetch(`/admin/tenants/${tenant.id}`, { method: "DELETE" }, token);
|
||||
setTenants((prev) => prev.filter((item) => item.id !== tenant.id));
|
||||
pushToast(t("toastTenantDeleted"), "info");
|
||||
} catch (err) {
|
||||
pushToast(getErrorMessage(err), "error");
|
||||
}
|
||||
openConfirmDialog(
|
||||
t("adminDeleteConfirm", { name: tenant.name }),
|
||||
async () => {
|
||||
try {
|
||||
await apiFetch(`/admin/tenants/${tenant.id}`, { method: "DELETE" }, token);
|
||||
setTenants((prev) => prev.filter((item) => item.id !== tenant.id));
|
||||
pushToast(t("toastTenantDeleted"), "info");
|
||||
} catch (err) {
|
||||
pushToast(getErrorMessage(err), "error");
|
||||
}
|
||||
},
|
||||
t("adminDelete")
|
||||
);
|
||||
};
|
||||
|
||||
const toggleUser = async (user: User) => {
|
||||
@@ -314,6 +348,59 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const deleteJob = async (job: Job) => {
|
||||
openConfirmDialog(
|
||||
t("adminDeleteJobConfirm"),
|
||||
async () => {
|
||||
try {
|
||||
await apiFetch(`/admin/jobs/${job.id}`, { method: "DELETE" }, token);
|
||||
setJobs((prev) => prev.filter((item) => item.id !== job.id));
|
||||
pushToast(t("toastJobDeleted"), "info");
|
||||
} catch (err) {
|
||||
pushToast(getErrorMessage(err), "error");
|
||||
}
|
||||
},
|
||||
t("adminDelete")
|
||||
);
|
||||
};
|
||||
|
||||
const toggleJobSelection = (jobId: string) => {
|
||||
setSelectedJobIds((prev) => (prev.includes(jobId) ? prev.filter((id) => id !== jobId) : [...prev, jobId]));
|
||||
};
|
||||
|
||||
const toggleAllJobs = (jobIds: string[]) => {
|
||||
setSelectedJobIds((prev) => (prev.length === jobIds.length ? [] : [...jobIds]));
|
||||
};
|
||||
|
||||
const cancelSelectedJobs = async () => {
|
||||
if (selectedJobIds.length === 0) return;
|
||||
try {
|
||||
await Promise.all(selectedJobIds.map((id) => apiFetch(`/admin/jobs/${id}/cancel`, { method: "POST" }, token)));
|
||||
setJobs((prev) => prev.map((item) => (selectedJobIds.includes(item.id) ? { ...item, status: "CANCELED" } : item)));
|
||||
pushToast(t("toastJobCanceled"), "info");
|
||||
} catch (err) {
|
||||
pushToast(getErrorMessage(err), "error");
|
||||
}
|
||||
};
|
||||
|
||||
const deleteSelectedJobs = async () => {
|
||||
if (selectedJobIds.length === 0) return;
|
||||
openConfirmDialog(
|
||||
t("adminDeleteJobsConfirm", { count: selectedJobIds.length }),
|
||||
async () => {
|
||||
try {
|
||||
await Promise.all(selectedJobIds.map((id) => apiFetch(`/admin/jobs/${id}`, { method: "DELETE" }, token)));
|
||||
setJobs((prev) => prev.filter((item) => !selectedJobIds.includes(item.id)));
|
||||
setSelectedJobIds([]);
|
||||
pushToast(t("toastJobDeleted"), "info");
|
||||
} catch (err) {
|
||||
pushToast(getErrorMessage(err), "error");
|
||||
}
|
||||
},
|
||||
t("adminDelete")
|
||||
);
|
||||
};
|
||||
|
||||
const sortBy = <T,>(items: T[], mode: string, getKey: (item: T) => string) => {
|
||||
const sorted = [...items];
|
||||
if (mode === "name" || mode === "email" || mode === "status") {
|
||||
@@ -364,7 +451,8 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
settings: {
|
||||
"google.client_id": settingsDraft.googleClientId,
|
||||
"google.client_secret": settingsDraft.googleClientSecret,
|
||||
"google.redirect_uri": settingsDraft.googleRedirectUri
|
||||
"google.redirect_uri": settingsDraft.googleRedirectUri,
|
||||
"cleanup.scan_limit": settingsDraft.cleanupScanLimit
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -673,6 +761,24 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="list-toolbar">
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={jobsSorted.length > 0 && selectedJobIds.length === jobsSorted.length}
|
||||
onChange={() => toggleAllJobs(jobsSorted.map((job) => job.id))}
|
||||
/>
|
||||
{t("selectAll")}
|
||||
</label>
|
||||
<div className="inline-actions">
|
||||
<button className="ghost" type="button" onClick={cancelSelectedJobs} disabled={selectedJobIds.length === 0}>
|
||||
{t("adminCancelSelected")}
|
||||
</button>
|
||||
<button className="ghost" type="button" onClick={deleteSelectedJobs} disabled={selectedJobIds.length === 0}>
|
||||
{t("adminDeleteSelected")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{jobsSorted.map((job) => (
|
||||
<div key={job.id} className="list-item">
|
||||
<div>
|
||||
@@ -680,8 +786,15 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
<p>{job.tenant?.name ?? "-"} · {job.mailboxAccount?.email ?? "-"}</p>
|
||||
</div>
|
||||
<div className="inline-actions">
|
||||
<input
|
||||
className="checkbox-input"
|
||||
type="checkbox"
|
||||
checked={selectedJobIds.includes(job.id)}
|
||||
onChange={() => toggleJobSelection(job.id)}
|
||||
/>
|
||||
<button className="ghost" onClick={() => retryJob(job)}>{t("adminRetry")}</button>
|
||||
<button className="ghost" onClick={() => cancelJob(job)}>{t("adminCancelJob")}</button>
|
||||
<button className="ghost" onClick={() => deleteJob(job)}>{t("adminDelete")}</button>
|
||||
<span>{new Date(job.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -718,6 +831,17 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, googleRedirectUri: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="field-row">
|
||||
<span>{t("adminCleanupScanLimit")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={settingsDraft.cleanupScanLimit}
|
||||
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, cleanupScanLimit: event.target.value }))}
|
||||
/>
|
||||
<small className="hint-text">{t("adminCleanupScanLimitHint")}</small>
|
||||
</label>
|
||||
<div className="inline-actions">
|
||||
<button className="ghost" onClick={() => setShowGoogleSecret((prev) => !prev)}>
|
||||
{showGoogleSecret ? t("adminHideSecret") : t("adminShowSecret")}
|
||||
@@ -738,6 +862,29 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{confirmDialog.open && (
|
||||
<div className="modal-backdrop" onClick={closeConfirmDialog}>
|
||||
<div className="modal" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>{t("confirmTitle")}</h3>
|
||||
<button className="ghost" type="button" onClick={closeConfirmDialog}>
|
||||
{t("close")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p>{confirmDialog.message}</p>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="ghost" type="button" onClick={closeConfirmDialog}>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
<button className="primary" type="button" onClick={handleConfirmAction}>
|
||||
{confirmDialog.confirmLabel ?? t("confirm")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,14 @@ export const apiFetch = async (path: string, options: RequestInit = {}, token?:
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(text);
|
||||
};
|
||||
|
||||
export const createEventSource = async (jobId: string, token: string) => {
|
||||
|
||||
@@ -27,6 +27,14 @@
|
||||
"register": "Registrieren",
|
||||
"createAccount": "Account erstellen",
|
||||
"noAccount": "Noch keinen Account?",
|
||||
"confirm": "Bestätigen",
|
||||
"confirmTitle": "Bitte bestätigen",
|
||||
"cancel": "Abbrechen",
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
"provider": "Anbieter",
|
||||
"authMode": "Login-Methode",
|
||||
"oauth": "OAuth",
|
||||
"passwordResetTitle": "Passwortänderung erforderlich",
|
||||
"passwordResetHint": "Dein Admin-Passwort wurde zurückgesetzt. Bitte setze ein neues Passwort, um fortzufahren.",
|
||||
"newPassword": "Neues Passwort",
|
||||
@@ -57,6 +65,8 @@
|
||||
"adminGoogleClientId": "Client-ID",
|
||||
"adminGoogleClientSecret": "Client-Secret",
|
||||
"adminGoogleRedirectUri": "Redirect-URL",
|
||||
"adminCleanupScanLimit": "Max. Mails pro Bereinigung",
|
||||
"adminCleanupScanLimitHint": "0 = unbegrenzt. Praktisch für Tests.",
|
||||
"adminSaveSettings": "Einstellungen speichern",
|
||||
"adminSaving": "Speichert...",
|
||||
"adminSettingsSaved": "Gespeichert",
|
||||
@@ -64,6 +74,10 @@
|
||||
"adminShowSecret": "Secret anzeigen",
|
||||
"adminHideSecret": "Secret verbergen",
|
||||
"adminSettingsSource": "Quellen - Client-ID: {{id}}, Secret: {{secret}}, Redirect: {{redirect}}",
|
||||
"selectAll": "Alle auswählen",
|
||||
"adminCancelSelected": "Auswahl abbrechen",
|
||||
"adminDeleteSelected": "Auswahl löschen",
|
||||
"adminDeleteJobsConfirm": "{{count}} ausgewählte Jobs samt Events löschen?",
|
||||
"adminRetry": "Retry",
|
||||
"adminCancelJob": "Cancel",
|
||||
"adminMailboxStatus": "Mailbox Status",
|
||||
@@ -72,7 +86,13 @@
|
||||
"oauthConnect": "OAuth prüfen/verbinden",
|
||||
"gmailConnect": "Gmail OAuth verbinden",
|
||||
"mailboxAdd": "Mailbox hinzufügen",
|
||||
"mailboxEditTitle": "Mailbox bearbeiten",
|
||||
"mailboxSave": "Speichern",
|
||||
"mailboxUpdate": "Aktualisieren",
|
||||
"mailboxEdit": "Bearbeiten",
|
||||
"mailboxDelete": "Löschen",
|
||||
"mailboxCancelEdit": "Abbrechen",
|
||||
"mailboxEmpty": "Noch keine Mailbox. Füge eine hinzu, um zu starten.",
|
||||
"cleanupStart": "Bereinigung starten",
|
||||
"cleanupDryRun": "Dry run (keine Änderungen)",
|
||||
"cleanupUnsubscribe": "Unsubscribe aktiv",
|
||||
@@ -82,6 +102,9 @@
|
||||
"cleanupOauthRequired": "Bitte Gmail OAuth verbinden, bevor die Bereinigung startet.",
|
||||
"cleanupDryRunHint": "Dry run simuliert Routing und Unsubscribe. Es werden keine Änderungen durchgeführt und keine E-Mails gesendet.",
|
||||
"rulesTitle": "Regeln",
|
||||
"rulesAdd": "Regel hinzufügen",
|
||||
"rulesAddTitle": "Regel erstellen",
|
||||
"rulesEditTitle": "Regel bearbeiten",
|
||||
"rulesName": "Rule Name",
|
||||
"rulesEnabled": "Rule aktiv",
|
||||
"rulesConditions": "Bedingungen",
|
||||
@@ -90,8 +113,25 @@
|
||||
"rulesAddAction": "+ Aktion",
|
||||
"rulesSave": "Regel speichern",
|
||||
"rulesOverview": "Regeln Übersicht",
|
||||
"rulesEmpty": "Noch keine Regeln.",
|
||||
"jobsTitle": "Jobs",
|
||||
"jobsEmpty": "Noch keine Jobs.",
|
||||
"jobsProgress": "Fortschritt",
|
||||
"jobsEta": "Restzeit",
|
||||
"jobDetailsTitle": "Job-Details",
|
||||
"jobNoEvents": "Noch keine Events.",
|
||||
"jobEvents": "Job Events",
|
||||
"phaseListing": "Listing",
|
||||
"phaseProcessing": "Verarbeitung",
|
||||
"phaseUnsubscribe": "Abmelden",
|
||||
"phaseListingPending": "Liste wird vorbereitet.",
|
||||
"phaseProcessingPending": "Warte auf Verarbeitung.",
|
||||
"phaseUnsubscribePending": "Warte auf Abmeldung.",
|
||||
"phaseUnsubscribeDisabled": "Abmeldung ist für diesen Job deaktiviert.",
|
||||
"phaseUnsubscribeSummary": "Abgemeldet {{ok}} · Fehlgeschlagen {{failed}} · Dry-Run {{dryRun}} ({{total}} gesamt).",
|
||||
"phaseStatusActive": "Aktiv",
|
||||
"phaseStatusDone": "Fertig",
|
||||
"phaseStatusPending": "Ausstehend",
|
||||
"noJobSelected": "Kein Job ausgewählt.",
|
||||
"tenantName": "Tenant Name",
|
||||
"password": "Passwort",
|
||||
@@ -109,13 +149,22 @@
|
||||
"countJobs": "{{count}} Jobs",
|
||||
"placeholderEmail": "email@example.com",
|
||||
"providerHintGmail": "Gmail nutzt OAuth. Du kannst das Passwort leer lassen und per OAuth verbinden.",
|
||||
"providerHintGmx": "GMX nutzt IMAP. Gib hier dein App‑Passwort oder IMAP‑Passwort ein.",
|
||||
"providerHintWebde": "web.de nutzt IMAP. Gib hier dein App‑Passwort oder IMAP‑Passwort ein.",
|
||||
"providerHelp": "Hilfe zur Mailbox‑Einrichtung",
|
||||
"providerHintGmx": "GMX nutzt IMAP. Gib hier dein App-Passwort oder IMAP-Passwort ein.",
|
||||
"providerHintWebde": "web.de nutzt IMAP. Gib hier dein App-Passwort oder IMAP-Passwort ein.",
|
||||
"mailboxPasswordHintEdit": "Leer lassen, um das aktuelle App-Passwort zu behalten.",
|
||||
"providerHelp": "Hilfe zur Mailbox-Einrichtung",
|
||||
"providerHelpTitle": "Mailbox‑Einrichtung",
|
||||
"providerHelpGmail": "Erstelle einen Google OAuth‑Client und verbinde per OAuth‑Button. Das Passwortfeld ist bei OAuth nicht nötig.",
|
||||
"providerHelpGmx": "Aktiviere IMAP in den GMX‑Einstellungen und erstelle ein App‑Passwort. Dieses Passwort hier verwenden.",
|
||||
"providerHelpWebde": "Aktiviere IMAP in den web.de‑Einstellungen und erstelle ein App‑Passwort. Dieses Passwort hier verwenden.",
|
||||
"wizardStepProvider": "Anbieter",
|
||||
"wizardStepDetails": "Details",
|
||||
"wizardStepReview": "Übersicht",
|
||||
"wizardChooseProvider": "Anbieter wählen",
|
||||
"wizardNext": "Weiter",
|
||||
"wizardBack": "Zurück",
|
||||
"wizardCreate": "Mailbox erstellen",
|
||||
"wizardConnectNow": "Gmail OAuth jetzt verbinden",
|
||||
"close": "Schließen",
|
||||
"providerGmail": "Gmail",
|
||||
"providerGmx": "GMX",
|
||||
@@ -197,6 +246,8 @@
|
||||
"toastLoginSuccess": "Erfolgreich eingeloggt.",
|
||||
"toastRegisterSuccess": "Account erfolgreich erstellt.",
|
||||
"toastMailboxAdded": "Mailbox hinzugefügt.",
|
||||
"toastMailboxUpdated": "Mailbox aktualisiert.",
|
||||
"toastMailboxDeleted": "Mailbox gelöscht.",
|
||||
"toastRuleSaved": "Regel gespeichert.",
|
||||
"toastRuleDeleted": "Regel gelöscht.",
|
||||
"toastCleanupStarted": "Bereinigung gestartet.",
|
||||
@@ -215,6 +266,9 @@
|
||||
"toastPasswordReset": "Passwort zurückgesetzt.",
|
||||
"toastJobCanceled": "Job abgebrochen.",
|
||||
"toastJobRetry": "Job neu gestartet.",
|
||||
"toastJobDeleted": "Job gelöscht.",
|
||||
"toastSettingsSaved": "Einstellungen gespeichert.",
|
||||
"toastSettingsFailed": "Einstellungen konnten nicht gespeichert werden."
|
||||
"toastSettingsFailed": "Einstellungen konnten nicht gespeichert werden.",
|
||||
"confirmMailboxDelete": "Mailbox {{email}} löschen? Dabei werden alle zugehörigen Daten entfernt.",
|
||||
"adminDeleteJobConfirm": "Diesen Job samt Events löschen?"
|
||||
}
|
||||
|
||||
@@ -27,6 +27,14 @@
|
||||
"register": "Register",
|
||||
"createAccount": "Create account",
|
||||
"noAccount": "No account yet?",
|
||||
"confirm": "Confirm",
|
||||
"confirmTitle": "Please confirm",
|
||||
"cancel": "Cancel",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"provider": "Provider",
|
||||
"authMode": "Auth mode",
|
||||
"oauth": "OAuth",
|
||||
"passwordResetTitle": "Password change required",
|
||||
"passwordResetHint": "Your admin password was reset. Please set a new password to continue.",
|
||||
"newPassword": "New password",
|
||||
@@ -57,6 +65,8 @@
|
||||
"adminGoogleClientId": "Client ID",
|
||||
"adminGoogleClientSecret": "Client secret",
|
||||
"adminGoogleRedirectUri": "Redirect URL",
|
||||
"adminCleanupScanLimit": "Max emails per cleanup",
|
||||
"adminCleanupScanLimitHint": "0 = unlimited. Useful for testing.",
|
||||
"adminSaveSettings": "Save settings",
|
||||
"adminSaving": "Saving...",
|
||||
"adminSettingsSaved": "Saved",
|
||||
@@ -64,6 +74,10 @@
|
||||
"adminShowSecret": "Show secret",
|
||||
"adminHideSecret": "Hide secret",
|
||||
"adminSettingsSource": "Sources - Client ID: {{id}}, Secret: {{secret}}, Redirect: {{redirect}}",
|
||||
"selectAll": "Select all",
|
||||
"adminCancelSelected": "Cancel selected",
|
||||
"adminDeleteSelected": "Delete selected",
|
||||
"adminDeleteJobsConfirm": "Delete {{count}} selected jobs and all events?",
|
||||
"adminRetry": "Retry",
|
||||
"adminCancelJob": "Cancel",
|
||||
"adminMailboxStatus": "Mailbox status",
|
||||
@@ -72,7 +86,13 @@
|
||||
"oauthConnect": "Check/connect OAuth",
|
||||
"gmailConnect": "Connect Gmail OAuth",
|
||||
"mailboxAdd": "Add mailbox",
|
||||
"mailboxEditTitle": "Edit mailbox",
|
||||
"mailboxSave": "Save",
|
||||
"mailboxUpdate": "Update",
|
||||
"mailboxEdit": "Edit",
|
||||
"mailboxDelete": "Delete",
|
||||
"mailboxCancelEdit": "Cancel",
|
||||
"mailboxEmpty": "No mailboxes yet. Add one to start cleaning.",
|
||||
"cleanupStart": "Start cleanup",
|
||||
"cleanupDryRun": "Dry run (no changes)",
|
||||
"cleanupUnsubscribe": "Unsubscribe enabled",
|
||||
@@ -82,6 +102,9 @@
|
||||
"cleanupOauthRequired": "Connect Gmail OAuth before starting cleanup.",
|
||||
"cleanupDryRunHint": "Dry run simulates routing and unsubscribe actions. No changes or emails are sent.",
|
||||
"rulesTitle": "Rules",
|
||||
"rulesAdd": "Add rule",
|
||||
"rulesAddTitle": "Create rule",
|
||||
"rulesEditTitle": "Edit rule",
|
||||
"rulesName": "Rule name",
|
||||
"rulesEnabled": "Rule enabled",
|
||||
"rulesConditions": "Conditions",
|
||||
@@ -90,8 +113,25 @@
|
||||
"rulesAddAction": "+ Add action",
|
||||
"rulesSave": "Save rule",
|
||||
"rulesOverview": "Rules overview",
|
||||
"rulesEmpty": "No rules yet.",
|
||||
"jobsTitle": "Jobs",
|
||||
"jobsEmpty": "No jobs yet.",
|
||||
"jobsProgress": "Progress",
|
||||
"jobsEta": "ETA",
|
||||
"jobDetailsTitle": "Job details",
|
||||
"jobNoEvents": "No events yet.",
|
||||
"jobEvents": "Job events",
|
||||
"phaseListing": "Listing",
|
||||
"phaseProcessing": "Processing",
|
||||
"phaseUnsubscribe": "Unsubscribe",
|
||||
"phaseListingPending": "Preparing message list.",
|
||||
"phaseProcessingPending": "Waiting for processing.",
|
||||
"phaseUnsubscribePending": "Waiting for unsubscribe.",
|
||||
"phaseUnsubscribeDisabled": "Unsubscribe disabled for this job.",
|
||||
"phaseUnsubscribeSummary": "Unsubscribed {{ok}} · Failed {{failed}} · Dry run {{dryRun}} ({{total}} total).",
|
||||
"phaseStatusActive": "Active",
|
||||
"phaseStatusDone": "Done",
|
||||
"phaseStatusPending": "Pending",
|
||||
"noJobSelected": "No job selected.",
|
||||
"tenantName": "Tenant name",
|
||||
"password": "Password",
|
||||
@@ -111,11 +151,20 @@
|
||||
"providerHintGmail": "Gmail uses OAuth. You can leave the password empty and connect via the OAuth button.",
|
||||
"providerHintGmx": "GMX uses IMAP. Enter your app password or IMAP password for this mailbox.",
|
||||
"providerHintWebde": "web.de uses IMAP. Enter your app password or IMAP password for this mailbox.",
|
||||
"mailboxPasswordHintEdit": "Leave empty to keep the current app password.",
|
||||
"providerHelp": "Help for mailbox setup",
|
||||
"providerHelpTitle": "Mailbox setup help",
|
||||
"providerHelpGmail": "Create a Google OAuth Client and connect via the OAuth button. The password field is not required when OAuth is used.",
|
||||
"providerHelpGmx": "Enable IMAP in your GMX settings and create an app password. Use that password here.",
|
||||
"providerHelpWebde": "Enable IMAP in your web.de account settings and create an app password. Use that password here.",
|
||||
"wizardStepProvider": "Provider",
|
||||
"wizardStepDetails": "Details",
|
||||
"wizardStepReview": "Review",
|
||||
"wizardChooseProvider": "Choose a provider",
|
||||
"wizardNext": "Next",
|
||||
"wizardBack": "Back",
|
||||
"wizardCreate": "Create mailbox",
|
||||
"wizardConnectNow": "Connect Gmail OAuth now",
|
||||
"close": "Close",
|
||||
"providerGmail": "Gmail",
|
||||
"providerGmx": "GMX",
|
||||
@@ -197,6 +246,8 @@
|
||||
"toastLoginSuccess": "Logged in successfully.",
|
||||
"toastRegisterSuccess": "Account created successfully.",
|
||||
"toastMailboxAdded": "Mailbox added.",
|
||||
"toastMailboxUpdated": "Mailbox updated.",
|
||||
"toastMailboxDeleted": "Mailbox deleted.",
|
||||
"toastRuleSaved": "Rule saved.",
|
||||
"toastRuleDeleted": "Rule deleted.",
|
||||
"toastCleanupStarted": "Cleanup job started.",
|
||||
@@ -215,6 +266,9 @@
|
||||
"toastPasswordReset": "Password reset.",
|
||||
"toastJobCanceled": "Job canceled.",
|
||||
"toastJobRetry": "Job retried.",
|
||||
"toastJobDeleted": "Job deleted.",
|
||||
"toastSettingsSaved": "Settings saved.",
|
||||
"toastSettingsFailed": "Settings save failed."
|
||||
"toastSettingsFailed": "Settings save failed.",
|
||||
"confirmMailboxDelete": "Delete mailbox {{email}}? This will remove all related data.",
|
||||
"adminDeleteJobConfirm": "Delete this job and all its events?"
|
||||
}
|
||||
|
||||
@@ -303,6 +303,14 @@ button.ghost {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 28px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
@@ -314,6 +322,25 @@ button.ghost {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.count-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: rgba(37, 99, 235, 0.12);
|
||||
color: var(--primary-strong);
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
margin-top: auto;
|
||||
display: grid;
|
||||
@@ -374,6 +401,16 @@ button.ghost {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.workspace-header {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.workspace-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
font-size: 18px;
|
||||
}
|
||||
@@ -492,6 +529,13 @@ select {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.list-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.events {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -553,6 +597,289 @@ select {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.modal-wide {
|
||||
width: min(860px, 94vw);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.wizard-steps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wizard-step {
|
||||
display: grid;
|
||||
grid-template-columns: 22px 1fr;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(37, 99, 235, 0.05);
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.wizard-step span {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: rgba(37, 99, 235, 0.12);
|
||||
color: var(--primary-strong);
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.wizard-step.active {
|
||||
background: rgba(37, 99, 235, 0.16);
|
||||
border-color: rgba(37, 99, 235, 0.35);
|
||||
color: var(--primary-strong);
|
||||
}
|
||||
|
||||
.wizard-panel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.wizard-choices {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.choice {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
background: #fff;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.choice:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(37, 99, 235, 0.4);
|
||||
box-shadow: 0 10px 18px rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
.choice.active {
|
||||
border-color: rgba(37, 99, 235, 0.6);
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
color: var(--primary-strong);
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.modal-fullscreen {
|
||||
width: min(1200px, 96vw);
|
||||
height: min(92vh, 1000px);
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
}
|
||||
|
||||
.job-hero {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
background: rgba(37, 99, 235, 0.08);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.job-progress {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
background: rgba(37, 99, 235, 0.15);
|
||||
border-radius: 999px;
|
||||
height: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--primary), var(--primary-strong));
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.job-progress-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.job-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.job-summary p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.job-summary strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.job-phase-bar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.phase-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.phase-fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
background: rgba(37, 99, 235, 0.16);
|
||||
width: 0%;
|
||||
transition: width 0.35s ease;
|
||||
}
|
||||
|
||||
.phase-step .phase-dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.6);
|
||||
box-shadow: 0 0 0 4px rgba(148, 163, 184, 0.1);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.phase-step span:not(.phase-fill):not(.phase-dot) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.phase-step.active {
|
||||
border-color: rgba(37, 99, 235, 0.6);
|
||||
color: var(--text);
|
||||
background: rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
.phase-step.active .phase-dot {
|
||||
background: var(--primary);
|
||||
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.18);
|
||||
animation: phasePulse 1.4s ease infinite;
|
||||
}
|
||||
|
||||
.phase-step.complete {
|
||||
border-color: rgba(37, 99, 235, 0.35);
|
||||
color: var(--text);
|
||||
background: rgba(37, 99, 235, 0.08);
|
||||
}
|
||||
|
||||
.phase-step.complete .phase-dot {
|
||||
background: var(--primary-strong);
|
||||
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.phase-step.disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
@keyframes phasePulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.18);
|
||||
}
|
||||
70% {
|
||||
transform: scale(1.06);
|
||||
box-shadow: 0 0 0 10px rgba(37, 99, 235, 0.08);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
/* Phase summary cards */
|
||||
.phase-summary {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.phase-card {
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
color: var(--text);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.phase-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.phase-card p {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.phase-time {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.phase-card.active {
|
||||
border-color: rgba(37, 99, 235, 0.55);
|
||||
background: rgba(37, 99, 235, 0.12);
|
||||
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.15);
|
||||
}
|
||||
|
||||
.phase-card.complete {
|
||||
border-color: rgba(37, 99, 235, 0.35);
|
||||
background: rgba(37, 99, 235, 0.08);
|
||||
}
|
||||
|
||||
.phase-card.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -652,6 +979,27 @@ select {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.list-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.checkbox-input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user