This commit is contained in:
daniel31x13
2025-09-18 20:23:40 -04:00
parent 7f8d6dcd50
commit d9d2e3b78f
14 changed files with 176 additions and 45 deletions

View File

@@ -8,7 +8,9 @@ interface Props {
children: ReactNode;
}
const stripeEnabled = process.env.NEXT_PUBLIC_STRIPE === "true";
const STRIPE_ENABLED = process.env.NEXT_PUBLIC_STRIPE === "true";
const TRIAL_PERIOD_DAYS = process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14;
const REQUIRE_CC = process.env.NEXT_PUBLIC_REQUIRE_CC === "true";
export default function AuthRedirect({ children }: Props) {
const router = useRouter();
@@ -22,11 +24,19 @@ export default function AuthRedirect({ children }: Props) {
const isLoggedIn = status === "authenticated";
const isUnauthenticated = status === "unauthenticated";
const isPublicPage = router.pathname.startsWith("/public");
const trialEndTime =
new Date(user?.createdAt || 0).getTime() +
(1 + Number(TRIAL_PERIOD_DAYS)) * 86400000; // Add 1 to account for the current day
const daysLeft = Math.floor((trialEndTime - Date.now()) / 86400000);
const hasInactiveSubscription =
user?.id &&
!user?.subscription?.active &&
!user.parentSubscription?.active &&
stripeEnabled;
STRIPE_ENABLED &&
(REQUIRE_CC || daysLeft <= 0);
// There are better ways of doing this... but this one works for now
const routes = [

View File

@@ -2,6 +2,10 @@ import verifySubscription from "./stripe/verifySubscription";
import { prisma } from "@linkwarden/prisma";
import stripeSDK from "./stripe/stripeSDK";
const REQUIRE_CC = process.env.NEXT_PUBLIC_REQUIRE_CC === "true";
const MANAGED_PAYMENTS_ENABLED =
process.env.MANAGED_PAYMENTS_ENABLED === "true";
export default async function paymentCheckout(email: string, priceId: string) {
const stripe = stripeSDK();
@@ -44,12 +48,16 @@ export default async function paymentCheckout(email: string, priceId: string) {
customer_email: isExistingCustomer ? undefined : email.toLowerCase(),
success_url: `${process.env.BASE_URL}/dashboard`,
cancel_url: `${process.env.BASE_URL}/login`,
subscription_data: {
trial_period_days: NEXT_PUBLIC_TRIAL_PERIOD_DAYS
? Number(NEXT_PUBLIC_TRIAL_PERIOD_DAYS)
: 14,
},
...(process.env.MANAGED_PAYMENTS_ENABLED === "true"
...(REQUIRE_CC
? {
subscription_data: {
trial_period_days: NEXT_PUBLIC_TRIAL_PERIOD_DAYS
? Number(NEXT_PUBLIC_TRIAL_PERIOD_DAYS)
: 14,
},
}
: {}),
...(MANAGED_PAYMENTS_ENABLED
? {
managed_payments: {
enabled: true,

View File

@@ -7,10 +7,25 @@ interface UserIncludingSubscription extends User {
parentSubscription: Subscription | null;
}
const TRIAL_PERIOD_DAYS = process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14;
const REQUIRE_CC = process.env.NEXT_PUBLIC_REQUIRE_CC === "true";
export default async function verifySubscription(
user?: UserIncludingSubscription | null
) {
if (!user || (!user.subscriptions && !user.parentSubscription)) {
if (!user) return null;
const trialEndTime =
new Date(user.createdAt).getTime() +
(1 + Number(TRIAL_PERIOD_DAYS)) * 86400000; // Add 1 to account for the current day
const daysLeft = Math.floor((trialEndTime - Date.now()) / 86400000);
if (
!user.subscriptions &&
!user.parentSubscription &&
(REQUIRE_CC || daysLeft <= 0)
) {
return null;
}
@@ -19,8 +34,9 @@ export default async function verifySubscription(
}
if (
!user.subscriptions?.active ||
new Date() > user.subscriptions.currentPeriodEnd
(!user.subscriptions?.active ||
new Date() > user.subscriptions.currentPeriodEnd) &&
(REQUIRE_CC || daysLeft <= 0)
) {
const subscription = await checkSubscriptionByEmail(user.email as string);

View File

@@ -46,6 +46,17 @@ export default async function verifyUser({
return null;
}
if (
!user.emailVerified &&
process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true"
) {
res.status(401).json({
response:
"Email not verified, please verify your email to continue using Linkwarden.",
});
return null;
}
if (STRIPE_SECRET_KEY) {
const subscribedUser = await verifySubscription(user);

View File

@@ -1,6 +1,6 @@
{
"name": "@linkwarden/web",
"version": "v2.12.2",
"version": "v2.13.0",
"main": "index.js",
"repository": "https://github.com/linkwarden/linkwarden.git",
"author": "Daniel31X13 <daniel31x13@gmail.com>",
@@ -74,7 +74,7 @@
"node-fetch": "^2.7.0",
"nodemailer": "^6.9.3",
"papaparse": "^5.5.3",
"playwright": "^1.45.0",
"playwright": "^1.55.0",
"react": "18.3.1",
"react-colorful": "^5.6.1",
"react-dom": "18.2.0",
@@ -98,7 +98,7 @@
"zustand": "^4.3.8"
},
"devDependencies": {
"@playwright/test": "^1.45.0",
"@playwright/test": "^1.55.0",
"@types/bcrypt": "^5.0.0",
"@types/dompurify": "^3.0.4",
"@types/jsdom": "^21.1.3",

View File

@@ -32,6 +32,8 @@ type UserModal = {
userId: number | null;
};
const TRIAL_PERIOD_DAYS = process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14;
export default function Billing() {
const router = useRouter();
const { t } = useTranslation();
@@ -40,9 +42,20 @@ export default function Billing() {
const { data: users = [] } = useUsers();
useEffect(() => {
if (!process.env.NEXT_PUBLIC_STRIPE || account?.parentSubscriptionId)
if (!process.env.NEXT_PUBLIC_STRIPE || account?.parentSubscriptionId) {
router.push("/settings/account");
}, []);
} else if (account?.createdAt) {
const trialEndTime =
new Date(account.createdAt).getTime() +
(1 + Number(TRIAL_PERIOD_DAYS)) * 86400000; // Add 1 to account for the current day
const daysLeft = Math.floor((trialEndTime - Date.now()) / 86400000);
if (daysLeft > 0 && !account.subscription?.active) {
router.push("/subscribe");
}
}
}, [account]);
const [searchQuery, setSearchQuery] = useState("");
const [filteredUsers, setFilteredUsers] = useState<User[]>();

View File

@@ -9,6 +9,11 @@ import getServerSideProps from "@/lib/client/getServerSideProps";
import { Trans, useTranslation } from "next-i18next";
import { useUser } from "@linkwarden/router/user";
import { Separator } from "@/components/ui/separator";
import Link from "next/link";
const TRIAL_PERIOD_DAYS =
Number(process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS) || 14;
const REQUIRE_CC = process.env.NEXT_PUBLIC_REQUIRE_CC === "true";
export default function Subscribe() {
const { t } = useTranslation();
@@ -21,6 +26,18 @@ export default function Subscribe() {
const { data: user } = useUser();
const [daysLeft, setDaysLeft] = useState<number>(0);
useEffect(() => {
if (user?.createdAt) {
const trialEndTime =
new Date(user.createdAt).getTime() +
(1 + Number(TRIAL_PERIOD_DAYS)) * 86400000; // Add 1 to account for the current day
setDaysLeft(Math.floor((trialEndTime - Date.now()) / 86400000));
}
}, [user]);
useEffect(() => {
if (
session.status === "authenticated" &&
@@ -45,9 +62,13 @@ export default function Subscribe() {
return (
<CenteredForm
text={`Start with a ${
process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14
}-day free trial, cancel anytime!`}
text={
daysLeft <= 0
? "Your free trial has ended, subscribe to continue."
: `You have ${REQUIRE_CC ? 14 : daysLeft || 0} ${
!REQUIRE_CC && daysLeft === 1 ? "day" : "days"
} left in your free trial.`
}
>
<div className="p-4 mx-auto flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 w-full bg-base-200 rounded-xl shadow-md border border-neutral-content">
<p className="sm:text-3xl text-xl text-center font-extralight">
@@ -116,11 +137,11 @@ export default function Subscribe() {
<p className="text-sm">
{plan === Plan.monthly
? t("total_monthly_desc", {
count: Number(process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS),
count: REQUIRE_CC ? 14 : daysLeft,
monthlyPrice: "4",
})
: t("total_annual_desc", {
count: Number(process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS),
count: REQUIRE_CC ? 14 : daysLeft,
annualPrice: "36",
})}
</p>
@@ -138,12 +159,21 @@ export default function Subscribe() {
{t("complete_subscription")}
</Button>
<div
onClick={() => signOut()}
className="w-fit mx-auto cursor-pointer text-neutral font-semibold "
>
{t("sign_out")}
</div>
{REQUIRE_CC ? (
<div
onClick={() => signOut()}
className="w-fit mx-auto cursor-pointer text-neutral font-semibold "
>
{t("sign_out")}
</div>
) : (
<Link
className="w-fit mx-auto cursor-pointer text-neutral font-semibold "
href="/dashboard"
>
{t("subscribe_later")}
</Link>
)}
</div>
</CenteredForm>
);

View File

@@ -513,5 +513,6 @@
"rename_tag_instruction": "Please provide a name for the final tag.",
"merging": "Merging...",
"delete_tags": "Delete {{count}} Tags",
"tags_deletion_confirmation_message": "Are you sure you want to delete {{count}} Tags? This will remove the tags from all links."
"tags_deletion_confirmation_message": "Are you sure you want to delete {{count}} Tags? This will remove the tags from all links.",
"subscribe_later": "Subscribe Later?"
}

View File

@@ -170,6 +170,7 @@ export default async function autoTagLink(
id: user.id,
},
},
aiGenerated: true,
},
})),
},

View File

@@ -5,6 +5,9 @@ type PickLinksOptions = {
maxBatchLinks: number;
};
const TRIAL_PERIOD_DAYS = process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14;
const REQUIRE_CC = process.env.NEXT_PUBLIC_REQUIRE_CC === "true";
export default async function getLinkBatchFairly({
maxBatchLinks,
}: PickLinksOptions) {
@@ -38,9 +41,26 @@ export default async function getLinkBatchFairly({
OR: [
{ subscriptions: { is: { active: true } } },
{ parentSubscription: { is: { active: true } } },
...(REQUIRE_CC
? []
: [
{
createdAt: {
gte: new Date(
new Date().getTime() -
Number(TRIAL_PERIOD_DAYS) * 86400000
),
},
},
]),
],
}
: {}),
...(process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true"
? {
emailVerified: { not: null },
}
: {}),
},
orderBy: [{ lastPickedAt: { sort: "asc", nulls: "first" } }, { id: "asc" }],
select: { id: true, lastPickedAt: true },

View File

@@ -26,14 +26,14 @@
"meilisearch": "^0.48.2",
"node-fetch": "^2.7.0",
"ollama-ai-provider": "^1.2.0",
"playwright": "^1.45.0",
"playwright": "^1.55.0",
"rss-parser": "^3.13.0",
"socks-proxy-agent": "^8.0.2",
"tsx": "^4.19.3",
"zod": "^3.23.8"
},
"devDependencies": {
"@playwright/test": "^1.45.0",
"@playwright/test": "^1.55.0",
"@types/node": "^22.14.1",
"nodemon": "^3.1.9",
"typescript": "^5.8.3"

View File

@@ -2,6 +2,8 @@ import { prisma } from "@linkwarden/prisma";
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
const stripeEnabled = process.env.STRIPE_SECRET_KEY;
const TRIAL_PERIOD_DAYS = process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14;
const REQUIRE_CC = process.env.NEXT_PUBLIC_REQUIRE_CC === "true";
export const hasPassedLimit = async (
userId: number,
@@ -22,6 +24,7 @@ export const hasPassedLimit = async (
select: {
parentSubscriptionId: true,
subscriptions: { select: { id: true, quantity: true } },
createdAt: true,
},
});
@@ -29,6 +32,22 @@ export const hasPassedLimit = async (
return true;
}
const trialEndTime =
new Date(user.createdAt).getTime() +
(1 + Number(TRIAL_PERIOD_DAYS)) * 86400000; // Add 1 to account for the current day
const daysLeft = Math.floor((trialEndTime - Date.now()) / 86400000);
if (!REQUIRE_CC && daysLeft > 0) {
const totalLinks = await prisma.link.count({
where: {
createdById: userId,
},
});
return MAX_LINKS_PER_USER - (numberOfImports + totalLinks) < 0;
}
const subscriptionId = user?.parentSubscriptionId ?? user?.subscriptions?.id;
let quantity = user?.subscriptions?.quantity;

View File

@@ -29,7 +29,9 @@ const useCollections = (auth?: MobileAuth) => {
: undefined
);
const data = await response.json();
return data.response;
if (Array.isArray(data.response)) return data.response;
else return [];
},
enabled: status === "authenticated",
});

View File

@@ -3221,12 +3221,12 @@
tiny-glob "^0.2.9"
tslib "^2.4.0"
"@playwright/test@^1.45.0":
version "1.45.0"
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.45.0.tgz#790a66165a46466c0d7099dd260881802f5aba7e"
integrity sha512-TVYsfMlGAaxeUllNkywbwek67Ncf8FRGn8ZlRdO291OL3NjG9oMbfVhyP82HQF0CZLMrYsvesqoUekxdWuF9Qw==
"@playwright/test@^1.55.0":
version "1.55.0"
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.55.0.tgz#080fa6d9ee6d749ff523b1c18259572d0268b963"
integrity sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==
dependencies:
playwright "1.45.0"
playwright "1.55.0"
"@prisma/client@^6.10.1":
version "6.10.1"
@@ -11188,17 +11188,17 @@ pkg-dir@^4.2.0:
dependencies:
find-up "^4.0.0"
playwright-core@1.45.0:
version "1.45.0"
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.45.0.tgz#5741a670b7c9060ce06852c0051d84736fb94edc"
integrity sha512-lZmHlFQ0VYSpAs43dRq1/nJ9G/6SiTI7VPqidld9TDefL9tX87bTKExWZZUF5PeRyqtXqd8fQi2qmfIedkwsNQ==
playwright-core@1.55.0:
version "1.55.0"
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.55.0.tgz#ec8a9f8ef118afb3e86e0f46f1393e3bea32adf4"
integrity sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==
playwright@1.45.0, playwright@^1.45.0:
version "1.45.0"
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.45.0.tgz#400c709c64438690f13705cb9c88ef93089c5c27"
integrity sha512-4z3ac3plDfYzGB6r0Q3LF8POPR20Z8D0aXcxbJvmfMgSSq1hkcgvFRXJk9rUq5H/MJ0Ktal869hhOdI/zUTeLA==
playwright@1.55.0, playwright@^1.55.0:
version "1.55.0"
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.55.0.tgz#7aca7ac3ffd9e083a8ad8b2514d6f9ba401cc78b"
integrity sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==
dependencies:
playwright-core "1.45.0"
playwright-core "1.55.0"
optionalDependencies:
fsevents "2.3.2"