Brijesh's Git Server — argus-web @ c02beda1900bfabbff84bdf336bb6120523f3250

Web Ul for argus

feat: jwt auth and api keys
Brijesh Wawdhane ops@brijesh.dev
Wed, 11 Dec 2024 23:34:41 +0530
commit

c02beda1900bfabbff84bdf336bb6120523f3250

parent

c97d8b383688c6e1b56e47d8dc1f4032f0afe366

M package.jsonpackage.json

@@ -9,9 +9,11 @@ "start": "next start",

"lint": "next lint" }, "dependencies": { + "next": "15.1.0", "react": "^19.0.0", "react-dom": "^19.0.0", - "next": "15.1.0" + "react-hook-form": "^7.54.0", + "recoil": "^0.7.7" }, "devDependencies": { "postcss": "^8",
M pnpm-lock.yamlpnpm-lock.yaml

@@ -17,6 +17,12 @@ version: 19.0.0

react-dom: specifier: ^19.0.0 version: 19.0.0(react@19.0.0) + react-hook-form: + specifier: ^7.54.0 + version: 7.54.0(react@19.0.0) + recoil: + specifier: ^0.7.7 + version: 0.7.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0) devDependencies: postcss: specifier: ^8

@@ -373,6 +379,9 @@ glob@10.4.5:

resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} hasBin: true + hamt_plus@1.0.2: + resolution: {integrity: sha512-t2JXKaehnMb9paaYA7J0BX8QQAY8lwfQ9Gjf4pg/mk4krt+cmwmU652HOoWonf+7+EQV97ARPMhhVgU1ra2GhA==} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'}

@@ -563,6 +572,12 @@ resolution: {integrity: sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==}

peerDependencies: react: ^19.0.0 + react-hook-form@7.54.0: + resolution: {integrity: sha512-PS05+UQy/IdSbJNojBypxAo9wllhHgGmyr8/dyGQcPoiMf3e7Dfb9PWYVRco55bLbxH9S+1yDDJeTdlYCSxO3A==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react@19.0.0: resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==} engines: {node: '>=0.10.0'}

@@ -574,6 +589,18 @@ readdirp@3.6.0:

resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + recoil@0.7.7: + resolution: {integrity: sha512-8Og5KPQW9LwC577Vc7Ug2P0vQshkv1y3zG3tSSkWMqkWSwHmE+by06L8JtnGocjW6gcCvfwB3YtrJG6/tWivNQ==} + peerDependencies: + react: '>=16.13.1' + react-dom: '*' + react-native: '*' + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + resolve@1.22.8: resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} hasBin: true

@@ -995,6 +1022,8 @@ minipass: 7.1.2

package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + hamt_plus@1.0.2: {} + hasown@2.0.2: dependencies: function-bind: 1.1.2

@@ -1157,6 +1186,10 @@ dependencies:

react: 19.0.0 scheduler: 0.25.0 + react-hook-form@7.54.0(react@19.0.0): + dependencies: + react: 19.0.0 + react@19.0.0: {} read-cache@1.0.0:

@@ -1166,6 +1199,13 @@

readdirp@3.6.0: dependencies: picomatch: 2.3.1 + + recoil@0.7.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + dependencies: + hamt_plus: 1.0.2 + react: 19.0.0 + optionalDependencies: + react-dom: 19.0.0(react@19.0.0) resolve@1.22.8: dependencies:
A src/app/(auth)/layout.js

@@ -0,0 +1,52 @@

+"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; + +export default function AuthLayout({ children }) { + const router = useRouter(); + const [isChecking, setIsChecking] = useState(true); + + useEffect(() => { + const checkAuth = async () => { + try { + // Check if token exists in localStorage + const token = localStorage.getItem("token"); + if (token) { + // Verify token by making a request to /auth/me + const response = await fetch("http://localhost:8080/auth/me", { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (response.ok) { + // Token is valid, redirect to dashboard + router.replace("/dashboard"); + return; + } else { + // Token is invalid, remove it + localStorage.removeItem("token"); + } + } + } catch (error) { + console.error("Error checking authentication:", error); + localStorage.removeItem("token"); + } finally { + setIsChecking(false); + } + }; + + checkAuth(); + }, []); + + if (isChecking) { + return ( + <div className="min-h-screen flex items-center justify-center bg-gray-50"> + <div className="text-gray-500">Loading...</div> + </div> + ); + } + + return children; +}
A src/app/(auth)/login/page.js

@@ -0,0 +1,25 @@

+import Link from "next/link"; +import LoginForm from "@/components/auth/LoginForm"; + +export default function LoginPage() { + return ( + <div className="min-h-screen flex items-center justify-center bg-gray-50"> + <div className="max-w-md w-full space-y-6 p-6 bg-white rounded-md border"> + <div className="flex flex-col gap-2"> + <h2 className="text-xl font-semibold">Sign in to your account</h2> + <p className="text-sm text-gray-600"> + Or{" "} + <Link + href="/register" + className="font-medium text-blue-800 hover:text-blue-1000" + > + create a new account + </Link> + </p> + </div> + + <LoginForm /> + </div> + </div> + ); +}
A src/app/(auth)/register/page.js

@@ -0,0 +1,25 @@

+import Link from "next/link"; +import RegisterForm from "@/components/auth/RegisterForm"; + +export default function RegisterPage() { + return ( + <div className="min-h-screen flex items-center justify-center bg-gray-50"> + <div className="max-w-md w-full space-y-6 p-6 bg-white rounded-md border"> + <div className="flex flex-col gap-2"> + <h2 className="text-xl font-semibold">Create your account</h2> + <p className="text-sm text-gray-600"> + Already have an account?{" "} + <Link + href="/login" + className="font-medium text-blue-800 hover:text-blue-1000" + > + Sign in + </Link> + </p> + </div> + + <RegisterForm /> + </div> + </div> + ); +}
A src/app/actions/auth.js

@@ -0,0 +1,53 @@

+"use server"; + +import { redirect } from "next/navigation"; + +export async function login(formData) { + try { + const response = await fetch("http://localhost:8080/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: formData.get("email"), + password: formData.get("password"), + }), + }); + + if (!response.ok) { + throw new Error("Login failed"); + } + + const data = await response.json(); + + // Note: We'll need to handle token storage on the client side + // as we can't access localStorage from server actions + return { success: true, data }; + } catch (error) { + return { success: false, error: "Invalid credentials" }; + } +} + +export async function register(formData) { + try { + const response = await fetch("http://localhost:8080/auth/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: formData.get("email"), + password: formData.get("password"), + }), + }); + + if (!response.ok) { + throw new Error("Registration failed"); + } + + redirect("/login"); + } catch (error) { + return { success: false, error: "Registration failed" }; + } +}
A src/app/dashboard/api-keys/layout.js

@@ -0,0 +1,39 @@

+"use client"; + +import { useAuth } from "@/context/AuthContext"; +import Link from "next/link"; +import Button from "@/components/shared/Button"; +import { usePathname } from "next/navigation"; + +export default function APIKeysLayout({ children }) { + const pathname = usePathname(); + const { user } = useAuth(); + + if (!user) { + return <div>Loading...</div>; + } + + return ( + <div> + <div className="py-4"> + <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> + <div className="flex items-center justify-between"> + <div> + <h1 className="text-2xl font-semibold text-gray-900">API Keys</h1> + <p className="mt-3 text-sm text-gray-500"> + Manage your API keys for programmatic access to Argus. + </p> + </div> + {pathname === "/dashboard/api-keys" && ( + <Link href="/dashboard/api-keys/new"> + <Button>Create API Key</Button> + </Link> + )} + </div> + </div> + </div> + + <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">{children}</div> + </div> + ); +}
A src/app/dashboard/api-keys/new/page.js

@@ -0,0 +1,116 @@

+"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import Button from "@/components/shared/Button"; +import Input from "@/components/shared/Input"; + +export default function NewAPIKeyPage() { + const router = useRouter(); + const [name, setName] = useState(""); + const [error, setError] = useState(""); + const [newKey, setNewKey] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + async function handleSubmit(e) { + e.preventDefault(); + setIsSubmitting(true); + setError(""); + + try { + const response = await fetch("http://localhost:8080/api-keys", { + method: "POST", + headers: { + Authorization: `Bearer ${localStorage.getItem("token")}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ name }), + }); + + if (!response.ok) throw new Error("Failed to create API key"); + + const data = await response.json(); + setNewKey(data.key); + } catch (error) { + setError("Failed to create API key"); + console.error("Error:", error); + } finally { + setIsSubmitting(false); + } + } + + return ( + <> + {newKey ? ( + <div className="bg-white shadow rounded-lg p-6"> + <div className="space-y-4"> + <h2 className="text-lg font-medium text-gray-900"> + API Key Created + </h2> + <p className="text-sm text-gray-500"> + Please copy your API key now. You won't be able to see it again! + </p> + <div className="bg-gray-50 p-4 rounded-md"> + <code className="text-sm break-all">{newKey}</code> + </div> + <div className="flex justify-end space-x-4"> + <Button + variant="secondary" + onClick={() => { + navigator.clipboard.writeText(newKey); + }} + > + Copy to Clipboard + </Button> + <Button onClick={() => router.push("/dashboard/api-keys")}> + Done + </Button> + </div> + </div> + </div> + ) : ( + <div className="bg-white shadow rounded-lg p-6"> + <form onSubmit={handleSubmit} className="space-y-6"> + <div> + <h2 className="text-lg font-medium text-gray-900"> + Create New API Key + </h2> + <p className="mt-1 text-sm text-gray-500"> + Give your API key a name to help you identify it later. + </p> + </div> + + {error && ( + <div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded"> + {error} + </div> + )} + + <div> + <Input + label="API Key Name" + value={name} + onChange={(e) => setName(e.target.value)} + placeholder="e.g., Development Server" + required + /> + </div> + + <div className="flex justify-end space-x-4"> + <Button + variant="secondary" + type="button" + onClick={() => router.push("/dashboard/api-keys")} + > + Cancel + </Button> + <Button type="submit" disabled={isSubmitting}> + {isSubmitting ? "Creating..." : "Create API Key"} + </Button> + </div> + </form> + </div> + )} + </> + ); +}
A src/app/dashboard/api-keys/page.js

@@ -0,0 +1,142 @@

+"use client"; + +import { useState, useEffect } from "react"; +import Link from "next/link"; +import Button from "@/components/shared/Button"; + +export default function APIKeysPage() { + const [apiKeys, setApiKeys] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + fetchAPIKeys(); + }, []); + + async function fetchAPIKeys() { + try { + const response = await fetch("http://localhost:8080/api-keys", { + headers: { + Authorization: `Bearer ${localStorage.getItem("token")}`, + }, + }); + + if (!response.ok) throw new Error("Failed to fetch API keys"); + + const data = await response.json(); + setApiKeys(data); + } catch (error) { + setError("Failed to load API keys"); + console.error("Error:", error); + } finally { + setIsLoading(false); + } + } + + if (isLoading) { + return ( + <div className="flex items-center justify-center h-64"> + <div className="text-gray-500">Loading API keys...</div> + </div> + ); + } + + if (error) { + return ( + <div className="rounded-lg bg-white shadow p-6"> + <div className="text-red-600">{error}</div> + </div> + ); + } + + async function handleRevokeKey(keyId) { + if ( + !confirm( + "Are you sure you want to revoke this API key? This action cannot be undone.", + ) + ) { + return; + } + + try { + const response = await fetch(`http://localhost:8080/api-keys/${keyId}`, { + method: "DELETE", + headers: { + Authorization: `Bearer ${localStorage.getItem("token")}`, + }, + }); + + if (!response.ok) throw new Error("Failed to revoke API key"); + + await fetchAPIKeys(); // Refresh the list + } catch (error) { + setError("Failed to revoke API key"); + console.error("Error:", error); + } + } + + if (!apiKeys) { + return ( + <div className="rounded-lg bg-white shadow"> + <div className="px-4 py-12 my-0"> + <div className="text-center"> + <h3 className="mt-2 text-lg font-semibold text-gray-900"> + No API keys + </h3> + <p className="mt-1 text-sm text-gray-500"> + Create an API key to get started with programmatic access. + </p> + <div className="mt-6"> + <Link href="/dashboard/api-keys/new"> + <Button variant="secondary">Create API Key</Button> + </Link> + </div> + </div> + </div> + </div> + ); + } + + return ( + <> + <div className="bg-white shadow rounded-lg divide-y divide-gray-200"> + {apiKeys && + apiKeys.map((key) => ( + <APIKeyItem + key={key.id} + apiKey={key} + onRevoke={() => handleRevokeKey(key.id)} + /> + ))} + </div> + </> + ); +} + +function APIKeyItem({ apiKey, onRevoke }) { + return ( + <div className="px-6 py-4"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-4"> + <h3 className="text-lg font-medium text-gray-900 capitalize w-48 truncate"> + {apiKey.name} + </h3> + <div className="mt-1 text-sm text-gray-500"> + Created on {new Date(apiKey.created_at).toLocaleDateString()} + </div> + {apiKey.last_used_at && ( + <div className="mt-1 text-sm text-gray-500"> + Last used on {new Date(apiKey.last_used_at).toLocaleDateString()} + </div> + )} + </div> + <button + onClick={onRevoke} + className="text-sm text-red-600 hover:text-red-900" + > + Revoke + </button> + </div> + </div> + ); +}
A src/app/dashboard/layout.js

@@ -0,0 +1,100 @@

+"use client"; + +import { useAuth } from "@/context/AuthContext"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +export default function DashboardLayout({ children }) { + const { user, logout, isLoading } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (!isLoading && !user) { + router.push("/login"); + } + }, [user, isLoading, router]); + + if (isLoading) { + return ( + <div className="min-h-screen flex items-center justify-center bg-gray-50"> + <div className="text-gray-500">Loading...</div> + </div> + ); + } + + if (!user) { + return null; + } + + return ( + <div className="min-h-screen bg-gray-50"> + <nav className="bg-white border-b"> + <div className="px-4"> + <div className="flex justify-between h-12"> + <div className="flex items-center gap-8"> + <span className="text-md font-medium">Argus Dashboard</span> + {/* <div className="flex items-center gap-4"> + <Link href="/dashboard"> + <p className="text-sm text-gray-600 hover:text-gray-800"> + Home + </p> + </Link> + <Link href="/dashboard/api-keys"> + <p className="text-sm text-gray-600 hover:text-gray-800"> + API Keys + </p> + </Link> + <Link href="/dashboard/monitors"> + <p className="text-sm text-gray-600 hover:text-gray-800"> + Monitors + </p> + </Link> + <Link href="/dashboard/logs"> + <p className="text-sm text-gray-600 hover:text-gray-800"> + Logs + </p> + </Link> + </div> */} + </div> + <div className="flex items-center gap-4"> + {user && ( + <span className="text-sm text-gray-600">{user.email}</span> + )} + <button + onClick={logout} + className="text-sm text-red-600 hover:text-red-800" + > + Logout + </button> + </div> + </div> + </div> + </nav> + <nav className="bg-white border-b"> + <div className="px-4"> + <div className="flex items-center gap-4 h-12"> + <Link href="/dashboard"> + <p className="text-sm text-gray-600 hover:text-gray-800">Home</p> + </Link> + <Link href="/dashboard/api-keys"> + <p className="text-sm text-gray-600 hover:text-gray-800"> + API Keys + </p> + </Link> + <Link href="/dashboard/monitors"> + <p className="text-sm text-gray-600 hover:text-gray-800"> + Monitors + </p> + </Link> + <Link href="/dashboard/logs"> + <p className="text-sm text-gray-600 hover:text-gray-800">Logs</p> + </Link> + </div> + </div> + </nav> + + <main>{children}</main> + </div> + ); +}
A src/app/dashboard/logs/page.js

@@ -0,0 +1,7 @@

+export default function LogsPage() { + return ( + <div className="p-4"> + <p>To do: Logs</p> + </div> + ); +}
A src/app/dashboard/monitors/page.js

@@ -0,0 +1,7 @@

+export default function MonitorsPage() { + return ( + <div className="p-4"> + <p>To do: Monitors</p> + </div> + ); +}
A src/app/dashboard/page.js

@@ -0,0 +1,48 @@

+"use client"; + +import { useAuth } from "@/context/AuthContext"; + +export default function DashboardPage() { + const { user, isLoading } = useAuth(); + + if (isLoading) { + return ( + <div className="flex items-center justify-center h-64"> + <div className="text-gray-500">Loading...</div> + </div> + ); + } + + if (!user) { + return null; + } + + return ( + <div className="p-4"> + <h1 className="text-lg mb-4">Welcome to your dashboard</h1> + <div className="space-y-4"> + <div> + <h2 className="mb-2">Profile</h2> + <div className="space-y-1"> + <div className="flex"> + <span className="text-sm text-gray-500 w-24">Email:</span> + <span className="text-sm">{user.email}</span> + </div> + <div className="flex"> + <span className="text-sm text-gray-500 w-24">Member since:</span> + <span className="text-sm"> + {new Date(user.created_at).toLocaleDateString()} + </span> + </div> + <div className="flex"> + <span className="text-sm text-gray-500 w-24">User ID:</span> + <span className="text-sm font-mono bg-gray-50 px-2 rounded"> + {user.id} + </span> + </div> + </div> + </div> + </div> + </div> + ); +}
M src/app/globals.csssrc/app/globals.css

@@ -1,21 +1,3 @@

@tailwind base; @tailwind components; @tailwind utilities; - -:root { - --background: #ffffff; - --foreground: #171717; -} - -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } -} - -body { - color: var(--foreground); - background: var(--background); - font-family: Arial, Helvetica, sans-serif; -}
M src/app/layout.jssrc/app/layout.js

@@ -1,4 +1,7 @@

+"use client"; + import { Geist, Geist_Mono } from "next/font/google"; +import { AuthProvider } from "@/context/AuthContext"; import "./globals.css"; const geistSans = Geist({

@@ -11,18 +14,18 @@ variable: "--font-geist-mono",

subsets: ["latin"], }); -export const metadata = { - title: "Create Next App", - description: "Generated by create next app", -}; +// export const metadata = { +// title: "Argus Dashboard", +// description: "Dashboard for Argus Logging Platform", +// }; export default function RootLayout({ children }) { return ( <html lang="en"> <body - className={`${geistSans.variable} ${geistMono.variable} antialiased`} + className={`${geistSans.variable} ${geistMono.variable} text-gray-800 min-h-screen bg-white antialiased`} > - {children} + <AuthProvider>{children}</AuthProvider> </body> </html> );
A src/components/auth/LoginForm.js

@@ -0,0 +1,74 @@

+"use client"; + +import { useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import Input from "../shared/Input"; +import Button from "../shared/Button"; +import { useAuth } from "@/context/AuthContext"; +import { login } from "@/app/actions/auth"; + +export default function LoginForm() { + const router = useRouter(); + const { login: authLogin } = useAuth(); + const [error, setError] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const formRef = useRef(); + + async function handleSubmit(formData) { + setIsSubmitting(true); + setError(""); + + const result = await login(formData); + + if (result.success) { + // Handle successful login on the client side + await authLogin(result.data.user, result.data.token); + router.push("/dashboard"); + } else { + setError(result.error); + } + + setIsSubmitting(false); + } + + return ( + <div className="space-y-4"> + {error && ( + <div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded"> + {error} + </div> + )} + + <form action={handleSubmit} ref={formRef}> + <div className="space-y-8"> + <div className="space-y-4"> + <Input + label="Email" + name="email" + type="email" + required + error={error} + /> + + <Input + label="Password" + name="password" + type="password" + required + minLength={6} + error={error} + /> + </div> + + <Button + type="submit" + disabled={isSubmitting} + className="w-full bg-blue-800 hover:bg-blue-900 transition text-white px-4 py-2 rounded-md font-medium" + > + {isSubmitting ? "Signing in..." : "Sign in"} + </Button> + </div> + </form> + </div> + ); +}
A src/components/auth/RegisterForm.js

@@ -0,0 +1,84 @@

+"use client"; + +import { useRef, useState } from "react"; +import Input from "../shared/Input"; +import Button from "../shared/Button"; +import { register } from "@/app/actions/auth"; + +export default function RegisterForm() { + const [error, setError] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const formRef = useRef(); + const passwordRef = useRef(); + + async function handleSubmit(formData) { + setIsSubmitting(true); + setError(""); + + // Check if passwords match + if (formData.get("password") !== formData.get("confirmPassword")) { + setError("Passwords do not match"); + setIsSubmitting(false); + return; + } + + const result = await register(formData); + + if (!result?.success && result?.error) { + setError(result.error); + } + + setIsSubmitting(false); + } + + return ( + <div className="space-y-4"> + {error && ( + <div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded"> + {error} + </div> + )} + + <form action={handleSubmit} ref={formRef}> + <div className="space-y-8"> + <div className="space-y-4"> + <Input + label="Email" + name="email" + type="email" + required + pattern="[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i" + error={error} + /> + + <Input + label="Password" + name="password" + type="password" + required + minLength={6} + ref={passwordRef} + error={error} + /> + + <Input + label="Confirm Password" + name="confirmPassword" + type="password" + required + error={error} + /> + </div> + + <Button + type="submit" + disabled={isSubmitting} + className="w-full bg-blue-800 hover:bg-blue-900 transition text-white px-4 py-2 rounded-md font-medium" + > + {isSubmitting ? "Registering..." : "Register"} + </Button> + </div> + </form> + </div> + ); +}
A src/components/shared/Button.js

@@ -0,0 +1,15 @@

+export default function Button({ children, variant = "primary", ...props }) { + const variants = { + primary: "bg-blue-700 hover:bg-blue-800 text-white", + secondary: "bg-gray-200 hover:bg-gray-300 text-gray-800", + }; + + return ( + <button + className={`px-3 py-1.5 rounded-md font-medium transition-colors ${variants[variant]}`} + {...props} + > + {children} + </button> + ); +}
A src/components/shared/Input.js

@@ -0,0 +1,24 @@

+import { forwardRef } from "react"; + +const Input = forwardRef(({ label, error, ...props }, ref) => { + return ( + <div className="w-full"> + {label && ( + <label className="block text-sm font-medium mb-1">{label}</label> + )} + <input + ref={ref} + className={`w-full appearance-none rounded-md border px-2.5 py-1.5 shadow-sm + outline-none transition bg-white text-gray-900 placeholder-gray-400 focus:ring-2 + focus:ring-blue-200 focus:border-blue-500 + ${error ? "border-red-500" : "border-gray-300"}`} + {...props} + /> + {error && <p className="mt-1 text-sm text-red-500">{error}</p>} + </div> + ); +}); + +Input.displayName = "Input"; + +export default Input;
A src/context/AuthContext.js

@@ -0,0 +1,65 @@

+"use client"; + +import { createContext, useContext, useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; + +const AuthContext = createContext({}); + +export function AuthProvider({ children }) { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const router = useRouter(); + + // Function to fetch user data using the token + const fetchUserData = async (token) => { + try { + const response = await fetch("http://localhost:8080/auth/me", { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error("Failed to fetch user data" + response.statusText); + } + + const userData = await response.json(); + setUser(userData); + return userData; + } catch (error) { + console.error("Error fetching user data:", error); + localStorage.removeItem("token"); + setUser(null); + router.push("/login"); + } + }; + + // Check for token and fetch user data on initial load + useEffect(() => { + const token = localStorage.getItem("token"); + if (token) { + fetchUserData(token).finally(() => setIsLoading(false)); + } else { + setIsLoading(false); + } + }, []); + + const login = async (userData, token) => { + setUser(userData); + localStorage.setItem("token", token); + }; + + const logout = () => { + setUser(null); + localStorage.removeItem("token"); + router.push("/login"); + }; + + return ( + <AuthContext.Provider value={{ user, login, logout, isLoading }}> + {children} + </AuthContext.Provider> + ); +} + +export const useAuth = () => useContext(AuthContext);
A src/middleware.js

@@ -0,0 +1,10 @@

+import { NextResponse } from "next/server"; + +export function middleware(request) { + // We'll handle auth checks in the components instead + return NextResponse.next(); +} + +export const config = { + matcher: ["/dashboard/:path*", "/login", "/register"], +};
A src/state/auth.js

@@ -0,0 +1,26 @@

+import { atom, selector } from "recoil"; + +export const authState = atom({ + key: "authState", + default: { + isAuthenticated: false, + user: null, + token: null, + }, +}); + +export const isAuthenticatedSelector = selector({ + key: "isAuthenticatedSelector", + get: ({ get }) => { + const auth = get(authState); + return auth?.isAuthenticated || false; + }, +}); + +export const userSelector = selector({ + key: "userSelector", + get: ({ get }) => { + const auth = get(authState); + return auth?.user || null; + }, +});
M tailwind.config.mjstailwind.config.mjs

@@ -10,6 +10,21 @@ extend: {

colors: { background: "var(--background)", foreground: "var(--foreground)", + blue: { + 100: "#e0f2ff", + 200: "#cae8ff", + 300: "#b5deff", + 400: "#96cefd", + 500: "#78bbfa", + 600: "#59a7f6", + 700: "#3892f3", + 800: "#147af3", + 900: "#0265dc", + 1000: "#0054b6", + 1100: "#004491", + 1200: "#003571", + 1300: "#002754", + }, }, }, },