adds user accounts, service requests, dashboard, admin panel, better layout, db+altcha+auth support

This commit is contained in:
Aidan 2025-07-07 20:01:59 -04:00
parent dfbc3cade9
commit 0043a5bf3c
40 changed files with 3981 additions and 188 deletions

View file

@ -5,3 +5,4 @@ examples/
node_modules
.next
.git
postgres/

3
.env.example Normal file
View file

@ -0,0 +1,3 @@
BETTER_AUTH_SECRET=
BETTER_AUTH_URL=http://localhost:3000
DATABASE_URL=postgres://<user>:<password>@<host>:<port>/<database>

4
.gitignore vendored
View file

@ -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/

185
README.md
View file

@ -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://<user>:<password>@<host>:<port>/<database>`
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://<user>:<password>@<host>:<port>/<database>`
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`

884
app/admin/page.tsx Normal file
View file

@ -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<User[]>([]);
const [requests, setRequests] = useState<ServiceRequest[]>([]);
const [services, setServices] = useState<Service[]>([]);
const [activityData, setActivityData] = useState<ActivityData | null>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<'overview' | 'users' | 'requests' | 'services'>('overview');
const [editingRequest, setEditingRequest] = useState<string | null>(null);
const [requestStatus, setRequestStatus] = useState<'pending' | 'approved' | 'denied'>('pending');
const [adminNotes, setAdminNotes] = useState("");
const [selectedService, setSelectedService] = useState<string>("");
const [selectedUser, setSelectedUser] = useState<string>("");
const [editingService, setEditingService] = useState<string | null>(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 <TbClock className="w-4 h-4 text-yellow-500" />;
case 'approved':
return <TbCheck className="w-4 h-4 text-green-500" />;
case 'denied':
return <TbX className="w-4 h-4 text-red-500" />;
default:
return <TbClock className="w-4 h-4 text-gray-500" />;
}
};
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 (
<main>
<Nav />
<div className="flex flex-col items-center justify-center min-h-[50vh] px-4">
<div className="animate-pulse text-lg">loading...</div>
</div>
</main>
);
}
if (!session) {
return (
<main>
<Nav />
<div className="flex flex-col items-center justify-center min-h-[50vh] px-4">
<div className="text-lg">redirecting to login...</div>
</div>
</main>
);
}
if ((session.user as ExtendedUser).role !== 'admin') {
return (
<main>
<Nav />
<div className="flex flex-col items-center justify-center min-h-[50vh] px-4">
<div className="text-lg text-red-600">Access denied: Admin privileges required</div>
</div>
</main>
);
}
if (!accessGranted) {
return (
<main>
<Nav />
<div className="max-w-2xl mx-auto px-4 py-8">
<div className="bg-white dark:bg-gray-800 p-8 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="flex flex-col items-center text-center">
<TbShield size={48} className="text-red-500 mb-4" />
<h1 className="text-2xl font-bold mb-4">One More Step</h1>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Please complete the CAPTCHA to access your dashboard.
</p>
<div className="w-full max-w-md">
<Altcha onStateChange={(ev) => {
if ('detail' in ev) {
handleCaptchaVerification(ev.detail.payload || "");
}
}} />
</div>
</div>
</div>
</div>
</main>
);
}
const pendingRequestsCount = requests.filter(r => r.status === 'pending').length;
const totalUsersCount = users.length;
const adminUsersCount = users.filter(u => u.role === 'admin').length;
return (
<main>
<Nav />
<div className="max-w-6xl mx-auto px-4 py-8">
<div className="flex flex-row items-center justify-start gap-3 mb-8">
<TbShield size={32} className="text-red-500" />
<h1 className="text-3xl sm:text-4xl font-bold">Admin</h1>
</div>
<div className="flex space-x-1 mb-8 bg-gray-100 dark:bg-gray-700 p-1 rounded-lg">
{[
{ id: 'overview', label: 'Overview', icon: TbChart },
{ id: 'users', label: 'Users', icon: TbUsers },
{ id: 'requests', label: 'Requests', icon: TbSend },
{ id: 'services', label: 'Services', icon: TbSettings }
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as 'overview' | 'users' | 'requests' | 'services')}
className={`flex items-center gap-2 px-4 py-2 rounded-md transition-colors ${
activeTab === tab.id
? 'bg-white dark:bg-gray-800 text-blue-600 shadow-sm'
: 'text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white'
}`}
>
<tab.icon className="w-4 h-4" />
{tab.label}
</button>
))}
</div>
{loading && (
<div className="text-center py-8">
<div className="animate-pulse">loading data...</div>
</div>
)}
{!loading && activeTab === 'overview' && (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Total Users</p>
<p className="text-2xl font-bold">{totalUsersCount}</p>
</div>
<TbUsers className="w-8 h-8 text-blue-500" />
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Admin Users</p>
<p className="text-2xl font-bold">{adminUsersCount}</p>
</div>
<TbShield className="w-8 h-8 text-red-500" />
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Pending Requests</p>
<p className="text-2xl font-bold">{pendingRequestsCount}</p>
</div>
<TbClock className="w-8 h-8 text-yellow-500" />
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Total Services</p>
<p className="text-2xl font-bold">{services.length}</p>
</div>
<TbSettings className="w-8 h-8 text-purple-500" />
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<TbTrendingUp className="w-5 h-5" />
Popular Services
</h3>
<div className="space-y-3">
{activityData?.servicePopularity?.slice(0, 5).map((service, index) => (
<div key={service.serviceName} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-gray-500">#{index + 1}</span>
<span className="font-medium">{service.serviceName}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">{service.requestCount} requests</span>
<div className={`w-3 h-3 rounded-full ${
service.approvedCount > service.requestCount / 2 ? 'bg-green-500' : 'bg-yellow-500'
}`} />
</div>
</div>
))}
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<TbCalendar className="w-5 h-5" />
Recent Activity
</h3>
<div className="space-y-3 max-h-64 overflow-y-auto">
{activityData?.recentActivity?.slice(0, 10).map((activity) => (
<div key={activity.id} className="flex items-start gap-3 pb-3 border-b border-gray-100 dark:border-gray-700 last:border-b-0">
<div className={`w-2 h-2 rounded-full mt-2 ${
activity.status === 'approved' ? 'bg-green-500' :
activity.status === 'denied' ? 'bg-red-500' : 'bg-yellow-500'
}`} />
<div className="flex-1">
<p className="text-sm">{activity.description}</p>
<p className="text-xs text-gray-500">
{new Date(activity.createdAt).toLocaleDateString()}
</p>
</div>
</div>
))}
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold mb-4">Last 7 Days Overview</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="text-center">
<p className="text-2xl font-bold text-blue-600">{activityData?.totals?.totalRequests || 0}</p>
<p className="text-sm text-gray-600">New Requests</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-green-600">{activityData?.totals?.totalUsers || 0}</p>
<p className="text-sm text-gray-600">New Users</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-purple-600">{activityData?.totals?.totalAccess || 0}</p>
<p className="text-sm text-gray-600">Access Granted</p>
</div>
</div>
</div>
</div>
)}
{!loading && activeTab === 'services' && (
<div className="space-y-6">
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold mb-4">Grant Service Access</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<select
value={selectedUser}
onChange={(e) => setSelectedUser(e.target.value)}
className="p-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700"
>
<option value="">Select User</option>
{users.map((user) => (
<option key={user.id} value={user.id}>{user.name} ({user.email})</option>
))}
</select>
<select
value={selectedService}
onChange={(e) => setSelectedService(e.target.value)}
className="p-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700"
>
<option value="">Select Service</option>
{services.map((service) => (
<option key={service.id} value={service.id}>{service.name}</option>
))}
</select>
<button
onClick={() => {
if (selectedUser && selectedService) {
grantServiceAccess(selectedUser, selectedService);
setSelectedUser("");
setSelectedService("");
}
}}
disabled={!selectedUser || !selectedService}
className="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 disabled:bg-gray-400 transition-colors"
>
Grant Access
</button>
</div>
</div>
<div className="space-y-4">
{services.map((service) => (
<div key={service.id} className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-semibold">{service.name}</h3>
<p className="text-gray-600 dark:text-gray-400">{service.description}</p>
<div className="flex items-center gap-4 mt-2">
<span className={`px-2 py-1 rounded-full text-xs ${
service.enabled ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{service.enabled ? 'Enabled' : 'Disabled'}
</span>
<span className="text-sm text-gray-500">
{service.priceStatus}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<div className="text-right">
<p className="text-sm text-gray-600">{service.users.length} users</p>
<p className="text-xs text-gray-500">with access</p>
</div>
<button
onClick={() => {
setEditingService(service.id);
setServiceSettings({
enabled: service.enabled,
priceStatus: service.priceStatus as "open" | "invite-only" | "by-request",
description: service.description,
joinLink: service.joinLink || ""
});
}}
className="text-blue-600 hover:text-blue-800 text-sm flex items-center gap-1"
>
<TbEdit className="w-4 h-4" />
Edit
</button>
</div>
</div>
{editingService === service.id ? (
<div className="space-y-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg mb-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Status</label>
<select
value={serviceSettings.enabled ? 'enabled' : 'disabled'}
onChange={(e) => setServiceSettings(prev => ({ ...prev, enabled: e.target.value === 'enabled' }))}
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700"
>
<option value="enabled">Enabled</option>
<option value="disabled">Disabled</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Access Type</label>
<select
value={serviceSettings.priceStatus}
onChange={(e) => setServiceSettings(prev => ({ ...prev, priceStatus: e.target.value as "open" | "invite-only" | "by-request" }))}
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700"
>
<option value="open">Open</option>
<option value="invite-only">Invite Only</option>
<option value="by-request">By Request</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">Description</label>
<textarea
value={serviceSettings.description}
onChange={(e) => setServiceSettings(prev => ({ ...prev, description: e.target.value }))}
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700"
rows={2}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Join Link</label>
<input
type="url"
value={serviceSettings.joinLink}
onChange={(e) => setServiceSettings(prev => ({ ...prev, joinLink: e.target.value }))}
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700"
placeholder="https://..."
/>
</div>
<div className="flex gap-2">
<button
onClick={() => updateService(service.id)}
className="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 transition-colors"
>
Save Changes
</button>
<button
onClick={() => setEditingService(null)}
className="bg-gray-500 text-white px-4 py-2 rounded-md hover:bg-gray-600 transition-colors"
>
Cancel
</button>
</div>
</div>
) : null}
{service.users.length > 0 && (
<div>
<h4 className="font-medium mb-3 flex items-center gap-2">
<TbEye className="w-4 h-4" />
Users with Access
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{service.users.map((user) => (
<div key={user.userId} className="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div>
<p className="font-medium text-sm">{user.userName}</p>
<p className="text-xs text-gray-500">{user.userEmail}</p>
</div>
<button
onClick={() => revokeServiceAccess(user.userId, service.id)}
className="text-red-600 hover:text-red-800 text-xs flex items-center gap-1"
>
<TbUserMinus className="w-3 h-3" />
Revoke
</button>
</div>
))}
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
{!loading && activeTab === 'users' && (
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="overflow-x-auto -mt-2">
<table className="min-w-full">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-600">
<th className="text-left py-3 px-4 font-medium">Name</th>
<th className="text-left py-3 px-4 font-medium">Email</th>
<th className="text-left py-3 px-4 font-medium">Role</th>
<th className="text-left py-3 px-4 font-medium">Verified</th>
<th className="text-left py-3 px-4 font-medium">Joined</th>
<th className="text-left py-3 px-4 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-b border-gray-100 dark:border-gray-700">
<td className="py-3 px-4">{user.name}</td>
<td className="py-3 px-4">{user.email}</td>
<td className="py-3 px-4">
<span className={`px-2 py-1 rounded-full text-xs ${
user.role === 'admin'
? 'bg-red-100 text-red-700'
: 'bg-blue-100 text-blue-700'
}`}>
{user.role}
</span>
</td>
<td className="py-3 px-4">
<span className={`px-2 py-1 rounded-full text-xs ${
user.emailVerified
? 'bg-green-100 text-green-700'
: 'bg-yellow-100 text-yellow-700'
}`}>
{user.emailVerified ? 'Yes' : 'No'}
</span>
</td>
<td className="py-3 px-4">
{new Date(user.createdAt).toLocaleDateString()}
</td>
<td className="py-3 px-4">
<select
value={user.role}
onChange={(e) => updateUserRole(user.id, e.target.value as 'user' | 'admin')}
className="text-sm border border-gray-300 dark:border-gray-600 rounded px-2 py-1 dark:bg-gray-700"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{!loading && activeTab === 'requests' && (
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<h2 className="text-xl font-semibold mb-4">Service Requests</h2>
<div className="space-y-4">
{requests.map((request) => (
<div key={request.id} className="border border-gray-200 dark:border-gray-600 rounded-lg p-4">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="font-semibold">{request.userName}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">{request.userEmail}</p>
<p className="text-sm mt-1">
Requesting access to <strong>{request.serviceName}</strong>
</p>
</div>
<div className={`flex items-center gap-2 px-3 py-1 rounded-full border ${getStatusColor(request.status)}`}>
{getStatusIcon(request.status)}
<span className="text-sm font-medium capitalize">{request.status}</span>
</div>
</div>
<div className="mb-3">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-1">Reason:</p>
<p className="text-sm">{request.reason}</p>
</div>
{request.adminNotes && (
<div className="mb-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-1 flex items-center gap-1">
<TbNotes className="w-4 h-4" />
Admin Notes:
</p>
<p className="text-sm">{request.adminNotes}</p>
</div>
)}
{editingRequest === request.id ? (
<div className="space-y-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<div>
<label className="block text-sm font-medium mb-1">Status</label>
<select
value={requestStatus}
onChange={(e) => setRequestStatus(e.target.value as 'pending' | 'approved' | 'denied')}
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700"
>
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="denied">Denied</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Admin Notes</label>
<textarea
value={adminNotes}
onChange={(e) => setAdminNotes(e.target.value)}
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700"
rows={3}
placeholder="Add notes for the user..."
/>
</div>
<div className="flex gap-2">
<button
onClick={() => updateRequestStatus(request.id)}
className="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 transition-colors"
>
Update
</button>
<button
onClick={() => setEditingRequest(null)}
className="bg-gray-500 text-white px-4 py-2 rounded-md hover:bg-gray-600 transition-colors"
>
Cancel
</button>
</div>
</div>
) : (
<div className="flex items-center justify-between">
<div className="text-xs text-gray-500">
Submitted: {new Date(request.createdAt).toLocaleDateString()}
{request.reviewedAt && (
<span className="ml-4">
Reviewed: {new Date(request.reviewedAt).toLocaleDateString()}
</span>
)}
</div>
<button
onClick={() => {
setEditingRequest(request.id);
setRequestStatus(request.status);
setAdminNotes(request.adminNotes || "");
}}
className="flex items-center gap-1 text-blue-600 hover:text-blue-800 text-sm"
>
<TbEdit className="w-4 h-4" />
Edit
</button>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
</main>
);
}

View file

@ -0,0 +1,103 @@
import { db } from "@/db";
import { serviceRequests, user, userServices, services } from "@/db/schema";
import { auth } from "@/util/auth";
import { eq, gte, desc, sql } from "drizzle-orm";
import { NextRequest } from "next/server";
export async function GET(request: NextRequest) {
try {
const session = await auth.api.getSession({
headers: request.headers
});
if (!session || session.user.role !== 'admin') {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const url = new URL(request.url);
const period = url.searchParams.get('period') || '7';
const daysAgo = parseInt(period);
const startDate = new Date();
startDate.setDate(startDate.getDate() - daysAgo);
const requestActivity = await db.select({
date: sql<string>`DATE(${serviceRequests.createdAt})`,
count: sql<number>`COUNT(*)`,
status: serviceRequests.status
})
.from(serviceRequests)
.where(gte(serviceRequests.createdAt, startDate))
.groupBy(sql`DATE(${serviceRequests.createdAt})`, serviceRequests.status)
.orderBy(sql`DATE(${serviceRequests.createdAt})`);
const userActivity = await db.select({
date: sql<string>`DATE(${user.createdAt})`,
count: sql<number>`COUNT(*)`
})
.from(user)
.where(gte(user.createdAt, startDate))
.groupBy(sql`DATE(${user.createdAt})`)
.orderBy(sql`DATE(${user.createdAt})`);
const accessActivity = await db.select({
date: sql<string>`DATE(${userServices.grantedAt})`,
count: sql<number>`COUNT(*)`
})
.from(userServices)
.where(gte(userServices.grantedAt, startDate))
.groupBy(sql`DATE(${userServices.grantedAt})`)
.orderBy(sql`DATE(${userServices.grantedAt})`);
const recentActivity = await db.select({
id: serviceRequests.id,
type: sql<string>`'request'`,
description: sql<string>`CONCAT(${user.name}, ' requested access to ', ${services.name})`,
status: serviceRequests.status,
createdAt: serviceRequests.createdAt,
userName: user.name,
serviceName: services.name
})
.from(serviceRequests)
.innerJoin(user, eq(serviceRequests.userId, user.id))
.innerJoin(services, eq(serviceRequests.serviceId, services.id))
.where(gte(serviceRequests.createdAt, startDate))
.orderBy(desc(serviceRequests.createdAt))
.limit(20);
const servicePopularity = await db.select({
serviceName: services.name,
requestCount: sql<number>`COUNT(${serviceRequests.id})`,
approvedCount: sql<number>`COUNT(CASE WHEN ${serviceRequests.status} = 'approved' THEN 1 END)`
})
.from(services)
.leftJoin(serviceRequests, eq(services.id, serviceRequests.serviceId))
.where(gte(serviceRequests.createdAt, startDate))
.groupBy(services.id, services.name)
.orderBy(sql`COUNT(${serviceRequests.id}) DESC`)
.limit(10);
const totals = await db.select({
totalRequests: sql<number>`COUNT(DISTINCT ${serviceRequests.id})`,
totalUsers: sql<number>`COUNT(DISTINCT ${user.id})`,
totalAccess: sql<number>`COUNT(DISTINCT ${userServices.id})`
})
.from(serviceRequests)
.fullJoin(user, gte(user.createdAt, startDate))
.fullJoin(userServices, gte(userServices.grantedAt, startDate))
.where(gte(serviceRequests.createdAt, startDate));
return Response.json({
requestActivity,
userActivity,
accessActivity,
recentActivity,
servicePopularity,
totals: totals[0] || { totalRequests: 0, totalUsers: 0, totalAccess: 0 },
period: daysAgo
});
} catch (error) {
console.error("Error fetching activity data:", error);
return Response.json({ error: "Internal server error" }, { status: 500 });
}
}

View file

@ -0,0 +1,121 @@
import { db } from "@/db";
import { serviceRequests, services, user, userServices } from "@/db/schema";
import { auth } from "@/util/auth";
import { eq, and } from "drizzle-orm";
import { NextRequest } from "next/server";
import { nanoid } from "nanoid";
export async function GET(request: NextRequest) {
try {
const session = await auth.api.getSession({
headers: request.headers
});
if (!session || session.user.role !== 'admin') {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const allRequests = await db.select({
id: serviceRequests.id,
reason: serviceRequests.reason,
status: serviceRequests.status,
adminNotes: serviceRequests.adminNotes,
reviewedAt: serviceRequests.reviewedAt,
createdAt: serviceRequests.createdAt,
updatedAt: serviceRequests.updatedAt,
userId: serviceRequests.userId,
userName: user.name,
userEmail: user.email,
serviceName: services.name,
serviceDescription: services.description
})
.from(serviceRequests)
.innerJoin(services, eq(serviceRequests.serviceId, services.id))
.innerJoin(user, eq(serviceRequests.userId, user.id))
.orderBy(serviceRequests.createdAt);
return Response.json({ requests: allRequests });
} catch (error) {
console.error("Error fetching admin requests:", error);
return Response.json({ error: "Internal server error" }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
try {
const session = await auth.api.getSession({
headers: request.headers
});
if (!session || session.user.role !== 'admin') {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const { requestId, status, adminNotes } = await request.json();
if (!requestId || !status) {
return Response.json({ error: "Request ID and status are required" }, { status: 400 });
}
if (!['pending', 'approved', 'denied'].includes(status)) {
return Response.json({ error: "Invalid status" }, { status: 400 });
}
const serviceRequest = await db.select({
userId: serviceRequests.userId,
serviceId: serviceRequests.serviceId,
currentStatus: serviceRequests.status
})
.from(serviceRequests)
.where(eq(serviceRequests.id, requestId))
.limit(1);
if (serviceRequest.length === 0) {
return Response.json({ error: "Request not found" }, { status: 404 });
}
await db.update(serviceRequests)
.set({
status,
adminNotes,
reviewedBy: session.user.id,
reviewedAt: new Date(),
updatedAt: new Date()
})
.where(eq(serviceRequests.id, requestId));
if (status === 'approved' && serviceRequest[0].currentStatus !== 'approved') {
const existingAccess = await db.select()
.from(userServices)
.where(and(
eq(userServices.userId, serviceRequest[0].userId),
eq(userServices.serviceId, serviceRequest[0].serviceId)
))
.limit(1);
if (existingAccess.length === 0) {
await db.insert(userServices).values({
id: nanoid(),
userId: serviceRequest[0].userId,
serviceId: serviceRequest[0].serviceId,
grantedBy: session.user.id,
grantedAt: new Date(),
createdAt: new Date()
});
}
}
if (status === 'denied' && serviceRequest[0].currentStatus === 'approved') {
await db.delete(userServices)
.where(and(
eq(userServices.userId, serviceRequest[0].userId),
eq(userServices.serviceId, serviceRequest[0].serviceId)
));
}
return Response.json({ success: true });
} catch (error) {
console.error("Error updating request:", error);
return Response.json({ error: "Internal server error" }, { status: 500 });
}
}

View file

@ -0,0 +1,165 @@
import { db } from "@/db";
import { services, userServices, user } from "@/db/schema";
import { auth } from "@/util/auth";
import { eq, and } from "drizzle-orm";
import { NextRequest } from "next/server";
import { nanoid } from "nanoid";
export async function GET(request: NextRequest) {
try {
const session = await auth.api.getSession({
headers: request.headers
});
if (!session || session.user.role !== 'admin') {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const allServices = await db.select({
id: services.id,
name: services.name,
description: services.description,
priceStatus: services.priceStatus,
joinLink: services.joinLink,
enabled: services.enabled,
createdAt: services.createdAt,
updatedAt: services.updatedAt
})
.from(services)
.orderBy(services.name);
const serviceAssignments = await db.select({
serviceId: userServices.serviceId,
userId: userServices.userId,
userName: user.name,
userEmail: user.email,
grantedAt: userServices.grantedAt
})
.from(userServices)
.innerJoin(user, eq(userServices.userId, user.id))
.orderBy(user.name);
const servicesWithUsers = allServices.map(service => ({
...service,
users: serviceAssignments.filter(assignment => assignment.serviceId === service.id)
}));
return Response.json({ services: servicesWithUsers });
} catch (error) {
console.error("Error fetching services:", error);
return Response.json({ error: "Internal server error" }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const session = await auth.api.getSession({
headers: request.headers
});
if (!session || session.user.role !== 'admin') {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const { action, userId, serviceId } = await request.json();
if (!action || !userId || !serviceId) {
return Response.json({ error: "Action, user ID, and service ID are required" }, { status: 400 });
}
if (action === 'grant') {
const existingAccess = await db.select()
.from(userServices)
.where(and(
eq(userServices.userId, userId),
eq(userServices.serviceId, serviceId)
))
.limit(1);
if (existingAccess.length > 0) {
return Response.json({ error: "User already has access to this service" }, { status: 400 });
}
await db.insert(userServices).values({
id: nanoid(),
userId,
serviceId,
grantedBy: session.user.id,
grantedAt: new Date(),
createdAt: new Date()
});
return Response.json({ success: true, message: "Access granted" });
} else if (action === 'revoke') {
await db.delete(userServices)
.where(and(
eq(userServices.userId, userId),
eq(userServices.serviceId, serviceId)
));
return Response.json({ success: true, message: "Access revoked" });
} else {
return Response.json({ error: "Invalid action. Use 'grant' or 'revoke'" }, { status: 400 });
}
} catch (error) {
console.error("Error managing service access:", error);
return Response.json({ error: "Internal server error" }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
try {
const session = await auth.api.getSession({
headers: request.headers
});
if (!session || session.user.role !== 'admin') {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const { serviceId, enabled, priceStatus, description, joinLink } = await request.json();
if (!serviceId) {
return Response.json({ error: "Service ID is required" }, { status: 400 });
}
const updates: {
updatedAt: Date;
enabled?: boolean;
priceStatus?: string;
description?: string;
joinLink?: string | null;
} = {
updatedAt: new Date()
};
if (typeof enabled === 'boolean') {
updates.enabled = enabled;
}
if (priceStatus && ['open', 'invite-only', 'by-request'].includes(priceStatus)) {
updates.priceStatus = priceStatus;
}
if (description !== undefined) {
updates.description = description;
}
if (joinLink !== undefined) {
updates.joinLink = joinLink || null;
}
await db.update(services)
.set(updates)
.where(eq(services.id, serviceId));
return Response.json({ success: true, message: "Service updated successfully" });
} catch (error) {
console.error("Error updating service:", error);
return Response.json({ error: "Internal server error" }, { status: 500 });
}
}

View file

@ -0,0 +1,68 @@
import { db } from "@/db";
import { user } from "@/db/schema";
import { auth } from "@/util/auth";
import { eq } from "drizzle-orm";
import { NextRequest } from "next/server";
export async function GET(request: NextRequest) {
try {
const session = await auth.api.getSession({
headers: request.headers
});
if (!session || session.user.role !== 'admin') {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const allUsers = await db.select({
id: user.id,
name: user.name,
email: user.email,
emailVerified: user.emailVerified,
role: user.role,
createdAt: user.createdAt,
updatedAt: user.updatedAt
})
.from(user)
.orderBy(user.createdAt);
return Response.json({ users: allUsers });
} catch (error) {
console.error("Error fetching users:", error);
return Response.json({ error: "Internal server error" }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
try {
const session = await auth.api.getSession({
headers: request.headers
});
if (!session || session.user.role !== 'admin') {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const { userId, role } = await request.json();
if (!userId || !role) {
return Response.json({ error: "User ID and role are required" }, { status: 400 });
}
if (!['user', 'admin'].includes(role)) {
return Response.json({ error: "Invalid role" }, { status: 400 });
}
await db.update(user)
.set({
role,
updatedAt: new Date()
})
.where(eq(user.id, userId));
return Response.json({ success: true });
} catch (error) {
console.error("Error updating user:", error);
return Response.json({ error: "Internal server error" }, { status: 500 });
}
}

View file

@ -0,0 +1,4 @@
import { auth } from "@/util/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { POST, GET } = toNextJsHandler(auth);

22
app/api/captcha/route.ts Normal file
View file

@ -0,0 +1,22 @@
import { createChallenge } from "altcha-lib";
import { NextResponse } from "next/server";
const hmacKey = process.env.ALTCHA_SECRET;
async function getChallenge() {
if (!hmacKey) {
console.error("ALTCHA_SECRET is not set")
return NextResponse.json({ error: "Internal server error" }, { status: 500 })
}
const challenge = await createChallenge({
hmacKey,
maxNumber: 1400000,
})
return NextResponse.json(challenge)
}
export async function GET() {
return getChallenge()
}

71
app/api/login/route.ts Normal file
View file

@ -0,0 +1,71 @@
import { auth } from "@/util/auth";
import { verifyCaptcha } from "@/util/captcha";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { email, password, token } = body;
if (!email || !password || !token) {
return NextResponse.json(
{ error: "Missing required fields" },
{ status: 400 }
);
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return NextResponse.json(
{ error: "Invalid email format" },
{ status: 400 }
);
}
const isCaptchaValid = await verifyCaptcha(token);
if (!isCaptchaValid) {
return NextResponse.json(
{ error: "Invalid captcha" },
{ status: 400 }
);
}
const signInResponse = await auth.api.signInEmail({
body: {
email,
password,
},
});
if (!signInResponse) {
return NextResponse.json(
{ error: "Failed to sign in" },
{ status: 500 }
);
}
if ('error' in signInResponse) {
const errorMessage = signInResponse.error && typeof signInResponse.error === 'object' && 'message' in signInResponse.error
? String(signInResponse.error.message)
: "Invalid credentials";
return NextResponse.json(
{ error: errorMessage },
{ status: 401 }
);
}
return NextResponse.json({
success: true,
message: "Signed in successfully",
user: signInResponse.user,
});
} catch (error: unknown) {
console.error("Login error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

23
app/api/logout/route.ts Normal file
View file

@ -0,0 +1,23 @@
import { auth } from "@/util/auth";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
try {
await auth.api.signOut({
headers: request.headers,
});
return NextResponse.json({
success: true,
message: "Signed out successfully",
});
} catch (error: unknown) {
console.error("Logout error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,110 @@
import { db } from "@/db";
import { serviceRequests, services, userServices } from "@/db/schema";
import { auth } from "@/util/auth";
import { verifyCaptcha } from "@/util/captcha";
import { eq, and } from "drizzle-orm";
import { NextRequest } from "next/server";
import { nanoid } from "nanoid";
export async function POST(request: NextRequest) {
try {
const session = await auth.api.getSession({
headers: request.headers
});
if (!session) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const { serviceId, reason, captchaToken } = await request.json();
if (!serviceId || !reason) {
return Response.json({ error: "Service ID and reason are required" }, { status: 400 });
}
const isValidCaptcha = await verifyCaptcha(captchaToken);
if (!isValidCaptcha) {
return Response.json({ error: "Invalid captcha" }, { status: 400 });
}
const service = await db.select().from(services).where(eq(services.name, serviceId)).limit(1);
if (service.length === 0) {
return Response.json({ error: "Service not found" }, { status: 404 });
}
if (!service[0].enabled) {
return Response.json({ error: "This service is currently unavailable" }, { status: 400 });
}
const existingAccess = await db.select()
.from(userServices)
.where(and(
eq(userServices.userId, session.user.id),
eq(userServices.serviceId, service[0].id)
))
.limit(1);
if (existingAccess.length > 0) {
return Response.json({ error: "You already have access to this service" }, { status: 400 });
}
const existingRequest = await db.select()
.from(serviceRequests)
.where(and(
eq(serviceRequests.userId, session.user.id),
eq(serviceRequests.serviceId, service[0].id),
eq(serviceRequests.status, 'pending')
))
.limit(1);
if (existingRequest.length > 0) {
return Response.json({ error: "You already have a pending request for this service" }, { status: 400 });
}
const requestId = nanoid();
await db.insert(serviceRequests).values({
id: requestId,
userId: session.user.id,
serviceId: service[0].id,
reason,
status: 'pending'
});
return Response.json({ success: true, requestId });
} catch (error) {
console.error("Error creating service request:", error);
return Response.json({ error: "Internal server error" }, { status: 500 });
}
}
export async function GET(request: NextRequest) {
try {
const session = await auth.api.getSession({
headers: request.headers
});
if (!session) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const userRequests = await db.select({
id: serviceRequests.id,
reason: serviceRequests.reason,
status: serviceRequests.status,
adminNotes: serviceRequests.adminNotes,
reviewedAt: serviceRequests.reviewedAt,
createdAt: serviceRequests.createdAt,
serviceName: services.name,
serviceDescription: services.description
})
.from(serviceRequests)
.innerJoin(services, eq(serviceRequests.serviceId, services.id))
.where(eq(serviceRequests.userId, session.user.id))
.orderBy(serviceRequests.createdAt);
return Response.json({ requests: userRequests });
} catch (error) {
console.error("Error fetching service requests:", error);
return Response.json({ error: "Internal server error" }, { status: 500 });
}
}

24
app/api/services/route.ts Normal file
View file

@ -0,0 +1,24 @@
import { db } from "@/db";
import { services } from "@/db/schema";
import { eq } from "drizzle-orm";
export async function GET() {
try {
const publicServices = await db.select({
id: services.id,
name: services.name,
description: services.description,
priceStatus: services.priceStatus,
joinLink: services.joinLink,
enabled: services.enabled
})
.from(services)
.where(eq(services.enabled, true))
.orderBy(services.name);
return Response.json({ services: publicServices });
} catch (error) {
console.error("Error fetching public services:", error);
return Response.json({ error: "Internal server error" }, { status: 500 });
}
}

102
app/api/signup/route.ts Normal file
View file

@ -0,0 +1,102 @@
import { auth } from "@/util/auth";
import { verifyCaptcha } from "@/util/captcha";
import { db } from "@/db";
import { user } from "@/db/schema";
import { sql } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { email, password, confirmPassword, token, name } = body;
if (!email || !password || !confirmPassword || !token) {
return NextResponse.json(
{ error: "Missing required fields" },
{ status: 400 }
);
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return NextResponse.json(
{ error: "Invalid email format" },
{ status: 400 }
);
}
if (password.length < 8) {
return NextResponse.json(
{ error: "Password must be at least 8 characters long" },
{ status: 400 }
);
}
if (password !== confirmPassword) {
return NextResponse.json(
{ error: "Passwords do not match" },
{ status: 400 }
);
}
const isCaptchaValid = await verifyCaptcha(token);
if (!isCaptchaValid) {
return NextResponse.json(
{ error: "Invalid captcha" },
{ status: 400 }
);
}
const userCount = await db.select({ count: sql<number>`count(*)` }).from(user);
const isFirstUser = userCount[0]?.count === 0;
const signUpResponse = await auth.api.signUpEmail({
body: {
email,
password,
name: name || email.split('@')[0],
role: isFirstUser ? 'admin' : 'user',
},
});
if (!signUpResponse) {
return NextResponse.json(
{ error: "Failed to create user" },
{ status: 500 }
);
}
if ('error' in signUpResponse) {
const errorMessage = signUpResponse.error && typeof signUpResponse.error === 'object' && 'message' in signUpResponse.error
? String(signUpResponse.error.message)
: "Failed to create user";
return NextResponse.json(
{ error: errorMessage },
{ status: 400 }
);
}
return NextResponse.json({
success: true,
message: "User created successfully",
user: signUpResponse.user,
isFirstUser,
});
} catch (error: unknown) {
console.error("Signup error:", error);
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes('duplicate key') || errorMessage.includes('already exists')) {
return NextResponse.json(
{ error: "An account with this email already exists" },
{ status: 409 }
);
}
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,52 @@
import { db } from "@/db";
import { userServices, services } from "@/db/schema";
import { auth } from "@/util/auth";
import { eq, sql } from "drizzle-orm";
import { NextRequest } from "next/server";
export async function GET(request: NextRequest) {
try {
const session = await auth.api.getSession({
headers: request.headers
});
if (!session) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const grantedServices = await db.select({
serviceId: services.id,
serviceName: services.name,
serviceDescription: services.description,
priceStatus: services.priceStatus,
joinLink: services.joinLink,
grantedAt: userServices.grantedAt,
isOpen: sql<boolean>`false`
})
.from(userServices)
.innerJoin(services, eq(userServices.serviceId, services.id))
.where(eq(userServices.userId, session.user.id));
const openServices = await db.select({
serviceId: services.id,
serviceName: services.name,
serviceDescription: services.description,
priceStatus: services.priceStatus,
joinLink: services.joinLink,
grantedAt: sql<Date | null>`null`,
isOpen: sql<boolean>`true`
})
.from(services)
.where(eq(services.priceStatus, "open"));
const grantedServiceIds = new Set(grantedServices.map(s => s.serviceId));
const uniqueOpenServices = openServices.filter(s => !grantedServiceIds.has(s.serviceId));
const allAccessibleServices = [...grantedServices, ...uniqueOpenServices];
return Response.json({ services: allAccessibleServices });
} catch (error) {
console.error("Error fetching user services:", error);
return Response.json({ error: "Internal server error" }, { status: 500 });
}
}

266
app/dashboard/page.tsx Normal file
View file

@ -0,0 +1,266 @@
"use client"
import { Nav } from "@/components/core/nav";
import { authClient } from "@/util/auth-client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { SiForgejo, SiJellyfin, SiOllama } from "react-icons/si";
import {
TbDashboard,
TbUser,
TbMail,
TbCalendar,
TbShield,
TbDeviceTv,
TbExternalLink,
TbServer,
TbCheck,
TbReceipt,
} from "react-icons/tb";
export default function Dashboard() {
const router = useRouter();
const { data: session, isPending } = authClient.useSession();
const [mounted, setMounted] = useState(false);
const [userServices, setUserServices] = useState<string[]>([]);
const [openServices, setOpenServices] = useState<string[]>([]);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (mounted && !isPending && !session) {
router.push("/login?message=Please sign in to access the dashboard");
}
}, [session, isPending, mounted, router]);
useEffect(() => {
if (session) {
fetchUserServices();
}
}, [session]);
const fetchUserServices = async () => {
try {
const response = await fetch("/api/user-services");
if (response.ok) {
const data = await response.json();
const services = data.services;
setUserServices(services.map((s: { serviceName: string }) => s.serviceName));
setOpenServices(services.filter((s: { isOpen: boolean }) => s.isOpen).map((s: { serviceName: string }) => s.serviceName));
}
} catch (error) {
console.error("Error fetching services:", error);
}
};
if (!mounted || isPending) {
return (
<main>
<Nav />
<div className="flex flex-col items-center justify-center min-h-[50vh] px-4">
<div className="animate-pulse text-lg">loading dashboard...</div>
</div>
</main>
);
}
if (!session) {
return (
<main>
<Nav />
<div className="flex flex-col items-center justify-center min-h-[50vh] px-4">
<div className="text-lg">redirecting to login...</div>
</div>
</main>
);
}
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(date);
};
return (
<main>
<Nav />
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="flex flex-row items-center justify-start gap-3 mb-8">
<TbDashboard size={32} className="text-blue-500" />
<h1 className="text-3xl sm:text-4xl font-bold">
Dashboard
</h1>
</div>
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 p-6 rounded-2xl mb-8">
<h2 className="text-2xl font-semibold">
Welcome back, {session.user.name || 'User'}! 👋
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<ServiceCard
title="TV"
icon={<TbDeviceTv />}
link="https://tv.ihate.college"
signupType="request"
signupLink="https://t.me/p0ntus"
hasAccess={userServices.includes("tv")}
isOpen={openServices.includes("tv")}
/>
<ServiceCard
title="TV Requests"
icon={<SiJellyfin />}
link="https://requests.ihate.college"
signupType="request"
signupLink="https://t.me/p0ntus"
hasAccess={userServices.includes("tv")}
isOpen={openServices.includes("tv")}
/>
<ServiceCard
title="Git"
icon={<SiForgejo />}
link="https://git.p0ntus.com"
signupType="self"
signupLink="https://git.p0ntus.com/user/sign_up"
hasAccess={userServices.includes("git")}
isOpen={openServices.includes("git")}
/>
<ServiceCard
title="Mail"
icon={<TbMail />}
link="https://pontusmail.org"
signupType="self"
signupLink="https://pontusmail.org/admin/user/signup"
hasAccess={userServices.includes("mail")}
isOpen={openServices.includes("mail")}
/>
<ServiceCard
title="AI"
icon={<SiOllama />}
link="https://ai.ihate.college"
signupType="request"
signupLink="https://t.me/p0ntus"
hasAccess={userServices.includes("ai")}
isOpen={openServices.includes("ai")}
/>
<ServiceCard
title="Hosting"
icon={<TbServer />}
signupType="request"
signupLink="https://t.me/p0ntus"
hasAccess={userServices.includes("hosting")}
isOpen={openServices.includes("hosting")}
/>
</div>
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl justify-between shadow-sm border border-gray-200 dark:border-gray-700 flex flex-row items-center gap-3 mb-8">
<h3 className="text-xl font-semibold">Need access to a service?</h3>
<Link href="/requests" className="bg-blue-500 text-white px-4 py-2 rounded-lg hover:bg-blue-600 transition-colors flex items-center gap-2 cursor-pointer">
<TbReceipt className="w-4 h-4" />
Make a Request
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3 mb-4">
<TbUser className="text-blue-500 w-6 h-6" />
<h3 className="text-xl font-semibold">My Profile</h3>
</div>
<div className="space-y-3">
<div className="flex items-center gap-2">
<TbUser className="w-4 h-4 text-gray-400" />
<span className="text-sm text-gray-500">Name:</span>
<span className="font-medium">{session.user.name || 'Not provided'}</span>
</div>
<div className="flex items-center gap-2">
<TbMail className="w-4 h-4 text-gray-400" />
<span className="text-sm text-gray-500">Email:</span>
<span className="font-medium">{session.user.email}</span>
</div>
<div className="flex items-center gap-2">
<TbShield className="w-4 h-4 text-gray-400" />
<span className="text-sm text-gray-500">Email Verified:</span>
<span className={`font-medium ${session.user.emailVerified ? 'text-green-600' : 'text-orange-500'}`}>
{session.user.emailVerified ? 'Yes' : 'No'}
</span>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3 mb-4">
<TbCalendar className="text-green-500 w-6 h-6" />
<h3 className="text-xl font-semibold">Account Details</h3>
</div>
<div className="space-y-3">
<div className="flex items-center gap-2">
<TbCalendar className="w-4 h-4 text-gray-400" />
<span className="text-sm text-gray-500">Account Created:</span>
<span className="font-medium text-sm">{formatDate(session.user.createdAt)}</span>
</div>
<div className="flex items-center gap-2">
<TbCalendar className="w-4 h-4 text-gray-400" />
<span className="text-sm text-gray-500">Last Updated:</span>
<span className="font-medium text-sm">{formatDate(session.user.updatedAt)}</span>
</div>
</div>
</div>
</div>
</div>
</main>
);
}
function ServiceCard({ title, icon, link, signupType, signupLink, hasAccess, isOpen }: {
title: string,
icon: React.ReactNode,
link?: string,
signupType: "request" | "self" | "invite",
signupLink: string,
hasAccess: boolean,
isOpen: boolean
}) {
const cardClassName = hasAccess
? "bg-green-50 dark:bg-green-900/20 p-6 rounded-xl shadow-sm border border-green-200 dark:border-green-700"
: "bg-red-50 dark:bg-red-900/20 p-6 rounded-xl shadow-sm border border-red-200 dark:border-red-700";
return (
<div className={cardClassName}>
<div className="flex items-center justify-between gap-3 mb-4">
<div className="flex items-center gap-2 cursor-pointer" onClick={() => hasAccess && link && window.open(link, '_blank')}>
{icon}
<h3 className="text-xl font-semibold">{title}</h3>
</div>
{hasAccess && link && <div className="flex items-center gap-2">
<TbExternalLink className="text-blue-500 w-6 h-6 cursor-pointer" onClick={() => window.open(link, '_blank')} />
</div>}
</div>
{hasAccess ? (
<div className="space-y-2">
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
<TbCheck className="w-4 h-4" />
<span className="text-sm font-medium">Access granted</span>
</div>
{isOpen && (
<p className="text-sm text-gray-500">
Open service: <Link href={signupLink} className="text-blue-500 hover:underline">{signupType === "request" ? "Request account" : "Create account"}</Link>
</p>
)}
</div>
) : (
<p className="text-sm text-gray-500 mt-4">
Need an account? <Link href={signupLink} className="text-blue-500 hover:underline">{signupType === "request" ? "Request one!" : "Sign up!"}</Link>
</p>
)}
</div>
);
}

View file

@ -3,11 +3,15 @@
:root {
--background: #ffffff;
--foreground: #171717;
--foreground-muted: #909090;
--foreground-muted-light: #adaaaa;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-foreground-muted: var(--foreground-muted);
--color-foreground-muted-light: var(--foreground-muted-light);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@ -16,6 +20,8 @@
:root {
--background: #0a0a0a;
--foreground: #ededed;
--foreground-muted: #909090;
--foreground-muted-light: #b0b0b0;
}
}

181
app/login/page.tsx Normal file
View file

@ -0,0 +1,181 @@
"use client"
import Altcha from "@/components/core/altcha";
import { Nav } from "@/components/core/nav";
import { TbLogin } from "react-icons/tb";
import { useState, useEffect, Suspense } from "react";
import { useForm, SubmitHandler } from "react-hook-form";
import { useRouter, useSearchParams } from "next/navigation";
import { authClient } from "@/util/auth-client";
interface LoginForm {
email: string;
password: string;
}
function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const [altchaState, setAltchaState] = useState<{ status: "success" | "error" | "expired" | "waiting", token: string }>({ status: "waiting", token: "" });
const [isLoading, setIsLoading] = useState(false);
const [apiError, setApiError] = useState<string>("");
const [successMessage, setSuccessMessage] = useState<string>("");
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginForm>();
useEffect(() => {
const message = searchParams.get('message');
if (message) {
setSuccessMessage(message);
}
}, [searchParams]);
const onSubmit: SubmitHandler<LoginForm> = async (data) => {
if (altchaState.status !== "success") {
setApiError("Please complete the captcha");
return;
}
setIsLoading(true);
setApiError("");
try {
const response = await fetch("/api/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
...data,
token: altchaState.token,
}),
});
const result = await response.json();
if (!response.ok) {
setApiError(result.error || "Failed to sign in");
return;
}
try {
await authClient.signIn.email({
email: data.email,
password: data.password,
});
const redirectTo = searchParams.get('redirect') || "/";
router.push(redirectTo);
} catch (signInError) {
console.error("Auth client sign in failed:", signInError);
const redirectTo = searchParams.get('redirect') || "/";
router.push(redirectTo);
}
} catch (error) {
console.error("Login error:", error);
setApiError("An unexpected error occurred. Please try again.");
} finally {
setIsLoading(false);
}
};
const handleAltchaStateChange = (e: Event | CustomEvent) => {
if ('detail' in e && e.detail?.payload) {
setAltchaState({ status: "success", token: e.detail.payload });
} else {
setAltchaState({ status: "error", token: "" });
}
};
return (
<div className="flex flex-col items-center justify-center mt-18 sm:my-16 px-4 gap-18">
<div className="flex flex-row items-center justify-between gap-2">
<TbLogin size={32} className="sm:w-9 sm:h-9" />
<h1 className="text-3xl sm:text-4xl font-bold">
login
</h1>
</div>
<form className="flex flex-col bg-foreground/10 rounded-2xl sm:rounded-4xl p-4 gap-4 w-1/4 min-w-80" onSubmit={handleSubmit(onSubmit)}>
{successMessage && (
<div className="p-3 bg-green-100 border border-green-400 text-green-700 rounded-md">
{successMessage}
</div>
)}
<h2 className="text-2xl sm:text-3xl font-light text-center w-full flex flex-wrap">
email
</h2>
<input
type="email"
placeholder="enter your email"
className="w-full p-2 rounded-md bg-foreground/10"
{...register("email", {
required: "Email is required",
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: "Please enter a valid email address"
}
})}
/>
{errors.email && <p className="text-red-500 text-sm">{errors.email.message}</p>}
<h2 className="text-2xl sm:text-3xl font-light text-center w-full flex flex-wrap">
password
</h2>
<input
type="password"
placeholder="enter your password"
className="w-full p-2 rounded-md bg-foreground/10"
{...register("password", {
required: "Password is required",
minLength: {
value: 1,
message: "Password is required"
}
})}
/>
{errors.password && <p className="text-red-500 text-sm">{errors.password.message}</p>}
<h2 className="text-2xl sm:text-3xl font-light text-center w-full flex flex-wrap">
captcha
</h2>
<Altcha onStateChange={handleAltchaStateChange} />
{apiError && (
<div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded-md">
{apiError}
</div>
)}
<button
className="bg-blue-400 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
type="submit"
disabled={isLoading || altchaState.status !== "success" || !altchaState.token}
>
{isLoading ? "Signing in..." : altchaState.status === "success" ? "login" : "waiting for captcha"}
</button>
<p className="text-center text-sm text-gray-600 mt-4">
Don&apos;t have an account?{" "}
<a href="/signup" className="text-blue-400 hover:underline">
Sign up here
</a>
</p>
</form>
</div>
);
}
export default function Login() {
return (
<main>
<Nav />
<Suspense fallback={<div>Loading...</div>}>
<LoginForm />
</Suspense>
</main>
);
}

View file

@ -1,68 +1,103 @@
"use client"
import Link from "next/link";
import { SiForgejo, SiJellyfin, SiOllama } from "react-icons/si";
import { TbMail, TbKey, TbServer, TbArrowRight } from "react-icons/tb";
import { SiForgejo, SiJellyfin, SiOllama, SiVaultwarden } from "react-icons/si";
import { TbMail, TbKey, TbServer, TbArrowRight, TbTool } from "react-icons/tb";
import { Nav } from "@/components/core/nav";
import { useEffect, useState } from "react";
interface Service {
id: string;
name: string;
description: string;
priceStatus: string;
joinLink?: string;
enabled: boolean;
}
const getServiceIcon = (serviceName: string) => {
switch (serviceName.toLowerCase()) {
case 'git':
return SiForgejo;
case 'tv':
return SiJellyfin;
case 'ai':
return SiOllama;
case 'mail':
case 'email':
return TbMail;
case 'hosting':
return TbServer;
case 'pass':
return SiVaultwarden;
case 'keybox':
return TbKey;
default:
return TbTool;
}
};
export default function Home() {
const [services, setServices] = useState<Service[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchServices();
}, []);
const fetchServices = async () => {
try {
const response = await fetch("/api/services");
if (response.ok) {
const data = await response.json();
setServices(data.services.slice(0, 6));
}
} catch (error) {
console.error("Error fetching services:", error);
} finally {
setLoading(false);
}
};
return (
<main>
<Nav />
<div className="flex flex-col items-center justify-between gap-3 my-12 sm:my-20 px-4">
<h1 className="text-3xl sm:text-4xl font-bold text-center">
<div className="flex flex-col items-center justify-center gap-10 sm:gap-16 my-8 sm:my-12 px-4">
<div className="flex flex-col items-center justify-center gap-4 sm:gap-8 text-center">
<h1 className="text-4xl sm:text-6xl font-bold">
p0ntus
</h1>
<h3 className="text-xl sm:text-2xl text-center">
open source at your fingertips
</h3>
<p className="text-lg sm:text-2xl font-light max-w-2xl">
a simple platform for privacy-conscious users who want to take control of their digital life
</p>
</div>
<hr className="border-black dark:border-white mt-16 mb-16 sm:mt-24 sm:mb-24" />
<div className="max-w-6xl mx-auto w-full px-4 md:px-10">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 gap-y-16 lg:gap-16">
<div className="flex flex-col items-center justify-start gap-6 h-full">
<div className="w-full max-w-7xl mx-auto">
<div className="flex flex-col lg:flex-row justify-center items-start gap-8 lg:gap-12">
<div className="flex flex-col items-center justify-start gap-6 w-full lg:max-w-md">
<h2 className="text-2xl sm:text-3xl font-bold text-center w-full">Services</h2>
<h3 className="hidden sm:block text-lg sm:text-xl italic text-center w-full">what can we offer you?</h3>
<div className="grid grid-cols-3 gap-14 sm:gap-10 my-6 sm:my-8">
<div className="flex flex-col items-center justify-center gap-3">
<Link href="/services/git" className="flex flex-col items-center gap-2">
<SiForgejo size={40} className="sm:w-12 sm:h-12" />
<h3 className="text-base sm:text-lg font-bold">git</h3>
<h3 className="text-lg sm:text-xl italic text-center w-full text-gray-600 hidden sm:block">what can we offer you?</h3>
{loading ? (
<div className="animate-pulse text-lg">Loading services...</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-8 sm:gap-10 my-6 sm:my-8">
{services.map((service) => {
const IconComponent = getServiceIcon(service.name);
return (
<div key={service.id} className="flex flex-col items-center justify-center gap-3">
<Link href={`/services/${service.name}`} className="flex flex-col items-center gap-2 hover:opacity-75 transition-opacity">
<IconComponent size={40} className="sm:w-12 sm:h-12" />
<h3 className="text-base sm:text-lg font-bold">{service.name}</h3>
</Link>
</div>
<div className="flex flex-col items-center justify-center gap-3">
<Link href="/services/mail" className="flex flex-col items-center gap-2">
<TbMail size={40} className="sm:w-12 sm:h-12" />
<h3 className="text-base sm:text-lg font-bold">mail</h3>
</Link>
);
})}
</div>
<div className="flex flex-col items-center justify-center gap-3">
<Link href="/services/ai" className="flex flex-col items-center gap-2">
<SiOllama size={40} className="sm:w-12 sm:h-12" />
<h3 className="text-base sm:text-lg font-bold">ai</h3>
</Link>
)}
</div>
<div className="flex flex-col items-center justify-center gap-3">
<Link href="/services/tv" className="flex flex-col items-center gap-2">
<SiJellyfin size={40} className="sm:w-12 sm:h-12" />
<h3 className="text-base sm:text-lg font-bold">tv</h3>
</Link>
</div>
<div className="flex flex-col items-center justify-center gap-3">
<Link href="/services/keybox" className="flex flex-col items-center gap-2">
<TbKey size={40} className="sm:w-12 sm:h-12" />
<h3 className="text-base sm:text-lg font-bold">keybox</h3>
</Link>
</div>
<div className="flex flex-col items-center justify-center gap-3">
<Link href="/services/hosting" className="flex flex-col items-center gap-2">
<TbServer size={40} className="sm:w-12 sm:h-12" />
<h3 className="text-base sm:text-lg font-bold">hosting</h3>
</Link>
</div>
</div>
</div>
<div className="flex flex-col items-center justify-start gap-6 h-full">
<div className="flex flex-col items-center justify-start gap-6 w-full lg:max-w-md">
<h2 className="text-2xl sm:text-3xl font-bold text-center w-full">Where we are</h2>
<h3 className="hidden sm:block text-lg sm:text-xl italic text-center w-full">how can you find us?</h3>
<h3 className="text-lg sm:text-xl italic text-center w-full text-gray-600 hidden sm:block">how can you find us?</h3>
<div className="flex flex-col items-center gap-6 mt-6">
<p className="text-base sm:text-lg text-center">
p0ntus is fully on the public internet! our servers are mainly located in the united states.
@ -70,14 +105,15 @@ export default function Home() {
<p className="text-base sm:text-lg text-center">
we also operate servers in the united states, canada and germany.
</p>
<Link href="/servers" className="flex flex-row items-center gap-2 text-base sm:text-lg text-center text-blue-500 hover:underline">
<Link href="/servers" className="flex flex-row items-center gap-2 text-base sm:text-lg text-center text-blue-500 hover:underline transition-colors">
our servers <TbArrowRight size={20} />
</Link>
</div>
</div>
<div className="flex flex-col items-center justify-start gap-6 h-full">
<div className="flex flex-col items-center justify-start gap-6 w-full lg:max-w-md">
<h2 className="text-2xl sm:text-3xl font-bold text-center w-full">Why is p0ntus free?</h2>
<h3 className="hidden sm:block text-lg sm:text-xl italic text-center w-full">what&apos;s the point?</h3>
<h3 className="text-lg sm:text-xl italic text-center w-full text-gray-600 hidden sm:block">what&apos;s the point?</h3>
<div className="flex flex-col items-center gap-6 mt-6">
<p className="text-base sm:text-lg text-center">
everything today includes microtransactions, and we were fed up with it.
@ -89,6 +125,7 @@ export default function Home() {
</div>
</div>
</div>
</div>
</main>
);
}

355
app/requests/page.tsx Normal file
View file

@ -0,0 +1,355 @@
"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 {
TbSend,
TbClock,
TbCheck,
TbX,
TbEye,
TbNotes,
TbInfoCircle,
} from "react-icons/tb";
import Link from "next/link";
interface ServiceRequest {
id: string;
reason: string;
status: 'pending' | 'approved' | 'denied';
adminNotes?: string;
reviewedAt?: string;
createdAt: string;
serviceName: string;
serviceDescription: string;
}
interface Service {
id: string;
name: string;
description: string;
priceStatus: string;
joinLink?: string;
enabled: boolean;
}
export default function ServiceRequests() {
const router = useRouter();
const { data: session, isPending } = authClient.useSession();
const [mounted, setMounted] = useState(false);
const [requests, setRequests] = useState<ServiceRequest[]>([]);
const [services, setServices] = useState<Service[]>([]);
const [userServices, setUserServices] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [selectedService, setSelectedService] = useState("");
const [reason, setReason] = useState("");
const [captchaToken, setCaptchaToken] = useState("");
const [message, setMessage] = useState("");
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (mounted && !isPending && !session) {
router.push("/login?message=Please sign in to access service requests");
}
}, [session, isPending, mounted, router]);
useEffect(() => {
if (session) {
fetchRequests();
fetchUserServices();
fetchServices();
}
}, [session]);
const fetchRequests = async () => {
try {
const response = await fetch("/api/service-requests");
if (response.ok) {
const data = await response.json();
setRequests(data.requests);
}
} catch (error) {
console.error("Error fetching requests:", error);
} finally {
setLoading(false);
}
};
const fetchUserServices = async () => {
try {
const response = await fetch("/api/user-services");
if (response.ok) {
const data = await response.json();
setUserServices(data.services.map((s: { serviceName: string }) => s.serviceName));
}
} catch (error) {
console.error("Error fetching user services:", error);
}
};
const fetchServices = async () => {
try {
const response = await fetch("/api/services");
if (response.ok) {
const data = await response.json();
setServices(data.services);
}
} catch (error) {
console.error("Error fetching services:", error);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedService || !reason || !captchaToken) {
setMessage("Please fill in all fields and complete the captcha");
return;
}
setSubmitting(true);
setMessage("");
try {
const response = await fetch("/api/service-requests", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
serviceId: selectedService,
reason,
captchaToken
}),
});
const data = await response.json();
if (response.ok) {
setMessage("Request submitted successfully!");
setSelectedService("");
setReason("");
setCaptchaToken("");
fetchRequests();
} else {
setMessage(data.error || "Failed to submit request");
}
} catch (error) {
console.error("Error submitting request:", error);
setMessage("An error occurred while submitting the request");
} finally {
setSubmitting(false);
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'pending':
return <TbClock className="w-5 h-5 text-yellow-500" />;
case 'approved':
return <TbCheck className="w-5 h-5 text-green-500" />;
case 'denied':
return <TbX className="w-5 h-5 text-red-500" />;
default:
return <TbClock className="w-5 h-5 text-gray-500" />;
}
};
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 (
<main>
<Nav />
<div className="flex flex-col items-center justify-center min-h-[50vh] px-4">
<div className="animate-pulse text-lg">loading...</div>
</div>
</main>
);
}
if (!session) {
return (
<main>
<Nav />
<div className="flex flex-col items-center justify-center min-h-[50vh] px-4">
<div className="text-lg">redirecting to login...</div>
</div>
</main>
);
}
return (
<main>
<Nav />
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="flex flex-row items-center justify-start gap-3 mb-8">
<TbSend size={32} className="text-blue-500" />
<h1 className="text-3xl sm:text-4xl font-bold">Service Requests</h1>
</div>
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 mb-8">
<h2 className="text-xl font-semibold mb-4">Request Service Access</h2>
<div className="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-lg border border-blue-200 dark:border-blue-700 mb-6">
<div className="flex items-start gap-3">
<TbInfoCircle className="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm text-blue-700 dark:text-blue-300">
<strong>Note:</strong> Open services (like Git and Mail) don&apos;t require requests - you already have access!
Visit the <Link href="/dashboard" className="underline hover:no-underline">dashboard</Link> to see your available services
and create accounts directly.
</p>
</div>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="service" className="block text-sm font-medium mb-2">
Service
</label>
<select
id="service"
value={selectedService}
onChange={(e) => setSelectedService(e.target.value)}
className="w-full p-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700"
required
>
<option value="">Select a service</option>
{services
.filter(s => (s.priceStatus === "by-request" || s.priceStatus === "invite-only") && !userServices.includes(s.name))
.map((service) => (
<option key={service.name} value={service.name}>
{service.name.charAt(0).toUpperCase() + service.name.slice(1)} - {service.description}
</option>
))}
</select>
{services.filter(s => (s.priceStatus === "by-request" || s.priceStatus === "invite-only") && !userServices.includes(s.name)).length === 0 && (
<p className="text-sm text-gray-500 mt-2">
No services require requests at this time. All available services are either open or you already have access.
</p>
)}
</div>
<div>
<label htmlFor="reason" className="block text-sm font-medium mb-2">
Reason for Request
</label>
<textarea
id="reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={4}
className="w-full p-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700"
placeholder="Please explain why you need access to this service..."
required
/>
</div>
<div>
<Altcha onStateChange={(ev) => {
if ('detail' in ev) {
setCaptchaToken(ev.detail.payload || "");
}
}} />
</div>
<button
type="submit"
disabled={submitting || !selectedService || !reason || !captchaToken}
className="bg-blue-500 text-white px-6 py-3 rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
>
<TbSend className="w-4 h-4" />
{submitting ? "Submitting..." : "Submit Request"}
</button>
</form>
{message && (
<div className={`mt-4 p-3 rounded-lg ${message.includes("success") ? "bg-green-50 text-green-700 border border-green-200" : "bg-red-50 text-red-700 border border-red-200"}`}>
{message}
</div>
)}
</div>
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
<TbEye className="w-5 h-5" />
My Requests
</h2>
{loading ? (
<div className="text-center py-8">
<div className="animate-pulse">Loading requests...</div>
</div>
) : requests.length === 0 ? (
<div className="text-center py-8 text-gray-500">
No requests found. Submit your first request above!
</div>
) : (
<div className="space-y-4">
{requests.map((request) => (
<div key={request.id} className="border border-gray-200 dark:border-gray-600 rounded-lg p-4">
<div className="flex items-start justify-between mb-2">
<div>
<h3 className="font-semibold text-lg">
{request.serviceName.charAt(0).toUpperCase() + request.serviceName.slice(1)}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
{request.serviceDescription}
</p>
</div>
<div className={`flex items-center gap-2 px-3 py-1 rounded-full border ${getStatusColor(request.status)}`}>
{getStatusIcon(request.status)}
<span className="text-sm font-medium capitalize">{request.status}</span>
</div>
</div>
<div className="mb-3">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-1">Reason:</p>
<p className="text-sm">{request.reason}</p>
</div>
{request.adminNotes && (
<div className="mb-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-1 flex items-center gap-1">
<TbNotes className="w-4 h-4" />
Admin Notes:
</p>
<p className="text-sm">{request.adminNotes}</p>
</div>
)}
<div className="text-xs text-gray-500">
Submitted: {new Date(request.createdAt).toLocaleDateString()}
{request.reviewedAt && (
<span className="ml-4">
Reviewed: {new Date(request.reviewedAt).toLocaleDateString()}
</span>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
</main>
);
}

View file

@ -1,9 +1,191 @@
"use client"
import { Nav } from "@/components/core/nav"
import { SiForgejo, SiJellyfin, SiOllama } from "react-icons/si"
import { TbKey, TbMail, TbServer, TbTool } from "react-icons/tb"
import { SiForgejo, SiJellyfin, SiOllama, SiVaultwarden } from "react-icons/si"
import { TbMail, TbServer, TbTool, TbKey, TbLogin, TbSend, TbExternalLink, TbInfoCircle } from "react-icons/tb"
import Link from "next/link"
import { useEffect, useState } from "react"
import { authClient } from "@/util/auth-client"
interface Service {
id: string;
name: string;
description: string;
priceStatus: string;
joinLink?: string;
enabled: boolean;
}
interface UserService {
serviceId: string;
serviceName: string;
serviceDescription: string;
priceStatus: string;
joinLink?: string;
grantedAt: string | null;
isOpen: boolean;
}
const getServiceIcon = (serviceName: string) => {
switch (serviceName.toLowerCase()) {
case 'git':
return SiForgejo;
case 'tv':
return SiJellyfin;
case 'ai':
return SiOllama;
case 'mail':
case 'email':
return TbMail;
case 'hosting':
return TbServer;
case 'keybox':
return TbKey;
case 'pass':
return SiVaultwarden;
default:
return TbTool;
}
};
export default function Services() {
const { data: session, isPending } = authClient.useSession();
const [services, setServices] = useState<Service[]>([]);
const [userServices, setUserServices] = useState<UserService[]>([]);
const [loading, setLoading] = useState(true);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
fetchServices();
if (session) {
fetchUserServices();
}
}, [session]);
const fetchServices = async () => {
try {
const response = await fetch("/api/services");
if (response.ok) {
const data = await response.json();
setServices(data.services);
}
} catch (error) {
console.error("Error fetching services:", error);
} finally {
setLoading(false);
}
};
const fetchUserServices = async () => {
try {
const response = await fetch("/api/user-services");
if (response.ok) {
const data = await response.json();
setUserServices(data.services);
}
} catch (error) {
console.error("Error fetching user services:", error);
}
};
const hasAccess = (serviceName: string) => {
return userServices.some(userService => userService.serviceName === serviceName);
};
const getUserJoinLink = (serviceName: string) => {
const userService = userServices.find(us => us.serviceName === serviceName);
return userService?.joinLink;
};
const getServiceButtonContent = (service: Service) => {
const isLoggedIn = !!session;
const userHasAccess = hasAccess(service.name);
const userJoinLink = getUserJoinLink(service.name);
const joinLink = userJoinLink || service.joinLink;
if (isLoggedIn && userHasAccess && joinLink) {
return (
<Link href={joinLink} target="_blank" rel="noopener noreferrer">
<button className="flex flex-row items-center justify-center gap-1 text-white bg-green-600 px-3 py-1.5 rounded-lg text-sm hover:bg-green-700 transition-all duration-300 cursor-pointer">
<TbExternalLink size={14} />
Open
</button>
</Link>
);
}
if (isLoggedIn && !userHasAccess && (service.priceStatus === 'by-request' || service.priceStatus === 'invite-only')) {
return (
<Link href="/requests">
<button className="flex flex-row items-center justify-center gap-1 text-white bg-blue-600 px-3 py-1.5 rounded-lg text-sm hover:bg-blue-700 transition-all duration-300 cursor-pointer">
<TbSend size={14} />
Request
</button>
</Link>
);
}
if (isLoggedIn && service.priceStatus === 'open' && joinLink) {
return (
<Link href={joinLink} target="_blank" rel="noopener noreferrer">
<button className="flex flex-row items-center justify-center gap-1 text-white bg-green-600 px-3 py-1.5 rounded-lg text-sm hover:bg-green-700 transition-all duration-300 cursor-pointer">
<TbExternalLink size={14} />
Join
</button>
</Link>
);
}
if (!isLoggedIn && service.priceStatus === 'open' && joinLink) {
return (
<Link href={joinLink} target="_blank" rel="noopener noreferrer">
<button className="flex flex-row items-center justify-center gap-1 text-white bg-green-600 px-3 py-1.5 rounded-lg text-sm hover:bg-green-700 transition-all duration-300 cursor-pointer">
<TbExternalLink size={14} />
Join
</button>
</Link>
);
}
if (!isLoggedIn && (service.priceStatus === 'invite-only' || service.priceStatus === 'by-request')) {
return (
<Link href="/login">
<button className="flex flex-row items-center justify-center gap-1 text-white bg-blue-600 px-3 py-1.5 rounded-lg text-sm hover:bg-blue-700 transition-all duration-300 cursor-pointer">
<TbLogin size={14} />
Login
</button>
</Link>
);
}
return null;
};
const getServiceCardColor = (service: Service) => {
const isLoggedIn = !!session;
const userHasAccess = hasAccess(service.name);
if (isLoggedIn && userHasAccess) {
return "bg-green-400 text-white";
}
switch (service.priceStatus) {
case 'open':
return "bg-blue-400 text-white";
case 'invite-only':
return "bg-orange-400 text-white";
case 'by-request':
return "bg-purple-400 text-white";
default:
return "bg-gray-400 text-white";
}
};
if (!mounted || isPending) {
return (
<main>
<Nav />
@ -20,68 +202,65 @@ export default function Services() {
</h2>
</div>
</div>
<div className="flex justify-center items-center py-12">
<div className="animate-pulse text-lg">Loading services...</div>
</div>
</main>
);
}
return (
<main>
<Nav />
<div className="flex flex-col items-center justify-between gap-6 sm:gap-10 my-12 sm:my-16 px-4">
<div className="flex flex-row items-center justify-between gap-2">
<TbTool size={32} className="sm:w-9 sm:h-9" />
<h1 className="text-3xl sm:text-4xl font-bold">
services
</h1>
</div>
<div className="flex flex-col items-center justify-between gap-2">
<h2 className="text-2xl sm:text-3xl font-light text-center w-full flex flex-wrap items-center justify-center">
please select a service.
</h2>
</div>
</div>
{loading ? (
<div className="flex justify-center items-center py-12">
<div className="animate-pulse text-lg">Loading services...</div>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 my-4 w-full max-w-6xl mx-auto px-4">
<Link href="/services/git">
<div className="flex flex-col gap-2 text-base sm:text-lg bg-blue-400 text-white px-6 sm:px-8 py-6 sm:py-8 rounded-2xl sm:rounded-4xl hover:bg-blue-500 transition-colors">
<div className="flex flex-row items-center justify-between gap-2">
<SiForgejo size={28} className="sm:w-9 sm:h-9" />
{services.map((service) => {
const IconComponent = getServiceIcon(service.name);
return (
<div key={service.id} className="flex flex-col gap-4">
<div className={`flex flex-col gap-4 text-base sm:text-lg px-6 sm:px-8 py-6 sm:py-8 rounded-2xl sm:rounded-4xl transition-all ${getServiceCardColor(service)}`}>
<Link href={`/services/${service.name}`} className="hover:opacity-90 transition-opacity">
<div className="flex flex-row items-center gap-3">
<IconComponent size={28} className="sm:w-9 sm:h-9" />
<span className="text-xl sm:text-2xl font-bold">
git
{service.name === 'mail' ? 'email' : service.name}
</span>
</div>
</div>
</Link>
<Link href="/services/mail">
<div className="flex flex-col gap-2 text-base sm:text-lg bg-blue-400 text-white px-6 sm:px-8 py-6 sm:py-8 rounded-2xl sm:rounded-4xl hover:bg-blue-500 transition-colors">
<div className="flex flex-row items-center justify-between gap-2">
<TbMail size={28} className="sm:w-9 sm:h-9" />
<span className="text-xl sm:text-2xl font-bold">
email
</span>
</div>
</div>
</Link>
<Link href="/services/ai">
<div className="flex flex-col gap-2 text-base sm:text-lg bg-blue-400 text-white px-6 sm:px-8 py-6 sm:py-8 rounded-2xl sm:rounded-4xl hover:bg-blue-500 transition-colors">
<div className="flex flex-row items-center justify-between gap-2">
<SiOllama size={28} className="sm:w-9 sm:h-9" />
<span className="text-xl sm:text-2xl font-bold">
ai
</span>
</div>
</div>
</Link>
<Link href="/services/tv">
<div className="flex flex-col gap-2 text-base sm:text-lg bg-blue-400 text-white px-6 sm:px-8 py-6 sm:py-8 rounded-2xl sm:rounded-4xl hover:bg-blue-500 transition-colors">
<div className="flex flex-row items-center justify-between gap-2">
<SiJellyfin size={28} className="sm:w-9 sm:h-9" />
<span className="text-xl sm:text-2xl font-bold">
tv
</span>
</div>
</div>
</Link>
<Link href="/services/keybox">
<div className="flex flex-col gap-2 text-base sm:text-lg bg-blue-400 text-white px-6 sm:px-8 py-6 sm:py-8 rounded-2xl sm:rounded-4xl hover:bg-blue-500 transition-colors">
<div className="flex flex-row items-center justify-between gap-2">
<TbKey size={28} className="sm:w-9 sm:h-9" />
<span className="text-xl sm:text-2xl font-bold">
keybox
</span>
</div>
</div>
</Link>
<Link href="/services/hosting">
<div className="flex flex-col gap-2 text-base sm:text-lg bg-blue-400 text-white px-6 sm:px-8 py-6 sm:py-8 rounded-2xl sm:rounded-4xl hover:bg-blue-500 transition-colors">
<div className="flex flex-row items-center justify-between gap-2">
<TbServer size={28} className="sm:w-9 sm:h-9" />
<span className="text-xl sm:text-2xl font-bold">
hosting
</span>
</div>
</div>
<div className="flex flex-row mt-2 gap-3">
{getServiceButtonContent(service)}
<Link href={`/services/${service.name}`} target="_blank" rel="noopener noreferrer">
<button className="flex flex-row items-center justify-center gap-1 text-white bg-green-600 px-3 py-1.5 rounded-lg text-sm hover:bg-green-700 transition-all duration-300 cursor-pointer">
<TbInfoCircle size={14} />
Info
</button>
</Link>
</div>
</div>
</div>
);
})}
</div>
)}
</main>
)
}

188
app/signup/page.tsx Normal file
View file

@ -0,0 +1,188 @@
"use client"
import Altcha from "@/components/core/altcha";
import { Nav } from "@/components/core/nav";
import { TbUserPlus } from "react-icons/tb";
import { useState } from "react";
import { useForm, SubmitHandler } from "react-hook-form";
import { useRouter } from "next/navigation";
import { authClient } from "@/util/auth-client";
interface SignupForm {
email: string;
password: string;
confirmPassword: string;
name?: string;
}
export default function Signup() {
const router = useRouter();
const [altchaState, setAltchaState] = useState<{ status: "success" | "error" | "expired" | "waiting", token: string }>({ status: "waiting", token: "" });
const [isLoading, setIsLoading] = useState(false);
const [apiError, setApiError] = useState<string>("");
const {
register,
handleSubmit,
formState: { errors },
watch,
} = useForm<SignupForm>();
const password = watch("password");
const onSubmit: SubmitHandler<SignupForm> = async (data) => {
if (altchaState.status !== "success") {
setApiError("Please complete the captcha");
return;
}
setIsLoading(true);
setApiError("");
try {
const response = await fetch("/api/signup", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
...data,
token: altchaState.token,
}),
});
const result = await response.json();
if (!response.ok) {
setApiError(result.error || "Failed to create account");
return;
}
try {
await authClient.signIn.email({
email: data.email,
password: data.password,
});
router.push("/");
} catch (signInError) {
console.error("Auto-login failed:", signInError);
router.push("/login?message=Account created successfully. Please sign in.");
}
} catch (error) {
console.error("Signup error:", error);
setApiError("An unexpected error occurred. Please try again.");
} finally {
setIsLoading(false);
}
};
const handleAltchaStateChange = (e: Event | CustomEvent) => {
if ('detail' in e && e.detail?.payload) {
setAltchaState({ status: "success", token: e.detail.payload });
} else {
setAltchaState({ status: "error", token: "" });
}
};
return (
<main>
<Nav />
<div className="flex flex-col items-center justify-center mt-18 sm:my-16 px-4 gap-18">
<div className="flex flex-row items-center justify-between gap-2">
<TbUserPlus size={32} className="sm:w-9 sm:h-9" />
<h1 className="text-3xl sm:text-4xl font-bold">
signup
</h1>
</div>
<form className="flex flex-col bg-foreground/10 rounded-2xl sm:rounded-4xl p-4 gap-4 w-1/4 min-w-80" onSubmit={handleSubmit(onSubmit)}>
<h2 className="text-2xl sm:text-3xl font-light text-center w-full flex flex-wrap">
name (optional)
</h2>
<input
type="text"
placeholder="enter your name"
className="w-full p-2 rounded-md bg-foreground/10"
{...register("name")}
/>
<h2 className="text-2xl sm:text-3xl font-light text-center w-full flex flex-wrap">
email
</h2>
<input
type="email"
placeholder="enter your email"
className="w-full p-2 rounded-md bg-foreground/10"
{...register("email", {
required: "Email is required",
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: "Please enter a valid email address"
}
})}
/>
{errors.email && <p className="text-red-500 text-sm">{errors.email.message}</p>}
<h2 className="text-2xl sm:text-3xl font-light text-center w-full flex flex-wrap">
password
</h2>
<input
type="password"
placeholder="enter your password"
className="w-full p-2 rounded-md bg-foreground/10"
{...register("password", {
required: "Password is required",
minLength: {
value: 8,
message: "Password must be at least 8 characters long"
},
pattern: {
value: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
message: "Password must contain at least one uppercase letter, one lowercase letter, and one number"
}
})}
/>
{errors.password && <p className="text-red-500 text-sm">{errors.password.message}</p>}
<h2 className="text-2xl sm:text-3xl font-light text-center w-full flex flex-wrap">
confirm password
</h2>
<input
type="password"
placeholder="confirm your password"
className="w-full p-2 rounded-md bg-foreground/10"
{...register("confirmPassword", {
required: "Please confirm your password",
validate: value => value === password || "Passwords do not match"
})}
/>
{errors.confirmPassword && <p className="text-red-500 text-sm">{errors.confirmPassword.message}</p>}
<h2 className="text-2xl sm:text-3xl font-light text-center w-full flex flex-wrap">
captcha
</h2>
<Altcha onStateChange={handleAltchaStateChange} />
{apiError && (
<div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded-md">
{apiError}
</div>
)}
<button
className="bg-blue-400 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
type="submit"
disabled={isLoading || altchaState.status !== "success" || !altchaState.token}
>
{isLoading ? "Creating account..." : altchaState.status === "success" ? "signup" : "waiting for captcha"}
</button>
<p className="text-center text-sm text-gray-600 mt-4">
Already have an account?{" "}
<a href="/login" className="text-blue-400 hover:underline">
Sign in here
</a>
</p>
</form>
</div>
</main>
);
}

View file

@ -0,0 +1,57 @@
"use client"
import { useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react'
interface AltchaProps {
onStateChange?: (ev: Event | CustomEvent) => void
}
const Altcha = forwardRef<{ value: string | null }, AltchaProps>(({ onStateChange }, ref) => {
const widgetRef = useRef<AltchaWidget & AltchaWidgetMethods & HTMLElement>(null)
const [value, setValue] = useState<string | null>(null)
useEffect(() => {
import('altcha')
}, [])
useImperativeHandle(ref, () => {
return {
get value() {
return value
}
}
}, [value])
useEffect(() => {
const handleStateChange = (ev: Event | CustomEvent) => {
if ('detail' in ev) {
setValue(ev.detail.payload || null)
onStateChange?.(ev)
}
}
const { current } = widgetRef
if (current) {
current.addEventListener('statechange', handleStateChange)
return () => current.removeEventListener('statechange', handleStateChange)
}
}, [onStateChange])
return (
<altcha-widget
challengeurl="/api/captcha"
ref={widgetRef}
style={{
'--altcha-max-width': '100%',
}}
debug={process.env.NODE_ENV === "development"}
aria-label="Security check"
aria-describedby="altcha-description"
></altcha-widget>
)
})
Altcha.displayName = 'Altcha'
export default Altcha

View file

@ -1,6 +1,36 @@
"use client"
import Link from "next/link";
import { authClient } from "@/util/auth-client";
import { useRouter } from "next/navigation";
interface ExtendedUser {
id: string;
name: string;
email: string;
emailVerified: boolean;
createdAt: Date;
updatedAt: Date;
image?: string | null;
role?: string;
}
export function Nav() {
const { data: session, isPending } = authClient.useSession();
const router = useRouter();
const handleSignOut = async () => {
try {
await fetch("/api/logout", {
method: "POST",
});
await authClient.signOut();
router.refresh();
} catch (error) {
console.error("Sign out error:", error);
}
};
return (
<div className="flex flex-col sm:flex-row items-center justify-between px-4 sm:px-5 py-3 gap-3 sm:gap-0">
<Link href="/">
@ -13,6 +43,31 @@ export function Nav() {
<Link href="/about" className="hover:underline">About</Link>
<Link href="/servers" className="hover:underline">Servers</Link>
<Link href="/services" className="hover:underline">Services</Link>
{isPending ? (
<div className="text-gray-500">Loading...</div>
) : session ? (
<div className="flex items-center gap-3">
<Link href="/dashboard" className="hover:underline">Dashboard</Link>
<Link href="/requests" className="hover:underline">Requests</Link>
{(session.user as ExtendedUser).role === 'admin' && (
<Link href="/admin" className="hover:underline text-red-500">Admin</Link>
)}
<span className="text-foreground-muted-light ml-6">Hi, <span className="font-bold text-foreground">{session.user.name || session.user.email}</span></span>
<button
onClick={handleSignOut}
className="text-red-400 hover:underline cursor-pointer"
>
Sign Out
</button>
</div>
) : (
<div className="flex items-center gap-3">
<Link href="/login" className="hover:underline">Login</Link>
<Link href="/signup" className="bg-blue-400 text-white px-3 py-1 rounded-md hover:bg-blue-500">
Sign Up
</Link>
</div>
)}
</div>
</div>
);

View file

@ -1,7 +1,33 @@
"use client"
import Link from "next/link";
import { Nav } from "../core/nav";
import { services } from "@/config/services";
import { TbArrowLeft, TbEye, TbLink, TbShieldLock } from "react-icons/tb";
import { TbArrowLeft, TbEye, TbLink, TbShieldLock, TbSend, TbExternalLink, TbLogin } from "react-icons/tb";
import { authClient } from "@/util/auth-client";
import { useEffect, useState } from "react";
import Altcha from "../core/altcha";
interface UserService {
serviceId: string;
serviceName: string;
serviceDescription: string;
priceStatus: string;
joinLink?: string;
grantedAt: string | null;
isOpen: boolean;
}
interface ServiceRequest {
id: string;
reason: string;
status: 'pending' | 'approved' | 'denied';
adminNotes?: string;
reviewedAt?: string;
createdAt: string;
serviceName: string;
serviceDescription: string;
}
function HumanPriceStatus(priceStatus: "open" | "invite-only" | "by-request") {
switch (priceStatus) {
@ -25,6 +51,20 @@ function HumanPriceStatusColor(priceStatus: "open" | "invite-only" | "by-request
}
}
function getUserAccessStatusColor(hasAccess: boolean, requestStatus?: string) {
if (hasAccess) return "bg-green-500";
if (requestStatus === 'pending') return "bg-yellow-500";
if (requestStatus === 'denied') return "bg-red-500";
return "bg-gray-500";
}
function getUserAccessStatusText(hasAccess: boolean, requestStatus?: string) {
if (hasAccess) return "You Have Access";
if (requestStatus === 'pending') return "Request Pending";
if (requestStatus === 'denied') return "Request Denied";
return "No Access";
}
function PriceStatusDesc(priceStatus: "open" | "invite-only" | "by-request", serviceName: string) {
switch (priceStatus) {
case "open":
@ -36,10 +76,171 @@ function PriceStatusDesc(priceStatus: "open" | "invite-only" | "by-request", ser
}
}
function getServiceButtonContent(
service: { name: string; priceStatus: string; joinLink?: string } | undefined,
session: { user: { id: string; email: string } } | null,
hasAccess: boolean,
joinLink: string | undefined,
serviceRequest: ServiceRequest | undefined,
setShowRequestForm: (show: boolean) => void
) {
const isLoggedIn = !!session;
if (isLoggedIn && hasAccess && joinLink) {
return (
<Link href={joinLink} target="_blank" rel="noopener noreferrer">
<button className="flex flex-row items-center justify-center gap-1 text-white bg-green-600 px-3 py-1.5 rounded-lg text-sm hover:bg-green-700 transition-all duration-300 cursor-pointer">
<TbExternalLink size={14} />
Open
</button>
</Link>
);
}
if (isLoggedIn && !hasAccess && (service?.priceStatus === 'by-request' || service?.priceStatus === 'invite-only')) {
if (service?.priceStatus === 'by-request' && !serviceRequest) {
return (
<button
onClick={() => setShowRequestForm(true)}
className="flex flex-row items-center justify-center gap-1 text-white bg-blue-600 px-3 py-1.5 rounded-lg text-sm hover:bg-blue-700 transition-all duration-300 cursor-pointer"
>
<TbSend size={14} />
Request
</button>
);
} else {
return (
<Link href="/requests">
<button className="flex flex-row items-center justify-center gap-1 text-white bg-blue-600 px-3 py-1.5 rounded-lg text-sm hover:bg-blue-700 transition-all duration-300 cursor-pointer">
<TbSend size={14} />
Request
</button>
</Link>
);
}
}
if (isLoggedIn && service?.priceStatus === 'open' && joinLink) {
return (
<Link href={joinLink} target="_blank" rel="noopener noreferrer">
<button className="flex flex-row items-center justify-center gap-1 text-white bg-green-600 px-3 py-1.5 rounded-lg text-sm hover:bg-green-700 transition-all duration-300 cursor-pointer">
<TbExternalLink size={14} />
Join
</button>
</Link>
);
}
if (!isLoggedIn && service?.priceStatus === 'open' && joinLink) {
return (
<Link href={joinLink} target="_blank" rel="noopener noreferrer">
<button className="flex flex-row items-center justify-center gap-1 text-white bg-green-600 px-3 py-1.5 rounded-lg text-sm hover:bg-green-700 transition-all duration-300 cursor-pointer">
<TbExternalLink size={14} />
Join
</button>
</Link>
);
}
if (!isLoggedIn && (service?.priceStatus === 'invite-only' || service?.priceStatus === 'by-request')) {
return (
<Link href="/login">
<button className="flex flex-row items-center justify-center gap-1 text-white bg-blue-600 px-3 py-1.5 rounded-lg text-sm hover:bg-blue-700 transition-all duration-300 cursor-pointer">
<TbLogin size={14} />
Login
</button>
</Link>
);
}
return null;
}
export function ServicesShell({ slug }: { slug: string }) {
const { data: session, isPending } = authClient.useSession();
const [userAccess, setUserAccess] = useState<UserService[]>([]);
const [userRequests, setUserRequests] = useState<ServiceRequest[]>([]);
const [, setLoading] = useState(true);
const [showRequestForm, setShowRequestForm] = useState(false);
const [requestReason, setRequestReason] = useState("");
const [captchaToken, setCaptchaToken] = useState("");
const [submitting, setSubmitting] = useState(false);
const service = services.find((service) => service.name === slug);
const Icon = service?.icon;
useEffect(() => {
if (session) {
fetchUserData();
} else if (!isPending) {
setLoading(false);
}
}, [session, isPending]);
const fetchUserData = async () => {
try {
const [accessResponse, requestsResponse] = await Promise.all([
fetch("/api/user-services"),
fetch("/api/service-requests")
]);
if (accessResponse.ok) {
const accessData = await accessResponse.json();
setUserAccess(accessData.services);
}
if (requestsResponse.ok) {
const requestsData = await requestsResponse.json();
setUserRequests(requestsData.requests);
}
} catch (error) {
console.error("Error fetching user data:", error);
} finally {
setLoading(false);
}
};
const submitRequest = async () => {
if (!requestReason.trim() || !captchaToken) return;
setSubmitting(true);
try {
const response = await fetch("/api/service-requests", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
serviceId: service?.name,
reason: requestReason,
captchaToken
}),
});
if (response.ok) {
setShowRequestForm(false);
setRequestReason("");
setCaptchaToken("");
fetchUserData();
} else {
const error = await response.json();
console.error("Request failed:", error.error);
}
} catch (error) {
console.error("Error submitting request:", error);
} finally {
setSubmitting(false);
}
};
const hasAccess = userAccess.some(access => access.serviceName === service?.name);
const userService = userAccess.find(access => access.serviceName === service?.name);
const isOpen = userService?.isOpen || false;
const serviceRequest = userRequests.find(request => request.serviceName === service?.name);
const joinLink = hasAccess
? userAccess.find(access => access.serviceName === service?.name)?.joinLink || service?.joinLink
: service?.joinLink;
return (
<main>
<Nav />
@ -61,23 +262,86 @@ export function ServicesShell({ slug }: { slug: string }) {
</Link>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-4 px-4 sm:px-8 lg:px-14">
<div className={`flex flex-col justify-between gap-4 rounded-2xl px-6 sm:px-8 py-4 ${HumanPriceStatusColor(service?.priceStatus as "open" | "invite-only" | "by-request")}`}>
<div className={`flex flex-col justify-between gap-4 rounded-2xl px-6 sm:px-8 py-4 ${
session ? getUserAccessStatusColor(hasAccess, serviceRequest?.status) : HumanPriceStatusColor(service?.priceStatus as "open" | "invite-only" | "by-request")
}`}>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 w-full my-2">
<h2 className="text-xl sm:text-2xl font-semibold text-white">
{HumanPriceStatus(service?.priceStatus as "open" | "invite-only" | "by-request")}
{session ? getUserAccessStatusText(hasAccess, serviceRequest?.status) : HumanPriceStatus(service?.priceStatus as "open" | "invite-only" | "by-request")}
</h2>
{service?.joinLink && (
<Link href={service.joinLink}>
<button className="flex flex-row items-center justify-center gap-2 text-white bg-green-600 px-4 py-2 rounded-full hover:underline transition-all duration-300 cursor-pointer w-full sm:w-auto">
Join!
</button>
</Link>
{getServiceButtonContent(service, session, hasAccess, joinLink, serviceRequest, setShowRequestForm)}
</div>
<div className="text-sm sm:text-base text-white mb-3">
{session ? (
hasAccess ? (
<div>
<p>You have access to {service?.name}! Click the button above to get started.</p>
{isOpen && (
<p className="mt-2 text-xs opacity-80">
Open service: {service?.quickLinks && service.quickLinks.length > 0 ? "Create an account to get started" : "Available for public registration"}
</p>
)}
</div>
<p className="text-sm sm:text-base text-white mb-3">
{PriceStatusDesc(service?.priceStatus as "open" | "invite-only" | "by-request", service?.name as string)}
) : serviceRequest ? (
<div>
<p>Request Status: <strong>{serviceRequest.status}</strong></p>
{serviceRequest.adminNotes && (
<p className="mt-2">Admin Notes: {serviceRequest.adminNotes}</p>
)}
<p className="mt-2 text-xs">
Submitted: {new Date(serviceRequest.createdAt).toLocaleDateString()}
</p>
</div>
) : (
<p>{PriceStatusDesc(service?.priceStatus as "open" | "invite-only" | "by-request", service?.name as string)}</p>
)
) : (
<p>{PriceStatusDesc(service?.priceStatus as "open" | "invite-only" | "by-request", service?.name as string)} Please sign in to check your access status.</p>
)}
</div>
</div>
{showRequestForm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 w-full max-w-md">
<h3 className="text-lg font-semibold mb-4">Request Access to {service?.name}</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2">Reason for Request</label>
<textarea
value={requestReason}
onChange={(e) => setRequestReason(e.target.value)}
className="w-full p-3 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700"
rows={3}
placeholder="Please explain why you need access to this service..."
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Verify you&apos;re human</label>
<Altcha onStateChange={(ev) => {
if ('detail' in ev) {
setCaptchaToken(ev.detail.payload || "");
}
}} />
</div>
<div className="flex gap-3">
<button
onClick={submitRequest}
disabled={!requestReason.trim() || !captchaToken || submitting}
className="flex-1 bg-blue-500 text-white px-4 py-2 rounded-lg hover:bg-blue-600 disabled:bg-gray-400 transition-colors"
>
{submitting ? "Submitting..." : "Submit Request"}
</button>
<button
onClick={() => setShowRequestForm(false)}
className="flex-1 bg-gray-500 text-white px-4 py-2 rounded-lg hover:bg-gray-600 transition-colors"
>
Cancel
</button>
</div>
</div>
</div>
</div>
)}
<div className={`flex flex-col justify-between gap-4 rounded-2xl px-6 sm:px-8 py-4 bg-gray-200`}>
<div className="flex flex-row items-center gap-2 w-full my-2">
<h2 className="flex flex-row items-center gap-2 text-xl sm:text-2xl font-semibold text-black">
@ -128,7 +392,7 @@ export function ServicesShell({ slug }: { slug: string }) {
</h2>
</div>
<ul className="list-disc list-inside text-sm sm:text-base text-black">
{service.quickLinks.map((link, index) => (
{service?.quickLinks?.map((link, index) => (
<Link href={link.url} key={index}>
<button className="flex flex-row items-center gap-2 text-black hover:underline transition-all duration-300 cursor-pointer">
<link.icon size={16} /> {link.name}

View file

@ -2,6 +2,7 @@ import {
SiForgejo,
SiJellyfin,
SiOllama,
SiVaultwarden,
} from "react-icons/si"
import {
TbBrowser,
@ -72,7 +73,7 @@ export const services = [
},
{
name: "tv",
description: "Private screening movies and tv shows. Powered by Jellyfin.",
description: "Private screening movies and TV shows. Powered by Jellyfin.",
icon: SiJellyfin,
priceStatus: "invite-only",
adminView: {
@ -174,6 +175,7 @@ export const services = [
description: "By-request server and service hosting.",
icon: TbServer,
priceStatus: "by-request",
joinLink: "https://pass.librecloud.cc",
adminView: {
"Your data": {
icon: TbServer,
@ -193,4 +195,41 @@ export const services = [
},
}
},
{
name: "pass",
description: "A private password manager. Powered by Vaultwarden.",
icon: SiVaultwarden,
priceStatus: "open",
adminView: {
"Your total entry count": {
icon: TbServer,
description: "Admins can view how many passwords you have stored.",
},
"Your email address": {
icon: TbMail,
description: "Your email address is visible to admins.",
},
"Your organizations": {
icon: TbLink,
description: "If you create an organization, admins can view basic details.",
},
},
quickLinks: [
{
name: "Create an Account",
url: "https://pass.librecloud.cc/#/signup",
icon: TbUserPlus,
},
{
name: "Login",
url: "https://pass.librecloud.cc/#/login",
icon: TbUser,
},
{
name: "Login with Passkey",
url: "https://pass.librecloud.cc/#/login-with-passkey",
icon: TbKey,
},
]
},
]

7
db/index.ts Normal file
View file

@ -0,0 +1,7 @@
import postgres from 'postgres';
import { drizzle } from 'drizzle-orm/postgres-js';
import * as schema from './schema';
const sql = postgres(process.env.DATABASE_URL!);
export const db = drizzle(sql, { schema });

84
db/schema.ts Normal file
View file

@ -0,0 +1,84 @@
import { pgTable, text, timestamp, boolean, pgEnum } from "drizzle-orm/pg-core";
export const roleEnum = pgEnum('role', ['user', 'admin']);
export const requestStatusEnum = pgEnum('request_status', ['pending', 'approved', 'denied']);
export const user = pgTable("user", {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
emailVerified: boolean('email_verified').$defaultFn(() => false).notNull(),
image: text('image'),
role: roleEnum('role').$defaultFn(() => 'user').notNull(),
createdAt: timestamp('created_at').$defaultFn(() => /* @__PURE__ */ new Date()).notNull(),
updatedAt: timestamp('updated_at').$defaultFn(() => /* @__PURE__ */ new Date()).notNull()
});
export const session = pgTable("session", {
id: text('id').primaryKey(),
expiresAt: timestamp('expires_at').notNull(),
token: text('token').notNull().unique(),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
userId: text('user_id').notNull().references(()=> user.id, { onDelete: 'cascade' })
});
export const account = pgTable("account", {
id: text('id').primaryKey(),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
userId: text('user_id').notNull().references(()=> user.id, { onDelete: 'cascade' }),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessTokenExpiresAt: timestamp('access_token_expires_at'),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
scope: text('scope'),
password: text('password'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull()
});
export const verification = pgTable("verification", {
id: text('id').primaryKey(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').$defaultFn(() => /* @__PURE__ */ new Date()),
updatedAt: timestamp('updated_at').$defaultFn(() => /* @__PURE__ */ new Date())
});
export const services = pgTable("services", {
id: text('id').primaryKey(),
name: text('name').notNull().unique(),
description: text('description').notNull(),
priceStatus: text('price_status').notNull(), // "open" | "invite-only" | "by-request"
joinLink: text('join_link'),
enabled: boolean('enabled').$defaultFn(() => true).notNull(),
createdAt: timestamp('created_at').$defaultFn(() => /* @__PURE__ */ new Date()).notNull(),
updatedAt: timestamp('updated_at').$defaultFn(() => /* @__PURE__ */ new Date()).notNull()
});
export const serviceRequests = pgTable("service_requests", {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
serviceId: text('service_id').notNull().references(() => services.id, { onDelete: 'cascade' }),
reason: text('reason'),
status: requestStatusEnum('status').$defaultFn(() => 'pending').notNull(),
adminNotes: text('admin_notes'),
reviewedBy: text('reviewed_by').references(() => user.id),
reviewedAt: timestamp('reviewed_at'),
createdAt: timestamp('created_at').$defaultFn(() => /* @__PURE__ */ new Date()).notNull(),
updatedAt: timestamp('updated_at').$defaultFn(() => /* @__PURE__ */ new Date()).notNull()
});
export const userServices = pgTable("user_services", {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
serviceId: text('service_id').notNull().references(() => services.id, { onDelete: 'cascade' }),
grantedBy: text('granted_by').references(() => user.id),
grantedAt: timestamp('granted_at').$defaultFn(() => /* @__PURE__ */ new Date()).notNull(),
createdAt: timestamp('created_at').$defaultFn(() => /* @__PURE__ */ new Date()).notNull()
});

10
drizzle.config.ts Normal file
View file

@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
out: './drizzle',
schema: './db/schema.ts',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});

View file

@ -0,0 +1,19 @@
services:
pontus:
build: .
container_name: pontus
ports:
- 3000:3000
environment:
- NODE_ENV=production
postgres:
image: postgres:17
container_name: pontus-postgres
volumes:
- ./postgres:/var/lib/postgresql/data
ports:
- 5432:5432
environment:
- POSTGRES_USER=pontus
- POSTGRES_PASSWORD=changeme
- POSTGRES_DB=pontus

View file

@ -3,6 +3,15 @@ services:
build: .
container_name: pontus
ports:
- 3000:3000
- 3000:3000 # Comment this out if you are using a reverse proxy
environment:
- NODE_ENV=production
postgres:
image: postgres:17
container_name: pontus-postgres
volumes:
- ./postgres:/var/lib/postgresql/data
environment:
- POSTGRES_USER=pontus
- POSTGRES_PASSWORD=changeme
- POSTGRES_DB=pontus

32
middleware.ts Normal file
View file

@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
const protectedRoutes = ['/dashboard'];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const isProtectedRoute = protectedRoutes.some(route =>
pathname.startsWith(route)
);
if (!isProtectedRoute) {
return NextResponse.next();
}
const sessionToken = request.cookies.get('better-auth.session_token')?.value;
if (!sessionToken) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('message', 'Please sign in to access this page');
loginUrl.searchParams.set('redirect', pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
};

View file

@ -6,25 +6,35 @@
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"db:seed": "bun tools/seed-db.ts"
},
"dependencies": {
"@types/react-world-flags": "^1.6.0",
"altcha": "^2.0.5",
"altcha-lib": "^1.3.0",
"better-auth": "^1.2.12",
"drizzle-orm": "^0.44.2",
"nanoid": "^5.0.0",
"next": "15.3.4",
"postgres": "^3.4.7",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.60.0",
"react-icons": "^5.5.0",
"react-world-flags": "^1.6.0"
},
"devDependencies": {
"typescript": "^5.8.3",
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.11",
"@types/node": "^20.19.4",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@tailwindcss/postcss": "^4.1.11",
"tailwindcss": "^4.1.11",
"drizzle-kit": "^0.31.4",
"eslint": "^9.30.1",
"eslint-config-next": "15.3.4",
"@eslint/eslintrc": "^3.3.1"
"tailwindcss": "^4.1.11",
"tsx": "^4.20.3",
"typescript": "^5.8.3"
}
}

22
tools/hmac.ts Normal file
View file

@ -0,0 +1,22 @@
import crypto from 'crypto'
import fs from 'fs'
const hmacKey = crypto.randomBytes(32).toString('hex')
if (fs.existsSync('.env.local')) {
const envFile = fs.readFileSync('.env.local', 'utf8')
// Double-check it's not already set
if (!envFile.includes('ALTCHA_SECRET')) {
fs.appendFileSync('.env.local', `\nALTCHA_SECRET=${hmacKey}`)
}
console.log(`Successfully wrote ALTCHA_SECRET to .env.local`)
} else if (fs.existsSync('.env')) {
const envFile = fs.readFileSync('.env', 'utf8')
// Double-check it's not already set
if (!envFile.includes('ALTCHA_SECRET')) {
fs.appendFileSync('.env', `\nALTCHA_SECRET=${hmacKey}`)
}
console.log(`Successfully wrote ALTCHA_SECRET to .env`)
} else {
console.error('No .env/.env.local file found, please create one first.')
}

28
tools/seed-db.ts Normal file
View file

@ -0,0 +1,28 @@
import { db } from "../db";
import { services } from "../db/schema";
import { services as serviceConfig } from "../config/services";
import { nanoid } from "nanoid";
async function seedDatabase() {
try {
console.log("Seeding database...");
await db.delete(services);
for (const service of serviceConfig) {
await db.insert(services).values({
id: nanoid(),
name: service.name,
description: service.description,
priceStatus: service.priceStatus,
joinLink: service.joinLink || null,
enabled: true
});
console.log(`✓ Added service: ${service.name}`);
}
console.log("Database seeded!");
} catch (error) {
console.error("Error seeding database:", error);
process.exit(1);
}
}
seedDatabase();

4
util/auth-client.ts Normal file
View file

@ -0,0 +1,4 @@
import { createAuthClient } from "better-auth/react"
export const authClient = createAuthClient({
baseURL: process.env.BETTER_AUTH_URL!
})

27
util/auth.ts Normal file
View file

@ -0,0 +1,27 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "../db";
import * as schema from "../db/schema";
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
schema,
}),
emailAndPassword: {
enabled: true,
requireEmailVerification: false,
},
session: {
expiresIn: 60 * 60 * 24 * 7,
},
user: {
additionalFields: {
role: {
type: "string",
required: false,
defaultValue: "user"
}
}
}
});

22
util/captcha.ts Normal file
View file

@ -0,0 +1,22 @@
import { verifySolution } from "altcha-lib";
export async function verifyCaptcha(token: string): Promise<boolean> {
const hmacKey = process.env.ALTCHA_SECRET;
if (!hmacKey) {
console.error("ALTCHA_SECRET is not set");
return false;
}
if (!token) {
return false;
}
try {
const isValid = await verifySolution(token, hmacKey);
return isValid;
} catch (error) {
console.error("[ALTCHA] Error:", error);
return false;
}
}