mirror of
https://github.com/fosrl/pangolin.git
synced 2025-06-30 09:04:48 +02:00
commit
b1702bf99a
63 changed files with 2024 additions and 268 deletions
3
Makefile
3
Makefile
|
@ -12,9 +12,6 @@ build-arm:
|
||||||
build-x86:
|
build-x86:
|
||||||
docker buildx build --platform linux/amd64 -t fosrl/pangolin:latest .
|
docker buildx build --platform linux/amd64 -t fosrl/pangolin:latest .
|
||||||
|
|
||||||
build-x86-ecr:
|
|
||||||
docker buildx build --platform linux/amd64 -t 216989133116.dkr.ecr.us-east-1.amazonaws.com/pangolin:latest --push .
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
docker build -t fosrl/pangolin:latest .
|
docker build -t fosrl/pangolin:latest .
|
||||||
|
|
||||||
|
|
60
README.md
60
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
|
<div align="center">
|
||||||
|
<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>
|
||||||
|
|
||||||
- [Installation Instructions](https://docs.fossorial.io/Getting%20Started/quick-install)
|
<h3 align="center">Tunneled Mesh Reverse Proxy Server with Access Control</h3>
|
||||||
- [Full Documentation](https://docs.fossorial.io)
|
<div align="center">
|
||||||
|
|
||||||
### Authors and Maintainers
|
_Your own self-hosted zero trust tunnel._
|
||||||
|
|
||||||
- [Milo Schwartz](https://github.com/miloschwartz)
|
</div>
|
||||||
- [Owen Schwartz](https://github.com/oschwartz10612)
|
|
||||||
|
|
||||||
## 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,23 @@ 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 Amazon EC2, DigitalOcean Droplet, or similar. There are many cheap VPS hosting options available to suit your needs.
|
||||||
|
|
||||||
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,17 +132,18 @@ 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
|
||||||
|
|
||||||
|
|
|
@ -41,3 +41,4 @@ flags:
|
||||||
disable_signup_without_invite: true
|
disable_signup_without_invite: true
|
||||||
disable_user_create_org: true
|
disable_user_create_org: true
|
||||||
allow_raw_resources: true
|
allow_raw_resources: true
|
||||||
|
allow_base_domain_resources: true
|
||||||
|
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
|
@ -54,3 +54,4 @@ flags:
|
||||||
disable_signup_without_invite: {{.DisableSignupWithoutInvite}}
|
disable_signup_without_invite: {{.DisableSignupWithoutInvite}}
|
||||||
disable_user_create_org: {{.DisableUserCreateOrg}}
|
disable_user_create_org: {{.DisableUserCreateOrg}}
|
||||||
allow_raw_resources: true
|
allow_raw_resources: true
|
||||||
|
allow_base_domain_resources: true
|
||||||
|
|
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 |
|
@ -51,13 +51,17 @@ export enum ActionsEnum {
|
||||||
// removeUserAction = "removeUserAction",
|
// removeUserAction = "removeUserAction",
|
||||||
// removeUserSite = "removeUserSite",
|
// removeUserSite = "removeUserSite",
|
||||||
getOrgUser = "getOrgUser",
|
getOrgUser = "getOrgUser",
|
||||||
"setResourcePassword" = "setResourcePassword",
|
setResourcePassword = "setResourcePassword",
|
||||||
"setResourcePincode" = "setResourcePincode",
|
setResourcePincode = "setResourcePincode",
|
||||||
"setResourceWhitelist" = "setResourceWhitelist",
|
setResourceWhitelist = "setResourceWhitelist",
|
||||||
"getResourceWhitelist" = "getResourceWhitelist",
|
getResourceWhitelist = "getResourceWhitelist",
|
||||||
"generateAccessToken" = "generateAccessToken",
|
generateAccessToken = "generateAccessToken",
|
||||||
"deleteAcessToken" = "deleteAcessToken",
|
deleteAcessToken = "deleteAcessToken",
|
||||||
"listAccessTokens" = "listAccessTokens"
|
listAccessTokens = "listAccessTokens",
|
||||||
|
createResourceRule = "createResourceRule",
|
||||||
|
deleteResourceRule = "deleteResourceRule",
|
||||||
|
listResourceRules = "listResourceRules",
|
||||||
|
updateResourceRule = "updateResourceRule",
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkUserActionPermission(
|
export async function checkUserActionPermission(
|
||||||
|
|
|
@ -3,8 +3,8 @@ import z from "zod";
|
||||||
export const passwordSchema = z
|
export const passwordSchema = z
|
||||||
.string()
|
.string()
|
||||||
.min(8, { message: "Password must be at least 8 characters long" })
|
.min(8, { message: "Password must be at least 8 characters long" })
|
||||||
.max(64, { message: "Password must be at most 64 characters long" })
|
.max(128, { message: "Password must be at most 128 characters long" })
|
||||||
.regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[,#?!@$%^&*-]).*$/, {
|
.regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[~!`@#$%^&*()_\-+={}[\]|\\:;"'<>,.\/?]).*$/, {
|
||||||
message: `Your password must meet the following conditions:
|
message: `Your password must meet the following conditions:
|
||||||
at least one uppercase English letter,
|
at least one uppercase English letter,
|
||||||
at least one lowercase English letter,
|
at least one lowercase English letter,
|
||||||
|
|
|
@ -95,6 +95,7 @@ export async function validateSessionToken(
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function invalidateSession(sessionId: string): Promise<void> {
|
export async function invalidateSession(sessionId: string): Promise<void> {
|
||||||
|
await db.delete(resourceSessions).where(eq(resourceSessions.userSessionId, sessionId));
|
||||||
await db.delete(sessions).where(eq(sessions.sessionId, sessionId));
|
await db.delete(sessions).where(eq(sessions.sessionId, sessionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -54,7 +54,8 @@ export const resources = sqliteTable("resources", {
|
||||||
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
|
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
isBaseDomain: integer("isBaseDomain", { mode: "boolean" })
|
isBaseDomain: integer("isBaseDomain", { mode: "boolean" }),
|
||||||
|
applyRules: integer("applyRules", { mode: "boolean" }).notNull().default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const targets = sqliteTable("targets", {
|
export const targets = sqliteTable("targets", {
|
||||||
|
@ -371,6 +372,18 @@ export const versionMigrations = sqliteTable("versionMigrations", {
|
||||||
executedAt: integer("executedAt").notNull()
|
executedAt: integer("executedAt").notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const resourceRules = sqliteTable("resourceRules", {
|
||||||
|
ruleId: integer("ruleId").primaryKey({ autoIncrement: true }),
|
||||||
|
resourceId: integer("resourceId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||||
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
|
priority: integer("priority").notNull(),
|
||||||
|
action: text("action").notNull(), // ACCEPT, DROP
|
||||||
|
match: text("match").notNull(), // CIDR, PATH, IP
|
||||||
|
value: text("value").notNull()
|
||||||
|
});
|
||||||
|
|
||||||
export type Org = InferSelectModel<typeof orgs>;
|
export type Org = InferSelectModel<typeof orgs>;
|
||||||
export type User = InferSelectModel<typeof users>;
|
export type User = InferSelectModel<typeof users>;
|
||||||
export type Site = InferSelectModel<typeof sites>;
|
export type Site = InferSelectModel<typeof sites>;
|
||||||
|
@ -403,3 +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>;
|
||||||
|
|
|
@ -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.12";
|
export const APP_VERSION = "1.0.0-beta.13";
|
||||||
|
|
||||||
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);
|
||||||
|
|
|
@ -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());
|
||||||
|
|
44
server/lib/validators.ts
Normal file
44
server/lib/validators.ts
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
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)
|
||||||
|
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;
|
||||||
|
}
|
|
@ -30,8 +30,13 @@ export async function logout(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
try {
|
try {
|
||||||
await invalidateSession(session.sessionId);
|
await invalidateSession(session.sessionId);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to invalidate session", error)
|
||||||
|
}
|
||||||
|
|
||||||
const isSecure = req.protocol === "https";
|
const isSecure = req.protocol === "https";
|
||||||
res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure));
|
res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure));
|
||||||
|
|
||||||
|
|
|
@ -8,10 +8,8 @@ import { db } from "@server/db";
|
||||||
import { passwordResetTokens, users } from "@server/db/schema";
|
import { passwordResetTokens, users } from "@server/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { alphabet, generateRandomString, sha256 } from "oslo/crypto";
|
import { alphabet, generateRandomString, sha256 } from "oslo/crypto";
|
||||||
import { encodeHex } from "oslo/encoding";
|
|
||||||
import { createDate } from "oslo";
|
import { createDate } from "oslo";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { generateIdFromEntropySize } from "@server/auth/sessions/app";
|
|
||||||
import { TimeSpan } from "oslo";
|
import { TimeSpan } from "oslo";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { sendEmail } from "@server/emails";
|
import { sendEmail } from "@server/emails";
|
||||||
|
@ -85,7 +83,9 @@ export async function requestPasswordReset(
|
||||||
const url = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${email}&token=${token}`;
|
const url = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${email}&token=${token}`;
|
||||||
|
|
||||||
if (!config.getRawConfig().email) {
|
if (!config.getRawConfig().email) {
|
||||||
logger.info(`Password reset requested for ${email}. Token: ${token}.`);
|
logger.info(
|
||||||
|
`Password reset requested for ${email}. Token: ${token}.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendEmail(
|
await sendEmail(
|
||||||
|
|
|
@ -1,33 +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 {
|
|
||||||
ResourceAccessToken,
|
|
||||||
ResourcePassword,
|
|
||||||
resourcePassword,
|
|
||||||
ResourcePincode,
|
|
||||||
resourcePincode,
|
|
||||||
resources,
|
|
||||||
sessions,
|
|
||||||
userOrgs,
|
|
||||||
users
|
|
||||||
} 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 db from "@server/db";
|
||||||
|
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 { 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 NodeCache from "node-cache";
|
||||||
import { generateSessionToken } from "@server/auth/sessions/app";
|
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({
|
||||||
|
@ -79,6 +84,7 @@ export async function verifyResourceSession(
|
||||||
host,
|
host,
|
||||||
originalRequestURL,
|
originalRequestURL,
|
||||||
requestIp,
|
requestIp,
|
||||||
|
path,
|
||||||
accessToken: token
|
accessToken: token
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
|
|
||||||
|
@ -146,18 +152,35 @@ export async function verifyResourceSession(
|
||||||
return allowed(res);
|
return allowed(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(resource.resourceId)}?redirect=${encodeURIComponent(originalRequestURL)}`;
|
// check the rules
|
||||||
|
if (resource.applyRules) {
|
||||||
|
const action = await checkRules(
|
||||||
|
resource.resourceId,
|
||||||
|
clientIp,
|
||||||
|
path
|
||||||
|
);
|
||||||
|
|
||||||
|
if (action == "ACCEPT") {
|
||||||
|
logger.debug("Resource allowed by rule");
|
||||||
|
return allowed(res);
|
||||||
|
} else if (action == "DROP") {
|
||||||
|
logger.debug("Resource denied by rule");
|
||||||
|
return notAllowed(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
// otherwise its undefined and we pass
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
@ -167,7 +190,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}.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -188,7 +213,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);
|
||||||
|
@ -196,7 +223,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) {
|
||||||
|
@ -219,7 +248,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);
|
||||||
|
@ -258,7 +289,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);
|
||||||
|
@ -282,8 +315,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,
|
||||||
|
@ -296,7 +329,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);
|
||||||
|
@ -438,3 +473,147 @@ async function isUserAllowedToAccessResource(
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function checkRules(
|
||||||
|
resourceId: number,
|
||||||
|
clientIp: string | undefined,
|
||||||
|
path: string | undefined
|
||||||
|
): Promise<"ACCEPT" | "DROP" | undefined> {
|
||||||
|
const ruleCacheKey = `rules:${resourceId}`;
|
||||||
|
|
||||||
|
let rules: ResourceRule[] | undefined = cache.get(ruleCacheKey);
|
||||||
|
|
||||||
|
if (!rules) {
|
||||||
|
rules = await db
|
||||||
|
.select()
|
||||||
|
.from(resourceRules)
|
||||||
|
.where(eq(resourceRules.resourceId, resourceId));
|
||||||
|
|
||||||
|
cache.set(ruleCacheKey, rules);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.length === 0) {
|
||||||
|
logger.debug("No rules found for resource", resourceId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// sort rules by priority in ascending order
|
||||||
|
rules = rules.sort((a, b) => a.priority - b.priority);
|
||||||
|
|
||||||
|
for (const rule of rules) {
|
||||||
|
if (!rule.enabled) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
clientIp &&
|
||||||
|
rule.match == "CIDR" &&
|
||||||
|
isIpInCidr(clientIp, rule.value)
|
||||||
|
) {
|
||||||
|
return rule.action as any;
|
||||||
|
} else if (clientIp && rule.match == "IP" && clientIp == rule.value) {
|
||||||
|
return rule.action as any;
|
||||||
|
} else if (
|
||||||
|
path &&
|
||||||
|
rule.match == "PATH" &&
|
||||||
|
isPathAllowed(rule.value, path)
|
||||||
|
) {
|
||||||
|
return rule.action as any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathAllowed(pattern: string, path: string): boolean {
|
||||||
|
logger.debug(`\nMatching path "${path}" against pattern "${pattern}"`);
|
||||||
|
|
||||||
|
// Normalize and split paths into segments
|
||||||
|
const normalize = (p: string) => p.split("/").filter(Boolean);
|
||||||
|
const patternParts = normalize(pattern);
|
||||||
|
const pathParts = normalize(path);
|
||||||
|
|
||||||
|
logger.debug(`Normalized pattern parts: [${patternParts.join(", ")}]`);
|
||||||
|
logger.debug(`Normalized path parts: [${pathParts.join(", ")}]`);
|
||||||
|
|
||||||
|
// Recursive function to try different wildcard matches
|
||||||
|
function matchSegments(patternIndex: number, pathIndex: number): boolean {
|
||||||
|
const indent = " ".repeat(pathIndex); // Indent based on recursion depth
|
||||||
|
const currentPatternPart = patternParts[patternIndex];
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
|
@ -27,6 +27,8 @@ import { verifyUserHasAction } from "../middlewares/verifyUserHasAction";
|
||||||
import { ActionsEnum } from "@server/auth/actions";
|
import { ActionsEnum } from "@server/auth/actions";
|
||||||
import { verifyUserIsOrgOwner } from "../middlewares/verifyUserIsOrgOwner";
|
import { verifyUserIsOrgOwner } from "../middlewares/verifyUserIsOrgOwner";
|
||||||
import { createNewt, getToken } from "./newt";
|
import { createNewt, getToken } from "./newt";
|
||||||
|
import rateLimit from "express-rate-limit";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
|
||||||
// Root routes
|
// Root routes
|
||||||
export const unauthenticated = Router();
|
export const unauthenticated = Router();
|
||||||
|
@ -184,6 +186,32 @@ authenticated.get(
|
||||||
verifyUserHasAction(ActionsEnum.listTargets),
|
verifyUserHasAction(ActionsEnum.listTargets),
|
||||||
target.listTargets
|
target.listTargets
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/resource/:resourceId/rule",
|
||||||
|
verifyResourceAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.createResourceRule),
|
||||||
|
resource.createResourceRule
|
||||||
|
);
|
||||||
|
authenticated.get(
|
||||||
|
"/resource/:resourceId/rules",
|
||||||
|
verifyResourceAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.listResourceRules),
|
||||||
|
resource.listResourceRules
|
||||||
|
);
|
||||||
|
authenticated.post(
|
||||||
|
"/resource/:resourceId/rule/:ruleId",
|
||||||
|
verifyResourceAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.updateResourceRule),
|
||||||
|
resource.updateResourceRule
|
||||||
|
);
|
||||||
|
authenticated.delete(
|
||||||
|
"/resource/:resourceId/rule/:ruleId",
|
||||||
|
verifyResourceAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.deleteResourceRule),
|
||||||
|
resource.deleteResourceRule
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/target/:targetId",
|
"/target/:targetId",
|
||||||
verifyTargetAccess,
|
verifyTargetAccess,
|
||||||
|
@ -203,6 +231,7 @@ authenticated.delete(
|
||||||
target.deleteTarget
|
target.deleteTarget
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/role",
|
"/org/:orgId/role",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
|
@ -452,22 +481,61 @@ authRouter.post(
|
||||||
);
|
);
|
||||||
authRouter.post("/2fa/disable", verifySessionUserMiddleware, auth.disable2fa);
|
authRouter.post("/2fa/disable", verifySessionUserMiddleware, auth.disable2fa);
|
||||||
authRouter.post("/verify-email", verifySessionMiddleware, auth.verifyEmail);
|
authRouter.post("/verify-email", verifySessionMiddleware, auth.verifyEmail);
|
||||||
|
|
||||||
authRouter.post(
|
authRouter.post(
|
||||||
"/verify-email/request",
|
"/verify-email/request",
|
||||||
verifySessionMiddleware,
|
verifySessionMiddleware,
|
||||||
|
rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 3,
|
||||||
|
keyGenerator: (req) => `requestEmailVerificationCode:${req.body.email}`,
|
||||||
|
handler: (req, res, next) => {
|
||||||
|
const message = `You can only request an email verification code ${3} times every ${15} minutes. Please try again later.`;
|
||||||
|
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
||||||
|
}
|
||||||
|
}),
|
||||||
auth.requestEmailVerificationCode
|
auth.requestEmailVerificationCode
|
||||||
);
|
);
|
||||||
|
|
||||||
// authRouter.post(
|
// authRouter.post(
|
||||||
// "/change-password",
|
// "/change-password",
|
||||||
// verifySessionUserMiddleware,
|
// verifySessionUserMiddleware,
|
||||||
// auth.changePassword
|
// auth.changePassword
|
||||||
// );
|
// );
|
||||||
authRouter.post("/reset-password/request", auth.requestPasswordReset);
|
|
||||||
|
authRouter.post(
|
||||||
|
"/reset-password/request",
|
||||||
|
rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 3,
|
||||||
|
keyGenerator: (req) => `requestPasswordReset:${req.body.email}`,
|
||||||
|
handler: (req, res, next) => {
|
||||||
|
const message = `You can only request a password reset ${3} times every ${15} minutes. Please try again later.`;
|
||||||
|
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
auth.requestPasswordReset
|
||||||
|
);
|
||||||
|
|
||||||
authRouter.post("/reset-password/", auth.resetPassword);
|
authRouter.post("/reset-password/", auth.resetPassword);
|
||||||
|
|
||||||
authRouter.post("/resource/:resourceId/password", resource.authWithPassword);
|
authRouter.post("/resource/:resourceId/password", resource.authWithPassword);
|
||||||
authRouter.post("/resource/:resourceId/pincode", resource.authWithPincode);
|
authRouter.post("/resource/:resourceId/pincode", resource.authWithPincode);
|
||||||
authRouter.post("/resource/:resourceId/whitelist", resource.authWithWhitelist);
|
|
||||||
|
authRouter.post(
|
||||||
|
"/resource/:resourceId/whitelist",
|
||||||
|
rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 10,
|
||||||
|
keyGenerator: (req) => `authWithWhitelist:${req.body.email}`,
|
||||||
|
handler: (req, res, next) => {
|
||||||
|
const message = `You can only request an email OTP ${10} times every ${15} minutes. Please try again later.`;
|
||||||
|
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
resource.authWithWhitelist
|
||||||
|
);
|
||||||
|
|
||||||
authRouter.post(
|
authRouter.post(
|
||||||
"/resource/:resourceId/access-token",
|
"/resource/:resourceId/access-token",
|
||||||
resource.authWithAccessToken
|
resource.authWithAccessToken
|
||||||
|
|
|
@ -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
|
||||||
|
|
145
server/routers/resource/createResourceRule.ts
Normal file
145
server/routers/resource/createResourceRule.ts
Normal file
|
@ -0,0 +1,145 @@
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { resourceRules, resources } from "@server/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import {
|
||||||
|
isValidCIDR,
|
||||||
|
isValidIP,
|
||||||
|
isValidUrlGlobPattern
|
||||||
|
} from "@server/lib/validators";
|
||||||
|
|
||||||
|
const createResourceRuleSchema = z
|
||||||
|
.object({
|
||||||
|
action: z.enum(["ACCEPT", "DROP"]),
|
||||||
|
match: z.enum(["CIDR", "IP", "PATH"]),
|
||||||
|
value: z.string().min(1),
|
||||||
|
priority: z.number().int(),
|
||||||
|
enabled: z.boolean().optional()
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const createResourceRuleParamsSchema = z
|
||||||
|
.object({
|
||||||
|
resourceId: z
|
||||||
|
.string()
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().positive())
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export async function createResourceRule(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedBody = createResourceRuleSchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { action, match, value, priority, enabled } = parsedBody.data;
|
||||||
|
|
||||||
|
const parsedParams = createResourceRuleParamsSchema.safeParse(
|
||||||
|
req.params
|
||||||
|
);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { resourceId } = parsedParams.data;
|
||||||
|
|
||||||
|
// Verify that the referenced resource exists
|
||||||
|
const [resource] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource with ID ${resourceId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
const [newRule] = await db
|
||||||
|
.insert(resourceRules)
|
||||||
|
.values({
|
||||||
|
resourceId,
|
||||||
|
action,
|
||||||
|
match,
|
||||||
|
value,
|
||||||
|
priority,
|
||||||
|
enabled
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: newRule,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Resource rule created successfully",
|
||||||
|
status: HttpCode.CREATED
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
71
server/routers/resource/deleteResourceRule.ts
Normal file
71
server/routers/resource/deleteResourceRule.ts
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { resourceRules, resources } from "@server/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
|
const deleteResourceRuleSchema = z
|
||||||
|
.object({
|
||||||
|
ruleId: z
|
||||||
|
.string()
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().positive()),
|
||||||
|
resourceId: z
|
||||||
|
.string()
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().positive())
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export async function deleteResourceRule(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = deleteResourceRuleSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ruleId } = parsedParams.data;
|
||||||
|
|
||||||
|
// Delete the rule and return the deleted record
|
||||||
|
const [deletedRule] = await db
|
||||||
|
.delete(resourceRules)
|
||||||
|
.where(eq(resourceRules.ruleId, ruleId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!deletedRule) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource rule with ID ${ruleId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: null,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Resource rule deleted successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -18,3 +18,7 @@ export * from "./authWithWhitelist";
|
||||||
export * from "./authWithAccessToken";
|
export * from "./authWithAccessToken";
|
||||||
export * from "./transferResource";
|
export * from "./transferResource";
|
||||||
export * from "./getExchangeToken";
|
export * from "./getExchangeToken";
|
||||||
|
export * from "./createResourceRule";
|
||||||
|
export * from "./deleteResourceRule";
|
||||||
|
export * from "./listResourceRules";
|
||||||
|
export * from "./updateResourceRule";
|
139
server/routers/resource/listResourceRules.ts
Normal file
139
server/routers/resource/listResourceRules.ts
Normal file
|
@ -0,0 +1,139 @@
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { resourceRules, resources } from "@server/db/schema";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import { eq, sql } from "drizzle-orm";
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
|
const listResourceRulesParamsSchema = z
|
||||||
|
.object({
|
||||||
|
resourceId: z
|
||||||
|
.string()
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().positive())
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const listResourceRulesSchema = z.object({
|
||||||
|
limit: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("1000")
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().positive()),
|
||||||
|
offset: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("0")
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().nonnegative())
|
||||||
|
});
|
||||||
|
|
||||||
|
function queryResourceRules(resourceId: number) {
|
||||||
|
let baseQuery = db
|
||||||
|
.select({
|
||||||
|
ruleId: resourceRules.ruleId,
|
||||||
|
resourceId: resourceRules.resourceId,
|
||||||
|
action: resourceRules.action,
|
||||||
|
match: resourceRules.match,
|
||||||
|
value: resourceRules.value,
|
||||||
|
priority: resourceRules.priority,
|
||||||
|
enabled: resourceRules.enabled
|
||||||
|
})
|
||||||
|
.from(resourceRules)
|
||||||
|
.leftJoin(resources, eq(resourceRules.resourceId, resources.resourceId))
|
||||||
|
.where(eq(resourceRules.resourceId, resourceId));
|
||||||
|
|
||||||
|
return baseQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListResourceRulesResponse = {
|
||||||
|
rules: Awaited<ReturnType<typeof queryResourceRules>>;
|
||||||
|
pagination: { total: number; limit: number; offset: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listResourceRules(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedQuery = listResourceRulesSchema.safeParse(req.query);
|
||||||
|
if (!parsedQuery.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedQuery.error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { limit, offset } = parsedQuery.data;
|
||||||
|
|
||||||
|
const parsedParams = listResourceRulesParamsSchema.safeParse(
|
||||||
|
req.params
|
||||||
|
);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { resourceId } = parsedParams.data;
|
||||||
|
|
||||||
|
// Verify the resource exists
|
||||||
|
const [resource] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource with ID ${resourceId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseQuery = queryResourceRules(resourceId);
|
||||||
|
|
||||||
|
let countQuery = db
|
||||||
|
.select({ count: sql<number>`cast(count(*) as integer)` })
|
||||||
|
.from(resourceRules)
|
||||||
|
.where(eq(resourceRules.resourceId, resourceId));
|
||||||
|
|
||||||
|
let rulesList = await baseQuery.limit(limit).offset(offset);
|
||||||
|
const totalCountResult = await countQuery;
|
||||||
|
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, {
|
||||||
|
data: {
|
||||||
|
rules: rulesList,
|
||||||
|
pagination: {
|
||||||
|
total: totalCount,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
}
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Resource rules retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
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({
|
||||||
|
@ -29,7 +29,8 @@ const updateResourceBodySchema = z
|
||||||
blockAccess: z.boolean().optional(),
|
blockAccess: z.boolean().optional(),
|
||||||
proxyPort: z.number().int().min(1).max(65535).optional(),
|
proxyPort: z.number().int().min(1).max(65535).optional(),
|
||||||
emailWhitelistEnabled: z.boolean().optional(),
|
emailWhitelistEnabled: z.boolean().optional(),
|
||||||
isBaseDomain: z.boolean().optional()
|
isBaseDomain: z.boolean().optional(),
|
||||||
|
applyRules: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.refine((data) => Object.keys(data).length > 0, {
|
.refine((data) => Object.keys(data).length > 0, {
|
||||||
|
@ -175,10 +176,10 @@ export async function updateResource(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let fullDomain = "";
|
let fullDomain: string | undefined;
|
||||||
if (updateData.isBaseDomain) {
|
if (updateData.isBaseDomain) {
|
||||||
fullDomain = org.domain;
|
fullDomain = org.domain;
|
||||||
} else {
|
} else if (updateData.subdomain) {
|
||||||
fullDomain = `${updateData.subdomain}.${org.domain}`;
|
fullDomain = `${updateData.subdomain}.${org.domain}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -187,6 +188,11 @@ export async function updateResource(
|
||||||
...(fullDomain && { fullDomain })
|
...(fullDomain && { fullDomain })
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
fullDomain &&
|
||||||
|
(updatePayload.subdomain !== undefined ||
|
||||||
|
updatePayload.isBaseDomain !== undefined)
|
||||||
|
) {
|
||||||
const [existingDomain] = await db
|
const [existingDomain] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(resources)
|
.from(resources)
|
||||||
|
@ -200,6 +206,7 @@ export async function updateResource(
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updatedResource = await db
|
const updatedResource = await db
|
||||||
.update(resources)
|
.update(resources)
|
||||||
|
|
179
server/routers/resource/updateResourceRule.ts
Normal file
179
server/routers/resource/updateResourceRule.ts
Normal file
|
@ -0,0 +1,179 @@
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { resourceRules, resources } from "@server/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import {
|
||||||
|
isValidCIDR,
|
||||||
|
isValidIP,
|
||||||
|
isValidUrlGlobPattern
|
||||||
|
} from "@server/lib/validators";
|
||||||
|
|
||||||
|
// Define Zod schema for request parameters validation
|
||||||
|
const updateResourceRuleParamsSchema = z
|
||||||
|
.object({
|
||||||
|
ruleId: z.string().transform(Number).pipe(z.number().int().positive()),
|
||||||
|
resourceId: z
|
||||||
|
.string()
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().positive())
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
// Define Zod schema for request body validation
|
||||||
|
const updateResourceRuleSchema = z
|
||||||
|
.object({
|
||||||
|
action: z.enum(["ACCEPT", "DROP"]).optional(),
|
||||||
|
match: z.enum(["CIDR", "IP", "PATH"]).optional(),
|
||||||
|
value: z.string().min(1).optional(),
|
||||||
|
priority: z.number().int(),
|
||||||
|
enabled: z.boolean().optional()
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.refine((data) => Object.keys(data).length > 0, {
|
||||||
|
message: "At least one field must be provided for update"
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function updateResourceRule(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
// Validate path parameters
|
||||||
|
const parsedParams = updateResourceRuleParamsSchema.safeParse(
|
||||||
|
req.params
|
||||||
|
);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate request body
|
||||||
|
const parsedBody = updateResourceRuleSchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ruleId, resourceId } = parsedParams.data;
|
||||||
|
const updateData = parsedBody.data;
|
||||||
|
|
||||||
|
// Verify that the resource exists
|
||||||
|
const [resource] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource with ID ${resourceId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
const [existingRule] = await db
|
||||||
|
.select()
|
||||||
|
.from(resourceRules)
|
||||||
|
.where(eq(resourceRules.ruleId, ruleId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existingRule) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource rule with ID ${ruleId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingRule.resourceId !== resourceId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
`Resource rule ${ruleId} does not belong to resource ${resourceId}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
const [updatedRule] = await db
|
||||||
|
.update(resourceRules)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq(resourceRules.ruleId, ruleId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: updatedRule,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Resource rule updated successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -149,8 +149,6 @@ export async function traefikConfigProvider(
|
||||||
: {})
|
: {})
|
||||||
};
|
};
|
||||||
|
|
||||||
logger.debug(config.getRawConfig().traefik.prefer_wildcard_cert)
|
|
||||||
|
|
||||||
const additionalMiddlewares =
|
const additionalMiddlewares =
|
||||||
config.getRawConfig().traefik.additional_middlewares || [];
|
config.getRawConfig().traefik.additional_middlewares || [];
|
||||||
|
|
||||||
|
|
|
@ -14,6 +14,7 @@ import m5 from "./scripts/1.0.0-beta6";
|
||||||
import m6 from "./scripts/1.0.0-beta9";
|
import m6 from "./scripts/1.0.0-beta9";
|
||||||
import m7 from "./scripts/1.0.0-beta10";
|
import m7 from "./scripts/1.0.0-beta10";
|
||||||
import m8 from "./scripts/1.0.0-beta12";
|
import m8 from "./scripts/1.0.0-beta12";
|
||||||
|
import m13 from "./scripts/1.0.0-beta13";
|
||||||
|
|
||||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||||
|
@ -27,7 +28,8 @@ const migrations = [
|
||||||
{ version: "1.0.0-beta.6", run: m5 },
|
{ version: "1.0.0-beta.6", run: m5 },
|
||||||
{ version: "1.0.0-beta.9", run: m6 },
|
{ version: "1.0.0-beta.9", run: m6 },
|
||||||
{ version: "1.0.0-beta.10", run: m7 },
|
{ version: "1.0.0-beta.10", run: m7 },
|
||||||
{ version: "1.0.0-beta.12", run: m8 }
|
{ version: "1.0.0-beta.12", run: m8 },
|
||||||
|
{ version: "1.0.0-beta.13", run: m13 }
|
||||||
// Add new migrations here as they are created
|
// Add new migrations here as they are created
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|
33
server/setup/scripts/1.0.0-beta13.ts
Normal file
33
server/setup/scripts/1.0.0-beta13.ts
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import db from "@server/db";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
const version = "1.0.0-beta.13";
|
||||||
|
|
||||||
|
export default async function migration() {
|
||||||
|
console.log(`Running setup script ${version}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.transaction((trx) => {
|
||||||
|
trx.run(sql`CREATE TABLE resourceRules (
|
||||||
|
ruleId integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
resourceId integer NOT NULL,
|
||||||
|
priority integer NOT NULL,
|
||||||
|
enabled integer DEFAULT true NOT NULL,
|
||||||
|
action text NOT NULL,
|
||||||
|
match text NOT NULL,
|
||||||
|
value text NOT NULL,
|
||||||
|
FOREIGN KEY (resourceId) REFERENCES resources(resourceId) ON UPDATE no action ON DELETE cascade
|
||||||
|
);`);
|
||||||
|
trx.run(
|
||||||
|
sql`ALTER TABLE resources ADD applyRules integer DEFAULT false NOT NULL;`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Added new table and column: resourceRules, applyRules`);
|
||||||
|
} catch (e) {
|
||||||
|
console.log("Unable to add new table and column: resourceRules, applyRules");
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>([]);
|
||||||
|
@ -49,14 +49,16 @@ export function RolesDataTable<TData, TValue>({
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
onColumnFiltersChange: setColumnFilters,
|
onColumnFiltersChange: setColumnFilters,
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
initialState: {
|
||||||
|
pagination: {
|
||||||
|
pageSize: 20,
|
||||||
|
pageIndex: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
state: {
|
state: {
|
||||||
sorting,
|
sorting,
|
||||||
columnFilters,
|
columnFilters
|
||||||
pagination: {
|
}
|
||||||
pageSize: 100,
|
|
||||||
pageIndex: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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>([]);
|
||||||
|
@ -49,14 +49,16 @@ export function UsersDataTable<TData, TValue>({
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
onColumnFiltersChange: setColumnFilters,
|
onColumnFiltersChange: setColumnFilters,
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
initialState: {
|
||||||
|
pagination: {
|
||||||
|
pageSize: 20,
|
||||||
|
pageIndex: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
state: {
|
state: {
|
||||||
sorting,
|
sorting,
|
||||||
columnFilters,
|
columnFilters
|
||||||
pagination: {
|
}
|
||||||
pageSize: 100,
|
|
||||||
pageIndex: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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>([]);
|
||||||
|
@ -50,14 +50,16 @@ export function ResourcesDataTable<TData, TValue>({
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
onColumnFiltersChange: setColumnFilters,
|
onColumnFiltersChange: setColumnFilters,
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
initialState: {
|
||||||
|
pagination: {
|
||||||
|
pageSize: 20,
|
||||||
|
pageIndex: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
state: {
|
state: {
|
||||||
sorting,
|
sorting,
|
||||||
columnFilters,
|
columnFilters
|
||||||
pagination: {
|
}
|
||||||
pageSize: 100,
|
|
||||||
pageIndex: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -42,7 +42,7 @@ export const ResourcesSplashCard = () => {
|
||||||
Resources
|
Resources
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
Resources are proxies to applications running on your private network. Create a resource for any HTTP or HTTPS app on your private network.
|
Resources are proxies to applications running on your private network. Create a resource for any HTTP/HTTPS or raw TCP/UDP service on your private network.
|
||||||
Each resource must be connected to a site to enable private, secure connectivity through an encrypted WireGuard tunnel.
|
Each resource must be connected to a site to enable private, secure connectivity through an encrypted WireGuard tunnel.
|
||||||
</p>
|
</p>
|
||||||
<ul className="text-sm text-muted-foreground space-y-2">
|
<ul className="text-sm text-muted-foreground space-y-2">
|
||||||
|
|
|
@ -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";
|
||||||
|
@ -75,7 +75,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();
|
||||||
|
@ -732,6 +731,9 @@ export default function ResourceAuthenticationPage() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Press enter to add an email after typing it in the input field.
|
||||||
|
</FormDescription>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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";
|
||||||
|
@ -113,7 +113,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());
|
||||||
|
@ -131,7 +130,7 @@ export default function ReverseProxyTargets(props: {
|
||||||
resolver: zodResolver(addTargetSchema),
|
resolver: zodResolver(addTargetSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
ip: "",
|
ip: "",
|
||||||
method: resource.http ? "http" : null,
|
method: resource.http ? "http" : null
|
||||||
// protocol: "TCP",
|
// protocol: "TCP",
|
||||||
} as z.infer<typeof addTargetSchema>
|
} as z.infer<typeof addTargetSchema>
|
||||||
});
|
});
|
||||||
|
@ -269,7 +268,7 @@ export default function ReverseProxyTargets(props: {
|
||||||
>(`/resource/${params.resourceId}/target`, data);
|
>(`/resource/${params.resourceId}/target`, data);
|
||||||
target.targetId = res.data.data.targetId;
|
target.targetId = res.data.data.targetId;
|
||||||
} else if (target.updated) {
|
} else if (target.updated) {
|
||||||
const res = await api.post(
|
await api.post(
|
||||||
`/target/${target.targetId}`,
|
`/target/${target.targetId}`,
|
||||||
data
|
data
|
||||||
);
|
);
|
||||||
|
@ -290,7 +289,7 @@ export default function ReverseProxyTargets(props: {
|
||||||
for (const targetId of targetsToRemove) {
|
for (const targetId of targetsToRemove) {
|
||||||
await api.delete(`/target/${targetId}`);
|
await api.delete(`/target/${targetId}`);
|
||||||
setTargets(
|
setTargets(
|
||||||
targets.filter((target) => target.targetId !== targetId)
|
targets.filter((t) => t.targetId !== targetId)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -316,10 +315,23 @@ export default function ReverseProxyTargets(props: {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSsl(val: boolean) {
|
async function saveSsl(val: boolean) {
|
||||||
const res = await api.post(`/resource/${params.resourceId}`, {
|
const res = await api
|
||||||
|
.post(`/resource/${params.resourceId}`, {
|
||||||
ssl: val
|
ssl: val
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Failed to update SSL configuration",
|
||||||
|
description: formatAxiosError(
|
||||||
|
err,
|
||||||
|
"An error occurred while updating the SSL configuration"
|
||||||
|
)
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (res && res.status === 200) {
|
||||||
setSslEnabled(val);
|
setSslEnabled(val);
|
||||||
updateResource({ ssl: val });
|
updateResource({ ssl: val });
|
||||||
|
|
||||||
|
@ -328,6 +340,7 @@ export default function ReverseProxyTargets(props: {
|
||||||
description: "SSL configuration updated successfully"
|
description: "SSL configuration updated successfully"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const columns: ColumnDef<LocalTarget>[] = [
|
const columns: ColumnDef<LocalTarget>[] = [
|
||||||
{
|
{
|
||||||
|
@ -652,7 +665,8 @@ export default function ReverseProxyTargets(props: {
|
||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Adding more than one target above will enable load balancing.
|
Adding more than one target above will enable load
|
||||||
|
balancing.
|
||||||
</p>
|
</p>
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
<SettingsSectionFooter>
|
<SettingsSectionFooter>
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -99,6 +99,11 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
||||||
href: `/{orgId}/settings/resources/{resourceId}/authentication`
|
href: `/{orgId}/settings/resources/{resourceId}/authentication`
|
||||||
// icon: <Shield className="w-4 h-4" />,
|
// icon: <Shield className="w-4 h-4" />,
|
||||||
});
|
});
|
||||||
|
sidebarNavItems.push({
|
||||||
|
title: "Rules",
|
||||||
|
href: `/{orgId}/settings/resources/{resourceId}/rules`
|
||||||
|
// icon: <Shield className="w-4 h-4" />,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
779
src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx
Normal file
779
src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx
Normal file
|
@ -0,0 +1,779 @@
|
||||||
|
"use client";
|
||||||
|
import { useEffect, useState, use } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@app/components/ui/form";
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
flexRender
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow
|
||||||
|
} from "@app/components/ui/table";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||||
|
import { ArrayElement } from "@server/types/ArrayElement";
|
||||||
|
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { createApiClient } from "@app/lib/api";
|
||||||
|
import {
|
||||||
|
SettingsContainer,
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionTitle,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionFooter
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import { ListResourceRulesResponse } from "@server/routers/resource/listResourceRules";
|
||||||
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||||
|
import { ArrowUpDown, Check, InfoIcon, X } from "lucide-react";
|
||||||
|
import {
|
||||||
|
InfoSection,
|
||||||
|
InfoSections,
|
||||||
|
InfoSectionTitle
|
||||||
|
} from "@app/components/InfoSection";
|
||||||
|
import { Separator } from "@app/components/ui/separator";
|
||||||
|
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||||
|
import {
|
||||||
|
isValidCIDR,
|
||||||
|
isValidIP,
|
||||||
|
isValidUrlGlobPattern
|
||||||
|
} from "@server/lib/validators";
|
||||||
|
import { Switch } from "@app/components/ui/switch";
|
||||||
|
|
||||||
|
// Schema for rule validation
|
||||||
|
const addRuleSchema = z.object({
|
||||||
|
action: z.string(),
|
||||||
|
match: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
priority: z.coerce.number().int().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
type LocalRule = ArrayElement<ListResourceRulesResponse["rules"]> & {
|
||||||
|
new?: boolean;
|
||||||
|
updated?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
enum RuleAction {
|
||||||
|
ACCEPT = "Always Allow",
|
||||||
|
DROP = "Always Deny"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RuleMatch {
|
||||||
|
IP = "IP",
|
||||||
|
CIDR = "IP Range",
|
||||||
|
PATH = "Path"
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ResourceRules(props: {
|
||||||
|
params: Promise<{ resourceId: number }>;
|
||||||
|
}) {
|
||||||
|
const params = use(props.params);
|
||||||
|
const { resource, updateResource } = useResourceContext();
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
const [rules, setRules] = useState<LocalRule[]>([]);
|
||||||
|
const [rulesToRemove, setRulesToRemove] = useState<number[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [pageLoading, setPageLoading] = useState(true);
|
||||||
|
const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules);
|
||||||
|
|
||||||
|
const addRuleForm = useForm({
|
||||||
|
resolver: zodResolver(addRuleSchema),
|
||||||
|
defaultValues: {
|
||||||
|
action: "ACCEPT",
|
||||||
|
match: "IP",
|
||||||
|
value: ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchRules = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get<
|
||||||
|
AxiosResponse<ListResourceRulesResponse>
|
||||||
|
>(`/resource/${params.resourceId}/rules`);
|
||||||
|
if (res.status === 200) {
|
||||||
|
setRules(res.data.data.rules);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Failed to fetch rules",
|
||||||
|
description: formatAxiosError(
|
||||||
|
err,
|
||||||
|
"An error occurred while fetching rules"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setPageLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchRules();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function addRule(data: z.infer<typeof addRuleSchema>) {
|
||||||
|
const isDuplicate = rules.some(
|
||||||
|
(rule) =>
|
||||||
|
rule.action === data.action &&
|
||||||
|
rule.match === data.match &&
|
||||||
|
rule.value === data.value
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isDuplicate) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Duplicate rule",
|
||||||
|
description: "A rule with these settings already exists"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.match === "CIDR" && !isValidCIDR(data.value)) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Invalid CIDR",
|
||||||
|
description: "Please enter a valid CIDR value"
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.match === "PATH" && !isValidUrlGlobPattern(data.value)) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Invalid URL path",
|
||||||
|
description: "Please enter a valid URL path value"
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.match === "IP" && !isValidIP(data.value)) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Invalid IP",
|
||||||
|
description: "Please enter a valid IP address"
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
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 = {
|
||||||
|
...data,
|
||||||
|
ruleId: new Date().getTime(),
|
||||||
|
new: true,
|
||||||
|
resourceId: resource.resourceId,
|
||||||
|
priority,
|
||||||
|
enabled: true
|
||||||
|
};
|
||||||
|
|
||||||
|
setRules([...rules, newRule]);
|
||||||
|
addRuleForm.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeRule = (ruleId: number) => {
|
||||||
|
setRules([...rules.filter((rule) => rule.ruleId !== ruleId)]);
|
||||||
|
if (!rules.find((rule) => rule.ruleId === ruleId)?.new) {
|
||||||
|
setRulesToRemove([...rulesToRemove, ruleId]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function updateRule(ruleId: number, data: Partial<LocalRule>) {
|
||||||
|
setRules(
|
||||||
|
rules.map((rule) =>
|
||||||
|
rule.ruleId === ruleId
|
||||||
|
? { ...rule, ...data, updated: true }
|
||||||
|
: rule
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveApplyRules(val: boolean) {
|
||||||
|
const res = await api
|
||||||
|
.post(`/resource/${params.resourceId}`, {
|
||||||
|
applyRules: val
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Failed to update rules",
|
||||||
|
description: formatAxiosError(
|
||||||
|
err,
|
||||||
|
"An error occurred while updating rules"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res && res.status === 200) {
|
||||||
|
setRulesEnabled(val);
|
||||||
|
updateResource({ applyRules: val });
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Enable Rules",
|
||||||
|
description: "Rule evaluation has been updated"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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/*)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRules() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
for (let rule of rules) {
|
||||||
|
const data = {
|
||||||
|
action: rule.action,
|
||||||
|
match: rule.match,
|
||||||
|
value: rule.value,
|
||||||
|
priority: rule.priority,
|
||||||
|
enabled: rule.enabled
|
||||||
|
};
|
||||||
|
|
||||||
|
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Invalid CIDR",
|
||||||
|
description: "Please enter a valid CIDR value"
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
rule.match === "PATH" &&
|
||||||
|
!isValidUrlGlobPattern(rule.value)
|
||||||
|
) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Invalid URL path",
|
||||||
|
description: "Please enter a valid URL path value"
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rule.match === "IP" && !isValidIP(rule.value)) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Invalid IP",
|
||||||
|
description: "Please enter a valid IP address"
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
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) {
|
||||||
|
const res = await api.put(
|
||||||
|
`/resource/${params.resourceId}/rule`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
rule.ruleId = res.data.data.ruleId;
|
||||||
|
} else if (rule.updated) {
|
||||||
|
await api.post(
|
||||||
|
`/resource/${params.resourceId}/rule/${rule.ruleId}`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setRules([
|
||||||
|
...rules.map((r) => {
|
||||||
|
let res = {
|
||||||
|
...r,
|
||||||
|
new: false,
|
||||||
|
updated: false
|
||||||
|
};
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ruleId of rulesToRemove) {
|
||||||
|
await api.delete(
|
||||||
|
`/resource/${params.resourceId}/rule/${ruleId}`
|
||||||
|
);
|
||||||
|
setRules(rules.filter((r) => r.ruleId !== ruleId));
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Rules updated",
|
||||||
|
description: "Rules updated successfully"
|
||||||
|
});
|
||||||
|
|
||||||
|
setRulesToRemove([]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Operation failed",
|
||||||
|
description: formatAxiosError(
|
||||||
|
err,
|
||||||
|
"An error occurred during the save operation"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
header: "Action",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Select
|
||||||
|
defaultValue={row.original.action}
|
||||||
|
onValueChange={(value: "ACCEPT" | "DROP") =>
|
||||||
|
updateRule(row.original.ruleId, { action: value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="min-w-[150px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ACCEPT">
|
||||||
|
{RuleAction.ACCEPT}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="DROP">{RuleAction.DROP}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "match",
|
||||||
|
header: "Match Type",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Select
|
||||||
|
defaultValue={row.original.match}
|
||||||
|
onValueChange={(value: "CIDR" | "IP" | "PATH") =>
|
||||||
|
updateRule(row.original.ruleId, { match: value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="min-w-[125px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="IP">{RuleMatch.IP}</SelectItem>
|
||||||
|
<SelectItem value="CIDR">{RuleMatch.CIDR}</SelectItem>
|
||||||
|
<SelectItem value="PATH">{RuleMatch.PATH}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "value",
|
||||||
|
header: "Value",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Input
|
||||||
|
defaultValue={row.original.value}
|
||||||
|
className="min-w-[200px]"
|
||||||
|
onBlur={(e) =>
|
||||||
|
updateRule(row.original.ruleId, {
|
||||||
|
value: e.target.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "enabled",
|
||||||
|
header: "Enabled",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Switch
|
||||||
|
defaultChecked={row.original.enabled}
|
||||||
|
onCheckedChange={(val) =>
|
||||||
|
updateRule(row.original.ruleId, { enabled: val })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center justify-end space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => removeRule(row.original.ruleId)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: rules,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pageLoading) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContainer>
|
||||||
|
<Alert className="hidden md:block">
|
||||||
|
<InfoIcon className="h-4 w-4" />
|
||||||
|
<AlertTitle className="font-semibold">About Rules</AlertTitle>
|
||||||
|
<AlertDescription className="mt-4">
|
||||||
|
<div className="space-y-1 mb-4">
|
||||||
|
<p>
|
||||||
|
Rules allow you to control access to your resource
|
||||||
|
based on a set of criteria. You can create rules to
|
||||||
|
allow or deny access based on IP address or URL
|
||||||
|
path.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<InfoSections>
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>Actions</InfoSectionTitle>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<Check className="text-green-500 w-4 h-4" />
|
||||||
|
Always Allow: Bypass all authentication
|
||||||
|
methods
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<X className="text-red-500 w-4 h-4" />
|
||||||
|
Always Deny: Block all requests; no
|
||||||
|
authentication can be attempted
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</InfoSection>
|
||||||
|
<Separator orientation="vertical" />
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>
|
||||||
|
Matching Criteria
|
||||||
|
</InfoSectionTitle>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
Match a specific IP address
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
Match a range of IP addresses in CIDR
|
||||||
|
notation
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
Match a URL path or pattern
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</InfoSection>
|
||||||
|
</InfoSections>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>Enable Rules</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
Enable or disable rule evaluation for this resource
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SwitchInput
|
||||||
|
id="rules-toggle"
|
||||||
|
label="Enable Rules"
|
||||||
|
defaultChecked={resource.applyRules}
|
||||||
|
onCheckedChange={async (val) => {
|
||||||
|
await saveApplyRules(val);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
Resource Rules Configuration
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
Configure rules to control access to your resource
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<Form {...addRuleForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={addRuleForm.handleSubmit(addRule)}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={addRuleForm.control}
|
||||||
|
name="action"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Action</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={
|
||||||
|
field.onChange
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ACCEPT">
|
||||||
|
{RuleAction.ACCEPT}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="DROP">
|
||||||
|
{RuleAction.DROP}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={addRuleForm.control}
|
||||||
|
name="match"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Match Type</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={
|
||||||
|
field.onChange
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="IP">
|
||||||
|
{RuleMatch.IP}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="CIDR">
|
||||||
|
{RuleMatch.CIDR}
|
||||||
|
</SelectItem>
|
||||||
|
{resource.http && (
|
||||||
|
<SelectItem value="PATH">
|
||||||
|
{RuleMatch.PATH}
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={addRuleForm.control}
|
||||||
|
name="value"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<InfoPopup
|
||||||
|
text="Value"
|
||||||
|
info={
|
||||||
|
getValueHelpText(
|
||||||
|
addRuleForm.watch(
|
||||||
|
"match"
|
||||||
|
)
|
||||||
|
) || ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!rulesEnabled}
|
||||||
|
>
|
||||||
|
Add Rule
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
<TableContainer>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column
|
||||||
|
.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
{row
|
||||||
|
.getVisibleCells()
|
||||||
|
.map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(
|
||||||
|
cell.column
|
||||||
|
.columnDef.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="h-24 text-center"
|
||||||
|
>
|
||||||
|
No rules. Add a rule using the form.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Rules are evaluated by priority in ascending order.
|
||||||
|
</p>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
<SettingsSectionFooter>
|
||||||
|
<Button
|
||||||
|
onClick={saveRules}
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Save Rules
|
||||||
|
</Button>
|
||||||
|
</SettingsSectionFooter>
|
||||||
|
</SettingsSection>
|
||||||
|
</SettingsContainer>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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();
|
||||||
|
|
|
@ -50,13 +50,15 @@ export function ShareLinksDataTable<TData, TValue>({
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
onColumnFiltersChange: setColumnFilters,
|
onColumnFiltersChange: setColumnFilters,
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
state: {
|
initialState: {
|
||||||
sorting,
|
|
||||||
columnFilters,
|
|
||||||
pagination: {
|
pagination: {
|
||||||
pageSize: 100,
|
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>([]);
|
||||||
|
@ -50,14 +50,16 @@ export function SitesDataTable<TData, TValue>({
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
onColumnFiltersChange: setColumnFilters,
|
onColumnFiltersChange: setColumnFilters,
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
initialState: {
|
||||||
|
pagination: {
|
||||||
|
pageSize: 20,
|
||||||
|
pageIndex: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
state: {
|
state: {
|
||||||
sorting,
|
sorting,
|
||||||
columnFilters,
|
columnFilters
|
||||||
pagination: {
|
}
|
||||||
pageSize: 100,
|
|
||||||
pageIndex: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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>>({
|
||||||
|
|
|
@ -25,7 +25,6 @@ export function DataTablePagination<TData>({
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between text-muted-foreground">
|
<div className="flex items-center justify-between text-muted-foreground">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<p className="text-sm font-medium">Rows per page</p>
|
|
||||||
<Select
|
<Select
|
||||||
value={`${table.getState().pagination.pageSize}`}
|
value={`${table.getState().pagination.pageSize}`}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
|
@ -38,7 +37,7 @@ export function DataTablePagination<TData>({
|
||||||
/>
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent side="top">
|
<SelectContent side="top">
|
||||||
{[10, 20, 30, 40, 50, 100, 200].map((pageSize) => (
|
{[10, 20, 30, 40, 50, 100].map((pageSize) => (
|
||||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||||
{pageSize}
|
{pageSize}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
|
@ -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());
|
||||||
|
|
|
@ -195,7 +195,7 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
|
||||||
Two-Factor Authentication
|
Two-Factor Authentication
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Enter the code from your authenticator app.
|
Enter the code from your authenticator app or one of your single-use backup codes.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Form {...mfaForm}>
|
<Form {...mfaForm}>
|
||||||
|
|
|
@ -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…
Add table
Add a link
Reference in a new issue