nextauth working.

This commit is contained in:
2025-06-30 06:36:03 -04:00
parent ccc6e41724
commit 41e55404bf
18 changed files with 925 additions and 23 deletions

237
package-lock.json generated
View File

@@ -8,10 +8,12 @@
"name": "pew-builder-nextjs", "name": "pew-builder-nextjs",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@auth/core": "^0.34.2",
"@headlessui/react": "^2.2.4", "@headlessui/react": "^2.2.4",
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"daisyui": "^4.7.3", "daisyui": "^4.7.3",
"next": "15.3.4", "next": "15.3.4",
"next-auth": "^4.24.11",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"zustand": "^5.0.6" "zustand": "^5.0.6"
@@ -42,6 +44,46 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@auth/core": {
"version": "0.34.2",
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.34.2.tgz",
"integrity": "sha512-KywHKRgLiF3l7PLyL73fjLSIBe1YNcA6sMeew4yMP6cfCWGXZrkkXd32AjRi1hlJ9nvovUBGZHvbn+LijO6ZeQ==",
"license": "ISC",
"dependencies": {
"@panva/hkdf": "^1.1.1",
"@types/cookie": "0.6.0",
"cookie": "0.6.0",
"jose": "^5.1.3",
"oauth4webapi": "^2.10.4",
"preact": "10.11.3",
"preact-render-to-string": "5.2.3"
},
"peerDependencies": {
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.2",
"nodemailer": "^6.8.0"
},
"peerDependenciesMeta": {
"@simplewebauthn/browser": {
"optional": true
},
"@simplewebauthn/server": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/@babel/runtime": {
"version": "7.27.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.4.3", "version": "1.4.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz",
@@ -1049,6 +1091,15 @@
"node": ">=12.4.0" "node": ">=12.4.0"
} }
}, },
"node_modules/@panva/hkdf": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@pkgjs/parseargs": { "node_modules/@pkgjs/parseargs": {
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -1224,6 +1275,12 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -2505,6 +2562,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4449,6 +4515,15 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/jose": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -4804,6 +4879,56 @@
} }
} }
}, },
"node_modules/next-auth": {
"version": "4.24.11",
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.11.tgz",
"integrity": "sha512-pCFXzIDQX7xmHFs4KVH4luCjaCbuPRtZ9oBUjUhOk84mZ9WVPf94n87TxYI4rSRf9HmfHEF8Yep3JrYDVOo3Cw==",
"license": "ISC",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@panva/hkdf": "^1.0.2",
"cookie": "^0.7.0",
"jose": "^4.15.5",
"oauth": "^0.9.15",
"openid-client": "^5.4.0",
"preact": "^10.6.3",
"preact-render-to-string": "^5.1.19",
"uuid": "^8.3.2"
},
"peerDependencies": {
"@auth/core": "0.34.2",
"next": "^12.2.5 || ^13 || ^14 || ^15",
"nodemailer": "^6.6.5",
"react": "^17.0.2 || ^18 || ^19",
"react-dom": "^17.0.2 || ^18 || ^19"
},
"peerDependenciesMeta": {
"@auth/core": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/next-auth/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/next-auth/node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/next/node_modules/postcss": { "node_modules/next/node_modules/postcss": {
"version": "8.4.31", "version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@@ -4859,6 +4984,21 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/oauth": {
"version": "0.9.15",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==",
"license": "MIT"
},
"node_modules/oauth4webapi": {
"version": "2.17.0",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-2.17.0.tgz",
"integrity": "sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -4992,6 +5132,60 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/oidc-token-hash": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.1.0.tgz",
"integrity": "sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA==",
"license": "MIT",
"engines": {
"node": "^10.13.0 || >=12.0.0"
}
},
"node_modules/openid-client": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
"license": "MIT",
"dependencies": {
"jose": "^4.15.9",
"lru-cache": "^6.0.0",
"object-hash": "^2.2.0",
"oidc-token-hash": "^5.0.3"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/openid-client/node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/openid-client/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/openid-client/node_modules/object-hash": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/optionator": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -5285,6 +5479,28 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/preact": {
"version": "10.11.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz",
"integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/preact-render-to-string": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz",
"integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==",
"license": "MIT",
"dependencies": {
"pretty-format": "^3.8.0"
},
"peerDependencies": {
"preact": ">=10"
}
},
"node_modules/prelude-ls": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -5295,6 +5511,12 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/pretty-format": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
"license": "MIT"
},
"node_modules/prop-types": { "node_modules/prop-types": {
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -6592,6 +6814,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -6802,6 +7033,12 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "url": "https://github.com/chalk/ansi-styles?sponsor=1"
} }
}, },
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/yaml": { "node_modules/yaml": {
"version": "2.8.0", "version": "2.8.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",

View File

@@ -9,10 +9,12 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@auth/core": "^0.34.2",
"@headlessui/react": "^2.2.4", "@headlessui/react": "^2.2.4",
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"daisyui": "^4.7.3", "daisyui": "^4.7.3",
"next": "15.3.4", "next": "15.3.4",
"next-auth": "^4.24.11",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"zustand": "^5.0.6" "zustand": "^5.0.6"

View File

@@ -0,0 +1,46 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
export default function ForgotPasswordPage() {
const [email, setEmail] = useState('');
const [submitted, setSubmitted] = useState(false);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmitted(true);
}
return (
<div className="flex flex-1 items-center justify-center min-h-[60vh]">
<div className="w-full max-w-md bg-white dark:bg-neutral-900 rounded-lg shadow p-8">
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-white">Forgot your password?</h1>
<p className="mb-6 text-gray-600 dark:text-gray-300 text-sm">
Enter your email address and we'll send you a link to reset your password.<br/>
<span className="text-primary font-semibold">(This feature is not yet implemented.)</span>
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<input
type="email"
required
placeholder="Email address"
className="w-full px-3 py-2 border border-gray-300 dark:border-neutral-700 rounded-md bg-white dark:bg-neutral-800 text-gray-900 dark:text-white focus:outline-none focus:ring-primary-500 focus:border-primary-500"
value={email}
onChange={e => setEmail(e.target.value)}
disabled={submitted}
/>
<button
type="submit"
className="w-full btn btn-primary"
disabled={submitted}
>
{submitted ? 'Check your email' : 'Send reset link'}
</button>
</form>
<div className="mt-6 text-center">
<Link href="/account/login" className="text-primary-600 hover:underline text-sm">Back to login</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import Link from 'next/link';
export default function AccountLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen flex flex-col">
{/* Simple navbar with back button */}
<nav className="bg-white dark:bg-neutral-900 shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-start h-16">
<div className="flex items-center">
<Link
href="/"
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-100 focus:outline-none transition"
>
<svg
className="h-5 w-5 mr-1"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back
</Link>
</div>
</div>
</div>
</nav>
{/* Main content */}
<main className="flex-1">
{children}
</main>
</div>
);
}

View File

@@ -0,0 +1,168 @@
'use client';
import { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { signIn } from 'next-auth/react';
import Link from 'next/link';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const router = useRouter();
const searchParams = useSearchParams();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError('');
const res = await signIn('credentials', {
redirect: false,
email,
password,
callbackUrl: searchParams.get('callbackUrl') || '/',
});
setLoading(false);
if (res?.error) {
setError('Invalid email or password');
} else if (res?.ok) {
router.push(res.url || '/');
}
}
async function handleGoogle() {
setLoading(true);
await signIn('google', { callbackUrl: searchParams.get('callbackUrl') || '/' });
setLoading(false);
}
return (
<div className="min-h-screen flex">
{/* Left side image or illustration */}
<div className="hidden lg:block relative w-0 flex-1 bg-gray-900">
{/* You can replace this with your own image or illustration */}
<img
className="absolute inset-0 h-full w-full object-cover opacity-80"
src="/window.svg"
alt="Login visual"
/>
</div>
{/* Right side form */}
<div className="flex-1 flex flex-col justify-center py-12 px-4 sm:px-6 lg:px-20 xl:px-24 bg-white dark:bg-neutral-900 min-h-screen">
<div className="mx-auto w-full max-w-md space-y-8">
<div>
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">Sign in to your account</h2>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
Or{' '}
<Link href="/account/register" className="font-medium text-primary-600 hover:text-primary-500">
Sign Up For Free
</Link>
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<input type="hidden" name="remember" value="true" />
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="email-address" className="sr-only">
Email address
</label>
<input
id="email-address"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={e => setEmail(e.target.value)}
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-neutral-700 placeholder-gray-500 dark:placeholder-neutral-400 text-gray-900 dark:text-white bg-white dark:bg-neutral-800 rounded-t-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
placeholder="Email address"
disabled={loading}
/>
</div>
<div>
<label htmlFor="password" className="sr-only">
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={e => setPassword(e.target.value)}
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-neutral-700 placeholder-gray-500 dark:placeholder-neutral-400 text-gray-900 dark:text-white bg-white dark:bg-neutral-800 rounded-b-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
placeholder="Password"
disabled={loading}
/>
</div>
</div>
{error && (
<div className="text-red-600 text-sm mt-2">{error}</div>
)}
<div className="flex items-center justify-between">
<div className="flex items-center">
<input
id="remember-me"
name="remember-me"
type="checkbox"
className="checkbox checkbox-primary"
disabled={loading}
/>
<label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900 dark:text-gray-300">
Remember me
</label>
</div>
<div className="text-sm">
<Link href="/account/forgot-password" className="font-medium text-primary-600 hover:text-primary-500">
Forgot your password?
</Link>
</div>
</div>
<div>
<button
type="submit"
className="w-full btn btn-primary text-white font-medium text-sm py-2 px-4 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
disabled={loading}
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</div>
</form>
{/* Social login buttons */}
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300 dark:border-neutral-700" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white dark:bg-neutral-900 text-gray-500 dark:text-gray-400">
Or continue with
</span>
</div>
</div>
<div className="mt-6 flex justify-center">
<button
type="button"
onClick={handleGoogle}
className="btn btn-outline flex justify-center items-center py-2 px-4 border border-gray-300 dark:border-neutral-700 rounded-md shadow-sm bg-white dark:bg-neutral-800 text-sm font-medium text-gray-500 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-neutral-700 transition"
disabled={loading}
>
<span className="sr-only">Sign in with Google</span>
{/* Google Icon Placeholder */}
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M21.35 11.1H12v2.8h5.35c-.23 1.2-1.4 3.5-5.35 3.5-3.22 0-5.85-2.67-5.85-5.9s2.63-5.9 5.85-5.9c1.83 0 3.06.78 3.76 1.44l2.57-2.5C17.09 3.59 14.77 2.5 12 2.5 6.75 2.5 2.5 6.75 2.5 12s4.25 9.5 9.5 9.5c5.47 0 9.09-3.84 9.09-9.25 0-.62-.07-1.08-.16-1.55z" /></svg>
<span className="ml-2">Sign in with Google</span>
</button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
'use client';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
export default function ProfilePage() {
const { data: session, status } = useSession();
const router = useRouter();
useEffect(() => {
if (status === 'unauthenticated') {
router.replace('/account/login');
}
}, [status, router]);
if (status === 'loading') {
return <div className="flex justify-center items-center h-64">Loading...</div>;
}
if (!session?.user) {
return null;
}
return (
<div className="max-w-xl mx-auto mt-12 p-6 bg-white dark:bg-neutral-900 rounded shadow">
<h1 className="text-2xl font-bold mb-4">Profile</h1>
<div className="space-y-2">
<div><span className="font-semibold">Name:</span> {session.user.name || 'N/A'}</div>
<div><span className="font-semibold">Email:</span> {session.user.email}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,94 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { signIn } from 'next-auth/react';
export default function RegisterPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const router = useRouter();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError('');
const res = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) {
setLoading(false);
setError(data.error || 'Registration failed');
return;
}
// Auto-login after registration
const signInRes = await signIn('credentials', {
redirect: false,
email,
password,
callbackUrl: '/account/profile',
});
setLoading(false);
if (signInRes?.ok) {
router.push('/');
} else {
router.push('/account/login?registered=1');
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-white dark:bg-neutral-900">
<div className="w-full max-w-md bg-white dark:bg-neutral-900 rounded-lg shadow p-8">
<h1 className="text-2xl font-bold mb-4 text-gray-900 dark:text-white">Create your account</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<input
type="email"
required
placeholder="Email address"
className="w-full px-3 py-2 border border-gray-300 dark:border-neutral-700 rounded-md bg-white dark:bg-neutral-800 text-gray-900 dark:text-white focus:outline-none focus:ring-primary-500 focus:border-primary-500"
value={email}
onChange={e => setEmail(e.target.value)}
disabled={loading}
/>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
required
placeholder="Password"
className="w-full px-3 py-2 border border-gray-300 dark:border-neutral-700 rounded-md bg-white dark:bg-neutral-800 text-gray-900 dark:text-white focus:outline-none focus:ring-primary-500 focus:border-primary-500 pr-10"
value={password}
onChange={e => setPassword(e.target.value)}
disabled={loading}
/>
<button
type="button"
className="absolute inset-y-0 right-2 flex items-center text-xs text-gray-500 dark:text-gray-300"
tabIndex={-1}
onClick={() => setShowPassword(v => !v)}
>
{showPassword ? 'Hide' : 'Show'}
</button>
</div>
{error && <div className="text-red-600 text-sm">{error}</div>}
<button
type="submit"
className="w-full btn btn-primary text-white font-medium text-sm py-2 px-4 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
disabled={loading}
>
{loading ? 'Creating account...' : 'Create Account'}
</button>
</form>
<div className="mt-6 text-center">
<Link href="/account/login" className="text-primary-600 hover:underline text-sm">Already have an account? Sign in</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,70 @@
import NextAuth from 'next-auth';
import GoogleProvider from 'next-auth/providers/google';
import CredentialsProvider from 'next-auth/providers/credentials';
// In-memory user store (for demo only)
type User = { email: string; password: string };
declare global {
// eslint-disable-next-line no-var
var _users: User[] | undefined;
}
const users: User[] = global._users || (global._users = []);
const handler = NextAuth({
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID ?? '',
clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? '',
}),
CredentialsProvider({
name: 'Credentials',
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" }
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null;
// Check in-memory user store
const user = users.find(
(u) => u.email === credentials.email && u.password === credentials.password
);
if (user) {
return {
id: user.email,
email: user.email,
name: user.email.split('@')[0],
};
}
// For demo, still allow the test user
if (credentials.email === "test@example.com" && credentials.password === "password") {
return {
id: "1",
email: credentials.email,
name: "Test User",
};
}
return null;
}
}),
],
pages: {
signIn: '/account/login',
// signUp: '/account/register', // Uncomment when register page is ready
// error: '/account/error', // Uncomment when error page is ready
},
callbacks: {
async session({ session, token }) {
// Add any additional user data to the session here
return session;
},
async jwt({ token, user }) {
// Add any additional user data to the JWT here
if (user) {
token.id = user.id;
}
return token;
},
},
})
export { handler as GET, handler as POST }

View File

@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server';
// In-memory user store (for demo only)
type User = { email: string; password: string };
declare global {
// eslint-disable-next-line no-var
var _users: User[] | undefined;
}
const users: User[] = global._users || (global._users = []);
export async function POST(req: Request) {
const { email, password } = await req.json();
if (!email || !password) {
return NextResponse.json({ error: 'Email and password are required.' }, { status: 400 });
}
// Check if user already exists
const existing = users.find((u) => u.email === email);
if (existing) {
return NextResponse.json({ error: 'User already exists.' }, { status: 400 });
}
// Add new user
users.push({ email, password });
return NextResponse.json({ success: true });
}

View File

@@ -42,9 +42,6 @@
/* Button styles */ /* Button styles */
/* Removed custom .btn-primary to avoid DaisyUI conflict */ /* Removed custom .btn-primary to avoid DaisyUI conflict */
.btn-secondary {
@apply bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-700 dark:hover:bg-neutral-600 text-neutral-700 dark:text-neutral-300 font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus-ring;
}
/* Input styles */ /* Input styles */
.input-field { .input-field {

View File

@@ -1,8 +1,7 @@
import "./globals.css"; import "./globals.css";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Inter } from "next/font/google"; import { Inter } from "next/font/google";
import Navbar from "@/components/Navbar"; import Providers from "@/components/Providers";
import { ThemeProvider } from "@/components/ThemeProvider";
const inter = Inter({ subsets: ["latin"] }); const inter = Inter({ subsets: ["latin"] });
@@ -19,12 +18,9 @@ export default function RootLayout({
return ( return (
<html lang="en" suppressHydrationWarning data-theme="pew"> <html lang="en" suppressHydrationWarning data-theme="pew">
<body className={`${inter.className} antialiased`}> <body className={`${inter.className} antialiased`}>
<ThemeProvider> <Providers>
<div className="min-h-screen bg-neutral-50 dark:bg-neutral-900 transition-colors duration-200">
<Navbar />
{children} {children}
</div> </Providers>
</ThemeProvider>
</body> </body>
</html> </html>
); );

View File

@@ -668,7 +668,7 @@ export default function Home() {
} else if (matchingComponent && !selectedParts[matchingComponent.id]) { } else if (matchingComponent && !selectedParts[matchingComponent.id]) {
return ( return (
<button <button
className="btn btn-primary btn-sm" className="btn btn-neutral btn-sm flex items-center gap-1"
onClick={() => { onClick={() => {
selectPartForComponent(matchingComponent.id, { selectPartForComponent(matchingComponent.id, {
id: part.id, id: part.id,
@@ -681,7 +681,8 @@ export default function Home() {
router.push('/build'); router.push('/build');
}} }}
> >
Add <span className="text-lg leading-none">+</span>
<span className="text-xs font-normal">to build</span>
</button> </button>
); );
} else { } else {

View File

@@ -0,0 +1,17 @@
'use client';
import { useEffect } from 'react';
import { useSession } from 'next-auth/react';
import { useAuthStore } from '@/store/useAuthStore';
export function AuthProvider({ children }: { children: React.ReactNode }) {
const { data: session, status } = useSession();
const { setSession, setLoading } = useAuthStore();
useEffect(() => {
setSession(session);
setLoading(status === 'loading');
}, [session, status, setSession, setLoading]);
return <>{children}</>;
}

View File

@@ -3,10 +3,23 @@
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import ThemeSwitcher from './ThemeSwitcher'; import ThemeSwitcher from './ThemeSwitcher';
import { MagnifyingGlassIcon, UserCircleIcon } from '@heroicons/react/24/outline'; import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSession, signIn, signOut } from 'next-auth/react';
import { useState, ReactNode } from 'react';
interface MenuItem {
label: string;
href?: string;
active?: boolean;
onClick?: () => void;
custom?: ReactNode;
}
export default function Navbar() { export default function Navbar() {
const pathname = usePathname(); const pathname = usePathname();
const { data: session, status } = useSession();
const loading = status === 'loading';
const [menuOpen, setMenuOpen] = useState(false);
const navItems = [ const navItems = [
{ href: '/parts', label: 'Parts Catalog' }, { href: '/parts', label: 'Parts Catalog' },
@@ -14,12 +27,114 @@ export default function Navbar() {
{ href: '/my-builds', label: 'My Builds' }, { href: '/my-builds', label: 'My Builds' },
]; ];
// Dropdown menu items
const rawMenuItems = [
session?.user
? {
label: 'Profile',
href: '/account/profile',
active: pathname === '/account/profile',
onClick: () => setMenuOpen(false),
}
: undefined,
session?.user
? {
label: 'Sign Out',
onClick: () => {
setMenuOpen(false);
signOut({ callbackUrl: '/' });
},
}
: {
label: 'Sign In',
onClick: () => {
setMenuOpen(false);
signIn();
},
},
{
label: 'Theme',
custom: <ThemeSwitcher />,
},
];
function isMenuItem(item: unknown): item is MenuItem {
return typeof item === 'object' && item !== null && 'label' in item;
}
const menuItems = rawMenuItems.filter(isMenuItem);
return ( return (
<> <>
{/* Top Bar */} {/* Top Bar */}
<div className="w-full bg-[#4B6516] text-white h-10 flex items-center justify-between px-4 sm:px-8"> <div className="w-full bg-[#4B6516] text-white h-10 flex items-center justify-between px-4 sm:px-8 relative z-20">
<Link href="/" className="font-bold text-lg tracking-tight hover:underline focus:underline">Pew Builder</Link> <Link href="/" className="font-bold text-lg tracking-tight hover:underline focus:underline">Pew Builder</Link>
<UserCircleIcon className="h-7 w-7 text-white opacity-80" /> <div className="relative">
{loading ? null : session?.user ? (
<>
<button
className="flex items-center gap-2 focus:outline-none focus:ring-2 focus:ring-white/70 rounded-full px-3 py-1 hover:bg-[#3a4d12] transition font-semibold"
aria-haspopup="true"
aria-expanded={menuOpen}
onClick={() => setMenuOpen((v) => !v)}
>
My Profile
{session.user.name && (
<span className="hidden sm:inline text-white font-medium text-sm">({session.user.name})</span>
)}
</button>
{menuOpen && (
<div
className="absolute right-0 mt-2 w-56 bg-white dark:bg-neutral-800 rounded-md shadow-lg ring-1 ring-black/10 dark:ring-white/10 focus:outline-none divide-y divide-neutral-100 dark:divide-neutral-700 animate-fade-in"
tabIndex={-1}
onBlur={() => setMenuOpen(false)}
>
<div className="py-1">
{session?.user && (
<div className="px-4 py-2 text-xs text-neutral-500 dark:text-neutral-400 border-b border-neutral-100 dark:border-neutral-700">
{session.user.email}
</div>
)}
{menuItems.map((item, idx) =>
item.custom ? (
<div key={idx} className="px-4 py-2 flex items-center justify-between">
<span className="text-sm text-neutral-700 dark:text-neutral-200">Theme</span>
{item.custom}
</div>
) : item.href ? (
<Link
key={idx}
href={item.href}
className={`block px-4 py-2 text-sm rounded transition-colors ${
item.active
? 'bg-primary/10 text-primary font-semibold'
: 'text-neutral-700 dark:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-700'
}`}
onClick={item.onClick}
>
{item.label}
</Link>
) : (
<button
key={idx}
className="block w-full text-left px-4 py-2 text-sm text-neutral-700 dark:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded transition-colors"
onClick={item.onClick}
>
{item.label}
</button>
)
)}
</div>
</div>
)}
</>
) : (
<button
className="btn btn-sm btn-primary font-semibold px-4 py-1"
onClick={() => signIn()}
>
Sign In
</button>
)}
</div>
</div> </div>
{/* Subnav */} {/* Subnav */}
@@ -42,15 +157,11 @@ export default function Navbar() {
))} ))}
</div> </div>
{/* Right: Sign In + Search */} {/* Right: Search */}
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<Link href="/signin" className="btn btn-sm btn-ghost text-sm font-medium">
Sign In
</Link>
<button className="p-2 rounded-full hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors"> <button className="p-2 rounded-full hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors">
<MagnifyingGlassIcon className="h-5 w-5 text-neutral-700 dark:text-neutral-200" /> <MagnifyingGlassIcon className="h-5 w-5 text-neutral-700 dark:text-neutral-200" />
</button> </button>
<ThemeSwitcher />
</div> </div>
</div> </div>
</nav> </nav>

View File

@@ -0,0 +1,15 @@
'use client';
import { usePathname } from 'next/navigation';
import Navbar from './Navbar';
export default function NavigationWrapper() {
const pathname = usePathname();
const isAccountPage = pathname?.startsWith('/account');
if (isAccountPage) {
return null;
}
return <Navbar />;
}

View File

@@ -120,11 +120,18 @@ export default function ProductCard({ product, onAdd, added }: ProductCardProps)
</Link> </Link>
{onAdd && ( {onAdd && (
<button <button
className="btn btn-accent btn-sm ml-2" className="btn btn-neutral btn-sm ml-2 flex items-center gap-1"
onClick={onAdd} onClick={onAdd}
disabled={added} disabled={added}
> >
{added ? 'Added!' : 'Add'} {added ? (
'Added!'
) : (
<>
<span className="text-lg leading-none">+</span>
<span className="text-xs font-normal">to build</span>
</>
)}
</button> </button>
)} )}
</div> </div>

View File

@@ -0,0 +1,21 @@
'use client';
import { SessionProvider } from 'next-auth/react';
import { AuthProvider } from './AuthProvider';
import { ThemeProvider } from './ThemeProvider';
import NavigationWrapper from './NavigationWrapper';
export default function Providers({ children }: { children: React.ReactNode }) {
return (
<SessionProvider>
<AuthProvider>
<ThemeProvider>
<div className="min-h-screen bg-neutral-50 dark:bg-neutral-900 transition-colors duration-200">
<NavigationWrapper />
{children}
</div>
</ThemeProvider>
</AuthProvider>
</SessionProvider>
);
}

16
src/store/useAuthStore.ts Normal file
View File

@@ -0,0 +1,16 @@
import { create } from 'zustand';
import { Session } from 'next-auth';
interface AuthStore {
session: Session | null;
isLoading: boolean;
setSession: (session: Session | null) => void;
setLoading: (isLoading: boolean) => void;
}
export const useAuthStore = create<AuthStore>((set) => ({
session: null,
isLoading: true,
setSession: (session) => set({ session }),
setLoading: (isLoading) => set({ isLoading }),
}));