security: Zufallspasswörter beim Erststart, kryptographisch sichere JTI-Generierung
- seedDefaultUsers: generiert kryptographisch zufällige Passwörter (crypto/rand) statt hartkodiertes "archivmailrockz" — Passwörter werden einmalig im Terminal angezeigt und können danach nicht wiederhergestellt werden - generateJTI: verwendet crypto/rand (16 Byte, hex) statt time.UnixNano XOR deadbeef Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+106
-9
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { features, type Feature } from "@/data/features";
|
||||
import {
|
||||
getUsers,
|
||||
createUser,
|
||||
@@ -276,18 +277,17 @@ export default function AdminPage() {
|
||||
|
||||
const auditTotalPages = Math.ceil(auditTotal / AUDIT_PAGE_SIZE);
|
||||
|
||||
if (authLoading || !user) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Navbar username={user.username} role={user.role} />
|
||||
<Navbar username={user?.username ?? ""} role={user?.role ?? ""} />
|
||||
<main className="mx-auto max-w-7xl px-4 py-6">
|
||||
{(authLoading || !user) ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-10 w-full max-w-sm" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
) : (<>
|
||||
<h1 className="mb-6 text-2xl font-bold">Administration</h1>
|
||||
|
||||
<Tabs defaultValue="dashboard">
|
||||
@@ -296,6 +296,7 @@ export default function AdminPage() {
|
||||
<TabsTrigger value="services">Dienste</TabsTrigger>
|
||||
<TabsTrigger value="users">Benutzer</TabsTrigger>
|
||||
<TabsTrigger value="audit">Audit-Log</TabsTrigger>
|
||||
<TabsTrigger value="modules">Module</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ── Dashboard ── */}
|
||||
@@ -1018,7 +1019,11 @@ export default function AdminPage() {
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="modules" className="mt-4">
|
||||
<ModulesTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>)}
|
||||
</main>
|
||||
|
||||
{/* Passwort-Reset Dialog */}
|
||||
@@ -1060,3 +1065,95 @@ export default function AdminPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Module Tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
"Planned": "bg-gray-100 text-gray-700",
|
||||
"In Progress": "bg-yellow-100 text-yellow-800",
|
||||
"In Review": "bg-blue-100 text-blue-800",
|
||||
"Deployed": "bg-green-100 text-green-800",
|
||||
};
|
||||
|
||||
const statusCounts = (list: Feature[]) => ({
|
||||
total: list.length,
|
||||
planned: list.filter((f) => f.status === "Planned").length,
|
||||
inProgress: list.filter((f) => f.status === "In Progress").length,
|
||||
inReview: list.filter((f) => f.status === "In Review").length,
|
||||
deployed: list.filter((f) => f.status === "Deployed").length,
|
||||
});
|
||||
|
||||
function ModulesTab() {
|
||||
const counts = statusCounts(features);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Modulübersicht</h2>
|
||||
|
||||
{/* Summary bar */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: "In Progress", value: counts.inProgress, color: "bg-yellow-100 text-yellow-800" },
|
||||
{ label: "In Review", value: counts.inReview, color: "bg-blue-100 text-blue-800" },
|
||||
{ label: "Deployed", value: counts.deployed, color: "bg-green-100 text-green-800" },
|
||||
{ label: "Geplant", value: counts.planned, color: "bg-gray-100 text-gray-700" },
|
||||
].map((s) => (
|
||||
<Card key={s.label}>
|
||||
<CardContent className="p-4 flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">{s.label}</span>
|
||||
<span className={`text-lg font-bold px-2 py-0.5 rounded ${s.color}`}>
|
||||
{s.value}
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-20">ID</TableHead>
|
||||
<TableHead>Feature</TableHead>
|
||||
<TableHead className="w-32">Status</TableHead>
|
||||
<TableHead className="w-24 text-center">Frontend</TableHead>
|
||||
<TableHead className="w-24 text-center">Backend</TableHead>
|
||||
<TableHead className="w-32">Aktualisiert</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{features.map((f) => (
|
||||
<TableRow key={f.id}>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{f.id}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{f.name}</TableCell>
|
||||
<TableCell>
|
||||
<span className={`text-xs font-medium px-2 py-1 rounded-full ${statusColors[f.status]}`}>
|
||||
{f.status}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{f.frontend
|
||||
? <span className="text-green-600 font-bold">✓</span>
|
||||
: <span className="text-muted-foreground">–</span>
|
||||
}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{f.backend
|
||||
? <span className="text-green-600 font-bold">✓</span>
|
||||
: <span className="text-muted-foreground">–</span>
|
||||
}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{f.lastUpdated}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -229,18 +229,16 @@ export default function ImapPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (authLoading || !user) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Navbar username={user.username} role={user.role} />
|
||||
<Navbar username={user?.username ?? ""} role={user?.role ?? ""} />
|
||||
<main className="mx-auto max-w-4xl px-4 py-6">
|
||||
{(authLoading || !user) ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
) : (<>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">IMAP Import</h1>
|
||||
<Button
|
||||
@@ -512,6 +510,7 @@ export default function ImapPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -290,18 +290,17 @@ export default function MailViewPage({
|
||||
}
|
||||
}
|
||||
|
||||
if (authLoading || !user) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Navbar username={user.username} role={user.role} />
|
||||
<Navbar username={user?.username ?? ""} role={user?.role ?? ""} />
|
||||
<main className="mx-auto max-w-4xl px-4 py-6 space-y-4">
|
||||
{(authLoading || !user) ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
) : (<>
|
||||
|
||||
{/* Back + Actions */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
@@ -394,6 +393,7 @@ export default function MailViewPage({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
+5
-4
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { login } from "@/lib/api";
|
||||
import { getCachedUser } from "@/lib/auth-cache";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -16,10 +17,10 @@ export default function LoginPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if already logged in via session cookie
|
||||
import("@/lib/api").then(({ getMe }) =>
|
||||
getMe().then(() => router.replace("/search")).catch(() => {})
|
||||
);
|
||||
// Only redirect if we have a cached session — no API call, no loop risk
|
||||
if (getCachedUser() !== null) {
|
||||
router.replace("/search");
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
|
||||
+64
-10
@@ -17,6 +17,13 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -31,6 +38,12 @@ import { Switch } from "@/components/ui/switch";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export default function SearchPage() {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
@@ -40,6 +53,8 @@ export default function SearchPage() {
|
||||
const [toFilter, setToFilter] = useState("");
|
||||
const [dateFrom, setDateFrom] = useState("");
|
||||
const [dateTo, setDateTo] = useState("");
|
||||
const [sort, setSort] = useState("date_desc");
|
||||
const [hasAttachment, setHasAttachment] = useState<boolean | undefined>(undefined);
|
||||
|
||||
const [results, setResults] = useState<SearchHit[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -68,6 +83,8 @@ export default function SearchPage() {
|
||||
to: toFilter || undefined,
|
||||
date_from: dateFrom || undefined,
|
||||
date_to: dateTo || undefined,
|
||||
sort: sort !== "date_desc" ? sort : undefined,
|
||||
has_attachment: hasAttachment,
|
||||
page: p,
|
||||
page_size: PAGE_SIZE,
|
||||
});
|
||||
@@ -82,7 +99,7 @@ export default function SearchPage() {
|
||||
setSearching(false);
|
||||
}
|
||||
},
|
||||
[query, fromFilter, toFilter, dateFrom, dateTo]
|
||||
[query, fromFilter, toFilter, dateFrom, dateTo, sort, hasAttachment]
|
||||
);
|
||||
|
||||
// Alle Mails beim Öffnen der Seite laden — direkt, ohne useCallback-Closure
|
||||
@@ -130,18 +147,18 @@ export default function SearchPage() {
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||
const allSelected = results.length > 0 && results.every((h) => selected.has(h.id));
|
||||
|
||||
if (authLoading || !user) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Navbar username={user.username} role={user.role} />
|
||||
<Navbar username={user?.username ?? ""} role={user?.role ?? ""} />
|
||||
<main className="mx-auto max-w-7xl px-4 py-6">
|
||||
{(authLoading || !user) && (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)}
|
||||
{!authLoading && user && (<>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
@@ -206,6 +223,34 @@ export default function SearchPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="sort-select" className="text-xs whitespace-nowrap">Sortierung</Label>
|
||||
<Select value={sort} onValueChange={setSort}>
|
||||
<SelectTrigger id="sort-select" className="h-8 w-40 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="date_desc">Datum (neu → alt)</SelectItem>
|
||||
<SelectItem value="date_asc">Datum (alt → neu)</SelectItem>
|
||||
<SelectItem value="relevance">Relevanz</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="attach-toggle"
|
||||
checked={hasAttachment === true}
|
||||
onCheckedChange={(checked) =>
|
||||
setHasAttachment(checked ? true : undefined)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="attach-toggle" className="text-xs cursor-pointer">
|
||||
Nur mit Anhang
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-6">
|
||||
@@ -257,6 +302,8 @@ export default function SearchPage() {
|
||||
<TableHead className="w-56">Von</TableHead>
|
||||
<TableHead>Betreff</TableHead>
|
||||
<TableHead className="w-48">An</TableHead>
|
||||
<TableHead className="w-8 text-center" title="Anhang">📎</TableHead>
|
||||
<TableHead className="w-20 text-right">Größe</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -294,6 +341,12 @@ export default function SearchPage() {
|
||||
<TableCell className="max-w-[14rem] truncate text-sm">{hit.from || "-"}</TableCell>
|
||||
<TableCell className="font-medium">{hit.subject || "(kein Betreff)"}</TableCell>
|
||||
<TableCell className="max-w-[12rem] truncate text-sm text-muted-foreground">{hit.to || "-"}</TableCell>
|
||||
<TableCell className="text-center text-sm">
|
||||
{hit.has_attachments ? "📎" : ""}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs text-muted-foreground whitespace-nowrap">
|
||||
{hit.size ? formatBytes(hit.size) : ""}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -353,6 +406,7 @@ export default function SearchPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* Full-page loading skeleton that matches the Navbar + content layout.
|
||||
* Prevents layout shift (flicker) while useAuth checks the session.
|
||||
*/
|
||||
export function PageSkeleton() {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Fake Navbar */}
|
||||
<div className="border-b bg-background">
|
||||
<div className="mx-auto flex h-14 max-w-7xl items-center justify-between px-4">
|
||||
<div className="flex items-center gap-6">
|
||||
<Skeleton className="h-5 w-24" />
|
||||
<Skeleton className="h-4 w-12" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-6 w-12 rounded-full" />
|
||||
<Skeleton className="h-8 w-20 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<main className="mx-auto max-w-7xl px-4 py-6 space-y-4">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+29
-16
@@ -2,35 +2,48 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { getMe, type MeResponse } from "@/lib/api";
|
||||
import { getMe } from "@/lib/api";
|
||||
import { getCachedUser, setCachedUser } from "@/lib/auth-cache";
|
||||
|
||||
interface AuthState {
|
||||
user: MeResponse | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
export { clearAuthCache } from "@/lib/auth-cache";
|
||||
|
||||
export function useAuth(requireRole?: "admin" | "auditor") {
|
||||
const router = useRouter();
|
||||
const [state, setState] = useState<AuthState>({
|
||||
user: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
});
|
||||
const cached = getCachedUser();
|
||||
const [user, setUser] = useState(cached);
|
||||
const [loading, setLoading] = useState(cached === null);
|
||||
|
||||
const checkAuth = useCallback(async () => {
|
||||
const cached = getCachedUser();
|
||||
if (cached !== null) {
|
||||
if (requireRole === "admin" && cached.role !== "admin") {
|
||||
router.replace("/search");
|
||||
return;
|
||||
}
|
||||
if (requireRole === "auditor" && cached.role !== "auditor" && cached.role !== "admin") {
|
||||
router.replace("/search");
|
||||
return;
|
||||
}
|
||||
setUser(cached);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await getMe();
|
||||
if (requireRole === "admin" && user.role !== "admin") {
|
||||
const me = await getMe();
|
||||
setCachedUser(me);
|
||||
if (requireRole === "admin" && me.role !== "admin") {
|
||||
router.replace("/search");
|
||||
return;
|
||||
}
|
||||
if (requireRole === "auditor" && user.role !== "auditor" && user.role !== "admin") {
|
||||
if (requireRole === "auditor" && me.role !== "auditor" && me.role !== "admin") {
|
||||
router.replace("/search");
|
||||
return;
|
||||
}
|
||||
setState({ user, loading: false, error: null });
|
||||
setUser(me);
|
||||
setLoading(false);
|
||||
} catch {
|
||||
setCachedUser(null);
|
||||
router.replace("/");
|
||||
}
|
||||
}, [router, requireRole]);
|
||||
@@ -39,5 +52,5 @@ export function useAuth(requireRole?: "admin" | "auditor") {
|
||||
checkAuth();
|
||||
}, [checkAuth]);
|
||||
|
||||
return state;
|
||||
return { user, loading };
|
||||
}
|
||||
|
||||
+10
-3
@@ -1,3 +1,5 @@
|
||||
import { clearAuthCache } from "@/lib/auth-cache";
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "";
|
||||
|
||||
async function request<T>(
|
||||
@@ -16,9 +18,7 @@ async function request<T>(
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = "/";
|
||||
}
|
||||
clearAuthCache();
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
@@ -81,6 +81,8 @@ export interface SearchHit {
|
||||
to?: string;
|
||||
subject?: string;
|
||||
date?: string;
|
||||
size?: number;
|
||||
has_attachments?: boolean;
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
@@ -155,6 +157,7 @@ export async function getMe(): Promise<MeResponse> {
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
clearAuthCache();
|
||||
await request<void>("/api/auth/logout", { method: "POST" });
|
||||
}
|
||||
|
||||
@@ -164,6 +167,8 @@ export async function searchEmails(params: {
|
||||
to?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
sort?: string;
|
||||
has_attachment?: boolean;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}): Promise<SearchResponse> {
|
||||
@@ -173,6 +178,8 @@ export async function searchEmails(params: {
|
||||
if (params.to) sp.set("to", params.to);
|
||||
if (params.date_from) sp.set("date_from", params.date_from);
|
||||
if (params.date_to) sp.set("date_to", params.date_to);
|
||||
if (params.sort) sp.set("sort", params.sort);
|
||||
if (params.has_attachment !== undefined) sp.set("has_attachment", String(params.has_attachment));
|
||||
if (params.page) sp.set("page", String(params.page));
|
||||
if (params.page_size) sp.set("page_size", String(params.page_size));
|
||||
return request<SearchResponse>(`/api/search?${sp.toString()}`);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { MeResponse } from "@/lib/api";
|
||||
|
||||
let cachedUser: MeResponse | null = null;
|
||||
|
||||
export function getCachedUser(): MeResponse | null {
|
||||
return cachedUser;
|
||||
}
|
||||
|
||||
export function setCachedUser(user: MeResponse | null): void {
|
||||
cachedUser = user;
|
||||
}
|
||||
|
||||
export function clearAuthCache(): void {
|
||||
cachedUser = null;
|
||||
}
|
||||
Reference in New Issue
Block a user