add supporer key program

This commit is contained in:
miloschwartz 2025-03-20 22:16:02 -04:00
parent 1c2ba4076a
commit cdc415079c
No known key found for this signature in database
17 changed files with 908 additions and 74 deletions

View file

@ -405,6 +405,15 @@ export const resourceRules = sqliteTable("resourceRules", {
value: text("value").notNull() value: text("value").notNull()
}); });
export const supporterKey = sqliteTable("supporterKey", {
keyId: integer("keyId").primaryKey({ autoIncrement: true }),
key: text("key").notNull(),
githubUsername: text("githubUsername").notNull(),
phrase: text("phrase"),
tier: text("tier"),
valid: integer("valid", { mode: "boolean" }).notNull().default(false)
});
export type Org = InferSelectModel<typeof orgs>; export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>; export type User = InferSelectModel<typeof users>;
export type Site = InferSelectModel<typeof sites>; export type Site = InferSelectModel<typeof sites>;
@ -439,3 +448,4 @@ export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
export type VersionMigration = InferSelectModel<typeof versionMigrations>; export type VersionMigration = InferSelectModel<typeof versionMigrations>;
export type ResourceRule = InferSelectModel<typeof resourceRules>; export type ResourceRule = InferSelectModel<typeof resourceRules>;
export type Domain = InferSelectModel<typeof domains>; export type Domain = InferSelectModel<typeof domains>;
export type SupporterKey = InferSelectModel<typeof supporterKey>;

View file

@ -10,6 +10,8 @@ import {
} from "@server/lib/consts"; } from "@server/lib/consts";
import { passwordSchema } from "@server/auth/passwordSchema"; import { passwordSchema } from "@server/auth/passwordSchema";
import stoi from "./stoi"; import stoi from "./stoi";
import db from "@server/db";
import { SupporterKey, supporterKey } from "@server/db/schema";
const portSchema = z.number().positive().gt(0).lte(65535); const portSchema = z.number().positive().gt(0).lte(65535);
@ -155,6 +157,10 @@ const configSchema = z.object({
export class Config { export class Config {
private rawConfig!: z.infer<typeof configSchema>; private rawConfig!: z.infer<typeof configSchema>;
supporterData: SupporterKey | null = null;
supporterHiddenUntil: number | null = null;
constructor() { constructor() {
this.loadConfig(); this.loadConfig();
} }
@ -183,7 +189,9 @@ export class Config {
} }
if (process.env.APP_BASE_DOMAIN) { if (process.env.APP_BASE_DOMAIN) {
console.log("You're using deprecated environment variables. Transition to the configuration file. https://docs.fossorial.io/"); console.log(
"You're using deprecated environment variables. Transition to the configuration file. https://docs.fossorial.io/"
);
} }
if (!environment) { if (!environment) {
@ -235,6 +243,17 @@ export class Config {
: "false"; : "false";
process.env.DASHBOARD_URL = parsedConfig.data.app.dashboard_url; process.env.DASHBOARD_URL = parsedConfig.data.app.dashboard_url;
try {
this.checkSupporterKey();
} catch (error) {
console.error("Error checking supporter key:", error);
}
if (this.supporterData) {
process.env.SUPPORTER_DATA = JSON.stringify(this.supporterData);
console.log("Thank you for being a supporter of Pangolin!");
}
this.rawConfig = parsedConfig.data; this.rawConfig = parsedConfig.data;
} }
@ -251,6 +270,86 @@ export class Config {
public getDomain(domainId: string) { public getDomain(domainId: string) {
return this.rawConfig.domains[domainId]; return this.rawConfig.domains[domainId];
} }
public hideSupporterKey(days: number = 7) {
const now = new Date().getTime();
if (this.supporterHiddenUntil && now < this.supporterHiddenUntil) {
return;
}
this.supporterHiddenUntil = now + 1000 * 60 * 60 * 24 * days;
}
public isSupporterKeyHidden() {
const now = new Date().getTime();
if (this.supporterHiddenUntil && now < this.supporterHiddenUntil) {
return true;
}
return false;
}
public async checkSupporterKey() {
const [key] = await db.select().from(supporterKey).limit(1);
if (!key) {
return;
}
const { key: licenseKey, githubUsername } = key;
const response = await fetch(
"https://api.dev.fossorial.io/api/v1/license/validate",
{
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
licenseKey,
githubUsername
})
}
);
if (!response.ok) {
this.supporterData = key;
return;
}
const data = await response.json();
if (!data.data.valid) {
this.supporterData = {
...key,
valid: false
};
return;
}
this.supporterData = {
...key,
valid: true
};
// update the supporter key in the database
await db.transaction(async (trx) => {
await trx.delete(supporterKey);
await trx.insert(supporterKey).values({
githubUsername,
key: licenseKey,
tier: data.data.tier || null,
phrase: data.data.cutePhrase || null,
valid: true
});
});
}
public getSupporterData() {
return this.supporterData;
}
} }
export const config = new Config(); export const config = new Config();

View file

@ -8,6 +8,7 @@ import * as target from "./target";
import * as user from "./user"; import * as user from "./user";
import * as auth from "./auth"; import * as auth from "./auth";
import * as role from "./role"; import * as role from "./role";
import * as supporterKey from "./supporterKey";
import * as accessToken from "./accessToken"; import * as accessToken from "./accessToken";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { import {
@ -239,7 +240,6 @@ authenticated.delete(
target.deleteTarget target.deleteTarget
); );
authenticated.put( authenticated.put(
"/org/:orgId/role", "/org/:orgId/role",
verifyOrgAccess, verifyOrgAccess,
@ -382,6 +382,9 @@ authenticated.get(
authenticated.get(`/org/:orgId/overview`, verifyOrgAccess, org.getOrgOverview); authenticated.get(`/org/:orgId/overview`, verifyOrgAccess, org.getOrgOverview);
authenticated.post(`/supporter-key/validate`, supporterKey.validateSupporterKey);
authenticated.post(`/supporter-key/hide`, supporterKey.hideSupporterKey);
unauthenticated.get("/resource/:resourceId/auth", resource.getResourceAuthInfo); unauthenticated.get("/resource/:resourceId/auth", resource.getResourceAuthInfo);
// authenticated.get( // authenticated.get(

View file

@ -4,8 +4,12 @@ import * as traefik from "@server/routers/traefik";
import * as resource from "./resource"; import * as resource from "./resource";
import * as badger from "./badger"; import * as badger from "./badger";
import * as auth from "@server/routers/auth"; import * as auth from "@server/routers/auth";
import * as supporterKey from "@server/routers/supporterKey";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { verifyResourceAccess, verifySessionUserMiddleware } from "@server/middlewares"; import {
verifyResourceAccess,
verifySessionUserMiddleware
} from "@server/middlewares";
// Root routes // Root routes
const internalRouter = Router(); const internalRouter = Router();
@ -28,6 +32,11 @@ internalRouter.post(
resource.getExchangeToken resource.getExchangeToken
); );
internalRouter.get(
`/supporter-key/visible`,
supporterKey.isSupporterKeyVisible
);
// Gerbil routes // Gerbil routes
const gerbilRouter = Router(); const gerbilRouter = Router();
internalRouter.use("/gerbil", gerbilRouter); internalRouter.use("/gerbil", gerbilRouter);

View file

@ -0,0 +1,35 @@
import { Request, Response, NextFunction } from "express";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { response as sendResponse } from "@server/lib";
import config from "@server/lib/config";
export type HideSupporterKeyResponse = {
hidden: boolean;
};
export async function hideSupporterKey(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
config.hideSupporterKey();
return sendResponse<HideSupporterKeyResponse>(res, {
data: {
hidden: true
},
success: true,
error: false,
message: "Hidden",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -0,0 +1,3 @@
export * from "./validateSupporterKey";
export * from "./isSupporterKeyVisible";
export * from "./hideSupporterKey";

View file

@ -0,0 +1,54 @@
import { Request, Response, NextFunction } from "express";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { response as sendResponse } from "@server/lib";
import config from "@server/lib/config";
import db from "@server/db";
import { count } from "drizzle-orm";
import { users } from "@server/db/schema";
export type IsSupporterKeyVisibleResponse = {
visible: boolean;
};
const USER_LIMIT = 5;
export async function isSupporterKeyVisible(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const hidden = config.isSupporterKeyHidden();
const key = config.getSupporterData();
let visible = !hidden && key?.valid !== true;
if (key?.tier === "Limited Supporter") {
const [numUsers] = await db.select({ count: count() }).from(users);
if (numUsers.count > USER_LIMIT) {
visible = true;
}
}
logger.debug(`Supporter key visible: ${visible}`);
logger.debug(JSON.stringify(key));
return sendResponse<IsSupporterKeyVisibleResponse>(res, {
data: {
visible
},
success: true,
error: false,
message: "Status",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -0,0 +1,115 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { response as sendResponse } from "@server/lib";
import { suppressDeprecationWarnings } from "moment";
import { supporterKey } from "@server/db/schema";
import db from "@server/db";
import { eq } from "drizzle-orm";
import config from "@server/lib/config";
const validateSupporterKeySchema = z
.object({
githubUsername: z.string().nonempty(),
key: z.string().nonempty()
})
.strict();
export type ValidateSupporterKeyResponse = {
valid: boolean;
githubUsername?: string;
tier?: string;
phrase?: string;
};
export async function validateSupporterKey(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = validateSupporterKeySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { githubUsername, key } = parsedBody.data;
const response = await fetch(
"https://api.dev.fossorial.io/api/v1/license/validate",
{
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
licenseKey: key,
githubUsername: githubUsername
})
}
);
if (!response.ok) {
logger.error(response);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred"
)
);
}
const data = await response.json();
if (!data || !data.data.valid) {
return sendResponse<ValidateSupporterKeyResponse>(res, {
data: {
valid: false
},
success: true,
error: false,
message: "Invalid supporter key",
status: HttpCode.OK
});
}
await db.transaction(async (trx) => {
await trx.delete(supporterKey);
await trx.insert(supporterKey).values({
githubUsername: githubUsername,
key: key,
tier: data.data.tier || null,
phrase: data.data.cutePhrase || null,
valid: true
});
});
await config.checkSupporterKey();
return sendResponse<ValidateSupporterKeyResponse>(res, {
data: {
valid: true,
githubUsername: data.data.githubUsername,
tier: data.data.tier,
phrase: data.data.cutePhrase
},
success: true,
error: false,
message: "Valid supporter key",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -17,6 +17,7 @@ import m8 from "./scripts/1.0.0-beta12";
import m13 from "./scripts/1.0.0-beta13"; import m13 from "./scripts/1.0.0-beta13";
import m15 from "./scripts/1.0.0-beta15"; import m15 from "./scripts/1.0.0-beta15";
import m16 from "./scripts/1.0.0"; import m16 from "./scripts/1.0.0";
import m17 from "./scripts/1.1.0";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER // THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA // EXCEPT FOR THE DATABASE AND THE SCHEMA
@ -33,7 +34,8 @@ const migrations = [
{ version: "1.0.0-beta.12", run: m8 }, { version: "1.0.0-beta.12", run: m8 },
{ version: "1.0.0-beta.13", run: m13 }, { version: "1.0.0-beta.13", run: m13 },
{ version: "1.0.0-beta.15", run: m15 }, { version: "1.0.0-beta.15", run: m15 },
{ version: "1.0.0", run: m16 } { version: "1.0.0", run: m16 },
{ version: "1.1.0", run: m17 }
// Add new migrations here as they are created // Add new migrations here as they are created
] as const; ] as const;

View file

@ -0,0 +1,28 @@
import db from "@server/db";
import { sql } from "drizzle-orm";
const version = "1.1.0";
export default async function migration() {
console.log(`Running setup script ${version}...`);
try {
db.transaction((trx) => {
trx.run(sql`CREATE TABLE 'supporterKey' (
'keyId' integer PRIMARY KEY AUTOINCREMENT NOT NULL,
'key' text NOT NULL,
'githubUsername' text NOT NULL,
'phrase' text,
'tier' text,
'valid' integer DEFAULT false NOT NULL
);`);
});
console.log(`Migrated database schema`);
} catch (e) {
console.log("Unable to migrate database schema");
throw e;
}
console.log(`${version} migration complete`);
}

View file

@ -23,14 +23,7 @@ import {
FormLabel, FormLabel,
FormMessage FormMessage
} from "@/components/ui/form"; } from "@/components/ui/form";
import { import { LockIcon, Binary, Key, User, Send, AtSign } from "lucide-react";
LockIcon,
Binary,
Key,
User,
Send,
AtSign
} from "lucide-react";
import { import {
InputOTP, InputOTP,
InputOTPGroup, InputOTPGroup,
@ -50,6 +43,7 @@ import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import Link from "next/link"; import Link from "next/link";
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
const pinSchema = z.object({ const pinSchema = z.object({
pin: z pin: z
@ -115,6 +109,8 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
const api = createApiClient({ env }); const api = createApiClient({ env });
const { supporterStatus } = useSupporterStatusContext();
function getDefaultSelectedMethod() { function getDefaultSelectedMethod() {
if (props.methods.sso) { if (props.methods.sso) {
return "sso"; return "sso";
@ -194,7 +190,10 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
const session = res.data.data.session; const session = res.data.data.session;
if (session) { if (session) {
window.location.href = appendRequestToken(props.redirect, session); window.location.href = appendRequestToken(
props.redirect,
session
);
} }
}) })
.catch((e) => { .catch((e) => {
@ -216,7 +215,10 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
setPincodeError(null); setPincodeError(null);
const session = res.data.data.session; const session = res.data.data.session;
if (session) { if (session) {
window.location.href = appendRequestToken(props.redirect, session); window.location.href = appendRequestToken(
props.redirect,
session
);
} }
}) })
.catch((e) => { .catch((e) => {
@ -241,7 +243,10 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
setPasswordError(null); setPasswordError(null);
const session = res.data.data.session; const session = res.data.data.session;
if (session) { if (session) {
window.location.href = appendRequestToken(props.redirect, session); window.location.href = appendRequestToken(
props.redirect,
session
);
} }
}) })
.catch((e) => { .catch((e) => {
@ -621,6 +626,15 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
</Tabs> </Tabs>
</CardContent> </CardContent>
</Card> </Card>
{supporterStatus?.visible && (
<div className="text-center mt-2">
<span className="text-sm text-muted-foreground opacity-50">
Server is running without a supporter key.
<br />
Consider supporting the project!
</span>
</div>
)}
</div> </div>
) : ( ) : (
<ResourceAccessDenied /> <ResourceAccessDenied />

View file

@ -8,6 +8,10 @@ import { Separator } from "@app/components/ui/separator";
import { pullEnv } from "@app/lib/pullEnv"; import { pullEnv } from "@app/lib/pullEnv";
import { BookOpenText, ExternalLink } from "lucide-react"; import { BookOpenText, ExternalLink } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import SupportStatusProvider from "@app/providers/SupporterStatusProvider";
import { createApiClient, internal, priv } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { IsSupporterKeyVisibleResponse } from "@server/routers/supporterKey";
export const metadata: Metadata = { export const metadata: Metadata = {
title: `Dashboard - Pangolin`, title: `Dashboard - Pangolin`,
@ -24,6 +28,15 @@ export default async function RootLayout({
}>) { }>) {
const env = pullEnv(); const env = pullEnv();
let supporterData = {
visible: true
};
const res = await priv.get<
AxiosResponse<IsSupporterKeyVisibleResponse>
>("supporter-key/visible");
supporterData.visible = res.data.data.visible;
const version = env.app.version; const version = env.app.version;
return ( return (
@ -36,65 +49,69 @@ export default async function RootLayout({
disableTransitionOnChange disableTransitionOnChange
> >
<EnvProvider env={pullEnv()}> <EnvProvider env={pullEnv()}>
{/* Main content */} <SupportStatusProvider supporterStatus={supporterData}>
<div className="flex-grow pb-3 md:pb-0">{children}</div> {/* Main content */}
<div className="flex-grow pb-3 md:pb-0">
{/* Footer */} {children}
<footer className="hidden md:block w-full mt-12 py-3 mb-6 px-4">
<div className="container mx-auto flex flex-wrap justify-center items-center h-3 space-x-4 text-sm text-neutral-400 dark:text-neutral-600">
<div className="flex items-center space-x-2 whitespace-nowrap">
<span>Pangolin</span>
</div>
<Separator orientation="vertical" />
<a
href="https://fossorial.io/"
target="_blank"
rel="noopener noreferrer"
aria-label="Built by Fossorial"
className="flex items-center space-x-3 whitespace-nowrap"
>
<span>Fossorial</span>
<ExternalLink className="w-3 h-3" />
</a>
<Separator orientation="vertical" />
<a
href="https://github.com/fosrl/pangolin"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="flex items-center space-x-3 whitespace-nowrap"
>
<span>Open Source</span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-3 h-3"
>
<path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.207 11.385.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.385-1.333-1.755-1.333-1.755-1.09-.744.082-.73.082-.73 1.205.085 1.84 1.24 1.84 1.24 1.07 1.835 2.807 1.305 3.492.997.107-.775.42-1.305.763-1.605-2.665-.305-5.467-1.335-5.467-5.93 0-1.31.468-2.382 1.236-3.22-.123-.303-.535-1.523.117-3.176 0 0 1.008-.322 3.3 1.23a11.52 11.52 0 013.006-.403c1.02.005 2.045.137 3.006.403 2.29-1.552 3.295-1.23 3.295-1.23.654 1.653.242 2.873.12 3.176.77.838 1.235 1.91 1.235 3.22 0 4.605-2.805 5.623-5.475 5.92.43.37.814 1.1.814 2.22v3.293c0 .32.217.693.825.576C20.565 21.795 24 17.298 24 12 24 5.373 18.627 0 12 0z" />
</svg>
</a>
<Separator orientation="vertical" />
<a
href="https://docs.fossorial.io/Pangolin/overview"
target="_blank"
rel="noopener noreferrer"
aria-label="Documentation"
className="flex items-center space-x-3 whitespace-nowrap"
>
<span>Documentation</span>
<BookOpenText className="w-3 h-3" />
</a>
{version && (
<>
<Separator orientation="vertical" />
<div className="whitespace-nowrap">
v{version}
</div>
</>
)}
</div> </div>
</footer>
{/* Footer */}
<footer className="hidden md:block w-full mt-12 py-3 mb-6 px-4">
<div className="container mx-auto flex flex-wrap justify-center items-center h-3 space-x-4 text-sm text-neutral-400 dark:text-neutral-600">
<div className="flex items-center space-x-2 whitespace-nowrap">
<span>Pangolin</span>
</div>
<Separator orientation="vertical" />
<a
href="https://fossorial.io/"
target="_blank"
rel="noopener noreferrer"
aria-label="Built by Fossorial"
className="flex items-center space-x-3 whitespace-nowrap"
>
<span>Fossorial</span>
<ExternalLink className="w-3 h-3" />
</a>
<Separator orientation="vertical" />
<a
href="https://github.com/fosrl/pangolin"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="flex items-center space-x-3 whitespace-nowrap"
>
<span>Open Source</span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-3 h-3"
>
<path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.207 11.385.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.385-1.333-1.755-1.333-1.755-1.09-.744.082-.73.082-.73 1.205.085 1.84 1.24 1.84 1.24 1.07 1.835 2.807 1.305 3.492.997.107-.775.42-1.305.763-1.605-2.665-.305-5.467-1.335-5.467-5.93 0-1.31.468-2.382 1.236-3.22-.123-.303-.535-1.523.117-3.176 0 0 1.008-.322 3.3 1.23a11.52 11.52 0 013.006-.403c1.02.005 2.045.137 3.006.403 2.29-1.552 3.295-1.23 3.295-1.23.654 1.653.242 2.873.12 3.176.77.838 1.235 1.91 1.235 3.22 0 4.605-2.805 5.623-5.475 5.92.43.37.814 1.1.814 2.22v3.293c0 .32.217.693.825.576C20.565 21.795 24 17.298 24 12 24 5.373 18.627 0 12 0z" />
</svg>
</a>
<Separator orientation="vertical" />
<a
href="https://docs.fossorial.io/Pangolin/overview"
target="_blank"
rel="noopener noreferrer"
aria-label="Documentation"
className="flex items-center space-x-3 whitespace-nowrap"
>
<span>Documentation</span>
<BookOpenText className="w-3 h-3" />
</a>
{version && (
<>
<Separator orientation="vertical" />
<div className="whitespace-nowrap">
v{version}
</div>
</>
)}
</div>
</footer>
</SupportStatusProvider>
</EnvProvider> </EnvProvider>
<Toaster /> <Toaster />
</ThemeProvider> </ThemeProvider>

View file

@ -24,6 +24,7 @@ import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { useUserContext } from "@app/hooks/useUserContext"; import { useUserContext } from "@app/hooks/useUserContext";
import ProfileIcon from "./ProfileIcon"; import ProfileIcon from "./ProfileIcon";
import SupporterStatus from "./SupporterStatus";
type HeaderProps = { type HeaderProps = {
orgId?: string; orgId?: string;
@ -42,7 +43,13 @@ export function Header({ orgId, orgs }: HeaderProps) {
return ( return (
<> <>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<ProfileIcon /> <div className="flex items-center gap-2">
<ProfileIcon />
<div className="hidden md:block">
<SupporterStatus />
</div>
</div>
<div className="flex items-center"> <div className="flex items-center">
<div className="hidden md:block"> <div className="hidden md:block">

View file

@ -0,0 +1,364 @@
"use client";
import Image from "next/image";
import { Separator } from "@app/components/ui/separator";
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
import { useState } from "react";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { Button } from "./ui/button";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "./Credenza";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "./ui/form";
import { Input } from "./ui/input";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { AxiosResponse } from "axios";
import { ValidateSupporterKeyResponse } from "@server/routers/supporterKey";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle
} from "./ui/card";
import { Check, ExternalLink } from "lucide-react";
const formSchema = z.object({
githubUsername: z
.string()
.nonempty({ message: "GitHub username is required" }),
key: z.string().nonempty({ message: "Supporter key is required" })
});
export default function SupporterStatus() {
const { supporterStatus, updateSupporterStatus } =
useSupporterStatusContext();
const [supportOpen, setSupportOpen] = useState(false);
const [keyOpen, setKeyOpen] = useState(false);
const [purchaseOptionsOpen, setPurchaseOptionsOpen] = useState(false);
const api = createApiClient(useEnvContext());
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
githubUsername: "",
key: ""
}
});
async function hide() {
await api.post("/supporter-key/hide");
updateSupporterStatus({
visible: false
});
}
async function onSubmit(values: z.infer<typeof formSchema>) {
try {
const res = await api.post<
AxiosResponse<ValidateSupporterKeyResponse>
>("/supporter-key/validate", {
githubUsername: values.githubUsername,
key: values.key
});
const data = res.data.data;
if (!data || !data.valid) {
toast({
variant: "destructive",
title: "Invalid Key",
description: "Your supporter key is invalid."
});
return;
}
toast({
variant: "default",
title: "Valid Key",
description:
"Your supporter key has been validated. Thank you for your support!"
});
setPurchaseOptionsOpen(false);
setKeyOpen(false);
updateSupporterStatus({
visible: false
});
} catch (error) {
toast({
variant: "destructive",
title: "Error",
description: formatAxiosError(
error,
"Failed to validate supporter key."
)
});
return;
}
}
return (
<>
<Credenza
open={purchaseOptionsOpen}
onOpenChange={(val) => {
setPurchaseOptionsOpen(val);
}}
>
<CredenzaContent className="max-w-3xl">
<CredenzaHeader>
<CredenzaTitle>
Support Development and Adopt a Pangolin!
</CredenzaTitle>
</CredenzaHeader>
<CredenzaBody>
<p>
Purchase a supporter key to help us continue
developing Pangolin. Your contribution allows us
commit more time to maintain and add new features to
the application for everyone. We will never use this
to paywall features.
</p>
<p>
You will also get to adopt and meet your very own
pet Pangolin!
</p>
<p>
Payments are processed via GitHub. Afterward, you
can retrieve your key on{" "}
<Link
href="https://supporters.dev.fossorial.io/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
our website
</Link>{" "}
and redeem it here.{" "}
<Link
href="https://supporters.dev.fossorial.io/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Learn more.
</Link>
</p>
<p>Please select the option that best suits you.</p>
<div className="grid md:grid-cols-2 grid-cols-1 gap-8">
<Card>
<CardHeader>
<CardTitle>Full Supporter</CardTitle>
</CardHeader>
<CardContent>
<p className="text-4xl mb-6">$95</p>
<ul className="space-y-3">
<li className="flex items-center gap-2">
<Check className="h-6 w-6 text-green-500" />
<span className="text-muted-foreground">
For the whole server
</span>
</li>
<li className="flex items-center gap-2">
<Check className="h-6 w-6 text-green-500" />
<span className="text-muted-foreground">
Lifetime purchase
</span>
</li>
<li className="flex items-center gap-2">
<Check className="h-6 w-6 text-green-500" />
<span className="text-muted-foreground">
Supporter status
</span>
</li>
</ul>
</CardContent>
<CardFooter>
<Link
href="https://www.google.com"
target="_blank"
rel="noopener noreferrer"
className="w-full"
>
<Button className="w-full">Buy</Button>
</Link>
</CardFooter>
</Card>
<Card>
<CardHeader>
<CardTitle>Limited Supporter</CardTitle>
</CardHeader>
<CardContent>
<p className="text-4xl mb-6">$25</p>
<ul className="space-y-3">
<li className="flex items-center gap-2">
<Check className="h-6 w-6 text-green-500" />
<span className="text-muted-foreground">
For 5 or less users
</span>
</li>
<li className="flex items-center gap-2">
<Check className="h-6 w-6 text-green-500" />
<span className="text-muted-foreground">
Lifetime purchase
</span>
</li>
<li className="flex items-center gap-2">
<Check className="h-6 w-6 text-green-500" />
<span className="text-muted-foreground">
Supporter status
</span>
</li>
</ul>
</CardContent>
<CardFooter>
<Link
href="https://www.google.com"
target="_blank"
rel="noopener noreferrer"
className="w-full"
>
<Button className="w-full">Buy</Button>
</Link>
</CardFooter>
</Card>
</div>
<div className="w-full pt-6 space-y-2">
<Button
className="w-full"
variant="outlinePrimary"
onClick={() => {
setKeyOpen(true);
}}
>
Redeem Supporter Key
</Button>
<Button
variant="ghost"
className="w-full"
onClick={() => hide()}
>
Hide for 7 days
</Button>
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
<Credenza
open={keyOpen}
onOpenChange={(val) => {
setKeyOpen(val);
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Enter Supporter Key</CredenzaTitle>
<CredenzaDescription>
Meet your very own pet Pangolin!
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="form"
>
<FormField
control={form.control}
name="githubUsername"
render={({ field }) => (
<FormItem>
<FormLabel>
GitHub Username
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="key"
render={({ field }) => (
<FormItem>
<FormLabel>Supporter Key</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button type="submit" form="form">
Submit
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
{supporterStatus?.visible ? (
<Button
variant="outlinePrimary"
size="sm"
className="gap-2"
onClick={() => {
setPurchaseOptionsOpen(true);
}}
>
Buy Supporter Key
</Button>
) : null}
</>
);
}

View file

@ -0,0 +1,16 @@
import { createContext } from "react";
export type SupporterStatus = {
visible: boolean;
};
type SupporterStatusContextType = {
supporterStatus: SupporterStatus | null;
updateSupporterStatus: (updatedSite: Partial<SupporterStatus>) => void;
};
const SupporterStatusContext = createContext<
SupporterStatusContextType | undefined
>(undefined);
export default SupporterStatusContext;

View file

@ -0,0 +1,12 @@
import SupporterStatusContext from "@app/contexts/supporterStatusContext";
import { useContext } from "react";
export function useSupporterStatusContext() {
const context = useContext(SupporterStatusContext);
if (context === undefined) {
throw new Error(
"useSupporterStatusContext must be used within an SupporterStatusProvider"
);
}
return context;
}

View file

@ -0,0 +1,46 @@
"use client";
import SupportStatusContext, {
SupporterStatus
} from "@app/contexts/supporterStatusContext";
import { useState } from "react";
interface ProviderProps {
children: React.ReactNode;
supporterStatus: SupporterStatus | null;
}
export function SupporterStatusProvider({
children,
supporterStatus
}: ProviderProps) {
const [supporterStatusState, setSupporterStatusState] =
useState<SupporterStatus | null>(supporterStatus);
const updateSupporterStatus = (
updatedSupporterStatus: Partial<SupporterStatus>
) => {
setSupporterStatusState((prev) => {
if (!prev) {
return updatedSupporterStatus as SupporterStatus;
}
return {
...prev,
...updatedSupporterStatus
};
});
};
return (
<SupportStatusContext.Provider
value={{
supporterStatus: supporterStatusState,
updateSupporterStatus
}}
>
{children}
</SupportStatusContext.Provider>
);
}
export default SupporterStatusProvider;