diff --git a/app/(pages)/register/page.tsx b/app/(pages)/register/page.tsx index cb62123..541737a 100644 --- a/app/(pages)/register/page.tsx +++ b/app/(pages)/register/page.tsx @@ -1,6 +1,66 @@ +"use client"; + +import { IconLock } from "@tabler/icons-react"; +import { motion } from "motion/react"; +import Link from "next/link"; +import { useEffect, useState } from "react"; import { AuthForm } from "@/components/auth-form"; export default function RegisterPage() { + const [registrationOpen, setRegistrationOpen] = useState( + null, + ); + + useEffect(() => { + fetch("/api/registration/status") + .then((res) => res.json()) + .then((data) => setRegistrationOpen(data.registrationOpen)) + .catch(() => setRegistrationOpen(false)); + }, []); + + if (registrationOpen === null) { + return
; + } + + if (!registrationOpen) { + return ( +
+
+
+ +
+ +
+
+

+ Registration Closed +

+

+ New accounts are not being accepted right now. Contact the admin + if you need access. +

+
+ + Sign in instead + +
+
+
+ ); + } + return (
diff --git a/app/(pages)/settings/page.tsx b/app/(pages)/settings/page.tsx new file mode 100644 index 0000000..08d8dd1 --- /dev/null +++ b/app/(pages)/settings/page.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { + IconLogout, + IconSettings, + IconShieldLock, + IconUser, + IconUserPlus, +} from "@tabler/icons-react"; +import { motion } from "motion/react"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; +import { + Card, + CardContent, + CardDescription, + CardTitle, +} from "@/components/ui/card"; +import { Switch } from "@/components/ui/switch"; +import { signOut, useSession } from "@/lib/auth/client"; + +const sectionVariants = { + hidden: { opacity: 0, y: 20 }, + visible: { + opacity: 1, + y: 0, + transition: { type: "spring" as const, stiffness: 200, damping: 24 }, + }, +}; + +export default function SettingsPage() { + const { data: session, isPending } = useSession(); + const router = useRouter(); + const [registrationOpen, setRegistrationOpen] = useState(false); + const [loadingSettings, setLoadingSettings] = useState(true); + const [toggling, setToggling] = useState(false); + + const isAdmin = session?.user?.role === "admin"; + + const fetchSettings = useCallback(async () => { + try { + const res = await fetch("/api/admin/settings"); + if (res.ok) { + const data = await res.json(); + setRegistrationOpen(data.registrationOpen); + } + } finally { + setLoadingSettings(false); + } + }, []); + + useEffect(() => { + if (isPending) return; + if (!session?.user) { + router.replace("/login"); + return; + } + if (session.user.role === "admin") { + fetchSettings(); + } else { + setLoadingSettings(false); + } + }, [session, isPending, router, fetchSettings]); + + async function handleToggleRegistration(checked: boolean) { + setToggling(true); + try { + const res = await fetch("/api/admin/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ registrationOpen: checked }), + }); + if (res.ok) { + setRegistrationOpen(checked); + } + } finally { + setToggling(false); + } + } + + if (isPending || loadingSettings) { + return
; + } + + if (!session?.user) return null; + + const memberSince = new Date(session.user.createdAt).toLocaleDateString( + undefined, + { year: "numeric", month: "long" }, + ); + const initial = session.user.name?.charAt(0).toUpperCase() ?? "?"; + + return ( + + +
+ +

Settings

+
+

+ Manage your account and preferences +

+
+ + {/* Account section */} + +
+ +

+ Account +

+
+ + +
+ {initial} +
+
+ {session.user.name} + {session.user.email} +

+ Member since {memberSince} +

+
+
+ + + +
+
+ + {/* Administration section — admin only */} + {isAdmin && ( + +
+ +

+ Administration +

+ + Admin + +
+ + +
+
+
+ +
+
+ Open registration + + Allow new users to create accounts. Useful for adding + household members. + +
+
+ +
+
+
+
+ )} +
+ ); +} diff --git a/app/api/admin/settings/route.ts b/app/api/admin/settings/route.ts new file mode 100644 index 0000000..093d435 --- /dev/null +++ b/app/api/admin/settings/route.ts @@ -0,0 +1,30 @@ +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth/server"; +import { getSetting, setSetting } from "@/lib/services/settings"; + +export async function GET() { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (session.user.role !== "admin") + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + + const registrationOpen = (await getSetting("registrationOpen")) === "true"; + return NextResponse.json({ registrationOpen }); +} + +export async function POST(request: Request) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (session.user.role !== "admin") + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + + const body = await request.json(); + if (typeof body.registrationOpen === "boolean") { + await setSetting("registrationOpen", String(body.registrationOpen)); + } + + return NextResponse.json({ success: true }); +} diff --git a/app/api/registration/status/route.ts b/app/api/registration/status/route.ts new file mode 100644 index 0000000..e2e4178 --- /dev/null +++ b/app/api/registration/status/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from "next/server"; +import { isRegistrationOpen } from "@/lib/services/settings"; + +export async function GET() { + const registrationOpen = await isRegistrationOpen(); + return NextResponse.json({ registrationOpen }); +} diff --git a/components/mobile-tab-bar.tsx b/components/mobile-tab-bar.tsx index 73f2a52..10231ac 100644 --- a/components/mobile-tab-bar.tsx +++ b/components/mobile-tab-bar.tsx @@ -1,20 +1,20 @@ "use client"; -import { IconHome, IconSearch, IconUser } from "@tabler/icons-react"; +import { IconHome, IconSearch, IconSettings } from "@tabler/icons-react"; import { motion } from "motion/react"; import Link from "next/link"; -import { usePathname, useRouter } from "next/navigation"; -import { signOut, useSession } from "@/lib/auth/client"; +import { usePathname } from "next/navigation"; +import { useSession } from "@/lib/auth/client"; const tabs = [ { href: "/dashboard", label: "Home", icon: IconHome }, { href: "/search", label: "Search", icon: IconSearch }, + { href: "/settings", label: "Settings", icon: IconSettings }, ] as const; export function MobileTabBar() { const { data: session } = useSession(); const pathname = usePathname(); - const router = useRouter(); if (!session?.user) return null; @@ -56,20 +56,6 @@ export function MobileTabBar() { ); })} -
{/* Safe area for devices with home indicator */}
diff --git a/components/nav-bar.tsx b/components/nav-bar.tsx index 342c084..4685578 100644 --- a/components/nav-bar.tsx +++ b/components/nav-bar.tsx @@ -1,6 +1,6 @@ "use client"; -import { IconLogout, IconSearch } from "@tabler/icons-react"; +import { IconLogout, IconSearch, IconSettings } from "@tabler/icons-react"; import { motion } from "motion/react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; @@ -78,6 +78,12 @@ export function NavBar() { {session.user.name} + + +