Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 84 additions & 44 deletions components/dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
"use client";

import { User } from "@supabase/supabase-js";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useEffect, useState, useCallback } from "react";
import { Subscription } from "@/types/subscription";
import { toast } from "sonner";
import ProfileCard from "@/components/dashboard/profile-card";
import SubscriptionCard from "@/components/dashboard/subscription-card";
import FreeTrialCard from "@/components/dashboard/freetrial-card";
import { isAllowedUrl } from "@/lib/utils";
import { UnsafeUrlError } from "@/types/url";

type DashboardPageProps = {
subscription: Subscription | null;
openAppQueryParams: string;
openAppQueryParams: string | URLSearchParams;
user: User;
};

Expand All @@ -30,54 +33,92 @@ export default function DashboardPage({
percent_credit_used: null,
});

useEffect(() => {
const handleCallbackForApp = async () => {
// Handle callback
const callback = searchParams?.get("callback");
if (callback) {
const decodedCallback = decodeURIComponent(callback);
const callbackUrl = new URL(decodedCallback);
const newSearchParams = new URLSearchParams(callbackUrl.search);
const openAppParams = new URLSearchParams(openAppQueryParams);

openAppParams.forEach((value, key) => {
newSearchParams.append(key, value);
});

callbackUrl.search = newSearchParams.toString();
const openAppUrl = callbackUrl.toString();

router.push(openAppUrl);

const currentUrl = new URL(window.location.href);
currentUrl.searchParams.delete("callback");
window.history.replaceState({}, "", currentUrl.toString());
const handleCallbackForApp = useCallback(async () => {
const callback = searchParams?.get("callback");
if (!callback) return;

try {
const decodedCallback = decodeURIComponent(callback);
const callbackUrl = new URL(decodedCallback);

if (!isAllowedUrl(callbackUrl)) {
throw new UnsafeUrlError(decodedCallback);
}

const newSearchParams = new URLSearchParams(callbackUrl.search);
const openAppParams =
typeof openAppQueryParams === "string"
? new URLSearchParams(openAppQueryParams)
: openAppQueryParams;

openAppParams.forEach((value, key) => {
newSearchParams.append(key, value);
});

callbackUrl.search = newSearchParams.toString();
const openAppUrl = callbackUrl.toString();

// Double-check the final URL
const finalUrl = new URL(openAppUrl);
if (!isAllowedUrl(finalUrl)) {
throw new UnsafeUrlError(openAppUrl);
}
};

const getUserRequestsUsage = async () => {
router.push(openAppUrl);

const currentUrl = new URL(window.location.href);
currentUrl.searchParams.delete("callback");
window.history.replaceState({}, "", currentUrl.toString());
} catch (error) {
if (error instanceof UnsafeUrlError) {
console.error(error.message);
toast.error(
"Unsafe link detected. Navigation blocked for your security.",
);
} else {
console.error("Error in handleCallbackForApp:", error);
toast.error(
"An error occurred while processing the link. Please try again.",
);
}
}
}, [router, searchParams, openAppQueryParams]);

const getUserRequestsUsage = useCallback(async () => {
try {
const response = await fetch("/api/dashboard-usage", {
method: "GET",
headers: { "Content-Type": "application/json" },
});

if (!response.ok) {
throw new Error("Failed to fetch requests usage.");
}
const usageData: UsageType = await response.json();
setUsage(usageData);
} catch (error) {
console.error("Error fetching requests usage:", error);
toast.error("Failed to fetch usage data. Please try again later.");
} finally {
setLoading(false);
}
}, []);

useEffect(() => {
const runEffects = async () => {
try {
const response = await fetch("/api/dashboard-usage", {
method: "GET",
headers: { "Content-Type": "application/json" },
});

if (!response.ok) {
toast.error("Failed to fetch requests usage.");
return;
}
const usage = await response.json();
setUsage(usage);
await handleCallbackForApp();
await getUserRequestsUsage();
} catch (error) {
toast.error(`Error fetching requests usage: ${error}`);
} finally {
setLoading(false);
console.error("Error in effect:", error);
toast.error(
"An unexpected error occurred. Please try refreshing the page.",
);
}
};

handleCallbackForApp();
getUserRequestsUsage();
}, [router, searchParams, openAppQueryParams]);
runEffects();
}, [handleCallbackForApp, getUserRequestsUsage]);

return (
<section className="relative">
Expand All @@ -91,7 +132,6 @@ export default function DashboardPage({
</div>
<div className="grid gap-6 lg:grid-cols-2">
<ProfileCard user={user} />
{/* Below commented out until we implement Free Trial */}
{subscription ? (
<SubscriptionCard
subscription={subscription}
Expand Down
2 changes: 1 addition & 1 deletion components/dashboard/freetrial-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { UsageType } from "../dashboard";

type FreeTrialCardProps = {
usage: UsageType;
openAppQueryParams: string;
openAppQueryParams: string | URLSearchParams;
loading: boolean;
};

Expand Down
2 changes: 1 addition & 1 deletion components/dashboard/subscription-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { useUpgradeSubscription } from "@/hooks/useUpgradeSubscription";
type SubscriptionCardProps = {
subscription: Subscription | null;
usage?: UsageType;
openAppQueryParams?: string;
openAppQueryParams?: string | URLSearchParams;
user: User;
loading: boolean;
};
Expand Down
19 changes: 19 additions & 0 deletions lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { AllowedProtocol } from "../types/url";

export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
Expand All @@ -23,6 +24,24 @@ export const normalizeDate = (dateString: string) => {
return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`;
};

const allowedProtocols: AllowedProtocol[] = [
"http:",
"https:",
"pearai:",
"vscode:",
"code-oss:",
];

export function isAllowedUrl(url: URL): boolean {
if (!allowedProtocols.includes(url.protocol as AllowedProtocol)) return false;
if (url.protocol === "http:" || url.protocol === "https:") {
return (
url.origin === window.location.origin || url.hostname === "localhost"
);
}
return true;
}

export const constructMetadata = ({
title,
description = defaultMetadata.description,
Expand Down
1 change: 0 additions & 1 deletion next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
17 changes: 17 additions & 0 deletions types/url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export type AllowedProtocol =
| "http:"
| "https:"
| "pearai:"
| "vscode:"
| "code-oss:";

export interface OpenAppParams {
[key: string]: string;
}

export class UnsafeUrlError extends Error {
constructor(url: string) {
super(`Unsafe URL detected: ${url}`);
this.name = "UnsafeUrlError";
}
}