refactor(auth): extract authentication logic into a dedicated service and configure docker build args
All checks were successful
Deploy Flowstate to VPS / deploy (push) Successful in 7s

This commit is contained in:
Chneemann 2026-08-22 06:28:27 +02:00
parent 1b5a532510
commit f3a526a68c
No known key found for this signature in database
4 changed files with 125 additions and 122 deletions

View file

@ -9,6 +9,14 @@ FROM node:20-alpine AS builder
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
# Inject NEXT_PUBLIC_ vars at build time (required for client-side embedding)
ARG NEXT_PUBLIC_GUEST_EMAIL
ARG NEXT_PUBLIC_GUEST_PASSWORD
ENV NEXT_PUBLIC_GUEST_EMAIL=$NEXT_PUBLIC_GUEST_EMAIL
ENV NEXT_PUBLIC_GUEST_PASSWORD=$NEXT_PUBLIC_GUEST_PASSWORD
RUN npm run build RUN npm run build
# 3. Run production image (Node.js server) # 3. Run production image (Node.js server)

View file

@ -1,15 +1,14 @@
/** /**
* @file (auth)/login/page.tsx * @file (auth)/login/page.tsx
* @description Client component providing a user login interface with NextAuth credentials authentication and guest login capabilities. * @description Client component providing a user login interface utilizing the auth service.
*/ */
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { getGuestCredentials } from "@/services/auth.service"; import { getGuestCredentials, loginUser } from "@/services/auth.service";
/** /**
* Renders the login page containing the authentication form, error handling, * Renders the login page containing the authentication form, error handling,
@ -25,13 +24,13 @@ export default function LoginPage() {
>(null); >(null);
/** /**
* Universal sign-in handler for both manual credentials and guest login. * Performs user authentication via email and password using the auth service.
* Redirects to the summary page upon success or sets an error message on failure.
* *
* @async * @async
* @param {string} email - The user email. * @param {string} email - The email address for login.
* @param {string} password - The user password. * @param {string} password - The account password.
* @param {"credentials" | "guest"} type - The login method type for specific loading states. * @param {"credentials" | "guest"} type - The authentication trigger source type.
* @returns {Promise<void>}
*/ */
const handleSignIn = async ( const handleSignIn = async (
email: string, email: string,
@ -41,42 +40,21 @@ export default function LoginPage() {
setError(null); setError(null);
setLoadingType(type); setLoadingType(type);
const defaultErrorMessage = "An unexpected error has occurred."; const result = await loginUser(email, password);
try { if (result.error) {
const res = await signIn("credentials", { setError(result.error);
email,
password,
redirect: false,
});
if (res?.error) {
if (res.error === "CredentialsSignin") {
setError("Incorrect email address or password.");
} else {
setError(defaultErrorMessage);
}
} else {
router.push("/summary");
return;
}
} catch (err: any) {
if (err?.message?.includes("fetch") || err?.name === "TypeError") {
setError("Server error: API endpoint not available");
} else {
setError(defaultErrorMessage);
}
} finally {
setLoadingType(null); setLoadingType(null);
} else {
router.push("/summary");
} }
}; };
/** /**
* Handles regular form submission. * Handles traditional credential-based form submissions.
* *
* @async * @async
* @param {React.SubmitEvent<HTMLFormElement>} e - The form submission event. * @param {React.SubmitEvent<HTMLFormElement>} e - The form submission event.
* @returns {Promise<void>}
*/ */
const handleSubmit = async (e: React.SubmitEvent<HTMLFormElement>) => { const handleSubmit = async (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
@ -87,48 +65,33 @@ export default function LoginPage() {
}; };
/** /**
* Handles quick guest login securely via Server Action. * Fetches guest user credentials and triggers guest authentication.
* *
* @async * @async
* @returns {Promise<void>}
*/ */
const handleGuestLogin = async () => { const handleGuestLogin = async () => {
setError(null); setError(null);
setLoadingType("guest"); setLoadingType("guest");
try { const result = await getGuestCredentials();
const result = await getGuestCredentials();
if ("error" in result && result.error) { if (result.error || !result.email || !result.password) {
setError(result.error); setError(result.error || "Guest login is not configured properly.");
setLoadingType(null);
return;
}
if (!result.email || !result.password) {
setError("Guest login is not configured properly.");
setLoadingType(null);
return;
}
await handleSignIn(result.email, result.password, "guest");
} catch (err) {
setError("Failed to initialize guest login.");
setLoadingType(null); setLoadingType(null);
return;
} }
await handleSignIn(result.email, result.password, "guest");
}; };
return ( return (
<div className="flex h-dvh w-full items-center justify-center"> <div className="flex h-dvh w-full items-center justify-center">
{/* Login Card Container */}
<div className="w-full max-w-sm p-6 space-y-4 border border-border rounded-xl bg-card backdrop-blur-md"> <div className="w-full max-w-sm p-6 space-y-4 border border-border rounded-xl bg-card backdrop-blur-md">
{/* Header Section */}
<div className="space-y-2 text-center"> <div className="space-y-2 text-center">
<h1 className="text-2xl font-bold tracking-tight">Flowstate Login</h1> <h1 className="text-2xl font-bold tracking-tight">Flowstate Login</h1>
<p className="text-sm text-foreground-muted">Sign in to continue.</p> <p className="text-sm text-foreground-muted">Sign in to continue.</p>
</div> </div>
{/* Error Notification Banner */}
{error && ( {error && (
<div <div
className="p-3 text-xs font-medium text-destructive bg-destructive-bg border border-destructive-border rounded-lg text-center" className="p-3 text-xs font-medium text-destructive bg-destructive-bg border border-destructive-border rounded-lg text-center"
@ -138,7 +101,6 @@ export default function LoginPage() {
</div> </div>
)} )}
{/* Credentials Form */}
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<label className="block space-y-2"> <label className="block space-y-2">
<span className="text-xs font-medium text-foreground-muted"> <span className="text-xs font-medium text-foreground-muted">
@ -175,7 +137,6 @@ export default function LoginPage() {
</button> </button>
</form> </form>
{/* Divider */}
<div className="relative flex items-center"> <div className="relative flex items-center">
<div className="grow border-t border-border"></div> <div className="grow border-t border-border"></div>
<span className="shrink mx-4 text-xs text-foreground-muted uppercase"> <span className="shrink mx-4 text-xs text-foreground-muted uppercase">
@ -184,7 +145,6 @@ export default function LoginPage() {
<div className="grow border-t border-border"></div> <div className="grow border-t border-border"></div>
</div> </div>
{/* Guest Login Button */}
<button <button
type="button" type="button"
disabled={loadingType !== null} disabled={loadingType !== null}
@ -196,7 +156,6 @@ export default function LoginPage() {
: "Sign in as Guest"} : "Sign in as Guest"}
</button> </button>
{/* Registration Link Footer */}
<div className="text-center text-xs text-foreground-muted"> <div className="text-center text-xs text-foreground-muted">
Don't have an account yet?{" "} Don't have an account yet?{" "}
<Link href="/register/" className="text-foreground hover:underline"> <Link href="/register/" className="text-foreground hover:underline">

View file

@ -1,6 +1,6 @@
/** /**
* @file RegisterPage.tsx * @file (auth)/register/page.tsx
* @description Client component providing a user registration interface for creating a new account. * @description Client component providing a user registration interface utilizing the auth service.
*/ */
"use client"; "use client";
@ -8,10 +8,11 @@
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { registerUser } from "@/services/auth.service";
/** /**
* Renders the registration page featuring a sign-up form, error feedback, * Renders the registration page featuring a sign-up form, error feedback,
* and routing logic to the login page upon successful account creation. * and routing logic to the summary view upon successful account creation.
* *
* @returns {JSX.Element} The rendered registration page component. * @returns {JSX.Element} The rendered registration page component.
*/ */
@ -21,12 +22,11 @@ export default function RegisterPage() {
const router = useRouter(); const router = useRouter();
/** /**
* Handles the submission of the registration form, sends user data to the API, * Handles user registration form submission, validates form data via auth service,
* creates the account, auto-logins the user, and redirects to the main view on success. * and navigates to summary page on successful registration.
* *
* @async * @async
* @param {React.SubmitEvent<HTMLFormElement>} event - The form submission event. * @param {React.SubmitEvent<HTMLFormElement>} event - The form submission event.
* @returns {Promise<void>}
*/ */
async function handleSubmit(event: React.SubmitEvent<HTMLFormElement>) { async function handleSubmit(event: React.SubmitEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
@ -34,61 +34,21 @@ export default function RegisterPage() {
setLoading(true); setLoading(true);
const formData = new FormData(event.currentTarget); const formData = new FormData(event.currentTarget);
const firstName = formData.get("firstName"); const payload = Object.fromEntries(formData.entries());
const lastName = formData.get("lastName");
const email = formData.get("email");
const password = formData.get("password");
const confirmPassword = formData.get("confirmPassword");
if (password !== confirmPassword) { const result = await registerUser(payload);
setError("Passwords do not match");
if (result.error) {
setError(result.error);
setLoading(false); setLoading(false);
return; } else {
}
try {
// Send registration request to the API
const response = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
firstName,
lastName,
email,
password,
confirmPassword,
}),
});
// Ensure the response is valid JSON before parsing
const contentType = response.headers.get("content-type");
let data;
if (contentType && contentType.includes("application/json")) {
data = await response.json();
} else {
throw new Error("Server error: API endpoint not available");
}
// Handle non-successful status codes
if (!response.ok) {
throw new Error(data.message || "Something went wrong.");
}
// Redirect user
router.push("/summary"); router.push("/summary");
return;
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
} }
} }
return ( return (
<div className="flex h-dvh w-full items-center justify-center"> <div className="flex h-dvh w-full items-center justify-center">
{/* Login Card Container */}
<div className="w-full max-w-sm p-6 space-y-6 border border-border rounded-xl bg-card backdrop-blur-md"> <div className="w-full max-w-sm p-6 space-y-6 border border-border rounded-xl bg-card backdrop-blur-md">
{/* Header Section */}
<div className="space-y-2 text-center"> <div className="space-y-2 text-center">
<h1 className="text-2xl font-bold tracking-tight"> <h1 className="text-2xl font-bold tracking-tight">
Create an Account Create an Account
@ -98,7 +58,6 @@ export default function RegisterPage() {
</p> </p>
</div> </div>
{/* Error Notification Banner */}
{error && ( {error && (
<div <div
className="p-3 text-xs font-medium text-destructive bg-destructive-bg border border-destructive-border rounded-lg text-center" className="p-3 text-xs font-medium text-destructive bg-destructive-bg border border-destructive-border rounded-lg text-center"
@ -108,7 +67,6 @@ export default function RegisterPage() {
</div> </div>
)} )}
{/* Credentials Form */}
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<input <input
@ -156,7 +114,6 @@ export default function RegisterPage() {
</button> </button>
</form> </form>
{/* Back Link Footer */}
<div className="text-center text-xs text-foreground-muted"> <div className="text-center text-xs text-foreground-muted">
Already have an account?{" "} Already have an account?{" "}
<Link href="/login" className="text-foreground hover:underline"> <Link href="/login" className="text-foreground hover:underline">

View file

@ -1,18 +1,19 @@
/** /**
* @file services/auth.service.ts * @file services/auth.service.ts
* @description Authentication service providing helper functions for retrieving authentication details such as guest credentials from environment variables. * @description Authentication service providing helper functions for guest credentials, sign-in, and registration.
*/ */
import { signIn } from "next-auth/react";
/** /**
* Retrieves configured guest account credentials from server or public environment variables. * Retrieves configured public guest credentials from environment variables.
* *
* @async * @async
* @returns {Promise<{ email?: string; password?: string; error?: string }>} An object containing the guest email and password, or an error message if unconfigured. * @returns {Promise<{ email?: string; password?: string; error?: string }>} An object containing guest credentials or an error message if unconfigured.
*/ */
export async function getGuestCredentials() { export async function getGuestCredentials() {
const email = process.env.GUEST_EMAIL || process.env.NEXT_PUBLIC_GUEST_EMAIL; const email = process.env.NEXT_PUBLIC_GUEST_EMAIL;
const password = const password = process.env.NEXT_PUBLIC_GUEST_PASSWORD;
process.env.GUEST_PASSWORD || process.env.NEXT_PUBLIC_GUEST_PASSWORD;
if (!email || !password) { if (!email || !password) {
return { error: "Guest login is not configured on the server." }; return { error: "Guest login is not configured on the server." };
@ -20,3 +21,81 @@ export async function getGuestCredentials() {
return { email, password }; return { email, password };
} }
/**
* Authenticates a user using credentials via NextAuth.
*
* @async
* @param {string} email - The user's email address.
* @param {string} password - The user's account password.
* @returns {Promise<{ success?: boolean; error?: string }>} An object indicating success or describing the authentication error.
*/
export async function loginUser(email: string, password: string) {
try {
const res = await signIn("credentials", {
email,
password,
redirect: false,
});
if (res?.error) {
if (res.error === "CredentialsSignin") {
return { error: "Incorrect email address or password." };
}
return { error: "An unexpected error has occurred." };
}
return { success: true };
} catch (err: any) {
if (
err?.message?.includes("fetch") ||
err?.name === "TypeError" ||
err?.message?.includes("network")
) {
return { error: "Server error: API endpoint not available" };
}
return { error: err?.message || "An unexpected error has occurred." };
}
}
/**
* Registers a new user account via the registration API endpoint.
*
* @async
* @param {Record<string, any>} payload - The user registration form payload containing user details and passwords.
* @returns {Promise<{ success?: boolean; error?: string }>} An object indicating success or describing the registration error.
*/
export async function registerUser(payload: Record<string, any>) {
if (payload.password !== payload.confirmPassword) {
return { error: "Passwords do not match" };
}
try {
const response = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const contentType = response.headers.get("content-type");
let data;
if (contentType && contentType.includes("application/json")) {
try {
data = await response.json();
} catch (jsonErr) {
return { error: "Server error: Invalid response format" };
}
} else {
throw new Error("Server error: API endpoint not available");
}
if (!response.ok) {
throw new Error(data.message || "Something went wrong.");
}
return { success: true };
} catch (err: any) {
return { error: err.message || "Something went wrong." };
}
}