From 0043a5bf3c8e1555746b433614c1950f7d972f41 Mon Sep 17 00:00:00 2001 From: Aidan Date: Mon, 7 Jul 2025 20:01:59 -0400 Subject: [PATCH] adds user accounts, service requests, dashboard, admin panel, better layout, db+altcha+auth support --- .dockerignore | 3 +- .env.example | 3 + .gitignore | 4 + README.md | 185 ++++++- app/admin/page.tsx | 884 ++++++++++++++++++++++++++++++ app/api/admin/activity/route.ts | 103 ++++ app/api/admin/requests/route.ts | 121 ++++ app/api/admin/services/route.ts | 165 ++++++ app/api/admin/users/route.ts | 68 +++ app/api/auth/[...all]/route.ts | 4 + app/api/captcha/route.ts | 22 + app/api/login/route.ts | 71 +++ app/api/logout/route.ts | 23 + app/api/service-requests/route.ts | 110 ++++ app/api/services/route.ts | 24 + app/api/signup/route.ts | 102 ++++ app/api/user-services/route.ts | 52 ++ app/dashboard/page.tsx | 266 +++++++++ app/globals.css | 6 + app/login/page.tsx | 181 ++++++ app/page.tsx | 191 ++++--- app/requests/page.tsx | 355 ++++++++++++ app/services/page.tsx | 307 ++++++++--- app/signup/page.tsx | 188 +++++++ components/core/altcha.tsx | 57 ++ components/core/nav.tsx | 55 ++ components/front/services.tsx | 290 +++++++++- config/services.ts | 41 +- db/index.ts | 7 + db/schema.ts | 84 +++ drizzle.config.ts | 10 + examples/docker-compose.dev.yml | 19 + examples/docker-compose.yml | 13 +- middleware.ts | 32 ++ package.json | 20 +- tools/hmac.ts | 22 + tools/seed-db.ts | 28 + util/auth-client.ts | 4 + util/auth.ts | 27 + util/captcha.ts | 22 + 40 files changed, 3981 insertions(+), 188 deletions(-) create mode 100644 .env.example create mode 100644 app/admin/page.tsx create mode 100644 app/api/admin/activity/route.ts create mode 100644 app/api/admin/requests/route.ts create mode 100644 app/api/admin/services/route.ts create mode 100644 app/api/admin/users/route.ts create mode 100644 app/api/auth/[...all]/route.ts create mode 100644 app/api/captcha/route.ts create mode 100644 app/api/login/route.ts create mode 100644 app/api/logout/route.ts create mode 100644 app/api/service-requests/route.ts create mode 100644 app/api/services/route.ts create mode 100644 app/api/signup/route.ts create mode 100644 app/api/user-services/route.ts create mode 100644 app/dashboard/page.tsx create mode 100644 app/login/page.tsx create mode 100644 app/requests/page.tsx create mode 100644 app/signup/page.tsx create mode 100644 components/core/altcha.tsx create mode 100644 db/index.ts create mode 100644 db/schema.ts create mode 100644 drizzle.config.ts create mode 100644 examples/docker-compose.dev.yml create mode 100644 middleware.ts create mode 100644 tools/hmac.ts create mode 100644 tools/seed-db.ts create mode 100644 util/auth-client.ts create mode 100644 util/auth.ts create mode 100644 util/captcha.ts diff --git a/.dockerignore b/.dockerignore index c6ff0c6..5e7da5b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,4 +4,5 @@ docker-compose.yml examples/ node_modules .next -.git \ No newline at end of file +.git +postgres/ \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..224a866 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +BETTER_AUTH_SECRET= +BETTER_AUTH_URL=http://localhost:3000 +DATABASE_URL=postgres://:@:/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3892f25..6b9c1ce 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel @@ -46,3 +47,6 @@ bun.lock* # docker docker-compose.yml !examples/docker-compose.yml + +# db +postgres/ \ No newline at end of file diff --git a/README.md b/README.md index e215bc4..6e32a63 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,171 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# pontus-front -## Getting Started +The source code for the p0ntus web frontend. -First, run the development server: +## Introduction + +p0ntus is the successor to LibreCloud, with the same goals as the latter. p0ntus brings simplicity and structure, carrying on the values, structure, and rhythm that LibreCloud offered. + +It's maintained solo, with occasional contributions from others. Because of this, I am severely limited by time. I am able to get to most requests and issues within the day. As it has become a controversial topic, I do use AI tools while programming. They help me so I still have time for other things in life. + +Going into technical details, I built p0ntus with my most familiar stack. It consists of Next.js, Shadcn UI, Drizzle, Postgres, Better Auth, Altcha, and Docker. It is built to be visually simple, with easily extendable code. As always, this project is Unlicensed, meaning the code is available under public domain. I highly encourage modification and forks. + +p0ntus will likely always be untested on Windows. I always encourage using Linux, but macOS works just fine too. Windows is spyware anyway, and you will have a much better experience both hosting and testing p0ntus under Linux. + +## Enterprise Usage + +I do encourage using p0ntus for commercial purposes. While the license doesn't hold you to it, I ask that you follow the same (or similar) values as the original project. They are open to interpretation, but hopefully your moral compass guides you. + +If you are a company or organization in need of support or features, you can contact me at aidan[at]p0ntus[dot]com. All changes made, regardless of compensation, will be published publicly under the Unlicense. Support and feature requests are available to individuals and non-profits for free. + +## Privacy + +p0ntus is great for privacy! No data is collected, and many measures are taken to avoid proprietary software (and the tracking, logging, and invasive measures that come with them), while still providing a great experience to you. + +CAPTCHAs are done with Altcha, which is a personal favorite of mine. It allows us to provide spam-free services, while still preserving your privacy. Proof of work is used instead, and no data is collected as a result. + +p0ntus itself does not collect information in the background without your knowledge, and your account data is represented by what *you, yourself have entered*. We bring a connected experience with as little data collection as possible. + +## Setup with Docker + +I **highly** encourage the use of Docker for self-hosting p0ntus. It is the preferred deployment method for production use, and has the most testing. If you are planning to improve/modify the source code of p0ntus, please use the "Setup for Development" section. It will help you test more efficiently. + +### What you need + +- A server or computer +- `git` and [Bun](https://bun.sh) +- Docker and Docker Compose + +### The Instructions + +Let's dive in! First, clone the repo: + +```bash +git clone https://git.p0ntus.com/pontus/pontus-front +``` + +For good measure, we'll install dependancies now: + +```bash +bun install # or npm +``` + +Next, you should set your `.env`. You can copy and edit a good working example like so: + +```bash +cp .env.example .env +vim .env # or nano +``` + +Once in the editor of your choice, edit `BETTER_AUTH_URL` with your domain name that you intend to deploy this to. Change the protocol if it applies. If you are developing p0ntus, you should leave this blank. `BETTER_AUTH_SECRET` should be set to a random value. + +You don't need to change anything else for now. We'll come back to `.env` in a second. Save and exit in your editor, then run this command to generate and insert an `ALTCHA_SECRET` into your `.env`: + +```bash +bun tools/hmac.ts +``` + +You can now copy the example Docker Compose file to the project root. You should change the password of the database, and additionally the username and database if you choose. + +```bash +cp examples/docker-compose.yml docker-compose.yml +``` + +Then, reopen `.env` with your choice of editor and change the database URL to the format below. Replace the placeholders with the values you just set in `docker-compose.yml`. The host is `postgres`, unless you changed the name in `docker-compose.yml`, and the port is `5432`. + +`postgres://:@:/` + +We will now bring up the database, push the schema, and seed services to the DB in one easy command. Let's go! + +```bash +docker compose up postgres -d && bunx drizzle-kit push && bun tools/seed-db.ts +``` + +Assuming that completed correctly, you will now be ready to build and run the web interface. It's easy: + +```bash +docker compose up -d +``` + +**You will now have everything working and ready!** You can find it at http://localhost:3000. This obviously isn't suitable for production use, so you should use a reverse proxy pointing to the `pontus-front` container. This will also allow for easy use of SSL. Reverse proxy is the most tested deployment method. I suggest NGINX Proxy Manager. + +If you use a reverse proxy, don't forget to comment the ports section out like so: + +```yaml +... +#ports: +# - 3000:3000 +... +``` + +## Setup for Development + +### What you need + +- A server or computer +- `git` and [Bun](https://bun.sh) +- Docker and Docker Compose + +### The Instructions + +Let's dive in! First, clone the repo: + +```bash +git clone https://git.p0ntus.com/pontus/pontus-front +``` + +For good measure, we'll install dependancies now: + +```bash +bun install # or npm +``` + +Next, you should set your `.env`. The only thing to change, for now, is setting `BETTER_AUTH_SECRET` to a random value. You can copy and edit a good working example like so: + +```bash +cp .env.example .env +vim .env # or nano +``` + +We'll now generate and insert an `ALTCHA_SECRET` into the `.env` file with this: + +```bash +bun tools/hmac.ts +``` + +You can now copy the example Docker Compose file to the project root. You should change the password of the database, and additionally the username and database if you choose. + +```bash +cp examples/docker-compose.dev.yml docker-compose.yml +``` + +Then, reopen `.env` with your choice of editor and change the database URL to the format below. Replace the placeholders with the values you just set in `docker-compose.yml`. In a development environment, your host should be `localhost`, and the port `5432`. This is the default in `examples/docker-compose.dev.yml` as well. + +`postgres://:@:/` + +We will now bring up the database, push the schema, and seed services to the DB in one easy command. Let's go! + +```bash +docker compose up postgres -d && bunx drizzle-kit push && bun tools/seed-db.ts +``` + +Assuming that completed correctly, you will now be ready to build and test the web interface. With Postgres running in the background, you are free to develop easily. You can use `bunx drizzle-kit studio` to get a better view at the DB in a web interface. + +Once you run the below command, the Next.js web interface will be brought up in a development environment. This makes it easier to see your changes, as they update live, in your browser, without restarting anything. ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or bun dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +At any time, you can also run `docker compose up -d --build` to test in Docker. -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +Now, open up http://localhost:3000 and see how it goes! Leave an Issue if you encounter any challenges or issues along the way. -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +## Updating -## Learn More +Updates are done through Forgejo (and mirrored GitHub). You can perform an update when there are new commits like so: -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +1. `git pull` +2. `bunx drizzle-kit push` +3. `bun tools/seed-db.ts` +4. `docker compose up -d --build` \ No newline at end of file diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..61b25cf --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,884 @@ +"use client" + +import { Nav } from "@/components/core/nav"; +import Altcha from "@/components/core/altcha"; +import { authClient } from "@/util/auth-client"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { + TbShield, + TbUsers, + TbSend, + TbCheck, + TbX, + TbClock, + TbEdit, + TbNotes, + TbChartLine as TbChart, + TbSettings, + TbTrendingUp, + TbCalendar, + TbEye, + TbUserMinus, +} from "react-icons/tb"; + +interface ExtendedUser { + id: string; + name: string; + email: string; + emailVerified: boolean; + createdAt: Date; + updatedAt: Date; + image?: string | null; + role?: string; +} + +interface User { + id: string; + name: string; + email: string; + emailVerified: boolean; + role: 'user' | 'admin'; + createdAt: string; + updatedAt: string; +} + +interface ServiceRequest { + id: string; + userId: string; + userName: string; + userEmail: string; + serviceName: string; + serviceDescription: string; + reason: string; + status: 'pending' | 'approved' | 'denied'; + adminNotes?: string; + reviewedAt?: string; + createdAt: string; + updatedAt: string; +} + +interface Service { + id: string; + name: string; + description: string; + priceStatus: string; + joinLink?: string; + enabled: boolean; + createdAt: string; + updatedAt: string; + users: { + userId: string; + userName: string; + userEmail: string; + grantedAt: string; + }[]; +} + +interface ActivityData { + requestActivity: Array<{ date: string; count: number; status: string }>; + userActivity: Array<{ date: string; count: number }>; + accessActivity: Array<{ date: string; count: number }>; + recentActivity: Array<{ + id: string; + type: string; + description: string; + status: string; + createdAt: string; + userName: string; + serviceName: string; + }>; + servicePopularity: Array<{ + serviceName: string; + requestCount: number; + approvedCount: number; + }>; + totals: { + totalRequests: number; + totalUsers: number; + totalAccess: number; + }; + period: number; +} + +export default function AdminDashboard() { + const router = useRouter(); + const { data: session, isPending } = authClient.useSession(); + const [mounted, setMounted] = useState(false); + const [accessGranted, setAccessGranted] = useState(false); + const [users, setUsers] = useState([]); + const [requests, setRequests] = useState([]); + const [services, setServices] = useState([]); + const [activityData, setActivityData] = useState(null); + const [loading, setLoading] = useState(true); + const [activeTab, setActiveTab] = useState<'overview' | 'users' | 'requests' | 'services'>('overview'); + const [editingRequest, setEditingRequest] = useState(null); + const [requestStatus, setRequestStatus] = useState<'pending' | 'approved' | 'denied'>('pending'); + const [adminNotes, setAdminNotes] = useState(""); + const [selectedService, setSelectedService] = useState(""); + const [selectedUser, setSelectedUser] = useState(""); + const [editingService, setEditingService] = useState(null); + const [serviceSettings, setServiceSettings] = useState({ + enabled: true, + priceStatus: "open" as "open" | "invite-only" | "by-request", + description: "", + joinLink: "" + }); + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + if (mounted && !isPending && !session) { + router.push("/login?message=Please sign in to access the admin dashboard"); + } + }, [session, isPending, mounted, router]); + + useEffect(() => { + if (session && (session.user as ExtendedUser).role !== 'admin') { + router.push("/dashboard?message=Access denied: Admin privileges required"); + } + }, [session, router]); + + useEffect(() => { + if (session && (session.user as ExtendedUser).role === 'admin' && accessGranted) { + fetchData(); + } + }, [session, accessGranted]); + + useEffect(() => { + if (session && (session.user as ExtendedUser).role === 'admin' && accessGranted) { + fetchData(); + } + }, [activeTab, session, accessGranted]); + + const fetchData = async () => { + setLoading(true); + try { + const [usersResponse, requestsResponse, servicesResponse, activityResponse] = await Promise.all([ + fetch("/api/admin/users"), + fetch("/api/admin/requests"), + fetch("/api/admin/services"), + fetch("/api/admin/activity?period=7") + ]); + + if (usersResponse.ok) { + const usersData = await usersResponse.json(); + setUsers(usersData.users); + } + + if (requestsResponse.ok) { + const requestsData = await requestsResponse.json(); + setRequests(requestsData.requests); + } + + if (servicesResponse.ok) { + const servicesData = await servicesResponse.json(); + setServices(servicesData.services); + } + + if (activityResponse.ok) { + const activityResponseData = await activityResponse.json(); + setActivityData(activityResponseData); + } + } catch (error) { + console.error("Error fetching admin data:", error); + } finally { + setLoading(false); + } + }; + + const handleCaptchaVerification = (token: string) => { + if (token) { + setAccessGranted(true); + } + }; + + const updateUserRole = async (userId: string, newRole: 'user' | 'admin') => { + try { + const response = await fetch("/api/admin/users", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ userId, role: newRole }), + }); + + if (response.ok) { + setUsers(users.map(user => + user.id === userId ? { ...user, role: newRole } : user + )); + } + } catch (error) { + console.error("Error updating user role:", error); + } + }; + + const updateRequestStatus = async (requestId: string) => { + try { + const response = await fetch("/api/admin/requests", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + requestId, + status: requestStatus, + adminNotes: adminNotes || undefined + }), + }); + + if (response.ok) { + setRequests(requests.map(request => + request.id === requestId + ? { ...request, status: requestStatus, adminNotes, reviewedAt: new Date().toISOString() } + : request + )); + setEditingRequest(null); + setAdminNotes(""); + } + } catch (error) { + console.error("Error updating request:", error); + } + }; + + const grantServiceAccess = async (userId: string, serviceId: string) => { + try { + const response = await fetch("/api/admin/services", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ action: 'grant', userId, serviceId }), + }); + + if (response.ok) { + fetchData(); + } + } catch (error) { + console.error("Error granting service access:", error); + } + }; + + const revokeServiceAccess = async (userId: string, serviceId: string) => { + try { + const response = await fetch("/api/admin/services", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ action: 'revoke', userId, serviceId }), + }); + + if (response.ok) { + fetchData(); + } + } catch (error) { + console.error("Error revoking service access:", error); + } + }; + + const updateService = async (serviceId: string) => { + try { + const response = await fetch("/api/admin/services", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + serviceId, + enabled: serviceSettings.enabled, + priceStatus: serviceSettings.priceStatus, + description: serviceSettings.description, + joinLink: serviceSettings.joinLink + }), + }); + + if (response.ok) { + setEditingService(null); + fetchData(); + } + } catch (error) { + console.error("Error updating service:", error); + } + }; + + const getStatusIcon = (status: string) => { + switch (status) { + case 'pending': + return ; + case 'approved': + return ; + case 'denied': + return ; + default: + return ; + } + }; + + const getStatusColor = (status: string) => { + switch (status) { + case 'pending': + return 'text-yellow-600 bg-yellow-50 border-yellow-200'; + case 'approved': + return 'text-green-600 bg-green-50 border-green-200'; + case 'denied': + return 'text-red-600 bg-red-50 border-red-200'; + default: + return 'text-gray-600 bg-gray-50 border-gray-200'; + } + }; + + if (!mounted || isPending) { + return ( +
+
+ ); + } + + if (!session) { + return ( +
+
+ ); + } + + if ((session.user as ExtendedUser).role !== 'admin') { + return ( +
+
+ ); + } + + if (!accessGranted) { + return ( +
+
+ ); + } + + const pendingRequestsCount = requests.filter(r => r.status === 'pending').length; + const totalUsersCount = users.length; + const adminUsersCount = users.filter(u => u.role === 'admin').length; + + return ( +
+