Add bank synchronization features with FinTS support and update dependencies
This commit is contained in:
173
src/components/import/BankConfigForm.tsx
Normal file
173
src/components/import/BankConfigForm.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const schema = z.object({
|
||||
providerPreference: z.enum(["auto", "comdirect", "fints"]),
|
||||
fintsBlz: z.string().min(1, "BLZ erforderlich").optional(),
|
||||
fintsUrl: z.string().url("Gültige URL erforderlich").optional(),
|
||||
fintsLogin: z.string().optional(),
|
||||
fintsProductId: z.string().optional(),
|
||||
fintsProductVersion: z.string().optional(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
export function BankConfigForm() {
|
||||
const config = useQuery(api.bank.config.getConfig);
|
||||
const syncState = useQuery(api.bank.config.getSyncState);
|
||||
const updateConfig = useMutation(api.bank.config.updateConfig);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
providerPreference: "auto",
|
||||
fintsBlz: "",
|
||||
fintsUrl: "",
|
||||
fintsLogin: "",
|
||||
fintsProductId: "",
|
||||
fintsProductVersion: "1.0.0",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
form.reset({
|
||||
providerPreference: config.providerPreference,
|
||||
fintsBlz: config.fints.blz,
|
||||
fintsUrl: config.fints.url,
|
||||
fintsLogin: config.fints.login,
|
||||
fintsProductId: config.fints.productId,
|
||||
fintsProductVersion: config.fints.productVersion ?? "1.0.0",
|
||||
});
|
||||
}
|
||||
}, [config, form]);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateConfig({
|
||||
providerPreference: values.providerPreference,
|
||||
fints: {
|
||||
blz: values.fintsBlz ?? "",
|
||||
url: values.fintsUrl ?? "",
|
||||
login: values.fintsLogin ?? "",
|
||||
productId: values.fintsProductId ?? "",
|
||||
productVersion: values.fintsProductVersion,
|
||||
},
|
||||
});
|
||||
toast.success("Bank-Konfiguration gespeichert");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Speichern fehlgeschlagen");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Bank-Sync & FinTS-Fallback</CardTitle>
|
||||
<CardDescription>
|
||||
comdirect REST wird bevorzugt. Fehlen Credentials oder schlägt REST fehl, greift FinTS
|
||||
automatisch (Provider „Auto“). PIN gehört in Convex-Env (FINTS_PIN), nicht in die DB.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{syncState?.lastError && (
|
||||
<Alert className="border-destructive/50 text-destructive">
|
||||
<AlertDescription>Letzter Sync-Fehler: {syncState.lastError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{syncState?.lastSync && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Letzter erfolgreicher Sync:{" "}
|
||||
{new Date(syncState.lastSync).toLocaleString("de-DE")}{" "}
|
||||
{syncState.lastProviderUsed && `(${syncState.lastProviderUsed})`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label>Provider-Präferenz</Label>
|
||||
<Select
|
||||
value={form.watch("providerPreference")}
|
||||
onValueChange={(v) =>
|
||||
form.setValue("providerPreference", v as FormValues["providerPreference"])
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="mt-2 w-full sm:w-[280px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto (REST → FinTS-Fallback)</SelectItem>
|
||||
<SelectItem value="comdirect">Nur comdirect REST</SelectItem>
|
||||
<SelectItem value="fints">Nur FinTS</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="fintsBlz">FinTS BLZ</Label>
|
||||
<Input id="fintsBlz" {...form.register("fintsBlz")} placeholder="20041111" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="fintsUrl">FinTS URL</Label>
|
||||
<Input
|
||||
id="fintsUrl"
|
||||
{...form.register("fintsUrl")}
|
||||
placeholder="https://fints.comdirect.de/fints"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="fintsLogin">Zugangsnummer (Login)</Label>
|
||||
<Input id="fintsLogin" {...form.register("fintsLogin")} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="fintsProductId">Produkt-ID (ZKA, optional)</Label>
|
||||
<Input
|
||||
id="fintsProductId"
|
||||
{...form.register("fintsProductId")}
|
||||
placeholder="Nach Registrierung eintragen"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="fintsProductVersion">Produkt-Version</Label>
|
||||
<Input id="fintsProductVersion" {...form.register("fintsProductVersion")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
REST: COMDIRECT_CLIENT_ID/SECRET in Convex-Env. FinTS: BLZ, URL, PIN in Env –
|
||||
Produkt-ID optional bis zur ZKA-Registrierung.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Button type="submit" disabled={saving}>
|
||||
Konfiguration speichern
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useAction } from "convex/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAction, useQuery } from "convex/react";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -8,23 +8,60 @@ import { Label } from "@/components/ui/label";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { toast } from "sonner";
|
||||
import { subDays, format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
|
||||
type Capabilities = {
|
||||
comdirectRestAvailable: boolean;
|
||||
fintsReady: boolean;
|
||||
fintsMissing: string[];
|
||||
fintsWarnings: string[];
|
||||
useFinTsDirect: boolean;
|
||||
};
|
||||
|
||||
export function ComdirectSyncPanel() {
|
||||
const getCapabilities = useAction(api.bank.sync.getCapabilities);
|
||||
const startAuth = useAction(api.comdirect.auth.start);
|
||||
const confirmAuth = useAction(api.comdirect.auth.confirm);
|
||||
const runSync = useAction(api.comdirect.sync.run);
|
||||
const runSync = useAction(api.bank.sync.run);
|
||||
const syncState = useQuery(api.bank.config.getSyncState);
|
||||
const bankConfig = useQuery(api.bank.config.getConfig);
|
||||
|
||||
const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
|
||||
const [zugangsnummer, setZugangsnummer] = useState("");
|
||||
const [pin, setPin] = useState("");
|
||||
const [fintsPin, setFintsPin] = useState("");
|
||||
const [tan, setTan] = useState("");
|
||||
const [challengeType, setChallengeType] = useState<string | null>(null);
|
||||
const [photoTan, setPhotoTan] = useState<string | null>(null);
|
||||
const [step, setStep] = useState<"login" | "confirm" | "sync">("login");
|
||||
const [step, setStep] = useState<"login" | "confirm" | "sync">("sync");
|
||||
const [from, setFrom] = useState(format(subDays(new Date(), 90), "yyyy-MM-dd"));
|
||||
const [to, setTo] = useState(format(new Date(), "yyyy-MM-dd"));
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void getCapabilities()
|
||||
.then((cap) => {
|
||||
setCapabilities(cap);
|
||||
if (cap.useFinTsDirect || !cap.comdirectRestAvailable) {
|
||||
setStep("sync");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setCapabilities(null);
|
||||
});
|
||||
}, [getCapabilities]);
|
||||
|
||||
const showRestLogin =
|
||||
capabilities?.comdirectRestAvailable &&
|
||||
!capabilities.useFinTsDirect &&
|
||||
bankConfig?.providerPreference !== "fints";
|
||||
|
||||
const handleStart = async () => {
|
||||
if (!capabilities?.comdirectRestAvailable) {
|
||||
toast.message("comdirect REST nicht konfiguriert – direkt über FinTS synchronisieren");
|
||||
setStep("sync");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await startAuth({ zugangsnummer, pin });
|
||||
@@ -57,13 +94,38 @@ export function ComdirectSyncPanel() {
|
||||
};
|
||||
|
||||
const handleSync = async () => {
|
||||
if (capabilities && !capabilities.fintsReady && capabilities.useFinTsDirect) {
|
||||
toast.error(`FinTS unvollständig: ${capabilities.fintsMissing.join(", ")} fehlt`);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await runSync({ from, to });
|
||||
toast.success(`${result.importedCount} importiert, ${result.skippedCount} übersprungen`);
|
||||
setStep("login");
|
||||
const result = await runSync({
|
||||
from,
|
||||
to,
|
||||
pin: fintsPin || undefined,
|
||||
});
|
||||
if (result.awaitingTan) {
|
||||
toast.message("FinTS-Freigabe erforderlich – bitte photoTAN bestätigen");
|
||||
} else {
|
||||
toast.success(
|
||||
`${result.importedCount} importiert (${result.provider}), ${result.skippedCount} übersprungen`,
|
||||
);
|
||||
if (capabilities?.fintsWarnings.length) {
|
||||
toast.message(capabilities.fintsWarnings[0]);
|
||||
}
|
||||
setStep(capabilities?.comdirectRestAvailable && !capabilities.useFinTsDirect ? "login" : "sync");
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Sync fehlgeschlagen");
|
||||
const message = e instanceof Error ? e.message : "Sync fehlgeschlagen";
|
||||
if (syncState?.lastSync) {
|
||||
toast.error(
|
||||
`${message}. Letzter erfolgreicher Sync: ${format(new Date(syncState.lastSync), "PPp", { locale: de })}`,
|
||||
);
|
||||
} else {
|
||||
toast.error(message);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -72,28 +134,48 @@ export function ComdirectSyncPanel() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>comdirect-Sync</CardTitle>
|
||||
<CardTitle>Bank-Sync (comdirect + FinTS-Fallback)</CardTitle>
|
||||
<CardDescription>
|
||||
Halbautomatischer Abruf über Convex Actions. PIN wird nicht gespeichert. Nach dem Sync werden
|
||||
Tokens gelöscht. Achtung: 3× falsche TAN sperrt den Zugang.
|
||||
Ohne comdirect REST-Credentials wird automatisch FinTS genutzt. Fehlende Produkt-ID
|
||||
blockiert den Start nicht.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{syncState?.lastSync && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Letzter Sync: {format(new Date(syncState.lastSync), "PPp", { locale: de })}
|
||||
{syncState.lastProviderUsed && ` via ${syncState.lastProviderUsed}`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Nur lesende Endpoints. Client-ID/Secret müssen in Convex-Env gesetzt sein (
|
||||
COMDIRECT_CLIENT_ID, COMDIRECT_CLIENT_SECRET).
|
||||
{capabilities?.useFinTsDirect ? (
|
||||
<>
|
||||
Modus: <strong>FinTS</strong>
|
||||
{capabilities.comdirectRestAvailable
|
||||
? " (Präferenz oder kein REST-Zwang)"
|
||||
: " (comdirect REST-Credentials fehlen)"}
|
||||
</>
|
||||
) : (
|
||||
<>Modus: comdirect REST mit FinTS-Fallback</>
|
||||
)}
|
||||
{capabilities?.fintsWarnings.map((w) => (
|
||||
<span key={w} className="mt-2 block text-amber-600 dark:text-amber-400">
|
||||
{w}
|
||||
</span>
|
||||
))}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{step === "login" && (
|
||||
{showRestLogin && step === "login" && (
|
||||
<>
|
||||
<div>
|
||||
<Label>Zugangsnummer</Label>
|
||||
<Label>Zugangsnummer (REST)</Label>
|
||||
<Input value={zugangsnummer} onChange={(e) => setZugangsnummer(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Online-Banking-PIN</Label>
|
||||
<Label>Online-Banking-PIN (REST, transient)</Label>
|
||||
<Input type="password" value={pin} onChange={(e) => setPin(e.target.value)} />
|
||||
</div>
|
||||
<Button onClick={handleStart} disabled={loading}>
|
||||
@@ -102,10 +184,12 @@ export function ComdirectSyncPanel() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "confirm" && (
|
||||
{step === "confirm" && showRestLogin && (
|
||||
<>
|
||||
{challengeType === "P_TAN_PUSH" && (
|
||||
<p className="text-sm">Bitte die Push-TAN in der comdirect-App freigeben, dann bestätigen.</p>
|
||||
<p className="text-sm">
|
||||
Bitte die Push-TAN in der comdirect-App freigeben, dann bestätigen.
|
||||
</p>
|
||||
)}
|
||||
{photoTan && (
|
||||
<img src={`data:image/png;base64,${photoTan}`} alt="photoTAN" className="mx-auto max-w-xs" />
|
||||
@@ -122,8 +206,18 @@ export function ComdirectSyncPanel() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "sync" && (
|
||||
{(step === "sync" || capabilities?.useFinTsDirect) && (
|
||||
<>
|
||||
{capabilities?.useFinTsDirect && (
|
||||
<div>
|
||||
<Label>FinTS-PIN (optional, wenn FINTS_PIN nicht in Env)</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={fintsPin}
|
||||
onChange={(e) => setFintsPin(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label>Von</Label>
|
||||
|
||||
102
src/components/import/TanAwaitDialog.tsx
Normal file
102
src/components/import/TanAwaitDialog.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function TanAwaitDialog() {
|
||||
const pendingTan = useQuery(api.bank.config.getPendingTan);
|
||||
const submitTan = useMutation(api.bank.config.submitTan);
|
||||
const [tan, setTan] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingTan?.status === "done") {
|
||||
toast.success("TAN bestätigt – Sync wird fortgesetzt");
|
||||
setTan("");
|
||||
}
|
||||
if (pendingTan?.status === "error" && pendingTan.errorMessage) {
|
||||
toast.error(pendingTan.errorMessage);
|
||||
setTan("");
|
||||
}
|
||||
}, [pendingTan?.status, pendingTan?.errorMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingTan?.status !== "awaiting") {
|
||||
setTan("");
|
||||
}
|
||||
}, [pendingTan?.status, pendingTan?._id]);
|
||||
|
||||
const open = pendingTan?.status === "awaiting";
|
||||
const needsManualTan = pendingTan?.isDecoupled !== true;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!tan.trim()) {
|
||||
toast.error("Bitte die TAN aus der photoTAN-App eingeben");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await submitTan({ tan: tan.trim() });
|
||||
toast.message("TAN übermittelt – Sync läuft weiter …");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "TAN konnte nicht übermittelt werden");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open}>
|
||||
<DialogContent className="sm:max-w-md [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>photoTAN erforderlich</DialogTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{pendingTan?.challengeMessage ??
|
||||
(needsManualTan
|
||||
? "Scannen Sie das Bild mit der photoTAN-App und geben Sie die angezeigte TAN hier ein."
|
||||
: "Bitte die Freigabe in der photoTAN-App bestätigen.")}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
{pendingTan?.photoTanBase64 && (
|
||||
<img
|
||||
src={`data:${pendingTan.photoTanMimeType ?? "image/png"};base64,${pendingTan.photoTanBase64}`}
|
||||
alt="photoTAN"
|
||||
className="mx-auto max-w-xs"
|
||||
/>
|
||||
)}
|
||||
{needsManualTan ? (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="fints-tan">TAN aus der photoTAN-App</Label>
|
||||
<Input
|
||||
id="fints-tan"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
placeholder="z. B. 123456"
|
||||
value={tan}
|
||||
onChange={(e) => setTan(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleSubmit();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button className="w-full" onClick={() => void handleSubmit()} disabled={submitting}>
|
||||
TAN bestätigen
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center text-sm text-muted-foreground">Warte auf Freigabe in der App …</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { CsvImportWizard } from "@/components/import/CsvImportWizard";
|
||||
import { ComdirectSyncPanel } from "@/components/import/ComdirectSyncPanel";
|
||||
import { TanAwaitDialog } from "@/components/import/TanAwaitDialog";
|
||||
|
||||
export function ImportPage() {
|
||||
return (
|
||||
<Tabs defaultValue="csv">
|
||||
<>
|
||||
<TanAwaitDialog />
|
||||
<Tabs defaultValue="csv">
|
||||
<TabsList>
|
||||
<TabsTrigger value="csv">CSV-Import</TabsTrigger>
|
||||
<TabsTrigger value="comdirect">comdirect-Sync</TabsTrigger>
|
||||
@@ -16,5 +19,6 @@ export function ImportPage() {
|
||||
<ComdirectSyncPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { toast } from "sonner";
|
||||
import { BankConfigForm } from "@/components/import/BankConfigForm";
|
||||
|
||||
export function SettingsPage() {
|
||||
const settings = useQuery(api.settings.get);
|
||||
@@ -50,6 +51,8 @@ export function SettingsPage() {
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
<BankConfigForm />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Konten</CardTitle>
|
||||
|
||||
Reference in New Issue
Block a user