mirror of
https://github.com/Kizuren/uLinkShortener.git
synced 2025-12-21 13:06:19 +01:00
Biome format
This commit is contained in:
parent
47969209eb
commit
8729e57def
85 changed files with 2467 additions and 1983 deletions
|
|
@ -96,5 +96,11 @@
|
|||
"organizeImports": "on"
|
||||
}
|
||||
}
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"cssModules": true,
|
||||
"tailwindDirectives": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { NextConfig } from "next";
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
allowedDevOrigins: ['localhost', '*.marcus7i.net'],
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "biome lint",
|
||||
"lint:fix": "biome check --write .",
|
||||
"format": "biome format . --write"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
plugins: ['@tailwindcss/postcss'],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
|
|||
|
|
@ -76,7 +76,9 @@
|
|||
padding: 1.5rem;
|
||||
box-shadow: var(--card-shadow);
|
||||
border: 1px solid var(--border-color);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
transition:
|
||||
transform 0.2s,
|
||||
box-shadow 0.2s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
|
@ -129,7 +131,9 @@
|
|||
padding: 1.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid var(--border-color);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
transition:
|
||||
transform 0.2s,
|
||||
box-shadow 0.2s;
|
||||
height: 20rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -157,7 +161,7 @@
|
|||
.graphs-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
.hero-title {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
|
@ -165,4 +169,4 @@
|
|||
.default-container {
|
||||
max-width: max-content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@
|
|||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dashboardButton, .statsButton {
|
||||
.dashboardButton,
|
||||
.statsButton {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
|
|
@ -42,7 +43,8 @@
|
|||
color: white;
|
||||
}
|
||||
|
||||
.dashboardButton:hover, .statsButton:hover {
|
||||
.dashboardButton:hover,
|
||||
.statsButton:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
|
@ -132,7 +134,8 @@
|
|||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.viewButton, .deleteButton {
|
||||
.viewButton,
|
||||
.deleteButton {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
|
|
@ -152,7 +155,8 @@
|
|||
color: white;
|
||||
}
|
||||
|
||||
.viewButton:hover, .deleteButton:hover {
|
||||
.viewButton:hover,
|
||||
.deleteButton:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
|
|
@ -172,7 +176,8 @@
|
|||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.makeAdminButton, .removeAdminButton {
|
||||
.makeAdminButton,
|
||||
.removeAdminButton {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
|
|
@ -193,7 +198,8 @@
|
|||
color: white;
|
||||
}
|
||||
|
||||
.makeAdminButton:hover, .removeAdminButton:hover {
|
||||
.makeAdminButton:hover,
|
||||
.removeAdminButton:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
|
|
@ -203,26 +209,26 @@
|
|||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
|
||||
.actionButtons {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.sectionHeader {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
|
||||
.searchContainer {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.searchInput {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
|
@ -13,7 +13,7 @@ export default function AdminDashboard() {
|
|||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
const { showToast } = useToast();
|
||||
|
||||
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [filteredUsers, setFilteredUsers] = useState<User[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
|
@ -23,7 +23,7 @@ export default function AdminDashboard() {
|
|||
const [isRecreatingStats, setIsRecreatingStats] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "unauthenticated" || (status === "authenticated" && !session?.user?.isAdmin)) {
|
||||
if (status === 'unauthenticated' || (status === 'authenticated' && !session?.user?.isAdmin)) {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
}, [status, session, router]);
|
||||
|
|
@ -41,12 +41,13 @@ export default function AdminDashboard() {
|
|||
}
|
||||
|
||||
const term = searchTerm.toLowerCase();
|
||||
const filtered = users.filter(user =>
|
||||
user.account_id.toLowerCase().includes(term) ||
|
||||
user.created_at.toLocaleString().toLowerCase().includes(term) ||
|
||||
(user.is_admin ? "admin" : "user").includes(term)
|
||||
const filtered = users.filter(
|
||||
user =>
|
||||
user.account_id.toLowerCase().includes(term) ||
|
||||
user.created_at.toLocaleString().toLowerCase().includes(term) ||
|
||||
(user.is_admin ? 'admin' : 'user').includes(term)
|
||||
);
|
||||
|
||||
|
||||
setFilteredUsers(filtered);
|
||||
}, [searchTerm, users]);
|
||||
|
||||
|
|
@ -54,20 +55,20 @@ export default function AdminDashboard() {
|
|||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/admin/users');
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch users');
|
||||
}
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (data.success) {
|
||||
const processedUsers = data.users.map((user: User) => ({
|
||||
account_id: user.account_id,
|
||||
is_admin: user.is_admin,
|
||||
created_at: new Date(user.created_at)
|
||||
created_at: new Date(user.created_at),
|
||||
}));
|
||||
|
||||
|
||||
setUsers(processedUsers);
|
||||
setFilteredUsers(processedUsers);
|
||||
} else {
|
||||
|
|
@ -83,7 +84,7 @@ export default function AdminDashboard() {
|
|||
|
||||
const handleDeleteUser = async () => {
|
||||
if (!userToDelete) return;
|
||||
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/users', {
|
||||
method: 'DELETE',
|
||||
|
|
@ -92,9 +93,9 @@ export default function AdminDashboard() {
|
|||
},
|
||||
body: JSON.stringify({ account_id: userToDelete }),
|
||||
});
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (response.ok && data.success) {
|
||||
showToast('User deleted successfully', 'success');
|
||||
setUsers(prevUsers => prevUsers.filter(user => user.account_id !== userToDelete));
|
||||
|
|
@ -119,11 +120,11 @@ export default function AdminDashboard() {
|
|||
try {
|
||||
setIsRecreatingStats(true);
|
||||
const response = await fetch('/api/admin/statistics/rebuild', {
|
||||
method: 'POST'
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (response.ok && data.success) {
|
||||
showToast('Statistics recreated successfully', 'success');
|
||||
} else {
|
||||
|
|
@ -142,16 +143,14 @@ export default function AdminDashboard() {
|
|||
const response = await fetch(`/api/admin/users/${accountId}/admin`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (response.ok && data.success) {
|
||||
showToast(data.message, 'success');
|
||||
setUsers(prevUsers =>
|
||||
prevUsers.map(user =>
|
||||
user.account_id === accountId
|
||||
? { ...user, is_admin: data.is_admin }
|
||||
: user
|
||||
setUsers(prevUsers =>
|
||||
prevUsers.map(user =>
|
||||
user.account_id === accountId ? { ...user, is_admin: data.is_admin } : user
|
||||
)
|
||||
);
|
||||
} else {
|
||||
|
|
@ -163,11 +162,11 @@ export default function AdminDashboard() {
|
|||
}
|
||||
};
|
||||
|
||||
if (status === "loading" || loading) {
|
||||
if (status === 'loading' || loading) {
|
||||
return <div className={styles.loading}>Loading...</div>;
|
||||
}
|
||||
|
||||
if (status === "unauthenticated" || (status === "authenticated" && !session?.user?.isAdmin)) {
|
||||
if (status === 'unauthenticated' || (status === 'authenticated' && !session?.user?.isAdmin)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -176,11 +175,11 @@ export default function AdminDashboard() {
|
|||
<header className={styles.adminHeader}>
|
||||
<h1 className={styles.adminTitle}>Admin Dashboard</h1>
|
||||
<div className={styles.actionButtons}>
|
||||
<Link href="/dashboard">
|
||||
<Link href='/dashboard'>
|
||||
<button className={styles.dashboardButton}>Back to Dashboard</button>
|
||||
</Link>
|
||||
<button
|
||||
className={styles.statsButton}
|
||||
<button
|
||||
className={styles.statsButton}
|
||||
onClick={handleRecreateStatistics}
|
||||
disabled={isRecreatingStats}
|
||||
>
|
||||
|
|
@ -194,17 +193,17 @@ export default function AdminDashboard() {
|
|||
<h2>Manage Users</h2>
|
||||
<div className={styles.searchContainer}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search users..."
|
||||
type='text'
|
||||
placeholder='Search users...'
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
className={styles.searchInput}
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
<button
|
||||
className={styles.clearSearchButton}
|
||||
onClick={() => setSearchTerm('')}
|
||||
title="Clear search"
|
||||
title='Clear search'
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
|
@ -228,29 +227,29 @@ export default function AdminDashboard() {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredUsers.map((user) => (
|
||||
{filteredUsers.map(user => (
|
||||
<tr key={user.account_id}>
|
||||
<td>{user.account_id}</td>
|
||||
<td>{user.created_at.toLocaleString()}</td>
|
||||
<td>{user.is_admin ? 'Admin' : 'User'}</td>
|
||||
<td className={styles.actions}>
|
||||
<Link href={`/admin/user/${user.account_id}`}>
|
||||
<button className={styles.viewButton}>
|
||||
View Details
|
||||
</button>
|
||||
<button className={styles.viewButton}>View Details</button>
|
||||
</Link>
|
||||
|
||||
|
||||
{user.account_id !== session?.user?.accountId && (
|
||||
<button
|
||||
className={user.is_admin ? styles.removeAdminButton : styles.makeAdminButton}
|
||||
<button
|
||||
className={
|
||||
user.is_admin ? styles.removeAdminButton : styles.makeAdminButton
|
||||
}
|
||||
onClick={() => handleToggleAdminStatus(user.account_id)}
|
||||
>
|
||||
{user.is_admin ? 'Remove Admin' : 'Make Admin'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
{!user.is_admin && (
|
||||
<button
|
||||
<button
|
||||
className={styles.deleteButton}
|
||||
onClick={() => confirmDeleteUser(user.account_id)}
|
||||
>
|
||||
|
|
@ -266,13 +265,13 @@ export default function AdminDashboard() {
|
|||
)}
|
||||
</section>
|
||||
|
||||
<ConfirmModal
|
||||
<ConfirmModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
title="Delete User"
|
||||
message="Are you sure you want to delete this user? This will permanently remove their account and all associated data including links and analytics. This action cannot be undone."
|
||||
title='Delete User'
|
||||
message='Are you sure you want to delete this user? This will permanently remove their account and all associated data including links and analytics. This action cannot be undone.'
|
||||
onConfirm={handleDeleteUser}
|
||||
onCancel={() => setIsDeleteModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@
|
|||
gap: 1rem;
|
||||
}
|
||||
|
||||
.backButton, .deleteButton {
|
||||
.backButton,
|
||||
.deleteButton {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
|
|
@ -42,12 +43,15 @@
|
|||
color: white;
|
||||
}
|
||||
|
||||
.backButton:hover, .deleteButton:hover {
|
||||
.backButton:hover,
|
||||
.deleteButton:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.userInfo, .linksSection, .sessionsSection {
|
||||
.userInfo,
|
||||
.linksSection,
|
||||
.sessionsSection {
|
||||
background-color: var(--card-bg);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
|
|
@ -167,8 +171,8 @@
|
|||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
|
||||
.actionButtons {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -332,13 +332,13 @@
|
|||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
|
||||
.analyticsHeader {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
|
||||
.graphs {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
|
@ -20,7 +20,7 @@ export default function AdminLinkDetailPage() {
|
|||
const { showToast } = useToast();
|
||||
const accountId = params.accountId as string;
|
||||
const shortId = params.shortId as string;
|
||||
|
||||
|
||||
const [link, setLink] = useState<LinkType | null>(null);
|
||||
const [targetUrl, setTargetUrl] = useState('');
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
|
@ -36,7 +36,7 @@ export default function AdminLinkDetailPage() {
|
|||
const [analyticsToDelete, setAnalyticsToDelete] = useState<string>('');
|
||||
const [isLoadingStats, setIsLoadingStats] = useState(true);
|
||||
const isRedirecting = useRef(false);
|
||||
|
||||
|
||||
// Stats data
|
||||
const [browserStats, setBrowserStats] = useState<StatItem[]>([]);
|
||||
const [osStats, setOsStats] = useState<StatItem[]>([]);
|
||||
|
|
@ -44,19 +44,19 @@ export default function AdminLinkDetailPage() {
|
|||
const [ipVersionStats, setIpVersionStats] = useState<StatItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "unauthenticated" || (status === "authenticated" && !session?.user?.isAdmin)) {
|
||||
if (status === 'unauthenticated' || (status === 'authenticated' && !session?.user?.isAdmin)) {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
}, [status, session, router]);
|
||||
|
||||
function isValidUrl(urlStr: string) : boolean {
|
||||
if(urlStr.trim() === "") {
|
||||
function isValidUrl(urlStr: string): boolean {
|
||||
if (urlStr.trim() === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(urlStr);
|
||||
return parsedUrl.protocol !== "" && parsedUrl.hostname !== "";
|
||||
return parsedUrl.protocol !== '' && parsedUrl.hostname !== '';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -69,10 +69,10 @@ export default function AdminLinkDetailPage() {
|
|||
}, 10);
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (isRedirecting.current || status !== "authenticated" || !session?.user?.isAdmin) return;
|
||||
|
||||
if (isRedirecting.current || status !== 'authenticated' || !session?.user?.isAdmin) return;
|
||||
|
||||
async function fetchLinkData() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
|
@ -83,7 +83,7 @@ export default function AdminLinkDetailPage() {
|
|||
router.push(`/admin/user/${accountId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success && data.link) {
|
||||
setLink(data.link);
|
||||
|
|
@ -101,21 +101,23 @@ export default function AdminLinkDetailPage() {
|
|||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fetchLinkData();
|
||||
}, [shortId, accountId, router, showToast, session, status]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!link || status !== "authenticated" || !session?.user?.isAdmin) return;
|
||||
|
||||
if (!link || status !== 'authenticated' || !session?.user?.isAdmin) return;
|
||||
|
||||
async function fetchAllAnalytics() {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${accountId}/links/${shortId}/analytics?all=true`);
|
||||
const response = await fetch(
|
||||
`/api/admin/users/${accountId}/links/${shortId}/analytics?all=true`
|
||||
);
|
||||
if (!response.ok) {
|
||||
showToast('Failed to load complete analytics data', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setAllAnalytics(data.analytics);
|
||||
|
|
@ -125,21 +127,23 @@ export default function AdminLinkDetailPage() {
|
|||
showToast('An error occurred while loading complete analytics data', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fetchAllAnalytics();
|
||||
}, [link, shortId, accountId, showToast, session, status]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!link || status !== "authenticated" || !session?.user?.isAdmin) return;
|
||||
|
||||
if (!link || status !== 'authenticated' || !session?.user?.isAdmin) return;
|
||||
|
||||
async function fetchPaginatedAnalytics() {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${accountId}/links/${shortId}/analytics?page=${page}&limit=${limit}`);
|
||||
const response = await fetch(
|
||||
`/api/admin/users/${accountId}/links/${shortId}/analytics?page=${page}&limit=${limit}`
|
||||
);
|
||||
if (!response.ok) {
|
||||
showToast('Failed to load analytics page', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setAnalytics(data.analytics);
|
||||
|
|
@ -148,13 +152,13 @@ export default function AdminLinkDetailPage() {
|
|||
showToast('An error occurred while loading analytics page', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fetchPaginatedAnalytics();
|
||||
}, [link, shortId, accountId, page, limit, showToast, session, status]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!link || allAnalytics.length === 0) return;
|
||||
|
||||
|
||||
async function generateStats() {
|
||||
setIsLoadingStats(true);
|
||||
try {
|
||||
|
|
@ -164,77 +168,85 @@ export default function AdminLinkDetailPage() {
|
|||
acc[browser] = (acc[browser] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
|
||||
// OS stats
|
||||
const oses = allAnalytics.reduce((acc: Record<string, number>, item) => {
|
||||
const os = item.platform || 'Unknown';
|
||||
acc[os] = (acc[os] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
|
||||
// Country stats
|
||||
const countries = allAnalytics.reduce((acc: Record<string, number>, item) => {
|
||||
const country = item.country || 'Unknown';
|
||||
acc[country] = (acc[country] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
|
||||
// IP version stats
|
||||
const ipVersions = allAnalytics.reduce((acc: Record<string, number>, item) => {
|
||||
const ipVersion = item.ip_version || 'Unknown';
|
||||
acc[ipVersion] = (acc[ipVersion] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
|
||||
// Convert to StatItem[] and sort by count
|
||||
setBrowserStats(Object.entries(browsers)
|
||||
.map(([id, count]) => ({ id, count }))
|
||||
.sort((a, b) => b.count - a.count));
|
||||
|
||||
setOsStats(Object.entries(oses)
|
||||
.map(([id, count]) => ({ id, count }))
|
||||
.sort((a, b) => b.count - a.count));
|
||||
|
||||
setCountryStats(Object.entries(countries)
|
||||
.map(([id, count]) => ({ id, count }))
|
||||
.sort((a, b) => b.count - a.count));
|
||||
|
||||
setIpVersionStats(Object.entries(ipVersions)
|
||||
.map(([id, count]) => ({ id, count }))
|
||||
.sort((a, b) => b.count - a.count));
|
||||
setBrowserStats(
|
||||
Object.entries(browsers)
|
||||
.map(([id, count]) => ({ id, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
);
|
||||
|
||||
setOsStats(
|
||||
Object.entries(oses)
|
||||
.map(([id, count]) => ({ id, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
);
|
||||
|
||||
setCountryStats(
|
||||
Object.entries(countries)
|
||||
.map(([id, count]) => ({ id, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
);
|
||||
|
||||
setIpVersionStats(
|
||||
Object.entries(ipVersions)
|
||||
.map(([id, count]) => ({ id, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
);
|
||||
} catch {
|
||||
showToast('An error occurred while processing analytics data', 'error');
|
||||
} finally {
|
||||
setIsLoadingStats(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
generateStats();
|
||||
}, [allAnalytics, link, showToast]);
|
||||
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
|
||||
const handleEditLink = async () => {
|
||||
if (!isValidUrl(targetUrl)) {
|
||||
showToast('Please enter a valid URL', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${accountId}/links/${shortId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
target_url: targetUrl
|
||||
body: JSON.stringify({
|
||||
target_url: targetUrl,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (data.success) {
|
||||
showToast('Link updated successfully', 'success');
|
||||
setIsEditing(false);
|
||||
|
|
@ -242,7 +254,7 @@ export default function AdminLinkDetailPage() {
|
|||
setLink({
|
||||
...link,
|
||||
target_url: targetUrl,
|
||||
last_modified: new Date()
|
||||
last_modified: new Date(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
|
|
@ -252,7 +264,7 @@ export default function AdminLinkDetailPage() {
|
|||
showToast('An error occurred while updating the link', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleDeleteAnalytics = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${accountId}/links/${shortId}/analytics`, {
|
||||
|
|
@ -260,18 +272,18 @@ export default function AdminLinkDetailPage() {
|
|||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
analytics_id: analyticsToDelete
|
||||
body: JSON.stringify({
|
||||
analytics_id: analyticsToDelete,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (data.success) {
|
||||
showToast('Analytics entry deleted successfully', 'success');
|
||||
|
||||
|
||||
setAnalytics(analytics.filter(item => item._id?.toString() !== analyticsToDelete));
|
||||
|
||||
|
||||
setTotalAnalytics(prev => prev - 1);
|
||||
} else {
|
||||
showToast(data.message || 'Failed to delete analytics entry', 'error');
|
||||
|
|
@ -283,7 +295,7 @@ export default function AdminLinkDetailPage() {
|
|||
setAnalyticsToDelete('');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleDeleteAllAnalytics = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${accountId}/links/${shortId}/analytics`, {
|
||||
|
|
@ -291,16 +303,16 @@ export default function AdminLinkDetailPage() {
|
|||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
delete_all: true
|
||||
body: JSON.stringify({
|
||||
delete_all: true,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (data.success) {
|
||||
showToast('All analytics entries deleted successfully', 'success');
|
||||
|
||||
|
||||
setAnalytics([]);
|
||||
setTotalAnalytics(0);
|
||||
setBrowserStats([]);
|
||||
|
|
@ -316,8 +328,8 @@ export default function AdminLinkDetailPage() {
|
|||
setShowDeleteAllModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (status === "loading" || isLoading) {
|
||||
|
||||
if (status === 'loading' || isLoading) {
|
||||
return (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.loader}></div>
|
||||
|
|
@ -325,25 +337,33 @@ export default function AdminLinkDetailPage() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "unauthenticated" || (status === "authenticated" && !session?.user?.isAdmin)) {
|
||||
|
||||
if (status === 'unauthenticated' || (status === 'authenticated' && !session?.user?.isAdmin)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
if (!link) {
|
||||
return (
|
||||
<div className={styles.error}>Link not found or you don't have permission to view it.</div>
|
||||
<div className={styles.error}>
|
||||
Link not found or you don't have permission to view it.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.titleSection}>
|
||||
<h1 className={styles.title}>Admin Link Management</h1>
|
||||
<div className={styles.breadcrumbs}>
|
||||
<Link href="/admin" className={styles.breadcrumbLink}>Admin</Link> >
|
||||
<Link href={`/admin/user/${accountId}`} className={styles.breadcrumbLink}>User</Link> >
|
||||
<Link href='/admin' className={styles.breadcrumbLink}>
|
||||
Admin
|
||||
</Link>{' '}
|
||||
>
|
||||
<Link href={`/admin/user/${accountId}`} className={styles.breadcrumbLink}>
|
||||
User
|
||||
</Link>{' '}
|
||||
>
|
||||
<span className={styles.breadcrumbCurrent}>Link {shortId}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -351,12 +371,12 @@ export default function AdminLinkDetailPage() {
|
|||
Back to User
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
|
||||
<div className={styles.userInfo}>
|
||||
<span className={styles.label}>Managing link for User ID:</span>
|
||||
<span className={styles.value}>{accountId}</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div className={styles.linkInfo}>
|
||||
<div className={styles.linkCard}>
|
||||
<h2>Link Information</h2>
|
||||
|
|
@ -365,20 +385,20 @@ export default function AdminLinkDetailPage() {
|
|||
<span className={styles.label}>Short ID:</span>
|
||||
<span className={styles.value}>{shortId}</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div className={styles.targetUrlHeader}>
|
||||
<div className={styles.linkDetailItem}>
|
||||
<span className={styles.label}>Short URL:</span>
|
||||
<a
|
||||
href={`${window.location.origin}/l/${shortId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<a
|
||||
href={`${window.location.origin}/l/${shortId}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={styles.shortUrl}
|
||||
>
|
||||
{`${window.location.origin}/l/${shortId}`}
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
<button
|
||||
className={styles.defaultButton}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(`${window.location.origin}/l/${shortId}`);
|
||||
|
|
@ -388,75 +408,74 @@ export default function AdminLinkDetailPage() {
|
|||
Copy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className={styles.linkDetailItem}>
|
||||
<span className={styles.label}>Created:</span>
|
||||
<span className={styles.value}>
|
||||
{link.created_at instanceof Date
|
||||
? link.created_at.toLocaleString()
|
||||
{link.created_at instanceof Date
|
||||
? link.created_at.toLocaleString()
|
||||
: new Date(link.created_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div className={styles.linkDetailItem}>
|
||||
<span className={styles.label}>Last Modified:</span>
|
||||
<span className={styles.value}>
|
||||
{link.last_modified instanceof Date
|
||||
? link.last_modified.toLocaleString()
|
||||
{link.last_modified instanceof Date
|
||||
? link.last_modified.toLocaleString()
|
||||
: new Date(link.last_modified).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div className={styles.targetUrlSection}>
|
||||
<div className={styles.targetUrlHeader}>
|
||||
<span className={styles.label}>Target URL:</span>
|
||||
{!isEditing && (
|
||||
<button
|
||||
className={styles.editButton}
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
<button className={styles.editButton} onClick={() => setIsEditing(true)}>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{isEditing ? (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
handleEditLink();
|
||||
}}
|
||||
className={styles.editForm}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="url"
|
||||
value={targetUrl}
|
||||
onChange={(e) => setTargetUrl(e.target.value)}
|
||||
className={styles.urlInput}
|
||||
placeholder="https://example.com"
|
||||
<input
|
||||
ref={inputRef}
|
||||
type='url'
|
||||
value={targetUrl}
|
||||
onChange={e => setTargetUrl(e.target.value)}
|
||||
className={styles.urlInput}
|
||||
placeholder='https://example.com'
|
||||
/>
|
||||
<div className={styles.editActions}>
|
||||
<button
|
||||
type="button"
|
||||
<button
|
||||
type='button'
|
||||
className={styles.cancelButton}
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
setTargetUrl(link?.target_url || '');
|
||||
setIsEditing(false);
|
||||
setTargetUrl(link?.target_url || '');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.saveButton}
|
||||
>
|
||||
<button type='submit' className={styles.saveButton}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<a href={link.target_url} target="_blank" rel="noopener noreferrer" className={styles.targetUrl}>
|
||||
<a
|
||||
href={link.target_url}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={styles.targetUrl}
|
||||
>
|
||||
{link.target_url}
|
||||
</a>
|
||||
)}
|
||||
|
|
@ -464,75 +483,55 @@ export default function AdminLinkDetailPage() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className={styles.analyticsSection}>
|
||||
<div className={styles.analyticsHeader}>
|
||||
<h2>Analytics</h2>
|
||||
<span className={styles.totalClicks}>
|
||||
Total Clicks: {totalAnalytics}
|
||||
</span>
|
||||
<span className={styles.totalClicks}>Total Clicks: {totalAnalytics}</span>
|
||||
{totalAnalytics > 0 && (
|
||||
<button
|
||||
className={styles.deleteAllButton}
|
||||
onClick={() => setShowDeleteAllModal(true)}
|
||||
>
|
||||
<button className={styles.deleteAllButton} onClick={() => setShowDeleteAllModal(true)}>
|
||||
Delete All Analytics
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{totalAnalytics > 0 ? (
|
||||
<>
|
||||
<div className={styles.graphs}>
|
||||
<div className={styles.graphCard}>
|
||||
<h3>Browsers</h3>
|
||||
<Graph
|
||||
type="doughnut"
|
||||
data={browserStats}
|
||||
loading={isLoadingStats}
|
||||
height={200}
|
||||
/>
|
||||
<Graph type='doughnut' data={browserStats} loading={isLoadingStats} height={200} />
|
||||
</div>
|
||||
|
||||
|
||||
<div className={styles.graphCard}>
|
||||
<h3>Operating Systems</h3>
|
||||
<Graph
|
||||
type="doughnut"
|
||||
data={osStats}
|
||||
loading={isLoadingStats}
|
||||
height={200}
|
||||
/>
|
||||
<Graph type='doughnut' data={osStats} loading={isLoadingStats} height={200} />
|
||||
</div>
|
||||
|
||||
|
||||
<div className={styles.graphCard}>
|
||||
<h3>Countries</h3>
|
||||
<Graph
|
||||
type="doughnut"
|
||||
data={countryStats}
|
||||
loading={isLoadingStats}
|
||||
height={200}
|
||||
/>
|
||||
<Graph type='doughnut' data={countryStats} loading={isLoadingStats} height={200} />
|
||||
</div>
|
||||
|
||||
|
||||
<div className={styles.graphCard}>
|
||||
<h3>IP Versions</h3>
|
||||
<Graph
|
||||
type="doughnut"
|
||||
data={ipVersionStats}
|
||||
loading={isLoadingStats}
|
||||
<Graph
|
||||
type='doughnut'
|
||||
data={ipVersionStats}
|
||||
loading={isLoadingStats}
|
||||
height={200}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnalyticsTable
|
||||
analytics={analytics}
|
||||
|
||||
<AnalyticsTable
|
||||
analytics={analytics}
|
||||
allAnalytics={allAnalytics}
|
||||
totalItems={totalAnalytics}
|
||||
currentPage={page}
|
||||
itemsPerPage={limit}
|
||||
onPageChange={handlePageChange}
|
||||
onDeleteClick={(id) => {
|
||||
onDeleteClick={id => {
|
||||
setAnalyticsToDelete(id);
|
||||
setShowDeleteModal(true);
|
||||
}}
|
||||
|
|
@ -544,31 +543,31 @@ export default function AdminLinkDetailPage() {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Confirm Delete Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={showDeleteModal}
|
||||
title="Delete Analytics Entry"
|
||||
message="Are you sure you want to delete this analytics entry? This action cannot be undone."
|
||||
confirmLabel="Delete"
|
||||
cancelLabel="Cancel"
|
||||
title='Delete Analytics Entry'
|
||||
message='Are you sure you want to delete this analytics entry? This action cannot be undone.'
|
||||
confirmLabel='Delete'
|
||||
cancelLabel='Cancel'
|
||||
onConfirm={handleDeleteAnalytics}
|
||||
onCancel={() => {
|
||||
setShowDeleteModal(false);
|
||||
setAnalyticsToDelete('');
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
{/* Confirm Delete All Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={showDeleteAllModal}
|
||||
title="Delete All Analytics"
|
||||
message="Are you sure you want to delete all analytics for this link? This action cannot be undone."
|
||||
confirmLabel="Delete All"
|
||||
cancelLabel="Cancel"
|
||||
title='Delete All Analytics'
|
||||
message='Are you sure you want to delete all analytics for this link? This action cannot be undone.'
|
||||
confirmLabel='Delete All'
|
||||
cancelLabel='Cancel'
|
||||
onConfirm={handleDeleteAllAnalytics}
|
||||
onCancel={() => setShowDeleteAllModal(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
|
|
@ -17,49 +17,49 @@ export default function UserDetailPage() {
|
|||
const { data: session, status } = useSession();
|
||||
const { showToast } = useToast();
|
||||
const accountId = params.accountId as string;
|
||||
|
||||
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [links, setLinks] = useState([]);
|
||||
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [revoking, setRevoking] = useState<string | null>(null);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "unauthenticated" || (status === "authenticated" && !session?.user?.isAdmin)) {
|
||||
if (status === 'unauthenticated' || (status === 'authenticated' && !session?.user?.isAdmin)) {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
}, [status, session, router]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "authenticated" && session?.user?.isAdmin) {
|
||||
if (status === 'authenticated' && session?.user?.isAdmin) {
|
||||
fetchUserData();
|
||||
}
|
||||
}, [status, session, accountId]);
|
||||
|
||||
|
||||
const fetchUserData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const userResponse = await fetch(`/api/admin/users/${accountId}`);
|
||||
|
||||
|
||||
if (!userResponse.ok) {
|
||||
throw new Error('Failed to fetch user details');
|
||||
}
|
||||
|
||||
|
||||
const userData = await userResponse.json();
|
||||
if (userData.success) {
|
||||
setUser(userData.user);
|
||||
|
||||
|
||||
const linksResponse = await fetch(`/api/admin/users/${accountId}/links`);
|
||||
const linksData = await linksResponse.json();
|
||||
|
||||
|
||||
if (linksResponse.ok && linksData.success) {
|
||||
setLinks(linksData.links);
|
||||
}
|
||||
|
||||
|
||||
const sessionsResponse = await fetch(`/api/admin/users/${accountId}/sessions`);
|
||||
const sessionsData = await sessionsResponse.json();
|
||||
|
||||
|
||||
if (sessionsResponse.ok && sessionsData.success) {
|
||||
setSessions(sessionsData.sessions);
|
||||
}
|
||||
|
|
@ -75,24 +75,22 @@ export default function UserDetailPage() {
|
|||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleRevokeSession = async (sessionId: string) => {
|
||||
try {
|
||||
setRevoking(sessionId);
|
||||
|
||||
|
||||
const response = await fetch(`/api/admin/users/${accountId}/sessions/revoke`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, accountId })
|
||||
body: JSON.stringify({ sessionId, accountId }),
|
||||
});
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (response.ok && data.success) {
|
||||
showToast('Session revoked successfully', 'success');
|
||||
setSessions(prevSessions =>
|
||||
prevSessions.filter(s => s.id !== sessionId)
|
||||
);
|
||||
setSessions(prevSessions => prevSessions.filter(s => s.id !== sessionId));
|
||||
} else {
|
||||
showToast(data.message || 'Failed to revoke session', 'error');
|
||||
}
|
||||
|
|
@ -103,7 +101,7 @@ export default function UserDetailPage() {
|
|||
setRevoking(null);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/users', {
|
||||
|
|
@ -113,9 +111,9 @@ export default function UserDetailPage() {
|
|||
},
|
||||
body: JSON.stringify({ account_id: accountId }),
|
||||
});
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (response.ok && data.success) {
|
||||
showToast('User deleted successfully', 'success');
|
||||
router.push('/admin');
|
||||
|
|
@ -134,34 +132,31 @@ export default function UserDetailPage() {
|
|||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
if (status === "loading" || loading) {
|
||||
|
||||
if (status === 'loading' || loading) {
|
||||
return <div className={styles.loading}>Loading user details...</div>;
|
||||
}
|
||||
|
||||
|
||||
if (!user) {
|
||||
return <div className={styles.error}>User not found</div>;
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<header className={styles.header}>
|
||||
<h1 className={styles.title}>User Details</h1>
|
||||
<div className={styles.actionButtons}>
|
||||
<Link href="/admin">
|
||||
<Link href='/admin'>
|
||||
<button className={styles.backButton}>Back to Admin</button>
|
||||
</Link>
|
||||
{!user.is_admin && (
|
||||
<button
|
||||
className={styles.deleteButton}
|
||||
onClick={() => setIsDeleteModalOpen(true)}
|
||||
>
|
||||
<button className={styles.deleteButton} onClick={() => setIsDeleteModalOpen(true)}>
|
||||
Delete User
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
<section className={styles.userInfo}>
|
||||
<h2>Account Information</h2>
|
||||
<div className={styles.infoCard}>
|
||||
|
|
@ -179,7 +174,7 @@ export default function UserDetailPage() {
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<section className={styles.linksSection}>
|
||||
<h2>User Links</h2>
|
||||
{links.length === 0 ? (
|
||||
|
|
@ -188,7 +183,7 @@ export default function UserDetailPage() {
|
|||
<AdminLinkTable links={links} accountId={user.account_id} onLinkDeleted={fetchUserData} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
|
||||
<section className={styles.sessionsSection}>
|
||||
<h2>Active Sessions</h2>
|
||||
{sessions.length === 0 ? (
|
||||
|
|
@ -206,7 +201,7 @@ export default function UserDetailPage() {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sessions.map((s) => (
|
||||
{sessions.map(s => (
|
||||
<tr key={s.id}>
|
||||
<td>{s.userAgent.split(' ').slice(0, 3).join(' ')}</td>
|
||||
<td>{s.ipAddress}</td>
|
||||
|
|
@ -228,14 +223,14 @@ export default function UserDetailPage() {
|
|||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<ConfirmModal
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
title="Delete User"
|
||||
title='Delete User'
|
||||
message={`Are you sure you want to delete user ${accountId}? This will permanently remove their account and all associated data including links and analytics. This action cannot be undone.`}
|
||||
onConfirm={handleDeleteUser}
|
||||
onCancel={() => setIsDeleteModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,25 +7,31 @@ import logger from '@/lib/logger';
|
|||
export async function POST() {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
|
||||
if (!session?.user?.isAdmin) {
|
||||
return NextResponse.json({
|
||||
message: "Unauthorized",
|
||||
success: false,
|
||||
}, { status: 401 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const result = await updateStats();
|
||||
|
||||
|
||||
return NextResponse.json({
|
||||
message: result.status,
|
||||
success: result.success,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error rebuilding statistics:', { error });
|
||||
return NextResponse.json({
|
||||
message: "Failed to rebuild statistics",
|
||||
success: false,
|
||||
}, { status: 500 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to rebuild statistics',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,47 +11,59 @@ export async function POST(
|
|||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
|
||||
if (!session?.user?.isAdmin) {
|
||||
return NextResponse.json({
|
||||
message: "Unauthorized",
|
||||
success: false,
|
||||
}, { status: 401 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const { accountId } = await params;
|
||||
|
||||
|
||||
if (!accountId) {
|
||||
return NextResponse.json({
|
||||
message: "Account ID is required",
|
||||
success: false,
|
||||
}, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Account ID is required',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (accountId === session.user.accountId) {
|
||||
return NextResponse.json({
|
||||
message: "You cannot change your own admin status",
|
||||
success: false,
|
||||
}, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'You cannot change your own admin status',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const isAdmin = await isUserAdmin(accountId);
|
||||
const result = await makeUserAdmin(accountId, !isAdmin);
|
||||
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json({
|
||||
message: result.status,
|
||||
success: false,
|
||||
}, { status: result.status === "User not found" ? 404 : 500 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: result.status,
|
||||
success: false,
|
||||
},
|
||||
{ status: result.status === 'User not found' ? 404 : 500 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
await removeAllSessionsByAccountId(accountId);
|
||||
|
||||
logger.info(`Admin status toggled for user ${accountId}, all sessions revoked`, {
|
||||
accountId,
|
||||
newStatus: !isAdmin
|
||||
|
||||
logger.info(`Admin status toggled for user ${accountId}, all sessions revoked`, {
|
||||
accountId,
|
||||
newStatus: !isAdmin,
|
||||
});
|
||||
|
||||
|
||||
return NextResponse.json({
|
||||
message: `${result.status} User will need to log in again.`,
|
||||
is_admin: !isAdmin,
|
||||
|
|
@ -59,9 +71,12 @@ export async function POST(
|
|||
});
|
||||
} catch (error) {
|
||||
logger.error('Error toggling admin status:', error);
|
||||
return NextResponse.json({
|
||||
message: "Failed to toggle admin status",
|
||||
success: false,
|
||||
}, { status: 500 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to toggle admin status',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,110 +7,141 @@ import logger from '@/lib/logger';
|
|||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ accountId: string, shortId: string }> }
|
||||
{ params }: { params: Promise<{ accountId: string; shortId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
|
||||
if (!session?.user?.isAdmin) {
|
||||
return NextResponse.json({
|
||||
message: "Unauthorized",
|
||||
success: false,
|
||||
}, { status: 401 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const { accountId, shortId } = await params;
|
||||
|
||||
|
||||
if (!accountId || !shortId) {
|
||||
return NextResponse.json({
|
||||
message: "Account ID and Short ID are required",
|
||||
success: false,
|
||||
}, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Account ID and Short ID are required',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const url = new URL(req.url);
|
||||
const page = parseInt(url.searchParams.get('page') || '1');
|
||||
const limit = parseInt(url.searchParams.get('limit') || '50');
|
||||
|
||||
const { analytics, total } = await getAllAnalytics(
|
||||
accountId,
|
||||
shortId,
|
||||
{ page, limit }
|
||||
);
|
||||
|
||||
|
||||
const { analytics, total } = await getAllAnalytics(accountId, shortId, { page, limit });
|
||||
|
||||
const sanitizedAnalytics = analytics.map(item => sanitizeMongoDocument(item));
|
||||
|
||||
|
||||
return NextResponse.json({
|
||||
analytics: sanitizedAnalytics,
|
||||
pagination: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
pages: Math.ceil(total / limit)
|
||||
pages: Math.ceil(total / limit),
|
||||
},
|
||||
success: true,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error getting analytics:', { error, accountId: (await params).accountId, shortId: (await params).shortId });
|
||||
return NextResponse.json({
|
||||
message: "Failed to retrieve analytics",
|
||||
success: false,
|
||||
}, { status: 500 });
|
||||
logger.error('Error getting analytics:', {
|
||||
error,
|
||||
accountId: (await params).accountId,
|
||||
shortId: (await params).shortId,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to retrieve analytics',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ accountId: string, shortId: string }> }
|
||||
{ params }: { params: Promise<{ accountId: string; shortId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
|
||||
if (!session?.user?.isAdmin) {
|
||||
return NextResponse.json({
|
||||
message: "Unauthorized",
|
||||
success: false,
|
||||
}, { status: 401 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const { accountId, shortId } = await params;
|
||||
|
||||
|
||||
if (!accountId || !shortId) {
|
||||
return NextResponse.json({
|
||||
message: "Account ID and Short ID are required",
|
||||
success: false,
|
||||
}, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Account ID and Short ID are required',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const body = await req.json();
|
||||
|
||||
|
||||
if (body.delete_all) {
|
||||
const result = await removeAllAnalytics(accountId, shortId);
|
||||
|
||||
return NextResponse.json({
|
||||
message: result.status,
|
||||
success: result.success,
|
||||
}, { status: result.success ? 200 : 400 });
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: result.status,
|
||||
success: result.success,
|
||||
},
|
||||
{ status: result.success ? 200 : 400 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (body.analytics_id) {
|
||||
const result = await removeAnalytics(accountId, shortId, body.analytics_id);
|
||||
|
||||
return NextResponse.json({
|
||||
message: result.status,
|
||||
success: result.success,
|
||||
}, { status: result.success ? 200 : 400 });
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: result.status,
|
||||
success: result.success,
|
||||
},
|
||||
{ status: result.success ? 200 : 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: "Either delete_all or analytics_id must be provided",
|
||||
success: false,
|
||||
}, { status: 400 });
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Either delete_all or analytics_id must be provided',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Error deleting analytics:', { error, accountId: (await params).accountId, shortId: (await params).shortId });
|
||||
return NextResponse.json({
|
||||
message: "Failed to delete analytics",
|
||||
success: false,
|
||||
}, { status: 500 });
|
||||
logger.error('Error deleting analytics:', {
|
||||
error,
|
||||
accountId: (await params).accountId,
|
||||
shortId: (await params).shortId,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to delete analytics',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,131 +7,182 @@ import logger from '@/lib/logger';
|
|||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ accountId: string, shortId: string }> }
|
||||
{ params }: { params: Promise<{ accountId: string; shortId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
|
||||
if (!session?.user?.isAdmin) {
|
||||
return NextResponse.json({
|
||||
message: "Unauthorized",
|
||||
success: false,
|
||||
}, { status: 401 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const { accountId, shortId } = await params;
|
||||
|
||||
|
||||
if (!accountId || !shortId) {
|
||||
return NextResponse.json({
|
||||
message: "Account ID and Short ID are required",
|
||||
success: false,
|
||||
}, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Account ID and Short ID are required',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const { link, return: linkReturn } = await getLinkById(accountId, shortId);
|
||||
|
||||
|
||||
if (!linkReturn.success || !link) {
|
||||
return NextResponse.json({
|
||||
message: linkReturn.status,
|
||||
success: false,
|
||||
}, { status: 404 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: linkReturn.status,
|
||||
success: false,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return NextResponse.json({
|
||||
link: sanitizeMongoDocument(link),
|
||||
success: true,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error getting link details:', { error, accountId: (await params).accountId, shortId: (await params).shortId });
|
||||
return NextResponse.json({
|
||||
message: "Failed to retrieve link details",
|
||||
success: false,
|
||||
}, { status: 500 });
|
||||
logger.error('Error getting link details:', {
|
||||
error,
|
||||
accountId: (await params).accountId,
|
||||
shortId: (await params).shortId,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to retrieve link details',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ accountId: string, shortId: string }> }
|
||||
{ params }: { params: Promise<{ accountId: string; shortId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
|
||||
if (!session?.user?.isAdmin) {
|
||||
return NextResponse.json({
|
||||
message: "Unauthorized",
|
||||
success: false,
|
||||
}, { status: 401 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const { accountId, shortId } = await params;
|
||||
|
||||
|
||||
if (!accountId || !shortId) {
|
||||
return NextResponse.json({
|
||||
message: "Account ID and Short ID are required",
|
||||
success: false,
|
||||
}, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Account ID and Short ID are required',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const body = await req.json();
|
||||
const { target_url } = body;
|
||||
|
||||
|
||||
if (!target_url) {
|
||||
return NextResponse.json({
|
||||
message: "Target URL is required",
|
||||
success: false,
|
||||
}, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Target URL is required',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const result = await editLink(accountId, shortId, target_url);
|
||||
|
||||
return NextResponse.json({
|
||||
message: result.status,
|
||||
success: result.success,
|
||||
}, { status: result.success ? 200 : 400 });
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: result.status,
|
||||
success: result.success,
|
||||
},
|
||||
{ status: result.success ? 200 : 400 }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Error updating link:', { error, accountId: (await params).accountId, shortId: (await params).shortId });
|
||||
return NextResponse.json({
|
||||
message: "Failed to update link",
|
||||
success: false,
|
||||
}, { status: 500 });
|
||||
logger.error('Error updating link:', {
|
||||
error,
|
||||
accountId: (await params).accountId,
|
||||
shortId: (await params).shortId,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to update link',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ accountId: string, shortId: string }> }
|
||||
{ params }: { params: Promise<{ accountId: string; shortId: string }> }
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
|
||||
if (!session?.user?.isAdmin) {
|
||||
return NextResponse.json({
|
||||
message: "Unauthorized",
|
||||
success: false,
|
||||
}, { status: 401 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const { accountId, shortId } = await params;
|
||||
|
||||
|
||||
if (!accountId || !shortId) {
|
||||
return NextResponse.json({
|
||||
message: "Account ID and Short ID are required",
|
||||
success: false,
|
||||
}, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Account ID and Short ID are required',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const result = await removeLink(accountId, shortId);
|
||||
|
||||
return NextResponse.json({
|
||||
message: result.status,
|
||||
success: result.success,
|
||||
}, { status: result.success ? 200 : 400 });
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: result.status,
|
||||
success: result.success,
|
||||
},
|
||||
{ status: result.success ? 200 : 400 }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Error deleting link:', { error, accountId: (await params).accountId, shortId: (await params).shortId });
|
||||
return NextResponse.json({
|
||||
message: "Failed to delete link",
|
||||
success: false,
|
||||
}, { status: 500 });
|
||||
logger.error('Error deleting link:', {
|
||||
error,
|
||||
accountId: (await params).accountId,
|
||||
shortId: (await params).shortId,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to delete link',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,35 +11,44 @@ export async function GET(
|
|||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
|
||||
if (!session?.user?.isAdmin) {
|
||||
return NextResponse.json({
|
||||
message: "Unauthorized",
|
||||
success: false,
|
||||
}, { status: 401 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const { accountId } = await params;
|
||||
|
||||
|
||||
if (!accountId) {
|
||||
return NextResponse.json({
|
||||
message: "Account ID is required",
|
||||
success: false,
|
||||
}, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Account ID is required',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const { links } = await getLinks(accountId);
|
||||
const sanitizedLinks = links.map(link => (sanitizeMongoDocument(link)));
|
||||
|
||||
const sanitizedLinks = links.map(link => sanitizeMongoDocument(link));
|
||||
|
||||
return NextResponse.json({
|
||||
links: sanitizedLinks,
|
||||
success: true,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error getting user links:', error);
|
||||
return NextResponse.json({
|
||||
message: "Failed to retrieve user links",
|
||||
success: false,
|
||||
}, { status: 500 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to retrieve user links',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,43 +11,55 @@ export async function GET(
|
|||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
|
||||
if (!session?.user?.isAdmin) {
|
||||
return NextResponse.json({
|
||||
message: "Unauthorized",
|
||||
success: false,
|
||||
}, { status: 401 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const { accountId } = await params;
|
||||
|
||||
|
||||
if (!accountId) {
|
||||
return NextResponse.json({
|
||||
message: "Account ID is required",
|
||||
success: false,
|
||||
}, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Account ID is required',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const user = await getUserById(accountId);
|
||||
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({
|
||||
message: "User not found",
|
||||
success: false,
|
||||
}, { status: 404 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'User not found',
|
||||
success: false,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const sanitizedUser = sanitizeMongoDocument(user);
|
||||
|
||||
|
||||
return NextResponse.json({
|
||||
user: sanitizedUser,
|
||||
success: true,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error getting user:', error);
|
||||
return NextResponse.json({
|
||||
message: "Failed to retrieve user",
|
||||
success: false,
|
||||
}, { status: 500 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to retrieve user',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,33 +7,42 @@ import logger from '@/lib/logger';
|
|||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
|
||||
if (!session?.user?.isAdmin) {
|
||||
return NextResponse.json({
|
||||
message: "Unauthorized",
|
||||
success: false,
|
||||
}, { status: 401 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const { sessionId, accountId } = await req.json();
|
||||
|
||||
|
||||
if (!sessionId || !accountId) {
|
||||
return NextResponse.json({
|
||||
message: "Session ID and Account ID are required",
|
||||
success: false,
|
||||
}, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Session ID and Account ID are required',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const result = await revokeSession(sessionId, accountId);
|
||||
|
||||
|
||||
return NextResponse.json({
|
||||
...result,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error revoking session:', { error });
|
||||
return NextResponse.json({
|
||||
message: "Failed to revoke session",
|
||||
success: false,
|
||||
}, { status: 500 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to revoke session',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,37 +11,46 @@ export async function GET(
|
|||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
|
||||
if (!session?.user?.isAdmin) {
|
||||
return NextResponse.json({
|
||||
message: "Unauthorized",
|
||||
success: false,
|
||||
}, { status: 401 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const { accountId } = await params;
|
||||
|
||||
|
||||
if (!accountId) {
|
||||
return NextResponse.json({
|
||||
message: "Account ID is required",
|
||||
success: false,
|
||||
}, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Account ID is required',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const sessions = await getSessions(accountId);
|
||||
const sanitizedSessions = Array.isArray(sessions)
|
||||
const sanitizedSessions = Array.isArray(sessions)
|
||||
? sessions.map(session => sanitizeMongoDocument(session))
|
||||
: [];
|
||||
|
||||
|
||||
return NextResponse.json({
|
||||
sessions: sanitizedSessions,
|
||||
success: true,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error getting user sessions:', error);
|
||||
return NextResponse.json({
|
||||
message: "Failed to retrieve user sessions",
|
||||
success: false,
|
||||
}, { status: 500 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to retrieve user sessions',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,84 +8,108 @@ import logger from '@/lib/logger';
|
|||
export async function GET() {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
|
||||
if (!session?.user?.isAdmin) {
|
||||
return NextResponse.json({
|
||||
message: "Unauthorized",
|
||||
success: false,
|
||||
}, { status: 401 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const result = await listUsers();
|
||||
|
||||
|
||||
if (result.users) {
|
||||
const sanitizedUsers = result.users.map(user => (sanitizeMongoDocument(user)));
|
||||
|
||||
const sanitizedUsers = result.users.map(user => sanitizeMongoDocument(user));
|
||||
|
||||
return NextResponse.json({
|
||||
users: sanitizedUsers,
|
||||
total: result.total,
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: result.return.status,
|
||||
success: result.return.success,
|
||||
}, { status: result.return.success ? 200 : 500 });
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: result.return.status,
|
||||
success: result.return.success,
|
||||
},
|
||||
{ status: result.return.success ? 200 : 500 }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Error getting users:', { error });
|
||||
return NextResponse.json({
|
||||
message: "Failed to retrieve users",
|
||||
success: false,
|
||||
}, { status: 500 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to retrieve users',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
|
||||
if (!session?.user?.isAdmin) {
|
||||
return NextResponse.json({
|
||||
message: "Unauthorized",
|
||||
success: false,
|
||||
}, { status: 401 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const { account_id } = await req.json();
|
||||
|
||||
|
||||
if (!account_id) {
|
||||
return NextResponse.json({
|
||||
message: "Account ID is required",
|
||||
success: false,
|
||||
}, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Account ID is required',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (account_id === session.user.accountId) {
|
||||
return NextResponse.json({
|
||||
message: "You cannot delete your own account from admin panel",
|
||||
success: false,
|
||||
}, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'You cannot delete your own account from admin panel',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (await isUserAdmin(account_id)) {
|
||||
return NextResponse.json({
|
||||
message: "Cannot delete admin accounts",
|
||||
success: false,
|
||||
}, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Cannot delete admin accounts',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const result = await removeUser(account_id);
|
||||
|
||||
|
||||
return NextResponse.json({
|
||||
message: result.status,
|
||||
success: result.success,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error deleting user:', { error });
|
||||
return NextResponse.json({
|
||||
message: "Failed to delete user",
|
||||
success: false,
|
||||
}, { status: 500 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to delete user',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,75 +9,87 @@ import logger from '@/lib/logger';
|
|||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
|
||||
if (!session?.user?.accountId) {
|
||||
logger.info('Analytics request failed due to unauthorized access', { url: req.url });
|
||||
return NextResponse.json({
|
||||
message: "Unauthorized. Please sign in first.",
|
||||
success: false,
|
||||
}, { status: 401 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized. Please sign in first.',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const account_id = session.user.accountId;
|
||||
const url = new URL(req.url);
|
||||
|
||||
|
||||
const link_id = url.searchParams.get('link_id');
|
||||
if (!link_id) {
|
||||
logger.info('Analytics request failed due to missing link_id', { account_id, url: req.url });
|
||||
return NextResponse.json({
|
||||
message: "Missing link_id parameter",
|
||||
success: false,
|
||||
}, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Missing link_id parameter',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const all = url.searchParams.get('all') === 'true';
|
||||
|
||||
|
||||
let page = 1;
|
||||
let limit = 50;
|
||||
|
||||
|
||||
if (!all) {
|
||||
page = parseInt(url.searchParams.get('page') || '1');
|
||||
limit = parseInt(url.searchParams.get('limit') || '50');
|
||||
}
|
||||
|
||||
|
||||
let startDate: Date | undefined;
|
||||
let endDate: Date | undefined;
|
||||
|
||||
|
||||
if (url.searchParams.get('startDate')) {
|
||||
startDate = new Date(url.searchParams.get('startDate')!);
|
||||
}
|
||||
|
||||
|
||||
if (url.searchParams.get('endDate')) {
|
||||
endDate = new Date(url.searchParams.get('endDate')!);
|
||||
}
|
||||
|
||||
const queryOptions = all
|
||||
|
||||
const queryOptions = all
|
||||
? {}
|
||||
: {
|
||||
page,
|
||||
limit,
|
||||
startDate,
|
||||
endDate
|
||||
: {
|
||||
page,
|
||||
limit,
|
||||
startDate,
|
||||
endDate,
|
||||
};
|
||||
|
||||
|
||||
const { analytics, total } = await getAllAnalytics(account_id, link_id, queryOptions);
|
||||
|
||||
return NextResponse.json({
|
||||
message: "Analytics retrieved successfully",
|
||||
success: true,
|
||||
analytics,
|
||||
pagination: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit)
|
||||
}
|
||||
}, { status: 200 });
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Analytics retrieved successfully',
|
||||
success: true,
|
||||
analytics,
|
||||
pagination: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Analytics retrieval error:', { error, url: req.url });
|
||||
return NextResponse.json({
|
||||
message: "Failed to retrieve analytics",
|
||||
success: false,
|
||||
}, { status: 500 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to retrieve analytics',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -85,18 +97,21 @@ export async function GET(req: NextRequest) {
|
|||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
|
||||
if (!session?.user?.accountId) {
|
||||
logger.info('Analytics deletion request failed due to unauthorized access', { url: req.url });
|
||||
return NextResponse.json({
|
||||
message: "Unauthorized. Please sign in first.",
|
||||
success: false,
|
||||
}, { status: 401 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized. Please sign in first.',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const account_id = session.user.accountId;
|
||||
let link_id, analytics_id, delete_all;
|
||||
|
||||
|
||||
try {
|
||||
const contentType = req.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json') && req.body) {
|
||||
|
|
@ -107,79 +122,119 @@ export async function DELETE(req: NextRequest) {
|
|||
}
|
||||
} catch {
|
||||
logger.info('Analytics deletion request failed due to missing parameters', { url: req.url });
|
||||
return NextResponse.json({
|
||||
message: "Missing required parameters",
|
||||
success: false,
|
||||
}, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Missing required parameters',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (!link_id) {
|
||||
logger.info('Analytics deletion request failed due to missing link_id', { account_id, url: req.url });
|
||||
return NextResponse.json({
|
||||
message: "Missing link_id parameter",
|
||||
success: false,
|
||||
}, { status: 400 });
|
||||
logger.info('Analytics deletion request failed due to missing link_id', {
|
||||
account_id,
|
||||
url: req.url,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Missing link_id parameter',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Delete all analytics for a link
|
||||
if (delete_all) {
|
||||
const result = await removeAllAnalytics(account_id, link_id);
|
||||
|
||||
|
||||
if (result.success) {
|
||||
logger.info('All analytics deletion request succeeded', { account_id, link_id, url: req.url });
|
||||
return NextResponse.json({
|
||||
message: "All analytics records deleted successfully",
|
||||
success: true,
|
||||
}, { status: 200 });
|
||||
logger.info('All analytics deletion request succeeded', {
|
||||
account_id,
|
||||
link_id,
|
||||
url: req.url,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'All analytics records deleted successfully',
|
||||
success: true,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} else {
|
||||
logger.info('All analytics deletion request failed', { error: result.status, account_id, link_id, url: req.url });
|
||||
return NextResponse.json({
|
||||
message: result.status,
|
||||
success: false,
|
||||
}, { status: 404 });
|
||||
logger.info('All analytics deletion request failed', {
|
||||
error: result.status,
|
||||
account_id,
|
||||
link_id,
|
||||
url: req.url,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: result.status,
|
||||
success: false,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Delete single analytics record
|
||||
if (!analytics_id) {
|
||||
logger.info('Analytics deletion request failed due to missing analytics_id', { account_id, link_id, url: req.url });
|
||||
return NextResponse.json({
|
||||
message: "Missing analytics_id parameter for single record deletion",
|
||||
success: false,
|
||||
}, { status: 400 });
|
||||
logger.info('Analytics deletion request failed due to missing analytics_id', {
|
||||
account_id,
|
||||
link_id,
|
||||
url: req.url,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Missing analytics_id parameter for single record deletion',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const result = await removeAnalytics(account_id, link_id, analytics_id);
|
||||
|
||||
|
||||
if (result.success) {
|
||||
logger.info('Single analytics record deletion request succeeded', {
|
||||
account_id,
|
||||
link_id,
|
||||
analytics_id,
|
||||
url: req.url
|
||||
logger.info('Single analytics record deletion request succeeded', {
|
||||
account_id,
|
||||
link_id,
|
||||
analytics_id,
|
||||
url: req.url,
|
||||
});
|
||||
return NextResponse.json({
|
||||
message: "Analytics record deleted successfully",
|
||||
success: true,
|
||||
}, { status: 200 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Analytics record deleted successfully',
|
||||
success: true,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} else {
|
||||
logger.info('Single analytics record deletion request failed', {
|
||||
error: result.status,
|
||||
account_id,
|
||||
link_id,
|
||||
analytics_id,
|
||||
url: req.url
|
||||
logger.info('Single analytics record deletion request failed', {
|
||||
error: result.status,
|
||||
account_id,
|
||||
link_id,
|
||||
analytics_id,
|
||||
url: req.url,
|
||||
});
|
||||
return NextResponse.json({
|
||||
message: result.status,
|
||||
success: false,
|
||||
}, { status: 404 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: result.status,
|
||||
success: false,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Analytics deletion error:', { error, url: req.url });
|
||||
return NextResponse.json({
|
||||
message: "Failed to delete analytics",
|
||||
success: false,
|
||||
}, { status: 500 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to delete analytics',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,63 +1,57 @@
|
|||
import NextAuth, { NextAuthOptions, User } from "next-auth";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import NextAuth, { NextAuthOptions, User } from 'next-auth';
|
||||
import CredentialsProvider from 'next-auth/providers/credentials';
|
||||
import logger from '@/lib/logger';
|
||||
import { existsUser, isUserAdmin } from "@/lib/userdb";
|
||||
import { JWT } from "next-auth/jwt";
|
||||
import { Session } from "next-auth";
|
||||
import { existsUser, isUserAdmin } from '@/lib/userdb';
|
||||
import { JWT } from 'next-auth/jwt';
|
||||
import { Session } from 'next-auth';
|
||||
import { createSession, updateSessionActivity, revokeSession } from '@/lib/sessiondb';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: "Account ID",
|
||||
name: 'Account ID',
|
||||
credentials: {
|
||||
accountId: { label: "Account ID", type: "text", placeholder: "Enter your Account ID" }
|
||||
accountId: { label: 'Account ID', type: 'text', placeholder: 'Enter your Account ID' },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
const { accountId } = credentials as { accountId: string };
|
||||
|
||||
|
||||
const exists = await existsUser(accountId);
|
||||
|
||||
|
||||
if (exists) {
|
||||
const isAdmin = await isUserAdmin(accountId);
|
||||
|
||||
|
||||
return {
|
||||
id: accountId,
|
||||
accountId,
|
||||
isAdmin,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, user, trigger }: { token: JWT; user: User, trigger?: string }) {
|
||||
async jwt({ token, user, trigger }: { token: JWT; user: User; trigger?: string }) {
|
||||
if (user) {
|
||||
token.accountId = user.accountId;
|
||||
token.isAdmin = user.isAdmin;
|
||||
|
||||
|
||||
const headersList = await headers();
|
||||
const userAgent = headersList.get('user-agent') || 'Unknown';
|
||||
const ip = headersList.get('x-forwarded-for') ||
|
||||
headersList.get('x-real-ip') ||
|
||||
'Unknown';
|
||||
|
||||
const { sessionId } = await createSession(
|
||||
user.accountId,
|
||||
userAgent,
|
||||
ip
|
||||
);
|
||||
|
||||
const ip = headersList.get('x-forwarded-for') || headersList.get('x-real-ip') || 'Unknown';
|
||||
|
||||
const { sessionId } = await createSession(user.accountId, userAgent, ip);
|
||||
|
||||
token.sessionId = sessionId;
|
||||
}
|
||||
|
||||
|
||||
if (trigger === 'update' && token.sessionId) {
|
||||
await updateSessionActivity(token.sessionId as string);
|
||||
}
|
||||
|
||||
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }: { session: Session; token: JWT }) {
|
||||
|
|
@ -66,11 +60,11 @@ export const authOptions: NextAuthOptions = {
|
|||
...session.user,
|
||||
accountId: token.accountId as string,
|
||||
isAdmin: token.isAdmin as boolean,
|
||||
sessionId: token.sessionId as string
|
||||
sessionId: token.sessionId as string,
|
||||
};
|
||||
}
|
||||
return session;
|
||||
}
|
||||
},
|
||||
},
|
||||
events: {
|
||||
async signOut({ token }) {
|
||||
|
|
@ -81,22 +75,22 @@ export const authOptions: NextAuthOptions = {
|
|||
logger.error('Error terminating session on signOut:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
signIn: '/',
|
||||
error: '/',
|
||||
},
|
||||
session: {
|
||||
strategy: "jwt" as const,
|
||||
strategy: 'jwt' as const,
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days (session lifetime)
|
||||
},
|
||||
jwt: {
|
||||
maxAge: 5 * 60, // 5 min (token lifetime)
|
||||
},
|
||||
secret: process.env.NEXTAUTH_SECRET || "/JZ9N+lqRtvspbAfs0HK41RkthPYuUdqxb+cuimYOXw=",
|
||||
secret: process.env.NEXTAUTH_SECRET || '/JZ9N+lqRtvspbAfs0HK41RkthPYuUdqxb+cuimYOXw=',
|
||||
};
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
export { handler as GET, handler as POST };
|
||||
|
|
|
|||
|
|
@ -7,37 +7,43 @@ import logger from '@/lib/logger';
|
|||
export async function GET() {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
|
||||
if (!session?.user?.accountId || !session?.user?.sessionId) {
|
||||
return NextResponse.json({
|
||||
valid: false,
|
||||
message: "No active session"
|
||||
}, { status: 401 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
valid: false,
|
||||
message: 'No active session',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const isValid = await isSessionValid(
|
||||
session.user.sessionId,
|
||||
session.user.accountId
|
||||
);
|
||||
|
||||
|
||||
const isValid = await isSessionValid(session.user.sessionId, session.user.accountId);
|
||||
|
||||
if (!isValid) {
|
||||
logger.info('Session check failed - revoked or expired session', {
|
||||
logger.info('Session check failed - revoked or expired session', {
|
||||
sessionId: session.user.sessionId,
|
||||
accountId: session.user.accountId
|
||||
accountId: session.user.accountId,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
valid: false,
|
||||
message: "Session has been revoked"
|
||||
}, { status: 401 });
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
valid: false,
|
||||
message: 'Session has been revoked',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return NextResponse.json({ valid: true });
|
||||
} catch (error) {
|
||||
logger.error('Error checking session:', error);
|
||||
return NextResponse.json({
|
||||
valid: false,
|
||||
message: "Error checking session"
|
||||
}, { status: 500 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
valid: false,
|
||||
message: 'Error checking session',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export async function POST(req: NextRequest) {
|
|||
let is_admin;
|
||||
logger.info('Registration request', { url: req.url });
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
|
||||
try {
|
||||
const contentType = req.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json') && req.body) {
|
||||
|
|
@ -18,44 +18,59 @@ export async function POST(req: NextRequest) {
|
|||
}
|
||||
} catch {
|
||||
logger.info('Registration request failed due to missing parameters', { url: req.url });
|
||||
return NextResponse.json({
|
||||
message: "Missing required parameters",
|
||||
success: false,
|
||||
}, { status: 400 });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Missing required parameters',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (is_admin) {
|
||||
const isAuthorized = await isUserAdmin(session?.user?.accountId);
|
||||
|
||||
|
||||
if (isAuthorized) {
|
||||
const account = await createUser(is_admin);
|
||||
logger.info('Account creation request succeeded (admin)', { is_admin, url: req.url });
|
||||
return NextResponse.json({
|
||||
message: "Admin registration successful",
|
||||
success: true,
|
||||
account_id: account.account_id,
|
||||
}, { status: 200 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Admin registration successful',
|
||||
success: true,
|
||||
account_id: account.account_id,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} else {
|
||||
logger.info('Registration request failed due to missing rights', { is_admin, url: req.url });
|
||||
return NextResponse.json({
|
||||
message: "Unauthorized admin registration attempt",
|
||||
success: false,
|
||||
}, { status: 401 });
|
||||
logger.info('Registration request failed due to missing rights', {
|
||||
is_admin,
|
||||
url: req.url,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized admin registration attempt',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const account = await createUser(false);
|
||||
|
||||
|
||||
return NextResponse.json({
|
||||
message: "Registration successful",
|
||||
message: 'Registration successful',
|
||||
success: true,
|
||||
account_id: account.account_id,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Registration error:', { error, url: req.url });
|
||||
return NextResponse.json({
|
||||
message: "Registration failed",
|
||||
success: false,
|
||||
}, { status: 500 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Registration failed',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export async function DELETE(req: NextRequest) {
|
|||
let account_id;
|
||||
logger.info('Account removal request', { url: req.url });
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
|
||||
try {
|
||||
const contentType = req.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json') && req.body) {
|
||||
|
|
@ -18,30 +18,42 @@ export async function DELETE(req: NextRequest) {
|
|||
}
|
||||
} catch {
|
||||
logger.info('Account removal request failed due to missing parameters', { url: req.url });
|
||||
return NextResponse.json({
|
||||
message: "Missing required parameters",
|
||||
success: false,
|
||||
}, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Missing required parameters',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (session?.user?.accountId === account_id || await isUserAdmin(session?.user?.accountId)) {
|
||||
|
||||
if (session?.user?.accountId === account_id || (await isUserAdmin(session?.user?.accountId))) {
|
||||
const accountRemovalResponse = await removeUser(account_id);
|
||||
return NextResponse.json({
|
||||
message: accountRemovalResponse.status,
|
||||
success: accountRemovalResponse.success,
|
||||
}, { status: accountRemovalResponse.success ? 200 : 500 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: accountRemovalResponse.status,
|
||||
success: accountRemovalResponse.success,
|
||||
},
|
||||
{ status: accountRemovalResponse.success ? 200 : 500 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
logger.info('Account removal request failed due to missing rights', { url: req.url });
|
||||
return NextResponse.json({
|
||||
message: "Unauthorized account removal attempt",
|
||||
success: false,
|
||||
}, { status: 401 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized account removal attempt',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Account removal error:', { error, url: req.url });
|
||||
return NextResponse.json({
|
||||
message: "Account removal failed",
|
||||
success: false,
|
||||
}, { status: 500 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Account removal failed',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,39 +8,48 @@ import logger from '@/lib/logger';
|
|||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
|
||||
if (!session?.user?.accountId) {
|
||||
return NextResponse.json({
|
||||
message: "Unauthorized",
|
||||
success: false,
|
||||
}, { status: 401 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const { sessionId } = await req.json();
|
||||
|
||||
|
||||
if (!sessionId) {
|
||||
return NextResponse.json({
|
||||
message: "Session ID is required",
|
||||
success: false,
|
||||
}, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Session ID is required',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const result = await revokeSession(sessionId, session.user.accountId);
|
||||
|
||||
|
||||
const isCurrentSession = sessionId === session.user.sessionId;
|
||||
if (isCurrentSession) {
|
||||
signOut({ redirect: false });
|
||||
signOut({ redirect: false });
|
||||
}
|
||||
|
||||
|
||||
return NextResponse.json({
|
||||
...result,
|
||||
isCurrentSession,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error revoking session:', { error });
|
||||
return NextResponse.json({
|
||||
message: "Failed to revoking session",
|
||||
success: false,
|
||||
}, { status: 500 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to revoking session',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,32 +7,38 @@ import logger from '@/lib/logger';
|
|||
export async function GET() {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
|
||||
if (!session?.user?.accountId) {
|
||||
return NextResponse.json({
|
||||
message: "Unauthorized",
|
||||
success: false,
|
||||
}, { status: 401 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const sessions = await getSessions(session.user.accountId);
|
||||
|
||||
|
||||
// Mark the current session
|
||||
const currentSessionId = session.user.sessionId;
|
||||
const sessionsWithCurrent = sessions.map(s => ({
|
||||
...s,
|
||||
isCurrentSession: s.id === currentSessionId
|
||||
isCurrentSession: s.id === currentSessionId,
|
||||
}));
|
||||
|
||||
|
||||
return NextResponse.json({
|
||||
sessions: sessionsWithCurrent,
|
||||
success: true,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error getting sessions:', { error });
|
||||
return NextResponse.json({
|
||||
message: "Failed to retrieve sessions",
|
||||
success: false,
|
||||
}, { status: 500 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to retrieve sessions',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,49 +11,72 @@ import { authOptions } from '@/app/api/auth/[...nextauth]/route';
|
|||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
|
||||
if (!session?.user?.accountId) {
|
||||
logger.info('Link retrieval request failed due to unauthorized access', { url: req.url });
|
||||
return NextResponse.json({
|
||||
message: "Unauthorized. Please sign in first.",
|
||||
success: false,
|
||||
}, { status: 401 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized. Please sign in first.',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const account_id = session.user.accountId;
|
||||
|
||||
|
||||
const url = new URL(req.url);
|
||||
const shortId = url.searchParams.get('shortId');
|
||||
|
||||
|
||||
if (!shortId) {
|
||||
logger.info('Link retrieval request failed due to missing shortId', { account_id, url: req.url });
|
||||
return NextResponse.json({
|
||||
message: "Missing shortId parameter",
|
||||
success: false,
|
||||
}, { status: 400 });
|
||||
logger.info('Link retrieval request failed due to missing shortId', {
|
||||
account_id,
|
||||
url: req.url,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Missing shortId parameter',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const { link, return: returnValue } = await getLinkById(account_id, shortId);
|
||||
|
||||
|
||||
if (returnValue.success && link) {
|
||||
return NextResponse.json({
|
||||
message: "Link retrieved successfully",
|
||||
success: true,
|
||||
link: sanitizeMongoDocument(link),
|
||||
}, { status: 200 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Link retrieved successfully',
|
||||
success: true,
|
||||
link: sanitizeMongoDocument(link),
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('Link retrieval request failed', { error: returnValue.status, account_id, shortId, url: req.url });
|
||||
return NextResponse.json({
|
||||
message: returnValue.status || "Link not found",
|
||||
success: false,
|
||||
}, { status: 404 });
|
||||
|
||||
logger.info('Link retrieval request failed', {
|
||||
error: returnValue.status,
|
||||
account_id,
|
||||
shortId,
|
||||
url: req.url,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: returnValue.status || 'Link not found',
|
||||
success: false,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Link retrieval error:', { error, url: req.url });
|
||||
return NextResponse.json({
|
||||
message: "Failed to retrieve link",
|
||||
success: false,
|
||||
}, { status: 500 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to retrieve link',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -61,18 +84,21 @@ export async function GET(req: NextRequest) {
|
|||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
|
||||
if (!session?.user?.accountId) {
|
||||
logger.info('Link creation request failed due to unauthorized access', { url: req.url });
|
||||
return NextResponse.json({
|
||||
message: "Unauthorized. Please sign in first.",
|
||||
success: false,
|
||||
}, { status: 401 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized. Please sign in first.',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const account_id = session.user.accountId;
|
||||
let target_url;
|
||||
|
||||
|
||||
try {
|
||||
const contentType = req.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json') && req.body) {
|
||||
|
|
@ -81,41 +107,60 @@ export async function POST(req: NextRequest) {
|
|||
}
|
||||
} catch {
|
||||
logger.info('Link creation request failed due to missing parameters', { url: req.url });
|
||||
return NextResponse.json({
|
||||
message: "Missing required parameters",
|
||||
success: false,
|
||||
}, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Missing required parameters',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (!target_url || !isValidUrl(target_url)) {
|
||||
logger.info('Link creation request failed due to invalid URL', { account_id, url: req.url });
|
||||
return NextResponse.json({
|
||||
message: "Invalid URL. Please provide a valid URL with http:// or https://",
|
||||
success: false,
|
||||
}, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Invalid URL. Please provide a valid URL with http:// or https://',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const { shortId, return: returnValue } = await createLink(account_id, target_url);
|
||||
|
||||
if(returnValue.success) {
|
||||
return NextResponse.json({
|
||||
message: "Link Creation succeeded",
|
||||
success: true,
|
||||
shortId,
|
||||
}, { status: 200 });
|
||||
|
||||
if (returnValue.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Link Creation succeeded',
|
||||
success: true,
|
||||
shortId,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
logger.error('Link creation request failed', { error: returnValue.status, account_id, url: req.url });
|
||||
return NextResponse.json({
|
||||
message: returnValue.status || "Link creation failed",
|
||||
success: false,
|
||||
}, { status: 422 });
|
||||
|
||||
logger.error('Link creation request failed', {
|
||||
error: returnValue.status,
|
||||
account_id,
|
||||
url: req.url,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: returnValue.status || 'Link creation failed',
|
||||
success: false,
|
||||
},
|
||||
{ status: 422 }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Link creation error:', { error, url: req.url });
|
||||
return NextResponse.json({
|
||||
message: "Link creation failed",
|
||||
success: false,
|
||||
}, { status: 500 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Link creation failed',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -123,18 +168,21 @@ export async function POST(req: NextRequest) {
|
|||
export async function PATCH(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
|
||||
if (!session?.user?.accountId) {
|
||||
logger.info('Link edit request failed due to unauthorized access', { url: req.url });
|
||||
return NextResponse.json({
|
||||
message: "Unauthorized. Please sign in first.",
|
||||
success: false,
|
||||
}, { status: 401 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized. Please sign in first.',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const account_id = session.user.accountId;
|
||||
let shortId, target_url;
|
||||
|
||||
|
||||
try {
|
||||
const contentType = req.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json') && req.body) {
|
||||
|
|
@ -144,48 +192,71 @@ export async function PATCH(req: NextRequest) {
|
|||
}
|
||||
} catch {
|
||||
logger.info('Link edit request failed due to missing parameters', { url: req.url });
|
||||
return NextResponse.json({
|
||||
message: "Missing required parameters",
|
||||
success: false,
|
||||
}, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Missing required parameters',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (!shortId) {
|
||||
logger.info('Link edit request failed due to missing shortId', { account_id, url: req.url });
|
||||
return NextResponse.json({
|
||||
message: "Missing shortId parameter",
|
||||
success: false,
|
||||
}, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Missing shortId parameter',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (!target_url || !isValidUrl(target_url)) {
|
||||
logger.info('Link edit request failed due to invalid URL', { account_id, url: req.url });
|
||||
return NextResponse.json({
|
||||
message: "Invalid URL. Please provide a valid URL with http:// or https://",
|
||||
success: false,
|
||||
}, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Invalid URL. Please provide a valid URL with http:// or https://',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const returnValue = await editLink(account_id, shortId, target_url);
|
||||
|
||||
if(returnValue.success) {
|
||||
return NextResponse.json({
|
||||
message: "Link updated successfully",
|
||||
success: true,
|
||||
}, { status: 200 });
|
||||
|
||||
if (returnValue.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Link updated successfully',
|
||||
success: true,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
logger.error('Link edit request failed', { error: returnValue.status, account_id, shortId, url: req.url });
|
||||
return NextResponse.json({
|
||||
message: returnValue.status || "Link update failed",
|
||||
success: false,
|
||||
}, { status: 422 });
|
||||
|
||||
logger.error('Link edit request failed', {
|
||||
error: returnValue.status,
|
||||
account_id,
|
||||
shortId,
|
||||
url: req.url,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: returnValue.status || 'Link update failed',
|
||||
success: false,
|
||||
},
|
||||
{ status: 422 }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Link edit error:', { error, url: req.url });
|
||||
return NextResponse.json({
|
||||
message: "Link update failed",
|
||||
success: false,
|
||||
}, { status: 500 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Link update failed',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -193,18 +264,21 @@ export async function PATCH(req: NextRequest) {
|
|||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
|
||||
if (!session?.user?.accountId) {
|
||||
logger.info('Link removal request failed due to unauthorized access', { url: req.url });
|
||||
return NextResponse.json({
|
||||
message: "Unauthorized. Please sign in first.",
|
||||
success: false,
|
||||
}, { status: 401 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized. Please sign in first.',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const account_id = session.user.accountId;
|
||||
let shortId;
|
||||
|
||||
|
||||
try {
|
||||
const contentType = req.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json') && req.body) {
|
||||
|
|
@ -213,40 +287,62 @@ export async function DELETE(req: NextRequest) {
|
|||
}
|
||||
} catch {
|
||||
logger.info('Link removal request failed due to missing parameters', { url: req.url });
|
||||
return NextResponse.json({
|
||||
message: "Missing required parameters",
|
||||
success: false,
|
||||
}, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Missing required parameters',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (!shortId) {
|
||||
logger.info('Link removal request failed due to missing shortId', { account_id, url: req.url });
|
||||
return NextResponse.json({
|
||||
message: "Missing shortId parameter",
|
||||
success: false,
|
||||
}, { status: 400 });
|
||||
logger.info('Link removal request failed due to missing shortId', {
|
||||
account_id,
|
||||
url: req.url,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Missing shortId parameter',
|
||||
success: false,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
await removeAllAnalytics(account_id, shortId);
|
||||
const returnValue = await removeLink(account_id, shortId);
|
||||
|
||||
if(returnValue.success) {
|
||||
return NextResponse.json({
|
||||
message: "Link removal succeeded",
|
||||
success: true,
|
||||
}, { status: 200 });
|
||||
|
||||
if (returnValue.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Link removal succeeded',
|
||||
success: true,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
logger.error('Link removal request failed', { error: returnValue.status, account_id, url: req.url });
|
||||
return NextResponse.json({
|
||||
message: returnValue.status || "Link removal failed",
|
||||
success: false,
|
||||
}, { status: 422 });
|
||||
|
||||
logger.error('Link removal request failed', {
|
||||
error: returnValue.status,
|
||||
account_id,
|
||||
url: req.url,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: returnValue.status || 'Link removal failed',
|
||||
success: false,
|
||||
},
|
||||
{ status: 422 }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Link removal error:', { error, url: req.url });
|
||||
return NextResponse.json({
|
||||
message: "Link removal failed",
|
||||
success: false,
|
||||
}, { status: 500 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Link removal failed',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,36 +9,52 @@ import logger from '@/lib/logger';
|
|||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
|
||||
if (!session?.user?.accountId) {
|
||||
logger.info('Links retrieval request failed due to unauthorized access', { url: req.url });
|
||||
return NextResponse.json({
|
||||
message: "Unauthorized. Please sign in first.",
|
||||
success: false,
|
||||
}, { status: 401 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Unauthorized. Please sign in first.',
|
||||
success: false,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const account_id = session.user.accountId;
|
||||
const { links, return: returnValue } = await getLinks(account_id);
|
||||
|
||||
|
||||
if (returnValue.success) {
|
||||
return NextResponse.json({
|
||||
message: "Links retrieved successfully",
|
||||
success: true,
|
||||
links: links.map(link => sanitizeMongoDocument(link)),
|
||||
}, { status: 200 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Links retrieved successfully',
|
||||
success: true,
|
||||
links: links.map(link => sanitizeMongoDocument(link)),
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('Links retrieval request failed', { error: returnValue.status, account_id, url: req.url });
|
||||
return NextResponse.json({
|
||||
message: returnValue.status || "Failed to retrieve links",
|
||||
success: false,
|
||||
}, { status: 404 });
|
||||
|
||||
logger.info('Links retrieval request failed', {
|
||||
error: returnValue.status,
|
||||
account_id,
|
||||
url: req.url,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: returnValue.status || 'Failed to retrieve links',
|
||||
success: false,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Links retrieval error:', { error, url: req.url });
|
||||
return NextResponse.json({
|
||||
message: "Failed to retrieve links",
|
||||
success: false,
|
||||
}, { status: 500 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to retrieve links',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,30 +4,40 @@ import { getAllStats, updateStats } from '@/lib/statisticsdb';
|
|||
export async function GET() {
|
||||
try {
|
||||
let stats = await getAllStats();
|
||||
|
||||
if (!stats || !stats.last_updated ||
|
||||
(new Date().getTime() - new Date(stats.last_updated).getTime() > 5 * 60 * 1000)) { // 5min
|
||||
|
||||
if (
|
||||
!stats ||
|
||||
!stats.last_updated ||
|
||||
new Date().getTime() - new Date(stats.last_updated).getTime() > 5 * 60 * 1000
|
||||
) {
|
||||
// 5min
|
||||
await updateStats();
|
||||
stats = await getAllStats();
|
||||
}
|
||||
|
||||
|
||||
if (!stats) {
|
||||
return NextResponse.json({
|
||||
message: "Failed to retrieve statistics",
|
||||
success: false,
|
||||
}, { status: 500 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Failed to retrieve statistics',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return NextResponse.json({
|
||||
message: "Statistics retrieved successfully",
|
||||
message: 'Statistics retrieved successfully',
|
||||
success: true,
|
||||
stats,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Statistics retrieval error:', error);
|
||||
return NextResponse.json({
|
||||
message: "An error occurred while retrieving statistics",
|
||||
success: false,
|
||||
}, { status: 500 });
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'An error occurred while retrieving statistics',
|
||||
success: false,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@
|
|||
gap: 1rem;
|
||||
}
|
||||
|
||||
.securityButton, .adminButton {
|
||||
.securityButton,
|
||||
.adminButton {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
|
|
@ -42,7 +43,8 @@
|
|||
color: white;
|
||||
}
|
||||
|
||||
.securityButton:hover, .adminButton:hover {
|
||||
.securityButton:hover,
|
||||
.adminButton:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
|
@ -128,18 +130,18 @@
|
|||
.inputGroup {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
|
||||
.dashboardHeader {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
|
||||
.actionButtons {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
|
||||
.urlShortener,
|
||||
.linksSection {
|
||||
padding: 1rem;
|
||||
|
|
@ -150,14 +152,14 @@
|
|||
.dashboardContainer {
|
||||
padding: 1rem 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.dashboardTitle {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.securityButton,
|
||||
|
||||
.securityButton,
|
||||
.adminButton {
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -273,8 +273,8 @@
|
|||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
|
||||
.graphs {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
|
|
@ -17,7 +17,7 @@ export default function LinkDetailPage() {
|
|||
const router = useRouter();
|
||||
const { showToast } = useToast();
|
||||
const shortId = params.shortId as string;
|
||||
|
||||
|
||||
const [link, setLink] = useState<LinkType | null>(null);
|
||||
const [targetUrl, setTargetUrl] = useState('');
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
|
@ -33,21 +33,21 @@ export default function LinkDetailPage() {
|
|||
const [analyticsToDelete, setAnalyticsToDelete] = useState<string>('');
|
||||
const [isLoadingStats, setIsLoadingStats] = useState(true);
|
||||
const isRedirecting = useRef(false);
|
||||
|
||||
|
||||
// Stats data
|
||||
const [browserStats, setBrowserStats] = useState<StatItem[]>([]);
|
||||
const [osStats, setOsStats] = useState<StatItem[]>([]);
|
||||
const [countryStats, setCountryStats] = useState<StatItem[]>([]);
|
||||
const [ipVersionStats, setIpVersionStats] = useState<StatItem[]>([]);
|
||||
|
||||
function isValidUrl(urlStr: string) : boolean {
|
||||
if(urlStr.trim() === "") {
|
||||
function isValidUrl(urlStr: string): boolean {
|
||||
if (urlStr.trim() === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(urlStr);
|
||||
return parsedUrl.protocol !== "" && parsedUrl.hostname !== "";
|
||||
return parsedUrl.protocol !== '' && parsedUrl.hostname !== '';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -60,10 +60,10 @@ export default function LinkDetailPage() {
|
|||
}, 10);
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (isRedirecting.current) return;
|
||||
|
||||
|
||||
async function fetchLinkData() {
|
||||
try {
|
||||
const response = await fetch(`/api/link?shortId=${shortId}`);
|
||||
|
|
@ -73,7 +73,7 @@ export default function LinkDetailPage() {
|
|||
router.push('/dashboard');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success && data.link) {
|
||||
setLink(data.link);
|
||||
|
|
@ -91,13 +91,13 @@ export default function LinkDetailPage() {
|
|||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fetchLinkData();
|
||||
}, [shortId, router, showToast]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!link) return;
|
||||
|
||||
|
||||
async function fetchAllAnalytics() {
|
||||
try {
|
||||
const response = await fetch(`/api/analytics?link_id=${shortId}&all=true`);
|
||||
|
|
@ -105,7 +105,7 @@ export default function LinkDetailPage() {
|
|||
showToast('Failed to load complete analytics data', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setAllAnalytics(data.analytics);
|
||||
|
|
@ -115,21 +115,23 @@ export default function LinkDetailPage() {
|
|||
showToast('An error occurred while loading complete analytics data', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fetchAllAnalytics();
|
||||
}, [link, shortId, showToast]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!link) return;
|
||||
|
||||
|
||||
async function fetchPaginatedAnalytics() {
|
||||
try {
|
||||
const response = await fetch(`/api/analytics?link_id=${shortId}&page=${page}&limit=${limit}`);
|
||||
const response = await fetch(
|
||||
`/api/analytics?link_id=${shortId}&page=${page}&limit=${limit}`
|
||||
);
|
||||
if (!response.ok) {
|
||||
showToast('Failed to load analytics page', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setAnalytics(data.analytics);
|
||||
|
|
@ -138,13 +140,13 @@ export default function LinkDetailPage() {
|
|||
showToast('An error occurred while loading analytics page', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fetchPaginatedAnalytics();
|
||||
}, [link, shortId, page, limit, showToast]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!link || allAnalytics.length === 0) return;
|
||||
|
||||
|
||||
async function generateStats() {
|
||||
setIsLoadingStats(true);
|
||||
try {
|
||||
|
|
@ -154,78 +156,86 @@ export default function LinkDetailPage() {
|
|||
acc[browser] = (acc[browser] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
|
||||
// OS stats
|
||||
const oses = allAnalytics.reduce((acc: Record<string, number>, item) => {
|
||||
const os = item.platform || 'Unknown';
|
||||
acc[os] = (acc[os] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
|
||||
// Country stats
|
||||
const countries = allAnalytics.reduce((acc: Record<string, number>, item) => {
|
||||
const country = item.country || 'Unknown';
|
||||
acc[country] = (acc[country] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
|
||||
// IP version stats
|
||||
const ipVersions = allAnalytics.reduce((acc: Record<string, number>, item) => {
|
||||
const ipVersion = item.ip_version || 'Unknown';
|
||||
acc[ipVersion] = (acc[ipVersion] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
|
||||
// Convert to StatItem[] and sort by count
|
||||
setBrowserStats(Object.entries(browsers)
|
||||
.map(([id, count]) => ({ id, count }))
|
||||
.sort((a, b) => b.count - a.count));
|
||||
|
||||
setOsStats(Object.entries(oses)
|
||||
.map(([id, count]) => ({ id, count }))
|
||||
.sort((a, b) => b.count - a.count));
|
||||
|
||||
setCountryStats(Object.entries(countries)
|
||||
.map(([id, count]) => ({ id, count }))
|
||||
.sort((a, b) => b.count - a.count));
|
||||
|
||||
setIpVersionStats(Object.entries(ipVersions)
|
||||
.map(([id, count]) => ({ id, count }))
|
||||
.sort((a, b) => b.count - a.count));
|
||||
setBrowserStats(
|
||||
Object.entries(browsers)
|
||||
.map(([id, count]) => ({ id, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
);
|
||||
|
||||
setOsStats(
|
||||
Object.entries(oses)
|
||||
.map(([id, count]) => ({ id, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
);
|
||||
|
||||
setCountryStats(
|
||||
Object.entries(countries)
|
||||
.map(([id, count]) => ({ id, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
);
|
||||
|
||||
setIpVersionStats(
|
||||
Object.entries(ipVersions)
|
||||
.map(([id, count]) => ({ id, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
);
|
||||
} catch {
|
||||
showToast('An error occurred while processing analytics data', 'error');
|
||||
} finally {
|
||||
setIsLoadingStats(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
generateStats();
|
||||
}, [allAnalytics, link, showToast]);
|
||||
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
|
||||
const handleEditLink = async () => {
|
||||
if (!isValidUrl(targetUrl)) {
|
||||
showToast('Please enter a valid URL', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/link`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
body: JSON.stringify({
|
||||
shortId: shortId,
|
||||
target_url: targetUrl
|
||||
target_url: targetUrl,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (data.success) {
|
||||
showToast('Link updated successfully', 'success');
|
||||
setIsEditing(false);
|
||||
|
|
@ -233,7 +243,7 @@ export default function LinkDetailPage() {
|
|||
setLink({
|
||||
...link,
|
||||
target_url: targetUrl,
|
||||
last_modified: new Date()
|
||||
last_modified: new Date(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
|
|
@ -243,7 +253,7 @@ export default function LinkDetailPage() {
|
|||
showToast('An error occurred while updating the link', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleDeleteAnalytics = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/analytics', {
|
||||
|
|
@ -251,19 +261,19 @@ export default function LinkDetailPage() {
|
|||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
body: JSON.stringify({
|
||||
link_id: shortId,
|
||||
analytics_id: analyticsToDelete
|
||||
analytics_id: analyticsToDelete,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (data.success) {
|
||||
showToast('Analytics entry deleted successfully', 'success');
|
||||
|
||||
|
||||
setAnalytics(analytics.filter(item => item._id?.toString() !== analyticsToDelete));
|
||||
|
||||
|
||||
setTotalAnalytics(prev => prev - 1);
|
||||
} else {
|
||||
showToast(data.message || 'Failed to delete analytics entry', 'error');
|
||||
|
|
@ -275,7 +285,7 @@ export default function LinkDetailPage() {
|
|||
setAnalyticsToDelete('');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleDeleteAllAnalytics = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/analytics', {
|
||||
|
|
@ -283,17 +293,17 @@ export default function LinkDetailPage() {
|
|||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
body: JSON.stringify({
|
||||
link_id: shortId,
|
||||
delete_all: true
|
||||
delete_all: true,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (data.success) {
|
||||
showToast('All analytics entries deleted successfully', 'success');
|
||||
|
||||
|
||||
setAnalytics([]);
|
||||
setTotalAnalytics(0);
|
||||
setBrowserStats([]);
|
||||
|
|
@ -309,7 +319,7 @@ export default function LinkDetailPage() {
|
|||
setShowDeleteAllModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.loadingContainer}>
|
||||
|
|
@ -318,16 +328,16 @@ export default function LinkDetailPage() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<h1 className={styles.title}>Link Details</h1>
|
||||
<Link href="/dashboard" className={styles.backLink}>
|
||||
<Link href='/dashboard' className={styles.backLink}>
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
|
||||
<div className={styles.linkInfo}>
|
||||
<div className={styles.linkCard}>
|
||||
<h2>Link Information</h2>
|
||||
|
|
@ -336,20 +346,20 @@ export default function LinkDetailPage() {
|
|||
<span className={styles.label}>Short ID:</span>
|
||||
<span className={styles.value}>{shortId}</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div className={styles.targetUrlHeader}>
|
||||
<div className={styles.linkDetailItem}>
|
||||
<span className={styles.label}>Short URL:</span>
|
||||
<a
|
||||
href={`${window.location.origin}/l/${shortId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<a
|
||||
href={`${window.location.origin}/l/${shortId}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={styles.shortUrl}
|
||||
>
|
||||
{`${window.location.origin}/l/${shortId}`}
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
<button
|
||||
className={styles.defaultButton}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(`${window.location.origin}/l/${shortId}`);
|
||||
|
|
@ -359,37 +369,34 @@ export default function LinkDetailPage() {
|
|||
Copy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className={styles.linkDetailItem}>
|
||||
<span className={styles.label}>Created:</span>
|
||||
<span className={styles.value}>
|
||||
{link ? new Date(link.created_at).toLocaleString() : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div className={styles.linkDetailItem}>
|
||||
<span className={styles.label}>Last Modified:</span>
|
||||
<span className={styles.value}>
|
||||
{link ? new Date(link.last_modified).toLocaleString() : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div className={styles.targetUrlSection}>
|
||||
<div className={styles.targetUrlHeader}>
|
||||
<span className={styles.label}>Target URL:</span>
|
||||
{!isEditing && (
|
||||
<button
|
||||
className={styles.defaultButton}
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
<button className={styles.defaultButton} onClick={() => setIsEditing(true)}>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{isEditing ? (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
handleEditLink();
|
||||
}}
|
||||
|
|
@ -397,15 +404,15 @@ export default function LinkDetailPage() {
|
|||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="url"
|
||||
type='url'
|
||||
value={targetUrl}
|
||||
onChange={(e) => setTargetUrl(e.target.value)}
|
||||
onChange={e => setTargetUrl(e.target.value)}
|
||||
className={styles.urlInput}
|
||||
placeholder="https://example.com"
|
||||
placeholder='https://example.com'
|
||||
/>
|
||||
<div className={styles.editActions}>
|
||||
<button
|
||||
type="button"
|
||||
<button
|
||||
type='button'
|
||||
className={styles.cancelButton}
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
|
|
@ -414,19 +421,16 @@ export default function LinkDetailPage() {
|
|||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.saveButton}
|
||||
>
|
||||
<button type='submit' className={styles.saveButton}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<a
|
||||
href={link?.target_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<a
|
||||
href={link?.target_url}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={styles.targetUrl}
|
||||
>
|
||||
{link?.target_url}
|
||||
|
|
@ -436,75 +440,55 @@ export default function LinkDetailPage() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className={styles.analyticsSection}>
|
||||
<div className={styles.analyticsHeader}>
|
||||
<h2>Analytics</h2>
|
||||
<span className={styles.totalClicks}>
|
||||
Total Clicks: {totalAnalytics}
|
||||
</span>
|
||||
<span className={styles.totalClicks}>Total Clicks: {totalAnalytics}</span>
|
||||
{totalAnalytics > 0 && (
|
||||
<button
|
||||
className={styles.deleteAllButton}
|
||||
onClick={() => setShowDeleteAllModal(true)}
|
||||
>
|
||||
<button className={styles.deleteAllButton} onClick={() => setShowDeleteAllModal(true)}>
|
||||
Delete All Analytics
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{totalAnalytics > 0 ? (
|
||||
<>
|
||||
<div className={styles.graphs}>
|
||||
<div className={styles.graphCard}>
|
||||
<h3>Browsers</h3>
|
||||
<Graph
|
||||
type="doughnut"
|
||||
data={browserStats}
|
||||
loading={isLoadingStats}
|
||||
height={200}
|
||||
/>
|
||||
<Graph type='doughnut' data={browserStats} loading={isLoadingStats} height={200} />
|
||||
</div>
|
||||
|
||||
|
||||
<div className={styles.graphCard}>
|
||||
<h3>Operating Systems</h3>
|
||||
<Graph
|
||||
type="doughnut"
|
||||
data={osStats}
|
||||
loading={isLoadingStats}
|
||||
height={200}
|
||||
/>
|
||||
<Graph type='doughnut' data={osStats} loading={isLoadingStats} height={200} />
|
||||
</div>
|
||||
|
||||
|
||||
<div className={styles.graphCard}>
|
||||
<h3>Countries</h3>
|
||||
<Graph
|
||||
type="doughnut"
|
||||
data={countryStats}
|
||||
loading={isLoadingStats}
|
||||
height={200}
|
||||
/>
|
||||
<Graph type='doughnut' data={countryStats} loading={isLoadingStats} height={200} />
|
||||
</div>
|
||||
|
||||
|
||||
<div className={styles.graphCard}>
|
||||
<h3>IP Versions</h3>
|
||||
<Graph
|
||||
type="doughnut"
|
||||
data={ipVersionStats}
|
||||
loading={isLoadingStats}
|
||||
<Graph
|
||||
type='doughnut'
|
||||
data={ipVersionStats}
|
||||
loading={isLoadingStats}
|
||||
height={200}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnalyticsTable
|
||||
analytics={analytics}
|
||||
|
||||
<AnalyticsTable
|
||||
analytics={analytics}
|
||||
allAnalytics={allAnalytics}
|
||||
totalItems={totalAnalytics}
|
||||
currentPage={page}
|
||||
itemsPerPage={limit}
|
||||
onPageChange={handlePageChange}
|
||||
onDeleteClick={(id) => {
|
||||
onDeleteClick={id => {
|
||||
setAnalyticsToDelete(id);
|
||||
setShowDeleteModal(true);
|
||||
}}
|
||||
|
|
@ -516,31 +500,31 @@ export default function LinkDetailPage() {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Confirm Delete Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={showDeleteModal}
|
||||
title="Delete Analytics Entry"
|
||||
message="Are you sure you want to delete this analytics entry? This action cannot be undone."
|
||||
confirmLabel="Delete"
|
||||
cancelLabel="Cancel"
|
||||
title='Delete Analytics Entry'
|
||||
message='Are you sure you want to delete this analytics entry? This action cannot be undone.'
|
||||
confirmLabel='Delete'
|
||||
cancelLabel='Cancel'
|
||||
onConfirm={handleDeleteAnalytics}
|
||||
onCancel={() => {
|
||||
setShowDeleteModal(false);
|
||||
setAnalyticsToDelete('');
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
{/* Confirm Delete All Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={showDeleteAllModal}
|
||||
title="Delete All Analytics"
|
||||
message="Are you sure you want to delete all analytics for this link? This action cannot be undone."
|
||||
confirmLabel="Delete All"
|
||||
cancelLabel="Cancel"
|
||||
title='Delete All Analytics'
|
||||
message='Are you sure you want to delete all analytics for this link? This action cannot be undone.'
|
||||
confirmLabel='Delete All'
|
||||
cancelLabel='Cancel'
|
||||
onConfirm={handleDeleteAllAnalytics}
|
||||
onCancel={() => setShowDeleteAllModal(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
|
@ -16,7 +16,7 @@ export default function Dashboard() {
|
|||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "unauthenticated") {
|
||||
if (status === 'unauthenticated') {
|
||||
router.push('/');
|
||||
}
|
||||
}, [status, router]);
|
||||
|
|
@ -31,11 +31,11 @@ export default function Dashboard() {
|
|||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/links');
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch links');
|
||||
}
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setLinks(data.links);
|
||||
|
|
@ -50,11 +50,11 @@ export default function Dashboard() {
|
|||
}
|
||||
};
|
||||
|
||||
if (status === "loading") {
|
||||
if (status === 'loading') {
|
||||
return <div className={styles.loading}>Loading...</div>;
|
||||
}
|
||||
|
||||
if (status === "unauthenticated") {
|
||||
if (status === 'unauthenticated') {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -63,12 +63,12 @@ export default function Dashboard() {
|
|||
<header className={styles.dashboardHeader}>
|
||||
<h1 className={styles.dashboardTitle}>Dashboard</h1>
|
||||
<div className={styles.actionButtons}>
|
||||
<Link href="/dashboard/security">
|
||||
<Link href='/dashboard/security'>
|
||||
<button className={styles.securityButton}>Security Settings</button>
|
||||
</Link>
|
||||
|
||||
|
||||
{session?.user?.isAdmin && (
|
||||
<Link href="/admin">
|
||||
<Link href='/admin'>
|
||||
<button className={styles.adminButton}>Admin Dashboard</button>
|
||||
</Link>
|
||||
)}
|
||||
|
|
@ -79,7 +79,7 @@ export default function Dashboard() {
|
|||
<h2>Create New Short Link</h2>
|
||||
<CreateLinkForm onLinkCreated={fetchLinks} />
|
||||
</section>
|
||||
|
||||
|
||||
<section className={styles.linksSection}>
|
||||
<h2>Your Shortened Links</h2>
|
||||
{loading ? (
|
||||
|
|
@ -104,15 +104,15 @@ function CreateLinkForm({ onLinkCreated }: CreateLinkFormProps) {
|
|||
const [url, setUrl] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const { showToast } = useToast();
|
||||
|
||||
const handleSubmit = async (e: { preventDefault: () => void; }) => {
|
||||
|
||||
const handleSubmit = async (e: { preventDefault: () => void }) => {
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
if (!url.trim()) {
|
||||
showToast('Please enter a URL', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
setCreating(true);
|
||||
const response = await fetch('/api/link', {
|
||||
|
|
@ -122,9 +122,9 @@ function CreateLinkForm({ onLinkCreated }: CreateLinkFormProps) {
|
|||
},
|
||||
body: JSON.stringify({ target_url: url }),
|
||||
});
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (response.ok && data.success) {
|
||||
showToast('Link created successfully!', 'success');
|
||||
setUrl('');
|
||||
|
|
@ -139,26 +139,22 @@ function CreateLinkForm({ onLinkCreated }: CreateLinkFormProps) {
|
|||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={styles.createForm}>
|
||||
<div className={styles.inputGroup}>
|
||||
<input
|
||||
type="url"
|
||||
type='url'
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="Enter URL to shorten"
|
||||
onChange={e => setUrl(e.target.value)}
|
||||
placeholder='Enter URL to shorten'
|
||||
className={styles.urlInput}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.createButton}
|
||||
disabled={creating}
|
||||
>
|
||||
<button type='submit' className={styles.createButton} disabled={creating}>
|
||||
{creating ? 'Creating...' : 'Create Short Link'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,14 +107,14 @@
|
|||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
|
||||
.dangerCard {
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
|
||||
.deleteAccountBtn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useSession, signOut } from 'next-auth/react';
|
||||
|
|
@ -19,20 +19,20 @@ export default function SecurityPage() {
|
|||
const handleAccountDeletion = async () => {
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
|
||||
|
||||
const response = await fetch('/api/auth/remove', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ account_id: session?.user?.accountId }),
|
||||
});
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (response.ok && data.success) {
|
||||
showToast('Account deleted successfully', 'success');
|
||||
|
||||
|
||||
await signOut({ redirect: false });
|
||||
router.push('/');
|
||||
} else {
|
||||
|
|
@ -52,7 +52,7 @@ export default function SecurityPage() {
|
|||
<div className={styles.container}>
|
||||
<header className={styles.header}>
|
||||
<h1>Security Settings</h1>
|
||||
<Link href="/dashboard" className={styles.backLink}>
|
||||
<Link href='/dashboard' className={styles.backLink}>
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
</header>
|
||||
|
|
@ -68,11 +68,11 @@ export default function SecurityPage() {
|
|||
<div className={styles.dangerInfo}>
|
||||
<h3>Delete Account</h3>
|
||||
<p>
|
||||
This will permanently delete your account and all associated data.
|
||||
This action cannot be undone.
|
||||
This will permanently delete your account and all associated data. This action
|
||||
cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
<button
|
||||
className={styles.deleteAccountBtn}
|
||||
onClick={() => setIsDeleteModalOpen(true)}
|
||||
disabled={isDeleting}
|
||||
|
|
@ -85,12 +85,12 @@ export default function SecurityPage() {
|
|||
|
||||
<ConfirmModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
title="Delete Account"
|
||||
message="Are you sure you want to delete your account? This will permanently remove your account and all your data, including all shortened links. This action cannot be undone."
|
||||
confirmLabel={isDeleting ? "Deleting..." : "Delete Account"}
|
||||
title='Delete Account'
|
||||
message='Are you sure you want to delete your account? This will permanently remove your account and all your data, including all shortened links. This action cannot be undone.'
|
||||
confirmLabel={isDeleting ? 'Deleting...' : 'Delete Account'}
|
||||
onConfirm={handleAccountDeletion}
|
||||
onCancel={() => setIsDeleteModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@
|
|||
body {
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
|
|
@ -64,4 +64,4 @@ body {
|
|||
padding-top: calc(var(--header-min-height) + 2rem);
|
||||
padding-bottom: calc(var(--footer-min-height) + 2rem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,33 +4,31 @@ import { getClientInfo } from '@/lib/utils';
|
|||
import { saveAnalytics } from '@/lib/analyticsdb';
|
||||
import logger from '@/lib/logger';
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ shortId: string }> }
|
||||
) {
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ shortId: string }> }) {
|
||||
try {
|
||||
const { shortId } = await params;
|
||||
|
||||
|
||||
const link = await getTargetUrl(shortId);
|
||||
|
||||
|
||||
if (!link || !link.target_url) {
|
||||
return NextResponse.redirect(new URL('/not-found', req.url));
|
||||
}
|
||||
|
||||
|
||||
const clientInfo = await getClientInfo(req);
|
||||
|
||||
|
||||
const analyticsData = {
|
||||
link_id: shortId,
|
||||
account_id: link.account_id,
|
||||
...clientInfo
|
||||
...clientInfo,
|
||||
};
|
||||
|
||||
saveAnalytics(analyticsData)
|
||||
.catch(err => logger.error('Failed to save analytics', { error: err, shortId }));
|
||||
|
||||
|
||||
saveAnalytics(analyticsData).catch(err =>
|
||||
logger.error('Failed to save analytics', { error: err, shortId })
|
||||
);
|
||||
|
||||
return NextResponse.redirect(new URL(link.target_url));
|
||||
} catch (error) {
|
||||
logger.error('Link redirection error', error);
|
||||
return NextResponse.redirect(new URL('/error', req.url));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,27 @@
|
|||
import Providers from '@/components/Providers';
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import Header from "@/components/header";
|
||||
import Footer from "@/components/footer";
|
||||
import type { Metadata } from 'next';
|
||||
import { Geist, Geist_Mono } from 'next/font/google';
|
||||
import Header from '@/components/header';
|
||||
import Footer from '@/components/footer';
|
||||
import { ToastProvider } from '@/contexts/ToastContext';
|
||||
import Toast from '@/components/ui/Toast';
|
||||
import ResponsiveLayout from '@/components/ResponsiveLayout';
|
||||
import SessionMonitor from '@/components/SessionMonitor';
|
||||
import "./globals.css";
|
||||
import './globals.css';
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
variable: '--font-geist-sans',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
variable: '--font-geist-mono',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "µLinkShortener",
|
||||
description: "Create short links and see who accessed them!",
|
||||
title: 'µLinkShortener',
|
||||
description: 'Create short links and see who accessed them!',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
|
@ -30,16 +30,12 @@ export default function RootLayout({
|
|||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<html lang='en'>
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||
<Providers>
|
||||
<ToastProvider>
|
||||
<Header />
|
||||
<div className="page-content">
|
||||
{children}
|
||||
</div>
|
||||
<div className='page-content'>{children}</div>
|
||||
<Footer />
|
||||
<Toast />
|
||||
<SessionMonitor />
|
||||
|
|
|
|||
|
|
@ -54,4 +54,4 @@
|
|||
|
||||
.hero-cta:hover {
|
||||
background-color: var(--accent-hover);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,16 +3,16 @@ import styles from './NotFound.module.css';
|
|||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main className={styles["default-container"]}>
|
||||
<section className={styles["hero-section"]}>
|
||||
<h1 className={styles["hero-title"]}>Link Not Found</h1>
|
||||
<p className={styles["hero-description"]}>
|
||||
<main className={styles['default-container']}>
|
||||
<section className={styles['hero-section']}>
|
||||
<h1 className={styles['hero-title']}>Link Not Found</h1>
|
||||
<p className={styles['hero-description']}>
|
||||
The shortened link you're looking for doesn't exist or has been removed.
|
||||
</p>
|
||||
<Link href="/" className={styles["hero-cta"]}>
|
||||
<Link href='/' className={styles['hero-cta']}>
|
||||
Go Home
|
||||
</Link>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
130
src/app/page.tsx
130
src/app/page.tsx
|
|
@ -1,4 +1,4 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
|
@ -17,20 +17,20 @@ const sampleData = {
|
|||
{ id: 'Thu', count: 35 },
|
||||
{ id: 'Fri', count: 50 },
|
||||
{ id: 'Sat', count: 20 },
|
||||
{ id: 'Sun', count: 15 }
|
||||
{ id: 'Sun', count: 15 },
|
||||
],
|
||||
geoData: [
|
||||
{ id: 'United States', count: 120 },
|
||||
{ id: 'Germany', count: 80 },
|
||||
{ id: 'United Kingdom', count: 65 },
|
||||
{ id: 'Canada', count: 45 },
|
||||
{ id: 'France', count: 40 }
|
||||
{ id: 'France', count: 40 },
|
||||
],
|
||||
deviceData: [
|
||||
{ id: 'Desktop', count: 210 },
|
||||
{ id: 'Mobile', count: 180 },
|
||||
{ id: 'Tablet', count: 50 }
|
||||
]
|
||||
{ id: 'Tablet', count: 50 },
|
||||
],
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
|
|
@ -39,7 +39,7 @@ export default function Home() {
|
|||
const { showToast } = useToast();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [statsLoading, setStatsLoading] = useState(true);
|
||||
|
||||
|
||||
// State for real statistics data
|
||||
const [ipVersionStats, setIpVersionStats] = useState<StatItem[]>([]);
|
||||
const [osStats, setOsStats] = useState<StatItem[]>([]);
|
||||
|
|
@ -47,24 +47,24 @@ export default function Home() {
|
|||
const [ispStats, setIspStats] = useState<StatItem[]>([]);
|
||||
const [totalClicks, setTotalClicks] = useState(0);
|
||||
const [totalLinks, setTotalLinks] = useState(0);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchStats() {
|
||||
try {
|
||||
setStatsLoading(true);
|
||||
const response = await fetch('/api/statistics');
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch statistics');
|
||||
}
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (data.success && data.stats) {
|
||||
const { stats } = data;
|
||||
setTotalLinks(stats.total_links || 0);
|
||||
setTotalClicks(stats.total_clicks || 0);
|
||||
|
||||
|
||||
if (stats.chart_data) {
|
||||
setIpVersionStats(stats.chart_data.ip_versions || []);
|
||||
setOsStats(stats.chart_data.os_stats || []);
|
||||
|
|
@ -78,38 +78,38 @@ export default function Home() {
|
|||
setStatsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
|
||||
const handleGetStarted = async () => {
|
||||
if (status === 'authenticated') {
|
||||
router.push('/dashboard');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Registration failed');
|
||||
}
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (data.success && data.account_id) {
|
||||
const signInResult = await signIn('credentials', {
|
||||
accountId: data.account_id,
|
||||
redirect: false
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
|
||||
if (signInResult?.error) {
|
||||
throw new Error(signInResult.error);
|
||||
}
|
||||
|
||||
|
||||
showToast('Account created successfully!', 'success');
|
||||
router.push('/dashboard');
|
||||
} else {
|
||||
|
|
@ -122,83 +122,83 @@ export default function Home() {
|
|||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<main className={styles["default-container"]}>
|
||||
<main className={styles['default-container']}>
|
||||
{/* Hero Section */}
|
||||
<section className={styles["hero-section"]}>
|
||||
<h1 className={styles["hero-title"]}>µLinkShortener</h1>
|
||||
<p className={styles["hero-description"]}>
|
||||
<section className={styles['hero-section']}>
|
||||
<h1 className={styles['hero-title']}>µLinkShortener</h1>
|
||||
<p className={styles['hero-description']}>
|
||||
An analytics-driven URL shortening service to track and manage your links.
|
||||
</p>
|
||||
<button
|
||||
className={styles["hero-cta"]}
|
||||
onClick={handleGetStarted}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<button className={styles['hero-cta']} onClick={handleGetStarted} disabled={isLoading}>
|
||||
{isLoading ? 'Loading...' : 'Get Started'}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* Stats Summary */}
|
||||
<section className={styles["stats-summary"]}>
|
||||
<div className={styles["stats-container"]}>
|
||||
<div className={styles["stats-card"]}>
|
||||
<section className={styles['stats-summary']}>
|
||||
<div className={styles['stats-container']}>
|
||||
<div className={styles['stats-card']}>
|
||||
<h3>Total Links</h3>
|
||||
<p className={styles["stats-number"]}>{statsLoading ? '...' : totalLinks.toLocaleString()}</p>
|
||||
<p className={styles['stats-number']}>
|
||||
{statsLoading ? '...' : totalLinks.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles["stats-card"]}>
|
||||
<div className={styles['stats-card']}>
|
||||
<h3>Total Clicks</h3>
|
||||
<p className={styles["stats-number"]}>{statsLoading ? '...' : totalClicks.toLocaleString()}</p>
|
||||
<p className={styles['stats-number']}>
|
||||
{statsLoading ? '...' : totalClicks.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Graphs Section */}
|
||||
<section className={styles["graphs-section"]}>
|
||||
<h2 className={styles["graphs-title"]}>Analytics Dashboard</h2>
|
||||
<div className={styles["graphs-container"]}>
|
||||
<div className={styles["graph-card"]}>
|
||||
<h3 className={styles["graph-title"]}>IP Versions</h3>
|
||||
<div className={styles["graph-content"]}>
|
||||
<Graph
|
||||
type="doughnut"
|
||||
data={ipVersionStats.length > 0 ? ipVersionStats : sampleData.deviceData}
|
||||
<section className={styles['graphs-section']}>
|
||||
<h2 className={styles['graphs-title']}>Analytics Dashboard</h2>
|
||||
<div className={styles['graphs-container']}>
|
||||
<div className={styles['graph-card']}>
|
||||
<h3 className={styles['graph-title']}>IP Versions</h3>
|
||||
<div className={styles['graph-content']}>
|
||||
<Graph
|
||||
type='doughnut'
|
||||
data={ipVersionStats.length > 0 ? ipVersionStats : sampleData.deviceData}
|
||||
loading={statsLoading}
|
||||
height={250}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles["graph-card"]}>
|
||||
<h3 className={styles["graph-title"]}>Operating Systems</h3>
|
||||
<div className={styles["graph-content"]}>
|
||||
<Graph
|
||||
type="doughnut"
|
||||
data={osStats.length > 0 ? osStats : sampleData.deviceData}
|
||||
|
||||
<div className={styles['graph-card']}>
|
||||
<h3 className={styles['graph-title']}>Operating Systems</h3>
|
||||
<div className={styles['graph-content']}>
|
||||
<Graph
|
||||
type='doughnut'
|
||||
data={osStats.length > 0 ? osStats : sampleData.deviceData}
|
||||
loading={statsLoading}
|
||||
height={250}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles["graph-card"]}>
|
||||
<h3 className={styles["graph-title"]}>Countries</h3>
|
||||
<div className={styles["graph-content"]}>
|
||||
<Graph
|
||||
type="doughnut"
|
||||
|
||||
<div className={styles['graph-card']}>
|
||||
<h3 className={styles['graph-title']}>Countries</h3>
|
||||
<div className={styles['graph-content']}>
|
||||
<Graph
|
||||
type='doughnut'
|
||||
data={countryStats.length > 0 ? countryStats : sampleData.geoData}
|
||||
loading={statsLoading}
|
||||
height={250}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles["graph-card"]}>
|
||||
<h3 className={styles["graph-title"]}>Internet Service Providers</h3>
|
||||
<div className={styles["graph-content"]}>
|
||||
<Graph
|
||||
type="doughnut"
|
||||
|
||||
<div className={styles['graph-card']}>
|
||||
<h3 className={styles['graph-title']}>Internet Service Providers</h3>
|
||||
<div className={styles['graph-content']}>
|
||||
<Graph
|
||||
type='doughnut'
|
||||
data={ispStats.length > 0 ? ispStats : sampleData.geoData}
|
||||
loading={statsLoading}
|
||||
height={250}
|
||||
|
|
@ -209,4 +209,4 @@ export default function Home() {
|
|||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,4 +114,4 @@
|
|||
.link:hover {
|
||||
color: var(--primary-color);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,14 +3,16 @@ import styles from './Privacy.module.css';
|
|||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<main className={styles["default-container"]}>
|
||||
<main className={styles['default-container']}>
|
||||
{/* Title Section */}
|
||||
<section className={styles["hero-section"]}>
|
||||
<h1 className={styles["hero-title"]}>Privacy Policy</h1>
|
||||
<p className={styles["hero-description"]}>
|
||||
We are committed to respecting user privacy while maintaining the integrity and security of our service. <br></br>This policy outlines what data we collect, why we collect it, and how it is used.
|
||||
<section className={styles['hero-section']}>
|
||||
<h1 className={styles['hero-title']}>Privacy Policy</h1>
|
||||
<p className={styles['hero-description']}>
|
||||
We are committed to respecting user privacy while maintaining the integrity and security
|
||||
of our service. <br></br>This policy outlines what data we collect, why we collect it, and
|
||||
how it is used.
|
||||
</p>
|
||||
<Link href="/" className={styles["hero-cta"]}>
|
||||
<Link href='/' className={styles['hero-cta']}>
|
||||
Back to Home
|
||||
</Link>
|
||||
</section>
|
||||
|
|
@ -29,9 +31,12 @@ export default function PrivacyPage() {
|
|||
<li>ISP information</li>
|
||||
<li>Geographic location based on IP address</li>
|
||||
</ul>
|
||||
<p>We also use cookies to store your account session and preferences for your convenience.</p>
|
||||
<p>
|
||||
We also use cookies to store your account session and preferences for your
|
||||
convenience.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<h2 className={styles['policy-section-title']}>How We Use Your Information</h2>
|
||||
<div className={styles['policy-text']}>
|
||||
<p>We use the collected information to:</p>
|
||||
|
|
@ -42,36 +47,62 @@ export default function PrivacyPage() {
|
|||
<li>Detect and prevent abusive usage</li>
|
||||
<li>Provide analytics to link creators</li>
|
||||
</ul>
|
||||
<p>We do <strong>not</strong> sell or share your personal data with third parties, except where required by law.</p>
|
||||
<p>
|
||||
We do <strong>not</strong> sell or share your personal data with third parties, except
|
||||
where required by law.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 className={styles['policy-section-title']}>Third-Party Services</h2>
|
||||
<div className={styles['policy-text']}>
|
||||
<p>We use Cloudflare as a content delivery network (CDN) and security provider. Cloudflare may process technical data such as IP addresses, request headers, and browser metadata to deliver and protect the service. This data is handled in accordance with <Link href="https://www.cloudflare.com/privacypolicy/" className={styles.link}>Cloudflare's Privacy Policy</Link>.</p>
|
||||
<p>
|
||||
We use Cloudflare as a content delivery network (CDN) and security provider.
|
||||
Cloudflare may process technical data such as IP addresses, request headers, and
|
||||
browser metadata to deliver and protect the service. This data is handled in
|
||||
accordance with{' '}
|
||||
<Link href='https://www.cloudflare.com/privacypolicy/' className={styles.link}>
|
||||
Cloudflare's Privacy Policy
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
<p>We do not share user data with any other third-party services.</p>
|
||||
</div>
|
||||
|
||||
|
||||
<h2 className={styles['policy-section-title']}>Data Retention</h2>
|
||||
<div className={styles['policy-text']}>
|
||||
<ul>
|
||||
<li><strong>Analytics and usage data</strong> are retained until explicitly deleted by the link creator.</li>
|
||||
<li><strong>User accounts and associated data</strong> are retained until a deletion request is received.</li>
|
||||
<li>Shortened URLs remain active until deleted by their creator or by us in accordance with our Terms of Service.</li>
|
||||
<li>
|
||||
<strong>Analytics and usage data</strong> are retained until explicitly deleted by
|
||||
the link creator.
|
||||
</li>
|
||||
<li>
|
||||
<strong>User accounts and associated data</strong> are retained until a deletion
|
||||
request is received.
|
||||
</li>
|
||||
<li>
|
||||
Shortened URLs remain active until deleted by their creator or by us in accordance
|
||||
with our Terms of Service.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2 className={styles['policy-section-title']}>Your Rights</h2>
|
||||
<div className={styles['policy-text']}>
|
||||
<p>You may request deletion of your account and associated data at any time by contacting us. Deletion is permanent and cannot be reversed.</p>
|
||||
<p>
|
||||
You may request deletion of your account and associated data at any time by contacting
|
||||
us. Deletion is permanent and cannot be reversed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<h2 className={styles['policy-section-title']}>Contact Us</h2>
|
||||
<div className={styles['policy-text']}>
|
||||
<p>If you have any questions about this Privacy Policy, please contact us at:</p>
|
||||
<Link href="mailto:privacy.uLink@kizuren.dev" className={styles.link}>privacy.uLink@kizuren.dev</Link>
|
||||
<Link href='mailto:privacy.uLink@kizuren.dev' className={styles.link}>
|
||||
privacy.uLink@kizuren.dev
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,4 +113,4 @@
|
|||
.link:hover {
|
||||
color: var(--primary-color);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,14 +3,15 @@ import styles from './ToS.module.css';
|
|||
|
||||
export default function TermsOfServicePage() {
|
||||
return (
|
||||
<main className={styles["default-container"]}>
|
||||
<main className={styles['default-container']}>
|
||||
{/* Title Section */}
|
||||
<section className={styles["hero-section"]}>
|
||||
<h1 className={styles["hero-title"]}>Terms of Service</h1>
|
||||
<p className={styles["hero-description"]}>
|
||||
By using our URL shortening service, you agree to comply with these Terms of Service. Please read them carefully before using the platform.
|
||||
<section className={styles['hero-section']}>
|
||||
<h1 className={styles['hero-title']}>Terms of Service</h1>
|
||||
<p className={styles['hero-description']}>
|
||||
By using our URL shortening service, you agree to comply with these Terms of Service.
|
||||
Please read them carefully before using the platform.
|
||||
</p>
|
||||
<Link href="/" className={styles["hero-cta"]}>
|
||||
<Link href='/' className={styles['hero-cta']}>
|
||||
Back to Home
|
||||
</Link>
|
||||
</section>
|
||||
|
|
@ -20,29 +21,45 @@ export default function TermsOfServicePage() {
|
|||
<div className={styles['policy-text-container']}>
|
||||
<h2 className={styles['policy-section-title']}>Acceptance of Terms</h2>
|
||||
<div className={styles['policy-text']}>
|
||||
<p>By accessing or using our URL shortening service, you agree to be bound by these Terms of Service. If you do not agree to these terms, do not use the service.</p>
|
||||
<p>
|
||||
By accessing or using our URL shortening service, you agree to be bound by these Terms
|
||||
of Service. If you do not agree to these terms, do not use the service.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<h2 className={styles['policy-section-title']}>Description of Service</h2>
|
||||
<div className={styles['policy-text']}>
|
||||
<p>We provide a URL shortening service with analytics and tracking functionality. The service is provided “as is,” without guarantees or warranties of any kind.</p>
|
||||
<p>
|
||||
We provide a URL shortening service with analytics and tracking functionality. The
|
||||
service is provided “as is,” without guarantees or warranties of any kind.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<h2 className={styles['policy-section-title']}>User Responsibilities</h2>
|
||||
<div className={styles['policy-text']}>
|
||||
<p>By using this service, you agree that you will <strong>not</strong>:</p>
|
||||
<p>
|
||||
By using this service, you agree that you will <strong>not</strong>:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Use the service for any unlawful or unauthorized purpose</li>
|
||||
<li>Distribute malware, phishing links, or any malicious code</li>
|
||||
<li>Infringe on any third party's intellectual property or proprietary rights</li>
|
||||
<li>
|
||||
Infringe on any third party's intellectual property or proprietary rights
|
||||
</li>
|
||||
<li>Harass, spam, or abuse individuals or systems</li>
|
||||
<li>Attempt to probe, scan, or compromise our infrastructure or interfere with service operation</li>
|
||||
<li>
|
||||
Attempt to probe, scan, or compromise our infrastructure or interfere with service
|
||||
operation
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
<h2 className={styles['policy-section-title']}>Content Restrictions</h2>
|
||||
<div className={styles['policy-text']}>
|
||||
<p>You may <strong>not</strong> use the service to create or distribute links that direct to content which:</p>
|
||||
<p>
|
||||
You may <strong>not</strong> use the service to create or distribute links that direct
|
||||
to content which:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Contains malware, viruses, or other harmful code</li>
|
||||
<li>Facilitates or promotes illegal activity</li>
|
||||
|
|
@ -51,27 +68,45 @@ export default function TermsOfServicePage() {
|
|||
<li>Includes adult or explicit content without compliant age verification</li>
|
||||
<li>Encourages self-harm, suicide, or criminal activity</li>
|
||||
</ul>
|
||||
<p>We reserve the right to remove or disable any links at any time without explanation.</p>
|
||||
<p>
|
||||
We reserve the right to remove or disable any links at any time without explanation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<h2 className={styles['policy-section-title']}>Service Modifications</h2>
|
||||
<div className={styles['policy-text']}>
|
||||
<p>We may modify, suspend, or discontinue any part of the service at any time, with or without notice. We are not liable for any loss, data deletion, or disruption caused by such changes.</p>
|
||||
<p>
|
||||
We may modify, suspend, or discontinue any part of the service at any time, with or
|
||||
without notice. We are not liable for any loss, data deletion, or disruption caused by
|
||||
such changes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<h2 className={styles['policy-section-title']}>Termination</h2>
|
||||
<div className={styles['policy-text']}>
|
||||
<p>We may suspend or terminate your access to the service at any time, with or without notice, for any reason we deem appropriate. This includes, but is not limited to, violations of these Terms, behavior we consider abusive, disruptive, unlawful, or harmful to the service, to us, to other users, or to third parties. Termination is at our sole discretion and may be irreversible. We are under no obligation to preserve, return, or provide access to any data following termination.</p>
|
||||
<p>Attempts to bypass suspension or re-register after termination may result in permanent blocking.</p>
|
||||
<p>
|
||||
We may suspend or terminate your access to the service at any time, with or without
|
||||
notice, for any reason we deem appropriate. This includes, but is not limited to,
|
||||
violations of these Terms, behavior we consider abusive, disruptive, unlawful, or
|
||||
harmful to the service, to us, to other users, or to third parties. Termination is at
|
||||
our sole discretion and may be irreversible. We are under no obligation to preserve,
|
||||
return, or provide access to any data following termination.
|
||||
</p>
|
||||
<p>
|
||||
Attempts to bypass suspension or re-register after termination may result in permanent
|
||||
blocking.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<h2 className={styles['policy-section-title']}>Contact Us</h2>
|
||||
<div className={styles['policy-text']}>
|
||||
<p>If you have any questions about these Terms of Service, please contact us at:</p>
|
||||
<Link href="mailto:terms.uLink@kizuren.dev" className={styles.link}>terms.uLink@kizuren.dev</Link>
|
||||
<Link href='mailto:terms.uLink@kizuren.dev' className={styles.link}>
|
||||
terms.uLink@kizuren.dev
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,4 +50,4 @@
|
|||
.footer {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,11 @@
|
|||
}
|
||||
|
||||
/* Base button styles for all buttons */
|
||||
.loginBtn, .registerBtn, .loginSubmitBtn, .logoutBtn, .dashboardBtn {
|
||||
.loginBtn,
|
||||
.registerBtn,
|
||||
.loginSubmitBtn,
|
||||
.logoutBtn,
|
||||
.dashboardBtn {
|
||||
background-color: var(--accent);
|
||||
color: var(--text-primary);
|
||||
border: none;
|
||||
|
|
@ -51,7 +55,10 @@
|
|||
padding: clamp(4px, 1vw, 8px) clamp(8px, 2vw, 16px);
|
||||
}
|
||||
|
||||
.loginBtn:hover, .registerBtn:hover, .loginSubmitBtn:hover, .dashboardBtn:hover {
|
||||
.loginBtn:hover,
|
||||
.registerBtn:hover,
|
||||
.loginSubmitBtn:hover,
|
||||
.dashboardBtn:hover {
|
||||
background-color: var(--accent-hover);
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
|
@ -85,7 +92,7 @@
|
|||
.container {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
|
||||
.auth {
|
||||
gap: 5px;
|
||||
}
|
||||
|
|
@ -100,14 +107,15 @@
|
|||
.header {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
|
||||
.auth {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.loginBtn, .registerBtn {
|
||||
|
||||
.loginBtn,
|
||||
.registerBtn {
|
||||
padding: 8px 16px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
|
@ -166,7 +174,7 @@
|
|||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
|
||||
.auth {
|
||||
min-width: auto;
|
||||
}
|
||||
|
|
@ -219,4 +227,4 @@
|
|||
|
||||
.idLabel {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,7 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
|
||||
export default function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<SessionProvider>
|
||||
{children}
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
|
|
@ -7,11 +7,11 @@ export default function ResponsiveLayout() {
|
|||
const header = document.querySelector('header');
|
||||
const footer = document.querySelector('footer');
|
||||
const content = document.querySelector('.page-content');
|
||||
|
||||
|
||||
if (header && footer && content) {
|
||||
const headerHeight = header.getBoundingClientRect().height;
|
||||
const footerHeight = footer.getBoundingClientRect().height;
|
||||
|
||||
|
||||
(content as HTMLElement).style.paddingTop = `${headerHeight}px`;
|
||||
(content as HTMLElement).style.paddingBottom = `${footerHeight}px`;
|
||||
}
|
||||
|
|
@ -20,24 +20,24 @@ export default function ResponsiveLayout() {
|
|||
useEffect(() => {
|
||||
adjustLayout();
|
||||
window.addEventListener('resize', adjustLayout);
|
||||
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
setTimeout(adjustLayout, 100);
|
||||
});
|
||||
|
||||
|
||||
const header = document.querySelector('header');
|
||||
const footer = document.querySelector('footer');
|
||||
|
||||
|
||||
if (header && footer) {
|
||||
observer.observe(header, { subtree: true, childList: true, attributes: true });
|
||||
observer.observe(footer, { subtree: true, childList: true, attributes: true });
|
||||
}
|
||||
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', adjustLayout);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { signOut, useSession } from 'next-auth/react';
|
||||
|
|
@ -8,22 +8,22 @@ export default function SessionMonitor() {
|
|||
const { data: session } = useSession();
|
||||
const { showToast } = useToast();
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
|
||||
|
||||
if (!session?.user) return;
|
||||
|
||||
|
||||
const checkSession = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/check-session');
|
||||
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
console.log('Session check failed:', data.message);
|
||||
|
||||
|
||||
showToast('Your session has expired or been revoked from another device', 'error');
|
||||
await signOut({ redirect: true, callbackUrl: '/' });
|
||||
}
|
||||
|
|
@ -31,15 +31,15 @@ export default function SessionMonitor() {
|
|||
console.error('Error checking session:', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
intervalRef.current = setInterval(checkSession, 10000);
|
||||
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [session, showToast]);
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,15 +5,13 @@ export default function Footer() {
|
|||
return (
|
||||
<footer className={styles.footer}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.copyright}>
|
||||
© {new Date().getFullYear()} µLinkShortener
|
||||
</div>
|
||||
<div className={styles.copyright}>© {new Date().getFullYear()} µLinkShortener</div>
|
||||
<div className={styles.links}>
|
||||
<Link href="/privacy">Privacy Policy</Link>
|
||||
<Link href="/tos">Terms of Service</Link>
|
||||
<Link href="https://github.com/Kizuren/uLinkShortener">GitHub</Link>
|
||||
<Link href='/privacy'>Privacy Policy</Link>
|
||||
<Link href='/tos'>Terms of Service</Link>
|
||||
<Link href='https://github.com/Kizuren/uLinkShortener'>GitHub</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { signIn, signOut, useSession } from "next-auth/react";
|
||||
import { signIn, signOut, useSession } from 'next-auth/react';
|
||||
import styles from './Header.module.css';
|
||||
import LoadingIcon from '@/components/ui/LoadingIcon';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
|
||||
const copyAccountIdToClipboard = (accountId: string) => {
|
||||
if (navigator.clipboard && accountId) {
|
||||
navigator.clipboard.writeText(accountId)
|
||||
navigator.clipboard
|
||||
.writeText(accountId)
|
||||
.then(() => {
|
||||
const displayElement = document.querySelector(`.${styles.accountIdDisplay}`);
|
||||
if (displayElement) {
|
||||
|
|
@ -33,9 +34,9 @@ export default function Header() {
|
|||
const router = useRouter();
|
||||
const { data: session, status } = useSession();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const isLoggedIn = status === "authenticated";
|
||||
const accountId = session?.user?.accountId as string || '';
|
||||
|
||||
const isLoggedIn = status === 'authenticated';
|
||||
const accountId = (session?.user?.accountId as string) || '';
|
||||
|
||||
useEffect(() => {
|
||||
if (showLoginForm && inputRef.current) {
|
||||
|
|
@ -44,19 +45,19 @@ export default function Header() {
|
|||
}, 10);
|
||||
}
|
||||
}, [showLoginForm]);
|
||||
|
||||
|
||||
const handleLoginSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const enteredAccountId = inputRef.current?.value;
|
||||
|
||||
|
||||
if (enteredAccountId) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await signIn("credentials", {
|
||||
const result = await signIn('credentials', {
|
||||
accountId: enteredAccountId,
|
||||
redirect: false
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
|
||||
if (result?.error) {
|
||||
showToast('Account not found. Please check your Account ID.', 'error');
|
||||
} else {
|
||||
|
|
@ -78,19 +79,19 @@ export default function Header() {
|
|||
const response = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Registration failed');
|
||||
}
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (data.success && data.account_id) {
|
||||
await signIn("credentials", {
|
||||
await signIn('credentials', {
|
||||
accountId: data.account_id,
|
||||
redirect: false
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
|
||||
router.push('/dashboard');
|
||||
} else {
|
||||
throw new Error(data.message || 'Failed to create account');
|
||||
|
|
@ -102,7 +103,7 @@ export default function Header() {
|
|||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleLogout = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
|
|
@ -120,69 +121,60 @@ export default function Header() {
|
|||
<header className={styles.header}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.logo}>
|
||||
<Link href="/">
|
||||
<Link href='/'>
|
||||
<h1>µLinkShortener</h1>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
|
||||
<div className={styles.auth}>
|
||||
{status === "loading" || isLoading ? (
|
||||
<LoadingIcon size={24} color="var(--accent)" />
|
||||
{status === 'loading' || isLoading ? (
|
||||
<LoadingIcon size={24} color='var(--accent)' />
|
||||
) : isLoggedIn ? (
|
||||
<div className={`${styles.userInfo} ${styles.animateIn}`}>
|
||||
<span
|
||||
<span
|
||||
className={styles.accountIdDisplay}
|
||||
onClick={() => copyAccountIdToClipboard(accountId)}
|
||||
title="Click to copy account ID"
|
||||
title='Click to copy account ID'
|
||||
>
|
||||
<span className={styles.idLabel}>Account ID: </span>
|
||||
{accountId}
|
||||
<span className={styles.copyMessage}>Copied!</span>
|
||||
</span>
|
||||
<button
|
||||
className={styles.logoutBtn}
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<button className={styles.logoutBtn} onClick={handleLogout}>
|
||||
Logout
|
||||
</button>
|
||||
<Link href="/dashboard">
|
||||
<Link href='/dashboard'>
|
||||
<button className={styles.dashboardBtn}>Dashboard</button>
|
||||
</Link>
|
||||
</div>
|
||||
) : !showLoginForm ? (
|
||||
<>
|
||||
<button
|
||||
className={styles.loginBtn}
|
||||
onClick={() => setShowLoginForm(true)}
|
||||
>
|
||||
<button className={styles.loginBtn} onClick={() => setShowLoginForm(true)}>
|
||||
Login
|
||||
</button>
|
||||
<button
|
||||
className={styles.registerBtn}
|
||||
onClick={handleRegister}
|
||||
>
|
||||
<button className={styles.registerBtn} onClick={handleRegister}>
|
||||
Register
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<form
|
||||
<form
|
||||
onSubmit={handleLoginSubmit}
|
||||
className={`${styles.loginForm} ${styles.animateIn}`}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Enter Account ID"
|
||||
pattern="[0-9]*"
|
||||
inputMode="numeric"
|
||||
type='text'
|
||||
placeholder='Enter Account ID'
|
||||
pattern='[0-9]*'
|
||||
inputMode='numeric'
|
||||
className={styles.accountInput}
|
||||
required
|
||||
/>
|
||||
<button type="submit" className={styles.loginSubmitBtn}>
|
||||
<button type='submit' className={styles.loginSubmitBtn}>
|
||||
Login
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
<button
|
||||
type='button'
|
||||
className={styles.cancelBtn}
|
||||
onClick={() => setShowLoginForm(false)}
|
||||
>
|
||||
|
|
@ -194,4 +186,4 @@ export default function Header() {
|
|||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,11 +74,21 @@
|
|||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(30px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
from {
|
||||
transform: translateY(30px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import styles from './ConfirmModal.module.css';
|
||||
|
|
@ -17,10 +17,10 @@ export default function ConfirmModal({
|
|||
isOpen,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = "Delete",
|
||||
cancelLabel = "Cancel",
|
||||
confirmLabel = 'Delete',
|
||||
cancelLabel = 'Cancel',
|
||||
onConfirm,
|
||||
onCancel
|
||||
onCancel,
|
||||
}: ConfirmModalProps) {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -29,12 +29,12 @@ export default function ConfirmModal({
|
|||
if (isOpen) {
|
||||
// Prevent scrolling of background content
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
|
||||
// Auto focus the first button
|
||||
const focusableElements = modalRef.current?.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
|
||||
|
||||
if (focusableElements && focusableElements.length > 0) {
|
||||
(focusableElements[0] as HTMLElement).focus();
|
||||
}
|
||||
|
|
@ -45,7 +45,7 @@ export default function ConfirmModal({
|
|||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
|
|
@ -58,33 +58,29 @@ export default function ConfirmModal({
|
|||
|
||||
return (
|
||||
<div className={styles.modalOverlay} onClick={onCancel}>
|
||||
<div
|
||||
className={styles.modalContent}
|
||||
<div
|
||||
className={styles.modalContent}
|
||||
onClick={e => e.stopPropagation()}
|
||||
ref={modalRef}
|
||||
role="dialog"
|
||||
aria-labelledby="modal-title"
|
||||
aria-modal="true"
|
||||
role='dialog'
|
||||
aria-labelledby='modal-title'
|
||||
aria-modal='true'
|
||||
>
|
||||
<h3 id="modal-title" className={styles.modalTitle}>{title}</h3>
|
||||
<h3 id='modal-title' className={styles.modalTitle}>
|
||||
{title}
|
||||
</h3>
|
||||
<div className={styles.modalBody}>
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<button
|
||||
className={styles.cancelButton}
|
||||
onClick={onCancel}
|
||||
>
|
||||
<button className={styles.cancelButton} onClick={onCancel}>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
className={styles.confirmButton}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
<button className={styles.confirmButton} onClick={onConfirm}>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,4 +43,4 @@
|
|||
.graphContainer {
|
||||
min-height: 12.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
|
|
@ -62,7 +62,7 @@ export default function Graph({
|
|||
loading = false,
|
||||
height = 200,
|
||||
maxItems = 8,
|
||||
colors = defaultColors
|
||||
colors = defaultColors,
|
||||
}: GraphProps) {
|
||||
const [chartLabels, setChartLabels] = useState<string[]>([]);
|
||||
const [chartValues, setChartValues] = useState<number[]>([]);
|
||||
|
|
@ -78,14 +78,14 @@ export default function Graph({
|
|||
const processedData = [...data];
|
||||
let labels: string[] = [];
|
||||
let values: number[] = [];
|
||||
|
||||
|
||||
if (processedData.length > maxItems) {
|
||||
const topItems = processedData.slice(0, maxItems - 1);
|
||||
const others = processedData.slice(maxItems - 1);
|
||||
|
||||
|
||||
labels = topItems.map(item => item.id);
|
||||
values = topItems.map(item => item.count);
|
||||
|
||||
|
||||
const othersSum = others.reduce((sum, item) => sum + item.count, 0);
|
||||
labels.push('Others');
|
||||
values.push(othersSum);
|
||||
|
|
@ -106,22 +106,22 @@ export default function Graph({
|
|||
chartRef.current.update();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const currentContainer = containerRef.current;
|
||||
|
||||
|
||||
if (currentContainer) {
|
||||
resizeObserver.observe(currentContainer);
|
||||
}
|
||||
|
||||
|
||||
// Also handle window resize
|
||||
const handleResize = () => {
|
||||
if (chartRef.current) {
|
||||
chartRef.current.update();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
|
||||
return () => {
|
||||
if (currentContainer) {
|
||||
resizeObserver.unobserve(currentContainer);
|
||||
|
|
@ -141,11 +141,11 @@ export default function Graph({
|
|||
labels: {
|
||||
color: 'white',
|
||||
font: {
|
||||
size: 10
|
||||
size: 10,
|
||||
},
|
||||
boxWidth: 12,
|
||||
padding: 10
|
||||
}
|
||||
padding: 10,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'var(--card-bg)',
|
||||
|
|
@ -155,10 +155,10 @@ export default function Graph({
|
|||
borderWidth: 1,
|
||||
padding: 8,
|
||||
boxWidth: 10,
|
||||
boxHeight: 10
|
||||
}
|
||||
boxHeight: 10,
|
||||
},
|
||||
},
|
||||
color: 'white'
|
||||
color: 'white',
|
||||
};
|
||||
|
||||
// Only add scales for bar and line charts
|
||||
|
|
@ -170,40 +170,40 @@ export default function Graph({
|
|||
ticks: {
|
||||
color: 'white',
|
||||
font: {
|
||||
size: 10
|
||||
size: 10,
|
||||
},
|
||||
maxRotation: 45,
|
||||
minRotation: 45,
|
||||
autoSkip: true,
|
||||
maxTicksLimit: 10
|
||||
maxTicksLimit: 10,
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.1)',
|
||||
border: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: 'white',
|
||||
font: {
|
||||
size: 10
|
||||
size: 10,
|
||||
},
|
||||
precision: 0
|
||||
precision: 0,
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.1)',
|
||||
border: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return baseOptions;
|
||||
};
|
||||
|
||||
|
|
@ -217,7 +217,7 @@ export default function Graph({
|
|||
backgroundColor: chartColors,
|
||||
borderColor: chartColors,
|
||||
borderWidth: 1,
|
||||
borderRadius: 4
|
||||
borderRadius: 4,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -233,7 +233,7 @@ export default function Graph({
|
|||
borderWidth: 2,
|
||||
tension: 0.1,
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 5
|
||||
pointHoverRadius: 5,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -247,7 +247,7 @@ export default function Graph({
|
|||
backgroundColor: chartColors,
|
||||
borderColor: chartColors,
|
||||
borderWidth: 1,
|
||||
hoverOffset: 5
|
||||
hoverOffset: 5,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -261,28 +261,28 @@ export default function Graph({
|
|||
) : data && data.length > 0 ? (
|
||||
<>
|
||||
{type === 'bar' && (
|
||||
<Bar
|
||||
data={getBarData()}
|
||||
options={getOptions()}
|
||||
ref={(ref) => {
|
||||
<Bar
|
||||
data={getBarData()}
|
||||
options={getOptions()}
|
||||
ref={ref => {
|
||||
if (ref) chartRef.current = ref;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{type === 'line' && (
|
||||
<Line
|
||||
data={getLineData()}
|
||||
options={getOptions()}
|
||||
ref={(ref) => {
|
||||
<Line
|
||||
data={getLineData()}
|
||||
options={getOptions()}
|
||||
ref={ref => {
|
||||
if (ref) chartRef.current = ref;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{type === 'doughnut' && (
|
||||
<Doughnut
|
||||
data={getDoughnutData()}
|
||||
options={getOptions()}
|
||||
ref={(ref) => {
|
||||
<Doughnut
|
||||
data={getDoughnutData()}
|
||||
options={getOptions()}
|
||||
ref={ref => {
|
||||
if (ref) chartRef.current = ref;
|
||||
}}
|
||||
/>
|
||||
|
|
@ -293,4 +293,4 @@ export default function Graph({
|
|||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
interface LoadingIconProps {
|
||||
size?: number;
|
||||
|
|
@ -7,14 +7,14 @@ interface LoadingIconProps {
|
|||
className?: string;
|
||||
}
|
||||
|
||||
export default function LoadingIcon({
|
||||
size = 24,
|
||||
export default function LoadingIcon({
|
||||
size = 24,
|
||||
color = 'var(--accent)',
|
||||
thickness = 2,
|
||||
className = ''
|
||||
className = '',
|
||||
}: LoadingIconProps) {
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
className={`loading-spinner ${className}`}
|
||||
style={{
|
||||
width: `${size}px`,
|
||||
|
|
@ -43,4 +43,4 @@ export default function LoadingIcon({
|
|||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@
|
|||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
transition: opacity 0.3s, transform 0.3s;
|
||||
transition:
|
||||
opacity 0.3s,
|
||||
transform 0.3s;
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
|
|
@ -52,6 +54,10 @@
|
|||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import styles from './Toast.module.css';
|
||||
|
|
@ -10,8 +10,8 @@ export default function Toast() {
|
|||
|
||||
return (
|
||||
<div className={styles.toastContainer}>
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
{toasts.map(toast => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`${styles.toast} ${styles[toast.type]} ${styles.toastShow}`}
|
||||
onClick={() => hideToast(toast.id)}
|
||||
|
|
@ -21,4 +21,4 @@ export default function Toast() {
|
|||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -161,25 +161,25 @@
|
|||
.hideOnMobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
.shortLinkCell {
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
|
||||
.targetUrl {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
|
||||
.targetUrl a {
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
|
||||
.actions {
|
||||
width: 60%;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.copyButton,
|
||||
|
||||
.copyButton,
|
||||
.deleteButton {
|
||||
padding: 0.3rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
|
|
@ -191,29 +191,29 @@
|
|||
.tableContainer {
|
||||
border: none;
|
||||
}
|
||||
|
||||
|
||||
.linkTable {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.linkTable th,
|
||||
|
||||
.linkTable th,
|
||||
.linkTable td {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.shortLinkCell {
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
|
||||
.targetUrl {
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
|
||||
.targetUrl a {
|
||||
max-width: 80px;
|
||||
}
|
||||
|
||||
|
||||
.actions {
|
||||
width: 70%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
|
@ -15,7 +15,7 @@ interface LinkData {
|
|||
|
||||
interface LinkTableProps {
|
||||
links: LinkData[];
|
||||
accountId: string
|
||||
accountId: string;
|
||||
onLinkDeleted: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -38,19 +38,21 @@ export default function AdminLinkTable({ links, accountId, onLinkDeleted }: Link
|
|||
}
|
||||
|
||||
const term = searchTerm.toLowerCase();
|
||||
const filtered = links.filter(link =>
|
||||
link.short_id.toLowerCase().includes(term) ||
|
||||
link.target_url.toLowerCase().includes(term) ||
|
||||
new Date(link.created_at).toLocaleString().toLowerCase().includes(term) ||
|
||||
new Date(link.last_modified).toLocaleString().toLowerCase().includes(term)
|
||||
const filtered = links.filter(
|
||||
link =>
|
||||
link.short_id.toLowerCase().includes(term) ||
|
||||
link.target_url.toLowerCase().includes(term) ||
|
||||
new Date(link.created_at).toLocaleString().toLowerCase().includes(term) ||
|
||||
new Date(link.last_modified).toLocaleString().toLowerCase().includes(term)
|
||||
);
|
||||
|
||||
|
||||
setFilteredLinks(filtered);
|
||||
}, [searchTerm, links]);
|
||||
|
||||
const copyToClipboard = (shortId: string) => {
|
||||
const fullUrl = `${window.location.origin}/l/${shortId}`;
|
||||
navigator.clipboard.writeText(fullUrl)
|
||||
navigator.clipboard
|
||||
.writeText(fullUrl)
|
||||
.then(() => {
|
||||
showToast('Link copied to clipboard!', 'success');
|
||||
})
|
||||
|
|
@ -72,7 +74,7 @@ export default function AdminLinkTable({ links, accountId, onLinkDeleted }: Link
|
|||
|
||||
const handleDeleteLink = async () => {
|
||||
if (!linkToDelete) return;
|
||||
|
||||
|
||||
try {
|
||||
setDeletingId(linkToDelete);
|
||||
const response = await fetch(`/api/admin/users/${accountId}/links/${linkToDelete}`, {
|
||||
|
|
@ -82,9 +84,9 @@ export default function AdminLinkTable({ links, accountId, onLinkDeleted }: Link
|
|||
},
|
||||
body: JSON.stringify({ shortId: linkToDelete }),
|
||||
});
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (response.ok && data.success) {
|
||||
showToast('Link deleted successfully!', 'success');
|
||||
if (onLinkDeleted) onLinkDeleted();
|
||||
|
|
@ -114,17 +116,17 @@ export default function AdminLinkTable({ links, accountId, onLinkDeleted }: Link
|
|||
<div className={styles.tableWrapper}>
|
||||
<div className={styles.searchContainer}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search links..."
|
||||
type='text'
|
||||
placeholder='Search links...'
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
className={styles.searchInput}
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
<button
|
||||
className={styles.clearSearchButton}
|
||||
onClick={() => setSearchTerm('')}
|
||||
title="Clear search"
|
||||
title='Clear search'
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
|
@ -150,10 +152,10 @@ export default function AdminLinkTable({ links, accountId, onLinkDeleted }: Link
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredLinks.map((link) => (
|
||||
{filteredLinks.map(link => (
|
||||
<tr key={link.short_id}>
|
||||
<td className={styles.shortLinkCell}>
|
||||
<Link
|
||||
<Link
|
||||
href={`/admin/user/${accountId}/links/${link.short_id}`}
|
||||
className={styles.shortLink}
|
||||
>
|
||||
|
|
@ -161,25 +163,25 @@ export default function AdminLinkTable({ links, accountId, onLinkDeleted }: Link
|
|||
</Link>
|
||||
</td>
|
||||
<td className={styles.targetUrl} title={link.target_url}>
|
||||
<a href={link.target_url} target="_blank" rel="noopener noreferrer">
|
||||
<a href={link.target_url} target='_blank' rel='noopener noreferrer'>
|
||||
{truncateUrl(link.target_url)}
|
||||
</a>
|
||||
</td>
|
||||
<td className={styles.hideOnMobile}>{formatDate(link.created_at)}</td>
|
||||
<td className={styles.hideOnMobile}>{formatDate(link.last_modified)}</td>
|
||||
<td className={styles.actions}>
|
||||
<button
|
||||
<button
|
||||
className={styles.copyButton}
|
||||
onClick={() => copyToClipboard(link.short_id)}
|
||||
title="Copy full short URL to clipboard"
|
||||
title='Copy full short URL to clipboard'
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
className={styles.deleteButton}
|
||||
onClick={() => confirmDelete(link.short_id)}
|
||||
disabled={deletingId === link.short_id}
|
||||
title="Delete this link"
|
||||
title='Delete this link'
|
||||
>
|
||||
{deletingId === link.short_id ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
|
|
@ -191,13 +193,13 @@ export default function AdminLinkTable({ links, accountId, onLinkDeleted }: Link
|
|||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
<ConfirmModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
title="Delete Link"
|
||||
message="Are you sure you want to delete this link? This action cannot be undone."
|
||||
title='Delete Link'
|
||||
message='Are you sure you want to delete this link? This action cannot be undone.'
|
||||
onConfirm={handleDeleteLink}
|
||||
onCancel={cancelDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -161,25 +161,25 @@
|
|||
.hideOnMobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
.shortLinkCell {
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
|
||||
.targetUrl {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
|
||||
.targetUrl a {
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
|
||||
.actions {
|
||||
width: 60%;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.copyButton,
|
||||
|
||||
.copyButton,
|
||||
.deleteButton {
|
||||
padding: 0.3rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
|
|
@ -191,29 +191,29 @@
|
|||
.tableContainer {
|
||||
border: none;
|
||||
}
|
||||
|
||||
|
||||
.linkTable {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.linkTable th,
|
||||
|
||||
.linkTable th,
|
||||
.linkTable td {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.shortLinkCell {
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
|
||||
.targetUrl {
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
|
||||
.targetUrl a {
|
||||
max-width: 80px;
|
||||
}
|
||||
|
||||
|
||||
.actions {
|
||||
width: 70%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
|
@ -31,19 +31,21 @@ export default function LinkTable({ links, onLinkDeleted }: LinkTableProps) {
|
|||
}
|
||||
|
||||
const term = searchTerm.toLowerCase();
|
||||
const filtered = links.filter(link =>
|
||||
link.short_id.toLowerCase().includes(term) ||
|
||||
link.target_url.toLowerCase().includes(term) ||
|
||||
new Date(link.created_at).toLocaleString().toLowerCase().includes(term) ||
|
||||
new Date(link.last_modified).toLocaleString().toLowerCase().includes(term)
|
||||
const filtered = links.filter(
|
||||
link =>
|
||||
link.short_id.toLowerCase().includes(term) ||
|
||||
link.target_url.toLowerCase().includes(term) ||
|
||||
new Date(link.created_at).toLocaleString().toLowerCase().includes(term) ||
|
||||
new Date(link.last_modified).toLocaleString().toLowerCase().includes(term)
|
||||
);
|
||||
|
||||
|
||||
setFilteredLinks(filtered);
|
||||
}, [searchTerm, links]);
|
||||
|
||||
const copyToClipboard = (shortId: string) => {
|
||||
const fullUrl = `${window.location.origin}/l/${shortId}`;
|
||||
navigator.clipboard.writeText(fullUrl)
|
||||
navigator.clipboard
|
||||
.writeText(fullUrl)
|
||||
.then(() => {
|
||||
showToast('Link copied to clipboard!', 'success');
|
||||
})
|
||||
|
|
@ -65,7 +67,7 @@ export default function LinkTable({ links, onLinkDeleted }: LinkTableProps) {
|
|||
|
||||
const handleDeleteLink = async () => {
|
||||
if (!linkToDelete) return;
|
||||
|
||||
|
||||
try {
|
||||
setDeletingId(linkToDelete);
|
||||
const response = await fetch('/api/link', {
|
||||
|
|
@ -75,9 +77,9 @@ export default function LinkTable({ links, onLinkDeleted }: LinkTableProps) {
|
|||
},
|
||||
body: JSON.stringify({ shortId: linkToDelete }),
|
||||
});
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (response.ok && data.success) {
|
||||
showToast('Link deleted successfully!', 'success');
|
||||
if (onLinkDeleted) onLinkDeleted();
|
||||
|
|
@ -107,17 +109,17 @@ export default function LinkTable({ links, onLinkDeleted }: LinkTableProps) {
|
|||
<div className={styles.tableWrapper}>
|
||||
<div className={styles.searchContainer}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search links..."
|
||||
type='text'
|
||||
placeholder='Search links...'
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
className={styles.searchInput}
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
<button
|
||||
className={styles.clearSearchButton}
|
||||
onClick={() => setSearchTerm('')}
|
||||
title="Clear search"
|
||||
title='Clear search'
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
|
@ -143,36 +145,33 @@ export default function LinkTable({ links, onLinkDeleted }: LinkTableProps) {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredLinks.map((link) => (
|
||||
{filteredLinks.map(link => (
|
||||
<tr key={link.short_id}>
|
||||
<td className={styles.shortLinkCell}>
|
||||
<Link
|
||||
href={`/dashboard/link/${link.short_id}`}
|
||||
className={styles.shortLink}
|
||||
>
|
||||
<Link href={`/dashboard/link/${link.short_id}`} className={styles.shortLink}>
|
||||
{link.short_id}
|
||||
</Link>
|
||||
</td>
|
||||
<td className={styles.targetUrl} title={link.target_url}>
|
||||
<a href={link.target_url} target="_blank" rel="noopener noreferrer">
|
||||
<a href={link.target_url} target='_blank' rel='noopener noreferrer'>
|
||||
{truncateUrl(link.target_url)}
|
||||
</a>
|
||||
</td>
|
||||
<td className={styles.hideOnMobile}>{formatDate(link.created_at)}</td>
|
||||
<td className={styles.hideOnMobile}>{formatDate(link.last_modified)}</td>
|
||||
<td className={styles.actions}>
|
||||
<button
|
||||
<button
|
||||
className={styles.copyButton}
|
||||
onClick={() => copyToClipboard(link.short_id)}
|
||||
title="Copy full short URL to clipboard"
|
||||
title='Copy full short URL to clipboard'
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
className={styles.deleteButton}
|
||||
onClick={() => confirmDelete(link.short_id)}
|
||||
disabled={deletingId === link.short_id}
|
||||
title="Delete this link"
|
||||
title='Delete this link'
|
||||
>
|
||||
{deletingId === link.short_id ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
|
|
@ -184,13 +183,13 @@ export default function LinkTable({ links, onLinkDeleted }: LinkTableProps) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
<ConfirmModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
title="Delete Link"
|
||||
message="Are you sure you want to delete this link? This action cannot be undone."
|
||||
title='Delete Link'
|
||||
message='Are you sure you want to delete this link? This action cannot be undone.'
|
||||
onConfirm={handleDeleteLink}
|
||||
onCancel={cancelDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,9 @@
|
|||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.loading, .error, .noSessions {
|
||||
.loading,
|
||||
.error,
|
||||
.noSessions {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
border-radius: 4px;
|
||||
|
|
@ -133,7 +135,7 @@
|
|||
.sessionsTable td:nth-child(3) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
.sessionsTable th:nth-child(4),
|
||||
.sessionsTable td:nth-child(4) {
|
||||
display: none;
|
||||
|
|
@ -144,14 +146,14 @@
|
|||
.sessionsTable {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.sessionsTable th,
|
||||
|
||||
.sessionsTable th,
|
||||
.sessionsTable td {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.revokeButton {
|
||||
padding: 0.3rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
|
@ -15,7 +15,7 @@ export default function SessionManager() {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [revoking, setRevoking] = useState<string | null>(null);
|
||||
const { showToast } = useToast();
|
||||
|
||||
|
||||
const isFetchingSessions = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -23,17 +23,17 @@ export default function SessionManager() {
|
|||
|
||||
async function fetchSessions() {
|
||||
if (!session?.user?.accountId) return;
|
||||
|
||||
|
||||
try {
|
||||
isFetchingSessions.current = true;
|
||||
setLoading(true);
|
||||
|
||||
|
||||
const response = await fetch('/api/auth/sessions');
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch sessions');
|
||||
}
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setSessions(data.sessions);
|
||||
|
|
@ -60,13 +60,14 @@ export default function SessionManager() {
|
|||
}
|
||||
|
||||
const term = searchTerm.toLowerCase();
|
||||
const filtered = sessions.filter(s =>
|
||||
s.userAgent.toLowerCase().includes(term) ||
|
||||
s.ipAddress.toLowerCase().includes(term) ||
|
||||
new Date(s.lastActive).toLocaleString().toLowerCase().includes(term) ||
|
||||
new Date(s.createdAt).toLocaleString().toLowerCase().includes(term)
|
||||
const filtered = sessions.filter(
|
||||
s =>
|
||||
s.userAgent.toLowerCase().includes(term) ||
|
||||
s.ipAddress.toLowerCase().includes(term) ||
|
||||
new Date(s.lastActive).toLocaleString().toLowerCase().includes(term) ||
|
||||
new Date(s.createdAt).toLocaleString().toLowerCase().includes(term)
|
||||
);
|
||||
|
||||
|
||||
setFilteredSessions(filtered);
|
||||
}, [searchTerm, sessions]);
|
||||
|
||||
|
|
@ -74,25 +75,23 @@ export default function SessionManager() {
|
|||
try {
|
||||
const sessionToRevoke = sessions.find(s => s.id === sessionId);
|
||||
if (sessionToRevoke?.isCurrentSession) {
|
||||
showToast("You cannot revoke your current session", "error");
|
||||
showToast('You cannot revoke your current session', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setRevoking(sessionId);
|
||||
|
||||
|
||||
const response = await fetch('/api/auth/sessions/revoke', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId })
|
||||
body: JSON.stringify({ sessionId }),
|
||||
});
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (response.ok && data.success) {
|
||||
showToast('Session revoked successfully', 'success');
|
||||
setSessions(prevSessions =>
|
||||
prevSessions.filter(s => s.id !== sessionId)
|
||||
);
|
||||
setSessions(prevSessions => prevSessions.filter(s => s.id !== sessionId));
|
||||
} else {
|
||||
showToast(data.message || 'Failed to revoke session', 'error');
|
||||
}
|
||||
|
|
@ -120,26 +119,26 @@ export default function SessionManager() {
|
|||
return (
|
||||
<div className={styles.sessionManager}>
|
||||
<h2>Active Sessions</h2>
|
||||
|
||||
|
||||
<div className={styles.searchContainer}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search sessions..."
|
||||
type='text'
|
||||
placeholder='Search sessions...'
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
className={styles.searchInput}
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
<button
|
||||
className={styles.clearSearchButton}
|
||||
onClick={() => setSearchTerm('')}
|
||||
title="Clear search"
|
||||
title='Clear search'
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{sessions.length === 0 ? (
|
||||
<p className={styles.noSessions}>No active sessions found.</p>
|
||||
) : filteredSessions.length === 0 ? (
|
||||
|
|
@ -157,7 +156,7 @@ export default function SessionManager() {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredSessions.map((s) => (
|
||||
{filteredSessions.map(s => (
|
||||
<tr key={s.id} className={s.isCurrentSession ? styles.currentSession : ''}>
|
||||
<td className={styles.deviceCell}>
|
||||
{s.userAgent.split(' ').slice(0, 3).join(' ')}
|
||||
|
|
@ -186,4 +185,4 @@ export default function SessionManager() {
|
|||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -177,12 +177,12 @@
|
|||
.tableContainer {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
|
||||
.analyticsTable th,
|
||||
.analyticsTable td {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.detailsGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
|
@ -259,9 +259,9 @@
|
|||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
|
||||
.searchContainer {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Analytics } from '@/types/analytics';
|
||||
|
|
@ -21,22 +21,22 @@ export default function AnalyticsTable({
|
|||
currentPage,
|
||||
itemsPerPage,
|
||||
onPageChange,
|
||||
onDeleteClick
|
||||
onDeleteClick,
|
||||
}: AnalyticsTableProps) {
|
||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [searchResults, setSearchResults] = useState<Analytics[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
|
||||
const totalPages = Math.ceil(totalItems / itemsPerPage);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
setSearchResults([]);
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
setIsSearching(true);
|
||||
const query = searchQuery.toLowerCase();
|
||||
const filtered = allAnalytics.filter(item => {
|
||||
|
|
@ -46,15 +46,17 @@ export default function AnalyticsTable({
|
|||
item.ip_version.toLowerCase().includes(query) ||
|
||||
item.country.toLowerCase().includes(query) ||
|
||||
(item.ip_data?.isp && item.ip_data.isp.toLowerCase().includes(query)) ||
|
||||
|
||||
// Device and browser info
|
||||
item.platform.toLowerCase().includes(query) ||
|
||||
item.platform
|
||||
.toLowerCase()
|
||||
.includes(query) ||
|
||||
item.browser.toLowerCase().includes(query) ||
|
||||
item.version.toLowerCase().includes(query) ||
|
||||
item.language.toLowerCase().includes(query) ||
|
||||
|
||||
// Additional details
|
||||
item.user_agent.toLowerCase().includes(query) ||
|
||||
item.user_agent
|
||||
.toLowerCase()
|
||||
.includes(query) ||
|
||||
item.referrer.toLowerCase().includes(query) ||
|
||||
item.remote_port.toLowerCase().includes(query) ||
|
||||
item.accept?.toLowerCase().includes(query) ||
|
||||
|
|
@ -62,10 +64,10 @@ export default function AnalyticsTable({
|
|||
item.accept_encoding?.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
setSearchResults(filtered);
|
||||
}, [searchQuery, allAnalytics]);
|
||||
|
||||
|
||||
const toggleRowExpansion = (id: string) => {
|
||||
const newExpandedRows = new Set(expandedRows);
|
||||
if (expandedRows.has(id)) {
|
||||
|
|
@ -75,56 +77,56 @@ export default function AnalyticsTable({
|
|||
}
|
||||
setExpandedRows(newExpandedRows);
|
||||
};
|
||||
|
||||
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchQuery(e.target.value);
|
||||
|
||||
|
||||
if (currentPage !== 1) {
|
||||
onPageChange(1);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const clearSearch = () => {
|
||||
setSearchQuery('');
|
||||
|
||||
|
||||
if (currentPage !== 1) {
|
||||
onPageChange(1);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const displayedAnalytics = isSearching ? searchResults : analytics;
|
||||
|
||||
|
||||
return (
|
||||
<div className={styles.tableContainer}>
|
||||
<div className={styles.tableHeader}>
|
||||
<h3 className={styles.tableTitle}>Click Details</h3>
|
||||
<div className={styles.searchContainer}>
|
||||
<input
|
||||
type="text"
|
||||
type='text'
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
placeholder="Search analytics..."
|
||||
placeholder='Search analytics...'
|
||||
className={styles.searchInput}
|
||||
aria-label="Search analytics"
|
||||
aria-label='Search analytics'
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
<button
|
||||
onClick={clearSearch}
|
||||
className={styles.clearSearchButton}
|
||||
aria-label="Clear search"
|
||||
aria-label='Clear search'
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{isSearching && (
|
||||
<div className={styles.searchResults}>
|
||||
Found {searchResults.length} result{searchResults.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className={styles.tableWrapper}>
|
||||
<table className={styles.analyticsTable}>
|
||||
<thead>
|
||||
|
|
@ -141,7 +143,7 @@ export default function AnalyticsTable({
|
|||
{displayedAnalytics.map(item => {
|
||||
const id = item._id?.toString() || '';
|
||||
const isExpanded = expandedRows.has(id);
|
||||
|
||||
|
||||
return (
|
||||
<tr key={id} className={isExpanded ? styles.expandedRow : ''}>
|
||||
<td>{new Date(item.timestamp).toLocaleString()}</td>
|
||||
|
|
@ -151,28 +153,28 @@ export default function AnalyticsTable({
|
|||
</td>
|
||||
<td>
|
||||
{item.country}
|
||||
<div className={styles.secondaryInfo}>ISP: {item.ip_data?.isp || 'Unknown'}</div>
|
||||
</td>
|
||||
<td>
|
||||
{item.platform}
|
||||
<div className={styles.secondaryInfo}>
|
||||
ISP: {item.ip_data?.isp || 'Unknown'}
|
||||
</div>
|
||||
</td>
|
||||
<td>{item.platform}</td>
|
||||
<td>
|
||||
{item.browser} {item.version}
|
||||
<div className={styles.secondaryInfo}>Lang: {item.language}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
<button
|
||||
className={styles.expandButton}
|
||||
onClick={() => toggleRowExpansion(id)}
|
||||
aria-label={isExpanded ? "Collapse details" : "Expand details"}
|
||||
aria-label={isExpanded ? 'Collapse details' : 'Expand details'}
|
||||
>
|
||||
{isExpanded ? '−' : '+'}
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
className={styles.deleteButton}
|
||||
onClick={() => onDeleteClick(id)}
|
||||
aria-label="Delete entry"
|
||||
aria-label='Delete entry'
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
|
|
@ -184,21 +186,19 @@ export default function AnalyticsTable({
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
{displayedAnalytics.length === 0 && (
|
||||
<div className={styles.noResults}>
|
||||
{isSearching
|
||||
? `No results found for "${searchQuery}"`
|
||||
: "No analytics data available"}
|
||||
{isSearching ? `No results found for "${searchQuery}"` : 'No analytics data available'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{expandedRows.size > 0 && (
|
||||
<div className={styles.expandedDetails}>
|
||||
{displayedAnalytics.map(item => {
|
||||
const id = item._id?.toString() || '';
|
||||
if (!expandedRows.has(id)) return null;
|
||||
|
||||
|
||||
return (
|
||||
<div key={`details-${id}`} className={styles.detailsCard}>
|
||||
<h4>Additional Details</h4>
|
||||
|
|
@ -233,22 +233,22 @@ export default function AnalyticsTable({
|
|||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{totalPages > 1 && !isSearching && (
|
||||
<div className={styles.pagination}>
|
||||
<button
|
||||
<button
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
className={styles.pageButton}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
|
||||
|
||||
<span className={styles.pageInfo}>
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
|
||||
<button
|
||||
|
||||
<button
|
||||
disabled={currentPage === totalPages}
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
className={styles.pageButton}
|
||||
|
|
@ -259,4 +259,4 @@ export default function AnalyticsTable({
|
|||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useState, useContext, ReactNode } from 'react';
|
||||
|
||||
|
|
@ -24,15 +24,15 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|||
const showToast = (message: string, type: ToastType = 'info') => {
|
||||
const id = Math.random().toString(36).substring(2, 9);
|
||||
const newToast = { id, message, type };
|
||||
setToasts((prev) => [...prev, newToast]);
|
||||
|
||||
setToasts(prev => [...prev, newToast]);
|
||||
|
||||
setTimeout(() => {
|
||||
hideToast(id);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const hideToast = (id: string) => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||
setToasts(prev => prev.filter(toast => toast.id !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -48,4 +48,4 @@ export function useToast() {
|
|||
throw new Error('useToast must be used within a ToastProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,11 +28,7 @@ export async function getIPData(ip: string): Promise<IPAddress> {
|
|||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
await collection.updateOne(
|
||||
{ ip_address: ip },
|
||||
{ $set: ipData },
|
||||
{ upsert: true }
|
||||
);
|
||||
await collection.updateOne({ ip_address: ip }, { $set: ipData }, { upsert: true });
|
||||
|
||||
return ipData;
|
||||
} catch {
|
||||
|
|
@ -42,7 +38,7 @@ export async function getIPData(ip: string): Promise<IPAddress> {
|
|||
isp: 'Unknown',
|
||||
country: 'Unknown',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -54,18 +50,19 @@ export interface AnalyticsQueryOptions {
|
|||
}
|
||||
|
||||
export async function getAllAnalytics(
|
||||
account_id: string,
|
||||
link_id: string,
|
||||
query_options: AnalyticsQueryOptions = {}): Promise<{analytics: Analytics[]; total: number}> {
|
||||
account_id: string,
|
||||
link_id: string,
|
||||
query_options: AnalyticsQueryOptions = {}
|
||||
): Promise<{ analytics: Analytics[]; total: number }> {
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<Analytics>(Collection.analytics_collection);
|
||||
|
||||
|
||||
const { startDate, endDate, page = 1, limit = 50 } = query_options;
|
||||
const timestamp: Record<string, Date> = {};
|
||||
if (startDate) timestamp["$gte"] = startDate;
|
||||
if (endDate) timestamp["$lte"] = endDate;
|
||||
|
||||
if (startDate) timestamp['$gte'] = startDate;
|
||||
if (endDate) timestamp['$lte'] = endDate;
|
||||
|
||||
// Overcomplicated shit
|
||||
const query: Omit<Partial<Analytics>, 'timestamp'> & { timestamp?: Record<string, Date> } = {
|
||||
account_id,
|
||||
|
|
@ -74,19 +71,19 @@ export async function getAllAnalytics(
|
|||
if (Object.keys(timestamp).length > 0) {
|
||||
query.timestamp = timestamp;
|
||||
}
|
||||
|
||||
|
||||
const cursor = collection
|
||||
.find(query)
|
||||
.sort({ timestamp: -1 }) // Most recent first
|
||||
.skip((page - 1) * limit)
|
||||
.limit(limit);
|
||||
|
||||
.find(query)
|
||||
.sort({ timestamp: -1 }) // Most recent first
|
||||
.skip((page - 1) * limit)
|
||||
.limit(limit);
|
||||
|
||||
const analytics = await cursor.toArray();
|
||||
const total = await collection.countDocuments(query);
|
||||
|
||||
return { analytics, total };
|
||||
} catch {
|
||||
return {analytics: [], total: 0};
|
||||
return { analytics: [], total: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -95,23 +92,29 @@ export async function saveAnalytics(analytics: Analytics): Promise<DetailedRetur
|
|||
const { db } = await getMongo();
|
||||
const collection = db.collection<Analytics>(Collection.analytics_collection);
|
||||
await collection.insertOne(analytics);
|
||||
|
||||
return { success: true, status: "Analytics successfully saved" };
|
||||
|
||||
return { success: true, status: 'Analytics successfully saved' };
|
||||
} catch {
|
||||
return { success: false, status: "An exception occured" };
|
||||
return { success: false, status: 'An exception occured' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeAllAnalytics(account_id: string, link_id: string): Promise<DetailedReturn> {
|
||||
export async function removeAllAnalytics(
|
||||
account_id: string,
|
||||
link_id: string
|
||||
): Promise<DetailedReturn> {
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<Analytics>(Collection.analytics_collection);
|
||||
const result = await collection.deleteMany({account_id: account_id, link_id: link_id});
|
||||
const result = await collection.deleteMany({ account_id: account_id, link_id: link_id });
|
||||
const success = result.deletedCount > 0;
|
||||
|
||||
return { success, status: success ? "Analytics were successfully deleted" : "No analytics found" };
|
||||
return {
|
||||
success,
|
||||
status: success ? 'Analytics were successfully deleted' : 'No analytics found',
|
||||
};
|
||||
} catch {
|
||||
return { success: false, status: "An exception occured" };
|
||||
return { success: false, status: 'An exception occured' };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -119,28 +122,35 @@ export async function removeAllAnalyticsFromUser(account_id: string): Promise<De
|
|||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<Analytics>(Collection.analytics_collection);
|
||||
const result = await collection.deleteMany({account_id: account_id});
|
||||
const result = await collection.deleteMany({ account_id: account_id });
|
||||
const hasRemovedAnalytics = result.deletedCount > 0;
|
||||
|
||||
return { success: true, status: hasRemovedAnalytics ? "All analytics were successfully removed" : "No analytics found" };
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status: hasRemovedAnalytics
|
||||
? 'All analytics were successfully removed'
|
||||
: 'No analytics found',
|
||||
};
|
||||
} catch {
|
||||
return { success: false, status: "An exception occured" };
|
||||
return { success: false, status: 'An exception occured' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeAnalytics(account_id: string, link_id: string, _id: string): Promise<DetailedReturn> {
|
||||
export async function removeAnalytics(
|
||||
account_id: string,
|
||||
link_id: string,
|
||||
_id: string
|
||||
): Promise<DetailedReturn> {
|
||||
const objectId = safeObjectId(_id);
|
||||
if(!objectId) return { success: false, status: "Invalid object ID" };
|
||||
if (!objectId) return { success: false, status: 'Invalid object ID' };
|
||||
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<Analytics>(Collection.analytics_collection);
|
||||
await collection.deleteOne(
|
||||
{_id: objectId, account_id: account_id, link_id: link_id}
|
||||
);
|
||||
|
||||
return { success: true, status: "Analytics successfully removed" };
|
||||
await collection.deleteOne({ _id: objectId, account_id: account_id, link_id: link_id });
|
||||
|
||||
return { success: true, status: 'Analytics successfully removed' };
|
||||
} catch {
|
||||
return { success: false, status: "An exception occured" };
|
||||
return { success: false, status: 'An exception occured' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,58 +3,73 @@ import { generateLinkID, isValidUrl } from './utils';
|
|||
import type { Link } from '@/types/link';
|
||||
import type { DetailedReturn } from '@/types/global';
|
||||
|
||||
export async function getLinks(account_id: string): Promise<{links: Link[], return: DetailedReturn}> {
|
||||
export async function getLinks(
|
||||
account_id: string
|
||||
): Promise<{ links: Link[]; return: DetailedReturn }> {
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<Link>(Collection.links_collection);
|
||||
|
||||
const links = await collection.find({ account_id })
|
||||
.sort({ created_at: -1 })
|
||||
.toArray();
|
||||
|
||||
return { links, return: { success: true, status: "Links retrieved successfully" } };
|
||||
|
||||
const links = await collection.find({ account_id }).sort({ created_at: -1 }).toArray();
|
||||
|
||||
return { links, return: { success: true, status: 'Links retrieved successfully' } };
|
||||
} catch {
|
||||
return { links: [], return: { success: false, status: "An exception occurred" } };
|
||||
return { links: [], return: { success: false, status: 'An exception occurred' } };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTargetUrl(short_id: string): Promise<{ target_url: string; account_id: string }> {
|
||||
export async function getTargetUrl(
|
||||
short_id: string
|
||||
): Promise<{ target_url: string; account_id: string }> {
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<Link>(Collection.links_collection);
|
||||
const found_link = await collection.findOne({short_id: short_id});
|
||||
|
||||
if (!found_link) return { target_url: "", account_id: "" };
|
||||
const found_link = await collection.findOne({ short_id: short_id });
|
||||
|
||||
return {
|
||||
if (!found_link) return { target_url: '', account_id: '' };
|
||||
|
||||
return {
|
||||
target_url: found_link.target_url,
|
||||
account_id: found_link.account_id
|
||||
account_id: found_link.account_id,
|
||||
};
|
||||
} catch {
|
||||
return { target_url: "", account_id: "" };
|
||||
return { target_url: '', account_id: '' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLinkById(account_id: string, short_id: string): Promise<{link: Link | null, return: DetailedReturn}> {
|
||||
export async function getLinkById(
|
||||
account_id: string,
|
||||
short_id: string
|
||||
): Promise<{ link: Link | null; return: DetailedReturn }> {
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<Link>(Collection.links_collection);
|
||||
|
||||
|
||||
const link = await collection.findOne({ short_id, account_id });
|
||||
|
||||
|
||||
if (!link) {
|
||||
return { link: null, return: { success: false, status: "Link not found or you don't have permission to view it" } };
|
||||
return {
|
||||
link: null,
|
||||
return: {
|
||||
success: false,
|
||||
status: "Link not found or you don't have permission to view it",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { link, return: { success: true, status: "Link retrieved successfully" } };
|
||||
|
||||
return { link, return: { success: true, status: 'Link retrieved successfully' } };
|
||||
} catch {
|
||||
return { link: null, return: { success: false, status: "An exception occurred" } };
|
||||
return { link: null, return: { success: false, status: 'An exception occurred' } };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createLink(account_id: string, target_url: string): Promise<{shortId: string | null, return: DetailedReturn}> {
|
||||
export async function createLink(
|
||||
account_id: string,
|
||||
target_url: string
|
||||
): Promise<{ shortId: string | null; return: DetailedReturn }> {
|
||||
try {
|
||||
if(!isValidUrl(target_url)) return { shortId: null, return: { success: false, status: "Invalid target URL" } };
|
||||
if (!isValidUrl(target_url))
|
||||
return { shortId: null, return: { success: false, status: 'Invalid target URL' } };
|
||||
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<Link>(Collection.links_collection);
|
||||
|
|
@ -68,38 +83,44 @@ export async function createLink(account_id: string, target_url: string): Promis
|
|||
} while (duplicate);
|
||||
|
||||
const newLink: Link = {
|
||||
short_id: shortId,
|
||||
target_url: target_url,
|
||||
account_id: account_id,
|
||||
created_at: new Date(),
|
||||
last_modified: new Date()
|
||||
}
|
||||
short_id: shortId,
|
||||
target_url: target_url,
|
||||
account_id: account_id,
|
||||
created_at: new Date(),
|
||||
last_modified: new Date(),
|
||||
};
|
||||
|
||||
await collection.insertOne(newLink);
|
||||
return { shortId, return: { success: true, status: "Link was successfully created" } };
|
||||
return { shortId, return: { success: true, status: 'Link was successfully created' } };
|
||||
} catch {
|
||||
return { shortId: null, return: { success: false, status: "An exception occured" } };
|
||||
return { shortId: null, return: { success: false, status: 'An exception occured' } };
|
||||
}
|
||||
}
|
||||
|
||||
export async function editLink(account_id: string, short_id: string, target_url: string): Promise<DetailedReturn> {
|
||||
export async function editLink(
|
||||
account_id: string,
|
||||
short_id: string,
|
||||
target_url: string
|
||||
): Promise<DetailedReturn> {
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<Link>(Collection.links_collection);
|
||||
|
||||
|
||||
const result = await collection.updateOne(
|
||||
{ account_id: account_id, short_id: short_id },
|
||||
{ $set: {
|
||||
target_url: target_url,
|
||||
last_modified: new Date()
|
||||
}}
|
||||
{ account_id: account_id, short_id: short_id },
|
||||
{
|
||||
$set: {
|
||||
target_url: target_url,
|
||||
last_modified: new Date(),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const success = result.modifiedCount > 0;
|
||||
|
||||
return { success, status: success ? "Link was successfully updated" : "Link not found" };
|
||||
return { success, status: success ? 'Link was successfully updated' : 'Link not found' };
|
||||
} catch {
|
||||
return { success: false, status: "An exception occured" };
|
||||
return { success: false, status: 'An exception occured' };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -107,12 +128,12 @@ export async function removeLink(account_id: string, short_id: string): Promise<
|
|||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<Link>(Collection.links_collection);
|
||||
const result = await collection.deleteOne({account_id: account_id, short_id: short_id});
|
||||
const result = await collection.deleteOne({ account_id: account_id, short_id: short_id });
|
||||
const success = result.deletedCount > 0;
|
||||
|
||||
return { success, status: success ? "Link was successfully removed" : "Link not found" };
|
||||
return { success, status: success ? 'Link was successfully removed' : 'Link not found' };
|
||||
} catch {
|
||||
return { success: false, status: "An exception occured" };
|
||||
return { success: false, status: 'An exception occured' };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -120,12 +141,15 @@ export async function removeAllLinksFromUser(account_id: string): Promise<Detail
|
|||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<Link>(Collection.links_collection);
|
||||
const result = await collection.deleteMany({account_id: account_id});
|
||||
const result = await collection.deleteMany({ account_id: account_id });
|
||||
const hasRemovedLinks = result.deletedCount > 0;
|
||||
|
||||
// Here it doesn't matter if no links were removed
|
||||
return { success: true, status: hasRemovedLinks ? "Links were successfully removed" : "No Links found" };
|
||||
return {
|
||||
success: true,
|
||||
status: hasRemovedLinks ? 'Links were successfully removed' : 'No Links found',
|
||||
};
|
||||
} catch {
|
||||
return { success: false, status: "An exception occured" };
|
||||
return { success: false, status: 'An exception occured' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,21 +2,15 @@ import winston from 'winston';
|
|||
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.json()
|
||||
),
|
||||
format: winston.format.combine(winston.format.timestamp(), winston.format.json()),
|
||||
transports: [
|
||||
new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.simple()
|
||||
)
|
||||
format: winston.format.combine(winston.format.colorize(), winston.format.simple()),
|
||||
}),
|
||||
|
||||
|
||||
new winston.transports.File({ filename: 'error.log', level: 'error' }),
|
||||
new winston.transports.File({ filename: 'combined.log' })
|
||||
]
|
||||
new winston.transports.File({ filename: 'combined.log' }),
|
||||
],
|
||||
});
|
||||
|
||||
export default logger;
|
||||
export default logger;
|
||||
|
|
|
|||
|
|
@ -1,22 +1,22 @@
|
|||
import { Db, MongoClient, ObjectId } from "mongodb";
|
||||
import { Db, MongoClient, ObjectId } from 'mongodb';
|
||||
|
||||
let client: MongoClient;
|
||||
let db: Db;
|
||||
|
||||
export const Collection = {
|
||||
links_collection: "links",
|
||||
analytics_collection: "analytics",
|
||||
statistics_collection: "statistics",
|
||||
user_collection: "users",
|
||||
sessions_collection: "sessions",
|
||||
ip_addresses_collection: "ip_addresses"
|
||||
links_collection: 'links',
|
||||
analytics_collection: 'analytics',
|
||||
statistics_collection: 'statistics',
|
||||
user_collection: 'users',
|
||||
sessions_collection: 'sessions',
|
||||
ip_addresses_collection: 'ip_addresses',
|
||||
};
|
||||
|
||||
export async function getMongo(): Promise<{ client: MongoClient; db: Db }> {
|
||||
if (client && db) {
|
||||
return { client, db };
|
||||
}
|
||||
|
||||
|
||||
if (!process.env.MONGO_URI) {
|
||||
throw new Error('Please add your MongoDB URI to .env');
|
||||
}
|
||||
|
|
@ -37,4 +37,4 @@ export function safeObjectId(id: string): ObjectId | null {
|
|||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,18 +5,18 @@ import { v4 as uuidv4 } from 'uuid';
|
|||
import logger from '@/lib/logger';
|
||||
|
||||
export async function createSession(
|
||||
accountId: string,
|
||||
userAgent: string,
|
||||
accountId: string,
|
||||
userAgent: string,
|
||||
ipAddress: string
|
||||
): Promise<{sessionId: string; return: DetailedReturn}> {
|
||||
): Promise<{ sessionId: string; return: DetailedReturn }> {
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<SessionInfo>(Collection.sessions_collection);
|
||||
|
||||
|
||||
const now = new Date();
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(now.getDate() + 30);
|
||||
|
||||
|
||||
const sessionId = uuidv4();
|
||||
const session: SessionInfo = {
|
||||
id: sessionId,
|
||||
|
|
@ -25,20 +25,20 @@ export async function createSession(
|
|||
ipAddress,
|
||||
lastActive: now,
|
||||
createdAt: now,
|
||||
expiresAt
|
||||
expiresAt,
|
||||
};
|
||||
|
||||
|
||||
await collection.insertOne(session);
|
||||
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
return: { success: true, status: "Session created" }
|
||||
return: { success: true, status: 'Session created' },
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error creating session:', error);
|
||||
return {
|
||||
sessionId: '',
|
||||
return: { success: false, status: "Failed to create session" }
|
||||
return: { success: false, status: 'Failed to create session' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -47,13 +47,13 @@ export async function isSessionValid(sessionId: string, accountId: string): Prom
|
|||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection(Collection.sessions_collection);
|
||||
|
||||
|
||||
const session = await collection.findOne({
|
||||
id: sessionId,
|
||||
accountId: accountId,
|
||||
revoked: { $ne: true }
|
||||
revoked: { $ne: true },
|
||||
});
|
||||
|
||||
|
||||
return !!session;
|
||||
} catch (error) {
|
||||
logger.error('Error checking session validity:', { error, sessionId, accountId });
|
||||
|
|
@ -65,15 +65,15 @@ export async function getSessions(accountId: string): Promise<SessionInfo[]> {
|
|||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<SessionInfo>(Collection.sessions_collection);
|
||||
|
||||
|
||||
const sessions = await collection
|
||||
.find({
|
||||
accountId,
|
||||
expiresAt: { $gt: new Date() }
|
||||
.find({
|
||||
accountId,
|
||||
expiresAt: { $gt: new Date() },
|
||||
})
|
||||
.sort({ lastActive: -1 })
|
||||
.toArray();
|
||||
|
||||
|
||||
return sessions;
|
||||
} catch (error) {
|
||||
logger.error('Error getting sessions:', error);
|
||||
|
|
@ -85,21 +85,21 @@ export async function revokeSession(sessionId: string, accountId: string): Promi
|
|||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<SessionInfo>(Collection.sessions_collection);
|
||||
|
||||
const result = await collection.deleteOne({
|
||||
|
||||
const result = await collection.deleteOne({
|
||||
id: sessionId,
|
||||
accountId
|
||||
accountId,
|
||||
});
|
||||
|
||||
|
||||
return {
|
||||
success: result.deletedCount > 0,
|
||||
status: result.deletedCount > 0 ? "Session revoked" : "Session not found"
|
||||
status: result.deletedCount > 0 ? 'Session revoked' : 'Session not found',
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error revoking session:', error);
|
||||
return {
|
||||
success: false,
|
||||
status: "Failed to revoke session"
|
||||
status: 'Failed to revoke session',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -108,20 +108,20 @@ export async function removeAllSessionsByAccountId(accountId: string): Promise<D
|
|||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<SessionInfo>(Collection.sessions_collection);
|
||||
|
||||
const result = await collection.deleteMany({
|
||||
accountId
|
||||
|
||||
const result = await collection.deleteMany({
|
||||
accountId,
|
||||
});
|
||||
|
||||
|
||||
return {
|
||||
success: result.deletedCount > 0,
|
||||
status: result.deletedCount > 0 ? "Sessions terminated" : "No Sessions found"
|
||||
status: result.deletedCount > 0 ? 'Sessions terminated' : 'No Sessions found',
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error terminating session:', error);
|
||||
return {
|
||||
success: false,
|
||||
status: "Failed to terminate session"
|
||||
status: 'Failed to terminate session',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -130,12 +130,9 @@ export async function updateSessionActivity(sessionId: string): Promise<void> {
|
|||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<SessionInfo>(Collection.sessions_collection);
|
||||
|
||||
await collection.updateOne(
|
||||
{ id: sessionId },
|
||||
{ $set: { lastActive: new Date() } }
|
||||
);
|
||||
|
||||
await collection.updateOne({ id: sessionId }, { $set: { lastActive: new Date() } });
|
||||
} catch (error) {
|
||||
logger.error('Error updating session activity:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,40 +23,46 @@ export async function updateStats(): Promise<DetailedReturn> {
|
|||
try {
|
||||
const { db } = await getMongo();
|
||||
const statisticsCollection = db.collection<Stats>(Collection.statistics_collection);
|
||||
|
||||
|
||||
// Get and update the data
|
||||
const analyticsCollection = db.collection<Analytics>(Collection.analytics_collection);
|
||||
const total_links = await db.collection<Link>(Collection.links_collection).countDocuments();
|
||||
const total_clicks = await analyticsCollection.countDocuments();
|
||||
const ipv6_count = await analyticsCollection.countDocuments({ip_version: 'IPv6'});
|
||||
const ipv6_count = await analyticsCollection.countDocuments({ ip_version: 'IPv6' });
|
||||
|
||||
const ip_versions: StatItem[] = [
|
||||
{ id: 'IPv4', count: total_clicks - ipv6_count },
|
||||
{ id: 'IPv6', count: ipv6_count },
|
||||
];
|
||||
|
||||
const os_stats_raw = await analyticsCollection.aggregate<StatItem>([
|
||||
{ $group: { _id: "$platform", count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $project: { id: "$_id", count: 1, _id: 0 } }
|
||||
]).toArray();
|
||||
const os_stats_raw = await analyticsCollection
|
||||
.aggregate<StatItem>([
|
||||
{ $group: { _id: '$platform', count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $project: { id: '$_id', count: 1, _id: 0 } },
|
||||
])
|
||||
.toArray();
|
||||
|
||||
const os_stats = os_stats_raw.map(item => ({
|
||||
id: formatOSStrings(item.id),
|
||||
count: item.count
|
||||
count: item.count,
|
||||
}));
|
||||
|
||||
const country_stats = await analyticsCollection.aggregate<StatItem>([
|
||||
{ $group: { _id: "$country", count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $project: { id: "$_id", count: 1, _id: 0 } }
|
||||
]).toArray();
|
||||
const country_stats = await analyticsCollection
|
||||
.aggregate<StatItem>([
|
||||
{ $group: { _id: '$country', count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $project: { id: '$_id', count: 1, _id: 0 } },
|
||||
])
|
||||
.toArray();
|
||||
|
||||
const isp_stats = await analyticsCollection.aggregate<StatItem>([
|
||||
{ $group: { _id: "$ip_data.isp", count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $project: { id: "$_id", count: 1, _id: 0 } }
|
||||
]).toArray();
|
||||
const isp_stats = await analyticsCollection
|
||||
.aggregate<StatItem>([
|
||||
{ $group: { _id: '$ip_data.isp', count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $project: { id: '$_id', count: 1, _id: 0 } },
|
||||
])
|
||||
.toArray();
|
||||
|
||||
const newStats: Stats = {
|
||||
total_links,
|
||||
|
|
@ -65,7 +71,7 @@ export async function updateStats(): Promise<DetailedReturn> {
|
|||
ip_versions,
|
||||
os_stats,
|
||||
country_stats,
|
||||
isp_stats
|
||||
isp_stats,
|
||||
},
|
||||
last_updated: new Date(),
|
||||
};
|
||||
|
|
@ -73,8 +79,8 @@ export async function updateStats(): Promise<DetailedReturn> {
|
|||
const result = await statisticsCollection.replaceOne({}, newStats, { upsert: true });
|
||||
const success = result.modifiedCount > 0;
|
||||
|
||||
return { success, status: success ? "Stats successfully updated" : "Failed to update stats" };
|
||||
return { success, status: success ? 'Stats successfully updated' : 'Failed to update stats' };
|
||||
} catch {
|
||||
return { success: false, status: "An exception occurred" };
|
||||
return { success: false, status: 'An exception occurred' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export async function isUserAdmin(account_id: string): Promise<boolean> {
|
|||
const { db } = await getMongo();
|
||||
const collection = db.collection<User>(Collection.user_collection);
|
||||
|
||||
const user = await collection.findOne({account_id: account_id});
|
||||
const user = await collection.findOne({ account_id: account_id });
|
||||
|
||||
return user?.is_admin ?? false;
|
||||
} catch {
|
||||
|
|
@ -39,27 +39,28 @@ export async function makeUserAdmin(account_id: string, admin: boolean): Promise
|
|||
);
|
||||
|
||||
if (result.matchedCount === 0) {
|
||||
return { success: false, status: "User not found" };
|
||||
return { success: false, status: 'User not found' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: result.modifiedCount > 0,
|
||||
status: result.modifiedCount > 0
|
||||
? `User is now ${admin ? 'an admin' : 'no longer an admin'}`
|
||||
: "No changes were made"
|
||||
return {
|
||||
success: result.modifiedCount > 0,
|
||||
status:
|
||||
result.modifiedCount > 0
|
||||
? `User is now ${admin ? 'an admin' : 'no longer an admin'}`
|
||||
: 'No changes were made',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error toggling admin status:', error);
|
||||
return { success: false, status: "An exception occurred" };
|
||||
return { success: false, status: 'An exception occurred' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function existsUser(account_id: string) : Promise<boolean> {
|
||||
export async function existsUser(account_id: string): Promise<boolean> {
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<User>(Collection.user_collection);
|
||||
|
||||
const user = await collection.findOne({account_id: account_id});
|
||||
const user = await collection.findOne({ account_id: account_id });
|
||||
|
||||
return user !== null;
|
||||
} catch {
|
||||
|
|
@ -67,7 +68,9 @@ export async function existsUser(account_id: string) : Promise<boolean> {
|
|||
}
|
||||
}
|
||||
|
||||
export async function createUser(is_admin: boolean): Promise<{account_id: string, return: DetailedReturn}> {
|
||||
export async function createUser(
|
||||
is_admin: boolean
|
||||
): Promise<{ account_id: string; return: DetailedReturn }> {
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<User>(Collection.user_collection);
|
||||
|
|
@ -81,17 +84,18 @@ export async function createUser(is_admin: boolean): Promise<{account_id: string
|
|||
} while (duplicate);
|
||||
|
||||
const newUser: User = {
|
||||
account_id: account_id,
|
||||
is_admin: is_admin,
|
||||
created_at: new Date()
|
||||
account_id: account_id,
|
||||
is_admin: is_admin,
|
||||
created_at: new Date(),
|
||||
};
|
||||
|
||||
const result = await collection.insertOne(newUser);
|
||||
if(!result.acknowledged) return { account_id: "", return: {success: false, status: "An error occured"} };
|
||||
if (!result.acknowledged)
|
||||
return { account_id: '', return: { success: false, status: 'An error occured' } };
|
||||
|
||||
return { account_id, return: {success: true, status: "User was successfully created"} };
|
||||
return { account_id, return: { success: true, status: 'User was successfully created' } };
|
||||
} catch {
|
||||
return { account_id: "", return: {success: false, status: "An exception occured"} };
|
||||
return { account_id: '', return: { success: false, status: 'An exception occured' } };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -100,15 +104,16 @@ export async function removeUser(account_id: string): Promise<DetailedReturn> {
|
|||
const { db } = await getMongo();
|
||||
const collection = db.collection<User>(Collection.user_collection);
|
||||
|
||||
const result = await collection.deleteOne({account_id: account_id});
|
||||
const result = await collection.deleteOne({ account_id: account_id });
|
||||
const removeAnalyticsResult = await removeAllAnalyticsFromUser(account_id);
|
||||
const removeLinksResult = await removeAllLinksFromUser(account_id);
|
||||
await removeAllSessionsByAccountId(account_id);
|
||||
const success = result.deletedCount > 0 && removeAnalyticsResult.success && removeLinksResult.success;
|
||||
const success =
|
||||
result.deletedCount > 0 && removeAnalyticsResult.success && removeLinksResult.success;
|
||||
|
||||
return { success: success, status: success ? "User successfully deleted" : "An error occured" };
|
||||
return { success: success, status: success ? 'User successfully deleted' : 'An error occured' };
|
||||
} catch {
|
||||
return { success: false, status: "An exception occured" };
|
||||
return { success: false, status: 'An exception occured' };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -116,7 +121,7 @@ export async function getUserById(account_id: string): Promise<User | null> {
|
|||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<User>(Collection.user_collection);
|
||||
|
||||
|
||||
const user = await collection.findOne({ account_id });
|
||||
return user;
|
||||
} catch {
|
||||
|
|
@ -124,7 +129,9 @@ export async function getUserById(account_id: string): Promise<User | null> {
|
|||
}
|
||||
}
|
||||
|
||||
export async function listUsers(query_options: UserQueryOptions = {}): Promise<{users: User[] | null, total: number, return: DetailedReturn}> {
|
||||
export async function listUsers(
|
||||
query_options: UserQueryOptions = {}
|
||||
): Promise<{ users: User[] | null; total: number; return: DetailedReturn }> {
|
||||
try {
|
||||
const { db } = await getMongo();
|
||||
const collection = db.collection<User>(Collection.user_collection);
|
||||
|
|
@ -138,21 +145,25 @@ export async function listUsers(query_options: UserQueryOptions = {}): Promise<{
|
|||
if (endDate) query.created_at.$lte = endDate;
|
||||
}
|
||||
|
||||
if (search && search.trim() !== "") {
|
||||
query.account_id = { $regex: new RegExp(search, "i") };
|
||||
if (search && search.trim() !== '') {
|
||||
query.account_id = { $regex: new RegExp(search, 'i') };
|
||||
}
|
||||
|
||||
const cursor = collection
|
||||
.find(query)
|
||||
.sort({ created_at: -1 }) // Most recent first
|
||||
.skip((page - 1) * limit)
|
||||
.limit(limit);
|
||||
.find(query)
|
||||
.sort({ created_at: -1 }) // Most recent first
|
||||
.skip((page - 1) * limit)
|
||||
.limit(limit);
|
||||
|
||||
const users = await cursor.toArray();
|
||||
const total = await collection.countDocuments(query);
|
||||
|
||||
return { users: users, total: total, return: {success: true, status: "Users successfully fetched"} };
|
||||
return {
|
||||
users: users,
|
||||
total: total,
|
||||
return: { success: true, status: 'Users successfully fetched' },
|
||||
};
|
||||
} catch {
|
||||
return { users: null, total: 0, return: {success: false, status: "An exception occured"} };
|
||||
return { users: null, total: 0, return: { success: false, status: 'An exception occured' } };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { NextRequest } from "next/server";
|
||||
import { NextRequest } from 'next/server';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
import { getIPData } from "./analyticsdb";
|
||||
import { getIPData } from './analyticsdb';
|
||||
|
||||
// For accounts
|
||||
const letterBytes = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
|
|
@ -33,23 +33,23 @@ export function generateAuthToken(): string {
|
|||
}
|
||||
|
||||
// For Links
|
||||
export function isValidUrl(urlStr: string) : boolean {
|
||||
if(urlStr.trim() === "") {
|
||||
export function isValidUrl(urlStr: string): boolean {
|
||||
if (urlStr.trim() === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(urlStr);
|
||||
return parsedUrl.protocol !== "" && parsedUrl.hostname !== "";
|
||||
return parsedUrl.protocol !== '' && parsedUrl.hostname !== '';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// For Clients
|
||||
const defaultValue = "Unknown"
|
||||
function valueOrDefault(value: string | null) : string {
|
||||
return !value || value?.trim() === "" ? defaultValue : value;
|
||||
const defaultValue = 'Unknown';
|
||||
function valueOrDefault(value: string | null): string {
|
||||
return !value || value?.trim() === '' ? defaultValue : value;
|
||||
}
|
||||
|
||||
export async function getClientInfo(req: NextRequest) {
|
||||
|
|
@ -109,9 +109,9 @@ export async function getClientInfo(req: NextRequest) {
|
|||
// For stats
|
||||
export function formatOSStrings(os_string: string): string {
|
||||
os_string = os_string.trim();
|
||||
os_string = os_string.replaceAll("\"", ""); // Windows usually reports ""Windows"""
|
||||
os_string = os_string.replaceAll("CPU ", ""); // iOS usually reports "CPU ....."
|
||||
os_string = os_string.replaceAll(" like Mac OS X", ""); // iOS usually reports at its end " like Mac OS X"
|
||||
os_string = os_string.replaceAll('"', ''); // Windows usually reports ""Windows"""
|
||||
os_string = os_string.replaceAll('CPU ', ''); // iOS usually reports "CPU ....."
|
||||
os_string = os_string.replaceAll(' like Mac OS X', ''); // iOS usually reports at its end " like Mac OS X"
|
||||
|
||||
return os_string;
|
||||
}
|
||||
|
|
@ -119,9 +119,9 @@ export function formatOSStrings(os_string: string): string {
|
|||
// For MongoDB
|
||||
export function sanitizeMongoDocument<T>(doc: T & { _id?: unknown }): T {
|
||||
if (!doc) return doc;
|
||||
|
||||
|
||||
const sanitized = { ...doc };
|
||||
delete sanitized._id;
|
||||
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
35
src/proxy.ts
35
src/proxy.ts
|
|
@ -5,36 +5,37 @@ import type { NextRequest } from 'next/server';
|
|||
export async function proxy(request: NextRequest) {
|
||||
const path = request.nextUrl.pathname;
|
||||
const response = NextResponse.next();
|
||||
|
||||
|
||||
try {
|
||||
if (path === '/dashboard' ||
|
||||
path === '/admin' ||
|
||||
path.startsWith('/api/link/') ||
|
||||
path.startsWith('/dashboard/') ||
|
||||
path.startsWith('/admin/')) {
|
||||
|
||||
const token = await getToken({
|
||||
if (
|
||||
path === '/dashboard' ||
|
||||
path === '/admin' ||
|
||||
path.startsWith('/api/link/') ||
|
||||
path.startsWith('/dashboard/') ||
|
||||
path.startsWith('/admin/')
|
||||
) {
|
||||
const token = await getToken({
|
||||
req: request,
|
||||
secret: process.env.NEXTAUTH_SECRET || 'fallback-secret-for-testing'
|
||||
secret: process.env.NEXTAUTH_SECRET || 'fallback-secret-for-testing',
|
||||
});
|
||||
|
||||
|
||||
// Not authenticated
|
||||
if (!token) {
|
||||
return NextResponse.redirect(new URL('/', request.url));
|
||||
}
|
||||
|
||||
|
||||
// Check token expiration
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (token.exp && (token.exp as number) < now) {
|
||||
return NextResponse.redirect(new URL('/api/auth/signout?callbackUrl=/', request.url));
|
||||
}
|
||||
|
||||
|
||||
// Check admin access
|
||||
if ((path === '/admin' || path.startsWith('/admin/')) && !token.isAdmin) {
|
||||
return NextResponse.redirect(new URL('/dashboard', request.url));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Middleware error:', error);
|
||||
|
|
@ -46,10 +47,10 @@ export async function proxy(request: NextRequest) {
|
|||
export const config = {
|
||||
matcher: [
|
||||
'/dashboard',
|
||||
'/dashboard/:path*',
|
||||
'/dashboard/:path*',
|
||||
'/admin',
|
||||
'/admin/:path*',
|
||||
'/api/link/:path*',
|
||||
'/api/auth/sessions/:path*'
|
||||
]
|
||||
};
|
||||
'/api/auth/sessions/:path*',
|
||||
],
|
||||
};
|
||||
|
|
|
|||
50
src/types/analytics.d.ts
vendored
50
src/types/analytics.d.ts
vendored
|
|
@ -1,30 +1,30 @@
|
|||
import { ObjectId } from "mongodb";
|
||||
import { ObjectId } from 'mongodb';
|
||||
|
||||
export interface Analytics {
|
||||
_id?: ObjectId
|
||||
link_id: string;
|
||||
account_id: string;
|
||||
ip_address: string;
|
||||
user_agent: string;
|
||||
platform: string;
|
||||
browser: string;
|
||||
version: string;
|
||||
language: string;
|
||||
referrer: string;
|
||||
timestamp: Date;
|
||||
remote_port: string;
|
||||
accept: string;
|
||||
accept_language: string;
|
||||
accept_encoding: string;
|
||||
country: string;
|
||||
ip_data: IPAddress;
|
||||
ip_version: string;
|
||||
_id?: ObjectId;
|
||||
link_id: string;
|
||||
account_id: string;
|
||||
ip_address: string;
|
||||
user_agent: string;
|
||||
platform: string;
|
||||
browser: string;
|
||||
version: string;
|
||||
language: string;
|
||||
referrer: string;
|
||||
timestamp: Date;
|
||||
remote_port: string;
|
||||
accept: string;
|
||||
accept_language: string;
|
||||
accept_encoding: string;
|
||||
country: string;
|
||||
ip_data: IPAddress;
|
||||
ip_version: string;
|
||||
}
|
||||
|
||||
export interface IPAddress {
|
||||
ip_address: string;
|
||||
ip_version: string;
|
||||
isp: string;
|
||||
country: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
ip_address: string;
|
||||
ip_version: string;
|
||||
isp: string;
|
||||
country: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
|
|
|||
10
src/types/auth.d.ts
vendored
10
src/types/auth.d.ts
vendored
|
|
@ -1,11 +1,11 @@
|
|||
import "next-auth";
|
||||
import 'next-auth';
|
||||
|
||||
declare module "next-auth" {
|
||||
declare module 'next-auth' {
|
||||
interface Session {
|
||||
user: {
|
||||
accountId: string;
|
||||
isAdmin: boolean;
|
||||
} & DefaultSession["user"];
|
||||
} & DefaultSession['user'];
|
||||
}
|
||||
|
||||
interface User {
|
||||
|
|
@ -14,9 +14,9 @@ declare module "next-auth" {
|
|||
}
|
||||
}
|
||||
|
||||
declare module "next-auth/jwt" {
|
||||
declare module 'next-auth/jwt' {
|
||||
interface JWT {
|
||||
accountId: string;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2
src/types/global.d.ts
vendored
2
src/types/global.d.ts
vendored
|
|
@ -1,4 +1,4 @@
|
|||
export interface DetailedReturn {
|
||||
success: boolean;
|
||||
status: string;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
12
src/types/link.d.ts
vendored
12
src/types/link.d.ts
vendored
|
|
@ -1,7 +1,7 @@
|
|||
export interface Link {
|
||||
short_id: string;
|
||||
target_url: string;
|
||||
account_id: string;
|
||||
created_at: Date;
|
||||
last_modified: Date;
|
||||
}
|
||||
short_id: string;
|
||||
target_url: string;
|
||||
account_id: string;
|
||||
created_at: Date;
|
||||
last_modified: Date;
|
||||
}
|
||||
|
|
|
|||
2
src/types/session.d.ts
vendored
2
src/types/session.d.ts
vendored
|
|
@ -7,4 +7,4 @@ export interface SessionInfo {
|
|||
createdAt: Date;
|
||||
expiresAt: Date;
|
||||
isCurrentSession?: boolean;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2
src/types/user.d.ts
vendored
2
src/types/user.d.ts
vendored
|
|
@ -2,4 +2,4 @@ export interface User {
|
|||
account_id: string;
|
||||
is_admin: boolean;
|
||||
created_at: Date;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,7 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
|
|
@ -23,9 +19,7 @@
|
|||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
|
|
@ -35,7 +29,5 @@
|
|||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue