Merge branch 'dev' into holepunch

This commit is contained in:
Owen 2025-03-25 20:42:14 -04:00
commit fbe7e0a427
No known key found for this signature in database
GPG key ID: 8271FDFFD9E0CCBD
45 changed files with 4617 additions and 422 deletions

View file

@ -12,7 +12,7 @@ post {
body:json {
{
"email": "owen@fossorial.io",
"email": "admin@fosrl.io",
"password": "Password123!"
}
}

View file

@ -0,0 +1,11 @@
meta {
name: adminListUsers
type: http
seq: 2
}
get {
url: http://localhost:3000/api/v1/users
body: none
auth: none
}

View file

@ -0,0 +1,11 @@
meta {
name: adminRemoveUser
type: http
seq: 3
}
delete {
url: http://localhost:3000/api/v1/user/ky5r7ivqs8wc7u4
body: none
auth: none
}

View file

@ -47,7 +47,7 @@ newt:
rate_limits:
global:
window_minutes: 1
max_requests: 100
max_requests: 500
{{if .EnableEmail}}
email:
smtp_host: "{{.EmailSMTPHost}}"

View file

@ -2,19 +2,19 @@ package main
import (
"bufio"
"bytes"
"embed"
"fmt"
"io"
"io/fs"
"os"
"time"
"os/exec"
"path/filepath"
"runtime"
"strings"
"syscall"
"bytes"
"text/template"
"time"
"unicode"
"golang.org/x/term"
@ -48,8 +48,8 @@ type Config struct {
EmailSMTPPass string
EmailNoReply string
InstallGerbil bool
TraefikBouncerKey string
DoCrowdsecInstall bool
TraefikBouncerKey string
DoCrowdsecInstall bool
}
func main() {
@ -95,33 +95,35 @@ func main() {
}
if !checkIsCrowdsecInstalledInCompose() {
fmt.Println("\n=== Crowdsec Install ===")
fmt.Println("\n=== CrowdSec Install ===")
// check if crowdsec is installed
if readBool(reader, "Would you like to install Crowdsec?", true) {
if readBool(reader, "Would you like to install CrowdSec?", false) {
fmt.Println("This installer constitutes a minimal viable CrowdSec deployment. CrowdSec will add extra complexity to your Pangolin installation and may not work to the best of its abilities out of the box. Users are expected to implement configuration adjustments on their own to achieve the best security posture. Consult the CrowdSec documentation for detailed configuration instructions.")
if readBool(reader, "Are you willing to manage CrowdSec?", false) {
if config.DashboardDomain == "" {
traefikConfig, err := ReadTraefikConfig("config/traefik/traefik_config.yml", "config/traefik/dynamic_config.yml")
if err != nil {
fmt.Printf("Error reading config: %v\n", err)
return
}
config.DashboardDomain = traefikConfig.DashboardDomain
config.LetsEncryptEmail = traefikConfig.LetsEncryptEmail
config.BadgerVersion = traefikConfig.BadgerVersion
if config.DashboardDomain == "" {
traefikConfig, err := ReadTraefikConfig("config/traefik/traefik_config.yml", "config/traefik/dynamic_config.yml")
if err != nil {
fmt.Printf("Error reading config: %v\n", err)
return
// print the values and check if they are right
fmt.Println("Detected values:")
fmt.Printf("Dashboard Domain: %s\n", config.DashboardDomain)
fmt.Printf("Let's Encrypt Email: %s\n", config.LetsEncryptEmail)
fmt.Printf("Badger Version: %s\n", config.BadgerVersion)
if !readBool(reader, "Are these values correct?", true) {
config = collectUserInput(reader)
}
}
config.DashboardDomain = traefikConfig.DashboardDomain
config.LetsEncryptEmail = traefikConfig.LetsEncryptEmail
config.BadgerVersion = traefikConfig.BadgerVersion
// print the values and check if they are right
fmt.Println("Detected values:")
fmt.Printf("Dashboard Domain: %s\n", config.DashboardDomain)
fmt.Printf("Let's Encrypt Email: %s\n", config.LetsEncryptEmail)
fmt.Printf("Badger Version: %s\n", config.BadgerVersion)
if !readBool(reader, "Are these values correct?", true) {
config = collectUserInput(reader)
}
config.DoCrowdsecInstall = true
installCrowdsec(config)
}
config.DoCrowdsecInstall = true
installCrowdsec(config)
}
}
@ -143,23 +145,23 @@ func readString(reader *bufio.Reader, prompt string, defaultValue string) string
}
func readPassword(prompt string, reader *bufio.Reader) string {
if term.IsTerminal(int(syscall.Stdin)) {
fmt.Print(prompt + ": ")
// Read password without echo if we're in a terminal
password, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println() // Add a newline since ReadPassword doesn't add one
if err != nil {
return ""
}
input := strings.TrimSpace(string(password))
if input == "" {
return readPassword(prompt, reader)
}
return input
} else {
if term.IsTerminal(int(syscall.Stdin)) {
fmt.Print(prompt + ": ")
// Read password without echo if we're in a terminal
password, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println() // Add a newline since ReadPassword doesn't add one
if err != nil {
return ""
}
input := strings.TrimSpace(string(password))
if input == "" {
return readPassword(prompt, reader)
}
return input
} else {
// Fallback to reading from stdin if not in a terminal
return readString(reader, prompt, "")
}
}
}
func readBool(reader *bufio.Reader, prompt string, defaultValue bool) bool {
@ -324,10 +326,10 @@ func createConfigFiles(config Config) error {
return nil
}
// skip .DS_Store
if strings.Contains(path, ".DS_Store") {
return nil
}
// skip .DS_Store
if strings.Contains(path, ".DS_Store") {
return nil
}
if d.IsDir() {
// Create directory
@ -376,7 +378,6 @@ func createConfigFiles(config Config) error {
return nil
}
func installDocker() error {
// Detect Linux distribution
cmd := exec.Command("cat", "/etc/os-release")
@ -654,29 +655,29 @@ func moveFile(src, dst string) error {
}
func waitForContainer(containerName string) error {
maxAttempts := 30
retryInterval := time.Second * 2
maxAttempts := 30
retryInterval := time.Second * 2
for attempt := 0; attempt < maxAttempts; attempt++ {
// Check if container is running
cmd := exec.Command("docker", "container", "inspect", "-f", "{{.State.Running}}", containerName)
var out bytes.Buffer
cmd.Stdout = &out
for attempt := 0; attempt < maxAttempts; attempt++ {
// Check if container is running
cmd := exec.Command("docker", "container", "inspect", "-f", "{{.State.Running}}", containerName)
var out bytes.Buffer
cmd.Stdout = &out
if err := cmd.Run(); err != nil {
// If the container doesn't exist or there's another error, wait and retry
time.Sleep(retryInterval)
continue
}
if err := cmd.Run(); err != nil {
// If the container doesn't exist or there's another error, wait and retry
time.Sleep(retryInterval)
continue
}
isRunning := strings.TrimSpace(out.String()) == "true"
if isRunning {
return nil
}
isRunning := strings.TrimSpace(out.String()) == "true"
if isRunning {
return nil
}
// Container exists but isn't running yet, wait and retry
time.Sleep(retryInterval)
}
// Container exists but isn't running yet, wait and retry
time.Sleep(retryInterval)
}
return fmt.Errorf("container %s did not start within %v seconds", containerName, maxAttempts*int(retryInterval.Seconds()))
return fmt.Errorf("container %s did not start within %v seconds", containerName, maxAttempts*int(retryInterval.Seconds()))
}

2668
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -57,6 +57,7 @@
"glob": "11.0.0",
"helmet": "8.0.0",
"http-errors": "2.0.0",
"i": "^0.3.7",
"input-otp": "1.4.1",
"js-yaml": "4.1.0",
"lucide-react": "0.469.0",
@ -66,12 +67,14 @@
"node-cache": "5.1.2",
"node-fetch": "3.3.2",
"nodemailer": "6.9.16",
"npm": "^11.2.0",
"oslo": "1.2.1",
"qrcode.react": "4.2.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-easy-sort": "^1.6.0",
"react-hook-form": "7.54.2",
"react-icons": "^5.5.0",
"rebuild": "0.1.2",
"semver": "7.6.3",
"tailwind-merge": "2.6.0",

View file

@ -471,6 +471,15 @@ export const resourceRules = sqliteTable("resourceRules", {
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 User = InferSelectModel<typeof users>;
export type Site = InferSelectModel<typeof sites>;
@ -510,3 +519,4 @@ export type Client = InferSelectModel<typeof clients>;
export type RoleClient = InferSelectModel<typeof roleClients>;
export type UserClient = InferSelectModel<typeof userClients>;
export type Domain = InferSelectModel<typeof domains>;
export type SupporterKey = InferSelectModel<typeof supporterKey>;

View file

@ -10,7 +10,9 @@ import {
} from "@server/lib/consts";
import { passwordSchema } from "@server/auth/passwordSchema";
import stoi from "./stoi";
import { start } from "repl";
import db from "@server/db";
import { SupporterKey, supporterKey } from "@server/db/schema";
import { eq } from "drizzle-orm";
const portSchema = z.number().positive().gt(0).lte(65535);
@ -162,6 +164,10 @@ const configSchema = z.object({
export class Config {
private rawConfig!: z.infer<typeof configSchema>;
supporterData: SupporterKey | null = null;
supporterHiddenUntil: number | null = null;
constructor() {
this.loadConfig();
}
@ -190,7 +196,9 @@ export class Config {
}
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) {
@ -242,6 +250,14 @@ export class Config {
: "false";
process.env.DASHBOARD_URL = parsedConfig.data.app.dashboard_url;
this.checkSupporterKey()
.then(() => {
console.log("Supporter key checked");
})
.catch((error) => {
console.error("Error checking supporter key:", error);
});
this.rawConfig = parsedConfig.data;
}
@ -258,6 +274,85 @@ export class Config {
public getDomain(domainId: string) {
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,
tier: data.data.tier,
valid: true
};
// update the supporter key in the database
await db
.update(supporterKey)
.set({
tier: data.data.tier || null,
phrase: data.data.cutePhrase || null,
valid: true
})
.where(eq(supporterKey.keyId, key.keyId));
}
public getSupporterData() {
return this.supporterData;
}
}
export const config = new Config();

View file

@ -2,7 +2,7 @@ import path from "path";
import { fileURLToPath } from "url";
// This is a placeholder value replaced by the build process
export const APP_VERSION = "1.0.0";
export const APP_VERSION = "1.1.0";
export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME);

View file

@ -15,3 +15,4 @@ export * from "./verifySetResourceUsers";
export * from "./verifyUserInRole";
export * from "./verifyAccessTokenAccess";
export * from "./verifyClientAccess";
export * from "./verifyUserIsServerAdmin";

View file

@ -0,0 +1,37 @@
import { Request, Response, NextFunction } from "express";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
export async function verifyUserIsServerAdmin(
req: Request,
res: Response,
next: NextFunction
) {
const userId = req.user!.userId;
if (!userId) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
);
}
try {
if (!req.user?.serverAdmin) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User is not a server admin"
)
);
}
return next();
} catch (e) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying organization access"
)
);
}
}

View file

@ -9,6 +9,7 @@ import * as user from "./user";
import * as auth from "./auth";
import * as role from "./role";
import * as client from "./client";
import * as supporterKey from "./supporterKey";
import * as accessToken from "./accessToken";
import HttpCode from "@server/types/HttpCode";
import {
@ -24,7 +25,8 @@ import {
verifySetResourceUsers,
verifyUserAccess,
getUserOrgs,
verifyClientAccess
verifyClientAccess,
verifyUserIsServerAdmin
} from "@server/middlewares";
import { verifyUserHasAction } from "../middlewares/verifyUserHasAction";
import { ActionsEnum } from "@server/auth/actions";
@ -413,6 +415,9 @@ authenticated.get(
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);
// authenticated.get(
@ -446,6 +451,13 @@ unauthenticated.get("/resource/:resourceId/auth", resource.getResourceAuthInfo);
unauthenticated.get("/user", verifySessionMiddleware, user.getUser);
authenticated.get("/users", verifyUserIsServerAdmin, user.adminListUsers);
authenticated.delete(
"/user/:userId",
verifyUserIsServerAdmin,
user.adminRemoveUser
);
authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser);
authenticated.get(
"/org/:orgId/users",

View file

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

@ -170,6 +170,10 @@ export async function traefikConfigProvider(
wildCard = `*.${domainParts.slice(1).join(".")}`;
}
if (resource.isBaseDomain) {
wildCard = resource.fullDomain;
}
const configDomain = config.getDomain(resource.domainId);
if (!configDomain) {

View file

@ -0,0 +1,92 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { sql, eq } from "drizzle-orm";
import logger from "@server/logger";
import { users } from "@server/db/schema";
const listUsersSchema = z
.object({
limit: z
.string()
.optional()
.default("1000")
.transform(Number)
.pipe(z.number().int().nonnegative()),
offset: z
.string()
.optional()
.default("0")
.transform(Number)
.pipe(z.number().int().nonnegative())
})
.strict();
async function queryUsers(limit: number, offset: number) {
return await db
.select({
id: users.userId,
email: users.email,
dateCreated: users.dateCreated,
})
.from(users)
.where(eq(users.serverAdmin, false))
.limit(limit)
.offset(offset);
}
export type AdminListUsersResponse = {
users: NonNullable<Awaited<ReturnType<typeof queryUsers>>>;
pagination: { total: number; limit: number; offset: number };
};
export async function adminListUsers(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = listUsersSchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
parsedQuery.error.errors.map((e) => e.message).join(", ")
)
);
}
const { limit, offset } = parsedQuery.data;
const allUsers = await queryUsers(
limit,
offset
);
const [{ count }] = await db
.select({ count: sql<number>`count(*)` })
.from(users);
return response<AdminListUsersResponse>(res, {
data: {
users: allUsers,
pagination: {
total: count,
limit,
offset
}
},
success: true,
error: false,
message: "Users retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -0,0 +1,61 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { userOrgs, users } from "@server/db/schema";
import { and, eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
const removeUserSchema = z
.object({
userId: z.string()
})
.strict();
export async function adminRemoveUser(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = removeUserSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { userId } = parsedParams.data;
// get the user first
const user = await db
.select()
.from(userOrgs)
.where(eq(userOrgs.userId, userId));
if (!user || user.length === 0) {
return next(createHttpError(HttpCode.NOT_FOUND, "User not found"));
}
await db.delete(users).where(eq(users.userId, userId));
return response(res, {
data: null,
success: true,
error: false,
message: "User removed successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -5,3 +5,5 @@ export * from "./addUserRole";
export * from "./inviteUser";
export * from "./acceptInvite";
export * from "./getOrgUser";
export * from "./adminListUsers";
export * from "./adminRemoveUser";

View file

@ -71,7 +71,7 @@ export async function removeUserOrg(
data: null,
success: true,
error: false,
message: "User remove from org successfully",
message: "User removed from org successfully",
status: HttpCode.OK
});
} catch (error) {

View file

@ -17,6 +17,7 @@ import m8 from "./scripts/1.0.0-beta12";
import m13 from "./scripts/1.0.0-beta13";
import m15 from "./scripts/1.0.0-beta15";
import m16 from "./scripts/1.0.0";
import m17 from "./scripts/1.1.0";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// 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.13", run: m13 },
{ 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
] 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

@ -107,23 +107,19 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
return (
<>
<div className="w-full bg-card sm:px-0 px-3 fixed top-0 z-10">
<div className="border-b">
<div className="container mx-auto flex flex-col content-between">
<div className="my-4">
<UserProvider user={user}>
<Header orgId={params.orgId} orgs={orgs} />
</UserProvider>
</div>
<TopbarNav items={topNavItems} orgId={params.orgId} />
<div className="w-full bg-card sm:px-0 fixed top-0 z-10 border-b">
<div className="container mx-auto flex flex-col content-between">
<div className="my-4 px-3 md:px-0">
<UserProvider user={user}>
<Header orgId={params.orgId} orgs={orgs} />
</UserProvider>
</div>
<TopbarNav items={topNavItems} orgId={params.orgId} />
</div>
</div>
<div className="container mx-auto sm:px-0 px-3 pt-[155px]">
<div className="container mx-auto sm:px-0 px-3">
{children}
</div>
{children}
</div>
</>
);

View file

@ -764,7 +764,7 @@ export default function CreateResourceForm({
<Link
className="text-sm text-primary flex items-center gap-1"
href="https://docs.fossorial.io/Getting%20Started/tcp-udp"
href="https://docs.fossorial.io/Pangolin/tcp-udp"
target="_blank"
rel="noopener noreferrer"
>

View file

@ -552,7 +552,7 @@ export default function ResourceRules(props: {
path.
</p>
</div>
<InfoSections>
<InfoSections cols={2}>
<InfoSection>
<InfoSectionTitle>Actions</InfoSectionTitle>
<ul className="text-sm text-muted-foreground space-y-1">
@ -568,7 +568,6 @@ export default function ResourceRules(props: {
</li>
</ul>
</InfoSection>
<Separator orientation="vertical" />
<InfoSection>
<InfoSectionTitle>
Matching Criteria

View file

@ -331,7 +331,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
columns={columns}
data={rows}
addSite={() => {
setIsCreateModalOpen(true);
router.push(`/${orgId}/settings/sites/create`);
}}
/>
</>

View file

@ -0,0 +1,861 @@
"use client";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { StrategySelect } from "@app/components/StrategySelect";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import { z } from "zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@app/components/ui/input";
import { Terminal, InfoIcon } from "lucide-react";
import { Button } from "@app/components/ui/button";
import CopyTextBox from "@app/components/CopyTextBox";
import CopyToClipboard from "@app/components/CopyToClipboard";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import { FaWindows, FaApple, FaFreebsd, FaDocker } from "react-icons/fa";
import { Checkbox } from "@app/components/ui/checkbox";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { generateKeypair } from "../[niceId]/wireguardConfig";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import {
CreateSiteBody,
CreateSiteResponse,
PickSiteDefaultsResponse
} from "@server/routers/site";
import { toast } from "@app/hooks/useToast";
import { AxiosResponse } from "axios";
import { useParams, useRouter } from "next/navigation";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "@app/components/ui/breadcrumb";
import Link from "next/link";
const createSiteFormSchema = z
.object({
name: z
.string()
.min(2, {
message: "Name must be at least 2 characters."
})
.max(30, {
message: "Name must not be longer than 30 characters."
}),
method: z.string(),
copied: z.boolean()
})
.refine(
(data) => {
if (data.method !== "local") {
return data.copied;
}
return true;
},
{
message: "Please confirm that you have copied the config.",
path: ["copied"]
}
);
type CreateSiteFormValues = z.infer<typeof createSiteFormSchema>;
type Commands = {
mac: Record<string, string[]>;
linux: Record<string, string[]>;
windows: Record<string, string[]>;
docker: Record<string, string[]>;
};
export default function Page() {
const { env } = useEnvContext();
const api = createApiClient({ env });
const { orgId } = useParams();
const router = useRouter();
const [tunnelTypes, setTunnelTypes] = useState<any>([
{
id: "newt",
title: "Newt Tunnel (Recommended)",
description:
"Easiest way to create an entrypoint into your network. No extra setup.",
disabled: true
},
{
id: "wireguard",
title: "Basic WireGuard",
description:
"Use any WireGuard client to establish a tunnel. Manual NAT setup required.",
disabled: true
},
{
id: "local",
title: "Local",
description: "Local resources only. No tunneling."
}
]);
const [loadingPage, setLoadingPage] = useState(true);
const [platform, setPlatform] = useState("linux");
const [architecture, setArchitecture] = useState("amd64");
const [commands, setCommands] = useState<Commands | null>(null);
const [newtId, setNewtId] = useState("");
const [newtSecret, setNewtSecret] = useState("");
const [newtEndpoint, setNewtEndpoint] = useState("");
const [publicKey, setPublicKey] = useState("");
const [privateKey, setPrivateKey] = useState("");
const [wgConfig, setWgConfig] = useState("");
const [createLoading, setCreateLoading] = useState(false);
const [siteDefaults, setSiteDefaults] =
useState<PickSiteDefaultsResponse | null>(null);
const hydrateWireGuardConfig = (
privateKey: string,
publicKey: string,
subnet: string,
address: string,
endpoint: string,
listenPort: string
) => {
const wgConfig = `[Interface]
Address = ${subnet}
ListenPort = 51820
PrivateKey = ${privateKey}
[Peer]
PublicKey = ${publicKey}
AllowedIPs = ${address.split("/")[0]}/32
Endpoint = ${endpoint}:${listenPort}
PersistentKeepalive = 5`;
setWgConfig(wgConfig);
};
const hydrateCommands = (
id: string,
secret: string,
endpoint: string,
version: string
) => {
const commands = {
mac: {
"Apple Silicon (arm64)": [
`curl -L -o newt "https://github.com/fosrl/newt/releases/download/${version}/newt_darwin_arm64" && chmod +x ./newt`,
`./newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
],
"Intel x64 (amd64)": [
`curl -L -o newt "https://github.com/fosrl/newt/releases/download/${version}/newt_darwin_amd64" && chmod +x ./newt`,
`./newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
]
},
linux: {
amd64: [
`wget -O newt "https://github.com/fosrl/newt/releases/download/${version}/newt_linux_amd64" && chmod +x ./newt`,
`./newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
],
arm64: [
`wget -O newt "https://github.com/fosrl/newt/releases/download/${version}/newt_linux_arm64" && chmod +x ./newt`,
`./newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
],
arm32: [
`wget -O newt "https://github.com/fosrl/newt/releases/download/${version}/newt_linux_arm32" && chmod +x ./newt`,
`./newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
],
arm32v6: [
`wget -O newt "https://github.com/fosrl/newt/releases/download/${version}/newt_linux_arm32v6" && chmod +x ./newt`,
`./newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
],
riscv64: [
`wget -O newt "https://github.com/fosrl/newt/releases/download/${version}/newt_linux_riscv64" && chmod +x ./newt`,
`./newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
]
},
freebsd: {
amd64: [
`fetch -o newt "https://github.com/fosrl/newt/releases/download/${version}/newt_freebsd_amd64" && chmod +x ./newt`,
`./newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
],
arm64: [
`fetch -o newt "https://github.com/fosrl/newt/releases/download/${version}/newt_freebsd_arm64" && chmod +x ./newt`,
`./newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
]
},
windows: {
x64: [
`curl -o newt.exe -L "https://github.com/fosrl/newt/releases/download/${version}/newt_windows_amd64.exe"`,
`newt.exe --id ${id} --secret ${secret} --endpoint ${endpoint}`
]
},
docker: {
"Docker Compose": [
`services:
newt:
image: fosrl/newt
container_name: newt
restart: unless-stopped
environment:
- PANGOLIN_ENDPOINT=${endpoint}
- NEWT_ID=${id}
- NEWT_SECRET=${secret}`
],
"Docker Run": [
`docker run -it fosrl/newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
]
}
};
setCommands(commands);
};
const getArchitectures = () => {
switch (platform) {
case "linux":
return ["amd64", "arm64", "arm32", "arm32v6", "riscv64"];
case "mac":
return ["Apple Silicon (arm64)", "Intel x64 (amd64)"];
case "windows":
return ["x64"];
case "docker":
return ["Docker Compose", "Docker Run"];
case "freebsd":
return ["amd64", "arm64"];
default:
return ["x64"];
}
};
const getPlatformName = (platformName: string) => {
switch (platformName) {
case "windows":
return "Windows";
case "mac":
return "macOS";
case "docker":
return "Docker";
case "freebsd":
return "FreeBSD";
default:
return "Linux";
}
};
const getCommand = () => {
const placeholder = ["Unknown command"];
if (!commands) {
return placeholder;
}
let platformCommands = commands[platform as keyof Commands];
if (!platformCommands) {
// get first key
const firstPlatform = Object.keys(commands)[0];
platformCommands = commands[firstPlatform as keyof Commands];
setPlatform(firstPlatform);
}
let architectureCommands = platformCommands[architecture];
if (!architectureCommands) {
// get first key
const firstArchitecture = Object.keys(platformCommands)[0];
architectureCommands = platformCommands[firstArchitecture];
setArchitecture(firstArchitecture);
}
return architectureCommands || placeholder;
};
const getPlatformIcon = (platformName: string) => {
switch (platformName) {
case "windows":
return <FaWindows className="h-4 w-4 mr-2" />;
case "mac":
return <FaApple className="h-4 w-4 mr-2" />;
case "docker":
return <FaDocker className="h-4 w-4 mr-2" />;
case "freebsd":
return <FaFreebsd className="h-4 w-4 mr-2" />;
default:
return <Terminal className="h-4 w-4 mr-2" />;
}
};
const form = useForm({
resolver: zodResolver(createSiteFormSchema),
defaultValues: {
name: "",
copied: false,
method: "newt"
}
});
async function onSubmit(data: CreateSiteFormValues) {
setCreateLoading(true);
let payload: CreateSiteBody = {
name: data.name,
type: data.method
};
if (data.method == "wireguard") {
if (!siteDefaults || !wgConfig) {
toast({
variant: "destructive",
title: "Error creating site",
description: "Key pair or site defaults not found"
});
setCreateLoading(false);
return;
}
payload = {
...payload,
subnet: siteDefaults.subnet,
exitNodeId: siteDefaults.exitNodeId,
pubKey: publicKey
};
}
if (data.method === "newt") {
if (!siteDefaults) {
toast({
variant: "destructive",
title: "Error creating site",
description: "Site defaults not found"
});
setCreateLoading(false);
return;
}
payload = {
...payload,
subnet: siteDefaults.subnet,
exitNodeId: siteDefaults.exitNodeId,
secret: siteDefaults.newtSecret,
newtId: siteDefaults.newtId
};
}
const res = await api
.put<
AxiosResponse<CreateSiteResponse>
>(`/org/${orgId}/site/`, payload)
.catch((e) => {
toast({
variant: "destructive",
title: "Error creating site",
description: formatAxiosError(e)
});
});
if (res && res.status === 201) {
const data = res.data.data;
router.push(`/${orgId}/settings/sites/${data.niceId}`);
}
setCreateLoading(false);
}
useEffect(() => {
const load = async () => {
setLoadingPage(true);
let newtVersion = "latest";
try {
const response = await fetch(
`https://api.github.com/repos/fosrl/newt/releases/latest`
);
if (!response.ok) {
throw new Error(
`Failed to fetch release info: ${response.statusText}`
);
}
const data = await response.json();
const latestVersion = data.tag_name;
newtVersion = latestVersion;
} catch (error) {
console.error("Error fetching latest release:", error);
}
const generatedKeypair = generateKeypair();
const privateKey = generatedKeypair.privateKey;
const publicKey = generatedKeypair.publicKey;
setPrivateKey(privateKey);
setPublicKey(publicKey);
await api
.get(`/org/${orgId}/pick-site-defaults`)
.catch((e) => {
// update the default value of the form to be local method
form.setValue("method", "local");
})
.then((res) => {
if (res && res.status === 200) {
const data = res.data.data;
setSiteDefaults(data);
const newtId = data.newtId;
const newtSecret = data.newtSecret;
const newtEndpoint = data.endpoint;
setNewtId(newtId);
setNewtSecret(newtSecret);
setNewtEndpoint(newtEndpoint);
hydrateCommands(
newtId,
newtSecret,
env.app.dashboardUrl,
newtVersion
);
hydrateWireGuardConfig(
privateKey,
data.publicKey,
data.subnet,
data.address,
data.endpoint,
data.listenPort
);
setTunnelTypes((prev: any) => {
return prev.map((item: any) => {
return {
...item,
disabled: false
};
});
});
}
});
setLoadingPage(false);
};
load();
}, []);
return (
<>
<div className="mb-4 flex-row">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<Link href="../">Sites</Link>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Create Site</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
<div className="flex justify-between">
<HeaderTitle
title="Create Site"
description="Follow the steps below to create and connect a new site"
/>
<Button
variant="outline"
onClick={() => {
router.push(`/${orgId}/settings/sites`);
}}
>
See All Sites
</Button>
</div>
{!loadingPage && (
<div>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Site Information
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
className="space-y-4"
id="create-site-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
Name
</FormLabel>
<FormControl>
<Input
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
This is the the
display name for the
site.
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Tunnel Type
</SettingsSectionTitle>
<SettingsSectionDescription>
Determine how you want to connect to your
site
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<StrategySelect
options={tunnelTypes}
defaultValue={
form.getValues("method") as string
}
onChange={(value) =>
form.setValue("method", value)
}
cols={3}
/>
</SettingsSectionBody>
</SettingsSection>
{form.watch("method") === "newt" && (
<>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Newt Credentials
</SettingsSectionTitle>
<SettingsSectionDescription>
This is how Newt will authenticate
with the server
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<InfoSections cols={3}>
<InfoSection>
<InfoSectionTitle>
Newt Endpoint
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
text={
env.app.dashboardUrl
}
/>
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
Newt ID
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
text={newtId}
/>
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
Newt Secret Key
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
text={newtSecret}
/>
</InfoSectionContent>
</InfoSection>
</InfoSections>
<Alert variant="default" className="">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
Save Your Credentials
</AlertTitle>
<AlertDescription>
You will only be able to see
this once. Make sure to copy it
to a secure place.
</AlertDescription>
</Alert>
<Form {...form}>
<form
className="space-y-4"
id="create-site-form"
>
<FormField
control={form.control}
name="copied"
render={({ field }) => (
<FormItem>
<div className="flex items-center space-x-2">
<Checkbox
id="terms"
defaultChecked={
form.getValues(
"copied"
) as boolean
}
onCheckedChange={(
e
) => {
form.setValue(
"copied",
e as boolean
);
}}
/>
<label
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
I have
copied the
config
</label>
</div>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Install Newt
</SettingsSectionTitle>
<SettingsSectionDescription>
Get Newt running on your system
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div>
<p className="font-bold mb-3">
Operating System
</p>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
{[
"linux",
"docker",
"mac",
"windows",
"freebsd"
].map((os) => (
<Button
key={os}
variant={
platform === os
? "squareOutlinePrimary"
: "squareOutline"
}
className={`flex-1 min-w-[120px] ${platform === os ? "bg-primary/10" : ""}`}
onClick={() => {
setPlatform(os);
}}
>
{getPlatformIcon(os)}
{getPlatformName(os)}
</Button>
))}
</div>
</div>
<div>
<p className="font-bold mb-3">
{platform === "docker"
? "Method"
: "Architecture"}
</p>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
{getArchitectures().map(
(arch) => (
<Button
key={arch}
variant={
architecture ===
arch
? "squareOutlinePrimary"
: "squareOutline"
}
className={`flex-1 min-w-[120px] ${architecture === arch ? "bg-primary/10" : ""}`}
onClick={() =>
setArchitecture(
arch
)
}
>
{arch}
</Button>
)
)}
</div>
<div className="pt-4">
<p className="font-bold mb-3">
Commands
</p>
<div className="mt-2">
<CopyTextBox
text={getCommand().join(
"\n"
)}
outline={true}
/>
</div>
</div>
</div>
</SettingsSectionBody>
</SettingsSection>
</>
)}
{form.watch("method") === "wireguard" && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
WireGuard Configuration
</SettingsSectionTitle>
<SettingsSectionDescription>
Use the following configuration to
connect to your network
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<CopyTextBox text={wgConfig} />
<Alert variant="default">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
Save Your Credentials
</AlertTitle>
<AlertDescription>
You will only be able to see this
once. Make sure to copy it to a
secure place.
</AlertDescription>
</Alert>
<Form {...form}>
<form
className="space-y-4"
id="create-site-form"
>
<FormField
control={form.control}
name="copied"
render={({ field }) => (
<FormItem>
<div className="flex items-center space-x-2">
<Checkbox
id="terms"
defaultChecked={
form.getValues(
"copied"
) as boolean
}
onCheckedChange={(
e
) => {
form.setValue(
"copied",
e as boolean
);
}}
/>
<label
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
I have copied
the config
</label>
</div>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionBody>
</SettingsSection>
)}
</SettingsContainer>
<div className="flex justify-end space-x-2 mt-8">
<Button
type="button"
variant="outline"
onClick={() => {
router.push(`/${orgId}/settings/sites`);
}}
>
Cancel
</Button>
<Button
type="button"
onClick={() => {
form.handleSubmit(onSubmit)();
}}
>
Create Site
</Button>
</div>
</div>
)}
</>
);
}

View file

@ -23,14 +23,7 @@ import {
FormLabel,
FormMessage
} from "@/components/ui/form";
import {
LockIcon,
Binary,
Key,
User,
Send,
AtSign
} from "lucide-react";
import { LockIcon, Binary, Key, User, Send, AtSign } from "lucide-react";
import {
InputOTP,
InputOTPGroup,
@ -50,6 +43,7 @@ import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import Link from "next/link";
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
const pinSchema = z.object({
pin: z
@ -115,6 +109,8 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
const api = createApiClient({ env });
const { supporterStatus } = useSupporterStatusContext();
function getDefaultSelectedMethod() {
if (props.methods.sso) {
return "sso";
@ -194,7 +190,10 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
const session = res.data.data.session;
if (session) {
window.location.href = appendRequestToken(props.redirect, session);
window.location.href = appendRequestToken(
props.redirect,
session
);
}
})
.catch((e) => {
@ -216,7 +215,10 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
setPincodeError(null);
const session = res.data.data.session;
if (session) {
window.location.href = appendRequestToken(props.redirect, session);
window.location.href = appendRequestToken(
props.redirect,
session
);
}
})
.catch((e) => {
@ -241,7 +243,10 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
setPasswordError(null);
const session = res.data.data.session;
if (session) {
window.location.href = appendRequestToken(props.redirect, session);
window.location.href = appendRequestToken(
props.redirect,
session
);
}
})
.catch((e) => {
@ -621,6 +626,15 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
</Tabs>
</CardContent>
</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>
) : (
<ResourceAccessDenied />

View file

@ -6,14 +6,20 @@ import { ThemeProvider } from "@app/providers/ThemeProvider";
import EnvProvider from "@app/providers/EnvProvider";
import { Separator } from "@app/components/ui/separator";
import { pullEnv } from "@app/lib/pullEnv";
import { BookOpenText } from "lucide-react";
import { BookOpenText, ExternalLink } from "lucide-react";
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 = {
title: `Dashboard - Pangolin`,
description: ""
};
export const dynamic = 'force-dynamic';
// const font = Figtree({ subsets: ["latin"] });
const font = Inter({ subsets: ["latin"] });
@ -24,6 +30,15 @@ export default async function RootLayout({
}>) {
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;
return (
@ -36,58 +51,69 @@ export default async function RootLayout({
disableTransitionOnChange
>
<EnvProvider env={pullEnv()}>
{/* Main content */}
<div className="flex-grow pb-3 md:pb-0">{children}</div>
{/* 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" />
<div className="whitespace-nowrap">
Built by Fossorial
</div>
<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>
</>
)}
<SupportStatusProvider supporterStatus={supporterData}>
{/* Main content */}
<div className="flex-grow pb-3 md:pb-0">
{children}
</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>
<Toaster />
</ThemeProvider>

View file

@ -96,7 +96,8 @@ export default function StepperForm() {
});
if (res && res.status === 201) {
setCurrentStep("site");
// setCurrentStep("site");
router.push(`/${values.orgId}/settings/sites/create`);
}
} catch (e) {
console.error(e);
@ -290,42 +291,6 @@ export default function StepperForm() {
</form>
</Form>
)}
{currentStep === "site" && (
<div>
<CreateSiteForm
setLoading={(val) => setLoading(val)}
setChecked={(val) => setIsChecked(val)}
orgId={orgForm.getValues().orgId}
onCreate={() => {
router.push(
`/${orgForm.getValues().orgId}/settings/resources`
);
}}
/>
<div className="flex justify-between mt-6">
<Button
type="submit"
variant="outline"
onClick={() => {
router.push(
`/${orgForm.getValues().orgId}/settings/sites`
);
}}
>
Skip for now
</Button>
<Button
type="submit"
form="create-site-form"
loading={loading}
disabled={loading || !isChecked}
>
Create Site
</Button>
</div>
</div>
)}
</section>
</CardContent>
</Card>

View file

@ -4,7 +4,11 @@ import { useState, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Copy, Check } from "lucide-react";
export default function CopyTextBox({ text = "", wrapText = false }) {
export default function CopyTextBox({
text = "",
wrapText = false,
outline = true
}) {
const [isCopied, setIsCopied] = useState(false);
const textRef = useRef<HTMLPreElement>(null);
@ -23,7 +27,9 @@ export default function CopyTextBox({ text = "", wrapText = false }) {
};
return (
<div className="relative w-full border rounded-md bg-card">
<div
className={`relative w-full border rounded-md ${!outline ? "bg-muted" : "bg-card"}`}
>
<pre
ref={textRef}
className={`p-4 pr-16 text-sm w-full ${

View file

@ -20,18 +20,32 @@ const CopyToClipboard = ({ text, isLink }: CopyToClipboardProps) => {
};
return (
<div className="flex items-center">
<div className="flex items-center space-x-2 max-w-full">
{isLink ? (
<Link
href={text}
target="_blank"
rel="noopener noreferrer"
className="hover:underline mr-2"
className="truncate hover:underline"
style={{ maxWidth: "100%" }} // Ensures truncation works within parent
title={text} // Shows full text on hover
>
{text}
</Link>
) : (
<span className="mr-2">{text}</span>
<span
className="truncate"
style={{
maxWidth: "100%",
display: "block",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis"
}}
title={text} // Full text tooltip
>
{text}
</span>
)}
<button
type="button"

View file

@ -24,6 +24,7 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import { useUserContext } from "@app/hooks/useUserContext";
import ProfileIcon from "./ProfileIcon";
import SupporterStatus from "./SupporterStatus";
type HeaderProps = {
orgId?: string;
@ -42,7 +43,13 @@ export function Header({ orgId, orgs }: HeaderProps) {
return (
<>
<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="hidden md:block">

View file

@ -3,11 +3,11 @@ export function SettingsContainer({ children }: { children: React.ReactNode }) {
}
export function SettingsSection({ children }: { children: React.ReactNode }) {
return <div className="border rounded-md bg-card p-4">{children}</div>
return <div className="border rounded-lg bg-card p-5">{children}</div>
}
export function SettingsSectionHeader({ children }: { children: React.ReactNode }) {
return <div className="space-y-0.5 pb-6">{children}</div>
return <div className="text-lg space-y-0.5 pb-6">{children}</div>
}
export function SettingsSectionForm({ children }: { children: React.ReactNode }) {

View file

@ -1,13 +1,13 @@
type SettingsSectionTitleProps = {
title: string | React.ReactNode;
description: string | React.ReactNode;
description?: string | React.ReactNode;
size?: "2xl" | "1xl";
};
export default function SettingsSectionTitle({
title,
description,
size,
size
}: SettingsSectionTitleProps) {
return (
<div
@ -20,7 +20,9 @@ export default function SettingsSectionTitle({
>
{title}
</h2>
<p className="text-muted-foreground">{description}</p>
{description && (
<p className="text-muted-foreground">{description}</p>
)}
</div>
);
}

View file

@ -2,42 +2,59 @@
import { cn } from "@app/lib/cn";
import { RadioGroup, RadioGroupItem } from "./ui/radio-group";
import { useState } from "react";
interface StrategyOption {
id: string;
title: string;
description: string;
disabled?: boolean; // New optional property
}
interface StrategySelectProps {
options: StrategyOption[];
defaultValue?: string;
onChange?: (value: string) => void;
cols?: number;
}
export function StrategySelect({
options,
defaultValue,
onChange
onChange,
cols
}: StrategySelectProps) {
const [selected, setSelected] = useState(defaultValue);
return (
<RadioGroup
defaultValue={defaultValue}
onValueChange={onChange}
className="grid gap-4"
onValueChange={(value) => {
setSelected(value);
onChange?.(value);
}}
className={`grid md:grid-cols-${cols ? cols : 1} gap-4`}
>
{options.map((option) => (
<label
key={option.id}
htmlFor={option.id}
data-state={
selected === option.id ? "checked" : "unchecked"
}
className={cn(
"relative flex cursor-pointer rounded-lg border-2 p-4",
"data-[state=checked]:border-primary data-[state=checked]:bg-primary/10 data-[state=checked]:text-primary"
"relative flex rounded-lg border-2 p-4 transition-colors cursor-pointer",
option.disabled
? "border-input text-muted-foreground cursor-not-allowed opacity-50"
: selected === option.id
? "border-primary bg-primary/10 text-primary"
: "border-input hover:bg-accent"
)}
>
<RadioGroupItem
value={option.id}
id={option.id}
disabled={option.disabled}
className="absolute left-4 top-5 h-4 w-4 border-primary text-primary"
/>
<div className="pl-7">

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

@ -21,20 +21,26 @@ const buttonVariants = cva(
secondary:
"bg-secondary border border-input border-2 text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
squareOutlinePrimary:
"border-2 border-primary bg-card hover:bg-primary/10 text-primary rounded-md",
squareOutline:
"border-2 border-input bg-card hover:bg-accent hover:text-accent-foreground rounded-md",
squareDefault:
"bg-primary text-primary-foreground hover:bg-primary/90 rounded-md",
text: "",
link: "text-primary underline-offset-4 hover:underline",
link: "text-primary underline-offset-4 hover:underline"
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
icon: "h-9 w-9"
}
},
defaultVariants: {
variant: "default",
size: "default",
},
size: "default"
}
}
);

View file

@ -3,7 +3,7 @@ import * as React from "react"
import { cn } from "@app/lib/cn"
export function TableContainer({ children }: { children: React.ReactNode }) {
return <div className="border rounded-md bg-card">{children}</div>
return <div className="border rounded-lg bg-card">{children}</div>
}
const Table = React.forwardRef<

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;