Merge branch 'dev' into feature/newt-podman-install

This commit is contained in:
Owen Schwartz 2025-04-10 21:40:23 -04:00 committed by GitHub
commit cf80d67bf8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 2206 additions and 1898 deletions

2
.gitignore vendored
View file

@ -32,4 +32,4 @@ installer
bin bin
.secrets .secrets
test_event.json test_event.json
.idea/ .idea/

View file

@ -10,7 +10,7 @@ services:
test: ["CMD", "curl", "-f", "http://localhost:3001/api/v1/"] test: ["CMD", "curl", "-f", "http://localhost:3001/api/v1/"]
interval: "3s" interval: "3s"
timeout: "3s" timeout: "3s"
retries: 5 retries: 15
gerbil: gerbil:
image: fosrl/gerbil:latest image: fosrl/gerbil:latest

View file

@ -10,7 +10,7 @@ services:
test: ["CMD", "curl", "-f", "http://localhost:3001/api/v1/"] test: ["CMD", "curl", "-f", "http://localhost:3001/api/v1/"]
interval: "3s" interval: "3s"
timeout: "3s" timeout: "3s"
retries: 5 retries: 15
{{if .InstallGerbil}} {{if .InstallGerbil}}
gerbil: gerbil:
image: fosrl/gerbil:{{.GerbilVersion}} image: fosrl/gerbil:{{.GerbilVersion}}

2824
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -20,6 +20,7 @@
"email": "email dev --dir server/emails/templates --port 3005" "email": "email dev --dir server/emails/templates --port 3005"
}, },
"dependencies": { "dependencies": {
"@asteasolutions/zod-to-openapi": "^7.3.0",
"@hookform/resolvers": "3.9.1", "@hookform/resolvers": "3.9.1",
"@node-rs/argon2": "2.0.2", "@node-rs/argon2": "2.0.2",
"@oslojs/crypto": "1.0.1", "@oslojs/crypto": "1.0.1",
@ -39,11 +40,13 @@
"@radix-ui/react-switch": "1.1.2", "@radix-ui/react-switch": "1.1.2",
"@radix-ui/react-tabs": "1.1.2", "@radix-ui/react-tabs": "1.1.2",
"@radix-ui/react-toast": "1.2.4", "@radix-ui/react-toast": "1.2.4",
"@react-email/components": "0.0.31", "@react-email/components": "0.0.36",
"@react-email/render": "^1.0.6",
"@react-email/tailwind": "1.0.4", "@react-email/tailwind": "1.0.4",
"@tanstack/react-table": "8.20.6", "@tanstack/react-table": "8.20.6",
"axios": "1.7.9", "axios": "1.8.4",
"better-sqlite3": "11.7.0", "better-sqlite3": "11.7.0",
"canvas-confetti": "1.9.3",
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
"clsx": "2.1.1", "clsx": "2.1.1",
"cmdk": "1.0.4", "cmdk": "1.0.4",
@ -62,7 +65,7 @@
"js-yaml": "4.1.0", "js-yaml": "4.1.0",
"lucide-react": "0.469.0", "lucide-react": "0.469.0",
"moment": "2.30.1", "moment": "2.30.1",
"next": "15.1.3", "next": "15.2.4",
"next-themes": "0.4.4", "next-themes": "0.4.4",
"node-cache": "5.1.2", "node-cache": "5.1.2",
"node-fetch": "3.3.2", "node-fetch": "3.3.2",
@ -77,6 +80,7 @@
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"rebuild": "0.1.2", "rebuild": "0.1.2",
"semver": "7.6.3", "semver": "7.6.3",
"swagger-ui-express": "^5.0.1",
"tailwind-merge": "2.6.0", "tailwind-merge": "2.6.0",
"tailwindcss-animate": "1.0.7", "tailwindcss-animate": "1.0.7",
"vaul": "1.1.2", "vaul": "1.1.2",
@ -99,16 +103,17 @@
"@types/react": "19.0.2", "@types/react": "19.0.2",
"@types/react-dom": "19.0.2", "@types/react-dom": "19.0.2",
"@types/semver": "7.5.8", "@types/semver": "7.5.8",
"@types/swagger-ui-express": "^4.1.8",
"@types/ws": "8.5.13", "@types/ws": "8.5.13",
"@types/yargs": "17.0.33", "@types/yargs": "17.0.33",
"drizzle-kit": "0.30.1", "drizzle-kit": "0.30.6",
"esbuild": "0.24.2", "esbuild": "0.25.2",
"esbuild-node-externals": "1.16.0", "esbuild-node-externals": "1.18.0",
"postcss": "^8", "postcss": "^8",
"react-email": "3.0.4", "react-email": "4.0.6",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tsc-alias": "1.8.10", "tsc-alias": "1.8.10",
"tsx": "4.19.2", "tsx": "4.19.3",
"typescript": "^5", "typescript": "^5",
"yargs": "17.7.2" "yargs": "17.7.2"
}, },

6
server/extendZod.ts Normal file
View file

@ -0,0 +1,6 @@
import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";
import { z } from "zod";
extendZodWithOpenApi(z);
export default function extendZod() {}

View file

@ -1,3 +1,5 @@
import "./extendZod.ts";
import { runSetupFunctions } from "./setup"; import { runSetupFunctions } from "./setup";
import { createApiServer } from "./apiServer"; import { createApiServer } from "./apiServer";
import { createNextServer } from "./nextServer"; import { createNextServer } from "./nextServer";

14
server/openApi.ts Normal file
View file

@ -0,0 +1,14 @@
import { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi";
export const registry = new OpenAPIRegistry();
export enum OpenAPITags {
Site = "Site",
Org = "Organization",
Resource = "Resource",
Role = "Role",
User = "User",
Target = "Target",
Rule = "Rule",
AccessToken = "Access Token"
}

View file

@ -8,6 +8,7 @@ import { fromError } from "zod-validation-error";
import { resourceAccessToken } from "@server/db/schemas"; import { resourceAccessToken } from "@server/db/schemas";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import db from "@server/db"; import db from "@server/db";
import { OpenAPITags, registry } from "@server/openApi";
const deleteAccessTokenParamsSchema = z const deleteAccessTokenParamsSchema = z
.object({ .object({
@ -15,6 +16,17 @@ const deleteAccessTokenParamsSchema = z
}) })
.strict(); .strict();
registry.registerPath({
method: "delete",
path: "/access-token/{accessTokenId}",
description: "Delete a access token.",
tags: [OpenAPITags.AccessToken],
request: {
params: deleteAccessTokenParamsSchema
},
responses: {}
});
export async function deleteAccessToken( export async function deleteAccessToken(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -22,6 +22,7 @@ import { createDate, TimeSpan } from "oslo";
import { hashPassword } from "@server/auth/password"; import { hashPassword } from "@server/auth/password";
import { encodeHexLowerCase } from "@oslojs/encoding"; import { encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2"; import { sha256 } from "@oslojs/crypto/sha2";
import { OpenAPITags, registry } from "@server/openApi";
export const generateAccessTokenBodySchema = z export const generateAccessTokenBodySchema = z
.object({ .object({
@ -45,6 +46,24 @@ export type GenerateAccessTokenResponse = Omit<
"tokenHash" "tokenHash"
> & { accessToken: string }; > & { accessToken: string };
registry.registerPath({
method: "post",
path: "/resource/{resourceId}/access-token",
description: "Generate a new access token for a resource.",
tags: [OpenAPITags.Resource, OpenAPITags.AccessToken],
request: {
params: generateAccssTokenParamsSchema,
body: {
content: {
"application/json": {
schema: generateAccessTokenBodySchema
}
}
}
},
responses: {}
});
export async function generateAccessToken( export async function generateAccessToken(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -15,6 +15,7 @@ import { sql, eq, or, inArray, and, count, isNull, lt, gt } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import stoi from "@server/lib/stoi"; import stoi from "@server/lib/stoi";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const listAccessTokensParamsSchema = z const listAccessTokensParamsSchema = z
.object({ .object({
@ -73,10 +74,7 @@ function queryAccessTokens(
resources, resources,
eq(resourceAccessToken.resourceId, resources.resourceId) eq(resourceAccessToken.resourceId, resources.resourceId)
) )
.leftJoin( .leftJoin(sites, eq(resources.resourceId, sites.siteId))
sites,
eq(resources.resourceId, sites.siteId)
)
.where( .where(
and( and(
inArray( inArray(
@ -98,10 +96,7 @@ function queryAccessTokens(
resources, resources,
eq(resourceAccessToken.resourceId, resources.resourceId) eq(resourceAccessToken.resourceId, resources.resourceId)
) )
.leftJoin( .leftJoin(sites, eq(resources.resourceId, sites.siteId))
sites,
eq(resources.resourceId, sites.siteId)
)
.where( .where(
and( and(
inArray( inArray(
@ -123,6 +118,34 @@ export type ListAccessTokensResponse = {
pagination: { total: number; limit: number; offset: number }; pagination: { total: number; limit: number; offset: number };
}; };
registry.registerPath({
method: "get",
path: "/org/{orgId}/access-tokens",
description: "List all access tokens in an organization.",
tags: [OpenAPITags.Org, OpenAPITags.AccessToken],
request: {
params: z.object({
orgId: z.string()
}),
query: listAccessTokensSchema
},
responses: {}
});
registry.registerPath({
method: "get",
path: "/resource/{resourceId}/access-tokens",
description: "List all access tokens in an organization.",
tags: [OpenAPITags.Resource, OpenAPITags.AccessToken],
request: {
params: z.object({
resourceId: z.number()
}),
query: listAccessTokensSchema
},
responses: {}
});
export async function listAccessTokens( export async function listAccessTokens(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -8,6 +8,7 @@ import createHttpError from "http-errors";
import { eq, sql } from "drizzle-orm"; import { eq, sql } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const listDomainsParamsSchema = z const listDomainsParamsSchema = z
.object({ .object({
@ -51,6 +52,20 @@ export type ListDomainsResponse = {
pagination: { total: number; limit: number; offset: number }; pagination: { total: number; limit: number; offset: number };
}; };
registry.registerPath({
method: "get",
path: "/org/{orgId}/domains",
description: "List all domains for a organization.",
tags: [OpenAPITags.Org],
request: {
params: z.object({
orgId: z.string()
}),
query: listDomainsSchema
},
responses: {}
});
export async function listDomains( export async function listDomains(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -19,6 +19,7 @@ import { createAdminRole } from "@server/setup/ensureActions";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { defaultRoleAllowedActions } from "../role"; import { defaultRoleAllowedActions } from "../role";
import { OpenAPITags, registry } from "@server/openApi";
const createOrgSchema = z const createOrgSchema = z
.object({ .object({
@ -29,6 +30,23 @@ const createOrgSchema = z
// const MAX_ORGS = 5; // const MAX_ORGS = 5;
registry.registerPath({
method: "put",
path: "/org",
description: "Create a new organization",
tags: [OpenAPITags.Org],
request: {
body: {
content: {
"application/json": {
schema: createOrgSchema
}
}
}
},
responses: {}
});
export async function createOrg( export async function createOrg(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -17,6 +17,7 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { sendToClient } from "../ws"; import { sendToClient } from "../ws";
import { deletePeer } from "../gerbil/peers"; import { deletePeer } from "../gerbil/peers";
import { OpenAPITags, registry } from "@server/openApi";
const deleteOrgSchema = z const deleteOrgSchema = z
.object({ .object({
@ -26,6 +27,17 @@ const deleteOrgSchema = z
export type DeleteOrgResponse = {}; export type DeleteOrgResponse = {};
registry.registerPath({
method: "delete",
path: "/org/{orgId}",
description: "Delete an organization",
tags: [OpenAPITags.Org],
request: {
params: deleteOrgSchema
},
responses: {}
});
export async function deleteOrg( export async function deleteOrg(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -8,6 +8,7 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const getOrgSchema = z const getOrgSchema = z
.object({ .object({
@ -19,6 +20,17 @@ export type GetOrgResponse = {
org: Org; org: Org;
}; };
registry.registerPath({
method: "get",
path: "/org/{orgId}",
description: "Get an organization",
tags: [OpenAPITags.Org],
request: {
params: getOrgSchema
},
responses: {}
});
export async function getOrg( export async function getOrg(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -8,6 +8,7 @@ import createHttpError from "http-errors";
import { sql, inArray } from "drizzle-orm"; import { sql, inArray } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const listOrgsSchema = z.object({ const listOrgsSchema = z.object({
limit: z limit: z
@ -21,7 +22,18 @@ const listOrgsSchema = z.object({
.optional() .optional()
.default("0") .default("0")
.transform(Number) .transform(Number)
.pipe(z.number().int().nonnegative()), .pipe(z.number().int().nonnegative())
});
registry.registerPath({
method: "get",
path: "/orgs",
description: "List all organizations in the system",
tags: [OpenAPITags.Org],
request: {
query: listOrgsSchema
},
responses: {}
}); });
export type ListOrgsResponse = { export type ListOrgsResponse = {
@ -57,13 +69,13 @@ export async function listOrgs(
pagination: { pagination: {
total: 0, total: 0,
limit, limit,
offset, offset
}, }
}, },
success: true, success: true,
error: false, error: false,
message: "No organizations found for the user", message: "No organizations found for the user",
status: HttpCode.OK, status: HttpCode.OK
}); });
} }
@ -86,13 +98,13 @@ export async function listOrgs(
pagination: { pagination: {
total: totalCount, total: totalCount,
limit, limit,
offset, offset
}, }
}, },
success: true, success: true,
error: false, error: false,
message: "Organizations retrieved successfully", message: "Organizations retrieved successfully",
status: HttpCode.OK, status: HttpCode.OK
}); });
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);

View file

@ -8,6 +8,7 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const updateOrgParamsSchema = z const updateOrgParamsSchema = z
.object({ .object({
@ -17,7 +18,7 @@ const updateOrgParamsSchema = z
const updateOrgBodySchema = z const updateOrgBodySchema = z
.object({ .object({
name: z.string().min(1).max(255).optional(), name: z.string().min(1).max(255).optional()
// domain: z.string().min(1).max(255).optional(), // domain: z.string().min(1).max(255).optional(),
}) })
.strict() .strict()
@ -25,6 +26,24 @@ const updateOrgBodySchema = z
message: "At least one field must be provided for update" message: "At least one field must be provided for update"
}); });
registry.registerPath({
method: "post",
path: "/org/{orgId}",
description: "Update an organization",
tags: [OpenAPITags.Org],
request: {
params: updateOrgParamsSchema,
body: {
content: {
"application/json": {
schema: updateOrgBodySchema
}
}
}
},
responses: {}
});
export async function updateOrg( export async function updateOrg(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -20,6 +20,7 @@ import { fromError } from "zod-validation-error";
import logger from "@server/logger"; import logger from "@server/logger";
import { subdomainSchema } from "@server/lib/schemas"; import { subdomainSchema } from "@server/lib/schemas";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { OpenAPITags, registry } from "@server/openApi";
const createResourceParamsSchema = z const createResourceParamsSchema = z
.object({ .object({
@ -90,6 +91,26 @@ const createRawResourceSchema = z
export type CreateResourceResponse = Resource; export type CreateResourceResponse = Resource;
registry.registerPath({
method: "put",
path: "/org/{orgId}/site/{siteId}/resource",
description: "Create a resource.",
tags: [OpenAPITags.Org, OpenAPITags.Resource],
request: {
params: createResourceParamsSchema,
body: {
content: {
"application/json": {
schema: createHttpResourceSchema.or(
createRawResourceSchema
)
}
}
}
},
responses: {}
});
export async function createResource( export async function createResource(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -13,6 +13,7 @@ import {
isValidIP, isValidIP,
isValidUrlGlobPattern isValidUrlGlobPattern
} from "@server/lib/validators"; } from "@server/lib/validators";
import { OpenAPITags, registry } from "@server/openApi";
const createResourceRuleSchema = z const createResourceRuleSchema = z
.object({ .object({
@ -33,6 +34,24 @@ const createResourceRuleParamsSchema = z
}) })
.strict(); .strict();
registry.registerPath({
method: "put",
path: "/resource/{resourceId}/rule",
description: "Create a resource rule.",
tags: [OpenAPITags.Resource, OpenAPITags.Rule],
request: {
params: createResourceRuleParamsSchema,
body: {
content: {
"application/json": {
schema: createResourceRuleSchema
}
}
}
},
responses: {}
});
export async function createResourceRule( export async function createResourceRule(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -11,6 +11,7 @@ import { fromError } from "zod-validation-error";
import { addPeer } from "../gerbil/peers"; import { addPeer } from "../gerbil/peers";
import { removeTargets } from "../newt/targets"; import { removeTargets } from "../newt/targets";
import { getAllowedIps } from "../target/helpers"; import { getAllowedIps } from "../target/helpers";
import { OpenAPITags, registry } from "@server/openApi";
// Define Zod schema for request parameters validation // Define Zod schema for request parameters validation
const deleteResourceSchema = z const deleteResourceSchema = z
@ -22,6 +23,17 @@ const deleteResourceSchema = z
}) })
.strict(); .strict();
registry.registerPath({
method: "delete",
path: "/resource/{resourceId}",
description: "Delete a resource.",
tags: [OpenAPITags.Resource],
request: {
params: deleteResourceSchema
},
responses: {}
});
export async function deleteResource( export async function deleteResource(
req: Request, req: Request,
res: Response, res: Response,
@ -88,7 +100,11 @@ export async function deleteResource(
.where(eq(newts.siteId, site.siteId)) .where(eq(newts.siteId, site.siteId))
.limit(1); .limit(1);
removeTargets(newt.newtId, targetsToBeRemoved, deletedResource.protocol); removeTargets(
newt.newtId,
targetsToBeRemoved,
deletedResource.protocol
);
} }
} }

View file

@ -8,13 +8,11 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const deleteResourceRuleSchema = z const deleteResourceRuleSchema = z
.object({ .object({
ruleId: z ruleId: z.string().transform(Number).pipe(z.number().int().positive()),
.string()
.transform(Number)
.pipe(z.number().int().positive()),
resourceId: z resourceId: z
.string() .string()
.transform(Number) .transform(Number)
@ -22,6 +20,17 @@ const deleteResourceRuleSchema = z
}) })
.strict(); .strict();
registry.registerPath({
method: "delete",
path: "/resource/{resourceId}/rule/{ruleId}",
description: "Delete a resource rule.",
tags: [OpenAPITags.Resource, OpenAPITags.Rule],
request: {
params: deleteResourceRuleSchema
},
responses: {}
});
export async function deleteResourceRule( export async function deleteResourceRule(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -8,6 +8,7 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import logger from "@server/logger"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
const getResourceSchema = z const getResourceSchema = z
.object({ .object({
@ -22,6 +23,17 @@ export type GetResourceResponse = Resource & {
siteName: string; siteName: string;
}; };
registry.registerPath({
method: "get",
path: "/resource/{resourceId}",
description: "Get a resource.",
tags: [OpenAPITags.Resource],
request: {
params: getResourceSchema
},
responses: {}
});
export async function getResource( export async function getResource(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -8,6 +8,7 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const getResourceWhitelistSchema = z const getResourceWhitelistSchema = z
.object({ .object({
@ -31,6 +32,17 @@ export type GetResourceWhitelistResponse = {
whitelist: NonNullable<Awaited<ReturnType<typeof queryWhitelist>>>; whitelist: NonNullable<Awaited<ReturnType<typeof queryWhitelist>>>;
}; };
registry.registerPath({
method: "get",
path: "/resource/{resourceId}/whitelist",
description: "Get the whitelist of emails for a specific resource.",
tags: [OpenAPITags.Resource],
request: {
params: getResourceWhitelistSchema
},
responses: {}
});
export async function getResourceWhitelist( export async function getResourceWhitelist(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -8,6 +8,7 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const listResourceRolesSchema = z const listResourceRolesSchema = z
.object({ .object({
@ -35,6 +36,17 @@ export type ListResourceRolesResponse = {
roles: NonNullable<Awaited<ReturnType<typeof query>>>; roles: NonNullable<Awaited<ReturnType<typeof query>>>;
}; };
registry.registerPath({
method: "get",
path: "/resource/{resourceId}/roles",
description: "List all roles for a resource.",
tags: [OpenAPITags.Resource, OpenAPITags.Role],
request: {
params: listResourceRolesSchema
},
responses: {}
});
export async function listResourceRoles( export async function listResourceRoles(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -8,6 +8,7 @@ import createHttpError from "http-errors";
import { z } from "zod"; import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import logger from "@server/logger"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
const listResourceRulesParamsSchema = z const listResourceRulesParamsSchema = z
.object({ .object({
@ -56,6 +57,18 @@ export type ListResourceRulesResponse = {
pagination: { total: number; limit: number; offset: number }; pagination: { total: number; limit: number; offset: number };
}; };
registry.registerPath({
method: "get",
path: "/resource/{resourceId}/rules",
description: "List rules for a resource.",
tags: [OpenAPITags.Resource, OpenAPITags.Rule],
request: {
params: listResourceRulesParamsSchema,
query: listResourceRulesSchema
},
responses: {}
});
export async function listResourceRules( export async function listResourceRules(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -8,6 +8,7 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const listResourceUsersSchema = z const listResourceUsersSchema = z
.object({ .object({
@ -33,6 +34,17 @@ export type ListResourceUsersResponse = {
users: NonNullable<Awaited<ReturnType<typeof queryUsers>>>; users: NonNullable<Awaited<ReturnType<typeof queryUsers>>>;
}; };
registry.registerPath({
method: "get",
path: "/resource/{resourceId}/users",
description: "List all users for a resource.",
tags: [OpenAPITags.Resource, OpenAPITags.User],
request: {
params: listResourceUsersSchema
},
responses: {}
});
export async function listResourceUsers( export async function listResourceUsers(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -16,6 +16,7 @@ import { sql, eq, or, inArray, and, count } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import stoi from "@server/lib/stoi"; import stoi from "@server/lib/stoi";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const listResourcesParamsSchema = z const listResourcesParamsSchema = z
.object({ .object({
@ -128,6 +129,34 @@ export type ListResourcesResponse = {
pagination: { total: number; limit: number; offset: number }; pagination: { total: number; limit: number; offset: number };
}; };
registry.registerPath({
method: "get",
path: "/site/{siteId}/resources",
description: "List resources for a site.",
tags: [OpenAPITags.Site, OpenAPITags.Resource],
request: {
params: z.object({
siteId: z.number()
}),
query: listResourcesSchema
},
responses: {}
});
registry.registerPath({
method: "get",
path: "/org/{orgId}/resources",
description: "List resources for an organization.",
tags: [OpenAPITags.Org, OpenAPITags.Resource],
request: {
params: z.object({
orgId: z.string()
}),
query: listResourcesSchema
},
responses: {}
});
export async function listResources( export async function listResources(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -10,6 +10,7 @@ import { hash } from "@node-rs/argon2";
import { response } from "@server/lib"; import { response } from "@server/lib";
import logger from "@server/logger"; import logger from "@server/logger";
import { hashPassword } from "@server/auth/password"; import { hashPassword } from "@server/auth/password";
import { OpenAPITags, registry } from "@server/openApi";
const setResourceAuthMethodsParamsSchema = z.object({ const setResourceAuthMethodsParamsSchema = z.object({
resourceId: z.string().transform(Number).pipe(z.number().int().positive()) resourceId: z.string().transform(Number).pipe(z.number().int().positive())
@ -21,6 +22,25 @@ const setResourceAuthMethodsBodySchema = z
}) })
.strict(); .strict();
registry.registerPath({
method: "post",
path: "/resource/{resourceId}/password",
description:
"Set the password for a resource. Setting the password to null will remove it.",
tags: [OpenAPITags.Resource],
request: {
params: setResourceAuthMethodsParamsSchema,
body: {
content: {
"application/json": {
schema: setResourceAuthMethodsBodySchema
}
}
}
},
responses: {}
});
export async function setResourcePassword( export async function setResourcePassword(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -11,9 +11,10 @@ import { response } from "@server/lib";
import stoi from "@server/lib/stoi"; import stoi from "@server/lib/stoi";
import logger from "@server/logger"; import logger from "@server/logger";
import { hashPassword } from "@server/auth/password"; import { hashPassword } from "@server/auth/password";
import { OpenAPITags, registry } from "@server/openApi";
const setResourceAuthMethodsParamsSchema = z.object({ const setResourceAuthMethodsParamsSchema = z.object({
resourceId: z.string().transform(Number).pipe(z.number().int().positive()), resourceId: z.string().transform(Number).pipe(z.number().int().positive())
}); });
const setResourceAuthMethodsBodySchema = z const setResourceAuthMethodsBodySchema = z
@ -21,25 +22,44 @@ const setResourceAuthMethodsBodySchema = z
pincode: z pincode: z
.string() .string()
.regex(/^\d{6}$/) .regex(/^\d{6}$/)
.or(z.null()), .or(z.null())
}) })
.strict(); .strict();
registry.registerPath({
method: "post",
path: "/resource/{resourceId}/pincode",
description:
"Set the PIN code for a resource. Setting the PIN code to null will remove it.",
tags: [OpenAPITags.Resource],
request: {
params: setResourceAuthMethodsParamsSchema,
body: {
content: {
"application/json": {
schema: setResourceAuthMethodsBodySchema
}
}
}
},
responses: {}
});
export async function setResourcePincode( export async function setResourcePincode(
req: Request, req: Request,
res: Response, res: Response,
next: NextFunction, next: NextFunction
): Promise<any> { ): Promise<any> {
try { try {
const parsedParams = setResourceAuthMethodsParamsSchema.safeParse( const parsedParams = setResourceAuthMethodsParamsSchema.safeParse(
req.params, req.params
); );
if (!parsedParams.success) { if (!parsedParams.success) {
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString(), fromError(parsedParams.error).toString()
), )
); );
} }
@ -48,8 +68,8 @@ export async function setResourcePincode(
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString(), fromError(parsedBody.error).toString()
), )
); );
} }
@ -75,15 +95,12 @@ export async function setResourcePincode(
success: true, success: true,
error: false, error: false,
message: "Resource PIN code set successfully", message: "Resource PIN code set successfully",
status: HttpCode.CREATED, status: HttpCode.CREATED
}); });
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
return next( return next(
createHttpError( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred",
),
); );
} }
} }

View file

@ -8,6 +8,7 @@ import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { eq, and, ne } from "drizzle-orm"; import { eq, and, ne } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
const setResourceRolesBodySchema = z const setResourceRolesBodySchema = z
.object({ .object({
@ -24,6 +25,25 @@ const setResourceRolesParamsSchema = z
}) })
.strict(); .strict();
registry.registerPath({
method: "post",
path: "/resource/{resourceId}/roles",
description:
"Set roles for a resource. This will replace all existing roles.",
tags: [OpenAPITags.Resource, OpenAPITags.Role],
request: {
params: setResourceRolesParamsSchema,
body: {
content: {
"application/json": {
schema: setResourceRolesBodySchema
}
}
}
},
responses: {}
});
export async function setResourceRoles( export async function setResourceRoles(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -8,6 +8,7 @@ import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
const setUserResourcesBodySchema = z const setUserResourcesBodySchema = z
.object({ .object({
@ -24,6 +25,25 @@ const setUserResourcesParamsSchema = z
}) })
.strict(); .strict();
registry.registerPath({
method: "post",
path: "/resource/{resourceId}/users",
description:
"Set users for a resource. This will replace all existing users.",
tags: [OpenAPITags.Resource, OpenAPITags.User],
request: {
params: setUserResourcesParamsSchema,
body: {
content: {
"application/json": {
schema: setUserResourcesBodySchema
}
}
}
},
responses: {}
});
export async function setResourceUsers( export async function setResourceUsers(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -8,6 +8,7 @@ import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
const setResourceWhitelistBodySchema = z const setResourceWhitelistBodySchema = z
.object({ .object({
@ -37,6 +38,25 @@ const setResourceWhitelistParamsSchema = z
}) })
.strict(); .strict();
registry.registerPath({
method: "post",
path: "/resource/{resourceId}/whitelist",
description:
"Set email whitelist for a resource. This will replace all existing emails.",
tags: [OpenAPITags.Resource],
request: {
params: setResourceWhitelistParamsSchema,
body: {
content: {
"application/json": {
schema: setResourceWhitelistBodySchema
}
}
}
},
responses: {}
});
export async function setResourceWhitelist( export async function setResourceWhitelist(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -11,6 +11,7 @@ import { fromError } from "zod-validation-error";
import { addPeer } from "../gerbil/peers"; import { addPeer } from "../gerbil/peers";
import { addTargets, removeTargets } from "../newt/targets"; import { addTargets, removeTargets } from "../newt/targets";
import { getAllowedIps } from "../target/helpers"; import { getAllowedIps } from "../target/helpers";
import { OpenAPITags, registry } from "@server/openApi";
const transferResourceParamsSchema = z const transferResourceParamsSchema = z
.object({ .object({
@ -27,6 +28,25 @@ const transferResourceBodySchema = z
}) })
.strict(); .strict();
registry.registerPath({
method: "post",
path: "/resource/{resourceId}/transfer",
description:
"Transfer a resource to a different site. This will also transfer the targets associated with the resource.",
tags: [OpenAPITags.Resource],
request: {
params: transferResourceParamsSchema,
body: {
content: {
"application/json": {
schema: transferResourceBodySchema
}
}
}
},
responses: {}
});
export async function transferResource( export async function transferResource(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -17,6 +17,8 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { subdomainSchema } from "@server/lib/schemas"; import { subdomainSchema } from "@server/lib/schemas";
import { registry } from "@server/openApi";
import { OpenAPITags } from "@server/openApi";
const updateResourceParamsSchema = z const updateResourceParamsSchema = z
.object({ .object({
@ -93,6 +95,26 @@ const updateRawResourceBodySchema = z
{ message: "Cannot update proxyPort" } { message: "Cannot update proxyPort" }
); );
registry.registerPath({
method: "post",
path: "/resource/{resourceId}",
description: "Update a resource.",
tags: [OpenAPITags.Resource],
request: {
params: updateResourceParamsSchema,
body: {
content: {
"application/json": {
schema: updateHttpResourceBodySchema.and(
updateRawResourceBodySchema
)
}
}
}
},
responses: {}
});
export async function updateResource( export async function updateResource(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -13,6 +13,7 @@ import {
isValidIP, isValidIP,
isValidUrlGlobPattern isValidUrlGlobPattern
} from "@server/lib/validators"; } from "@server/lib/validators";
import { OpenAPITags, registry } from "@server/openApi";
// Define Zod schema for request parameters validation // Define Zod schema for request parameters validation
const updateResourceRuleParamsSchema = z const updateResourceRuleParamsSchema = z
@ -39,6 +40,24 @@ const updateResourceRuleSchema = z
message: "At least one field must be provided for update" message: "At least one field must be provided for update"
}); });
registry.registerPath({
method: "post",
path: "/resource/{resourceId}/rule/{ruleId}",
description: "Update a resource rule.",
tags: [OpenAPITags.Resource, OpenAPITags.Rule],
request: {
params: updateResourceRuleParamsSchema,
body: {
content: {
"application/json": {
schema: updateResourceRuleSchema
}
}
}
},
responses: {}
});
export async function updateResourceRule( export async function updateResourceRule(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -9,6 +9,7 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { ActionsEnum } from "@server/auth/actions"; import { ActionsEnum } from "@server/auth/actions";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
const createRoleParamsSchema = z const createRoleParamsSchema = z
.object({ .object({
@ -33,6 +34,24 @@ export type CreateRoleBody = z.infer<typeof createRoleSchema>;
export type CreateRoleResponse = Role; export type CreateRoleResponse = Role;
registry.registerPath({
method: "put",
path: "/org/{orgId}/role",
description: "Create a role.",
tags: [OpenAPITags.Org, OpenAPITags.Role],
request: {
params: createRoleParamsSchema,
body: {
content: {
"application/json": {
schema: createRoleSchema
}
}
}
},
responses: {}
});
export async function createRole( export async function createRole(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -8,6 +8,7 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const deleteRoleSchema = z const deleteRoleSchema = z
.object({ .object({
@ -21,6 +22,24 @@ const deelteRoleBodySchema = z
}) })
.strict(); .strict();
registry.registerPath({
method: "delete",
path: "/role/{roleId}",
description: "Delete a role.",
tags: [OpenAPITags.Role],
request: {
params: deleteRoleSchema,
body: {
content: {
"application/json": {
schema: deelteRoleBodySchema
}
}
}
},
responses: {}
});
export async function deleteRole( export async function deleteRole(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -9,6 +9,7 @@ import { sql, eq } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import stoi from "@server/lib/stoi"; import stoi from "@server/lib/stoi";
import { OpenAPITags, registry } from "@server/openApi";
const listRolesParamsSchema = z const listRolesParamsSchema = z
.object({ .object({
@ -57,6 +58,18 @@ export type ListRolesResponse = {
}; };
}; };
registry.registerPath({
method: "get",
path: "/orgs/{orgId}/roles",
description: "List roles.",
tags: [OpenAPITags.Org, OpenAPITags.Role],
request: {
params: listRolesParamsSchema,
query: listRolesSchema
},
responses: {}
});
export async function listRoles( export async function listRoles(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -13,6 +13,7 @@ import { fromError } from "zod-validation-error";
import { hash } from "@node-rs/argon2"; import { hash } from "@node-rs/argon2";
import { newts } from "@server/db/schemas"; import { newts } from "@server/db/schemas";
import moment from "moment"; import moment from "moment";
import { OpenAPITags, registry } from "@server/openApi";
import { hashPassword } from "@server/auth/password"; import { hashPassword } from "@server/auth/password";
const createSiteParamsSchema = z const createSiteParamsSchema = z
@ -35,7 +36,7 @@ const createSiteSchema = z
subnet: z.string().optional(), subnet: z.string().optional(),
newtId: z.string().optional(), newtId: z.string().optional(),
secret: z.string().optional(), secret: z.string().optional(),
type: z.string() type: z.enum(["newt", "wireguard", "local"])
}) })
.strict(); .strict();
@ -43,6 +44,24 @@ export type CreateSiteBody = z.infer<typeof createSiteSchema>;
export type CreateSiteResponse = Site; export type CreateSiteResponse = Site;
registry.registerPath({
method: "put",
path: "/org/{orgId}/site",
description: "Create a new site.",
tags: [OpenAPITags.Site, OpenAPITags.Org],
request: {
params: createSiteParamsSchema,
body: {
content: {
"application/json": {
schema: createSiteSchema
}
}
}
},
responses: {}
});
export async function createSite( export async function createSite(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -10,6 +10,7 @@ import logger from "@server/logger";
import { deletePeer } from "../gerbil/peers"; import { deletePeer } from "../gerbil/peers";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { sendToClient } from "../ws"; import { sendToClient } from "../ws";
import { OpenAPITags, registry } from "@server/openApi";
const deleteSiteSchema = z const deleteSiteSchema = z
.object({ .object({
@ -17,6 +18,17 @@ const deleteSiteSchema = z
}) })
.strict(); .strict();
registry.registerPath({
method: "delete",
path: "/site/{siteId}",
description: "Delete a site and all its associated data.",
tags: [OpenAPITags.Site],
request: {
params: deleteSiteSchema
},
responses: {}
});
export async function deleteSite( export async function deleteSite(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -9,6 +9,7 @@ import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import stoi from "@server/lib/stoi"; import stoi from "@server/lib/stoi";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const getSiteSchema = z const getSiteSchema = z
.object({ .object({
@ -43,6 +44,34 @@ async function query(siteId?: number, niceId?: string, orgId?: string) {
export type GetSiteResponse = NonNullable<Awaited<ReturnType<typeof query>>>; export type GetSiteResponse = NonNullable<Awaited<ReturnType<typeof query>>>;
registry.registerPath({
method: "get",
path: "/org/{orgId}/site/{niceId}",
description:
"Get a site by orgId and niceId. NiceId is a readable ID for the site and unique on a per org basis.",
tags: [OpenAPITags.Org, OpenAPITags.Site],
request: {
params: z.object({
orgId: z.string(),
niceId: z.string()
})
},
responses: {}
});
registry.registerPath({
method: "get",
path: "/site/{siteId}",
description: "Get a site by siteId.",
tags: [OpenAPITags.Site],
request: {
params: z.object({
siteId: z.number()
})
},
responses: {}
});
export async function getSite( export async function getSite(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -8,6 +8,7 @@ import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { z } from "zod"; import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const listSitesParamsSchema = z const listSitesParamsSchema = z
.object({ .object({
@ -59,6 +60,18 @@ export type ListSitesResponse = {
pagination: { total: number; limit: number; offset: number }; pagination: { total: number; limit: number; offset: number };
}; };
registry.registerPath({
method: "get",
path: "/org/{orgId}/sites",
description: "List all sites in an organization",
tags: [OpenAPITags.Org, OpenAPITags.Site],
request: {
params: listSitesParamsSchema,
query: listSitesSchema
},
responses: {}
});
export async function listSites( export async function listSites(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -9,6 +9,8 @@ import logger from "@server/logger";
import { findNextAvailableCidr } from "@server/lib/ip"; import { findNextAvailableCidr } from "@server/lib/ip";
import { generateId } from "@server/auth/sessions/app"; import { generateId } from "@server/auth/sessions/app";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { OpenAPITags, registry } from "@server/openApi";
import { z } from "zod";
export type PickSiteDefaultsResponse = { export type PickSiteDefaultsResponse = {
exitNodeId: number; exitNodeId: number;
@ -22,6 +24,20 @@ export type PickSiteDefaultsResponse = {
newtSecret: string; newtSecret: string;
}; };
registry.registerPath({
method: "get",
path: "/org/{orgId}/pick-site-defaults",
description:
"Return pre-requisite data for creating a site, such as the exit node, subnet, Newt credentials, etc.",
tags: [OpenAPITags.Org, OpenAPITags.Site],
request: {
params: z.object({
orgId: z.string()
})
},
responses: {}
});
export async function pickSiteDefaults( export async function pickSiteDefaults(
req: Request, req: Request,
res: Response, res: Response,
@ -45,7 +61,7 @@ export async function pickSiteDefaults(
// list all of the sites on that exit node // list all of the sites on that exit node
const sitesQuery = await db const sitesQuery = await db
.select({ .select({
subnet: sites.subnet, subnet: sites.subnet
}) })
.from(sites) .from(sites)
.where(eq(sites.exitNodeId, exitNode.exitNodeId)); .where(eq(sites.exitNodeId, exitNode.exitNodeId));
@ -53,8 +69,17 @@ export async function pickSiteDefaults(
// TODO: we need to lock this subnet for some time so someone else does not take it // TODO: we need to lock this subnet for some time so someone else does not take it
let subnets = sitesQuery.map((site) => site.subnet); let subnets = sitesQuery.map((site) => site.subnet);
// exclude the exit node address by replacing after the / with a site block size // exclude the exit node address by replacing after the / with a site block size
subnets.push(exitNode.address.replace(/\/\d+$/, `/${config.getRawConfig().gerbil.site_block_size}`)); subnets.push(
const newSubnet = findNextAvailableCidr(subnets, config.getRawConfig().gerbil.site_block_size, exitNode.address); exitNode.address.replace(
/\/\d+$/,
`/${config.getRawConfig().gerbil.site_block_size}`
)
);
const newSubnet = findNextAvailableCidr(
subnets,
config.getRawConfig().gerbil.site_block_size,
exitNode.address
);
if (!newSubnet) { if (!newSubnet) {
return next( return next(
createHttpError( createHttpError(
@ -77,12 +102,12 @@ export async function pickSiteDefaults(
endpoint: exitNode.endpoint, endpoint: exitNode.endpoint,
subnet: newSubnet, subnet: newSubnet,
newtId, newtId,
newtSecret: secret, newtSecret: secret
}, },
success: true, success: true,
error: false, error: false,
message: "Organization retrieved successfully", message: "Organization retrieved successfully",
status: HttpCode.OK, status: HttpCode.OK
}); });
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);

View file

@ -8,6 +8,7 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const updateSiteParamsSchema = z const updateSiteParamsSchema = z
.object({ .object({
@ -35,6 +36,25 @@ const updateSiteBodySchema = z
message: "At least one field must be provided for update" message: "At least one field must be provided for update"
}); });
registry.registerPath({
method: "post",
path: "/site/{siteId}",
description:
"Update a site.",
tags: [OpenAPITags.Site],
request: {
params: updateSiteParamsSchema,
body: {
content: {
"application/json": {
schema: updateSiteBodySchema
}
}
}
},
responses: {}
});
export async function updateSite( export async function updateSite(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -13,6 +13,7 @@ import { addTargets } from "../newt/targets";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { pickPort } from "./helpers"; import { pickPort } from "./helpers";
import { isTargetValid } from "@server/lib/validators"; import { isTargetValid } from "@server/lib/validators";
import { OpenAPITags, registry } from "@server/openApi";
const createTargetParamsSchema = z const createTargetParamsSchema = z
.object({ .object({
@ -34,6 +35,24 @@ const createTargetSchema = z
export type CreateTargetResponse = Target; export type CreateTargetResponse = Target;
registry.registerPath({
method: "put",
path: "/resource/{resourceId}/target",
description: "Create a target for a resource.",
tags: [OpenAPITags.Resource, OpenAPITags.Target],
request: {
params: createTargetParamsSchema,
body: {
content: {
"application/json": {
schema: createTargetSchema
}
}
}
},
responses: {}
});
export async function createTarget( export async function createTarget(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -11,6 +11,7 @@ import { addPeer } from "../gerbil/peers";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { removeTargets } from "../newt/targets"; import { removeTargets } from "../newt/targets";
import { getAllowedIps } from "./helpers"; import { getAllowedIps } from "./helpers";
import { OpenAPITags, registry } from "@server/openApi";
const deleteTargetSchema = z const deleteTargetSchema = z
.object({ .object({
@ -18,6 +19,17 @@ const deleteTargetSchema = z
}) })
.strict(); .strict();
registry.registerPath({
method: "delete",
path: "/target/{targetId}",
description: "Delete a target.",
tags: [OpenAPITags.Target],
request: {
params: deleteTargetSchema
},
responses: {}
});
export async function deleteTarget( export async function deleteTarget(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -8,6 +8,7 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const getTargetSchema = z const getTargetSchema = z
.object({ .object({
@ -15,6 +16,17 @@ const getTargetSchema = z
}) })
.strict(); .strict();
registry.registerPath({
method: "get",
path: "/target/{targetId}",
description: "Get a target.",
tags: [OpenAPITags.Target],
request: {
params: getTargetSchema
},
responses: {}
});
export async function getTarget( export async function getTarget(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -8,6 +8,7 @@ import createHttpError from "http-errors";
import { z } from "zod"; import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import logger from "@server/logger"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
const listTargetsParamsSchema = z const listTargetsParamsSchema = z
.object({ .object({
@ -56,6 +57,18 @@ export type ListTargetsResponse = {
pagination: { total: number; limit: number; offset: number }; pagination: { total: number; limit: number; offset: number };
}; };
registry.registerPath({
method: "get",
path: "/resource/{resourceId}/targets",
description: "List targets for a resource.",
tags: [OpenAPITags.Resource, OpenAPITags.Target],
request: {
params: listTargetsParamsSchema,
query: listTargetsSchema
},
responses: {}
});
export async function listTargets( export async function listTargets(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -12,6 +12,7 @@ import { addPeer } from "../gerbil/peers";
import { addTargets } from "../newt/targets"; import { addTargets } from "../newt/targets";
import { pickPort } from "./helpers"; import { pickPort } from "./helpers";
import { isTargetValid } from "@server/lib/validators"; import { isTargetValid } from "@server/lib/validators";
import { OpenAPITags, registry } from "@server/openApi";
const updateTargetParamsSchema = z const updateTargetParamsSchema = z
.object({ .object({
@ -31,6 +32,24 @@ const updateTargetBodySchema = z
message: "At least one field must be provided for update" message: "At least one field must be provided for update"
}); });
registry.registerPath({
method: "post",
path: "/target/{targetId}",
description: "Update a target.",
tags: [OpenAPITags.Target],
request: {
params: updateTargetParamsSchema,
body: {
content: {
"application/json": {
schema: updateTargetBodySchema
}
}
}
},
responses: {}
});
export async function updateTarget( export async function updateTarget(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -9,6 +9,7 @@ import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import stoi from "@server/lib/stoi"; import stoi from "@server/lib/stoi";
import { OpenAPITags, registry } from "@server/openApi";
const addUserRoleParamsSchema = z const addUserRoleParamsSchema = z
.object({ .object({
@ -19,6 +20,17 @@ const addUserRoleParamsSchema = z
export type AddUserRoleResponse = z.infer<typeof addUserRoleParamsSchema>; export type AddUserRoleResponse = z.infer<typeof addUserRoleParamsSchema>;
registry.registerPath({
method: "post",
path: "/role/{roleId}/add/{userId}",
description: "Add a role to a user.",
tags: [OpenAPITags.Role, OpenAPITags.User],
request: {
params: addUserRoleParamsSchema
},
responses: {}
});
export async function addUserRole( export async function addUserRole(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -9,6 +9,7 @@ import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import { OpenAPITags, registry } from "@server/openApi";
async function queryUser(orgId: string, userId: string) { async function queryUser(orgId: string, userId: string) {
const [user] = await db const [user] = await db
@ -40,6 +41,17 @@ const getOrgUserParamsSchema = z
}) })
.strict(); .strict();
registry.registerPath({
method: "get",
path: "/org/{orgId}/user/{userId}",
description: "Get a user in an organization.",
tags: [OpenAPITags.Org, OpenAPITags.User],
request: {
params: getOrgUserParamsSchema
},
responses: {}
});
export async function getOrgUser( export async function getOrgUser(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -14,6 +14,7 @@ import { hashPassword } from "@server/auth/password";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { sendEmail } from "@server/emails"; import { sendEmail } from "@server/emails";
import SendInviteLink from "@server/emails/templates/SendInviteLink"; import SendInviteLink from "@server/emails/templates/SendInviteLink";
import { OpenAPITags, registry } from "@server/openApi";
const inviteUserParamsSchema = z const inviteUserParamsSchema = z
.object({ .object({
@ -42,6 +43,24 @@ export type InviteUserResponse = {
const inviteTracker: Record<string, { timestamps: number[] }> = {}; const inviteTracker: Record<string, { timestamps: number[] }> = {};
registry.registerPath({
method: "post",
path: "/org/{orgId}/create-invite",
description: "Invite a user to join an organization.",
tags: [OpenAPITags.Org],
request: {
params: inviteUserParamsSchema,
body: {
content: {
"application/json": {
schema: inviteUserBodySchema
}
}
}
},
responses: {}
});
export async function inviteUser( export async function inviteUser(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -8,6 +8,7 @@ import createHttpError from "http-errors";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const listUsersParamsSchema = z const listUsersParamsSchema = z
.object({ .object({
@ -57,6 +58,18 @@ export type ListUsersResponse = {
pagination: { total: number; limit: number; offset: number }; pagination: { total: number; limit: number; offset: number };
}; };
registry.registerPath({
method: "get",
path: "/org/{orgId}/users",
description: "List users in an organization.",
tags: [OpenAPITags.Org, OpenAPITags.User],
request: {
params: listUsersParamsSchema,
query: listUsersSchema
},
responses: {}
});
export async function listUsers( export async function listUsers(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -8,6 +8,7 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const removeUserSchema = z const removeUserSchema = z
.object({ .object({
@ -16,6 +17,17 @@ const removeUserSchema = z
}) })
.strict(); .strict();
registry.registerPath({
method: "delete",
path: "/org/{orgId}/user/{userId}",
description: "Remove a user from an organization.",
tags: [OpenAPITags.Org, OpenAPITags.User],
request: {
params: removeUserSchema
},
responses: {}
});
export async function removeUserOrg( export async function removeUserOrg(
req: Request, req: Request,
res: Response, res: Response,

View file

@ -53,13 +53,11 @@ const createSiteFormSchema = z
.object({ .object({
name: z name: z
.string() .string()
.min(2, { .min(2, { message: "Name must be at least 2 characters." })
message: "Name must be at least 2 characters."
})
.max(30, { .max(30, {
message: "Name must not be longer than 30 characters." message: "Name must not be longer than 30 characters."
}), }),
method: z.string(), method: z.enum(["newt", "wireguard", "local"]),
copied: z.boolean() copied: z.boolean()
}) })
.refine( .refine(
@ -77,6 +75,15 @@ const createSiteFormSchema = z
type CreateSiteFormValues = z.infer<typeof createSiteFormSchema>; type CreateSiteFormValues = z.infer<typeof createSiteFormSchema>;
type SiteType = "newt" | "wireguard" | "local";
interface TunnelTypeOption {
id: SiteType;
title: string;
description: string;
disabled?: boolean;
}
type Commands = { type Commands = {
mac: Record<string, string[]>; mac: Record<string, string[]>;
linux: Record<string, string[]>; linux: Record<string, string[]>;
@ -102,7 +109,9 @@ export default function Page() {
const { orgId } = useParams(); const { orgId } = useParams();
const router = useRouter(); const router = useRouter();
const [tunnelTypes, setTunnelTypes] = useState<any>([ const [tunnelTypes, setTunnelTypes] = useState<
ReadonlyArray<TunnelTypeOption>
>([
{ {
id: "newt", id: "newt",
title: "Newt Tunnel (Recommended)", title: "Newt Tunnel (Recommended)",
@ -342,22 +351,15 @@ WantedBy=default.target`
} }
}; };
const form = useForm({ const form = useForm<CreateSiteFormValues>({
resolver: zodResolver(createSiteFormSchema), resolver: zodResolver(createSiteFormSchema),
defaultValues: { defaultValues: { name: "", copied: false, method: "newt" }
name: "",
copied: false,
method: "newt"
}
}); });
async function onSubmit(data: CreateSiteFormValues) { async function onSubmit(data: CreateSiteFormValues) {
setCreateLoading(true); setCreateLoading(true);
let payload: CreateSiteBody = { let payload: CreateSiteBody = { name: data.name, type: data.method };
name: data.name,
type: data.method
};
if (data.method == "wireguard") { if (data.method == "wireguard") {
if (!siteDefaults || !wgConfig) { if (!siteDefaults || !wgConfig) {
@ -486,10 +488,7 @@ WantedBy=default.target`
setTunnelTypes((prev: any) => { setTunnelTypes((prev: any) => {
return prev.map((item: any) => { return prev.map((item: any) => {
return { return { ...item, disabled: false };
...item,
disabled: false
};
}); });
}); });
} }
@ -564,9 +563,8 @@ WantedBy=default.target`
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
<FormDescription> <FormDescription>
This is the This is the display
display name for the name for the site.
site.
</FormDescription> </FormDescription>
</FormItem> </FormItem>
)} )}
@ -590,12 +588,10 @@ WantedBy=default.target`
<SettingsSectionBody> <SettingsSectionBody>
<StrategySelect <StrategySelect
options={tunnelTypes} options={tunnelTypes}
defaultValue={ defaultValue={form.getValues("method")}
form.getValues("method") as string onChange={(value) => {
} form.setValue("method", value);
onChange={(value) => }}
form.setValue("method", value)
}
cols={3} cols={3}
/> />
</SettingsSectionBody> </SettingsSectionBody>

View file

@ -0,0 +1,46 @@
"use client";
import React from "react";
import confetti from "canvas-confetti";
export default function SupporterMessage({ tier }: { tier: string }) {
return (
<div className="relative flex items-center space-x-2 whitespace-nowrap group">
<span
className="cursor-pointer"
onClick={(e) => {
// Get the bounding box of the element
const rect = (
e.target as HTMLElement
).getBoundingClientRect();
// Trigger confetti centered on the word "Pangolin"
confetti({
particleCount: 100,
spread: 70,
origin: {
x: (rect.left + rect.width / 2) / window.innerWidth,
y: rect.top / window.innerHeight
},
colors: ["#FFA500", "#FF4500", "#FFD700"]
});
}}
>
Pangolin
</span>
{/* SVG Star */}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 24 24"
className="w-4 h-4 text-primary"
>
<path d="M12 .587l3.668 7.431 8.2 1.192-5.934 5.782 1.4 8.168L12 18.896l-7.334 3.864 1.4-8.168L.132 9.21l8.2-1.192z" />
</svg>
{/* Popover */}
<div className="absolute left-1/2 transform -translate-x-1/2 -top-10 hidden group-hover:block bg-white/10 text-primary text-sm rounded-md shadow-lg px-4 py-2 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
Thank you for supporting Pangolin as a {tier}!
</div>
</div>
);
}

View file

@ -2,65 +2,63 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base { @layer base {
:root { :root {
--background: 0 0% 100%; --background: 0 0% 100%;
--foreground: 20 0.0% 10.0%; --foreground: 20 0% 10%;
--card: 0 0% 100%; --card: 0 0% 100%;
--card-foreground: 20 0.0% 10.0%; --card-foreground: 20 0% 10%;
--popover: 0 0% 100%; --popover: 0 0% 100%;
--popover-foreground: 20 0.0% 10.0%; --popover-foreground: 20 0% 10%;
--primary: 24.6 95% 53.1%; --primary: 24.6 95% 53.1%;
--primary-foreground: 60 9.1% 97.8%; --primary-foreground: 60 9.1% 97.8%;
--secondary: 60 4.8% 95.9%; --secondary: 60 4.8% 95.9%;
--secondary-foreground: 24 9.8% 10%; --secondary-foreground: 24 9.8% 10%;
--muted: 60 4.8% 85.0%; --muted: 60 4.8% 85%;
--muted-foreground: 25 5.3% 44.7%; --muted-foreground: 25 5.3% 44.7%;
--accent: 60 4.8% 90%; --accent: 60 4.8% 90%;
--accent-foreground: 24 9.8% 10%; --accent-foreground: 24 9.8% 10%;
--destructive: 0 84.2% 60.2%; --destructive: 0 84.2% 60.2%;
--destructive-foreground: 60 9.1% 97.8%; --destructive-foreground: 60 9.1% 97.8%;
--border: 20 5.9% 80%; --border: 20 5.9% 80%;
--input: 20 5.9% 75%; --input: 20 5.9% 75%;
--ring: 24.6 95% 53.1%; --ring: 24.6 95% 53.1%;
--radius: 0.75rem; --radius: 0.75rem;
--chart-1: 12 76% 61%; --chart-1: 12 76% 61%;
--chart-2: 173 58% 39%; --chart-2: 173 58% 39%;
--chart-3: 197 37% 24%; --chart-3: 197 37% 24%;
--chart-4: 43 74% 66%; --chart-4: 43 74% 66%;
--chart-5: 27 87% 67%; --chart-5: 27 87% 67%;
} }
.dark { .dark {
--background: 20 0.0% 10.0%; --background: 20 0% 10%;
--foreground: 60 9.1% 97.8%; --foreground: 60 9.1% 97.8%;
--card: 20 0.0% 10.0%; --card: 20 0% 10%;
--card-foreground: 60 9.1% 97.8%; --card-foreground: 60 9.1% 97.8%;
--popover: 20 0.0% 10.0%; --popover: 20 0% 10%;
--popover-foreground: 60 9.1% 97.8%; --popover-foreground: 60 9.1% 97.8%;
--primary: 20.5 90.2% 48.2%; --primary: 20.5 90.2% 48.2%;
--primary-foreground: 60 9.1% 97.8%; --primary-foreground: 60 9.1% 97.8%;
--secondary: 12 6.5% 15.0%; --secondary: 12 6.5% 15%;
--secondary-foreground: 60 9.1% 97.8%; --secondary-foreground: 60 9.1% 97.8%;
--muted: 12 6.5% 25.0%; --muted: 12 6.5% 25%;
--muted-foreground: 24 5.4% 63.9%; --muted-foreground: 24 5.4% 63.9%;
--accent: 12 2.5% 15.0%; --accent: 12 2.5% 15%;
--accent-foreground: 60 9.1% 97.8%; --accent-foreground: 60 9.1% 97.8%;
--destructive: 0 72.2% 50.6%; --destructive: 0 72.2% 50.6%;
--destructive-foreground: 60 9.1% 97.8%; --destructive-foreground: 60 9.1% 97.8%;
--border: 12 6.5% 30.0%; --border: 12 6.5% 30%;
--input: 12 6.5% 35.0%; --input: 12 6.5% 35%;
--ring: 20.5 90.2% 48.2%; --ring: 20.5 90.2% 48.2%;
--chart-1: 220 70% 50%; --chart-1: 220 70% 50%;
--chart-2: 160 60% 45%; --chart-2: 160 60% 45%;
--chart-3: 30 80% 55%; --chart-3: 30 80% 55%;
--chart-4: 280 65% 60%; --chart-4: 280 65% 60%;
--chart-5: 340 75% 55%; --chart-5: 340 75% 55%;
} }
} }
@layer base { @layer base {
* { * {
@apply border-border; @apply border-border;
@ -70,4 +68,3 @@
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }

View file

@ -12,13 +12,14 @@ import SupportStatusProvider from "@app/providers/SupporterStatusProvider";
import { createApiClient, internal, priv } from "@app/lib/api"; import { createApiClient, internal, priv } from "@app/lib/api";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { IsSupporterKeyVisibleResponse } from "@server/routers/supporterKey"; import { IsSupporterKeyVisibleResponse } from "@server/routers/supporterKey";
import SupporterMessage from "./components/SupporterMessage";
export const metadata: Metadata = { export const metadata: Metadata = {
title: `Dashboard - Pangolin`, title: `Dashboard - Pangolin`,
description: "" description: ""
}; };
export const dynamic = 'force-dynamic'; export const dynamic = "force-dynamic";
// const font = Figtree({ subsets: ["latin"] }); // const font = Figtree({ subsets: ["latin"] });
const font = Inter({ subsets: ["latin"] }); const font = Inter({ subsets: ["latin"] });
@ -34,9 +35,9 @@ export default async function RootLayout({
visible: true visible: true
} as any; } as any;
const res = await priv.get< const res = await priv.get<AxiosResponse<IsSupporterKeyVisibleResponse>>(
AxiosResponse<IsSupporterKeyVisibleResponse> "supporter-key/visible"
>("supporter-key/visible"); );
supporterData.visible = res.data.data.visible; supporterData.visible = res.data.data.visible;
supporterData.tier = res.data.data.tier; supporterData.tier = res.data.data.tier;
@ -61,9 +62,15 @@ export default async function RootLayout({
{/* Footer */} {/* Footer */}
<footer className="hidden md:block w-full mt-12 py-3 mb-6 px-4"> <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="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"> {supporterData?.tier ? (
<span>Pangolin</span> <SupporterMessage
</div> tier={supporterData.tier}
/>
) : (
<div className="flex items-center space-x-2 whitespace-nowrap">
<span>Pangolin</span>
</div>
)}
<Separator orientation="vertical" /> <Separator orientation="vertical" />
<a <a
href="https://fossorial.io/" href="https://fossorial.io/"

View file

@ -4,38 +4,39 @@ import { cn } from "@app/lib/cn";
import { RadioGroup, RadioGroupItem } from "./ui/radio-group"; import { RadioGroup, RadioGroupItem } from "./ui/radio-group";
import { useState } from "react"; import { useState } from "react";
interface StrategyOption { interface StrategyOption<TValue extends string> {
id: string; id: TValue;
title: string; title: string;
description: string; description: string;
disabled?: boolean; // New optional property disabled?: boolean;
} }
interface StrategySelectProps { interface StrategySelectProps<TValue extends string> {
options: StrategyOption[]; options: ReadonlyArray<StrategyOption<TValue>>;
defaultValue?: string; defaultValue?: TValue;
onChange?: (value: string) => void; onChange?: (value: TValue) => void;
cols?: number; cols?: number;
} }
export function StrategySelect({ export function StrategySelect<TValue extends string>({
options, options,
defaultValue, defaultValue,
onChange, onChange,
cols cols
}: StrategySelectProps) { }: StrategySelectProps<TValue>) {
const [selected, setSelected] = useState(defaultValue); const [selected, setSelected] = useState<TValue | undefined>(defaultValue);
return ( return (
<RadioGroup <RadioGroup
defaultValue={defaultValue} defaultValue={defaultValue}
onValueChange={(value) => { onValueChange={(value: string) => {
setSelected(value); const typedValue = value as TValue;
onChange?.(value); setSelected(typedValue);
onChange?.(typedValue);
}} }}
className={`grid md:grid-cols-${cols ? cols : 1} gap-4`} className={`grid md:grid-cols-${cols ? cols : 1} gap-4`}
> >
{options.map((option) => ( {options.map((option: StrategyOption<TValue>) => (
<label <label
key={option.id} key={option.id}
htmlFor={option.id} htmlFor={option.id}

View file

@ -47,6 +47,7 @@ import {
CardTitle CardTitle
} from "./ui/card"; } from "./ui/card";
import { Check, ExternalLink } from "lucide-react"; import { Check, ExternalLink } from "lucide-react";
import confetti from "canvas-confetti";
const formSchema = z.object({ const formSchema = z.object({
githubUsername: z githubUsername: z
@ -100,6 +101,7 @@ export default function SupporterStatus() {
return; return;
} }
// Trigger the toast
toast({ toast({
variant: "default", variant: "default",
title: "Valid Key", title: "Valid Key",
@ -107,6 +109,50 @@ export default function SupporterStatus() {
"Your supporter key has been validated. Thank you for your support!" "Your supporter key has been validated. Thank you for your support!"
}); });
// Fireworks-style confetti
const duration = 5 * 1000; // 5 seconds
const animationEnd = Date.now() + duration;
const defaults = {
startVelocity: 30,
spread: 360,
ticks: 60,
zIndex: 0,
colors: ["#FFA500", "#FF4500", "#FFD700"] // Orange hues
};
function randomInRange(min: number, max: number) {
return Math.random() * (max - min) + min;
}
const interval = setInterval(() => {
const timeLeft = animationEnd - Date.now();
if (timeLeft <= 0) {
clearInterval(interval);
return;
}
const particleCount = 50 * (timeLeft / duration);
// Launch confetti from two random horizontal positions
confetti({
...defaults,
particleCount,
origin: {
x: randomInRange(0.1, 0.3),
y: Math.random() - 0.2
}
});
confetti({
...defaults,
particleCount,
origin: {
x: randomInRange(0.7, 0.9),
y: Math.random() - 0.2
}
});
}, 250);
setPurchaseOptionsOpen(false); setPurchaseOptionsOpen(false);
setKeyOpen(false); setKeyOpen(false);
@ -177,7 +223,9 @@ export default function SupporterStatus() {
</p> </p>
<div className="py-6"> <div className="py-6">
<p className="mb-3 text-center">Please select the option that best suits you.</p> <p className="mb-3 text-center">
Please select the option that best suits you.
</p>
<div className="grid md:grid-cols-2 grid-cols-1 gap-8"> <div className="grid md:grid-cols-2 grid-cols-1 gap-8">
<Card> <Card>
<CardHeader> <CardHeader>

19
src/types/canvas-confetti.d.ts vendored Normal file
View file

@ -0,0 +1,19 @@
declare module "canvas-confetti" {
export interface ConfettiOptions {
particleCount?: number;
angle?: number;
spread?: number;
startVelocity?: number;
decay?: number;
gravity?: number;
drift?: number;
ticks?: number;
origin?: { x?: number; y?: number };
colors?: string[];
shapes?: string[];
scalar?: number;
zIndex?: number;
}
export default function confetti(options?: ConfettiOptions): Promise<null>;
}

View file

@ -5,59 +5,59 @@ const config: Config = {
content: [ content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}", "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}", "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}", "./src/app/**/*.{js,ts,jsx,tsx,mdx}"
], ],
theme: { theme: {
extend: { extend: {
colors: { colors: {
background: 'hsl(var(--background))', background: "hsl(var(--background))",
foreground: 'hsl(var(--foreground))', foreground: "hsl(var(--foreground))",
card: { card: {
DEFAULT: 'hsl(var(--card))', DEFAULT: "hsl(var(--card))",
foreground: 'hsl(var(--card-foreground))' foreground: "hsl(var(--card-foreground))"
}, },
popover: { popover: {
DEFAULT: 'hsl(var(--popover))', DEFAULT: "hsl(var(--popover))",
foreground: 'hsl(var(--popover-foreground))' foreground: "hsl(var(--popover-foreground))"
}, },
primary: { primary: {
DEFAULT: 'hsl(var(--primary))', DEFAULT: "hsl(var(--primary))",
foreground: 'hsl(var(--primary-foreground))' foreground: "hsl(var(--primary-foreground))"
}, },
secondary: { secondary: {
DEFAULT: 'hsl(var(--secondary))', DEFAULT: "hsl(var(--secondary))",
foreground: 'hsl(var(--secondary-foreground))' foreground: "hsl(var(--secondary-foreground))"
}, },
muted: { muted: {
DEFAULT: 'hsl(var(--muted))', DEFAULT: "hsl(var(--muted))",
foreground: 'hsl(var(--muted-foreground))' foreground: "hsl(var(--muted-foreground))"
}, },
accent: { accent: {
DEFAULT: 'hsl(var(--accent))', DEFAULT: "hsl(var(--accent))",
foreground: 'hsl(var(--accent-foreground))' foreground: "hsl(var(--accent-foreground))"
}, },
destructive: { destructive: {
DEFAULT: 'hsl(var(--destructive))', DEFAULT: "hsl(var(--destructive))",
foreground: 'hsl(var(--destructive-foreground))' foreground: "hsl(var(--destructive-foreground))"
}, },
border: 'hsl(var(--border))', border: "hsl(var(--border))",
input: 'hsl(var(--input))', input: "hsl(var(--input))",
ring: 'hsl(var(--ring))', ring: "hsl(var(--ring))",
chart: { chart: {
'1': 'hsl(var(--chart-1))', "1": "hsl(var(--chart-1))",
'2': 'hsl(var(--chart-2))', "2": "hsl(var(--chart-2))",
'3': 'hsl(var(--chart-3))', "3": "hsl(var(--chart-3))",
'4': 'hsl(var(--chart-4))', "4": "hsl(var(--chart-4))",
'5': 'hsl(var(--chart-5))' "5": "hsl(var(--chart-5))"
} }
}, },
borderRadius: { borderRadius: {
lg: 'var(--radius)', lg: "var(--radius)",
md: 'calc(var(--radius) - 2px)', md: "calc(var(--radius) - 2px)",
sm: 'calc(var(--radius) - 4px)' sm: "calc(var(--radius) - 4px)"
} }
} }
}, },
plugins: [require("tailwindcss-animate")], plugins: [require("tailwindcss-animate")]
}; };
export default config; export default config;