feat(PROJ-53): Konfigurierbare Listenanzahl pro Seite

- users.list_page_size (Default 25), PATCH /api/auth/preferences,
  Whitelist 25/50/100/200, Wert in login/me-Response
- Settings-UI mit Select, /search nutzt gespeicherte Seitengröße
- /api/search page_size serverseitig auf max. 500 gecappt

fix(PROJ-46): login_attempts-Migration nutzte s.db statt s.pool
(Backend kompilierte nicht)

feat(PROJ-50): DSGVO-Löschersuchen Backend (dsgvo_requests, Handler,
cc_addr/bcc_addr Indexerweiterung) — noch nicht QA'd/deployed
This commit is contained in:
sysops
2026-06-14 22:25:02 +02:00
parent b73ef55a65
commit 472ba6a087
25 changed files with 1078 additions and 111 deletions
+9 -7
View File
@@ -44,7 +44,7 @@ import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Bookmark, BookmarkPlus, Trash2 } from "lucide-react";
const PAGE_SIZE = 25;
const DEFAULT_PAGE_SIZE = 25;
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
@@ -86,6 +86,8 @@ export default function SearchPage() {
const { user, loading: authLoading } = useAuth();
const router = useRouter();
const pageSize = user?.list_page_size ?? DEFAULT_PAGE_SIZE;
const [query, setQuery] = useState("");
const [fromFilter, setFromFilter] = useState("");
const [toFilter, setToFilter] = useState("");
@@ -143,7 +145,7 @@ export default function SearchPage() {
sort: sort !== "date_desc" ? sort : undefined,
has_attachment: hasAttachment,
page: p,
page_size: PAGE_SIZE,
page_size: pageSize,
});
setResults(res.hits || []);
setTotal(res.total);
@@ -156,7 +158,7 @@ export default function SearchPage() {
setSearching(false);
}
},
[query, fromFilter, toFilter, dateFrom, dateTo, sort, hasAttachment]
[query, fromFilter, toFilter, dateFrom, dateTo, sort, hasAttachment, pageSize]
);
// Superadmin has no mail access — redirect to admin dashboard
@@ -170,7 +172,7 @@ export default function SearchPage() {
useEffect(() => {
if (!user || user.role === "superadmin") return;
setSearching(true);
searchEmails({ page: 1, page_size: PAGE_SIZE })
searchEmails({ page: 1, page_size: pageSize })
.then((res) => {
setResults(res.hits || []);
setTotal(res.total);
@@ -182,7 +184,7 @@ export default function SearchPage() {
setTotal(0);
})
.finally(() => setSearching(false));
}, [user]);
}, [user, pageSize]);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
@@ -339,7 +341,7 @@ export default function SearchPage() {
date_to: s.query.date_to || undefined,
has_attachment: s.query.has_attachment === "true" ? true : undefined,
page: 1,
page_size: PAGE_SIZE,
page_size: pageSize,
})
.then((res) => {
setResults(res.hits || []);
@@ -364,7 +366,7 @@ export default function SearchPage() {
}
}
const totalPages = Math.ceil(total / PAGE_SIZE);
const totalPages = Math.ceil(total / pageSize);
const allSelected = results.length > 0 && results.every((h) => selected.has(h.id));
return (
+77 -1
View File
@@ -16,10 +16,18 @@ import {
DialogFooter,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Server, Lock, Info } from "lucide-react";
import {
changePassword,
changeEmail,
updatePreferences,
getTOTPSetup,
confirmTOTPSetup,
disableTOTP,
@@ -28,7 +36,7 @@ import {
} from "@/lib/api";
export default function SettingsPage() {
const { user, loading } = useAuth();
const { user, loading, refresh } = useAuth();
// ── Password state ────────────────────────────────────────────────────
const [currentPw, setCurrentPw] = useState("");
@@ -59,6 +67,13 @@ export default function SettingsPage() {
const [disableError, setDisableError] = useState("");
const [disableLoading, setDisableLoading] = useState(false);
// ── Listenanzahl (Eintraege pro Seite) ────────────────────────────────
const [listPageSize, setListPageSize] = useState("25");
const [listPageSizeInitialized, setListPageSizeInitialized] = useState(false);
const [listPageSizeError, setListPageSizeError] = useState("");
const [listPageSizeSuccess, setListPageSizeSuccess] = useState("");
const [listPageSizeLoading, setListPageSizeLoading] = useState(false);
// ── System info (FQDN + IMAP-Ports) ───────────────────────────────────
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null);
const [systemInfoLoading, setSystemInfoLoading] = useState(true);
@@ -87,6 +102,12 @@ export default function SettingsPage() {
setEmailInitialized(true);
}
// Initialize list page size from user data once available
if (user && !listPageSizeInitialized) {
setListPageSize(String(user.list_page_size || 25));
setListPageSizeInitialized(true);
}
// ── Handlers ──────────────────────────────────────────────────────────
async function handleChangePassword(e: React.FormEvent) {
@@ -139,6 +160,24 @@ export default function SettingsPage() {
}
}
async function handleChangeListPageSize(value: string) {
setListPageSizeError("");
setListPageSizeSuccess("");
const parsed = Number(value);
setListPageSizeLoading(true);
try {
const result = await updatePreferences(parsed);
setListPageSize(String(result.list_page_size));
setListPageSizeSuccess("Eintraege pro Seite wurden gespeichert.");
await refresh();
} catch (err) {
setListPageSizeError(err instanceof Error ? err.message : "Fehler beim Speichern");
} finally {
setListPageSizeLoading(false);
}
}
async function handleSetupTOTP() {
setTotpError("");
setTotpSuccess("");
@@ -312,6 +351,43 @@ export default function SettingsPage() {
</CardContent>
</Card>
{/* ── Card 2b: Listenanzahl ─────────────────────────────────────── */}
<Card>
<CardHeader>
<CardTitle>Listenansicht</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="list-page-size">Eintraege pro Seite</Label>
<Select
value={listPageSize}
onValueChange={handleChangeListPageSize}
disabled={listPageSizeLoading}
>
<SelectTrigger id="list-page-size" className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="200">200</SelectItem>
</SelectContent>
</Select>
</div>
{listPageSizeError && (
<Alert variant="destructive">
<AlertDescription>{listPageSizeError}</AlertDescription>
</Alert>
)}
{listPageSizeSuccess && (
<Alert>
<AlertDescription>{listPageSizeSuccess}</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
{/* ── Card 3: Zwei-Faktor-Authentifizierung ────────────────────── */}
<Card>
<CardHeader>
+11 -1
View File
@@ -58,5 +58,15 @@ export function useAuth(requireRole?: "admin" | "domain_admin" | "superadmin" |
checkAuth();
}, [checkAuth]);
return { user, loading };
const refresh = useCallback(async () => {
try {
const me = await getMe();
setCachedUser(me);
setUser(me);
} catch {
// ignore refresh errors, keep existing cached user
}
}, []);
return { user, loading, refresh };
}
+1
View File
@@ -16,6 +16,7 @@ export {
logout,
changePassword,
changeEmail,
updatePreferences,
getUsers,
createUser,
updateUser,
+10
View File
@@ -26,6 +26,7 @@ export interface MeResponse {
username: string;
role: string;
email: string;
list_page_size: number;
}
export interface CreateUserRequest {
@@ -82,6 +83,15 @@ export async function changeEmail(
});
}
export async function updatePreferences(
listPageSize: number
): Promise<{ ok: boolean; list_page_size: number }> {
return request<{ ok: boolean; list_page_size: number }>("/api/auth/preferences", {
method: "PATCH",
body: JSON.stringify({ list_page_size: listPageSize }),
});
}
// ── User management functions ─────────────────────────────────────────────────
export async function getUsers(): Promise<User[]> {