|
@@ -0,0 +1,156 @@
|
|
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
|
|
+// Folder: components/auth
|
|
|
|
|
+// File: LoginForm.jsx
|
|
|
|
|
+// Relative Path: components/auth/LoginForm.jsx
|
|
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+"use client";
|
|
|
|
|
+
|
|
|
|
|
+import React from "react";
|
|
|
|
|
+import { useRouter } from "next/navigation";
|
|
|
|
|
+
|
|
|
|
|
+import { login } from "@/lib/frontend/apiClient";
|
|
|
|
|
+import { sanitizeNext } from "@/lib/frontend/authRedirect";
|
|
|
|
|
+import {
|
|
|
|
|
+ getLoginErrorMessage,
|
|
|
|
|
+ getLoginReasonAlert,
|
|
|
|
|
+} from "@/lib/frontend/authMessages";
|
|
|
|
|
+import { homePath } from "@/lib/frontend/routes";
|
|
|
|
|
+
|
|
|
|
|
+import { Button } from "@/components/ui/button";
|
|
|
|
|
+import { Input } from "@/components/ui/input";
|
|
|
|
|
+import { Label } from "@/components/ui/label";
|
|
|
|
|
+import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
|
|
|
|
|
+import {
|
|
|
|
|
+ Card,
|
|
|
|
|
+ CardHeader,
|
|
|
|
|
+ CardTitle,
|
|
|
|
|
+ CardDescription,
|
|
|
|
|
+ CardContent,
|
|
|
|
|
+ CardFooter,
|
|
|
|
|
+} from "@/components/ui/card";
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * LoginForm (RHL-020)
|
|
|
|
|
+ *
|
|
|
|
|
+ * Responsibilities:
|
|
|
|
|
+ * - Render login UI using shadcn/ui primitives.
|
|
|
|
|
+ * - Show a reason banner (session expired / logged out) if present.
|
|
|
|
|
+ * - Submit credentials via apiClient.login().
|
|
|
|
|
+ * - On success: redirect to `nextPath` (if safe) or "/" (default).
|
|
|
|
|
+ * - On failure: show a safe, user-friendly error message.
|
|
|
|
|
+ *
|
|
|
|
|
+ * Clean code notes:
|
|
|
|
|
+ * - "reason" and "nextPath" parsing/sanitization is centralized in helpers.
|
|
|
|
|
+ * - Error message mapping is centralized in lib/frontend/authMessages.js.
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param {{ reason: string|null, nextPath: string|null }} props
|
|
|
|
|
+ */
|
|
|
|
|
+export default function LoginForm({ reason, nextPath }) {
|
|
|
|
|
+ const router = useRouter();
|
|
|
|
|
+
|
|
|
|
|
+ // Controlled inputs: keep state explicit and predictable.
|
|
|
|
|
+ const [username, setUsername] = React.useState("");
|
|
|
|
|
+ const [password, setPassword] = React.useState("");
|
|
|
|
|
+
|
|
|
|
|
+ // UX state: disable submit while in-flight.
|
|
|
|
|
+ const [isSubmitting, setIsSubmitting] = React.useState(false);
|
|
|
|
|
+
|
|
|
|
|
+ // UI-safe error message shown above the form.
|
|
|
|
|
+ const [errorMessage, setErrorMessage] = React.useState("");
|
|
|
|
|
+
|
|
|
|
|
+ // Informational banner (optional).
|
|
|
|
|
+ const reasonAlert = getLoginReasonAlert(reason);
|
|
|
|
|
+
|
|
|
|
|
+ // Defensive: sanitize again on the client (even though server parsing already does it).
|
|
|
|
|
+ const safeNext = sanitizeNext(nextPath) || homePath();
|
|
|
|
|
+
|
|
|
|
|
+ async function onSubmit(e) {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+
|
|
|
|
|
+ // Minimal validation to avoid unnecessary network calls.
|
|
|
|
|
+ const u = username.trim();
|
|
|
|
|
+ const p = password;
|
|
|
|
|
+
|
|
|
|
|
+ if (!u || !p) {
|
|
|
|
|
+ setErrorMessage("Please enter username and password.");
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ setIsSubmitting(true);
|
|
|
|
|
+ setErrorMessage("");
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ // Backend sets an HTTP-only cookie on success.
|
|
|
|
|
+ await login({ username: u, password: p });
|
|
|
|
|
+
|
|
|
|
|
+ // Replace history entry so "Back" does not return to login.
|
|
|
|
|
+ router.replace(safeNext);
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ setErrorMessage(getLoginErrorMessage(err));
|
|
|
|
|
+ setIsSubmitting(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <Card>
|
|
|
|
|
+ <CardHeader>
|
|
|
|
|
+ <CardTitle>Sign in</CardTitle>
|
|
|
|
|
+ <CardDescription>
|
|
|
|
|
+ Enter your credentials to access the delivery note browser.
|
|
|
|
|
+ </CardDescription>
|
|
|
|
|
+ </CardHeader>
|
|
|
|
|
+
|
|
|
|
|
+ <CardContent className="space-y-4">
|
|
|
|
|
+ {reasonAlert ? (
|
|
|
|
|
+ <Alert>
|
|
|
|
|
+ <AlertTitle>{reasonAlert.title}</AlertTitle>
|
|
|
|
|
+ <AlertDescription>{reasonAlert.description}</AlertDescription>
|
|
|
|
|
+ </Alert>
|
|
|
|
|
+ ) : null}
|
|
|
|
|
+
|
|
|
|
|
+ {errorMessage ? (
|
|
|
|
|
+ <Alert variant="destructive">
|
|
|
|
|
+ <AlertTitle>Login error</AlertTitle>
|
|
|
|
|
+ <AlertDescription>{errorMessage}</AlertDescription>
|
|
|
|
|
+ </Alert>
|
|
|
|
|
+ ) : null}
|
|
|
|
|
+
|
|
|
|
|
+ <form onSubmit={onSubmit} className="space-y-4">
|
|
|
|
|
+ <div className="grid gap-2">
|
|
|
|
|
+ <Label htmlFor="username">Username</Label>
|
|
|
|
|
+ <Input
|
|
|
|
|
+ id="username"
|
|
|
|
|
+ name="username"
|
|
|
|
|
+ autoComplete="username"
|
|
|
|
|
+ value={username}
|
|
|
|
|
+ onChange={(e) => setUsername(e.target.value)}
|
|
|
|
|
+ disabled={isSubmitting}
|
|
|
|
|
+ placeholder="e.g. branchuser"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div className="grid gap-2">
|
|
|
|
|
+ <Label htmlFor="password">Password</Label>
|
|
|
|
|
+ <Input
|
|
|
|
|
+ id="password"
|
|
|
|
|
+ name="password"
|
|
|
|
|
+ type="password"
|
|
|
|
|
+ autoComplete="current-password"
|
|
|
|
|
+ value={password}
|
|
|
|
|
+ onChange={(e) => setPassword(e.target.value)}
|
|
|
|
|
+ disabled={isSubmitting}
|
|
|
|
|
+ placeholder="••••••••"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <CardFooter className="p-0">
|
|
|
|
|
+ <Button type="submit" className="w-full" disabled={isSubmitting}>
|
|
|
|
|
+ {isSubmitting ? "Signing in..." : "Sign in"}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </CardFooter>
|
|
|
|
|
+ </form>
|
|
|
|
|
+ </CardContent>
|
|
|
|
|
+ </Card>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|