adds user accounts, service requests, dashboard, admin panel, better layout, db+altcha+auth support
This commit is contained in:
parent
dfbc3cade9
commit
0043a5bf3c
40 changed files with 3981 additions and 188 deletions
884
app/admin/page.tsx
Normal file
884
app/admin/page.tsx
Normal file
|
@ -0,0 +1,884 @@
|
|||
"use client"
|
||||
|
||||
import { Nav } from "@/components/core/nav";
|
||||
import Altcha from "@/components/core/altcha";
|
||||
import { authClient } from "@/util/auth-client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
TbShield,
|
||||
TbUsers,
|
||||
TbSend,
|
||||
TbCheck,
|
||||
TbX,
|
||||
TbClock,
|
||||
TbEdit,
|
||||
TbNotes,
|
||||
TbChartLine as TbChart,
|
||||
TbSettings,
|
||||
TbTrendingUp,
|
||||
TbCalendar,
|
||||
TbEye,
|
||||
TbUserMinus,
|
||||
} from "react-icons/tb";
|
||||
|
||||
interface ExtendedUser {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
emailVerified: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
image?: string | null;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
emailVerified: boolean;
|
||||
role: 'user' | 'admin';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface ServiceRequest {
|
||||
id: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
userEmail: string;
|
||||
serviceName: string;
|
||||
serviceDescription: string;
|
||||
reason: string;
|
||||
status: 'pending' | 'approved' | 'denied';
|
||||
adminNotes?: string;
|
||||
reviewedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface Service {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
priceStatus: string;
|
||||
joinLink?: string;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
users: {
|
||||
userId: string;
|
||||
userName: string;
|
||||
userEmail: string;
|
||||
grantedAt: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface ActivityData {
|
||||
requestActivity: Array<{ date: string; count: number; status: string }>;
|
||||
userActivity: Array<{ date: string; count: number }>;
|
||||
accessActivity: Array<{ date: string; count: number }>;
|
||||
recentActivity: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
description: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
userName: string;
|
||||
serviceName: string;
|
||||
}>;
|
||||
servicePopularity: Array<{
|
||||
serviceName: string;
|
||||
requestCount: number;
|
||||
approvedCount: number;
|
||||
}>;
|
||||
totals: {
|
||||
totalRequests: number;
|
||||
totalUsers: number;
|
||||
totalAccess: number;
|
||||
};
|
||||
period: number;
|
||||
}
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const router = useRouter();
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [accessGranted, setAccessGranted] = useState(false);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [requests, setRequests] = useState<ServiceRequest[]>([]);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [activityData, setActivityData] = useState<ActivityData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'users' | 'requests' | 'services'>('overview');
|
||||
const [editingRequest, setEditingRequest] = useState<string | null>(null);
|
||||
const [requestStatus, setRequestStatus] = useState<'pending' | 'approved' | 'denied'>('pending');
|
||||
const [adminNotes, setAdminNotes] = useState("");
|
||||
const [selectedService, setSelectedService] = useState<string>("");
|
||||
const [selectedUser, setSelectedUser] = useState<string>("");
|
||||
const [editingService, setEditingService] = useState<string | null>(null);
|
||||
const [serviceSettings, setServiceSettings] = useState({
|
||||
enabled: true,
|
||||
priceStatus: "open" as "open" | "invite-only" | "by-request",
|
||||
description: "",
|
||||
joinLink: ""
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (mounted && !isPending && !session) {
|
||||
router.push("/login?message=Please sign in to access the admin dashboard");
|
||||
}
|
||||
}, [session, isPending, mounted, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (session && (session.user as ExtendedUser).role !== 'admin') {
|
||||
router.push("/dashboard?message=Access denied: Admin privileges required");
|
||||
}
|
||||
}, [session, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (session && (session.user as ExtendedUser).role === 'admin' && accessGranted) {
|
||||
fetchData();
|
||||
}
|
||||
}, [session, accessGranted]);
|
||||
|
||||
useEffect(() => {
|
||||
if (session && (session.user as ExtendedUser).role === 'admin' && accessGranted) {
|
||||
fetchData();
|
||||
}
|
||||
}, [activeTab, session, accessGranted]);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [usersResponse, requestsResponse, servicesResponse, activityResponse] = await Promise.all([
|
||||
fetch("/api/admin/users"),
|
||||
fetch("/api/admin/requests"),
|
||||
fetch("/api/admin/services"),
|
||||
fetch("/api/admin/activity?period=7")
|
||||
]);
|
||||
|
||||
if (usersResponse.ok) {
|
||||
const usersData = await usersResponse.json();
|
||||
setUsers(usersData.users);
|
||||
}
|
||||
|
||||
if (requestsResponse.ok) {
|
||||
const requestsData = await requestsResponse.json();
|
||||
setRequests(requestsData.requests);
|
||||
}
|
||||
|
||||
if (servicesResponse.ok) {
|
||||
const servicesData = await servicesResponse.json();
|
||||
setServices(servicesData.services);
|
||||
}
|
||||
|
||||
if (activityResponse.ok) {
|
||||
const activityResponseData = await activityResponse.json();
|
||||
setActivityData(activityResponseData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching admin data:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCaptchaVerification = (token: string) => {
|
||||
if (token) {
|
||||
setAccessGranted(true);
|
||||
}
|
||||
};
|
||||
|
||||
const updateUserRole = async (userId: string, newRole: 'user' | 'admin') => {
|
||||
try {
|
||||
const response = await fetch("/api/admin/users", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ userId, role: newRole }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setUsers(users.map(user =>
|
||||
user.id === userId ? { ...user, role: newRole } : user
|
||||
));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating user role:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const updateRequestStatus = async (requestId: string) => {
|
||||
try {
|
||||
const response = await fetch("/api/admin/requests", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
requestId,
|
||||
status: requestStatus,
|
||||
adminNotes: adminNotes || undefined
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setRequests(requests.map(request =>
|
||||
request.id === requestId
|
||||
? { ...request, status: requestStatus, adminNotes, reviewedAt: new Date().toISOString() }
|
||||
: request
|
||||
));
|
||||
setEditingRequest(null);
|
||||
setAdminNotes("");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating request:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const grantServiceAccess = async (userId: string, serviceId: string) => {
|
||||
try {
|
||||
const response = await fetch("/api/admin/services", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ action: 'grant', userId, serviceId }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchData();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error granting service access:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const revokeServiceAccess = async (userId: string, serviceId: string) => {
|
||||
try {
|
||||
const response = await fetch("/api/admin/services", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ action: 'revoke', userId, serviceId }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchData();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error revoking service access:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const updateService = async (serviceId: string) => {
|
||||
try {
|
||||
const response = await fetch("/api/admin/services", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
serviceId,
|
||||
enabled: serviceSettings.enabled,
|
||||
priceStatus: serviceSettings.priceStatus,
|
||||
description: serviceSettings.description,
|
||||
joinLink: serviceSettings.joinLink
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setEditingService(null);
|
||||
fetchData();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating service:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <TbClock className="w-4 h-4 text-yellow-500" />;
|
||||
case 'approved':
|
||||
return <TbCheck className="w-4 h-4 text-green-500" />;
|
||||
case 'denied':
|
||||
return <TbX className="w-4 h-4 text-red-500" />;
|
||||
default:
|
||||
return <TbClock className="w-4 h-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'text-yellow-600 bg-yellow-50 border-yellow-200';
|
||||
case 'approved':
|
||||
return 'text-green-600 bg-green-50 border-green-200';
|
||||
case 'denied':
|
||||
return 'text-red-600 bg-red-50 border-red-200';
|
||||
default:
|
||||
return 'text-gray-600 bg-gray-50 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
if (!mounted || isPending) {
|
||||
return (
|
||||
<main>
|
||||
<Nav />
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] px-4">
|
||||
<div className="animate-pulse text-lg">loading...</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<main>
|
||||
<Nav />
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] px-4">
|
||||
<div className="text-lg">redirecting to login...</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if ((session.user as ExtendedUser).role !== 'admin') {
|
||||
return (
|
||||
<main>
|
||||
<Nav />
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] px-4">
|
||||
<div className="text-lg text-red-600">Access denied: Admin privileges required</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!accessGranted) {
|
||||
return (
|
||||
<main>
|
||||
<Nav />
|
||||
<div className="max-w-2xl mx-auto px-4 py-8">
|
||||
<div className="bg-white dark:bg-gray-800 p-8 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<TbShield size={48} className="text-red-500 mb-4" />
|
||||
<h1 className="text-2xl font-bold mb-4">One More Step</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Please complete the CAPTCHA to access your dashboard.
|
||||
</p>
|
||||
<div className="w-full max-w-md">
|
||||
<Altcha onStateChange={(ev) => {
|
||||
if ('detail' in ev) {
|
||||
handleCaptchaVerification(ev.detail.payload || "");
|
||||
}
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const pendingRequestsCount = requests.filter(r => r.status === 'pending').length;
|
||||
const totalUsersCount = users.length;
|
||||
const adminUsersCount = users.filter(u => u.role === 'admin').length;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<Nav />
|
||||
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||
<div className="flex flex-row items-center justify-start gap-3 mb-8">
|
||||
<TbShield size={32} className="text-red-500" />
|
||||
<h1 className="text-3xl sm:text-4xl font-bold">Admin</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-1 mb-8 bg-gray-100 dark:bg-gray-700 p-1 rounded-lg">
|
||||
{[
|
||||
{ id: 'overview', label: 'Overview', icon: TbChart },
|
||||
{ id: 'users', label: 'Users', icon: TbUsers },
|
||||
{ id: 'requests', label: 'Requests', icon: TbSend },
|
||||
{ id: 'services', label: 'Services', icon: TbSettings }
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as 'overview' | 'users' | 'requests' | 'services')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-md transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-white dark:bg-gray-800 text-blue-600 shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-pulse">loading data...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && activeTab === 'overview' && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Total Users</p>
|
||||
<p className="text-2xl font-bold">{totalUsersCount}</p>
|
||||
</div>
|
||||
<TbUsers className="w-8 h-8 text-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Admin Users</p>
|
||||
<p className="text-2xl font-bold">{adminUsersCount}</p>
|
||||
</div>
|
||||
<TbShield className="w-8 h-8 text-red-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Pending Requests</p>
|
||||
<p className="text-2xl font-bold">{pendingRequestsCount}</p>
|
||||
</div>
|
||||
<TbClock className="w-8 h-8 text-yellow-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Total Services</p>
|
||||
<p className="text-2xl font-bold">{services.length}</p>
|
||||
</div>
|
||||
<TbSettings className="w-8 h-8 text-purple-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<TbTrendingUp className="w-5 h-5" />
|
||||
Popular Services
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{activityData?.servicePopularity?.slice(0, 5).map((service, index) => (
|
||||
<div key={service.serviceName} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-gray-500">#{index + 1}</span>
|
||||
<span className="font-medium">{service.serviceName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">{service.requestCount} requests</span>
|
||||
<div className={`w-3 h-3 rounded-full ${
|
||||
service.approvedCount > service.requestCount / 2 ? 'bg-green-500' : 'bg-yellow-500'
|
||||
}`} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<TbCalendar className="w-5 h-5" />
|
||||
Recent Activity
|
||||
</h3>
|
||||
<div className="space-y-3 max-h-64 overflow-y-auto">
|
||||
{activityData?.recentActivity?.slice(0, 10).map((activity) => (
|
||||
<div key={activity.id} className="flex items-start gap-3 pb-3 border-b border-gray-100 dark:border-gray-700 last:border-b-0">
|
||||
<div className={`w-2 h-2 rounded-full mt-2 ${
|
||||
activity.status === 'approved' ? 'bg-green-500' :
|
||||
activity.status === 'denied' ? 'bg-red-500' : 'bg-yellow-500'
|
||||
}`} />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm">{activity.description}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{new Date(activity.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold mb-4">Last 7 Days Overview</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-blue-600">{activityData?.totals?.totalRequests || 0}</p>
|
||||
<p className="text-sm text-gray-600">New Requests</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-green-600">{activityData?.totals?.totalUsers || 0}</p>
|
||||
<p className="text-sm text-gray-600">New Users</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-purple-600">{activityData?.totals?.totalAccess || 0}</p>
|
||||
<p className="text-sm text-gray-600">Access Granted</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && activeTab === 'services' && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold mb-4">Grant Service Access</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<select
|
||||
value={selectedUser}
|
||||
onChange={(e) => setSelectedUser(e.target.value)}
|
||||
className="p-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700"
|
||||
>
|
||||
<option value="">Select User</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>{user.name} ({user.email})</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={selectedService}
|
||||
onChange={(e) => setSelectedService(e.target.value)}
|
||||
className="p-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700"
|
||||
>
|
||||
<option value="">Select Service</option>
|
||||
{services.map((service) => (
|
||||
<option key={service.id} value={service.id}>{service.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (selectedUser && selectedService) {
|
||||
grantServiceAccess(selectedUser, selectedService);
|
||||
setSelectedUser("");
|
||||
setSelectedService("");
|
||||
}
|
||||
}}
|
||||
disabled={!selectedUser || !selectedService}
|
||||
className="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 disabled:bg-gray-400 transition-colors"
|
||||
>
|
||||
Grant Access
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{services.map((service) => (
|
||||
<div key={service.id} className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{service.name}</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">{service.description}</p>
|
||||
<div className="flex items-center gap-4 mt-2">
|
||||
<span className={`px-2 py-1 rounded-full text-xs ${
|
||||
service.enabled ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{service.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{service.priceStatus}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-gray-600">{service.users.length} users</p>
|
||||
<p className="text-xs text-gray-500">with access</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingService(service.id);
|
||||
setServiceSettings({
|
||||
enabled: service.enabled,
|
||||
priceStatus: service.priceStatus as "open" | "invite-only" | "by-request",
|
||||
description: service.description,
|
||||
joinLink: service.joinLink || ""
|
||||
});
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-800 text-sm flex items-center gap-1"
|
||||
>
|
||||
<TbEdit className="w-4 h-4" />
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editingService === service.id ? (
|
||||
<div className="space-y-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg mb-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Status</label>
|
||||
<select
|
||||
value={serviceSettings.enabled ? 'enabled' : 'disabled'}
|
||||
onChange={(e) => setServiceSettings(prev => ({ ...prev, enabled: e.target.value === 'enabled' }))}
|
||||
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700"
|
||||
>
|
||||
<option value="enabled">Enabled</option>
|
||||
<option value="disabled">Disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Access Type</label>
|
||||
<select
|
||||
value={serviceSettings.priceStatus}
|
||||
onChange={(e) => setServiceSettings(prev => ({ ...prev, priceStatus: e.target.value as "open" | "invite-only" | "by-request" }))}
|
||||
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700"
|
||||
>
|
||||
<option value="open">Open</option>
|
||||
<option value="invite-only">Invite Only</option>
|
||||
<option value="by-request">By Request</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Description</label>
|
||||
<textarea
|
||||
value={serviceSettings.description}
|
||||
onChange={(e) => setServiceSettings(prev => ({ ...prev, description: e.target.value }))}
|
||||
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Join Link</label>
|
||||
<input
|
||||
type="url"
|
||||
value={serviceSettings.joinLink}
|
||||
onChange={(e) => setServiceSettings(prev => ({ ...prev, joinLink: e.target.value }))}
|
||||
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => updateService(service.id)}
|
||||
className="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingService(null)}
|
||||
className="bg-gray-500 text-white px-4 py-2 rounded-md hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{service.users.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium mb-3 flex items-center gap-2">
|
||||
<TbEye className="w-4 h-4" />
|
||||
Users with Access
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{service.users.map((user) => (
|
||||
<div key={user.userId} className="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium text-sm">{user.userName}</p>
|
||||
<p className="text-xs text-gray-500">{user.userEmail}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => revokeServiceAccess(user.userId, service.id)}
|
||||
className="text-red-600 hover:text-red-800 text-xs flex items-center gap-1"
|
||||
>
|
||||
<TbUserMinus className="w-3 h-3" />
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && activeTab === 'users' && (
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div className="overflow-x-auto -mt-2">
|
||||
<table className="min-w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-600">
|
||||
<th className="text-left py-3 px-4 font-medium">Name</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Email</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Role</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Verified</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Joined</th>
|
||||
<th className="text-left py-3 px-4 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="border-b border-gray-100 dark:border-gray-700">
|
||||
<td className="py-3 px-4">{user.name}</td>
|
||||
<td className="py-3 px-4">{user.email}</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className={`px-2 py-1 rounded-full text-xs ${
|
||||
user.role === 'admin'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-blue-100 text-blue-700'
|
||||
}`}>
|
||||
{user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className={`px-2 py-1 rounded-full text-xs ${
|
||||
user.emailVerified
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{user.emailVerified ? 'Yes' : 'No'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<select
|
||||
value={user.role}
|
||||
onChange={(e) => updateUserRole(user.id, e.target.value as 'user' | 'admin')}
|
||||
className="text-sm border border-gray-300 dark:border-gray-600 rounded px-2 py-1 dark:bg-gray-700"
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && activeTab === 'requests' && (
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-xl font-semibold mb-4">Service Requests</h2>
|
||||
<div className="space-y-4">
|
||||
{requests.map((request) => (
|
||||
<div key={request.id} className="border border-gray-200 dark:border-gray-600 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="font-semibold">{request.userName}</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{request.userEmail}</p>
|
||||
<p className="text-sm mt-1">
|
||||
Requesting access to <strong>{request.serviceName}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className={`flex items-center gap-2 px-3 py-1 rounded-full border ${getStatusColor(request.status)}`}>
|
||||
{getStatusIcon(request.status)}
|
||||
<span className="text-sm font-medium capitalize">{request.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-1">Reason:</p>
|
||||
<p className="text-sm">{request.reason}</p>
|
||||
</div>
|
||||
|
||||
{request.adminNotes && (
|
||||
<div className="mb-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-1 flex items-center gap-1">
|
||||
<TbNotes className="w-4 h-4" />
|
||||
Admin Notes:
|
||||
</p>
|
||||
<p className="text-sm">{request.adminNotes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingRequest === request.id ? (
|
||||
<div className="space-y-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Status</label>
|
||||
<select
|
||||
value={requestStatus}
|
||||
onChange={(e) => setRequestStatus(e.target.value as 'pending' | 'approved' | 'denied')}
|
||||
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700"
|
||||
>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="denied">Denied</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Admin Notes</label>
|
||||
<textarea
|
||||
value={adminNotes}
|
||||
onChange={(e) => setAdminNotes(e.target.value)}
|
||||
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700"
|
||||
rows={3}
|
||||
placeholder="Add notes for the user..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => updateRequestStatus(request.id)}
|
||||
className="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingRequest(null)}
|
||||
className="bg-gray-500 text-white px-4 py-2 rounded-md hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs text-gray-500">
|
||||
Submitted: {new Date(request.createdAt).toLocaleDateString()}
|
||||
{request.reviewedAt && (
|
||||
<span className="ml-4">
|
||||
Reviewed: {new Date(request.reviewedAt).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingRequest(request.id);
|
||||
setRequestStatus(request.status);
|
||||
setAdminNotes(request.adminNotes || "");
|
||||
}}
|
||||
className="flex items-center gap-1 text-blue-600 hover:text-blue-800 text-sm"
|
||||
>
|
||||
<TbEdit className="w-4 h-4" />
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
103
app/api/admin/activity/route.ts
Normal file
103
app/api/admin/activity/route.ts
Normal file
|
@ -0,0 +1,103 @@
|
|||
import { db } from "@/db";
|
||||
import { serviceRequests, user, userServices, services } from "@/db/schema";
|
||||
import { auth } from "@/util/auth";
|
||||
import { eq, gte, desc, sql } from "drizzle-orm";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers
|
||||
});
|
||||
|
||||
if (!session || session.user.role !== 'admin') {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const period = url.searchParams.get('period') || '7';
|
||||
const daysAgo = parseInt(period);
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - daysAgo);
|
||||
|
||||
const requestActivity = await db.select({
|
||||
date: sql<string>`DATE(${serviceRequests.createdAt})`,
|
||||
count: sql<number>`COUNT(*)`,
|
||||
status: serviceRequests.status
|
||||
})
|
||||
.from(serviceRequests)
|
||||
.where(gte(serviceRequests.createdAt, startDate))
|
||||
.groupBy(sql`DATE(${serviceRequests.createdAt})`, serviceRequests.status)
|
||||
.orderBy(sql`DATE(${serviceRequests.createdAt})`);
|
||||
|
||||
const userActivity = await db.select({
|
||||
date: sql<string>`DATE(${user.createdAt})`,
|
||||
count: sql<number>`COUNT(*)`
|
||||
})
|
||||
.from(user)
|
||||
.where(gte(user.createdAt, startDate))
|
||||
.groupBy(sql`DATE(${user.createdAt})`)
|
||||
.orderBy(sql`DATE(${user.createdAt})`);
|
||||
|
||||
const accessActivity = await db.select({
|
||||
date: sql<string>`DATE(${userServices.grantedAt})`,
|
||||
count: sql<number>`COUNT(*)`
|
||||
})
|
||||
.from(userServices)
|
||||
.where(gte(userServices.grantedAt, startDate))
|
||||
.groupBy(sql`DATE(${userServices.grantedAt})`)
|
||||
.orderBy(sql`DATE(${userServices.grantedAt})`);
|
||||
|
||||
const recentActivity = await db.select({
|
||||
id: serviceRequests.id,
|
||||
type: sql<string>`'request'`,
|
||||
description: sql<string>`CONCAT(${user.name}, ' requested access to ', ${services.name})`,
|
||||
status: serviceRequests.status,
|
||||
createdAt: serviceRequests.createdAt,
|
||||
userName: user.name,
|
||||
serviceName: services.name
|
||||
})
|
||||
.from(serviceRequests)
|
||||
.innerJoin(user, eq(serviceRequests.userId, user.id))
|
||||
.innerJoin(services, eq(serviceRequests.serviceId, services.id))
|
||||
.where(gte(serviceRequests.createdAt, startDate))
|
||||
.orderBy(desc(serviceRequests.createdAt))
|
||||
.limit(20);
|
||||
|
||||
const servicePopularity = await db.select({
|
||||
serviceName: services.name,
|
||||
requestCount: sql<number>`COUNT(${serviceRequests.id})`,
|
||||
approvedCount: sql<number>`COUNT(CASE WHEN ${serviceRequests.status} = 'approved' THEN 1 END)`
|
||||
})
|
||||
.from(services)
|
||||
.leftJoin(serviceRequests, eq(services.id, serviceRequests.serviceId))
|
||||
.where(gte(serviceRequests.createdAt, startDate))
|
||||
.groupBy(services.id, services.name)
|
||||
.orderBy(sql`COUNT(${serviceRequests.id}) DESC`)
|
||||
.limit(10);
|
||||
|
||||
const totals = await db.select({
|
||||
totalRequests: sql<number>`COUNT(DISTINCT ${serviceRequests.id})`,
|
||||
totalUsers: sql<number>`COUNT(DISTINCT ${user.id})`,
|
||||
totalAccess: sql<number>`COUNT(DISTINCT ${userServices.id})`
|
||||
})
|
||||
.from(serviceRequests)
|
||||
.fullJoin(user, gte(user.createdAt, startDate))
|
||||
.fullJoin(userServices, gte(userServices.grantedAt, startDate))
|
||||
.where(gte(serviceRequests.createdAt, startDate));
|
||||
|
||||
return Response.json({
|
||||
requestActivity,
|
||||
userActivity,
|
||||
accessActivity,
|
||||
recentActivity,
|
||||
servicePopularity,
|
||||
totals: totals[0] || { totalRequests: 0, totalUsers: 0, totalAccess: 0 },
|
||||
period: daysAgo
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error fetching activity data:", error);
|
||||
return Response.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
121
app/api/admin/requests/route.ts
Normal file
121
app/api/admin/requests/route.ts
Normal file
|
@ -0,0 +1,121 @@
|
|||
import { db } from "@/db";
|
||||
import { serviceRequests, services, user, userServices } from "@/db/schema";
|
||||
import { auth } from "@/util/auth";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { NextRequest } from "next/server";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers
|
||||
});
|
||||
|
||||
if (!session || session.user.role !== 'admin') {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const allRequests = await db.select({
|
||||
id: serviceRequests.id,
|
||||
reason: serviceRequests.reason,
|
||||
status: serviceRequests.status,
|
||||
adminNotes: serviceRequests.adminNotes,
|
||||
reviewedAt: serviceRequests.reviewedAt,
|
||||
createdAt: serviceRequests.createdAt,
|
||||
updatedAt: serviceRequests.updatedAt,
|
||||
userId: serviceRequests.userId,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
serviceName: services.name,
|
||||
serviceDescription: services.description
|
||||
})
|
||||
.from(serviceRequests)
|
||||
.innerJoin(services, eq(serviceRequests.serviceId, services.id))
|
||||
.innerJoin(user, eq(serviceRequests.userId, user.id))
|
||||
.orderBy(serviceRequests.createdAt);
|
||||
|
||||
return Response.json({ requests: allRequests });
|
||||
} catch (error) {
|
||||
console.error("Error fetching admin requests:", error);
|
||||
return Response.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers
|
||||
});
|
||||
|
||||
if (!session || session.user.role !== 'admin') {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { requestId, status, adminNotes } = await request.json();
|
||||
|
||||
if (!requestId || !status) {
|
||||
return Response.json({ error: "Request ID and status are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!['pending', 'approved', 'denied'].includes(status)) {
|
||||
return Response.json({ error: "Invalid status" }, { status: 400 });
|
||||
}
|
||||
|
||||
const serviceRequest = await db.select({
|
||||
userId: serviceRequests.userId,
|
||||
serviceId: serviceRequests.serviceId,
|
||||
currentStatus: serviceRequests.status
|
||||
})
|
||||
.from(serviceRequests)
|
||||
.where(eq(serviceRequests.id, requestId))
|
||||
.limit(1);
|
||||
|
||||
if (serviceRequest.length === 0) {
|
||||
return Response.json({ error: "Request not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
await db.update(serviceRequests)
|
||||
.set({
|
||||
status,
|
||||
adminNotes,
|
||||
reviewedBy: session.user.id,
|
||||
reviewedAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(serviceRequests.id, requestId));
|
||||
|
||||
if (status === 'approved' && serviceRequest[0].currentStatus !== 'approved') {
|
||||
const existingAccess = await db.select()
|
||||
.from(userServices)
|
||||
.where(and(
|
||||
eq(userServices.userId, serviceRequest[0].userId),
|
||||
eq(userServices.serviceId, serviceRequest[0].serviceId)
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (existingAccess.length === 0) {
|
||||
await db.insert(userServices).values({
|
||||
id: nanoid(),
|
||||
userId: serviceRequest[0].userId,
|
||||
serviceId: serviceRequest[0].serviceId,
|
||||
grantedBy: session.user.id,
|
||||
grantedAt: new Date(),
|
||||
createdAt: new Date()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'denied' && serviceRequest[0].currentStatus === 'approved') {
|
||||
await db.delete(userServices)
|
||||
.where(and(
|
||||
eq(userServices.userId, serviceRequest[0].userId),
|
||||
eq(userServices.serviceId, serviceRequest[0].serviceId)
|
||||
));
|
||||
}
|
||||
|
||||
return Response.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error updating request:", error);
|
||||
return Response.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
165
app/api/admin/services/route.ts
Normal file
165
app/api/admin/services/route.ts
Normal file
|
@ -0,0 +1,165 @@
|
|||
import { db } from "@/db";
|
||||
import { services, userServices, user } from "@/db/schema";
|
||||
import { auth } from "@/util/auth";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { NextRequest } from "next/server";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers
|
||||
});
|
||||
|
||||
if (!session || session.user.role !== 'admin') {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const allServices = await db.select({
|
||||
id: services.id,
|
||||
name: services.name,
|
||||
description: services.description,
|
||||
priceStatus: services.priceStatus,
|
||||
joinLink: services.joinLink,
|
||||
enabled: services.enabled,
|
||||
createdAt: services.createdAt,
|
||||
updatedAt: services.updatedAt
|
||||
})
|
||||
.from(services)
|
||||
.orderBy(services.name);
|
||||
|
||||
const serviceAssignments = await db.select({
|
||||
serviceId: userServices.serviceId,
|
||||
userId: userServices.userId,
|
||||
userName: user.name,
|
||||
userEmail: user.email,
|
||||
grantedAt: userServices.grantedAt
|
||||
})
|
||||
.from(userServices)
|
||||
.innerJoin(user, eq(userServices.userId, user.id))
|
||||
.orderBy(user.name);
|
||||
|
||||
const servicesWithUsers = allServices.map(service => ({
|
||||
...service,
|
||||
users: serviceAssignments.filter(assignment => assignment.serviceId === service.id)
|
||||
}));
|
||||
|
||||
return Response.json({ services: servicesWithUsers });
|
||||
} catch (error) {
|
||||
console.error("Error fetching services:", error);
|
||||
return Response.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers
|
||||
});
|
||||
|
||||
if (!session || session.user.role !== 'admin') {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { action, userId, serviceId } = await request.json();
|
||||
|
||||
if (!action || !userId || !serviceId) {
|
||||
return Response.json({ error: "Action, user ID, and service ID are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (action === 'grant') {
|
||||
const existingAccess = await db.select()
|
||||
.from(userServices)
|
||||
.where(and(
|
||||
eq(userServices.userId, userId),
|
||||
eq(userServices.serviceId, serviceId)
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (existingAccess.length > 0) {
|
||||
return Response.json({ error: "User already has access to this service" }, { status: 400 });
|
||||
}
|
||||
|
||||
await db.insert(userServices).values({
|
||||
id: nanoid(),
|
||||
userId,
|
||||
serviceId,
|
||||
grantedBy: session.user.id,
|
||||
grantedAt: new Date(),
|
||||
createdAt: new Date()
|
||||
});
|
||||
|
||||
return Response.json({ success: true, message: "Access granted" });
|
||||
|
||||
} else if (action === 'revoke') {
|
||||
await db.delete(userServices)
|
||||
.where(and(
|
||||
eq(userServices.userId, userId),
|
||||
eq(userServices.serviceId, serviceId)
|
||||
));
|
||||
|
||||
return Response.json({ success: true, message: "Access revoked" });
|
||||
|
||||
} else {
|
||||
return Response.json({ error: "Invalid action. Use 'grant' or 'revoke'" }, { status: 400 });
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error managing service access:", error);
|
||||
return Response.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers
|
||||
});
|
||||
|
||||
if (!session || session.user.role !== 'admin') {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { serviceId, enabled, priceStatus, description, joinLink } = await request.json();
|
||||
|
||||
if (!serviceId) {
|
||||
return Response.json({ error: "Service ID is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const updates: {
|
||||
updatedAt: Date;
|
||||
enabled?: boolean;
|
||||
priceStatus?: string;
|
||||
description?: string;
|
||||
joinLink?: string | null;
|
||||
} = {
|
||||
updatedAt: new Date()
|
||||
};
|
||||
|
||||
if (typeof enabled === 'boolean') {
|
||||
updates.enabled = enabled;
|
||||
}
|
||||
|
||||
if (priceStatus && ['open', 'invite-only', 'by-request'].includes(priceStatus)) {
|
||||
updates.priceStatus = priceStatus;
|
||||
}
|
||||
|
||||
if (description !== undefined) {
|
||||
updates.description = description;
|
||||
}
|
||||
|
||||
if (joinLink !== undefined) {
|
||||
updates.joinLink = joinLink || null;
|
||||
}
|
||||
|
||||
await db.update(services)
|
||||
.set(updates)
|
||||
.where(eq(services.id, serviceId));
|
||||
|
||||
return Response.json({ success: true, message: "Service updated successfully" });
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error updating service:", error);
|
||||
return Response.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
68
app/api/admin/users/route.ts
Normal file
68
app/api/admin/users/route.ts
Normal file
|
@ -0,0 +1,68 @@
|
|||
import { db } from "@/db";
|
||||
import { user } from "@/db/schema";
|
||||
import { auth } from "@/util/auth";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers
|
||||
});
|
||||
|
||||
if (!session || session.user.role !== 'admin') {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const allUsers = await db.select({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
emailVerified: user.emailVerified,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt
|
||||
})
|
||||
.from(user)
|
||||
.orderBy(user.createdAt);
|
||||
|
||||
return Response.json({ users: allUsers });
|
||||
} catch (error) {
|
||||
console.error("Error fetching users:", error);
|
||||
return Response.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers
|
||||
});
|
||||
|
||||
if (!session || session.user.role !== 'admin') {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { userId, role } = await request.json();
|
||||
|
||||
if (!userId || !role) {
|
||||
return Response.json({ error: "User ID and role are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!['user', 'admin'].includes(role)) {
|
||||
return Response.json({ error: "Invalid role" }, { status: 400 });
|
||||
}
|
||||
|
||||
await db.update(user)
|
||||
.set({
|
||||
role,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(user.id, userId));
|
||||
|
||||
return Response.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error updating user:", error);
|
||||
return Response.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
4
app/api/auth/[...all]/route.ts
Normal file
4
app/api/auth/[...all]/route.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
import { auth } from "@/util/auth";
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
|
||||
export const { POST, GET } = toNextJsHandler(auth);
|
22
app/api/captcha/route.ts
Normal file
22
app/api/captcha/route.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { createChallenge } from "altcha-lib";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const hmacKey = process.env.ALTCHA_SECRET;
|
||||
|
||||
async function getChallenge() {
|
||||
if (!hmacKey) {
|
||||
console.error("ALTCHA_SECRET is not set")
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 })
|
||||
}
|
||||
|
||||
const challenge = await createChallenge({
|
||||
hmacKey,
|
||||
maxNumber: 1400000,
|
||||
})
|
||||
|
||||
return NextResponse.json(challenge)
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
return getChallenge()
|
||||
}
|
71
app/api/login/route.ts
Normal file
71
app/api/login/route.ts
Normal file
|
@ -0,0 +1,71 @@
|
|||
import { auth } from "@/util/auth";
|
||||
import { verifyCaptcha } from "@/util/captcha";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { email, password, token } = body;
|
||||
|
||||
if (!email || !password || !token) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid email format" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const isCaptchaValid = await verifyCaptcha(token);
|
||||
if (!isCaptchaValid) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid captcha" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const signInResponse = await auth.api.signInEmail({
|
||||
body: {
|
||||
email,
|
||||
password,
|
||||
},
|
||||
});
|
||||
|
||||
if (!signInResponse) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to sign in" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
if ('error' in signInResponse) {
|
||||
const errorMessage = signInResponse.error && typeof signInResponse.error === 'object' && 'message' in signInResponse.error
|
||||
? String(signInResponse.error.message)
|
||||
: "Invalid credentials";
|
||||
return NextResponse.json(
|
||||
{ error: errorMessage },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Signed in successfully",
|
||||
user: signInResponse.user,
|
||||
});
|
||||
|
||||
} catch (error: unknown) {
|
||||
console.error("Login error:", error);
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
23
app/api/logout/route.ts
Normal file
23
app/api/logout/route.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { auth } from "@/util/auth";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
await auth.api.signOut({
|
||||
headers: request.headers,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Signed out successfully",
|
||||
});
|
||||
|
||||
} catch (error: unknown) {
|
||||
console.error("Logout error:", error);
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
110
app/api/service-requests/route.ts
Normal file
110
app/api/service-requests/route.ts
Normal file
|
@ -0,0 +1,110 @@
|
|||
import { db } from "@/db";
|
||||
import { serviceRequests, services, userServices } from "@/db/schema";
|
||||
import { auth } from "@/util/auth";
|
||||
import { verifyCaptcha } from "@/util/captcha";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { NextRequest } from "next/server";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { serviceId, reason, captchaToken } = await request.json();
|
||||
|
||||
if (!serviceId || !reason) {
|
||||
return Response.json({ error: "Service ID and reason are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const isValidCaptcha = await verifyCaptcha(captchaToken);
|
||||
if (!isValidCaptcha) {
|
||||
return Response.json({ error: "Invalid captcha" }, { status: 400 });
|
||||
}
|
||||
|
||||
const service = await db.select().from(services).where(eq(services.name, serviceId)).limit(1);
|
||||
if (service.length === 0) {
|
||||
return Response.json({ error: "Service not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!service[0].enabled) {
|
||||
return Response.json({ error: "This service is currently unavailable" }, { status: 400 });
|
||||
}
|
||||
|
||||
const existingAccess = await db.select()
|
||||
.from(userServices)
|
||||
.where(and(
|
||||
eq(userServices.userId, session.user.id),
|
||||
eq(userServices.serviceId, service[0].id)
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (existingAccess.length > 0) {
|
||||
return Response.json({ error: "You already have access to this service" }, { status: 400 });
|
||||
}
|
||||
|
||||
const existingRequest = await db.select()
|
||||
.from(serviceRequests)
|
||||
.where(and(
|
||||
eq(serviceRequests.userId, session.user.id),
|
||||
eq(serviceRequests.serviceId, service[0].id),
|
||||
eq(serviceRequests.status, 'pending')
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (existingRequest.length > 0) {
|
||||
return Response.json({ error: "You already have a pending request for this service" }, { status: 400 });
|
||||
}
|
||||
|
||||
const requestId = nanoid();
|
||||
await db.insert(serviceRequests).values({
|
||||
id: requestId,
|
||||
userId: session.user.id,
|
||||
serviceId: service[0].id,
|
||||
reason,
|
||||
status: 'pending'
|
||||
});
|
||||
|
||||
return Response.json({ success: true, requestId });
|
||||
} catch (error) {
|
||||
console.error("Error creating service request:", error);
|
||||
return Response.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const userRequests = await db.select({
|
||||
id: serviceRequests.id,
|
||||
reason: serviceRequests.reason,
|
||||
status: serviceRequests.status,
|
||||
adminNotes: serviceRequests.adminNotes,
|
||||
reviewedAt: serviceRequests.reviewedAt,
|
||||
createdAt: serviceRequests.createdAt,
|
||||
serviceName: services.name,
|
||||
serviceDescription: services.description
|
||||
})
|
||||
.from(serviceRequests)
|
||||
.innerJoin(services, eq(serviceRequests.serviceId, services.id))
|
||||
.where(eq(serviceRequests.userId, session.user.id))
|
||||
.orderBy(serviceRequests.createdAt);
|
||||
|
||||
return Response.json({ requests: userRequests });
|
||||
} catch (error) {
|
||||
console.error("Error fetching service requests:", error);
|
||||
return Response.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
24
app/api/services/route.ts
Normal file
24
app/api/services/route.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import { db } from "@/db";
|
||||
import { services } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const publicServices = await db.select({
|
||||
id: services.id,
|
||||
name: services.name,
|
||||
description: services.description,
|
||||
priceStatus: services.priceStatus,
|
||||
joinLink: services.joinLink,
|
||||
enabled: services.enabled
|
||||
})
|
||||
.from(services)
|
||||
.where(eq(services.enabled, true))
|
||||
.orderBy(services.name);
|
||||
|
||||
return Response.json({ services: publicServices });
|
||||
} catch (error) {
|
||||
console.error("Error fetching public services:", error);
|
||||
return Response.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
102
app/api/signup/route.ts
Normal file
102
app/api/signup/route.ts
Normal file
|
@ -0,0 +1,102 @@
|
|||
import { auth } from "@/util/auth";
|
||||
import { verifyCaptcha } from "@/util/captcha";
|
||||
import { db } from "@/db";
|
||||
import { user } from "@/db/schema";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { email, password, confirmPassword, token, name } = body;
|
||||
|
||||
if (!email || !password || !confirmPassword || !token) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid email format" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return NextResponse.json(
|
||||
{ error: "Password must be at least 8 characters long" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
return NextResponse.json(
|
||||
{ error: "Passwords do not match" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const isCaptchaValid = await verifyCaptcha(token);
|
||||
if (!isCaptchaValid) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid captcha" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const userCount = await db.select({ count: sql<number>`count(*)` }).from(user);
|
||||
const isFirstUser = userCount[0]?.count === 0;
|
||||
|
||||
const signUpResponse = await auth.api.signUpEmail({
|
||||
body: {
|
||||
email,
|
||||
password,
|
||||
name: name || email.split('@')[0],
|
||||
role: isFirstUser ? 'admin' : 'user',
|
||||
},
|
||||
});
|
||||
|
||||
if (!signUpResponse) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create user" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
if ('error' in signUpResponse) {
|
||||
const errorMessage = signUpResponse.error && typeof signUpResponse.error === 'object' && 'message' in signUpResponse.error
|
||||
? String(signUpResponse.error.message)
|
||||
: "Failed to create user";
|
||||
return NextResponse.json(
|
||||
{ error: errorMessage },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "User created successfully",
|
||||
user: signUpResponse.user,
|
||||
isFirstUser,
|
||||
});
|
||||
|
||||
} catch (error: unknown) {
|
||||
console.error("Signup error:", error);
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
if (errorMessage.includes('duplicate key') || errorMessage.includes('already exists')) {
|
||||
return NextResponse.json(
|
||||
{ error: "An account with this email already exists" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
52
app/api/user-services/route.ts
Normal file
52
app/api/user-services/route.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
import { db } from "@/db";
|
||||
import { userServices, services } from "@/db/schema";
|
||||
import { auth } from "@/util/auth";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const grantedServices = await db.select({
|
||||
serviceId: services.id,
|
||||
serviceName: services.name,
|
||||
serviceDescription: services.description,
|
||||
priceStatus: services.priceStatus,
|
||||
joinLink: services.joinLink,
|
||||
grantedAt: userServices.grantedAt,
|
||||
isOpen: sql<boolean>`false`
|
||||
})
|
||||
.from(userServices)
|
||||
.innerJoin(services, eq(userServices.serviceId, services.id))
|
||||
.where(eq(userServices.userId, session.user.id));
|
||||
|
||||
const openServices = await db.select({
|
||||
serviceId: services.id,
|
||||
serviceName: services.name,
|
||||
serviceDescription: services.description,
|
||||
priceStatus: services.priceStatus,
|
||||
joinLink: services.joinLink,
|
||||
grantedAt: sql<Date | null>`null`,
|
||||
isOpen: sql<boolean>`true`
|
||||
})
|
||||
.from(services)
|
||||
.where(eq(services.priceStatus, "open"));
|
||||
|
||||
const grantedServiceIds = new Set(grantedServices.map(s => s.serviceId));
|
||||
const uniqueOpenServices = openServices.filter(s => !grantedServiceIds.has(s.serviceId));
|
||||
|
||||
const allAccessibleServices = [...grantedServices, ...uniqueOpenServices];
|
||||
|
||||
return Response.json({ services: allAccessibleServices });
|
||||
} catch (error) {
|
||||
console.error("Error fetching user services:", error);
|
||||
return Response.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
266
app/dashboard/page.tsx
Normal file
266
app/dashboard/page.tsx
Normal file
|
@ -0,0 +1,266 @@
|
|||
"use client"
|
||||
|
||||
import { Nav } from "@/components/core/nav";
|
||||
import { authClient } from "@/util/auth-client";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { SiForgejo, SiJellyfin, SiOllama } from "react-icons/si";
|
||||
import {
|
||||
TbDashboard,
|
||||
TbUser,
|
||||
TbMail,
|
||||
TbCalendar,
|
||||
TbShield,
|
||||
TbDeviceTv,
|
||||
TbExternalLink,
|
||||
TbServer,
|
||||
TbCheck,
|
||||
TbReceipt,
|
||||
} from "react-icons/tb";
|
||||
|
||||
export default function Dashboard() {
|
||||
const router = useRouter();
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [userServices, setUserServices] = useState<string[]>([]);
|
||||
const [openServices, setOpenServices] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (mounted && !isPending && !session) {
|
||||
router.push("/login?message=Please sign in to access the dashboard");
|
||||
}
|
||||
}, [session, isPending, mounted, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (session) {
|
||||
fetchUserServices();
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
const fetchUserServices = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/user-services");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const services = data.services;
|
||||
setUserServices(services.map((s: { serviceName: string }) => s.serviceName));
|
||||
setOpenServices(services.filter((s: { isOpen: boolean }) => s.isOpen).map((s: { serviceName: string }) => s.serviceName));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching services:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (!mounted || isPending) {
|
||||
return (
|
||||
<main>
|
||||
<Nav />
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] px-4">
|
||||
<div className="animate-pulse text-lg">loading dashboard...</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<main>
|
||||
<Nav />
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] px-4">
|
||||
<div className="text-lg">redirecting to login...</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
return (
|
||||
<main>
|
||||
<Nav />
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<div className="flex flex-row items-center justify-start gap-3 mb-8">
|
||||
<TbDashboard size={32} className="text-blue-500" />
|
||||
<h1 className="text-3xl sm:text-4xl font-bold">
|
||||
Dashboard
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 p-6 rounded-2xl mb-8">
|
||||
<h2 className="text-2xl font-semibold">
|
||||
Welcome back, {session.user.name || 'User'}! 👋
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<ServiceCard
|
||||
title="TV"
|
||||
icon={<TbDeviceTv />}
|
||||
link="https://tv.ihate.college"
|
||||
signupType="request"
|
||||
signupLink="https://t.me/p0ntus"
|
||||
hasAccess={userServices.includes("tv")}
|
||||
isOpen={openServices.includes("tv")}
|
||||
/>
|
||||
<ServiceCard
|
||||
title="TV Requests"
|
||||
icon={<SiJellyfin />}
|
||||
link="https://requests.ihate.college"
|
||||
signupType="request"
|
||||
signupLink="https://t.me/p0ntus"
|
||||
hasAccess={userServices.includes("tv")}
|
||||
isOpen={openServices.includes("tv")}
|
||||
/>
|
||||
<ServiceCard
|
||||
title="Git"
|
||||
icon={<SiForgejo />}
|
||||
link="https://git.p0ntus.com"
|
||||
signupType="self"
|
||||
signupLink="https://git.p0ntus.com/user/sign_up"
|
||||
hasAccess={userServices.includes("git")}
|
||||
isOpen={openServices.includes("git")}
|
||||
/>
|
||||
<ServiceCard
|
||||
title="Mail"
|
||||
icon={<TbMail />}
|
||||
link="https://pontusmail.org"
|
||||
signupType="self"
|
||||
signupLink="https://pontusmail.org/admin/user/signup"
|
||||
hasAccess={userServices.includes("mail")}
|
||||
isOpen={openServices.includes("mail")}
|
||||
/>
|
||||
<ServiceCard
|
||||
title="AI"
|
||||
icon={<SiOllama />}
|
||||
link="https://ai.ihate.college"
|
||||
signupType="request"
|
||||
signupLink="https://t.me/p0ntus"
|
||||
hasAccess={userServices.includes("ai")}
|
||||
isOpen={openServices.includes("ai")}
|
||||
/>
|
||||
<ServiceCard
|
||||
title="Hosting"
|
||||
icon={<TbServer />}
|
||||
signupType="request"
|
||||
signupLink="https://t.me/p0ntus"
|
||||
hasAccess={userServices.includes("hosting")}
|
||||
isOpen={openServices.includes("hosting")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl justify-between shadow-sm border border-gray-200 dark:border-gray-700 flex flex-row items-center gap-3 mb-8">
|
||||
<h3 className="text-xl font-semibold">Need access to a service?</h3>
|
||||
<Link href="/requests" className="bg-blue-500 text-white px-4 py-2 rounded-lg hover:bg-blue-600 transition-colors flex items-center gap-2 cursor-pointer">
|
||||
<TbReceipt className="w-4 h-4" />
|
||||
Make a Request
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<TbUser className="text-blue-500 w-6 h-6" />
|
||||
<h3 className="text-xl font-semibold">My Profile</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<TbUser className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-500">Name:</span>
|
||||
<span className="font-medium">{session.user.name || 'Not provided'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<TbMail className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-500">Email:</span>
|
||||
<span className="font-medium">{session.user.email}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<TbShield className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-500">Email Verified:</span>
|
||||
<span className={`font-medium ${session.user.emailVerified ? 'text-green-600' : 'text-orange-500'}`}>
|
||||
{session.user.emailVerified ? 'Yes' : 'No'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<TbCalendar className="text-green-500 w-6 h-6" />
|
||||
<h3 className="text-xl font-semibold">Account Details</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<TbCalendar className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-500">Account Created:</span>
|
||||
<span className="font-medium text-sm">{formatDate(session.user.createdAt)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<TbCalendar className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-500">Last Updated:</span>
|
||||
<span className="font-medium text-sm">{formatDate(session.user.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceCard({ title, icon, link, signupType, signupLink, hasAccess, isOpen }: {
|
||||
title: string,
|
||||
icon: React.ReactNode,
|
||||
link?: string,
|
||||
signupType: "request" | "self" | "invite",
|
||||
signupLink: string,
|
||||
hasAccess: boolean,
|
||||
isOpen: boolean
|
||||
}) {
|
||||
const cardClassName = hasAccess
|
||||
? "bg-green-50 dark:bg-green-900/20 p-6 rounded-xl shadow-sm border border-green-200 dark:border-green-700"
|
||||
: "bg-red-50 dark:bg-red-900/20 p-6 rounded-xl shadow-sm border border-red-200 dark:border-red-700";
|
||||
|
||||
return (
|
||||
<div className={cardClassName}>
|
||||
<div className="flex items-center justify-between gap-3 mb-4">
|
||||
<div className="flex items-center gap-2 cursor-pointer" onClick={() => hasAccess && link && window.open(link, '_blank')}>
|
||||
{icon}
|
||||
<h3 className="text-xl font-semibold">{title}</h3>
|
||||
</div>
|
||||
{hasAccess && link && <div className="flex items-center gap-2">
|
||||
<TbExternalLink className="text-blue-500 w-6 h-6 cursor-pointer" onClick={() => window.open(link, '_blank')} />
|
||||
</div>}
|
||||
</div>
|
||||
{hasAccess ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-green-600 dark:text-green-400">
|
||||
<TbCheck className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">Access granted</span>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<p className="text-sm text-gray-500">
|
||||
Open service: <Link href={signupLink} className="text-blue-500 hover:underline">{signupType === "request" ? "Request account" : "Create account"}</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 mt-4">
|
||||
Need an account? <Link href={signupLink} className="text-blue-500 hover:underline">{signupType === "request" ? "Request one!" : "Sign up!"}</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -3,11 +3,15 @@
|
|||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--foreground-muted: #909090;
|
||||
--foreground-muted-light: #adaaaa;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-foreground-muted: var(--foreground-muted);
|
||||
--color-foreground-muted-light: var(--foreground-muted-light);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
@ -16,6 +20,8 @@
|
|||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
--foreground-muted: #909090;
|
||||
--foreground-muted-light: #b0b0b0;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
181
app/login/page.tsx
Normal file
181
app/login/page.tsx
Normal file
|
@ -0,0 +1,181 @@
|
|||
"use client"
|
||||
|
||||
import Altcha from "@/components/core/altcha";
|
||||
import { Nav } from "@/components/core/nav";
|
||||
import { TbLogin } from "react-icons/tb";
|
||||
import { useState, useEffect, Suspense } from "react";
|
||||
import { useForm, SubmitHandler } from "react-hook-form";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { authClient } from "@/util/auth-client";
|
||||
|
||||
interface LoginForm {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
function LoginForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [altchaState, setAltchaState] = useState<{ status: "success" | "error" | "expired" | "waiting", token: string }>({ status: "waiting", token: "" });
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [apiError, setApiError] = useState<string>("");
|
||||
const [successMessage, setSuccessMessage] = useState<string>("");
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<LoginForm>();
|
||||
|
||||
useEffect(() => {
|
||||
const message = searchParams.get('message');
|
||||
if (message) {
|
||||
setSuccessMessage(message);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const onSubmit: SubmitHandler<LoginForm> = async (data) => {
|
||||
if (altchaState.status !== "success") {
|
||||
setApiError("Please complete the captcha");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setApiError("");
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...data,
|
||||
token: altchaState.token,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setApiError(result.error || "Failed to sign in");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await authClient.signIn.email({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
});
|
||||
const redirectTo = searchParams.get('redirect') || "/";
|
||||
router.push(redirectTo);
|
||||
} catch (signInError) {
|
||||
console.error("Auth client sign in failed:", signInError);
|
||||
const redirectTo = searchParams.get('redirect') || "/";
|
||||
router.push(redirectTo);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Login error:", error);
|
||||
setApiError("An unexpected error occurred. Please try again.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAltchaStateChange = (e: Event | CustomEvent) => {
|
||||
if ('detail' in e && e.detail?.payload) {
|
||||
setAltchaState({ status: "success", token: e.detail.payload });
|
||||
} else {
|
||||
setAltchaState({ status: "error", token: "" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center mt-18 sm:my-16 px-4 gap-18">
|
||||
<div className="flex flex-row items-center justify-between gap-2">
|
||||
<TbLogin size={32} className="sm:w-9 sm:h-9" />
|
||||
<h1 className="text-3xl sm:text-4xl font-bold">
|
||||
login
|
||||
</h1>
|
||||
</div>
|
||||
<form className="flex flex-col bg-foreground/10 rounded-2xl sm:rounded-4xl p-4 gap-4 w-1/4 min-w-80" onSubmit={handleSubmit(onSubmit)}>
|
||||
{successMessage && (
|
||||
<div className="p-3 bg-green-100 border border-green-400 text-green-700 rounded-md">
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h2 className="text-2xl sm:text-3xl font-light text-center w-full flex flex-wrap">
|
||||
email
|
||||
</h2>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="enter your email"
|
||||
className="w-full p-2 rounded-md bg-foreground/10"
|
||||
{...register("email", {
|
||||
required: "Email is required",
|
||||
pattern: {
|
||||
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||
message: "Please enter a valid email address"
|
||||
}
|
||||
})}
|
||||
/>
|
||||
{errors.email && <p className="text-red-500 text-sm">{errors.email.message}</p>}
|
||||
|
||||
<h2 className="text-2xl sm:text-3xl font-light text-center w-full flex flex-wrap">
|
||||
password
|
||||
</h2>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="enter your password"
|
||||
className="w-full p-2 rounded-md bg-foreground/10"
|
||||
{...register("password", {
|
||||
required: "Password is required",
|
||||
minLength: {
|
||||
value: 1,
|
||||
message: "Password is required"
|
||||
}
|
||||
})}
|
||||
/>
|
||||
{errors.password && <p className="text-red-500 text-sm">{errors.password.message}</p>}
|
||||
|
||||
<h2 className="text-2xl sm:text-3xl font-light text-center w-full flex flex-wrap">
|
||||
captcha
|
||||
</h2>
|
||||
<Altcha onStateChange={handleAltchaStateChange} />
|
||||
|
||||
{apiError && (
|
||||
<div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded-md">
|
||||
{apiError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="bg-blue-400 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
type="submit"
|
||||
disabled={isLoading || altchaState.status !== "success" || !altchaState.token}
|
||||
>
|
||||
{isLoading ? "Signing in..." : altchaState.status === "success" ? "login" : "waiting for captcha"}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-sm text-gray-600 mt-4">
|
||||
Don't have an account?{" "}
|
||||
<a href="/signup" className="text-blue-400 hover:underline">
|
||||
Sign up here
|
||||
</a>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
return (
|
||||
<main>
|
||||
<Nav />
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
</main>
|
||||
);
|
||||
}
|
191
app/page.tsx
191
app/page.tsx
|
@ -1,90 +1,127 @@
|
|||
"use client"
|
||||
|
||||
import Link from "next/link";
|
||||
import { SiForgejo, SiJellyfin, SiOllama } from "react-icons/si";
|
||||
import { TbMail, TbKey, TbServer, TbArrowRight } from "react-icons/tb";
|
||||
import { SiForgejo, SiJellyfin, SiOllama, SiVaultwarden } from "react-icons/si";
|
||||
import { TbMail, TbKey, TbServer, TbArrowRight, TbTool } from "react-icons/tb";
|
||||
import { Nav } from "@/components/core/nav";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Service {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
priceStatus: string;
|
||||
joinLink?: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const getServiceIcon = (serviceName: string) => {
|
||||
switch (serviceName.toLowerCase()) {
|
||||
case 'git':
|
||||
return SiForgejo;
|
||||
case 'tv':
|
||||
return SiJellyfin;
|
||||
case 'ai':
|
||||
return SiOllama;
|
||||
case 'mail':
|
||||
case 'email':
|
||||
return TbMail;
|
||||
case 'hosting':
|
||||
return TbServer;
|
||||
case 'pass':
|
||||
return SiVaultwarden;
|
||||
case 'keybox':
|
||||
return TbKey;
|
||||
default:
|
||||
return TbTool;
|
||||
}
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchServices();
|
||||
}, []);
|
||||
|
||||
const fetchServices = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/services");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setServices(data.services.slice(0, 6));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching services:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main>
|
||||
<Nav />
|
||||
<div className="flex flex-col items-center justify-between gap-3 my-12 sm:my-20 px-4">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold text-center">
|
||||
p0ntus
|
||||
</h1>
|
||||
<h3 className="text-xl sm:text-2xl text-center">
|
||||
open source at your fingertips
|
||||
</h3>
|
||||
</div>
|
||||
<hr className="border-black dark:border-white mt-16 mb-16 sm:mt-24 sm:mb-24" />
|
||||
<div className="max-w-6xl mx-auto w-full px-4 md:px-10">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 gap-y-16 lg:gap-16">
|
||||
<div className="flex flex-col items-center justify-start gap-6 h-full">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-center w-full">Services</h2>
|
||||
<h3 className="hidden sm:block text-lg sm:text-xl italic text-center w-full">what can we offer you?</h3>
|
||||
<div className="grid grid-cols-3 gap-14 sm:gap-10 my-6 sm:my-8">
|
||||
<div className="flex flex-col items-center justify-center gap-3">
|
||||
<Link href="/services/git" className="flex flex-col items-center gap-2">
|
||||
<SiForgejo size={40} className="sm:w-12 sm:h-12" />
|
||||
<h3 className="text-base sm:text-lg font-bold">git</h3>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center gap-3">
|
||||
<Link href="/services/mail" className="flex flex-col items-center gap-2">
|
||||
<TbMail size={40} className="sm:w-12 sm:h-12" />
|
||||
<h3 className="text-base sm:text-lg font-bold">mail</h3>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center gap-3">
|
||||
<Link href="/services/ai" className="flex flex-col items-center gap-2">
|
||||
<SiOllama size={40} className="sm:w-12 sm:h-12" />
|
||||
<h3 className="text-base sm:text-lg font-bold">ai</h3>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center gap-3">
|
||||
<Link href="/services/tv" className="flex flex-col items-center gap-2">
|
||||
<SiJellyfin size={40} className="sm:w-12 sm:h-12" />
|
||||
<h3 className="text-base sm:text-lg font-bold">tv</h3>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center gap-3">
|
||||
<Link href="/services/keybox" className="flex flex-col items-center gap-2">
|
||||
<TbKey size={40} className="sm:w-12 sm:h-12" />
|
||||
<h3 className="text-base sm:text-lg font-bold">keybox</h3>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center gap-3">
|
||||
<Link href="/services/hosting" className="flex flex-col items-center gap-2">
|
||||
<TbServer size={40} className="sm:w-12 sm:h-12" />
|
||||
<h3 className="text-base sm:text-lg font-bold">hosting</h3>
|
||||
<div className="flex flex-col items-center justify-center gap-10 sm:gap-16 my-8 sm:my-12 px-4">
|
||||
<div className="flex flex-col items-center justify-center gap-4 sm:gap-8 text-center">
|
||||
<h1 className="text-4xl sm:text-6xl font-bold">
|
||||
p0ntus
|
||||
</h1>
|
||||
<p className="text-lg sm:text-2xl font-light max-w-2xl">
|
||||
a simple platform for privacy-conscious users who want to take control of their digital life
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full max-w-7xl mx-auto">
|
||||
<div className="flex flex-col lg:flex-row justify-center items-start gap-8 lg:gap-12">
|
||||
<div className="flex flex-col items-center justify-start gap-6 w-full lg:max-w-md">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-center w-full">Services</h2>
|
||||
<h3 className="text-lg sm:text-xl italic text-center w-full text-gray-600 hidden sm:block">what can we offer you?</h3>
|
||||
{loading ? (
|
||||
<div className="animate-pulse text-lg">Loading services...</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-8 sm:gap-10 my-6 sm:my-8">
|
||||
{services.map((service) => {
|
||||
const IconComponent = getServiceIcon(service.name);
|
||||
return (
|
||||
<div key={service.id} className="flex flex-col items-center justify-center gap-3">
|
||||
<Link href={`/services/${service.name}`} className="flex flex-col items-center gap-2 hover:opacity-75 transition-opacity">
|
||||
<IconComponent size={40} className="sm:w-12 sm:h-12" />
|
||||
<h3 className="text-base sm:text-lg font-bold">{service.name}</h3>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-start gap-6 w-full lg:max-w-md">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-center w-full">Where we are</h2>
|
||||
<h3 className="text-lg sm:text-xl italic text-center w-full text-gray-600 hidden sm:block">how can you find us?</h3>
|
||||
<div className="flex flex-col items-center gap-6 mt-6">
|
||||
<p className="text-base sm:text-lg text-center">
|
||||
p0ntus is fully on the public internet! our servers are mainly located in the united states.
|
||||
</p>
|
||||
<p className="text-base sm:text-lg text-center">
|
||||
we also operate servers in the united states, canada and germany.
|
||||
</p>
|
||||
<Link href="/servers" className="flex flex-row items-center gap-2 text-base sm:text-lg text-center text-blue-500 hover:underline transition-colors">
|
||||
our servers <TbArrowRight size={20} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-start gap-6 h-full">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-center w-full">Where we are</h2>
|
||||
<h3 className="hidden sm:block text-lg sm:text-xl italic text-center w-full">how can you find us?</h3>
|
||||
<div className="flex flex-col items-center gap-6 mt-6">
|
||||
<p className="text-base sm:text-lg text-center">
|
||||
p0ntus is fully on the public internet! our servers are mainly located in the united states.
|
||||
</p>
|
||||
<p className="text-base sm:text-lg text-center">
|
||||
we also operate servers in the united states, canada and germany.
|
||||
</p>
|
||||
<Link href="/servers" className="flex flex-row items-center gap-2 text-base sm:text-lg text-center text-blue-500 hover:underline">
|
||||
our servers <TbArrowRight size={20} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-start gap-6 h-full">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-center w-full">Why is p0ntus free?</h2>
|
||||
<h3 className="hidden sm:block text-lg sm:text-xl italic text-center w-full">what's the point?</h3>
|
||||
<div className="flex flex-col items-center gap-6 mt-6">
|
||||
<p className="text-base sm:text-lg text-center">
|
||||
everything today includes microtransactions, and we were fed up with it.
|
||||
</p>
|
||||
<p className="text-base sm:text-lg text-center">
|
||||
p0ntus exists to show that it is possible to have a free and open set of services that people have fun using.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col items-center justify-start gap-6 w-full lg:max-w-md">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-center w-full">Why is p0ntus free?</h2>
|
||||
<h3 className="text-lg sm:text-xl italic text-center w-full text-gray-600 hidden sm:block">what's the point?</h3>
|
||||
<div className="flex flex-col items-center gap-6 mt-6">
|
||||
<p className="text-base sm:text-lg text-center">
|
||||
everything today includes microtransactions, and we were fed up with it.
|
||||
</p>
|
||||
<p className="text-base sm:text-lg text-center">
|
||||
p0ntus exists to show that it is possible to have a free and open set of services that people have fun using.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
355
app/requests/page.tsx
Normal file
355
app/requests/page.tsx
Normal file
|
@ -0,0 +1,355 @@
|
|||
"use client"
|
||||
|
||||
import { Nav } from "@/components/core/nav";
|
||||
import Altcha from "@/components/core/altcha";
|
||||
import { authClient } from "@/util/auth-client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
TbSend,
|
||||
TbClock,
|
||||
TbCheck,
|
||||
TbX,
|
||||
TbEye,
|
||||
TbNotes,
|
||||
TbInfoCircle,
|
||||
} from "react-icons/tb";
|
||||
import Link from "next/link";
|
||||
|
||||
interface ServiceRequest {
|
||||
id: string;
|
||||
reason: string;
|
||||
status: 'pending' | 'approved' | 'denied';
|
||||
adminNotes?: string;
|
||||
reviewedAt?: string;
|
||||
createdAt: string;
|
||||
serviceName: string;
|
||||
serviceDescription: string;
|
||||
}
|
||||
|
||||
interface Service {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
priceStatus: string;
|
||||
joinLink?: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export default function ServiceRequests() {
|
||||
const router = useRouter();
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [requests, setRequests] = useState<ServiceRequest[]>([]);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [userServices, setUserServices] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [selectedService, setSelectedService] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [captchaToken, setCaptchaToken] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (mounted && !isPending && !session) {
|
||||
router.push("/login?message=Please sign in to access service requests");
|
||||
}
|
||||
}, [session, isPending, mounted, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (session) {
|
||||
fetchRequests();
|
||||
fetchUserServices();
|
||||
fetchServices();
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
const fetchRequests = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/service-requests");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setRequests(data.requests);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching requests:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUserServices = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/user-services");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUserServices(data.services.map((s: { serviceName: string }) => s.serviceName));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching user services:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchServices = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/services");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setServices(data.services);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching services:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedService || !reason || !captchaToken) {
|
||||
setMessage("Please fill in all fields and complete the captcha");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setMessage("");
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/service-requests", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
serviceId: selectedService,
|
||||
reason,
|
||||
captchaToken
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setMessage("Request submitted successfully!");
|
||||
setSelectedService("");
|
||||
setReason("");
|
||||
setCaptchaToken("");
|
||||
fetchRequests();
|
||||
} else {
|
||||
setMessage(data.error || "Failed to submit request");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error submitting request:", error);
|
||||
setMessage("An error occurred while submitting the request");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <TbClock className="w-5 h-5 text-yellow-500" />;
|
||||
case 'approved':
|
||||
return <TbCheck className="w-5 h-5 text-green-500" />;
|
||||
case 'denied':
|
||||
return <TbX className="w-5 h-5 text-red-500" />;
|
||||
default:
|
||||
return <TbClock className="w-5 h-5 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'text-yellow-600 bg-yellow-50 border-yellow-200';
|
||||
case 'approved':
|
||||
return 'text-green-600 bg-green-50 border-green-200';
|
||||
case 'denied':
|
||||
return 'text-red-600 bg-red-50 border-red-200';
|
||||
default:
|
||||
return 'text-gray-600 bg-gray-50 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
if (!mounted || isPending) {
|
||||
return (
|
||||
<main>
|
||||
<Nav />
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] px-4">
|
||||
<div className="animate-pulse text-lg">loading...</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<main>
|
||||
<Nav />
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] px-4">
|
||||
<div className="text-lg">redirecting to login...</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<Nav />
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
<div className="flex flex-row items-center justify-start gap-3 mb-8">
|
||||
<TbSend size={32} className="text-blue-500" />
|
||||
<h1 className="text-3xl sm:text-4xl font-bold">Service Requests</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 mb-8">
|
||||
<h2 className="text-xl font-semibold mb-4">Request Service Access</h2>
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-lg border border-blue-200 dark:border-blue-700 mb-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<TbInfoCircle className="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||
<strong>Note:</strong> Open services (like Git and Mail) don't require requests - you already have access!
|
||||
Visit the <Link href="/dashboard" className="underline hover:no-underline">dashboard</Link> to see your available services
|
||||
and create accounts directly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="service" className="block text-sm font-medium mb-2">
|
||||
Service
|
||||
</label>
|
||||
<select
|
||||
id="service"
|
||||
value={selectedService}
|
||||
onChange={(e) => setSelectedService(e.target.value)}
|
||||
className="w-full p-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700"
|
||||
required
|
||||
>
|
||||
<option value="">Select a service</option>
|
||||
{services
|
||||
.filter(s => (s.priceStatus === "by-request" || s.priceStatus === "invite-only") && !userServices.includes(s.name))
|
||||
.map((service) => (
|
||||
<option key={service.name} value={service.name}>
|
||||
{service.name.charAt(0).toUpperCase() + service.name.slice(1)} - {service.description}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{services.filter(s => (s.priceStatus === "by-request" || s.priceStatus === "invite-only") && !userServices.includes(s.name)).length === 0 && (
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
No services require requests at this time. All available services are either open or you already have access.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="reason" className="block text-sm font-medium mb-2">
|
||||
Reason for Request
|
||||
</label>
|
||||
<textarea
|
||||
id="reason"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full p-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700"
|
||||
placeholder="Please explain why you need access to this service..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Altcha onStateChange={(ev) => {
|
||||
if ('detail' in ev) {
|
||||
setCaptchaToken(ev.detail.payload || "");
|
||||
}
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !selectedService || !reason || !captchaToken}
|
||||
className="bg-blue-500 text-white px-6 py-3 rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
|
||||
>
|
||||
<TbSend className="w-4 h-4" />
|
||||
{submitting ? "Submitting..." : "Submit Request"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{message && (
|
||||
<div className={`mt-4 p-3 rounded-lg ${message.includes("success") ? "bg-green-50 text-green-700 border border-green-200" : "bg-red-50 text-red-700 border border-red-200"}`}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-xl font-semibold mb-4 flex items-center gap-2">
|
||||
<TbEye className="w-5 h-5" />
|
||||
My Requests
|
||||
</h2>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-pulse">Loading requests...</div>
|
||||
</div>
|
||||
) : requests.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
No requests found. Submit your first request above!
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{requests.map((request) => (
|
||||
<div key={request.id} className="border border-gray-200 dark:border-gray-600 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">
|
||||
{request.serviceName.charAt(0).toUpperCase() + request.serviceName.slice(1)}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{request.serviceDescription}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`flex items-center gap-2 px-3 py-1 rounded-full border ${getStatusColor(request.status)}`}>
|
||||
{getStatusIcon(request.status)}
|
||||
<span className="text-sm font-medium capitalize">{request.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-1">Reason:</p>
|
||||
<p className="text-sm">{request.reason}</p>
|
||||
</div>
|
||||
|
||||
{request.adminNotes && (
|
||||
<div className="mb-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-1 flex items-center gap-1">
|
||||
<TbNotes className="w-4 h-4" />
|
||||
Admin Notes:
|
||||
</p>
|
||||
<p className="text-sm">{request.adminNotes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
Submitted: {new Date(request.createdAt).toLocaleDateString()}
|
||||
{request.reviewedAt && (
|
||||
<span className="ml-4">
|
||||
Reviewed: {new Date(request.reviewedAt).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
|
@ -1,9 +1,214 @@
|
|||
"use client"
|
||||
|
||||
import { Nav } from "@/components/core/nav"
|
||||
import { SiForgejo, SiJellyfin, SiOllama } from "react-icons/si"
|
||||
import { TbKey, TbMail, TbServer, TbTool } from "react-icons/tb"
|
||||
import { SiForgejo, SiJellyfin, SiOllama, SiVaultwarden } from "react-icons/si"
|
||||
import { TbMail, TbServer, TbTool, TbKey, TbLogin, TbSend, TbExternalLink, TbInfoCircle } from "react-icons/tb"
|
||||
import Link from "next/link"
|
||||
import { useEffect, useState } from "react"
|
||||
import { authClient } from "@/util/auth-client"
|
||||
|
||||
interface Service {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
priceStatus: string;
|
||||
joinLink?: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface UserService {
|
||||
serviceId: string;
|
||||
serviceName: string;
|
||||
serviceDescription: string;
|
||||
priceStatus: string;
|
||||
joinLink?: string;
|
||||
grantedAt: string | null;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
const getServiceIcon = (serviceName: string) => {
|
||||
switch (serviceName.toLowerCase()) {
|
||||
case 'git':
|
||||
return SiForgejo;
|
||||
case 'tv':
|
||||
return SiJellyfin;
|
||||
case 'ai':
|
||||
return SiOllama;
|
||||
case 'mail':
|
||||
case 'email':
|
||||
return TbMail;
|
||||
case 'hosting':
|
||||
return TbServer;
|
||||
case 'keybox':
|
||||
return TbKey;
|
||||
case 'pass':
|
||||
return SiVaultwarden;
|
||||
default:
|
||||
return TbTool;
|
||||
}
|
||||
};
|
||||
|
||||
export default function Services() {
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [userServices, setUserServices] = useState<UserService[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchServices();
|
||||
if (session) {
|
||||
fetchUserServices();
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
const fetchServices = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/services");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setServices(data.services);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching services:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUserServices = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/user-services");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUserServices(data.services);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching user services:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const hasAccess = (serviceName: string) => {
|
||||
return userServices.some(userService => userService.serviceName === serviceName);
|
||||
};
|
||||
|
||||
const getUserJoinLink = (serviceName: string) => {
|
||||
const userService = userServices.find(us => us.serviceName === serviceName);
|
||||
return userService?.joinLink;
|
||||
};
|
||||
|
||||
const getServiceButtonContent = (service: Service) => {
|
||||
const isLoggedIn = !!session;
|
||||
const userHasAccess = hasAccess(service.name);
|
||||
const userJoinLink = getUserJoinLink(service.name);
|
||||
const joinLink = userJoinLink || service.joinLink;
|
||||
|
||||
if (isLoggedIn && userHasAccess && joinLink) {
|
||||
return (
|
||||
<Link href={joinLink} target="_blank" rel="noopener noreferrer">
|
||||
<button className="flex flex-row items-center justify-center gap-1 text-white bg-green-600 px-3 py-1.5 rounded-lg text-sm hover:bg-green-700 transition-all duration-300 cursor-pointer">
|
||||
<TbExternalLink size={14} />
|
||||
Open
|
||||
</button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoggedIn && !userHasAccess && (service.priceStatus === 'by-request' || service.priceStatus === 'invite-only')) {
|
||||
return (
|
||||
<Link href="/requests">
|
||||
<button className="flex flex-row items-center justify-center gap-1 text-white bg-blue-600 px-3 py-1.5 rounded-lg text-sm hover:bg-blue-700 transition-all duration-300 cursor-pointer">
|
||||
<TbSend size={14} />
|
||||
Request
|
||||
</button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoggedIn && service.priceStatus === 'open' && joinLink) {
|
||||
return (
|
||||
<Link href={joinLink} target="_blank" rel="noopener noreferrer">
|
||||
<button className="flex flex-row items-center justify-center gap-1 text-white bg-green-600 px-3 py-1.5 rounded-lg text-sm hover:bg-green-700 transition-all duration-300 cursor-pointer">
|
||||
<TbExternalLink size={14} />
|
||||
Join
|
||||
</button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoggedIn && service.priceStatus === 'open' && joinLink) {
|
||||
return (
|
||||
<Link href={joinLink} target="_blank" rel="noopener noreferrer">
|
||||
<button className="flex flex-row items-center justify-center gap-1 text-white bg-green-600 px-3 py-1.5 rounded-lg text-sm hover:bg-green-700 transition-all duration-300 cursor-pointer">
|
||||
<TbExternalLink size={14} />
|
||||
Join
|
||||
</button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoggedIn && (service.priceStatus === 'invite-only' || service.priceStatus === 'by-request')) {
|
||||
return (
|
||||
<Link href="/login">
|
||||
<button className="flex flex-row items-center justify-center gap-1 text-white bg-blue-600 px-3 py-1.5 rounded-lg text-sm hover:bg-blue-700 transition-all duration-300 cursor-pointer">
|
||||
<TbLogin size={14} />
|
||||
Login
|
||||
</button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getServiceCardColor = (service: Service) => {
|
||||
const isLoggedIn = !!session;
|
||||
const userHasAccess = hasAccess(service.name);
|
||||
|
||||
if (isLoggedIn && userHasAccess) {
|
||||
return "bg-green-400 text-white";
|
||||
}
|
||||
|
||||
switch (service.priceStatus) {
|
||||
case 'open':
|
||||
return "bg-blue-400 text-white";
|
||||
case 'invite-only':
|
||||
return "bg-orange-400 text-white";
|
||||
case 'by-request':
|
||||
return "bg-purple-400 text-white";
|
||||
default:
|
||||
return "bg-gray-400 text-white";
|
||||
}
|
||||
};
|
||||
|
||||
if (!mounted || isPending) {
|
||||
return (
|
||||
<main>
|
||||
<Nav />
|
||||
<div className="flex flex-col items-center justify-between gap-6 sm:gap-10 my-12 sm:my-16 px-4">
|
||||
<div className="flex flex-row items-center justify-between gap-2">
|
||||
<TbTool size={32} className="sm:w-9 sm:h-9" />
|
||||
<h1 className="text-3xl sm:text-4xl font-bold">
|
||||
services
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-between gap-2">
|
||||
<h2 className="text-2xl sm:text-3xl font-light text-center w-full flex flex-wrap items-center justify-center">
|
||||
please select a service.
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<div className="animate-pulse text-lg">Loading services...</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<Nav />
|
||||
|
@ -20,68 +225,42 @@ export default function Services() {
|
|||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 my-4 w-full max-w-6xl mx-auto px-4">
|
||||
<Link href="/services/git">
|
||||
<div className="flex flex-col gap-2 text-base sm:text-lg bg-blue-400 text-white px-6 sm:px-8 py-6 sm:py-8 rounded-2xl sm:rounded-4xl hover:bg-blue-500 transition-colors">
|
||||
<div className="flex flex-row items-center justify-between gap-2">
|
||||
<SiForgejo size={28} className="sm:w-9 sm:h-9" />
|
||||
<span className="text-xl sm:text-2xl font-bold">
|
||||
git
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href="/services/mail">
|
||||
<div className="flex flex-col gap-2 text-base sm:text-lg bg-blue-400 text-white px-6 sm:px-8 py-6 sm:py-8 rounded-2xl sm:rounded-4xl hover:bg-blue-500 transition-colors">
|
||||
<div className="flex flex-row items-center justify-between gap-2">
|
||||
<TbMail size={28} className="sm:w-9 sm:h-9" />
|
||||
<span className="text-xl sm:text-2xl font-bold">
|
||||
email
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href="/services/ai">
|
||||
<div className="flex flex-col gap-2 text-base sm:text-lg bg-blue-400 text-white px-6 sm:px-8 py-6 sm:py-8 rounded-2xl sm:rounded-4xl hover:bg-blue-500 transition-colors">
|
||||
<div className="flex flex-row items-center justify-between gap-2">
|
||||
<SiOllama size={28} className="sm:w-9 sm:h-9" />
|
||||
<span className="text-xl sm:text-2xl font-bold">
|
||||
ai
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href="/services/tv">
|
||||
<div className="flex flex-col gap-2 text-base sm:text-lg bg-blue-400 text-white px-6 sm:px-8 py-6 sm:py-8 rounded-2xl sm:rounded-4xl hover:bg-blue-500 transition-colors">
|
||||
<div className="flex flex-row items-center justify-between gap-2">
|
||||
<SiJellyfin size={28} className="sm:w-9 sm:h-9" />
|
||||
<span className="text-xl sm:text-2xl font-bold">
|
||||
tv
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href="/services/keybox">
|
||||
<div className="flex flex-col gap-2 text-base sm:text-lg bg-blue-400 text-white px-6 sm:px-8 py-6 sm:py-8 rounded-2xl sm:rounded-4xl hover:bg-blue-500 transition-colors">
|
||||
<div className="flex flex-row items-center justify-between gap-2">
|
||||
<TbKey size={28} className="sm:w-9 sm:h-9" />
|
||||
<span className="text-xl sm:text-2xl font-bold">
|
||||
keybox
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href="/services/hosting">
|
||||
<div className="flex flex-col gap-2 text-base sm:text-lg bg-blue-400 text-white px-6 sm:px-8 py-6 sm:py-8 rounded-2xl sm:rounded-4xl hover:bg-blue-500 transition-colors">
|
||||
<div className="flex flex-row items-center justify-between gap-2">
|
||||
<TbServer size={28} className="sm:w-9 sm:h-9" />
|
||||
<span className="text-xl sm:text-2xl font-bold">
|
||||
hosting
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<div className="animate-pulse text-lg">Loading services...</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 my-4 w-full max-w-6xl mx-auto px-4">
|
||||
{services.map((service) => {
|
||||
const IconComponent = getServiceIcon(service.name);
|
||||
|
||||
return (
|
||||
<div key={service.id} className="flex flex-col gap-4">
|
||||
<div className={`flex flex-col gap-4 text-base sm:text-lg px-6 sm:px-8 py-6 sm:py-8 rounded-2xl sm:rounded-4xl transition-all ${getServiceCardColor(service)}`}>
|
||||
<Link href={`/services/${service.name}`} className="hover:opacity-90 transition-opacity">
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
<IconComponent size={28} className="sm:w-9 sm:h-9" />
|
||||
<span className="text-xl sm:text-2xl font-bold">
|
||||
{service.name === 'mail' ? 'email' : service.name}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="flex flex-row mt-2 gap-3">
|
||||
{getServiceButtonContent(service)}
|
||||
<Link href={`/services/${service.name}`} target="_blank" rel="noopener noreferrer">
|
||||
<button className="flex flex-row items-center justify-center gap-1 text-white bg-green-600 px-3 py-1.5 rounded-lg text-sm hover:bg-green-700 transition-all duration-300 cursor-pointer">
|
||||
<TbInfoCircle size={14} />
|
||||
Info
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
|
188
app/signup/page.tsx
Normal file
188
app/signup/page.tsx
Normal file
|
@ -0,0 +1,188 @@
|
|||
"use client"
|
||||
|
||||
import Altcha from "@/components/core/altcha";
|
||||
import { Nav } from "@/components/core/nav";
|
||||
import { TbUserPlus } from "react-icons/tb";
|
||||
import { useState } from "react";
|
||||
import { useForm, SubmitHandler } from "react-hook-form";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { authClient } from "@/util/auth-client";
|
||||
|
||||
interface SignupForm {
|
||||
email: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export default function Signup() {
|
||||
const router = useRouter();
|
||||
const [altchaState, setAltchaState] = useState<{ status: "success" | "error" | "expired" | "waiting", token: string }>({ status: "waiting", token: "" });
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [apiError, setApiError] = useState<string>("");
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
watch,
|
||||
} = useForm<SignupForm>();
|
||||
|
||||
const password = watch("password");
|
||||
|
||||
const onSubmit: SubmitHandler<SignupForm> = async (data) => {
|
||||
if (altchaState.status !== "success") {
|
||||
setApiError("Please complete the captcha");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setApiError("");
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/signup", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...data,
|
||||
token: altchaState.token,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setApiError(result.error || "Failed to create account");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await authClient.signIn.email({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
});
|
||||
router.push("/");
|
||||
} catch (signInError) {
|
||||
console.error("Auto-login failed:", signInError);
|
||||
router.push("/login?message=Account created successfully. Please sign in.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Signup error:", error);
|
||||
setApiError("An unexpected error occurred. Please try again.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAltchaStateChange = (e: Event | CustomEvent) => {
|
||||
if ('detail' in e && e.detail?.payload) {
|
||||
setAltchaState({ status: "success", token: e.detail.payload });
|
||||
} else {
|
||||
setAltchaState({ status: "error", token: "" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main>
|
||||
<Nav />
|
||||
<div className="flex flex-col items-center justify-center mt-18 sm:my-16 px-4 gap-18">
|
||||
<div className="flex flex-row items-center justify-between gap-2">
|
||||
<TbUserPlus size={32} className="sm:w-9 sm:h-9" />
|
||||
<h1 className="text-3xl sm:text-4xl font-bold">
|
||||
signup
|
||||
</h1>
|
||||
</div>
|
||||
<form className="flex flex-col bg-foreground/10 rounded-2xl sm:rounded-4xl p-4 gap-4 w-1/4 min-w-80" onSubmit={handleSubmit(onSubmit)}>
|
||||
<h2 className="text-2xl sm:text-3xl font-light text-center w-full flex flex-wrap">
|
||||
name (optional)
|
||||
</h2>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="enter your name"
|
||||
className="w-full p-2 rounded-md bg-foreground/10"
|
||||
{...register("name")}
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl sm:text-3xl font-light text-center w-full flex flex-wrap">
|
||||
email
|
||||
</h2>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="enter your email"
|
||||
className="w-full p-2 rounded-md bg-foreground/10"
|
||||
{...register("email", {
|
||||
required: "Email is required",
|
||||
pattern: {
|
||||
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||
message: "Please enter a valid email address"
|
||||
}
|
||||
})}
|
||||
/>
|
||||
{errors.email && <p className="text-red-500 text-sm">{errors.email.message}</p>}
|
||||
<h2 className="text-2xl sm:text-3xl font-light text-center w-full flex flex-wrap">
|
||||
password
|
||||
</h2>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="enter your password"
|
||||
className="w-full p-2 rounded-md bg-foreground/10"
|
||||
{...register("password", {
|
||||
required: "Password is required",
|
||||
minLength: {
|
||||
value: 8,
|
||||
message: "Password must be at least 8 characters long"
|
||||
},
|
||||
pattern: {
|
||||
value: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
|
||||
message: "Password must contain at least one uppercase letter, one lowercase letter, and one number"
|
||||
}
|
||||
})}
|
||||
/>
|
||||
{errors.password && <p className="text-red-500 text-sm">{errors.password.message}</p>}
|
||||
|
||||
<h2 className="text-2xl sm:text-3xl font-light text-center w-full flex flex-wrap">
|
||||
confirm password
|
||||
</h2>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="confirm your password"
|
||||
className="w-full p-2 rounded-md bg-foreground/10"
|
||||
{...register("confirmPassword", {
|
||||
required: "Please confirm your password",
|
||||
validate: value => value === password || "Passwords do not match"
|
||||
})}
|
||||
/>
|
||||
{errors.confirmPassword && <p className="text-red-500 text-sm">{errors.confirmPassword.message}</p>}
|
||||
|
||||
<h2 className="text-2xl sm:text-3xl font-light text-center w-full flex flex-wrap">
|
||||
captcha
|
||||
</h2>
|
||||
<Altcha onStateChange={handleAltchaStateChange} />
|
||||
|
||||
{apiError && (
|
||||
<div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded-md">
|
||||
{apiError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="bg-blue-400 text-white px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
type="submit"
|
||||
disabled={isLoading || altchaState.status !== "success" || !altchaState.token}
|
||||
>
|
||||
{isLoading ? "Creating account..." : altchaState.status === "success" ? "signup" : "waiting for captcha"}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-sm text-gray-600 mt-4">
|
||||
Already have an account?{" "}
|
||||
<a href="/login" className="text-blue-400 hover:underline">
|
||||
Sign in here
|
||||
</a>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue