login screen serving correctly

This commit is contained in:
Jason Ross
2026-03-23 17:12:39 -05:00
parent 377e06017d
commit a98960c0be
10 changed files with 143 additions and 129 deletions
+5 -5
View File
@@ -3,13 +3,13 @@ services:
build:
context: .
dockerfile: Dockerfile
container_name: postcard-web
container_name: ${POSTCARD_WEB_CONTAINER_NAME:-postcard-web}
ports:
- "3000:3000"
- "${POSTCARD_WEB_HOST_PORT:-3000}:3000"
environment:
- NODE_ENV=production
- PORT=3000
- HOSTNAME=0.0.0.0
NODE_ENV: production
PORT: 3000
HOSTNAME: 0.0.0.0
restart: unless-stopped
# Optional: Podman specific security options
# security_opt:
+4 -1
View File
@@ -1,8 +1,11 @@
import path from "node:path";
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
/* config options here */
turbopack: {
root: path.join(__dirname),
},
};
export default nextConfig;
+2 -2
View File
@@ -3,8 +3,8 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"start": "next start",
"lint": "eslint"
},
+5 -3
View File
@@ -3,13 +3,15 @@
:root {
--background: #ffffff;
--foreground: #171717;
--font-sans: "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif;
--font-mono: "SFMono-Regular", "Consolas", "Liberation Mono", monospace;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
}
@media (prefers-color-scheme: dark) {
@@ -22,5 +24,5 @@
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
font-family: var(--font-sans);
}
+1 -15
View File
@@ -1,17 +1,6 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
@@ -23,10 +12,7 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<html lang="en" className="h-full antialiased">
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
+10 -64
View File
@@ -1,68 +1,14 @@
"use client";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { LoginForm } from "@/components/auth/LoginForm";
import { useActionState } from "react";
import { login } from "@/lib/auth";
import Link from "next/link";
import { Loader2 } from "lucide-react";
export default async function HomePage() {
const cookieStore = await cookies();
const authToken = cookieStore.get("auth_token");
export default function LoginPage() {
const [state, formAction, isPending] = useActionState(login, null);
if (authToken) {
redirect("/c");
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-4">
<div className="w-full max-w-md space-y-8 rounded-xl bg-white p-8 shadow-sm ring-1 ring-gray-200">
<div className="text-center">
<h2 className="text-3xl font-bold tracking-tight text-gray-900">Sign in to Postcard</h2>
</div>
<form action={formAction} className="mt-8 space-y-6">
<div className="space-y-4">
<div>
<label htmlFor="username" className="sr-only">Username</label>
<input
id="username"
name="username"
type="text"
required
className="relative block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:z-10 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
placeholder="Username"
/>
</div>
<div>
<label htmlFor="password" className="sr-only">Password</label>
<input
id="password"
name="password"
type="password"
required
className="relative block w-full rounded-md border-0 py-1.5 px-3 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:z-10 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
placeholder="Password"
/>
</div>
</div>
{state?.error && (
<div className="text-sm text-red-500 text-center bg-red-50 p-2 rounded-md ring-1 ring-red-200">
{state.error}
</div>
)}
<div>
<button
type="submit"
disabled={isPending}
className="group relative flex w-full justify-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-70 disabled:cursor-not-allowed transition-all"
>
{isPending ? <Loader2 className="animate-spin h-5 w-5" /> : "Sign in"}
</button>
</div>
</form>
<div className="text-center text-sm">
<span className="text-gray-500">Don&apos;t have an account? </span>
<Link href="/register" className="font-semibold text-blue-600 hover:text-blue-500">
Sign up
</Link>
</div>
</div>
</div>
);
return <LoginForm />;
}
+72
View File
@@ -0,0 +1,72 @@
"use client";
import Link from "next/link";
import { useActionState } from "react";
import { Loader2 } from "lucide-react";
import { login } from "@/lib/auth";
export function LoginForm() {
const [state, formAction, isPending] = useActionState(login, null);
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-4">
<div className="w-full max-w-md space-y-8 rounded-xl bg-white p-8 shadow-sm ring-1 ring-gray-200">
<div className="text-center">
<h2 className="text-3xl font-bold tracking-tight text-gray-900">Sign in to Postcard</h2>
</div>
<form action={formAction} className="mt-8 space-y-6">
<div className="space-y-4">
<div>
<label htmlFor="username" className="sr-only">
Username
</label>
<input
id="username"
name="username"
type="text"
required
className="relative block w-full rounded-md border-0 px-3 py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:z-10 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
placeholder="Username"
/>
</div>
<div>
<label htmlFor="password" className="sr-only">
Password
</label>
<input
id="password"
name="password"
type="password"
required
className="relative block w-full rounded-md border-0 px-3 py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:z-10 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
placeholder="Password"
/>
</div>
</div>
{state?.error && (
<div className="rounded-md bg-red-50 p-2 text-center text-sm text-red-500 ring-1 ring-red-200">
{state.error}
</div>
)}
<div>
<button
type="submit"
disabled={isPending}
className="group relative flex w-full justify-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white transition-all hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:cursor-not-allowed disabled:opacity-70"
>
{isPending ? <Loader2 className="h-5 w-5 animate-spin" /> : "Sign in"}
</button>
</div>
</form>
<div className="text-center text-sm">
<span className="text-gray-500">Don&apos;t have an account? </span>
<Link href="/register" className="font-semibold text-blue-600 hover:text-blue-500">
Sign up
</Link>
</div>
</div>
</div>
);
}
+19 -11
View File
@@ -5,7 +5,14 @@ import { redirect } from "next/navigation";
const API_URL = process.env.API_URL || "http://localhost:8080";
export async function login(prevState: any, formData: FormData) {
type AuthActionState = {
error?: string;
} | null;
const isSecureCookie = process.env.NODE_ENV !== "development";
export async function login(prevState: AuthActionState, formData: FormData) {
void prevState;
const username = formData.get("username") as string;
const password = formData.get("password") as string;
@@ -28,7 +35,7 @@ export async function login(prevState: any, formData: FormData) {
const cookieStore = await cookies();
cookieStore.set("auth_token", mockJwt, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
secure: isSecureCookie,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 7, // 1 week
@@ -47,14 +54,14 @@ export async function login(prevState: any, formData: FormData) {
const cookieStore = await cookies();
cookieStore.set("auth_token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
secure: isSecureCookie,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 7, // 1 week
});
}
} catch (error) {
} catch {
// Mock fallback if connection fails completely (e.g. backend down)
if (process.env.NODE_ENV === "development") {
console.warn("Mocking login because backend connection failed.");
@@ -62,7 +69,7 @@ export async function login(prevState: any, formData: FormData) {
const cookieStore = await cookies();
cookieStore.set("auth_token", mockJwt, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
secure: isSecureCookie,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 7, // 1 week
@@ -75,7 +82,8 @@ export async function login(prevState: any, formData: FormData) {
redirect("/c");
}
export async function register(prevState: any, formData: FormData) {
export async function register(prevState: AuthActionState, formData: FormData) {
void prevState;
const username = formData.get("username") as string;
const password = formData.get("password") as string;
const displayName = formData.get("displayName") as string;
@@ -99,7 +107,7 @@ export async function register(prevState: any, formData: FormData) {
const cookieStore = await cookies();
cookieStore.set("auth_token", mockJwt, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
secure: isSecureCookie,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 7, // 1 week
@@ -118,14 +126,14 @@ export async function register(prevState: any, formData: FormData) {
const cookieStore = await cookies();
cookieStore.set("auth_token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
secure: isSecureCookie,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 7, // 1 week
});
}
} catch (error) {
} catch {
// Mock fallback
if (process.env.NODE_ENV === "development") {
console.warn("Mocking register because backend connection failed.");
@@ -133,7 +141,7 @@ export async function register(prevState: any, formData: FormData) {
const cookieStore = await cookies();
cookieStore.set("auth_token", mockJwt, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
secure: isSecureCookie,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 7, // 1 week
@@ -150,4 +158,4 @@ export async function logout() {
const cookieStore = await cookies();
cookieStore.delete("auth_token");
redirect("/");
}
}
-28
View File
@@ -1,28 +0,0 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const authToken = request.cookies.get('auth_token')
const { pathname } = request.nextUrl
const isAuthRoute = pathname === '/' || pathname === '/register'
// Redirect unauthenticated users to /
if (!authToken && !isAuthRoute) {
return NextResponse.redirect(new URL('/', request.url))
}
// Redirect authenticated users away from auth routes
if (authToken && isAuthRoute) {
return NextResponse.redirect(new URL('/c', request.url))
}
return NextResponse.next()
}
// Match all routes except api, static files, images, etc.
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function proxy(request: NextRequest) {
const authToken = request.cookies.get("auth_token");
const { pathname } = request.nextUrl;
const isAuthRoute = pathname === "/" || pathname === "/register";
if (!authToken && !isAuthRoute) {
return NextResponse.redirect(new URL("/", request.url));
}
if (authToken && isAuthRoute) {
return NextResponse.redirect(new URL("/c", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: [
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};