Pull up downstream changes

This commit is contained in:
Owen 2025-07-13 21:57:24 -07:00
parent c679875273
commit 98a261e38c
No known key found for this signature in database
GPG key ID: 8271FDFFD9E0CCBD
108 changed files with 9799 additions and 2038 deletions

View file

@ -27,3 +27,4 @@ bruno/
LICENSE
CONTRIBUTING.md
dist
.git

19
docker-compose.dev.yml Normal file
View file

@ -0,0 +1,19 @@
services:
# PostgreSQL Service
db:
image: postgres:17 # Use the PostgreSQL 17 image
container_name: dev_postgres # Name your PostgreSQL container
environment:
POSTGRES_DB: postgres # Default database name
POSTGRES_USER: postgres # Default user
POSTGRES_PASSWORD: password # Default password (change for production!)
ports:
- "5432:5432" # Map host port 5432 to container port 5432
restart: no
redis:
image: redis:latest # Use the latest Redis image
container_name: dev_redis # Name your Redis container
ports:
- "6379:6379" # Map host port 6379 to container port 6379
restart: no

View file

@ -3,7 +3,7 @@ import path from "path";
export default defineConfig({
dialect: "postgresql",
schema: path.join("server", "db", "pg", "schema.ts"),
schema: [path.join("server", "db", "pg", "schema.ts")],
out: path.join("server", "migrations"),
verbose: true,
dbCredentials: {

View file

@ -1,4 +1,4 @@
name: captcha_remediation
iame: captcha_remediation
filters:
- Alert.Remediation == true && Alert.GetScope() == "Ip" && Alert.GetScenario() contains "http"
decisions:

View file

@ -10,7 +10,8 @@
"setupErrorIdentifier": "Organization ID is already taken. Please choose a different one.",
"componentsErrorNoMemberCreate": "You are not currently a member of any organizations. Create an organization to get started.",
"componentsErrorNoMember": "You are not currently a member of any organizations.",
"welcome": "Welcome to Pangolin",
"welcome": "Welcome!",
"welcomeTo": "Welcome to",
"componentsCreateOrg": "Create an Organization",
"componentsMember": "You're a member of {count, plural, =0 {no organization} =1 {one organization} other {# organizations}}.",
"componentsInvalidKey": "Invalid or expired license keys detected. Follow license terms to continue using all features.",
@ -777,8 +778,6 @@
"orgPoliciesAdd": "Add Organization Policy",
"orgRequired": "Organization is required",
"error": "Error",
"refreshError": "Failed to refresh data",
"refresh": "Refresh",
"success": "Success",
"orgPolicyAddedDescription": "Policy added successfully",
"orgPolicyUpdatedDescription": "Policy updated successfully",
@ -1094,6 +1093,7 @@
"sidebarIdentityProviders": "Identity Providers",
"sidebarLicense": "License",
"sidebarClients": "Clients",
"sidebarDomains": "Domains",
"enableDockerSocket": "Enable Docker Socket",
"enableDockerSocketDescription": "Enable Docker Socket discovery for populating container information. Socket path must be provided to Newt.",
"enableDockerSocketLink": "Learn More",
@ -1138,6 +1138,50 @@
"initialSetupDescription": "Create the intial server admin account. Only one server admin can exist. You can always change these credentials later.",
"createAdminAccount": "Create Admin Account",
"setupErrorCreateAdmin": "An error occurred while creating the server admin account.",
"certificateStatus": "Certificate Status",
"loading": "Loading",
"restart": "Restart",
"domains": "Domains",
"domainsDescription": "Manage domains for your organization",
"domainsSearch": "Search domains...",
"domainAdd": "Add Domain",
"domainAddDescription": "Register a new domain with your organization",
"domainCreate": "Create Domain",
"domainCreatedDescription": "Domain created successfully",
"domainDeletedDescription": "Domain deleted successfully",
"domainQuestionRemove": "Are you sure you want to remove the domain {domain} from your account?",
"domainMessageRemove": "Once removed, the domain will no longer be associated with your account.",
"domainMessageConfirm": "To confirm, please type the domain name below.",
"domainConfirmDelete": "Confirm Delete Domain",
"domainDelete": "Delete Domain",
"domain": "Domain",
"selectDomainTypeNsName": "Domain Delegation (NS)",
"selectDomainTypeNsDescription": "This domain and all its subdomains. Use this when you want to control an entire domain zone.",
"selectDomainTypeCnameName": "Single Domain (CNAME)",
"selectDomainTypeCnameDescription": "Just this specific domain. Use this for individual subdomains or specific domain entries.",
"domainDelegation": "Single Domain",
"selectType": "Select a type",
"actions": "Actions",
"refresh": "Refresh",
"refreshError": "Failed to refresh data",
"verified": "Verified",
"pending": "Pending",
"sidebarBilling": "Billing",
"billing": "Billing",
"orgBillingDescription": "Manage your billing information and subscriptions",
"github": "GitHub",
"pangolinHosted": "Pangolin Hosted",
"fossorial": "Fossorial",
"completeAccountSetup": "Complete Account Setup",
"completeAccountSetupDescription": "Set your password to get started",
"accountSetupSent": "We'll send an account setup code to this email address.",
"accountSetupCode": "Setup Code",
"accountSetupCodeDescription": "Check your email for the setup code.",
"passwordCreate": "Create Password",
"passwordCreateConfirm": "Confirm Password",
"accountSetupSubmit": "Send Setup Code",
"completeSetup": "Complete Setup",
"accountSetupSuccess": "Account setup completed! Welcome to Pangolin!",
"documentation": "Documentation",
"saveAllSettings": "Save All Settings",
"settingsUpdated": "Settings updated",
@ -1147,5 +1191,23 @@
"sidebarCollapse": "Collapse",
"sidebarExpand": "Expand",
"newtUpdateAvailable": "Update Available",
"newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience."
"newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.",
"domainPickerEnterDomain": "Enter your domain",
"domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, or just myapp",
"domainPickerDescription": "Enter a full domain, subdomain, or just a name to see available options",
"domainPickerTabAll": "All",
"domainPickerTabOrganization": "Organization",
"domainPickerTabProvided": "Provided",
"domainPickerSortAsc": "A-Z",
"domainPickerSortDesc": "Z-A",
"domainPickerCheckingAvailability": "Checking availability...",
"domainPickerNoMatchingDomains": "No matching domains found for \"{userInput}\". Try a different domain or check your organization's domain settings.",
"domainPickerOrganizationDomains": "Organization Domains",
"domainPickerProvidedDomains": "Provided Domains",
"domainPickerSubdomain": "Subdomain: {subdomain}",
"domainPickerNamespace": "Namespace: {namespace}",
"domainPickerShowMore": "Show More",
"domainNotFound": "Domain Not Found",
"domainNotFoundDescription": "This resource is disabled because the domain no longer exists our system. Please set a new domain for this resource.",
"failed": "Failed"
}

4558
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -28,6 +28,7 @@
},
"dependencies": {
"@asteasolutions/zod-to-openapi": "^7.3.2",
"@aws-sdk/client-s3": "3.837.0",
"@hookform/resolvers": "3.9.1",
"@node-rs/argon2": "^2.0.2",
"@oslojs/crypto": "1.0.1",
@ -101,6 +102,7 @@
"react-icons": "^5.5.0",
"rebuild": "0.1.2",
"semver": "7.7.2",
"stripe": "18.2.1",
"swagger-ui-express": "^5.0.1",
"tailwind-merge": "2.6.0",
"tw-animate-css": "^1.3.3",
@ -127,6 +129,7 @@
"@types/jsonwebtoken": "^9.0.9",
"@types/node": "^22",
"@types/nodemailer": "6.4.17",
"@types/pg": "8.15.4",
"@types/react": "19.1.7",
"@types/react-dom": "19.1.6",
"@types/semver": "7.7.0",

View file

@ -6,7 +6,8 @@ import logger from "@server/logger";
import {
errorHandlerMiddleware,
notFoundMiddleware,
rateLimitMiddleware
rateLimitMiddleware,
requestTimeoutMiddleware
} from "@server/middlewares";
import { authenticated, unauthenticated } from "@server/routers/external";
import { router as wsRouter, handleWSUpgrade } from "@server/routers/ws";
@ -19,6 +20,7 @@ const externalPort = config.getRawConfig().server.external_port;
export function createApiServer() {
const apiServer = express();
const prefix = `/api/v1`;
const trustProxy = config.getRawConfig().server.trust_proxy;
if (trustProxy) {
@ -54,6 +56,9 @@ export function createApiServer() {
apiServer.use(cookieParser());
apiServer.use(express.json());
// Add request timeout middleware
apiServer.use(requestTimeoutMiddleware(60000)); // 60 second timeout
if (!dev) {
apiServer.use(
rateLimitMiddleware({
@ -66,7 +71,6 @@ export function createApiServer() {
}
// API routes
const prefix = `/api/v1`;
apiServer.use(logIncomingMiddleware);
apiServer.use(prefix, unauthenticated);
apiServer.use(prefix, authenticated);

View file

@ -90,7 +90,10 @@ export enum ActionsEnum {
setApiKeyOrgs = "setApiKeyOrgs",
listApiKeyActions = "listApiKeyActions",
listApiKeys = "listApiKeys",
getApiKey = "getApiKey"
getApiKey = "getApiKey",
createOrgDomain = "createOrgDomain",
deleteOrgDomain = "deleteOrgDomain",
restartOrgDomain = "restartOrgDomain"
}
export async function checkUserActionPermission(

View file

@ -1,40 +0,0 @@
import { db } from '@server/db';
import { limitsTable } from '@server/db';
import { and, eq } from 'drizzle-orm';
import createHttpError from 'http-errors';
import HttpCode from '@server/types/HttpCode';
interface CheckLimitOptions {
orgId: string;
limitName: string;
currentValue: number;
increment?: number;
}
export async function checkOrgLimit({ orgId, limitName, currentValue, increment = 0 }: CheckLimitOptions): Promise<boolean> {
try {
const limit = await db.select()
.from(limitsTable)
.where(
and(
eq(limitsTable.orgId, orgId),
eq(limitsTable.name, limitName)
)
)
.limit(1);
if (limit.length === 0) {
throw createHttpError(HttpCode.NOT_FOUND, `Limit "${limitName}" not found for organization`);
}
const limitValue = limit[0].value;
// Check if the current value plus the increment is within the limit
return (currentValue + increment) <= limitValue;
} catch (error) {
if (error instanceof Error) {
throw createHttpError(HttpCode.INTERNAL_SERVER_ERROR, `Error checking limit: ${error.message}`);
}
throw createHttpError(HttpCode.INTERNAL_SERVER_ERROR, 'Unknown error occurred while checking limit');
}
}

View file

@ -59,7 +59,7 @@ export async function getUniqueExitNodeEndpointName(): Promise<string> {
export function generateName(): string {
return (
const name = (
names.descriptors[
Math.floor(Math.random() * names.descriptors.length)
] +
@ -68,4 +68,7 @@ export function generateName(): string {
)
.toLowerCase()
.replace(/\s/g, "-");
// clean out any non-alphanumeric characters except for dashes
return name.replace(/[^a-z0-9-]/g, "");
}

View file

@ -1,4 +1,5 @@
import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import { readConfigFile } from "@server/lib/readConfigFile";
import { withReplicas } from "drizzle-orm/pg-core";
@ -20,19 +21,31 @@ function createDb() {
);
}
const primary = DrizzlePostgres(connectionString);
// Create connection pools instead of individual connections
const primaryPool = new Pool({
connectionString,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
const replicas = [];
if (!replicaConnections.length) {
replicas.push(primary);
replicas.push(DrizzlePostgres(primaryPool));
} else {
for (const conn of replicaConnections) {
const replica = DrizzlePostgres(conn.connection_string);
replicas.push(replica);
const replicaPool = new Pool({
connectionString: conn.connection_string,
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
replicas.push(DrizzlePostgres(replicaPool));
}
}
return withReplicas(primary, replicas as any);
return withReplicas(DrizzlePostgres(primaryPool), replicas as any);
}
export const db = createDb();

View file

@ -14,6 +14,9 @@ export const domains = pgTable("domains", {
baseDomain: varchar("baseDomain").notNull(),
configManaged: boolean("configManaged").notNull().default(false),
type: varchar("type"), // "ns", "cname", "a"
verified: boolean("verified").notNull().default(false),
failed: boolean("failed").notNull().default(false),
tries: integer("tries").notNull().default(0)
});
export const orgs = pgTable("orgs", {
@ -44,9 +47,9 @@ export const sites = pgTable("sites", {
}),
name: varchar("name").notNull(),
pubKey: varchar("pubKey"),
subnet: varchar("subnet").notNull(),
megabytesIn: real("bytesIn"),
megabytesOut: real("bytesOut"),
subnet: varchar("subnet"),
megabytesIn: real("bytesIn").default(0),
megabytesOut: real("bytesOut").default(0),
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
type: varchar("type").notNull(), // "newt" or "wireguard"
online: boolean("online").notNull().default(false),
@ -282,18 +285,6 @@ export const userResources = pgTable("userResources", {
.references(() => resources.resourceId, { onDelete: "cascade" })
});
export const limitsTable = pgTable("limits", {
limitId: serial("limitId").primaryKey(),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull(),
name: varchar("name").notNull(),
value: bigint("value", { mode: "number" }).notNull(),
description: varchar("description")
});
export const userInvites = pgTable("userInvites", {
inviteId: varchar("inviteId").primaryKey(),
orgId: varchar("orgId")
@ -520,7 +511,8 @@ export const clients = pgTable("clients", {
type: varchar("type").notNull(), // "olm"
online: boolean("online").notNull().default(false),
endpoint: varchar("endpoint"),
lastHolePunch: integer("lastHolePunch")
lastHolePunch: integer("lastHolePunch"),
maxConnections: integer("maxConnections")
});
export const clientSites = pgTable("clientSites", {
@ -590,7 +582,6 @@ export type RoleSite = InferSelectModel<typeof roleSites>;
export type UserSite = InferSelectModel<typeof userSites>;
export type RoleResource = InferSelectModel<typeof roleResources>;
export type UserResource = InferSelectModel<typeof userResources>;
export type Limit = InferSelectModel<typeof limitsTable>;
export type UserInvite = InferSelectModel<typeof userInvites>;
export type UserOrg = InferSelectModel<typeof userOrgs>;
export type ResourceSession = InferSelectModel<typeof resourceSessions>;
@ -613,3 +604,4 @@ export type Olm = InferSelectModel<typeof olms>;
export type OlmSession = InferSelectModel<typeof olmSessions>;
export type UserClient = InferSelectModel<typeof userClients>;
export type RoleClient = InferSelectModel<typeof roleClients>;
export type OrgDomains = InferSelectModel<typeof orgDomains>;

View file

@ -3,30 +3,25 @@ import logger from "@server/logger";
import config from "@server/lib/config";
class RedisManager {
private static instance: RedisManager;
public client: Redis | null = null;
private subscriber: Redis | null = null;
private publisher: Redis | null = null;
private isEnabled: boolean = false;
private isHealthy: boolean = true;
private lastHealthCheck: number = 0;
private healthCheckInterval: number = 30000; // 30 seconds
private subscribers: Map<
string,
Set<(channel: string, message: string) => void>
> = new Map();
private constructor() {
constructor() {
this.isEnabled = config.getRawConfig().flags?.enable_redis || false;
if (this.isEnabled) {
this.initializeClients();
}
}
public static getInstance(): RedisManager {
if (!RedisManager.instance) {
RedisManager.instance = new RedisManager();
}
return RedisManager.instance;
}
private getRedisConfig(): RedisOptions {
const redisConfig = config.getRawConfig().redis!;
const opts: RedisOptions = {
@ -34,38 +29,78 @@ class RedisManager {
port: redisConfig.port!,
password: redisConfig.password,
db: redisConfig.db,
tls: {
rejectUnauthorized:
redisConfig.tls?.reject_unauthorized || false
}
// tls: {
// rejectUnauthorized:
// redisConfig.tls?.reject_unauthorized || false
// }
};
return opts;
}
// Add reconnection logic in initializeClients
private initializeClients(): void {
const config = this.getRedisConfig();
try {
// Main client for general operations
this.client = new Redis(config);
this.client = new Redis({
...config,
enableReadyCheck: false,
maxRetriesPerRequest: 3,
keepAlive: 30000,
connectTimeout: 10000, // 10 seconds
commandTimeout: 5000, // 5 seconds
});
// Dedicated publisher client
this.publisher = new Redis(config);
this.publisher = new Redis({
...config,
enableReadyCheck: false,
maxRetriesPerRequest: 3,
keepAlive: 30000,
connectTimeout: 10000, // 10 seconds
commandTimeout: 5000, // 5 seconds
});
// Dedicated subscriber client
this.subscriber = new Redis(config);
this.subscriber = new Redis({
...config,
enableReadyCheck: false,
maxRetriesPerRequest: 3,
keepAlive: 30000,
connectTimeout: 10000, // 10 seconds
commandTimeout: 5000, // 5 seconds
});
// Set up error handlers
// Add reconnection handlers
this.client.on("error", (err) => {
logger.error("Redis client error:", err);
this.isHealthy = false;
});
this.client.on("reconnecting", () => {
logger.info("Redis client reconnecting...");
this.isHealthy = false;
});
this.client.on("ready", () => {
logger.info("Redis client ready");
this.isHealthy = true;
});
this.publisher.on("error", (err) => {
logger.error("Redis publisher error:", err);
this.isHealthy = false;
});
this.publisher.on("ready", () => {
logger.info("Redis publisher ready");
});
this.subscriber.on("error", (err) => {
logger.error("Redis subscriber error:", err);
this.isHealthy = false;
});
this.subscriber.on("ready", () => {
logger.info("Redis subscriber ready");
});
// Set up connection handlers
@ -102,18 +137,65 @@ class RedisManager {
);
logger.info("Redis clients initialized successfully");
// Start periodic health monitoring
this.startHealthMonitoring();
} catch (error) {
logger.error("Failed to initialize Redis clients:", error);
this.isEnabled = false;
}
}
public isRedisEnabled(): boolean {
return this.isEnabled && this.client !== null;
private startHealthMonitoring(): void {
if (!this.isEnabled) return;
// Check health every 30 seconds
setInterval(async () => {
try {
await this.checkRedisHealth();
} catch (error) {
logger.error("Error during Redis health monitoring:", error);
}
}, this.healthCheckInterval);
}
public getClient(): Redis | null {
return this.client;
public isRedisEnabled(): boolean {
return this.isEnabled && this.client !== null && this.isHealthy;
}
private async checkRedisHealth(): Promise<boolean> {
const now = Date.now();
// Only check health every 30 seconds
if (now - this.lastHealthCheck < this.healthCheckInterval) {
return this.isHealthy;
}
this.lastHealthCheck = now;
if (!this.client) {
this.isHealthy = false;
return false;
}
try {
await Promise.race([
this.client.ping(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Health check timeout')), 2000)
)
]);
this.isHealthy = true;
return true;
} catch (error) {
logger.error("Redis health check failed:", error);
this.isHealthy = false;
return false;
}
}
public getClient(): Redis {
return this.client!;
}
public async set(
@ -247,11 +329,25 @@ class RedisManager {
public async publish(channel: string, message: string): Promise<boolean> {
if (!this.isRedisEnabled() || !this.publisher) return false;
// Quick health check before attempting to publish
const isHealthy = await this.checkRedisHealth();
if (!isHealthy) {
logger.warn("Skipping Redis publish due to unhealthy connection");
return false;
}
try {
await this.publisher.publish(channel, message);
// Add timeout to prevent hanging
await Promise.race([
this.publisher.publish(channel, message),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Redis publish timeout')), 3000)
)
]);
return true;
} catch (error) {
logger.error("Redis PUBLISH error:", error);
this.isHealthy = false; // Mark as unhealthy on error
return false;
}
}
@ -267,13 +363,19 @@ class RedisManager {
if (!this.subscribers.has(channel)) {
this.subscribers.set(channel, new Set());
// Only subscribe to the channel if it's the first subscriber
await this.subscriber.subscribe(channel);
await Promise.race([
this.subscriber.subscribe(channel),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Redis subscribe timeout')), 5000)
)
]);
}
this.subscribers.get(channel)!.add(callback);
return true;
} catch (error) {
logger.error("Redis SUBSCRIBE error:", error);
this.isHealthy = false;
return false;
}
}
@ -330,5 +432,6 @@ class RedisManager {
}
}
export const redisManager = RedisManager.getInstance();
export const redisManager = new RedisManager();
export const redis = redisManager.getClient();
export default redisManager;

View file

@ -7,7 +7,7 @@ export const domains = sqliteTable("domains", {
configManaged: integer("configManaged", { mode: "boolean" })
.notNull()
.default(false),
type: text("type"), // "ns", "cname", "a"
type: text("type") // "ns", "cname", "a"
});
export const orgs = sqliteTable("orgs", {
@ -16,6 +16,15 @@ export const orgs = sqliteTable("orgs", {
subnet: text("subnet").notNull(),
});
export const userDomains = sqliteTable("userDomains", {
userId: text("userId")
.notNull()
.references(() => users.userId, { onDelete: "cascade" }),
domainId: text("domainId")
.notNull()
.references(() => domains.domainId, { onDelete: "cascade" })
});
export const orgDomains = sqliteTable("orgDomains", {
orgId: text("orgId")
.notNull()
@ -38,9 +47,9 @@ export const sites = sqliteTable("sites", {
}),
name: text("name").notNull(),
pubKey: text("pubKey"),
subnet: text("subnet").notNull(),
megabytesIn: integer("bytesIn"),
megabytesOut: integer("bytesOut"),
subnet: text("subnet"),
megabytesIn: integer("bytesIn").default(0),
megabytesOut: integer("bytesOut").default(0),
lastBandwidthUpdate: text("lastBandwidthUpdate"),
type: text("type").notNull(), // "newt" or "wireguard"
online: integer("online", { mode: "boolean" }).notNull().default(false),
@ -48,7 +57,7 @@ export const sites = sqliteTable("sites", {
// exit node stuff that is how to connect to the site when it has a wg server
address: text("address"), // this is the address of the wireguard interface in newt
endpoint: text("endpoint"), // this is how to reach gerbil externally - gets put into the wireguard config
publicKey: text("pubicKey"), // TODO: Fix typo in publicKey
publicKey: text("publicKey"), // TODO: Fix typo in publicKey
lastHolePunch: integer("lastHolePunch"),
listenPort: integer("listenPort"),
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
@ -626,13 +635,14 @@ export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>;
export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
export type VersionMigration = InferSelectModel<typeof versionMigrations>;
export type ResourceRule = InferSelectModel<typeof resourceRules>;
export type Domain = InferSelectModel<typeof domains>;
export type Client = InferSelectModel<typeof clients>;
export type ClientSite = InferSelectModel<typeof clientSites>;
export type RoleClient = InferSelectModel<typeof roleClients>;
export type UserClient = InferSelectModel<typeof userClients>;
export type Domain = InferSelectModel<typeof domains>;
export type SupporterKey = InferSelectModel<typeof supporterKey>;
export type Idp = InferSelectModel<typeof idp>;
export type ApiKey = InferSelectModel<typeof apiKeys>;
export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>;
export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>;
export type OrgDomains = InferSelectModel<typeof orgDomains>;

View file

@ -2,6 +2,7 @@ import { render } from "@react-email/render";
import { ReactElement } from "react";
import emailClient from "@server/emails";
import logger from "@server/logger";
import config from "@server/lib/config";
export async function sendEmail(
template: ReactElement,
@ -24,9 +25,11 @@ export async function sendEmail(
const emailHtml = await render(template);
const appName = "Fossorial - Pangolin";
await emailClient.sendMail({
from: {
name: opts.name || "Pangolin",
name: opts.name || appName,
address: opts.from,
},
to: opts.to,

View file

@ -1,11 +1,5 @@
import {
Body,
Head,
Html,
Preview,
Tailwind
} from "@react-email/components";
import * as React from "react";
import React from "react";
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
import { themeColors } from "./lib/theme";
import {
EmailContainer,
@ -22,29 +16,29 @@ interface Props {
}
export const ConfirmPasswordReset = ({ email }: Props) => {
const previewText = `Your password has been reset`;
const previewText = `Your password has been successfully reset.`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind config={themeColors}>
<Body className="font-sans relative">
<Body className="font-sans bg-gray-50">
<EmailContainer>
<EmailLetterHead />
<EmailHeading>Password Reset Confirmation</EmailHeading>
{/* <EmailHeading>Password Successfully Reset</EmailHeading> */}
<EmailGreeting>Hi {email || "there"},</EmailGreeting>
<EmailGreeting>Hi there,</EmailGreeting>
<EmailText>
This email confirms that your password has just been
reset. If you made this change, no further action is
required.
Your password has been successfully reset. You can
now sign in to your account using your new password.
</EmailText>
<EmailText>
Thank you for keeping your account secure.
If you didn't make this change, please contact our
support team immediately to secure your account.
</EmailText>
<EmailFooter>

View file

@ -1,11 +1,5 @@
import {
Body,
Head,
Html,
Preview,
Tailwind
} from "@react-email/components";
import * as React from "react";
import React from "react";
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
import { themeColors } from "./lib/theme";
import {
EmailContainer,
@ -18,6 +12,7 @@ import {
EmailText
} from "./components/Email";
import CopyCodeBox from "./components/CopyCodeBox";
import ButtonLink from "./components/ButtonLink";
interface Props {
email: string;
@ -26,37 +21,39 @@ interface Props {
}
export const ResetPasswordCode = ({ email, code, link }: Props) => {
const previewText = `Your password reset code is ${code}`;
const previewText = `Reset your password with code: ${code}`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind config={themeColors}>
<Body className="font-sans">
<Body className="font-sans bg-gray-50">
<EmailContainer>
<EmailLetterHead />
<EmailHeading>Password Reset Request</EmailHeading>
{/* <EmailHeading>Reset Your Password</EmailHeading> */}
<EmailGreeting>Hi {email || "there"},</EmailGreeting>
<EmailGreeting>Hi there,</EmailGreeting>
<EmailText>
Youve requested to reset your password. Please{" "}
<a href={link} className="text-primary">
click here
</a>{" "}
and follow the instructions to reset your password,
or manually enter the following code:
You've requested to reset your password. Click the
button below to reset your password, or use the
verification code provided if prompted.
</EmailText>
<EmailSection>
<ButtonLink href={link}>Reset Password</ButtonLink>
</EmailSection>
<EmailSection>
<CopyCodeBox text={code} />
</EmailSection>
<EmailText>
If you didnt request this, you can safely ignore
this email.
This reset code will expire in 2 hours. If you
didn't request a password reset, you can safely
ignore this email.
</EmailText>
<EmailFooter>

View file

@ -1,11 +1,5 @@
import {
Body,
Head,
Html,
Preview,
Tailwind
} from "@react-email/components";
import * as React from "react";
import React from "react";
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
import {
EmailContainer,
EmailLetterHead,
@ -32,34 +26,40 @@ export const ResourceOTPCode = ({
orgName: organizationName,
otp
}: ResourceOTPCodeProps) => {
const previewText = `Your one-time password for ${resourceName} is ${otp}`;
const previewText = `Your access code for ${resourceName}: ${otp}`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind config={themeColors}>
<Body className="font-sans">
<Body className="font-sans bg-gray-50">
<EmailContainer>
<EmailLetterHead />
<EmailHeading>
Your One-Time Code for {resourceName}
</EmailHeading>
{/* <EmailHeading> */}
{/* Access Code for {resourceName} */}
{/* </EmailHeading> */}
<EmailGreeting>Hi {email || "there"},</EmailGreeting>
<EmailGreeting>Hi there,</EmailGreeting>
<EmailText>
Youve requested a one-time password to access{" "}
You've requested access to{" "}
<strong>{resourceName}</strong> in{" "}
<strong>{organizationName}</strong>. Use the code
below to complete your authentication:
<strong>{organizationName}</strong>. Use the
verification code below to complete your
authentication.
</EmailText>
<EmailSection>
<CopyCodeBox text={otp} />
</EmailSection>
<EmailText>
This code will expire in 15 minutes. If you didn't
request this code, please ignore this email.
</EmailText>
<EmailFooter>
<EmailSignature />
</EmailFooter>

View file

@ -1,11 +1,5 @@
import {
Body,
Head,
Html,
Preview,
Tailwind,
} from "@react-email/components";
import * as React from "react";
import React from "react";
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
import { themeColors } from "./lib/theme";
import {
EmailContainer,
@ -41,35 +35,44 @@ export const SendInviteLink = ({
<Head />
<Preview>{previewText}</Preview>
<Tailwind config={themeColors}>
<Body className="font-sans">
<Body className="font-sans bg-gray-50">
<EmailContainer>
<EmailLetterHead />
<EmailHeading>Invited to Join {orgName}</EmailHeading>
{/* <EmailHeading> */}
{/* You're Invited to Join {orgName} */}
{/* </EmailHeading> */}
<EmailGreeting>Hi {email || "there"},</EmailGreeting>
<EmailGreeting>Hi there,</EmailGreeting>
<EmailText>
Youve been invited to join the organization{" "}
You've been invited to join{" "}
<strong>{orgName}</strong>
{inviterName ? ` by ${inviterName}.` : "."} Please
access the link below to accept the invite.
</EmailText>
<EmailText>
This invite will expire in{" "}
<strong>
{expiresInDays}{" "}
{expiresInDays === "1" ? "day" : "days"}.
</strong>
{inviterName ? ` by ${inviterName}` : ""}. Click the
button below to accept your invitation and get
started.
</EmailText>
<EmailSection>
<ButtonLink href={inviteLink}>
Accept Invite to {orgName}
Accept Invitation
</ButtonLink>
</EmailSection>
{/* <EmailText> */}
{/* If you're having trouble clicking the button, copy */}
{/* and paste the URL below into your web browser: */}
{/* <br /> */}
{/* <span className="break-all">{inviteLink}</span> */}
{/* </EmailText> */}
<EmailText>
This invite expires in {expiresInDays}{" "}
{expiresInDays === "1" ? "day" : "days"}. If the
link has expired, please contact the owner of the
organization to request a new invitation.
</EmailText>
<EmailFooter>
<EmailSignature />
</EmailFooter>

View file

@ -1,11 +1,5 @@
import {
Body,
Head,
Html,
Preview,
Tailwind
} from "@react-email/components";
import * as React from "react";
import React from "react";
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
import { themeColors } from "./lib/theme";
import {
EmailContainer,
@ -23,44 +17,52 @@ interface Props {
}
export const TwoFactorAuthNotification = ({ email, enabled }: Props) => {
const previewText = `Two-Factor Authentication has been ${enabled ? "enabled" : "disabled"}`;
const previewText = `Two-Factor Authentication ${enabled ? "enabled" : "disabled"} for your account`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind config={themeColors}>
<Body className="font-sans">
<Body className="font-sans bg-gray-50">
<EmailContainer>
<EmailLetterHead />
<EmailHeading>
Two-Factor Authentication{" "}
{enabled ? "Enabled" : "Disabled"}
</EmailHeading>
{/* <EmailHeading> */}
{/* Security Update: 2FA{" "} */}
{/* {enabled ? "Enabled" : "Disabled"} */}
{/* </EmailHeading> */}
<EmailGreeting>Hi {email || "there"},</EmailGreeting>
<EmailGreeting>Hi there,</EmailGreeting>
<EmailText>
This email confirms that Two-Factor Authentication
has been successfully{" "}
{enabled ? "enabled" : "disabled"} on your account.
Two-factor authentication has been successfully{" "}
<strong>{enabled ? "enabled" : "disabled"}</strong>{" "}
on your account.
</EmailText>
{enabled ? (
<>
<EmailText>
With Two-Factor Authentication enabled, your
account is now more secure. Please ensure you
keep your authentication method safe.
Your account is now protected with an
additional layer of security. Keep your
authentication method safe and accessible.
</EmailText>
</>
) : (
<>
<EmailText>
With Two-Factor Authentication disabled, your
account may be less secure. We recommend
enabling it to protect your account.
We recommend re-enabling two-factor
authentication to keep your account secure.
</EmailText>
</>
)}
<EmailText>
If you didn't make this change, please contact our
support team immediately.
</EmailText>
<EmailFooter>
<EmailSignature />
</EmailFooter>

View file

@ -1,5 +1,5 @@
import React from "react";
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
import * as React from "react";
import { themeColors } from "./lib/theme";
import {
EmailContainer,
@ -24,25 +24,24 @@ export const VerifyEmail = ({
verificationCode,
verifyLink
}: VerifyEmailProps) => {
const previewText = `Your verification code is ${verificationCode}`;
const previewText = `Verify your email with code: ${verificationCode}`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind config={themeColors}>
<Body className="font-sans">
<Body className="font-sans bg-gray-50">
<EmailContainer>
<EmailLetterHead />
<EmailHeading>Please Verify Your Email</EmailHeading>
{/* <EmailHeading>Verify Your Email Address</EmailHeading> */}
<EmailGreeting>Hi {username || "there"},</EmailGreeting>
<EmailGreeting>Hi there,</EmailGreeting>
<EmailText>
Youve requested to verify your email. Please use
the code below to complete the verification process
upon logging in.
Welcome! To complete your account setup, please
verify your email address using the code below.
</EmailText>
<EmailSection>
@ -50,7 +49,8 @@ export const VerifyEmail = ({
</EmailSection>
<EmailText>
If you didnt request this, you can safely ignore
This verification code will expire in 15 minutes. If
you didn't create an account, you can safely ignore
this email.
</EmailText>

View file

@ -0,0 +1,131 @@
import React from "react";
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
import { themeColors } from "./lib/theme";
import {
EmailContainer,
EmailFooter,
EmailGreeting,
EmailHeading,
EmailLetterHead,
EmailSection,
EmailSignature,
EmailText,
EmailInfoSection
} from "./components/Email";
import ButtonLink from "./components/ButtonLink";
import CopyCodeBox from "./components/CopyCodeBox";
interface WelcomeQuickStartProps {
username?: string;
link: string;
fallbackLink: string;
resourceMethod: string;
resourceHostname: string;
resourcePort: string | number;
resourceUrl: string;
cliCommand: string;
}
export const WelcomeQuickStart = ({
username,
link,
fallbackLink,
resourceMethod,
resourceHostname,
resourcePort,
resourceUrl,
cliCommand
}: WelcomeQuickStartProps) => {
const previewText = "Welcome! Here's what to do next";
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind config={themeColors}>
<Body className="font-sans bg-gray-50">
<EmailContainer>
<EmailLetterHead />
<EmailGreeting>Hi there,</EmailGreeting>
<EmailText>
Thank you for trying out Pangolin! We're excited to
have you on board.
</EmailText>
<EmailText>
To continue to configure your site, resources, and
other features, complete your account setup to
access the full dashboard.
</EmailText>
<EmailSection>
<ButtonLink href={link}>
View Your Dashboard
</ButtonLink>
{/* <p className="text-sm text-gray-300 mt-2"> */}
{/* If the button above doesn't work, you can also */}
{/* use this{" "} */}
{/* <a href={fallbackLink} className="underline"> */}
{/* link */}
{/* </a> */}
{/* . */}
{/* </p> */}
</EmailSection>
<EmailSection>
<div className="mb-2 font-semibold text-gray-900 text-base text-left">
Connect your site using Newt
</div>
<div className="inline-block w-full">
<div className="bg-gray-50 border border-gray-200 rounded-lg px-6 py-4 mx-auto text-left">
<span className="text-sm font-mono text-gray-900 tracking-wider">
{cliCommand}
</span>
</div>
<p className="text-xs text-gray-500 mt-2">
To learn how to use Newt, including more
installation methods, visit the{" "}
<a
href="https://docs.fossorial.io"
className="underline"
>
docs
</a>
.
</p>
</div>
</EmailSection>
<EmailInfoSection
title="Your Demo Resource"
items={[
{ label: "Method", value: resourceMethod },
{ label: "Hostname", value: resourceHostname },
{ label: "Port", value: resourcePort },
{
label: "Resource URL",
value: (
<a
href={resourceUrl}
className="underline text-blue-600"
>
{resourceUrl}
</a>
)
}
]}
/>
<EmailFooter>
<EmailSignature />
</EmailFooter>
</EmailContainer>
</Body>
</Tailwind>
</Html>
);
};
export default WelcomeQuickStart;

View file

@ -12,7 +12,11 @@ export default function ButtonLink({
return (
<a
href={href}
className={`rounded-full bg-primary px-4 py-2 text-center font-semibold text-white text-xl no-underline inline-block ${className}`}
className={`inline-block bg-primary hover:bg-primary/90 text-white font-semibold px-8 py-3 rounded-lg text-center no-underline transition-colors ${className}`}
style={{
backgroundColor: "#F97316",
textDecoration: "none"
}}
>
{children}
</a>

View file

@ -2,10 +2,15 @@ import React from "react";
export default function CopyCodeBox({ text }: { text: string }) {
return (
<div className="text-center rounded-lg bg-neutral-100 p-2">
<span className="text-2xl font-mono text-neutral-600 tracking-wide">
<div className="inline-block">
<div className="bg-gray-50 border border-gray-200 rounded-lg px-6 py-4 mx-auto">
<span className="text-2xl font-mono text-gray-900 tracking-wider font-semibold">
{text}
</span>
</div>
<p className="text-xs text-gray-500 mt-2">
Copy and paste this code when prompted
</p>
</div>
);
}

View file

@ -1,47 +1,26 @@
import { Container } from "@react-email/components";
import React from "react";
import { Container, Img } from "@react-email/components";
// EmailContainer: Wraps the entire email layout
export function EmailContainer({ children }: { children: React.ReactNode }) {
return (
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
<Container className="bg-white border border-solid border-gray-200 max-w-lg mx-auto my-8 rounded-lg overflow-hidden shadow-sm">
{children}
</Container>
);
}
// EmailLetterHead: For branding or logo at the top
// EmailLetterHead: For branding with logo on dark background
export function EmailLetterHead() {
return (
<div className="mb-4">
<table
role="presentation"
width="100%"
style={{
marginBottom: "24px"
}}
>
<tr>
<td
style={{
fontSize: "14px",
fontWeight: "bold",
color: "#F97317"
}}
>
Pangolin
</td>
<td
style={{
fontSize: "14px",
textAlign: "right",
color: "#6B7280"
}}
>
{new Date().getFullYear()}
</td>
</tr>
</table>
<div className="px-6 pt-8 pb-2 text-center">
<Img
src="https://fossorial-public-assets.s3.us-east-1.amazonaws.com/word_mark_black.png"
alt="Fossorial"
width="120"
height="auto"
className="mx-auto"
/>
</div>
);
}
@ -49,14 +28,22 @@ export function EmailLetterHead() {
// EmailHeading: For the primary message or headline
export function EmailHeading({ children }: { children: React.ReactNode }) {
return (
<h1 className="text-2xl font-semibold text-gray-800 text-center">
<div className="px-6 pt-4 pb-1">
<h1 className="text-2xl font-semibold text-gray-900 text-center leading-tight">
{children}
</h1>
</div>
);
}
export function EmailGreeting({ children }: { children: React.ReactNode }) {
return <p className="text-base text-gray-700 my-4">{children}</p>;
return (
<div className="px-6">
<p className="text-base text-gray-700 leading-relaxed">
{children}
</p>
</div>
);
}
// EmailText: For general text content
@ -68,9 +55,13 @@ export function EmailText({
className?: string;
}) {
return (
<p className={`my-2 text-base text-gray-700 ${className}`}>
<div className="px-6">
<p
className={`text-base text-gray-700 leading-relaxed ${className}`}
>
{children}
</p>
</div>
);
}
@ -82,20 +73,70 @@ export function EmailSection({
children: React.ReactNode;
className?: string;
}) {
return <div className={`text-center my-6 ${className}`}>{children}</div>;
return (
<div className={`px-6 py-6 text-center ${className}`}>{children}</div>
);
}
// EmailFooter: For closing or signature
export function EmailFooter({ children }: { children: React.ReactNode }) {
return <div className="text-sm text-gray-500 mt-6">{children}</div>;
return (
<div className="px-6 py-6 border-t border-gray-100 bg-gray-50">
{children}
<p className="text-xs text-gray-400 mt-4">
For any questions or support, please contact us at:
<br />
support@fossorial.io
</p>
<p className="text-xs text-gray-300 text-center mt-4">
&copy; {new Date().getFullYear()} Fossorial, Inc. All rights
reserved.
</p>
</div>
);
}
export function EmailSignature() {
return (
<p>
<div className="text-sm text-gray-600">
<p className="mb-2">
Best regards,
<br />
Fossorial
<strong>The Fossorial Team</strong>
</p>
</div>
);
}
// EmailInfoSection: For structured key-value info (like resource details)
export function EmailInfoSection({
title,
items
}: {
title?: string;
items: { label: string; value: React.ReactNode }[];
}) {
return (
<div className="px-6 py-4">
{title && (
<div className="mb-2 font-semibold text-gray-900 text-base">
{title}
</div>
)}
<table className="w-full text-sm text-left">
<tbody>
{items.map((item, idx) => (
<tr key={idx}>
<td className="pr-4 py-1 text-gray-600 align-top whitespace-nowrap">
{item.label}
</td>
<td className="py-1 text-gray-900 break-all">
{item.value}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View file

@ -1,3 +1,5 @@
import React from "react";
export const themeColors = {
theme: {
extend: {

View file

@ -113,7 +113,9 @@ export class Config {
private async checkKeyStatus() {
const licenseStatus = await license.check();
if (!licenseStatus.isHostLicensed) {
if (
!licenseStatus.isHostLicensed
) {
this.checkSupporterKey();
}
}

View file

@ -14,7 +14,7 @@ type IPVersion = 4 | 6;
* Detects IP version from address string
*/
function detectIpVersion(ip: string): IPVersion {
return ip.includes(':') ? 6 : 4;
return ip.includes(":") ? 6 : 4;
}
/**
@ -24,8 +24,7 @@ function ipToBigInt(ip: string): bigint {
const version = detectIpVersion(ip);
if (version === 4) {
return ip.split('.')
.reduce((acc, octet) => {
return ip.split(".").reduce((acc, octet) => {
const num = parseInt(octet);
if (isNaN(num) || num < 0 || num > 255) {
throw new Error(`Invalid IPv4 octet: ${octet}`);
@ -36,17 +35,18 @@ function ipToBigInt(ip: string): bigint {
// Handle IPv6
// Expand :: notation
let fullAddress = ip;
if (ip.includes('::')) {
const parts = ip.split('::');
if (parts.length > 2) throw new Error('Invalid IPv6 address: multiple :: found');
const missing = 8 - (parts[0].split(':').length + parts[1].split(':').length);
const padding = Array(missing).fill('0').join(':');
if (ip.includes("::")) {
const parts = ip.split("::");
if (parts.length > 2)
throw new Error("Invalid IPv6 address: multiple :: found");
const missing =
8 - (parts[0].split(":").length + parts[1].split(":").length);
const padding = Array(missing).fill("0").join(":");
fullAddress = `${parts[0]}:${padding}:${parts[1]}`;
}
return fullAddress.split(':')
.reduce((acc, hextet) => {
const num = parseInt(hextet || '0', 16);
return fullAddress.split(":").reduce((acc, hextet) => {
const num = parseInt(hextet || "0", 16);
if (isNaN(num) || num < 0 || num > 65535) {
throw new Error(`Invalid IPv6 hextet: ${hextet}`);
}
@ -65,11 +65,15 @@ function bigIntToIp(num: bigint, version: IPVersion): string {
octets.unshift(Number(num & BigInt(255)));
num = num >> BigInt(8);
}
return octets.join('.');
return octets.join(".");
} else {
const hextets: string[] = [];
for (let i = 0; i < 8; i++) {
hextets.unshift(Number(num & BigInt(65535)).toString(16).padStart(4, '0'));
hextets.unshift(
Number(num & BigInt(65535))
.toString(16)
.padStart(4, "0")
);
num = num >> BigInt(16);
}
// Compress zero sequences
@ -79,7 +83,7 @@ function bigIntToIp(num: bigint, version: IPVersion): string {
let currentZeroLength = 0;
for (let i = 0; i < hextets.length; i++) {
if (hextets[i] === '0000') {
if (hextets[i] === "0000") {
if (currentZeroStart === -1) currentZeroStart = i;
currentZeroLength++;
if (currentZeroLength > maxZeroLength) {
@ -93,12 +97,14 @@ function bigIntToIp(num: bigint, version: IPVersion): string {
}
if (maxZeroLength > 1) {
hextets.splice(maxZeroStart, maxZeroLength, '');
if (maxZeroStart === 0) hextets.unshift('');
if (maxZeroStart + maxZeroLength === 8) hextets.push('');
hextets.splice(maxZeroStart, maxZeroLength, "");
if (maxZeroStart === 0) hextets.unshift("");
if (maxZeroStart + maxZeroLength === 8) hextets.push("");
}
return hextets.map(h => h === '0000' ? '0' : h.replace(/^0+/, '')).join(':');
return hextets
.map((h) => (h === "0000" ? "0" : h.replace(/^0+/, "")))
.join(":");
}
}
@ -106,7 +112,7 @@ function bigIntToIp(num: bigint, version: IPVersion): string {
* Converts CIDR to IP range
*/
export function cidrToRange(cidr: string): IPRange {
const [ip, prefix] = cidr.split('/');
const [ip, prefix] = cidr.split("/");
const version = detectIpVersion(ip);
const prefixBits = parseInt(prefix);
const ipBigInt = ipToBigInt(ip);
@ -118,7 +124,10 @@ export function cidrToRange(cidr: string): IPRange {
}
const shiftBits = BigInt(maxPrefix - prefixBits);
const mask = BigInt.asUintN(version === 4 ? 64 : 128, (BigInt(1) << shiftBits) - BigInt(1));
const mask = BigInt.asUintN(
version === 4 ? 64 : 128,
(BigInt(1) << shiftBits) - BigInt(1)
);
const start = ipBigInt & ~mask;
const end = start | mask;
@ -142,17 +151,19 @@ export function findNextAvailableCidr(
}
// If no existing CIDRs, use the IP version from startCidr
const version = startCidr
? detectIpVersion(startCidr.split('/')[0])
: 4; // Default to IPv4 if no startCidr provided
const version = startCidr ? detectIpVersion(startCidr.split("/")[0]) : 4; // Default to IPv4 if no startCidr provided
// Use appropriate default startCidr if none provided
startCidr = startCidr || (version === 4 ? "0.0.0.0/0" : "::/0");
// If there are existing CIDRs, ensure all are same version
if (existingCidrs.length > 0 &&
existingCidrs.some(cidr => detectIpVersion(cidr.split('/')[0]) !== version)) {
throw new Error('All CIDRs must be of the same IP version');
if (
existingCidrs.length > 0 &&
existingCidrs.some(
(cidr) => detectIpVersion(cidr.split("/")[0]) !== version
)
) {
throw new Error("All CIDRs must be of the same IP version");
}
// Extract the network part from startCidr to ensure we stay in the right subnet
@ -160,7 +171,7 @@ export function findNextAvailableCidr(
// Convert existing CIDRs to ranges and sort them
const existingRanges = existingCidrs
.map(cidr => cidrToRange(cidr))
.map((cidr) => cidrToRange(cidr))
.sort((a, b) => (a.start < b.start ? -1 : 1));
// Calculate block size
@ -176,7 +187,9 @@ export function findNextAvailableCidr(
const nextRange = existingRanges[i];
// Align current to block size
const alignedCurrent = current + ((blockSizeBigInt - (current % blockSizeBigInt)) % blockSizeBigInt);
const alignedCurrent =
current +
((blockSizeBigInt - (current % blockSizeBigInt)) % blockSizeBigInt);
// Check if we've gone beyond the maximum allowed IP
if (alignedCurrent + blockSizeBigInt - BigInt(1) > maxIp) {
@ -184,7 +197,10 @@ export function findNextAvailableCidr(
}
// If we're at the end of existing ranges or found a gap
if (!nextRange || alignedCurrent + blockSizeBigInt - BigInt(1) < nextRange.start) {
if (
!nextRange ||
alignedCurrent + blockSizeBigInt - BigInt(1) < nextRange.start
) {
return `${bigIntToIp(alignedCurrent, version)}/${blockSize}`;
}
@ -206,7 +222,7 @@ export function findNextAvailableCidr(
*/
export function isIpInCidr(ip: string, cidr: string): boolean {
const ipVersion = detectIpVersion(ip);
const cidrVersion = detectIpVersion(cidr.split('/')[0]);
const cidrVersion = detectIpVersion(cidr.split("/")[0]);
// If IP versions don't match, the IP cannot be in the CIDR range
if (ipVersion !== cidrVersion) {
@ -219,11 +235,10 @@ export function isIpInCidr(ip: string, cidr: string): boolean {
return ipBigInt >= range.start && ipBigInt <= range.end;
}
export async function getNextAvailableClientSubnet(orgId: string): Promise<string> {
const [org] = await db
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId));
export async function getNextAvailableClientSubnet(
orgId: string
): Promise<string> {
const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId));
const existingAddressesSites = await db
.select({
@ -240,15 +255,15 @@ export async function getNextAvailableClientSubnet(orgId: string): Promise<strin
.where(and(isNotNull(clients.subnet), eq(clients.orgId, orgId)));
const addresses = [
...existingAddressesSites.map((site) => `${site.address?.split("/")[0]}/32`), // we are overriding the 32 so that we pick individual addresses in the subnet of the org for the site and the client even though they are stored with the /block_size of the org
...existingAddressesClients.map((client) => `${client.address.split("/")}/32`)
...existingAddressesSites.map(
(site) => `${site.address?.split("/")[0]}/32`
), // we are overriding the 32 so that we pick individual addresses in the subnet of the org for the site and the client even though they are stored with the /block_size of the org
...existingAddressesClients.map(
(client) => `${client.address.split("/")}/32`
)
].filter((address) => address !== null) as string[];
let subnet = findNextAvailableCidr(
addresses,
32,
org.subnet
); // pick the sites address in the org
let subnet = findNextAvailableCidr(addresses, 32, org.subnet); // pick the sites address in the org
if (!subnet) {
throw new Error("No available subnets remaining in space");
}

View file

@ -133,7 +133,10 @@ export const configSchema = z
db: z.number().int().nonnegative().optional().default(0),
tls: z
.object({
reject_unauthorized: z.boolean().optional().default(true)
reject_unauthorized: z
.boolean()
.optional()
.default(true)
})
.optional()
})
@ -226,9 +229,9 @@ export const configSchema = z
disable_local_sites: z.boolean().optional(),
disable_basic_wireguard_sites: z.boolean().optional(),
disable_config_managed_domains: z.boolean().optional(),
enable_clients: z.boolean().optional()
enable_clients: z.boolean().optional(),
})
.optional()
.optional(),
})
.refine(
(data) => {

File diff suppressed because it is too large Load diff

View file

@ -14,10 +14,17 @@ export * from "./verifyAdmin";
export * from "./verifySetResourceUsers";
export * from "./verifyUserInRole";
export * from "./verifyAccessTokenAccess";
export * from "./requestTimeout";
export * from "./verifyClientAccess";
export * from "./verifyUserHasAction";
export * from "./verifyUserIsServerAdmin";
export * from "./verifyIsLoggedInUser";
export * from "./verifyIsLoggedInUser";
export * from "./verifyClientAccess";
export * from "./integration";
export * from "./verifyValidLicense";
export * from "./verifyUserHasAction";
export * from "./verifyApiKeyAccess";
export * from "./verifyDomainAccess";
export * from "./verifyClientsEnabled";
export * from "./verifyUserIsOrgOwner";

View file

@ -0,0 +1,35 @@
import { Request, Response, NextFunction } from 'express';
import logger from '@server/logger';
import createHttpError from 'http-errors';
import HttpCode from '@server/types/HttpCode';
export function requestTimeoutMiddleware(timeoutMs: number = 30000) {
return (req: Request, res: Response, next: NextFunction) => {
// Set a timeout for the request
const timeout = setTimeout(() => {
if (!res.headersSent) {
logger.error(`Request timeout: ${req.method} ${req.url} from ${req.ip}`);
return next(
createHttpError(
HttpCode.REQUEST_TIMEOUT,
'Request timeout - operation took too long to complete'
)
);
}
}, timeoutMs);
// Clear timeout when response finishes
res.on('finish', () => {
clearTimeout(timeout);
});
// Clear timeout when response closes
res.on('close', () => {
clearTimeout(timeout);
});
next();
};
}
export default requestTimeoutMiddleware;

View file

@ -0,0 +1,93 @@
import { Request, Response, NextFunction } from "express";
import { db, domains, orgDomains } from "@server/db";
import { userOrgs, apiKeyOrg } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
export async function verifyDomainAccess(
req: Request,
res: Response,
next: NextFunction
) {
try {
const userId = req.user!.userId;
const domainId =
req.params.domainId || req.body.apiKeyId || req.query.apiKeyId;
const orgId = req.params.orgId;
if (!userId) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
);
}
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
if (!domainId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid domain ID")
);
}
const [domain] = await db
.select()
.from(domains)
.innerJoin(orgDomains, eq(orgDomains.domainId, domains.domainId))
.where(
and(
eq(orgDomains.domainId, domainId),
eq(orgDomains.orgId, orgId)
)
)
.limit(1);
if (!domain.orgDomains) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Domain with ID ${domainId} not found`
)
);
}
if (!req.userOrg) {
const userOrgRole = await db
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.userId, userId),
eq(userOrgs.orgId, apiKeyOrg.orgId)
)
)
.limit(1);
req.userOrg = userOrgRole[0];
}
if (!req.userOrg) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
)
);
}
const userOrgRoleId = req.userOrg.roleId;
req.userOrgRoleId = userOrgRoleId;
return next();
} catch (error) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying domain access"
)
);
}
}

View file

@ -14,5 +14,6 @@ export enum OpenAPITags {
AccessToken = "Access Token",
Idp = "Identity Provider",
Client = "Client",
ApiKey = "API Key"
ApiKey = "API Key",
Domain = "Domain"
}

View file

@ -12,6 +12,7 @@ import { createTOTPKeyURI } from "oslo/otp";
import logger from "@server/logger";
import { verifyPassword } from "@server/auth/password";
import { unauthorized } from "@server/auth/unauthorizedResponse";
import config from "@server/lib/config";
import { UserType } from "@server/types/UserTypes";
export const requestTotpSecretBody = z
@ -73,7 +74,11 @@ export async function requestTotpSecret(
const hex = crypto.getRandomValues(new Uint8Array(20));
const secret = encodeHex(hex);
const uri = createTOTPKeyURI("Pangolin", user.email!, hex);
const uri = createTOTPKeyURI(
"Pangolin",
user.email!,
hex
);
await db
.update(users)

View file

@ -57,8 +57,6 @@ export async function signup(
const { email, password, inviteToken, inviteId } = parsedBody.data;
logger.debug("signup", { email, password, inviteToken, inviteId });
const passwordHash = await hashPassword(password);
const userId = generateId(15);

View file

@ -4,7 +4,7 @@ import { z } from "zod";
import { fromError } from "zod-validation-error";
import HttpCode from "@server/types/HttpCode";
import { response } from "@server/lib";
import { db } from "@server/db";
import { db, userOrgs } from "@server/db";
import { User, emailVerificationCodes, users } from "@server/db";
import { eq } from "drizzle-orm";
import { isWithinExpirationDate } from "oslo";

View file

@ -94,7 +94,7 @@ export async function createClient(
const { orgId } = parsedParams.data;
if (!req.userOrgRoleId) {
if (req.user && !req.userOrgRoleId) {
return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
);
@ -208,7 +208,7 @@ export async function createClient(
clientId: newClient.clientId
});
if (req.userOrgRoleId != adminRole[0].roleId) {
if (req.user && req.userOrgRoleId != adminRole[0].roleId) {
// make sure the user can access the site
trx.insert(userClients).values({
userId: req.user?.userId!,

View file

@ -126,7 +126,7 @@ export async function listClients(
}
const { orgId } = parsedParams.data;
if (orgId && orgId !== req.userOrgId) {
if (req.user && orgId && orgId !== req.userOrgId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
@ -135,7 +135,9 @@ export async function listClients(
);
}
const accessibleClients = await db
let accessibleClients;
if (req.user) {
accessibleClients = await db
.select({
clientId: sql<number>`COALESCE(${userClients.clientId}, ${roleClients.clientId})`
})
@ -150,6 +152,12 @@ export async function listClients(
eq(roleClients.roleId, req.userOrgRoleId!)
)
);
} else {
accessibleClients = await db
.select({ clientId: clients.clientId })
.from(clients)
.where(eq(clients.orgId, orgId));
}
const accessibleClientIds = accessibleClients.map(
(client) => client.clientId

View file

@ -7,6 +7,7 @@ import { generateId } from "@server/auth/sessions/app";
import { getNextAvailableClientSubnet } from "@server/lib/ip";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
export type PickClientDefaultsResponse = {
olmId: string;
@ -20,6 +21,17 @@ const pickClientDefaultsSchema = z
})
.strict();
registry.registerPath({
method: "get",
path: "/site/{siteId}/pick-client-defaults",
description: "Return pre-requisite data for creating a client.",
tags: [OpenAPITags.Client, OpenAPITags.Site],
request: {
params: pickClientDefaultsSchema
},
responses: {}
});
export async function pickClientDefaults(
req: Request,
res: Response,

View file

@ -0,0 +1,252 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, Domain, domains, OrgDomains, orgDomains } from "@server/db";
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";
import { subdomainSchema } from "@server/lib/schemas";
import { generateId } from "@server/auth/sessions/app";
import { eq, and } from "drizzle-orm";
import { isValidDomain } from "@server/lib/validators";
const paramsSchema = z
.object({
orgId: z.string()
})
.strict();
const bodySchema = z
.object({
type: z.enum(["ns", "cname"]),
baseDomain: subdomainSchema
})
.strict();
export type CreateDomainResponse = {
domainId: string;
nsRecords?: string[];
cnameRecords?: { baseDomain: string; value: string }[];
txtRecords?: { baseDomain: string; value: string }[];
};
// Helper to check if a domain is a subdomain or equal to another domain
function isSubdomainOrEqual(a: string, b: string): boolean {
const aParts = a.toLowerCase().split(".");
const bParts = b.toLowerCase().split(".");
if (aParts.length < bParts.length) return false;
return aParts.slice(-bParts.length).join(".") === bParts.join(".");
}
export async function createOrgDomain(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
const { type, baseDomain } = parsedBody.data;
// Validate organization exists
if (!isValidDomain(baseDomain)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid domain format")
);
}
let numOrgDomains: OrgDomains[] | undefined;
let cnameRecords: CreateDomainResponse["cnameRecords"];
let txtRecords: CreateDomainResponse["txtRecords"];
let nsRecords: CreateDomainResponse["nsRecords"];
let returned: Domain | undefined;
await db.transaction(async (trx) => {
const [existing] = await trx
.select()
.from(domains)
.where(
and(
eq(domains.baseDomain, baseDomain),
eq(domains.type, type)
)
)
.leftJoin(
orgDomains,
eq(orgDomains.domainId, domains.domainId)
);
if (existing) {
const {
domains: existingDomain,
orgDomains: existingOrgDomain
} = existing;
// user alrady added domain to this account
// always reject
if (existingOrgDomain?.orgId === orgId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Domain is already added to this org"
)
);
}
// domain already exists elsewhere
// check if it's already fully verified
if (existingDomain.verified) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Domain is already verified to an org"
)
);
}
}
// --- Domain overlap logic ---
// Only consider existing verified domains
const verifiedDomains = await trx
.select()
.from(domains)
.where(eq(domains.verified, true));
if (type === "cname") {
// Block if a verified CNAME exists at the same name
const cnameExists = verifiedDomains.some(
(d) => d.type === "cname" && d.baseDomain === baseDomain
);
if (cnameExists) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`A CNAME record already exists for ${baseDomain}. Only one CNAME record is allowed per domain.`
)
);
}
// Block if a verified NS exists at or below (same or subdomain)
const nsAtOrBelow = verifiedDomains.some(
(d) =>
d.type === "ns" &&
(isSubdomainOrEqual(baseDomain, d.baseDomain) ||
baseDomain === d.baseDomain)
);
if (nsAtOrBelow) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`A nameserver (NS) record exists at or below ${baseDomain}. You cannot create a CNAME record here.`
)
);
}
} else if (type === "ns") {
// Block if a verified NS exists at or below (same or subdomain)
const nsAtOrBelow = verifiedDomains.some(
(d) =>
d.type === "ns" &&
(isSubdomainOrEqual(baseDomain, d.baseDomain) ||
baseDomain === d.baseDomain)
);
if (nsAtOrBelow) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`A nameserver (NS) record already exists at or below ${baseDomain}. You cannot create another NS record here.`
)
);
}
}
const domainId = generateId(15);
const [insertedDomain] = await trx
.insert(domains)
.values({
domainId,
baseDomain,
type
})
.returning();
returned = insertedDomain;
// add domain to account
await trx
.insert(orgDomains)
.values({
orgId,
domainId
})
.returning();
// TODO: This needs to be cross region and not hardcoded
if (type === "ns") {
nsRecords = ["ns-east.fossorial.io", "ns-west.fossorial.io"];
} else if (type === "cname") {
cnameRecords = [
{
value: `${domainId}.cname.fossorial.io`,
baseDomain: baseDomain
},
{
value: `_acme-challenge.${domainId}.cname.fossorial.io`,
baseDomain: `_acme-challenge.${baseDomain}`
}
];
}
numOrgDomains = await trx
.select()
.from(orgDomains)
.where(eq(orgDomains.orgId, orgId));
});
if (!returned) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to create domain"
)
);
}
return response<CreateDomainResponse>(res, {
data: {
domainId: returned.domainId,
cnameRecords,
txtRecords,
nsRecords
},
success: true,
error: false,
message: "Domain created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -0,0 +1,72 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, domains, OrgDomains, orgDomains } from "@server/db";
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";
import { and, eq } from "drizzle-orm";
const paramsSchema = z
.object({
domainId: z.string(),
orgId: z.string()
})
.strict();
export type DeleteAccountDomainResponse = {
success: boolean;
};
export async function deleteAccountDomain(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsed = paramsSchema.safeParse(req.params);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsed.error).toString()
)
);
}
const { domainId, orgId } = parsed.data;
let numOrgDomains: OrgDomains[] | undefined;
await db.transaction(async (trx) => {
await trx
.delete(orgDomains)
.where(
and(
eq(orgDomains.orgId, orgId),
eq(orgDomains.domainId, domainId)
)
);
await trx.delete(domains).where(eq(domains.domainId, domainId));
numOrgDomains = await trx
.select()
.from(orgDomains)
.where(eq(orgDomains.orgId, orgId));
});
return response<DeleteAccountDomainResponse>(res, {
data: { success: true },
success: true,
error: false,
message: "Domain deleted from account successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -1 +1,4 @@
export * from "./listDomains";
export * from "./createOrgDomain";
export * from "./deleteOrgDomain";
export * from "./restartOrgDomain";

View file

@ -37,7 +37,11 @@ async function queryDomains(orgId: string, limit: number, offset: number) {
const res = await db
.select({
domainId: domains.domainId,
baseDomain: domains.baseDomain
baseDomain: domains.baseDomain,
verified: domains.verified,
type: domains.type,
failed: domains.failed,
tries: domains.tries,
})
.from(orgDomains)
.where(eq(orgDomains.orgId, orgId))
@ -112,7 +116,7 @@ export async function listDomains(
},
success: true,
error: false,
message: "Users retrieved successfully",
message: "Domains retrieved successfully",
status: HttpCode.OK
});
} catch (error) {

View file

@ -0,0 +1,57 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, domains } from "@server/db";
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";
import { and, eq } from "drizzle-orm";
const paramsSchema = z
.object({
domainId: z.string(),
orgId: z.string()
})
.strict();
export type RestartOrgDomainResponse = {
success: boolean;
};
export async function restartOrgDomain(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsed = paramsSchema.safeParse(req.params);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsed.error).toString()
)
);
}
const { domainId, orgId } = parsed.data;
await db
.update(domains)
.set({ failed: false, tries: 0 })
.where(and(eq(domains.domainId, domainId)));
return response<RestartOrgDomainResponse>(res, {
data: { success: true },
success: true,
error: false,
message: "Domain restarted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -33,15 +33,16 @@ import {
verifyClientAccess,
verifyApiKeyAccess,
createStore,
verifyDomainAccess,
verifyClientsEnabled,
verifyUserHasAction,
verifyUserIsOrgOwner
} from "@server/middlewares";
import { verifyUserHasAction } from "../middlewares/verifyUserHasAction";
import { ActionsEnum } from "@server/auth/actions";
import { verifyUserIsOrgOwner } from "../middlewares/verifyUserIsOrgOwner";
import { createNewt, getNewtToken } from "./newt";
import { getOlmToken } from "./olm";
import rateLimit from "express-rate-limit";
import createHttpError from "http-errors";
import { verifyClientsEnabled } from "@server/middlewares/verifyClintsEnabled";
// Root routes
export const unauthenticated = Router();
@ -54,10 +55,7 @@ unauthenticated.get("/", (_, res) => {
export const authenticated = Router();
authenticated.use(verifySessionUserMiddleware);
authenticated.get(
"/pick-org-defaults",
org.pickOrgDefaults
);
authenticated.get("/pick-org-defaults", org.pickOrgDefaults);
authenticated.get("/org/checkId", org.checkId);
authenticated.put("/org", getUserOrgs, org.createOrg);
@ -750,6 +748,29 @@ authenticated.get(
apiKeys.getApiKey
);
authenticated.put(
`/org/:orgId/domain`,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createOrgDomain),
domain.createOrgDomain
);
authenticated.post(
`/org/:orgId/domain/:domainId/restart`,
verifyOrgAccess,
verifyDomainAccess,
verifyUserHasAction(ActionsEnum.restartOrgDomain),
domain.restartOrgDomain
);
authenticated.delete(
`/org/:orgId/domain/:domainId`,
verifyOrgAccess,
verifyDomainAccess,
verifyUserHasAction(ActionsEnum.deleteOrgDomain),
domain.deleteAccountDomain
);
// Auth routes
export const authRouter = Router();
unauthenticated.use("/auth", authRouter);

View file

@ -1,5 +1,5 @@
import { Request, Response, NextFunction } from "express";
import { eq, and, lt, inArray } from "drizzle-orm";
import { eq, and, lt, inArray, sql } from "drizzle-orm";
import { sites } from "@server/db";
import { db } from "@server/db";
import logger from "@server/logger";
@ -7,6 +7,9 @@ import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response";
// Track sites that are already offline to avoid unnecessary queries
const offlineSites = new Set<string>();
interface PeerBandwidth {
publicKey: string;
bytesIn: number;
@ -28,42 +31,61 @@ export const receiveBandwidth = async (
const currentTime = new Date();
const oneMinuteAgo = new Date(currentTime.getTime() - 60000); // 1 minute ago
logger.debug(`Received data: ${JSON.stringify(bandwidthData)}`);
await db.transaction(async (trx) => {
// First, handle sites that are actively reporting bandwidth
const activePeers = bandwidthData.filter(peer => peer.bytesIn > 0 || peer.bytesOut > 0);
const activePeers = bandwidthData.filter(peer => peer.bytesIn > 0); // Bytesout will have data as it tries to send keep alive messages
if (activePeers.length > 0) {
// Get all active sites in one query
const activeSites = await trx
.select()
.from(sites)
.where(inArray(sites.pubKey, activePeers.map(p => p.publicKey)));
// Remove any active peers from offline tracking since they're sending data
activePeers.forEach(peer => offlineSites.delete(peer.publicKey));
// Create a map for quick lookup
const siteMap = new Map();
activeSites.forEach(site => {
siteMap.set(site.pubKey, site);
});
// Aggregate usage data by organization
const orgUsageMap = new Map<string, number>();
const orgUptimeMap = new Map<string, number>();
// Update sites with actual bandwidth usage
// Update all active sites with bandwidth data and get the site data in one operation
const updatedSites = [];
for (const peer of activePeers) {
const site = siteMap.get(peer.publicKey);
if (!site) continue;
await trx
const updatedSite = await trx
.update(sites)
.set({
megabytesOut: (site.megabytesOut || 0) + peer.bytesIn,
megabytesIn: (site.megabytesIn || 0) + peer.bytesOut,
megabytesOut: sql`${sites.megabytesOut} + ${peer.bytesIn}`,
megabytesIn: sql`${sites.megabytesIn} + ${peer.bytesOut}`,
lastBandwidthUpdate: currentTime.toISOString(),
online: true
})
.where(eq(sites.siteId, site.siteId));
.where(eq(sites.pubKey, peer.publicKey))
.returning({
online: sites.online,
orgId: sites.orgId,
siteId: sites.siteId,
lastBandwidthUpdate: sites.lastBandwidthUpdate,
});
if (updatedSite.length > 0) {
updatedSites.push({ ...updatedSite[0], peer });
}
}
// Calculate org usage aggregations using the updated site data
for (const { peer, ...site } of updatedSites) {
// Aggregate bandwidth usage for the org
const totalBandwidth = peer.bytesIn + peer.bytesOut;
const currentOrgUsage = orgUsageMap.get(site.orgId) || 0;
orgUsageMap.set(site.orgId, currentOrgUsage + totalBandwidth);
// Add 10 seconds of uptime for each active site
const currentOrgUptime = orgUptimeMap.get(site.orgId) || 0;
orgUptimeMap.set(site.orgId, currentOrgUptime + 10 / 60); // Store in minutes and jut add 10 seconds
}
}
// Handle sites that reported zero bandwidth but need online status updated
const zeroBandwidthPeers = bandwidthData.filter(peer => peer.bytesIn === 0 && peer.bytesOut === 0);
const zeroBandwidthPeers = bandwidthData.filter(peer =>
peer.bytesIn === 0 && !offlineSites.has(peer.publicKey) // Bytesout will have data as it tries to send keep alive messages
);
if (zeroBandwidthPeers.length > 0) {
const zeroBandwidthSites = await trx
@ -91,18 +113,14 @@ export const receiveBandwidth = async (
await trx
.update(sites)
.set({
lastBandwidthUpdate: currentTime.toISOString(),
online: newOnlineStatus
})
.where(eq(sites.siteId, site.siteId));
} else {
// Just update the heartbeat timestamp
await trx
.update(sites)
.set({
lastBandwidthUpdate: currentTime.toISOString()
})
.where(eq(sites.siteId, site.siteId));
// If site went offline, add it to our tracking set
if (!newOnlineStatus && site.pubKey) {
offlineSites.add(site.pubKey);
}
}
}
}

View file

@ -11,6 +11,7 @@ import {
idpOidcConfig,
idpOrg,
orgs,
Role,
roles,
userOrgs,
users
@ -307,6 +308,8 @@ export async function validateOidcCallback(
let existingUserId = existingUser?.userId;
let orgUserCounts: { orgId: string; userCount: number }[] = [];
// sync the user with the orgs and roles
await db.transaction(async (trx) => {
let userId = existingUser?.userId;
@ -410,6 +413,19 @@ export async function validateOidcCallback(
}))
);
}
// Loop through all the orgs and get the total number of users from the userOrgs table
for (const org of currentUserOrgs) {
const userCount = await trx
.select()
.from(userOrgs)
.where(eq(userOrgs.orgId, org.orgId));
orgUserCounts.push({
orgId: org.orgId,
userCount: userCount.length
});
}
});
const token = generateSessionToken();

View file

@ -87,7 +87,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
let siteSubnet = oldSite.subnet;
let exitNodeIdToQuery = oldSite.exitNodeId;
if (exitNodeId && oldSite.exitNodeId !== exitNodeId) {
if (exitNodeId && (oldSite.exitNodeId !== exitNodeId || !oldSite.subnet)) {
// This effectively moves the exit node to the new one
exitNodeIdToQuery = exitNodeId; // Use the provided exitNodeId if it differs from the site's exitNodeId
@ -105,7 +105,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
.limit(1);
const blockSize = config.getRawConfig().gerbil.site_block_size;
const subnets = sitesQuery.map((site) => site.subnet);
const subnets = sitesQuery.map((site) => site.subnet).filter((subnet) => subnet !== null);
subnets.push(exitNode.address.replace(/\/\d+$/, `/${blockSize}`));
const newSubnet = findNextAvailableCidr(
subnets,

View file

@ -49,7 +49,7 @@ export async function createNewt(
const { newtId, secret } = parsedBody.data;
if (!req.userOrgRoleId) {
if (req.user && !req.userOrgRoleId) {
return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
);

View file

@ -33,8 +33,6 @@ const createOrgSchema = z
})
.strict();
// const MAX_ORGS = 5;
registry.registerPath({
method: "put",
path: "/org",
@ -80,16 +78,6 @@ export async function createOrg(
);
}
// const userOrgIds = req.userOrgIds;
// if (userOrgIds && userOrgIds.length > MAX_ORGS) {
// return next(
// createHttpError(
// HttpCode.FORBIDDEN,
// `Maximum number of organizations reached.`
// )
// );
// }
const { orgId, name, subnet } = parsedBody.data;
if (!isValidCIDR(subnet)) {
@ -147,7 +135,7 @@ export async function createOrg(
.values({
orgId,
name,
subnet,
subnet
})
.returning();
@ -182,9 +170,7 @@ export async function createOrg(
const actionIds = await trx.select().from(actions).execute();
if (actionIds.length > 0) {
await trx
.insert(roleActions)
.values(
await trx.insert(roleActions).values(
actionIds.map((action) => ({
roleId,
actionId: action.actionId,

View file

@ -89,6 +89,8 @@ export async function deleteOrg(
.where(eq(sites.orgId, orgId))
.limit(1);
const deletedNewtIds: string[] = [];
await db.transaction(async (trx) => {
if (sites) {
for (const site of orgSites) {
@ -102,11 +104,7 @@ export async function deleteOrg(
.where(eq(newts.siteId, site.siteId))
.returning();
if (deletedNewt) {
const payload = {
type: `newt/terminate`,
data: {}
};
sendToClient(deletedNewt.newtId, payload);
deletedNewtIds.push(deletedNewt.newtId);
// delete all of the sessions for the newt
await trx
@ -131,6 +129,18 @@ export async function deleteOrg(
await trx.delete(orgs).where(eq(orgs.orgId, orgId));
});
// Send termination messages outside of transaction to prevent blocking
for (const newtId of deletedNewtIds) {
const payload = {
type: `newt/terminate`,
data: {}
};
// Don't await this to prevent blocking the response
sendToClient(newtId, payload).catch(error => {
logger.error("Failed to send termination message to newt:", error);
});
}
return response(res, {
data: null,
success: true,

View file

@ -69,7 +69,8 @@ function queryResources(
http: resources.http,
protocol: resources.protocol,
proxyPort: resources.proxyPort,
enabled: resources.enabled
enabled: resources.enabled,
domainId: resources.domainId
})
.from(resources)
.leftJoin(sites, eq(resources.siteId, sites.siteId))
@ -103,7 +104,8 @@ function queryResources(
http: resources.http,
protocol: resources.protocol,
proxyPort: resources.proxyPort,
enabled: resources.enabled
enabled: resources.enabled,
domainId: resources.domainId
})
.from(resources)
.leftJoin(sites, eq(resources.siteId, sites.siteId))

View file

@ -38,7 +38,7 @@ const createSiteSchema = z
subnet: z.string().optional(),
newtId: z.string().optional(),
secret: z.string().optional(),
address: z.string().optional(),
// address: z.string().optional(),
type: z.enum(["newt", "wireguard", "local"])
})
.strict()
@ -97,7 +97,7 @@ export async function createSite(
subnet,
newtId,
secret,
address
// address
} = parsedBody.data;
const parsedParams = createSiteParamsSchema.safeParse(req.params);
@ -129,58 +129,58 @@ export async function createSite(
);
}
let updatedAddress = null;
if (address) {
if (!isValidIP(address)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid subnet format. Please provide a valid CIDR notation."
)
);
}
if (!isIpInCidr(address, org.subnet)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"IP is not in the CIDR range of the subnet."
)
);
}
updatedAddress = `${address}/${org.subnet.split("/")[1]}`; // we want the block size of the whole org
// make sure the subnet is unique
const addressExistsSites = await db
.select()
.from(sites)
.where(eq(sites.address, updatedAddress))
.limit(1);
if (addressExistsSites.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
`Subnet ${subnet} already exists`
)
);
}
const addressExistsClients = await db
.select()
.from(sites)
.where(eq(sites.subnet, updatedAddress))
.limit(1);
if (addressExistsClients.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
`Subnet ${subnet} already exists`
)
);
}
}
// let updatedAddress = null;
// if (address) {
// if (!isValidIP(address)) {
// return next(
// createHttpError(
// HttpCode.BAD_REQUEST,
// "Invalid subnet format. Please provide a valid CIDR notation."
// )
// );
// }
//
// if (!isIpInCidr(address, org.subnet)) {
// return next(
// createHttpError(
// HttpCode.BAD_REQUEST,
// "IP is not in the CIDR range of the subnet."
// )
// );
// }
//
// updatedAddress = `${address}/${org.subnet.split("/")[1]}`; // we want the block size of the whole org
//
// // make sure the subnet is unique
// const addressExistsSites = await db
// .select()
// .from(sites)
// .where(eq(sites.address, updatedAddress))
// .limit(1);
//
// if (addressExistsSites.length > 0) {
// return next(
// createHttpError(
// HttpCode.CONFLICT,
// `Subnet ${subnet} already exists`
// )
// );
// }
//
// const addressExistsClients = await db
// .select()
// .from(sites)
// .where(eq(sites.subnet, updatedAddress))
// .limit(1);
// if (addressExistsClients.length > 0) {
// return next(
// createHttpError(
// HttpCode.CONFLICT,
// `Subnet ${subnet} already exists`
// )
// );
// }
// }
const niceId = await getUniqueSiteName(orgId);
@ -205,7 +205,7 @@ export async function createSite(
exitNodeId,
name,
niceId,
address: updatedAddress || null,
// address: updatedAddress || null,
subnet,
type,
dockerSocketEnabled: type == "newt",
@ -221,7 +221,7 @@ export async function createSite(
orgId,
name,
niceId,
address: updatedAddress || null,
// address: updatedAddress || null,
type,
dockerSocketEnabled: type == "newt",
subnet: "0.0.0.0/0"

View file

@ -62,6 +62,8 @@ export async function deleteSite(
);
}
let deletedNewtId: string | null = null;
await db.transaction(async (trx) => {
if (site.pubKey) {
if (site.type == "wireguard") {
@ -73,11 +75,7 @@ export async function deleteSite(
.where(eq(newts.siteId, siteId))
.returning();
if (deletedNewt) {
const payload = {
type: `newt/terminate`,
data: {}
};
sendToClient(deletedNewt.newtId, payload);
deletedNewtId = deletedNewt.newtId;
// delete all of the sessions for the newt
await trx
@ -90,6 +88,18 @@ export async function deleteSite(
await trx.delete(sites).where(eq(sites.siteId, siteId));
});
// Send termination message outside of transaction to prevent blocking
if (deletedNewtId) {
const payload = {
type: `newt/terminate`,
data: {}
};
// Don't await this to prevent blocking the response
sendToClient(deletedNewtId, payload).catch(error => {
logger.error("Failed to send termination message to newt:", error);
});
}
return response(res, {
data: null,
success: true,

View file

@ -20,10 +20,10 @@ export type PickSiteDefaultsResponse = {
name: string;
listenPort: number;
endpoint: string;
subnet: string;
subnet: string; // TODO: make optional?
newtId: string;
newtSecret: string;
clientAddress: string;
clientAddress?: string;
};
registry.registerPath({
@ -86,7 +86,7 @@ export async function pickSiteDefaults(
.where(eq(sites.exitNodeId, exitNode.exitNodeId));
// 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).filter((subnet) => subnet !== null);
// exclude the exit node address by replacing after the / with a site block size
subnets.push(
exitNode.address.replace(
@ -108,17 +108,17 @@ export async function pickSiteDefaults(
);
}
const newClientAddress = await getNextAvailableClientSubnet(orgId);
if (!newClientAddress) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"No available subnet found"
)
);
}
// const newClientAddress = await getNextAvailableClientSubnet(orgId);
// if (!newClientAddress) {
// return next(
// createHttpError(
// HttpCode.INTERNAL_SERVER_ERROR,
// "No available subnet found"
// )
// );
// }
const clientAddress = newClientAddress.split("/")[0];
// const clientAddress = newClientAddress.split("/")[0];
const newtId = generateId(15);
const secret = generateId(48);
@ -133,7 +133,7 @@ export async function pickSiteDefaults(
endpoint: exitNode.endpoint,
// subnet: `${newSubnet.split("/")[0]}/${config.getRawConfig().gerbil.block_size}`, // we want the block size of the whole subnet
subnet: newSubnet,
clientAddress: clientAddress,
// clientAddress: clientAddress,
newtId,
newtSecret: secret
},

View file

@ -56,12 +56,8 @@ export async function traefikConfigProvider(
.select({
// Resource fields
resourceId: resources.resourceId,
subdomain: resources.subdomain,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
blockAccess: resources.blockAccess,
sso: resources.sso,
emailWhitelistEnabled: resources.emailWhitelistEnabled,
http: resources.http,
proxyPort: resources.proxyPort,
protocol: resources.protocol,
@ -74,10 +70,6 @@ export async function traefikConfigProvider(
subnet: sites.subnet,
exitNodeId: sites.exitNodeId,
},
// Org fields
org: {
orgId: orgs.orgId
},
enabled: resources.enabled,
stickySession: resources.stickySession,
tlsServerName: resources.tlsServerName,
@ -85,7 +77,6 @@ export async function traefikConfigProvider(
})
.from(resources)
.innerJoin(sites, eq(sites.siteId, resources.siteId))
.innerJoin(orgs, eq(resources.orgId, orgs.orgId))
.where(eq(sites.exitNodeId, currentExitNodeId));
// Get all resource IDs from the first query
@ -179,7 +170,6 @@ export async function traefikConfigProvider(
for (const resource of allResources) {
const targets = resource.targets as Target[];
const site = resource.site;
const org = resource.org;
const routerName = `${resource.resourceId}-router`;
const serviceName = `${resource.resourceId}-service`;
@ -203,11 +193,6 @@ export async function traefikConfigProvider(
continue;
}
// HTTP configuration remains the same
if (!resource.subdomain && !resource.isBaseDomain) {
continue;
}
// add routers and services empty objects if they don't exist
if (!config_output.http.routers) {
config_output.http.routers = {};
@ -299,7 +284,7 @@ export async function traefikConfigProvider(
} else if (site.type === "newt") {
if (
!target.internalPort ||
!target.method
!target.method || !site.subnet
) {
return false;
}
@ -315,7 +300,7 @@ export async function traefikConfigProvider(
url: `${target.method}://${target.ip}:${target.port}`
};
} else if (site.type === "newt") {
const ip = site.subnet.split("/")[0];
const ip = site.subnet!.split("/")[0];
return {
url: `${target.method}://${ip}:${target.internalPort}`
};
@ -409,7 +394,7 @@ export async function traefikConfigProvider(
return false;
}
} else if (site.type === "newt") {
if (!target.internalPort) {
if (!target.internalPort || !site.subnet) {
return false;
}
}
@ -424,7 +409,7 @@ export async function traefikConfigProvider(
address: `${target.ip}:${target.port}`
};
} else if (site.type === "newt") {
const ip = site.subnet.split("/")[0];
const ip = site.subnet!.split("/")[0];
return {
address: `${ip}:${target.internalPort}`
};

View file

@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { db, UserOrg } from "@server/db";
import { roles, userInvites, userOrgs, users } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
@ -92,6 +92,7 @@ export async function acceptInvite(
}
let roleId: number;
let totalUsers: UserOrg[] | undefined;
// get the role to make sure it exists
const existingRole = await db
.select()
@ -122,6 +123,12 @@ export async function acceptInvite(
await trx
.delete(userInvites)
.where(eq(userInvites.inviteId, inviteId));
// Get the total number of users in the org now
totalUsers = await db
.select()
.from(userOrgs)
.where(eq(userOrgs.orgId, existingInvite.orgId));
});
return response<AcceptInviteResponse>(res, {

View file

@ -6,7 +6,7 @@ import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { db } from "@server/db";
import { db, UserOrg } from "@server/db";
import { and, eq } from "drizzle-orm";
import { idp, idpOidcConfig, roles, userOrgs, users } from "@server/db";
import { generateId } from "@server/auth/sessions/app";
@ -135,13 +135,16 @@ export async function createOrgUser(
);
}
const [existingUser] = await db
let orgUsers: UserOrg[] | undefined;
await db.transaction(async (trx) => {
const [existingUser] = await trx
.select()
.from(users)
.where(eq(users.username, username));
if (existingUser) {
const [existingOrgUser] = await db
const [existingOrgUser] = await trx
.select()
.from(userOrgs)
.where(
@ -160,7 +163,7 @@ export async function createOrgUser(
);
}
await db
await trx
.insert(userOrgs)
.values({
orgId,
@ -171,7 +174,7 @@ export async function createOrgUser(
} else {
const userId = generateId(15);
const [newUser] = await db
const [newUser] = await trx
.insert(users)
.values({
userId: userId,
@ -185,7 +188,7 @@ export async function createOrgUser(
})
.returning();
await db
await trx
.insert(userOrgs)
.values({
orgId,
@ -194,6 +197,14 @@ export async function createOrgUser(
})
.returning();
}
// List all of the users in the org
orgUsers = await trx
.select()
.from(userOrgs)
.where(eq(userOrgs.orgId, orgId));
});
} else {
return next(
createHttpError(HttpCode.BAD_REQUEST, "User type is required")

View file

@ -99,6 +99,7 @@ export async function inviteUser(
regenerate
} = parsedBody.data;
// Check if the organization exists
const org = await db
.select()

View file

@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, resources, sites } from "@server/db";
import { db, resources, sites, UserOrg } from "@server/db";
import { userOrgs, userResources, users, userSites } from "@server/db";
import { and, eq, exists } from "drizzle-orm";
import response from "@server/lib/response";
@ -65,6 +65,8 @@ export async function removeUserOrg(
);
}
let userCount: UserOrg[] | undefined;
await db.transaction(async (trx) => {
await trx
.delete(userOrgs)
@ -108,6 +110,11 @@ export async function removeUserOrg(
)
)
);
userCount = await trx
.select()
.from(userOrgs)
.where(eq(userOrgs.orgId, orgId));
});
return response(res, {

View file

@ -0,0 +1,430 @@
"use client";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { useToast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import { formatAxiosError } from "@app/lib/api";
import { CreateDomainResponse } from "@server/routers/domain/createOrgDomain";
import { StrategySelect } from "@app/components/StrategySelect";
import { AxiosResponse } from "axios";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon, AlertTriangle } from "lucide-react";
import CopyToClipboard from "@app/components/CopyToClipboard";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import { useOrgContext } from "@app/hooks/useOrgContext";
const formSchema = z.object({
baseDomain: z.string().min(1, "Domain is required"),
type: z.enum(["ns", "cname"])
});
type FormValues = z.infer<typeof formSchema>;
type CreateDomainFormProps = {
open: boolean;
setOpen: (open: boolean) => void;
onCreated?: (domain: CreateDomainResponse) => void;
};
export default function CreateDomainForm({
open,
setOpen,
onCreated
}: CreateDomainFormProps) {
const [loading, setLoading] = useState(false);
const [createdDomain, setCreatedDomain] =
useState<CreateDomainResponse | null>(null);
const api = createApiClient(useEnvContext());
const t = useTranslations();
const { toast } = useToast();
const { org } = useOrgContext();
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
baseDomain: "",
type: "ns"
}
});
function reset() {
form.reset();
setLoading(false);
setCreatedDomain(null);
}
async function onSubmit(values: FormValues) {
setLoading(true);
try {
const response = await api.put<AxiosResponse<CreateDomainResponse>>(
`/org/${org.org.orgId}/domain`,
values
);
const domainData = response.data.data;
setCreatedDomain(domainData);
toast({
title: t("success"),
description: t("domainCreatedDescription")
});
onCreated?.(domainData);
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
} finally {
setLoading(false);
}
}
const domainType = form.watch("type");
const baseDomain = form.watch("baseDomain");
return (
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t("domainAdd")}</CredenzaTitle>
<CredenzaDescription>
{t("domainAddDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{!createdDomain ? (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="create-domain-form"
>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<StrategySelect
options={[
{
id: "ns",
title: t(
"selectDomainTypeNsName"
),
description: t(
"selectDomainTypeNsDescription"
)
},
{
id: "cname",
title: t(
"selectDomainTypeCnameName"
),
description: t(
"selectDomainTypeCnameDescription"
)
}
]}
defaultValue={field.value}
onChange={field.onChange}
cols={1}
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="baseDomain"
render={({ field }) => (
<FormItem>
<FormLabel>{t("domain")}</FormLabel>
<FormControl>
<Input
placeholder="example.com"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
) : (
<div className="space-y-6">
<Alert variant="default">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
Add DNS Records
</AlertTitle>
<AlertDescription>
Add the following DNS records to your domain
provider to complete the setup.
</AlertDescription>
</Alert>
<div className="space-y-4">
{domainType === "ns" &&
createdDomain.nsRecords && (
<div>
<h3 className="font-medium mb-3">
NS Records
</h3>
<InfoSections cols={1}>
<InfoSection>
<InfoSectionTitle>
Record
</InfoSectionTitle>
<InfoSectionContent>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
Type:
</span>
<span className="text-sm font-mono">
NS
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
Name:
</span>
<span className="text-sm font-mono">
{baseDomain}
</span>
</div>
<span className="text-sm font-medium">
Value:
</span>
{createdDomain.nsRecords.map(
(
nsRecord,
index
) => (
<div
className="flex justify-between items-center"
key={
index
}
>
<CopyToClipboard
text={
nsRecord
}
/>
</div>
)
)}
</div>
</InfoSectionContent>
</InfoSection>
</InfoSections>
</div>
)}
{domainType === "cname" && (
<>
{createdDomain.cnameRecords &&
createdDomain.cnameRecords.length >
0 && (
<div>
<h3 className="font-medium mb-3">
CNAME Records
</h3>
<InfoSections cols={1}>
{createdDomain.cnameRecords.map(
(
cnameRecord,
index
) => (
<InfoSection
key={index}
>
<InfoSectionTitle>
Record{" "}
{index +
1}
</InfoSectionTitle>
<InfoSectionContent>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
Type:
</span>
<span className="text-sm font-mono">
CNAME
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
Name:
</span>
<span className="text-sm font-mono">
{
cnameRecord.baseDomain
}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
Value:
</span>
<CopyToClipboard
text={
cnameRecord.value
}
/>
</div>
</div>
</InfoSectionContent>
</InfoSection>
)
)}
</InfoSections>
</div>
)}
{createdDomain.txtRecords &&
createdDomain.txtRecords.length >
0 && (
<div>
<h3 className="font-medium mb-3">
TXT Records
</h3>
<InfoSections cols={1}>
{createdDomain.txtRecords.map(
(
txtRecord,
index
) => (
<InfoSection
key={index}
>
<InfoSectionTitle>
Record{" "}
{index +
1}
</InfoSectionTitle>
<InfoSectionContent>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
Type:
</span>
<span className="text-sm font-mono">
TXT
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
Name:
</span>
<span className="text-sm font-mono">
{
txtRecord.baseDomain
}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
Value:
</span>
<CopyToClipboard
text={
txtRecord.value
}
/>
</div>
</div>
</InfoSectionContent>
</InfoSection>
)
)}
</InfoSections>
</div>
)}
</>
)}
</div>
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle className="font-semibold">
Save These Records
</AlertTitle>
<AlertDescription>
Make sure to save these DNS records as you
will not see them again.
</AlertDescription>
</Alert>
<Alert variant="info">
<AlertTriangle className="h-4 w-4" />
<AlertTitle className="font-semibold">
DNS Propagation
</AlertTitle>
<AlertDescription>
DNS changes may take some time to propagate
across the internet. This can take anywhere
from a few minutes to 48 hours, depending on
your DNS provider and TTL settings.
</AlertDescription>
</Alert>
</div>
)}
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
{!createdDomain && (
<Button
type="submit"
form="create-domain-form"
loading={loading}
disabled={loading}
>
{t("domainCreate")}
</Button>
)}
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View file

@ -0,0 +1,37 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { useTranslations } from "next-intl";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
onAdd?: () => void;
onRefresh?: () => void;
isRefreshing?: boolean;
}
export function DomainsDataTable<TData, TValue>({
columns,
data,
onAdd,
onRefresh,
isRefreshing
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
title={t("domains")}
searchPlaceholder={t("domainsSearch")}
searchColumn="baseDomain"
addButtonText={t("domainAdd")}
onAdd={onAdd}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
/>
);
}

View file

@ -0,0 +1,261 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DomainsDataTable } from "./DomainsDataTable";
import { Button } from "@app/components/ui/button";
import { ArrowUpDown } from "lucide-react";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { Badge } from "@app/components/ui/badge";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import CreateDomainForm from "./CreateDomainForm";
import { useToast } from "@app/hooks/useToast";
import { useOrgContext } from "@app/hooks/useOrgContext";
export type DomainRow = {
domainId: string;
baseDomain: string;
type: string;
verified: boolean;
failed: boolean;
tries: number;
};
type Props = {
domains: DomainRow[];
};
export default function DomainsTable({ domains }: Props) {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [selectedDomain, setSelectedDomain] = useState<DomainRow | null>(
null
);
const [isRefreshing, setIsRefreshing] = useState(false);
const [restartingDomains, setRestartingDomains] = useState<Set<string>>(new Set());
const api = createApiClient(useEnvContext());
const router = useRouter();
const t = useTranslations();
const { toast } = useToast();
const { org } = useOrgContext();
const refreshData = async () => {
setIsRefreshing(true);
try {
router.refresh();
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
} finally {
setIsRefreshing(false);
}
};
const deleteDomain = async (domainId: string) => {
try {
await api.delete(`/org/${org.org.orgId}/domain/${domainId}`);
toast({
title: t("success"),
description: t("domainDeletedDescription")
});
setIsDeleteModalOpen(false);
refreshData();
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
}
};
const restartDomain = async (domainId: string) => {
setRestartingDomains(prev => new Set(prev).add(domainId));
try {
await api.post(`/org/${org.org.orgId}/domain/${domainId}/restart`);
toast({
title: t("success"),
description: t("domainRestartedDescription", { fallback: "Domain verification restarted successfully" })
});
refreshData();
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
} finally {
setRestartingDomains(prev => {
const newSet = new Set(prev);
newSet.delete(domainId);
return newSet;
});
}
};
const getTypeDisplay = (type: string) => {
switch (type) {
case "ns":
return t("selectDomainTypeNsName");
case "cname":
return t("selectDomainTypeCnameName");
default:
return type;
}
};
const columns: ColumnDef<DomainRow>[] = [
{
accessorKey: "baseDomain",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("domain")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "type",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("type")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const type = row.original.type;
return (
<Badge variant="secondary">{getTypeDisplay(type)}</Badge>
);
}
},
{
accessorKey: "verified",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("status")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const { verified, failed } = row.original;
if (verified) {
return <Badge variant="green">{t("verified")}</Badge>;
} else if (failed) {
return <Badge variant="destructive">{t("failed", { fallback: "Failed" })}</Badge>;
} else {
return <Badge variant="yellow">{t("pending")}</Badge>;
}
}
},
{
id: "actions",
cell: ({ row }) => {
const domain = row.original;
const isRestarting = restartingDomains.has(domain.domainId);
return (
<div className="flex items-center justify-end gap-2">
{domain.failed && (
<Button
variant="outline"
size="sm"
onClick={() => restartDomain(domain.domainId)}
disabled={isRestarting}
>
{isRestarting ? t("restarting", { fallback: "Restarting..." }) : t("restart", { fallback: "Restart" })}
</Button>
)}
<Button
variant="secondary"
size="sm"
onClick={() => {
setSelectedDomain(domain);
setIsDeleteModalOpen(true);
}}
>
{t("delete")}
</Button>
</div>
);
}
}
];
return (
<>
{selectedDomain && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedDomain(null);
}}
dialog={
<div className="space-y-4">
<p>
{t("domainQuestionRemove", {
domain: selectedDomain.baseDomain
})}
</p>
<p>
<b>{t("domainMessageRemove")}</b>
</p>
<p>{t("domainMessageConfirm")}</p>
</div>
}
buttonText={t("domainConfirmDelete")}
onConfirm={async () =>
deleteDomain(selectedDomain.domainId)
}
string={selectedDomain.baseDomain}
title={t("domainDelete")}
/>
)}
<CreateDomainForm
open={isCreateModalOpen}
setOpen={setIsCreateModalOpen}
onCreated={(domain) => {
refreshData();
}}
/>
<DomainsDataTable
columns={columns}
data={domains}
onAdd={() => setIsCreateModalOpen(true)}
onRefresh={refreshData}
isRefreshing={isRefreshing}
/>
</>
);
}

View file

@ -0,0 +1,60 @@
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import DomainsTable, { DomainRow } from "./DomainsTable";
import { getTranslations } from "next-intl/server";
import { cache } from "react";
import { GetOrgResponse } from "@server/routers/org";
import { redirect } from "next/navigation";
import OrgProvider from "@app/providers/OrgProvider";
import { ListDomainsResponse } from "@server/routers/domain";
type Props = {
params: Promise<{ orgId: string }>;
};
export default async function DomainsPage(props: Props) {
const params = await props.params;
let domains: DomainRow[] = [];
try {
const res = await internal.get<
AxiosResponse<ListDomainsResponse>
>(`/org/${params.orgId}/domains`, await authCookieHeader());
domains = res.data.data.domains as DomainRow[];
} catch (e) {
console.error(e);
}
let org = null;
try {
const getOrg = cache(async () =>
internal.get<AxiosResponse<GetOrgResponse>>(
`/org/${params.orgId}`,
await authCookieHeader()
)
);
const res = await getOrg();
org = res.data.data;
} catch {
redirect(`/${params.orgId}`);
}
if (!org) {
}
const t = await getTranslations();
return (
<>
<OrgProvider org={org}>
<SettingsSectionTitle
title={t("domains")}
description={t("domainsDescription")}
/>
<DomainsTable domains={domains} />
</OrgProvider>
</>
);
}

View file

@ -202,25 +202,25 @@ export default function GeneralPage() {
)}
/>
<FormField
control={form.control}
name="subnet"
render={({ field }) => (
<FormItem>
<FormLabel>Subnet</FormLabel>
<FormControl>
<Input
{...field}
disabled={true}
/>
</FormControl>
<FormMessage />
<FormDescription>
The subnet for this organization's network configuration.
</FormDescription>
</FormItem>
)}
/>
{/* <FormField */}
{/* control={form.control} */}
{/* name="subnet" */}
{/* render={({ field }) => ( */}
{/* <FormItem> */}
{/* <FormLabel>Subnet</FormLabel> */}
{/* <FormControl> */}
{/* <Input */}
{/* {...field} */}
{/* disabled={true} */}
{/* /> */}
{/* </FormControl> */}
{/* <FormMessage /> */}
{/* <FormDescription> */}
{/* The subnet for this organization's network configuration. */}
{/* </FormDescription> */}
{/* </FormItem> */}
{/* )} */}
{/* /> */}
</form>
</Form>
</SettingsSectionForm>

View file

@ -1,6 +1,7 @@
import { Metadata } from "next";
import {
Combine,
KeyRound,
LinkIcon,
Settings,
Users,
@ -11,6 +12,7 @@ import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation";
import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { ListOrgsResponse } from "@server/routers/org";
import { GetOrgResponse, ListUserOrgsResponse } from "@server/routers/org";
import { authCookieHeader } from "@app/lib/api/cookies";
import { cache } from "react";

View file

@ -32,6 +32,8 @@ import { Switch } from "@app/components/ui/switch";
import { AxiosResponse } from "axios";
import { UpdateResourceResponse } from "@server/routers/resource";
import { useTranslations } from "next-intl";
import { InfoPopup } from "@app/components/ui/info-popup";
import { Badge } from "@app/components/ui/badge";
export type ResourceRow = {
id: number;
@ -45,6 +47,7 @@ export type ResourceRow = {
protocol: string;
proxyPort: number | null;
enabled: boolean;
domainId?: string;
};
type ResourcesTableProps = {
@ -158,6 +161,13 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div className="flex items-center space-x-2">
{!resourceRow.domainId ? (
<InfoPopup
info={t("domainNotFoundDescription")}
text={t("domainNotFound")}
/>
) : (
<div>
{!resourceRow.http ? (
<CopyToClipboard
@ -171,6 +181,8 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
/>
)}
</div>
)}
</div>
);
}
},
@ -215,7 +227,10 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
header: t("enabled"),
cell: ({ row }) => (
<Switch
defaultChecked={row.original.enabled}
defaultChecked={
!row.original.domainId ? false : row.original.enabled
}
disabled={!row.original.domainId}
onCheckedChange={(val) =>
toggleResourceEnabled(val, row.original.id)
}
@ -261,7 +276,11 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
<Link
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
>
<Button variant={"secondary"} className="ml-2" size="sm">
<Button
variant={"secondary"}
className="ml-2"
size="sm"
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>

View file

@ -10,10 +10,14 @@ import {
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useDockerSocket } from "@app/hooks/useDockerSocket";
import { useTranslations } from "next-intl";
import { AxiosResponse } from "axios";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { RotateCw } from "lucide-react";
import { createApiClient } from "@app/lib/api";
type ResourceInfoBoxType = {};

View file

@ -205,10 +205,10 @@ export default function ResourceAuthenticationPage() {
console.error(e);
toast({
variant: "destructive",
title: t('resourceErrorAuthFetch'),
title: t("resourceErrorAuthFetch"),
description: formatAxiosError(
e,
t('resourceErrorAuthFetchDescription')
t("resourceErrorAuthFetchDescription")
)
});
}
@ -235,18 +235,18 @@ export default function ResourceAuthenticationPage() {
});
toast({
title: t('resourceWhitelistSave'),
description: t('resourceWhitelistSaveDescription')
title: t("resourceWhitelistSave"),
description: t("resourceWhitelistSaveDescription")
});
router.refresh();
} catch (e) {
console.error(e);
toast({
variant: "destructive",
title: t('resourceErrorWhitelistSave'),
title: t("resourceErrorWhitelistSave"),
description: formatAxiosError(
e,
t('resourceErrorWhitelistSaveDescription')
t("resourceErrorWhitelistSaveDescription")
)
});
} finally {
@ -283,18 +283,18 @@ export default function ResourceAuthenticationPage() {
});
toast({
title: t('resourceAuthSettingsSave'),
description: t('resourceAuthSettingsSaveDescription')
title: t("resourceAuthSettingsSave"),
description: t("resourceAuthSettingsSaveDescription")
});
router.refresh();
} catch (e) {
console.error(e);
toast({
variant: "destructive",
title: t('resourceErrorUsersRolesSave'),
title: t("resourceErrorUsersRolesSave"),
description: formatAxiosError(
e,
t('resourceErrorUsersRolesSaveDescription')
t("resourceErrorUsersRolesSaveDescription")
)
});
} finally {
@ -310,8 +310,8 @@ export default function ResourceAuthenticationPage() {
})
.then(() => {
toast({
title: t('resourcePasswordRemove'),
description: t('resourcePasswordRemoveDescription')
title: t("resourcePasswordRemove"),
description: t("resourcePasswordRemoveDescription")
});
updateAuthInfo({
@ -322,10 +322,10 @@ export default function ResourceAuthenticationPage() {
.catch((e) => {
toast({
variant: "destructive",
title: t('resourceErrorPasswordRemove'),
title: t("resourceErrorPasswordRemove"),
description: formatAxiosError(
e,
t('resourceErrorPasswordRemoveDescription')
t("resourceErrorPasswordRemoveDescription")
)
});
})
@ -340,8 +340,8 @@ export default function ResourceAuthenticationPage() {
})
.then(() => {
toast({
title: t('resourcePincodeRemove'),
description: t('resourcePincodeRemoveDescription')
title: t("resourcePincodeRemove"),
description: t("resourcePincodeRemoveDescription")
});
updateAuthInfo({
@ -352,10 +352,10 @@ export default function ResourceAuthenticationPage() {
.catch((e) => {
toast({
variant: "destructive",
title: t('resourceErrorPincodeRemove'),
title: t("resourceErrorPincodeRemove"),
description: formatAxiosError(
e,
t('resourceErrorPincodeRemoveDescription')
t("resourceErrorPincodeRemoveDescription")
)
});
})
@ -400,17 +400,17 @@ export default function ResourceAuthenticationPage() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('resourceUsersRoles')}
{t("resourceUsersRoles")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('resourceUsersRolesDescription')}
{t("resourceUsersRolesDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<SwitchInput
id="sso-toggle"
label={t('ssoUse')}
description={t('ssoUseDescription')}
label={t("ssoUse")}
defaultChecked={resource.sso}
onCheckedChange={(val) => setSsoEnabled(val)}
/>
@ -430,7 +430,9 @@ export default function ResourceAuthenticationPage() {
name="roles"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>{t('roles')}</FormLabel>
<FormLabel>
{t("roles")}
</FormLabel>
<FormControl>
<TagInput
{...field}
@ -440,7 +442,9 @@ export default function ResourceAuthenticationPage() {
setActiveTagIndex={
setActiveRolesTagIndex
}
placeholder={t('accessRoleSelect2')}
placeholder={t(
"accessRoleSelect2"
)}
size="sm"
tags={
usersRolesForm.getValues()
@ -474,7 +478,9 @@ export default function ResourceAuthenticationPage() {
</FormControl>
<FormMessage />
<FormDescription>
{t('resourceRoleDescription')}
{t(
"resourceRoleDescription"
)}
</FormDescription>
</FormItem>
)}
@ -484,7 +490,9 @@ export default function ResourceAuthenticationPage() {
name="users"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>{t('users')}</FormLabel>
<FormLabel>
{t("users")}
</FormLabel>
<FormControl>
<TagInput
{...field}
@ -494,7 +502,9 @@ export default function ResourceAuthenticationPage() {
setActiveTagIndex={
setActiveUsersTagIndex
}
placeholder={t('accessUserSelect')}
placeholder={t(
"accessUserSelect"
)}
tags={
usersRolesForm.getValues()
.users
@ -534,6 +544,7 @@ export default function ResourceAuthenticationPage() {
)}
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
@ -542,7 +553,7 @@ export default function ResourceAuthenticationPage() {
disabled={loadingSaveUsersRoles}
form="users-roles-form"
>
{t('resourceUsersRolesSubmit')}
{t("resourceUsersRolesSubmit")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
@ -550,25 +561,31 @@ export default function ResourceAuthenticationPage() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('resourceAuthMethods')}
{t("resourceAuthMethods")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('resourceAuthMethodsDescriptions')}
{t("resourceAuthMethodsDescriptions")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
{/* Password Protection */}
<div className="flex items-center justify-between">
<div className="flex items-center justify-between border rounded-md p-2 mb-4">
<div
className={`flex items-center text-${!authInfo.password ? "neutral" : "green"}-500 space-x-2`}
className={`flex items-center ${!authInfo.password ? "text-muted-foreground" : "text-green-500"} text-sm space-x-2`}
>
<Key />
<Key size="14" />
<span>
{t('resourcePasswordProtection', {status: authInfo.password? t('enabled') : t('disabled')})}
{t("resourcePasswordProtection", {
status: authInfo.password
? t("enabled")
: t("disabled")
})}
</span>
</div>
<Button
variant="secondary"
size="sm"
onClick={
authInfo.password
? removeResourcePassword
@ -577,23 +594,28 @@ export default function ResourceAuthenticationPage() {
loading={loadingRemoveResourcePassword}
>
{authInfo.password
? t('passwordRemove')
: t('passwordAdd')}
? t("passwordRemove")
: t("passwordAdd")}
</Button>
</div>
{/* PIN Code Protection */}
<div className="flex items-center justify-between">
<div className="flex items-center justify-between border rounded-md p-2">
<div
className={`flex items-center text-${!authInfo.pincode ? "neutral" : "green"}-500 space-x-2`}
className={`flex items-center ${!authInfo.pincode ? "text-muted-foreground" : "text-green-500"} space-x-2 text-sm`}
>
<Binary />
<Binary size="14" />
<span>
{t('resourcePincodeProtection', {status: authInfo.pincode ? t('enabled') : t('disabled')})}
{t("resourcePincodeProtection", {
status: authInfo.pincode
? t("enabled")
: t("disabled")
})}
</span>
</div>
<Button
variant="secondary"
size="sm"
onClick={
authInfo.pincode
? removeResourcePincode
@ -602,37 +624,39 @@ export default function ResourceAuthenticationPage() {
loading={loadingRemoveResourcePincode}
>
{authInfo.pincode
? t('pincodeRemove')
: t('pincodeAdd')}
? t("pincodeRemove")
: t("pincodeAdd")}
</Button>
</div>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('otpEmailTitle')}
{t("otpEmailTitle")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('otpEmailTitleDescription')}
{t("otpEmailTitleDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
{!env.email.emailEnabled && (
<Alert variant="neutral" className="mb-4">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t('otpEmailSmtpRequired')}
{t("otpEmailSmtpRequired")}
</AlertTitle>
<AlertDescription>
{t('otpEmailSmtpRequiredDescription')}
{t("otpEmailSmtpRequiredDescription")}
</AlertDescription>
</Alert>
)}
<SwitchInput
id="whitelist-toggle"
label={t('otpEmailWhitelist')}
label={t("otpEmailWhitelist")}
defaultChecked={resource.emailWhitelistEnabled}
onCheckedChange={setWhitelistEnabled}
disabled={!env.email.emailEnabled}
@ -648,8 +672,12 @@ export default function ResourceAuthenticationPage() {
<FormItem>
<FormLabel>
<InfoPopup
text={t('otpEmailWhitelistList')}
info={t('otpEmailWhitelistListDescription')}
text={t(
"otpEmailWhitelistList"
)}
info={t(
"otpEmailWhitelistListDescription"
)}
/>
</FormLabel>
<FormControl>
@ -672,7 +700,10 @@ export default function ResourceAuthenticationPage() {
.regex(
/^\*@[\w.-]+\.[a-zA-Z]{2,}$/,
{
message: t('otpEmailErrorInvalid')
message:
t(
"otpEmailErrorInvalid"
)
}
)
)
@ -683,7 +714,9 @@ export default function ResourceAuthenticationPage() {
setActiveTagIndex={
setActiveEmailTagIndex
}
placeholder={t('otpEmailEnter')}
placeholder={t(
"otpEmailEnter"
)}
tags={
whitelistForm.getValues()
.emails
@ -706,7 +739,9 @@ export default function ResourceAuthenticationPage() {
/>
</FormControl>
<FormDescription>
{t('otpEmailEnterDescription')}
{t(
"otpEmailEnterDescription"
)}
</FormDescription>
</FormItem>
)}
@ -714,6 +749,7 @@ export default function ResourceAuthenticationPage() {
</form>
</Form>
)}
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
@ -722,7 +758,7 @@ export default function ResourceAuthenticationPage() {
loading={loadingSaveWhitelist}
disabled={loadingSaveWhitelist}
>
{t('otpEmailWhitelistSave')}
{t("otpEmailWhitelistSave")}
</Button>
</SettingsSectionFooter>
</SettingsSection>

View file

@ -66,6 +66,18 @@ import {
} from "@server/routers/resource";
import { SwitchInput } from "@app/components/SwitchInput";
import { useTranslations } from "next-intl";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import DomainPicker from "@app/components/DomainPicker";
import { Globe } from "lucide-react";
const TransferFormSchema = z.object({
siteId: z.number()
@ -80,6 +92,7 @@ export default function GeneralForm() {
const { org } = useOrgContext();
const router = useRouter();
const t = useTranslations();
const [editDomainOpen, setEditDomainOpen] = useState(false);
const { env } = useEnvContext();
@ -99,46 +112,22 @@ export default function GeneralForm() {
const [domainType, setDomainType] = useState<"subdomain" | "basedomain">(
resource.isBaseDomain ? "basedomain" : "subdomain"
);
const [resourceFullDomain, setResourceFullDomain] = useState(
`${resource.ssl ? "https" : "http"}://${resource.fullDomain}`
);
const [selectedDomain, setSelectedDomain] = useState<{
domainId: string;
subdomain?: string;
fullDomain: string;
} | null>(null);
const GeneralFormSchema = z
.object({
enabled: z.boolean(),
subdomain: z.string().optional(),
name: z.string().min(1).max(255),
proxyPort: z.number().optional(),
http: z.boolean(),
isBaseDomain: z.boolean().optional(),
domainId: z.string().optional()
})
.refine(
(data) => {
if (!data.http) {
return z
.number()
.int()
.min(1)
.max(65535)
.safeParse(data.proxyPort).success;
}
return true;
},
{
message: t("proxyErrorInvalidPort"),
path: ["proxyPort"]
}
)
.refine(
(data) => {
if (data.http && !data.isBaseDomain) {
return subdomainSchema.safeParse(data.subdomain).success;
}
return true;
},
{
message: t("subdomainErrorInvalid"),
path: ["subdomain"]
}
);
});
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
@ -148,9 +137,6 @@ export default function GeneralForm() {
enabled: resource.enabled,
name: resource.name,
subdomain: resource.subdomain ? resource.subdomain : undefined,
proxyPort: resource.proxyPort ? resource.proxyPort : undefined,
http: resource.http,
isBaseDomain: resource.isBaseDomain ? true : false,
domainId: resource.domainId || undefined
},
mode: "onChange"
@ -213,10 +199,8 @@ export default function GeneralForm() {
{
enabled: data.enabled,
name: data.name,
subdomain: data.http ? data.subdomain : undefined,
proxyPort: data.proxyPort,
isBaseDomain: data.http ? data.isBaseDomain : undefined,
domainId: data.http ? data.domainId : undefined
subdomain: data.subdomain,
domainId: data.domainId,
}
)
.catch((e) => {
@ -242,8 +226,6 @@ export default function GeneralForm() {
enabled: data.enabled,
name: data.name,
subdomain: data.subdomain,
proxyPort: data.proxyPort,
isBaseDomain: data.isBaseDomain,
fullDomain: resource.fullDomain
});
@ -288,6 +270,7 @@ export default function GeneralForm() {
return (
!loadingPage && (
<>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
@ -304,7 +287,7 @@ export default function GeneralForm() {
<Form {...form} key={formKey}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid grid-cols-1 md:grid-cols-2 gap-4"
className="space-y-4"
id="general-settings-form"
>
<FormField
@ -316,18 +299,24 @@ export default function GeneralForm() {
<FormControl>
<SwitchInput
id="enable-resource"
defaultChecked={resource.enabled}
onCheckedChange={(val) => form.setValue("enabled", val)}
defaultChecked={
resource.enabled
}
label={
t(
"resourceEnable"
)
}
onCheckedChange={(
val
) =>
form.setValue(
"enabled",
val
)
}
/>
</FormControl>
<div className="space-y-1">
<FormLabel className="text-base">
{t("resourceEnable")}
</FormLabel>
<FormDescription>
{t("resourceVisibilityTitleDescription")}
</FormDescription>
</div>
</div>
<FormMessage />
</FormItem>
@ -351,240 +340,27 @@ export default function GeneralForm() {
/>
{resource.http && (
<>
{env.flags
.allowBaseDomainResources && (
<FormField
control={form.control}
name="isBaseDomain"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"domainType"
)}
</FormLabel>
<Select
value={
domainType
}
onValueChange={(
val
) => {
setDomainType(
val ===
"basedomain"
? "basedomain"
: "subdomain"
);
form.setValue(
"isBaseDomain",
val ===
"basedomain"
? true
: false
);
}}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="subdomain">
{t(
"subdomain"
)}
</SelectItem>
<SelectItem value="basedomain">
{t(
"baseDomain"
)}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="col-span-2">
{domainType === "subdomain" ? (
<div className="w-fill space-y-2">
<FormLabel>
{t("subdomain")}
</FormLabel>
<div className="flex">
<div className="w-1/2">
<FormField
control={
form.control
}
name="subdomain"
render={({
field
}) => (
<FormItem>
<FormControl>
<Input
{...field}
className="border-r-0 rounded-r-none"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="w-1/2">
<FormField
control={
form.control
}
name="domainId"
render={({
field
}) => (
<FormItem>
<Select
onValueChange={
field.onChange
}
defaultValue={
field.value
}
value={
field.value
}
>
<FormControl>
<SelectTrigger className="rounded-l-none">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{baseDomains.map(
(
option
) => (
<SelectItem
key={
option.domainId
}
value={
option.domainId
}
>
.
{
option.baseDomain
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</div>
) : (
<FormField
control={form.control}
name="domainId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"baseDomain"
)}
</FormLabel>
<Select
onValueChange={
field.onChange
}
defaultValue={
field.value ||
baseDomains[0]
?.domainId
}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{baseDomains.map(
(
option
) => (
<SelectItem
key={
option.domainId
}
value={
option.domainId
}
>
{
option.baseDomain
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
</>
)}
{!resource.http && (
<FormField
control={form.control}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"resourcePortNumber"
)}
</FormLabel>
<FormControl>
<Input
type="number"
value={
field.value ??
""
}
onChange={(e) =>
field.onChange(
e.target
.value
? parseInt(
e
.target
.value
)
: null
<div className="space-y-2">
<Label>Domain</Label>
<div className="border p-2 rounded-md flex items-center justify-between">
<span className="text-sm text-muted-foreground flex items-center gap-2">
<Globe size="14"/>
{resourceFullDomain}
</span>
<Button
variant="secondary"
type="button"
size="sm"
onClick={() =>
setEditDomainOpen(
true
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
>
Edit Domain
</Button>
</div>
</div>
)}
</form>
</Form>
@ -594,6 +370,9 @@ export default function GeneralForm() {
<SettingsSectionFooter>
<Button
type="submit"
onClick={() => {
console.log(form.getValues());
}}
loading={saveLoading}
disabled={saveLoading}
form="general-settings-form"
@ -653,7 +432,8 @@ export default function GeneralForm() {
) =>
site.siteId ===
field.value
)?.name
)
?.name
: t(
"siteSelect"
)}
@ -661,7 +441,7 @@ export default function GeneralForm() {
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput
placeholder={t(
@ -675,7 +455,9 @@ export default function GeneralForm() {
</CommandEmpty>
<CommandGroup>
{sites.map(
(site) => (
(
site
) => (
<CommandItem
value={`${site.name}:${site.siteId}`}
key={
@ -731,6 +513,47 @@ export default function GeneralForm() {
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
<Credenza
open={editDomainOpen}
onOpenChange={(setOpen) => setEditDomainOpen(setOpen)}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Edit Domain</CredenzaTitle>
<CredenzaDescription>
Select a domain for your resource
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<DomainPicker
orgId={orgId as string}
onDomainChange={(res) => {
const selected = {
domainId: res.domainId,
subdomain: res.subdomain,
fullDomain: res.fullDomain
};
setSelectedDomain(selected);
}}
/>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("cancel")}</Button>
</CredenzaClose>
<Button onClick={() => {
if (selectedDomain) {
setResourceFullDomain(selectedDomain.fullDomain);
form.setValue("domainId", selectedDomain.domainId);
form.setValue("subdomain", selectedDomain.subdomain);
setEditDomainOpen(false);
}
}}>Select Domain</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
)
);
}

View file

@ -796,37 +796,8 @@ export default function ReverseProxyTargets(props: {
</FormItem>
)}
/>
<Collapsible
open={isAdvancedOpen}
onOpenChange={setIsAdvancedOpen}
className="space-y-2"
>
<div className="flex items-center justify-between space-x-4">
<CollapsibleTrigger asChild>
<Button
variant="text"
size="sm"
className="p-0 flex items-center justify-start gap-2 w-full"
>
<p className="text-sm text-muted-foreground">
{t(
"targetTlsSettingsAdvanced"
)}
</p>
<div>
<ChevronsUpDown className="h-4 w-4" />
<span className="sr-only">
Toggle
</span>
</div>
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent className="space-y-2">
<FormField
control={
tlsSettingsForm.control
}
control={tlsSettingsForm.control}
name="tlsServerName"
render={({ field }) => (
<FormItem>
@ -845,8 +816,6 @@ export default function ReverseProxyTargets(props: {
</FormItem>
)}
/>
</CollapsibleContent>
</Collapsible>
</form>
</Form>
</SettingsSectionForm>

View file

@ -594,17 +594,10 @@ export default function ResourceRules(props: {
<div className="flex items-center space-x-2">
<SwitchInput
id="rules-toggle"
label={t('rulesEnable')}
defaultChecked={rulesEnabled}
onCheckedChange={(val) => setRulesEnabled(val)}
/>
<div className="space-y-1">
<label className="text-base font-medium">
{t('rulesEnable')}
</label>
<p className="text-sm text-muted-foreground">
{t('rulesEnableDescription')}
</p>
</div>
</div>
<Form {...addRuleForm}>

View file

@ -63,6 +63,7 @@ import { SquareArrowOutUpRight } from "lucide-react";
import CopyTextBox from "@app/components/CopyTextBox";
import Link from "next/link";
import { useTranslations } from "next-intl";
import DomainPicker from "@app/components/DomainPicker";
const baseResourceFormSchema = z.object({
name: z.string().min(1).max(255),
@ -70,17 +71,10 @@ const baseResourceFormSchema = z.object({
http: z.boolean()
});
const httpResourceFormSchema = z.discriminatedUnion("isBaseDomain", [
z.object({
isBaseDomain: z.literal(true),
domainId: z.string().min(1)
}),
z.object({
isBaseDomain: z.literal(false),
domainId: z.string().min(1),
subdomain: z.string().pipe(subdomainSchema)
})
]);
const httpResourceFormSchema = z.object({
domainId: z.string().optional(),
subdomain: z.string().optional()
});
const tcpUdpResourceFormSchema = z.object({
protocol: z.string(),
@ -143,11 +137,7 @@ export default function Page() {
const httpForm = useForm<HttpResourceFormValues>({
resolver: zodResolver(httpResourceFormSchema),
defaultValues: {
subdomain: "",
domainId: "",
isBaseDomain: false
}
defaultValues: {}
});
const tcpUdpForm = useForm<TcpUdpResourceFormValues>({
@ -173,20 +163,10 @@ export default function Page() {
if (isHttp) {
const httpData = httpForm.getValues();
if (httpData.isBaseDomain) {
Object.assign(payload, {
domainId: httpData.domainId,
isBaseDomain: true,
protocol: "tcp"
});
} else {
Object.assign(payload, {
subdomain: httpData.subdomain,
domainId: httpData.domainId,
isBaseDomain: false,
protocol: "tcp"
domainId: httpData.domainId
});
}
} else {
const tcpUdpData = tcpUdpForm.getValues();
Object.assign(payload, {
@ -498,218 +478,23 @@ export default function Page() {
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...httpForm}>
<form
className="space-y-4"
id="http-settings-form"
>
{env.flags
.allowBaseDomainResources && (
<FormField
control={
httpForm.control
}
name="isBaseDomain"
render={({
field
}) => (
<FormItem>
<FormLabel>
{t(
"domainType"
)}
</FormLabel>
<Select
value={
field.value
? "basedomain"
: "subdomain"
}
onValueChange={(
value
) => {
field.onChange(
value ===
"basedomain"
<DomainPicker
orgId={orgId as string}
onDomainChange={(res) => {
httpForm.setValue(
"subdomain",
res.subdomain
);
httpForm.setValue(
"domainId",
res.domainId
);
console.log(
"Domain changed:",
res
);
}}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="subdomain">
{t(
"subdomain"
)}
</SelectItem>
<SelectItem value="basedomain">
{t(
"baseDomain"
)}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
{!httpForm.watch(
"isBaseDomain"
) && (
<FormItem>
<FormLabel>
{t("subdomain")}
</FormLabel>
<div className="flex space-x-0">
<div className="w-1/2">
<FormField
control={
httpForm.control
}
name="subdomain"
render={({
field
}) => (
<FormItem>
<FormControl>
<Input
{...field}
className="border-r-0 rounded-r-none"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="w-1/2">
<FormField
control={
httpForm.control
}
name="domainId"
render={({
field
}) => (
<FormItem>
<Select
onValueChange={
field.onChange
}
value={
field.value
}
defaultValue={
field.value
}
>
<FormControl>
<SelectTrigger className="rounded-l-none">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{baseDomains.map(
(
option
) => (
<SelectItem
key={
option.domainId
}
value={
option.domainId
}
>
.
{
option.baseDomain
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<FormDescription>
{t(
"subdomnainDescription"
)}
</FormDescription>
</FormItem>
)}
{httpForm.watch(
"isBaseDomain"
) && (
<FormField
control={
httpForm.control
}
name="domainId"
render={({
field
}) => (
<FormItem>
<FormLabel>
{t(
"baseDomain"
)}
</FormLabel>
<Select
onValueChange={
field.onChange
}
defaultValue={
field.value
}
{...field}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{baseDomains.map(
(
option
) => (
<SelectItem
key={
option.domainId
}
value={
option.domainId
}
>
{
option.baseDomain
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
) : (
@ -921,7 +706,7 @@ export default function Page() {
type="button"
onClick={() =>
router.push(
`/${orgId}/settings/resources/${resourceId}`
`/${orgId}/settings/resources/${resourceId}/proxy`
)
}
>

View file

@ -67,7 +67,8 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
resource.whitelist
? "protected"
: "not_protected",
enabled: resource.enabled
enabled: resource.enabled,
domainId: resource.domainId || undefined
};
});

View file

@ -5,10 +5,12 @@ import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ArrowRight, DockIcon as Docker, Globe, Server, X } from "lucide-react";
import Link from "next/link";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from 'next-intl';
export const SitesSplashCard = () => {
const [isDismissed, setIsDismissed] = useState(true);
const { env } = useEnvContext();
const key = "sites-splash-card-dismissed";
const t = useTranslations();

View file

@ -375,7 +375,10 @@ WantedBy=default.target`
async function onSubmit(data: CreateSiteFormValues) {
setCreateLoading(true);
let payload: CreateSiteBody = { name: data.name, type: data.method };
let payload: CreateSiteBody = {
name: data.name,
type: data.method as "newt" | "wireguard" | "local"
};
if (data.method == "wireguard") {
if (!siteDefaults || !wgConfig) {
@ -412,7 +415,7 @@ WantedBy=default.target`
exitNodeId: siteDefaults.exitNodeId,
secret: siteDefaults.newtSecret,
newtId: siteDefaults.newtId,
address: clientAddress
// address: clientAddress
};
}
@ -573,42 +576,42 @@ WantedBy=default.target`
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientAddress"
render={({ field }) => (
<FormItem>
<FormLabel>
Site Address
</FormLabel>
<FormControl>
<Input
autoComplete="off"
value={
clientAddress
}
onChange={(
e
) => {
setClientAddress(
e.target
.value
);
field.onChange(
e.target
.value
);
}}
/>
</FormControl>
<FormMessage />
<FormDescription>
Specify the IP
address of the host.
</FormDescription>
</FormItem>
)}
/>
{/* <FormField */}
{/* control={form.control} */}
{/* name="clientAddress" */}
{/* render={({ field }) => ( */}
{/* <FormItem> */}
{/* <FormLabel> */}
{/* Site Address */}
{/* </FormLabel> */}
{/* <FormControl> */}
{/* <Input */}
{/* autoComplete="off" */}
{/* value={ */}
{/* clientAddress */}
{/* } */}
{/* onChange={( */}
{/* e */}
{/* ) => { */}
{/* setClientAddress( */}
{/* e.target */}
{/* .value */}
{/* ); */}
{/* field.onChange( */}
{/* e.target */}
{/* .value */}
{/* ); */}
{/* }} */}
{/* /> */}
{/* </FormControl> */}
{/* <FormMessage /> */}
{/* <FormDescription> */}
{/* Specify the IP */}
{/* address of the host. */}
{/* </FormDescription> */}
{/* </FormItem> */}
{/* )} */}
{/* /> */}
</form>
</Form>
</SettingsSectionForm>
@ -760,7 +763,7 @@ WantedBy=default.target`
? "squareOutlinePrimary"
: "squareOutline"
}
className={`flex-1 min-w-[120px] ${platform === os ? "bg-primary/10" : ""}`}
className={`flex-1 min-w-[120px] ${platform === os ? "bg-primary/10" : ""} shadow-none`}
onClick={() => {
setPlatform(os);
}}
@ -791,7 +794,7 @@ WantedBy=default.target`
? "squareOutlinePrimary"
: "squareOutline"
}
className={`flex-1 min-w-[120px] ${architecture === arch ? "bg-primary/10" : ""}`}
className={`flex-1 min-w-[120px] ${architecture === arch ? "bg-primary/10" : ""} shadow-none`}
onClick={() =>
setArchitecture(
arch

View file

@ -1,5 +1,6 @@
import { Metadata } from "next";
import { Users } from "lucide-react";
import { TopbarNav } from "@app/components/TopbarNav";
import { KeyRound, Users } from "lucide-react";
import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation";
import { cache } from "react";

View file

@ -1,4 +1,5 @@
import ProfileIcon from "@app/components/ProfileIcon";
import ThemeSwitcher from "@app/components/ThemeSwitcher";
import { Separator } from "@app/components/ui/separator";
import { priv } from "@app/lib/api";
import { verifySession } from "@app/lib/auth/verifySession";
@ -11,7 +12,7 @@ import { cache } from "react";
import { getTranslations } from "next-intl/server";
export const metadata: Metadata = {
title: `Auth - Pangolin`,
title: `Auth - "Pangolin`,
description: ""
};
@ -23,6 +24,7 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
const getUser = cache(verifySession);
const user = await getUser();
const t = await getTranslations();
const hideFooter = true;
const licenseStatusRes = await cache(
async () =>
@ -34,20 +36,18 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
return (
<div className="h-full flex flex-col">
{user && (
<UserProvider user={user}>
<div className="p-3 ml-auto">
<ProfileIcon />
<div className="flex justify-end items-center p-3 space-x-2">
<ThemeSwitcher />
</div>
</UserProvider>
)}
<div className="flex-1 flex items-center justify-center">
<div className="w-full max-w-md p-3">{children}</div>
</div>
{!(
licenseStatus.isHostLicensed && licenseStatus.isLicenseValid
hideFooter || (
licenseStatus.isHostLicensed &&
licenseStatus.isLicenseValid)
) && (
<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">
@ -73,7 +73,7 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
aria-label="GitHub"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>{t('communityEdition')}</span>
<span>{t("communityEdition")}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"

View file

@ -54,10 +54,10 @@ export default async function Page(props: {
<div className="flex flex-col items-center">
<Mail className="w-12 h-12 mb-4 text-primary" />
<h2 className="text-2xl font-bold mb-2 text-center">
{t('inviteAlready')}
{t("inviteAlready")}
</h2>
<p className="text-center">
{t('inviteAlreadyDescription')}
{t("inviteAlreadyDescription")}
</p>
</div>
</div>
@ -67,7 +67,7 @@ export default async function Page(props: {
{(!signUpDisabled || isInvite) && (
<p className="text-center text-muted-foreground mt-4">
{t('authNoAccount')}{" "}
{t("authNoAccount")}{" "}
<Link
href={
!redirectUrl
@ -76,7 +76,7 @@ export default async function Page(props: {
}
className="underline"
>
{t('signup')}
{t("signup")}
</Link>
</p>
)}

View file

@ -54,12 +54,14 @@ export type ResetPasswordFormProps = {
emailParam?: string;
tokenParam?: string;
redirect?: string;
quickstart?: boolean;
};
export default function ResetPasswordForm({
emailParam,
tokenParam,
redirect
redirect,
quickstart
}: ResetPasswordFormProps) {
const router = useRouter();
@ -184,8 +186,53 @@ export default function ResetPasswordForm({
return;
}
setSuccessMessage(t('passwordResetSuccess'));
setSuccessMessage(quickstart ? t('accountSetupSuccess') : t('passwordResetSuccess'));
// Auto-login after successful password reset
try {
const loginRes = await api.post("/auth/login", {
email: form.getValues("email"),
password: form.getValues("password")
});
if (loginRes.data.data?.codeRequested) {
if (redirect) {
router.push(`/auth/login?redirect=${redirect}`);
} else {
router.push("/auth/login");
}
return;
}
if (loginRes.data.data?.emailVerificationRequired) {
try {
await api.post("/auth/verify-email/request");
} catch (verificationError) {
console.error("Failed to send verification code:", verificationError);
}
if (redirect) {
router.push(`/auth/verify-email?redirect=${redirect}`);
} else {
router.push("/auth/verify-email");
}
return;
}
// Login successful, redirect
setTimeout(() => {
if (redirect) {
const safe = cleanRedirect(redirect);
router.push(safe);
} else {
router.push("/");
}
setIsSubmitting(false);
}, 1500);
} catch (loginError) {
// Auto-login failed, but password reset was successful
console.error("Auto-login failed:", loginError);
setTimeout(() => {
if (redirect) {
const safe = cleanRedirect(redirect);
@ -197,14 +244,20 @@ export default function ResetPasswordForm({
}, 1500);
}
}
}
return (
<div>
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t('passwordReset')}</CardTitle>
<CardTitle>
{quickstart ? t('completeAccountSetup') : t('passwordReset')}
</CardTitle>
<CardDescription>
{t('passwordResetDescription')}
{quickstart
? t('completeAccountSetupDescription')
: t('passwordResetDescription')
}
</CardDescription>
</CardHeader>
<CardContent>
@ -229,7 +282,10 @@ export default function ResetPasswordForm({
</FormControl>
<FormMessage />
<FormDescription>
{t('passwordResetSent')}
{quickstart
? t('accountSetupSent')
: t('passwordResetSent')
}
</FormDescription>
</FormItem>
)}
@ -269,7 +325,10 @@ export default function ResetPasswordForm({
render={({ field }) => (
<FormItem>
<FormLabel>
{t('passwordResetCode')}
{quickstart
? t('accountSetupCode')
: t('passwordResetCode')
}
</FormLabel>
<FormControl>
<Input
@ -279,7 +338,10 @@ export default function ResetPasswordForm({
</FormControl>
<FormMessage />
<FormDescription>
{t('passwordResetCodeDescription')}
{quickstart
? t('accountSetupCodeDescription')
: t('passwordResetCodeDescription')
}
</FormDescription>
</FormItem>
)}
@ -292,7 +354,10 @@ export default function ResetPasswordForm({
render={({ field }) => (
<FormItem>
<FormLabel>
{t('passwordNew')}
{quickstart
? t('passwordCreate')
: t('passwordNew')
}
</FormLabel>
<FormControl>
<Input
@ -310,7 +375,10 @@ export default function ResetPasswordForm({
render={({ field }) => (
<FormItem>
<FormLabel>
{t('passwordNewConfirm')}
{quickstart
? t('passwordCreateConfirm')
: t('passwordNewConfirm')
}
</FormLabel>
<FormControl>
<Input
@ -407,7 +475,7 @@ export default function ResetPasswordForm({
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{state === "reset"
? t('passwordReset')
? (quickstart ? t('completeSetup') : t('passwordReset'))
: t('pincodeSubmit2')}
</Button>
)}
@ -422,7 +490,10 @@ export default function ResetPasswordForm({
{isSubmitting && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t('passwordResetSubmit')}
{quickstart
? t('accountSetupSubmit')
: t('passwordResetSubmit')
}
</Button>
)}

View file

@ -13,6 +13,7 @@ export default async function Page(props: {
redirect: string | undefined;
email: string | undefined;
token: string | undefined;
quickstart?: string | undefined;
}>;
}) {
const searchParams = await props.searchParams;
@ -35,6 +36,9 @@ export default async function Page(props: {
redirect={searchParams.redirect}
tokenParam={searchParams.token}
emailParam={searchParams.email}
quickstart={
searchParams.quickstart === "true" ? true : undefined
}
/>
<p className="text-center text-muted-foreground mt-4">
@ -46,7 +50,7 @@ export default async function Page(props: {
}
className="underline"
>
{t('loginBack')}
{t("loginBack")}
</Link>
</p>
</>

View file

@ -43,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 Image from "next/image";
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
import { useTranslations } from "next-intl";
@ -185,8 +186,8 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
setOtpState("otp_sent");
submitOtpForm.setValue("email", values.email);
toast({
title: t('otpEmailSent'),
description: t('otpEmailSentDescription')
title: t("otpEmailSent"),
description: t("otpEmailSentDescription")
});
return;
}
@ -202,7 +203,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
.catch((e) => {
console.error(e);
setWhitelistError(
formatAxiosError(e, t('otpEmailErrorAuthenticate'))
formatAxiosError(e, t("otpEmailErrorAuthenticate"))
);
})
.then(() => setLoadingLogin(false));
@ -227,7 +228,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
.catch((e) => {
console.error(e);
setPincodeError(
formatAxiosError(e, t('pincodeErrorAuthenticate'))
formatAxiosError(e, t("pincodeErrorAuthenticate"))
);
})
.then(() => setLoadingLogin(false));
@ -255,7 +256,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
.catch((e) => {
console.error(e);
setPasswordError(
formatAxiosError(e, t('passwordErrorAuthenticate'))
formatAxiosError(e, t("passwordErrorAuthenticate"))
);
})
.finally(() => setLoadingLogin(false));
@ -276,30 +277,25 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
}
}
function getTitle() {
return t("authenticationRequired");
}
function getSubtitle(resourceName: string) {
return numMethods > 1
? t("authenticationMethodChoose", { name: props.resource.name })
: t("authenticationRequest", { name: props.resource.name });
}
return (
<div>
{!accessDenied ? (
<div>
<div className="text-center mb-2">
<span className="text-sm text-muted-foreground">
{t('poweredBy')}{" "}
<Link
href="https://github.com/fosrl/pangolin"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Pangolin
</Link>
</span>
</div>
<Card>
<CardHeader>
<CardTitle>{t('authenticationRequired')}</CardTitle>
<CardTitle>{getTitle()}</CardTitle>
<CardDescription>
{numMethods > 1
? t('authenticationMethodChoose', {name: props.resource.name})
: t('authenticationRequest', {name: props.resource.name})}
{getSubtitle(props.resource.name)}
</CardDescription>
</CardHeader>
<CardContent>
@ -329,19 +325,19 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
{props.methods.password && (
<TabsTrigger value="password">
<Key className="w-4 h-4 mr-1" />{" "}
{t('password')}
{t("password")}
</TabsTrigger>
)}
{props.methods.sso && (
<TabsTrigger value="sso">
<User className="w-4 h-4 mr-1" />{" "}
{t('user')}
{t("user")}
</TabsTrigger>
)}
{props.methods.whitelist && (
<TabsTrigger value="whitelist">
<AtSign className="w-4 h-4 mr-1" />{" "}
{t('email')}
{t("email")}
</TabsTrigger>
)}
</TabsList>
@ -364,7 +360,9 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('pincodeInput')}
{t(
"pincodeInput"
)}
</FormLabel>
<FormControl>
<div className="flex justify-center">
@ -433,7 +431,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
disabled={loadingLogin}
>
<LockIcon className="w-4 h-4 mr-2" />
{t('pincodeSubmit')}
{t("pincodeSubmit")}
</Button>
</form>
</Form>
@ -459,7 +457,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('password')}
{t("password")}
</FormLabel>
<FormControl>
<Input
@ -487,7 +485,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
disabled={loadingLogin}
>
<LockIcon className="w-4 h-4 mr-2" />
{t('passwordSubmit')}
{t("passwordSubmit")}
</Button>
</form>
</Form>
@ -528,7 +526,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('email')}
{t("email")}
</FormLabel>
<FormControl>
<Input
@ -537,7 +535,9 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
/>
</FormControl>
<FormDescription>
{t('otpEmailDescription')}
{t(
"otpEmailDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
@ -559,7 +559,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
disabled={loadingLogin}
>
<Send className="w-4 h-4 mr-2" />
{t('otpEmailSend')}
{t("otpEmailSend")}
</Button>
</form>
</Form>
@ -581,7 +581,9 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('otpEmail')}
{t(
"otpEmail"
)}
</FormLabel>
<FormControl>
<Input
@ -609,7 +611,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
disabled={loadingLogin}
>
<LockIcon className="w-4 h-4 mr-2" />
{t('otpEmailSubmit')}
{t("otpEmailSubmit")}
</Button>
<Button
@ -621,7 +623,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
submitOtpForm.reset();
}}
>
{t('backToEmail')}
{t("backToEmail")}
</Button>
</form>
</Form>
@ -634,7 +636,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
{supporterStatus?.visible && (
<div className="text-center mt-2">
<span className="text-sm text-muted-foreground opacity-50">
{t('noSupportKey')}
{t("noSupportKey")}
</span>
</div>
)}

View file

@ -57,7 +57,9 @@ export default function SignupForm({
}: SignupFormProps) {
const router = useRouter();
const api = createApiClient(useEnvContext());
const { env } = useEnvContext();
const api = createApiClient({ env });
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -116,8 +118,8 @@ export default function SignupForm({
}
return (
<Card className="w-full max-w-md">
<CardHeader>
<Card className="w-full max-w-md shadow-md">
<CardHeader className="border-b">
<div className="flex flex-row items-center justify-center">
<Image
src={`/logo/pangolin_orange.svg`}
@ -135,7 +137,7 @@ export default function SignupForm({
</p>
</div>
</CardHeader>
<CardContent>
<CardContent className="pt-6">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
@ -161,10 +163,7 @@ export default function SignupForm({
<FormItem>
<FormLabel>{t('password')}</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@ -177,10 +176,7 @@ export default function SignupForm({
<FormItem>
<FormLabel>{t('confirmPassword')}</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>

View file

@ -111,6 +111,9 @@
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--shadow-2xs: 0 1px 1px rgba(0, 0, 0, 0.03);
--inset-shadow-2xs: inset 0 1px 1px rgba(0, 0, 1, 0.03);
}
@layer base {

View file

@ -1,7 +1,6 @@
import type { Metadata } from "next";
import "./globals.css";
import { Inter } from "next/font/google";
import { Toaster } from "@/components/ui/toaster";
import { ThemeProvider } from "@app/providers/ThemeProvider";
import EnvProvider from "@app/providers/EnvProvider";
import { pullEnv } from "@app/lib/pullEnv";
@ -15,10 +14,11 @@ import LicenseViolation from "@app/components/LicenseViolation";
import { cache } from "react";
import { NextIntlClientProvider } from "next-intl";
import { getLocale } from "next-intl/server";
import { Toaster } from "@app/components/ui/toaster";
export const metadata: Metadata = {
title: `Dashboard - Pangolin`,
description: ""
description: "",
};
export const dynamic = "force-dynamic";

View file

@ -10,7 +10,8 @@ import {
Workflow,
KeyRound,
TicketCheck,
User
User,
Globe
} from "lucide-react";
export type SidebarNavSection = {
@ -31,6 +32,11 @@ export const orgNavSections: SidebarNavSection[] = [
title: "sidebarResources",
href: "/{orgId}/settings/resources",
icon: <Waypoints className="h-4 w-4" />
},
{
title: "sidebarDomains",
href: "/{orgId}/settings/domains",
icon: <Globe className="h-4 w-4" />
}
]
},

View file

@ -75,7 +75,6 @@ export default async function Page(props: {
const allCookies = await cookies();
const lastOrgCookie = allCookies.get("pangolin-last-org")?.value;
if (lastOrgCookie && orgs.length > 0) {
const lastOrgExists = orgs.some((org) => org.orgId === lastOrgCookie);
if (lastOrgExists) {
redirect(`/${lastOrgCookie}`);
@ -87,23 +86,22 @@ export default async function Page(props: {
redirect("/setup");
}
}
}
return (
<UserProvider user={user}>
<Layout orgs={orgs} navItems={[]}>
<div className="w-full max-w-md mx-auto md:mt-32 mt-4">
<OrganizationLanding
disableCreateOrg={
env.flags.disableUserCreateOrg && !user.serverAdmin
}
organizations={orgs.map((org) => ({
name: org.name,
id: org.orgId
}))}
/>
</div>
</Layout>
</UserProvider>
);
// return (
// <UserProvider user={user}>
// <Layout orgs={orgs} navItems={[]}>
// <div className="w-full max-w-md mx-auto md:mt-32 mt-4">
// <OrganizationLanding
// disableCreateOrg={
// env.flags.disableUserCreateOrg && !user.serverAdmin
// }
// organizations={orgs.map((org) => ({
// name: org.name,
// id: org.orgId
// }))}
// />
// </div>
// </Layout>
// </UserProvider>
// );
}

View file

@ -0,0 +1,499 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
AlertCircle,
CheckCircle2,
Building2,
Zap,
ArrowUpDown
} from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { createApiClient, formatAxiosError } from "@/lib/api";
import { useEnvContext } from "@/hooks/useEnvContext";
import { toast } from "@/hooks/useToast";
import { ListDomainsResponse } from "@server/routers/domain/listDomains";
import { AxiosResponse } from "axios";
import { cn } from "@/lib/cn";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useTranslations } from "next-intl";
type OrganizationDomain = {
domainId: string;
baseDomain: string;
verified: boolean;
type: "ns" | "cname";
};
type AvailableOption = {
domainNamespaceId: string;
fullDomain: string;
domainId: string;
};
type DomainOption = {
id: string;
domain: string;
type: "organization" | "provided";
verified?: boolean;
domainType?: "ns" | "cname";
domainId?: string;
domainNamespaceId?: string;
subdomain?: string;
};
interface DomainPickerProps {
orgId: string;
onDomainChange?: (domainInfo: {
domainId: string;
domainNamespaceId?: string;
type: "organization" | "provided";
subdomain?: string;
fullDomain: string;
baseDomain: string;
}) => void;
}
export default function DomainPicker({
orgId,
onDomainChange
}: DomainPickerProps) {
const { env } = useEnvContext();
const api = createApiClient({ env });
const t = useTranslations();
const [userInput, setUserInput] = useState<string>("");
const [selectedOption, setSelectedOption] = useState<DomainOption | null>(
null
);
const [availableOptions, setAvailableOptions] = useState<AvailableOption[]>(
[]
);
const [isChecking, setIsChecking] = useState(false);
const [organizationDomains, setOrganizationDomains] = useState<
OrganizationDomain[]
>([]);
const [loadingDomains, setLoadingDomains] = useState(false);
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [activeTab, setActiveTab] = useState<
"all" | "organization" | "provided"
>("all");
const [providedDomainsShown, setProvidedDomainsShown] = useState(3);
useEffect(() => {
const loadOrganizationDomains = async () => {
setLoadingDomains(true);
try {
const response = await api.get<
AxiosResponse<ListDomainsResponse>
>(`/org/${orgId}/domains`);
if (response.status === 200) {
const domains = response.data.data.domains
.filter(
(domain) =>
domain.type === "ns" || domain.type === "cname"
)
.map((domain) => ({
...domain,
type: domain.type as "ns" | "cname"
}));
setOrganizationDomains(domains);
}
} catch (error) {
console.error("Failed to load organization domains:", error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to load organization domains"
});
} finally {
setLoadingDomains(false);
}
};
loadOrganizationDomains();
}, [orgId, api]);
// Generate domain options based on user input
const generateDomainOptions = (): DomainOption[] => {
const options: DomainOption[] = [];
if (!userInput.trim()) return options;
// Check if input is more than one level deep (contains multiple dots)
const isMultiLevel = (userInput.match(/\./g) || []).length > 1;
// Add organization domain options
organizationDomains.forEach((orgDomain) => {
if (orgDomain.type === "cname") {
// For CNAME domains, check if the user input matches exactly
if (
orgDomain.baseDomain.toLowerCase() ===
userInput.toLowerCase()
) {
options.push({
id: `org-${orgDomain.domainId}`,
domain: orgDomain.baseDomain,
type: "organization",
verified: orgDomain.verified,
domainType: "cname",
domainId: orgDomain.domainId
});
}
} else if (orgDomain.type === "ns") {
// For NS domains, check if the user input could be a subdomain
const userInputLower = userInput.toLowerCase();
const baseDomainLower = orgDomain.baseDomain.toLowerCase();
// Check if user input ends with the base domain
if (userInputLower.endsWith(`.${baseDomainLower}`)) {
const subdomain = userInputLower.slice(
0,
-(baseDomainLower.length + 1)
);
options.push({
id: `org-${orgDomain.domainId}`,
domain: userInput,
type: "organization",
verified: orgDomain.verified,
domainType: "ns",
domainId: orgDomain.domainId,
subdomain: subdomain
});
} else if (userInputLower === baseDomainLower) {
// Exact match for base domain
options.push({
id: `org-${orgDomain.domainId}`,
domain: orgDomain.baseDomain,
type: "organization",
verified: orgDomain.verified,
domainType: "ns",
domainId: orgDomain.domainId
});
}
}
});
// Add provided domain options (always try to match provided domains)
availableOptions.forEach((option) => {
options.push({
id: `provided-${option.domainNamespaceId}`,
domain: option.fullDomain,
type: "provided",
domainNamespaceId: option.domainNamespaceId,
domainId: option.domainId,
});
});
// Sort options
return options.sort((a, b) => {
const comparison = a.domain.localeCompare(b.domain);
return sortOrder === "asc" ? comparison : -comparison;
});
};
const domainOptions = generateDomainOptions();
// Filter options based on active tab
const filteredOptions = domainOptions.filter((option) => {
if (activeTab === "all") return true;
return option.type === activeTab;
});
// Separate organization and provided options for pagination
const organizationOptions = filteredOptions.filter(
(opt) => opt.type === "organization"
);
const allProvidedOptions = filteredOptions.filter(
(opt) => opt.type === "provided"
);
const providedOptions = allProvidedOptions.slice(0, providedDomainsShown);
const hasMoreProvided = allProvidedOptions.length > providedDomainsShown;
// Handle option selection
const handleOptionSelect = (option: DomainOption) => {
setSelectedOption(option);
if (option.type === "organization") {
if (option.domainType === "cname") {
onDomainChange?.({
domainId: option.domainId!,
type: "organization",
subdomain: undefined,
fullDomain: option.domain,
baseDomain: option.domain
});
} else if (option.domainType === "ns") {
const subdomain = option.subdomain || "";
onDomainChange?.({
domainId: option.domainId!,
type: "organization",
subdomain: subdomain || undefined,
fullDomain: option.domain,
baseDomain: option.domain
});
}
} else if (option.type === "provided") {
// Extract subdomain from full domain
const parts = option.domain.split(".");
const subdomain = parts[0];
const baseDomain = parts.slice(1).join(".");
onDomainChange?.({
domainId: option.domainId!,
domainNamespaceId: option.domainNamespaceId,
type: "provided",
subdomain: subdomain,
fullDomain: option.domain,
baseDomain: baseDomain
});
}
};
return (
<div className="space-y-6">
{/* Domain Input */}
<div className="space-y-2">
<Label htmlFor="domain-input">
{t("domainPickerEnterDomain")}
</Label>
<Input
id="domain-input"
value={userInput}
onChange={(e) => {
// Only allow letters, numbers, hyphens, and periods
const validInput = e.target.value.replace(
/[^a-zA-Z0-9.-]/g,
""
);
setUserInput(validInput);
}}
/>
<p className="text-xs text-muted-foreground">
{t("domainPickerDescription")}
</p>
</div>
{/* Tabs and Sort Toggle */}
<div className="flex justify-between items-center">
<Tabs
value={activeTab}
onValueChange={(value) =>
setActiveTab(
value as "all" | "organization" | "provided"
)
}
>
<TabsList>
<TabsTrigger value="all">
{t("domainPickerTabAll")}
</TabsTrigger>
<TabsTrigger value="organization">
{t("domainPickerTabOrganization")}
</TabsTrigger>
<TabsTrigger value="provided">
{t("domainPickerTabProvided")}
</TabsTrigger>
</TabsList>
</Tabs>
<Button
variant="outline"
size="sm"
onClick={() =>
setSortOrder(sortOrder === "asc" ? "desc" : "asc")
}
>
<ArrowUpDown className="h-4 w-4 mr-2" />
{sortOrder === "asc"
? t("domainPickerSortAsc")
: t("domainPickerSortDesc")}
</Button>
</div>
{/* Loading State */}
{isChecking && (
<div className="flex items-center justify-center p-8">
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
<span>{t("domainPickerCheckingAvailability")}</span>
</div>
</div>
)}
{/* No Options */}
{!isChecking &&
filteredOptions.length === 0 &&
userInput.trim() && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{t("domainPickerNoMatchingDomains", { userInput })}
</AlertDescription>
</Alert>
)}
{/* Domain Options */}
{!isChecking && filteredOptions.length > 0 && (
<div className="space-y-4">
{/* Organization Domains */}
{organizationOptions.length > 0 && (
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Building2 className="h-4 w-4" />
<h4 className="text-sm font-medium">
{t("domainPickerOrganizationDomains")}
</h4>
</div>
<div className="grid gap-2">
{organizationOptions.map((option) => (
<div
key={option.id}
className={cn(
"transition-all p-3 rounded-lg border",
selectedOption?.id === option.id
? "border-primary bg-primary/5"
: "border-input",
option.verified
? "cursor-pointer hover:bg-accent"
: "cursor-not-allowed opacity-60"
)}
onClick={() =>
option.verified && handleOptionSelect(option)
}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center space-x-2">
<p className="font-mono text-sm">
{option.domain}
</p>
{/* <Badge */}
{/* variant={ */}
{/* option.domainType === */}
{/* "ns" */}
{/* ? "default" */}
{/* : "secondary" */}
{/* } */}
{/* > */}
{/* {option.domainType} */}
{/* </Badge> */}
{option.verified ? (
<CheckCircle2 className="h-3 w-3 text-green-500" />
) : (
<AlertCircle className="h-3 w-3 text-yellow-500" />
)}
</div>
{option.subdomain && (
<p className="text-xs text-muted-foreground mt-1">
{t(
"domainPickerSubdomain",
{
subdomain:
option.subdomain
}
)}
</p>
)}
{!option.verified && (
<p className="text-xs text-yellow-600 mt-1">
Domain is unverified
</p>
)}
</div>
{selectedOption?.id ===
option.id && (
<CheckCircle2 className="h-4 w-4 text-primary" />
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Provided Domains */}
{providedOptions.length > 0 && (
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Zap className="h-4 w-4" />
<div className="text-sm font-medium">
{t("domainPickerProvidedDomains")}
</div>
</div>
<div className="grid gap-2">
{providedOptions.map((option) => (
<div
key={option.id}
className={cn(
"transition-all p-3 rounded-lg border",
selectedOption?.id === option.id
? "border-primary bg-primary/5"
: "border-input",
"cursor-pointer hover:bg-accent"
)}
onClick={() =>
handleOptionSelect(option)
}
>
<div className="flex items-center justify-between">
<div>
<p className="font-mono text-sm">
{option.domain}
</p>
<p className="text-xs text-muted-foreground">
{t(
"domainPickerNamespace",
{
namespace:
option.domainNamespaceId as string
}
)}
</p>
</div>
{selectedOption?.id ===
option.id && (
<CheckCircle2 className="h-4 w-4 text-primary" />
)}
</div>
</div>
))}
</div>
{hasMoreProvided && (
<Button
variant="outline"
size="sm"
onClick={() =>
setProvidedDomainsShown(
(prev) => prev + 3
)
}
className="w-full"
>
{t("domainPickerShowMore")}
</Button>
)}
</div>
)}
</div>
)}
</div>
);
}
function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null;
return (...args: Parameters<T>) => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => {
func(...args);
}, wait);
};
}

View file

@ -31,7 +31,8 @@ export async function Layout({
const allCookies = await cookies();
const sidebarStateCookie = allCookies.get("pangolin-sidebar-state")?.value;
const initialSidebarCollapsed = sidebarStateCookie === "collapsed" ||
const initialSidebarCollapsed =
sidebarStateCookie === "collapsed" ||
(sidebarStateCookie !== "expanded" && defaultSidebarCollapsed);
return (
@ -49,7 +50,7 @@ export async function Layout({
{/* Main content area */}
<div
className={cn(
"flex-1 flex flex-col h-full min-w-0",
"flex-1 flex flex-col h-full min-w-0 relative",
!showSidebar && "w-full"
)}
>
@ -69,7 +70,10 @@ export async function Layout({
{/* Main content */}
<main className="flex-1 overflow-y-auto p-3 md:p-6 w-full">
<div className="container mx-auto max-w-12xl mb-12">
<div className={cn(
"container mx-auto max-w-12xl mb-12",
showHeader && "md:pt-20" // Add top padding only on desktop to account for fixed header
)}>
{children}
</div>
</main>

View file

@ -7,6 +7,8 @@ import Link from "next/link";
import ProfileIcon from "@app/components/ProfileIcon";
import ThemeSwitcher from "@app/components/ThemeSwitcher";
import { useTheme } from "next-themes";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { Badge } from "./ui/badge";
interface LayoutHeaderProps {
showTopBar: boolean;
@ -15,6 +17,7 @@ interface LayoutHeaderProps {
export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
const { theme } = useTheme();
const [path, setPath] = useState<string>("");
const { env } = useEnvContext();
useEffect(() => {
function getPath() {
@ -56,7 +59,6 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
</Link>
</div>
{/* Profile controls on the right */}
{showTopBar && (
<div className="flex items-center space-x-2">
<ThemeSwitcher />

View file

@ -152,7 +152,7 @@ export function LayoutSidebar({
onClick={() =>
setIsSidebarCollapsed(!isSidebarCollapsed)
}
className="cursor-pointer absolute -right-2.5 top-1/2 transform -translate-y-1/2 w-2 h-8 rounded-full flex items-center justify-center transition-all duration-200 ease-in-out hover:scale-110 group"
className="cursor-pointer absolute -right-2.5 top-1/2 transform -translate-y-1/2 w-2 h-8 rounded-full flex items-center justify-center transition-all duration-200 ease-in-out hover:scale-110 group z-[60]"
aria-label={
isSidebarCollapsed
? "Expand sidebar"

View file

@ -11,6 +11,7 @@ import {
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { ArrowRight, Plus } from "lucide-react";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
interface Organization {
@ -29,6 +30,8 @@ export default function OrganizationLanding({
}: OrganizationLandingProps) {
const [selectedOrg, setSelectedOrg] = useState<string | null>(null);
const { env } = useEnvContext();
const handleOrgClick = (orgId: string) => {
setSelectedOrg(orgId);
};

View file

@ -19,7 +19,7 @@ export function SettingsSectionForm({
}: {
children: React.ReactNode;
}) {
return <div className="max-w-xl">{children}</div>;
return <div className="max-w-xl space-y-4">{children}</div>;
}
export function SettingsSectionTitle({

View file

@ -48,6 +48,7 @@ export function SidebarNav({
const niceId = params.niceId as string;
const resourceId = params.resourceId as string;
const userId = params.userId as string;
const apiKeyId = params.apiKeyId as string;
const clientId = params.clientId as string;
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
const { user } = useUserContext();
@ -59,6 +60,7 @@ export function SidebarNav({
.replace("{niceId}", niceId)
.replace("{resourceId}", resourceId)
.replace("{userId}", userId)
.replace("{apiKeyId}", apiKeyId)
.replace("{clientId}", clientId);
}

View file

@ -67,7 +67,8 @@ export default function SupporterStatus({ isCollapsed = false }: SupporterStatus
const [keyOpen, setKeyOpen] = useState(false);
const [purchaseOptionsOpen, setPurchaseOptionsOpen] = useState(false);
const api = createApiClient(useEnvContext());
const { env } = useEnvContext();
const api = createApiClient({ env });
const t = useTranslations();
const formSchema = z.object({

View file

@ -497,7 +497,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
<div className="w-full">
<div
className={cn(
`flex flex-row flex-wrap items-center gap-1.5 p-1.5 w-full rounded-md border border-input bg-background text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50`,
`flex flex-row flex-wrap items-center gap-1.5 p-1.5 w-full rounded-md border border-input text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50`,
styleClasses?.inlineTagsContainer
)}
>

View file

@ -16,9 +16,9 @@ const badgeVariants = cva(
destructive:
"border-transparent bg-destructive text-destructive-foreground",
outline: "text-foreground",
green: "border-transparent bg-green-500",
yellow: "border-transparent bg-yellow-500",
red: "border-transparent bg-red-300",
green: "border-green-600 bg-green-500/20 text-green-700 dark:text-green-300",
yellow: "border-yellow-600 bg-yellow-500/20 text-yellow-700 dark:text-yellow-300",
red: "border-red-400 bg-red-300/20 text-red-600 dark:text-red-300",
},
},
defaultVariants: {

Some files were not shown because too many files have changed in this diff Show more