mirror of
https://github.com/fosrl/pangolin.git
synced 2025-05-19 08:37:48 +01:00
Merge branch 'dev' into crowdsec
This commit is contained in:
commit
492e53edf3
55 changed files with 1175 additions and 523 deletions
2
.github/workflows/cicd.yml
vendored
2
.github/workflows/cicd.yml
vendored
|
@ -36,7 +36,7 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
TAG=${{ env.TAG }}
|
TAG=${{ env.TAG }}
|
||||||
sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts
|
sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts
|
||||||
cat server/lib/
|
cat server/lib/consts.ts
|
||||||
|
|
||||||
- name: Pull latest Gerbil version
|
- name: Pull latest Gerbil version
|
||||||
id: get-gerbil-tag
|
id: get-gerbil-tag
|
||||||
|
|
66
README.md
66
README.md
|
@ -1,4 +1,5 @@
|
||||||
# Pangolin
|
<div align="center">
|
||||||
|
<h2 align="center"><a href="https://fossorial.io"><img alt="pangolin" src="public/logo//word_mark.png" width="400" /></a></h2>
|
||||||
|
|
||||||
[](https://docs.fossorial.io/)
|
[](https://docs.fossorial.io/)
|
||||||
[](https://hub.docker.com/r/fosrl/pangolin)
|
[](https://hub.docker.com/r/fosrl/pangolin)
|
||||||
|
@ -6,19 +7,28 @@
|
||||||
[](https://discord.gg/HCJR8Xhme4)
|
[](https://discord.gg/HCJR8Xhme4)
|
||||||
[](https://www.youtube.com/@fossorial-app)
|
[](https://www.youtube.com/@fossorial-app)
|
||||||
|
|
||||||
Pangolin is a self-hosted tunneled reverse proxy management server with identity and access control, designed to securely expose private resources through use with the Traefik reverse proxy and WireGuard tunnel clients like Newt. With Pangolin, you retain full control over your infrastructure while providing a user-friendly and feature-rich solution for managing proxies, authentication, and access, and simplifying complex network setups, all with a clean and simple UI.
|
</div>
|
||||||
|
|
||||||
### Installation and Documentation
|
<h3 align="center">Tunneled Mesh Reverse Proxy Server with Access Control</h3>
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
- [Installation Instructions](https://docs.fossorial.io/Getting%20Started/quick-install)
|
_Your own self-hosted zero trust tunnel._
|
||||||
- [Full Documentation](https://docs.fossorial.io)
|
|
||||||
|
|
||||||
### Authors and Maintainers
|
</div>
|
||||||
|
|
||||||
- [Milo Schwartz](https://github.com/miloschwartz)
|
<div align="center">
|
||||||
- [Owen Schwartz](https://github.com/oschwartz10612)
|
<h5>
|
||||||
|
<a href="https://docs.fossorial.io/Getting%20Started/quick-install">
|
||||||
|
Install Guide
|
||||||
|
</a>
|
||||||
|
<span> | </span>
|
||||||
|
<a href="https://docs.fossorial.io">
|
||||||
|
Full Documentation
|
||||||
|
</a>
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
|
||||||
## Preview
|
Pangolin is a self-hosted tunneled reverse proxy server with identity and access control, designed to securely expose private resources on distributed networks. Acting as a central hub, it connects isolated networks — even those behind restrictive firewalls — through encrypted tunnels, enabling easy access to remote services without opening ports.
|
||||||
|
|
||||||
<img src="public/screenshots/sites.png" alt="Preview"/>
|
<img src="public/screenshots/sites.png" alt="Preview"/>
|
||||||
|
|
||||||
|
@ -28,16 +38,18 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
|
||||||
|
|
||||||
### Reverse Proxy Through WireGuard Tunnel
|
### Reverse Proxy Through WireGuard Tunnel
|
||||||
|
|
||||||
- Expose private resources on your network **without opening ports**.
|
- Expose private resources on your network **without opening ports** (firewall punching).
|
||||||
- Secure and easy to configure site-to-site connectivity via a custom **user space WireGuard client**, [Newt](https://github.com/fosrl/newt).
|
- Secure and easy to configure site-to-site connectivity via a custom **user space WireGuard client**, [Newt](https://github.com/fosrl/newt).
|
||||||
- Built-in support for any WireGuard client.
|
- Built-in support for any WireGuard client.
|
||||||
- Automated **SSL certificates** (https) via [LetsEncrypt](https://letsencrypt.org/).
|
- Automated **SSL certificates** (https) via [LetsEncrypt](https://letsencrypt.org/).
|
||||||
- Support for HTTP/HTTPS and **raw TCP/UDP services**.
|
- Support for HTTP/HTTPS and **raw TCP/UDP services**.
|
||||||
|
- Load balancing.
|
||||||
|
|
||||||
### Identity & Access Management
|
### Identity & Access Management
|
||||||
|
|
||||||
- Centralized authentication system using platform SSO. **Users will only have to manage one login.**
|
- Centralized authentication system using platform SSO. **Users will only have to manage one login.**
|
||||||
- Totp with backup codes for two-factor authentication.
|
- **Define access control rules for IPs, IP ranges, and URL paths per resource.**
|
||||||
|
- TOTP with backup codes for two-factor authentication.
|
||||||
- Create organizations, each with multiple sites, users, and roles.
|
- Create organizations, each with multiple sites, users, and roles.
|
||||||
- **Role-based access control** to manage resource access permissions.
|
- **Role-based access control** to manage resource access permissions.
|
||||||
- Additional authentication options include:
|
- Additional authentication options include:
|
||||||
|
@ -55,20 +67,18 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
|
||||||
|
|
||||||
### Easy Deployment
|
### Easy Deployment
|
||||||
|
|
||||||
|
- Run on any cloud provider or on-premises.
|
||||||
- Docker Compose based setup for simplified deployment.
|
- Docker Compose based setup for simplified deployment.
|
||||||
- Future-proof installation script for streamlined setup and feature additions.
|
- Future-proof installation script for streamlined setup and feature additions.
|
||||||
- Run on any VPS.
|
|
||||||
- Use your preferred WireGuard client to connect, or use Newt, our custom user space client for the best experience.
|
- Use your preferred WireGuard client to connect, or use Newt, our custom user space client for the best experience.
|
||||||
|
|
||||||
### Modular Design
|
### Modular Design
|
||||||
|
|
||||||
- Extend functionality with existing [Traefik](https://github.com/traefik/traefik) plugins, such as [Fail2Ban](https://plugins.traefik.io/plugins/628c9ebcffc0cd18356a979f/fail2-ban) or [CrowdSec](https://plugins.traefik.io/plugins/6335346ca4caa9ddeffda116/crowdsec-bouncer-traefik-plugin), which integrate seamlessly.
|
- Extend functionality with existing [Traefik](https://github.com/traefik/traefik) plugins, such as [Fail2Ban](https://plugins.traefik.io/plugins/628c9ebcffc0cd18356a979f/fail2-ban) or [CrowdSec](https://plugins.traefik.io/plugins/6335346ca4caa9ddeffda116/crowdsec-bouncer-traefik-plugin).
|
||||||
- Attach as many sites to the central server as you wish.
|
- Attach as many sites to the central server as you wish.
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
Pangolin has a straightforward and simple dashboard UI:
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -94,22 +104,27 @@ Pangolin has a straightforward and simple dashboard UI:
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Workflow Example
|
## Deployment and Usage Example
|
||||||
|
|
||||||
### Deployment and Usage Example
|
|
||||||
|
|
||||||
1. **Deploy the Central Server**:
|
1. **Deploy the Central Server**:
|
||||||
|
|
||||||
- Deploy the Docker Compose stack containing Pangolin, Gerbil, and Traefik onto a VPS hosted on a cloud platform like Amazon EC2, DigitalOcean Droplet, or similar. There are many cheap VPS hosting options available to suit your needs.
|
- Deploy the Docker Compose stack onto a VPS hosted on a cloud platform like RackNerd, Amazon EC2, DigitalOcean Droplet, or similar. There are many cheap VPS hosting options available to suit your needs.
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> Many of our users have had a great experience with [RackNerd](https://my.racknerd.com/aff.php?aff=13788). Depending on promotions, you can likely get a **VPS with 1 vCPU, 1GB RAM, and ~20GB SSD for just around $12/year**. That's a great deal!
|
||||||
|
> We are part of the [RackNerd](https://my.racknerd.com/aff.php?aff=13788) affiliate program, so if you sign up using [our link](https://my.racknerd.com/aff.php?aff=13788), we receive a small commission which helps us maintain the project and keep it free for everyone.
|
||||||
|
|
||||||
2. **Domain Configuration**:
|
2. **Domain Configuration**:
|
||||||
|
|
||||||
- Point your domain name to the VPS and configure Pangolin with your preferred settings.
|
- Point your domain name to the VPS and configure Pangolin with your preferred settings.
|
||||||
|
|
||||||
3. **Connect Private Sites**:
|
3. **Connect Private Sites**:
|
||||||
|
|
||||||
- Install Newt or use another WireGuard client on private sites.
|
- Install Newt or use another WireGuard client on private sites.
|
||||||
- Automatically establish a connection from these sites to the central server.
|
- Automatically establish a connection from these sites to the central server.
|
||||||
|
|
||||||
4. **Configure Users & Roles**
|
4. **Configure Users & Roles**
|
||||||
|
|
||||||
- Define organizations and invite users.
|
- Define organizations and invite users.
|
||||||
- Implement user- or role-based permissions to control resource access.
|
- Implement user- or role-based permissions to control resource access.
|
||||||
|
|
||||||
|
@ -121,21 +136,22 @@ Pangolin has a straightforward and simple dashboard UI:
|
||||||
|
|
||||||
## Similar Projects and Inspirations
|
## Similar Projects and Inspirations
|
||||||
|
|
||||||
Pangolin was inspired by several existing projects and concepts:
|
**Cloudflare Tunnels**:
|
||||||
|
|
||||||
- **Cloudflare Tunnels**:
|
|
||||||
A similar approach to proxying private resources securely, but Pangolin is a self-hosted alternative, giving you full control over your infrastructure.
|
A similar approach to proxying private resources securely, but Pangolin is a self-hosted alternative, giving you full control over your infrastructure.
|
||||||
|
|
||||||
- **Authentik and Authelia**:
|
**Authentik and Authelia**:
|
||||||
These projects inspired Pangolin’s centralized authentication system for proxies, enabling robust user and role management.
|
These projects inspired Pangolin’s centralized authentication system for proxies, enabling robust user and role management.
|
||||||
|
|
||||||
## Project Development / Roadmap
|
## Project Development / Roadmap
|
||||||
|
|
||||||
Pangolin is under active development, and we are continuously adding new features and improvements. View the [project board](https://github.com/orgs/fosrl/projects/1) for more detailed info.
|
> [!NOTE]
|
||||||
|
> Pangolin is under heavy development. The roadmap is subject to change as we fix bugs, add new features, and make improvements.
|
||||||
|
|
||||||
|
View the [project board](https://github.com/orgs/fosrl/projects/1) for more detailed info.
|
||||||
|
|
||||||
## Licensing
|
## Licensing
|
||||||
|
|
||||||
Pangolin is dual licensed under the AGPLv3 and the Fossorial Commercial license. For inquiries about commercial licensing, please contact us.
|
Pangolin is dual licensed under the AGPLv3 and the Fossorial Commercial license. For inquiries about commercial licensing, please contact us at [numbat@fossorial.io](mailto:numbat@fossorial.io).
|
||||||
|
|
||||||
## Contributions
|
## Contributions
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
version: "3.7"
|
name: pangolin
|
||||||
|
|
||||||
services:
|
services:
|
||||||
pangolin:
|
pangolin:
|
||||||
image: fosrl/pangolin:latest
|
image: fosrl/pangolin:latest
|
||||||
|
@ -32,7 +31,6 @@ services:
|
||||||
- SYS_MODULE
|
- SYS_MODULE
|
||||||
ports:
|
ports:
|
||||||
- 51820:51820/udp
|
- 51820:51820/udp
|
||||||
- 8080:8080 # Port for traefik because of the network_mode
|
|
||||||
- 443:443 # Port for traefik because of the network_mode
|
- 443:443 # Port for traefik because of the network_mode
|
||||||
- 80:80 # Port for traefik because of the network_mode
|
- 80:80 # Port for traefik because of the network_mode
|
||||||
|
|
||||||
|
@ -47,8 +45,8 @@ services:
|
||||||
command:
|
command:
|
||||||
- --configFile=/etc/traefik/traefik_config.yml
|
- --configFile=/etc/traefik/traefik_config.yml
|
||||||
volumes:
|
volumes:
|
||||||
- ./traefik:/etc/traefik:ro # Volume to store the Traefik configuration
|
- ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration
|
||||||
- ./letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
|
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
|
|
9
eslint.config.js
Normal file
9
eslint.config.js
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
// eslint.config.js
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
semi: "error",
|
||||||
|
"prefer-const": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
|
@ -1,3 +1,4 @@
|
||||||
|
name: pangolin
|
||||||
services:
|
services:
|
||||||
pangolin:
|
pangolin:
|
||||||
image: fosrl/pangolin:{{.PangolinVersion}}
|
image: fosrl/pangolin:{{.PangolinVersion}}
|
||||||
|
|
BIN
public/logo/word_mark.png
Normal file
BIN
public/logo/word_mark.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 62 KiB |
|
@ -11,7 +11,7 @@ import {
|
||||||
users
|
users
|
||||||
} from "@server/db/schema";
|
} from "@server/db/schema";
|
||||||
import db from "@server/db";
|
import db from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, inArray } from "drizzle-orm";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import type { RandomReader } from "@oslojs/crypto/random";
|
import type { RandomReader } from "@oslojs/crypto/random";
|
||||||
import { generateRandomString } from "@oslojs/crypto/random";
|
import { generateRandomString } from "@oslojs/crypto/random";
|
||||||
|
@ -95,11 +95,36 @@ export async function validateSessionToken(
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function invalidateSession(sessionId: string): Promise<void> {
|
export async function invalidateSession(sessionId: string): Promise<void> {
|
||||||
await db.delete(sessions).where(eq(sessions.sessionId, sessionId));
|
try {
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
await trx
|
||||||
|
.delete(resourceSessions)
|
||||||
|
.where(eq(resourceSessions.userSessionId, sessionId));
|
||||||
|
await trx.delete(sessions).where(eq(sessions.sessionId, sessionId));
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Failed to invalidate session", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function invalidateAllSessions(userId: string): Promise<void> {
|
export async function invalidateAllSessions(userId: string): Promise<void> {
|
||||||
await db.delete(sessions).where(eq(sessions.userId, userId));
|
try {
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
const userSessions = await trx
|
||||||
|
.select()
|
||||||
|
.from(sessions)
|
||||||
|
.where(eq(sessions.userId, userId));
|
||||||
|
await trx.delete(resourceSessions).where(
|
||||||
|
inArray(
|
||||||
|
resourceSessions.userSessionId,
|
||||||
|
userSessions.map((s) => s.sessionId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await trx.delete(sessions).where(eq(sessions.userId, userId));
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Failed to all invalidate user sessions", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serializeSessionCookie(
|
export function serializeSessionCookie(
|
||||||
|
|
|
@ -377,6 +377,8 @@ export const resourceRules = sqliteTable("resourceRules", {
|
||||||
resourceId: integer("resourceId")
|
resourceId: integer("resourceId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||||
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
|
priority: integer("priority").notNull(),
|
||||||
action: text("action").notNull(), // ACCEPT, DROP
|
action: text("action").notNull(), // ACCEPT, DROP
|
||||||
match: text("match").notNull(), // CIDR, PATH, IP
|
match: text("match").notNull(), // CIDR, PATH, IP
|
||||||
value: text("value").notNull()
|
value: text("value").notNull()
|
||||||
|
@ -414,4 +416,4 @@ export type ResourceOtp = InferSelectModel<typeof resourceOtp>;
|
||||||
export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>;
|
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>;
|
||||||
|
|
|
@ -2,7 +2,7 @@ import path from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
// This is a placeholder value replaced by the build process
|
// This is a placeholder value replaced by the build process
|
||||||
export const APP_VERSION = "1.0.0-beta.13";
|
export const APP_VERSION = "1.0.0-beta.14";
|
||||||
|
|
||||||
export const __FILENAME = fileURLToPath(import.meta.url);
|
export const __FILENAME = fileURLToPath(import.meta.url);
|
||||||
export const __DIRNAME = path.dirname(__FILENAME);
|
export const __DIRNAME = path.dirname(__FILENAME);
|
||||||
|
|
183
server/lib/ip.test.ts
Normal file
183
server/lib/ip.test.ts
Normal file
|
@ -0,0 +1,183 @@
|
||||||
|
import { cidrToRange, findNextAvailableCidr } from "./ip";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares two objects for deep equality
|
||||||
|
* @param actual The actual value to test
|
||||||
|
* @param expected The expected value to compare against
|
||||||
|
* @param message The message to display if assertion fails
|
||||||
|
* @throws Error if objects are not equal
|
||||||
|
*/
|
||||||
|
export function assertEqualsObj<T>(actual: T, expected: T, message: string): void {
|
||||||
|
const actualStr = JSON.stringify(actual);
|
||||||
|
const expectedStr = JSON.stringify(expected);
|
||||||
|
if (actualStr !== expectedStr) {
|
||||||
|
throw new Error(`${message}\nExpected: ${expectedStr}\nActual: ${actualStr}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares two primitive values for equality
|
||||||
|
* @param actual The actual value to test
|
||||||
|
* @param expected The expected value to compare against
|
||||||
|
* @param message The message to display if assertion fails
|
||||||
|
* @throws Error if values are not equal
|
||||||
|
*/
|
||||||
|
export function assertEquals<T>(actual: T, expected: T, message: string): void {
|
||||||
|
if (actual !== expected) {
|
||||||
|
throw new Error(`${message}\nExpected: ${expected}\nActual: ${actual}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests if a function throws an expected error
|
||||||
|
* @param fn The function to test
|
||||||
|
* @param expectedError The expected error message or part of it
|
||||||
|
* @param message The message to display if assertion fails
|
||||||
|
* @throws Error if function doesn't throw or throws unexpected error
|
||||||
|
*/
|
||||||
|
export function assertThrows(
|
||||||
|
fn: () => void,
|
||||||
|
expectedError: string,
|
||||||
|
message: string
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
throw new Error(`${message}: Expected to throw "${expectedError}"`);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Error)) {
|
||||||
|
throw new Error(`${message}\nUnexpected error type: ${typeof error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!error.message.includes(expectedError)) {
|
||||||
|
throw new Error(
|
||||||
|
`${message}\nExpected error: ${expectedError}\nActual error: ${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Test cases
|
||||||
|
function testFindNextAvailableCidr() {
|
||||||
|
console.log("Running findNextAvailableCidr tests...");
|
||||||
|
|
||||||
|
// Test 1: Basic IPv4 allocation
|
||||||
|
{
|
||||||
|
const existing = ["10.0.0.0/16", "10.1.0.0/16"];
|
||||||
|
const result = findNextAvailableCidr(existing, 16, "10.0.0.0/8");
|
||||||
|
assertEquals(result, "10.2.0.0/16", "Basic IPv4 allocation failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: Finding gap between allocations
|
||||||
|
{
|
||||||
|
const existing = ["10.0.0.0/16", "10.2.0.0/16"];
|
||||||
|
const result = findNextAvailableCidr(existing, 16, "10.0.0.0/8");
|
||||||
|
assertEquals(result, "10.1.0.0/16", "Finding gap between allocations failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: No available space
|
||||||
|
{
|
||||||
|
const existing = ["10.0.0.0/8"];
|
||||||
|
const result = findNextAvailableCidr(existing, 8, "10.0.0.0/8");
|
||||||
|
assertEquals(result, null, "No available space test failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// // Test 4: IPv6 allocation
|
||||||
|
// {
|
||||||
|
// const existing = ["2001:db8::/32", "2001:db8:1::/32"];
|
||||||
|
// const result = findNextAvailableCidr(existing, 32, "2001:db8::/16");
|
||||||
|
// assertEquals(result, "2001:db8:2::/32", "Basic IPv6 allocation failed");
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Test 5: Mixed IP versions
|
||||||
|
// {
|
||||||
|
// const existing = ["10.0.0.0/16", "2001:db8::/32"];
|
||||||
|
// assertThrows(
|
||||||
|
// () => findNextAvailableCidr(existing, 16),
|
||||||
|
// "All CIDRs must be of the same IP version",
|
||||||
|
// "Mixed IP versions test failed"
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Test 6: Empty input
|
||||||
|
{
|
||||||
|
const existing: string[] = [];
|
||||||
|
const result = findNextAvailableCidr(existing, 16);
|
||||||
|
assertEquals(result, null, "Empty input test failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 7: Block size alignment
|
||||||
|
{
|
||||||
|
const existing = ["10.0.0.0/24"];
|
||||||
|
const result = findNextAvailableCidr(existing, 24, "10.0.0.0/16");
|
||||||
|
assertEquals(result, "10.0.1.0/24", "Block size alignment test failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 8: Block size alignment
|
||||||
|
{
|
||||||
|
const existing: string[] = [];
|
||||||
|
const result = findNextAvailableCidr(existing, 24, "10.0.0.0/16");
|
||||||
|
assertEquals(result, "10.0.0.0/24", "Block size alignment test failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 9: Large block size request
|
||||||
|
{
|
||||||
|
const existing = ["10.0.0.0/24", "10.0.1.0/24"];
|
||||||
|
const result = findNextAvailableCidr(existing, 16, "10.0.0.0/16");
|
||||||
|
assertEquals(result, null, "Large block size request test failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("All findNextAvailableCidr tests passed!");
|
||||||
|
}
|
||||||
|
|
||||||
|
// function testCidrToRange() {
|
||||||
|
// console.log("Running cidrToRange tests...");
|
||||||
|
|
||||||
|
// // Test 1: Basic IPv4 conversion
|
||||||
|
// {
|
||||||
|
// const result = cidrToRange("192.168.0.0/24");
|
||||||
|
// assertEqualsObj(result, {
|
||||||
|
// start: BigInt("3232235520"),
|
||||||
|
// end: BigInt("3232235775")
|
||||||
|
// }, "Basic IPv4 conversion failed");
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Test 2: IPv6 conversion
|
||||||
|
// {
|
||||||
|
// const result = cidrToRange("2001:db8::/32");
|
||||||
|
// assertEqualsObj(result, {
|
||||||
|
// start: BigInt("42540766411282592856903984951653826560"),
|
||||||
|
// end: BigInt("42540766411282592875350729025363378175")
|
||||||
|
// }, "IPv6 conversion failed");
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Test 3: Invalid prefix length
|
||||||
|
// {
|
||||||
|
// assertThrows(
|
||||||
|
// () => cidrToRange("192.168.0.0/33"),
|
||||||
|
// "Invalid prefix length for IPv4",
|
||||||
|
// "Invalid IPv4 prefix test failed"
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Test 4: Invalid IPv6 prefix
|
||||||
|
// {
|
||||||
|
// assertThrows(
|
||||||
|
// () => cidrToRange("2001:db8::/129"),
|
||||||
|
// "Invalid prefix length for IPv6",
|
||||||
|
// "Invalid IPv6 prefix test failed"
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// console.log("All cidrToRange tests passed!");
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Run all tests
|
||||||
|
try {
|
||||||
|
// testCidrToRange();
|
||||||
|
testFindNextAvailableCidr();
|
||||||
|
console.log("All tests passed successfully!");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Test failed:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
154
server/lib/ip.ts
154
server/lib/ip.ts
|
@ -3,58 +3,162 @@ interface IPRange {
|
||||||
end: bigint;
|
end: bigint;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type IPVersion = 4 | 6;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts IP address string to BigInt for numerical operations
|
* Detects IP version from address string
|
||||||
|
*/
|
||||||
|
function detectIpVersion(ip: string): IPVersion {
|
||||||
|
return ip.includes(':') ? 6 : 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts IPv4 or IPv6 address string to BigInt for numerical operations
|
||||||
*/
|
*/
|
||||||
function ipToBigInt(ip: string): bigint {
|
function ipToBigInt(ip: string): bigint {
|
||||||
return ip.split('.')
|
const version = detectIpVersion(ip);
|
||||||
.reduce((acc, octet) => BigInt.asUintN(64, (acc << BigInt(8)) + BigInt(parseInt(octet))), BigInt(0));
|
|
||||||
|
if (version === 4) {
|
||||||
|
return ip.split('.')
|
||||||
|
.reduce((acc, octet) => {
|
||||||
|
const num = parseInt(octet);
|
||||||
|
if (isNaN(num) || num < 0 || num > 255) {
|
||||||
|
throw new Error(`Invalid IPv4 octet: ${octet}`);
|
||||||
|
}
|
||||||
|
return BigInt.asUintN(64, (acc << BigInt(8)) + BigInt(num));
|
||||||
|
}, BigInt(0));
|
||||||
|
} else {
|
||||||
|
// Handle IPv6
|
||||||
|
// Expand :: notation
|
||||||
|
let fullAddress = ip;
|
||||||
|
if (ip.includes('::')) {
|
||||||
|
const parts = ip.split('::');
|
||||||
|
if (parts.length > 2) throw new Error('Invalid IPv6 address: multiple :: found');
|
||||||
|
const missing = 8 - (parts[0].split(':').length + parts[1].split(':').length);
|
||||||
|
const padding = Array(missing).fill('0').join(':');
|
||||||
|
fullAddress = `${parts[0]}:${padding}:${parts[1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fullAddress.split(':')
|
||||||
|
.reduce((acc, hextet) => {
|
||||||
|
const num = parseInt(hextet || '0', 16);
|
||||||
|
if (isNaN(num) || num < 0 || num > 65535) {
|
||||||
|
throw new Error(`Invalid IPv6 hextet: ${hextet}`);
|
||||||
|
}
|
||||||
|
return BigInt.asUintN(128, (acc << BigInt(16)) + BigInt(num));
|
||||||
|
}, BigInt(0));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts BigInt to IP address string
|
* Converts BigInt to IP address string
|
||||||
*/
|
*/
|
||||||
function bigIntToIp(num: bigint): string {
|
function bigIntToIp(num: bigint, version: IPVersion): string {
|
||||||
const octets: number[] = [];
|
if (version === 4) {
|
||||||
for (let i = 0; i < 4; i++) {
|
const octets: number[] = [];
|
||||||
octets.unshift(Number(num & BigInt(255)));
|
for (let i = 0; i < 4; i++) {
|
||||||
num = num >> BigInt(8);
|
octets.unshift(Number(num & BigInt(255)));
|
||||||
|
num = num >> BigInt(8);
|
||||||
|
}
|
||||||
|
return octets.join('.');
|
||||||
|
} else {
|
||||||
|
const hextets: string[] = [];
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
hextets.unshift(Number(num & BigInt(65535)).toString(16).padStart(4, '0'));
|
||||||
|
num = num >> BigInt(16);
|
||||||
|
}
|
||||||
|
// Compress zero sequences
|
||||||
|
let maxZeroStart = -1;
|
||||||
|
let maxZeroLength = 0;
|
||||||
|
let currentZeroStart = -1;
|
||||||
|
let currentZeroLength = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < hextets.length; i++) {
|
||||||
|
if (hextets[i] === '0000') {
|
||||||
|
if (currentZeroStart === -1) currentZeroStart = i;
|
||||||
|
currentZeroLength++;
|
||||||
|
if (currentZeroLength > maxZeroLength) {
|
||||||
|
maxZeroLength = currentZeroLength;
|
||||||
|
maxZeroStart = currentZeroStart;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentZeroStart = -1;
|
||||||
|
currentZeroLength = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxZeroLength > 1) {
|
||||||
|
hextets.splice(maxZeroStart, maxZeroLength, '');
|
||||||
|
if (maxZeroStart === 0) hextets.unshift('');
|
||||||
|
if (maxZeroStart + maxZeroLength === 8) hextets.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
return hextets.map(h => h === '0000' ? '0' : h.replace(/^0+/, '')).join(':');
|
||||||
}
|
}
|
||||||
return octets.join('.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts CIDR to IP range
|
* Converts CIDR to IP range
|
||||||
*/
|
*/
|
||||||
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 prefixBits = parseInt(prefix);
|
const prefixBits = parseInt(prefix);
|
||||||
const ipBigInt = ipToBigInt(ip);
|
const ipBigInt = ipToBigInt(ip);
|
||||||
const mask = BigInt.asUintN(64, (BigInt(1) << BigInt(32 - prefixBits)) - BigInt(1));
|
|
||||||
|
// Validate prefix length
|
||||||
|
const maxPrefix = version === 4 ? 32 : 128;
|
||||||
|
if (prefixBits < 0 || prefixBits > maxPrefix) {
|
||||||
|
throw new Error(`Invalid prefix length for IPv${version}: ${prefix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shiftBits = BigInt(maxPrefix - prefixBits);
|
||||||
|
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;
|
||||||
|
|
||||||
return { start, end };
|
return { start, end };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds the next available CIDR block given existing allocations
|
* Finds the next available CIDR block given existing allocations
|
||||||
* @param existingCidrs Array of existing CIDR blocks
|
* @param existingCidrs Array of existing CIDR blocks
|
||||||
* @param blockSize Desired prefix length for the new block (e.g., 24 for /24)
|
* @param blockSize Desired prefix length for the new block
|
||||||
* @param startCidr Optional CIDR to start searching from (default: "0.0.0.0/0")
|
* @param startCidr Optional CIDR to start searching from
|
||||||
* @returns Next available CIDR block or null if none found
|
* @returns Next available CIDR block or null if none found
|
||||||
*/
|
*/
|
||||||
export function findNextAvailableCidr(
|
export function findNextAvailableCidr(
|
||||||
existingCidrs: string[],
|
existingCidrs: string[],
|
||||||
blockSize: number,
|
blockSize: number,
|
||||||
startCidr: string = "0.0.0.0/0"
|
startCidr?: string
|
||||||
): string | null {
|
): string | null {
|
||||||
|
|
||||||
|
if (!startCidr && existingCidrs.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no existing CIDRs, use the IP version from startCidr
|
||||||
|
const version = startCidr
|
||||||
|
? detectIpVersion(startCidr.split('/')[0])
|
||||||
|
: 4; // Default to IPv4 if no startCidr provided
|
||||||
|
|
||||||
|
// Use appropriate default startCidr if none provided
|
||||||
|
startCidr = startCidr || (version === 4 ? "0.0.0.0/0" : "::/0");
|
||||||
|
|
||||||
|
// If there are existing CIDRs, ensure all are same version
|
||||||
|
if (existingCidrs.length > 0 &&
|
||||||
|
existingCidrs.some(cidr => detectIpVersion(cidr.split('/')[0]) !== version)) {
|
||||||
|
throw new Error('All CIDRs must be of the same IP version');
|
||||||
|
}
|
||||||
|
|
||||||
// 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
|
||||||
const blockSizeBigInt = BigInt(1) << BigInt(32 - blockSize);
|
const maxPrefix = version === 4 ? 32 : 128;
|
||||||
|
const blockSizeBigInt = BigInt(1) << BigInt(maxPrefix - blockSize);
|
||||||
|
|
||||||
// Start from the beginning of the given CIDR
|
// Start from the beginning of the given CIDR
|
||||||
let current = cidrToRange(startCidr).start;
|
let current = cidrToRange(startCidr).start;
|
||||||
|
@ -63,7 +167,6 @@ export function findNextAvailableCidr(
|
||||||
// Iterate through existing ranges
|
// Iterate through existing ranges
|
||||||
for (let i = 0; i <= existingRanges.length; i++) {
|
for (let i = 0; i <= existingRanges.length; i++) {
|
||||||
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);
|
||||||
|
|
||||||
|
@ -74,7 +177,7 @@ 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)}/${blockSize}`;
|
return `${bigIntToIp(alignedCurrent, version)}/${blockSize}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move current pointer to after the current range
|
// Move current pointer to after the current range
|
||||||
|
@ -85,12 +188,19 @@ export function findNextAvailableCidr(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if a given IP address is within a CIDR range
|
* Checks if a given IP address is within a CIDR range
|
||||||
* @param ip IP address to check
|
* @param ip IP address to check
|
||||||
* @param cidr CIDR range to check against
|
* @param cidr CIDR range to check against
|
||||||
* @returns boolean indicating if IP is within the CIDR range
|
* @returns boolean indicating if IP is within the CIDR range
|
||||||
*/
|
*/
|
||||||
export function isIpInCidr(ip: string, cidr: string): boolean {
|
export function isIpInCidr(ip: string, cidr: string): boolean {
|
||||||
|
const ipVersion = detectIpVersion(ip);
|
||||||
|
const cidrVersion = detectIpVersion(cidr.split('/')[0]);
|
||||||
|
|
||||||
|
if (ipVersion !== cidrVersion) {
|
||||||
|
throw new Error('IP address and CIDR must be of the same version');
|
||||||
|
}
|
||||||
|
|
||||||
const ipBigInt = ipToBigInt(ip);
|
const ipBigInt = ipToBigInt(ip);
|
||||||
const range = cidrToRange(cidr);
|
const range = cidrToRange(cidr);
|
||||||
return ipBigInt >= range.start && ipBigInt <= range.end;
|
return ipBigInt >= range.start && ipBigInt <= range.end;
|
||||||
|
|
|
@ -8,3 +8,4 @@ export const subdomainSchema = z
|
||||||
)
|
)
|
||||||
.min(1, "Subdomain must be at least 1 character long")
|
.min(1, "Subdomain must be at least 1 character long")
|
||||||
.transform((val) => val.toLowerCase());
|
.transform((val) => val.toLowerCase());
|
||||||
|
|
96
server/lib/validators.ts
Normal file
96
server/lib/validators.ts
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
export function isValidCIDR(cidr: string): boolean {
|
||||||
|
return z.string().cidr().safeParse(cidr).success;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidIP(ip: string): boolean {
|
||||||
|
return z.string().ip().safeParse(ip).success;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidUrlGlobPattern(pattern: string): boolean {
|
||||||
|
// Remove leading slash if present
|
||||||
|
pattern = pattern.startsWith("/") ? pattern.slice(1) : pattern;
|
||||||
|
|
||||||
|
// Empty string is not valid
|
||||||
|
if (!pattern) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split path into segments
|
||||||
|
const segments = pattern.split("/");
|
||||||
|
|
||||||
|
// Check each segment
|
||||||
|
for (let i = 0; i < segments.length; i++) {
|
||||||
|
const segment = segments[i];
|
||||||
|
|
||||||
|
// Empty segments are not allowed (double slashes), except at the end
|
||||||
|
if (!segment && i !== segments.length - 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If segment contains *, it must be exactly *
|
||||||
|
if (segment.includes("*") && segment !== "*") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check each character in the segment
|
||||||
|
for (let j = 0; j < segment.length; j++) {
|
||||||
|
const char = segment[j];
|
||||||
|
|
||||||
|
// Check for percent-encoded sequences
|
||||||
|
if (char === "%" && j + 2 < segment.length) {
|
||||||
|
const hex1 = segment[j + 1];
|
||||||
|
const hex2 = segment[j + 2];
|
||||||
|
if (
|
||||||
|
!/^[0-9A-Fa-f]$/.test(hex1) ||
|
||||||
|
!/^[0-9A-Fa-f]$/.test(hex2)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
j += 2; // Skip the next two characters
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow:
|
||||||
|
// - unreserved (A-Z a-z 0-9 - . _ ~)
|
||||||
|
// - sub-delims (! $ & ' ( ) * + , ; =)
|
||||||
|
// - @ : for compatibility with some systems
|
||||||
|
if (!/^[A-Za-z0-9\-._~!$&'()*+,;=@:]$/.test(char)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUrlValid(url: string | undefined) {
|
||||||
|
if (!url) return true; // the link is optional in the schema so if it's empty it's valid
|
||||||
|
var pattern = new RegExp(
|
||||||
|
"^(https?:\\/\\/)?" + // protocol
|
||||||
|
"((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|" + // domain name
|
||||||
|
"((\\d{1,3}\\.){3}\\d{1,3}))" + // OR ip (v4) address
|
||||||
|
"(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*" + // port and path
|
||||||
|
"(\\?[;&a-z\\d%_.~+=-]*)?" + // query string
|
||||||
|
"(\\#[-a-z\\d_]*)?$",
|
||||||
|
"i"
|
||||||
|
);
|
||||||
|
return !!pattern.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTargetValid(value: string | undefined) {
|
||||||
|
if (!value) return true;
|
||||||
|
|
||||||
|
const DOMAIN_REGEX =
|
||||||
|
/^[a-zA-Z0-9_](?:[a-zA-Z0-9-_]{0,61}[a-zA-Z0-9_])?(?:\.[a-zA-Z0-9_](?:[a-zA-Z0-9-_]{0,61}[a-zA-Z0-9_])?)*$/;
|
||||||
|
const IPV4_REGEX =
|
||||||
|
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||||
|
const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i;
|
||||||
|
|
||||||
|
if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DOMAIN_REGEX.test(value);
|
||||||
|
}
|
|
@ -149,8 +149,6 @@ export async function resetPassword(
|
||||||
|
|
||||||
const passwordHash = await hashPassword(newPassword);
|
const passwordHash = await hashPassword(newPassword);
|
||||||
|
|
||||||
await invalidateAllSessions(resetRequest[0].userId);
|
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
await trx
|
await trx
|
||||||
.update(users)
|
.update(users)
|
||||||
|
@ -162,11 +160,21 @@ export async function resetPassword(
|
||||||
.where(eq(passwordResetTokens.email, email));
|
.where(eq(passwordResetTokens.email, email));
|
||||||
});
|
});
|
||||||
|
|
||||||
await sendEmail(ConfirmPasswordReset({ email }), {
|
try {
|
||||||
from: config.getNoReplyEmail(),
|
await invalidateAllSessions(resetRequest[0].userId);
|
||||||
to: email,
|
} catch (e) {
|
||||||
subject: "Password Reset Confirmation"
|
logger.error("Failed to invalidate user sessions", e);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendEmail(ConfirmPasswordReset({ email }), {
|
||||||
|
from: config.getNoReplyEmail(),
|
||||||
|
to: email,
|
||||||
|
subject: "Password Reset Confirmation"
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Failed to send password reset confirmation email", e);
|
||||||
|
}
|
||||||
|
|
||||||
return response<ResetPasswordResponse>(res, {
|
return response<ResetPasswordResponse>(res, {
|
||||||
data: null,
|
data: null,
|
||||||
|
|
|
@ -1,36 +1,38 @@
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import { generateSessionToken } from "@server/auth/sessions/app";
|
||||||
import { NextFunction, Request, Response } from "express";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
import { response } from "@server/lib/response";
|
|
||||||
import db from "@server/db";
|
|
||||||
import {
|
|
||||||
resourceRules,
|
|
||||||
ResourceAccessToken,
|
|
||||||
ResourcePassword,
|
|
||||||
resourcePassword,
|
|
||||||
ResourcePincode,
|
|
||||||
resourcePincode,
|
|
||||||
resources,
|
|
||||||
sessions,
|
|
||||||
userOrgs,
|
|
||||||
users,
|
|
||||||
ResourceRule
|
|
||||||
} from "@server/db/schema";
|
|
||||||
import { and, eq } from "drizzle-orm";
|
|
||||||
import config from "@server/lib/config";
|
|
||||||
import {
|
import {
|
||||||
createResourceSession,
|
createResourceSession,
|
||||||
serializeResourceSessionCookie,
|
serializeResourceSessionCookie,
|
||||||
validateResourceSessionToken
|
validateResourceSessionToken
|
||||||
} from "@server/auth/sessions/resource";
|
} from "@server/auth/sessions/resource";
|
||||||
import { Resource, roleResources, userResources } from "@server/db/schema";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
|
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
|
||||||
import NodeCache from "node-cache";
|
import db from "@server/db";
|
||||||
import { generateSessionToken } from "@server/auth/sessions/app";
|
import {
|
||||||
|
Resource,
|
||||||
|
ResourceAccessToken,
|
||||||
|
ResourcePassword,
|
||||||
|
resourcePassword,
|
||||||
|
ResourcePincode,
|
||||||
|
resourcePincode,
|
||||||
|
ResourceRule,
|
||||||
|
resourceRules,
|
||||||
|
resources,
|
||||||
|
roleResources,
|
||||||
|
sessions,
|
||||||
|
userOrgs,
|
||||||
|
userResources,
|
||||||
|
users
|
||||||
|
} from "@server/db/schema";
|
||||||
|
import config from "@server/lib/config";
|
||||||
import { isIpInCidr } from "@server/lib/ip";
|
import { isIpInCidr } from "@server/lib/ip";
|
||||||
|
import { response } from "@server/lib/response";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import NodeCache from "node-cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
// We'll see if this speeds anything up
|
// We'll see if this speeds anything up
|
||||||
const cache = new NodeCache({
|
const cache = new NodeCache({
|
||||||
|
@ -88,7 +90,15 @@ export async function verifyResourceSession(
|
||||||
|
|
||||||
const clientIp = requestIp?.split(":")[0];
|
const clientIp = requestIp?.split(":")[0];
|
||||||
|
|
||||||
const resourceCacheKey = `resource:${host}`;
|
let cleanHost = host;
|
||||||
|
// if the host ends with :443 or :80 remove it
|
||||||
|
if (cleanHost.endsWith(":443")) {
|
||||||
|
cleanHost = cleanHost.slice(0, -4);
|
||||||
|
} else if (cleanHost.endsWith(":80")) {
|
||||||
|
cleanHost = cleanHost.slice(0, -3);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceCacheKey = `resource:${cleanHost}`;
|
||||||
let resourceData:
|
let resourceData:
|
||||||
| {
|
| {
|
||||||
resource: Resource | null;
|
resource: Resource | null;
|
||||||
|
@ -109,11 +119,11 @@ export async function verifyResourceSession(
|
||||||
resourcePassword,
|
resourcePassword,
|
||||||
eq(resourcePassword.resourceId, resources.resourceId)
|
eq(resourcePassword.resourceId, resources.resourceId)
|
||||||
)
|
)
|
||||||
.where(eq(resources.fullDomain, host))
|
.where(eq(resources.fullDomain, cleanHost))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
logger.debug("Resource not found", host);
|
logger.debug("Resource not found", cleanHost);
|
||||||
return notAllowed(res);
|
return notAllowed(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -129,7 +139,7 @@ export async function verifyResourceSession(
|
||||||
const { resource, pincode, password } = resourceData;
|
const { resource, pincode, password } = resourceData;
|
||||||
|
|
||||||
if (!resource) {
|
if (!resource) {
|
||||||
logger.debug("Resource not found", host);
|
logger.debug("Resource not found", cleanHost);
|
||||||
return notAllowed(res);
|
return notAllowed(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -169,18 +179,16 @@ export async function verifyResourceSession(
|
||||||
// otherwise its undefined and we pass
|
// otherwise its undefined and we pass
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(resource.resourceId)}?redirect=${encodeURIComponent(originalRequestURL)}`;
|
const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(
|
||||||
|
resource.resourceId
|
||||||
|
)}?redirect=${encodeURIComponent(originalRequestURL)}`;
|
||||||
|
|
||||||
// check for access token
|
// check for access token
|
||||||
let validAccessToken: ResourceAccessToken | undefined;
|
let validAccessToken: ResourceAccessToken | undefined;
|
||||||
if (token) {
|
if (token) {
|
||||||
const [accessTokenId, accessToken] = token.split(".");
|
const [accessTokenId, accessToken] = token.split(".");
|
||||||
const { valid, error, tokenItem } = await verifyResourceAccessToken(
|
const { valid, error, tokenItem } = await verifyResourceAccessToken(
|
||||||
{
|
{ resource, accessTokenId, accessToken }
|
||||||
resource,
|
|
||||||
accessTokenId,
|
|
||||||
accessToken
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
@ -190,7 +198,9 @@ export async function verifyResourceSession(
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
if (config.getRawConfig().app.log_failed_attempts) {
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`Resource access token is invalid. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
|
`Resource access token is invalid. Resource ID: ${
|
||||||
|
resource.resourceId
|
||||||
|
}. IP: ${clientIp}.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -211,7 +221,9 @@ export async function verifyResourceSession(
|
||||||
if (!sessions) {
|
if (!sessions) {
|
||||||
if (config.getRawConfig().app.log_failed_attempts) {
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`Missing resource sessions. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
|
`Missing resource sessions. Resource ID: ${
|
||||||
|
resource.resourceId
|
||||||
|
}. IP: ${clientIp}.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return notAllowed(res);
|
return notAllowed(res);
|
||||||
|
@ -219,7 +231,9 @@ export async function verifyResourceSession(
|
||||||
|
|
||||||
const resourceSessionToken =
|
const resourceSessionToken =
|
||||||
sessions[
|
sessions[
|
||||||
`${config.getRawConfig().server.session_cookie_name}${resource.ssl ? "_s" : ""}`
|
`${config.getRawConfig().server.session_cookie_name}${
|
||||||
|
resource.ssl ? "_s" : ""
|
||||||
|
}`
|
||||||
];
|
];
|
||||||
|
|
||||||
if (resourceSessionToken) {
|
if (resourceSessionToken) {
|
||||||
|
@ -242,7 +256,9 @@ export async function verifyResourceSession(
|
||||||
);
|
);
|
||||||
if (config.getRawConfig().app.log_failed_attempts) {
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`Resource session is an exchange token. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
|
`Resource session is an exchange token. Resource ID: ${
|
||||||
|
resource.resourceId
|
||||||
|
}. IP: ${clientIp}.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return notAllowed(res);
|
return notAllowed(res);
|
||||||
|
@ -281,7 +297,9 @@ export async function verifyResourceSession(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resourceSession.userSessionId && sso) {
|
if (resourceSession.userSessionId && sso) {
|
||||||
const userAccessCacheKey = `userAccess:${resourceSession.userSessionId}:${resource.resourceId}`;
|
const userAccessCacheKey = `userAccess:${
|
||||||
|
resourceSession.userSessionId
|
||||||
|
}:${resource.resourceId}`;
|
||||||
|
|
||||||
let isAllowed: boolean | undefined =
|
let isAllowed: boolean | undefined =
|
||||||
cache.get(userAccessCacheKey);
|
cache.get(userAccessCacheKey);
|
||||||
|
@ -305,8 +323,8 @@ export async function verifyResourceSession(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// At this point we have checked all sessions, but since the access token is valid, we should allow access
|
// At this point we have checked all sessions, but since the access token is
|
||||||
// and create a new session.
|
// valid, we should allow access and create a new session.
|
||||||
if (validAccessToken) {
|
if (validAccessToken) {
|
||||||
return await createAccessTokenSession(
|
return await createAccessTokenSession(
|
||||||
res,
|
res,
|
||||||
|
@ -319,7 +337,9 @@ export async function verifyResourceSession(
|
||||||
|
|
||||||
if (config.getRawConfig().app.log_failed_attempts) {
|
if (config.getRawConfig().app.log_failed_attempts) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`Resource access not allowed. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
|
`Resource access not allowed. Resource ID: ${
|
||||||
|
resource.resourceId
|
||||||
|
}. IP: ${clientIp}.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return notAllowed(res, redirectUrl);
|
return notAllowed(res, redirectUrl);
|
||||||
|
@ -485,69 +505,123 @@ async function checkRules(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let hasAcceptRule = false;
|
// sort rules by priority in ascending order
|
||||||
|
rules = rules.sort((a, b) => a.priority - b.priority);
|
||||||
|
|
||||||
// First pass: look for DROP rules
|
|
||||||
for (const rule of rules) {
|
for (const rule of rules) {
|
||||||
|
if (!rule.enabled) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(clientIp &&
|
clientIp &&
|
||||||
rule.match == "CIDR" &&
|
rule.match == "CIDR" &&
|
||||||
isIpInCidr(clientIp, rule.value) &&
|
isIpInCidr(clientIp, rule.value)
|
||||||
rule.action === "DROP") ||
|
|
||||||
(clientIp &&
|
|
||||||
rule.match == "IP" &&
|
|
||||||
clientIp == rule.value &&
|
|
||||||
rule.action === "DROP") ||
|
|
||||||
(path &&
|
|
||||||
rule.match == "PATH" &&
|
|
||||||
urlGlobToRegex(rule.value).test(path) &&
|
|
||||||
rule.action === "DROP")
|
|
||||||
) {
|
) {
|
||||||
return "DROP";
|
return rule.action as any;
|
||||||
}
|
} else if (clientIp && rule.match == "IP" && clientIp == rule.value) {
|
||||||
// Track if we see any ACCEPT rules for the second pass
|
return rule.action as any;
|
||||||
if (rule.action === "ACCEPT") {
|
} else if (
|
||||||
hasAcceptRule = true;
|
path &&
|
||||||
}
|
rule.match == "PATH" &&
|
||||||
}
|
isPathAllowed(rule.value, path)
|
||||||
|
) {
|
||||||
// Second pass: only check ACCEPT rules if we found one and didn't find a DROP
|
return rule.action as any;
|
||||||
if (hasAcceptRule) {
|
|
||||||
for (const rule of rules) {
|
|
||||||
if (rule.action !== "ACCEPT") continue;
|
|
||||||
|
|
||||||
if (
|
|
||||||
(clientIp &&
|
|
||||||
rule.match == "CIDR" &&
|
|
||||||
isIpInCidr(clientIp, rule.value)) ||
|
|
||||||
(clientIp &&
|
|
||||||
rule.match == "IP" &&
|
|
||||||
clientIp == rule.value) ||
|
|
||||||
(path &&
|
|
||||||
rule.match == "PATH" &&
|
|
||||||
urlGlobToRegex(rule.value).test(path))
|
|
||||||
) {
|
|
||||||
return "ACCEPT";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
function urlGlobToRegex(pattern: string): RegExp {
|
function isPathAllowed(pattern: string, path: string): boolean {
|
||||||
// Trim any leading or trailing slashes
|
logger.debug(`\nMatching path "${path}" against pattern "${pattern}"`);
|
||||||
pattern = pattern.replace(/^\/+|\/+$/g, "");
|
|
||||||
|
|
||||||
// Escape special regex characters except *
|
// Normalize and split paths into segments
|
||||||
const escapedPattern = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
const normalize = (p: string) => p.split("/").filter(Boolean);
|
||||||
|
const patternParts = normalize(pattern);
|
||||||
|
const pathParts = normalize(path);
|
||||||
|
|
||||||
// Replace * with regex pattern for any valid URL segment characters
|
logger.debug(`Normalized pattern parts: [${patternParts.join(", ")}]`);
|
||||||
const regexPattern = escapedPattern.replace(/\*/g, "[a-zA-Z0-9_-]+");
|
logger.debug(`Normalized path parts: [${pathParts.join(", ")}]`);
|
||||||
|
|
||||||
// Create the final pattern that:
|
// Recursive function to try different wildcard matches
|
||||||
// 1. Optionally matches leading slash
|
function matchSegments(patternIndex: number, pathIndex: number): boolean {
|
||||||
// 2. Matches the pattern
|
const indent = " ".repeat(pathIndex); // Indent based on recursion depth
|
||||||
// 3. Optionally matches trailing slash
|
const currentPatternPart = patternParts[patternIndex];
|
||||||
return new RegExp(`^/?${regexPattern}/?$`);
|
const currentPathPart = pathParts[pathIndex];
|
||||||
}
|
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Checking patternIndex=${patternIndex} (${currentPatternPart || "END"}) vs pathIndex=${pathIndex} (${currentPathPart || "END"})`
|
||||||
|
);
|
||||||
|
|
||||||
|
// If we've consumed all pattern parts, we should have consumed all path parts
|
||||||
|
if (patternIndex >= patternParts.length) {
|
||||||
|
const result = pathIndex >= pathParts.length;
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Reached end of pattern, remaining path: ${pathParts.slice(pathIndex).join("/")} -> ${result}`
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we've consumed all path parts but still have pattern parts
|
||||||
|
if (pathIndex >= pathParts.length) {
|
||||||
|
// The only way this can match is if all remaining pattern parts are wildcards
|
||||||
|
const remainingPattern = patternParts.slice(patternIndex);
|
||||||
|
const result = remainingPattern.every((p) => p === "*");
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Reached end of path, remaining pattern: ${remainingPattern.join("/")} -> ${result}`
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For wildcards, try consuming different numbers of path segments
|
||||||
|
if (currentPatternPart === "*") {
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Found wildcard at pattern index ${patternIndex}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try consuming 0 segments (skip the wildcard)
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Trying to skip wildcard (consume 0 segments)`
|
||||||
|
);
|
||||||
|
if (matchSegments(patternIndex + 1, pathIndex)) {
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Successfully matched by skipping wildcard`
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try consuming current segment and recursively try rest
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Trying to consume segment "${currentPathPart}" for wildcard`
|
||||||
|
);
|
||||||
|
if (matchSegments(patternIndex, pathIndex + 1)) {
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Successfully matched by consuming segment for wildcard`
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`${indent}Failed to match wildcard`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For regular segments, they must match exactly
|
||||||
|
if (currentPatternPart !== currentPathPart) {
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Segment mismatch: "${currentPatternPart}" != "${currentPathPart}"`
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`${indent}Segments match: "${currentPatternPart}" = "${currentPathPart}"`
|
||||||
|
);
|
||||||
|
// Move to next segments in both pattern and path
|
||||||
|
return matchSegments(patternIndex + 1, pathIndex + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = matchSegments(0, 0);
|
||||||
|
logger.debug(`Final result: ${result}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
|
@ -17,7 +17,7 @@ import { eq, and } from "drizzle-orm";
|
||||||
import stoi from "@server/lib/stoi";
|
import stoi from "@server/lib/stoi";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { subdomainSchema } from "@server/schemas/subdomainSchema";
|
import { subdomainSchema } from "@server/lib/schemas";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
const createResourceParamsSchema = z
|
const createResourceParamsSchema = z
|
||||||
|
|
|
@ -8,12 +8,19 @@ import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
|
import {
|
||||||
|
isValidCIDR,
|
||||||
|
isValidIP,
|
||||||
|
isValidUrlGlobPattern
|
||||||
|
} from "@server/lib/validators";
|
||||||
|
|
||||||
const createResourceRuleSchema = z
|
const createResourceRuleSchema = z
|
||||||
.object({
|
.object({
|
||||||
action: z.enum(["ACCEPT", "DROP"]),
|
action: z.enum(["ACCEPT", "DROP"]),
|
||||||
match: z.enum(["CIDR", "IP", "PATH"]),
|
match: z.enum(["CIDR", "IP", "PATH"]),
|
||||||
value: z.string().min(1)
|
value: z.string().min(1),
|
||||||
|
priority: z.number().int(),
|
||||||
|
enabled: z.boolean().optional()
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
@ -42,7 +49,7 @@ export async function createResourceRule(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { action, match, value } = parsedBody.data;
|
const { action, match, value, priority, enabled } = parsedBody.data;
|
||||||
|
|
||||||
const parsedParams = createResourceRuleParamsSchema.safeParse(
|
const parsedParams = createResourceRuleParamsSchema.safeParse(
|
||||||
req.params
|
req.params
|
||||||
|
@ -74,6 +81,41 @@ export async function createResourceRule(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!resource.http) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Cannot create rule for non-http resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match === "CIDR") {
|
||||||
|
if (!isValidCIDR(value)) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Invalid CIDR provided"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (match === "IP") {
|
||||||
|
if (!isValidIP(value)) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (match === "PATH") {
|
||||||
|
if (!isValidUrlGlobPattern(value)) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Invalid URL glob pattern provided"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create the new resource rule
|
// Create the new resource rule
|
||||||
const [newRule] = await db
|
const [newRule] = await db
|
||||||
.insert(resourceRules)
|
.insert(resourceRules)
|
||||||
|
@ -81,7 +123,9 @@ export async function createResourceRule(
|
||||||
resourceId,
|
resourceId,
|
||||||
action,
|
action,
|
||||||
match,
|
match,
|
||||||
value
|
value,
|
||||||
|
priority,
|
||||||
|
enabled
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|
|
@ -40,12 +40,14 @@ function queryResourceRules(resourceId: number) {
|
||||||
resourceId: resourceRules.resourceId,
|
resourceId: resourceRules.resourceId,
|
||||||
action: resourceRules.action,
|
action: resourceRules.action,
|
||||||
match: resourceRules.match,
|
match: resourceRules.match,
|
||||||
value: resourceRules.value
|
value: resourceRules.value,
|
||||||
|
priority: resourceRules.priority,
|
||||||
|
enabled: resourceRules.enabled
|
||||||
})
|
})
|
||||||
.from(resourceRules)
|
.from(resourceRules)
|
||||||
.leftJoin(resources, eq(resourceRules.resourceId, resources.resourceId))
|
.leftJoin(resources, eq(resourceRules.resourceId, resources.resourceId))
|
||||||
.where(eq(resourceRules.resourceId, resourceId));
|
.where(eq(resourceRules.resourceId, resourceId));
|
||||||
|
|
||||||
return baseQuery;
|
return baseQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -71,7 +73,9 @@ export async function listResourceRules(
|
||||||
}
|
}
|
||||||
const { limit, offset } = parsedQuery.data;
|
const { limit, offset } = parsedQuery.data;
|
||||||
|
|
||||||
const parsedParams = listResourceRulesParamsSchema.safeParse(req.params);
|
const parsedParams = listResourceRulesParamsSchema.safeParse(
|
||||||
|
req.params
|
||||||
|
);
|
||||||
if (!parsedParams.success) {
|
if (!parsedParams.success) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
|
@ -99,16 +103,19 @@ export async function listResourceRules(
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseQuery = queryResourceRules(resourceId);
|
const baseQuery = queryResourceRules(resourceId);
|
||||||
|
|
||||||
let countQuery = db
|
let countQuery = db
|
||||||
.select({ count: sql<number>`cast(count(*) as integer)` })
|
.select({ count: sql<number>`cast(count(*) as integer)` })
|
||||||
.from(resourceRules)
|
.from(resourceRules)
|
||||||
.where(eq(resourceRules.resourceId, resourceId));
|
.where(eq(resourceRules.resourceId, resourceId));
|
||||||
|
|
||||||
const rulesList = await baseQuery.limit(limit).offset(offset);
|
let rulesList = await baseQuery.limit(limit).offset(offset);
|
||||||
const totalCountResult = await countQuery;
|
const totalCountResult = await countQuery;
|
||||||
const totalCount = totalCountResult[0].count;
|
const totalCount = totalCountResult[0].count;
|
||||||
|
|
||||||
|
// sort rules list by the priority in ascending order
|
||||||
|
rulesList = rulesList.sort((a, b) => a.priority - b.priority);
|
||||||
|
|
||||||
return response<ListResourceRulesResponse>(res, {
|
return response<ListResourceRulesResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
rules: rulesList,
|
rules: rulesList,
|
||||||
|
@ -129,4 +136,4 @@ export async function listResourceRules(
|
||||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,8 +8,8 @@ import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { subdomainSchema } from "@server/schemas/subdomainSchema";
|
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
import { subdomainSchema } from "@server/lib/schemas";
|
||||||
|
|
||||||
const updateResourceParamsSchema = z
|
const updateResourceParamsSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
|
|
@ -8,14 +8,16 @@ import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
|
import {
|
||||||
|
isValidCIDR,
|
||||||
|
isValidIP,
|
||||||
|
isValidUrlGlobPattern
|
||||||
|
} from "@server/lib/validators";
|
||||||
|
|
||||||
// Define Zod schema for request parameters validation
|
// Define Zod schema for request parameters validation
|
||||||
const updateResourceRuleParamsSchema = z
|
const updateResourceRuleParamsSchema = z
|
||||||
.object({
|
.object({
|
||||||
ruleId: z
|
ruleId: z.string().transform(Number).pipe(z.number().int().positive()),
|
||||||
.string()
|
|
||||||
.transform(Number)
|
|
||||||
.pipe(z.number().int().positive()),
|
|
||||||
resourceId: z
|
resourceId: z
|
||||||
.string()
|
.string()
|
||||||
.transform(Number)
|
.transform(Number)
|
||||||
|
@ -28,7 +30,9 @@ const updateResourceRuleSchema = z
|
||||||
.object({
|
.object({
|
||||||
action: z.enum(["ACCEPT", "DROP"]).optional(),
|
action: z.enum(["ACCEPT", "DROP"]).optional(),
|
||||||
match: z.enum(["CIDR", "IP", "PATH"]).optional(),
|
match: z.enum(["CIDR", "IP", "PATH"]).optional(),
|
||||||
value: z.string().min(1).optional()
|
value: z.string().min(1).optional(),
|
||||||
|
priority: z.number().int(),
|
||||||
|
enabled: z.boolean().optional()
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.refine((data) => Object.keys(data).length > 0, {
|
.refine((data) => Object.keys(data).length > 0, {
|
||||||
|
@ -42,7 +46,9 @@ export async function updateResourceRule(
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
// Validate path parameters
|
// Validate path parameters
|
||||||
const parsedParams = updateResourceRuleParamsSchema.safeParse(req.params);
|
const parsedParams = updateResourceRuleParamsSchema.safeParse(
|
||||||
|
req.params
|
||||||
|
);
|
||||||
if (!parsedParams.success) {
|
if (!parsedParams.success) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
|
@ -82,6 +88,15 @@ export async function updateResourceRule(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!resource.http) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Cannot create rule for non-http resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Verify that the rule exists and belongs to the specified resource
|
// Verify that the rule exists and belongs to the specified resource
|
||||||
const [existingRule] = await db
|
const [existingRule] = await db
|
||||||
.select()
|
.select()
|
||||||
|
@ -107,6 +122,40 @@ export async function updateResourceRule(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const match = updateData.match || existingRule.match;
|
||||||
|
const { value } = updateData;
|
||||||
|
|
||||||
|
if (value !== undefined) {
|
||||||
|
if (match === "CIDR") {
|
||||||
|
if (!isValidCIDR(value)) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Invalid CIDR provided"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (match === "IP") {
|
||||||
|
if (!isValidIP(value)) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Invalid IP provided"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (match === "PATH") {
|
||||||
|
if (!isValidUrlGlobPattern(value)) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Invalid URL glob pattern provided"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update the rule
|
// Update the rule
|
||||||
const [updatedRule] = await db
|
const [updatedRule] = await db
|
||||||
.update(resourceRules)
|
.update(resourceRules)
|
||||||
|
@ -127,4 +176,4 @@ export async function updateResourceRule(
|
||||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,34 +12,7 @@ import { fromError } from "zod-validation-error";
|
||||||
import { addTargets } from "../newt/targets";
|
import { addTargets } from "../newt/targets";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { pickPort } from "./helpers";
|
import { pickPort } from "./helpers";
|
||||||
|
import { isTargetValid } from "@server/lib/validators";
|
||||||
// Regular expressions for validation
|
|
||||||
const DOMAIN_REGEX =
|
|
||||||
/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
|
||||||
const IPV4_REGEX =
|
|
||||||
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
|
||||||
const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i;
|
|
||||||
|
|
||||||
// Schema for domain names and IP addresses
|
|
||||||
const domainSchema = z
|
|
||||||
.string()
|
|
||||||
.min(1, "Domain cannot be empty")
|
|
||||||
.max(255, "Domain name too long")
|
|
||||||
.refine(
|
|
||||||
(value) => {
|
|
||||||
// Check if it's a valid IP address (v4 or v6)
|
|
||||||
if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it's a valid domain name
|
|
||||||
return DOMAIN_REGEX.test(value);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: "Invalid domain name or IP address format",
|
|
||||||
path: ["domain"]
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const createTargetParamsSchema = z
|
const createTargetParamsSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -52,7 +25,7 @@ const createTargetParamsSchema = z
|
||||||
|
|
||||||
const createTargetSchema = z
|
const createTargetSchema = z
|
||||||
.object({
|
.object({
|
||||||
ip: domainSchema,
|
ip: z.string().refine(isTargetValid),
|
||||||
method: z.string().optional().nullable(),
|
method: z.string().optional().nullable(),
|
||||||
port: z.number().int().min(1).max(65535),
|
port: z.number().int().min(1).max(65535),
|
||||||
enabled: z.boolean().default(true)
|
enabled: z.boolean().default(true)
|
||||||
|
|
|
@ -11,34 +11,7 @@ import { fromError } from "zod-validation-error";
|
||||||
import { addPeer } from "../gerbil/peers";
|
import { addPeer } from "../gerbil/peers";
|
||||||
import { addTargets } from "../newt/targets";
|
import { addTargets } from "../newt/targets";
|
||||||
import { pickPort } from "./helpers";
|
import { pickPort } from "./helpers";
|
||||||
|
import { isTargetValid } from "@server/lib/validators";
|
||||||
// Regular expressions for validation
|
|
||||||
const DOMAIN_REGEX =
|
|
||||||
/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
|
||||||
const IPV4_REGEX =
|
|
||||||
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
|
||||||
const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i;
|
|
||||||
|
|
||||||
// Schema for domain names and IP addresses
|
|
||||||
const domainSchema = z
|
|
||||||
.string()
|
|
||||||
.min(1, "Domain cannot be empty")
|
|
||||||
.max(255, "Domain name too long")
|
|
||||||
.refine(
|
|
||||||
(value) => {
|
|
||||||
// Check if it's a valid IP address (v4 or v6)
|
|
||||||
if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it's a valid domain name
|
|
||||||
return DOMAIN_REGEX.test(value);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: "Invalid domain name or IP address format",
|
|
||||||
path: ["domain"]
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateTargetParamsSchema = z
|
const updateTargetParamsSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -48,7 +21,7 @@ const updateTargetParamsSchema = z
|
||||||
|
|
||||||
const updateTargetBodySchema = z
|
const updateTargetBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
ip: domainSchema.optional(),
|
ip: z.string().refine(isTargetValid),
|
||||||
method: z.string().min(1).max(10).optional().nullable(),
|
method: z.string().min(1).max(10).optional().nullable(),
|
||||||
port: z.number().int().min(1).max(65535).optional(),
|
port: z.number().int().min(1).max(65535).optional(),
|
||||||
enabled: z.boolean().optional()
|
enabled: z.boolean().optional()
|
||||||
|
|
|
@ -1,14 +1,18 @@
|
||||||
import db from "@server/db";
|
import db from "@server/db";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
const version = "1.0.0-beta.13";
|
||||||
|
|
||||||
export default async function migration() {
|
export default async function migration() {
|
||||||
console.log("Running setup script 1.0.0-beta.13...");
|
console.log(`Running setup script ${version}...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
db.transaction((trx) => {
|
db.transaction((trx) => {
|
||||||
trx.run(sql`CREATE TABLE resourceRules (
|
trx.run(sql`CREATE TABLE resourceRules (
|
||||||
ruleId integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
ruleId integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
resourceId integer NOT NULL,
|
resourceId integer NOT NULL,
|
||||||
|
priority integer NOT NULL,
|
||||||
|
enabled integer DEFAULT true NOT NULL,
|
||||||
action text NOT NULL,
|
action text NOT NULL,
|
||||||
match text NOT NULL,
|
match text NOT NULL,
|
||||||
value text NOT NULL,
|
value text NOT NULL,
|
||||||
|
@ -25,5 +29,5 @@ export default async function migration() {
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Done.");
|
console.log(`${version} migration complete`);
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,7 +10,7 @@ import {
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import { Input } from "@app/components/ui/input";
|
import { Input } from "@app/components/ui/input";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
@ -48,7 +48,6 @@ export default function CreateRoleForm({
|
||||||
setOpen,
|
setOpen,
|
||||||
afterCreate,
|
afterCreate,
|
||||||
}: CreateRoleFormProps) {
|
}: CreateRoleFormProps) {
|
||||||
const { toast } = useToast();
|
|
||||||
const { org } = useOrgContext();
|
const { org } = useOrgContext();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
|
@ -9,7 +9,7 @@ import {
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
@ -56,7 +56,6 @@ export default function DeleteRoleForm({
|
||||||
setOpen,
|
setOpen,
|
||||||
afterDelete,
|
afterDelete,
|
||||||
}: CreateRoleFormProps) {
|
}: CreateRoleFormProps) {
|
||||||
const { toast } = useToast();
|
|
||||||
const { org } = useOrgContext();
|
const { org } = useOrgContext();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
|
@ -9,7 +9,7 @@ import {
|
||||||
SortingState,
|
SortingState,
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
ColumnFiltersState,
|
ColumnFiltersState,
|
||||||
getFilteredRowModel,
|
getFilteredRowModel
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
|
@ -18,7 +18,7 @@ import {
|
||||||
TableContainer,
|
TableContainer,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
@ -35,7 +35,7 @@ interface DataTableProps<TData, TValue> {
|
||||||
export function RolesDataTable<TData, TValue>({
|
export function RolesDataTable<TData, TValue>({
|
||||||
addRole,
|
addRole,
|
||||||
columns,
|
columns,
|
||||||
data,
|
data
|
||||||
}: DataTableProps<TData, TValue>) {
|
}: DataTableProps<TData, TValue>) {
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||||
|
@ -50,13 +50,15 @@ export function RolesDataTable<TData, TValue>({
|
||||||
onColumnFiltersChange: setColumnFilters,
|
onColumnFiltersChange: setColumnFilters,
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
initialState: {
|
initialState: {
|
||||||
sorting,
|
|
||||||
columnFilters,
|
|
||||||
pagination: {
|
pagination: {
|
||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
pageIndex: 0,
|
pageIndex: 0
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
columnFilters
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -102,7 +104,7 @@ export function RolesDataTable<TData, TValue>({
|
||||||
: flexRender(
|
: flexRender(
|
||||||
header.column.columnDef
|
header.column.columnDef
|
||||||
.header,
|
.header,
|
||||||
header.getContext(),
|
header.getContext()
|
||||||
)}
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
);
|
);
|
||||||
|
@ -123,7 +125,7 @@ export function RolesDataTable<TData, TValue>({
|
||||||
<TableCell key={cell.id}>
|
<TableCell key={cell.id}>
|
||||||
{flexRender(
|
{flexRender(
|
||||||
cell.column.columnDef.cell,
|
cell.column.columnDef.cell,
|
||||||
cell.getContext(),
|
cell.getContext()
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -12,7 +12,7 @@ import { ArrowUpDown, Crown, MoreHorizontal } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { RolesDataTable } from "./RolesDataTable";
|
import { RolesDataTable } from "./RolesDataTable";
|
||||||
import { Role } from "@server/db/schema";
|
import { Role } from "@server/db/schema";
|
||||||
import CreateRoleForm from "./CreateRoleForm";
|
import CreateRoleForm from "./CreateRoleForm";
|
||||||
|
@ -37,7 +37,6 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
const { org } = useOrgContext();
|
const { org } = useOrgContext();
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const columns: ColumnDef<RoleRow>[] = [
|
const columns: ColumnDef<RoleRow>[] = [
|
||||||
{
|
{
|
||||||
|
|
|
@ -17,7 +17,7 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue
|
SelectValue
|
||||||
} from "@app/components/ui/select";
|
} from "@app/components/ui/select";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { InviteUserBody, InviteUserResponse } from "@server/routers/user";
|
import { InviteUserBody, InviteUserResponse } from "@server/routers/user";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
|
@ -54,7 +54,6 @@ const formSchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
|
export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
|
||||||
const { toast } = useToast();
|
|
||||||
const { org } = useOrgContext();
|
const { org } = useOrgContext();
|
||||||
|
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
|
|
|
@ -9,7 +9,7 @@ import {
|
||||||
SortingState,
|
SortingState,
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
ColumnFiltersState,
|
ColumnFiltersState,
|
||||||
getFilteredRowModel,
|
getFilteredRowModel
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
|
@ -18,7 +18,7 @@ import {
|
||||||
TableContainer,
|
TableContainer,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
@ -35,7 +35,7 @@ interface DataTableProps<TData, TValue> {
|
||||||
export function UsersDataTable<TData, TValue>({
|
export function UsersDataTable<TData, TValue>({
|
||||||
inviteUser,
|
inviteUser,
|
||||||
columns,
|
columns,
|
||||||
data,
|
data
|
||||||
}: DataTableProps<TData, TValue>) {
|
}: DataTableProps<TData, TValue>) {
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||||
|
@ -50,13 +50,15 @@ export function UsersDataTable<TData, TValue>({
|
||||||
onColumnFiltersChange: setColumnFilters,
|
onColumnFiltersChange: setColumnFilters,
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
initialState: {
|
initialState: {
|
||||||
sorting,
|
|
||||||
columnFilters,
|
|
||||||
pagination: {
|
pagination: {
|
||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
pageIndex: 0,
|
pageIndex: 0
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
columnFilters
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -102,7 +104,7 @@ export function UsersDataTable<TData, TValue>({
|
||||||
: flexRender(
|
: flexRender(
|
||||||
header.column.columnDef
|
header.column.columnDef
|
||||||
.header,
|
.header,
|
||||||
header.getContext(),
|
header.getContext()
|
||||||
)}
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
);
|
);
|
||||||
|
@ -123,7 +125,7 @@ export function UsersDataTable<TData, TValue>({
|
||||||
<TableCell key={cell.id}>
|
<TableCell key={cell.id}>
|
||||||
{flexRender(
|
{flexRender(
|
||||||
cell.column.columnDef.cell,
|
cell.column.columnDef.cell,
|
||||||
cell.getContext(),
|
cell.getContext()
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -14,7 +14,7 @@ import { useState } from "react";
|
||||||
import InviteUserForm from "./InviteUserForm";
|
import InviteUserForm from "./InviteUserForm";
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { formatAxiosError } from "@app/lib/api";
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
|
@ -47,7 +47,6 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||||
|
|
||||||
const { user, updateUser } = useUserContext();
|
const { user, updateUser } = useUserContext();
|
||||||
const { org } = useOrgContext();
|
const { org } = useOrgContext();
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const columns: ColumnDef<UserRow>[] = [
|
const columns: ColumnDef<UserRow>[] = [
|
||||||
{
|
{
|
||||||
|
|
|
@ -16,7 +16,7 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue
|
SelectValue
|
||||||
} from "@app/components/ui/select";
|
} from "@app/components/ui/select";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { InviteUserResponse } from "@server/routers/user";
|
import { InviteUserResponse } from "@server/routers/user";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
|
@ -47,7 +47,6 @@ const formSchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function AccessControlsPage() {
|
export default function AccessControlsPage() {
|
||||||
const { toast } = useToast();
|
|
||||||
const { orgUser: user } = userOrgUserContext();
|
const { orgUser: user } = userOrgUserContext();
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
|
@ -4,7 +4,7 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||||
import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
|
import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
|
@ -56,7 +56,6 @@ export default function GeneralPage() {
|
||||||
const { orgUser } = userOrgUserContext();
|
const { orgUser } = userOrgUserContext();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { org } = useOrgContext();
|
const { org } = useOrgContext();
|
||||||
const { toast } = useToast();
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
const [loadingDelete, setLoadingDelete] = useState(false);
|
const [loadingDelete, setLoadingDelete] = useState(false);
|
||||||
|
|
|
@ -11,7 +11,7 @@ import {
|
||||||
FormMessage
|
FormMessage
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import { Input } from "@app/components/ui/input";
|
import { Input } from "@app/components/ui/input";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
@ -59,7 +59,7 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue
|
SelectValue
|
||||||
} from "@app/components/ui/select";
|
} from "@app/components/ui/select";
|
||||||
import { subdomainSchema } from "@server/schemas/subdomainSchema";
|
import { subdomainSchema } from "@server/lib/schemas";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { SquareArrowOutUpRight } from "lucide-react";
|
import { SquareArrowOutUpRight } from "lucide-react";
|
||||||
import CopyTextBox from "@app/components/CopyTextBox";
|
import CopyTextBox from "@app/components/CopyTextBox";
|
||||||
|
@ -117,8 +117,6 @@ export default function CreateResourceForm({
|
||||||
open,
|
open,
|
||||||
setOpen
|
setOpen
|
||||||
}: CreateResourceFormProps) {
|
}: CreateResourceFormProps) {
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
|
@ -9,7 +9,7 @@ import {
|
||||||
SortingState,
|
SortingState,
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
ColumnFiltersState,
|
ColumnFiltersState,
|
||||||
getFilteredRowModel,
|
getFilteredRowModel
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -19,7 +19,7 @@ import {
|
||||||
TableContainer,
|
TableContainer,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
@ -36,7 +36,7 @@ interface ResourcesDataTableProps<TData, TValue> {
|
||||||
export function ResourcesDataTable<TData, TValue>({
|
export function ResourcesDataTable<TData, TValue>({
|
||||||
addResource,
|
addResource,
|
||||||
columns,
|
columns,
|
||||||
data,
|
data
|
||||||
}: ResourcesDataTableProps<TData, TValue>) {
|
}: ResourcesDataTableProps<TData, TValue>) {
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||||
|
@ -51,13 +51,15 @@ export function ResourcesDataTable<TData, TValue>({
|
||||||
onColumnFiltersChange: setColumnFilters,
|
onColumnFiltersChange: setColumnFilters,
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
initialState: {
|
initialState: {
|
||||||
sorting,
|
|
||||||
columnFilters,
|
|
||||||
pagination: {
|
pagination: {
|
||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
pageIndex: 0,
|
pageIndex: 0
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
columnFilters
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -103,7 +105,7 @@ export function ResourcesDataTable<TData, TValue>({
|
||||||
: flexRender(
|
: flexRender(
|
||||||
header.column.columnDef
|
header.column.columnDef
|
||||||
.header,
|
.header,
|
||||||
header.getContext(),
|
header.getContext()
|
||||||
)}
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
);
|
);
|
||||||
|
@ -124,7 +126,7 @@ export function ResourcesDataTable<TData, TValue>({
|
||||||
<TableCell key={cell.id}>
|
<TableCell key={cell.id}>
|
||||||
{flexRender(
|
{flexRender(
|
||||||
cell.column.columnDef.cell,
|
cell.column.columnDef.cell,
|
||||||
cell.getContext(),
|
cell.getContext()
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -26,7 +26,7 @@ import { useState } from "react";
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
import { set } from "zod";
|
import { set } from "zod";
|
||||||
import { formatAxiosError } from "@app/lib/api";
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
|
@ -52,8 +52,6 @@ type ResourcesTableProps = {
|
||||||
export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
|
|
@ -14,7 +14,7 @@ export default function CustomDomainInput({
|
||||||
domainSuffix,
|
domainSuffix,
|
||||||
placeholder = "Enter subdomain",
|
placeholder = "Enter subdomain",
|
||||||
value: defaultValue,
|
value: defaultValue,
|
||||||
onChange,
|
onChange
|
||||||
}: CustomDomainInputProps) {
|
}: CustomDomainInputProps) {
|
||||||
const [value, setValue] = React.useState(defaultValue);
|
const [value, setValue] = React.useState(defaultValue);
|
||||||
|
|
||||||
|
@ -34,10 +34,10 @@ export default function CustomDomainInput({
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="rounded-r-none flex-grow"
|
className="rounded-r-none w-full"
|
||||||
/>
|
/>
|
||||||
<div className="inline-flex items-center px-3 rounded-r-md border border-l-0 border-input bg-muted text-muted-foreground">
|
<div className="max-w-1/2 flex items-center px-3 rounded-r-md border border-l-0 border-input bg-muted text-muted-foreground">
|
||||||
<span className="text-sm">.{domainSuffix}</span>
|
<span className="text-sm truncate">.{domainSuffix}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -11,7 +11,7 @@ import {
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import { Input } from "@app/components/ui/input";
|
import { Input } from "@app/components/ui/input";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
@ -55,8 +55,6 @@ export default function SetResourcePasswordForm({
|
||||||
resourceId,
|
resourceId,
|
||||||
onSetPassword,
|
onSetPassword,
|
||||||
}: SetPasswordFormProps) {
|
}: SetPasswordFormProps) {
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
|
@ -11,7 +11,7 @@ import {
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import { Input } from "@app/components/ui/input";
|
import { Input } from "@app/components/ui/input";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
@ -60,8 +60,6 @@ export default function SetResourcePincodeForm({
|
||||||
resourceId,
|
resourceId,
|
||||||
onSetPincode,
|
onSetPincode,
|
||||||
}: SetPincodeFormProps) {
|
}: SetPincodeFormProps) {
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ListRolesResponse } from "@server/routers/role";
|
import { ListRolesResponse } from "@server/routers/role";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
|
@ -49,6 +49,7 @@ import {
|
||||||
} from "@app/components/Settings";
|
} from "@app/components/Settings";
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
const UsersRolesFormSchema = z.object({
|
const UsersRolesFormSchema = z.object({
|
||||||
roles: z.array(
|
roles: z.array(
|
||||||
|
@ -75,7 +76,6 @@ const whitelistSchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function ResourceAuthenticationPage() {
|
export default function ResourceAuthenticationPage() {
|
||||||
const { toast } = useToast();
|
|
||||||
const { org } = useOrgContext();
|
const { org } = useOrgContext();
|
||||||
const { resource, updateResource, authInfo, updateAuthInfo } =
|
const { resource, updateResource, authInfo, updateAuthInfo } =
|
||||||
useResourceContext();
|
useResourceContext();
|
||||||
|
@ -83,6 +83,7 @@ export default function ResourceAuthenticationPage() {
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
|
|
||||||
const api = createApiClient({ env });
|
const api = createApiClient({ env });
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const [pageLoading, setPageLoading] = useState(true);
|
const [pageLoading, setPageLoading] = useState(true);
|
||||||
|
|
||||||
|
@ -237,6 +238,7 @@ export default function ResourceAuthenticationPage() {
|
||||||
title: "Saved successfully",
|
title: "Saved successfully",
|
||||||
description: "Whitelist settings have been saved"
|
description: "Whitelist settings have been saved"
|
||||||
});
|
});
|
||||||
|
router.refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
toast({
|
toast({
|
||||||
|
@ -284,6 +286,7 @@ export default function ResourceAuthenticationPage() {
|
||||||
title: "Saved successfully",
|
title: "Saved successfully",
|
||||||
description: "Authentication settings have been saved"
|
description: "Authentication settings have been saved"
|
||||||
});
|
});
|
||||||
|
router.refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
toast({
|
toast({
|
||||||
|
@ -315,6 +318,7 @@ export default function ResourceAuthenticationPage() {
|
||||||
updateAuthInfo({
|
updateAuthInfo({
|
||||||
password: false
|
password: false
|
||||||
});
|
});
|
||||||
|
router.refresh();
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
toast({
|
toast({
|
||||||
|
@ -345,6 +349,7 @@ export default function ResourceAuthenticationPage() {
|
||||||
updateAuthInfo({
|
updateAuthInfo({
|
||||||
pincode: false
|
pincode: false
|
||||||
});
|
});
|
||||||
|
router.refresh();
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
toast({
|
toast({
|
||||||
|
|
|
@ -45,7 +45,7 @@ import {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow
|
TableRow
|
||||||
} from "@app/components/ui/table";
|
} from "@app/components/ui/table";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||||
import { ArrayElement } from "@server/types/ArrayElement";
|
import { ArrayElement } from "@server/types/ArrayElement";
|
||||||
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
||||||
|
@ -62,39 +62,11 @@ import {
|
||||||
SettingsSectionFooter
|
SettingsSectionFooter
|
||||||
} from "@app/components/Settings";
|
} from "@app/components/Settings";
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
import { useSiteContext } from "@app/hooks/useSiteContext";
|
import { useRouter } from "next/navigation";
|
||||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
import { isTargetValid } from "@server/lib/validators";
|
||||||
|
|
||||||
// Regular expressions for validation
|
|
||||||
const DOMAIN_REGEX =
|
|
||||||
/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
|
||||||
const IPV4_REGEX =
|
|
||||||
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
|
||||||
const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i;
|
|
||||||
|
|
||||||
// Schema for domain names and IP addresses
|
|
||||||
const domainSchema = z
|
|
||||||
.string()
|
|
||||||
.min(1, "Domain cannot be empty")
|
|
||||||
.max(255, "Domain name too long")
|
|
||||||
.refine(
|
|
||||||
(value) => {
|
|
||||||
// Check if it's a valid IP address (v4 or v6)
|
|
||||||
if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it's a valid domain name
|
|
||||||
return DOMAIN_REGEX.test(value);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: "Invalid domain name or IP address format",
|
|
||||||
path: ["domain"]
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const addTargetSchema = z.object({
|
const addTargetSchema = z.object({
|
||||||
ip: domainSchema,
|
ip: z.string().refine(isTargetValid),
|
||||||
method: z.string().nullable(),
|
method: z.string().nullable(),
|
||||||
port: z.coerce.number().int().positive()
|
port: z.coerce.number().int().positive()
|
||||||
// protocol: z.string(),
|
// protocol: z.string(),
|
||||||
|
@ -113,7 +85,6 @@ export default function ReverseProxyTargets(props: {
|
||||||
}) {
|
}) {
|
||||||
const params = use(props.params);
|
const params = use(props.params);
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { resource, updateResource } = useResourceContext();
|
const { resource, updateResource } = useResourceContext();
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
@ -126,6 +97,7 @@ export default function ReverseProxyTargets(props: {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const [pageLoading, setPageLoading] = useState(true);
|
const [pageLoading, setPageLoading] = useState(true);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const addTargetForm = useForm({
|
const addTargetForm = useForm({
|
||||||
resolver: zodResolver(addTargetSchema),
|
resolver: zodResolver(addTargetSchema),
|
||||||
|
@ -300,6 +272,7 @@ export default function ReverseProxyTargets(props: {
|
||||||
});
|
});
|
||||||
|
|
||||||
setTargetsToRemove([]);
|
setTargetsToRemove([]);
|
||||||
|
router.refresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
toast({
|
toast({
|
||||||
|
@ -340,6 +313,7 @@ export default function ReverseProxyTargets(props: {
|
||||||
title: "SSL Configuration",
|
title: "SSL Configuration",
|
||||||
description: "SSL configuration updated successfully"
|
description: "SSL configuration updated successfully"
|
||||||
});
|
});
|
||||||
|
router.refresh();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -447,6 +421,7 @@ export default function ReverseProxyTargets(props: {
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="http">http</SelectItem>
|
<SelectItem value="http">http</SelectItem>
|
||||||
<SelectItem value="https">https</SelectItem>
|
<SelectItem value="https">https</SelectItem>
|
||||||
|
<SelectItem value="h2c">h2c</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)
|
)
|
||||||
|
@ -543,6 +518,9 @@ export default function ReverseProxyTargets(props: {
|
||||||
<SelectItem value="https">
|
<SelectItem value="https">
|
||||||
https
|
https
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
<SelectItem value="h2c">
|
||||||
|
h2c
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
|
@ -34,7 +34,7 @@ import { AxiosResponse } from "axios";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { GetResourceAuthInfoResponse } from "@server/routers/resource";
|
import { GetResourceAuthInfoResponse } from "@server/routers/resource";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import {
|
import {
|
||||||
SettingsContainer,
|
SettingsContainer,
|
||||||
SettingsSection,
|
SettingsSection,
|
||||||
|
@ -49,9 +49,8 @@ import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||||
import CustomDomainInput from "../CustomDomainInput";
|
import CustomDomainInput from "../CustomDomainInput";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { subdomainSchema } from "@server/schemas/subdomainSchema";
|
import { subdomainSchema } from "@server/lib/schemas";
|
||||||
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
|
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
|
||||||
import { pullEnv } from "@app/lib/pullEnv";
|
|
||||||
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
|
||||||
import { Label } from "@app/components/ui/label";
|
import { Label } from "@app/components/ui/label";
|
||||||
|
|
||||||
|
@ -102,7 +101,6 @@ type TransferFormValues = z.infer<typeof TransferFormSchema>;
|
||||||
|
|
||||||
export default function GeneralForm() {
|
export default function GeneralForm() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const { toast } = useToast();
|
|
||||||
const { resource, updateResource } = useResourceContext();
|
const { resource, updateResource } = useResourceContext();
|
||||||
const { org } = useOrgContext();
|
const { org } = useOrgContext();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
|
@ -16,7 +16,6 @@ import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormDescription,
|
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
|
@ -40,7 +39,7 @@ import {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow
|
TableRow
|
||||||
} from "@app/components/ui/table";
|
} from "@app/components/ui/table";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||||
import { ArrayElement } from "@server/types/ArrayElement";
|
import { ArrayElement } from "@server/types/ArrayElement";
|
||||||
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
||||||
|
@ -58,7 +57,7 @@ import {
|
||||||
import { ListResourceRulesResponse } from "@server/routers/resource/listResourceRules";
|
import { ListResourceRulesResponse } from "@server/routers/resource/listResourceRules";
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||||
import { Check, Info, InfoIcon, X } from "lucide-react";
|
import { ArrowUpDown, Check, InfoIcon, X } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
InfoSection,
|
InfoSection,
|
||||||
InfoSections,
|
InfoSections,
|
||||||
|
@ -66,12 +65,20 @@ import {
|
||||||
} from "@app/components/InfoSection";
|
} from "@app/components/InfoSection";
|
||||||
import { Separator } from "@app/components/ui/separator";
|
import { Separator } from "@app/components/ui/separator";
|
||||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||||
|
import {
|
||||||
|
isValidCIDR,
|
||||||
|
isValidIP,
|
||||||
|
isValidUrlGlobPattern
|
||||||
|
} from "@server/lib/validators";
|
||||||
|
import { Switch } from "@app/components/ui/switch";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
// Schema for rule validation
|
// Schema for rule validation
|
||||||
const addRuleSchema = z.object({
|
const addRuleSchema = z.object({
|
||||||
action: z.string(),
|
action: z.string(),
|
||||||
match: z.string(),
|
match: z.string(),
|
||||||
value: z.string()
|
value: z.string(),
|
||||||
|
priority: z.coerce.number().int().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
type LocalRule = ArrayElement<ListResourceRulesResponse["rules"]> & {
|
type LocalRule = ArrayElement<ListResourceRulesResponse["rules"]> & {
|
||||||
|
@ -84,11 +91,16 @@ enum RuleAction {
|
||||||
DROP = "Always Deny"
|
DROP = "Always Deny"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum RuleMatch {
|
||||||
|
PATH = "Path",
|
||||||
|
IP = "IP",
|
||||||
|
CIDR = "IP Range",
|
||||||
|
}
|
||||||
|
|
||||||
export default function ResourceRules(props: {
|
export default function ResourceRules(props: {
|
||||||
params: Promise<{ resourceId: number }>;
|
params: Promise<{ resourceId: number }>;
|
||||||
}) {
|
}) {
|
||||||
const params = use(props.params);
|
const params = use(props.params);
|
||||||
const { toast } = useToast();
|
|
||||||
const { resource, updateResource } = useResourceContext();
|
const { resource, updateResource } = useResourceContext();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const [rules, setRules] = useState<LocalRule[]>([]);
|
const [rules, setRules] = useState<LocalRule[]>([]);
|
||||||
|
@ -96,6 +108,7 @@ export default function ResourceRules(props: {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [pageLoading, setPageLoading] = useState(true);
|
const [pageLoading, setPageLoading] = useState(true);
|
||||||
const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules);
|
const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const addRuleForm = useForm({
|
const addRuleForm = useForm({
|
||||||
resolver: zodResolver(addRuleSchema),
|
resolver: zodResolver(addRuleSchema),
|
||||||
|
@ -177,11 +190,23 @@ export default function ResourceRules(props: {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// find the highest priority and add one
|
||||||
|
let priority = data.priority;
|
||||||
|
if (priority === undefined) {
|
||||||
|
priority = rules.reduce(
|
||||||
|
(acc, rule) => (rule.priority > acc ? rule.priority : acc),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
priority++;
|
||||||
|
}
|
||||||
|
|
||||||
const newRule: LocalRule = {
|
const newRule: LocalRule = {
|
||||||
...data,
|
...data,
|
||||||
ruleId: new Date().getTime(),
|
ruleId: new Date().getTime(),
|
||||||
new: true,
|
new: true,
|
||||||
resourceId: resource.resourceId
|
resourceId: resource.resourceId,
|
||||||
|
priority,
|
||||||
|
enabled: true
|
||||||
};
|
};
|
||||||
|
|
||||||
setRules([...rules, newRule]);
|
setRules([...rules, newRule]);
|
||||||
|
@ -230,6 +255,18 @@ export default function ResourceRules(props: {
|
||||||
title: "Enable Rules",
|
title: "Enable Rules",
|
||||||
description: "Rule evaluation has been updated"
|
description: "Rule evaluation has been updated"
|
||||||
});
|
});
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getValueHelpText(type: string) {
|
||||||
|
switch (type) {
|
||||||
|
case "CIDR":
|
||||||
|
return "Enter an address in CIDR format (e.g., 103.21.244.0/22)";
|
||||||
|
case "IP":
|
||||||
|
return "Enter an IP address (e.g., 103.21.244.12)";
|
||||||
|
case "PATH":
|
||||||
|
return "Enter a URL path or pattern (e.g., /api/v1/todos or /api/v1/*)";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -240,7 +277,9 @@ export default function ResourceRules(props: {
|
||||||
const data = {
|
const data = {
|
||||||
action: rule.action,
|
action: rule.action,
|
||||||
match: rule.match,
|
match: rule.match,
|
||||||
value: rule.value
|
value: rule.value,
|
||||||
|
priority: rule.priority,
|
||||||
|
enabled: rule.enabled
|
||||||
};
|
};
|
||||||
|
|
||||||
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) {
|
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) {
|
||||||
|
@ -274,8 +313,33 @@ export default function ResourceRules(props: {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (rule.priority === undefined) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Invalid Priority",
|
||||||
|
description: "Please enter a valid priority"
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// make sure no duplicate priorities
|
||||||
|
const priorities = rules.map((r) => r.priority);
|
||||||
|
if (priorities.length !== new Set(priorities).size) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Duplicate Priorities",
|
||||||
|
description: "Please enter unique priorities"
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (rule.new) {
|
if (rule.new) {
|
||||||
const res = await api.put(`/resource/${params.resourceId}/rule`, data);
|
const res = await api.put(
|
||||||
|
`/resource/${params.resourceId}/rule`,
|
||||||
|
data
|
||||||
|
);
|
||||||
rule.ruleId = res.data.data.ruleId;
|
rule.ruleId = res.data.data.ruleId;
|
||||||
} else if (rule.updated) {
|
} else if (rule.updated) {
|
||||||
await api.post(
|
await api.post(
|
||||||
|
@ -300,9 +364,7 @@ export default function ResourceRules(props: {
|
||||||
await api.delete(
|
await api.delete(
|
||||||
`/resource/${params.resourceId}/rule/${ruleId}`
|
`/resource/${params.resourceId}/rule/${ruleId}`
|
||||||
);
|
);
|
||||||
setRules(
|
setRules(rules.filter((r) => r.ruleId !== ruleId));
|
||||||
rules.filter((r) => r.ruleId !== ruleId)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
|
@ -311,6 +373,7 @@ export default function ResourceRules(props: {
|
||||||
});
|
});
|
||||||
|
|
||||||
setRulesToRemove([]);
|
setRulesToRemove([]);
|
||||||
|
router.refresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
toast({
|
toast({
|
||||||
|
@ -326,6 +389,50 @@ export default function ResourceRules(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnDef<LocalRule>[] = [
|
const columns: ColumnDef<LocalRule>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "priority",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
column.toggleSorting(column.getIsSorted() === "asc")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Priority
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Input
|
||||||
|
defaultValue={row.original.priority}
|
||||||
|
className="w-[75px]"
|
||||||
|
type="number"
|
||||||
|
onBlur={(e) => {
|
||||||
|
const parsed = z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.optional()
|
||||||
|
.safeParse(e.target.value);
|
||||||
|
|
||||||
|
if (!parsed.data) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Invalid IP",
|
||||||
|
description: "Please enter a valid priority"
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRule(row.original.ruleId, {
|
||||||
|
priority: parsed.data
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "action",
|
accessorKey: "action",
|
||||||
header: "Action",
|
header: "Action",
|
||||||
|
@ -336,8 +443,8 @@ export default function ResourceRules(props: {
|
||||||
updateRule(row.original.ruleId, { action: value })
|
updateRule(row.original.ruleId, { action: value })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="min-w-[100px]">
|
<SelectTrigger className="min-w-[150px]">
|
||||||
{row.original.action}
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="ACCEPT">
|
<SelectItem value="ACCEPT">
|
||||||
|
@ -358,13 +465,13 @@ export default function ResourceRules(props: {
|
||||||
updateRule(row.original.ruleId, { match: value })
|
updateRule(row.original.ruleId, { match: value })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="min-w-[100px]">
|
<SelectTrigger className="min-w-[125px]">
|
||||||
{row.original.match}
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="IP">IP</SelectItem>
|
<SelectItem value="PATH">{RuleMatch.PATH}</SelectItem>
|
||||||
<SelectItem value="CIDR">IP Range</SelectItem>
|
<SelectItem value="IP">{RuleMatch.IP}</SelectItem>
|
||||||
<SelectItem value="PATH">PATH</SelectItem>
|
<SelectItem value="CIDR">{RuleMatch.CIDR}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)
|
)
|
||||||
|
@ -384,6 +491,18 @@ export default function ResourceRules(props: {
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "enabled",
|
||||||
|
header: "Enabled",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Switch
|
||||||
|
defaultChecked={row.original.enabled}
|
||||||
|
onCheckedChange={(val) =>
|
||||||
|
updateRule(row.original.ruleId, { enabled: val })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
|
@ -414,18 +533,18 @@ export default function ResourceRules(props: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsContainer>
|
<SettingsContainer>
|
||||||
<Alert>
|
<Alert className="hidden md:block">
|
||||||
<InfoIcon className="h-4 w-4" />
|
<InfoIcon className="h-4 w-4" />
|
||||||
<AlertTitle className="font-semibold">About Rules</AlertTitle>
|
<AlertTitle className="font-semibold">About Rules</AlertTitle>
|
||||||
<AlertDescription className="mt-4">
|
<AlertDescription className="mt-4">
|
||||||
<p className="mb-4">
|
<div className="space-y-1 mb-4">
|
||||||
Rules allow you to control access to your resource based
|
<p>
|
||||||
on a set of criteria. You can create rules to allow or
|
Rules allow you to control access to your resource
|
||||||
deny access based on IP address or URL path. Deny rules
|
based on a set of criteria. You can create rules to
|
||||||
take precedence over allow rules. If a request matches
|
allow or deny access based on IP address or URL
|
||||||
both an allow and a deny rule, the deny rule will be
|
path.
|
||||||
applied.
|
</p>
|
||||||
</p>
|
</div>
|
||||||
<InfoSections>
|
<InfoSections>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>Actions</InfoSectionTitle>
|
<InfoSectionTitle>Actions</InfoSectionTitle>
|
||||||
|
@ -475,7 +594,7 @@ export default function ResourceRules(props: {
|
||||||
<SwitchInput
|
<SwitchInput
|
||||||
id="rules-toggle"
|
id="rules-toggle"
|
||||||
label="Enable Rules"
|
label="Enable Rules"
|
||||||
defaultChecked={resource.applyRules}
|
defaultChecked={rulesEnabled}
|
||||||
onCheckedChange={async (val) => {
|
onCheckedChange={async (val) => {
|
||||||
await saveApplyRules(val);
|
await saveApplyRules(val);
|
||||||
}}
|
}}
|
||||||
|
@ -546,17 +665,17 @@ export default function ResourceRules(props: {
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="IP">
|
|
||||||
IP
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="CIDR">
|
|
||||||
IP Range
|
|
||||||
</SelectItem>
|
|
||||||
{resource.http && (
|
{resource.http && (
|
||||||
<SelectItem value="PATH">
|
<SelectItem value="PATH">
|
||||||
PATH
|
{RuleMatch.PATH}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
)}
|
)}
|
||||||
|
<SelectItem value="IP">
|
||||||
|
{RuleMatch.IP}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="CIDR">
|
||||||
|
{RuleMatch.CIDR}
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
@ -572,11 +691,11 @@ export default function ResourceRules(props: {
|
||||||
<InfoPopup
|
<InfoPopup
|
||||||
text="Value"
|
text="Value"
|
||||||
info={
|
info={
|
||||||
addRuleForm.watch(
|
getValueHelpText(
|
||||||
"match"
|
addRuleForm.watch(
|
||||||
) === "CIDR"
|
"match"
|
||||||
? "Enter an address in CIDR format (e.g., 103.21.244.0/22)"
|
)
|
||||||
: "Enter a URL path or pattern (e.g., /api/v1/todos or /api/v1/*)"
|
) || ""
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
|
@ -590,7 +709,7 @@ export default function ResourceRules(props: {
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={loading || !rulesEnabled}
|
disabled={!rulesEnabled}
|
||||||
>
|
>
|
||||||
Add Rule
|
Add Rule
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -645,6 +764,9 @@ export default function ResourceRules(props: {
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Rules are evaluated by priority in ascending order.
|
||||||
|
</p>
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
<SettingsSectionFooter>
|
<SettingsSectionFooter>
|
||||||
<Button
|
<Button
|
||||||
|
@ -659,72 +781,3 @@ export default function ResourceRules(props: {
|
||||||
</SettingsContainer>
|
</SettingsContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValidCIDR(cidr: string): boolean {
|
|
||||||
// Match CIDR pattern (e.g., "192.168.0.0/24")
|
|
||||||
const cidrPattern =
|
|
||||||
/^([0-9]{1,3}\.){3}[0-9]{1,3}\/([0-9]|[1-2][0-9]|3[0-2])$/;
|
|
||||||
|
|
||||||
if (!cidrPattern.test(cidr)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate IP address part
|
|
||||||
const ipPart = cidr.split("/")[0];
|
|
||||||
const octets = ipPart.split(".");
|
|
||||||
|
|
||||||
return octets.every((octet) => {
|
|
||||||
const num = parseInt(octet, 10);
|
|
||||||
return num >= 0 && num <= 255;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidIP(ip: string): boolean {
|
|
||||||
const ipPattern = /^([0-9]{1,3}\.){3}[0-9]{1,3}$/;
|
|
||||||
|
|
||||||
if (!ipPattern.test(ip)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const octets = ip.split(".");
|
|
||||||
|
|
||||||
return octets.every((octet) => {
|
|
||||||
const num = parseInt(octet, 10);
|
|
||||||
return num >= 0 && num <= 255;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidUrlGlobPattern(pattern: string): boolean {
|
|
||||||
// Remove leading slash if present
|
|
||||||
pattern = pattern.startsWith("/") ? pattern.slice(1) : pattern;
|
|
||||||
|
|
||||||
// Empty string is not valid
|
|
||||||
if (!pattern) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Split path into segments
|
|
||||||
const segments = pattern.split("/");
|
|
||||||
|
|
||||||
// Check each segment
|
|
||||||
for (let i = 0; i < segments.length; i++) {
|
|
||||||
const segment = segments[i];
|
|
||||||
|
|
||||||
// Empty segments are not allowed (double slashes)
|
|
||||||
if (!segment && i !== segments.length - 1) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If segment contains *, it must be exactly *
|
|
||||||
if (segment.includes("*") && segment !== "*") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for invalid characters
|
|
||||||
if (!/^[a-zA-Z0-9_*-]*$/.test(segment)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
|
@ -18,7 +18,7 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue
|
SelectValue
|
||||||
} from "@app/components/ui/select";
|
} from "@app/components/ui/select";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { InviteUserBody, InviteUserResponse } from "@server/routers/user";
|
import { InviteUserBody, InviteUserResponse } from "@server/routers/user";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
|
@ -94,7 +94,6 @@ export default function CreateShareLinkForm({
|
||||||
setOpen,
|
setOpen,
|
||||||
onCreated
|
onCreated
|
||||||
}: FormProps) {
|
}: FormProps) {
|
||||||
const { toast } = useToast();
|
|
||||||
const { org } = useOrgContext();
|
const { org } = useOrgContext();
|
||||||
|
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
|
|
|
@ -51,12 +51,14 @@ export function ShareLinksDataTable<TData, TValue>({
|
||||||
onColumnFiltersChange: setColumnFilters,
|
onColumnFiltersChange: setColumnFilters,
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
initialState: {
|
initialState: {
|
||||||
sorting,
|
|
||||||
columnFilters,
|
|
||||||
pagination: {
|
pagination: {
|
||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
pageIndex: 0
|
pageIndex: 0
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
columnFilters
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -25,7 +25,7 @@ import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
import { formatAxiosError } from "@app/lib/api";
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { ArrayElement } from "@server/types/ArrayElement";
|
import { ArrayElement } from "@server/types/ArrayElement";
|
||||||
|
@ -54,8 +54,6 @@ export default function ShareLinksTable({
|
||||||
}: ShareLinksTableProps) {
|
}: ShareLinksTableProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
|
|
@ -10,7 +10,7 @@ import {
|
||||||
FormMessage
|
FormMessage
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import { Input } from "@app/components/ui/input";
|
import { Input } from "@app/components/ui/input";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
@ -38,7 +38,16 @@ import { SiteRow } from "./SitesTable";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ArrowUpRight, SquareArrowOutUpRight } from "lucide-react";
|
import {
|
||||||
|
ArrowUpRight,
|
||||||
|
ChevronsUpDown,
|
||||||
|
SquareArrowOutUpRight
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger
|
||||||
|
} from "@app/components/ui/collapsible";
|
||||||
|
|
||||||
const createSiteFormSchema = z.object({
|
const createSiteFormSchema = z.object({
|
||||||
name: z
|
name: z
|
||||||
|
@ -72,14 +81,14 @@ export default function CreateSiteForm({
|
||||||
setChecked,
|
setChecked,
|
||||||
orgId
|
orgId
|
||||||
}: CreateSiteFormProps) {
|
}: CreateSiteFormProps) {
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isChecked, setIsChecked] = useState(false);
|
const [isChecked, setIsChecked] = useState(false);
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const [keypair, setKeypair] = useState<{
|
const [keypair, setKeypair] = useState<{
|
||||||
publicKey: string;
|
publicKey: string;
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
|
@ -184,10 +193,9 @@ export default function CreateSiteForm({
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await api
|
const res = await api
|
||||||
.put<AxiosResponse<CreateSiteResponse>>(
|
.put<
|
||||||
`/org/${orgId}/site/`,
|
AxiosResponse<CreateSiteResponse>
|
||||||
payload
|
>(`/org/${orgId}/site/`, payload)
|
||||||
)
|
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
|
@ -237,6 +245,18 @@ PersistentKeepalive = 5`
|
||||||
|
|
||||||
const newtConfig = `newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${env.app.dashboardUrl}`;
|
const newtConfig = `newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${env.app.dashboardUrl}`;
|
||||||
|
|
||||||
|
const newtConfigDockerCompose = `services:
|
||||||
|
newt:
|
||||||
|
image: fosrl/newt
|
||||||
|
container_name: newt
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- PANGOLIN_ENDPOINT=${env.app.dashboardUrl}
|
||||||
|
- NEWT_ID=${siteDefaults?.newtId}
|
||||||
|
- NEWT_SECRET=${siteDefaults?.newtSecret}`;
|
||||||
|
|
||||||
|
const newtConfigDockerRun = `docker run -it fosrl/newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${env.app.dashboardUrl}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
|
@ -307,32 +327,6 @@ PersistentKeepalive = 5`
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="w-full">
|
|
||||||
{form.watch("method") === "wireguard" && !isLoading ? (
|
|
||||||
<>
|
|
||||||
<CopyTextBox text={wgConfig} />
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
You will only be able to see the
|
|
||||||
configuration once.
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
) : form.watch("method") === "wireguard" &&
|
|
||||||
isLoading ? (
|
|
||||||
<p>Loading WireGuard configuration...</p>
|
|
||||||
) : form.watch("method") === "newt" ? (
|
|
||||||
<>
|
|
||||||
<CopyTextBox
|
|
||||||
text={newtConfig}
|
|
||||||
wrapText={false}
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
You will only be able to see the
|
|
||||||
configuration once.
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{form.watch("method") === "newt" && (
|
{form.watch("method") === "newt" && (
|
||||||
<Link
|
<Link
|
||||||
className="text-sm text-primary flex items-center gap-1"
|
className="text-sm text-primary flex items-center gap-1"
|
||||||
|
@ -348,6 +342,81 @@ PersistentKeepalive = 5`
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="w-full">
|
||||||
|
{form.watch("method") === "wireguard" && !isLoading ? (
|
||||||
|
<>
|
||||||
|
<CopyTextBox text={wgConfig} />
|
||||||
|
<span className="text-sm text-muted-foreground mt-2">
|
||||||
|
You will only be able to see the
|
||||||
|
configuration once.
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : form.watch("method") === "wireguard" &&
|
||||||
|
isLoading ? (
|
||||||
|
<p>Loading WireGuard configuration...</p>
|
||||||
|
) : form.watch("method") === "newt" ? (
|
||||||
|
<>
|
||||||
|
<div className="mb-2">
|
||||||
|
<Collapsible
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={setIsOpen}
|
||||||
|
className="space-y-2"
|
||||||
|
>
|
||||||
|
<div className="mx-auto">
|
||||||
|
<CopyTextBox
|
||||||
|
text={newtConfig}
|
||||||
|
wrapText={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between space-x-4">
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="sm"
|
||||||
|
className="p-0 flex items-center justify-between w-full"
|
||||||
|
>
|
||||||
|
<h4 className="text-sm font-semibold">
|
||||||
|
Expand for Docker Deployment
|
||||||
|
Details
|
||||||
|
</h4>
|
||||||
|
<div>
|
||||||
|
<ChevronsUpDown className="h-4 w-4" />
|
||||||
|
<span className="sr-only">
|
||||||
|
Toggle
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
</div>
|
||||||
|
<CollapsibleContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<b>Docker Compose</b>
|
||||||
|
<CopyTextBox
|
||||||
|
text={
|
||||||
|
newtConfigDockerCompose
|
||||||
|
}
|
||||||
|
wrapText={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<b>Docker Run</b>
|
||||||
|
|
||||||
|
<CopyTextBox
|
||||||
|
text={newtConfigDockerRun}
|
||||||
|
wrapText={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
You will only be able to see the
|
||||||
|
configuration once.
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
{form.watch("method") === "local" && (
|
{form.watch("method") === "local" && (
|
||||||
<Link
|
<Link
|
||||||
className="text-sm text-primary flex items-center gap-1"
|
className="text-sm text-primary flex items-center gap-1"
|
||||||
|
@ -355,10 +424,7 @@ PersistentKeepalive = 5`
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<span>
|
<span> Local sites do not tunnel, learn more</span>
|
||||||
{" "}
|
|
||||||
Local sites do not tunnel, learn more
|
|
||||||
</span>
|
|
||||||
<SquareArrowOutUpRight size={14} />
|
<SquareArrowOutUpRight size={14} />
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -9,7 +9,7 @@ import {
|
||||||
SortingState,
|
SortingState,
|
||||||
getSortedRowModel,
|
getSortedRowModel,
|
||||||
ColumnFiltersState,
|
ColumnFiltersState,
|
||||||
getFilteredRowModel,
|
getFilteredRowModel
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -19,7 +19,7 @@ import {
|
||||||
TableContainer,
|
TableContainer,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
@ -36,7 +36,7 @@ interface DataTableProps<TData, TValue> {
|
||||||
export function SitesDataTable<TData, TValue>({
|
export function SitesDataTable<TData, TValue>({
|
||||||
addSite,
|
addSite,
|
||||||
columns,
|
columns,
|
||||||
data,
|
data
|
||||||
}: DataTableProps<TData, TValue>) {
|
}: DataTableProps<TData, TValue>) {
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||||
|
@ -51,13 +51,15 @@ export function SitesDataTable<TData, TValue>({
|
||||||
onColumnFiltersChange: setColumnFilters,
|
onColumnFiltersChange: setColumnFilters,
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
initialState: {
|
initialState: {
|
||||||
sorting,
|
|
||||||
columnFilters,
|
|
||||||
pagination: {
|
pagination: {
|
||||||
pageSize: 100,
|
pageSize: 20,
|
||||||
pageIndex: 0,
|
pageIndex: 0
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
columnFilters
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -103,7 +105,7 @@ export function SitesDataTable<TData, TValue>({
|
||||||
: flexRender(
|
: flexRender(
|
||||||
header.column.columnDef
|
header.column.columnDef
|
||||||
.header,
|
.header,
|
||||||
header.getContext(),
|
header.getContext()
|
||||||
)}
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
);
|
);
|
||||||
|
@ -124,7 +126,7 @@ export function SitesDataTable<TData, TValue>({
|
||||||
<TableCell key={cell.id}>
|
<TableCell key={cell.id}>
|
||||||
{flexRender(
|
{flexRender(
|
||||||
cell.column.columnDef.cell,
|
cell.column.columnDef.cell,
|
||||||
cell.getContext(),
|
cell.getContext()
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -22,7 +22,7 @@ import { AxiosResponse } from "axios";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import CreateSiteForm from "./CreateSiteForm";
|
import CreateSiteForm from "./CreateSiteForm";
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { formatAxiosError } from "@app/lib/api";
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
@ -47,8 +47,6 @@ type SitesTableProps = {
|
||||||
export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
|
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
|
||||||
|
|
|
@ -15,7 +15,7 @@ import {
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { useSiteContext } from "@app/hooks/useSiteContext";
|
import { useSiteContext } from "@app/hooks/useSiteContext";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
SettingsContainer,
|
SettingsContainer,
|
||||||
|
@ -40,7 +40,6 @@ type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||||
|
|
||||||
export default function GeneralPage() {
|
export default function GeneralPage() {
|
||||||
const { site, updateSite } = useSiteContext();
|
const { site, updateSite } = useSiteContext();
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
|
|
|
@ -36,7 +36,7 @@ import {
|
||||||
} from "@server/routers/auth";
|
} from "@server/routers/auth";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { Alert, AlertDescription } from "../../../components/ui/alert";
|
import { Alert, AlertDescription } from "../../../components/ui/alert";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { formatAxiosError } from "@app/lib/api";;
|
import { formatAxiosError } from "@app/lib/api";;
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
|
@ -96,8 +96,6 @@ export default function ResetPasswordForm({
|
||||||
|
|
||||||
const [state, setState] = useState<"request" | "reset" | "mfa">(getState());
|
const [state, setState] = useState<"request" | "reset" | "mfa">(getState());
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
|
|
@ -48,7 +48,7 @@ import {
|
||||||
import ResourceAccessDenied from "./ResourceAccessDenied";
|
import ResourceAccessDenied from "./ResourceAccessDenied";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
const pinSchema = z.object({
|
const pinSchema = z.object({
|
||||||
|
@ -91,7 +91,6 @@ type ResourceAuthPortalProps = {
|
||||||
|
|
||||||
export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const getNumMethods = () => {
|
const getNumMethods = () => {
|
||||||
let colLength = 0;
|
let colLength = 0;
|
||||||
|
|
|
@ -31,7 +31,7 @@ import { AxiosResponse } from "axios";
|
||||||
import { VerifyEmailResponse } from "@server/routers/auth";
|
import { VerifyEmailResponse } from "@server/routers/auth";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { Alert, AlertDescription } from "../../../components/ui/alert";
|
import { Alert, AlertDescription } from "../../../components/ui/alert";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { formatAxiosError } from "@app/lib/api";;
|
import { formatAxiosError } from "@app/lib/api";;
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
|
@ -61,8 +61,6 @@ export default function VerifyEmailForm({
|
||||||
const [isResending, setIsResending] = useState(false);
|
const [isResending, setIsResending] = useState(false);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof FormSchema>>({
|
const form = useForm<z.infer<typeof FormSchema>>({
|
||||||
|
|
|
@ -28,7 +28,7 @@ import {
|
||||||
CredenzaHeader,
|
CredenzaHeader,
|
||||||
CredenzaTitle
|
CredenzaTitle
|
||||||
} from "@app/components/Credenza";
|
} from "@app/components/Credenza";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { formatAxiosError } from "@app/lib/api";;
|
import { formatAxiosError } from "@app/lib/api";;
|
||||||
import { useUserContext } from "@app/hooks/useUserContext";
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from "./ui/input-otp";
|
import { InputOTP, InputOTPGroup, InputOTPSlot } from "./ui/input-otp";
|
||||||
|
@ -50,8 +50,6 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
|
||||||
|
|
||||||
const [step, setStep] = useState<"password" | "success">("password");
|
const [step, setStep] = useState<"password" | "success">("password");
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const { user, updateUser } = useUserContext();
|
const { user, updateUser } = useUserContext();
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
|
@ -35,7 +35,7 @@ import {
|
||||||
CredenzaHeader,
|
CredenzaHeader,
|
||||||
CredenzaTitle
|
CredenzaTitle
|
||||||
} from "@app/components/Credenza";
|
} from "@app/components/Credenza";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { formatAxiosError } from "@app/lib/api";;
|
import { formatAxiosError } from "@app/lib/api";;
|
||||||
import CopyTextBox from "@app/components/CopyTextBox";
|
import CopyTextBox from "@app/components/CopyTextBox";
|
||||||
import { QRCodeCanvas, QRCodeSVG } from "qrcode.react";
|
import { QRCodeCanvas, QRCodeSVG } from "qrcode.react";
|
||||||
|
@ -64,8 +64,6 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const { user, updateUser } = useUserContext();
|
const { user, updateUser } = useUserContext();
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
|
@ -12,7 +12,7 @@ import {
|
||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from "@app/components/ui/dropdown-menu";
|
} from "@app/components/ui/dropdown-menu";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { formatAxiosError } from "@app/lib/api";;
|
import { formatAxiosError } from "@app/lib/api";;
|
||||||
import { Laptop, LogOut, Moon, Sun } from "lucide-react";
|
import { Laptop, LogOut, Moon, Sun } from "lucide-react";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
|
@ -23,7 +23,6 @@ import Disable2FaForm from "./Disable2FaForm";
|
||||||
import Enable2FaForm from "./Enable2FaForm";
|
import Enable2FaForm from "./Enable2FaForm";
|
||||||
|
|
||||||
export default function ProfileIcon() {
|
export default function ProfileIcon() {
|
||||||
const { toast } = useToast();
|
|
||||||
const { setTheme, theme } = useTheme();
|
const { setTheme, theme } = useTheme();
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
const api = createApiClient({ env });
|
const api = createApiClient({ env });
|
||||||
|
|
Loading…
Reference in a new issue