adds user accounts, service requests, dashboard, admin panel, better layout, db+altcha+auth support
This commit is contained in:
parent
dfbc3cade9
commit
0043a5bf3c
40 changed files with 3981 additions and 188 deletions
|
@ -5,3 +5,4 @@ examples/
|
|||
node_modules
|
||||
.next
|
||||
.git
|
||||
postgres/
|
3
.env.example
Normal file
3
.env.example
Normal 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
4
.gitignore
vendored
|
@ -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
185
README.md
|
@ -1,36 +1,171 @@
|
|||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# pontus-front
|
||||
|
||||
## Getting Started
|
||||
The source code for the p0ntus web frontend.
|
||||
|
||||
First, run the development server:
|
||||
## Introduction
|
||||
|
||||
p0ntus is the successor to LibreCloud, with the same goals as the latter. p0ntus brings simplicity and structure, carrying on the values, structure, and rhythm that LibreCloud offered.
|
||||
|
||||
It's maintained solo, with occasional contributions from others. Because of this, I am severely limited by time. I am able to get to most requests and issues within the day. As it has become a controversial topic, I do use AI tools while programming. They help me so I still have time for other things in life.
|
||||
|
||||
Going into technical details, I built p0ntus with my most familiar stack. It consists of Next.js, Shadcn UI, Drizzle, Postgres, Better Auth, Altcha, and Docker. It is built to be visually simple, with easily extendable code. As always, this project is Unlicensed, meaning the code is available under public domain. I highly encourage modification and forks.
|
||||
|
||||
p0ntus will likely always be untested on Windows. I always encourage using Linux, but macOS works just fine too. Windows is spyware anyway, and you will have a much better experience both hosting and testing p0ntus under Linux.
|
||||
|
||||
## Enterprise Usage
|
||||
|
||||
I do encourage using p0ntus for commercial purposes. While the license doesn't hold you to it, I ask that you follow the same (or similar) values as the original project. They are open to interpretation, but hopefully your moral compass guides you.
|
||||
|
||||
If you are a company or organization in need of support or features, you can contact me at aidan[at]p0ntus[dot]com. All changes made, regardless of compensation, will be published publicly under the Unlicense. Support and feature requests are available to individuals and non-profits for free.
|
||||
|
||||
## Privacy
|
||||
|
||||
p0ntus is great for privacy! No data is collected, and many measures are taken to avoid proprietary software (and the tracking, logging, and invasive measures that come with them), while still providing a great experience to you.
|
||||
|
||||
CAPTCHAs are done with Altcha, which is a personal favorite of mine. It allows us to provide spam-free services, while still preserving your privacy. Proof of work is used instead, and no data is collected as a result.
|
||||
|
||||
p0ntus itself does not collect information in the background without your knowledge, and your account data is represented by what *you, yourself have entered*. We bring a connected experience with as little data collection as possible.
|
||||
|
||||
## Setup with Docker
|
||||
|
||||
I **highly** encourage the use of Docker for self-hosting p0ntus. It is the preferred deployment method for production use, and has the most testing. If you are planning to improve/modify the source code of p0ntus, please use the "Setup for Development" section. It will help you test more efficiently.
|
||||
|
||||
### What you need
|
||||
|
||||
- A server or computer
|
||||
- `git` and [Bun](https://bun.sh)
|
||||
- Docker and Docker Compose
|
||||
|
||||
### The Instructions
|
||||
|
||||
Let's dive in! First, clone the repo:
|
||||
|
||||
```bash
|
||||
git clone https://git.p0ntus.com/pontus/pontus-front
|
||||
```
|
||||
|
||||
For good measure, we'll install dependancies now:
|
||||
|
||||
```bash
|
||||
bun install # or npm
|
||||
```
|
||||
|
||||
Next, you should set your `.env`. You can copy and edit a good working example like so:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
vim .env # or nano
|
||||
```
|
||||
|
||||
Once in the editor of your choice, edit `BETTER_AUTH_URL` with your domain name that you intend to deploy this to. Change the protocol if it applies. If you are developing p0ntus, you should leave this blank. `BETTER_AUTH_SECRET` should be set to a random value.
|
||||
|
||||
You don't need to change anything else for now. We'll come back to `.env` in a second. Save and exit in your editor, then run this command to generate and insert an `ALTCHA_SECRET` into your `.env`:
|
||||
|
||||
```bash
|
||||
bun tools/hmac.ts
|
||||
```
|
||||
|
||||
You can now copy the example Docker Compose file to the project root. You should change the password of the database, and additionally the username and database if you choose.
|
||||
|
||||
```bash
|
||||
cp examples/docker-compose.yml docker-compose.yml
|
||||
```
|
||||
|
||||
Then, reopen `.env` with your choice of editor and change the database URL to the format below. Replace the placeholders with the values you just set in `docker-compose.yml`. The host is `postgres`, unless you changed the name in `docker-compose.yml`, and the port is `5432`.
|
||||
|
||||
`postgres://<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
884
app/admin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
103
app/api/admin/activity/route.ts
Normal file
103
app/api/admin/activity/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
121
app/api/admin/requests/route.ts
Normal file
121
app/api/admin/requests/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
165
app/api/admin/services/route.ts
Normal file
165
app/api/admin/services/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
68
app/api/admin/users/route.ts
Normal file
68
app/api/admin/users/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
4
app/api/auth/[...all]/route.ts
Normal file
4
app/api/auth/[...all]/route.ts
Normal 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
22
app/api/captcha/route.ts
Normal 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
71
app/api/login/route.ts
Normal 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
23
app/api/logout/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
110
app/api/service-requests/route.ts
Normal file
110
app/api/service-requests/route.ts
Normal 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
24
app/api/services/route.ts
Normal 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
102
app/api/signup/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
52
app/api/user-services/route.ts
Normal file
52
app/api/user-services/route.ts
Normal 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
266
app/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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
181
app/login/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
191
app/page.tsx
191
app/page.tsx
|
@ -1,90 +1,127 @@
|
|||
"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">
|
||||
p0ntus
|
||||
</h1>
|
||||
<h3 className="text-xl sm:text-2xl text-center">
|
||||
open source at your fingertips
|
||||
</h3>
|
||||
</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">
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
<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="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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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="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.
|
||||
</p>
|
||||
<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 transition-colors">
|
||||
our servers <TbArrowRight size={20} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-start gap-6 h-full">
|
||||
<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>
|
||||
<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.
|
||||
</p>
|
||||
<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">
|
||||
our servers <TbArrowRight size={20} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-start gap-6 h-full">
|
||||
<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'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.
|
||||
</p>
|
||||
<p className="text-base sm:text-lg text-center">
|
||||
p0ntus exists to show that it is possible to have a free and open set of services that people have fun using.
|
||||
</p>
|
||||
|
||||
<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="text-lg sm:text-xl italic text-center w-full text-gray-600 hidden sm:block">what'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.
|
||||
</p>
|
||||
<p className="text-base sm:text-lg text-center">
|
||||
p0ntus exists to show that it is possible to have a free and open set of services that people have fun using.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
355
app/requests/page.tsx
Normal file
355
app/requests/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
|
@ -1,9 +1,214 @@
|
|||
"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 />
|
||||
<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>
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<div className="animate-pulse text-lg">Loading services...</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<Nav />
|
||||
|
@ -20,68 +225,42 @@ export default function Services() {
|
|||
</h2>
|
||||
</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" />
|
||||
<span className="text-xl sm:text-2xl font-bold">
|
||||
git
|
||||
</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>
|
||||
</Link>
|
||||
</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">
|
||||
{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">
|
||||
{service.name === 'mail' ? 'email' : service.name}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
<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
188
app/signup/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
57
components/core/altcha.tsx
Normal file
57
components/core/altcha.tsx
Normal 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
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
) : 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>
|
||||
<p className="text-sm sm:text-base text-white mb-3">
|
||||
{PriceStatusDesc(service?.priceStatus as "open" | "invite-only" | "by-request", service?.name as string)}
|
||||
</p>
|
||||
</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'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}
|
||||
|
|
|
@ -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
7
db/index.ts
Normal 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
84
db/schema.ts
Normal 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
10
drizzle.config.ts
Normal 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!,
|
||||
},
|
||||
});
|
19
examples/docker-compose.dev.yml
Normal file
19
examples/docker-compose.dev.yml
Normal 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
|
|
@ -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
32
middleware.ts
Normal 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).*)',
|
||||
],
|
||||
};
|
20
package.json
20
package.json
|
@ -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
22
tools/hmac.ts
Normal 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
28
tools/seed-db.ts
Normal 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
4
util/auth-client.ts
Normal 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
27
util/auth.ts
Normal 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
22
util/captcha.ts
Normal 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;
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue