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

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

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

@ -0,0 +1,884 @@
"use client"
import { Nav } from "@/components/core/nav";
import Altcha from "@/components/core/altcha";
import { authClient } from "@/util/auth-client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import {
TbShield,
TbUsers,
TbSend,
TbCheck,
TbX,
TbClock,
TbEdit,
TbNotes,
TbChartLine as TbChart,
TbSettings,
TbTrendingUp,
TbCalendar,
TbEye,
TbUserMinus,
} from "react-icons/tb";
interface ExtendedUser {
id: string;
name: string;
email: string;
emailVerified: boolean;
createdAt: Date;
updatedAt: Date;
image?: string | null;
role?: string;
}
interface User {
id: string;
name: string;
email: string;
emailVerified: boolean;
role: 'user' | 'admin';
createdAt: string;
updatedAt: string;
}
interface ServiceRequest {
id: string;
userId: string;
userName: string;
userEmail: string;
serviceName: string;
serviceDescription: string;
reason: string;
status: 'pending' | 'approved' | 'denied';
adminNotes?: string;
reviewedAt?: string;
createdAt: string;
updatedAt: string;
}
interface Service {
id: string;
name: string;
description: string;
priceStatus: string;
joinLink?: string;
enabled: boolean;
createdAt: string;
updatedAt: string;
users: {
userId: string;
userName: string;
userEmail: string;
grantedAt: string;
}[];
}
interface ActivityData {
requestActivity: Array<{ date: string; count: number; status: string }>;
userActivity: Array<{ date: string; count: number }>;
accessActivity: Array<{ date: string; count: number }>;
recentActivity: Array<{
id: string;
type: string;
description: string;
status: string;
createdAt: string;
userName: string;
serviceName: string;
}>;
servicePopularity: Array<{
serviceName: string;
requestCount: number;
approvedCount: number;
}>;
totals: {
totalRequests: number;
totalUsers: number;
totalAccess: number;
};
period: number;
}
export default function AdminDashboard() {
const router = useRouter();
const { data: session, isPending } = authClient.useSession();
const [mounted, setMounted] = useState(false);
const [accessGranted, setAccessGranted] = useState(false);
const [users, setUsers] = useState<User[]>([]);
const [requests, setRequests] = useState<ServiceRequest[]>([]);
const [services, setServices] = useState<Service[]>([]);
const [activityData, setActivityData] = useState<ActivityData | null>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<'overview' | 'users' | 'requests' | 'services'>('overview');
const [editingRequest, setEditingRequest] = useState<string | null>(null);
const [requestStatus, setRequestStatus] = useState<'pending' | 'approved' | 'denied'>('pending');
const [adminNotes, setAdminNotes] = useState("");
const [selectedService, setSelectedService] = useState<string>("");
const [selectedUser, setSelectedUser] = useState<string>("");
const [editingService, setEditingService] = useState<string | null>(null);
const [serviceSettings, setServiceSettings] = useState({
enabled: true,
priceStatus: "open" as "open" | "invite-only" | "by-request",
description: "",
joinLink: ""
});
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (mounted && !isPending && !session) {
router.push("/login?message=Please sign in to access the admin dashboard");
}
}, [session, isPending, mounted, router]);
useEffect(() => {
if (session && (session.user as ExtendedUser).role !== 'admin') {
router.push("/dashboard?message=Access denied: Admin privileges required");
}
}, [session, router]);
useEffect(() => {
if (session && (session.user as ExtendedUser).role === 'admin' && accessGranted) {
fetchData();
}
}, [session, accessGranted]);
useEffect(() => {
if (session && (session.user as ExtendedUser).role === 'admin' && accessGranted) {
fetchData();
}
}, [activeTab, session, accessGranted]);
const fetchData = async () => {
setLoading(true);
try {
const [usersResponse, requestsResponse, servicesResponse, activityResponse] = await Promise.all([
fetch("/api/admin/users"),
fetch("/api/admin/requests"),
fetch("/api/admin/services"),
fetch("/api/admin/activity?period=7")
]);
if (usersResponse.ok) {
const usersData = await usersResponse.json();
setUsers(usersData.users);
}
if (requestsResponse.ok) {
const requestsData = await requestsResponse.json();
setRequests(requestsData.requests);
}
if (servicesResponse.ok) {
const servicesData = await servicesResponse.json();
setServices(servicesData.services);
}
if (activityResponse.ok) {
const activityResponseData = await activityResponse.json();
setActivityData(activityResponseData);
}
} catch (error) {
console.error("Error fetching admin data:", error);
} finally {
setLoading(false);
}
};
const handleCaptchaVerification = (token: string) => {
if (token) {
setAccessGranted(true);
}
};
const updateUserRole = async (userId: string, newRole: 'user' | 'admin') => {
try {
const response = await fetch("/api/admin/users", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ userId, role: newRole }),
});
if (response.ok) {
setUsers(users.map(user =>
user.id === userId ? { ...user, role: newRole } : user
));
}
} catch (error) {
console.error("Error updating user role:", error);
}
};
const updateRequestStatus = async (requestId: string) => {
try {
const response = await fetch("/api/admin/requests", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
requestId,
status: requestStatus,
adminNotes: adminNotes || undefined
}),
});
if (response.ok) {
setRequests(requests.map(request =>
request.id === requestId
? { ...request, status: requestStatus, adminNotes, reviewedAt: new Date().toISOString() }
: request
));
setEditingRequest(null);
setAdminNotes("");
}
} catch (error) {
console.error("Error updating request:", error);
}
};
const grantServiceAccess = async (userId: string, serviceId: string) => {
try {
const response = await fetch("/api/admin/services", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ action: 'grant', userId, serviceId }),
});
if (response.ok) {
fetchData();
}
} catch (error) {
console.error("Error granting service access:", error);
}
};
const revokeServiceAccess = async (userId: string, serviceId: string) => {
try {
const response = await fetch("/api/admin/services", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ action: 'revoke', userId, serviceId }),
});
if (response.ok) {
fetchData();
}
} catch (error) {
console.error("Error revoking service access:", error);
}
};
const updateService = async (serviceId: string) => {
try {
const response = await fetch("/api/admin/services", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
serviceId,
enabled: serviceSettings.enabled,
priceStatus: serviceSettings.priceStatus,
description: serviceSettings.description,
joinLink: serviceSettings.joinLink
}),
});
if (response.ok) {
setEditingService(null);
fetchData();
}
} catch (error) {
console.error("Error updating service:", error);
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'pending':
return <TbClock className="w-4 h-4 text-yellow-500" />;
case 'approved':
return <TbCheck className="w-4 h-4 text-green-500" />;
case 'denied':
return <TbX className="w-4 h-4 text-red-500" />;
default:
return <TbClock className="w-4 h-4 text-gray-500" />;
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'pending':
return 'text-yellow-600 bg-yellow-50 border-yellow-200';
case 'approved':
return 'text-green-600 bg-green-50 border-green-200';
case 'denied':
return 'text-red-600 bg-red-50 border-red-200';
default:
return 'text-gray-600 bg-gray-50 border-gray-200';
}
};
if (!mounted || isPending) {
return (
<main>
<Nav />
<div className="flex flex-col items-center justify-center min-h-[50vh] px-4">
<div className="animate-pulse text-lg">loading...</div>
</div>
</main>
);
}
if (!session) {
return (
<main>
<Nav />
<div className="flex flex-col items-center justify-center min-h-[50vh] px-4">
<div className="text-lg">redirecting to login...</div>
</div>
</main>
);
}
if ((session.user as ExtendedUser).role !== 'admin') {
return (
<main>
<Nav />
<div className="flex flex-col items-center justify-center min-h-[50vh] px-4">
<div className="text-lg text-red-600">Access denied: Admin privileges required</div>
</div>
</main>
);
}
if (!accessGranted) {
return (
<main>
<Nav />
<div className="max-w-2xl mx-auto px-4 py-8">
<div className="bg-white dark:bg-gray-800 p-8 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="flex flex-col items-center text-center">
<TbShield size={48} className="text-red-500 mb-4" />
<h1 className="text-2xl font-bold mb-4">One More Step</h1>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Please complete the CAPTCHA to access your dashboard.
</p>
<div className="w-full max-w-md">
<Altcha onStateChange={(ev) => {
if ('detail' in ev) {
handleCaptchaVerification(ev.detail.payload || "");
}
}} />
</div>
</div>
</div>
</div>
</main>
);
}
const pendingRequestsCount = requests.filter(r => r.status === 'pending').length;
const totalUsersCount = users.length;
const adminUsersCount = users.filter(u => u.role === 'admin').length;
return (
<main>
<Nav />
<div className="max-w-6xl mx-auto px-4 py-8">
<div className="flex flex-row items-center justify-start gap-3 mb-8">
<TbShield size={32} className="text-red-500" />
<h1 className="text-3xl sm:text-4xl font-bold">Admin</h1>
</div>
<div className="flex space-x-1 mb-8 bg-gray-100 dark:bg-gray-700 p-1 rounded-lg">
{[
{ id: 'overview', label: 'Overview', icon: TbChart },
{ id: 'users', label: 'Users', icon: TbUsers },
{ id: 'requests', label: 'Requests', icon: TbSend },
{ id: 'services', label: 'Services', icon: TbSettings }
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as 'overview' | 'users' | 'requests' | 'services')}
className={`flex items-center gap-2 px-4 py-2 rounded-md transition-colors ${
activeTab === tab.id
? 'bg-white dark:bg-gray-800 text-blue-600 shadow-sm'
: 'text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white'
}`}
>
<tab.icon className="w-4 h-4" />
{tab.label}
</button>
))}
</div>
{loading && (
<div className="text-center py-8">
<div className="animate-pulse">loading data...</div>
</div>
)}
{!loading && activeTab === 'overview' && (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Total Users</p>
<p className="text-2xl font-bold">{totalUsersCount}</p>
</div>
<TbUsers className="w-8 h-8 text-blue-500" />
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Admin Users</p>
<p className="text-2xl font-bold">{adminUsersCount}</p>
</div>
<TbShield className="w-8 h-8 text-red-500" />
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Pending Requests</p>
<p className="text-2xl font-bold">{pendingRequestsCount}</p>
</div>
<TbClock className="w-8 h-8 text-yellow-500" />
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Total Services</p>
<p className="text-2xl font-bold">{services.length}</p>
</div>
<TbSettings className="w-8 h-8 text-purple-500" />
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<TbTrendingUp className="w-5 h-5" />
Popular Services
</h3>
<div className="space-y-3">
{activityData?.servicePopularity?.slice(0, 5).map((service, index) => (
<div key={service.serviceName} className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-gray-500">#{index + 1}</span>
<span className="font-medium">{service.serviceName}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">{service.requestCount} requests</span>
<div className={`w-3 h-3 rounded-full ${
service.approvedCount > service.requestCount / 2 ? 'bg-green-500' : 'bg-yellow-500'
}`} />
</div>
</div>
))}
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<TbCalendar className="w-5 h-5" />
Recent Activity
</h3>
<div className="space-y-3 max-h-64 overflow-y-auto">
{activityData?.recentActivity?.slice(0, 10).map((activity) => (
<div key={activity.id} className="flex items-start gap-3 pb-3 border-b border-gray-100 dark:border-gray-700 last:border-b-0">
<div className={`w-2 h-2 rounded-full mt-2 ${
activity.status === 'approved' ? 'bg-green-500' :
activity.status === 'denied' ? 'bg-red-500' : 'bg-yellow-500'
}`} />
<div className="flex-1">
<p className="text-sm">{activity.description}</p>
<p className="text-xs text-gray-500">
{new Date(activity.createdAt).toLocaleDateString()}
</p>
</div>
</div>
))}
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold mb-4">Last 7 Days Overview</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="text-center">
<p className="text-2xl font-bold text-blue-600">{activityData?.totals?.totalRequests || 0}</p>
<p className="text-sm text-gray-600">New Requests</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-green-600">{activityData?.totals?.totalUsers || 0}</p>
<p className="text-sm text-gray-600">New Users</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-purple-600">{activityData?.totals?.totalAccess || 0}</p>
<p className="text-sm text-gray-600">Access Granted</p>
</div>
</div>
</div>
</div>
)}
{!loading && activeTab === 'services' && (
<div className="space-y-6">
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold mb-4">Grant Service Access</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<select
value={selectedUser}
onChange={(e) => setSelectedUser(e.target.value)}
className="p-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700"
>
<option value="">Select User</option>
{users.map((user) => (
<option key={user.id} value={user.id}>{user.name} ({user.email})</option>
))}
</select>
<select
value={selectedService}
onChange={(e) => setSelectedService(e.target.value)}
className="p-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700"
>
<option value="">Select Service</option>
{services.map((service) => (
<option key={service.id} value={service.id}>{service.name}</option>
))}
</select>
<button
onClick={() => {
if (selectedUser && selectedService) {
grantServiceAccess(selectedUser, selectedService);
setSelectedUser("");
setSelectedService("");
}
}}
disabled={!selectedUser || !selectedService}
className="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 disabled:bg-gray-400 transition-colors"
>
Grant Access
</button>
</div>
</div>
<div className="space-y-4">
{services.map((service) => (
<div key={service.id} className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-semibold">{service.name}</h3>
<p className="text-gray-600 dark:text-gray-400">{service.description}</p>
<div className="flex items-center gap-4 mt-2">
<span className={`px-2 py-1 rounded-full text-xs ${
service.enabled ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{service.enabled ? 'Enabled' : 'Disabled'}
</span>
<span className="text-sm text-gray-500">
{service.priceStatus}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<div className="text-right">
<p className="text-sm text-gray-600">{service.users.length} users</p>
<p className="text-xs text-gray-500">with access</p>
</div>
<button
onClick={() => {
setEditingService(service.id);
setServiceSettings({
enabled: service.enabled,
priceStatus: service.priceStatus as "open" | "invite-only" | "by-request",
description: service.description,
joinLink: service.joinLink || ""
});
}}
className="text-blue-600 hover:text-blue-800 text-sm flex items-center gap-1"
>
<TbEdit className="w-4 h-4" />
Edit
</button>
</div>
</div>
{editingService === service.id ? (
<div className="space-y-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg mb-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Status</label>
<select
value={serviceSettings.enabled ? 'enabled' : 'disabled'}
onChange={(e) => setServiceSettings(prev => ({ ...prev, enabled: e.target.value === 'enabled' }))}
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700"
>
<option value="enabled">Enabled</option>
<option value="disabled">Disabled</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Access Type</label>
<select
value={serviceSettings.priceStatus}
onChange={(e) => setServiceSettings(prev => ({ ...prev, priceStatus: e.target.value as "open" | "invite-only" | "by-request" }))}
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700"
>
<option value="open">Open</option>
<option value="invite-only">Invite Only</option>
<option value="by-request">By Request</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">Description</label>
<textarea
value={serviceSettings.description}
onChange={(e) => setServiceSettings(prev => ({ ...prev, description: e.target.value }))}
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700"
rows={2}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Join Link</label>
<input
type="url"
value={serviceSettings.joinLink}
onChange={(e) => setServiceSettings(prev => ({ ...prev, joinLink: e.target.value }))}
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700"
placeholder="https://..."
/>
</div>
<div className="flex gap-2">
<button
onClick={() => updateService(service.id)}
className="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 transition-colors"
>
Save Changes
</button>
<button
onClick={() => setEditingService(null)}
className="bg-gray-500 text-white px-4 py-2 rounded-md hover:bg-gray-600 transition-colors"
>
Cancel
</button>
</div>
</div>
) : null}
{service.users.length > 0 && (
<div>
<h4 className="font-medium mb-3 flex items-center gap-2">
<TbEye className="w-4 h-4" />
Users with Access
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{service.users.map((user) => (
<div key={user.userId} className="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div>
<p className="font-medium text-sm">{user.userName}</p>
<p className="text-xs text-gray-500">{user.userEmail}</p>
</div>
<button
onClick={() => revokeServiceAccess(user.userId, service.id)}
className="text-red-600 hover:text-red-800 text-xs flex items-center gap-1"
>
<TbUserMinus className="w-3 h-3" />
Revoke
</button>
</div>
))}
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
{!loading && activeTab === 'users' && (
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div className="overflow-x-auto -mt-2">
<table className="min-w-full">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-600">
<th className="text-left py-3 px-4 font-medium">Name</th>
<th className="text-left py-3 px-4 font-medium">Email</th>
<th className="text-left py-3 px-4 font-medium">Role</th>
<th className="text-left py-3 px-4 font-medium">Verified</th>
<th className="text-left py-3 px-4 font-medium">Joined</th>
<th className="text-left py-3 px-4 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-b border-gray-100 dark:border-gray-700">
<td className="py-3 px-4">{user.name}</td>
<td className="py-3 px-4">{user.email}</td>
<td className="py-3 px-4">
<span className={`px-2 py-1 rounded-full text-xs ${
user.role === 'admin'
? 'bg-red-100 text-red-700'
: 'bg-blue-100 text-blue-700'
}`}>
{user.role}
</span>
</td>
<td className="py-3 px-4">
<span className={`px-2 py-1 rounded-full text-xs ${
user.emailVerified
? 'bg-green-100 text-green-700'
: 'bg-yellow-100 text-yellow-700'
}`}>
{user.emailVerified ? 'Yes' : 'No'}
</span>
</td>
<td className="py-3 px-4">
{new Date(user.createdAt).toLocaleDateString()}
</td>
<td className="py-3 px-4">
<select
value={user.role}
onChange={(e) => updateUserRole(user.id, e.target.value as 'user' | 'admin')}
className="text-sm border border-gray-300 dark:border-gray-600 rounded px-2 py-1 dark:bg-gray-700"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{!loading && activeTab === 'requests' && (
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<h2 className="text-xl font-semibold mb-4">Service Requests</h2>
<div className="space-y-4">
{requests.map((request) => (
<div key={request.id} className="border border-gray-200 dark:border-gray-600 rounded-lg p-4">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="font-semibold">{request.userName}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">{request.userEmail}</p>
<p className="text-sm mt-1">
Requesting access to <strong>{request.serviceName}</strong>
</p>
</div>
<div className={`flex items-center gap-2 px-3 py-1 rounded-full border ${getStatusColor(request.status)}`}>
{getStatusIcon(request.status)}
<span className="text-sm font-medium capitalize">{request.status}</span>
</div>
</div>
<div className="mb-3">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-1">Reason:</p>
<p className="text-sm">{request.reason}</p>
</div>
{request.adminNotes && (
<div className="mb-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-1 flex items-center gap-1">
<TbNotes className="w-4 h-4" />
Admin Notes:
</p>
<p className="text-sm">{request.adminNotes}</p>
</div>
)}
{editingRequest === request.id ? (
<div className="space-y-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<div>
<label className="block text-sm font-medium mb-1">Status</label>
<select
value={requestStatus}
onChange={(e) => setRequestStatus(e.target.value as 'pending' | 'approved' | 'denied')}
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700"
>
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="denied">Denied</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Admin Notes</label>
<textarea
value={adminNotes}
onChange={(e) => setAdminNotes(e.target.value)}
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700"
rows={3}
placeholder="Add notes for the user..."
/>
</div>
<div className="flex gap-2">
<button
onClick={() => updateRequestStatus(request.id)}
className="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 transition-colors"
>
Update
</button>
<button
onClick={() => setEditingRequest(null)}
className="bg-gray-500 text-white px-4 py-2 rounded-md hover:bg-gray-600 transition-colors"
>
Cancel
</button>
</div>
</div>
) : (
<div className="flex items-center justify-between">
<div className="text-xs text-gray-500">
Submitted: {new Date(request.createdAt).toLocaleDateString()}
{request.reviewedAt && (
<span className="ml-4">
Reviewed: {new Date(request.reviewedAt).toLocaleDateString()}
</span>
)}
</div>
<button
onClick={() => {
setEditingRequest(request.id);
setRequestStatus(request.status);
setAdminNotes(request.adminNotes || "");
}}
className="flex items-center gap-1 text-blue-600 hover:text-blue-800 text-sm"
>
<TbEdit className="w-4 h-4" />
Edit
</button>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
</main>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

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

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

View file

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

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

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

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

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

View file

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

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

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

View file

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

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

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

View file

@ -1,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&apos;s the point?</h3>
<div className="flex flex-col items-center gap-6 mt-6">
<p className="text-base sm:text-lg text-center">
everything today includes microtransactions, and we were fed up with it.
</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&apos;s the point?</h3>
<div className="flex flex-col items-center gap-6 mt-6">
<p className="text-base sm:text-lg text-center">
everything today includes microtransactions, and we were fed up with it.
</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
View file

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

View file

@ -1,9 +1,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
View file

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