mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-29 23:25:58 +02:00
Pull up downstream changes
This commit is contained in:
parent
c679875273
commit
98a261e38c
108 changed files with 9799 additions and 2038 deletions
|
@ -27,3 +27,4 @@ bruno/
|
||||||
LICENSE
|
LICENSE
|
||||||
CONTRIBUTING.md
|
CONTRIBUTING.md
|
||||||
dist
|
dist
|
||||||
|
.git
|
||||||
|
|
19
docker-compose.dev.yml
Normal file
19
docker-compose.dev.yml
Normal 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
|
|
@ -3,7 +3,7 @@ import path from "path";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
dialect: "postgresql",
|
dialect: "postgresql",
|
||||||
schema: path.join("server", "db", "pg", "schema.ts"),
|
schema: [path.join("server", "db", "pg", "schema.ts")],
|
||||||
out: path.join("server", "migrations"),
|
out: path.join("server", "migrations"),
|
||||||
verbose: true,
|
verbose: true,
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
name: captcha_remediation
|
iame: captcha_remediation
|
||||||
filters:
|
filters:
|
||||||
- Alert.Remediation == true && Alert.GetScope() == "Ip" && Alert.GetScenario() contains "http"
|
- Alert.Remediation == true && Alert.GetScope() == "Ip" && Alert.GetScenario() contains "http"
|
||||||
decisions:
|
decisions:
|
||||||
|
|
|
@ -10,7 +10,8 @@
|
||||||
"setupErrorIdentifier": "Organization ID is already taken. Please choose a different one.",
|
"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.",
|
"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.",
|
"componentsErrorNoMember": "You are not currently a member of any organizations.",
|
||||||
"welcome": "Welcome to Pangolin",
|
"welcome": "Welcome!",
|
||||||
|
"welcomeTo": "Welcome to",
|
||||||
"componentsCreateOrg": "Create an Organization",
|
"componentsCreateOrg": "Create an Organization",
|
||||||
"componentsMember": "You're a member of {count, plural, =0 {no organization} =1 {one organization} other {# organizations}}.",
|
"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.",
|
"componentsInvalidKey": "Invalid or expired license keys detected. Follow license terms to continue using all features.",
|
||||||
|
@ -777,8 +778,6 @@
|
||||||
"orgPoliciesAdd": "Add Organization Policy",
|
"orgPoliciesAdd": "Add Organization Policy",
|
||||||
"orgRequired": "Organization is required",
|
"orgRequired": "Organization is required",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"refreshError": "Failed to refresh data",
|
|
||||||
"refresh": "Refresh",
|
|
||||||
"success": "Success",
|
"success": "Success",
|
||||||
"orgPolicyAddedDescription": "Policy added successfully",
|
"orgPolicyAddedDescription": "Policy added successfully",
|
||||||
"orgPolicyUpdatedDescription": "Policy updated successfully",
|
"orgPolicyUpdatedDescription": "Policy updated successfully",
|
||||||
|
@ -1094,6 +1093,7 @@
|
||||||
"sidebarIdentityProviders": "Identity Providers",
|
"sidebarIdentityProviders": "Identity Providers",
|
||||||
"sidebarLicense": "License",
|
"sidebarLicense": "License",
|
||||||
"sidebarClients": "Clients",
|
"sidebarClients": "Clients",
|
||||||
|
"sidebarDomains": "Domains",
|
||||||
"enableDockerSocket": "Enable Docker Socket",
|
"enableDockerSocket": "Enable Docker Socket",
|
||||||
"enableDockerSocketDescription": "Enable Docker Socket discovery for populating container information. Socket path must be provided to Newt.",
|
"enableDockerSocketDescription": "Enable Docker Socket discovery for populating container information. Socket path must be provided to Newt.",
|
||||||
"enableDockerSocketLink": "Learn More",
|
"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.",
|
"initialSetupDescription": "Create the intial server admin account. Only one server admin can exist. You can always change these credentials later.",
|
||||||
"createAdminAccount": "Create Admin Account",
|
"createAdminAccount": "Create Admin Account",
|
||||||
"setupErrorCreateAdmin": "An error occurred while creating the server 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",
|
"documentation": "Documentation",
|
||||||
"saveAllSettings": "Save All Settings",
|
"saveAllSettings": "Save All Settings",
|
||||||
"settingsUpdated": "Settings updated",
|
"settingsUpdated": "Settings updated",
|
||||||
|
@ -1147,5 +1191,23 @@
|
||||||
"sidebarCollapse": "Collapse",
|
"sidebarCollapse": "Collapse",
|
||||||
"sidebarExpand": "Expand",
|
"sidebarExpand": "Expand",
|
||||||
"newtUpdateAvailable": "Update Available",
|
"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
4558
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -28,6 +28,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asteasolutions/zod-to-openapi": "^7.3.2",
|
"@asteasolutions/zod-to-openapi": "^7.3.2",
|
||||||
|
"@aws-sdk/client-s3": "3.837.0",
|
||||||
"@hookform/resolvers": "3.9.1",
|
"@hookform/resolvers": "3.9.1",
|
||||||
"@node-rs/argon2": "^2.0.2",
|
"@node-rs/argon2": "^2.0.2",
|
||||||
"@oslojs/crypto": "1.0.1",
|
"@oslojs/crypto": "1.0.1",
|
||||||
|
@ -101,6 +102,7 @@
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"rebuild": "0.1.2",
|
"rebuild": "0.1.2",
|
||||||
"semver": "7.7.2",
|
"semver": "7.7.2",
|
||||||
|
"stripe": "18.2.1",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
"tailwind-merge": "2.6.0",
|
"tailwind-merge": "2.6.0",
|
||||||
"tw-animate-css": "^1.3.3",
|
"tw-animate-css": "^1.3.3",
|
||||||
|
@ -127,6 +129,7 @@
|
||||||
"@types/jsonwebtoken": "^9.0.9",
|
"@types/jsonwebtoken": "^9.0.9",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"@types/nodemailer": "6.4.17",
|
"@types/nodemailer": "6.4.17",
|
||||||
|
"@types/pg": "8.15.4",
|
||||||
"@types/react": "19.1.7",
|
"@types/react": "19.1.7",
|
||||||
"@types/react-dom": "19.1.6",
|
"@types/react-dom": "19.1.6",
|
||||||
"@types/semver": "7.7.0",
|
"@types/semver": "7.7.0",
|
||||||
|
|
|
@ -6,7 +6,8 @@ import logger from "@server/logger";
|
||||||
import {
|
import {
|
||||||
errorHandlerMiddleware,
|
errorHandlerMiddleware,
|
||||||
notFoundMiddleware,
|
notFoundMiddleware,
|
||||||
rateLimitMiddleware
|
rateLimitMiddleware,
|
||||||
|
requestTimeoutMiddleware
|
||||||
} from "@server/middlewares";
|
} from "@server/middlewares";
|
||||||
import { authenticated, unauthenticated } from "@server/routers/external";
|
import { authenticated, unauthenticated } from "@server/routers/external";
|
||||||
import { router as wsRouter, handleWSUpgrade } from "@server/routers/ws";
|
import { router as wsRouter, handleWSUpgrade } from "@server/routers/ws";
|
||||||
|
@ -19,6 +20,7 @@ const externalPort = config.getRawConfig().server.external_port;
|
||||||
|
|
||||||
export function createApiServer() {
|
export function createApiServer() {
|
||||||
const apiServer = express();
|
const apiServer = express();
|
||||||
|
const prefix = `/api/v1`;
|
||||||
|
|
||||||
const trustProxy = config.getRawConfig().server.trust_proxy;
|
const trustProxy = config.getRawConfig().server.trust_proxy;
|
||||||
if (trustProxy) {
|
if (trustProxy) {
|
||||||
|
@ -54,6 +56,9 @@ export function createApiServer() {
|
||||||
apiServer.use(cookieParser());
|
apiServer.use(cookieParser());
|
||||||
apiServer.use(express.json());
|
apiServer.use(express.json());
|
||||||
|
|
||||||
|
// Add request timeout middleware
|
||||||
|
apiServer.use(requestTimeoutMiddleware(60000)); // 60 second timeout
|
||||||
|
|
||||||
if (!dev) {
|
if (!dev) {
|
||||||
apiServer.use(
|
apiServer.use(
|
||||||
rateLimitMiddleware({
|
rateLimitMiddleware({
|
||||||
|
@ -66,7 +71,6 @@ export function createApiServer() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// API routes
|
// API routes
|
||||||
const prefix = `/api/v1`;
|
|
||||||
apiServer.use(logIncomingMiddleware);
|
apiServer.use(logIncomingMiddleware);
|
||||||
apiServer.use(prefix, unauthenticated);
|
apiServer.use(prefix, unauthenticated);
|
||||||
apiServer.use(prefix, authenticated);
|
apiServer.use(prefix, authenticated);
|
||||||
|
|
|
@ -90,7 +90,10 @@ export enum ActionsEnum {
|
||||||
setApiKeyOrgs = "setApiKeyOrgs",
|
setApiKeyOrgs = "setApiKeyOrgs",
|
||||||
listApiKeyActions = "listApiKeyActions",
|
listApiKeyActions = "listApiKeyActions",
|
||||||
listApiKeys = "listApiKeys",
|
listApiKeys = "listApiKeys",
|
||||||
getApiKey = "getApiKey"
|
getApiKey = "getApiKey",
|
||||||
|
createOrgDomain = "createOrgDomain",
|
||||||
|
deleteOrgDomain = "deleteOrgDomain",
|
||||||
|
restartOrgDomain = "restartOrgDomain"
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkUserActionPermission(
|
export async function checkUserActionPermission(
|
||||||
|
|
|
@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -59,7 +59,7 @@ export async function getUniqueExitNodeEndpointName(): Promise<string> {
|
||||||
|
|
||||||
|
|
||||||
export function generateName(): string {
|
export function generateName(): string {
|
||||||
return (
|
const name = (
|
||||||
names.descriptors[
|
names.descriptors[
|
||||||
Math.floor(Math.random() * names.descriptors.length)
|
Math.floor(Math.random() * names.descriptors.length)
|
||||||
] +
|
] +
|
||||||
|
@ -68,4 +68,7 @@ export function generateName(): string {
|
||||||
)
|
)
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/\s/g, "-");
|
.replace(/\s/g, "-");
|
||||||
|
|
||||||
|
// clean out any non-alphanumeric characters except for dashes
|
||||||
|
return name.replace(/[^a-z0-9-]/g, "");
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres";
|
import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres";
|
||||||
|
import { Pool } from "pg";
|
||||||
import { readConfigFile } from "@server/lib/readConfigFile";
|
import { readConfigFile } from "@server/lib/readConfigFile";
|
||||||
import { withReplicas } from "drizzle-orm/pg-core";
|
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 = [];
|
const replicas = [];
|
||||||
|
|
||||||
if (!replicaConnections.length) {
|
if (!replicaConnections.length) {
|
||||||
replicas.push(primary);
|
replicas.push(DrizzlePostgres(primaryPool));
|
||||||
} else {
|
} else {
|
||||||
for (const conn of replicaConnections) {
|
for (const conn of replicaConnections) {
|
||||||
const replica = DrizzlePostgres(conn.connection_string);
|
const replicaPool = new Pool({
|
||||||
replicas.push(replica);
|
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();
|
export const db = createDb();
|
||||||
|
|
|
@ -14,6 +14,9 @@ export const domains = pgTable("domains", {
|
||||||
baseDomain: varchar("baseDomain").notNull(),
|
baseDomain: varchar("baseDomain").notNull(),
|
||||||
configManaged: boolean("configManaged").notNull().default(false),
|
configManaged: boolean("configManaged").notNull().default(false),
|
||||||
type: varchar("type"), // "ns", "cname", "a"
|
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", {
|
export const orgs = pgTable("orgs", {
|
||||||
|
@ -44,9 +47,9 @@ export const sites = pgTable("sites", {
|
||||||
}),
|
}),
|
||||||
name: varchar("name").notNull(),
|
name: varchar("name").notNull(),
|
||||||
pubKey: varchar("pubKey"),
|
pubKey: varchar("pubKey"),
|
||||||
subnet: varchar("subnet").notNull(),
|
subnet: varchar("subnet"),
|
||||||
megabytesIn: real("bytesIn"),
|
megabytesIn: real("bytesIn").default(0),
|
||||||
megabytesOut: real("bytesOut"),
|
megabytesOut: real("bytesOut").default(0),
|
||||||
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
|
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
|
||||||
type: varchar("type").notNull(), // "newt" or "wireguard"
|
type: varchar("type").notNull(), // "newt" or "wireguard"
|
||||||
online: boolean("online").notNull().default(false),
|
online: boolean("online").notNull().default(false),
|
||||||
|
@ -282,18 +285,6 @@ export const userResources = pgTable("userResources", {
|
||||||
.references(() => resources.resourceId, { onDelete: "cascade" })
|
.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", {
|
export const userInvites = pgTable("userInvites", {
|
||||||
inviteId: varchar("inviteId").primaryKey(),
|
inviteId: varchar("inviteId").primaryKey(),
|
||||||
orgId: varchar("orgId")
|
orgId: varchar("orgId")
|
||||||
|
@ -520,7 +511,8 @@ export const clients = pgTable("clients", {
|
||||||
type: varchar("type").notNull(), // "olm"
|
type: varchar("type").notNull(), // "olm"
|
||||||
online: boolean("online").notNull().default(false),
|
online: boolean("online").notNull().default(false),
|
||||||
endpoint: varchar("endpoint"),
|
endpoint: varchar("endpoint"),
|
||||||
lastHolePunch: integer("lastHolePunch")
|
lastHolePunch: integer("lastHolePunch"),
|
||||||
|
maxConnections: integer("maxConnections")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const clientSites = pgTable("clientSites", {
|
export const clientSites = pgTable("clientSites", {
|
||||||
|
@ -590,7 +582,6 @@ export type RoleSite = InferSelectModel<typeof roleSites>;
|
||||||
export type UserSite = InferSelectModel<typeof userSites>;
|
export type UserSite = InferSelectModel<typeof userSites>;
|
||||||
export type RoleResource = InferSelectModel<typeof roleResources>;
|
export type RoleResource = InferSelectModel<typeof roleResources>;
|
||||||
export type UserResource = InferSelectModel<typeof userResources>;
|
export type UserResource = InferSelectModel<typeof userResources>;
|
||||||
export type Limit = InferSelectModel<typeof limitsTable>;
|
|
||||||
export type UserInvite = InferSelectModel<typeof userInvites>;
|
export type UserInvite = InferSelectModel<typeof userInvites>;
|
||||||
export type UserOrg = InferSelectModel<typeof userOrgs>;
|
export type UserOrg = InferSelectModel<typeof userOrgs>;
|
||||||
export type ResourceSession = InferSelectModel<typeof resourceSessions>;
|
export type ResourceSession = InferSelectModel<typeof resourceSessions>;
|
||||||
|
@ -613,3 +604,4 @@ export type Olm = InferSelectModel<typeof olms>;
|
||||||
export type OlmSession = InferSelectModel<typeof olmSessions>;
|
export type OlmSession = InferSelectModel<typeof olmSessions>;
|
||||||
export type UserClient = InferSelectModel<typeof userClients>;
|
export type UserClient = InferSelectModel<typeof userClients>;
|
||||||
export type RoleClient = InferSelectModel<typeof roleClients>;
|
export type RoleClient = InferSelectModel<typeof roleClients>;
|
||||||
|
export type OrgDomains = InferSelectModel<typeof orgDomains>;
|
|
@ -3,30 +3,25 @@ import logger from "@server/logger";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
class RedisManager {
|
class RedisManager {
|
||||||
private static instance: RedisManager;
|
|
||||||
public client: Redis | null = null;
|
public client: Redis | null = null;
|
||||||
private subscriber: Redis | null = null;
|
private subscriber: Redis | null = null;
|
||||||
private publisher: Redis | null = null;
|
private publisher: Redis | null = null;
|
||||||
private isEnabled: boolean = false;
|
private isEnabled: boolean = false;
|
||||||
|
private isHealthy: boolean = true;
|
||||||
|
private lastHealthCheck: number = 0;
|
||||||
|
private healthCheckInterval: number = 30000; // 30 seconds
|
||||||
private subscribers: Map<
|
private subscribers: Map<
|
||||||
string,
|
string,
|
||||||
Set<(channel: string, message: string) => void>
|
Set<(channel: string, message: string) => void>
|
||||||
> = new Map();
|
> = new Map();
|
||||||
|
|
||||||
private constructor() {
|
constructor() {
|
||||||
this.isEnabled = config.getRawConfig().flags?.enable_redis || false;
|
this.isEnabled = config.getRawConfig().flags?.enable_redis || false;
|
||||||
if (this.isEnabled) {
|
if (this.isEnabled) {
|
||||||
this.initializeClients();
|
this.initializeClients();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static getInstance(): RedisManager {
|
|
||||||
if (!RedisManager.instance) {
|
|
||||||
RedisManager.instance = new RedisManager();
|
|
||||||
}
|
|
||||||
return RedisManager.instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getRedisConfig(): RedisOptions {
|
private getRedisConfig(): RedisOptions {
|
||||||
const redisConfig = config.getRawConfig().redis!;
|
const redisConfig = config.getRawConfig().redis!;
|
||||||
const opts: RedisOptions = {
|
const opts: RedisOptions = {
|
||||||
|
@ -34,38 +29,78 @@ class RedisManager {
|
||||||
port: redisConfig.port!,
|
port: redisConfig.port!,
|
||||||
password: redisConfig.password,
|
password: redisConfig.password,
|
||||||
db: redisConfig.db,
|
db: redisConfig.db,
|
||||||
tls: {
|
// tls: {
|
||||||
rejectUnauthorized:
|
// rejectUnauthorized:
|
||||||
redisConfig.tls?.reject_unauthorized || false
|
// redisConfig.tls?.reject_unauthorized || false
|
||||||
}
|
// }
|
||||||
};
|
};
|
||||||
return opts;
|
return opts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add reconnection logic in initializeClients
|
||||||
private initializeClients(): void {
|
private initializeClients(): void {
|
||||||
const config = this.getRedisConfig();
|
const config = this.getRedisConfig();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Main client for general operations
|
this.client = new Redis({
|
||||||
this.client = new Redis(config);
|
...config,
|
||||||
|
enableReadyCheck: false,
|
||||||
|
maxRetriesPerRequest: 3,
|
||||||
|
keepAlive: 30000,
|
||||||
|
connectTimeout: 10000, // 10 seconds
|
||||||
|
commandTimeout: 5000, // 5 seconds
|
||||||
|
});
|
||||||
|
|
||||||
// Dedicated publisher client
|
this.publisher = new Redis({
|
||||||
this.publisher = new Redis(config);
|
...config,
|
||||||
|
enableReadyCheck: false,
|
||||||
|
maxRetriesPerRequest: 3,
|
||||||
|
keepAlive: 30000,
|
||||||
|
connectTimeout: 10000, // 10 seconds
|
||||||
|
commandTimeout: 5000, // 5 seconds
|
||||||
|
});
|
||||||
|
|
||||||
// Dedicated subscriber client
|
this.subscriber = new Redis({
|
||||||
this.subscriber = new Redis(config);
|
...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) => {
|
this.client.on("error", (err) => {
|
||||||
logger.error("Redis client 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) => {
|
this.publisher.on("error", (err) => {
|
||||||
logger.error("Redis publisher 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) => {
|
this.subscriber.on("error", (err) => {
|
||||||
logger.error("Redis subscriber error:", err);
|
logger.error("Redis subscriber error:", err);
|
||||||
|
this.isHealthy = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.subscriber.on("ready", () => {
|
||||||
|
logger.info("Redis subscriber ready");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up connection handlers
|
// Set up connection handlers
|
||||||
|
@ -102,18 +137,65 @@ class RedisManager {
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info("Redis clients initialized successfully");
|
logger.info("Redis clients initialized successfully");
|
||||||
|
|
||||||
|
// Start periodic health monitoring
|
||||||
|
this.startHealthMonitoring();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Failed to initialize Redis clients:", error);
|
logger.error("Failed to initialize Redis clients:", error);
|
||||||
this.isEnabled = false;
|
this.isEnabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public isRedisEnabled(): boolean {
|
private startHealthMonitoring(): void {
|
||||||
return this.isEnabled && this.client !== null;
|
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 {
|
public isRedisEnabled(): boolean {
|
||||||
return this.client;
|
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(
|
public async set(
|
||||||
|
@ -247,11 +329,25 @@ class RedisManager {
|
||||||
public async publish(channel: string, message: string): Promise<boolean> {
|
public async publish(channel: string, message: string): Promise<boolean> {
|
||||||
if (!this.isRedisEnabled() || !this.publisher) return false;
|
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 {
|
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;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Redis PUBLISH error:", error);
|
logger.error("Redis PUBLISH error:", error);
|
||||||
|
this.isHealthy = false; // Mark as unhealthy on error
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -267,13 +363,19 @@ class RedisManager {
|
||||||
if (!this.subscribers.has(channel)) {
|
if (!this.subscribers.has(channel)) {
|
||||||
this.subscribers.set(channel, new Set());
|
this.subscribers.set(channel, new Set());
|
||||||
// Only subscribe to the channel if it's the first subscriber
|
// 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);
|
this.subscribers.get(channel)!.add(callback);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Redis SUBSCRIBE error:", error);
|
logger.error("Redis SUBSCRIBE error:", error);
|
||||||
|
this.isHealthy = false;
|
||||||
return 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;
|
export default redisManager;
|
||||||
|
|
|
@ -7,7 +7,7 @@ export const domains = sqliteTable("domains", {
|
||||||
configManaged: integer("configManaged", { mode: "boolean" })
|
configManaged: integer("configManaged", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
type: text("type"), // "ns", "cname", "a"
|
type: text("type") // "ns", "cname", "a"
|
||||||
});
|
});
|
||||||
|
|
||||||
export const orgs = sqliteTable("orgs", {
|
export const orgs = sqliteTable("orgs", {
|
||||||
|
@ -16,6 +16,15 @@ export const orgs = sqliteTable("orgs", {
|
||||||
subnet: text("subnet").notNull(),
|
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", {
|
export const orgDomains = sqliteTable("orgDomains", {
|
||||||
orgId: text("orgId")
|
orgId: text("orgId")
|
||||||
.notNull()
|
.notNull()
|
||||||
|
@ -38,9 +47,9 @@ export const sites = sqliteTable("sites", {
|
||||||
}),
|
}),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
pubKey: text("pubKey"),
|
pubKey: text("pubKey"),
|
||||||
subnet: text("subnet").notNull(),
|
subnet: text("subnet"),
|
||||||
megabytesIn: integer("bytesIn"),
|
megabytesIn: integer("bytesIn").default(0),
|
||||||
megabytesOut: integer("bytesOut"),
|
megabytesOut: integer("bytesOut").default(0),
|
||||||
lastBandwidthUpdate: text("lastBandwidthUpdate"),
|
lastBandwidthUpdate: text("lastBandwidthUpdate"),
|
||||||
type: text("type").notNull(), // "newt" or "wireguard"
|
type: text("type").notNull(), // "newt" or "wireguard"
|
||||||
online: integer("online", { mode: "boolean" }).notNull().default(false),
|
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
|
// 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
|
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
|
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"),
|
lastHolePunch: integer("lastHolePunch"),
|
||||||
listenPort: integer("listenPort"),
|
listenPort: integer("listenPort"),
|
||||||
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
|
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
|
||||||
|
@ -626,13 +635,14 @@ export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>;
|
||||||
export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
|
export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
|
||||||
export type VersionMigration = InferSelectModel<typeof versionMigrations>;
|
export type VersionMigration = InferSelectModel<typeof versionMigrations>;
|
||||||
export type ResourceRule = InferSelectModel<typeof resourceRules>;
|
export type ResourceRule = InferSelectModel<typeof resourceRules>;
|
||||||
|
export type Domain = InferSelectModel<typeof domains>;
|
||||||
export type Client = InferSelectModel<typeof clients>;
|
export type Client = InferSelectModel<typeof clients>;
|
||||||
export type ClientSite = InferSelectModel<typeof clientSites>;
|
export type ClientSite = InferSelectModel<typeof clientSites>;
|
||||||
export type RoleClient = InferSelectModel<typeof roleClients>;
|
export type RoleClient = InferSelectModel<typeof roleClients>;
|
||||||
export type UserClient = InferSelectModel<typeof userClients>;
|
export type UserClient = InferSelectModel<typeof userClients>;
|
||||||
export type Domain = InferSelectModel<typeof domains>;
|
|
||||||
export type SupporterKey = InferSelectModel<typeof supporterKey>;
|
export type SupporterKey = InferSelectModel<typeof supporterKey>;
|
||||||
export type Idp = InferSelectModel<typeof idp>;
|
export type Idp = InferSelectModel<typeof idp>;
|
||||||
export type ApiKey = InferSelectModel<typeof apiKeys>;
|
export type ApiKey = InferSelectModel<typeof apiKeys>;
|
||||||
export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>;
|
export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>;
|
||||||
export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>;
|
export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>;
|
||||||
|
export type OrgDomains = InferSelectModel<typeof orgDomains>;
|
|
@ -2,6 +2,7 @@ import { render } from "@react-email/render";
|
||||||
import { ReactElement } from "react";
|
import { ReactElement } from "react";
|
||||||
import emailClient from "@server/emails";
|
import emailClient from "@server/emails";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
export async function sendEmail(
|
export async function sendEmail(
|
||||||
template: ReactElement,
|
template: ReactElement,
|
||||||
|
@ -24,9 +25,11 @@ export async function sendEmail(
|
||||||
|
|
||||||
const emailHtml = await render(template);
|
const emailHtml = await render(template);
|
||||||
|
|
||||||
|
const appName = "Fossorial - Pangolin";
|
||||||
|
|
||||||
await emailClient.sendMail({
|
await emailClient.sendMail({
|
||||||
from: {
|
from: {
|
||||||
name: opts.name || "Pangolin",
|
name: opts.name || appName,
|
||||||
address: opts.from,
|
address: opts.from,
|
||||||
},
|
},
|
||||||
to: opts.to,
|
to: opts.to,
|
||||||
|
|
|
@ -1,11 +1,5 @@
|
||||||
import {
|
import React from "react";
|
||||||
Body,
|
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
|
||||||
Head,
|
|
||||||
Html,
|
|
||||||
Preview,
|
|
||||||
Tailwind
|
|
||||||
} from "@react-email/components";
|
|
||||||
import * as React from "react";
|
|
||||||
import { themeColors } from "./lib/theme";
|
import { themeColors } from "./lib/theme";
|
||||||
import {
|
import {
|
||||||
EmailContainer,
|
EmailContainer,
|
||||||
|
@ -22,29 +16,29 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ConfirmPasswordReset = ({ email }: Props) => {
|
export const ConfirmPasswordReset = ({ email }: Props) => {
|
||||||
const previewText = `Your password has been reset`;
|
const previewText = `Your password has been successfully reset.`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
<Preview>{previewText}</Preview>
|
<Preview>{previewText}</Preview>
|
||||||
<Tailwind config={themeColors}>
|
<Tailwind config={themeColors}>
|
||||||
<Body className="font-sans relative">
|
<Body className="font-sans bg-gray-50">
|
||||||
<EmailContainer>
|
<EmailContainer>
|
||||||
<EmailLetterHead />
|
<EmailLetterHead />
|
||||||
|
|
||||||
<EmailHeading>Password Reset Confirmation</EmailHeading>
|
{/* <EmailHeading>Password Successfully Reset</EmailHeading> */}
|
||||||
|
|
||||||
<EmailGreeting>Hi {email || "there"},</EmailGreeting>
|
<EmailGreeting>Hi there,</EmailGreeting>
|
||||||
|
|
||||||
<EmailText>
|
<EmailText>
|
||||||
This email confirms that your password has just been
|
Your password has been successfully reset. You can
|
||||||
reset. If you made this change, no further action is
|
now sign in to your account using your new password.
|
||||||
required.
|
|
||||||
</EmailText>
|
</EmailText>
|
||||||
|
|
||||||
<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>
|
</EmailText>
|
||||||
|
|
||||||
<EmailFooter>
|
<EmailFooter>
|
||||||
|
|
|
@ -1,11 +1,5 @@
|
||||||
import {
|
import React from "react";
|
||||||
Body,
|
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
|
||||||
Head,
|
|
||||||
Html,
|
|
||||||
Preview,
|
|
||||||
Tailwind
|
|
||||||
} from "@react-email/components";
|
|
||||||
import * as React from "react";
|
|
||||||
import { themeColors } from "./lib/theme";
|
import { themeColors } from "./lib/theme";
|
||||||
import {
|
import {
|
||||||
EmailContainer,
|
EmailContainer,
|
||||||
|
@ -18,6 +12,7 @@ import {
|
||||||
EmailText
|
EmailText
|
||||||
} from "./components/Email";
|
} from "./components/Email";
|
||||||
import CopyCodeBox from "./components/CopyCodeBox";
|
import CopyCodeBox from "./components/CopyCodeBox";
|
||||||
|
import ButtonLink from "./components/ButtonLink";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
email: string;
|
email: string;
|
||||||
|
@ -26,37 +21,39 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ResetPasswordCode = ({ email, code, link }: 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 (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
<Preview>{previewText}</Preview>
|
<Preview>{previewText}</Preview>
|
||||||
<Tailwind config={themeColors}>
|
<Tailwind config={themeColors}>
|
||||||
<Body className="font-sans">
|
<Body className="font-sans bg-gray-50">
|
||||||
<EmailContainer>
|
<EmailContainer>
|
||||||
<EmailLetterHead />
|
<EmailLetterHead />
|
||||||
|
|
||||||
<EmailHeading>Password Reset Request</EmailHeading>
|
{/* <EmailHeading>Reset Your Password</EmailHeading> */}
|
||||||
|
|
||||||
<EmailGreeting>Hi {email || "there"},</EmailGreeting>
|
<EmailGreeting>Hi there,</EmailGreeting>
|
||||||
|
|
||||||
<EmailText>
|
<EmailText>
|
||||||
You’ve requested to reset your password. Please{" "}
|
You've requested to reset your password. Click the
|
||||||
<a href={link} className="text-primary">
|
button below to reset your password, or use the
|
||||||
click here
|
verification code provided if prompted.
|
||||||
</a>{" "}
|
|
||||||
and follow the instructions to reset your password,
|
|
||||||
or manually enter the following code:
|
|
||||||
</EmailText>
|
</EmailText>
|
||||||
|
|
||||||
|
<EmailSection>
|
||||||
|
<ButtonLink href={link}>Reset Password</ButtonLink>
|
||||||
|
</EmailSection>
|
||||||
|
|
||||||
<EmailSection>
|
<EmailSection>
|
||||||
<CopyCodeBox text={code} />
|
<CopyCodeBox text={code} />
|
||||||
</EmailSection>
|
</EmailSection>
|
||||||
|
|
||||||
<EmailText>
|
<EmailText>
|
||||||
If you didn’t request this, you can safely ignore
|
This reset code will expire in 2 hours. If you
|
||||||
this email.
|
didn't request a password reset, you can safely
|
||||||
|
ignore this email.
|
||||||
</EmailText>
|
</EmailText>
|
||||||
|
|
||||||
<EmailFooter>
|
<EmailFooter>
|
||||||
|
|
|
@ -1,11 +1,5 @@
|
||||||
import {
|
import React from "react";
|
||||||
Body,
|
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
|
||||||
Head,
|
|
||||||
Html,
|
|
||||||
Preview,
|
|
||||||
Tailwind
|
|
||||||
} from "@react-email/components";
|
|
||||||
import * as React from "react";
|
|
||||||
import {
|
import {
|
||||||
EmailContainer,
|
EmailContainer,
|
||||||
EmailLetterHead,
|
EmailLetterHead,
|
||||||
|
@ -32,34 +26,40 @@ export const ResourceOTPCode = ({
|
||||||
orgName: organizationName,
|
orgName: organizationName,
|
||||||
otp
|
otp
|
||||||
}: ResourceOTPCodeProps) => {
|
}: ResourceOTPCodeProps) => {
|
||||||
const previewText = `Your one-time password for ${resourceName} is ${otp}`;
|
const previewText = `Your access code for ${resourceName}: ${otp}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
<Preview>{previewText}</Preview>
|
<Preview>{previewText}</Preview>
|
||||||
<Tailwind config={themeColors}>
|
<Tailwind config={themeColors}>
|
||||||
<Body className="font-sans">
|
<Body className="font-sans bg-gray-50">
|
||||||
<EmailContainer>
|
<EmailContainer>
|
||||||
<EmailLetterHead />
|
<EmailLetterHead />
|
||||||
|
|
||||||
<EmailHeading>
|
{/* <EmailHeading> */}
|
||||||
Your One-Time Code for {resourceName}
|
{/* Access Code for {resourceName} */}
|
||||||
</EmailHeading>
|
{/* </EmailHeading> */}
|
||||||
|
|
||||||
<EmailGreeting>Hi {email || "there"},</EmailGreeting>
|
<EmailGreeting>Hi there,</EmailGreeting>
|
||||||
|
|
||||||
<EmailText>
|
<EmailText>
|
||||||
You’ve requested a one-time password to access{" "}
|
You've requested access to{" "}
|
||||||
<strong>{resourceName}</strong> in{" "}
|
<strong>{resourceName}</strong> in{" "}
|
||||||
<strong>{organizationName}</strong>. Use the code
|
<strong>{organizationName}</strong>. Use the
|
||||||
below to complete your authentication:
|
verification code below to complete your
|
||||||
|
authentication.
|
||||||
</EmailText>
|
</EmailText>
|
||||||
|
|
||||||
<EmailSection>
|
<EmailSection>
|
||||||
<CopyCodeBox text={otp} />
|
<CopyCodeBox text={otp} />
|
||||||
</EmailSection>
|
</EmailSection>
|
||||||
|
|
||||||
|
<EmailText>
|
||||||
|
This code will expire in 15 minutes. If you didn't
|
||||||
|
request this code, please ignore this email.
|
||||||
|
</EmailText>
|
||||||
|
|
||||||
<EmailFooter>
|
<EmailFooter>
|
||||||
<EmailSignature />
|
<EmailSignature />
|
||||||
</EmailFooter>
|
</EmailFooter>
|
||||||
|
|
|
@ -1,11 +1,5 @@
|
||||||
import {
|
import React from "react";
|
||||||
Body,
|
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
|
||||||
Head,
|
|
||||||
Html,
|
|
||||||
Preview,
|
|
||||||
Tailwind,
|
|
||||||
} from "@react-email/components";
|
|
||||||
import * as React from "react";
|
|
||||||
import { themeColors } from "./lib/theme";
|
import { themeColors } from "./lib/theme";
|
||||||
import {
|
import {
|
||||||
EmailContainer,
|
EmailContainer,
|
||||||
|
@ -41,35 +35,44 @@ export const SendInviteLink = ({
|
||||||
<Head />
|
<Head />
|
||||||
<Preview>{previewText}</Preview>
|
<Preview>{previewText}</Preview>
|
||||||
<Tailwind config={themeColors}>
|
<Tailwind config={themeColors}>
|
||||||
<Body className="font-sans">
|
<Body className="font-sans bg-gray-50">
|
||||||
<EmailContainer>
|
<EmailContainer>
|
||||||
<EmailLetterHead />
|
<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>
|
<EmailText>
|
||||||
You’ve been invited to join the organization{" "}
|
You've been invited to join{" "}
|
||||||
<strong>{orgName}</strong>
|
<strong>{orgName}</strong>
|
||||||
{inviterName ? ` by ${inviterName}.` : "."} Please
|
{inviterName ? ` by ${inviterName}` : ""}. Click the
|
||||||
access the link below to accept the invite.
|
button below to accept your invitation and get
|
||||||
</EmailText>
|
started.
|
||||||
|
|
||||||
<EmailText>
|
|
||||||
This invite will expire in{" "}
|
|
||||||
<strong>
|
|
||||||
{expiresInDays}{" "}
|
|
||||||
{expiresInDays === "1" ? "day" : "days"}.
|
|
||||||
</strong>
|
|
||||||
</EmailText>
|
</EmailText>
|
||||||
|
|
||||||
<EmailSection>
|
<EmailSection>
|
||||||
<ButtonLink href={inviteLink}>
|
<ButtonLink href={inviteLink}>
|
||||||
Accept Invite to {orgName}
|
Accept Invitation
|
||||||
</ButtonLink>
|
</ButtonLink>
|
||||||
</EmailSection>
|
</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>
|
<EmailFooter>
|
||||||
<EmailSignature />
|
<EmailSignature />
|
||||||
</EmailFooter>
|
</EmailFooter>
|
||||||
|
|
|
@ -1,11 +1,5 @@
|
||||||
import {
|
import React from "react";
|
||||||
Body,
|
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
|
||||||
Head,
|
|
||||||
Html,
|
|
||||||
Preview,
|
|
||||||
Tailwind
|
|
||||||
} from "@react-email/components";
|
|
||||||
import * as React from "react";
|
|
||||||
import { themeColors } from "./lib/theme";
|
import { themeColors } from "./lib/theme";
|
||||||
import {
|
import {
|
||||||
EmailContainer,
|
EmailContainer,
|
||||||
|
@ -23,44 +17,52 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TwoFactorAuthNotification = ({ email, enabled }: 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 (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
<Preview>{previewText}</Preview>
|
<Preview>{previewText}</Preview>
|
||||||
<Tailwind config={themeColors}>
|
<Tailwind config={themeColors}>
|
||||||
<Body className="font-sans">
|
<Body className="font-sans bg-gray-50">
|
||||||
<EmailContainer>
|
<EmailContainer>
|
||||||
<EmailLetterHead />
|
<EmailLetterHead />
|
||||||
|
|
||||||
<EmailHeading>
|
{/* <EmailHeading> */}
|
||||||
Two-Factor Authentication{" "}
|
{/* Security Update: 2FA{" "} */}
|
||||||
{enabled ? "Enabled" : "Disabled"}
|
{/* {enabled ? "Enabled" : "Disabled"} */}
|
||||||
</EmailHeading>
|
{/* </EmailHeading> */}
|
||||||
|
|
||||||
<EmailGreeting>Hi {email || "there"},</EmailGreeting>
|
<EmailGreeting>Hi there,</EmailGreeting>
|
||||||
|
|
||||||
<EmailText>
|
<EmailText>
|
||||||
This email confirms that Two-Factor Authentication
|
Two-factor authentication has been successfully{" "}
|
||||||
has been successfully{" "}
|
<strong>{enabled ? "enabled" : "disabled"}</strong>{" "}
|
||||||
{enabled ? "enabled" : "disabled"} on your account.
|
on your account.
|
||||||
</EmailText>
|
</EmailText>
|
||||||
|
|
||||||
{enabled ? (
|
{enabled ? (
|
||||||
|
<>
|
||||||
<EmailText>
|
<EmailText>
|
||||||
With Two-Factor Authentication enabled, your
|
Your account is now protected with an
|
||||||
account is now more secure. Please ensure you
|
additional layer of security. Keep your
|
||||||
keep your authentication method safe.
|
authentication method safe and accessible.
|
||||||
</EmailText>
|
</EmailText>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<EmailText>
|
<EmailText>
|
||||||
With Two-Factor Authentication disabled, your
|
We recommend re-enabling two-factor
|
||||||
account may be less secure. We recommend
|
authentication to keep your account secure.
|
||||||
enabling it to protect your account.
|
|
||||||
</EmailText>
|
</EmailText>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<EmailText>
|
||||||
|
If you didn't make this change, please contact our
|
||||||
|
support team immediately.
|
||||||
|
</EmailText>
|
||||||
|
|
||||||
<EmailFooter>
|
<EmailFooter>
|
||||||
<EmailSignature />
|
<EmailSignature />
|
||||||
</EmailFooter>
|
</EmailFooter>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
|
import React from "react";
|
||||||
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
|
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
|
||||||
import * as React from "react";
|
|
||||||
import { themeColors } from "./lib/theme";
|
import { themeColors } from "./lib/theme";
|
||||||
import {
|
import {
|
||||||
EmailContainer,
|
EmailContainer,
|
||||||
|
@ -24,25 +24,24 @@ export const VerifyEmail = ({
|
||||||
verificationCode,
|
verificationCode,
|
||||||
verifyLink
|
verifyLink
|
||||||
}: VerifyEmailProps) => {
|
}: VerifyEmailProps) => {
|
||||||
const previewText = `Your verification code is ${verificationCode}`;
|
const previewText = `Verify your email with code: ${verificationCode}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
<Preview>{previewText}</Preview>
|
<Preview>{previewText}</Preview>
|
||||||
<Tailwind config={themeColors}>
|
<Tailwind config={themeColors}>
|
||||||
<Body className="font-sans">
|
<Body className="font-sans bg-gray-50">
|
||||||
<EmailContainer>
|
<EmailContainer>
|
||||||
<EmailLetterHead />
|
<EmailLetterHead />
|
||||||
|
|
||||||
<EmailHeading>Please Verify Your Email</EmailHeading>
|
{/* <EmailHeading>Verify Your Email Address</EmailHeading> */}
|
||||||
|
|
||||||
<EmailGreeting>Hi {username || "there"},</EmailGreeting>
|
<EmailGreeting>Hi there,</EmailGreeting>
|
||||||
|
|
||||||
<EmailText>
|
<EmailText>
|
||||||
You’ve requested to verify your email. Please use
|
Welcome! To complete your account setup, please
|
||||||
the code below to complete the verification process
|
verify your email address using the code below.
|
||||||
upon logging in.
|
|
||||||
</EmailText>
|
</EmailText>
|
||||||
|
|
||||||
<EmailSection>
|
<EmailSection>
|
||||||
|
@ -50,7 +49,8 @@ export const VerifyEmail = ({
|
||||||
</EmailSection>
|
</EmailSection>
|
||||||
|
|
||||||
<EmailText>
|
<EmailText>
|
||||||
If you didn’t 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.
|
this email.
|
||||||
</EmailText>
|
</EmailText>
|
||||||
|
|
||||||
|
|
131
server/emails/templates/WelcomeQuickStart.tsx
Normal file
131
server/emails/templates/WelcomeQuickStart.tsx
Normal 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;
|
|
@ -12,7 +12,11 @@ export default function ButtonLink({
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={href}
|
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}
|
{children}
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -2,10 +2,15 @@ import React from "react";
|
||||||
|
|
||||||
export default function CopyCodeBox({ text }: { text: string }) {
|
export default function CopyCodeBox({ text }: { text: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center rounded-lg bg-neutral-100 p-2">
|
<div className="inline-block">
|
||||||
<span className="text-2xl font-mono text-neutral-600 tracking-wide">
|
<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}
|
{text}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
|
Copy and paste this code when prompted
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,47 +1,26 @@
|
||||||
import { Container } from "@react-email/components";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { Container, Img } from "@react-email/components";
|
||||||
|
|
||||||
// EmailContainer: Wraps the entire email layout
|
// EmailContainer: Wraps the entire email layout
|
||||||
export function EmailContainer({ children }: { children: React.ReactNode }) {
|
export function EmailContainer({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
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}
|
{children}
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmailLetterHead: For branding or logo at the top
|
// EmailLetterHead: For branding with logo on dark background
|
||||||
export function EmailLetterHead() {
|
export function EmailLetterHead() {
|
||||||
return (
|
return (
|
||||||
<div className="mb-4">
|
<div className="px-6 pt-8 pb-2 text-center">
|
||||||
<table
|
<Img
|
||||||
role="presentation"
|
src="https://fossorial-public-assets.s3.us-east-1.amazonaws.com/word_mark_black.png"
|
||||||
width="100%"
|
alt="Fossorial"
|
||||||
style={{
|
width="120"
|
||||||
marginBottom: "24px"
|
height="auto"
|
||||||
}}
|
className="mx-auto"
|
||||||
>
|
/>
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -49,14 +28,22 @@ export function EmailLetterHead() {
|
||||||
// EmailHeading: For the primary message or headline
|
// EmailHeading: For the primary message or headline
|
||||||
export function EmailHeading({ children }: { children: React.ReactNode }) {
|
export function EmailHeading({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
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}
|
{children}
|
||||||
</h1>
|
</h1>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmailGreeting({ children }: { children: React.ReactNode }) {
|
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
|
// EmailText: For general text content
|
||||||
|
@ -68,9 +55,13 @@ export function EmailText({
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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}
|
{children}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -82,20 +73,70 @@ export function EmailSection({
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
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
|
// EmailFooter: For closing or signature
|
||||||
export function EmailFooter({ children }: { children: React.ReactNode }) {
|
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">
|
||||||
|
© {new Date().getFullYear()} Fossorial, Inc. All rights
|
||||||
|
reserved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmailSignature() {
|
export function EmailSignature() {
|
||||||
return (
|
return (
|
||||||
<p>
|
<div className="text-sm text-gray-600">
|
||||||
|
<p className="mb-2">
|
||||||
Best regards,
|
Best regards,
|
||||||
<br />
|
<br />
|
||||||
Fossorial
|
<strong>The Fossorial Team</strong>
|
||||||
</p>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
export const themeColors = {
|
export const themeColors = {
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
|
|
@ -113,7 +113,9 @@ export class Config {
|
||||||
|
|
||||||
private async checkKeyStatus() {
|
private async checkKeyStatus() {
|
||||||
const licenseStatus = await license.check();
|
const licenseStatus = await license.check();
|
||||||
if (!licenseStatus.isHostLicensed) {
|
if (
|
||||||
|
!licenseStatus.isHostLicensed
|
||||||
|
) {
|
||||||
this.checkSupporterKey();
|
this.checkSupporterKey();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@ type IPVersion = 4 | 6;
|
||||||
* Detects IP version from address string
|
* Detects IP version from address string
|
||||||
*/
|
*/
|
||||||
function detectIpVersion(ip: string): IPVersion {
|
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);
|
const version = detectIpVersion(ip);
|
||||||
|
|
||||||
if (version === 4) {
|
if (version === 4) {
|
||||||
return ip.split('.')
|
return ip.split(".").reduce((acc, octet) => {
|
||||||
.reduce((acc, octet) => {
|
|
||||||
const num = parseInt(octet);
|
const num = parseInt(octet);
|
||||||
if (isNaN(num) || num < 0 || num > 255) {
|
if (isNaN(num) || num < 0 || num > 255) {
|
||||||
throw new Error(`Invalid IPv4 octet: ${octet}`);
|
throw new Error(`Invalid IPv4 octet: ${octet}`);
|
||||||
|
@ -36,17 +35,18 @@ function ipToBigInt(ip: string): bigint {
|
||||||
// Handle IPv6
|
// Handle IPv6
|
||||||
// Expand :: notation
|
// Expand :: notation
|
||||||
let fullAddress = ip;
|
let fullAddress = ip;
|
||||||
if (ip.includes('::')) {
|
if (ip.includes("::")) {
|
||||||
const parts = ip.split('::');
|
const parts = ip.split("::");
|
||||||
if (parts.length > 2) throw new Error('Invalid IPv6 address: multiple :: found');
|
if (parts.length > 2)
|
||||||
const missing = 8 - (parts[0].split(':').length + parts[1].split(':').length);
|
throw new Error("Invalid IPv6 address: multiple :: found");
|
||||||
const padding = Array(missing).fill('0').join(':');
|
const missing =
|
||||||
|
8 - (parts[0].split(":").length + parts[1].split(":").length);
|
||||||
|
const padding = Array(missing).fill("0").join(":");
|
||||||
fullAddress = `${parts[0]}:${padding}:${parts[1]}`;
|
fullAddress = `${parts[0]}:${padding}:${parts[1]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return fullAddress.split(':')
|
return fullAddress.split(":").reduce((acc, hextet) => {
|
||||||
.reduce((acc, hextet) => {
|
const num = parseInt(hextet || "0", 16);
|
||||||
const num = parseInt(hextet || '0', 16);
|
|
||||||
if (isNaN(num) || num < 0 || num > 65535) {
|
if (isNaN(num) || num < 0 || num > 65535) {
|
||||||
throw new Error(`Invalid IPv6 hextet: ${hextet}`);
|
throw new Error(`Invalid IPv6 hextet: ${hextet}`);
|
||||||
}
|
}
|
||||||
|
@ -65,11 +65,15 @@ function bigIntToIp(num: bigint, version: IPVersion): string {
|
||||||
octets.unshift(Number(num & BigInt(255)));
|
octets.unshift(Number(num & BigInt(255)));
|
||||||
num = num >> BigInt(8);
|
num = num >> BigInt(8);
|
||||||
}
|
}
|
||||||
return octets.join('.');
|
return octets.join(".");
|
||||||
} else {
|
} else {
|
||||||
const hextets: string[] = [];
|
const hextets: string[] = [];
|
||||||
for (let i = 0; i < 8; i++) {
|
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);
|
num = num >> BigInt(16);
|
||||||
}
|
}
|
||||||
// Compress zero sequences
|
// Compress zero sequences
|
||||||
|
@ -79,7 +83,7 @@ function bigIntToIp(num: bigint, version: IPVersion): string {
|
||||||
let currentZeroLength = 0;
|
let currentZeroLength = 0;
|
||||||
|
|
||||||
for (let i = 0; i < hextets.length; i++) {
|
for (let i = 0; i < hextets.length; i++) {
|
||||||
if (hextets[i] === '0000') {
|
if (hextets[i] === "0000") {
|
||||||
if (currentZeroStart === -1) currentZeroStart = i;
|
if (currentZeroStart === -1) currentZeroStart = i;
|
||||||
currentZeroLength++;
|
currentZeroLength++;
|
||||||
if (currentZeroLength > maxZeroLength) {
|
if (currentZeroLength > maxZeroLength) {
|
||||||
|
@ -93,12 +97,14 @@ function bigIntToIp(num: bigint, version: IPVersion): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (maxZeroLength > 1) {
|
if (maxZeroLength > 1) {
|
||||||
hextets.splice(maxZeroStart, maxZeroLength, '');
|
hextets.splice(maxZeroStart, maxZeroLength, "");
|
||||||
if (maxZeroStart === 0) hextets.unshift('');
|
if (maxZeroStart === 0) hextets.unshift("");
|
||||||
if (maxZeroStart + maxZeroLength === 8) hextets.push('');
|
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
|
* Converts CIDR to IP range
|
||||||
*/
|
*/
|
||||||
export function cidrToRange(cidr: string): IPRange {
|
export function cidrToRange(cidr: string): IPRange {
|
||||||
const [ip, prefix] = cidr.split('/');
|
const [ip, prefix] = cidr.split("/");
|
||||||
const version = detectIpVersion(ip);
|
const version = detectIpVersion(ip);
|
||||||
const prefixBits = parseInt(prefix);
|
const prefixBits = parseInt(prefix);
|
||||||
const ipBigInt = ipToBigInt(ip);
|
const ipBigInt = ipToBigInt(ip);
|
||||||
|
@ -118,7 +124,10 @@ export function cidrToRange(cidr: string): IPRange {
|
||||||
}
|
}
|
||||||
|
|
||||||
const shiftBits = BigInt(maxPrefix - prefixBits);
|
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 start = ipBigInt & ~mask;
|
||||||
const end = start | mask;
|
const end = start | mask;
|
||||||
|
|
||||||
|
@ -142,17 +151,19 @@ export function findNextAvailableCidr(
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no existing CIDRs, use the IP version from startCidr
|
// If no existing CIDRs, use the IP version from startCidr
|
||||||
const version = startCidr
|
const version = startCidr ? detectIpVersion(startCidr.split("/")[0]) : 4; // Default to IPv4 if no startCidr provided
|
||||||
? detectIpVersion(startCidr.split('/')[0])
|
|
||||||
: 4; // Default to IPv4 if no startCidr provided
|
|
||||||
|
|
||||||
// Use appropriate default startCidr if none provided
|
// Use appropriate default startCidr if none provided
|
||||||
startCidr = startCidr || (version === 4 ? "0.0.0.0/0" : "::/0");
|
startCidr = startCidr || (version === 4 ? "0.0.0.0/0" : "::/0");
|
||||||
|
|
||||||
// If there are existing CIDRs, ensure all are same version
|
// If there are existing CIDRs, ensure all are same version
|
||||||
if (existingCidrs.length > 0 &&
|
if (
|
||||||
existingCidrs.some(cidr => detectIpVersion(cidr.split('/')[0]) !== version)) {
|
existingCidrs.length > 0 &&
|
||||||
throw new Error('All CIDRs must be of the same IP version');
|
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
|
// 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
|
// Convert existing CIDRs to ranges and sort them
|
||||||
const existingRanges = existingCidrs
|
const existingRanges = existingCidrs
|
||||||
.map(cidr => cidrToRange(cidr))
|
.map((cidr) => cidrToRange(cidr))
|
||||||
.sort((a, b) => (a.start < b.start ? -1 : 1));
|
.sort((a, b) => (a.start < b.start ? -1 : 1));
|
||||||
|
|
||||||
// Calculate block size
|
// Calculate block size
|
||||||
|
@ -176,7 +187,9 @@ export function findNextAvailableCidr(
|
||||||
const nextRange = existingRanges[i];
|
const nextRange = existingRanges[i];
|
||||||
|
|
||||||
// Align current to block size
|
// 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
|
// Check if we've gone beyond the maximum allowed IP
|
||||||
if (alignedCurrent + blockSizeBigInt - BigInt(1) > maxIp) {
|
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 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}`;
|
return `${bigIntToIp(alignedCurrent, version)}/${blockSize}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -206,7 +222,7 @@ export function findNextAvailableCidr(
|
||||||
*/
|
*/
|
||||||
export function isIpInCidr(ip: string, cidr: string): boolean {
|
export function isIpInCidr(ip: string, cidr: string): boolean {
|
||||||
const ipVersion = detectIpVersion(ip);
|
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 IP versions don't match, the IP cannot be in the CIDR range
|
||||||
if (ipVersion !== cidrVersion) {
|
if (ipVersion !== cidrVersion) {
|
||||||
|
@ -219,11 +235,10 @@ export function isIpInCidr(ip: string, cidr: string): boolean {
|
||||||
return ipBigInt >= range.start && ipBigInt <= range.end;
|
return ipBigInt >= range.start && ipBigInt <= range.end;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getNextAvailableClientSubnet(orgId: string): Promise<string> {
|
export async function getNextAvailableClientSubnet(
|
||||||
const [org] = await db
|
orgId: string
|
||||||
.select()
|
): Promise<string> {
|
||||||
.from(orgs)
|
const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId));
|
||||||
.where(eq(orgs.orgId, orgId));
|
|
||||||
|
|
||||||
const existingAddressesSites = await db
|
const existingAddressesSites = await db
|
||||||
.select({
|
.select({
|
||||||
|
@ -240,15 +255,15 @@ export async function getNextAvailableClientSubnet(orgId: string): Promise<strin
|
||||||
.where(and(isNotNull(clients.subnet), eq(clients.orgId, orgId)));
|
.where(and(isNotNull(clients.subnet), eq(clients.orgId, orgId)));
|
||||||
|
|
||||||
const addresses = [
|
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
|
...existingAddressesSites.map(
|
||||||
...existingAddressesClients.map((client) => `${client.address.split("/")}/32`)
|
(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[];
|
].filter((address) => address !== null) as string[];
|
||||||
|
|
||||||
let subnet = findNextAvailableCidr(
|
let subnet = findNextAvailableCidr(addresses, 32, org.subnet); // pick the sites address in the org
|
||||||
addresses,
|
|
||||||
32,
|
|
||||||
org.subnet
|
|
||||||
); // pick the sites address in the org
|
|
||||||
if (!subnet) {
|
if (!subnet) {
|
||||||
throw new Error("No available subnets remaining in space");
|
throw new Error("No available subnets remaining in space");
|
||||||
}
|
}
|
||||||
|
|
|
@ -133,7 +133,10 @@ export const configSchema = z
|
||||||
db: z.number().int().nonnegative().optional().default(0),
|
db: z.number().int().nonnegative().optional().default(0),
|
||||||
tls: z
|
tls: z
|
||||||
.object({
|
.object({
|
||||||
reject_unauthorized: z.boolean().optional().default(true)
|
reject_unauthorized: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.default(true)
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
})
|
})
|
||||||
|
@ -226,9 +229,9 @@ export const configSchema = z
|
||||||
disable_local_sites: z.boolean().optional(),
|
disable_local_sites: z.boolean().optional(),
|
||||||
disable_basic_wireguard_sites: z.boolean().optional(),
|
disable_basic_wireguard_sites: z.boolean().optional(),
|
||||||
disable_config_managed_domains: z.boolean().optional(),
|
disable_config_managed_domains: z.boolean().optional(),
|
||||||
enable_clients: z.boolean().optional()
|
enable_clients: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.optional()
|
.optional(),
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -14,10 +14,17 @@ export * from "./verifyAdmin";
|
||||||
export * from "./verifySetResourceUsers";
|
export * from "./verifySetResourceUsers";
|
||||||
export * from "./verifyUserInRole";
|
export * from "./verifyUserInRole";
|
||||||
export * from "./verifyAccessTokenAccess";
|
export * from "./verifyAccessTokenAccess";
|
||||||
|
export * from "./requestTimeout";
|
||||||
|
export * from "./verifyClientAccess";
|
||||||
|
export * from "./verifyUserHasAction";
|
||||||
export * from "./verifyUserIsServerAdmin";
|
export * from "./verifyUserIsServerAdmin";
|
||||||
export * from "./verifyIsLoggedInUser";
|
export * from "./verifyIsLoggedInUser";
|
||||||
|
export * from "./verifyIsLoggedInUser";
|
||||||
export * from "./verifyClientAccess";
|
export * from "./verifyClientAccess";
|
||||||
export * from "./integration";
|
export * from "./integration";
|
||||||
export * from "./verifyValidLicense";
|
export * from "./verifyValidLicense";
|
||||||
export * from "./verifyUserHasAction";
|
export * from "./verifyUserHasAction";
|
||||||
export * from "./verifyApiKeyAccess";
|
export * from "./verifyApiKeyAccess";
|
||||||
|
export * from "./verifyDomainAccess";
|
||||||
|
export * from "./verifyClientsEnabled";
|
||||||
|
export * from "./verifyUserIsOrgOwner";
|
||||||
|
|
35
server/middlewares/requestTimeout.ts
Normal file
35
server/middlewares/requestTimeout.ts
Normal 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;
|
93
server/middlewares/verifyDomainAccess.ts
Normal file
93
server/middlewares/verifyDomainAccess.ts
Normal 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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -14,5 +14,6 @@ export enum OpenAPITags {
|
||||||
AccessToken = "Access Token",
|
AccessToken = "Access Token",
|
||||||
Idp = "Identity Provider",
|
Idp = "Identity Provider",
|
||||||
Client = "Client",
|
Client = "Client",
|
||||||
ApiKey = "API Key"
|
ApiKey = "API Key",
|
||||||
|
Domain = "Domain"
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,7 @@ import { createTOTPKeyURI } from "oslo/otp";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { verifyPassword } from "@server/auth/password";
|
import { verifyPassword } from "@server/auth/password";
|
||||||
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
||||||
|
import config from "@server/lib/config";
|
||||||
import { UserType } from "@server/types/UserTypes";
|
import { UserType } from "@server/types/UserTypes";
|
||||||
|
|
||||||
export const requestTotpSecretBody = z
|
export const requestTotpSecretBody = z
|
||||||
|
@ -73,7 +74,11 @@ export async function requestTotpSecret(
|
||||||
|
|
||||||
const hex = crypto.getRandomValues(new Uint8Array(20));
|
const hex = crypto.getRandomValues(new Uint8Array(20));
|
||||||
const secret = encodeHex(hex);
|
const secret = encodeHex(hex);
|
||||||
const uri = createTOTPKeyURI("Pangolin", user.email!, hex);
|
const uri = createTOTPKeyURI(
|
||||||
|
"Pangolin",
|
||||||
|
user.email!,
|
||||||
|
hex
|
||||||
|
);
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(users)
|
.update(users)
|
||||||
|
|
|
@ -57,8 +57,6 @@ export async function signup(
|
||||||
|
|
||||||
const { email, password, inviteToken, inviteId } = parsedBody.data;
|
const { email, password, inviteToken, inviteId } = parsedBody.data;
|
||||||
|
|
||||||
logger.debug("signup", { email, password, inviteToken, inviteId });
|
|
||||||
|
|
||||||
const passwordHash = await hashPassword(password);
|
const passwordHash = await hashPassword(password);
|
||||||
const userId = generateId(15);
|
const userId = generateId(15);
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { response } from "@server/lib";
|
import { response } from "@server/lib";
|
||||||
import { db } from "@server/db";
|
import { db, userOrgs } from "@server/db";
|
||||||
import { User, emailVerificationCodes, users } from "@server/db";
|
import { User, emailVerificationCodes, users } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { isWithinExpirationDate } from "oslo";
|
import { isWithinExpirationDate } from "oslo";
|
||||||
|
|
|
@ -94,7 +94,7 @@ export async function createClient(
|
||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
if (!req.userOrgRoleId) {
|
if (req.user && !req.userOrgRoleId) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
||||||
);
|
);
|
||||||
|
@ -208,7 +208,7 @@ export async function createClient(
|
||||||
clientId: newClient.clientId
|
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
|
// make sure the user can access the site
|
||||||
trx.insert(userClients).values({
|
trx.insert(userClients).values({
|
||||||
userId: req.user?.userId!,
|
userId: req.user?.userId!,
|
||||||
|
|
|
@ -126,7 +126,7 @@ export async function listClients(
|
||||||
}
|
}
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
if (orgId && orgId !== req.userOrgId) {
|
if (req.user && orgId && orgId !== req.userOrgId) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
|
@ -135,7 +135,9 @@ export async function listClients(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessibleClients = await db
|
let accessibleClients;
|
||||||
|
if (req.user) {
|
||||||
|
accessibleClients = await db
|
||||||
.select({
|
.select({
|
||||||
clientId: sql<number>`COALESCE(${userClients.clientId}, ${roleClients.clientId})`
|
clientId: sql<number>`COALESCE(${userClients.clientId}, ${roleClients.clientId})`
|
||||||
})
|
})
|
||||||
|
@ -150,6 +152,12 @@ export async function listClients(
|
||||||
eq(roleClients.roleId, req.userOrgRoleId!)
|
eq(roleClients.roleId, req.userOrgRoleId!)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
accessibleClients = await db
|
||||||
|
.select({ clientId: clients.clientId })
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.orgId, orgId));
|
||||||
|
}
|
||||||
|
|
||||||
const accessibleClientIds = accessibleClients.map(
|
const accessibleClientIds = accessibleClients.map(
|
||||||
(client) => client.clientId
|
(client) => client.clientId
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { generateId } from "@server/auth/sessions/app";
|
||||||
import { getNextAvailableClientSubnet } from "@server/lib/ip";
|
import { getNextAvailableClientSubnet } from "@server/lib/ip";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
|
||||||
export type PickClientDefaultsResponse = {
|
export type PickClientDefaultsResponse = {
|
||||||
olmId: string;
|
olmId: string;
|
||||||
|
@ -20,6 +21,17 @@ const pickClientDefaultsSchema = z
|
||||||
})
|
})
|
||||||
.strict();
|
.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(
|
export async function pickClientDefaults(
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
|
|
252
server/routers/domain/createOrgDomain.ts
Normal file
252
server/routers/domain/createOrgDomain.ts
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
72
server/routers/domain/deleteOrgDomain.ts
Normal file
72
server/routers/domain/deleteOrgDomain.ts
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1 +1,4 @@
|
||||||
export * from "./listDomains";
|
export * from "./listDomains";
|
||||||
|
export * from "./createOrgDomain";
|
||||||
|
export * from "./deleteOrgDomain";
|
||||||
|
export * from "./restartOrgDomain";
|
|
@ -37,7 +37,11 @@ async function queryDomains(orgId: string, limit: number, offset: number) {
|
||||||
const res = await db
|
const res = await db
|
||||||
.select({
|
.select({
|
||||||
domainId: domains.domainId,
|
domainId: domains.domainId,
|
||||||
baseDomain: domains.baseDomain
|
baseDomain: domains.baseDomain,
|
||||||
|
verified: domains.verified,
|
||||||
|
type: domains.type,
|
||||||
|
failed: domains.failed,
|
||||||
|
tries: domains.tries,
|
||||||
})
|
})
|
||||||
.from(orgDomains)
|
.from(orgDomains)
|
||||||
.where(eq(orgDomains.orgId, orgId))
|
.where(eq(orgDomains.orgId, orgId))
|
||||||
|
@ -112,7 +116,7 @@ export async function listDomains(
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Users retrieved successfully",
|
message: "Domains retrieved successfully",
|
||||||
status: HttpCode.OK
|
status: HttpCode.OK
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
57
server/routers/domain/restartOrgDomain.ts
Normal file
57
server/routers/domain/restartOrgDomain.ts
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -33,15 +33,16 @@ import {
|
||||||
verifyClientAccess,
|
verifyClientAccess,
|
||||||
verifyApiKeyAccess,
|
verifyApiKeyAccess,
|
||||||
createStore,
|
createStore,
|
||||||
|
verifyDomainAccess,
|
||||||
|
verifyClientsEnabled,
|
||||||
|
verifyUserHasAction,
|
||||||
|
verifyUserIsOrgOwner
|
||||||
} from "@server/middlewares";
|
} from "@server/middlewares";
|
||||||
import { verifyUserHasAction } from "../middlewares/verifyUserHasAction";
|
|
||||||
import { ActionsEnum } from "@server/auth/actions";
|
import { ActionsEnum } from "@server/auth/actions";
|
||||||
import { verifyUserIsOrgOwner } from "../middlewares/verifyUserIsOrgOwner";
|
|
||||||
import { createNewt, getNewtToken } from "./newt";
|
import { createNewt, getNewtToken } from "./newt";
|
||||||
import { getOlmToken } from "./olm";
|
import { getOlmToken } from "./olm";
|
||||||
import rateLimit from "express-rate-limit";
|
import rateLimit from "express-rate-limit";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { verifyClientsEnabled } from "@server/middlewares/verifyClintsEnabled";
|
|
||||||
|
|
||||||
// Root routes
|
// Root routes
|
||||||
export const unauthenticated = Router();
|
export const unauthenticated = Router();
|
||||||
|
@ -54,10 +55,7 @@ unauthenticated.get("/", (_, res) => {
|
||||||
export const authenticated = Router();
|
export const authenticated = Router();
|
||||||
authenticated.use(verifySessionUserMiddleware);
|
authenticated.use(verifySessionUserMiddleware);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get("/pick-org-defaults", org.pickOrgDefaults);
|
||||||
"/pick-org-defaults",
|
|
||||||
org.pickOrgDefaults
|
|
||||||
);
|
|
||||||
authenticated.get("/org/checkId", org.checkId);
|
authenticated.get("/org/checkId", org.checkId);
|
||||||
authenticated.put("/org", getUserOrgs, org.createOrg);
|
authenticated.put("/org", getUserOrgs, org.createOrg);
|
||||||
|
|
||||||
|
@ -750,6 +748,29 @@ authenticated.get(
|
||||||
apiKeys.getApiKey
|
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
|
// Auth routes
|
||||||
export const authRouter = Router();
|
export const authRouter = Router();
|
||||||
unauthenticated.use("/auth", authRouter);
|
unauthenticated.use("/auth", authRouter);
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { Request, Response, NextFunction } from "express";
|
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 { sites } from "@server/db";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
@ -7,6 +7,9 @@ import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
|
|
||||||
|
// Track sites that are already offline to avoid unnecessary queries
|
||||||
|
const offlineSites = new Set<string>();
|
||||||
|
|
||||||
interface PeerBandwidth {
|
interface PeerBandwidth {
|
||||||
publicKey: string;
|
publicKey: string;
|
||||||
bytesIn: number;
|
bytesIn: number;
|
||||||
|
@ -28,42 +31,61 @@ export const receiveBandwidth = async (
|
||||||
const currentTime = new Date();
|
const currentTime = new Date();
|
||||||
const oneMinuteAgo = new Date(currentTime.getTime() - 60000); // 1 minute ago
|
const oneMinuteAgo = new Date(currentTime.getTime() - 60000); // 1 minute ago
|
||||||
|
|
||||||
|
logger.debug(`Received data: ${JSON.stringify(bandwidthData)}`);
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
// First, handle sites that are actively reporting bandwidth
|
// 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) {
|
if (activePeers.length > 0) {
|
||||||
// Get all active sites in one query
|
// Remove any active peers from offline tracking since they're sending data
|
||||||
const activeSites = await trx
|
activePeers.forEach(peer => offlineSites.delete(peer.publicKey));
|
||||||
.select()
|
|
||||||
.from(sites)
|
|
||||||
.where(inArray(sites.pubKey, activePeers.map(p => p.publicKey)));
|
|
||||||
|
|
||||||
// Create a map for quick lookup
|
// Aggregate usage data by organization
|
||||||
const siteMap = new Map();
|
const orgUsageMap = new Map<string, number>();
|
||||||
activeSites.forEach(site => {
|
const orgUptimeMap = new Map<string, number>();
|
||||||
siteMap.set(site.pubKey, site);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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) {
|
for (const peer of activePeers) {
|
||||||
const site = siteMap.get(peer.publicKey);
|
const updatedSite = await trx
|
||||||
if (!site) continue;
|
|
||||||
|
|
||||||
await trx
|
|
||||||
.update(sites)
|
.update(sites)
|
||||||
.set({
|
.set({
|
||||||
megabytesOut: (site.megabytesOut || 0) + peer.bytesIn,
|
megabytesOut: sql`${sites.megabytesOut} + ${peer.bytesIn}`,
|
||||||
megabytesIn: (site.megabytesIn || 0) + peer.bytesOut,
|
megabytesIn: sql`${sites.megabytesIn} + ${peer.bytesOut}`,
|
||||||
lastBandwidthUpdate: currentTime.toISOString(),
|
lastBandwidthUpdate: currentTime.toISOString(),
|
||||||
online: true
|
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
|
// 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) {
|
if (zeroBandwidthPeers.length > 0) {
|
||||||
const zeroBandwidthSites = await trx
|
const zeroBandwidthSites = await trx
|
||||||
|
@ -91,18 +113,14 @@ export const receiveBandwidth = async (
|
||||||
await trx
|
await trx
|
||||||
.update(sites)
|
.update(sites)
|
||||||
.set({
|
.set({
|
||||||
lastBandwidthUpdate: currentTime.toISOString(),
|
|
||||||
online: newOnlineStatus
|
online: newOnlineStatus
|
||||||
})
|
})
|
||||||
.where(eq(sites.siteId, site.siteId));
|
.where(eq(sites.siteId, site.siteId));
|
||||||
} else {
|
|
||||||
// Just update the heartbeat timestamp
|
// If site went offline, add it to our tracking set
|
||||||
await trx
|
if (!newOnlineStatus && site.pubKey) {
|
||||||
.update(sites)
|
offlineSites.add(site.pubKey);
|
||||||
.set({
|
}
|
||||||
lastBandwidthUpdate: currentTime.toISOString()
|
|
||||||
})
|
|
||||||
.where(eq(sites.siteId, site.siteId));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,7 @@ import {
|
||||||
idpOidcConfig,
|
idpOidcConfig,
|
||||||
idpOrg,
|
idpOrg,
|
||||||
orgs,
|
orgs,
|
||||||
|
Role,
|
||||||
roles,
|
roles,
|
||||||
userOrgs,
|
userOrgs,
|
||||||
users
|
users
|
||||||
|
@ -307,6 +308,8 @@ export async function validateOidcCallback(
|
||||||
|
|
||||||
let existingUserId = existingUser?.userId;
|
let existingUserId = existingUser?.userId;
|
||||||
|
|
||||||
|
let orgUserCounts: { orgId: string; userCount: number }[] = [];
|
||||||
|
|
||||||
// sync the user with the orgs and roles
|
// sync the user with the orgs and roles
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
let userId = existingUser?.userId;
|
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();
|
const token = generateSessionToken();
|
||||||
|
|
|
@ -87,7 +87,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||||
|
|
||||||
let siteSubnet = oldSite.subnet;
|
let siteSubnet = oldSite.subnet;
|
||||||
let exitNodeIdToQuery = oldSite.exitNodeId;
|
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
|
// This effectively moves the exit node to the new one
|
||||||
exitNodeIdToQuery = exitNodeId; // Use the provided exitNodeId if it differs from the site's exitNodeId
|
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);
|
.limit(1);
|
||||||
|
|
||||||
const blockSize = config.getRawConfig().gerbil.site_block_size;
|
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}`));
|
subnets.push(exitNode.address.replace(/\/\d+$/, `/${blockSize}`));
|
||||||
const newSubnet = findNextAvailableCidr(
|
const newSubnet = findNextAvailableCidr(
|
||||||
subnets,
|
subnets,
|
||||||
|
|
|
@ -49,7 +49,7 @@ export async function createNewt(
|
||||||
|
|
||||||
const { newtId, secret } = parsedBody.data;
|
const { newtId, secret } = parsedBody.data;
|
||||||
|
|
||||||
if (!req.userOrgRoleId) {
|
if (req.user && !req.userOrgRoleId) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
||||||
);
|
);
|
||||||
|
|
|
@ -33,8 +33,6 @@ const createOrgSchema = z
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
// const MAX_ORGS = 5;
|
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "put",
|
method: "put",
|
||||||
path: "/org",
|
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;
|
const { orgId, name, subnet } = parsedBody.data;
|
||||||
|
|
||||||
if (!isValidCIDR(subnet)) {
|
if (!isValidCIDR(subnet)) {
|
||||||
|
@ -147,7 +135,7 @@ export async function createOrg(
|
||||||
.values({
|
.values({
|
||||||
orgId,
|
orgId,
|
||||||
name,
|
name,
|
||||||
subnet,
|
subnet
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
@ -182,9 +170,7 @@ export async function createOrg(
|
||||||
const actionIds = await trx.select().from(actions).execute();
|
const actionIds = await trx.select().from(actions).execute();
|
||||||
|
|
||||||
if (actionIds.length > 0) {
|
if (actionIds.length > 0) {
|
||||||
await trx
|
await trx.insert(roleActions).values(
|
||||||
.insert(roleActions)
|
|
||||||
.values(
|
|
||||||
actionIds.map((action) => ({
|
actionIds.map((action) => ({
|
||||||
roleId,
|
roleId,
|
||||||
actionId: action.actionId,
|
actionId: action.actionId,
|
||||||
|
|
|
@ -89,6 +89,8 @@ export async function deleteOrg(
|
||||||
.where(eq(sites.orgId, orgId))
|
.where(eq(sites.orgId, orgId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
|
const deletedNewtIds: string[] = [];
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
if (sites) {
|
if (sites) {
|
||||||
for (const site of orgSites) {
|
for (const site of orgSites) {
|
||||||
|
@ -102,11 +104,7 @@ export async function deleteOrg(
|
||||||
.where(eq(newts.siteId, site.siteId))
|
.where(eq(newts.siteId, site.siteId))
|
||||||
.returning();
|
.returning();
|
||||||
if (deletedNewt) {
|
if (deletedNewt) {
|
||||||
const payload = {
|
deletedNewtIds.push(deletedNewt.newtId);
|
||||||
type: `newt/terminate`,
|
|
||||||
data: {}
|
|
||||||
};
|
|
||||||
sendToClient(deletedNewt.newtId, payload);
|
|
||||||
|
|
||||||
// delete all of the sessions for the newt
|
// delete all of the sessions for the newt
|
||||||
await trx
|
await trx
|
||||||
|
@ -131,6 +129,18 @@ export async function deleteOrg(
|
||||||
await trx.delete(orgs).where(eq(orgs.orgId, orgId));
|
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, {
|
return response(res, {
|
||||||
data: null,
|
data: null,
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
@ -69,7 +69,8 @@ function queryResources(
|
||||||
http: resources.http,
|
http: resources.http,
|
||||||
protocol: resources.protocol,
|
protocol: resources.protocol,
|
||||||
proxyPort: resources.proxyPort,
|
proxyPort: resources.proxyPort,
|
||||||
enabled: resources.enabled
|
enabled: resources.enabled,
|
||||||
|
domainId: resources.domainId
|
||||||
})
|
})
|
||||||
.from(resources)
|
.from(resources)
|
||||||
.leftJoin(sites, eq(resources.siteId, sites.siteId))
|
.leftJoin(sites, eq(resources.siteId, sites.siteId))
|
||||||
|
@ -103,7 +104,8 @@ function queryResources(
|
||||||
http: resources.http,
|
http: resources.http,
|
||||||
protocol: resources.protocol,
|
protocol: resources.protocol,
|
||||||
proxyPort: resources.proxyPort,
|
proxyPort: resources.proxyPort,
|
||||||
enabled: resources.enabled
|
enabled: resources.enabled,
|
||||||
|
domainId: resources.domainId
|
||||||
})
|
})
|
||||||
.from(resources)
|
.from(resources)
|
||||||
.leftJoin(sites, eq(resources.siteId, sites.siteId))
|
.leftJoin(sites, eq(resources.siteId, sites.siteId))
|
||||||
|
|
|
@ -38,7 +38,7 @@ const createSiteSchema = z
|
||||||
subnet: z.string().optional(),
|
subnet: z.string().optional(),
|
||||||
newtId: z.string().optional(),
|
newtId: z.string().optional(),
|
||||||
secret: z.string().optional(),
|
secret: z.string().optional(),
|
||||||
address: z.string().optional(),
|
// address: z.string().optional(),
|
||||||
type: z.enum(["newt", "wireguard", "local"])
|
type: z.enum(["newt", "wireguard", "local"])
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
@ -97,7 +97,7 @@ export async function createSite(
|
||||||
subnet,
|
subnet,
|
||||||
newtId,
|
newtId,
|
||||||
secret,
|
secret,
|
||||||
address
|
// address
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
|
|
||||||
const parsedParams = createSiteParamsSchema.safeParse(req.params);
|
const parsedParams = createSiteParamsSchema.safeParse(req.params);
|
||||||
|
@ -129,58 +129,58 @@ export async function createSite(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let updatedAddress = null;
|
// let updatedAddress = null;
|
||||||
if (address) {
|
// if (address) {
|
||||||
if (!isValidIP(address)) {
|
// if (!isValidIP(address)) {
|
||||||
return next(
|
// return next(
|
||||||
createHttpError(
|
// createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
// HttpCode.BAD_REQUEST,
|
||||||
"Invalid subnet format. Please provide a valid CIDR notation."
|
// "Invalid subnet format. Please provide a valid CIDR notation."
|
||||||
)
|
// )
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
if (!isIpInCidr(address, org.subnet)) {
|
// if (!isIpInCidr(address, org.subnet)) {
|
||||||
return next(
|
// return next(
|
||||||
createHttpError(
|
// createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
// HttpCode.BAD_REQUEST,
|
||||||
"IP is not in the CIDR range of the subnet."
|
// "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
|
// updatedAddress = `${address}/${org.subnet.split("/")[1]}`; // we want the block size of the whole org
|
||||||
|
//
|
||||||
// make sure the subnet is unique
|
// // make sure the subnet is unique
|
||||||
const addressExistsSites = await db
|
// const addressExistsSites = await db
|
||||||
.select()
|
// .select()
|
||||||
.from(sites)
|
// .from(sites)
|
||||||
.where(eq(sites.address, updatedAddress))
|
// .where(eq(sites.address, updatedAddress))
|
||||||
.limit(1);
|
// .limit(1);
|
||||||
|
//
|
||||||
if (addressExistsSites.length > 0) {
|
// if (addressExistsSites.length > 0) {
|
||||||
return next(
|
// return next(
|
||||||
createHttpError(
|
// createHttpError(
|
||||||
HttpCode.CONFLICT,
|
// HttpCode.CONFLICT,
|
||||||
`Subnet ${subnet} already exists`
|
// `Subnet ${subnet} already exists`
|
||||||
)
|
// )
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
const addressExistsClients = await db
|
// const addressExistsClients = await db
|
||||||
.select()
|
// .select()
|
||||||
.from(sites)
|
// .from(sites)
|
||||||
.where(eq(sites.subnet, updatedAddress))
|
// .where(eq(sites.subnet, updatedAddress))
|
||||||
.limit(1);
|
// .limit(1);
|
||||||
if (addressExistsClients.length > 0) {
|
// if (addressExistsClients.length > 0) {
|
||||||
return next(
|
// return next(
|
||||||
createHttpError(
|
// createHttpError(
|
||||||
HttpCode.CONFLICT,
|
// HttpCode.CONFLICT,
|
||||||
`Subnet ${subnet} already exists`
|
// `Subnet ${subnet} already exists`
|
||||||
)
|
// )
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
const niceId = await getUniqueSiteName(orgId);
|
const niceId = await getUniqueSiteName(orgId);
|
||||||
|
|
||||||
|
@ -205,7 +205,7 @@ export async function createSite(
|
||||||
exitNodeId,
|
exitNodeId,
|
||||||
name,
|
name,
|
||||||
niceId,
|
niceId,
|
||||||
address: updatedAddress || null,
|
// address: updatedAddress || null,
|
||||||
subnet,
|
subnet,
|
||||||
type,
|
type,
|
||||||
dockerSocketEnabled: type == "newt",
|
dockerSocketEnabled: type == "newt",
|
||||||
|
@ -221,7 +221,7 @@ export async function createSite(
|
||||||
orgId,
|
orgId,
|
||||||
name,
|
name,
|
||||||
niceId,
|
niceId,
|
||||||
address: updatedAddress || null,
|
// address: updatedAddress || null,
|
||||||
type,
|
type,
|
||||||
dockerSocketEnabled: type == "newt",
|
dockerSocketEnabled: type == "newt",
|
||||||
subnet: "0.0.0.0/0"
|
subnet: "0.0.0.0/0"
|
||||||
|
|
|
@ -62,6 +62,8 @@ export async function deleteSite(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let deletedNewtId: string | null = null;
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
if (site.pubKey) {
|
if (site.pubKey) {
|
||||||
if (site.type == "wireguard") {
|
if (site.type == "wireguard") {
|
||||||
|
@ -73,11 +75,7 @@ export async function deleteSite(
|
||||||
.where(eq(newts.siteId, siteId))
|
.where(eq(newts.siteId, siteId))
|
||||||
.returning();
|
.returning();
|
||||||
if (deletedNewt) {
|
if (deletedNewt) {
|
||||||
const payload = {
|
deletedNewtId = deletedNewt.newtId;
|
||||||
type: `newt/terminate`,
|
|
||||||
data: {}
|
|
||||||
};
|
|
||||||
sendToClient(deletedNewt.newtId, payload);
|
|
||||||
|
|
||||||
// delete all of the sessions for the newt
|
// delete all of the sessions for the newt
|
||||||
await trx
|
await trx
|
||||||
|
@ -90,6 +88,18 @@ export async function deleteSite(
|
||||||
await trx.delete(sites).where(eq(sites.siteId, siteId));
|
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, {
|
return response(res, {
|
||||||
data: null,
|
data: null,
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
@ -20,10 +20,10 @@ export type PickSiteDefaultsResponse = {
|
||||||
name: string;
|
name: string;
|
||||||
listenPort: number;
|
listenPort: number;
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
subnet: string;
|
subnet: string; // TODO: make optional?
|
||||||
newtId: string;
|
newtId: string;
|
||||||
newtSecret: string;
|
newtSecret: string;
|
||||||
clientAddress: string;
|
clientAddress?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
|
@ -86,7 +86,7 @@ export async function pickSiteDefaults(
|
||||||
.where(eq(sites.exitNodeId, exitNode.exitNodeId));
|
.where(eq(sites.exitNodeId, exitNode.exitNodeId));
|
||||||
|
|
||||||
// TODO: we need to lock this subnet for some time so someone else does not take it
|
// TODO: we need to lock this subnet for some time so someone else does not take it
|
||||||
let subnets = sitesQuery.map((site) => site.subnet);
|
let subnets = sitesQuery.map((site) => site.subnet).filter((subnet) => subnet !== null);
|
||||||
// exclude the exit node address by replacing after the / with a site block size
|
// exclude the exit node address by replacing after the / with a site block size
|
||||||
subnets.push(
|
subnets.push(
|
||||||
exitNode.address.replace(
|
exitNode.address.replace(
|
||||||
|
@ -108,17 +108,17 @@ export async function pickSiteDefaults(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newClientAddress = await getNextAvailableClientSubnet(orgId);
|
// const newClientAddress = await getNextAvailableClientSubnet(orgId);
|
||||||
if (!newClientAddress) {
|
// if (!newClientAddress) {
|
||||||
return next(
|
// return next(
|
||||||
createHttpError(
|
// createHttpError(
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
// HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
"No available subnet found"
|
// "No available subnet found"
|
||||||
)
|
// )
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
|
|
||||||
const clientAddress = newClientAddress.split("/")[0];
|
// const clientAddress = newClientAddress.split("/")[0];
|
||||||
|
|
||||||
const newtId = generateId(15);
|
const newtId = generateId(15);
|
||||||
const secret = generateId(48);
|
const secret = generateId(48);
|
||||||
|
@ -133,7 +133,7 @@ export async function pickSiteDefaults(
|
||||||
endpoint: exitNode.endpoint,
|
endpoint: exitNode.endpoint,
|
||||||
// subnet: `${newSubnet.split("/")[0]}/${config.getRawConfig().gerbil.block_size}`, // we want the block size of the whole subnet
|
// subnet: `${newSubnet.split("/")[0]}/${config.getRawConfig().gerbil.block_size}`, // we want the block size of the whole subnet
|
||||||
subnet: newSubnet,
|
subnet: newSubnet,
|
||||||
clientAddress: clientAddress,
|
// clientAddress: clientAddress,
|
||||||
newtId,
|
newtId,
|
||||||
newtSecret: secret
|
newtSecret: secret
|
||||||
},
|
},
|
||||||
|
|
|
@ -56,12 +56,8 @@ export async function traefikConfigProvider(
|
||||||
.select({
|
.select({
|
||||||
// Resource fields
|
// Resource fields
|
||||||
resourceId: resources.resourceId,
|
resourceId: resources.resourceId,
|
||||||
subdomain: resources.subdomain,
|
|
||||||
fullDomain: resources.fullDomain,
|
fullDomain: resources.fullDomain,
|
||||||
ssl: resources.ssl,
|
ssl: resources.ssl,
|
||||||
blockAccess: resources.blockAccess,
|
|
||||||
sso: resources.sso,
|
|
||||||
emailWhitelistEnabled: resources.emailWhitelistEnabled,
|
|
||||||
http: resources.http,
|
http: resources.http,
|
||||||
proxyPort: resources.proxyPort,
|
proxyPort: resources.proxyPort,
|
||||||
protocol: resources.protocol,
|
protocol: resources.protocol,
|
||||||
|
@ -74,10 +70,6 @@ export async function traefikConfigProvider(
|
||||||
subnet: sites.subnet,
|
subnet: sites.subnet,
|
||||||
exitNodeId: sites.exitNodeId,
|
exitNodeId: sites.exitNodeId,
|
||||||
},
|
},
|
||||||
// Org fields
|
|
||||||
org: {
|
|
||||||
orgId: orgs.orgId
|
|
||||||
},
|
|
||||||
enabled: resources.enabled,
|
enabled: resources.enabled,
|
||||||
stickySession: resources.stickySession,
|
stickySession: resources.stickySession,
|
||||||
tlsServerName: resources.tlsServerName,
|
tlsServerName: resources.tlsServerName,
|
||||||
|
@ -85,7 +77,6 @@ export async function traefikConfigProvider(
|
||||||
})
|
})
|
||||||
.from(resources)
|
.from(resources)
|
||||||
.innerJoin(sites, eq(sites.siteId, resources.siteId))
|
.innerJoin(sites, eq(sites.siteId, resources.siteId))
|
||||||
.innerJoin(orgs, eq(resources.orgId, orgs.orgId))
|
|
||||||
.where(eq(sites.exitNodeId, currentExitNodeId));
|
.where(eq(sites.exitNodeId, currentExitNodeId));
|
||||||
|
|
||||||
// Get all resource IDs from the first query
|
// Get all resource IDs from the first query
|
||||||
|
@ -179,7 +170,6 @@ export async function traefikConfigProvider(
|
||||||
for (const resource of allResources) {
|
for (const resource of allResources) {
|
||||||
const targets = resource.targets as Target[];
|
const targets = resource.targets as Target[];
|
||||||
const site = resource.site;
|
const site = resource.site;
|
||||||
const org = resource.org;
|
|
||||||
|
|
||||||
const routerName = `${resource.resourceId}-router`;
|
const routerName = `${resource.resourceId}-router`;
|
||||||
const serviceName = `${resource.resourceId}-service`;
|
const serviceName = `${resource.resourceId}-service`;
|
||||||
|
@ -203,11 +193,6 @@ export async function traefikConfigProvider(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTTP configuration remains the same
|
|
||||||
if (!resource.subdomain && !resource.isBaseDomain) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// add routers and services empty objects if they don't exist
|
// add routers and services empty objects if they don't exist
|
||||||
if (!config_output.http.routers) {
|
if (!config_output.http.routers) {
|
||||||
config_output.http.routers = {};
|
config_output.http.routers = {};
|
||||||
|
@ -299,7 +284,7 @@ export async function traefikConfigProvider(
|
||||||
} else if (site.type === "newt") {
|
} else if (site.type === "newt") {
|
||||||
if (
|
if (
|
||||||
!target.internalPort ||
|
!target.internalPort ||
|
||||||
!target.method
|
!target.method || !site.subnet
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -315,7 +300,7 @@ export async function traefikConfigProvider(
|
||||||
url: `${target.method}://${target.ip}:${target.port}`
|
url: `${target.method}://${target.ip}:${target.port}`
|
||||||
};
|
};
|
||||||
} else if (site.type === "newt") {
|
} else if (site.type === "newt") {
|
||||||
const ip = site.subnet.split("/")[0];
|
const ip = site.subnet!.split("/")[0];
|
||||||
return {
|
return {
|
||||||
url: `${target.method}://${ip}:${target.internalPort}`
|
url: `${target.method}://${ip}:${target.internalPort}`
|
||||||
};
|
};
|
||||||
|
@ -409,7 +394,7 @@ export async function traefikConfigProvider(
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} else if (site.type === "newt") {
|
} else if (site.type === "newt") {
|
||||||
if (!target.internalPort) {
|
if (!target.internalPort || !site.subnet) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -424,7 +409,7 @@ export async function traefikConfigProvider(
|
||||||
address: `${target.ip}:${target.port}`
|
address: `${target.ip}:${target.port}`
|
||||||
};
|
};
|
||||||
} else if (site.type === "newt") {
|
} else if (site.type === "newt") {
|
||||||
const ip = site.subnet.split("/")[0];
|
const ip = site.subnet!.split("/")[0];
|
||||||
return {
|
return {
|
||||||
address: `${ip}:${target.internalPort}`
|
address: `${ip}:${target.internalPort}`
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db, UserOrg } from "@server/db";
|
||||||
import { roles, userInvites, userOrgs, users } from "@server/db";
|
import { roles, userInvites, userOrgs, users } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
|
@ -92,6 +92,7 @@ export async function acceptInvite(
|
||||||
}
|
}
|
||||||
|
|
||||||
let roleId: number;
|
let roleId: number;
|
||||||
|
let totalUsers: UserOrg[] | undefined;
|
||||||
// get the role to make sure it exists
|
// get the role to make sure it exists
|
||||||
const existingRole = await db
|
const existingRole = await db
|
||||||
.select()
|
.select()
|
||||||
|
@ -122,6 +123,12 @@ export async function acceptInvite(
|
||||||
await trx
|
await trx
|
||||||
.delete(userInvites)
|
.delete(userInvites)
|
||||||
.where(eq(userInvites.inviteId, inviteId));
|
.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, {
|
return response<AcceptInviteResponse>(res, {
|
||||||
|
|
|
@ -6,7 +6,7 @@ import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { db } from "@server/db";
|
import { db, UserOrg } from "@server/db";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { idp, idpOidcConfig, roles, userOrgs, users } from "@server/db";
|
import { idp, idpOidcConfig, roles, userOrgs, users } from "@server/db";
|
||||||
import { generateId } from "@server/auth/sessions/app";
|
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()
|
.select()
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.username, username));
|
.where(eq(users.username, username));
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
const [existingOrgUser] = await db
|
const [existingOrgUser] = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(userOrgs)
|
.from(userOrgs)
|
||||||
.where(
|
.where(
|
||||||
|
@ -160,7 +163,7 @@ export async function createOrgUser(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await db
|
await trx
|
||||||
.insert(userOrgs)
|
.insert(userOrgs)
|
||||||
.values({
|
.values({
|
||||||
orgId,
|
orgId,
|
||||||
|
@ -171,7 +174,7 @@ export async function createOrgUser(
|
||||||
} else {
|
} else {
|
||||||
const userId = generateId(15);
|
const userId = generateId(15);
|
||||||
|
|
||||||
const [newUser] = await db
|
const [newUser] = await trx
|
||||||
.insert(users)
|
.insert(users)
|
||||||
.values({
|
.values({
|
||||||
userId: userId,
|
userId: userId,
|
||||||
|
@ -185,7 +188,7 @@ export async function createOrgUser(
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
await db
|
await trx
|
||||||
.insert(userOrgs)
|
.insert(userOrgs)
|
||||||
.values({
|
.values({
|
||||||
orgId,
|
orgId,
|
||||||
|
@ -194,6 +197,14 @@ export async function createOrgUser(
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// List all of the users in the org
|
||||||
|
orgUsers = await trx
|
||||||
|
.select()
|
||||||
|
.from(userOrgs)
|
||||||
|
.where(eq(userOrgs.orgId, orgId));
|
||||||
|
});
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "User type is required")
|
createHttpError(HttpCode.BAD_REQUEST, "User type is required")
|
||||||
|
|
|
@ -99,6 +99,7 @@ export async function inviteUser(
|
||||||
regenerate
|
regenerate
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
|
|
||||||
|
|
||||||
// Check if the organization exists
|
// Check if the organization exists
|
||||||
const org = await db
|
const org = await db
|
||||||
.select()
|
.select()
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
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 { userOrgs, userResources, users, userSites } from "@server/db";
|
||||||
import { and, eq, exists } from "drizzle-orm";
|
import { and, eq, exists } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
|
@ -65,6 +65,8 @@ export async function removeUserOrg(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let userCount: UserOrg[] | undefined;
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
await trx
|
await trx
|
||||||
.delete(userOrgs)
|
.delete(userOrgs)
|
||||||
|
@ -108,6 +110,11 @@ export async function removeUserOrg(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
userCount = await trx
|
||||||
|
.select()
|
||||||
|
.from(userOrgs)
|
||||||
|
.where(eq(userOrgs.orgId, orgId));
|
||||||
});
|
});
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
|
|
430
src/app/[orgId]/settings/domains/CreateDomainForm.tsx
Normal file
430
src/app/[orgId]/settings/domains/CreateDomainForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
37
src/app/[orgId]/settings/domains/DomainsDataTable.tsx
Normal file
37
src/app/[orgId]/settings/domains/DomainsDataTable.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
261
src/app/[orgId]/settings/domains/DomainsTable.tsx
Normal file
261
src/app/[orgId]/settings/domains/DomainsTable.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
60
src/app/[orgId]/settings/domains/page.tsx
Normal file
60
src/app/[orgId]/settings/domains/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -202,25 +202,25 @@ export default function GeneralPage() {
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
{/* <FormField */}
|
||||||
control={form.control}
|
{/* control={form.control} */}
|
||||||
name="subnet"
|
{/* name="subnet" */}
|
||||||
render={({ field }) => (
|
{/* render={({ field }) => ( */}
|
||||||
<FormItem>
|
{/* <FormItem> */}
|
||||||
<FormLabel>Subnet</FormLabel>
|
{/* <FormLabel>Subnet</FormLabel> */}
|
||||||
<FormControl>
|
{/* <FormControl> */}
|
||||||
<Input
|
{/* <Input */}
|
||||||
{...field}
|
{/* {...field} */}
|
||||||
disabled={true}
|
{/* disabled={true} */}
|
||||||
/>
|
{/* /> */}
|
||||||
</FormControl>
|
{/* </FormControl> */}
|
||||||
<FormMessage />
|
{/* <FormMessage /> */}
|
||||||
<FormDescription>
|
{/* <FormDescription> */}
|
||||||
The subnet for this organization's network configuration.
|
{/* The subnet for this organization's network configuration. */}
|
||||||
</FormDescription>
|
{/* </FormDescription> */}
|
||||||
</FormItem>
|
{/* </FormItem> */}
|
||||||
)}
|
{/* )} */}
|
||||||
/>
|
{/* /> */}
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</SettingsSectionForm>
|
</SettingsSectionForm>
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
import {
|
import {
|
||||||
Combine,
|
Combine,
|
||||||
|
KeyRound,
|
||||||
LinkIcon,
|
LinkIcon,
|
||||||
Settings,
|
Settings,
|
||||||
Users,
|
Users,
|
||||||
|
@ -11,6 +12,7 @@ import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { internal } from "@app/lib/api";
|
import { internal } from "@app/lib/api";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
|
import { ListOrgsResponse } from "@server/routers/org";
|
||||||
import { GetOrgResponse, ListUserOrgsResponse } from "@server/routers/org";
|
import { GetOrgResponse, ListUserOrgsResponse } from "@server/routers/org";
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
|
|
|
@ -32,6 +32,8 @@ import { Switch } from "@app/components/ui/switch";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { UpdateResourceResponse } from "@server/routers/resource";
|
import { UpdateResourceResponse } from "@server/routers/resource";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||||
|
import { Badge } from "@app/components/ui/badge";
|
||||||
|
|
||||||
export type ResourceRow = {
|
export type ResourceRow = {
|
||||||
id: number;
|
id: number;
|
||||||
|
@ -45,6 +47,7 @@ export type ResourceRow = {
|
||||||
protocol: string;
|
protocol: string;
|
||||||
proxyPort: number | null;
|
proxyPort: number | null;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
domainId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ResourcesTableProps = {
|
type ResourcesTableProps = {
|
||||||
|
@ -158,6 +161,13 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const resourceRow = row.original;
|
const resourceRow = row.original;
|
||||||
return (
|
return (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{!resourceRow.domainId ? (
|
||||||
|
<InfoPopup
|
||||||
|
info={t("domainNotFoundDescription")}
|
||||||
|
text={t("domainNotFound")}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<div>
|
<div>
|
||||||
{!resourceRow.http ? (
|
{!resourceRow.http ? (
|
||||||
<CopyToClipboard
|
<CopyToClipboard
|
||||||
|
@ -171,6 +181,8 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -215,7 +227,10 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||||
header: t("enabled"),
|
header: t("enabled"),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Switch
|
<Switch
|
||||||
defaultChecked={row.original.enabled}
|
defaultChecked={
|
||||||
|
!row.original.domainId ? false : row.original.enabled
|
||||||
|
}
|
||||||
|
disabled={!row.original.domainId}
|
||||||
onCheckedChange={(val) =>
|
onCheckedChange={(val) =>
|
||||||
toggleResourceEnabled(val, row.original.id)
|
toggleResourceEnabled(val, row.original.id)
|
||||||
}
|
}
|
||||||
|
@ -261,7 +276,11 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||||
<Link
|
<Link
|
||||||
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
|
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")}
|
{t("edit")}
|
||||||
<ArrowRight className="ml-2 w-4 h-4" />
|
<ArrowRight className="ml-2 w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -10,10 +10,14 @@ import {
|
||||||
InfoSections,
|
InfoSections,
|
||||||
InfoSectionTitle
|
InfoSectionTitle
|
||||||
} from "@app/components/InfoSection";
|
} from "@app/components/InfoSection";
|
||||||
import { createApiClient } from "@app/lib/api";
|
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useDockerSocket } from "@app/hooks/useDockerSocket";
|
import { useDockerSocket } from "@app/hooks/useDockerSocket";
|
||||||
import { useTranslations } from "next-intl";
|
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 = {};
|
type ResourceInfoBoxType = {};
|
||||||
|
|
||||||
|
|
|
@ -205,10 +205,10 @@ export default function ResourceAuthenticationPage() {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: t('resourceErrorAuthFetch'),
|
title: t("resourceErrorAuthFetch"),
|
||||||
description: formatAxiosError(
|
description: formatAxiosError(
|
||||||
e,
|
e,
|
||||||
t('resourceErrorAuthFetchDescription')
|
t("resourceErrorAuthFetchDescription")
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -235,18 +235,18 @@ export default function ResourceAuthenticationPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t('resourceWhitelistSave'),
|
title: t("resourceWhitelistSave"),
|
||||||
description: t('resourceWhitelistSaveDescription')
|
description: t("resourceWhitelistSaveDescription")
|
||||||
});
|
});
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: t('resourceErrorWhitelistSave'),
|
title: t("resourceErrorWhitelistSave"),
|
||||||
description: formatAxiosError(
|
description: formatAxiosError(
|
||||||
e,
|
e,
|
||||||
t('resourceErrorWhitelistSaveDescription')
|
t("resourceErrorWhitelistSaveDescription")
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -283,18 +283,18 @@ export default function ResourceAuthenticationPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t('resourceAuthSettingsSave'),
|
title: t("resourceAuthSettingsSave"),
|
||||||
description: t('resourceAuthSettingsSaveDescription')
|
description: t("resourceAuthSettingsSaveDescription")
|
||||||
});
|
});
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: t('resourceErrorUsersRolesSave'),
|
title: t("resourceErrorUsersRolesSave"),
|
||||||
description: formatAxiosError(
|
description: formatAxiosError(
|
||||||
e,
|
e,
|
||||||
t('resourceErrorUsersRolesSaveDescription')
|
t("resourceErrorUsersRolesSaveDescription")
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -310,8 +310,8 @@ export default function ResourceAuthenticationPage() {
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast({
|
toast({
|
||||||
title: t('resourcePasswordRemove'),
|
title: t("resourcePasswordRemove"),
|
||||||
description: t('resourcePasswordRemoveDescription')
|
description: t("resourcePasswordRemoveDescription")
|
||||||
});
|
});
|
||||||
|
|
||||||
updateAuthInfo({
|
updateAuthInfo({
|
||||||
|
@ -322,10 +322,10 @@ export default function ResourceAuthenticationPage() {
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: t('resourceErrorPasswordRemove'),
|
title: t("resourceErrorPasswordRemove"),
|
||||||
description: formatAxiosError(
|
description: formatAxiosError(
|
||||||
e,
|
e,
|
||||||
t('resourceErrorPasswordRemoveDescription')
|
t("resourceErrorPasswordRemoveDescription")
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
@ -340,8 +340,8 @@ export default function ResourceAuthenticationPage() {
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast({
|
toast({
|
||||||
title: t('resourcePincodeRemove'),
|
title: t("resourcePincodeRemove"),
|
||||||
description: t('resourcePincodeRemoveDescription')
|
description: t("resourcePincodeRemoveDescription")
|
||||||
});
|
});
|
||||||
|
|
||||||
updateAuthInfo({
|
updateAuthInfo({
|
||||||
|
@ -352,10 +352,10 @@ export default function ResourceAuthenticationPage() {
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: t('resourceErrorPincodeRemove'),
|
title: t("resourceErrorPincodeRemove"),
|
||||||
description: formatAxiosError(
|
description: formatAxiosError(
|
||||||
e,
|
e,
|
||||||
t('resourceErrorPincodeRemoveDescription')
|
t("resourceErrorPincodeRemoveDescription")
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
@ -400,17 +400,17 @@ export default function ResourceAuthenticationPage() {
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
<SettingsSectionTitle>
|
<SettingsSectionTitle>
|
||||||
{t('resourceUsersRoles')}
|
{t("resourceUsersRoles")}
|
||||||
</SettingsSectionTitle>
|
</SettingsSectionTitle>
|
||||||
<SettingsSectionDescription>
|
<SettingsSectionDescription>
|
||||||
{t('resourceUsersRolesDescription')}
|
{t("resourceUsersRolesDescription")}
|
||||||
</SettingsSectionDescription>
|
</SettingsSectionDescription>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm>
|
||||||
<SwitchInput
|
<SwitchInput
|
||||||
id="sso-toggle"
|
id="sso-toggle"
|
||||||
label={t('ssoUse')}
|
label={t("ssoUse")}
|
||||||
description={t('ssoUseDescription')}
|
|
||||||
defaultChecked={resource.sso}
|
defaultChecked={resource.sso}
|
||||||
onCheckedChange={(val) => setSsoEnabled(val)}
|
onCheckedChange={(val) => setSsoEnabled(val)}
|
||||||
/>
|
/>
|
||||||
|
@ -430,7 +430,9 @@ export default function ResourceAuthenticationPage() {
|
||||||
name="roles"
|
name="roles"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-col items-start">
|
<FormItem className="flex flex-col items-start">
|
||||||
<FormLabel>{t('roles')}</FormLabel>
|
<FormLabel>
|
||||||
|
{t("roles")}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<TagInput
|
<TagInput
|
||||||
{...field}
|
{...field}
|
||||||
|
@ -440,7 +442,9 @@ export default function ResourceAuthenticationPage() {
|
||||||
setActiveTagIndex={
|
setActiveTagIndex={
|
||||||
setActiveRolesTagIndex
|
setActiveRolesTagIndex
|
||||||
}
|
}
|
||||||
placeholder={t('accessRoleSelect2')}
|
placeholder={t(
|
||||||
|
"accessRoleSelect2"
|
||||||
|
)}
|
||||||
size="sm"
|
size="sm"
|
||||||
tags={
|
tags={
|
||||||
usersRolesForm.getValues()
|
usersRolesForm.getValues()
|
||||||
|
@ -474,7 +478,9 @@ export default function ResourceAuthenticationPage() {
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t('resourceRoleDescription')}
|
{t(
|
||||||
|
"resourceRoleDescription"
|
||||||
|
)}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
@ -484,7 +490,9 @@ export default function ResourceAuthenticationPage() {
|
||||||
name="users"
|
name="users"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-col items-start">
|
<FormItem className="flex flex-col items-start">
|
||||||
<FormLabel>{t('users')}</FormLabel>
|
<FormLabel>
|
||||||
|
{t("users")}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<TagInput
|
<TagInput
|
||||||
{...field}
|
{...field}
|
||||||
|
@ -494,7 +502,9 @@ export default function ResourceAuthenticationPage() {
|
||||||
setActiveTagIndex={
|
setActiveTagIndex={
|
||||||
setActiveUsersTagIndex
|
setActiveUsersTagIndex
|
||||||
}
|
}
|
||||||
placeholder={t('accessUserSelect')}
|
placeholder={t(
|
||||||
|
"accessUserSelect"
|
||||||
|
)}
|
||||||
tags={
|
tags={
|
||||||
usersRolesForm.getValues()
|
usersRolesForm.getValues()
|
||||||
.users
|
.users
|
||||||
|
@ -534,6 +544,7 @@ export default function ResourceAuthenticationPage() {
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
</SettingsSectionForm>
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
<SettingsSectionFooter>
|
<SettingsSectionFooter>
|
||||||
<Button
|
<Button
|
||||||
|
@ -542,7 +553,7 @@ export default function ResourceAuthenticationPage() {
|
||||||
disabled={loadingSaveUsersRoles}
|
disabled={loadingSaveUsersRoles}
|
||||||
form="users-roles-form"
|
form="users-roles-form"
|
||||||
>
|
>
|
||||||
{t('resourceUsersRolesSubmit')}
|
{t("resourceUsersRolesSubmit")}
|
||||||
</Button>
|
</Button>
|
||||||
</SettingsSectionFooter>
|
</SettingsSectionFooter>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
@ -550,25 +561,31 @@ export default function ResourceAuthenticationPage() {
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
<SettingsSectionTitle>
|
<SettingsSectionTitle>
|
||||||
{t('resourceAuthMethods')}
|
{t("resourceAuthMethods")}
|
||||||
</SettingsSectionTitle>
|
</SettingsSectionTitle>
|
||||||
<SettingsSectionDescription>
|
<SettingsSectionDescription>
|
||||||
{t('resourceAuthMethodsDescriptions')}
|
{t("resourceAuthMethodsDescriptions")}
|
||||||
</SettingsSectionDescription>
|
</SettingsSectionDescription>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm>
|
||||||
{/* Password Protection */}
|
{/* Password Protection */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between border rounded-md p-2 mb-4">
|
||||||
<div
|
<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>
|
<span>
|
||||||
{t('resourcePasswordProtection', {status: authInfo.password? t('enabled') : t('disabled')})}
|
{t("resourcePasswordProtection", {
|
||||||
|
status: authInfo.password
|
||||||
|
? t("enabled")
|
||||||
|
: t("disabled")
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
onClick={
|
onClick={
|
||||||
authInfo.password
|
authInfo.password
|
||||||
? removeResourcePassword
|
? removeResourcePassword
|
||||||
|
@ -577,23 +594,28 @@ export default function ResourceAuthenticationPage() {
|
||||||
loading={loadingRemoveResourcePassword}
|
loading={loadingRemoveResourcePassword}
|
||||||
>
|
>
|
||||||
{authInfo.password
|
{authInfo.password
|
||||||
? t('passwordRemove')
|
? t("passwordRemove")
|
||||||
: t('passwordAdd')}
|
: t("passwordAdd")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* PIN Code Protection */}
|
{/* PIN Code Protection */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between border rounded-md p-2">
|
||||||
<div
|
<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>
|
<span>
|
||||||
{t('resourcePincodeProtection', {status: authInfo.pincode ? t('enabled') : t('disabled')})}
|
{t("resourcePincodeProtection", {
|
||||||
|
status: authInfo.pincode
|
||||||
|
? t("enabled")
|
||||||
|
: t("disabled")
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
onClick={
|
onClick={
|
||||||
authInfo.pincode
|
authInfo.pincode
|
||||||
? removeResourcePincode
|
? removeResourcePincode
|
||||||
|
@ -602,37 +624,39 @@ export default function ResourceAuthenticationPage() {
|
||||||
loading={loadingRemoveResourcePincode}
|
loading={loadingRemoveResourcePincode}
|
||||||
>
|
>
|
||||||
{authInfo.pincode
|
{authInfo.pincode
|
||||||
? t('pincodeRemove')
|
? t("pincodeRemove")
|
||||||
: t('pincodeAdd')}
|
: t("pincodeAdd")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</SettingsSectionForm>
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
<SettingsSectionTitle>
|
<SettingsSectionTitle>
|
||||||
{t('otpEmailTitle')}
|
{t("otpEmailTitle")}
|
||||||
</SettingsSectionTitle>
|
</SettingsSectionTitle>
|
||||||
<SettingsSectionDescription>
|
<SettingsSectionDescription>
|
||||||
{t('otpEmailTitleDescription')}
|
{t("otpEmailTitleDescription")}
|
||||||
</SettingsSectionDescription>
|
</SettingsSectionDescription>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm>
|
||||||
{!env.email.emailEnabled && (
|
{!env.email.emailEnabled && (
|
||||||
<Alert variant="neutral" className="mb-4">
|
<Alert variant="neutral" className="mb-4">
|
||||||
<InfoIcon className="h-4 w-4" />
|
<InfoIcon className="h-4 w-4" />
|
||||||
<AlertTitle className="font-semibold">
|
<AlertTitle className="font-semibold">
|
||||||
{t('otpEmailSmtpRequired')}
|
{t("otpEmailSmtpRequired")}
|
||||||
</AlertTitle>
|
</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{t('otpEmailSmtpRequiredDescription')}
|
{t("otpEmailSmtpRequiredDescription")}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<SwitchInput
|
<SwitchInput
|
||||||
id="whitelist-toggle"
|
id="whitelist-toggle"
|
||||||
label={t('otpEmailWhitelist')}
|
label={t("otpEmailWhitelist")}
|
||||||
defaultChecked={resource.emailWhitelistEnabled}
|
defaultChecked={resource.emailWhitelistEnabled}
|
||||||
onCheckedChange={setWhitelistEnabled}
|
onCheckedChange={setWhitelistEnabled}
|
||||||
disabled={!env.email.emailEnabled}
|
disabled={!env.email.emailEnabled}
|
||||||
|
@ -648,8 +672,12 @@ export default function ResourceAuthenticationPage() {
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
<InfoPopup
|
<InfoPopup
|
||||||
text={t('otpEmailWhitelistList')}
|
text={t(
|
||||||
info={t('otpEmailWhitelistListDescription')}
|
"otpEmailWhitelistList"
|
||||||
|
)}
|
||||||
|
info={t(
|
||||||
|
"otpEmailWhitelistListDescription"
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
|
@ -672,7 +700,10 @@ export default function ResourceAuthenticationPage() {
|
||||||
.regex(
|
.regex(
|
||||||
/^\*@[\w.-]+\.[a-zA-Z]{2,}$/,
|
/^\*@[\w.-]+\.[a-zA-Z]{2,}$/,
|
||||||
{
|
{
|
||||||
message: t('otpEmailErrorInvalid')
|
message:
|
||||||
|
t(
|
||||||
|
"otpEmailErrorInvalid"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -683,7 +714,9 @@ export default function ResourceAuthenticationPage() {
|
||||||
setActiveTagIndex={
|
setActiveTagIndex={
|
||||||
setActiveEmailTagIndex
|
setActiveEmailTagIndex
|
||||||
}
|
}
|
||||||
placeholder={t('otpEmailEnter')}
|
placeholder={t(
|
||||||
|
"otpEmailEnter"
|
||||||
|
)}
|
||||||
tags={
|
tags={
|
||||||
whitelistForm.getValues()
|
whitelistForm.getValues()
|
||||||
.emails
|
.emails
|
||||||
|
@ -706,7 +739,9 @@ export default function ResourceAuthenticationPage() {
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t('otpEmailEnterDescription')}
|
{t(
|
||||||
|
"otpEmailEnterDescription"
|
||||||
|
)}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
@ -714,6 +749,7 @@ export default function ResourceAuthenticationPage() {
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
)}
|
||||||
|
</SettingsSectionForm>
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
<SettingsSectionFooter>
|
<SettingsSectionFooter>
|
||||||
<Button
|
<Button
|
||||||
|
@ -722,7 +758,7 @@ export default function ResourceAuthenticationPage() {
|
||||||
loading={loadingSaveWhitelist}
|
loading={loadingSaveWhitelist}
|
||||||
disabled={loadingSaveWhitelist}
|
disabled={loadingSaveWhitelist}
|
||||||
>
|
>
|
||||||
{t('otpEmailWhitelistSave')}
|
{t("otpEmailWhitelistSave")}
|
||||||
</Button>
|
</Button>
|
||||||
</SettingsSectionFooter>
|
</SettingsSectionFooter>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
|
@ -66,6 +66,18 @@ import {
|
||||||
} from "@server/routers/resource";
|
} from "@server/routers/resource";
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
import { useTranslations } from "next-intl";
|
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({
|
const TransferFormSchema = z.object({
|
||||||
siteId: z.number()
|
siteId: z.number()
|
||||||
|
@ -80,6 +92,7 @@ export default function GeneralForm() {
|
||||||
const { org } = useOrgContext();
|
const { org } = useOrgContext();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
const [editDomainOpen, setEditDomainOpen] = useState(false);
|
||||||
|
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
|
|
||||||
|
@ -99,46 +112,22 @@ export default function GeneralForm() {
|
||||||
const [domainType, setDomainType] = useState<"subdomain" | "basedomain">(
|
const [domainType, setDomainType] = useState<"subdomain" | "basedomain">(
|
||||||
resource.isBaseDomain ? "basedomain" : "subdomain"
|
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
|
const GeneralFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
subdomain: z.string().optional(),
|
subdomain: z.string().optional(),
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
proxyPort: z.number().optional(),
|
|
||||||
http: z.boolean(),
|
|
||||||
isBaseDomain: z.boolean().optional(),
|
|
||||||
domainId: z.string().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>;
|
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||||
|
|
||||||
|
@ -148,9 +137,6 @@ export default function GeneralForm() {
|
||||||
enabled: resource.enabled,
|
enabled: resource.enabled,
|
||||||
name: resource.name,
|
name: resource.name,
|
||||||
subdomain: resource.subdomain ? resource.subdomain : undefined,
|
subdomain: resource.subdomain ? resource.subdomain : undefined,
|
||||||
proxyPort: resource.proxyPort ? resource.proxyPort : undefined,
|
|
||||||
http: resource.http,
|
|
||||||
isBaseDomain: resource.isBaseDomain ? true : false,
|
|
||||||
domainId: resource.domainId || undefined
|
domainId: resource.domainId || undefined
|
||||||
},
|
},
|
||||||
mode: "onChange"
|
mode: "onChange"
|
||||||
|
@ -213,10 +199,8 @@ export default function GeneralForm() {
|
||||||
{
|
{
|
||||||
enabled: data.enabled,
|
enabled: data.enabled,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
subdomain: data.http ? data.subdomain : undefined,
|
subdomain: data.subdomain,
|
||||||
proxyPort: data.proxyPort,
|
domainId: data.domainId,
|
||||||
isBaseDomain: data.http ? data.isBaseDomain : undefined,
|
|
||||||
domainId: data.http ? data.domainId : undefined
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
|
@ -242,8 +226,6 @@ export default function GeneralForm() {
|
||||||
enabled: data.enabled,
|
enabled: data.enabled,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
subdomain: data.subdomain,
|
subdomain: data.subdomain,
|
||||||
proxyPort: data.proxyPort,
|
|
||||||
isBaseDomain: data.isBaseDomain,
|
|
||||||
fullDomain: resource.fullDomain
|
fullDomain: resource.fullDomain
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -288,6 +270,7 @@ export default function GeneralForm() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
!loadingPage && (
|
!loadingPage && (
|
||||||
|
<>
|
||||||
<SettingsContainer>
|
<SettingsContainer>
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
|
@ -304,7 +287,7 @@ export default function GeneralForm() {
|
||||||
<Form {...form} key={formKey}>
|
<Form {...form} key={formKey}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="grid grid-cols-1 md:grid-cols-2 gap-4"
|
className="space-y-4"
|
||||||
id="general-settings-form"
|
id="general-settings-form"
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
|
@ -316,18 +299,24 @@ export default function GeneralForm() {
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SwitchInput
|
<SwitchInput
|
||||||
id="enable-resource"
|
id="enable-resource"
|
||||||
defaultChecked={resource.enabled}
|
defaultChecked={
|
||||||
onCheckedChange={(val) => form.setValue("enabled", val)}
|
resource.enabled
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
t(
|
||||||
|
"resourceEnable"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onCheckedChange={(
|
||||||
|
val
|
||||||
|
) =>
|
||||||
|
form.setValue(
|
||||||
|
"enabled",
|
||||||
|
val
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<div className="space-y-1">
|
|
||||||
<FormLabel className="text-base">
|
|
||||||
{t("resourceEnable")}
|
|
||||||
</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
{t("resourceVisibilityTitleDescription")}
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
@ -351,240 +340,27 @@ export default function GeneralForm() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{resource.http && (
|
{resource.http && (
|
||||||
<>
|
<div className="space-y-2">
|
||||||
{env.flags
|
<Label>Domain</Label>
|
||||||
.allowBaseDomainResources && (
|
<div className="border p-2 rounded-md flex items-center justify-between">
|
||||||
<FormField
|
<span className="text-sm text-muted-foreground flex items-center gap-2">
|
||||||
control={form.control}
|
<Globe size="14"/>
|
||||||
name="isBaseDomain"
|
{resourceFullDomain}
|
||||||
render={({ field }) => (
|
</span>
|
||||||
<FormItem>
|
<Button
|
||||||
<FormLabel>
|
variant="secondary"
|
||||||
{t(
|
type="button"
|
||||||
"domainType"
|
size="sm"
|
||||||
)}
|
onClick={() =>
|
||||||
</FormLabel>
|
setEditDomainOpen(
|
||||||
<Select
|
true
|
||||||
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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
>
|
||||||
</FormControl>
|
Edit Domain
|
||||||
<FormMessage />
|
</Button>
|
||||||
</FormItem>
|
</div>
|
||||||
)}
|
</div>
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
@ -594,6 +370,9 @@ export default function GeneralForm() {
|
||||||
<SettingsSectionFooter>
|
<SettingsSectionFooter>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
onClick={() => {
|
||||||
|
console.log(form.getValues());
|
||||||
|
}}
|
||||||
loading={saveLoading}
|
loading={saveLoading}
|
||||||
disabled={saveLoading}
|
disabled={saveLoading}
|
||||||
form="general-settings-form"
|
form="general-settings-form"
|
||||||
|
@ -653,7 +432,8 @@ export default function GeneralForm() {
|
||||||
) =>
|
) =>
|
||||||
site.siteId ===
|
site.siteId ===
|
||||||
field.value
|
field.value
|
||||||
)?.name
|
)
|
||||||
|
?.name
|
||||||
: t(
|
: t(
|
||||||
"siteSelect"
|
"siteSelect"
|
||||||
)}
|
)}
|
||||||
|
@ -661,7 +441,7 @@ export default function GeneralForm() {
|
||||||
</Button>
|
</Button>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-full p-0">
|
<PopoverContent className="w-full p-0" align="start">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
|
@ -675,7 +455,9 @@ export default function GeneralForm() {
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{sites.map(
|
{sites.map(
|
||||||
(site) => (
|
(
|
||||||
|
site
|
||||||
|
) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
value={`${site.name}:${site.siteId}`}
|
value={`${site.name}:${site.siteId}`}
|
||||||
key={
|
key={
|
||||||
|
@ -731,6 +513,47 @@ export default function GeneralForm() {
|
||||||
</SettingsSectionFooter>
|
</SettingsSectionFooter>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
</SettingsContainer>
|
</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>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -796,37 +796,8 @@ export default function ReverseProxyTargets(props: {
|
||||||
</FormItem>
|
</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
|
<FormField
|
||||||
control={
|
control={tlsSettingsForm.control}
|
||||||
tlsSettingsForm.control
|
|
||||||
}
|
|
||||||
name="tlsServerName"
|
name="tlsServerName"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
|
@ -845,8 +816,6 @@ export default function ReverseProxyTargets(props: {
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</SettingsSectionForm>
|
</SettingsSectionForm>
|
||||||
|
|
|
@ -594,17 +594,10 @@ export default function ResourceRules(props: {
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<SwitchInput
|
<SwitchInput
|
||||||
id="rules-toggle"
|
id="rules-toggle"
|
||||||
|
label={t('rulesEnable')}
|
||||||
defaultChecked={rulesEnabled}
|
defaultChecked={rulesEnabled}
|
||||||
onCheckedChange={(val) => setRulesEnabled(val)}
|
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>
|
</div>
|
||||||
|
|
||||||
<Form {...addRuleForm}>
|
<Form {...addRuleForm}>
|
||||||
|
|
|
@ -63,6 +63,7 @@ import { SquareArrowOutUpRight } from "lucide-react";
|
||||||
import CopyTextBox from "@app/components/CopyTextBox";
|
import CopyTextBox from "@app/components/CopyTextBox";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import DomainPicker from "@app/components/DomainPicker";
|
||||||
|
|
||||||
const baseResourceFormSchema = z.object({
|
const baseResourceFormSchema = z.object({
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
|
@ -70,17 +71,10 @@ const baseResourceFormSchema = z.object({
|
||||||
http: z.boolean()
|
http: z.boolean()
|
||||||
});
|
});
|
||||||
|
|
||||||
const httpResourceFormSchema = z.discriminatedUnion("isBaseDomain", [
|
const httpResourceFormSchema = z.object({
|
||||||
z.object({
|
domainId: z.string().optional(),
|
||||||
isBaseDomain: z.literal(true),
|
subdomain: z.string().optional()
|
||||||
domainId: z.string().min(1)
|
});
|
||||||
}),
|
|
||||||
z.object({
|
|
||||||
isBaseDomain: z.literal(false),
|
|
||||||
domainId: z.string().min(1),
|
|
||||||
subdomain: z.string().pipe(subdomainSchema)
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
|
|
||||||
const tcpUdpResourceFormSchema = z.object({
|
const tcpUdpResourceFormSchema = z.object({
|
||||||
protocol: z.string(),
|
protocol: z.string(),
|
||||||
|
@ -143,11 +137,7 @@ export default function Page() {
|
||||||
|
|
||||||
const httpForm = useForm<HttpResourceFormValues>({
|
const httpForm = useForm<HttpResourceFormValues>({
|
||||||
resolver: zodResolver(httpResourceFormSchema),
|
resolver: zodResolver(httpResourceFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {}
|
||||||
subdomain: "",
|
|
||||||
domainId: "",
|
|
||||||
isBaseDomain: false
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const tcpUdpForm = useForm<TcpUdpResourceFormValues>({
|
const tcpUdpForm = useForm<TcpUdpResourceFormValues>({
|
||||||
|
@ -173,20 +163,10 @@ export default function Page() {
|
||||||
|
|
||||||
if (isHttp) {
|
if (isHttp) {
|
||||||
const httpData = httpForm.getValues();
|
const httpData = httpForm.getValues();
|
||||||
if (httpData.isBaseDomain) {
|
|
||||||
Object.assign(payload, {
|
|
||||||
domainId: httpData.domainId,
|
|
||||||
isBaseDomain: true,
|
|
||||||
protocol: "tcp"
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
Object.assign(payload, {
|
Object.assign(payload, {
|
||||||
subdomain: httpData.subdomain,
|
subdomain: httpData.subdomain,
|
||||||
domainId: httpData.domainId,
|
domainId: httpData.domainId
|
||||||
isBaseDomain: false,
|
|
||||||
protocol: "tcp"
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const tcpUdpData = tcpUdpForm.getValues();
|
const tcpUdpData = tcpUdpForm.getValues();
|
||||||
Object.assign(payload, {
|
Object.assign(payload, {
|
||||||
|
@ -498,218 +478,23 @@ export default function Page() {
|
||||||
</SettingsSectionDescription>
|
</SettingsSectionDescription>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<SettingsSectionForm>
|
<DomainPicker
|
||||||
<Form {...httpForm}>
|
orgId={orgId as string}
|
||||||
<form
|
onDomainChange={(res) => {
|
||||||
className="space-y-4"
|
httpForm.setValue(
|
||||||
id="http-settings-form"
|
"subdomain",
|
||||||
>
|
res.subdomain
|
||||||
{env.flags
|
);
|
||||||
.allowBaseDomainResources && (
|
httpForm.setValue(
|
||||||
<FormField
|
"domainId",
|
||||||
control={
|
res.domainId
|
||||||
httpForm.control
|
);
|
||||||
}
|
console.log(
|
||||||
name="isBaseDomain"
|
"Domain changed:",
|
||||||
render={({
|
res
|
||||||
field
|
|
||||||
}) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
{t(
|
|
||||||
"domainType"
|
|
||||||
)}
|
|
||||||
</FormLabel>
|
|
||||||
<Select
|
|
||||||
value={
|
|
||||||
field.value
|
|
||||||
? "basedomain"
|
|
||||||
: "subdomain"
|
|
||||||
}
|
|
||||||
onValueChange={(
|
|
||||||
value
|
|
||||||
) => {
|
|
||||||
field.onChange(
|
|
||||||
value ===
|
|
||||||
"basedomain"
|
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
|
||||||
<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>
|
</SettingsSectionBody>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
) : (
|
) : (
|
||||||
|
@ -921,7 +706,7 @@ export default function Page() {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
router.push(
|
router.push(
|
||||||
`/${orgId}/settings/resources/${resourceId}`
|
`/${orgId}/settings/resources/${resourceId}/proxy`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
@ -67,7 +67,8 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
|
||||||
resource.whitelist
|
resource.whitelist
|
||||||
? "protected"
|
? "protected"
|
||||||
: "not_protected",
|
: "not_protected",
|
||||||
enabled: resource.enabled
|
enabled: resource.enabled,
|
||||||
|
domainId: resource.domainId || undefined
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -5,10 +5,12 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ArrowRight, DockIcon as Docker, Globe, Server, X } from "lucide-react";
|
import { ArrowRight, DockIcon as Docker, Globe, Server, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
export const SitesSplashCard = () => {
|
export const SitesSplashCard = () => {
|
||||||
const [isDismissed, setIsDismissed] = useState(true);
|
const [isDismissed, setIsDismissed] = useState(true);
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
|
||||||
const key = "sites-splash-card-dismissed";
|
const key = "sites-splash-card-dismissed";
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
|
@ -375,7 +375,10 @@ WantedBy=default.target`
|
||||||
async function onSubmit(data: CreateSiteFormValues) {
|
async function onSubmit(data: CreateSiteFormValues) {
|
||||||
setCreateLoading(true);
|
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 (data.method == "wireguard") {
|
||||||
if (!siteDefaults || !wgConfig) {
|
if (!siteDefaults || !wgConfig) {
|
||||||
|
@ -412,7 +415,7 @@ WantedBy=default.target`
|
||||||
exitNodeId: siteDefaults.exitNodeId,
|
exitNodeId: siteDefaults.exitNodeId,
|
||||||
secret: siteDefaults.newtSecret,
|
secret: siteDefaults.newtSecret,
|
||||||
newtId: siteDefaults.newtId,
|
newtId: siteDefaults.newtId,
|
||||||
address: clientAddress
|
// address: clientAddress
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -573,42 +576,42 @@ WantedBy=default.target`
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
{/* <FormField */}
|
||||||
control={form.control}
|
{/* control={form.control} */}
|
||||||
name="clientAddress"
|
{/* name="clientAddress" */}
|
||||||
render={({ field }) => (
|
{/* render={({ field }) => ( */}
|
||||||
<FormItem>
|
{/* <FormItem> */}
|
||||||
<FormLabel>
|
{/* <FormLabel> */}
|
||||||
Site Address
|
{/* Site Address */}
|
||||||
</FormLabel>
|
{/* </FormLabel> */}
|
||||||
<FormControl>
|
{/* <FormControl> */}
|
||||||
<Input
|
{/* <Input */}
|
||||||
autoComplete="off"
|
{/* autoComplete="off" */}
|
||||||
value={
|
{/* value={ */}
|
||||||
clientAddress
|
{/* clientAddress */}
|
||||||
}
|
{/* } */}
|
||||||
onChange={(
|
{/* onChange={( */}
|
||||||
e
|
{/* e */}
|
||||||
) => {
|
{/* ) => { */}
|
||||||
setClientAddress(
|
{/* setClientAddress( */}
|
||||||
e.target
|
{/* e.target */}
|
||||||
.value
|
{/* .value */}
|
||||||
);
|
{/* ); */}
|
||||||
field.onChange(
|
{/* field.onChange( */}
|
||||||
e.target
|
{/* e.target */}
|
||||||
.value
|
{/* .value */}
|
||||||
);
|
{/* ); */}
|
||||||
}}
|
{/* }} */}
|
||||||
/>
|
{/* /> */}
|
||||||
</FormControl>
|
{/* </FormControl> */}
|
||||||
<FormMessage />
|
{/* <FormMessage /> */}
|
||||||
<FormDescription>
|
{/* <FormDescription> */}
|
||||||
Specify the IP
|
{/* Specify the IP */}
|
||||||
address of the host.
|
{/* address of the host. */}
|
||||||
</FormDescription>
|
{/* </FormDescription> */}
|
||||||
</FormItem>
|
{/* </FormItem> */}
|
||||||
)}
|
{/* )} */}
|
||||||
/>
|
{/* /> */}
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</SettingsSectionForm>
|
</SettingsSectionForm>
|
||||||
|
@ -760,7 +763,7 @@ WantedBy=default.target`
|
||||||
? "squareOutlinePrimary"
|
? "squareOutlinePrimary"
|
||||||
: "squareOutline"
|
: "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={() => {
|
onClick={() => {
|
||||||
setPlatform(os);
|
setPlatform(os);
|
||||||
}}
|
}}
|
||||||
|
@ -791,7 +794,7 @@ WantedBy=default.target`
|
||||||
? "squareOutlinePrimary"
|
? "squareOutlinePrimary"
|
||||||
: "squareOutline"
|
: "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={() =>
|
onClick={() =>
|
||||||
setArchitecture(
|
setArchitecture(
|
||||||
arch
|
arch
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { Metadata } from "next";
|
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 { verifySession } from "@app/lib/auth/verifySession";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import ProfileIcon from "@app/components/ProfileIcon";
|
import ProfileIcon from "@app/components/ProfileIcon";
|
||||||
|
import ThemeSwitcher from "@app/components/ThemeSwitcher";
|
||||||
import { Separator } from "@app/components/ui/separator";
|
import { Separator } from "@app/components/ui/separator";
|
||||||
import { priv } from "@app/lib/api";
|
import { priv } from "@app/lib/api";
|
||||||
import { verifySession } from "@app/lib/auth/verifySession";
|
import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
|
@ -11,7 +12,7 @@ import { cache } from "react";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: `Auth - Pangolin`,
|
title: `Auth - "Pangolin`,
|
||||||
description: ""
|
description: ""
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -23,6 +24,7 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
|
||||||
const getUser = cache(verifySession);
|
const getUser = cache(verifySession);
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
const t = await getTranslations();
|
const t = await getTranslations();
|
||||||
|
const hideFooter = true;
|
||||||
|
|
||||||
const licenseStatusRes = await cache(
|
const licenseStatusRes = await cache(
|
||||||
async () =>
|
async () =>
|
||||||
|
@ -34,20 +36,18 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
{user && (
|
<div className="flex justify-end items-center p-3 space-x-2">
|
||||||
<UserProvider user={user}>
|
<ThemeSwitcher />
|
||||||
<div className="p-3 ml-auto">
|
|
||||||
<ProfileIcon />
|
|
||||||
</div>
|
</div>
|
||||||
</UserProvider>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<div className="w-full max-w-md p-3">{children}</div>
|
<div className="w-full max-w-md p-3">{children}</div>
|
||||||
</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">
|
<footer className="hidden md:block w-full mt-12 py-3 mb-6 px-4">
|
||||||
<div className="container mx-auto flex flex-wrap justify-center items-center h-3 space-x-4 text-sm text-neutral-400 dark:text-neutral-600">
|
<div className="container mx-auto flex flex-wrap justify-center items-center h-3 space-x-4 text-sm text-neutral-400 dark:text-neutral-600">
|
||||||
|
@ -73,7 +73,7 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
|
||||||
aria-label="GitHub"
|
aria-label="GitHub"
|
||||||
className="flex items-center space-x-2 whitespace-nowrap"
|
className="flex items-center space-x-2 whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<span>{t('communityEdition')}</span>
|
<span>{t("communityEdition")}</span>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
|
|
|
@ -54,10 +54,10 @@ export default async function Page(props: {
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<Mail className="w-12 h-12 mb-4 text-primary" />
|
<Mail className="w-12 h-12 mb-4 text-primary" />
|
||||||
<h2 className="text-2xl font-bold mb-2 text-center">
|
<h2 className="text-2xl font-bold mb-2 text-center">
|
||||||
{t('inviteAlready')}
|
{t("inviteAlready")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-center">
|
<p className="text-center">
|
||||||
{t('inviteAlreadyDescription')}
|
{t("inviteAlreadyDescription")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -67,7 +67,7 @@ export default async function Page(props: {
|
||||||
|
|
||||||
{(!signUpDisabled || isInvite) && (
|
{(!signUpDisabled || isInvite) && (
|
||||||
<p className="text-center text-muted-foreground mt-4">
|
<p className="text-center text-muted-foreground mt-4">
|
||||||
{t('authNoAccount')}{" "}
|
{t("authNoAccount")}{" "}
|
||||||
<Link
|
<Link
|
||||||
href={
|
href={
|
||||||
!redirectUrl
|
!redirectUrl
|
||||||
|
@ -76,7 +76,7 @@ export default async function Page(props: {
|
||||||
}
|
}
|
||||||
className="underline"
|
className="underline"
|
||||||
>
|
>
|
||||||
{t('signup')}
|
{t("signup")}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -54,12 +54,14 @@ export type ResetPasswordFormProps = {
|
||||||
emailParam?: string;
|
emailParam?: string;
|
||||||
tokenParam?: string;
|
tokenParam?: string;
|
||||||
redirect?: string;
|
redirect?: string;
|
||||||
|
quickstart?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ResetPasswordForm({
|
export default function ResetPasswordForm({
|
||||||
emailParam,
|
emailParam,
|
||||||
tokenParam,
|
tokenParam,
|
||||||
redirect
|
redirect,
|
||||||
|
quickstart
|
||||||
}: ResetPasswordFormProps) {
|
}: ResetPasswordFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
@ -184,8 +186,53 @@ export default function ResetPasswordForm({
|
||||||
return;
|
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(() => {
|
setTimeout(() => {
|
||||||
if (redirect) {
|
if (redirect) {
|
||||||
const safe = cleanRedirect(redirect);
|
const safe = cleanRedirect(redirect);
|
||||||
|
@ -197,14 +244,20 @@ export default function ResetPasswordForm({
|
||||||
}, 1500);
|
}, 1500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('passwordReset')}</CardTitle>
|
<CardTitle>
|
||||||
|
{quickstart ? t('completeAccountSetup') : t('passwordReset')}
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{t('passwordResetDescription')}
|
{quickstart
|
||||||
|
? t('completeAccountSetupDescription')
|
||||||
|
: t('passwordResetDescription')
|
||||||
|
}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
@ -229,7 +282,10 @@ export default function ResetPasswordForm({
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t('passwordResetSent')}
|
{quickstart
|
||||||
|
? t('accountSetupSent')
|
||||||
|
: t('passwordResetSent')
|
||||||
|
}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
@ -269,7 +325,10 @@ export default function ResetPasswordForm({
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t('passwordResetCode')}
|
{quickstart
|
||||||
|
? t('accountSetupCode')
|
||||||
|
: t('passwordResetCode')
|
||||||
|
}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
|
@ -279,7 +338,10 @@ export default function ResetPasswordForm({
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t('passwordResetCodeDescription')}
|
{quickstart
|
||||||
|
? t('accountSetupCodeDescription')
|
||||||
|
: t('passwordResetCodeDescription')
|
||||||
|
}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
@ -292,7 +354,10 @@ export default function ResetPasswordForm({
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t('passwordNew')}
|
{quickstart
|
||||||
|
? t('passwordCreate')
|
||||||
|
: t('passwordNew')
|
||||||
|
}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
|
@ -310,7 +375,10 @@ export default function ResetPasswordForm({
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t('passwordNewConfirm')}
|
{quickstart
|
||||||
|
? t('passwordCreateConfirm')
|
||||||
|
: t('passwordNewConfirm')
|
||||||
|
}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
|
@ -407,7 +475,7 @@ export default function ResetPasswordForm({
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
)}
|
)}
|
||||||
{state === "reset"
|
{state === "reset"
|
||||||
? t('passwordReset')
|
? (quickstart ? t('completeSetup') : t('passwordReset'))
|
||||||
: t('pincodeSubmit2')}
|
: t('pincodeSubmit2')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
@ -422,7 +490,10 @@ export default function ResetPasswordForm({
|
||||||
{isSubmitting && (
|
{isSubmitting && (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
)}
|
)}
|
||||||
{t('passwordResetSubmit')}
|
{quickstart
|
||||||
|
? t('accountSetupSubmit')
|
||||||
|
: t('passwordResetSubmit')
|
||||||
|
}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
@ -13,6 +13,7 @@ export default async function Page(props: {
|
||||||
redirect: string | undefined;
|
redirect: string | undefined;
|
||||||
email: string | undefined;
|
email: string | undefined;
|
||||||
token: string | undefined;
|
token: string | undefined;
|
||||||
|
quickstart?: string | undefined;
|
||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const searchParams = await props.searchParams;
|
const searchParams = await props.searchParams;
|
||||||
|
@ -35,6 +36,9 @@ export default async function Page(props: {
|
||||||
redirect={searchParams.redirect}
|
redirect={searchParams.redirect}
|
||||||
tokenParam={searchParams.token}
|
tokenParam={searchParams.token}
|
||||||
emailParam={searchParams.email}
|
emailParam={searchParams.email}
|
||||||
|
quickstart={
|
||||||
|
searchParams.quickstart === "true" ? true : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p className="text-center text-muted-foreground mt-4">
|
<p className="text-center text-muted-foreground mt-4">
|
||||||
|
@ -46,7 +50,7 @@ export default async function Page(props: {
|
||||||
}
|
}
|
||||||
className="underline"
|
className="underline"
|
||||||
>
|
>
|
||||||
{t('loginBack')}
|
{t("loginBack")}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -43,6 +43,7 @@ import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
|
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
@ -185,8 +186,8 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
setOtpState("otp_sent");
|
setOtpState("otp_sent");
|
||||||
submitOtpForm.setValue("email", values.email);
|
submitOtpForm.setValue("email", values.email);
|
||||||
toast({
|
toast({
|
||||||
title: t('otpEmailSent'),
|
title: t("otpEmailSent"),
|
||||||
description: t('otpEmailSentDescription')
|
description: t("otpEmailSentDescription")
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -202,7 +203,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setWhitelistError(
|
setWhitelistError(
|
||||||
formatAxiosError(e, t('otpEmailErrorAuthenticate'))
|
formatAxiosError(e, t("otpEmailErrorAuthenticate"))
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.then(() => setLoadingLogin(false));
|
.then(() => setLoadingLogin(false));
|
||||||
|
@ -227,7 +228,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setPincodeError(
|
setPincodeError(
|
||||||
formatAxiosError(e, t('pincodeErrorAuthenticate'))
|
formatAxiosError(e, t("pincodeErrorAuthenticate"))
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.then(() => setLoadingLogin(false));
|
.then(() => setLoadingLogin(false));
|
||||||
|
@ -255,7 +256,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setPasswordError(
|
setPasswordError(
|
||||||
formatAxiosError(e, t('passwordErrorAuthenticate'))
|
formatAxiosError(e, t("passwordErrorAuthenticate"))
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.finally(() => setLoadingLogin(false));
|
.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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{!accessDenied ? (
|
{!accessDenied ? (
|
||||||
<div>
|
<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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('authenticationRequired')}</CardTitle>
|
<CardTitle>{getTitle()}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{numMethods > 1
|
{getSubtitle(props.resource.name)}
|
||||||
? t('authenticationMethodChoose', {name: props.resource.name})
|
|
||||||
: t('authenticationRequest', {name: props.resource.name})}
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
@ -329,19 +325,19 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
{props.methods.password && (
|
{props.methods.password && (
|
||||||
<TabsTrigger value="password">
|
<TabsTrigger value="password">
|
||||||
<Key className="w-4 h-4 mr-1" />{" "}
|
<Key className="w-4 h-4 mr-1" />{" "}
|
||||||
{t('password')}
|
{t("password")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
{props.methods.sso && (
|
{props.methods.sso && (
|
||||||
<TabsTrigger value="sso">
|
<TabsTrigger value="sso">
|
||||||
<User className="w-4 h-4 mr-1" />{" "}
|
<User className="w-4 h-4 mr-1" />{" "}
|
||||||
{t('user')}
|
{t("user")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
{props.methods.whitelist && (
|
{props.methods.whitelist && (
|
||||||
<TabsTrigger value="whitelist">
|
<TabsTrigger value="whitelist">
|
||||||
<AtSign className="w-4 h-4 mr-1" />{" "}
|
<AtSign className="w-4 h-4 mr-1" />{" "}
|
||||||
{t('email')}
|
{t("email")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
@ -364,7 +360,9 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t('pincodeInput')}
|
{t(
|
||||||
|
"pincodeInput"
|
||||||
|
)}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
|
@ -433,7 +431,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
disabled={loadingLogin}
|
disabled={loadingLogin}
|
||||||
>
|
>
|
||||||
<LockIcon className="w-4 h-4 mr-2" />
|
<LockIcon className="w-4 h-4 mr-2" />
|
||||||
{t('pincodeSubmit')}
|
{t("pincodeSubmit")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
@ -459,7 +457,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t('password')}
|
{t("password")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
|
@ -487,7 +485,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
disabled={loadingLogin}
|
disabled={loadingLogin}
|
||||||
>
|
>
|
||||||
<LockIcon className="w-4 h-4 mr-2" />
|
<LockIcon className="w-4 h-4 mr-2" />
|
||||||
{t('passwordSubmit')}
|
{t("passwordSubmit")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
@ -528,7 +526,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t('email')}
|
{t("email")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
|
@ -537,7 +535,9 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t('otpEmailDescription')}
|
{t(
|
||||||
|
"otpEmailDescription"
|
||||||
|
)}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
@ -559,7 +559,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
disabled={loadingLogin}
|
disabled={loadingLogin}
|
||||||
>
|
>
|
||||||
<Send className="w-4 h-4 mr-2" />
|
<Send className="w-4 h-4 mr-2" />
|
||||||
{t('otpEmailSend')}
|
{t("otpEmailSend")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
@ -581,7 +581,9 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t('otpEmail')}
|
{t(
|
||||||
|
"otpEmail"
|
||||||
|
)}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
|
@ -609,7 +611,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
disabled={loadingLogin}
|
disabled={loadingLogin}
|
||||||
>
|
>
|
||||||
<LockIcon className="w-4 h-4 mr-2" />
|
<LockIcon className="w-4 h-4 mr-2" />
|
||||||
{t('otpEmailSubmit')}
|
{t("otpEmailSubmit")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
@ -621,7 +623,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
submitOtpForm.reset();
|
submitOtpForm.reset();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('backToEmail')}
|
{t("backToEmail")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
@ -634,7 +636,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
{supporterStatus?.visible && (
|
{supporterStatus?.visible && (
|
||||||
<div className="text-center mt-2">
|
<div className="text-center mt-2">
|
||||||
<span className="text-sm text-muted-foreground opacity-50">
|
<span className="text-sm text-muted-foreground opacity-50">
|
||||||
{t('noSupportKey')}
|
{t("noSupportKey")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -57,7 +57,9 @@ export default function SignupForm({
|
||||||
}: SignupFormProps) {
|
}: SignupFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const { env } = useEnvContext();
|
||||||
|
|
||||||
|
const api = createApiClient({ env });
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
@ -116,8 +118,8 @@ export default function SignupForm({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md shadow-md">
|
||||||
<CardHeader>
|
<CardHeader className="border-b">
|
||||||
<div className="flex flex-row items-center justify-center">
|
<div className="flex flex-row items-center justify-center">
|
||||||
<Image
|
<Image
|
||||||
src={`/logo/pangolin_orange.svg`}
|
src={`/logo/pangolin_orange.svg`}
|
||||||
|
@ -135,7 +137,7 @@ export default function SignupForm({
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="pt-6">
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
@ -161,10 +163,7 @@ export default function SignupForm({
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t('password')}</FormLabel>
|
<FormLabel>{t('password')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input type="password" {...field} />
|
||||||
type="password"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
@ -177,10 +176,7 @@ export default function SignupForm({
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t('confirmPassword')}</FormLabel>
|
<FormLabel>{t('confirmPassword')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input type="password" {...field} />
|
||||||
type="password"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|
|
@ -111,6 +111,9 @@
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
--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 {
|
@layer base {
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import { Toaster } from "@/components/ui/toaster";
|
|
||||||
import { ThemeProvider } from "@app/providers/ThemeProvider";
|
import { ThemeProvider } from "@app/providers/ThemeProvider";
|
||||||
import EnvProvider from "@app/providers/EnvProvider";
|
import EnvProvider from "@app/providers/EnvProvider";
|
||||||
import { pullEnv } from "@app/lib/pullEnv";
|
import { pullEnv } from "@app/lib/pullEnv";
|
||||||
|
@ -15,10 +14,11 @@ import LicenseViolation from "@app/components/LicenseViolation";
|
||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import { NextIntlClientProvider } from "next-intl";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
import { getLocale } from "next-intl/server";
|
import { getLocale } from "next-intl/server";
|
||||||
|
import { Toaster } from "@app/components/ui/toaster";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: `Dashboard - Pangolin`,
|
title: `Dashboard - Pangolin`,
|
||||||
description: ""
|
description: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
|
@ -10,7 +10,8 @@ import {
|
||||||
Workflow,
|
Workflow,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
TicketCheck,
|
TicketCheck,
|
||||||
User
|
User,
|
||||||
|
Globe
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export type SidebarNavSection = {
|
export type SidebarNavSection = {
|
||||||
|
@ -31,6 +32,11 @@ export const orgNavSections: SidebarNavSection[] = [
|
||||||
title: "sidebarResources",
|
title: "sidebarResources",
|
||||||
href: "/{orgId}/settings/resources",
|
href: "/{orgId}/settings/resources",
|
||||||
icon: <Waypoints className="h-4 w-4" />
|
icon: <Waypoints className="h-4 w-4" />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "sidebarDomains",
|
||||||
|
href: "/{orgId}/settings/domains",
|
||||||
|
icon: <Globe className="h-4 w-4" />
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
@ -75,7 +75,6 @@ export default async function Page(props: {
|
||||||
const allCookies = await cookies();
|
const allCookies = await cookies();
|
||||||
const lastOrgCookie = allCookies.get("pangolin-last-org")?.value;
|
const lastOrgCookie = allCookies.get("pangolin-last-org")?.value;
|
||||||
|
|
||||||
if (lastOrgCookie && orgs.length > 0) {
|
|
||||||
const lastOrgExists = orgs.some((org) => org.orgId === lastOrgCookie);
|
const lastOrgExists = orgs.some((org) => org.orgId === lastOrgCookie);
|
||||||
if (lastOrgExists) {
|
if (lastOrgExists) {
|
||||||
redirect(`/${lastOrgCookie}`);
|
redirect(`/${lastOrgCookie}`);
|
||||||
|
@ -87,23 +86,22 @@ export default async function Page(props: {
|
||||||
redirect("/setup");
|
redirect("/setup");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
// return (
|
||||||
<UserProvider user={user}>
|
// <UserProvider user={user}>
|
||||||
<Layout orgs={orgs} navItems={[]}>
|
// <Layout orgs={orgs} navItems={[]}>
|
||||||
<div className="w-full max-w-md mx-auto md:mt-32 mt-4">
|
// <div className="w-full max-w-md mx-auto md:mt-32 mt-4">
|
||||||
<OrganizationLanding
|
// <OrganizationLanding
|
||||||
disableCreateOrg={
|
// disableCreateOrg={
|
||||||
env.flags.disableUserCreateOrg && !user.serverAdmin
|
// env.flags.disableUserCreateOrg && !user.serverAdmin
|
||||||
}
|
// }
|
||||||
organizations={orgs.map((org) => ({
|
// organizations={orgs.map((org) => ({
|
||||||
name: org.name,
|
// name: org.name,
|
||||||
id: org.orgId
|
// id: org.orgId
|
||||||
}))}
|
// }))}
|
||||||
/>
|
// />
|
||||||
</div>
|
// </div>
|
||||||
</Layout>
|
// </Layout>
|
||||||
</UserProvider>
|
// </UserProvider>
|
||||||
);
|
// );
|
||||||
}
|
}
|
||||||
|
|
499
src/components/DomainPicker.tsx
Normal file
499
src/components/DomainPicker.tsx
Normal 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);
|
||||||
|
};
|
||||||
|
}
|
|
@ -31,7 +31,8 @@ export async function Layout({
|
||||||
const allCookies = await cookies();
|
const allCookies = await cookies();
|
||||||
const sidebarStateCookie = allCookies.get("pangolin-sidebar-state")?.value;
|
const sidebarStateCookie = allCookies.get("pangolin-sidebar-state")?.value;
|
||||||
|
|
||||||
const initialSidebarCollapsed = sidebarStateCookie === "collapsed" ||
|
const initialSidebarCollapsed =
|
||||||
|
sidebarStateCookie === "collapsed" ||
|
||||||
(sidebarStateCookie !== "expanded" && defaultSidebarCollapsed);
|
(sidebarStateCookie !== "expanded" && defaultSidebarCollapsed);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -49,7 +50,7 @@ export async function Layout({
|
||||||
{/* Main content area */}
|
{/* Main content area */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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"
|
!showSidebar && "w-full"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
@ -69,7 +70,10 @@ export async function Layout({
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<main className="flex-1 overflow-y-auto p-3 md:p-6 w-full">
|
<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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
@ -7,6 +7,8 @@ import Link from "next/link";
|
||||||
import ProfileIcon from "@app/components/ProfileIcon";
|
import ProfileIcon from "@app/components/ProfileIcon";
|
||||||
import ThemeSwitcher from "@app/components/ThemeSwitcher";
|
import ThemeSwitcher from "@app/components/ThemeSwitcher";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { Badge } from "./ui/badge";
|
||||||
|
|
||||||
interface LayoutHeaderProps {
|
interface LayoutHeaderProps {
|
||||||
showTopBar: boolean;
|
showTopBar: boolean;
|
||||||
|
@ -15,6 +17,7 @@ interface LayoutHeaderProps {
|
||||||
export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
|
export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const [path, setPath] = useState<string>("");
|
const [path, setPath] = useState<string>("");
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function getPath() {
|
function getPath() {
|
||||||
|
@ -56,7 +59,6 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Profile controls on the right */}
|
|
||||||
{showTopBar && (
|
{showTopBar && (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<ThemeSwitcher />
|
<ThemeSwitcher />
|
||||||
|
|
|
@ -152,7 +152,7 @@ export function LayoutSidebar({
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setIsSidebarCollapsed(!isSidebarCollapsed)
|
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={
|
aria-label={
|
||||||
isSidebarCollapsed
|
isSidebarCollapsed
|
||||||
? "Expand sidebar"
|
? "Expand sidebar"
|
||||||
|
|
|
@ -11,6 +11,7 @@ import {
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ArrowRight, Plus } from "lucide-react";
|
import { ArrowRight, Plus } from "lucide-react";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
interface Organization {
|
interface Organization {
|
||||||
|
@ -29,6 +30,8 @@ export default function OrganizationLanding({
|
||||||
}: OrganizationLandingProps) {
|
}: OrganizationLandingProps) {
|
||||||
const [selectedOrg, setSelectedOrg] = useState<string | null>(null);
|
const [selectedOrg, setSelectedOrg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
|
||||||
const handleOrgClick = (orgId: string) => {
|
const handleOrgClick = (orgId: string) => {
|
||||||
setSelectedOrg(orgId);
|
setSelectedOrg(orgId);
|
||||||
};
|
};
|
||||||
|
|
|
@ -19,7 +19,7 @@ export function SettingsSectionForm({
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
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({
|
export function SettingsSectionTitle({
|
||||||
|
|
|
@ -48,6 +48,7 @@ export function SidebarNav({
|
||||||
const niceId = params.niceId as string;
|
const niceId = params.niceId as string;
|
||||||
const resourceId = params.resourceId as string;
|
const resourceId = params.resourceId as string;
|
||||||
const userId = params.userId as string;
|
const userId = params.userId as string;
|
||||||
|
const apiKeyId = params.apiKeyId as string;
|
||||||
const clientId = params.clientId as string;
|
const clientId = params.clientId as string;
|
||||||
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
|
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
|
||||||
const { user } = useUserContext();
|
const { user } = useUserContext();
|
||||||
|
@ -59,6 +60,7 @@ export function SidebarNav({
|
||||||
.replace("{niceId}", niceId)
|
.replace("{niceId}", niceId)
|
||||||
.replace("{resourceId}", resourceId)
|
.replace("{resourceId}", resourceId)
|
||||||
.replace("{userId}", userId)
|
.replace("{userId}", userId)
|
||||||
|
.replace("{apiKeyId}", apiKeyId)
|
||||||
.replace("{clientId}", clientId);
|
.replace("{clientId}", clientId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -67,7 +67,8 @@ export default function SupporterStatus({ isCollapsed = false }: SupporterStatus
|
||||||
const [keyOpen, setKeyOpen] = useState(false);
|
const [keyOpen, setKeyOpen] = useState(false);
|
||||||
const [purchaseOptionsOpen, setPurchaseOptionsOpen] = useState(false);
|
const [purchaseOptionsOpen, setPurchaseOptionsOpen] = useState(false);
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const { env } = useEnvContext();
|
||||||
|
const api = createApiClient({ env });
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
|
|
|
@ -497,7 +497,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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
|
styleClasses?.inlineTagsContainer
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
@ -16,9 +16,9 @@ const badgeVariants = cva(
|
||||||
destructive:
|
destructive:
|
||||||
"border-transparent bg-destructive text-destructive-foreground",
|
"border-transparent bg-destructive text-destructive-foreground",
|
||||||
outline: "text-foreground",
|
outline: "text-foreground",
|
||||||
green: "border-transparent bg-green-500",
|
green: "border-green-600 bg-green-500/20 text-green-700 dark:text-green-300",
|
||||||
yellow: "border-transparent bg-yellow-500",
|
yellow: "border-yellow-600 bg-yellow-500/20 text-yellow-700 dark:text-yellow-300",
|
||||||
red: "border-transparent bg-red-300",
|
red: "border-red-400 bg-red-300/20 text-red-600 dark:text-red-300",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue